testguard-cli 0.6.0 → 0.8.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 (45) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +287 -34
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +210 -8
  5. package/spec/README.md +32 -11
  6. package/spec/lib/validate.mjs +125 -14
  7. package/spec/lib/wilson.mjs +112 -0
  8. package/spec/schemas/baseline.schema.json +7 -7
  9. package/spec/schemas/brief.schema.json +12 -12
  10. package/spec/schemas/calibration.schema.json +96 -26
  11. package/spec/schemas/claims.schema.json +129 -25
  12. package/spec/schemas/common.schema.json +127 -29
  13. package/spec/schemas/evidence.schema.json +175 -73
  14. package/spec/schemas/gate.schema.json +12 -12
  15. package/spec/schemas/ignore.schema.json +4 -4
  16. package/spec/schemas/replay.schema.json +10 -10
  17. package/spec/schemas/status.schema.json +25 -25
  18. package/src/cli.mjs +20 -5
  19. package/src/commands/claims.mjs +22 -2
  20. package/src/commands/probe.mjs +24 -4
  21. package/src/commands/replay.mjs +1 -1
  22. package/src/gate/changed.mjs +9 -2
  23. package/src/probe/attribution.mjs +123 -0
  24. package/src/probe/classify.mjs +18 -2
  25. package/src/probe/cost.mjs +205 -0
  26. package/src/probe/discover.mjs +14 -6
  27. package/src/probe/inject.mjs +41 -9
  28. package/src/probe/mocks.mjs +2 -0
  29. package/src/probe/probe.mjs +235 -52
  30. package/src/probe/progress.mjs +87 -0
  31. package/src/probe/pyimports.mjs +287 -0
  32. package/src/probe/rank.mjs +2 -0
  33. package/src/probe/runners/index.mjs +38 -11
  34. package/src/probe/runners/jest.mjs +13 -1
  35. package/src/probe/runners/playwright.mjs +9 -0
  36. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  37. package/src/probe/runners/python/_testguard_report.py +141 -0
  38. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  39. package/src/probe/runners/python.mjs +316 -0
  40. package/src/probe/runners/shared.mjs +10 -1
  41. package/src/probe/runners/vitest.mjs +13 -1
  42. package/src/replay/replay.mjs +50 -22
  43. package/src/scaffold/producers.python.mjs +209 -0
  44. package/src/scaffold/scaffold.mjs +86 -32
  45. 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
 
