tdd-enforcer 0.2.1 → 0.2.3
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/helpers.test.ts +218 -160
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +43 -0
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +321 -218
- package/behaviour.md +26 -11
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +62 -57
- package/engine/transition.ts +9 -2
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +5 -0
- package/tsconfig.json +12 -0
package/adapters/pi/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
88
|
+
// Patterns in rules.json are relative to repo root; convert absolute path
|
|
89
|
+
const relPath = relative(root, filePath);
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
115
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: allowed", { toolName, relPath, phase });
|
|
116
|
+
}
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
145
|
-
phase === "refactor" ? [] : changed.filter((f) => !isAllowed(f, phase, config));
|
|
194
|
+
const cmdViolations = [...new Set([...tddViolations, ...phaseViolations])];
|
|
146
195
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
}
|