tdd-cli 0.2.1__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.1 → tdd_cli-0.3.0}/CHANGELOG.md +26 -1
  2. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/PKG-INFO +1 -1
  3. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/__init__.py +1 -1
  4. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/adapters/base.py +24 -8
  5. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/adapters/pytest_adapter.py +10 -7
  6. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/adapters/vitest_adapter.py +19 -10
  7. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/cli.py +18 -0
  8. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/config.py +13 -0
  9. tdd_cli-0.3.0/tests/test_failure_clipping.py +64 -0
  10. tdd_cli-0.3.0/tests/test_project_env.py +124 -0
  11. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_suite_overrides.py +5 -5
  12. tdd_cli-0.3.0/tests/test_target_validation.py +55 -0
  13. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_vitest_adapter.py +30 -2
  14. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/.gitignore +0 -0
  15. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/LICENSE +0 -0
  16. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/README.md +0 -0
  17. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/SECURITY.md +0 -0
  18. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/claude-code-hooks/README.md +0 -0
  19. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  20. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  21. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/plan.md +0 -0
  22. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/skills/tdd-drive/README.md +0 -0
  23. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  24. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/README.md +0 -0
  25. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  26. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/pyproject.toml +0 -0
  27. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/adapters/__init__.py +0 -0
  28. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/advance.py +0 -0
  29. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/contract.py +0 -0
  30. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/envelope.py +0 -0
  31. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/fleet.py +0 -0
  32. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/gitutil.py +0 -0
  33. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/identity.py +0 -0
  34. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/leases.py +0 -0
  35. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/ledger.py +0 -0
  36. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/machine.py +0 -0
  37. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/render.py +0 -0
  38. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/snapshot.py +0 -0
  39. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/src/tddcli/staging.py +0 -0
  40. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/conftest.py +0 -0
  41. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_artifact_regeneration.py +0 -0
  42. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_baseline_integrity.py +0 -0
  43. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_config_and_staging.py +0 -0
  44. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_config_drift.py +0 -0
  45. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_contract.py +0 -0
  46. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_doctor_attribution.py +0 -0
  47. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_end_to_end.py +0 -0
  48. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_example_plan.py +0 -0
  49. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_fleet.py +0 -0
  50. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_heartbeat.py +0 -0
  51. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_init_detection.py +0 -0
  52. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_pin_cycles.py +0 -0
  53. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_progress.py +0 -0
  54. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_project_commands.py +0 -0
  55. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_python_env_managers.py +0 -0
  56. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_refactor_cycles.py +0 -0
  57. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_release_surface.py +0 -0
  58. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_run_claim.py +0 -0
  59. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_single_project_repo.py +0 -0
  60. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_snapshot_and_identity.py +0 -0
  61. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_stub_hint.py +0 -0
  62. {tdd_cli-0.2.1 → tdd_cli-0.3.0}/tests/test_worker_leases.py +0 -0
@@ -4,7 +4,32 @@ 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).
8
33
 
9
34
  ## [0.2.1] - 2026-08-10
10
35
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tdd-cli
3
- Version: 0.2.1
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.1"
6
+ __version__ = "0.3.0"
@@ -64,6 +64,20 @@ def _overlap_error(overlap: list[str]) -> str:
64
64
  )
65
65
 
66
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
+
67
81
  def run_command(
68
82
  command: str, cwd: Path, timeout: int = 1800,
69
83
  extra_env: dict[str, str] | None = None,
@@ -128,17 +142,19 @@ class Adapter:
128
142
  alternate runner config is still observed — without widening the default
129
143
  config, which is exactly the workaround that breaks CI.
130
144
  """
131
- return [(self._test_cmd(), None)] + [
132
- (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
133
147
  ]
134
148
 
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:
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:
140
156
  return None
141
- 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()}
142
158
 
143
159
  def stub_hint(self) -> str:
144
160
  """The language idiom for a stub body, quoted into the create_stub directive."""
@@ -27,6 +27,7 @@ from .base import (
27
27
  Verdict,
28
28
  _overlap_error,
29
29
  _suite_overlap,
30
+ clip_failure,
30
31
  run_command,
31
32
  )
32
33
 
@@ -139,12 +140,14 @@ class PytestAdapter(Adapter):
139
140
  if hit is not None:
140
141
  verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
141
142
  call = hit.get("call") or hit.get("setup") or {}
142
- verdict.target_failure = str(call.get("longrepr", ""))[:1500]
143
+ verdict.target_failure = clip_failure(str(call.get("longrepr", "")))
143
144
  else:
144
145
  target_file = native.split("::", 1)[0]
145
146
  if any(c == target_file or c.startswith(target_file) for c in uncollectable):
146
147
  verdict.target_outcome = NOT_COLLECTED
147
- verdict.target_failure = self._collector_error(collectors, target_file)[:1500]
148
+ verdict.target_failure = clip_failure(
149
+ self._collector_error(collectors, target_file)
150
+ )
148
151
  else:
149
152
  verdict.target_outcome = NOT_FOUND
150
153
  return verdict
@@ -162,8 +165,8 @@ class PytestAdapter(Adapter):
162
165
  `test_command` — pytest's `--collect-only` composes with any run command."""
163
166
  ov = self.project.override_for(rel)
164
167
  if ov is None:
165
- return self._collect_cmd(), None
166
- 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)
167
170
 
168
171
  def _test_files(self) -> list[Path]:
