session-orchestrator 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +105 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/USER-GUIDE.md +8 -3
  21. package/docs/ci-setup.md +121 -7
  22. package/docs/codex-setup.md +1 -1
  23. package/docs/components.md +6 -6
  24. package/docs/cursor-setup.md +22 -9
  25. package/docs/events-schema.md +5 -1
  26. package/docs/instruction-delivery.md +444 -0
  27. package/docs/rule-authoring.md +58 -9
  28. package/docs/session-config-reference.md +244 -9
  29. package/docs/session-config-template.md +39 -3
  30. package/hooks/_lib/guard-source-loader.mjs +467 -0
  31. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  32. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  33. package/hooks/config-protection.mjs +0 -0
  34. package/hooks/enforce-commands.mjs +10 -2
  35. package/hooks/hooks-codex.json +1 -1
  36. package/hooks/hooks-cursor.json +11 -2
  37. package/hooks/hooks-pi.json +10 -0
  38. package/hooks/hooks.json +21 -1
  39. package/hooks/on-session-end.mjs +178 -18
  40. package/hooks/on-session-start.mjs +23 -0
  41. package/hooks/post-bash-write-verify.mjs +977 -0
  42. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  43. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  44. package/hooks/pre-bash-issue-budget.mjs +167 -0
  45. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  46. package/hooks/pre-bash-templates-first.mjs +96 -63
  47. package/hooks/subagent-telemetry.mjs +527 -37
  48. package/package.json +5 -2
  49. package/pi/prompts/contract-version-bump.md +12 -0
  50. package/rules/README.md +32 -0
  51. package/scripts/archive-closed-prds.mjs +12 -22
  52. package/scripts/autopilot-multi.mjs +103 -20
  53. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  54. package/scripts/check-doc-consistency.sh +17 -1
  55. package/scripts/eval-session.mjs +50 -9
  56. package/scripts/fleet-instruction-scan.mjs +141 -0
  57. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  58. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  59. package/scripts/lib/backlog-scan.mjs +39 -6
  60. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  61. package/scripts/lib/ci-status-banner.mjs +75 -12
  62. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  63. package/scripts/lib/command-blocker.mjs +1013 -58
  64. package/scripts/lib/config/config-protection.mjs +2 -1
  65. package/scripts/lib/config/drift-check.mjs +9 -1
  66. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  67. package/scripts/lib/config/issue-budget.mjs +123 -0
  68. package/scripts/lib/config/reconcile.mjs +21 -0
  69. package/scripts/lib/config/section-extractor.mjs +121 -1
  70. package/scripts/lib/config-schema.mjs +23 -3
  71. package/scripts/lib/config.mjs +17 -0
  72. package/scripts/lib/convergence-monitor.mjs +49 -3
  73. package/scripts/lib/description-surface.mjs +535 -0
  74. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  75. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  76. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  77. package/scripts/lib/eval/engine.mjs +47 -5
  78. package/scripts/lib/events.mjs +59 -7
  79. package/scripts/lib/gates/gate-full.mjs +15 -3
  80. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  81. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  82. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  83. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  84. package/scripts/lib/handover-gate.mjs +7 -3
  85. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  86. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  87. package/scripts/lib/io.mjs +345 -10
  88. package/scripts/lib/issue-budget.mjs +269 -0
  89. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  90. package/scripts/lib/label-scope.mjs +47 -0
  91. package/scripts/lib/learnings/schema.mjs +43 -3
  92. package/scripts/lib/lock-reaper.mjs +1 -2
  93. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  94. package/scripts/lib/peer-discovery.mjs +645 -0
  95. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  96. package/scripts/lib/product-repo-detect.mjs +9 -8
  97. package/scripts/lib/project-hygiene.mjs +432 -0
  98. package/scripts/lib/quality-gate.mjs +167 -0
  99. package/scripts/lib/recommendations-v0.mjs +1 -1
  100. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  101. package/scripts/lib/reconcile/emitter.mjs +23 -4
  102. package/scripts/lib/reconcile/engine.mjs +147 -39
  103. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  104. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  105. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  106. package/scripts/lib/resource-probe.mjs +19 -0
  107. package/scripts/lib/rule-loader.mjs +6 -0
  108. package/scripts/lib/scope-baseline.mjs +564 -0
  109. package/scripts/lib/scope-gate.mjs +399 -98
  110. package/scripts/lib/session-close-backfill.mjs +61 -6
  111. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  112. package/scripts/lib/session-id.mjs +221 -41
  113. package/scripts/lib/session-lock.mjs +304 -6
  114. package/scripts/lib/session-schema/constants.mjs +22 -3
  115. package/scripts/lib/session-schema/validator.mjs +16 -0
  116. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  117. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  118. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  119. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  120. package/scripts/lib/spiral-carryover.mjs +142 -30
  121. package/scripts/lib/state-md/mission-status.mjs +53 -3
  122. package/scripts/lib/subagents-schema.mjs +43 -9
  123. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  124. package/scripts/lib/tests-src-ratio.mjs +484 -0
  125. package/scripts/lib/validate/check-agents.mjs +56 -0
  126. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  127. package/scripts/lib/validate/check-rules.mjs +217 -35
  128. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  129. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  130. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  131. package/scripts/lib/vault-archive.mjs +17 -2
  132. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  133. package/scripts/lib/vault-mirror/process.mjs +30 -0
  134. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  135. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  136. package/scripts/lib/wave-resource-gate.mjs +115 -11
  137. package/scripts/lib/worktree/listing.mjs +44 -7
  138. package/scripts/mcp-server.sh +17 -3
  139. package/scripts/measure-context-overhead.sh +151 -0
  140. package/scripts/memory-propose.mjs +72 -9
  141. package/scripts/print-applicable-rules.mjs +51 -12
  142. package/scripts/release.mjs +534 -0
  143. package/scripts/run-quality-gate.mjs +123 -5
  144. package/scripts/validate-wave-scope.mjs +182 -17
  145. package/scripts/vault-integration-watcher.mjs +32 -10
  146. package/skills/_shared/config-reading.md +2 -2
  147. package/skills/bootstrap/fast-template.md +1 -1
  148. package/skills/claude-md-drift-check/checker.mjs +145 -28
  149. package/skills/contract-version-bump/SKILL.md +219 -0
  150. package/skills/discovery/SKILL.md +4 -4
  151. package/skills/discovery/issue-templates.md +11 -11
  152. package/skills/discovery/probes-audit.md +1 -1
  153. package/skills/discovery/probes-feature.md +1 -1
  154. package/skills/discovery/probes-session.md +26 -5
  155. package/skills/ecosystem-health/SKILL.md +1 -1
  156. package/skills/ecosystem-health/wizard.md +4 -4
  157. package/skills/evolve/SKILL.md +1 -0
  158. package/skills/gitlab-ops/SKILL.md +20 -12
  159. package/skills/gitlab-portfolio/SKILL.md +2 -2
  160. package/skills/hook-development/SKILL.md +1 -1
  161. package/skills/mode-selector/SKILL.md +1 -1
  162. package/skills/npm-publish/SKILL.md +17 -1
  163. package/skills/plan/SKILL.md +5 -5
  164. package/skills/plan/mode-feature.md +4 -4
  165. package/skills/plan/mode-new.md +10 -10
  166. package/skills/plan/mode-retro.md +1 -1
  167. package/skills/quality-gates/SKILL.md +1 -1
  168. package/skills/reconcile/SKILL.md +21 -4
  169. package/skills/session-end/SKILL.md +34 -13
  170. package/skills/session-end/discovery-scan.md +4 -2
  171. package/skills/session-end/drift-operations.md +4 -4
  172. package/skills/session-end/metrics-collection.md +13 -0
  173. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  174. package/skills/session-end/phase-3-6-tail.md +2 -1
  175. package/skills/session-end/plan-verification.md +5 -2
  176. package/skills/session-end/vault-operations.md +1 -1
  177. package/skills/session-end/verification-checklist.md +1 -1
  178. package/skills/session-plan/SKILL.md +6 -2
  179. package/skills/session-plan/wave-template.md +2 -0
  180. package/skills/session-start/SKILL.md +73 -7
  181. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  182. package/skills/test-runner/SKILL.md +2 -2
  183. package/skills/vault-sync/validator.mjs +108 -7
  184. package/skills/wave-executor/SKILL.md +5 -2
  185. package/skills/wave-executor/circuit-breaker.md +2 -0
  186. package/skills/wave-executor/wave-loop.md +163 -10
  187. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-test-value-bans.mjs — Advisory scan for the two lint-enforceable test
