tdd-enforcer 0.1.0
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/DESIGN.md +184 -0
- package/adapters/pi/helpers.test.ts +203 -0
- package/adapters/pi/helpers.ts +60 -0
- package/adapters/pi/hooks.ts +59 -0
- package/adapters/pi/index.ts +122 -0
- package/adapters/pi/prompts.ts +64 -0
- package/adapters/pi/tools.ts +165 -0
- package/engine/config.test.ts +125 -0
- package/engine/config.ts +32 -0
- package/engine/enforce.test.ts +89 -0
- package/engine/enforce.ts +31 -0
- package/engine/git.test.ts +271 -0
- package/engine/git.ts +98 -0
- package/engine/index.ts +6 -0
- package/engine/state.test.ts +84 -0
- package/engine/state.ts +38 -0
- package/engine/transition.test.ts +221 -0
- package/engine/transition.ts +76 -0
- package/engine/types.ts +21 -0
- package/package.json +15 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
|
|
6
|
+
import type { Config, TestRunner } from "./types.js";
|
|
7
|
+
import { initGit, snapshot } from "./git.js";
|
|
8
|
+
|
|
9
|
+
// ── Pure unit tests: nextPhase ──────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("nextPhase", () => {
|
|
12
|
+
it("returns green from red", () => {
|
|
13
|
+
expect(nextPhase("red")).toBe("green");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns refactor from green", () => {
|
|
17
|
+
expect(nextPhase("green")).toBe("refactor");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns red from refactor", () => {
|
|
21
|
+
expect(nextPhase("refactor")).toBe("red");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns null for unknown phase", () => {
|
|
25
|
+
expect(nextPhase("blurple" as any)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// ── Pure unit tests: checkGate ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function makeRunner(passed: boolean): TestRunner {
|
|
32
|
+
return async (_cmds, _timeout) => ({
|
|
33
|
+
passed,
|
|
34
|
+
message: passed ? "all ok" : "tests failed",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const testConfig: Config = {
|
|
39
|
+
allowedRedPhaseFiles: [],
|
|
40
|
+
allowedGreenPhaseFiles: [],
|
|
41
|
+
testCommands: ["npm test"],
|
|
42
|
+
timeoutSeconds: 30,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const emptyConfig: Config = { ...testConfig, testCommands: [] };
|
|
46
|
+
|
|
47
|
+
describe("checkGate", () => {
|
|
48
|
+
describe("empty test commands (gate skipped)", () => {
|
|
49
|
+
it("passes every transition when no test commands configured", async () => {
|
|
50
|
+
const r1 = await checkGate("red", "green", makeRunner(false), emptyConfig);
|
|
51
|
+
expect(r1.passed).toBe(true);
|
|
52
|
+
const r2 = await checkGate("red", "green", makeRunner(true), emptyConfig);
|
|
53
|
+
expect(r2.passed).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("red → green (tests must fail)", () => {
|
|
58
|
+
it("allows when tests fail", async () => {
|
|
59
|
+
const r = await checkGate("red", "green", makeRunner(false), testConfig);
|
|
60
|
+
expect(r.passed).toBe(true);
|
|
61
|
+
expect(r.message).toMatch(/proceed|fail/i);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("blocks when tests pass", async () => {
|
|
65
|
+
const r = await checkGate("red", "green", makeRunner(true), testConfig);
|
|
66
|
+
expect(r.passed).toBe(false);
|
|
67
|
+
expect(r.message).toMatch(/break a test/i);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("green → refactor (tests must pass)", () => {
|
|
72
|
+
it("allows when tests pass", async () => {
|
|
73
|
+
const r = await checkGate("green", "refactor", makeRunner(true), testConfig);
|
|
74
|
+
expect(r.passed).toBe(true);
|
|
75
|
+
expect(r.message).toMatch(/pass/i);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("blocks when tests fail", async () => {
|
|
79
|
+
const r = await checkGate("green", "refactor", makeRunner(false), testConfig);
|
|
80
|
+
expect(r.passed).toBe(false);
|
|
81
|
+
expect(r.message).toMatch(/failing/i);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("refactor → red (tests must pass)", () => {
|
|
86
|
+
it("allows when tests pass", async () => {
|
|
87
|
+
const r = await checkGate("refactor", "red", makeRunner(true), testConfig);
|
|
88
|
+
expect(r.passed).toBe(true);
|
|
89
|
+
expect(r.message).toMatch(/pass/i);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("blocks when tests fail", async () => {
|
|
93
|
+
const r = await checkGate("refactor", "red", makeRunner(false), testConfig);
|
|
94
|
+
expect(r.passed).toBe(false);
|
|
95
|
+
expect(r.message).toMatch(/failing/i);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("unknown transition", () => {
|
|
100
|
+
it("blocks with message containing the transition string", async () => {
|
|
101
|
+
const r = await checkGate("green", "red" as any, makeRunner(true), testConfig);
|
|
102
|
+
expect(r.passed).toBe(false);
|
|
103
|
+
expect(r.message).toContain("green→red");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("passes test commands to the runner", async () => {
|
|
108
|
+
let captured: string[] | undefined;
|
|
109
|
+
const runner: TestRunner = async (cmds) => {
|
|
110
|
+
captured = cmds;
|
|
111
|
+
return { passed: true, message: "" };
|
|
112
|
+
};
|
|
113
|
+
await checkGate("red", "green", runner, testConfig);
|
|
114
|
+
expect(captured).toEqual(["npm test"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("passes timeoutSeconds to the runner", async () => {
|
|
118
|
+
let captured: number | undefined;
|
|
119
|
+
const runner: TestRunner = async (_cmds, t) => {
|
|
120
|
+
captured = t;
|
|
121
|
+
return { passed: true, message: "" };
|
|
122
|
+
};
|
|
123
|
+
await checkGate("red", "green", runner, testConfig);
|
|
124
|
+
expect(captured).toBe(30);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("passes multiple test commands to the runner", async () => {
|
|
128
|
+
const multiConfig: Config = {
|
|
129
|
+
...testConfig,
|
|
130
|
+
testCommands: ["npm run test:unit", "npm run test:integration"],
|
|
131
|
+
};
|
|
132
|
+
let captured: string[] | undefined;
|
|
133
|
+
const runner: TestRunner = async (cmds) => {
|
|
134
|
+
captured = cmds;
|
|
135
|
+
return { passed: true, message: "" };
|
|
136
|
+
};
|
|
137
|
+
await checkGate("red", "green", runner, multiConfig);
|
|
138
|
+
expect(captured).toHaveLength(2);
|
|
139
|
+
expect(captured).toContain("npm run test:unit");
|
|
140
|
+
expect(captured).toContain("npm run test:integration");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── Integration tests: getDisallowedChanges ──────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function withTempDir(fn: (dir: string) => void) {
|
|
147
|
+
const dir = join(tmpdir(), `tdd-transition-test-${Date.now()}`);
|
|
148
|
+
mkdirSync(dir, { recursive: true });
|
|
149
|
+
try {
|
|
150
|
+
fn(dir);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(dir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const denyConfig: Config = {
|
|
157
|
+
allowedRedPhaseFiles: ["tests/**/*.test.ts"],
|
|
158
|
+
allowedGreenPhaseFiles: ["src/**/*.ts"],
|
|
159
|
+
testCommands: [],
|
|
160
|
+
timeoutSeconds: 30,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
describe("getDisallowedChanges", () => {
|
|
164
|
+
it("returns empty for refactor phase regardless of git state", () => {
|
|
165
|
+
withTempDir((dir) => {
|
|
166
|
+
// No git at all — safe because refactor returns early
|
|
167
|
+
expect(getDisallowedChanges(dir, "refactor", denyConfig)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns empty when no files changed", () => {
|
|
172
|
+
withTempDir((dir) => {
|
|
173
|
+
initGit(dir);
|
|
174
|
+
snapshot(dir, "red");
|
|
175
|
+
expect(getDisallowedChanges(dir, "red", denyConfig)).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns disallowed files in red phase", () => {
|
|
180
|
+
withTempDir((dir) => {
|
|
181
|
+
initGit(dir);
|
|
182
|
+
snapshot(dir, "red");
|
|
183
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
184
|
+
mkdirSync(join(dir, "tests"), { recursive: true });
|
|
185
|
+
writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
|
|
186
|
+
writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
|
|
187
|
+
writeFileSync(join(dir, "README.md"), "// docs", "utf-8");
|
|
188
|
+
const violations = getDisallowedChanges(dir, "red", denyConfig);
|
|
189
|
+
expect(violations).toContain("src/main.ts");
|
|
190
|
+
expect(violations).not.toContain("tests/foo.test.ts");
|
|
191
|
+
expect(violations).not.toContain("README.md");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns disallowed files in green phase", () => {
|
|
196
|
+
withTempDir((dir) => {
|
|
197
|
+
initGit(dir);
|
|
198
|
+
snapshot(dir, "green");
|
|
199
|
+
mkdirSync(join(dir, "tests"), { recursive: true });
|
|
200
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
|
|
202
|
+
writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
|
|
203
|
+
writeFileSync(join(dir, "package.json"), "{}", "utf-8");
|
|
204
|
+
const violations = getDisallowedChanges(dir, "green", denyConfig);
|
|
205
|
+
expect(violations).toContain("tests/foo.test.ts");
|
|
206
|
+
expect(violations).not.toContain("src/main.ts");
|
|
207
|
+
expect(violations).not.toContain("package.json");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("catches untracked files, not just modified", () => {
|
|
212
|
+
withTempDir((dir) => {
|
|
213
|
+
initGit(dir);
|
|
214
|
+
snapshot(dir, "red");
|
|
215
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
216
|
+
writeFileSync(join(dir, "src", "new.ts"), "// brand new", "utf-8");
|
|
217
|
+
const violations = getDisallowedChanges(dir, "red", denyConfig);
|
|
218
|
+
expect(violations).toContain("src/new.ts");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Phase, Config, Transition } from "./types.js";
|
|
2
|
+
import { PHASE_CYCLE } from "./types.js";
|
|
3
|
+
import { changesSinceSnapshot } from "./git.js";
|
|
4
|
+
import { disallowedFiles } from "./enforce.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the next phase in the cycle.
|
|
8
|
+
*/
|
|
9
|
+
export function nextPhase(current: Phase): Phase | null {
|
|
10
|
+
return PHASE_CYCLE[current] ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GateResult {
|
|
14
|
+
passed: boolean;
|
|
15
|
+
message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TestRunner = (commands: string[], timeoutSeconds: number) => Promise<GateResult>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run the transition gate check.
|
|
22
|
+
* - RED→GREEN: tests must fail (all non-zero exit)
|
|
23
|
+
* - GREEN→REFACTOR: tests must pass (all zero exit)
|
|
24
|
+
* - REFACTOR→RED: tests must pass (all zero exit)
|
|
25
|
+
*/
|
|
26
|
+
export async function checkGate(
|
|
27
|
+
from: Phase,
|
|
28
|
+
to: Phase,
|
|
29
|
+
testRunner: TestRunner,
|
|
30
|
+
config: Config,
|
|
31
|
+
): Promise<GateResult> {
|
|
32
|
+
if (config.testCommands.length === 0) {
|
|
33
|
+
return { passed: true, message: "No test commands configured — gate skipped." };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = await testRunner(config.testCommands, config.timeoutSeconds);
|
|
37
|
+
|
|
38
|
+
switch (`${from}→${to}` as Transition) {
|
|
39
|
+
case "red→green":
|
|
40
|
+
if (result.passed) {
|
|
41
|
+
return {
|
|
42
|
+
passed: false,
|
|
43
|
+
message: "Tests pass. Break a test first before transitioning to GREEN.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { passed: true, message: "Tests fail — proceed to GREEN." };
|
|
47
|
+
|
|
48
|
+
case "green→refactor":
|
|
49
|
+
case "refactor→red":
|
|
50
|
+
if (!result.passed) {
|
|
51
|
+
return {
|
|
52
|
+
passed: false,
|
|
53
|
+
message: "Tests must pass before transitioning. Fix failing tests first.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { passed: true, message: "All tests pass — proceeding." };
|
|
57
|
+
|
|
58
|
+
default:
|
|
59
|
+
return { passed: false, message: `Unknown transition: ${from}→${to}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate that no disallowed files have been modified since the phase snapshot.
|
|
65
|
+
* Returns list of violating files (empty = ok).
|
|
66
|
+
*/
|
|
67
|
+
export function getDisallowedChanges(
|
|
68
|
+
projectRoot: string,
|
|
69
|
+
phase: Phase,
|
|
70
|
+
config: Config,
|
|
71
|
+
): string[] {
|
|
72
|
+
if (phase === "refactor") return [];
|
|
73
|
+
|
|
74
|
+
const changed = changesSinceSnapshot(projectRoot);
|
|
75
|
+
return disallowedFiles(changed, phase, config);
|
|
76
|
+
}
|
package/engine/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Phase = "red" | "green" | "refactor";
|
|
2
|
+
|
|
3
|
+
export interface PhaseState {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
current: Phase;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
allowedRedPhaseFiles: string[];
|
|
10
|
+
allowedGreenPhaseFiles: string[];
|
|
11
|
+
testCommands: string[];
|
|
12
|
+
timeoutSeconds: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Transition = "red→green" | "green→refactor" | "refactor→red";
|
|
16
|
+
|
|
17
|
+
export const PHASE_CYCLE: Record<Phase, Phase | null> = {
|
|
18
|
+
red: "green",
|
|
19
|
+
green: "refactor",
|
|
20
|
+
refactor: "red",
|
|
21
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tdd-enforcer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"picomatch": "^4.0.4"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"vitest": "^3"
|
|
11
|
+
},
|
|
12
|
+
"pi": {
|
|
13
|
+
"extensions": ["./adapters/pi/index.ts"]
|
|
14
|
+
}
|
|
15
|
+
}
|