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.
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/CHANGELOG.md +59 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/PKG-INFO +24 -7
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/README.md +23 -6
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/__init__.py +7 -3
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/base.py +26 -4
- tdd_cli-0.6.0/src/tddcli/adapters/exec_adapter.py +173 -0
- tdd_cli-0.6.0/src/tddcli/adapters/gradle_adapter.py +327 -0
- tdd_cli-0.6.0/src/tddcli/adapters/xctest_adapter.py +307 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/cli.py +158 -82
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/config.py +26 -16
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/leases.py +49 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/ledger.py +17 -8
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_doctor_attribution.py +74 -5
- tdd_cli-0.6.0/tests/test_exec_adapter.py +313 -0
- tdd_cli-0.6.0/tests/test_gradle_adapter.py +453 -0
- tdd_cli-0.6.0/tests/test_named_leases_and_timeout.py +471 -0
- tdd_cli-0.6.0/tests/test_xctest_adapter.py +434 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/.gitignore +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/LICENSE +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/SECURITY.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/plan.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/pyproject.toml +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/advance.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/render.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/conftest.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_batch_collection.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_contract.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_fleet.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_progress.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_project_env.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_suite_overrides.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_target_validation.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.4.1 → tdd_cli-0.6.0}/tests/test_vitest_adapter.py +0 -0
- {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.
|
|
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 `
|
|
294
|
-
project's own environment manager, detected from its marker files —
|
|
295
|
-
`poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
296
|
-
the project root first, then the worktree root (workspace layouts keep one
|
|
297
|
-
top). With no marker, the active environment's bare `pytest` runs. An
|
|
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 `
|
|
266
|
-
project's own environment manager, detected from its marker files —
|
|
267
|
-
`poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
|
|
268
|
-
the project root first, then the worktree root (workspace layouts keep one
|
|
269
|
-
top). With no marker, the active environment's bare `pytest` runs. An
|
|
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:
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|