tdd-cli 0.11.0__tar.gz → 0.12.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 (96) hide show
  1. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/CHANGELOG.md +37 -0
  2. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/PKG-INFO +1 -1
  3. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/docs/PRD.md +21 -2
  4. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/base.py +2 -3
  6. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/cargo_adapter.py +137 -12
  7. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/advance.py +1 -1
  8. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/ledger.py +8 -1
  9. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/machine.py +51 -15
  10. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_cargo_adapter.py +188 -1
  11. tdd_cli-0.12.1/tests/test_close_sweep_gates.py +361 -0
  12. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_release_surface.py +19 -0
  13. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/.gitignore +0 -0
  14. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/LICENSE +0 -0
  15. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/README.md +0 -0
  16. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/SECURITY.md +0 -0
  17. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/docs/harness-integration.md +0 -0
  18. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/claude-code-hooks/README.md +0 -0
  19. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  20. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  21. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/plan.md +0 -0
  22. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/skills/tdd-drive/README.md +0 -0
  23. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  24. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/skills/tdd-handoff/README.md +0 -0
  25. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  26. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/pyproject.toml +0 -0
  27. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/__init__.py +0 -0
  28. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/exec_adapter.py +0 -0
  29. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
  30. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/pytest_adapter.py +0 -0
  31. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/vitest_adapter.py +0 -0
  32. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
  33. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/cli.py +0 -0
  34. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/config.py +0 -0
  35. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/contract.py +0 -0
  36. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/docs.py +0 -0
  37. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/envelope.py +0 -0
  38. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/fleet.py +0 -0
  39. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/gitutil.py +0 -0
  40. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/identity.py +0 -0
  41. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/leases.py +0 -0
  42. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/plan_paths.py +0 -0
  43. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/render.py +0 -0
  44. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/snapshot.py +0 -0
  45. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/staging.py +0 -0
  46. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/src/tddcli/target_lint.py +0 -0
  47. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/conftest.py +0 -0
  48. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_advance_adoption.py +0 -0
  49. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_ancillary_files.py +0 -0
  50. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_artifact_regeneration.py +0 -0
  51. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_baseline_integrity.py +0 -0
  52. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_baseline_sanity.py +0 -0
  53. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_batch_collection.py +0 -0
  54. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_concurrent_advance.py +0 -0
  55. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_config_and_staging.py +0 -0
  56. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_config_drift.py +0 -0
  57. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_contract.py +0 -0
  58. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_docs_command.py +0 -0
  59. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_doctor_attribution.py +0 -0
  60. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_doctor_blockers.py +0 -0
  61. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_end_to_end.py +0 -0
  62. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_evidence_extraction.py +0 -0
  63. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_example_plan.py +0 -0
  64. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_exec_adapter.py +0 -0
  65. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_executor_attribution.py +0 -0
  66. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_executor_notes.py +0 -0
  67. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_failure_clipping.py +0 -0
  68. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_fleet.py +0 -0
  69. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_gradle_adapter.py +0 -0
  70. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_heartbeat.py +0 -0
  71. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_id_normalisation.py +0 -0
  72. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_init_detection.py +0 -0
  73. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_late_baseline.py +0 -0
  74. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_named_leases_and_timeout.py +0 -0
  75. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_pin_cycles.py +0 -0
  76. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_plan_paths.py +0 -0
  77. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_progress.py +0 -0
  78. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_project_commands.py +0 -0
  79. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_project_env.py +0 -0
  80. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_pytest_xdist_group.py +0 -0
  81. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_python_env_managers.py +0 -0
  82. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_refactor_cycles.py +0 -0
  83. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_run_claim.py +0 -0
  84. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_sensitivity_evidence.py +0 -0
  85. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_single_project_repo.py +0 -0
  86. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_snapshot_and_identity.py +0 -0
  87. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_stub_hint.py +0 -0
  88. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_suite_overrides.py +0 -0
  89. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_target_lint.py +0 -0
  90. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_target_validation.py +0 -0
  91. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_timing_visibility.py +0 -0
  92. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_undeclared_close_gate.py +0 -0
  93. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_undeclared_dedup.py +0 -0
  94. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_vitest_adapter.py +0 -0
  95. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_worker_leases.py +0 -0
  96. {tdd_cli-0.11.0 → tdd_cli-0.12.1}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,43 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.1] - 2026-09-17
