tdd-enforcer 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/adapters/pi/helpers.test.ts +246 -244
- package/adapters/pi/helpers.ts +117 -91
- package/adapters/pi/hooks.test.ts +528 -425
- package/adapters/pi/hooks.ts +297 -230
- package/adapters/pi/index.test.ts +282 -272
- package/adapters/pi/index.ts +222 -200
- package/adapters/pi/log.test.ts +91 -91
- package/adapters/pi/log.ts +39 -27
- package/adapters/pi/prompts.test.ts +25 -25
- package/adapters/pi/prompts.ts +28 -30
- package/adapters/pi/tools.test.ts +391 -331
- package/adapters/pi/tools.ts +368 -325
- package/behaviour.md +15 -1
- package/biome.json +37 -0
- package/engine/config.test.ts +157 -157
- package/engine/config.ts +22 -19
- package/engine/enforce.test.ts +155 -145
- package/engine/enforce.ts +31 -23
- package/engine/git.test.ts +529 -507
- package/engine/git.ts +240 -114
- package/engine/index.ts +27 -4
- package/engine/state.test.ts +69 -69
- package/engine/state.ts +22 -20
- package/engine/transition.test.ts +229 -177
- package/engine/transition.ts +55 -52
- package/engine/types.ts +9 -9
- package/package.json +29 -22
- package/skills/tdd-enforcer/SKILL.md +2 -2
- package/tsconfig.json +10 -10
package/engine/state.ts
CHANGED
|
@@ -1,38 +1,40 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
import type { PhaseState } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const TDD_DIR = ".pi/tdd";
|
|
6
6
|
const VALID_PHASES = new Set(["red", "green", "refactor"]);
|
|
7
7
|
|
|
8
8
|
export function phaseStatePath(projectRoot: string): string {
|
|
9
|
-
|
|
9
|
+
return join(projectRoot, TDD_DIR, "state.json");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function ensureDir(path: string): void {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const dir = dirname(path);
|
|
14
|
+
if (!existsSync(dir)) {
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function loadPhaseState(projectRoot: string): PhaseState {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const path = phaseStatePath(projectRoot);
|
|
21
|
+
const raw = readFileSync(path, "utf-8");
|
|
22
|
+
const parsed = JSON.parse(raw) as PhaseState;
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
if (typeof parsed.current !== "string" || !VALID_PHASES.has(parsed.current)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`state.json: invalid phase "${String(parsed.current)}". Must be red, green, or refactor.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
return {
|
|
31
|
+
enabled: parsed.enabled === true,
|
|
32
|
+
current: parsed.current,
|
|
33
|
+
};
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function savePhaseState(projectRoot: string, state: PhaseState): void {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const path = phaseStatePath(projectRoot);
|
|
38
|
+
ensureDir(path);
|
|
39
|
+
writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
|
|
38
40
|
}
|
|
@@ -1,206 +1,258 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
-
import { getDisallowedChanges, nextPhase, checkGate } from "./transition.js";
|
|
3
|
-
import type { Config, TestRunner } from "./types.js";
|
|
4
1
|
import picomatch from "picomatch";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { checkGate, getDisallowedChanges, nextPhase } from "./transition.js";
|
|
4
|
+
import type { Config, TestRunner } from "./types.js";
|
|
5
5
|
|
|
6
6
|
// ── Pure unit tests: nextPhase ──────────────────────────────────────────────
|
|
7
7
|
|
|
8
8
|
describe("nextPhase", () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
it("returns green from red", () => {
|
|
10
|
+
expect(nextPhase("red")).toBe("green");
|
|
11
|
+
});
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
it("returns refactor from green", () => {
|
|
14
|
+
expect(nextPhase("green")).toBe("refactor");
|
|
15
|
+
});
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
it("returns red from refactor", () => {
|
|
18
|
+
expect(nextPhase("refactor")).toBe("red");
|
|
19
|
+
});
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
it("returns null for unknown phase", () => {
|
|
22
|
+
expect(nextPhase("blurple" as any)).toBeNull();
|
|
23
|
+
});
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
// ── Pure unit tests: checkGate ──────────────────────────────────────────────
|
|
27
27
|
|
|
28
28
|
function makeRunner(passed: boolean): TestRunner {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
return async (_cmds, _timeout) => ({
|
|
30
|
+
passed,
|
|
31
|
+
message: passed ? "all ok" : "tests failed",
|
|
32
|
+
});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const testConfig: Config = {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
blockedInRed: [],
|
|
37
|
+
blockedInGreen: [],
|
|
38
|
+
testCommands: ["npm test"],
|
|
39
|
+
timeoutSeconds: 30,
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
describe("checkGate", () => {
|
|
43
|
+
describe("red → green (tests must fail)", () => {
|
|
44
|
+
it("allows when tests fail", async () => {
|
|
45
|
+
const r = await checkGate("red", "green", makeRunner(false), testConfig);
|
|
46
|
+
expect(r.passed).toBe(true);
|
|
47
|
+
expect(r.message).toMatch(/proceed|fail/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("blocks when tests pass", async () => {
|
|
51
|
+
const r = await checkGate("red", "green", makeRunner(true), testConfig);
|
|
52
|
+
expect(r.passed).toBe(false);
|
|
53
|
+
expect(r.message).toMatch(/transitioning to GREEN/i);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("green → refactor (tests must pass)", () => {
|
|
58
|
+
it("allows when tests pass", async () => {
|
|
59
|
+
const r = await checkGate(
|
|
60
|
+
"green",
|
|
61
|
+
"refactor",
|
|
62
|
+
makeRunner(true),
|
|
63
|
+
testConfig,
|
|
64
|
+
);
|
|
65
|
+
expect(r.passed).toBe(true);
|
|
66
|
+
expect(r.message).toMatch(/pass/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("blocks when tests fail", async () => {
|
|
70
|
+
const r = await checkGate(
|
|
71
|
+
"green",
|
|
72
|
+
"refactor",
|
|
73
|
+
makeRunner(false),
|
|
74
|
+
testConfig,
|
|
75
|
+
);
|
|
76
|
+
expect(r.passed).toBe(false);
|
|
77
|
+
expect(r.message).toMatch(/transitioning to REFACTOR/i);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("refactor → red (tests must pass)", () => {
|
|
82
|
+
it("allows when tests pass", async () => {
|
|
83
|
+
const r = await checkGate(
|
|
84
|
+
"refactor",
|
|
85
|
+
"red",
|
|
86
|
+
makeRunner(true),
|
|
87
|
+
testConfig,
|
|
88
|
+
);
|
|
89
|
+
expect(r.passed).toBe(true);
|
|
90
|
+
expect(r.message).toMatch(/pass/i);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("blocks when tests fail", async () => {
|
|
94
|
+
const r = await checkGate(
|
|
95
|
+
"refactor",
|
|
96
|
+
"red",
|
|
97
|
+
makeRunner(false),
|
|
98
|
+
testConfig,
|
|
99
|
+
);
|
|
100
|
+
expect(r.passed).toBe(false);
|
|
101
|
+
expect(r.message).toMatch(/transitioning to RED/i);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("passes test commands to the runner", async () => {
|
|
106
|
+
let captured: string[] | undefined;
|
|
107
|
+
const runner: TestRunner = async (cmds) => {
|
|
108
|
+
captured = cmds;
|
|
109
|
+
return { passed: true, message: "" };
|
|
110
|
+
};
|
|
111
|
+
await checkGate("red", "green", runner, testConfig);
|
|
112
|
+
expect(captured).toEqual(["npm test"]);
|
|
113
|
+
});
|
|
43
114
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("refactor → red (tests must pass)", () => {
|
|
73
|
-
it("allows when tests pass", async () => {
|
|
74
|
-
const r = await checkGate("refactor", "red", makeRunner(true), testConfig);
|
|
75
|
-
expect(r.passed).toBe(true);
|
|
76
|
-
expect(r.message).toMatch(/pass/i);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("blocks when tests fail", async () => {
|
|
80
|
-
const r = await checkGate("refactor", "red", makeRunner(false), testConfig);
|
|
81
|
-
expect(r.passed).toBe(false);
|
|
82
|
-
expect(r.message).toMatch(/transitioning to RED/i);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
it("passes test commands to the runner", async () => {
|
|
88
|
-
let captured: string[] | undefined;
|
|
89
|
-
const runner: TestRunner = async (cmds) => {
|
|
90
|
-
captured = cmds;
|
|
91
|
-
return { passed: true, message: "" };
|
|
92
|
-
};
|
|
93
|
-
await checkGate("red", "green", runner, testConfig);
|
|
94
|
-
expect(captured).toEqual(["npm test"]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("passes timeoutSeconds to the runner", async () => {
|
|
98
|
-
let captured: number | undefined;
|
|
99
|
-
const runner: TestRunner = async (_cmds, t) => {
|
|
100
|
-
captured = t;
|
|
101
|
-
return { passed: true, message: "" };
|
|
102
|
-
};
|
|
103
|
-
await checkGate("red", "green", runner, testConfig);
|
|
104
|
-
expect(captured).toBe(30);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("passes multiple test commands to the runner", async () => {
|
|
108
|
-
const multiConfig: Config = {
|
|
109
|
-
...testConfig,
|
|
110
|
-
testCommands: ["npm run test:unit", "npm run test:integration"],
|
|
111
|
-
};
|
|
112
|
-
let captured: string[] | undefined;
|
|
113
|
-
const runner: TestRunner = async (cmds) => {
|
|
114
|
-
captured = cmds;
|
|
115
|
-
return { passed: true, message: "" };
|
|
116
|
-
};
|
|
117
|
-
await checkGate("red", "green", runner, multiConfig);
|
|
118
|
-
expect(captured).toHaveLength(2);
|
|
119
|
-
expect(captured).toContain("npm run test:unit");
|
|
120
|
-
expect(captured).toContain("npm run test:integration");
|
|
121
|
-
});
|
|
115
|
+
it("passes timeoutSeconds to the runner", async () => {
|
|
116
|
+
let captured: number | undefined;
|
|
117
|
+
const runner: TestRunner = async (_cmds, t) => {
|
|
118
|
+
captured = t;
|
|
119
|
+
return { passed: true, message: "" };
|
|
120
|
+
};
|
|
121
|
+
await checkGate("red", "green", runner, testConfig);
|
|
122
|
+
expect(captured).toBe(30);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("passes multiple test commands to the runner", async () => {
|
|
126
|
+
const multiConfig: Config = {
|
|
127
|
+
...testConfig,
|
|
128
|
+
testCommands: ["npm run test:unit", "npm run test:integration"],
|
|
129
|
+
};
|
|
130
|
+
let captured: string[] | undefined;
|
|
131
|
+
const runner: TestRunner = async (cmds) => {
|
|
132
|
+
captured = cmds;
|
|
133
|
+
return { passed: true, message: "" };
|
|
134
|
+
};
|
|
135
|
+
await checkGate("red", "green", runner, multiConfig);
|
|
136
|
+
expect(captured).toHaveLength(2);
|
|
137
|
+
expect(captured).toContain("npm run test:unit");
|
|
138
|
+
expect(captured).toContain("npm run test:integration");
|
|
139
|
+
});
|
|
122
140
|
});
|
|
123
141
|
|
|
124
142
|
// ── Pure unit tests: getDisallowedChanges ────────────────────────────────────
|
|
125
143
|
|
|
126
144
|
const denyConfig: Config = {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
blockedInRed: ["src/**/*.ts"],
|
|
146
|
+
blockedInGreen: ["tests/**/*.test.ts"],
|
|
147
|
+
testCommands: [],
|
|
148
|
+
timeoutSeconds: 30,
|
|
131
149
|
};
|
|
132
150
|
|
|
133
151
|
describe("getDisallowedChanges", () => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
152
|
+
let mockChangesSinceSnapshot: ReturnType<typeof vi.fn>;
|
|
153
|
+
let mockDisallowedFiles: ReturnType<typeof vi.fn>;
|
|
154
|
+
|
|
155
|
+
function makeDeps(overrides = {}) {
|
|
156
|
+
return {
|
|
157
|
+
changesSinceSnapshot: mockChangesSinceSnapshot,
|
|
158
|
+
disallowedFiles: mockDisallowedFiles,
|
|
159
|
+
...overrides,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
vi.clearAllMocks();
|
|
165
|
+
mockChangesSinceSnapshot = vi.fn().mockReturnValue([]);
|
|
166
|
+
mockDisallowedFiles = vi.fn().mockReturnValue([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns empty for refactor phase regardless of git state", () => {
|
|
170
|
+
const result = getDisallowedChanges(
|
|
171
|
+
"/any",
|
|
172
|
+
"refactor",
|
|
173
|
+
denyConfig,
|
|
174
|
+
makeDeps(),
|
|
175
|
+
);
|
|
176
|
+
expect(result).toEqual([]);
|
|
177
|
+
expect(mockChangesSinceSnapshot).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns empty when no files changed", () => {
|
|
181
|
+
mockChangesSinceSnapshot.mockReturnValue([]);
|
|
182
|
+
const result = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
183
|
+
expect(result).toEqual([]);
|
|
184
|
+
expect(mockChangesSinceSnapshot).toHaveBeenCalledWith("/test");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("returns disallowed files in red phase", () => {
|
|
188
|
+
mockChangesSinceSnapshot.mockReturnValue([
|
|
189
|
+
"src/main.ts",
|
|
190
|
+
"tests/foo.test.ts",
|
|
191
|
+
"README.md",
|
|
192
|
+
]);
|
|
193
|
+
const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
|
|
194
|
+
mockDisallowedFiles.mockImplementation(
|
|
195
|
+
(changed: string[], phase: string) => {
|
|
196
|
+
if (phase === "red") {
|
|
197
|
+
return changed.filter((f: string) => matchBlockedInRed(f));
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
const violations = getDisallowedChanges(
|
|
203
|
+
"/test",
|
|
204
|
+
"red",
|
|
205
|
+
denyConfig,
|
|
206
|
+
makeDeps(),
|
|
207
|
+
);
|
|
208
|
+
expect(violations).toContain("src/main.ts");
|
|
209
|
+
expect(violations).not.toContain("tests/foo.test.ts");
|
|
210
|
+
expect(violations).not.toContain("README.md");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("returns disallowed files in green phase", () => {
|
|
214
|
+
mockChangesSinceSnapshot.mockReturnValue([
|
|
215
|
+
"tests/foo.test.ts",
|
|
216
|
+
"src/main.ts",
|
|
217
|
+
"package.json",
|
|
218
|
+
]);
|
|
219
|
+
const matchBlockedInGreen = picomatch(denyConfig.blockedInGreen);
|
|
220
|
+
mockDisallowedFiles.mockImplementation(
|
|
221
|
+
(changed: string[], phase: string) => {
|
|
222
|
+
if (phase === "green") {
|
|
223
|
+
return changed.filter((f: string) => matchBlockedInGreen(f));
|
|
224
|
+
}
|
|
225
|
+
return [];
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
const violations = getDisallowedChanges(
|
|
229
|
+
"/test",
|
|
230
|
+
"green",
|
|
231
|
+
denyConfig,
|
|
232
|
+
makeDeps(),
|
|
233
|
+
);
|
|
234
|
+
expect(violations).toContain("tests/foo.test.ts");
|
|
235
|
+
expect(violations).not.toContain("src/main.ts");
|
|
236
|
+
expect(violations).not.toContain("package.json");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("catches untracked files, not just modified", () => {
|
|
240
|
+
mockChangesSinceSnapshot.mockReturnValue(["src/new.ts"]);
|
|
241
|
+
const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
|
|
242
|
+
mockDisallowedFiles.mockImplementation(
|
|
243
|
+
(changed: string[], phase: string) => {
|
|
244
|
+
if (phase === "red") {
|
|
245
|
+
return changed.filter((f: string) => matchBlockedInRed(f));
|
|
246
|
+
}
|
|
247
|
+
return [];
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
const violations = getDisallowedChanges(
|
|
251
|
+
"/test",
|
|
252
|
+
"red",
|
|
253
|
+
denyConfig,
|
|
254
|
+
makeDeps(),
|
|
255
|
+
);
|
|
256
|
+
expect(violations).toContain("src/new.ts");
|
|
257
|
+
});
|
|
206
258
|
});
|
package/engine/transition.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
import type { Phase, Config, Transition } from "./types.js";
|
|
2
|
-
import { PHASE_CYCLE } from "./types.js";
|
|
3
|
-
import { changesSinceSnapshot } from "./git.js";
|
|
4
1
|
import { disallowedFiles } from "./enforce.js";
|
|
2
|
+
import { changesSinceSnapshot } from "./git.js";
|
|
3
|
+
import type { Config, Phase, Transition } from "./types.js";
|
|
4
|
+
import { PHASE_CYCLE } from "./types.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Get the next phase in the cycle.
|
|
8
8
|
*/
|
|
9
9
|
export function nextPhase(current: Phase): Phase | null {
|
|
10
|
-
|
|
10
|
+
return PHASE_CYCLE[current] ?? null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface GateResult {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
passed: boolean;
|
|
15
|
+
message: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export type TestRunner = (
|
|
18
|
+
export type TestRunner = (
|
|
19
|
+
commands: string[],
|
|
20
|
+
timeoutSeconds: number,
|
|
21
|
+
) => Promise<GateResult>;
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Run the transition gate check.
|
|
@@ -24,42 +27,42 @@ export type TestRunner = (commands: string[], timeoutSeconds: number) => Promise
|
|
|
24
27
|
* - REFACTOR→RED: tests must pass (all zero exit)
|
|
25
28
|
*/
|
|
26
29
|
export async function checkGate(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
from: Phase,
|
|
31
|
+
to: Phase,
|
|
32
|
+
testRunner: TestRunner,
|
|
33
|
+
config: Config,
|
|
31
34
|
): Promise<GateResult> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
switch (`${from}→${to}` as Transition) {
|
|
35
|
-
case "red→green":
|
|
36
|
-
if (result.passed) {
|
|
37
|
-
return {
|
|
38
|
-
passed: false,
|
|
39
|
-
message: "Tests passed. Add a failing test before transitioning to GREEN.",
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
return { passed: true, message: "Tests fail — proceed to GREEN." };
|
|
35
|
+
const result = await testRunner(config.testCommands, config.timeoutSeconds);
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
switch (`${from}→${to}` as Transition) {
|
|
38
|
+
case "red→green":
|
|
39
|
+
if (result.passed) {
|
|
40
|
+
return {
|
|
41
|
+
passed: false,
|
|
42
|
+
message:
|
|
43
|
+
"Tests passed. Add a failing test before transitioning to GREEN.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { passed: true, message: "Tests fail — proceed to GREEN." };
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
case "green→refactor":
|
|
49
|
+
if (!result.passed) {
|
|
50
|
+
return {
|
|
51
|
+
passed: false,
|
|
52
|
+
message: "Tests failed. Fix them before transitioning to REFACTOR.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { passed: true, message: "All tests pass — proceeding." };
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
case "refactor→red":
|
|
58
|
+
if (!result.passed) {
|
|
59
|
+
return {
|
|
60
|
+
passed: false,
|
|
61
|
+
message: "Tests failed. Fix them before transitioning to RED.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { passed: true, message: "All tests pass — proceeding." };
|
|
65
|
+
}
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -67,19 +70,19 @@ export async function checkGate(
|
|
|
67
70
|
* Returns list of violating files (empty = ok).
|
|
68
71
|
*/
|
|
69
72
|
export function getDisallowedChanges(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
projectRoot: string,
|
|
74
|
+
phase: Phase,
|
|
75
|
+
config: Config,
|
|
76
|
+
deps: {
|
|
77
|
+
changesSinceSnapshot: typeof changesSinceSnapshot;
|
|
78
|
+
disallowedFiles: typeof disallowedFiles;
|
|
79
|
+
} = {
|
|
80
|
+
changesSinceSnapshot,
|
|
81
|
+
disallowedFiles,
|
|
82
|
+
},
|
|
80
83
|
): string[] {
|
|
81
|
-
|
|
84
|
+
if (phase === "refactor") return [];
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
const changed = deps.changesSinceSnapshot(projectRoot);
|
|
87
|
+
return deps.disallowedFiles(changed, phase, config);
|
|
85
88
|
}
|
package/engine/types.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
export type Phase = "red" | "green" | "refactor";
|
|
2
2
|
|
|
3
3
|
export interface PhaseState {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
current: Phase;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export interface Config {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
blockedInRed: string[];
|
|
10
|
+
blockedInGreen: string[];
|
|
11
|
+
testCommands: string[];
|
|
12
|
+
timeoutSeconds: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export type Transition = "red→green" | "green→refactor" | "refactor→red";
|
|
16
16
|
|
|
17
17
|
export const PHASE_CYCLE: Record<Phase, Phase | null> = {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
red: "green",
|
|
19
|
+
green: "refactor",
|
|
20
|
+
refactor: "red",
|
|
21
21
|
};
|