tdd-enforcer 0.2.2 → 0.2.5
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/helpers.test.ts +220 -162
- package/adapters/pi/helpers.ts +50 -17
- package/adapters/pi/hooks.test.ts +472 -0
- package/adapters/pi/hooks.ts +215 -157
- package/adapters/pi/index.test.ts +302 -0
- package/adapters/pi/index.ts +192 -135
- package/adapters/pi/log.test.ts +63 -41
- package/adapters/pi/log.ts +13 -4
- package/adapters/pi/prompts.test.ts +35 -0
- package/adapters/pi/prompts.ts +13 -12
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +322 -219
- package/behaviour.md +26 -11
- package/engine/config.test.ts +21 -21
- package/engine/config.ts +6 -6
- package/engine/enforce.test.ts +93 -27
- package/engine/enforce.ts +34 -10
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +63 -61
- package/engine/transition.ts +9 -2
- package/engine/types.ts +2 -2
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +22 -21
- package/tsconfig.json +12 -0
package/engine/git.ts
CHANGED
|
@@ -4,6 +4,24 @@ import { join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const TDD_DIR = ".pi/tdd";
|
|
6
6
|
|
|
7
|
+
export type GitDeps = {
|
|
8
|
+
execSync: (command: string, options?: ExecSyncOptions) => Buffer;
|
|
9
|
+
existsSync: (path: string) => boolean;
|
|
10
|
+
mkdirSync: (path: string, options?: { recursive?: boolean }) => void;
|
|
11
|
+
writeFileSync: (path: string, data: string, encoding: BufferEncoding) => void;
|
|
12
|
+
unlinkSync: (path: string) => void;
|
|
13
|
+
rmSync: (path: string, options?: { recursive?: boolean; force?: boolean }) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const defaultDeps: GitDeps = {
|
|
17
|
+
execSync: execSync as (command: string, options?: ExecSyncOptions) => Buffer,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
writeFileSync: writeFileSync as (path: string, data: string, encoding: BufferEncoding) => void,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
};
|
|
24
|
+
|
|
7
25
|
function gitEnv(projectRoot: string): NodeJS.ProcessEnv {
|
|
8
26
|
const gitDir = join(projectRoot, TDD_DIR, ".git");
|
|
9
27
|
return {
|
|
@@ -12,116 +30,129 @@ function gitEnv(projectRoot: string): NodeJS.ProcessEnv {
|
|
|
12
30
|
};
|
|
13
31
|
}
|
|
14
32
|
|
|
15
|
-
function gitExec(args: string, projectRoot: string, options?: ExecSyncOptions): string {
|
|
33
|
+
function gitExec(args: string, projectRoot: string, deps: GitDeps, options?: ExecSyncOptions): string {
|
|
16
34
|
const env = { ...process.env, ...gitEnv(projectRoot) };
|
|
17
|
-
return execSync(`git ${args}`, { ...options, env, encoding: "utf-8" } as ExecSyncOptions).toString();
|
|
35
|
+
return deps.execSync(`git ${args}`, { ...options, env, encoding: "utf-8" } as ExecSyncOptions).toString();
|
|
18
36
|
}
|
|
19
37
|
|
|
20
|
-
export function initGit(projectRoot: string): void {
|
|
38
|
+
export function initGit(projectRoot: string, deps: GitDeps = defaultDeps): void {
|
|
21
39
|
const tddPath = join(projectRoot, TDD_DIR);
|
|
22
40
|
const gitDir = join(tddPath, ".git");
|
|
23
|
-
if (existsSync(gitDir)) return;
|
|
41
|
+
if (deps.existsSync(gitDir)) return;
|
|
24
42
|
|
|
25
|
-
mkdirSync(tddPath, { recursive: true });
|
|
26
|
-
gitExec(`init "${tddPath}"`, projectRoot, { stdio: "pipe" as const });
|
|
27
|
-
gitExec(`config core.worktree "${projectRoot}"`, projectRoot, { stdio: "pipe" as const });
|
|
28
|
-
gitExec(`config core.excludesFile "${join(tddPath, ".gitignore")}"`, projectRoot, { stdio: "pipe" as const });
|
|
43
|
+
deps.mkdirSync(tddPath, { recursive: true });
|
|
44
|
+
gitExec(`init "${tddPath}"`, projectRoot, deps, { stdio: "pipe" as const });
|
|
45
|
+
gitExec(`config core.worktree "${projectRoot}"`, projectRoot, deps, { stdio: "pipe" as const });
|
|
46
|
+
gitExec(`config core.excludesFile "${join(tddPath, ".gitignore")}"`, projectRoot, deps, { stdio: "pipe" as const });
|
|
29
47
|
|
|
30
48
|
const gitignorePath = join(tddPath, ".gitignore");
|
|
31
|
-
if (!existsSync(gitignorePath)) {
|
|
32
|
-
writeFileSync(
|
|
49
|
+
if (!deps.existsSync(gitignorePath)) {
|
|
50
|
+
deps.writeFileSync(
|
|
33
51
|
gitignorePath,
|
|
34
52
|
["node_modules/", ".pnpm-store/", ".next/", "dist/", "build/", ".cache/", "*.log", ".DS_Store", "Thumbs.db", ""].join("\n"),
|
|
35
53
|
"utf-8",
|
|
36
54
|
);
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
gitExec("add -A", projectRoot, { stdio: "pipe" as const });
|
|
40
|
-
|
|
57
|
+
gitExec("add -A", projectRoot, deps, { stdio: "pipe" as const });
|
|
58
|
+
stageFiles(projectRoot, [".pi/tdd/state.json", ".pi/tdd/rules.json", ".pi/tdd/.gitignore"], deps);
|
|
59
|
+
gitExec('commit --allow-empty -m "tdd: init"', projectRoot, deps, { stdio: "pipe" as const });
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
/** Destroy the private git repo and re-init from scratch. */
|
|
44
|
-
export function resetGit(projectRoot: string): void {
|
|
63
|
+
export function resetGit(projectRoot: string, deps: GitDeps = defaultDeps): void {
|
|
45
64
|
const tddPath = join(projectRoot, TDD_DIR);
|
|
46
65
|
const gitDir = join(tddPath, ".git");
|
|
47
|
-
if (existsSync(gitDir)) {
|
|
48
|
-
rmSync(gitDir, { recursive: true, force: true });
|
|
66
|
+
if (deps.existsSync(gitDir)) {
|
|
67
|
+
deps.rmSync(gitDir, { recursive: true, force: true });
|
|
49
68
|
}
|
|
50
|
-
initGit(projectRoot);
|
|
69
|
+
initGit(projectRoot, deps);
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/** Stage all + commit with --allow-empty so every phase transition has a labeled commit. */
|
|
54
|
-
export function snapshot(projectRoot: string, phase: string): string {
|
|
55
|
-
gitExec("add -A", projectRoot, { stdio: "pipe" as const });
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
export function snapshot(projectRoot: string, phase: string, deps: GitDeps = defaultDeps): string {
|
|
74
|
+
gitExec("add -A", projectRoot, deps, { stdio: "pipe" as const });
|
|
75
|
+
stageFiles(projectRoot, [".pi/tdd/state.json", ".pi/tdd/rules.json", ".pi/tdd/.gitignore"], deps);
|
|
76
|
+
gitExec(`commit --allow-empty -m "tdd: ${phase}"`, projectRoot, deps, { stdio: "pipe" as const });
|
|
77
|
+
return gitExec("rev-parse HEAD", projectRoot, deps).trim();
|
|
58
78
|
}
|
|
59
79
|
|
|
60
|
-
export function modifiedFiles(projectRoot: string): string[] {
|
|
61
|
-
const out = gitExec("diff --name-only HEAD", projectRoot).trim();
|
|
80
|
+
export function modifiedFiles(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
|
|
81
|
+
const out = gitExec("diff --name-only HEAD", projectRoot, deps).trim();
|
|
62
82
|
return out ? out.split("\n") : [];
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
export function untrackedFiles(projectRoot: string): string[] {
|
|
66
|
-
const out = gitExec("ls-files --others --exclude-standard", projectRoot).trim();
|
|
85
|
+
export function untrackedFiles(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
|
|
86
|
+
const out = gitExec("ls-files --others --exclude-standard", projectRoot, deps).trim();
|
|
67
87
|
return out ? out.split("\n") : [];
|
|
68
88
|
}
|
|
69
89
|
|
|
70
|
-
export function changesSinceSnapshot(projectRoot: string): string[] {
|
|
71
|
-
return [...new Set([...modifiedFiles(projectRoot), ...untrackedFiles(projectRoot)])];
|
|
90
|
+
export function changesSinceSnapshot(projectRoot: string, deps: GitDeps = defaultDeps): string[] {
|
|
91
|
+
return [...new Set([...modifiedFiles(projectRoot, deps), ...untrackedFiles(projectRoot, deps)])];
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
export function restoreFilesTo(projectRoot: string, files: string[], source?: string): void {
|
|
94
|
+
export function restoreFilesTo(projectRoot: string, files: string[], source?: string, deps: GitDeps = defaultDeps): void {
|
|
75
95
|
if (files.length === 0) return;
|
|
76
96
|
|
|
77
97
|
// Separate tracked (git restore) from untracked (delete)
|
|
78
|
-
const tracked = gitExec("ls-files", projectRoot)
|
|
98
|
+
const tracked = gitExec("ls-files", projectRoot, deps)
|
|
79
99
|
.trim()
|
|
80
100
|
.split("\n")
|
|
81
101
|
.filter(Boolean);
|
|
82
102
|
const trackedSet = new Set(tracked);
|
|
83
103
|
|
|
84
104
|
const trackedFiles = files.filter((f) => trackedSet.has(f));
|
|
85
|
-
const
|
|
105
|
+
const untrackedFilesList = files.filter((f) => !trackedSet.has(f));
|
|
86
106
|
|
|
87
107
|
if (trackedFiles.length > 0) {
|
|
88
108
|
const escaped = trackedFiles.map((f) => `"${f}"`).join(" ");
|
|
89
109
|
const sourceFlag = source ? `--source=${source} ` : "";
|
|
90
|
-
gitExec(`restore ${sourceFlag}--worktree -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
|
|
110
|
+
gitExec(`restore ${sourceFlag}--worktree -- ${escaped}`, projectRoot, deps, { stdio: "pipe" as const });
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
for (const f of
|
|
113
|
+
for (const f of untrackedFilesList) {
|
|
94
114
|
try {
|
|
95
|
-
unlinkSync(f);
|
|
115
|
+
deps.unlinkSync(f);
|
|
96
116
|
} catch {
|
|
97
117
|
// File may already be gone, ignore
|
|
98
118
|
}
|
|
99
119
|
}
|
|
100
120
|
}
|
|
101
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Force-stage files in the private git repo, bypassing worktree .gitignore.
|
|
124
|
+
* Does not commit — call snapshot() or commit separately to persist.
|
|
125
|
+
*/
|
|
126
|
+
export function stageFiles(projectRoot: string, files: string[], deps: GitDeps = defaultDeps): void {
|
|
127
|
+
const existing = files.filter((f) => deps.existsSync(join(projectRoot, f)));
|
|
128
|
+
if (existing.length === 0) return;
|
|
129
|
+
const escaped = existing.map((f) => `"${f}"`).join(" ");
|
|
130
|
+
gitExec(`add -f ${escaped}`, projectRoot, deps, { stdio: "pipe" as const });
|
|
131
|
+
}
|
|
132
|
+
|
|
102
133
|
/**
|
|
103
134
|
* Create a lightweight commit of the current working tree without touching the stash ref.
|
|
104
135
|
* Returns the commit hash. Used as a pre-bash baseline for per-command diff.
|
|
105
136
|
*/
|
|
106
|
-
export function gitStashCreate(projectRoot: string): string {
|
|
107
|
-
const hash = gitExec("stash create --include-untracked", projectRoot).trim();
|
|
108
|
-
if (!hash)
|
|
137
|
+
export function gitStashCreate(projectRoot: string, deps: GitDeps = defaultDeps): string {
|
|
138
|
+
const hash = gitExec("stash create --include-untracked", projectRoot, deps).trim();
|
|
139
|
+
if (!hash) return "HEAD";
|
|
109
140
|
return hash;
|
|
110
141
|
}
|
|
111
142
|
|
|
112
|
-
export function headHash(projectRoot: string): string {
|
|
113
|
-
return gitExec("rev-parse HEAD", projectRoot).trim();
|
|
143
|
+
export function headHash(projectRoot: string, deps: GitDeps = defaultDeps): string {
|
|
144
|
+
return gitExec("rev-parse HEAD", projectRoot, deps).trim();
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
/** Get the commit message of HEAD. */
|
|
117
|
-
export function headMessage(projectRoot: string): string {
|
|
118
|
-
return gitExec("log -1 --format=%s HEAD", projectRoot).trim();
|
|
148
|
+
export function headMessage(projectRoot: string, deps: GitDeps = defaultDeps): string {
|
|
149
|
+
return gitExec("log -1 --format=%s HEAD", projectRoot, deps).trim();
|
|
119
150
|
}
|
|
120
151
|
|
|
121
152
|
/** Check if HEAD has a parent commit (i.e. can go back one). */
|
|
122
|
-
export function hasParent(projectRoot: string): boolean {
|
|
153
|
+
export function hasParent(projectRoot: string, deps: GitDeps = defaultDeps): boolean {
|
|
123
154
|
try {
|
|
124
|
-
gitExec("rev-parse HEAD~1", projectRoot);
|
|
155
|
+
gitExec("rev-parse HEAD~1", projectRoot, deps);
|
|
125
156
|
return true;
|
|
126
157
|
} catch {
|
|
127
158
|
return false;
|
|
@@ -131,21 +162,21 @@ export function hasParent(projectRoot: string): boolean {
|
|
|
131
162
|
/**
|
|
132
163
|
* Get files changed since a specific commit (instead of HEAD).
|
|
133
164
|
*/
|
|
134
|
-
export function changesSince(projectRoot: string, commitHash: string): string[] {
|
|
135
|
-
const out = gitExec(`diff --name-only ${commitHash} -- .`, projectRoot).trim();
|
|
165
|
+
export function changesSince(projectRoot: string, commitHash: string, deps: GitDeps = defaultDeps): string[] {
|
|
166
|
+
const out = gitExec(`diff --name-only ${commitHash} -- .`, projectRoot, deps).trim();
|
|
136
167
|
const files = out ? out.split("\n") : [];
|
|
137
168
|
// Also include untracked files
|
|
138
|
-
const untracked = untrackedFiles(projectRoot);
|
|
169
|
+
const untracked = untrackedFiles(projectRoot, deps);
|
|
139
170
|
return [...new Set([...files, ...untracked])];
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
/** Hard reset — discard all uncommitted changes (tracked and untracked), keep HEAD. */
|
|
143
|
-
export function resetHard(projectRoot: string): void {
|
|
144
|
-
gitExec("reset --hard", projectRoot, { stdio: "pipe" as const });
|
|
145
|
-
gitExec("clean -fd", projectRoot, { stdio: "pipe" as const });
|
|
174
|
+
export function resetHard(projectRoot: string, deps: GitDeps = defaultDeps): void {
|
|
175
|
+
gitExec("reset --hard", projectRoot, deps, { stdio: "pipe" as const });
|
|
176
|
+
gitExec("clean -fd", projectRoot, deps, { stdio: "pipe" as const });
|
|
146
177
|
}
|
|
147
178
|
|
|
148
179
|
/** Soft reset — remove last commit, keep working tree content as unstaged. */
|
|
149
|
-
export function undoLastCommit(projectRoot: string): void {
|
|
150
|
-
gitExec("reset --soft HEAD~1", projectRoot, { stdio: "pipe" as const });
|
|
180
|
+
export function undoLastCommit(projectRoot: string, deps: GitDeps = defaultDeps): void {
|
|
181
|
+
gitExec("reset --soft HEAD~1", projectRoot, deps, { stdio: "pipe" as const });
|
|
151
182
|
}
|
package/engine/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { isAllowed, disallowedFiles } from "./enforce.js";
|
|
2
|
-
export { initGit, resetGit, snapshot, changesSinceSnapshot, changesSince, modifiedFiles, untrackedFiles, restoreFilesTo, gitStashCreate, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
|
|
2
|
+
export { initGit, resetGit, snapshot, changesSinceSnapshot, changesSince, modifiedFiles, untrackedFiles, restoreFilesTo, gitStashCreate, stageFiles, 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";
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { getDisallowedChanges, nextPhase, checkGate } from "./transition.js";
|
|
6
3
|
import type { Config, TestRunner } from "./types.js";
|
|
7
|
-
import
|
|
4
|
+
import picomatch from "picomatch";
|
|
8
5
|
|
|
9
6
|
// ── Pure unit tests: nextPhase ──────────────────────────────────────────────
|
|
10
7
|
|
|
@@ -36,8 +33,8 @@ function makeRunner(passed: boolean): TestRunner {
|
|
|
36
33
|
}
|
|
37
34
|
|
|
38
35
|
const testConfig: Config = {
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
blockedInRed: [],
|
|
37
|
+
blockedInGreen: [],
|
|
41
38
|
testCommands: ["npm test"],
|
|
42
39
|
timeoutSeconds: 30,
|
|
43
40
|
};
|
|
@@ -124,81 +121,86 @@ describe("checkGate", () => {
|
|
|
124
121
|
});
|
|
125
122
|
});
|
|
126
123
|
|
|
127
|
-
// ──
|
|
128
|
-
|
|
129
|
-
function withTempDir(fn: (dir: string) => void) {
|
|
130
|
-
const dir = join(tmpdir(), `tdd-transition-test-${Date.now()}`);
|
|
131
|
-
mkdirSync(dir, { recursive: true });
|
|
132
|
-
try {
|
|
133
|
-
fn(dir);
|
|
134
|
-
} finally {
|
|
135
|
-
rmSync(dir, { recursive: true, force: true });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
124
|
+
// ── Pure unit tests: getDisallowedChanges ────────────────────────────────────
|
|
138
125
|
|
|
139
126
|
const denyConfig: Config = {
|
|
140
|
-
|
|
141
|
-
|
|
127
|
+
blockedInRed: ["src/**/*.ts"],
|
|
128
|
+
blockedInGreen: ["tests/**/*.test.ts"],
|
|
142
129
|
testCommands: [],
|
|
143
130
|
timeoutSeconds: 30,
|
|
144
131
|
};
|
|
145
132
|
|
|
146
133
|
describe("getDisallowedChanges", () => {
|
|
134
|
+
let mockChangesSinceSnapshot: ReturnType<typeof vi.fn>;
|
|
135
|
+
let mockDisallowedFiles: ReturnType<typeof vi.fn>;
|
|
136
|
+
|
|
137
|
+
function makeDeps(overrides = {}) {
|
|
138
|
+
return {
|
|
139
|
+
changesSinceSnapshot: mockChangesSinceSnapshot,
|
|
140
|
+
disallowedFiles: mockDisallowedFiles,
|
|
141
|
+
...overrides,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
vi.clearAllMocks();
|
|
147
|
+
mockChangesSinceSnapshot = vi.fn().mockReturnValue([]);
|
|
148
|
+
mockDisallowedFiles = vi.fn().mockReturnValue([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
147
151
|
it("returns empty for refactor phase regardless of git state", () => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
+
const result = getDisallowedChanges("/any", "refactor", denyConfig, makeDeps());
|
|
153
|
+
expect(result).toEqual([]);
|
|
154
|
+
expect(mockChangesSinceSnapshot).not.toHaveBeenCalled();
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
it("returns empty when no files changed", () => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
});
|
|
158
|
+
mockChangesSinceSnapshot.mockReturnValue([]);
|
|
159
|
+
const result = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
160
|
+
expect(result).toEqual([]);
|
|
161
|
+
expect(mockChangesSinceSnapshot).toHaveBeenCalledWith("/test");
|
|
160
162
|
});
|
|
161
163
|
|
|
162
164
|
it("returns disallowed files in red phase", () => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
writeFileSync(join(dir, "README.md"), "// docs", "utf-8");
|
|
171
|
-
const violations = getDisallowedChanges(dir, "red", denyConfig);
|
|
172
|
-
expect(violations).toContain("src/main.ts");
|
|
173
|
-
expect(violations).not.toContain("tests/foo.test.ts");
|
|
174
|
-
expect(violations).not.toContain("README.md");
|
|
165
|
+
mockChangesSinceSnapshot.mockReturnValue(["src/main.ts", "tests/foo.test.ts", "README.md"]);
|
|
166
|
+
const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
|
|
167
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
168
|
+
if (phase === "red") {
|
|
169
|
+
return changed.filter((f: string) => matchBlockedInRed(f));
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
175
172
|
});
|
|
173
|
+
const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
174
|
+
expect(violations).toContain("src/main.ts");
|
|
175
|
+
expect(violations).not.toContain("tests/foo.test.ts");
|
|
176
|
+
expect(violations).not.toContain("README.md");
|
|
176
177
|
});
|
|
177
178
|
|
|
178
179
|
it("returns disallowed files in green phase", () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
writeFileSync(join(dir, "package.json"), "{}", "utf-8");
|
|
187
|
-
const violations = getDisallowedChanges(dir, "green", denyConfig);
|
|
188
|
-
expect(violations).toContain("tests/foo.test.ts");
|
|
189
|
-
expect(violations).not.toContain("src/main.ts");
|
|
190
|
-
expect(violations).not.toContain("package.json");
|
|
180
|
+
mockChangesSinceSnapshot.mockReturnValue(["tests/foo.test.ts", "src/main.ts", "package.json"]);
|
|
181
|
+
const matchBlockedInGreen = picomatch(denyConfig.blockedInGreen);
|
|
182
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
183
|
+
if (phase === "green") {
|
|
184
|
+
return changed.filter((f: string) => matchBlockedInGreen(f));
|
|
185
|
+
}
|
|
186
|
+
return [];
|
|
191
187
|
});
|
|
188
|
+
const violations = getDisallowedChanges("/test", "green", denyConfig, makeDeps());
|
|
189
|
+
expect(violations).toContain("tests/foo.test.ts");
|
|
190
|
+
expect(violations).not.toContain("src/main.ts");
|
|
191
|
+
expect(violations).not.toContain("package.json");
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
it("catches untracked files, not just modified", () => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
mockChangesSinceSnapshot.mockReturnValue(["src/new.ts"]);
|
|
196
|
+
const matchBlockedInRed = picomatch(denyConfig.blockedInRed);
|
|
197
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
198
|
+
if (phase === "red") {
|
|
199
|
+
return changed.filter((f: string) => matchBlockedInRed(f));
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
202
|
});
|
|
203
|
+
const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
204
|
+
expect(violations).toContain("src/new.ts");
|
|
203
205
|
});
|
|
204
206
|
});
|
package/engine/transition.ts
CHANGED
|
@@ -70,9 +70,16 @@ export function getDisallowedChanges(
|
|
|
70
70
|
projectRoot: string,
|
|
71
71
|
phase: Phase,
|
|
72
72
|
config: Config,
|
|
73
|
+
deps: {
|
|
74
|
+
changesSinceSnapshot: typeof changesSinceSnapshot;
|
|
75
|
+
disallowedFiles: typeof disallowedFiles;
|
|
76
|
+
} = {
|
|
77
|
+
changesSinceSnapshot,
|
|
78
|
+
disallowedFiles,
|
|
79
|
+
},
|
|
73
80
|
): string[] {
|
|
74
81
|
if (phase === "refactor") return [];
|
|
75
82
|
|
|
76
|
-
const changed = changesSinceSnapshot(projectRoot);
|
|
77
|
-
return disallowedFiles(changed, phase, config);
|
|
83
|
+
const changed = deps.changesSinceSnapshot(projectRoot);
|
|
84
|
+
return deps.disallowedFiles(changed, phase, config);
|
|
78
85
|
}
|
package/engine/types.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tdd-enforcer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"picomatch": "^4.0.4"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
|
+
"@earendil-works/pi-coding-agent": "^0.79.6",
|
|
13
|
+
"@types/node": "^25.9.3",
|
|
14
|
+
"typebox": "^1.2.16",
|
|
12
15
|
"vitest": "^3"
|
|
13
16
|
},
|
|
14
17
|
"pi": {
|
|
@@ -32,15 +32,16 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"testCommands":
|
|
35
|
+
"blockedInRed": ["src/**/*.ts", "lib/**/*.ts", "!src/**/*.test.ts"],
|
|
36
|
+
"blockedInGreen": ["**/*.test.ts", "**/*.spec.ts"],
|
|
37
|
+
"testCommands": ["npm test"],
|
|
38
38
|
"timeoutSeconds": 30
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
- `
|
|
43
|
-
- `
|
|
42
|
+
- `blockedInRed` — globs the agent **cannot** modify in RED phase (implementation files)
|
|
43
|
+
- `blockedInGreen` — globs the agent **cannot** modify in GREEN phase (test files)
|
|
44
|
+
- `!` exclusion prefix — optional, carves out subsets from a block list at init time. E.g. `!src/**/*.test.ts` excludes co-located test files from `blockedInRed` so the agent can write them in RED phase
|
|
44
45
|
- `testCommands` — shell commands to run tests
|
|
45
46
|
- `timeoutSeconds` — test timeout per command
|
|
46
47
|
|
|
@@ -51,31 +52,31 @@ It locks files per phase — only test files in RED, only implementation files i
|
|
|
51
52
|
## Phase Rules
|
|
52
53
|
|
|
53
54
|
### RED
|
|
54
|
-
|
|
55
|
+
Files matching `blockedInRed` are locked — everything else is free.
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
Implement features matching `allowedGreenPhaseFiles` patterns. Test files from `allowedRedPhaseFiles` are locked. Files matching neither set are always free. Call `next_tdd_phase` once tests pass.
|
|
57
|
+
Write failing tests for one feature at a time. Think about what could go wrong and test for it — don't just verify the happy path, cover unhappy paths and edge cases too. Keep cycles small so reverting is cheap and safe if assumptions turn out wrong.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
All files are free to modify. Refactor without changing behaviour. Call `next_tdd_phase` once tests pass to start a new RED cycle.
|
|
59
|
+
Call `next_tdd_phase` once tests fail.
|
|
61
60
|
|
|
62
|
-
|
|
61
|
+
### GREEN
|
|
62
|
+
Files matching `blockedInGreen` are locked — everything else is free.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Write the simplest code that makes the failing tests pass — nothing more. The tests are your spec; if they pass, you're done.
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
If the RED phase tests were wrong, call `previous_tdd_phase` to go back and fix them before implementing. All current changes are lost, but that's better since the current changes was building on false assumptions. Don't be afraid to discard — clean slate beats patched code.
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
- Wrong assumptions about the task? Roll back with `previous_tdd_phase`, the phase restarts clean.
|
|
70
|
-
- Fundamentally blocked? Ask the user to run the appropriate `/tdd:` command (change phase, disable, or reset).
|
|
68
|
+
Call `next_tdd_phase` once all tests pass.
|
|
71
69
|
|
|
72
|
-
|
|
70
|
+
### REFACTOR
|
|
71
|
+
All files are free to modify. Refactor without changing behaviour.
|
|
72
|
+
Call `next_tdd_phase` once tests pass to start a new RED cycle.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
---
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
## Hard Rules
|
|
77
77
|
|
|
78
|
-
- **
|
|
78
|
+
- **Never write to `.pi/tdd/`.** The extension owns that directory — writes are blocked and bash changes are reverted.
|
|
79
|
+
- **Never run `/tdd:` commands yourself.** They're registered as user-only commands and won't work when you type them.
|
|
79
80
|
|
|
80
81
|
---
|
|
81
82
|
|
|
@@ -93,7 +94,7 @@ Also validates no locked files were modified. On success, records the current st
|
|
|
93
94
|
Use when the previous phase's work was wrong and the current phase cannot proceed because of it. Rolls back to the previous phase so that work can be redone correctly. All changes made in the current phase are lost.
|
|
94
95
|
|
|
95
96
|
### `tdd_status`
|
|
96
|
-
Shows the current phase,
|
|
97
|
+
Shows the current phase, blocked file globs per phase, and test commands.
|
|
97
98
|
|
|
98
99
|
---
|
|
99
100
|
|
package/tsconfig.json
ADDED