Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,8 @@ one has no move left but to re-run doctor and read the same output again.
### 8.2 Registration
| Command | Behaviour |
|---|---|
| `tdd plan register <path>` | parse front-matter, resolve plan blob at HEAD, store contract |
| `tdd run start --plan <path\|id>` | create run; capture executor identity from environment; capture per-project baselines; verify artifact freshness; open cycle 1 |
| `tdd plan register <path>` | parse front-matter, resolve plan blob at HEAD, lint declared targets (grammar + root-prefix rules), store contract. Refuses with `reason: "target_lint"` and a `findings` list when any target can't match a collected id — e.g. pytest target missing `::`, vitest missing ` > `, gradle/xctest wrong separator, or target path that duplicates the project's `root`. Recovery: fix the spelling (the finding often carries a `suggestion`), or for a genuinely nested root path, create the directory first (the filesystem-existence check then exempts it). |
| `tdd run start --plan <path\|id>` | re-lints the stored contract's declared targets against the *current* `tdd.toml` (catching root/adapter drift since registration) before claiming the worktree — refuses with `reason: "target_lint"` if findings appear; then creates run, captures executor identity from environment, captures per-project baselines, verifies artifact freshness, opens cycle 1 |

### 8.3 The loop
| Command | Behaviour |
Expand Down
8 changes: 8 additions & 0 deletions src/tddcli/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ def _suite_env(self, override) -> dict[str, str] | None:
return None
return {k: os.path.expandvars(v) for k, v in merged.items()}

def lint_target_id(self, native: str) -> str | None:
"""Return a problem message when `native` can never match a collected id, else None."""
return None

def target_path(self, native: str) -> str | None:
"""Return the file-path portion of `native`, or None for non-path-bearing ids."""
return None

def stub_hint(self) -> str:
"""The language idiom for a stub body, quoted into the create_stub directive."""
return "a body that fails loudly, never working logic"
Expand Down
8 changes: 8 additions & 0 deletions src/tddcli/adapters/gradle_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ def stub_hint(self) -> str:
" assertion failure"
)

def lint_target_id(self, native: str) -> str | None:
if "/" not in native:
return (
f"gradle target ids must contain '/' between class and method "
f"(got {native!r}); expected shape: com.example.ClassName/testMethodName"
)
return None

# ------------------------------------------------------------------
# Core command
# ------------------------------------------------------------------
Expand Down
8 changes: 8 additions & 0 deletions src/tddcli/adapters/pytest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class PytestAdapter(Adapter):
def stub_hint(self) -> str:
return "`raise NotImplementedError` in every body"

def lint_target_id(self, native: str) -> str | None:
if "::" not in native:
return f"pytest target ids must contain '::' (got {native!r}); expected shape: path/to/test_file.py::test_name"
return None

def target_path(self, native: str) -> str | None:
return native.split("::", 1)[0]

def _runner_prefix(self) -> str:
"""The project root is checked before the worktree root: a workspace keeps
one lockfile at the top, but a member with its own marker owns its choice."""
Expand Down
11 changes: 11 additions & 0 deletions src/tddcli/adapters/vitest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ class VitestAdapter(Adapter):
def stub_hint(self) -> str:
return '`throw new Error("not implemented")` in every body'

def lint_target_id(self, native: str) -> str | None:
if " > " not in native:
return (
f"vitest target ids must contain ' > ' between the file and test name "
f"(got {native!r}); expected shape: <file> > <describe titles> <test title>"
)
return None

def target_path(self, native: str) -> str | None:
return native.split(" > ", 1)[0]

