tdd-cli 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/CHANGELOG.md +18 -0
  2. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/PKG-INFO +1 -1
  3. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/__init__.py +1 -1
  4. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/base.py +31 -0
  5. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/pytest_adapter.py +37 -0
  6. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/vitest_adapter.py +40 -1
  7. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/cli.py +10 -0
  8. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_doctor_attribution.py +43 -0
  9. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_suite_overrides.py +179 -0
  10. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/.gitignore +0 -0
  11. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/LICENSE +0 -0
  12. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/README.md +0 -0
  13. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/SECURITY.md +0 -0
  14. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/README.md +0 -0
  15. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  16. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  17. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/plan.md +0 -0
  18. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/README.md +0 -0
  19. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  20. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/README.md +0 -0
  21. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  22. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/pyproject.toml +0 -0
  23. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/adapters/__init__.py +0 -0
  24. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/advance.py +0 -0
  25. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/config.py +0 -0
  26. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/contract.py +0 -0
  27. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/envelope.py +0 -0
  28. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/fleet.py +0 -0
  29. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/gitutil.py +0 -0
  30. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/identity.py +0 -0
  31. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/leases.py +0 -0
  32. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/ledger.py +0 -0
  33. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/machine.py +0 -0
  34. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/render.py +0 -0
  35. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/snapshot.py +0 -0
  36. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/src/tddcli/staging.py +0 -0
  37. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/conftest.py +0 -0
  38. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_artifact_regeneration.py +0 -0
  39. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_baseline_integrity.py +0 -0
  40. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_config_and_staging.py +0 -0
  41. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_config_drift.py +0 -0
  42. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_contract.py +0 -0
  43. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_end_to_end.py +0 -0
  44. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_example_plan.py +0 -0
  45. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_fleet.py +0 -0
  46. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_heartbeat.py +0 -0
  47. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_init_detection.py +0 -0
  48. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_pin_cycles.py +0 -0
  49. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_progress.py +0 -0
  50. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_project_commands.py +0 -0
  51. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_python_env_managers.py +0 -0
  52. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_refactor_cycles.py +0 -0
  53. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_release_surface.py +0 -0
  54. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_run_claim.py +0 -0
  55. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_single_project_repo.py +0 -0
  56. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_snapshot_and_identity.py +0 -0
  57. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_stub_hint.py +0 -0
  58. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_vitest_adapter.py +0 -0
  59. {tdd_cli-0.2.0 → tdd_cli-0.2.1}/tests/test_worker_leases.py +0 -0
@@ -6,6 +6,24 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-08-10
10
+
11
+ ### Added
12
+
13
+ - `tdd doctor` check `default suite cannot reach override files`: when a
14
+ project declares overrides, doctor probes the default suite's discovery
15
+ (pytest: the test command with `--collect-only`; vitest: `vitest list`) and
16
+ fails if it reaches files an override owns — the premise suite overrides
17
+ require, which nothing previously enforced.
18
+
19
+ ### Fixed
20
+
21
+ - A test observed by more than one suite invocation of the union (the default
22
+ suite's discovery sweeping an override's files, e.g. a bare `pytest` default)
23
+ is now a loud tooling error naming the overlapping tests and the fix, instead
24
+ of the target being silently judged by whichever suite reported it first —
25
+ previously an env-less run whose failure said nothing about the overlap.
26
+
9
27
  ## [0.2.0] - 2026-08-10
10
28
 
11
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tdd-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Ledger-backed TDD process controller for autonomous coding agents
5
5
  Project-URL: Homepage, https://github.com/geuben/tdd-cli
6
6
  Project-URL: Repository, https://github.com/geuben/tdd-cli
@@ -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.2.1"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import subprocess
7
+ from collections import Counter
7
8
  from dataclasses import dataclass, field
8
9
  from pathlib import Path
9
10
 
@@ -42,6 +43,27 @@ class Collection:
42
43
  failed_files: dict[str, str] = field(default_factory=dict)
43
44
 
44
45
 
