tdd-enforcer 0.2.3 → 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 +3 -3
- package/adapters/pi/hooks.test.ts +2 -2
- package/adapters/pi/index.test.ts +2 -2
- package/adapters/pi/index.ts +4 -4
- package/adapters/pi/prompts.test.ts +6 -14
- package/adapters/pi/prompts.ts +13 -12
- package/adapters/pi/tools.test.ts +2 -2
- package/adapters/pi/tools.ts +7 -7
- 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/transition.test.ts +10 -13
- package/engine/types.ts +2 -2
- package/package.json +1 -1
- package/skills/tdd-enforcer/SKILL.md +22 -21
|
@@ -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
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
6
|
-
|
|
5
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
6
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
7
7
|
testCommands: ["npm test"],
|
|
8
8
|
timeoutSeconds: 30,
|
|
9
9
|
};
|
package/adapters/pi/index.ts
CHANGED
|
@@ -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
|
|
123
|
-
const
|
|
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
|
-
`
|
|
135
|
-
`
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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("
|
|
30
|
-
expect(result).toContain("src/**/*.ts");
|
|
22
|
+
expect(result).toContain("Blocked files: src/**/*.ts");
|
|
31
23
|
});
|
|
32
24
|
|
|
33
25
|
it("returns REFACTOR prompt", () => {
|
package/adapters/pi/prompts.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import type { Phase, Config } from "../../engine/types.js";
|
|
2
2
|
|
|
3
|
-
export function getNudgePrompt(phase: Phase, config: Config
|
|
4
|
-
const
|
|
5
|
-
const
|
|
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
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"
|
|
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.
|
|
18
|
-
`
|
|
19
|
-
"Call `next_tdd_phase` to proceed to REFACTOR.\n" +
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
-
|
|
9
|
-
|
|
8
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
9
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
10
10
|
testCommands: ["npm test"],
|
|
11
11
|
timeoutSeconds: 30,
|
|
12
12
|
};
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -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
|
|
307
|
-
const
|
|
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
|
-
`
|
|
322
|
-
`
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
"
|
|
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);
|
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
|
/**
|
|
@@ -33,8 +33,8 @@ function makeRunner(passed: boolean): TestRunner {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const testConfig: Config = {
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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
|
|
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) =>
|
|
199
|
+
return changed.filter((f: string) => matchBlockedInRed(f));
|
|
203
200
|
}
|
|
204
201
|
return [];
|
|
205
202
|
});
|
package/engine/types.ts
CHANGED
package/package.json
CHANGED
|
@@ -32,15 +32,16 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"testCommands":
|
|
35
|
+
"blockedInRed": ["src/**/*.ts", "lib/**/*.ts", "!src/**/*.test.ts"],
|
|
36
|
+
"blockedInGreen": ["**/*.test.ts", "**/*.spec.ts"],
|
|
37
|
+
"testCommands": ["npm test"],
|
|
38
38
|
"timeoutSeconds": 30
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
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
|
-
|
|
55
|
+
Files matching `blockedInRed` are locked — everything else is free.
|
|
55
56
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
---
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
## Hard Rules
|
|
77
77
|
|
|
78
|
-
- **
|
|
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,
|
|
97
|
+
Shows the current phase, blocked file globs per phase, and test commands.
|
|
97
98
|
|
|
98
99
|
---
|
|
99
100
|
|