diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 514a73b0b..06474ba6a 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,167 +6,168 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 159 -- serial total: 1009.4s +- suites: 160 +- serial total: 927.0s | seconds | share | suite | | ---: | ---: | --- | -| 140.6 | 13.9% | `tests/land-lock.bats` | -| 108.1 | 10.7% | `tests/derived-check.bats` | -| 102.2 | 10.1% | `tests/ci-wait.bats` | -| 83.1 | 8.2% | `tests/land.bats` | -| 54.5 | 5.4% | `tests/hooks-wiring-check.bats` | -| 36.1 | 3.6% | `tests/helpers.bats` | -| 35.9 | 3.6% | `tests/ci-local-parity.bats` | -| 34.4 | 3.4% | `tests/main-watch.bats` | -| 24.3 | 2.4% | `tests/hook-latency-drift.bats` | -| 23.7 | 2.3% | `tests/sbom-check.bats` | -| 17.4 | 1.7% | `tests/renovate-config-validator.bats` | -| 16.9 | 1.7% | `tests/claim-check.bats` | -| 13.2 | 1.3% | `tests/board-diff-overlap.bats` | -| 11.4 | 1.1% | `tests/config-lint.bats` | -| 10.2 | 1.0% | `tests/graph-check.bats` | -| 10.2 | 1.0% | `tests/prebuilt-lint.bats` | -| 9.5 | 0.9% | `tests/released.bats` | -| 9.0 | 0.9% | `tests/target-race.bats` | -| 8.9 | 0.9% | `tests/replay.bats` | -| 8.9 | 0.9% | `tests/board-write-record.bats` | -| 8.8 | 0.9% | `tests/run-shape-guard.bats` | -| 8.5 | 0.8% | `tests/token-bench.bats` | -| 8.3 | 0.8% | `tests/sbom.bats` | -| 7.5 | 0.7% | `tests/filed-here-check.bats` | -| 7.2 | 0.7% | `tests/ready-guard.bats` | -| 7.0 | 0.7% | `tests/mutant.bats` | -| 6.5 | 0.6% | `tests/perf-record.bats` | -| 6.4 | 0.6% | `tests/schema-check.bats` | -| 6.4 | 0.6% | `tests/mcp-allow-check.bats` | -| 6.3 | 0.6% | `tests/stop-guard.bats` | -| 5.9 | 0.6% | `tests/lock-complete.bats` | -| 5.9 | 0.6% | `tests/ready-lint.bats` | -| 5.8 | 0.6% | `tests/pkl-check.bats` | -| 5.4 | 0.5% | `tests/step-receipt.bats` | -| 5.1 | 0.5% | `tests/session-start.bats` | -| 5.0 | 0.5% | `tests/singleton.bats` | -| 4.6 | 0.5% | `tests/pre-commit-staging.bats` | -| 4.5 | 0.4% | `tests/in-progress-drain.bats` | -| 4.4 | 0.4% | `tests/release-assets-check.bats` | -| 4.0 | 0.4% | `tests/task-registry.bats` | -| 3.8 | 0.4% | `tests/board-move-guard.bats` | -| 3.7 | 0.4% | `tests/land-divergence.bats` | -| 3.6 | 0.4% | `tests/reference-check.bats` | -| 3.5 | 0.4% | `tests/doctor-race.bats` | -| 3.4 | 0.3% | `tests/ntia-check.bats` | -| 3.4 | 0.3% | `tests/target-ensure.bats` | -| 3.3 | 0.3% | `tests/semver.bats` | -| 3.2 | 0.3% | `tests/issue-read-check.bats` | -| 3.2 | 0.3% | `tests/with-lock.bats` | -| 2.7 | 0.3% | `tests/issue-read-guard.bats` | -| 2.7 | 0.3% | `tests/board-sweep.bats` | -| 2.7 | 0.3% | `tests/ready-cites-check.bats` | -| 2.4 | 0.2% | `tests/spawn-census.bats` | -| 2.4 | 0.2% | `tests/suite-select.bats` | -| 2.3 | 0.2% | `tests/hk-selection.bats` | +| 132.0 | 14.2% | `tests/land-lock.bats` | +| 102.0 | 11.0% | `tests/ci-wait.bats` | +| 97.7 | 10.5% | `tests/derived-check.bats` | +| 74.7 | 8.1% | `tests/land.bats` | +| 49.1 | 5.3% | `tests/hooks-wiring-check.bats` | +| 36.1 | 3.9% | `tests/helpers.bats` | +| 34.6 | 3.7% | `tests/main-watch.bats` | +| 34.3 | 3.7% | `tests/ci-local-parity.bats` | +| 24.2 | 2.6% | `tests/hook-latency-drift.bats` | +| 16.4 | 1.8% | `tests/claim-check.bats` | +| 16.3 | 1.8% | `tests/sbom-check.bats` | +| 15.4 | 1.7% | `tests/graph-check.bats` | +| 13.9 | 1.5% | `tests/board-diff-overlap.bats` | +| 12.1 | 1.3% | `tests/board-write-record.bats` | +| 10.2 | 1.1% | `tests/config-lint.bats` | +| 10.0 | 1.1% | `tests/prebuilt-lint.bats` | +| 8.3 | 0.9% | `tests/run-shape-guard.bats` | +| 7.4 | 0.8% | `tests/filed-here-check.bats` | +| 7.3 | 0.8% | `tests/token-bench.bats` | +| 7.3 | 0.8% | `tests/target-race.bats` | +| 6.9 | 0.7% | `tests/ready-guard.bats` | +| 6.9 | 0.7% | `tests/ready-lint.bats` | +| 6.8 | 0.7% | `tests/board-sweep.bats` | +| 6.4 | 0.7% | `tests/mutant.bats` | +| 5.8 | 0.6% | `tests/mcp-allow-check.bats` | +| 5.6 | 0.6% | `tests/renovate-config-validator.bats` | +| 5.4 | 0.6% | `tests/stop-guard.bats` | +| 5.2 | 0.6% | `tests/released.bats` | +| 5.1 | 0.5% | `tests/lock-complete.bats` | +| 4.9 | 0.5% | `tests/singleton.bats` | +| 4.9 | 0.5% | `tests/replay.bats` | +| 4.8 | 0.5% | `tests/session-start.bats` | +| 4.7 | 0.5% | `tests/sbom.bats` | +| 4.7 | 0.5% | `tests/step-receipt.bats` | +| 4.4 | 0.5% | `tests/pre-commit-staging.bats` | +| 4.2 | 0.5% | `tests/ready-cites-check.bats` | +| 4.0 | 0.4% | `tests/in-progress-drain.bats` | +| 3.9 | 0.4% | `tests/board-move-guard.bats` | +| 3.8 | 0.4% | `tests/task-registry.bats` | +| 3.8 | 0.4% | `tests/schema-check.bats` | +| 3.7 | 0.4% | `tests/release-assets-check.bats` | +| 3.4 | 0.4% | `tests/doctor-race.bats` | +| 3.4 | 0.4% | `tests/land-divergence.bats` | +| 3.2 | 0.4% | `tests/semver.bats` | +| 3.2 | 0.3% | `tests/ntia-check.bats` | +| 3.1 | 0.3% | `tests/issue-read-check.bats` | +| 3.0 | 0.3% | `tests/target-ensure.bats` | +| 3.0 | 0.3% | `tests/reference-check.bats` | +| 2.8 | 0.3% | `tests/with-lock.bats` | +| 2.6 | 0.3% | `tests/issue-read-guard.bats` | +| 2.5 | 0.3% | `tests/hk-selection.bats` | | 2.3 | 0.2% | `tests/landed-check.bats` | -| 2.2 | 0.2% | `tests/fanout-guard.bats` | -| 2.1 | 0.2% | `tests/spec-ref-check.bats` | -| 2.1 | 0.2% | `tests/unlanded-check.bats` | -| 2.0 | 0.2% | `tests/claim-race-check.bats` | -| 2.0 | 0.2% | `tests/closing-key-check.bats` | -| 1.9 | 0.2% | `tests/run-shape.bats` | -| 1.8 | 0.2% | `tests/ci-slow-needed.bats` | -| 1.8 | 0.2% | `tests/finding-sink-check.bats` | -| 1.8 | 0.2% | `tests/signing-posture.bats` | -| 1.7 | 0.2% | `tests/tree-clean.bats` | -| 1.7 | 0.2% | `tests/skill-check.bats` | -| 1.7 | 0.2% | `tests/issue-search-guard.bats` | -| 1.7 | 0.2% | `tests/memories-check.bats` | -| 1.7 | 0.2% | `tests/reclaim-census.bats` | -| 1.6 | 0.2% | `tests/claimed-keys.bats` | -| 1.5 | 0.1% | `tests/ci-tools-check.bats` | -| 1.4 | 0.1% | `tests/bot-issue.bats` | -| 1.4 | 0.1% | `tests/mutant-census.bats` | -| 1.3 | 0.1% | `tests/verify.bats` | -| 1.2 | 0.1% | `tests/rules-drift.bats` | -| 1.2 | 0.1% | `tests/done-check.bats` | -| 1.2 | 0.1% | `tests/deferral-check.bats` | -| 1.2 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.2 | 0.1% | `tests/land-divergence-assert.bats` | -| 1.2 | 0.1% | `tests/ready-lint-deferral.bats` | +| 2.2 | 0.2% | `tests/claim-race-check.bats` | +| 2.1 | 0.2% | `tests/suite-select.bats` | +| 2.1 | 0.2% | `tests/closing-key-check.bats` | +| 2.0 | 0.2% | `tests/fanout-guard.bats` | +| 1.8 | 0.2% | `tests/bot-issue.bats` | +| 1.8 | 0.2% | `tests/unlanded-check.bats` | +| 1.7 | 0.2% | `tests/ci-slow-needed.bats` | +| 1.7 | 0.2% | `tests/spec-ref-check.bats` | +| 1.6 | 0.2% | `tests/ci-tools-check.bats` | +| 1.6 | 0.2% | `tests/issue-search-guard.bats` | +| 1.6 | 0.2% | `tests/skill-check.bats` | +| 1.6 | 0.2% | `tests/reclaim-census.bats` | +| 1.6 | 0.2% | `tests/finding-sink-check.bats` | +| 1.5 | 0.2% | `tests/tree-clean.bats` | +| 1.5 | 0.2% | `tests/claimed-keys.bats` | +| 1.4 | 0.2% | `tests/ci-lease-precondition.bats` | +| 1.4 | 0.2% | `tests/signing-posture.bats` | +| 1.3 | 0.1% | `tests/awk-regex-check.bats` | +| 1.3 | 0.1% | `tests/memories-check.bats` | +| 1.3 | 0.1% | `tests/run-shape.bats` | +| 1.3 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.2 | 0.1% | `tests/alive.bats` | +| 1.2 | 0.1% | `tests/verify.bats` | +| 1.1 | 0.1% | `tests/mutant-census.bats` | +| 1.1 | 0.1% | `tests/perf-record.bats` | | 1.1 | 0.1% | `tests/linear-check.bats` | -| 1.1 | 0.1% | `tests/install-check.bats` | -| 1.0 | 0.1% | `tests/awk-regex-check.bats` | -| 1.0 | 0.1% | `tests/module-map-check.bats` | -| 1.0 | 0.1% | `tests/alive.bats` | -| 1.0 | 0.1% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.1% | `tests/target-prune.bats` | -| 0.9 | 0.1% | `tests/perf-assert.bats` | -| 0.9 | 0.1% | `tests/hook-pin-check.bats` | -| 0.9 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.8 | 0.1% | `tests/gh-guard.bats` | -| 0.8 | 0.1% | `tests/release-tracking-check.bats` | -| 0.8 | 0.1% | `tests/prose-only-check.bats` | +| 1.1 | 0.1% | `tests/deferral-check.bats` | +| 1.0 | 0.1% | `tests/spawn-census.bats` | +| 1.0 | 0.1% | `tests/done-check.bats` | +| 0.9 | 0.1% | `tests/install-check.bats` | +| 0.9 | 0.1% | `tests/nonverdict-scan.bats` | +| 0.9 | 0.1% | `tests/land-divergence-assert.bats` | +| 0.9 | 0.1% | `tests/rules-drift.bats` | +| 0.9 | 0.1% | `tests/attestation-check.bats` | +| 0.9 | 0.1% | `tests/checks-green.bats` | +| 0.8 | 0.1% | `tests/perf-assert.bats` | +| 0.8 | 0.1% | `tests/module-map-check.bats` | +| 0.8 | 0.1% | `tests/hook-pin-check.bats` | | 0.8 | 0.1% | `tests/issue-search-check.bats` | -| 0.8 | 0.1% | `tests/attestation-check.bats` | -| 0.8 | 0.1% | `tests/doctor.bats` | | 0.8 | 0.1% | `tests/done-pr-check.bats` | -| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.1% | `tests/gh-guard.bats` | +| 0.7 | 0.1% | `tests/target-prune.bats` | +| 0.7 | 0.1% | `tests/prose-only-check.bats` | +| 0.7 | 0.1% | `tests/sbom-binary.bats` | +| 0.7 | 0.1% | `tests/doctor.bats` | +| 0.7 | 0.1% | `tests/release-tracking-check.bats` | +| 0.7 | 0.1% | `tests/pr-unsubscribed.bats` | | 0.7 | 0.1% | `tests/render-cli.bats` | | 0.7 | 0.1% | `tests/timeout-drift.bats` | -| 0.7 | 0.1% | `tests/sbom-binary.bats` | -| 0.7 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.7 | 0.1% | `tests/checks-green.bats` | -| 0.7 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.1% | `tests/perf-compare.bats` | -| 0.6 | 0.1% | `tests/install.bats` | -| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.6 | 0.1% | `tests/stop-posture-check.bats` | -| 0.6 | 0.1% | `tests/verified.bats` | +| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.6 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | | 0.6 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.6 | 0.1% | `tests/connector-verb-guard.bats` | -| 0.5 | 0.1% | `tests/hook-profile-check.bats` | +| 0.6 | 0.1% | `tests/checksums.bats` | +| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.6 | 0.1% | `tests/perf-compare.bats` | +| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.1% | `tests/hook-profile-check.bats` | +| 0.5 | 0.1% | `tests/verified.bats` | +| 0.5 | 0.1% | `tests/install.bats` | +| 0.5 | 0.1% | `tests/stop-posture-check.bats` | +| 0.5 | 0.1% | `tests/connector-verb-guard.bats` | +| 0.5 | 0.1% | `tests/branch-age-check.bats` | +| 0.5 | 0.1% | `tests/abandon-matrix.bats` | | 0.5 | 0.0% | `tests/macos-link-check.bats` | -| 0.5 | 0.0% | `tests/publish-credential-check.bats` | -| 0.5 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.5 | 0.0% | `tests/checksums.bats` | -| 0.5 | 0.0% | `tests/land-lock-check.bats` | -| 0.5 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.0% | `tests/publish-credential-check.bats` | +| 0.4 | 0.0% | `tests/land-lock-check.bats` | +| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.4 | 0.0% | `tests/pipefail-grep-check.bats` | | 0.4 | 0.0% | `tests/msrv-pin-agreement.bats` | +| 0.4 | 0.0% | `tests/license-table-check.bats` | | 0.4 | 0.0% | `tests/sonar-gate.bats` | | 0.4 | 0.0% | `tests/connector-allow-guard.bats` | -| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.4 | 0.0% | `tests/commit-convention.bats` | -| 0.4 | 0.0% | `tests/suite-bench-check.bats` | -| 0.4 | 0.0% | `tests/abandon-matrix.bats` | -| 0.4 | 0.0% | `tests/serena-mcp.bats` | -| 0.4 | 0.0% | `tests/branch-age-check.bats` | -| 0.4 | 0.0% | `tests/transcript-corpus-check.bats` | -| 0.3 | 0.0% | `tests/release-due.bats` | -| 0.3 | 0.0% | `tests/board-payloads.bats` | +| 0.4 | 0.0% | `tests/cap-drift.bats` | +| 0.4 | 0.0% | `tests/board-payloads.bats` | +| 0.4 | 0.0% | `tests/batten-glob-check.bats` | +| 0.3 | 0.0% | `tests/serena-mcp.bats` | +| 0.3 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.3 | 0.0% | `tests/transcript-corpus-check.bats` | | 0.3 | 0.0% | `tests/timeout-check.bats` | +| 0.3 | 0.0% | `tests/no-doctests.bats` | +| 0.3 | 0.0% | `tests/release-due.bats` | +| 0.3 | 0.0% | `tests/pkl-check.bats` | +| 0.3 | 0.0% | `tests/ci-drift.bats` | | 0.3 | 0.0% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.0% | `tests/commit-convention.bats` | +| 0.3 | 0.0% | `tests/suite-bench-check.bats` | +| 0.3 | 0.0% | `tests/commit-attribution.bats` | | 0.3 | 0.0% | `tests/report-only-check.bats` | -| 0.3 | 0.0% | `tests/no-doctests.bats` | -| 0.3 | 0.0% | `tests/cap-drift.bats` | -| 0.3 | 0.0% | `tests/license-table-check.bats` | -| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | | 0.3 | 0.0% | `tests/container-preflight.bats` | -| 0.3 | 0.0% | `tests/mise-action-floor.bats` | -| 0.3 | 0.0% | `tests/commit-attribution.bats` | -| 0.3 | 0.0% | `tests/ci-drift.bats` | +| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.2 | 0.0% | `tests/mise-pin-agreement.bats` | -| 0.2 | 0.0% | `tests/batten-glob-check.bats` | | 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.2 | 0.0% | `tests/git-hook.bats` | -| 0.2 | 0.0% | `tests/perf-gate.bats` | +| 0.2 | 0.0% | `tests/mise-action-floor.bats` | | 0.2 | 0.0% | `tests/token-bench-check.bats` | -| 0.2 | 0.0% | `tests/task-fail-closed.bats` | -| 0.2 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/perf-gate.bats` | | 0.1 | 0.0% | `tests/dist.bats` | +| 0.1 | 0.0% | `tests/task-fail-closed.bats` | | 0.1 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.1 | 0.0% | `tests/remedy-payload-source.bats` | -| 0.1 | 0.0% | `tests/perf-pair.bats` | +| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | -| 0.1 | 0.0% | `tests/darwin-link.bats` | +| 0.1 | 0.0% | `tests/perf-pair.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | +| 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/crates/batten/tests/inverted_board_cases.rs b/crates/batten/tests/inverted_board_cases.rs new file mode 100644 index 000000000..f9933b1ec --- /dev/null +++ b/crates/batten/tests/inverted_board_cases.rs @@ -0,0 +1,119 @@ +//! The mapping ledger for a case a live suite INVERTED rather than lost +//! (CLOUD-908's column, this bundle's three). +//! +//! `bats-tests-not-deleted` conserves case NAMES, not counts: a case name that +//! disappears between `origin/main` and the head tree owes exactly one arm in +//! `crates/batten/tests/*.rs`, whatever the suite's total did. That is the right +//! reading and it is what caught this branch — three board-gate cases asserted a +//! contract a row here REVERSES, so each was rewritten in place with the reason +//! in the case, and a rewritten title is a vanished name. +//! +//! `contract_drift.rs` carries the other ledger block, for a suite that was +//! retired outright. This one is the in-place shape: every target below is the +//! same living suite the case was declared in, and the successor is the case that +//! now asserts the opposite. CLOUD-908's own measurement named exactly this as +//! the thing nothing marked — "one whose behaviour was deliberately inverted with +//! nothing marking it" — so recording it here is the column working, not a toll. +//! +//! WHAT THE ASSERTIONS BUY, which is what stops the arms being three comments. An +//! arm claiming an inversion is a claim about the tree, and both halves of it are +//! checkable: the successor case must EXIST under its new name, and the old name +//! must be GONE. Without the second half an arm would pass while the rewrite was +//! reverted, and without the first it would pass while the successor was never +//! written — the phantom-target shape the column already refuses one level up. +//! +//! THE ARMS ARE ORDINARY COMMENTS, NOT PART OF THE DOC BLOCK ABOVE, and that is +//! mechanical rather than stylistic: the engine reads an arm by +//! `strip_prefix("// changed:")` after trimming leading whitespace, so a line +//! spelled `//! // changed: …` inside a doc comment claims nothing at all. An arm +//! that claims nothing leaves its case unmapped, which is a refusal — the quiet +//! way to write three comments and still be red. + +// changed: "could not look outranks a refusal, so a half-run sweep is never exit 1" tests/board-sweep.bats CLOUD-921 reverses it: a clone-scoped abstention no longer suppresses a refusal, so the tag-less half-run IS exit 1 and only a board-scoped could-not-look outranks one +// changed: "a blocker noted as closed needs no relation" tests/ready-lint.bats the exemption's premise is false — Linear does not drop the relation when a blocker completes, measured on CLOUD-661 Done since 2026-08-18 with both dependents still carrying the edge +// changed: "a closed blocker in Linear's rendered-mention form is exempt" tests/ready-lint.bats the same premise, in the rendered-mention spelling: the form a blocker is written in decides nothing about whether its relation exists + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::fs; + +use common::at_root; + +/// One inversion: the suite, the name that went, and the name that replaced it. +/// +/// The tuple is the arm's own three fields minus the reason, which is prose for a +/// reader and decides nothing. Keeping them in one table is what lets a single +/// assertion hold every arm rather than one test per row — a shape that would go +/// stale the moment a fourth inversion lands. +const INVERSIONS: &[(&str, &str, &str)] = &[ + ( + "tests/board-sweep.bats", + "could not look outranks a refusal, so a half-run sweep is never exit 1", + "a board-scoped could-not-look outranks a refusal, so a half-run sweep is never exit 1", + ), + ( + "tests/ready-lint.bats", + "a blocker noted as closed needs no relation", + "a blocker noted as closed still needs its relation", + ), + ( + "tests/ready-lint.bats", + "a closed blocker in Linear's rendered-mention form is exempt", + "a closed blocker in Linear's rendered-mention form is judged like any other", + ), +]; + +/// The case names a bats suite declares, read the way the engine reads them. +/// +/// `@test "` opens and the first `"` closes, which is `Conserves`' own +/// `case`/`close` pair. A second reading of that grammar here would be a copy +/// that drifts, so this is deliberately the same two tokens and nothing more. +fn case_names(text: &str) -> Vec { + text.lines() + .filter_map(|line| line.trim_start().strip_prefix("@test \"")) + .filter_map(|rest| rest.split_once('"')) + .map(|(case, _)| case.to_owned()) + .collect() +} + +#[test] +fn every_inverted_case_has_a_successor_in_its_own_suite() { + for (suite, _, successor) in INVERSIONS { + let text = fs::read_to_string(at_root(suite)).unwrap(); + let names = case_names(&text); + assert!( + names.iter().any(|name| name == successor), + "{suite} must declare the case that replaced an inverted one, or the arm claiming the inversion names a successor the tree does not have: {successor}" + ); + } +} + +#[test] +fn no_inverted_case_still_stands_under_its_old_name() { + for (suite, retired, _) in INVERSIONS { + let text = fs::read_to_string(at_root(suite)).unwrap(); + let names = case_names(&text); + assert!( + !names.iter().any(|name| name == retired), + "{suite} still declares a case an arm records as inverted, so either the rewrite was reverted or the arm is wrong: {retired}" + ); + } +} + +#[test] +fn the_two_halves_disagree_about_every_row() { + // Anti-vacuity, and the one assertion that would catch a copy-paste row whose + // two names are the same string: such a row passes both tests above trivially + // — the successor exists and the retired name is therefore present, which the + // second test would then fail on. Asserting the distinctness directly says so + // once rather than leaving a reader to derive it from a confusing failure. + for (suite, retired, successor) in INVERSIONS { + assert_ne!( + retired, successor, + "{suite}: an arm whose retired and successor names are identical records no inversion" + ); + } +} diff --git a/mise-tasks/board-sweep.sh b/mise-tasks/board-sweep.sh index 4d5baff36..e4d70f20a 100755 --- a/mise-tasks/board-sweep.sh +++ b/mise-tasks/board-sweep.sh @@ -37,6 +37,39 @@ # faithfully, freshness not at all, so a recovered `status` may be stale. # Recover the structure from the cache; re-read a row before deciding its state. # +# A CLONE-SCOPED ABSTENTION MUST NOT SUPPRESS A BOARD-SCOPED VERDICT +# (CLOUD-921). `released` calls `graph-check` by path and `graph-check` calls +# `ready-lint`, so for its whole life a checkout with no `v*` tag took out the two +# gates this sweep exists for — and the tag-less clone is the ORDINARY case: a web +# session clones shallow and single-branch, and `git fetch origin main` with the +# configured refspec brings no tags. Measured 2026-08-22 over six harvested +# payloads: `released COULD NOT LOOK`, and `graph-check` never ran. The gates were +# fine; the chain was not. +# +# So `graph-check` is a LEAF here, invoked directly and before `released`. That is +# the decouple rather than a reorder, because a reorder leaves the topology — the +# next clone-shaped input reintroduces it. `released` is unchanged (CLOUD-921 §1): +# its refs-only arm is correct behaviour for a tag-less clone. It still gets the +# payload set when a tag exists, and its own internal `graph-check` call stays its +# own composition — it consults the gate only for rows the TAG shipped, and that +# verdict surfaces as `released`'s own `REFUSED ()` lines. One gate name is +# reported by this sweep once, so this is not CLOUD-351's two-sweeps shape. +# +# TWO ABSTENTION LANES, because "could not look" was one word for two facts: +# +# BOARD-SCOPED (`unjudgeable`) — a gate could not decide over the PAYLOAD SET: +# `graph-check`, `done-pr-check` or `spec-ref-check` exiting 2, or a set that +# is empty or not JSON. The board has NOT been judged; that is exit 2 and it +# outranks a refusal, which is CLOUD-251's discipline unchanged. +# +# CLONE-SCOPED (`abstained`) — the input is a property of this CLONE or its +# environment rather than of the board: no `v*` tag for `released`, no +# `--pulls` file and no reachable `gh`. The board WAS judged; one gate had +# nothing to judge with. That is exit 3, and a refusal outranks it. +# +# The distinction is the whole of CLOUD-921: those two were one exit code, so a +# tag-less clone could not tell "coherent, one gate abstained" from "not judged". +# # NO SCHEDULE, deliberately (CLOUD-825 §5). Whether this also runs on a cron, at # Stop, or as a `land` postlude is a trigger question with its own cost — # CLOUD-812 measured 96 idle cron ticks/day flooring at ~2,900 billed min/month. @@ -47,7 +80,11 @@ # With no `--payloads`, the set is `$BOARD_PAYLOADS_DIR/*.json` when that # directory holds any, and stdin otherwise. `-` forces stdin. # -# Exit 0 the board is coherent / 1 dissonance named / 2 could not look. +# Exit 0 the board is coherent / 1 dissonance named / 2 the board was not judged +# (a board-scoped gate could not look) / 3 the board was judged and is coherent, +# and a clone-scoped gate abstained. `3` is this layer's existing "no verdict +# here, the caller decides" code — `checks-green.sh` and `sonar-gate.sh` both +# already publish it. # # Declared mutations (CLOUD-418), one per clause the suite must be able to lose. # `@` delimits each sed script and the rows are `|`-separated, so a script may @@ -57,6 +94,9 @@ #MUTANT gate-two-laundered|s@^\t\tunjudgeable=@\t\trefusals=@|a gate exiting 2 is not laundered into the refusal lane #MUTANT drain-not-invoked|s@^run_gate in-progress-drain@true in-progress-drain@|a landed-but-In-Progress row is named by in-progress-drain #MUTANT released-fed-nothing|s@^\trun_gate released.*@\trun_gate released "$here/released.sh" "$tag" /dev/null) || count=0 refusals=0 unjudgeable=0 +# The clone-scoped lane (CLOUD-921). Only this task's own pre-gather steps write +# it: a gate that RAN and exited 2 has said something about the payload set, which +# is board-scoped by construction, so `run_gate` never touches this counter. +abstained=0 # Pointer-only per non-negotiable rule 4: the gate's name and its verdict. Each # gate's own per-issue lines carry issue keys and rule ids and are passed @@ -182,7 +226,7 @@ run_gate() { # run_gate # The loop names TASKS and the files carry `.sh` (CLOUD-865), so the filename is # built here rather than assumed equal to the task name — the same split # `mutant` makes over `$MUTANT_GATES`. -for gate in released in-progress-drain done-pr-check spec-ref-check; do +for gate in graph-check duplicate-close-check released in-progress-drain done-pr-check spec-ref-check; do [[ -x "$here/$gate.sh" ]] || { echo "::error:: board-sweep: cannot run $here/$gate.sh. A gate that cannot run is not a pass — the sweep needs it, so this is 'could not look'." >&2 exit 2 @@ -191,7 +235,24 @@ done echo "board-sweep: $count issue(s)" -# --- released -> graph-check -> ready-lint ----------------------------------- +# --- graph-check -> ready-lint ----------------------------------------------- +# +# THE LEAF, AND IT GOES FIRST (CLOUD-921). `graph-check` decides whether the board +# is honestly labelled and calls `ready-lint` per Todo row, so between them they +# are what the sweep exists for. Their input is entirely the payload set — no tag, +# no range, no forge — which is exactly why nothing clone-scoped may sit upstream +# of them. It is invoked here rather than reached through `released`. +run_gate graph-check "$here/graph-check.sh" <<<"$issues" + +# --- duplicate-close-check --------------------------------------------------- +# +# CLOUD-829. A row closed as a duplicate in the same operation that closed its +# target decided two things at once, and one of them was never argued. Board-scoped +# like `graph-check`: its whole input is the payload set, no tag and no forge, so it +# sits here rather than behind anything clone-shaped. +run_gate duplicate-close-check "$here/duplicate-close-check.sh" <<<"$issues" + +# --- released ---------------------------------------------------------------- # # THE REDIRECT THIS TASK EXISTS TO REPLACE. `released ` with no stdin # reports the refs a tag shipped and returns; only the stdin arm reaches @@ -203,9 +264,9 @@ if [[ -z "$tag" ]]; then tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n1) || tag="" fi if [[ -z "$tag" ]]; then - unjudgeable=$((unjudgeable + 1)) - echo " released COULD NOT LOOK" - echo "::error:: board-sweep: this checkout carries no \`v*\` tag, so \`released\` cannot resolve a range and \`graph-check\` behind it is never reached. Fetch tags, or pass --tag." >&2 + abstained=$((abstained + 1)) + echo " released ABSTAINED" + echo "::notice:: board-sweep: this checkout carries no \`v*\` tag, so \`released\` cannot resolve a range and says nothing about which rows a release shipped. Every board-scoped gate above still ran. Fetch tags, or pass --tag." >&2 else run_gate released "$here/released.sh" "$tag" <<<"$issues" fi @@ -233,9 +294,13 @@ elif command -v gh >/dev/null 2>&1; then jq -c '[.[] | {number, state: (.state | ascii_downcase), draft: .isDraft}]') || pulls="" fi if [[ -z "$pulls" ]] || ! jq -e 'type == "array"' <<<"$pulls" >/dev/null 2>&1; then - unjudgeable=$((unjudgeable + 1)) - echo " done-pr-check COULD NOT LOOK" - echo "::error:: board-sweep: no pull-request state to judge Done against. Supply --pulls (a JSON array of {number,state,draft}), or make \`gh\` reachable." >&2 + # THIS TASK'S OWN GATHER STEP FAILED, which is a fact about the environment + # rather than about the board — no `gh`, no `--pulls`. `done-pr-check`'s OWN + # exit 2 (a row naming a PR whose state was piped but absent) stays in the + # board-scoped lane, because that one is a statement about a row. + abstained=$((abstained + 1)) + echo " done-pr-check ABSTAINED" + echo "::notice:: board-sweep: no pull-request state to judge Done against, so that gate was not run. Supply --pulls (a JSON array of {number,state,draft}), or make \`gh\` reachable." >&2 else # Every payload carries the whole list; `done-pr-check` selects by number, so # a per-issue projection here would be a second answer to a question that @@ -253,12 +318,24 @@ fi # `git grep -hoE "CLOUD-[0-9]+'?s? §[0-9]+"` names, not just the active columns. run_gate spec-ref-check "$here/spec-ref-check.sh" <<<"$issues" +# THE ORDER OF THESE THREE IS THE CONTRACT (CLOUD-921). +# +# A board-scoped abstention still outranks everything: nothing below is worth +# reporting about a board that was not judged. if [[ "$unjudgeable" -gt 0 ]]; then echo "board-sweep: $unjudgeable gate(s) could not look — the board has not been judged" >&2 exit 2 fi +# A REFUSAL OUTRANKS A CLONE-SCOPED ABSTENTION, and this is the reversal. The +# board WAS judged here, so reporting "not judged" would withhold a verdict that +# exists — which is what a tag-less clone did to every dissonance the other gates +# found. if [[ "$refusals" -gt 0 ]]; then echo "board-sweep: $refusals gate(s) name dissonance above" >&2 exit 1 fi +if [[ "$abstained" -gt 0 ]]; then + echo "board-sweep: the board is coherent ($count issue(s)); $abstained gate(s) abstained on a property of this clone, not of the board" >&2 + exit 3 +fi echo "board-sweep: every gate ran and none names dissonance ($count issue(s))" diff --git a/mise-tasks/board-write-record.sh b/mise-tasks/board-write-record.sh index 8792edf15..3026543b1 100755 --- a/mise-tasks/board-write-record.sh +++ b/mise-tasks/board-write-record.sh @@ -45,7 +45,8 @@ # # POINTER-ONLY IS LOAD-BEARING HERE (non-negotiable 4), not decorative: the text # this reads is the entire issue body. Five fields reach the file — kind, id, -# updatedAt, verdict, and the diff overlap — and nothing is ever printed. +# updatedAt, verdict, the named paths, and the rows the stored body cites that the +# caller passed as no relation (CLOUD-923) — and nothing is ever printed. # # THE FIFTH FIELD IS PHASE 3 (CLOUD-514), and it is what the first two phases # left out. The `verdict` column prices REFINEMENT: it asks whether the new row @@ -101,6 +102,19 @@ # `self-mutating-row` since CLOUD-480; the class is how a row names a string it # must also contain. #MUTANT overlap-frozen-at-write-time|s@ --n[a]med @ @|A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED +# +# CLOUD-923's column, and the mutation is the one that passes every other row: read +# the citations from the caller's ARGUMENT instead of the tracker's response, so a +# caller that strips them from what it sends records zero over a body that carries +# eight. Anchored on `^if`, so it cannot match its own `#MUTANT` line and is +# not the self-mutating shape `mutant` refuses since CLOUD-480. +# THIS DECLARATION WENT STALE UNDER ITS OWN AUTHOR (CLOUD-941's class, one commit +# apart): it targeted the `-n "${description:-}"` guard, and CLOUD-806 replaced that +# guard with `-n "$emitted"` when the keys moved to `ready-lint`'s emission. `sed` +# does not call "matched zero lines" an error, so `mutant` reported it `inert` rather +# than passing — which is the verdict working, and the reason a declaration must be +# re-read whenever the line it names is edited. +#MUTANT cites-read-from-the-argument|s@^if \[\[ "$kind" = issue \]\] && \[\[ -n "$emitted" \]\]; then@if false; then@|a write records the rows its stored body cites set -uo pipefail # @@ -292,7 +306,12 @@ if [[ "$kind" = issue ]]; then # omits the relations key, `unjudgeable-relations` fires, and `-` is the # honest answer for a groom whose relations nothing here can see. if [[ -n "$payload" ]]; then - printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint.sh" >/dev/null 2>&1 + # STDOUT IS KEPT NOW (CLOUD-806). `ready-lint` emits the structure it already + # built — `cites-body` and `cites-blockers` — before it branches on a verdict, + # so this reads the derived fact instead of rebuilding it with a second regex + # over the same body. stderr still goes nowhere: its pointers are the lint's + # own report, and this file prints nothing. + lint_out=$(printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint.sh" 2>/dev/null) case $? in 0) verdict=ready ;; 1) verdict=unready ;; @@ -323,12 +342,127 @@ if [[ "$kind" = issue ]]; then overlap=$(printf '%s' "$description" | "$(dirname -- "${BASH_SOURCE[0]}")/board-diff-overlap.sh" --named 2>/dev/null) || overlap=- fi [[ -n "$overlap" ]] || overlap=- + # ONE COLUMN, ONE WHITESPACE-FREE TOKEN (CLOUD-923). `board-diff-overlap + # --named` emits ` ...`, so this column was the only variable-width + # one — and a record whose fifth field can swallow the rest of the line cannot + # have a sixth. `filed-here-check` already comma-joined it on read (`packed`), + # so the value it computes is unchanged; what moves is where the join happens. + # Without this, the cites column below lands inside the named-path list and the + # gate refuses a lap over a path called `0`. + # + # THE ONE-WAY COST, stated rather than papered over with a back-compat claim + # this cannot honour: a record line written by the PREVIOUS shape carries the + # space-separated form, so `filed-here-check` reads its second and later paths + # into the cites column and judges the row on fewer named paths than it named. + # That direction cannot manufacture a refusal — it can only miss one — and the + # window is bounded by the store, which lives under `$GIT_DIR`, is never + # committed, and dies with the container. Writer and reader ship in one commit. + overlap=${overlap// /,} +fi + +# THE CITED-KEYS COLUMN (CLOUD-923). The tracker auto-links every `CLOUD-nnn` +# mention in a body into a symmetric `relatedTo` edge, so writing a body modifies +# every row it cites — rows the caller passed as no parameter, named as no +# relation, and is told about in no response. Measured over one grooming session: +# 43 edges added, 11 passed, 32 minted by prose, and therefore 32 rows outside the +# session's scope silently modified. Nothing saw it: `graph-check` reads +# `relatedTo` for nothing at all, and this recorder had five columns and no +# relation term. +# +# ─── WHAT THIS COLUMN IS, AND WHAT IT IS NOT ───────────────────────────────── +# +# CLOUD-923 §1 says the pre-write set is in the `issue-read-check` payload and the +# post-write set is in the `save_issue` response, so an observed DELTA needs no new +# fetch. BOTH HALVES ARE FALSE, measured 2026-08-23 rather than assumed: +# +# * a `save_issue` response carries no `relations` key at all — only +# `get_issue(includeRelations: true)` does, and a hook holds no tracker +# credential to make that call (`claim-check`'s constraint); +# * `issue-read-check`'s receipt is `key seen read_at body_hash seen_status` — +# five fields, no relation set. The payload had one; the receipt did not keep +# it. +# +# So the observed delta is not computable here without the fetch §1 forbids. What +# IS in hand is the caller's arguments and the body the tracker STORED, and their +# difference is the set of edges prose will mint: **the keys the stored body cites +# that the caller passed as no relation.** +# +# That is a PREDICTION, not an observation, and the difference is stated rather +# than absorbed: a cited row that was already related is counted here and adds no +# edge, so this OVER-counts and never under-counts. Conservative in the direction +# CLOUD-923 §2 asks for — the failure mode it names is the record being quieter +# than the truth, and an upper bound cannot be that. +# +# Unforgeable for the same reason the verdict and the named-paths column are: the +# body read is the tracker's RESPONSE, never the caller's argument. A caller who +# strips citations from what it SENDS still gets them counted from what came back. +# +# Pointer-only per non-negotiable rule 4: a count and the far-end keys, +# comma-joined so the record stays one field per column. Never a line of the body +# that minted them. +# +# THE KEYS COME FROM `ready-lint`, NOT FROM A SECOND SCAN (CLOUD-806). This file +# already spawns that gate on this very body, and that gate is the one program in +# the tree that turns a Ready block into structure — it strips Linear's +# `` mention markup, dedupes, and orders numerically. A second regex here +# would be a second authority over one question, and the two would disagree the +# first time either was touched. +# +# ABSENT IS "COULD NOT LOOK", NEVER EMPTY. The producer emits the line BEFORE it +# branches on a verdict, so a missing line means it exited before reaching the +# emission — an unreadable payload — and this column stays `-`. A line that is +# PRESENT and carries no keys is the honest zero. Collapsing those two is the +# CLOUD-251 shape the overlap column above already takes care to avoid. +# +# REPORTED, NEVER REFUSED, and that is CLOUD-923's open call decided. The tracker's +# auto-linking is not the author's choice and no body can opt out of it, so a +# refusal would be a toll with no remedy — the shape `filed-here-check`'s own +# header warns about. This file prints nothing and moves no exit code regardless; +# what changes is that a later reader can see which far-end rows a branch touched. +cites=- +# The guard is the EMISSION, not the description: `ready-lint` is the producer, so +# "did it get far enough to emit" is the only thing that decides whether this +# column can be computed at all (CLOUD-806). +emitted="" +cited="" +if [[ -n "${lint_out:-}" ]]; then + while IFS= read -r line; do + [[ "$line" == cites-body* ]] || continue + emitted=1 + cited=$(tr ' ' '\n' <<<"${line#cites-body }" | grep -vx '' || true) + break + done <<<"$lint_out" +fi +if [[ "$kind" = issue ]] && [[ -n "$emitted" ]]; then + # The caller's arguments, all three directions: a row passed as a blocker is + # not "minted by prose" however the body also mentions it. + passed=$(printf '%s' "$raw" | jq -r ' + [ .tool_input.relatedTo[]?, .tool_input.blockedBy[]?, .tool_input.blocks[]? ] + | map(select(type == "string")) | unique | .[] + ' 2>/dev/null) || passed="" + minted="" + while IFS= read -r k; do + [[ -n "$k" ]] || continue + # The row's own key is not an edge to anywhere. + [[ "$k" != "$id" ]] || continue + grep -qxF -- "$k" <<<"$passed" && continue + minted="${minted:+$minted,}$k" + done <<<"$(sort -t- -k2,2n <<<"$cited")" + # ZERO IS A COUNT; `-` IS "COULD NOT LOOK". A body the tracker did not return + # leaves the initialiser above standing, so the two are distinguishable in the + # record — CLOUD-251's split, which this column would otherwise collapse in the + # quiet direction. + if [[ -z "$minted" ]]; then + cites=0 + else + cites="$(($(tr -cd , <<<"$minted" | wc -c) + 1)):$minted" + fi fi mkdir -p "$git_dir/batten-receipts" 2>/dev/null || exit 0 # Slashes are the one character a filename cannot carry; the substitution matches # every other branch-keyed receipt here. record="$git_dir/batten-receipts/board-writes.${branch//\//-}" -printf '%s %s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" "$overlap" >>"$record" 2>/dev/null || exit 0 +printf '%s %s %s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" "$overlap" "$cites" >>"$record" 2>/dev/null || exit 0 exit 0 diff --git a/mise-tasks/duplicate-close-check.sh b/mise-tasks/duplicate-close-check.sh new file mode 100755 index 000000000..3ac672507 --- /dev/null +++ b/mise-tasks/duplicate-close-check.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +#MISE description="Gate: a duplicate close decided in the same operation as its target's close (reads get_issue payloads on stdin) — CLOUD-829" +# +# CLOUD-829. Measured: two closes, one operation, `2026-08-21T02:37:51.492Z`. +# CLOUD-777 was marked Done and CLOUD-817 was closed as a Duplicate OF CLOUD-777, +# in the same operation — and CLOUD-817's own text was the finding that CLOUD-777's +# acceptance passed *vacuously*. So the row saying "this Done rests on a clause that +# cannot fail" was filed away as a restatement of the row it contradicts. +# +# A duplicate close is a claim that two rows say THE SAME THING. Here one row said a +# thing and the other said that thing is not true. The direction of the error is +# what matters: a duplicate close makes the closed row's content unreachable from +# the surviving row's, so the finding did not merely lose — it stopped being +# readable. `crates/batten/src/hook.rs` still names CLOUD-817 as the owner of an +# open decision, and following that pointer lands on a row filed under another. +# +# ─── WHAT THIS DECIDES, AND WHAT IT REFUSES TO PRETEND TO DECIDE ───────────── +# +# Whether two rows CONTRADICT is not computable, and a gate claiming to decide it +# would be estimating (non-negotiable rule 3). What is computable is narrower and +# covers the measured instance exactly: a duplicate close whose target changed into +# a completed state in the SAME OPERATION is a close that decided two things at +# once, and one of them was never argued. +# +# THE REFUSAL IS A DEMAND FOR A DECISION, NEVER A VERDICT ABOUT WHO WAS RIGHT. The +# gate cannot know which row was correct and says so: the two closes were decided +# together, and one of them has to be argued separately. That is what keeps this a +# gate rather than a judge (CLOUD-93). It moves no row and reverses no close. +# +# ─── THE WINDOW IS ONE SECOND, AND IT IS A BOUND RATHER THAN "SIMULTANEOUS" ── +# +# Implemented as equality of the two timestamps TRUNCATED TO THE SECOND, which is a +# strictly-less-than-one-second bound. No date arithmetic, deliberately: `date -d` +# is GNU-only and `mise-tasks/**` must stay BSD portable — `no-gnu-sed-in-place` +# and its siblings already deny that class of shortcut, and CI runs ubuntu and is +# structurally blind to all of them. +# +# THE HONEST LIMIT, stated rather than left to be discovered: two closes 400ms +# apart that straddle a second boundary are missed. Widening needs real duration +# arithmetic on two ISO strings, which is the thing portability rules out here. The +# measured case is same-second to the millisecond, so the default catches it, and +# `DUPLICATE_CLOSE_WINDOW` exists so a caller who has a wider case can say so +# rather than editing the predicate. +# +# Exit 0 no duplicate close shares an operation with its target's / 1 one does / 2 +# could not look — matching `graph-check` and `released` so all three compose. +# +# Declared mutations (CLOUD-418), one per clause the suite must be able to lose. +# NO `|` MAY APPEAR IN A SED SCRIPT HERE. `mutant.sh` reads each row with +# `IFS='|' read -r slug script want`, so a pattern carrying its own `|` shifts every +# field after it — measured on this file's first three declarations, which all +# targeted a line containing `||` and came back `unappliable`, `inert` and +# `names-no-case` respectively, one per failure shape the tool distinguishes. Each +# mutation therefore names a line with no `||` in it, which is a constraint on WHICH +# line to corrupt rather than on what to prove. +#MUTANT window-never-compares|s@^\treport "$id" "duplicate-closed-with-its-target@\ttrue "$id" "@|a duplicate close in the same operation as its target's close is refused +#MUTANT absent-key-reads-as-clean|s@^any_key=.*@any_key=1@|a set with no duplicateOf key anywhere is could not look +#MUTANT target-outside-the-set-passes|s@^in_set() .*@in_set() { true; }@|a duplicate whose target was not piped is unjudgeable +set -uo pipefail + +# One second, expressed as the number of leading characters of an ISO-8601 +# timestamp that must match: `2026-08-21T02:37:51` is 19. +window="${DUPLICATE_CLOSE_WINDOW:-19}" + +if ! payload=$(cat) || [[ -z "${payload//[[:space:]]/}" ]]; then + echo "::error:: duplicate-close-check: stdin is empty; expected get_issue payload(s)" >&2 + exit 2 +fi +if ! issues=$(jq -sc 'if length == 1 and (.[0] | type == "array") then .[0] else . end' <<<"$payload" 2>/dev/null) || + ! jq -e 'type == "array" and length > 0' <<<"$issues" >/dev/null 2>&1; then + echo "::error:: duplicate-close-check: stdin is not a get_issue payload set. Recover it with \`mise run board-payloads ...\`, which reads this session's own results rather than text an agent re-typed (CLOUD-526)." >&2 + exit 2 +fi + +# Byte-stable ordering everywhere: numeric by issue number, as every board gate here +# does, so re-running produces the same bytes. +by_num() { sort -t- -k2,2n; } +ids=$(jq -r '.[].id // empty' <<<"$issues" | by_num) +id_index=$'\n'"$ids"$'\n' +in_set() { [[ "$id_index" == *$'\n'"$1"$'\n'* ]]; } + +violations=0 +unjudgeable=0 +# Pointer-only per non-negotiable rule 4: the two keys and the two timestamps. +# NEVER a line of either body, and specifically never the sentence that made one row +# contradict the other — that sentence is the whole reason this row exists, and +# echoing it would put the content this gate protects into a log. +report() { + echo "$1 $2" >&2 + violations=$((violations + 1)) +} +unjudged() { + echo "$1 $2" >&2 + unjudgeable=$((unjudgeable + 1)) +} + +# THE ANTI-VACUITY TERM, and it is the same one `graph-check` draws on `blockedBy` +# and `released` on `attachments`: a caller who projected the relation away gets +# zero duplicates, an unconditional pass, and a verdict over a field it never saw. +# An explicit `null` is DATA and is judged; only a set where NO payload carries the +# key at all is could-not-look. +# +# Keyed to a pseudo-id rather than per row, because the property is of the piped +# SET. A per-id line would convert every row in a projection-free sweep into a +# finding, which is the shape `graph-check`'s own header warns about. +any_key=$(jq -r '[.[] | select((try (.relations | has("duplicateOf")) catch false))] | length' <<<"$issues" 2>/dev/null) || any_key=0 +if [[ -z "$any_key" ]] || [[ "$any_key" -eq 0 ]]; then + unjudged "graph" "unjudgeable-duplicateof (no payload carries the key — re-fetch with get_issue(includeRelations: true))" + echo "::error:: duplicate-close-check: $unjudgeable payload set(s) could not be judged" >&2 + exit 2 +fi + +# `completedAt` is the tracker's own stamp for entering a completed type, and +# `canceledAt` for entering a canceled one — a Duplicate is a canceled type. Both are +# read from the ROW'S OWN payload, so nothing here infers a transition from a +# column: a row's history is the tracker's answer, not this gate's reconstruction. +while IFS=$'\t' read -r id closed_at target; do + [[ -n "$id" ]] || continue + [[ "$target" != - && "$target" != null ]] || continue + # A close with no stamp cannot be compared to anything. That is could-not-look + # about this row rather than a pass: the field the predicate turns on is absent. + [[ "$closed_at" != - && "$closed_at" != null ]] || { + unjudged "$id" "unjudgeable-close-time (duplicateOf $target, and this row carries no canceledAt)" + continue + } + in_set "$target" || { + # The caller chose the closure, exactly as `graph-check` says of an edge + # leaving the piped set: a target that was not piped is a question nobody + # asked, never a clean answer. + unjudged "$id" "unjudgeable-duplicate-target ($target not in the piped set)" + continue + } + target_at=$(jq -r --arg t "$target" '.[] | select(.id == $t) | .completedAt // empty' <<<"$issues" 2>/dev/null | head -n1) + [[ -n "$target_at" ]] || continue + closed_at=${closed_at:0:$window} + target_at=${target_at:0:$window} + [[ "$closed_at" = "$target_at" ]] || continue + report "$id" "duplicate-closed-with-its-target ($target completed at $target_at, this row closed at $closed_at)" + # `-` FOR AN ABSENT FIELD, NEVER THE EMPTY STRING. Tab is whitespace to `read`, so + # consecutive tabs COLLAPSE and an empty middle column shifts every field after it + # left — measured: a row with no `canceledAt` put its `duplicateOf` target into the + # timestamp variable and left the target empty, so the row read as "not a duplicate" + # and passed. A placeholder keeps the column count fixed whatever is null. +done < <(jq -r '.[] | [(.id // "-"), (.canceledAt // "-"), (.relations.duplicateOf.id // .relations.duplicateOf // "-")] | @tsv' <<<"$issues" 2>/dev/null | by_num) + +if [[ "$unjudgeable" -ne 0 ]]; then + echo "::error:: duplicate-close-check: $unjudgeable row(s) could not be judged — the set does not answer the question" >&2 + exit 2 +fi +if [[ "$violations" -ne 0 ]]; then + echo "::error:: duplicate-close-check: $violations duplicate close(s) decided in the same operation as the target's own close. The gate does not know which row was right, and is not claiming to: what it refuses is TWO decisions taken as one. Argue the close on its own — either the rows really do say the same thing, or the closed one carries a finding about the survivor and needs reopening." >&2 + exit 1 +fi +echo "duplicate-close-check: no duplicate close shares an operation with its target's ($(printf '%s' "$ids" | grep -c . || true) row(s))" diff --git a/mise-tasks/filed-here-check.sh b/mise-tasks/filed-here-check.sh index 6c2e14a01..5354f3935 100755 --- a/mise-tasks/filed-here-check.sh +++ b/mise-tasks/filed-here-check.sh @@ -214,6 +214,17 @@ report() { # pointer-only: an id, and for the diff refusal one tracked path # `creates` still counts CREATE lines, so the pass line reports how many rows the # branch filed rather than how many times they were linted. # +# THE SIXTH COLUMN IS READ AND NOT JUDGED (CLOUD-923). It holds the rows the stored +# body cites that the caller passed as no relation — the edges the tracker's +# auto-linking mints from prose. It is named here rather than left to fall into +# `overlap`, which is what a trailing field would otherwise do. `_` is this +# file's own spelling for a column it deliberately does not read, as the note above +# on the updatedAt column says: the recorder now +# comma-joins the named-path list so every column is one token, and without that +# this gate would read `0` as a named path and refuse a lap over it. Nothing prices +# the citation set, for the reason CLOUD-923 decided: the tracker's auto-linking is +# not the author's choice, so a toll on it would have no remedy. +# # `_` for the updatedAt column: it is the recorder's forgery-resistant half and # this gate has no use for it, and naming it `_` is what keeps the linter from # reading a deliberate placeholder as a dead variable. @@ -223,7 +234,7 @@ report() { # pointer-only: an id, and for the diff refusal one tracked path # look", and passes. A branch cannot be refused for a question its recorder was # never able to ask. latest="" -while read -r kind id _ verdict overlap; do +while read -r kind id _ verdict overlap _; do [[ -n "$kind" ]] || continue case "$kind" in comment) @@ -249,9 +260,10 @@ while read -r kind id _ verdict overlap; do *) rebuilt="${rebuilt:+$rebuilt }$entry" ;; esac done - # ` ...` from the recorder, comma-joined so one entry stays one - # shell word. Absent or blank is `-`, the same "could not look" the verdict - # column already draws. + # `,...` from the recorder, already comma-joined there since + # CLOUD-923 so one column is one shell word. The join is kept here too: a record + # written before that change carries the space-separated form, and this gate + # reads a store that outlives a single lap. packed=${overlap:--} packed=${packed// /,} latest="${rebuilt:+$rebuilt }$id=${verdict:--}=${packed}" diff --git a/mise-tasks/graph-check.sh b/mise-tasks/graph-check.sh index 1002edcbe..7eddcf132 100755 --- a/mise-tasks/graph-check.sh +++ b/mise-tasks/graph-check.sh @@ -11,9 +11,12 @@ # in-progress-unassigned In Progress => assignee != null # in-review-no-pr In Review => at least one linked GitHub PR # todo-not-ready Todo => ready-lint over it exits 0 -# todo-unmilestoned Todo => the payload carries a projectMilestone +# unmilestoned () a STARTED, unparented row carries a projectMilestone +# — Todo, In Progress or In Review (CLOUD-771) +# child-unmilestoned a child of a MILESTONED parent carries a milestone +# of its own — the same one, or a different one +# declared; carrying none is the refusal (CLOUD-599) # blockedby-cycle the blockedBy relation is acyclic -# dangling-blocker every blocker is present in the piped set # # The third is CLOUD-375's, and it is a peer of the first two rather than a # frontier note because `Todo` is a column CLAIM of the same kind: the board model @@ -29,8 +32,21 @@ # # unjudgeable-blockedby a payload carries no blockedBy key -> exit 2 # unjudgeable-milestone no payload carries projectMilestone -> exit 2 +# dangling-blocker a blocker is outside the piped set -> exit 2 # excluded (unjudgeable-ready-block) ready-lint could not read it -> exit 2 +# excluded (unjudgeable-blocker …) a blocker of THIS row is outside -> exit 2 +# the piped set, so whether it is +# resolved is a question nobody asked # excluded (blocked-by …) a blocker has not landed -> exit 0 +# frontier-over-retired-blocker a blocker resolved by being CANCELED +# or DUPLICATE rather than completed, +# so the premise may have gone with it -> exit 0 +# +# `dangling-blocker` MOVED into this family from the violation list (CLOUD-678). +# Both of the arms above it are the same fact — a blocker outside the closure — +# and one of them was reporting a lying board while the other reported an +# unanswerable question. The decision and the rejected option are recorded beside +# the code rather than here. # # And one more predicate, CLOUD-234's: prose is not a second authority for a # column, so a status the board decides is checked against the board. @@ -42,6 +58,28 @@ # alphabet cannot spell it # unjudgeable-description a payload carries no description key -> exit 2 # +# ─── THIS FILE'S `CLOUD-[0-9]+` SITES ARE NOT RE-DERIVATIONS (CLOUD-806) ───── +# +# CLOUD-806 asked for `ready-lint` to emit the structure it builds and for this +# file's three re-derived issue-key regexes to be deleted. The emission landed and +# has a consumer; the deletion does not apply here, and the measurement is recorded +# so nobody spends a second attempt discovering it. +# +# Measured 2026-08-23 — this file derives the key at FIVE sites, not three, and not +# one of them rebuilds anything `ready-lint` computes: +# +# the cycle report ids out of `tsort`'s OUTPUT. Not a body at all; `ready-lint` +# never sees it and could not emit it. +# the claim scan `CLOUD-N is ` over prose, twice, plus the CAPSPAN +# (four sites) arm. A predicate `ready-lint` does not implement, over the +# whole body rather than the §8 span. +# +# `ready-lint`'s own sites derive §8 blocker citations and deferral citations — +# different predicates over different spans. So the duplication CLOUD-806 names is +# of the REGEX LITERAL across nine spellings, which that row explicitly scopes out +# as CLOUD-761's, and not of the derivation. Deleting a site here would mean +# deleting a predicate, not a rebuild. +# # DECLARED FIELD SET (CLOUD-526), stated because a gate that never writes its # input contract down grows one by accident: `id` and `status` for every issue, # `relations.blockedBy` for the graph, `description` for the §8 claim scan and @@ -56,7 +94,22 @@ # The mutation demotes the milestone refusal to a note, which is the reading that # let 174 open issues accumulate with no phase: visible in the output, invisible # in the exit code, and therefore invisible to `verify` and CI. -#MUTANT milestone-refusal-is-a-note|s@^\t\treport "\$id" "todo-unmilestoned"@\t\tnote "$id" "todo-unmilestoned"@|in a set where others carry one, is refused +# CLOUD-477's three arms, one per terminal column the name match could not see. +# Each targets a line carrying no `|`, because `mutant.sh` reads a declaration with +# `IFS='|'` and a pattern with its own pipe shifts every field after it. +#MUTANT canceled-blocker-still-starves|s@^\t\[\[ "$t" = canceled \]\] && return 0@\t:@|a Todo row whose only blocker is Canceled reaches the frontier +#MUTANT duplicate-blocker-still-starves|s@^\t\[\[ "$t" = duplicate \]\] && return 0@\t:@|a Todo row whose only blocker is Duplicate reaches the frontier +#MUTANT in-review-loses-its-name-arm|s@^\t\[\[ "$(status_of "$1")" = "In Review" \]\] && return 0@\t:@|a blocker In Review still resolves, since its type is started +#MUTANT retirement-is-silent|s@^\t\t\[\[ -z "$retired" \]\] .*@\t\t:@|a frontier row over a retired blocker says so +#MUTANT milestone-refusal-is-a-note|s@^\t\treport "\$id" "unmilestoned@\t\tnote "$id" "unmilestoned@|in a set where others carry one, is refused +# CLOUD-599's two arms, mutating in opposite directions. The first demotes the +# refusal to a note — the quiet direction, where the clause reports and decides +# nothing. +#MUTANT child-refusal-is-a-note|s@^\t\t\treport "\$id" "child-unmilestoned@\t\t\tnote "$id" "child-unmilestoned@|a child with no milestone under a milestoned parent is refused +# The second drops the `!= "null"` guard on the CHILD's milestone, so a child +# carrying a DIFFERENT milestone is refused too — the nuisance direction, and the +# arm four live rows depend on. Named a line with no `||` in it deliberately. +#MUTANT declared-rephase-refused|s@^\t\telif \[\[ "$(milestone_col_of "$parent")" = "set" \]\] .*@\t\telif [[ -n "$parent" ]]; then@|a child carrying a DIFFERENT milestone is the declared re-phase and passes # # CLOUD-838's arm takes the same directive, for the same reason one level down: # demoted to a note the unscannable claim still prints and still exits 0, so the @@ -91,6 +144,12 @@ # untouched, so the ready queue is judged by whoever reads the log. A case # asserting the string but not the status would survive it. #MUTANT todo-refusal-is-a-note|s@^ report "\$id" "todo-not-ready"@ note "$id" "todo-not-ready"@|a Todo issue with no Ready block is refused +# +# CLOUD-678's arm, and the mutation is the rewrite that removes the check while +# passing every other row: returning "resolved" for a blocker nobody piped. The +# `in_set` guard becomes vacuously true, the row reaches the frontier, and the +# question "is this blocker done" is answered by never having been asked. +#MUTANT absent-blocker-reads-as-resolved|s@^ if ! in_set "$to"; then@ if false; then@|a blocker outside the piped set is unjudgeable, not resolved set -euo pipefail lint="$(dirname "$0")/ready-lint.sh" @@ -132,8 +191,114 @@ unjudged() { by_num() { sort -t- -k2,2n; } ids=$(jq -r '.[].id' <<<"$issues" | by_num) -in_set() { grep -qxF "$1" <<<"$ids"; } -status_of() { jq -r --arg id "$1" '.[] | select(.id == $id) | .status' <<<"$issues"; } + +# --- THE JOINS ARE INDEXED, AND THE INDEX IS BUILT ONCE (CLOUD-634) ----------- +# +# These three lookups are the plumbing under every predicate below, and each used +# to be a linear rescan paid PER NODE OR PER EDGE rather than once per run: +# `status_of` ran a fresh `jq` over the WHOLE payload array to resolve one id and +# is called from four loops; `in_set` ran `grep -qxF` once per edge; adjacency ran +# `grep -E "^$id "` over the whole edge list inside the loop over every id, which +# is O(V·E) with a process per node. +# +# The board is small, which is why it never bit. What makes it worth fixing is +# WHERE this runs: every fan-out member computes the frontier independently, +# because that shared determinism is what replaces a dispatcher +# (`mem:workflow/agent-fanout`), so the cost is per session per member and grows +# with the board rather than with the work. +# +# It is an INDEXING defect in the plumbing, not an algorithmic one in the decision. +# Every genuinely graph-shaped step is already delegated to an exact tool — cycles +# to `tsort`, ancestry to `git merge-base --is-ancestor` — and none is at fault. +# +# ─── WHY PARAMETER EXPANSION AND NOT AN ASSOCIATIVE ARRAY ──────────────────── +# +# `mise-tasks/**` must stay bash 3.2 and BSD portable: macOS ships bash 3.2, and +# `no-bash4-mapfile`, `no-bash4-wait-n`, `no-gnu-sed-in-place` and +# `no-gnu-xargs-r` already deny the usual shortcuts. CI runs ubuntu and is +# structurally blind to all of them, so an associative array would pass here and +# fail on a contributor's machine. `declare -A` is bash 4. +# +# So the index is a newline-delimited string and the lookup is parameter +# expansion, which runs IN PROCESS: no `jq`, no `grep`, no fork per lookup. Each +# record is `\n\t` and both delimiters are structural — an issue key +# carries neither, so no record can be confused with part of another. +# +# The verdicts are unchanged, and that is the whole obligation: this alters how a +# lookup is resolved and no predicate, rule id or exit code. The suite's existing +# cases ARE the parity assertion, which is why none of them moved. +status_index=$(jq -r '.[] | "\(.id)\t\(.status)"' <<<"$issues") +status_index=$'\n'"$status_index"$'\n' +id_index=$'\n'"$ids"$'\n' + +# Membership: is this key one of the piped rows? +in_set() { [[ "$id_index" == *$'\n'"$1"$'\n'* ]]; } + +# The status the board gave this row, or the empty string when the set does not +# carry it. The empty answer is load-bearing and has two readers — the +# status-claim scan reports `status-claim-unjudgeable` on it, and the frontier +# loop guards with `in_set` before trusting it (CLOUD-678). +status_of() { + local rest=${status_index#*$'\n'"$1"$'\t'} + [[ "$rest" != "$status_index" ]] || return 0 + printf '%s' "${rest%%$'\n'*}" +} + +# The board's TYPE for this row's column, or the empty string when the set does not +# carry it. CLOUD-477 needs this because the frontier resolved a blocker by column +# NAME, and a name match cannot see a terminal column it was not told about. +statustype_index=$(jq -r '.[] | "\(.id)\t\(.statusType // "")"' <<<"$issues") +statustype_index=$'\n'"$statustype_index"$'\n' +statustype_of() { + local rest=${statustype_index#*$'\n'"$1"$'\t'} + [[ "$rest" != "$statustype_index" ]] || return 0 + printf '%s' "${rest%%$'\n'*}" +} + +# IS THIS BLOCKER SETTLED? (CLOUD-477.) The frontier loop asked `case "$(status_of +# …)" in Done | "In Review")`, so anything not literally one of those two names fell +# into the catch-all and blocked. Two terminal columns land there, and both mean +# "this work will never be done, and that is settled" — so the dependent was +# excluded from the frontier PERMANENTLY, with no path back but a human noticing. +# Worse than a wrong answer, because the exclusion prints as `excluded (blocked-by +# …)` and reads exactly like a legitimate block. +# +# MEASURED, AND THE ROW'S OWN §1 IS WRONG ABOUT IT. That clause says the payload +# carries "a canceled type covering both `Canceled` and `Duplicate`". It does not: +# `Canceled` is `canceled` and `Duplicate` is `duplicate`, two distinct values +# (2026-08-23, over the live board). Keying on one of them would have fixed half the +# defect and left `Duplicate` starving — including CLOUD-335, the example the row +# itself cites. Both are named here for that reason. +# +# `In Review` STAYS NAME-BASED, and that is not an oversight: its type is `started`, +# the same value `In Progress` carries, so a type-only rule would starve every row +# behind landed-but-unreleased work. Confirmed on the live board. +# +# One test per line and no `|` in any of them, so each arm is separately mutable — +# `mutant.sh` reads a declaration with `IFS='|'`, and a pattern carrying its own pipe +# shifts every field after it. +blocker_resolved() { # blocker_resolved + local t + t=$(statustype_of "$1") + [[ "$t" = completed ]] && return 0 + [[ "$t" = canceled ]] && return 0 + [[ "$t" = duplicate ]] && return 0 + [[ "$(status_of "$1")" = "In Review" ]] && return 0 + return 1 +} + +# Does this row carry a milestone at all — `set`, `null`, or the empty string when +# the set does not carry the row? CLOUD-599's clause needs the PARENT's answer while +# the loop below is on the child, and presence is the whole question (see the clause +# for why identity is not). Same shape and same bash-3.2 reason as `status_of`: +# parameter expansion in process, never `declare -A`. +milestone_col_index=$(jq -r '.[] | "\(.id)\t\(if (.projectMilestone // null) == null then "null" else "set" end)"' <<<"$issues") +milestone_col_index=$'\n'"$milestone_col_index"$'\n' +milestone_col_of() { + local rest=${milestone_col_index#*$'\n'"$1"$'\t'} + [[ "$rest" != "$milestone_col_index" ]] || return 0 + printf '%s' "${rest%%$'\n'*}" +} # --- the milestone claim's anti-vacuity arm (CLOUD-695) ----------------------- # @@ -156,15 +321,19 @@ status_of() { jq -r --arg id "$1" '.[] | select(.id == $id) | .status' <<<"$issu # is genuinely unmilestoned reads as projected-away and reports unjudgeable. That # is the conservative direction — could-not-look, never a wrong answer — and the # message names the fix, which a caller can take in one re-fetch. +# THE SET IT SCOPES TO WIDENS WITH THE CLAUSE (CLOUD-771). It was the Todo ids; +# it is now every STARTED row, because a set holding only In Progress and In Review +# rows would otherwise report unjudgeable where it should report violations — the +# arm going stale in the quiet direction the moment the clause it guards grew. milestone_judgeable=1 -todo_ids=$(jq -r '[.[] | select(.status == "Todo") | .id] | join(" ")' <<<"$issues") -if [[ -n "$todo_ids" ]] && ! jq -e 'any(.[]; has("projectMilestone"))' <<<"$issues" >/dev/null 2>&1; then +started_ids=$(jq -r '[.[] | select(.status == "Todo" or .status == "In Progress" or .status == "In Review") | .id] | join(" ")' <<<"$issues") +if [[ -n "$started_ids" ]] && ! jq -e 'any(.[]; has("projectMilestone"))' <<<"$issues" >/dev/null 2>&1; then milestone_judgeable=0 - unjudged "graph" "unjudgeable-milestone ($(tr ' ' '\n' <<<"$todo_ids" | by_num | tr '\n' ' ' | sed 's/ $//'))" + unjudged "graph" "unjudgeable-milestone ($(tr ' ' '\n' <<<"$started_ids" | by_num | tr '\n' ' ' | sed 's/ $//'))" fi # --- the three board predicates ----------------------------------------------- -while IFS=$'\t' read -r id status assignee prs milestone; do +while IFS=$'\t' read -r id status assignee prs milestone parent; do if [[ "$status" = "In Progress" ]] && [[ "$assignee" = "null" ]]; then report "$id" "in-progress-unassigned" fi @@ -190,10 +359,101 @@ while IFS=$'\t' read -r id status assignee prs milestone; do # a child inherits its parent's phase — and the two compose rather than overlap: # that one ranges over PARENTED issues, and most of the 174 have no parent at # all, so every one of them passes it. - if [[ "$milestone_judgeable" = 1 ]] && [[ "$status" = "Todo" ]] && [[ "$milestone" = "null" ]]; then - report "$id" "todo-unmilestoned" + # CLOUD-771 WIDENED THIS FROM Todo TO EVERY STARTED COLUMN, and the reason is + # that the seam was placed where the claim is weakest. Todo is the column an + # agent is instructed to LEAVE as fast as possible — AGENTS.md says claim before + # writing code — so the ordinary compliant workflow was itself the escape. + # Measured: CLOUD-769 was filed unphased, sat in the gated column for 138 + # seconds, and left it unphased with no rule broken and no gate fired. Over the + # live board, 20 unparented rows outside Todo carried no milestone: 4 In + # Progress, 16 In Review, against 0 in Todo, which had been swept that day. + # + # In Progress and In Review are STRONGER claims than "pullable", not weaker: one + # says work is being done and the other that it landed. Backlog stays out + # deliberately — that is what CLOUD-505 bought, and demanding a phase at file + # time taxes triage when the issue is least understood. Done, Canceled and + # Duplicate stay out because a closed row's phase changes nothing. + # + # A PARENTED ROW IS SKIPPED, so CLOUD-599's `child-unmilestoned` owns it and no + # row is reported twice. That clause ranges over `(child, parent)` pairs and + # inherits the parent's phase; this one ranges over rows with no parent to + # inherit from, which is the overwhelming majority. + if [[ "$milestone_judgeable" = 1 ]] && [[ "$parent" = "null" ]] && + [[ "$status" = "Todo" || "$status" = "In Progress" || "$status" = "In Review" ]] && + [[ "$milestone" = "null" ]]; then + # THE RULE ID CARRIES THE COLUMN NOW, rather than naming one. `todo-unmilestoned` + # was accurate while the clause was Todo-only and would be a lie in either + # direction once it is not — a reader seeing it on an In Review row would + # mistrust the gate, and a second id per column would be two names for one + # predicate. `released`'s `refusal_for` reads the first `[a-z-]+` token, so + # `unmilestoned` still resolves for it. + report "$id" "unmilestoned ($status)" fi -done < <(jq -r '.[] | [.id, .status, (.assigneeId // "null"), ([.attachments[]? | select(.url | test("github.com/.*/pull/"))] | length), (if (.projectMilestone // null) == null then "null" else "set" end)] | @tsv' <<<"$issues" | by_num) + + # --- CLOUD-599: a child inherits its parent's phase ------------------------ + # + # "Is Phase 3 done" had two answers over two different sets and nothing + # reconciled them: milestone membership is what the progress bar counts, the + # epic tree is what the epics promise ("they carry no work of their own and + # close when their children close"). Measured over 39 children of the three + # Phase 3 epics, the sets disagreed in BOTH directions — and the milestone + # could therefore read 100% with four issues its own epics parent still open, + # which is a completion signal that means nothing. + # + # THE DECISION IS THE ROW'S, taken 2026-08-17 and not re-litigated here: the + # epic tree is authoritative for phase membership. A child of a milestoned + # parent belongs to that parent's phase unless deliberately re-phased, and a + # re-phase must be DECLARED by carrying the other milestone rather than by + # carrying none. That is what makes the epics' promise meaningful. + # + # FOUR ARMS, and only one of them refuses: + # + # parent not in the piped set -> unjudgeable. A closure that does not carry + # the parent cannot answer, and guessing from + # its absence is CLOUD-678's defect exactly — + # which is why this reuses `in_set` rather + # than minting a second reading of it. + # parent carries no milestone -> pass. No pair can diverge, and reporting it + # would fire on the ordinary shape. + # child carries a DIFFERENT one -> pass. The declared re-phase, and the + # difference between a gate and a nuisance. + # Four live rows depend on this arm. + # child carries NONE -> REFUSED. The silent case, all eight live + # instances, and the only one where the + # divergence is absent from the data instead + # of recorded in it. + # + # THE REFUSAL NEEDS PRESENCE, NEVER IDENTITY, and getting that wrong cost a + # regression worth recording. The first cut of this clause projected the + # milestone's ID so it could compare the child's against the parent's — on the + # reasoning that telling a declared re-phase from a silent gap needs identity. + # It does not: BOTH the same-milestone and different-milestone arms PASS, so the + # only distinction the refusal draws is "the child carries one" against "the + # child carries none". Identity would only ever be used to decide not to refuse. + # + # The cost of the wrong cut was measured rather than argued: making `.id` + # load-bearing read `tests/board-sweep.bats`'s `projectMilestone: {name: "m"}` — + # a fixture with no `id` — as ABSENT, so two of that suite's cases went red on a + # clean tree and `mutant` reported them `case-already-red`. Collapsing "present + # but idless" into "absent" is the same could-not-look conflation this file + # fixes everywhere else, and the boolean projection never had it. + # + # Pointer-only: the child, the rule, and the parent. §5 suggests naming the + # milestone too; a name is a wide free-text field that decides nothing here, and + # `released`'s `refusal_for` reads the first `[a-z-]+` token either way. + if [[ "$milestone_judgeable" = 1 ]] && [[ "$parent" != "null" ]]; then + if ! in_set "$parent"; then + unjudged "$id" "child-milestone-unjudgeable (parent $parent not in the set)" + elif [[ "$(milestone_col_of "$parent")" = "set" ]] && [[ "$milestone" = "null" ]]; then + report "$id" "child-unmilestoned (parent $parent)" + fi + fi + # EVERY FIELD CARRIES A PLACEHOLDER, never the empty string. Tab is whitespace to + # `read`, so consecutive tabs COLLAPSE and one empty column shifts every field after + # it left — the defect measured on `duplicate-close-check` this same day, where a row + # with no timestamp read its own duplicate target as the timestamp and then passed. + # `parentId` is the field CLOUD-771 added and the one most often absent. +done < <(jq -r '.[] | [.id, .status, (.assigneeId // "null"), ([.attachments[]? | select(.url | test("github.com/.*/pull/"))] | length), (if (.projectMilestone // null) == null then "null" else "set" end), (.parentId // "null")] | @tsv' <<<"$issues" | by_num) # --- graph coherence ---------------------------------------------------------- # @@ -216,10 +476,50 @@ fi edges=$(jq -r '.[] | .id as $id | .relations.blockedBy[]?.id | "\($id) \(.)"' <<<"$issues" | by_num) +# ADJACENCY, INDEXED (CLOUD-634). The frontier loop asked `grep -E "^$id "` over +# the whole edge list once per id — O(V·E) with a process per node. This walks the +# list once, in process, and emits the same ` ` lines in the same order, +# so the loop that reads it is untouched. `while read` over a here-string rather +# than parameter expansion here because the answer is a SET of lines, not one +# value, and the caller wants them one at a time. +edges_from() { # edges_from -> the edge lines whose FROM is + local want=$1 line + while IFS= read -r line; do + [[ "$line" == "$want "* ]] || continue + printf '%s\n' "$line" + done <<<"$edges" +} + +# `dangling-blocker` IS AN UNJUDGED ARM, NOT A VIOLATION (CLOUD-678), and it is +# set-keyed like the two arms above rather than keyed to the dependent. +# +# THE OPEN CALL THIS ROW WAS ASKED TO DECIDE, with the rejected option recorded. +# Rejected: keep it exit 1 on the argument that it is the anti-vacuity guard and +# weakening it lets a caller project edges away. That argument belongs to +# `unjudgeable-blockedby` above, which fires when the KEY is absent — a caller who +# projects the relations away is caught there, and this arm never was that guard. +# +# What decided it is a measurement rather than the balance of arguments. Linear +# does NOT drop `blockedBy` when the blocker completes (CLOUD-661 has been Done +# since 2026-08-18T23:01:59Z and both dependents still carry the edge), so an +# active-only closure — the closure the workflow actually prescribes — carries an +# edge to a Done ancestor for every landed blocker. Measured on `b2f8992`: piping +# `{672, 674}` produced `dangling-blocker` twice over a board that was correct. A +# violation that fires on correct input trains readers to ignore it. +# +# And it makes the two arms agree, which is the part that could not be left: one +# fact — a blocker outside the piped closure — was exit 1 here and about to become +# exit 2 in the frontier loop below. `released`'s `refusal_for` already carries a +# hand-written `grep -vx 'dangling-blocker'` to undo the id-keying; set-keying it +# makes that filter structurally unnecessary rather than merely unused. +out_of_closure="" while read -r from to; do [[ -n "$from" ]] || continue - in_set "$to" || report "$from" "dangling-blocker ($to)" + in_set "$to" || out_of_closure="$out_of_closure $to" done <<<"$edges" +if [[ -n "$out_of_closure" ]]; then + unjudged "graph" "dangling-blocker ($(tr ' ' '\n' <<<"${out_of_closure# }" | sort -u | by_num | tr '\n' ' ' | sed 's/ $//'))" +fi if [[ -n "$edges" ]] && ! tsort <<<"$edges" >/dev/null 2>&1; then cycle=$(tsort <<<"$edges" 2>&1 >/dev/null | grep -oE 'CLOUD-[0-9]+' | by_num | sort -u | tr '\n' ' ' || true) @@ -413,18 +713,62 @@ while read -r id; do continue ;; esac + # THREE ARMS, NOT TWO (CLOUD-678). `status_of` is a `jq` select over the piped + # payloads, so for an id that is not in the set it returns the empty string — + # which fell into this case's catch-all and converted "I was not given this + # blocker" into "this blocker has not completed". Measured on `b2f8992`: two + # Todo rows whose only blocker had completed the night before were withheld + # from the frontier, over a closure that prescribes excluding Done rows. + # + # The discriminator is `in_set`, the file's own predicate, rather than a new + # sentinel from `status_of` — the status-claim scan above already resolves the + # same ambiguity that way (`status-claim-unjudgeable`), and a sentinel would + # have to be taught to every reader of that function to answer one of them. + # + # So an out-of-closure blocker is UNJUDGED and never a note: a silent frontier + # omission is what made this invisible, because `excluded (blocked-by …)` reads + # identically to a legitimate block and an empty frontier reads as "nothing is + # ready" — which CLOUD-607's acceptance treats as success. ok=1 blocking="" + unknown="" + retired="" while read -r _ to; do [[ -n "$to" ]] || continue - case "$(status_of "$to")" in Done | "In Review") ;; *) + if ! in_set "$to"; then + ok=0 + unknown="$unknown $to" + continue + fi + if ! blocker_resolved "$to"; then ok=0 blocking="$blocking $to" - ;; + continue + fi + # A blocker that resolved because it was RETIRED rather than completed is + # collected, not silently swallowed — see the note below. + case "$(statustype_of "$to")" in + canceled | duplicate) retired="$retired $to" ;; esac - done < <(grep -E "^$id " <<<"$edges" || true) + done < <(edges_from "$id") if [[ "$ok" = 1 ]]; then frontier+=("$id") + # CLOUD-477's SECOND DECISION, taken rather than assumed. Resolving a retired + # blocker is right for the frontier and is not obviously right for the WORK: an + # issue whose blocker was cancelled may have had its premise removed with it. So + # the row is schedulable AND the reason is on the record. A `note`, so the exit + # code is unmoved — this is information about a coherent board, not a finding. + [[ -z "$retired" ]] || note "$id" "frontier-over-retired-blocker${retired}" + elif [[ -n "$unknown" ]]; then + # Keyed to the ISSUE rather than to `graph`, unlike the arm above: this one + # is why THIS row is off the frontier, so a reader needs the dependent's id + # to act on it. The blockers it could not resolve are named beside it. + # The id-keying that would be wrong above is safe here for a structural + # reason rather than by luck: this loop judges only `Todo` rows, and + # `released`'s `refusal_for` asks only about `In Review` ones, so no line + # from here can reach it. `excluded (unjudgeable-ready-block)` above is + # already id-keyed on the same argument. + unjudged "$id" "excluded (unjudgeable-blocker${unknown}${blocking})" else note "$id" "excluded (blocked-by${blocking})" fi diff --git a/mise-tasks/ready-cites-check.sh b/mise-tasks/ready-cites-check.sh index fbf8e9af3..a90f3ff8b 100755 --- a/mise-tasks/ready-cites-check.sh +++ b/mise-tasks/ready-cites-check.sh @@ -72,12 +72,68 @@ # payload could not be read or the tree could not be resolved — matching # `ready-lint` and `spec-ref-check` so all three compose under one contract. # +# ─── A PATH HAS THREE ANSWERS, NOT TWO (CLOUD-920) ─────────────────────────── +# +# "Zero-judgement: the file is there or it is not" was one bit where the question +# needs two, and it collapsed in the direction that punishes an author for being +# precise. A §7 obligation naming the suite its own row exists to WRITE cites a +# path that is absent BY DESIGN. Measured over one session's ten-row closure: +# three refusals, CLOUD-359, CLOUD-361 and CLOUD-920 — every one a §7 test +# obligation, not one a stale citation. Precision on this arm was zero, and the +# third row is the row filed to fix it. The cheapest way to pass was to stop +# naming the file, which is the opposite of what CLOUD-826 wanted. +# +# So: resolves / refused / PROSPECTIVE, the fourth value CLOUD-251's split needs +# here — not "is", "is not" or "could not look", but "not yet, by design". +# +# ─── WHICH MECHANISM DRAWS THE LINE, AND WHY THE CHEAPER TWO LOST ──────────── +# +# CLOUD-920 §2 named three candidates and made the choice this row's. Decided, and +# the rejected options recorded because a later reader will reach for them again: +# +# REJECTED — the row's own STATUS. "A row not yet In Progress cannot have written +# its tests" is cheap and already in the payload, and it is wrong for exactly the +# row that matters: CLOUD-920 was In Progress while its own citation was still +# prospective, so the rule would refuse the row it exists to fix, at the moment +# it was being fixed. +# +# REJECTED AS THE PRIMARY TERM — git history. `--diff-filter=D` genuinely +# discriminates deleted from never-written, and it is BLIND in the ordinary +# environment. Measured 2026-08-23 on a web-session clone: it is shallow, and +# `tests/memory-guard.bats` — deleted by CLOUD-442 — returns nothing, the same +# answer as a path that never existed. As the primary term it would silently +# restore CLOUD-826's defect in every web session, which is the one outcome +# worse than the false positive being fixed. +# +# CHOSEN — an explicit spelling, with history kept as the corroborating term. +# A citation the block marks `(new)` is prospective; an unmarked absence is +# CLOUD-826's refusal, unchanged. It puts the burden on the author, which is the +# cost, and it keeps the gate a pure function of the payload plus the tree — +# the property every board gate here holds. History is then asked only to +# REFUTE a marker (a path deleted in an ancestor was present, so `(new)` is +# false), never to grant one, so where it cannot look nothing is forgiven that +# would not have been forgiven anyway. +# +# The marker is matched WITH ITS PATH — "`` (new)" — so one `(new)` written +# elsewhere in a block cannot excuse every citation in it. +# +# A prospective citation is reported on stderr and leaves the exit code unmoved, +# which is `graph-check`'s `note` shape. Reported rather than skipped: skipping is +# CLOUD-826's defect restored, and the count is in the summary line either way. +# # The mutation admits `tests/fixtures/` back into the corpus, so a citation that # resolves only against a quotation of itself passes — the vacuity above, restored. #MUTANT fixtures-satisfy-a-citation|s@:!:tests/fixtures/@:!:tests/no-such-dir/@|resolves only in a fixture and nowhere else # And the block-selection mutation: read the FIRST opener rather than the last, so # a superseded clause's stale citations are judged as live obligations. #MUTANT superseded-block-is-judged|s@tail -n1@head -n1@|the last opener is the live block +# CLOUD-920's two arms, and they mutate in opposite directions. The first drops the +# marker requirement, so every absent path becomes prospective — CLOUD-826's defect +# restored, which is the one thing the fix must not buy. +#MUTANT marker-not-required|s@^ if grep -qF -- "\\`$p\\` (new)" <<<"$block"; then@ if true; then@|an unmarked absent path is still refused +# The second removes the anti-forgery term, so a `(new)` marker on a path that was +# DELETED passes — an author's claim believed over the history that refutes it. +#MUTANT marker-outranks-history|s@^ if \[\[ -n "$history" \]\] .*@ if false; then@|a marker on a deleted path is refused, not believed set -uo pipefail # THE ROOT IS `git::repo_root`'S ANSWER, NEVER `--show-toplevel` (CLOUD-824). That @@ -140,8 +196,25 @@ CLAUSE_7='^[[:space:]]*([*-][[:space:]]*)?\*\*[^*]*\((§|clause )7\)|^#{2,6}[[:s findings=0 resolved=0 cited=0 +prospective=0 first_finding=1 reports="" +notes="" + +# CAN HISTORY BE ASKED WHETHER A PATH ONCE EXISTED? (CLOUD-920.) A shallow clone +# cannot answer, and that is not an edge case: a Claude Code web session clones +# shallow, so this is the ordinary environment. Measured 2026-08-23 on such a +# clone — `git log --diff-filter=D -- tests/memory-guard.bats`, a path retired by +# CLOUD-442, returns NOTHING, indistinguishable from a path that never existed. +# +# That measurement is why history is the CORROBORATING term here rather than the +# discriminating one. It is asked only to REFUTE a `(new)` marker, never to grant +# one, so where it cannot look the marker stands on its own and no absence is +# silently forgiven. +history="" +if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = false ]]; then + history=1 +fi report() { reports="${reports} $1"$'\n' @@ -200,23 +273,60 @@ while IFS= read -r key; do done # (2) CITED PATHS, anywhere in the live block. A backticked token carrying a `/` - # and ending in a source extension. Zero-judgement: the file is there or it is - # not. + # and ending in a source extension. THREE OUTCOMES, not two (CLOUD-920). # shellcheck disable=SC2016 # the backticks are literal markdown, not a subshell for p in $(grep -oE '`[A-Za-z0-9_./-]+\.(rs|toml|yml|yaml|bats|md|json|pkl|sh|lock|rego)`' <<<"$block" 2>/dev/null | tr -d '`' | grep -F / | sort -u); do cited=$((cited + 1)) if [[ -e "$p" ]]; then resolved=$((resolved + 1)) - else - report "$key §1 $p absent-cited-path" + continue + fi + # PROSPECTIVE, and only when the block SAYS SO. `(new)` immediately after the + # backticked path is the marker; anything else absent stays CLOUD-826's + # refusal. The marker is matched against the path so a single `(new)` + # elsewhere in the block cannot excuse every citation in it. + if grep -qF -- "\`$p\` (new)" <<<"$block"; then + # THE ANTI-FORGERY TERM. The marker is the author's claim that this file + # does not exist yet; history is the one place that can contradict it. A + # path DELETED in an ancestor was present, so `(new)` is false and the + # citation is the stale obligation CLOUD-826 exists to refuse — marking it + # must not buy a pass. + if [[ -n "$history" ]] && [[ -n "$(git log --format=%h --diff-filter=D -1 -- "$p" 2>/dev/null)" ]]; then + report "$key §1 $p stale-cited-path" + continue + fi + prospective=$((prospective + 1)) + # `note`, not `report`: the exit code is unmoved, and the pointer is still + # emitted so a prospective citation is legible rather than skipped. + notes="${notes} $key §1 $p prospective-cited-path"$'\n' + continue fi + report "$key §1 $p absent-cited-path" done done < <(jq -r '.[] | .id // empty' <<<"$issues" 2>/dev/null || true) +# NOTES BEFORE THE VERDICT, and on stderr either way: a prospective citation is +# information about a correct block, so it must not be able to change the exit +# code, and it must not be buried under a refusal that came after it. +if [[ -n "$notes" ]]; then + printf '%s' "$notes" | sort >&2 + if [[ -z "$history" ]]; then + # THE HONEST LIMIT, stated rather than left to be discovered. In a shallow + # clone the anti-forgery term above cannot fire, so a `(new)` marker on a + # genuinely DELETED path reads as prospective — CLOUD-826's direction. The + # gate says so instead of implying it was checked. + echo "::notice:: ready-cites-check: $prospective prospective citation(s) above, and this clone is SHALLOW — so \`(new)\` could not be checked against history. A marker on a path that was deleted rather than never written is not detectable here; CI runs against a full clone, where it is." >&2 + fi +fi + if [[ "$findings" -ne 0 ]]; then [[ "$first_finding" = 1 ]] && echo "::error:: ready-cites-check: a Ready block cites something the tree does not carry. This checks EXISTENCE, never relevance — whether a test that exists is the right test is not computable (CLOUD-93). A citation resolving only under tests/fixtures/ is refused, because a fixture quoting the citation is not the thing cited:" >&2 printf '%s' "$reports" | sort >&2 echo "::error:: ready-cites-check: $findings of $cited citation(s) resolve nothing" >&2 exit 1 fi -echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree" +if [[ "$prospective" -gt 0 ]]; then + echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree; $prospective prospective" +else + echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree" +fi diff --git a/mise-tasks/ready-lint.sh b/mise-tasks/ready-lint.sh index e54ceea37..227d885fd 100755 --- a/mise-tasks/ready-lint.sh +++ b/mise-tasks/ready-lint.sh @@ -39,6 +39,13 @@ # discriminating case is a `no bump` type: a releasable one collapses to the # same answer either way, so it would pass under the mutation and prove nothing. #MUTANT break-read-off-the-whole-line|s/\[\[ "\$type_token" == \*.!.\* \]\]/grep -qE "!" <<<"$bump_line"/|denying a break +# CLOUD-806's two arms. The first drops the body emission, so a consumer reading +# the derived fact silently gets nothing and cannot tell that from an empty body. +#MUTANT emission-dropped|s@^emit_keys cites-body .*@true@|the body's cited keys are emitted before any verdict +# The second emits it AFTER the no-ready-block refusal, which is where it was first +# written: the fact then exists for refined rows only, and an unrefined row's stray +# citation — the likeliest kind — becomes invisible. +#MUTANT emission-after-the-verdict|s@^emit_keys cites-body @exit 1 # @|an unrefined body still emits its cited keys set -euo pipefail payload=$(cat) @@ -140,6 +147,35 @@ READY_OPENERS='^\*\*Refinement|^#{2,3} +Refinement|^#{2,3} +Ready|^\*\*Definitio # The parent dialect, needed twice: to locate a block, and to exempt it from the # clause floor below. PARENT_OPENER='^#{2,3} +Refinement gate' +# --- THE DERIVED FACT, PART ONE: THE BODY'S KEYS (CLOUD-806) ------------------ +# +# `cites-body` IS EMITTED HERE, BEFORE THE FIRST VERDICT EXIT, and the position is +# the whole of its correctness. It is a property of the BODY, not of the Ready +# block: an unrefined row still cites rows, and the tracker still mints an edge per +# citation from it. Emitting it after the `no-ready-block` refusal below would make +# the fact unavailable for exactly the rows most likely to carry a stray citation, +# and a consumer would read that absence as "could not look" over a body that was +# read perfectly well. +# +# `cites-blockers` cannot come this early — its span does not exist yet — so it is +# emitted at the §8 scan. The two are separate lines for that reason rather than +# for tidiness: a caller can be handed one set and not the other, and an absent +# line means "this run never got far enough to know", per set. +# +# Byte-stable: numeric by issue number, deduped. `by_key_num` and not a bare sort, +# for `graph-check`'s reason — `CLOUD-10` sorts before `CLOUD-9` lexically, so a +# caller diffing two runs could not tell an ordering change from a content one. +by_key_num() { sort -t- -k2,2n; } +emit_keys() { # emit_keys