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