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 269ec9d5..c2644f02 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