tdd-enforcer 0.1.0

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.
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFiles, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
6
+
7
+ let testDir: string;
8
+
9
+ beforeAll(() => {
10
+ testDir = join(tmpdir(), `tdd-git-test-${Date.now()}`);
11
+ mkdirSync(join(testDir, ".pi", "tdd"), { recursive: true });
12
+ mkdirSync(join(testDir, "src"), { recursive: true });
13
+ writeFileSync(join(testDir, "src", "main.ts"), "// initial", "utf-8");
14
+ });
15
+
16
+ afterAll(() => {
17
+ rmSync(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe("git operations", () => {
21
+ it("initGit creates private git repo", () => {
22
+ initGit(testDir);
23
+ expect(existsSync(join(testDir, ".pi", "tdd", ".git"))).toBe(true);
24
+ expect(existsSync(join(testDir, ".pi", "tdd", ".gitignore"))).toBe(true);
25
+ });
26
+
27
+ it("has initial commit after init", () => {
28
+ const hash = headHash(testDir);
29
+ expect(hash).toBeTruthy();
30
+ expect(hash.length).toBeGreaterThan(10);
31
+ });
32
+
33
+ it("snapshot captures changes", () => {
34
+ writeFileSync(join(testDir, "src", "main.ts"), "// modified", "utf-8");
35
+ const hash = snapshot(testDir, "green");
36
+ expect(hash).toBeTruthy();
37
+ });
38
+
39
+ it("modifiedFiles returns changed files", () => {
40
+ writeFileSync(join(testDir, "src", "main.ts"), "// changed again", "utf-8");
41
+ const modified = modifiedFiles(testDir);
42
+ expect(modified).toContain("src/main.ts");
43
+ });
44
+
45
+ it("untrackedFiles returns new files", () => {
46
+ writeFileSync(join(testDir, "newfile.ts"), "// new", "utf-8");
47
+ const untracked = untrackedFiles(testDir);
48
+ expect(untracked).toContain("newfile.ts");
49
+ });
50
+
51
+ it("changesSinceSnapshot combines modified + untracked", () => {
52
+ writeFileSync(join(testDir, "src", "main.ts"), "// yet another change", "utf-8");
53
+ writeFileSync(join(testDir, "another.ts"), "// also new", "utf-8");
54
+ const changes = changesSinceSnapshot(testDir);
55
+ expect(changes).toContain("src/main.ts");
56
+ expect(changes).toContain("another.ts");
57
+ });
58
+
59
+ it("restoreFiles reverts specific files", () => {
60
+ writeFileSync(join(testDir, "src", "main.ts"), "// to be reverted", "utf-8");
61
+ expect(modifiedFiles(testDir)).toContain("src/main.ts");
62
+
63
+ restoreFiles(testDir, ["src/main.ts"]);
64
+ expect(modifiedFiles(testDir)).not.toContain("src/main.ts");
65
+ });
66
+
67
+ it("modifiedFiles returns empty when HEAD matches working tree", () => {
68
+ expect(modifiedFiles(testDir)).not.toContain("src/main.ts");
69
+ });
70
+
71
+ it("untrackedFiles returns empty when no new files", () => {
72
+ const untracked = untrackedFiles(testDir);
73
+ expect(untracked).not.toContain("src/main.ts");
74
+ });
75
+
76
+ it("changesSinceSnapshot deduplicates when file is both modified and untracked", () => {
77
+ // Write a file, snapshot it, then delete and recreate as different type
78
+ const tmp = join(tmpdir(), `tdd-dedup-${Date.now()}`);
79
+ mkdirSync(tmp, { recursive: true });
80
+ try {
81
+ initGit(tmp);
82
+ writeFileSync(join(tmp, "file.txt"), "original", "utf-8");
83
+ snapshot(tmp, "red");
84
+ // Delete tracked file — it's now a deletion (modified)
85
+ rmSync(join(tmp, "file.txt"));
86
+ // Recreate as untracked with same name
87
+ writeFileSync(join(tmp, "file.txt"), "new content", "utf-8");
88
+ const changes = changesSinceSnapshot(tmp);
89
+ // Should appear exactly once despite matching both conditions
90
+ const count = changes.filter((f) => f === "file.txt").length;
91
+ expect(count).toBe(1);
92
+ } finally {
93
+ rmSync(tmp, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ it("restoreFiles does nothing when files list is empty", () => {
98
+ // Should not throw
99
+ expect(() => restoreFiles(testDir, [])).not.toThrow();
100
+ });
101
+ });
102
+
103
+ // ── Isolated tests for untested git functions ────────────────────────────────
104
+
105
+ function withTempDir(fn: (dir: string) => void) {
106
+ const dir = join(tmpdir(), `tdd-git-extra-${Date.now()}`);
107
+ mkdirSync(dir, { recursive: true });
108
+ try {
109
+ fn(dir);
110
+ } finally {
111
+ rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ }
114
+
115
+ describe("headMessage", () => {
116
+ it("returns init commit message after initGit", () => {
117
+ withTempDir((dir) => {
118
+ initGit(dir);
119
+ expect(headMessage(dir)).toBe("tdd: init");
120
+ });
121
+ });
122
+
123
+ it("returns snapshot phase in commit message", () => {
124
+ withTempDir((dir) => {
125
+ initGit(dir);
126
+ snapshot(dir, "green");
127
+ expect(headMessage(dir)).toBe("tdd: green");
128
+ });
129
+ });
130
+
131
+ it("updates after each snapshot", () => {
132
+ withTempDir((dir) => {
133
+ initGit(dir);
134
+ snapshot(dir, "red");
135
+ expect(headMessage(dir)).toBe("tdd: red");
136
+ snapshot(dir, "green");
137
+ expect(headMessage(dir)).toBe("tdd: green");
138
+ snapshot(dir, "refactor");
139
+ expect(headMessage(dir)).toBe("tdd: refactor");
140
+ });
141
+ });
142
+ });
143
+
144
+ describe("hasParent", () => {
145
+ it("returns false when only init commit exists", () => {
146
+ withTempDir((dir) => {
147
+ initGit(dir);
148
+ expect(hasParent(dir)).toBe(false);
149
+ });
150
+ });
151
+
152
+ it("returns true after first snapshot", () => {
153
+ withTempDir((dir) => {
154
+ initGit(dir);
155
+ snapshot(dir, "red");
156
+ expect(hasParent(dir)).toBe(true);
157
+ });
158
+ });
159
+
160
+ it("returns true after multiple snapshots", () => {
161
+ withTempDir((dir) => {
162
+ initGit(dir);
163
+ snapshot(dir, "red");
164
+ snapshot(dir, "green");
165
+ snapshot(dir, "refactor");
166
+ expect(hasParent(dir)).toBe(true);
167
+ });
168
+ });
169
+
170
+ it("returns false after popping all snapshots back to init", () => {
171
+ withTempDir((dir) => {
172
+ initGit(dir);
173
+ snapshot(dir, "red");
174
+ expect(hasParent(dir)).toBe(true);
175
+ undoLastCommit(dir);
176
+ expect(hasParent(dir)).toBe(false);
177
+ });
178
+ });
179
+ });
180
+
181
+ describe("resetHard", () => {
182
+ it("discards uncommitted changes", () => {
183
+ withTempDir((dir) => {
184
+ initGit(dir);
185
+ writeFileSync(join(dir, "file.txt"), "original", "utf-8");
186
+ snapshot(dir, "red");
187
+ writeFileSync(join(dir, "file.txt"), "dirty", "utf-8");
188
+ resetHard(dir);
189
+ expect(readFileSync(join(dir, "file.txt"), "utf-8")).toBe("original");
190
+ });
191
+ });
192
+
193
+ it("discards untracked files", () => {
194
+ withTempDir((dir) => {
195
+ initGit(dir);
196
+ snapshot(dir, "red");
197
+ writeFileSync(join(dir, "scratch.txt"), "should vanish", "utf-8");
198
+ resetHard(dir);
199
+ expect(existsSync(join(dir, "scratch.txt"))).toBe(false);
200
+ });
201
+ });
202
+
203
+ it("leaves committed changes intact", () => {
204
+ withTempDir((dir) => {
205
+ initGit(dir);
206
+ writeFileSync(join(dir, "stays.txt"), "persists", "utf-8");
207
+ snapshot(dir, "red");
208
+ resetHard(dir);
209
+ expect(existsSync(join(dir, "stays.txt"))).toBe(true);
210
+ expect(readFileSync(join(dir, "stays.txt"), "utf-8")).toBe("persists");
211
+ });
212
+ });
213
+
214
+ it("does not throw when working tree matches HEAD", () => {
215
+ withTempDir((dir) => {
216
+ initGit(dir);
217
+ writeFileSync(join(dir, "file.txt"), "content", "utf-8");
218
+ snapshot(dir, "red");
219
+ expect(() => resetHard(dir)).not.toThrow();
220
+ });
221
+ });
222
+ });
223
+
224
+ describe("undoLastCommit", () => {
225
+ it("removes last commit and keeps its content as unstaged changes", () => {
226
+ withTempDir((dir) => {
227
+ initGit(dir);
228
+ writeFileSync(join(dir, "file.txt"), "v1", "utf-8");
229
+ snapshot(dir, "red");
230
+ writeFileSync(join(dir, "file.txt"), "v2", "utf-8");
231
+ snapshot(dir, "green");
232
+
233
+ // No uncommitted changes before undo
234
+ const before = changesSinceSnapshot(dir);
235
+ expect(before).toHaveLength(0);
236
+
237
+ undoLastCommit(dir);
238
+
239
+ // HEAD is now red snapshot (v1), but WT still has v2
240
+ expect(headMessage(dir)).toBe("tdd: red");
241
+ expect(modifiedFiles(dir)).toContain("file.txt");
242
+ expect(readFileSync(join(dir, "file.txt"), "utf-8")).toBe("v2");
243
+ });
244
+ });
245
+
246
+ it("exposes popped content — new file stays in working tree and index", () => {
247
+ withTempDir((dir) => {
248
+ initGit(dir);
249
+ snapshot(dir, "red");
250
+ writeFileSync(join(dir, "new.txt"), "added in green", "utf-8");
251
+ snapshot(dir, "green");
252
+
253
+ expect(changesSinceSnapshot(dir)).toHaveLength(0);
254
+
255
+ undoLastCommit(dir);
256
+
257
+ // git reset --soft preserves the index, so new.txt is still staged
258
+ // It shows as modified (added) against HEAD, not as untracked
259
+ expect(headMessage(dir)).toBe("tdd: red");
260
+ expect(changesSinceSnapshot(dir)).toContain("new.txt");
261
+ expect(readFileSync(join(dir, "new.txt"), "utf-8")).toBe("added in green");
262
+ });
263
+ });
264
+
265
+ it("errors when there is no parent commit", () => {
266
+ withTempDir((dir) => {
267
+ initGit(dir);
268
+ expect(() => undoLastCommit(dir)).toThrow();
269
+ });
270
+ });
271
+ });
package/engine/git.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { execSync, type ExecSyncOptions } from "node:child_process";
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const TDD_DIR = ".pi/tdd";
6
+
7
+ function gitEnv(projectRoot: string): NodeJS.ProcessEnv {
8
+ const gitDir = join(projectRoot, TDD_DIR, ".git");
9
+ return {
10
+ GIT_DIR: gitDir,
11
+ GIT_WORK_TREE: projectRoot,
12
+ };
13
+ }
14
+
15
+ function gitExec(args: string, projectRoot: string, options?: ExecSyncOptions): string {
16
+ const env = { ...process.env, ...gitEnv(projectRoot) };
17
+ return execSync(`git ${args}`, { ...options, env, encoding: "utf-8" } as ExecSyncOptions).toString();
18
+ }
19
+
20
+ export function initGit(projectRoot: string): void {
21
+ const tddPath = join(projectRoot, TDD_DIR);
22
+ const gitDir = join(tddPath, ".git");
23
+ if (existsSync(gitDir)) return;
24
+
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 });
29
+
30
+ const gitignorePath = join(tddPath, ".gitignore");
31
+ if (!existsSync(gitignorePath)) {
32
+ writeFileSync(
33
+ gitignorePath,
34
+ ["node_modules/", ".pnpm-store/", ".next/", "dist/", "build/", ".cache/", "*.log", ".DS_Store", "Thumbs.db", ""].join("\n"),
35
+ "utf-8",
36
+ );
37
+ }
38
+
39
+ gitExec("add -A", projectRoot, { stdio: "pipe" as const });
40
+ gitExec('commit --allow-empty -m "tdd: init"', projectRoot, { stdio: "pipe" as const });
41
+ }
42
+
43
+ /** Stage all + commit with --allow-empty so every phase transition has a labeled commit. */
44
+ export function snapshot(projectRoot: string, phase: string): string {
45
+ gitExec("add -A", projectRoot, { stdio: "pipe" as const });
46
+ gitExec(`commit --allow-empty -m "tdd: ${phase}"`, projectRoot, { stdio: "pipe" as const });
47
+ return gitExec("rev-parse HEAD", projectRoot).trim();
48
+ }
49
+
50
+ export function modifiedFiles(projectRoot: string): string[] {
51
+ const out = gitExec("diff --name-only HEAD", projectRoot).trim();
52
+ return out ? out.split("\n") : [];
53
+ }
54
+
55
+ export function untrackedFiles(projectRoot: string): string[] {
56
+ const out = gitExec("ls-files --others --exclude-standard", projectRoot).trim();
57
+ return out ? out.split("\n") : [];
58
+ }
59
+
60
+ export function changesSinceSnapshot(projectRoot: string): string[] {
61
+ return [...new Set([...modifiedFiles(projectRoot), ...untrackedFiles(projectRoot)])];
62
+ }
63
+
64
+ export function restoreFiles(projectRoot: string, files: string[]): void {
65
+ if (files.length === 0) return;
66
+ const escaped = files.map((f) => `"${f}"`).join(" ");
67
+ gitExec(`restore -- ${escaped}`, projectRoot, { stdio: "pipe" as const });
68
+ }
69
+
70
+ export function headHash(projectRoot: string): string {
71
+ return gitExec("rev-parse HEAD", projectRoot).trim();
72
+ }
73
+
74
+ /** Get the commit message of HEAD. */
75
+ export function headMessage(projectRoot: string): string {
76
+ return gitExec("log -1 --format=%s HEAD", projectRoot).trim();
77
+ }
78
+
79
+ /** Check if HEAD has a parent commit (i.e. can go back one). */
80
+ export function hasParent(projectRoot: string): boolean {
81
+ try {
82
+ gitExec("rev-parse HEAD~1", projectRoot);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** Hard reset — discard all uncommitted changes (tracked and untracked), keep HEAD. */
90
+ export function resetHard(projectRoot: string): void {
91
+ gitExec("reset --hard", projectRoot, { stdio: "pipe" as const });
92
+ gitExec("clean -fd", projectRoot, { stdio: "pipe" as const });
93
+ }
94
+
95
+ /** Soft reset — remove last commit, keep working tree content as unstaged. */
96
+ export function undoLastCommit(projectRoot: string): void {
97
+ gitExec("reset --soft HEAD~1", projectRoot, { stdio: "pipe" as const });
98
+ }
@@ -0,0 +1,6 @@
1
+ export { isAllowed, disallowedFiles } from "./enforce.js";
2
+ export { initGit, snapshot, changesSinceSnapshot, modifiedFiles, untrackedFiles, restoreFiles, headHash, headMessage, hasParent, resetHard, undoLastCommit } from "./git.js";
3
+ export { loadConfig } from "./config.js";
4
+ export { loadPhaseState, savePhaseState } from "./state.js";
5
+ export { nextPhase, checkGate, getDisallowedChanges } from "./transition.js";
6
+ export type { Phase, PhaseState, Config, Transition, GateResult, TestRunner } from "./types.js";
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { loadPhaseState, savePhaseState } from "./state.js";
6
+
7
+ function withTempDir(fn: (dir: string) => void) {
8
+ const dir = join(tmpdir(), `tdd-state-test-${Date.now()}`);
9
+ mkdirSync(dir, { recursive: true });
10
+ try {
11
+ fn(dir);
12
+ } finally {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ describe("loadPhaseState", () => {
18
+ it("throws when no file exists", () => {
19
+ withTempDir((dir) => {
20
+ expect(() => loadPhaseState(dir)).toThrow();
21
+ });
22
+ });
23
+
24
+ it("returns parsed state from phase.json", () => {
25
+ withTempDir((dir) => {
26
+ const tddDir = join(dir, ".pi", "tdd");
27
+ mkdirSync(tddDir, { recursive: true });
28
+ writeFileSync(
29
+ join(tddDir, "phase.json"),
30
+ JSON.stringify({ enabled: true, current: "green" }),
31
+ "utf-8",
32
+ );
33
+ const state = loadPhaseState(dir);
34
+ expect(state.enabled).toBe(true);
35
+ expect(state.current).toBe("green");
36
+ });
37
+ });
38
+
39
+ it("throws when current is an invalid phase", () => {
40
+ withTempDir((dir) => {
41
+ const tddDir = join(dir, ".pi", "tdd");
42
+ mkdirSync(tddDir, { recursive: true });
43
+ writeFileSync(
44
+ join(tddDir, "phase.json"),
45
+ JSON.stringify({ enabled: true, current: "blurple" }),
46
+ "utf-8",
47
+ );
48
+ expect(() => loadPhaseState(dir)).toThrow();
49
+ });
50
+ });
51
+
52
+ it("throws when current is the old 'off' value", () => {
53
+ withTempDir((dir) => {
54
+ const tddDir = join(dir, ".pi", "tdd");
55
+ mkdirSync(tddDir, { recursive: true });
56
+ writeFileSync(
57
+ join(tddDir, "phase.json"),
58
+ JSON.stringify({ enabled: false, current: "off" }),
59
+ "utf-8",
60
+ );
61
+ expect(() => loadPhaseState(dir)).toThrow();
62
+ });
63
+ });
64
+
65
+ it("throws on malformed JSON", () => {
66
+ withTempDir((dir) => {
67
+ const tddDir = join(dir, ".pi", "tdd");
68
+ mkdirSync(tddDir, { recursive: true });
69
+ writeFileSync(join(tddDir, "phase.json"), "not json{{{", "utf-8");
70
+ expect(() => loadPhaseState(dir)).toThrow();
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("savePhaseState", () => {
76
+ it("writes phase.json and can be read back", () => {
77
+ withTempDir((dir) => {
78
+ savePhaseState(dir, { enabled: true, current: "refactor" });
79
+ const state = loadPhaseState(dir);
80
+ expect(state.enabled).toBe(true);
81
+ expect(state.current).toBe("refactor");
82
+ });
83
+ });
84
+ });
@@ -0,0 +1,38 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import type { PhaseState } from "./types.js";
4
+
5
+ const TDD_DIR = ".pi/tdd";
6
+ const VALID_PHASES = new Set(["red", "green", "refactor"]);
7
+
8
+ export function phaseStatePath(projectRoot: string): string {
9
+ return join(projectRoot, TDD_DIR, "phase.json");
10
+ }
11
+
12
+ function ensureDir(path: string): void {
13
+ const dir = dirname(path);
14
+ if (!existsSync(dir)) {
15
+ mkdirSync(dir, { recursive: true });
16
+ }
17
+ }
18
+
19
+ export function loadPhaseState(projectRoot: string): PhaseState {
20
+ const path = phaseStatePath(projectRoot);
21
+ const raw = readFileSync(path, "utf-8");
22
+ const parsed = JSON.parse(raw) as PhaseState;
23
+
24
+ if (typeof parsed.current !== "string" || !VALID_PHASES.has(parsed.current)) {
25
+ throw new Error(`phase.json: invalid phase "${String(parsed.current)}". Must be red, green, or refactor.`);
26
+ }
27
+
28
+ return {
29
+ enabled: parsed.enabled === true,
30
+ current: parsed.current,
31
+ };
32
+ }
33
+
34
+ export function savePhaseState(projectRoot: string, state: PhaseState): void {
35
+ const path = phaseStatePath(projectRoot);
36
+ ensureDir(path);
37
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
38
+ }