tdd-enforcer 0.3.2 → 0.3.4

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.
@@ -0,0 +1,445 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { advancePhase, getStatusInfo, revertPhase } from "./orchestrate.js";
3
+ import type { Config, Phase, PhaseState } from "./types.js";
4
+
5
+ const CONFIG: Config = {
6
+ blockedInRed: ["tests/**/*.test.ts"],
7
+ blockedInGreen: ["src/**/*.ts"],
8
+ testCommands: ["npm test"],
9
+ timeoutSeconds: 30,
10
+ };
11
+
12
+ function enabledState(current: Phase): PhaseState {
13
+ return { enabled: true, current };
14
+ }
15
+
16
+ describe("advancePhase", () => {
17
+ let mockGetDisallowedChanges: ReturnType<typeof vi.fn>;
18
+ let mockCheckGate: ReturnType<typeof vi.fn>;
19
+ let mockNextPhase: ReturnType<typeof vi.fn>;
20
+ let mockSnapshot: ReturnType<typeof vi.fn>;
21
+ let mockSavePhaseState: ReturnType<typeof vi.fn>;
22
+ let mockTestRunner: ReturnType<typeof vi.fn>;
23
+
24
+ function makeDeps(overrides = {}) {
25
+ return {
26
+ getDisallowedChanges: mockGetDisallowedChanges,
27
+ checkGate: mockCheckGate,
28
+ nextPhase: mockNextPhase,
29
+ snapshot: mockSnapshot,
30
+ savePhaseState: mockSavePhaseState,
31
+ testRunner: mockTestRunner,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ mockGetDisallowedChanges = vi.fn().mockReturnValue([]);
39
+ mockCheckGate = vi.fn().mockResolvedValue({ passed: true, message: "ok" });
40
+ mockNextPhase = vi
41
+ .fn()
42
+ .mockImplementation((p: string) =>
43
+ p === "red"
44
+ ? "green"
45
+ : p === "green"
46
+ ? "refactor"
47
+ : p === "refactor"
48
+ ? "red"
49
+ : null,
50
+ );
51
+ mockSnapshot = vi.fn().mockReturnValue("hash123");
52
+ mockSavePhaseState = vi.fn();
53
+ mockTestRunner = vi.fn();
54
+ });
55
+
56
+ it("returns allowlist violation error with revert hint when files blocked", async () => {
57
+ mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]);
58
+ const result = await advancePhase(
59
+ "/test",
60
+ enabledState("red"),
61
+ CONFIG,
62
+ makeDeps(),
63
+ );
64
+ expect(result.ok).toBe(false);
65
+ expect(result.message).toBe(
66
+ "BLOCKED: files not allowed in RED phase:\n" +
67
+ " - src/violation.ts" +
68
+ "\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- src/violation.ts",
69
+ );
70
+ });
71
+
72
+ it("returns allowlist violation for multiple blocked files", async () => {
73
+ mockGetDisallowedChanges.mockReturnValue(["src/a.ts", "src/b.ts"]);
74
+ const result = await advancePhase(
75
+ "/test",
76
+ enabledState("red"),
77
+ CONFIG,
78
+ makeDeps(),
79
+ );
80
+ expect(result.ok).toBe(false);
81
+ expect(result.message).toContain("src/a.ts");
82
+ expect(result.message).toContain("src/b.ts");
83
+ expect(result.message).toContain("cd .pi/tdd && git diff HEAD -- src/a.ts");
84
+ });
85
+
86
+ it("returns gate failure error when checkGate fails", async () => {
87
+ mockCheckGate.mockResolvedValue({
88
+ passed: false,
89
+ message: "Tests failed:\n - npm test",
90
+ });
91
+ const result = await advancePhase(
92
+ "/test",
93
+ enabledState("green"),
94
+ CONFIG,
95
+ makeDeps(),
96
+ );
97
+ expect(result.ok).toBe(false);
98
+ expect(result.message).toBe("Tests failed:\n - npm test");
99
+ });
100
+
101
+ it("passes testRunner to checkGate", () => {
102
+ advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
103
+ expect(mockCheckGate).toHaveBeenCalledWith(
104
+ "red",
105
+ "green",
106
+ mockTestRunner,
107
+ CONFIG,
108
+ );
109
+ });
110
+
111
+ it("passes correct phase transition red→green", () => {
112
+ advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
113
+ expect(mockCheckGate).toHaveBeenCalledWith(
114
+ "red",
115
+ "green",
116
+ expect.anything(),
117
+ expect.anything(),
118
+ );
119
+ });
120
+
121
+ it("passes correct phase transition green→refactor", () => {
122
+ advancePhase("/test", enabledState("green"), CONFIG, makeDeps());
123
+ expect(mockCheckGate).toHaveBeenCalledWith(
124
+ "green",
125
+ "refactor",
126
+ expect.anything(),
127
+ expect.anything(),
128
+ );
129
+ });
130
+
131
+ it("passes correct phase transition refactor→red", () => {
132
+ advancePhase("/test", enabledState("refactor"), CONFIG, makeDeps());
133
+ expect(mockCheckGate).toHaveBeenCalledWith(
134
+ "refactor",
135
+ "red",
136
+ expect.anything(),
137
+ expect.anything(),
138
+ );
139
+ });
140
+
141
+ it("calls snapshot with root and from phase on success", async () => {
142
+ await advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
143
+ expect(mockSnapshot).toHaveBeenCalledWith("/test", "red");
144
+ });
145
+
146
+ it("calls savePhaseState with root and new state on success", async () => {
147
+ await advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
148
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
149
+ enabled: true,
150
+ current: "green",
151
+ });
152
+ });
153
+
154
+ it("returns ok true with newState on success", async () => {
155
+ const result = await advancePhase(
156
+ "/test",
157
+ enabledState("red"),
158
+ CONFIG,
159
+ makeDeps(),
160
+ );
161
+ expect(result.ok).toBe(true);
162
+ expect(result.message).toBe("");
163
+ expect(result.newState).toEqual({
164
+ enabled: true,
165
+ current: "green",
166
+ });
167
+ });
168
+
169
+ it("does not snapshot or save when allowlist check fails", async () => {
170
+ mockGetDisallowedChanges.mockReturnValue(["src/violation.ts"]);
171
+ await advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
172
+ expect(mockSnapshot).not.toHaveBeenCalled();
173
+ expect(mockSavePhaseState).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("does not snapshot or save when gate check fails", async () => {
177
+ mockCheckGate.mockResolvedValue({
178
+ passed: false,
179
+ message: "fail",
180
+ });
181
+ await advancePhase("/test", enabledState("green"), CONFIG, makeDeps());
182
+ expect(mockSnapshot).not.toHaveBeenCalled();
183
+ expect(mockSavePhaseState).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("calls getDisallowedChanges with root, current phase, and config", () => {
187
+ advancePhase("/test", enabledState("red"), CONFIG, makeDeps());
188
+ expect(mockGetDisallowedChanges).toHaveBeenCalledWith(
189
+ "/test",
190
+ "red",
191
+ CONFIG,
192
+ );
193
+ });
194
+
195
+ it("uses nextPhase from deps if provided", () => {
196
+ const customNext = vi.fn().mockReturnValue("refactor");
197
+ advancePhase(
198
+ "/test",
199
+ enabledState("red"),
200
+ CONFIG,
201
+ makeDeps({ nextPhase: customNext }),
202
+ );
203
+ expect(customNext).toHaveBeenCalledWith("red");
204
+ });
205
+
206
+ it("falls back to real nextPhase when not provided in deps", () => {
207
+ advancePhase(
208
+ "/test",
209
+ enabledState("red"),
210
+ CONFIG,
211
+ makeDeps({ nextPhase: undefined }),
212
+ );
213
+ // Should use the real nextPhase function (red→green)
214
+ expect(mockCheckGate).toHaveBeenCalledWith(
215
+ "red",
216
+ "green",
217
+ expect.anything(),
218
+ expect.anything(),
219
+ );
220
+ });
221
+ });
222
+
223
+ describe("revertPhase", () => {
224
+ let mockHasParent: ReturnType<typeof vi.fn>;
225
+ let mockHeadMessage: ReturnType<typeof vi.fn>;
226
+ let mockResetHard: ReturnType<typeof vi.fn>;
227
+ let mockUndoLastCommit: ReturnType<typeof vi.fn>;
228
+ let mockSavePhaseState: ReturnType<typeof vi.fn>;
229
+
230
+ function makeDeps(overrides = {}) {
231
+ return {
232
+ hasParent: mockHasParent,
233
+ headMessage: mockHeadMessage,
234
+ resetHard: mockResetHard,
235
+ undoLastCommit: mockUndoLastCommit,
236
+ savePhaseState: mockSavePhaseState,
237
+ ...overrides,
238
+ };
239
+ }
240
+
241
+ beforeEach(() => {
242
+ vi.clearAllMocks();
243
+ mockHasParent = vi.fn().mockReturnValue(true);
244
+ mockHeadMessage = vi.fn().mockReturnValue("tdd: red");
245
+ mockResetHard = vi.fn();
246
+ mockUndoLastCommit = vi.fn();
247
+ mockSavePhaseState = vi.fn();
248
+ });
249
+
250
+ it("returns error when no parent commit", async () => {
251
+ mockHasParent.mockReturnValue(false);
252
+ const result = await revertPhase(
253
+ "/test",
254
+ enabledState("green"),
255
+ makeDeps(),
256
+ );
257
+ expect(result.ok).toBe(false);
258
+ expect(result.message).toBe("No previous phase to revert to.");
259
+ });
260
+
261
+ it("returns error with tampering warning when HEAD is not a TDD snapshot", async () => {
262
+ mockHeadMessage.mockReturnValue("some random commit");
263
+ const result = await revertPhase(
264
+ "/test",
265
+ enabledState("green"),
266
+ makeDeps(),
267
+ );
268
+ expect(result.ok).toBe(false);
269
+ expect(result.message).toBe(
270
+ 'HEAD commit "some random commit" is not a TDD snapshot. Cannot determine previous phase.\n' +
271
+ "The private git repo at .pi/tdd must not be manually modified. " +
272
+ "Tampering with it will cause TDD state corruption.",
273
+ );
274
+ });
275
+
276
+ it("resets hard and pops last commit on success", async () => {
277
+ const result = await revertPhase(
278
+ "/test",
279
+ enabledState("green"),
280
+ makeDeps(),
281
+ );
282
+ expect(result.ok).toBe(true);
283
+ expect(result.message).toBe("Reverted to RED.");
284
+ expect(result.newState?.current).toBe("red");
285
+ expect(mockResetHard).toHaveBeenCalledWith("/test");
286
+ expect(mockUndoLastCommit).toHaveBeenCalledWith("/test");
287
+ });
288
+
289
+ it("saves the reverted phase state", async () => {
290
+ await revertPhase("/test", enabledState("green"), makeDeps());
291
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
292
+ enabled: true,
293
+ current: "red",
294
+ });
295
+ });
296
+
297
+ it("determines previous phase from HEAD commit message", async () => {
298
+ mockHeadMessage.mockReturnValue("tdd: green");
299
+ const result = await revertPhase(
300
+ "/test",
301
+ enabledState("refactor"),
302
+ makeDeps(),
303
+ );
304
+ expect(result.ok).toBe(true);
305
+ expect(result.newState?.current).toBe("green");
306
+ });
307
+
308
+ it("determines refactor phase from HEAD commit message", async () => {
309
+ mockHeadMessage.mockReturnValue("tdd: refactor");
310
+ const result = await revertPhase("/test", enabledState("red"), makeDeps());
311
+ expect(result.ok).toBe(true);
312
+ expect(result.newState?.current).toBe("refactor");
313
+ });
314
+
315
+ it("preserves enabled state in new state", async () => {
316
+ const result = await revertPhase(
317
+ "/test",
318
+ { enabled: false, current: "green" },
319
+ makeDeps(),
320
+ );
321
+ expect(result.ok).toBe(true);
322
+ expect(result.newState?.enabled).toBe(false);
323
+ });
324
+
325
+ it("calls deps from provided deps object", async () => {
326
+ const customHasParent = vi.fn().mockReturnValue(true);
327
+ const customHeadMessage = vi.fn().mockReturnValue("tdd: green");
328
+ const customResetHard = vi.fn();
329
+ const customUndoLastCommit = vi.fn();
330
+ const customSavePhaseState = vi.fn();
331
+
332
+ await revertPhase("/test", enabledState("refactor"), {
333
+ hasParent: customHasParent,
334
+ headMessage: customHeadMessage,
335
+ resetHard: customResetHard,
336
+ undoLastCommit: customUndoLastCommit,
337
+ savePhaseState: customSavePhaseState,
338
+ });
339
+
340
+ expect(customHasParent).toHaveBeenCalled();
341
+ expect(customHeadMessage).toHaveBeenCalled();
342
+ expect(customResetHard).toHaveBeenCalled();
343
+ expect(customUndoLastCommit).toHaveBeenCalled();
344
+ expect(customSavePhaseState).toHaveBeenCalled();
345
+ });
346
+
347
+ it("falls back to real functions when deps not provided", async () => {
348
+ // Should use real hasParent, headMessage, etc. from engine
349
+ const result = await revertPhase("/test", enabledState("green"));
350
+ // Real hasParent will fail because /test isn't a git repo
351
+ expect(result.ok).toBe(false);
352
+ });
353
+ });
354
+
355
+ describe("getStatusInfo", () => {
356
+ it("returns enabled status with RED phase", () => {
357
+ const info = getStatusInfo(
358
+ { enabled: true, current: "red" },
359
+ {
360
+ blockedInRed: ["tests/**/*.test.ts"],
361
+ blockedInGreen: ["src/**/*.ts"],
362
+ testCommands: ["npm test"],
363
+ timeoutSeconds: 30,
364
+ },
365
+ );
366
+ expect(info).toBe(
367
+ "TDD enforcer enabled\n" +
368
+ "Current phase: RED\n" +
369
+ "Blocked in RED: tests/**/*.test.ts\n" +
370
+ "Blocked in GREEN: src/**/*.ts\n" +
371
+ "Test commands: npm test",
372
+ );
373
+ });
374
+
375
+ it("returns disabled status", () => {
376
+ const info = getStatusInfo(
377
+ { enabled: false, current: "red" },
378
+ {
379
+ blockedInRed: [],
380
+ blockedInGreen: [],
381
+ testCommands: [],
382
+ timeoutSeconds: 30,
383
+ },
384
+ );
385
+ expect(info).toBe(
386
+ "TDD enforcer disabled\n" +
387
+ "Current phase: RED\n" +
388
+ "Blocked in RED: (none)\n" +
389
+ "Blocked in GREEN: (none)\n" +
390
+ "Test commands: (none)",
391
+ );
392
+ });
393
+
394
+ it("shows GREEN phase", () => {
395
+ const info = getStatusInfo(
396
+ { enabled: true, current: "green" },
397
+ {
398
+ blockedInRed: [],
399
+ blockedInGreen: [],
400
+ testCommands: [],
401
+ timeoutSeconds: 30,
402
+ },
403
+ );
404
+ expect(info).toContain("GREEN");
405
+ });
406
+
407
+ it("shows REFACTOR phase", () => {
408
+ const info = getStatusInfo(
409
+ { enabled: true, current: "refactor" },
410
+ {
411
+ blockedInRed: [],
412
+ blockedInGreen: [],
413
+ testCommands: [],
414
+ timeoutSeconds: 30,
415
+ },
416
+ );
417
+ expect(info).toContain("REFACTOR");
418
+ });
419
+
420
+ it("shows multiple blocked files for RED", () => {
421
+ const info = getStatusInfo(
422
+ { enabled: true, current: "red" },
423
+ {
424
+ blockedInRed: ["*.ts", "*.json", "src/**"],
425
+ blockedInGreen: [],
426
+ testCommands: [],
427
+ timeoutSeconds: 30,
428
+ },
429
+ );
430
+ expect(info).toContain("Blocked in RED: *.ts, *.json, src/**");
431
+ });
432
+
433
+ it("shows multiple test commands", () => {
434
+ const info = getStatusInfo(
435
+ { enabled: true, current: "red" },
436
+ {
437
+ blockedInRed: [],
438
+ blockedInGreen: [],
439
+ testCommands: ["npx vitest run", "npm run lint"],
440
+ timeoutSeconds: 30,
441
+ },
442
+ );
443
+ expect(info).toContain("Test commands: npx vitest run, npm run lint");
444
+ });
445
+ });
@@ -0,0 +1,159 @@
1
+ import {
2
+ hasParent as realHasParent,
3
+ headMessage as realHeadMessage,
4
+ resetHard as realResetHard,
5
+ snapshot as realSnapshot,
6
+ undoLastCommit as realUndoLastCommit,
7
+ } from "./git.js";
8
+ import { savePhaseState as realSavePhaseState } from "./state.js";
9
+ import {
10
+ checkGate as realCheckGate,
11
+ getDisallowedChanges as realGetDisallowedChanges,
12
+ nextPhase as realNextPhase,
13
+ } from "./transition.js";
14
+ import type { Config, Phase, PhaseState } from "./types.js";
15
+
16
+ export interface AdvanceResult {
17
+ ok: boolean;
18
+ message: string;
19
+ newState?: PhaseState;
20
+ }
21
+
22
+ export interface AdvanceDeps {
23
+ nextPhase?: typeof realNextPhase;
24
+ getDisallowedChanges?: typeof realGetDisallowedChanges;
25
+ checkGate?: typeof realCheckGate;
26
+ snapshot?: typeof realSnapshot;
27
+ savePhaseState?: typeof realSavePhaseState;
28
+ }
29
+
30
+ export interface RevertDeps {
31
+ hasParent?: typeof realHasParent;
32
+ headMessage?: typeof realHeadMessage;
33
+ resetHard?: typeof realResetHard;
34
+ undoLastCommit?: typeof realUndoLastCommit;
35
+ savePhaseState?: typeof realSavePhaseState;
36
+ }
37
+
38
+ /**
39
+ * Advance to the next phase in the RED→GREEN→REFACTOR cycle.
40
+ * Runs allowlist check and transition gate before advancing.
41
+ * Returns a result object — caller (adapter) handles logging and formatting.
42
+ */
43
+ export async function advancePhase(
44
+ root: string,
45
+ state: PhaseState,
46
+ config: Config,
47
+ deps: AdvanceDeps & {
48
+ testRunner: (
49
+ commands: string[],
50
+ timeoutSeconds: number,
51
+ ) => Promise<{ passed: boolean; message: string }>;
52
+ },
53
+ ): Promise<AdvanceResult> {
54
+ const np = deps.nextPhase ?? realNextPhase;
55
+ const gdc = deps.getDisallowedChanges ?? realGetDisallowedChanges;
56
+ const cg = deps.checkGate ?? realCheckGate;
57
+ const snap = deps.snapshot ?? realSnapshot;
58
+ const sps = deps.savePhaseState ?? realSavePhaseState;
59
+
60
+ const from = state.current;
61
+ const to = np(from) as Phase;
62
+
63
+ // 1. Allowlist check
64
+ const violations = gdc(root, from, config);
65
+ if (violations.length > 0) {
66
+ return {
67
+ ok: false,
68
+ message:
69
+ `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
70
+ violations.map((f) => ` - ${f}`).join("\n") +
71
+ `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
72
+ };
73
+ }
74
+
75
+ // 2. Gate check
76
+ const gate = await cg(from, to, deps.testRunner, config);
77
+ if (!gate.passed) {
78
+ return { ok: false, message: gate.message };
79
+ }
80
+
81
+ // 3. Snapshot
82
+ snap(root, from);
83
+
84
+ // 4. Save state
85
+ const newState: PhaseState = { ...state, current: to };
86
+ sps(root, newState);
87
+
88
+ return { ok: true, message: "", newState };
89
+ }
90
+
91
+ /**
92
+ * Revert to the previous phase using the private git snapshot log.
93
+ * Reads the phase label from HEAD commit and restores that state.
94
+ * Returns a result object — caller (adapter) handles logging and formatting.
95
+ */
96
+ export async function revertPhase(
97
+ root: string,
98
+ state: PhaseState,
99
+ deps?: RevertDeps,
100
+ ): Promise<AdvanceResult> {
101
+ const hp = deps?.hasParent ?? realHasParent;
102
+ const hm = deps?.headMessage ?? realHeadMessage;
103
+ const rh = deps?.resetHard ?? realResetHard;
104
+ const ulc = deps?.undoLastCommit ?? realUndoLastCommit;
105
+ const sps = deps?.savePhaseState ?? realSavePhaseState;
106
+
107
+ if (!hp(root)) {
108
+ return { ok: false, message: "No previous phase to revert to." };
109
+ }
110
+
111
+ const headMsg = hm(root);
112
+ const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
113
+ if (!phaseMatch) {
114
+ return {
115
+ ok: false,
116
+ message:
117
+ `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
118
+ "The private git repo at .pi/tdd must not be manually modified. " +
119
+ "Tampering with it will cause TDD state corruption.",
120
+ };
121
+ }
122
+
123
+ const prevPhase = phaseMatch[1] as Phase;
124
+
125
+ // Nuke uncommitted changes
126
+ rh(root);
127
+
128
+ // Pop last snapshot
129
+ ulc(root);
130
+
131
+ // Update phase
132
+ const newState: PhaseState = { ...state, current: prevPhase };
133
+ sps(root, newState);
134
+
135
+ return {
136
+ ok: true,
137
+ message: `Reverted to ${prevPhase.toUpperCase()}.`,
138
+ newState,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Get a human-readable status string from current state and config.
144
+ */
145
+ export function getStatusInfo(state: PhaseState, config: Config): string {
146
+ const enabledStr = state.enabled ? "enabled" : "disabled";
147
+ const phaseStr = state.current.toUpperCase();
148
+ const redBlk = config.blockedInRed.join(", ") || "(none)";
149
+ const greenBlk = config.blockedInGreen.join(", ") || "(none)";
150
+ const commands = config.testCommands.join(", ") || "(none)";
151
+
152
+ return (
153
+ `TDD enforcer ${enabledStr}\n` +
154
+ `Current phase: ${phaseStr}\n` +
155
+ `Blocked in RED: ${redBlk}\n` +
156
+ `Blocked in GREEN: ${greenBlk}\n` +
157
+ `Test commands: ${commands}`
158
+ );
159
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getNudgePrompt } from "./prompts.js";
3
+ import type { Config } from "./types.js";
4
+
5
+ const config: Config = {
6
+ blockedInRed: ["tests/**/*.test.ts"],
7
+ blockedInGreen: ["src/**/*.ts"],
8
+ testCommands: ["npm test"],
9
+ timeoutSeconds: 30,
10
+ };
11
+
12
+ const emptyConfig: Config = {
13
+ blockedInRed: [],
14
+ blockedInGreen: [],
15
+ testCommands: [],
16
+ timeoutSeconds: 30,
17
+ };
18
+
19
+ describe("getNudgePrompt", () => {
20
+ it("returns RED prompt with blocked files when phase is red", () => {
21
+ const result = getNudgePrompt("red", config);
22
+ expect(result).toBe(
23
+ "You are now in **RED** phase. Write failing tests.\n" +
24
+ "Blocked files: tests/**/*.test.ts\n" +
25
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to GREEN.\n" +
26
+ "Think about what could go wrong and test for it — don't just verify the happy path, " +
27
+ "cover unhappy paths and edge cases too.\n" +
28
+ "Minimise the scope of each TDD cycle so reverting is cheap.",
29
+ );
30
+ });
31
+
32
+ it("returns GREEN prompt with blocked files when phase is green", () => {
33
+ const result = getNudgePrompt("green", config);
34
+ expect(result).toBe(
35
+ "You are now in **GREEN** phase. Implement features.\n" +
36
+ "Blocked files: src/**/*.ts\n" +
37
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to REFACTOR.\n" +
38
+ "Write minimal code to make the failing tests pass — nothing more.\n" +
39
+ "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them.",
40
+ );
41
+ });
42
+
43
+ it("returns REFACTOR prompt when phase is refactor", () => {
44
+ const result = getNudgePrompt("refactor", config);
45
+ expect(result).toBe(
46
+ "You are now in **REFACTOR** phase. Both test and implementation files are free to modify. " +
47
+ "Refactor without changing behavior. Call `next_tdd_phase` to start a new RED cycle.",
48
+ );
49
+ });
50
+
51
+ it("returns empty string for unknown phase", () => {
52
+ const result = getNudgePrompt("blurple" as any, config);
53
+ expect(result).toBe("");
54
+ });
55
+
56
+ it("shows empty blocked files list when no blocks configured", () => {
57
+ const result = getNudgePrompt("red", emptyConfig);
58
+ expect(result).toContain("Blocked files: ");
59
+ });
60
+
61
+ it("shows multiple blocked files comma-separated in RED", () => {
62
+ const cfg: Config = {
63
+ ...config,
64
+ blockedInRed: ["*.ts", "*.json", "src/**"],
65
+ };
66
+ const result = getNudgePrompt("red", cfg);
67
+ expect(result).toContain("Blocked files: *.ts, *.json, src/**");
68
+ });
69
+
70
+ it("shows multiple blocked files comma-separated in GREEN", () => {
71
+ const cfg: Config = {
72
+ ...config,
73
+ blockedInGreen: ["*.test.ts", "*.spec.ts"],
74
+ };
75
+ const result = getNudgePrompt("green", cfg);
76
+ expect(result).toContain("Blocked files: *.test.ts, *.spec.ts");
77
+ });
78
+
79
+ it("includes RED keyword in red phase prompt", () => {
80
+ expect(getNudgePrompt("red", config)).toContain("RED");
81
+ });
82
+
83
+ it("includes GREEN keyword in green phase prompt", () => {
84
+ expect(getNudgePrompt("green", config)).toContain("GREEN");
85
+ });
86
+
87
+ it("includes REFACTOR keyword in refactor phase prompt", () => {
88
+ expect(getNudgePrompt("refactor", config)).toContain("REFACTOR");
89
+ });
90
+ });
@@ -1,4 +1,4 @@
1
- import type { Config, Phase } from "../../engine/types.js";
1
+ import type { Config, Phase } from "./types.js";
2
2
 
3
3
  export function getNudgePrompt(phase: Phase, config: Config): string {
4
4
  const redBlock = config.blockedInRed.join(", ");