tdd-enforcer 0.1.6 → 0.1.8
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/adapters/pi/hooks.ts +54 -11
- package/adapters/pi/tools.ts +5 -2
- package/engine/git.test.ts +5 -5
- package/engine/git.ts +24 -2
- package/engine/index.ts +1 -1
- package/package.json +1 -1
package/adapters/pi/hooks.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
123
|
+
tddLog(tddDir, "DEBUG", "tool_result: no changes in this bash command");
|
|
90
124
|
return;
|
|
91
125
|
}
|
|
92
126
|
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
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
|
};
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import {
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const asyncExec = promisify(exec);
|
|
4
7
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
8
|
import {
|
|
6
9
|
savePhaseState,
|
|
@@ -67,7 +70,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
67
70
|
const results = await Promise.all(
|
|
68
71
|
commands.map(async (cmd) => {
|
|
69
72
|
try {
|
|
70
|
-
|
|
73
|
+
await asyncExec(cmd, { cwd: root, timeout: timeout * 1000 });
|
|
71
74
|
return { command: cmd, passed: true };
|
|
72
75
|
} catch {
|
|
73
76
|
return { command: cmd, passed: false };
|
package/engine/git.test.ts
CHANGED
|
@@ -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,
|
|
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("
|
|
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
|
-
|
|
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("
|
|
97
|
+
it("restoreFilesTo does nothing when files list is empty", () => {
|
|
98
98
|
// Should not throw
|
|
99
|
-
expect(() =>
|
|
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
|
|
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
|
-
|
|
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,
|
|
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";
|