initial commit
This commit is contained in:
commit
414140b962
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
tsconfig.tsbuildinfo
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Living Software LTD
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
doc/Screenshot 2026-06-20 at 14.28.29.png
Normal file
BIN
doc/Screenshot 2026-06-20 at 14.28.29.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
BIN
doc/spike-screenshot.jpeg
Normal file
BIN
doc/spike-screenshot.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
172
doc/spike-smells.md
Normal file
172
doc/spike-smells.md
Normal file
@ -0,0 +1,172 @@
|
||||
|
||||
# Asked qwen3.6 35B about code smells
|
||||
|
||||
Analyze the code in src/ for code smells and improvements.
|
||||
|
||||
Qwen:
|
||||
|
||||
# Here's my analysis of the codebase:
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Code Smells & Improvements
|
||||
|
||||
### 1. Duplicated Constants (REFACTOR_FOCI)
|
||||
|
||||
Location: src/state.ts and src/ui.ts
|
||||
|
||||
Both files define their own copy of REFACTOR_FOCI:
|
||||
|
||||
```ts
|
||||
// state.ts
|
||||
export const REFACTOR_FOCI: RefactorFocus[] = [...];
|
||||
// ui.ts (line 16)
|
||||
const REFACTOR_FOCI: RefactorFocus[] = [...];
|
||||
```
|
||||
|
||||
Fix: Export the constant from state.ts and import it in ui.ts. This is a direct DRY violation.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 2. canTransition is Dead Code
|
||||
|
||||
canTransition() in state.ts is never called by any tool or event handler. The phase enforcement in index.ts is done via imperative if checks in the go-to-refactor tool and the tool_call event
|
||||
handler. The function sits there with full test coverage but zero usage.
|
||||
|
||||
Options:
|
||||
- Delete it if it's not needed — the enforcement logic is already in the tools.
|
||||
- Use it in go-to-refactor instead of the manual if/else checks, making the tool logic cleaner and the guard centralized.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 3. redToGreen is Never Called Directly
|
||||
|
||||
redToGreen() is only called internally inside recordTestResult() — it's never invoked by any tool. It's effectively a private helper, yet it's exported and has its own test suite.
|
||||
|
||||
Fix: Either make it private (or just not export it) and remove its standalone tests, or keep it exported if you want it as a reusable state-machine primitive.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 4. Magic String "npm test"
|
||||
|
||||
go-to-green hardcodes const testCommand = "npm test 2>&1"; while run-tests allows a custom command. The go-to-green tool should also accept an optional command parameter for consistency.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 5. persistState / restoreState — Fragile Serialization
|
||||
|
||||
The state persistence relies on pi.appendEntry / ctx.sessionManager.getEntries() with string-based filtering (e.type === "custom" && e.customType === "tdd-cycle"). This is fragile:
|
||||
- No versioning — if the CycleState interface changes, old entries may corrupt state.
|
||||
- No deduplication logic beyond .pop() — if multiple entries exist, only the last one is kept (which is correct, but the intent isn't explicit).
|
||||
- restoreState is called at the start of every tool execution, meaning state is reloaded on each call — this is fine for a TDD cycle, but worth noting.
|
||||
|
||||
Suggestion: Add a version field to CycleState and handle version mismatches gracefully.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 6. buildSteeringMessage is a Long Switch
|
||||
|
||||
This function has 5 if branches that could be replaced with a Record<RefactorFocus, (decision: RefactorDecision) => string> mapping. This would make it data-driven and easier to extend:
|
||||
|
||||
```ts
|
||||
const STEERING_MESSAGES: Record<RefactorFocus, (d: RefactorDecision) => string> = {
|
||||
"next-test": () => "...",
|
||||
audit: () => "...",
|
||||
// etc.
|
||||
};
|
||||
```
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 7. Repeated ctx.ui.setWidget Calls
|
||||
|
||||
Every tool and event handler calls ctx.ui.setWidget("tdd-status", ...) with similar patterns. This could be extracted into a helper like updateStatusWidget(ctx) to reduce repetition and ensure
|
||||
consistency.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 8. renderResult Type Guards are Repetitive
|
||||
|
||||
All four tools have the same boilerplate in renderResult:
|
||||
|
||||
```ts
|
||||
const details = result.details as { ... } | undefined;
|
||||
if (!details) {
|
||||
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
Fix: Extract this into a shared renderResultFallback helper.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 9. go-to-refactor Non-TUI Fallback is Silent
|
||||
|
||||
When ctx.mode !== "tui", the tool silently defaults to { focus: "next-test" } without any user confirmation. This could be confusing — the developer has no way to choose a different refactoring
|
||||
focus.
|
||||
|
||||
Suggestion: In non-TUI mode, return the REFACTOR phase status without auto-advancing, and let the developer call the tool again with an explicit parameter.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 10. invalidate in UI Component is a No-Op
|
||||
|
||||
```ts
|
||||
return { render, invalidate: () => {}, handleInput };
|
||||
```
|
||||
|
||||
The invalidate callback does nothing. If the TUI framework expects it to trigger a re-render, this is a bug. If it's intentionally unused, document why.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 11. recordTestResult Returns TransitionResult but Caller Ignores valid/error
|
||||
|
||||
In index.ts, recordTestResult is called and its state is used, but the valid and error fields are discarded:
|
||||
|
||||
```ts
|
||||
const transition = recordTestResult(state, passed);
|
||||
state = transition.state; // valid/error ignored
|
||||
```
|
||||
|
||||
If recordTestResult returns invalid (e.g., called from REFACTOR phase), the state is still updated. The tool should check transition.valid and handle errors.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### 12. go-to-green Hardcodes Timeout
|
||||
|
||||
Both go-to-green and run-tests use timeout: 60_000. This is fine, but consider making it configurable via a tool parameter for projects with slower test suites.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
### Summary Table
|
||||
|
||||
┌────┬───────────────────────────────────────────┬──────────┬────────┐
|
||||
│ # │ Smell │ Severity │ Effort │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 1 │ Duplicated REFACTOR_FOCI │ Low │ 5 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 2 │ Dead canTransition │ Medium │ 10 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 3 │ Unused export redToGreen │ Low │ 5 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 4 │ Hardcoded "npm test" in go-to-green │ Low │ 5 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 5 │ Fragile state persistence (no versioning) │ Medium │ 20 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 6 │ Long switch → data-driven mapping │ Low │ 10 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 7 │ Repeated setWidget calls │ Low │ 10 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 8 │ Repetitive renderResult guards │ Low │ 10 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 9 │ Silent non-TUI fallback │ Medium │ 15 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 10 │ No-op invalidate │ Low │ 5 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 11 │ Ignored valid/error from recordTestResult │ High │ 10 min │
|
||||
├────┼───────────────────────────────────────────┼──────────┼────────┤
|
||||
│ 12 │ Hardcoded timeout │ Low │ 5 min │
|
||||
└────┴───────────────────────────────────────────┴──────────┴────────┘
|
||||
|
||||
The highest priority fix is #11 — the recordTestResult return value's valid field is silently ignored, which means invalid state transitions (e.g., recording test results while in REFACTOR phase)
|
||||
would silently corrupt state.
|
||||
3350
package-lock.json
generated
Normal file
3350
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "pi-tdd-cycle",
|
||||
"version": "0.1.0",
|
||||
"description": "Pi extension that enforces the red-green-refactor TDD cycle",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"tdd",
|
||||
"red-green-refactor",
|
||||
"extension"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "latest",
|
||||
"@earendil-works/pi-tui": "latest",
|
||||
"@types/node": "latest",
|
||||
"typebox": "latest",
|
||||
"typescript": "latest",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
495
src/index.ts
Normal file
495
src/index.ts
Normal file
@ -0,0 +1,495 @@
|
||||
/**
|
||||
* TDD Cycle Extension
|
||||
*
|
||||
* Enforces the red-green-refactor TDD cycle:
|
||||
* 🔴 RED → Write a failing test, then implement to make it pass
|
||||
* 🟢 GREEN → Tests pass, ready for refactoring
|
||||
* 🔀 REFACTOR → Developer chooses what to refactor; agent executes;
|
||||
* loop back to developer until "next test" chosen
|
||||
*
|
||||
* Tools:
|
||||
* go-to-green - Run tests and transition based on result
|
||||
* go-to-refactor - Enter the developer decision phase (or report back)
|
||||
* run-tests - Run tests without phase transition
|
||||
* tdd-status - Show current phase and state
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
import {
|
||||
createInitialState,
|
||||
recordTestResult,
|
||||
greenToRefactor,
|
||||
applyRefactorDecision,
|
||||
phaseEmoji,
|
||||
phaseDescription,
|
||||
phaseGuidelines,
|
||||
type CycleState,
|
||||
type RefactorDecision,
|
||||
} from "./state.js";
|
||||
import { createRefactorDecisionComponent } from "./ui.js";
|
||||
|
||||
export default function tddCycle(pi: ExtensionAPI): void {
|
||||
let state: CycleState = createInitialState();
|
||||
|
||||
function persistState(): void {
|
||||
pi.appendEntry("tdd-cycle", {
|
||||
phase: state.phase,
|
||||
testDescription: state.testDescription,
|
||||
testsRan: state.testsRan,
|
||||
testsPassed: state.testsPassed,
|
||||
testsWritten: state.testsWritten,
|
||||
lastDecision: state.lastDecision,
|
||||
customRefactoringInstruction: state.customRefactoringInstruction,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreState(ctx: ExtensionContext): void {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const tddEntry = entries
|
||||
.filter(
|
||||
(e: { type: string; customType?: string }) =>
|
||||
e.type === "custom" && e.customType === "tdd-cycle",
|
||||
)
|
||||
.pop() as { data?: Partial<CycleState> } | undefined;
|
||||
|
||||
if (tddEntry?.data) {
|
||||
state = {
|
||||
...createInitialState(),
|
||||
...tddEntry.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function statusMessage(): string {
|
||||
return phaseDescription(state);
|
||||
}
|
||||
|
||||
function guidelinesMessage(): string {
|
||||
return phaseGuidelines(state);
|
||||
}
|
||||
|
||||
function buildSteeringMessage(decision: RefactorDecision): string {
|
||||
if (decision.focus === "next-test") {
|
||||
return (
|
||||
"🔀 **Next test.** Phase changed: REFACTOR → **RED**.\n\n" +
|
||||
"Write the next failing test."
|
||||
);
|
||||
}
|
||||
if (decision.focus === "audit") {
|
||||
return (
|
||||
"🔍 **Code audit requested.**\n\n" +
|
||||
"Review the implementation:\n" +
|
||||
"1. Identify any code smells\n" +
|
||||
"2. Is all the code actually required?\n" +
|
||||
"3. Is the implementation minimal — does it only implement what the tests require?\n" +
|
||||
"4. Report your findings back to the developer\n\n" +
|
||||
"After your audit, call **go-to-refactor** again to report back."
|
||||
);
|
||||
}
|
||||
if (decision.focus === "tests") {
|
||||
return (
|
||||
"🛠 **Refactor the tests.**\n\n" +
|
||||
"Improve test structure and coverage while keeping tests passing.\n" +
|
||||
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||
);
|
||||
}
|
||||
if (decision.focus === "implementation") {
|
||||
return (
|
||||
"🛠 **Refactor the implementation.**\n\n" +
|
||||
"Improve production code structure.\n" +
|
||||
"After refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||
);
|
||||
}
|
||||
if (decision.focus === "custom" && decision.customInstruction) {
|
||||
return (
|
||||
"🔀 **Custom refactoring instruction:**\n\n" +
|
||||
decision.customInstruction +
|
||||
"\n\nAfter refactoring, run tests to verify they still pass, then call **go-to-refactor**."
|
||||
);
|
||||
}
|
||||
return "Refactoring decision applied. Call go-to-refactor when done.";
|
||||
}
|
||||
|
||||
// ── Tool: go-to-green ────────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "go-to-green",
|
||||
label: "Go to Green",
|
||||
description:
|
||||
"Run tests to verify implementation. If tests pass, transitions to GREEN phase. " +
|
||||
"If tests fail, stays in RED with feedback. Use this after writing a failing test and its implementation.",
|
||||
parameters: Type.Object({
|
||||
testDescription: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Brief description of what test(s) to run (e.g., 'user login test'). " +
|
||||
"Used for display purposes.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
restoreState(ctx);
|
||||
|
||||
const testDesc = params.testDescription ?? state.testDescription;
|
||||
if (testDesc) {
|
||||
state.testDescription = testDesc;
|
||||
}
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
]);
|
||||
|
||||
const testCommand = "npm test 2>&1";
|
||||
const result = await pi.exec("bash", ["-c", testCommand], {
|
||||
signal: ctx.signal,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const passed = result.code === 0;
|
||||
const transition = recordTestResult(state, passed);
|
||||
state = transition.state;
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
|
||||
]);
|
||||
|
||||
if (transition.state.phase === "GREEN") {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "tdd-cycle",
|
||||
content:
|
||||
"🟢 **GREEN PHASE ACHIEVED!**\n\n" +
|
||||
(transition.message ?? "") + "\n\n" +
|
||||
"The developer must now decide what to refactor.\n" +
|
||||
"Call **go-to-refactor** to enter the refactoring decision phase.",
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
}
|
||||
|
||||
persistState();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: transition.message ?? "Tests executed.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
passed,
|
||||
phase: transition.state.phase,
|
||||
exitCode: result.code,
|
||||
output: result.stdout.slice(0, 50_000),
|
||||
},
|
||||
};
|
||||
},
|
||||
renderCall(args, theme, _context) {
|
||||
const text = theme.fg("toolTitle", theme.bold("go-to-green ")) + (args.testDescription ? theme.fg("muted", args.testDescription) : "");
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as { passed?: boolean; phase?: string } | undefined;
|
||||
if (!details) {
|
||||
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||
}
|
||||
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||
const status = details.passed
|
||||
? theme.fg("success", phase + " Tests passed")
|
||||
: theme.fg("error", phase + " Tests failed");
|
||||
return new Text(status, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Tool: go-to-refactor ─────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "go-to-refactor",
|
||||
label: "Go to Refactor",
|
||||
description:
|
||||
"Enter the REFACTOR phase for developer decisions, or report back after refactoring work is done. " +
|
||||
"When called from GREEN: enters REFACTOR and prompts developer. " +
|
||||
"When called from REFACTOR: re-prompts the developer for a new decision. " +
|
||||
"Call this after refactoring work is complete and tests pass again.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
restoreState(ctx);
|
||||
|
||||
if (state.phase === "GREEN") {
|
||||
// First entry: transition from GREEN to REFACTOR
|
||||
const transition = greenToRefactor(state);
|
||||
state = transition.state;
|
||||
persistState();
|
||||
} else if (state.phase !== "REFACTOR") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Cannot enter REFACTOR from phase " + state.phase + ". Must be in GREEN or already in REFACTOR.",
|
||||
},
|
||||
],
|
||||
details: { phase: state.phase },
|
||||
};
|
||||
}
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
]);
|
||||
|
||||
if (ctx.mode === "tui" && ctx.hasUI) {
|
||||
const decision = await ctx.ui.custom<RefactorDecision | null>(
|
||||
createRefactorDecisionComponent(state.testDescription),
|
||||
);
|
||||
|
||||
if (decision) {
|
||||
// Get custom instruction text if needed
|
||||
let effectiveDecision = decision;
|
||||
if (decision.focus === "custom") {
|
||||
const customText = await ctx.ui.input(
|
||||
"Custom instruction:",
|
||||
"e.g. 'Extract the service class'",
|
||||
);
|
||||
if (customText?.trim()) {
|
||||
effectiveDecision = { focus: "custom", customInstruction: customText.trim() };
|
||||
} else {
|
||||
// Cancelled or empty — default to implementation
|
||||
effectiveDecision = { focus: "implementation" };
|
||||
}
|
||||
}
|
||||
|
||||
const applyResult = applyRefactorDecision(state, effectiveDecision);
|
||||
state = applyResult.state;
|
||||
persistState();
|
||||
|
||||
const steeringMessage = buildSteeringMessage(effectiveDecision);
|
||||
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "tdd-cycle",
|
||||
content: steeringMessage,
|
||||
display: true,
|
||||
},
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: applyResult.message ?? "Refactoring decision applied.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
decision: effectiveDecision,
|
||||
phase: state.phase,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Developer cancelled. Staying in REFACTOR phase.",
|
||||
},
|
||||
],
|
||||
details: { phase: "REFACTOR" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Non-TUI mode: fallback to next-test
|
||||
if (state.phase === "REFACTOR") {
|
||||
const fallbackDecision: RefactorDecision = { focus: "next-test" };
|
||||
const applyResult = applyRefactorDecision(state, fallbackDecision);
|
||||
state = applyResult.state;
|
||||
persistState();
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: applyResult.message ?? "Non-TUI: advancing to next test." },
|
||||
],
|
||||
details: { phase: "RED" },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Entered REFACTOR phase." }],
|
||||
details: { phase: "REFACTOR" },
|
||||
};
|
||||
},
|
||||
renderCall(_args, theme, _context) {
|
||||
return new Text(theme.fg("toolTitle", theme.bold("go-to-refactor")), 0, 0);
|
||||
},
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as { phase?: string; decision?: RefactorDecision } | undefined;
|
||||
if (!details) {
|
||||
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||
}
|
||||
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||
const text = details.decision
|
||||
? phase + " Refactored: " + details.decision.focus
|
||||
: phase + " Entered refactor phase";
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Tool: run-tests ──────────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "run-tests",
|
||||
label: "Run Tests",
|
||||
description:
|
||||
"Run tests without phase transition. Useful for checking test status " +
|
||||
"during development. Returns exit code and output.",
|
||||
parameters: Type.Object({
|
||||
command: Type.Optional(
|
||||
Type.String({
|
||||
description: "Custom test command to run (default: 'npm test').",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||
restoreState(ctx);
|
||||
|
||||
const command = params.command ?? "npm test";
|
||||
const result = await pi.exec("bash", ["-c", command], {
|
||||
signal,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const passed = result.code === 0;
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
ctx.ui.theme.fg(passed ? "success" : "error", passed ? "✓ Tests passed" : "✗ Tests failed"),
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: (passed ? "✓" : "✗") + " Tests " + (passed ? "passed" : "failed") + " (exit code: " + result.code + ")",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
passed,
|
||||
exitCode: result.code,
|
||||
output: result.stdout.slice(0, 50_000),
|
||||
stderr: result.stderr.slice(0, 50_000),
|
||||
},
|
||||
};
|
||||
},
|
||||
renderCall(args, theme, _context) {
|
||||
const text = theme.fg("toolTitle", theme.bold("run-tests ")) + (args.command ? theme.fg("muted", args.command) : theme.fg("muted", "npm test"));
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as { passed?: boolean } | undefined;
|
||||
if (!details) {
|
||||
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||
}
|
||||
const status = details.passed
|
||||
? theme.fg("success", "✓ Tests passed")
|
||||
: theme.fg("error", "✗ Tests failed");
|
||||
return new Text(status, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Tool: tdd-status ─────────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "tdd-status",
|
||||
label: "TDD Status",
|
||||
description:
|
||||
"Show current TDD cycle phase and state. Returns the current phase, " +
|
||||
"test count, and guidelines for what to do next.",
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
restoreState(ctx);
|
||||
|
||||
const statusText = phaseDescription(state);
|
||||
const guidelines = phaseGuidelines(state);
|
||||
const testsWritten = state.testsWritten ?? 0;
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusText),
|
||||
ctx.ui.theme.fg("dim", "Tests written: " + testsWritten),
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: statusText + "\n\nTests written: " + testsWritten + "\n\n" + guidelines,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
phase: state.phase,
|
||||
testsRan: state.testsRan,
|
||||
testsPassed: state.testsPassed,
|
||||
testsWritten,
|
||||
},
|
||||
};
|
||||
},
|
||||
renderCall(_args, theme, _context) {
|
||||
return new Text(theme.fg("toolTitle", theme.bold("tdd-status")), 0, 0);
|
||||
},
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const details = result.details as { phase?: string } | undefined;
|
||||
if (!details) {
|
||||
return new Text(result.content[0]?.type === "text" ? result.content[0].text : "", 0, 0);
|
||||
}
|
||||
const phase = details.phase ? phaseEmoji(details.phase as any) : "";
|
||||
return new Text(phase + " " + details.phase, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
restoreState(ctx);
|
||||
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
]);
|
||||
|
||||
if (ctx.mode === "tui") {
|
||||
ctx.ui.notify("TDD Cycle started in " + state.phase + " phase", "info");
|
||||
}
|
||||
});
|
||||
|
||||
// Allow go-to-green during REFACTOR (to verify tests after refactoring)
|
||||
// Only block: RED → go-to-refactor (must pass tests first)
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (state.phase === "RED" && event.toolName === "go-to-refactor") {
|
||||
return {
|
||||
block: true,
|
||||
reason: "Cannot go to REFACTOR from RED phase. You must first make tests pass (GREEN phase). Call go-to-green after your implementation passes the test.",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async () => {
|
||||
return {
|
||||
message: {
|
||||
customType: "tdd-cycle-guidelines",
|
||||
content: "\n\n[TDD CYCLE - " + state.phase + " PHASE]\n" + guidelinesMessage(),
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
ctx.ui.setWidget("tdd-status", [
|
||||
ctx.ui.theme.fg("accent", statusMessage()),
|
||||
]);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
persistState();
|
||||
});
|
||||
}
|
||||
337
src/state.ts
Normal file
337
src/state.ts
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* TDD Cycle State Machine
|
||||
*
|
||||
* Enforces the red-green-refactor cycle:
|
||||
* RED → Write a failing test, then implement to make it pass
|
||||
* GREEN → Tests pass, ready for refactoring
|
||||
* REFACTOR → Developer chooses what to refactor
|
||||
* After refactor: back to RED for next test (or GREEN if no new test)
|
||||
*/
|
||||
|
||||
// ── Phase ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Phase = "RED" | "GREEN" | "REFACTOR";
|
||||
|
||||
export const PHASES: Phase[] = ["RED", "GREEN", "REFACTOR"];
|
||||
|
||||
// ── Refactor Focus ───────────────────────────────────────────────────────────
|
||||
|
||||
export type RefactorFocus = "audit" | "tests" | "implementation" | "next-test" | "custom";
|
||||
|
||||
export const REFACTOR_FOCI: RefactorFocus[] = [
|
||||
"audit",
|
||||
"tests",
|
||||
"implementation",
|
||||
"next-test",
|
||||
"custom",
|
||||
];
|
||||
|
||||
// ── Decision Result ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface RefactorDecision {
|
||||
focus: RefactorFocus;
|
||||
customInstruction?: string;
|
||||
}
|
||||
|
||||
// ── Cycle State ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CycleState {
|
||||
/** Current phase */
|
||||
phase: Phase;
|
||||
/** Test description (what we're trying to make pass) */
|
||||
testDescription?: string;
|
||||
/** Whether tests have been run in the current cycle */
|
||||
testsRan?: boolean;
|
||||
/** Whether tests passed in the current cycle */
|
||||
testsPassed?: boolean;
|
||||
/** Number of tests written so far in this session */
|
||||
testsWritten?: number;
|
||||
/** Last refactoring decision (if any) */
|
||||
lastDecision?: RefactorDecision;
|
||||
/** Custom refactoring instruction from developer */
|
||||
customRefactoringInstruction?: string;
|
||||
}
|
||||
|
||||
export function createInitialState(): CycleState {
|
||||
return {
|
||||
phase: "RED",
|
||||
testsRan: false,
|
||||
testsPassed: false,
|
||||
testsWritten: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Transitions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TransitionResult {
|
||||
/** New state */
|
||||
state: CycleState;
|
||||
/** Message to show to the LLM (optional) */
|
||||
message?: string;
|
||||
/** Whether the transition was valid */
|
||||
valid: boolean;
|
||||
/** Error message if transition was invalid */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition from RED to GREEN.
|
||||
* Valid only when tests have been run and passed.
|
||||
*/
|
||||
export function redToGreen(state: CycleState): TransitionResult {
|
||||
if (!state.testsRan) {
|
||||
return {
|
||||
state,
|
||||
valid: false,
|
||||
error: "Cannot transition to GREEN: tests have not been run yet. Run tests first.",
|
||||
};
|
||||
}
|
||||
if (!state.testsPassed) {
|
||||
return {
|
||||
state,
|
||||
valid: false,
|
||||
error: "Cannot transition to GREEN: tests did not pass. Stay in RED and make tests pass.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: { ...state, phase: "GREEN", testsRan: false, testsPassed: false },
|
||||
message:
|
||||
"✅ Tests pass! Phase changed: GREEN → REFACTOR. " +
|
||||
"You should now refactor. Call go-to-refactor when ready.",
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition from GREEN to REFACTOR.
|
||||
* Always valid.
|
||||
*/
|
||||
export function greenToRefactor(state: CycleState): TransitionResult {
|
||||
return {
|
||||
state: { ...state, phase: "REFACTOR" },
|
||||
message:
|
||||
"🔄 Phase changed: GREEN → REFACTOR. " +
|
||||
"Developer will now decide what to refactor.",
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a refactoring decision.
|
||||
* Only transitions to RED for "next-test"; other options stay in REFACTOR
|
||||
* so the agent can do the actual refactoring work.
|
||||
*/
|
||||
export function applyRefactorDecision(
|
||||
state: CycleState,
|
||||
decision: RefactorDecision,
|
||||
): TransitionResult {
|
||||
const newState: CycleState = {
|
||||
...state,
|
||||
phase: "RED",
|
||||
lastDecision: decision,
|
||||
customRefactoringInstruction:
|
||||
decision.focus === "custom" ? decision.customInstruction : undefined,
|
||||
testsRan: false,
|
||||
testsPassed: false,
|
||||
};
|
||||
|
||||
if (decision.focus === "next-test") {
|
||||
newState.testsWritten = (state.testsWritten ?? 0) + 1;
|
||||
newState.phase = "RED";
|
||||
let message = `🔀 Next test. Phase changed: REFACTOR → RED.`;
|
||||
return { state: newState, message, valid: true };
|
||||
}
|
||||
|
||||
// For audit, tests, implementation, custom — stay in REFACTOR for agent to work
|
||||
newState.phase = "REFACTOR";
|
||||
let message = `🔀 Refactoring decision: ${decision.focus}.`;
|
||||
if (decision.focus === "custom" && decision.customInstruction) {
|
||||
message += ` Custom instruction: "${decision.customInstruction}"`;
|
||||
}
|
||||
message += " Phase: REFACTOR (working).";
|
||||
|
||||
return {
|
||||
state: newState,
|
||||
message,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that tests have been run and their result.
|
||||
* This is called by the go-to-green tool after test execution.
|
||||
*/
|
||||
export function recordTestResult(
|
||||
state: CycleState,
|
||||
passed: boolean,
|
||||
): TransitionResult {
|
||||
if (state.phase !== "RED" && state.phase !== "GREEN") {
|
||||
return {
|
||||
state,
|
||||
valid: false,
|
||||
error: `Cannot record test results in phase ${state.phase}. Must be in RED or GREEN.`,
|
||||
};
|
||||
}
|
||||
|
||||
const newState: CycleState = {
|
||||
...state,
|
||||
testsRan: true,
|
||||
testsPassed: passed,
|
||||
};
|
||||
|
||||
if (passed && state.phase === "RED") {
|
||||
// Tests passed while in RED → auto-transition to GREEN
|
||||
// Keep testsRan/testsPassed from newState (they reflect the just-run tests)
|
||||
const greenResult = redToGreen(newState);
|
||||
return {
|
||||
...greenResult,
|
||||
state: { ...greenResult.state, testsRan: true, testsPassed: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (!passed) {
|
||||
return {
|
||||
state: newState,
|
||||
message:
|
||||
"❌ Tests failed. Stay in RED. Fix the implementation to make tests pass.",
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Tests passed while already in GREEN → stay in GREEN
|
||||
return {
|
||||
state: newState,
|
||||
message: "✅ Tests still pass. Stay in GREEN.",
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transition is valid before executing it.
|
||||
*/
|
||||
export function canTransition(
|
||||
state: CycleState,
|
||||
targetPhase: Phase,
|
||||
): { valid: boolean; reason: string } {
|
||||
if (state.phase === targetPhase) {
|
||||
return { valid: true, reason: `Already in ${targetPhase}` };
|
||||
}
|
||||
|
||||
if (state.phase === "RED") {
|
||||
if (targetPhase === "GREEN") {
|
||||
const can = !!state.testsRan && !!state.testsPassed;
|
||||
return {
|
||||
valid: can,
|
||||
reason: can
|
||||
? "Tests passed, can go to GREEN"
|
||||
: "Must run passing tests first",
|
||||
};
|
||||
}
|
||||
if (targetPhase === "REFACTOR") {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"Cannot go directly from RED to REFACTOR. Must go through GREEN first.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (state.phase === "GREEN") {
|
||||
if (targetPhase === "RED") {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"Cannot go from GREEN back to RED. Complete refactoring first.",
|
||||
};
|
||||
}
|
||||
if (targetPhase === "REFACTOR") {
|
||||
return { valid: true, reason: "Can go to REFACTOR from GREEN" };
|
||||
}
|
||||
}
|
||||
|
||||
if (state.phase === "REFACTOR") {
|
||||
if (targetPhase === "RED") {
|
||||
return { valid: true, reason: "Can go to RED after refactoring decision" };
|
||||
}
|
||||
if (targetPhase === "GREEN") {
|
||||
return {
|
||||
valid: false,
|
||||
reason:
|
||||
"Cannot go from REFACTOR to GREEN. Must go to RED first (write next test).",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, reason: `Invalid transition from ${state.phase} to ${targetPhase}` };
|
||||
}
|
||||
|
||||
// ── Formatting ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function phaseEmoji(phase: Phase): string {
|
||||
switch (phase) {
|
||||
case "RED":
|
||||
return "🔴";
|
||||
case "GREEN":
|
||||
return "🟢";
|
||||
case "REFACTOR":
|
||||
return "🔀";
|
||||
}
|
||||
}
|
||||
|
||||
export function phaseDescription(state: CycleState): string {
|
||||
const emoji = phaseEmoji(state.phase);
|
||||
const phase = state.phase;
|
||||
|
||||
switch (phase) {
|
||||
case "RED":
|
||||
if (!state.testsRan) {
|
||||
return `${emoji} RED — Write a failing test, then implement to make it pass.`;
|
||||
}
|
||||
if (state.testsPassed) {
|
||||
return `${emoji} RED→GREEN — Tests passed! Ready for refactoring.`;
|
||||
}
|
||||
return `${emoji} RED — Tests failed. Fix implementation.`;
|
||||
case "GREEN":
|
||||
return `${emoji} GREEN — Tests pass. Ready for refactoring.`;
|
||||
case "REFACTOR":
|
||||
return `${emoji} REFACTOR — Developer decides what to refactor.`;
|
||||
}
|
||||
}
|
||||
|
||||
export function phaseGuidelines(state: CycleState): string {
|
||||
switch (state.phase) {
|
||||
case "RED":
|
||||
if (!state.testsRan) {
|
||||
return `You are in RED phase.
|
||||
- Write a failing test first (the test should fail before your implementation)
|
||||
- Then write minimal implementation to make the test pass
|
||||
- Run tests with bash to verify
|
||||
- When tests pass, the system will move you to GREEN`;
|
||||
}
|
||||
if (state.testsPassed) {
|
||||
return `You are in RED→GREEN transition. Tests passed!
|
||||
- You should now call go-to-refactor to enter the REFACTOR phase
|
||||
- Do NOT write a new test yet — refactor first`;
|
||||
}
|
||||
return `You are in RED phase. Tests failed.
|
||||
- Improve your implementation to make tests pass
|
||||
- Do NOT change the test — the test defines the requirement
|
||||
- Run tests with bash to verify`;
|
||||
case "GREEN":
|
||||
return `You are in GREEN phase. Tests pass.
|
||||
- Call go-to-refactor to enter the REFACTOR phase
|
||||
- The developer will then decide what to refactor`;
|
||||
case "REFACTOR":
|
||||
if (state.lastDecision) {
|
||||
return `You are in REFACTOR phase. The developer chose: ${state.lastDecision.focus}.
|
||||
- Execute the refactoring work now
|
||||
- After refactoring, run tests to verify they still pass
|
||||
- Then call go-to-refactor again to report back to the developer`;
|
||||
}
|
||||
return `You are in REFACTOR phase. The developer is deciding what to refactor.
|
||||
- Wait for the developer's decision
|
||||
- Options: audit (code smells), refactor tests, refactor implementation, next test, or custom
|
||||
- After refactoring, run tests to verify they still pass
|
||||
- Then call go-to-refactor to report back to the developer`;
|
||||
}
|
||||
}
|
||||
297
src/test/state.test.ts
Normal file
297
src/test/state.test.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createInitialState,
|
||||
redToGreen,
|
||||
greenToRefactor,
|
||||
applyRefactorDecision,
|
||||
recordTestResult,
|
||||
canTransition,
|
||||
phaseEmoji,
|
||||
phaseDescription,
|
||||
phaseGuidelines,
|
||||
type CycleState,
|
||||
type RefactorDecision,
|
||||
} from "../state.js";
|
||||
|
||||
describe("createInitialState", () => {
|
||||
it("returns RED phase with defaults", () => {
|
||||
const state = createInitialState();
|
||||
expect(state.phase).toBe("RED");
|
||||
expect(state.testsRan).toBe(false);
|
||||
expect(state.testsPassed).toBe(false);
|
||||
expect(state.testsWritten).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redToGreen", () => {
|
||||
it("transitions when tests ran and passed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: true,
|
||||
testsWritten: 1,
|
||||
};
|
||||
const result = redToGreen(state);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.phase).toBe("GREEN");
|
||||
expect(result.state.testsRan).toBe(false);
|
||||
expect(result.state.testsPassed).toBe(false);
|
||||
expect(result.message).toContain("GREEN");
|
||||
});
|
||||
|
||||
it("rejects when tests have not been run", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: false,
|
||||
testsPassed: false,
|
||||
};
|
||||
const result = redToGreen(state);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("have not been run");
|
||||
});
|
||||
|
||||
it("rejects when tests failed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: false,
|
||||
};
|
||||
const result = redToGreen(state);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("did not pass");
|
||||
});
|
||||
});
|
||||
|
||||
describe("greenToRefactor", () => {
|
||||
it("always transitions from GREEN to REFACTOR", () => {
|
||||
const state: CycleState = { phase: "GREEN" };
|
||||
const result = greenToRefactor(state);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.phase).toBe("REFACTOR");
|
||||
expect(result.message).toContain("REFACTOR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRefactorDecision", () => {
|
||||
it("stays in REFACTOR with tests focus", () => {
|
||||
const state: CycleState = { phase: "REFACTOR", testsWritten: 1 };
|
||||
const decision: RefactorDecision = { focus: "tests" };
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.phase).toBe("REFACTOR");
|
||||
expect(result.state.lastDecision).toEqual(decision);
|
||||
});
|
||||
|
||||
it("stays in REFACTOR with implementation focus", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const decision: RefactorDecision = { focus: "implementation" };
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.state.phase).toBe("REFACTOR");
|
||||
expect(result.state.lastDecision).toEqual(decision);
|
||||
});
|
||||
|
||||
it("stays in REFACTOR with audit focus", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const decision: RefactorDecision = { focus: "audit" };
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.state.phase).toBe("REFACTOR");
|
||||
expect(result.state.lastDecision).toEqual(decision);
|
||||
});
|
||||
|
||||
it("transitions to RED for next-test focus", () => {
|
||||
const state: CycleState = { phase: "REFACTOR", testsWritten: 3 };
|
||||
const decision: RefactorDecision = { focus: "next-test" };
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.state.testsWritten).toBe(4);
|
||||
expect(result.state.phase).toBe("RED");
|
||||
});
|
||||
|
||||
it("stores custom instruction for custom focus", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const decision: RefactorDecision = {
|
||||
focus: "custom",
|
||||
customInstruction: "Extract the service class",
|
||||
};
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.state.customRefactoringInstruction).toBe(
|
||||
"Extract the service class",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets testsRan and testsPassed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "REFACTOR",
|
||||
testsRan: true,
|
||||
testsPassed: true,
|
||||
};
|
||||
const decision: RefactorDecision = { focus: "implementation" };
|
||||
const result = applyRefactorDecision(state, decision);
|
||||
expect(result.state.testsRan).toBe(false);
|
||||
expect(result.state.testsPassed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordTestResult", () => {
|
||||
it("records passing tests in RED → auto-transitions to GREEN", () => {
|
||||
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
|
||||
const result = recordTestResult(state, true);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.testsRan).toBe(true);
|
||||
expect(result.state.testsPassed).toBe(true);
|
||||
expect(result.state.phase).toBe("GREEN");
|
||||
});
|
||||
|
||||
it("records failing tests in RED → stays in RED", () => {
|
||||
const state: CycleState = { phase: "RED", testsRan: false, testsPassed: false };
|
||||
const result = recordTestResult(state, false);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.testsRan).toBe(true);
|
||||
expect(result.state.testsPassed).toBe(false);
|
||||
expect(result.state.phase).toBe("RED");
|
||||
expect(result.message).toContain("failed");
|
||||
});
|
||||
|
||||
it("records passing tests in GREEN → stays in GREEN", () => {
|
||||
const state: CycleState = { phase: "GREEN", testsRan: true, testsPassed: true };
|
||||
const result = recordTestResult(state, true);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.state.phase).toBe("GREEN");
|
||||
});
|
||||
|
||||
it("rejects recording in REFACTOR phase", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const result = recordTestResult(state, true);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("REFACTOR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canTransition", () => {
|
||||
it("allows RED → GREEN when tests passed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: true,
|
||||
};
|
||||
const result = canTransition(state, "GREEN");
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks RED → GREEN when tests failed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: false,
|
||||
};
|
||||
const result = canTransition(state, "GREEN");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks RED → REFACTOR (must go through GREEN)", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: true,
|
||||
};
|
||||
const result = canTransition(state, "REFACTOR");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("allows GREEN → REFACTOR", () => {
|
||||
const state: CycleState = { phase: "GREEN" };
|
||||
const result = canTransition(state, "REFACTOR");
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks GREEN → RED", () => {
|
||||
const state: CycleState = { phase: "GREEN" };
|
||||
const result = canTransition(state, "RED");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("allows REFACTOR → RED", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const result = canTransition(state, "RED");
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks REFACTOR → GREEN", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const result = canTransition(state, "GREEN");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phaseEmoji", () => {
|
||||
it("returns correct emoji for each phase", () => {
|
||||
expect(phaseEmoji("RED")).toBe("🔴");
|
||||
expect(phaseEmoji("GREEN")).toBe("🟢");
|
||||
expect(phaseEmoji("REFACTOR")).toBe("🔀");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phaseDescription", () => {
|
||||
it("describes RED when no tests ran", () => {
|
||||
const state = createInitialState();
|
||||
expect(phaseDescription(state)).toContain("RED");
|
||||
expect(phaseDescription(state)).toContain("Write a failing test");
|
||||
});
|
||||
|
||||
it("describes RED→GREEN when tests passed in RED", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: true,
|
||||
};
|
||||
expect(phaseDescription(state)).toContain("RED→GREEN");
|
||||
});
|
||||
|
||||
it("describes RED when tests failed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: false,
|
||||
};
|
||||
expect(phaseDescription(state)).toContain("Tests failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phaseGuidelines", () => {
|
||||
it("gives RED guidelines when no tests ran", () => {
|
||||
const state = createInitialState();
|
||||
const guidelines = phaseGuidelines(state);
|
||||
expect(guidelines).toContain("Write a failing test");
|
||||
});
|
||||
|
||||
it("gives RED guidelines when tests failed", () => {
|
||||
const state: CycleState = {
|
||||
phase: "RED",
|
||||
testsRan: true,
|
||||
testsPassed: false,
|
||||
};
|
||||
const guidelines = phaseGuidelines(state);
|
||||
expect(guidelines).toContain("Tests failed");
|
||||
expect(guidelines).toContain("Do NOT change the test");
|
||||
});
|
||||
|
||||
it("gives GREEN guidelines", () => {
|
||||
const state: CycleState = { phase: "GREEN" };
|
||||
const guidelines = phaseGuidelines(state);
|
||||
expect(guidelines).toContain("GREEN");
|
||||
expect(guidelines).toContain("go-to-refactor");
|
||||
});
|
||||
|
||||
it("gives REFACTOR guidelines when deciding", () => {
|
||||
const state: CycleState = { phase: "REFACTOR" };
|
||||
const guidelines = phaseGuidelines(state);
|
||||
expect(guidelines).toContain("REFACTOR");
|
||||
expect(guidelines).toContain("Wait for the developer");
|
||||
});
|
||||
|
||||
it("gives REFACTOR guidelines when working on a decision", () => {
|
||||
const state: CycleState = { phase: "REFACTOR", lastDecision: { focus: "implementation" } };
|
||||
const guidelines = phaseGuidelines(state);
|
||||
expect(guidelines).toContain("REFACTOR");
|
||||
expect(guidelines).toContain("Execute the refactoring");
|
||||
});
|
||||
});
|
||||
143
src/ui.ts
Normal file
143
src/ui.ts
Normal file
@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Custom UI for the TDD cycle developer decision prompt.
|
||||
*
|
||||
* Renders an interactive menu during the REFACTOR phase where the developer
|
||||
* can choose what to do next.
|
||||
*
|
||||
* Usage: pass the returned component to ctx.ui.custom().
|
||||
* For custom text input, use ctx.ui.input() in the tool's execute function.
|
||||
*/
|
||||
|
||||
import {
|
||||
matchesKey,
|
||||
Key,
|
||||
type TUI,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import type { Theme, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { RefactorFocus, RefactorDecision } from "./state.js";
|
||||
|
||||
const REFACTOR_FOCI: RefactorFocus[] = [
|
||||
"audit",
|
||||
"tests",
|
||||
"implementation",
|
||||
"next-test",
|
||||
"custom",
|
||||
];
|
||||
|
||||
const FOCUS_LABELS: Record<RefactorFocus, string> = {
|
||||
audit: "Identify code smells & verify minimality",
|
||||
tests: "Refactor the tests",
|
||||
implementation: "Refactor the implementation",
|
||||
"next-test": "Continue to next test",
|
||||
custom: "Something else (custom instruction)",
|
||||
};
|
||||
|
||||
const FOCUS_DESCRIPTIONS: Record<RefactorFocus, string> = {
|
||||
audit:
|
||||
"Audit implementation: is all code needed? Is it minimal? Are there code smells?",
|
||||
tests: "Clean up and improve test structure",
|
||||
implementation: "Improve production code structure",
|
||||
"next-test": "Write the next failing test",
|
||||
custom: "Provide a custom instruction",
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a custom UI component for the refactoring decision prompt.
|
||||
* Call this from ctx.ui.custom() callback.
|
||||
* For "custom" selection, the execute function should use ctx.ui.input() for the text.
|
||||
*/
|
||||
export function createRefactorDecisionComponent(
|
||||
testDescription?: string,
|
||||
): (
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
_kb: KeybindingsManager,
|
||||
done: (decision: RefactorDecision | null) => void,
|
||||
) => {
|
||||
render: (width: number) => string[];
|
||||
invalidate: () => void;
|
||||
handleInput: (data: string) => boolean;
|
||||
} {
|
||||
let selectedIndex = 0;
|
||||
|
||||
return (tui, theme, _kb, done) => {
|
||||
function render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const w = Math.max(1, width - 2);
|
||||
|
||||
// Header
|
||||
lines.push(theme.fg("accent", "─".repeat(w)));
|
||||
lines.push(
|
||||
theme.fg("accent", " 🔀 REFACTOR PHASE — What should we do next?"),
|
||||
);
|
||||
if (testDescription) {
|
||||
lines.push(theme.fg("muted", ` Current test: ${testDescription}`));
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Options
|
||||
for (let i = 0; i < REFACTOR_FOCI.length; i++) {
|
||||
const focus = REFACTOR_FOCI[i];
|
||||
const selected = i === selectedIndex;
|
||||
const prefix = selected
|
||||
? theme.fg("accent", "▸ ")
|
||||
: " ";
|
||||
const label = theme.fg(
|
||||
selected ? "accent" : "text",
|
||||
FOCUS_LABELS[focus],
|
||||
);
|
||||
lines.push(`${prefix}${label}`);
|
||||
if (selected) {
|
||||
lines.push(
|
||||
` ${theme.fg("dim", FOCUS_DESCRIPTIONS[focus])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"dim",
|
||||
` ${theme.bold("↑↓")} navigate ${theme.bold("Enter")} select ${theme.bold("Esc")} cancel`,
|
||||
),
|
||||
);
|
||||
lines.push(theme.fg("accent", "─".repeat(w)));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function handleInput(data: string): boolean {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||
tui.requestRender();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.down)) {
|
||||
selectedIndex = Math.min(
|
||||
REFACTOR_FOCI.length - 1,
|
||||
selectedIndex + 1,
|
||||
);
|
||||
tui.requestRender();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const focus = REFACTOR_FOCI[selectedIndex];
|
||||
// For "custom", return focus without instruction text;
|
||||
// the execute function will use ctx.ui.input() to get the text.
|
||||
done({ focus });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return { render, invalidate: () => {}, handleInput };
|
||||
};
|
||||
}
|
||||
4328
transcripts/code-smells.html
Normal file
4328
transcripts/code-smells.html
Normal file
File diff suppressed because one or more lines are too long
4328
transcripts/spike.html
Normal file
4328
transcripts/spike.html
Normal file
File diff suppressed because one or more lines are too long
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/test/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user