testguard-cli 0.14.2 → 0.15.1
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/CHANGELOG.md +71 -1
- package/README.md +30 -4
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +99 -8
- package/spec/README.md +7 -0
- package/spec/lib/validate.mjs +26 -1
- package/spec/schemas/common.schema.json +10 -0
- package/spec/schemas/evidence.schema.json +14 -0
- package/spec/schemas/gate.schema.json +1 -0
- package/spec/schemas/status.schema.json +2 -1
- package/src/cli.mjs +34 -4
- package/src/command-budget.mjs +52 -0
- package/src/commands/probe.mjs +22 -0
- package/src/commands/replay.mjs +14 -1
- package/src/commands/sweep.mjs +15 -6
- package/src/gate/changed.mjs +40 -3
- package/src/git.mjs +4 -2
- package/src/init/templates/SKILL.md +1 -1
- package/src/probe/attribution.mjs +2 -0
- package/src/probe/discover.mjs +44 -8
- package/src/probe/imports.mjs +785 -0
- package/src/probe/mocks.mjs +13 -4
- package/src/probe/probe.mjs +177 -20
- package/src/probe/rank.mjs +28 -5
- package/src/probe/runners/discovery.mjs +636 -0
- package/src/probe/runners/index.mjs +51 -19
- package/src/probe/runners/jest.mjs +32 -1
- package/src/probe/runners/playwright.mjs +58 -2
- package/src/probe/runners/python.mjs +7 -7
- package/src/probe/runners/shared.mjs +71 -7
- package/src/probe/runners/vitest.mjs +21 -0
- package/src/replay/replay.mjs +108 -13
- package/src/status/status.mjs +6 -0
- package/src/status/surface.mjs +3 -2
- package/src/sweep/sweep.mjs +5 -0
package/src/commands/probe.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
|
|
|
10
10
|
import { costReport, renderCost } from '../probe/cost.mjs';
|
|
11
11
|
import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
|
|
12
12
|
import { validate } from '../../spec/lib/validate.mjs';
|
|
13
|
+
import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
|
|
13
14
|
export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
|
|
14
15
|
|
|
15
16
|
export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
|
|
@@ -36,10 +37,16 @@ export function partialScope(only, records) {
|
|
|
36
37
|
export async function probeCommand({ projectDir, values, version }, io) {
|
|
37
38
|
const confirmRuns = Number(values.confirm);
|
|
38
39
|
const budgetMs = Number(values.budget);
|
|
40
|
+
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
39
41
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
40
42
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
41
43
|
return 3;
|
|
42
44
|
}
|
|
45
|
+
if (commandBudgetMs === null) {
|
|
46
|
+
io.err('--command-budget must be at least 1000 milliseconds');
|
|
47
|
+
return 3;
|
|
48
|
+
}
|
|
49
|
+
const commandBudget = createCommandBudget(commandBudgetMs);
|
|
43
50
|
if (values.progress && !isProgressMode(values.progress)) {
|
|
44
51
|
io.err(`--progress must be one of auto, tty, plain, ndjson, none (got ${values.progress})`);
|
|
45
52
|
return 3;
|
|
@@ -50,6 +57,19 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
50
57
|
const stageOut = stageReporter(progress, (s) => progressTo.write(s));
|
|
51
58
|
const claims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
|
|
52
59
|
if (claims.claims.length === 0) {
|
|
60
|
+
if (values['allow-empty'] && values.claim === undefined) {
|
|
61
|
+
io.err('claims file declares no claims; verification skipped (--allow-empty); run gate to check changed-file coverage');
|
|
62
|
+
if (values.json) {
|
|
63
|
+
const doc = computeStatus({ projectDir, toolVersion: version, paths: {
|
|
64
|
+
claims: values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir),
|
|
65
|
+
evidence: evidencePath(projectDir), provisional: provisionalEvidencePath(projectDir), baseline: baselinePath(projectDir),
|
|
66
|
+
} });
|
|
67
|
+
const result = validate('status', doc);
|
|
68
|
+
if (!result.ok) throw new Error(`empty probe JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
69
|
+
io.out(JSON.stringify(doc, null, 2));
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
53
73
|
io.err('claims file declares no claims; nothing to verify');
|
|
54
74
|
return 2;
|
|
55
75
|
}
|
|
@@ -74,6 +94,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
74
94
|
previous,
|
|
75
95
|
confirmRuns,
|
|
76
96
|
budgetMs,
|
|
97
|
+
commandBudget,
|
|
77
98
|
mode: values['in-place'] ? 'in-place' : 'worktree',
|
|
78
99
|
ref: values.ref ?? 'HEAD',
|
|
79
100
|
refExplicit: values.ref !== undefined,
|
|
@@ -101,6 +122,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
101
122
|
if (values.verbose || r.verdict !== 'killed') io.out(renderRecord(r, { provisional, showDiscovered: firstOfClaim }) + (r.reusedFrom ? ' (reused)' : ''));
|
|
102
123
|
},
|
|
103
124
|
});
|
|
125
|
+
commandBudget?.assertOpen();
|
|
104
126
|
writeSpecDoc('evidence', outPath, evidence);
|
|
105
127
|
|
|
106
128
|
const g = gate(evidence.records, baseline, { severityFloor: values.severity });
|
package/src/commands/replay.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join, resolve } from 'node:path';
|
|
2
2
|
import { replay, calibrationFrom, renderReplay } from '../replay/replay.mjs';
|
|
3
3
|
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
4
|
+
import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
|
|
4
5
|
|
|
5
6
|
export const replayPath = (projectDir) => join(projectDir, '.testguard', 'replay.json');
|
|
6
7
|
export const calibrationPath = (projectDir) => join(projectDir, '.testguard', 'calibration.json');
|
|
@@ -13,17 +14,24 @@ export async function replayCommand({ projectDir, values, version }, io) {
|
|
|
13
14
|
}
|
|
14
15
|
const confirmRuns = Number(values.confirm);
|
|
15
16
|
const budgetMs = Number(values.budget);
|
|
17
|
+
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
16
18
|
const limit = values.max ? Number(values.max) : undefined;
|
|
17
19
|
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
18
20
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
19
21
|
return 3;
|
|
20
22
|
}
|
|
23
|
+
if (commandBudgetMs === null) {
|
|
24
|
+
io.err('--command-budget must be at least 1000 milliseconds');
|
|
25
|
+
return 3;
|
|
26
|
+
}
|
|
27
|
+
const commandBudget = createCommandBudget(commandBudgetMs);
|
|
21
28
|
|
|
22
29
|
const doc = await replay({
|
|
23
30
|
projectDir,
|
|
24
31
|
range,
|
|
25
32
|
confirmRuns,
|
|
26
33
|
budgetMs,
|
|
34
|
+
commandBudget,
|
|
27
35
|
runnerCommand: values['runner-cmd'],
|
|
28
36
|
runnerName: values.runner,
|
|
29
37
|
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
@@ -36,12 +44,17 @@ export async function replayCommand({ projectDir, values, version }, io) {
|
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
const outPath = values.out ? resolve(values.out) : replayPath(projectDir);
|
|
39
|
-
writeSpecDoc('replay', outPath, doc);
|
|
40
47
|
const calibration = calibrationFrom(doc, { toolVersion: version });
|
|
41
48
|
// Beside the replay document, whatever --out says: the two are one result,
|
|
42
49
|
// and splitting them across directories loses the pairing — and leaves a
|
|
43
50
|
// file behind in a repository the run is only meant to read.
|
|
44
51
|
const calPath = values.baseline ? resolve(values.baseline) : values.out ? join(dirname(outPath), 'calibration.json') : calibrationPath(projectDir);
|
|
52
|
+
// Replay and calibration are one result. Cross the deadline boundary only
|
|
53
|
+
// after both complete documents exist in memory; serialization is then the
|
|
54
|
+
// documented non-interruptible final step, so expiry can never leave half
|
|
55
|
+
// of the pair as though it were complete.
|
|
56
|
+
commandBudget?.assertOpen();
|
|
57
|
+
writeSpecDoc('replay', outPath, doc);
|
|
45
58
|
writeSpecDoc('calibration', calPath, calibration);
|
|
46
59
|
|
|
47
60
|
if (values.json) {
|
package/src/commands/sweep.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { join, resolve } from 'node:path';
|
|
|
2
2
|
import { sweep, renderSweep, ConcernError } from '../sweep/sweep.mjs';
|
|
3
3
|
import { resolveChangedRef } from '../gate/changed.mjs';
|
|
4
4
|
import { writeSpecDoc } from '../evidence/writer.mjs';
|
|
5
|
+
import { createCommandBudget, parseCommandBudget } from '../command-budget.mjs';
|
|
5
6
|
|
|
6
7
|
export const sweepPath = (projectDir) => join(projectDir, '.testguard', 'sweep.json');
|
|
7
8
|
/**
|
|
@@ -29,6 +30,18 @@ export const sweepEvidencePath = (projectDir) => join(projectDir, '.testguard',
|
|
|
29
30
|
*/
|
|
30
31
|
export async function sweepCommand({ projectDir, values, version }, io) {
|
|
31
32
|
const mode = values['save-paths'] ? 'save-paths' : 'changed';
|
|
33
|
+
const confirmRuns = Number(values.confirm);
|
|
34
|
+
const budgetMs = Number(values.budget);
|
|
35
|
+
const commandBudgetMs = parseCommandBudget(values['command-budget']);
|
|
36
|
+
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
37
|
+
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
38
|
+
return 3;
|
|
39
|
+
}
|
|
40
|
+
if (commandBudgetMs === null) {
|
|
41
|
+
io.err('--command-budget must be at least 1000 milliseconds');
|
|
42
|
+
return 3;
|
|
43
|
+
}
|
|
44
|
+
const commandBudget = createCommandBudget(commandBudgetMs);
|
|
32
45
|
// A concern that aims at the write surface or at a glob needs no diff, the
|
|
33
46
|
// same as --save-paths. Only a concern that defers to the gate does.
|
|
34
47
|
const aimed = Boolean(values.concern) || mode === 'save-paths';
|
|
@@ -41,12 +54,6 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
41
54
|
io.err('sweep needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF. CI bases and a safe local remote default or differently named upstream are detected automatically. Or sweep the write surface instead with --save-paths.');
|
|
42
55
|
return 3;
|
|
43
56
|
}
|
|
44
|
-
const confirmRuns = Number(values.confirm);
|
|
45
|
-
const budgetMs = Number(values.budget);
|
|
46
|
-
if (!Number.isInteger(confirmRuns) || confirmRuns < 1 || !Number.isInteger(budgetMs) || budgetMs < 1000) {
|
|
47
|
-
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
48
|
-
return 3;
|
|
49
|
-
}
|
|
50
57
|
let cap;
|
|
51
58
|
if (values.cap !== undefined) {
|
|
52
59
|
cap = Number(values.cap);
|
|
@@ -70,6 +77,7 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
70
77
|
cap,
|
|
71
78
|
confirmRuns,
|
|
72
79
|
budgetMs,
|
|
80
|
+
commandBudget,
|
|
73
81
|
runnerCommand: values['runner-cmd'],
|
|
74
82
|
runnerName: values.runner,
|
|
75
83
|
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
@@ -95,6 +103,7 @@ export async function sweepCommand({ projectDir, values, version }, io) {
|
|
|
95
103
|
|
|
96
104
|
const { evidence, ...document } = doc;
|
|
97
105
|
const outPath = values.out ? resolve(values.out) : sweepPath(projectDir);
|
|
106
|
+
commandBudget?.assertOpen();
|
|
98
107
|
writeSpecDoc('sweep', outPath, document);
|
|
99
108
|
// Written only when something was actually probed: an empty document would
|
|
100
109
|
// teach the next run that every class is unproductive.
|
package/src/gate/changed.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, realpathSync, lstatSync } from 'node:fs';
|
|
2
2
|
import { extname, join, relative, resolve, dirname } from 'node:path';
|
|
3
3
|
import { git, repoRoot, headSha, GitError } from '../git.mjs';
|
|
4
|
-
import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
4
|
+
import { loadClaims, defaultClaimsPath, ClaimsError } from '../claims/load.mjs';
|
|
5
5
|
import { readSpecDoc } from '../evidence/writer.mjs';
|
|
6
6
|
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
7
7
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
@@ -49,6 +49,33 @@ export const isDefaultExcluded = (file) => DEFAULT_EXCLUDE_RES.some((re) => re.t
|
|
|
49
49
|
|
|
50
50
|
export const defaultIgnorePath = (projectDir) => join(projectDir, 'testguard.ignore.json');
|
|
51
51
|
|
|
52
|
+
/** Cache validated descendant boundaries for one measurement, never across runs. */
|
|
53
|
+
export function nestedProjectResolver(projectDir) {
|
|
54
|
+
const root = realpathSync(resolve(projectDir));
|
|
55
|
+
const markers = new Map();
|
|
56
|
+
return (file) => {
|
|
57
|
+
const parts = file.split('/').slice(0, -1);
|
|
58
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
59
|
+
const project = parts.slice(0, i).join('/');
|
|
60
|
+
if (!markers.has(project)) {
|
|
61
|
+
const marker = join(root, project, 'testguard.claims.json');
|
|
62
|
+
let stat;
|
|
63
|
+
try { stat = lstatSync(marker); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw new ClaimsError(`cannot inspect nested claims file ${marker}: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (stat) {
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(marker) !== marker) throw new ClaimsError(`nested claims file must be a regular file without symlink traversal: ${marker}`);
|
|
69
|
+
loadClaims(marker);
|
|
70
|
+
}
|
|
71
|
+
markers.set(project, Boolean(stat));
|
|
72
|
+
}
|
|
73
|
+
if (markers.get(project)) return project;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
52
79
|
/**
|
|
53
80
|
* The reference the change is measured against. Environment overrides and CI
|
|
54
81
|
* bases are handled here; local Git discovery is separate because it needs a
|
|
@@ -212,7 +239,15 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
|
|
|
212
239
|
const excluded = [];
|
|
213
240
|
const covered = [];
|
|
214
241
|
const uncovered = [];
|
|
242
|
+
const nested = new Map();
|
|
243
|
+
const ownerOf = nestedProjectResolver(projectDir);
|
|
215
244
|
for (const file of changed) {
|
|
245
|
+
const owner = ownerOf(file);
|
|
246
|
+
if (owner) {
|
|
247
|
+
if (!nested.has(owner)) nested.set(owner, []);
|
|
248
|
+
nested.get(owner).push(file);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
216
251
|
if (!SOURCE_EXT.has(extname(file))) { excluded.push({ file, by: 'non-source' }); continue; }
|
|
217
252
|
const ux = userExcludes.find((x) => x.re.test(file));
|
|
218
253
|
if (ux) { excluded.push({ file, by: `exclude:${ux.glob}` }); continue; }
|
|
@@ -256,6 +291,7 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
|
|
|
256
291
|
changed: changed.length,
|
|
257
292
|
evaluated,
|
|
258
293
|
excluded,
|
|
294
|
+
...(nested.size ? { nested: [...nested].map(([project, files]) => ({ project, files })) } : {}),
|
|
259
295
|
covered,
|
|
260
296
|
uncovered,
|
|
261
297
|
reliedOn,
|
|
@@ -271,7 +307,8 @@ export function renderGate(doc) {
|
|
|
271
307
|
for (const u of doc.uncovered) lines.push(`UNCLAIMED ${u.file} (${u.kind}${u.nearestClaimId ? `, nearest claim ${u.nearestClaimId}` : ''})\n → ${u.suggestion}`);
|
|
272
308
|
for (const r of doc.reliedOn) lines.push(`excused ${r.files.join(', ')} by ignore "${r.pattern}": ${r.reason}${r.expires ? ` (expires ${r.expires})` : ''}`);
|
|
273
309
|
for (const e of doc.expired) lines.push(`EXPIRED ignore "${e.pattern}" (expired ${e.expires}) no longer excuses ${e.files.join(', ')}`);
|
|
274
|
-
|
|
310
|
+
for (const n of doc.nested ?? []) lines.push(`delegated ${n.files.length} file(s) belong to the TestGuard project at ${n.project}/; run testguard gate ${n.project} --changed ${doc.ref}${doc.includeDirty ? ' --include-dirty' : ''}; child coverage has not been evaluated here`);
|
|
311
|
+
if (doc.changed > 0 && doc.evaluated === 0) lines.push(`note: every changed file was excluded or delegated (${[...new Set(doc.excluded.map((x) => x.by))].join(', ')}); 0 evaluated${doc.strict ? ' — --strict makes this a failure' : ''}`);
|
|
275
312
|
if (doc.changed === 0) lines.push(`no changes since ${doc.ref}; nothing to claim`);
|
|
276
313
|
if (doc.uncovered.length === 0 && doc.exitCode === 0 && doc.evaluated > 0) lines.push('every changed source file carries a claim or an excusing ignore entry');
|
|
277
314
|
return lines.join('\n');
|
package/src/git.mjs
CHANGED
|
@@ -6,12 +6,14 @@ import { randomBytes } from 'node:crypto';
|
|
|
6
6
|
|
|
7
7
|
export class GitError extends Error {}
|
|
8
8
|
|
|
9
|
-
export function
|
|
9
|
+
export function gitRaw(args, cwd, env) {
|
|
10
10
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: env ? { ...process.env, ...env } : process.env });
|
|
11
11
|
if (r.status !== 0) throw new GitError(`git ${args.join(' ')}: ${(r.stderr || r.stdout).trim()}`);
|
|
12
|
-
return r.stdout
|
|
12
|
+
return r.stdout;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export const git = (args, cwd, env) => gitRaw(args, cwd, env).trim();
|
|
16
|
+
|
|
15
17
|
export const repoRoot = (dir) => git(['rev-parse', '--show-toplevel'], dir);
|
|
16
18
|
|
|
17
19
|
/** Full sha of `ref` (default HEAD), or null when it does not resolve. */
|
|
@@ -20,7 +20,7 @@ which files exist. `state` is one of:
|
|
|
20
20
|
|
|
21
21
|
| state | meaning | you do |
|
|
22
22
|
|---|---|---|
|
|
23
|
-
| `no-claims` |
|
|
23
|
+
| `no-claims` | missing or empty `testguard.claims.json` | `testguard scaffold <file>` for a file with guards; replace every `TODO:` statement with the intended guarantee from a spec, ticket, or past bug; keep or drop each proposal; move the claims into `testguard.claims.json` |
|
|
24
24
|
| `unclaimed-changes` | files you changed carry no claim (when a reference is explicit, supplied by CI, or safely inferred from local Git's remote default/upstream metadata) | `next.file` names the first; `testguard scaffold <file>` and state the claim, or add a `testguard.ignore.json` path entry with a reason a reviewer will accept. **Before** writing more code. |
|
|
25
25
|
| `invalid-anchors` | an exact fault anchor moved or became ambiguous | run `testguard claims --check-anchors`; repair `next.target` in the claims file without changing what the fault means, then re-probe. Never guess or auto-fix an anchor. |
|
|
26
26
|
| `unprobed` | claims never probed | `testguard probe` |
|
|
@@ -117,6 +117,8 @@ export function isReusable(prior, current) {
|
|
|
117
117
|
const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
|
|
118
118
|
return prior.inputs.targetHash === current.inputs.targetHash
|
|
119
119
|
&& same(prior.inputs.defenderHashes, current.inputs.defenderHashes)
|
|
120
|
+
&& same(prior.inputs.testUniverseHash, current.inputs.testUniverseHash)
|
|
121
|
+
&& same(prior.inputs.discoveryHashes, current.inputs.discoveryHashes)
|
|
120
122
|
&& same(prior.defenders.requested, current.requested)
|
|
121
123
|
&& same(prior.defenders.resolved, current.resolved)
|
|
122
124
|
&& prior.subject.contentHash === current.contentHash;
|
package/src/probe/discover.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { listTestFiles } from './runners/shared.mjs';
|
|
3
3
|
import { testGlobs as PY_TEST_GLOBS } from './runners/python.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { fileImportRelation } from './rank.mjs';
|
|
5
5
|
import { pyFileImports } from './pyimports.mjs';
|
|
6
6
|
import { classifyDefenders } from './mocks.mjs';
|
|
7
|
+
import { normalizeDiscoveredFiles } from './runners/discovery.mjs';
|
|
7
8
|
|
|
8
9
|
/** A target's language decides how its importers are found. Nothing else about it matters here. */
|
|
9
10
|
export const isPython = (rel) => rel.endsWith('.py');
|
|
@@ -17,13 +18,48 @@ export const isPython = (rel) => rel.endsWith('.py');
|
|
|
17
18
|
* `nocover` therefore means exactly "no test file imports this source without
|
|
18
19
|
* mocking it".
|
|
19
20
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const explicitFiles = (projectDir, testUniverse) => {
|
|
22
|
+
if (testUniverse === undefined) return null;
|
|
23
|
+
if (!Array.isArray(testUniverse) && !Object.isFrozen(testUniverse)) throw new TypeError('explicit test universe manifest must be immutable');
|
|
24
|
+
const files = Array.isArray(testUniverse) ? testUniverse : testUniverse?.files;
|
|
25
|
+
if (!Array.isArray(files)) throw new TypeError('explicit test universe must be an array or a manifest with files');
|
|
26
|
+
if (!Object.isFrozen(files)) throw new TypeError('explicit test universe must be immutable');
|
|
27
|
+
return normalizeDiscoveredFiles(projectDir, files);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function discoverDefendersDetailed(projectDir, targetRel, testUniverse) {
|
|
31
|
+
const explicit = explicitFiles(projectDir, testUniverse);
|
|
32
|
+
const candidates = explicit ?? (isPython(targetRel) ? listTestFiles(projectDir, PY_TEST_GLOBS) : listTestFiles(projectDir));
|
|
33
|
+
if (isPython(targetRel)) {
|
|
34
|
+
const pythonCandidates = candidates.filter((t) => t.endsWith('.py'));
|
|
35
|
+
const importing = pythonCandidates.filter((t) => pyFileImports(projectDir, join(projectDir, t), targetRel, t));
|
|
36
|
+
return { importing, ...classifyDefenders(projectDir, targetRel, importing), indeterminate: [], dependencies: pythonCandidates, relations: new Map() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const relations = new Map();
|
|
40
|
+
const importing = [];
|
|
41
|
+
const indeterminate = [];
|
|
42
|
+
const jsCandidates = candidates.filter((candidate) => !candidate.endsWith('.py'));
|
|
43
|
+
// Every candidate's source participates in the negative discovery result:
|
|
44
|
+
// editing an unrelated test so it starts importing the target must invalidate
|
|
45
|
+
// a prior `nocover`, not silently reuse it.
|
|
46
|
+
const dependencies = new Set(jsCandidates);
|
|
47
|
+
for (const file of jsCandidates) {
|
|
48
|
+
const relation = fileImportRelation(projectDir, join(projectDir, file), targetRel);
|
|
49
|
+
relations.set(file, relation);
|
|
50
|
+
for (const dependency of relation.dependencies) dependencies.add(dependency);
|
|
51
|
+
if (relation.status === 'matched') importing.push(file);
|
|
52
|
+
else if (relation.status === 'indeterminate') indeterminate.push({ file, reason: relation.reason, issues: relation.issues ?? [] });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
importing,
|
|
56
|
+
...classifyDefenders(projectDir, targetRel, importing, relations),
|
|
57
|
+
indeterminate,
|
|
58
|
+
dependencies: [...dependencies].sort(),
|
|
59
|
+
relations,
|
|
60
|
+
};
|
|
25
61
|
}
|
|
26
62
|
|
|
27
|
-
export function discoverDefenders(projectDir, targetRel) {
|
|
28
|
-
return discoverDefendersDetailed(projectDir, targetRel).canDetect;
|
|
63
|
+
export function discoverDefenders(projectDir, targetRel, testUniverse) {
|
|
64
|
+
return discoverDefendersDetailed(projectDir, targetRel, testUniverse).canDetect;
|
|
29
65
|
}
|