tdd-enforcer 0.3.3 → 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(
@@ -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: {},
@@ -293,11 +246,7 @@ export async function executeTddStatus(
293
246
  throw new Error(`TDD: ${result.reason}`);
294
247
  }
295
248
  const { state, config } = result;
296
- const enabledStr = state.enabled ? "enabled" : "disabled";
297
- const phaseStr = state.current.toUpperCase();
298
- const redBlk = config.blockedInRed.join(", ") || "(none)";
299
- const greenBlk = config.blockedInGreen.join(", ") || "(none)";
300
- const commands = config.testCommands.join(", ") || "(none)";
249
+ const info = getStatusInfo(state, config);
301
250
 
302
251
  deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
303
252
  enabled: state.enabled,
@@ -305,17 +254,7 @@ export async function executeTddStatus(
305
254
  });
306
255
 
307
256
  return {
308
- content: [
309
- {
310
- type: "text",
311
- text:
312
- `\nTDD enforcer ${enabledStr}\n` +
313
- `Current phase: ${phaseStr}\n` +
314
- `Blocked in RED: ${redBlk}\n` +
315
- `Blocked in GREEN: ${greenBlk}\n` +
316
- `Test commands: ${commands}`,
317
- },
318
- ],
257
+ content: [{ type: "text", text: `\n${info}` }],
319
258
  details: {
320
259
  enabled: state.enabled,
321
260
  phase: state.current,
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
+ });