tdd-enforcer 0.2.2 → 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/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
- gitExec('commit --allow-empty -m "tdd: init"', projectRoot, { stdio: "pipe" as const });
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
- gitExec(`commit --allow-empty -m "tdd: ${phase}"`, projectRoot, { stdio: "pipe" as const });
57
- return gitExec("rev-parse HEAD", projectRoot).trim();
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 untrackedFiles = files.filter((f) => !trackedSet.has(f));
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 untrackedFiles) {
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) throw new Error("git stash create returned empty 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 { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
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 { initGit, snapshot } from "./git.js";
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
- // ── Integration tests: getDisallowedChanges ──────────────────────────────────
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
- withTempDir((dir) => {
149
- // No git at all — safe because refactor returns early
150
- expect(getDisallowedChanges(dir, "refactor", denyConfig)).toEqual([]);
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
- withTempDir((dir) => {
156
- initGit(dir);
157
- snapshot(dir, "red");
158
- expect(getDisallowedChanges(dir, "red", denyConfig)).toEqual([]);
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
- withTempDir((dir) => {
164
- initGit(dir);
165
- snapshot(dir, "red");
166
- mkdirSync(join(dir, "src"), { recursive: true });
167
- mkdirSync(join(dir, "tests"), { recursive: true });
168
- writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
169
- writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
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 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
- withTempDir((dir) => {
180
- initGit(dir);
181
- snapshot(dir, "green");
182
- mkdirSync(join(dir, "tests"), { recursive: true });
183
- mkdirSync(join(dir, "src"), { recursive: true });
184
- writeFileSync(join(dir, "tests", "foo.test.ts"), "// test", "utf-8");
185
- writeFileSync(join(dir, "src", "main.ts"), "// impl", "utf-8");
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");
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
- withTempDir((dir) => {
196
- initGit(dir);
197
- snapshot(dir, "red");
198
- mkdirSync(join(dir, "src"), { recursive: true });
199
- writeFileSync(join(dir, "src", "new.ts"), "// brand new", "utf-8");
200
- const violations = getDisallowedChanges(dir, "red", denyConfig);
201
- expect(violations).toContain("src/new.ts");
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
  });
@@ -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.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": {
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "types": ["node"],
8
+ "skipLibCheck": true
9
+ },
10
+ "include": ["**/*.ts"],
11
+ "exclude": ["node_modules"]
12
+ }