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