tdd-enforcer 0.1.6 → 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.
@@ -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
  };
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tdd-enforcer",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"