tdd-cli 0.1.0__py3-none-any.whl
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.
- tdd_cli-0.1.0.dist-info/METADATA +391 -0
- tdd_cli-0.1.0.dist-info/RECORD +24 -0
- tdd_cli-0.1.0.dist-info/WHEEL +4 -0
- tdd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tdd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- tddcli/__init__.py +6 -0
- tddcli/adapters/__init__.py +48 -0
- tddcli/adapters/base.py +119 -0
- tddcli/adapters/pytest_adapter.py +179 -0
- tddcli/adapters/vitest_adapter.py +170 -0
- tddcli/advance.py +423 -0
- tddcli/cli.py +1043 -0
- tddcli/config.py +255 -0
- tddcli/contract.py +237 -0
- tddcli/envelope.py +96 -0
- tddcli/fleet.py +128 -0
- tddcli/gitutil.py +138 -0
- tddcli/identity.py +82 -0
- tddcli/leases.py +118 -0
- tddcli/ledger.py +433 -0
- tddcli/machine.py +390 -0
- tddcli/render.py +275 -0
- tddcli/snapshot.py +90 -0
- tddcli/staging.py +130 -0
tddcli/config.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""tdd.toml — the project registry and artifact graph (§7.1).
|
|
2
|
+
|
|
3
|
+
Roots are declared, never discovered by scanning for marker files (R7.1): two
|
|
4
|
+
projects in one repo can share a marker, and directory-listing order must not decide
|
|
5
|
+
which suite runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from fnmatch import fnmatch
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CONFIG_NAME = "tdd.toml"
|
|
17
|
+
|
|
18
|
+
#: Build output the tool must never author, regardless of the repository's .gitignore.
|
|
19
|
+
#: Without this, a run in a repo lacking a .gitignore commits __pycache__ alongside
|
|
20
|
+
#: the test it was asked to commit.
|
|
21
|
+
DEFAULT_IGNORES = (
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
"node_modules",
|
|
27
|
+
".venv",
|
|
28
|
+
"venv",
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
".coverage",
|
|
32
|
+
"htmlcov",
|
|
33
|
+
".DS_Store",
|
|
34
|
+
".tox",
|
|
35
|
+
"*.pyc",
|
|
36
|
+
"*.pyo",
|
|
37
|
+
"*.egg-info",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Project:
|
|
47
|
+
name: str
|
|
48
|
+
root: str
|
|
49
|
+
adapter: str
|
|
50
|
+
test_paths: list[str] = field(default_factory=list)
|
|
51
|
+
lint: list[str] = field(default_factory=list)
|
|
52
|
+
typecheck: list[str] = field(default_factory=list)
|
|
53
|
+
in_close_sweep: bool = True
|
|
54
|
+
#: The project's own suite command. Without this the tool runs its adapter's
|
|
55
|
+
#: default invocation, which can differ from what the project actually runs —
|
|
56
|
+
#: parallelism, plugins, markers — so the suite under TDD is not the suite the
|
|
57
|
+
#: team trusts. The adapter appends only its reporting flags.
|
|
58
|
+
test_command: str | None = None
|
|
59
|
+
#: Per-file collection. Must not be parallelised: collection is cheap and xdist
|
|
60
|
+
#: adds startup cost per file.
|
|
61
|
+
collect_command: str | None = None
|
|
62
|
+
|
|
63
|
+
def owns(self, rel_path: str) -> bool:
|
|
64
|
+
if self.root == ".": # single-project repo: the root is the worktree itself
|
|
65
|
+
return True
|
|
66
|
+
return rel_path == self.root or rel_path.startswith(self.root.rstrip("/") + "/")
|
|
67
|
+
|
|
68
|
+
def relative_to_root(self, rel_path: str) -> str:
|
|
69
|
+
prefix = self.root.rstrip("/") + "/"
|
|
70
|
+
return rel_path[len(prefix):] if rel_path.startswith(prefix) else rel_path
|
|
71
|
+
|
|
72
|
+
def is_test_file(self, rel_path: str) -> bool:
|
|
73
|
+
"""rel_path is relative to the worktree root."""
|
|
74
|
+
if not self.owns(rel_path):
|
|
75
|
+
return False
|
|
76
|
+
inner = self.relative_to_root(rel_path)
|
|
77
|
+
for pattern in self.test_paths:
|
|
78
|
+
if pattern.endswith("/"):
|
|
79
|
+
if inner.startswith(pattern):
|
|
80
|
+
return True
|
|
81
|
+
elif fnmatch(inner, pattern) or fnmatch(rel_path, pattern):
|
|
82
|
+
return True
|
|
83
|
+
elif inner.startswith(pattern.rstrip("/") + "/"):
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Artifact:
|
|
90
|
+
name: str
|
|
91
|
+
path: str
|
|
92
|
+
produced_by: str
|
|
93
|
+
regenerate: str | None = None
|
|
94
|
+
check: str | None = None
|
|
95
|
+
consumed_by: list[str] = field(default_factory=list)
|
|
96
|
+
generated: bool = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def upstream_artifact(self) -> str | None:
|
|
100
|
+
if self.produced_by.startswith("artifact."):
|
|
101
|
+
return self.produced_by.split(".", 1)[1]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def owns(self, rel_path: str) -> bool:
|
|
105
|
+
p = self.path.rstrip("/")
|
|
106
|
+
return rel_path == p or rel_path.startswith(p + "/")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Config:
|
|
111
|
+
worktree: Path
|
|
112
|
+
projects: dict[str, Project]
|
|
113
|
+
artifacts: dict[str, Artifact]
|
|
114
|
+
ignores: tuple[str, ...] = DEFAULT_IGNORES
|
|
115
|
+
|
|
116
|
+
def is_ignored(self, rel_path: str) -> bool:
|
|
117
|
+
parts = Path(rel_path).parts
|
|
118
|
+
for pattern in self.ignores:
|
|
119
|
+
if any(fnmatch(part, pattern) for part in parts):
|
|
120
|
+
return True
|
|
121
|
+
if fnmatch(rel_path, pattern):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def project(self, name: str) -> Project:
|
|
126
|
+
if name not in self.projects:
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
f"unknown project {name!r}; registered: {sorted(self.projects)}"
|
|
129
|
+
)
|
|
130
|
+
return self.projects[name]
|
|
131
|
+
|
|
132
|
+
def owning_project(self, rel_path: str) -> Project | None:
|
|
133
|
+
# Longest root wins, so nested roots resolve deterministically; a "." root
|
|
134
|
+
# counts as length zero so any nested root beats the repo-root project.
|
|
135
|
+
def depth(p: Project) -> int:
|
|
136
|
+
return 0 if p.root == "." else len(p.root)
|
|
137
|
+
|
|
138
|
+
best = None
|
|
139
|
+
for proj in self.projects.values():
|
|
140
|
+
if proj.owns(rel_path) and (best is None or depth(proj) > depth(best)):
|
|
141
|
+
best = proj
|
|
142
|
+
return best
|
|
143
|
+
|
|
144
|
+
def is_generated(self, rel_path: str) -> bool:
|
|
145
|
+
"""R7.7 — generated output is excluded from authorship accounting."""
|
|
146
|
+
return any(art.owns(rel_path) for art in self.artifacts.values() if art.generated)
|
|
147
|
+
|
|
148
|
+
def artifact_chain(self, art: Artifact) -> list[Artifact]:
|
|
149
|
+
"""`art` plus every upstream artifact its regenerate hook may refresh."""
|
|
150
|
+
chain, seen = [art], {art.name}
|
|
151
|
+
while (up := chain[-1].upstream_artifact) is not None and up not in seen:
|
|
152
|
+
chain.append(self.artifacts[up])
|
|
153
|
+
seen.add(up)
|
|
154
|
+
return chain
|
|
155
|
+
|
|
156
|
+
def close_sweep_projects(self, cycle_projects: list[str], touched: set[str]) -> list[str]:
|
|
157
|
+
"""R9.2 — the cycle's own projects, plus anything downstream of an artifact it touched."""
|
|
158
|
+
names = {n for n in cycle_projects}
|
|
159
|
+
for art in self.artifacts.values():
|
|
160
|
+
if self._artifact_touched(art, touched):
|
|
161
|
+
names.update(art.consumed_by)
|
|
162
|
+
return [n for n in sorted(names) if self.projects[n].in_close_sweep]
|
|
163
|
+
|
|
164
|
+
def _artifact_touched(self, art: Artifact, touched: set[str]) -> bool:
|
|
165
|
+
producer = art.produced_by
|
|
166
|
+
if producer.startswith("artifact."):
|
|
167
|
+
upstream = self.artifacts.get(producer.split(".", 1)[1])
|
|
168
|
+
return bool(upstream and self._artifact_touched(upstream, touched))
|
|
169
|
+
proj = self.projects.get(producer)
|
|
170
|
+
if proj is None:
|
|
171
|
+
return False
|
|
172
|
+
return any(proj.owns(p) for p in touched)
|
|
173
|
+
|
|
174
|
+
def full_sweep_projects(self) -> list[str]:
|
|
175
|
+
return sorted(self.projects)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def config_sha(worktree: Path) -> str:
|
|
179
|
+
"""Hash of tdd.toml as it stands in the working tree.
|
|
180
|
+
|
|
181
|
+
The registry is deliberately a reviewed, branch-scoped file rather than ledger
|
|
182
|
+
state — but that makes it editable mid-run, and one edit is load-bearing:
|
|
183
|
+
widening `test_paths` to match implementation files would silently disable the
|
|
184
|
+
RED-commit classification that detects implementation written during RED. The
|
|
185
|
+
ledger pins this hash at run start so the drift surfaces.
|
|
186
|
+
"""
|
|
187
|
+
path = worktree / CONFIG_NAME
|
|
188
|
+
return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find_config(start: Path) -> Path | None:
|
|
192
|
+
cur = start.resolve()
|
|
193
|
+
for candidate in [cur, *cur.parents]:
|
|
194
|
+
if (candidate / CONFIG_NAME).is_file():
|
|
195
|
+
return candidate / CONFIG_NAME
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def load(worktree: Path) -> Config:
|
|
200
|
+
path = worktree / CONFIG_NAME
|
|
201
|
+
if not path.is_file():
|
|
202
|
+
raise ConfigError(f"no {CONFIG_NAME} at {worktree}")
|
|
203
|
+
raw = tomllib.loads(path.read_text())
|
|
204
|
+
|
|
205
|
+
projects: dict[str, Project] = {}
|
|
206
|
+
for name, body in (raw.get("project") or {}).items():
|
|
207
|
+
if "root" not in body:
|
|
208
|
+
raise ConfigError(f"project {name!r} has no root")
|
|
209
|
+
if "adapter" not in body:
|
|
210
|
+
raise ConfigError(f"project {name!r} has no adapter")
|
|
211
|
+
projects[name] = Project(
|
|
212
|
+
name=name,
|
|
213
|
+
root=body["root"].rstrip("/"),
|
|
214
|
+
adapter=body["adapter"],
|
|
215
|
+
test_paths=body.get("test_paths", []),
|
|
216
|
+
lint=body.get("lint", []),
|
|
217
|
+
typecheck=body.get("typecheck", []),
|
|
218
|
+
in_close_sweep=body.get("in_close_sweep", True),
|
|
219
|
+
test_command=body.get("test_command"),
|
|
220
|
+
collect_command=body.get("collect_command"),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
artifacts: dict[str, Artifact] = {}
|
|
224
|
+
for name, body in (raw.get("artifact") or {}).items():
|
|
225
|
+
artifacts[name] = Artifact(
|
|
226
|
+
name=name,
|
|
227
|
+
path=body["path"],
|
|
228
|
+
produced_by=body["produced_by"],
|
|
229
|
+
regenerate=body.get("regenerate"),
|
|
230
|
+
check=body.get("check"),
|
|
231
|
+
consumed_by=body.get("consumed_by", []),
|
|
232
|
+
generated=body.get("generated", False),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
for art in artifacts.values():
|
|
236
|
+
up = art.upstream_artifact
|
|
237
|
+
if up is not None and up not in artifacts:
|
|
238
|
+
raise ConfigError(f"artifact {art.name!r} names unknown upstream artifact {up!r}")
|
|
239
|
+
if up is None and art.produced_by not in projects:
|
|
240
|
+
raise ConfigError(
|
|
241
|
+
f"artifact {art.name!r} produced_by unknown project {art.produced_by!r}"
|
|
242
|
+
)
|
|
243
|
+
for c in art.consumed_by:
|
|
244
|
+
if c not in projects:
|
|
245
|
+
raise ConfigError(f"artifact {art.name!r} consumed_by unknown project {c!r}")
|
|
246
|
+
|
|
247
|
+
if not projects:
|
|
248
|
+
raise ConfigError("no projects registered")
|
|
249
|
+
extra = tuple(raw.get("ignore", []))
|
|
250
|
+
return Config(
|
|
251
|
+
worktree=worktree,
|
|
252
|
+
projects=projects,
|
|
253
|
+
artifacts=artifacts,
|
|
254
|
+
ignores=DEFAULT_IGNORES + extra,
|
|
255
|
+
)
|
tddcli/contract.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Plan contracts from YAML front-matter, hashed at the committed blob (§7.2).
|
|
2
|
+
|
|
3
|
+
The plan's *commit* is the contract, so a planning agent needs no integration with
|
|
4
|
+
this tool and an implementing agent cannot quietly move its own goalposts: editing
|
|
5
|
+
front-matter mid-run changes the blob and raises `plan_blob_changed` (R7.11).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from . import gitutil
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
FENCE = "---"
|
|
20
|
+
|
|
21
|
+
STANDARD = "standard"
|
|
22
|
+
PIN = "pin"
|
|
23
|
+
CONTRACT = "contract"
|
|
24
|
+
REFACTOR = "refactor"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContractError(RuntimeError):
|
|
28
|
+
"""Malformed front-matter. Hard-fails registration (R7.10)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DeclaredCycle:
|
|
33
|
+
ordinal: int
|
|
34
|
+
kind: str
|
|
35
|
+
projects: list[str]
|
|
36
|
+
tests: list[str]
|
|
37
|
+
title: str = ""
|
|
38
|
+
files: list[str] = field(default_factory=list)
|
|
39
|
+
stub_expected: list[str] = field(default_factory=list)
|
|
40
|
+
modifies_tests: list[str] = field(default_factory=list)
|
|
41
|
+
commit_messages: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"n": self.ordinal,
|
|
46
|
+
"kind": self.kind,
|
|
47
|
+
"projects": self.projects,
|
|
48
|
+
"tests": self.tests,
|
|
49
|
+
"title": self.title,
|
|
50
|
+
"files": self.files,
|
|
51
|
+
"stub_expected": self.stub_expected,
|
|
52
|
+
"modifies_tests": self.modifies_tests,
|
|
53
|
+
"commit_messages": self.commit_messages,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def from_dict(d: dict) -> "DeclaredCycle":
|
|
58
|
+
return DeclaredCycle(
|
|
59
|
+
ordinal=d["n"],
|
|
60
|
+
kind=d["kind"],
|
|
61
|
+
projects=d["projects"],
|
|
62
|
+
tests=d["tests"],
|
|
63
|
+
title=d.get("title", ""),
|
|
64
|
+
files=d.get("files", []),
|
|
65
|
+
stub_expected=d.get("stub_expected", []),
|
|
66
|
+
modifies_tests=d.get("modifies_tests", []),
|
|
67
|
+
commit_messages=d.get("commit_messages", {}),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class PlanContract:
|
|
73
|
+
plan_path: str
|
|
74
|
+
status: str # declared | undeclared
|
|
75
|
+
cycles: list[DeclaredCycle]
|
|
76
|
+
annotation_keys: list[str]
|
|
77
|
+
blob_sha: str | None = None
|
|
78
|
+
commit_sha: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_front_matter(text: str) -> str | None:
|
|
82
|
+
"""Return the raw YAML block, or None when there is no front-matter at all."""
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
if not lines or lines[0].strip() != FENCE:
|
|
85
|
+
return None
|
|
86
|
+
for i in range(1, len(lines)):
|
|
87
|
+
if lines[i].strip() == FENCE:
|
|
88
|
+
return "\n".join(lines[1:i])
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _as_list(value, field_name: str, ordinal: int) -> list[str]:
|
|
93
|
+
if value is None:
|
|
94
|
+
return []
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
return [value]
|
|
97
|
+
if isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
98
|
+
return list(value)
|
|
99
|
+
raise ContractError(f"cycle {ordinal}: {field_name} must be a string or list of strings")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_cycle(raw: dict, config: Config | None) -> DeclaredCycle:
|
|
103
|
+
if not isinstance(raw, dict):
|
|
104
|
+
raise ContractError(f"each cycle must be a mapping, got {type(raw).__name__}")
|
|
105
|
+
if "n" not in raw:
|
|
106
|
+
raise ContractError("every cycle needs an `n` ordinal")
|
|
107
|
+
ordinal = raw["n"]
|
|
108
|
+
if not isinstance(ordinal, int):
|
|
109
|
+
raise ContractError(f"cycle ordinal must be an integer, got {ordinal!r}")
|
|
110
|
+
|
|
111
|
+
flags = [
|
|
112
|
+
name for name, key in (
|
|
113
|
+
(PIN, "pin_cycle"),
|
|
114
|
+
(CONTRACT, "contract_cycle"),
|
|
115
|
+
(REFACTOR, "refactor_cycle"),
|
|
116
|
+
) if raw.get(key)
|
|
117
|
+
]
|
|
118
|
+
if len(flags) > 1:
|
|
119
|
+
raise ContractError(
|
|
120
|
+
f"cycle {ordinal}: cycle kinds are exclusive, got {flags}"
|
|
121
|
+
)
|
|
122
|
+
kind = flags[0] if flags else STANDARD
|
|
123
|
+
|
|
124
|
+
tests = _as_list(raw.get("tests") or raw.get("test"), "test", ordinal)
|
|
125
|
+
projects = _as_list(raw.get("projects") or raw.get("project"), "project", ordinal)
|
|
126
|
+
if not projects:
|
|
127
|
+
raise ContractError(f"cycle {ordinal}: no project declared")
|
|
128
|
+
|
|
129
|
+
# A refactor cycle changes structure without changing behaviour: existing tests are
|
|
130
|
+
# the guard, so it has no target of its own and opens straight into refactor.
|
|
131
|
+
if kind == REFACTOR and tests:
|
|
132
|
+
raise ContractError(
|
|
133
|
+
f"cycle {ordinal}: a refactor cycle declares no test — the existing suite"
|
|
134
|
+
" is the guard. Use a pin cycle if new behaviour must be characterised first."
|
|
135
|
+
)
|
|
136
|
+
if kind != REFACTOR and not tests:
|
|
137
|
+
raise ContractError(
|
|
138
|
+
f"cycle {ordinal}: no test declared. Mark it `refactor_cycle: true` if it"
|
|
139
|
+
" is behaviour-preserving with no new test."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if len(tests) > 1 and kind != CONTRACT:
|
|
143
|
+
raise ContractError(
|
|
144
|
+
f"cycle {ordinal}: {len(tests)} tests declared but this is not a contract cycle."
|
|
145
|
+
" One behaviour per cycle (R9.8)."
|
|
146
|
+
)
|
|
147
|
+
if kind == CONTRACT and len(tests) < 2:
|
|
148
|
+
raise ContractError(
|
|
149
|
+
f"cycle {ordinal}: contract cycles must declare more than one target test"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if config is not None:
|
|
153
|
+
for name in projects:
|
|
154
|
+
if name not in config.projects:
|
|
155
|
+
raise ContractError(
|
|
156
|
+
f"cycle {ordinal}: unknown project {name!r};"
|
|
157
|
+
f" registered: {sorted(config.projects)}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
commits = {}
|
|
161
|
+
for phase_key in ("red", "green", "refactor", "pin"):
|
|
162
|
+
msg = raw.get(f"commit_{phase_key}")
|
|
163
|
+
if msg is not None:
|
|
164
|
+
if not isinstance(msg, str):
|
|
165
|
+
raise ContractError(f"cycle {ordinal}: commit_{phase_key} must be a string")
|
|
166
|
+
commits[phase_key] = msg
|
|
167
|
+
|
|
168
|
+
return DeclaredCycle(
|
|
169
|
+
ordinal=ordinal,
|
|
170
|
+
kind=kind,
|
|
171
|
+
projects=projects,
|
|
172
|
+
tests=tests,
|
|
173
|
+
title=raw.get("title", ""),
|
|
174
|
+
files=_as_list(raw.get("files"), "files", ordinal),
|
|
175
|
+
stub_expected=_as_list(raw.get("stub_expected"), "stub_expected", ordinal),
|
|
176
|
+
modifies_tests=_as_list(raw.get("modifies_tests"), "modifies_tests", ordinal),
|
|
177
|
+
commit_messages=commits,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse(text: str, plan_path: str, config: Config | None = None) -> PlanContract:
|
|
182
|
+
"""Absent front-matter is legitimate (R7.9); malformed front-matter is a defect (R7.10)."""
|
|
183
|
+
block = split_front_matter(text)
|
|
184
|
+
if block is None:
|
|
185
|
+
return PlanContract(plan_path=plan_path, status="undeclared", cycles=[], annotation_keys=[])
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
data = yaml.safe_load(block)
|
|
189
|
+
except yaml.YAMLError as exc:
|
|
190
|
+
raise ContractError(f"front-matter is not valid YAML: {exc}") from exc
|
|
191
|
+
|
|
192
|
+
if not isinstance(data, dict):
|
|
193
|
+
raise ContractError("front-matter must be a mapping")
|
|
194
|
+
if "cycles" not in data:
|
|
195
|
+
raise ContractError("front-matter present but declares no `cycles`")
|
|
196
|
+
if not isinstance(data["cycles"], list) or not data["cycles"]:
|
|
197
|
+
raise ContractError("`cycles` must be a non-empty list")
|
|
198
|
+
|
|
199
|
+
cycles = [parse_cycle(c, config) for c in data["cycles"]]
|
|
200
|
+
ordinals = [c.ordinal for c in cycles]
|
|
201
|
+
if len(set(ordinals)) != len(ordinals):
|
|
202
|
+
raise ContractError(f"duplicate cycle ordinals: {ordinals}")
|
|
203
|
+
cycles.sort(key=lambda c: c.ordinal)
|
|
204
|
+
|
|
205
|
+
keys = data.get("annotation_keys", [])
|
|
206
|
+
if not isinstance(keys, list) or not all(isinstance(k, str) for k in keys):
|
|
207
|
+
raise ContractError("annotation_keys must be a list of strings")
|
|
208
|
+
|
|
209
|
+
return PlanContract(
|
|
210
|
+
plan_path=plan_path,
|
|
211
|
+
status="declared",
|
|
212
|
+
cycles=cycles,
|
|
213
|
+
annotation_keys=keys,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def register(worktree: Path, plan_rel: str, config: Config | None) -> PlanContract:
|
|
218
|
+
"""Read the plan as committed — never the working-tree copy."""
|
|
219
|
+
try:
|
|
220
|
+
blob, commit = gitutil.blob_sha_at_head(worktree, plan_rel)
|
|
221
|
+
text = gitutil.show_at_head(worktree, plan_rel)
|
|
222
|
+
except gitutil.GitError as exc:
|
|
223
|
+
raise ContractError(
|
|
224
|
+
f"{plan_rel} must be committed before registration: {exc}"
|
|
225
|
+
) from exc
|
|
226
|
+
contract = parse(text, plan_rel, config)
|
|
227
|
+
contract.blob_sha = blob
|
|
228
|
+
contract.commit_sha = commit
|
|
229
|
+
return contract
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cycles_to_json(cycles: list[DeclaredCycle]) -> str:
|
|
233
|
+
return json.dumps([c.to_dict() for c in cycles])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cycles_from_json(blob: str) -> list[DeclaredCycle]:
|
|
237
|
+
return [DeclaredCycle.from_dict(d) for d in json.loads(blob)]
|
tddcli/envelope.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Output envelope and the closed next_action verb set (R8.3a).
|
|
2
|
+
|
|
3
|
+
`verb` is the authority on control flow. `detail` is human-readable and explicitly
|
|
4
|
+
non-authoritative — skills and hooks dispatch on the verb, never on the prose.
|
|
5
|
+
Adding a verb is a specification change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
VERB_SET_VERSION = 2
|
|
16
|
+
|
|
17
|
+
#: Version of the envelope shape itself — the top-level keys and their meaning.
|
|
18
|
+
#: Harness hooks and skills parse this structure programmatically; bump it on any
|
|
19
|
+
#: change to the shape so consumers can detect drift instead of breaking silently.
|
|
20
|
+
ENVELOPE_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Verb(str, Enum):
|
|
24
|
+
WRITE_TEST = "write_test"
|
|
25
|
+
WRITE_IMPLEMENTATION = "write_implementation"
|
|
26
|
+
CREATE_STUB = "create_stub"
|
|
27
|
+
FIX_REGRESSION = "fix_regression"
|
|
28
|
+
RUN_SENSITIVITY_CHECK = "run_sensitivity_check"
|
|
29
|
+
NAME_TARGET_TEST = "name_target_test"
|
|
30
|
+
REFACTOR_OR_ADVANCE = "refactor_or_advance"
|
|
31
|
+
CONFIRM_CYCLE_APPLICABLE = "confirm_cycle_applicable"
|
|
32
|
+
ANNOTATE_CYCLE = "annotate_cycle"
|
|
33
|
+
RESOLVE_BLOCKER = "resolve_blocker"
|
|
34
|
+
AWAIT_BASELINE = "await_baseline"
|
|
35
|
+
COMPLETE = "complete"
|
|
36
|
+
BLOCKED = "blocked"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TERMINAL_VERBS = {Verb.COMPLETE, Verb.BLOCKED}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class NextAction:
|
|
44
|
+
verb: Verb
|
|
45
|
+
detail: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def terminal(self) -> bool:
|
|
49
|
+
return self.verb in TERMINAL_VERBS
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"verb": self.verb.value,
|
|
54
|
+
"detail": self.detail,
|
|
55
|
+
"terminal": self.terminal,
|
|
56
|
+
"verb_set_version": VERB_SET_VERSION,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Envelope:
|
|
62
|
+
ok: bool = True
|
|
63
|
+
run: dict | None = None
|
|
64
|
+
result: dict = field(default_factory=dict)
|
|
65
|
+
next_action: NextAction | None = None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
#: Set by commands that have already written human-readable output. The envelope
|
|
68
|
+
#: is still returned (tests assert on it) but is not printed alongside the prose.
|
|
69
|
+
silent: bool = False
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
out: dict = {"ok": self.ok, "envelope_version": ENVELOPE_VERSION}
|
|
73
|
+
if self.error is not None:
|
|
74
|
+
out["error"] = self.error
|
|
75
|
+
out["run"] = self.run
|
|
76
|
+
out["result"] = self.result
|
|
77
|
+
out["next_action"] = self.next_action.to_dict() if self.next_action else None
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
def emit(self) -> int:
|
|
81
|
+
if not self.silent:
|
|
82
|
+
json.dump(self.to_dict(), sys.stdout, indent=2, default=str)
|
|
83
|
+
sys.stdout.write("\n")
|
|
84
|
+
return 0 if self.ok else 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def failure(error: str, **result) -> Envelope:
|
|
88
|
+
return Envelope(ok=False, error=error, result=result)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def heartbeat(**fields) -> None:
|
|
92
|
+
"""A progress line on stderr. Never stdout — PRD §8's envelope
|
|
93
|
+
contract lives there, and NDJSON in front of it breaks every consumer that does
|
|
94
|
+
`json.loads(stdout)`. `flush=True` is not optional: unflushed output defeats the
|
|
95
|
+
entire purpose of making a slow baseline legible while it runs."""
|
|
96
|
+
print(json.dumps(fields), file=sys.stderr, flush=True)
|