tdd-cli 0.12.0__tar.gz → 0.12.2__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.12.0 → tdd_cli-0.12.2}/CHANGELOG.md +32 -0
  2. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/PKG-INFO +1 -1
  3. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/__init__.py +1 -1
  4. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/cargo_adapter.py +144 -15
  5. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/config.py +19 -2
  6. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_cargo_adapter.py +195 -1
  7. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_config_and_staging.py +39 -1
  8. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/.gitignore +0 -0
  9. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/LICENSE +0 -0
  10. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/README.md +0 -0
  11. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/SECURITY.md +0 -0
  12. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/docs/PRD.md +0 -0
  13. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/docs/harness-integration.md +0 -0
  14. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/claude-code-hooks/README.md +0 -0
  15. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/claude-code-hooks/bash_hook.py +0 -0
  16. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/claude-code-hooks/stop_hook.py +0 -0
  17. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/plan.md +0 -0
  18. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/skills/tdd-drive/README.md +0 -0
  19. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/skills/tdd-drive/SKILL.md +0 -0
  20. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/skills/tdd-handoff/README.md +0 -0
  21. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/examples/skills/tdd-handoff/SKILL.md +0 -0
  22. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/pyproject.toml +0 -0
  23. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/__init__.py +0 -0
  24. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/base.py +0 -0
  25. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/exec_adapter.py +0 -0
  26. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/gradle_adapter.py +0 -0
  27. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/pytest_adapter.py +0 -0
  28. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/vitest_adapter.py +0 -0
  29. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/adapters/xctest_adapter.py +0 -0
  30. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/advance.py +0 -0
  31. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/cli.py +0 -0
  32. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/contract.py +0 -0
  33. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/docs.py +0 -0
  34. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/envelope.py +0 -0
  35. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/fleet.py +0 -0
  36. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/gitutil.py +0 -0
  37. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/identity.py +0 -0
  38. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/leases.py +0 -0
  39. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/ledger.py +0 -0
  40. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/machine.py +0 -0
  41. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/plan_paths.py +0 -0
  42. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/render.py +0 -0
  43. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/snapshot.py +0 -0
  44. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/staging.py +0 -0
  45. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/src/tddcli/target_lint.py +0 -0
  46. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/conftest.py +0 -0
  47. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_advance_adoption.py +0 -0
  48. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_ancillary_files.py +0 -0
  49. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_artifact_regeneration.py +0 -0
  50. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_baseline_integrity.py +0 -0
  51. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_baseline_sanity.py +0 -0
  52. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_batch_collection.py +0 -0
  53. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_close_sweep_gates.py +0 -0
  54. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_concurrent_advance.py +0 -0
  55. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_config_drift.py +0 -0
  56. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_contract.py +0 -0
  57. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_docs_command.py +0 -0
  58. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_doctor_attribution.py +0 -0
  59. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_doctor_blockers.py +0 -0
  60. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_end_to_end.py +0 -0
  61. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_evidence_extraction.py +0 -0
  62. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_example_plan.py +0 -0
  63. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_exec_adapter.py +0 -0
  64. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_executor_attribution.py +0 -0
  65. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_executor_notes.py +0 -0
  66. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_failure_clipping.py +0 -0
  67. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_fleet.py +0 -0
  68. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_gradle_adapter.py +0 -0
  69. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_heartbeat.py +0 -0
  70. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_id_normalisation.py +0 -0
  71. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_init_detection.py +0 -0
  72. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_late_baseline.py +0 -0
  73. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_named_leases_and_timeout.py +0 -0
  74. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_pin_cycles.py +0 -0
  75. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_plan_paths.py +0 -0
  76. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_progress.py +0 -0
  77. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_project_commands.py +0 -0
  78. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_project_env.py +0 -0
  79. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_pytest_xdist_group.py +0 -0
  80. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_python_env_managers.py +0 -0
  81. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_refactor_cycles.py +0 -0
  82. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_release_surface.py +0 -0
  83. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_run_claim.py +0 -0
  84. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_sensitivity_evidence.py +0 -0
  85. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_single_project_repo.py +0 -0
  86. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_snapshot_and_identity.py +0 -0
  87. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_stub_hint.py +0 -0
  88. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_suite_overrides.py +0 -0
  89. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_target_lint.py +0 -0
  90. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_target_validation.py +0 -0
  91. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_timing_visibility.py +0 -0
  92. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_undeclared_close_gate.py +0 -0
  93. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_undeclared_dedup.py +0 -0
  94. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_vitest_adapter.py +0 -0
  95. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_worker_leases.py +0 -0
  96. {tdd_cli-0.12.0 → tdd_cli-0.12.2}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,38 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.2] - 2026-09-17
