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/advance.py ADDED
@@ -0,0 +1,423 @@
1
+ """Phase handlers for `tdd advance` — the only command that changes phase (§8.3).
2
+
3
+ Every transition here is the consequence of an observed suite run. Nothing accepts a
4
+ phase from the caller.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ from . import adapters, gitutil, staging
12
+ from . import config as config_mod
13
+ from .adapters.base import FAILED, NOT_COLLECTED, NOT_FOUND, PASSED
14
+ from .envelope import Envelope, NextAction, Verb
15
+ from .machine import (
16
+ AWAITING_IMPL,
17
+ AWAITING_PIN,
18
+ AWAITING_REFACTOR,
19
+ AWAITING_TEST,
20
+ SENSITIVITY_REQUIRED,
21
+ Engine,
22
+ )
23
+
24
+
25
+ def _reply(engine: Engine, cycle, verb: Verb, detail: str, **result) -> Envelope:
26
+ fresh = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],)) if cycle else None
27
+ return Envelope(
28
+ run=engine.run_state(fresh or cycle),
29
+ result=result,
30
+ next_action=NextAction(verb, detail),
31
+ )
32
+
33
+
34
+ def _last_invocation_hash(engine: Engine, cycle) -> str | None:
35
+ rows = engine.ledger.invocations(cycle["id"], cycle["phase"])
36
+ return rows[-1]["tree_hash"] if rows else None
37
+
38
+
39
+ def _adopt_target(engine: Engine, cycle, missing: list[str]) -> tuple[list[str], list[str]]:
40
+ """R8.9 — resolve the target from newly collected tests when the declared id misses."""
41
+ projects = json.loads(cycle["projects"])
42
+ at_start = engine.ledger.collection(engine.run["id"])
43
+ known = {t for tests in at_start.values() for t in tests}
44
+ existing_targets = set(json.loads(cycle["target_tests"]))
45
+
46
+ new_tests: list[str] = []
47
+ for name in projects:
48
+ adapter = adapters.build(engine.config.project(name), engine.worktree)
49
+ current = adapter.collect()
50
+ new_tests.extend(sorted(current.tests - known - existing_targets))
51
+ return new_tests, missing
52
+
53
+
54
+ def _stub_directive_issued(engine: Engine, cycle) -> bool:
55
+ return engine.ledger.one(
56
+ "SELECT id FROM integrity_event WHERE cycle_id = ? AND kind = 'stub_directive_issued'",
57
+ (cycle["id"],),
58
+ ) is not None
59
+
60
+
61
+ def _sanctioned_stubs(engine: Engine, cycle, implementation: list[str]) -> list[str]:
62
+ """The files that answer a `create_stub` directive the tool itself issued.
63
+
64
+ Only *new* files qualify: an uncollectable import is satisfied by a module that
65
+ did not exist, so a change to a file already at HEAD is implementation however
66
+ the cycle arrived here.
67
+ """
68
+ if not implementation or not _stub_directive_issued(engine, cycle):
69
+ return []
70
+ at_head = gitutil.tracked_at_head(engine.worktree, implementation)
71
+ return [p for p in implementation if p not in at_head]
72
+
73
+
74
+ def _stage_and_commit(engine: Engine, cycle, phase: str, declared) -> tuple[str | None, list[str], object]:
75
+ changed = engine.authored_changes(cycle)
76
+ classification = staging.classify(
77
+ engine.config, changed, json.loads(cycle["projects"]), declared, engine.excluded
78
+ )
79
+ if phase == staging.RED:
80
+ adopted = _sanctioned_stubs(engine, cycle, classification.implementation)
81
+ if adopted:
82
+ classification.adopt_stubs(adopted)
83
+ engine.ledger.event(
84
+ engine.run["id"], cycle["id"], "stub_adopted", json.dumps(adopted),
85
+ )
86
+ if classification.implementation:
87
+ engine.ledger.event(
88
+ engine.run["id"], cycle["id"], "implementation_during_red",
89
+ json.dumps(classification.implementation),
90
+ )
91
+ if classification.outside:
92
+ engine.ledger.event(
93
+ engine.run["id"], cycle["id"], "undeclared_file_touched",
94
+ json.dumps(classification.outside),
95
+ )
96
+ paths = staging.paths_for_phase(phase, classification)
97
+ message = staging.default_message(phase, declared, cycle["ordinal"])
98
+ sha, staged = staging.commit(
99
+ engine.worktree, paths, message, engine.trailers(cycle, phase)
100
+ )
101
+ if sha:
102
+ engine.record_commit(cycle, phase, sha, message, staged)
103
+ return sha, staged, classification
104
+
105
+
106
+ # -- handlers ------------------------------------------------------------
107
+
108
+
109
+ def _handle_test_phase(engine: Engine, cycle, retried: bool, expect_pass: bool) -> Envelope:
110
+ """AWAITING_TEST (expect fail) and AWAITING_PIN (expect pass) share this shape."""
111
+ declared = engine.declared_for(cycle["ordinal"])
112
+ projects = json.loads(cycle["projects"])
113
+ targets = json.loads(cycle["target_tests"])
114
+ phase = cycle["phase"]
115
+
116
+ outcomes, others, _, failure = engine.run_projects(
117
+ projects, targets, cycle, phase, retried
118
+ )
119
+
120
+ missing = [t for t, o in outcomes.items() if o == NOT_FOUND]
121
+ if missing:
122
+ candidates, _ = _adopt_target(engine, cycle, missing)
123
+ if len(candidates) == 1:
124
+ engine.ledger.event(
125
+ engine.run["id"], cycle["id"], "declared_test_mismatch",
126
+ json.dumps({"declared": missing, "adopted": candidates}),
127
+ )
128
+ kept = [t for t in targets if t not in missing]
129
+ engine.ledger.update(
130
+ "cycle", cycle["id"], target_tests=json.dumps(kept + candidates)
131
+ )
132
+ cycle = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],))
133
+ return _reply(
134
+ engine, cycle, Verb.REFACTOR_OR_ADVANCE,
135
+ f"Adopted {candidates[0]} as the target (declared {missing[0]} was not"
136
+ " collected). Run `tdd advance` again to evaluate it.",
137
+ adopted=candidates,
138
+ )
139
+ if len(candidates) > 1:
140
+ engine.ledger.event(
141
+ engine.run["id"], cycle["id"], "multiple_new_tests",
142
+ json.dumps(candidates),
143
+ )
144
+ return _reply(
145
+ engine, cycle, Verb.NAME_TARGET_TEST,
146
+ "Several new tests appeared; a cycle covers one behaviour. Name the"
147
+ " intended target with `tdd target <id>`.",
148
+ candidates=candidates,
149
+ )
150
+ return _reply(
151
+ engine, cycle, Verb.WRITE_TEST,
152
+ f"Target {missing[0]} was not collected and no new test was found."
153
+ " Write the failing test.",
154
+ missing=missing,
155
+ )
156
+
157
+ not_collected = [t for t, o in outcomes.items() if o == NOT_COLLECTED]
158
+ if not_collected:
159
+ if not _stub_directive_issued(engine, cycle):
160
+ engine.ledger.event(
161
+ engine.run["id"], cycle["id"], "stub_directive_issued",
162
+ json.dumps(not_collected),
163
+ )
164
+ owner = not_collected[0].split("::", 1)[0]
165
+ hint = adapters.build(engine.config.project(owner), engine.worktree).stub_hint()
166
+ return _reply(
167
+ engine, cycle, Verb.CREATE_STUB,
168
+ f"{not_collected[0]} could not be collected — the module it imports does not"
169
+ " exist yet. Create the stub and nothing else: no logic, no behaviour, just"
170
+ f" enough for the import and the type checker ({hint})."
171
+ " It is staged with the test in the RED commit, not counted as"
172
+ " implementation. Then run `tdd advance`.",
173
+ not_collected=not_collected, failure=failure,
174
+ )
175
+
176
+ if others:
177
+ return _reply(
178
+ engine, cycle, Verb.FIX_REGRESSION,
179
+ f"{len(others)} test(s) outside this cycle are failing. Fix them before"
180
+ " proceeding.",
181
+ other_failures=others,
182
+ )
183
+
184
+ passed_all = all(o == PASSED for o in outcomes.values())
185
+ failed_all = all(o == FAILED for o in outcomes.values())
186
+
187
+ if expect_pass:
188
+ if not passed_all:
189
+ return _reply(
190
+ engine, cycle, Verb.WRITE_TEST,
191
+ "A pin cycle's test must pass on arrival — it characterises behaviour"
192
+ " that already exists. This one does not pass.",
193
+ outcomes=outcomes, failure=failure,
194
+ )
195
+ sha, staged, _ = _stage_and_commit(engine, cycle, staging.PIN, declared)
196
+ engine.transition(cycle, SENSITIVITY_REQUIRED)
197
+ return _reply(
198
+ engine, cycle, Verb.RUN_SENSITIVITY_CHECK,
199
+ "Pin recorded. Now prove it bites: `tdd sensitivity begin`, mutate the"
200
+ " behaviour under test, `tdd sensitivity check`, then `tdd sensitivity end`.",
201
+ commit=sha, staged=staged,
202
+ )
203
+
204
+ if passed_all:
205
+ engine.ledger.event(
206
+ engine.run["id"], cycle["id"], "red_first_violation", json.dumps(targets)
207
+ )
208
+ # The phase must move, or the verified check is never read: _handle_sensitivity
209
+ # is reachable only from SENSITIVITY_REQUIRED, so staying here asks forever.
210
+ engine.transition(cycle, SENSITIVITY_REQUIRED)
211
+ return _reply(
212
+ engine, cycle, Verb.RUN_SENSITIVITY_CHECK,
213
+ "The test passed before any implementation. Run a sensitivity check to prove"
214
+ " it can fail: `tdd sensitivity begin`, mutate, `tdd sensitivity check`,"
215
+ " `tdd sensitivity end`.",
216
+ outcomes=outcomes,
217
+ )
218
+
219
+ if not failed_all:
220
+ return _reply(
221
+ engine, cycle, Verb.WRITE_TEST,
222
+ "A contract cycle's targets must all fail together before implementation.",
223
+ outcomes=outcomes,
224
+ )
225
+
226
+ sha, staged, classification = _stage_and_commit(engine, cycle, staging.RED, declared)
227
+ engine.transition(cycle, AWAITING_IMPL)
228
+ return _reply(
229
+ engine, cycle, Verb.WRITE_IMPLEMENTATION,
230
+ f"RED confirmed. Write the minimum code to pass {targets[0]}, then"
231
+ " `tdd advance`.",
232
+ commit=sha, staged=staged, failure=failure,
233
+ implementation_during_red=classification.implementation or None,
234
+ )
235
+
236
+
237
+ def _handle_impl(engine: Engine, cycle, retried: bool) -> Envelope:
238
+ declared = engine.declared_for(cycle["ordinal"])
239
+ projects = json.loads(cycle["projects"])
240
+ targets = json.loads(cycle["target_tests"])
241
+
242
+ outcomes, others, _, failure = engine.run_projects(
243
+ projects, targets, cycle, AWAITING_IMPL, retried
244
+ )
245
+
246
+ if others:
247
+ return _reply(
248
+ engine, cycle, Verb.FIX_REGRESSION,
249
+ f"The implementation broke {len(others)} test(s) elsewhere.",
250
+ other_failures=others,
251
+ )
252
+ if not all(o == PASSED for o in outcomes.values()):
253
+ return _reply(
254
+ engine, cycle, Verb.WRITE_IMPLEMENTATION,
255
+ "Target still failing. Adjust the implementation and `tdd advance`.",
256
+ outcomes=outcomes, failure=failure,
257
+ )
258
+
259
+ sha, staged, _ = _stage_and_commit(engine, cycle, staging.GREEN, declared)
260
+ engine.transition(cycle, AWAITING_REFACTOR)
261
+ return _reply(
262
+ engine, cycle, Verb.REFACTOR_OR_ADVANCE,
263
+ "GREEN confirmed. Refactor if the plan calls for it, then `tdd advance` to close"
264
+ " the cycle.",
265
+ commit=sha, staged=staged,
266
+ )
267
+
268
+
269
+ def _handle_sensitivity(engine: Engine, cycle) -> Envelope:
270
+ done = engine.ledger.completed_sensitivity(cycle["id"])
271
+ if done is None:
272
+ return _reply(
273
+ engine, cycle, Verb.RUN_SENSITIVITY_CHECK,
274
+ "A verified sensitivity check is required before this cycle can proceed.",
275
+ )
276
+ engine.transition(cycle, AWAITING_REFACTOR)
277
+ return _reply(
278
+ engine, cycle, Verb.REFACTOR_OR_ADVANCE,
279
+ "Sensitivity verified. Perform the planned refactor, then `tdd advance`.",
280
+ )
281
+
282
+
283
+ def _handle_refactor(engine: Engine, cycle, retried: bool) -> Envelope:
284
+ declared = engine.declared_for(cycle["ordinal"])
285
+ missing = engine.missing_annotations(cycle)
286
+ if missing:
287
+ return _reply(
288
+ engine, cycle, Verb.ANNOTATE_CYCLE,
289
+ "This plan requires judgement annotations before a cycle closes: "
290
+ + ", ".join(missing),
291
+ missing_annotations=missing,
292
+ )
293
+
294
+ touched = engine.authored_changes(cycle)
295
+ sha, staged, _ = _stage_and_commit(engine, cycle, staging.REFACTOR, declared)
296
+ if sha:
297
+ touched = set(staged) | touched
298
+
299
+ regenerated = engine.check_artifacts(cycle)
300
+
301
+ # §6.1 — when nothing changed since GREEN, the cycle's own suites just passed on an
302
+ # identical tree. A refactor cycle never skips: its suite is the only guard.
303
+ prior = engine.ledger.invocations(cycle["id"], AWAITING_IMPL)
304
+ skip_own = bool(
305
+ cycle["kind"] != "refactor"
306
+ and sha is None
307
+ and prior
308
+ and prior[-1]["tree_hash"] == engine.tree_hash(json.loads(cycle["projects"]))
309
+ )
310
+ outcome = engine.sweep(cycle, touched, skip_own=skip_own)
311
+
312
+ if not outcome.ok:
313
+ if outcome.failures:
314
+ return _reply(
315
+ engine, cycle, Verb.FIX_REGRESSION,
316
+ f"Close sweep found {len(outcome.failures)} failing test(s).",
317
+ failures=outcome.failures, commit=sha,
318
+ )
319
+ gates = [{"project": p, "kind": k, "output": o} for p, k, o in outcome.gates]
320
+ return _reply(
321
+ engine, cycle, Verb.FIX_REGRESSION,
322
+ "Close sweep is green but lint/typecheck gates failed.",
323
+ gates=gates, commit=sha,
324
+ )
325
+
326
+ nxt = engine.close_cycle(cycle)
327
+ if nxt is None:
328
+ return Envelope(
329
+ run={
330
+ "id": engine.run["id"],
331
+ "plan": engine.contract_row["plan_path"],
332
+ "cycle": cycle["ordinal"],
333
+ "of": len(engine.declared),
334
+ "phase": "CLOSED",
335
+ "executor": engine.run["executor_model"],
336
+ },
337
+ result={"commit": sha, "regenerated": regenerated or None},
338
+ next_action=NextAction(
339
+ Verb.COMPLETE, "All declared cycles are complete. Run `tdd log render`."
340
+ ),
341
+ )
342
+ verb, opening = engine.opening_action(nxt)
343
+ return _reply(
344
+ engine, nxt, verb, f"Cycle {cycle['ordinal']} closed. {opening}",
345
+ commit=sha, regenerated=regenerated or None,
346
+ )
347
+
348
+
349
+ # -- entry point ---------------------------------------------------------
350
+
351
+ HANDLERS = {
352
+ AWAITING_TEST: lambda e, c, r: _handle_test_phase(e, c, r, expect_pass=False),
353
+ AWAITING_PIN: lambda e, c, r: _handle_test_phase(e, c, r, expect_pass=True),
354
+ AWAITING_IMPL: _handle_impl,
355
+ AWAITING_REFACTOR: _handle_refactor,
356
+ SENSITIVITY_REQUIRED: lambda e, c, r: _handle_sensitivity(e, c),
357
+ }
358
+
359
+
360
+ def _check_config_drift(engine: Engine, cycle) -> None:
361
+ """The registry is a reviewed file, not ledger state — so pin it and watch it.
362
+
363
+ Widening `test_paths` mid-run would reclassify implementation as tests and
364
+ silently disable the RED-commit detection in R9.14.
365
+ """
366
+ pinned = engine.run["config_sha"]
367
+ if not pinned:
368
+ return
369
+ current = config_mod.config_sha(engine.worktree)
370
+ if current == pinned:
371
+ return
372
+ already = engine.ledger.one(
373
+ "SELECT id FROM integrity_event WHERE run_id = ? AND kind = 'config_changed'"
374
+ " AND detail = ?",
375
+ (engine.run["id"], current),
376
+ )
377
+ if already is None:
378
+ engine.ledger.event(
379
+ engine.run["id"], cycle["id"], "config_changed",
380
+ current,
381
+ )
382
+
383
+
384
+ def advance(engine: Engine, cycle, retry: bool = False) -> Envelope:
385
+ _check_config_drift(engine, cycle)
386
+
387
+ if engine.ledger.open_sensitivity(cycle["id"]) is not None:
388
+ return _reply(
389
+ engine, cycle, Verb.RUN_SENSITIVITY_CHECK,
390
+ "A sensitivity check is open. Close it with `tdd sensitivity end` before"
391
+ " advancing — the tree is deliberately mutated.",
392
+ )
393
+
394
+ phase = cycle["phase"]
395
+ if phase in (AWAITING_TEST, AWAITING_PIN, AWAITING_IMPL):
396
+ current = engine.tree_hash(json.loads(cycle["projects"]))
397
+ previous = _last_invocation_hash(engine, cycle)
398
+ if previous == current and not retry:
399
+ consecutive = sum(
400
+ 1 for r in engine.ledger.invocations(cycle["id"], phase)[-3:]
401
+ if r["retried"]
402
+ )
403
+ if consecutive >= 3:
404
+ return _reply(
405
+ engine, cycle, Verb.RESOLVE_BLOCKER,
406
+ "Three consecutive retries with no change to the tree. Record a"
407
+ " blocker with `tdd blocker` or change the code.",
408
+ )
409
+ return _reply(
410
+ engine, cycle, Verb.WRITE_IMPLEMENTATION
411
+ if phase == AWAITING_IMPL else Verb.WRITE_TEST,
412
+ "no_change_since_last_run — nothing under this cycle's project roots has"
413
+ " changed since the last run. Edit the code, or pass --retry to re-run"
414
+ " anyway (flaky or environmental failures).",
415
+ )
416
+
417
+ handler = HANDLERS.get(phase)
418
+ if handler is None:
419
+ return Envelope(
420
+ ok=False, error=f"no handler for phase {phase}",
421
+ run=engine.run_state(cycle),
422
+ )
423
+ return handler(engine, cycle, retry)