46
+ def _suite_overlap(suite_ids: list[set[str]]) -> list[str]:
47
+ """Test ids observed by more than one suite invocation of the union (R7.13).
48
+ Overlap means the default command's discovery reaches files an override
49
+ owns, so those tests also ran without the override's command/env — and
50
+ target matching would judge the target by whichever run came first."""
51
+ counts = Counter(i for ids in suite_ids for i in ids)
52
+ return sorted(i for i, n in counts.items() if n > 1)
53
+
54
+
55
+ def _overlap_error(overlap: list[str]) -> str:
56
+ shown = ", ".join(overlap[:5])
57
+ more = f" (and {len(overlap) - 5} more)" if len(overlap) > 5 else ""
58
+ return (
59
+ f"observed by more than one suite invocation: {shown}{more}."
60
+ " The default suite's discovery reaches files an override owns, so"
61
+ " these tests also ran without the override's command/env. Scope the"
62
+ " default test_command so it cannot reach them (e.g. pass the default"
63
+ " test directories explicitly)."
64
+ )
65
+
66
+
45
67
  def run_command(
46
68
  command: str, cwd: Path, timeout: int = 1800,
47
69
  extra_env: dict[str, str] | None = None,
@@ -128,6 +150,15 @@ class Adapter:
128
150
  def collectable(self) -> GateResult:
129
151
  raise NotImplementedError
130
152
 
153
+ def override_isolation(self) -> GateResult:
154
+ """Whether the default suite's discovery stays out of files an override
155
+ owns (R7.13's premise). Overlap means runs observe those tests without
156
+ the override's command/env — and the union then holds the same test
157
+ twice with conflicting outcomes. Adapters with a way to probe discovery
158
+ override this; the base answer is ok so third-party adapters without a
159
+ probe don't fail doctor."""
160
+ return GateResult(ok=True)
161
+
131
162
  def lint(self) -> GateResult:
132
163
  return self._gate(self.project.lint)
133
164
 
@@ -25,6 +25,8 @@ from .base import (
25
25
  Collection,
26
26
  GateResult,
27
27
  Verdict,
28
+ _overlap_error,
29
+ _suite_overlap,
28
30
  run_command,
29
31
  )
30
32
 
@@ -100,6 +102,7 @@ class PytestAdapter(Adapter):
100
102
  # agent to rewrite a test that is fine.
101
103
  tests: list[dict] = []
102
104
  collectors: list[dict] = []
105
+ suite_ids: list[set[str]] = []
103
106
  for base_cmd, extra_env in self._suite_invocations():
104
107
  report, error = self._suite_report(base_cmd, extra_env)
105
108
  if report is None:
@@ -108,6 +111,12 @@ class PytestAdapter(Adapter):
108
111
  verdict.duration_ms += int(report.get("duration", 0) * 1000)
109
112
  tests.extend(report.get("tests", []))
110
113
  collectors.extend(report.get("collectors", []))
114
+ suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
115
+
116
+ overlap = _suite_overlap(suite_ids)
117
+ if overlap:
118
+ verdict.error = _overlap_error(overlap)
119
+ return verdict
111
120
 
112
121
  uncollectable: set[str] = set()
113
122
  for collector in collectors:
@@ -193,6 +202,34 @@ class PytestAdapter(Adapter):
193
202
  chunks.append(out.strip())
194
203
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
195
204
 
205
+ def override_isolation(self) -> GateResult:
206
+ """Probes the *test* command's discovery, not `collect_command`'s: the
207
+ test command is what runs at suite time, and a scoped `test_command`
208
+ with a bare per-file `collect_command` is a legitimate registry (the
209
+ per-file command always gets an explicit path). `{workers}` becomes 0 —
210
+ xdist's "no workers" — since discovery needs no parallelism. A probe
211
+ that fails to collect at all is `collectable`'s finding, not this one's."""
212
+ if not self.project.overrides:
213
+ return GateResult(ok=True)
214
+ probe = f"{self._test_cmd().replace('{workers}', '0')} --collect-only -q"
215
+ code, out, err = run_command(probe, self.root)
216
+ reached = sorted({
217
+ f for f in (
218
+ line.split("::", 1)[0]
219
+ for line in out.splitlines()
220
+ if "::" in line
221
+ )
222
+ if self.project.override_for(f)
223
+ })
224
+ if not reached:
225
+ return GateResult(ok=True)
226
+ return GateResult(ok=False, output=(
227
+ "the default suite's discovery reaches files an override owns, so"
228
+ " suite runs would observe them without the override's command/env:"
229
+ f" {', '.join(reached[:5])}. Scope the default test_command so it"
230
+ " cannot reach them (e.g. `pytest tests/`)."
231
+ ))
232
+
196
233
  def collect(self) -> Collection:
197
234
  """Per file (R10.3) — one uncollectable module must not destroy the whole set."""
198
235
  result = Collection()
@@ -21,6 +21,8 @@ from .base import (
21
21
  Collection,
22
22
  GateResult,
23
23
  Verdict,
24
+ _overlap_error,
25
+ _suite_overlap,
24
26
  run_command,
25
27
  )
26
28
 
@@ -61,6 +63,7 @@ class VitestAdapter(Adapter):
61
63
  # producing no JSON is a loud error, not a silent gap: swallowing it would
62
64
  # report a target living in that suite as `not_found`.
63
65
  suites: list[dict] = []
66
+ suite_ids: list[set[str]] = []
64
67
  for base, extra_env in self._suite_invocations():
65
68
  code, out, err = self._run_suite(f"{base} --reporter=json", extra_env)
66
69
  report = _extract_json(out)
@@ -70,7 +73,18 @@ class VitestAdapter(Adapter):
70
73
  )
