34.3 Change proposal workflow (plan → diff → tests)
Overview and links for this section of the guide.
On this page
The Problem with "Just Do It"
When you ask an AI to "add a phone number field to the User model," it might:
- Create a completely new file instead of editing the existing one
- Use a different naming convention than your codebase
- Miss some call sites that need updating
- Introduce breaking changes to your API
The solution: Force a structured workflow that separates planning from execution.
The Three-Step Flow
Never ask the model to "just do it." Instead, enforce a three-phase process:
┌─────────────────────────────────────────────────────────────────┐
│ CHANGE PROPOSAL WORKFLOW │
├───────────────┬───────────────┬─────────────────────────────────┤
│ PHASE 1 │ PHASE 2 │ PHASE 3 │
│ PLAN │ DIFF │ TEST │
├───────────────┼───────────────┼─────────────────────────────────┤
│ "What needs │ "Generate the │ "Create tests that verify │
│ to change?" │ actual code" │ the change works" │
│ │ │ │
│ • Read files │ • Output │ • Generate test cases │
│ • List steps │ search/ │ • Run existing tests │
│ • Identify │ replace │ • Flag regressions │
│ risks │ blocks │ │
└───────────────┴───────────────┴─────────────────────────────────┘
Phase 1: PLAN
USER: Add a `createdAt` timestamp to the User model
SYSTEM: Before making changes, first analyze what needs to change.
Read the relevant files and output a plan in this format:
1. Files that need modification
2. For each file: what changes are needed
3. Potential risks or side effects
AI: I'll analyze the codebase for User-related files...
[reads src/models/user.ts, src/db/schema.prisma, src/api/users.ts]
**Plan:**
1. `src/db/schema.prisma` - Add `createdAt DateTime @default(now())` to User model
2. `src/models/user.ts` - Add `createdAt: Date` to TypeScript interface
3. `src/api/users.ts` - Include `createdAt` in API responses (lines 45, 78)
4. Generate a new Prisma migration
**Risks:**
- Existing records won't have `createdAt` - migration needs a default
- API response shape changes - check if frontend expects this
Proceed with generating diffs? (y/n)
Phase 2: DIFF
USER: y
AI: Generating diffs using Search/Replace format...
**File: src/db/schema.prisma**
<<<<<<< SEARCH
model User {
id String @id @default(uuid())
email String @unique
name String?
password String
}
=======
model User {
id String @id @default(uuid())
email String @unique
name String?
password String
createdAt DateTime @default(now())
}
>>>>>>> REPLACE
**File: src/models/user.ts**
<<<<<<< SEARCH
export interface User {
id: string;
email: string;
name: string | null;
}
=======
export interface User {
id: string;
email: string;
name: string | null;
createdAt: Date;
}
>>>>>>> REPLACE
Apply these changes? (y/n)
Phase 3: TEST
USER: y (changes applied)
AI: Changes applied successfully. Now generating tests...
**New test for src/models/user.test.ts:**
<<<<<<< SEARCH
describe('User model', () => {
// existing tests...
});
=======
describe('User model', () => {
// existing tests...
it('should include createdAt timestamp', async () => {
const user = await createUser({ email: '[email protected]' });
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.createdAt.getTime()).toBeLessThanOrEqual(Date.now());
});
});
>>>>>>> REPLACE
Running existing tests to check for regressions...
$ npm test
✓ 42 tests passed
✗ 1 test failed: "API response matches snapshot"
→ Snapshot needs updating (new field added)
Should I update the snapshot? (y/n)
Generating Clean Diffs
LLMs are notoriously unreliable with line numbers. Standard unified diff format often fails because line
numbers shift as you make changes.
A better format is Search/Replace Blocks:
<<<<<<< SEARCH
exact text to find (including whitespace)
=======
replacement text
>>>>>>> REPLACE
This format is:
- Robust: Doesn't depend on line numbers
- Unambiguous: Finds exact text match
- Easy to parse: Simple regex can extract blocks
- Reviewable: Human can see exactly what changes
Parsing Search/Replace Blocks
// diff-parser.ts
interface DiffBlock {
search: string;
replace: string;
}
function parseDiffBlocks(output: string): DiffBlock[] {
const blocks: DiffBlock[] = [];
const regex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
let match;
while ((match = regex.exec(output)) !== null) {
blocks.push({
search: match[1],
replace: match[2]
});
}
return blocks;
}
function applyDiff(content: string, block: DiffBlock): string {
if (!content.includes(block.search)) {
throw new Error(`Search text not found in file:\n${block.search.slice(0, 100)}...`);
}
// Count occurrences to prevent ambiguous replacements
const occurrences = content.split(block.search).length - 1;
if (occurrences > 1) {
throw new Error(`Ambiguous: found ${occurrences} matches for search text`);
}
return content.replace(block.search, block.replace);
}
Applying Changes Safely
The full change application pipeline:
// apply-changes.ts
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
interface FileChange {
filePath: string;
blocks: DiffBlock[];
}
async function applyChanges(
changes: FileChange[],
options: { dryRun?: boolean; backup?: boolean } = {}
) {
const results = [];
for (const change of changes) {
const fullPath = path.resolve(change.filePath);
// Safety check: file must exist
if (!fs.existsSync(fullPath)) {
throw new Error(`File not found: ${change.filePath}`);
}
// Read current content
let content = fs.readFileSync(fullPath, 'utf8');
const original = content;
// Apply each diff block
for (const block of change.blocks) {
content = applyDiff(content, block);
}
if (options.dryRun) {
console.log(`[DRY RUN] Would modify: ${change.filePath}`);
console.log(generateUnifiedDiff(original, content));
continue;
}
// Create backup before writing
if (options.backup) {
fs.writeFileSync(`${fullPath}.backup`, original);
}
// Write the new content
fs.writeFileSync(fullPath, content);
results.push({ file: change.filePath, success: true });
}
return results;
}
// Run tests after changes
function verifyChanges(testCommand = 'npm test'): boolean {
try {
execSync(testCommand, { stdio: 'inherit' });
return true;
} catch (error) {
console.error('Tests failed after applying changes!');
return false;
}
}
Run with dryRun: true to preview changes before applying. This catches issues like "search text not
found" before modifying any files.
Automatic Test Generation
After generating code changes, prompt the model to generate corresponding tests:
// test-generation-prompt.txt
You just made changes to the following files:
${modifiedFiles.join('\n')}
Generate test cases that verify these changes work correctly.
Focus on:
1. The new functionality (does the new code work?)
2. Regression prevention (did we break existing behavior?)
3. Edge cases (what happens with invalid input?)
Output format: Search/Replace blocks for test files.
If no test file exists for this module, create one.
The key insight: Generate tests after code, not before. The model has just "thought through" the implementation, so it understands the edge cases.