tdd-cli 0.1.0__tar.gz → 0.2.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 (59) hide show
  1. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/CHANGELOG.md +13 -0
  2. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/PKG-INFO +30 -1
  3. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/README.md +29 -0
  4. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/base.py +26 -2
  6. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/pytest_adapter.py +61 -26
  7. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/vitest_adapter.py +62 -18
  8. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/config.py +84 -1
  9. tdd_cli-0.2.0/tests/test_suite_overrides.py +406 -0
  10. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/.gitignore +0 -0
  11. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/LICENSE +0 -0
  12. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/SECURITY.md +0 -0
  13. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/README.md +0 -0
  14. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  15. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  16. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/plan.md +0 -0
  17. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-drive/README.md +0 -0
  18. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  19. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-handoff/README.md +0 -0
  20. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  21. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/pyproject.toml +0 -0
  22. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/adapters/__init__.py +0 -0
  23. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/advance.py +0 -0
  24. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/cli.py +0 -0
  25. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/contract.py +0 -0
  26. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/envelope.py +0 -0
  27. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/fleet.py +0 -0
  28. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/gitutil.py +0 -0
  29. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/identity.py +0 -0
  30. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/leases.py +0 -0
  31. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/ledger.py +0 -0
  32. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/machine.py +0 -0
  33. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/render.py +0 -0
  34. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/snapshot.py +0 -0
  35. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/src/tddcli/staging.py +0 -0
  36. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/conftest.py +0 -0
  37. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_artifact_regeneration.py +0 -0
  38. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_baseline_integrity.py +0 -0
  39. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_config_and_staging.py +0 -0
  40. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_config_drift.py +0 -0
  41. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_contract.py +0 -0
  42. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_doctor_attribution.py +0 -0
  43. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_end_to_end.py +0 -0
  44. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_example_plan.py +0 -0
  45. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_fleet.py +0 -0
  46. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_heartbeat.py +0 -0
  47. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_init_detection.py +0 -0
  48. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_pin_cycles.py +0 -0
  49. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_progress.py +0 -0
  50. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_project_commands.py +0 -0
  51. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_python_env_managers.py +0 -0
  52. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_refactor_cycles.py +0 -0
  53. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_release_surface.py +0 -0
  54. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_run_claim.py +0 -0
  55. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_single_project_repo.py +0 -0
  56. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_snapshot_and_identity.py +0 -0
  57. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_stub_hint.py +0 -0
  58. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_vitest_adapter.py +0 -0
  59. {tdd_cli-0.1.0 → tdd_cli-0.2.0}/tests/test_worker_leases.py +0 -0
@@ -6,6 +6,19 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-08-10
10
+
11
+ ### Added
12
+
13
+ - Per-pattern suite overrides (`[[project.<name>.override]]` in `tdd.toml`): an
14
+ alternate `test_command` — plus optional `collect_command` and `env` — for
15
+ files the default runner config cannot reach, such as contract tests that need
16
+ a live backend. Collection and suite runs union the default suite with every
17
+ override suite, so a cycle can target such a test without widening the default
18
+ config (which breaks CI and pollutes target adoption with the other suite's
19
+ tests). Override patterns classify their files as tests without being repeated
20
+ in `test_paths`; `env` values may reference `${VAR}`, expanded at invocation.
21
+
9
22
  ## [0.1.0] - 2026-08-08
10
23
 
11
24
  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.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
@@ -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.0"
@@ -77,7 +77,9 @@ class Adapter:
77
77
  def run(self, target: str | None = None) -> Verdict:
78
78
  raise NotImplementedError
79
79
 
