testguard-cli 0.6.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 (43) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +277 -27
  3. package/package.json +2 -2
  4. package/spec/GATE-SEMANTICS.md +112 -5
  5. package/spec/README.md +28 -10
  6. package/spec/lib/validate.mjs +84 -8
  7. package/spec/schemas/baseline.schema.json +7 -7
  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 +127 -29
  12. package/spec/schemas/evidence.schema.json +169 -73
  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 +10 -10
  16. package/spec/schemas/status.schema.json +25 -25
  17. package/src/cli.mjs +20 -5
  18. package/src/commands/claims.mjs +22 -2
  19. package/src/commands/probe.mjs +24 -4
  20. package/src/gate/changed.mjs +9 -2
  21. package/src/probe/attribution.mjs +123 -0
  22. package/src/probe/classify.mjs +18 -2
  23. package/src/probe/cost.mjs +145 -0
  24. package/src/probe/discover.mjs +14 -6
  25. package/src/probe/inject.mjs +41 -9
  26. package/src/probe/mocks.mjs +2 -0
  27. package/src/probe/probe.mjs +235 -52
  28. package/src/probe/progress.mjs +87 -0
  29. package/src/probe/pyimports.mjs +287 -0
  30. package/src/probe/rank.mjs +2 -0
  31. package/src/probe/runners/index.mjs +38 -11
  32. package/src/probe/runners/jest.mjs +13 -1
  33. package/src/probe/runners/playwright.mjs +9 -0
  34. package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
  35. package/src/probe/runners/python/_testguard_report.py +141 -0
  36. package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
  37. package/src/probe/runners/python.mjs +316 -0
  38. package/src/probe/runners/shared.mjs +10 -1
  39. package/src/probe/runners/vitest.mjs +13 -1
  40. package/src/replay/replay.mjs +3 -2
  41. package/src/scaffold/producers.python.mjs +209 -0
  42. package/src/scaffold/scaffold.mjs +86 -32
  43. package/src/util/glob.mjs +7 -1
