tdd-enforcer 0.2.2 → 0.2.5

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.
@@ -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
  /**