tdd-cli 0.11.0__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/CHANGELOG.md +19 -0
  2. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/PKG-INFO +1 -1
  3. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/docs/PRD.md +21 -2
  4. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/__init__.py +1 -1
  5. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/base.py +2 -3
  6. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/advance.py +1 -1
  7. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/ledger.py +8 -1
  8. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/machine.py +51 -15
  9. tdd_cli-0.12.0/tests/test_close_sweep_gates.py +361 -0
  10. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_release_surface.py +19 -0
  11. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/.gitignore +0 -0
  12. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/LICENSE +0 -0
  13. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/README.md +0 -0
  14. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/SECURITY.md +0 -0
  15. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/docs/harness-integration.md +0 -0
  16. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/claude-code-hooks/README.md +0 -0
  17. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/claude-code-hooks/bash_hook.py +0 -0
  18. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/claude-code-hooks/stop_hook.py +0 -0
  19. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/plan.md +0 -0
  20. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/skills/tdd-drive/README.md +0 -0
  21. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/skills/tdd-drive/SKILL.md +0 -0
  22. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/skills/tdd-handoff/README.md +0 -0
  23. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/examples/skills/tdd-handoff/SKILL.md +0 -0
  24. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/pyproject.toml +0 -0
  25. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/__init__.py +0 -0
  26. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/cargo_adapter.py +0 -0
  27. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/exec_adapter.py +0 -0
  28. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/gradle_adapter.py +0 -0
  29. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/pytest_adapter.py +0 -0
  30. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/vitest_adapter.py +0 -0
  31. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/adapters/xctest_adapter.py +0 -0
  32. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/cli.py +0 -0
  33. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/config.py +0 -0
  34. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/contract.py +0 -0
  35. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/docs.py +0 -0
  36. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/envelope.py +0 -0
  37. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/fleet.py +0 -0
  38. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/gitutil.py +0 -0
  39. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/identity.py +0 -0
  40. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/leases.py +0 -0
  41. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/plan_paths.py +0 -0
  42. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/render.py +0 -0
  43. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/snapshot.py +0 -0
  44. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/staging.py +0 -0
  45. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/src/tddcli/target_lint.py +0 -0
  46. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/conftest.py +0 -0
  47. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_advance_adoption.py +0 -0
  48. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_ancillary_files.py +0 -0
  49. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_artifact_regeneration.py +0 -0
  50. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_baseline_integrity.py +0 -0
  51. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_baseline_sanity.py +0 -0
  52. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_batch_collection.py +0 -0
  53. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_cargo_adapter.py +0 -0
  54. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_concurrent_advance.py +0 -0
  55. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_config_and_staging.py +0 -0
  56. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_config_drift.py +0 -0
  57. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_contract.py +0 -0
  58. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_docs_command.py +0 -0
  59. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_doctor_attribution.py +0 -0
  60. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_doctor_blockers.py +0 -0
  61. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_end_to_end.py +0 -0
  62. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_evidence_extraction.py +0 -0
  63. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_example_plan.py +0 -0
  64. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_exec_adapter.py +0 -0
  65. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_executor_attribution.py +0 -0
  66. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_executor_notes.py +0 -0
  67. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_failure_clipping.py +0 -0
  68. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_fleet.py +0 -0
  69. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_gradle_adapter.py +0 -0
  70. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_heartbeat.py +0 -0
  71. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_id_normalisation.py +0 -0
  72. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_init_detection.py +0 -0
  73. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_late_baseline.py +0 -0
  74. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_named_leases_and_timeout.py +0 -0
  75. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_pin_cycles.py +0 -0
  76. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_plan_paths.py +0 -0
  77. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_progress.py +0 -0
  78. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_project_commands.py +0 -0
  79. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_project_env.py +0 -0
  80. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_pytest_xdist_group.py +0 -0
  81. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_python_env_managers.py +0 -0
  82. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_refactor_cycles.py +0 -0
  83. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_run_claim.py +0 -0
  84. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_sensitivity_evidence.py +0 -0
  85. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_single_project_repo.py +0 -0
  86. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_snapshot_and_identity.py +0 -0
  87. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_stub_hint.py +0 -0
  88. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_suite_overrides.py +0 -0
  89. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_target_lint.py +0 -0
  90. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_target_validation.py +0 -0
  91. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_timing_visibility.py +0 -0
  92. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_undeclared_close_gate.py +0 -0
  93. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_undeclared_dedup.py +0 -0
  94. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_vitest_adapter.py +0 -0
  95. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_worker_leases.py +0 -0
  96. {tdd_cli-0.11.0 → tdd_cli-0.12.0}/tests/test_xctest_adapter.py +0 -0