80
- def _run_suite(self, command: str) -> tuple[int, str, str]:
80
+ def _run_suite(
81
+ self, command: str, extra_env: dict[str, str] | None = None
82
+ ) -> tuple[int, str, str]:
81
83
  """Run the suite under a machine-wide worker lease.
82
84
 
83
85
  Substituting `{workers}` is opt-in per project; a command without the
@@ -91,9 +93,31 @@ class Adapter:
91
93
  return run_command(
92
94
  command.replace("{workers}", str(workers)),
93
95
  self.root,
94
- extra_env={"TDD_WORKERS": str(workers)},
96
+ extra_env={"TDD_WORKERS": str(workers), **(extra_env or {})},
95
97
  )
96
98
 
99
+ def _test_cmd(self) -> str:
100
+ raise NotImplementedError
101
+
102
+ def _suite_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
103
+ """The default suite plus one invocation per declared override (R7.13).
104
+
105
+ Runs and collection union these results, so a test reachable only under an
106
+ alternate runner config is still observed — without widening the default
107
+ config, which is exactly the workaround that breaks CI.
108
+ """
109
+ return [(self._test_cmd(), None)] + [
110
+ (ov.test_command, self._override_env(ov)) for ov in self.project.overrides
111
+ ]
112
+
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:
118
+ return None
119
+ return {k: os.path.expandvars(v) for k, v in override.env.items()}
120
+
97
121
  def stub_hint(self) -> str:
98
122
  """The language idiom for a stub body, quoted into the create_stub directive."""
99
123
  return "a body that fails loudly, never working logic"
@@ -71,8 +71,9 @@ class PytestAdapter(Adapter):
71
71
  def _collect_cmd(self) -> str:
72
72
  return self.project.collect_command or self._base_cmd()
73
73
 
74
- def run(self, target: str | None = None) -> Verdict:
75
- verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
74
+ def _suite_report(
75
+ self, base_cmd: str, extra_env: dict[str, str] | None
76
+ ) -> tuple[dict | None, str]:
76
77
  with tempfile.TemporaryDirectory(prefix="tdd-pytest-") as tmp:
77
78
  report_path = Path(tmp) / "report.json"
78
79
  # Only reporting flags are appended — parallelism, markers and plugins
@@ -80,26 +81,40 @@ class PytestAdapter(Adapter):
80
81
  # `collectors` is omitted when nothing fails to collect, and present with
81
82
  # the failing entry when something does, which is when it is consulted.
82
83
  cmd = (
83
- f"{self._test_cmd()} --json-report"
84
+ f"{base_cmd} --json-report"
84
85
  f" --json-report-file={shlex.quote(str(report_path))}"
85
86
  )
86
- code, out, err = self._run_suite(cmd)
87
+ code, out, err = self._run_suite(cmd, extra_env)
87
88
  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]
89
+ return None, (
90
+ f"`{base_cmd}` produced no JSON report"
91
+ " (is pytest-json-report installed?): " + (err or out)[:500]
91
92
  )
92
- return verdict
93
- report = json.loads(report_path.read_text())
93
+ return json.loads(report_path.read_text()), ""
94
94
 
95
- verdict.duration_ms = int(report.get("duration", 0) * 1000)
95
+ def run(self, target: str | None = None) -> Verdict:
96
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
97
+ # Union across the default suite and every override suite (R7.13). A suite
98
+ # that produces no report is a loud error, not a silent gap: swallowing it
99
+ # would report a target that lives in that suite as `not_found`, sending the
100
+ # agent to rewrite a test that is fine.
101
+ tests: list[dict] = []
102
+ collectors: list[dict] = []
103
+ for base_cmd, extra_env in self._suite_invocations():
104
+ report, error = self._suite_report(base_cmd, extra_env)
105
+ if report is None:
106
+ verdict.error = error
107
+ return verdict
108
+ verdict.duration_ms += int(report.get("duration", 0) * 1000)
109
+ tests.extend(report.get("tests", []))
110
+ collectors.extend(report.get("collectors", []))
96
111
 
97
112
  uncollectable: set[str] = set()
98
- for collector in report.get("collectors", []):
113
+ for collector in collectors:
99
114
  if collector.get("outcome") not in (None, "passed"):
100
115
  uncollectable.add(collector.get("nodeid", ""))
101
116
 
102
- for test in report.get("tests", []):
117
+ for test in tests:
103
118
  qualified = self.qualify(test["nodeid"])
104
119
  if test["outcome"] == "passed":
105
120
  verdict.passed.append(qualified)
@@ -111,9 +126,7 @@ class PytestAdapter(Adapter):
111
126
  return verdict
112
127
 
113
128
  native = self.strip(target)
114
- hit = next(
115
- (t for t in report.get("tests", []) if t["nodeid"] == native), None
116
- )
129
+ hit = next((t for t in tests if t["nodeid"] == native), None)
117
130
  if hit is not None:
118
131
  verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
119
132
  call = hit.get("call") or hit.get("setup") or {}
@@ -122,21 +135,30 @@ class PytestAdapter(Adapter):
122
135
  target_file = native.split("::", 1)[0]
123
136
  if any(c == target_file or c.startswith(target_file) for c in uncollectable):
124
137
  verdict.target_outcome = NOT_COLLECTED
125
- verdict.target_failure = self._collector_error(report, target_file)[:1500]
138
+ verdict.target_failure = self._collector_error(collectors, target_file)[:1500]
126
139
  else:
127
140
  verdict.target_outcome = NOT_FOUND
128
141
  return verdict
129
142
 
130
143
  @staticmethod
131
- def _collector_error(report: dict, target_file: str) -> str:
132
- for collector in report.get("collectors", []):
144
+ def _collector_error(collectors: list[dict], target_file: str) -> str:
145
+ for collector in collectors:
133
146
  if collector.get("nodeid", "").startswith(target_file):
134
147
  return str(collector.get("longrepr", ""))
135
148
  return ""
136
149
 
150
+ def _collect_cmd_for(self, rel: str) -> tuple[str, dict[str, str] | None]:
151
+ """The collection command for one file: the owning override's, else the
152
+ project default. An override without a `collect_command` collects with its
153
+ `test_command` — pytest's `--collect-only` composes with any run command."""
154
+ ov = self.project.override_for(rel)
155
+ if ov is None:
156
+ return self._collect_cmd(), None
157
+ return ov.collect_command or ov.test_command, self._override_env(ov)
158
+
137
159
  def _test_files(self) -> list[Path]:
