tdd-enforcer 0.2.2 → 0.2.5

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.
@@ -6,182 +6,240 @@ import { changesSince, restoreFilesTo, gitStashCreate } from "../../engine/git.j
6
6
  import { loadTddState } from "./helpers.js";
7
7
  import { tddLog } from "./log.js";
8
8
 
9
- // Correlates tool_call → tool_result for per-command bash diff
10
- const preBashStashes = new Map<string, string>();
11
-
12
- export function registerHooks(pi: ExtensionAPI): void {
13
- pi.on("tool_call", async (event, ctx: ExtensionContext) => {
14
- const root = ctx.cwd;
15
- const tddDir = join(root, ".pi", "tdd");
16
- const tdd = loadTddState(root);
17
- if (!tdd.ok) {
18
- tddLog(tddDir, "WARN", "tool_call: TDD not active, edit passes through", {
19
- toolName: (event as any).toolName,
20
- reason: tdd.reason,
21
- });
22
- return;
23
- }
24
-
25
- const { state, config } = tdd;
26
- const phase = state.current;
27
-
28
- // Bash: stash pre-command state for per-command diff later
29
- if ((event as any).toolName === "bash") {
30
- try {
31
- const hash = gitStashCreate(root);
32
- preBashStashes.set(event.toolCallId, hash);
33
- tddLog(tddDir, "DEBUG", "tool_call: bash pre-stash created", {
34
- toolCallId: event.toolCallId,
35
- hash,
36
- });
37
- } catch (e) {
38
- tddLog(tddDir, "ERROR", "tool_call: bash pre-stash failed", {
39
- toolCallId: event.toolCallId,
40
- error: (e as Error).message,
41
- });
42
- }
43
- return;
44
- }
45
-
46
- let filePath: string | undefined;
47
- let toolName: string | undefined;
48
- if (isToolCallEventType("write", event)) {
49
- toolName = "write";
50
- filePath = (event as any).input?.path;
51
- } else if (isToolCallEventType("edit", event)) {
52
- toolName = "edit";
53
- filePath = (event as any).input?.path;
54
- } else {
55
- tddLog(tddDir, "DEBUG", "tool_call: non-file tool, ignored", {
56
- toolName: (event as any).toolName,
57
- });
58
- return;
59
- }
9
+ export async function handleToolCall(
10
+ event: any,
11
+ ctx: ExtensionContext,
12
+ deps: {
13
+ loadTddState: typeof loadTddState;
14
+ gitStashCreate: typeof gitStashCreate;
15
+ isAllowed: typeof isAllowed;
16
+ tddLog: typeof tddLog;
17
+ isToolCallEventType: typeof isToolCallEventType;
18
+ preBashStashes: Map<string, string>;
19
+ } = {
20
+ loadTddState,
21
+ gitStashCreate,
22
+ isAllowed,
23
+ tddLog,
24
+ isToolCallEventType,
25
+ preBashStashes: new Map(),
26
+ },
27
+ ): Promise<void | { block: boolean; reason: string }> {
28
+ const root = ctx.cwd;
29
+ const tddDir = join(root, ".pi", "tdd");
30
+ const tdd = deps.loadTddState(root);
31
+ if (!tdd.ok) {
32
+ deps.tddLog(tddDir, "WARN", "tool_call: TDD not active, edit passes through", {
33
+ toolName: (event as any).toolName,
34
+ reason: tdd.reason,
35
+ });
36
+ return;
37
+ }
60
38
 
61
- if (!filePath) {
62
- tddLog(tddDir, "WARN", "tool_call: no path in input, cannot block", {
63
- toolName,
39
+ const { state, config } = tdd;
40
+ if (!state.enabled) {
41
+ deps.tddLog(tddDir, "DEBUG", "tool_call: TDD disabled, passes through", {
42
+ toolName: (event as any).toolName,
43
+ });
44
+ return;
45
+ }
46
+ const phase = state.current;
47
+
48
+ // Bash: stash pre-command state for per-command diff later
49
+ if ((event as any).toolName === "bash") {
50
+ try {
51
+ const hash = deps.gitStashCreate(root);
52
+ deps.preBashStashes.set(event.toolCallId, hash);
53
+ deps.tddLog(tddDir, "DEBUG", "tool_call: bash pre-stash created", {
54
+ toolCallId: event.toolCallId,
55
+ hash,
64
56
  });
65
- return;
66
- }
67
-
68
- // Patterns in rules.json are relative to repo root; convert absolute path
69
- const relPath = relative(root, filePath);
70
-
71
- // Never allow writes to .pi/tdd/ when TDD is active
72
- if (relPath.startsWith(".pi/tdd/")) {
73
- tddLog(tddDir, "INFO", "tool_call: blocked .pi/tdd/ file", { toolName, relPath });
74
- return {
75
- block: true,
76
- reason: "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.",
77
- };
78
- }
79
-
80
- const allowed = isAllowed(relPath, phase, config);
81
- tddLog(tddDir, "DEBUG", "tool_call: check", { toolName, relPath, phase, allowed });
82
-
83
- if (!allowed) {
84
- tddLog(tddDir, "INFO", "tool_call: blocked file modification", {
85
- toolName,
86
- relPath,
87
- phase,
57
+ } catch (e) {
58
+ deps.tddLog(tddDir, "ERROR", "tool_call: bash pre-stash failed", {
59
+ toolCallId: event.toolCallId,
60
+ error: (e as Error).message,
88
61
  });
89
- return {
90
- block: true,
91
- reason: `TDD ${phase.toUpperCase()}: "${relPath}" is locked in this phase.`,
92
- };
93
62
  }
63
+ return;
64
+ }
65
+
66
+ let filePath: string | undefined;
67
+ let toolName: string | undefined;
68
+ if (deps.isToolCallEventType("write", event)) {
69
+ toolName = "write";
70
+ filePath = (event as any).input?.path;
71
+ } else if (deps.isToolCallEventType("edit", event)) {
72
+ toolName = "edit";
73
+ filePath = (event as any).input?.path;
74
+ } else {
75
+ deps.tddLog(tddDir, "DEBUG", "tool_call: non-file tool, ignored", {
76
+ toolName: (event as any).toolName,
77
+ });
78
+ return;
79
+ }
94
80
 
95
- tddLog(tddDir, "DEBUG", "tool_call: allowed", { toolName, relPath, phase });
96
- });
81
+ if (!filePath) {
82
+ deps.tddLog(tddDir, "WARN", "tool_call: no path in input, cannot block", {
83
+ toolName,
84
+ });
85
+ return;
86
+ }
97
87
 
98
- pi.on("tool_result", async (event, ctx: ExtensionContext) => {
99
- if (!isBashToolResult(event)) return;
88
+ // Patterns in rules.json are relative to repo root; convert absolute path
89
+ const relPath = relative(root, filePath);
100
90
 
101
- const root = ctx.cwd;
102
- const tddDir = join(root, ".pi", "tdd");
91
+ // Never allow writes to .pi/tdd/ when TDD is active
92
+ if (relPath.startsWith(".pi/tdd/")) {
93
+ deps.tddLog(tddDir, "INFO", "tool_call: blocked .pi/tdd/ file", { toolName, relPath });
94
+ return {
95
+ block: true,
96
+ reason: "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.",
97
+ };
98
+ }
103
99
 
104
- // Get the pre-bash stash for this tool call
105
- const stashHash = preBashStashes.get(event.toolCallId);
106
- preBashStashes.delete(event.toolCallId);
107
- if (!stashHash) {
108
- tddLog(tddDir, "WARN", "tool_result: no pre-bash stash found", {
109
- toolCallId: event.toolCallId,
110
- });
111
- return;
112
- }
100
+ const allowed = deps.isAllowed(relPath, phase, config);
101
+ deps.tddLog(tddDir, "DEBUG", "tool_call: check", { toolName, relPath, phase, allowed });
113
102
 
114
- const tdd = loadTddState(root);
115
- if (!tdd.ok) {
116
- tddLog(tddDir, "WARN", "tool_result: TDD not active, bash passes through", {
117
- reason: tdd.reason,
118
- });
119
- return;
120
- }
103
+ if (!allowed) {
104
+ deps.tddLog(tddDir, "INFO", "tool_call: blocked file modification", {
105
+ toolName,
106
+ relPath,
107
+ phase,
108
+ });
109
+ return {
110
+ block: true,
111
+ reason: `TDD ${phase.toUpperCase()}: "${relPath}" is locked in this phase.`,
112
+ };
113
+ }
121
114
 
122
- const { state, config } = tdd;
123
- const phase = state.current;
115
+ deps.tddLog(tddDir, "DEBUG", "tool_call: allowed", { toolName, relPath, phase });
116
+ }
124
117
 
125
- // Diff against pre-bash stash — only changes from THIS command
126
- const changed = changesSince(root, stashHash);
118
+ export async function handleToolResult(
119
+ event: any,
120
+ ctx: ExtensionContext,
121
+ deps: {
122
+ isBashToolResult: typeof isBashToolResult;
123
+ loadTddState: typeof loadTddState;
124
+ tddLog: typeof tddLog;
125
+ changesSince: typeof changesSince;
126
+ isAllowed: typeof isAllowed;
127
+ restoreFilesTo: typeof restoreFilesTo;
128
+ preBashStashes: Map<string, string>;
129
+ } = {
130
+ isBashToolResult,
131
+ loadTddState,
132
+ tddLog,
133
+ changesSince,
134
+ isAllowed,
135
+ restoreFilesTo,
136
+ preBashStashes: new Map(),
137
+ },
138
+ ): Promise<
139
+ | void
140
+ | { isError: boolean; content: Array<{ type: string; text: string }> }
141
+ > {
142
+ if (!deps.isBashToolResult(event)) return;
143
+
144
+ const root = ctx.cwd;
145
+ const tddDir = join(root, ".pi", "tdd");
146
+
147
+ // Get the pre-bash stash for this tool call
148
+ const stashHash = deps.preBashStashes.get(event.toolCallId);
149
+ deps.preBashStashes.delete(event.toolCallId);
150
+ if (!stashHash) {
151
+ deps.tddLog(tddDir, "WARN", "tool_result: no pre-bash stash found", {
152
+ toolCallId: event.toolCallId,
153
+ });
154
+ return;
155
+ }
127
156
 
128
- if (changed.length === 0) {
129
- tddLog(tddDir, "DEBUG", "tool_result: no changes in this bash command");
157
+ const tdd = deps.loadTddState(root);
158
+ if (!tdd.ok) {
159
+ deps.tddLog(tddDir, "WARN", "tool_result: TDD not active, bash passes through", {
160
+ reason: tdd.reason,
161
+ });
162
+ return;
163
+ }
164
+
165
+ const { state, config } = tdd;
166
+ if (!state.enabled) {
167
+ deps.tddLog(tddDir, "DEBUG", "tool_result: TDD disabled, bash passes through");
168
+ return;
169
+ }
170
+ const phase = state.current;
171
+
172
+ // Diff against pre-bash stash — only changes from THIS command
173
+ const changed = deps.changesSince(root, stashHash);
174
+
175
+ if (changed.length === 0) {
176
+ deps.tddLog(tddDir, "DEBUG", "tool_result: no changes in this bash command");
177
+ return;
178
+ }
179
+
180
+ // Config files (.pi/tdd/) are always violations when TDD is active
181
+ const tddViolations = changed.filter((f) => f.startsWith(".pi/tdd/"));
182
+
183
+ if (phase === "refactor") {
184
+ // In refactor, only .pi/tdd/ files are violations
185
+ if (tddViolations.length === 0) {
186
+ deps.tddLog(tddDir, "DEBUG", "tool_result: refactor phase, no TDD dir violations");
130
187
  return;
131
188
  }
189
+ }
132
190
 
133
- // Config files (.pi/tdd/) are always violations when TDD is active
134
- const tddViolations = changed.filter((f) => f.startsWith(".pi/tdd/"));
135
-
136
- if (phase === "refactor") {
137
- // In refactor, only .pi/tdd/ files are violations
138
- if (tddViolations.length === 0) {
139
- tddLog(tddDir, "DEBUG", "tool_result: refactor phase, no TDD dir violations");
140
- return;
141
- }
142
- }
191
+ const phaseViolations =
192
+ phase === "refactor" ? [] : changed.filter((f) => !deps.isAllowed(f, phase, config));
143
193
 
144
- const phaseViolations =
145
- phase === "refactor" ? [] : changed.filter((f) => !isAllowed(f, phase, config));
194
+ const cmdViolations = [...new Set([...tddViolations, ...phaseViolations])];
146
195
 
147
- const cmdViolations = [...new Set([...tddViolations, ...phaseViolations])];
148
-
149
- if (cmdViolations.length === 0) {
150
- tddLog(tddDir, "DEBUG", "tool_result: no violations among changed files", {
151
- changed,
152
- });
153
- return;
154
- }
155
-
156
- tddLog(tddDir, "WARN", "tool_result: locked files modified by bash", {
157
- phase,
158
- violations: cmdViolations,
196
+ if (cmdViolations.length === 0) {
197
+ deps.tddLog(tddDir, "DEBUG", "tool_result: no violations among changed files", {
198
+ changed,
159
199
  });
200
+ return;
201
+ }
160
202
 
161
- // Revert only this command's violations back to pre-bash state
162
- restoreFilesTo(root, cmdViolations, stashHash);
163
-
164
- // Find remaining allowed changes from this command (exclude .pi/tdd/)
165
- const cmdAllowed = changed.filter(
166
- (f) => isAllowed(f, phase, config) && !f.startsWith(".pi/tdd/"),
167
- );
203
+ deps.tddLog(tddDir, "WARN", "tool_result: locked files modified by bash", {
204
+ phase,
205
+ violations: cmdViolations,
206
+ });
168
207
 
169
- const existingText = event.content.map((c) => ("text" in c ? c.text : "")).join("");
170
- let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
171
- cmdViolations.forEach((f) => (warning += `\n - ${f}`));
172
- if (cmdAllowed.length > 0) {
173
- warning += `\n\nAllowed changes retained:`;
174
- cmdAllowed.forEach((f) => (warning += `\n - ${f}`));
175
- }
208
+ // Revert only this command's violations back to pre-bash state
209
+ deps.restoreFilesTo(root, cmdViolations, stashHash);
210
+
211
+ // Find remaining allowed changes from this command (exclude .pi/tdd/)
212
+ const cmdAllowed = changed.filter(
213
+ (f) => deps.isAllowed(f, phase, config) && !f.startsWith(".pi/tdd/"),
214
+ );
215
+
216
+ const existingText = event.content.map((c: any) => ("text" in c ? c.text : "")).join("");
217
+ let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
218
+ cmdViolations.forEach((f) => (warning += `\n - ${f}`));
219
+ if (cmdAllowed.length > 0) {
220
+ warning += `\n\nAllowed changes retained:`;
221
+ cmdAllowed.forEach((f) => (warning += `\n - ${f}`));
222
+ }
223
+
224
+ return {
225
+ isError: true,
226
+ content: [
227
+ {
228
+ type: "text",
229
+ text: existingText + warning,
230
+ },
231
+ ],
232
+ };
233
+ }
176
234
 
177
- return {
178
- isError: true,
179
- content: [
180
- {
181
- type: "text",
182
- text: existingText + warning,
183
- },
184
- ],
185
- };
186
- });
235
+ export function registerHooks(pi: ExtensionAPI): void {
236
+ const preBashStashes = new Map<string, string>();
237
+ pi.on("tool_call", (event, ctx) => handleToolCall(event, ctx, {
238
+ loadTddState, gitStashCreate, isAllowed, tddLog, isToolCallEventType,
239
+ preBashStashes,
240
+ }));
241
+ pi.on("tool_result", (event, ctx) => handleToolResult(event, ctx, {
242
+ isBashToolResult, loadTddState, tddLog, changesSince, isAllowed,
243
+ restoreFilesTo, preBashStashes,
244
+ }));
187
245
  }