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.
- package/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- 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`.
|