testguard-cli 0.5.0 → 0.7.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 (65) hide show
  1. package/CHANGELOG.md +628 -0
  2. package/README.md +536 -42
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +166 -2
  5. package/spec/README.md +29 -10
  6. package/spec/lib/validate.mjs +140 -8
  7. package/spec/schemas/baseline.schema.json +13 -5
  8. package/spec/schemas/brief.schema.json +12 -12
  9. package/spec/schemas/calibration.schema.json +5 -5
  10. package/spec/schemas/claims.schema.json +122 -25
  11. package/spec/schemas/common.schema.json +131 -29
  12. package/spec/schemas/evidence.schema.json +476 -69
  13. package/spec/schemas/gate.schema.json +12 -12
  14. package/spec/schemas/ignore.schema.json +4 -4
  15. package/spec/schemas/replay.schema.json +85 -0
  16. package/spec/schemas/status.schema.json +258 -56
  17. package/src/admit/admit.mjs +17 -0
  18. package/src/baseline/baseline.mjs +31 -2
  19. package/src/brief/brief.mjs +71 -7
  20. package/src/claims/removed.mjs +161 -0
  21. package/src/cli.mjs +68 -10
  22. package/src/commands/admit.mjs +116 -0
  23. package/src/commands/baseline.mjs +17 -3
  24. package/src/commands/brief.mjs +13 -7
  25. package/src/commands/claims.mjs +55 -6
  26. package/src/commands/init.mjs +19 -6
  27. package/src/commands/mcp.mjs +12 -0
  28. package/src/commands/probe.mjs +35 -7
  29. package/src/commands/replay.mjs +63 -0
  30. package/src/commands/status.mjs +3 -1
  31. package/src/gate/changed.mjs +9 -2
  32. package/src/git.mjs +5 -0
  33. package/src/init/init.mjs +182 -29
  34. package/src/init/templates/SKILL.md +3 -3
  35. package/src/mcp/server.mjs +104 -0
  36. package/src/mcp/tools.mjs +114 -0
  37. package/src/probe/attribution.mjs +123 -0
  38. package/src/probe/classify.mjs +18 -2
  39. package/src/probe/contention.mjs +60 -0
  40. package/src/probe/cost.mjs +145 -0
  41. package/src/probe/discover.mjs +20 -3
  42. package/src/probe/independence.mjs +59 -0
  43. package/src/probe/inject.mjs +41 -9
  44. package/src/probe/mocks.mjs +122 -0
  45. package/src/probe/probe.mjs +331 -40
  46. package/src/probe/progress.mjs +87 -0
  47. package/src/probe/pyimports.mjs +287 -0
  48. package/src/probe/rank.mjs +75 -15
  49. package/src/probe/runners/index.mjs +79 -8
  50. package/src/probe/runners/jest.mjs +15 -3
  51. package/src/probe/runners/playwright.mjs +142 -0
  52. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  53. package/src/probe/runners/python/_testguard_report.py +141 -0
  54. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  55. package/src/probe/runners/python.mjs +316 -0
  56. package/src/probe/runners/shared.mjs +80 -14
  57. package/src/probe/runners/vitest.mjs +16 -3
  58. package/src/render.mjs +2 -2
  59. package/src/replay/label.mjs +64 -0
  60. package/src/replay/replay.mjs +310 -0
  61. package/src/scaffold/producers.mjs +217 -6
  62. package/src/scaffold/producers.python.mjs +209 -0
  63. package/src/scaffold/scaffold.mjs +90 -30
  64. package/src/status/status.mjs +47 -6
  65. package/src/util/glob.mjs +7 -1