10
+
11
+ ### Fixed
12
+
13
+ - **A wildcard directory in `test_paths` matched nothing** (#136): a pattern ending in `/` was
14
+ compared as a literal prefix by `Project.is_test_file` and joined as a literal path by the cargo
15
+ adapter's `_test_files`, so `crates/*/tests/` — how a workspace declares one pattern instead of
16
+ one per crate — matched no file at all. Every test file in such a project classified as
17
+ implementation, so a RED commit touching only tests was recorded as `implementation_during_red`,
18
+ reporting the agent for writing production code it had not written; `_test_files` returned an
19
+ empty list, leaving the unaccounted-files check with nothing to compare. A directory pattern is
20
+ now matched per path segment (`crates/*/tests/` covers `crates/x/tests/main.rs`, not
21
+ `crates/x/src/tests/main.rs`), and the adapter globs it.
22
+
23
+ ## [0.12.1] - 2026-09-17
24
+
25
+ ### Fixed
26
+
27
+ - **cargo: a test target whose name differs from its file's stem** (#134): the adapter derived the
28
+ target from the `Running tests/<file>.rs` header's path, which is only the target's name under
29
+ cargo's default of one binary per `tests/*.rs`. A crate that sets `autotests = false` and declares
30
+ its own `[[test]]` — many files compiled into one binary named something else — was unreachable:
31
+ collection ran `--test <stem>` and cargo answered ``no test target named `<stem>` in default-run
32
+ packages``, so every cycle target came back `not_collected`, whatever id the plan declared. The
33
+ target is now read from the artifact the header names (`…/deps/bridge_tests-<hash>`), which is
34
+ also the only thing distinguishing two crates in a workspace whose test files are both
35
+ `tests/main.rs`. The crate manifests supply the mapping back: target → the file it is built from
36
+ for `target_path` and the collection file set, and the declared spelling for `--test`, since cargo
37
+ writes a target's `-` as `_` in the artifact. Manifests are located by walking up from the declared
38
+ `test_paths`, not from `_test_files()`, which does not expand a wildcard directory pattern such as
39
+ `crates/*/tests/`. Under the default layout target and stem still agree and nothing changes.
40
+
9
41
  ## [0.12.0] - 2026-09-17
10
42
 
11
43
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.12.0
3
+ Version: 0.12.2
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
@@ -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.12.0"
6
+ __version__ = "0.12.2"
@@ -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:
@@ -135,9 +259,13 @@ class CargoAdapter(Adapter):
135
259
  found: set[Path] = set()
136
260
  for pattern in self.project.test_patterns or []:
137
261
  if pattern.endswith("/"):
138
- base = self.root / pattern
139
- if base.is_dir():
140
- found.update(p for p in base.glob("*.rs") if p.is_file())
262
+ # `self.root / pattern` is a literal path, so a wildcard
263
+ # directory pattern (`crates/*/tests/`) never resolves; glob it.
264
+ # A trailing slash is accepted by glob on every supported
265
+ # Python (3.11–3.14), so the pattern goes through as declared.
266
+ for base in self.root.glob(pattern):
267
+ if base.is_dir():
268
+ found.update(p for p in base.glob("*.rs") if p.is_file())
141
269
  else:
142
270
  found.update(p for p in self.root.glob(pattern) if p.is_file())
143
271
  return sorted(found)
@@ -154,18 +282,19 @@ class CargoAdapter(Adapter):
154
282
  if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
155
283
  return None
156
284
  tests = self._parse_list(out)
157
- files = {f"tests/{t.split('::', 1)[0]}.rs" for t in tests if not t.startswith("lib::")}
285
+ files = {self.target_path(t) for t in tests if not t.startswith("lib::")} - {None}
158
286
  return {self.qualify(t) for t in tests}, files
159
287
 
160
288
  def _collect_per_file(self, rels, result):
161
289
  for rel in sorted(rels):
162
- stem = Path(rel).stem
163
- cmd = f"{self._targeted_prefix()} --test {shlex.quote(stem)} -- --list 2>&1"
290
+ declared = self._target_of_file(rel)
291
+ cmd = f"{self._targeted_prefix()} --test {shlex.quote(declared)} -- --list 2>&1"
164
292
  code, out, _err = self._run_suite(cmd, self._suite_env(None))
165
293
  if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
166
294
  result.failed_files[rel] = clip_failure(self._errors(out))
167
295
  continue
168
- result.tests |= {self.qualify(t) for t in self._parse_list(out, default=stem)}
296
+ default = declared.replace("-", "_")
297
+ result.tests |= {self.qualify(t) for t in self._parse_list(out, default=default)}
169
298
  return result
170
299
 
171
300
  def _targeted_prefix(self) -> str:
@@ -73,6 +73,20 @@ class Override:
73
73
  timeout: int | None = None
74
74
 
75
75
 
76
+ def _dir_pattern_matches(pattern: str, inner: str) -> bool:
77
+ """True when `inner` sits under the directory `pattern` describes.
78
+
79
+ `pattern` ends in `/` and may contain wildcards; matching is per path
80
+ segment, so `crates/*/tests/` covers `crates/dd-bridge/tests/main.rs` but
81
+ not `crates/dd-bridge/src/main.rs`.
82
+ """
83
+ segments = pattern.rstrip("/").split("/")
84
+ parts = inner.split("/")
85
+ if len(parts) <= len(segments):
86
+ return False
87
+ return all(fnmatch(part, seg) for part, seg in zip(parts, segments))
88
+
89
+
76
90
  @dataclass
77
91
  class Project:
78
92
  name: str
@@ -141,11 +155,14 @@ class Project:
141
155
  inner = self.relative_to_root(rel_path)
142
156
  for pattern in self.test_patterns:
143
157
  if pattern.endswith("/"):
144
- if inner.startswith(pattern):
158
+ # A directory pattern may carry wildcards (`crates/*/tests/`),
159
+ # so it is matched as a glob against the leading path segments,
160
+ # not compared as a literal prefix.
161
+ if _dir_pattern_matches(pattern, inner):
145
162
  return True
146
163
  elif fnmatch(inner, pattern) or fnmatch(rel_path, pattern):
147
164
  return True
148
- elif inner.startswith(pattern.rstrip("/") + "/"):
165
+ elif _dir_pattern_matches(pattern.rstrip("/") + "/", inner):
149
166
  return True
150
167
  return False
151
168
 
@@ -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,197 @@ 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"
585
+
586
+
587
+ def test_test_files_expands_a_wildcard_directory_pattern(tmp_path):
588
+ """`self.root / "crates/*/tests/"` is a literal path and never resolves."""
589
+ a = make_consolidated_adapter(tmp_path)
590
+ rels = sorted(str(p.relative_to(tmp_path)) for p in a._test_files())
591
+ assert rels == ["crates/dd-bridge/tests/main.rs", "crates/dd-core/tests/main.rs"]
@@ -21,6 +21,11 @@ adapter = "vitest"
21
21
  test_paths = ["e2e/"]
22
22
  in_close_sweep = false
23
23
 
24
+ [project.workspace]
25
+ root = "ws"
26
+ adapter = "cargo"
27
+ test_paths = ["crates/*/tests/"]
28
+
24
29
  [artifact.openapi]
25
30
  path = "schema/openapi.json"
26
31
  produced_by = "backend"
@@ -43,7 +48,7 @@ def cfg(tmp_path):
43
48
 
44
49
 
45
50
  def test_projects_and_roots_are_declared_not_scanned(cfg):
46
- assert sorted(cfg.projects) == ["backend", "e2e", "frontend"]
51
+ assert sorted(cfg.projects) == ["backend", "e2e", "frontend", "workspace"]
47
52
  assert cfg.project("backend").root == "backend"
48
53
 
49
54
 
@@ -320,3 +325,36 @@ def test_health_command_parses_onto_project(tmp_path):
320
325
  )