4
+ * bans from `.claude/rules/testing.md` § "Lint-Enforceable Test Bans".
5
+ *
6
+ * WARN-ONLY (v1): findings never fail the process. Exit is 0 whenever the scan
7
+ * completed, so this can be wired into a pre-commit hook as a pure advisory.
8
+ *
9
+ * Bans:
10
+ * B1 (exact-count) Exact count assertions on DYNAMIC sets:
11
+ * `.toHaveLength(<literal>)`
12
+ * `.length).toBe|toEqual|toStrictEqual(<literal>)`
13
+ * Only a count DERIVED FROM a dynamic set — a directory
14
+ * walk (`readdirSync`/`glob`/`git ls-files`), a registry or
15
+ * export map (`Object.keys`/`values`/`entries`) — is
16
+ * flagged. Such a count drifts on every legitimate catalog
17
+ * growth (`testing.md` § "Dynamic Artifact Counts"); use the
18
+ * floor/ceiling pattern instead. A FIXED arity on a static
19
+ * fixture (a hand-built array, a parsed test record, a hash
20
+ * width) does not drift and is NOT flagged — the subject is
21
+ * traced inline, then back to its nearest assignment, and
22
+ * must reach a dynamic source before the count is a finding.
23
+ * This is the narrowing `testing.md` already prescribes
24
+ * ("count derived from a directory walk / registry / export
25
+ * map"), which v1's blanket literal-match over-reported.
26
+ *
27
+ * Exempt literals: 0 and 1. `toHaveLength(0)` is an
28
+ * EMPTINESS invariant ("no violations") and
29
+ * `toHaveLength(1)` a UNIQUENESS invariant — both are
30
+ * behavioural claims that do not drift when a catalog
31
+ * grows. The growth-drift class starts at 2.
32
+ *
33
+ * Carve-out: a documented integrity anchor (fixed-width
34
+ * hash, protocol-fixed tuple size) is exempted by an
35
+ * `// integrity-anchor: <reason>` comment on the SAME line
36
+ * or on the line immediately ABOVE the assertion.
37
+ *
38
+ * B2 (prose-pin) A test file that reads a `.md` document via readFileSync
39
+ * AND carries >= 3 `toContain(` / `toMatch(` assertions is
40
+ * reported as a SUSPECTED prose pin (TV-002c). Heuristic,
41
+ * not a verdict — a doc-derived value assert is fine, a
42
+ * sentence-presence assert is not.
43
+ *
44
+ * B3 (bare-exit) A bare `expect(<x>.code|status).toBe(0)` inside an
45
+ * `it(`/`test(` block of a test file whose hook-under-test
46
+ * is DENY-CAPABLE, with no discriminator in the same block.
47
+ *
48
+ * Under the `exit 0` PreToolUse protocol (#906) allow AND
49
+ * deny both exit 0, so an exit-code assertion alone passes
50
+ * in BOTH directions — an assert-nothing. stdout is the only
51
+ * channel that still discriminates. Discriminators accepted:
52
+ * `expectAllow` / `expectDeny` / `expectNoDeny` from
53
+ * `tests/_helpers/hook-decision.mjs`, or any `expect(...)`
54
+ * naming `stdout` in the same block.
55
+ *
56
+ * B4 (decision-copy) A hand-rolled, POSITIVE assertion of the hook decision
57
+ * contract in a test file that is not one of the contract's
58
+ * declared owners. This is the mechanical brake on the six
59
+ * local helper copies and the 21 soft
60
+ * `toContain('"permissionDecision":"deny"')` substring
61
+ * asserts that survived the #906 protocol change verbatim.
62
+ *
63
+ * Two contract keys (#941 3b): `permissionDecision`
64
+ * (allow/deny) is unambiguous and flagged in ANY non-owner
65
+ * file; `systemMessage` (the emitWarn warn-envelope carrier)
66
+ * is OVERLOADED with plain hook output, so it is flagged only
67
+ * when the file-under-test is a deny-capable hook — otherwise
68
+ * operator-steer / session-start banner asserts would
69
+ * false-positive. Without the systemMessage key a warn-block
70
+ * copy is invisible (the warn envelope carries no
71
+ * `permissionDecision`): the "census keyed on the payload
72
+ * misses the channel" trap, inside the rule that names it.
73
+ *
74
+ * NOT flagged (each an inverse of the banned shape): comment
75
+ * lines; a line that goes THROUGH the helper
76
+ * (`expectDeny(…).hookSpecificOutput.permissionDecisionReason`);
77
+ * and absence guards (`.toBeUndefined()`, `.not.`), which
78
+ * assert the key is missing rather than re-stating it.
79
+ *
80
+ * B5 (clock-bomb) A hardcoded absolute-date literal asserted against a
81
+ * subject that HAS an injectable clock seam, in a block that
82
+ * does not use it. The expected value is then a function of
83
+ * the wall clock, so the test goes red on a calendar date
84
+ * nobody chose (learning `test-fixture-time-bomb`, conf 0.9:
85
+ * CI turned red on 2026-07-30 with no code change).
86
+ *
87
+ * The seam is PROVEN from the file itself: an id is in scope
88
+ * only when some OTHER block in the same file passes an
89
+ * explicit clock argument (`now:` / `nowMs:` / `clock:`) to
90
+ * it. A function whose public API exposes a clock parameter
91
+ * reads the clock on its main path — that is why the seam
92
+ * exists. Absent that proof the check says nothing, so a
93
+ * pure input→output date function is out of scope by
94
+ * construction rather than by exception list.
95
+ *
96
+ * NOT flagged: blocks that control the clock (`now:` arg,
97
+ * `vi.useFakeTimers` / `vi.setSystemTime`); date literals in
98
+ * INPUT position (only `.toBe`/`.toEqual`/`.toStrictEqual`
99
+ * expected values are read), which leaves the passthrough
100
+ * class (input date === output date) untouched.
101
+ *
102
+ * Per-file opt-out: `// @test-value-bans-allowed` in the first 5 lines skips
103
+ * the file entirely (same convention as check-test-fixture-shapes.mjs's
104
+ * `// @secret-shape-allowed`). Used by this check's own test file, which must
105
+ * embed ban signatures as fixture literals.
106
+ *
107
+ * Usage:
108
+ * check-test-value-bans.mjs [<repo-root>] [--stdin] [--json] [--quiet]
109
+ *
110
+ * <repo-root> defaults to process.cwd()
111
+ * --stdin read newline-separated file paths to scan from stdin instead
112
+ * of enumerating tracked tests/ files (pre-commit staged-only
113
+ * mode — keeps the hook fast). Paths may be relative to root.
114
+ * --json emit a single JSON object on stdout, nothing else
115
+ * --quiet suppress the "no findings" line in human mode
116
+ *
117
+ * Exit codes:
118
+ * 0 — scan completed (findings are ADVISORY and do not change the exit code)
119
+ * 2 — tool error (missing/unreadable root, bad argv)
120
+ */
121
+
122
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
123
+ import { join, isAbsolute, relative } from 'node:path';
124
+ import { execFileSync } from 'node:child_process';
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // argv
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const argv = process.argv.slice(2);
131
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
132
+ const positionals = argv.filter((a) => !a.startsWith('--'));
133
+
134
+ const KNOWN_FLAGS = new Set(['--stdin', '--json', '--quiet', '--help']);
135
+ for (const f of flags) {
136
+ if (!KNOWN_FLAGS.has(f)) {
137
+ console.error(`Unknown flag: ${f}`);
138
+ console.error('Usage: check-test-value-bans.mjs [<repo-root>] [--stdin] [--json] [--quiet]');
139
+ process.exit(2);
140
+ }
141
+ }
142
+
143
+ if (flags.has('--help')) {
144
+ console.log('Usage: check-test-value-bans.mjs [<repo-root>] [--stdin] [--json] [--quiet]');
145
+ console.log('');
146
+ console.log('Advisory scan for the two lint-enforceable test bans (testing.md).');
147
+ console.log(' B1 exact count assertions on dynamic sets (toHaveLength(<n>), .length).toBe(<n>))');
148
+ console.log(' B2 suspected prose pins (.md readFileSync + >=3 toContain/toMatch)');
149
+ console.log(' B3 bare exit-0 assertion on a deny-capable hook (allow and deny both exit 0)');
150
+ console.log(' B4 hook decision contract restated outside tests/_helpers/hook-decision.mjs');
151
+ console.log(' B5 hardcoded date asserted against a clock-seamed subject without using the seam');
152
+ console.log('');
153
+ console.log(' <repo-root> repository root (default: cwd)');
154
+ console.log(' --stdin scan newline-separated paths from stdin (staged-only mode)');
155
+ console.log(' --json machine-readable output on stdout');
156
+ console.log(' --quiet suppress the no-findings line');
157
+ console.log('');
158
+ console.log('Exit: 0 always when the scan completes (warn-only v1); 2 on tool error.');
159
+ process.exit(0);
160
+ }
161
+
162
+ if (positionals.length > 1) {
163
+ console.error('Error: at most one positional <repo-root> argument is accepted');
164
+ process.exit(2);
165
+ }
166
+
167
+ const repoRoot = positionals[0] ?? process.cwd();
168
+ if (!existsSync(repoRoot)) {
169
+ console.error(`Error: repo root does not exist: ${repoRoot}`);
170
+ process.exit(2);
171
+ }
172
+
173
+ const jsonMode = flags.has('--json');
174
+ const quiet = flags.has('--quiet');
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Detection constants
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** Literals that are behavioural invariants rather than growth-drift pins. */
181
+ const EXEMPT_COUNT_LITERALS = new Set([0, 1]);
182
+
183
+ const CARVE_OUT_MARKER = '// integrity-anchor:';
184
+ const MAGIC_COMMENT = '// @test-value-bans-allowed';
185
+ const MAGIC_COMMENT_SCAN_LINES = 5;
186
+
187
+ /** B2 heuristic: how many toContain/toMatch calls make a .md-reading file suspect. */
188
+ const PROSE_ASSERT_THRESHOLD = 3;
189
+
190
+ const B1_PATTERNS = [
191
+ {
192
+ name: 'toHaveLength',
193
+ regex: /\.toHaveLength\(\s*(\d+)\s*\)/g,
194
+ },
195
+ {
196
+ name: 'length-toBe',
197
+ // `[\s)]*` — a SINGLE character class — replaces the former
198
+ // `\s*\)?\s*\)?\s*`. That old shape put three `\s*` groups adjacent around
199
+ // two optional `)`; a whitespace run before a FAILING `.toBe(` could then be
200
+ // partitioned O(n²) ways across those groups, polynomial backtracking (the
201
+ // ReDoS risk this hardening removes). One class matches the same `.length ) )`
202
+ // gap unambiguously in linear time, with no ambiguous quantifier adjacency.
203
+ regex: /\.length[\s)]*\.(?:toBe|toEqual|toStrictEqual)\(\s*(\d+)\s*\)/g,
204
+ },
205
+ ];
206
+
207
+ /**
208
+ * A value derived from a directory walk, registry, or export map — the "dynamic
209
+ * set" B1 targets (`testing.md` § Lint-Enforceable Test Bans: "count derived
210
+ * from a directory walk / registry / export map"). A count over such a set
211
+ * drifts on catalog growth; a fixed arity over a STATIC fixture does not. B1
212
+ * flags a count only when its subject reaches one of these sources.
213
+ */
214
+ const DYNAMIC_SOURCE =
215
+ /\b(?:readdirSync|readdir|globSync|glob|Reflect\.ownKeys|Object\.(?:keys|values|entries|getOwnPropertyNames))\s*\(|\bls-files\b/;
216
+
217
+ const B1_HINT =
218
+ 'use floor/ceiling (toBeGreaterThanOrEqual / toBeLessThanOrEqual) — testing.md § Dynamic Artifact Counts; ' +
219
+ 'or carve out with `// integrity-anchor: <reason>` if the count is a fixed protocol/hash width';
220
+ const B2_HINT =
221
+ 'asserting prose presence in a .md pins wording, not behaviour (test-value.md TV-002c) — ' +
222
+ 'assert the parsed/derived value the doc describes, or delete the test';
223
+ const B3_HINT =
224
+ 'under the exit-0 PreToolUse protocol allow AND deny both exit 0, so this passes in both ' +
225
+ 'directions — use expectAllow(result) / expectDeny(result, reason) from ' +
226
+ 'tests/_helpers/hook-decision.mjs, or assert on stdout in the same block';
227
+ const B4_HINT =
228
+ 'the hook decision contract lives in tests/_helpers/hook-decision.mjs — import expectDeny/' +
229
+ 'expectAllow/expectWarn instead of restating the envelope (permissionDecision or the warn ' +
230
+ 'systemMessage) here; hand-rolled copies survive the next protocol change verbatim (the ' +
231
+ '#906 class: 6 helper copies + 21 soft substring asserts)';
232
+ const B5_HINT =
233
+ 'this subject takes an injectable clock elsewhere in the same file — pass it here too ' +
234
+ '(`{ now: new Date("…") }`) or freeze the clock with vi.setSystemTime(); a hardcoded date ' +
235
+ 'compared against a now-dependent value is a time bomb that goes red on a calendar date ' +
236
+ 'nobody chose (learning test-fixture-time-bomb — CI red 2026-07-30, no code change)';
237
+
238
+ // --- B3: deny-capable-hook exit-code discrimination -------------------------
239
+
240
+ /**
241
+ * A hook is DENY-CAPABLE when its source emits a permission-decision envelope.
242
+ * Derived from `<repoRoot>/hooks/` at scan time rather than hardcoded, so a new
243
+ * deny-capable hook is covered the moment it lands.
244
+ */
245
+ const DENY_EMITTER = /\bemitDeny\b|\bdenyDecision\b|permissionDecision/;
246
+
247
+ /** `const HOOK = …'hooks/<name>.mjs'` / `…'hooks', '<name>.mjs'` (module level). */
248
+ const HOOK_CONST_LINE = /^(?:export\s+)?(?:const|let|var)\s/;
249
+ const HOOK_PATH_REF = /hooks[/\\]([\w-]+\.(?:mjs|sh))\b|['"]hooks['"]\s*,\s*['"]([\w-]+\.(?:mjs|sh))['"]/g;
250
+
251
+ /** `expect(res.code).toBe(0)` and its `.status` / `.exitCode` / toEqual variants. */
252
+ const BARE_EXIT_OK =
253
+ /expect\(\s*[A-Za-z_$][\w$.[\]'"]*\.(?:code|status|exitCode)\s*\)\s*\.(?:toBe|toEqual|toStrictEqual)\(\s*0\s*\)/;
254
+
255
+ /** Anything that still tells allow from deny (or warn) under the exit-0 protocol.
256
+ * `Warn` is the #941-3b `expectWarn` route the sibling adds to hook-decision.mjs
257
+ * for the systemMessage warn-envelope — a block going through it is the outcome
258
+ * these bans exist to produce, so it is never itself flagged. */
259
+ const DECISION_DISCRIMINATOR = /\bexpect(?:Allow|Deny|NoDeny|Warn)\s*\(/;
260
+ const STDOUT_ASSERT = /expect\([^)]*\bstdout\b|\bstdout\b[^\n]*\)\s*\.(?:toBe|toEqual|toContain|toMatch)/;
261
+
262
+ // --- B4: hook-decision contract ownership -----------------------------------
263
+
264
+ /**
265
+ * The permission-decision contract key — unambiguous. Any restatement outside a
266
+ * declared owner is a copy regardless of the file's subject.
267
+ */
268
+ const PERMISSION_DECISION_KEY = 'permissionDecision';
269
+
270
+ /**
271
+ * The warn path's contract key (#941 point 3b). Since W1's `emitWarn`, the
272
+ * operator notice rides a TOP-LEVEL `systemMessage` and carries NO
273
+ * `permissionDecision`, so a hand-rolled warn-contract block was structurally
274
+ * invisible to a B4 keyed only on `permissionDecision` — the repo's own
275
+ * "census-keyed-on-the-payload misses the channel" failure class, reproduced
276
+ * inside the very rule that names it.
277
+ *
278
+ * `systemMessage` is OVERLOADED, though: it is also the plain output of
279
+ * non-decision hooks (operator-steer, the session-start banner). Only
280
+ * `emitWarn`'s systemMessage is the contract `expectWarn` owns, and it appears
281
+ * ONLY in deny-capable-hook tests. So this key's arm of B4 is scope-gated on
282
+ * that (the same gate B3 uses), where `permissionDecision` needs none.
283
+ */
284
+ const SYSTEM_MESSAGE_KEY = 'systemMessage';
285
+
286
+ /**
287
+ * The only files allowed to name the decision contract directly. Each owns a
288
+ * DIFFERENT side of it — none is a consumer-side assertion copy:
289
+ * - the helper itself: the consumer-side assertion contract (SSOT)
290
+ * - io.test.mjs: producer-side — tests the `denyDecision`/`emitDeny` BUILDER
291
+ * in scripts/lib/io.mjs, whose field names are literally its subject matter
292
+ * - pi-hook-bridge.test.mjs: its fixtures are third-party/legacy-protocol hook
293
+ * sources the bridge must translate, not this repo's own contract
294
+ */
295
+ const DECISION_CONTRACT_OWNERS = new Set([
296
+ 'tests/_helpers/hook-decision.mjs',
297
+ 'tests/lib/io.test.mjs',
298
+ 'tests/lib/pi-hook-bridge.test.mjs',
299
+ ]);
300
+
301
+ /** Matchers that RE-STATE the contract (as opposed to asserting its absence). */
302
+ const POSITIVE_MATCHER = /\.(?:toBe|toEqual|toStrictEqual|toContain|toMatch|toMatchObject)\(/;
303
+ const ABSENCE_ASSERT = /\.toBeUndefined\(|\.toBeNull\(|\.not\./;
304
+ /** A quoted JSON-key literal for one contract key — the soft-substring-assert
305
+ * shape from #906 (`toContain('"<key>":"…"')`). */
306
+ const keyLiteralRe = (key) => new RegExp(`["'\\\\]+${key}["'\\\\]+\\s*:`);
307
+
308
+ // --- B5: date-literal time bombs --------------------------------------------
309
+
310
+ /**
311
+ * An absolute date pinned as the EXPECTED value of an equality assertion.
312
+ * Input-position date literals (`created_at: '2026-06-21T…'`) do not match —
313
+ * that is what keeps the passthrough class (input date === output date) out.
314
+ */
315
+ const DATE_EXPECTATION =
316
+ /\.(?:toBe|toEqual|toStrictEqual)\(\s*(['"`])(\d{4}-\d{2}-\d{2}(?:[T ][^'"`]*)?)\1\s*\)/;
317
+
318
+ /** An explicit clock handed to a callee — the seam this ban asks tests to use. */
319
+ const CLOCK_ARG = /\b(?:now|nowMs|nowIso|clock|currentDate)\s*:/;
320
+
321
+ /** Freezing the global clock — equally valid control, but not a seam PROOF. */
322
+ const FAKE_TIMER = /\b(?:useFakeTimers|setSystemTime|advanceTimersByTime|runAllTimers)\b/;
323
+
324
+ /** `import { a, b as c } from './rel.mjs'` — SUT candidates live behind these. */
325
+ const RELATIVE_IMPORT = /import\s+([^;]+?)\s+from\s+['"](\.[^'"]+)['"]/g;
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // File enumeration
329
+ // ---------------------------------------------------------------------------
330
+
331
+ /** Tracked tests/**\/*.test.mjs via git ls-files (repo-wide default mode). */
332
+ function trackedTestFiles() {
333
+ let out;
334
+ try {
335
+ out = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' });
336
+ } catch {
337
+ return []; // not a git repo — nothing to scan
338
+ }
339
+ return out
340
+ .split('\n')
341
+ .filter(Boolean)
342
+ .map((rel) => rel.replace(/\\/g, '/'))
343
+ .filter((rel) => /^tests\/.*\.test\.mjs$/.test(rel));
344
+ }
345
+
346
+ /** Newline-separated paths from stdin (staged-only mode). */
347
+ function stdinFiles() {
348
+ let raw;
349
+ try {
350
+ raw = readFileSync(0, 'utf8');
351
+ } catch {
352
+ return []; // no stdin attached
353
+ }
354
+ return raw
355
+ .split('\n')
356
+ .map((l) => l.trim())
357
+ .filter(Boolean)
358
+ .map((p) => (isAbsolute(p) ? relative(repoRoot, p) : p).replace(/\\/g, '/'));
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Scanning
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /**
366
+ * Return true when the line itself, or the line above it, carries the
367
+ * documented integrity-anchor carve-out marker.
368
+ * @param {string[]} lines
369
+ * @param {number} idx zero-based index of the asserting line
370
+ */
371
+ function hasCarveOut(lines, idx) {
372
+ if (lines[idx].includes(CARVE_OUT_MARKER)) return true;
373
+ const prev = idx > 0 ? lines[idx - 1] : '';
374
+ return prev.trimStart().startsWith('//') && prev.includes(CARVE_OUT_MARKER);
375
+ }
376
+
377
+ /** A `//`, `*` or `/*` line — prose, never an assertion. */
378
+ function isCommentLine(line) {
379
+ const t = line.trimStart();
380
+ return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*');
381
+ }
382
+
383
+ /**
384
+ * The subject expression a B1 assertion pins the length/size of — the text
385
+ * inside the enclosing `expect( … )`, with one trailing `)` stripped. Works for
386
+ * both `expect(<subj>).toHaveLength(n)` (matchIndex at `.toHaveLength`) and
387
+ * `expect(<subj>.length).toBe(n)` (matchIndex at `.length`).
388
+ * @param {string} line
389
+ * @param {number} matchIndex column where the B1 pattern begins
390
+ */
391
+ function b1Subject(line, matchIndex) {
392
+ const before = line.slice(0, matchIndex);
393
+ const ei = before.lastIndexOf('expect(');
394
+ const inner = ei !== -1 ? before.slice(ei + 'expect('.length) : before;
395
+ return inner.replace(/\)\s*$/, '').trim();
396
+ }
397
+
398
+ /** The leading identifier of an expression (`Object` in `Object.keys(x)`). */
399
+ function leadingIdent(expr) {
400
+ const m = /^[(\s]*([A-Za-z_$][\w$]*)/.exec(expr);
401
+ return m ? m[1] : null;
402
+ }
403
+
404
+ /**
405
+ * True when a B1 count is derived from a dynamic set rather than a static
406
+ * fixture. Checked first inline on the subject expression, then by tracing a
407
+ * bare subject identifier back to its NEAREST assignment above the assertion —
408
+ * a walk/registry call on that assignment's right-hand side makes the count
409
+ * dynamic. A subject that never reaches a {@link DYNAMIC_SOURCE} (a hand-built
410
+ * array, a parsed record, a fixed-width hash) is a legitimate static arity and
411
+ * is not flagged. Precision over recall by construction: an undetectable
412
+ * dynamic source simply yields no finding, the correct direction for a mutable
413
+ * advisory.
414
+ * @param {string[]} lines
415
+ * @param {number} idx zero-based index of the asserting line
416
+ * @param {string} subject the length subject expression
417
+ */
418
+ function b1IsDynamic(lines, idx, subject) {
419
+ if (DYNAMIC_SOURCE.test(subject)) return true;
420
+ const id = leadingIdent(subject);
421
+ if (!id) return false;
422
+ const assignRe = new RegExp(
423
+ `(?:const|let|var)\\s+(?:\\{[^}]*\\b${id}\\b[^}]*\\}|${id})\\s*=(?!=)` +
424
+ `|(?:^|[^.\\w$])${id}\\s*=(?!=)`,
425
+ );
426
+ for (let j = idx - 1; j >= 0; j--) {
427
+ if (isCommentLine(lines[j])) continue;
428
+ if (assignRe.test(lines[j])) return DYNAMIC_SOURCE.test(lines[j]);
429
+ }
430
+ return false;
431
+ }
432
+
433
+ /**
434
+ * The set of deny-capable hook basenames under `<repoRoot>/hooks/`.
435
+ * Empty (→ B3 inert) when the root has no hooks/ directory.
436
+ * @returns {Set<string>}
437
+ */
438
+ function denyCapableHooks() {
439
+ const dir = join(repoRoot, 'hooks');
440
+ /** @type {Set<string>} */
441
+ const out = new Set();
442
+ let entries;
443
+ try {
444
+ entries = readdirSync(dir);
445
+ } catch {
446
+ return out;
447
+ }
448
+ for (const name of entries) {
449
+ if (!name.endsWith('.mjs')) continue;
450
+ try {
451
+ if (DENY_EMITTER.test(readFileSync(join(dir, name), 'utf8'))) out.add(name);
452
+ } catch {
453
+ // unreadable — treat as not deny-capable
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /**
460
+ * The hook file(s) a test declares as its subject, read off module-level
461
+ * `const HOOK = …hooks/<name>.mjs` bindings — the uniform convention in this
462
+ * suite. A file that declares none is not a hook test and is out of B3 scope.
463
+ * @param {string[]} lines
464
+ * @returns {string[]} hook basenames
465
+ */
466
+ function declaredHookSubjects(lines) {
467
+ /** @type {Set<string>} */
468
+ const hooks = new Set();
469
+ for (const line of lines) {
470
+ if (!HOOK_CONST_LINE.test(line) || isCommentLine(line)) continue;
471
+ for (const m of line.matchAll(new RegExp(HOOK_PATH_REF.source, 'g'))) {
472
+ hooks.add(m[1] ?? m[2]);
473
+ }
474
+ }
475
+ return [...hooks];
476
+ }
477
+
478
+ /**
479
+ * Split a file into `it(`/`test(` blocks. The block ends at the first later line
480
+ * that closes at the SAME indentation (prettier-formatted `});`), falling back
481
+ * to the next sibling test. Over-inclusion is the safe direction here: a wider
482
+ * block can only reveal MORE discriminators, never invent a finding.
483
+ * @param {string[]} lines
484
+ * @returns {Array<{start: number, end: number}>} half-open [start, end) indices
485
+ */
486
+ function testBlocks(lines) {
487
+ const OPENER = /^(\s*)(?:it|test)(?:\.\w+)*\s*\(/;
488
+ const SIBLING = /^\s*(?:it|test|describe)(?:\.\w+)*\s*\(/;
489
+ const out = [];
490
+ for (let i = 0; i < lines.length; i++) {
491
+ const m = OPENER.exec(lines[i]);
492
+ if (!m) continue;
493
+ const closer = new RegExp(`^${m[1]}\\}\\)`);
494
+ let end = lines.length;
495
+ for (let j = i + 1; j < lines.length; j++) {
496
+ if (SIBLING.test(lines[j])) {
497
+ end = j;
498
+ break;
499
+ }
500
+ if (closer.test(lines[j])) {
501
+ end = j + 1;
502
+ break;
503
+ }
504
+ }
505
+ out.push({ start: i, end });
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /**
511
+ * Identifiers this test file imports from the repo's OWN modules (relative
512
+ * specifiers). These are the subject-under-test candidates for B5; framework
513
+ * (`vitest`) and stdlib (`node:*`) imports are structurally excluded because
514
+ * their specifiers are not relative.
515
+ * @param {string} content
516
+ * @returns {string[]}
517
+ */
518
+ function importedLocalIdentifiers(content) {
519
+ /** @type {Set<string>} */
520
+ const ids = new Set();
521
+ for (const m of content.matchAll(RELATIVE_IMPORT)) {
522
+ const clause = m[1];
523
+ // `{ a, b as c }` → c ; `x` / `* as ns` → x / ns
524
+ for (const part of clause.replace(/[{}]/g, ',').split(',')) {
525
+ const t = part.trim();
526
+ if (!t) continue;
527
+ const alias = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(t);
528
+ const name = alias ? alias[1] : /^([A-Za-z_$][\w$]*)$/.exec(t)?.[1];
529
+ if (name && name !== 'type') ids.add(name);
530
+ }
531
+ }
532
+ return [...ids];
533
+ }
534
+
535
+ /** True when `id` is invoked anywhere in these lines. */
536
+ function callsIdentifier(lines, id) {
537
+ const re = new RegExp(`\\b${id}\\s*\\(`);
538
+ return lines.some((l) => !isCommentLine(l) && re.test(l));
539
+ }
540
+
541
+ /**
542
+ * B5 findings for one file.
543
+ *
544
+ * Two passes over the file's `it`/`test` blocks:
545
+ * 1. PROVE the seam — an imported id called from a block that also hands over
546
+ * an explicit clock argument is clock-seamed. A public API only grows a
547
+ * `now` parameter because the function reads the clock on its main path.
548
+ * 2. FLAG — in blocks with NO clock control at all, any equality assertion
549
+ * pinning an absolute date against such a subject is a time bomb.
550
+ *
551
+ * Recall is deliberately traded for precision: a clock-dependent function that
552
+ * never exposes a seam is invisible here, and that is the correct failure
553
+ * direction for an advisory a developer can mute.
554
+ *
555
+ * @param {string} relPath
556
+ * @param {string} content
557
+ * @param {string[]} lines
558
+ * @returns {Array<{file: string, line: number, ban: string, match: string, hint: string}>}
559
+ */
560
+ function scanClockBombs(relPath, content, lines) {
561
+ const findings = [];
562
+ const sutIds = importedLocalIdentifiers(content);
563
+ if (sutIds.length === 0) return findings;
564
+
565
+ const blocks = testBlocks(lines).map(({ start, end }) => {
566
+ const body = lines.slice(start, end);
567
+ const live = body.filter((l) => !isCommentLine(l));
568
+ return {
569
+ start,
570
+ body,
571
+ hasClockArg: live.some((l) => CLOCK_ARG.test(l)),
572
+ hasFakeTimer: live.some((l) => FAKE_TIMER.test(l)),
573
+ };
574
+ });
575
+
576
+ /** @type {Set<string>} */
577
+ const seamed = new Set();
578
+ for (const b of blocks) {
579
+ if (!b.hasClockArg) continue;
580
+ for (const id of sutIds) if (callsIdentifier(b.body, id)) seamed.add(id);
581
+ }
582
+ if (seamed.size === 0) return findings;
583
+
584
+ for (const b of blocks) {
585
+ if (b.hasClockArg || b.hasFakeTimer) continue;
586
+ const subject = [...seamed].find((id) => callsIdentifier(b.body, id));
587
+ if (!subject) continue;
588
+ b.body.forEach((line, k) => {
589
+ if (isCommentLine(line)) return;
590
+ const m = DATE_EXPECTATION.exec(line);
591
+ if (!m) return;
592
+ findings.push({
593
+ file: relPath,
594
+ line: b.start + k + 1,
595
+ ban: 'B5-date-time-bomb',
596
+ match: `${m[0].trim()} — ${subject}() called without its clock seam`,
597
+ hint: B5_HINT,
598
+ });
599
+ });
600
+ }
601
+ return findings;
602
+ }
603
+
604
+ /**
605
+ * Scan one file's content, returning its findings.
606
+ * @param {string} relPath
607
+ * @param {string} content
608
+ * @param {Set<string>} [denyHooks] deny-capable hook basenames (B3 scope gate)
609
+ */
610
+ function scanContent(relPath, content, denyHooks = new Set()) {
611
+ /** @type {Array<{file: string, line: number, ban: string, match: string, hint: string}>} */
612
+ const findings = [];
613
+ const lines = content.split('\n');
614
+
615
+ if (lines.slice(0, MAGIC_COMMENT_SCAN_LINES).some((l) => l.includes(MAGIC_COMMENT))) {
616
+ return findings;
617
+ }
618
+
619
+ // --- B1: exact count assertions on DYNAMIC sets -----------------------
620
+ // A fixed arity over a static fixture does not drift and is legitimate; only
621
+ // a count whose subject reaches a directory walk / registry / export map is
622
+ // flagged (testing.md § Lint-Enforceable Test Bans). This narrowing is what
623
+ // separates the ~349-finding v1 noise from the handful of real drift pins.
624
+ lines.forEach((line, idx) => {
625
+ for (const { regex } of B1_PATTERNS) {
626
+ const re = new RegExp(regex.source, 'g');
627
+ let m;
628
+ while ((m = re.exec(line)) !== null) {
629
+ if (EXEMPT_COUNT_LITERALS.has(Number(m[1]))) continue;
630
+ if (hasCarveOut(lines, idx)) continue;
631
+ if (!b1IsDynamic(lines, idx, b1Subject(line, m.index))) continue;
632
+ findings.push({
633
+ file: relPath,
634
+ line: idx + 1,
635
+ ban: 'B1-exact-count',
636
+ match: m[0].trim(),
637
+ hint: B1_HINT,
638
+ });
639
+ }
640
+ }
641
+ });
642
+
643
+ // --- B2: suspected prose pin -------------------------------------------
644
+ const mdReadLine = lines.findIndex((l) => /readFileSync\(/.test(l) && /\.md\b/.test(l));
645
+ if (mdReadLine !== -1) {
646
+ const proseAsserts = (content.match(/\.(?:toContain|toMatch)\(/g) ?? []).length;
647
+ if (proseAsserts >= PROSE_ASSERT_THRESHOLD) {
648
+ findings.push({
649
+ file: relPath,
650
+ line: mdReadLine + 1,
651
+ ban: 'B2-prose-pin-suspected',
652
+ match: `readFileSync on .md + ${proseAsserts} toContain/toMatch asserts`,
653
+ hint: B2_HINT,
654
+ });
655
+ }
656
+ }
657
+
658
+ // --- B3: bare exit-code allow assertion on a deny-capable hook ----------
659
+ // Scope gate: EVERY hook this file declares as its subject must be
660
+ // deny-capable. A file that also drives a non-deny hook, a husky hook or a
661
+ // plain CLI is out — there, exit 0 is an unambiguous claim.
662
+ const subjects = declaredHookSubjects(lines);
663
+ const isDenyCapableHookTest =
664
+ subjects.length > 0 && subjects.every((h) => denyHooks.has(h));
665
+ if (isDenyCapableHookTest) {
666
+ for (const { start, end } of testBlocks(lines)) {
667
+ const block = lines.slice(start, end);
668
+ const discriminated = block.some(
669
+ (l) => !isCommentLine(l) && (DECISION_DISCRIMINATOR.test(l) || STDOUT_ASSERT.test(l)),
670
+ );
671
+ if (discriminated) continue;
672
+ block.forEach((line, k) => {
673
+ if (isCommentLine(line) || !BARE_EXIT_OK.test(line)) return;
674
+ findings.push({
675
+ file: relPath,
676
+ line: start + k + 1,
677
+ ban: 'B3-bare-hook-exit-code',
678
+ match: line.trim(),
679
+ hint: B3_HINT,
680
+ });
681
+ });
682
+ }
683
+ }
684
+
685
+ // --- B4: hook-decision contract copied outside its owners ---------------
686
+ if (!DECISION_CONTRACT_OWNERS.has(relPath)) {
687
+ /** Lines already flagged — a two-key line reports once, not twice. */
688
+ const b4Flagged = new Set();
689
+ const scanContractKey = (key) => {
690
+ const literalRe = keyLiteralRe(key);
691
+ lines.forEach((line, idx) => {
692
+ if (b4Flagged.has(idx)) return;
693
+ if (!line.includes(key) || isCommentLine(line)) return;
694
+ // Goes THROUGH the helper — the outcome this ban exists to produce.
695
+ if (DECISION_DISCRIMINATOR.test(line)) return;
696
+ // Absence guards assert the key is GONE; they cannot re-state a contract.
697
+ if (ABSENCE_ASSERT.test(line)) return;
698
+ const restatesKey = literalRe.test(line);
699
+ const assertsKey = POSITIVE_MATCHER.test(line) && /expect\(/.test(line);
700
+ if (!restatesKey && !assertsKey) return;
701
+ b4Flagged.add(idx);
702
+ findings.push({
703
+ file: relPath,
704
+ line: idx + 1,
705
+ ban: 'B4-hook-decision-contract-copy',
706
+ match: line.trim().slice(0, 120),
707
+ hint: B4_HINT,
708
+ });
709
+ });
710
+ };
711
+ // permissionDecision: unambiguous — a copy in ANY non-owner file.
712
+ scanContractKey(PERMISSION_DECISION_KEY);
713
+ // systemMessage (#941 3b): the warn-decision carrier, but overloaded with
714
+ // plain hook output — a contract copy ONLY when the file-under-test is a
715
+ // deny-capable hook, where emitWarn's systemMessage lives. Without this
716
+ // gate, operator-steer / session-start banner asserts would false-positive.
717
+ if (isDenyCapableHookTest) scanContractKey(SYSTEM_MESSAGE_KEY);
718
+ }
719
+
720
+ // --- B5: date literal pinned against a clock-seamed subject -------------
721
+ findings.push(...scanClockBombs(relPath, content, lines));
722
+
723
+ return findings;
724
+ }
725
+
726
+ // ---------------------------------------------------------------------------
727
+ // Main
728
+ // ---------------------------------------------------------------------------
729
+
730
+ const candidates = flags.has('--stdin') ? stdinFiles() : trackedTestFiles();
731
+ const denyHooks = denyCapableHooks();
732
+
733
+ /** @type {Array<{file: string, line: number, ban: string, match: string, hint: string}>} */
734
+ const findings = [];
735
+ let scanned = 0;
736
+
737
+ for (const rel of candidates) {
738
+ if (!/\.test\.mjs$/.test(rel)) continue; // stdin mode may hand us anything
739
+ const abs = join(repoRoot, rel);
740
+ let content;
741
+ try {
742
+ if (!statSync(abs).isFile()) continue;
743
+ content = readFileSync(abs, 'utf8');
744
+ } catch {
745
+ continue; // deleted/unreadable — nothing to say about it
746
+ }
747
+ scanned++;
748
+ findings.push(...scanContent(rel, content, denyHooks));
749
+ }
750
+
751
+ const counts = {
752
+ 'B1-exact-count': findings.filter((f) => f.ban === 'B1-exact-count').length,
753
+ 'B2-prose-pin-suspected': findings.filter((f) => f.ban === 'B2-prose-pin-suspected').length,
754
+ 'B3-bare-hook-exit-code': findings.filter((f) => f.ban === 'B3-bare-hook-exit-code').length,
755
+ 'B4-hook-decision-contract-copy': findings.filter(
756
+ (f) => f.ban === 'B4-hook-decision-contract-copy',
757
+ ).length,
758
+ 'B5-date-time-bomb': findings.filter((f) => f.ban === 'B5-date-time-bomb').length,
759
+ };
760
+
761
+ if (jsonMode) {
762
+ console.log(JSON.stringify({ advisory: true, scanned, counts, findings }, null, 2));
763
+ } else if (findings.length === 0) {
764
+ if (!quiet) console.log(`check-test-value-bans: 0 findings across ${scanned} test file(s) — advisory`);
765
+ } else {
766
+ console.log(`check-test-value-bans: ${findings.length} advisory finding(s) across ${scanned} test file(s)`);
767
+ for (const f of findings) {
768
+ console.log(` ${f.ban} ${f.file}:${f.line} ${f.match}`);
769
+ }
770
+ if (counts['B1-exact-count'] > 0) console.log(` B1 hint: ${B1_HINT}`);
771
+ if (counts['B2-prose-pin-suspected'] > 0) console.log(` B2 hint: ${B2_HINT}`);
772
+ if (counts['B3-bare-hook-exit-code'] > 0) console.log(` B3 hint: ${B3_HINT}`);
773
+ if (counts['B4-hook-decision-contract-copy'] > 0) console.log(` B4 hint: ${B4_HINT}`);
774
+ if (counts['B5-date-time-bomb'] > 0) console.log(` B5 hint: ${B5_HINT}`);
775
+ console.log(' (advisory — this check never blocks; see .claude/rules/testing.md § Lint-Enforceable Test Bans)');
776
+ }
777
+
778
+ // NOT `process.exit(0)`: on a PIPE, exiting truncates stdout writes still queued
779
+ // in the async pipe buffer — the full-corpus `--json` payload is well past the
780
+ // ~64 KiB pipe capacity, so `… --json | jq` silently received cut-off JSON while
781
+ // `… --json > file` was complete. Setting exitCode lets the writes drain first.
782
+ process.exitCode = 0;