138
160
  found: list[Path] = []
139
- for pattern in self.project.test_paths or ["tests/"]:
161
+ for pattern in self.project.test_patterns or ["tests/"]:
140
162
  if pattern.endswith("/"):
141
163
  base = self.root / pattern
142
164
  if base.is_dir():
@@ -147,27 +169,40 @@ class PytestAdapter(Adapter):
147
169
  return sorted({p for p in found if p.is_file()})
148
170
 
149
171
  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).
172
+ """One whole-suite `--collect-only` per declared suite (§10) — the default
173
+ command plus each override's — not the per-file `collect()` loop below.
174
+ That loop is R10.3/R10.4's per-file collection, the slow path (the
175
+ whole-suite probe costs 0.04s on a broken project vs. minutes for the
176
+ per-file sweep on a real one).
154
177
 
155
178
  Reads **stdout**, not stderr: `uv` writes environment warnings
156
179
  (`VIRTUAL_ENV=... does not match ...`) to stderr while pytest writes the
157
180
  actual `ModuleNotFoundError` to stdout. A doctor check that reads stderr
158
181
  loses the real error and the failure surfaces unattributed.
159
182
  """
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])
183
+ chunks = []
184
+ probes = [(self._collect_cmd(), None)] + [
185
+ (ov.collect_command or ov.test_command, self._override_env(ov))
186
+ for ov in self.project.overrides
187
+ ]
188
+ for cmd, env in probes:
189
+ code, out, err = run_command(
190
+ f"{cmd} --collect-only -q", self.root, extra_env=env
191
+ )
192
+ if code != 0:
193
+ chunks.append(out.strip())
194
+ return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
162
195
 
163
196
  def collect(self) -> Collection:
164
197
  """Per file (R10.3) — one uncollectable module must not destroy the whole set."""
165
198
  result = Collection()
166
199
  for path in self._test_files():
167
200
  rel = path.relative_to(self.root)
201
+ base, env = self._collect_cmd_for(str(rel))
168
202
  code, out, err = run_command(
169
- f"{self._collect_cmd()} --collect-only -q {shlex.quote(str(rel))}",
203
+ f"{base} --collect-only -q {shlex.quote(str(rel))}",
170
204
  self.root,
205
+ extra_env=env,
171
206
  )
172
207
  if code != 0:
173
208
  result.failed_files[str(rel)] = (err or out).strip()[:800]
@@ -49,19 +49,32 @@ class VitestAdapter(Adapter):
49
49
  rel = suite_path
50
50
  return self.qualify(f"{rel} > {full_name}")
51
51
 
52
+ def _test_cmd(self) -> str:
53
+ return self.project.test_command or "npx vitest run"
54
+
55
+ def _collect_cmd(self) -> str:
56
+ return self.project.collect_command or "npx vitest list"
57
+
52
58
  def run(self, target: str | None = None) -> Verdict:
53
59
  verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
54
- base = self.project.test_command or "npx vitest run"
55
- code, out, err = self._run_suite(f"{base} --reporter=json")
56
- report = _extract_json(out)
57
- if report is None:
58
- verdict.error = f"vitest produced no JSON output: {(err or out)[:500]}"
59
- return verdict
60
+ # Union across the default suite and every override suite (R7.13). A suite
61
+ # producing no JSON is a loud error, not a silent gap: swallowing it would
62
+ # report a target living in that suite as `not_found`.
63
+ suites: list[dict] = []
64
+ for base, extra_env in self._suite_invocations():
65
+ code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
66
+ report = _extract_json(out)
67
+ if report is None:
68
+ verdict.error = (
69
+ f"`{base}` produced no JSON output: {(err or out)[:500]}"
70
+ )
71
+ return verdict
72
+ verdict.duration_ms += int(report.get("duration") or 0)
73
+ suites.extend(report.get("testResults", []))
60
74
 
61
- verdict.duration_ms = int(report.get("duration") or 0)
62
75
  failed_suites: dict[str, str] = {}
63
76
 
64
- for suite in report.get("testResults", []):
77
+ for suite in suites:
65
78
  suite_path = suite.get("name", "")
66
79
  assertions = suite.get("assertionResults", [])
67
80
  if not assertions and suite.get("status") == "failed":
@@ -82,7 +95,7 @@ class VitestAdapter(Adapter):
82
95
  return verdict
83
96
  if target in verdict.failed:
84
97
  verdict.target_outcome = FAILED
85
- for suite in report.get("testResults", []):
98
+ for suite in suites:
86
99
  for t in suite.get("assertionResults", []):
87
100
  if self._id_for(suite.get("name", ""), t["fullName"]) == target:
