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.
Files changed (59) hide show
  1. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/CHANGELOG.md +31 -0
  2. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/PKG-INFO +30 -1
  3. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/README.md +29 -0
  4. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/base.py +57 -2
  6. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/pytest_adapter.py +98 -26
  7. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/vitest_adapter.py +100 -17
  8. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/cli.py +10 -0
  9. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/config.py +84 -1
  10. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_doctor_attribution.py +43 -0
  11. tdd_cli-0.2.1/tests/test_suite_overrides.py +585 -0
  12. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/.gitignore +0 -0
  13. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/LICENSE +0 -0
  14. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/SECURITY.md +0 -0
  15. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/README.md +0 -0
  16. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  17. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  18. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/plan.md +0 -0
  19. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/README.md +0 -0
  20. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  21. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/README.md +0 -0
  22. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  23. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/pyproject.toml +0 -0
  24. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/adapters/__init__.py +0 -0
  25. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/advance.py +0 -0
  26. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/contract.py +0 -0
  27. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/envelope.py +0 -0
  28. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/fleet.py +0 -0
  29. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/gitutil.py +0 -0
  30. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/identity.py +0 -0
  31. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/leases.py +0 -0
  32. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/ledger.py +0 -0
  33. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/machine.py +0 -0
  34. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/render.py +0 -0
  35. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/snapshot.py +0 -0
  36. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/src/tddcli/staging.py +0 -0
  37. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/conftest.py +0 -0
  38. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_artifact_regeneration.py +0 -0
  39. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_baseline_integrity.py +0 -0
  40. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_config_and_staging.py +0 -0
  41. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_config_drift.py +0 -0
  42. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_contract.py +0 -0
  43. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_end_to_end.py +0 -0
  44. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_example_plan.py +0 -0
  45. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_fleet.py +0 -0
  46. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_heartbeat.py +0 -0
  47. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_init_detection.py +0 -0
  48. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_pin_cycles.py +0 -0
  49. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_progress.py +0 -0
  50. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_project_commands.py +0 -0
  51. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_python_env_managers.py +0 -0
  52. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_refactor_cycles.py +0 -0
  53. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_release_surface.py +0 -0
  54. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_run_claim.py +0 -0
  55. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_single_project_repo.py +0 -0
  56. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_snapshot_and_identity.py +0 -0
  57. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_stub_hint.py +0 -0
  58. {tdd_cli-0.1.0 → tdd_cli-0.2.1}/tests/test_vitest_adapter.py +0 -0
  59. {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.0
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
@@ -3,4 +3,4 @@
3
3
  State is derived from observed test execution, never asserted by the caller.
4
4
  """
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.2.1"
@@ -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(self, command: str) -> tuple[int, str, str]:
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 run(self, target: str | None = None) -> Verdict:
75
- verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
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"{self._test_cmd()} --json-report"
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
- verdict.error = (
89
- "pytest produced no JSON report (is pytest-json-report installed?): "
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
- return verdict
93
- report = json.loads(report_path.read_text())
95
+ return json.loads(report_path.read_text()), ""
94
96
 
95
- verdict.duration_ms = int(report.get("duration", 0) * 1000)
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 report.get("collectors", []):
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 report.get("tests", []):
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(report, target_file)[:1500]
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(report: dict, target_file: str) -> str:
132
- for collector in report.get("collectors", []):
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.test_paths or ["tests/"]:
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
- """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).
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
- 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])
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"{self._collect_cmd()} --collect-only -q {shlex.quote(str(rel))}",
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
- 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]}"
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 report.get("testResults", []):
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 report.get("testResults", []):
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.test_paths or ["**/*.test.ts"]:
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
- """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])
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
- base = self.project.collect_command or "npx vitest list"
149
- code, out, err = run_command(f"{base} {shlex.quote(str(rel))}", self.root)
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():