testguard-cli 0.5.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 (47) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/README.md +275 -31
  3. package/package.json +1 -1
  4. package/spec/GATE-SEMANTICS.md +58 -1
  5. package/spec/README.md +1 -0
  6. package/spec/lib/validate.mjs +57 -1
  7. package/spec/schemas/baseline.schema.json +8 -0
  8. package/spec/schemas/common.schema.json +5 -1
  9. package/spec/schemas/evidence.schema.json +374 -63
  10. package/spec/schemas/replay.schema.json +85 -0
  11. package/spec/schemas/status.schema.json +256 -54
  12. package/src/admit/admit.mjs +17 -0
  13. package/src/baseline/baseline.mjs +31 -2
  14. package/src/brief/brief.mjs +71 -7
  15. package/src/claims/removed.mjs +161 -0
  16. package/src/cli.mjs +53 -10
  17. package/src/commands/admit.mjs +116 -0
  18. package/src/commands/baseline.mjs +17 -3
  19. package/src/commands/brief.mjs +13 -7
  20. package/src/commands/claims.mjs +35 -6
  21. package/src/commands/init.mjs +19 -6
  22. package/src/commands/mcp.mjs +12 -0
  23. package/src/commands/probe.mjs +11 -3
  24. package/src/commands/replay.mjs +63 -0
  25. package/src/commands/status.mjs +3 -1
  26. package/src/git.mjs +5 -0
  27. package/src/init/init.mjs +182 -29
  28. package/src/init/templates/SKILL.md +3 -3
  29. package/src/mcp/server.mjs +104 -0
  30. package/src/mcp/tools.mjs +114 -0
  31. package/src/probe/contention.mjs +60 -0
  32. package/src/probe/discover.mjs +12 -3
  33. package/src/probe/independence.mjs +59 -0
  34. package/src/probe/mocks.mjs +120 -0
  35. package/src/probe/probe.mjs +126 -18
  36. package/src/probe/rank.mjs +73 -15
  37. package/src/probe/runners/index.mjs +49 -5
  38. package/src/probe/runners/jest.mjs +3 -3
  39. package/src/probe/runners/playwright.mjs +133 -0
  40. package/src/probe/runners/shared.mjs +70 -13
  41. package/src/probe/runners/vitest.mjs +4 -3
  42. package/src/render.mjs +2 -2
  43. package/src/replay/label.mjs +64 -0
  44. package/src/replay/replay.mjs +309 -0
  45. package/src/scaffold/producers.mjs +217 -6
  46. package/src/scaffold/scaffold.mjs +9 -3
  47. package/src/status/status.mjs +47 -6
@@ -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
+ }
@@ -1,9 +1,9 @@
1
- import { TEST_GLOBS, npx, runProcess, checkBinary, listTestFiles } from './shared.mjs';
1
+ import { TEST_GLOBS, runProcess, checkRunner, runnerArgv, listTestFiles } from './shared.mjs';
2
2
 
3
3
  export const name = 'jest';
4
4
  // jest's default testMatch also collects anything under __tests__/.
5
5
  export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/**/*.mjs', '**/__tests__/**/*.cjs', '**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.jsx'];
6
- export const check = (opts) => checkBinary({ ...opts, bin: 'jest' });
6
+ export const check = (opts) => checkRunner({ ...opts, pkg: 'jest', bin: 'jest' });
7
7
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
8
8
  // --runTestsByPath: positionals are exact paths, not regexes — a path with `+` or `(` would otherwise silently match nothing.
