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,296 @@
1
+ // alarm -- fire only when a deletion removes the last coverage of a hot symbol.
2
+ //
3
+ // Most test deletions are legitimate, so katana is silent by default and
4
+ // records everything. It alarms in exactly one situation: the deleted test was
5
+ // the *last* test covering a symbol `critical-areas.json` marks high-risk. When
6
+ // that file is missing or malformed the alarm degrades to recording-only and
7
+ // says so; a gate that manufactures failures on missing data gets muted, and a
8
+ // muted gate is worse than no gate.
9
+
10
+ import fs from 'node:fs';
11
+
12
+ import { isTestFile } from './diffscan.mjs';
13
+
14
+ export const DEGRADED_NOTICE =
15
+ 'critical-area data unavailable, recording only, not alarming';
16
+
17
+ // risk_score at or above this makes a name-matched last-coverage loss CRITICAL;
18
+ // below it the loss is still real but ranked HIGH.
19
+ const CRITICAL_RISK = 0.7;
20
+
21
+ // Directory names too generic to imply a coverage relationship on their own.
22
+ const GENERIC_DIRS = new Set([
23
+ 'src',
24
+ 'lib',
25
+ 'app',
26
+ 'apps',
27
+ 'packages',
28
+ 'pkg',
29
+ 'tests',
30
+ 'test',
31
+ '__tests__',
32
+ 'e2e',
33
+ 'spec',
34
+ 'dist',
35
+ 'build',
36
+ ]);
37
+
38
+ const CODE_SUFFIXES = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py'];
39
+
40
+ export const Fidelity = {
41
+ NAME_MATCHED: { value: 'name-matched', rank: 0 },
42
+ HEURISTIC: { value: 'heuristic', rank: 1 },
43
+ };
44
+
45
+ export const Severity = {
46
+ CRITICAL: { value: 'critical', sortKey: 0 },
47
+ HIGH: { value: 'high', sortKey: 1 },
48
+ MEDIUM: { value: 'medium', sortKey: 2 },
49
+ };
50
+
51
+ /**
52
+ * @typedef {{available: boolean, areas: Array<Record<string, any>>, reason: string}} CriticalAreas
53
+ */
54
+
55
+ /** JSON-contract shape of a finding. */
56
+ export function findingToDict(f) {
57
+ return {
58
+ kind: f.kind,
59
+ test: f.test,
60
+ file: f.file,
61
+ area: f.area,
62
+ fidelity: f.fidelity.value,
63
+ severity: f.severity.value,
64
+ evidence: f.evidence,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Load critical-areas.json; unavailable (not throwing) on any problem.
70
+ * @returns {CriticalAreas}
71
+ */
72
+ export function loadCriticalAreas(filePath) {
73
+ if (filePath === null || filePath === undefined) {
74
+ return {
75
+ available: false,
76
+ areas: [],
77
+ reason: 'critical-area file not provided',
78
+ };
79
+ }
80
+ if (!fs.existsSync(filePath)) {
81
+ return {
82
+ available: false,
83
+ areas: [],
84
+ reason: `critical-area file not found: ${filePath}`,
85
+ };
86
+ }
87
+ let data;
88
+ try {
89
+ data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
90
+ } catch (exc) {
91
+ return {
92
+ available: false,
93
+ areas: [],
94
+ reason: `critical-area file malformed: ${exc.message}`,
95
+ };
96
+ }
97
+ const areas =
98
+ data &&
99
+ typeof data === 'object' &&
100
+ !Array.isArray(data) &&
101
+ Array.isArray(data.areas)
102
+ ? data.areas
103
+ : [];
104
+ return { available: true, areas: [...areas], reason: '' };
105
+ }
106
+
107
+ const norm = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
108
+
109
+ /**
110
+ * Symbols an area path exposes: its basename minus code suffix, plus parts.
111
+ * `src/loyalty/points.service.ts` -> {points.service, points, service}.
112
+ */
113
+ export function areaSymbols(areaPath) {
114
+ let base = areaPath.replace(/\\/g, '/').split('/').pop();
115
+ for (const suffix of CODE_SUFFIXES) {
116
+ if (base.endsWith(suffix)) {
117
+ base = base.slice(0, -suffix.length);
118
+ break;
119
+ }
120
+ }
121
+ const symbols = new Set([base]);
122
+ for (const part of base.split('.')) if (part) symbols.add(part);
123
+ return symbols;
124
+ }
125
+
126
+ const areaNormSymbols = (areaPath) => {
127
+ const out = new Set();
128
+ for (const s of areaSymbols(areaPath)) {
129
+ const n = norm(s);
130
+ if (n.length >= 4) out.add(n);
131
+ }
132
+ return out;
133
+ };
134
+
135
+ const dirsOf = (p) => {
136
+ const parts = p.replace(/\\/g, '/').split('/').filter(Boolean);
137
+ return parts.slice(0, -1);
138
+ };
139
+
140
+ const significantDirs = (p) =>
141
+ new Set(dirsOf(p).filter((d) => !GENERIC_DIRS.has(d)));
142
+
143
+ const nameCovers = (testName, normSymbols) => {
144
+ const normalized = norm(testName);
145
+ return [...normSymbols].some((sym) => normalized.includes(sym));
146
+ };
147
+
148
+ // Heavy/ignored directories never worth walking for test files (#395).
149
+ const SKIP_DIRS = new Set([
150
+ '.git',
151
+ 'node_modules',
152
+ '__pycache__',
153
+ '.venv',
154
+ 'venv',
155
+ 'dist',
156
+ 'build',
157
+ '.mypy_cache',
158
+ '.pytest_cache',
159
+ '.tox',
160
+ 'coverage',
161
+ '.next',
162
+ '.turbo',
163
+ ]);
164
+
165
+ /**
166
+ * Enumerate repo test files, pruning heavy dirs. Deterministic (sorted).
167
+ * @returns {[string, string][]} [relPosixPath, absolutePath] pairs.
168
+ */
169
+ export function repoTestFiles(repo) {
170
+ const results = [];
171
+ const walk = (absDir, relDir) => {
172
+ let entries;
173
+ try {
174
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
175
+ } catch {
176
+ return;
177
+ }
178
+ const dirs = [];
179
+ const files = [];
180
+ for (const e of entries) {
181
+ if (e.isDirectory()) {
182
+ if (!SKIP_DIRS.has(e.name)) dirs.push(e.name);
183
+ } else if (e.isFile()) {
184
+ files.push(e.name);
185
+ }
186
+ }
187
+ for (const name of files.sort()) {
188
+ const rel = relDir ? `${relDir}/${name}` : name;
189
+ if (isTestFile(rel)) results.push([rel, `${absDir}/${name}`]);
190
+ }
191
+ for (const name of dirs.sort()) {
192
+ walk(`${absDir}/${name}`, relDir ? `${relDir}/${name}` : name);
193
+ }
194
+ };
195
+ walk(String(repo), '');
196
+ return results;
197
+ }
198
+
199
+ const PY_TEST_DEF = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/gm;
200
+ const JS_TEST_CALL =
201
+ /\b(?:describe|context|it|test)(?:\.\w+)?\s*\(\s*(['"`])(.*?)\1/g;
202
+
203
+ function testNames(text) {
204
+ const names = [];
205
+ for (const m of text.matchAll(PY_TEST_DEF)) names.push(m[1]);
206
+ for (const m of text.matchAll(JS_TEST_CALL)) names.push(m[2]);
207
+ return names;
208
+ }
209
+
210
+ const readTextSafe = (p) => {
211
+ try {
212
+ return fs.readFileSync(p, 'utf8');
213
+ } catch {
214
+ return null;
215
+ }
216
+ };
217
+
218
+ function nameCoverageRemains(repo, normSymbols) {
219
+ for (const [, abs] of repoTestFiles(repo)) {
220
+ const text = readTextSafe(abs);
221
+ if (text === null) continue;
222
+ if (testNames(text).some((name) => nameCovers(name, normSymbols)))
223
+ return true;
224
+ }
225
+ return false;
226
+ }
227
+
228
+ function dirCoverageRemains(repo, areaDirs) {
229
+ for (const [rel, abs] of repoTestFiles(repo)) {
230
+ if (!dirsOf(rel).some((d) => areaDirs.has(d))) continue;
231
+ const text = readTextSafe(abs);
232
+ if (text === null) continue;
233
+ if (testNames(text).length) return true;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ /** Return last-coverage-removed findings; empty when data is unavailable. */
239
+ export function buildFindings(deletions, areas, repo) {
240
+ if (!areas.available) return []; // silent by default: never alarm on degraded data
241
+ const findings = [];
242
+
243
+ for (const deletion of deletions) {
244
+ let best = null;
245
+ for (const area of areas.areas) {
246
+ const areaPath = area.path || '';
247
+ const risk = Number.parseFloat(area.risk_score) || 0.0;
248
+ const normSymbols = areaNormSymbols(areaPath);
249
+
250
+ let fidelity;
251
+ let severity;
252
+ if (normSymbols.size && nameCovers(deletion.name, normSymbols)) {
253
+ if (nameCoverageRemains(repo, normSymbols)) continue;
254
+ fidelity = Fidelity.NAME_MATCHED;
255
+ severity = risk >= CRITICAL_RISK ? Severity.CRITICAL : Severity.HIGH;
256
+ } else {
257
+ const areaDirs = significantDirs(areaPath);
258
+ const delDirs = new Set(dirsOf(deletion.file));
259
+ if (![...areaDirs].some((d) => delDirs.has(d))) continue;
260
+ if (dirCoverageRemains(repo, areaDirs)) continue;
261
+ fidelity = Fidelity.HEURISTIC;
262
+ severity = Severity.MEDIUM;
263
+ }
264
+
265
+ const candidate = {
266
+ kind: 'last-coverage-removed',
267
+ test: deletion.name,
268
+ file: deletion.file,
269
+ area: areaPath,
270
+ fidelity,
271
+ severity,
272
+ evidence: `${deletion.name} was the last test covering ${areaPath}`,
273
+ };
274
+ // Keep the best candidate per deletion: lower (fidelity.rank, sortKey)
275
+ // wins, element-wise (name-matched outranks heuristic; then severity).
276
+ if (best === null) {
277
+ best = candidate;
278
+ } else {
279
+ const better =
280
+ fidelity.rank !== best.fidelity.rank
281
+ ? fidelity.rank < best.fidelity.rank
282
+ : severity.sortKey < best.severity.sortKey;
283
+ if (better) best = candidate;
284
+ }
285
+ }
286
+ if (best !== null) findings.push(best);
287
+ }
288
+
289
+ findings.sort(
290
+ (a, b) =>
291
+ a.severity.sortKey - b.severity.sortKey ||
292
+ a.file.localeCompare(b.file) ||
293
+ a.test.localeCompare(b.test),
294
+ );
295
+ return findings;
296
+ }
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // canary-katana -- quarantine deleted and newly-skipped tests, with provenance.
3
+ //
4
+ // Captures every removed or skipped test into an append-only ledger (who
5
+ // deleted it, when, in which commit, and why), and alarms in exactly one case:
6
+ // the removed test was the last coverage of a symbol `critical-areas.json`
7
+ // marks high-risk.
8
+ //
9
+ // Advisory by default (always exit 0). `--strict` exits 1 only on a real alarm;
10
+ // a degraded run (no critical-area data) stays exit 0 even under `--strict` --
11
+ // a gate that fails on missing data gets muted, and a muted gate is worse than
12
+ // none.
13
+ //
14
+ // Invoked via `canary skills run canary-katana -- [options]`.
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import {
20
+ createParser,
21
+ formatUsageError,
22
+ EXIT_USAGE,
23
+ } from '../../../lib/parse-args.mjs';
24
+ import * as diffscan from './diffscan.mjs';
25
+ import * as alarm from './alarm.mjs';
26
+ import * as ledger from './ledger.mjs';
27
+
28
+ // --- no-silent-abstention (#508 D2, skill-CLI convention half) ---------------
29
+ //
30
+ // Skill CLIs are deliberately self-contained -- no engine import -- so they
31
+ // cannot call `gateOutcome`. They honour the doctrine by CONVENTION, emitting
32
+ // the same greppable line the engine helper does; the skill-layer conformance
33
+ // registry (agents/skills/test/gate-conformance.test.ts) holds them to it.
34
+ //
35
+ // U+26A0 / U+2014 as escapes so this source stays ASCII, matching
36
+ // ts/src/core/gate-result.ts.
37
+ const ABSTAINED_LINE =
38
+ '\u{26A0} Abstained \u{2014} verified zero items; this is not a pass.';
39
+
40
+ const PREFIX = 'canary-katana:';
41
+
42
+ const USAGE =
43
+ 'usage: canary-katana [-h] [--repo PATH] [--diff-file PATH] [--ledger PATH]\n' +
44
+ ' [--critical-areas PATH] [--json] [--strict] [--no-write]\n' +
45
+ '\n' +
46
+ 'Quarantine deleted and newly-skipped tests into an append-only ledger with\n' +
47
+ 'provenance, and alarm when a removal drops the last coverage of a critical\n' +
48
+ 'area.\n' +
49
+ '\n' +
50
+ 'options:\n' +
51
+ ' -h, --help show this help message and exit\n' +
52
+ ' --repo PATH repository to inspect (default: .)\n' +
53
+ ' --diff-file PATH read the diff from a file instead of git\n' +
54
+ ' --ledger PATH ledger location (default: <repo>/.canary/quarantine.json)\n' +
55
+ ' --critical-areas PATH critical-areas.json used to raise alarms\n' +
56
+ ' --json emit machine-readable output instead of human text\n' +
57
+ ' --strict exit 1 on a real alarm (degraded runs stay 0)\n' +
58
+ ' --no-write do not append to the ledger (read-only run)';
59
+
60
+ /**
61
+ * katana takes no positionals, so any leftover token -- dashed or not -- is a
62
+ * usage error rather than something silently ignored, and `--` has nothing to
63
+ * protect. The four shared invariants (null-prototype lookup, empty-value
64
+ * rejection, arity, `--flag=value`) live in the shared parser; see #479 for why
65
+ * they stopped living here.
66
+ */
67
+ export const CLI_SPEC = {
68
+ prog: 'canary-katana',
69
+ booleans: {
70
+ '--json': 'json',
71
+ '--strict': 'strict',
72
+ '--no-write': 'noWrite',
73
+ },
74
+ values: {
75
+ '--repo': { key: 'repo' },
76
+ '--diff-file': { key: 'diffFile' },
77
+ '--ledger': { key: 'ledger' },
78
+ '--critical-areas': { key: 'criticalAreas' },
79
+ },
80
+ defaults: { repo: '.' },
81
+ };
82
+
83
+ const parseArgs = createParser(CLI_SPEC);
84
+
85
+ /**
86
+ * Return { text, base }. `base` is a git ref when one is resolvable. With
87
+ * diffFile the diff is read verbatim and git is still consulted (best-effort)
88
+ * for provenance; without it the diff is computed from the repo's own history.
89
+ */
90
+ function loadDiff(repo, diffFile) {
91
+ if (diffFile) {
92
+ if (!fs.existsSync(diffFile)) {
93
+ const err = new Error(`diff file not found: ${diffFile}`);
94
+ err.notFound = true;
95
+ throw err;
96
+ }
97
+ const text = fs.readFileSync(diffFile, 'utf8');
98
+ let base = null;
99
+ try {
100
+ base = diffscan.resolveBase(repo, null);
101
+ } catch {
102
+ base = null; // non-git repo: provenance stays unknown
103
+ }
104
+ return { text, base };
105
+ }
106
+ const base = diffscan.resolveBase(repo, null);
107
+ return { text: diffscan.diffText(repo, base), base };
108
+ }
109
+
110
+ function provenance(repo, base, file) {
111
+ if (base === null) return null;
112
+ try {
113
+ return diffscan.commitForFile(repo, base, file);
114
+ } catch {
115
+ return null; // missing history is unknown, not fatal
116
+ }
117
+ }
118
+
119
+ function toEntries(repo, base, deletions) {
120
+ return deletions.map((d) => {
121
+ const commit = provenance(repo, base, d.file);
122
+ return ledger.LedgerEntry({
123
+ test: d.name,
124
+ file: d.file,
125
+ kind: d.kind,
126
+ marker: d.marker,
127
+ commit: commit ? commit.sha : '',
128
+ author: commit ? commit.author : 'unknown',
129
+ date: commit ? commit.date : '',
130
+ reason: commit ? commit.subject : '',
131
+ // The `Ticket:` trailer is one way an issue link arrives; a quarantine
132
+ // producer writing a caused row is the other. Both land in `issue`.
133
+ issue: commit ? commit.ticket : '',
134
+ });
135
+ });
136
+ }
137
+
138
+ function renderText(deletions, findings, degraded, scanned) {
139
+ // #508: katana's denominator is the DIFF it read, not the deletions it found.
140
+ // Zero deletions in a 500-line diff is a real result; zero deletions in an
141
+ // EMPTY diff means nothing was examined at all. `0 deletion(s) captured` reads
142
+ // identically in both cases, which is precisely the shape the doctrine bans.
143
+ if (!scanned) {
144
+ return (
145
+ `${ABSTAINED_LINE} The diff was empty, so no deleted test could be ` +
146
+ 'captured. Check --repo/--diff-file, or that the range actually ' +
147
+ 'contains changes.'
148
+ );
149
+ }
150
+ const lines = [`${deletions.length} deletion(s) captured.`];
151
+ if (degraded) lines.push(alarm.DEGRADED_NOTICE);
152
+ for (const f of findings) {
153
+ lines.push(
154
+ ` [${f.severity.value}] ${f.file}::${f.test} removed the last coverage of ${f.area}`,
155
+ );
156
+ }
157
+ return lines.join('\n');
158
+ }
159
+
160
+ export function main(argv = []) {
161
+ const { opts: args, help, error } = parseArgs(argv);
162
+
163
+ // FIRST, before loadDiff and before any ledger write: a usage request or a
164
+ // typo must never mutate the working tree.
165
+ if (help) {
166
+ console.log(USAGE);
167
+ return 0;
168
+ }
169
+ if (error) {
170
+ console.error(formatUsageError(CLI_SPEC.prog, error));
171
+ return EXIT_USAGE;
172
+ }
173
+
174
+ const repo = args.repo;
175
+ // `!= null`, not a truthiness test: "--ledger was not given" and "--ledger
176
+ // was given an empty path" are different situations, and only the first one
177
+ // may fall back to the default. (An empty value is rejected at parse time,
178
+ // so this branch is now unreachable with '' -- the explicit null check keeps
179
+ // it that way if the parser ever loosens.)
180
+ const ledgerPath =
181
+ args.ledger != null
182
+ ? args.ledger
183
+ : path.join(repo, '.canary', 'quarantine.json');
184
+
185
+ let diff;
186
+ let base;
187
+ try {
188
+ ({ text: diff, base } = loadDiff(repo, args.diffFile));
189
+ } catch (exc) {
190
+ if (exc.notFound) {
191
+ console.error(`${PREFIX} ${exc.message}`);
192
+ return 1;
193
+ }
194
+ console.error(`${PREFIX} could not read diff: ${exc.message}`);
195
+ return 1;
196
+ }
197
+
198
+ // Non-blank diff text is the denominator probe: `loadDiff` succeeding does
199
+ // not mean it returned anything to scan.
200
+ const scanned = diff.trim().length > 0;
201
+ const deletions = diffscan.findDeletions(diff);
202
+ const entries = toEntries(repo, base, deletions);
203
+
204
+ if (!args.noWrite) {
205
+ try {
206
+ ledger.appendEntries(ledgerPath, entries);
207
+ } catch (exc) {
208
+ console.error(`${PREFIX} ${exc.message}`);
209
+ return 1;
210
+ }
211
+ }
212
+
213
+ const areas = alarm.loadCriticalAreas(args.criticalAreas);
214
+ const degraded = !areas.available;
215
+ const findings = alarm.buildFindings(deletions, areas, repo);
216
+
217
+ if (args.json) {
218
+ const payload = {
219
+ schema_version: ledger.SCHEMA_VERSION,
220
+ captured: deletions.map(diffscan.deletionToDict),
221
+ findings: findings.map(alarm.findingToDict),
222
+ ledger: String(ledgerPath),
223
+ };
224
+ if (degraded) payload.degraded_notice = alarm.DEGRADED_NOTICE;
225
+ // Additive (#508): a consumer can distinguish "no deletions" from "nothing
226
+ // examined" without parsing prose.
227
+ payload.checked = scanned ? 1 : 0;
228
+ payload.abstained = !scanned;
229
+ console.log(JSON.stringify(payload, null, 2));
230
+ } else {
231
+ console.log(renderText(deletions, findings, degraded, scanned));
232
+ }
233
+
234
+ // Advisory by default (D3); --strict inherits EXIT_ABSTAINED (3) on an empty
235
+ // diff, distinct from 1 ("captured a real deletion").
236
+ if (args.strict && !scanned) return 3;
237
+ return args.strict && findings.length ? 1 : 0;
238
+ }
239
+
240
+ // Direct execution (the skill runner execs this file via its shebang).
241
+ //
242
+ // `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
243
+ // the pipe buffer, and `process.exit` tears the process down mid-write, leaving
244
+ // truncated JSON that still exits 0 (#791).
245
+ if (import.meta.url === `file://${process.argv[1]}`) {
246
+ process.exitCode = main(process.argv.slice(2));
247
+ }