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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +105 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +244 -9
- package/docs/session-config-template.md +39 -3
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +5 -2
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -13
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +73 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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;
|