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 +6 -7
- package/adapters/pi/helpers.test.ts +14 -14
- package/adapters/pi/helpers.ts +4 -4
- package/adapters/pi/hooks.ts +54 -11
- package/adapters/pi/index.ts +7 -7
- package/adapters/pi/prompts.ts +7 -42
- package/adapters/pi/tools.ts +1 -5
- package/engine/config.test.ts +51 -0
- package/engine/config.ts +6 -6
- package/engine/git.test.ts +5 -5
- package/engine/git.ts +24 -2
- package/engine/index.ts +1 -1
- package/engine/state.test.ts +6 -6
- package/engine/state.ts +2 -2
- package/engine/transition.test.ts +3 -20
- package/engine/transition.ts +10 -8
- package/package.json +1 -1
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
|
|
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
|
-
├──
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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("
|
|
57
|
+
expect(result.reason).toContain("state.json");
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it("returns invalid
|
|
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", "
|
|
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/
|
|
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", "
|
|
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
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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", "
|
|
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
|
|
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", "
|
|
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/
|
|
200
|
+
expect(result.reason).toContain("Invalid .pi/tdd/state.json");
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
});
|
package/adapters/pi/helpers.ts
CHANGED
|
@@ -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, "
|
|
26
|
+
const phasePath = join(tddDir, "state.json");
|
|
27
27
|
if (!existsSync(phasePath)) {
|
|
28
|
-
return { ok: false, reason: "Missing .pi/tdd/
|
|
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/
|
|
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
|
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/index.ts
CHANGED
|
@@ -29,10 +29,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const phasePath = join(tddDir, "
|
|
32
|
+
const phasePath = join(tddDir, "state.json");
|
|
33
33
|
if (!existsSync(phasePath)) {
|
|
34
|
-
tddLog(tddDir, "WARN", "tdd:on: missing
|
|
35
|
-
ctx.ui.notify("Missing .pi/tdd/
|
|
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
|
|
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/
|
|
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
|
|
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/
|
|
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
|
|
package/adapters/pi/prompts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
}
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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: {},
|
package/engine/config.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
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";
|
package/engine/state.test.ts
CHANGED
|
@@ -21,12 +21,12 @@ describe("loadPhaseState", () => {
|
|
|
21
21
|
});
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it("returns parsed state from
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
|
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, "
|
|
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(`
|
|
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(/
|
|
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(/
|
|
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(/
|
|
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;
|
package/engine/transition.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|