tdd-enforcer 0.2.5 → 0.2.7

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.
@@ -1,38 +1,39 @@
1
- import { describe, it, expect, beforeEach, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Config, Phase } from "../../engine/types.js";
2
3
  import { handleToolCall, handleToolResult } from "./hooks.js";
3
4
 
4
5
  // ── shared mock factories ───────────────────────────────────────────────────
5
6
 
6
7
  function makeCallDeps(overrides: Record<string, unknown> = {}) {
7
- return {
8
- loadTddState: mockLoadTddState,
9
- gitStashCreate: mockGitStashCreate,
10
- isAllowed: mockIsAllowed,
11
- tddLog: mockTddLog,
12
- isToolCallEventType: mockIsToolCallEventType,
13
- preBashStashes,
14
- ...overrides,
15
- };
8
+ return {
9
+ loadTddState: mockLoadTddState,
10
+ gitStashCreate: mockGitStashCreate,
11
+ isAllowed: mockIsAllowed,
12
+ tddLog: mockTddLog,
13
+ isToolCallEventType: mockIsToolCallEventType,
14
+ preBashStashes,
15
+ ...overrides,
16
+ };
16
17
  }
17
18
 
18
19
  function makeResultDeps(overrides: Record<string, unknown> = {}) {
19
- return {
20
- isBashToolResult: mockIsBashToolResult,
21
- loadTddState: mockLoadTddState,
22
- tddLog: mockTddLog,
23
- changesSince: mockChangesSince,
24
- isAllowed: mockIsAllowed,
25
- restoreFilesTo: mockRestoreFilesTo,
26
- preBashStashes,
27
- ...overrides,
28
- };
20
+ return {
21
+ isBashToolResult: mockIsBashToolResult,
22
+ loadTddState: mockLoadTddState,
23
+ tddLog: mockTddLog,
24
+ changesSince: mockChangesSince,
25
+ isAllowed: mockIsAllowed,
26
+ restoreFilesTo: mockRestoreFilesTo,
27
+ preBashStashes,
28
+ ...overrides,
29
+ };
29
30
  }
30
31
 
31
32
  const VALID_CONFIG = {
32
- blockedInRed: ["src/**/*.ts"],
33
- blockedInGreen: ["tests/**/*.test.ts"],
34
- testCommands: ["npm test"],
35
- timeoutSeconds: 30,
33
+ blockedInRed: ["src/**/*.ts"],
34
+ blockedInGreen: ["tests/**/*.test.ts"],
35
+ testCommands: ["npm test"],
36
+ timeoutSeconds: 30,
36
37
  };
37
38
 
38
39
  // ── mock declarations ───────────────────────────────────────────────────────
@@ -45,428 +46,530 @@ const mockIsToolCallEventType = vi.fn();
45
46
  const mockIsBashToolResult = vi.fn();
46
47
  const mockChangesSince = vi.fn();
47
48
  const mockRestoreFilesTo = vi.fn();
48
- const preBashStashes = new Map<string, string>();
49
+ const preBashStashes = new Map<
50
+ string,
51
+ { stashHash: string; phase: Phase; config: Config }
52
+ >();
49
53
 
50
54
  // Default implementations for type-guard mocks so they behave like real ones.
51
55
  mockIsToolCallEventType.mockImplementation(
52
- (type: string, event: any) => event.toolName === type,
56
+ (type: string, event: any) => event.toolName === type,
53
57
  );
54
58
  mockIsBashToolResult.mockImplementation(
55
- (event: any) => event.toolName === "bash",
59
+ (event: any) => event.toolName === "bash",
56
60
  );
57
61
 
58
62
  beforeEach(() => {
59
- vi.clearAllMocks();
60
- preBashStashes.clear();
63
+ vi.clearAllMocks();
64
+ preBashStashes.clear();
61
65
  });
62
66
 
63
67
  // ── helpers ──────────────────────────────────────────────────────────────────
64
68
 
65
69
  function enabledTddState(overrides?: { current?: string }) {
66
- return {
67
- ok: true as const,
68
- state: { enabled: true, current: overrides?.current ?? "red" },
69
- config: VALID_CONFIG,
70
- };
70
+ return {
71
+ ok: true as const,
72
+ state: { enabled: true, current: overrides?.current ?? "red" },
73
+ config: VALID_CONFIG,
74
+ };
75
+ }
76
+
77
+ function stashEntry(phase: Phase) {
78
+ return { stashHash: "stash123", phase, config: VALID_CONFIG };
71
79
  }
72
80
 
73
81
  // ── handleToolCall ──────────────────────────────────────────────────────────
74
82
 
