tdd-enforcer 0.3.2 → 0.3.4

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.
@@ -13,9 +13,8 @@ import {
13
13
  gitStashCreate,
14
14
  restoreFilesTo,
15
15
  } from "../../engine/git.js";
16
+ import { loadTddState, tddLog } from "../../engine/index.js";
16
17
  import type { Config, Phase } from "../../engine/types.js";
17
- import { loadTddState } from "./helpers.js";
18
- import { tddLog } from "./log.js";
19
18
 
20
19
  export async function handleToolCall(
21
20
  event: any,
@@ -3,10 +3,14 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { resetGit, savePhaseState, snapshot } from "../../engine/index.js";
7
- import { loadTddState } from "./helpers.js";
6
+ import {
7
+ loadTddState,
8
+ resetGit,
9
+ savePhaseState,
10
+ snapshot,
11
+ tddLog,
12
+ } from "../../engine/index.js";
8
13
  import { registerHooks } from "./hooks.js";
9
- import { tddLog } from "./log.js";
10
14
  import { registerTools } from "./tools.js";
11
15
 
12
16
  export async function handleTddOn(
@@ -382,15 +382,20 @@ describe("executeTddStatus", () => {
382
382
  ).rejects.toThrow("Missing .pi/tdd/");
383
383
  });
384
384
 
385
- it("throws when TDD disabled", async () => {
385
+ it("returns status details when TDD disabled", async () => {
386
386
  mockLoadTddState.mockReturnValue({
387
387
  ok: true,
388
388
  state: { enabled: false, current: "red" },
389
389
  config: CONFIG,
390
390
  });
391
- await expect(
392
- executeTddStatus({ cwd: "/test" } as any, makeDeps()),
393
- ).rejects.toThrow("not enabled");
391
+ const result = await executeTddStatus({ cwd: "/test" } as any, makeDeps());
392
+ expect(result.content[0].text).toContain("disabled");
393
+ expect(result.content[0].text).toContain("RED");
394
+ expect(result.content[0].text).toContain("tests/**/*.test.ts");
395
+ expect(result.content[0].text).toContain("src/**/*.ts");
396
+ expect(result.details).toBeDefined();
397
+ expect(result.details?.enabled).toBe(false);
398
+ expect(result.details?.phase).toBe("red");
394
399
  });
395
400
 
396
401
  it("returns status details when TDD enabled", async () => {
@@ -11,19 +11,22 @@ import type {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import type { Phase, TestRunner } from "../../engine/index.js";
13
13
  import {
14
+ advancePhase,
14
15
  checkGate,
15
16
  getDisallowedChanges,
17
+ getNudgePrompt,
18
+ getStatusInfo,
16
19
  hasParent,
17
20
  headMessage,
21
+ loadTddState,
18
22
  nextPhase,
19
23
  resetHard,
24
+ revertPhase,
20
25
  savePhaseState,
21
26
  snapshot,
27
+ tddLog,
22
28
  undoLastCommit,
23
29
  } from "../../engine/index.js";
24
- import { loadTddState } from "./helpers.js";
25
- import { tddLog } from "./log.js";
26
- import { getNudgePrompt } from "./prompts.js";
27
30
 
28
31
  // ── De dependency types ─────────────────────────────────────────────────────
29
32
 
@@ -115,26 +118,14 @@ export async function executeNextPhase(
115
118
 
116
119
  deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
117
120
 
118
- // 1. Allowlist check
119
- const violations = deps.getDisallowedChanges(root, from, config);
120
- if (violations.length > 0) {
121
- deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
122
- from,
123
- violations,
124
- });
125
- throw new Error(
126
- `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
127
- violations.map((f) => ` - ${f}`).join("\n") +
128
- `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
129
- );
130
- }
131
-
132
- // 2. Gate check
133
121
  const testRunner: TestRunner = async (commands, timeout) => {
134
122
  const results = await Promise.all(
135
123
  commands.map(async (cmd) => {
136
124
  try {
137
- await deps.asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
125
+ await deps.asyncExec(cmd, {
126
+ cwd: root,
127
+ timeout: timeout * 1000,
128
+ });
138
129
  return { command: cmd, passed: true };
139
130
  } catch {
140
131
  return { command: cmd, passed: false };
@@ -152,31 +143,30 @@ export async function executeNextPhase(
152
143
  return { passed: true, message: "All tests passed." };
153
144
  };
154
145
 
155
- const gate = await deps.checkGate(from, to, testRunner, config);
156
- deps.tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
157
- from,
158
- to,
159
- passed: gate.passed,
160
- message: gate.message,
146
+ const result = await advancePhase(root, state, config, {
147
+ nextPhase: deps.nextPhase,
148
+ getDisallowedChanges: deps.getDisallowedChanges,
149
+ checkGate: deps.checkGate,
150
+ snapshot: deps.snapshot,
151
+ savePhaseState: deps.savePhaseState,
152
+ testRunner,
161
153
  });
162
154
 
163
- if (!gate.passed) {
164
- throw new Error(gate.message);
155
+ if (!result.ok) {
156
+ // advancePhase already returns the exact error message with revert hint
157
+ // Log the failure at adapter level
158
+ deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
159
+ from,
160
+ violations: result.message,
161
+ });
162
+ throw new Error(result.message);
165
163
  }
166
164
 
167
- // 3. Snapshot label with the phase the work was done in
168
- const hash = deps.snapshot(root, from);
169
- deps.tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
165
+ deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", {
170
166
  from,
171
167
  to,
172
- hash,
173
168
  });
174
169
 
175
- // 4. Save state
176
- state.current = to;
177
- deps.savePhaseState(root, state);
178
- deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
179
-
180
170
  return {
181
171
  content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
182
172
  details: {},
@@ -208,65 +198,28 @@ export async function executePreviousPhase(
208
198
 
209
199
  const { state } = tdd;
210
200
 
211
- if (!deps.hasParent(root)) {
212
- deps.tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
213
- phase: state.current,
214
- });
215
- throw new Error("No previous phase to revert to.");
216
- }
217
-
218
- // Read phase from HEAD snapshot commit message (source of truth).
219
- // Snapshot is labeled with the phase the work was done in, so we use
220
- // it directly — no hardcoded phase map needed.
221
- const headMsg = deps.headMessage(root);
222
- const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
223
- if (!phaseMatch) {
224
- deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
225
- headMsg,
226
- });
227
- throw new Error(
228
- `HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
229
- `The private git repo at .pi/tdd must not be manually modified. ` +
230
- `Tampering with it will cause TDD state corruption.`,
231
- );
232
- }
233
- const label = phaseMatch[1];
234
- if (label !== "red" && label !== "green" && label !== "refactor") {
235
- deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid phase label", {
236
- headMsg,
237
- label,
238
- });
239
- throw new Error(
240
- `HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
241
- );
242
- }
243
- const prevPhase: Phase = label;
244
- deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
245
- from: state.current,
246
- to: prevPhase,
247
- headMsg,
201
+ const result = await revertPhase(root, state, {
202
+ hasParent: deps.hasParent,
203
+ headMessage: deps.headMessage,
204
+ resetHard: deps.resetHard,
205
+ undoLastCommit: deps.undoLastCommit,
206
+ savePhaseState: deps.savePhaseState,
248
207
  });
249
208
 
250
- // 1. Nuke any uncommitted changes, WT matches HEAD
251
- deps.resetHard(root);
252
- deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
253
-
254
- // 2. Pop last snapshot commit, keep its content as unstaged
255
- deps.undoLastCommit(root);
256
- deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
209
+ if (!result.ok) {
210
+ throw new Error(result.message);
211
+ }
257
212
 
258
- // 3. Update phase label from the snapshot's own label
259
- state.current = prevPhase;
260
- deps.savePhaseState(root, state);
261
213
  deps.tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
262
- to: prevPhase,
214
+ from: state.current,
215
+ to: result.newState?.current,
263
216
  });
264
217
 
265
218
  return {
266
219
  content: [
267
220
  {
268
221
  type: "text",
269
- text: `\nReverted to ${prevPhase.toUpperCase()}. Working tree has the previous snapshot content as unstaged changes.`,
222
+ text: `\n${result.message} Working tree has the previous snapshot content as unstaged changes.`,
270
223
  },
271
224
  ],
272
225
  details: {},
@@ -292,35 +245,18 @@ export async function executeTddStatus(
292
245
  });
293
246
  throw new Error(`TDD: ${result.reason}`);
294
247
  }
295
- if (!result.state.enabled) {
296
- deps.tddLog(tddDir, "WARN", "tdd_status: TDD disabled");
297
- throw new Error("TDD is not enabled. Run /tdd:on to enable it.");
298
- }
299
-
300
248
  const { state, config } = result;
301
- const phaseStr = state.current.toUpperCase();
302
- const redBlk = config.blockedInRed.join(", ") || "(none)";
303
- const greenBlk = config.blockedInGreen.join(", ") || "(none)";
304
- const commands = config.testCommands.join(", ") || "(none)";
249
+ const info = getStatusInfo(state, config);
305
250
 
306
251
  deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
252
+ enabled: state.enabled,
307
253
  phase: state.current,
308
254
  });
