tdd-cli 0.1.0__py3-none-any.whl
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.
- tdd_cli-0.1.0.dist-info/METADATA +391 -0
- tdd_cli-0.1.0.dist-info/RECORD +24 -0
- tdd_cli-0.1.0.dist-info/WHEEL +4 -0
- tdd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tdd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- tddcli/__init__.py +6 -0
- tddcli/adapters/__init__.py +48 -0
- tddcli/adapters/base.py +119 -0
- tddcli/adapters/pytest_adapter.py +179 -0
- tddcli/adapters/vitest_adapter.py +170 -0
- tddcli/advance.py +423 -0
- tddcli/cli.py +1043 -0
- tddcli/config.py +255 -0
- tddcli/contract.py +237 -0
- tddcli/envelope.py +96 -0
- tddcli/fleet.py +128 -0
- tddcli/gitutil.py +138 -0
- tddcli/identity.py +82 -0
- tddcli/leases.py +118 -0
- tddcli/ledger.py +433 -0
- tddcli/machine.py +390 -0
- tddcli/render.py +275 -0
- tddcli/snapshot.py +90 -0
- tddcli/staging.py +130 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""pytest adapter.
|
|
2
|
+
|
|
3
|
+
Two defects in the prior system are corrected here (R10.2):
|
|
4
|
+
|
|
5
|
+
* `collectors` is inspected, so an import error reports `not_collected` and is never
|
|
6
|
+
conflated with a mistyped identifier (`not_found`). One resolves by writing a stub;
|
|
7
|
+
the other by fixing a name. Conflating them sent agents to ask a human.
|
|
8
|
+
* The report file is written to a fresh temp directory each run, so a stale report can
|
|
9
|
+
never be read as if current.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import shlex
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .base import (
|
|
20
|
+
FAILED,
|
|
21
|
+
NOT_COLLECTED,
|
|
22
|
+
NOT_FOUND,
|
|
23
|
+
PASSED,
|
|
24
|
+
Adapter,
|
|
25
|
+
Collection,
|
|
26
|
+
GateResult,
|
|
27
|
+
Verdict,
|
|
28
|
+
run_command,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
#: Environment-manager marker files, most specific first. A pyproject.toml alone is
|
|
32
|
+
#: NOT a marker: Poetry, pipenv, PDM and plain-venv projects all have one, and
|
|
33
|
+
#: assuming uv there runs the suite in an environment the project never built.
|
|
34
|
+
RUNNER_MARKERS = (
|
|
35
|
+
("uv.lock", "uv run "),
|
|
36
|
+
("poetry.lock", "poetry run "),
|
|
37
|
+
("Pipfile", "pipenv run "),
|
|
38
|
+
("pdm.lock", "pdm run "),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PytestAdapter(Adapter):
|
|
43
|
+
name = "pytest"
|
|
44
|
+
|
|
45
|
+
def stub_hint(self) -> str:
|
|
46
|
+
return "`raise NotImplementedError` in every body"
|
|
47
|
+
|
|
48
|
+
def _runner_prefix(self) -> str:
|
|
49
|
+
"""The project root is checked before the worktree root: a workspace keeps
|
|
50
|
+
one lockfile at the top, but a member with its own marker owns its choice."""
|
|
51
|
+
for base in (self.root, self.worktree):
|
|
52
|
+
for marker, prefix in RUNNER_MARKERS:
|
|
53
|
+
if (base / marker).is_file():
|
|
54
|
+
return prefix
|
|
55
|
+
pyproject = self.root / "pyproject.toml"
|
|
56
|
+
if pyproject.is_file() and "[tool.poetry]" in pyproject.read_text():
|
|
57
|
+
return "poetry run "
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
def _base_cmd(self) -> str:
|
|
61
|
+
return f"{self._runner_prefix()}pytest"
|
|
62
|
+
|
|
63
|
+
def plugin_probe_cmd(self) -> str:
|
|
64
|
+
"""The pytest-json-report check, runnable in the project's own environment."""
|
|
65
|
+
return f"{self._runner_prefix()}python -c 'import pytest_jsonreport'"
|
|
66
|
+
|
|
67
|
+
def _test_cmd(self) -> str:
|
|
68
|
+
"""The project's own suite command, so the suite under TDD is the real one."""
|
|
69
|
+
return self.project.test_command or self._base_cmd()
|
|
70
|
+
|
|
71
|
+
def _collect_cmd(self) -> str:
|
|
72
|
+
return self.project.collect_command or self._base_cmd()
|
|
73
|
+
|
|
74
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
75
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="tdd-pytest-") as tmp:
|
|
77
|
+
report_path = Path(tmp) / "report.json"
|
|
78
|
+
# Only reporting flags are appended — parallelism, markers and plugins
|
|
79
|
+
# stay exactly as the project declared them. json-report is xdist-safe:
|
|
80
|
+
# `collectors` is omitted when nothing fails to collect, and present with
|
|
81
|
+
# the failing entry when something does, which is when it is consulted.
|
|
82
|
+
cmd = (
|
|
83
|
+
f"{self._test_cmd()} --json-report"
|
|
84
|
+
f" --json-report-file={shlex.quote(str(report_path))}"
|
|
85
|
+
)
|
|
86
|
+
code, out, err = self._run_suite(cmd)
|
|
87
|
+
if not report_path.is_file():
|
|
88
|
+
verdict.error = (
|
|
89
|
+
"pytest produced no JSON report (is pytest-json-report installed?): "
|
|
90
|
+
+ (err or out)[:500]
|
|
91
|
+
)
|
|
92
|
+
return verdict
|
|
93
|
+
report = json.loads(report_path.read_text())
|
|
94
|
+
|
|
95
|
+
verdict.duration_ms = int(report.get("duration", 0) * 1000)
|
|
96
|
+
|
|
97
|
+
uncollectable: set[str] = set()
|
|
98
|
+
for collector in report.get("collectors", []):
|
|
99
|
+
if collector.get("outcome") not in (None, "passed"):
|
|
100
|
+
uncollectable.add(collector.get("nodeid", ""))
|
|
101
|
+
|
|
102
|
+
for test in report.get("tests", []):
|
|
103
|
+
qualified = self.qualify(test["nodeid"])
|
|
104
|
+
if test["outcome"] == "passed":
|
|
105
|
+
verdict.passed.append(qualified)
|
|
106
|
+
elif test["outcome"] in ("failed", "error"):
|
|
107
|
+
verdict.failed.append(qualified)
|
|
108
|
+
|
|
109
|
+
if target is None:
|
|
110
|
+
verdict.target_outcome = NOT_FOUND
|
|
111
|
+
return verdict
|
|
112
|
+
|
|
113
|
+
native = self.strip(target)
|
|
114
|
+
hit = next(
|
|
115
|
+
(t for t in report.get("tests", []) if t["nodeid"] == native), None
|
|
116
|
+
)
|
|
117
|
+
if hit is not None:
|
|
118
|
+
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
|
|
119
|
+
call = hit.get("call") or hit.get("setup") or {}
|
|
120
|
+
verdict.target_failure = str(call.get("longrepr", ""))[:1500]
|
|
121
|
+
else:
|
|
122
|
+
target_file = native.split("::", 1)[0]
|
|
123
|
+
if any(c == target_file or c.startswith(target_file) for c in uncollectable):
|
|
124
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
125
|
+
verdict.target_failure = self._collector_error(report, target_file)[:1500]
|
|
126
|
+
else:
|
|
127
|
+
verdict.target_outcome = NOT_FOUND
|
|
128
|
+
return verdict
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _collector_error(report: dict, target_file: str) -> str:
|
|
132
|
+
for collector in report.get("collectors", []):
|
|
133
|
+
if collector.get("nodeid", "").startswith(target_file):
|
|
134
|
+
return str(collector.get("longrepr", ""))
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
def _test_files(self) -> list[Path]:
|
|
138
|
+
found: list[Path] = []
|
|
139
|
+
for pattern in self.project.test_paths or ["tests/"]:
|
|
140
|
+
if pattern.endswith("/"):
|
|
141
|
+
base = self.root / pattern
|
|
142
|
+
if base.is_dir():
|
|
143
|
+
found.extend(sorted(base.rglob("test_*.py")))
|
|
144
|
+
found.extend(sorted(base.rglob("*_test.py")))
|
|
145
|
+
else:
|
|
146
|
+
found.extend(sorted(self.root.glob(pattern)))
|
|
147
|
+
return sorted({p for p in found if p.is_file()})
|
|
148
|
+
|
|
149
|
+
def collectable(self) -> GateResult:
|
|
150
|
+
"""A single whole-suite `--collect-only` (§10), not the per-file
|
|
151
|
+
`collect()` loop below — that loop is R10.3/R10.4's per-file collection,
|
|
152
|
+
the slow path (the whole-suite probe costs 0.04s on a broken project vs.
|
|
153
|
+
minutes for the per-file sweep on a real one).
|
|
154
|
+
|
|
155
|
+
Reads **stdout**, not stderr: `uv` writes environment warnings
|
|
156
|
+
(`VIRTUAL_ENV=... does not match ...`) to stderr while pytest writes the
|
|
157
|
+
actual `ModuleNotFoundError` to stdout. A doctor check that reads stderr
|
|
158
|
+
loses the real error and the failure surfaces unattributed.
|
|
159
|
+
"""
|
|
160
|
+
code, out, err = run_command(f"{self._collect_cmd()} --collect-only -q", self.root)
|
|
161
|
+
return GateResult(ok=code == 0, output="" if code == 0 else out.strip()[:2000])
|
|
162
|
+
|
|
163
|
+
def collect(self) -> Collection:
|
|
164
|
+
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
165
|
+
result = Collection()
|
|
166
|
+
for path in self._test_files():
|
|
167
|
+
rel = path.relative_to(self.root)
|
|
168
|
+
code, out, err = run_command(
|
|
169
|
+
f"{self._collect_cmd()} --collect-only -q {shlex.quote(str(rel))}",
|
|
170
|
+
self.root,
|
|
171
|
+
)
|
|
172
|
+
if code != 0:
|
|
173
|
+
result.failed_files[str(rel)] = (err or out).strip()[:800]
|
|
174
|
+
continue
|
|
175
|
+
for line in out.splitlines():
|
|
176
|
+
line = line.strip()
|
|
177
|
+
if "::" in line and not line.startswith(("=", "-", "no tests")):
|
|
178
|
+
result.tests.add(self.qualify(line))
|
|
179
|
+
return result
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""vitest adapter.
|
|
2
|
+
|
|
3
|
+
Test ids are `<worktree-relative file> > <fullName>`, where fullName is the
|
|
4
|
+
space-joined ancestorTitles plus the test title. vitest may prefix its JSON with
|
|
5
|
+
non-JSON lines, so the payload is located rather than assumed (R10.2).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .base import (
|
|
16
|
+
FAILED,
|
|
17
|
+
NOT_COLLECTED,
|
|
18
|
+
NOT_FOUND,
|
|
19
|
+
PASSED,
|
|
20
|
+
Adapter,
|
|
21
|
+
Collection,
|
|
22
|
+
GateResult,
|
|
23
|
+
Verdict,
|
|
24
|
+
run_command,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_json(raw: str) -> dict | None:
|
|
29
|
+
start = raw.find("{")
|
|
30
|
+
while start != -1:
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(raw[start:])
|
|
33
|
+
except json.JSONDecodeError:
|
|
34
|
+
start = raw.find("{", start + 1)
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class VitestAdapter(Adapter):
|
|
39
|
+
name = "vitest"
|
|
40
|
+
|
|
41
|
+
def stub_hint(self) -> str:
|
|
42
|
+
return "`throw new Error(\"not implemented\")` in every body"
|
|
43
|
+
|
|
44
|
+
def _id_for(self, suite_path: str, full_name: str) -> str:
|
|
45
|
+
abs_path = Path(suite_path)
|
|
46
|
+
try:
|
|
47
|
+
rel = os.path.relpath(abs_path, self.worktree)
|
|
48
|
+
except ValueError:
|
|
49
|
+
rel = suite_path
|
|
50
|
+
return self.qualify(f"{rel} > {full_name}")
|
|
51
|
+
|
|
52
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
53
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
54
|
+
base = self.project.test_command or "npx vitest run"
|
|
55
|
+
code, out, err = self._run_suite(f"{base} --reporter=json")
|
|
56
|
+
report = _extract_json(out)
|
|
57
|
+
if report is None:
|
|
58
|
+
verdict.error = f"vitest produced no JSON output: {(err or out)[:500]}"
|
|
59
|
+
return verdict
|
|
60
|
+
|
|
61
|
+
verdict.duration_ms = int(report.get("duration") or 0)
|
|
62
|
+
failed_suites: dict[str, str] = {}
|
|
63
|
+
|
|
64
|
+
for suite in report.get("testResults", []):
|
|
65
|
+
suite_path = suite.get("name", "")
|
|
66
|
+
assertions = suite.get("assertionResults", [])
|
|
67
|
+
if not assertions and suite.get("status") == "failed":
|
|
68
|
+
failed_suites[suite_path] = str(suite.get("message", ""))[:1500]
|
|
69
|
+
for t in assertions:
|
|
70
|
+
qualified = self._id_for(suite_path, t["fullName"])
|
|
71
|
+
if t["status"] == "passed":
|
|
72
|
+
verdict.passed.append(qualified)
|
|
73
|
+
elif t["status"] == "failed":
|
|
74
|
+
verdict.failed.append(qualified)
|
|
75
|
+
|
|
76
|
+
if target is None:
|
|
77
|
+
verdict.target_outcome = NOT_FOUND
|
|
78
|
+
return verdict
|
|
79
|
+
|
|
80
|
+
if target in verdict.passed:
|
|
81
|
+
verdict.target_outcome = PASSED
|
|
82
|
+
return verdict
|
|
83
|
+
if target in verdict.failed:
|
|
84
|
+
verdict.target_outcome = FAILED
|
|
85
|
+
for suite in report.get("testResults", []):
|
|
86
|
+
for t in suite.get("assertionResults", []):
|
|
87
|
+
if self._id_for(suite.get("name", ""), t["fullName"]) == target:
|
|
88
|
+
verdict.target_failure = "\n".join(
|
|
89
|
+
m[:600] for m in t.get("failureMessages", [])[:3]
|
|
90
|
+
)
|
|
91
|
+
return verdict
|
|
92
|
+
|
|
93
|
+
target_file = self.strip(target).split(" > ", 1)[0]
|
|
94
|
+
for suite_path, message in failed_suites.items():
|
|
95
|
+
if suite_path.endswith(target_file):
|
|
96
|
+
verdict.target_outcome = NOT_COLLECTED
|
|
97
|
+
verdict.target_failure = message
|
|
98
|
+
return verdict
|
|
99
|
+
verdict.target_outcome = NOT_FOUND
|
|
100
|
+
return verdict
|
|
101
|
+
|
|
102
|
+
def _test_files(self) -> list[Path]:
|
|
103
|
+
found: set[Path] = set()
|
|
104
|
+
for pattern in self.project.test_paths or ["**/*.test.ts"]:
|
|
105
|
+
pat = pattern.rstrip("/") + "/**/*" if pattern.endswith("/") else pattern
|
|
106
|
+
for path in self.root.glob(pat):
|
|
107
|
+
if path.is_file() and path.suffix in (".ts", ".tsx", ".js", ".jsx"):
|
|
108
|
+
if "node_modules" not in path.parts:
|
|
109
|
+
found.add(path)
|
|
110
|
+
return sorted(found)
|
|
111
|
+
|
|
112
|
+
def _parse_list_output(self, out: str, path: Path) -> set[str]:
|
|
113
|
+
"""Parse `vitest list` text output into ids matching those `run()` produces.
|
|
114
|
+
|
|
115
|
+
`vitest list` ignores `--reporter=json` (checked against vitest 4.1.0) and
|
|
116
|
+
emits one line per test:
|
|
117
|
+
|
|
118
|
+
relative/file.test.ts > describe > nested describe > it name
|
|
119
|
+
|
|
120
|
+
`run()` reports the same test with `fullName`, which is the ancestor titles
|
|
121
|
+
and the title joined by a **space**. Splitting on " > " and rejoining the
|
|
122
|
+
name parts with a space reproduces that exactly; keeping the arrows would
|
|
123
|
+
yield ids that never match a verdict.
|
|
124
|
+
"""
|
|
125
|
+
found: set[str] = set()
|
|
126
|
+
for line in out.splitlines():
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if " > " not in line:
|
|
129
|
+
continue
|
|
130
|
+
_, _, remainder = line.partition(" > ")
|
|
131
|
+
full_name = " ".join(part.strip() for part in remainder.split(" > "))
|
|
132
|
+
if full_name:
|
|
133
|
+
found.add(self._id_for(str(path), full_name))
|
|
134
|
+
return found
|
|
135
|
+
|
|
136
|
+
def collectable(self) -> GateResult:
|
|
137
|
+
"""A single whole-suite probe, mirroring the pytest adapter's `--collect-only`
|
|
138
|
+
(§10): `npx vitest list` at the project root rather than the
|
|
139
|
+
per-file `collect()` loop below."""
|
|
140
|
+
base = self.project.collect_command or "npx vitest list"
|
|
141
|
+
code, out, err = run_command(base, self.root)
|
|
142
|
+
return GateResult(ok=code == 0, output="" if code == 0 else (err or out).strip()[:2000])
|
|
143
|
+
|
|
144
|
+
def collect(self) -> Collection:
|
|
145
|
+
result = Collection()
|
|
146
|
+
for path in self._test_files():
|
|
147
|
+
rel = path.relative_to(self.root)
|
|
148
|
+
base = self.project.collect_command or "npx vitest list"
|
|
149
|
+
code, out, err = run_command(f"{base} {shlex.quote(str(rel))}", self.root)
|
|
150
|
+
|
|
151
|
+
payload = _extract_json(out)
|
|
152
|
+
if payload is not None:
|
|
153
|
+
entries = payload if isinstance(payload, list) else payload.get("tests", [])
|
|
154
|
+
for entry in entries or []:
|
|
155
|
+
name = entry.get("fullName") or entry.get("name")
|
|
156
|
+
if name:
|
|
157
|
+
result.tests.add(self._id_for(str(path), name))
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
names = self._parse_list_output(out, path)
|
|
161
|
+
if names:
|
|
162
|
+
result.tests.update(names)
|
|
163
|
+
else:
|
|
164
|
+
# Zero tests from a file that exists is a tooling failure, not an
|
|
165
|
+
# empty file — record it rather than silently collecting nothing.
|
|
166
|
+
result.failed_files[str(rel)] = (
|
|
167
|
+
f"no tests parsed from `{base}` (exit {code}): "
|
|
168
|
+
+ (err or out).strip()[:600]
|
|
169
|
+
)
|
|
170
|
+
return result
|