75
83
  describe("handleToolCall", () => {
76
- it("passes through when .pi/tdd/ does not exist", async () => {
77
- mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing .pi/tdd/" });
78
-
79
- const result = await handleToolCall(
80
- { toolCallId: "1", toolName: "edit", input: { path: "/x/foo.ts" } },
81
- { cwd: "/x" } as any,
82
- makeCallDeps(),
83
- );
84
-
85
- expect(result).toBeUndefined();
86
- expect(mockTddLog).toHaveBeenCalled();
87
- });
88
-
89
- it("passes through when rules.json missing", async () => {
90
- mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing rules.json" });
91
-
92
- const result = await handleToolCall(
93
- { toolCallId: "1", toolName: "edit", input: { path: "/x/foo.ts" } },
94
- { cwd: "/x" } as any,
95
- makeCallDeps(),
96
- );
97
-
98
- expect(result).toBeUndefined();
99
- });
100
-
101
- it("passes through when TDD disabled", async () => {
102
- mockLoadTddState.mockReturnValue({
103
- ok: true,
104
- state: { enabled: false, current: "red" },
105
- config: VALID_CONFIG,
106
- });
107
-
108
- const result = await handleToolCall(
109
- { toolCallId: "1", toolName: "edit", input: { path: "/x/src/main.ts" } },
110
- { cwd: "/x" } as any,
111
- makeCallDeps(),
112
- );
113
-
114
- expect(result).toBeUndefined();
115
- });
116
-
117
- it("stores stash for bash tool and returns undefined", async () => {
118
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
119
- mockGitStashCreate.mockReturnValue("abc123");
120
-
121
- const result = await handleToolCall(
122
- { toolCallId: "bash-1", toolName: "bash" },
123
- { cwd: "/x" } as any,
124
- makeCallDeps(),
125
- );
126
-
127
- expect(result).toBeUndefined();
128
- expect(mockGitStashCreate).toHaveBeenCalledWith("/x");
129
- expect(preBashStashes.get("bash-1")).toBe("abc123");
130
- });
131
-
132
- it("handles bash stash failure gracefully", async () => {
133
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
134
- mockGitStashCreate.mockImplementation(() => { throw new Error("git error"); });
135
-
136
- const result = await handleToolCall(
137
- { toolCallId: "bash-2", toolName: "bash" },
138
- { cwd: "/x" } as any,
139
- makeCallDeps(),
140
- );
141
-
142
- expect(result).toBeUndefined();
143
- expect(preBashStashes.has("bash-2")).toBe(false);
144
- });
145
-
146
- it("blocks write to .pi/tdd/ file when TDD active", async () => {
147
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
148
-
149
- const result = await handleToolCall(
150
- { toolCallId: "1", toolName: "write", input: { path: "/x/.pi/tdd/rules.json" } },
151
- { cwd: "/x" } as any,
152
- makeCallDeps(),
153
- );
154
-
155
- expect(result).toBeDefined();
156
- expect((result as any).block).toBe(true);
157
- expect((result as any).reason).toContain("Config files are locked");
158
- });
159
-
160
- it("blocks edit to .pi/tdd/ file when TDD active", async () => {
161
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
162
-
163
- const result = await handleToolCall(
164
- { toolCallId: "1", toolName: "edit", input: { path: "/x/.pi/tdd/state.json" } },
165
- { cwd: "/x" } as any,
166
- makeCallDeps(),
167
- );
168
-
169
- expect(result).toBeDefined();
170
- expect((result as any).block).toBe(true);
171
- });
172
-
173
- it("allows write to allowed file in RED phase (test file)", async () => {
174
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
175
- mockIsAllowed.mockReturnValue(true);
176
-
177
- const result = await handleToolCall(
178
- { toolCallId: "1", toolName: "write", input: { path: "/x/tests/foo.test.ts" } },
179
- { cwd: "/x" } as any,
180
- makeCallDeps(),
181
- );
182
-
183
- expect(result).toBeUndefined();
184
- expect(mockIsAllowed).toHaveBeenCalledWith("tests/foo.test.ts", "red", VALID_CONFIG);
185
- });
186
-
187
- it("blocks write to disallowed file in RED phase (impl file)", async () => {
188
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
189
- mockIsAllowed.mockReturnValue(false);
190
-
191
- const result = await handleToolCall(
192
- { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
193
- { cwd: "/x" } as any,
194
- makeCallDeps(),
195
- );
196
-
197
- expect(result).toBeDefined();
198
- expect((result as any).block).toBe(true);
199
- expect((result as any).reason).toContain("RED");
200
- });
201
-
202
- it("allows write to allowed file in GREEN phase (impl file)", async () => {
203
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
204
- mockIsAllowed.mockReturnValue(true);
205
-
206
- const result = await handleToolCall(
207
- { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
208
- { cwd: "/x" } as any,
209
- makeCallDeps(),
210
- );
211
-
212
- expect(result).toBeUndefined();
213
- });
214
-
215
- it("blocks write to disallowed file in GREEN phase (test file)", async () => {
216
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
217
- mockIsAllowed.mockReturnValue(false);
218
-
219
- const result = await handleToolCall(
220
- { toolCallId: "1", toolName: "write", input: { path: "/x/tests/foo.test.ts" } },
221
- { cwd: "/x" } as any,
222
- makeCallDeps(),
223
- );
224
-
225
- expect(result).toBeDefined();
226
- expect((result as any).block).toBe(true);
227
- expect((result as any).reason).toContain("GREEN");
228
- });
229
-
230
- it("passes through non-file tool (e.g. read)", async () => {
231
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
232
-
233
- const result = await handleToolCall(
234
- { toolCallId: "1", toolName: "read", input: { path: "/x/foo.ts" } },
235
- { cwd: "/x" } as any,
236
- makeCallDeps(),
237
- );
238
-
239
- expect(result).toBeUndefined();
240
- });
241
-
242
- it("passes through write with no path", async () => {
243
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
244
-
245
- const result = await handleToolCall(
246
- { toolCallId: "1", toolName: "write" } as any,
247
- { cwd: "/x" } as any,
248
- makeCallDeps(),
249
- );
250
-
251
- expect(result).toBeUndefined();
252
- });
253
-
254
- it("allows free file (matching neither glob set) in any phase", async () => {
255
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
256
- mockIsAllowed.mockReturnValue(true);
257
-
258
- const result = await handleToolCall(
259
- { toolCallId: "1", toolName: "write", input: { path: "/x/README.md" } },
260
- { cwd: "/x" } as any,
261
- makeCallDeps(),
262
- );
263
-
264
- expect(result).toBeUndefined();
265
- });
266
-
267
- it("allows in REFACTOR phase regardless of file type", async () => {
268
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "refactor" }));
269
- // isAllowed is never called because the handler reaches .pi/tdd/ check
270
- // before isAllowed, and for non-.pi/tdd/ files in refactor it would
271
- // call isAllowed but we mock it anyway.
272
-
273
- const r1 = await handleToolCall(
274
- { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
275
- { cwd: "/x" } as any,
276
- makeCallDeps(),
277
- );
278
- const r2 = await handleToolCall(
279
- { toolCallId: "2", toolName: "write", input: { path: "/x/tests/foo.test.ts" } },
280
- { cwd: "/x" } as any,
281
- makeCallDeps(),
282
- );
283
-
284
- expect(r1).toBeUndefined();
285
- expect(r2).toBeUndefined();
286
-
287
- // In refactor, isAllowed is still called even though it returns true
288
- // (the implementation doesn't short-circuit for refactor before isAllowed)
289
- expect(mockIsAllowed).toHaveBeenCalledWith("src/main.ts", "refactor", VALID_CONFIG);
290
- expect(mockIsAllowed).toHaveBeenCalledWith("tests/foo.test.ts", "refactor", VALID_CONFIG);
291
- });
84
+ it("passes through when .pi/tdd/ does not exist", async () => {
85
+ mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing .pi/tdd/" });
86
+
87
+ const result = await handleToolCall(
88
+ { toolCallId: "1", toolName: "edit", input: { path: "/x/foo.ts" } },
89
+ { cwd: "/x" } as any,
90
+ makeCallDeps(),
91
+ );
92
+
93
+ expect(result).toBeUndefined();
94
+ expect(mockTddLog).toHaveBeenCalled();
95
+ });
96
+
97
+ it("passes through when rules.json missing", async () => {
98
+ mockLoadTddState.mockReturnValue({
99
+ ok: false,
100
+ reason: "Missing rules.json",
101
+ });
102
+
103
+ const result = await handleToolCall(
104
+ { toolCallId: "1", toolName: "edit", input: { path: "/x/foo.ts" } },
105
+ { cwd: "/x" } as any,
106
+ makeCallDeps(),
107
+ );
108
+
109
+ expect(result).toBeUndefined();
110
+ });
111
+
112
+ it("passes through when TDD disabled", async () => {
113
+ mockLoadTddState.mockReturnValue({
114
+ ok: true,
115
+ state: { enabled: false, current: "red" },
116
+ config: VALID_CONFIG,
117
+ });
118
+
119
+ const result = await handleToolCall(
120
+ { toolCallId: "1", toolName: "edit", input: { path: "/x/src/main.ts" } },
121
+ { cwd: "/x" } as any,
122
+ makeCallDeps(),
123
+ );
124
+
125
+ expect(result).toBeUndefined();
126
+ });
127
+
128
+ it("stores stash for bash tool and returns undefined", async () => {
129
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
130
+ mockGitStashCreate.mockReturnValue("abc123");
131
+
132
+ const result = await handleToolCall(
133
+ { toolCallId: "bash-1", toolName: "bash" },
134
+ { cwd: "/x" } as any,
135
+ makeCallDeps(),
136
+ );
137
+
138
+ expect(result).toBeUndefined();
139
+ expect(mockGitStashCreate).toHaveBeenCalledWith("/x");
140
+ const cached = preBashStashes.get("bash-1");
141
+ expect(cached).toBeDefined();
142
+ expect(cached?.stashHash).toBe("abc123");
143
+ expect(cached?.phase).toBe("red");
144
+ expect(cached?.config).toEqual(VALID_CONFIG);
145
+ });
146
+
147
+ it("handles bash stash failure gracefully", async () => {
148
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
149
+ mockGitStashCreate.mockImplementation(() => {
150
+ throw new Error("git error");
151
+ });
152
+
153
+ const result = await handleToolCall(
154
+ { toolCallId: "bash-2", toolName: "bash" },
155
+ { cwd: "/x" } as any,
156
+ makeCallDeps(),
157
+ );
158
+
159
+ expect(result).toBeUndefined();
160
+ expect(preBashStashes.has("bash-2")).toBe(false);
161
+ });
162
+
163
+ it("blocks write to .pi/tdd/ file when TDD active", async () => {
164
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
165
+
166
+ const result = await handleToolCall(
167
+ {
168
+ toolCallId: "1",
169
+ toolName: "write",
170
+ input: { path: "/x/.pi/tdd/rules.json" },
171
+ },
172
+ { cwd: "/x" } as any,
173
+ makeCallDeps(),
174
+ );
175
+
176
+ expect(result).toBeDefined();
177
+ expect((result as any).block).toBe(true);
178
+ expect((result as any).reason).toContain("Config files are locked");
179
+ });
180
+
181
+ it("blocks edit to .pi/tdd/ file when TDD active", async () => {
182
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
183
+
184
+ const result = await handleToolCall(
185
+ {
186
+ toolCallId: "1",
187
+ toolName: "edit",
188
+ input: { path: "/x/.pi/tdd/state.json" },
189
+ },
190
+ { cwd: "/x" } as any,
191
+ makeCallDeps(),
192
+ );
193
+
194
+ expect(result).toBeDefined();
195
+ expect((result as any).block).toBe(true);
196
+ });
197
+
198
+ it("allows write to allowed file in RED phase (test file)", async () => {
199
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
200
+ mockIsAllowed.mockReturnValue(true);
201
+
202
+ const result = await handleToolCall(
203
+ {
204
+ toolCallId: "1",
205
+ toolName: "write",
206
+ input: { path: "/x/tests/foo.test.ts" },
207
+ },
208
+ { cwd: "/x" } as any,
209
+ makeCallDeps(),
210
+ );
211
+
212
+ expect(result).toBeUndefined();
213
+ expect(mockIsAllowed).toHaveBeenCalledWith(
214
+ "tests/foo.test.ts",
215
+ "red",
216
+ VALID_CONFIG,
217
+ );
218
+ });
219
+
220
+ it("blocks write to disallowed file in RED phase (impl file)", async () => {
221
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
222
+ mockIsAllowed.mockReturnValue(false);
223
+
224
+ const result = await handleToolCall(
225
+ { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
226
+ { cwd: "/x" } as any,
227
+ makeCallDeps(),
228
+ );
229
+
230
+ expect(result).toBeDefined();
231
+ expect((result as any).block).toBe(true);
232
+ expect((result as any).reason).toContain("RED");
233
+ });
234
+
235
+ it("allows write to allowed file in GREEN phase (impl file)", async () => {
236
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
237
+ mockIsAllowed.mockReturnValue(true);
238
+
239
+ const result = await handleToolCall(
240
+ { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
241
+ { cwd: "/x" } as any,
242
+ makeCallDeps(),
243
+ );
244
+
245
+ expect(result).toBeUndefined();
246
+ });
247
+
248
+ it("blocks write to disallowed file in GREEN phase (test file)", async () => {
249
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
250
+ mockIsAllowed.mockReturnValue(false);
251
+
252
+ const result = await handleToolCall(
253
+ {
254
+ toolCallId: "1",
255
+ toolName: "write",
256
+ input: { path: "/x/tests/foo.test.ts" },
257
+ },
258
+ { cwd: "/x" } as any,
259
+ makeCallDeps(),
260
+ );
261
+
262
+ expect(result).toBeDefined();
263
+ expect((result as any).block).toBe(true);
264
+ expect((result as any).reason).toContain("GREEN");
265
+ });
266
+
267
+ it("passes through non-file tool (e.g. read)", async () => {
268
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
269
+
270
+ const result = await handleToolCall(
271
+ { toolCallId: "1", toolName: "read", input: { path: "/x/foo.ts" } },
272
+ { cwd: "/x" } as any,
273
+ makeCallDeps(),
274
+ );
275
+
276
+ expect(result).toBeUndefined();
277
+ });
278
+
279
+ it("passes through write with no path", async () => {
280
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
281
+
282
+ const result = await handleToolCall(
283
+ { toolCallId: "1", toolName: "write" } as any,
284
+ { cwd: "/x" } as any,
285
+ makeCallDeps(),
286
+ );
287
+
288
+ expect(result).toBeUndefined();
289
+ });
290
+
291
+ it("allows free file (matching neither glob set) in any phase", async () => {
292
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
293
+ mockIsAllowed.mockReturnValue(true);
294
+
295
+ const result = await handleToolCall(
296
+ { toolCallId: "1", toolName: "write", input: { path: "/x/README.md" } },
297
+ { cwd: "/x" } as any,
298
+ makeCallDeps(),
299
+ );
300
+
301
+ expect(result).toBeUndefined();
302
+ });
303
+
304
+ it("allows in REFACTOR phase regardless of file type", async () => {
305
+ mockLoadTddState.mockReturnValue(enabledTddState({ current: "refactor" }));
306
+ // isAllowed is never called because the handler reaches .pi/tdd/ check
307
+ // before isAllowed, and for non-.pi/tdd/ files in refactor it would
308
+ // call isAllowed — but we mock it anyway.
309
+
310
+ const r1 = await handleToolCall(
311
+ { toolCallId: "1", toolName: "write", input: { path: "/x/src/main.ts" } },
312
+ { cwd: "/x" } as any,
313
+ makeCallDeps(),
314
+ );
315
+ const r2 = await handleToolCall(
316
+ {
317
+ toolCallId: "2",
318
+ toolName: "write",
319
+ input: { path: "/x/tests/foo.test.ts" },
320
+ },
321
+ { cwd: "/x" } as any,
322
+ makeCallDeps(),
323
+ );
324
+
325
+ expect(r1).toBeUndefined();
326
+ expect(r2).toBeUndefined();
327
+
328
+ // In refactor, isAllowed is still called even though it returns true
329
+ // (the implementation doesn't short-circuit for refactor before isAllowed)
330
+ expect(mockIsAllowed).toHaveBeenCalledWith(
331
+ "src/main.ts",
332
+ "refactor",
333
+ VALID_CONFIG,
334
+ );
335
+ expect(mockIsAllowed).toHaveBeenCalledWith(
336
+ "tests/foo.test.ts",
337
+ "refactor",
338
+ VALID_CONFIG,
339
+ );
340
+ });
292
341
  });
293
342
 
294
343
  // ── handleToolResult ────────────────────────────────────────────────────────
295
344
 
296
345
  describe("handleToolResult", () => {
297
- it("returns undefined for non-bash result", async () => {
298
- const result = await handleToolResult(
299
- { toolCallId: "1", toolName: "read", content: [] },
300
- { cwd: "/x" } as any,
301
- makeResultDeps(),
302
- );
303
-
304
- expect(result).toBeUndefined();
305
- });
306
-
307
- it("passes through when no pre-stash found", async () => {
308
- mockIsBashToolResult.mockReturnValue(true);
309
-
310
- const result = await handleToolResult(
311
- { toolCallId: "nonexistent", toolName: "bash", content: [{ type: "text", text: "ok" }] },
312
- { cwd: "/x" } as any,
313
- makeResultDeps(),
314
- );
315
-
316
- expect(result).toBeUndefined();
317
- expect(mockTddLog).toHaveBeenCalled();
318
- });
319
-
320
- it("passes through when TDD disabled", async () => {
321
- mockIsBashToolResult.mockReturnValue(true);
322
- preBashStashes.set("some-id", "stash123");
323
- mockLoadTddState.mockReturnValue({
324
- ok: true,
325
- state: { enabled: false, current: "red" },
326
- config: VALID_CONFIG,
327
- });
328
-
329
- const result = await handleToolResult(
330
- { toolCallId: "some-id", toolName: "bash", content: [{ type: "text", text: "ok" }] },
331
- { cwd: "/x" } as any,
332
- makeResultDeps(),
333
- );
334
-
335
- expect(result).toBeUndefined();
336
- });
337
-
338
- it("passes through when .pi/tdd/ does not exist", async () => {
339
- mockIsBashToolResult.mockReturnValue(true);
340
- preBashStashes.set("1", "stash123");
341
- mockLoadTddState.mockReturnValue({ ok: false, reason: "Missing .pi/tdd/" });
342
-
343
- const result = await handleToolResult(
344
- { toolCallId: "1", toolName: "bash", content: [] },
345
- { cwd: "/x" } as any,
346
- makeResultDeps(),
347
- );
348
-
349
- expect(result).toBeUndefined();
350
- });
351
-
352
- it("reverts locked file when modified by bash", async () => {
353
- mockIsBashToolResult.mockReturnValue(true);
354
- preBashStashes.set("bash-revert", "stash-revert");
355
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
356
- mockChangesSince.mockReturnValue(["tests/foo.test.ts"]);
357
- mockIsAllowed.mockReturnValue(false);
358
-
359
- const result = await handleToolResult(
360
- { toolCallId: "bash-revert", toolName: "bash", content: [{ type: "text", text: "done" }] },
361
- { cwd: "/x" } as any,
362
- makeResultDeps(),
363
- );
364
-
365
- expect(result).toBeDefined();
366
- expect(result!.isError).toBe(true);
367
- expect(result!.content[0].text).toContain("reverted");
368
- expect(result!.content[0].text).toContain("tests/foo.test.ts");
369
-
370
- // The stash should be cleaned up
371
- expect(preBashStashes.has("bash-revert")).toBe(false);
372
-
373
- // The file should have been restored
374
- expect(mockRestoreFilesTo).toHaveBeenCalledWith("/x", ["tests/foo.test.ts"], "stash-revert");
375
- });
376
-
377
- it("passes through when TDD changes to .pi/tdd/ file (not tracked by private git)", async () => {
378
- mockIsBashToolResult.mockReturnValue(true);
379
- // preBashStashes has no entry for this callId — handleToolCall never ran
380
-
381
- const result = await handleToolResult(
382
- { toolCallId: "no-stash", toolName: "bash", content: [{ type: "text", text: "done" }] },
383
- { cwd: "/x" } as any,
384
- makeResultDeps(),
385
- );
386
-
387
- expect(result).toBeUndefined();
388
- });
389
-
390
- it("passes through when bash makes no changes", async () => {
391
- mockIsBashToolResult.mockReturnValue(true);
392
- preBashStashes.set("bash-nochange", "stash-nc");
393
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "red" }));
394
- mockChangesSince.mockReturnValue([]);
395
-
396
- const result = await handleToolResult(
397
- { toolCallId: "bash-nochange", toolName: "bash", content: [{ type: "text", text: "done" }] },
398
- { cwd: "/x" } as any,
399
- makeResultDeps(),
400
- );
401
-
402
- expect(result).toBeUndefined();
403
- expect(mockTddLog).toHaveBeenCalled();
404
- });
405
-
406
- it("passes through in refactor phase with no .pi/tdd/ violations", async () => {
407
- mockIsBashToolResult.mockReturnValue(true);
408
- preBashStashes.set("bash-refactor", "stash-ref");
409
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "refactor" }));
410
- mockChangesSince.mockReturnValue(["src/main.ts"]);
411
- // In refactor, .pi/tdd/ violations check: src/main.ts doesn't start with .pi/tdd/
412
- // so tddViolations is empty, then phase === "refactor" early-returns.
413
-
414
- const result = await handleToolResult(
415
- { toolCallId: "bash-refactor", toolName: "bash", content: [{ type: "text", text: "done" }] },
416
- { cwd: "/x" } as any,
417
- makeResultDeps(),
418
- );
419
-
420
- expect(result).toBeUndefined();
421
- });
422
-
423
- it("retains allowed changes alongside reverted violations", async () => {
424
- mockIsBashToolResult.mockReturnValue(true);
425
- preBashStashes.set("bash-mixed", "stash-mixed");
426
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
427
- mockChangesSince.mockReturnValue(["tests/foo.test.ts", "README.md"]);
428
- // phaseViolations filter (changed = ["tests/foo.test.ts", "README.md"]):
429
- // f="tests/foo.test.ts" → isAllowed returns false → !false = true → violation
430
- // f="README.md" isAllowed returns true → !true = false → not a violation
431
- // cmdAllowed filter:
432
- // f="tests/foo.test.ts" → isAllowed returns false → excluded from cmdAllowed
433
- // f="README.md" → isAllowed returns true → included in cmdAllowed
434
- mockIsAllowed
435
- .mockReturnValueOnce(false) // phaseViolations: tests/foo.test.ts → violation
436
- .mockReturnValueOnce(true) // phaseViolations: README.md → not a violation
437
- .mockReturnValueOnce(false) // cmdAllowed: tests/foo.test.ts → excluded
438
- .mockReturnValueOnce(true); // cmdAllowed: README.md included
439
-
440
- const result = await handleToolResult(
441
- { toolCallId: "bash-mixed", toolName: "bash", content: [{ type: "text", text: "done" }] },
442
- { cwd: "/x" } as any,
443
- makeResultDeps(),
444
- );
445
-
446
- expect(result).toBeDefined();
447
- expect(result!.isError).toBe(true);
448
- expect(result!.content[0].text).toContain("reverted");
449
- expect(result!.content[0].text).toContain("tests/foo.test.ts");
450
- expect(result!.content[0].text).toContain("Allowed changes retained");
451
- expect(result!.content[0].text).toContain("README.md");
452
-
453
- // Only the violation should be restored
454
- expect(mockRestoreFilesTo).toHaveBeenCalledWith("/x", ["tests/foo.test.ts"], "stash-mixed");
455
- });
456
-
457
- it("passes through when only allowed files changed", async () => {
458
- mockIsBashToolResult.mockReturnValue(true);
459
- preBashStashes.set("bash-allowed", "stash-allowed");
460
- mockLoadTddState.mockReturnValue(enabledTddState({ current: "green" }));
461
- mockChangesSince.mockReturnValue(["src/main.ts"]);
462
- mockIsAllowed.mockReturnValue(true);
463
-
464
- const result = await handleToolResult(
465
- { toolCallId: "bash-allowed", toolName: "bash", content: [{ type: "text", text: "done" }] },
466
- { cwd: "/x" } as any,
467
- makeResultDeps(),
468
- );
469
-
470
- expect(result).toBeUndefined();
471
- });
346
+ it("returns undefined for non-bash result", async () => {
347
+ const result = await handleToolResult(
348
+ { toolCallId: "1", toolName: "read", content: [] },
349
+ { cwd: "/x" } as any,
350
+ makeResultDeps(),
351
+ );
352
+
353
+ expect(result).toBeUndefined();
354
+ });
355
+
356
+ it("passes through when no pre-stash found", async () => {
357
+ mockIsBashToolResult.mockReturnValue(true);
358
+
359
+ const result = await handleToolResult(
360
+ {
361
+ toolCallId: "nonexistent",
362
+ toolName: "bash",
363
+ content: [{ type: "text", text: "ok" }],
364
+ },
365
+ { cwd: "/x" } as any,
366
+ makeResultDeps(),
367
+ );
368
+
369
+ expect(result).toBeUndefined();
370
+ expect(mockTddLog).toHaveBeenCalled();
371
+ });
372
+
373
+ it("loadTddState never called in handleToolResult — uses cached state", async () => {
374
+ mockIsBashToolResult.mockReturnValue(true);
375
+ preBashStashes.set("verify-cache", stashEntry("green"));
376
+ mockChangesSince.mockReturnValue([]);
377
+
378
+ await handleToolResult(
379
+ {
380
+ toolCallId: "verify-cache",
381
+ toolName: "bash",
382
+ content: [{ type: "text", text: "ok" }],
383
+ },
384
+ { cwd: "/x" } as any,
385
+ makeResultDeps(),
386
+ );
387
+
388
+ expect(mockLoadTddState).not.toHaveBeenCalled();
389
+ });
390
+
391
+ it("reverts .pi/tdd/ violations from cached state", async () => {
392
+ mockIsBashToolResult.mockReturnValue(true);
393
+ preBashStashes.set("exploit-id", {
394
+ stashHash: "stash-exploit",
395
+ phase: "green",
396
+ config: VALID_CONFIG,
397
+ });
398
+ mockChangesSince.mockReturnValue([".pi/tdd/state.json"]);
399
+
400
+ const result = await handleToolResult(
401
+ {
402
+ toolCallId: "exploit-id",
403
+ toolName: "bash",
404
+ content: [{ type: "text", text: "ok" }],
405
+ },
406
+ { cwd: "/x" } as any,
407
+ makeResultDeps(),
408
+ );
409
+
410
+ expect(mockRestoreFilesTo).toHaveBeenCalledWith(
411
+ "/x",
412
+ [".pi/tdd/state.json"],
413
+ "stash-exploit",
414
+ );
415
+ expect(result).toBeDefined();
416
+ expect(result?.content[0].text).toContain("reverted");
417
+ });
418
+
419
+ it("reverts locked file when modified by bash (uses cached phase)", async () => {
420
+ mockIsBashToolResult.mockReturnValue(true);
421
+ preBashStashes.set("bash-revert", stashEntry("green"));
422
+ mockChangesSince.mockReturnValue(["tests/foo.test.ts"]);
423
+ mockIsAllowed.mockReturnValue(false);
424
+
425
+ const result = await handleToolResult(
426
+ {
427
+ toolCallId: "bash-revert",
428
+ toolName: "bash",
429
+ content: [{ type: "text", text: "done" }],
430
+ },
431
+ { cwd: "/x" } as any,
432
+ makeResultDeps(),
433
+ );
434
+
435
+ expect(result).toBeDefined();
436
+ expect(result?.isError).toBe(true);
437
+ expect(result?.content[0].text).toContain("reverted");
438
+ expect(result?.content[0].text).toContain("tests/foo.test.ts");
439
+
440
+ // The stash should be cleaned up
441
+ expect(preBashStashes.has("bash-revert")).toBe(false);
442
+
443
+ // The file should have been restored
444
+ expect(mockRestoreFilesTo).toHaveBeenCalledWith(
445
+ "/x",
446
+ ["tests/foo.test.ts"],
447
+ "stash123",
448
+ );
449
+ });
450
+
451
+ it("passes through when no stash entry", async () => {
452
+ mockIsBashToolResult.mockReturnValue(true);
453
+ // preBashStashes has no entry for this callId — handleToolCall never ran
454
+
455
+ const result = await handleToolResult(
456
+ {
457
+ toolCallId: "no-stash",
458
+ toolName: "bash",
459
+ content: [{ type: "text", text: "done" }],
460
+ },
461
+ { cwd: "/x" } as any,
462
+ makeResultDeps(),
463
+ );
464
+
465
+ expect(result).toBeUndefined();
466
+ });
467
+
468
+ it("passes through when bash makes no changes", async () => {
469
+ mockIsBashToolResult.mockReturnValue(true);
470
+ preBashStashes.set("bash-nochange", stashEntry("red"));
471
+ mockChangesSince.mockReturnValue([]);
472
+
473
+ const result = await handleToolResult(
474
+ {
475
+ toolCallId: "bash-nochange",
476
+ toolName: "bash",
477
+ content: [{ type: "text", text: "done" }],
478
+ },
479
+ { cwd: "/x" } as any,
480
+ makeResultDeps(),
481
+ );
482
+
483
+ expect(result).toBeUndefined();
484
+ expect(mockTddLog).toHaveBeenCalled();
485
+ });
486
+
487
+ it("passes through in refactor phase with no .pi/tdd/ violations", async () => {
488
+ mockIsBashToolResult.mockReturnValue(true);
489
+ preBashStashes.set("bash-refactor", stashEntry("refactor"));
490
+ mockChangesSince.mockReturnValue(["src/main.ts"]);
491
+
492
+ const result = await handleToolResult(
493
+ {
494
+ toolCallId: "bash-refactor",
495
+ toolName: "bash",
496
+ content: [{ type: "text", text: "done" }],
497
+ },
498
+ { cwd: "/x" } as any,
499
+ makeResultDeps(),
500
+ );
501
+
502
+ expect(result).toBeUndefined();
503
+ });
504
+
505
+ it("retains allowed changes alongside reverted violations", async () => {
506
+ mockIsBashToolResult.mockReturnValue(true);
507
+ preBashStashes.set("bash-mixed", stashEntry("green"));
508
+ mockChangesSince.mockReturnValue(["tests/foo.test.ts", "README.md"]);
509
+ mockIsAllowed
510
+ .mockReturnValueOnce(false) // phaseViolations: tests/foo.test.ts → violation
511
+ .mockReturnValueOnce(true) // phaseViolations: README.md → not a violation
512
+ .mockReturnValueOnce(false) // cmdAllowed: tests/foo.test.ts → excluded
513
+ .mockReturnValueOnce(true); // cmdAllowed: README.md → included
514
+
515
+ const result = await handleToolResult(
516
+ {
517
+ toolCallId: "bash-mixed",
518
+ toolName: "bash",
519
+ content: [{ type: "text", text: "done" }],
520
+ },
521
+ { cwd: "/x" } as any,
522
+ makeResultDeps(),
523
+ );
524
+
525
+ expect(result).toBeDefined();
526
+ expect(result?.isError).toBe(true);
527
+ expect(result?.content[0].text).toContain("reverted");
528
+ expect(result?.content[0].text).toContain("tests/foo.test.ts");
529
+ expect(result?.content[0].text).toContain("Allowed changes retained");
530
+ expect(result?.content[0].text).toContain("README.md");
531
+
532
+ expect(mockRestoreFilesTo).toHaveBeenCalledWith(
533
+ "/x",
534
+ ["tests/foo.test.ts"],
535
+ "stash123",
536
+ );
537
+ });
538
+
539
+ it("passes through when only allowed files changed", async () => {
540
+ mockIsBashToolResult.mockReturnValue(true);
541
+ preBashStashes.set("bash-allowed", stashEntry("green"));
542
+ mockChangesSince.mockReturnValue(["src/main.ts"]);
543
+ mockIsAllowed.mockReturnValue(true);
544
+
545
+ const result = await handleToolResult(
546
+ {
547
+ toolCallId: "bash-allowed",
548
+ toolName: "bash",
549
+ content: [{ type: "text", text: "done" }],
550
+ },
551
+ { cwd: "/x" } as any,
552
+ makeResultDeps(),
553
+ );
554
+
555
+ expect(result).toBeUndefined();
556
+ });
557
+
558
+ it("cleans up stash entry after processing", async () => {
559
+ mockIsBashToolResult.mockReturnValue(true);
560
+ preBashStashes.set("cleanup", stashEntry("green"));
561
+ mockChangesSince.mockReturnValue([]);
562
+
563
+ await handleToolResult(
564
+ {
565
+ toolCallId: "cleanup",
566
+ toolName: "bash",
567
+ content: [{ type: "text", text: "done" }],
568
+ },
569
+ { cwd: "/x" } as any,
570
+ makeResultDeps(),
571
+ );
572
+
573
+ expect(preBashStashes.has("cleanup")).toBe(false);
574
+ });
472
575
  });