tdd-enforcer 0.1.0 → 0.1.2

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,59 +1,122 @@
1
+ import { join, relative } from "node:path";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { isToolCallEventType, isBashToolResult } from "@earendil-works/pi-coding-agent";
3
4
  import { isAllowed } from "../../engine/enforce.js";
4
5
  import { changesSinceSnapshot, restoreFiles } from "../../engine/git.js";
5
6
  import { loadTddState } from "./helpers.js";
7
+ import { tddLog } from "./log.js";
6
8
 
7
9
  export function registerHooks(pi: ExtensionAPI): void {
8
10
  pi.on("tool_call", async (event, ctx: ExtensionContext) => {
9
11
  const root = ctx.cwd;
12
+ const tddDir = join(root, ".pi", "tdd");
10
13
  const tdd = loadTddState(root);
11
- if (!tdd.ok) return;
14
+ if (!tdd.ok) {
15
+ tddLog(tddDir, "WARN", "tool_call: TDD not active, edit passes through", {
16
+ toolName: (event as any).toolName,
17
+ reason: tdd.reason,
18
+ });
19
+ return;
20
+ }
12
21
 
13
22
  const { state, config } = tdd;
14
23
  const phase = state.current;
15
24
 
16
- // write/edit pre-block
17
25
  let filePath: string | undefined;
18
- if (isToolCallEventType("write", event)) filePath = event.input.path;
19
- else if (isToolCallEventType("edit", event)) filePath = event.input.path;
20
- else return;
26
+ let toolName: string | undefined;
27
+ if (isToolCallEventType("write", event)) {
28
+ toolName = "write";
29
+ filePath = (event as any).input?.path;
30
+ } else if (isToolCallEventType("edit", event)) {
31
+ toolName = "edit";
32
+ filePath = (event as any).input?.path;
33
+ } else {
34
+ tddLog(tddDir, "DEBUG", "tool_call: non-file tool, ignored", {
35
+ toolName: (event as any).toolName,
36
+ });
37
+ return;
38
+ }
39
+
40
+ if (!filePath) {
41
+ tddLog(tddDir, "WARN", "tool_call: no path in input, cannot block", {
42
+ toolName,
43
+ });
44
+ return;
45
+ }
21
46
 
22
- if (!filePath) return;
47
+ // Patterns in rules.json are relative to repo root; convert absolute path
48
+ const relPath = relative(root, filePath);
49
+ const allowed = isAllowed(relPath, phase, config);
50
+ tddLog(tddDir, "DEBUG", "tool_call: check", { toolName, relPath, phase, allowed });
23
51
 
24
- if (!isAllowed(filePath, phase, config)) {
52
+ if (!allowed) {
53
+ tddLog(tddDir, "INFO", "tool_call: blocked file modification", {
54
+ toolName,
55
+ relPath,
56
+ phase,
57
+ });
25
58
  return {
26
59
  block: true,
27
- reason: `TDD ${phase.toUpperCase()}: "${filePath}" is locked in this phase.`,
60
+ reason: `TDD ${phase.toUpperCase()}: "${relPath}" is locked in this phase.`,
28
61
  };
29
62
  }
63
+
64
+ tddLog(tddDir, "DEBUG", "tool_call: allowed", { toolName, relPath, phase });
30
65
  });
31
66
 
32
67
  pi.on("tool_result", async (event, ctx: ExtensionContext) => {
33
68
  if (!isBashToolResult(event)) return;
34
69
 
35
70
  const root = ctx.cwd;
71
+ const tddDir = join(root, ".pi", "tdd");
36
72
  const tdd = loadTddState(root);
37
- if (!tdd.ok) return;
73
+ if (!tdd.ok) {
74
+ tddLog(tddDir, "WARN", "tool_result: TDD not active, bash passes through", {
75
+ reason: tdd.reason,
76
+ });
77
+ return;
78
+ }
38
79
 
39
80
  const { state, config } = tdd;
40
81
  const phase = state.current;
41
- if (phase === "refactor") return;
82
+ if (phase === "refactor") {
83
+ tddLog(tddDir, "DEBUG", "tool_result: refactor phase, no check");
84
+ return;
85
+ }
42
86
 
43
87
  const changed = changesSinceSnapshot(root);
44
- if (changed.length === 0) return;
88
+ if (changed.length === 0) {
89
+ tddLog(tddDir, "DEBUG", "tool_result: no changes since snapshot");
90
+ return;
91
+ }
45
92
 
46
93
  const violations = changed.filter((f) => !isAllowed(f, phase, config));
47
- if (violations.length === 0) return;
94
+ if (violations.length === 0) {
95
+ tddLog(tddDir, "DEBUG", "tool_result: no violations among changed files", {
96
+ changed,
97
+ });
98
+ return;
99
+ }
100
+
101
+ tddLog(tddDir, "INFO", "tool_result: reverting violations", {
102
+ phase,
103
+ violations,
104
+ allChanged: changed,
105
+ });
48
106
 
49
107
  restoreFiles(root, violations);
50
108
 
51
109
  const existingText = event.content.map((c) => ("text" in c ? c.text : "")).join("");
52
110
  return {
53
111
  content: [
54
- { type: "text", text: existingText + `\n\n⚠️ TDD: Bash modified files locked in ${phase.toUpperCase()} phase. Reverted: ${violations.join(", ")}` },
112
+ {
113
+ type: "text",
114
+ text:
115
+ existingText +
116
+ `\n\n⚠️ TDD: Bash modified files locked in ${phase.toUpperCase()} phase. ` +
117
+ `Reverted: ${violations.join(", ")}`,
118
+ },
55
119
  ],
56
120
  };
57
121
  });
58
-
59
122
  }
@@ -5,6 +5,7 @@ import { loadPhaseState, loadConfig, savePhaseState, initGit } from "../../engin
5
5
  import { registerTools } from "./tools.js";
6
6
  import { registerHooks } from "./hooks.js";
7
7
  import { loadTddState } from "./helpers.js";
8
+ import { tddLog } from "./log.js";
8
9
 
9
10
  export default function (pi: ExtensionAPI) {
10
11
  pi.registerCommand("tdd:on", {
@@ -13,19 +14,24 @@ export default function (pi: ExtensionAPI) {
13
14
  const root = ctx.cwd;
14
15
  const tddDir = join(root, ".pi", "tdd");
15
16
 
17
+ tddLog(tddDir, "INFO", "tdd:on: starting");
18
+
16
19
  if (!existsSync(tddDir)) {
20
+ tddLog(tddDir, "WARN", "tdd:on: missing .pi/tdd/ directory");
17
21
  ctx.ui.notify("Missing .pi/tdd/ directory. See the tdd-init skill to learn how to set up TDD configs.", "error");
18
22
  return;
19
23
  }
20
24
 
21
25
  const rulesPath = join(tddDir, "rules.json");
22
26
  if (!existsSync(rulesPath)) {
27
+ tddLog(tddDir, "WARN", "tdd:on: missing rules.json");
23
28
  ctx.ui.notify("Missing .pi/tdd/rules.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
24
29
  return;
25
30
  }
26
31
 
27
32
  const phasePath = join(tddDir, "phase.json");
28
33
  if (!existsSync(phasePath)) {
34
+ tddLog(tddDir, "WARN", "tdd:on: missing phase.json");
29
35
  ctx.ui.notify("Missing .pi/tdd/phase.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
30
36
  return;
31
37
  }
@@ -33,19 +39,28 @@ export default function (pi: ExtensionAPI) {
33
39
  let state;
34
40
  try {
35
41
  state = loadPhaseState(root);
36
- } catch {
42
+ } catch (e) {
43
+ tddLog(tddDir, "WARN", "tdd:on: invalid phase.json", {
44
+ error: (e as Error).message,
45
+ });
37
46
  ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:on again.", "error");
38
47
  return;
39
48
  }
40
49
 
41
50
  try {
42
51
  loadConfig(root);
43
- } catch {
52
+ } catch (e) {
53
+ tddLog(tddDir, "WARN", "tdd:on: invalid rules.json", {
54
+ error: (e as Error).message,
55
+ });
44
56
  ctx.ui.notify("Invalid .pi/tdd/rules.json. Fix or delete it, then run /tdd:on again.", "error");
45
57
  return;
46
58
  }
47
59
 
48
60
  if (state.enabled) {
61
+ tddLog(tddDir, "INFO", "tdd:on: already enabled", {
62
+ phase: state.current,
63
+ });
49
64
  ctx.ui.notify(`TDD already enabled — ${state.current.toUpperCase()} phase`, "info");
50
65
  return;
51
66
  }
@@ -53,14 +68,23 @@ export default function (pi: ExtensionAPI) {
53
68
  if (!existsSync(join(tddDir, ".git", "HEAD"))) {
54
69
  try {
55
70
  initGit(root);
56
- } catch {
71
+ tddLog(tddDir, "INFO", "tdd:on: git initialised");
72
+ } catch (e) {
73
+ tddLog(tddDir, "ERROR", "tdd:on: git init failed", {
74
+ error: (e as Error).message,
75
+ });
57
76
  ctx.ui.notify("Failed to initialise private git repo.", "error");
58
77
  return;
59
78
  }
79
+ } else {
80
+ tddLog(tddDir, "DEBUG", "tdd:on: git repo already exists");
60
81
  }
61
82
 
62
83
  state.enabled = true;
63
84
  savePhaseState(root, state);
85
+ tddLog(tddDir, "INFO", "tdd:on: enabled", {
86
+ phase: state.current,
87
+ });
64
88
  ctx.ui.notify(`TDD enabled — ${state.current.toUpperCase()} phase`, "info");
65
89
  },
66
90
  });
@@ -69,22 +93,30 @@ export default function (pi: ExtensionAPI) {
69
93
  description: "Disable TDD enforcement",
70
94
  handler: async (_args: string, ctx: ExtensionContext) => {
71
95
  const root = ctx.cwd;
96
+ const tddDir = join(root, ".pi", "tdd");
72
97
 
73
98
  let state;
74
99
  try {
75
100
  state = loadPhaseState(root);
76
- } catch {
101
+ } catch (e) {
102
+ tddLog(tddDir, "WARN", "tdd:off: invalid phase.json", {
103
+ error: (e as Error).message,
104
+ });
77
105
  ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:off again.", "error");
78
106
  return;
79
107
  }
80
108
 
81
109
  if (!state.enabled) {
110
+ tddLog(tddDir, "INFO", "tdd:off: already disabled");
82
111
  ctx.ui.notify("TDD already disabled", "info");
83
112
  return;
84
113
  }
85
114
 
86
115
  state.enabled = false;
87
116
  savePhaseState(root, state);
117
+ tddLog(tddDir, "INFO", "tdd:off: disabled", {
118
+ was: state.current,
119
+ });
88
120
  ctx.ui.notify("TDD disabled", "info");
89
121
  },
90
122
  });
@@ -93,9 +125,13 @@ export default function (pi: ExtensionAPI) {
93
125
  description: "Show TDD enforcement status",
94
126
  handler: async (_args: string, ctx: ExtensionContext) => {
95
127
  const root = ctx.cwd;
128
+ const tddDir = join(root, ".pi", "tdd");
96
129
  const result = loadTddState(root);
97
130
 
98
131
  if (!result.ok) {
132
+ tddLog(tddDir, "WARN", "tdd:status: TDD not active", {
133
+ reason: result.reason,
134
+ });
99
135
  ctx.ui.notify(`TDD: ${result.reason}`, "error");
100
136
  return;
101
137
  }
@@ -106,6 +142,10 @@ export default function (pi: ExtensionAPI) {
106
142
  const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
107
143
  const commands = config.testCommands.join(", ") || "(none)";
108
144
 
145
+ tddLog(tddDir, "INFO", "tdd:status: queried", {
146
+ phase: state.current,
147
+ });
148
+
109
149
  ctx.ui.notify(
110
150
  `TDD enforcer enabled\n` +
111
151
  `Current phase: ${phaseStr}\n` +
@@ -0,0 +1,30 @@
1
+ import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const MAX_LINES = 1000;
5
+
6
+ export function tddLog(
7
+ tddDir: string,
8
+ level: "INFO" | "WARN" | "ERROR" | "DEBUG",
9
+ msg: string,
10
+ data?: Record<string, unknown>,
11
+ ): void {
12
+ try {
13
+ const logPath = join(tddDir, "tdd.log");
14
+ const timestamp = new Date().toISOString();
15
+ const dataStr = data !== undefined ? ` ${JSON.stringify(data)}` : "";
16
+ const line = `[${timestamp}] [${level}] ${msg}${dataStr}\n`;
17
+
18
+ appendFileSync(logPath, line, "utf-8");
19
+
20
+ // Trim to last MAX_LINES
21
+ const content = readFileSync(logPath, "utf-8");
22
+ const lines = content.split("\n");
23
+ if (lines.length > MAX_LINES + 1) {
24
+ const trimmed = lines.slice(-MAX_LINES).join("\n") + "\n";
25
+ writeFileSync(logPath, trimmed, "utf-8");
26
+ }
27
+ } catch {
28
+ // Logging never throws — fail silently
29
+ }
30
+ }
@@ -27,8 +27,10 @@ export function registerTools(pi: ExtensionAPI): void {
27
27
  parameters: Type.Object({}),
28
28
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
29
29
  const root = ctx.cwd;
30
+ const tddDir = join(root, ".pi", "tdd");
30
31
  const tdd = loadTddState(root);
31
32
  if (!tdd.ok) {
33
+ tddLog(tddDir, "WARN", "next_tdd_phase: TDD not active", { reason: tdd.reason });
32
34
  return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
33
35
  }
34
36
 
@@ -36,12 +38,19 @@ export function registerTools(pi: ExtensionAPI): void {
36
38
  const from = state.current;
37
39
  const to = nextPhase(from);
38
40
  if (!to) {
41
+ tddLog(tddDir, "WARN", "next_tdd_phase: no next phase", { from });
39
42
  return { content: [{ type: "text", text: `No next phase from ${from}.` }], details: {} };
40
43
  }
41
44
 
45
+ tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
46
+
42
47
  // 1. Allowlist check
43
48
  const violations = getDisallowedChanges(root, from, config);
44
49
  if (violations.length > 0) {
50
+ tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
51
+ from,
52
+ violations,
53
+ });
45
54
  return {
46
55
  content: [
47
56
  {
@@ -80,16 +89,29 @@ export function registerTools(pi: ExtensionAPI): void {
80
89
  };
81
90
 
82
91
  const gate = await checkGate(from, to, testRunner, config);
92
+ tddLog(tddDir, "DEBUG", "next_tdd_phase: gate result", {
93
+ from,
94
+ to,
95
+ passed: gate.passed,
96
+ message: gate.message,
97
+ });
98
+
83
99
  if (!gate.passed) {
84
100
  return { content: [{ type: "text", text: gate.message }], details: {} };
85
101
  }
86
102
 
87
103
  // 3. Snapshot — label with the phase the work was done in
88
- snapshot(root, from);
104
+ const hash = snapshot(root, from);
105
+ tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
106
+ from,
107
+ to,
108
+ hash,
109
+ });
89
110
 
90
111
  // 4. Save state
91
112
  state.current = to;
92
113
  savePhaseState(root, state);
114
+ tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
93
115
 
94
116
  return {
95
117
  content: [{ type: "text", text: getNudgePrompt(to, config) }],
@@ -107,14 +129,21 @@ export function registerTools(pi: ExtensionAPI): void {
107
129
  parameters: Type.Object({}),
108
130
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
109
131
  const root = ctx.cwd;
132
+ const tddDir = join(root, ".pi", "tdd");
110
133
  const tdd = loadTddState(root);
111
134
  if (!tdd.ok) {
135
+ tddLog(tddDir, "WARN", "previous_tdd_phase: TDD not active", {
136
+ reason: tdd.reason,
137
+ });
112
138
  return { content: [{ type: "text", text: `TDD: ${tdd.reason}` }], details: {} };
113
139
  }
114
140
 
115
141
  const { state } = tdd;
116
142
 
117
143
  if (!hasParent(root)) {
144
+ tddLog(tddDir, "WARN", "previous_tdd_phase: no parent commit", {
145
+ phase: state.current,
146
+ });
118
147
  return {
119
148
  content: [{ type: "text", text: "No previous phase to revert to." }],
120
149
  details: {},
@@ -127,6 +156,9 @@ export function registerTools(pi: ExtensionAPI): void {
127
156
  const headMsg = headMessage(root);
128
157
  const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
129
158
  if (!phaseMatch) {
159
+ tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
160
+ headMsg,
161
+ });
130
162
  return {
131
163
  content: [
132
164
  {
@@ -140,16 +172,26 @@ export function registerTools(pi: ExtensionAPI): void {
140
172
  };
141
173
  }
142
174
  const prevPhase = phaseMatch[1] as Phase;
175
+ tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
176
+ from: state.current,
177
+ to: prevPhase,
178
+ headMsg,
179
+ });
143
180
 
144
181
  // 1. Nuke any uncommitted changes, WT matches HEAD
145
182
  resetHard(root);
183
+ tddLog(tddDir, "DEBUG", "previous_tdd_phase: resetHard done");
146
184
 
147
185
  // 2. Pop last snapshot commit, keep its content as unstaged
148
186
  undoLastCommit(root);
187
+ tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
149
188
 
150
189
  // 3. Update phase label from the snapshot's own label
151
190
  state.current = prevPhase;
152
191
  savePhaseState(root, state);
192
+ tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
193
+ to: prevPhase,
194
+ });
153
195
 
154
196
  return {
155
197
  content: [
@@ -162,4 +204,56 @@ export function registerTools(pi: ExtensionAPI): void {
162
204
  };
163
205
  },
164
206
  });
207
+
208
+ pi.registerTool({
209
+ name: "tdd_status",
210
+ label: "TDD Status",
211
+ description:
212
+ "Show the current TDD enforcement status: enabled/disabled, current phase, " +
213
+ "allowed file globs, and test commands.",
214
+ parameters: Type.Object({}),
215
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
216
+ const root = ctx.cwd;
217
+ const tddDir = join(root, ".pi", "tdd");
218
+ const result = loadTddState(root);
219
+
220
+ if (!result.ok) {
221
+ tddLog(tddDir, "WARN", "tdd_status: TDD not active", {
222
+ reason: result.reason,
223
+ });
224
+ return { content: [{ type: "text", text: `TDD: ${result.reason}` }], details: {} };
225
+ }
226
+
227
+ const { state, config } = result;
228
+ const phaseStr = state.current.toUpperCase();
229
+ const redGlobs = config.allowedRedPhaseFiles.join(", ") || "(none)";
230
+ const greenGlobs = config.allowedGreenPhaseFiles.join(", ") || "(none)";
231
+ const commands = config.testCommands.join(", ") || "(none)";
232
+
233
+ tddLog(tddDir, "INFO", "tdd_status: queried", {
234
+ phase: state.current,
235
+ });
236
+
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text:
242
+ `TDD enforcer enabled\n` +
243
+ `Current phase: ${phaseStr}\n` +
244
+ `Test files: ${redGlobs}\n` +
245
+ `Impl files: ${greenGlobs}\n` +
246
+ `Test commands: ${commands}`,
247
+ },
248
+ ],
249
+ details: {
250
+ enabled: true,
251
+ phase: state.current,
252
+ allowedRedPhaseFiles: config.allowedRedPhaseFiles,
253
+ allowedGreenPhaseFiles: config.allowedGreenPhaseFiles,
254
+ testCommands: config.testCommands,
255
+ },
256
+ };
257
+ },
258
+ });
165
259
  }
package/engine/git.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execSync, type ExecSyncOptions } from "node:child_process";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  const TDD_DIR = ".pi/tdd";
@@ -63,8 +63,29 @@ export function changesSinceSnapshot(projectRoot: string): string[] {
63
63
 
64
64
  export function restoreFiles(projectRoot: string, files: string[]): void {
65
65
  if (files.length === 0) return;
66
- const escaped = files.map((f) => `"${f}"`).join(" ");
67
- gitExec(`restore -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
66
+
67
+ // Separate tracked (git restore) from untracked (delete)
68
+ const tracked = gitExec("ls-files", projectRoot)
69
+ .trim()
70
+ .split("\n")
71
+ .filter(Boolean);
72
+ const trackedSet = new Set(tracked);
73
+
74
+ const trackedFiles = files.filter((f) => trackedSet.has(f));
75
+ const untrackedFiles = files.filter((f) => !trackedSet.has(f));
76
+
77
+ if (trackedFiles.length > 0) {
78
+ const escaped = trackedFiles.map((f) => `"${f}"`).join(" ");
79
+ gitExec(`restore -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
80
+ }
81
+
82
+ for (const f of untrackedFiles) {
83
+ try {
84
+ unlinkSync(f);
85
+ } catch {
86
+ // File may already be gone, ignore
87
+ }
88
+ }
68
89
  }
69
90
 
70
91
  export function headHash(projectRoot: string): string {
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
- "keywords": ["pi-package"],
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
6
8
  "dependencies": {
7
9
  "picomatch": "^4.0.4"
8
10
  },
@@ -10,6 +12,8 @@
10
12
  "vitest": "^3"
11
13
  },
12
14
  "pi": {
13
- "extensions": ["./adapters/pi/index.ts"]
15
+ "extensions": [
16
+ "./adapters/pi/index.ts"
17
+ ]
14
18
  }
15
19
  }