canary-test-cli 7.1.0 → 8.0.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 +327 -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-batwoman/SKILL.md +119 -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 +187 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -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 +197 -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 +144 -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-screech/SKILL.md +109 -0
- package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
- package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
- package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
- package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
- package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -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 +275 -0
- package/dist/engine/analysis/batwoman/audit.js +39 -0
- package/dist/engine/analysis/batwoman/closure.js +159 -0
- package/dist/engine/analysis/batwoman/gh-history.js +119 -0
- package/dist/engine/analysis/batwoman/probes.js +195 -0
- package/dist/engine/analysis/batwoman/registry.js +142 -0
- package/dist/engine/analysis/batwoman/render.js +194 -0
- package/dist/engine/analysis/batwoman/run-window.js +122 -0
- package/dist/engine/analysis/batwoman/text.js +84 -0
- package/dist/engine/analysis/batwoman/triggers.js +122 -0
- package/dist/engine/analysis/batwoman/verdict.js +64 -0
- package/dist/engine/analysis/cli.js +47 -14
- package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
- package/dist/engine/batwoman-cli.js +119 -0
- package/dist/engine/ci-ready-cli.js +71 -0
- package/dist/engine/cli-commands.js +49 -72
- package/dist/engine/cli.core.js +16 -0
- package/dist/engine/company-knowledge-cli.js +10 -2
- package/dist/engine/core/ci-ready.js +112 -0
- package/dist/engine/core/company-knowledge.js +8 -0
- package/dist/engine/core/migrator.js +147 -20
- package/dist/engine/core/permission-matrix.js +219 -0
- package/dist/engine/core/quality-scorer.js +27 -19
- package/dist/engine/core/scaling-curve.js +143 -0
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +103 -3
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/string-literals.js +3 -1
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/vacuity-scanner.js +330 -15
- package/dist/engine/core/workflow-discovery.js +41 -23
- package/dist/engine/guardian/adjudication-github.js +136 -0
- package/dist/engine/guardian/adjudication.js +119 -340
- package/dist/engine/guardian/analysis-emit.js +7 -2
- package/dist/engine/guardian/cli.js +277 -249
- package/dist/engine/guardian/coverage.js +2 -1
- package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
- package/dist/engine/guardian/diff-coverage/paths.js +5 -9
- package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
- package/dist/engine/guardian/diff-extractor.js +31 -32
- package/dist/engine/guardian/pr-check.js +354 -223
- package/dist/engine/guardian/pr-comment.js +35 -58
- package/dist/engine/guardian/weak-test.js +236 -0
- package/dist/engine/mcp-server.js +67 -4
- package/dist/engine/permission-matrix-cli.js +51 -0
- package/dist/engine/scaling-curve-cli.js +147 -0
- package/dist/engine/skills-cli.js +171 -51
- package/dist/engine/workflow-cli.js +85 -65
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-cassandra
|
|
3
|
+
description: >
|
|
4
|
+
Vacuous-test detection — finds tests that PASS WITHOUT PROVING ANYTHING: an
|
|
5
|
+
assertion that compares a value with itself, a test that never invokes the
|
|
6
|
+
target it claims to cover, and a test whose every assertion is an absence, or
|
|
7
|
+
a trivially true presence check, observed on a bystander rather than on the
|
|
8
|
+
code under test. Use when the user says "why did this pass against the bug",
|
|
9
|
+
"are these tests actually testing anything", "audit my suite for vacuous
|
|
10
|
+
tests", "green but worthless", or after a bug shipped through a green suite.
|
|
11
|
+
Advisory and deterministic — no LLM, no execution. NOT for tests with zero
|
|
12
|
+
assertions (that is `canary review-test`'s LINT-006), NOT for flaky tests
|
|
13
|
+
(canary-flake-hunter), and NOT a coverage tool — a vacuous test has coverage,
|
|
14
|
+
which is exactly why coverage never caught it.
|
|
15
|
+
cli: scripts/cli.mjs
|
|
16
|
+
requires: [node>=20]
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Canary Cassandra
|
|
20
|
+
|
|
21
|
+
> Cassandra Cain reads the fake. So does this skill.
|
|
22
|
+
|
|
23
|
+
A test with no assertions is easy to find and everybody already looks for it.
|
|
24
|
+
The dangerous test is the one with assertions that **cannot fail** — it has
|
|
25
|
+
coverage, it goes green, and it goes green identically against the bug it was
|
|
26
|
+
written to catch. Three of those shipped in this project in a single cycle
|
|
27
|
+
(recorded in [#486]) and every gate the repo owned read all three as healthy.
|
|
28
|
+
|
|
29
|
+
Cassandra is Tier-0: deterministic, no LLM, no network, no execution.
|
|
30
|
+
|
|
31
|
+
## What it finds
|
|
32
|
+
|
|
33
|
+
| Rule | Severity | Fires on |
|
|
34
|
+
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `VAC-001` | critical | An assertion whose expectation is identical to the value it checks — `expect(true).toBe(true)`, `assert x == x` |
|
|
36
|
+
| `VAC-002` | warning | The test never references the target it claims to cover |
|
|
37
|
+
| `VAC-003` | warning | Every assertion in the test asserts an _absence_, and none of them observes the target — so nothing proves it ran |
|
|
38
|
+
| `VAC-005` | warning | Every assertion is a trivially true _presence_ check (`toBeDefined`, `toBeTruthy`, `assert x is not None`) on a value the test built itself before the target ran |
|
|
39
|
+
|
|
40
|
+
`VAC-001` is deterministic, hence `critical`: no implementation can fail it.
|
|
41
|
+
`VAC-005` abstains (no finding) whenever it cannot prove the subject is a
|
|
42
|
+
bystander, such as a name bound in a hook or a multi-line initialiser. `VAC-004`
|
|
43
|
+
is reserved for the self-excusing-skip rule
|
|
44
|
+
(`docs/changes/vac-004-self-excusing-skip/`). `VAC-002` and `VAC-003` depend on
|
|
45
|
+
resolving a target, which is inference, so they are `warning` and carry a
|
|
46
|
+
fidelity tier.
|
|
47
|
+
|
|
48
|
+
## Run it
|
|
49
|
+
|
|
50
|
+
Two doors, one detector. Both run the same engine rules, so they cannot disagree
|
|
51
|
+
about a finding or about the denominator.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
canary vacuity-check tests/ # human-readable, skips counted per reason
|
|
55
|
+
canary vacuity-check tests/ --verbose # ...plus every skipped test at file:line
|
|
56
|
+
canary vacuity-check tests/ --json # verdict + denominator + skips
|
|
57
|
+
canary vacuity-check tests/a.test.ts # one file
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
As a skill, for an orchestrator or a CI step composing all four Tier-0
|
|
61
|
+
detectors:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
canary skills run canary-cassandra -- tests/
|
|
65
|
+
canary skills run canary-cassandra -- tests/ --json --strict
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Usage, flags, and the full rule list — this one is runnable verbatim, needs no
|
|
69
|
+
fixtures, and is what CI executes to prove the doc still matches the CLI:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
canary skills run canary-cassandra -- --help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `--json` envelope matches `canary-savant` / `canary-blackhawk` /
|
|
76
|
+
`canary-katana` — `schema_version`, a `findings` array of
|
|
77
|
+
`{file, line, rule_id, severity, snippet, why}`, and a `summary` — so findings
|
|
78
|
+
from all four merge without special-casing one. Cassandra adds `suggestion` and
|
|
79
|
+
`fidelity` per finding, and `tests_checked` to the summary, because its
|
|
80
|
+
denominator is tests rather than files.
|
|
81
|
+
|
|
82
|
+
**Advisory by design.** Findings exit **0**. This is the repo's established
|
|
83
|
+
shape for a new detector — advisory first, ratchet to strict only after triage
|
|
84
|
+
(see the dogfooding jobs, [#485]). Do not wire it as a required check on the day
|
|
85
|
+
you adopt it; run it, triage the count, then decide.
|
|
86
|
+
|
|
87
|
+
A collapsed denominator is **not** advisory and exits **3**. See below.
|
|
88
|
+
|
|
89
|
+
## The fidelity ladder (the part that decides whether to trust a finding)
|
|
90
|
+
|
|
91
|
+
A test's "declared target" is declared nowhere, so `VAC-002`/`VAC-003` have to
|
|
92
|
+
resolve one. Three rungs, and the finding says which one it used:
|
|
93
|
+
|
|
94
|
+
| Tier | How the target was resolved | Trust |
|
|
95
|
+
| ----------------- | --------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
96
|
+
| `annotated` | The author wrote `// @covers <symbol>`. That exact symbol is checked. | High — the author stated the contract |
|
|
97
|
+
| `import-inferred` | The symbols imported from first-party (relative) modules, closed over local helpers | Medium — read the test before acting |
|
|
98
|
+
| _(skipped)_ | Neither available. Reported as a skip with its reason; the test is **not** reported as clean. | None — the check did not run |
|
|
99
|
+
|
|
100
|
+
`import-inferred` reads four binding forms, not one: a named or default import,
|
|
101
|
+
a **namespace** import (`import * as store from './store.js'`), a **dynamic**
|
|
102
|
+
import (`const { save } = await import('./store.js')`), and a handle bound to a
|
|
103
|
+
first-party **script path** that a `spawnSync`/`execFileSync`-family call then
|
|
104
|
+
runs. The last is what a subprocess test has instead of a symbol; the
|
|
105
|
+
discriminator is the path, so spawning a bare command (`spawnSync('git', …)`)
|
|
106
|
+
still counts as reaching nothing. A test that launches a script path written
|
|
107
|
+
inline, or that carries a bare `await import('./x.js')`, is likewise read as
|
|
108
|
+
reaching its target — but `VAC-003` stays dark for it and says so in the skip
|
|
109
|
+
list, because "did an assertion observe the target" needs a symbol that a
|
|
110
|
+
subprocess boundary does not provide (#705).
|
|
111
|
+
|
|
112
|
+
To upgrade a finding from inferred to annotated, add the annotation above the
|
|
113
|
+
test:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// @covers resolveOverlay
|
|
117
|
+
it('falls back to the tracked overlay', () => {
|
|
118
|
+
/* ... */
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
That is also the fix for a false positive: if the target is reached several
|
|
123
|
+
frames deep and the inference cannot see it, `@covers` tells the check what to
|
|
124
|
+
look for instead of arguing with the heuristic.
|
|
125
|
+
|
|
126
|
+
## Why a zero denominator exits 3
|
|
127
|
+
|
|
128
|
+
The whole point of this skill is that **green can mean nothing**. A vacuity
|
|
129
|
+
detector that reported its own silence as success would be the joke telling
|
|
130
|
+
itself, so:
|
|
131
|
+
|
|
132
|
+
- **No test file matched** → exit 3, `Abstained — verified zero items`.
|
|
133
|
+
- **Files matched but held zero tests** → also exit 3. This is the subtler zero:
|
|
134
|
+
the file-level count looks healthy, and a scanner that only guarded that one
|
|
135
|
+
prints a clean tick.
|
|
136
|
+
- **A test whose target could not be resolved** → a `skipped` entry naming the
|
|
137
|
+
test and the reason, rendered in every summary line.
|
|
138
|
+
|
|
139
|
+
`checked` in the JSON payload is the number of **tests** read, not files. Always
|
|
140
|
+
read it before believing a zero.
|
|
141
|
+
|
|
142
|
+
## Reading the output
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
[CRITICAL] tests/cli.test.ts:41 (VAC-001)
|
|
146
|
+
--help does not write the ledger: Assertion compares a value with itself; no implementation can fail it.
|
|
147
|
+
→ Assert the value the code under test should have produced, not the input.
|
|
148
|
+
|
|
149
|
+
3 finding(s) across 214 checked (11 skipped: VAC-002/VAC-003 (adds two numbers) [target unresolvable: ...])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The summary line always carries the denominator and the skips. A finding count
|
|
153
|
+
with no denominator is not a result.
|
|
154
|
+
|
|
155
|
+
## Rationalizations to reject
|
|
156
|
+
|
|
157
|
+
| Rationalization | Why it is wrong |
|
|
158
|
+
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
159
|
+
| "It's covered, so it's tested" | A vacuous test executes the line and asserts nothing about it. Coverage is the reason this class survives — it satisfies the metric perfectly. |
|
|
160
|
+
| "VAC-002 flagged a test I know is correct, so it's junk" | Add `// @covers <symbol>`. That converts a guess into a checked claim, permanently, for every future run. |
|
|
161
|
+
| "0 findings, we're clean" | Only if `checked` is greater than zero. Read the denominator; exit 3 means the check never ran. |
|
|
162
|
+
| "We'll make it a required check right away" | A new detector lands advisory. Triage the existing count first, then ratchet — otherwise the first red build teaches the team to bypass the gate. |
|
|
163
|
+
| "The absence assertion is fine, the test passes" | That is the `VAC-003` defect exactly: it also passed when the code exited before doing anything. One positive assertion makes it load-bearing. |
|
|
164
|
+
|
|
165
|
+
## Escalation
|
|
166
|
+
|
|
167
|
+
- **A VAC-001 finding is never a false positive.** It is a comparison of a value
|
|
168
|
+
with itself. Fix the test.
|
|
169
|
+
- **A large VAC-002 count on first run usually means dynamic imports.** Tests
|
|
170
|
+
that `await import(...)` their target have no static import for the inference
|
|
171
|
+
to read. Annotate them, or accept the tier and move on — do not rewrite the
|
|
172
|
+
suite to satisfy a heuristic.
|
|
173
|
+
- **A finding you disagree with is a doc gap, not an argument.** Write the
|
|
174
|
+
`@covers` annotation; the next reader gets the answer for free.
|
|
175
|
+
|
|
176
|
+
## Related
|
|
177
|
+
|
|
178
|
+
- `canary review-test` — `LINT-006` (zero assertions) and `SOUND-001/002/003` (a
|
|
179
|
+
test that pins a value no correct implementation must produce). Cassandra
|
|
180
|
+
deliberately does not duplicate either.
|
|
181
|
+
- `canary-promote-test` — consumes these findings as a structured verdict and
|
|
182
|
+
blocks promotion on the deterministic ones.
|
|
183
|
+
- `canary-katana`, `canary-savant`, `canary-blackhawk` — the other Tier-0
|
|
184
|
+
deterministic scanners.
|
|
185
|
+
|
|
186
|
+
[#485]: https://github.com/bop-clocktower/canary/issues/485
|
|
187
|
+
[#486]: https://github.com/bop-clocktower/canary/issues/486
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canary-cassandra -- vacuous-test detection (#755).
|
|
3
|
+
//
|
|
4
|
+
// Finds tests that PASS WITHOUT PROVING ANYTHING: an assertion that compares a
|
|
5
|
+
// value with itself (VAC-001), a test that never invokes the target it claims
|
|
6
|
+
// to cover (VAC-002), and a test whose every assertion is an absence observed
|
|
7
|
+
// on a bystander (VAC-003), and a test whose every assertion is a trivially
|
|
8
|
+
// true presence check on a value the test built itself (VAC-005).
|
|
9
|
+
//
|
|
10
|
+
// <paths> files or directories to scan (default: the current directory).
|
|
11
|
+
// --json emit machine-readable findings instead of human text.
|
|
12
|
+
// --strict exit 1 when there are findings (default is advisory: exit 0).
|
|
13
|
+
//
|
|
14
|
+
// Deterministic: no LLM, no network, no test execution. The rules themselves
|
|
15
|
+
// live in the engine (`core/vacuity-scanner`), which is also what `canary
|
|
16
|
+
// vacuity-check` and the promotion gate run -- see engine.mjs for why this one
|
|
17
|
+
// skill delegates where its three siblings self-host.
|
|
18
|
+
//
|
|
19
|
+
// Invoked via `canary skills run canary-cassandra -- [paths] [--json] [--strict]`.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
createParser,
|
|
25
|
+
formatUsageError,
|
|
26
|
+
EXIT_USAGE,
|
|
27
|
+
} from '../../../lib/parse-args.mjs';
|
|
28
|
+
import { loadEngine } from './engine.mjs';
|
|
29
|
+
|
|
30
|
+
export const SCHEMA_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
const PREFIX = 'canary-cassandra:';
|
|
33
|
+
|
|
34
|
+
/** Reserved CLI-wide: exit 3 means "abstained -- verified zero items". */
|
|
35
|
+
const EXIT_ABSTAINED = 3;
|
|
36
|
+
|
|
37
|
+
// U+2192 written as an escape so this source stays ASCII, matching the family.
|
|
38
|
+
const ARROW = '\u{2192}';
|
|
39
|
+
|
|
40
|
+
const USAGE =
|
|
41
|
+
'usage: canary-cassandra [-h] [--json] [--strict] [--] [path ...]\n' +
|
|
42
|
+
'\n' +
|
|
43
|
+
'Vacuous-test detection: finds tests that pass without proving anything.\n' +
|
|
44
|
+
'\n' +
|
|
45
|
+
'positional arguments:\n' +
|
|
46
|
+
' path files or directories to scan (default: the current directory)\n' +
|
|
47
|
+
'\n' +
|
|
48
|
+
'options:\n' +
|
|
49
|
+
' -h, --help show this help message and exit\n' +
|
|
50
|
+
' --json emit machine-readable findings instead of human text\n' +
|
|
51
|
+
' --strict exit 1 when there are findings (default is advisory: exit 0)\n' +
|
|
52
|
+
'\n' +
|
|
53
|
+
'rules:\n' +
|
|
54
|
+
' VAC-001 (critical) assertion compares a value with itself\n' +
|
|
55
|
+
' VAC-002 (warning) the test never invokes the target it covers\n' +
|
|
56
|
+
' VAC-003 (warning) every assertion is an absence, on a bystander\n' +
|
|
57
|
+
' VAC-005 (warning) every assertion is trivial presence, on a bystander\n' +
|
|
58
|
+
'\n' +
|
|
59
|
+
'The denominator is TESTS read, not files. A zero denominator exits 3 under\n' +
|
|
60
|
+
'--strict; it is never reported as a clean scan.';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `--` terminator and a lone `-` come with declaring positionals, so a file
|
|
64
|
+
* literally named `--json` stays reachable. Shared with the other skill CLIs
|
|
65
|
+
* via `lib/parse-args.mjs`; `test/skill-cli-conformance.test.ts` asserts this
|
|
66
|
+
* export exists so a hand-rolled parser cannot land.
|
|
67
|
+
*/
|
|
68
|
+
export const CLI_SPEC = {
|
|
69
|
+
prog: 'canary-cassandra',
|
|
70
|
+
booleans: { '--json': 'json', '--strict': 'strict' },
|
|
71
|
+
positionals: { key: 'paths', defaults: ['.'] },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const parseArgs = createParser(CLI_SPEC);
|
|
75
|
+
|
|
76
|
+
// Resolved once, at load, so `main` stays synchronous like every sibling's --
|
|
77
|
+
// the conformance suite calls `main(argv)` and reads a number back. A failed
|
|
78
|
+
// resolution is carried, not thrown: `--help` must answer even with no engine.
|
|
79
|
+
const ENGINE = await loadEngine();
|
|
80
|
+
|
|
81
|
+
/** Every test file the given paths contribute, de-duplicated and ordered. */
|
|
82
|
+
function collectFiles(paths, engine) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const files = [];
|
|
85
|
+
for (const entry of paths) {
|
|
86
|
+
const found = engine.isDir(entry)
|
|
87
|
+
? engine.collectTestFiles(entry)
|
|
88
|
+
: [entry];
|
|
89
|
+
for (const file of found) {
|
|
90
|
+
if (seen.has(file)) continue;
|
|
91
|
+
seen.add(file);
|
|
92
|
+
files.push(file);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return files;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Run the engine scanner over each file, accumulating one gate result. */
|
|
99
|
+
function scanFiles(files, engine) {
|
|
100
|
+
const findings = [];
|
|
101
|
+
const skipped = [];
|
|
102
|
+
let checked = 0;
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const r = engine.scanVacuity(file);
|
|
105
|
+
checked += r.checked;
|
|
106
|
+
findings.push(...r.findings);
|
|
107
|
+
if (r.skipped) skipped.push(...r.skipped);
|
|
108
|
+
}
|
|
109
|
+
return { checked, findings, skipped };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The sibling finding envelope, plus the two fields only cassandra has. */
|
|
113
|
+
function toJson(f) {
|
|
114
|
+
return {
|
|
115
|
+
file: f.file,
|
|
116
|
+
line: f.line,
|
|
117
|
+
rule_id: f.rule,
|
|
118
|
+
severity: f.severity,
|
|
119
|
+
// `snippet` in the sibling envelope is "the locus, in one line". For a
|
|
120
|
+
// vacuity finding that is the test, not the source line: the defect is the
|
|
121
|
+
// test as a whole, and a single line of it reads as a lint hit.
|
|
122
|
+
snippet: f.test,
|
|
123
|
+
why: f.message,
|
|
124
|
+
suggestion: f.suggestion,
|
|
125
|
+
fidelity: f.fidelity ?? null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function summary(result, filesScanned, outcome) {
|
|
130
|
+
const bySeverity = {};
|
|
131
|
+
for (const f of result.findings) {
|
|
132
|
+
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
files_scanned: filesScanned,
|
|
136
|
+
// The denominator that matters. A healthy `files_scanned` over zero tests
|
|
137
|
+
// is the subtler zero, and the one a file-counting scanner prints a clean
|
|
138
|
+
// tick on.
|
|
139
|
+
tests_checked: result.checked,
|
|
140
|
+
abstained: outcome.abstained,
|
|
141
|
+
findings: result.findings.length,
|
|
142
|
+
by_severity: bySeverity,
|
|
143
|
+
skipped: result.skipped.length,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderText(result, outcome) {
|
|
148
|
+
const lines = [];
|
|
149
|
+
for (const f of result.findings) {
|
|
150
|
+
const tier = f.fidelity ? ` [${f.fidelity}]` : '';
|
|
151
|
+
lines.push(
|
|
152
|
+
`[${f.severity.toUpperCase()}] ${f.file}:${f.line} (${f.rule})${tier}`,
|
|
153
|
+
);
|
|
154
|
+
lines.push(` ${f.test}: ${f.message}`);
|
|
155
|
+
lines.push(` ${ARROW} ${f.suggestion}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
lines.push(outcome.summaryLine);
|
|
159
|
+
if (outcome.abstained) {
|
|
160
|
+
lines.push(
|
|
161
|
+
'No test was read, so nothing here is proven. Point at a directory ' +
|
|
162
|
+
'holding test files, or pass one directly.',
|
|
163
|
+
);
|
|
164
|
+
} else if (!result.findings.length) {
|
|
165
|
+
lines.push(
|
|
166
|
+
'Advisory by default. Re-run with --strict to fail on findings.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Exit 1 with a named reason. "Cannot verify" is a finding, not a skip. */
|
|
173
|
+
function fail(message) {
|
|
174
|
+
console.error(`${PREFIX} ${message}`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Emit the result in the caller's mode. */
|
|
179
|
+
function report(result, files, outcome, json) {
|
|
180
|
+
if (!json) {
|
|
181
|
+
console.log(renderText(result, outcome));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
console.log(
|
|
185
|
+
JSON.stringify(
|
|
186
|
+
{
|
|
187
|
+
schema_version: SCHEMA_VERSION,
|
|
188
|
+
findings: result.findings.map(toJson),
|
|
189
|
+
skipped: result.skipped,
|
|
190
|
+
summary: summary(result, files.length, outcome),
|
|
191
|
+
},
|
|
192
|
+
null,
|
|
193
|
+
2,
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Scan the given paths.
|
|
200
|
+
*
|
|
201
|
+
* Returns the scan plus its gate outcome, or a numeric exit code when nothing
|
|
202
|
+
* could be scanned at all -- an unresolvable engine and a missing path are
|
|
203
|
+
* FAILURES, never a clean result with an empty finding list.
|
|
204
|
+
*/
|
|
205
|
+
function scan(paths) {
|
|
206
|
+
if (!ENGINE.ok) return fail(ENGINE.error);
|
|
207
|
+
for (const entry of paths) {
|
|
208
|
+
if (!fs.existsSync(entry)) return fail(`path not found: ${entry}`);
|
|
209
|
+
}
|
|
210
|
+
const files = collectFiles(paths, ENGINE);
|
|
211
|
+
const result = scanFiles(files, ENGINE);
|
|
212
|
+
if (files.length === 0) {
|
|
213
|
+
result.skipped.push({
|
|
214
|
+
name: paths.join(', '),
|
|
215
|
+
reason: `no test file matched (looked for ${ENGINE.SCANNABLE_DESC})`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// The same helper the engine's own surfaces use, rather than a hand-copied
|
|
219
|
+
// abstention line: this CLI already imports the engine, so the doctrine is
|
|
220
|
+
// enforced by the code instead of by convention.
|
|
221
|
+
const outcome = ENGINE.gateOutcome(
|
|
222
|
+
{
|
|
223
|
+
checked: result.checked,
|
|
224
|
+
findings: result.findings,
|
|
225
|
+
skipped: result.skipped,
|
|
226
|
+
},
|
|
227
|
+
'advisory',
|
|
228
|
+
{ noun: 'test(s)' },
|
|
229
|
+
);
|
|
230
|
+
return { files, result, outcome };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function main(argv = []) {
|
|
234
|
+
const { positionals: paths, opts, help, error } = parseArgs(argv);
|
|
235
|
+
|
|
236
|
+
// Usage resolves before any filesystem or engine work, so `--help` answers
|
|
237
|
+
// even in an install where the engine never resolved.
|
|
238
|
+
if (help) {
|
|
239
|
+
console.log(USAGE);
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (error) {
|
|
243
|
+
console.error(formatUsageError(CLI_SPEC.prog, error));
|
|
244
|
+
return EXIT_USAGE;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const scanned = scan(paths);
|
|
248
|
+
if (typeof scanned === 'number') return scanned;
|
|
249
|
+
const { files, result, outcome } = scanned;
|
|
250
|
+
report(result, files, outcome, opts.json);
|
|
251
|
+
|
|
252
|
+
// Advisory by default (D3): findings are loud, the exit is not. Under
|
|
253
|
+
// --strict the exit-code contract applies, and a collapsed denominator takes
|
|
254
|
+
// EXIT_ABSTAINED (3) -- distinct from 1, "found something real".
|
|
255
|
+
if (!opts.strict) return 0;
|
|
256
|
+
if (outcome.abstained) return EXIT_ABSTAINED;
|
|
257
|
+
return result.findings.length ? 1 : 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Direct execution (the skill runner execs this file via its shebang).
|
|
261
|
+
//
|
|
262
|
+
// `process.exitCode` rather than `process.exit()`: a `--json` payload over a
|
|
263
|
+
// large suite exceeds the pipe buffer, and `process.exit` tears the process
|
|
264
|
+
// down mid-write, truncating it to ~64KB. Truncated JSON that still exits 0 is
|
|
265
|
+
// a machine-readable result a consumer cannot parse but a shell reads as
|
|
266
|
+
// success -- the exact class of quiet failure the whole family guards against.
|
|
267
|
+
// Setting the code lets node drain stdout and exit with the same status.
|
|
268
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
269
|
+
process.exitCode = main(process.argv.slice(2));
|
|
270
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Engine resolution for the cassandra skill CLI (#755).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS SKILL IMPORTS THE ENGINE AND ITS SIBLINGS DO NOT
|
|
4
|
+
//
|
|
5
|
+
// savant, blackhawk and katana each own their detection outright, so their CLIs
|
|
6
|
+
// are self-contained by construction. Cassandra's detection is NOT its own: it
|
|
7
|
+
// already lives in `ts/src/core/vacuity-scanner.ts` and is already exercised by
|
|
8
|
+
// `canary vacuity-check` and by the promotion gate. That module's docstring
|
|
9
|
+
// states the position deliberately -- #605 accepted that `static_linter` and
|
|
10
|
+
// `quality_scorer` already overlap and a third half-enforcer would be the real
|
|
11
|
+
// defect -- so shipping a second, hand-copied vacuity scanner inside this skill
|
|
12
|
+
// would trade #755's asymmetry for a worse one: two detectors that disagree.
|
|
13
|
+
//
|
|
14
|
+
// So the missing piece was never the detection. It was the `cli:` entry point,
|
|
15
|
+
// and this file is what lets one exist without forking the rules.
|
|
16
|
+
//
|
|
17
|
+
// The engine sits three-plus levels above this script in both layouts canary
|
|
18
|
+
// ships, and the lookup is a fixed, ordered list rather than a search: an
|
|
19
|
+
// ambiguous resolution would make which rules ran depend on the install.
|
|
20
|
+
//
|
|
21
|
+
// repo checkout : <root>/ts/dist/core/... (after `npm --prefix ts run build`)
|
|
22
|
+
// npm package : <pkg>/dist/engine/core/... (staged by npm/scripts/build-engine.mjs)
|
|
23
|
+
//
|
|
24
|
+
// A failure to resolve is reported as a failure. It is never absorbed into a
|
|
25
|
+
// clean scan -- "the detector could not load" and "the detector found nothing"
|
|
26
|
+
// must not print the same thing.
|
|
27
|
+
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
// scripts -> canary-cassandra -> claude-code -> skills -> agents -> <root>
|
|
35
|
+
const ROOT = path.resolve(HERE, '..', '..', '..', '..', '..');
|
|
36
|
+
|
|
37
|
+
/** Candidate engine directories, highest priority first. */
|
|
38
|
+
export function engineCandidates(root = ROOT, env = process.env) {
|
|
39
|
+
const candidates = [];
|
|
40
|
+
// An explicit override exists for overlay installs, where the skill is
|
|
41
|
+
// deployed away from the engine that owns its rules.
|
|
42
|
+
if (env.CANARY_ENGINE_DIR)
|
|
43
|
+
candidates.push(path.resolve(env.CANARY_ENGINE_DIR));
|
|
44
|
+
candidates.push(path.join(root, 'ts', 'dist'));
|
|
45
|
+
candidates.push(path.join(root, 'dist', 'engine'));
|
|
46
|
+
return candidates;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MODULES = [
|
|
50
|
+
['core', 'vacuity-scanner.js'],
|
|
51
|
+
['core', 'test-files.js'],
|
|
52
|
+
['core', 'gate-result.js'],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** The first candidate directory holding every module we need, or null. */
|
|
56
|
+
export function resolveEngineDir(candidates = engineCandidates()) {
|
|
57
|
+
for (const dir of candidates) {
|
|
58
|
+
if (MODULES.every((parts) => fs.existsSync(path.join(dir, ...parts)))) {
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load the engine halves this CLI delegates to.
|
|
67
|
+
*
|
|
68
|
+
* @returns `{ ok: true, ... }` on success, or `{ ok: false, error }` naming
|
|
69
|
+
* every directory that was tried -- a zero-denominator report of its own.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadEngine(candidates = engineCandidates()) {
|
|
72
|
+
const dir = resolveEngineDir(candidates);
|
|
73
|
+
if (dir === null) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error:
|
|
77
|
+
'the vacuity engine could not be located, so nothing was scanned. ' +
|
|
78
|
+
`Tried: ${candidates.join(', ')}. In a Canary checkout run ` +
|
|
79
|
+
'`npm --prefix ts run build`; otherwise reinstall canary-test-cli, or ' +
|
|
80
|
+
'set CANARY_ENGINE_DIR to the directory holding core/.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const load = (parts) => import(pathToFileURL(path.join(dir, ...parts)).href);
|
|
84
|
+
const [vacuity, testFiles, gate] = await Promise.all(MODULES.map(load));
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
dir,
|
|
88
|
+
scanVacuity: vacuity.scanVacuity,
|
|
89
|
+
collectTestFiles: testFiles.collectTestFiles,
|
|
90
|
+
isDir: testFiles.isDir,
|
|
91
|
+
SCANNABLE_DESC: testFiles.SCANNABLE_DESC,
|
|
92
|
+
gateOutcome: gate.gateOutcome,
|
|
93
|
+
EXIT_ABSTAINED: gate.EXIT_ABSTAINED,
|
|
94
|
+
};
|
|
95
|
+
}
|