tdd-enforcer 0.2.1 → 0.2.3
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 +218 -160
- 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 +43 -0
- package/adapters/pi/tools.test.ts +351 -0
- package/adapters/pi/tools.ts +321 -218
- package/behaviour.md +26 -11
- package/engine/git.test.ts +432 -173
- package/engine/git.ts +80 -49
- package/engine/index.ts +1 -1
- package/engine/transition.test.ts +62 -57
- package/engine/transition.ts +9 -2
- package/package.json +4 -1
- package/skills/tdd-enforcer/SKILL.md +5 -0
- 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
|
|
|
@@ -124,17 +121,7 @@ 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
127
|
allowedRedPhaseFiles: ["tests/**/*.test.ts"],
|
|
@@ -144,61 +131,79 @@ const denyConfig: Config = {
|
|
|
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
|
-
|
|
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 matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
|
|
167
|
+
const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
|
|
168
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
169
|
+
if (phase === "red") {
|
|
170
|
+
return changed.filter((f: string) => !matchesRed(f) && matchesGreen(f));
|
|
171
|
+
}
|
|
172
|
+
return [];
|
|
175
173
|
});
|
|
174
|
+
const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
175
|
+
expect(violations).toContain("src/main.ts");
|
|
176
|
+
expect(violations).not.toContain("tests/foo.test.ts");
|
|
177
|
+
expect(violations).not.toContain("README.md");
|
|
176
178
|
});
|
|
177
179
|
|
|
178
180
|
it("returns disallowed files in green phase", () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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");
|
|
181
|
+
mockChangesSinceSnapshot.mockReturnValue(["tests/foo.test.ts", "src/main.ts", "package.json"]);
|
|
182
|
+
const matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
|
|
183
|
+
const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
|
|
184
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
185
|
+
if (phase === "green") {
|
|
186
|
+
return changed.filter((f: string) => matchesRed(f) && !matchesGreen(f));
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
191
189
|
});
|
|
190
|
+
const violations = getDisallowedChanges("/test", "green", denyConfig, makeDeps());
|
|
191
|
+
expect(violations).toContain("tests/foo.test.ts");
|
|
192
|
+
expect(violations).not.toContain("src/main.ts");
|
|
193
|
+
expect(violations).not.toContain("package.json");
|
|
192
194
|
});
|
|
193
195
|
|
|
194
196
|
it("catches untracked files, not just modified", () => {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
mockChangesSinceSnapshot.mockReturnValue(["src/new.ts"]);
|
|
198
|
+
const matchesRed = picomatch(denyConfig.allowedRedPhaseFiles);
|
|
199
|
+
const matchesGreen = picomatch(denyConfig.allowedGreenPhaseFiles);
|
|
200
|
+
mockDisallowedFiles.mockImplementation((changed: string[], phase: string) => {
|
|
201
|
+
if (phase === "red") {
|
|
202
|
+
return changed.filter((f: string) => !matchesRed(f) && matchesGreen(f));
|
|
203
|
+
}
|
|
204
|
+
return [];
|
|
202
205
|
});
|
|
206
|
+
const violations = getDisallowedChanges("/test", "red", denyConfig, makeDeps());
|
|
207
|
+
expect(violations).toContain("src/new.ts");
|
|
203
208
|
});
|
|
204
209
|
});
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tdd-enforcer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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": {
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tdd-enforcer
|
|
3
|
+
description: Use when working within TDD enforcer extension — understand phase rules, file locks, agent tools vs user commands, and when to call next/previous phase or ask the user for help.
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# TDD Enforcer Skill
|
|
2
7
|
|
|
3
8
|
This extension enforces the **Red-Green-Refactor** cycle of TDD.
|
package/tsconfig.json
ADDED