tdd-cli 0.9.0__tar.gz → 0.10.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 (88) hide show
  1. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/CHANGELOG.md +25 -0
  2. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/PKG-INFO +19 -2
  3. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/README.md +18 -1
  4. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/pyproject.toml +2 -1
  5. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/__init__.py +1 -1
  6. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/__init__.py +2 -0
  7. tdd_cli-0.10.1/src/tddcli/adapters/cargo_adapter.py +321 -0
  8. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/pytest_adapter.py +21 -3
  9. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/conftest.py +0 -1
  10. tdd_cli-0.10.1/tests/test_cargo_adapter.py +395 -0
  11. tdd_cli-0.10.1/tests/test_pytest_xdist_group.py +97 -0
  12. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/.gitignore +0 -0
  13. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/LICENSE +0 -0
  14. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/SECURITY.md +0 -0
  15. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/README.md +0 -0
  16. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/bash_hook.py +0 -0
  17. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/claude-code-hooks/stop_hook.py +0 -0
  18. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/plan.md +0 -0
  19. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-drive/README.md +0 -0
  20. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-drive/SKILL.md +0 -0
  21. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-handoff/README.md +0 -0
  22. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/examples/skills/tdd-handoff/SKILL.md +0 -0
  23. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/base.py +0 -0
  24. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/exec_adapter.py +0 -0
  25. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/gradle_adapter.py +0 -0
  26. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/vitest_adapter.py +0 -0
  27. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/adapters/xctest_adapter.py +0 -0
  28. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/advance.py +0 -0
  29. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/cli.py +0 -0
  30. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/config.py +0 -0
  31. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/contract.py +0 -0
  32. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/envelope.py +0 -0
  33. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/fleet.py +0 -0
  34. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/gitutil.py +0 -0
  35. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/identity.py +0 -0
  36. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/leases.py +0 -0
  37. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/ledger.py +0 -0
  38. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/machine.py +0 -0
  39. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/render.py +0 -0
  40. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/snapshot.py +0 -0
  41. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/staging.py +0 -0
  42. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/src/tddcli/target_lint.py +0 -0
  43. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_advance_adoption.py +0 -0
  44. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_ancillary_files.py +0 -0
  45. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_artifact_regeneration.py +0 -0
  46. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_baseline_integrity.py +0 -0
  47. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_baseline_sanity.py +0 -0
  48. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_batch_collection.py +0 -0
  49. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_concurrent_advance.py +0 -0
  50. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_config_and_staging.py +0 -0
  51. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_config_drift.py +0 -0
  52. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_contract.py +0 -0
  53. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_doctor_attribution.py +0 -0
  54. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_doctor_blockers.py +0 -0
  55. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_end_to_end.py +0 -0
  56. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_evidence_extraction.py +0 -0
  57. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_example_plan.py +0 -0
  58. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_exec_adapter.py +0 -0
  59. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_executor_attribution.py +0 -0
  60. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_executor_notes.py +0 -0
  61. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_failure_clipping.py +0 -0
  62. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_fleet.py +0 -0
  63. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_gradle_adapter.py +0 -0
  64. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_heartbeat.py +0 -0
  65. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_id_normalisation.py +0 -0
  66. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_init_detection.py +0 -0
  67. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_named_leases_and_timeout.py +0 -0
  68. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_pin_cycles.py +0 -0
  69. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_progress.py +0 -0
  70. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_project_commands.py +0 -0
  71. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_project_env.py +0 -0
  72. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_python_env_managers.py +0 -0
  73. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_refactor_cycles.py +0 -0
  74. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_release_surface.py +0 -0
  75. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_run_claim.py +0 -0
  76. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_sensitivity_evidence.py +0 -0
  77. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_single_project_repo.py +0 -0
  78. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_snapshot_and_identity.py +0 -0
  79. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_stub_hint.py +0 -0
  80. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_suite_overrides.py +0 -0
  81. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_target_lint.py +0 -0
  82. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_target_validation.py +0 -0
  83. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_timing_visibility.py +0 -0
  84. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_undeclared_close_gate.py +0 -0
  85. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_undeclared_dedup.py +0 -0
  86. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_vitest_adapter.py +0 -0
  87. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_worker_leases.py +0 -0
  88. {tdd_cli-0.9.0 → tdd_cli-0.10.1}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,31 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.1] - 2026-09-06
