testguard-cli 0.4.0 → 0.6.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/CHANGELOG.md +291 -0
- package/README.md +321 -27
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +109 -1
- package/spec/README.md +4 -2
- package/spec/lib/validate.mjs +75 -1
- package/spec/schemas/baseline.schema.json +8 -0
- package/spec/schemas/brief.schema.json +11 -1
- package/spec/schemas/common.schema.json +32 -1
- package/spec/schemas/evidence.schema.json +374 -63
- package/spec/schemas/gate.schema.json +52 -0
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +269 -45
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +96 -8
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +69 -12
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +26 -12
- package/src/commands/claims.mjs +35 -6
- package/src/commands/gate.mjs +47 -0
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +12 -3
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +4 -1
- package/src/gate/changed.mjs +222 -0
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +21 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/contention.mjs +60 -0
- package/src/probe/discover.mjs +12 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/mocks.mjs +120 -0
- package/src/probe/probe.mjs +126 -18
- package/src/probe/rank.mjs +73 -15
- package/src/probe/runners/index.mjs +49 -5
- package/src/probe/runners/jest.mjs +3 -3
- package/src/probe/runners/playwright.mjs +133 -0
- package/src/probe/runners/shared.mjs +70 -13
- package/src/probe/runners/vitest.mjs +4 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +309 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/scaffold.mjs +9 -3
- package/src/status/status.mjs +88 -7
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { git, GitError } from '../git.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maturity-ladder L3: independence.
|
|
5
|
+
*
|
|
6
|
+
* `probe` measures POWER — would this test notice if the code were wrong.
|
|
7
|
+
* It says nothing about who wrote the test. An agent that writes the code and
|
|
8
|
+
* its defender in one change encodes whatever it believed, and a kill by such
|
|
9
|
+
* a test is weaker evidence than a kill by a test written separately
|
|
10
|
+
* (SpecBench: agents saturate the tests they can see).
|
|
11
|
+
*
|
|
12
|
+
* This is a SIGNAL, never a verdict. A co-authored kill is legitimate — a bug
|
|
13
|
+
* fix *should* ship with its regression test — but a repository where every
|
|
14
|
+
* kill is co-authored has no independent verification, whatever its claim
|
|
15
|
+
* verification rate says. Ranking may read it; `classify()` never does.
|
|
16
|
+
*/
|
|
17
|
+
const CLASSES = ['co-authored', 'separate-change', 'unknown'];
|
|
18
|
+
|
|
19
|
+
/** `git log -1` for one path at a commit → {commit, author} or null when there is no history for it. */
|
|
20
|
+
function lastCommit(dir, ref, path) {
|
|
21
|
+
try {
|
|
22
|
+
const out = git(['log', '-1', '--format=%H%x09%ae', ref, '--', path], dir);
|
|
23
|
+
if (!out) return null;
|
|
24
|
+
const [commit, author] = out.split('\t');
|
|
25
|
+
return commit && author ? { commit, author } : null;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof GitError) return null;
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Was the killing defender last touched by the same change as the fault's
|
|
34
|
+
* target? `defenders` are the files whose tests failed on the fault.
|
|
35
|
+
*
|
|
36
|
+
* `co-authored` — same commit, or the same author identity within the
|
|
37
|
+
* commits the caller considers one change (`range`).
|
|
38
|
+
* `separate-change` — different commits by different identities.
|
|
39
|
+
* `unknown` — no history for one of them: a working-tree snapshot of
|
|
40
|
+
* untracked files, a shallow clone, or no commits.
|
|
41
|
+
*/
|
|
42
|
+
export function classifyIndependence({ dir, ref = 'HEAD', targetFile, defenders = [] }) {
|
|
43
|
+
const target = lastCommit(dir, ref, targetFile);
|
|
44
|
+
if (!target || defenders.length === 0) return { class: 'unknown' };
|
|
45
|
+
|
|
46
|
+
let sameAuthor = false;
|
|
47
|
+
let worst = null; // the most independent defender decides: one separate author is independence
|
|
48
|
+
for (const d of defenders) {
|
|
49
|
+
const def = lastCommit(dir, ref, d);
|
|
50
|
+
if (!def) return { class: 'unknown', targetCommit: target.commit };
|
|
51
|
+
const co = def.commit === target.commit || def.author === target.author;
|
|
52
|
+
if (def.author === target.author) sameAuthor = true;
|
|
53
|
+
if (!co) return { class: 'separate-change', defenderCommit: def.commit, targetCommit: target.commit, sameAuthor: false };
|
|
54
|
+
worst ??= def;
|
|
55
|
+
}
|
|
56
|
+
return { class: 'co-authored', defenderCommit: worst.commit, targetCommit: target.commit, sameAuthor };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { CLASSES as INDEPENDENCE_CLASSES };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { specifierResolvesTo } from './rank.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mock-awareness for defenders (issues #20, #21).
|
|
7
|
+
*
|
|
8
|
+
* A test file that `vi.mock`s / `jest.mock`s the target cannot detect any
|
|
9
|
+
* fault in it, yet it imports the target, so the import rule would count it
|
|
10
|
+
* as a defender. Two consequences the field reports measured: `nocover`
|
|
11
|
+
* under-reports (a module with eighteen "defenders" that is effectively
|
|
12
|
+
* uncovered) and every probe wastes runs on files that cannot fail. The
|
|
13
|
+
* mocked count is itself the strongest blindness signal.
|
|
14
|
+
*
|
|
15
|
+
* And the cheapest static signal of all: a file that mocks the target and
|
|
16
|
+
* never `expect(...)`s anything from it. That single fact explained the
|
|
17
|
+
* seam behind an escaped bug without injecting anything.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MOCK_CALL = /\b(?:vi|jest)\.(?:mock|doMock|unstable_mockModule)\(\s*(['"])([^'"]+)\1/g;
|
|
21
|
+
const IMPORT_STMT = /import\s+([^'";]+?)\s+from\s*(['"])([^'"]+)\2|import\s*(['"])([^'"]+)\4|(?:const|let|var)\s+([^=;]+?)\s*=\s*(?:await\s+)?(?:require|import)\(\s*(['"])([^'"]+)\7\s*\)/g;
|
|
22
|
+
const UNASSERTED = /unasserted:\s*(.+?)\s*(?:\*\/)?\s*$/;
|
|
23
|
+
|
|
24
|
+
/** The specifiers this source mocks, with their line numbers. */
|
|
25
|
+
export function mockSpecifiers(source) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const lines = source.split('\n');
|
|
28
|
+
lines.forEach((text, i) => {
|
|
29
|
+
for (const m of text.matchAll(MOCK_CALL)) out.push({ specifier: m[2], line: i + 1 });
|
|
30
|
+
});
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Local names bound to a module specifier by import/require statements. `*` marks a namespace/default object binding. */
|
|
35
|
+
function bindingsFor(source, matchesTarget) {
|
|
36
|
+
const names = [];
|
|
37
|
+
for (const m of source.matchAll(IMPORT_STMT)) {
|
|
38
|
+
const spec = m[3] ?? m[5] ?? m[8];
|
|
39
|
+
if (!spec || !matchesTarget(spec)) continue;
|
|
40
|
+
const clause = (m[1] ?? m[6] ?? '').trim();
|
|
41
|
+
if (!clause) continue; // side-effect import
|
|
42
|
+
const ns = /\*\s+as\s+([\w$]+)/.exec(clause);
|
|
43
|
+
if (ns) names.push(ns[1]);
|
|
44
|
+
const braces = /\{([^}]*)\}/.exec(clause);
|
|
45
|
+
if (braces) for (const part of braces[1].split(',')) { const local = part.trim().split(/\s+as\s+|\s*:\s*/).pop()?.trim(); if (local && /^[\w$]+$/.test(local)) names.push(local); }
|
|
46
|
+
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+[\w$]+/, '').replace(/,/g, '').trim();
|
|
47
|
+
if (def && /^[\w$]+$/.test(def)) names.push(def);
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(names)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does any `expect(...)` argument mention one of the bindings (as an identifier or a member root)? */
|
|
53
|
+
function expectMentions(source, bindings) {
|
|
54
|
+
if (bindings.length === 0) return false;
|
|
55
|
+
const re = /\bexpect\s*\(((?:[^()]|\((?:[^()]|\([^()]*\))*\))*)\)/g;
|
|
56
|
+
for (const m of source.matchAll(re)) {
|
|
57
|
+
const arg = m[1];
|
|
58
|
+
for (const b of bindings) if (new RegExp(`(^|[^\\w$.])${b.replace(/\$/g, '\\$')}(?![\\w$])`).test(arg)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How one test file relates to one target: does it import it, does it mock it,
|
|
65
|
+
* does it ever assert on what it imported from it, and did the author annotate
|
|
66
|
+
* an unasserted mock with a reason (`// unasserted: <why>` on or above the
|
|
67
|
+
* mock line).
|
|
68
|
+
*/
|
|
69
|
+
export function analyzeDefender(projectDir, testRel, targetRel) {
|
|
70
|
+
const abs = join(projectDir, testRel);
|
|
71
|
+
let source;
|
|
72
|
+
try {
|
|
73
|
+
source = readFileSync(abs, 'utf8');
|
|
74
|
+
} catch {
|
|
75
|
+
return { file: testRel, imports: false, mocks: false, asserted: false, annotation: null };
|
|
76
|
+
}
|
|
77
|
+
const matchesTarget = (spec) => specifierResolvesTo(projectDir, abs, spec, targetRel);
|
|
78
|
+
const mocks = mockSpecifiers(source).filter((m) => matchesTarget(m.specifier));
|
|
79
|
+
const bindings = bindingsFor(source, matchesTarget);
|
|
80
|
+
const asserted = expectMentions(source, bindings);
|
|
81
|
+
let annotation = null;
|
|
82
|
+
if (mocks.length) {
|
|
83
|
+
const lines = source.split('\n');
|
|
84
|
+
for (const m of mocks) {
|
|
85
|
+
for (let i = m.line - 1; i >= Math.max(0, m.line - 3) && !annotation; i--) {
|
|
86
|
+
const a = UNASSERTED.exec(lines[i]);
|
|
87
|
+
if (a) annotation = a[1];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { file: testRel, imports: bindings.length > 0 || matchesTargetAnyImport(source, matchesTarget), mocks: mocks.length > 0, asserted, annotation };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function matchesTargetAnyImport(source, matchesTarget) {
|
|
95
|
+
for (const m of source.matchAll(/(?:from\s*|import\s*\(?\s*|require\s*\(\s*)['"]([^'"]+)['"]/g)) if (matchesTarget(m[1])) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Split candidate test files for one target into the ones that can detect a
|
|
101
|
+
* fault in it and the ones that mock it, plus the static signals. A file that
|
|
102
|
+
* mocks the target is never a defender; a mocking file that never asserts on
|
|
103
|
+
* the target carries `mocked-never-asserted`, unless annotated, in which case
|
|
104
|
+
* the annotation is recorded (`unasserted-annotated`) — silenced, never hidden.
|
|
105
|
+
*/
|
|
106
|
+
export function classifyDefenders(projectDir, targetRel, candidates) {
|
|
107
|
+
const canDetect = [];
|
|
108
|
+
const mocking = [];
|
|
109
|
+
const signals = [];
|
|
110
|
+
for (const file of candidates) {
|
|
111
|
+
const a = analyzeDefender(projectDir, file, targetRel);
|
|
112
|
+
if (a.mocks) {
|
|
113
|
+
mocking.push(file);
|
|
114
|
+
if (!a.asserted) signals.push(a.annotation ? { file, signal: 'unasserted-annotated', reason: a.annotation } : { file, signal: 'mocked-never-asserted' });
|
|
115
|
+
} else {
|
|
116
|
+
canDetect.push(file);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { canDetect, mocking, signals };
|
|
120
|
+
}
|
package/src/probe/probe.mjs
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync as fsRealpathSync } from 'node:fs';
|
|
2
2
|
import { git } from '../git.mjs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
|
-
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { repoRoot as gitRoot, headSha, isDirty, snapshotWorkingTree } from '../git.mjs';
|
|
6
|
-
import { discoverDefenders } from './discover.mjs';
|
|
6
|
+
import { discoverDefenders, discoverDefendersDetailed } from './discover.mjs';
|
|
7
|
+
import { classifyDefenders } from './mocks.mjs';
|
|
8
|
+
import { detectContention, contentionWarning } from './contention.mjs';
|
|
7
9
|
import { createScratch, inPlace, PreconditionError } from './worktree.mjs';
|
|
8
10
|
import { applyFault, locate } from './inject.mjs';
|
|
9
11
|
import { resolveDefenders, parseCommandTemplate } from './runners/shared.mjs';
|
|
10
|
-
import { selectRunner, RUNNERS } from './runners/index.mjs';
|
|
12
|
+
import { selectRunner, RUNNERS, OWNED_RUNNERS, partitionByRunner, mergeRuns } from './runners/index.mjs';
|
|
11
13
|
import { classify, shouldStopEarly } from './classify.mjs';
|
|
12
14
|
import { blastRadius, rank } from './rank.mjs';
|
|
15
|
+
import { classifyIndependence } from './independence.mjs';
|
|
13
16
|
import { hashFile, sha256 } from '../util/hash.mjs';
|
|
14
17
|
import { fingerprint } from '../../spec/lib/fingerprint.mjs';
|
|
15
18
|
|
|
16
19
|
const isKill = (r) => r.outcome === 'fail' && r.assertionFailures > 0;
|
|
17
20
|
|
|
21
|
+
/** realpath when the path still exists; the path itself otherwise. Never throws. */
|
|
22
|
+
function realpathSync(p) {
|
|
23
|
+
try {
|
|
24
|
+
return fsRealpathSync(p);
|
|
25
|
+
} catch {
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
function readRunnerVersion(projectDir, name = 'vitest') {
|
|
19
31
|
try {
|
|
20
32
|
return JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve(`${name}/package.json`), 'utf8')).version;
|
|
@@ -33,6 +45,10 @@ export async function probe({
|
|
|
33
45
|
confirmRuns = 3,
|
|
34
46
|
mode = 'worktree',
|
|
35
47
|
ref = 'HEAD',
|
|
48
|
+
refExplicit = false,
|
|
49
|
+
ignoreDirty = false,
|
|
50
|
+
serial = false,
|
|
51
|
+
onWarn = () => {},
|
|
36
52
|
budgetMs = 120_000,
|
|
37
53
|
runnerCommand,
|
|
38
54
|
runnerName = 'auto',
|
|
@@ -69,11 +85,17 @@ export async function probe({
|
|
|
69
85
|
|
|
70
86
|
// Worktree mode probes a commit, not the working tree. Uncommitted defender
|
|
71
87
|
// or target edits would be silently absent — the same survivors, no hint why.
|
|
88
|
+
// Refused for the IMPLICIT HEAD, which is the silent-mismatch trap. With an
|
|
89
|
+
// explicit --ref the user has named the commit they mean (a pre-fix probe
|
|
90
|
+
// in a post-mortem is the most valuable run there is); with --ignore-dirty
|
|
91
|
+
// they have said they know. Both proceed with a warning that names the
|
|
92
|
+
// files, and the evidence records what was ignored.
|
|
72
93
|
let snapshot;
|
|
94
|
+
let ignoredDirty = [];
|
|
73
95
|
if (mode === 'worktree') {
|
|
74
96
|
if (includeDirty) {
|
|
75
97
|
if (isDirty(root)) snapshot = snapshotWorkingTree(root);
|
|
76
|
-
} else
|
|
98
|
+
} else {
|
|
77
99
|
const watched = new Set(targets);
|
|
78
100
|
for (const claim of claims.claims) {
|
|
79
101
|
if (selected && !selected.has(claim.id)) continue;
|
|
@@ -82,16 +104,28 @@ export async function probe({
|
|
|
82
104
|
for (const g of claim.defendedBy ?? []) if (!g.includes('*')) watched.add(relative(root, join(projectDir, g)));
|
|
83
105
|
}
|
|
84
106
|
const dirty = git(['status', '--porcelain', '--', ...watched], root).split('\n').filter(Boolean).map((l) => l.replace(/^[ MADRCU?!]{1,2}\s+/, '').replace(/^.* -> /, ''));
|
|
107
|
+
if (dirty.length && !refExplicit && !ignoreDirty) {
|
|
108
|
+
throw new PreconditionError(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); worktree mode probes HEAD (${head.slice(0, 7)}), so those changes would be silently ignored. Commit them, run with --include-dirty to probe the working tree, use --in-place, or --ignore-dirty if you mean HEAD as committed.`);
|
|
109
|
+
}
|
|
85
110
|
if (dirty.length) {
|
|
86
|
-
|
|
111
|
+
ignoredDirty = dirty.sort();
|
|
112
|
+
onWarn(`${dirty.length} defender/target file${dirty.length === 1 ? ' has' : 's have'} uncommitted changes (${dirty.join(', ')}); probing ${ref === 'HEAD' ? `HEAD (${head.slice(0, 7)})` : `${ref} (${head.slice(0, 7)})`} as committed — the working-tree versions are NOT what is being probed. Recorded in the evidence as repo.ignoredDirty.`);
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
}
|
|
116
|
+
// Contention: name it before the first run, and record it, so a later
|
|
117
|
+
// reader of a TIMEOUT or FLAKY-DEFENDER knows the machine was loaded.
|
|
118
|
+
const contention = detectContention();
|
|
119
|
+
if (contention.detected) onWarn(contentionWarning(contention));
|
|
120
|
+
|
|
90
121
|
const startedAt = new Date().toISOString();
|
|
91
122
|
const iso = mode === 'worktree' ? createScratch({ repoRoot: root, projectDir, ref: snapshot ?? ref, scratchBase, nodeModules }) : inPlace({ repoRoot: root, projectDir });
|
|
123
|
+
const isoReal = realpathSync(iso.projectDir);
|
|
92
124
|
const records = [];
|
|
93
125
|
let runnerVersion;
|
|
126
|
+
let runnerSource;
|
|
94
127
|
let runner = RUNNERS[runnerName === 'auto' ? 'vitest' : runnerName];
|
|
128
|
+
const runnersUsed = new Map(); // every runner that ran defenders, beyond the project runner
|
|
95
129
|
try {
|
|
96
130
|
const commandTemplate = runnerCommand ? parseCommandTemplate(runnerCommand) : undefined;
|
|
97
131
|
if (!commandTemplate) {
|
|
@@ -103,21 +137,60 @@ export async function probe({
|
|
|
103
137
|
}
|
|
104
138
|
runner = sel.runner;
|
|
105
139
|
runnerVersion = sel.version;
|
|
140
|
+
runnerSource = sel.source;
|
|
106
141
|
}
|
|
107
|
-
|
|
142
|
+
runnersUsed.set(runner.name, runnerVersion ?? readRunnerVersion(projectDir, runner.name));
|
|
143
|
+
// Files an owning runner (Playwright) claims run under it, whatever the
|
|
144
|
+
// project runner is; it must resolve before the first such defender runs.
|
|
145
|
+
const owned = OWNED_RUNNERS.filter((r) => r !== runner);
|
|
146
|
+
const ownedChecked = new Map();
|
|
147
|
+
const ensureOwned = async (r, file) => {
|
|
148
|
+
if (!ownedChecked.has(r)) ownedChecked.set(r, await r.check({ projectDir: iso.projectDir }));
|
|
149
|
+
const c = ownedChecked.get(r);
|
|
150
|
+
if (!c.ok) throw new PreconditionError(`${file} is a ${r.name} test (it lives under ${r.name}'s testDir) but ${r.name} is not resolvable in the ${mode === 'worktree' ? 'scratch worktree' : 'project'} (${c.message}).`);
|
|
151
|
+
runnersUsed.set(r.name, c.version);
|
|
152
|
+
};
|
|
153
|
+
const allTests = [...new Set([...runner.tests(iso.projectDir).filter((t) => !owned.some((r) => r.owns(iso.projectDir, t))), ...owned.flatMap((r) => r.tests(iso.projectDir))])].sort();
|
|
108
154
|
const baselineCache = new Map();
|
|
109
155
|
const prior = previous && previous.run.confirmRuns === confirmRuns
|
|
110
156
|
? new Map(previous.records.map((r) => [`${r.claim.id}/${r.subject.id}`, r]))
|
|
111
157
|
: new Map();
|
|
112
|
-
const runDefenders = (files) =>
|
|
158
|
+
const runDefenders = async (files) => {
|
|
159
|
+
if (commandTemplate) return runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate, serial });
|
|
160
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
161
|
+
const parts = [];
|
|
162
|
+
for (const [r, group] of groups) {
|
|
163
|
+
if (r !== runner) await ensureOwned(r, group[0]);
|
|
164
|
+
parts.push(await r.run({ projectDir: iso.projectDir, files: group, budgetMs, serial }));
|
|
165
|
+
}
|
|
166
|
+
return mergeRuns(parts);
|
|
167
|
+
};
|
|
168
|
+
/** Which runner each defender runs under, when more than the project runner is involved. */
|
|
169
|
+
const byRunner = (files) => {
|
|
170
|
+
const groups = partitionByRunner(iso.projectDir, files, runner);
|
|
171
|
+
if (groups.size <= 1) return undefined; // one runner ran them all, whichever it was
|
|
172
|
+
return Object.fromEntries([...groups].map(([r, group]) => [r.name, group]));
|
|
173
|
+
};
|
|
113
174
|
|
|
114
175
|
for (const claim of claims.claims) {
|
|
115
176
|
if (selected && !selected.has(claim.id)) continue;
|
|
116
177
|
const declared = claim.defendedBy?.length ? resolveDefenders(iso.projectDir, claim.defendedBy) : null;
|
|
117
178
|
for (const fault of claim.faults) {
|
|
118
|
-
|
|
179
|
+
// Mock-awareness: a discovered file that mocks the target is not a
|
|
180
|
+
// defender; a declared one that mocks it stays (the author named it)
|
|
181
|
+
// but is listed, because a declared defender that mocks the subject
|
|
182
|
+
// is a broken evidence chain the author should see.
|
|
183
|
+
const mockInfo = declared ? classifyDefenders(iso.projectDir, fault.file, declared) : discoverDefendersDetailed(iso.projectDir, fault.file);
|
|
184
|
+
const defenders = declared ?? mockInfo.canDetect;
|
|
119
185
|
const stage = (name, i, n) => onStage({ claimId: claim.id, faultId: fault.id, stage: name, i, n });
|
|
120
|
-
const record = await probeOne({ claim, fault, defenders, discovered: declared === null, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id
|
|
186
|
+
const record = await probeOne({ claim, fault, defenders, discovered: declared === null, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior: prior.get(`${claim.id}/${fault.id}`), priorRunId: previous?.run.id, byRunner: byRunner(defenders), mocking: mockInfo.mocking, signals: mockInfo.signals,
|
|
187
|
+
historyRef: snapshot ?? (mode === 'worktree' ? head : 'HEAD'), historyDir: root,
|
|
188
|
+
// A path from the runner is absolute inside the SCRATCH worktree, or
|
|
189
|
+
// project-relative. Either way history is read from the real repository,
|
|
190
|
+
// so both must land on a repo-root-relative path. The runner reports
|
|
191
|
+
// realpaths (/private/var on macOS) while the worktree path may not be
|
|
192
|
+
// one — realpath both sides or every comparison silently misses.
|
|
193
|
+
toRepoPath: (p) => relative(root, join(projectDir, isAbsolute(p) ? relative(isoReal, realpathSync(p)) : p)) });
|
|
121
194
|
records.push(record);
|
|
122
195
|
onProgress(record);
|
|
123
196
|
}
|
|
@@ -133,9 +206,12 @@ export async function probe({
|
|
|
133
206
|
id: `run-${startedAt.replace(/[-:.]/g, '').slice(0, 15)}`,
|
|
134
207
|
startedAt,
|
|
135
208
|
finishedAt: new Date().toISOString(),
|
|
136
|
-
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}) },
|
|
209
|
+
repo: { head, dirty: isDirty(root), ...(snapshot ? { snapshot } : {}), ...(ignoredDirty.length ? { ignoredDirty } : {}) },
|
|
137
210
|
runner: { name: runner.name, ...((runnerVersion ?? readRunnerVersion(projectDir, runner.name)) ? { version: runnerVersion ?? readRunnerVersion(projectDir, runner.name) } : {}) },
|
|
211
|
+
...(runnersUsed.size > 1 ? { runners: [...runnersUsed].map(([n, v]) => ({ name: n, ...(v ? { version: v } : {}) })) } : {}),
|
|
138
212
|
confirmRuns,
|
|
213
|
+
...(serial ? { serial: true } : {}),
|
|
214
|
+
...(contention.detected ? { contention } : {}),
|
|
139
215
|
...(confirmRuns < 3 ? { provisional: true } : {}),
|
|
140
216
|
mode,
|
|
141
217
|
},
|
|
@@ -143,7 +219,7 @@ export async function probe({
|
|
|
143
219
|
};
|
|
144
220
|
}
|
|
145
221
|
|
|
146
|
-
async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId }) {
|
|
222
|
+
async function probeOne({ claim, fault, defenders, discovered, allTests, iso, confirmRuns, escalate, baselineCache, runDefenders, stage, prior, priorRunId, byRunner, mocking = [], signals = [], historyRef, historyDir, toRepoPath }) {
|
|
147
223
|
const targetPath = join(iso.projectDir, fault.file);
|
|
148
224
|
const targetExists = existsSync(targetPath);
|
|
149
225
|
const inputs = {
|
|
@@ -177,10 +253,27 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
177
253
|
break;
|
|
178
254
|
}
|
|
179
255
|
}
|
|
256
|
+
// The decision stops at the first non-green run — nothing about this
|
|
257
|
+
// fault can be trusted after it. But "failed once in three" and "fails
|
|
258
|
+
// every time" are different problems for whoever has to fix the
|
|
259
|
+
// defender, and the only way to tell them apart is to finish the runs.
|
|
260
|
+
// Paid once per defender set, and only when it is already broken.
|
|
261
|
+
if (runs.length < confirmRuns && runs[runs.length - 1].outcome === 'fail') {
|
|
262
|
+
for (let i = runs.length; i < confirmRuns; i++) {
|
|
263
|
+
stage('baseline-flake', i + 1, confirmRuns);
|
|
264
|
+
runs.push((await runDefenders(defenders)).run);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
180
267
|
baselineCache.set(key, { runs, loadMessage });
|
|
181
268
|
}
|
|
182
269
|
const baseline = baselineCache.get(key);
|
|
183
270
|
detail.baselineRuns = baseline.runs;
|
|
271
|
+
// Observed instability of the defenders on UNMODIFIED source: `fail` is
|
|
272
|
+
// the tests running and disagreeing with themselves. A load error or a
|
|
273
|
+
// timeout is not flakiness — nothing was measured about stability — so
|
|
274
|
+
// neither is counted, on either side of the ratio.
|
|
275
|
+
const measured = baseline.runs.filter((r) => r.outcome === 'pass' || r.outcome === 'fail');
|
|
276
|
+
if (measured.length) detail.flakeRate = { runs: measured.length, failures: measured.filter((r) => r.outcome === 'fail').length };
|
|
184
277
|
if (baseline.runs.some((r) => r.outcome === 'error')) {
|
|
185
278
|
// The defenders did not load. That is not flakiness and not a verdict
|
|
186
279
|
// about the fault; the claim cannot be probed until they do.
|
|
@@ -193,11 +286,11 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
193
286
|
const probeRuns = rawProbeRuns;
|
|
194
287
|
for (let i = 0; i < confirmRuns; i++) {
|
|
195
288
|
stage('probe', i + 1, confirmRuns);
|
|
196
|
-
const { run, timeouts, loadMessage } = await runDefenders(defenders);
|
|
197
|
-
probeRuns.push({ ...run, timeouts, loadMessage });
|
|
289
|
+
const { run, timeouts, loadMessage, failedTests } = await runDefenders(defenders);
|
|
290
|
+
probeRuns.push({ ...run, timeouts, loadMessage, failedTests });
|
|
198
291
|
if (shouldStopEarly(probeRuns)) break;
|
|
199
292
|
}
|
|
200
|
-
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, ...run }) => run);
|
|
293
|
+
detail.probeRuns = probeRuns.map(({ timeouts, loadMessage, failedTests, ...run }) => run);
|
|
201
294
|
const provisional = classify({ defenders, anchor, baselineRuns: detail.baselineRuns, probeRuns, confirmRuns });
|
|
202
295
|
|
|
203
296
|
// Escalation: does anything *undeclared* catch it? A single run cannot
|
|
@@ -207,15 +300,21 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
207
300
|
if (provisional.verdict === 'survived' && escalate && broader.length > 0) {
|
|
208
301
|
const runs = [];
|
|
209
302
|
let killers = null;
|
|
303
|
+
let stoppedEarly;
|
|
210
304
|
for (let i = 0; i < confirmRuns; i++) {
|
|
211
305
|
stage('escalation', i + 1, confirmRuns);
|
|
212
306
|
const { run, failedTests } = await runDefenders(allTests);
|
|
213
307
|
runs.push(run);
|
|
214
308
|
killers = killers === null ? new Set(failedTests) : new Set(failedTests.filter((t) => killers.has(t)));
|
|
215
|
-
|
|
309
|
+
// Attribution needs a test that failed in ALL N runs. Once the
|
|
310
|
+
// intersection is empty no further run can name a killer, so the
|
|
311
|
+
// remaining runs are wasted; stopping can only fail to NAME a
|
|
312
|
+
// killer, never upgrade a verdict, which is the pessimistic side.
|
|
313
|
+
if (killers.size === 0) { stoppedEarly = 'no-common-failure'; break; }
|
|
216
314
|
}
|
|
217
315
|
detail.escalated = true;
|
|
218
316
|
detail.escalationRuns = runs;
|
|
317
|
+
if (stoppedEarly) detail.escalationStoppedEarly = stoppedEarly;
|
|
219
318
|
if (killers.size > 0 && runs.length === confirmRuns && runs.every(isKill)) {
|
|
220
319
|
detail.reason = 'killed-by-undeclared-tests';
|
|
221
320
|
detail.undeclaredKillers = [...killers].sort();
|
|
@@ -234,15 +333,24 @@ async function probeOne({ claim, fault, defenders, discovered, allTests, iso, co
|
|
|
234
333
|
|
|
235
334
|
const blast = targetExists ? blastRadius(iso.projectDir, fault.file) : 0;
|
|
236
335
|
|
|
336
|
+
// L3: a kill by a test written in the same change as the code it guards is
|
|
337
|
+
// weaker evidence than one written separately. Signal only — the verdict is
|
|
338
|
+
// already decided above, and ranking is the only consumer.
|
|
339
|
+
if (verdict === 'killed' && historyRef) {
|
|
340
|
+
// Only the tests that actually failed on the fault are its killers.
|
|
341
|
+
const killers = [...new Set(rawProbeRuns.flatMap((r) => r.failedTests ?? []).map((t) => t.split('::')[0]).filter(Boolean))];
|
|
342
|
+
detail.independence = classifyIndependence({ dir: historyDir, ref: historyRef, targetFile: toRepoPath(fault.file), defenders: (killers.length ? killers : defenders).map(toRepoPath) });
|
|
343
|
+
}
|
|
344
|
+
|
|
237
345
|
return {
|
|
238
346
|
fingerprint: fingerprint({ claimId: claim.id, subjectId: fault.id, file: fault.file, verdict }),
|
|
239
347
|
claim: { id: claim.id, statement: claim.statement, severity: claim.severity, source: claim.source, producedBy: claim.producedBy },
|
|
240
348
|
subject: { kind: 'fault', id: fault.id, description: fault.description, file: fault.file, faultClass: fault.faultClass, producedBy: fault.producedBy, contentHash: sha256(`${fault.find}\n${fault.replace}`) },
|
|
241
349
|
verdict,
|
|
242
350
|
detail,
|
|
243
|
-
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}) },
|
|
351
|
+
defenders: { requested: claim.defendedBy ?? [], resolved: defenders, nocover: defenders.length === 0, ...(discovered ? { discovered: true } : {}), ...(byRunner ? { byRunner } : {}), ...(mocking.length ? { mocking } : {}), ...(signals.length ? { signals } : {}) },
|
|
244
352
|
inputs,
|
|
245
|
-
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast }),
|
|
353
|
+
rank: rank({ severity: claim.severity, sourceKind: claim.source.kind, blast, independence: detail.independence?.class }),
|
|
246
354
|
};
|
|
247
355
|
}
|
|
248
356
|
|
package/src/probe/rank.mjs
CHANGED
|
@@ -11,8 +11,10 @@ function parseJsonc(text) {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Alias rules for a project: tsconfig/jsconfig `compilerOptions.paths` (with
|
|
14
|
-
* `baseUrl`, following a relative `extends` a few levels
|
|
15
|
-
* `
|
|
14
|
+
* `baseUrl`, following a relative `extends` a few levels, and every
|
|
15
|
+
* `references[].path` — Vite projects keep `paths` in `tsconfig.app.json`),
|
|
16
|
+
* vite/vitest `resolve.alias` read as text, and package.json `imports`. Bare
|
|
17
|
+
* specifiers that match no rule are ignored, as documented.
|
|
16
18
|
*/
|
|
17
19
|
export function loadAliases(projectDir) {
|
|
18
20
|
if (aliasCache.has(projectDir)) return aliasCache.get(projectDir);
|
|
@@ -22,25 +24,69 @@ export function loadAliases(projectDir) {
|
|
|
22
24
|
rules.push({ prefix, suffix, wildcard: pattern.includes('*'), targets: targets.map((t) => resolve(base, t)) });
|
|
23
25
|
};
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Every tsconfig in the chain: the root, what it extends, what it references.
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const queue = ['tsconfig.json', 'jsconfig.json'].map((f) => join(projectDir, f)).filter(existsSync).slice(0, 1);
|
|
30
|
+
while (queue.length && seen.size < 12) {
|
|
31
|
+
const cfgPath = queue.shift();
|
|
32
|
+
if (seen.has(cfgPath) || !existsSync(cfgPath)) continue;
|
|
33
|
+
seen.add(cfgPath);
|
|
29
34
|
let cfg;
|
|
30
35
|
try {
|
|
31
36
|
cfg = parseJsonc(readFileSync(cfgPath, 'utf8'));
|
|
32
37
|
} catch {
|
|
33
|
-
|
|
38
|
+
continue;
|
|
34
39
|
}
|
|
35
40
|
const co = cfg.compilerOptions ?? {};
|
|
36
|
-
if (
|
|
37
|
-
|
|
41
|
+
if (co.paths) {
|
|
42
|
+
const base = co.baseUrl !== undefined ? resolve(dirname(cfgPath), co.baseUrl) : dirname(cfgPath);
|
|
43
|
+
for (const [pattern, targets] of Object.entries(co.paths)) if (Array.isArray(targets)) toRule(pattern, targets, base);
|
|
44
|
+
}
|
|
38
45
|
const ext = typeof cfg.extends === 'string' && cfg.extends.startsWith('.') ? cfg.extends : null;
|
|
39
|
-
|
|
46
|
+
if (ext) queue.push(resolve(dirname(cfgPath), ext.endsWith('.json') ? ext : ext + '.json'));
|
|
47
|
+
for (const ref of Array.isArray(cfg.references) ? cfg.references : []) {
|
|
48
|
+
if (typeof ref?.path !== 'string') continue;
|
|
49
|
+
const p = resolve(dirname(cfgPath), ref.path);
|
|
50
|
+
queue.push(p.endsWith('.json') ? p : join(p, 'tsconfig.json'));
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
|
|
54
|
+
// vite / vitest config: `resolve.alias` as `{ '@': path.resolve(__dirname, './src') }`,
|
|
55
|
+
// `{ '@': fileURLToPath(new URL('./src', import.meta.url)) }`, `{ '@': './src' }`
|
|
56
|
+
// or `[{ find: '@', replacement: … }]`. Read as text: the config may be TypeScript.
|
|
57
|
+
for (const f of ['vitest.config.ts', 'vitest.config.mts', 'vitest.config.js', 'vitest.config.mjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
|
|
58
|
+
const path = join(projectDir, f);
|
|
59
|
+
if (!existsSync(path)) continue;
|
|
60
|
+
const text = readFileSync(path, 'utf8');
|
|
61
|
+
const start = /\balias\s*:\s*([\[{])/.exec(text);
|
|
62
|
+
if (!start) continue;
|
|
63
|
+
// Take the balanced bracket block that follows `alias:`.
|
|
64
|
+
const open = start.index + start[0].length - 1;
|
|
65
|
+
const pairs = { '{': '}', '[': ']' };
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let end = -1;
|
|
68
|
+
for (let i = open; i < text.length; i++) {
|
|
69
|
+
if (text[i] === '{' || text[i] === '[' || text[i] === '(') depth++;
|
|
70
|
+
else if (text[i] === '}' || text[i] === ']' || text[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
71
|
+
}
|
|
72
|
+
if (end === -1) continue;
|
|
73
|
+
const block = text.slice(open, end + 1);
|
|
74
|
+
const PATHLIKE = /(['"])(\.{1,2}\/[^'"]*|src(?:\/[^'"]*)?)\1/;
|
|
75
|
+
let entries;
|
|
76
|
+
if (start[1] === '[') {
|
|
77
|
+
entries = [...block.matchAll(/find\s*:\s*(['"])([^'"]+)\1([\s\S]*?)(?=find\s*:|\]$)/g)].map((m) => [m[2], m[3]]);
|
|
78
|
+
} else {
|
|
79
|
+
// key: <value up to the next top-level key>; the value may contain calls with their own commas
|
|
80
|
+
const keys = [...block.matchAll(/(['"]?)([@#~$\w./-]+)\1\s*:(?!\/)/g)];
|
|
81
|
+
entries = keys.map((k, i) => [k[2], block.slice(k.index + k[0].length, keys[i + 1]?.index ?? block.length)]);
|
|
82
|
+
}
|
|
83
|
+
for (const [key, value] of entries) {
|
|
84
|
+
const target = PATHLIKE.exec(value)?.[2];
|
|
85
|
+
if (!key || !target) continue;
|
|
86
|
+
const abs = resolve(projectDir, target);
|
|
87
|
+
rules.push({ prefix: key, suffix: '', wildcard: false, targets: [abs] });
|
|
88
|
+
rules.push({ prefix: key.endsWith('/') ? key : key + '/', suffix: '', wildcard: true, targets: [join(abs, '*')] });
|
|
89
|
+
}
|
|
44
90
|
}
|
|
45
91
|
|
|
46
92
|
const pkgPath = join(projectDir, 'package.json');
|
|
@@ -94,6 +140,14 @@ function resolvesTo(fromFile, specifier, targetAbs, aliases) {
|
|
|
94
140
|
return bases.some((b) => expandBase(b).includes(targetAbs));
|
|
95
141
|
}
|
|
96
142
|
|
|
143
|
+
/** Does `specifier`, as written in `absFile`, resolve to `targetRel` (relative or alias-resolved)? */
|
|
144
|
+
export function specifierResolvesTo(projectDir, absFile, specifier, targetRel) {
|
|
145
|
+
return resolvesTo(absFile, specifier, resolve(projectDir, targetRel), loadAliases(projectDir));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Clear the alias cache (tests create and delete projects at the same paths). */
|
|
149
|
+
export const resetAliasCache = () => aliasCache.clear();
|
|
150
|
+
|
|
97
151
|
/** Does the file at `absFile` import `targetRel` (relative or alias-resolved)? */
|
|
98
152
|
export function fileImports(projectDir, absFile, targetRel) {
|
|
99
153
|
const targetAbs = resolve(projectDir, targetRel);
|
|
@@ -136,9 +190,13 @@ export function blastRadius(projectDir, targetRel) {
|
|
|
136
190
|
return count;
|
|
137
191
|
}
|
|
138
192
|
|
|
193
|
+
// A kill whose defender was written in the same change as the code carries
|
|
194
|
+
// less independent evidence. Ranking may read the signal; verdicts never do.
|
|
195
|
+
const INDEPENDENCE_WEIGHT = { 'separate-change': 1, unknown: 0.95, 'co-authored': 0.85 };
|
|
196
|
+
|
|
139
197
|
/** Additive ordering only. Never touches the verdict. */
|
|
140
|
-
export function rank({ severity, sourceKind, blast }) {
|
|
141
|
-
const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast));
|
|
198
|
+
export function rank({ severity, sourceKind, blast, independence }) {
|
|
199
|
+
const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast)) * (INDEPENDENCE_WEIGHT[independence] ?? 1);
|
|
142
200
|
return { score: Number(score.toFixed(3)), blastRadius: blast, tier: severity };
|
|
143
201
|
}
|
|
144
202
|
|
|
@@ -1,20 +1,64 @@
|
|
|
1
1
|
import * as vitest from './vitest.mjs';
|
|
2
2
|
import * as jest from './jest.mjs';
|
|
3
|
+
import * as playwright from './playwright.mjs';
|
|
3
4
|
|
|
4
|
-
export const RUNNERS = { vitest, jest };
|
|
5
|
+
export const RUNNERS = { vitest, jest, playwright };
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* Runners that own files rather than projects: a defender under Playwright's
|
|
9
|
+
* testDir runs under Playwright whatever the project runner is. `auto` never
|
|
10
|
+
* picks one of these for the whole project; `--runner playwright` does.
|
|
11
|
+
*/
|
|
12
|
+
export const OWNED_RUNNERS = [playwright];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pick the project runner: an explicit name, or the first of vitest, jest that
|
|
16
|
+
* is resolvable from the project. Returns { runner, version } or { error }.
|
|
9
17
|
*/
|
|
10
18
|
export async function selectRunner({ projectDir, name = 'auto' }) {
|
|
11
19
|
const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
|
|
12
|
-
if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest or auto` };
|
|
20
|
+
if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest, playwright or auto` };
|
|
13
21
|
const messages = [];
|
|
14
22
|
for (const r of candidates) {
|
|
15
23
|
const c = await r.check({ projectDir });
|
|
16
|
-
if (c.ok) return { runner: r, version: c.version };
|
|
24
|
+
if (c.ok) return { runner: r, version: c.version, source: c.source };
|
|
17
25
|
messages.push(`${r.name}: ${c.message}`);
|
|
18
26
|
}
|
|
19
27
|
return { error: messages.join('; ') };
|
|
20
28
|
}
|
|
29
|
+
|
|
30
|
+
/** The runner a test file runs under: an owning runner if one claims it, else the project runner. */
|
|
31
|
+
export function runnerFor(projectDir, file, primary) {
|
|
32
|
+
if (primary.owns?.(projectDir, file)) return primary;
|
|
33
|
+
return OWNED_RUNNERS.find((r) => r !== primary && r.owns(projectDir, file)) ?? primary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Group files by the runner they run under, in a stable order (primary first). */
|
|
37
|
+
export function partitionByRunner(projectDir, files, primary) {
|
|
38
|
+
const groups = new Map([[primary, []]]);
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
const r = runnerFor(projectDir, f, primary);
|
|
41
|
+
if (!groups.has(r)) groups.set(r, []);
|
|
42
|
+
groups.get(r).push(f);
|
|
43
|
+
}
|
|
44
|
+
if (groups.get(primary).length === 0 && groups.size > 1) groups.delete(primary);
|
|
45
|
+
return groups;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Merge the runs of several runners into one testRun, pessimistically: any
|
|
50
|
+
* load error is an error, any timeout is a timeout, any failure is a failure.
|
|
51
|
+
* Counts add; failed test ids concatenate.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeRuns(parts) {
|
|
54
|
+
if (parts.length === 1) return parts[0];
|
|
55
|
+
const runs = parts.map((p) => p.run);
|
|
56
|
+
const outcome = runs.some((r) => r.outcome === 'error') ? 'error' : runs.some((r) => r.outcome === 'timeout') ? 'timeout' : runs.some((r) => r.outcome === 'fail') ? 'fail' : 'pass';
|
|
57
|
+
const sum = (k) => runs.reduce((n, r) => n + (r.tests[k] ?? 0), 0);
|
|
58
|
+
return {
|
|
59
|
+
run: { outcome, tests: { total: sum('total'), passed: sum('passed'), failed: sum('failed') }, assertionFailures: runs.reduce((n, r) => n + r.assertionFailures, 0), durationMs: runs.reduce((n, r) => n + r.durationMs, 0) },
|
|
60
|
+
timeouts: parts.reduce((n, p) => n + (p.timeouts ?? 0), 0),
|
|
61
|
+
loadMessage: parts.find((p) => p.loadMessage)?.loadMessage,
|
|
62
|
+
failedTests: parts.flatMap((p) => p.failedTests ?? []),
|
|
63
|
+
};
|
|
64
|
+
}
|