canary-test-cli 7.0.0 → 7.2.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 (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -0,0 +1,228 @@
1
+ ---
2
+ name: canary-promote-test
3
+ description: >
4
+ Move a generated test from `tests/generated/` into the committed test suite —
5
+ reviews it for correctness, drops the generation header, relocates it to the
6
+ matching suite directory, and confirms it runs in the project's normal test
7
+ flow. Use for "promote this test", "commit this generated test", "move this
8
+ test into the suite", or "keep this test" — always after the generated test
9
+ has been validated against the SUT, never before. Not for tests needing
10
+ substantial rewriting (regenerate instead) or throwaway investigation tests
11
+ (leave in `tests/generated/`).
12
+ ---
13
+
14
+ # Canary: Promote Test
15
+
16
+ > Move a generated test from `tests/generated/` into the committed test suite.
17
+ > Reviews the test for correctness, drops the generation header, relocates it
18
+ > under the appropriate suite directory, and confirms it runs in the project's
19
+ > normal test flow.
20
+
21
+ ## When to Use
22
+
23
+ - After `canary-generate-test` produced a file that has been validated against
24
+ the SUT
25
+ - When a user explicitly asks to "commit", "save", or "keep" a generated test
26
+ - When extending an existing suite with a new case the team has agreed to
27
+ maintain
28
+ - NOT before the generated test has been executed and reviewed — promotion is
29
+ the _last_ step, not the first
30
+ - NOT for tests that need substantial rewriting — regenerate with a better
31
+ prompt instead of hand-patching
32
+ - NOT for tests targeting throwaway investigations (perf spikes, ad-hoc bug
33
+ triage) — leave those in `tests/generated/`
34
+
35
+ ## Process
36
+
37
+ ### Phase 0: GATE — Get the Structured Verdict First
38
+
39
+ Run this before reading the test. It is deterministic, takes no API key, and it
40
+ will refuse the drafts that are not worth your review time.
41
+
42
+ ```bash
43
+ canary promote-check tests/generated/api/orders_post.py --json
44
+ ```
45
+
46
+ | Exit | Verdict | What it means |
47
+ | ---- | --------- | ------------------------------------------------------------ |
48
+ | `0` | `promote` | No gating defect. Advisory findings may remain — your call. |
49
+ | `1` | `block` | A gating defect. **Do not promote.** Regenerate or fix. |
50
+ | `3` | `abstain` | No verdict could be produced. Promotion is **not** approved. |
51
+
52
+ **Which axes gate, and why only those.** Gating on all eight axes of a quality
53
+ critique would block nearly every promotion, so only deterministic defects do:
54
+
55
+ | Axis | Rules | Gates | Reason |
56
+ | ----------------- | ------------------------------ | ----- | ------------------------------------------------------ |
57
+ | `soundness` | `SOUND-001/002/003` | yes | Pins a value no correct implementation must produce |
58
+ | `assertions` | `LINT-006` | yes | A test that asserts nothing always passes |
59
+ | `flakiness` | `FLAKE-001/002` | yes | A hardcoded sleep in a committed suite is a future red |
60
+ | `vacuity` | `VAC-001/003`, annotated `002` | yes | Cannot fail, or contradicts a declared `@covers` |
61
+ | `selectors` | `LINT-001/002/003` | no | Brittle, not wrong — a reviewer's call |
62
+ | `maintainability` | `LINT-005`, `FLAKE-003/004` | no | Style and softer signals |
63
+
64
+ **`VAC-002` gates only at `annotated` fidelity.** At `import-inferred` it is an
65
+ inference about which symbol the test meant to exercise, and a heuristic must
66
+ not be load-bearing on a promotion gate. If you want the gate to check the real
67
+ target, add the annotation to the generated test:
68
+
69
+ ```ts
70
+ // @covers resolveOverlay
71
+ ```
72
+
73
+ **An `abstain` is not a pass.** Exit 3 means the checker had no subject — an
74
+ unparseable extension, or a file with no test declarations. Promotion falls back
75
+ to the manual review below and is **not** approved by silence. Do not read a
76
+ missing verdict as either stricter or looser than one.
77
+
78
+ **An LLM judgement never gates.** `harness:test-craft` runs an 8-axis per-test
79
+ critique and remains exactly what step 5 below calls it: an optional deeper
80
+ audit for a human. Everything that blocks in this repo is deterministic, and
81
+ `promote-check` keeps it that way — the verdict has no field an LLM opinion
82
+ could arrive in.
83
+
84
+ ### Phase 1: REVIEW — Confirm the Test Is Worth Keeping
85
+
86
+ 1. **Read the test end-to-end.** Treat it like any other code review — naming,
87
+ assertions, hardcoded values, missing edge cases. Generated tests are drafts,
88
+ not finished artifacts.
89
+ 2. **Run the test against the real SUT** (not just the env it was generated
90
+ against). A test that only passes in one environment is a fixture-bound test,
91
+ not a regression test.
92
+ 3. **Check assertion strength.** A test that only asserts "status code 200" is
93
+ weak; promote it only if that's genuinely the contract. If the SUT returns
94
+ structured data, the test should assert on shape.
95
+ 4. **Confirm no hardcoded secrets or environment-specific URLs.** Replace with
96
+ fixtures or env-driven config before promoting.
97
+ 5. **Optional: run `harness:test-craft` for a deeper quality audit.**
98
+ `test-craft` runs an 8-axis per-test LLM critique (assertion density,
99
+ flakiness risk, contract vs implementation, etc.). Use it when the generated
100
+ test is substantial or when the team wants a second opinion before
101
+ committing. Not required for simple happy-path tests, and **never a blocker**
102
+ — see Phase 0.
103
+ 6. **Triage the advisory findings** `promote-check` reported. They did not
104
+ block; deciding whether they matter here is the review's job.
105
+ 7. **Decide: promote, regenerate, or discard.** If review reveals more than ~3
106
+ small fixes, regenerate with a sharper prompt instead.
107
+
108
+ ### Phase 2: RELOCATE — Move into the Suite
109
+
110
+ 1. **Identify the destination directory.** Mirror the suite's structure:
111
+ - `tests/generated/api/foo.py` → `tests/api/foo.py`
112
+ - `tests/generated/e2e/checkout.spec.ts` → `tests/e2e/checkout.spec.ts`
113
+ - `tests/generated/unit/validator.py` → `tests/unit/validator.py`
114
+ 2. **Match suite conventions.** Look at neighboring files for:
115
+ - Import style (relative vs absolute)
116
+ - Fixture/setup imports (most suites have a `conftest.py` or shared setup)
117
+ - Naming conventions (`test_<feature>_<case>.py`, `<feature>.spec.ts`)
118
+ 3. **Move with `git mv`** so the history is preserved if anyone later runs
119
+ `git log --follow`.
120
+ 4. **Update imports** if the file referenced anything by relative path from
121
+ `tests/generated/`.
122
+
123
+ ### Phase 3: CLEAN — Drop Generation Artifacts
124
+
125
+ 1. **Remove the timestamped generation header.** The "Generated by Canary on
126
+ [date]" comment is useful in scratch space — meaningless in a committed test
127
+ and rots immediately.
128
+ 2. **Remove any placeholder TODOs.** The generator sometimes emits
129
+ `# TODO: adjust selector` or similar — either resolve them or stop the
130
+ promotion and regenerate.
131
+ 3. **Tighten formatting.** Run the project's formatter (`black`, `prettier`,
132
+ etc.) so the file matches surrounding style.
133
+ 4. **Strip dead code.** Imports the generator added "just in case" but the test
134
+ doesn't use.
135
+
136
+ ### Phase 4: VERIFY — Run in the Project's Normal Flow
137
+
138
+ 1. **Run the suite that owns this test:**
139
+ - Python: `pytest tests/api/test_orders_post.py`
140
+ - Playwright: `npx playwright test tests/e2e/checkout.spec.ts`
141
+ - Match whatever CI runs.
142
+ 2. **Run the full suite** to confirm no collateral failure (shared fixtures,
143
+ port conflicts, ordering issues).
144
+ 3. **Confirm CI configuration picks it up.** If the suite has a glob in CI
145
+ config, verify the new path matches. If not, add it.
146
+ 4. **Log the promotion.** Append a one-line entry to `docs/CANARY_STATE.md` so
147
+ the project ledger tracks which generated tests have been promoted.
148
+
149
+ ## Canary Integration
150
+
151
+ - **`tests/generated/`** — Source for promotion. Gitignored; nothing here is
152
+ ever a final artifact.
153
+ - **`tests/<suite>/`** — Destination. Each suite has its own conventions; never
154
+ invent a new top-level dir during promotion.
155
+ - **`docs/CANARY_STATE.md`** — Append a promotion entry: requirement, generated
156
+ path, promoted path, date.
157
+
158
+ ## Success Criteria
159
+
160
+ - The promoted test passes when run via the project's normal test command
161
+ - The full suite passes (no collateral breakage)
162
+ - The file matches the surrounding code style (formatter clean, lint clean)
163
+ - No generation artifacts remain (timestamp header, placeholder TODOs, unused
164
+ imports)
165
+ - CI picks up the new test on the next push
166
+
167
+ ## Rationalizations to Reject
168
+
169
+ | Rationalization | Why It Is Wrong |
170
+ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
171
+ | "`promote-check` exited 3, so nothing was wrong" | Exit 3 is an abstention — the checker had no subject at all. It is the one outcome that proves nothing, so it can never stand in for approval. |
172
+ | "`promote-check` blocked on VAC-002, but the test is fine" | Only an `annotated` VAC-002 blocks, which means the file's own `@covers` names a symbol the test never touches. Either the annotation is wrong or the test is. |
173
+ | "I'll wire `harness:test-craft` into the gate for better coverage" | An 8-axis LLM critique gating a promotion would block nearly everything and would make a judgement load-bearing. Deterministic verdicts gate; critiques inform. |
174
+ | "The test works, I'll skip the review and just commit it" | Generated code looks plausible but commonly has weak assertions or hardcoded values. Review is the whole point — promotion without review just imports debt into the suite. |
175
+ | "I'll fix the 6 issues I found in the review by editing the test" | Six issues means the prompt was wrong. Regenerate; hand-edits won't transfer to the next similar test. |
176
+ | "I'll leave the timestamp header so we know when it was generated" | Git history already records when the file was committed. The header rots and creates noise. |
177
+ | "It passes against staging, that's good enough" | If the test only passes in one environment, it's a fixture-bound smoke check, not a regression test. Either parametrize the env or don't promote it. |
178
+ | "I'll commit it without running the full suite — I only changed one test" | New tests can break shared fixtures, conflict on ports, leak state. Always run the full suite once before commit. |
179
+
180
+ ## Examples
181
+
182
+ ### Example: Clean promotion
183
+
184
+ **Source:** `tests/generated/api/orders_post_201.py`, validated, single status
185
+ assertion is exactly the contract.
186
+
187
+ **Action:**
188
+
189
+ 1. `git mv tests/generated/api/orders_post_201.py tests/api/test_orders_post_201.py`
190
+ 2. Drop the `# Generated by Canary...` header.
191
+ 3. Adjust import to match `tests/api/conftest.py` fixtures.
192
+ 4. Run `pytest tests/api/` → passes.
193
+ 5. Append to `CANARY_STATE.md`: promoted `orders_post_201.py` →
194
+ `tests/api/test_orders_post_201.py` on [date].
195
+
196
+ ### Example: Promotion abandoned — regenerate instead
197
+
198
+ **Source:** `tests/generated/e2e/checkout.spec.ts`. Review finds: hardcoded test
199
+ user creds, no wait for navigation, weak assertion (`expect(true).toBe(true)`),
200
+ TODO comments left in three places.
201
+
202
+ **Action:** Stop. Regenerate with a sharper prompt that names the real fixture
203
+ user, specifies the wait condition, and asserts the actual checkout success
204
+ state. Do not commit the broken draft.
205
+
206
+ ### Example: Test for throwaway investigation
207
+
208
+ **Source:** `tests/generated/performance/spike_search_50rps.js`. Used once to
209
+ confirm a single perf hypothesis. No ongoing value.
210
+
211
+ **Action:** Do not promote. Leave in `tests/generated/`. If the team wants
212
+ ongoing perf monitoring, generate a _new_ test with a sustainable RPS profile
213
+ and promote that.
214
+
215
+ ## Escalation
216
+
217
+ - **When the review reveals the SUT itself is broken:** Don't promote the test
218
+ that passes against the broken SUT. File a bug; only promote the test once the
219
+ SUT is fixed and the assertion has a real contract behind it.
220
+ - **When the suite has no existing convention to match:** This usually means the
221
+ test belongs in a new sub-suite. Ask the user to confirm the suite structure
222
+ before inventing one.
223
+ - **When CI doesn't pick up the new path:** Don't merge until CI config matches.
224
+ A test that exists but isn't run is worse than no test — it implies coverage
225
+ that doesn't exist.
226
+ - **When the promoted test starts flaking after merge:** Treat as a real
227
+ regression in the test (or the SUT). Don't quarantine in `tests/generated/` —
228
+ fix or delete.
@@ -0,0 +1,233 @@
1
+ ---
2
+ name: canary-savant
3
+ description:
4
+ Order-dependence and isolation detector for test suites. A deterministic
5
+ static scan flags the shared-state smells which predict order-dependent tests
6
+ - a module-level mutable a test writes to, a setup with no matching teardown,
7
+ a mutated process singleton, an order-coupled name - with no test execution,
8
+ so it runs anywhere node does and on every PR. An opt-in confirming pass
9
+ (--confirm) shuffles the suite under a pinned seed and bisects the prefix to
10
+ name the polluting test. Advisory by default; pytest and vitest idioms.
11
+ cli: scripts/cli.mjs
12
+ requires: [node>=20]
13
+ ---
14
+
15
+ # Canary Savant
16
+
17
+ A test that only passes because of the tests that ran before it is a lie that
18
+ passes CI. Savant finds shared-state leakage and names the culprit. The **static
19
+ pass** is the cheap half that runs on every PR and points at the _suspects_. The
20
+ **confirming pass** (`--confirm`, opt-in) proves a leak by shuffling the suite
21
+ under a pinned seed and bisecting the prefix to name the polluter — not just the
22
+ victim.
23
+
24
+ Tier-0 by the definition in
25
+ [ADR 0015](../../../../docs/knowledge/decisions/0015-skill-capability-vocabulary.md):
26
+ deterministic, no network, no agent or LLM. Also no secrets and no dependency on
27
+ any other skill. Both passes qualify — `--confirm` runs the suite locally, which
28
+ needs no network and no model.
29
+
30
+ ## Rules (static pass — suspects)
31
+
32
+ | Rule | Severity | Fires on |
33
+ | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | `SV001-module-mutable-global` | medium | A module-scope mutable (`= {}`, `= []`, `set()`, `dict()`, `list()`, or a top-level JS `let`/`var`/`const` object/array) that some line later mutates in place (`.append`/`.add`/`[...] =`/`+=`/`.attr =`). Fires on the **declaration**, the leak's source. |
35
+ | `SV002-missing-teardown` | medium | A **class/all-scoped** setup whose matching teardown is absent: pytest `setup_class`/`setUpClass`, or vitest/jest `beforeAll`. Per-test setup (`setUp`/`setup_method`/`beforeEach`) is excluded - it rebuilds state each test, so it does not leak. |
36
+ | `SV003-shared-singleton-mutation` | low | A process-global singleton assigned without restore: `os.environ[...] =`, `sys.modules[...] =`, `process.env.X =`. Reads and `==` comparisons never fire, and neither does a file that demonstrably restores the global — a same-key (or computed-loop) restore in `afterEach`/`afterAll`/teardown/post-`yield` fixture code or an in-test `try`/`finally`, or a write-back from a snapshot saved from that global. |
37
+ | `SV004-order-coupled-name` | low | A test name or comment that encodes ordering: `test_1_…`, a **terminal** ordinal (`test_first()`, `test_last()` - not `test_first_match_wins`), `must run before …`, `it('… run first')`. |
38
+
39
+ A finding is a **suspect, not a verdict.** A module dict that is only ever read
40
+ is a legitimate constant and does not fire; only a _mutated_ one does.
41
+
42
+ ## Framework conditioning
43
+
44
+ The setup/teardown idioms (`SV002`) differ by ecosystem, so the rule is
45
+ conditioned on the file: Python files are read with pytest/unittest markers, JS
46
+ and TS with vitest/jest markers. The idioms do not collide across languages, so
47
+ each file is judged by its own ecosystem's conventions.
48
+
49
+ ## Fidelity limits (AST-lite, on purpose)
50
+
51
+ The static pass is a scanner with no parser dependency, so it ships anywhere
52
+ `node` does. The cost, stated plainly:
53
+
54
+ - **`SV001` mutation is file-scoped, not flow-scoped.** Any in-place mutation of
55
+ a module-level name anywhere in the file indicts the declaration, even if the
56
+ mutation sits in a helper rather than a test body. A shared-state leak is a
57
+ shared-state leak regardless of which function does the writing.
58
+ - **`SV002` is presence-based, not pairing-based.** A file with one `setUpClass`
59
+ and one `tearDownClass` is considered balanced even if a _second_ class lacks
60
+ teardown. It also only judges class/all-scoped setup, so a genuinely leaky
61
+ per-test setup (rare) is missed - the deliberate false-positive/false-negative
62
+ trade from dogfooding.
63
+ - **Comment-blind for code rules.** `SV003` skips commented-out lines, and
64
+ `SV002` judges both halves of its pair against a code-only projection of the
65
+ file (comments dropped, string contents blanked) - so neither a prose mention
66
+ of `afterAll` nor a fixture string containing one can stand in for the real
67
+ teardown (#732). `SV004` deliberately is _not_ comment-blind, because an
68
+ ordering note in a comment is exactly the self-reported dependence it looks
69
+ for.
70
+ - **String-aware for code anchors.** An `SV003` (or `SV004` name-pattern) match
71
+ that _starts_ inside a string literal on its own line is rejected as fixture
72
+ data. `SV004`'s directive-text alternatives stay unfiltered on purpose: their
73
+ signal (test titles, docstrings) legitimately lives inside strings.
74
+ - **Restoration check is file-level.** `SV003` suppression cannot verify that a
75
+ fixture actually applies to the mutating test, and a computed-key loop restore
76
+ is assumed to cover the whole family — the same file-wide trade blackhawk
77
+ makes for frozen clocks. `vi.unstubAllEnvs`/`monkeypatch` never suppress a
78
+ direct assignment: they only undo their own mutations.
79
+ - **Line-scoped.** A declaration or call split across lines can be missed.
80
+ - **A missed suspect costs less than a false one** — the same bias as
81
+ canary-blackhawk.
82
+
83
+ ## Inline suppression (per-line escape hatch)
84
+
85
+ A suite that legitimately contains a suspect line — most commonly a fixture
86
+ string feeding a tool that _tests_ these detections — can suppress a single
87
+ finding with an inline pragma, same dialect as `blackhawk-ignore`:
88
+
89
+ ```py
90
+ # savant-ignore SV004 -- fixture: directive-text input the rule under test must detect
91
+ ```
92
+
93
+ - **Reason required** (the `-- reason` tail) — keeps suppressions honest and
94
+ greppable, like `eslint-disable-next-line` / `# noqa`. A bare
95
+ `savant-ignore SV004` suppresses nothing.
96
+ - **Rule-scoped** (`SV004`, or the full `SV004-order-coupled-name`) so it never
97
+ blanket-silences the line; another rule firing on the same line still fires.
98
+ - **Placement:** the pragma may trail the offending line or sit on the line
99
+ directly above it. Comma-separate ids to suppress several (`SV003,SV004`).
100
+ - **Counted separately:** suppressed findings are reported as
101
+ `N suppressed (inline savant-ignore)` and in the JSON `summary.suppressed`,
102
+ out of the actionable total — so a genuinely clean suite can read zero
103
+ findings while the known-OK fixtures stay visible.
104
+ - **String-guarded:** pragma text that itself sits _inside_ a string literal is
105
+ fixture data, not a directive — it neither suppresses its own line nor the
106
+ next one.
107
+
108
+ ## Which files get scanned
109
+
110
+ A directory walk only visits **test** files — `*.test.*`, `*.spec.*`,
111
+ `test_*.py`, `*_test.py`, or any supported source under `tests/`, `test/`,
112
+ `__tests__/`, `e2e/`, `spec/`. A file named explicitly on the command line is
113
+ always scanned. Supported suffixes: `.py`, `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`,
114
+ `.cjs`. Dependency directories (`node_modules`, `.venv`, …) are never walked.
115
+
116
+ ## Invocation
117
+
118
+ ```bash
119
+ # Scan the repo's test files (advisory - always exits 0):
120
+ canary skills run canary-savant
121
+
122
+ # Scan a specific suite:
123
+ canary skills run canary-savant -- tests/unit
124
+
125
+ # Machine-readable findings:
126
+ canary skills run canary-savant -- tests --json
127
+
128
+ # Fail the step on any suspect:
129
+ canary skills run canary-savant -- tests --strict
130
+
131
+ # Usage, options, and the full rule list (exits 0):
132
+ canary skills run canary-savant -- --help
133
+ ```
134
+
135
+ An unknown flag is rejected with `unrecognized arguments: <flag>` and exit 2.
136
+ `--seed` is validated as a determinism flag rather than being allowed to decay
137
+ into a random seed. All three failures exit 2:
138
+
139
+ | Input | Error |
140
+ | ---------------------------------- | ------------------------------------------------------------ |
141
+ | missing value, or a following flag | `argument --seed: expected one argument` |
142
+ | `abc`, `3.7`, `1e999`, `0x10`, `` | `argument --seed: invalid int value: '<value>'` |
143
+ | beyond ±(2^53 − 1) | `argument --seed: seed out of safe integer range: '<value>'` |
144
+
145
+ The last one matters because `Number()` rounds silently above 2^53 − 1, so the
146
+ seed actually used would differ from the seed asked for — the same lie the whole
147
+ flag exists to prevent.
148
+
149
+ `--seed 42` and `--seed=42` are equivalent, and both accept negative seeds
150
+ (`--seed -5`, `--seed=-5`). Use `--` to end option parsing when a path itself
151
+ starts with a dash.
152
+
153
+ ### The confirming pass — dynamic confirmation (`--confirm`, opt-in)
154
+
155
+ `--confirm` runs the suite in declared order, re-runs it shuffled under a pinned
156
+ seed, and for each order-dependent victim runs it alone and **bisects the prefix
157
+ to name the polluter** — the earlier test whose state leaked. Opt-in because a
158
+ shuffled re-run at least doubles wall-clock; always prints the seed and a
159
+ copy-pasteable reproduce command.
160
+
161
+ ```bash
162
+ # Confirm order-dependence, pinning the seed for reproducibility:
163
+ canary skills run canary-savant -- tests --confirm --seed 424242
164
+ ```
165
+
166
+ **pytest and vitest** are both supported; savant auto-detects from the target
167
+ (extensions, then a directory scan, then config files). pytest needs a shuffle
168
+ plugin (`pytest-randomly` or `pytest-random-order`) and declines loudly if none
169
+ is installed; vitest's shuffle is built in (`--sequence.shuffle`), so no plugin
170
+ is required. Node drives the project's _own_ runner — savant orchestrates it, it
171
+ does not run in the target's language.
172
+
173
+ **Polluter bisect is pytest-only.** vitest has no CLI-driven ordered per-test
174
+ execution, so a vitest target gets victim _detection_ (which tests break under
175
+ shuffle) but not culprit _naming_. For pytest, node ids are captured via
176
+ `--collect-only`, so class-based layouts (`file.py::Class::test`) re-run
177
+ correctly.
178
+
179
+ `--json` shape:
180
+
181
+ ```json
182
+ {
183
+ "schema_version": 1,
184
+ "findings": [
185
+ {
186
+ "file": "tests/test_cache.py",
187
+ "line": 3,
188
+ "rule_id": "SV001-module-mutable-global",
189
+ "severity": "medium",
190
+ "snippet": "_CACHE = {}",
191
+ "why": "a module-level mutable is written by a test, so state leaks into whatever test runs next"
192
+ }
193
+ ],
194
+ "summary": {
195
+ "files_scanned": 8,
196
+ "findings": 1,
197
+ "by_severity": { "medium": 1 },
198
+ "suppressed": 0
199
+ }
200
+ }
201
+ ```
202
+
203
+ ## Fixing what it finds
204
+
205
+ | Finding | Fix |
206
+ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
207
+ | `SV001` | Move the mutable into a fixture that rebuilds it per test, or reset it in teardown. Module-level mutable state shared across tests is the leak. |
208
+ | `SV002` | Add the matching teardown (`teardown_method`, `afterEach`, a `yield` fixture) so acquired state is released. |
209
+ | `SV003` | Use a restoring helper — pytest `monkeypatch.setenv`, or save/restore around the test — instead of assigning the global directly. |
210
+ | `SV004` | Make the test self-contained so order stops mattering, then drop the ordering hint from the name/comment. |
211
+
212
+ ## Dogfooding and the `--strict` promotion path
213
+
214
+ canary runs savant's static pass over its **own** test suite on every PR
215
+ (`.github/workflows/harness-quality.yml`, the `Skills (JS)` job), **advisory**:
216
+ it prints suspects to the log and always exits 0. Tuning the rules against that
217
+ real suite dropped the backlog from 37 findings to a handful of genuine
218
+ suspects; the structural residue (savant's own suite testing SV004's
219
+ directive-text detection, #496) is suppressed with per-site `savant-ignore`
220
+ reasons and stays visible in the suppressed count. Promote to blocking by
221
+ appending `--strict` to that step once the remaining suspects are triaged (fixed
222
+ or confirmed benign) - the same advisory-first path every canary gate takes.
223
+
224
+ ## Roadmap
225
+
226
+ - **Shipped:** the static pass; the dynamic confirming pass (baseline → shuffle
227
+ → classify) with isolation + polluter bisect (pytest); vitest as a
228
+ confirming-pass classify target; pytest node-id capture for class-based
229
+ layouts; advisory CI gate dogfooded on canary's own suite (rules tuned to kill
230
+ the dominant false positives).
231
+ - **Remaining:** flip the advisory gate to `--strict` once the suspect backlog
232
+ is triaged; vitest polluter naming is out of scope until vitest gains ordered
233
+ per-test execution. See `docs/changes/canary-savant/proposal.md`.