10
+
11
+ ### Fixed
12
+
13
+ - **pytest adapter: `--dist loadgroup` node ids.** pytest-xdist reports a test marked
14
+ `xdist_group("<g>")` as `<nodeid>@<g>` and pytest-json-report records that spelling, so a
15
+ grouped target was `not_found` on every suite run even though it was collected and
16
+ passed, and its `passed`/`failed` ids never matched collected ids. The adapter now strips
17
+ the trailing `@<group>` when reading report and `--collect-only` ids. Only an `@` after
18
+ the last `]` is treated as the suffix, so parametrised ids such as
19
+ `test_x[user@example.com]` are left intact.
20
+
21
+ ## [0.10.0] - 2026-09-05
22
+
23
+ ### Added
24
+
25
+ - **cargo adapter** — Rust driver through `cargo test`. Ids are `<target>::<path>`
26
+ (`lib::…` for unit tests, `<tests-file-stem>::…` for integration tests) and compose
27
+ to `--lib`/`--test <name>` + `-- --exact <path>` for targeted runs. Verdicts are
28
+ parsed from cargo's per-test console lines, attributed to targets by the
29
+ `Running …` headers with stderr merged; doc-tests are excluded. A compile error maps
30
+ to `not_collected`, matching gradle/xctest. Collection is `cargo test --tests --
31
+ --list` with a per-file `--test <stem>` fallback; `tdd doctor`'s collectable gate is
32
+ `cargo test --no-run`. Target lint requires the `::` separator.
33
+
9
34
  ## [0.9.0] - 2026-09-03
10
35
 
11
36
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -341,7 +341,7 @@ the next plan is written.
341
341
 
342
342
  ## Adapters
343
343
 
344
- `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
344
+ `pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
345
345
  the suite through the project's own environment manager, detected from its marker files —
346
346
  `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
347
347
  checked at the project root first, then the worktree root (workspace layouts keep one
@@ -393,6 +393,23 @@ lease = "ios-simulator"
393
393
  timeout = 900
394
394
  ```
395
395
 
