tdd-cli 0.5.1__tar.gz → 0.7.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 (74) hide show
  1. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/CHANGELOG.md +46 -0
  2. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/PKG-INFO +89 -9
  3. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/README.md +88 -8
  4. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/__init__.py +2 -0
  6. tdd_cli-0.7.0/src/tddcli/adapters/gradle_adapter.py +327 -0
  7. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/xctest_adapter.py +11 -11
  8. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/advance.py +10 -0
  9. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/cli.py +87 -17
  10. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/config.py +29 -7
  11. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/ledger.py +63 -20
  12. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/machine.py +20 -5
  13. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/conftest.py +88 -1
  14. tdd_cli-0.7.0/tests/test_baseline_integrity.py +321 -0
  15. tdd_cli-0.7.0/tests/test_concurrent_advance.py +178 -0
  16. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_config_and_staging.py +103 -0
  17. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_doctor_attribution.py +74 -5
  18. tdd_cli-0.7.0/tests/test_gradle_adapter.py +453 -0
  19. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_heartbeat.py +20 -1
  20. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_xctest_adapter.py +40 -49
  21. tdd_cli-0.5.1/tests/test_baseline_integrity.py +0 -148
  22. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/.gitignore +0 -0
  23. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/LICENSE +0 -0
  24. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/SECURITY.md +0 -0
  25. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/README.md +0 -0
  26. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  27. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  28. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/plan.md +0 -0
  29. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-drive/README.md +0 -0
  30. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  31. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-handoff/README.md +0 -0
  32. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  33. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/pyproject.toml +0 -0
  34. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/base.py +0 -0
  35. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/exec_adapter.py +0 -0
  36. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
  37. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
  38. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/contract.py +0 -0
  39. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/envelope.py +0 -0
  40. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/fleet.py +0 -0
  41. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/gitutil.py +0 -0
  42. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/identity.py +0 -0
  43. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/leases.py +0 -0
  44. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/render.py +0 -0
  45. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/snapshot.py +0 -0
  46. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/src/tddcli/staging.py +0 -0
  47. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_artifact_regeneration.py +0 -0
  48. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_batch_collection.py +0 -0
  49. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_config_drift.py +0 -0
  50. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_contract.py +0 -0
  51. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_doctor_blockers.py +0 -0
  52. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_end_to_end.py +0 -0
  53. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_example_plan.py +0 -0
  54. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_exec_adapter.py +0 -0
  55. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_failure_clipping.py +0 -0
  56. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_fleet.py +0 -0
  57. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_init_detection.py +0 -0
  58. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_named_leases_and_timeout.py +0 -0
  59. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_pin_cycles.py +0 -0
  60. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_progress.py +0 -0
  61. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_project_commands.py +0 -0
  62. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_project_env.py +0 -0
  63. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_python_env_managers.py +0 -0
  64. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_refactor_cycles.py +0 -0
  65. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_release_surface.py +0 -0
  66. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_run_claim.py +0 -0
  67. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_single_project_repo.py +0 -0
  68. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_snapshot_and_identity.py +0 -0
  69. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_stub_hint.py +0 -0
  70. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_suite_overrides.py +0 -0
  71. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_target_validation.py +0 -0
  72. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_timing_visibility.py +0 -0
  73. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_vitest_adapter.py +0 -0
  74. {tdd_cli-0.5.1 → tdd_cli-0.7.0}/tests/test_worker_leases.py +0 -0
