canary-test-cli 6.8.1 → 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.
- package/dist/engine/analysis/cli.js +155 -45
- package/dist/engine/analysis/engine.js +9 -9
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +2 -2
- package/dist/engine/core/migrator.js +142 -31
- package/dist/engine/core/static-linter.js +2 -2
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +1 -1
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/coverage.js +15 -1420
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +5 -5
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +174 -0
- 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
|
|
473
|
+
export class GuardianFinding {
|
|
474
474
|
path;
|
|
475
475
|
unit;
|
|
476
476
|
kind;
|
|
@@ -585,7 +585,7 @@ function uncoveredShare(result, uncovered) {
|
|
|
585
585
|
* Severity for an uncovered **coverage-verified** result (#553).
|
|
586
586
|
*
|
|
587
587
|
* Only this tier can be graded, because only this tier knows *which* lines ran
|
|
588
|
-
* (see the `uncovered_lines` comment on {@link
|
|
588
|
+
* (see the `uncovered_lines` comment on {@link GuardianFinding}). The grade combines
|
|
589
589
|
* how much is unhit with how much of the change that represents:
|
|
590
590
|
*
|
|
591
591
|
* - `CRITICAL` — a large block (>= 20 lines) that is essentially untouched
|
|
@@ -642,7 +642,7 @@ export function buildFindings(results) {
|
|
|
642
642
|
severity = Severity.HIGH;
|
|
643
643
|
const unit = result.unit;
|
|
644
644
|
const uncovered = [...(result.uncovered_lines ?? [])];
|
|
645
|
-
findings.push(new
|
|
645
|
+
findings.push(new GuardianFinding({
|
|
646
646
|
path: unit.path,
|
|
647
647
|
unit: unit.symbol || unit.path,
|
|
648
648
|
fidelity: result.fidelity,
|
|
@@ -724,7 +724,7 @@ export function buildWeakTestFindings(testUnits, diffText) {
|
|
|
724
724
|
const code = added.join('\n');
|
|
725
725
|
const framework = frameworkForTestPath(unit.path);
|
|
726
726
|
if (isAssertionFreeTest(code, framework)) {
|
|
727
|
-
findings.push(new
|
|
727
|
+
findings.push(new GuardianFinding({
|
|
728
728
|
path: unit.path,
|
|
729
729
|
unit: unit.path,
|
|
730
730
|
kind: 'weak-test',
|
|
@@ -904,7 +904,7 @@ const SEVERITY_ICON = {
|
|
|
904
904
|
function ensureAscii(json) {
|
|
905
905
|
return json.replace(/[-]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
906
906
|
}
|
|
907
|
-
/** Serialize a {@link
|
|
907
|
+
/** Serialize a {@link GuardianFinding} to a stable JSON-friendly object. */
|
|
908
908
|
function findingDict(finding) {
|
|
909
909
|
return {
|
|
910
910
|
path: finding.path,
|
package/dist/engine-checks.d.ts
CHANGED
|
@@ -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. */
|
package/dist/engine-checks.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/overlay-commands.js
CHANGED
|
@@ -446,7 +446,10 @@ function updateOne(o, git, out, err) {
|
|
|
446
446
|
}
|
|
447
447
|
if (clean === 'dirty') {
|
|
448
448
|
err.write(`overlay "${o.name}": local modifications in ${o.path} — refusing to update. ` +
|
|
449
|
-
`Commit/stash them, or
|
|
449
|
+
`Commit/stash them, or discard them with ` +
|
|
450
|
+
`'canary overlay remove ${o.name} --force' and re-add. ` +
|
|
451
|
+
`(Plain 'overlay remove' refuses on a dirty clone too, so it will not ` +
|
|
452
|
+
`throw the edits away by accident.)\n`);
|
|
450
453
|
return 1;
|
|
451
454
|
}
|
|
452
455
|
if (o.ref) {
|
|
@@ -520,8 +523,15 @@ function update(name, deps = {}) {
|
|
|
520
523
|
/**
|
|
521
524
|
* `canary overlay remove <name>` — deregister an overlay and delete its clone.
|
|
522
525
|
* Unknown name is an error; the registry is left unchanged in that case.
|
|
526
|
+
*
|
|
527
|
+
* Removal is destructive and irreversible, so it takes the same posture as
|
|
528
|
+
* `overlay update` (#675): a clone with local modifications — or one whose git
|
|
529
|
+
* status cannot be read, which is no safer to delete blind — is refused, and
|
|
530
|
+
* `--force` is the explicit opt-in. A clone that is already gone is not a
|
|
531
|
+
* refusal: there is nothing left to lose, so the stale registry row is dropped.
|
|
523
532
|
*/
|
|
524
|
-
function remove(name, deps = {}) {
|
|
533
|
+
function remove(name, deps = {}, opts = {}) {
|
|
534
|
+
const git = deps.git ?? defaultGit;
|
|
525
535
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
526
536
|
const out = deps.out ?? process.stdout;
|
|
527
537
|
const err = deps.err ?? process.stderr;
|
|
@@ -538,6 +548,22 @@ function remove(name, deps = {}) {
|
|
|
538
548
|
err.write(`canary overlay remove: no overlay named "${name}".\n`);
|
|
539
549
|
return 1;
|
|
540
550
|
}
|
|
551
|
+
if (!opts.force && fs.existsSync(entry.path)) {
|
|
552
|
+
const clean = workingTreeStatus(entry.path, git);
|
|
553
|
+
if (clean === 'unreadable') {
|
|
554
|
+
err.write(`canary overlay remove: cannot read git status at ${entry.path} — ` +
|
|
555
|
+
`refusing to delete it. Inspect the directory, then re-run with ` +
|
|
556
|
+
`--force to remove it anyway.\n`);
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
if (clean === 'dirty') {
|
|
560
|
+
err.write(`canary overlay remove: local modifications in ${entry.path} — ` +
|
|
561
|
+
`refusing to delete them. Commit or discard the changes ` +
|
|
562
|
+
`(git -C ${entry.path} status), then re-run; or re-run with ` +
|
|
563
|
+
`--force to delete the clone and the changes.\n`);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
541
567
|
fs.rmSync(entry.path, { recursive: true, force: true });
|
|
542
568
|
const { registry: next } = registry.remove(reg, name);
|
|
543
569
|
registry.write(next, homeDir);
|