88
101
  verdict.target_failure = "\n".join(
@@ -101,7 +114,7 @@ class VitestAdapter(Adapter):
101
114
 
102
115
  def _test_files(self) -> list[Path]:
103
116
  found: set[Path] = set()
104
- for pattern in self.project.test_paths or ["**/*.test.ts"]:
117
+ for pattern in self.project.test_patterns or ["**/*.test.ts"]:
105
118
  pat = pattern.rstrip("/") + "/**/*" if pattern.endswith("/") else pattern
106
119
  for path in self.root.glob(pat):
107
120
  if path.is_file() and path.suffix in (".ts", ".tsx", ".js", ".jsx"):
@@ -134,19 +147,50 @@ class VitestAdapter(Adapter):
134
147
  return found
135
148
 
136
149
  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])
150
+ """One whole-suite probe per declared suite, mirroring the pytest adapter's
151
+ `--collect-only` (§10): `npx vitest list` at the project root rather than
152
+ the per-file `collect()` loop below.
153
+
154
+ An override without a `collect_command` fails here, at run start, not
155
+ per-file during a cycle: `vitest list` knows nothing of the override's
156
+ config, and falling back to the override's *run* command would execute the
157
+ suite — against a live backend — just to enumerate it.
158
+ """
159
+ chunks = []
160
+ code, out, err = run_command(self._collect_cmd(), self.root)
161
+ if code != 0:
162
+ chunks.append((err or out).strip())
163
+ for ov in self.project.overrides:
164
+ if not ov.collect_command:
165
+ chunks.append(
166
+ f"override {ov.pattern!r}: a vitest override needs an explicit"
167
+ ' collect_command (e.g. "npx vitest list --config'
168
+ ' vitest.other.config.ts")'
169
+ )
170
+ continue
171
+ code, out, err = run_command(
172
+ ov.collect_command, self.root, extra_env=self._override_env(ov)
173
+ )
174
+ if code != 0:
175
+ chunks.append((err or out).strip())
176
+ return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
143
177
 
144
178
  def collect(self) -> Collection:
145
179
  result = Collection()
146
180
  for path in self._test_files():
147
181
  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)
182
+ ov = self.project.override_for(str(rel))
183
+ if ov is not None and not ov.collect_command:
184
+ result.failed_files[str(rel)] = (
185
+ f"override {ov.pattern!r} declares no collect_command; vitest"
186
+ " cannot list these tests under the default config"
187
+ )
188
+ continue
189
+ base = ov.collect_command if ov else self._collect_cmd()
190
+ env = self._override_env(ov)
191
+ code, out, err = run_command(
192
+ f"{base} {shlex.quote(str(rel))}", self.root, extra_env=env
193
+ )
150
194
 
151
195
  payload = _extract_json(out)
152
196
  if payload is not None:
@@ -42,6 +42,35 @@ class ConfigError(RuntimeError):
42
42
  pass
43
43
 
44
44
 
45
+ def _pattern_matches(rel_path: str, pattern: str) -> bool:
46
+ """`test_paths`-style matching: trailing-slash directory, glob, or bare
47
+ directory prefix."""
48
+ if pattern.endswith("/"):
49
+ return rel_path.startswith(pattern)
50
+ return fnmatch(rel_path, pattern) or rel_path.startswith(pattern.rstrip("/") + "/")
51
+
52
+
53
+ @dataclass
54
+ class Override:
55
+ """An alternate suite for the files matching `pattern` (R7.13).
56
+
57
+ Some tests intentionally live outside the project's default runner config —
58
+ contract tests that need a live backend being the motivating case. Widening the
59
+ default config to satisfy collection breaks CI (the default suite suddenly makes
60
+ real network calls) and pollutes target adoption with tests from the other suite.
61
+ Instead the registry declares the alternate command; collection and suite runs
62
+ union the default suite with every override suite.
63
+
64
+ `pattern` matches paths relative to the project root, like `test_paths`.
65
+ `env` values may reference `${VAR}`, expanded from the environment at invocation.
66
+ """
67
+
68
+ pattern: str
69
+ test_command: str
70
+ collect_command: str | None = None
71
+ env: dict[str, str] = field(default_factory=dict)
72
+
73
+
45
74
  @dataclass
46
75
  class Project:
47
76
  name: str
@@ -59,6 +88,26 @@ class Project:
59
88
  #: Per-file collection. Must not be parallelised: collection is cheap and xdist
60
89
  #: adds startup cost per file.
61
90
  collect_command: str | None = None
91
+ #: Alternate suites for files the default command cannot reach (R7.13).
92
+ overrides: list[Override] = field(default_factory=list)
93
+
94
+ def override_for(self, rel_path: str) -> Override | None:
95
+ """First declared override whose pattern matches (path relative to the
96
+ project root), so precedence is the reviewed file's order, never dict or
97
+ filesystem order. Patterns follow `test_paths` semantics: a glob, a
98
+ trailing-slash directory, or a bare directory prefix."""
99
+ for ov in self.overrides:
100
+ if _pattern_matches(rel_path, ov.pattern):
101
+ return ov
102
+ return None
103
+
104
+ @property
105
+ def test_patterns(self) -> list[str]:
106
+ """`test_paths` plus every override pattern. An override's files are tests by
107
+ declaration — the pattern exists to name the command that runs them — so they
108
+ classify as tests for staging and discovery without being repeated in
109
+ `test_paths`."""
110
+ return self.test_paths + [ov.pattern for ov in self.overrides]
62
111
 
