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.
- package/.github/workflows/ci.yml +28 -0
- package/adapters/pi/helpers.test.ts +246 -244
- package/adapters/pi/helpers.ts +117 -91
- package/adapters/pi/hooks.test.ts +528 -425
- package/adapters/pi/hooks.ts +297 -230
- package/adapters/pi/index.test.ts +282 -272
- package/adapters/pi/index.ts +222 -200
- package/adapters/pi/log.test.ts +91 -91
- package/adapters/pi/log.ts +39 -27
- package/adapters/pi/prompts.test.ts +25 -25
- package/adapters/pi/prompts.ts +28 -30
- package/adapters/pi/tools.test.ts +391 -331
- package/adapters/pi/tools.ts +368 -325
- package/behaviour.md +15 -1
- package/biome.json +37 -0
- package/engine/config.test.ts +157 -157
- package/engine/config.ts +22 -19
- package/engine/enforce.test.ts +155 -145
- package/engine/enforce.ts +31 -23
- package/engine/git.test.ts +529 -507
- package/engine/git.ts +240 -114
- package/engine/index.ts +27 -4
- package/engine/state.test.ts +69 -69
- package/engine/state.ts +22 -20
- package/engine/transition.test.ts +229 -177
- package/engine/transition.ts +55 -52
- package/engine/types.ts +9 -9
- package/package.json +29 -22
- package/skills/tdd-enforcer/SKILL.md +2 -2
- package/tsconfig.json +10 -10
package/adapters/pi/hooks.ts
CHANGED
|
@@ -1,245 +1,312 @@
|
|
|
1
1
|
import { join, relative } from "node:path";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
isBashToolResult,
|
|
8
|
+
isToolCallEventType,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
4
10
|
import { isAllowed } from "../../engine/enforce.js";
|
|
5
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
changesSince,
|
|
13
|
+
gitStashCreate,
|
|
14
|
+
restoreFilesTo,
|
|
15
|
+
} from "../../engine/git.js";
|
|
16
|
+
import type { Config, Phase } from "../../engine/types.js";
|
|
6
17
|
import { loadTddState } from "./helpers.js";
|
|
7
18
|
import { tddLog } from "./log.js";
|
|
8
19
|
|
|
9
20
|
export async function handleToolCall(
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
21
|
+
event: any,
|
|
22
|
+
ctx: ExtensionContext,
|
|
23
|
+
deps: {
|
|
24
|
+
loadTddState: typeof loadTddState;
|
|
25
|
+
gitStashCreate: typeof gitStashCreate;
|
|
26
|
+
isAllowed: typeof isAllowed;
|
|
27
|
+
tddLog: typeof tddLog;
|
|
28
|
+
isToolCallEventType: typeof isToolCallEventType;
|
|
29
|
+
preBashStashes: Map<
|
|
30
|
+
string,
|
|
31
|
+
{ stashHash: string; phase: Phase; config: Config }
|
|
32
|
+
>;
|
|
33
|
+
} = {
|
|
34
|
+
loadTddState,
|
|
35
|
+
gitStashCreate,
|
|
36
|
+
isAllowed,
|
|
37
|
+
tddLog,
|
|
38
|
+
isToolCallEventType,
|
|
39
|
+
preBashStashes: new Map(),
|
|
40
|
+
},
|
|
41
|
+
): Promise<undefined | { block: boolean; reason: string }> {
|
|
42
|
+
const root = ctx.cwd;
|
|
43
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
44
|
+
const tdd = deps.loadTddState(root);
|
|
45
|
+
if (!tdd.ok) {
|
|
46
|
+
deps.tddLog(
|
|
47
|
+
tddDir,
|
|
48
|
+
"WARN",
|
|
49
|
+
"tool_call: TDD not active, edit passes through",
|
|
50
|
+
{
|
|
51
|
+
toolName: (event as any).toolName,
|
|
52
|
+
reason: tdd.reason,
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { state, config } = tdd;
|
|
59
|
+
if (!state.enabled) {
|
|
60
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: TDD disabled, passes through", {
|
|
61
|
+
toolName: (event as any).toolName,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const phase = state.current;
|
|
66
|
+
|
|
67
|
+
// Bash: stash pre-command state for per-command diff later
|
|
68
|
+
if ((event as any).toolName === "bash") {
|
|
69
|
+
try {
|
|
70
|
+
const hash = deps.gitStashCreate(root);
|
|
71
|
+
deps.preBashStashes.set(event.toolCallId, {
|
|
72
|
+
stashHash: hash,
|
|
73
|
+
phase,
|
|
74
|
+
config,
|
|
75
|
+
});
|
|
76
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: bash pre-stash created", {
|
|
77
|
+
toolCallId: event.toolCallId,
|
|
78
|
+
hash,
|
|
79
|
+
});
|
|
80
|
+
} catch (e) {
|
|
81
|
+
deps.tddLog(tddDir, "ERROR", "tool_call: bash pre-stash failed", {
|
|
82
|
+
toolCallId: event.toolCallId,
|
|
83
|
+
error: (e as Error).message,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let filePath: string | undefined;
|
|
90
|
+
let toolName: string | undefined;
|
|
91
|
+
if (deps.isToolCallEventType("write", event)) {
|
|
92
|
+
toolName = "write";
|
|
93
|
+
filePath = (event as any).input?.path;
|
|
94
|
+
} else if (deps.isToolCallEventType("edit", event)) {
|
|
95
|
+
toolName = "edit";
|
|
96
|
+
filePath = (event as any).input?.path;
|
|
97
|
+
} else {
|
|
98
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: non-file tool, ignored", {
|
|
99
|
+
toolName: (event as any).toolName,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!filePath) {
|
|
105
|
+
deps.tddLog(tddDir, "WARN", "tool_call: no path in input, cannot block", {
|
|
106
|
+
toolName,
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Patterns in rules.json are relative to repo root; convert absolute path
|
|
112
|
+
const relPath = relative(root, filePath);
|
|
113
|
+
|
|
114
|
+
// Never allow writes to .pi/tdd/ when TDD is active
|
|
115
|
+
if (relPath.startsWith(".pi/tdd/")) {
|
|
116
|
+
deps.tddLog(tddDir, "INFO", "tool_call: blocked .pi/tdd/ file", {
|
|
117
|
+
toolName,
|
|
118
|
+
relPath,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
block: true,
|
|
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.",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const allowed = deps.isAllowed(relPath, phase, config);
|
|
128
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: check", {
|
|
129
|
+
toolName,
|
|
130
|
+
relPath,
|
|
131
|
+
phase,
|
|
132
|
+
allowed,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!allowed) {
|
|
136
|
+
deps.tddLog(tddDir, "INFO", "tool_call: blocked file modification", {
|
|
137
|
+
toolName,
|
|
138
|
+
relPath,
|
|
139
|
+
phase,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
block: true,
|
|
143
|
+
reason: `TDD ${phase.toUpperCase()}: "${relPath}" is locked in this phase.`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
deps.tddLog(tddDir, "DEBUG", "tool_call: allowed", {
|
|
148
|
+
toolName,
|
|
149
|
+
relPath,
|
|
150
|
+
phase,
|
|
151
|
+
});
|
|
116
152
|
}
|
|
117
153
|
|
|
118
154
|
export async function handleToolResult(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
event: any,
|
|
156
|
+
ctx: ExtensionContext,
|
|
157
|
+
deps: {
|
|
158
|
+
isBashToolResult: typeof isBashToolResult;
|
|
159
|
+
loadTddState: typeof loadTddState;
|
|
160
|
+
tddLog: typeof tddLog;
|
|
161
|
+
changesSince: typeof changesSince;
|
|
162
|
+
isAllowed: typeof isAllowed;
|
|
163
|
+
restoreFilesTo: typeof restoreFilesTo;
|
|
164
|
+
preBashStashes: Map<
|
|
165
|
+
string,
|
|
166
|
+
{ stashHash: string; phase: Phase; config: Config }
|
|
167
|
+
>;
|
|
168
|
+
} = {
|
|
169
|
+
isBashToolResult,
|
|
170
|
+
loadTddState,
|
|
171
|
+
tddLog,
|
|
172
|
+
changesSince,
|
|
173
|
+
isAllowed,
|
|
174
|
+
restoreFilesTo,
|
|
175
|
+
preBashStashes: new Map(),
|
|
176
|
+
},
|
|
138
177
|
): Promise<
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
| undefined
|
|
179
|
+
| { isError: boolean; content: Array<{ type: string; text: string }> }
|
|
141
180
|
> {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
181
|
+
if (!deps.isBashToolResult(event)) return;
|
|
182
|
+
|
|
183
|
+
const root = ctx.cwd;
|
|
184
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
185
|
+
|
|
186
|
+
// Get the pre-bash stash for this tool call
|
|
187
|
+
const entry = deps.preBashStashes.get(event.toolCallId);
|
|
188
|
+
deps.preBashStashes.delete(event.toolCallId);
|
|
189
|
+
if (!entry) {
|
|
190
|
+
deps.tddLog(tddDir, "WARN", "tool_result: no pre-bash stash found", {
|
|
191
|
+
toolCallId: event.toolCallId,
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { stashHash, phase, config } = entry;
|
|
197
|
+
|
|
198
|
+
// Diff against pre-bash stash — only changes from THIS command
|
|
199
|
+
const changed = deps.changesSince(root, stashHash);
|
|
200
|
+
|
|
201
|
+
if (changed.length === 0) {
|
|
202
|
+
deps.tddLog(
|
|
203
|
+
tddDir,
|
|
204
|
+
"DEBUG",
|
|
205
|
+
"tool_result: no changes in this bash command",
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Revert .pi/tdd/ violations unconditionally.
|
|
211
|
+
// The stash was created before bash ran, so it has the real pre-bash state.
|
|
212
|
+
const tddViolations = changed.filter((f) => f.startsWith(".pi/tdd/"));
|
|
213
|
+
if (tddViolations.length > 0) {
|
|
214
|
+
deps.restoreFilesTo(root, tddViolations, stashHash);
|
|
215
|
+
deps.tddLog(tddDir, "WARN", "tool_result: reverted .pi/tdd/ file", {
|
|
216
|
+
violations: tddViolations,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check phase-locked violations using cached phase + config
|
|
221
|
+
const phaseViolations =
|
|
222
|
+
phase === "refactor"
|
|
223
|
+
? []
|
|
224
|
+
: changed.filter((f) => !deps.isAllowed(f, phase, config));
|
|
225
|
+
|
|
226
|
+
const cmdViolations = [...new Set([...tddViolations, ...phaseViolations])];
|
|
227
|
+
|
|
228
|
+
if (cmdViolations.length === 0) {
|
|
229
|
+
deps.tddLog(
|
|
230
|
+
tddDir,
|
|
231
|
+
"DEBUG",
|
|
232
|
+
"tool_result: no violations among changed files",
|
|
233
|
+
{
|
|
234
|
+
changed,
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
deps.tddLog(tddDir, "WARN", "tool_result: locked files modified by bash", {
|
|
241
|
+
phase,
|
|
242
|
+
violations: cmdViolations,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Revert phase-locked violations (tddViolations already reverted above)
|
|
246
|
+
if (phaseViolations.length > 0) {
|
|
247
|
+
deps.restoreFilesTo(root, phaseViolations, stashHash);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Find remaining allowed changes (exclude .pi/tdd/)
|
|
251
|
+
const cmdAllowed = changed.filter(
|
|
252
|
+
(f) => deps.isAllowed(f, phase, config) && !f.startsWith(".pi/tdd/"),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return formatWarning(event, phase, cmdViolations, cmdAllowed);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Build the error response warning about reverted files. */
|
|
259
|
+
function formatWarning(
|
|
260
|
+
event: any,
|
|
261
|
+
phase: string,
|
|
262
|
+
cmdViolations: string[],
|
|
263
|
+
cmdAllowed: string[],
|
|
264
|
+
): { isError: boolean; content: Array<{ type: string; text: string }> } {
|
|
265
|
+
const existingText = event.content
|
|
266
|
+
?.map((c: any) => ("text" in c ? c.text : ""))
|
|
267
|
+
.join("");
|
|
268
|
+
let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
|
|
269
|
+
for (const f of cmdViolations) warning += `\n - ${f}`;
|
|
270
|
+
if (cmdAllowed.length > 0) {
|
|
271
|
+
warning += `\n\nAllowed changes retained:`;
|
|
272
|
+
for (const f of cmdAllowed) warning += `\n - ${f}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
isError: true,
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: existingText + warning,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
};
|
|
233
284
|
}
|
|
234
285
|
|
|
235
286
|
export function registerHooks(pi: ExtensionAPI): void {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
287
|
+
const preBashStashes = new Map<
|
|
288
|
+
string,
|
|
289
|
+
{ stashHash: string; phase: Phase; config: Config }
|
|
290
|
+
>();
|
|
291
|
+
pi.on("tool_call", (event, ctx) =>
|
|
292
|
+
handleToolCall(event, ctx, {
|
|
293
|
+
loadTddState,
|
|
294
|
+
gitStashCreate,
|
|
295
|
+
isAllowed,
|
|
296
|
+
tddLog,
|
|
297
|
+
isToolCallEventType,
|
|
298
|
+
preBashStashes,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
pi.on("tool_result", (event, ctx) =>
|
|
302
|
+
handleToolResult(event, ctx, {
|
|
303
|
+
isBashToolResult,
|
|
304
|
+
loadTddState,
|
|
305
|
+
tddLog,
|
|
306
|
+
changesSince,
|
|
307
|
+
isAllowed,
|
|
308
|
+
restoreFilesTo,
|
|
309
|
+
preBashStashes,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
245
312
|
}
|