From 89c0ab7908f867ab373418d5ed68771cfedc5a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:47:33 +0000 Subject: [PATCH 1/2] fix(rsr): restore the library half of #1117 lost in its merge; main compiles again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge commit 4d5538c8 ("Merge branch 'main' into rsr/criteria-accept-chora-deed") that #1117 was squashed from kept the new call `deed = deed_state(repo)` in descriptiles_parse/1 but dropped the definition of deed_state/1, the `deed == :broken -> :fail` branch, 3.1.1's descriptile_carrier/0 and the deed-first anchor read. So Hypatia.Rules.RsrConformance has not compiled on main since 0cc44552 (undefined function deed_state/1), and #1117's own tests expect the dropped behaviour. This applies the diff from the squash (0cc44552) to the branch's last commit before that merge (d0880bb5) for lib/rules/rsr_conformance.ex only: 3.1.1 reads a top-level descriptile clause of the chora deed, 3.1.8 reads the (anchor …) clause first, and 3.2.1 fails a chora deed that does not parse whatever .a2ml remains. With it, the 33 rsr_conformance tests pass and `mix compile --warnings-as-errors` is clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZoBy5xGnGDLk7wQ8jC --- lib/rules/rsr_conformance.ex | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/rules/rsr_conformance.ex b/lib/rules/rsr_conformance.ex index 23b93751..9986ee28 100644 --- a/lib/rules/rsr_conformance.ex +++ b/lib/rules/rsr_conformance.ex @@ -460,7 +460,7 @@ defmodule Hypatia.Rules.RsrConformance do ".well-known/humans.txt" ]), "2.3.1" => any_of(["0-AI-MANIFEST.a2ml", "0-AI-MANIFEST.deed"]), - "3.1.1" => present_mr("descriptiles"), + "3.1.1" => descriptile_carrier(), "3.1.2" => descriptile("STATE", "status"), "3.1.3" => descriptile("META", "meta"), "3.1.4" => descriptile("ECOSYSTEM", "ecosystem"), @@ -603,20 +603,21 @@ defmodule Hypatia.Rules.RsrConformance do legacy = any_of_mr(["descriptiles/ANCHOR.a2ml", "descriptiles/anchors/ANCHOR.a2ml"]) fn repo -> - case legacy.(repo) do - :pass -> :pass - _ -> if deed_clause?(repo, "anchor"), do: :pass, else: :fail - end + if deed_clause?(repo, "anchor"), do: :pass, else: legacy.(repo) end end - defp deed_parses?(repo) do + # The repo's chora deed: :absent (none at the root), :ok (it parses as a + # repo-deed), or :broken (it does not parse, is not a repo-deed, has an + # illegal name, or there is more than one). + defp deed_state(repo) do with {:ok, path} <- Hypatia.Deed.repo_deed_path(repo), {:ok, text} <- File.read(path), {:ok, {"repo-deed", _items}} <- Hypatia.Deed.parse(text) do - true + :ok else - _ -> false + :none -> :absent + _ -> :broken end end @@ -634,12 +635,15 @@ defmodule Hypatia.Rules.RsrConformance do # files (the 0-AI-MANIFEST manifest and friends, which open with `@directive` # / prose rather than a `[section]` header) are a DIFFERENT A2ML surface and # are not validated by the record-dialect reader — requiring them to parse as - # record dialect would false-fail every conformant repo. With no - # record-dialect file at all, the successor carrier decides: a repo whose - # descriptiles moved into its chora deed passes when that deed parses - # (Hypatia.Deed, the reader that agrees with the standards gate). Neither a - # record-dialect file nor a parseable deed is :fail — nothing to validate is - # not valid. + # record dialect would false-fail every conformant repo. + # + # The chora deed is read first, as the successor carrier: when the repo has + # one it must parse (Hypatia.Deed, the reader that agrees with the standards + # gate), whatever .a2ml remains, because a broken deed declares nothing and + # "all carriers valid" cannot pass over it. Then every record-dialect file + # left must parse too. A repo whose descriptiles all moved into its deed + # passes on the deed alone. Neither a record-dialect file nor a deed is + # :fail: nothing to validate is not valid. defp descriptiles_parse(repo) do candidates = Path.wildcard(Hypatia.Paths.machine_tree_join(repo, ["descriptiles", "*.a2ml"])) ++ @@ -652,8 +656,8 @@ defmodule Hypatia.Rules.RsrConformance do deed = deed_state(repo) cond do - record_files == [] -> - if deed_parses?(repo), do: :pass, else: :fail + deed == :broken -> + :fail record_files == [] -> if deed == :ok, do: :pass, else: :fail From 7427ce464fa7fbc4a6776e5a7255a4852c3d8a53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 19:48:10 +0000 Subject: [PATCH 2/2] feat(rsr): the scorecard deed carries :assessor and :assessed-at The owner ratified the RSR scorecard clause on 2026-11-14 with one change: it records who scored and when (standards clauses/rsr-scorecard.abnf v0.2.0, branch deed/rsr-scorecard-assessor). - RsrConformance.to_deed/2 writes :assessor, "hypatia rsr_score " with hypatia's version from mix.exs (fixed at compile time, as Hypatia.Verdict does; also assessor/0), and :assessed-at, the run time as an RFC 3339 UTC timestamp with whole seconds ("2026-11-14T19:33:12Z"), the one spelling the standards checker accepts. - New option :assessed_at injects a DateTime for a deterministic file; any offset is converted to UTC and the fraction dropped, without a time-zone database. - Tests: the writer test checks both fields; a new test pins an injected time, a fraction and a +05:30 offset. - docs/consumers/rsr-oracle.adoc and the mix task doc describe the fields, and that a rescore of an unchanged commit is no longer byte-identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0136P7ZoBy5xGnGDLk7wQ8jC --- docs/consumers/rsr-oracle.adoc | 12 ++++++--- lib/mix/tasks/hypatia.rsr_score.ex | 7 +++--- lib/rules/rsr_conformance.ex | 34 +++++++++++++++++++++++--- test/rules/rsr_conformance_test.exs | 38 ++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/docs/consumers/rsr-oracle.adoc b/docs/consumers/rsr-oracle.adoc index 1615ec84..289b7e59 100644 --- a/docs/consumers/rsr-oracle.adoc +++ b/docs/consumers/rsr-oracle.adoc @@ -61,9 +61,15 @@ it would be counted as your files — the oracle grading itself. The `path` of each is worth knowing if you reference the scorecard in a later step: it is written to `repo//.machine_readable/scorecards/rsr-scorecard_praxis.deed` and also printed to the job summary. It is the RSR scorecard deed (standards -`1-formats/deed/spec/abnf/clauses/rsr-scorecard.abnf`, DRAFT; owner ruling -2026-11-14), checked by `deed_lint.js --rsr-scorecard`, and it records the -scored commit as `:source-commit`. It replaced `rsr.scorecard.a2ml`: A2ML is +`1-formats/deed/spec/abnf/clauses/rsr-scorecard.abnf` v0.2.0; owner ruling +2026-11-14, ratified the same day), checked by `deed_lint.js --rsr-scorecard`. +It records the scored commit as `:source-commit`, the oracle that scored it as +`:assessor` (`"hypatia rsr_score "`, hypatia's version from +`mix.exs`), and when it ran as `:assessed-at`, an RFC 3339 UTC timestamp such +as `"2026-11-14T19:33:12Z"`. Because `:assessed-at` is the run time, a +rescore of an unchanged commit is not byte-identical to the last one: compare +`:source-commit` and the criteria, not the file, to tell whether anything +changed. It replaced `rsr.scorecard.a2ml`: A2ML is retired, so the oracle no longer writes that file, and one already in your repository is left for your own migration PR to remove. diff --git a/lib/mix/tasks/hypatia.rsr_score.ex b/lib/mix/tasks/hypatia.rsr_score.ex index ed54e005..a00efd92 100644 --- a/lib/mix/tasks/hypatia.rsr_score.ex +++ b/lib/mix/tasks/hypatia.rsr_score.ex @@ -17,9 +17,10 @@ defmodule Mix.Tasks.Hypatia.RsrScore do else the bundled test fixture) * `--standards P` root of a checked-out `hyperpolymath/standards` (for the SSOT) * `--write` write `REPO/.machine_readable/scorecards/rsr-scorecard_praxis.deed`, - the RSR scorecard deed (standards `clauses/rsr-scorecard.abnf`, DRAFT; owner - ruling 2026-11-14). It replaced `rsr.scorecard.a2ml`: A2ML is retired, so the - oracle no longer writes one, and leaves any already in the repo alone (its + the RSR scorecard deed (standards `clauses/rsr-scorecard.abnf` v0.2.0; owner + ruling 2026-11-14), stamped with `:assessor` and `:assessed-at` (UTC). It + replaced `rsr.scorecard.a2ml`: A2ML is retired, so the oracle no longer + writes one, and leaves any already in the repo alone (its removal is the repo's own migration PR) * `--source-commit SHA` record the scored commit (40 lowercase hex) in the scorecard; only with `--write` diff --git a/lib/rules/rsr_conformance.ex b/lib/rules/rsr_conformance.ex index 9986ee28..8f25b890 100644 --- a/lib/rules/rsr_conformance.ex +++ b/lib/rules/rsr_conformance.ex @@ -134,12 +134,25 @@ defmodule Hypatia.Rules.RsrConformance do }} end + # The assessor written into every scorecard: this tool and hypatia's version + # from mix.exs, fixed at compile time (as Hypatia.Verdict does). + @assessor "hypatia rsr_score #{Mix.Project.config()[:version]}" + + @doc "The `:assessor` `to_deed/2` writes: `\"hypatia rsr_score \"`." + @spec assessor() :: String.t() + def assessor, do: @assessor + @doc """ Serialize a scorecard as an RSR scorecard deed: the one `(rsr-scorecard …)` clause of a `praxis-deed` with `:profile rsr-scorecard` (standards - `1-formats/deed/spec/abnf/clauses/rsr-scorecard.abnf` v0.1.0, DRAFT; - owner ruling 2026-11-14). It replaces the A2ML `rsr.scorecard.a2ml`: A2ML is - retired, and the oracle no longer produces it. + `1-formats/deed/spec/abnf/clauses/rsr-scorecard.abnf` v0.2.0; owner ruling + 2026-11-14, ratified the same day with `:assessor` and `:assessed-at`). It + replaces the A2ML `rsr.scorecard.a2ml`: A2ML is retired, and the oracle no + longer produces it. + + Always written: `:assessor`, hypatia's name and version (`assessor/0`), and + `:assessed-at`, the run time as an RFC 3339 UTC timestamp with whole seconds + (`"2026-11-14T19:33:12Z"`), the one spelling the standards checker accepts. Options, all optional: @@ -148,6 +161,9 @@ defmodule Hypatia.Rules.RsrConformance do * `:beholding_chora` — the scored repo's estate chora, as a deed literal (`#uuid"…"` or `#u8"…"`), copied from its chora deed. * `:source_commit` — the scored commit, 40 lowercase hex characters. + * `:assessed_at` — the run time as a `DateTime` (default + `DateTime.utc_now/0`). Any offset is converted to UTC and the fraction + dropped; inject a fixed one for a deterministic file. Every criterion is written, `na` included, so a reader can recompute the score and coverage from the criteria, which the standards checker does. @@ -159,6 +175,7 @@ defmodule Hypatia.Rules.RsrConformance do repo = Keyword.get(opts, :repo) || sc.repo beholding = Keyword.get(opts, :beholding_chora) commit = Keyword.get(opts, :source_commit) + assessed_at = deed_timestamp(Keyword.get_lazy(opts, :assessed_at, &DateTime.utc_now/0)) header = [ ";; SPDX-License-Identifier: MPL-2.0\n", @@ -174,6 +191,8 @@ defmodule Hypatia.Rules.RsrConformance do " :repo #{deed_string(repo)}\n", " :spec rsr-criteria-v2\n", " :spec-version #{deed_string(sc.spec_version || "unknown")}\n", + " :assessor #{deed_string(@assessor)}\n", + " :assessed-at #{deed_string(assessed_at)}\n", " :tier #{sc.tier}\n", " :score #{deed_decimal(sc.score, 2)}\n", " :provisional #{deed_bool(sc.provisional)}\n", @@ -226,6 +245,15 @@ defmodule Hypatia.Rules.RsrConformance do end end + # sc-timestamp: RFC 3339 in UTC, whole seconds, "Z". Going through Unix + # seconds converts any offset without a time-zone database. + defp deed_timestamp(%DateTime{} = dt) do + dt + |> DateTime.to_unix() + |> DateTime.from_unix!() + |> DateTime.to_iso8601() + end + # A deed string: the four escapes the grammar allows, and nothing else. defp deed_string(s) do body = diff --git a/test/rules/rsr_conformance_test.exs b/test/rules/rsr_conformance_test.exs index 088659cc..393bae41 100644 --- a/test/rules/rsr_conformance_test.exs +++ b/test/rules/rsr_conformance_test.exs @@ -537,7 +537,7 @@ defmodule Hypatia.Rules.RsrConformanceTest do describe "scorecard serialization" do @describetag :tmp_dir - # The RSR scorecard deed (standards clauses/rsr-scorecard.abnf, DRAFT; owner + # The RSR scorecard deed (standards clauses/rsr-scorecard.abnf v0.2.0; owner # ruling 2026-11-14), read back with the deed reader that agrees with the # standards gate. defp card_of(text) do @@ -567,6 +567,14 @@ defmodule Hypatia.Rules.RsrConformanceTest do assert score =~ ~r/\A[0-9]+(\.[0-9]+)?\z/ refute Map.has_key?(card, "source-commit") + # who scored and when: hypatia's version from mix.exs, and the run time + assert card["assessor"] == + {:string, "hypatia rsr_score #{Mix.Project.config()[:version]}"} + + assert card["assessor"] == {:string, RsrConformance.assessor()} + assert {:string, at} = card["assessed-at"] + assert at =~ ~r/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\z/ + assert length(criteria) == length(sc.results) assert Enum.any?( @@ -602,6 +610,34 @@ defmodule Hypatia.Rules.RsrConformanceTest do assert card["source-commit"] == {:string, commit} end + test ":assessed-at takes an injected time, in UTC with whole seconds", %{tmp_dir: tmp} do + {:ok, sc} = RsrConformance.score(synthetic_catalogue(), tmp) + fixed = ~U[2026-11-14 19:33:12.654321Z] + text = RsrConformance.to_deed(sc, assessed_at: fixed) + {_, card, _} = card_of(text) + assert card["assessed-at"] == {:string, "2026-11-14T19:33:12Z"} + # deterministic: the same time gives the same file + assert RsrConformance.to_deed(sc, assessed_at: fixed) == text + + # an offset is converted to UTC, not copied + offset = %DateTime{ + year: 2026, + month: 11, + day: 15, + hour: 1, + minute: 3, + second: 12, + microsecond: {0, 0}, + time_zone: "Asia/Kolkata", + zone_abbr: "IST", + utc_offset: 19_800, + std_offset: 0 + } + + {_, card, _} = card_of(RsrConformance.to_deed(sc, assessed_at: offset)) + assert card["assessed-at"] == {:string, "2026-11-14T19:33:12Z"} + end + test "a repo with no chora deed gives no context", %{tmp_dir: tmp} do assert RsrConformance.deed_context(tmp) == [] end