From 2dcb3bf9ce37705929ec99c1c85a0054ffb9ea3c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 21 Oct 2026 23:19:07 +0200 Subject: [PATCH 1/6] ci: generate docs/roadmaps/roadmap.yaml instead of committing it (Refs #5897) The committed aggregate drifted from entries/ after every main merge and check_roadmap_fragment_required.sh failed (#5862 twice, #5876). Freeze the base as roadmap-base.yaml, gitignore roadmap.yaml, produce it with make roadmap-aggregate, and generate it in x86-main before any section so sov.roadmap-valid never reports NOT-RUN. Guards learn generated mode. Pmat-Ticket: PMAT-5897 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_diff_additive.sh | 9 +++ scripts/check_roadmap_fragment_required.sh | 80 +++++++++++++++++++ scripts/lib/roadmap_fragments.py | 24 +++++- 9 files changed, 147 insertions(+), 4 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 905ae731c2..d2d7ce09ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,6 +248,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: | + python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --write + python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --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_diff_additive.sh b/scripts/check_roadmap_diff_additive.sh index 07b1614084..8eb168e4d8 100755 --- a/scripts/check_roadmap_diff_additive.sh +++ b/scripts/check_roadmap_diff_additive.sh @@ -53,6 +53,7 @@ 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" +BASE_FILE="docs/roadmaps/roadmap-base.yaml" # present = roadmap.yaml is GENERATED, not committed # --------------------------------------------------------------------------- # run_check [] -> roadmap_diff.py's stdout+exit code. @@ -608,6 +609,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 +669,10 @@ 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 +if git -C "$REPO_ROOT" cat-file -e "$HEAD_REF:$BASE_FILE" 2>/dev/null; then + printf 'PASS %s is generated at head; the frozen base is judged by check_roadmap_fragment_required.sh (G2), so there is no committed aggregate to diff\n' "$ROADMAP_FILE" + exit 0 +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..f233e6c9f5 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,55 @@ 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) git -C "$repo" show "$head:$name" >"$td/entries/${name##*/}" || { rm -rf -- "${td:?}"; return 2; } ;; esac + done < <(git -C "$repo" ls-tree -r --name-only "$head" -- "$ENTRIES_DIR" 2>/dev/null) + git -C "$repo" show "$head:$BASE_FILE" >"$td/base.yaml" || { rm -rf -- "${td:?}"; return 2; } + 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 +195,11 @@ judge() { return 2 } + if git -C "$repo" cat-file -e "$head:$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 @@ -425,6 +480,31 @@ 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"; } + 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_dup_fragment() { printf -- '- id: PMAT-300\n title: dup\n status: planned\n- id: PMAT-300\n title: dup2\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-300.yaml"; commit_all "$1"; } + 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 + # An unresolvable ref is ENV (rc 2), never a pass. n=$((n + 1)) local out rc diff --git a/scripts/lib/roadmap_fragments.py b/scripts/lib/roadmap_fragments.py index c9fa1ff535..f819666fde 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,6 +491,24 @@ 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) @@ -541,6 +560,9 @@ def _parser(): 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") From a8c00da50a2e27ac611b2fa294ae6603e4669ef5 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 21 Oct 2026 23:27:39 +0200 Subject: [PATCH 2/6] ci: generate the roadmap aggregate in every section workspace; G4 + base-or-head keying (Refs #5897) Quorum #5898@2dcb3bf9ce findings: fat_driver clones HEAD per section so the gitignored roadmap.yaml never reached guard-tree or sov.*; generate it in setup_workspace. fragment_required: key generated mode on base OR head carrying the base file, add G4 (a fragment is never deleted) and G3b (one fragment = one id = its filename), and self-test rows for the dup, delete, rename and migration cases (23/23). Pmat-Ticket: PMAT-5897 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_roadmap_fragment_required.sh | 38 ++++++++++++++++++++-- scripts/ci/fat_driver.py | 8 +++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/scripts/check_roadmap_fragment_required.sh b/scripts/check_roadmap_fragment_required.sh index f233e6c9f5..fa83c24fbe 100755 --- a/scripts/check_roadmap_fragment_required.sh +++ b/scripts/check_roadmap_fragment_required.sh @@ -160,7 +160,27 @@ judge_generated() { while IFS= read -r name; do case "$name" in *.yaml) git -C "$repo" show "$head:$name" >"$td/entries/${name##*/}" || { rm -rf -- "${td:?}"; return 2; } ;; esac done < <(git -C "$repo" ls-tree -r --name-only "$head" -- "$ENTRIES_DIR" 2>/dev/null) - git -C "$repo" show "$head:$BASE_FILE" >"$td/base.yaml" || { rm -rf -- "${td:?}"; return 2; } + # G4 entries/ is append-only: deleting a fragment drops a ticket (or un-supersedes a base entry). + if [ -n "$(git -C "$repo" diff --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 + # G3b one fragment = one id = its filename, so two files can never carry the same ticket. + local f ids + for f in "$td"/entries/*.yaml; do + [ -e "$f" ] || continue + ids=$(grep -E '^- id: ' "$f" | sed -E "s/^- id: *['\"]?([^'\"]*)['\"]?.*/\1/") + 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")" "$(printf '%s' "$ids" | tr '\n' ' ')" + fi + done + 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:?}" @@ -195,7 +215,8 @@ judge() { return 2 } - if git -C "$repo" cat-file -e "$head:$BASE_FILE" 2>/dev/null; then + 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 @@ -500,10 +521,21 @@ PY g_fragment_only() { printf -- '- id: PMAT-200\n title: new\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-200.yaml"; commit_all "$1"; } 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_dup_fragment() { printf -- '- id: PMAT-300\n title: dup\n status: planned\n- id: PMAT-300\n title: dup2\n status: planned\n' >"$1/$ENTRIES_DIR/PMAT-300.yaml"; commit_all "$1"; } + g_delete_fragment() { git -C "$1" rm -q "$ENTRIES_DIR/PMAT-100.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 deleted -> REFUSE G4' 1 'FAIL G4' g_delete_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)) 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): From 92a9536bceed8f3bec50674cf9a654e869021c5a Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 21 Oct 2026 23:33:14 +0200 Subject: [PATCH 3/6] ci: completion_is_cited scans the generated roadmap.yaml, not roadmap-base.yaml (Refs #5897) The guard's universe is every roadmap*.yaml, so the frozen base reported 287 entries under a path the baseline is not keyed on (rc 1 at head, rc 0 on main). Exclude the base (the aggregate carries every entry) and fail loudly if the base exists without the generated aggregate. Case-table row added (36/36). Pmat-Ticket: PMAT-5897 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_roadmap_completion_is_cited.sh | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) 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) From 900b8518dfddc45da9b83a89ced9b4a292298ebd Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 21 Oct 2026 23:42:04 +0200 Subject: [PATCH 4/6] ci: judge the generated aggregates additively; G4 covers renames and nested fragments (Refs #5897) Quorum #5898@92a9536bce: (1) G4 now diffs with --no-renames so a renamed fragment (id edited) is a deletion, and refuses any path under entries/ that is not a top-level .yaml (the generator reads only those). (2) check_roadmap_diff_additive no longer PASSes early in generated mode: it aggregates base file + entries/ at both refs and runs the additive check on the pair, so a fragment rewriting a base entry's title is refused (rows 28-29). Self-tests: 25/25, 29/29. Pmat-Ticket: PMAT-5897 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_roadmap_diff_additive.sh | 76 ++++++++++++++++++++-- scripts/check_roadmap_fragment_required.sh | 13 +++- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/scripts/check_roadmap_diff_additive.sh b/scripts/check_roadmap_diff_additive.sh index 8eb168e4d8..e1e3311505 100755 --- a/scripts/check_roadmap_diff_additive.sh +++ b/scripts/check_roadmap_diff_additive.sh @@ -53,6 +53,8 @@ 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 # --------------------------------------------------------------------------- @@ -93,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. @@ -552,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" @@ -669,9 +698,46 @@ 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 -if git -C "$REPO_ROOT" cat-file -e "$HEAD_REF:$BASE_FILE" 2>/dev/null; then - printf 'PASS %s is generated at head; the frozen base is judged by check_roadmap_fragment_required.sh (G2), so there is no committed aggregate to diff\n' "$ROADMAP_FILE" - exit 0 +# 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 + mkdir -p "$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) git -C "$REPO_ROOT" show "$ref:$name" >"$out.d/entries/${name##*/}" || 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" diff --git a/scripts/check_roadmap_fragment_required.sh b/scripts/check_roadmap_fragment_required.sh index fa83c24fbe..10fabb3f56 100755 --- a/scripts/check_roadmap_fragment_required.sh +++ b/scripts/check_roadmap_fragment_required.sh @@ -161,10 +161,17 @@ judge_generated() { case "$name" in *.yaml) git -C "$repo" show "$head:$name" >"$td/entries/${name##*/}" || { rm -rf -- "${td:?}"; return 2; } ;; 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 --name-only --diff-filter=D "$base" "$head" -- "$ENTRIES_DIR")" ]; then + 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 id = its filename, so two files can never carry the same ticket. local f ids for f in "$td"/entries/*.yaml; do @@ -522,6 +529,8 @@ PY 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"; } @@ -534,6 +543,8 @@ PY 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 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 From 43ca7b900f11eecdf907c0a4edd1fee1ddd1fb3e Mon Sep 17 00:00:00 2001 From: t Date: Wed, 21 Oct 2026 23:49:22 +0200 Subject: [PATCH 5/6] ci: G5 Pmat-Ticket trailer check; G3b loads the fragment YAML (Refs #5897) Quorum #5898@900b8518df blocker 3: sov roadmap-fragment-parity arms after the migration and wants a Pmat-Ticket trailer per changed fragment; none of the recent fragment PRs carry one. G5 fails in our own guard with the remedy printed (an empty commit carrying the trailer). G3b now loads the YAML so a second entry cannot hide from the grep. Self-test 27/27. Pmat-Ticket: PMAT-5897 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_roadmap_fragment_required.sh | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/check_roadmap_fragment_required.sh b/scripts/check_roadmap_fragment_required.sh index 10fabb3f56..a0f711e6d9 100755 --- a/scripts/check_roadmap_fragment_required.sh +++ b/scripts/check_roadmap_fragment_required.sh @@ -172,16 +172,30 @@ judge_generated() { 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 id = its filename, so two files can never carry the same ticket. + # 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=$(grep -E '^- id: ' "$f" | sed -E "s/^- id: *['\"]?([^'\"]*)['\"]?.*/\1/") + ids=$(python3 -c 'import sys,yaml +d=yaml.safe_load(open(sys.argv[1])) +print(" ".join(str(e.get("id")) if isinstance(e,dict) else "?" for e in d) if isinstance(d,list) else "?")' "$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")" "$(printf '%s' "$ids" | tr '\n' ' ')" + 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). + local msgs + msgs=$(git -C "$repo" log --no-merges --format=%B "$base..$head" 2>/dev/null) + while IFS= read -r name; do + [[ "$name" =~ ^$ENTRIES_DIR/([^/]+)\.yaml$ ]] || continue + if ! grep -qE "^Pmat-Ticket:[[:space:]]*${BASH_REMATCH[1]}[[:space:]]*$" <<<"$msgs"; then + violations=$((violations + 1)) + printf 'FAIL G5 %s changes with no `Pmat-Ticket: %s` trailer on any commit of the range — add one (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" @@ -392,7 +406,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 && git -C "$1" commit -qm head ${2:+-m "$2"}; } # ---- builders ------------------------------------------------------- # THE MEASURED SHAPE: `pmat work add` appends an entry to the monolith and @@ -525,7 +539,9 @@ PY 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"; } + 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_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"; } @@ -542,6 +558,8 @@ PY 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: 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 From 3e876403e9f09b3bd10d651f4bf92c66397c4a0b Mon Sep 17 00:00:00 2001 From: t Date: Wed, 21 Oct 2026 23:56:10 +0200 Subject: [PATCH 6/6] ci: G5 judges trailers as sov does (last paragraph), skips merge_group (Refs #5897) Quorum #5898@43ca7b900f: G5 now uses sov's exact `git log %(trailers:key=Pmat-Ticket,valueonly)` and fixed-string match, so a trailer outside the final paragraph is refused (row), as is a wrong-id trailer (row). merge_group is skipped: the queue composes the squash message. Self-test 29/29; on this branch the range now passes only with this commit's trailer block. Pmat-Ticket: PMAT-5897 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_roadmap_fragment_required.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/check_roadmap_fragment_required.sh b/scripts/check_roadmap_fragment_required.sh index a0f711e6d9..1c87169497 100755 --- a/scripts/check_roadmap_fragment_required.sh +++ b/scripts/check_roadmap_fragment_required.sh @@ -187,13 +187,16 @@ print(" ".join(str(e.get("id")) if isinstance(e,dict) else "?" for e in d) if is 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). - local msgs - msgs=$(git -C "$repo" log --no-merges --format=%B "$base..$head" 2>/dev/null) + # 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 -qE "^Pmat-Ticket:[[:space:]]*${BASH_REMATCH[1]}[[:space:]]*$" <<<"$msgs"; then + if ! printf '%s\n' "$trailers" | grep -qFx -- "${BASH_REMATCH[1]}"; then violations=$((violations + 1)) - printf 'FAIL G5 %s changes with no `Pmat-Ticket: %s` trailer on any commit of the range — add one (e.g. git commit --allow-empty -m "chore: ticket trailer" -m "Pmat-Ticket: %s")\n' "$name" "${BASH_REMATCH[1]}" "${BASH_REMATCH[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 @@ -541,6 +544,8 @@ PY } 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"; } @@ -559,6 +564,8 @@ PY 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