tdd-cli 0.1.0__tar.gz → 0.2.1__tar.gz
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 → tdd_cli-0.2.1}/CHANGELOG.md +31 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/PKG-INFO +30 -1
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/README.md +29 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/base.py +57 -2
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/pytest_adapter.py +98 -26
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/vitest_adapter.py +100 -17
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/cli.py +10 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/config.py +84 -1
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_doctor_attribution.py +43 -0
- tdd_cli-0.2.1/tests/test_suite_overrides.py +585 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/.gitignore +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/LICENSE +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/SECURITY.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/plan.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/pyproject.toml +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/render.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/conftest.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_contract.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_fleet.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_progress.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_vitest_adapter.py +0 -0
- {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_worker_leases.py +0 -0
|
@@ -6,6 +6,37 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.1] - 2026-08-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `tdd doctor` check `default suite cannot reach override files`: when a
|
|
14
|
+
project declares overrides, doctor probes the default suite's discovery
|
|
15
|
+
(pytest: the test command with `--collect-only`; vitest: `vitest list`) and
|
|
16
|
+
fails if it reaches files an override owns — the premise suite overrides
|
|
17
|
+
require, which nothing previously enforced.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- A test observed by more than one suite invocation of the union (the default
|
|
22
|
+
suite's discovery sweeping an override's files, e.g. a bare `pytest` default)
|
|
23
|
+
is now a loud tooling error naming the overlapping tests and the fix, instead
|
|
24
|
+
of the target being silently judged by whichever suite reported it first —
|
|
25
|
+
previously an env-less run whose failure said nothing about the overlap.
|
|
26
|
+
|
|
27
|
+
## [0.2.0] - 2026-08-10
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Per-pattern suite overrides (`[[project.<name>.override]]` in `tdd.toml`): an
|
|
32
|
+
alternate `test_command` — plus optional `collect_command` and `env` — for
|
|
33
|
+
files the default runner config cannot reach, such as contract tests that need
|
|
34
|
+
a live backend. Collection and suite runs union the default suite with every
|
|
35
|
+
override suite, so a cycle can target such a test without widening the default
|
|
36
|
+
config (which breaks CI and pollutes target adoption with the other suite's
|
|
37
|
+
tests). Override patterns classify their files as tests without being repeated
|
|
38
|
+
in `test_paths`; `env` values may reference `${VAR}`, expanded at invocation.
|
|
39
|
+
|
|
9
40
|
## [0.1.0] - 2026-08-08
|
|
10
41
|
|
|
11
42
|
Initial release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tdd-cli
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Ledger-backed TDD process controller for autonomous coding agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/geuben/tdd-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/geuben/tdd-cli
|
|
@@ -132,6 +132,35 @@ generated = true # excluded from authorship accounting
|
|
|
132
132
|
A generator that is never hand-edited (`codegen`) is an artifact regeneration command, not a
|
|
133
133
|
project. It has no tests and no cycles.
|
|
134
134
|
|
|
135
|
+
Some tests intentionally live outside the project's default runner config — contract tests
|
|
136
|
+
that need a live backend being the common case, where CI runs the default suite with no
|
|
137
|
+
backend up. Widening the default config to make such a test collectable is the wrong fix:
|
|
138
|
+
the plain suite starts making real network calls, and the other suite's tests pollute
|
|
139
|
+
target adoption. Instead, declare an override per pattern:
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
[project.frontend]
|
|
143
|
+
root = "frontend"
|
|
144
|
+
adapter = "vitest"
|
|
145
|
+
test_paths = ["**/*.test.ts"]
|
|
146
|
+
test_command = "npx vitest run"
|
|
147
|
+
|
|
148
|
+
[[project.frontend.override]]
|
|
149
|
+
pattern = "src/__contract__/"
|
|
150
|
+
test_command = "npx vitest run --config vitest.contract.config.ts"
|
|
151
|
+
collect_command = "npx vitest list --config vitest.contract.config.ts"
|
|
152
|
+
env = { API_URL = "http://localhost:${API_PORT}" }
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Collection and suite runs union the default suite with every override suite, so a cycle can
|
|
156
|
+
target a test only the alternate config reaches. Patterns match paths relative to the
|
|
157
|
+
project root with `test_paths` semantics, and override files classify as tests without
|
|
158
|
+
being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded from the
|
|
159
|
+
environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
|
|
160
|
+
composes with the run command); a vitest override must declare one — `vitest list` knows
|
|
161
|
+
nothing of the override config, and the mismatch is refused at `tdd run start`, not
|
|
162
|
+
mid-cycle.
|
|
163
|
+
|
|
135
164
|
## Sharing cores between concurrent agents
|
|
136
165
|
|
|
137
166
|
Several agents running tdd-cli on one machine (each in its own worktree) face a bad
|
|
@@ -104,6 +104,35 @@ generated = true # excluded from authorship accounting
|
|
|
104
104
|
A generator that is never hand-edited (`codegen`) is an artifact regeneration command, not a
|
|
105
105
|
project. It has no tests and no cycles.
|
|
106
106
|
|
|
107
|
+
Some tests intentionally live outside the project's default runner config — contract tests
|
|
108
|
+
that need a live backend being the common case, where CI runs the default suite with no
|
|
109
|
+
backend up. Widening the default config to make such a test collectable is the wrong fix:
|
|
110
|
+
the plain suite starts making real network calls, and the other suite's tests pollute
|
|
111
|
+
target adoption. Instead, declare an override per pattern:
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
[project.frontend]
|
|
115
|
+
root = "frontend"
|
|
116
|
+
adapter = "vitest"
|
|
117
|
+
test_paths = ["**/*.test.ts"]
|
|
118
|
+
test_command = "npx vitest run"
|
|
119
|
+
|
|
120
|
+
[[project.frontend.override]]
|
|
121
|
+
pattern = "src/__contract__/"
|
|
122
|
+
test_command = "npx vitest run --config vitest.contract.config.ts"
|
|
123
|
+
collect_command = "npx vitest list --config vitest.contract.config.ts"
|
|
124
|
+
env = { API_URL = "http://localhost:${API_PORT}" }
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Collection and suite runs union the default suite with every override suite, so a cycle can
|
|
128
|
+
target a test only the alternate config reaches. Patterns match paths relative to the
|
|
129
|
+
project root with `test_paths` semantics, and override files classify as tests without
|
|
130
|
+
being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded from the
|
|
131
|
+
environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
|
|
132
|
+
composes with the run command); a vitest override must declare one — `vitest list` knows
|
|
133
|
+
nothing of the override config, and the mismatch is refused at `tdd run start`, not
|
|
134
|
+
mid-cycle.
|
|
135
|
+
|
|
107
136
|
## Sharing cores between concurrent agents
|
|
108
137
|
|
|
109
138
|
Several agents running tdd-cli on one machine (each in its own worktree) face a bad
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
from collections import Counter
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -42,6 +43,27 @@ class Collection:
|
|
|
42
43
|
failed_files: dict[str, str] = field(default_factory=dict)
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def _suite_overlap(suite_ids: list[set[str]]) -> list[str]:
|
|
47
|
+
"""Test ids observed by more than one suite invocation of the union (R7.13).
|
|
48
|
+
Overlap means the default command's discovery reaches files an override
|
|
49
|
+
owns, so those tests also ran without the override's command/env — and
|
|
50
|
+
target matching would judge the target by whichever run came first."""
|
|
51
|
+
counts = Counter(i for ids in suite_ids for i in ids)
|
|
52
|
+
return sorted(i for i, n in counts.items() if n > 1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _overlap_error(overlap: list[str]) -> str:
|
|
56
|
+
shown = ", ".join(overlap[:5])
|
|
57
|
+
more = f" (and {len(overlap) - 5} more)" if len(overlap) > 5 else ""
|
|
58
|
+
return (
|
|
59
|
+
f"observed by more than one suite invocation: {shown}{more}."
|
|
60
|
+
" The default suite's discovery reaches files an override owns, so"
|
|
61
|
+
" these tests also ran without the override's command/env. Scope the"
|
|
62
|
+
" default test_command so it cannot reach them (e.g. pass the default"
|
|
63
|
+
" test directories explicitly)."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
45
67
|
def run_command(
|
|
46
68
|
command: str, cwd: Path, timeout: int = 1800,
|
|
47
69
|
extra_env: dict[str, str] | None = None,
|
|
@@ -77,7 +99,9 @@ class Adapter:
|
|
|
77
99
|
def run(self, target: str | None = None) -> Verdict:
|
|
78
100
|
raise NotImplementedError
|
|
79
101
|
|
|
80
|
-
def _run_suite(
|
|
102
|
+
def _run_suite(
|
|
103
|
+
self, command: str, extra_env: dict[str, str] | None = None
|
|
104
|
+
) -> tuple[int, str, str]:
|
|
81
105
|
"""Run the suite under a machine-wide worker lease.
|
|
82
106
|
|
|
83
107
|
Substituting `{workers}` is opt-in per project; a command without the
|
|
@@ -91,9 +115,31 @@ class Adapter:
|
|
|
91
115
|
return run_command(
|
|
92
116
|
command.replace("{workers}", str(workers)),
|
|
93
117
|
self.root,
|
|
94
|
-
extra_env={"TDD_WORKERS": str(workers)},
|
|
118
|
+
extra_env={"TDD_WORKERS": str(workers), **(extra_env or {})},
|
|
95
119
|
)
|
|
96
120
|
|
|
121
|
+
def _test_cmd(self) -> str:
|
|
122
|
+
raise NotImplementedError
|
|
123
|
+
|
|
124
|
+
def _suite_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
|
|
125
|
+
"""The default suite plus one invocation per declared override (R7.13).
|
|
126
|
+
|
|
127
|
+
Runs and collection union these results, so a test reachable only under an
|
|
128
|
+
alternate runner config is still observed — without widening the default
|
|
129
|
+
config, which is exactly the workaround that breaks CI.
|
|
130
|
+
"""
|
|
131
|
+
return [(self._test_cmd(), None)] + [
|
|
132
|
+
(ov.test_command, self._override_env(ov)) for ov in self.project.overrides
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _override_env(override) -> dict[str, str] | None:
|
|
137
|
+
"""`${VAR}` references resolve from the environment at invocation time, so a
|
|
138
|
+
port assigned by the harness need not be hard-coded in the reviewed file."""
|
|
139
|
+
if override is None or not override.env:
|
|
140
|
+
return None
|
|
141
|
+
return {k: os.path.expandvars(v) for k, v in override.env.items()}
|
|
142
|
+
|
|
97
143
|
def stub_hint(self) -> str:
|
|
98
144
|
"""The language idiom for a stub body, quoted into the create_stub directive."""
|
|
99
145
|
return "a body that fails loudly, never working logic"
|
|
@@ -104,6 +150,15 @@ class Adapter:
|
|
|
104
150
|
def collectable(self) -> GateResult:
|
|
105
151
|
raise NotImplementedError
|
|
106
152
|
|
|
153
|
+
def override_isolation(self) -> GateResult:
|
|
154
|
+
"""Whether the default suite's discovery stays out of files an override
|
|
155
|
+
owns (R7.13's premise). Overlap means runs observe those tests without
|
|
156
|
+
the override's command/env — and the union then holds the same test
|
|
157
|
+
twice with conflicting outcomes. Adapters with a way to probe discovery
|
|
158
|
+
override this; the base answer is ok so third-party adapters without a
|
|
159
|
+
probe don't fail doctor."""
|
|
160
|
+
return GateResult(ok=True)
|
|
161
|
+
|
|
107
162
|
def lint(self) -> GateResult:
|
|
108
163
|
return self._gate(self.project.lint)
|
|
109
164
|
|
|
@@ -25,6 +25,8 @@ from .base import (
|
|
|
25
25
|
Collection,
|
|
26
26
|
GateResult,
|
|
27
27
|
Verdict,
|
|
28
|
+
_overlap_error,
|
|
29
|
+
_suite_overlap,
|
|
28
30
|
run_command,
|
|
29
31
|
)
|
|
30
32
|
|
|
@@ -71,8 +73,9 @@ class PytestAdapter(Adapter):
|
|
|
71
73
|
def _collect_cmd(self) -> str:
|
|
72
74
|
return self.project.collect_command or self._base_cmd()
|
|
73
75
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
+
def _suite_report(
|
|
77
|
+
self, base_cmd: str, extra_env: dict[str, str] | None
|
|
78
|
+
) -> tuple[dict | None, str]:
|
|
76
79
|
with tempfile.TemporaryDirectory(prefix="tdd-pytest-") as tmp:
|
|
77
80
|
report_path = Path(tmp) / "report.json"
|
|
78
81
|
# Only reporting flags are appended — parallelism, markers and plugins
|
|
@@ -80,26 +83,47 @@ class PytestAdapter(Adapter):
|
|
|
80
83
|
# `collectors` is omitted when nothing fails to collect, and present with
|
|
81
84
|
# the failing entry when something does, which is when it is consulted.
|
|
82
85
|
cmd = (
|
|
83
|
-
f"{
|
|
86
|
+
f"{base_cmd} --json-report"
|
|
84
87
|
f" --json-report-file={shlex.quote(str(report_path))}"
|
|
85
88
|
)
|
|
86
|
-
code, out, err = self._run_suite(cmd)
|
|
89
|
+
code, out, err = self._run_suite(cmd, extra_env)
|
|
87
90
|
if not report_path.is_file():
|
|
88
|
-
|
|
89
|
-
"
|
|
90
|
-
+ (err or out)[:500]
|
|
91
|
+
return None, (
|
|
92
|
+
f"`{base_cmd}` produced no JSON report"
|
|
93
|
+
" (is pytest-json-report installed?): " + (err or out)[:500]
|
|
91
94
|
)
|
|
92
|
-
|
|
93
|
-
report = json.loads(report_path.read_text())
|
|
95
|
+
return json.loads(report_path.read_text()), ""
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
def run(self, target: str | None = None) -> Verdict:
|
|
98
|
+
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
99
|
+
# Union across the default suite and every override suite (R7.13). A suite
|
|
100
|
+
# that produces no report is a loud error, not a silent gap: swallowing it
|
|
101
|
+
# would report a target that lives in that suite as `not_found`, sending the
|
|
102
|
+
# agent to rewrite a test that is fine.
|
|
103
|
+
tests: list[dict] = []
|
|
104
|
+
collectors: list[dict] = []
|
|
105
|
+
suite_ids: list[set[str]] = []
|
|
106
|
+
for base_cmd, extra_env in self._suite_invocations():
|
|
107
|
+
report, error = self._suite_report(base_cmd, extra_env)
|
|
108
|
+
if report is None:
|
|
109
|
+
verdict.error = error
|
|
110
|
+
return verdict
|
|
111
|
+
verdict.duration_ms += int(report.get("duration", 0) * 1000)
|
|
112
|
+
tests.extend(report.get("tests", []))
|
|
113
|
+
collectors.extend(report.get("collectors", []))
|
|
114
|
+
suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
|
|
115
|
+
|
|
116
|
+
overlap = _suite_overlap(suite_ids)
|
|
117
|
+
if overlap:
|
|
118
|
+
verdict.error = _overlap_error(overlap)
|
|
119
|
+
return verdict
|
|
96
120
|
|
|
97
121
|
uncollectable: set[str] = set()
|
|
98
|
-
for collector in
|
|
122
|
+
for collector in collectors:
|
|
99
123
|
if collector.get("outcome") not in (None, "passed"):
|
|
100
124
|
uncollectable.add(collector.get("nodeid", ""))
|
|
101
125
|
|
|
102
|
-
for test in
|
|
126
|
+
for test in tests:
|
|
103
127
|
qualified = self.qualify(test["nodeid"])
|
|
104
128
|
if test["outcome"] == "passed":
|
|
105
129
|
verdict.passed.append(qualified)
|
|
@@ -111,9 +135,7 @@ class PytestAdapter(Adapter):
|
|
|
111
135
|
return verdict
|
|
112
136
|
|
|
113
137
|
native = self.strip(target)
|
|
114
|
-
hit = next(
|
|
115
|
-
(t for t in report.get("tests", []) if t["nodeid"] == native), None
|
|
116
|
-
)
|
|
138
|
+
hit = next((t for t in tests if t["nodeid"] == native), None)
|
|
117
139
|
if hit is not None:
|
|
118
140
|
verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
|
|
119
141
|
call = hit.get("call") or hit.get("setup") or {}
|
|
@@ -122,21 +144,30 @@ class PytestAdapter(Adapter):
|
|
|
122
144
|
target_file = native.split("::", 1)[0]
|
|
123
145
|
if any(c == target_file or c.startswith(target_file) for c in uncollectable):
|
|
124
146
|
verdict.target_outcome = NOT_COLLECTED
|
|
125
|
-
verdict.target_failure = self._collector_error(
|
|
147
|
+
verdict.target_failure = self._collector_error(collectors, target_file)[:1500]
|
|
126
148
|
else:
|
|
127
149
|
verdict.target_outcome = NOT_FOUND
|
|
128
150
|
return verdict
|
|
129
151
|
|
|
130
152
|
@staticmethod
|
|
131
|
-
def _collector_error(
|
|
132
|
-
for collector in
|
|
153
|
+
def _collector_error(collectors: list[dict], target_file: str) -> str:
|
|
154
|
+
for collector in collectors:
|
|
133
155
|
if collector.get("nodeid", "").startswith(target_file):
|
|
134
156
|
return str(collector.get("longrepr", ""))
|
|
135
157
|
return ""
|
|
136
158
|
|
|
159
|
+
def _collect_cmd_for(self, rel: str) -> tuple[str, dict[str, str] | None]:
|
|
160
|
+
"""The collection command for one file: the owning override's, else the
|
|
161
|
+
project default. An override without a `collect_command` collects with its
|
|
162
|
+
`test_command` — pytest's `--collect-only` composes with any run command."""
|
|
163
|
+
ov = self.project.override_for(rel)
|
|
164
|
+
if ov is None:
|
|
165
|
+
return self._collect_cmd(), None
|
|
166
|
+
return ov.collect_command or ov.test_command, self._override_env(ov)
|
|
167
|
+
|
|
137
168
|
def _test_files(self) -> list[Path]:
|
|
138
169
|
found: list[Path] = []
|
|
139
|
-
for pattern in self.project.
|
|
170
|
+
for pattern in self.project.test_patterns or ["tests/"]:
|
|
140
171
|
if pattern.endswith("/"):
|
|
141
172
|
base = self.root / pattern
|
|
142
173
|
if base.is_dir():
|
|
@@ -147,27 +178,68 @@ class PytestAdapter(Adapter):
|
|
|
147
178
|
return sorted({p for p in found if p.is_file()})
|
|
148
179
|
|
|
149
180
|
def collectable(self) -> GateResult:
|
|
150
|
-
"""
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
"""One whole-suite `--collect-only` per declared suite (§10) — the default
|
|
182
|
+
command plus each override's — not the per-file `collect()` loop below.
|
|
183
|
+
That loop is R10.3/R10.4's per-file collection, the slow path (the
|
|
184
|
+
whole-suite probe costs 0.04s on a broken project vs. minutes for the
|
|
185
|
+
per-file sweep on a real one).
|
|
154
186
|
|
|
155
187
|
Reads **stdout**, not stderr: `uv` writes environment warnings
|
|
156
188
|
(`VIRTUAL_ENV=... does not match ...`) to stderr while pytest writes the
|
|
157
189
|
actual `ModuleNotFoundError` to stdout. A doctor check that reads stderr
|
|
158
190
|
loses the real error and the failure surfaces unattributed.
|
|
159
191
|
"""
|
|
160
|
-
|
|
161
|
-
|
|
192
|
+
chunks = []
|
|
193
|
+
probes = [(self._collect_cmd(), None)] + [
|
|
194
|
+
(ov.collect_command or ov.test_command, self._override_env(ov))
|
|
195
|
+
for ov in self.project.overrides
|
|
196
|
+
]
|
|
197
|
+
for cmd, env in probes:
|
|
198
|
+
code, out, err = run_command(
|
|
199
|
+
f"{cmd} --collect-only -q", self.root, extra_env=env
|
|
200
|
+
)
|
|
201
|
+
if code != 0:
|
|
202
|
+
chunks.append(out.strip())
|
|
203
|
+
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
204
|
+
|
|
205
|
+
def override_isolation(self) -> GateResult:
|
|
206
|
+
"""Probes the *test* command's discovery, not `collect_command`'s: the
|
|
207
|
+
test command is what runs at suite time, and a scoped `test_command`
|
|
208
|
+
with a bare per-file `collect_command` is a legitimate registry (the
|
|
209
|
+
per-file command always gets an explicit path). `{workers}` becomes 0 —
|
|
210
|
+
xdist's "no workers" — since discovery needs no parallelism. A probe
|
|
211
|
+
that fails to collect at all is `collectable`'s finding, not this one's."""
|
|
212
|
+
if not self.project.overrides:
|
|
213
|
+
return GateResult(ok=True)
|
|
214
|
+
probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
|
|
215
|
+
code, out, err = run_command(probe, self.root)
|
|
216
|
+
reached = sorted({
|
|
217
|
+
f for f in (
|
|
218
|
+
line.split("::", 1)[0]
|
|
219
|
+
for line in out.splitlines()
|
|
220
|
+
if "::" in line
|
|
221
|
+
)
|
|
222
|
+
if self.project.override_for(f)
|
|
223
|
+
})
|
|
224
|
+
if not reached:
|
|
225
|
+
return GateResult(ok=True)
|
|
226
|
+
return GateResult(ok=False, output=(
|
|
227
|
+
"the default suite's discovery reaches files an override owns, so"
|
|
228
|
+
" suite runs would observe them without the override's command/env:"
|
|
229
|
+
f" {', '.join(reached[:5])}. Scope the default test_command so it"
|
|
230
|
+
" cannot reach them (e.g. `pytest tests/`)."
|
|
231
|
+
))
|
|
162
232
|
|
|
163
233
|
def collect(self) -> Collection:
|
|
164
234
|
"""Per file (R10.3) — one uncollectable module must not destroy the whole set."""
|
|
165
235
|
result = Collection()
|
|
166
236
|
for path in self._test_files():
|
|
167
237
|
rel = path.relative_to(self.root)
|
|
238
|
+
base, env = self._collect_cmd_for(str(rel))
|
|
168
239
|
code, out, err = run_command(
|
|
169
|
-
f"{
|
|
240
|
+
f"{base} --collect-only -q {shlex.quote(str(rel))}",
|
|
170
241
|
self.root,
|
|
242
|
+
extra_env=env,
|
|
171
243
|
)
|
|
172
244
|
if code != 0:
|
|
173
245
|
result.failed_files[str(rel)] = (err or out).strip()[:800]
|
|
@@ -21,6 +21,8 @@ from .base import (
|
|
|
21
21
|
Collection,
|
|
22
22
|
GateResult,
|
|
23
23
|
Verdict,
|
|
24
|
+
_overlap_error,
|
|
25
|
+
_suite_overlap,
|
|
24
26
|
run_command,
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -49,19 +51,44 @@ class VitestAdapter(Adapter):
|
|
|
49
51
|
rel = suite_path
|
|
50
52
|
return self.qualify(f"{rel} > {full_name}")
|
|
51
53
|
|
|
54
|
+
def _test_cmd(self) -> str:
|
|
55
|
+
return self.project.test_command or "npx vitest run"
|
|
56
|
+
|
|
57
|
+
def _collect_cmd(self) -> str:
|
|
58
|
+
return self.project.collect_command or "npx vitest list"
|
|
59
|
+
|
|
52
60
|
def run(self, target: str | None = None) -> Verdict:
|
|
53
61
|
verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
report
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
# Union across the default suite and every override suite (R7.13). A suite
|
|
63
|
+
# producing no JSON is a loud error, not a silent gap: swallowing it would
|
|
64
|
+
# report a target living in that suite as `not_found`.
|
|
65
|
+
suites: list[dict] = []
|
|
66
|
+
suite_ids: list[set[str]] = []
|
|
67
|
+
for base, extra_env in self._suite_invocations():
|
|
68
|
+
code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
|
|
69
|
+
report = _extract_json(out)
|
|
70
|
+
if report is None:
|
|
71
|
+
verdict.error = (
|
|
72
|
+
f"`{base}` produced no JSON output: {(err or out)[:500]}"
|
|
73
|
+
)
|
|
74
|
+
return verdict
|
|
75
|
+
verdict.duration_ms += int(report.get("duration") or 0)
|
|
76
|
+
results = report.get("testResults", [])
|
|
77
|
+
suites.extend(results)
|
|
78
|
+
suite_ids.append({
|
|
79
|
+
self._id_for(s.get("name", ""), t["fullName"])
|
|
80
|
+
for s in results
|
|
81
|
+
for t in s.get("assertionResults", [])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
overlap = _suite_overlap(suite_ids)
|
|
85
|
+
if overlap:
|
|
86
|
+
verdict.error = _overlap_error(overlap)
|
|
59
87
|
return verdict
|
|
60
88
|
|
|
61
|
-
verdict.duration_ms = int(report.get("duration") or 0)
|
|
62
89
|
failed_suites: dict[str, str] = {}
|
|
63
90
|
|
|
64
|
-
for suite in
|
|
91
|
+
for suite in suites:
|
|
65
92
|
suite_path = suite.get("name", "")
|
|
66
93
|
assertions = suite.get("assertionResults", [])
|
|
67
94
|
if not assertions and suite.get("status") == "failed":
|
|
@@ -82,7 +109,7 @@ class VitestAdapter(Adapter):
|
|
|
82
109
|
return verdict
|
|
83
110
|
if target in verdict.failed:
|
|
84
111
|
verdict.target_outcome = FAILED
|
|
85
|
-
for suite in
|
|
112
|
+
for suite in suites:
|
|
86
113
|
for t in suite.get("assertionResults", []):
|
|
87
114
|
if self._id_for(suite.get("name", ""), t["fullName"]) == target:
|
|
88
115
|
verdict.target_failure = "\n".join(
|
|
@@ -101,7 +128,7 @@ class VitestAdapter(Adapter):
|
|
|
101
128
|
|
|
102
129
|
def _test_files(self) -> list[Path]:
|
|
103
130
|
found: set[Path] = set()
|
|
104
|
-
for pattern in self.project.
|
|
131
|
+
for pattern in self.project.test_patterns or ["**/*.test.ts"]:
|
|
105
132
|
pat = pattern.rstrip("/") + "/**/*" if pattern.endswith("/") else pattern
|
|
106
133
|
for path in self.root.glob(pat):
|
|
107
134
|
if path.is_file() and path.suffix in (".ts", ".tsx", ".js", ".jsx"):
|
|
@@ -134,19 +161,75 @@ class VitestAdapter(Adapter):
|
|
|
134
161
|
return found
|
|
135
162
|
|
|
136
163
|
def collectable(self) -> GateResult:
|
|
137
|
-
"""
|
|
138
|
-
(§10): `npx vitest list` at the project root rather than
|
|
139
|
-
per-file `collect()` loop below.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
"""One whole-suite probe per declared suite, mirroring the pytest adapter's
|
|
165
|
+
`--collect-only` (§10): `npx vitest list` at the project root rather than
|
|
166
|
+
the per-file `collect()` loop below.
|
|
167
|
+
|
|
168
|
+
An override without a `collect_command` fails here, at run start, not
|
|
169
|
+
per-file during a cycle: `vitest list` knows nothing of the override's
|
|
170
|
+
config, and falling back to the override's *run* command would execute the
|
|
171
|
+
suite — against a live backend — just to enumerate it.
|
|
172
|
+
"""
|
|
173
|
+
chunks = []
|
|
174
|
+
code, out, err = run_command(self._collect_cmd(), self.root)
|
|
175
|
+
if code != 0:
|
|
176
|
+
chunks.append((err or out).strip())
|
|
177
|
+
for ov in self.project.overrides:
|
|
178
|
+
if not ov.collect_command:
|
|
179
|
+
chunks.append(
|
|
180
|
+
f"override {ov.pattern!r}: a vitest override needs an explicit"
|
|
181
|
+
' collect_command (e.g. "npx vitest list --config'
|
|
182
|
+
' vitest.other.config.ts")'
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
code, out, err = run_command(
|
|
186
|
+
ov.collect_command, self.root, extra_env=self._override_env(ov)
|
|
187
|
+
)
|
|
188
|
+
if code != 0:
|
|
189
|
+
chunks.append((err or out).strip())
|
|
190
|
+
return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
|
|
191
|
+
|
|
192
|
+
def override_isolation(self) -> GateResult:
|
|
193
|
+
"""Probes with the default `vitest list` — the same stand-in for the
|
|
194
|
+
default run config that `collectable()` already relies on (`vitest run`
|
|
195
|
+
has no listing mode, and running the suite just to enumerate it would
|
|
196
|
+
execute against whatever the tests need live)."""
|
|
197
|
+
if not self.project.overrides:
|
|
198
|
+
return GateResult(ok=True)
|
|
199
|
+
code, out, err = run_command(self._collect_cmd(), self.root)
|
|
200
|
+
reached = sorted({
|
|
201
|
+
f for f in (
|
|
202
|
+
line.strip().partition(" > ")[0]
|
|
203
|
+
for line in out.splitlines()
|
|
204
|
+
if " > " in line
|
|
205
|
+
)
|
|
206
|
+
if self.project.override_for(f)
|
|
207
|
+
})
|
|
208
|
+
if not reached:
|
|
209
|
+
return GateResult(ok=True)
|
|
210
|
+
return GateResult(ok=False, output=(
|
|
211
|
+
"the default config's discovery reaches files an override owns, so"
|
|
212
|
+
" suite runs would observe them without the override's command/env:"
|
|
213
|
+
f" {', '.join(reached[:5])}. Exclude them from the default vitest"
|
|
214
|
+
" config (test.exclude) or scope its include globs."
|
|
215
|
+
))
|
|
143
216
|
|
|
144
217
|
def collect(self) -> Collection:
|
|
145
218
|
result = Collection()
|
|
146
219
|
for path in self._test_files():
|
|
147
220
|
rel = path.relative_to(self.root)
|
|
148
|
-
|
|
149
|
-
|
|
221
|
+
ov = self.project.override_for(str(rel))
|
|
222
|
+
if ov is not None and not ov.collect_command:
|
|
223
|
+
result.failed_files[str(rel)] = (
|
|
224
|
+
f"override {ov.pattern!r} declares no collect_command; vitest"
|
|
225
|
+
" cannot list these tests under the default config"
|
|
226
|
+
)
|
|
227
|
+
continue
|
|
228
|
+
base = ov.collect_command if ov else self._collect_cmd()
|
|
229
|
+
env = self._override_env(ov)
|
|
230
|
+
code, out, err = run_command(
|
|
231
|
+
f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
|
|
232
|
+
)
|
|
150
233
|
|
|
151
234
|
payload = _extract_json(out)
|
|
152
235
|
if payload is not None:
|
|
@@ -275,6 +275,16 @@ def cmd_doctor(args) -> Envelope:
|
|
|
275
275
|
gate = adapter.collectable()
|
|
276
276
|
check("collectable", gate.ok, gate.output, project=name)
|
|
277
277
|
|
|
278
|
+
# R7.13's premise — "files the default runner config cannot reach" — is
|
|
279
|
+
# a config property nothing else enforces. Probe it at preflight so the
|
|
280
|
+
# overlap is named here, not discovered as an opaque mid-cycle failure.
|
|
281
|
+
if project.overrides:
|
|
282
|
+
gate = adapter.override_isolation()
|
|
283
|
+
check(
|
|
284
|
+
"default suite cannot reach override files",
|
|
285
|
+
gate.ok, gate.output, project=name,
|
|
286
|
+
)
|
|
287
|
+
|
|
278
288
|
projects[name] = {"ok": all(c["ok"] for c in checks[before:])}
|
|
279
289
|
|
|
280
290
|
for art in cfg.artifacts.values():
|