tdd-cli 0.4.0__tar.gz → 0.5.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 (70) hide show
  1. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/CHANGELOG.md +54 -0
  2. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/PKG-INFO +1 -1
  3. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/__init__.py +1 -1
  4. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/__init__.py +5 -3
  5. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/base.py +68 -4
  6. tdd_cli-0.5.1/src/tddcli/adapters/exec_adapter.py +173 -0
  7. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/pytest_adapter.py +37 -11
  8. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/adapters/vitest_adapter.py +37 -4
  9. tdd_cli-0.5.1/src/tddcli/adapters/xctest_adapter.py +307 -0
  10. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/cli.py +139 -82
  11. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/config.py +26 -16
  12. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/leases.py +49 -0
  13. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/ledger.py +17 -8
  14. tdd_cli-0.5.1/tests/test_batch_collection.py +233 -0
  15. tdd_cli-0.5.1/tests/test_exec_adapter.py +313 -0
  16. tdd_cli-0.5.1/tests/test_named_leases_and_timeout.py +471 -0
  17. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_suite_overrides.py +7 -1
  18. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_timing_visibility.py +14 -5
  19. tdd_cli-0.5.1/tests/test_xctest_adapter.py +434 -0
  20. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/.gitignore +0 -0
  21. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/LICENSE +0 -0
  22. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/README.md +0 -0
  23. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/SECURITY.md +0 -0
  24. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/README.md +0 -0
  25. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  26. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  27. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/plan.md +0 -0
  28. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-drive/README.md +0 -0
  29. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  30. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-handoff/README.md +0 -0
  31. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  32. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/pyproject.toml +0 -0
  33. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/advance.py +0 -0
  34. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/contract.py +0 -0
  35. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/envelope.py +0 -0
  36. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/fleet.py +0 -0
  37. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/gitutil.py +0 -0
  38. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/identity.py +0 -0
  39. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/machine.py +0 -0
  40. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/render.py +0 -0
  41. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/snapshot.py +0 -0
  42. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/src/tddcli/staging.py +0 -0
  43. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/conftest.py +0 -0
  44. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_artifact_regeneration.py +0 -0
  45. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_baseline_integrity.py +0 -0
  46. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_config_and_staging.py +0 -0
  47. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_config_drift.py +0 -0
  48. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_contract.py +0 -0
  49. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_doctor_attribution.py +0 -0
  50. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_doctor_blockers.py +0 -0
  51. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_end_to_end.py +0 -0
  52. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_example_plan.py +0 -0
  53. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_failure_clipping.py +0 -0
  54. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_fleet.py +0 -0
  55. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_heartbeat.py +0 -0
  56. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_init_detection.py +0 -0
  57. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_pin_cycles.py +0 -0
  58. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_progress.py +0 -0
  59. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_project_commands.py +0 -0
  60. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_project_env.py +0 -0
  61. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_python_env_managers.py +0 -0
  62. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_refactor_cycles.py +0 -0
  63. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_release_surface.py +0 -0
  64. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_run_claim.py +0 -0
  65. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_single_project_repo.py +0 -0
  66. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_snapshot_and_identity.py +0 -0
  67. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_stub_hint.py +0 -0
  68. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_target_validation.py +0 -0
  69. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_vitest_adapter.py +0 -0
  70. {tdd_cli-0.4.0 → tdd_cli-0.5.1}/tests/test_worker_leases.py +0 -0