10
+
11
+ ### Fixed
12
+
13
+ - **cargo: a test target whose name differs from its file's stem** (#134): the adapter derived the
14
+ target from the `Running tests/<file>.rs` header's path, which is only the target's name under
15
+ cargo's default of one binary per `tests/*.rs`. A crate that sets `autotests = false` and declares
16
+ its own `[[test]]` — many files compiled into one binary named something else — was unreachable:
17
+ collection ran `--test <stem>` and cargo answered ``no test target named `<stem>` in default-run
18
+ packages``, so every cycle target came back `not_collected`, whatever id the plan declared. The
19
+ target is now read from the artifact the header names (`…/deps/bridge_tests-<hash>`), which is
20
+ also the only thing distinguishing two crates in a workspace whose test files are both
21
+ `tests/main.rs`. The crate manifests supply the mapping back: target → the file it is built from
22
+ for `target_path` and the collection file set, and the declared spelling for `--test`, since cargo
23
+ writes a target's `-` as `_` in the artifact. Manifests are located by walking up from the declared
24
+ `test_paths`, not from `_test_files()`, which does not expand a wildcard directory pattern such as
25
+ `crates/*/tests/`. Under the default layout target and stem still agree and nothing changes.
26
+
27
+ ## [0.12.0] - 2026-09-17
28
+
29
+ ### Changed
30
+
31
+ - **Close sweep runs all gates before any suite** (#129): lint and typecheck now run for every
32
+ project in the sweep set before any project's suite executes. A failing gate short-circuits the
33
+ sweep immediately — no suite runs, and `next_action` returns `fix_regression` with the failing
34
+ gate entry. Previously each project ran its suite first and only then checked its gates.
35
+ - **`Adapter._gate` stops at the first failing command** (#129): the first non-zero exit ends the
36
+ gate; subsequent commands in the same gate list are not executed.
37
+ - **Gate memoisation** (#129): a gate whose project tree hash is unchanged since it last passed
38
+ within the same run is skipped (no command executed) and recorded with `skipped = 1` in
39
+ `gate_result`. A failed gate or a changed tree always re-runs.
40
+ - **`fix_regression` detail wording** (#129): the reply for a gate failure now reads
41
+ `"Close sweep stopped before the suite: lint/typecheck gates failed."` (was `"Close sweep is
42
+ green but lint/typecheck gates failed."`).
43
+ - **Schema v11** (#129): `gate_result` gains `tree_hash TEXT` and
44
+ `skipped INTEGER NOT NULL DEFAULT 0`. Existing ledgers are migrated on open via `MIGRATIONS[10]`.
45
+
9
46
  ## [0.11.0] - 2026-09-16
10
47
 
11
48
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.11.0
3
+ Version: 0.12.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
@@ -195,6 +195,18 @@ Typed: `regression`, `target_unfixable`, `bad_red`, `plan_defect`, `tooling`, `c
195
195
  Without it the only available label is `regression`, which records a defect the run introduced —
196
196
  so the agent must either misreport its own work or leave the failure unfiled.
197
197
 
198
+ ### GateResult
199
+ One execution (or memo-skip) of one project's lint or typecheck gate during a close sweep.
200
+
201
+ | Field | Notes |
202
+ |---|---|
203
+ | `id`, `run_id`, `cycle_id`, `project`, `kind` | `kind` is `lint` or `typecheck` |
204
+ | `ok` | `1` = passed (or skipped), `0` = failed |
205
+ | `output` | truncated stdout/stderr of the failing command; empty when `ok = 1` |
206
+ | `tree_hash` | hash of the project tree at the time the gate ran or was skipped (schema v11) |
207
+ | `skipped` | `1` if the gate was memo-skipped (tree unchanged, gate previously passed within this run); `0` otherwise (schema v11) |
208
+ | `at` | |
209
+
198
210
  ---
199
211
 
200
212
  ## 6. State machine
@@ -561,7 +573,9 @@ For the passed-on-arrival case, which occurred in 4 of 8 executed cycles in the
561
573
  - **R9.2** On the transition out of `AWAITING_REFACTOR`, the close sweep runs: the cycle's own
562
574
  projects, plus every project **downstream of an artifact the cycle modified** per the
563
575
  `consumed_by` edges in §7.1, plus lint and typecheck for each. Projects with
564
- `in_close_sweep = false` are always excluded.
576
+ `in_close_sweep = false` are always excluded. The close sweep runs lint and typecheck for
577
+ **every project in scope before any suite**; if any gate fails, the sweep returns immediately
578
+ without running any suite.
565
579
  - **R9.3** A **full sweep** — every registered project, lint and typecheck, including
566
580
  `in_close_sweep = false` suites — runs once at plan completion.
567
581
  - **R9.4** Rationale: a per-cycle sweep of all four projects with `mypy` and `tsc` costs minutes,
@@ -664,7 +678,12 @@ For the passed-on-arrival case, which occurred in 4 of 8 executed cycles in the
664
678
 
665
679
  ### 9.4 Lint, typecheck and artifacts
666
680
  - **R9.11** Lint and typecheck results are part of the close-sweep verdict, surfaced alongside
667
- test failures rather than discovered later at commit time.
681
+ test failures rather than discovered later at commit time. The gate pass stops at the **first
682
+ failing gate**; `Adapter._gate` stops at the **first failing command** within a gate. A gate
683
+ whose project tree hash is unchanged since that gate last passed within the same run is
684
+ **skipped** (no command is executed) and recorded with `skipped = 1` in `gate_result`. Limit:
685
+ gate configuration that lives outside the project's declared roots (e.g. a top-level
686
+ `pyproject.toml`) does not affect the tree hash and therefore does not invalidate the memo.
668
687
  - **R9.12** Before a run starts and at every cycle close, declared artifacts are checked for
669
688
  staleness against their producer. When the tool cannot resolve staleness itself (no `regenerate`
670
689
  command, or the regenerate hook produced no commit), it emits `stale_artifact` and blocks the
@@ -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.11.0"
6
+ __version__ = "0.12.1"
@@ -302,9 +302,8 @@ class Adapter:
302
302
  return self._gate(self.project.typecheck)
303
303
 
304
304
  def _gate(self, commands: list[str]) -> GateResult:
305
- chunks = []
306
305
  for cmd in commands:
307
306
  code, out, err = run_command(cmd, self.root, label="gate")
308
307
  if code != 0:
309
- chunks.append(f"$ {cmd}\n{out}\n{err}".strip())
310
- return GateResult(ok=not chunks, output="\n\n".join(chunks)[:4000])
308
+ return GateResult(ok=False, output=f"$ {cmd}\n{out}\n{err}".strip()[:4000])
309
+ return GateResult(ok=True)
@@ -8,6 +8,16 @@ binary name for a file under `tests/` (`tests/roundtrip.rs` → `roundtrip`); th
8
8
  lib::layout::tests::wraps_short
9
9
  roundtrip::renders_cover_only
10
10
 
11
+ The target is the *binary* name, which is only the file stem under cargo's
12
+ default of one binary per `tests/*.rs`. A crate that sets `autotests = false`
13
+ and declares its own `[[test]]` compiles many files into one binary named
14
+ something else entirely — `tests/main.rs` with a `mod` per file, built as
15
+ `bridge_tests` — and then ids read `bridge_tests::adapter_config::parses`, the
16
+ file is `tests/main.rs`, and `--test bridge_tests` is what runs it. The manifest
17
+ is the only thing relating the two, so it is read; the `Running` header's
18
+ artifact (`…/deps/bridge_tests-<hash>`) is what names the target per block,
19
+ since the header's path is `tests/main.rs` in every crate of a workspace.
20
+
11
21
  A targeted run composes to cargo's own selectors without translation —
12
22
  `cargo test --lib -- --exact layout::tests::wraps_short` or
13
23
  `cargo test --test roundtrip -- --exact renders_cover_only` — and nothing else
@@ -49,6 +59,7 @@ from __future__ import annotations
49
59
  import re
50
60
  import shlex
51
61
  import time
62
+ import tomllib
52
63
  from pathlib import Path
53
64
 
54
65
  from .base import (
@@ -63,7 +74,14 @@ from .base import (
63
74
 
64
75
  # ` Running unittests src/lib.rs (target/debug/deps/x-abc)` → target "lib"
65
76
  # ` Running tests/roundtrip.rs (target/debug/deps/roundtrip-abc)` → "roundtrip"
66
- _RUNNING_RE = re.compile(r"^\s*Running (?:unittests (\S+)|(\S+)) \(", re.MULTILINE)
77
+ # The artifact in parentheses is captured too: it is the only part of the header
78
+ # that names the *target*, which a crate may name differently from its file
79
+ # (`tests/main.rs` compiled as `bridge_tests`), and the only part that tells two
80
+ # crates' identically-pathed test files apart in a workspace.
81
+ _RUNNING_RE = re.compile(r"^\s*Running (?:unittests (\S+)|(\S+)) \(([^)]*)\)", re.MULTILINE)
82
+ # `bridge_tests-541f4f38a741dff0` → `bridge_tests` (cargo abbreviates the hash
83
+ # in some outputs, so its length is not pinned)
84
+ _ARTIFACT_HASH_RE = re.compile(r"-[0-9a-f]+$")
67
85
  _DOCTEST_RE = re.compile(r"^\s*Doc-tests ", re.MULTILINE)
68
86
  # `test layout::tests::wraps_short ... ok` / `... FAILED` / `... ignored`
69
87
  _TEST_LINE_RE = re.compile(r"^test (\S+) \.\.\. (ok|FAILED|ignored)\s*$", re.MULTILINE)
@@ -76,18 +94,36 @@ _COULD_NOT_COMPILE_RE = re.compile(r"could not compile")
76
94
  _NO_LIB_RE = re.compile(r"no library targets found")
77
95
 
78
96
 
97
+ def _artifact_target(artifact: str) -> str | None:
98
+ """Target name from a `target/debug/deps/<name>-<hash>` artifact path.
99
+
100
+ cargo writes `-` in a target name as `_` here, so the result is the mangled
101
+ spelling; `CargoAdapter._demangle_target` maps it back to the declared one.
102
+ """
103
+ stem = Path(artifact).name
104
+ stripped = _ARTIFACT_HASH_RE.sub("", stem)
105
+ return stripped or None
106
+
107
+
79
108
  def _target_of_header(match: re.Match) -> str | None:
80
- unit, path = match.group(1), match.group(2)
109
+ unit, path, artifact = match.group(1), match.group(2), match.group(3)
81
110
  if unit is not None:
82
111
  return "lib"
83
112
  if path.startswith("tests/") and path.endswith(".rs"):
84
- return Path(path).stem
113
+ # The artifact names the target; the path only names the file it was
114
+ # built from, and those differ whenever a crate declares `[[test]]`
115
+ # with a `name` of its own.
116
+ return _artifact_target(artifact) or Path(path).stem
85
117
  return None # examples/benches: not test targets
86
118
 
87
119
 
88
120
  class CargoAdapter(Adapter):
89
121
  name = "cargo"
90
122
 
123
+ def __init__(self, *args, **kwargs):
124
+ super().__init__(*args, **kwargs)
125
+ self._targets_cache: dict[str, str] | None = None
126
+
91
127
  def stub_hint(self) -> str:
92
128
  return (
93
129
  "a compiling stub — a function whose body is `todo!()` — so the test"
@@ -99,7 +135,10 @@ class CargoAdapter(Adapter):
99
135
  if "::" not in native:
100
136
  return (
101
137
  f"cargo target ids must be '<target>::<path>' (got {native!r}); expected"
102
- " shape: lib::module::tests::name or <tests-file-stem>::name"
138
+ " shape: lib::module::tests::name or <test-binary>::name — the"
139
+ " binary is the tests file's stem unless the crate declares"
140
+ " [[test]] name, and `cargo test --tests -- --list` prints the"
141
+ " spelling to use"
103
142
  )
104
143
  return None
105
144
 
@@ -107,7 +146,90 @@ class CargoAdapter(Adapter):
107
146
  target, _, _ = native.partition("::")
108
147
  if target == "lib":
109
148
  return None
110
- return f"tests/{target}.rs"
149
+ declared = self._declared_target(target)
150
+ path = self._manifest_targets().get(declared)
151
+ # No `[[test]]` entry: cargo's default, one binary per file, named for it.
152
+ return path or f"tests/{target}.rs"
153
+
154
+ # ------------------------------------------------------------------
155
+ # Target names vs. their files
156
+ #
157
+ # By default a target is named after its file (`tests/roundtrip.rs` →
158
+ # `roundtrip`), and the two are interchangeable. A crate that sets
159
+ # `autotests = false` and declares its own `[[test]]` breaks that: several
160
+ # files compile into one binary whose `name` is unrelated to any of them
161
+ # (`tests/main.rs` → `bridge_tests`). Only the manifest relates the two.
162
+ # ------------------------------------------------------------------
163
+
164
+ def _manifest_targets(self) -> dict[str, str]:
165
+ """`{declared [[test]] name: path relative to the project root}`.
166
+
167
+ Read from the manifest owning each file under `test_paths`, so a
168
+ workspace contributes one entry per crate that declares a test target.
169
+ """
170
+ if self._targets_cache is None:
171
+ targets: dict[str, str] = {}
172
+ for manifest in self._manifests():
173
+ try:
174
+ raw = tomllib.loads(manifest.read_text())
175
+ except (OSError, tomllib.TOMLDecodeError):
176
+ continue
177
+ for entry in raw.get("test", []) or []:
178
+ name, rel = entry.get("name"), entry.get("path")
179
+ if not name or not rel:
180
+ continue
181
+ try:
182
+ full = (manifest.parent / rel).resolve().relative_to(self.root.resolve())
183
+ except ValueError:
184
+ continue
185
+ targets[name] = str(full)
186
+ self._targets_cache = targets
187
+ return self._targets_cache
188
+
189
+ def _manifests(self) -> set[Path]:
190
+ """The nearest Cargo.toml above each declared test directory.
191
+
192
+ Walked from `test_paths` rather than `_test_files()`, which does not
193
+ expand a wildcard directory pattern (`crates/*/tests/`) — the very shape
194
+ a workspace of crates uses.
195
+ """
196
+ found: set[Path] = set()
197
+ root = self.root.resolve()
198
+ for pattern in self.project.test_patterns or []:
199
+ for match in self.root.glob(pattern.rstrip("/") or "."):
200
+ start = match.resolve()
201
+ # Bounded by construction: a glob of the root cannot escape it,
202
+ # so the ascent always reaches root and stops there.
203
+ for here in [start, *start.parents]:
204
+ candidate = here / "Cargo.toml"
205
+ if candidate.is_file():
206
+ found.add(candidate)
207
+ break
208
+ if here == root:
209
+ break
210
+ return found
211
+
212
+ def _declared_target(self, target: str) -> str:
213
+ """The `[[test]] name` behind a collected target name.
214
+
215
+ cargo writes a target's `-` as `_` in the artifact filename the id is
216
+ read from, so `my-tests` is collected as `my_tests`; `--test` wants the
217
+ declared spelling back.
218
+ """
219
+ names = self._manifest_targets()
220
+ if target in names:
221
+ return target
222
+ for declared in names:
223
+ if declared.replace("-", "_") == target:
224
+ return declared
225
+ return target
226
+
227
+ def _target_of_file(self, rel: str) -> str:
228
+ """The target a test file belongs to; its stem under cargo's default."""
229
+ for declared, path in self._manifest_targets().items():
230
+ if path == rel:
231
+ return declared
232
+ return Path(rel).stem
111
233
 
112
234
  # ------------------------------------------------------------------
113
235
  # Commands
@@ -116,10 +238,12 @@ class CargoAdapter(Adapter):
116
238
  def _test_cmd(self) -> str:
117
239
  return self.project.test_command or "cargo test --tests"
118
240
 
119
- @staticmethod
120
- def _selector(native_id: str) -> str:
241
+ def _selector(self, native_id: str) -> str:
121
242
  target, _, path = native_id.partition("::")
122
- scope = "--lib" if target == "lib" else f"--test {shlex.quote(target)}"
243
+ if target == "lib":
244
+ scope = "--lib"
245
+ else:
246
+ scope = f"--test {shlex.quote(self._declared_target(target))}"
123
247
  return f"{scope} -- --exact {shlex.quote(path)}"
124
248
 
125
249
  def _targeted_cmd(self, native_id: str) -> str:
@@ -154,18 +278,19 @@ class CargoAdapter(Adapter):
154
278
  if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
155
279
  return None
156
280
  tests = self._parse_list(out)
157
- files = {f"tests/{t.split('::', 1)[0]}.rs" for t in tests if not t.startswith("lib::")}
281
+ files = {self.target_path(t) for t in tests if not t.startswith("lib::")} - {None}
158
282
  return {self.qualify(t) for t in tests}, files
159
283
 
160
284
  def _collect_per_file(self, rels, result):
161
285
  for rel in sorted(rels):
162
- stem = Path(rel).stem
163
- cmd = f"{self._targeted_prefix()} --test {shlex.quote(stem)} -- --list 2>&1"
286
+ declared = self._target_of_file(rel)
287
+ cmd = f"{self._targeted_prefix()} --test {shlex.quote(declared)} -- --list 2>&1"
164
288
  code, out, _err = self._run_suite(cmd, self._suite_env(None))
165
289
  if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
166
290
  result.failed_files[rel] = clip_failure(self._errors(out))
167
291
  continue
168
- result.tests |= {self.qualify(t) for t in self._parse_list(out, default=stem)}
292
+ default = declared.replace("-", "_")
293
+ result.tests |= {self.qualify(t) for t in self._parse_list(out, default=default)}
169
294
  return result
170
295
 
171
296
  def _targeted_prefix(self) -> str:
@@ -520,7 +520,7 @@ def _handle_refactor(engine: Engine, cycle, retried: bool) -> Envelope:
520
520
  engine,
521
521
  cycle,
522
522
  Verb.FIX_REGRESSION,
523
- "Close sweep is green but lint/typecheck gates failed.",
523
+ "Close sweep stopped before the suite: lint/typecheck gates failed.",
524
524
  gates=gates,
525
525
  commit=sha,
526
526
  )
@@ -13,7 +13,7 @@ import sqlite3
13
13
  from datetime import datetime, timedelta, timezone
14
14
  from pathlib import Path
15
15
 
16
- SCHEMA_VERSION = 10
16
+ SCHEMA_VERSION = 11
17
17
 
18
18
 
19
19
  class LedgerVersionError(RuntimeError):
@@ -45,6 +45,11 @@ MIGRATIONS: dict[int, str] = {
45
45
  8: "ALTER TABLE artifact_check ADD COLUMN regenerate_failed INTEGER NOT NULL DEFAULT 0;",
46
46
  # v9 -> v10 added start_sha column to run; ALTER TABLE covers old ledgers.
47
47
  9: "ALTER TABLE run ADD COLUMN start_sha TEXT;",
48
+ # v10 -> v11 added tree_hash and skipped columns to gate_result; ALTER TABLE covers old ledgers.
49
+ 10: (
50
+ "ALTER TABLE gate_result ADD COLUMN tree_hash TEXT;"
51
+ " ALTER TABLE gate_result ADD COLUMN skipped INTEGER NOT NULL DEFAULT 0;"
52
+ ),
48
53
  }
49
54
 
50
55
  SCHEMA = """
@@ -158,6 +163,8 @@ CREATE TABLE IF NOT EXISTS gate_result (
158
163
  kind TEXT NOT NULL, -- lint | typecheck
159
164
  ok INTEGER NOT NULL,
160
165
  output TEXT,
166
+ tree_hash TEXT,
167
+ skipped INTEGER NOT NULL DEFAULT 0,
161
168
  at TEXT NOT NULL
162
169
  );
163
170
 
@@ -270,9 +270,58 @@ class Engine:
270
270
  unbaselined: dict[str, list[str]] = {}
271
271
  unobserved: dict[str, str] = {}
272
272
 
273
+ adapters_by_name = {
274
+ name: adapters.build(self.config.project(name), self.worktree) for name in names
275
+ }
276
+
273
277
  for name in names:
274
- project = self.config.project(name)
275
- adapter = adapters.build(project, self.worktree)
278
+ adapter = adapters_by_name[name]
279
+ current_hash = self.tree_hash([name])
280
+ gate_failed = False
281
+ for kind, gate_fn in (("lint", adapter.lint), ("typecheck", adapter.typecheck)):
282
+ last = self.ledger.one(
283
+ "SELECT ok, tree_hash FROM gate_result"
284
+ " WHERE run_id = ? AND project = ? AND kind = ?"
285
+ " ORDER BY id DESC LIMIT 1",
286
+ (self.run["id"], name, kind),
287
+ )
288
+ if last and last["ok"] == 1 and last["tree_hash"] == current_hash:
289
+ self.ledger.insert(
290
+ "gate_result",
291
+ run_id=self.run["id"],
292
+ cycle_id=cycle_row["id"],
293
+ project=name,
294
+ kind=kind,
295
+ ok=1,
296
+ output="",
297
+ tree_hash=current_hash,
298
+ skipped=1,
299
+ at=now(),
300
+ )
301
+ continue
302
+ gate = gate_fn()
303
+ self.ledger.insert(
304
+ "gate_result",
305
+ run_id=self.run["id"],
306
+ cycle_id=cycle_row["id"],
307
+ project=name,
308
+ kind=kind,
309
+ ok=int(gate.ok),
310
+ output=gate.output,
311
+ tree_hash=current_hash,
312
+ at=now(),
313
+ )
314
+ if not gate.ok:
315
+ gates.append((name, kind, gate.output))
316
+ gate_failed = True
317
+ break
318
+ if gate_failed:
319
+ return SweepOutcome(
320
+ failures=failures, gates=gates, unbaselined=unbaselined, unobserved=unobserved
321
+ )
322
+
323
+ for name in names:
324
+ adapter = adapters_by_name[name]
276
325
  started = time.monotonic()
277
326
  verdict = adapter.run(None)
278
327
  heartbeat(
@@ -350,19 +399,6 @@ class Engine:
350
399
  tree_hash=self.tree_hash([name]),
351
400
  started_at=now(),
352
401
  )
353
- for kind, gate in (("lint", adapter.lint()), ("typecheck", adapter.typecheck())):
354
- self.ledger.insert(
355
- "gate_result",
356
- run_id=self.run["id"],
357
- cycle_id=cycle_row["id"],
358
- project=name,
359
- kind=kind,
360
- ok=int(gate.ok),
361
- output=gate.output,
362
- at=now(),
363
- )
364
- if not gate.ok:
365
- gates.append((name, kind, gate.output))
366
402
  return SweepOutcome(
367
403
  failures=failures, gates=gates, unbaselined=unbaselined, unobserved=unobserved
368
404
  )
@@ -13,7 +13,7 @@ from unittest.mock import patch
13
13
 
14
14
  from tddcli import config as config_mod
15
15
  from tddcli.adapters import available
16
- from tddcli.adapters.base import FAILED, NOT_COLLECTED, NOT_FOUND, PASSED
16
+ from tddcli.adapters.base import FAILED, NOT_COLLECTED, NOT_FOUND, PASSED, Collection
17
17
  from tddcli.adapters.cargo_adapter import CargoAdapter
18
18
 
19
19
  TOML = """
@@ -395,3 +395,190 @@ def test_every_invocation_carries_the_project_env(tmp_path):
395
395
  for call in rs.call_args_list:
396
396
  env = call.args[1] if len(call.args) > 1 else call.kwargs.get("extra_env")
397
397
  assert env == {"CARGO_HOME": "/opt/cargo"}, call
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # A crate whose test binary is not named after its file
402
+ #
403
+ # `autotests = false` plus a `[[test]]` entry compiles every file under tests/
404
+ # into one binary with a name of its own. The target is then unrelated to the
405
+ # file it was built from, and in a workspace every crate's header reads
406
+ # `Running tests/main.rs` — only the artifact tells them apart.
407
+ # ---------------------------------------------------------------------------
408
+
409
+ CONSOLIDATED_LIST = """
410
+ Running tests/main.rs (/work/target/debug/deps/bridge_tests-541f4f38a741dff0)
411
+
412
+ adapter_config::poll_config_parses_targets_and_maps: test
413
+ adapter_webhook_config::webhook_table_parses_listen_token_and_maps: test
414
+
415
+ 2 tests, 0 benchmarks
416
+ """
417
+
418
+ WORKSPACE_LIST = """
419
+ Running tests/main.rs (/work/target/debug/deps/bridge_tests-541f4f38a741dff0)
420
+
421
+ adapter_config::poll_config_parses_targets_and_maps: test
422
+
423
+ Running tests/main.rs (/work/target/debug/deps/core_tests-9f1e2d3c4b5a6879)
424
+
425
+ attention_clear::clear_removes_the_alert: test
426
+
427
+ 2 tests, 0 benchmarks
428
+ """
429
+
430
+
431
+ def make_consolidated_adapter(tmp_path: Path) -> CargoAdapter:
432
+ """A workspace of two crates, each compiling tests/main.rs under its own name."""
433
+ (tmp_path / "tdd.toml").write_text(
434
+ "\n".join(
435
+ [
436
+ "[project.ws]",
437
+ 'root = "."',
438
+ 'adapter = "cargo"',
439
+ 'test_paths = ["crates/*/tests/"]',
440
+ "",
441
+ ]
442
+ )
443
+ )
444
+ for crate, target in (("dd-bridge", "bridge_tests"), ("dd-core", "core_tests")):
445
+ tests = tmp_path / "crates" / crate / "tests"
446
+ tests.mkdir(parents=True)
447
+ (tests / "main.rs").write_text("mod adapter_config;\n")
448
+ (tmp_path / "crates" / crate / "Cargo.toml").write_text(
449
+ "\n".join(
450
+ [
451
+ "[package]",
452
+ f'name = "{crate}"',
453
+ "autotests = false",
454
+ "",
455
+ "[[test]]",
456
+ f'name = "{target}"',
457
+ 'path = "tests/main.rs"',
458
+ "",
459
+ ]
460
+ )
461
+ )
462
+ cfg = config_mod.load(tmp_path)
463
+ return CargoAdapter(cfg.project("ws"), tmp_path)
464
+
465
+
466
+ def test_ids_use_the_binary_name_not_the_file_stem(tmp_path):
467
+ a = make_consolidated_adapter(tmp_path)
468
+ assert a._parse_list(CONSOLIDATED_LIST) == {
469
+ "bridge_tests::adapter_config::poll_config_parses_targets_and_maps",
470
+ "bridge_tests::adapter_webhook_config::webhook_table_parses_listen_token_and_maps",
471
+ }
472
+
473
+
474
+ def test_identically_pathed_files_are_told_apart_by_their_artifact(tmp_path):
475
+ a = make_consolidated_adapter(tmp_path)
476
+ assert a._parse_list(WORKSPACE_LIST) == {
477
+ "bridge_tests::adapter_config::poll_config_parses_targets_and_maps",
478
+ "core_tests::attention_clear::clear_removes_the_alert",
479
+ }
480
+
481
+
482
+ def test_targeted_command_scopes_to_the_declared_binary(tmp_path):
483
+ a = make_consolidated_adapter(tmp_path)
484
+ assert a._targeted_cmd("bridge_tests::adapter_config::parses") == (
485
+ "cargo test --test bridge_tests -- --exact adapter_config::parses"
486
+ )
487
+
488
+
489
+ def test_target_path_maps_a_declared_binary_to_the_file_it_is_built_from(tmp_path):
490
+ a = make_consolidated_adapter(tmp_path)
491
+ assert a.target_path("bridge_tests::adapter_config::parses") == (
492
+ "crates/dd-bridge/tests/main.rs"
493
+ )
494
+ assert a.target_path("core_tests::attention_clear::clears") == ("crates/dd-core/tests/main.rs")
495
+
496
+
497
+ def test_per_file_collection_scopes_to_the_files_binary(tmp_path):
498
+ a = make_consolidated_adapter(tmp_path)
499
+ seen: list[str] = []
500
+
501
+ def record(cmd, env):
502
+ seen.append(cmd)
503
+ return 0, CONSOLIDATED_LIST, ""
504
+
505
+ with patch.object(CargoAdapter, "_run_suite", side_effect=record):
506
+ a._collect_per_file({"crates/dd-bridge/tests/main.rs"}, Collection())
507
+ assert "--test bridge_tests" in seen[0]
508
+ assert "--test main" not in seen[0]
509
+
510
+
511
+ def test_a_dashed_binary_name_is_run_under_its_declared_spelling(tmp_path):
512
+ """cargo writes `-` as `_` in the artifact, but `--test` wants the manifest's spelling."""
513
+ (tmp_path / "tdd.toml").write_text(
514
+ '[project.ws]\nroot = "."\nadapter = "cargo"\ntest_paths = ["tests/"]\n'
515
+ )
516
+ (tmp_path / "tests").mkdir()
517
+ (tmp_path / "tests" / "main.rs").write_text("mod a;\n")
518
+ (tmp_path / "Cargo.toml").write_text(
519
+ '[package]\nname = "w"\nautotests = false\n\n'
520
+ '[[test]]\nname = "my-tests"\npath = "tests/main.rs"\n'
521
+ )
522
+ cfg = config_mod.load(tmp_path)
523
+ a = CargoAdapter(cfg.project("ws"), tmp_path)
524
+ assert a._targeted_cmd("my_tests::a::works") == (
525
+ "cargo test --test my-tests -- --exact a::works"
526
+ )
527
+
528
+
529
+ def test_collection_reports_the_file_each_target_was_built_from(tmp_path):
530
+ """The set of covered files is the manifest's, not `tests/<target>.rs`."""
531
+ a = make_consolidated_adapter(tmp_path)
532
+ with patch.object(CargoAdapter, "_run_suite", return_value=(0, WORKSPACE_LIST, "")):
533
+ tests, files = a._collect_batch("cargo test --tests -- --list", {})
534
+ assert files == {"crates/dd-bridge/tests/main.rs", "crates/dd-core/tests/main.rs"}
535
+ assert "ws::bridge_tests::adapter_config::poll_config_parses_targets_and_maps" in tests
536
+
537
+
538
+ def test_a_malformed_artifact_falls_back_to_the_file_stem(tmp_path):
539
+ """Nothing usable in the parentheses: the file stem is still cargo's default."""
540
+ a = make_adapter(tmp_path)
541
+ output = (
542
+ " Running tests/roundtrip.rs (/work/target/debug/deps/-1d2c3b4a)\n"
543
+ "\nrenders_cover_only: test\n"
544
+ )
545
+ assert a._parse_list(output) == {"roundtrip::renders_cover_only"}
546
+
547
+
548
+ def test_a_test_entry_without_a_path_resolves_through_cargos_default(tmp_path):
549
+ """`[[test]] name = "x"` with no `path` means `tests/x.rs`; no mapping needed."""
550
+ (tmp_path / "tdd.toml").write_text(
551
+ '[project.ws]\nroot = "."\nadapter = "cargo"\ntest_paths = ["tests/"]\n'
552
+ )
553
+ (tmp_path / "tests").mkdir()
554
+ (tmp_path / "tests" / "roundtrip.rs").write_text("#[test] fn t() {}\n")
555
+ (tmp_path / "Cargo.toml").write_text('[package]\nname = "w"\n\n[[test]]\nname = "roundtrip"\n')
556
+ cfg = config_mod.load(tmp_path)
557
+ a = CargoAdapter(cfg.project("ws"), tmp_path)
558
+ assert a._manifest_targets() == {}
559
+ assert a.target_path("roundtrip::t") == "tests/roundtrip.rs"
560
+ assert a._targeted_cmd("roundtrip::t") == ("cargo test --test roundtrip -- --exact t")
561
+
562
+
563
+ def test_a_file_shaped_test_path_still_finds_the_manifest(tmp_path):
564
+ """`test_paths` may name files (`tests/*.rs`), not only directories."""
565
+ (tmp_path / "tdd.toml").write_text(
566
+ '[project.ws]\nroot = "."\nadapter = "cargo"\ntest_paths = ["tests/*.rs"]\n'
567
+ )
568
+ (tmp_path / "tests").mkdir()
569
+ (tmp_path / "tests" / "main.rs").write_text("mod a;\n")
570
+ (tmp_path / "Cargo.toml").write_text(
571
+ '[package]\nname = "w"\nautotests = false\n\n'
572
+ '[[test]]\nname = "w_tests"\npath = "tests/main.rs"\n'
573
+ )
574
+ cfg = config_mod.load(tmp_path)
575
+ a = CargoAdapter(cfg.project("ws"), tmp_path)
576
+ assert a._manifest_targets() == {"w_tests": "tests/main.rs"}
577
+
578
+
579
+ def test_a_crate_with_no_manifest_keeps_cargos_default_mapping(tmp_path):
580
+ """No Cargo.toml above the tests dir: the walk stops at the root, nothing is claimed."""
581
+ a = make_adapter(tmp_path) # kernel/tests/roundtrip.rs, no Cargo.toml anywhere
582
+ assert a._manifest_targets() == {}
583
+ assert a.target_path("roundtrip::renders_cover_only") == "tests/roundtrip.rs"
584
+ assert a._target_of_file("tests/roundtrip.rs") == "roundtrip"