396
+ The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
397
+ `<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
398
+ `roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
399
+ cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
400
+ translation. Verdicts come from cargo's per-test console lines, attributed to targets by
401
+ the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
402
+ (`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
403
+ compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
404
+ `src/` — unit-test modules live inside production files and must not be staged as tests.
405
+
406
+ ```toml
407
+ [project.kernel]
408
+ root = "kernel"
409
+ adapter = "cargo"
410
+ test_paths = ["tests/"]
411
+ ```
412
+
396
413
  Third-party adapters register under the
397
414
  `tddcli.adapters` entry-point group:
398
415
 
@@ -313,7 +313,7 @@ the next plan is written.
313
313
 
314
314
  ## Adapters
315
315
 
316
- `pytest`, `vitest`, `gradle`, `xctest`, and `exec` are built in. The pytest adapter runs
316
+ `pytest`, `vitest`, `gradle`, `xctest`, `cargo`, and `exec` are built in. The pytest adapter runs
317
317
  the suite through the project's own environment manager, detected from its marker files —
318
318
  `uv.lock`, `poetry.lock`, `Pipfile`, `pdm.lock`, or `[tool.poetry]` in `pyproject.toml` —
319
319
  checked at the project root first, then the worktree root (workspace layouts keep one
@@ -365,6 +365,23 @@ lease = "ios-simulator"
365
365
  timeout = 900
366
366
  ```
367
367
 
368
+ The `cargo` adapter drives Rust crates through `cargo test`. Test ids are
369
+ `<target>::<path>` — `lib::layout::tests::wraps_short` for a `#[cfg(test)]` unit test,
370
+ `roundtrip::renders_cover_only` for `tests/roundtrip.rs` — so a targeted run composes to
371
+ cargo's own selectors (`--lib` / `--test <name>` plus `-- --exact <path>`) with no
372
+ translation. Verdicts come from cargo's per-test console lines, attributed to targets by
373
+ the `Running …` headers (stderr is merged so the order survives); doc-tests are excluded
374
+ (`--tests`). A *compile* failure maps to `not_collected`, as for gradle and xctest: write a
375
+ compiling stub (`todo!()`), then observe the panic. Declare `tests/` as `test_paths`, not
376
+ `src/` — unit-test modules live inside production files and must not be staged as tests.
377
+
378
+ ```toml
379
+ [project.kernel]
380
+ root = "kernel"
381
+ adapter = "cargo"
382
+ test_paths = ["tests/"]
383
+ ```
384
+
368
385
  Third-party adapters register under the
369
386
  `tddcli.adapters` entry-point group:
370
387
 
@@ -47,10 +47,11 @@ packages = ["src/tddcli"]
47
47
  include = ["src", "tests", "examples", "README.md", "LICENSE", "CHANGELOG.md", "SECURITY.md"]
48
48
 
49
49
  [dependency-groups]
50
- dev = ["pytest>=8.0", "pytest-json-report>=1.5", "ruff>=0.5", "zizmor>=1.29"]
50
+ dev = ["pytest>=8.0", "pytest-json-report>=1.5", "pytest-xdist>=3.5", "ruff>=0.5", "zizmor>=1.29"]
51
51
 
52
52
  [tool.pytest.ini_options]
53
53
  testpaths = ["tests"]
54
+ addopts = ["-n", "auto"]
54
55
 
55
56
  [tool.ruff]
56
57
  line-length = 100
@@ -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.9.0"
6
+ __version__ = "0.10.1"
@@ -4,6 +4,7 @@ import importlib.metadata
4
4
  from pathlib import Path
5
5
 
6
6
  from .base import Adapter, Collection, GateResult, Verdict
7
+ from .cargo_adapter import CargoAdapter
7
8
  from .exec_adapter import ExecAdapter
8
9
  from .gradle_adapter import GradleAdapter
9
10
  from .pytest_adapter import PytestAdapter
@@ -11,6 +12,7 @@ from .vitest_adapter import VitestAdapter
11
12
  from .xctest_adapter import XCTestAdapter
12
13
 
13
14
  REGISTRY: dict[str, type[Adapter]] = {
15
+ "cargo": CargoAdapter,
14
16
  "exec": ExecAdapter,
15
17
  "gradle": GradleAdapter,
16
18
  "pytest": PytestAdapter,
@@ -0,0 +1,321 @@
1
+ """cargo adapter — Rust driver through `cargo test` (§10).
2
+
3
+ Test ids are `<target>::<path>`: the *target* is `lib` for unit tests compiled
4
+ into the library (`#[cfg(test)]` modules under `src/`) or the integration-test
5
+ binary name for a file under `tests/` (`tests/roundtrip.rs` → `roundtrip`); the
6
+ *path* is the test's module path exactly as cargo prints it:
7
+
8
+ lib::layout::tests::wraps_short
9
+ roundtrip::renders_cover_only
10
+
11
+ A targeted run composes to cargo's own selectors without translation —
12
+ `cargo test --lib -- --exact layout::tests::wraps_short` or
13
+ `cargo test --test roundtrip -- --exact renders_cover_only` — and nothing else
14
+ is appended.
15
+
16
+ Console output *is* the oracle here: `cargo test` writes no result file, but
17
+ its per-test lines (`test <path> ... ok|FAILED|ignored`) are stable, and each
18
+ target's block is preceded by a `Running unittests src/lib.rs (…)` /
19
+ `Running tests/<name>.rs (…)` header that names the target. cargo prints the
20
+ headers on stderr and the test lines on stdout, so every invocation is run with
21
+ `2>&1` to keep them interleaved in order; attribution is "the most recent
22
+ header". Doc-tests (`Doc-tests <crate>`) have no stable ids and are excluded by
23
+ running `cargo test --tests` by default.
24
+
25
+ A *compile* failure maps to `not_collected`, not `failed`. Rust has no separate
26
+ collection phase — a test naming a missing symbol fails `rustc`, not the run —
27
+ so it is treated like Python's import error and Kotlin's compile error: write a
28
+ compiling stub (a function whose body is `todo!()`), then observe the
29
+ assertion failure. The discriminator is `error: could not compile` (or an
30
+ `error[E…]` diagnostic) with no test line produced.
31
+
32
+ Only files under `test_paths` are tests for staging purposes. Declare
33
+ `tests/`, not `src/`: a `#[cfg(test)]` module lives inside a production file,
34
+ and classifying `src/` as tests would stage implementation in RED commits.
35
+ Cycle targets therefore live in `tests/*.rs`; `lib::` ids are still collected
36
+ and scored so the baseline sees them.
37
+
38
+ Config (`tdd.toml`):
39
+
40
+ [project.kernel]
41
+ root = "transcript-kernel"
42
+ adapter = "cargo"
43
+ test_paths = ["tests/"]
44
+ test_command = "cargo test --tests" # default when omitted
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import re
50
+ import shlex
51
+ import time
52
+ from pathlib import Path
53
+
54
+ from .base import (
55
+ FAILED,
56
+ NOT_COLLECTED,
57
+ PASSED,
58
+ Adapter,
59
+ GateResult,
60
+ Verdict,
61
+ clip_failure,
62
+ )
63
+
64
+ # ` Running unittests src/lib.rs (target/debug/deps/x-abc)` → target "lib"
65
+ # ` Running tests/roundtrip.rs (target/debug/deps/roundtrip-abc)` → "roundtrip"
66
+ _RUNNING_RE = re.compile(r"^\s*Running (?:unittests (\S+)|(\S+)) \(", re.MULTILINE)
67
+ _DOCTEST_RE = re.compile(r"^\s*Doc-tests ", re.MULTILINE)
68
+ # `test layout::tests::wraps_short ... ok` / `... FAILED` / `... ignored`
69
+ _TEST_LINE_RE = re.compile(r"^test (\S+) \.\.\. (ok|FAILED|ignored)\s*$", re.MULTILINE)
70
+ # `layout::tests::wraps_short: test` (from `-- --list`)
71
+ _LIST_LINE_RE = re.compile(r"^(\S+): test\s*$", re.MULTILINE)
72
+ # `---- layout::tests::wraps_short stdout ----` opens a failure block
73
+ _FAILURE_BLOCK_RE = re.compile(r"^---- (\S+) stdout ----\s*$", re.MULTILINE)
74
+ _COMPILE_ERROR_RE = re.compile(r"^error(?:\[E\d+\])?: ", re.MULTILINE)
75
+ _COULD_NOT_COMPILE_RE = re.compile(r"could not compile")
76
+ _NO_LIB_RE = re.compile(r"no library targets found")
77
+
78
+
79
+ def _target_of_header(match: re.Match) -> str | None:
80
+ unit, path = match.group(1), match.group(2)
81
+ if unit is not None:
82
+ return "lib"
83
+ if path.startswith("tests/") and path.endswith(".rs"):
84
+ return Path(path).stem
85
+ return None # examples/benches: not test targets
86
+
87
+
88
+ class CargoAdapter(Adapter):
89
+ name = "cargo"
90
+
91
+ def stub_hint(self) -> str:
92
+ return (
93
+ "a compiling stub — a function whose body is `todo!()` — so the test"
94
+ " fails on its assertion/panic rather than on `rustc`; a missing"
95
+ " symbol is a *compile* failure (not_collected), not RED"
96
+ )
97
+
98
+ def lint_target_id(self, native: str) -> str | None:
99
+ if "::" not in native:
100
+ return (
101
+ f"cargo target ids must be '<target>::<path>' (got {native!r}); expected"
102
+ " shape: lib::module::tests::name or <tests-file-stem>::name"
103
+ )
104
+ return None
105
+
106
+ def target_path(self, native: str) -> str | None:
107
+ target, _, _ = native.partition("::")
108
+ if target == "lib":
109
+ return None
110
+ return f"tests/{target}.rs"
111
+
112
+ # ------------------------------------------------------------------
113
+ # Commands
114
+ # ------------------------------------------------------------------
115
+
116
+ def _test_cmd(self) -> str:
117
+ return self.project.test_command or "cargo test --tests"
118
+
119
+ @staticmethod
120
+ def _selector(native_id: str) -> str:
121
+ target, _, path = native_id.partition("::")
122
+ scope = "--lib" if target == "lib" else f"--test {shlex.quote(target)}"
123
+ return f"{scope} -- --exact {shlex.quote(path)}"
124
+
125
+ def _targeted_cmd(self, native_id: str) -> str:
126
+ """`cargo test <flags…>` from the declared command, with the suite-wide
127
+ target selector (`--tests`) dropped so the per-target scope can be set."""
128
+ return f"{self._targeted_prefix()} {self._selector(native_id)}"
129
+
130
+ # ------------------------------------------------------------------
131
+ # Discovery — integration test files
132
+ # ------------------------------------------------------------------
133
+
134
+ def _test_files(self) -> list[Path]:
135
+ found: set[Path] = set()
136
+ for pattern in self.project.test_patterns or []:
137
+ 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())
141
+ else:
142
+ found.update(p for p in self.root.glob(pattern) if p.is_file())
143
+ return sorted(found)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Collection — `cargo test … -- --list`, attributed by target header
147
+ # ------------------------------------------------------------------
148
+
149
+ def _collect_invocations(self):
150
+ return [(f"{self._test_cmd()} -- --list", self._suite_env(None))]
151
+
152
+ def _collect_batch(self, command, env):
153
+ code, out, _err = self._run_suite(f"{command} 2>&1", env)
154
+ if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
155
+ return None
156
+ tests = self._parse_list(out)
157
+ files = {f"tests/{t.split('::', 1)[0]}.rs" for t in tests if not t.startswith("lib::")}
158
+ return {self.qualify(t) for t in tests}, files
159
+
160
+ def _collect_per_file(self, rels, result):
161
+ for rel in sorted(rels):
162
+ stem = Path(rel).stem
163
+ cmd = f"{self._targeted_prefix()} --test {shlex.quote(stem)} -- --list 2>&1"
164
+ code, out, _err = self._run_suite(cmd, self._suite_env(None))
165
+ if code != 0 or _COULD_NOT_COMPILE_RE.search(out):
166
+ result.failed_files[rel] = clip_failure(self._errors(out))
167
+ continue
168
+ result.tests |= {self.qualify(t) for t in self._parse_list(out, default=stem)}
169
+ return result
170
+
171
+ def _targeted_prefix(self) -> str:
172
+ parts = shlex.split(self._test_cmd())
173
+ parts = [p for p in parts if p != "--tests"]
174
+ if "--" in parts:
175
+ parts = parts[: parts.index("--")]
176
+ return shlex.join(parts)
177
+
178
+ @staticmethod
179
+ def _parse_list(output: str, default: str | None = None) -> set[str]:
180
+ """Walk `--list` output, prefixing each `path: test` line with the target
181
+ named by the most recent `Running …` header (or `default` when the
182
+ invocation was already scoped to one target)."""
183
+ tests: set[str] = set()
184
+ target = default
185
+ for line in output.splitlines():
186
+ header = _RUNNING_RE.match(line)
187
+ if header:
188
+ target = _target_of_header(header)
189
+ continue
190
+ if _DOCTEST_RE.match(line):
191
+ target = None
192
+ continue
193
+ entry = _LIST_LINE_RE.match(line)
194
+ if entry and target is not None:
195
+ tests.add(f"{target}::{entry.group(1)}")
196
+ return tests
197
+
198
+ def collectable(self) -> GateResult:
199
+ code, out, err = self._run_suite(
200
+ f"{self._targeted_prefix()} --no-run 2>&1", self._suite_env(None)
201
+ )
202
+ if code == 0:
203
+ return GateResult(ok=True)
204
+ return GateResult(ok=False, output=clip_failure(self._errors(out + err)))
205
+
206
+ # ------------------------------------------------------------------
207
+ # Results
208
+ # ------------------------------------------------------------------
209
+
210
+ @staticmethod
211
+ def _parse_run(output: str, default: str | None = None):
212
+ """`(passed, failed, failures)` from a merged `cargo test` console."""
213
+ passed: set[str] = set()
214
+ failed: set[str] = set()
215
+ failures: dict[str, str] = {}
216
+ target = default
217
+ # Position of each header, alongside the target it switches to — used to
218
+ # attribute a failure block by the header most recently seen before it,
219
+ # since two targets can share a test path (e.g. both name a test
220
+ # `tests::it_works`) and a path-only lookup would collide between them.
221
+ header_spans: list[tuple[int, str | None]] = []
222
+ pos = 0
223
+ for line in output.splitlines(keepends=True):
224
+ header = _RUNNING_RE.match(line)
225
+ if header:
226
+ target = _target_of_header(header)
227
+ header_spans.append((pos, target))
228
+ elif _DOCTEST_RE.match(line):
229
+ target = None
230
+ header_spans.append((pos, target))
231
+ else:
232
+ entry = _TEST_LINE_RE.match(line)
233
+ if entry and target is not None:
234
+ path, outcome = entry.groups()
235
+ tid = f"{target}::{path}"
236
+ if outcome == "ok":
237
+ passed.add(tid)
238
+ elif outcome == "FAILED":
239
+ failed.add(tid)
240
+ pos += len(line)
241
+
242
+ def _target_at(offset: int) -> str | None:
243
+ current = default
244
+ for start, tgt in header_spans:
245
+ if start > offset:
246
+ break
247
+ current = tgt
248
+ return current
249
+
250
+ # Failure blocks: from `---- <path> stdout ----` up to the next block or
251
+ # the trailing `failures:` summary list.
252
+ blocks = list(_FAILURE_BLOCK_RE.finditer(output))
253
+ for i, m in enumerate(blocks):
254
+ end = blocks[i + 1].start() if i + 1 < len(blocks) else len(output)
255
+ body = output[m.end() : end]
256
+ body = body.split("\nfailures:\n", 1)[0].strip()
257
+ path = m.group(1)
258
+ tid = f"{_target_at(m.start()) or 'lib'}::{path}"
259
+ failures[tid] = clip_failure(body) if body else "test failed"
260
+ return passed, failed, failures
261
+
262
+ @staticmethod
263
+ def _errors(combined: str) -> str:
264
+ lines = [
265
+ ln
266
+ for ln in combined.splitlines()
267
+ if _COMPILE_ERROR_RE.match(ln) or "-->" in ln or "could not compile" in ln
268
+ ]
269
+ return "\n".join(lines) if lines else combined
270
+
271
+ def run(self, target: str | None = None) -> Verdict:
272
+ verdict = Verdict(project=self.project.name, adapter=self.name, target=target)
273
+ env = self._suite_env(None)
274
+
275
+ native = self.strip(target) if target is not None else None
276
+ if native is not None:
277
+ cmd = self._targeted_cmd(native)
278
+ default = native.partition("::")[0]
279
+ else:
280
+ cmd = self._test_cmd()
281
+ default = None
282
+
283
+ started = time.monotonic()
284
+ code, out, err = self._run_suite(f"{cmd} 2>&1", env)
285
+ verdict.duration_ms = int((time.monotonic() - started) * 1000)
286
+ combined = out + err
287
+
288
+ passed, failed, failures = self._parse_run(combined, default=default)
289
+ verdict.passed = sorted(self.qualify(t) for t in passed)
290
+ verdict.failed = sorted(self.qualify(t) for t in failed)
291
+
292
+ if not passed and not failed:
293
+ if _COULD_NOT_COMPILE_RE.search(combined) or (
294
+ code != 0 and _COMPILE_ERROR_RE.search(combined)
295
+ ):
296
+ errors = clip_failure(self._errors(combined))
297
+ if target is not None:
298
+ verdict.target_outcome = NOT_COLLECTED
299
+ verdict.target_failure = errors
300
+ else:
301
+ verdict.error = errors
302
+ elif target is not None and _NO_LIB_RE.search(combined):
303
+ verdict.target_outcome = NOT_COLLECTED
304
+ verdict.target_failure = clip_failure(combined)
305
+ # else: filter matched nothing → NOT_FOUND; empty full run is clean
306
+ return verdict
307
+
308
+ if target is None or native is None:
309
+ return verdict
310
+
311
+ if target in verdict.passed:
312
+ verdict.target_outcome = PASSED
313
+ elif target in verdict.failed:
314
+ verdict.target_outcome = FAILED
315
+ text = failures.get(native, "test failed")
316
+ verdict.target_failure = text
317
+ verdict.target_evidence = next(
318
+ (ln for ln in text.splitlines() if "panicked" in ln or "assertion" in ln),
319
+ next((ln for ln in text.splitlines() if ln.strip()), ""),
320
+ )
321
+ return verdict
@@ -43,6 +43,21 @@ RUNNER_MARKERS = (
43
43
  )
44
44
 
45
45
 
46
+ def _strip_xdist_group(nodeid: str) -> str:
47
+ """The plain node id behind xdist's `--dist loadgroup` spelling.
48
+
49
+ A test marked `xdist_group("<g>")` is reported as `<nodeid>@<g>` and pytest-json-report
50
+ records it that way; collected ids and declared targets carry no suffix, so the report
51
+ side is canonicalised here. Only an `@` after the last `]` can be xdist's: function names
52
+ cannot contain one, and parametrised values (`test_x[user@example.com]`) live inside the
53
+ brackets.
54
+ """
55
+ at = nodeid.rfind("@")
56
+ if at == -1 or at < nodeid.rfind("]"):
57
+ return nodeid
58
+ return nodeid[:at]
59
+
60
+
46
61
  class PytestAdapter(Adapter):
47
62
  name = "pytest"
48
63
 
@@ -121,7 +136,9 @@ class PytestAdapter(Adapter):
121
136
  verdict.duration_ms += int(report.get("duration", 0) * 1000)
122
137
  tests.extend(report.get("tests", []))
123
138
  collectors.extend(report.get("collectors", []))
124
- suite_ids.append({t["nodeid"] for t in report.get("tests", [])})
139
+ suite_ids.append(
140
+ {_strip_xdist_group(t["nodeid"]) for t in report.get("tests", [])}
141
+ )
125
142
 
126
143
  overlap = _suite_overlap(suite_ids)
127
144
  if overlap:
@@ -134,7 +151,7 @@ class PytestAdapter(Adapter):
134
151
  uncollectable.add(collector.get("nodeid", ""))
135
152
 
136
153
  for test in tests:
137
- qualified = self.qualify(test["nodeid"])
154
+ qualified = self.qualify(_strip_xdist_group(test["nodeid"]))
138
155
  if test["outcome"] == "passed":
139
156
  verdict.passed.append(qualified)
140
157
  elif test["outcome"] in ("failed", "error"):
@@ -145,7 +162,7 @@ class PytestAdapter(Adapter):
145
162
  return verdict
146
163
 
147
164
  native = self.strip(target)
148
- hit = next((t for t in tests if t["nodeid"] == native), None)
165
+ hit = next((t for t in tests if _strip_xdist_group(t["nodeid"]) == native), None)
149
166
  if hit is not None:
150
167
  verdict.target_outcome = PASSED if hit["outcome"] == "passed" else FAILED
151
168
  call = hit.get("call") or hit.get("setup") or {}
@@ -268,6 +285,7 @@ class PytestAdapter(Adapter):
268
285
  for line in out.splitlines():
269
286
  line = line.strip()
270
287
  if "::" in line and not line.startswith(("=", "-", "no tests")):
288
+ line = _strip_xdist_group(line)
271
289
  tests.add(self.qualify(line))
272
290
  files.add(line.split("::", 1)[0])
273
291
  return tests, files
@@ -64,7 +64,6 @@ def repo(tmp_path, ledger_home):
64
64
  ".pytest_cache/\n__pycache__/\n*.pyc\n.coverage\njsonreport*.json\n"
65
65
  )
66
66
 
67
- git(root.parent, "init", "-q", str(root)) if False else None
68
67
  subprocess.run(["git", "init", "-q", str(root)], check=True)
69
68
  git(root, "config", "user.email", "test@example.com")
70
69
  git(root, "config", "user.name", "Test")