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.
- package/adapters/pi/helpers.test.ts +220 -162
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +35 -0
- package/adapters/pi/prompts.ts +13 -12
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +322 -219
- package/behaviour.md +26 -11
- package/engine/config.test.ts +21 -21
- package/engine/config.ts +6 -6
- package/engine/enforce.test.ts +93 -27
- package/engine/enforce.ts +34 -10
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +63 -61
- package/engine/transition.ts +9 -2
- package/engine/types.ts +2 -2
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +22 -21
- package/tsconfig.json +12 -0
package/engine/config.test.ts
CHANGED
|
@@ -28,8 +28,8 @@ describe("loadConfig", () => {
|
|
|
28
28
|
writeFileSync(
|
|
29
29
|
join(tddDir, "rules.json"),
|
|
30
30
|
JSON.stringify({
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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.
|
|
17
|
-
throw new Error("rules.json:
|
|
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.
|
|
20
|
-
throw new Error("rules.json:
|
|
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
|
-
|
|
28
|
-
|
|
27
|
+
blockedInRed: parsed.blockedInRed,
|
|
28
|
+
blockedInGreen: parsed.blockedInGreen,
|
|
29
29
|
testCommands: parsed.testCommands,
|
|
30
30
|
timeoutSeconds: parsed.timeoutSeconds ?? 120,
|
|
31
31
|
};
|
package/engine/enforce.test.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
20
|
-
expect(isAllowed("
|
|
21
|
-
expect(isAllowed("
|
|
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
|
|
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
|
|
30
|
-
expect(isAllowed("
|
|
31
|
-
expect(isAllowed("
|
|
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
|
|
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
|
|
42
|
-
expect(isAllowed("tests/
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
|
10
|
-
* - GREEN: files
|
|
11
|
-
* -
|
|
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
|
|
17
|
-
|
|
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
|
/**
|