169
172
  found: list[Path] = []
@@ -190,8 +193,8 @@ class PytestAdapter(Adapter):
190
193
  loses the real error and the failure surfaces unattributed.
191
194
  """
192
195
  chunks = []
193
- probes = [(self._collect_cmd(), None)] + [
194
- (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))
195
198
  for ov in self.project.overrides
196
199
  ]
197
200
  for cmd, env in probes:
@@ -212,7 +215,7 @@ class PytestAdapter(Adapter):
212
215
  if not self.project.overrides:
213
216
  return GateResult(ok=True)
214
217
  probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
215
- code, out, err = run_command(probe, self.root)
218
+ code, out, err = run_command(probe, self.root, extra_env=self._suite_env(None))
216
219
  reached = sorted({
217
220
  f for f in (
218
221
  line.split("::", 1)[0]
@@ -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
@@ -23,6 +26,7 @@ from .base import (
23
26
  Verdict,
24
27
  _overlap_error,
25
28
  _suite_overlap,
29
+ clip_failure,
26
30
  run_command,
27
31
  )
28
32
 
@@ -46,7 +50,7 @@ class VitestAdapter(Adapter):
46
50
  def _id_for(self, suite_path: str, full_name: str) -> str:
47
51
  abs_path = Path(suite_path)
48
52
  try:
49
- rel = os.path.relpath(abs_path, self.worktree)
53
+ rel = os.path.relpath(abs_path, self.root)
50
54
  except ValueError:
51
55
  rel = suite_path
52
56
  return self.qualify(f"{rel} > {full_name}")
@@ -92,7 +96,7 @@ class VitestAdapter(Adapter):
92
96
  suite_path = suite.get("name", "")
93
97
  assertions = suite.get("assertionResults", [])
94
98
  if not assertions and suite.get("status") == "failed":
95
- failed_suites[suite_path] = str(suite.get("message", ""))[:1500]
99
+ failed_suites[suite_path] = clip_failure(str(suite.get("message", "")))
96
100
  for t in assertions:
97
101
  qualified = self._id_for(suite_path, t["fullName"])
98
102
  if t["status"] == "passed":
@@ -113,7 +117,8 @@ class VitestAdapter(Adapter):
113
117
  for t in suite.get("assertionResults", []):
114
118
  if self._id_for(suite.get("name", ""), t["fullName"]) == target:
115
119
  verdict.target_failure = "\n".join(
116
- m[:600] for m in t.get("failureMessages", [])[:3]
120
+ clip_failure(m, 600)
121
+ for m in t.get("failureMessages", [])[:3]
117
122
  )
118
123
  return verdict
119
124
 
@@ -171,7 +176,9 @@ class VitestAdapter(Adapter):
171
176
  suite — against a live backend — just to enumerate it.
172
177
  """
173
178
  chunks = []
174
- 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
+ )
175
182
  if code != 0:
176
183
  chunks.append((err or out).strip())
177
184
  for ov in self.project.overrides:
@@ -183,7 +190,7 @@ class VitestAdapter(Adapter):
183
190
  )
184
191
  continue
185
192
  code, out, err = run_command(
186
- ov.collect_command, self.root, extra_env=self._override_env(ov)
193
+ ov.collect_command, self.root, extra_env=self._suite_env(ov)
187
194
  )
188
195
  if code != 0:
189
196
  chunks.append((err or out).strip())
@@ -196,7 +203,9 @@ class VitestAdapter(Adapter):
196
203
  execute against whatever the tests need live)."""
197
204
  if not self.project.overrides:
198
205
  return GateResult(ok=True)
199
- code, out, err = run_command(self._collect_cmd(), self.root)
206
+ code, out, err = run_command(
207
+ self._collect_cmd(), self.root, extra_env=self._suite_env(None)
208
+ )
200
209
  reached = sorted({
201
210
  f for f in (
202
211
  line.strip().partition(" > ")[0]
@@ -226,7 +235,7 @@ class VitestAdapter(Adapter):
226
235
  )
227
236
  continue
228
237
  base = ov.collect_command if ov else self._collect_cmd()
229
- env = self._override_env(ov)
238
+ env = self._suite_env(ov)
230
239
  code, out, err = run_command(
231
240
  f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
232
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
@@ -822,6 +823,23 @@ def cmd_target(args) -> Envelope:
822
823
  cycle = ledger.open_cycle(run["id"])
823
824
  if cycle is None:
824
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
+
825
843
  ledger.update("cycle", cycle["id"], target_tests=json.dumps([args.test]))
826
844
  ledger.event(run["id"], cycle["id"], "target_named_by_agent", args.test)
827
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
 
@@ -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
 
@@ -485,9 +485,9 @@ def test_vitest_duplicate_test_id_across_suites_is_a_loud_error(
485
485
  return 1, json.dumps(result("failed")), ""
486
486
 
487
487
  monkeypatch.setattr(adapters.base, "run_command", fake)
488
- verdict = adapter.run("backend::backend/contract/api.test.ts > pings")
488
+ verdict = adapter.run("backend::contract/api.test.ts > pings")
489
489
  assert verdict.error is not None
490
- assert "backend/contract/api.test.ts > pings" in verdict.error
490
+ assert "contract/api.test.ts > pings" in verdict.error
491
491
  assert "more than one suite" in verdict.error
492
492
 
493
493
 
@@ -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