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/snapshot.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Working-tree snapshots for sensitivity checks (§8.4, R8.5).
|
|
2
|
+
|
|
3
|
+
Restoration must return the tree to its *pre-mutation* state, never to HEAD: at a
|
|
4
|
+
passed-on-arrival the RED test is written but uncommitted, so the tree is legitimately
|
|
5
|
+
dirty and `git checkout --` would destroy it. Untracked files cannot be restored by
|
|
6
|
+
git at all, so contents are captured directly.
|
|
7
|
+
|
|
8
|
+
Build output is excluded throughout — a suite run between `begin` and `end` rewrites
|
|
9
|
+
`.pyc` files, and comparing those would make every verification fail.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import gitutil
|
|
20
|
+
from .config import Config
|
|
21
|
+
|
|
22
|
+
MAX_CAPTURE_BYTES = 4 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def authored_dirty(worktree: Path, config: Config) -> set[str]:
|
|
26
|
+
return {p for p in gitutil.dirty_paths(worktree) if not config.is_ignored(p)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read(worktree: Path, rel: str) -> bytes | None:
|
|
30
|
+
path = worktree / rel
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
return None
|
|
33
|
+
if path.stat().st_size > MAX_CAPTURE_BYTES:
|
|
34
|
+
return None
|
|
35
|
+
return path.read_bytes()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def capture(worktree: Path, config: Config) -> str:
|
|
39
|
+
"""Snapshot every non-ignored dirty path, contents included."""
|
|
40
|
+
entries = {}
|
|
41
|
+
for rel in sorted(authored_dirty(worktree, config)):
|
|
42
|
+
data = _read(worktree, rel)
|
|
43
|
+
entries[rel] = (
|
|
44
|
+
None if data is None else base64.b64encode(data).decode("ascii")
|
|
45
|
+
)
|
|
46
|
+
return json.dumps({"files": entries})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fingerprint(worktree: Path, config: Config) -> str:
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
for rel in sorted(authored_dirty(worktree, config)):
|
|
52
|
+
h.update(rel.encode())
|
|
53
|
+
data = _read(worktree, rel)
|
|
54
|
+
h.update(hashlib.sha256(data).digest() if data is not None else b"missing")
|
|
55
|
+
return h.hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def restore(worktree: Path, config: Config, snapshot_json: str) -> list[str]:
|
|
59
|
+
"""Return the tree to the captured state. Returns the paths acted on."""
|
|
60
|
+
saved = json.loads(snapshot_json)["files"]
|
|
61
|
+
touched: list[str] = []
|
|
62
|
+
|
|
63
|
+
for rel in sorted(authored_dirty(worktree, config)):
|
|
64
|
+
if rel in saved:
|
|
65
|
+
continue
|
|
66
|
+
# Dirty now, clean when captured: revert it, or remove it if it is new.
|
|
67
|
+
path = worktree / rel
|
|
68
|
+
tracked = bool(
|
|
69
|
+
gitutil.git(worktree, "ls-files", "--", rel, check=False).strip()
|
|
70
|
+
)
|
|
71
|
+
if tracked:
|
|
72
|
+
gitutil.checkout_paths(worktree, [rel])
|
|
73
|
+
elif path.is_file():
|
|
74
|
+
path.unlink()
|
|
75
|
+
touched.append(rel)
|
|
76
|
+
|
|
77
|
+
for rel, encoded in saved.items():
|
|
78
|
+
path = worktree / rel
|
|
79
|
+
if encoded is None:
|
|
80
|
+
if path.is_file():
|
|
81
|
+
path.unlink()
|
|
82
|
+
touched.append(rel)
|
|
83
|
+
continue
|
|
84
|
+
data = base64.b64decode(encoded)
|
|
85
|
+
if not path.is_file() or path.read_bytes() != data:
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
path.write_bytes(data)
|
|
88
|
+
touched.append(rel)
|
|
89
|
+
|
|
90
|
+
return sorted(set(touched))
|
tddcli/staging.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Staging and commits, derived from the phase being left (§9.5).
|
|
2
|
+
|
|
3
|
+
The tool stages; agents never do. `git add -A` is never used.
|
|
4
|
+
|
|
5
|
+
R9.14 falls out of this for free: if the RED commit's staged set would contain a file
|
|
6
|
+
that is neither a test nor a declared stub, that *is* implementation written during
|
|
7
|
+
RED — detected exactly, in every language, without parsing a line of source.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import gitutil
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .contract import DeclaredCycle
|
|
18
|
+
|
|
19
|
+
RED = "red"
|
|
20
|
+
GREEN = "green"
|
|
21
|
+
REFACTOR = "refactor"
|
|
22
|
+
PIN = "pin"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Classification:
|
|
27
|
+
tests: list[str] = field(default_factory=list)
|
|
28
|
+
stubs: list[str] = field(default_factory=list)
|
|
29
|
+
implementation: list[str] = field(default_factory=list)
|
|
30
|
+
generated: list[str] = field(default_factory=list)
|
|
31
|
+
outside: list[str] = field(default_factory=list) # outside every project root
|
|
32
|
+
excluded: list[str] = field(default_factory=list) # pre-existing dirt (R9.21)
|
|
33
|
+
ignored: list[str] = field(default_factory=list) # build output; never authored
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def undeclared_impl(self) -> list[str]:
|
|
37
|
+
return self.implementation
|
|
38
|
+
|
|
39
|
+
def adopt_stubs(self, paths: list[str]) -> None:
|
|
40
|
+
"""Move tool-sanctioned stubs out of implementation and into the RED commit.
|
|
41
|
+
|
|
42
|
+
The plan declares stubs it foresaw; this covers the ones the *tool* demanded
|
|
43
|
+
after an uncollectable target (R9.14) — leaving them classified as
|
|
44
|
+
implementation would report the agent for doing exactly as it was told.
|
|
45
|
+
"""
|
|
46
|
+
chosen = set(paths)
|
|
47
|
+
self.implementation = [p for p in self.implementation if p not in chosen]
|
|
48
|
+
self.stubs = sorted(set(self.stubs) | chosen)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def classify(
|
|
52
|
+
config: Config,
|
|
53
|
+
changed: set[str],
|
|
54
|
+
cycle_projects: list[str],
|
|
55
|
+
declared: DeclaredCycle | None,
|
|
56
|
+
excluded: set[str],
|
|
57
|
+
) -> Classification:
|
|
58
|
+
out = Classification()
|
|
59
|
+
stub_set = set(declared.stub_expected) if declared else set()
|
|
60
|
+
roots = {config.project(p).root for p in cycle_projects}
|
|
61
|
+
|
|
62
|
+
for rel in sorted(changed):
|
|
63
|
+
if config.is_ignored(rel): # build output is never authored
|
|
64
|
+
out.ignored.append(rel)
|
|
65
|
+
continue
|
|
66
|
+
if rel in excluded:
|
|
67
|
+
out.excluded.append(rel)
|
|
68
|
+
continue
|
|
69
|
+
if config.is_generated(rel): # R7.7
|
|
70
|
+
out.generated.append(rel)
|
|
71
|
+
continue
|
|
72
|
+
owner = config.owning_project(rel)
|
|
73
|
+
if owner is None or owner.root not in roots:
|
|
74
|
+
out.outside.append(rel)
|
|
75
|
+
continue
|
|
76
|
+
if owner.is_test_file(rel):
|
|
77
|
+
out.tests.append(rel)
|
|
78
|
+
elif rel in stub_set or owner.relative_to_root(rel) in stub_set:
|
|
79
|
+
out.stubs.append(rel)
|
|
80
|
+
else:
|
|
81
|
+
out.implementation.append(rel)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def paths_for_phase(phase: str, classification: Classification) -> list[str]:
|
|
86
|
+
if phase == RED:
|
|
87
|
+
return sorted(classification.tests + classification.stubs)
|
|
88
|
+
if phase == PIN:
|
|
89
|
+
return sorted(classification.tests)
|
|
90
|
+
# GREEN and REFACTOR take everything authored inside the cycle's projects.
|
|
91
|
+
return sorted(
|
|
92
|
+
classification.tests + classification.stubs + classification.implementation
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def default_message(phase: str, declared: DeclaredCycle | None, ordinal: int) -> str:
|
|
97
|
+
if declared and phase in declared.commit_messages:
|
|
98
|
+
return declared.commit_messages[phase]
|
|
99
|
+
title = (declared.title if declared else "") or f"cycle {ordinal}"
|
|
100
|
+
return {
|
|
101
|
+
RED: f"test: {title}",
|
|
102
|
+
PIN: f"test: pin {title}",
|
|
103
|
+
GREEN: f"feat: {title}",
|
|
104
|
+
REFACTOR: f"refactor: {title}",
|
|
105
|
+
}[phase]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def commit(
|
|
109
|
+
worktree: Path,
|
|
110
|
+
paths: list[str],
|
|
111
|
+
message: str,
|
|
112
|
+
trailers: dict[str, str],
|
|
113
|
+
) -> tuple[str | None, list[str]]:
|
|
114
|
+
"""Stage exactly `paths` and commit. Returns (sha, staged) — (None, []) if empty."""
|
|
115
|
+
gitutil.reset_index(worktree)
|
|
116
|
+
if not paths:
|
|
117
|
+
return None, []
|
|
118
|
+
gitutil.add(worktree, paths)
|
|
119
|
+
staged = gitutil.staged_paths(worktree)
|
|
120
|
+
if not staged:
|
|
121
|
+
return None, []
|
|
122
|
+
sha = gitutil.commit(worktree, message, trailers)
|
|
123
|
+
return sha, staged
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def commit_generated(
|
|
127
|
+
worktree: Path, paths: list[str], artifact: str, trailers: dict[str, str]
|
|
128
|
+
) -> tuple[str | None, list[str]]:
|
|
129
|
+
"""R9.20 — regenerated output lands in its own commit, separately reviewable."""
|
|
130
|
+
return commit(worktree, paths, f"chore({artifact}): regenerate", trailers)
|