63
112
  def owns(self, rel_path: str) -> bool:
64
113
  if self.root == ".": # single-project repo: the root is the worktree itself
@@ -74,7 +123,7 @@ class Project:
74
123
  if not self.owns(rel_path):
75
124
  return False
76
125
  inner = self.relative_to_root(rel_path)
77
- for pattern in self.test_paths:
126
+ for pattern in self.test_patterns:
78
127
  if pattern.endswith("/"):
79
128
  if inner.startswith(pattern):
80
129
  return True
@@ -196,6 +245,39 @@ def find_config(start: Path) -> Path | None:
196
245
  return None
197
246
 
198
247
 
248
+ def _load_overrides(project: str, raw: list) -> list[Override]:
249
+ overrides: list[Override] = []
250
+ for i, body in enumerate(raw, start=1):
251
+ if not isinstance(body, dict):
252
+ raise ConfigError(
253
+ f"project {project!r} override #{i} must be a table"
254
+ " ([[project.<name>.override]])"
255
+ )
256
+ if "pattern" not in body:
257
+ raise ConfigError(f"project {project!r} override #{i} has no pattern")
258
+ if "test_command" not in body:
259
+ raise ConfigError(
260
+ f"project {project!r} override {body['pattern']!r} has no test_command"
261
+ )
262
+ env = body.get("env", {})
263
+ if not isinstance(env, dict) or not all(
264
+ isinstance(v, str) for v in env.values()
265
+ ):
266
+ raise ConfigError(
267
+ f"project {project!r} override {body['pattern']!r}: env must be a"
268
+ " table of string values"
269
+ )
270
+ overrides.append(
271
+ Override(
272
+ pattern=body["pattern"],
273
+ test_command=body["test_command"],
274
+ collect_command=body.get("collect_command"),
275
+ env=env,
276
+ )
277
+ )
278
+ return overrides
279
+
280
+
199
281
  def load(worktree: Path) -> Config:
200
282
  path = worktree / CONFIG_NAME
201
283
  if not path.is_file():
@@ -218,6 +300,7 @@ def load(worktree: Path) -> Config:
218
300
  in_close_sweep=body.get("in_close_sweep", True),
219
301
  test_command=body.get("test_command"),
220
302
  collect_command=body.get("collect_command"),
303
+ overrides=_load_overrides(name, body.get("override", [])),
221
304
  )
222
305
 
223
306
  artifacts: dict[str, Artifact] = {}