@@ -4,6 +4,60 @@ 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
+ ## [0.5.1] - 2026-08-17
8
+
9
+ ### Added
10
+
11
+ - **exec adapter** — exit-code oracles as first-class test suites. Any executable
12
+ file (or any file paired with a `test_command`) becomes a test: exit 0 → passed,
13
+ non-zero → failed, stdout+stderr captured as failure output. Non-executable files
14
+ without a `test_command` map to `not_collected` rather than `failed`, so a missing
15
+ executable bit is a configuration error rather than a test failure. Supports a
16
+ `{file}` placeholder in `test_command` for per-file invocation, with shell-quoting
17
+ so paths with spaces are handled correctly (#33).
18
+
19
+ - **xctest adapter** — XCTest/xcodebuild driver for Swift and Objective-C projects.
20
+ Test ids use xcodebuild's own `-only-testing:` format (`Bundle/Class/method`) so
21
+ targeted runs compose without translation. A build failure maps to `not_collected`
22
+ rather than `failed` — Swift has no separate collection phase, so a missing-symbol
23
+ build error is not the RED state; the discipline is to write a compiling stub first,
24
+ then observe an assertion failure. Collection tries `xcodebuild test
25
+ -enumerate-tests` (Xcode 16+) and falls back to grepping Swift source files for
26
+ `class Foo: XCTestCase` and `func testBar()` patterns. Requires `test_command` in
27
+ `tdd.toml` so the adapter can append `-only-testing:` and `-enumerate-tests` flags
28
+ without guessing the scheme or destination (#34).
29
+
30
+ - **Named exclusive leases** (`lease = "<name>"` on a project or override) — before
31
+ running a suite the tool acquires an exclusive machine-wide named lease using the
32
+ same lease-directory machinery as the worker budget. Only one holder machine-wide
33
+ can hold a given name at a time; a waiting invocation blocks with a `lease_waiting`
34
+ heartbeat every 5 s rather than silently hanging. Stale locks (pid dead or older
35
+ than 1 h) are swept immediately so a crash can never permanently block a name.
36
+ The name is free-form and machine-scoped; two repos naming the same lease
37
+ intentionally contend, which is the intended semantics for shared physical hardware
38
+ or a port-bound service (#35).
39
+
40
+ - **Per-project suite timeouts** (`timeout = <seconds>` on a project or override) —
41
+ overrides the 1800 s default for suite invocations. `tdd doctor` warns when a
42
+ configured timeout is shorter than the longest recorded full-suite invocation for
43
+ that project, so a mis-configuration is caught at preflight rather than at the
44
+ timeout boundary (#35).
45
+
46
+ ## [0.4.1] - 2026-08-16
47
+
48
+ ### Changed
49
+
50
+ - `collect()` runs one invocation per declared suite instead of one per test
51
+ file, falling back to the per-file loop for anything a batch did not account
52
+ for. Per-file collection was **77% of a real `run start`** — 313 subprocesses
53
+ costing 402s, against 117s to actually run every test — because cost scaled
54
+ with file count at a ~1.08s floor per invocation (the environment manager
55
+ resolving plus the runner booting). Measured 38.8x faster on a 60-file
56
+ project, with an identical collected set. R10.3's guarantee is unchanged: a
57
+ file that fails to collect is still attributed to itself and cannot destroy
58
+ the set, and a file the batch never reports is still collected individually,
59
+ so the set can only match or improve on the old one (#27).
60
+
7
61
  ## [0.4.0] - 2026-08-16
8
62
 
9
63
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.4.0
3
+ Version: 0.5.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.4.0"
6
+ __version__ = "0.5.1"
@@ -4,12 +4,16 @@ import importlib.metadata
4
4
  from pathlib import Path
5
5
 
6
6
  from .base import Adapter, Collection, GateResult, Verdict
7
+ from .exec_adapter import ExecAdapter
7
8
  from .pytest_adapter import PytestAdapter
8
9
  from .vitest_adapter import VitestAdapter
10
+ from .xctest_adapter import XCTestAdapter
9
11
 
10
12
  REGISTRY: dict[str, type[Adapter]] = {
13
+ "exec": ExecAdapter,
11
14
  "pytest": PytestAdapter,
12
15
  "vitest": VitestAdapter,
16
+ "xctest": XCTestAdapter,
13
17
  }
14
18
 
15
19
 
@@ -39,9 +43,7 @@ def build(project, worktree: Path) -> Adapter:
39
43
  cls = ep.load()
40
44
  break
41
45
  if cls is None:
42
- raise RuntimeError(
43
- f"unknown adapter {project.adapter!r}; available: {sorted(available())}"
44
- )
46
+ raise RuntimeError(f"unknown adapter {project.adapter!r}; available: {sorted(available())}")
45
47
  return cls(project, worktree)
46
48
 
47
49
 
@@ -87,7 +87,9 @@ TIMING_ENV = "TDD_TIMING"
87
87
 
88
88
 
89
89
  def run_command(
90
- command: str, cwd: Path, timeout: int = 1800,
90
+ command: str,
91
+ cwd: Path,
92
+ timeout: int = 1800,
91
93
  extra_env: dict[str, str] | None = None,
92
94
  label: str | None = None,
93
95
  ) -> tuple[int, str, str]:
@@ -135,13 +137,16 @@ class Adapter:
135
137
 
136
138
  def strip(self, qualified: str) -> str:
137
139
  prefix = f"{self.project.name}::"
138
- return qualified[len(prefix):] if qualified.startswith(prefix) else qualified
140
+ return qualified[len(prefix) :] if qualified.startswith(prefix) else qualified
139
141
 
140
142
  def run(self, target: str | None = None) -> Verdict:
141
143
  raise NotImplementedError
142
144
 
143
145
  def _run_suite(
144
- self, command: str, extra_env: dict[str, str] | None = None
146
+ self,
147
+ command: str,
148
+ extra_env: dict[str, str] | None = None,
149
+ timeout: int | None = None,
145
150
  ) -> tuple[int, str, str]:
146
151
  """Run the suite under a machine-wide worker lease.
147
152
 
@@ -151,15 +156,32 @@ class Adapter:
151
156
  prefer to read the budget themselves. The lease is held for the whole
152
157
  invocation so concurrent agents in other worktrees see this one and
153
158
  take a smaller share.
159
+
160
+ When the project declares `lease`, an exclusive named lease is acquired
161
+ first (outer context), so only one instance of that suite runs machine-
162
+ wide at a time. When `timeout` is given it overrides the project-level
163
+ and default values.
154
164
  """
155
- with leases.worker_lease() as workers:
165
+ effective_timeout = timeout or self.project.timeout or 1800
166
+
167
+ def _run(workers: int) -> tuple[int, str, str]:
156
168
  return run_command(
157
169
  command.replace("{workers}", str(workers)),
158
170
  self.root,
171
+ timeout=effective_timeout,
159
172
  extra_env={"TDD_WORKERS": str(workers), **(extra_env or {})},
160
173
  label="suite",
161
174
  )
162
175
 
176
+ lease_name = getattr(self.project, "lease", None)
177
+ if lease_name:
178
+ with leases.named_lease(lease_name):
179
+ with leases.worker_lease() as workers:
180
+ return _run(workers)
181
+ else:
182
+ with leases.worker_lease() as workers:
183
+ return _run(workers)
184
+
163
185
  def _test_cmd(self) -> str:
164
186
  raise NotImplementedError
165
187
 
@@ -189,6 +211,48 @@ class Adapter:
189
211
  return "a body that fails loudly, never working logic"
190
212
 
191
213
  def collect(self) -> Collection:
214
+ """Enumerate the project's tests: one invocation per declared suite, with
215
+ the per-file loop kept for what that cannot account for (issue #27).
216
+
217
+ Per file, collection cost scaled with *file count* rather than test count —
218
+ measured at 77% of a whole `run start`, against a floor of ~1.08s per
219
+ invocation that is the environment manager resolving plus the runner
220
+ booting, not collection work.
221
+
222
+ The batch and the loop do not discover the same way: a batch uses the
223
+ runner's own config, the loop walks `test_paths`. So the loop still runs for
224
+ every file the batch did not report — whether the batch failed, returned
225
+ nothing, or simply never mentioned that file. R10.3's guarantee is
226
+ unchanged: one uncollectable file is attributed to itself, and cannot
227
+ destroy the set. What changes is that the healthy case no longer pays for
228
+ the broken one.
229
+ """
230
+ result = Collection()
231
+ unaccounted = {str(p.relative_to(self.root)) for p in self._test_files()}
232
+ for command, env in self._collect_invocations():
233
+ batch = self._collect_batch(command, env)
234
+ if batch is None:
235
+ continue
236
+ tests, files = batch
237
+ result.tests |= tests
238
+ unaccounted -= files
239
+ return self._collect_per_file(unaccounted, result)
240
+
241
+ def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
242
+ """One collection command per declared suite: the default plus each
243
+ override's (R7.13), so an override's files are enumerated with its own
244
+ command and env."""
245
+ raise NotImplementedError
246
+
247
+ def _collect_batch(
248
+ self, command: str, env: dict[str, str] | None
249
+ ) -> tuple[set[str], set[str]] | None:
250
+ """`(test ids, files seen)` for one whole-suite invocation, or None when it
251
+ produced nothing usable — the signal to leave its files to the loop."""
252
+ raise NotImplementedError
253
+
254
+ def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
255
+ """R10.3's loop, now reached only for files no batch accounted for."""
192
256
  raise NotImplementedError
193
257
 
194
258
  def collectable(self) -> GateResult:
@@ -0,0 +1,173 @@
1
+ """exec adapter — exit-code oracles as first-class tests (§10).
2
+
3
+ Each file matching `test_paths` is one test; the verdict is the exit code.
4
+ No output parsing, no runner protocol. Any toolchain can be bootstrapped by
5
+ wrapping its runner in a script before (or instead of) a native adapter
6
+ existing; shell-gate TDD cycles (write a failing check, migrate code until it
7
+ exits 0) use this adapter natively.
8
+
9
+ Collection is a filesystem walk — no subprocess needed. A file that is
10
+ matched by `test_paths` but cannot be run (not executable, no `test_command`)
11
+ maps to `not_collected` rather than `failed`, preserving the "a broken oracle
12
+ is not RED" semantics the other adapters uphold via import errors.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shlex
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from .base import (
23
+ FAILED,
24
+ NOT_COLLECTED,
25
+ PASSED,
26
+ Adapter,
27
+ Collection,
28
+ GateResult,
29
+ Verdict,
30
+ clip_failure,
31
+ run_command,
32
+ )
33
+
34
+
35
+ class ExecAdapter(Adapter):
36
+ name = "exec"
37
+
38
+ def stub_hint(self) -> str:
39
+ return "a script body that exits non-zero (e.g. `exit 1` with a message explaining what is missing)"
40
+
41
+ # ------------------------------------------------------------------
42
+ # Command resolution
43
+ # ------------------------------------------------------------------
44
+
45
+ def _cmd_for(self, rel: str) -> str:
46
+ """The shell command that runs one script.
47
+
48
+ When `test_command` is set it acts as a template: `{file}` is replaced
49
+ by the shell-quoted relative path. Without it the file is invoked
50
+ directly (requires executable bit and a shebang line).
51
+ """
52
+ if self.project.test_command:
53
+ return self.project.test_command.replace("{file}", shlex.quote(rel))
54
+ return f"./{rel}"
55
+
56
+ def _is_runnable(self, path: Path) -> bool:
57
+ """True when the file can be executed: either a test_command handles it,
58
+ or the file itself has the executable bit set."""
59
+ if self.project.test_command:
60
+ return True
61
+ return os.access(path, os.X_OK)
62
+
63
+ # ------------------------------------------------------------------
64
+ # Discovery
65
+ # ------------------------------------------------------------------
66
+
67
+ def _test_files(self) -> list[Path]:
68
+ found: set[Path] = set()
69
+ for pattern in self.project.test_patterns or []:
70
+ if pattern.endswith("/"):
71
+ base = self.root / pattern
72
+ if base.is_dir():
73
+ for p in base.rglob("*"):
74
+ if p.is_file():
75
+ found.add(p)
76
+ else:
77
+ for p in self.root.glob(pattern):
78
+ if p.is_file():
79
+ found.add(p)
80
+ return sorted(found)
81
+
82
+ # ------------------------------------------------------------------
83
+ # Collection — filesystem walk, no subprocess
84
+ # ------------------------------------------------------------------
85
+
86
+ def collect(self) -> Collection:
87
+ result = Collection()
88
+ for path in self._test_files():
89
+ rel = str(path.relative_to(self.root))
90
+ if self._is_runnable(path):
91
+ result.tests.add(self.qualify(rel))
92
+ else:
93
+ result.failed_files[rel] = (
94
+ f"{rel}: not executable and no test_command configured"
95
+ ' (chmod +x, or add test_command = "bash {file}" to tdd.toml)'
96
+ )
97
+ return result
98
+
99
+ # The base collect() calls these three; exec overrides collect() entirely
100
+ # so they are never reached. Providing trivial bodies avoids surprises if
101
+ # a subclass or test calls them directly.
102
+ def _collect_invocations(self):
103
+ return []
104
+
105
+ def _collect_batch(self, command, env):
106
+ return None
107
+
108
+ def _collect_per_file(self, rels, result):
109
+ return result
110
+
111
+ # ------------------------------------------------------------------
112
+ # Doctor gate
113
+ # ------------------------------------------------------------------
114
+
115
+ def collectable(self) -> GateResult:
116
+ """Warn about files that matched test_paths but cannot be run."""
117
+ if self.project.test_command:
118
+ return GateResult(ok=True)
119
+ non_exec = [
120
+ str(p.relative_to(self.root)) for p in self._test_files() if not os.access(p, os.X_OK)
121
+ ]
122
+ if not non_exec:
123
+ return GateResult(ok=True)
124
+ listed = "\n".join(non_exec[:10])
125
+ more = f"\n… and {len(non_exec) - 10} more" if len(non_exec) > 10 else ""
126
+ return GateResult(
127
+ ok=False,
128
+ output=(
129
+ "these files matched test_paths but are not executable"
130
+ ' (chmod +x, or set test_command = "bash {file}" in tdd.toml):\n' + listed + more
131
+ ),
132
+ )
133
+
134
+ # ------------------------------------------------------------------
135
+ # Suite run
136
+ # ------------------------------------------------------------------
137
+
138
+ def run(self, target: str | None = None) -> Verdict:
139
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
140
+ env = self._suite_env(None)
141
+ started = time.monotonic()
142
+
143
+ for path in self._test_files():
144
+ rel = str(path.relative_to(self.root))
145
+ qualified = self.qualify(rel)
146
+
147
+ # When targeting, skip every other test for speed.
148
+ if target is not None and qualified != target:
149
+ continue
150
+
151
+ if not self._is_runnable(path):
152
+ if target == qualified:
153
+ verdict.target_outcome = NOT_COLLECTED
154
+ verdict.target_failure = f"{rel}: not executable and no test_command configured"
155
+ continue
156
+
157
+ code, out, err = run_command(
158
+ self._cmd_for(rel), self.root, extra_env=env, label="suite"
159
+ )
160
+ combined = (out + "\n" + err).strip()
161
+
162
+ if code == 0:
163
+ verdict.passed.append(qualified)
164
+ if target == qualified:
165
+ verdict.target_outcome = PASSED
166
+ else:
167
+ verdict.failed.append(qualified)
168
+ if target == qualified:
169
+ verdict.target_outcome = FAILED
170
+ verdict.target_failure = clip_failure(combined)
171
+
172
+ verdict.duration_ms = int((time.monotonic() - started) * 1000)
173
+ return verdict
@@ -235,23 +235,49 @@ class PytestAdapter(Adapter):
235
235
  " cannot reach them (e.g. `pytest tests/`)."
236
236
  ))
237
237
 
238
- def collect(self) -> Collection:
238
+ def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
239
+ """An override without a `collect_command` collects with its `test_command`
240
+ — `--collect-only` composes with any run command."""
241
+ return [(self._collect_cmd(), self._suite_env(None))] + [
242
+ (ov.collect_command or ov.test_command, self._suite_env(ov))
243
+ for ov in self.project.overrides
244
+ ]
245
+
246
+ def _nodeids(self, out: str) -> tuple[set[str], set[str]]:
247
+ tests: set[str] = set()
248
+ files: set[str] = set()
249
+ for line in out.splitlines():
250
+ line = line.strip()
251
+ if "::" in line and not line.startswith(("=", "-", "no tests")):
252
+ tests.add(self.qualify(line))
253
+ files.add(line.split("::", 1)[0])
254
+ return tests, files
255
+
256
+ def _collect_batch(
257
+ self, command: str, env: dict[str, str] | None
258
+ ) -> tuple[set[str], set[str]] | None:
259
+ code, out, err = run_command(
260
+ f"{command} --collect-only -q", self.root, extra_env=env, label="collect"
261
+ )
262
+ if code != 0:
263
+ return None
264
+ # An empty result needs no special case: it accounts for no files, so every
265
+ # file falls to the loop exactly as a failure would.
266
+ return self._nodeids(out)
267
+
268
+ def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
239
269
  """Per file (R10.3) — one uncollectable module must not destroy the whole set."""
240
- result = Collection()
241
- for path in self._test_files():
242
- rel = path.relative_to(self.root)
243
- base, env = self._collect_cmd_for(str(rel))
270
+ for rel in sorted(rels):
271
+ base, env = self._collect_cmd_for(rel)
244
272
  code, out, err = run_command(
245
- f"{base} --collect-only -q {shlex.quote(str(rel))}",
273
+ f"{base} --collect-only -q {shlex.quote(rel)}",
246
274
  self.root,
247
275
  extra_env=env,
248
276
  label="collect",
249
277
  )
250
278
  if code != 0:
251
- result.failed_files[str(rel)] = (err or out).strip()[:800]
279
+ result.failed_files[rel] = (err or out).strip()[:800]
252
280
  continue
253
- for line in out.splitlines():
254
- line = line.strip()
255
- if "::" in line and not line.startswith(("=", "-", "no tests")):
256
- result.tests.add(self.qualify(line))
281
+ tests, _ = self._nodeids(out)
282
+ result.tests |= tests
257
283
  return result
@@ -223,10 +223,43 @@ class VitestAdapter(Adapter):
223
223
  " config (test.exclude) or scope its include globs."
224
224
  ))
225
225
 
226
- def collect(self) -> Collection:
227
- result = Collection()
228
- for path in self._test_files():
229
- rel = path.relative_to(self.root)
226
+ def _collect_invocations(self) -> list[tuple[str, dict[str, str] | None]]:
227
+ """An override with no `collect_command` gets no batch — `vitest list` knows
228
+ nothing of its config, and its files fall to the loop, which records the
229
+ missing `collect_command` against each of them as before."""
230
+ return [(self._collect_cmd(), self._suite_env(None))] + [
231
+ (ov.collect_command, self._suite_env(ov))
232
+ for ov in self.project.overrides if ov.collect_command
233
+ ]
234
+
235
+ def _collect_batch(
236
+ self, command: str, env: dict[str, str] | None
237
+ ) -> tuple[set[str], set[str]] | None:
238
+ """Unlike `_parse_list_output`, which pins every id to the one file it was
239
+ given, a whole-suite listing must read the file from each line — the same
240
+ `file > describe > name` shape, one file per line rather than one per run."""
241
+ code, out, err = run_command(command, self.root, extra_env=env, label="collect")
242
+ if code != 0:
243
+ return None
244
+ tests: set[str] = set()
245
+ files: set[str] = set()
246
+ for line in out.splitlines():
247
+ line = line.strip()
248
+ if " > " not in line:
249
+ continue
250
+ rel, _, remainder = line.partition(" > ")
251
+ full_name = " ".join(part.strip() for part in remainder.split(" > "))
252
+ if rel and full_name:
253
+ tests.add(self.qualify(f"{rel.strip()} > {full_name}"))
254
+ files.add(rel.strip())
255
+ # An empty result needs no special case: it accounts for no files, so every
256
+ # file falls to the loop exactly as a failure would.
257
+ return tests, files
258
+
259
+ def _collect_per_file(self, rels: set[str], result: Collection) -> Collection:
260
+ for rel_str in sorted(rels):
261
+ path = self.root / rel_str
262
+ rel = Path(rel_str)
230
263
  ov = self.project.override_for(str(rel))
231
264
  if ov is not None and not ov.collect_command:
232
265
  result.failed_files[str(rel)] = (