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,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2 — coverage derived from the harness knowledge graph
|
|
3
|
+
* (`GRAPH_VERIFIED`). Reads the NDJSON `.harness/graph/graph.json` directly;
|
|
4
|
+
* no agent/LLM module and no `analyze_diff`/`get_impact` MCP tool is involved
|
|
5
|
+
* (SC-11 boundary).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { isTestPath } from './paths.js';
|
|
9
|
+
import { isRecord, makeResult, pathBoundaryMatch, splitLines, Fidelity, } from './types.js';
|
|
10
|
+
// Edge types that indicate a test exercises a source unit. The live graph
|
|
11
|
+
// carries no explicit `tests`/`covers` edge, so coverage is *derived* from
|
|
12
|
+
// calls/imports reach.
|
|
13
|
+
const REACH_EDGE_TYPES = new Set(['calls', 'imports']);
|
|
14
|
+
/**
|
|
15
|
+
* Tier 2: derive coverage from the harness knowledge graph (`GRAPH_VERIFIED`).
|
|
16
|
+
*
|
|
17
|
+
* The graph has no explicit `tests`/`covers` edge, so coverage is **derived**:
|
|
18
|
+
* a changed file is graph-covered iff some **test-path node** reaches the
|
|
19
|
+
* file's node (or a symbol node it `contains`) via a `calls`/`imports` edge.
|
|
20
|
+
* Conservative by design (edge present → covered).
|
|
21
|
+
*
|
|
22
|
+
* `maxDepth` bounds the reverse-BFS hop distance from the changed unit's
|
|
23
|
+
* node(s) to the covering test node (#320). The changed unit's nodes are depth
|
|
24
|
+
* 0; their direct predecessors are depth 1; one hop of indirection is depth 2;
|
|
25
|
+
* and so on. `maxDepth=1` requires a DIRECT test→source edge; `maxDepth=null`
|
|
26
|
+
* is unbounded (today's behavior, byte-for-byte unchanged).
|
|
27
|
+
*
|
|
28
|
+
* Reads the NDJSON `graph.json` directly. Missing/empty graph → `null` (never
|
|
29
|
+
* blocks).
|
|
30
|
+
*/
|
|
31
|
+
export function resolveFromGraph(units, graphPath = '.harness/graph/graph.json', maxDepth = null) {
|
|
32
|
+
const graph = readGraph(graphPath);
|
|
33
|
+
if (graph === null)
|
|
34
|
+
return null;
|
|
35
|
+
// Index file/symbol node ids by path (exact + suffix match support).
|
|
36
|
+
const pathToIds = new Map();
|
|
37
|
+
for (const [nodeId, nodePath] of graph.idToPath) {
|
|
38
|
+
if (nodePath)
|
|
39
|
+
push(pathToIds, nodePath, nodeId);
|
|
40
|
+
}
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const unit of units) {
|
|
43
|
+
// Target set: the file node(s) for this unit + all symbols they contain.
|
|
44
|
+
const seedIds = idsForPath(pathToIds, unit.path);
|
|
45
|
+
if (seedIds.length === 0) {
|
|
46
|
+
// Unit has no node in the graph → no graph signal. Emit nothing so the
|
|
47
|
+
// orchestrator falls through to the heuristic tier (FIX 2).
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const targets = containedClosure(graph.containsFwd, seedIds);
|
|
51
|
+
const coveringTest = findCoveringTest(graph, targets, maxDepth);
|
|
52
|
+
const covered = coveringTest !== null;
|
|
53
|
+
const evidence = covered
|
|
54
|
+
? `reached by test ${coveringTest}`
|
|
55
|
+
: `no test node reaches ${unit.path} via calls/imports`;
|
|
56
|
+
results.push(makeResult({
|
|
57
|
+
unit,
|
|
58
|
+
covered,
|
|
59
|
+
fidelity: Fidelity.GraphVerified,
|
|
60
|
+
evidence,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
/** Read the NDJSON graph into its adjacency views; `null` if unusable. */
|
|
66
|
+
function readGraph(graphPath) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
if (!existsSync(graphPath))
|
|
70
|
+
return null;
|
|
71
|
+
// ACCEPTED DIVERGENCE: Python's `read_text(encoding="utf-8")` here can raise
|
|
72
|
+
// an UNCAUGHT `UnicodeDecodeError` on a non-UTF-8 graph (the Python only
|
|
73
|
+
// catches `OSError`) — a latent crash that violates the guardian's "absence
|
|
74
|
+
// never blocks" contract. Node's `readFileSync(path, 'utf-8')` substitutes
|
|
75
|
+
// U+FFFD instead of throwing; a replacement char inside a line just fails
|
|
76
|
+
// that line's `JSON.parse` and is skipped. We intentionally KEEP the safe
|
|
77
|
+
// degrade rather than reproduce the crash.
|
|
78
|
+
text = readFileSync(graphPath, 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (text.trim() === '')
|
|
84
|
+
return null;
|
|
85
|
+
const graph = {
|
|
86
|
+
idToPath: new Map(),
|
|
87
|
+
containsFwd: new Map(),
|
|
88
|
+
reachRev: new Map(),
|
|
89
|
+
};
|
|
90
|
+
for (const raw of splitLines(text)) {
|
|
91
|
+
const record = parseRecord(raw);
|
|
92
|
+
if (record === null)
|
|
93
|
+
continue;
|
|
94
|
+
if (record['kind'] === 'node')
|
|
95
|
+
addNode(graph, record);
|
|
96
|
+
else if (record['kind'] === 'edge')
|
|
97
|
+
addEdge(graph, record);
|
|
98
|
+
}
|
|
99
|
+
return graph.idToPath.size === 0 ? null : graph;
|
|
100
|
+
}
|
|
101
|
+
/** One NDJSON line as an object record, or `null` if it is neither. */
|
|
102
|
+
function parseRecord(raw) {
|
|
103
|
+
const line = raw.trim();
|
|
104
|
+
if (!line)
|
|
105
|
+
return null;
|
|
106
|
+
let record;
|
|
107
|
+
try {
|
|
108
|
+
record = JSON.parse(line);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Valid JSON but not an object (e.g. `null`, `5`, `[1,2]`, `"x"`) → not a
|
|
114
|
+
// node/edge record; skip it rather than crash on `.get` (FIX 3).
|
|
115
|
+
return isRecord(record) ? record : null;
|
|
116
|
+
}
|
|
117
|
+
function addNode(graph, record) {
|
|
118
|
+
const nodeId = record['id'];
|
|
119
|
+
if (nodeId === undefined || nodeId === null)
|
|
120
|
+
return;
|
|
121
|
+
const p = record['path'];
|
|
122
|
+
graph.idToPath.set(String(nodeId), typeof p === 'string' ? p : '');
|
|
123
|
+
}
|
|
124
|
+
function addEdge(graph, record) {
|
|
125
|
+
const etype = record['type'];
|
|
126
|
+
const src = record['from'];
|
|
127
|
+
const dst = record['to'];
|
|
128
|
+
if (src === undefined || src === null || dst === undefined || dst === null) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const from = String(src);
|
|
132
|
+
const to = String(dst);
|
|
133
|
+
if (etype === 'contains')
|
|
134
|
+
push(graph.containsFwd, from, to);
|
|
135
|
+
else if (typeof etype === 'string' && REACH_EDGE_TYPES.has(etype)) {
|
|
136
|
+
push(graph.reachRev, to, from);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Node ids for `path`; ambiguous suffix matches deliberately resolve to none. */
|
|
140
|
+
function idsForPath(pathToIds, path) {
|
|
141
|
+
const exact = pathToIds.get(path);
|
|
142
|
+
if (exact !== undefined)
|
|
143
|
+
return exact;
|
|
144
|
+
// Boundary suffix match only; on a unique matched path use its ids. On
|
|
145
|
+
// multiple distinct matched paths (duplicate basenames) treat as ambiguous
|
|
146
|
+
// and return no ids — do NOT union unrelated nodes (FIX 6).
|
|
147
|
+
const matchedPaths = [];
|
|
148
|
+
for (const nodePath of pathToIds.keys()) {
|
|
149
|
+
if (pathBoundaryMatch(nodePath, path))
|
|
150
|
+
matchedPaths.push(nodePath);
|
|
151
|
+
}
|
|
152
|
+
return matchedPaths.length === 1 ? pathToIds.get(matchedPaths[0]) : [];
|
|
153
|
+
}
|
|
154
|
+
/** The seed nodes plus everything they transitively `contains`. */
|
|
155
|
+
function containedClosure(containsFwd, seedIds) {
|
|
156
|
+
const targets = new Set(seedIds);
|
|
157
|
+
const frontier = [...targets];
|
|
158
|
+
while (frontier.length > 0) {
|
|
159
|
+
const node = frontier.pop();
|
|
160
|
+
for (const child of containsFwd.get(node) ?? []) {
|
|
161
|
+
if (!targets.has(child)) {
|
|
162
|
+
targets.add(child);
|
|
163
|
+
frontier.push(child);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return targets;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Reverse-BFS over calls/imports; the first reached test-path node wins.
|
|
171
|
+
*
|
|
172
|
+
* FIX 1 (#320): a genuine FIFO BFS so first-discovery depth is the minimum;
|
|
173
|
+
* a LIFO stack could stamp an intermediate node at a non-minimal depth and
|
|
174
|
+
* prune it before a shorter path arrives, under-crediting coverage at
|
|
175
|
+
* maxDepth >= 3.
|
|
176
|
+
*/
|
|
177
|
+
function findCoveringTest(graph, targets, maxDepth) {
|
|
178
|
+
const walk = {
|
|
179
|
+
graph,
|
|
180
|
+
targets,
|
|
181
|
+
seen: new Set(targets),
|
|
182
|
+
queue: [...targets].map((t) => [t, 0]),
|
|
183
|
+
};
|
|
184
|
+
let head = 0;
|
|
185
|
+
while (head < walk.queue.length) {
|
|
186
|
+
const [node, depth] = walk.queue[head++];
|
|
187
|
+
if (maxDepth !== null && depth >= maxDepth) {
|
|
188
|
+
continue; // cannot expand deeper — predecessors would exceed bound
|
|
189
|
+
}
|
|
190
|
+
const found = enqueuePredecessors(walk, node, depth);
|
|
191
|
+
if (found !== null)
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Enqueue every unseen predecessor of `node`, returning the path of the first
|
|
198
|
+
* one that is a test (which ends the walk).
|
|
199
|
+
*/
|
|
200
|
+
function enqueuePredecessors(walk, node, depth) {
|
|
201
|
+
const predecessors = walk.graph.reachRev.get(node);
|
|
202
|
+
if (predecessors === undefined)
|
|
203
|
+
return null;
|
|
204
|
+
for (const source of predecessors) {
|
|
205
|
+
if (walk.seen.has(source))
|
|
206
|
+
continue;
|
|
207
|
+
walk.seen.add(source);
|
|
208
|
+
const sourcePath = walk.graph.idToPath.get(source) ?? '';
|
|
209
|
+
if (sourcePath && isTestPath(sourcePath) && !walk.targets.has(source)) {
|
|
210
|
+
return sourcePath; // test reached within maxDepth
|
|
211
|
+
}
|
|
212
|
+
walk.queue.push([source, depth + 1]);
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
function push(map, key, value) {
|
|
217
|
+
const existing = map.get(key);
|
|
218
|
+
if (existing === undefined)
|
|
219
|
+
map.set(key, [value]);
|
|
220
|
+
else
|
|
221
|
+
existing.push(value);
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=graph-tier.js.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 3 — the last-resort naming/AST heuristic (`HEURISTIC`, never `null`).
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { basename, join, posix } from 'node:path';
|
|
6
|
+
import { isTestPath } from './paths.js';
|
|
7
|
+
import { makeResult, stem, Fidelity, } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Derive candidate symbol names for a unit: the file stem plus top-level
|
|
10
|
+
* `def`/`class` names. Python uses `ast` for `.py`; here a column-0 line scan
|
|
11
|
+
* approximates top-level extraction (indented, nested defs are excluded, just
|
|
12
|
+
* as `ast.parse(...).body` would exclude them). A cheap regex covers other
|
|
13
|
+
* languages.
|
|
14
|
+
*
|
|
15
|
+
* KNOWN HEURISTIC-TIER LIMITATION: unlike a real AST, this lexical scan can pick
|
|
16
|
+
* up a phantom `def ghost()`/`class Phantom` sitting at column 0 inside a
|
|
17
|
+
* triple-quoted string or in a syntactically-broken file, yielding a false
|
|
18
|
+
* heuristic-covered verdict if a test happens to mention that name. Full `ast`
|
|
19
|
+
* parity is not portable to Node; this is accepted as a lowest-fidelity-tier
|
|
20
|
+
* (HEURISTIC) imprecision — the report and graph tiers, which outrank it, are
|
|
21
|
+
* exact. (A cheap mitigation would be to blank string literals before the scan,
|
|
22
|
+
* omitted here to avoid any risk of dropping a real top-level symbol.)
|
|
23
|
+
*/
|
|
24
|
+
function extractSymbols(unitPath, repoRoot) {
|
|
25
|
+
const symbols = new Set([stem(unitPath)]);
|
|
26
|
+
let source;
|
|
27
|
+
try {
|
|
28
|
+
source = readFileSync(join(repoRoot, unitPath), 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return symbols;
|
|
32
|
+
}
|
|
33
|
+
const decl = unitPath.endsWith('.py')
|
|
34
|
+
? /^(?:async\s+def|def|class)\s+([A-Za-z_]\w*)/gm
|
|
35
|
+
: /\b(?:function|class|def|const|let|var)\s+([A-Za-z_]\w*)/g;
|
|
36
|
+
for (let m = decl.exec(source); m !== null; m = decl.exec(source)) {
|
|
37
|
+
symbols.add(m[1]);
|
|
38
|
+
}
|
|
39
|
+
return symbols;
|
|
40
|
+
}
|
|
41
|
+
/** Recursively list every file under `root` (sorted for determinism). */
|
|
42
|
+
function walkFiles(root) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const walk = (dir) => {
|
|
45
|
+
let entries;
|
|
46
|
+
try {
|
|
47
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
53
|
+
const full = join(dir, entry.name);
|
|
54
|
+
if (entry.isDirectory())
|
|
55
|
+
walk(full);
|
|
56
|
+
else if (entry.isFile())
|
|
57
|
+
out.push(full);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(root);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Relative POSIX path of `full` under `root`. */
|
|
64
|
+
function relPosix(root, full) {
|
|
65
|
+
const rel = full.slice(root.length).replace(/^[/\\]/, '');
|
|
66
|
+
return rel.split(/[/\\]/).join(posix.sep);
|
|
67
|
+
}
|
|
68
|
+
/** Yield `[relPath, text]` for every test-looking file under `repoRoot`. */
|
|
69
|
+
function iterTestFiles(repoRoot) {
|
|
70
|
+
const all = walkFiles(repoRoot);
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const out = [];
|
|
73
|
+
const emit = (full) => {
|
|
74
|
+
if (seen.has(full))
|
|
75
|
+
return;
|
|
76
|
+
seen.add(full);
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
text = readFileSync(full, 'utf-8');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
out.push([relPosix(repoRoot, full), text]);
|
|
85
|
+
};
|
|
86
|
+
const matchers = [
|
|
87
|
+
(b) => /^test_.*\.py$/.test(b),
|
|
88
|
+
(b) => b.includes('.test.'),
|
|
89
|
+
(b) => b.includes('.spec.'),
|
|
90
|
+
];
|
|
91
|
+
for (const matches of matchers) {
|
|
92
|
+
for (const full of all) {
|
|
93
|
+
if (matches(basename(full)))
|
|
94
|
+
emit(full);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Also any file living under a tests/ directory (broader net).
|
|
98
|
+
for (const full of all) {
|
|
99
|
+
if (!full.endsWith('.py'))
|
|
100
|
+
continue;
|
|
101
|
+
if (isTestPath(relPosix(repoRoot, full)))
|
|
102
|
+
emit(full);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Tier 3: last-resort naming/AST heuristic (`HEURISTIC`, never `null`).
|
|
108
|
+
*
|
|
109
|
+
* A unit is heuristic-covered iff some test file under `repoRoot` references
|
|
110
|
+
* the unit's file stem or a top-level symbol name (word-boundary scan).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveHeuristic(units, repoRoot = '.') {
|
|
113
|
+
const testFiles = iterTestFiles(repoRoot);
|
|
114
|
+
const results = [];
|
|
115
|
+
for (const unit of units) {
|
|
116
|
+
const symbols = extractSymbols(unit.path, repoRoot);
|
|
117
|
+
// Avoid pathological single-letter stems matching everything.
|
|
118
|
+
const patterns = [];
|
|
119
|
+
for (const sym of symbols) {
|
|
120
|
+
if (sym.length >= 2)
|
|
121
|
+
patterns.push(new RegExp(`\\b${escapeRe(sym)}\\b`));
|
|
122
|
+
}
|
|
123
|
+
let covering = null;
|
|
124
|
+
for (const [rel, text] of testFiles) {
|
|
125
|
+
// A file never counts as covering itself.
|
|
126
|
+
if (rel === unit.path)
|
|
127
|
+
continue;
|
|
128
|
+
if (patterns.some((pat) => pat.test(text))) {
|
|
129
|
+
covering = rel;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const covered = covering !== null;
|
|
134
|
+
const evidence = covered
|
|
135
|
+
? `referenced by ${covering}`
|
|
136
|
+
: `no test file references ${stem(unit.path)}`;
|
|
137
|
+
results.push(makeResult({
|
|
138
|
+
unit,
|
|
139
|
+
covered,
|
|
140
|
+
fidelity: Fidelity.Heuristic,
|
|
141
|
+
evidence,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
/** Escape a string for literal use inside a RegExp (Python's `re.escape`). */
|
|
147
|
+
function escapeRe(s) {
|
|
148
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=heuristic-tier.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SC-3 fidelity ladder itself, plus the record of what the coverage input
|
|
3
|
+
* actually was on this run (#554) — the denominator that separates "checked and
|
|
4
|
+
* clean" from "never checked".
|
|
5
|
+
*/
|
|
6
|
+
import { resolveFromGraph } from './graph-tier.js';
|
|
7
|
+
import { resolveHeuristic } from './heuristic-tier.js';
|
|
8
|
+
import { matchUnitsToIndex, readReportIndex } from './report-tier.js';
|
|
9
|
+
/**
|
|
10
|
+
* SC-3 orchestrator: resolve each unit at the highest available fidelity.
|
|
11
|
+
*
|
|
12
|
+
* The ladder is applied **per unit**, not per batch. For each unit the first
|
|
13
|
+
* tier that has a signal for *that* unit wins:
|
|
14
|
+
*
|
|
15
|
+
* 1. `coveragePath` lists the unit's path → `COVERAGE_VERIFIED`
|
|
16
|
+
* 2. else a graph node for the unit exists → `GRAPH_VERIFIED`
|
|
17
|
+
* 3. else the naming heuristic → `HEURISTIC` (always returns)
|
|
18
|
+
*
|
|
19
|
+
* A unit absent from the report is NOT judged COVERAGE_VERIFIED-uncovered; it
|
|
20
|
+
* falls through to the graph then heuristic tier (FIX 2). Returns exactly one
|
|
21
|
+
* {@link CoverageResult} per input unit, in input order, fidelity-labeled.
|
|
22
|
+
*
|
|
23
|
+
* `graphMaxDepth` bounds the graph tier's reverse-BFS hop distance (#320) and
|
|
24
|
+
* is forwarded verbatim to `resolveFromGraph`.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveCoverage(units, options = {}) {
|
|
27
|
+
return resolveCoverageWithInput(units, options).results;
|
|
28
|
+
}
|
|
29
|
+
/** Classify a {@link CoverageInputState}. Zero matched is never `verified`. */
|
|
30
|
+
export function coverageStatus(state) {
|
|
31
|
+
if (state.unitsTotal > 0 && state.unitsMatched === state.unitsTotal) {
|
|
32
|
+
return 'verified';
|
|
33
|
+
}
|
|
34
|
+
return state.unitsMatched > 0 ? 'partial' : 'unavailable';
|
|
35
|
+
}
|
|
36
|
+
const COVERAGE_EM_DASH = '\u{2014}';
|
|
37
|
+
const FALLBACK_TIER = 'judged at graph/heuristic tier only';
|
|
38
|
+
/**
|
|
39
|
+
* The human-readable degradation notice for a coverage run, or `null` when the
|
|
40
|
+
* report covered every changed unit (nothing was degraded, so nothing is said).
|
|
41
|
+
*
|
|
42
|
+
* A run that judged nothing (`unitsTotal === 0`) also returns `null` — it makes
|
|
43
|
+
* no coverage claim in either direction, and the abstention path reports it.
|
|
44
|
+
*/
|
|
45
|
+
export function coverageDegradedNotice(state) {
|
|
46
|
+
const { requested, found, parsed, filesInReport } = state;
|
|
47
|
+
const { unitsMatched: matched, unitsTotal: total } = state;
|
|
48
|
+
if (total === 0)
|
|
49
|
+
return null;
|
|
50
|
+
const status = coverageStatus(state);
|
|
51
|
+
if (status === 'verified')
|
|
52
|
+
return null;
|
|
53
|
+
const dash = ` ${COVERAGE_EM_DASH} `;
|
|
54
|
+
if (status === 'partial') {
|
|
55
|
+
return (`coverage partial${dash}report at '${requested}' matched ` +
|
|
56
|
+
`${matched} of ${total} changed file(s); the other ${total - matched} ` +
|
|
57
|
+
FALLBACK_TIER);
|
|
58
|
+
}
|
|
59
|
+
const head = `coverage unavailable${dash}`;
|
|
60
|
+
if (requested === null) {
|
|
61
|
+
return `${head}no coverage report was supplied; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
62
|
+
}
|
|
63
|
+
if (!found) {
|
|
64
|
+
return `${head}report not found at '${requested}'; ${total} changed file(s) ${FALLBACK_TIER}`;
|
|
65
|
+
}
|
|
66
|
+
if (!parsed) {
|
|
67
|
+
return (`${head}report at '${requested}' yielded no usable records; ` +
|
|
68
|
+
`${total} changed file(s) ${FALLBACK_TIER}`);
|
|
69
|
+
}
|
|
70
|
+
return (`${head}report at '${requested}' covers ${filesInReport} file(s) but ` +
|
|
71
|
+
`matched 0 of ${total} changed file(s); ${FALLBACK_TIER}`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* {@link resolveCoverage}, additionally reporting which mode the run was in.
|
|
75
|
+
*
|
|
76
|
+
* Identical ladder, identical results — the only addition is the
|
|
77
|
+
* {@link CoverageInputState} record, so a later reader can tell a clean result
|
|
78
|
+
* from a blind one (#554).
|
|
79
|
+
*/
|
|
80
|
+
export function resolveCoverageWithInput(units, options = {}) {
|
|
81
|
+
const { coveragePath = null, graphPath = '.harness/graph/graph.json', repoRoot = '.', graphMaxDepth = null, } = options;
|
|
82
|
+
// Reference-keyed map mirrors the Python `id(unit)` bookkeeping, so distinct
|
|
83
|
+
// units that happen to share a path are still tracked independently.
|
|
84
|
+
const resolved = new Map();
|
|
85
|
+
let remaining = [...units];
|
|
86
|
+
const coverage = {
|
|
87
|
+
requested: coveragePath,
|
|
88
|
+
found: false,
|
|
89
|
+
parsed: false,
|
|
90
|
+
filesInReport: 0,
|
|
91
|
+
unitsMatched: 0,
|
|
92
|
+
unitsTotal: units.length,
|
|
93
|
+
};
|
|
94
|
+
if (coveragePath !== null) {
|
|
95
|
+
const read = readReportIndex(coveragePath);
|
|
96
|
+
coverage.found = read.found;
|
|
97
|
+
coverage.parsed = read.index !== null;
|
|
98
|
+
coverage.filesInReport =
|
|
99
|
+
read.index === null ? 0 : Object.keys(read.index).length;
|
|
100
|
+
const report = read.index === null ? null : matchUnitsToIndex(remaining, read.index);
|
|
101
|
+
// An empty array (no unit matched the report) is falsy-equivalent in the
|
|
102
|
+
// Python `if report:` guard — fall through rather than lock in nothing.
|
|
103
|
+
if (report !== null && report.length > 0) {
|
|
104
|
+
for (const r of report)
|
|
105
|
+
resolved.set(r.unit, r);
|
|
106
|
+
coverage.unitsMatched = report.length;
|
|
107
|
+
remaining = remaining.filter((u) => !resolved.has(u));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (remaining.length > 0) {
|
|
111
|
+
const graph = resolveFromGraph(remaining, graphPath, graphMaxDepth);
|
|
112
|
+
if (graph !== null && graph.length > 0) {
|
|
113
|
+
for (const r of graph)
|
|
114
|
+
resolved.set(r.unit, r);
|
|
115
|
+
remaining = remaining.filter((u) => !resolved.has(u));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (remaining.length > 0) {
|
|
119
|
+
for (const r of resolveHeuristic(remaining, repoRoot)) {
|
|
120
|
+
resolved.set(r.unit, r);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { results: units.map((unit) => resolved.get(unit)), coverage };
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=orchestrator.js.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path predicates shared by the graph and heuristic tiers, and by the finding
|
|
3
|
+
* filters downstream: what is a test, what is test *support*, and what is
|
|
4
|
+
* hand-authored program source at all.
|
|
5
|
+
*/
|
|
6
|
+
import { basename, extname } from 'node:path';
|
|
7
|
+
const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec\.[^/]+$/;
|
|
8
|
+
/**
|
|
9
|
+
* True if `path` looks like a test file (`tests/**`, `test_*.py`, `*.test.*`,
|
|
10
|
+
* `*.spec.*`).
|
|
11
|
+
*/
|
|
12
|
+
export function isTestPath(path) {
|
|
13
|
+
return TEST_PATH_RE.test(path);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Split a basename's stem into `-`/`_`/`.`-separated components (#565).
|
|
17
|
+
*
|
|
18
|
+
* `playwright-fixture.ts` → `['playwright', 'fixture']`;
|
|
19
|
+
* `user.fixtures.ts` → `['user', 'fixtures']`. The final extension is dropped
|
|
20
|
+
* first so it never appears as a component.
|
|
21
|
+
*/
|
|
22
|
+
function basenameComponents(path) {
|
|
23
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
24
|
+
const dot = base.lastIndexOf('.');
|
|
25
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
26
|
+
return stem.split(/[-_.]/).filter((part) => part.length > 0);
|
|
27
|
+
}
|
|
28
|
+
/** Basename components that mark a file as test *support* rather than source. */
|
|
29
|
+
const FIXTURE_COMPONENTS = new Set([
|
|
30
|
+
'fixture',
|
|
31
|
+
'fixtures',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* True if `path` is test *infrastructure* identified by filename idiom (#565).
|
|
35
|
+
*
|
|
36
|
+
* {@link isTestPath} recognises tests by directory (`tests/**`) or by the
|
|
37
|
+
* `*.test.*` / `*.spec.*` / `test_*.py` naming rules, and the skip layer
|
|
38
|
+
* recognises fixtures by the `fixtures/` *directory* convention. Neither
|
|
39
|
+
* catches a file that is test support by **name** while sitting in an ordinary
|
|
40
|
+
* source directory — the measured cases being a pytest `conftest_otel.py` and a
|
|
41
|
+
* Playwright `playwright-fixture.ts`, both under `scripts/otel_bootstrap/`.
|
|
42
|
+
*
|
|
43
|
+
* Such a file cannot host a test in the sense a coverage finding means: it *is*
|
|
44
|
+
* the harness the tests run inside. Asking it for a covering test inverts the
|
|
45
|
+
* relationship, so a reviewer's only correct response is 👎 — the precision
|
|
46
|
+
* cost #413 and #562 both describe.
|
|
47
|
+
*
|
|
48
|
+
* Two deliberate narrowings:
|
|
49
|
+
*
|
|
50
|
+
* - **Components, not substrings.** `conftestimonial.py` and
|
|
51
|
+
* `prefixtures.ts` are ordinary source and must survive. Matching on
|
|
52
|
+
* `-`/`_`/`.`-separated components is what pytest's own name-based
|
|
53
|
+
* resolution and the `*.fixtures.ts` idiom actually mean.
|
|
54
|
+
* - **`conftest` is Python-only.** It is pytest's resolution rule
|
|
55
|
+
* specifically; a `conftest.js` carries no framework meaning and is left as
|
|
56
|
+
* source.
|
|
57
|
+
*
|
|
58
|
+
* Known over-match, accepted: a production module genuinely named
|
|
59
|
+
* `fixture-generator.ts` is suppressed. That trade is deliberate — a missed
|
|
60
|
+
* finding on a file named after fixtures costs far less than a finding no
|
|
61
|
+
* reviewer can ever act on, which is what drags `precision = TP / (TP + FP)`
|
|
62
|
+
* below the promotion bar.
|
|
63
|
+
*
|
|
64
|
+
* Kept separate from {@link isTestPath} on purpose: that predicate also decides
|
|
65
|
+
* what *confers* graph coverage, so widening it would let a conftest mark every
|
|
66
|
+
* module it imports as tested — a false negative in place of a false positive.
|
|
67
|
+
*/
|
|
68
|
+
export function isTestSupportPath(path) {
|
|
69
|
+
const components = basenameComponents(path);
|
|
70
|
+
if (path.endsWith('.py') && components.includes('conftest'))
|
|
71
|
+
return true;
|
|
72
|
+
return components.some((part) => FIXTURE_COMPONENTS.has(part));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Extensions that denote hand-authored, executable program source (#413).
|
|
76
|
+
*
|
|
77
|
+
* The membership rule is deliberately simple and defensible: **a programming
|
|
78
|
+
* language belongs; data, config, markup, and style do not.** `.sh` is in (it is
|
|
79
|
+
* executable logic — bats/shunit2 exist); `.json`, `.yaml`, `.sql`, `.css`, and
|
|
80
|
+
* `.html` are out (nothing a naming heuristic could meaningfully judge).
|
|
81
|
+
*
|
|
82
|
+
* A repo that disagrees at the margins tunes the glob layer
|
|
83
|
+
* (`canary.guardian.pr.heuristicExclude`) rather than this list.
|
|
84
|
+
*/
|
|
85
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
86
|
+
// TS/JS + component dialects.
|
|
87
|
+
'.ts',
|
|
88
|
+
'.tsx',
|
|
89
|
+
'.mts',
|
|
90
|
+
'.cts',
|
|
91
|
+
'.js',
|
|
92
|
+
'.jsx',
|
|
93
|
+
'.mjs',
|
|
94
|
+
'.cjs',
|
|
95
|
+
'.vue',
|
|
96
|
+
'.svelte',
|
|
97
|
+
'.astro',
|
|
98
|
+
// Python / Ruby / PHP / Perl / Lua.
|
|
99
|
+
'.py',
|
|
100
|
+
'.pyi',
|
|
101
|
+
'.rb',
|
|
102
|
+
'.php',
|
|
103
|
+
'.pl',
|
|
104
|
+
'.pm',
|
|
105
|
+
'.lua',
|
|
106
|
+
// JVM + .NET.
|
|
107
|
+
'.java',
|
|
108
|
+
'.kt',
|
|
109
|
+
'.kts',
|
|
110
|
+
'.scala',
|
|
111
|
+
'.groovy',
|
|
112
|
+
'.clj',
|
|
113
|
+
'.cljs',
|
|
114
|
+
'.cs',
|
|
115
|
+
'.fs',
|
|
116
|
+
'.vb',
|
|
117
|
+
// Systems.
|
|
118
|
+
'.go',
|
|
119
|
+
'.rs',
|
|
120
|
+
'.c',
|
|
121
|
+
'.h',
|
|
122
|
+
'.cc',
|
|
123
|
+
'.cpp',
|
|
124
|
+
'.cxx',
|
|
125
|
+
'.hpp',
|
|
126
|
+
'.hh',
|
|
127
|
+
'.m',
|
|
128
|
+
'.mm',
|
|
129
|
+
'.swift',
|
|
130
|
+
// Functional / scientific / other.
|
|
131
|
+
'.ex',
|
|
132
|
+
'.exs',
|
|
133
|
+
'.erl',
|
|
134
|
+
'.dart',
|
|
135
|
+
'.r',
|
|
136
|
+
'.jl',
|
|
137
|
+
// Shell.
|
|
138
|
+
'.sh',
|
|
139
|
+
'.bash',
|
|
140
|
+
'.zsh',
|
|
141
|
+
'.ps1',
|
|
142
|
+
'.psm1',
|
|
143
|
+
]);
|
|
144
|
+
/**
|
|
145
|
+
* True if `path` looks like hand-authored program source (#413).
|
|
146
|
+
*
|
|
147
|
+
* Used to gate the Tier-3 naming heuristic. That heuristic asks "does any test
|
|
148
|
+
* file reference this file's stem or a top-level symbol?" — for a config
|
|
149
|
+
* dotfile, a lockfile, or a data blob there are no symbols and no test will
|
|
150
|
+
* ever name it, so the verdict is structurally always "uncovered": a guaranteed
|
|
151
|
+
* false positive rather than a signal. An extension-less file (`Makefile`,
|
|
152
|
+
* `Dockerfile`) and a bare dotfile (`.eslintrc`) are both non-source.
|
|
153
|
+
*/
|
|
154
|
+
export function isSourcePath(path) {
|
|
155
|
+
const base = basename(path);
|
|
156
|
+
// `.eslintrc` — `extname` calls this '' already, but a dotfile WITH a real
|
|
157
|
+
// extension (`.eslintrc.json`) must be judged on that extension, which the
|
|
158
|
+
// normal path handles.
|
|
159
|
+
const ext = extname(base).toLowerCase();
|
|
160
|
+
if (!ext)
|
|
161
|
+
return false;
|
|
162
|
+
return SOURCE_EXTENSIONS.has(ext);
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=paths.js.map
|