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 CHANGED
@@ -3,11 +3,16 @@
3
3
  **Lock files per TDD phase. Gate transitions on test outcomes.**
4
4
 
5
5
  [![CI](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml/badge.svg)](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml)
6
+
6
7
  [![npm version](https://img.shields.io/npm/v/tdd-enforcer.svg)](https://www.npmjs.com/package/tdd-enforcer)
7
8
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -416,5 +416,3 @@ describe("handleTddJump", () => {
416
416
  expect(ctx.notifications[0].message).toContain("Skipped to RED phase");
417
417
  });
418
418
  });
419
-
420
-
@@ -152,7 +152,6 @@ export async function handleTddStatus(
152
152
  );
153
153
  }
154
154
 
155
-
156
155
  export async function handleTddJump(
157
156
  phase: "red" | "green" | "refactor",
158
157
  ctx: ExtensionContext,
@@ -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 ────────────────────────────────────────────────────
@@ -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 timedOut = (err as any)?.killed === true;
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((r) => !r.passed && !r.timedOut);
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(passed: boolean, timeout?: boolean): TestRunner {
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
- message: timeout
33
- ? "npm test: timed out"
34
- : passed
35
- ? "all ok"
36
- : "tests failed",
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 () => {
@@ -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
- "name": "tdd-enforcer",
3
- "version": "0.3.6",
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
- }
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
  }