tdd-enforcer 0.1.5 → 0.1.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/DESIGN.md CHANGED
@@ -20,7 +20,7 @@ RED ──(tests fail)──► GREEN ──(tests pass)──► REFACTOR ─
20
20
 
21
21
  ### `previous_tdd_phase`
22
22
 
23
- Reverts to the previous snapshot. Pops the phase stack, restores working tree to exact prior state. No gate checks — just revert. Must have clear warning in the tool schema that this will revert all changes in the current state.
23
+ Reverts to the previous snapshot by parsing the phase label from the last git snapshot commit. Restores working tree to exact prior state. No gate checks — just revert. Must have clear warning in the tool schema that this will revert all changes in the current state.
24
24
 
25
25
  ---
26
26
 
@@ -57,7 +57,7 @@ A separate git repository at `.pi/tdd/.git/` that tracks the project root as its
57
57
  ```
58
58
  .pi/tdd/
59
59
  ├── .gitignore # private git — excludes file patterns from snapshots
60
- ├── phase.json # {current: "red", stack: ["s1","s2","s3"]}
60
+ ├── state.json # {current: "red", enabled: true}
61
61
  ├── rules.json # user config
62
62
  └── .git/ # private git — init with --git-dir
63
63
  ```
@@ -75,7 +75,7 @@ A separate git repository at `.pi/tdd/.git/` that tracks the project root as its
75
75
 
76
76
  **On `previous_tdd_phase` (revert):**
77
77
  - `git restore --source=<prev-commit> --worktree -- .` — restores project to exact prior snapshot
78
- - Pop the stack in `phase.json`
78
+ - Pop the last snapshot commit in the private git repo
79
79
 
80
80
  ### Benefits of private git
81
81
 
@@ -147,17 +147,16 @@ If the user wants to exclude additional files from TDD snapshots only, they can
147
147
 
148
148
  ## Phase State Persistence
149
149
 
150
- Stored in `.pi/tdd/phase.json`:
150
+ Stored in `.pi/tdd/state.json`:
151
151
 
152
152
  ```json
153
153
  {
154
154
  "current": "green",
155
- "enabled": true,
156
- "stack": ["commit-hash-1", "commit-hash-2", "commit-hash-3"]
155
+ "enabled": true
157
156
  }
158
157
  ```
159
158
 
160
- The git commit hashes anchor each snapshot. `previous_tdd_phase` restores to the parent of the latest commit and pops the stack. Survives session restarts and extension reloads.
159
+ The snapshot history lives in the private git repo's commit log no explicit stack array needed. `previous_tdd_phase` reads the phase label from the HEAD commit message and restores to the parent. Survives session restarts and extension reloads.
161
160
 
162
161
  ---
163
162
 
@@ -44,7 +44,7 @@ describe("loadTddState", () => {
44
44
  });
45
45
  });
46
46
 
