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.
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/CHANGELOG.md +27 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/PKG-INFO +6 -2
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/README.md +5 -1
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/PRD.md +21 -3
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/harness-integration.md +2 -2
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/__init__.py +1 -1
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/vitest_adapter.py +52 -28
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/advance.py +94 -69
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/cli.py +17 -5
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/conftest.py +33 -0
- tdd_cli-0.14.1/tests/fake_vitest.py +121 -0
- tdd_cli-0.14.1/tests/test_advance_adoption.py +278 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_batch_collection.py +35 -10
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_suite_overrides.py +42 -2
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_validation.py +33 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_vitest_adapter.py +66 -0
- tdd_cli-0.14.0/tests/test_advance_adoption.py +0 -129
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/.gitignore +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/LICENSE +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/SECURITY.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/docs/split-runner.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/README.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/bash_hook.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/claude-code-hooks/stop_hook.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/plan.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/README.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-drive/SKILL.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/README.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/pyproject.toml +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/actor.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/__init__.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/base.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/cargo_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/exec_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/pytest_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/agentfs.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/config.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/contract.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/docs.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/envelope.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/fleet.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/gitutil.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/identity.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/leases.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/ledger.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/machine.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/plan_paths.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/render.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/runner.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/snapshot.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/staging.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/src/tddcli/target_lint.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_actor_seams.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_ancillary_files.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_artifact_regeneration.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_baseline_integrity.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_baseline_sanity.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_cargo_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_close_sweep_gates.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_concurrent_advance.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_config_and_staging.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_config_drift.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_contract.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_docs_command.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_doctor_attribution.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_doctor_blockers.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_end_to_end.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_evidence_extraction.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_example_plan.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_exec_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_executor_attribution.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_executor_notes.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_failure_clipping.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_fleet.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_gradle_adapter.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_heartbeat.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_id_normalisation.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_init_detection.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_late_baseline.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_named_leases_and_timeout.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_pin_cycles.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_plan_paths.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_progress.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_project_commands.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_project_env.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_pytest_xdist_group.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_python_env_managers.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_refactor_cycles.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_release_surface.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_run_abandon.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_run_claim.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_runner_import.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_sensitivity_evidence.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_single_project_repo.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_snapshot_and_identity.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_client.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_config.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_doctor.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_identity.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_ledger.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_split_runner.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_stub_hint.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_sudo_actor.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_suite_scope.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_lint.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_target_only_runs.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_time_report.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_timing_visibility.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_tree_hash.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_undeclared_close_gate.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_undeclared_dedup.py +0 -0
- {tdd_cli-0.14.0 → tdd_cli-0.14.1}/tests/test_worker_leases.py +0 -0
- {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.
|
|
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
|
|
537
|
-
|
|
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,
|
|
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 |
|
|
@@ -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
|
|
90
|
-
abs_path = Path(suite_path)
|
|
101
|
+
def _rel(self, suite_path: str) -> str:
|
|
91
102
|
try:
|
|
92
|
-
|
|
103
|
+
return os.path.relpath(Path(suite_path), self.root)
|
|
93
104
|
except ValueError:
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
if
|
|
347
|
+
for entry in listing:
|
|
348
|
+
file, name = entry.get("file"), entry.get("name")
|
|
349
|
+
if not (file and name):
|
|
324
350
|
continue
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
223
|
-
|
|
224
|
-
engine
|
|
225
|
-
cycle
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
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([
|
|
1509
|
-
ledger.event(run["id"], cycle["id"], "target_named_by_agent",
|
|
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":
|
|
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).
|