9
- export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [npx, 'jest', '--ci', '--json', `--outputFile=${outFile}`, '--runTestsByPath', ...files] });
9
+ export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(opts.serial ? ['--runInBand'] : []), '--runTestsByPath', ...files] });
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { npx, runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
5
+ import { globToRegExp } from '../../util/glob.mjs';
6
+
7
+ /**
8
+ * Playwright runner. Selected **per file**, not per project: a defender that
9
+ * lives under Playwright's `testDir` runs here, everything else runs under
10
+ * the project runner (vitest/jest). One claim may list both kinds. Selected
11
+ * for the whole project only with `--runner playwright`.
12
+ *
13
+ * Report protocol: `--reporter=json` with the JSON written to a file we name
14
+ * (PLAYWRIGHT_JSON_OUTPUT_FILE; the older PLAYWRIGHT_JSON_OUTPUT_NAME is set
15
+ * too), never parsed from stdout. Playwright's test statuses map onto the
16
+ * spec's testRun as follows, and the mapping is the point of the adapter:
17
+ *
18
+ * expected → passed
19
+ * unexpected/failed → failed, counts toward assertionFailures (can kill)
20
+ * unexpected/timedOut → failed, counts toward timeouts (never a kill)
21
+ * flaky → failed on an attempt and passed on retry: the run
22
+ * is NOT green. Counted as failed with neither an
23
+ * assertion failure nor a timeout, so a green-looking
24
+ * exit code becomes FLAKY-DEFENDER through the
25
+ * ordinary N-run rules, exactly as GATE-SEMANTICS says.
26
+ * skipped → neither passed nor failed
27
+ */
28
+
29
+ export const name = 'playwright';
30
+
31
+ const CONFIG_FILES = ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js', 'playwright.config.mjs', 'playwright.config.cjs'];
32
+ const SPEC_GLOBS = ['**/*.spec.js', '**/*.spec.mjs', '**/*.spec.cjs', '**/*.spec.ts', '**/*.spec.mts', '**/*.spec.tsx', '**/*.spec.jsx',
33
+ '**/*.test.js', '**/*.test.mjs', '**/*.test.cjs', '**/*.test.ts', '**/*.test.mts', '**/*.test.tsx', '**/*.test.jsx'];
34
+
35
+ const configCache = new Map();
36
+
37
+ /**
38
+ * The project's Playwright config, read as text (it may be TypeScript, so it
39
+ * is never imported): `{ path, testDir }` or null when there is none.
40
+ * `testDir` defaults to the config's own directory, as Playwright does.
41
+ */
42
+ export function loadConfig(projectDir) {
43
+ if (configCache.has(projectDir)) return configCache.get(projectDir);
44
+ let out = null;
45
+ for (const f of CONFIG_FILES) {
46
+ const path = join(projectDir, f);
47
+ if (!existsSync(path)) continue;
48
+ const text = readFileSync(path, 'utf8');
49
+ const m = /\btestDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text);
50
+ const testDir = relative(projectDir, resolve(projectDir, m ? m[2] : '.')).split(sep).join('/') || '.';
51
+ out = { path: f, testDir };
52
+ break;
53
+ }
54
+ configCache.set(projectDir, out);
55
+ return out;
56
+ }
57
+
58
+ /** Clear the config cache (tests create and delete projects at the same paths). */
59
+ export const resetConfigCache = () => configCache.clear();
60
+
61
+ export const testGlobs = SPEC_GLOBS;
62
+ const SPEC_RES = SPEC_GLOBS.map(globToRegExp);
63
+
64
+ /** Does this test file belong to Playwright: is there a config, and does the file live under its testDir? */
65
+ export function owns(projectDir, file) {
66
+ const cfg = loadConfig(projectDir);
67
+ if (!cfg) return false;
68
+ if (!SPEC_RES.some((re) => re.test(file))) return false;
69
+ return cfg.testDir === '.' ? true : file.startsWith(cfg.testDir + '/');
70
+ }
71
+
72
+ /**
73
+ * Resolvable = a config exists AND `@playwright/test` resolves from the
74
+ * project. Deliberately not `npx --no-install playwright --version`: the npx
75
+ * cache and a Python `playwright` on PATH both answer that from a project
76
+ * that has no Playwright at all, and `playwright test` would then run
77
+ * against nothing. The package `playwright test` needs is the precondition.
78
+ */
79
+ export async function check({ projectDir }) {
80
+ const cfg = loadConfig(projectDir);
81
+ if (!cfg) return { ok: false, message: 'no playwright.config.* in the project' };
82
+ try {
83
+ const pkg = JSON.parse(readFileSync(createRequire(join(projectDir, 'noop.js')).resolve('@playwright/test/package.json'), 'utf8'));
84
+ return { ok: true, version: pkg.version };
85
+ } catch {
86
+ return { ok: false, message: '@playwright/test is not installed in the project (npm i -D @playwright/test)' };
87
+ }
88
+ }
89
+
90
+ export const tests = (projectDir) => listTestFiles(projectDir, SPEC_GLOBS).filter((f) => owns(projectDir, f));
91
+
92
+ /**
93
+ * Playwright's JSON report → the spec's testRun. Pure; exported for the
94
+ * sample-driven tests. `durationMs` is the wall clock measured by the runner.
95
+ */
96
+ export function parseReport(report, durationMs) {
97
+ const tests = [];
98
+ const walk = (suite) => {
99
+ for (const spec of suite.specs ?? []) {
100
+ for (const t of spec.tests ?? []) {
101
+ const results = t.results ?? [];
102
+ const last = results[results.length - 1];
103
+ tests.push({ id: `${suite.file ?? spec.file ?? ''}::${spec.title}`, status: t.status, finalResult: last?.status, error: results.find((r) => r.error)?.error?.message });
104
+ }
105
+ }
106
+ for (const s of suite.suites ?? []) walk(s);
107
+ };
108
+ for (const s of report.suites ?? []) walk(s);
109
+
110
+ const expected = tests.filter((t) => t.status === 'expected').length;
111
+ const unexpected = tests.filter((t) => t.status === 'unexpected');
112
+ const flaky = tests.filter((t) => t.status === 'flaky').length;
113
+ const timeouts = unexpected.filter((t) => t.finalResult === 'timedOut' || t.finalResult === 'interrupted').length;
114
+ const assertionFailures = unexpected.length - timeouts;
115
+ const failedTests = [...unexpected, ...tests.filter((t) => t.status === 'flaky')].map((t) => t.id);
116
+ const total = tests.length;
117
+ const summary = { total, passed: expected, failed: unexpected.length + flaky };
118
+
119
+ const globalErrors = report.errors ?? [];
120
+ let outcome;
121
+ if (total === 0) outcome = 'error';
122
+ else if (unexpected.length > 0 || flaky > 0) outcome = 'fail';
123
+ else outcome = 'pass';
124
+ const loadMessage = outcome === 'error'
125
+ ? (globalErrors.length ? firstInformativeLine(globalErrors.map((e) => e.message ?? '').join('\n')) : 'playwright found no tests')
126
+ : undefined;
127
+ return { run: { outcome, tests: summary, assertionFailures, durationMs }, timeouts, loadMessage, failedTests, flaky };
128
+ }
129
+
130
+ export const run = (opts) => {
131
+ const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
132
+ return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
133
+ };