@@ -4,6 +4,52 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [0.7.0] - 2026-08-23
10
+
11
+ ### Fixed
12
+
13
+ - **Concurrent `tdd advance` no longer corrupts a run.** Two `advance` processes
14
+ racing on the same worktree could both close the same cycle row. Because
15
+ `close_cycle` unconditionally opened the next ordinal, a double-close forked the
16
+ run into two parallel cycle chains; every remaining cycle ran twice, the run
17
+ "completed" with one chain's last row permanently open, and the doubling was
18
+ invisible from the agent's perspective. `close_cycle` now re-reads `closed_at`
19
+ before acting; if the row is already closed it returns the currently-open cycle
20
+ without transitioning or opening anything. `open_cycle` returns the existing open
21
+ row for an ordinal rather than inserting a duplicate.
22
+
23
+ ### Added
24
+
25
+ - **Per-worktree advance claim.** `tdd advance` now acquires a `advance_claim` row
26
+ before dispatching. A second concurrent `advance` is refused immediately with
27
+ `ok: false`, `reason: "advance_in_flight"`, and metadata (`pid`, `started_at`,
28
+ `elapsed_s`) that lets the agent confirm the holder is still alive. The claim is
29
+ released in a `finally` so a raising handler cannot wedge the worktree; a claim
30
+ held by a dead pid is reclaimed automatically on the next call. Schema version
31
+ bumped to 3.
32
+
33
+ ## [0.6.0] - 2026-08-19
34
+
35
+ ### Added
36
+
37
+ - **gradle adapter** — Gradle driver for Kotlin/JVM and Android projects. Runs the
38
+ project's Gradle test task (`./gradlew test`, `testDebugUnitTest`, or
39
+ `connectedDebugAndroidTest`) and reads per-test verdicts from the JUnit XML Gradle
40
+ writes, rather than scraping the console — unit and instrumented tasks share one
41
+ parser, so the task is a config choice, not a code path. A compile failure maps to
42
+ `not_collected` rather than `failed`: Kotlin has no separate collection phase, so a
43
+ test referencing a missing symbol fails the build, and the "stub before RED"
44
+ discipline holds exactly as it does for a Python import error. The discriminator is
45
+ whether fresh JUnit XML was produced, not "BUILD FAILED" (a test failure prints that
46
+ too); a `--tests` filter matching nothing maps to `not_found`. Stale results are
47
+ cleared before each run, which also invalidates Gradle's up-to-date check so a
48
+ targeted run is scored only against the XML it produced. Collection is a per-file
49
+ grep of Kotlin/Java sources (Gradle has no cheap whole-suite enumerator), so one
50
+ unreadable file cannot destroy the set. `tdd doctor` gains a `gradle wrapper present`
51
+ check, active only when the command uses `./gradlew` (#41).
52
+
7
53
  ## [0.5.1] - 2026-08-17
8
54
 
9
55
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.5.1
3
+ Version: 0.7.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,57 @@ 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
+ ```
316
+
317
+ The `xctest` adapter drives Swift and Objective-C projects through `xcodebuild`. Test ids
318
+ use xcodebuild's own `-only-testing:` format — `BundleName/ClassName/testMethodName` — so
319
+ a single XCTest can be driven through RED → GREEN without any id translation. Collection
320
+ prefers `xcodebuild test -enumerate-tests` (Xcode 16+) and falls back to grepping
321
+ `class Foo: XCTestCase` / `func testBar()` out of the Swift sources under `test_paths`,
322
+ deriving the bundle name from `-scheme`. As with gradle, a *build* failure maps to
323
+ `not_collected` rather than `failed`, so a stub referencing a missing symbol is not
324
+ mistaken for RED.
325
+
326
+ `test_command` is required here — the adapter cannot guess the scheme, destination, or
327
+ derived-data path. It appends `-only-testing:` for targeted runs and `-enumerate-tests`
328
+ for collection, and changes nothing else. Simulator runs are serial in practice: give
329
+ them a `lease` so two projects don't drive the same simulator at once.
330
+
331
+ ```toml
332
+ [project.native-ios]
333
+ root = "native-ios"
334
+ adapter = "xctest"
335
+ test_paths = ["AppTests/"]
336
+ test_command = """xcodebuild test \
337
+ -project App.xcodeproj \
338
+ -scheme AppTests \
339
+ -destination 'platform=iOS Simulator,name=App-Unit' \
340
+ -derivedDataPath /tmp/app-unit-dd"""
341
+ lease = "ios-simulator"
342
+ timeout = 900
343
+ ```
299
344
 
300
345
  Third-party adapters register under the
301
346
  `tddcli.adapters` entry-point group:
@@ -358,14 +403,49 @@ and is never reclassified as a pin.
358
403
  | `tdd metrics` | fidelity, attempts, violations, interventions |
359
404
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
360
405
 
406
+ ## Scoped baseline capture (R9.5c)
407
+
408
+ `run start` probes only the projects the plan can actually reach: the declared cycle projects
409
+ plus the transitive `consumed_by` closure of artifacts whose producer is in that set. Projects
410
+ outside the reachable set never run during the plan, so their baseline is never subtracted from
411
+ anything — probing them is pure overhead. A `baseline_scoped` integrity event records which
412
+ projects were skipped, so the scoping is auditable.
413
+
414
+ Pass `--baseline-all` to probe every project in `tdd.toml` regardless:
415
+
416
+ tdd run start --plan tasks/plan.md --baseline-all
417
+
418
+ Use this when a cycle may edit files outside the predicted reachable set and you want every
419
+ project baselined up front rather than hitting the `no_baseline_for_project` escape hatch later.
420
+
421
+ ### When a sweep reaches an un-baselined project (R9.5d)
422
+
423
+ If an edit during a run touches a file owned by an artifact that was outside the predicted
424
+ reachable set, the close sweep may pull in a project that was never baselined. Its failures are
425
+ unattributable — no baseline exists to subtract — so the sweep replies `resolve_blocker` with
426
+ kind `no_baseline_for_project` rather than mislabelling them as regressions. Recovery:
427
+
428
+ tdd blocker --kind no_baseline_for_project --detail "svc pulled in unexpectedly"
429
+ tdd resume --unblock --accept-failures --note "folding svc sweep failures into baseline"
430
+
431
+ `--accept-failures` inserts a fresh baseline row for the un-baselined project (recording those
432
+ failures as pre-existing) and records a `baseline_amended` event. The next advance proceeds
433
+ with the project properly baselined.
434
+
361
435
  ## Running a long baseline
362
436
 
363
- `run start` probes every project's suite before a run exists (R9.5a), and on a real project
437
+ `run start` probes the reachable project set (R9.5c) before a run exists, and on a real project
364
438
  that can take minutes — well past an agent harness's default Bash timeout. If the command
365
439
  appears to hang or time out, **do not re-run it**: the probe is still making progress in the
366
440
  background, and a second `run start` against the same worktree is refused with
367
441
  `reason: "baseline_in_progress"` — retrying on timeout just stacks refusals on top of a
368
- baseline that was never stuck. In order of preference:
442
+ baseline that was never stuck.
443
+
444
+ `tdd advance` is similarly single-flight per worktree. A close sweep (artifact regeneration, full
445
+ suite, lint, typecheck) can run for several minutes. If an advance appears to hang, **do not
446
+ re-run it**: a second `advance` against the same worktree is refused with
447
+ `reason: "advance_in_flight"`. The refusal carries `pid`, `started_at`, and `elapsed_s` so the
448
+ agent can confirm the first process is still alive. Run `tdd status` to see the current run state. In order of preference:
369
449
 
370
450
  1. **Background it.** Run `tdd run start` in the background if your harness supports it. The
371
451
  heartbeat (`baseline_captured` / `project_completed` lines on stderr) lands in the task log
@@ -262,12 +262,57 @@ 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
+ ```
288
+
289
+ The `xctest` adapter drives Swift and Objective-C projects through `xcodebuild`. Test ids
290
+ use xcodebuild's own `-only-testing:` format — `BundleName/ClassName/testMethodName` — so
291
+ a single XCTest can be driven through RED → GREEN without any id translation. Collection
292
+ prefers `xcodebuild test -enumerate-tests` (Xcode 16+) and falls back to grepping
293
+ `class Foo: XCTestCase` / `func testBar()` out of the Swift sources under `test_paths`,
294
+ deriving the bundle name from `-scheme`. As with gradle, a *build* failure maps to
295
+ `not_collected` rather than `failed`, so a stub referencing a missing symbol is not
296
+ mistaken for RED.
297
+
298
+ `test_command` is required here — the adapter cannot guess the scheme, destination, or
299
+ derived-data path. It appends `-only-testing:` for targeted runs and `-enumerate-tests`
300
+ for collection, and changes nothing else. Simulator runs are serial in practice: give
301
+ them a `lease` so two projects don't drive the same simulator at once.
302
+
303
+ ```toml
304
+ [project.native-ios]
305
+ root = "native-ios"
306
+ adapter = "xctest"
307
+ test_paths = ["AppTests/"]
308
+ test_command = """xcodebuild test \
309
+ -project App.xcodeproj \
310
+ -scheme AppTests \
311
+ -destination 'platform=iOS Simulator,name=App-Unit' \
312
+ -derivedDataPath /tmp/app-unit-dd"""
313
+ lease = "ios-simulator"
314
+ timeout = 900
315
+ ```
271
316
 
272
317
  Third-party adapters register under the
273
318
  `tddcli.adapters` entry-point group:
@@ -330,14 +375,49 @@ and is never reclassified as a pin.
330
375
  | `tdd metrics` | fidelity, attempts, violations, interventions |
331
376
  | `tdd fleet [--json]` | all active runs across every worktree; read-only |
332
377
 
378
+ ## Scoped baseline capture (R9.5c)
379
+
380
+ `run start` probes only the projects the plan can actually reach: the declared cycle projects
381
+ plus the transitive `consumed_by` closure of artifacts whose producer is in that set. Projects
382
+ outside the reachable set never run during the plan, so their baseline is never subtracted from
383
+ anything — probing them is pure overhead. A `baseline_scoped` integrity event records which
384
+ projects were skipped, so the scoping is auditable.
385
+
386
+ Pass `--baseline-all` to probe every project in `tdd.toml` regardless:
387
+
388
+ tdd run start --plan tasks/plan.md --baseline-all
389
+
390
+ Use this when a cycle may edit files outside the predicted reachable set and you want every
391
+ project baselined up front rather than hitting the `no_baseline_for_project` escape hatch later.
392
+
393
+ ### When a sweep reaches an un-baselined project (R9.5d)
394
+
395
+ If an edit during a run touches a file owned by an artifact that was outside the predicted
396
+ reachable set, the close sweep may pull in a project that was never baselined. Its failures are
397
+ unattributable — no baseline exists to subtract — so the sweep replies `resolve_blocker` with
398
+ kind `no_baseline_for_project` rather than mislabelling them as regressions. Recovery:
399
+
400
+ tdd blocker --kind no_baseline_for_project --detail "svc pulled in unexpectedly"
401
+ tdd resume --unblock --accept-failures --note "folding svc sweep failures into baseline"
402
+
403
+ `--accept-failures` inserts a fresh baseline row for the un-baselined project (recording those
404
+ failures as pre-existing) and records a `baseline_amended` event. The next advance proceeds
405
+ with the project properly baselined.
406
+
333
407
  ## Running a long baseline
334
408
 
335
- `run start` probes every project's suite before a run exists (R9.5a), and on a real project
409
+ `run start` probes the reachable project set (R9.5c) before a run exists, and on a real project
336
410
  that can take minutes — well past an agent harness's default Bash timeout. If the command
337
411
  appears to hang or time out, **do not re-run it**: the probe is still making progress in the
338
412
  background, and a second `run start` against the same worktree is refused with
339
413
  `reason: "baseline_in_progress"` — retrying on timeout just stacks refusals on top of a
340
- baseline that was never stuck. In order of preference:
414
+ baseline that was never stuck.
415
+
416
+ `tdd advance` is similarly single-flight per worktree. A close sweep (artifact regeneration, full
417
+ suite, lint, typecheck) can run for several minutes. If an advance appears to hang, **do not
418
+ re-run it**: a second `advance` against the same worktree is refused with
419
+ `reason: "advance_in_flight"`. The refusal carries `pid`, `started_at`, and `elapsed_s` so the
420
+ agent can confirm the first process is still alive. Run `tdd status` to see the current run state. In order of preference:
341
421
 
342
422
  1. **Background it.** Run `tdd run start` in the background if your harness supports it. The
343
423
  heartbeat (`baseline_captured` / `project_completed` lines on stderr) lands in the task log
@@ -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.5.1"
6
+ __version__ = "0.7.0"
@@ -5,12 +5,14 @@ from pathlib import Path
5
5
 
6
6
  from .base import Adapter, Collection, GateResult, Verdict
7
7
  from .exec_adapter import ExecAdapter
8
+ from .gradle_adapter import GradleAdapter
8
9
  from .pytest_adapter import PytestAdapter
9
10
  from .vitest_adapter import VitestAdapter
10
11
  from .xctest_adapter import XCTestAdapter
11
12
 
12
13
  REGISTRY: dict[str, type[Adapter]] = {
13
14
  "exec": ExecAdapter,
15
+ "gradle": GradleAdapter,
14
16
  "pytest": PytestAdapter,
15
17
  "vitest": VitestAdapter,
16
18
  "xctest": XCTestAdapter,
@@ -0,0 +1,327 @@
1
+ """gradle adapter — Gradle driver for Kotlin/JVM and Android projects (§10).
2
+
3
+ Runs the project's Gradle test task (`./gradlew test`, `testDebugUnitTest`,
4
+ `connectedDebugAndroidTest`, …) and reads the per-test verdicts from the
5
+ JUnit XML Gradle always writes. Console output is *not* parsed: Gradle's
6
+ console is a task-level summary, not a reliable per-test log, whereas the XML
7
+ under `build/test-results/` (unit) and `build/outputs/.../androidTest-results/`
8
+ (instrumented) carries an exact `classname` / `name` / `failure` for every
9
+ case. Both unit and instrumented tasks write the same JUnit shape, so one
10
+ parser serves both — the task is a config choice (`test_command`), not a code
11
+ path.
12
+
13
+ Test ids use `FQCN/method` so a targeted run composes to Gradle's own filter
14
+ without translation:
15
+
16
+ com.example.PollCadenceTest/scalesOnlyUnderE2E
17
+
18
+ The adapter appends `--tests com.example.PollCadenceTest.scalesOnlyUnderE2E`
19
+ for a targeted run; nothing else.
20
+
21
+ A *compile* failure maps to `not_collected`, not `failed`. Kotlin has no
22
+ separate collection phase — a test referencing a missing symbol fails
23
+ `compileTestKotlin`, not the run — so a compile error is treated like Python's
24
+ import error and Swift's build failure: write a compiling stub, then observe
25
+ the assertion failure. The discriminator is not "BUILD FAILED" (a *test*
26
+ failure prints that too) but *whether any fresh JUnit XML was produced*: tests
27
+ that ran leave results, a compile error leaves none.
28
+
29
+ Stale results (R10.3): Gradle leaves the previous run's XML on disk and skips
30
+ up-to-date tasks, so a targeted `--tests` run could otherwise be scored against
31
+ another test's old XML. The adapter deletes the result files before each run —
32
+ which also invalidates Gradle's up-to-date check, forcing the task to actually
33
+ re-run — so whatever XML exists afterward belongs to this invocation alone.
34
+
35
+ Collection is a per-file grep of Kotlin/Java sources (R10.3's fallback):
36
+ Gradle has no cheap whole-suite test enumerator, so there is no batch path.
37
+ One uncollectable file is recorded against itself and cannot destroy the set.
38
+
39
+ Config (`tdd.toml`):
40
+
41
+ [project.android-app]
42
+ root = "android-app"
43
+ adapter = "gradle"
44
+ test_paths = ["src/test/"] # Kotlin/Java test sources
45
+ test_command = "./gradlew testDebugUnitTest" # JVM unit tests (fast)
46
+
47
+ Instrumented tests run the same way through a different task; give them a
48
+ longer timeout and a `lease` so only one run touches a device at a time:
49
+
50
+ [project.android-e2e]
51
+ root = "android-app"
52
+ adapter = "gradle"
53
+ test_paths = ["src/androidTest/"]
54
+ test_command = "./gradlew connectedDebugAndroidTest"
55
+ lease = "android-emulator"
56
+ timeout = 1200
57
+ in_close_sweep = false # too slow per cycle
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import re
63
+ import shlex
64
+ import time
65
+ from pathlib import Path
66
+ from xml.etree import ElementTree
67
+
68
+ from .base import (
69
+ FAILED,
70
+ NOT_COLLECTED,
71
+ PASSED,
72
+ Adapter,
73
+ Collection,
74
+ GateResult,
75
+ Verdict,
76
+ clip_failure,
77
+ )
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Patterns applied to gradle console output (only to classify a run that
81
+ # produced no test results — the XML is authoritative for everything else)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ _BUILD_FAILED_RE = re.compile(r"BUILD FAILED")
85
+
86
+ # A --tests filter that matched nothing: the target is not_found, not a failure.
87
+ # > No tests found for given includes: [com.example.FooTest.bar](--tests filter)
88
+ _NO_TESTS_RE = re.compile(r"No tests found for given includes")
89
+
90
+ # Kotlin compiler errors ("e: file:line:col: message") and javac errors.
91
+ _KOTLIN_ERROR_RE = re.compile(r"^e: .*$", re.MULTILINE)
92
+
93
+ # Kotlin/Java source patterns for the per-file grep fallback.
94
+ _PACKAGE_RE = re.compile(r"^\s*package\s+([\w.]+)", re.MULTILINE)
95
+ _CLASS_RE = re.compile(r"\bclass\s+(\w+)")
96
+ # `@Test` (JUnit 4/5) followed by the annotated function/method name. Handles
97
+ # Kotlin `fun name`, Kotlin backtick names (`fun \`does the thing\`()`), and
98
+ # Java `void name()`. Cross-class attribution within a file is not scope-aware
99
+ # — the batch (a real run) is authoritative; this is the fallback.
100
+ _METHOD_RE = re.compile(
101
+ r"@Test\b.*?\b(?:fun|void)\s+(?:`(?P<bt>[^`]+)`|(?P<id>\w+))",
102
+ re.DOTALL,
103
+ )
104
+
105
+
106
+ class GradleAdapter(Adapter):
107
+ name = "gradle"
108
+
109
+ def stub_hint(self) -> str:
110
+ return (
111
+ "a test method body that compiles but fails its assertion — a missing"
112
+ " symbol is a *compile* failure (not_collected), not RED; write a stub"
113
+ ' that compiles first (e.g. `fail("not implemented")`), then observe the'
114
+ " assertion failure"
115
+ )
116
+
117
+ # ------------------------------------------------------------------
118
+ # Core command
119
+ # ------------------------------------------------------------------
120
+
121
+ def _test_cmd(self) -> str:
122
+ return self.project.test_command or "./gradlew test"
123
+
124
+ def _gradle_filter(self, native_id: str) -> str:
125
+ """Translate an `FQCN/method` id into Gradle's `--tests FQCN.method` form.
126
+
127
+ A bare class id (no `/`) is passed through, targeting the whole class.
128
+ """
129
+ if "/" in native_id:
130
+ cls, method = native_id.rsplit("/", 1)
131
+ return f"{cls}.{method}"
132
+ return native_id
133
+
134
+ # ------------------------------------------------------------------
135
+ # Discovery — Kotlin / Java source files
136
+ # ------------------------------------------------------------------
137
+
138
+ def _test_files(self) -> list[Path]:
139
+ found: set[Path] = set()
140
+ for pattern in self.project.test_patterns or []:
141
+ if pattern.endswith("/"):
142
+ base = self.root / pattern
143
+ if base.is_dir():
144
+ for suffix in ("*.kt", "*.java"):
145
+ found.update(p for p in base.rglob(suffix) if p.is_file())
146
+ else:
147
+ found.update(p for p in self.root.glob(pattern) if p.is_file())
148
+ return sorted(found)
149
+
150
+ # ------------------------------------------------------------------
151
+ # Collection — per-file grep (no cheap whole-suite enumerator exists)
152
+ # ------------------------------------------------------------------
153
+
154
+ def collect(self) -> Collection:
155
+ result = Collection()
156
+ for path in self._test_files():
157
+ rel = str(path.relative_to(self.root))
158
+ try:
159
+ result.tests |= self._grep_tests(path)
160
+ except OSError as exc:
161
+ result.failed_files[rel] = str(exc)
162
+ return result
163
+
164
+ def _grep_tests(self, path: Path) -> set[str]:
165
+ """Extract `pkg.Class/method` ids from one Kotlin/Java source file.
166
+
167
+ Finds every `@Test`-annotated method and every declared class, forming
168
+ `pkg.Class/method` ids qualified by the package declaration. Matches
169
+ are file-order, not scope-aware — a file with two classes attributes
170
+ each test method to both. The batch path is authoritative; this is the
171
+ fallback that keeps one unreadable file from destroying the whole set.
172
+ """
173
+ text = path.read_text(encoding="utf-8", errors="replace")
174
+ pkg_match = _PACKAGE_RE.search(text)
175
+ pkg = f"{pkg_match.group(1)}." if pkg_match else ""
176
+ classes = _CLASS_RE.findall(text)
177
+ methods = [m.group("bt") or m.group("id") for m in _METHOD_RE.finditer(text)]
178
+ ids: set[str] = set()
179
+ for class_ in classes:
180
+ for method in methods:
181
+ ids.add(self.qualify(f"{pkg}{class_}/{method}"))
182
+ return ids
183
+
184
+ # The base collect() calls these three; gradle overrides collect() entirely
185
+ # so they are never reached. Trivial bodies avoid surprising a direct caller.
186
+ def _collect_invocations(self):
187
+ return []
188
+
189
+ def _collect_batch(self, command, env):
190
+ return None
191
+
192
+ def _collect_per_file(self, rels, result):
193
+ return result
194
+
195
+ # ------------------------------------------------------------------
196
+ # Doctor gate
197
+ # ------------------------------------------------------------------
198
+
199
+ def collectable(self) -> GateResult:
200
+ """Collection is a source grep — it needs no toolchain, so enumeration is
201
+ always possible. Whether Gradle itself can run is checked by the
202
+ `gradle wrapper present` branch in `tdd doctor` (§8.1)."""
203
+ return GateResult(ok=True)
204
+
205
+ # ------------------------------------------------------------------
206
+ # JUnit XML results
207
+ # ------------------------------------------------------------------
208
+
209
+ def _result_files(self) -> list[Path]:
210
+ """Every JUnit XML file Gradle writes for this project's tests.
211
+
212
+ Covers both JVM unit tasks (`build/test-results/<task>/TEST-*.xml`) and
213
+ Android instrumented tasks (`build/outputs/.../androidTest-results/`),
214
+ across every module under the project root.
215
+ """
216
+ found: set[Path] = set()
217
+ found.update(self.root.glob("**/build/test-results/**/*.xml"))
218
+ found.update(self.root.glob("**/build/outputs/**/androidTest-results/**/*.xml"))
219
+ return sorted(found)
220
+
221
+ def _clear_results(self) -> None:
222
+ """Delete stale JUnit XML before a run.
223
+
224
+ Two problems in one: Gradle leaves the previous run's XML on disk, and
225
+ it skips up-to-date tasks (so no fresh XML is written). Removing the
226
+ result files fixes both — a targeted run is scored only against the XML
227
+ it produced, and deleting a task's output invalidates its up-to-date
228
+ check so the task actually re-runs.
229
+ """
230
+ for path in self._result_files():
231
+ try:
232
+ path.unlink()
233
+ except OSError:
234
+ pass
235
+
236
+ def _parse_results(self) -> tuple[set[str], set[str], dict[str, str]]:
237
+ """Parse every JUnit XML file into `(passed, failed, failures)`.
238
+
239
+ A `<testcase>` with a `<failure>`/`<error>` child failed; with a
240
+ `<skipped>` child it is neither (excluded); otherwise it passed. Ids are
241
+ qualified. `failures` maps a failed id to its message + stack trace.
242
+ """
243
+ passed: set[str] = set()
244
+ failed: set[str] = set()
245
+ failures: dict[str, str] = {}
246
+ for path in self._result_files():
247
+ try:
248
+ tree = ElementTree.parse(path)
249
+ except ElementTree.ParseError:
250
+ continue
251
+ for case in tree.iter("testcase"):
252
+ classname = case.get("classname") or ""
253
+ method = case.get("name") or ""
254
+ raw = f"{classname}/{method}" if classname else method
255
+ qualified = self.qualify(raw)
256
+ failure = case.find("failure")
257
+ if failure is None:
258
+ failure = case.find("error")
259
+ if failure is not None:
260
+ failed.add(qualified)
261
+ failures[qualified] = self._failure_text(failure)
262
+ elif case.find("skipped") is None:
263
+ passed.add(qualified)
264
+ return passed, failed, failures
265
+
266
+ @staticmethod
267
+ def _failure_text(failure: ElementTree.Element) -> str:
268
+ message = failure.get("message") or ""
269
+ body = (failure.text or "").strip()
270
+ combined = "\n".join(part for part in (message, body) if part)
271
+ return clip_failure(combined) if combined else "test failed"
272
+
273
+ def _build_errors(self, combined: str) -> str:
274
+ """Lines that explain why a run produced no tests: compiler errors and
275
+ Gradle's own failure report."""
276
+ wanted = ("e: ", ": error:", "FAILED", "FAILURE:", "What went wrong", "Execution failed")
277
+ lines = [line for line in combined.splitlines() if any(w in line for w in wanted)]
278
+ return clip_failure("\n".join(lines)) if lines else clip_failure(combined)
279
+
280
+ # ------------------------------------------------------------------
281
+ # Suite run
282
+ # ------------------------------------------------------------------
283
+
284
+ def run(self, target: str | None = None) -> Verdict:
285
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
286
+ env = self._suite_env(None)
287
+
288
+ cmd = self._test_cmd()
289
+ if target is not None:
290
+ native = self.strip(target)
291
+ cmd = f"{cmd} --tests {shlex.quote(self._gradle_filter(native))}"
292
+
293
+ started = time.monotonic()
294
+ self._clear_results()
295
+ code, out, err = self._run_suite(cmd, env)
296
+ verdict.duration_ms = int((time.monotonic() - started) * 1000)
297
+ combined = out + "\n" + err
298
+
299
+ passed, failed, failures = self._parse_results()
300
+ verdict.passed = sorted(passed)
301
+ verdict.failed = sorted(failed)
302
+
303
+ # No test ran. Distinguish "filter matched nothing" (not_found) from a
304
+ # compile/config failure (not_collected) from a genuinely empty suite.
305
+ if not passed and not failed:
306
+ if _NO_TESTS_RE.search(combined):
307
+ return verdict # target stays NOT_FOUND; empty full run is clean
308
+ if code != 0 or _BUILD_FAILED_RE.search(combined):
309
+ errors = self._build_errors(combined)
310
+ if target is not None:
311
+ verdict.target_outcome = NOT_COLLECTED
312
+ verdict.target_failure = errors
313
+ else:
314
+ verdict.error = errors
315
+ return verdict
316
+
317
+ if target is None:
318
+ return verdict
319
+
320
+ if target in passed:
321
+ verdict.target_outcome = PASSED
322
+ elif target in failed:
323
+ verdict.target_outcome = FAILED
324
+ verdict.target_failure = failures.get(target, "test failed")
325
+ # else NOT_FOUND (default): the suite ran but never produced this id
326
+
327
+ return verdict