tdd-enforcer 0.2.7 → 0.2.9
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.
- package/adapters/pi/hooks.ts +4 -1
- package/adapters/pi/index.test.ts +194 -0
- package/adapters/pi/index.ts +85 -0
- package/adapters/pi/prompts.ts +2 -1
- package/adapters/pi/tools.test.ts +58 -47
- package/adapters/pi/tools.ts +24 -71
- package/package.json +30 -30
- package/skills/tdd-enforcer/SKILL.md +1 -1
package/adapters/pi/hooks.ts
CHANGED
|
@@ -120,7 +120,7 @@ export async function handleToolCall(
|
|
|
120
120
|
return {
|
|
121
121
|
block: true,
|
|
122
122
|
reason:
|
|
123
|
-
"TDD: Config files are locked. No bypassing TDD allowed. If bypassing is justified, ask the user: turn TDD off (/tdd:off), reset (/tdd:reset), or change phase via /tdd commands.",
|
|
123
|
+
"TDD: Config files are locked. No bypassing TDD allowed. If bypassing is justified, ask the user: turn TDD off (/tdd:off), reset (/tdd:reset), or change phase via /tdd commands.\n\nIf TDD reverts too much of your progress, reduce the scope of each TDD cycle to minimise lost progress.",
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -267,6 +267,9 @@ function formatWarning(
|
|
|
267
267
|
.join("");
|
|
268
268
|
let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
|
|
269
269
|
for (const f of cmdViolations) warning += `\n - ${f}`;
|
|
270
|
+
if (cmdViolations.some((f) => f.startsWith(".pi/tdd/"))) {
|
|
271
|
+
warning += `\n\nIf TDD reverts too much of your progress, reduce the scope of each TDD cycle to minimise lost progress.`;
|
|
272
|
+
}
|
|
270
273
|
if (cmdAllowed.length > 0) {
|
|
271
274
|
warning += `\n\nAllowed changes retained:`;
|
|
272
275
|
for (const f of cmdAllowed) warning += `\n - ${f}`;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
handleBeforeAgentStart,
|
|
4
|
+
handleTddJump,
|
|
3
5
|
handleTddOff,
|
|
4
6
|
handleTddOn,
|
|
5
7
|
handleTddReset,
|
|
@@ -310,3 +312,195 @@ describe("handleTddReset", () => {
|
|
|
310
312
|
expect(ctx.notifications[0].type).toBe("error");
|
|
311
313
|
});
|
|
312
314
|
});
|
|
315
|
+
|
|
316
|
+
// ── handleTddJump ───────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe("handleTddJump", () => {
|
|
319
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
320
|
+
let mockSnapshot: ReturnType<typeof vi.fn>;
|
|
321
|
+
let mockSavePhaseState: ReturnType<typeof vi.fn>;
|
|
322
|
+
let mockTddLog: ReturnType<typeof vi.fn>;
|
|
323
|
+
|
|
324
|
+
function makeDeps(overrides = {}) {
|
|
325
|
+
return {
|
|
326
|
+
loadTddState: mockLoadTddState,
|
|
327
|
+
snapshot: mockSnapshot,
|
|
328
|
+
savePhaseState: mockSavePhaseState,
|
|
329
|
+
tddLog: mockTddLog,
|
|
330
|
+
...overrides,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
vi.clearAllMocks();
|
|
336
|
+
mockLoadTddState = vi.fn();
|
|
337
|
+
mockSnapshot = vi.fn().mockReturnValue("snap123");
|
|
338
|
+
mockSavePhaseState = vi.fn();
|
|
339
|
+
mockTddLog = vi.fn();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
function tddOk(overrides?: { current?: string; enabled?: boolean }) {
|
|
343
|
+
return {
|
|
344
|
+
ok: true as const,
|
|
345
|
+
state: {
|
|
346
|
+
enabled: overrides?.enabled ?? true,
|
|
347
|
+
current: overrides?.current ?? "red",
|
|
348
|
+
},
|
|
349
|
+
config,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
it("shows error when TDD not setup", async () => {
|
|
354
|
+
mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing .pi/tdd/" });
|
|
355
|
+
const ctx = makeCtx();
|
|
356
|
+
await handleTddJump("green", ctx, makeDeps());
|
|
357
|
+
expect(ctx.notifications[0].message).toContain("Missing");
|
|
358
|
+
expect(ctx.notifications[0].type).toBe("error");
|
|
359
|
+
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("notifies no-op when already in target phase", async () => {
|
|
363
|
+
mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
|
|
364
|
+
const ctx = makeCtx();
|
|
365
|
+
await handleTddJump("green", ctx, makeDeps());
|
|
366
|
+
expect(ctx.notifications[0].message).toContain("already in GREEN");
|
|
367
|
+
expect(ctx.notifications[0].type).toBe("info");
|
|
368
|
+
expect(mockSnapshot).not.toHaveBeenCalled();
|
|
369
|
+
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("snapshots, auto-enables, jumps phase, notifies", async () => {
|
|
373
|
+
mockLoadTddState.mockReturnValue(tddOk({ current: "red", enabled: true }));
|
|
374
|
+
const ctx = makeCtx();
|
|
375
|
+
await handleTddJump("green", ctx, makeDeps());
|
|
376
|
+
|
|
377
|
+
expect(mockSnapshot).toHaveBeenCalledWith("/test", "red");
|
|
378
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
|
|
379
|
+
enabled: true,
|
|
380
|
+
current: "green",
|
|
381
|
+
});
|
|
382
|
+
expect(ctx.notifications[0].message).toContain("Skipped to GREEN phase");
|
|
383
|
+
expect(ctx.notifications[0].type).toBe("info");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("auto-enables when TDD disabled", async () => {
|
|
387
|
+
mockLoadTddState.mockReturnValue(tddOk({ current: "red", enabled: false }));
|
|
388
|
+
const ctx = makeCtx();
|
|
389
|
+
await handleTddJump("green", ctx, makeDeps());
|
|
390
|
+
|
|
391
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
|
|
392
|
+
enabled: true,
|
|
393
|
+
current: "green",
|
|
394
|
+
});
|
|
395
|
+
expect(ctx.notifications[0].message).toContain("Skipped to GREEN phase");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("works for refactor from green", async () => {
|
|
399
|
+
mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
|
|
400
|
+
const ctx = makeCtx();
|
|
401
|
+
await handleTddJump("refactor", ctx, makeDeps());
|
|
402
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
|
|
403
|
+
enabled: true,
|
|
404
|
+
current: "refactor",
|
|
405
|
+
});
|
|
406
|
+
expect(ctx.notifications[0].message).toContain("Skipped to REFACTOR phase");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("works for red from green", async () => {
|
|
410
|
+
mockLoadTddState.mockReturnValue(tddOk({ current: "green" }));
|
|
411
|
+
const ctx = makeCtx();
|
|
412
|
+
await handleTddJump("red", ctx, makeDeps());
|
|
413
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
|
|
414
|
+
enabled: true,
|
|
415
|
+
current: "red",
|
|
416
|
+
});
|
|
417
|
+
expect(ctx.notifications[0].message).toContain("Skipped to RED phase");
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ── handleBeforeAgentStart ──────────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
describe("handleBeforeAgentStart", () => {
|
|
424
|
+
let mockLoadTddState: ReturnType<typeof vi.fn>;
|
|
425
|
+
|
|
426
|
+
function makeDeps(overrides = {}) {
|
|
427
|
+
return {
|
|
428
|
+
loadTddState: mockLoadTddState,
|
|
429
|
+
...overrides,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function makeEvent(): {
|
|
434
|
+
systemPromptOptions: { promptGuidelines: string[] };
|
|
435
|
+
} {
|
|
436
|
+
return { systemPromptOptions: { promptGuidelines: [] } };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
vi.clearAllMocks();
|
|
441
|
+
mockLoadTddState = vi.fn();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("pushes guidelines when TDD enabled", async () => {
|
|
445
|
+
mockLoadTddState.mockReturnValue({
|
|
446
|
+
ok: true,
|
|
447
|
+
state: { enabled: true, current: "red" },
|
|
448
|
+
config: {
|
|
449
|
+
blockedInRed: [],
|
|
450
|
+
blockedInGreen: [],
|
|
451
|
+
testCommands: [],
|
|
452
|
+
timeoutSeconds: 30,
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
const event = makeEvent();
|
|
456
|
+
await handleBeforeAgentStart(
|
|
457
|
+
event as any,
|
|
458
|
+
{ cwd: "/test" } as any,
|
|
459
|
+
makeDeps(),
|
|
460
|
+
);
|
|
461
|
+
expect(event.systemPromptOptions.promptGuidelines).toHaveLength(3);
|
|
462
|
+
expect(event.systemPromptOptions.promptGuidelines[0]).toContain(
|
|
463
|
+
"locked files will be blocked",
|
|
464
|
+
);
|
|
465
|
+
expect(event.systemPromptOptions.promptGuidelines[1]).toContain(
|
|
466
|
+
"next_tdd_phase",
|
|
467
|
+
);
|
|
468
|
+
expect(event.systemPromptOptions.promptGuidelines[2]).toContain(
|
|
469
|
+
"cycle so reverting is cheap",
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("does not push guidelines when TDD not setup", async () => {
|
|
474
|
+
mockLoadTddState.mockReturnValue({
|
|
475
|
+
ok: false,
|
|
476
|
+
reason: "Missing .pi/tdd/",
|
|
477
|
+
});
|
|
478
|
+
const event = makeEvent();
|
|
479
|
+
await handleBeforeAgentStart(
|
|
480
|
+
event as any,
|
|
481
|
+
{ cwd: "/test" } as any,
|
|
482
|
+
makeDeps(),
|
|
483
|
+
);
|
|
484
|
+
expect(event.systemPromptOptions.promptGuidelines).toHaveLength(0);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("does not push guidelines when TDD disabled", async () => {
|
|
488
|
+
mockLoadTddState.mockReturnValue({
|
|
489
|
+
ok: true,
|
|
490
|
+
state: { enabled: false, current: "red" },
|
|
491
|
+
config: {
|
|
492
|
+
blockedInRed: [],
|
|
493
|
+
blockedInGreen: [],
|
|
494
|
+
testCommands: [],
|
|
495
|
+
timeoutSeconds: 30,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
const event = makeEvent();
|
|
499
|
+
await handleBeforeAgentStart(
|
|
500
|
+
event as any,
|
|
501
|
+
{ cwd: "/test" } as any,
|
|
502
|
+
makeDeps(),
|
|
503
|
+
);
|
|
504
|
+
expect(event.systemPromptOptions.promptGuidelines).toHaveLength(0);
|
|
505
|
+
});
|
|
506
|
+
});
|
package/adapters/pi/index.ts
CHANGED
|
@@ -148,6 +148,77 @@ export async function handleTddStatus(
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
export async function handleBeforeAgentStart(
|
|
152
|
+
event: {
|
|
153
|
+
systemPromptOptions: { promptGuidelines: string[] };
|
|
154
|
+
},
|
|
155
|
+
ctx: { cwd: string },
|
|
156
|
+
deps: {
|
|
157
|
+
loadTddState: typeof loadTddState;
|
|
158
|
+
},
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const tdd = deps.loadTddState(ctx.cwd);
|
|
161
|
+
if (!tdd.ok || !tdd.state.enabled) return;
|
|
162
|
+
|
|
163
|
+
event.systemPromptOptions.promptGuidelines.push(
|
|
164
|
+
"You are working under TDD enforcement. Each phase restricts which files you can modify — locked files will be blocked automatically.",
|
|
165
|
+
"Use `next_tdd_phase` to advance through the cycle, `previous_tdd_phase` to revert a phase, `tdd_status` to check current phase and blocked file rules.",
|
|
166
|
+
"Minimise the scope of each TDD cycle so reverting is cheap.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function handleTddJump(
|
|
171
|
+
phase: "red" | "green" | "refactor",
|
|
172
|
+
ctx: ExtensionContext,
|
|
173
|
+
deps: {
|
|
174
|
+
loadTddState: typeof loadTddState;
|
|
175
|
+
snapshot: typeof snapshot;
|
|
176
|
+
savePhaseState: typeof savePhaseState;
|
|
177
|
+
tddLog: typeof tddLog;
|
|
178
|
+
} = {
|
|
179
|
+
loadTddState,
|
|
180
|
+
snapshot,
|
|
181
|
+
savePhaseState,
|
|
182
|
+
tddLog,
|
|
183
|
+
},
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const root = ctx.cwd;
|
|
186
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
187
|
+
|
|
188
|
+
const setup = deps.loadTddState(root);
|
|
189
|
+
if (!setup.ok) {
|
|
190
|
+
deps.tddLog(tddDir, "WARN", `tdd:${phase}: setup invalid`, {
|
|
191
|
+
reason: setup.reason,
|
|
192
|
+
});
|
|
193
|
+
ctx.ui.notify(setup.reason, "error");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const { state } = setup;
|
|
198
|
+
|
|
199
|
+
if (state.current === phase) {
|
|
200
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: already in ${phase}`, {
|
|
201
|
+
phase,
|
|
202
|
+
});
|
|
203
|
+
ctx.ui.notify(`TDD: already in ${phase.toUpperCase()} phase.`, "info");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Snapshot the current phase's work before jumping
|
|
208
|
+
deps.snapshot(root, state.current);
|
|
209
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: snapshot taken`, {
|
|
210
|
+
from: state.current,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Auto-enable if disabled, set phase
|
|
214
|
+
state.enabled = true;
|
|
215
|
+
state.current = phase;
|
|
216
|
+
deps.savePhaseState(root, state);
|
|
217
|
+
|
|
218
|
+
deps.tddLog(tddDir, "INFO", `tdd:${phase}: jumped`);
|
|
219
|
+
ctx.ui.notify(`Skipped to ${phase.toUpperCase()} phase.`, "info");
|
|
220
|
+
}
|
|
221
|
+
|
|
151
222
|
export async function handleTddReset(
|
|
152
223
|
ctx: ExtensionContext,
|
|
153
224
|
deps: {
|
|
@@ -239,6 +310,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
310
|
handleTddReset(ctx, { ...defaultDeps, resetGit }),
|
|
240
311
|
});
|
|
241
312
|
|
|
313
|
+
for (const phase of ["red", "green", "refactor"] as const) {
|
|
314
|
+
pi.registerCommand(`tdd:${phase}`, {
|
|
315
|
+
description:
|
|
316
|
+
`Skip to ${phase.toUpperCase()} phase. ` +
|
|
317
|
+
"Snapshot working tree, auto-enable TDD, set phase. No gate checks.",
|
|
318
|
+
handler: (_args: string, ctx: ExtensionContext) =>
|
|
319
|
+
handleTddJump(phase, ctx, defaultDeps),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
242
323
|
registerTools(pi);
|
|
243
324
|
registerHooks(pi);
|
|
325
|
+
|
|
326
|
+
pi.on("before_agent_start", (event, ctx) =>
|
|
327
|
+
handleBeforeAgentStart(event, ctx, { loadTddState }),
|
|
328
|
+
);
|
|
244
329
|
}
|
package/adapters/pi/prompts.ts
CHANGED
|
@@ -11,7 +11,8 @@ export function getNudgePrompt(phase: Phase, config: Config): string {
|
|
|
11
11
|
`Blocked files: ${redBlock}\n` +
|
|
12
12
|
"All other files are free to modify. Call `next_tdd_phase` to proceed to GREEN.\n" +
|
|
13
13
|
"Think about what could go wrong and test for it — don't just verify the happy path, " +
|
|
14
|
-
"cover unhappy paths and edge cases too
|
|
14
|
+
"cover unhappy paths and edge cases too.\n" +
|
|
15
|
+
"Minimise the scope of each TDD cycle so reverting is cheap."
|
|
15
16
|
);
|
|
16
17
|
case "green":
|
|
17
18
|
return (
|
|
@@ -72,24 +72,26 @@ describe("executeNextPhase", () => {
|
|
|
72
72
|
mockAsyncExec.mockResolvedValue({ stdout: "", stderr: "" });
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("
|
|
75
|
+
it("throws when TDD not setup", async () => {
|
|
76
76
|
mockLoadTddState.mockReturnValue({
|
|
77
77
|
ok: false,
|
|
78
78
|
reason:
|
|
79
79
|
"Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs.",
|
|
80
80
|
});
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
await expect(
|
|
82
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
83
|
+
).rejects.toThrow("Missing .pi/tdd/");
|
|
83
84
|
});
|
|
84
85
|
|
|
85
|
-
it("
|
|
86
|
+
it("throws when TDD disabled", async () => {
|
|
86
87
|
mockLoadTddState.mockReturnValue({
|
|
87
88
|
ok: true,
|
|
88
89
|
state: { enabled: false, current: "red" },
|
|
89
90
|
config: CONFIG,
|
|
90
91
|
});
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
await expect(
|
|
93
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
94
|
+
).rejects.toThrow("not enabled");
|
|
93
95
|
});
|
|
94
96
|
|
|
95
97
|
it("blocks when allowlist violations exist", async () => {
|
|
@@ -99,9 +101,9 @@ describe("executeNextPhase", () => {
|
|
|
99
101
|
config: CONFIG,
|
|
100
102
|
});
|
|
101
103
|
mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
await expect(
|
|
105
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
106
|
+
).rejects.toThrow("BLOCKED");
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
it("blocks red→green when tests pass (need failing test)", async () => {
|
|
@@ -115,9 +117,9 @@ describe("executeNextPhase", () => {
|
|
|
115
117
|
message:
|
|
116
118
|
"Tests passed. Add a failing test before transitioning to GREEN.",
|
|
117
119
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
await expect(
|
|
121
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
122
|
+
).rejects.toThrow("Add a failing test");
|
|
121
123
|
});
|
|
122
124
|
|
|
123
125
|
it("blocks green→refactor when tests fail", async () => {
|
|
@@ -130,9 +132,24 @@ describe("executeNextPhase", () => {
|
|
|
130
132
|
passed: false,
|
|
131
133
|
message: "Tests failed. Fix them before transitioning to REFACTOR.",
|
|
132
134
|
});
|
|
135
|
+
await expect(
|
|
136
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
137
|
+
).rejects.toThrow("failed");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("result text has leading newline for spacing", async () => {
|
|
141
|
+
mockLoadTddState.mockReturnValue({
|
|
142
|
+
ok: true,
|
|
143
|
+
state: { enabled: true, current: "red" },
|
|
144
|
+
config: CONFIG,
|
|
145
|
+
});
|
|
146
|
+
mockCheckGate.mockResolvedValue({
|
|
147
|
+
passed: true,
|
|
148
|
+
message: "Tests fail — proceed to GREEN.",
|
|
149
|
+
});
|
|
150
|
+
mockGetNudgePrompt.mockReturnValue("content");
|
|
133
151
|
const result = await executeNextPhase({ cwd: "/test" } as any, makeDeps());
|
|
134
|
-
expect(result.content[0].text
|
|
135
|
-
expect(result.content[0].text).toContain("REFACTOR");
|
|
152
|
+
expect(result.content[0].text.startsWith("\n")).toBe(true);
|
|
136
153
|
});
|
|
137
154
|
|
|
138
155
|
it("advances red→green when tests fail", async () => {
|
|
@@ -185,9 +202,9 @@ describe("executeNextPhase", () => {
|
|
|
185
202
|
passed: false,
|
|
186
203
|
message: "Tests failed. Fix them before transitioning to RED.",
|
|
187
204
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
205
|
+
await expect(
|
|
206
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
207
|
+
).rejects.toThrow("failed");
|
|
191
208
|
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
192
209
|
});
|
|
193
210
|
|
|
@@ -251,57 +268,49 @@ describe("executePreviousPhase", () => {
|
|
|
251
268
|
mockHasParent.mockReturnValue(true);
|
|
252
269
|
});
|
|
253
270
|
|
|
254
|
-
it("
|
|
271
|
+
it("throws when TDD not setup", async () => {
|
|
255
272
|
mockLoadTddState.mockReturnValue({
|
|
256
273
|
ok: false,
|
|
257
274
|
reason: "Missing .pi/tdd/ directory.",
|
|
258
275
|
});
|
|
259
|
-
|
|
260
|
-
{ cwd: "/test" } as any,
|
|
261
|
-
|
|
262
|
-
);
|
|
263
|
-
expect(result.content[0].text).toContain("Missing .pi/tdd/");
|
|
276
|
+
await expect(
|
|
277
|
+
executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
|
|
278
|
+
).rejects.toThrow("Missing .pi/tdd/");
|
|
264
279
|
});
|
|
265
280
|
|
|
266
|
-
it("
|
|
281
|
+
it("throws when TDD disabled", async () => {
|
|
267
282
|
mockLoadTddState.mockReturnValue({
|
|
268
283
|
ok: true,
|
|
269
284
|
state: { enabled: false, current: "red" },
|
|
270
285
|
config: CONFIG,
|
|
271
286
|
});
|
|
272
|
-
|
|
273
|
-
{ cwd: "/test" } as any,
|
|
274
|
-
|
|
275
|
-
);
|
|
276
|
-
expect(result.content[0].text).toContain("not enabled");
|
|
287
|
+
await expect(
|
|
288
|
+
executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
|
|
289
|
+
).rejects.toThrow("not enabled");
|
|
277
290
|
});
|
|
278
291
|
|
|
279
|
-
it("
|
|
292
|
+
it("throws when no parent commit", async () => {
|
|
280
293
|
mockLoadTddState.mockReturnValue({
|
|
281
294
|
ok: true,
|
|
282
295
|
state: { enabled: true, current: "red" },
|
|
283
296
|
config: CONFIG,
|
|
284
297
|
});
|
|
285
298
|
mockHasParent.mockReturnValue(false);
|
|
286
|
-
|
|
287
|
-
{ cwd: "/test" } as any,
|
|
288
|
-
|
|
289
|
-
);
|
|
290
|
-
expect(result.content[0].text).toContain("No previous phase");
|
|
299
|
+
await expect(
|
|
300
|
+
executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
|
|
301
|
+
).rejects.toThrow("No previous phase");
|
|
291
302
|
});
|
|
292
303
|
|
|
293
|
-
it("
|
|
304
|
+
it("throws when HEAD message is not a TDD snapshot", async () => {
|
|
294
305
|
mockLoadTddState.mockReturnValue({
|
|
295
306
|
ok: true,
|
|
296
307
|
state: { enabled: true, current: "red" },
|
|
297
308
|
config: CONFIG,
|
|
298
309
|
});
|
|
299
310
|
mockHeadMessage.mockReturnValue("garbage");
|
|
300
|
-
|
|
301
|
-
{ cwd: "/test" } as any,
|
|
302
|
-
|
|
303
|
-
);
|
|
304
|
-
expect(result.content[0].text).toContain("not a TDD snapshot");
|
|
311
|
+
await expect(
|
|
312
|
+
executePreviousPhase({ cwd: "/test" } as any, makeDeps()),
|
|
313
|
+
).rejects.toThrow("not a TDD snapshot");
|
|
305
314
|
});
|
|
306
315
|
|
|
307
316
|
it("reverts to previous phase on success", async () => {
|
|
@@ -363,23 +372,25 @@ describe("executeTddStatus", () => {
|
|
|
363
372
|
mockTddLog = vi.fn();
|
|
364
373
|
});
|
|
365
374
|
|
|
366
|
-
it("
|
|
375
|
+
it("throws when TDD not setup", async () => {
|
|
367
376
|
mockLoadTddState.mockReturnValue({
|
|
368
377
|
ok: false,
|
|
369
378
|
reason: "Missing .pi/tdd/ directory.",
|
|
370
379
|
});
|
|
371
|
-
|
|
372
|
-
|
|
380
|
+
await expect(
|
|
381
|
+
executeTddStatus({ cwd: "/test" } as any, makeDeps()),
|
|
382
|
+
).rejects.toThrow("Missing .pi/tdd/");
|
|
373
383
|
});
|
|
374
384
|
|
|
375
|
-
it("
|
|
385
|
+
it("throws when TDD disabled", async () => {
|
|
376
386
|
mockLoadTddState.mockReturnValue({
|
|
377
387
|
ok: true,
|
|
378
388
|
state: { enabled: false, current: "red" },
|
|
379
389
|
config: CONFIG,
|
|
380
390
|
});
|
|
381
|
-
|
|
382
|
-
|
|
391
|
+
await expect(
|
|
392
|
+
executeTddStatus({ cwd: "/test" } as any, makeDeps()),
|
|
393
|
+
).rejects.toThrow("not enabled");
|
|
383
394
|
});
|
|
384
395
|
|
|
385
396
|
it("returns status details when TDD enabled", async () => {
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -102,19 +102,11 @@ export async function executeNextPhase(
|
|
|
102
102
|
deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", {
|
|
103
103
|
reason: tdd.reason,
|
|
104
104
|
});
|
|
105
|
-
|
|
106
|
-
content: [{ type: "text", text: `TDD: ${tdd.reason}` }],
|
|
107
|
-
details: {},
|
|
108
|
-
};
|
|
105
|
+
throw new Error(`TDD: ${tdd.reason}`);
|
|
109
106
|
}
|
|
110
107
|
if (!tdd.state.enabled) {
|
|
111
108
|
deps.tddLog(tddDir, "WARN", "next_tdd_phase: TDD disabled");
|
|
112
|
-
|
|
113
|
-
content: [
|
|
114
|
-
{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
|
|
115
|
-
],
|
|
116
|
-
details: {},
|
|
117
|
-
};
|
|
109
|
+
throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
|
|
118
110
|
}
|
|
119
111
|
|
|
120
112
|
const { state, config } = tdd;
|
|
@@ -130,18 +122,11 @@ export async function executeNextPhase(
|
|
|
130
122
|
from,
|
|
131
123
|
violations,
|
|
132
124
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
139
|
-
violations.map((f) => ` - ${f}`).join("\n") +
|
|
140
|
-
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
141
|
-
},
|
|
142
|
-
],
|
|
143
|
-
details: {},
|
|
144
|
-
};
|
|
125
|
+
throw new Error(
|
|
126
|
+
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
127
|
+
violations.map((f) => ` - ${f}`).join("\n") +
|
|
128
|
+
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
129
|
+
);
|
|
145
130
|
}
|
|
146
131
|
|
|
147
132
|
// 2. Gate check
|
|
@@ -176,7 +161,7 @@ export async function executeNextPhase(
|
|
|
176
161
|
});
|
|
177
162
|
|
|
178
163
|
if (!gate.passed) {
|
|
179
|
-
|
|
164
|
+
throw new Error(gate.message);
|
|
180
165
|
}
|
|
181
166
|
|
|
182
167
|
// 3. Snapshot — label with the phase the work was done in
|
|
@@ -193,7 +178,7 @@ export async function executeNextPhase(
|
|
|
193
178
|
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
194
179
|
|
|
195
180
|
return {
|
|
196
|
-
content: [{ type: "text", text: deps.getNudgePrompt(to, config) }],
|
|
181
|
+
content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
|
|
197
182
|
details: {},
|
|
198
183
|
};
|
|
199
184
|
}
|
|
@@ -214,19 +199,11 @@ export async function executePreviousPhase(
|
|
|
214
199
|
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
|
|
215
200
|
reason: tdd.reason,
|
|
216
201
|
});
|
|
217
|
-
|
|
218
|
-
content: [{ type: "text", text: `TDD: ${tdd.reason}` }],
|
|
219
|
-
details: {},
|
|
220
|
-
};
|
|
202
|
+
throw new Error(`TDD: ${tdd.reason}`);
|
|
221
203
|
}
|
|
222
204
|
if (!tdd.state.enabled) {
|
|
223
205
|
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: TDD disabled");
|
|
224
|
-
|
|
225
|
-
content: [
|
|
226
|
-
{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
|
|
227
|
-
],
|
|
228
|
-
details: {},
|
|
229
|
-
};
|
|
206
|
+
throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
|
|
230
207
|
}
|
|
231
208
|
|
|
232
209
|
const { state } = tdd;
|
|
@@ -235,10 +212,7 @@ export async function executePreviousPhase(
|
|
|
235
212
|
deps.tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
|
|
236
213
|
phase: state.current,
|
|
237
214
|
});
|
|
238
|
-
|
|
239
|
-
content: [{ type: "text", text: "No previous phase to revert to." }],
|
|
240
|
-
details: {},
|
|
241
|
-
};
|
|
215
|
+
throw new Error("No previous phase to revert to.");
|
|
242
216
|
}
|
|
243
217
|
|
|
244
218
|
// Read phase from HEAD snapshot commit message (source of truth).
|
|
@@ -250,18 +224,11 @@ export async function executePreviousPhase(
|
|
|
250
224
|
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
|
|
251
225
|
headMsg,
|
|
252
226
|
});
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
`HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
259
|
-
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
260
|
-
`Tampering with it will cause TDD state corruption.`,
|
|
261
|
-
},
|
|
262
|
-
],
|
|
263
|
-
details: {},
|
|
264
|
-
};
|
|
227
|
+
throw new Error(
|
|
228
|
+
`HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
229
|
+
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
230
|
+
`Tampering with it will cause TDD state corruption.`,
|
|
231
|
+
);
|
|
265
232
|
}
|
|
266
233
|
const label = phaseMatch[1];
|
|
267
234
|
if (label !== "red" && label !== "green" && label !== "refactor") {
|
|
@@ -269,15 +236,9 @@ export async function executePreviousPhase(
|
|
|
269
236
|
headMsg,
|
|
270
237
|
label,
|
|
271
238
|
});
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
type: "text",
|
|
276
|
-
text: `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
|
|
277
|
-
},
|
|
278
|
-
],
|
|
279
|
-
details: {},
|
|
280
|
-
};
|
|
239
|
+
throw new Error(
|
|
240
|
+
`HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
|
|
241
|
+
);
|
|
281
242
|
}
|
|
282
243
|
const prevPhase: Phase = label;
|
|
283
244
|
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
|
|
@@ -305,7 +266,7 @@ export async function executePreviousPhase(
|
|
|
305
266
|
content: [
|
|
306
267
|
{
|
|
307
268
|
type: "text",
|
|
308
|
-
text:
|
|
269
|
+
text: `\nReverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
|
|
309
270
|
},
|
|
310
271
|
],
|
|
311
272
|
details: {},
|
|
@@ -329,19 +290,11 @@ export async function executeTddStatus(
|
|
|
329
290
|
deps.tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
|
|
330
291
|
reason: result.reason,
|
|
331
292
|
});
|
|
332
|
-
|
|
333
|
-
content: [{ type: "text", text: `TDD: ${result.reason}` }],
|
|
334
|
-
details: {},
|
|
335
|
-
};
|
|
293
|
+
throw new Error(`TDD: ${result.reason}`);
|
|
336
294
|
}
|
|
337
295
|
if (!result.state.enabled) {
|
|
338
296
|
deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
|
|
339
|
-
|
|
340
|
-
content: [
|
|
341
|
-
{ type: "text", text: "TDD is not enabled. Run /tdd:on to enable it." },
|
|
342
|
-
],
|
|
343
|
-
details: {},
|
|
344
|
-
};
|
|
297
|
+
throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
|
|
345
298
|
}
|
|
346
299
|
|
|
347
300
|
const { state, config } = result;
|
|
@@ -359,7 +312,7 @@ export async function executeTddStatus(
|
|
|
359
312
|
{
|
|
360
313
|
type: "text",
|
|
361
314
|
text:
|
|
362
|
-
|
|
315
|
+
`\nTDD enforcer enabled\n` +
|
|
363
316
|
`Current phase: ${phaseStr}\n` +
|
|
364
317
|
`Blocked in RED: ${redBlk}\n` +
|
|
365
318
|
`Blocked in GREEN: ${greenBlk}\n` +
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
2
|
+
"name": "tdd-enforcer",
|
|
3
|
+
"version": "0.2.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"fmt": "biome format --write .",
|
|
7
|
+
"lint": "biome check .",
|
|
8
|
+
"lint:fix": "biome check --write .",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"picomatch": "^4.0.4"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@biomejs/biome": "^2.5.0",
|
|
19
|
+
"@earendil-works/pi-coding-agent": "^0.79.6",
|
|
20
|
+
"@types/node": "^25.9.3",
|
|
21
|
+
"typebox": "^1.2.16",
|
|
22
|
+
"vitest": "^3"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./adapters/pi/index.ts"
|
|
27
|
+
],
|
|
28
|
+
"skills": [
|
|
29
|
+
"./skills"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
32
|
}
|
|
@@ -54,7 +54,7 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
54
54
|
### RED
|
|
55
55
|
Files matching `blockedInRed` are locked — everything else is free.
|
|
56
56
|
|
|
57
|
-
Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too.
|
|
57
|
+
Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too. Minimise the scope of each TDD cycle so reverting is cheap and safe if assumptions turn out wrong.
|
|
58
58
|
|
|
59
59
|
Call `next_tdd_phase` once tests fail.
|
|
60
60
|
|