tdd-enforcer 0.3.6 → 0.3.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/README.md +5 -0
- package/adapters/pi/index.test.ts +0 -2
- package/adapters/pi/index.ts +0 -1
- package/adapters/pi/tools.test.ts +79 -0
- package/adapters/pi/tools.ts +19 -3
- package/engine/transition.test.ts +52 -6
- package/engine/transition.ts +10 -0
- package/package.json +41 -41
package/README.md
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
**Lock files per TDD phase. Gate transitions on test outcomes.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml)
|
|
6
|
+
|
|
6
7
|
[](https://www.npmjs.com/package/tdd-enforcer)
|
|
7
8
|
[](LICENSE)
|
|
8
9
|
|
|
9
10
|
---
|
|
10
11
|
|
|
12
|
+
<img width="741" height="586" alt="Screenshot 2026-06-26 at 20 52 02" src="https://github.com/user-attachments/assets/4bba490d-ab94-43b1-8c2a-552b34374aee" />
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
11
16
|
## Features
|
|
12
17
|
|
|
13
18
|
- **Phase-locked file access** — prevents the agent from modifying test files in GREEN and implementation files in RED. Everything is open in REFACTOR
|
package/adapters/pi/index.ts
CHANGED
|
@@ -246,6 +246,85 @@ describe("executeNextPhase", () => {
|
|
|
246
246
|
current: "red",
|
|
247
247
|
});
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
it("passes ctx.signal through to asyncExec for user cancellation", async () => {
|
|
251
|
+
const ac = new AbortController();
|
|
252
|
+
|
|
253
|
+
// Use green→refactor so passing tests satisfy the gate check
|
|
254
|
+
mockLoadTddState.mockReturnValue({
|
|
255
|
+
ok: true,
|
|
256
|
+
state: { enabled: true, current: "green" },
|
|
257
|
+
config: CONFIG,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// A checkGate mock that actually runs the testRunner so asyncExec is called
|
|
261
|
+
const callTestRunner = vi.fn(
|
|
262
|
+
async (_from: string, _to: string, tr: any, cfg: any) => {
|
|
263
|
+
return tr(cfg.testCommands, cfg.timeoutSeconds);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
mockAsyncExec.mockImplementation(async (_cmd: string, opts?: any) => {
|
|
268
|
+
expect(opts?.signal).toBe(ac.signal);
|
|
269
|
+
return { stdout: "", stderr: "" };
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await executeNextPhase(
|
|
273
|
+
{ cwd: "/test", signal: ac.signal } as any,
|
|
274
|
+
makeDeps({ checkGate: callTestRunner as any }),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(mockAsyncExec).toHaveBeenCalledWith(
|
|
278
|
+
"npm test",
|
|
279
|
+
expect.objectContaining({ signal: ac.signal }),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns cancellation message when signal is aborted mid-execution", async () => {
|
|
284
|
+
const ac = new AbortController();
|
|
285
|
+
|
|
286
|
+
mockLoadTddState.mockReturnValue({
|
|
287
|
+
ok: true,
|
|
288
|
+
state: { enabled: true, current: "green" },
|
|
289
|
+
config: CONFIG,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const callTestRunner = vi.fn(
|
|
293
|
+
async (_from: string, _to: string, tr: any, cfg: any) => {
|
|
294
|
+
return tr(cfg.testCommands, cfg.timeoutSeconds);
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// asyncExec that hangs until signal aborts, then rejects like exec does on kill
|
|
299
|
+
mockAsyncExec.mockImplementation(async (_cmd: string, opts?: any) => {
|
|
300
|
+
return new Promise((_resolve, reject) => {
|
|
301
|
+
if (opts?.signal) {
|
|
302
|
+
if (opts.signal.aborted) {
|
|
303
|
+
const err: any = new Error("canceled");
|
|
304
|
+
err.killed = true;
|
|
305
|
+
reject(err);
|
|
306
|
+
} else {
|
|
307
|
+
opts.signal.addEventListener("abort", () => {
|
|
308
|
+
const err: any = new Error("canceled");
|
|
309
|
+
err.killed = true;
|
|
310
|
+
reject(err);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const promise = executeNextPhase(
|
|
318
|
+
{ cwd: "/test", signal: ac.signal } as any,
|
|
319
|
+
makeDeps({ checkGate: callTestRunner as any }),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
ac.abort();
|
|
323
|
+
|
|
324
|
+
await expect(promise).rejects.toThrow("cancelled");
|
|
325
|
+
expect(mockSnapshot).not.toHaveBeenCalled();
|
|
326
|
+
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
249
328
|
});
|
|
250
329
|
|
|
251
330
|
// ── executePreviousPhase ────────────────────────────────────────────────────
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface NextPhaseDeps {
|
|
|
40
40
|
getNudgePrompt: typeof getNudgePrompt;
|
|
41
41
|
asyncExec: (
|
|
42
42
|
command: string,
|
|
43
|
-
options?: { cwd?: string; timeout?: number },
|
|
43
|
+
options?: { cwd?: string; timeout?: number; signal?: AbortSignal },
|
|
44
44
|
) => Promise<{ stdout: string; stderr: string }>;
|
|
45
45
|
tddLog: typeof tddLog;
|
|
46
46
|
}
|
|
@@ -118,6 +118,7 @@ export async function executeNextPhase(
|
|
|
118
118
|
|
|
119
119
|
deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
|
|
120
120
|
|
|
121
|
+
const signal = ctx.signal;
|
|
121
122
|
const testRunner: TestRunner = async (commands, timeout) => {
|
|
122
123
|
const results = await Promise.all(
|
|
123
124
|
commands.map(async (cmd) => {
|
|
@@ -125,21 +126,36 @@ export async function executeNextPhase(
|
|
|
125
126
|
await deps.asyncExec(cmd, {
|
|
126
127
|
cwd: root,
|
|
127
128
|
timeout: timeout * 1000,
|
|
129
|
+
signal,
|
|
128
130
|
});
|
|
129
131
|
return { command: cmd, passed: true, timedOut: false } as const;
|
|
130
132
|
} catch (err) {
|
|
131
|
-
const
|
|
133
|
+
const killed = (err as any)?.killed === true;
|
|
134
|
+
const cancelled = signal?.aborted === true;
|
|
135
|
+
const timedOut = killed && !cancelled;
|
|
132
136
|
return {
|
|
133
137
|
command: cmd,
|
|
134
138
|
passed: false,
|
|
135
139
|
timedOut,
|
|
140
|
+
cancelled,
|
|
136
141
|
} as const;
|
|
137
142
|
}
|
|
138
143
|
}),
|
|
139
144
|
);
|
|
140
145
|
|
|
146
|
+
const cancelled = results.filter((r) => (r as any).cancelled === true);
|
|
141
147
|
const timedOut = results.filter((r) => r.timedOut);
|
|
142
|
-
const failed = results.filter(
|
|
148
|
+
const failed = results.filter(
|
|
149
|
+
(r) => !r.passed && !r.timedOut && !(r as any).cancelled,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (cancelled.length > 0) {
|
|
153
|
+
return {
|
|
154
|
+
passed: false,
|
|
155
|
+
cancelled: true,
|
|
156
|
+
message: `Test execution was cancelled.\n${cancelled.map((f) => ` - ${f.command}`).join("\n")}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
143
159
|
|
|
144
160
|
if (timedOut.length > 0) {
|
|
145
161
|
return {
|
|
@@ -25,15 +25,22 @@ describe("nextPhase", () => {
|
|
|
25
25
|
|
|
26
26
|
// ── Pure unit tests: checkGate ──────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
-
function makeRunner(
|
|
28
|
+
function makeRunner(
|
|
29
|
+
passed: boolean,
|
|
30
|
+
timeout?: boolean,
|
|
31
|
+
cancelled?: boolean,
|
|
32
|
+
): TestRunner {
|
|
29
33
|
return async (_cmds, _timeout) => ({
|
|
30
34
|
passed,
|
|
31
35
|
timeout,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
cancelled,
|
|
37
|
+
message: cancelled
|
|
38
|
+
? "Test execution was cancelled."
|
|
39
|
+
: timeout
|
|
40
|
+
? "npm test: timed out"
|
|
41
|
+
: passed
|
|
42
|
+
? "all ok"
|
|
43
|
+
: "tests failed",
|
|
37
44
|
});
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -70,6 +77,19 @@ describe("checkGate", () => {
|
|
|
70
77
|
expect(r.message).toMatch(/timed out/i);
|
|
71
78
|
expect(r.message).not.toMatch(/proceed|fail/i);
|
|
72
79
|
});
|
|
80
|
+
|
|
81
|
+
it("blocks on cancellation — preserves cancellation message", async () => {
|
|
82
|
+
const r = await checkGate(
|
|
83
|
+
"red",
|
|
84
|
+
"green",
|
|
85
|
+
makeRunner(false, undefined, true),
|
|
86
|
+
testConfig,
|
|
87
|
+
);
|
|
88
|
+
expect(r.passed).toBe(false);
|
|
89
|
+
expect(r.cancelled).toBe(true);
|
|
90
|
+
expect(r.message).toMatch(/cancelled/i);
|
|
91
|
+
expect(r.message).not.toMatch(/timed out|proceed|fail/i);
|
|
92
|
+
});
|
|
73
93
|
});
|
|
74
94
|
|
|
75
95
|
describe("green → refactor (tests must pass)", () => {
|
|
@@ -107,6 +127,19 @@ describe("checkGate", () => {
|
|
|
107
127
|
expect(r.message).toMatch(/timed out/i);
|
|
108
128
|
expect(r.message).not.toMatch(/fix them/i);
|
|
109
129
|
});
|
|
130
|
+
|
|
131
|
+
it("blocks on cancellation — preserves cancellation message", async () => {
|
|
132
|
+
const r = await checkGate(
|
|
133
|
+
"green",
|
|
134
|
+
"refactor",
|
|
135
|
+
makeRunner(false, undefined, true),
|
|
136
|
+
testConfig,
|
|
137
|
+
);
|
|
138
|
+
expect(r.passed).toBe(false);
|
|
139
|
+
expect(r.cancelled).toBe(true);
|
|
140
|
+
expect(r.message).toMatch(/cancelled/i);
|
|
141
|
+
expect(r.message).not.toMatch(/timed out|fix them/i);
|
|
142
|
+
});
|
|
110
143
|
});
|
|
111
144
|
|
|
112
145
|
describe("refactor → red (tests must pass)", () => {
|
|
@@ -144,6 +177,19 @@ describe("checkGate", () => {
|
|
|
144
177
|
expect(r.message).toMatch(/timed out/i);
|
|
145
178
|
expect(r.message).not.toMatch(/fix them/i);
|
|
146
179
|
});
|
|
180
|
+
|
|
181
|
+
it("blocks on cancellation — preserves cancellation message", async () => {
|
|
182
|
+
const r = await checkGate(
|
|
183
|
+
"refactor",
|
|
184
|
+
"red",
|
|
185
|
+
makeRunner(false, undefined, true),
|
|
186
|
+
testConfig,
|
|
187
|
+
);
|
|
188
|
+
expect(r.passed).toBe(false);
|
|
189
|
+
expect(r.cancelled).toBe(true);
|
|
190
|
+
expect(r.message).toMatch(/cancelled/i);
|
|
191
|
+
expect(r.message).not.toMatch(/timed out|fix them/i);
|
|
192
|
+
});
|
|
147
193
|
});
|
|
148
194
|
|
|
149
195
|
it("passes test commands to the runner", async () => {
|
package/engine/transition.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface GateResult {
|
|
|
14
14
|
passed: boolean;
|
|
15
15
|
message: string;
|
|
16
16
|
timeout?: boolean;
|
|
17
|
+
cancelled?: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export type TestRunner = (
|
|
@@ -35,6 +36,15 @@ export async function checkGate(
|
|
|
35
36
|
): Promise<GateResult> {
|
|
36
37
|
const result = await testRunner(config.testCommands, config.timeoutSeconds);
|
|
37
38
|
|
|
39
|
+
// Cancellation blocks all transitions — preserve the message from testRunner
|
|
40
|
+
if (result.cancelled) {
|
|
41
|
+
return {
|
|
42
|
+
passed: false,
|
|
43
|
+
cancelled: true,
|
|
44
|
+
message: result.message,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
// Timeout blocks all transitions — don't suggest "fix tests"
|
|
39
49
|
if (result.timeout) {
|
|
40
50
|
return {
|
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
2
|
+
"name": "tdd-enforcer",
|
|
3
|
+
"version": "0.3.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "TDD enforcer extension for the pi coding agent — enforces Red-Green-Refactor phases with file access restrictions and transition gates",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Cyclone1070 <hoangmai1070@gmail.com>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Cyclone1070/tdd-enforcer.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Cyclone1070/tdd-enforcer/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Cyclone1070/tdd-enforcer#readme",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"check": "biome check --write . && vitest run",
|
|
18
|
+
"lint": "biome check .",
|
|
19
|
+
"lint:fix": "biome check --write .",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"pi-package"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"picomatch": "^4.0.4"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@biomejs/biome": "^2.5.0",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "^0.79.6",
|
|
31
|
+
"@types/node": "^25.9.3",
|
|
32
|
+
"typebox": "^1.2.16",
|
|
33
|
+
"vitest": "^3"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./adapters/pi/index.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./skills"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
43
|
}
|