def normalise_id(self, test_id: str) -> str:
"""Canonicalise the describe/test separator for target matching.

Expand Down
9 changes: 9 additions & 0 deletions src/tddcli/adapters/xctest_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ def stub_hint(self) -> str:
" compiles first, then observe the assertion failure"
)

def lint_target_id(self, native: str) -> str | None:
parts = native.split("/")
if len(parts) != 3 or any(not p for p in parts):
return (
f"xctest target ids must be exactly three '/'-separated parts "
f"(got {native!r}); expected shape: Bundle/Class/testMethod"
)
return None

# ------------------------------------------------------------------
# Core command
# ------------------------------------------------------------------
Expand Down
20 changes: 20 additions & 0 deletions src/tddcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from . import (
contract as contract_mod,
)
from . import target_lint as target_lint_mod
from .adapters.base import FAILED, NOT_COLLECTED
from .advance import advance as do_advance
from .envelope import Envelope, NextAction, Verb, failure, heartbeat
Expand Down Expand Up @@ -460,6 +461,15 @@ def cmd_plan_register(args) -> Envelope:
plan=rel,
)

if parsed.cycles:
lint_findings = target_lint_mod.lint_cycles(parsed.cycles, cfg, worktree)
if lint_findings:
return failure(
"declared targets failed lint",
reason="target_lint",
findings=lint_findings,
)

existing = ledger.one(
"SELECT * FROM plan_contract WHERE plan_path = ? AND git_blob_sha IS ?",
(rel, parsed.blob_sha),
Expand Down Expand Up @@ -631,6 +641,16 @@ def cmd_run_start(args) -> Envelope:

# R9.5c — scope baseline capture to plan-reachable projects unless opted out.
declared_cycles = contract_mod.cycles_from_json(contract_row["declared_cycles"])

if declared_cycles:
lint_findings = target_lint_mod.lint_cycles(declared_cycles, cfg, worktree)
if lint_findings:
return failure(
"declared targets failed lint",
reason="target_lint",
findings=lint_findings,
)

if declared_cycles and not args.baseline_all:
declared_names = [p for c in declared_cycles for p in c.projects]
reachable_names = cfg.reachable_projects(declared_names)
Expand Down
61 changes: 61 additions & 0 deletions src/tddcli/target_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Static lint of declared targets: grammar and root-prefix rules."""
from __future__ import annotations

from pathlib import Path

from . import adapters
from .machine import Engine


def lint_cycles(cycles, cfg, worktree: Path) -> list[dict]:
"""Return findings for any cycle whose declared targets fail static lint.

Grammar rule: each adapter's `lint_target_id` returns a problem string when the
native id can never match a collected id (e.g. pytest target missing '::').

Root-prefix rule: when a project's root != '.' and the target's path portion
starts with '<root>/', flag it — unless the actual nested path (or its parent
directory) exists in the worktree, which signals a genuine nested root layout.
"""
findings = []
for cycle in cycles:
if cycle.kind == "refactor":
continue
for test_id in cycle.tests:
qualified = Engine._qualify(cycle, test_id)
project_name, native = qualified.split("::", 1)
try:
project = cfg.project(project_name)
except Exception:
continue
adapter = adapters.build(project, worktree)

lint_fn = getattr(adapter, "lint_target_id", lambda n: None)
problem = lint_fn(native)
if problem:
findings.append({"cycle": cycle.ordinal, "project": project_name, "test": test_id, "problem": problem})
continue

path_fn = getattr(adapter, "target_path", lambda n: None)
path_part = path_fn(native)
if path_part is not None and project.root != ".":
root_prefix = project.root + "/"
if path_part.startswith(root_prefix):
stripped = path_part[len(root_prefix):]
nested = worktree / project.root / path_part
if not nested.exists() and not nested.parent.exists():
suffix = native[len(path_part):]
suggestion = stripped + suffix
findings.append({
"cycle": cycle.ordinal,
"project": project_name,
"test": test_id,
"problem": (
f"target path {path_part!r} duplicates the project root {project.root!r}; "
f"the collected id would be {stripped + suffix!r}. "
f"To register a genuinely nested path, create the directory first."
),
"suggestion": suggestion,
})

return findings
94 changes: 94 additions & 0 deletions tasks/friction-logs/issue-71-target-lint-friction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Implementation Friction Log: tasks/issue-71-target-lint.md

- Run: 13
- Executor: claude-sonnet-4-6 (source: transcript)
- Plan blob: `0b145a79e6fb5802fb1cd46470ef4a7061535fd6` (declared)
- Started: 2026-08-28T16:04:38.652931+00:00 Ended: 2026-08-28T16:59:53.241200+00:00 Outcome: complete
- Baseline failures at start: tddcli=0

## Plan fidelity

- Declared cycles: 8
- Delivered: 8 Skipped: 0
- Never reached: none
- Human interventions: 0

### Cycle 8: run start refuses lint findings introduced by config drift _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_run_start_refuses_lint_findings_from_config_drift`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 2, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `e65bcbece` [red] test: run start re-lints the stored contract against current config (1 files)
- `43bd20f81` [green] feat: target lint gates run start before the baseline claim (2 files)

### Cycle 7: register refuses a root-duplicated vitest target _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_register_refuses_a_root_duplicated_vitest_target`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (**passed**)
- **Sensitivity check:** verified, restore byte-identical
- observed: `tmp_path = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-210/test_register_refuses_a_root_d0')`
- **Commits:**
- `6fe3b74c5` [refactor] refactor: register refuses a root-duplicated vitest target (1 files)
- **Event — red_first_violation:** ["tddcli::tests/test_target_lint.py::test_register_refuses_a_root_duplicated_vitest_target"]

