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