@@ -0,0 +1,406 @@
1
+ """Per-pattern suite overrides (R7.13).
2
+
3
+ Some tests intentionally live outside the project's default runner config —
4
+ contract tests that need a live backend being the motivating case. Before
5
+ overrides existed, the only way to make such a test collectable was to widen the
6
+ default config, which broke CI (the plain suite suddenly made real network
7
+ calls) and polluted target adoption with the other suite's tests. The registry
8
+ now declares the alternate command per pattern, and collection and runs union
9
+ the default suite with every override suite.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import pytest as pytest_framework
18
+
19
+ from conftest import git, run_cli, write_plan
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
+ OVERRIDE_BLOCK = (
36
+ "[[project.backend.override]]\n"
37
+ 'pattern = "contract/"\n'
38
+ 'test_command = "pytest contract"\n'
39
+ 'collect_command = "pytest contract -p no:cacheprovider"\n'
40
+ "env = { API_URL = \"http://localhost:${TDD_TEST_PORT}\" }\n"
41
+ )
42
+
43
+
44
+ # -- registry ------------------------------------------------------------
45
+
46
+
47
+ def test_override_is_parsed_from_the_registry(tmp_path):
48
+ project = project_with(tmp_path, OVERRIDE_BLOCK)
49
+ (ov,) = project.overrides
50
+ assert ov.pattern == "contract/"
51
+ assert ov.test_command == "pytest contract"
52
+ assert ov.collect_command == "pytest contract -p no:cacheprovider"
53
+ assert ov.env == {"API_URL": "http://localhost:${TDD_TEST_PORT}"}
54
+
55
+
56
+ def test_override_without_pattern_or_command_is_refused(tmp_path):
57
+ with pytest_framework.raises(ConfigError, match="has no pattern"):
58
+ project_with(
59
+ tmp_path, '[[project.backend.override]]\ntest_command = "pytest x"\n'
60
+ )
61
+ with pytest_framework.raises(ConfigError, match="has no test_command"):
62
+ project_with(tmp_path, '[[project.backend.override]]\npattern = "x/"\n')
63
+
64
+
65
+ def test_override_env_must_be_string_valued(tmp_path):
66
+ with pytest_framework.raises(ConfigError, match="env must be a table"):
67
+ project_with(
68
+ tmp_path,
69
+ "[[project.backend.override]]\n"
70
+ 'pattern = "x/"\n'
71
+ 'test_command = "pytest x"\n'
72
+ "env = { PORT = 9600 }\n",
73
+ )
74
+
75
+
76
+ def test_override_for_matches_like_test_paths_first_declared_wins(tmp_path):
77
+ project = project_with(
78
+ tmp_path,
79
+ "[[project.backend.override]]\n"
80
+ 'pattern = "contract/smoke/**"\n'
81
+ 'test_command = "pytest contract/smoke"\n'
82
+ "[[project.backend.override]]\n"
83
+ 'pattern = "contract/"\n'
84
+ 'test_command = "pytest contract"\n'
85
+ "[[project.backend.override]]\n"
86
+ 'pattern = "legacy"\n'
87
+ 'test_command = "pytest legacy"\n',
88
+ )
89
+ assert project.override_for("contract/smoke/test_a.py").test_command == (
90
+ "pytest contract/smoke"
91
+ )
92
+ assert project.override_for("contract/test_b.py").test_command == "pytest contract"
93
+ assert project.override_for("legacy/test_old.py").test_command == "pytest legacy"
94
+ assert project.override_for("tests/test_c.py") is None
95
+
96
+
97
+ def test_override_files_classify_as_tests_without_repeating_test_paths(tmp_path):
98
+ """Staging classification must see override files as tests: otherwise a RED
99
+ commit containing only the new contract test is flagged as implementation
100
+ written during RED."""
101
+ project = project_with(tmp_path, OVERRIDE_BLOCK)
102
+ assert project.is_test_file("backend/contract/test_api.py")
103
+ assert not project.is_test_file("backend/app/api.py")
104
+
105
+
106
+ # -- pytest adapter ------------------------------------------------------
107
+
108
+
109
+ def _fake_pytest_run(reports_by_prefix: dict[str, dict], seen: list):
110
+ """A run_command double that answers each suite command with its own report,
111
+ keyed by command prefix, writing the JSON where the real plugin would."""
112
+
113
+ def fake(command, cwd, timeout=1800, extra_env=None):
114
+ seen.append((command, extra_env))
115
+ for prefix, report in reports_by_prefix.items():
116
+ if command.startswith(prefix):
117
+ marker = "--json-report-file="
118
+ if marker in command:
119
+ path = command.split(marker, 1)[1].split(" --", 1)[0]
120
+ Path(path.strip("'\"")).write_text(json.dumps(report))
121
+ return 1 if report.get("tests") else 0, "", ""
122
+ raise AssertionError(f"unexpected command: {command}")
123
+
124
+ return fake
125
+
126
+
127
+ def test_pytest_run_unions_default_and_override_suites(tmp_path, monkeypatch):
128
+ project = project_with(
129
+ tmp_path,
130
+ 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK,
131
+ )
132
+ adapter = adapters.build(project, tmp_path)
133
+ seen: list = []
134
+ monkeypatch.setattr(
135
+ adapters.base,
136
+ "run_command",
137
+ _fake_pytest_run(
138
+ {
139
+ "pytest tests": {
140
+ "duration": 1.0,
141
+ "tests": [{"nodeid": "tests/test_a.py::test_a", "outcome": "passed"}],
142
+ },
143
+ "pytest contract": {
144
+ "duration": 2.0,
145
+ "tests": [
146
+ {
147
+ "nodeid": "contract/test_api.py::test_ping",
148
+ "outcome": "failed",
149
+ "call": {"longrepr": "boom"},
150
+ }
151
+ ],
152
+ },
153
+ },
154
+ seen,
155
+ ),
156
+ )
157
+ verdict = adapter.run("backend::contract/test_api.py::test_ping")
158
+ assert verdict.error is None
159
+ assert verdict.target_outcome == "failed"
160
+ assert verdict.target_failure == "boom"
161
+ assert verdict.passed == ["backend::tests/test_a.py::test_a"]
162
+ assert verdict.failed == ["backend::contract/test_api.py::test_ping"]
163
+ assert verdict.duration_ms == 3000
164
+ assert [c.split(" --json-report", 1)[0] for c, _ in seen] == [
165
+ "pytest tests",
166
+ "pytest contract",
167
+ ]
168
+
169
+
170
+ def test_pytest_override_env_reaches_the_suite_with_vars_expanded(
171
+ tmp_path, monkeypatch
172
+ ):
173
+ monkeypatch.setenv("TDD_TEST_PORT", "9600")
174
+ project = project_with(tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK)
175
+ adapter = adapters.build(project, tmp_path)
176
+ seen: list = []
177
+ monkeypatch.setattr(
178
+ adapters.base,
179
+ "run_command",
180
+ _fake_pytest_run(
181
+ {"pytest tests": {"tests": []}, "pytest contract": {"tests": []}}, seen
182
+ ),
183
+ )
184
+ adapter.run(None)
185
+ (_, default_env), (_, override_env) = seen
186
+ assert "API_URL" not in default_env
187
+ assert override_env["API_URL"] == "http://localhost:9600"
188
+
189
+
190
+ def test_pytest_broken_override_suite_is_a_loud_error_not_a_silent_gap(
191
+ tmp_path, monkeypatch
192
+ ):
193
+ """Swallowing a report-less override run would resolve a target living in that
194
+ suite as `not_found`, sending the agent to rewrite a perfectly good test."""
195
+ project = project_with(tmp_path, 'test_command = "pytest tests"\n' + OVERRIDE_BLOCK)
196
+ adapter = adapters.build(project, tmp_path)
197
+
198
+ def fake(command, cwd, timeout=1800, extra_env=None):
199
+ if command.startswith("pytest contract"):
200
+ return 4, "", "ERROR: file or directory not found: contract"
201
+ marker = "--json-report-file="
202
+ path = command.split(marker, 1)[1].split(" --", 1)[0]
203
+ Path(path.strip("'\"")).write_text(json.dumps({"tests": []}))
204
+ return 0, "", ""
205
+
206
+ monkeypatch.setattr(adapters.base, "run_command", fake)
207
+ verdict = adapter.run("backend::contract/test_api.py::test_ping")
208
+ assert verdict.error is not None
209
+ assert "pytest contract" in verdict.error
210
+
211
+
212
+ def test_pytest_collection_routes_override_files_to_the_override_command(
213
+ tmp_path, monkeypatch
214
+ ):
215
+ project = project_with(tmp_path, OVERRIDE_BLOCK)
216
+ (tmp_path / "backend" / "tests").mkdir(parents=True)
217
+ (tmp_path / "backend" / "contract").mkdir()
218
+ (tmp_path / "backend" / "tests" / "test_a.py").write_text("def test_a(): pass\n")
219
+ (tmp_path / "backend" / "contract" / "test_api.py").write_text(
220
+ "def test_ping(): pass\n"
221
+ )
222
+ adapter = adapters.build(project, tmp_path)
223
+ seen: list = []
224
+
225
+ def fake(command, cwd, timeout=1800, extra_env=None):
226
+ seen.append((command, extra_env))
227
+ name = "test_api.py::test_ping" if "contract" in command else "test_a.py::test_a"
228
+ return 0, name, ""
229
+
230
+ monkeypatch.setattr(adapters.pytest_adapter, "run_command", fake)
231
+ collection = adapter.collect()
232
+ assert collection.tests == {
233
+ "backend::test_a.py::test_a",
234
+ "backend::test_api.py::test_ping",
235
+ }
236
+ contract_calls = [c for c, _ in seen if "contract/test_api.py" in c]
237
+ assert contract_calls and contract_calls[0].startswith(
238
+ "pytest contract -p no:cacheprovider --collect-only -q"
239
+ )
240
+
241
+
242
+ # -- vitest adapter ------------------------------------------------------
243
+
244
+
245
+ VITEST_OVERRIDE = (
246
+ 'test_command = "npx vitest run"\n'
247
+ "[[project.backend.override]]\n"
248
+ 'pattern = "contract/"\n'
249
+ 'test_command = "npx vitest run --config vitest.contract.config.ts"\n'
250
+ 'collect_command = "npx vitest list --config vitest.contract.config.ts"\n'
251
+ )
252
+
253
+
254
+ def _vitest_report(file_path: str, full_name: str, status: str) -> dict:
255
+ return {
256
+ "duration": 5,
257
+ "testResults": [
258
+ {
259
+ "name": file_path,
260
+ "status": status,
261
+ "assertionResults": [
262
+ {"fullName": full_name, "status": status, "failureMessages": ["nope"]}
263
+ ],
264
+ }
265
+ ],
266
+ }
267
+
268
+
269
+ def test_vitest_run_finds_a_target_that_only_the_override_config_reaches(
270
+ tmp_path, monkeypatch
271
+ ):
272
+ project = project_with(tmp_path, VITEST_OVERRIDE, adapter="vitest")
273
+ adapter = adapters.build(project, tmp_path)
274
+
275
+ def fake(command, cwd, timeout=1800, extra_env=None):
276
+ if "--config vitest.contract.config.ts" in command:
277
+ report = _vitest_report(
278
+ str(tmp_path / "backend" / "contract" / "api.contract.test.ts"),
279
+ "pings the api",
280
+ "failed",
281
+ )
282
+ else:
283
+ report = _vitest_report(
284
+ str(tmp_path / "backend" / "unit.test.ts"), "adds", "passed"
285
+ )
286
+ return 1, json.dumps(report), ""
287
+
288
+ monkeypatch.setattr(adapters.base, "run_command", fake)
289
+ target = "backend::backend/contract/api.contract.test.ts > pings the api"
290
+ verdict = adapter.run(target)
291
+ assert verdict.error is None
292
+ assert verdict.target_outcome == "failed"
293
+ assert verdict.target_failure == "nope"
294
+ assert verdict.passed == ["backend::backend/unit.test.ts > adds"]
295
+
296
+
297
+ def test_vitest_override_without_collect_command_fails_the_collectable_gate(
298
+ tmp_path, monkeypatch
299
+ ):
300
+ """Fail at run start, not per-file mid-cycle: `vitest list` knows nothing of
301
+ the override config, and falling back to the override's *run* command would
302
+ execute the suite — against a live backend — just to enumerate it."""
303
+ project = project_with(
304
+ tmp_path,
305
+ "[[project.backend.override]]\n"
306
+ 'pattern = "contract/"\n'
307
+ 'test_command = "npx vitest run --config vitest.contract.config.ts"\n',
308
+ adapter="vitest",
309
+ )
310
+ adapter = adapters.build(project, tmp_path)
311
+ monkeypatch.setattr(
312
+ adapters.vitest_adapter, "run_command", lambda *a, **k: (0, "", "")
313
+ )
314
+ gate = adapter.collectable()
315
+ assert not gate.ok
316
+ assert "collect_command" in gate.output
317
+
318
+ (tmp_path / "backend" / "contract").mkdir(parents=True)
319
+ (tmp_path / "backend" / "contract" / "api.contract.test.ts").write_text("")
320
+ collection = adapter.collect()
321
+ assert "contract/api.contract.test.ts" in collection.failed_files
322
+
323
+
324
+ def test_vitest_collection_routes_override_files_to_the_override_command(
325
+ tmp_path, monkeypatch
326
+ ):
327
+ project = project_with(tmp_path, VITEST_OVERRIDE, adapter="vitest")
328
+ (tmp_path / "backend" / "contract").mkdir(parents=True)
329
+ contract_file = tmp_path / "backend" / "contract" / "api.contract.test.ts"
330
+ contract_file.write_text("")
331
+ adapter = adapters.build(project, tmp_path)
332
+ seen: list = []
333
+
334
+ def fake(command, cwd, timeout=1800, extra_env=None):
335
+ seen.append(command)
336
+ return 0, "contract/api.contract.test.ts > pings the api", ""
337
+
338
+ monkeypatch.setattr(adapters.vitest_adapter, "run_command", fake)
339
+ collection = adapter.collect()
340
+ assert collection.tests == {
341
+ "backend::backend/contract/api.contract.test.ts > pings the api"
342
+ }
343
+ assert seen[0].startswith("npx vitest list --config vitest.contract.config.ts ")
344
+
345
+ # With every suite listable the gate passes: the probe must not manufacture a
346
+ # failure out of a healthy override.
347
+ assert adapter.collectable().ok
348
+
349
+
350
+ # -- end to end ----------------------------------------------------------
351
+
352
+
353
+ PLAN = """---
354
+ cycles:
355
+ - n: 1
356
+ project: backend
357
+ test: "contract/test_api.py::test_add_via_api"
358
+ ---
359
+ """
360
+
361
+
362
+ def test_a_cycle_can_target_a_test_only_an_override_suite_reaches(repo):
363
+ """The full RED → GREEN path for a target the default command never runs.
364
+
365
+ The default suite is pinned to `pytest tests`, so nothing under `contract/`
366
+ is reachable by it; before overrides this cycle could not reach RED at all.
367
+ """
368
+ (repo / "backend" / "contract").mkdir()
369
+ (repo / "backend" / "contract" / "test_ping.py").write_text(
370
+ "def test_ping():\n assert True\n"
371
+ )
372
+ (repo / "backend" / "app" / "calc.py").write_text(
373
+ "def add(a, b):\n raise NotImplementedError\n"
374
+ )
375
+ (repo / "tdd.toml").write_text(
376
+ "[project.backend]\n"
377
+ 'root = "backend"\n'
378
+ 'adapter = "pytest"\n'
379
+ 'test_paths = ["tests/"]\n'
380
+ 'test_command = "pytest tests"\n'
381
+ "lint = []\n"
382
+ "typecheck = []\n"
383
+ "[[project.backend.override]]\n"
384
+ 'pattern = "contract/"\n'
385
+ 'test_command = "pytest contract"\n'
386
+ )
387
+ git(repo, "add", "-A")
388
+ git(repo, "commit", "-q", "-m", "declare the contract-suite override")
389
+
390
+ plan = write_plan(repo, PLAN)
391
+ run_cli(repo, "plan", "register", plan)
392
+ start = run_cli(repo, "run", "start", "--plan", plan)
393
+ assert start["ok"], start
394
+
395
+ (repo / "backend" / "contract" / "test_api.py").write_text(
396
+ "from app.calc import add\n\n\n"
397
+ "def test_add_via_api():\n assert add(2, 2) == 4\n"
398
+ )
399
+ red = run_cli(repo, "advance")
400
+ assert red["next_action"]["verb"] == "write_implementation", red
401
+
402
+ (repo / "backend" / "app" / "calc.py").write_text(
403
+ "def add(a, b):\n return a + b\n"
404
+ )
405
+ green = run_cli(repo, "advance")
406
+ assert green["next_action"]["verb"] == "refactor_or_advance", green
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