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.
- package/CHANGELOG.md +628 -0
- package/README.md +536 -42
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +166 -2
- package/spec/README.md +29 -10
- package/spec/lib/validate.mjs +140 -8
- package/spec/schemas/baseline.schema.json +13 -5
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +131 -29
- package/spec/schemas/evidence.schema.json +476 -69
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +85 -0
- package/spec/schemas/status.schema.json +258 -56
- package/src/admit/admit.mjs +17 -0
- package/src/baseline/baseline.mjs +31 -2
- package/src/brief/brief.mjs +71 -7
- package/src/claims/removed.mjs +161 -0
- package/src/cli.mjs +68 -10
- package/src/commands/admit.mjs +116 -0
- package/src/commands/baseline.mjs +17 -3
- package/src/commands/brief.mjs +13 -7
- package/src/commands/claims.mjs +55 -6
- package/src/commands/init.mjs +19 -6
- package/src/commands/mcp.mjs +12 -0
- package/src/commands/probe.mjs +35 -7
- package/src/commands/replay.mjs +63 -0
- package/src/commands/status.mjs +3 -1
- package/src/gate/changed.mjs +9 -2
- package/src/git.mjs +5 -0
- package/src/init/init.mjs +182 -29
- package/src/init/templates/SKILL.md +3 -3
- package/src/mcp/server.mjs +104 -0
- package/src/mcp/tools.mjs +114 -0
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/contention.mjs +60 -0
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +20 -3
- package/src/probe/independence.mjs +59 -0
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +122 -0
- package/src/probe/probe.mjs +331 -40
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +75 -15
- package/src/probe/runners/index.mjs +79 -8
- package/src/probe/runners/jest.mjs +15 -3
- package/src/probe/runners/playwright.mjs +142 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +80 -14
- package/src/probe/runners/vitest.mjs +16 -3
- package/src/render.mjs +2 -2
- package/src/replay/label.mjs +64 -0
- package/src/replay/replay.mjs +310 -0
- package/src/scaffold/producers.mjs +217 -6
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +90 -30
- package/src/status/status.mjs +47 -6
- 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
|
+
}
|
package/src/probe/rank.mjs
CHANGED
|
@@ -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
|
|
15
|
-
* `
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
39
|
+
continue;
|
|
34
40
|
}
|
|
35
41
|
const co = cfg.compilerOptions ?? {};
|
|
36
|
-
if (
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 = {
|
|
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
|
|
8
|
-
* resolvable from the project. Returns
|
|
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
|
|
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,
|
|
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) =>
|
|
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
|
-
|
|
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';
|