### Cycle 6: a genuinely nested root-named path is not flagged _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_register_accepts_a_genuinely_nested_root_path`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (**passed**)
- **Sensitivity check:** verified, restore byte-identical
- observed: `repo = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-202/test_register_accepts_a_genuin0/workspace')`
- **Commits:**
- `503d5b8e8` [refactor] refactor: a genuinely nested root-named path is not flagged (1 files)
- **Event — red_first_violation:** ["tddcli::tests/test_target_lint.py::test_register_accepts_a_genuinely_nested_root_path"]

### Cycle 5: register refuses a target that duplicates the project root prefix _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_register_refuses_a_root_duplicated_pytest_target`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** passed (**passed**)
- **Sensitivity check:** verified, restore byte-identical
- observed: `repo = PosixPath('/private/var/folders/zl/3010c_557g5_2rm9tyqsc03h0000gp/T/pytest-of-headless-coding/pytest-193/test_register_refuses_a_root_d0/workspace')`
- **Commits:**
- `0aaf325c3` [refactor] refactor: register refuses a target that duplicates the project root prefix (1 files)
- **Event — red_first_violation:** ["tddcli::tests/test_target_lint.py::test_register_refuses_a_root_duplicated_pytest_target"]

### Cycle 4: an xctest target without Bundle/Class/method shape is flagged _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_xctest_target_without_three_parts_is_flagged`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `baa62ccdc` [red] test: xctest grammar lint requires Bundle/Class/testMethod (1 files)
- `b6bcb7862` [green] feat: xctest lint_target_id flags ids without three slash-parts (1 files)

### Cycle 3: a gradle target without the class/method slash is flagged _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_gradle_target_without_slash_is_flagged`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `58ea0fd1a` [red] test: gradle grammar lint requires the classname/method slash (1 files)
- `b1b852681` [green] feat: gradle lint_target_id flags ids missing the / separator (1 files)

### Cycle 2: a vitest target without ' > ' is flagged by the grammar hook _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_vitest_target_without_describe_separator_is_flagged`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2}
- **First run outcome:** failed (as expected)
- **Commits:**
- `7c997c020` [red] test: vitest grammar lint requires the ' > ' separator (1 files)
- `4ffdbf073` [green] feat: vitest lint_target_id flags ids missing ' > ' (1 files)
- `aae238fc8` [refactor] refactor: a vitest target without ' > ' is flagged by the grammar hook (1 files)

### Cycle 1: register refuses a pytest target without the :: separator _(standard)_
- **Target:** `tddcli::tests/test_target_lint.py::test_register_refuses_a_pytest_target_without_separator`
- **Projects:** `tddcli`
- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 2, 'CLOSE_SWEEP': 1}
- **First run outcome:** failed (as expected)
- **Commits:**
- `d6a8433ea` [red] test: plan register refuses a pytest target with no :: (1 files)
- `c1b262f7d` [green] feat: target lint — adapter id-grammar hook, wired into plan register (4 files)

13 changes: 7 additions & 6 deletions tests/test_heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ def test_baseline_heartbeat_reports_elapsed_seconds(repo, capsys):

def test_claim_records_projects_done_as_each_completes(repo_multi, monkeypatch):
"""Seam, proven by P6: `monkeypatch.setattr(adapters, "build", spy)`. `build` is
called once per project in `tdd.toml` order (`['backend', 'frontend']`), so the
row seen on the second call reports `projects_done == 1` and
`projects_total == 2`."""
called once per project in `tdd.toml` order (`['backend', 'frontend']`) for baseline
probing (plus once for target lint before the claim), so the row seen on the last
call reports `projects_done == 1` and `projects_total == 2`."""
plan = write_plan(repo_multi, PLAN_MULTI)
run_cli(repo_multi, "plan", "register", plan)
real_build = adapters.build
Expand All @@ -122,9 +122,10 @@ def spy(project, worktree):
out = run_cli(repo_multi, "run", "start", "--plan", plan)
assert out["ok"], out

assert len(seen) == 2, seen
second = seen[1]
assert second is not None
# One lint call (no claim yet) + two baseline probes (claim present)
probe_seen = [s for s in seen if s is not None]
assert len(probe_seen) == 2, seen
second = probe_seen[1]
assert second["projects_done"] == 1, second
assert second["projects_total"] == 2, second

Expand Down
Loading