From d6a8433ea8f8d026ab1e92aebb418dafd73fab6b Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:07:02 +0100 Subject: [PATCH 01/16] test: plan register refuses a pytest target with no :: TDD-Run: 13 TDD-Cycle: 1 TDD-Phase: red --- tests/test_target_lint.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_target_lint.py diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py new file mode 100644 index 0000000..c72f4f0 --- /dev/null +++ b/tests/test_target_lint.py @@ -0,0 +1,25 @@ +"""Tests for target lint: grammar and root-prefix validation at plan register / run start.""" +from __future__ import annotations + +from conftest import run_cli, write_plan + +_PLAN_NO_SEP = """--- +cycles: + - n: 1 + project: backend + title: "register refuses a pytest target without the :: separator" + test: "tests/test_add.py" + files: [] +--- +""" + + +def test_register_refuses_a_pytest_target_without_separator(repo): + plan = write_plan(repo, _PLAN_NO_SEP) + out = run_cli(repo, "plan", "register", plan) + assert out["ok"] is False + assert out["result"]["reason"] == "target_lint" + findings = out["result"]["findings"] + assert len(findings) == 1 + assert findings[0]["cycle"] == 1 + assert "::" in findings[0]["problem"] From c1b262f7d93b97c78d6deb443fa897df84ec0857 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:14:07 +0100 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20target=20lint=20=E2=80=94=20adapt?= =?UTF-8?q?er=20id-grammar=20hook,=20wired=20into=20plan=20register?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD-Run: 13 TDD-Cycle: 1 TDD-Phase: green --- src/tddcli/adapters/base.py | 8 ++++ src/tddcli/adapters/pytest_adapter.py | 8 ++++ src/tddcli/cli.py | 10 +++++ src/tddcli/target_lint.py | 61 +++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 src/tddcli/target_lint.py diff --git a/src/tddcli/adapters/base.py b/src/tddcli/adapters/base.py index e1801af..7ce42b6 100644 --- a/src/tddcli/adapters/base.py +++ b/src/tddcli/adapters/base.py @@ -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" diff --git a/src/tddcli/adapters/pytest_adapter.py b/src/tddcli/adapters/pytest_adapter.py index 35a6fc6..b7897f4 100644 --- a/src/tddcli/adapters/pytest_adapter.py +++ b/src/tddcli/adapters/pytest_adapter.py @@ -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.""" diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index 2aea8d1..f2233c9 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -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 @@ -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), diff --git a/src/tddcli/target_lint.py b/src/tddcli/target_lint.py new file mode 100644 index 0000000..3d57cda --- /dev/null +++ b/src/tddcli/target_lint.py @@ -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 '/', 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 From 7c997c020230fbbae87f36737c32d5ccc2e3d2de Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:17:48 +0100 Subject: [PATCH 03/16] test: vitest grammar lint requires the ' > ' separator TDD-Run: 13 TDD-Cycle: 2 TDD-Phase: red --- tests/test_target_lint.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index c72f4f0..a648011 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -1,8 +1,27 @@ """Tests for target lint: grammar and root-prefix validation at plan register / run start.""" from __future__ import annotations +from pathlib import Path + +from tddcli import config as config_mod +from tddcli.adapters.vitest_adapter import VitestAdapter + from conftest import run_cli, write_plan +_VITEST_TOML = """ +[project.frontend] +root = "frontend" +adapter = "vitest" +test_paths = ["**/*.test.ts"] +""" + + +def vitest_adapter_for(tmp_path: Path) -> VitestAdapter: + (tmp_path / "tdd.toml").write_text(_VITEST_TOML) + (tmp_path / "frontend").mkdir() + cfg = config_mod.load(tmp_path) + return VitestAdapter(cfg.project("frontend"), tmp_path) + _PLAN_NO_SEP = """--- cycles: - n: 1 @@ -14,6 +33,13 @@ """ +def test_vitest_target_without_describe_separator_is_flagged(tmp_path): + adapter = vitest_adapter_for(tmp_path) + msg = adapter.lint_target_id("a.test.ts::does a thing") + assert msg + assert " > " in msg + + def test_register_refuses_a_pytest_target_without_separator(repo): plan = write_plan(repo, _PLAN_NO_SEP) out = run_cli(repo, "plan", "register", plan) From 4ffdbf073a01eb0ba083e7757c987aa263260368 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:19:43 +0100 Subject: [PATCH 04/16] feat: vitest lint_target_id flags ids missing ' > ' TDD-Run: 13 TDD-Cycle: 2 TDD-Phase: green --- src/tddcli/adapters/vitest_adapter.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tddcli/adapters/vitest_adapter.py b/src/tddcli/adapters/vitest_adapter.py index 3eaa5fd..a0cc315 100644 --- a/src/tddcli/adapters/vitest_adapter.py +++ b/src/tddcli/adapters/vitest_adapter.py @@ -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: > " + ) + 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. From aae238fc87438e65d88302c282e3f57984c250ab Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:21:37 +0100 Subject: [PATCH 05/16] refactor: a vitest target without ' > ' is flagged by the grammar hook TDD-Run: 13 TDD-Cycle: 2 TDD-Phase: refactor --- tests/test_target_lint.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index a648011..96e5449 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -3,11 +3,10 @@ from pathlib import Path +from conftest import run_cli, write_plan from tddcli import config as config_mod from tddcli.adapters.vitest_adapter import VitestAdapter -from conftest import run_cli, write_plan - _VITEST_TOML = """ [project.frontend] root = "frontend" From 58ea0fd1a2c23797dc6f76ebcaa29b05c58a2f96 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:25:24 +0100 Subject: [PATCH 06/16] test: gradle grammar lint requires the classname/method slash TDD-Run: 13 TDD-Cycle: 3 TDD-Phase: red --- tests/test_target_lint.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index 96e5449..f415bfa 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -5,6 +5,7 @@ from conftest import run_cli, write_plan from tddcli import config as config_mod +from tddcli.adapters.gradle_adapter import GradleAdapter from tddcli.adapters.vitest_adapter import VitestAdapter _VITEST_TOML = """ @@ -14,6 +15,14 @@ test_paths = ["**/*.test.ts"] """ +_GRADLE_TOML = """ +[project.app] +root = "app" +adapter = "gradle" +test_paths = ["src/test/"] +test_command = "./gradlew test" +""" + def vitest_adapter_for(tmp_path: Path) -> VitestAdapter: (tmp_path / "tdd.toml").write_text(_VITEST_TOML) @@ -32,6 +41,20 @@ def vitest_adapter_for(tmp_path: Path) -> VitestAdapter: """ +def gradle_adapter_for(tmp_path: Path) -> GradleAdapter: + (tmp_path / "tdd.toml").write_text(_GRADLE_TOML) + (tmp_path / "app" / "src" / "test").mkdir(parents=True) + cfg = config_mod.load(tmp_path) + return GradleAdapter(cfg.project("app"), tmp_path) + + +def test_gradle_target_without_slash_is_flagged(tmp_path): + adapter = gradle_adapter_for(tmp_path) + msg = adapter.lint_target_id("com.foo.BarTest.testBaz") + assert msg + assert "/" in msg + + def test_vitest_target_without_describe_separator_is_flagged(tmp_path): adapter = vitest_adapter_for(tmp_path) msg = adapter.lint_target_id("a.test.ts::does a thing") From b1b852681793fa6f7272f23ee4bffc24f1cbaa69 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:27:22 +0100 Subject: [PATCH 07/16] feat: gradle lint_target_id flags ids missing the / separator TDD-Run: 13 TDD-Cycle: 3 TDD-Phase: green --- src/tddcli/adapters/gradle_adapter.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/tddcli/adapters/gradle_adapter.py b/src/tddcli/adapters/gradle_adapter.py index 488e159..83b00db 100644 --- a/src/tddcli/adapters/gradle_adapter.py +++ b/src/tddcli/adapters/gradle_adapter.py @@ -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 # ------------------------------------------------------------------ From baa62ccdc1b866a4e6db4602746b103f117eca35 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:31:13 +0100 Subject: [PATCH 08/16] test: xctest grammar lint requires Bundle/Class/testMethod TDD-Run: 13 TDD-Cycle: 4 TDD-Phase: red --- tests/test_target_lint.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index f415bfa..1f62f74 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -7,6 +7,7 @@ from tddcli import config as config_mod from tddcli.adapters.gradle_adapter import GradleAdapter from tddcli.adapters.vitest_adapter import VitestAdapter +from tddcli.adapters.xctest_adapter import XCTestAdapter _VITEST_TOML = """ [project.frontend] @@ -23,6 +24,14 @@ test_command = "./gradlew test" """ +_XCTEST_TOML = """ +[project.ios] +root = "ios" +adapter = "xctest" +test_paths = ["AppTests/"] +test_command = "xcodebuild test -scheme AppTests" +""" + def vitest_adapter_for(tmp_path: Path) -> VitestAdapter: (tmp_path / "tdd.toml").write_text(_VITEST_TOML) @@ -41,6 +50,13 @@ def vitest_adapter_for(tmp_path: Path) -> VitestAdapter: """ +def xctest_adapter_for(tmp_path: Path) -> XCTestAdapter: + (tmp_path / "tdd.toml").write_text(_XCTEST_TOML) + (tmp_path / "ios" / "AppTests").mkdir(parents=True) + cfg = config_mod.load(tmp_path) + return XCTestAdapter(cfg.project("ios"), tmp_path) + + def gradle_adapter_for(tmp_path: Path) -> GradleAdapter: (tmp_path / "tdd.toml").write_text(_GRADLE_TOML) (tmp_path / "app" / "src" / "test").mkdir(parents=True) @@ -48,6 +64,13 @@ def gradle_adapter_for(tmp_path: Path) -> GradleAdapter: return GradleAdapter(cfg.project("app"), tmp_path) +def test_xctest_target_without_three_parts_is_flagged(tmp_path): + adapter = xctest_adapter_for(tmp_path) + msg = adapter.lint_target_id("AppTests.RecTests.testStopsRecording") + assert msg + assert "Bundle/Class" in msg + + def test_gradle_target_without_slash_is_flagged(tmp_path): adapter = gradle_adapter_for(tmp_path) msg = adapter.lint_target_id("com.foo.BarTest.testBaz") From b6bcb7862b115870041f11fe0781aee98bbfd309 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:33:08 +0100 Subject: [PATCH 09/16] feat: xctest lint_target_id flags ids without three slash-parts TDD-Run: 13 TDD-Cycle: 4 TDD-Phase: green --- src/tddcli/adapters/xctest_adapter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tddcli/adapters/xctest_adapter.py b/src/tddcli/adapters/xctest_adapter.py index f1c8e20..ac12cfc 100644 --- a/src/tddcli/adapters/xctest_adapter.py +++ b/src/tddcli/adapters/xctest_adapter.py @@ -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 # ------------------------------------------------------------------ From 0aaf325c31846c1c5d30d3ae7e99c8f4bb38011d Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:38:54 +0100 Subject: [PATCH 10/16] refactor: register refuses a target that duplicates the project root prefix TDD-Run: 13 TDD-Cycle: 5 TDD-Phase: refactor --- tests/test_target_lint.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index 1f62f74..7610f15 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -85,6 +85,30 @@ def test_vitest_target_without_describe_separator_is_flagged(tmp_path): assert " > " in msg +def test_register_refuses_a_root_duplicated_pytest_target(repo): + (repo / "tdd.toml").write_text( + "[project.proj]\n" + 'root = "backend"\n' + 'adapter = "pytest"\n' + 'test_paths = ["tests/"]\n' + "lint = []\n" + "typecheck = []\n" + ) + import subprocess + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "swap project name"], check=True) + plan = write_plan( + repo, + "---\ncycles:\n - n: 1\n project: proj\n test: \"backend/tests/test_add.py::test_add\"\n files: []\n---\n", + ) + out = run_cli(repo, "plan", "register", plan) + assert out["ok"] is False + assert out["result"]["reason"] == "target_lint" + findings = out["result"]["findings"] + assert len(findings) == 1 + assert findings[0]["suggestion"] == "tests/test_add.py::test_add" + + def test_register_refuses_a_pytest_target_without_separator(repo): plan = write_plan(repo, _PLAN_NO_SEP) out = run_cli(repo, "plan", "register", plan) From 503d5b8e8107a9f5fc6aed2d0e71f4def786ce7c Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:44:26 +0100 Subject: [PATCH 11/16] refactor: a genuinely nested root-named path is not flagged TDD-Run: 13 TDD-Cycle: 6 TDD-Phase: refactor --- tests/test_target_lint.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index 7610f15..3ec1460 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -85,6 +85,28 @@ def test_vitest_target_without_describe_separator_is_flagged(tmp_path): assert " > " in msg +def test_register_accepts_a_genuinely_nested_root_path(repo): + (repo / "tdd.toml").write_text( + "[project.proj]\n" + 'root = "backend"\n' + 'adapter = "pytest"\n' + 'test_paths = ["tests/"]\n' + "lint = []\n" + "typecheck = []\n" + ) + (repo / "backend" / "backend" / "tests").mkdir(parents=True) + (repo / "backend" / "backend" / "tests" / "test_add.py").write_text("def test_add(): pass\n") + import subprocess + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "nested root"], check=True) + plan = write_plan( + repo, + "---\ncycles:\n - n: 1\n project: proj\n test: \"backend/tests/test_add.py::test_add\"\n files: []\n---\n", + ) + out = run_cli(repo, "plan", "register", plan) + assert out["ok"] is True + + def test_register_refuses_a_root_duplicated_pytest_target(repo): (repo / "tdd.toml").write_text( "[project.proj]\n" From 6fe3b74c527da11e3bc3e4e1a4085c3e5097e30d Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:49:54 +0100 Subject: [PATCH 12/16] refactor: register refuses a root-duplicated vitest target TDD-Run: 13 TDD-Cycle: 7 TDD-Phase: refactor --- tests/test_target_lint.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index 3ec1460..0f247be 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -85,6 +85,31 @@ def test_vitest_target_without_describe_separator_is_flagged(tmp_path): assert " > " in msg +def test_register_refuses_a_root_duplicated_vitest_target(tmp_path, ledger_home): + (tmp_path / "tdd.toml").write_text( + "[project.proj]\n" + 'root = "scripts"\n' + 'adapter = "vitest"\n' + 'test_paths = ["**/*.test.js"]\n' + "lint = []\n" + "typecheck = []\n" + ) + (tmp_path / "scripts").mkdir() + import subprocess + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "t@t.com"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "T"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-q", "-m", "init"], check=True) + plan = write_plan( + tmp_path, + '---\ncycles:\n - n: 1\n project: proj\n test: "scripts/__tests__/x.test.js > does a thing"\n files: []\n---\n', + ) + out = run_cli(tmp_path, "plan", "register", plan) + assert out["ok"] is False + assert out["result"]["reason"] == "target_lint" + + def test_register_accepts_a_genuinely_nested_root_path(repo): (repo / "tdd.toml").write_text( "[project.proj]\n" From e65bcbece63360baa0edf8a5b76826b9d4798369 Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:53:27 +0100 Subject: [PATCH 13/16] test: run start re-lints the stored contract against current config TDD-Run: 13 TDD-Cycle: 8 TDD-Phase: red --- tests/test_target_lint.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_target_lint.py b/tests/test_target_lint.py index 0f247be..5c7d0f4 100644 --- a/tests/test_target_lint.py +++ b/tests/test_target_lint.py @@ -85,6 +85,41 @@ def test_vitest_target_without_describe_separator_is_flagged(tmp_path): assert " > " in msg +def test_run_start_refuses_lint_findings_from_config_drift(repo): + (repo / "tdd.toml").write_text( + "[project.proj]\n" + 'root = "."\n' + 'adapter = "pytest"\n' + 'test_paths = ["backend/tests/"]\n' + "lint = []\n" + "typecheck = []\n" + ) + import subprocess + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "root=."], check=True) + plan = write_plan( + repo, + '---\ncycles:\n - n: 1\n project: proj\n test: "backend/tests/test_smoke.py::test_smoke"\n files: []\n---\n', + ) + reg = run_cli(repo, "plan", "register", plan) + assert reg["ok"], reg + + (repo / "tdd.toml").write_text( + "[project.proj]\n" + 'root = "backend"\n' + 'adapter = "pytest"\n' + 'test_paths = ["tests/"]\n' + "lint = []\n" + "typecheck = []\n" + ) + subprocess.run(["git", "-C", str(repo), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", "drift root"], check=True) + + out = run_cli(repo, "run", "start", "--plan", plan) + assert out["ok"] is False + assert out["result"]["reason"] == "target_lint" + + def test_register_refuses_a_root_duplicated_vitest_target(tmp_path, ledger_home): (tmp_path / "tdd.toml").write_text( "[project.proj]\n" From 43bd20f81bb609e0f04783348b4650de6becd9ea Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 17:58:16 +0100 Subject: [PATCH 14/16] feat: target lint gates run start before the baseline claim TDD-Run: 13 TDD-Cycle: 8 TDD-Phase: green --- src/tddcli/cli.py | 10 ++++++++++ tests/test_heartbeat.py | 13 +++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index f2233c9..725c087 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -641,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) diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index ff86bf4..0bcdacf 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -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 @@ -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 From 46bf7caef4d5caae2f13ba572de3e5ad4291735b Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 18:00:46 +0100 Subject: [PATCH 15/16] docs: extend register and run-start PRD rows with target lint --- docs/PRD.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 3db41b8..4ce9682 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 ` | parse front-matter, resolve plan blob at HEAD, store contract | -| `tdd run start --plan ` | create run; capture executor identity from environment; capture per-project baselines; verify artifact freshness; open cycle 1 | +| `tdd plan register ` | 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 ` | 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 | From 673c8c8440bd7e976db2b1e0275eec132f919d1a Mon Sep 17 00:00:00 2001 From: geuben Date: Fri, 28 Aug 2026 18:00:49 +0100 Subject: [PATCH 16/16] docs: friction log for issue-71-target-lint --- .../issue-71-target-lint-friction.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tasks/friction-logs/issue-71-target-lint-friction.md diff --git a/tasks/friction-logs/issue-71-target-lint-friction.md b/tasks/friction-logs/issue-71-target-lint-friction.md new file mode 100644 index 0000000..2ab209d --- /dev/null +++ b/tasks/friction-logs/issue-71-target-lint-friction.md @@ -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) +