@@ -0,0 +1,141 @@
1
+ """
2
+ Shared report writing for TestGuard's two Python reporters.
3
+
4
+ TestGuard never installs anything into the project under test. Both reporters
5
+ are put on PYTHONPATH from the tool's own package and loaded from there, so a
6
+ project with zero test dependencies stays a project with zero test
7
+ dependencies.
8
+
9
+ The report is TestGuard's own shape, not jest's. `unittest` and `pytest` both
10
+ distinguish an assertion failure from a collection/import error natively, and
11
+ that distinction is the whole point of the adapter: flattening it into a
12
+ foreign shape would throw away the best signal Python offers and move the
13
+ verdict semantics out of the tested JavaScript and into here.
14
+
15
+ Shape (schemaVersion 1):
16
+
17
+ {
18
+ "testguard": 1,
19
+ "runner": "pytest" | "unittest",
20
+ "rootdir": "<absolute dir the run started from>",
21
+ "collectionErrors": [{"file": str, "message": str}],
22
+ "tests": [{"id": "<file>::<name>", "file": str, "name": str,
23
+ "outcome": "passed"|"failed"|"skipped"|"xfail"|"xpass",
24
+ "phase": "setup"|"call"|"teardown",
25
+ "exceptionType": str|null, "message": str|null}],
26
+ "provenance": {"<relative target path>": "<absolute path actually
27
+ imported>" | null}
28
+ }
29
+
30
+ `provenance` is the Python-specific precondition. A scratch git worktree
31
+ isolates Node for free, because Node loads files by path. Python does not: an
32
+ editable install (PEP 660 puts an import hook ahead of every path entry) or a
33
+ site-packages copy means the interpreter loads the ORIGINAL file while
34
+ TestGuard has faulted the copy. The baseline stays green, every fault
35
+ SURVIVES, and the result is a confident lie. So every reporter reports which
36
+ file the interpreter actually loaded, and the JavaScript side refuses to
37
+ proceed when that is not the file it faulted.
38
+ """
39
+
40
+ import json
41
+ import os
42
+ import sys
43
+
44
+ ENV_OUT = "TESTGUARD_REPORT"
45
+ ENV_TARGETS = "TESTGUARD_TARGETS"
46
+
47
+ MAX_MESSAGE = 4000
48
+
49
+
50
+ def clip(text):
51
+ """One bounded string. A pytest longrepr can be megabytes; the report is read whole."""
52
+ if text is None:
53
+ return None
54
+ s = str(text)
55
+ return s if len(s) <= MAX_MESSAGE else s[:MAX_MESSAGE] + "\n… (truncated)"
56
+
57
+
58
+ def targets():
59
+ """Fault target files (project-relative) whose import provenance must be proved."""
60
+ raw = os.environ.get(ENV_TARGETS)
61
+ if not raw:
62
+ return []
63
+ try:
64
+ value = json.loads(raw)
65
+ except ValueError:
66
+ return []
67
+ return [t for t in value if isinstance(t, str)] if isinstance(value, list) else []
68
+
69
+
70
+ def module_tail(rootdir, rel):
71
+ """
72
+ The trailing path that identifies `rel` as a module wherever it is imported
73
+ from: its basename preceded by every parent directory that is a package
74
+ (`__init__.py`). `src/websec/core.py` with `src/websec/__init__.py` present
75
+ and no `src/__init__.py` gives `websec/core.py` — which is what a
76
+ site-packages copy of the same module would also end with.
77
+ """
78
+ parts = [os.path.basename(rel)]
79
+ directory = os.path.dirname(os.path.join(rootdir, rel))
80
+ # Bounded: a package nesting deeper than this is pathological, and an
81
+ # unbounded walk on a malformed tree would climb to the filesystem root.
82
+ for _ in range(32):
83
+ if not os.path.isfile(os.path.join(directory, "__init__.py")):
84
+ break
85
+ parts.insert(0, os.path.basename(directory))
86
+ parent = os.path.dirname(directory)
87
+ if parent == directory:
88
+ break
89
+ directory = parent
90
+ return os.path.join(*parts)
91
+
92
+
93
+ def provenance(rootdir):
94
+ """
95
+ For each target: the absolute file the interpreter actually loaded for it,
96
+ or None when no module with that tail was loaded at all.
97
+
98
+ Read from `sys.modules` after the tests have run, so it reflects the real
99
+ `sys.path` the suite ran under — including anything a `conftest.py`, a
100
+ `pyproject.toml` `pythonpath`, or an editable-install hook did to it. A
101
+ separate `find_spec` subprocess could not see any of that.
102
+ """
103
+ out = {}
104
+ loaded = []
105
+ for module in list(sys.modules.values()):
106
+ path = getattr(module, "__file__", None)
107
+ if not path:
108
+ continue
109
+ try:
110
+ loaded.append(os.path.realpath(path))
111
+ except OSError:
112
+ continue
113
+ for rel in targets():
114
+ tail = module_tail(rootdir, rel)
115
+ match = None
116
+ for path in loaded:
117
+ if path == tail or path.endswith(os.sep + tail):
118
+ match = path
119
+ break
120
+ out[rel] = match
121
+ return out
122
+
123
+
124
+ def write(runner, rootdir, tests, collection_errors):
125
+ """Write the report where TestGuard asked for it. Never to stdout: a test may print anything."""
126
+ out_path = os.environ.get(ENV_OUT)
127
+ if not out_path:
128
+ return
129
+ document = {
130
+ "testguard": 1,
131
+ "runner": runner,
132
+ "rootdir": rootdir,
133
+ "collectionErrors": collection_errors,
134
+ "tests": tests,
135
+ "provenance": provenance(rootdir),
136
+ }
137
+ directory = os.path.dirname(out_path)
138
+ if directory and not os.path.isdir(directory):
139
+ os.makedirs(directory, exist_ok=True)
140
+ with open(out_path, "w", encoding="utf-8") as handle:
141
+ json.dump(document, handle)
@@ -0,0 +1,168 @@
1
+ """
2
+ TestGuard's stdlib `unittest` runner.
3
+
4
+ Run as `python -m _testguard_unittest_main <test files…>` from a PYTHONPATH
5
+ entry inside the tool's own package. It needs nothing installed anywhere: a
6
+ project whose test dependencies are the standard library stays that way, which
7
+ is the whole reason this file exists rather than a pytest plugin requirement.
8
+
9
+ Two deliberate departures from `python -m unittest`:
10
+
11
+ * Test modules are imported here, not by the loader. `unittest` turns an
12
+ import failure into a synthetic `_FailedTest` that *errors at run time* —
13
+ indistinguishable, from the outside, from a test that ran and failed. That
14
+ distinction is the difference between "your tests caught the fault" and
15
+ "the fault stopped the suite from loading", so it is made explicitly.
16
+
17
+ * Nothing is written to stdout or stderr. `unittest`'s own runner prints its
18
+ summary to stderr, where it interleaves with whatever the tests print —
19
+ the exact trap that makes hand-rolled harnesses report a suite as green
20
+ when it is not. The result is a JSON file or nothing at all.
21
+ """
22
+
23
+ import importlib
24
+ import os
25
+ import sys
26
+ import traceback
27
+ import unittest
28
+
29
+ import _testguard_report as tg
30
+
31
+
32
+ def _module_for(path):
33
+ """
34
+ `(top_level_dir, dotted_name)` for a test file, the way `unittest discover`
35
+ would resolve it: walk up while the directory is a package, and the first
36
+ directory that is not becomes the import root.
37
+ """
38
+ absolute = os.path.abspath(path)
39
+ parts = [os.path.splitext(os.path.basename(absolute))[0]]
40
+ directory = os.path.dirname(absolute)
41
+ for _ in range(32):
42
+ if not os.path.isfile(os.path.join(directory, "__init__.py")):
43
+ break
44
+ parts.insert(0, os.path.basename(directory))
45
+ parent = os.path.dirname(directory)
46
+ if parent == directory:
47
+ break
48
+ directory = parent
49
+ return directory, ".".join(parts)
50
+
51
+
52
+ def _rel(path):
53
+ if not path:
54
+ return ""
55
+ try:
56
+ return os.path.relpath(path, os.getcwd()).replace(os.sep, "/")
57
+ except ValueError:
58
+ return str(path).replace(os.sep, "/")
59
+
60
+
61
+ def _file_of(test):
62
+ module = sys.modules.get(getattr(test, "__module__", "") or getattr(type(test), "__module__", ""))
63
+ return _rel(getattr(module, "__file__", None))
64
+
65
+
66
+ class Result(unittest.TestResult):
67
+ """
68
+ Every outcome `unittest` can produce, kept apart.
69
+
70
+ `addFailure` (an assertion rejected the behaviour) and `addError` (the test
71
+ body raised something else) are both recorded as `failed`: the JavaScript
72
+ runners already treat a thrown TypeError in a test body as the suite
73
+ noticing, and a Python adapter that disagreed would make the same fault
74
+ kill under jest and survive under unittest. The exception class is reported
75
+ alongside so the difference is never lost, only kept out of the verdict.
76
+ """
77
+
78
+ def __init__(self):
79
+ super().__init__()
80
+ self.records = {}
81
+
82
+ def _record(self, test):
83
+ key = test.id()
84
+ if key not in self.records:
85
+ file_rel = _file_of(test)
86
+ name = key
87
+ module_name = getattr(test, "__module__", "")
88
+ if module_name and name.startswith(module_name + "."):
89
+ name = name[len(module_name) + 1:]
90
+ self.records[key] = {
91
+ "id": "%s::%s" % (file_rel or "", name),
92
+ "file": file_rel,
93
+ "name": name,
94
+ "outcome": "passed",
95
+ "phase": "call",
96
+ "exceptionType": None,
97
+ "message": None,
98
+ }
99
+ return self.records[key]
100
+
101
+ def _fail(self, test, err, outcome="failed"):
102
+ record = self._record(test)
103
+ record["outcome"] = outcome
104
+ record["exceptionType"] = err[0].__name__ if err and err[0] else None
105
+ record["message"] = tg.clip("".join(traceback.format_exception(*err))) if err else None
106
+
107
+ def startTest(self, test):
108
+ super().startTest(test)
109
+ self._record(test)
110
+
111
+ def addFailure(self, test, err):
112
+ super().addFailure(test, err)
113
+ self._fail(test, err)
114
+
115
+ def addError(self, test, err):
116
+ super().addError(test, err)
117
+ self._fail(test, err)
118
+
119
+ def addSubTest(self, test, subtest, err):
120
+ super().addSubTest(test, subtest, err)
121
+ if err is not None:
122
+ self._fail(test, err)
123
+
124
+ def addSkip(self, test, reason):
125
+ super().addSkip(test, reason)
126
+ record = self._record(test)
127
+ record["outcome"] = "skipped"
128
+ record["message"] = tg.clip(reason)
129
+
130
+ def addExpectedFailure(self, test, err):
131
+ super().addExpectedFailure(test, err)
132
+ self._fail(test, err, outcome="xfail")
133
+
134
+ def addUnexpectedSuccess(self, test):
135
+ super().addUnexpectedSuccess(test)
136
+ self._record(test)["outcome"] = "xpass"
137
+
138
+
139
+ def main(argv):
140
+ collection_errors = []
141
+ loader = unittest.defaultTestLoader
142
+ suite = unittest.TestSuite()
143
+
144
+ if os.getcwd() not in sys.path:
145
+ sys.path.insert(0, os.getcwd())
146
+
147
+ for path in argv:
148
+ top_level, dotted = _module_for(path)
149
+ if top_level not in sys.path:
150
+ sys.path.insert(0, top_level)
151
+ try:
152
+ module = importlib.import_module(dotted)
153
+ except BaseException: # SyntaxError and SystemExit included: both are load failures, not verdicts
154
+ collection_errors.append({"file": _rel(path), "message": tg.clip(traceback.format_exc())})
155
+ continue
156
+ try:
157
+ suite.addTests(loader.loadTestsFromModule(module))
158
+ except BaseException:
159
+ collection_errors.append({"file": _rel(path), "message": tg.clip(traceback.format_exc())})
160
+
161
+ result = Result()
162
+ suite.run(result)
163
+ tg.write("unittest", os.getcwd(), list(result.records.values()), collection_errors)
164
+ return 0 if result.wasSuccessful() and not collection_errors else 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,316 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { runProcess, listTestFiles, firstInformativeLine } from './shared.mjs';
6
+
7
+ /**
8
+ * The Python runner. Selected **per file**: any `.py` defender runs here
9
+ * whatever the project runner is, which is what makes a repository holding
10
+ * both a JavaScript and a Python package probeable in one run. Selected for
11
+ * the whole project by `--runner python|pytest|unittest`, or by `auto` when
12
+ * neither vitest nor jest resolves.
13
+ *
14
+ * Two engines, one adapter. `pytest` when the project's interpreter can import
15
+ * it, stdlib `unittest` otherwise — and `unittest` is not a fallback for the
16
+ * poor: it is the only engine that runs on a project whose test dependencies
17
+ * are the standard library, which is a deliberate choice in exactly the
18
+ * codebases most worth probing. The engine that ran is what the evidence
19
+ * records as `runner.name`; TestGuard never says pytest ran when it did not.
20
+ *
21
+ * Neither engine is installed into the project. The reporters live in
22
+ * `runners/python/` inside this package and reach the interpreter through
23
+ * PYTHONPATH — `-p _testguard_pytest_plugin` for pytest, `-m
24
+ * _testguard_unittest_main` for unittest. The project's dependency set is
25
+ * never touched.
26
+ *
27
+ * Report protocol: TestGuard's own JSON shape (see `python/_testguard_report.py`),
28
+ * written to a file named by `TESTGUARD_REPORT`, never parsed from stdout —
29
+ * `unittest` writes its summary to stderr where it interleaves with whatever
30
+ * the tests print, and reading that stream is how hand-rolled harnesses come
31
+ * to report a red suite as green.
32
+ *
33
+ * Python outcomes map onto the spec's testRun as follows:
34
+ *
35
+ * passed → passed
36
+ * failed → failed, counts toward assertionFailures (can kill).
37
+ * An `AssertionError` and a `TypeError` raised by the
38
+ * test body are alike here, as they are under jest:
39
+ * both are the suite rejecting the behaviour. The
40
+ * exception class is reported, never scored.
41
+ * failed, timed out → failed, counts toward timeouts (never a kill)
42
+ * xfail → neither passed nor failed
43
+ * xpass → the suite's own expectation is wrong, so the run is
44
+ * NOT green — counted as failed with no assertion
45
+ * failure, so it reaches FLAKY-DEFENDER through the
46
+ * ordinary N-run rules rather than killing anything
47
+ * skipped → neither passed nor failed
48
+ * collection error → the suite could not load: `error`, never a kill.
49
+ * This is what a fault whose replacement does not
50
+ * parse looks like from Python, and it is how
51
+ * `fault-invalid` is reached.
52
+ */
53
+
54
+ const HERE = dirname(fileURLToPath(import.meta.url));
55
+ export const PYTHON_DIR = join(HERE, 'python');
56
+
57
+ export const name = 'python';
58
+
59
+ /**
60
+ * pytest's default `python_files`, plus unittest's default discovery pattern
61
+ * (`test*.py`). A helper named `testutils.py` matches and collects nothing,
62
+ * which costs a little; a test file that did not match would be invisible to
63
+ * discovery and report `nocover`, which costs the truth.
64
+ */
65
+ export const testGlobs = ['**/test_*.py', '**/test*.py', '**/*_test.py'];
66
+
67
+ const PY_FILE = /\.py$/;
68
+ const TIMEOUT = /\bTimeout >|\btimed out\b|\bTimeoutError\b/i;
69
+
70
+ /**
71
+ * Interpreters to try, best first: the active virtualenv, the project's own,
72
+ * then PATH.
73
+ *
74
+ * An explicit `--python` is NOT one of these. It is the only candidate, and it
75
+ * is returned alone: an interpreter the user named and TestGuard quietly
76
+ * replaced with another would change which dependencies the tests ran against
77
+ * without changing anything the evidence says.
78
+ */
79
+ function candidates(projectDir, python) {
80
+ const out = [];
81
+ const win = process.platform === 'win32';
82
+ const binOf = (root) => (win ? join(root, 'Scripts', 'python.exe') : join(root, 'bin', 'python'));
83
+ const explicit = python ?? process.env.TESTGUARD_PYTHON;
84
+ if (explicit) return [{ path: explicit, source: 'project', explicit: true }];
85
+ if (process.env.VIRTUAL_ENV) out.push({ path: binOf(process.env.VIRTUAL_ENV), source: 'project' });
86
+ for (const dir of ['.venv', 'venv', '.env']) out.push({ path: binOf(join(projectDir, dir)), source: 'project' });
87
+ out.push({ path: win ? 'python.exe' : 'python3', source: 'path' });
88
+ out.push({ path: win ? 'py.exe' : 'python', source: 'path' });
89
+ return out;
90
+ }
91
+
92
+ const PROBE = [
93
+ 'import json,sys',
94
+ 'v=".".join(map(str,sys.version_info[:3]))',
95
+ 'try:',
96
+ ' import pytest; p=pytest.__version__',
97
+ 'except Exception: p=None',
98
+ 'print(json.dumps({"python":v,"pytest":p}))',
99
+ ].join('\n');
100
+
101
+ const interpreters = new Map(); // projectDir -> resolution promise
102
+
103
+ /** Forget resolved interpreters (tests create and delete projects at the same paths). */
104
+ export const resetInterpreterCache = () => interpreters.clear();
105
+
106
+ /**
107
+ * Ask an interpreter what it is and whether it can import pytest.
108
+ *
109
+ * Deliberately NOT run with `-I`. Isolated mode ignores `PYTHONPATH` and the
110
+ * user site directory, so it would answer "no pytest" for an interpreter that
111
+ * will import pytest perfectly well during the actual run — and TestGuard
112
+ * would silently choose the stdlib engine for a suite written in pytest, which
113
+ * then collects nothing. The check has to see what the run will see.
114
+ */
115
+ function ask(path, budgetMs) {
116
+ return new Promise((resolve) => {
117
+ let child;
118
+ try {
119
+ child = spawn(path, ['-c', PROBE], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, CI: '1' } });
120
+ } catch {
121
+ return resolve(null);
122
+ }
123
+ let out = '';
124
+ child.on('error', () => resolve(null));
125
+ child.stdout.on('data', (d) => (out += d));
126
+ child.stderr.on('data', () => {});
127
+ const timer = setTimeout(() => child.kill('SIGKILL'), budgetMs);
128
+ child.on('close', (code) => {
129
+ clearTimeout(timer);
130
+ if (code !== 0) return resolve(null);
131
+ try {
132
+ resolve(JSON.parse(out.trim().split('\n').pop()));
133
+ } catch {
134
+ resolve(null);
135
+ }
136
+ });
137
+ });
138
+ }
139
+
140
+ /**
141
+ * The interpreter this project's tests run under, and whether it can import
142
+ * pytest. Resolved against the **original** project directory, never the
143
+ * scratch worktree: a virtualenv is gitignored, so it is not in the worktree
144
+ * checkout, and it is referenced by absolute path the way virtualenvs are
145
+ * meant to be — never copied and never symlinked, which would break the
146
+ * `pyvenv.cfg` prefix detection that makes one work at all.
147
+ */
148
+ export function resolveInterpreter({ projectDir, python, budgetMs = 30_000 }) {
149
+ const key = `${projectDir}\0${python ?? ''}`;
150
+ if (interpreters.has(key)) return interpreters.get(key);
151
+ const task = (async () => {
152
+ const tried = [];
153
+ for (const candidate of candidates(projectDir, python)) {
154
+ if (!candidate.explicit && candidate.source === 'project' && !existsSync(candidate.path)) continue;
155
+ const answer = await ask(candidate.path, budgetMs);
156
+ if (answer) return { ...candidate, ...answer };
157
+ tried.push(candidate.path);
158
+ }
159
+ const explicit = python ?? process.env.TESTGUARD_PYTHON;
160
+ return { error: explicit
161
+ ? `${explicit} is not a usable Python interpreter`
162
+ : `no usable Python interpreter (tried ${tried.join(', ') || 'nothing'}); install Python 3.8+, or point --python / TESTGUARD_PYTHON at one` };
163
+ })();
164
+ interpreters.set(key, task);
165
+ return task;
166
+ }
167
+
168
+ /**
169
+ * Resolvable = an interpreter answers, and — when an engine is pinned — that
170
+ * engine is available. `--runner pytest` on a machine that has no pytest is a
171
+ * precondition failure, never a silent fall back to unittest: which engine ran
172
+ * changes what the evidence means.
173
+ */
174
+ /**
175
+ * Which engine runs, given what the interpreter answered.
176
+ *
177
+ * Pure, and separate from the spawn, because the interesting branch is the one
178
+ * that is hard to stage: an interpreter that resolves but cannot import
179
+ * pytest. Testing that through `check()` would mean building a bare virtualenv
180
+ * inside a unit test, so the decision is lifted out and the spawn is left with
181
+ * nothing to decide.
182
+ */
183
+ export function chooseEngine(pinned, found) {
184
+ const engine = pinned ?? (found.pytest ? 'pytest' : 'unittest');
185
+ if (engine === 'pytest' && !found.pytest) {
186
+ return { ok: false, message: `pytest is not importable from ${found.path} (pip install pytest, or use --runner unittest for the stdlib runner)` };
187
+ }
188
+ return { ok: true, engine, version: engine === 'pytest' ? found.pytest : `CPython ${found.python}` };
189
+ }
190
+
191
+ export function makeCheck(pinned) {
192
+ return async ({ projectDir, sourceDir, python, budgetMs = 30_000 }) => {
193
+ // Always the ORIGINAL project directory when there is one: a virtualenv is
194
+ // gitignored, so the scratch worktree does not contain it.
195
+ const found = await resolveInterpreter({ projectDir: sourceDir ?? projectDir, python, budgetMs });
196
+ if (found.error) return { ok: false, message: found.error };
197
+ const chosen = chooseEngine(pinned, found);
198
+ if (!chosen.ok) return chosen;
199
+ return { ...chosen, interpreter: found.path, source: found.source };
200
+ };
201
+ }
202
+
203
+ /** Any `.py` file. A Python test can never run under vitest, so ownership needs no configuration to read. */
204
+ export const owns = (projectDir, file) => PY_FILE.test(file);
205
+
206
+ export const tests = (projectDir) => listTestFiles(projectDir, testGlobs).filter((f) => owns(projectDir, f));
207
+
208
+ /**
209
+ * TestGuard's Python report → the spec's testRun. Pure; exported for the
210
+ * sample-driven tests. `provenance` rides along for the import check that
211
+ * `probe` performs once the fault is live; it is not part of the testRun.
212
+ */
213
+ export function parseReport(report, durationMs) {
214
+ const all = report.tests ?? [];
215
+ const collectionErrors = report.collectionErrors ?? [];
216
+ const failed = all.filter((t) => t.outcome === 'failed');
217
+ const timedOut = failed.filter((t) => TIMEOUT.test(t.message ?? ''));
218
+ const xpass = all.filter((t) => t.outcome === 'xpass');
219
+ const passed = all.filter((t) => t.outcome === 'passed').length;
220
+ const total = all.length;
221
+ const summary = { total, passed, failed: failed.length + xpass.length };
222
+
223
+ let outcome;
224
+ if (collectionErrors.length > 0 || total === 0) outcome = 'error';
225
+ else if (failed.length > 0 || xpass.length > 0) outcome = 'fail';
226
+ else outcome = 'pass';
227
+
228
+ const loadMessage = outcome === 'error'
229
+ ? (collectionErrors.length
230
+ ? firstInformativeLine(collectionErrors.map((e) => e.message ?? '').join('\n'))
231
+ : 'python collected no tests')
232
+ : undefined;
233
+
234
+ return {
235
+ run: { outcome, tests: summary, assertionFailures: failed.length - timedOut.length, durationMs },
236
+ timeouts: timedOut.length,
237
+ loadMessage,
238
+ failedTests: [...failed, ...xpass].map((t) => t.id),
239
+ provenance: report.provenance ?? {},
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Environment for one run. Three things matter beyond finding the reporter:
245
+ *
246
+ * PYTHONDONTWRITEBYTECODE + PYTHONPYCACHEPREFIX — `.pyc` files are validated
247
+ * by the source's mtime **in whole seconds** and its size. A fault that
248
+ * preserves the byte length and is applied within the same second as the
249
+ * file it replaces can therefore be served from a stale cache and never
250
+ * execute: a green run that proves nothing. The prefix moves every cache
251
+ * lookup out of the tree, so an `--in-place` run cannot read the user's
252
+ * existing `__pycache__` either, and DONTWRITEBYTECODE means the prefix
253
+ * directory is never actually created.
254
+ *
255
+ * TESTGUARD_TARGETS — the fault's file, so the reporter can say which file
256
+ * the interpreter really loaded for it.
257
+ */
258
+ export function environment(targets) {
259
+ const existing = process.env.PYTHONPATH;
260
+ return {
261
+ PYTHONPATH: existing ? `${PYTHON_DIR}${process.platform === 'win32' ? ';' : ':'}${existing}` : PYTHON_DIR,
262
+ PYTHONDONTWRITEBYTECODE: '1',
263
+ PYTHONPYCACHEPREFIX: '{out}.pycache',
264
+ PYTHONUNBUFFERED: '1',
265
+ TESTGUARD_REPORT: '{out}',
266
+ TESTGUARD_TARGETS: JSON.stringify(targets ?? []),
267
+ };
268
+ }
269
+
270
+ /**
271
+ * `--maxfail=0` is appended on every pytest run, and is not optional: a
272
+ * project whose `addopts` carries `-x` would otherwise stop at the first
273
+ * failure. The verdict would survive that (one assertion failure is still a
274
+ * kill) but the list of failing tests would not, and that list is what names
275
+ * an undeclared killer during escalation. `0` means no limit and overrides
276
+ * the configured value; the rest of the project's `addopts` is left alone,
277
+ * because TestGuard runs a project's tests the way the project runs them.
278
+ */
279
+ export function argvFor({ engine, interpreter, files, serial }) {
280
+ if (engine === 'pytest') {
281
+ return [interpreter, '-m', 'pytest', '-p', '_testguard_pytest_plugin', '-p', 'no:cacheprovider',
282
+ '--maxfail=0', '-q', ...(serial ? ['-p', 'no:xdist'] : []), ...files];
283
+ }
284
+ return [interpreter, '-m', '_testguard_unittest_main', ...files];
285
+ }
286
+
287
+ export function makeRun(pinned) {
288
+ const check = makeCheck(pinned);
289
+ return async (opts) => {
290
+ const resolved = await check({ projectDir: opts.sourceDir ?? opts.projectDir, python: opts.python });
291
+ if (!resolved.ok) {
292
+ return { run: { outcome: 'error', tests: { total: 0, passed: 0, failed: 0 }, assertionFailures: 0, durationMs: 0 }, timeouts: 0, loadMessage: resolved.message, failedTests: [], provenance: {} };
293
+ }
294
+ return runProcess({
295
+ ...opts,
296
+ env: environment(opts.targets),
297
+ parse: parseReport,
298
+ argv: (files) => argvFor({ engine: resolved.engine, interpreter: resolved.interpreter, files, serial: opts.serial }),
299
+ });
300
+ };
301
+ }
302
+
303
+ export const check = makeCheck(null);
304
+ export const run = makeRun(null);
305
+
306
+ /** `--runner pytest` / `--runner unittest`: the same adapter with the engine pinned. */
307
+ export function pinned(engine) {
308
+ return { name: engine, testGlobs, owns, tests, parseReport, fatalEdit, check: makeCheck(engine), run: makeRun(engine) };
309
+ }
310
+
311
+ /**
312
+ * The negative control's edit, for Python: an unterminated group, which is a
313
+ * `SyntaxError` at import time whatever the module contained. See the
314
+ * JavaScript runners for why this exists.
315
+ */
316
+ export const fatalEdit = () => '# testguard negative control: this file must not parse\n(\n';
@@ -136,6 +136,13 @@ export function firstInformativeLine(message) {
136
136
  }
137
137
 
138
138
  export function parseReport(report, durationMs) {
139
+ // A report that parsed as JSON but carries no `testResults` is not a suite
140
+ // that ran zero tests — it is a report of a shape this parser does not
141
+ // understand, which is what `--runner-cmd` pointed at a non-jest reporter
142
+ // produces. The verdict is already safe (a non-green baseline makes the
143
+ // claim unverifiable rather than passing), but without this the user is told
144
+ // `defenders-failed-to-load` with no reason at all.
145
+ const unknownShape = report == null || typeof report !== 'object' || !Array.isArray(report.testResults);
139
146
  const files = report.testResults ?? [];
140
147
  const loadFailed = files.some((f) => f.status === 'failed' && (f.assertionResults?.length ?? 0) === 0);
141
148
  const results = files.flatMap((f) => (f.assertionResults ?? []).map((t) => ({ ...t, id: `${f.name}::${t.fullName ?? t.title ?? ''}` }))).filter((t) => t.status === 'failed');
@@ -153,7 +160,9 @@ export function parseReport(report, durationMs) {
153
160
  // jest opens a load failure with "● Test suite failed to run" and puts the
154
161
  // cause lines below; vitest puts it on the first line. Take the first line
155
162
  // that names an error, else the first non-empty one.
156
- const loadMessage = loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
163
+ const loadMessage = unknownShape
164
+ ? 'the runner produced a report with no `testResults` array: this parser reads the jest-compatible JSON report, so a --runner-cmd must emit that shape (vitest --reporter=json, jest --json)'
165
+ : loadFailed ? firstInformativeLine(files.find((f) => f.message)?.message ?? '') : undefined;
157
166
  return { run, timeouts, loadMessage, failedTests };
158
167
  }
159
168
 
@@ -5,4 +5,16 @@ export const testGlobs = TEST_GLOBS;
5
5
  export const check = (opts) => checkRunner({ ...opts, pkg: 'vitest', bin: 'vitest' });
6
6
  export const tests = (projectDir) => listTestFiles(projectDir, testGlobs);
7
7
  // --serial: one file at a time in one process, so a contended machine cannot turn a slow suite into a TIMEOUT verdict.
8
- export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => [...runnerArgv(opts.projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(opts.serial ? ['--no-file-parallelism'] : [])] });
8
+ /** The command line, as data. Exported so it is falsifiable without spawning anything. */
9
+ export const argvFor = (projectDir, files, outFile, { serial = false } = {}) =>
10
+ [...runnerArgv(projectDir, 'vitest', 'vitest'), 'run', ...files, '--reporter=json', `--outputFile=${outFile}`, ...(serial ? ['--no-file-parallelism'] : [])];
11
+ export const run = (opts) => runProcess({ ...opts, argv: (files, outFile) => argvFor(opts.projectDir, files, outFile, opts) });
12
+
13
+ /**
14
+ * The negative control's edit: content this runner's loader cannot possibly
15
+ * parse. Used to prove the defenders actually execute a file before a
16
+ * `survived` verdict about it is allowed to stand. An unterminated group is
17
+ * a syntax error at every JavaScript/TypeScript parser, whatever the file
18
+ * contained before.
19
+ */
20
+ export const fatalEdit = () => '/* testguard negative control: this file must not parse */\n(\n';