tdd-enforcer 0.2.6 → 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/tsconfig.json +10 -10
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ci:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
node-version: [22, 24]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-node@v4
|
|
20
|
+
with:
|
|
21
|
+
node-version: ${{ matrix.node-version }}
|
|
22
|
+
cache: npm
|
|
23
|
+
|
|
24
|
+
- run: npm ci
|
|
25
|
+
|
|
26
|
+
- run: npm run lint
|
|
27
|
+
|
|
28
|
+
- run: npm test
|
|
@@ -1,273 +1,275 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { loadTddState } from "./helpers.js";
|
|
3
3
|
|
|
4
4
|
const validRules = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
6
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
7
|
+
testCommands: ["npm test"],
|
|
8
|
+
timeoutSeconds: 30,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
const validPhase = {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
enabled: true,
|
|
13
|
+
current: "red",
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
describe("loadTddState", () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
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
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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/");
|
|
73
|
+
});
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
it("returns missing rules.json error when only dir exists", () => {
|
|
76
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
77
|
+
if (path.includes("rules.json")) return false;
|
|
78
|
+
if (path.includes(".pi/tdd")) return true;
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
const result = loadTddState("/test", makeDeps());
|
|
82
|
+
expect(result.ok).toBe(false);
|
|
83
|
+
expect(result.reason).toContain("rules.json");
|
|
84
|
+
});
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
86
|
+
it("auto-creates state.json when missing (default RED disabled)", () => {
|
|
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",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
it("auto-creates state.json when corrupted (recovers to default)", () => {
|
|
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");
|
|
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
|
+
}
|
|
122
|
+
});
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
124
|
+
it("returns invalid rules.json error for malformed JSON", () => {
|
|
125
|
+
mockLoadConfig.mockImplementation(() => {
|
|
126
|
+
throw new Error("Unexpected token");
|
|
127
|
+
});
|
|
128
|
+
const result = loadTddState("/test", makeDeps());
|
|
129
|
+
expect(result.ok).toBe(false);
|
|
130
|
+
expect(result.reason).toContain("Invalid .pi/tdd/rules.json");
|
|
131
|
+
});
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
133
|
+
it("returns ok when state.json has enabled: false (callers check enabled)", () => {
|
|
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
|
+
}
|
|
141
|
+
});
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
});
|
|
143
|
+
it("returns ok with state and config when everything valid", () => {
|
|
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(["tests/**/*.test.ts"]);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
156
154
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
155
|
+
it("auto-creates git repo when missing", () => {
|
|
156
|
+
let gitExists = false;
|
|
157
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
158
|
+
if (path.includes(".git")) return gitExists;
|
|
159
|
+
if (path.includes(".pi/tdd")) return true;
|
|
160
|
+
return true;
|
|
161
|
+
});
|
|
162
|
+
mockInitGit.mockImplementation(() => {
|
|
163
|
+
gitExists = true;
|
|
164
|
+
});
|
|
165
|
+
const result = loadTddState("/test", makeDeps());
|
|
166
|
+
expect(result.ok).toBe(true);
|
|
167
|
+
expect(mockInitGit).toHaveBeenCalled();
|
|
168
|
+
});
|
|
171
169
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
170
|
+
it("handles multiple calls without error", () => {
|
|
171
|
+
const r1 = loadTddState("/test", makeDeps());
|
|
172
|
+
expect(r1.ok).toBe(true);
|
|
173
|
+
const r2 = loadTddState("/test", makeDeps());
|
|
174
|
+
expect(r2.ok).toBe(true);
|
|
175
|
+
expect(mockLoadConfig).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
179
177
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
178
|
+
it("recovers from invalid current phase in state.json (auto-creates default)", () => {
|
|
179
|
+
mockLoadPhaseState.mockImplementation(() => {
|
|
180
|
+
throw new Error("invalid phase");
|
|
181
|
+
});
|
|
182
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
183
|
+
const result = loadTddState("/test", makeDeps());
|
|
184
|
+
expect(result.ok).toBe(true);
|
|
185
|
+
if (result.ok) {
|
|
186
|
+
expect(result.state.enabled).toBe(false);
|
|
187
|
+
expect(result.state.current).toBe("red");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
192
190
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
191
|
+
it("recoverState: HEAD tdd:red → enabled green", () => {
|
|
192
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
193
|
+
if (path.includes("state.json")) return false; // missing → triggers recover
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
mockHeadMessage.mockReturnValue("tdd: red");
|
|
197
|
+
mockNextPhase.mockImplementation(realNextPhase);
|
|
198
|
+
const result = loadTddState("/test", makeDeps());
|
|
199
|
+
expect(result.ok).toBe(true);
|
|
200
|
+
if (result.ok) {
|
|
201
|
+
expect(result.state.enabled).toBe(true);
|
|
202
|
+
expect(result.state.current).toBe("green");
|
|
203
|
+
}
|
|
204
|
+
});
|
|
207
205
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
206
|
+
it("recoverState: HEAD tdd:green → enabled refactor", () => {
|
|
207
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
208
|
+
if (path.includes("state.json")) return false;
|
|
209
|
+
return true;
|
|
210
|
+
});
|
|
211
|
+
mockHeadMessage.mockReturnValue("tdd: green");
|
|
212
|
+
const result = loadTddState("/test", makeDeps());
|
|
213
|
+
expect(result.ok).toBe(true);
|
|
214
|
+
if (result.ok) {
|
|
215
|
+
expect(result.state.enabled).toBe(true);
|
|
216
|
+
expect(result.state.current).toBe("refactor");
|
|
217
|
+
}
|
|
218
|
+
});
|
|
221
219
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
220
|
+
it("recoverState: HEAD tdd:refactor → enabled red", () => {
|
|
221
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
222
|
+
if (path.includes("state.json")) return false;
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
mockHeadMessage.mockReturnValue("tdd: refactor");
|
|
226
|
+
const result = loadTddState("/test", makeDeps());
|
|
227
|
+
expect(result.ok).toBe(true);
|
|
228
|
+
if (result.ok) {
|
|
229
|
+
expect(result.state.enabled).toBe(true);
|
|
230
|
+
expect(result.state.current).toBe("red");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
235
233
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
234
|
+
it("force-adds state.json after creating it from recovery", () => {
|
|
235
|
+
mockExistsSync.mockImplementation((path: string) => {
|
|
236
|
+
if (path.includes("state.json")) return false; // missing → triggers create
|
|
237
|
+
if (path.includes(".git")) return false;
|
|
238
|
+
return true;
|
|
239
|
+
});
|
|
240
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
241
|
+
const result = loadTddState("/test", makeDeps());
|
|
242
|
+
expect(result.ok).toBe(true);
|
|
243
|
+
expect(mockStageFiles).toHaveBeenCalledWith("/test", [
|
|
244
|
+
".pi/tdd/state.json",
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
247
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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");
|
|
267
|
+
});
|
|
268
|
+
mockHeadMessage.mockReturnValue("tdd: init");
|
|
269
|
+
const result = loadTddState("/test", makeDeps());
|
|
270
|
+
expect(result.ok).toBe(true);
|
|
271
|
+
expect(mockStageFiles).toHaveBeenCalledWith("/test", [
|
|
272
|
+
".pi/tdd/state.json",
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
273
275
|
});
|