canary-test-cli 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. package/package.json +3 -2
@@ -0,0 +1,243 @@
1
+ ---
2
+ name: canary-katana
3
+ description:
4
+ Quarantines deleted and newly-skipped tests instead of letting them vanish.
5
+ Captures every removed or skipped test with provenance (who, when, which
6
+ commit, why) into an append-only ledger, and alarms in exactly one case — the
7
+ deletion removed the last coverage of a symbol critical-areas.json marks
8
+ high-risk. Silent by default, degrades to recording-only when critical-area
9
+ data is missing. Self-contained, deterministic, advisory by default.
10
+ cli: scripts/cli.mjs
11
+ requires: [node>=20]
12
+ ---
13
+
14
+ # Canary Katana
15
+
16
+ Named for Tatsu Yamashiro's Soultaker — the blade that captures the soul of
17
+ whatever it cuts. A deleted test is coverage that leaves without a trace: the
18
+ suite still goes green, the gap is invisible, and nobody notices until the bug
19
+ it caught ships. Katana catches every test as it is removed or muted, records
20
+ who took it and why, and raises its voice only when the cut was the last thing
21
+ guarding a critical path.
22
+
23
+ Tier-0 deterministic analysis: no LLM, no network, no secrets, no dependency on
24
+ any other skill at runtime.
25
+
26
+ ## What it captures
27
+
28
+ | Event | Detected from a diff |
29
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
30
+ | `removed` | a `def test_*` / `async def test_*` (Python) or `describe`/`it`/`test('…')` (JS/TS) that left on a `-` line **and did not come back on the `+` side** |
31
+ | `skipped` | a `+`-side skip/mute marker: `@pytest.mark.skip` / `skipif` / `xfail`, or `it.skip` / `test.skip` / `describe.skip`, `it.only` / `test.only`, `xit` / `xdescribe` / `fit` |
32
+
33
+ A test flipped in place from `it('x')` to `it.skip('x')` is **one** event, not
34
+ two: the skip supersedes the removal so the ledger never double-counts a
35
+ mute-in-place as both a deletion and a skip.
36
+
37
+ The general form of that rule is the emphasis above: a `(file, title)` that
38
+ reappears on the `+` side was **modified, not removed**. Without it, any rewrite
39
+ of a declaration line recorded a deletion of a test that is still in the tree —
40
+ a prettier reflow of a long signature, or a `.skip` lifted in place, was enough
41
+ (#783). The ledger is append-only, so such a row is permanent and cannot be
42
+ corrected without the hand-edit the ledger exists to prevent; and a consumer
43
+ that attributes on the newest matching row would hand every test under a phantom
44
+ removal of a `describe` the wrong ticket. A **rename** is still a removal — the
45
+ old title's coverage really is gone.
46
+
47
+ ## The one thing it alarms on
48
+
49
+ Most test deletions are legitimate — dead feature removal, genuine dedup — so
50
+ alarming on every one is nag fatigue within a week, and a gate people mute is
51
+ worse than no gate. Katana is **silent by default** and alarms only when a
52
+ removed test was the **last coverage** of a symbol listed in
53
+ `critical-areas.json` (produced by `canary-critical-areas`).
54
+
55
+ - **name-matched** — the removed test's name matches an area symbol and no other
56
+ test still covers it. Severity `critical` when the area's `risk_score` is high
57
+ (≥ 0.7), otherwise `high`.
58
+ - **heuristic** — only the test's _directory_ maps to the area (no name match).
59
+ Always severity `medium`, and flagged as lower fidelity.
60
+
61
+ ### Degradation is loud and safe
62
+
63
+ When `critical-areas.json` is missing or malformed, katana records everything
64
+ but alarms on nothing, printing:
65
+
66
+ ```text
67
+ critical-area data unavailable, recording only, not alarming
68
+ ```
69
+
70
+ Degradation never manufactures a failure — even under `--strict`, a degraded run
71
+ exits `0`.
72
+
73
+ ## The ledger
74
+
75
+ Append-only JSON at `.canary/quarantine.json` (override with `--ledger`). Each
76
+ row carries full provenance so a vanished test leaves a trail:
77
+
78
+ ```json
79
+ {
80
+ "schema_version": 2,
81
+ "entries": [
82
+ {
83
+ "test": "test_points_service_earns",
84
+ "file": "tests/test_points.py",
85
+ "kind": "removed",
86
+ "marker": "",
87
+ "commit": "…40 hex…",
88
+ "author": "Ada Lovelace",
89
+ "date": "2026-07-20T10:00:00+00:00",
90
+ "reason": "chore: drop points coverage",
91
+ "cause": "",
92
+ "issue": "",
93
+ "expiry": ""
94
+ }
95
+ ]
96
+ }
97
+ ```
98
+
99
+ Re-running on the same change adds nothing (entries de-duplicate); a corrupt
100
+ ledger is a hard error, never silently overwritten.
101
+
102
+ ### Schema v2: why a row is out, not just how it left (#771)
103
+
104
+ `cause`, `issue` and `expiry` are written by the quarantine producer, not by
105
+ katana. Katana records what it can observe from a diff — a test was removed or
106
+ skipped — and leaves `cause` empty, because "someone deleted this in commit
107
+ abc123" is provenance, not a judgement about why the test is out of the suite.
108
+
109
+ `reason` and `cause` are deliberately separate. `reason` is **derived** (the
110
+ commit subject). `cause` is **asserted** — one of `flaky`, `product-defect`,
111
+ `blocked-data`, `obsolete`. Collapsing them would dress an auto-derived string
112
+ up as a claim someone stands behind.
113
+
114
+ **One row per `(test, file)` may state a cause, and a caused row wins.** A row
115
+ with a cause supersedes a causeless row for the same pair, and a causeless row
116
+ is dropped when a caused row already exists. This is the one place the ledger is
117
+ not purely append-only, and it exists because the alternative is worse: katana
118
+ recording `{kind: 'skipped', cause: ''}` and a quarantine producer recording
119
+ `{kind: 'skipped', cause: 'product-defect', issue: …}` differ in every-field
120
+ identity, so **both** would persist — and a consumer that fails on an unlinked
121
+ quarantine (`canary-ci-ready` does) would fail on the causeless row while the
122
+ linked row sat beside it. The ledger would be contradicting itself about one
123
+ test.
124
+
125
+ History survives that rule: only rows differing in cause-bearing state collapse.
126
+ Two caused rows, or two causeless rows, keep the full-field identity and both
127
+ remain.
128
+
129
+ ### Where `issue` comes from, and why the trailer keeps its own name
130
+
131
+ `issue` is the bug the quarantine is waiting on. It has two sources, and both
132
+ land in the same field:
133
+
134
+ - **A `Ticket:` commit trailer**, read by katana at capture time (`Bug:` and
135
+ `Tracked:` are accepted spellings). This is the low-friction path: the person
136
+ switching the test off names the bug in the commit that does it.
137
+ - **A quarantine producer**, writing a caused row directly.
138
+
139
+ v1 called this field `ticket` (#781). It is folded into `issue` here rather than
140
+ kept alongside, because two fields answering "what is this waiting on" is how a
141
+ consumer ends up reading the empty one — and the consumer is specific:
142
+ `canary-ci-ready` fails a quarantine with no **linked issue**, in either Jira or
143
+ GitHub. The schema now uses the consumer's word. A v1 row's `ticket` migrates
144
+ onto `issue` on load, so no recorded link is lost.
145
+
146
+ `Ticket:` survives as the name of the **trailer**, which is a mechanism rather
147
+ than a schema: it is what you type in a commit message, and renaming it would
148
+ invalidate the trailers already written without teaching anyone anything.
149
+
150
+ Empty is a real and important state, not a gap to paper over. A test switched
151
+ off with nothing to chase is the worst thing this ledger can record, and it can
152
+ only be seen if it is recorded honestly.
153
+
154
+ A v1 file is normalized on load, so every row comes back carrying the v2 fields
155
+ (empty where unrecorded). That is what makes writing `schema_version: 2` honest
156
+ — the version claims these rows have these fields, and after load they do.
157
+ Stamping the version over un-migrated rows would make it a promise the file does
158
+ not keep.
159
+
160
+ ## Invocation
161
+
162
+ ```bash
163
+ # Diff the current branch against its merge-base, record, advise (exit 0):
164
+ canary skills run canary-katana
165
+
166
+ # Feed an explicit diff and a critical-areas map:
167
+ canary skills run canary-katana -- \
168
+ --diff-file changes.diff --critical-areas .canary/critical-areas.json
169
+
170
+ # Machine-readable:
171
+ canary skills run canary-katana -- --json
172
+
173
+ # Fail the step only when a critical path loses its last coverage:
174
+ canary skills run canary-katana -- --strict
175
+
176
+ # Usage and options (exits 0, and writes nothing to the ledger):
177
+ canary skills run canary-katana -- --help
178
+ ```
179
+
180
+ Value flags (`--repo`, `--diff-file`, `--ledger`, `--critical-areas`) accept
181
+ both `--repo <path>` and `--repo=<path>`, matching `canary-instrument` and
182
+ `canary-fail-fast`.
183
+
184
+ An unknown flag is rejected with `unrecognized arguments: <flag>` and exit 2,
185
+ and a value flag left without a usable value is
186
+ `argument <flag>: expected one argument` (exit 2). That covers all three ways
187
+ the value can go missing: the flag is last, the next token is another flag, or
188
+ the value is empty — in either the `--repo=` spelling or, the one shells
189
+ actually produce, `--repo "$UNSET_VAR"`. Empty is rejected rather than accepted
190
+ because `--repo ''` would resolve the ledger to `path.join('', '.canary', ...)`
191
+ and write it into the process CWD instead of the target repo.
192
+
193
+ All of these are decided before any diff is read or ledger entry is appended, so
194
+ a usage request or a typo never mutates the working tree.
195
+
196
+ `--json` shape:
197
+
198
+ ```json
199
+ {
200
+ "schema_version": 2,
201
+ "captured": [
202
+ { "name": "…", "file": "…", "kind": "removed", "line": 3, "marker": "" }
203
+ ],
204
+ "findings": [
205
+ {
206
+ "kind": "last-coverage-removed",
207
+ "test": "…",
208
+ "file": "…",
209
+ "area": "src/loyalty/points.service.ts",
210
+ "fidelity": "name-matched",
211
+ "severity": "critical",
212
+ "evidence": "…"
213
+ }
214
+ ],
215
+ "ledger": ".canary/quarantine.json"
216
+ }
217
+ ```
218
+
219
+ A degraded run adds a top-level `"degraded_notice"` and an empty `findings`.
220
+
221
+ ## CI wiring (GitHub Actions)
222
+
223
+ Advisory first, then promote to blocking once the ledger is trusted — the same
224
+ path every canary gate takes.
225
+
226
+ ```yaml
227
+ - name: Quarantine deleted tests (advisory)
228
+ run:
229
+ canary skills run canary-katana -- --critical-areas
230
+ .canary/critical-areas.json
231
+ # Once trusted, add --strict so a last-coverage loss fails the PR:
232
+ # run: canary skills run canary-katana -- --critical-areas .canary/critical-areas.json --strict
233
+ ```
234
+
235
+ ## Fidelity limits (regex/diff-lite, on purpose)
236
+
237
+ - **Line-scoped diff parsing.** A declaration split across lines can be missed;
238
+ katana errs toward recording the clear cases.
239
+ - **Name/dir coverage is heuristic.** "Last coverage" is inferred from test
240
+ names and directory layout, not a real coverage run — treat `heuristic`
241
+ findings as prompts to look, not verdicts.
242
+ - **Provenance needs git.** Fed a `--diff-file` outside a git repo, author and
243
+ commit are recorded as `unknown` / empty rather than guessed.
@@ -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
+ }