canary-test-cli 6.8.0 → 7.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 (36) hide show
  1. package/dist/engine/analysis/cli.js +155 -45
  2. package/dist/engine/analysis/engine.js +9 -9
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +2 -2
  5. package/dist/engine/core/migrator.js +142 -31
  6. package/dist/engine/core/static-linter.js +2 -2
  7. package/dist/engine/core/workspace-detect.js +0 -0
  8. package/dist/engine/guardian/adjudication.js +1 -1
  9. package/dist/engine/guardian/agent-tier.js +3 -3
  10. package/dist/engine/guardian/coverage.js +15 -1397
  11. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  12. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  13. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  14. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  15. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  16. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  17. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  18. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  19. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  20. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  21. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  22. package/dist/engine/guardian/pr-check.js +22 -7
  23. package/dist/engine-checks.d.ts +15 -0
  24. package/dist/engine-checks.js +92 -1
  25. package/dist/overlay-commands.d.ts +12 -1
  26. package/dist/overlay-commands.js +28 -2
  27. package/dist/router.js +17 -5
  28. package/dist/uninstall-render.d.ts +11 -0
  29. package/dist/uninstall-render.js +60 -0
  30. package/dist/uninstall-scan.d.ts +14 -0
  31. package/dist/uninstall-scan.js +273 -0
  32. package/dist/uninstall-types.d.ts +46 -0
  33. package/dist/uninstall-types.js +91 -0
  34. package/dist/uninstall.d.ts +13 -0
  35. package/dist/uninstall.js +174 -0
  36. package/package.json +1 -1
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Tier 1 — coverage resolved from an explicit report (`COVERAGE_VERIFIED`).
3
+ *
4
+ * Owns the lcov reader, the format dispatch (which report shape is this file?),
5
+ * and the per-unit matching that turns a report index into verdicts.
6
+ */
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import { parseCobertura } from './formats/cobertura.js';
10
+ import { parseCoverageJson } from './formats/coverage-json.js';
11
+ import { expandRanges, makeResult, matchFile, rangesStr, selfDescribing, splitLines, pyInt, Fidelity, } from './types.js';
12
+ /**
13
+ * Parse `lcov.info` into `{path: {line: hits}}`.
14
+ *
15
+ * Every `DA:` record is a line the instrumenter measured, so the recorded lines
16
+ * are exactly the coverable set — see `FileCoverage`.
17
+ */
18
+ function parseLcov(text) {
19
+ const byPath = {};
20
+ let current = null;
21
+ for (const line of splitLines(text)) {
22
+ if (line.startsWith('SF:')) {
23
+ current = line.slice(3).trim();
24
+ if (!(current in byPath))
25
+ byPath[current] = {};
26
+ }
27
+ else if (line.startsWith('DA:') && current !== null) {
28
+ recordDa(byPath[current], line.slice(3).trim());
29
+ }
30
+ else if (line.trim() === 'end_of_record') {
31
+ current = null;
32
+ }
33
+ }
34
+ const index = {};
35
+ for (const [path, hits] of Object.entries(byPath)) {
36
+ index[path] = selfDescribing(hits);
37
+ }
38
+ return index;
39
+ }
40
+ /** Fold one `DA:<line>,<hits>` body into a file's hit map, skipping junk. */
41
+ function recordDa(hits, body) {
42
+ const parts = body.split(',');
43
+ if (parts.length < 2)
44
+ return;
45
+ const lineno = pyInt(parts[0]);
46
+ const count = pyInt(parts[1]);
47
+ if (lineno === null || count === null)
48
+ return;
49
+ hits[lineno] = count;
50
+ }
51
+ /** Read a report file as UTF-8, returning `null` on any read/decode failure. */
52
+ function readReportText(reportPath) {
53
+ try {
54
+ const buf = readFileSync(reportPath);
55
+ // Fatal decode: a non-UTF-8 report must fall through, never raise out of
56
+ // the guardian gate (mirrors Python's UnicodeDecodeError → None).
57
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Tier 1: resolve coverage from an explicit report (`COVERAGE_VERIFIED`).
65
+ *
66
+ * Supports `lcov.info` (`DA:<line>,<hits>`), the canary coverage-json shape,
67
+ * and Cobertura `coverage.xml` (line-level). Unrecognized/empty/unreadable →
68
+ * `null` (caller falls through to a lower fidelity tier — absence never
69
+ * blocks).
70
+ */
71
+ export function resolveFromReport(units, reportPath) {
72
+ const { index } = readReportIndex(reportPath);
73
+ if (index === null)
74
+ return null;
75
+ return matchUnitsToIndex(units, index);
76
+ }
77
+ /** Read + parse a coverage report, reporting each step's outcome separately. */
78
+ export function readReportIndex(reportPath) {
79
+ const unusable = (found) => ({ found, index: null });
80
+ if (!existsSync(reportPath))
81
+ return unusable(false);
82
+ const text = readReportText(reportPath);
83
+ // Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
84
+ if (text === null)
85
+ return unusable(true);
86
+ const index = parseByFormat(basename(reportPath).toLowerCase(), text);
87
+ if (index === null || Object.keys(index).length === 0)
88
+ return unusable(true);
89
+ return { found: true, index };
90
+ }
91
+ /** Pick the reader by report filename; `null` for a format we don't know. */
92
+ function parseByFormat(name, text) {
93
+ if (name.endsWith('.json')) {
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(text);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ return parseCoverageJson(parsed);
102
+ }
103
+ if (name.endsWith('.info') || name.includes('lcov'))
104
+ return parseLcov(text);
105
+ if (name.endsWith('.xml'))
106
+ return parseCobertura(text);
107
+ // Unrecognized format → fall through to a lower fidelity tier.
108
+ return null;
109
+ }
110
+ /** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
111
+ export function matchUnitsToIndex(units, index) {
112
+ const results = [];
113
+ for (const unit of units) {
114
+ const file = matchFile(unit.path, index);
115
+ if (file === null) {
116
+ // Unit path is nowhere in the report index → "not instrumented", which is
117
+ // NOT the same as "instrumented and unhit". Emit no COVERAGE_VERIFIED
118
+ // result so the orchestrator falls through to a lower-fidelity tier for
119
+ // this unit (FIX 2).
120
+ continue;
121
+ }
122
+ const { hits, coverable: measured } = file;
123
+ const added = expandRanges(unit.added_ranges);
124
+ // The per-line form of the check above (#655/#657): where the report says
125
+ // which lines it instrumented, a changed line outside that set could not
126
+ // have been executed and is scored by neither side. Where it does not say,
127
+ // every changed line counts and absence means uncovered.
128
+ const coverable = measured === null ? added : added.filter((ln) => measured.has(ln));
129
+ if (coverable.length === 0) {
130
+ // Every changed line is non-coverable, so this report has nothing to say
131
+ // about the unit. An abstention — never a clean pass, never a finding.
132
+ // Falls through to the graph/heuristic tier exactly as an absent path does.
133
+ continue;
134
+ }
135
+ const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
136
+ const covered = uncovered.length === 0;
137
+ // State the denominator: "all covered" over 20 changed lines and over the 3
138
+ // of them that were coverable are very different claims (#508).
139
+ const evidence = covered
140
+ ? `lines ${rangesStr(unit.added_ranges)}: all ${coverable.length} coverable line(s) covered`
141
+ : `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} of ${coverable.length} coverable line(s) uncovered`;
142
+ results.push(makeResult({
143
+ unit,
144
+ covered,
145
+ fidelity: Fidelity.CoverageVerified,
146
+ evidence,
147
+ uncovered_lines: uncovered,
148
+ coverable_lines: coverable.length,
149
+ }));
150
+ }
151
+ return results;
152
+ }
153
+ //# sourceMappingURL=report-tier.js.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Proof that a module has no runtime content at all (#562) — the one
3
+ * suppression that is about the FILE rather than the fidelity tier.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { splitLines } from './types.js';
8
+ /**
9
+ * Filenames/paths that plausibly hold nothing but type declarations (#562).
10
+ *
11
+ * A NAME GATE ONLY -- it decides which files are worth reading, never which
12
+ * are suppressed. {@link isTypeOnlyModule} always confirms against content,
13
+ * because a `types.ts` that also exports an enum or a const map is ordinary
14
+ * TypeScript and its findings are real.
15
+ */
16
+ function isTypeModuleCandidate(path) {
17
+ const base = path.slice(path.lastIndexOf('/') + 1);
18
+ if (base.endsWith('.d.ts'))
19
+ return true;
20
+ if (!/\.(ts|tsx|mts|cts)$/.test(base))
21
+ return false;
22
+ if (base === 'types.ts' || base.endsWith('.types.ts'))
23
+ return true;
24
+ return path.split('/').slice(0, -1).includes('types');
25
+ }
26
+ /**
27
+ * True if `path` is a module with no runtime content at all (#562).
28
+ *
29
+ * The false-positive class this closes is the one the heuristic-tier fix
30
+ * (#413) structurally cannot reach. `filterHeuristicNoise` is gated on
31
+ * `fidelity === HEURISTIC` on purpose -- a coverage-verified verdict rests on
32
+ * a real lcov row, so suppressing by path at that tier would discard
33
+ * evidence. A type-only module is the case that breaks the symmetry: the lcov
34
+ * row is accurate (39 lines, genuinely never executed) and the finding is
35
+ * still unsatisfiable, because an interface has no runtime existence for a
36
+ * test to reach. The evidence needed is therefore about the FILE, not the
37
+ * tier: prove there is nothing executable in it.
38
+ *
39
+ * Conservative in one direction on purpose. Every uncertainty -- an
40
+ * unreadable file, an unrecognised construct -- resolves to `false`, keeping
41
+ * the finding. A missed suppression costs one noisy finding; a wrong
42
+ * suppression hides untested code, which is the thing the guardian exists to
43
+ * catch.
44
+ */
45
+ export function isTypeOnlyModule(path, repoRoot) {
46
+ if (!isTypeModuleCandidate(path))
47
+ return false;
48
+ let source;
49
+ try {
50
+ source = readFileSync(join(repoRoot, path), 'utf-8');
51
+ }
52
+ catch {
53
+ return false; // unreadable -> unproven -> keep the finding
54
+ }
55
+ return isTypeOnlySource(source);
56
+ }
57
+ /** Drop `//` and `/* *\/` comments so keywords inside prose never count. */
58
+ function stripComments(source) {
59
+ const out = [];
60
+ let inBlock = false;
61
+ for (const line of splitLines(source)) {
62
+ let text = line;
63
+ if (inBlock) {
64
+ const end = text.indexOf('*/');
65
+ if (end === -1) {
66
+ out.push('');
67
+ continue;
68
+ }
69
+ text = text.slice(end + 2);
70
+ inBlock = false;
71
+ }
72
+ for (;;) {
73
+ const start = text.indexOf('/*');
74
+ if (start === -1)
75
+ break;
76
+ const end = text.indexOf('*/', start + 2);
77
+ if (end === -1) {
78
+ text = text.slice(0, start);
79
+ inBlock = true;
80
+ break;
81
+ }
82
+ text = text.slice(0, start) + text.slice(end + 2);
83
+ }
84
+ const line2 = text.indexOf('//');
85
+ out.push(line2 === -1 ? text : text.slice(0, line2));
86
+ }
87
+ return out.join('\n');
88
+ }
89
+ /**
90
+ * Top-level constructs TypeScript erases entirely at compile time (#562).
91
+ *
92
+ * An ALLOWLIST, not a denylist, and that is the load-bearing choice. A
93
+ * denylist of runtime keywords misses everything it did not enumerate -- a
94
+ * bare `register('widget')` declares nothing and still runs -- and every gap
95
+ * in it suppresses a real finding. An allowlist fails the other way: an
96
+ * unrecognised construct reads as runtime and the finding survives.
97
+ *
98
+ * Plain `import { X } from '...'` is allowed because TypeScript elides an
99
+ * import whose bindings are only used in type positions; if a binding were
100
+ * used as a value, the using statement itself would appear at top level and
101
+ * be rejected. A side-effect `import './x'` carries no binding, is never
102
+ * elided, and is therefore not matched.
103
+ */
104
+ function isErasableTopLevelLine(line) {
105
+ if (line === '')
106
+ return true;
107
+ if (/^[})\];,]+$/.test(line))
108
+ return true;
109
+ if (/^import\s+type\b/.test(line))
110
+ return true;
111
+ if (/^import\b.*\sfrom\s/.test(line))
112
+ return true;
113
+ if (/^export\s+type\b/.test(line))
114
+ return true;
115
+ const bare = line.replace(/^(?:export\s+default\s+|export\s+|declare\s+)+/, '');
116
+ return /^(?:interface|type)\s/.test(bare);
117
+ }
118
+ /**
119
+ * True if every TOP-LEVEL statement in `source` is compile-time-only.
120
+ *
121
+ * Brace depth is tracked so an interface body is never mistaken for
122
+ * statements: only depth-0 lines are judged. An unbalanced file (depth does
123
+ * not return to zero) is treated as unproven rather than type-only -- brace
124
+ * counting is lexical, so a `{` inside a string literal could otherwise hide
125
+ * the rest of the file from inspection.
126
+ */
127
+ function isTypeOnlySource(source) {
128
+ let depth = 0;
129
+ for (const raw of splitLines(stripComments(source))) {
130
+ const line = raw.trim();
131
+ if (depth === 0 && !isErasableTopLevelLine(line))
132
+ return false;
133
+ depth += bracketDelta(line);
134
+ if (depth < 0)
135
+ return false;
136
+ }
137
+ return depth === 0;
138
+ }
139
+ /** Net nesting change across one line: openers minus closers. */
140
+ function bracketDelta(line) {
141
+ let delta = 0;
142
+ for (const ch of line) {
143
+ if (ch === '{' || ch === '(' || ch === '[')
144
+ delta += 1;
145
+ else if (ch === '}' || ch === ')' || ch === ']')
146
+ delta -= 1;
147
+ }
148
+ return delta;
149
+ }
150
+ //# sourceMappingURL=type-only.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The coverage resolver's shared shapes, plus the small primitives every tier
3
+ * needs to speak about them (line arithmetic, Python-parity parsing, path
4
+ * matching). Leaf module: it imports nothing from the rest of the resolver.
5
+ */
6
+ import { basename, extname } from 'node:path';
7
+ /** Confidence tier of a coverage signal (lower rank == higher fidelity). */
8
+ export var Fidelity;
9
+ (function (Fidelity) {
10
+ Fidelity["CoverageVerified"] = "coverage-verified";
11
+ Fidelity["GraphVerified"] = "graph-verified";
12
+ Fidelity["Heuristic"] = "heuristic";
13
+ })(Fidelity || (Fidelity = {}));
14
+ const FIDELITY_RANK = {
15
+ [Fidelity.CoverageVerified]: 0,
16
+ [Fidelity.GraphVerified]: 1,
17
+ [Fidelity.Heuristic]: 2,
18
+ };
19
+ /** 0=coverage, 1=graph, 2=heuristic. Lower means higher fidelity. */
20
+ export function fidelityRank(fidelity) {
21
+ return FIDELITY_RANK[fidelity];
22
+ }
23
+ /**
24
+ * Build a {@link CoverageResult}, defaulting `uncovered_lines` to `[]`. Stands
25
+ * in for the Python dataclass's `field(default_factory=list)` — the graph and
26
+ * heuristic tiers never populate uncovered lines and rely on that default.
27
+ */
28
+ export function makeResult(fields) {
29
+ return { ...fields, uncovered_lines: fields.uncovered_lines ?? [] };
30
+ }
31
+ /** Every line the report recorded is a line it could measure (lcov/Cobertura). */
32
+ export function selfDescribing(hits) {
33
+ return { hits, coverable: recordedLines(hits) };
34
+ }
35
+ /** The line numbers a hit map has records for. */
36
+ export function recordedLines(hits) {
37
+ return new Set(Object.keys(hits).map(Number));
38
+ }
39
+ /** Split like Python's `str.splitlines()` for the common line endings. */
40
+ export function splitLines(text) {
41
+ return text.split(/\r\n|\r|\n/);
42
+ }
43
+ /**
44
+ * Parse an integer the way Python's `int(str)` does for our inputs: optional
45
+ * surrounding whitespace and sign, digits only. Returns `null` on failure
46
+ * (Python would raise `ValueError`, which the callers catch-and-skip).
47
+ */
48
+ export function pyInt(value) {
49
+ const trimmed = value.trim();
50
+ if (!/^[+-]?\d+$/.test(trimmed))
51
+ return null;
52
+ return Number.parseInt(trimmed, 10);
53
+ }
54
+ /** bool is excluded (a JSON true/false is not a valid line/hit count). */
55
+ export function isInt(value) {
56
+ // typeof boolean !== 'number', so booleans are already excluded here — the
57
+ // JS analog of Python's explicit `not isinstance(value, bool)` guard.
58
+ return typeof value === 'number' && Number.isInteger(value);
59
+ }
60
+ export function isRecord(value) {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
62
+ }
63
+ /** Flatten inclusive `[start, end]` ranges into a sorted, de-duped line list. */
64
+ export function expandRanges(ranges) {
65
+ const lines = new Set();
66
+ for (const [start, end] of ranges) {
67
+ for (let ln = start; ln <= end; ln++)
68
+ lines.add(ln);
69
+ }
70
+ return [...lines].sort((a, b) => a - b);
71
+ }
72
+ /** Python's `Path(p).stem`: basename minus its final extension. */
73
+ export function stem(path) {
74
+ const base = basename(path);
75
+ const ext = extname(base);
76
+ return ext ? base.slice(0, -ext.length) : base;
77
+ }
78
+ /**
79
+ * True iff `candidate` and `target` name the same file path suffix.
80
+ *
81
+ * Exact match, or one is a suffix of the other on a **path-separator boundary**
82
+ * (`a/b/foo.py` vs `foo.py`). Rejects loose substring collisions such as
83
+ * `foobar.py` vs `bar.py` and `usermodels.py` vs `models.py` (FIX 6).
84
+ */
85
+ export function pathBoundaryMatch(candidate, target) {
86
+ return (candidate === target ||
87
+ candidate.endsWith('/' + target) ||
88
+ target.endsWith('/' + candidate));
89
+ }
90
+ /**
91
+ * Look up one file's coverage for `path` in a report index.
92
+ *
93
+ * Prefers an EXACT path match. Otherwise falls back to a **boundary** suffix
94
+ * match (report paths may be absolute, `./`-prefixed, or repo-relative). On
95
+ * multiple boundary matches (duplicate basenames) the lookup is ambiguous and
96
+ * returns `null` — the unit is then skipped and falls through rather than
97
+ * binding to an arbitrary first match (FIX 6).
98
+ */
99
+ export function matchFile(path, index) {
100
+ if (path in index)
101
+ return index[path];
102
+ const matches = [];
103
+ for (const [reportPath, file] of Object.entries(index)) {
104
+ if (pathBoundaryMatch(reportPath, path))
105
+ matches.push(file);
106
+ }
107
+ return matches.length === 1 ? matches[0] : null;
108
+ }
109
+ /** Render ranges compactly, e.g. `[[12, 28], [30, 30]]` → `"12-28, 30"`. */
110
+ export function rangesStr(ranges) {
111
+ return ranges
112
+ .map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`))
113
+ .join(', ');
114
+ }
115
+ //# sourceMappingURL=types.js.map
@@ -470,7 +470,7 @@ export function filterHeuristicNoise(results, excludeGlobs) {
470
470
  * concern). `severity` reuses {@link Severity}; `fidelity` carries the
471
471
  * confidence tier from the underlying coverage signal.
472
472
  */
473
- export class Finding {
473
+ export class GuardianFinding {
474
474
  path;
475
475
  unit;
476
476
  kind;
@@ -565,11 +565,27 @@ const HIGH_UNCOVERED_SHARE = 0.5;
565
565
  function addedLineCount(ranges) {
566
566
  return ranges.reduce((total, [start, end]) => total + (end - start + 1), 0);
567
567
  }
568
+ /**
569
+ * The fraction of what the report could speak to that came back unhit (#655).
570
+ *
571
+ * The denominator is the unit's **coverable** lines — added lines overstate it,
572
+ * because on a new file that is largely imports, types and blanks most changed
573
+ * lines were never instrumented at all, and dividing by them drives every share
574
+ * toward zero. Where the count is unknown (the graph and heuristic tiers, which
575
+ * are not graded by share anyway) it falls back to the added-line count, so
576
+ * grades predating that field are unchanged. An unknown denominator yields a
577
+ * full share rather than a low one — an absent measurement must never read as a
578
+ * good score (ADR 0010).
579
+ */
580
+ function uncoveredShare(result, uncovered) {
581
+ const denominator = result.coverable_lines ?? addedLineCount(result.unit.added_ranges);
582
+ return denominator > 0 ? uncovered / denominator : 1;
583
+ }
568
584
  /**
569
585
  * Severity for an uncovered **coverage-verified** result (#553).
570
586
  *
571
587
  * Only this tier can be graded, because only this tier knows *which* lines ran
572
- * (see the `uncovered_lines` comment on {@link Finding}). The grade combines
588
+ * (see the `uncovered_lines` comment on {@link GuardianFinding}). The grade combines
573
589
  * how much is unhit with how much of the change that represents:
574
590
  *
575
591
  * - `CRITICAL` — a large block (>= 20 lines) that is essentially untouched
@@ -589,8 +605,7 @@ function coverageVerifiedSeverity(result) {
589
605
  const uncovered = result.uncovered_lines?.length ?? 0;
590
606
  if (uncovered === 0)
591
607
  return Severity.HIGH;
592
- const added = addedLineCount(result.unit.added_ranges);
593
- const share = added > 0 ? uncovered / added : 1;
608
+ const share = uncoveredShare(result, uncovered);
594
609
  if (uncovered >= CRITICAL_UNCOVERED_LINES &&
595
610
  share >= CRITICAL_UNCOVERED_SHARE)
596
611
  return Severity.CRITICAL;
@@ -627,7 +642,7 @@ export function buildFindings(results) {
627
642
  severity = Severity.HIGH;
628
643
  const unit = result.unit;
629
644
  const uncovered = [...(result.uncovered_lines ?? [])];
630
- findings.push(new Finding({
645
+ findings.push(new GuardianFinding({
631
646
  path: unit.path,
632
647
  unit: unit.symbol || unit.path,
633
648
  fidelity: result.fidelity,
@@ -709,7 +724,7 @@ export function buildWeakTestFindings(testUnits, diffText) {
709
724
  const code = added.join('\n');
710
725
  const framework = frameworkForTestPath(unit.path);
711
726
  if (isAssertionFreeTest(code, framework)) {
712
- findings.push(new Finding({
727
+ findings.push(new GuardianFinding({
713
728
  path: unit.path,
714
729
  unit: unit.path,
715
730
  kind: 'weak-test',
@@ -889,7 +904,7 @@ const SEVERITY_ICON = {
889
904
  function ensureAscii(json) {
890
905
  return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
891
906
  }
892
- /** Serialize a {@link Finding} to a stable JSON-friendly object. */
907
+ /** Serialize a {@link GuardianFinding} to a stable JSON-friendly object. */
893
908
  function findingDict(finding) {
894
909
  return {
895
910
  path: finding.path,
@@ -20,6 +20,21 @@ export declare function parseRegistryVersion(rawBody: string): string | null;
20
20
  export declare function isOlder(a: string, b: string): boolean;
21
21
  /** CLI version vs latest release. Offline degrades to info, never a failure. */
22
22
  export declare function checkVersion(deps?: EngineCheckDeps): Promise<CheckResult>;
23
+ /**
24
+ * Installed Claude Code plugin vs the marketplace clone (#522).
25
+ *
26
+ * Canary ships through two independent version streams and only the CLI had
27
+ * staleness detection, so `canary upgrade` would report everything current
28
+ * while leaving the plugin — the way Canary is actually used in-editor —
29
+ * arbitrarily far behind. A real consuming project ran 4.0.0 for six weeks
30
+ * against a 6.x CLI, during which six of Canary's agents did not exist in the
31
+ * version being loaded, with nothing anywhere surfacing it.
32
+ *
33
+ * Purely local file reads: no network, so this cannot fail offline. Both files
34
+ * are untrusted input — a hand-edited manifest degrades to info or skip rather
35
+ * than throwing out of `doctor`.
36
+ */
37
+ export declare function checkPluginVersion(deps?: EngineCheckDeps): CheckResult;
23
38
  /** git present on PATH. */
24
39
  export declare function checkGit(deps?: EngineCheckDeps): CheckResult;
25
40
  /** Registered overlays present, fresh, and free of local modifications. */
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.parseRegistryVersion = parseRegistryVersion;
37
37
  exports.isOlder = isOlder;
38
38
  exports.checkVersion = checkVersion;
39
+ exports.checkPluginVersion = checkPluginVersion;
39
40
  exports.checkGit = checkGit;
40
41
  exports.checkOverlays = checkOverlays;
41
42
  exports.checkProjectConfig = checkProjectConfig;
@@ -160,6 +161,92 @@ async function checkVersion(deps = {}) {
160
161
  label: `CLI ${current} (latest)`,
161
162
  };
162
163
  }
164
+ /** Marketplace that carries the Claude Code plugin, and the plugin's key in it. */
165
+ const MARKETPLACE = 'bop-clocktower';
166
+ const PLUGIN_KEY = `canary@${MARKETPLACE}`;
167
+ /** Parse JSON from disk; null on missing file, unreadable path, or bad syntax. */
168
+ function readJson(file) {
169
+ try {
170
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Newest version among the installs recorded for {@link PLUGIN_KEY}.
178
+ *
179
+ * Claude Code stores an array per plugin key because user- and project-scoped
180
+ * installs coexist. Taking the first would report a stale plugin whenever the
181
+ * older scope happened to be listed first, so take the newest and let the
182
+ * comparison speak for the one actually in charge.
183
+ */
184
+ function installedPluginVersion(manifest) {
185
+ const plugins = manifest
186
+ ?.plugins;
187
+ const entries = plugins?.[PLUGIN_KEY];
188
+ if (!Array.isArray(entries))
189
+ return null;
190
+ const versions = entries
191
+ .map((e) => e?.version)
192
+ .filter((v) => typeof v === 'string');
193
+ if (versions.length === 0)
194
+ return null;
195
+ return versions.reduce((best, v) => (isOlder(best, v) ? v : best));
196
+ }
197
+ /**
198
+ * Installed Claude Code plugin vs the marketplace clone (#522).
199
+ *
200
+ * Canary ships through two independent version streams and only the CLI had
201
+ * staleness detection, so `canary upgrade` would report everything current
202
+ * while leaving the plugin — the way Canary is actually used in-editor —
203
+ * arbitrarily far behind. A real consuming project ran 4.0.0 for six weeks
204
+ * against a 6.x CLI, during which six of Canary's agents did not exist in the
205
+ * version being loaded, with nothing anywhere surfacing it.
206
+ *
207
+ * Purely local file reads: no network, so this cannot fail offline. Both files
208
+ * are untrusted input — a hand-edited manifest degrades to info or skip rather
209
+ * than throwing out of `doctor`.
210
+ */
211
+ function checkPluginVersion(deps = {}) {
212
+ const home = deps.homeDir ?? os.homedir();
213
+ const pluginsDir = path.join(home, '.claude', 'plugins');
214
+ const installed = installedPluginVersion(readJson(path.join(pluginsDir, 'installed_plugins.json')));
215
+ if (!installed) {
216
+ // CLI-only users have no plugin to be stale; not a finding.
217
+ return {
218
+ id: 'engine:plugin-version',
219
+ status: 'skip',
220
+ label: 'Claude Code plugin: not installed',
221
+ };
222
+ }
223
+ const marketplace = readJson(path.join(pluginsDir, 'marketplaces', MARKETPLACE, '.claude-plugin', 'plugin.json'))?.version;
224
+ if (typeof marketplace !== 'string') {
225
+ return {
226
+ id: 'engine:plugin-version',
227
+ status: 'info',
228
+ label: `Claude Code plugin ${installed} (could not read the ${MARKETPLACE} marketplace to compare)`,
229
+ remedy: `Refresh it with: /plugin marketplace update ${MARKETPLACE}`,
230
+ };
231
+ }
232
+ if (isOlder(installed, marketplace)) {
233
+ return {
234
+ id: 'engine:plugin-version',
235
+ status: 'fail',
236
+ label: `Claude Code plugin ${installed} is behind marketplace ${marketplace}`,
237
+ // Both commands, in order, always. `/plugin marketplace update` refreshes
238
+ // the clone but leaves the cached plugin in place, and `/plugin install`
239
+ // without it reinstalls whatever the stale clone holds — each alone
240
+ // reports success and changes nothing the user cares about.
241
+ remedy: `Update both — either alone is a no-op:\n /plugin marketplace update ${MARKETPLACE}\n /plugin install ${PLUGIN_KEY}`,
242
+ };
243
+ }
244
+ return {
245
+ id: 'engine:plugin-version',
246
+ status: 'pass',
247
+ label: `Claude Code plugin ${installed} (marketplace ${marketplace})`,
248
+ };
249
+ }
163
250
  /** git present on PATH. */
164
251
  function checkGit(deps = {}) {
165
252
  const git = deps.git ?? realGit;
@@ -245,7 +332,10 @@ function checkOverlays(deps = {}) {
245
332
  id: `overlay:${o.name}:clean`,
246
333
  status: 'fail',
247
334
  label: `overlay "${o.name}": ${clean === 'dirty' ? 'local modifications' : 'git status unreadable'}`,
248
- remedy: `Commit/stash changes in ${o.path}, or canary overlay remove ${o.name} and re-add.`,
335
+ // `--force` is part of the remedy on purpose: since #675 a plain
336
+ // `overlay remove` refuses on a dirty clone, so the old advice was a
337
+ // dead end for exactly the user this check fires on.
338
+ remedy: `Commit/stash changes in ${o.path}, or discard them with canary overlay remove ${o.name} --force and re-add.`,
249
339
  });
250
340
  }
251
341
  }
@@ -527,6 +617,7 @@ function checkSkillRequirements(deps = {}) {
527
617
  async function runEngineChecks(deps = {}) {
528
618
  return [
529
619
  await checkVersion(deps),
620
+ checkPluginVersion(deps),
530
621
  checkGit(deps),
531
622
  ...checkOverlays(deps),
532
623
  checkOverlayConflicts(deps),
@@ -72,8 +72,19 @@ export declare function lint(nameOrPath: string | undefined, deps?: CommandDeps,
72
72
  * updates all; refuses on local modifications or a non-fast-forward.
73
73
  */
74
74
  export declare function update(name: string | null, deps?: CommandDeps): number;
75
+ /** Options for {@link remove}. */
76
+ export interface RemoveOptions {
77
+ /** `--force`: delete even when the clone has local modifications (#675). */
78
+ force?: boolean;
79
+ }
75
80
  /**
76
81
  * `canary overlay remove <name>` — deregister an overlay and delete its clone.
77
82
  * Unknown name is an error; the registry is left unchanged in that case.
83
+ *
84
+ * Removal is destructive and irreversible, so it takes the same posture as
85
+ * `overlay update` (#675): a clone with local modifications — or one whose git
86
+ * status cannot be read, which is no safer to delete blind — is refused, and
87
+ * `--force` is the explicit opt-in. A clone that is already gone is not a
88
+ * refusal: there is nothing left to lose, so the stale registry row is dropped.
78
89
  */
79
- export declare function remove(name: string, deps?: CommandDeps): number;
90
+ export declare function remove(name: string, deps?: CommandDeps, opts?: RemoveOptions): number;