tdd-cli 0.14.0__tar.gz → 0.14.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 (116) hide show
  1. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/CHANGELOG.md +27 -0
  2. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/PKG-INFO +6 -2
  3. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/README.md +5 -1
  4. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/PRD.md +21 -3
  5. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/harness-integration.md +2 -2
  6. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/__init__.py +1 -1
  7. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/vitest_adapter.py +52 -28
  8. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/advance.py +94 -69
  9. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/cli.py +17 -5
  10. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/conftest.py +33 -0
  11. tdd_cli-0.14.1/tests/fake_vitest.py +121 -0
  12. tdd_cli-0.14.1/tests/test_advance_adoption.py +278 -0
  13. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_batch_collection.py +35 -10
  14. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_suite_overrides.py +42 -2
  15. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_validation.py +33 -0
  16. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_vitest_adapter.py +66 -0
  17. tdd_cli-0.14.0/tests/test_advance_adoption.py +0 -129
  18. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/.gitignore +0 -0
  19. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/LICENSE +0 -0
  20. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/SECURITY.md +0 -0
  21. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/split-runner.md +0 -0
  22. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/README.md +0 -0
  23. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  24. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  25. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/plan.md +0 -0
  26. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/README.md +0 -0
  27. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  28. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/README.md +0 -0
  29. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  30. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/pyproject.toml +0 -0
  31. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/actor.py +0 -0
  32. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/__init__.py +0 -0
  33. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/base.py +0 -0
  34. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/cargo_adapter.py +0 -0
  35. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/exec_adapter.py +0 -0
  36. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
  37. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/pytest_adapter.py +0 -0
  38. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
  39. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/agentfs.py +0 -0
  40. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/config.py +0 -0
  41. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/contract.py +0 -0
  42. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/docs.py +0 -0
  43. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/envelope.py +0 -0
  44. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/fleet.py +0 -0
  45. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/gitutil.py +0 -0
  46. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/identity.py +0 -0
  47. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/leases.py +0 -0
  48. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/ledger.py +0 -0
  49. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/machine.py +0 -0
  50. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/plan_paths.py +0 -0
  51. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/render.py +0 -0
  52. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/runner.py +0 -0
  53. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/snapshot.py +0 -0
  54. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/staging.py +0 -0
  55. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/target_lint.py +0 -0
  56. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_actor_seams.py +0 -0
  57. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_ancillary_files.py +0 -0
  58. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_artifact_regeneration.py +0 -0
  59. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_baseline_integrity.py +0 -0
  60. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_baseline_sanity.py +0 -0
  61. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_cargo_adapter.py +0 -0
  62. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_close_sweep_gates.py +0 -0
  63. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_concurrent_advance.py +0 -0
  64. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_config_and_staging.py +0 -0
  65. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_config_drift.py +0 -0
  66. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_contract.py +0 -0
  67. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_docs_command.py +0 -0
  68. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_doctor_attribution.py +0 -0
  69. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_doctor_blockers.py +0 -0
  70. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_end_to_end.py +0 -0
  71. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_evidence_extraction.py +0 -0
  72. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_example_plan.py +0 -0
  73. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_exec_adapter.py +0 -0
  74. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_executor_attribution.py +0 -0
  75. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_executor_notes.py +0 -0
  76. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_failure_clipping.py +0 -0
  77. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_fleet.py +0 -0
  78. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_gradle_adapter.py +0 -0
  79. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_heartbeat.py +0 -0
  80. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_id_normalisation.py +0 -0
  81. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_init_detection.py +0 -0
  82. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_late_baseline.py +0 -0
  83. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_named_leases_and_timeout.py +0 -0
  84. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_pin_cycles.py +0 -0
  85. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_plan_paths.py +0 -0
  86. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_progress.py +0 -0
  87. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_project_commands.py +0 -0
  88. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_project_env.py +0 -0
  89. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_pytest_xdist_group.py +0 -0
  90. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_python_env_managers.py +0 -0
  91. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_refactor_cycles.py +0 -0
  92. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_release_surface.py +0 -0
  93. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_run_abandon.py +0 -0
  94. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_run_claim.py +0 -0
  95. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_runner_import.py +0 -0
  96. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_sensitivity_evidence.py +0 -0
  97. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_single_project_repo.py +0 -0
  98. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_snapshot_and_identity.py +0 -0
  99. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_client.py +0 -0
  100. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_config.py +0 -0
  101. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_doctor.py +0 -0
  102. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_identity.py +0 -0
  103. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_ledger.py +0 -0
  104. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_runner.py +0 -0
  105. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_stub_hint.py +0 -0
  106. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_sudo_actor.py +0 -0
  107. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_suite_scope.py +0 -0
  108. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_lint.py +0 -0
  109. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_only_runs.py +0 -0
  110. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_time_report.py +0 -0
  111. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_timing_visibility.py +0 -0
  112. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_tree_hash.py +0 -0
  113. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_undeclared_close_gate.py +0 -0
  114. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_undeclared_dedup.py +0 -0
  115. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_worker_leases.py +0 -0
  116. {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,33 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.14.1] - 2026-09-25
