Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/consumers/rsr-oracle.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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/<target-path>/.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 <version>"`, 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.

Expand Down
7 changes: 4 additions & 3 deletions lib/mix/tasks/hypatia.rsr_score.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
34 changes: 31 additions & 3 deletions lib/rules/rsr_conformance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>\"`."
@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:

Expand All @@ -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.
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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 =
Expand Down
38 changes: 37 additions & 1 deletion test/rules/rsr_conformance_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?(
Expand Down Expand Up @@ -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
Expand Down
Loading