diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 51c0eb097f..b36d8c29ef 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -249,6 +249,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__":
]