34.3 Change proposal workflow (plan → diff → tests)

Overview and links for this section of the guide.

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;
  }
}
Always Use Dry Run First

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.

Where to go next