71
74
  return verdict
72
75
  verdict.duration_ms += int(report.get("duration") or 0)
73
- suites.extend(report.get("testResults", []))
76
+ results = report.get("testResults", [])
77
+ suites.extend(results)
78
+ suite_ids.append({
79
+ self._id_for(s.get("name", ""), t["fullName"])
80
+ for s in results
81
+ for t in s.get("assertionResults", [])
82
+ })
83
+
84
+ overlap = _suite_overlap(suite_ids)
85
+ if overlap:
86
+ verdict.error = _overlap_error(overlap)
87
+ return verdict
74
88
 
75
89
  failed_suites: dict[str, str] = {}
76
90
 
@@ -175,6 +189,31 @@ class VitestAdapter(Adapter):
175
189
  chunks.append((err or out).strip())
176
190
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
177
191
 
192
+ def override_isolation(self) -> GateResult:
193
+ """Probes with the default `vitest list` — the same stand-in for the
194
+ default run config that `collectable()` already relies on (`vitest run`
195
+ has no listing mode, and running the suite just to enumerate it would
196
+ execute against whatever the tests need live)."""
197
+ if not self.project.overrides:
198
+ return GateResult(ok=True)
199
+ code, out, err = run_command(self._collect_cmd(), self.root)
200
+ reached = sorted({
201
+ f for f in (
202
+ line.strip().partition(" > ")[0]
203
+ for line in out.splitlines()
204
+ if " > " in line
205
+ )
206
+ if self.project.override_for(f)
207
+ })
208
+ if not reached:
209
+ return GateResult(ok=True)
210
+ return GateResult(ok=False, output=(
211
+ "the default config's discovery reaches files an override owns, so"
212
+ " suite runs would observe them without the override's command/env:"
213
+ f" {', '.join(reached[:5])}. Exclude them from the default vitest"
214
+ " config (test.exclude) or scope its include globs."
215
+ ))
216
+
178
217
  def collect(self) -> Collection:
179
218
  result = Collection()
180
219
  for path in self._test_files():
@@ -275,6 +275,16 @@ def cmd_doctor(args) -> Envelope:
275
275
  gate = adapter.collectable()
276
276
  check("collectable", gate.ok, gate.output, project=name)
277
277
 
278
+ # R7.13's premise — "files the default runner config cannot reach" — is
279
+ # a config property nothing else enforces. Probe it at preflight so the
280
+ # overlap is named here, not discovered as an opaque mid-cycle failure.
281
+ if project.overrides:
282
+ gate = adapter.override_isolation()
283
+ check(
284
+ "default suite cannot reach override files",
285
+ gate.ok, gate.output, project=name,
286
+ )
287
+
278
288
  projects[name] = {"ok": all(c["ok"] for c in checks[before:])}
279
289
 
280
290
  for art in cfg.artifacts.values():
@@ -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
@@ -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::backend/contract/api.test.ts > pings")
489
+ assert verdict.error is not None
490
+ assert "backend/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
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
File without changes