tdd-enforcer 0.2.3 → 0.2.6

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.
@@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { loadTddState } from "./helpers.js";
3
3
 
4
4
  const validRules = {
5
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
6
- allowedGreenPhaseFiles: ["src/**/*.ts"],
5
+ blockedInRed: ["tests/**/*.test.ts"],
6
+ blockedInGreen: ["src/**/*.ts"],
7
7
  testCommands: ["npm test"],
8
8
  timeoutSeconds: 30,
9
9
  };
@@ -148,7 +148,7 @@ describe("loadTddState", () => {
148
148
  expect(result.state.current).toBe("red");
149
149
  expect(result.state.enabled).toBe(true);
150
150
  expect(result.config.testCommands).toEqual(["npm test"]);
151
- expect(result.config.allowedRedPhaseFiles).toEqual([
151
+ expect(result.config.blockedInRed).toEqual([
152
152
  "tests/**/*.test.ts",
153
153
  ]);
154
154
  }
@@ -29,8 +29,8 @@ function makeResultDeps(overrides: Record<string, unknown> = {}) {
29
29
  }
30
30
 
31
31
  const VALID_CONFIG = {
32
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
33
- allowedGreenPhaseFiles: ["src/**/*.ts"],
32
+ blockedInRed: ["src/**/*.ts"],
33
+ blockedInGreen: ["tests/**/*.test.ts"],
34
34
  testCommands: ["npm test"],
35
35
  timeoutSeconds: 30,
36
36
  };
@@ -2,8 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { handleTddOn, handleTddOff, handleTddStatus, handleTddReset } from "./index.js";
3
3
 
4
4
  const config = {
5
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
6
- allowedGreenPhaseFiles: ["src/**/*.ts"],
5
+ blockedInRed: ["tests/**/*.test.ts"],
6
+ blockedInGreen: ["src/**/*.ts"],
7
7
  testCommands: ["npm test"],
8
8
  timeoutSeconds: 30,
9
9
  };
@@ -119,8 +119,8 @@ export async function handleTddStatus(
119
119
  const { state, config } = result;
120
120
  const enabledStr = state.enabled ? "enabled" : "disabled";
121
121
  const phaseStr = state.current.toUpperCase();
122
- const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
123
- const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
122
+ const redBlk = config.blockedInRed.join(", ") || "(none)";
123
+ const greenBlk = config.blockedInGreen.join(", ") || "(none)";
124
124
  const commands = config.testCommands.join(", ") || "(none)";
125
125
 
126
126
  deps.tddLog(tddDir, "INFO", "tdd:status: queried", {
@@ -131,8 +131,8 @@ export async function handleTddStatus(
131
131
  ctx.ui.notify(
132
132
  `TDD enforcer ${enabledStr}\n` +
133
133
  `Current phase: ${phaseStr}\n` +
134
- `Test files: ${redGlobs}\n` +
135
- `Impl files: ${greenGlobs}\n` +
134
+ `Blocked in RED: ${redBlk}\n` +
135
+ `Blocked in GREEN: ${greenBlk}\n` +
136
136
  `Test commands: ${commands}`,
137
137
  "info",
138
138
  );
@@ -3,31 +3,23 @@ import { getNudgePrompt } from "./prompts.js";
3
3
  import type { Config } from "../../engine/types.js";
4
4
 
5
5
  const config: Config = {
6
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
7
- allowedGreenPhaseFiles: ["src/**/*.ts"],
6
+ blockedInRed: ["tests/**/*.test.ts"],
7
+ blockedInGreen: ["src/**/*.ts"],
8
8
  testCommands: ["npm test"],
9
9
  timeoutSeconds: 30,
10
10
  };
11
11
 
12
12
  describe("getNudgePrompt", () => {
13
- it("returns RED prompt with config patterns by default", () => {
13
+ it("returns RED prompt with blocked files", () => {
14
14
  const result = getNudgePrompt("red", config);
15
15
  expect(result).toContain("RED");
16
- expect(result).toContain("tests/**/*.test.ts");
16
+ expect(result).toContain("Blocked files: tests/**/*.test.ts");
17
17
  });
18
18
 
19
- it("returns RED prompt with custom matchedFiles override", () => {
20
- const result = getNudgePrompt("red", config, ["custom/**/*.test.ts"]);
21
- expect(result).toContain("RED");
22
- expect(result).toContain("custom/**/*.test.ts");
23
- expect(result).not.toContain("tests/**/*.test.ts");
24
- });
25
-
26
- it("returns GREEN prompt with both red and green patterns", () => {
19
+ it("returns GREEN prompt with blocked files", () => {
27
20
  const result = getNudgePrompt("green", config);
28
21
  expect(result).toContain("GREEN");
29
- expect(result).toContain("tests/**/*.test.ts");
30
- expect(result).toContain("src/**/*.ts");
22
+ expect(result).toContain("Blocked files: src/**/*.ts");
31
23
  });
32
24
 
33
25
  it("returns REFACTOR prompt", () => {
@@ -1,24 +1,25 @@
1
1
  import type { Phase, Config } from "../../engine/types.js";
2
2
 
3
- export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: string[]): string {
4
- const redPatterns = (matchedFiles ?? config.allowedRedPhaseFiles).join(", ");
5
- const greenPatterns = config.allowedGreenPhaseFiles.join(", ");
3
+ export function getNudgePrompt(phase: Phase, config: Config): string {
4
+ const redBlock = config.blockedInRed.join(", ");
5
+ const greenBlock = config.blockedInGreen.join(", ");
6
6
 
7
7
  switch (phase) {
8
8
  case "red":
9
9
  return (
10
- `You are now in **RED** phase. Write failing tests matching: ${redPatterns}\n` +
11
- "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN.\n" +
12
- "Keep this cycle small. Write tests for one feature at a time. " +
13
- "Cover happy path, edge cases, and unhappy paths before advancing to GREEN."
10
+ `You are now in **RED** phase. Write failing tests.\n` +
11
+ `Blocked files: ${redBlock}\n` +
12
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to GREEN.\n" +
13
+ "Think about what could go wrong and test for it don't just verify the happy path, " +
14
+ "cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap."
14
15
  );
15
16
  case "green":
16
17
  return (
17
- `You are now in **GREEN** phase. Test files (${redPatterns}) are locked.\n` +
18
- `Implement features matching: ${greenPatterns}\n` +
19
- "Call `next_tdd_phase` to proceed to REFACTOR.\n" +
20
- "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them. " +
21
- "Don't be afraid to discard clean slate beats patched code."
18
+ `You are now in **GREEN** phase. Implement features.\n` +
19
+ `Blocked files: ${greenBlock}\n` +
20
+ "All other files are free to modify. Call `next_tdd_phase` to proceed to REFACTOR.\n" +
21
+ "Write minimal code to make the failing tests pass nothing more.\n" +
22
+ "If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them."
22
23
  );
23
24
  case "refactor":
24
25
  return (
@@ -5,8 +5,8 @@ import type { NextPhaseDeps, PreviousPhaseDeps, TddStatusDeps } from "./tools.js
5
5
  // ── Helpers ─────────────────────────────────────────────────────────────────
6
6
 
7
7
  const CONFIG = {
8
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
9
- allowedGreenPhaseFiles: ["src/**/*.ts"],
8
+ blockedInRed: ["tests/**/*.test.ts"],
9
+ blockedInGreen: ["src/**/*.ts"],
10
10
  testCommands: ["npm test"],
11
11
  timeoutSeconds: 30,
12
12
  };
@@ -303,8 +303,8 @@ export async function executeTddStatus(
303
303
 
304
304
  const { state, config } = result;
305
305
  const phaseStr = state.current.toUpperCase();
306
- const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
307
- const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
306
+ const redBlk = config.blockedInRed.join(", ") || "(none)";
307
+ const greenBlk = config.blockedInGreen.join(", ") || "(none)";
308
308
  const commands = config.testCommands.join(", ") || "(none)";
309
309
 
310
310
  deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
@@ -318,16 +318,16 @@ export async function executeTddStatus(
318
318
  text:
319
319
  `TDD enforcer enabled\n` +
320
320
  `Current phase: ${phaseStr}\n` +
321
- `Test files: ${redGlobs}\n` +
322
- `Impl files: ${greenGlobs}\n` +
321
+ `Blocked in RED: ${redBlk}\n` +
322
+ `Blocked in GREEN: ${greenBlk}\n` +
323
323
  `Test commands: ${commands}`,
324
324
  },
325
325
  ],
326
326
  details: {
327
327
  enabled: true,
328
328
  phase: state.current,
329
- allowedRedPhaseFiles: config.allowedRedPhaseFiles,
330
- allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
329
+ blockedInRed: config.blockedInRed,
330
+ blockedInGreen: config.blockedInGreen,
331
331
  testCommands: config.testCommands,
332
332
  },
333
333
  };
@@ -366,7 +366,7 @@ export function registerTools(pi: ExtensionAPI): void {
366
366
  label: "TDD Status",
367
367
  description:
368
368
  "Show the current TDD enforcement status: enabled/disabled, current phase, " +
369
- "allowed file globs, and test commands.",
369
+ "blocked file globs per phase, and test commands.",
370
370
  parameters: Type.Object({}),
371
371
  execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
372
372
  return executeTddStatus(ctx, defaultTddStatusDeps);
@@ -28,8 +28,8 @@ describe("loadConfig", () => {
28
28
  writeFileSync(
29
29
  join(tddDir, "rules.json"),
30
30
  JSON.stringify({
31
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
32
- allowedGreenPhaseFiles: ["src/**/*.ts"],
31
+ blockedInRed: ["tests/**/*.test.ts"],
32
+ blockedInGreen: ["src/**/*.ts"],
33
33
  testCommands: ["npm run test"],
34
34
  timeoutSeconds: 60,
35
35
  }),
@@ -37,7 +37,7 @@ describe("loadConfig", () => {
37
37
  );
38
38
 
39
39
  const config = loadConfig(dir);
40
- expect(config.allowedRedPhaseFiles).toEqual(["tests/**/*.test.ts"]);
40
+ expect(config.blockedInRed).toEqual(["tests/**/*.test.ts"]);
41
41
  expect(config.timeoutSeconds).toBe(60);
42
42
  });
43
43
  });
@@ -49,8 +49,8 @@ describe("loadConfig", () => {
49
49
  writeFileSync(
50
50
  join(tddDir, "rules.json"),
51
51
  JSON.stringify({
52
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
53
- allowedGreenPhaseFiles: ["src/**/*.ts"],
52
+ blockedInRed: ["tests/**/*.test.ts"],
53
+ blockedInGreen: ["src/**/*.ts"],
54
54
  testCommands: ["npm run test:unit", "npm run test:integration"],
55
55
  }),
56
56
  "utf-8",
@@ -62,15 +62,15 @@ describe("loadConfig", () => {
62
62
  });
63
63
 
64
64
  describe("validation — throws on invalid content", () => {
65
- it("throws when allowedRedPhaseFiles is not an array", () => {
65
+ it("throws when blockedInRed is not an array", () => {
66
66
  withTempDir((dir) => {
67
67
  const tddDir = join(dir, ".pi", "tdd");
68
68
  mkdirSync(tddDir, { recursive: true });
69
69
  writeFileSync(
70
70
  join(tddDir, "rules.json"),
71
71
  JSON.stringify({
72
- allowedRedPhaseFiles: "not-an-array",
73
- allowedGreenPhaseFiles: ["src/**/*.ts"],
72
+ blockedInRed: "not-an-array",
73
+ blockedInGreen: ["src/**/*.ts"],
74
74
  testCommands: ["npm test"],
75
75
  }),
76
76
  "utf-8",
@@ -79,15 +79,15 @@ describe("loadConfig", () => {
79
79
  });
80
80
  });
81
81
 
82
- it("throws when allowedGreenPhaseFiles is not an array", () => {
82
+ it("throws when blockedInGreen is not an array", () => {
83
83
  withTempDir((dir) => {
84
84
  const tddDir = join(dir, ".pi", "tdd");
85
85
  mkdirSync(tddDir, { recursive: true });
86
86
  writeFileSync(
87
87
  join(tddDir, "rules.json"),
88
88
  JSON.stringify({
89
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
90
- allowedGreenPhaseFiles: null,
89
+ blockedInRed: ["tests/**/*.test.ts"],
90
+ blockedInGreen: null,
91
91
  testCommands: ["npm test"],
92
92
  }),
93
93
  "utf-8",
@@ -103,8 +103,8 @@ describe("loadConfig", () => {
103
103
  writeFileSync(
104
104
  join(tddDir, "rules.json"),
105
105
  JSON.stringify({
106
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
107
- allowedGreenPhaseFiles: ["src/**/*.ts"],
106
+ blockedInRed: ["tests/**/*.test.ts"],
107
+ blockedInGreen: ["src/**/*.ts"],
108
108
  testCommands: "npm test",
109
109
  }),
110
110
  "utf-8",
@@ -122,15 +122,15 @@ describe("loadConfig", () => {
122
122
  });
123
123
  });
124
124
 
125
- it("throws when allowedRedPhaseFiles is empty", () => {
125
+ it("throws when blockedInRed is empty", () => {
126
126
  withTempDir((dir) => {
127
127
  const tddDir = join(dir, ".pi", "tdd");
128
128
  mkdirSync(tddDir, { recursive: true });
129
129
  writeFileSync(
130
130
  join(tddDir, "rules.json"),
131
131
  JSON.stringify({
132
- allowedRedPhaseFiles: [],
133
- allowedGreenPhaseFiles: ["src/**/*.ts"],
132
+ blockedInRed: [],
133
+ blockedInGreen: ["src/**/*.ts"],
134
134
  testCommands: ["npm test"],
135
135
  }),
136
136
  "utf-8",
@@ -139,15 +139,15 @@ describe("loadConfig", () => {
139
139
  });
140
140
  });
141
141
 
142
- it("throws when allowedGreenPhaseFiles is empty", () => {
142
+ it("throws when blockedInGreen is empty", () => {
143
143
  withTempDir((dir) => {
144
144
  const tddDir = join(dir, ".pi", "tdd");
145
145
  mkdirSync(tddDir, { recursive: true });
146
146
  writeFileSync(
147
147
  join(tddDir, "rules.json"),
148
148
  JSON.stringify({
149
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
150
- allowedGreenPhaseFiles: [],
149
+ blockedInRed: ["tests/**/*.test.ts"],
150
+ blockedInGreen: [],
151
151
  testCommands: ["npm test"],
152
152
  }),
153
153
  "utf-8",
@@ -163,8 +163,8 @@ describe("loadConfig", () => {
163
163
  writeFileSync(
164
164
  join(tddDir, "rules.json"),
165
165
  JSON.stringify({
166
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
167
- allowedGreenPhaseFiles: ["src/**/*.ts"],
166
+ blockedInRed: ["tests/**/*.test.ts"],
167
+ blockedInGreen: ["src/**/*.ts"],
168
168
  testCommands: [],
169
169
  }),
170
170
  "utf-8",
package/engine/config.ts CHANGED
@@ -13,19 +13,19 @@ export function loadConfig(projectRoot: string): Config {
13
13
  const raw = readFileSync(path, "utf-8");
14
14
  const parsed = JSON.parse(raw);
15
15
 
16
- if (!Array.isArray(parsed.allowedRedPhaseFiles) || parsed.allowedRedPhaseFiles.length === 0) {
17
- throw new Error("rules.json: allowedRedPhaseFiles must be a non-empty array");
16
+ if (!Array.isArray(parsed.blockedInRed) || parsed.blockedInRed.length === 0) {
17
+ throw new Error("rules.json: blockedInRed must be a non-empty array");
18
18
  }
19
- if (!Array.isArray(parsed.allowedGreenPhaseFiles) || parsed.allowedGreenPhaseFiles.length === 0) {
20
- throw new Error("rules.json: allowedGreenPhaseFiles must be a non-empty array");
19
+ if (!Array.isArray(parsed.blockedInGreen) || parsed.blockedInGreen.length === 0) {
20
+ throw new Error("rules.json: blockedInGreen must be a non-empty array");
21
21
  }
22
22
  if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
23
23
  throw new Error("rules.json: testCommands must be a non-empty array");
24
24
  }
25
25
 
26
26
  return {
27
- allowedRedPhaseFiles: parsed.allowedRedPhaseFiles,
28
- allowedGreenPhaseFiles: parsed.allowedGreenPhaseFiles,
27
+ blockedInRed: parsed.blockedInRed,
28
+ blockedInGreen: parsed.blockedInGreen,
29
29
  testCommands: parsed.testCommands,
30
30
  timeoutSeconds: parsed.timeoutSeconds ?? 120,
31
31
  };
@@ -3,8 +3,8 @@ import { isAllowed, disallowedFiles } from "./enforce.js";
3
3
  import type { Config } from "./types.js";
4
4
 
5
5
  const testConfig: Config = {
6
- allowedRedPhaseFiles: ["tests/**/*.test.ts", "specs/**/*.spec.ts"],
7
- allowedGreenPhaseFiles: ["src/**/*.ts", "lib/**/*.ts"],
6
+ blockedInRed: ["src/**/*.ts", "lib/**/*.ts"],
7
+ blockedInGreen: ["tests/**/*.test.ts", "specs/**/*.spec.ts"],
8
8
  testCommands: ["npm test"],
9
9
  timeoutSeconds: 30,
10
10
  };
@@ -16,41 +16,114 @@ describe("isAllowed", () => {
16
16
  });
17
17
 
18
18
  describe("red phase", () => {
19
- it("allows red phase files", () => {
20
- expect(isAllowed("tests/unit/foo.test.ts", "red", testConfig)).toBe(true);
21
- expect(isAllowed("specs/api.spec.ts", "red", testConfig)).toBe(true);
19
+ it("allows free files", () => {
20
+ expect(isAllowed("README.md", "red", testConfig)).toBe(true);
21
+ expect(isAllowed("package.json", "red", testConfig)).toBe(true);
22
22
  });
23
23
 
24
- it("blocks green phase files", () => {
24
+ it("blocks files in blockedInRed", () => {
25
25
  expect(isAllowed("src/main.ts", "red", testConfig)).toBe(false);
26
26
  expect(isAllowed("lib/helper.ts", "red", testConfig)).toBe(false);
27
27
  });
28
28
 
29
- it("allows free files (match neither)", () => {
30
- expect(isAllowed("README.md", "red", testConfig)).toBe(true);
31
- expect(isAllowed("package.json", "red", testConfig)).toBe(true);
29
+ it("allows test files in red phase", () => {
30
+ expect(isAllowed("tests/foo.test.ts", "red", testConfig)).toBe(true);
31
+ expect(isAllowed("specs/api.spec.ts", "red", testConfig)).toBe(true);
32
32
  });
33
33
  });
34
34
 
35
35
  describe("green phase", () => {
36
- it("allows green phase files", () => {
36
+ it("allows free files", () => {
37
+ expect(isAllowed("README.md", "green", testConfig)).toBe(true);
38
+ });
39
+
40
+ it("allows implementation files in green phase", () => {
37
41
  expect(isAllowed("src/main.ts", "green", testConfig)).toBe(true);
38
42
  expect(isAllowed("lib/helper.ts", "green", testConfig)).toBe(true);
39
43
  });
40
44
 
41
- it("blocks red phase files", () => {
42
- expect(isAllowed("tests/unit/foo.test.ts", "green", testConfig)).toBe(false);
45
+ it("blocks files in blockedInGreen", () => {
46
+ expect(isAllowed("tests/foo.test.ts", "green", testConfig)).toBe(false);
43
47
  expect(isAllowed("specs/api.spec.ts", "green", testConfig)).toBe(false);
44
48
  });
49
+ });
45
50
 
46
- it("allows free files", () => {
47
- expect(isAllowed("README.md", "green", testConfig)).toBe(true);
51
+ describe("negation patterns (!)", () => {
52
+ const exclConfig: Config = {
53
+ blockedInRed: ["src/**/*.ts"],
54
+ blockedInGreen: ["src/**/*.ts", "!**/*.test.ts"],
55
+ testCommands: ["npm test"],
56
+ timeoutSeconds: 30,
57
+ };
58
+
59
+ it("excludes files matching !pattern from the block", () => {
60
+ expect(isAllowed("src/main.test.ts", "green", exclConfig)).toBe(true);
48
61
  });
49
- });
50
62
 
51
- it("handles nested glob patterns", () => {
52
- expect(isAllowed("src/deep/nested/file.ts", "green", testConfig)).toBe(true);
53
- expect(isAllowed("tests/deep/nested/test.test.ts", "red", testConfig)).toBe(true);
63
+ it("still blocks files not matching the exclusion", () => {
64
+ expect(isAllowed("src/main.ts", "green", exclConfig)).toBe(false);
65
+ });
66
+
67
+ it("excludes spec files with a second !pattern", () => {
68
+ const multiExcl: Config = {
69
+ blockedInRed: [],
70
+ blockedInGreen: ["src/**/*.ts", "!**/*.test.ts", "!**/*.spec.ts"],
71
+ testCommands: [],
72
+ timeoutSeconds: 30,
73
+ };
74
+ expect(isAllowed("src/main.spec.ts", "green", multiExcl)).toBe(true);
75
+ expect(isAllowed("src/main.test.ts", "green", multiExcl)).toBe(true);
76
+ expect(isAllowed("src/main.ts", "green", multiExcl)).toBe(false);
77
+ });
78
+
79
+ it("excludes via complex glob in negation pattern", () => {
80
+ const complexExcl: Config = {
81
+ blockedInRed: [],
82
+ blockedInGreen: ["src/**", "!src/vendor/**"],
83
+ testCommands: [],
84
+ timeoutSeconds: 30,
85
+ };
86
+ expect(isAllowed("src/vendor/lib.js", "green", complexExcl)).toBe(true);
87
+ expect(isAllowed("src/app.ts", "green", complexExcl)).toBe(false);
88
+ expect(isAllowed("src/utils/helper.ts", "green", complexExcl)).toBe(false);
89
+ });
90
+
91
+ it("supports ! negation in blockedInRed as well", () => {
92
+ const redExcl: Config = {
93
+ blockedInRed: ["src/**/*.ts", "!src/**/*.test.ts"],
94
+ blockedInGreen: [],
95
+ testCommands: [],
96
+ timeoutSeconds: 30,
97
+ };
98
+ // test file excluded from block → allowed in red
99
+ expect(isAllowed("src/main.test.ts", "red", redExcl)).toBe(true);
100
+ // impl file still blocked
101
+ expect(isAllowed("src/main.ts", "red", redExcl)).toBe(false);
102
+ // free file unaffected
103
+ expect(isAllowed("README.md", "red", redExcl)).toBe(true);
104
+ });
105
+
106
+ it("free file with exclusions in config remains free", () => {
107
+ expect(isAllowed("README.md", "green", exclConfig)).toBe(true);
108
+ });
109
+
110
+ it("file matching !pattern alone (no positive match) is not blocked", () => {
111
+ // File matches the negation pattern but NOT the positive pattern.
112
+ // MatchPatterns returns false (no positive match), so it's not blocked.
113
+ expect(isAllowed("other/foo.test.ts", "green", exclConfig)).toBe(true);
114
+ });
115
+
116
+ it("exclusion-only patterns block nothing", () => {
117
+ const exclOnly: Config = {
118
+ blockedInRed: [],
119
+ blockedInGreen: ["!**/*.test.ts"],
120
+ testCommands: [],
121
+ timeoutSeconds: 30,
122
+ };
123
+ expect(isAllowed("any/file.ts", "green", exclOnly)).toBe(true);
124
+ expect(isAllowed("README.md", "green", exclOnly)).toBe(true);
125
+ expect(isAllowed("tests/foo.test.ts", "green", exclOnly)).toBe(true);
126
+ });
54
127
  });
55
128
  });
56
129
 
@@ -64,12 +137,12 @@ describe("disallowedFiles", () => {
64
137
  expect(disallowedFiles([], "green", testConfig)).toEqual([]);
65
138
  });
66
139
 
67
- it("filters out green files in red phase", () => {
140
+ it("filters out blockedInRed files in red phase", () => {
68
141
  const files = ["src/main.ts", "README.md", "tests/foo.test.ts"];
69
142
  expect(disallowedFiles(files, "red", testConfig)).toEqual(["src/main.ts"]);
70
143
  });
71
144
 
72
- it("filters out red files in green phase", () => {
145
+ it("filters out blockedInGreen files in green phase", () => {
73
146
  const files = ["tests/foo.test.ts", "README.md", "src/main.ts"];
74
147
  expect(disallowedFiles(files, "green", testConfig)).toEqual(["tests/foo.test.ts"]);
75
148
  });
@@ -79,11 +152,4 @@ describe("disallowedFiles", () => {
79
152
  expect(disallowedFiles(free, "red", testConfig)).toEqual([]);
80
153
  expect(disallowedFiles(free, "green", testConfig)).toEqual([]);
81
154
  });
82
-
83
- it("blocks everything when all files match the other phase", () => {
84
- const redFiles = ["tests/a.test.ts", "specs/b.spec.ts"];
85
- const greenFiles = ["src/c.ts", "lib/d.ts"];
86
- expect(disallowedFiles(redFiles, "green", testConfig)).toEqual(redFiles);
87
- expect(disallowedFiles(greenFiles, "red", testConfig)).toEqual(greenFiles);
88
- });
89
155
  });
package/engine/enforce.ts CHANGED
@@ -1,25 +1,49 @@
1
1
  import picomatch from "picomatch";
2
2
  import type { Phase, Config } from "./types.js";
3
3
 
4
+ /**
5
+ * Match a file path against a list of glob patterns with !exclusion support.
6
+ *
7
+ * Positive patterns (no ! prefix) include a file in the match.
8
+ * Negative patterns (! prefix) exclude a file from the match.
9
+ * A file matches if it matches any positive pattern AND no negative pattern.
10
+ * Empty pattern list = no match.
11
+ */
12
+ function matchPatterns(patterns: string[], filePath: string): boolean {
13
+ const positive: string[] = [];
14
+ const negative: string[] = [];
15
+
16
+ for (const p of patterns) {
17
+ if (p.startsWith("!")) {
18
+ negative.push(p.slice(1));
19
+ } else {
20
+ positive.push(p);
21
+ }
22
+ }
23
+
24
+ if (positive.length === 0) return false;
25
+
26
+ const matchesPositive = positive.some((p) => picomatch(p)(filePath));
27
+ if (!matchesPositive) return false;
28
+
29
+ const matchesNegative = negative.some((p) => picomatch(p)(filePath));
30
+ return !matchesNegative;
31
+ }
32
+
4
33
  /**
5
34
  * Check if a file path is allowed to be modified in the current phase.
6
35
  *
7
36
  * Rules:
8
37
  * - REFACTOR: everything allowed
9
- * - RED: files matching allowedRedPhaseFiles + free files (match neither set)
10
- * - GREEN: files matching allowedGreenPhaseFiles + free files
11
- * - Free files (matching neither glob set) are always allowed in all phases
38
+ * - RED: files in blockedInRed are blocked, everything else is free
39
+ * - GREEN: files in blockedInGreen are blocked, everything else is free
40
+ * - ! negation patterns exclude subsets from a block list
12
41
  */
13
42
  export function isAllowed(filePath: string, phase: Phase, config: Config): boolean {
14
43
  if (phase === "refactor") return true;
15
44
 
16
- const matchesRed = config.allowedRedPhaseFiles.some((p) => picomatch(p)(filePath));
17
- const matchesGreen = config.allowedGreenPhaseFiles.some((p) => picomatch(p)(filePath));
18
-
19
- if (phase === "red") return matchesRed || (!matchesRed && !matchesGreen);
20
- if (phase === "green") return matchesGreen || (!matchesRed && !matchesGreen);
21
-
22
- return true;
45
+ const blocked = phase === "red" ? config.blockedInRed : config.blockedInGreen;
46
+ return !matchPatterns(blocked, filePath);
23
47
  }
24
48
 
25
49
  /**
@@ -33,8 +33,8 @@ function makeRunner(passed: boolean): TestRunner {
33
33
  }
34
34
 
35
35
  const testConfig: Config = {
36
- allowedRedPhaseFiles: [],
37
- allowedGreenPhaseFiles: [],
36
+ blockedInRed: [],
37
+ blockedInGreen: [],
38
38
  testCommands: ["npm test"],
39
39
  timeoutSeconds: 30,
40
40
  };
@@ -124,8 +124,8 @@ describe("checkGate", () => {
124
124
  // ── Pure unit tests: getDisallowedChanges ────────────────────────────────────
125
125
 
126
126
  const denyConfig: Config = {
127
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
128
- allowedGreenPhaseFiles: ["src/**/*.ts"],
127
+ blockedInRed: ["src/**/*.ts"],
128
+ blockedInGreen: ["tests/**/*.test.ts"],
129
129
  testCommands: [],
130
130
  timeoutSeconds: 30,
131
131
  };
@@ -163,11 +163,10 @@ describe("getDisallowedChanges", () => {
163
163
 
164
164
  it("returns disallowed files in red phase", () => {
165
165
  mockChangesSinceSnapshot.mockReturnValue(["src/main.ts", "tests/foo.test.ts", "README.md"]);
166
- const matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
167
- const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
166
+ const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
168
167
  mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
169
168
  if (phase === "red") {
170
- return changed.filter((f: string) => !matchesRed(f) && matchesGreen(f));
169
+ return changed.filter((f: string) => matchBlockedInRed(f));
171
170
  }
172
171
  return [];
173
172
  });
@@ -179,11 +178,10 @@ describe("getDisallowedChanges", () => {
179
178
 
180
179
  it("returns disallowed files in green phase", () => {
181
180
  mockChangesSinceSnapshot.mockReturnValue(["tests/foo.test.ts", "src/main.ts", "package.json"]);
182
- const matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
183
- const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
181
+ const matchBlockedInGreen = picomatch(denyConfig.blockedInGreen);
184
182
  mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
185
183
  if (phase === "green") {
186
- return changed.filter((f: string) => matchesRed(f) && !matchesGreen(f));
184
+ return changed.filter((f: string) => matchBlockedInGreen(f));
187
185
  }
188
186
  return [];
189
187
  });
@@ -195,11 +193,10 @@ describe("getDisallowedChanges", () => {
195
193
 
196
194
  it("catches untracked files, not just modified", () => {
197
195
  mockChangesSinceSnapshot.mockReturnValue(["src/new.ts"]);
198
- const matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
199
- const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
196
+ const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
200
197
  mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
201
198
  if (phase === "red") {
202
- return changed.filter((f: string) => !matchesRed(f) && matchesGreen(f));
199
+ return changed.filter((f: string) => matchBlockedInRed(f));
203
200
  }
204
201
  return [];
205
202
  });
package/engine/types.ts CHANGED
@@ -6,8 +6,8 @@ export interface PhaseState {
6
6
  }
7
7
 
8
8
  export interface Config {
9
- allowedRedPhaseFiles: string[];
10
- allowedGreenPhaseFiles: string[];
9
+ blockedInRed: string[];
10
+ blockedInGreen: string[];
11
11
  testCommands: string[];
12
12
  timeoutSeconds: number;
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -28,19 +28,20 @@ It locks files per phase — only test files in RED, only implementation files i
28
28
 
29
29
  ## Setup
30
30
 
31
- 1. **Agent** creates `.pi/tdd/rules.json` with these fields:
31
+ 1. **Agent** checks if the repo has a test framework set up. If it doesn't, stop and ask the user what they want. Do not make assumptions, pick defaults, or proceed without their explicit decision. Then create `.pi/tdd/rules.json` with these fields:
32
32
 
33
33
  ```json
34
34
  {
35
- "allowedRedPhaseFiles": ["tests/**/*.test.ts"],
36
- "allowedGreenPhaseFiles": ["src/**/*.ts"],
37
- "testCommands": ["npm test"],
35
+ "blockedInRed": ["src/**/*.ts", "lib/**/*.ts", "!src/**/*.test.ts"],
36
+ "blockedInGreen": ["**/*.test.ts"],
37
+ "testCommands": ["npm test"],
38
38
  "timeoutSeconds": 30
39
39
  }
40
40
  ```
41
41
 
42
- - `allowedRedPhaseFiles` — globs the agent can create/modify in RED phase (tests)
43
- - `allowedGreenPhaseFiles` — globs the agent can create/modify in GREEN phase (implementation)
42
+ - `blockedInRed` — globs the agent **cannot** modify in RED phase (implementation files)
43
+ - `blockedInGreen` — globs the agent **cannot** modify in GREEN phase (test files)
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
44
45
  - `testCommands` — shell commands to run tests
45
46
  - `timeoutSeconds` — test timeout per command
46
47
 
@@ -51,31 +52,31 @@ It locks files per phase — only test files in RED, only implementation files i
51
52
  ## Phase Rules
52
53
 
53
54
  ### RED
54
- Write failing tests matching `allowedRedPhaseFiles` patterns. Files matching `allowedGreenPhaseFiles` are locked. Files matching neither set are always free. Call `next_tdd_phase` once tests fail.
55
+ Files matching `blockedInRed` are locked everything else is free.
55
56
 
56
- ### GREEN
57
- Implement features matching `allowedGreenPhaseFiles` patterns. Test files from `allowedRedPhaseFiles` are locked. Files matching neither set are always free. Call `next_tdd_phase` once tests pass.
57
+ Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap and safe if assumptions turn out wrong.
58
58
 
59
- ### REFACTOR
60
- All files are free to modify. Refactor without changing behaviour. Call `next_tdd_phase` once tests pass to start a new RED cycle.
59
+ Call `next_tdd_phase` once tests fail.
61
60
 
62
- ---
61
+ ### GREEN
62
+ Files matching `blockedInGreen` are locked — everything else is free.
63
63
 
64
- ## Hard Rules
64
+ Write the simplest code that makes the failing tests pass — nothing more. The tests are your spec; if they pass, you're done.
65
65
 
66
- - **Never bypass TDD.** If TDD blocks a change, it's because you're in the wrong phase or the file isn't allowed in this phase. The solution is always to work within the rules, not around them.
66
+ If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them before implementing. All current changes are lost, but that's better since the current changes was building on false assumptions. Don't be afraid to discard clean slate beats patched code.
67
67
 
68
- - Need to change a locked file? Either advance through the cycle (`next_tdd_phase`) or roll back to fix earlier work (`previous_tdd_phase`).
69
- - Wrong assumptions about the task? Roll back with `previous_tdd_phase`, the phase restarts clean.
70
- - Fundamentally blocked? Ask the user to run the appropriate `/tdd:` command (change phase, disable, or reset).
68
+ Call `next_tdd_phase` once all tests pass.
71
69
 
72
- - **Never write to `.pi/tdd/`.** The extension owns that directory — writes are blocked and bash changes are reverted. Any change you make there is ignored or overwritten.
70
+ ### REFACTOR
71
+ All files are free to modify. Refactor without changing behaviour.
72
+ Call `next_tdd_phase` once tests pass to start a new RED cycle.
73
73
 
74
- - **Never run `/tdd:` commands yourself.** They're registered as user-only commands. They won't work when you type them.
74
+ ---
75
75
 
76
- - **Don't be afraid to discard.** If the previous phase work was wrong, all current-phase changes are built on false assumptions. Prefer a clean slate — call `previous_tdd_phase` and redo it properly.
76
+ ## Hard Rules
77
77
 
78
- - **Keep cycles small but tests comprehensive.** Write tests for one feature at a time. Cover happy path, edge cases, and unhappy paths before moving to GREEN. Small cycles mean less to lose if assumptions turn out wrong. Reverting becomes cheap and safe.
78
+ - **Never write to `.pi/tdd/`.** The extension owns that directory writes are blocked and bash changes are reverted.
79
+ - **Never run `/tdd:` commands yourself.** They're registered as user-only commands and won't work when you type them.
79
80
 
80
81
  ---
81
82
 
@@ -93,7 +94,7 @@ Also validates no locked files were modified. On success, records the current st
93
94
  Use when the previous phase's work was wrong and the current phase cannot proceed because of it. Rolls back to the previous phase so that work can be redone correctly. All changes made in the current phase are lost.
94
95
 
95
96
  ### `tdd_status`
96
- Shows the current phase, allowed file globs, and test commands.
97
+ Shows the current phase, blocked file globs per phase, and test commands.
97
98
 
98
99
  ---
99
100