10
+
11
+ ### Fixed
12
+
13
+ - **vitest ids from named vitest projects** (#163). Whole-suite collection read `vitest list`
14
+ text, which prefixes `[<project>] ` to every line of a named vitest project and prints paths
15
+ relative to that project's `root`, so it collected ids no run ever reports (and, via the
16
+ per-file loop, a second spelling of every test). It now reads `vitest list --json` and roots
17
+ each id at the test's own file. tdd appends `--json` itself; a collect command must not.
18
+ - **The vitest override-isolation check sees named vitest projects** (#163). `tdd doctor`'s
19
+ check that the default config does not reach an override's files read the same text
20
+ listing, so a `[<project>] ` prefix meant it could never match. It now reads
21
+ `vitest list --json`, and a listing that is not JSON fails the check, naming the command.
22
+ - **Adoption never picks another cycle's test** (#163). When a cycle's declared test was
23
+ missing, `tdd advance` adopted any test new since the run started, including an earlier
24
+ cycle's target, so a premature advance handed cycle 1's test to cycle 2. Every target in
25
+ the run is now excluded, compared by normalised id.
26
+ - **`tdd target` accepts the plan's spelling of a test** (#163). It matched only the exact
27
+ collected id, so it could not name the declared vitest id (`src/a.test.ts > math > adds`
28
+ for a collected `frontend::src/a.test.ts > math adds`). It now qualifies and normalises
29
+ its argument as a plan declaration is, and stores the collected id.
30
+ - **An adopted target the run cannot find is refused** (#163). A test that collection
31
+ listed but no run reported was recorded as the target and came back `not_found` on every
32
+ advance, re-adopting a different test each time. The adoption is now evaluated first; on
33
+ `not_found` the declared target is kept, `adopted_target_not_found` is recorded, and
34
+ `tdd advance` answers `resolve_blocker`.
35
+
9
36
  ## [0.14.0] - 2026-09-24
10
37
 
11
38
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.14.0
3
+ Version: 0.14.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
@@ -170,7 +170,11 @@ being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded fr
170
170
  environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
171
171
  composes with the run command); a vitest override must declare one — `vitest list` knows
172
172
  nothing of the override config, and the mismatch is refused at `tdd run start`, not
173
- mid-cycle.
173
+ mid-cycle. For the whole-suite listing tdd appends `--json` to each vitest collect command
174
+ (the default and every override's) and reads each test's file from vitest's JSON, so a
175
+ collect command must not carry `--json` itself: `vitest list --json --json` prints nothing.
176
+ The override-isolation check in `tdd doctor` reads the same JSON listing, and a listing it
177
+ cannot read fails the check, naming the command it ran.
174
178
 
175
179
  ## Sharing cores between concurrent agents
176
180
 
@@ -142,7 +142,11 @@ being repeated in `test_paths`. `env` values may reference `${VAR}`, expanded fr
142
142
  environment at invocation. For pytest, `collect_command` is optional (`--collect-only`
143
143
  composes with the run command); a vitest override must declare one — `vitest list` knows
144
144
  nothing of the override config, and the mismatch is refused at `tdd run start`, not
145
- mid-cycle.
145
+ mid-cycle. For the whole-suite listing tdd appends `--json` to each vitest collect command
146
+ (the default and every override's) and reads each test's file from vitest's JSON, so a
147
+ collect command must not carry `--json` itself: `vitest list --json --json` prints nothing.
148
+ The override-isolation check in `tdd doctor` reads the same JSON listing, and a listing it
149
+ cannot read fails the check, naming the command it ran.
146
150
 
147
151
  ## Sharing cores between concurrent agents
148
152
 
@@ -533,8 +533,11 @@ one has no move left but to re-run doctor and read the same output again.
533
533
  - **R8.7** A blocked run is not live. H1 must permit the agent to stop, or a blocker traps it.
534
534
  - **R8.8** `human_intervention` events are the input to interventions-per-run, the primary
535
535
  autonomy metric.
536
- - **R8.9** In `AWAITING_TEST`, `advance` resolves the target by diffing `collect()` against cycle
537
- open. When the declared target is `not_found`, the adoption flow runs:
536
+ - **R8.9** In `AWAITING_TEST`, `advance` resolves the target by diffing `collect()` against the
537
+ run's start, and never offers a test that another cycle of the run targets: an earlier cycle's
538
+ test is new since the start too, and adopting it would hand one test to two cycles (#163). The
539
+ exclusion compares `normalise_id` forms (R10.8), because a target may be stored in its declared
540
+ spelling. When the declared target is `not_found`, the adoption flow runs:
538
541
  - **Single new test:** adopted as the target; `declared_test_mismatch` is recorded. The verdict
539
542
  from the run that already happened is evaluated immediately in the same `advance` call — no
540
543
  extra suite run. If the adopted test failed, the RED commit is made and the cycle moves to
@@ -544,6 +547,14 @@ one has no move left but to re-run doctor and read the same output again.
544
547
  above; `declared_test_mismatch` is recorded with the full candidate list.
545
548
  - **Multiple new tests — ambiguous:** recorded as `multiple_new_tests`; `next_action` requires
546
549
  the agent to name the intended target with `tdd target <id>` rather than guessing.
550
+ `tdd target` qualifies its argument the way a plan declaration is qualified and matches it by
551
+ `normalise_id` (R10.8), so the plan's own spelling names the test; it stores the collected id,
552
+ and refuses an id that matches no collected test in any spelling (#163).
553
+ - **Adopted target the run cannot find:** the adoption is evaluated before it is recorded. If
554
+ the run reports the adopted test `not_found`, collection and execution disagree about its id,
555
+ and no test the agent writes can fix that: the declared target is kept, no
556
+ `declared_test_mismatch` is recorded, `adopted_target_not_found` is (`{declared, adopted}`),
557
+ and `next_action` is `resolve_blocker`, asking for a `tooling` blocker (#163).
547
558
  The run that produced `not_found` already executed the new test; its verdict is retrieved from
548
559
  the in-hand `Verdict` objects without an extra suite run.
549
560
 
@@ -861,7 +872,14 @@ Adapter.typecheck(project) -> GateResult
861
872
  file it came from. `tdd doctor` reads the subprocess's **stdout**: `uv` writes
862
873
  environment warnings (e.g. `VIRTUAL_ENV=... does not match ...`) to stderr, while pytest writes
863
874
  the actual collection error (e.g. `ModuleNotFoundError`) to stdout. A check that reads stderr
864
- reports the wrapper's noise and loses the real error underneath it.
875
+ reports the wrapper's noise and loses the real error underneath it. Collection's whole-suite
876
+ vitest listing is `vitest list --json`, not the text listing `collectable()` probes: the text
877
+ prefixes `[<project>] ` to every line of a named vitest project and prints paths relative to
878
+ that project's own `root`, while the JSON carries each test's absolute `file`, which the
879
+ adapter makes root-relative exactly as `run()` does (#163). The vitest override-isolation probe
880
+ reads the same `vitest list --json` and decides reach from each entry's root-relative `file`; a
881
+ listing that is not a JSON array fails the probe and names the command, since passing it would
882
+ disable the check without a signal.
865
883
 
866
884
  ---
867
885
 
@@ -74,11 +74,11 @@ skill that duplicates any of it will fight the ledger.
74
74
  | `write_implementation` | RED is confirmed and committed, or the target still fails at GREEN (only the target ran) | write the minimum code to pass the target, then `tdd advance` |
75
75
  | `fix_regression` | tests outside the cycle are failing at GREEN, or the close sweep / lint / typecheck gates failed | fix them without breaking the target, then `tdd advance` |
76
76
  | `run_sensitivity_check` | a test passed where proof it *can* fail is required | `tdd sensitivity begin`, mutate the behaviour under test, `tdd sensitivity check`, then `tdd sensitivity end` |
77
- | `name_target_test` | several new tests appeared; a cycle covers one behaviour | pick the intended one: `tdd target <id>` |
77
+ | `name_target_test` | several new tests appeared; a cycle covers one behaviour | pick the intended one: `tdd target <id>`, in the collected spelling or the plan's own (`src/a.test.ts > math > adds`); the collected id is stored |
78
78
  | `refactor_or_advance` | GREEN is committed, or the tool simply wants `tdd advance` next | refactor only if the plan calls for it, then `tdd advance` |
79
79
  | `confirm_cycle_applicable` | a judgement point outside the cycle loop: config scaffolded, no active run, doctor passed | review / register / start as the `detail` names; if a cycle no longer applies, `tdd cycle skip --reason "..."` |
80
80
  | `annotate_cycle` | the plan requires judgement annotations before the cycle closes | `tdd annotate --key <k> --value "..."` for each missing key, then `tdd advance` |
81
- | `resolve_blocker` | wedged: three unchanged retries, or failing environment checks | fix the cause, or record it: `tdd blocker --kind <kind> --detail "..."` |
81
+ | `resolve_blocker` | wedged: three unchanged retries, failing environment checks, or an adopted target the run cannot find | fix the cause, or record it: `tdd blocker --kind <kind> --detail "..."` |
82
82
  | `await_baseline` | baseline collection is in flight (collector pid alive) | poll `tdd progress`; **never** re-run `tdd run start` for a live claim |
83
83
  | `complete` *(terminal)* | the run (or command) is finished | render the friction log if the `detail` asks, then stop |
84
84
  | `blocked` *(terminal)* | a typed blocker was recorded | surface the blocker to the human and stop |
@@ -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.14.0"
6
+ __version__ = "0.14.1"
@@ -41,6 +41,18 @@ def _extract_json(raw: str) -> dict | None:
41
41
  return None
42
42
 
43
43
 
44
+ def _json_listing(raw: str) -> list[dict] | None:
45
+ """`vitest list --json`'s array of `{name, file, projectName}`, or None when the
46
+ output is not one."""
47
+ try:
48
+ payload = json.loads(raw)
49
+ except json.JSONDecodeError:
50
+ return None
51
+ if not isinstance(payload, list):
52
+ return None
53
+ return [entry for entry in payload if isinstance(entry, dict)]
54
+
55
+
44
56
  #: The characters a JavaScript regex treats as syntax. Only these are escaped: vitest
45
57
  #: may compile `-t` with the `u` flag, which rejects the identity escapes (`\ `, `\-`)
46
58
  #: that Python's `re.escape` emits.
@@ -86,13 +98,14 @@ class VitestAdapter(Adapter):
86
98
  name = " ".join(part.strip() for part in remainder.split(" > "))
87
99
  return self.qualify(f"{file_part} > {name}")
88
100
 
89
- def _id_for(self, suite_path: str, full_name: str) -> str:
90
- abs_path = Path(suite_path)
101
+ def _rel(self, suite_path: str) -> str:
91
102
  try:
92
- rel = os.path.relpath(abs_path, self.root)
103
+ return os.path.relpath(Path(suite_path), self.root)
93
104
  except ValueError:
94
- rel = suite_path
95
- return self.qualify(f"{rel} > {full_name}")
105
+ return suite_path
106
+
107
+ def _id_for(self, suite_path: str, full_name: str) -> str:
108
+ return self.qualify(f"{self._rel(suite_path)} > {full_name}")
96
109
 
97
110
  def _test_cmd(self) -> str:
98
111
  return self.project.test_command or "npx vitest run"
@@ -267,24 +280,31 @@ class VitestAdapter(Adapter):
267
280
  return GateResult(ok=not chunks, output="\n\n".join(chunks)[:2000])
268
281
 
269
282
  def override_isolation(self) -> GateResult:
270
- """Probes with the default `vitest list` — the same stand-in for the
283
+ """Probes with the default `vitest list --json` — the same stand-in for the
271
284
  default run config that `collectable()` already relies on (`vitest run`
272
285
  has no listing mode, and running the suite just to enumerate it would
273
- execute against whatever the tests need live)."""
286
+ execute against whatever the tests need live). Reach is decided from each
287
+ entry's root-relative `file`; a listing that is not JSON fails the gate
288
+ rather than passing it by reaching nothing."""
274
289
  if not self.project.overrides:
275
290
  return GateResult(ok=True)
291
+ command = f"{self._collect_cmd()} --json"
276
292
  code, out, err = run_command(
277
- self._collect_cmd(), self.root, extra_env=self._suite_env(None), label="doctor"
278
- )
279
- reached = sorted(
280
- {
281
- f
282
- for f in (
283
- line.strip().partition(" > ")[0] for line in out.splitlines() if " > " in line
284
- )
285
- if self.project.override_for(f)
286
- }
293
+ command, self.root, extra_env=self._suite_env(None), label="doctor"
287
294
  )
295
+ listing = _json_listing(out)
296
+ if listing is None:
297
+ return GateResult(
298
+ ok=False,
299
+ output=(
300
+ f"`{command}` did not print a JSON array (exit {code}); tdd reads"
301
+ " vitest's JSON listing to check which files the default config"
302
+ " reaches, so a collect command must not already carry --json: "
303
+ + (err or out).strip()[:600]
304
+ ),
305
+ )
306
+ listed = {self._rel(entry["file"]) for entry in listing if entry.get("file")}
307
+ reached = sorted(f for f in listed if self.project.override_for(f))
288
308
  if not reached:
289
309
  return GateResult(ok=True)
290
310
  return GateResult(
@@ -311,22 +331,26 @@ class VitestAdapter(Adapter):
311
331
  self, command: str, env: dict[str, str] | None
312
332
  ) -> tuple[set[str], set[str]] | None:
313
333
  """Unlike `_parse_list_output`, which pins every id to the one file it was
314
- given, a whole-suite listing must read the file from each line — the same
315
- `file > describe > name` shape, one file per line rather than one per run."""
316
- code, out, err = run_command(command, self.root, extra_env=env, label="collect")
334
+ given, a whole-suite listing must read each test's file. The text listing
335
+ cannot supply it: a named vitest project prefixes `[<name>] ` to every line,
336
+ and a project with its own `root` prints paths relative to that root. The
337
+ JSON listing carries the absolute `file`, rooted here exactly as `run()`
338
+ roots its report. Anything that is not a JSON array is left to the loop."""
339
+ code, out, err = run_command(f"{command} --json", self.root, extra_env=env, label="collect")
317
340
  if code != 0:
318
341
  return None
342
+ listing = _json_listing(out)
343
+ if listing is None:
344
+ return None
319
345
  tests: set[str] = set()
320
346
  files: set[str] = set()
321
- for line in out.splitlines():
322
- line = line.strip()
323
- if " > " not in line:
347
+ for entry in listing:
348
+ file, name = entry.get("file"), entry.get("name")
349
+ if not (file and name):
324
350
  continue
325
- rel, _, remainder = line.partition(" > ")
326
- full_name = " ".join(part.strip() for part in remainder.split(" > "))
327
- if rel and full_name:
328
- tests.add(self.qualify(f"{rel.strip()} > {full_name}"))
329
- files.add(rel.strip())
351
+ full_name = " ".join(part.strip() for part in name.split(" > "))
352
+ tests.add(self._id_for(file, full_name))
353
+ files.add(self._rel(file))
330
354
  # An empty result needs no special case: it accounts for no files, so every
331
355
  # file falls to the loop exactly as a failure would.
332
356
  return tests, files
@@ -58,17 +58,30 @@ def _last_invocation_hash(engine: Engine, cycle) -> str | None:
58
58
 
59
59
 
60
60
  def _adopt_target(engine: Engine, cycle, missing: list[str]) -> tuple[list[str], list[str]]:
61
- """R8.9 — resolve the target from newly collected tests when the declared id misses."""
61
+ """R8.9 — resolve the target from newly collected tests when the declared id misses.
62
+
63
+ A test another cycle of the run already targets is never a candidate: it is new
64
+ since the run started too. Targets may be stored in the declared spelling, so the
65
+ exclusion compares normalised ids (#163)."""
62
66
  projects = json.loads(cycle["projects"])
63
67
  at_start = engine.ledger.collection(engine.run["id"])
64
68
  known = {t for tests in at_start.values() for t in tests}
65
- existing_targets = set(json.loads(cycle["target_tests"]))
69
+ run_targets = {
70
+ t
71
+ for row in engine.ledger.all(
72
+ "SELECT target_tests FROM cycle WHERE run_id = ?", (engine.run["id"],)
73
+ )
74
+ for t in json.loads(row["target_tests"])
75
+ }
66
76
 
67
77
  new_tests: list[str] = []
68
78
  for name in projects:
69
79
  adapter = adapters.build(engine.config.project(name), engine.worktree)
80
+ claimed = {adapter.normalise_id(t) for t in run_targets}
70
81
  current = adapter.collect()
71
- new_tests.extend(sorted(current.tests - known - existing_targets))
82
+ new_tests.extend(
83
+ sorted(t for t in current.tests - known if adapter.normalise_id(t) not in claimed)
84
+ )
72
85
  return new_tests, missing
73
86
 
74
87
 
@@ -200,6 +213,60 @@ def _evaluate_adopted(
200
213
  return outcomes.get(adopted), failure
201
214
 
202
215
 
216
+ def _adopt(
217
+ engine: Engine,
218
+ cycle,
219
+ projects: list[str],
220
+ targets: list[str],
221
+ missing: list[str],
222
+ adopted: str,
223
+ detail: dict,
224
+ verdicts,
225
+ retried: bool,
226
+ ):
227
+ """R8.9's one adoption path, whichever branch chose `adopted`: evaluate the new
228
+ target, then record the mismatch and retarget the cycle. Returns the reply when
229
+ the target is refused or has no outcome yet, else `(cycle, targets, outcome,
230
+ failure)`.
231
+
232
+ The evaluation comes first because a test that collection lists but the run
233
+ cannot find would otherwise become the target and come back `not_found` on every
234
+ advance (#163): collection and execution disagree about its id, which no test
235
+ the agent writes can fix."""
236
+ kept = [t for t in targets if t not in missing]
237
+ outcome, failure = _evaluate_adopted(engine, cycle, projects, kept, adopted, verdicts, retried)
238
+ if outcome == NOT_FOUND:
239
+ engine.ledger.event(
240
+ engine.run["id"],
241
+ cycle["id"],
242
+ "adopted_target_not_found",
243
+ json.dumps({"declared": missing, "adopted": [adopted]}),
244
+ )
245
+ return _reply(
246
+ engine,
247
+ cycle,
248
+ Verb.RESOLVE_BLOCKER,
249
+ f"{adopted} is collected but the run cannot find it: collection and execution"
250
+ " disagree about its id. The declared target is unchanged. Record it:"
251
+ " `tdd blocker --kind tooling --detail '...'`.",
252
+ adopted=[adopted],
253
+ declared=missing,
254
+ )
255
+ engine.ledger.event(engine.run["id"], cycle["id"], "declared_test_mismatch", json.dumps(detail))
256
+ engine.ledger.update("cycle", cycle["id"], target_tests=json.dumps(kept + [adopted]))
257
+ cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
258
+ if outcome is None:
259
+ return _reply(
260
+ engine,
261
+ cycle,
262
+ Verb.REFACTOR_OR_ADVANCE,
263
+ f"Adopted {adopted} as the target (declared {missing[0]} was not"
264
+ " collected). Run `tdd advance` again to evaluate it.",
265
+ adopted=[adopted],
266
+ )
267
+ return cycle, kept + [adopted], outcome, failure
268
+
269
+
203
270
  # -- handlers ------------------------------------------------------------
204
271
 
205
272
 
@@ -219,67 +286,23 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
219
286
  missing = [t for t, o in outcomes.items() if o == NOT_FOUND]
220
287
  if missing:
221
288
  candidates, _ = _adopt_target(engine, cycle, missing)
222
- if len(candidates) == 1:
223
- engine.ledger.event(
224
- engine.run["id"],
225
- cycle["id"],
226
- "declared_test_mismatch",
227
- json.dumps({"declared": missing, "adopted": candidates}),
228
- )
229
- kept = [t for t in targets if t not in missing]
230
- engine.ledger.update("cycle", cycle["id"], target_tests=json.dumps(kept + candidates))
231
- cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
232
- adopted_outcome, adopted_failure = _evaluate_adopted(
233
- engine, cycle, projects, kept, candidates[0], verdicts, retried
289
+ if not candidates:
290
+ return _reply(
291
+ engine,
292
+ cycle,
293
+ Verb.WRITE_TEST,
294
+ f"Target {missing[0]} was not collected and no new test was found."
295
+ " Write the failing test.",
296
+ missing=missing,
234
297
  )
235
- if adopted_outcome is None:
236
- return _reply(
237
- engine,
238
- cycle,
239
- Verb.REFACTOR_OR_ADVANCE,
240
- f"Adopted {candidates[0]} as the target (declared {missing[0]} was not"
241
- " collected). Run `tdd advance` again to evaluate it.",
242
- adopted=candidates,
243
- )
244
- targets = kept + candidates
245
- outcomes = {candidates[0]: adopted_outcome}
246
- failure = adopted_failure or failure
247
- others = [t for t in others if t != candidates[0]]
248
- elif len(candidates) > 1:
298
+ if len(candidates) == 1:
299
+ adopted = candidates[0]
300
+ detail = {"declared": missing, "adopted": candidates}
301
+ else:
249
302
  owner = missing[0].split("::", 1)[0]
250
303
  adapter = adapters.build(engine.config.project(owner), engine.worktree)
251
304
  resolved = _disambiguate(candidates, missing[0], adapter)
252
- if resolved is not None:
253
- engine.ledger.event(
254
- engine.run["id"],
255
- cycle["id"],
256
- "declared_test_mismatch",
257
- json.dumps(
258
- {"declared": missing, "adopted": [resolved], "all_candidates": candidates}
259
- ),
260
- )
261
- kept = [t for t in targets if t not in missing]
262
- engine.ledger.update(
263
- "cycle", cycle["id"], target_tests=json.dumps(kept + [resolved])
264
- )
265
- cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
266
- adopted_outcome, adopted_failure = _evaluate_adopted(
267
- engine, cycle, projects, kept, resolved, verdicts, retried
268
- )
269
- if adopted_outcome is None:
270
- return _reply(
271
- engine,
272
- cycle,
273
- Verb.REFACTOR_OR_ADVANCE,
274
- f"Adopted {resolved} as the target (declared {missing[0]} was not"
275
- " collected). Run `tdd advance` again to evaluate it.",
276
- adopted=[resolved],
277
- )
278
- targets = kept + [resolved]
279
- outcomes = {resolved: adopted_outcome}
280
- failure = adopted_failure or failure
281
- others = [t for t in others if t != resolved]
282
- else:
305
+ if resolved is None:
283
306
  engine.ledger.event(
284
307
  engine.run["id"],
285
308
  cycle["id"],
@@ -294,15 +317,17 @@ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool)
294
317
  " intended target with `tdd target <id>`.",
295
318
  candidates=candidates,
296
319
  )
297
- else:
298
- return _reply(
299
- engine,
300
- cycle,
301
- Verb.WRITE_TEST,
302
- f"Target {missing[0]} was not collected and no new test was found."
303
- " Write the failing test.",
304
- missing=missing,
305
- )
320
+ adopted = resolved
321
+ detail = {"declared": missing, "adopted": [resolved], "all_candidates": candidates}
322
+ adoption = _adopt(
323
+ engine, cycle, projects, targets, missing, adopted, detail, verdicts, retried
324
+ )
325
+ if isinstance(adoption, Envelope):
326
+ return adoption
327
+ cycle, targets, adopted_outcome, adopted_failure = adoption
328
+ outcomes = {adopted: adopted_outcome}
329
+ failure = adopted_failure or failure
330
+ others = [t for t in others if t != adopted]
306
331
 
307
332
  not_collected = [t for t, o in outcomes.items() if o == NOT_COLLECTED]
308
333
  if not_collected:
@@ -1493,11 +1493,23 @@ def cmd_target(args) -> Envelope:
1493
1493
  # grounded in observed execution (#15): recording free text deferred a typo —
1494
1494
  # or a speculative `tdd target env` — to the next suite run, where it
1495
1495
  # surfaced as `not_found` against a test that never existed.
1496
+ #
1497
+ # The argument is qualified and matched the way a plan declaration is (#163), so
1498
+ # the plan's own spelling names the test it collected as; the collected id is what
1499
+ # is stored, because outcome lookups compare against verdicts verbatim.
1500
+ declared = _engine(worktree, cfg, ledger, run).declared_for(cycle["ordinal"])
1501
+ wanted = Engine._qualify(declared, args.test) if declared else args.test
1496
1502
  known: set[str] = set()
1503
+ target: str | None = None
1497
1504
  for name in json.loads(cycle["projects"]):
1498
1505
  adapter = adapters.build(cfg.project(name), worktree)
1499
- known |= adapter.collect().tests
1500
- if args.test not in known:
1506
+ collected = adapter.collect().tests
1507
+ known |= collected
1508
+ norm = adapter.normalise_id(wanted)
1509
+ target = target or next(
1510
+ (t for t in sorted(collected) if adapter.normalise_id(t) == norm), None
1511
+ )
1512
+ if target is None:
1501
1513
  close = difflib.get_close_matches(args.test, sorted(known), n=3, cutoff=0.6)
1502
1514
  hint = f" Closest collected ids: {', '.join(close)}." if close else ""
1503
1515
  return failure(
@@ -1505,11 +1517,11 @@ def cmd_target(args) -> Envelope:
1505
1517
  f" the target was not changed.{hint}"
1506
1518
  )
1507
1519
 
1508
- ledger.update("cycle", cycle["id"], target_tests=json.dumps([args.test]))
1509
- ledger.event(run["id"], cycle["id"], "target_named_by_agent", args.test)
1520
+ ledger.update("cycle", cycle["id"], target_tests=json.dumps([target]))
1521
+ ledger.event(run["id"], cycle["id"], "target_named_by_agent", target)
1510
1522
  return Envelope(
1511
1523
  run={"id": run["id"], "cycle": cycle["ordinal"]},
1512
- result={"target": args.test},
1524
+ result={"target": target},
1513
1525
  next_action=NextAction(Verb.REFACTOR_OR_ADVANCE, "Target set. `tdd advance`."),
1514
1526
  )
1515
1527
 
@@ -1,8 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import pwd
6
+ import shlex
5
7
  import subprocess
8
+ import sys
6
9
  from pathlib import Path
7
10
  from types import SimpleNamespace
8
11
 
@@ -162,6 +165,36 @@ def repo(tmp_path, ledger_home):
162
165
  return root
163
166
 
164
167
 
168
+ FAKE_VITEST = Path(__file__).parent / "fake_vitest.py"
169
+
170
+
171
+ @pytest.fixture
172
+ def repo_vitest(tmp_path, ledger_home):
173
+ """A git repo with one vitest project, `frontend`, driven by `fake_vitest.py`:
174
+ a named vitest project (`[core]`) whose runner is a Python script."""
175
+ root = tmp_path / "workspace"
176
+ (root / "frontend" / "src").mkdir(parents=True)
177
+ fake = f"{shlex.quote(sys.executable)} {shlex.quote(str(FAKE_VITEST))}"
178
+ (root / "tdd.toml").write_text(
179
+ "[project.frontend]\n"
180
+ 'root = "frontend"\n'
181
+ 'adapter = "vitest"\n'
182
+ 'test_paths = ["**/*.test.ts"]\n'
183
+ f"test_command = {json.dumps(fake + ' run')}\n"
184
+ f"collect_command = {json.dumps(fake + ' list')}\n"
185
+ "lint = []\n"
186
+ "typecheck = []\n"
187
+ )
188
+ (root / "frontend" / "src" / "smoke.test.ts").write_text("case: smoke > works = pass\n")
189
+
190
+ subprocess.run(["git", "init", "-q", str(root)], check=True)
191
+ git(root, "config", "user.email", "test@example.com")
192
+ git(root, "config", "user.name", "Test")
193
+ git(root, "add", "-A")
194
+ git(root, "commit", "-q", "-m", "initial")
195
+ return root
196
+
197
+
165
198
  @pytest.fixture
166
199
  def repo_multi(repo, ledger_home):
167
200
  """`repo` plus an empty vitest `frontend` (a `package.json`, no test files).