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__":