309
255
 
310
256
  return {
311
- content: [
312
- {
313
- type: "text",
314
- text:
315
- `\nTDD enforcer enabled\n` +
316
- `Current phase: ${phaseStr}\n` +
317
- `Blocked in RED: ${redBlk}\n` +
318
- `Blocked in GREEN: ${greenBlk}\n` +
319
- `Test commands: ${commands}`,
320
- },
321
- ],
257
+ content: [{ type: "text", text: `\n${info}` }],
322
258
  details: {
323
- enabled: true,
259
+ enabled: state.enabled,
324
260
  phase: state.current,
325
261
  blockedInRed: config.blockedInRed,
326
262
  blockedInGreen: config.blockedInGreen,
@@ -61,6 +61,24 @@ describe("loadConfig", () => {
61
61
  });
62
62
  });
63
63
 
64
+ it("defaults timeoutSeconds to 120 when omitted", () => {
65
+ withTempDir((dir) => {
66
+ const tddDir = join(dir, ".pi", "tdd");
67
+ mkdirSync(tddDir, { recursive: true });
68
+ writeFileSync(
69
+ join(tddDir, "rules.json"),
70
+ JSON.stringify({
71
+ blockedInRed: ["tests/**/*.test.ts"],
72
+ blockedInGreen: ["src/**/*.ts"],
73
+ testCommands: ["npm test"],
74
+ }),
75
+ "utf-8",
76
+ );
77
+ const config = loadConfig(dir);
78
+ expect(config.timeoutSeconds).toBe(120);
79
+ });
80
+ });
81
+
64
82
  describe("validation — throws on invalid content", () => {
65
83
  it("throws when blockedInRed is not an array", () => {
66
84
  withTempDir((dir) => {
@@ -172,5 +190,56 @@ describe("loadConfig", () => {
172
190
  expect(() => loadConfig(dir)).toThrow();
173
191
  });
174
192
  });
193
+
194
+ it("throws when blockedInRed contains non-strings", () => {
195
+ withTempDir((dir) => {
196
+ const tddDir = join(dir, ".pi", "tdd");
197
+ mkdirSync(tddDir, { recursive: true });
198
+ writeFileSync(
199
+ join(tddDir, "rules.json"),
200
+ JSON.stringify({
201
+ blockedInRed: [123],
202
+ blockedInGreen: ["src/**/*.ts"],
203
+ testCommands: ["npm test"],
204
+ }),
205
+ "utf-8",
206
+ );
207
+ expect(() => loadConfig(dir)).toThrow();
208
+ });
209
+ });
210
+
211
+ it("throws when blockedInGreen contains non-strings", () => {
212
+ withTempDir((dir) => {
213
+ const tddDir = join(dir, ".pi", "tdd");
214
+ mkdirSync(tddDir, { recursive: true });
215
+ writeFileSync(
216
+ join(tddDir, "rules.json"),
217
+ JSON.stringify({
218
+ blockedInRed: ["tests/**/*.test.ts"],
219
+ blockedInGreen: [null],
220
+ testCommands: ["npm test"],
221
+ }),
222
+ "utf-8",
223
+ );
224
+ expect(() => loadConfig(dir)).toThrow();
225
+ });
226
+ });
227
+
228
+ it("throws when testCommands contains non-strings", () => {
229
+ withTempDir((dir) => {
230
+ const tddDir = join(dir, ".pi", "tdd");
231
+ mkdirSync(tddDir, { recursive: true });
232
+ writeFileSync(
233
+ join(tddDir, "rules.json"),
234
+ JSON.stringify({
235
+ blockedInRed: ["tests/**/*.test.ts"],
236
+ blockedInGreen: ["src/**/*.ts"],
237
+ testCommands: ["npm test", 456],
238
+ }),
239
+ "utf-8",
240
+ );
241
+ expect(() => loadConfig(dir)).toThrow();
242
+ });
243
+ });
175
244
  });
176
245
  });