@@ -6,6 +6,25 @@ and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-09-17
10
+
11
+ ### Changed
12
+
13
+ - **Close sweep runs all gates before any suite** (#129): lint and typecheck now run for every
14
+ project in the sweep set before any project's suite executes. A failing gate short-circuits the
15
+ sweep immediately — no suite runs, and `next_action` returns `fix_regression` with the failing
16
+ gate entry. Previously each project ran its suite first and only then checked its gates.
17
+ - **`Adapter._gate` stops at the first failing command** (#129): the first non-zero exit ends the
18
+ gate; subsequent commands in the same gate list are not executed.
19
+ - **Gate memoisation** (#129): a gate whose project tree hash is unchanged since it last passed
20
+ within the same run is skipped (no command executed) and recorded with `skipped = 1` in
21
+ `gate_result`. A failed gate or a changed tree always re-runs.
22
+ - **`fix_regression` detail wording** (#129): the reply for a gate failure now reads
23
+ `"Close sweep stopped before the suite: lint/typecheck gates failed."` (was `"Close sweep is
24
+ green but lint/typecheck gates failed."`).
25
+ - **Schema v11** (#129): `gate_result` gains `tree_hash TEXT` and
26
+ `skipped INTEGER NOT NULL DEFAULT 0`. Existing ledgers are migrated on open via `MIGRATIONS[10]`.
27
+
9
28
  ## [0.11.0] - 2026-09-16
10
29
 
11
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tdd-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Ledger-backed TDD process controller for autonomous coding agents
5
5
  Project-URL: Homepage, https://github.com/geuben/tdd-cli
6
6
  Project-URL: Repository, https://github.com/geuben/tdd-cli
@@ -195,6 +195,18 @@ Typed: `regression`, `target_unfixable`, `bad_red`, `plan_defect`, `tooling`, `c
195
195
  Without it the only available label is `regression`, which records a defect the run introduced —
196
196
  so the agent must either misreport its own work or leave the failure unfiled.
197
197
 
198
+ ### GateResult
199
+ One execution (or memo-skip) of one project's lint or typecheck gate during a close sweep.
200
+
201
+ | Field | Notes |
202
+ |---|---|
203
+ | `id`, `run_id`, `cycle_id`, `project`, `kind` | `kind` is `lint` or `typecheck` |
204
+ | `ok` | `1` = passed (or skipped), `0` = failed |
205
+ | `output` | truncated stdout/stderr of the failing command; empty when `ok = 1` |
206
+ | `tree_hash` | hash of the project tree at the time the gate ran or was skipped (schema v11) |
207
+ | `skipped` | `1` if the gate was memo-skipped (tree unchanged, gate previously passed within this run); `0` otherwise (schema v11) |
208
+ | `at` | |
209
+
198
210
  ---
199
211
 
200
212
  ## 6. State machine
@@ -561,7 +573,9 @@ For the passed-on-arrival case, which occurred in 4 of 8 executed cycles in the
561
573
  - **R9.2** On the transition out of `AWAITING_REFACTOR`, the close sweep runs: the cycle's own
562
574
  projects, plus every project **downstream of an artifact the cycle modified** per the
563
575
  `consumed_by` edges in §7.1, plus lint and typecheck for each. Projects with
564
- `in_close_sweep = false` are always excluded.
576
+ `in_close_sweep = false` are always excluded. The close sweep runs lint and typecheck for
577
+ **every project in scope before any suite**; if any gate fails, the sweep returns immediately
578
+ without running any suite.
565
579
  - **R9.3** A **full sweep** — every registered project, lint and typecheck, including
566
580
  `in_close_sweep = false` suites — runs once at plan completion.
567
581
  - **R9.4** Rationale: a per-cycle sweep of all four projects with `mypy` and `tsc` costs minutes,
@@ -664,7 +678,12 @@ For the passed-on-arrival case, which occurred in 4 of 8 executed cycles in the
664
678
 
665
679
  ### 9.4 Lint, typecheck and artifacts
666
680
  - **R9.11** Lint and typecheck results are part of the close-sweep verdict, surfaced alongside
667
- test failures rather than discovered later at commit time.
681
+ test failures rather than discovered later at commit time. The gate pass stops at the **first
682
+ failing gate**; `Adapter._gate` stops at the **first failing command** within a gate. A gate
683
+ whose project tree hash is unchanged since that gate last passed within the same run is
684
+ **skipped** (no command is executed) and recorded with `skipped = 1` in `gate_result`. Limit:
685
+ gate configuration that lives outside the project's declared roots (e.g. a top-level
686
+ `pyproject.toml`) does not affect the tree hash and therefore does not invalidate the memo.
668
687
  - **R9.12** Before a run starts and at every cycle close, declared artifacts are checked for
669
688
  staleness against their producer. When the tool cannot resolve staleness itself (no `regenerate`
670
689
  command, or the regenerate hook produced no commit), it emits `stale_artifact` and blocks the
@@ -3,4 +3,4 @@
3
3
  State is derived from observed test execution, never asserted by the caller.
4
4
  """
5
5
 
6
- __version__ = "0.11.0"
6
+ __version__ = "0.12.0"
@@ -302,9 +302,8 @@ class Adapter:
302
302
  return self._gate(self.project.typecheck)
303
303
 
304
304
  def _gate(self, commands: list[str]) -> GateResult:
305
- chunks = []
306
305
  for cmd in commands:
307
306
  code, out, err = run_command(cmd, self.root, label="gate")
308
307
  if code != 0:
309
- chunks.append(f"$ {cmd}\n{out}\n{err}".strip())
310
- return GateResult(ok=not chunks, output="\n\n".join(chunks)[:4000])
308
+ return GateResult(ok=False, output=f"$ {cmd}\n{out}\n{err}".strip()[:4000])
309
+ return GateResult(ok=True)
@@ -520,7 +520,7 @@ def _handle_refactor(engine: Engine, cycle, retried: bool) -> Envelope:
520
520
  engine,
521
521
  cycle,
522
522
  Verb.FIX_REGRESSION,
523
- "Close sweep is green but lint/typecheck gates failed.",
523
+ "Close sweep stopped before the suite: lint/typecheck gates failed.",
524
524
  gates=gates,
525
525
  commit=sha,
526
526
  )
@@ -13,7 +13,7 @@ import sqlite3
13
13
  from datetime import datetime, timedelta, timezone
14
14
  from pathlib import Path
15
15
 
16
- SCHEMA_VERSION = 10
16
+ SCHEMA_VERSION = 11
17
17
 
18
18
 
19
19
  class LedgerVersionError(RuntimeError):
@@ -45,6 +45,11 @@ MIGRATIONS: dict[int, str] = {
45
45
  8: "ALTER TABLE artifact_check ADD COLUMN regenerate_failed INTEGER NOT NULL DEFAULT 0;",
46
46
  # v9 -> v10 added start_sha column to run; ALTER TABLE covers old ledgers.
47
47
  9: "ALTER TABLE run ADD COLUMN start_sha TEXT;",
48
+ # v10 -> v11 added tree_hash and skipped columns to gate_result; ALTER TABLE covers old ledgers.
49
+ 10: (
50
+ "ALTER TABLE gate_result ADD COLUMN tree_hash TEXT;"
51
+ " ALTER TABLE gate_result ADD COLUMN skipped INTEGER NOT NULL DEFAULT 0;"
52
+ ),
48
53
  }
49
54
 
50
55
  SCHEMA = """
@@ -158,6 +163,8 @@ CREATE TABLE IF NOT EXISTS gate_result (
158
163
  kind TEXT NOT NULL, -- lint | typecheck
159
164
  ok INTEGER NOT NULL,
160
165
  output TEXT,
166
+ tree_hash TEXT,
167
+ skipped INTEGER NOT NULL DEFAULT 0,
161
168
  at TEXT NOT NULL
162
169
  );
163
170
 
@@ -270,9 +270,58 @@ class Engine:
270
270
  unbaselined: dict[str, list[str]] = {}
271
271
  unobserved: dict[str, str] = {}
272
272
 
273
+ adapters_by_name = {
274
+ name: adapters.build(self.config.project(name), self.worktree) for name in names
275
+ }
276
+
273
277
  for name in names:
274
- project = self.config.project(name)
275
- adapter = adapters.build(project, self.worktree)
278
+ adapter = adapters_by_name[name]
279
+ current_hash = self.tree_hash([name])
280
+ gate_failed = False
281
+ for kind, gate_fn in (("lint", adapter.lint), ("typecheck", adapter.typecheck)):
282
+ last = self.ledger.one(
283
+ "SELECT ok, tree_hash FROM gate_result"
284
+ " WHERE run_id = ? AND project = ? AND kind = ?"
285
+ " ORDER BY id DESC LIMIT 1",
286
+ (self.run["id"], name, kind),
287
+ )
288
+ if last and last["ok"] == 1 and last["tree_hash"] == current_hash:
289
+ self.ledger.insert(
290
+ "gate_result",
291
+ run_id=self.run["id"],
292
+ cycle_id=cycle_row["id"],
293
+ project=name,
294
+ kind=kind,
295
+ ok=1,
296
+ output="",
297
+ tree_hash=current_hash,
298
+ skipped=1,
299
+ at=now(),
300
+ )
301
+ continue
302
+ gate = gate_fn()
303
+ self.ledger.insert(
304
+ "gate_result",
305
+ run_id=self.run["id"],
306
+ cycle_id=cycle_row["id"],
307
+ project=name,
308
+ kind=kind,
309
+ ok=int(gate.ok),
310
+ output=gate.output,
311
+ tree_hash=current_hash,
312
+ at=now(),
313
+ )
314
+ if not gate.ok:
315
+ gates.append((name, kind, gate.output))
316
+ gate_failed = True
317
+ break
318
+ if gate_failed:
319
+ return SweepOutcome(
320
+ failures=failures, gates=gates, unbaselined=unbaselined, unobserved=unobserved
321
+ )
322
+
323
+ for name in names:
324
+ adapter = adapters_by_name[name]
276
325
  started = time.monotonic()
277
326
  verdict = adapter.run(None)
278
327
  heartbeat(
@@ -350,19 +399,6 @@ class Engine:
350
399
  tree_hash=self.tree_hash([name]),
351
400
  started_at=now(),
352
401
  )
353
- for kind, gate in (("lint", adapter.lint()), ("typecheck", adapter.typecheck())):
354
- self.ledger.insert(
355
- "gate_result",
356
- run_id=self.run["id"],
357
- cycle_id=cycle_row["id"],
358
- project=name,
359
- kind=kind,
360
- ok=int(gate.ok),
361
- output=gate.output,
362
- at=now(),
363
- )
364
- if not gate.ok:
365
- gates.append((name, kind, gate.output))
366
402
  return SweepOutcome(
367
403
  failures=failures, gates=gates, unbaselined=unbaselined, unobserved=unobserved
368
404
  )
@@ -0,0 +1,361 @@
1
+ """Close-sweep gate ordering and short-circuit (issue #129)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from conftest import git, run_cli, write_plan
8
+ from tddcli import gitutil
9
+ from tddcli.ledger import Ledger
10
+
11
+ # ── inner plan used by every test to reach the close sweep ────────────────────
12
+
13
+ SIMPLE_PLAN = """---
14
+ cycles:
15
+ - n: 1
16
+ project: backend
17
+ title: "basic pass"
18
+ test: "tests/test_gate_probe.py::test_gate_probe"
19
+ stub_expected: ["app/probe.py"]
20
+ commit_red: "test: gate probe"
21
+ commit_green: "feat: gate probe"
22
+ ---
23
+ """
24
+
25
+ _TEST = """\
26
+ from app.probe import probe
27
+
28
+
29
+ def test_gate_probe():
30
+ assert probe() == 42
31
+ """
32
+
33
+ _STUB = "def probe():\n raise NotImplementedError\n"
34
+ _IMPL = "def probe():\n return 42\n"
35
+
36
+
37
+ def _toml(lint_cmds: list[str], typecheck_cmds: list[str] | None = None) -> str:
38
+ tc = typecheck_cmds if typecheck_cmds is not None else []
39
+ return (
40
+ "[project.backend]\n"
41
+ 'root = "backend"\n'
42
+ 'adapter = "pytest"\n'
43
+ 'test_paths = ["tests/"]\n'
44
+ f"lint = {lint_cmds!r}\n"
45
+ f"typecheck = {tc!r}\n"
46
+ )
47
+
48
+
49
+ def _drive_to_close(
50
+ repo: Path,
51
+ lint_cmds: list[str] | None = None,
52
+ typecheck_cmds: list[str] | None = None,
53
+ project: str = "backend",
54
+ ) -> dict:
55
+ """Drive a single-cycle inner plan through RED → GREEN and return the
56
+ close-sweep envelope (the advance at AWAITING_REFACTOR)."""
57
+ lint = lint_cmds if lint_cmds is not None else []
58
+ (repo / "tdd.toml").write_text(_toml(lint, typecheck_cmds))
59
+ git(repo, "add", "-A")
60
+ git(repo, "commit", "-q", "-m", "configure gates")
61
+
62
+ plan = write_plan(repo, SIMPLE_PLAN)
63
+ assert run_cli(repo, "plan", "register", plan)["ok"]
64
+ assert run_cli(repo, "run", "start", "--plan", plan)["ok"]
65
+
66
+ # RED: write test + stub so the test collects but fails
67
+ (repo / project / "tests" / "test_gate_probe.py").write_text(_TEST)
68
+ (repo / project / "app" / "probe.py").write_text(_STUB)
69
+ adv = run_cli(repo, "advance")
70
+ assert adv["next_action"]["verb"] == "write_implementation", adv
71
+
72
+ # GREEN: write real implementation
73
+ (repo / project / "app" / "probe.py").write_text(_IMPL)
74
+ adv = run_cli(repo, "advance")
75
+ assert adv["next_action"]["verb"] == "refactor_or_advance", adv
76
+
77
+ # Advance at AWAITING_REFACTOR → triggers the close sweep
78
+ return run_cli(repo, "advance")
79
+
80
+
81
+ # ── cycle 1 ── pin: a failing gate replies fix_regression with that gate's entry
82
+
83
+
84
+ def test_failing_lint_replies_fix_regression_with_the_lint_gate(repo):
85
+ out = _drive_to_close(repo, lint_cmds=["sh -c 'exit 1'"])
86
+ verb = out["next_action"]["verb"]
87
+ gates = out.get("result", {}).get("gates", [])
88
+ assert (verb, [(g["project"], g["kind"]) for g in gates]) == (
89
+ "fix_regression",
90
+ [("backend", "lint")],
91
+ )
92
+
93
+
94
+ # ── cycle 2 ── pin: a gate that ran records a gate_result row
95
+
96
+
97
+ def test_a_failing_lint_records_its_gate_result_row(repo):
98
+ out = _drive_to_close(repo, lint_cmds=["sh -c 'exit 1'"])
99
+ run_id = out["run"]["id"]
100
+ led = Ledger(gitutil.repo_identity(repo))
101
+ rows = led.all(
102
+ "SELECT * FROM gate_result WHERE run_id = ? AND project = 'backend' AND kind = 'lint'",
103
+ (run_id,),
104
+ )
105
+ assert any(r["ok"] == 0 for r in rows)
106
+
107
+
108
+ # ── cycle 3 ── _gate stops at the first failing command
109
+
110
+
111
+ def test_gate_stops_at_the_first_failing_command(repo):
112
+ lint_cmds = [
113
+ "sh -c 'echo LINT-BOOM; exit 1'",
114
+ "sh -c 'echo SECOND-RAN; exit 1'",
115
+ ]
116
+ out = _drive_to_close(repo, lint_cmds=lint_cmds)
117
+ gates = out.get("result", {}).get("gates", [])
118
+ lint_gate = next((g for g in gates if g["kind"] == "lint"), None)
119
+ assert lint_gate is not None
120
+ assert "SECOND-RAN" not in lint_gate["output"]
121
+
122
+
123
+ # ── cycle 4 ── a failing lint means typecheck is never run
124
+
125
+
126
+ def test_a_failing_lint_stops_the_gate_pass_before_typecheck(repo, tmp_path):
127
+ counter = tmp_path / "typecheck-ran"
128
+ typecheck_cmds = [f"sh -c 'echo ran >> {counter}; exit 1'"]
129
+ _drive_to_close(repo, lint_cmds=["sh -c 'exit 1'"], typecheck_cmds=typecheck_cmds)
130
+ assert not counter.exists()
131
+
132
+
133
+ # ── cycle 5 ── a failing gate short-circuits the sweep before any suite runs
134
+
135
+
136
+ def test_a_failing_gate_runs_no_suite(repo):
137
+ out = _drive_to_close(repo, lint_cmds=["sh -c 'exit 1'"])
138
+ run_id = out["run"]["id"]
139
+ led = Ledger(gitutil.repo_identity(repo))
140
+ rows = led.all(
141
+ "SELECT * FROM invocation WHERE run_id = ? AND phase_at = 'CLOSE_SWEEP'",
142
+ (run_id,),
143
+ )
144
+ assert len(rows) == 0
145
+
146
+
147
+ # ── cycle 6 ── every sweep project's gates run before any project's suite
148
+
149
+ _THREE_PLAN = """\
150
+ ---
151
+ cycles:
152
+ - n: 1
153
+ project: backend
154
+ title: "basic pass"
155
+ test: "tests/test_gate_probe.py::test_gate_probe"
156
+ stub_expected: ["app/probe.py"]
157
+ commit_red: "test: gate probe"
158
+ commit_green: "feat: gate probe"
159
+ ---
160
+ """
161
+
162
+
163
+ def test_a_later_projects_failing_gate_runs_no_earlier_suite(repo_three):
164
+ # Rewrite tdd.toml: backend has no lint, svc has a failing lint.
165
+ (repo_three / "tdd.toml").write_text(
166
+ "[project.backend]\n"
167
+ 'root = "backend"\n'
168
+ 'adapter = "pytest"\n'
169
+ 'test_paths = ["tests/"]\n'
170
+ "\n"
171
+ "[project.svc]\n"
172
+ 'root = "svc"\n'
173
+ 'adapter = "pytest"\n'
174
+ 'test_paths = ["tests/"]\n'
175
+ "lint = [\"sh -c 'exit 1'\"]\n"
176
+ "typecheck = []\n"
177
+ "\n"
178
+ "[project.other]\n"
179
+ 'root = "other"\n'
180
+ 'adapter = "pytest"\n'
181
+ 'test_paths = ["tests/"]\n'
182
+ "\n"
183
+ "[artifact.schema]\n"
184
+ 'path = "backend/schema.json"\n'
185
+ 'produced_by = "backend"\n'
186
+ 'consumed_by = ["svc"]\n'
187
+ 'regenerate = "true"\n'
188
+ )
189
+ git(repo_three, "add", "-A")
190
+ git(repo_three, "commit", "-q", "-m", "configure svc lint")
191
+
192
+ plan = write_plan(repo_three, _THREE_PLAN)
193
+ assert run_cli(repo_three, "plan", "register", plan)["ok"]
194
+ assert run_cli(repo_three, "run", "start", "--plan", plan)["ok"]
195
+
196
+ # RED: test + stub
197
+ (repo_three / "backend" / "tests" / "test_gate_probe.py").write_text(_TEST)
198
+ (repo_three / "backend" / "app" / "probe.py").write_text(_STUB)
199
+ adv = run_cli(repo_three, "advance")
200
+ assert adv["next_action"]["verb"] == "write_implementation", adv
201
+
202
+ # GREEN: real implementation
203
+ (repo_three / "backend" / "app" / "probe.py").write_text(_IMPL)
204
+ adv = run_cli(repo_three, "advance")
205
+ assert adv["next_action"]["verb"] == "refactor_or_advance", adv
206
+
207
+ # Modify backend/schema.json (dirty) → pulls svc into the close sweep
208
+ (repo_three / "backend" / "schema.json").write_text('{"version": 2}\n')
209
+
210
+ # Advance at AWAITING_REFACTOR → svc's lint fails → no suite runs
211
+ out = run_cli(repo_three, "advance")
212
+
213
+ run_id = out["run"]["id"]
214
+ led = Ledger(gitutil.repo_identity(repo_three))
215
+ rows = led.all(
216
+ "SELECT * FROM invocation WHERE run_id = ? AND phase_at = 'CLOSE_SWEEP' AND project = 'backend'",
217
+ (run_id,),
218
+ )
219
+ assert len(rows) == 0
220
+
221
+
222
+ # ── cycle 7 ── the gate-failure reply does not claim the sweep is green
223
+
224
+
225
+ def test_the_gate_failure_reply_does_not_claim_the_sweep_is_green(repo):
226
+ out = _drive_to_close(repo, lint_cmds=["sh -c 'exit 1'"])
227
+ assert out["next_action"]["detail"] == (
228
+ "Close sweep stopped before the suite: lint/typecheck gates failed."
229
+ )
230
+
231
+
232
+ # ── cycle 8 ── pin: a gate that failed is re-run at the same tree
233
+
234
+
235
+ def test_a_failed_gate_is_rerun_at_the_same_tree(repo, tmp_path):
236
+ counter = tmp_path / "lint-count"
237
+ lint_cmds = [f"sh -c 'echo x >> {counter}; exit 1'"]
238
+ _drive_to_close(repo, lint_cmds=lint_cmds)
239
+ # Advance again at AWAITING_REFACTOR with nothing edited (same tree) → gate re-runs
240
+ run_cli(repo, "advance")
241
+ assert counter.read_text().count("\n") == 2
242
+
243
+
244
+ # ── cycle 9 ── pin: a gate is re-run when its project tree changed
245
+
246
+
247
+ def test_a_gate_is_rerun_when_the_tree_changed(repo, tmp_path):
248
+ """Lint passes but suite fails first time; tree changes; lint runs again second time."""
249
+ counter = tmp_path / "lint-count"
250
+ lint_cmds = [f"sh -c 'echo x >> {counter}'"] # exits 0 (passes)
251
+
252
+ # Set up a failing test that will be in the close sweep
253
+ (repo / "tdd.toml").write_text(_toml(lint_cmds))
254
+ git(repo, "add", "-A")
255
+ git(repo, "commit", "-q", "-m", "configure gates")
256
+
257
+ plan = write_plan(repo, SIMPLE_PLAN)
258
+ assert run_cli(repo, "plan", "register", plan)["ok"]
259
+ assert run_cli(repo, "run", "start", "--plan", plan)["ok"]
260
+
261
+ # RED
262
+ (repo / "backend" / "tests" / "test_gate_probe.py").write_text(_TEST)
263
+ (repo / "backend" / "app" / "probe.py").write_text(_STUB)
264
+ adv = run_cli(repo, "advance")
265
+ assert adv["next_action"]["verb"] == "write_implementation", adv
266
+
267
+ # GREEN
268
+ (repo / "backend" / "app" / "probe.py").write_text(_IMPL)
269
+ adv = run_cli(repo, "advance")
270
+ assert adv["next_action"]["verb"] == "refactor_or_advance", adv
271
+
272
+ # First close: add a failing test → lint passes but suite fails
273
+ (repo / "backend" / "tests" / "test_fail.py").write_text("def test_fail():\n assert False\n")
274
+ out1 = run_cli(repo, "advance")
275
+ assert out1["next_action"]["verb"] == "fix_regression", out1
276
+
277
+ # Fix the failing test → tree changes → second close: lint re-runs
278
+ (repo / "backend" / "tests" / "test_fail.py").write_text("def test_fail():\n assert True\n")
279
+ out2 = run_cli(repo, "advance")
280
+ assert out2["next_action"]["verb"] != "fix_regression" or out2.get("result", {}).get("gates"), (
281
+ out2
282
+ )
283
+
284
+ assert counter.read_text().count("\n") == 2
285
+
286
+
287
+ # ── cycle 11 ── a gate that passed at this tree hash is not re-executed
288
+
289
+
290
+ def test_a_passing_gate_is_not_reexecuted_at_an_unchanged_tree(repo, tmp_path):
291
+ """Lint passes first close; suite fails first close; tree unchanged for second close →
292
+ lint should be skipped the second time."""
293
+ counter = tmp_path / "lint-count"
294
+ lint_cmds = [f"sh -c 'echo x >> {counter}'"] # exits 0 (passes)
295
+
296
+ (repo / "tdd.toml").write_text(_toml(lint_cmds))
297
+ git(repo, "add", "-A")
298
+ git(repo, "commit", "-q", "-m", "configure gates")
299
+
300
+ plan = write_plan(repo, SIMPLE_PLAN)
301
+ assert run_cli(repo, "plan", "register", plan)["ok"]
302
+ assert run_cli(repo, "run", "start", "--plan", plan)["ok"]
303
+
304
+ # RED/GREEN
305
+ (repo / "backend" / "tests" / "test_gate_probe.py").write_text(_TEST)
306
+ (repo / "backend" / "app" / "probe.py").write_text(_STUB)
307
+ assert run_cli(repo, "advance")["next_action"]["verb"] == "write_implementation"
308
+ (repo / "backend" / "app" / "probe.py").write_text(_IMPL)
309
+ assert run_cli(repo, "advance")["next_action"]["verb"] == "refactor_or_advance"
310
+
311
+ # First close: add failing test (dirty) → lint passes, suite fails
312
+ (repo / "backend" / "tests" / "test_fail.py").write_text("def test_fail():\n assert False\n")
313
+ out1 = run_cli(repo, "advance")
314
+ assert out1["next_action"]["verb"] == "fix_regression", out1
315
+
316
+ # Second close: no change to tree → lint should be skipped
317
+ run_cli(repo, "advance")
318
+ # (could be fix_regression again due to suite failure, but lint must NOT re-run)
319
+
320
+ assert counter.read_text().count("\n") == 1
321
+
322
+
323
+ # ── cycle 12 ── a skipped gate still records a gate_result row, marked skipped
324
+
325
+
326
+ def test_a_skipped_gate_records_a_skipped_row(repo, tmp_path):
327
+ """Same scenario as cycle 11: lint passes first close, skipped second close.
328
+ The second gate_result row must have skipped=1."""
329
+ counter = tmp_path / "lint-count"
330
+ lint_cmds = [f"sh -c 'echo x >> {counter}'"]
331
+
332
+ (repo / "tdd.toml").write_text(_toml(lint_cmds))
333
+ git(repo, "add", "-A")
334
+ git(repo, "commit", "-q", "-m", "configure gates")
335
+
336
+ plan = write_plan(repo, SIMPLE_PLAN)
337
+ assert run_cli(repo, "plan", "register", plan)["ok"]
338
+ assert run_cli(repo, "run", "start", "--plan", plan)["ok"]
339
+
340
+ # RED/GREEN
341
+ (repo / "backend" / "tests" / "test_gate_probe.py").write_text(_TEST)
342
+ (repo / "backend" / "app" / "probe.py").write_text(_STUB)
343
+ assert run_cli(repo, "advance")["next_action"]["verb"] == "write_implementation"
344
+ (repo / "backend" / "app" / "probe.py").write_text(_IMPL)
345
+ assert run_cli(repo, "advance")["next_action"]["verb"] == "refactor_or_advance"
346
+
347
+ # First close: failing test (dirty) → lint passes
348
+ (repo / "backend" / "tests" / "test_fail.py").write_text("def test_fail():\n assert False\n")
349
+ out1 = run_cli(repo, "advance")
350
+ run_id = out1["run"]["id"]
351
+ assert out1["next_action"]["verb"] == "fix_regression", out1
352
+
353
+ # Second close: no tree change → lint skipped
354
+ run_cli(repo, "advance")
355
+
356
+ led = Ledger(gitutil.repo_identity(repo))
357
+ rows = led.all(
358
+ "SELECT skipped FROM gate_result WHERE run_id = ? AND project = 'backend' AND kind = 'lint' ORDER BY id",
359
+ (run_id,),
360
+ )
361
+ assert [r["skipped"] for r in rows] == [0, 1]
@@ -197,3 +197,22 @@ def test_run_gains_start_sha_column(tmp_path, ledger_home):
197
197
  reopened = Ledger(tmp_path / "somerepo")
198
198
  cols2 = {r[1] for r in reopened.db.execute("PRAGMA table_info(run)").fetchall()}
199
199
  assert "start_sha" in cols2
200
+
201
+
202
+ def test_gate_result_gains_tree_hash_and_skipped_columns(tmp_path, ledger_home):
203
+ # Fresh ledger: both columns must exist in CREATE TABLE
204
+ ledger = Ledger(tmp_path / "somerepo")
205
+ cols = {r[1] for r in ledger.db.execute("PRAGMA table_info(gate_result)").fetchall()}
206
+ assert "tree_hash" in cols
207
+ assert "skipped" in cols
208
+
209
+ # Migration path: force back to v10 (before these columns), drop them, reopen
210
+ ledger._write("UPDATE meta SET value = '10' WHERE key = 'schema_version'", ())
211
+ ledger.db.execute("ALTER TABLE gate_result DROP COLUMN tree_hash")
212
+ ledger.db.execute("ALTER TABLE gate_result DROP COLUMN skipped")
213
+ ledger.db.close()
214
+
215
+ reopened = Ledger(tmp_path / "somerepo")
216
+ cols2 = {r[1] for r in reopened.db.execute("PRAGMA table_info(gate_result)").fetchall()}
217
+ assert "tree_hash" in cols2
218
+ assert "skipped" in cols2
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