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.
- package/adapters/pi/hooks.ts +1 -2
- package/adapters/pi/index.ts +7 -3
- package/adapters/pi/tools.test.ts +9 -4
- package/adapters/pi/tools.ts +42 -106
- package/engine/config.test.ts +69 -0
- package/engine/config.ts +9 -0
- package/engine/index.ts +6 -1
- package/engine/log.test.ts +171 -0
- package/engine/orchestrate.test.ts +445 -0
- package/engine/orchestrate.ts +159 -0
- package/engine/prompts.test.ts +90 -0
- package/{adapters/pi → engine}/prompts.ts +1 -1
- package/engine/state.test.ts +274 -2
- package/engine/state.ts +135 -1
- package/package.json +1 -2
- package/skills/tdd-enforcer/SKILL.md +4 -2
- package/DESIGN.md +0 -183
- package/adapters/pi/helpers.test.ts +0 -275
- package/adapters/pi/helpers.ts +0 -146
- package/adapters/pi/log.test.ts +0 -95
- package/adapters/pi/prompts.test.ts +0 -35
- package/behaviour.md +0 -175
- /package/{adapters/pi → engine}/log.ts +0 -0
package/engine/state.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { loadPhaseState, savePhaseState } from "./state.js";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { loadPhaseState, loadTddState, savePhaseState } from "./state.js";
|
|
6
6
|
|
|
7
7
|
function withTempDir(fn: (dir: string) => void) {
|
|
8
8
|
const dir = join(tmpdir(), `tdd-state-test-${Date.now()}`);
|
|
@@ -82,3 +82,275 @@ describe("savePhaseState", () => {
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
// ── loadTddState ────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const validRules = {
|
|
89
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
90
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
91
|
+
testCommands: ["npm test"],
|
|
92
|
+
timeoutSeconds: 30,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const validPhase = {
|
|
96
|
+
enabled: true,
|
|
97
|
+
current: "red",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
describe("loadTddState", () => {
|
|
101
|
+
let mockExistsSync: ReturnType<typeof vi.fn>;
|
|
102
|
+
let mockLoadConfig: ReturnType<typeof vi.fn>;
|
|
103
|
+
let mockInitGit: ReturnType<typeof vi.fn>;
|
|
104
|
+
let mockLoadPhaseState: ReturnType<typeof vi.fn>;
|
|
105
|
+
let mockSavePhaseState: ReturnType<typeof vi.fn>;
|
|
106
|
+
let mockHeadMessage: ReturnType<typeof vi.fn>;
|
|
107
|
+
let mockNextPhase: ReturnType<typeof vi.fn>;
|
|
108
|
+
let mockStageFiles: ReturnType<typeof vi.fn>;
|
|
109
|
+
|
|
110
|
+
const realNextPhase = (p: string) =>
|
|
111
|
+
p === "red"
|
|
112
|
+
? "green"
|
|
113
|
+
: p === "green"
|
|
114
|
+
? "refactor"
|
|
115
|
+
: p === "refactor"
|
|
116
|
+
? "red"
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
function makeDeps(overrides = {}) {
|
|
120
|
+
return {
|
|
121
|
+
existsSync: mockExistsSync,
|
|
122
|
+
loadConfig: mockLoadConfig,
|
|
123
|
+
initGit: mockInitGit,
|
|
124
|
+
loadPhaseState: mockLoadPhaseState,
|
|
125
|
+
savePhaseState: mockSavePhaseState,
|
|
126
|
+
headMessage: mockHeadMessage,
|
|
127
|
+
nextPhase: mockNextPhase,
|
|
128
|
+
stageFiles: mockStageFiles,
|
|
129
|
+
...overrides,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
vi.clearAllMocks();
|
|
135
|
+
mockExistsSync = vi.fn().mockImplementation((path: string) => {
|
|
136
|
+
if (path.includes(".git")) return false;
|
|
137
|
+
if (path.includes(".pi/tdd")) return true;
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
mockLoadConfig = vi.fn().mockReturnValue(validRules);
|
|
141
|
+
mockInitGit = vi.fn();
|
|
142
|
+
mockLoadPhaseState = vi.fn().mockReturnValue(validPhase);
|
|
143
|
+
mockSavePhaseState = vi.fn();
|
|
144
|
+
mockHeadMessage = vi.fn().mockReturnValue("tdd: red");
|
|
145
|
+
mockNextPhase = vi.fn().mockImplementation(realNextPhase);
|
|
146
|
+
mockStageFiles = vi.fn();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns missing dir error when .pi/tdd does not exist", () => {
|
|
150
|
+
mockExistsSync.mockReturnValue(false);
|
|
151
|
+
const result = loadTddState("/test", makeDeps());
|
|
152
|
+
expect(result.ok).toBe(false);
|
|
153
|
+
expect(result.reason).toContain("Missing .pi/tdd/");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns missing rules.json error when only dir exists", () => {
|
|
157
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
158
|
+
if (path.includes("rules.json")) return false;
|
|
159
|
+
if (path.includes(".pi/tdd")) return true;
|
|
160
|
+
return false;
|
|
161
|
+
});
|
|
162
|
+
const result = loadTddState("/test", makeDeps());
|
|
163
|
+
expect(result.ok).toBe(false);
|
|
164
|
+
expect(result.reason).toContain("rules.json");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("auto-creates state.json when missing (default RED disabled)", () => {
|
|
168
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
169
|
+
if (path.includes("state.json")) return false;
|
|
170
|
+
if (path.includes(".git")) return false;
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
174
|
+
const result = loadTddState("/test", makeDeps());
|
|
175
|
+
expect(result.ok).toBe(true);
|
|
176
|
+
if (result.ok) {
|
|
177
|
+
expect(result.state.enabled).toBe(false);
|
|
178
|
+
expect(result.state.current).toBe("red");
|
|
179
|
+
}
|
|
180
|
+
expect(mockInitGit).toHaveBeenCalled();
|
|
181
|
+
expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
|
|
182
|
+
enabled: false,
|
|
183
|
+
current: "red",
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("auto-creates state.json when corrupted (recovers to default)", () => {
|
|
188
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
189
|
+
if (path.includes("state.json")) return true;
|
|
190
|
+
if (path.includes(".git")) return false;
|
|
191
|
+
return true;
|
|
192
|
+
});
|
|
193
|
+
mockLoadPhaseState.mockImplementation(() => {
|
|
194
|
+
throw new Error("corrupt");
|
|
195
|
+
});
|
|
196
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
197
|
+
const result = loadTddState("/test", makeDeps());
|
|
198
|
+
expect(result.ok).toBe(true);
|
|
199
|
+
if (result.ok) {
|
|
200
|
+
expect(result.state.enabled).toBe(false);
|
|
201
|
+
expect(result.state.current).toBe("red");
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns invalid rules.json error for malformed JSON", () => {
|
|
206
|
+
mockLoadConfig.mockImplementation(() => {
|
|
207
|
+
throw new Error("Unexpected token");
|
|
208
|
+
});
|
|
209
|
+
const result = loadTddState("/test", makeDeps());
|
|
210
|
+
expect(result.ok).toBe(false);
|
|
211
|
+
expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns ok when state.json has enabled: false (callers check enabled)", () => {
|
|
215
|
+
mockLoadPhaseState.mockReturnValue({ enabled: false, current: "red" });
|
|
216
|
+
const result = loadTddState("/test", makeDeps());
|
|
217
|
+
expect(result.ok).toBe(true);
|
|
218
|
+
if (result.ok) {
|
|
219
|
+
expect(result.state.enabled).toBe(false);
|
|
220
|
+
expect(result.state.current).toBe("red");
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("returns ok with state and config when everything valid", () => {
|
|
225
|
+
mockLoadPhaseState.mockReturnValue(validPhase);
|
|
226
|
+
const result = loadTddState("/test", makeDeps());
|
|
227
|
+
expect(result.ok).toBe(true);
|
|
228
|
+
if (result.ok) {
|
|
229
|
+
expect(result.state.current).toBe("red");
|
|
230
|
+
expect(result.state.enabled).toBe(true);
|
|
231
|
+
expect(result.config.testCommands).toEqual(["npm test"]);
|
|
232
|
+
expect(result.config.blockedInRed).toEqual(["tests/**/*.test.ts"]);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("auto-creates git repo when missing", () => {
|
|
237
|
+
let gitExists = false;
|
|
238
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
239
|
+
if (path.includes(".git")) return gitExists;
|
|
240
|
+
if (path.includes(".pi/tdd")) return true;
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
mockInitGit.mockImplementation(() => {
|
|
244
|
+
gitExists = true;
|
|
245
|
+
});
|
|
246
|
+
const result = loadTddState("/test", makeDeps());
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
expect(mockInitGit).toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("handles multiple calls without error", () => {
|
|
252
|
+
const r1 = loadTddState("/test", makeDeps());
|
|
253
|
+
expect(r1.ok).toBe(true);
|
|
254
|
+
const r2 = loadTddState("/test", makeDeps());
|
|
255
|
+
expect(r2.ok).toBe(true);
|
|
256
|
+
expect(mockLoadConfig).toHaveBeenCalledTimes(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("recovers from invalid current phase in state.json (auto-creates default)", () => {
|
|
260
|
+
mockLoadPhaseState.mockImplementation(() => {
|
|
261
|
+
throw new Error("invalid phase");
|
|
262
|
+
});
|
|
263
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
264
|
+
const result = loadTddState("/test", makeDeps());
|
|
265
|
+
expect(result.ok).toBe(true);
|
|
266
|
+
if (result.ok) {
|
|
267
|
+
expect(result.state.enabled).toBe(false);
|
|
268
|
+
expect(result.state.current).toBe("red");
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("recoverState: HEAD tdd:red → enabled green", () => {
|
|
273
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
274
|
+
if (path.includes("state.json")) return false;
|
|
275
|
+
return true;
|
|
276
|
+
});
|
|
277
|
+
mockHeadMessage.mockReturnValue("tdd: red");
|
|
278
|
+
mockNextPhase.mockImplementation(realNextPhase);
|
|
279
|
+
const result = loadTddState("/test", makeDeps());
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
if (result.ok) {
|
|
282
|
+
expect(result.state.enabled).toBe(true);
|
|
283
|
+
expect(result.state.current).toBe("green");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("recoverState: HEAD tdd:green → enabled refactor", () => {
|
|
288
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
289
|
+
if (path.includes("state.json")) return false;
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
mockHeadMessage.mockReturnValue("tdd: green");
|
|
293
|
+
const result = loadTddState("/test", makeDeps());
|
|
294
|
+
expect(result.ok).toBe(true);
|
|
295
|
+
if (result.ok) {
|
|
296
|
+
expect(result.state.enabled).toBe(true);
|
|
297
|
+
expect(result.state.current).toBe("refactor");
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("recoverState: HEAD tdd:refactor → enabled red", () => {
|
|
302
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
303
|
+
if (path.includes("state.json")) return false;
|
|
304
|
+
return true;
|
|
305
|
+
});
|
|
306
|
+
mockHeadMessage.mockReturnValue("tdd: refactor");
|
|
307
|
+
const result = loadTddState("/test", makeDeps());
|
|
308
|
+
expect(result.ok).toBe(true);
|
|
309
|
+
if (result.ok) {
|
|
310
|
+
expect(result.state.enabled).toBe(true);
|
|
311
|
+
expect(result.state.current).toBe("red");
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("force-adds state.json after creating it from recovery", () => {
|
|
316
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
317
|
+
if (path.includes("state.json")) return false;
|
|
318
|
+
if (path.includes(".git")) return false;
|
|
319
|
+
return true;
|
|
320
|
+
});
|
|
321
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
322
|
+
const result = loadTddState("/test", makeDeps());
|
|
323
|
+
expect(result.ok).toBe(true);
|
|
324
|
+
expect(mockStageFiles).toHaveBeenCalledWith("/test", [
|
|
325
|
+
".pi/tdd/state.json",
|
|
326
|
+
]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("does not force-add state.json when it already exists and is valid", () => {
|
|
330
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
331
|
+
if (path.includes("state.json")) return true;
|
|
332
|
+
if (path.includes(".git")) return true;
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
const result = loadTddState("/test", makeDeps());
|
|
336
|
+
expect(result.ok).toBe(true);
|
|
337
|
+
expect(mockStageFiles).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("force-adds state.json after recovering from corrupted state", () => {
|
|
341
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
342
|
+
if (path.includes("state.json")) return true;
|
|
343
|
+
if (path.includes(".git")) return true;
|
|
344
|
+
return true;
|
|
345
|
+
});
|
|
346
|
+
mockLoadPhaseState.mockImplementation(() => {
|
|
347
|
+
throw new Error("corrupt");
|
|
348
|
+
});
|
|
349
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
350
|
+
const result = loadTddState("/test", makeDeps());
|
|
351
|
+
expect(result.ok).toBe(true);
|
|
352
|
+
expect(mockStageFiles).toHaveBeenCalledWith("/test", [
|
|
353
|
+
".pi/tdd/state.json",
|
|
354
|
+
]);
|
|
355
|
+
});
|
|
356
|
+
});
|
package/engine/state.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { headMessage, initGit, stageFiles } from "./git.js";
|
|
5
|
+
import { nextPhase } from "./transition.js";
|
|
6
|
+
import type { Config, PhaseState } from "./types.js";
|
|
4
7
|
|
|
5
8
|
const TDD_DIR = ".pi/tdd";
|
|
6
9
|
const VALID_PHASES = new Set(["red", "green", "refactor"]);
|
|
7
10
|
|
|
11
|
+
export type TddLoadResult =
|
|
12
|
+
| { ok: true; state: PhaseState; config: Config }
|
|
13
|
+
| { ok: false; reason: string };
|
|
14
|
+
|
|
8
15
|
export function phaseStatePath(projectRoot: string): string {
|
|
9
16
|
return join(projectRoot, TDD_DIR, "state.json");
|
|
10
17
|
}
|
|
@@ -38,3 +45,130 @@ export function savePhaseState(projectRoot: string, state: PhaseState): void {
|
|
|
38
45
|
ensureDir(path);
|
|
39
46
|
writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
|
|
40
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recover state.json from private git HEAD, or create default.
|
|
51
|
+
* Returns the state (does not save to disk — caller does that).
|
|
52
|
+
*/
|
|
53
|
+
function recoverState(
|
|
54
|
+
root: string,
|
|
55
|
+
tddDir: string,
|
|
56
|
+
deps: {
|
|
57
|
+
existsSync: typeof existsSync;
|
|
58
|
+
headMessage: typeof headMessage;
|
|
59
|
+
nextPhase: typeof nextPhase;
|
|
60
|
+
} = { existsSync, headMessage, nextPhase },
|
|
61
|
+
): PhaseState {
|
|
62
|
+
const gitDir = join(tddDir, ".git");
|
|
63
|
+
if (deps.existsSync(gitDir)) {
|
|
64
|
+
try {
|
|
65
|
+
const msg = deps.headMessage(root);
|
|
66
|
+
const m = msg.match(/^tdd: (red|green|refactor|init)$/);
|
|
67
|
+
if (m) {
|
|
68
|
+
const label = m[1];
|
|
69
|
+
if (label === "init") {
|
|
70
|
+
return { enabled: false, current: "red" };
|
|
71
|
+
}
|
|
72
|
+
const next = deps.nextPhase(label);
|
|
73
|
+
return { enabled: true, current: next ?? "red" };
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// No commits or bad HEAD — fall through to default
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { enabled: false, current: "red" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load TDD state + config in one go.
|
|
84
|
+
* Auto-creates state.json from private git HEAD when missing or corrupted.
|
|
85
|
+
* Returns ok:true with state and config when rules.json is valid.
|
|
86
|
+
* Returns ok:false with a specific reason string otherwise.
|
|
87
|
+
*
|
|
88
|
+
* Callers must check state.enabled themselves if they need active enforcement.
|
|
89
|
+
*/
|
|
90
|
+
export function loadTddState(
|
|
91
|
+
root: string,
|
|
92
|
+
deps: {
|
|
93
|
+
existsSync: typeof existsSync;
|
|
94
|
+
loadConfig: typeof loadConfig;
|
|
95
|
+
initGit: typeof initGit;
|
|
96
|
+
loadPhaseState: typeof loadPhaseState;
|
|
97
|
+
savePhaseState: typeof savePhaseState;
|
|
98
|
+
headMessage: typeof headMessage;
|
|
99
|
+
nextPhase: typeof nextPhase;
|
|
100
|
+
stageFiles: typeof stageFiles;
|
|
101
|
+
} = {
|
|
102
|
+
existsSync,
|
|
103
|
+
loadConfig,
|
|
104
|
+
initGit,
|
|
105
|
+
loadPhaseState,
|
|
106
|
+
savePhaseState,
|
|
107
|
+
headMessage,
|
|
108
|
+
nextPhase,
|
|
109
|
+
stageFiles,
|
|
110
|
+
},
|
|
111
|
+
): TddLoadResult {
|
|
112
|
+
const tddDir = join(root, ".pi", "tdd");
|
|
113
|
+
if (!deps.existsSync(tddDir)) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason:
|
|
117
|
+
"Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rulesPath = join(tddDir, "rules.json");
|
|
122
|
+
if (!deps.existsSync(rulesPath)) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason:
|
|
126
|
+
"Missing .pi/tdd/rules.json. See the tdd-enforcer skill to learn how to set up TDD configs.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let config: Config;
|
|
131
|
+
try {
|
|
132
|
+
config = deps.loadConfig(root);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}. See the tdd-enforcer skill.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Init git if missing — required for state recovery and all consumers
|
|
141
|
+
const gitDir = join(tddDir, ".git");
|
|
142
|
+
if (!deps.existsSync(gitDir)) {
|
|
143
|
+
try {
|
|
144
|
+
deps.initGit(root);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
reason: `Failed to initialise private git repo: ${(e as Error).message}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Auto-create state.json if missing or corrupted
|
|
154
|
+
const phasePath = join(tddDir, "state.json");
|
|
155
|
+
let state: PhaseState | undefined;
|
|
156
|
+
if (deps.existsSync(phasePath)) {
|
|
157
|
+
try {
|
|
158
|
+
state = deps.loadPhaseState(root);
|
|
159
|
+
} catch {
|
|
160
|
+
// Corrupted — recover below
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!state) {
|
|
164
|
+
state = recoverState(root, tddDir, {
|
|
165
|
+
existsSync: deps.existsSync,
|
|
166
|
+
headMessage: deps.headMessage,
|
|
167
|
+
nextPhase: deps.nextPhase,
|
|
168
|
+
});
|
|
169
|
+
deps.savePhaseState(root, state);
|
|
170
|
+
deps.stageFiles(root, [".pi/tdd/state.json"]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { ok: true, state, config };
|
|
174
|
+
}
|
package/package.json
CHANGED
|
@@ -42,8 +42,10 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
42
42
|
- `blockedInRed` — globs the agent **cannot** modify in RED phase (implementation files)
|
|
43
43
|
- `blockedInGreen` — globs the agent **cannot** modify in GREEN phase (test files)
|
|
44
44
|
- `!` exclusion prefix — optional, carves out subsets from a block list at init time. E.g. `!src/**/*.test.ts` excludes co-located test files from `blockedInRed` so the agent can write them in RED phase
|
|
45
|
-
- `testCommands` —
|
|
46
|
-
|
|
45
|
+
- `testCommands` — determines if a phase transition passes. Exit 0 passes, non-zero blocks. **Runs in parallel** — all entries are started concurrently. Use `&&` inside a single string entry to chain multiple commands in one step (e.g. `"npm run build && npm test"`). Do not rely on array ordering for dependency chains; put dependent commands in the same string entry with `&&`.
|
|
46
|
+
|
|
47
|
+
**Prefer auto-fix commands** that apply fixes (formatting, linting, etc.) before reporting remaining violations. Without auto-fix, formatting or lint issues in phase-locked files (e.g. test files in GREEN) will block the gate with no way to fix them — forcing `previous_tdd_phase` and losing all progress. Auto-fix commands avoid this deadlock by fixing locked files before the check runs.
|
|
48
|
+
- `timeoutSeconds` — test timeout per command (default: 120)
|
|
47
49
|
|
|
48
50
|
2. **User** runs `/tdd:on` to enable enforcement.
|
|
49
51
|
|
package/DESIGN.md
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
# TDD Enforcer — pi Extension Design
|
|
2
|
-
|
|
3
|
-
## Concept
|
|
4
|
-
|
|
5
|
-
A pi extension that enforces the Red-Green-Refactor TDD cycle by:
|
|
6
|
-
- Tracking current phase (RED / GREEN / REFACTOR)
|
|
7
|
-
- Restricting which files the agent can modify per phase
|
|
8
|
-
- Running tests on phase transitions to enforce red/green gate
|
|
9
|
-
- Nudging the agent with phase-appropriate prompts
|
|
10
|
-
|
|
11
|
-
## Tools
|
|
12
|
-
|
|
13
|
-
### `next_tdd_phase`
|
|
14
|
-
|
|
15
|
-
Advances the cycle:
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
RED ──(tests fail)──► GREEN ──(tests pass)──► REFACTOR ──(tests pass)──► RED
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
### `previous_tdd_phase`
|
|
22
|
-
|
|
23
|
-
Reverts to the previous snapshot by parsing the phase label from the last git snapshot commit. Restores working tree to exact prior state. No gate checks — just revert. Must have clear warning in the tool schema that this will revert all changes in the current state.
|
|
24
|
-
|
|
25
|
-
---
|
|
26
|
-
|
|
27
|
-
## Phase Rules
|
|
28
|
-
|
|
29
|
-
| Phase | `allowedRedPhaseFiles` | `allowedGreenPhaseFiles` | Everything else |
|
|
30
|
-
|-------|----------------------|------------------------|-----------------|
|
|
31
|
-
| RED | ✅ Allowed | ❌ Locked | ✅ Free |
|
|
32
|
-
| GREEN | ❌ Locked | ✅ Allowed | ✅ Free |
|
|
33
|
-
| REFACTOR | ✅ Allowed | ✅ Allowed | ✅ Free |
|
|
34
|
-
|
|
35
|
-
### Transition Gates
|
|
36
|
-
|
|
37
|
-
- **RED → GREEN**: Tests must **fail**. If tests pass, tool returns error — agent must break a test first.
|
|
38
|
-
- **GREEN → REFACTOR**: Tests must **pass** (all exit codes zero).
|
|
39
|
-
- **REFACTOR → RED**: Tests must **pass** (all exit codes zero).
|
|
40
|
-
|
|
41
|
-
### Nudging Prompts
|
|
42
|
-
|
|
43
|
-
Each successful transition returns a message guiding the agent:
|
|
44
|
-
|
|
45
|
-
- **→ RED**: *"You are now in **RED** phase. Write failing tests matching `allowedRedPhaseFiles` patterns. Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN."* (list matched files)
|
|
46
|
-
- **→ GREEN**: *"You are now in **GREEN** phase. Files matching `allowedRedPhaseFiles` are locked. Implement features in `allowedGreenPhaseFiles` to make tests pass. Call `next_tdd_phase` to proceed to REFACTOR."*
|
|
47
|
-
- **→ REFACTOR**: *"You are now in **REFACTOR** phase. Both `allowedRedPhaseFiles` and `allowedGreenPhaseFiles` are free to modify. Refactor without changing behavior. Call `next_tdd_phase` to start a new RED cycle."*
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
## File Enforcement: Private Git + `tool_call` Fast-Feedback
|
|
52
|
-
|
|
53
|
-
### Source of truth: private git repo
|
|
54
|
-
|
|
55
|
-
A separate git repository at `.pi/tdd/.git/` that tracks the project root as its working tree. The user's `.git/` is never touched.
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
.pi/tdd/
|
|
59
|
-
├── .gitignore # private git — excludes file patterns from snapshots
|
|
60
|
-
├── state.json # {current: "red", enabled: true}
|
|
61
|
-
├── rules.json # user config
|
|
62
|
-
└── .git/ # private git — init with --git-dir
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
**Setup:** `git init` with `--git-dir=.pi/tdd/.git --work-tree=<project-root>`.
|
|
66
|
-
|
|
67
|
-
**On phase entry (snapshot):**
|
|
68
|
-
`git add -A && git commit -m "tdd: <phase> <ts>"` — captures entire working tree state.
|
|
69
|
-
|
|
70
|
-
**On `next_tdd_phase` / `previous_tdd_phase` (allowlist check):**
|
|
71
|
-
- `git diff --name-only HEAD` against previous snapshot commit
|
|
72
|
-
- Cross-reference each changed file against phase allowlist
|
|
73
|
-
- Violations → BLOCK with list of disallowed files
|
|
74
|
-
- Also check for untracked files (`git ls-files --others --exclude-standard`)
|
|
75
|
-
|
|
76
|
-
**On `previous_tdd_phase` (revert):**
|
|
77
|
-
- `git restore --source=<prev-commit> --worktree -- .` — restores project to exact prior snapshot
|
|
78
|
-
- Pop the last snapshot commit in the private git repo
|
|
79
|
-
|
|
80
|
-
### Benefits of private git
|
|
81
|
-
|
|
82
|
-
- Catches ALL modifications — write, edit, bash, sed, python, C, anything — because it diffs the working tree, not tool calls
|
|
83
|
-
- Cross-platform (git is everywhere)
|
|
84
|
-
- No shell parsing, no fragile regexes, no edge cases
|
|
85
|
-
- Zero interference with user's git — different `.git/`, no shared refs, no hooks, no global config
|
|
86
|
-
- `.pi/tdd/` is disposable — user can nuke it anytime
|
|
87
|
-
- Free diff, merge, partial restore, binary handling — no custom engine to write
|
|
88
|
-
|
|
89
|
-
### Fast feedback: per-tool enforcement
|
|
90
|
-
|
|
91
|
-
The transition-time check catches everything, but it's wasteful to let the agent work on wrong files for a full phase. We enforce per-tool:
|
|
92
|
-
|
|
93
|
-
#### `write` / `edit` — pre-execution block
|
|
94
|
-
|
|
95
|
-
The file path is a direct parameter. In `tool_call`:
|
|
96
|
-
- If path is disallowed in current phase → `{ block: true, reason: "..." }`
|
|
97
|
-
- Otherwise → allow
|
|
98
|
-
|
|
99
|
-
#### `bash` — post-execution detect-and-revert
|
|
100
|
-
|
|
101
|
-
Bash can modify files indirectly (redirects, `sed -i`, scripts, compilers). Parsing command strings to predict targets is fragile. Instead:
|
|
102
|
-
|
|
103
|
-
1. **Let bash run** — no pre-check
|
|
104
|
-
2. **In `tool_result`:** `git diff --name-only HEAD` + `git ls-files --others --exclude-standard` to get all changes since phase snapshot
|
|
105
|
-
3. For each file: if it's disallowed in current phase → `git restore <filepath>` and append warning
|
|
106
|
-
|
|
107
|
-
No in-memory tracking needed. The check is the same for every file regardless of how it was modified — write/edit changes that passed pre-check naturally match the allowed globs, violations get reverted.
|
|
108
|
-
|
|
109
|
-
#### Why not regex bash parsing?
|
|
110
|
-
|
|
111
|
-
Everyone else does it (pi-proof, pi-superteam, tdd-guard). It's fragile — misses `$(dynamic paths)`, glob expansion, scripts calling other scripts, piped commands, heredocs with variables. Our git-based post-check catches everything regex misses, with zero false negatives.
|
|
112
|
-
|
|
113
|
-
Regex pre-check is optional (could catch obvious cases for better UX) but the git post-check is the reliable enforcer.
|
|
114
|
-
|
|
115
|
-
---
|
|
116
|
-
|
|
117
|
-
### `.gitignore`
|
|
118
|
-
|
|
119
|
-
The private git's work-tree is the project root, so it respects the project's `.gitignore` automatically — no copy needed, no separate file needed.
|
|
120
|
-
|
|
121
|
-
If the user wants to exclude additional files from TDD snapshots only, they can create `.pi/tdd/.gitignore` with those patterns. Git checks `.gitignore` starting from the work-tree root, so a file there is picked up naturally.
|
|
122
|
-
|
|
123
|
-
---
|
|
124
|
-
|
|
125
|
-
## Config: `.pi/tdd/rules.json`
|
|
126
|
-
|
|
127
|
-
```json
|
|
128
|
-
{
|
|
129
|
-
"allowedRedPhaseFiles": ["tests/**/*.test.ts", "specs/**/*.spec.ts"],
|
|
130
|
-
"allowedGreenPhaseFiles": ["src/**/*.ts"],
|
|
131
|
-
"testCommands": ["npm run test:unit", "npm run test:integration"],
|
|
132
|
-
"timeoutSeconds": 120
|
|
133
|
-
}
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
- `allowedRedPhaseFiles`: Glob patterns for files allowed in RED phase (typically test files).
|
|
137
|
-
- `allowedGreenPhaseFiles`: Glob patterns for files allowed in GREEN phase (typically implementation files).
|
|
138
|
-
- Files matching neither set are free in all phases.
|
|
139
|
-
- `testCommands`: `string` (shell-chained with `&&` for sequential) or `string[]` (run in parallel). Must be non-interactive.
|
|
140
|
-
- `timeoutSeconds`: Per-command timeout. Extension passes it as pi's `bash` tool timeout param, so we don't rely on system `timeout` binary.
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
---
|
|
147
|
-
|
|
148
|
-
## Phase State Persistence
|
|
149
|
-
|
|
150
|
-
Stored in `.pi/tdd/state.json`:
|
|
151
|
-
|
|
152
|
-
```json
|
|
153
|
-
{
|
|
154
|
-
"current": "green",
|
|
155
|
-
"enabled": true
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
The snapshot history lives in the private git repo's commit log — no explicit stack array needed. `previous_tdd_phase` reads the phase label from the HEAD commit message and restores to the parent. Survives session restarts and extension reloads.
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## Open Questions
|
|
164
|
-
|
|
165
|
-
### Initial state
|
|
166
|
-
|
|
167
|
-
How does a project start?
|
|
168
|
-
|
|
169
|
-
- **Auto RED**: Start in RED unconditionally. If tests already pass, agent sees a warning that there's no failing test yet — it needs to write one or break one.
|
|
170
|
-
- **Auto-detect**: Run tests on startup. If failing → RED. If passing → GREEN (agent is already in the "make it pass" phase).
|
|
171
|
-
- **Prompt**: Ask the user what phase to start in.
|
|
172
|
-
|
|
173
|
-
### Enabling/disabling
|
|
174
|
-
|
|
175
|
-
Should we have `/tdd:on` and `/tdd:off` commands to toggle without unloading the extension?
|
|
176
|
-
|
|
177
|
-
### Config location
|
|
178
|
-
|
|
179
|
-
Only `.pi/tdd/rules.json` in project root? Or support nested configs?
|
|
180
|
-
|
|
181
|
-
### Multiple projects / monorepos
|
|
182
|
-
|
|
183
|
-
If the project root has sub-projects with different test commands, does rules.json support multiple entries keyed by directory?
|