package/engine/config.ts CHANGED
@@ -16,15 +16,24 @@ export function loadConfig(projectRoot: string): Config {
16
16
  if (!Array.isArray(parsed.blockedInRed) || parsed.blockedInRed.length === 0) {
17
17
  throw new Error("rules.json: blockedInRed must be a non-empty array");
18
18
  }
19
+ if (!parsed.blockedInRed.every((p: unknown) => typeof p === "string")) {
20
+ throw new Error("rules.json: blockedInRed must contain only strings");
21
+ }
19
22
  if (
20
23
  !Array.isArray(parsed.blockedInGreen) ||
21
24
  parsed.blockedInGreen.length === 0
22
25
  ) {
23
26
  throw new Error("rules.json: blockedInGreen must be a non-empty array");
24
27
  }
28
+ if (!parsed.blockedInGreen.every((p: unknown) => typeof p === "string")) {
29
+ throw new Error("rules.json: blockedInGreen must contain only strings");
30
+ }
25
31
  if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
26
32
  throw new Error("rules.json: testCommands must be a non-empty array");
27
33
  }
34
+ if (!parsed.testCommands.every((p: unknown) => typeof p === "string")) {
35
+ throw new Error("rules.json: testCommands must contain only strings");
36
+ }
28
37
 
29
38
  return {
30
39
  blockedInRed: parsed.blockedInRed,
package/engine/index.ts CHANGED
@@ -17,7 +17,12 @@ export {
17
17
  undoLastCommit,
18
18
  untrackedFiles,
19
19
  } from "./git.js";
20
- export { loadPhaseState, savePhaseState } from "./state.js";
20
+ export { tddLog } from "./log.js";
21
+ export type { AdvanceResult } from "./orchestrate.js";
22
+ export { advancePhase, getStatusInfo, revertPhase } from "./orchestrate.js";
23
+ export { getNudgePrompt } from "./prompts.js";
24
+ export type { TddLoadResult } from "./state.js";
25
+ export { loadPhaseState, loadTddState, savePhaseState } from "./state.js";
21
26
  export { checkGate, getDisallowedChanges, nextPhase } from "./transition.js";
22
27
  export type {
23
28
  Config,
@@ -0,0 +1,171 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { tddLog } from "./log.js";
3
+
4
+ describe("tddLog", () => {
5
+ let mockExistsSync: ReturnType<typeof vi.fn>;
6
+ let mockMkdirSync: ReturnType<typeof vi.fn>;
7
+ let mockAppendFileSync: ReturnType<typeof vi.fn>;
8
+ let mockWriteFileSync: ReturnType<typeof vi.fn>;
9
+ let mockReadFileSync: ReturnType<typeof vi.fn>;
10
+ let writtenContent: string;
11
+
12
+ function makeDeps() {
13
+ return {
14
+ existsSync: mockExistsSync,
15
+ mkdirSync: mockMkdirSync,
16
+ appendFileSync: mockAppendFileSync,
17
+ writeFileSync: mockWriteFileSync,
18
+ readFileSync: mockReadFileSync,
19
+ };
20
+ }
21
+
22
+ beforeEach(() => {
23
+ writtenContent = "";
24
+ mockExistsSync = vi.fn().mockReturnValue(true);
25
+ mockMkdirSync = vi.fn();
26
+ mockAppendFileSync = vi.fn((_path: string, content: string) => {
27
+ writtenContent += content;
28
+ });
29
+ mockWriteFileSync = vi.fn((_path: string, content: string) => {
30
+ writtenContent = content;
31
+ });
32
+ mockReadFileSync = vi.fn(() => writtenContent);
33
+ });
34
+
35
+ it("appends log line with timestamp and level", () => {
36
+ tddLog("/tdd", "INFO", "hello", undefined, makeDeps());
37
+
38
+ expect(mockAppendFileSync).toHaveBeenCalledOnce();
39
+ const written = mockAppendFileSync.mock.calls[0][1] as string;
40
+ expect(written).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
41
+ expect(written).toContain("[INFO]");
42
+ expect(written).toContain("hello");
43
+ expect(written.endsWith("\n")).toBe(true);
44
+ });
45
+
46
+ it("appends multiple lines", () => {
47
+ tddLog("/tdd", "INFO", "line one", undefined, makeDeps());
48
+ tddLog("/tdd", "DEBUG", "line two", undefined, makeDeps());
49
+
50
+ const lines = writtenContent.trim().split("\n");
51
+ expect(lines).toHaveLength(2);
52
+ expect(lines[0]).toContain("[INFO]");
53
+ expect(lines[0]).toContain("line one");
54
+ expect(lines[1]).toContain("[DEBUG]");
55
+ expect(lines[1]).toContain("line two");
56
+ expect(mockAppendFileSync).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it("includes data as JSON when provided", () => {
60
+ tddLog("/tdd", "INFO", "with data", { key: "val", num: 42 }, makeDeps());
61
+
62
+ expect(writtenContent).toContain('{"key":"val","num":42}');
63
+ });
64
+
65
+ it("includes data after message separated by a space", () => {
66
+ tddLog("/tdd", "INFO", "msg", { a: 1 }, makeDeps());
67
+
68
+ const line = writtenContent.trim();
69
+ expect(line).toContain('msg {"a":1}');
70
+ });
71
+
72
+ it("trims to last 1000 lines when exceeded", () => {
73
+ for (let i = 0; i < 1005; i++) {
74
+ tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
75
+ }
76
+
77
+ const lines = writtenContent.trim().split("\n");
78
+ expect(lines).toHaveLength(1000);
79
+ expect(lines[0]).toContain("line 5");
80
+ expect(lines[999]).toContain("line 1004");
81
+ });
82
+
83
+ it("does not throw when appendFileSync fails", () => {
84
+ mockAppendFileSync = vi.fn(() => {
85
+ throw new Error("ENOENT: no such file or directory");
86
+ });
87
+
88
+ expect(() =>
89
+ tddLog("/nonexistent/path/tdd", "INFO", "fail", undefined, makeDeps()),
90
+ ).not.toThrow();
91
+ });
92
+
93
+ it("does not throw when readFileSync fails (first write)", () => {
94
+ mockReadFileSync = vi.fn(() => {
95
+ throw new Error("ENOENT: no such file or directory");
96
+ });
97
+
98
+ expect(() =>
99
+ tddLog("/tdd", "INFO", "hello", undefined, makeDeps()),
100
+ ).not.toThrow();
101
+ expect(mockAppendFileSync).toHaveBeenCalled();
102
+ });
103
+
104
+ it("handles undefined data gracefully", () => {
105
+ tddLog("/tdd", "WARN", "no data", undefined, makeDeps());
106
+
107
+ expect(writtenContent).toContain("[WARN]");
108
+ expect(writtenContent).toContain("no data");
109
+ expect(writtenContent).not.toContain("{}");
110
+ });
111
+
112
+ it("logs at WARN level", () => {
113
+ tddLog("/tdd", "WARN", "warning message", undefined, makeDeps());
114
+ expect(writtenContent).toContain("[WARN]");
115
+ });
116
+
117
+ it("logs at ERROR level", () => {
118
+ tddLog("/tdd", "ERROR", "error message", undefined, makeDeps());
119
+ expect(writtenContent).toContain("[ERROR]");
120
+ });
121
+
122
+ it("logs at DEBUG level", () => {
123
+ tddLog("/tdd", "DEBUG", "debug message", undefined, makeDeps());
124
+ expect(writtenContent).toContain("[DEBUG]");
125
+ });
126
+
127
+ it("writes to the path join(tddDir, 'tdd.log')", () => {
128
+ tddLog("/custom/tdd/path", "INFO", "test", undefined, makeDeps());
129
+
130
+ expect(mockAppendFileSync).toHaveBeenCalledWith(
131
+ "/custom/tdd/path/tdd.log",
132
+ expect.any(String),
133
+ "utf-8",
134
+ );
135
+ });
136
+
137
+ it("trims to exactly 1000 lines keeping newest", () => {
138
+ for (let i = 0; i < 1001; i++) {
139
+ tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
140
+ }
141
+
142
+ const lines = writtenContent.trim().split("\n");
143
+ expect(lines).toHaveLength(1000);
144
+ expect(lines[0]).toContain("line 1");
145
+ expect(lines[999]).toContain("line 1000");
146
+ });
147
+
148
+ it("keeps all lines when exactly at MAX_LINES", () => {
149
+ for (let i = 0; i < 1000; i++) {
150
+ tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
151
+ }
152
+
153
+ const lines = writtenContent.trim().split("\n");
154
+ expect(lines).toHaveLength(1000);
155
+ expect(lines[0]).toContain("line 0");
156
+ expect(lines[999]).toContain("line 999");
157
+ });
158
+
159
+ it("appends to existing content instead of overwriting", () => {
160
+ tddLog("/tdd", "INFO", "first", undefined, makeDeps());
161
+ tddLog("/tdd", "INFO", "second", undefined, makeDeps());
162
+
163
+ expect(writtenContent).toContain("first");
164
+ expect(writtenContent).toContain("second");
165
+ });
166
+
167
+ it("uses the real filesystem deps by default", () => {
168
+ // Just verify the function exists and can be called without deps
169
+ expect(typeof tddLog).toBe("function");
170
+ });
171
+ });