tdd-enforcer 0.3.3 → 0.3.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/README.md +169 -0
- package/adapters/pi/hooks.ts +1 -2
- package/adapters/pi/index.ts +7 -3
- package/adapters/pi/tools.test.ts +19 -0
- package/adapters/pi/tools.ts +59 -105
- package/engine/index.ts +6 -1
- package/engine/log.test.ts +171 -0
- package/engine/orchestrate.test.ts +445 -0
- package/engine/orchestrate.ts +159 -0
- package/engine/prompts.test.ts +90 -0
- package/{adapters/pi → engine}/prompts.ts +1 -1
- package/engine/state.test.ts +274 -2
- package/engine/state.ts +135 -1
- package/engine/transition.test.ts +46 -2
- package/engine/transition.ts +10 -0
- package/package.json +13 -1
- package/adapters/pi/helpers.test.ts +0 -275
- package/adapters/pi/helpers.ts +0 -143
- package/adapters/pi/log.test.ts +0 -95
- package/adapters/pi/prompts.test.ts +0 -35
- /package/{adapters/pi → engine}/log.ts +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# tdd-enforcer
|
|
2
|
+
|
|
3
|
+
**Lock files per TDD phase. Gate transitions on test outcomes.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/Cyclone1070/tdd-enforcer/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/tdd-enforcer)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Phase-locked file access** — prevents the agent from modifying test files in GREEN and implementation files in RED. Everything is open in REFACTOR
|
|
14
|
+
- **Guards all file-modifying tools** — `write`, `edit`, and `bash` are all intercepted. All invalid modifications are blocked.
|
|
15
|
+
- **Automatic transition gates** — advancing to the next phase requires tests to fail (RED→GREEN) or pass (GREEN→REFACTOR, REFACTOR→RED). Misconfigured or broken transitions are rejected
|
|
16
|
+
- **Safe rollback** — if the previous phase's work was wrong, reverting discards all current changes and restores the exact working tree from before that phase
|
|
17
|
+
- **Stays out of your way** — TDD enforcement is opt-in (`/tdd:on`). Disable anytime with `/tdd:off` to unlock all files
|
|
18
|
+
- **Version-controlled config** — `.pi/tdd/rules.json` lives in your repo alongside the code, so the whole team shares the same rules
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
### 1. Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi install npm:tdd-enforcer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### 2. Ask the agent to set up TDD
|
|
31
|
+
|
|
32
|
+
Tell the agent to configure TDD for your project, using the tdd-enforcer skill to create `.pi/tdd/rules.json` with the right file globs and test commands for your stack.
|
|
33
|
+
|
|
34
|
+
### 3. Enable TDD
|
|
35
|
+
|
|
36
|
+
Once configured, run:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/tdd:on
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Config reference
|
|
43
|
+
|
|
44
|
+
`.pi/tdd/rules.json` fields (created by the agent, not manually):
|
|
45
|
+
|
|
46
|
+
| Field | Description |
|
|
47
|
+
|-------|-------------|
|
|
48
|
+
| `blockedInRed` | Globs the agent **cannot** touch in RED phase (implementation files) |
|
|
49
|
+
| `blockedInGreen` | Globs the agent **cannot** touch in GREEN phase (test files) |
|
|
50
|
+
| `!` prefix | Exclusion: carves out subsets from a block (e.g. co-located test files) |
|
|
51
|
+
| `testCommands` | Commands run in parallel for gate checks. Exit 0 = pass, non-zero = block. Use `&&` inside a single entry to chain dependent steps |
|
|
52
|
+
| `timeoutSeconds` | Test timeout per command (default: 120) |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### User commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---------|-------------|
|
|
62
|
+
| `/tdd:on` | Enable TDD enforcement |
|
|
63
|
+
| `/tdd:off` | Disable TDD enforcement, all files become free |
|
|
64
|
+
| `/tdd:status` | Show phase, blocked globs, test commands |
|
|
65
|
+
| `/tdd:reset` | **Destructive**: nukes all snapshot history, resets to RED (disabled) |
|
|
66
|
+
| `/tdd:red`, `/tdd:green`, `/tdd:refactor` | Skip to a given phase (auto-enables, no gate checks) |
|
|
67
|
+
|
|
68
|
+
### Agent tools
|
|
69
|
+
|
|
70
|
+
| Tool | When to use | Effect |
|
|
71
|
+
|------|-------------|--------|
|
|
72
|
+
| `next_tdd_phase` | Current phase work is complete | Runs allowlist check + gate test, snapshots state, advances phase|
|
|
73
|
+
| `previous_tdd_phase` | Previous phase work was wrong | **Discards all current-phase changes**, restores working tree to previous snapshot |
|
|
74
|
+
| `tdd_status` | Check current enforcement state | Returns phase, blocked globs, test commands |
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## How it works
|
|
79
|
+
|
|
80
|
+
Uses a **private git repository** at `.pi/tdd/.git/` (separate from your project's real git history) to detect locked-file changes, revert invalid modifications, and track state across phase transitions.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
tests fail tests pass
|
|
84
|
+
┌──────┐ (gate check) ┌────────┐ (gate check) ┌──────────┐
|
|
85
|
+
│ RED │ ──────────────────▶│ GREEN │ ───────────────▶│ REFACTOR │
|
|
86
|
+
│(test)│ │ (impl) │ │(cleanup) │
|
|
87
|
+
└──────┘ └────────┘ └──────────┘
|
|
88
|
+
▲ │
|
|
89
|
+
└────────────────────────────────────────────────────────┘
|
|
90
|
+
tests pass
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every phase transition runs two validations before advancing:
|
|
94
|
+
|
|
95
|
+
1. **Allowlist check** — scans working tree changes against the phase's blocked globs. If any locked file has been modified, the transition is rejected with the violating paths listed
|
|
96
|
+
2. **Gate check** — runs `testCommands` in parallel. The required outcome depends on the transition:
|
|
97
|
+
- RED→GREEN: all commands must fail (a passing test suite means there's no failing test to justify moving to GREEN)
|
|
98
|
+
- GREEN→REFACTOR: all commands must pass
|
|
99
|
+
- REFACTOR→RED: all commands must pass
|
|
100
|
+
|
|
101
|
+
If both checks pass, the working tree is snapshotted and the phase advances.
|
|
102
|
+
|
|
103
|
+
### File-level enforcement
|
|
104
|
+
|
|
105
|
+
When TDD is active, every `write`, `edit`, and `bash` tool call is intercepted:
|
|
106
|
+
|
|
107
|
+
- **`write` / `edit`** — the target file path is checked against the current phase's blocked globs before the tool executes. Locked file writes are blocked with an error message
|
|
108
|
+
- **`bash`** — the working tree is stashed before the command runs. After it finishes, the diff is compared against the stash to find what changed. Any locked-file modifications are automatically reverted, and the command output is amended with a warning listing the violations
|
|
109
|
+
|
|
110
|
+
This means the agent can attempt any change — enforcement happens transparently at the tool layer.
|
|
111
|
+
|
|
112
|
+
### Rollback mechanics
|
|
113
|
+
|
|
114
|
+
Each phase transition creates a labeled commit in a private git repository at `.pi/tdd/.git/`. Calling `previous_tdd_phase`:
|
|
115
|
+
|
|
116
|
+
1. Confirms HEAD is a TDD snapshot (commit message starts with `tdd: {phase}`)
|
|
117
|
+
2. Hard-resets the working tree to discard uncommitted changes
|
|
118
|
+
3. Soft-resets HEAD~1 to pop the snapshot
|
|
119
|
+
4. Sets the phase back
|
|
120
|
+
|
|
121
|
+
Since TDD owns its own git repo, rollback doesn't touch the project's real git history at all.
|
|
122
|
+
|
|
123
|
+
### State recovery
|
|
124
|
+
|
|
125
|
+
If `state.json` is missing or corrupted, the extension recovers by reading the last TDD commit message from `.pi/tdd/.git/`. The label (`tdd: red`, `tdd: green`, etc.) determines the current phase. If no TDD commits exist, it defaults to disabled in RED.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm install
|
|
133
|
+
npm run check
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Project structure
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
tdd-enforcer/
|
|
140
|
+
├── engine/ # Framework-agnostic core
|
|
141
|
+
│ ├── types.ts # Phase, Config, Transition types
|
|
142
|
+
│ ├── config.ts # Load & validate rules.json
|
|
143
|
+
│ ├── state.ts # Load/save/recover phase state
|
|
144
|
+
│ ├── enforce.ts # Glob-based file allowlist checks
|
|
145
|
+
│ ├── transition.ts # Gate checks (test failure/pass per transition)
|
|
146
|
+
│ ├── orchestrate.ts # advancePhase / revertPhase orchestration
|
|
147
|
+
│ ├── git.ts # Private git repo for snapshots & diff
|
|
148
|
+
│ ├── log.ts # Append-only log with line cap
|
|
149
|
+
│ └── prompts.ts # Phase-specific agent nudges
|
|
150
|
+
├── adapters/
|
|
151
|
+
│ └── pi/ # pi extension adapter
|
|
152
|
+
│ ├── index.ts # Extension entry: commands (tdd:on/off/status/reset/jump)
|
|
153
|
+
│ ├── hooks.ts # Intercept write/edit/bash tool calls & results
|
|
154
|
+
│ └── tools.ts # Agent tools: next_tdd_phase, previous_tdd_phase, tdd_status
|
|
155
|
+
├── skills/
|
|
156
|
+
│ └── tdd-enforcer/
|
|
157
|
+
│ └── SKILL.md # Agent instructions for TDD workflows
|
|
158
|
+
└── package.json
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- `engine/` — pure logic, zero pi dependencies. Testable in isolation
|
|
162
|
+
- `adapters/pi/` — pi-specific wiring: commands, hooks, agent tools
|
|
163
|
+
- `skills/tdd-enforcer/` — agent instructions consumed at runtime
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
package/adapters/pi/hooks.ts
CHANGED
|
@@ -13,9 +13,8 @@ import {
|
|
|
13
13
|
gitStashCreate,
|
|
14
14
|
restoreFilesTo,
|
|
15
15
|
} from "../../engine/git.js";
|
|
16
|
+
import { loadTddState, tddLog } from "../../engine/index.js";
|
|
16
17
|
import type { Config, Phase } from "../../engine/types.js";
|
|
17
|
-
import { loadTddState } from "./helpers.js";
|
|
18
|
-
import { tddLog } from "./log.js";
|
|
19
18
|
|
|
20
19
|
export async function handleToolCall(
|
|
21
20
|
event: any,
|
package/adapters/pi/index.ts
CHANGED
|
@@ -3,10 +3,14 @@ import type {
|
|
|
3
3
|
ExtensionAPI,
|
|
4
4
|
ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
loadTddState,
|
|
8
|
+
resetGit,
|
|
9
|
+
savePhaseState,
|
|
10
|
+
snapshot,
|
|
11
|
+
tddLog,
|
|
12
|
+
} from "../../engine/index.js";
|
|
8
13
|
import { registerHooks } from "./hooks.js";
|
|
9
|
-
import { tddLog } from "./log.js";
|
|
10
14
|
import { registerTools } from "./tools.js";
|
|
11
15
|
|
|
12
16
|
export async function handleTddOn(
|
|
@@ -172,6 +172,25 @@ describe("executeNextPhase", () => {
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
it("blocks on timeout with timeout message from checkGate", async () => {
|
|
176
|
+
mockLoadTddState.mockReturnValue({
|
|
177
|
+
ok: true,
|
|
178
|
+
state: { enabled: true, current: "red" },
|
|
179
|
+
config: CONFIG,
|
|
180
|
+
});
|
|
181
|
+
mockCheckGate.mockResolvedValue({
|
|
182
|
+
passed: false,
|
|
183
|
+
timeout: true,
|
|
184
|
+
message:
|
|
185
|
+
"Tests timed out after 30s. The test command may have hung or an operation may be blocking.",
|
|
186
|
+
});
|
|
187
|
+
await expect(
|
|
188
|
+
executeNextPhase({ cwd: "/test" } as any, makeDeps()),
|
|
189
|
+
).rejects.toThrow("timed out");
|
|
190
|
+
expect(mockSnapshot).not.toHaveBeenCalled();
|
|
191
|
+
expect(mockSavePhaseState).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
175
194
|
it("advances green→refactor when tests pass", async () => {
|
|
176
195
|
mockLoadTddState.mockReturnValue({
|
|
177
196
|
ok: true,
|
package/adapters/pi/tools.ts
CHANGED
|
@@ -11,19 +11,22 @@ import type {
|
|
|
11
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { Phase, TestRunner } from "../../engine/index.js";
|
|
13
13
|
import {
|
|
14
|
+
advancePhase,
|
|
14
15
|
checkGate,
|
|
15
16
|
getDisallowedChanges,
|
|
17
|
+
getNudgePrompt,
|
|
18
|
+
getStatusInfo,
|
|
16
19
|
hasParent,
|
|
17
20
|
headMessage,
|
|
21
|
+
loadTddState,
|
|
18
22
|
nextPhase,
|
|
19
23
|
resetHard,
|
|
24
|
+
revertPhase,
|
|
20
25
|
savePhaseState,
|
|
21
26
|
snapshot,
|
|
27
|
+
tddLog,
|
|
22
28
|
undoLastCommit,
|
|
23
29
|
} from "../../engine/index.js";
|
|
24
|
-
import { loadTddState } from "./helpers.js";
|
|
25
|
-
import { tddLog } from "./log.js";
|
|
26
|
-
import { getNudgePrompt } from "./prompts.js";
|
|
27
30
|
|
|
28
31
|
// ── De dependency types ─────────────────────────────────────────────────────
|
|
29
32
|
|
|
@@ -115,34 +118,37 @@ export async function executeNextPhase(
|
|
|
115
118
|
|
|
116
119
|
deps.tddLog(tddDir, "INFO", "next_tdd_phase: starting", { from, to });
|
|
117
120
|
|
|
118
|
-
// 1. Allowlist check
|
|
119
|
-
const violations = deps.getDisallowedChanges(root, from, config);
|
|
120
|
-
if (violations.length > 0) {
|
|
121
|
-
deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
122
|
-
from,
|
|
123
|
-
violations,
|
|
124
|
-
});
|
|
125
|
-
throw new Error(
|
|
126
|
-
`BLOCKED: files not allowed in ${from.toUpperCase()} phase:\n` +
|
|
127
|
-
violations.map((f) => ` - ${f}`).join("\n") +
|
|
128
|
-
`\nRevert or remove them before proceeding.\n\nInspect with: cd .pi/tdd && git diff HEAD -- ${violations[0]}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// 2. Gate check
|
|
133
121
|
const testRunner: TestRunner = async (commands, timeout) => {
|
|
134
122
|
const results = await Promise.all(
|
|
135
123
|
commands.map(async (cmd) => {
|
|
136
124
|
try {
|
|
137
|
-
await deps.asyncExec(cmd, {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
125
|
+
await deps.asyncExec(cmd, {
|
|
126
|
+
cwd: root,
|
|
127
|
+
timeout: timeout * 1000,
|
|
128
|
+
});
|
|
129
|
+
return { command: cmd, passed: true, timedOut: false } as const;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const timedOut = (err as any)?.killed === true;
|
|
132
|
+
return {
|
|
133
|
+
command: cmd,
|
|
134
|
+
passed: false,
|
|
135
|
+
timedOut,
|
|
136
|
+
} as const;
|
|
141
137
|
}
|
|
142
138
|
}),
|
|
143
139
|
);
|
|
144
140
|
|
|
145
|
-
const
|
|
141
|
+
const timedOut = results.filter((r) => r.timedOut);
|
|
142
|
+
const failed = results.filter((r) => !r.passed && !r.timedOut);
|
|
143
|
+
|
|
144
|
+
if (timedOut.length > 0) {
|
|
145
|
+
return {
|
|
146
|
+
passed: false,
|
|
147
|
+
timeout: true,
|
|
148
|
+
message: `Tests timed out after ${timeout}s:\n${timedOut.map((f) => ` - ${f.command}`).join("\n")}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
146
152
|
if (failed.length > 0) {
|
|
147
153
|
return {
|
|
148
154
|
passed: false,
|
|
@@ -152,31 +158,30 @@ export async function executeNextPhase(
|
|
|
152
158
|
return { passed: true, message: "All tests passed." };
|
|
153
159
|
};
|
|
154
160
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
const result = await advancePhase(root, state, config, {
|
|
162
|
+
nextPhase: deps.nextPhase,
|
|
163
|
+
getDisallowedChanges: deps.getDisallowedChanges,
|
|
164
|
+
checkGate: deps.checkGate,
|
|
165
|
+
snapshot: deps.snapshot,
|
|
166
|
+
savePhaseState: deps.savePhaseState,
|
|
167
|
+
testRunner,
|
|
161
168
|
});
|
|
162
169
|
|
|
163
|
-
if (!
|
|
164
|
-
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
// advancePhase already returns the exact error message with revert hint
|
|
172
|
+
// Log the failure at adapter level
|
|
173
|
+
deps.tddLog(tddDir, "WARN", "next_tdd_phase: blocked by allowlist", {
|
|
174
|
+
from,
|
|
175
|
+
violations: result.message,
|
|
176
|
+
});
|
|
177
|
+
throw new Error(result.message);
|
|
165
178
|
}
|
|
166
179
|
|
|
167
|
-
|
|
168
|
-
const hash = deps.snapshot(root, from);
|
|
169
|
-
deps.tddLog(tddDir, "INFO", "next_tdd_phase: snapshot created", {
|
|
180
|
+
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", {
|
|
170
181
|
from,
|
|
171
182
|
to,
|
|
172
|
-
hash,
|
|
173
183
|
});
|
|
174
184
|
|
|
175
|
-
// 4. Save state
|
|
176
|
-
state.current = to;
|
|
177
|
-
deps.savePhaseState(root, state);
|
|
178
|
-
deps.tddLog(tddDir, "INFO", "next_tdd_phase: complete", { from, to });
|
|
179
|
-
|
|
180
185
|
return {
|
|
181
186
|
content: [{ type: "text", text: `\n${deps.getNudgePrompt(to, config)}` }],
|
|
182
187
|
details: {},
|
|
@@ -208,65 +213,28 @@ export async function executePreviousPhase(
|
|
|
208
213
|
|
|
209
214
|
const { state } = tdd;
|
|
210
215
|
|
|
211
|
-
|
|
212
|
-
deps.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// Read phase from HEAD snapshot commit message (source of truth).
|
|
219
|
-
// Snapshot is labeled with the phase the work was done in, so we use
|
|
220
|
-
// it directly — no hardcoded phase map needed.
|
|
221
|
-
const headMsg = deps.headMessage(root);
|
|
222
|
-
const phaseMatch = headMsg.match(/^tdd: (red|green|refactor)/);
|
|
223
|
-
if (!phaseMatch) {
|
|
224
|
-
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid HEAD message", {
|
|
225
|
-
headMsg,
|
|
226
|
-
});
|
|
227
|
-
throw new Error(
|
|
228
|
-
`HEAD commit "${headMsg}" is not a TDD snapshot. Cannot determine previous phase.\n` +
|
|
229
|
-
`The private git repo at .pi/tdd must not be manually modified. ` +
|
|
230
|
-
`Tampering with it will cause TDD state corruption.`,
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
const label = phaseMatch[1];
|
|
234
|
-
if (label !== "red" && label !== "green" && label !== "refactor") {
|
|
235
|
-
deps.tddLog(tddDir, "ERROR", "previous_tdd_phase: invalid phase label", {
|
|
236
|
-
headMsg,
|
|
237
|
-
label,
|
|
238
|
-
});
|
|
239
|
-
throw new Error(
|
|
240
|
-
`HEAD commit "${headMsg}" has unexpected label. Cannot determine previous phase.`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
const prevPhase: Phase = label;
|
|
244
|
-
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: reverting", {
|
|
245
|
-
from: state.current,
|
|
246
|
-
to: prevPhase,
|
|
247
|
-
headMsg,
|
|
216
|
+
const result = await revertPhase(root, state, {
|
|
217
|
+
hasParent: deps.hasParent,
|
|
218
|
+
headMessage: deps.headMessage,
|
|
219
|
+
resetHard: deps.resetHard,
|
|
220
|
+
undoLastCommit: deps.undoLastCommit,
|
|
221
|
+
savePhaseState: deps.savePhaseState,
|
|
248
222
|
});
|
|
249
223
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// 2. Pop last snapshot commit, keep its content as unstaged
|
|
255
|
-
deps.undoLastCommit(root);
|
|
256
|
-
deps.tddLog(tddDir, "DEBUG", "previous_tdd_phase: undoLastCommit done");
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
throw new Error(result.message);
|
|
226
|
+
}
|
|
257
227
|
|
|
258
|
-
// 3. Update phase label from the snapshot's own label
|
|
259
|
-
state.current = prevPhase;
|
|
260
|
-
deps.savePhaseState(root, state);
|
|
261
228
|
deps.tddLog(tddDir, "INFO", "previous_tdd_phase: complete", {
|
|
262
|
-
|
|
229
|
+
from: state.current,
|
|
230
|
+
to: result.newState?.current,
|
|
263
231
|
});
|
|
264
232
|
|
|
265
233
|
return {
|
|
266
234
|
content: [
|
|
267
235
|
{
|
|
268
236
|
type: "text",
|
|
269
|
-
text: `\
|
|
237
|
+
text: `\n${result.message} Working tree has the previous snapshot content as unstaged changes.`,
|
|
270
238
|
},
|
|
271
239
|
],
|
|
272
240
|
details: {},
|
|
@@ -293,11 +261,7 @@ export async function executeTddStatus(
|
|
|
293
261
|
throw new Error(`TDD: ${result.reason}`);
|
|
294
262
|
}
|
|
295
263
|
const { state, config } = result;
|
|
296
|
-
const
|
|
297
|
-
const phaseStr = state.current.toUpperCase();
|
|
298
|
-
const redBlk = config.blockedInRed.join(", ") || "(none)";
|
|
299
|
-
const greenBlk = config.blockedInGreen.join(", ") || "(none)";
|
|
300
|
-
const commands = config.testCommands.join(", ") || "(none)";
|
|
264
|
+
const info = getStatusInfo(state, config);
|
|
301
265
|
|
|
302
266
|
deps.tddLog(tddDir, "INFO", "tdd_status: queried", {
|
|
303
267
|
enabled: state.enabled,
|
|
@@ -305,17 +269,7 @@ export async function executeTddStatus(
|
|
|
305
269
|
});
|
|
306
270
|
|
|
307
271
|
return {
|
|
308
|
-
content: [
|
|
309
|
-
{
|
|
310
|
-
type: "text",
|
|
311
|
-
text:
|
|
312
|
-
`\nTDD enforcer ${enabledStr}\n` +
|
|
313
|
-
`Current phase: ${phaseStr}\n` +
|
|
314
|
-
`Blocked in RED: ${redBlk}\n` +
|
|
315
|
-
`Blocked in GREEN: ${greenBlk}\n` +
|
|
316
|
-
`Test commands: ${commands}`,
|
|
317
|
-
},
|
|
318
|
-
],
|
|
272
|
+
content: [{ type: "text", text: `\n${info}` }],
|
|
319
273
|
details: {
|
|
320
274
|
enabled: state.enabled,
|
|
321
275
|
phase: state.current,
|
package/engine/index.ts
CHANGED
|
@@ -17,7 +17,12 @@ export {
|
|
|
17
17
|
undoLastCommit,
|
|
18
18
|
untrackedFiles,
|
|
19
19
|
} from "./git.js";
|
|
20
|
-
export {
|
|
20
|
+
export { tddLog } from "./log.js";
|
|
21
|
+
export type { AdvanceResult } from "./orchestrate.js";
|
|
22
|
+
export { advancePhase, getStatusInfo, revertPhase } from "./orchestrate.js";
|
|
23
|
+
export { getNudgePrompt } from "./prompts.js";
|
|
24
|
+
export type { TddLoadResult } from "./state.js";
|
|
25
|
+
export { loadPhaseState, loadTddState, savePhaseState } from "./state.js";
|
|
21
26
|
export { checkGate, getDisallowedChanges, nextPhase } from "./transition.js";
|
|
22
27
|
export type {
|
|
23
28
|
Config,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { tddLog } from "./log.js";
|
|
3
|
+
|
|
4
|
+
describe("tddLog", () => {
|
|
5
|
+
let mockExistsSync: ReturnType<typeof vi.fn>;
|
|
6
|
+
let mockMkdirSync: ReturnType<typeof vi.fn>;
|
|
7
|
+
let mockAppendFileSync: ReturnType<typeof vi.fn>;
|
|
8
|
+
let mockWriteFileSync: ReturnType<typeof vi.fn>;
|
|
9
|
+
let mockReadFileSync: ReturnType<typeof vi.fn>;
|
|
10
|
+
let writtenContent: string;
|
|
11
|
+
|
|
12
|
+
function makeDeps() {
|
|
13
|
+
return {
|
|
14
|
+
existsSync: mockExistsSync,
|
|
15
|
+
mkdirSync: mockMkdirSync,
|
|
16
|
+
appendFileSync: mockAppendFileSync,
|
|
17
|
+
writeFileSync: mockWriteFileSync,
|
|
18
|
+
readFileSync: mockReadFileSync,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
writtenContent = "";
|
|
24
|
+
mockExistsSync = vi.fn().mockReturnValue(true);
|
|
25
|
+
mockMkdirSync = vi.fn();
|
|
26
|
+
mockAppendFileSync = vi.fn((_path: string, content: string) => {
|
|
27
|
+
writtenContent += content;
|
|
28
|
+
});
|
|
29
|
+
mockWriteFileSync = vi.fn((_path: string, content: string) => {
|
|
30
|
+
writtenContent = content;
|
|
31
|
+
});
|
|
32
|
+
mockReadFileSync = vi.fn(() => writtenContent);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("appends log line with timestamp and level", () => {
|
|
36
|
+
tddLog("/tdd", "INFO", "hello", undefined, makeDeps());
|
|
37
|
+
|
|
38
|
+
expect(mockAppendFileSync).toHaveBeenCalledOnce();
|
|
39
|
+
const written = mockAppendFileSync.mock.calls[0][1] as string;
|
|
40
|
+
expect(written).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
41
|
+
expect(written).toContain("[INFO]");
|
|
42
|
+
expect(written).toContain("hello");
|
|
43
|
+
expect(written.endsWith("\n")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("appends multiple lines", () => {
|
|
47
|
+
tddLog("/tdd", "INFO", "line one", undefined, makeDeps());
|
|
48
|
+
tddLog("/tdd", "DEBUG", "line two", undefined, makeDeps());
|
|
49
|
+
|
|
50
|
+
const lines = writtenContent.trim().split("\n");
|
|
51
|
+
expect(lines).toHaveLength(2);
|
|
52
|
+
expect(lines[0]).toContain("[INFO]");
|
|
53
|
+
expect(lines[0]).toContain("line one");
|
|
54
|
+
expect(lines[1]).toContain("[DEBUG]");
|
|
55
|
+
expect(lines[1]).toContain("line two");
|
|
56
|
+
expect(mockAppendFileSync).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("includes data as JSON when provided", () => {
|
|
60
|
+
tddLog("/tdd", "INFO", "with data", { key: "val", num: 42 }, makeDeps());
|
|
61
|
+
|
|
62
|
+
expect(writtenContent).toContain('{"key":"val","num":42}');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("includes data after message separated by a space", () => {
|
|
66
|
+
tddLog("/tdd", "INFO", "msg", { a: 1 }, makeDeps());
|
|
67
|
+
|
|
68
|
+
const line = writtenContent.trim();
|
|
69
|
+
expect(line).toContain('msg {"a":1}');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("trims to last 1000 lines when exceeded", () => {
|
|
73
|
+
for (let i = 0; i < 1005; i++) {
|
|
74
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lines = writtenContent.trim().split("\n");
|
|
78
|
+
expect(lines).toHaveLength(1000);
|
|
79
|
+
expect(lines[0]).toContain("line 5");
|
|
80
|
+
expect(lines[999]).toContain("line 1004");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not throw when appendFileSync fails", () => {
|
|
84
|
+
mockAppendFileSync = vi.fn(() => {
|
|
85
|
+
throw new Error("ENOENT: no such file or directory");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(() =>
|
|
89
|
+
tddLog("/nonexistent/path/tdd", "INFO", "fail", undefined, makeDeps()),
|
|
90
|
+
).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not throw when readFileSync fails (first write)", () => {
|
|
94
|
+
mockReadFileSync = vi.fn(() => {
|
|
95
|
+
throw new Error("ENOENT: no such file or directory");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(() =>
|
|
99
|
+
tddLog("/tdd", "INFO", "hello", undefined, makeDeps()),
|
|
100
|
+
).not.toThrow();
|
|
101
|
+
expect(mockAppendFileSync).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles undefined data gracefully", () => {
|
|
105
|
+
tddLog("/tdd", "WARN", "no data", undefined, makeDeps());
|
|
106
|
+
|
|
107
|
+
expect(writtenContent).toContain("[WARN]");
|
|
108
|
+
expect(writtenContent).toContain("no data");
|
|
109
|
+
expect(writtenContent).not.toContain("{}");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("logs at WARN level", () => {
|
|
113
|
+
tddLog("/tdd", "WARN", "warning message", undefined, makeDeps());
|
|
114
|
+
expect(writtenContent).toContain("[WARN]");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("logs at ERROR level", () => {
|
|
118
|
+
tddLog("/tdd", "ERROR", "error message", undefined, makeDeps());
|
|
119
|
+
expect(writtenContent).toContain("[ERROR]");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("logs at DEBUG level", () => {
|
|
123
|
+
tddLog("/tdd", "DEBUG", "debug message", undefined, makeDeps());
|
|
124
|
+
expect(writtenContent).toContain("[DEBUG]");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("writes to the path join(tddDir, 'tdd.log')", () => {
|
|
128
|
+
tddLog("/custom/tdd/path", "INFO", "test", undefined, makeDeps());
|
|
129
|
+
|
|
130
|
+
expect(mockAppendFileSync).toHaveBeenCalledWith(
|
|
131
|
+
"/custom/tdd/path/tdd.log",
|
|
132
|
+
expect.any(String),
|
|
133
|
+
"utf-8",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("trims to exactly 1000 lines keeping newest", () => {
|
|
138
|
+
for (let i = 0; i < 1001; i++) {
|
|
139
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lines = writtenContent.trim().split("\n");
|
|
143
|
+
expect(lines).toHaveLength(1000);
|
|
144
|
+
expect(lines[0]).toContain("line 1");
|
|
145
|
+
expect(lines[999]).toContain("line 1000");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps all lines when exactly at MAX_LINES", () => {
|
|
149
|
+
for (let i = 0; i < 1000; i++) {
|
|
150
|
+
tddLog("/tdd", "DEBUG", `line ${i}`, undefined, makeDeps());
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lines = writtenContent.trim().split("\n");
|
|
154
|
+
expect(lines).toHaveLength(1000);
|
|
155
|
+
expect(lines[0]).toContain("line 0");
|
|
156
|
+
expect(lines[999]).toContain("line 999");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("appends to existing content instead of overwriting", () => {
|
|
160
|
+
tddLog("/tdd", "INFO", "first", undefined, makeDeps());
|
|
161
|
+
tddLog("/tdd", "INFO", "second", undefined, makeDeps());
|
|
162
|
+
|
|
163
|
+
expect(writtenContent).toContain("first");
|
|
164
|
+
expect(writtenContent).toContain("second");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("uses the real filesystem deps by default", () => {
|
|
168
|
+
// Just verify the function exists and can be called without deps
|
|
169
|
+
expect(typeof tddLog).toBe("function");
|
|
170
|
+
});
|
|
171
|
+
});
|