47
- it("returns missing phase.json when rules exists but phase missing", () => {
47
+ it("returns missing state.json when rules exists but state missing", () => {
48
48
  withTempDir((dir) => {
49
49
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
50
50
  writeFileSync(
@@ -54,11 +54,11 @@ describe("loadTddState", () => {
54
54
  );
55
55
  const result = loadTddState(dir);
56
56
  expect(result.ok).toBe(false);
57
- expect(result.reason).toContain("phase.json");
57
+ expect(result.reason).toContain("state.json");
58
58
  });
59
59
  });
60
60
 
61
- it("returns invalid phase.json error for malformed JSON", () => {
61
+ it("returns invalid state.json error for malformed JSON", () => {
62
62
  withTempDir((dir) => {
63
63
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
64
64
  writeFileSync(
@@ -66,10 +66,10 @@ describe("loadTddState", () => {
66
66
  JSON.stringify(validRules),
67
67
  "utf-8",
68
68
  );
69
- writeFileSync(join(dir, ".pi", "tdd", "phase.json"), "not json", "utf-8");
69
+ writeFileSync(join(dir, ".pi", "tdd", "state.json"), "not json", "utf-8");
70
70
  const result = loadTddState(dir);
71
71
  expect(result.ok).toBe(false);
72
- expect(result.reason).toContain("Invalid .pi/tdd/phase.json");
72
+ expect(result.reason).toContain("Invalid .pi/tdd/state.json");
73
73
  });
74
74
  });
75
75
 
@@ -82,7 +82,7 @@ describe("loadTddState", () => {
82
82
  "utf-8",
83
83
  );
84
84
  writeFileSync(
85
- join(dir, ".pi", "tdd", "phase.json"),
85
+ join(dir, ".pi", "tdd", "state.json"),
86
86
  JSON.stringify(validPhase),
87
87
  "utf-8",
88
88
  );
@@ -92,7 +92,7 @@ describe("loadTddState", () => {
92
92
  });
93
93
  });
94
94
 
95
- it("returns disabled error when phase.json has enabled: false", () => {
95
+ it("returns disabled error when state.json has enabled: false", () => {
96
96
  withTempDir((dir) => {
97
97
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
98
98
  writeFileSync(
@@ -101,7 +101,7 @@ describe("loadTddState", () => {
101
101
  "utf-8",
102
102
  );
103
103
  writeFileSync(
104
- join(dir, ".pi", "tdd", "phase.json"),
104
+ join(dir, ".pi", "tdd", "state.json"),
105
105
  JSON.stringify({ enabled: false, current: "red" }),
106
106
  "utf-8",
107
107
  );
@@ -120,7 +120,7 @@ describe("loadTddState", () => {
120
120
  "utf-8",
121
121
  );
122
122
  writeFileSync(
123
- join(dir, ".pi", "tdd", "phase.json"),
123
+ join(dir, ".pi", "tdd", "state.json"),
124
124
  JSON.stringify(validPhase),
125
125
  "utf-8",
126
126
  );
@@ -144,7 +144,7 @@ describe("loadTddState", () => {
144
144
  "utf-8",
145
145
  );
146
146
  writeFileSync(
147
- join(dir, ".pi", "tdd", "phase.json"),
147
+ join(dir, ".pi", "tdd", "state.json"),
148
148
  JSON.stringify(validPhase),
149
149
  "utf-8",
150
150
  );
@@ -169,7 +169,7 @@ describe("loadTddState", () => {
169
169
  "utf-8",
170
170
  );
171
171
  writeFileSync(
172
- join(dir, ".pi", "tdd", "phase.json"),
172
+ join(dir, ".pi", "tdd", "state.json"),
173
173
  JSON.stringify(validPhase),
174
174
  "utf-8",
175
175
  );
@@ -182,7 +182,7 @@ describe("loadTddState", () => {
182
182
  });
183
183
  });
184
184
 
185
- it("passes through the phase.json validation error for invalid current phase", () => {
185
+ it("passes through the state.json validation error for invalid current phase", () => {
186
186
  withTempDir((dir) => {
187
187
  mkdirSync(join(dir, ".pi", "tdd"), { recursive: true });
188
188
  writeFileSync(
@@ -191,13 +191,13 @@ describe("loadTddState", () => {
191
191
  "utf-8",
192
192
  );
193
193
  writeFileSync(
194
- join(dir, ".pi", "tdd", "phase.json"),
194
+ join(dir, ".pi", "tdd", "state.json"),
195
195
  JSON.stringify({ enabled: true, current: "blurple" }),
196
196
  "utf-8",
197
197
  );
198
198
  const result = loadTddState(dir);
199
199
  expect(result.ok).toBe(false);
200
- expect(result.reason).toContain("Invalid .pi/tdd/phase.json");
200
+ expect(result.reason).toContain("Invalid .pi/tdd/state.json");
201
201
  });
202
202
  });
203
203
  });
@@ -23,16 +23,16 @@ export function loadTddState(root: string): TddLoadResult {
23
23
  return { ok: false, reason: "Missing .pi/tdd/rules.json. See the tdd-init skill to learn how to set up TDD configs." };
24
24
  }
25
25
 
26
- const phasePath = join(tddDir, "phase.json");
26
+ const phasePath = join(tddDir, "state.json");
27
27
  if (!existsSync(phasePath)) {
28
- return { ok: false, reason: "Missing .pi/tdd/phase.json. See the tdd-init skill to learn how to set up TDD configs." };
28
+ return { ok: false, reason: "Missing .pi/tdd/state.json. See the tdd-init skill to learn how to set up TDD configs." };
29
29
  }
30
30
 
31
31
  let state: PhaseState;
32
32
  try {
33
33
  state = loadPhaseState(root);
34
34
  } catch (e) {
35
- return { ok: false, reason: `Invalid .pi/tdd/phase.json: ${(e as Error).message}` };
35
+ return { ok: false, reason: `Invalid .pi/tdd/state.json: ${(e as Error).message}` };
36
36
  }
37
37
 
38
38
  let config: Config;
@@ -43,7 +43,7 @@ export function loadTddState(root: string): TddLoadResult {
43
43
  }
44
44
 
45
45
  if (!state.enabled) {
46
- return { ok: false, reason: "TDD is not enabled. Run /tdd to enable it." };
46
+ return { ok: false, reason: "TDD is not enabled. Run /tdd:on to enable it." };
47
47
  }
48
48
 
49
49
  // Heal git if missing
@@ -2,10 +2,13 @@ import { join, relative } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { isToolCallEventType, isBashToolResult } from "@earendil-works/pi-coding-agent";
4
4
  import { isAllowed } from "../../engine/enforce.js";
5
- import { changesSinceSnapshot } from "../../engine/git.js";
5
+ import { changesSince, restoreFilesTo, gitStashCreate } from "../../engine/git.js";
6
6
  import { loadTddState } from "./helpers.js";
7
7
  import { tddLog } from "./log.js";
8
8
 
9
+ // Correlates tool_call → tool_result for per-command bash diff
10
+ const preBashStashes = new Map<string, string>();
11
+
9
12
  export function registerHooks(pi: ExtensionAPI): void {
10
13
  pi.on("tool_call", async (event, ctx: ExtensionContext) => {
11
14
  const root = ctx.cwd;
@@ -22,6 +25,24 @@ export function registerHooks(pi: ExtensionAPI): void {
22
25
  const { state, config } = tdd;
23
26
  const phase = state.current;
24
27
 
28
+ // Bash: stash pre-command state for per-command diff later
29
+ if ((event as any).toolName === "bash") {
30
+ try {
31
+ const hash = gitStashCreate(root);
32
+ preBashStashes.set(event.toolCallId, hash);
33
+ tddLog(tddDir, "DEBUG", "tool_call: bash pre-stash created", {
34
+ toolCallId: event.toolCallId,
35
+ hash,
36
+ });
37
+ } catch (e) {
38
+ tddLog(tddDir, "ERROR", "tool_call: bash pre-stash failed", {
39
+ toolCallId: event.toolCallId,
40
+ error: (e as Error).message,
41
+ });
42
+ }
43
+ return;
44
+ }
45
+
25
46
  let filePath: string | undefined;
26
47
  let toolName: string | undefined;
27
48
  if (isToolCallEventType("write", event)) {
@@ -69,6 +90,17 @@ export function registerHooks(pi: ExtensionAPI): void {
69
90
 
70
91
  const root = ctx.cwd;
71
92
  const tddDir = join(root, ".pi", "tdd");
93
+
94
+ // Get the pre-bash stash for this tool call
95
+ const stashHash = preBashStashes.get(event.toolCallId);
96
+ preBashStashes.delete(event.toolCallId);
97
+ if (!stashHash) {
98
+ tddLog(tddDir, "WARN", "tool_result: no pre-bash stash found", {
99
+ toolCallId: event.toolCallId,
100
+ });
101
+ return;
102
+ }
103
+
72
104
  const tdd = loadTddState(root);
73
105
  if (!tdd.ok) {
74
106
  tddLog(tddDir, "WARN", "tool_result: TDD not active, bash passes through", {
@@ -84,14 +116,16 @@ export function registerHooks(pi: ExtensionAPI): void {
84
116
  return;
85
117
  }
86
118
 
87
- const changed = changesSinceSnapshot(root);
119
+ // Diff against pre-bash stash — only changes from THIS command
120
+ const changed = changesSince(root, stashHash);
121
+
88
122
  if (changed.length === 0) {
89
- tddLog(tddDir, "DEBUG", "tool_result: no changes since snapshot");
123
+ tddLog(tddDir, "DEBUG", "tool_result: no changes in this bash command");
90
124
  return;
91
125
  }
92
126
 
93
- const violations = changed.filter((f) => !isAllowed(f, phase, config));
94
- if (violations.length === 0) {
127
+ const cmdViolations = changed.filter((f) => !isAllowed(f, phase, config));
128
+ if (cmdViolations.length === 0) {
95
129
  tddLog(tddDir, "DEBUG", "tool_result: no violations among changed files", {
96
130
  changed,
97
131
  });
@@ -100,20 +134,29 @@ export function registerHooks(pi: ExtensionAPI): void {
100
134
 
101
135
  tddLog(tddDir, "WARN", "tool_result: locked files modified by bash", {
102
136
  phase,
103
- violations,
137
+ violations: cmdViolations,
104
138
  });
105
139
 
140
+ // Revert only this command's violations back to pre-bash state
141
+ restoreFilesTo(root, cmdViolations, stashHash);
142
+
143
+ // Find remaining allowed changes from this command
144
+ const cmdAllowed = changed.filter((f) => isAllowed(f, phase, config));
145
+
106
146
  const existingText = event.content.map((c) => ("text" in c ? c.text : "")).join("");
147
+ let warning = `\n\n⛔ ${phase.toUpperCase()}: reverted locked files modified by bash:`;
148
+ cmdViolations.forEach((f) => (warning += `\n - ${f}`));
149
+ if (cmdAllowed.length > 0) {
150
+ warning += `\n\nAllowed changes retained:`;
151
+ cmdAllowed.forEach((f) => (warning += `\n - ${f}`));
152
+ }
153
+
107
154
  return {
108
155
  isError: true,
109
156
  content: [
110
157
  {
111
158
  type: "text",
112
- text:
113
- existingText +
114
- `\n\n⛔ ${phase.toUpperCase()}: bash modified locked files: ${violations.join(", ")}\n` +
115
- `next_tdd_phase blocked until reverted. ` +
116
- `Inspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
159
+ text: existingText + warning,
117
160
  },
118
161
  ],
119
162
  };
@@ -29,10 +29,10 @@ export default function (pi: ExtensionAPI) {
29
29
  return;
30
30
  }
31
31
 
32
- const phasePath = join(tddDir, "phase.json");
32
+ const phasePath = join(tddDir, "state.json");
33
33
  if (!existsSync(phasePath)) {
34
- tddLog(tddDir, "WARN", "tdd:on: missing phase.json");
35
- ctx.ui.notify("Missing .pi/tdd/phase.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
34
+ tddLog(tddDir, "WARN", "tdd:on: missing state.json");
35
+ ctx.ui.notify("Missing .pi/tdd/state.json. See the tdd-init skill to learn how to set up TDD configs.", "error");
36
36
  return;
37
37
  }
38
38
 
@@ -40,10 +40,10 @@ export default function (pi: ExtensionAPI) {
40
40
  try {
41
41
  state = loadPhaseState(root);
42
42
  } catch (e) {
43
- tddLog(tddDir, "WARN", "tdd:on: invalid phase.json", {
43
+ tddLog(tddDir, "WARN", "tdd:on: invalid state.json", {
44
44
  error: (e as Error).message,
45
45
  });
46
- ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:on again.", "error");
46
+ ctx.ui.notify("Invalid .pi/tdd/state.json. Fix or delete it, then run /tdd:on again.", "error");
47
47
  return;
48
48
  }
49
49
 
@@ -105,10 +105,10 @@ export default function (pi: ExtensionAPI) {
105
105
  try {
106
106
  state = loadPhaseState(root);
107
107
  } catch (e) {
108
- tddLog(tddDir, "WARN", "tdd:off: invalid phase.json", {
108
+ tddLog(tddDir, "WARN", "tdd:off: invalid state.json", {
109
109
  error: (e as Error).message,
110
110
  });
111
- ctx.ui.notify("Invalid .pi/tdd/phase.json. Fix or delete it, then run /tdd:off again.", "error");
111
+ ctx.ui.notify("Invalid .pi/tdd/state.json. Fix or delete it, then run /tdd:off again.", "error");
112
112
  return;
113
113
  }
114
114
 
@@ -1,22 +1,19 @@
1
1
  import type { Phase, Config } from "../../engine/types.js";
2
2
 
3
- function listMatched(files: string[]): string {
4
- if (files.length === 0) return "";
5
- return "\nMatched files: " + files.join(", ");
6
- }
7
-
8
3
  export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: string[]): string {
4
+ const redPatterns = (matchedFiles ?? config.allowedRedPhaseFiles).join(", ");
5
+ const greenPatterns = config.allowedGreenPhaseFiles.join(", ");
6
+
9
7
  switch (phase) {
10
8
  case "red":
11
9
  return (
12
- "You are now in **RED** phase. Write failing tests matching `allowedRedPhaseFiles` patterns. " +
13
- "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN." +
14
- listMatched(matchedFiles ?? config.allowedRedPhaseFiles)
10
+ `You are now in **RED** phase. Write failing tests matching: ${redPatterns}\n` +
11
+ "Only these files can be modified. Once tests fail, call `next_tdd_phase` to proceed to GREEN."
15
12
  );
16
13
  case "green":
17
14
  return (
18
- "You are now in **GREEN** phase. Files matching `allowedRedPhaseFiles` are locked. " +
19
- "Implement features in `allowedGreenPhaseFiles` to make tests pass. " +
15
+ `You are now in **GREEN** phase. Test files (${redPatterns}) are locked.\n` +
16
+ `Implement features matching: ${greenPatterns}\n` +
20
17
  "Call `next_tdd_phase` to proceed to REFACTOR."
21
18
  );
22
19
  case "refactor":
@@ -29,36 +26,4 @@ export function getNudgePrompt(phase: Phase, config: Config, matchedFiles?: stri
29
26
  }
30
27
  }
31
28
 
32
- export function buildPhasePrompt(phase: Phase, config: Config): string {
33
- const redPatterns = config.allowedRedPhaseFiles.join(", ") || "(none)";
34
- const greenPatterns = config.allowedGreenPhaseFiles.join(", ") || "(none)";
35
-
36
- let allowed: string;
37
- let locked: string;
38
29
 
39
- switch (phase) {
40
- case "red":
41
- allowed = `Test files (${redPatterns})`;
42
- locked = `Implementation files (${greenPatterns})`;
43
- break;
44
- case "green":
45
- allowed = `Implementation files (${greenPatterns})`;
46
- locked = `Test files (${redPatterns})`;
47
- break;
48
- case "refactor":
49
- return (
50
- "**TDD phase: REFACTOR** — All files are free to modify. " +
51
- "Do not change behavior. When done, call `next_tdd_phase`."
52
- );
53
- default:
54
- return "";
55
- }
56
-
57
- return (
58
- `**TDD phase: ${phase.toUpperCase()}**\n` +
59
- `- Allowed: ${allowed}\n` +
60
- `- Locked: ${locked}\n` +
61
- "Files not matching either pattern are free to modify.\n" +
62
- "Call `next_tdd_phase` to advance the cycle."
63
- );
64
- }
@@ -38,10 +38,6 @@ export function registerTools(pi: ExtensionAPI): void {
38
38
  const { state, config } = tdd;
39
39
  const from = state.current;
40
40
  const to = nextPhase(from);
41
- if (!to) {
42
- tddLog(tddDir, "WARN", "next_tdd_phase: no next phase", { from });
43
- return { content: [{ type: "text", text: `No next phase from ${from}.` }], details: {} };
44
- }
45
41
 
46
42
  tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
47
43
 
@@ -59,7 +55,7 @@ export function registerTools(pi: ExtensionAPI): void {
59
55
  text:
60
56
  `BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
61
57
  violations.map((f) => ` - ${f}`).join("\n") +
62
- "\nRevert or remove them before proceeding.",
58
+ `\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
63
59
  },
64
60
  ],
65
61
  details: {},
@@ -121,5 +121,56 @@ describe("loadConfig", () => {
121
121
  expect(() => loadConfig(dir)).toThrow();
122
122
  });
123
123
  });
124
+
125
+ it("throws when allowedRedPhaseFiles is empty", () => {
126
+ withTempDir((dir) => {
127
+ const tddDir = join(dir, ".pi", "tdd");
128
+ mkdirSync(tddDir, { recursive: true });
129
+ writeFileSync(
130
+ join(tddDir, "rules.json"),
131
+ JSON.stringify({
132
+ allowedRedPhaseFiles: [],
133
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
134
+ testCommands: ["npm test"],
135
+ }),
136
+ "utf-8",
137
+ );
138
+ expect(() => loadConfig(dir)).toThrow();
139
+ });
140
+ });
141
+
142
+ it("throws when allowedGreenPhaseFiles is empty", () => {
143
+ withTempDir((dir) => {
144
+ const tddDir = join(dir, ".pi", "tdd");
145
+ mkdirSync(tddDir, { recursive: true });
146
+ writeFileSync(
147
+ join(tddDir, "rules.json"),
148
+ JSON.stringify({
149
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
150
+ allowedGreenPhaseFiles: [],
151
+ testCommands: ["npm test"],
152
+ }),
153
+ "utf-8",
154
+ );
155
+ expect(() => loadConfig(dir)).toThrow();
156
+ });
157
+ });
158
+
159
+ it("throws when testCommands is empty", () => {
160
+ withTempDir((dir) => {
161
+ const tddDir = join(dir, ".pi", "tdd");
162
+ mkdirSync(tddDir, { recursive: true });
163
+ writeFileSync(
164
+ join(tddDir, "rules.json"),
165
+ JSON.stringify({
166
+ allowedRedPhaseFiles: ["tests/**/*.test.ts"],
167
+ allowedGreenPhaseFiles: ["src/**/*.ts"],
168
+ testCommands: [],
169
+ }),
170
+ "utf-8",
171
+ );
172
+ expect(() => loadConfig(dir)).toThrow();
173
+ });
174
+ });
124
175
  });
125
176
  });
package/engine/config.ts CHANGED
@@ -13,14 +13,14 @@ export function loadConfig(projectRoot: string): Config {
13
13
  const raw = readFileSync(path, "utf-8");
14
14
  const parsed = JSON.parse(raw);
15
15
 
16
- if (!Array.isArray(parsed.allowedRedPhaseFiles)) {
17
- throw new Error("rules.json: allowedRedPhaseFiles must be an array");
16
+ if (!Array.isArray(parsed.allowedRedPhaseFiles) || parsed.allowedRedPhaseFiles.length === 0) {
17
+ throw new Error("rules.json: allowedRedPhaseFiles must be a non-empty array");
18
18
  }
19
- if (!Array.isArray(parsed.allowedGreenPhaseFiles)) {
20
- throw new Error("rules.json: allowedGreenPhaseFiles must be an array");
19
+ if (!Array.isArray(parsed.allowedGreenPhaseFiles) || parsed.allowedGreenPhaseFiles.length === 0) {
20
+ throw new Error("rules.json: allowedGreenPhaseFiles must be a non-empty array");
21
21
  }
22
- if (!Array.isArray(parsed.testCommands)) {
23
- throw new Error("rules.json: testCommands must be an array");
22
+ if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
23
+ throw new Error("rules.json: testCommands must be a non-empty array");
24
24
  }
25
25
 
26
26
  return {
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
2
  import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFiles, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
5
+ import { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFilesTo, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
6
6
 
7
7
  let testDir: string;
8
8
 
@@ -56,11 +56,11 @@ describe("git operations", () => {
56
56
  expect(changes).toContain("another.ts");
57
57
  });
58
58
 
59
- it("restoreFiles reverts specific files", () => {
59
+ it("restoreFilesTo reverts specific files", () => {
60
60
  writeFileSync(join(testDir, "src", "main.ts"), "// to be reverted", "utf-8");
61
61
  expect(modifiedFiles(testDir)).toContain("src/main.ts");
62
62
 
63
- restoreFiles(testDir, ["src/main.ts"]);
63
+ restoreFilesTo(testDir, ["src/main.ts"]);
64
64
  expect(modifiedFiles(testDir)).not.toContain("src/main.ts");
65
65
  });
66
66
 
@@ -94,9 +94,9 @@ describe("git operations", () => {
94
94
  }
95
95
  });
96
96
 
97
- it("restoreFiles does nothing when files list is empty", () => {
97
+ it("restoreFilesTo does nothing when files list is empty", () => {
98
98
  // Should not throw
99
- expect(() => restoreFiles(testDir, [])).not.toThrow();
99
+ expect(() => restoreFilesTo(testDir, [])).not.toThrow();
100
100
  });
101
101
  });
102
102
 
package/engine/git.ts CHANGED
@@ -61,7 +61,7 @@ export function changesSinceSnapshot(projectRoot: string): string[] {
61
61
  return [...new Set([...modifiedFiles(projectRoot), ...untrackedFiles(projectRoot)])];
62
62
  }
63
63
 
64
- export function restoreFiles(projectRoot: string, files: string[]): void {
64
+ export function restoreFilesTo(projectRoot: string, files: string[], source?: string): void {
65
65
  if (files.length === 0) return;
66
66
 
67
67
  // Separate tracked (git restore) from untracked (delete)
@@ -76,7 +76,8 @@ export function restoreFiles(projectRoot: string, files: string[]): void {
76
76
 
77
77
  if (trackedFiles.length > 0) {
78
78
  const escaped = trackedFiles.map((f) => `"${f}"`).join(" ");
79
- gitExec(`restore -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
79
+ const sourceFlag = source ? `--source=${source} ` : "";
80
+ gitExec(`restore ${sourceFlag}--worktree -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
80
81
  }
81
82
 
82
83
  for (const f of untrackedFiles) {
@@ -88,6 +89,16 @@ export function restoreFiles(projectRoot: string, files: string[]): void {
88
89
  }
89
90
  }
90
91
 
92
+ /**
93
+ * Create a lightweight commit of the current working tree without touching the stash ref.
94
+ * Returns the commit hash. Used as a pre-bash baseline for per-command diff.
95
+ */
96
+ export function gitStashCreate(projectRoot: string): string {
97
+ const hash = gitExec("stash create --include-untracked", projectRoot).trim();
98
+ if (!hash) throw new Error("git stash create returned empty hash");
99
+ return hash;
100
+ }
101
+
91
102
  export function headHash(projectRoot: string): string {
92
103
  return gitExec("rev-parse HEAD", projectRoot).trim();
93
104
  }
@@ -107,6 +118,17 @@ export function hasParent(projectRoot: string): boolean {
107
118
  }
108
119
  }
109
120
 
121
+ /**
122
+ * Get files changed since a specific commit (instead of HEAD).
123
+ */
124
+ export function changesSince(projectRoot: string, commitHash: string): string[] {
125
+ const out = gitExec(`diff --name-only ${commitHash} -- .`, projectRoot).trim();
126
+ const files = out ? out.split("\n") : [];
127
+ // Also include untracked files
128
+ const untracked = untrackedFiles(projectRoot);
129
+ return [...new Set([...files, ...untracked])];
130
+ }
131
+
110
132
  /** Hard reset — discard all uncommitted changes (tracked and untracked), keep HEAD. */
111
133
  export function resetHard(projectRoot: string): void {
112
134
  gitExec("reset --hard", projectRoot, { stdio: "pipe" as const });
package/engine/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { isAllowed, disallowedFiles } from "./enforce.js";
2
- export { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFiles, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
2
+ export { initGit, snapshot, changesSinceSnapshot, changesSince, modifiedFiles, untrackedFiles, restoreFilesTo, gitStashCreate, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
3
3
  export { loadConfig } from "./config.js";
4
4
  export { loadPhaseState, savePhaseState } from "./state.js";
5
5
  export { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
@@ -21,12 +21,12 @@ describe("loadPhaseState", () => {
21
21
  });
22
22
  });
23
23
 
24
- it("returns parsed state from phase.json", () => {
24
+ it("returns parsed state from state.json", () => {
25
25
  withTempDir((dir) => {
26
26
  const tddDir = join(dir, ".pi", "tdd");
27
27
  mkdirSync(tddDir, { recursive: true });
28
28
  writeFileSync(
29
- join(tddDir, "phase.json"),
29
+ join(tddDir, "state.json"),
30
30
  JSON.stringify({ enabled: true, current: "green" }),
31
31
  "utf-8",
32
32
  );
@@ -41,7 +41,7 @@ describe("loadPhaseState", () => {
41
41
  const tddDir = join(dir, ".pi", "tdd");
42
42
  mkdirSync(tddDir, { recursive: true });
43
43
  writeFileSync(
44
- join(tddDir, "phase.json"),
44
+ join(tddDir, "state.json"),
45
45
  JSON.stringify({ enabled: true, current: "blurple" }),
46
46
  "utf-8",
47
47
  );
@@ -54,7 +54,7 @@ describe("loadPhaseState", () => {
54
54
  const tddDir = join(dir, ".pi", "tdd");
55
55
  mkdirSync(tddDir, { recursive: true });
56
56
  writeFileSync(
57
- join(tddDir, "phase.json"),
57
+ join(tddDir, "state.json"),
58
58
  JSON.stringify({ enabled: false, current: "off" }),
59
59
  "utf-8",
60
60
  );
@@ -66,14 +66,14 @@ describe("loadPhaseState", () => {
66
66
  withTempDir((dir) => {
67
67
  const tddDir = join(dir, ".pi", "tdd");
68
68
  mkdirSync(tddDir, { recursive: true });
69
- writeFileSync(join(tddDir, "phase.json"), "not json{{{", "utf-8");
69
+ writeFileSync(join(tddDir, "state.json"), "not json{{{", "utf-8");
70
70
  expect(() => loadPhaseState(dir)).toThrow();
71
71
  });
72
72
  });
73
73
  });
74
74
 
75
75
  describe("savePhaseState", () => {
76
- it("writes phase.json and can be read back", () => {
76
+ it("writes state.json and can be read back", () => {
77
77
  withTempDir((dir) => {
78
78
  savePhaseState(dir, { enabled: true, current: "refactor" });
79
79
  const state = loadPhaseState(dir);
package/engine/state.ts CHANGED
@@ -6,7 +6,7 @@ const TDD_DIR = ".pi/tdd";
6
6
  const VALID_PHASES = new Set(["red", "green", "refactor"]);
7
7
 
8
8
  export function phaseStatePath(projectRoot: string): string {
9
- return join(projectRoot, TDD_DIR, "phase.json");
9
+ return join(projectRoot, TDD_DIR, "state.json");
10
10
  }
11
11
 
12
12
  function ensureDir(path: string): void {
@@ -22,7 +22,7 @@ export function loadPhaseState(projectRoot: string): PhaseState {
22
22
  const parsed = JSON.parse(raw) as PhaseState;
23
23
 
24
24
  if (typeof parsed.current !== "string" || !VALID_PHASES.has(parsed.current)) {
25
- throw new Error(`phase.json: invalid phase "${String(parsed.current)}". Must be red, green, or refactor.`);
25
+ throw new Error(`state.json: invalid phase "${String(parsed.current)}". Must be red, green, or refactor.`);
26
26
  }
27
27
 
28
28
  return {
@@ -42,17 +42,7 @@ const testConfig: Config = {
42
42
  timeoutSeconds: 30,
43
43
  };
44
44
 
45
- const emptyConfig: Config = { ...testConfig, testCommands: [] };
46
-
47
45
  describe("checkGate", () => {
48
- describe("empty test commands (gate skipped)", () => {
49
- it("passes every transition when no test commands configured", async () => {
50
- const r1 = await checkGate("red", "green", makeRunner(false), emptyConfig);
51
- expect(r1.passed).toBe(true);
52
- const r2 = await checkGate("red", "green", makeRunner(true), emptyConfig);
53
- expect(r2.passed).toBe(true);
54
- });
55
- });
56
46
 
57
47
  describe("red → green (tests must fail)", () => {
58
48
  it("allows when tests fail", async () => {
@@ -64,7 +54,7 @@ describe("checkGate", () => {
64
54
  it("blocks when tests pass", async () => {
65
55
  const r = await checkGate("red", "green", makeRunner(true), testConfig);
66
56
  expect(r.passed).toBe(false);
67
- expect(r.message).toMatch(/break a test/i);
57
+ expect(r.message).toMatch(/transitioning to GREEN/i);
68
58
  });
69
59
  });
70
60
 
@@ -78,7 +68,7 @@ describe("checkGate", () => {
78
68
  it("blocks when tests fail", async () => {
79
69
  const r = await checkGate("green", "refactor", makeRunner(false), testConfig);
80
70
  expect(r.passed).toBe(false);
81
- expect(r.message).toMatch(/failing/i);
71
+ expect(r.message).toMatch(/transitioning to REFACTOR/i);
82
72
  });
83
73
  });
84
74
 
@@ -92,17 +82,10 @@ describe("checkGate", () => {
92
82
  it("blocks when tests fail", async () => {
93
83
  const r = await checkGate("refactor", "red", makeRunner(false), testConfig);
94
84
  expect(r.passed).toBe(false);
95
- expect(r.message).toMatch(/failing/i);
85
+ expect(r.message).toMatch(/transitioning to RED/i);
96
86
  });
97
87
  });
98
88
 
99
- describe("unknown transition", () => {
100
- it("blocks with message containing the transition string", async () => {
101
- const r = await checkGate("green", "red" as any, makeRunner(true), testConfig);
102
- expect(r.passed).toBe(false);
103
- expect(r.message).toContain("green→red");
104
- });
105
- });
106
89
 
107
90
  it("passes test commands to the runner", async () => {
108
91
  let captured: string[] | undefined;
@@ -29,10 +29,6 @@ export async function checkGate(
29
29
  testRunner: TestRunner,
30
30
  config: Config,
31
31
  ): Promise<GateResult> {
32
- if (config.testCommands.length === 0) {
33
- return { passed: true, message: "No test commands configured — gate skipped." };
34
- }
35
-
36
32
  const result = await testRunner(config.testCommands, config.timeoutSeconds);
37
33
 
38
34
  switch (`${from}→${to}` as Transition) {
@@ -40,23 +36,29 @@ export async function checkGate(
40
36
  if (result.passed) {
41
37
  return {
42
38
  passed: false,
43
- message: "Tests pass. Break a test first before transitioning to GREEN.",
39
+ message: "Tests pass. Add a failing test before transitioning to GREEN.",
44
40
  };
45
41
  }
46
42
  return { passed: true, message: "Tests fail — proceed to GREEN." };
47
43
 
48
44
  case "green→refactor":
45
+ if (!result.passed) {
46
+ return {
47
+ passed: false,
48
+ message: "Tests fail. Fix them before transitioning to REFACTOR.",
49
+ };
50
+ }
51
+ return { passed: true, message: "All tests pass — proceeding." };
52
+
49
53
  case "refactor→red":
50
54
  if (!result.passed) {
51
55
  return {
52
56
  passed: false,
53
- message: "Tests must pass before transitioning. Fix failing tests first.",
57
+ message: "Tests fail. Fix them before transitioning to RED.",
54
58
  };
55
59
  }
56
60
  return { passed: true, message: "All tests pass — proceeding." };
57
61
 
58
- default:
59
- return { passed: false, message: `Unknown transition: ${from}→${to}` };
60
62
  }
61
63
  }
62
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"