tdd-cli 0.4.1__tar.gz → 0.6.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 (72) hide show
  1. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/CHANGELOG.md +59 -0
  2. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/PKG-INFO +24 -7
  3. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/README.md +23 -6
  4. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/__init__.py +7 -3
  6. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/base.py +26 -4
  7. tdd_cli-0.6.0/src/tddcli/adapters/exec_adapter.py +173 -0
  8. tdd_cli-0.6.0/src/tddcli/adapters/gradle_adapter.py +327 -0
  9. tdd_cli-0.6.0/src/tddcli/adapters/xctest_adapter.py +307 -0
  10. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/cli.py +158 -82
  11. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/config.py +26 -16
  12. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/leases.py +49 -0
  13. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/ledger.py +17 -8
  14. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_doctor_attribution.py +74 -5
  15. tdd_cli-0.6.0/tests/test_exec_adapter.py +313 -0
  16. tdd_cli-0.6.0/tests/test_gradle_adapter.py +453 -0
  17. tdd_cli-0.6.0/tests/test_named_leases_and_timeout.py +471 -0
  18. tdd_cli-0.6.0/tests/test_xctest_adapter.py +434 -0
  19. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/.gitignore +0 -0
  20. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/LICENSE +0 -0
  21. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/SECURITY.md +0 -0
  22. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/README.md +0 -0
  23. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  24. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  25. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/plan.md +0 -0
  26. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-drive/README.md +0 -0
  27. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  28. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-handoff/README.md +0 -0
  29. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  30. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/pyproject.toml +0 -0
  31. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
  32. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
  33. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/advance.py +0 -0
  34. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/contract.py +0 -0
  35. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/envelope.py +0 -0
  36. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/fleet.py +0 -0
  37. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/gitutil.py +0 -0
  38. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/identity.py +0 -0
  39. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/machine.py +0 -0
  40. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/render.py +0 -0
  41. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/snapshot.py +0 -0
  42. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/staging.py +0 -0
  43. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/conftest.py +0 -0
  44. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_artifact_regeneration.py +0 -0
  45. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_baseline_integrity.py +0 -0
  46. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_batch_collection.py +0 -0
  47. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_config_and_staging.py +0 -0
  48. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_config_drift.py +0 -0
  49. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_contract.py +0 -0
  50. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_doctor_blockers.py +0 -0
  51. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_end_to_end.py +0 -0
  52. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_example_plan.py +0 -0
  53. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_failure_clipping.py +0 -0
  54. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_fleet.py +0 -0
  55. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_heartbeat.py +0 -0
  56. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_init_detection.py +0 -0
  57. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_pin_cycles.py +0 -0
  58. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_progress.py +0 -0
  59. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_project_commands.py +0 -0
  60. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_project_env.py +0 -0
  61. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_python_env_managers.py +0 -0
  62. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_refactor_cycles.py +0 -0
  63. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_release_surface.py +0 -0
  64. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_run_claim.py +0 -0
  65. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_single_project_repo.py +0 -0
  66. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_snapshot_and_identity.py +0 -0
  67. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_stub_hint.py +0 -0
  68. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_suite_overrides.py +0 -0
  69. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_target_validation.py +0 -0
  70. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_timing_visibility.py +0 -0
  71. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_vitest_adapter.py +0 -0
  72. {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_worker_leases.py +0 -0
@@ -4,6 +4,65 @@ 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.6.0] - 2026-08-19
8
+
9
+ ### Added
10
+
11
+ - **gradle adapter** — Gradle driver for Kotlin/JVM and Android projects. Runs the
12
+ project's Gradle test task (`./gradlew test`, `testDebugUnitTest`, or
13
+ `connectedDebugAndroidTest`) and reads per-test verdicts from the JUnit XML Gradle
14
+ writes, rather than scraping the console — unit and instrumented tasks share one
15
+ parser, so the task is a config choice, not a code path. A compile failure maps to
16
+ `not_collected` rather than `failed`: Kotlin has no separate collection phase, so a
17
+ test referencing a missing symbol fails the build, and the "stub before RED"
18
+ discipline holds exactly as it does for a Python import error. The discriminator is
19
+ whether fresh JUnit XML was produced, not "BUILD FAILED" (a test failure prints that
20
+ too); a `--tests` filter matching nothing maps to `not_found`. Stale results are
21
+ cleared before each run, which also invalidates Gradle's up-to-date check so a
22
+ targeted run is scored only against the XML it produced. Collection is a per-file
23
+ grep of Kotlin/Java sources (Gradle has no cheap whole-suite enumerator), so one
24
+ unreadable file cannot destroy the set. `tdd doctor` gains a `gradle wrapper present`
25
+ check, active only when the command uses `./gradlew` (#41).
26
+
27
+ ## [0.5.1] - 2026-08-17
28
+
29
+ ### Added
30
+
31
+ - **exec adapter** — exit-code oracles as first-class test suites. Any executable
32
+ file (or any file paired with a `test_command`) becomes a test: exit 0 → passed,
33
+ non-zero → failed, stdout+stderr captured as failure output. Non-executable files
34
+ without a `test_command` map to `not_collected` rather than `failed`, so a missing
35
+ executable bit is a configuration error rather than a test failure. Supports a
36
+ `{file}` placeholder in `test_command` for per-file invocation, with shell-quoting
37
+ so paths with spaces are handled correctly (#33).
38
+
39
+ - **xctest adapter** — XCTest/xcodebuild driver for Swift and Objective-C projects.
40
+ Test ids use xcodebuild's own `-only-testing:` format (`Bundle/Class/method`) so
41
+ targeted runs compose without translation. A build failure maps to `not_collected`
42
+ rather than `failed` — Swift has no separate collection phase, so a missing-symbol
43
+ build error is not the RED state; the discipline is to write a compiling stub first,
44
+ then observe an assertion failure. Collection tries `xcodebuild test
45
+ -enumerate-tests` (Xcode 16+) and falls back to grepping Swift source files for
46
+ `class Foo: XCTestCase` and `func testBar()` patterns. Requires `test_command` in
47
+ `tdd.toml` so the adapter can append `-only-testing:` and `-enumerate-tests` flags
48
+ without guessing the scheme or destination (#34).
49
+
50
+ - **Named exclusive leases** (`lease = "<name>"` on a project or override) — before
51
+ running a suite the tool acquires an exclusive machine-wide named lease using the
52
+ same lease-directory machinery as the worker budget. Only one holder machine-wide
53
+ can hold a given name at a time; a waiting invocation blocks with a `lease_waiting`
54
+ heartbeat every 5 s rather than silently hanging. Stale locks (pid dead or older
55
+ than 1 h) are swept immediately so a crash can never permanently block a name.
56
+ The name is free-form and machine-scoped; two repos naming the same lease
57
+ intentionally contend, which is the intended semantics for shared physical hardware
58
+ or a port-bound service (#35).
59
+
60
+ - **Per-project suite timeouts** (`timeout = <seconds>` on a project or override) —
61
+ overrides the 1800 s default for suite invocations. `tdd doctor` warns when a
62
+ configured timeout is shorter than the longest recorded full-suite invocation for
63
+ that project, so a mis-configuration is caught at preflight rather than at the
64
+ timeout boundary (#35).
65
+
7
66
  ## [0.4.1] - 2026-08-16
8
67
 
9
68
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.4.1
3
+ Version: 0.6.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
@@ -290,12 +290,29 @@ the next plan is written.
290
290
 
291
291
  ## Adapters
292
292
 
293
- `pytest` and `vitest` are built in. The pytest adapter runs the suite through the
294
- project's own environment manager, detected from its marker files — `uv.lock`,
295
- `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` — checked at
296
- the project root first, then the worktree root (workspace layouts keep one lockfile at the
297
- top). With no marker, the active environment's bare `pytest` runs. An explicit
298
- `test_command` always wins.
293
+ `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
294
+ the suite through the project's own environment manager, detected from its marker files —
295
+ `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
296
+ checked at the project root first, then the worktree root (workspace layouts keep one
297
+ lockfile at the top). With no marker, the active environment's bare `pytest` runs. An
298
+ explicit `test_command` always wins.
299
+
300
+ The `gradle` adapter drives Kotlin/JVM and Android projects. It runs the project's Gradle
301
+ test task and reads per-test verdicts from the JUnit XML Gradle writes, rather than
302
+ scraping the console. The task is a config choice — `./gradlew test` or `testDebugUnitTest`
303
+ for fast JVM unit tests, `connectedDebugAndroidTest` for instrumented tests on a device
304
+ (give those a longer `timeout` and a `lease` so only one run touches the device at a time).
305
+ A *compile* failure maps to `not_collected`, not `failed`: Kotlin has no separate
306
+ collection phase, so a stub referencing a missing symbol fails the build, and the "stub
307
+ before RED" discipline holds exactly as it does for a Python import error.
308
+
309
+ ```toml
310
+ [project.android-app]
311
+ root = "android-app"
312
+ adapter = "gradle"
313
+ test_paths = ["src/test/"]
314
+ test_command = "./gradlew testDebugUnitTest"
315
+ ```
299
316
 
300
317
  Third-party adapters register under the
301
318
  `tddcli.adapters` entry-point group:
@@ -262,12 +262,29 @@ the next plan is written.
262
262
 
263
263
  ## Adapters
264
264
 
265
- `pytest` and `vitest` are built in. The pytest adapter runs the suite through the
266
- project's own environment manager, detected from its marker files — `uv.lock`,
267
- `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` — checked at
268
- the project root first, then the worktree root (workspace layouts keep one lockfile at the
269
- top). With no marker, the active environment's bare `pytest` runs. An explicit
270
- `test_command` always wins.
265
+ `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
266
+ the suite through the project's own environment manager, detected from its marker files —
267
+ `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
268
+ checked at the project root first, then the worktree root (workspace layouts keep one
269
+ lockfile at the top). With no marker, the active environment's bare `pytest` runs. An
270
+ explicit `test_command` always wins.
271
+
272
+ The `gradle` adapter drives Kotlin/JVM and Android projects. It runs the project's Gradle
273
+ test task and reads per-test verdicts from the JUnit XML Gradle writes, rather than
274
+ scraping the console. The task is a config choice — `./gradlew test` or `testDebugUnitTest`
275
+ for fast JVM unit tests, `connectedDebugAndroidTest` for instrumented tests on a device
276
+ (give those a longer `timeout` and a `lease` so only one run touches the device at a time).
277
+ A *compile* failure maps to `not_collected`, not `failed`: Kotlin has no separate
278
+ collection phase, so a stub referencing a missing symbol fails the build, and the "stub
279
+ before RED" discipline holds exactly as it does for a Python import error.
280
+
281
+ ```toml
282
+ [project.android-app]
283
+ root = "android-app"
284
+ adapter = "gradle"
285
+ test_paths = ["src/test/"]
286
+ test_command = "./gradlew testDebugUnitTest"
287
+ ```
271
288
 
272
289
  Third-party adapters register under the
273
290
  `tddcli.adapters` entry-point group:
@@ -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.1"
6
+ __version__ = "0.6.0"
@@ -4,12 +4,18 @@ 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
8
+ from .gradle_adapter import GradleAdapter
7
9
  from .pytest_adapter import PytestAdapter
8
10
  from .vitest_adapter import VitestAdapter
11
+ from .xctest_adapter import XCTestAdapter
9
12
 
10
13
  REGISTRY: dict[str, type[Adapter]] = {
14
+ "exec": ExecAdapter,
15
+ "gradle": GradleAdapter,
11
16
  "pytest": PytestAdapter,
12
17
  "vitest": VitestAdapter,
18
+ "xctest": XCTestAdapter,
13
19
  }
14
20
 
15
21
 
@@ -39,9 +45,7 @@ def build(project, worktree: Path) -> Adapter:
39
45
  cls = ep.load()
40
46
  break
41
47
  if cls is None:
42
- raise RuntimeError(
43
- f"unknown adapter {project.adapter!r}; available: {sorted(available())}"
44
- )
48
+ raise RuntimeError(f"unknown adapter {project.adapter!r}; available: {sorted(available())}")
45
49
  return cls(project, worktree)
46
50
 
47
51
 
@@ -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
 
@@ -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