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.
Files changed (128) hide show
  1. package/agents/skills/README.md +327 -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-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. 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
+ }