@@ -168,6 +169,7 @@ export function fileImports(projectDir, absFile, targetRel) {
168
169
  * bare package specifiers are not. Direct imports only.
169
170
  */
170
171
  export function blastRadius(projectDir, targetRel) {
172
+ if (targetRel.endsWith('.py')) return pyBlastRadius(projectDir, targetRel);
171
173
  const targetAbs = resolve(projectDir, targetRel);
172
174
  const aliases = loadAliases(projectDir);
173
175
  let count = 0;
@@ -1,32 +1,55 @@
1
1
  import * as vitest from './vitest.mjs';
2
2
  import * as jest from './jest.mjs';
3
3
  import * as playwright from './playwright.mjs';
4
+ import * as pythonRunner from './python.mjs';
4
5
 
5
- export const RUNNERS = { vitest, jest, playwright };
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'];
6
19
 
7
20
  /**
8
21
  * 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.
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.
11
27
  */
12
- export const OWNED_RUNNERS = [playwright];
28
+ export const OWNED_RUNNERS = [playwright, pythonRunner];
13
29
 
14
30
  /**
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 }.
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.
17
37
  */
18
- export async function selectRunner({ projectDir, name = 'auto' }) {
19
- const candidates = name === 'auto' ? [vitest, jest] : [RUNNERS[name]];
20
- if (!candidates[0]) return { error: `unknown runner "${name}"; use vitest, jest, playwright 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` };
21
41
  const messages = [];
22
42
  for (const r of candidates) {
23
- const c = await r.check({ projectDir });
24
- if (c.ok) return { runner: r, version: c.version, source: c.source };
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 };
25
45
  messages.push(`${r.name}: ${c.message}`);
26
46
  }
27
47
  return { error: messages.join('; ') };
28
48
  }
29
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
+
30
53
  /** The runner a test file runs under: an owning runner if one claims it, else the project runner. */
31
54
  export function runnerFor(projectDir, file, primary) {
32
55
  if (primary.owns?.(projectDir, file)) return primary;
@@ -52,6 +75,9 @@ export function partitionByRunner(projectDir, files, primary) {
52
75
  */
53
76
  export function mergeRuns(parts) {
54
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 ?? {}));
55
81
  const runs = parts.map((p) => p.run);
56
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';
57
83
  const sum = (k) => runs.reduce((n, r) => n + (r.tests[k] ?? 0), 0);
@@ -60,5 +86,6 @@ export function mergeRuns(parts) {
60
86
  timeouts: parts.reduce((n, p) => n + (p.timeouts ?? 0), 0),
61
87
  loadMessage: parts.find((p) => p.loadMessage)?.loadMessage,
62
88
  failedTests: parts.flatMap((p) => p.failedTests ?? []),
89
+ provenance,
63
90
  };
64
91
  }
@@ -6,4 +6,16 @@ export const testGlobs = [...TEST_GLOBS, '**/__tests__/**/*.js', '**/__tests__/*
6
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) => [...runnerArgv(opts.projectDir, 'jest', 'jest'), '--ci', '--json', `--outputFile=${outFile}`, ...(opts.serial ? ['--runInBand'] : []), '--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';
@@ -131,3 +131,12 @@ export const run = (opts) => {
131
131
  const env = { PLAYWRIGHT_JSON_OUTPUT_FILE: '{out}', PLAYWRIGHT_JSON_OUTPUT_NAME: '{out}' };
132
132
  return runProcess({ ...opts, env, parse: parseReport, argv: (files) => [npx, 'playwright', 'test', '--reporter=json', ...(opts.serial ? ['--workers=1'] : []), ...files] });
133
133
  };
134
+
135
+ /**
136
+ * The negative control's edit: content this runner's loader cannot possibly
137
+ * parse. Used to prove the defenders actually execute a file before a
138
+ * `survived` verdict about it is allowed to stand. An unterminated group is
139
+ * a syntax error at every JavaScript/TypeScript parser, whatever the file
140
+ * contained before.
141
+ */
142
+ export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';
@@ -0,0 +1,132 @@
1
+ """
2
+ TestGuard's pytest reporter.
3
+
4
+ Loaded with `-p _testguard_pytest_plugin` from a PYTHONPATH entry inside the
5
+ tool's own package. Nothing is installed into the project under test, so
6
+ `pytest` needs no report plugin of its own and the project's dependency set is
7
+ untouched — which matters, because the suites most worth probing are often the
8
+ ones that deliberately have none.
9
+
10
+ Only long-stable hooks are used, so one file serves pytest 7, 8 and 9.
11
+ """
12
+
13
+ import os
14
+
15
+ import _testguard_report as tg
16
+
17
+ _tests = {} # nodeid -> record being assembled
18
+ _files = {} # nodeid -> absolute test file
19
+ _collection_errors = []
20
+
21
+ # `unittest.expectedFailure` on a test that passes reaches pytest as a plain
22
+ # failure whose whole report is this string; pytest's own
23
+ # `@pytest.mark.xfail` reports the same situation as a pass carrying
24
+ # `wasxfail`. Both are the suite's expectation being wrong rather than an
25
+ # assertion rejecting the fault, so both must land on `xpass` — otherwise the
26
+ # same test would be kill-eligible under pytest and not under unittest.
27
+ _UNEXPECTED_SUCCESS = "Unexpected success"
28
+
29
+ # Worst-first: a later phase never downgrades a test that already failed.
30
+ _RANK = {"failed": 4, "xpass": 3, "xfail": 2, "skipped": 1, "passed": 0}
31
+
32
+
33
+ def _rel(path):
34
+ """Project-relative, forward-slashed. pytest's rootdir may be an ancestor of the probed project."""
35
+ if not path:
36
+ return ""
37
+ try:
38
+ return os.path.relpath(path, os.getcwd()).replace(os.sep, "/")
39
+ except ValueError: # different drive on Windows
40
+ return str(path).replace(os.sep, "/")
41
+
42
+
43
+ def _record(nodeid):
44
+ if nodeid not in _tests:
45
+ file_rel = _rel(_files.get(nodeid)) or nodeid.split("::")[0]
46
+ _tests[nodeid] = {
47
+ "id": nodeid if nodeid.startswith(file_rel) else "%s::%s" % (file_rel, nodeid),
48
+ "file": file_rel,
49
+ "name": nodeid.split("::", 1)[1] if "::" in nodeid else nodeid,
50
+ "outcome": "passed",
51
+ "phase": "call",
52
+ "exceptionType": None,
53
+ "message": None,
54
+ }
55
+ return _tests[nodeid]
56
+
57
+
58
+ def _apply(record, outcome, phase, message, exception_type):
59
+ if _RANK[outcome] < _RANK[record["outcome"]]:
60
+ return
61
+ record["outcome"] = outcome
62
+ record["phase"] = phase
63
+ if message is not None:
64
+ record["message"] = tg.clip(message)
65
+ if exception_type is not None:
66
+ record["exceptionType"] = exception_type
67
+
68
+
69
+ def pytest_collection_modifyitems(items):
70
+ """Absolute path per test, taken while the items still carry it."""
71
+ for item in items:
72
+ path = getattr(item, "path", None) or getattr(item, "fspath", None)
73
+ if path is not None:
74
+ _files[item.nodeid] = str(path)
75
+
76
+
77
+ def pytest_exception_interact(node, call, report):
78
+ """
79
+ The exception's class name, which no report object carries. Recorded but
80
+ never allowed to change a verdict: an assertion failure and a TypeError
81
+ raised by the test body are both the suite rejecting the behaviour, and the
82
+ JavaScript runners already treat them alike. It is reported because it is
83
+ the cheapest thing to look at when a kill turns out to be the wrong kind.
84
+
85
+ pytest calls this hook *after* `pytest_runtest_logreport` for the same
86
+ phase, so the record already exists and is amended in place rather than
87
+ read from a map that would still be empty.
88
+ """
89
+ name = getattr(getattr(call, "excinfo", None), "typename", None)
90
+ nodeid = getattr(report, "nodeid", "") or getattr(node, "nodeid", "")
91
+ if name and nodeid in _tests:
92
+ _tests[nodeid]["exceptionType"] = name
93
+
94
+
95
+ def pytest_collectreport(report):
96
+ """
97
+ A module that will not import. This is the load failure: it is what a fault
98
+ whose replacement does not parse looks like from here, and it must never be
99
+ mistaken for the tests rejecting the fault.
100
+ """
101
+ if getattr(report, "failed", False):
102
+ _collection_errors.append({
103
+ "file": _rel(str(getattr(report, "fspath", "") or "")) or report.nodeid,
104
+ "message": tg.clip(report.longrepr),
105
+ })
106
+
107
+
108
+ def pytest_runtest_logreport(report):
109
+ phase = getattr(report, "when", "call")
110
+ record = _record(report.nodeid)
111
+ exception_type = None
112
+ message = tg.clip(report.longrepr) if report.longrepr is not None else None
113
+
114
+ if report.failed:
115
+ unexpected_success = isinstance(message, str) and message.startswith(_UNEXPECTED_SUCCESS)
116
+ _apply(record, "xpass" if unexpected_success else "failed", phase, message, exception_type)
117
+ elif report.skipped:
118
+ # pytest reports an xfail as a skip carrying `wasxfail`.
119
+ _apply(record, "xfail" if hasattr(report, "wasxfail") else "skipped", phase, message, exception_type)
120
+ elif report.passed and hasattr(report, "wasxfail"):
121
+ # A test expected to fail that passed: the suite's own expectation is
122
+ # wrong, so the run is not green — but nothing asserted anything about
123
+ # the fault, so it can never be a kill.
124
+ _apply(record, "xpass", phase, message, exception_type)
125
+
126
+
127
+ def pytest_internalerror(excrepr):
128
+ _collection_errors.append({"file": "", "message": tg.clip(excrepr)})
129
+
130
+
131
+ def pytest_sessionfinish(session, exitstatus):
132
+ tg.write("pytest", os.getcwd(), list(_tests.values()), _collection_errors)