tdd-cli 0.2.0__tar.gz → 0.3.0__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 (62) hide show
  1. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/CHANGELOG.md +44 -1
  2. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/PKG-INFO +1 -1
  3. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/__init__.py +1 -1
  4. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/base.py +55 -8
  5. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/pytest_adapter.py +46 -6
  6. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/vitest_adapter.py +58 -10
  7. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/cli.py +28 -0
  8. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/config.py +13 -0
  9. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_doctor_attribution.py +43 -0
  10. tdd_cli-0.3.0/tests/test_failure_clipping.py +64 -0
  11. tdd_cli-0.3.0/tests/test_project_env.py +124 -0
  12. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_suite_overrides.py +182 -3
  13. tdd_cli-0.3.0/tests/test_target_validation.py +55 -0
  14. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_vitest_adapter.py +30 -2
  15. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/.gitignore +0 -0
  16. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/LICENSE +0 -0
  17. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/README.md +0 -0
  18. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/SECURITY.md +0 -0
  19. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/README.md +0 -0
  20. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  21. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  22. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/plan.md +0 -0
  23. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-drive/README.md +0 -0
  24. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  25. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/README.md +0 -0
  26. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  27. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/pyproject.toml +0 -0
  28. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/adapters/__init__.py +0 -0
  29. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/advance.py +0 -0
  30. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/contract.py +0 -0
  31. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/envelope.py +0 -0
  32. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/fleet.py +0 -0
  33. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/gitutil.py +0 -0
  34. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/identity.py +0 -0
  35. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/leases.py +0 -0
  36. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/ledger.py +0 -0
  37. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/machine.py +0 -0
  38. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/render.py +0 -0
  39. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/snapshot.py +0 -0
  40. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/src/tddcli/staging.py +0 -0
  41. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/conftest.py +0 -0
  42. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_artifact_regeneration.py +0 -0
  43. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_baseline_integrity.py +0 -0
  44. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_config_and_staging.py +0 -0
  45. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_config_drift.py +0 -0
  46. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_contract.py +0 -0
  47. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_end_to_end.py +0 -0
  48. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_example_plan.py +0 -0
  49. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_fleet.py +0 -0
  50. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_heartbeat.py +0 -0
  51. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_init_detection.py +0 -0
  52. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_pin_cycles.py +0 -0
  53. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_progress.py +0 -0
  54. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_project_commands.py +0 -0
  55. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_python_env_managers.py +0 -0
  56. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_refactor_cycles.py +0 -0
  57. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_release_surface.py +0 -0
  58. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_run_claim.py +0 -0
  59. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_single_project_repo.py +0 -0
  60. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_snapshot_and_identity.py +0 -0
  61. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_stub_hint.py +0 -0
  62. {tdd_cli-0.2.0 → tdd_cli-0.3.0}/tests/test_worker_leases.py +0 -0
