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.
Files changed (51) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +321 -27
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +109 -1
  5. package/spec/README.md +4 -2
  6. package/spec/lib/validate.mjs +75 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/brief.schema.json +11 -1
  9. package/spec/schemas/common.schema.json +32 -1
  10. package/spec/schemas/evidence.schema.json +374 -63
  11. package/spec/schemas/gate.schema.json +52 -0
  12. package/spec/schemas/replay.schema.json +85 -0
  13. package/spec/schemas/status.schema.json +269 -45
  14. package/src/admit/admit.mjs +17 -0
  15. package/src/baseline/baseline.mjs +31 -2
  16. package/src/brief/brief.mjs +96 -8
  17. package/src/claims/removed.mjs +161 -0
  18. package/src/cli.mjs +69 -12
  19. package/src/commands/admit.mjs +116 -0
  20. package/src/commands/baseline.mjs +17 -3
  21. package/src/commands/brief.mjs +26 -12
  22. package/src/commands/claims.mjs +35 -6
  23. package/src/commands/gate.mjs +47 -0
  24. package/src/commands/init.mjs +19 -6
  25. package/src/commands/mcp.mjs +12 -0
  26. package/src/commands/probe.mjs +12 -3
  27. package/src/commands/replay.mjs +63 -0
  28. package/src/commands/status.mjs +4 -1
  29. package/src/gate/changed.mjs +222 -0
  30. package/src/git.mjs +5 -0
  31. package/src/init/init.mjs +182 -29
  32. package/src/init/templates/SKILL.md +21 -3
  33. package/src/mcp/server.mjs +104 -0
  34. package/src/mcp/tools.mjs +114 -0
  35. package/src/probe/contention.mjs +60 -0
  36. package/src/probe/discover.mjs +12 -3
  37. package/src/probe/independence.mjs +59 -0
  38. package/src/probe/mocks.mjs +120 -0
  39. package/src/probe/probe.mjs +126 -18
  40. package/src/probe/rank.mjs +73 -15
  41. package/src/probe/runners/index.mjs +49 -5
  42. package/src/probe/runners/jest.mjs +3 -3
  43. package/src/probe/runners/playwright.mjs +133 -0
  44. package/src/probe/runners/shared.mjs +70 -13
  45. package/src/probe/runners/vitest.mjs +4 -3
  46. package/src/render.mjs +2 -2
  47. package/src/replay/label.mjs +64 -0
  48. package/src/replay/replay.mjs +309 -0
  49. package/src/scaffold/producers.mjs +217 -6
  50. package/src/scaffold/scaffold.mjs +9 -3
  51. 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
+ }
@@ -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 if (ref === 'HEAD') {
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
- 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, or use --in-place.`);
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
- const allTests = runner.tests(iso.projectDir);
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) => runner.run({ projectDir: iso.projectDir, files, budgetMs, commandTemplate });
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
- const defenders = declared ?? discoverDefenders(iso.projectDir, fault.file);
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
- if (killers.size === 0) break;
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
 
@@ -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) and package.json
15
- * `imports`. Bare specifiers that match no rule are ignored, as documented.
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
- let cfgPath = ['tsconfig.json', 'jsconfig.json'].map((f) => join(projectDir, f)).find(existsSync);
26
- let baseUrl;
27
- let paths;
28
- for (let hop = 0; cfgPath && hop < 5; hop++) {
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
- break;
38
+ continue;
34
39
  }
35
40
  const co = cfg.compilerOptions ?? {};
36
- if (baseUrl === undefined && co.baseUrl !== undefined) baseUrl = resolve(dirname(cfgPath), co.baseUrl);
37
- if (paths === undefined && co.paths !== undefined) paths = { dir: dirname(cfgPath), map: co.paths };
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
- cfgPath = ext ? resolve(dirname(cfgPath), ext.endsWith('.json') ? ext : ext + '.json') : null;
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
- if (paths) {
42
- const base = baseUrl ?? paths.dir;
43
- for (const [pattern, targets] of Object.entries(paths.map)) if (Array.isArray(targets)) toRule(pattern, targets, base);
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
- * Pick the runner: an explicit name, or the first of vitest, jest that is
8
- * resolvable from the project. Returns { runner, version } or { error }.
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
+ }