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.
@@ -1,22 +1,9 @@
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";
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
5
2
  import { loadTddState } from "./helpers.js";
6
3
 
7
- function withTempDir(fn: (dir: string) => void) {
8
- const dir = join(tmpdir(), `tdd-helpers-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
4
  const validRules = {
18
- allowedRedPhaseFiles: ["tests/**/*.test.ts"],
19
- allowedGreenPhaseFiles: ["src/**/*.ts"],
5
+ blockedInRed: ["tests/**/*.test.ts"],
6
+ blockedInGreen: ["src/**/*.ts"],
20
7
  testCommands: ["npm test"],
21
8
  timeoutSeconds: 30,
22
9
  };
@@ -27,189 +14,260 @@ const validPhase = {
27
14
  };
28
15
 
29
16
  describe("loadTddState", () => {
30
- it("returns missing dir error when .pi/tdd does not exist", () => {
31
- withTempDir((dir) => {
32
- const result = loadTddState(dir);
33
- expect(result.ok).toBe(false);
34
- expect(result.reason).toContain("Missing .pi/tdd/");
17
+ let mockExistsSync: ReturnType<typeof vi.fn>;
18
+ let mockLoadConfig: ReturnType<typeof vi.fn>;
19
+ let mockInitGit: ReturnType<typeof vi.fn>;
20
+ let mockLoadPhaseState: ReturnType<typeof vi.fn>;
21
+ let mockSavePhaseState: ReturnType<typeof vi.fn>;
22
+ let mockHeadMessage: ReturnType<typeof vi.fn>;
23
+ let mockNextPhase: ReturnType<typeof vi.fn>;
24
+ let mockStageFiles: ReturnType<typeof vi.fn>;
25
+
26
+ // Shared helper to simulate nextPhase behavior
27
+ const realNextPhase = (p: string) =>
28
+ p === "red"
29
+ ? "green"
30
+ : p === "green"
31
+ ? "refactor"
32
+ : p === "refactor"
33
+ ? "red"
34
+ : null;
35
+
36
+ function makeDeps(overrides = {}) {
37
+ return {
38
+ existsSync: mockExistsSync,
39
+ loadConfig: mockLoadConfig,
40
+ initGit: mockInitGit,
41
+ loadPhaseState: mockLoadPhaseState,
42
+ savePhaseState: mockSavePhaseState,
43
+ headMessage: mockHeadMessage,
44
+ nextPhase: mockNextPhase,
45
+ stageFiles: mockStageFiles,
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ // Default: all setup valid
53
+ mockExistsSync = vi.fn().mockImplementation((path: string) => {
54
+ // Make .pi/tdd and rules.json exist by default
55
+ if (path.includes(".git")) return false;
56
+ if (path.includes(".pi/tdd")) return true;
57
+ return true;
35
58
  });
59
+ mockLoadConfig = vi.fn().mockReturnValue(validRules);
60
+ mockInitGit = vi.fn();
61
+ mockLoadPhaseState = vi.fn().mockReturnValue(validPhase);
62
+ mockSavePhaseState = vi.fn();
63
+ mockHeadMessage = vi.fn().mockReturnValue("tdd: red");
64
+ mockNextPhase = vi.fn().mockImplementation(realNextPhase);
65
+ mockStageFiles = vi.fn();
66
+ });
67
+
68
+ it("returns missing dir error when .pi/tdd does not exist", () => {
69
+ mockExistsSync.mockReturnValue(false);
70
+ const result = loadTddState("/test", makeDeps());
71
+ expect(result.ok).toBe(false);
72
+ expect(result.reason).toContain("Missing .pi/tdd/");
36
73
  });
37
74
 
38
75
  it("returns missing rules.json error when only dir exists", () => {
39
- withTempDir((dir) => {
40
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
41
- const result = loadTddState(dir);
42
- expect(result.ok).toBe(false);
43
- expect(result.reason).toContain("rules.json");
76
+ mockExistsSync.mockImplementation((path: string) => {
77
+ if (path.includes("rules.json")) return false;
78
+ if (path.includes(".pi/tdd")) return true;
79
+ return false;
44
80
  });
81
+ const result = loadTddState("/test", makeDeps());
82
+ expect(result.ok).toBe(false);
83
+ expect(result.reason).toContain("rules.json");
45
84
  });
46
85
 
47
86
  it("auto-creates state.json when missing (default RED disabled)", () => {
48
- withTempDir((dir) => {
49
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
50
- writeFileSync(
51
- join(dir, ".pi", "tdd", "rules.json"),
52
- JSON.stringify(validRules),
53
- "utf-8",
54
- );
55
- const result = loadTddState(dir);
56
- expect(result.ok).toBe(true);
57
- if (result.ok) {
58
- expect(result.state.enabled).toBe(false);
59
- expect(result.state.current).toBe("red");
60
- }
87
+ mockExistsSync.mockImplementation((path: string) => {
88
+ if (path.includes("state.json")) return false;
89
+ if (path.includes(".git")) return false;
90
+ return true;
91
+ });
92
+ mockHeadMessage.mockReturnValue("tdd: init");
93
+ const result = loadTddState("/test", makeDeps());
94
+ expect(result.ok).toBe(true);
95
+ if (result.ok) {
96
+ expect(result.state.enabled).toBe(false);
97
+ expect(result.state.current).toBe("red");
98
+ }
99
+ expect(mockInitGit).toHaveBeenCalled();
100
+ expect(mockSavePhaseState).toHaveBeenCalledWith("/test", {
101
+ enabled: false,
102
+ current: "red",
61
103
  });
62
104
  });
63
105
 
64
106
  it("auto-creates state.json when corrupted (recovers to default)", () => {
65
- withTempDir((dir) => {
66
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
67
- writeFileSync(
68
- join(dir, ".pi", "tdd", "rules.json"),
69
- JSON.stringify(validRules),
70
- "utf-8",
71
- );
72
- writeFileSync(join(dir, ".pi", "tdd", "state.json"), "not json", "utf-8");
73
- const result = loadTddState(dir);
74
- expect(result.ok).toBe(true);
75
- if (result.ok) {
76
- expect(result.state.enabled).toBe(false);
77
- expect(result.state.current).toBe("red");
78
- }
107
+ mockExistsSync.mockImplementation((path: string) => {
108
+ if (path.includes("state.json")) return true; // exists but corrupted
109
+ if (path.includes(".git")) return false;
110
+ return true;
111
+ });
112
+ mockLoadPhaseState.mockImplementation(() => {
113
+ throw new Error("corrupt");
79
114
  });
115
+ mockHeadMessage.mockReturnValue("tdd: init");
116
+ const result = loadTddState("/test", makeDeps());
117
+ expect(result.ok).toBe(true);
118
+ if (result.ok) {
119
+ expect(result.state.enabled).toBe(false);
120
+ expect(result.state.current).toBe("red");
121
+ }
80
122
  });
81
123
 
82
124
  it("returns invalid rules.json error for malformed JSON", () => {
83
- withTempDir((dir) => {
84
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
85
- writeFileSync(
86
- join(dir, ".pi", "tdd", "rules.json"),
87
- "not json",
88
- "utf-8",
89
- );
90
- writeFileSync(
91
- join(dir, ".pi", "tdd", "state.json"),
92
- JSON.stringify(validPhase),
93
- "utf-8",
94
- );
95
- const result = loadTddState(dir);
96
- expect(result.ok).toBe(false);
97
- expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
125
+ mockLoadConfig.mockImplementation(() => {
126
+ throw new Error("Unexpected token");
98
127
  });
128
+ const result = loadTddState("/test", makeDeps());
129
+ expect(result.ok).toBe(false);
130
+ expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
99
131
  });
100
132
 
101
133
  it("returns ok when state.json has enabled: false (callers check enabled)", () => {
102
- withTempDir((dir) => {
103
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
104
- writeFileSync(
105
- join(dir, ".pi", "tdd", "rules.json"),
106
- JSON.stringify(validRules),
107
- "utf-8",
108
- );
109
- writeFileSync(
110
- join(dir, ".pi", "tdd", "state.json"),
111
- JSON.stringify({ enabled: false, current: "red" }),
112
- "utf-8",
113
- );
114
- const result = loadTddState(dir);
115
- expect(result.ok).toBe(true);
116
- if (result.ok) {
117
- expect(result.state.enabled).toBe(false);
118
- expect(result.state.current).toBe("red");
119
- }
120
- });
134
+ mockLoadPhaseState.mockReturnValue({ enabled: false, current: "red" });
135
+ const result = loadTddState("/test", makeDeps());
136
+ expect(result.ok).toBe(true);
137
+ if (result.ok) {
138
+ expect(result.state.enabled).toBe(false);
139
+ expect(result.state.current).toBe("red");
140
+ }
121
141
  });
122
142
 
123
143
  it("returns ok with state and config when everything valid", () => {
124
- withTempDir((dir) => {
125
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
126
- writeFileSync(
127
- join(dir, ".pi", "tdd", "rules.json"),
128
- JSON.stringify(validRules),
129
- "utf-8",
130
- );
131
- writeFileSync(
132
- join(dir, ".pi", "tdd", "state.json"),
133
- JSON.stringify(validPhase),
134
- "utf-8",
135
- );
136
- const result = loadTddState(dir);
137
- expect(result.ok).toBe(true);
138
- if (result.ok) {
139
- expect(result.state.current).toBe("red");
140
- expect(result.state.enabled).toBe(true);
141
- expect(result.config.testCommands).toEqual(["npm test"]);
142
- expect(result.config.allowedRedPhaseFiles).toEqual(["tests/**/*.test.ts"]);
143
- }
144
- });
144
+ mockLoadPhaseState.mockReturnValue(validPhase);
145
+ const result = loadTddState("/test", makeDeps());
146
+ expect(result.ok).toBe(true);
147
+ if (result.ok) {
148
+ expect(result.state.current).toBe("red");
149
+ expect(result.state.enabled).toBe(true);
150
+ expect(result.config.testCommands).toEqual(["npm test"]);
151
+ expect(result.config.blockedInRed).toEqual([
152
+ "tests/**/*.test.ts",
153
+ ]);
154
+ }
145
155
  });
146
156
 
147
157
  it("auto-creates git repo when missing", () => {
148
- withTempDir((dir) => {
149
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
150
- writeFileSync(
151
- join(dir, ".pi", "tdd", "rules.json"),
152
- JSON.stringify(validRules),
153
- "utf-8",
154
- );
155
- writeFileSync(
156
- join(dir, ".pi", "tdd", "state.json"),
157
- JSON.stringify(validPhase),
158
- "utf-8",
159
- );
158
+ let gitExists = false;
159
+ mockExistsSync.mockImplementation((path: string) => {
160
+ if (path.includes(".git")) return gitExists;
161
+ if (path.includes(".pi/tdd")) return true;
162
+ return true;
163
+ });
164
+ mockInitGit.mockImplementation(() => {
165
+ gitExists = true;
166
+ });
167
+ const result = loadTddState("/test", makeDeps());
168
+ expect(result.ok).toBe(true);
169
+ expect(mockInitGit).toHaveBeenCalled();
170
+ });
160
171
 
161
- const gitDir = join(dir, ".pi", "tdd", ".git");
162
- expect(existsSync(gitDir)).toBe(false);
172
+ it("handles multiple calls without error", () => {
173
+ const r1 = loadTddState("/test", makeDeps());
174
+ expect(r1.ok).toBe(true);
175
+ const r2 = loadTddState("/test", makeDeps());
176
+ expect(r2.ok).toBe(true);
177
+ expect(mockLoadConfig).toHaveBeenCalledTimes(2);
178
+ });
163
179
 
164
- const result = loadTddState(dir);
165
- expect(result.ok).toBe(true);
180
+ it("recovers from invalid current phase in state.json (auto-creates default)", () => {
181
+ mockLoadPhaseState.mockImplementation(() => {
182
+ throw new Error("invalid phase");
183
+ });
184
+ mockHeadMessage.mockReturnValue("tdd: init");
185
+ const result = loadTddState("/test", makeDeps());
186
+ expect(result.ok).toBe(true);
187
+ if (result.ok) {
188
+ expect(result.state.enabled).toBe(false);
189
+ expect(result.state.current).toBe("red");
190
+ }
191
+ });
166
192
 
167
- // Git should now exist loadTddState heals it
168
- expect(existsSync(gitDir)).toBe(true);
193
+ it("recoverState: HEAD tdd:red enabled green", () => {
194
+ mockExistsSync.mockImplementation((path: string) => {
195
+ if (path.includes("state.json")) return false; // missing → triggers recover
196
+ return true;
169
197
  });
198
+ mockHeadMessage.mockReturnValue("tdd: red");
199
+ mockNextPhase.mockImplementation(realNextPhase);
200
+ const result = loadTddState("/test", makeDeps());
201
+ expect(result.ok).toBe(true);
202
+ if (result.ok) {
203
+ expect(result.state.enabled).toBe(true);
204
+ expect(result.state.current).toBe("green");
205
+ }
170
206
  });
171
207
 
172
- it("handles multiple calls without error", () => {
173
- withTempDir((dir) => {
174
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
175
- writeFileSync(
176
- join(dir, ".pi", "tdd", "rules.json"),
177
- JSON.stringify(validRules),
178
- "utf-8",
179
- );
180
- writeFileSync(
181
- join(dir, ".pi", "tdd", "state.json"),
182
- JSON.stringify(validPhase),
183
- "utf-8",
184
- );
208
+ it("recoverState: HEAD tdd:green enabled refactor", () => {
209
+ mockExistsSync.mockImplementation((path: string) => {
210
+ if (path.includes("state.json")) return false;
211
+ return true;
212
+ });
213
+ mockHeadMessage.mockReturnValue("tdd: green");
214
+ const result = loadTddState("/test", makeDeps());
215
+ expect(result.ok).toBe(true);
216
+ if (result.ok) {
217
+ expect(result.state.enabled).toBe(true);
218
+ expect(result.state.current).toBe("refactor");
219
+ }
220
+ });
185
221
 
186
- const r1 = loadTddState(dir);
187
- expect(r1.ok).toBe(true);
222
+ it("recoverState: HEAD tdd:refactor → enabled red", () => {
223
+ mockExistsSync.mockImplementation((path: string) => {
224
+ if (path.includes("state.json")) return false;
225
+ return true;
226
+ });
227
+ mockHeadMessage.mockReturnValue("tdd: refactor");
228
+ const result = loadTddState("/test", makeDeps());
229
+ expect(result.ok).toBe(true);
230
+ if (result.ok) {
231
+ expect(result.state.enabled).toBe(true);
232
+ expect(result.state.current).toBe("red");
233
+ }
234
+ });
188
235
 
189
- const r2 = loadTddState(dir);
190
- expect(r2.ok).toBe(true);
236
+ it("force-adds state.json after creating it from recovery", () => {
237
+ mockExistsSync.mockImplementation((path: string) => {
238
+ if (path.includes("state.json")) return false; // missing → triggers create
239
+ if (path.includes(".git")) return false;
240
+ return true;
191
241
  });
242
+ mockHeadMessage.mockReturnValue("tdd: init");
243
+ const result = loadTddState("/test", makeDeps());
244
+ expect(result.ok).toBe(true);
245
+ expect(mockStageFiles).toHaveBeenCalledWith("/test", [".pi/tdd/state.json"]);
192
246
  });
193
247
 
194
- it("recovers from invalid current phase in state.json (auto-creates default)", () => {
195
- withTempDir((dir) => {
196
- mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
197
- writeFileSync(
198
- join(dir, ".pi", "tdd", "rules.json"),
199
- JSON.stringify(validRules),
200
- "utf-8",
201
- );
202
- writeFileSync(
203
- join(dir, ".pi", "tdd", "state.json"),
204
- JSON.stringify({ enabled: true, current: "blurple" }),
205
- "utf-8",
206
- );
207
- const result = loadTddState(dir);
208
- expect(result.ok).toBe(true);
209
- if (result.ok) {
210
- expect(result.state.enabled).toBe(false);
211
- expect(result.state.current).toBe("red");
212
- }
248
+ it("does not force-add state.json when it already exists and is valid", () => {
249
+ mockExistsSync.mockImplementation((path: string) => {
250
+ if (path.includes("state.json")) return true;
251
+ if (path.includes(".git")) return true;
252
+ return true;
253
+ });
254
+ const result = loadTddState("/test", makeDeps());
255
+ expect(result.ok).toBe(true);
256
+ expect(mockStageFiles).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("force-adds state.json after recovering from corrupted state", () => {
260
+ mockExistsSync.mockImplementation((path: string) => {
261
+ if (path.includes("state.json")) return true; // exists but corrupted
262
+ if (path.includes(".git")) return true;
263
+ return true;
264
+ });
265
+ mockLoadPhaseState.mockImplementation(() => {
266
+ throw new Error("corrupt");
213
267
  });
268
+ mockHeadMessage.mockReturnValue("tdd: init");
269
+ const result = loadTddState("/test", makeDeps());
270
+ expect(result.ok).toBe(true);
271
+ expect(mockStageFiles).toHaveBeenCalledWith("/test", [".pi/tdd/state.json"]);
214
272
  });
215
273
  });
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { loadPhaseState, savePhaseState, loadConfig, headMessage, nextPhase, initGit } from "../../engine/index.js";
3
+ import { loadPhaseState, savePhaseState, loadConfig, headMessage, nextPhase, initGit, stageFiles } from "../../engine/index.js";
4
4
  import type { PhaseState, Config, Phase } from "../../engine/types.js";
5
5
 
6
6
  export type TddLoadResult =
@@ -11,18 +11,29 @@ export type TddLoadResult =
11
11
  * Recover state.json from private git HEAD, or create default.
12
12
  * Returns the state (does not save to disk — caller does that).
13
13
  */
14
- function recoverState(root: string, tddDir: string): PhaseState {
14
+ function recoverState(
15
+ root: string,
16
+ tddDir: string,
17
+ deps: {
18
+ existsSync: typeof existsSync;
19
+ headMessage: typeof headMessage;
20
+ nextPhase: typeof nextPhase;
21
+ } = { existsSync, headMessage, nextPhase },
22
+ ): PhaseState {
15
23
  const gitDir = join(tddDir, ".git");
16
- if (existsSync(gitDir)) {
24
+ if (deps.existsSync(gitDir)) {
17
25
  try {
18
- const msg = headMessage(root);
26
+ const msg = deps.headMessage(root);
19
27
  const m = msg.match(/^tdd: (red|green|refactor|init)$/);
20
28
  if (m) {
21
- const headPhase = m[1] as Phase;
22
- if (headPhase === "init") {
29
+ const label = m[1];
30
+ if (label === "init") {
23
31
  return { enabled: false, current: "red" };
24
32
  }
25
- const next = nextPhase(headPhase);
33
+ if (label !== "red" && label !== "green" && label !== "refactor") {
34
+ return { enabled: false, current: "red" };
35
+ }
36
+ const next = deps.nextPhase(label);
26
37
  return { enabled: true, current: next ?? "red" };
27
38
  }
28
39
  } catch {
@@ -40,29 +51,50 @@ function recoverState(root: string, tddDir: string): PhaseState {
40
51
  *
41
52
  * Callers must check state.enabled themselves if they need active enforcement.
42
53
  */
43
- export function loadTddState(root: string): TddLoadResult {
54
+ export function loadTddState(
55
+ root: string,
56
+ deps: {
57
+ existsSync: typeof existsSync;
58
+ loadConfig: typeof loadConfig;
59
+ initGit: typeof initGit;
60
+ loadPhaseState: typeof loadPhaseState;
61
+ savePhaseState: typeof savePhaseState;
62
+ headMessage: typeof headMessage;
63
+ nextPhase: typeof nextPhase;
64
+ stageFiles: typeof stageFiles;
65
+ } = {
66
+ existsSync,
67
+ loadConfig,
68
+ initGit,
69
+ loadPhaseState,
70
+ savePhaseState,
71
+ headMessage,
72
+ nextPhase,
73
+ stageFiles,
74
+ },
75
+ ): TddLoadResult {
44
76
  const tddDir = join(root, ".pi", "tdd");
45
- if (!existsSync(tddDir)) {
77
+ if (!deps.existsSync(tddDir)) {
46
78
  return { ok: false, reason: "Missing .pi/tdd/ directory. See the tdd-enforcer skill to learn how to set up TDD configs." };
47
79
  }
48
80
 
49
81
  const rulesPath = join(tddDir, "rules.json");
50
- if (!existsSync(rulesPath)) {
82
+ if (!deps.existsSync(rulesPath)) {
51
83
  return { ok: false, reason: "Missing .pi/tdd/rules.json. See the tdd-enforcer skill to learn how to set up TDD configs." };
52
84
  }
53
85
 
54
86
  let config: Config;
55
87
  try {
56
- config = loadConfig(root);
88
+ config = deps.loadConfig(root);
57
89
  } catch (e) {
58
90
  return { ok: false, reason: `Invalid .pi/tdd/rules.json: ${(e as Error).message}. See the tdd-enforcer skill.` };
59
91
  }
60
92
 
61
93
  // Init git if missing — required for state recovery and all consumers
62
94
  const gitDir = join(tddDir, ".git");
63
- if (!existsSync(gitDir)) {
95
+ if (!deps.existsSync(gitDir)) {
64
96
  try {
65
- initGit(root);
97
+ deps.initGit(root);
66
98
  } catch (e) {
67
99
  return { ok: false, reason: `Failed to initialise private git repo: ${(e as Error).message}` };
68
100
  }
@@ -71,16 +103,17 @@ export function loadTddState(root: string): TddLoadResult {
71
103
  // Auto-create state.json if missing or corrupted
72
104
  const phasePath = join(tddDir, "state.json");
73
105
  let state: PhaseState | undefined;
74
- if (existsSync(phasePath)) {
106
+ if (deps.existsSync(phasePath)) {
75
107
  try {
76
- state = loadPhaseState(root);
108
+ state = deps.loadPhaseState(root);
77
109
  } catch {
78
110
  // Corrupted — recover below
79
111
  }
80
112
  }
81
113
  if (!state) {
82
- state = recoverState(root, tddDir);
83
- savePhaseState(root, state);
114
+ state = recoverState(root, tddDir, { existsSync: deps.existsSync, headMessage: deps.headMessage, nextPhase: deps.nextPhase });
115
+ deps.savePhaseState(root, state);
116
+ deps.stageFiles(root, [".pi/tdd/state.json"]);
84
117
  }
85
118
 
86
119
  return { ok: true, state, config };