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.
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)