diff --git a/.machine_readable/Debtfile.a2ml b/.machine_readable/Debtfile.a2ml index c637206e..288e64c4 100644 --- a/.machine_readable/Debtfile.a2ml +++ b/.machine_readable/Debtfile.a2ml @@ -27,7 +27,7 @@ forgotten. ### docs-md-not-adoc - description: Docs under docs/ still in Markdown; estate policy is AsciiDoc (.adoc) except the four GitHub-required .md files - probe: git ls-files 'docs/**/*.md' 'docs/*.md' | grep -vEi '(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT|CHANGELOG)\.md$' | wc -l -- count: 2 +- count: 1 - ceiling: 1 - severity: low - policy: remediable @@ -39,7 +39,7 @@ forgotten. ### gate-scripts-without-tests - description: Scripts under scripts/ with no matching scripts/tests/-test.sh — a gate with no test has never been shown able to fail Re-baselined 2026-09-22 (round 2, issue #953): the committed count had fossilized at 31 while the tree measured 38+; set to measured 40 with a declared ceiling raise. Falls automatically as tests land. - probe: n=0; for f in $(git ls-files 'scripts/*.sh'); do b=$(basename "$f" .sh); case "$b" in *-test) continue;; esac; if [ ! -f "scripts/tests/${b}-test.sh" ] && [ ! -f "scripts/tests/${b#check-}-test.sh" ] && [ ! -f "scripts/tests/${b#run-}-test.sh" ]; then n=$((n+1)); fi; done; echo "$n" -- count: 43 +- count: 40 - ceiling: 40 - severity: high - policy: remediable diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index 43bbd654..763f7351 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -153,7 +153,7 @@ name = "AXEL Protocol" stream = "protocol" home = "2-protocols/axel/" canonical_doc = "2-protocols/axel/README.adoc" -source_hash = "sha256:3074c2eb863fe42ff4e26fc85c2520bb40da2853af90b50a1908e111a8908db2" +source_hash = "sha256:84005883477e12b0c748bc9e7d68cbb309c199e4509fbfaed27b0454e773a88d" route = "age-gating + explicit-content enforcement" [[spec]] @@ -270,7 +270,7 @@ name = "Standards Hypatia Rules" stream = "integration" home = "hypatia-rules/" canonical_doc = "hypatia-rules/README.adoc" -source_hash = "sha256:b78a413b26148b04b2de9485d31514bd2ba6461832bb741c48010a58518afa29" +source_hash = "sha256:60a367489822dda378a6f0049add96a52f511777e08868c31f9b6a062134264d" route = "the dogfooding rules that scan THIS repo (incl. drift detection)" [[spec]] diff --git a/.machine_readable/scorecards/estate-constitution.scorecard.a2ml b/.machine_readable/scorecards/estate-constitution.scorecard.a2ml index 708e6158..893ab1f8 100644 --- a/.machine_readable/scorecards/estate-constitution.scorecard.a2ml +++ b/.machine_readable/scorecards/estate-constitution.scorecard.a2ml @@ -44,6 +44,6 @@ id = "S1" text = "Known tensions SHOULD identify class, priority, containment, next action, and evidence status." system = "manual document review" status = "pass" -evidence = "0-canon/constitution/KNOWN-TENSIONS.adoc supplies all six fields for the twelve required tensions." -check = "test $(grep -c '^|.*|.*|.*|.*|.*|' 0-canon/constitution/KNOWN-TENSIONS.adoc) -eq 13" +evidence = "0-canon/constitution/KNOWN-TENSIONS.adoc supplies all six fields for the thirteen recorded tensions." +check = "test $(grep -c '^|.*|.*|.*|.*|.*|' 0-canon/constitution/KNOWN-TENSIONS.adoc) -eq 14" effects = "Omission would allow unresolved policy questions to masquerade as implementation completion." diff --git a/ULTRAPLAN-2026-09-29.adoc b/ULTRAPLAN-2026-09-29.adoc index c763aff6..64c64880 100644 --- a/ULTRAPLAN-2026-09-29.adoc +++ b/ULTRAPLAN-2026-09-29.adoc @@ -271,7 +271,7 @@ Keep the evidence, but do not treat as standards-repo implementation work unless |https://github.com/hyperpolymath/standards/issues/309[#309] |2026-08-27 |[campaign] Python residual cleanup — ~45 estate-authored .py files remain (banned) |meta:recurring |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/323[#323] |2026-08-27 |[standing] Estate CodeQL cron drift detection + 6-week budget review |cicd, meta:campaign |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/324[#324] |2026-08-27 |[campaign] Long-tail non-canonical CodeQL cron sweep (~86 files / 30 repos) |cicd, meta:campaign |Carry-forward from 2026-09-24 plan -|https://github.com/hyperpolymath/standards/issues/331[#331] |2026-09-08 |[campaign] Estate boj-build.yml sweep — repair or retire (~30 repos with malformed JSON + dead .local host + http://) |meta:campaign, refactor |Carry-forward from 2026-09-24 plan +|https://github.com/hyperpolymath/standards/issues/331[#331] |2026-09-08 |[campaign] Estate boj-build.yml sweep — repair or retire (~30 repos with malformed JSON + dead .local host + plain HTTP) |meta:campaign, refactor |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/342[#342] |2026-08-26 |audit: contractiles estate state 2026-06-02 — schema drift + trident-claim/on-disk mismatch |meta:recurring, status:needs-owner |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/343[#343] |2026-09-03 |audit: dotfile drift across estate (2026-06-02 sample) — .editorconfig / .gitignore / .gitattributes |status:needs-owner |Carry-forward from 2026-09-24 plan |https://github.com/hyperpolymath/standards/issues/348[#348] |2026-08-27 |automation: hypatia ruleset + gitbots should be able to fan-out doc + 6a2 + contractile refreshes themselves |automation, enhancement, governance |Carry-forward from 2026-09-24 plan diff --git a/docs/tier3-gate-probe.md b/docs/tier3-gate-probe.adoc similarity index 76% rename from docs/tier3-gate-probe.md rename to docs/tier3-gate-probe.adoc index f7d07a29..efb12fe6 100644 --- a/docs/tier3-gate-probe.md +++ b/docs/tier3-gate-probe.adoc @@ -1,4 +1,4 @@ -# Tier 3 gate probe += Tier 3 gate probe Positive control for ruleset 23359343 `Optimus-Branch`, armed 2026-09-22. @@ -8,13 +8,13 @@ registry, so a red result here is a fault in the gate, not in the change. What this probe is meant to establish: -1. The four required contexts (`CodeQL`, `SonarCloud Code Analysis`, +. The four required contexts (`CodeQL`, `SonarCloud Code Analysis`, `governance / Code quality + docs`, `uses ⊆ actions.lock`) all reach a terminal state on a real pull-request head. -2. Whether the `code_scanning` rule passes when Scorecard has published no +. Whether the `code_scanning` rule passes when Scorecard has published no analysis to `refs/pull/N/merge` — measured at 2 of 696 pull-ref analyses, so this is the expected case rather than an edge case. -3. That `mergeStateStatus` reflects the ruleset, now that +. That `mergeStateStatus` reflects the ruleset, now that `current_user_can_bypass` reads `never`. Delete the branch once read. The measurement lives in `dev-notes`, not here. diff --git a/scripts/tests/branch-gates-apply-test.sh b/scripts/tests/apply-branch-gates-test.sh similarity index 99% rename from scripts/tests/branch-gates-apply-test.sh rename to scripts/tests/apply-branch-gates-test.sh index 8fb0ab9d..d1bc8d2a 100755 --- a/scripts/tests/branch-gates-apply-test.sh +++ b/scripts/tests/apply-branch-gates-test.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # -# branch-gates-apply-test.sh — regression test for apply-branch-gates.sh. +# apply-branch-gates-test.sh — regression test for apply-branch-gates.sh. # # WHAT THIS PINS, AND WHY A PASSING SUITE WOULD NOT BE ENOUGH # The defect this script exists to prevent is a VACUOUS GATE: a @@ -15,7 +15,7 @@ # removed, and the suite must go RED. A mutant that survives means the # corresponding control is decorative. # -# Run: bash scripts/tests/branch-gates-apply-test.sh +# Run: bash scripts/tests/apply-branch-gates-test.sh set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/apply-protection-floor-test.sh similarity index 99% rename from scripts/tests/protection-floor-test.sh rename to scripts/tests/apply-protection-floor-test.sh index 9d4c2edd..cacabdcd 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/apply-protection-floor-test.sh @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MPL-2.0 # Regression suite for scripts/apply-protection-floor.sh # -# House conventions, shared with scripts/tests/branch-gates-apply-test.sh: +# House conventions, shared with scripts/tests/apply-branch-gates-test.sh: # * a `gh` shim maps an API path to a fixture by KEY=$(tr '/?&=' '____') # * A MISSING FIXTURE IS A FREE ASSERTION that the path is never queried: the shim # exits 1, so any code reaching for an unplanned endpoint fails loudly. diff --git a/scripts/tests/build-registry-test.sh b/scripts/tests/build-registry-test.sh index 14d2d23b..96b4fea3 100755 --- a/scripts/tests/build-registry-test.sh +++ b/scripts/tests/build-registry-test.sh @@ -41,8 +41,14 @@ cp "$ROOT/scripts/build-registry.sh" scripts/build-registry.sh REG=".machine_readable/REGISTRY.a2ml" TOP="TOPOLOGY.adoc" +# Carry the proposed artefacts too: a local regeneration must be testable +# before committing. Stage only in this throwaway clone so the mutation +# controls below restore these exact inputs with git checkout. +cp "$ROOT/$REG" "$REG" +cp "$ROOT/$TOP" "$TOP" +git add -- "$REG" "$TOP" -echo "== the committed artefacts are in sync with the committed tree ==" +echo "== the proposed artefacts are in sync with the committed source tree ==" out="$(bash scripts/build-registry.sh --check 2>&1)"; rc=$? if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "OK:"; then ok "--check reports OK on a clean checkout" diff --git a/scripts/tests/wave3-scorecards-test.sh b/scripts/tests/build-scorecards-test.sh similarity index 100% rename from scripts/tests/wave3-scorecards-test.sh rename to scripts/tests/build-scorecards-test.sh diff --git a/scripts/tests/triage-2026-09-24-apply-test.sh b/scripts/tests/triage-2026-09-24-apply-test.sh new file mode 100755 index 00000000..0232268f --- /dev/null +++ b/scripts/tests/triage-2026-09-24-apply-test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Exercise the triage applier with an offline gh stub; never contact GitHub. +# Keep shell startup hooks from replacing the offline command stubs. +unset BASH_ENV ENV +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" "$TMP/temp with spaces" +export TRIAGE_TEST_LOG="$TMP/calls.jsonl" +export TMPDIR="$TMP/temp with spaces" +cat > "$TMP/bin/gh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +case "$1 $2" in + 'auth status') exit "${TRIAGE_TEST_AUTH_RC:-0}" ;; + 'label list') printf '%s\n' scope:estate scope:repo priority:p1 ;; + 'issue view') + if [[ " $* " == *' --json title '* ]]; then + echo 'Document the standard' + else + case "$3" in 89|913|956) echo OPEN ;; *) echo CLOSED ;; esac + fi ;; + 'api repos/hyperpolymath/standards/issues/1/comments') echo 0 ;; + 'api -X'|'issue comment'|'issue close') + jq -cn --args '$ARGS.positional' -- "$@" >> "$TRIAGE_TEST_LOG" + # Read payloads during the call, before the applier's cleanup. + args=("$@") + for ((i=0; i<${#args[@]}; i++)); do + case "${args[i]}" in + --input) jq -e '.labels | length > 0' "${args[i+1]}" >/dev/null ;; + --body-file) test -s "${args[i+1]}" ;; + esac + done ;; + *) echo "Unexpected gh invocation: $*" >&2; exit 2 ;; +esac +STUB +cat > "$TMP/bin/sleep" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB +chmod +x "$TMP/bin/gh" "$TMP/bin/sleep" +export PATH="$TMP/bin:$PATH" +cd "$ROOT" + +: > "$TRIAGE_TEST_LOG" +DRY_RUN=1 bash scripts/triage-2026-09-24-apply.sh > "$TMP/dry.log" +test ! -s "$TRIAGE_TEST_LOG" +test -z "$(find "$TMPDIR" -mindepth 1 -print -quit)" +grep -qF '[dry-run] gh api -X POST' "$TMP/dry.log" +grep -qF '[dry-run] gh issue comment 913' "$TMP/dry.log" +grep -qF '[dry-run] gh api -X PATCH' "$TMP/dry.log" +echo 'PASS: dry run previews commands, makes no write calls and cleans temporary files' + +for run in 1 2; do + : > "$TRIAGE_TEST_LOG" + DRY_RUN=0 bash scripts/triage-2026-09-24-apply.sh > "$TMP/run.log" + jq -se ' + ([.[] | select(.[0:3] == ["api", "-X", "POST"])] | length) == 2 and + ([.[] | select(.[0:2] == ["issue", "close"])] | length) == 1 and + ([.[] | select(.[0:2] == ["issue", "comment"])] | length) == 1 and + ([.[] | select(.[0:3] == ["api", "-X", "PATCH"])] | length) == 1 and + any(.[]; .[0:3] == ["issue", "close", "956"] and + any(.[]; startswith("Closed as fixed by #1034") and contains("\n\n"))) and + any(.[]; .[0:3] == ["api", "-X", "PATCH"] and + any(.[]; startswith("description=Canonical standards, specifications")) and + index("topics[]=policy-as-code") != null) + ' "$TRIAGE_TEST_LOG" >/dev/null + jq -sr '[.[] | select(.[0:3] == ["api", "-X", "POST"])][0][-1]' \ + "$TRIAGE_TEST_LOG" > "$TMP/path-$run" + test -z "$(find "$TMPDIR" -mindepth 1 -print -quit)" +done +! cmp -s "$TMP/path-1" "$TMP/path-2" +echo 'PASS: argument boundaries and payloads survive spaces; runs use distinct, cleaned directories' + +: > "$TRIAGE_TEST_LOG" +if TRIAGE_TEST_AUTH_RC=1 DRY_RUN=0 bash scripts/triage-2026-09-24-apply.sh > "$TMP/fail.log"; then + echo 'FAIL: authentication failure was ignored' >&2; exit 1 +fi +test ! -s "$TRIAGE_TEST_LOG" +test -z "$(find "$TMPDIR" -mindepth 1 -print -quit)" +echo 'PASS: preflight failure makes no writes and cleans its temporary directory' diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/update-actions-lock-test.sh similarity index 100% rename from scripts/tests/actions-lock-update-test.sh rename to scripts/tests/update-actions-lock-test.sh diff --git a/scripts/triage-2026-09-24-apply.sh b/scripts/triage-2026-09-24-apply.sh index f5d4afea..2d9c3fee 100755 --- a/scripts/triage-2026-09-24-apply.sh +++ b/scripts/triage-2026-09-24-apply.sh @@ -28,7 +28,9 @@ set -uo pipefail cd "$(git rev-parse --show-toplevel)" REPO=hyperpolymath/standards DRY=${DRY_RUN:-0} -run() { if [ "$DRY" = "1" ]; then echo "[dry-run] $*"; else eval "$@"; fi; } +run() { if [ "$DRY" = "1" ]; then echo "[dry-run] $*"; else "$@" >/dev/null; fi; } +TRIAGE_TMP=$(mktemp -d) || exit 1 +trap 'rm -rf "$TRIAGE_TMP"' EXIT pause() { [ "$DRY" = "1" ] || sleep 0.15; } echo "== 0. preflight" @@ -268,8 +270,8 @@ while IFS='|' read -r num disp; do done [ ${#want[@]} -eq 0 ] && continue json=$(printf '%s\n' "${want[@]}" | jq -R . | jq -s .) - printf '{"labels":%s}' "$json" > /tmp/arena-label-body.json - if run "gh api -X POST repos/$REPO/issues/$num/labels --input /tmp/arena-label-body.json >/dev/null 2>&1"; then + printf '{"labels":%s}' "$json" > "$TRIAGE_TMP/arena-label-body.json" + if run gh api -X POST "repos/$REPO/issues/$num/labels" --input "$TRIAGE_TMP/arena-label-body.json" 2>/dev/null; then labelled=$((labelled+1)) else echo " label POST failed on #$num - continuing" @@ -287,67 +289,67 @@ close_if_open() { # $1=number $2=reason-tag local state=$(gh issue view "$n" -R "$REPO" --json state --jq .state 2>/dev/null) || return [ "$state" = "OPEN" ] || { echo " #$n already $state - skipping"; return; } if [ "$DRY" = "1" ]; then echo "[dry-run] close #$n"; else - gh issue close "$n" -R "$REPO" --comment "$(cat "/tmp/arena-close-$n.md")" >/dev/null \ + gh issue close "$n" -R "$REPO" --comment "$(cat "$TRIAGE_TMP/arena-close-$n.md")" >/dev/null \ && echo " closed #$n" || echo " close FAILED #$n" fi pause } -cat > /tmp/arena-close-956.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-956.md" <<'EOF' Closed as fixed by #1034 (2026-09-23). Verified via the rulesets API on 2026-09-24: the active ruleset `main gate: append-only + required checks + signatures + scanning` enforces 21 required status contexts, required signatures, and code-scanning thresholds on the default branch. Every gate this repo ships is now actually required here. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-637.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-637.md" <<'EOF' Closed as absorbed: #787 (Owner decision sheet D1-D72) is "one answerable place for #637 + #715 + #709 + #658", so this register is superseded by it. Rulings continue on #787. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cp /tmp/arena-close-637.md /tmp/arena-close-709.md -cp /tmp/arena-close-637.md /tmp/arena-close-715.md +cp "$TRIAGE_TMP/arena-close-637.md" "$TRIAGE_TMP/arena-close-709.md" +cp "$TRIAGE_TMP/arena-close-637.md" "$TRIAGE_TMP/arena-close-715.md" -cat > /tmp/arena-close-784.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-784.md" <<'EOF' Closed as answered: the census this issue was waiting for is #968 - 39 repos with step-level actions.lock desync (plus #969 for the job-level population). The hypothesis is no longer open. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-808.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-808.md" <<'EOF' Closed as subsumed by #913: the 2026-09-22 census measured this same defect population at 76 `uses: ../../` refs, with acceptance criteria. The mis-triage knowledge from this issue (failure + 0 jobs + run name == path, indistinguishable from callee-lockfile poisoning) is preserved in a comment on #913 before this close. The fix continues under #913. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-708.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-708.md" <<'EOF' Closed as fixed: the current `lockfile-drift-detect.yml` implements rc-honesty (1 = drift, 2 = usage/env error), per-repo slugs (no more anonymised `_w`), and counts tab-delimited data rows only (banner lines no longer counted as drift) - its comments cite this issue as the resolved reference. "Has only ever run once" is stale: the sweep runs weekly. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-658.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-658.md" <<'EOF' Closed as superseded by the 2026-09-22 ruling (Deno banned outright; Bun is tier 1). The "migrate Deno to Bun" premise is replaced by Deno retirement: #919 sizes it (95 `deno task` definitions across 23 files in the 11 ledgered repos) and #926 covers the k9-coordination harness. Preserved data from this issue: 30 deno.json locations assessed, of which 18 were blocked on npm packages that do not exist. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-920.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-920.md" <<'EOF' Closed as fixed: `rhodium-standard-repositories/.github/workflows/language-policy.yml` line 116 now reads "the JS runtime is Bun per the 2026-09-22 ruling, which banned Deno as well" (committed with the 2026-09-24 intake/canon changes). Remaining estate copies of the old comment sit in the template-sync population tracked under #659. (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-close-927.md <<'EOF' +cat > "$TRIAGE_TMP/arena-close-927.md" <<'EOF' Closed as fixed: `:source-repo:` now points at https://github.com/hyperpolymath/standards (committed with the 2026-09-24 intake/canon changes). (ULTRAPLAN-2026-09-24.adoc, part 5.2) EOF -cat > /tmp/arena-913-preserve.md <<'EOF' +cat > "$TRIAGE_TMP/arena-913-preserve.md" <<'EOF' Preserved from #808 (subsumed by this issue, closed 2026-09-24): why the malformed `uses: ../../` refs were invisible. A workflow that fails to parse produces the triple `conclusion=failure`, **0 jobs**, and a run `name` equal to its file *path* - the same triple produced by callee-lockfile poisoning. These were repeatedly mis-triaged as lockfile faults; they never parsed. Also: do not use `gh actions-lock` rewrite mode to fix these refs - it previously invented `uses: $/.github/actions/...` refs that fail the same way. EOF if [ "$(gh issue view 913 -R "$REPO" --json state --jq .state 2>/dev/null)" = "OPEN" ]; then - run "gh issue comment 913 -R $REPO --body-file /tmp/arena-913-preserve.md >/dev/null" \ + run gh issue comment 913 -R "$REPO" --body-file "$TRIAGE_TMP/arena-913-preserve.md" \ || echo " (913 preservation comment skipped/failed - continuing)" fi @@ -367,7 +369,11 @@ echo "2. closes done" # 3. DESCRIPTION + TOPICS (ULTRAPLAN part 7) # --------------------------------------------------------------------------- DESC='Canonical standards, specifications and governance for the Hyperpolymath estate: policy-as-code, machine-readable specs (A2ML/DEED), and the reusable CI/CD + security canon for a 500+ repository software estate.' -run "gh api -X PATCH repos/$REPO -f description=\"$DESC\" -f topics[]=standards -f topics[]=specification -f topics[]=\"policy-as-code\" -f topics[]=governance -f topics[]=compliance -f topics[]=\"machine-readable\" -f topics[]=documentation -f topics[]=\"open-standards\" -f topics[]=deed -f topics[]=k9 -f topics[]=\"epistemic-computing\" -f topics[]=hyperpolymath >/dev/null" +run gh api -X PATCH "repos/$REPO" -f "description=$DESC" \ + -f 'topics[]=standards' -f 'topics[]=specification' -f 'topics[]=policy-as-code' \ + -f 'topics[]=governance' -f 'topics[]=compliance' -f 'topics[]=machine-readable' \ + -f 'topics[]=documentation' -f 'topics[]=open-standards' -f 'topics[]=deed' \ + -f 'topics[]=k9' -f 'topics[]=epistemic-computing' -f 'topics[]=hyperpolymath' echo "3. description + topics set" echo "done. (DRY_RUN=$DRY)" diff --git a/tests/test_governance_reusable_shape.sh b/tests/test_governance_reusable_shape.sh index 5f3b062a..ab4ed7a9 100755 --- a/tests/test_governance_reusable_shape.sh +++ b/tests/test_governance_reusable_shape.sh @@ -29,7 +29,10 @@ ok() { echo "PASS: $1"; pass=$((pass+1)); } bad() { echo "FAIL: $1"; fail=$((fail+1)); } # job block extractor: lines of job (from " :" to the next " :") -job_block() { awk -v id="$1" '$0==" "id":" {p=1; print; next} p && /^ [a-z][a-z-]*:$/ {exit} p {print}' "$F"; } +job_block() { awk -v id="$1" '$0==" "id":" {p=1; print; next} p && /^ [a-zA-Z_][a-zA-Z0-9_-]*:$/ {exit} p {print}' "$F"; } + +# Restrict job discovery to jobs: so workflow_call is not counted as a job. +job_ids() { sed -n '/^jobs:/,$p' "$F" | grep -oP '^ \K[a-zA-Z_][a-zA-Z0-9_-]*(?=:$)'; } # 1. context freeze FROZEN="Check Workflow Staleness @@ -46,14 +49,15 @@ Actions lockfile verify Trusted-base reduction policy Licence consistency Exemption ratchet -Debt ratchet" +Debt ratchet +UUID v7 conformance" ACTUAL=$(grep -P '^ name: ' "$F" | sed 's/^ name: //') if [ "$(printf '%s' "$FROZEN" | sort)" = "$(printf '%s' "$ACTUAL" | sort)" ]; then ok "job names match the frozen context list ($(printf '%s\n' "$ACTUAL" | wc -l) jobs)" else bad "job names drifted from the frozen list — renames create phantom contexts estate-wide"; diff <(printf '%s\n' "$FROZEN" | sort) <(printf '%s\n' "$ACTUAL" | sort); fi # 2. runs-on if grep -q 'runs-on: ubuntu-latest' "$F"; then bad "hardcoded runs-on present (inputs.runs-on ignored)"; else ok "every job uses inputs.runs-on"; fi -njobs=$(grep -cP '^ [a-z][a-z-]*:$' "$F"); nro=$(grep -c 'runs-on: ${{ inputs.runs-on }}' "$F") +njobs=$(job_ids | wc -l); nro=$(grep -c 'runs-on: ${{ inputs.runs-on }}' "$F") [ "$njobs" -eq "$nro" ] && ok "runs-on count ($nro) equals job count ($njobs)" || bad "runs-on count $nro != job count $njobs" # 3. actions-lock-verify job @@ -72,7 +76,7 @@ printf '%s' "$W" | grep -q 'LOCK_SCRIPT' && bad "workflow-lint still copies the # 5. continue-on-error allow-list ALLOWED="language-policy quality workflow-lint" viol=0 -for id in $(grep -oP '^ \K[a-z][a-z-]*(?=:$)' "$F"); do +for id in $(job_ids); do case " $ALLOWED " in *" $id "*) continue;; esac if job_block "$id" | grep -q 'continue-on-error: true'; then echo " continue-on-error in gate job: $id"; viol=1; fi done diff --git a/tools/yaml-comment-proof/.gitignore b/tools/yaml-comment-proof/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/tools/yaml-comment-proof/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tools/yaml-comment-proof/README.adoc b/tools/yaml-comment-proof/README.adoc new file mode 100644 index 00000000..e1b9f1c7 --- /dev/null +++ b/tools/yaml-comment-proof/README.adoc @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += yaml-comment-proof +:toc: macro + +The comment-preservation proof that `3-practice/YAML-POLICY.adoc` §4 requires +before Y-2 (yq for writing) or Y-3 (KYAML) can come into force. Tracks +standards#1021. + +== Run + +[source,bash] +---- +cd tools/yaml-comment-proof +bun install --frozen-lockfile +bun prove.js ../../.github/workflows/*.yml +---- + +`prove.js` rewrites *copies* in a temp dir; it never edits its arguments. +Exit 0 = every arm clean, 1 = a rewriter lost/moved a comment or was not +idempotent, 2 = the harness itself failed a control (do not read the table). + +== What it checks + +`oracle.js` records every comment as *(node path, position, text)* using +`yaml` (eemeli) — deliberately a different parser from go-yaml, which yq and +kubectl share. Position is taken from source offsets, not AST comment slots, +because the AST hangs the same comment on different slots in block vs flow +syntax. A comment whose text survives but whose node changes is *MOVED*, which +is a failure: parse-clean and "same number of `#` lines" are not the bar. + +Before measuring anything the runner proves itself: + +* calibration pair (`fixtures/calibration.*`, one comment per position class): + block → hand-written KYAML must be PRESERVED; +* a moved comment and a dropped comment on that pair must go red; +* on the real corpus, a dropped-pin mutant and a moved-pin mutant must be + killed, each naming file and path; +* zero files or zero comments is exit 2, never a pass. + +Arms: `identity` (`yq -i '.'`), `pin-bump` (`yq -i` rewriting every 40-hex +`uses:` pin), `kyaml` (`yq -o kyaml`). Each is run twice; comments are compared +original → pass 1 *and* original → pass 2. kubectl's KYAML printer is not +covered. + +== Measured — standards `bd9313a6`, 56 workflows, yq v4.53.3 + +2186 comments, of which 183 are trailing pin comments on `uses:` (equal to the +independent grep count — the oracle's positive control). + +|=== +| arm | preserved | moved | dropped | pass-2 drift | idempotent | blank lines lost | verdict + +| identity | 2186/2186 | 0 | 0 | 10 | 55/56 | 567 | FAIL +| pin-bump | 2186/2186 | 0 | 0 | 10 | 55/56 | 567 | FAIL +| kyaml | 2186/2186 | 0 | 0 | 0 | 56/56 | 813 | PASS +|=== + +The only loss: in `tag-ruleset-canon.yml` yq relocates a 10-line comment block +(the R-14 App-identity rationale) on its *second* run, from before +`steps/1/name` to before `steps/1/id` — inside the step it used to precede. +Pass 1 is clean, so a single-run check would have passed it. + +Reported, not failed on: blank-line loss; and one data difference +(`k9-contractile.yml`) that is the two parsers disagreeing about the +*original* — a `run: |` block scalar at EOF with no final newline — not a +rewriter defect. + +What the kyaml PASS does and does not say: + +* It covers block YAML → KYAML *conversion* and KYAML re-emission by + `yq -o kyaml`: no comment lost or moved, stable on re-run — including + `tag-ruleset-canon.yml`, where block re-emission drifts. +* It does *not* cover editing a KYAML file with plain `yq -i`, whose default + output is block YAML. That Y-2-on-Y-3 case is for #1022/#1023. +* It is *not* a KEP-5295 conformance verdict on yq's dialect (#1022). Observed: + yq's output has no `---` header and pulls an end-of-file comment inside the + closing brace. + +Status: implemented and tested. Not wired into CI. diff --git a/tools/yaml-comment-proof/bun.lock b/tools/yaml-comment-proof/bun.lock new file mode 100644 index 00000000..a536b887 --- /dev/null +++ b/tools/yaml-comment-proof/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "yaml-comment-proof", + "dependencies": { + "yaml": "2.9.1", + }, + }, + }, + "packages": { + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + } +} diff --git a/tools/yaml-comment-proof/fixtures/calibration.block.yml b/tools/yaml-comment-proof/fixtures/calibration.block.yml new file mode 100644 index 00000000..dd5fa812 --- /dev/null +++ b/tools/yaml-comment-proof/fixtures/calibration.block.yml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# Calibration fixture: one comment in every position class. +name: calibration +on: + push: + branches: [main] +permissions: read-all +# before-entry comment on jobs +jobs: + build: + runs-on: ubuntu-latest # key-trailing runner + steps: + # before the first sequence item + - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v4.2.2 + # between sequence items + - name: test + run: echo ok # trailing run +# end of file diff --git a/tools/yaml-comment-proof/fixtures/calibration.kyaml.yml b/tools/yaml-comment-proof/fixtures/calibration.kyaml.yml new file mode 100644 index 00000000..76fc0fd2 --- /dev/null +++ b/tools/yaml-comment-proof/fixtures/calibration.kyaml.yml @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MPL-2.0 +# Calibration fixture: one comment in every position class. +--- +{ + name: "calibration", + on: { + push: { + branches: ["main"], + }, + }, + permissions: "read-all", + # before-entry comment on jobs + jobs: { + build: { + "runs-on": "ubuntu-latest", # key-trailing runner + steps: [ + # before the first sequence item + { + uses: "actions/checkout@0123456789abcdef0123456789abcdef01234567", # v4.2.2 + }, + # between sequence items + { + name: "test", + run: "echo ok", # trailing run + }, + ], + }, + }, +} +# end of file diff --git a/tools/yaml-comment-proof/oracle.js b/tools/yaml-comment-proof/oracle.js new file mode 100644 index 00000000..1e244121 --- /dev/null +++ b/tools/yaml-comment-proof/oracle.js @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// Comment-association oracle for standards#1021 (YAML-POLICY §4). +// +// Every comment is recorded as (node path, position, text) and two documents are +// compared on that triple. A comment that survives but re-attaches to another +// node is a FAILURE (MOVED), not a pass: presence-only comparison is what #1021 +// rejects. +// +// Parser: eemeli/yaml, deliberately independent of go-yaml (which yq and kubectl +// share), so a comment-handling bug in the rewriter cannot cancel itself out in +// the checker. +// +// Association is by SOURCE POSITION, not by the AST's comment slots. Measured on +// fixtures/calibration.*: the AST hangs the same comment on different slots in +// block vs flow syntax (a pin comment is `trailing steps/0/uses` in block style +// and `after-collection steps/0` in KYAML), so slot-based association would call +// every KYAML rewrite a move. The positional rule is syntax-independent: +// trailing — the comment shares a line with the END of a scalar that precedes +// it; attach to the last such scalar. +// before — otherwise, attach to the first scalar (key or value) starting +// after it. +// document-end — no scalar follows. +import { parseDocument, Parser, isMap, isSeq, isPair, isScalar } from "yaml"; + +/** Return a lookup from UTF-16 source offsets to zero-based line numbers. */ +function lineIndex(src) { + const starts = [0]; + for (let i = 0; i < src.length; i++) if (src[i] === "\n") starts.push(i + 1); + return (off) => { + let lo = 0, hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= off) lo = mid; else hi = mid - 1; + } + return lo; + }; +} + +/** + * Return {offset, text} comments in source order, deduplicated by UTF-16 offset. + * Text excludes the leading # and surrounding whitespace. + */ +function commentTokens(src) { + const seen = new Map(); + const walk = (x) => { + if (x == null || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(walk); return; } + if (x.type === "comment" && typeof x.source === "string") { + seen.set(x.offset, x.source.replace(/^#/, "").trim()); + } + for (const v of Object.values(x)) if (v && typeof v === "object") walk(v); + }; + for (const tok of new Parser().parse(src)) walk(tok); + return [...seen.entries()].sort((a, b) => a[0] - b[0]).map(([offset, text]) => ({ offset, text })); +} + +/** + * Return ranged keys and non-collection nodes (including aliases) in source order. + * Paths join mapping keys and zero-based sequence indices with / without escaping; + * a root leaf uses . The [start, end) ranges use UTF-16 source offsets. + */ +function scalars(doc) { + const out = []; + const walk = (node, path) => { + if (node == null) return; + if (isMap(node) || isSeq(node)) { + node.items.forEach((item, i) => { + if (isPair(item)) { + const k = isScalar(item.key) ? String(item.key.value) : String(item.key); + const p = [...path, k]; + if (item.key?.range) out.push({ path: p.join("/"), start: item.key.range[0], end: item.key.range[1] }); + walk(item.value, p); + } else { + walk(item, [...path, String(i)]); + } + }); + } else if (node.range) { + out.push({ path: path.join("/") || "", start: node.range[0], end: node.range[1] }); + } + }; + walk(doc.contents, []); + return out.sort((a, b) => a.start - b.start || a.end - b.end); +} + +/** + * Return {path, kind, text} comments in source order, or [] if there are none. + * Text excludes the leading # and surrounding whitespace. A comment is trailing + * when a preceding node ends on its line; otherwise it is before the next node, + * or document-end at if no node follows. Paths use unescaped / separators + * between mapping keys and zero-based sequence indices. + * Throws an Error prefixed with "parse:" for the first YAML 1.2 parse error. + */ +export function comments(src) { + const doc = parseDocument(src, { version: "1.2" }); + if (doc.errors.length) throw new Error(`parse: ${doc.errors[0].message}`); + const lineOf = lineIndex(src); + const nodes = scalars(doc); + return commentTokens(src).map(({ offset, text }) => { + const line = lineOf(offset); + let trail = null; + for (const n of nodes) if (n.end <= offset && lineOf(Math.max(n.end - 1, n.start)) === line) trail = n; + if (trail) return { path: trail.path, kind: "trailing", text }; + const next = nodes.find((n) => n.start > offset); + return next ? { path: next.path, kind: "before", text } : { path: "", kind: "document-end", text }; + }); +} + +const key = (c) => `${c.path}\u0000${c.kind}\u0000${c.text}`; + +/** + * Compare comments in the original and replacement YAML, counting duplicates. + * Return {total, preserved, dropped, moved, added}: total counts original comments; + * preserved counts matching path, kind and normalised text. Unmatched originals + * pair with the first remaining replacement comment of the same text as moved + * {from, to} records; unpaired originals are dropped and replacements are added. + * Propagates parse errors from comments() for either source. + */ +export function compare(origSrc, newSrc) { + const a = comments(origSrc); + const b = comments(newSrc); + const pool = new Map(); + for (const c of b) pool.set(key(c), (pool.get(key(c)) ?? 0) + 1); + const missing = []; + let preserved = 0; + for (const c of a) { + const k = key(c); + if (pool.get(k) > 0) { pool.set(k, pool.get(k) - 1); preserved += 1; } else missing.push(c); + } + const extra = []; + for (const c of b) { + const k = key(c); + if (pool.get(k) > 0) { pool.set(k, pool.get(k) - 1); extra.push(c); } + } + // Same text at a different (path, kind) is MOVED; text found nowhere is DROPPED. + const dropped = [], moved = []; + for (const c of missing) { + const j = extra.findIndex((e) => e.text === c.text); + if (j >= 0) { moved.push({ from: c, to: extra[j] }); extra.splice(j, 1); } else dropped.push(c); + } + return { total: a.length, preserved, dropped, moved, added: extra }; +} diff --git a/tools/yaml-comment-proof/package.json b/tools/yaml-comment-proof/package.json new file mode 100644 index 00000000..58e44b3f --- /dev/null +++ b/tools/yaml-comment-proof/package.json @@ -0,0 +1,7 @@ +{ + "name": "yaml-comment-proof", + "private": true, + "type": "module", + "description": "standards#1021 — comment-preservation proof for YAML rewriters (YAML-POLICY §4)", + "dependencies": { "yaml": "2.9.1" } +} diff --git a/tools/yaml-comment-proof/prove.js b/tools/yaml-comment-proof/prove.js new file mode 100644 index 00000000..b7efd3b2 --- /dev/null +++ b/tools/yaml-comment-proof/prove.js @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MPL-2.0 +// standards#1021 — comment-preservation proof for YAML rewriters (YAML-POLICY §4). +// +// bun prove.js +// +// Runs three rewrite arms over copies of the given files, never the files +// themselves, and reports per arm: comments preserved/total, moved, dropped, +// idempotency (pass 2 byte-equal to pass 1), data equality, and blank-line loss +// (reported, not failed on). +// +// identity yq -i '.' — the Y-2 no-op rewrite +// pin-bump yq -i '... .uses |= sub()' — a realistic Y-2 edit +// kyaml yq -o kyaml '.' — the Y-3 conversion +// +// kubectl's KYAML printer is NOT covered by this harness. +// +// A rewriter that is clean on pass 1 can still move comments on pass 2 (measured: +// yq relocates a comment block in tag-ruleset-canon.yml only on its second run), +// so comments are also compared original -> pass 2 ("pass-2 drift"). +// +// data-equal compares the parsed value of original and pass 1 with THIS parser. +// It is reported, not part of the verdict: where it differs the cause may be the +// two parsers disagreeing about the ORIGINAL (measured: a clip block scalar at +// EOF with no final newline), which is not a rewriter defect. +// +// Exit 0: every arm preserves every comment on both passes and is idempotent. +// Exit 1: at least one arm lost, moved or added a comment, or was not idempotent. +// Exit 2: the harness itself is not trustworthy — a calibration control or a +// mutant was not detected, or there was nothing to measure. +import { compare, comments } from "./oracle.js"; +import { parse } from "yaml"; +import { mkdtempSync, readFileSync, writeFileSync, copyFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, basename, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const scratch = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "yaml-comment-proof-")); +process.once("exit", () => rmSync(scratch, { recursive: true, force: true })); +const die = (msg) => { console.error(`HARNESS INVALID: ${msg}`); process.exit(2); }; + +function readInput(f) { + try { return readFileSync(f, "utf8"); } + catch (e) { die(`cannot read input ${f}: ${e.message}`); } +} + +function yq(args, input) { + const r = Bun.spawnSync(["yq", ...args], { stdin: input === undefined ? "ignore" : Buffer.from(input) }); + if (r.exitCode !== 0) throw new Error(`yq ${args.join(" ")}: ${r.stderr.toString().trim()}`); + return r.stdout.toString(); +} +function yqInPlace(expr, src) { + const f = join(scratch, "inplace.yml"); + writeFileSync(f, src); + yq(["-i", expr, f]); + return readFileSync(f, "utf8"); +} +const PIN = "f".repeat(40); +const ARMS = { + identity: (s) => yqInPlace(".", s), + "pin-bump": (s) => yqInPlace(`(.jobs[].steps[]? | select(.uses != null) | .uses) |= sub("@[0-9a-f]{40}$"; "@${PIN}")`, s), + kyaml: (s) => yq(["-p", "yaml", "-o", "kyaml", "."], s), +}; +const dataOf = (s) => JSON.stringify(parse(s, { version: "1.2" })); +const blanks = (s) => s.split("\n").filter((l) => l.trim() === "").length; + +try { console.log(`yq: ${yq(["--version"]).trim()}`); } +catch (e) { die(`yq version check failed: ${e.message}`); } + +// ── 1. Calibration: the oracle must pass a known-good pair and fail two known-bad ones. +{ + const block = readInput(join(here, "fixtures/calibration.block.yml")); + const kyaml = readInput(join(here, "fixtures/calibration.kyaml.yml")); + const good = compare(block, kyaml); + if (good.total < 9 || good.preserved !== good.total) die(`calibration pair not PRESERVED (${good.preserved}/${good.total})`); + const moved = block.replace("# before-entry comment on jobs\njobs:", "jobs:").replace("permissions:", "# before-entry comment on jobs\npermissions:"); + const m = compare(block, moved); + if (m.moved.length !== 1 || m.moved[0].from.path !== "jobs") die("calibration MOVED control not detected"); + const dropped = block.replace(" # v4.2.2", ""); + const d = compare(block, dropped); + if (d.dropped.length !== 1 || d.dropped[0].path !== "jobs/build/steps/0/uses") die("calibration DROPPED control not detected"); + console.log(`calibration: pair ${good.preserved}/${good.total} PRESERVED; moved control red; dropped control red`); +} + +// ── 2. Corpus. +const files = process.argv.slice(2); +if (files.length === 0) die("no files given"); +const corpus = []; +for (const f of files) { + const src = readInput(f); + try { corpus.push({ f, src, cs: comments(src) }); } + catch (e) { console.log(`skip (original does not parse): ${f}: ${e.message}`); } +} +const totalComments = corpus.reduce((n, x) => n + x.cs.length, 0); +const pinComments = corpus.reduce((n, x) => n + x.cs.filter((c) => c.kind === "trailing" && /\/uses$/.test(c.path)).length, 0); +if (corpus.length === 0 || totalComments === 0) die(`nothing to measure (files=${corpus.length}, comments=${totalComments})`); +console.log(`corpus: ${corpus.length} files, ${totalComments} comments, ${pinComments} trailing pin comments on uses:`); + +// ── 3. Mutants on the real corpus: each must be caught, naming file and path. +{ + const host = corpus.find((x) => x.cs.some((c) => c.kind === "trailing" && /\/uses$/.test(c.path))); + if (!host) die("no file with a trailing pin comment to mutate"); + const lines = host.src.split("\n"); + const i = lines.findIndex((l) => /^\s*-?\s*uses:\s*\S+@[0-9a-f]{40}\s+#/.test(l)); + const j = lines.findIndex((l, k) => k > i && /^\s+[\w-]+:\s*\S/.test(l) && !l.includes("#")); + if (i < 0 || j < 0) die("mutant sites not found"); + const cm = lines[i].slice(lines[i].indexOf(" #")); + const drop = lines.slice(); drop[i] = lines[i].slice(0, lines[i].indexOf(" #")); + const move = drop.slice(); move[j] = lines[j] + cm; + for (const [name, mut, want] of [["dropped", drop.join("\n"), "dropped"], ["moved", move.join("\n"), "moved"]]) { + if (mut === host.src) die(`mutant ${name} is identical to its source`); + try { yq(["."], mut); comments(mut); } catch (e) { die(`mutant ${name} does not parse: ${e.message}`); } + const r = compare(host.src, mut); + const hit = r[want][0]; + if (!hit) die(`mutant ${name} SURVIVED on ${host.f}`); + const at = want === "moved" ? `${hit.from.path} -> ${hit.to.path}` : hit.path; + console.log(`mutant ${name}: killed — ${basename(host.f)} ${at}`); + } +} + +// ── 4. Arms. +let red = false; +const rows = []; +const losses = []; +for (const [arm, run] of Object.entries(ARMS)) { + const t = { total: 0, preserved: 0, moved: 0, dropped: 0, added: 0, drift: 0, idem: 0, data: 0, blank: 0, errors: 0 }; + for (const { f, src } of corpus) { + try { + const p1 = run(src), p2 = run(p1); + const r = compare(src, p1); + t.total += r.total; t.preserved += r.preserved; + t.moved += r.moved.length; t.dropped += r.dropped.length; t.added += r.added.length; + const r2 = compare(src, p2); + t.drift += r2.moved.length + r2.dropped.length + r2.added.length; + for (const x of r2.moved) losses.push({ arm, f, kind: "pass-2 moved", path: `${x.from.path} (${x.from.kind}) -> ${x.to.path} (${x.to.kind})`, text: x.from.text }); + for (const x of r2.dropped) losses.push({ arm, f, kind: "pass-2 dropped", ...x }); + if (p1 === p2) t.idem++; + if (arm !== "pin-bump" && dataOf(src) === dataOf(p1)) t.data++; + t.blank += Math.max(0, blanks(src) - blanks(p1)); + for (const x of r.dropped) losses.push({ arm, f, kind: "dropped", ...x }); + for (const x of r.moved) losses.push({ arm, f, kind: "moved", path: `${x.from.path} (${x.from.kind}) -> ${x.to.path} (${x.to.kind})`, text: x.from.text }); + for (const x of r.added) losses.push({ arm, f, kind: "added", ...x }); + } catch (e) { t.errors++; losses.push({ arm, f, error: e.message }); continue; } + } + const ok = t.preserved === t.total && t.added === 0 && t.drift === 0 && t.idem === corpus.length && t.errors === 0; + if (!ok) red = true; + rows.push([arm, `${t.preserved}/${t.total}`, t.moved, t.dropped, t.added, t.drift, `${t.idem}/${corpus.length}`, + arm === "pin-bump" ? "n/a" : `${t.data}/${corpus.length}`, t.blank, t.errors, ok ? "PASS" : "FAIL"]); +} +console.log("\n| arm | comments preserved | moved | dropped | added | pass-2 drift | idempotent | data-equal | blank lines lost | errors | verdict |"); +console.log("|---|---|---|---|---|---|---|---|---|---|---|"); +for (const r of rows) console.log(`| ${r.join(" | ")} |`); +if (losses.length) { + console.log(`\nfirst losses (of ${losses.length}):`); + for (const l of losses.slice(0, 40)) console.log(` [${l.arm}] ${l.kind ?? "error"} ${basename(l.f)} ${l.path ?? ""} ${l.kind ? `(${l.text ?? ""})` : l.error}`); +} +if (process.env.PROOF_JSON) writeFileSync(process.env.PROOF_JSON, JSON.stringify({ rows, losses }, null, 2)); +process.exit(red ? 1 : 0);