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.
@@ -0,0 +1,165 @@
1
+ import { Type } from "typebox";
2
+ import { execSync } from "node:child_process";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ savePhaseState,
6
+ nextPhase,
7
+ checkGate,
8
+ getDisallowedChanges,
9
+ snapshot,
10
+ hasParent,
11
+ resetHard,
12
+ undoLastCommit,
13
+ headMessage,
14
+ } from "../../engine/index.js";
15
+ import type { TestRunner, Phase } from "../../engine/index.js";
16
+ import { getNudgePrompt } from "./prompts.js";
17
+ import { loadTddState } from "./helpers.js";
18
+
19
+
20
+ export function registerTools(pi: ExtensionAPI): void {
21
+ pi.registerTool({
22
+ name: "next_tdd_phase",
23
+ label: "Next TDD Phase",
24
+ description:
25
+ "Advance to the next TDD phase. Runs transition gates (test pass/fail checks) " +
26
+ "and allowlist validation (no forbidden files modified).",
27
+ parameters: Type.Object({}),
28
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
29
+ const root = ctx.cwd;
30
+ const tdd = loadTddState(root);
31
+ if (!tdd.ok) {
32
+ return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
33
+ }
34
+
35
+ const { state, config } = tdd;
36
+ const from = state.current;
37
+ const to = nextPhase(from);
38
+ if (!to) {
39
+ return { content: [{ type: "text", text: `No next phase from ${from}.` }], details: {} };
40
+ }
41
+
42
+ // 1. Allowlist check
43
+ const violations = getDisallowedChanges(root, from, config);
44
+ if (violations.length > 0) {
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text",
49
+ text:
50
+ `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
51
+ violations.map((f) => ` - ${f}`).join("\n") +
52
+ "\nRevert or remove them before proceeding.",
53
+ },
54
+ ],
55
+ details: {},
56
+ };
57
+ }
58
+
59
+ // 2. Gate check
60
+ const testRunner: TestRunner = async (commands, timeout) => {
61
+ const results = await Promise.all(
62
+ commands.map(async (cmd) => {
63
+ try {
64
+ execSync(cmd, { cwd: root, stdio: "pipe", timeout: timeout * 1000 });
65
+ return { command: cmd, passed: true };
66
+ } catch {
67
+ return { command: cmd, passed: false };
68
+ }
69
+ }),
70
+ );
71
+
72
+ const failed = results.filter((r) => !r.passed);
73
+ if (failed.length > 0) {
74
+ return {
75
+ passed: false,
76
+ message: "Tests failed:\n" + failed.map((f) => ` - ${f.command}`).join("\n"),
77
+ };
78
+ }
79
+ return { passed: true, message: "All tests passed." };
80
+ };
81
+
82
+ const gate = await checkGate(from, to, testRunner, config);
83
+ if (!gate.passed) {
84
+ return { content: [{ type: "text", text: gate.message }], details: {} };
85
+ }
86
+
87
+ // 3. Snapshot — label with the phase the work was done in
88
+ snapshot(root, from);
89
+
90
+ // 4. Save state
91
+ state.current = to;
92
+ savePhaseState(root, state);
93
+
94
+ return {
95
+ content: [{ type: "text", text: getNudgePrompt(to, config) }],
96
+ details: {},
97
+ };
98
+ },
99
+ });
100
+
101
+ pi.registerTool({
102
+ name: "previous_tdd_phase",
103
+ label: "Previous TDD Phase",
104
+ description:
105
+ "WARNING: Destroys ALL uncommitted changes and pops the last snapshot commit. " +
106
+ "Working tree keeps the popped commit's content as unstaged changes.",
107
+ parameters: Type.Object({}),
108
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
109
+ const root = ctx.cwd;
110
+ const tdd = loadTddState(root);
111
+ if (!tdd.ok) {
112
+ return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
113
+ }
114
+
115
+ const { state } = tdd;
116
+
117
+ if (!hasParent(root)) {
118
+ return {
119
+ content: [{ type: "text", text: "No previous phase to revert to." }],
120
+ details: {},
121
+ };
122
+ }
123
+
124
+ // Read phase from HEAD snapshot commit message (source of truth).
125
+ // Snapshot is labeled with the phase the work was done in, so we use
126
+ // it directly — no hardcoded phase map needed.
127
+ const headMsg = headMessage(root);
128
+ const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
129
+ if (!phaseMatch) {
130
+ return {
131
+ content: [
132
+ {
133
+ type: "text",
134
+ text: `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
135
+ `The private git repo at .pi/tdd must not be manually modified. ` +
136
+ `Tampering with it will cause TDD state corruption.`,
137
+ },
138
+ ],
139
+ details: {},
140
+ };
141
+ }
142
+ const prevPhase = phaseMatch[1] as Phase;
143
+
144
+ // 1. Nuke any uncommitted changes, WT matches HEAD
145
+ resetHard(root);
146
+
147
+ // 2. Pop last snapshot commit, keep its content as unstaged
148
+ undoLastCommit(root);
149
+
150
+ // 3. Update phase label from the snapshot's own label
151
+ state.current = prevPhase;
152
+ savePhaseState(root, state);
153
+
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: `Reverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
159
+ },
160
+ ],
161
+ details: {},
162
+ };
163
+ },
164
+ });
165
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadConfig } from "./config.js";
6
+
7
+ function withTempDir(fn: (dir: string) => void) {
8
+ const dir = join(tmpdir(), `tdd-test-${Date.now()}`);
9
+ mkdirSync(dir, { recursive: true });
10
+ try {
11
+ fn(dir);
12
+ } finally {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ describe("loadConfig", () => {
18
+ it("throws when no config exists", () => {
19
+ withTempDir((dir) => {
20
+ expect(() => loadConfig(dir)).toThrow();
21
+ });
22
+ });
23
+
24
+ it("loads config from rules.json", () => {
25
+ withTempDir((dir) => {
26
+ const tddDir = join(dir, ".pi", "tdd");
27
+ mkdirSync(tddDir, { recursive: true });
28
+ writeFileSync(
29
+ join(tddDir, "rules.json"),
30
+ JSON.stringify({
31
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
32
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
33
+ testCommands: ["npm run test"],
34
+ timeoutSeconds: 60,
35
+ }),
36
+ "utf-8",
37
+ );
38
+
39
+ const config = loadConfig(dir);
40
+ expect(config.allowedRedPhaseFiles).toEqual(["tests/**/*.test.ts"]);
41
+ expect(config.timeoutSeconds).toBe(60);
42
+ });
43
+ });
44
+
45
+ it("supports multiple test commands", () => {
46
+ withTempDir((dir) => {
47
+ const tddDir = join(dir, ".pi", "tdd");
48
+ mkdirSync(tddDir, { recursive: true });
49
+ writeFileSync(
50
+ join(tddDir, "rules.json"),
51
+ JSON.stringify({
52
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
53
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
54
+ testCommands: ["npm run test:unit", "npm run test:integration"],
55
+ }),
56
+ "utf-8",
57
+ );
58
+
59
+ const config = loadConfig(dir);
60
+ expect(config.testCommands).toHaveLength(2);
61
+ });
62
+ });
63
+
64
+ describe("validation — throws on invalid content", () => {
65
+ it("throws when allowedRedPhaseFiles is not an array", () => {
66
+ withTempDir((dir) => {
67
+ const tddDir = join(dir, ".pi", "tdd");
68
+ mkdirSync(tddDir, { recursive: true });
69
+ writeFileSync(
70
+ join(tddDir, "rules.json"),
71
+ JSON.stringify({
72
+ allowedRedPhaseFiles: "not-an-array",
73
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
74
+ testCommands: ["npm test"],
75
+ }),
76
+ "utf-8",
77
+ );
78
+ expect(() => loadConfig(dir)).toThrow();
79
+ });
80
+ });
81
+
82
+ it("throws when allowedGreenPhaseFiles is not an array", () => {
83
+ withTempDir((dir) => {
84
+ const tddDir = join(dir, ".pi", "tdd");
85
+ mkdirSync(tddDir, { recursive: true });
86
+ writeFileSync(
87
+ join(tddDir, "rules.json"),
88
+ JSON.stringify({
89
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
90
+ allowedGreenPhaseFiles: null,
91
+ testCommands: ["npm test"],
92
+ }),
93
+ "utf-8",
94
+ );
95
+ expect(() => loadConfig(dir)).toThrow();
96
+ });
97
+ });
98
+
99
+ it("throws when testCommands is not an array", () => {
100
+ withTempDir((dir) => {
101
+ const tddDir = join(dir, ".pi", "tdd");
102
+ mkdirSync(tddDir, { recursive: true });
103
+ writeFileSync(
104
+ join(tddDir, "rules.json"),
105
+ JSON.stringify({
106
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
107
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
108
+ testCommands: "npm test",
109
+ }),
110
+ "utf-8",
111
+ );
112
+ expect(() => loadConfig(dir)).toThrow();
113
+ });
114
+ });
115
+
116
+ it("throws on malformed JSON", () => {
117
+ withTempDir((dir) => {
118
+ const tddDir = join(dir, ".pi", "tdd");
119
+ mkdirSync(tddDir, { recursive: true });
120
+ writeFileSync(join(tddDir, "rules.json"), "not json{{", "utf-8");
121
+ expect(() => loadConfig(dir)).toThrow();
122
+ });
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Config } from "./types.js";
4
+
5
+ const TDD_DIR = ".pi/tdd";
6
+
7
+ export function configPath(projectRoot: string): string {
8
+ return join(projectRoot, TDD_DIR, "rules.json");
9
+ }
10
+
11
+ export function loadConfig(projectRoot: string): Config {
12
+ const path = configPath(projectRoot);
13
+ const raw = readFileSync(path, "utf-8");
14
+ const parsed = JSON.parse(raw);
15
+
16
+ if (!Array.isArray(parsed.allowedRedPhaseFiles)) {
17
+ throw new Error("rules.json: allowedRedPhaseFiles must be an array");
18
+ }
19
+ if (!Array.isArray(parsed.allowedGreenPhaseFiles)) {
20
+ throw new Error("rules.json: allowedGreenPhaseFiles must be an array");
21
+ }
22
+ if (!Array.isArray(parsed.testCommands)) {
23
+ throw new Error("rules.json: testCommands must be an array");
24
+ }
25
+
26
+ return {
27
+ allowedRedPhaseFiles: parsed.allowedRedPhaseFiles,
28
+ allowedGreenPhaseFiles: parsed.allowedGreenPhaseFiles,
29
+ testCommands: parsed.testCommands,
30
+ timeoutSeconds: parsed.timeoutSeconds ?? 120,
31
+ };
32
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isAllowed, disallowedFiles } from "./enforce.js";
3
+ import type { Config } from "./types.js";
4
+
5
+ const testConfig: Config = {
6
+ allowedRedPhaseFiles: ["tests/**/*.test.ts", "specs/**/*.spec.ts"],
7
+ allowedGreenPhaseFiles: ["src/**/*.ts", "lib/**/*.ts"],
8
+ testCommands: ["npm test"],
9
+ timeoutSeconds: 30,
10
+ };
11
+
12
+ describe("isAllowed", () => {
13
+ it("allows everything in refactor phase", () => {
14
+ expect(isAllowed("any/file.ts", "refactor", testConfig)).toBe(true);
15
+ expect(isAllowed("tests/foo.test.ts", "refactor", testConfig)).toBe(true);
16
+ });
17
+
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);
22
+ });
23
+
24
+ it("blocks green phase files", () => {
25
+ expect(isAllowed("src/main.ts", "red", testConfig)).toBe(false);
26
+ expect(isAllowed("lib/helper.ts", "red", testConfig)).toBe(false);
27
+ });
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);
32
+ });
33
+ });
34
+
35
+ describe("green phase", () => {
36
+ it("allows green phase files", () => {
37
+ expect(isAllowed("src/main.ts", "green", testConfig)).toBe(true);
38
+ expect(isAllowed("lib/helper.ts", "green", testConfig)).toBe(true);
39
+ });
40
+
41
+ it("blocks red phase files", () => {
42
+ expect(isAllowed("tests/unit/foo.test.ts", "green", testConfig)).toBe(false);
43
+ expect(isAllowed("specs/api.spec.ts", "green", testConfig)).toBe(false);
44
+ });
45
+
46
+ it("allows free files", () => {
47
+ expect(isAllowed("README.md", "green", testConfig)).toBe(true);
48
+ });
49
+ });
50
+
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);
54
+ });
55
+ });
56
+
57
+ describe("disallowedFiles", () => {
58
+ it("returns empty for refactor phase", () => {
59
+ expect(disallowedFiles(["src/main.ts", "tests/foo.test.ts"], "refactor", testConfig)).toEqual([]);
60
+ });
61
+
62
+ it("returns empty when input list is empty", () => {
63
+ expect(disallowedFiles([], "red", testConfig)).toEqual([]);
64
+ expect(disallowedFiles([], "green", testConfig)).toEqual([]);
65
+ });
66
+
67
+ it("filters out green files in red phase", () => {
68
+ const files = ["src/main.ts", "README.md", "tests/foo.test.ts"];
69
+ expect(disallowedFiles(files, "red", testConfig)).toEqual(["src/main.ts"]);
70
+ });
71
+
72
+ it("filters out red files in green phase", () => {
73
+ const files = ["tests/foo.test.ts", "README.md", "src/main.ts"];
74
+ expect(disallowedFiles(files, "green", testConfig)).toEqual(["tests/foo.test.ts"]);
75
+ });
76
+
77
+ it("allows free files in both phases", () => {
78
+ const free = ["README.md", "package.json", "docs/guide.md"];
79
+ expect(disallowedFiles(free, "red", testConfig)).toEqual([]);
80
+ expect(disallowedFiles(free, "green", testConfig)).toEqual([]);
81
+ });
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
+ });
@@ -0,0 +1,31 @@
1
+ import picomatch from "picomatch";
2
+ import type { Phase, Config } from "./types.js";
3
+
4
+ /**
5
+ * Check if a file path is allowed to be modified in the current phase.
6
+ *
7
+ * Rules:
8
+ * - 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
12
+ */
13
+ export function isAllowed(filePath: string, phase: Phase, config: Config): boolean {
14
+ if (phase === "refactor") return true;
15
+
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;
23
+ }
24
+
25
+ /**
26
+ * Filter a list of file paths to those that are disallowed in the current phase.
27
+ */
28
+ export function disallowedFiles(files: string[], phase: Phase, config: Config): string[] {
29
+ if (phase === "refactor") return [];
30
+ return files.filter((f) => !isAllowed(f, phase, config));
31
+ }