tdd-enforcer 0.2.6 → 0.2.8
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/.github/workflows/ci.yml +28 -0
- package/adapters/pi/helpers.test.ts +246 -244
- package/adapters/pi/helpers.ts +117 -91
- package/adapters/pi/hooks.test.ts +528 -425
- package/adapters/pi/hooks.ts +297 -230
- package/adapters/pi/index.test.ts +388 -272
- package/adapters/pi/index.ts +284 -200
- package/adapters/pi/log.test.ts +91 -91
- package/adapters/pi/log.ts +39 -27
- package/adapters/pi/prompts.test.ts +25 -25
- package/adapters/pi/prompts.ts +29 -30
- package/adapters/pi/tools.test.ts +387 -331
- package/adapters/pi/tools.ts +321 -325
- package/behaviour.md +15 -1
- package/biome.json +37 -0
- package/engine/config.test.ts +157 -157
- package/engine/config.ts +22 -19
- package/engine/enforce.test.ts +155 -145
- package/engine/enforce.ts +31 -23
- package/engine/git.test.ts +529 -507
- package/engine/git.ts +240 -114
- package/engine/index.ts +27 -4
- package/engine/state.test.ts +69 -69
- package/engine/state.ts +22 -20
- package/engine/transition.test.ts +229 -177
- package/engine/transition.ts +55 -52
- package/engine/types.ts +9 -9
- package/package.json +30 -23
- package/tsconfig.json +10 -10
package/behaviour.md
CHANGED
|
@@ -44,10 +44,24 @@ After this gate, every surface has `state` + `config`. Then branches on `state.e
|
|
|
44
44
|
| `tdd:off` | Setup valid? | If `disabled` → error "already off". Set `enabled: false`. |
|
|
45
45
|
| `tdd:status` | Setup valid? | Show state + config regardless of `enabled`. |
|
|
46
46
|
| `tdd:reset` | Setup valid? | Nuke private git, re-init, snapshot, set `enabled: false`. |
|
|
47
|
-
| `tdd:red`
|
|
47
|
+
| `tdd:red` | Setup valid? | If already in RED → no-op. Snapshot working tree, auto-enable if disabled, set `current: "red"`. Notify "Skipped to RED phase". |
|
|
48
|
+
| `tdd:green` | Setup valid? | If already in GREEN → no-op. Snapshot working tree, auto-enable if disabled, set `current: "green"`. Notify "Skipped to GREEN phase". |
|
|
49
|
+
| `tdd:refactor` | Setup valid? | If already in REFACTOR → no-op. Snapshot working tree, auto-enable if disabled, set `current: "refactor"`. Notify "Skipped to REFACTOR phase". |
|
|
48
50
|
|
|
49
51
|
All errors reference the tdd-enforcer skill.
|
|
50
52
|
|
|
53
|
+
### Phase Jump Commands — Usage
|
|
54
|
+
|
|
55
|
+
These let the user skip phases they don't need for small changes:
|
|
56
|
+
|
|
57
|
+
| Scenario | Command | Why |
|
|
58
|
+
|----------|---------|-----|
|
|
59
|
+
| "This is just an implementation change, no test needed" | `/tdd:green` | Skip RED, go straight to GREEN. Test files stay locked, implementation files unlocked. |
|
|
60
|
+
| "This is just cleanup, not new behaviour" | `/tdd:refactor` | Skip RED + GREEN. All files unlocked. |
|
|
61
|
+
| "Done refactoring, start next cycle" | `/tdd:red` | Same as `next_tdd_phase` from REFACTOR but direct — no gate check needed. |
|
|
62
|
+
|
|
63
|
+
Unlike `next_tdd_phase`, these commands do NOT run transition gate checks. The user is explicitly choosing to skip a phase — they own the consequences.
|
|
64
|
+
|
|
51
65
|
---
|
|
52
66
|
|
|
53
67
|
## Hooks (agent — automatic)
|
package/biome.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
|
|
3
|
+
"vcs": {
|
|
4
|
+
"enabled": true,
|
|
5
|
+
"clientKind": "git",
|
|
6
|
+
"useIgnoreFile": true
|
|
7
|
+
},
|
|
8
|
+
"files": {
|
|
9
|
+
"ignoreUnknown": false
|
|
10
|
+
},
|
|
11
|
+
"formatter": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"indentStyle": "tab"
|
|
14
|
+
},
|
|
15
|
+
"linter": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"rules": {
|
|
18
|
+
"preset": "recommended",
|
|
19
|
+
"suspicious": {
|
|
20
|
+
"noExplicitAny": "off"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"javascript": {
|
|
25
|
+
"formatter": {
|
|
26
|
+
"quoteStyle": "double"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"assist": {
|
|
30
|
+
"enabled": true,
|
|
31
|
+
"actions": {
|
|
32
|
+
"source": {
|
|
33
|
+
"organizeImports": "on"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/engine/config.test.ts
CHANGED
|
@@ -1,176 +1,176 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
2
|
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
6
|
|
|
7
7
|
function withTempDir(fn: (dir: string) => void) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
const dir = join(tmpdir(), `tdd-test-${Date.now()}`);
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
try {
|
|
11
|
+
fn(dir);
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
describe("loadConfig", () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
it("throws when no config exists", () => {
|
|
19
|
+
withTempDir((dir) => {
|
|
20
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
it("loads config from rules.json", () => {
|
|
25
|
+
withTempDir((dir) => {
|
|
26
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
27
|
+
mkdirSync(tddDir, { recursive: true });
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(tddDir, "rules.json"),
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
32
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
33
|
+
testCommands: ["npm run test"],
|
|
34
|
+
timeoutSeconds: 60,
|
|
35
|
+
}),
|
|
36
|
+
"utf-8",
|
|
37
|
+
);
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
const config = loadConfig(dir);
|
|
40
|
+
expect(config.blockedInRed).toEqual(["tests/**/*.test.ts"]);
|
|
41
|
+
expect(config.timeoutSeconds).toBe(60);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
it("supports multiple test commands", () => {
|
|
46
|
+
withTempDir((dir) => {
|
|
47
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
48
|
+
mkdirSync(tddDir, { recursive: true });
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(tddDir, "rules.json"),
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
53
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
54
|
+
testCommands: ["npm run test:unit", "npm run test:integration"],
|
|
55
|
+
}),
|
|
56
|
+
"utf-8",
|
|
57
|
+
);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const config = loadConfig(dir);
|
|
60
|
+
expect(config.testCommands).toHaveLength(2);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
64
|
+
describe("validation — throws on invalid content", () => {
|
|
65
|
+
it("throws when blockedInRed is not an array", () => {
|
|
66
|
+
withTempDir((dir) => {
|
|
67
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
68
|
+
mkdirSync(tddDir, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(tddDir, "rules.json"),
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
blockedInRed: "not-an-array",
|
|
73
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
74
|
+
testCommands: ["npm test"],
|
|
75
|
+
}),
|
|
76
|
+
"utf-8",
|
|
77
|
+
);
|
|
78
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
it("throws when blockedInGreen is not an array", () => {
|
|
83
|
+
withTempDir((dir) => {
|
|
84
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
85
|
+
mkdirSync(tddDir, { recursive: true });
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(tddDir, "rules.json"),
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
90
|
+
blockedInGreen: null,
|
|
91
|
+
testCommands: ["npm test"],
|
|
92
|
+
}),
|
|
93
|
+
"utf-8",
|
|
94
|
+
);
|
|
95
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
99
|
+
it("throws when testCommands is not an array", () => {
|
|
100
|
+
withTempDir((dir) => {
|
|
101
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
102
|
+
mkdirSync(tddDir, { recursive: true });
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(tddDir, "rules.json"),
|
|
105
|
+
JSON.stringify({
|
|
106
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
107
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
108
|
+
testCommands: "npm test",
|
|
109
|
+
}),
|
|
110
|
+
"utf-8",
|
|
111
|
+
);
|
|
112
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
it("throws on malformed JSON", () => {
|
|
117
|
+
withTempDir((dir) => {
|
|
118
|
+
const tddDir = join(dir, ".pi", "tdd");
|
|
119
|
+
mkdirSync(tddDir, { recursive: true });
|
|
120
|
+
writeFileSync(join(tddDir, "rules.json"), "not json{{", "utf-8");
|
|
121
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
125
|
+
it("throws when blockedInRed 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
|
+
blockedInRed: [],
|
|
133
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
134
|
+
testCommands: ["npm test"],
|
|
135
|
+
}),
|
|
136
|
+
"utf-8",
|
|
137
|
+
);
|
|
138
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
it("throws when blockedInGreen 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
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
150
|
+
blockedInGreen: [],
|
|
151
|
+
testCommands: ["npm test"],
|
|
152
|
+
}),
|
|
153
|
+
"utf-8",
|
|
154
|
+
);
|
|
155
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
blockedInRed: ["tests/**/*.test.ts"],
|
|
167
|
+
blockedInGreen: ["src/**/*.ts"],
|
|
168
|
+
testCommands: [],
|
|
169
|
+
}),
|
|
170
|
+
"utf-8",
|
|
171
|
+
);
|
|
172
|
+
expect(() => loadConfig(dir)).toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
176
|
});
|
package/engine/config.ts
CHANGED
|
@@ -5,28 +5,31 @@ import type { Config } from "./types.js";
|
|
|
5
5
|
const TDD_DIR = ".pi/tdd";
|
|
6
6
|
|
|
7
7
|
export function configPath(projectRoot: string): string {
|
|
8
|
-
|
|
8
|
+
return join(projectRoot, TDD_DIR, "rules.json");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function loadConfig(projectRoot: string): Config {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const path = configPath(projectRoot);
|
|
13
|
+
const raw = readFileSync(path, "utf-8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
if (!Array.isArray(parsed.blockedInRed) || parsed.blockedInRed.length === 0) {
|
|
17
|
+
throw new Error("rules.json: blockedInRed must be a non-empty array");
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
!Array.isArray(parsed.blockedInGreen) ||
|
|
21
|
+
parsed.blockedInGreen.length === 0
|
|
22
|
+
) {
|
|
23
|
+
throw new Error("rules.json: blockedInGreen must be a non-empty array");
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(parsed.testCommands) || parsed.testCommands.length === 0) {
|
|
26
|
+
throw new Error("rules.json: testCommands must be a non-empty array");
|
|
27
|
+
}
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
return {
|
|
30
|
+
blockedInRed: parsed.blockedInRed,
|
|
31
|
+
blockedInGreen: parsed.blockedInGreen,
|
|
32
|
+
testCommands: parsed.testCommands,
|
|
33
|
+
timeoutSeconds: parsed.timeoutSeconds ?? 120,
|
|
34
|
+
};
|
|
32
35
|
}
|