321
326
  with pytest.raises(config_mod.ConfigError):
322
327
  config_mod.load(tmp_path)
328
+
329
+
330
+ def test_a_wildcard_directory_test_path_matches_per_segment(cfg):
331
+ """`crates/*/tests/` is a glob, not a literal prefix.
332
+
333
+ A workspace declares its test directories with one pattern rather than one
334
+ per crate. Comparing it as a literal string classifies every test file as
335
+ implementation, which reports the agent for writing production code during
336
+ RED when it only edited tests.
337
+ """
338
+ ws = cfg.project("workspace")
339
+ assert ws.is_test_file("ws/crates/dd-bridge/tests/main.rs")
340
+ assert ws.is_test_file("ws/crates/dd-core/tests/attention_clear.rs")
341
+ # The wildcard spans exactly one segment, and the directory itself is not a file.
342
+ assert not ws.is_test_file("ws/crates/dd-bridge/src/adapter/config.rs")
343
+ assert not ws.is_test_file("ws/crates/dd-bridge/src/tests/inner.rs")
344
+ assert not ws.is_test_file("ws/crates/a/b/tests/deep.rs")
345
+ assert not ws.is_test_file("ws/crates/dd-bridge/tests")
346
+
347
+
348
+ def test_a_wildcard_test_path_classifies_edits_as_tests_not_implementation(cfg):
349
+ """The staging consequence: RED may touch these without being reported."""
350
+ changed = {
351
+ "ws/crates/dd-bridge/tests/main.rs",
352
+ "ws/crates/dd-bridge/tests/adapter_config.rs",
353
+ "ws/crates/dd-bridge/src/adapter/config.rs",
354
+ }
355
+ out = staging.classify(cfg, changed, ["workspace"], None, set())
356
+ assert out.tests == [
357
+ "ws/crates/dd-bridge/tests/adapter_config.rs",
358
+ "ws/crates/dd-bridge/tests/main.rs",
359
+ ]
360
+ assert out.implementation == ["ws/crates/dd-bridge/src/adapter/config.rs"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes