From cf3e975f0f813f9335bee910ae5eb2a4b6fdf870 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 13:16:00 +0200 Subject: [PATCH 1/3] ci(rearm): re-arm approved PRs after a gate fix, only at the reviewed head (Refs #5793) The advisory-gate split landed in #5908 with re-arm left out. This adds the remainder of #5793: scripts/ci/rearm_ready.sh (anchored APPROVE by an allowlisted non-author, latest review wins, enqueue with expectedHeadOid, no plain-merge fallback), the rearm-ready workflow, the self-test wired into the advisory step and the andon job, and contract advisory-gate-diff-v1 auto_rearm / ADG-INV-005 / FALSIFY-ADG-008. PMAT-Ticket: PMAT-5793 PMAT-Batch: BATCH-aprender-3269 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/advisory-andon.yml | 1 + .github/workflows/ci.yml | 1 + .github/workflows/rearm-ready.yml | 29 ++++++++ ci/sections.yml | 1 + contracts/advisory-gate-diff-v1.yaml | 24 +++++- scripts/ci/advisory_step_selftest.sh | 2 +- scripts/ci/rearm_ready.sh | 107 +++++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/rearm-ready.yml create mode 100755 scripts/ci/rearm_ready.sh diff --git a/.github/workflows/advisory-andon.yml b/.github/workflows/advisory-andon.yml index 172a251d06..4b5479ee55 100644 --- a/.github/workflows/advisory-andon.yml +++ b/.github/workflows/advisory-andon.yml @@ -38,6 +38,7 @@ jobs: GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 bash scripts/ci/advisory_andon.sh --self-test + bash scripts/ci/rearm_ready.sh --self-test out="${RUNNER_TEMP:?}/andon.ndjson" rc=0 cargo deny --locked --all-features --format json check advisories >/dev/null 2>"$out" || rc=$? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 408fa9a820..90b49a1ebb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -411,6 +411,7 @@ jobs: bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh bash scripts/ci/advisory_positive_control.sh + bash scripts/ci/rearm_ready.sh --self-test - name: Every deny.toml exemption must still be live env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/rearm-ready.yml b/.github/workflows/rearm-ready.yml new file mode 100644 index 0000000000..3b4fd19469 --- /dev/null +++ b/.github/workflows/rearm-ready.yml @@ -0,0 +1,29 @@ +name: rearm-ready +# PMAT-5792 (ADG-INV-005): after main moves or on a timer, re-arm ready PRs whose head is the one that was approved. +# Never a PR or merge_group job. A PR whose head moved after approval is left alone (needs a fresh review). +on: + schedule: + - cron: '41 * * * *' + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read + pull-requests: write +concurrency: + group: rearm-ready + cancel-in-progress: false +jobs: + rearm: + runs-on: [self-hosted, Linux, X64, clean-room] + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Self-test, then re-arm approved un-armed PRs + env: + GH_TOKEN: ${{ github.token }} + REARM_REVIEWERS: ${{ vars.REARM_REVIEWERS }} + run: | + set -euo pipefail + bash scripts/ci/rearm_ready.sh --self-test + bash scripts/ci/rearm_ready.sh run diff --git a/ci/sections.yml b/ci/sections.yml index 538998bd19..3208c5e78d 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -2721,6 +2721,7 @@ jobs: bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh bash scripts/ci/advisory_positive_control.sh + bash scripts/ci/rearm_ready.sh --self-test # Separate from the check above on purpose: a newly-FIXED upstream must # never fail anyone's build. It fails only this guard, whose remedy is # deleting a line. diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index 87be729e14..43e296195a 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -47,6 +47,12 @@ equations: - "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so the one bypass through deny.toml is closed. This is NOT a claim that a PR cannot edit its own judge: the gate scripts, ci/sections.yml and cargo config (a .cargo alias can shadow cargo-deny) run from the PR tree, so a PR that edits them is judged by its own edit. Those paths are gate content and need the arbiter sign-off (non-author quorum); pinning the judge to main is out of this PR" - "deny.toml advisories ignores are id-wide: cargo-deny has no per-crate ignore. They stay bounded, not scoped: ignores-added fails any PR that adds one, and check_deny_exemptions_live.sh (now --all-features, same as the gate) fails a dead one. Per-crate scoping needs an upstream cargo-deny feature and is out of this PR" - "The base is pinned: merge_group uses merge_group.base_sha, pull_request uses pull_request.base.sha, anything else (or a non-sha) is COULD-NOT-CHECK rc 75, never a pass" + auto_rearm: + formula: "when a gate fix lands, every PR that was ready (green, approved) before the stop is re-armed without a human" + domain: "open PRs with no auto-merge armed whose latest review is an anchored APPROVE by an allowlisted reviewer other than the author" + codomain: "armed set restored" + invariants: + - "Re-arm never arms a PR whose head changed after its approval" proof_obligations: - id: ADG-INV-001 @@ -70,6 +76,12 @@ proof_obligations: formal: "∀ lock_48_0_6: A_record(lock) ∋ RUSTSEC-2026-0378 ∧ A_record(lock) ∋ RUSTSEC-2026-0379" applies_to: one_scanner_of_record + - id: ADG-INV-005 + type: invariant + property: "Ready PRs are re-armed after a gate fix" + formal: "∀ pr: head_unchanged ⇒ armed_after ≥ armed_before" + applies_to: auto_rearm + falsification_tests: - id: FALSIFY-ADG-001 rule: "Main-resident advisory does not block a PR" @@ -114,14 +126,22 @@ falsification_tests: test: "scripts/ci/advisory_step_selftest.sh, 4 cases + 1 mutant: the step's run block is extracted from ci.yml and run against stub scripts on a throwaway origin/main; gate rc 75 gives 75, gate rc 1 gives 1, a failing self-test gives its rc, all green gives 0" if_fails: "a red or could-not-check gate leaves the step green and a PR merges on an unjudged advisory" mutation: "delete the advisory_gate.sh invocation from the step; the mutant case must stay green on a failing gate, which the cases above forbid (measured 2026-10-20)" + - id: FALSIFY-ADG-008 + rule: "Re-arm never arms a changed head (ADG-INV-005)" + prediction: "a ready PR whose head sha moved after approval is not re-armed; one whose head is unchanged is" + test: "scripts/ci/rearm_ready.sh --self-test: select cases (head unchanged arms; moved or unapproved does not; empty arms nothing) and run against a stub gh, which enqueues exactly the PRs whose LATEST review is an anchored APPROVE by an allowlisted reviewer other than the author, with expectedHeadOid = the review's commit.oid; any later non-approval review cancels it; there is no gh pr merge fallback; .github/workflows/rearm-ready.yml calls rearm_ready.sh run" + if_fails: "a gate fix arms an unreviewed head into the queue" + mutation: "drop the approved_sha == current_sha test in select_, or the expectedHeadOid, anchor, allowlist, author exclusion or cancel; the matching case must turn RED" + qa_gate: id: F-ADG-001 name: Advisory gate diff contract - description: "PR delta gate, main andon, one scanner of record" + description: "PR delta gate, main andon, one scanner of record, auto re-arm" checks: - pr_gate_verdict - main_resident_advisory - one_scanner_of_record - pass_criteria: "All 7 falsification tests pass; the scanner script has a must-fail/must-pass case table" + - auto_rearm + pass_criteria: "All 8 falsification tests pass; the scanner script has a must-fail/must-pass case table" falsification: "Revert the gate to lock-vs-DB; FALSIFY-ADG-001 must turn RED" diff --git a/scripts/ci/advisory_step_selftest.sh b/scripts/ci/advisory_step_selftest.sh index e57b0b8219..d22326e964 100755 --- a/scripts/ci/advisory_step_selftest.sh +++ b/scripts/ci/advisory_step_selftest.sh @@ -24,7 +24,7 @@ extract() { # WORKFLOW -> the run block, de-indented make_repo() { # DIR -> repo with scripts/ci stubs committed on refs/remotes/origin/main local r=$1 f mkdir -p "$r/scripts/ci" - for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest advisory_positive_control check_sov_advisories_off; do + for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest advisory_positive_control check_sov_advisories_off rearm_ready; do printf '#!/usr/bin/env bash\nexit "$(cat "$(dirname "$0")/%s.rc" 2>/dev/null || echo 0)"\n' "$f" >"$r/scripts/ci/$f.sh" done git -C "$r" init -q diff --git a/scripts/ci/rearm_ready.sh b/scripts/ci/rearm_ready.sh new file mode 100755 index 0000000000..e886848a76 --- /dev/null +++ b/scripts/ci/rearm_ready.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# rearm_ready.sh -- which ready PRs a gate fix may re-arm (contract advisory-gate-diff-v1, ADG-INV-005). +# +# rearm_ready.sh select FILE FILE = TSV "pr approved_sha current_sha"; prints PRs to arm +# rearm_ready.sh run list open un-armed PRs (one gh call), select, arm each with plain `gh pr merge` +# rearm_ready.sh --self-test case table, plus `run` against a stub gh (RED when select_ is not called) +# A PR whose head moved after its approval is NOT re-armed (a changed head needs a fresh review). +set -euo pipefail + +select_() { + awk -F'\t' 'NF >= 3 && $1 != "" && $2 ~ /^[0-9a-f]{40}$/ && $2 == $3 { print $1 "\t" $3 "\t" $4 }' "$1" +} + +# stdin: `gh pr list --json id,number,author,headRefOid,autoMergeRequest,reviews`; stdout: TSV "pr approved_sha current_sha node_id" +# approved_sha = commit.oid of the latest anchored APPROVE review by an allowlisted reviewer (REARM_REVIEWERS, never the PR author); +# ANY later review that is not such an approval (negative or a plain comment) cancels it. +plan_() { + jq -r --arg ok "${REARM_REVIEWERS:-}" '($ok | split(" ") | map(select(. != ""))) as $allow + | .[] | select(.autoMergeRequest == null) | . as $p + | def neg: (.state == "CHANGES_REQUESTED") or (.body | test("HOLD|CHANGES-NEEDED|REQUEST_CHANGES")); + def tok: (.body | test("(^|\\n|: |- )APPROVE([^A-Za-z]|$)")); + def pos: (neg | not) and tok and (.author.login as $l | ($allow | index($l)) != null and $l != $p.author.login); + [.reviews | to_entries[]] as $r + | ([$r[] | select(.value | pos) | .key] | last) as $ai + | (if $ai == null or any($r[]; .key > $ai and (.value | pos | not)) then "" + else ($r[] | select(.key == $ai) | .value.commit.oid // "") end) as $sha + | [$p.number, $sha, $p.headRefOid, $p.id] | @tsv' +} + +# Arms atomically against the reviewed head: enqueuePullRequest(expectedHeadOid) is refused by GitHub if the head moved +# after the select. The queue owns the merge method, so none is passed. There is NO `gh pr merge` fallback: #5794 bans +# every flag on it (so no --match-head-commit), and a plain merge after a re-read of the head is a check-then-act race that +# could arm a head nobody reviewed. A refused enqueue (head moved, or checks still pending) is left for the next run. +run_() { + local t n sha id + t=$(mktemp) + # shellcheck disable=SC2064 + trap "rm -f -- '${t:?}'" RETURN + gh pr list --state open --limit 100 --json id,number,author,headRefOid,autoMergeRequest,reviews | plan_ >"$t" + while IFS=$'\t' read -r n sha id; do + if gh api graphql -f query='mutation($id:ID!,$h:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$h}){clientMutationId}}' \ + -f id="$id" -f h="$sha" >/dev/null 2>&1; then + continue + fi + echo "rearm: PR $n enqueue refused at the reviewed head; not armed" >&2 + done < <(select_ "$t") +} + +self_test() { + local d bad=0 a b + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + a=$(printf 'a%.0s' {1..40}); b=$(printf 'b%.0s' {1..40}) + printf '1\t%s\t%s\n2\t%s\t%s\n3\t\t%s\n' "$a" "$a" "$a" "$b" "$a" >"$d/t.tsv" + [ "$(select_ "$d/t.tsv" | cut -f1 | tr '\n' ' ')" = "1 " ] || { echo "SELF-TEST FAIL: only PR 1 (head unchanged) may be re-armed" >&2; bad=1; } + printf '' >"$d/e.tsv" + [ -z "$(select_ "$d/e.tsv")" ] || { echo "SELF-TEST FAIL: empty input arms nothing" >&2; bad=1; } + # run_ end to end with a stub gh. PRs: 1 approved+unchanged; 2 head moved; 3 already armed; + # 4 APPROVE then a later CHANGES-NEEDED; 5 APPROVE then a later HOLD; 6 CHANGES-NEEDED then a fresh APPROVE. + mkdir -p "$d/bin" + # rv LOGIN BODY OID -> one review object; pr N HEAD AUTOMERGE REVIEWS -> one PR object + rv() { printf '{"author":{"login":"%s"},"state":"COMMENTED","body":"%s","commit":{"oid":"%s"}}' "$1" "$2" "$3"; } + pr() { printf '{"id":"N%s","number":%s,"author":{"login":"au"},"headRefOid":"%s","autoMergeRequest":%s,"reviews":[%s]}' "$1" "$1" "$2" "$3" "$4"; } + { + printf '[' + pr 1 "$a" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 2 "$b" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 3 "$a" '{"x":1}' "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 4 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'seat 2: CHANGES-NEEDED' "$a")"; printf , + pr 5 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'HOLD' "$a")"; printf , + pr 6 "$a" null "$(rv s2 'CHANGES-NEEDED' "$a"),$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 7 "$a" null "$(rv s1 'seat 1: NOT APPROVED' "$a")"; printf , + pr 8 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'just a comment' "$a")"; printf , + pr 9 "$a" null "$(rv rando 'seat 1: APPROVE' "$a")"; printf , + pr 10 "$a" null "$(rv au 'seat 1: APPROVE' "$a")"; printf , + pr 11 "$a" null "$(rv s1 'seat 1: APPROVE' "$b")" + printf ']' + } >"$d/prs.json" + cat >"$d/bin/gh" <>"$d/armed"; [ -z "\${STUB_REFUSE:-}" ] +else echo "\$*" >>"$d/other"; exit 1 +fi +STUB + chmod +x "$d/bin/gh" + REARM_REVIEWERS="s1 s2 au" PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true + got=$(grep 'expectedHeadOid:\$h' "$d/armed" 2>/dev/null | grep -o 'id=N[0-9]* -f h=[0-9a-f]*' | tr '\n' ',') + # 1 ok; 2 head moved; 3 armed; 4/5 later negative; 6 fresh approve after negative -> arms; 7 "NOT APPROVED" is no approval; + # 8 any later non-approval cancels; 9 reviewer off the allowlist; 10 author self-approval; 11 commit.oid (b) != head (a), so not armed. + [ "$got" = "id=N1 -f h=$a,id=N6 -f h=$a," ] || { echo "SELF-TEST FAIL: run must enqueue exactly PR 1 and 6 with expectedHeadOid=commit.oid, got: $got" >&2; bad=1; } + rm -f "${d:?}/armed" "${d:?}/other" + REARM_REVIEWERS="s1 s2 au" STUB_REFUSE=1 PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true + if [ -e "$d/other" ]; then + echo "SELF-TEST FAIL: a refused enqueue must not fall back to any other gh call (the head pin is the mutation only)" >&2; bad=1 + fi + [ "$bad" -eq 0 ] && echo "rearm_ready self-test: 4 cases ok" + return "$bad" +} + +case "${1:-}" in + --self-test) self_test ;; + select) select_ "$2" ;; + run) run_ ;; + *) echo "usage: $0 select FILE | run | --self-test" >&2; exit 2 ;; +esac From 1d83a03bd82e3f98e2f1dc0c7f5f0116d8c132de Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 13:18:24 +0200 Subject: [PATCH 2/3] ci(roadmap): roadmap.yaml is generated from roadmap-base.yaml + entries/ (Refs #5901) Rename the tracked aggregate to roadmap-base.yaml, gitignore the generated roadmap.yaml, build it in CI via make roadmap-aggregate, and make the fragment readers (batch_fold, additive/fragment-required/completion-cited checks) follow the rename. Adds PMAT-5897 entry. Carries #5901 (Refs #5897). PMAT-Ticket: PMAT-5901 PMAT-Batch: BATCH-aprender-3269 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 7 + .gitignore | 3 + Makefile | 4 +- docs/roadmaps/entries/PMAT-5897.yaml | 22 +++ .../{roadmap.yaml => roadmap-base.yaml} | 0 scripts/batch_fold.sh | 2 +- scripts/check_roadmap_completion_is_cited.sh | 24 +++ scripts/check_roadmap_diff_additive.sh | 79 ++++++++- scripts/check_roadmap_fragment_required.sh | 156 +++++++++++++++++- scripts/ci/fat_driver.py | 8 + scripts/ci_test_tier.sh | 6 +- scripts/comparator_pin_citations.txt | 2 +- scripts/lib/roadmap_fragments.py | 64 ++++++- 13 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 docs/roadmaps/entries/PMAT-5897.yaml rename docs/roadmaps/{roadmap.yaml => roadmap-base.yaml} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90b49a1ebb..017d639b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,6 +286,13 @@ jobs: run: setsid --wait bash scripts/ci/install_pinned_toolchain.sh --self-test - name: install the toolchain rust-toolchain.toml pins run: setsid --wait bash scripts/ci/install_pinned_toolchain.sh + # docs/roadmaps/roadmap.yaml is generated (gitignored): roadmap-base.yaml plus + # entries/. sov.roadmap-valid reports NOT-RUN when the file is absent, so build + # it before any section reads it. Checks the generated file is idempotent too. + - name: generate docs/roadmaps/roadmap.yaml (base + entries) + run: | + make roadmap-aggregate + make roadmap-aggregate-check - name: Sections (the determinism raster returns first; the rest keep running) run: >- python3 scripts/ci/fat_driver.py run diff --git a/.gitignore b/.gitignore index b524a2aec6..d3d322005c 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ docs/audits/quorum-*.json.lanes/ # PVL-001 EV-8a (#4202): `pv discharge run`'s full log. The tracked summary is its sibling # crates/aprender-contracts-staging/discharge-summary.json, outside the tree it hashes. crates/aprender-contracts-staging/lean/discharge.json + +# GENERATED by `make roadmap-aggregate` from roadmap-base.yaml + entries/ (never committed) +docs/roadmaps/roadmap.yaml diff --git a/Makefile b/Makefile index 4058d06cd9..cb50f924df 100644 --- a/Makefile +++ b/Makefile @@ -595,10 +595,10 @@ readme-sync-check: ## Fail if README.md is not what the generator produces .PHONY: oracle-owl oracle-owl-check .PHONY: roadmap-aggregate roadmap-aggregate-check roadmap-aggregate: ## Regenerate docs/roadmaps/roadmap.yaml from docs/roadmaps/entries/ (#3296) - @python3 scripts/lib/roadmap_fragments.py aggregate --write + @python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --write roadmap-aggregate-check: ## Fail if roadmap.yaml is not what the aggregator produces, or if it is not idempotent - @python3 scripts/lib/roadmap_fragments.py aggregate --check + @python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --check .PHONY: ratchet-semantics-test ratchet-semantics-test: ## BSE-03: D2 ratchet polarity rows (--class readme) diff --git a/docs/roadmaps/entries/PMAT-5897.yaml b/docs/roadmaps/entries/PMAT-5897.yaml new file mode 100644 index 0000000000..4f10323a73 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5897.yaml @@ -0,0 +1,22 @@ +- id: PMAT-5897 + github_issue: 5897 + item_type: task + title: 'roadmap.yaml aggregate goes stale vs entries/: freeze roadmap-base.yaml, generate roadmap.yaml, do not commit it' + status: in_progress + priority: high + assigned_to: aprender-w5018 + created: 2026-10-21T21:30:00Z + updated: 2026-10-21T21:30:00Z + spec: null + acceptance_criteria: + - 'docs/roadmaps/roadmap.yaml is gitignored and produced by make roadmap-aggregate from roadmap-base.yaml plus entries/' + - 'check_roadmap_fragment_required.sh judges generated mode (G1 no re-added aggregate, G2 no base edit, G3 fragment-only change) and its self-test stays green' + - 'x86-main generates the aggregate before any section, so sov.roadmap-valid never reports NOT-RUN' + - 'readers of roadmap.yaml that cannot work on an untracked file are listed as follow-up tickets (pmat work add/complete shim, session_docs_commit.sh)' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: null diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap-base.yaml similarity index 100% rename from docs/roadmaps/roadmap.yaml rename to docs/roadmaps/roadmap-base.yaml diff --git a/scripts/batch_fold.sh b/scripts/batch_fold.sh index b69d8d0d7c..ce1ff67daf 100755 --- a/scripts/batch_fold.sh +++ b/scripts/batch_fold.sh @@ -140,7 +140,7 @@ regen() { # regenerate the generated set once, assert the fixed points, commit i | grep -vxE 'docs/roadmaps/roadmap\.yaml|contracts/contracts\.nt|contracts/shapes\.ttl|README\.md' || true) [ -z "$extra" ] || die "regen: wrote outside the generated set: $(printf '%s ' $extra)" if [ -n "$(git status --porcelain --untracked-files=no)" ]; then - git add -- docs/roadmaps/roadmap.yaml contracts/contracts.nt contracts/shapes.ttl README.md 2>/dev/null + git add -- $(git ls-files --error-unmatch docs/roadmaps/roadmap.yaml 2>/dev/null) contracts/contracts.nt contracts/shapes.ttl README.md 2>/dev/null git -c commit.gpgsign=false commit -q -m "batch: regenerate the generated set once (batch_fold.sh --regen) roadmap aggregate, pv extract (contracts.nt + shapes.ttl) and the diff --git a/scripts/check_roadmap_completion_is_cited.sh b/scripts/check_roadmap_completion_is_cited.sh index 0e18532db0..5ec3010b2f 100755 --- a/scripts/check_roadmap_completion_is_cited.sh +++ b/scripts/check_roadmap_completion_is_cited.sh @@ -248,6 +248,11 @@ def is_candidate(rel): d, fn = os.path.split(rel) if not fn.lower().endswith(".yaml"): return False + # roadmap-base.yaml is the frozen half of the GENERATED roadmap.yaml (#5897): + # the aggregate is scanned and baselined, so scanning the base too would + # report every entry a second time under a path the baseline is not keyed on. + if rel.replace(os.sep, "/") == "docs/roadmaps/roadmap-base.yaml": + return False if re.fullmatch(r"(?i)roadmap.*\.yaml", fn): return True return d.replace(os.sep, "/").endswith("docs/roadmaps") @@ -277,6 +282,12 @@ except Exception: pass in_scope, out_scope, unparsed = [], [], [] +# The base is not scanned itself; without the generated aggregate nothing of it +# would be, which is a silent pass. Make that loud. +if (os.path.isfile(os.path.join(root, "docs/roadmaps/roadmap-base.yaml")) + and "docs/roadmaps/roadmap.yaml" not in cand): + unparsed.append(("docs/roadmaps/roadmap.yaml", + "generated aggregate absent -- run `make roadmap-aggregate`")) for rel in sorted(cand): p = os.path.join(root, rel) if not os.path.isfile(p): @@ -623,6 +634,19 @@ YAML n_lane=$(grep -c 'lane-deadbeef' "$TD/scan3.out" "$TD/scan3.err" 2>/dev/null | awk -F: '{t+=$2} END {print t+0}') row 'lane scratch contributes no records' 0 "$n_lane" + # GENERATED mode (#5897): roadmap-base.yaml is the frozen half of the generated + # roadmap.yaml and is never scanned as a second copy of its entries. + mk docs/roadmaps/roadmap-base.yaml <<'YAML' +roadmap: +- id: R-BASE-HALF + status: completed + notes: null +YAML + scan_roadmaps "$TD" >"$TD/scan4.out" 2>"$TD/scan4.err" || true + n_base=$(grep -c 'roadmap-base.yaml' "$TD/scan4.out" "$TD/scan4.err" 2>/dev/null | awk -F: '{t+=$2} END {print t+0}') + row 'roadmap-base.yaml contributes no records (the aggregate is the unit)' 0 "$n_base" + rm -f "${TD:?}/docs/roadmaps/roadmap-base.yaml" + n_out=$(grep -c '^SCOPE out crates/other/roadmap.yaml' "$TD/scan.err" || true) row 'foreign schema is reported out of scope, not skipped' 1 "$n_out" n_rec=$(grep -c 'crates/other/roadmap.yaml' <<< "$SCAN" || true) diff --git a/scripts/check_roadmap_diff_additive.sh b/scripts/check_roadmap_diff_additive.sh index 07b1614084..661e98a6ae 100755 --- a/scripts/check_roadmap_diff_additive.sh +++ b/scripts/check_roadmap_diff_additive.sh @@ -53,6 +53,9 @@ REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" PY_LIB="${REPO_ROOT}/scripts/lib/roadmap_diff.py" PY_TRIM="${REPO_ROOT}/scripts/roadmap_trim.py" ROADMAP_FILE="docs/roadmaps/roadmap.yaml" +ENTRIES_DIR="docs/roadmaps/entries" +PY_AGG="${REPO_ROOT}/scripts/lib/roadmap_fragments.py" +BASE_FILE="docs/roadmaps/roadmap-base.yaml" # present = roadmap.yaml is GENERATED, not committed # --------------------------------------------------------------------------- # run_check [] -> roadmap_diff.py's stdout+exit code. @@ -92,7 +95,7 @@ if [ "${1:-}" = "--self-test" ]; then not_measured=0 # The case table has exactly this many rows; a run that reaches fewer, or more, is RED, never "n/n" # (#5047). Raise it with every row added. The two summary rows at the end are not counted. - SELFTEST_MIN_ROWS=27 + SELFTEST_MIN_ROWS=29 # selftest_verdict ROWS FAILS NOT_MEASURED MIN -> prints the summary; rc 0 all rows ran and # passed, 1 a row failed or other than MIN ran, 2 every row that ran passed but one was not measured. @@ -551,10 +554,37 @@ EOF *) printf 'FAIL row %-2s --staged: wanted rc=0 with base=%s, got rc=%s\n%s\n' "$row" "$tip" "$rc" "$out" | sed 's|^| |'; fails=1 ;; esac + # Rows 28-29: GENERATED mode (roadmap.yaml untracked, roadmap-base.yaml + entries/). + # The aggregates at both refs are judged, so a fragment that rewrites a base + # entry's title is refused and a fragment that adds a ticket passes. + GR="$TD/gen"; mkdir -p "$GR/scripts/lib" "$GR/docs/roadmaps/entries" + cp "$REPO_ROOT/scripts/check_roadmap_diff_additive.sh" "$GR/scripts/" + cp "$REPO_ROOT"/scripts/lib/roadmap_*.py "$REPO_ROOT/scripts/lib/resolve_base.sh" "$GR/scripts/lib/" + ( cd "$GR" && git init -q -b main . && git config user.email t@t && git config user.name t \ + && git config core.hooksPath /dev/null \ + && cp "$TD/base.yaml" docs/roadmaps/roadmap-base.yaml && git add -A && git commit -qm base \ + && git update-ref refs/remotes/origin/main "$(git rev-parse main)" ) + gen_run() { ( cd "$GR" && git add -A && git commit -qm c && bash scripts/check_roadmap_diff_additive.sh HEAD~1 HEAD 2>&1 ); } + row=$((row + 1)) + printf -- "- id: A-1\n title: 'REWRITTEN'\n status: planned\n notes: null\n" >"$GR/docs/roadmaps/entries/A-1.yaml" + out=$(gen_run); rc=$? + case "$rc" in + 1) printf 'ok row %-2s generated: a fragment rewriting a base entry title is REFUSED (rc=1)\n' "$row" ;; + *) printf 'FAIL row %-2s generated: wanted rc=1 for a title rewrite, got rc=%s\n%s\n' "$row" "$rc" "$out" | sed 's|^| |'; fails=1 ;; + esac + row=$((row + 1)) + ( cd "$GR" && git reset -q --hard HEAD~1 ) && mkdir -p "$GR/docs/roadmaps/entries" + printf -- "- id: A-9\n title: 'brand new'\n status: planned\n notes: null\n" >"$GR/docs/roadmaps/entries/A-9.yaml" + out=$(gen_run); rc=$? + case "$rc:$out" in + 0:*added=1*) printf 'ok row %-2s generated: a fragment adding a ticket PASSes (added=1)\n' "$row" ;; + *) printf 'FAIL row %-2s generated: wanted rc=0 added=1, got rc=%s\n%s\n' "$row" "$rc" "$out" | sed 's|^| |'; fails=1 ;; + esac + # The summary's own rows (#5047). A nested run, which the second row starts, skips both. if [ -z "${ROADMAP_DIFF_SELFTEST_NESTED:-}" ]; then got="" - for c in "27 0 0 27:0" "26 0 0 27:1" "27 0 1 27:2" "27 1 1 27:1" "28 0 0 27:1"; do + for c in "29 0 0 29:0" "28 0 0 29:1" "29 0 1 29:2" "29 1 1 29:1" "30 0 0 29:1"; do # shellcheck disable=SC2086 # the four numbers are meant to split rc=0; selftest_verdict ${c%%:*} >/dev/null || rc=$? got="$got${got:+ }$rc" @@ -608,6 +638,10 @@ if [ "${1:-}" = "--staged" ]; then exit 0 fi if ! git -C "$REPO_ROOT" ls-files --cached --error-unmatch -- "$ROADMAP_FILE" >/dev/null 2>&1; then + if git -C "$REPO_ROOT" ls-files --cached --error-unmatch -- "$BASE_FILE" >/dev/null 2>&1; then + printf '%s: %s is generated (untracked) and %s is staged — the frozen base is judged by check_roadmap_fragment_required.sh (G2).\n' "$PROG" "$ROADMAP_FILE" "$BASE_FILE" + exit 0 + fi printf '%s: %s is staged for DELETION — the id set may only grow.\n' "$PROG" "$ROADMAP_FILE" >&2 exit 1 fi @@ -664,6 +698,47 @@ if [ "$(git -C "$REPO_ROOT" rev-parse "$BASE_REF^{commit}")" = "$(git -C "$REPO_ printf 'PASS base and head are the same commit (a push/workflow_dispatch run on the branch tip): there is no PR diff to judge here; the judgement happened on the pull_request and merge_group runs\n' exit 0 fi +# GENERATED mode: judge the AGGREGATES (base file + entries/) at both refs, so a +# fragment that rewrites a base entry's title or criteria is as visible as an +# edit to the old committed roadmap.yaml was. A ref with no base file is the +# pre-migration tree: its committed roadmap.yaml is the aggregate. +if git -C "$REPO_ROOT" cat-file -e "$HEAD_REF:$BASE_FILE" 2>/dev/null \ + || git -C "$REPO_ROOT" cat-file -e "$BASE_REF:$BASE_FILE" 2>/dev/null; then + GTD=$(mktemp -d "${TMPDIR:-/tmp}/rmdiffgen.XXXXXX") || exit 2 + # agg_at -> the aggregate of base file + top-level entries/*.yaml at + agg_at() { + local ref="$1" out="$2" name + if git -C "$REPO_ROOT" cat-file -e "$ref:$BASE_FILE" 2>/dev/null; then + install -d "$out.d/entries" + git -C "$REPO_ROOT" show "$ref:$BASE_FILE" >"$out.d/base.yaml" || return 2 + while IFS= read -r name; do + case "$name" in + "$ENTRIES_DIR"/*.yaml) fname="${name##*/}"; git -C "$REPO_ROOT" show "$ref:$name" >"$out.d/entries/$fname" || return 2 ;; + esac + done < <(git -C "$REPO_ROOT" ls-tree --name-only "$ref" -- "$ENTRIES_DIR/") + python3 "$PY_AGG" aggregate --base-file "$out.d/base.yaml" --entries "$out.d/entries" --roadmap "$out" --write >/dev/null || return 2 + else + git -C "$REPO_ROOT" show "$ref:$ROADMAP_FILE" >"$out" || return 2 + fi + } + if ! agg_at "$BASE_REF" "$GTD/base-agg.yaml" || ! agg_at "$HEAD_REF" "$GTD/head-agg.yaml"; then + rm -rf -- "${GTD:?}" + printf '%s: could not build the aggregate at %s or %s\n' "$PROG" "$BASE_REF" "$HEAD_REF" >&2 + exit 2 + fi + if out=$(run_check "$GTD/base-agg.yaml" "$GTD/head-agg.yaml" 2>&1); then + rm -rf -- "${GTD:?}" + printf '%s\n' "$out" + printf 'PASS (generated mode: aggregates judged)\n' + exit 0 + fi + rc=$? + rm -rf -- "${GTD:?}" + printf '%s\n' "$out" + [ "$rc" -eq 2 ] && exit 2 + printf '\nA fragment may add a ticket or edit its lifecycle fields; it may not rewrite another field of an existing entry (PMAT-980).\n' + exit 1 +fi if out=$(run_check "$BASE_REF" "$HEAD_REF" 2>&1); then printf '%s\n' "$out" printf 'PASS\n' diff --git a/scripts/check_roadmap_fragment_required.sh b/scripts/check_roadmap_fragment_required.sh index 5a428523f6..03f859667e 100755 --- a/scripts/check_roadmap_fragment_required.sh +++ b/scripts/check_roadmap_fragment_required.sh @@ -93,6 +93,7 @@ REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" PY_LIB="$REPO_ROOT/scripts/lib/roadmap_fragments.py" ROADMAP_FILE="docs/roadmaps/roadmap.yaml" ENTRIES_DIR="docs/roadmaps/entries" +BASE_FILE="docs/roadmaps/roadmap-base.yaml" # shellcheck source=scripts/lib/resolve_base.sh . "$REPO_ROOT/scripts/lib/resolve_base.sh" || exit 1 @@ -128,6 +129,104 @@ remedy() { printf ' fragment path: it stays in the base and is immutable (RMFR-OB-005).\n' } +# --------------------------------------------------------------------------- +# GENERATED mode: roadmap.yaml is not committed at all. It is built on demand +# by `make roadmap-aggregate` from the frozen $BASE_FILE plus entries/, so two +# PRs can never contend on it and it cannot go stale after a main merge. +# Active once head carries $BASE_FILE. Three rules over base..head: +# G1 $ROADMAP_FILE is not tracked at head (a re-added aggregate is refused). +# G2 $BASE_FILE is frozen: unchanged from base. The migration PR (base has no +# $BASE_FILE) is allowed only if it is byte-equal to base's roadmap.yaml. +# G3 base + entries/ aggregates idempotently, with no duplicate ids. +# --------------------------------------------------------------------------- +judge_generated() { + local repo=$1 base=$2 head=$3 td violations=0 name out rc + td=$(mktemp -d "${TMPDIR:-/tmp}/rmfraggen.XXXXXX") || return 2 + printf '=== %s (generated aggregate): base=%s head=%s ===\n' "$PROG" "$base" "$head" + if git -C "$repo" cat-file -e "$head:$ROADMAP_FILE" 2>/dev/null; then + violations=$((violations + 1)) + printf 'FAIL G1 %s is tracked at head — it is GENERATED (make roadmap-aggregate), never committed\n' "$ROADMAP_FILE" + fi + if git -C "$repo" cat-file -e "$base:$BASE_FILE" 2>/dev/null; then + if [ -n "$(git -C "$repo" diff --name-only "$base" "$head" -- "$BASE_FILE")" ]; then + violations=$((violations + 1)) + printf 'FAIL G2 %s changed — the base is frozen; change a ticket through docs/roadmaps/entries/.yaml (a fragment supersedes the base entry)\n' "$BASE_FILE" + fi + elif ! git -C "$repo" show "$base:$ROADMAP_FILE" 2>/dev/null | cmp -s - <(git -C "$repo" show "$head:$BASE_FILE"); then + violations=$((violations + 1)) + printf 'FAIL G2 migration: %s is not byte-equal to base'"'"'s %s\n' "$BASE_FILE" "$ROADMAP_FILE" + fi + mkdir -p "$td/entries" + while IFS= read -r name; do + case "$name" in + *.yaml) + fname="${name##*/}" + if ! git -C "$repo" show "$head:$name" >"$td/entries/$fname"; then + rm -rf -- "${td:?}" + return 2 + fi ;; + esac + done < <(git -C "$repo" ls-tree -r --name-only "$head" -- "$ENTRIES_DIR" 2>/dev/null) + # G4 entries/ is append-only: deleting a fragment drops a ticket (or un-supersedes a base entry). + if [ -n "$(git -C "$repo" diff --no-renames --name-only --diff-filter=D "$base" "$head" -- "$ENTRIES_DIR")" ]; then + violations=$((violations + 1)) + printf 'FAIL G4 a fragment under %s/ is deleted — fragments are never removed; set the ticket status instead\n' "$ENTRIES_DIR" + fi + # G4b the generator reads only the top-level entries/*.yaml; anything else there is invisible to it. + while IFS= read -r name; do + if ! [[ "$name" =~ ^$ENTRIES_DIR/[^/]+\.yaml$ ]]; then + violations=$((violations + 1)) + printf 'FAIL G4 %s: only top-level %s/.yaml files are fragments — the generator ignores anything else\n' "$name" "$ENTRIES_DIR" + fi + done < <(git -C "$repo" ls-tree -r --name-only "$head" -- "$ENTRIES_DIR" 2>/dev/null) + # G3b one fragment = one YAML list holding ONE mapping whose id is its filename (loaded, not grepped, + # so a second entry cannot hide behind an indented or quoted line). + local f ids + for f in "$td"/entries/*.yaml; do + [ -e "$f" ] || continue + ids=$(python3 "$PY_LIB" fragment-ids "$f" 2>/dev/null) || ids="(unparseable)" + if [ "$ids" != "$(basename "$f" .yaml)" ]; then + violations=$((violations + 1)) + printf 'FAIL G3 %s/%s must hold exactly one entry whose id is its filename (found: %s)\n' "$ENTRIES_DIR" "$(basename "$f")" "$ids" + fi + done + # G5 every fragment added or changed carries a `Pmat-Ticket: ` trailer on a commit of base..head + # (sov.roadmap-fragment-parity demands it once the aggregate is untracked; fail here with the remedy, not there). + # Judged exactly as sov does: git's own trailer parser (LAST paragraph only), same command. A merge_group + # commit is the queue's squash of the PR, whose message the queue composes, so G5 is a PR-time check only. + local trailers + trailers=$(git -C "$repo" log --format='%(trailers:key=Pmat-Ticket,valueonly)' "$base..$head" 2>/dev/null | tr -d ' ' | grep -v '^$' | sort -u || true) + while IFS= read -r name; do + [ "${GITHUB_EVENT_NAME:-}" = merge_group ] && break + [[ "$name" =~ ^$ENTRIES_DIR/([^/]+)\.yaml$ ]] || continue + if ! grep -qFx -- "${BASH_REMATCH[1]}" <<< "$trailers"; then + violations=$((violations + 1)) + printf 'FAIL G5 %s changes with no `Pmat-Ticket: %s` trailer in the LAST paragraph of a commit in the range (git trailers, as sov reads them) — e.g. git commit --allow-empty -m "chore: ticket trailer" -m "Pmat-Ticket: %s"\n' "$name" "${BASH_REMATCH[1]}" "${BASH_REMATCH[1]}" + fi + done < <(git -C "$repo" diff --no-renames --name-only --diff-filter=AM "$base" "$head" -- "$ENTRIES_DIR") + if ! git -C "$repo" show "$head:$BASE_FILE" >"$td/base.yaml" 2>/dev/null; then + rm -rf -- "${td:?}" + printf 'FAIL G2 %s is missing at head — the frozen base cannot be deleted or renamed\n' "$BASE_FILE" + printf '%s: %s violation(s)\n' "$PROG" "$((violations + 1))" + return 1 + fi + out=$(python3 "$PY_LIB" aggregate --check --base-file "$td/base.yaml" --roadmap "$td/absent.yaml" --entries "$td/entries" 2>&1) + rc=$? + rm -rf -- "${td:?}" + if [ "$rc" = 0 ]; then + printf 'ok G3 base + entries/ aggregates idempotently\n' + else + violations=$((violations + 1)) + printf 'FAIL G3 base + entries/ does not aggregate cleanly:\n%s\n' "$out" + fi + if [ "$violations" != 0 ]; then + printf '%s: %s violation(s)\n' "$PROG" "$violations" + return 1 + fi + printf 'PASS %s: generated aggregate untracked, base frozen, fragments aggregate\n' "$PROG" + return 0 +} + # --------------------------------------------------------------------------- # judge -> 0 clean · 1 violation · 2 cannot judge # --------------------------------------------------------------------------- @@ -145,6 +244,12 @@ judge() { return 2 } + if git -C "$repo" cat-file -e "$head:$BASE_FILE" 2>/dev/null \ + || git -C "$repo" cat-file -e "$base:$BASE_FILE" 2>/dev/null; then + judge_generated "$repo" "$base" "$head" + return $? + fi + td=$(mktemp -d "${TMPDIR:-/tmp}/rmfragreq.XXXXXX") || return 2 if [ -n "$(git -C "$repo" diff --name-only "$base" "$head" -- "$ROADMAP_FILE")" ]; then @@ -272,6 +377,7 @@ mkrepo() { } self_test() { + unset GITHUB_EVENT_NAME # the rows judge PR-shape ranges; a merge_group env would skip G5 local td n=0 red=0 TEMPLATE td=$(mktemp -d "${TMPDIR:-/tmp}/rmfragreq-selftest.XXXXXX") || return 2 cleanup() { @@ -309,7 +415,7 @@ self_test() { printf 'ok row %-2s rc=%s %s\n' "$n" "$rc" "$label" } - commit_all() { git -C "$1" add -A && git -C "$1" commit -qm head; } + commit_all() { git -C "$1" add -A && if [ -n "${2:-}" ]; then git -C "$1" commit -qm head -m "$2"; else git -C "$1" commit -qm head; fi; } # ---- builders ------------------------------------------------------- # THE MEASURED SHAPE: `pmat work add` appends an entry to the monolith and @@ -425,6 +531,54 @@ PY row 'supersession through the fragment, regenerated -> PASS' \ 0 'ok CHANGED PMAT-100 — docs/roadmaps/entries/PMAT-100.yaml changes in the same diff' b_supersede + # ---- GENERATED mode (roadmap.yaml untracked, roadmap-base.yaml frozen) ---- + grow() { # LABEL WANT PATTERN BUILDER -- fixture: migrated tree, then BUILDER commits a change + local label=$1 want=$2 pat=$3 builder=$4 d out rc + n=$((n + 1)); d="$td/g$n" + mkdir -p "$d/$ENTRIES_DIR" && git -C "$d" init -q --template="$TEMPLATE" \ + && git -C "$d" config user.email t@e.com && git -C "$d" config user.name t \ + && printf '%s' "$BASE_ROADMAP" >"$d/$BASE_FILE" \ + && printf -- '- id: PMAT-100\n title: first\n status: planned\n' >"$d/$ENTRIES_DIR/PMAT-100.yaml" \ + && git -C "$d" add -A && git -C "$d" commit -qm base && "$builder" "$d" \ + || { printf 'FAIL row %-2s %s: fixture/builder failed\n' "$n" "$label"; red=$((red + 1)); return; } + out=$(judge "$d" HEAD~1 HEAD 2>&1); rc=$? + if [ "$rc" != "$want" ] || ! grep -qF -- "$pat" <<<"$out"; then + printf 'FAIL row %-2s rc=%s (wanted %s, must contain: %s) %s\n' "$n" "$rc" "$want" "$pat" "$label" + printf '%s\n' "$out" | sed 's/^/ /'; red=$((red + 1)); return + fi + printf 'ok row %-2s rc=%s %s\n' "$n" "$rc" "$label" + } + g_fragment_only() { printf -- '- id: PMAT-200\n title: new\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; commit_all "$1" "Pmat-Ticket: PMAT-200"; } + g_fragment_no_trailer() { printf -- '- id: PMAT-200\n title: new\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; commit_all "$1"; } + g_trailer_mid() { printf -- '- id: PMAT-200\n title: new\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; git -C "$1" add -A && git -C "$1" commit -qm head -m "Pmat-Ticket: PMAT-200" -m "Co-Authored-By: T "; } + g_trailer_wrong() { printf -- '- id: PMAT-200\n title: new\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; commit_all "$1" "Pmat-Ticket: PMAT-999"; } + g_two_entries() { printf -- '- id: PMAT-200\n title: new\n status: planned\n- id: PMAT-201\n title: smuggled\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; commit_all "$1" "Pmat-Ticket: PMAT-200"; } + g_readd_aggregate() { cp "$1/$BASE_FILE" "$1/$ROADMAP_FILE"; commit_all "$1"; } + g_edit_base() { printf -- '- id: PMAT-400\n title: sneaked\n status: planned\n' >>"$1/$BASE_FILE"; commit_all "$1"; } + g_delete_fragment() { git -C "$1" rm -q "$ENTRIES_DIR/PMAT-100.yaml"; commit_all "$1"; } + g_rename_fragment() { git -C "$1" mv "$ENTRIES_DIR/PMAT-100.yaml" "$ENTRIES_DIR/PMAT-199.yaml"; sed -i 's/PMAT-100/PMAT-199/' "$1/$ENTRIES_DIR/PMAT-199.yaml"; commit_all "$1"; } + g_nested_fragment() { mkdir -p "$1/$ENTRIES_DIR/sub"; printf -- '- id: PMAT-600\n title: hidden\n status: planned\n' >"$1/$ENTRIES_DIR/sub/PMAT-600.yaml"; commit_all "$1"; } + g_delete_base() { git -C "$1" rm -q "$BASE_FILE"; commit_all "$1"; } + g_rename_base() { git -C "$1" mv "$BASE_FILE" "$1/docs/roadmaps/moved.yaml"; commit_all "$1"; } + g_dup_fragment() { printf -- '- id: PMAT-300\n title: dup\n status: planned\n' | tee "$1/$ENTRIES_DIR/PMAT-300.yaml" >"$1/$ENTRIES_DIR/PMAT-301.yaml"; commit_all "$1"; } + m_migrate() { git -C "$1" mv "$ROADMAP_FILE" "$BASE_FILE"; commit_all "$1"; } + m_migrate_edit() { git -C "$1" mv "$ROADMAP_FILE" "$BASE_FILE"; printf -- '- id: PMAT-500\n title: x\n status: planned\n' >>"$1/$BASE_FILE"; commit_all "$1"; } + row 'migration: roadmap.yaml renamed byte-identically to the base -> PASS' 0 'PASS check_roadmap_fragment_required.sh: generated aggregate' m_migrate + row 'migration: the renamed base is not byte-equal -> REFUSE G2' 1 'FAIL G2' m_migrate_edit + grow 'generated: a fragment alone (no aggregate to regenerate) -> PASS' 0 'PASS check_roadmap_fragment_required.sh: generated aggregate' g_fragment_only + grow 'generated: roadmap.yaml re-added and tracked -> REFUSE G1' 1 'FAIL G1' g_readd_aggregate + grow 'generated: the frozen base edited -> REFUSE G2' 1 'FAIL G2' g_edit_base + grow 'generated: a duplicate id across fragments -> REFUSE G3' 1 'FAIL G3' g_dup_fragment + grow 'generated: a fragment with no Pmat-Ticket trailer -> REFUSE G5' 1 'FAIL G5' g_fragment_no_trailer + grow 'generated: trailer present but NOT in the last paragraph -> REFUSE G5' 1 'FAIL G5' g_trailer_mid + grow 'generated: trailer names another ticket -> REFUSE G5' 1 'FAIL G5' g_trailer_wrong + grow 'generated: a second entry inside one fragment -> REFUSE G3' 1 'FAIL G3' g_two_entries + grow 'generated: a fragment deleted -> REFUSE G4' 1 'FAIL G4' g_delete_fragment + grow 'generated: a fragment renamed with its id edited -> REFUSE G4' 1 'FAIL G4' g_rename_fragment + grow 'generated: a nested fragment the generator never reads -> REFUSE G4' 1 'FAIL G4' g_nested_fragment + grow 'generated: the frozen base deleted -> REFUSE G2 (not the legacy judge)' 1 'FAIL G2' g_delete_base + grow 'generated: the frozen base renamed -> REFUSE G2' 1 'FAIL G2' g_rename_base + # An unresolvable ref is ENV (rc 2), never a pass. n=$((n + 1)) local out rc diff --git a/scripts/ci/fat_driver.py b/scripts/ci/fat_driver.py index 5f9de2b80b..d2d65a9e44 100644 --- a/scripts/ci/fat_driver.py +++ b/scripts/ci/fat_driver.py @@ -751,6 +751,14 @@ def setup_workspace(self): k, _, v = line.partition(" ") subprocess.run(g + ["config", k, v], check=True) subprocess.run(g + ["checkout", "-q", "--detach", self.ctx.head], check=True) + # docs/roadmaps/roadmap.yaml is generated and gitignored, so a clone of HEAD + # never has it: build it here, or every roadmap guard and sov.roadmap-valid + # in this section runs against an absent file (#5897). + ws = self.workspace + if (ws / "docs/roadmaps/roadmap-base.yaml").is_file(): + subprocess.run(["python3", str(ws / "scripts/lib/roadmap_fragments.py"), "aggregate", + "--base-file", str(ws / "docs/roadmaps/roadmap-base.yaml"), "--write"], + cwd=str(ws), check=True, stdout=self.log, stderr=subprocess.STDOUT) def start_container(self, spec): if isinstance(spec, str): diff --git a/scripts/ci_test_tier.sh b/scripts/ci_test_tier.sh index d52c6e4cd2..7ec8807369 100755 --- a/scripts/ci_test_tier.sh +++ b/scripts/ci_test_tier.sh @@ -610,10 +610,12 @@ self_test() { row 0 " (mutant file really differs from the script)" '^DIFFERS$' bash -c "if cmp -s '$T' '$td/mutant-tagreuse.sh'; then echo SAME; else echo DIFFERS; fi" row 0 "MUTANT: a push arm that does not special-case tags reuses the quick MG green on a tag -> the row above goes RED" '^MUTANT-REUSE$' bash -c "o=\$(GITHUB_REF=refs/tags/v9.9.9 bash '$td/mutant-tagreuse.sh' --event push --repo-root '$td/s-leaf' $MGARGS); if grep -q '^tier=reuse$' <<< \"\$o\"; then echo MUTANT-REUSE; else echo SURVIVED; fi" # #3658 DOCS-ONLY on a pull_request (paths that exist in this tree). - printf 'docs/roadmaps/roadmap.yaml\ndocs/audits/pp-066-plan-quorum.md\n' > "$td/d-docs.txt" + printf 'docs/roadmaps/roadmap-base.yaml\ndocs/roadmaps/entries/PMAT-5897.yaml\ndocs/audits/pp-066-plan-quorum.md\n' > "$td/d-docs.txt" row 0 "pull_request, roadmap + audit only -> tier=none (no Rust test reads those paths)" '^tier=none$' bash "$T" --event pull_request --diff-from "$td/d-docs.txt" - printf 'docs/roadmaps/roadmap.yaml\ndocs/roadmaps/entries/PMAT-DELETED-3658.yaml\n' > "$td/d-docs-del.txt" + printf 'docs/roadmaps/roadmap-base.yaml\ndocs/roadmaps/entries/PMAT-DELETED-3658.yaml\n' > "$td/d-docs-del.txt" row 0 " ...but a DELETED docs path falls through to quick (a tree reader asserts cited paths exist)" '^tier=quick$' bash "$T" --event pull_request --diff-from "$td/d-docs-del.txt" + printf 'docs/roadmaps/roadmap.yaml\n' > "$td/d-gen.txt" + row 0 " ...and the GENERATED docs/roadmaps/roadmap.yaml (untracked) fails closed to quick, never none" '^tier=quick$' bash "$T" --event pull_request --diff-from "$td/d-gen.txt" printf 'docs/roadmaps/roadmap.yaml\nscripts/foo.sh\n' > "$td/d-docs-mixed.txt" row 0 " ...and roadmap + ANY non-docs path is not docs-only -> quick" '^tier=quick$' bash "$T" --event pull_request --diff-from "$td/d-docs-mixed.txt" printf 'docs/specifications/aprender-monorepo-consolidation.md\n' > "$td/d-spec.txt" diff --git a/scripts/comparator_pin_citations.txt b/scripts/comparator_pin_citations.txt index 0684b328cb..9c206bc695 100644 --- a/scripts/comparator_pin_citations.txt +++ b/scripts/comparator_pin_citations.txt @@ -23,7 +23,7 @@ docs/audits/parity-evidence-pr-plan.md historical a dated record of a measuremen docs/roadmaps/entries/PMAT-3563.yaml historical ticket record quoting its issue, which names the old pin; dated 2026-09-24 [39173bcac] 2:d49fdd8340f2 docs/roadmaps/entries/PMAT-3740.yaml historical ticket record quoting its issue, which names the old pin; dated 2026-09-24 [39173bcac] 1:232f9d7fb82a docs/roadmaps/entries/PMAT-3741.yaml historical ticket record quoting its issue, which names the old pin; dated 2026-09-24 [39173bcac] 14:fddb5672f3a7 -docs/roadmaps/roadmap.yaml historical ticket record quoting its issue, which names the old pin; dated 2026-09-24 [39173bcac] 18:38afc7a5fac9 +docs/roadmaps/roadmap-base.yaml historical ticket record quoting its issue, which names the old pin; dated 2026-09-24 [39173bcac] 18:38afc7a5fac9 docs/specifications/0.66-performance-parity-report.md historical a dated spec/contract line naming the pin a measurement was made against [39173bcac] 1:ee1a37d8d96d docs/specifications/PP-LLAMA-001-MASTER.md historical a dated spec/contract line naming the pin a measurement was made against [39173bcac] 2:ef251fe400ce docs/specifications/PP-LLAMA-001-RATIONALE.md historical a dated spec/contract line naming the pin a measurement was made against [39173bcac] 3:678133cbf553 diff --git a/scripts/lib/roadmap_fragments.py b/scripts/lib/roadmap_fragments.py index c9fa1ff535..b20189b5c7 100644 --- a/scripts/lib/roadmap_fragments.py +++ b/scripts/lib/roadmap_fragments.py @@ -481,7 +481,8 @@ def _read(path, what): def _cmd_aggregate(a, roadmap, entries_dir): - base = _read(roadmap, roadmap) + src = a.base_file or roadmap + base = _read(src, src) if base is None: return 2 frags = read_fragments(entries_dir) @@ -490,9 +491,51 @@ def _cmd_aggregate(a, roadmap, entries_dir): except ValueError as e: sys.stderr.write("FAIL %s\n" % e) return 1 + if a.base_file: + return _emit_generated(a, base, out, frags, roadmap) return _emit(a, base, out, frags, roadmap) +def _emit_generated(a, base, out, frags, roadmap): + """GENERATED mode: roadmap.yaml is untracked output of (base-file, entries/). + --check proves idempotence, and that a generated file already on disk is not + stale; an absent one is fine (it is generated on demand, never committed).""" + if a.check: + if aggregate(out, frags) != out: + sys.stderr.write("FAIL aggregate is not idempotent on this input\n") + return 1 + if os.path.exists(roadmap) and _read(roadmap, roadmap) != out: + sys.stderr.write("FAIL %s is stale against entries/ -- run `make roadmap-aggregate`\n" % roadmap) + return 1 + sys.stderr.write("ok base + %d fragment(s) aggregates idempotently\n" % len(frags)) + return 0 + return _emit(a, base, out, frags, roadmap) + + +def _fragment_ids_of(path): + """The ids one fragment holds (space-joined), `?` if it is not a list of + mappings, None if it does not load.""" + import yaml + try: + with open(path, encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + except (OSError, yaml.YAMLError): + return None + if not isinstance(doc, list): + return "?" + return " ".join(str(e["id"]) if isinstance(e, dict) and "id" in e else "?" for e in doc) + + +def _cmd_fragment_ids(a): + """`fragment-ids FILE...` -> one line per file with the ids it holds, or + `(unparseable)` (rc 1). check_roadmap_fragment_required.sh G3b compares the + line to the filename.""" + got = [_fragment_ids_of(path) for path in a.ids] + for line in got: + print("(unparseable)" if line is None else line) + return 1 if None in got else 0 + + def _cmd_changed(a): """`changed --base A --head B` -> one `VERDICTID` line per entry whose BYTES differ, plus `PREAMBLE` if the header did. The consumer decides @@ -535,12 +578,15 @@ def _cmd_adopt(a, roadmap, entries_dir): def _parser(): ap = argparse.ArgumentParser(prog="roadmap_fragments.py") - ap.add_argument("cmd", nargs="?", choices=["aggregate", "changed", "adopt"]) - ap.add_argument("ids", nargs="*", help="adopt: the entry id(s) to move into entries/") + ap.add_argument("cmd", nargs="?", choices=["aggregate", "changed", "adopt", "fragment-ids"]) + ap.add_argument("ids", nargs="*", help="adopt: the entry id(s) to move into entries/; fragment-ids: the fragment file(s) to read") ap.add_argument("--write", action="store_true") ap.add_argument("--check", action="store_true", help="fail if roadmap.yaml is not what the aggregator produces") ap.add_argument("--roadmap", help="judge this roadmap.yaml instead of the repo's") + ap.add_argument("--base-file", help="GENERATED mode: the committed, frozen base " + "(docs/roadmaps/roadmap-base.yaml). The aggregate is base + entries/, " + "written to --roadmap (untracked) and never read back as its own base") ap.add_argument("--entries", help="read fragments from here instead of docs/roadmaps/entries/") ap.add_argument("--base", help="changed: the roadmap.yaml to compare FROM") ap.add_argument("--head", help="changed: the roadmap.yaml to compare TO") @@ -553,15 +599,17 @@ def main(argv=None): a = ap.parse_args(argv) if a.selftest: return selftest() - roadmap, entries_dir = _paths(a) + if a.cmd == "fragment-ids": + return _cmd_fragment_ids(a) if a.cmd == "changed": return _cmd_changed(a) + roadmap, entries_dir = _paths(a) if a.cmd == "adopt": return _cmd_adopt(a, roadmap, entries_dir) - if a.cmd != "aggregate": - ap.print_usage(sys.stderr) - return 2 - return _cmd_aggregate(a, roadmap, entries_dir) + if a.cmd == "aggregate": + return _cmd_aggregate(a, roadmap, entries_dir) + ap.print_usage(sys.stderr) + return 2 if __name__ == "__main__": From c887b3c1329693983e8f64c42ba6be74d0229ce4 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 13:18:50 +0200 Subject: [PATCH 3/3] chore(flow): changelog.d/ fragments and scripts/check_changelog_fragments.sh guard (Refs #5761) FLOW-009 RH-1/RH-2: per-ticket changelog fragments under changelog.d/, a schema check, an aggregate step and a self-test; gitignore pmat db-shm/wal. Named check_ so guard_tree dispatches it. PMAT-Ticket: PMAT-5761 PMAT-Batch: BATCH-aprender-3269 Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + changelog.d/README.md | 25 ++++++ scripts/check_changelog_fragments.sh | 112 +++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 changelog.d/README.md create mode 100755 scripts/check_changelog_fragments.sh diff --git a/.gitignore b/.gitignore index d3d322005c..9dc0842ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,5 @@ crates/aprender-contracts-staging/lean/discharge.json # GENERATED by `make roadmap-aggregate` from roadmap-base.yaml + entries/ (never committed) docs/roadmaps/roadmap.yaml +.pmat/context.db-shm +.pmat/context.db-wal diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000000..64511cb8c3 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,25 @@ +# changelog.d/ + +One file per ticket: `changelog.d/.md` (e.g. `PMAT-1234.md`, `GH-5761.md`). A PR writes its +fragment and never edits `CHANGELOG.md`; the aggregate is generated at release, so N PRs never +contend on one file (FLOW-009 RH-1/RH-2, paiml/aprender#5761). + +Fragment shape (schema only, checked per PR): + +``` +### Fixed + +- fix(scope): what changed (Refs #N) +``` + +Exactly one `###
` heading (Added, Changed, Fixed, Performance, Security, Removed) followed +by one or more `- ` bullets. + +``` +bash scripts/check_changelog_fragments.sh check # schema of every fragment +bash scripts/check_changelog_fragments.sh aggregate # release: fold into CHANGELOG.md [Unreleased], delete fragments +bash scripts/check_changelog_fragments.sh --self-test # case table +``` + +Roadmap fragments are `docs/roadmaps/entries/.yaml` (apr-roadmap-fragments-v1); +`roadmap.yaml` is their generated aggregate. diff --git a/scripts/check_changelog_fragments.sh b/scripts/check_changelog_fragments.sh new file mode 100755 index 0000000000..8a52b79d46 --- /dev/null +++ b/scripts/check_changelog_fragments.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# check_changelog_fragments.sh -- changelog.d/.md fragments (FLOW-009 RH-1/RH-2, #5761). +# +# check [DIR] schema of every fragment; exit 1 on a bad one +# aggregate [DIR] [CHANGELOG] fold fragments into CHANGELOG [Unreleased], then delete them +# --self-test plant each falsifier; must PASS/FAIL as named +set -euo pipefail + +SECTIONS="Added Changed Fixed Performance Security Removed" + +# One defect per line: "FILE: reason". Empty output = valid. +check_dir() { + local dir=$1 f name + for f in "$dir"/*.md; do + [ -e "$f" ] || continue + name=$(basename "$f") + [ "$name" = README.md ] && continue + if ! [[ "$name" =~ ^[A-Za-z]+-[0-9]+\.md$ ]]; then + echo "$name: file name must be .md (e.g. PMAT-1234.md)" + continue + fi + awk -v name="$name" -v secs="$SECTIONS" ' + BEGIN { n = split(secs, s, " "); for (i = 1; i <= n; i++) ok[s[i]] = 1 } + /^[[:space:]]*$/ { next } + /^### / { h++; sec = substr($0, 5); if (!(sec in ok)) printf "%s: unknown section \"%s\"\n", name, sec; if (b) printf "%s: heading after bullets\n", name; next } + /^- / { b++; if (!h) printf "%s: bullet before the ### heading\n", name; next } + { printf "%s: line is neither a heading nor a \"- \" bullet: %s\n", name, $0 } + END { if (h != 1) printf "%s: needs exactly one ### heading (found %d)\n", name, h; if (!b) printf "%s: needs at least one bullet\n", name } + ' "$f" + done +} + +aggregate() { + local dir=$1 log=$2 tmp sec f body + [ -f "$log" ] || { echo "no $log" >&2; return 1; } + local bad + bad=$(check_dir "$dir") + [ -z "$bad" ] || { printf '%s\n' "$bad" >&2; return 1; } + tmp=$(mktemp) + body="" + for sec in $SECTIONS; do + local lines="" + for f in "$dir"/*.md; do + [ -e "$f" ] || continue + [ "$(basename "$f")" = README.md ] && continue + if grep -qx "### $sec" "$f"; then + lines="$lines$(grep '^- ' "$f")"$'\n' + fi + done + [ -z "$lines" ] || body="$body### $sec"$'\n\n'"$lines"$'\n' + done + [ -n "$body" ] || { echo "no fragments" >&2; rm -f "${tmp:?}"; return 0; } + awk -v body="$body" ' + { print } + /^## \[Unreleased\]/ && !done { print ""; printf "%s", body; done = 1 } + ' "$log" >"$tmp" + mv "$tmp" "$log" + for f in "$dir"/*.md; do + [ "$(basename "$f")" = README.md ] || rm -f -- "${f:?}" + done +} + +self_test() { + local d rc=0 + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + expect() { # name want(PASS|FAIL) content filename [needle] + mkdir -p "${d:?}/$1" + printf '%s\n' "$3" >"$d/$1/${4:-T-1.md}" + local out got=PASS + out=$(check_dir "$d/$1") + [ -z "$out" ] || got=FAIL + if [ "$got" != "$2" ] || { [ -n "${5:-}" ] && [[ "$out" != *"$5"* ]]; }; then + echo "SELF-TEST FAIL: $1 wanted $2 ${5:-}, got $got: $out" >&2 + rc=1 + fi + } + expect ok PASS $'### Fixed\n\n- fix(x): y (#1)' + expect bad-name FAIL $'### Fixed\n\n- a' notes.md 'file name must be' + expect no-heading FAIL '- a' T-1.md 'needs exactly one ### heading' + expect bad-section FAIL $'### Misc\n\n- a' T-1.md 'unknown section' + expect no-bullet FAIL '### Fixed' T-1.md 'at least one bullet' + expect prose FAIL $'### Fixed\n\nsome prose' T-1.md 'neither a heading' + expect two-headings FAIL $'### Fixed\n- a\n### Added\n- b' T-1.md 'needs exactly one' + # aggregate: both fragments land under [Unreleased], in section order, and the files go. + mkdir -p "$d/agg" + printf '# Changelog\n\n## [Unreleased]\n\n## [1.0.0]\n' >"$d/CHANGELOG.md" + printf '### Fixed\n\n- fix a (#1)\n' >"$d/agg/PMAT-1.md" + printf '### Added\n\n- add b (#2)\n' >"$d/agg/PMAT-2.md" + aggregate "$d/agg" "$d/CHANGELOG.md" + local want got + want=$(printf '# Changelog\n\n## [Unreleased]\n\n### Added\n\n- add b (#2)\n\n### Fixed\n\n- fix a (#1)\n\n\n## [1.0.0]') + got=$(cat "$d/CHANGELOG.md") + [ "$got" = "$want" ] || { echo "SELF-TEST FAIL: aggregate wrote: $got" >&2; rc=1; } + [ ! -e "$d/agg/PMAT-1.md" ] || { echo "SELF-TEST FAIL: fragment not deleted" >&2; rc=1; } + [ "$rc" -eq 0 ] && echo "changelog_fragments self-test: 8 cases ok" + return "$rc" +} + +# guard_tree.sh runs this bare, from the repo root: no argument = check changelog.d. +cmd=${1:-check} +case "$cmd" in + -h|--help) echo "usage: $0 [check [DIR]] | aggregate [DIR] [CHANGELOG] | --self-test" ;; + --self-test) self_test ;; + check) + out=$(check_dir "${2:-changelog.d}") + if [ -n "$out" ]; then printf '%s\n' "$out" >&2; exit 1; fi + ;; + aggregate) aggregate "${2:-changelog.d}" "${3:-CHANGELOG.md}" ;; + *) echo "usage: $0 check [DIR] | aggregate [DIR] [CHANGELOG] | --self-test" >&2; exit 2 ;; +esac