@@ -0,0 +1,287 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, basename } from 'node:path';
3
+ import { walk } from '../util/glob.mjs';
4
+
5
+ /**
6
+ * Python import and patch analysis: the counterpart of the specifier
7
+ * resolution in `rank.mjs`, which understands only JavaScript.
8
+ *
9
+ * Without it every Python claim that declares no `defendedBy` resolves to no
10
+ * defender and reports `nocover` — not an error, not a warning, just a quiet
11
+ * and wrong answer about a suite that may defend the claim perfectly well.
12
+ *
13
+ * Python is matched on module names rather than on paths, because that is what
14
+ * Python itself matches on: a file at `src/pkg/mod.py` is imported as
15
+ * `pkg.mod` from anywhere on `sys.path`, and the test that reaches it by
16
+ * inserting `src/` into `sys.path` is indistinguishable, at the import site,
17
+ * from one that reaches it through an installed distribution.
18
+ */
19
+
20
+ const PY = /\.py$/;
21
+ const COMMENT = /^\s*#/;
22
+
23
+ /**
24
+ * The dotted module name of a file, and the directory that name is relative
25
+ * to: walk up while each directory is a package (`__init__.py`), exactly as
26
+ * Python resolves a module to its package. `src/pkg/mod.py` with
27
+ * `src/pkg/__init__.py` present and no `src/__init__.py` is `pkg.mod` rooted
28
+ * at `src`.
29
+ *
30
+ * A package's own `__init__.py` is the package: `pkg/__init__.py` is `pkg`,
31
+ * not `pkg.__init__`, which is what makes `import pkg.anything` reach it.
32
+ */
33
+ export function pyModuleName(projectDir, rel) {
34
+ const parts = [basename(rel).replace(PY, '')];
35
+ if (parts[0] === '__init__') parts.pop();
36
+ let dir = dirname(join(projectDir, rel));
37
+ for (let depth = 0; depth < 32; depth++) {
38
+ if (!existsSync(join(dir, '__init__.py'))) break;
39
+ parts.unshift(basename(dir));
40
+ const parent = dirname(dir);
41
+ if (parent === dir) break;
42
+ dir = parent;
43
+ }
44
+ return { dotted: parts.join('.'), isPackage: basename(rel) === '__init__.py' };
45
+ }
46
+
47
+ /**
48
+ * Logical import lines: `\` continuations joined, and a parenthesised
49
+ * `from x import (a, b)` collapsed onto one line so the names are visible.
50
+ */
51
+ function logicalLines(source) {
52
+ const raw = source.split('\n');
53
+ const out = [];
54
+ for (let i = 0; i < raw.length; i++) {
55
+ let line = raw[i];
56
+ if (COMMENT.test(line)) continue;
57
+ while (/\\\s*$/.test(line) && i + 1 < raw.length) line = line.replace(/\\\s*$/, ' ') + raw[++i];
58
+ if (/^\s*from\s/.test(line) && line.includes('(') && !line.includes(')')) {
59
+ // Bounded: a malformed file must not make this loop to the end of a large source.
60
+ for (let n = 0; n < 200 && i + 1 < raw.length && !line.includes(')'); n++) line += ' ' + raw[++i];
61
+ }
62
+ out.push(line);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** The package a file lives in, as a dotted name ('' when it is not in a package). */
68
+ function packageOf(projectDir, rel) {
69
+ const dir = dirname(rel);
70
+ if (dir === '.' || dir === '') return '';
71
+ return pyModuleName(projectDir, join(dir, '__init__.py')).dotted;
72
+ }
73
+
74
+ /**
75
+ * Every module path a source imports, as absolute dotted names.
76
+ *
77
+ * `from pkg import mod` is recorded as both `pkg` and `pkg.mod`, because
78
+ * Python cannot tell the two apart at the import site and neither can a
79
+ * reader: whether `mod` is a submodule or an attribute of `pkg` depends on
80
+ * what is on disk. Recording both is the pessimistic reading — it can only
81
+ * make a file count as a defender that might not be, never hide one that is.
82
+ */
83
+ export function pyImports(source, selfPackage = '') {
84
+ const found = new Set();
85
+ const absolute = (level, module) => {
86
+ if (level === 0) return module;
87
+ const parts = selfPackage ? selfPackage.split('.') : [];
88
+ const base = parts.slice(0, Math.max(0, parts.length - (level - 1)));
89
+ return [...base, ...(module ? module.split('.') : [])].join('.');
90
+ };
91
+
92
+ for (const line of logicalLines(source)) {
93
+ const plain = /^\s*import\s+(.+?)\s*$/.exec(line);
94
+ if (plain && !/^\s*import\s*\(/.test(line)) {
95
+ for (const piece of plain[1].split(',')) {
96
+ const name = piece.trim().split(/\s+as\s+/)[0].trim();
97
+ if (/^[\w.]+$/.test(name)) found.add(name);
98
+ }
99
+ continue;
100
+ }
101
+ const rel = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+?)\s*$/.exec(line);
102
+ if (rel) {
103
+ const base = absolute(rel[1].length, rel[2]);
104
+ if (base) found.add(base);
105
+ const names = rel[3].replace(/[()]/g, '');
106
+ if (!names.includes('*')) {
107
+ for (const piece of names.split(',')) {
108
+ const name = piece.trim().split(/\s+as\s+/)[0].trim();
109
+ if (/^\w+$/.test(name)) found.add(base ? `${base}.${name}` : name);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ // `importlib.import_module('pkg.mod')` and `__import__('pkg.mod')`.
115
+ for (const m of source.matchAll(/(?:import_module|__import__)\s*\(\s*(['"])([\w.]+)\1/g)) found.add(m[2]);
116
+ return [...found];
117
+ }
118
+
119
+ /** Does `dotted` name the target module, or something inside it when the target is a package's `__init__.py`? */
120
+ function hits(dotted, target) {
121
+ if (!target.dotted) return false;
122
+ return dotted === target.dotted || (target.isPackage && dotted.startsWith(target.dotted + '.'));
123
+ }
124
+
125
+ /** Does the Python file at `absFile` import `targetRel`? */
126
+ export function pyFileImports(projectDir, absFile, targetRel, testRel) {
127
+ const target = pyModuleName(projectDir, targetRel);
128
+ if (!target.dotted) return false;
129
+ let source;
130
+ try {
131
+ source = readFileSync(absFile, 'utf8');
132
+ } catch {
133
+ return false;
134
+ }
135
+ return pyImports(source, packageOf(projectDir, testRel ?? absFile)).some((d) => hits(d, target));
136
+ }
137
+
138
+ /** Non-test Python files that import the target, by direct import only. */
139
+ export function pyBlastRadius(projectDir, targetRel) {
140
+ const target = pyModuleName(projectDir, targetRel);
141
+ if (!target.dotted) return 0;
142
+ let count = 0;
143
+ for (const rel of walk(projectDir)) {
144
+ if (!PY.test(rel) || rel === targetRel || IS_PY_TEST.test(rel)) continue;
145
+ let source;
146
+ try {
147
+ source = readFileSync(join(projectDir, rel), 'utf8');
148
+ } catch {
149
+ continue;
150
+ }
151
+ if (pyImports(source, packageOf(projectDir, rel)).some((d) => hits(d, target))) count++;
152
+ }
153
+ return count;
154
+ }
155
+
156
+ export const IS_PY_TEST = /(^|\/)(test[^/]*\.py|[^/]*_test\.py)$/;
157
+
158
+ const PATCH_STRING = /\b(?:mock\.|unittest\.mock\.|mocker\.|monkeypatch\.)?(?:patch|setattr|setitem|patch\.object|patch\.dict)\s*\(\s*(['"])([\w.]+)\1/g;
159
+ // `.test()` on a global regex advances its lastIndex and carries that state to
160
+ // the next call. The predicate gets its own non-global copy so line-by-line
161
+ // scanning cannot silently start matching from the middle of a line.
162
+ const PATCH_LINE = new RegExp(PATCH_STRING.source);
163
+ const UNASSERTED = /unasserted:\s*(.+?)\s*$/;
164
+ const ASSERT_LINE = /(^|[^\w.])assert(?:\s|_|\b)|\bself\.assert\w*\s*\(|\bassert\w*\s*\(/;
165
+
166
+ /**
167
+ * Which parts of the target a test replaces.
168
+ *
169
+ * This is where Python and JavaScript genuinely differ, and copying the
170
+ * JavaScript rule would be wrong. `vi.mock('./mod')` replaces a whole module,
171
+ * so a file that calls it can detect nothing in that module and is never a
172
+ * defender. `patch('pkg.mod.fn')` replaces **one attribute**: a fault anywhere
173
+ * else in `pkg.mod` is still fully detectable, and excluding the file would
174
+ * turn a real verdict into `nocover` — the same silent wrong answer, pointing
175
+ * the other way.
176
+ *
177
+ * So only a patch of the module itself disqualifies a defender. An attribute
178
+ * patch leaves the file a defender and is reported as a signal, because it is
179
+ * still the cheapest available hint about where that test is blind.
180
+ */
181
+ export function pyPatches(source, target) {
182
+ const whole = [];
183
+ const attributes = [];
184
+ for (const m of source.matchAll(PATCH_STRING)) {
185
+ const path = m[2];
186
+ if (!target.dotted) continue;
187
+ if (path === target.dotted) whole.push(path);
188
+ else if (path.startsWith(target.dotted + '.')) attributes.push(path);
189
+ }
190
+ return { whole, attributes };
191
+ }
192
+
193
+ /** Does any assertion in the file mention a name bound from the target? */
194
+ function assertsOnTarget(source, bindings) {
195
+ if (bindings.length === 0) return false;
196
+ for (const line of source.split('\n')) {
197
+ if (COMMENT.test(line) || !ASSERT_LINE.test(line)) continue;
198
+ for (const b of bindings) if (new RegExp(`(^|[^\\w.])${b}(?![\\w])`).test(line)) return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ /** Local names this source binds from the target module. */
204
+ function bindingsFor(source, target, selfPackage) {
205
+ const names = new Set();
206
+ for (const line of logicalLines(source)) {
207
+ const rel = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+?)\s*$/.exec(line);
208
+ if (rel) {
209
+ const parts = selfPackage ? selfPackage.split('.') : [];
210
+ const base = rel[1].length === 0 ? rel[2] : [...parts.slice(0, Math.max(0, parts.length - (rel[1].length - 1))), ...(rel[2] ? rel[2].split('.') : [])].join('.');
211
+ const list = rel[3].replace(/[()]/g, '');
212
+ for (const piece of list.split(',')) {
213
+ const [imported, alias] = piece.trim().split(/\s+as\s+/).map((x) => x && x.trim());
214
+ if (!imported || !/^\w+$/.test(imported)) continue;
215
+ if (base === target.dotted) names.add(alias || imported); // from pkg.mod import fn
216
+ else if (`${base}.${imported}` === target.dotted) names.add(alias || imported); // from pkg import mod
217
+ }
218
+ continue;
219
+ }
220
+ const plain = /^\s*import\s+([\w.]+)(?:\s+as\s+(\w+))?\s*$/.exec(line);
221
+ if (plain && plain[1] === target.dotted) names.add(plain[2] || plain[1].split('.')[0]);
222
+ }
223
+ return [...names];
224
+ }
225
+
226
+ /**
227
+ * How one Python test file relates to one target: does it import it, does it
228
+ * replace the whole module, which attributes of it does it replace, does it
229
+ * ever assert on anything it took from it, and did the author annotate an
230
+ * unasserted patch with a reason (`# unasserted: <why>` on or above the line).
231
+ */
232
+ export function analyzePyDefender(projectDir, testRel, targetRel) {
233
+ const target = pyModuleName(projectDir, targetRel);
234
+ let source;
235
+ try {
236
+ source = readFileSync(join(projectDir, testRel), 'utf8');
237
+ } catch {
238
+ return { file: testRel, imports: false, mocks: false, asserted: false, annotation: null, patchedAttributes: [] };
239
+ }
240
+ const selfPackage = packageOf(projectDir, testRel);
241
+ const imports = pyImports(source, selfPackage).some((d) => hits(d, target));
242
+ const { whole, attributes } = pyPatches(source, target);
243
+ const bindings = bindingsFor(source, target, selfPackage);
244
+ const asserted = assertsOnTarget(source, bindings);
245
+
246
+ let annotation = null;
247
+ if (whole.length || attributes.length) {
248
+ const lines = source.split('\n');
249
+ lines.forEach((text, i) => {
250
+ if (annotation || !PATCH_LINE.test(text)) return;
251
+ for (let j = i; j >= Math.max(0, i - 2) && !annotation; j--) {
252
+ const a = UNASSERTED.exec(lines[j]);
253
+ if (a) annotation = a[1];
254
+ }
255
+ });
256
+ }
257
+ return { file: testRel, imports, mocks: whole.length > 0, asserted, annotation, patchedAttributes: attributes };
258
+ }
259
+
260
+ /**
261
+ * The Python reading of the same question, which is deliberately not the
262
+ * JavaScript one.
263
+ *
264
+ * `vi.mock('./mod')` replaces a module; `patch('pkg.mod.fn')` replaces a single
265
+ * attribute of one. Applying the JavaScript rule to Python would drop every
266
+ * test that patches anything in the target — including tests that defend it
267
+ * perfectly — and report `nocover` for a claim that is in fact well covered.
268
+ * So only a patch of the module itself removes a defender. An attribute patch
269
+ * keeps it and raises `target-attribute-patched`, which names the attributes:
270
+ * a fault in one of them is the place that test is least likely to notice.
271
+ */
272
+ export function classifyPythonDefenders(projectDir, targetRel, candidates) {
273
+ const canDetect = [];
274
+ const mocking = [];
275
+ const signals = [];
276
+ for (const file of candidates) {
277
+ const a = analyzePyDefender(projectDir, file, targetRel);
278
+ if (a.mocks) {
279
+ mocking.push(file);
280
+ if (!a.asserted) signals.push(a.annotation ? { file, signal: 'unasserted-annotated', reason: a.annotation } : { file, signal: 'mocked-never-asserted' });
281
+ } else {
282
+ canDetect.push(file);
283
+ if (a.patchedAttributes.length) signals.push({ file, signal: 'target-attribute-patched', reason: a.patchedAttributes.slice(0, 8).join(', ') });
284
+ }
285
+ }
286
+ return { canDetect, mocking, signals };
287
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import { dirname, join, resolve, extname } from 'node:path';
3
3
  import { walk } from '../util/glob.mjs';
4
+ import { pyBlastRadius } from './pyimports.mjs';
4
5
 
5
6
  const aliasCache = new Map();
6
7
 
@@ -11,8 +12,10 @@ function parseJsonc(text) {
11
12
 
12
13
  /**
13
14
  * 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.
15
+ * `baseUrl`, following a relative `extends` a few levels, and every
16
+ * `references[].path` — Vite projects keep `paths` in `tsconfig.app.json`),
17
+ * vite/vitest `resolve.alias` read as text, and package.json `imports`. Bare
18
+ * specifiers that match no rule are ignored, as documented.
16
19
  */
17
20
  export function loadAliases(projectDir) {
18
21
  if (aliasCache.has(projectDir)) return aliasCache.get(projectDir);
@@ -22,25 +25,69 @@ export function loadAliases(projectDir) {
22
25
  rules.push({ prefix, suffix, wildcard: pattern.includes('*'), targets: targets.map((t) => resolve(base, t)) });
23
26
  };
24
27
 
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++) {
28
+ // Every tsconfig in the chain: the root, what it extends, what it references.
29
+ const seen = new Set();
30
+ const queue = ['tsconfig.json', 'jsconfig.json'].map((f) => join(projectDir, f)).filter(existsSync).slice(0, 1);
31
+ while (queue.length && seen.size < 12) {
32
+ const cfgPath = queue.shift();
33
+ if (seen.has(cfgPath) || !existsSync(cfgPath)) continue;
34
+ seen.add(cfgPath);
29
35
  let cfg;
30
36
  try {
31
37
  cfg = parseJsonc(readFileSync(cfgPath, 'utf8'));
32
38
  } catch {
33
- break;
39
+ continue;
34
40
  }
35
41
  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 };
42
+ if (co.paths) {
43
+ const base = co.baseUrl !== undefined ? resolve(dirname(cfgPath), co.baseUrl) : dirname(cfgPath);
44
+ for (const [pattern, targets] of Object.entries(co.paths)) if (Array.isArray(targets)) toRule(pattern, targets, base);
45
+ }
38
46
  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;
47
+ if (ext) queue.push(resolve(dirname(cfgPath), ext.endsWith('.json') ? ext : ext + '.json'));
48
+ for (const ref of Array.isArray(cfg.references) ? cfg.references : []) {
49
+ if (typeof ref?.path !== 'string') continue;
50
+ const p = resolve(dirname(cfgPath), ref.path);
51
+ queue.push(p.endsWith('.json') ? p : join(p, 'tsconfig.json'));
52
+ }
40
53
  }
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);
54
+
55
+ // vite / vitest config: `resolve.alias` as `{ '@': path.resolve(__dirname, './src') }`,
56
+ // `{ '@': fileURLToPath(new URL('./src', import.meta.url)) }`, `{ '@': './src' }`
57
+ // or `[{ find: '@', replacement: … }]`. Read as text: the config may be TypeScript.
58
+ 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']) {
59
+ const path = join(projectDir, f);
60
+ if (!existsSync(path)) continue;
61
+ const text = readFileSync(path, 'utf8');
62
+ const start = /\balias\s*:\s*([\[{])/.exec(text);
63
+ if (!start) continue;
64
+ // Take the balanced bracket block that follows `alias:`.
65
+ const open = start.index + start[0].length - 1;
66
+ const pairs = { '{': '}', '[': ']' };
67
+ let depth = 0;
68
+ let end = -1;
69
+ for (let i = open; i < text.length; i++) {
70
+ if (text[i] === '{' || text[i] === '[' || text[i] === '(') depth++;
71
+ else if (text[i] === '}' || text[i] === ']' || text[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
72
+ }
73
+ if (end === -1) continue;
74
+ const block = text.slice(open, end + 1);
75
+ const PATHLIKE = /(['"])(\.{1,2}\/[^'"]*|src(?:\/[^'"]*)?)\1/;
76
+ let entries;
77
+ if (start[1] === '[') {
78
+ entries = [...block.matchAll(/find\s*:\s*(['"])([^'"]+)\1([\s\S]*?)(?=find\s*:|\]$)/g)].map((m) => [m[2], m[3]]);
79
+ } else {
80
+ // key: <value up to the next top-level key>; the value may contain calls with their own commas
81
+ const keys = [...block.matchAll(/(['"]?)([@#~$\w./-]+)\1\s*:(?!\/)/g)];
82
+ entries = keys.map((k, i) => [k[2], block.slice(k.index + k[0].length, keys[i + 1]?.index ?? block.length)]);
83
+ }
84
+ for (const [key, value] of entries) {
85
+ const target = PATHLIKE.exec(value)?.[2];
86
+ if (!key || !target) continue;
87
+ const abs = resolve(projectDir, target);
88
+ rules.push({ prefix: key, suffix: '', wildcard: false, targets: [abs] });
89
+ rules.push({ prefix: key.endsWith('/') ? key : key + '/', suffix: '', wildcard: true, targets: [join(abs, '*')] });
90
+ }
44
91
  }
45
92
 
46
93
  const pkgPath = join(projectDir, 'package.json');
@@ -94,6 +141,14 @@ function resolvesTo(fromFile, specifier, targetAbs, aliases) {
94
141
  return bases.some((b) => expandBase(b).includes(targetAbs));
95
142
  }
96
143
 
144
+ /** Does `specifier`, as written in `absFile`, resolve to `targetRel` (relative or alias-resolved)? */
145
+ export function specifierResolvesTo(projectDir, absFile, specifier, targetRel) {
146
+ return resolvesTo(absFile, specifier, resolve(projectDir, targetRel), loadAliases(projectDir));
147
+ }
148
+
149
+ /** Clear the alias cache (tests create and delete projects at the same paths). */
150
+ export const resetAliasCache = () => aliasCache.clear();
151
+
97
152
  /** Does the file at `absFile` import `targetRel` (relative or alias-resolved)? */
98
153
  export function fileImports(projectDir, absFile, targetRel) {
99
154
  const targetAbs = resolve(projectDir, targetRel);
@@ -114,6 +169,7 @@ export function fileImports(projectDir, absFile, targetRel) {
114
169
  * bare package specifiers are not. Direct imports only.
115
170
  */
116
171
  export function blastRadius(projectDir, targetRel) {
172
+ if (targetRel.endsWith('.py')) return pyBlastRadius(projectDir, targetRel);
117
173
  const targetAbs = resolve(projectDir, targetRel);
118
174
  const aliases = loadAliases(projectDir);
119
175
  let count = 0;
@@ -136,9 +192,13 @@ export function blastRadius(projectDir, targetRel) {
136
192
  return count;
137
193
  }
138
194
 
195
+ // A kill whose defender was written in the same change as the code carries
196
+ // less independent evidence. Ranking may read the signal; verdicts never do.
197
+ const INDEPENDENCE_WEIGHT = { 'separate-change': 1, unknown: 0.95, 'co-authored': 0.85 };
198
+
139
199
  /** 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));
200
+ export function rank({ severity, sourceKind, blast, independence }) {
201
+ const score = SEVERITY_WEIGHT[severity] * (SOURCE_WEIGHT[sourceKind] ?? 0.9) * (1 + Math.log2(1 + blast)) * (INDEPENDENCE_WEIGHT[independence] ?? 1);
142
202
  return { score: Number(score.toFixed(3)), blastRadius: blast, tier: severity };
143
203
  }
144
204
 
@@ -1,20 +1,91 @@
1
1
  import * as vitest from './vitest.mjs';
2
2
  import * as jest from './jest.mjs';
3
+ import * as playwright from './playwright.mjs';
4
+ import * as pythonRunner from './python.mjs';
3
5
 
4
- export const RUNNERS = { vitest, jest };
6
+ export const RUNNERS = {
7
+ vitest,
8
+ jest,
9
+ playwright,
10
+ python: pythonRunner,
11
+ // The same adapter with its engine pinned: `--runner pytest` fails rather
12
+ // than falling back to unittest, because which engine ran changes what the
13
+ // evidence means.
14
+ pytest: pythonRunner.pinned('pytest'),
15
+ unittest: pythonRunner.pinned('unittest'),
16
+ };
17
+
18
+ export const RUNNER_NAMES = ['vitest', 'jest', 'playwright', 'python', 'pytest', 'unittest'];
19
+
20
+ /**
21
+ * Runners that own files rather than projects: a defender under Playwright's
22
+ * testDir runs under Playwright, and a `.py` defender runs under Python,
23
+ * whatever the project runner is. That is what makes a repository holding
24
+ * both a JavaScript and a Python package probeable in one run. `auto` may
25
+ * still pick Python as the project runner when no JavaScript runner resolves;
26
+ * it never picks Playwright, which needs a config to mean anything.
27
+ */
28
+ export const OWNED_RUNNERS = [playwright, pythonRunner];
5
29
 
6
30
  /**
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 }.
31
+ * Pick the project runner: an explicit name, or the first of vitest, jest,
32
+ * python that is resolvable from the project. Returns
33
+ * { runner, version, source, engine } or { error }. `engine` is set when the
34
+ * runner has more than one (Python: pytest or unittest) and names the one
35
+ * that will actually run — it, not the module name, is what the evidence
36
+ * records.
9
37
  */
10
- export async function selectRunner({ projectDir, name = 'auto' }) {
11
- const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
12
- if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest or auto` };
38
+ export async function selectRunner({ projectDir, sourceDir, python, name = 'auto' }) {
39
+ const candidates = name === 'auto' ? [vitest, jest, pythonRunner] : [RUNNERS[name]];
40
+ if (!candidates[0]) return { error: `unknown runner "${name}"; use ${RUNNER_NAMES.join(', ')} or auto` };
13
41
  const messages = [];
14
42
  for (const r of candidates) {
15
- const c = await r.check({ projectDir });
16
- if (c.ok) return { runner: r, version: c.version };
43
+ const c = await r.check({ projectDir, sourceDir, python });
44
+ if (c.ok) return { runner: r, version: c.version, source: c.source, engine: c.engine };
17
45
  messages.push(`${r.name}: ${c.message}`);
18
46
  }
19
47
  return { error: messages.join('; ') };
20
48
  }
49
+
50
+ /** What the evidence calls a runner: the engine that actually ran, else the module's name. */
51
+ export const runnerLabel = (runner, engines) => engines?.get(runner) ?? runner.name;
52
+
53
+ /** The runner a test file runs under: an owning runner if one claims it, else the project runner. */
54
+ export function runnerFor(projectDir, file, primary) {
55
+ if (primary.owns?.(projectDir, file)) return primary;
56
+ return OWNED_RUNNERS.find((r) => r !== primary && r.owns(projectDir, file)) ?? primary;
57
+ }
58
+
59
+ /** Group files by the runner they run under, in a stable order (primary first). */
60
+ export function partitionByRunner(projectDir, files, primary) {
61
+ const groups = new Map([[primary, []]]);
62
+ for (const f of files) {
63
+ const r = runnerFor(projectDir, f, primary);
64
+ if (!groups.has(r)) groups.set(r, []);
65
+ groups.get(r).push(f);
66
+ }
67
+ if (groups.get(primary).length === 0 && groups.size > 1) groups.delete(primary);
68
+ return groups;
69
+ }
70
+
71
+ /**
72
+ * Merge the runs of several runners into one testRun, pessimistically: any
73
+ * load error is an error, any timeout is a timeout, any failure is a failure.
74
+ * Counts add; failed test ids concatenate.
75
+ */
76
+ export function mergeRuns(parts) {
77
+ if (parts.length === 1) return parts[0];
78
+ // Import provenance is per-target and never contradictory between runners:
79
+ // only the Python runner reports any, and only for its own targets.
80
+ const provenance = Object.assign({}, ...parts.map((p) => p.provenance ?? {}));
81
+ const runs = parts.map((p) => p.run);
82
+ const outcome = runs.some((r) => r.outcome === 'error') ? 'error' : runs.some((r) => r.outcome === 'timeout') ? 'timeout' : runs.some((r) => r.outcome === 'fail') ? 'fail' : 'pass';
83
+ const sum = (k) => runs.reduce((n, r) => n + (r.tests[k] ?? 0), 0);
84
+ return {
85
+ 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) },
86
+ timeouts: parts.reduce((n, p) => n + (p.timeouts ?? 0), 0),
87
+ loadMessage: parts.find((p) => p.loadMessage)?.loadMessage,
88
+ failedTests: parts.flatMap((p) => p.failedTests ?? []),
89
+ provenance,
90
+ };
91
+ }
@@ -1,9 +1,21 @@
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
+ /** The command line, as data. Exported so it is falsifiable without spawning anything. */
10
+ export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
11
+ [...runnerArgv(projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(serial ? ['--runInBand'] : []), '--runTestsByPath', ...files];
12
+ export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => argvFor(opts.projectDir, files, outFile, opts) });
13
+
14
+ /**
15
+ * The negative control's edit: content this runner's loader cannot possibly
16
+ * parse. Used to prove the defenders actually execute a file before a
17
+ * `survived` verdict about it is allowed to stand. An unterminated group is
18
+ * a syntax error at every JavaScript/TypeScript parser, whatever the file
19
+ * contained before.
20
+ */
21
+ export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';