@@ -4,7 +4,50 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.3.0] - 2026-08-10
8
+
9
+ ### Added
10
+
11
+ - `env` on `[project.<name>]`: environment for the default suite's runs and
12
+ collection, with the same semantics as an override's `env` (`${VAR}` expands
13
+ from the environment at invocation). An override's `env` layers on top for
14
+ its own suite. Previously only override suites could declare environment,
15
+ leaving a default suite that reads an endpoint from a variable with no
16
+ registry-level way to receive it (#16).
17
+
18
+ ### Fixed
19
+
20
+ - vitest test ids are project-root-relative (`frontend::app/x.test.tsx > name`),
21
+ matching pytest nodeids and the form plan declarations qualify to — they were
22
+ worktree-relative (`frontend::frontend/app/...`), so a declared vitest target
23
+ could never match a verdict: standard cycles limped through on R8.9 adoption
24
+ (a spurious `declared_test_mismatch` per cycle) and pin cycles deadlocked in
25
+ `AWAITING_PIN`, since a pre-existing test is never adoptable (#21).
26
+ - `tdd target` refuses a name that is not a collected test in the cycle's
27
+ projects, suggesting the closest collected ids — previously any string was
28
+ recorded as the target and failed later, misattributed, as `not_found` (#15).
29
+ - Failure text (`target_failure`, uncollected-suite messages) is clipped keeping
30
+ both ends instead of truncated from the head: Python puts the actual error at
31
+ the tail of a traceback, so a head-only cut on a deep stack delivered
32
+ framework frames and cut exactly the line that says what went wrong (#17).
33
+
34
+ ## [0.2.1] - 2026-08-10
35
+
36
+ ### Added
37
+
38
+ - `tdd doctor` check `default suite cannot reach override files`: when a
39
+ project declares overrides, doctor probes the default suite's discovery
40
+ (pytest: the test command with `--collect-only`; vitest: `vitest list`) and
41
+ fails if it reaches files an override owns — the premise suite overrides
42
+ require, which nothing previously enforced.
43
+
44
+ ### Fixed
45
+
46
+ - A test observed by more than one suite invocation of the union (the default
47
+ suite's discovery sweeping an override's files, e.g. a bare `pytest` default)
48
+ is now a loud tooling error naming the overlapping tests and the fix, instead
49
+ of the target being silently judged by whichever suite reported it first —
50
+ previously an env-less run whose failure said nothing about the overlap.
8
51
 
9
52
  ## [0.2.0] - 2026-08-10
10
53
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tdd-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
@@ -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.2.0"
6
+ __version__ = "0.3.0"
@@ -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,41 @@ 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
+
67
+ def clip_failure(text: str, limit: int = 1500) -> str:
68
+ """Clip failure text to `limit`, keeping both ends. Python puts the actual
69
+ error at the tail of a traceback, so a head-only cut on a deep stack
70
+ (async frameworks, ORMs, HTTP clients) delivered framework frames and cut
71
+ exactly the line that says what went wrong — forcing a re-run outside tdd
72
+ to see an error the tool already had. The head is kept too: for a plain
73
+ assertion failure the first line carries the assertion itself."""
74
+ if len(text) <= limit:
75
+ return text
76
+ head = limit // 5
77
+ tail = limit - head
78
+ return f"{text[:head]}\n… [clipped] …\n{text[-tail:]}"
79
+
80
+
45
81
  def run_command(
46
82
  command: str, cwd: Path, timeout: int = 1800,
47
83
  extra_env: dict[str, str] | None = None,
@@ -106,17 +142,19 @@ class Adapter:
106
142
  alternate runner config is still observed — without widening the default
107
143
  config, which is exactly the workaround that breaks CI.
108
144
  """
109
- return [(self._test_cmd(), None)] + [
110
- (ov.test_command, self._override_env(ov)) for ov in self.project.overrides
145
+ return [(self._test_cmd(), self._suite_env(None))] + [
146
+ (ov.test_command, self._suite_env(ov)) for ov in self.project.overrides
111
147
  ]
112
148
 
113
- @staticmethod
114
- def _override_env(override) -> dict[str, str] | None:
115
- """`${VAR}` references resolve from the environment at invocation time, so a
116
- port assigned by the harness need not be hard-coded in the reviewed file."""
117
- if override is None or not override.env:
149
+ def _suite_env(self, override) -> dict[str, str] | None:
150
+ """The environment for one suite invocation: the project's `env`, with the
151
+ owning override's layered on top (None means the default suite). `${VAR}`
152
+ references resolve from the environment at invocation time, so a port
153
+ assigned per checkout need not be hard-coded in the reviewed file."""
154
+ merged = {**self.project.env, **(override.env if override else {})}
155
+ if not merged:
118
156
  return None
119
- return {k: os.path.expandvars(v) for k, v in override.env.items()}
157
+ return {k: os.path.expandvars(v) for k, v in merged.items()}
120
158
 
121
159
  def stub_hint(self) -> str:
122
160
  """The language idiom for a stub body, quoted into the create_stub directive."""
@@ -128,6 +166,15 @@ class Adapter:
128
166
  def collectable(self) -> GateResult:
129
167
  raise NotImplementedError
130
168
 
169
+ def override_isolation(self) -> GateResult:
170
+ """Whether the default suite's discovery stays out of files an override
171
+ owns (R7.13's premise). Overlap means runs observe those tests without
172
+ the override's command/env — and the union then holds the same test
173
+ twice with conflicting outcomes. Adapters with a way to probe discovery
174
+ override this; the base answer is ok so third-party adapters without a
175
+ probe don't fail doctor."""
176
+ return GateResult(ok=True)
177
+
131
178
  def lint(self) -> GateResult:
132
179
  return self._gate(self.project.lint)
133
180
 
@@ -25,6 +25,9 @@ from .base import (
25
25
  Collection,
26
26
  GateResult,
27
27
  Verdict,
28
+ _overlap_error,
29
+ _suite_overlap,
30
+ clip_failure,
28
31
  run_command,
29
32
  )
30
33
 
@@ -100,6 +103,7 @@ class PytestAdapter(Adapter):
100
103
  # agent to rewrite a test that is fine.
101
104
  tests: list[dict] = []
102
105
  collectors: list[dict] = []
106
+ suite_ids: list[set[str]] = []
103
107
  for base_cmd, extra_env in self._suite_invocations():
104
108
  report, error = self._suite_report(base_cmd, extra_env)
105
109
  if report is None:
@@ -108,6 +112,12 @@ class PytestAdapter(Adapter):
108
112
  verdict.duration_ms += int(report.get("duration", 0) * 1000)
109
113
  tests.extend(report.get("tests", []))
110
114
  collectors.extend(report.get("collectors", []))
115
+ suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
116
+
117
+ overlap = _suite_overlap(suite_ids)
118
+ if overlap:
119
+ verdict.error = _overlap_error(overlap)
120
+ return verdict
111
121
 
112
122
  uncollectable: set[str] = set()
113
123
  for collector in collectors:
@@ -130,12 +140,14 @@ class PytestAdapter(Adapter):
130
140
  if hit is not None:
131
141
  verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
132
142
  call = hit.get("call") or hit.get("setup") or {}
133
- verdict.target_failure = str(call.get("longrepr", ""))[:1500]
143
+ verdict.target_failure = clip_failure(str(call.get("longrepr", "")))
134
144
  else:
135
145
  target_file = native.split("::", 1)[0]
136
146
  if any(c == target_file or c.startswith(target_file) for c in uncollectable):
137
147
  verdict.target_outcome = NOT_COLLECTED
138
- verdict.target_failure = self._collector_error(collectors, target_file)[:1500]
148
+ verdict.target_failure = clip_failure(
149
+ self._collector_error(collectors, target_file)
150
+ )
139
151
  else:
140
152
  verdict.target_outcome = NOT_FOUND
141
153
  return verdict
@@ -153,8 +165,8 @@ class PytestAdapter(Adapter):
153
165
  `test_command` — pytest's `--collect-only` composes with any run command."""
154
166
  ov = self.project.override_for(rel)
155
167
  if ov is None:
156
- return self._collect_cmd(), None
157
- return ov.collect_command or ov.test_command, self._override_env(ov)
168
+ return self._collect_cmd(), self._suite_env(None)
169
+ return ov.collect_command or ov.test_command, self._suite_env(ov)
158
170
 
159
171
  def _test_files(self) -> list[Path]:
160
172
  found: list[Path] = []
@@ -181,8 +193,8 @@ class PytestAdapter(Adapter):
181
193
  loses the real error and the failure surfaces unattributed.
182
194
  """
183
195
  chunks = []
184
- probes = [(self._collect_cmd(), None)] + [
185
- (ov.collect_command or ov.test_command, self._override_env(ov))
196
+ probes = [(self._collect_cmd(), self._suite_env(None))] + [
197
+ (ov.collect_command or ov.test_command, self._suite_env(ov))
186
198
  for ov in self.project.overrides
187
199
  ]
188
200
  for cmd, env in probes:
@@ -193,6 +205,34 @@ class PytestAdapter(Adapter):
193
205
  chunks.append(out.strip())
194
206
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
195
207
 
208
+ def override_isolation(self) -> GateResult:
209
+ """Probes the *test* command's discovery, not `collect_command`'s: the
210
+ test command is what runs at suite time, and a scoped `test_command`
211
+ with a bare per-file `collect_command` is a legitimate registry (the
212
+ per-file command always gets an explicit path). `{workers}` becomes 0 —
213
+ xdist's "no workers" — since discovery needs no parallelism. A probe
214
+ that fails to collect at all is `collectable`'s finding, not this one's."""
215
+ if not self.project.overrides:
216
+ return GateResult(ok=True)
217
+ probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
218
+ code, out, err = run_command(probe, self.root, extra_env=self._suite_env(None))
219
+ reached = sorted({
220
+ f for f in (
221
+ line.split("::", 1)[0]
222
+ for line in out.splitlines()
223
+ if "::" in line
224
+ )
225
+ if self.project.override_for(f)
226
+ })
227
+ if not reached:
228
+ return GateResult(ok=True)
229
+ return GateResult(ok=False, output=(
230
+ "the default suite's discovery reaches files an override owns, so"
231
+ " suite runs would observe them without the override's command/env:"
232
+ f" {', '.join(reached[:5])}. Scope the default test_command so it"
233
+ " cannot reach them (e.g. `pytest tests/`)."
234
+ ))
235
+
196
236
  def collect(self) -> Collection:
197
237
  """Per file (R10.3) — one uncollectable module must not destroy the whole set."""
198
238
  result = Collection()
@@ -1,8 +1,11 @@
1
1
  """vitest adapter.
2
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).
3
+ Test ids are `<project-root-relative file> > <fullName>`, where fullName is the
4
+ space-joined ancestorTitles plus the test title. Root-relative matches the pytest
5
+ adapter's nodeids and decisively `Engine._qualify`, which strips the project
6
+ root from plan declarations; a worktree-relative id here can never equal a
7
+ declared target. vitest may prefix its JSON with non-JSON lines, so the payload
8
+ is located rather than assumed (R10.2).
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -21,6 +24,9 @@ from .base import (
21
24
  Collection,
22
25
  GateResult,
23
26
  Verdict,
27
+ _overlap_error,
28
+ _suite_overlap,
29
+ clip_failure,
24
30
  run_command,
25
31
  )
26
32
 
@@ -44,7 +50,7 @@ class VitestAdapter(Adapter):
44
50
  def _id_for(self, suite_path: str, full_name: str) -> str:
45
51
  abs_path = Path(suite_path)
46
52
  try:
47
- rel = os.path.relpath(abs_path, self.worktree)
53
+ rel = os.path.relpath(abs_path, self.root)
48
54
  except ValueError:
49
55
  rel = suite_path
50
56
  return self.qualify(f"{rel} > {full_name}")
@@ -61,6 +67,7 @@ class VitestAdapter(Adapter):
61
67
  # producing no JSON is a loud error, not a silent gap: swallowing it would
62
68
  # report a target living in that suite as `not_found`.
63
69
  suites: list[dict] = []
70
+ suite_ids: list[set[str]] = []
64
71
  for base, extra_env in self._suite_invocations():
65
72
  code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
66
73
  report = _extract_json(out)
@@ -70,7 +77,18 @@ class VitestAdapter(Adapter):
70
77
  )
71
78
  return verdict
72
79
  verdict.duration_ms += int(report.get("duration") or 0)
73
- suites.extend(report.get("testResults", []))
80
+ results = report.get("testResults", [])
81
+ suites.extend(results)
82
+ suite_ids.append({
83
+ self._id_for(s.get("name", ""), t["fullName"])
84
+ for s in results
85
+ for t in s.get("assertionResults", [])
86
+ })
87
+
88
+ overlap = _suite_overlap(suite_ids)
89
+ if overlap:
90
+ verdict.error = _overlap_error(overlap)
91
+ return verdict
74
92
 
75
93
  failed_suites: dict[str, str] = {}
76
94
 
@@ -78,7 +96,7 @@ class VitestAdapter(Adapter):
78
96
  suite_path = suite.get("name", "")
79
97
  assertions = suite.get("assertionResults", [])
80
98
  if not assertions and suite.get("status") == "failed":
81
- failed_suites[suite_path] = str(suite.get("message", ""))[:1500]
99
+ failed_suites[suite_path] = clip_failure(str(suite.get("message", "")))
82
100
  for t in assertions:
83
101
  qualified = self._id_for(suite_path, t["fullName"])
84
102
  if t["status"] == "passed":
@@ -99,7 +117,8 @@ class VitestAdapter(Adapter):
99
117
  for t in suite.get("assertionResults", []):
100
118
  if self._id_for(suite.get("name", ""), t["fullName"]) == target:
101
119
  verdict.target_failure = "\n".join(
102
- m[:600] for m in t.get("failureMessages", [])[:3]
120
+ clip_failure(m, 600)
121
+ for m in t.get("failureMessages", [])[:3]
103
122
  )
104
123
  return verdict
105
124
 
@@ -157,7 +176,9 @@ class VitestAdapter(Adapter):
157
176
  suite — against a live backend — just to enumerate it.
158
177
  """
159
178
  chunks = []
160
- code, out, err = run_command(self._collect_cmd(), self.root)
179
+ code, out, err = run_command(
180
+ self._collect_cmd(), self.root, extra_env=self._suite_env(None)
181
+ )
161
182
  if code != 0:
162
183
  chunks.append((err or out).strip())
163
184
  for ov in self.project.overrides:
@@ -169,12 +190,39 @@ class VitestAdapter(Adapter):
169
190
  )
170
191
  continue
171
192
  code, out, err = run_command(
172
- ov.collect_command, self.root, extra_env=self._override_env(ov)
193
+ ov.collect_command, self.root, extra_env=self._suite_env(ov)
173
194
  )
174
195
  if code != 0:
175
196
  chunks.append((err or out).strip())
176
197
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
177
198
 
199
+ def override_isolation(self) -> GateResult:
200
+ """Probes with the default `vitest list` — the same stand-in for the
201
+ default run config that `collectable()` already relies on (`vitest run`
202
+ has no listing mode, and running the suite just to enumerate it would
203
+ execute against whatever the tests need live)."""
204
+ if not self.project.overrides:
205
+ return GateResult(ok=True)
206
+ code, out, err = run_command(
207
+ self._collect_cmd(), self.root, extra_env=self._suite_env(None)
208
+ )
209
+ reached = sorted({
210
+ f for f in (
211
+ line.strip().partition(" > ")[0]
212
+ for line in out.splitlines()
213
+ if " > " in line
214
+ )
215
+ if self.project.override_for(f)
216
+ })
217
+ if not reached:
218
+ return GateResult(ok=True)
219
+ return GateResult(ok=False, output=(
220
+ "the default config's discovery reaches files an override owns, so"
221
+ " suite runs would observe them without the override's command/env:"
222
+ f" {', '.join(reached[:5])}. Exclude them from the default vitest"
223
+ " config (test.exclude) or scope its include globs."
224
+ ))
225
+
178
226
  def collect(self) -> Collection:
179
227
  result = Collection()
180
228
  for path in self._test_files():
@@ -187,7 +235,7 @@ class VitestAdapter(Adapter):
187
235
  )
188
236
  continue
189
237
  base = ov.collect_command if ov else self._collect_cmd()
190
- env = self._override_env(ov)
238
+ env = self._suite_env(ov)
191
239
  code, out, err = run_command(
192
240
  f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
193
241
  )
@@ -6,6 +6,7 @@ No command accepts a phase, a cycle number, or executor identity (R8.3).
6
6
  from __future__ import annotations
7
7
 
8
8
  import argparse
9
+ import difflib
9
10
  import json
10
11
  import os
11
12
  import socket
@@ -275,6 +276,16 @@ def cmd_doctor(args) -> Envelope:
275
276
  gate = adapter.collectable()
276
277
  check("collectable", gate.ok, gate.output, project=name)
277
278
 
279
+ # R7.13's premise — "files the default runner config cannot reach" — is
280
+ # a config property nothing else enforces. Probe it at preflight so the
281
+ # overlap is named here, not discovered as an opaque mid-cycle failure.
282
+ if project.overrides:
283
+ gate = adapter.override_isolation()
284
+ check(
285
+ "default suite cannot reach override files",
286
+ gate.ok, gate.output, project=name,
287
+ )
288
+
278
289
  projects[name] = {"ok": all(c["ok"] for c in checks[before:])}
279
290
 
280
291
  for art in cfg.artifacts.values():
@@ -812,6 +823,23 @@ def cmd_target(args) -> Envelope:
812
823
  cycle = ledger.open_cycle(run["id"])
813
824
  if cycle is None:
814
825
  return failure("no open cycle")
826
+
827
+ # The target must be grounded in observed collection, the same way phase is
828
+ # grounded in observed execution (#15): recording free text deferred a typo —
829
+ # or a speculative `tdd target env` — to the next suite run, where it
830
+ # surfaced as `not_found` against a test that never existed.
831
+ known: set[str] = set()
832
+ for name in json.loads(cycle["projects"]):
833
+ adapter = adapters.build(cfg.project(name), worktree)
834
+ known |= adapter.collect().tests
835
+ if args.test not in known:
836
+ close = difflib.get_close_matches(args.test, sorted(known), n=3, cutoff=0.6)
837
+ hint = f" Closest collected ids: {', '.join(close)}." if close else ""
838
+ return failure(
839
+ f"{args.test} is not a collected test in this cycle's projects;"
840
+ f" the target was not changed.{hint}"
841
+ )
842
+
815
843
  ledger.update("cycle", cycle["id"], target_tests=json.dumps([args.test]))
816
844
  ledger.event(run["id"], cycle["id"], "target_named_by_agent", args.test)
817
845
  return Envelope(
@@ -88,6 +88,11 @@ class Project:
88
88
  #: Per-file collection. Must not be parallelised: collection is cheap and xdist
89
89
  #: adds startup cost per file.
90
90
  collect_command: str | None = None
91
+ #: Environment for the default suite's runs and collection, same semantics as
92
+ #: an override's `env`: `${VAR}` references expand from the environment at
93
+ #: invocation, so per-checkout values (a database port) stay out of the
94
+ #: reviewed file. An override's `env` layers on top for its own suite.
95
+ env: dict[str, str] = field(default_factory=dict)
91
96
  #: Alternate suites for files the default command cannot reach (R7.13).
92
97
  overrides: list[Override] = field(default_factory=list)
93
98
 
@@ -290,6 +295,13 @@ def load(worktree: Path) -> Config:
290
295
  raise ConfigError(f"project {name!r} has no root")
291
296
  if "adapter" not in body:
292
297
  raise ConfigError(f"project {name!r} has no adapter")
298
+ env = body.get("env", {})
299
+ if not isinstance(env, dict) or not all(
300
+ isinstance(v, str) for v in env.values()
301
+ ):
302
+ raise ConfigError(
303
+ f"project {name!r}: env must be a table of string values"
304
+ )
293
305
  projects[name] = Project(
294
306
  name=name,
295
307
  root=body["root"].rstrip("/"),
@@ -300,6 +312,7 @@ def load(worktree: Path) -> Config:
300
312
  in_close_sweep=body.get("in_close_sweep", True),
301
313
  test_command=body.get("test_command"),
302
314
  collect_command=body.get("collect_command"),
315
+ env=env,
303
316
  overrides=_load_overrides(name, body.get("override", [])),
304
317
  )
305
318
 
@@ -73,3 +73,46 @@ def test_doctor_does_not_check_node_modules_for_a_pytest_project(repo_multi):
73
73
  backend_checks = [c for c in checks if c.get("project") == "backend"]
74
74
  assert backend_checks, checks
75
75
  assert not any(c["check"] == "node_modules present" for c in backend_checks), backend_checks
76
+
77
+
78
+ def test_doctor_flags_a_default_suite_that_reaches_override_files(repo):
79
+ """R7.13's premise — "files the default runner config cannot reach" — is a
80
+ config property nothing else enforces. With a bare `pytest` default, its own
81
+ discovery sweeps the override directory, and every run would observe those
82
+ tests without the override's command/env. Doctor must name the overlap at
83
+ preflight instead of leaving it to fail opaquely mid-cycle."""
84
+ (repo / "backend" / "contract").mkdir()
85
+ (repo / "backend" / "contract" / "test_api.py").write_text(
86
+ "def test_ping():\n assert True\n"
87
+ )
88
+ (repo / "tdd.toml").write_text(
89
+ "[project.backend]\n"
90
+ 'root = "backend"\n'
91
+ 'adapter = "pytest"\n'
92
+ 'test_paths = ["tests/"]\n'
93
+ "[[project.backend.override]]\n"
94
+ 'pattern = "contract/"\n'
95
+ 'test_command = "pytest contract"\n'
96
+ )
97
+ out = run_cli(repo, "doctor")
98
+ isolation = [
99
+ c for c in out["result"]["checks"]
100
+ if c["check"] == "default suite cannot reach override files"
101
+ ]
102
+ assert len(isolation) == 1
103
+ assert isolation[0]["ok"] is False
104
+ assert isolation[0]["project"] == "backend"
105
+ assert "contract/test_api.py" in isolation[0]["detail"]
106
+
107
+ scoped = (repo / "tdd.toml").read_text().replace(
108
+ 'test_paths = ["tests/"]\n',
109
+ 'test_paths = ["tests/"]\ntest_command = "pytest tests"\n',
110
+ )
111
+ (repo / "tdd.toml").write_text(scoped)
112
+ out = run_cli(repo, "doctor")
113
+ isolation = [
114
+ c for c in out["result"]["checks"]
115
+ if c["check"] == "default suite cannot reach override files"
116
+ ]
117
+ assert len(isolation) == 1
118
+ assert isolation[0]["ok"] is True
@@ -0,0 +1,64 @@
1
+ """Failure text keeps the tail, where Python puts the error (#17).
2
+
3
+ `target_failure` was truncated to a fixed budget from the head. Deep tracebacks
4
+ (async frameworks, ORMs, HTTP stacks) exceed it easily, delivering frames of
5
+ framework internals and cutting exactly the line that says what went wrong —
6
+ forcing a manual re-run outside tdd to see an error the tool already had.
7
+ Both ends are kept: the head carries the assertion line for plain failures,
8
+ the tail carries the exception for deep ones.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+
16
+ from tddcli import adapters
17
+ from tddcli import config as config_mod
18
+ from tddcli.adapters.base import clip_failure
19
+
20
+
21
+ def test_short_text_passes_through_unclipped():
22
+ assert clip_failure("boom", 1500) == "boom"
23
+
24
+
25
+ def test_long_text_keeps_both_ends_and_marks_the_elision():
26
+ text = "assert first line\n" + ("framework frame\n" * 200) + "ConnectionRefusedError: [Errno 61]"
27
+ clipped = clip_failure(text, 1500)
28
+ assert len(clipped) <= 1500 + 20 # the marker is the only overhead
29
+ assert clipped.startswith("assert first line")
30
+ assert clipped.endswith("ConnectionRefusedError: [Errno 61]")
31
+ assert "…" in clipped
32
+
33
+
34
+ def _pytest_adapter(tmp_path):
35
+ (tmp_path / "tdd.toml").write_text(
36
+ "[project.backend]\n"
37
+ 'root = "backend"\n'
38
+ 'adapter = "pytest"\n'
39
+ 'test_paths = ["tests/"]\n'
40
+ 'test_command = "pytest tests"\n'
41
+ )
42
+ return adapters.build(config_mod.load(tmp_path).project("backend"), tmp_path)
43
+
44
+
45
+ def test_pytest_target_failure_keeps_the_error_at_the_tail(tmp_path, monkeypatch):
46
+ adapter = _pytest_adapter(tmp_path)
47
+ longrepr = ("connector frame\n" * 300) + "ConnectionRefusedError: [Errno 61]"
48
+
49
+ def fake(command, cwd, timeout=1800, extra_env=None):
50
+ marker = "--json-report-file="
51
+ path = command.split(marker, 1)[1].split(" --", 1)[0]
52
+ Path(path.strip("'\"")).write_text(json.dumps({
53
+ "tests": [{
54
+ "nodeid": "tests/test_db.py::test_connects",
55
+ "outcome": "failed",
56
+ "call": {"longrepr": longrepr},
57
+ }],
58
+ }))
59
+ return 1, "", ""
60
+
61
+ monkeypatch.setattr(adapters.base, "run_command", fake)
62
+ verdict = adapter.run("backend::tests/test_db.py::test_connects")
63
+ assert verdict.target_failure.endswith("ConnectionRefusedError: [Errno 61]")
64
+ assert len(verdict.target_failure) <= 1520
@@ -0,0 +1,124 @@
1
+ """Per-project `env` for the default suite (#16).
2
+
3
+ Override suites gained an `env` mapping in 0.2.0 because suite commands
4
+ sometimes need environment the invoking shell doesn't have. That need is not
5
+ specific to override suites: a default suite that reads an infrastructure
6
+ endpoint from a variable (a database port varying per worktree being the
7
+ motivating shape) previously had no registry-level way to receive it — the
8
+ workarounds were baking values into `test_command`, teaching the suite's own
9
+ bootstrap to hunt for env files, or exporting variables invisibly in whatever
10
+ shell runs `tdd advance`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import pytest as pytest_framework
19
+
20
+ from tddcli import adapters
21
+ from tddcli import config as config_mod
22
+ from tddcli.config import ConfigError
23
+
24
+
25
+ def project_with(tmp_path: Path, extra: str, adapter: str = "pytest"):
26
+ (tmp_path / "tdd.toml").write_text(
27
+ "[project.backend]\n"
28
+ 'root = "backend"\n'
29
+ f'adapter = "{adapter}"\n'
30
+ 'test_paths = ["tests/"]\n' + extra
31
+ )
32
+ return config_mod.load(tmp_path).project("backend")
33
+
34
+
35
+ def test_project_env_is_parsed_from_the_registry(tmp_path):
36
+ project = project_with(
37
+ tmp_path, 'env = { TEST_DB_PORT = "${DB_PORT}" }\n'
38
+ )
39
+ assert project.env == {"TEST_DB_PORT": "${DB_PORT}"}
40
+
41
+
42
+ def test_project_env_must_be_string_valued(tmp_path):
43
+ with pytest_framework.raises(ConfigError, match="env must be a table"):
44
+ project_with(tmp_path, "env = { PORT = 6032 }\n")
45
+
46
+
47
+ def test_default_suite_runs_with_the_project_env_expanded(tmp_path, monkeypatch):
48
+ monkeypatch.setenv("DB_PORT", "6032")
49
+ project = project_with(
50
+ tmp_path,
51
+ 'test_command = "pytest tests"\n'
52
+ 'env = { TEST_DB_PORT = "${DB_PORT}" }\n',
53
+ )
54
+ adapter = adapters.build(project, tmp_path)
55
+ seen: list = []
56
+
57
+ def fake(command, cwd, timeout=1800, extra_env=None):
58
+ seen.append((command, extra_env))
59
+ marker = "--json-report-file="
60
+ path = command.split(marker, 1)[1].split(" --", 1)[0]
61
+ Path(path.strip("'\"")).write_text(json.dumps({"tests": []}))
62
+ return 0, "", ""
63
+
64
+ monkeypatch.setattr(adapters.base, "run_command", fake)
65
+ adapter.run(None)
66
+ ((_, env),) = seen
67
+ assert env["TEST_DB_PORT"] == "6032"
68
+
69
+
70
+ def test_override_env_layers_on_top_of_the_project_env(tmp_path, monkeypatch):
71
+ monkeypatch.setenv("DB_PORT", "6032")
72
+ project = project_with(
73
+ tmp_path,
74
+ 'test_command = "pytest tests"\n'
75
+ 'env = { TEST_DB_PORT = "${DB_PORT}", SHARED = "from-project" }\n'
76
+ "[[project.backend.override]]\n"
77
+ 'pattern = "contract/"\n'
78
+ 'test_command = "pytest contract"\n'
79
+ 'env = { SHARED = "from-override" }\n',
80
+ )
81
+ adapter = adapters.build(project, tmp_path)
82
+ invocations = adapter._suite_invocations()
83
+ assert invocations[0][1] == {"TEST_DB_PORT": "6032", "SHARED": "from-project"}
84
+ assert invocations[1][1] == {"TEST_DB_PORT": "6032", "SHARED": "from-override"}
85
+
86
+
87
+ def test_pytest_collection_of_default_files_carries_the_project_env(
88
+ tmp_path, monkeypatch
89
+ ):
90
+ project = project_with(tmp_path, 'env = { TEST_DB_PORT = "6032" }\n')
91
+ (tmp_path / "backend" / "tests").mkdir(parents=True)
92
+ (tmp_path / "backend" / "tests" / "test_a.py").write_text(
93
+ "def test_a(): pass\n"
94
+ )
95
+ adapter = adapters.build(project, tmp_path)
96
+ seen: list = []
97
+
98
+ def fake(command, cwd, timeout=1800, extra_env=None):
99
+ seen.append((command, extra_env))
100
+ return 0, "tests/test_a.py::test_a\n", ""
101
+
102
+ monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
103
+ adapter.collect()
104
+ assert seen and all(env == {"TEST_DB_PORT": "6032"} for _, env in seen)
105
+
106
+
107
+ def test_vitest_default_suite_runs_with_the_project_env(tmp_path, monkeypatch):
108
+ project = project_with(
109
+ tmp_path,
110
+ 'test_command = "npx vitest run"\n'
111
+ 'env = { API_URL = "http://localhost:6032" }\n',
112
+ adapter="vitest",
113
+ )
114
+ adapter = adapters.build(project, tmp_path)
115
+ seen: list = []
116
+
117
+ def fake(command, cwd, timeout=1800, extra_env=None):
118
+ seen.append((command, extra_env))
119
+ return 0, json.dumps({"testResults": []}), ""
120
+
121
+ monkeypatch.setattr(adapters.base, "run_command", fake)
122
+ adapter.run(None)
123
+ ((_, env),) = seen
124
+ assert env["API_URL"] == "http://localhost:6032"
@@ -286,12 +286,12 @@ def test_vitest_run_finds_a_target_that_only_the_override_config_reaches(
286
286
  return 1, json.dumps(report), ""
287
287
 
288
288
  monkeypatch.setattr(adapters.base, "run_command", fake)
289
- target = "backend::backend/contract/api.contract.test.ts > pings the api"
289
+ target = "backend::contract/api.contract.test.ts > pings the api"
290
290
  verdict = adapter.run(target)
291
291
  assert verdict.error is None
292
292
  assert verdict.target_outcome == "failed"
293
293
  assert verdict.target_failure == "nope"
294
- assert verdict.passed == ["backend::backend/unit.test.ts > adds"]
294
+ assert verdict.passed == ["backend::unit.test.ts > adds"]
295
295
 
296
296
 
297
297
  def test_vitest_override_without_collect_command_fails_the_collectable_gate(
@@ -338,7 +338,7 @@ def test_vitest_collection_routes_override_files_to_the_override_command(
338
338
  monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
339
339
  collection = adapter.collect()
340
340
  assert collection.tests == {
341
- "backend::backend/contract/api.contract.test.ts > pings the api"
341
+ "backend::contract/api.contract.test.ts > pings the api"
342
342
  }
343
343
  assert seen[0].startswith("npx vitest list --config vitest.contract.config.ts ")
344
344
 
@@ -404,3 +404,182 @@ def test_a_cycle_can_target_a_test_only_an_override_suite_reaches(repo):
404
404
  )
405
405
  green = run_cli(repo, "advance")
406
406
  assert green["next_action"]["verb"] == "refactor_or_advance", green
407
+
408
+
409
+ # -- overlap between the default suite and an override suite -------------
410
+ #
411
+ # The feature's premise is "files the default runner config cannot reach". A
412
+ # bare default command (plain `pytest`) quietly breaks that premise: its own
413
+ # discovery sweeps the override directories, the union then holds the same test
414
+ # twice — once observed without the override's command/env — and target
415
+ # matching judged the target by whichever suite reported it first. That failure
416
+ # is opaque (the test fails on some downstream assertion, nothing names the
417
+ # overlap), so it must be a loud typed error instead.
418
+
419
+
420
+ def test_pytest_duplicate_nodeid_across_suites_is_a_loud_error(
421
+ tmp_path, monkeypatch
422
+ ):
423
+ project = project_with(
424
+ tmp_path, 'test_command = "pytest"\n' + OVERRIDE_BLOCK
425
+ )
426
+ adapter = adapters.build(project, tmp_path)
427
+ duplicated = {
428
+ "nodeid": "contract/test_api.py::test_ping",
429
+ "outcome": "failed",
430
+ "call": {"longrepr": "assert None == '1'"},
431
+ }
432
+ monkeypatch.setattr(
433
+ adapters.base,
434
+ "run_command",
435
+ _fake_pytest_run(
436
+ {
437
+ # Bare default discovery sweeps contract/ too.
438
+ "pytest --json-report": {"duration": 1.0, "tests": [duplicated]},
439
+ "pytest contract": {
440
+ "duration": 2.0,
441
+ "tests": [dict(duplicated, outcome="passed")],
442
+ },
443
+ },
444
+ [],
445
+ ),
446
+ )
447
+ verdict = adapter.run("backend::contract/test_api.py::test_ping")
448
+ assert verdict.error is not None
449
+ assert "contract/test_api.py::test_ping" in verdict.error
450
+ assert "more than one suite" in verdict.error
451
+ assert "test_command" in verdict.error
452
+
453
+
454
+ def test_vitest_duplicate_test_id_across_suites_is_a_loud_error(
455
+ tmp_path, monkeypatch
456
+ ):
457
+ project = project_with(
458
+ tmp_path,
459
+ 'test_command = "npx vitest run"\n'
460
+ "[[project.backend.override]]\n"
461
+ 'pattern = "contract/"\n'
462
+ 'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
463
+ 'collect_command = "npx vitest list --config vitest.contract.config.ts"\n',
464
+ adapter="vitest",
465
+ )
466
+ adapter = adapters.build(project, tmp_path)
467
+ suite_path = str(tmp_path / "backend" / "contract" / "api.test.ts")
468
+
469
+ def result(status):
470
+ return {
471
+ "testResults": [
472
+ {
473
+ "name": suite_path,
474
+ "status": status,
475
+ "assertionResults": [
476
+ {"fullName": "pings", "status": status}
477
+ ],
478
+ }
479
+ ]
480
+ }
481
+
482
+ def fake(command, cwd, timeout=1800, extra_env=None):
483
+ if "--config" in command:
484
+ return 0, json.dumps(result("passed")), ""
485
+ return 1, json.dumps(result("failed")), ""
486
+
487
+ monkeypatch.setattr(adapters.base, "run_command", fake)
488
+ verdict = adapter.run("backend::contract/api.test.ts > pings")
489
+ assert verdict.error is not None
490
+ assert "contract/api.test.ts > pings" in verdict.error
491
+ assert "more than one suite" in verdict.error
492
+
493
+
494
+ def test_pytest_isolation_probe_flags_default_reach_into_override_files(
495
+ tmp_path, monkeypatch
496
+ ):
497
+ project = project_with(
498
+ tmp_path, 'test_command = "pytest -n {workers}"\n' + OVERRIDE_BLOCK
499
+ )
500
+ adapter = adapters.build(project, tmp_path)
501
+ seen: list = []
502
+
503
+ def fake(command, cwd, timeout=1800, extra_env=None):
504
+ seen.append(command)
505
+ return 0, (
506
+ "tests/test_a.py::test_a\n"
507
+ "contract/test_api.py::test_ping\n"
508
+ "2 tests collected in 0.01s\n"
509
+ ), ""
510
+
511
+ monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
512
+ gate = adapter.override_isolation()
513
+ assert gate.ok is False
514
+ assert "contract/test_api.py" in gate.output
515
+ assert "test_command" in gate.output
516
+ # The probe asks the *test* command what it would discover — that is the
517
+ # command whose reach matters at run time — with parallelism disabled.
518
+ assert seen == ["pytest -n 0 --collect-only -q"]
519
+
520
+
521
+ def test_pytest_isolation_probe_passes_when_the_default_suite_is_scoped(
522
+ tmp_path, monkeypatch
523
+ ):
524
+ project = project_with(
525
+ tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK
526
+ )
527
+ adapter = adapters.build(project, tmp_path)
528
+ monkeypatch.setattr(
529
+ adapters.pytest_adapter,
530
+ "run_command",
531
+ lambda command, cwd, timeout=1800, extra_env=None: (
532
+ 0, "tests/test_a.py::test_a\n", ""
533
+ ),
534
+ )
535
+ gate = adapter.override_isolation()
536
+ assert gate.ok is True
537
+
538
+
539
+ def test_vitest_isolation_probe_flags_default_reach_into_override_files(
540
+ tmp_path, monkeypatch
541
+ ):
542
+ project = project_with(
543
+ tmp_path,
544
+ "[[project.backend.override]]\n"
545
+ 'pattern = "contract/"\n'
546
+ 'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
547
+ 'collect_command = "npx vitest list --config vitest.contract.config.ts"\n',
548
+ adapter="vitest",
549
+ )
550
+ adapter = adapters.build(project, tmp_path)
551
+ seen: list = []
552
+
553
+ def fake(command, cwd, timeout=1800, extra_env=None):
554
+ seen.append(command)
555
+ return 0, (
556
+ "src/__tests__/a.test.ts > adds\n"
557
+ "contract/api.test.ts > pings\n"
558
+ ), ""
559
+
560
+ monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
561
+ gate = adapter.override_isolation()
562
+ assert gate.ok is False
563
+ assert "contract/api.test.ts" in gate.output
564
+ assert seen == ["npx vitest list"]
565
+
566
+
567
+ def test_isolation_probe_is_free_when_a_project_declares_no_overrides(
568
+ tmp_path, monkeypatch
569
+ ):
570
+ project = project_with(tmp_path, 'test_command = "pytest tests"\n')
571
+ adapter = adapters.build(project, tmp_path)
572
+
573
+ def explode(command, cwd, timeout=1800, extra_env=None):
574
+ raise AssertionError("no probe should run without overrides")
575
+
576
+ monkeypatch.setattr(adapters.pytest_adapter, "run_command", explode)
577
+ assert adapter.override_isolation().ok is True
578
+
579
+
580
+ def test_overlap_error_truncates_past_five_ids():
581
+ ids = [f"contract/test_api.py::test_{i}" for i in range(7)]
582
+ message = adapters.base._overlap_error(ids)
583
+ assert "test_4" in message and "test_5" not in message
584
+ assert "and 2 more" in message
585
+ assert "test_command" in message
@@ -0,0 +1,55 @@
1
+ """`tdd target` must refuse a name that is not a collected test (#15).
2
+
3
+ The command previously recorded whatever string it was given. A typo — or an
4
+ agent probing the CLI surface with something like `tdd target env` — silently
5
+ *changed the target* to a nonexistent test, deferred the failure to the next
6
+ suite run, and misattributed it as `not_found`, pointing the executor at a test
7
+ that was never supposed to exist. Phase is derived from observed execution;
8
+ the target must be grounded in observed collection the same way.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from conftest import run_cli, write_plan
14
+
15
+ PLAN = """---
16
+ cycles:
17
+ - n: 1
18
+ project: backend
19
+ title: "adding two numbers"
20
+ test: "tests/test_add.py::test_add_two_numbers"
21
+ stub_expected: ["app/calc.py"]
22
+ commit_red: "test: adding two numbers"
23
+ commit_green: "feat: add()"
24
+ ---
25
+
26
+ # Plan
27
+ """
28
+
29
+
30
+ def start(repo):
31
+ plan = write_plan(repo, PLAN)
32
+ assert run_cli(repo, "plan", "register", plan)["ok"]
33
+ started = run_cli(repo, "run", "start", "--plan", plan)
34
+ assert started["ok"], started
35
+
36
+
37
+ def test_target_refuses_a_name_that_is_not_a_collected_test(repo):
38
+ start(repo)
39
+ out = run_cli(repo, "target", "env")
40
+ assert out["ok"] is False
41
+ assert "not a collected test" in out["error"]
42
+
43
+
44
+ def test_target_suggests_the_closest_collected_ids_on_a_near_miss(repo):
45
+ start(repo)
46
+ out = run_cli(repo, "target", "backend::tests/test_smoke.py::test_smok")
47
+ assert out["ok"] is False
48
+ assert "backend::tests/test_smoke.py::test_smoke" in out["error"]
49
+
50
+
51
+ def test_target_accepts_a_collected_test(repo):
52
+ start(repo)
53
+ out = run_cli(repo, "target", "backend::tests/test_smoke.py::test_smoke")
54
+ assert out["ok"] is True, out
55
+ assert out["result"]["target"] == "backend::tests/test_smoke.py::test_smoke"
@@ -56,11 +56,39 @@ def test_parsed_ids_match_the_form_run_produces(tmp_path):
56
56
  assert from_run in from_list, sorted(from_list)
57
57
 
58
58
 
59
- def test_ids_are_project_namespaced_and_worktree_relative(tmp_path):
59
+ def test_ids_are_project_namespaced_and_root_relative(tmp_path):
60
+ """The path inside the id is project-root-relative, like a pytest nodeid.
61
+
62
+ Found live: ids were worktree-relative (`frontend::frontend/app/...`) while
63
+ `Engine._qualify` strips the root prefix from plan declarations
64
+ (`frontend::app/...`), so a declared vitest target could never match a
65
+ verdict. Standard cycles limped through on R8.9 adoption; pin cycles
66
+ deadlocked in AWAITING_PIN because the pinned test predates the run.
67
+ """
60
68
  adapter = adapter_for(tmp_path)
61
69
  path = tmp_path / "frontend" / "contexts" / "__tests__" / "AuthContext.test.tsx"
62
70
  one = next(iter(adapter._parse_list_output(LIST_OUTPUT, path)))
63
- assert one.startswith("frontend::frontend/contexts/__tests__/AuthContext.test.tsx > ")
71
+ assert one.startswith("frontend::contexts/__tests__/AuthContext.test.tsx > ")
72
+
73
+
74
+ def test_ids_match_what_a_plan_declaration_qualifies_to(tmp_path):
75
+ """A plan declaring `frontend/contexts/... > name` must hit the adapter's id."""
76
+ from tddcli.contract import DeclaredCycle
77
+ from tddcli.machine import Engine
78
+
79
+ adapter = adapter_for(tmp_path)
80
+ path = tmp_path / "frontend" / "contexts" / "__tests__" / "AuthContext.test.tsx"
81
+ declared = DeclaredCycle(
82
+ ordinal=1,
83
+ kind="pin",
84
+ projects=["frontend"],
85
+ tests=[
86
+ "frontend/contexts/__tests__/AuthContext.test.tsx"
87
+ " > logout clears the stored token"
88
+ ],
89
+ )
90
+ qualified = Engine._qualify(declared, declared.tests[0])
91
+ assert qualified in adapter._parse_list_output(LIST_OUTPUT, path)
64
92
 
65
93
 
66
94
  def test_arrows_are_not_left_in_the_name(tmp_path):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes