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,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