open-design/apps/daemon/tests/plugins-patch-edit.test.ts
2026-05-14 16:35:46 +08:00

214 lines
8.1 KiB
TypeScript

// Phase 7 entry slice — patch-edit atom impl.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
applyPatchForStep,
readPlanProgress,
skipStep,
} from '../src/plugins/atoms/patch-edit.js';
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-patch-edit-'));
await mkdir(path.join(cwd, 'plan'), { recursive: true });
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
async function seedPlan(steps: Array<Record<string, unknown>>, ownership: Array<{ file: string; layer: string }> = []) {
await writeFile(path.join(cwd, 'plan', 'steps.json'), JSON.stringify(steps, null, 2) + '\n');
await writeFile(path.join(cwd, 'plan', 'ownership.json'), JSON.stringify(ownership, null, 2) + '\n');
}
describe('applyPatchForStep — happy paths', () => {
it('applies a single-file edit + records the receipt', async () => {
await seedPlan([
{ id: 'rewrite-button', files: ['Button.tsx'], rationale: '', risk: 'low', status: 'pending' },
]);
await writeFile(path.join(cwd, 'Button.tsx'),
`export const Button = () => {\n return <button>old</button>;\n};\n`);
const diff = `--- a/Button.tsx
+++ b/Button.tsx
@@ -1,3 +1,3 @@
export const Button = () => {
- return <button>old</button>;
+ return <button>NEW</button>;
};
`;
const result = await applyPatchForStep({
cwd,
stepId: 'rewrite-button',
diff,
rationale: 'tighten copy',
});
expect(result.status).toBe('completed');
expect(result.filesTouched).toEqual(['Button.tsx']);
expect(result.added).toBe(1);
expect(result.removed).toBe(1);
const updated = await readFile(path.join(cwd, 'Button.tsx'), 'utf8');
expect(updated).toContain('NEW');
const receipt = JSON.parse(await readFile(path.join(cwd, 'plan', 'receipts', 'step-rewrite-button.json'), 'utf8'));
expect(receipt.rationale).toBe('tighten copy');
expect(receipt.added).toBe(1);
const steps = JSON.parse(await readFile(path.join(cwd, 'plan', 'steps.json'), 'utf8'));
expect(steps[0].status).toBe('completed');
});
it('creates a new file when the source is /dev/null and the path is in step.files[]', async () => {
await seedPlan([
{ id: 'add-config', files: ['config/feature-flags.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
const diff = `--- /dev/null
+++ b/config/feature-flags.ts
@@ -0,0 +1,3 @@
+export const flags = {
+ newButton: true,
+};
`;
const result = await applyPatchForStep({ cwd, stepId: 'add-config', diff });
expect(result.status).toBe('completed');
const created = await readFile(path.join(cwd, 'config', 'feature-flags.ts'), 'utf8');
expect(created).toContain('newButton: true');
});
it('deletes a file when the target is /dev/null', async () => {
await seedPlan([
{ id: 'remove-old', files: ['old.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
await writeFile(path.join(cwd, 'old.ts'), 'export const x = 1;\n');
const diff = `--- a/old.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-export const x = 1;
`;
const result = await applyPatchForStep({ cwd, stepId: 'remove-old', diff });
expect(result.status).toBe('completed');
await expect(readFile(path.join(cwd, 'old.ts'), 'utf8')).rejects.toThrow();
});
});
describe('applyPatchForStep — safety guards', () => {
it('refuses a hunk whose target is not in step.files[]', async () => {
await seedPlan([
{ id: 'rewrite-button', files: ['Button.tsx'], rationale: '', risk: 'low', status: 'pending' },
]);
await writeFile(path.join(cwd, 'Other.tsx'), 'export const X = 1;\n');
const diff = `--- a/Other.tsx
+++ b/Other.tsx
@@ -1,1 +1,1 @@
-export const X = 1;
+export const X = 2;
`;
const result = await applyPatchForStep({ cwd, stepId: 'rewrite-button', diff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/not in step\.files\[\]/);
// Verify no mutation happened.
expect(await readFile(path.join(cwd, 'Other.tsx'), 'utf8')).toBe('export const X = 1;\n');
});
it('refuses to touch a shell-tier file when step.risk !== high', async () => {
await seedPlan(
[{ id: 'tweak-layout', files: ['app/layout.tsx'], rationale: '', risk: 'low', status: 'pending' }],
[{ file: 'app/layout.tsx', layer: 'shell' }],
);
await mkdir(path.join(cwd, 'app'), { recursive: true });
await writeFile(path.join(cwd, 'app', 'layout.tsx'),
`export default function Layout() { return null; }\n`);
const diff = `--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,1 +1,1 @@
-export default function Layout() { return null; }
+export default function Layout() { return <div />; }
`;
const result = await applyPatchForStep({ cwd, stepId: 'tweak-layout', diff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/may not touch shell-tier/);
});
it('refuses path-traversal targets', async () => {
await seedPlan([
{ id: 'naughty', files: ['../escape.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
const diff = `--- /dev/null
+++ b/../escape.ts
@@ -0,0 +1,1 @@
+x
`;
const result = await applyPatchForStep({ cwd, stepId: 'naughty', diff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/unsafe path/);
});
it('refuses Windows absolute targets', async () => {
await seedPlan([
{ id: 'naughty', files: ['C:/escape.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
const diff = `--- /dev/null
+++ b/C:/escape.ts
@@ -0,0 +1,1 @@
+x
`;
const result = await applyPatchForStep({ cwd, stepId: 'naughty', diff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/unsafe path/);
});
it('rejects context mismatches (stale patch detection)', async () => {
await seedPlan([
{ id: 'rewrite-x', files: ['x.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
await writeFile(path.join(cwd, 'x.ts'), 'line one\nline two\nline three\n');
const staleDiff = `--- a/x.ts
+++ b/x.ts
@@ -1,3 +1,3 @@
different one
-line two
+line TWO
line three
`;
const result = await applyPatchForStep({ cwd, stepId: 'rewrite-x', diff: staleDiff });
expect(result.status).toBe('failed');
expect(result.reason).toMatch(/context mismatch/);
// Original content preserved on rejection.
expect(await readFile(path.join(cwd, 'x.ts'), 'utf8')).toBe('line one\nline two\nline three\n');
});
it('returns idempotent result when called on an already-completed step', async () => {
await seedPlan([
{ id: 'done', files: ['x.ts'], rationale: '', risk: 'low', status: 'completed' },
]);
const r = await applyPatchForStep({ cwd, stepId: 'done', diff: '--- a/x.ts\n+++ b/x.ts\n@@ -0,0 +0,0 @@\n' });
expect(r.status).toBe('completed');
expect(r.reason).toMatch(/already in terminal state/);
});
});
describe('skipStep + readPlanProgress', () => {
it('marks a step skipped + writes a receipt', async () => {
await seedPlan([
{ id: 'maybe', files: ['x.ts'], rationale: '', risk: 'low', status: 'pending' },
{ id: 'next', files: ['y.ts'], rationale: '', risk: 'low', status: 'pending' },
]);
await skipStep({ cwd, stepId: 'maybe', rationale: 'no longer needed after token alignment' });
const steps = JSON.parse(await readFile(path.join(cwd, 'plan', 'steps.json'), 'utf8'));
expect(steps.find((s: { id: string }) => s.id === 'maybe').status).toBe('skipped');
const receipt = JSON.parse(await readFile(path.join(cwd, 'plan', 'receipts', 'step-maybe.json'), 'utf8'));
expect(receipt.rationale).toMatch(/token alignment/);
});
it('readPlanProgress reports total + terminal counts', async () => {
await seedPlan([
{ id: 'a', files: [], rationale: '', risk: 'low', status: 'pending' },
{ id: 'b', files: [], rationale: '', risk: 'low', status: 'completed' },
{ id: 'c', files: [], rationale: '', risk: 'low', status: 'skipped' },
]);
const p = await readPlanProgress(cwd);
expect(p).toEqual({ total: 3, terminal: 2 });
});
});