mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
// Phase 7 entry slice / spec §20.3 / §21.3.2 — patch-edit atom.
|
|
//
|
|
// SKILL.md fragment ships at plugins/_official/atoms/patch-edit/.
|
|
// The runner applies a unified diff to a project cwd one step at a
|
|
// time, enforcing the safety contract:
|
|
//
|
|
// - The diff may only touch files listed in the matching
|
|
// plan/steps.json entry.
|
|
// - It may NOT touch a file classified as 'shell' tier in
|
|
// plan/ownership.json unless the matching step's risk='high'.
|
|
// - It refuses to introduce new files outside the step's files[].
|
|
// - It writes per-step receipts under plan/receipts/<id>.json.
|
|
// - It updates plan/steps.json's status field per step
|
|
// (pending → completed | skipped | failed).
|
|
//
|
|
// The diff format is a strict subset of unified diff:
|
|
//
|
|
// --- a/path/to/file
|
|
// +++ b/path/to/file
|
|
// @@ -start,len +start,len @@
|
|
// ␣context line
|
|
// -removed line
|
|
// +added line
|
|
//
|
|
// Multiple files are supported in one patch; the parser splits on
|
|
// `--- a/` markers. For new-file creation, callers use:
|
|
//
|
|
// --- /dev/null
|
|
// +++ b/path/to/new-file
|
|
// @@ -0,0 +1,N @@
|
|
// +new line 1
|
|
// +new line 2
|
|
//
|
|
// For file deletion:
|
|
//
|
|
// --- a/path/to/old-file
|
|
// +++ /dev/null
|
|
// @@ -1,N +0,0 @@
|
|
// -old line
|
|
//
|
|
// The applier validates context lines to catch stale patches.
|
|
|
|
import path from 'node:path';
|
|
import { promises as fsp } from 'node:fs';
|
|
import { randomBytes } from 'node:crypto';
|
|
import type { OwnershipEntry, RewriteStep } from './rewrite-plan.js';
|
|
|
|
// Plan §3.Z1 — atomic file write helper.
|
|
//
|
|
// Writes the body to a sibling temp file (`<path>.<random>.tmp`)
|
|
// and renames into place. POSIX rename(2) is atomic when source
|
|
// and destination are on the same filesystem, so a partial write
|
|
// never leaves the consumer staring at a half-truncated file.
|
|
//
|
|
// The temp file is removed on the failure path so a crash mid-
|
|
// write doesn't leak an orphan tmp blob next to every plan step.
|
|
async function atomicWriteFile(target: string, body: string | Buffer): Promise<void> {
|
|
const dir = path.dirname(target);
|
|
const base = path.basename(target);
|
|
const tmp = path.join(dir, `${base}.${randomBytes(6).toString('hex')}.tmp`);
|
|
await fsp.mkdir(dir, { recursive: true });
|
|
try {
|
|
await fsp.writeFile(tmp, body);
|
|
await fsp.rename(tmp, target);
|
|
} catch (err) {
|
|
// Best-effort cleanup of the orphan tmp file. We intentionally
|
|
// swallow the unlink error; rename failure is what the caller
|
|
// needs to see.
|
|
try { await fsp.unlink(tmp); } catch { /* tmp may not exist */ }
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export type PatchStepStatus = 'pending' | 'completed' | 'skipped' | 'failed';
|
|
|
|
export interface PatchStepRecord extends Omit<RewriteStep, 'rationale'> {
|
|
rationale?: string;
|
|
status?: PatchStepStatus;
|
|
completedAt?: string;
|
|
}
|
|
|
|
export interface PatchReceiptEntry {
|
|
step: string;
|
|
files: string[];
|
|
added: number;
|
|
removed: number;
|
|
rationale: string;
|
|
completedAt: string;
|
|
}
|
|
|
|
export interface ApplyPatchInput {
|
|
// Project cwd (the directory plan/steps.json lives under and the
|
|
// diff applies inside).
|
|
cwd: string;
|
|
// The step id in plan/steps.json this patch is supposed to satisfy.
|
|
stepId: string;
|
|
// The unified diff text. May contain multiple file hunks.
|
|
diff: string;
|
|
// Free-text rationale recorded into the receipt.
|
|
rationale?: string;
|
|
// When true (default false), allow file creation outside the
|
|
// step's files[]. Typical use: tests adding fixtures.
|
|
allowOutOfStepCreation?: boolean;
|
|
}
|
|
|
|
export interface ApplyPatchResult {
|
|
status: 'completed' | 'skipped' | 'failed';
|
|
filesTouched: string[];
|
|
added: number;
|
|
removed: number;
|
|
// When status='failed' or 'skipped', a structured reason the
|
|
// pipeline runner can surface to the agent.
|
|
reason?: string;
|
|
}
|
|
|
|
export interface SkipStepInput {
|
|
cwd: string;
|
|
stepId: string;
|
|
rationale: string;
|
|
}
|
|
|
|
export async function applyPatchForStep(input: ApplyPatchInput): Promise<ApplyPatchResult> {
|
|
const cwd = path.resolve(input.cwd);
|
|
const stepsPath = path.join(cwd, 'plan', 'steps.json');
|
|
const ownershipPath = path.join(cwd, 'plan', 'ownership.json');
|
|
|
|
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'patch-edit: missing plan/steps.json (run rewrite-plan first)');
|
|
const ownership = await readJson<OwnershipEntry[]>(ownershipPath, 'patch-edit: missing plan/ownership.json (run rewrite-plan first)').catch(() => [] as OwnershipEntry[]);
|
|
|
|
const step = steps.find((s) => s.id === input.stepId);
|
|
if (!step) {
|
|
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: `unknown step '${input.stepId}'` };
|
|
}
|
|
if (step.status === 'completed' || step.status === 'skipped' || step.status === 'failed') {
|
|
return { status: step.status === 'completed' ? 'completed' : 'skipped', filesTouched: [], added: 0, removed: 0,
|
|
reason: `step already in terminal state '${step.status}'` };
|
|
}
|
|
|
|
const allowedFiles = new Set(step.files);
|
|
const ownershipMap = new Map(ownership.map((o) => [o.file, o.layer]));
|
|
const hunks = parseUnifiedDiff(input.diff);
|
|
if (hunks.length === 0) {
|
|
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: 'patch contained no hunks' };
|
|
}
|
|
// Validate before touching disk: every hunk's target file must be
|
|
// in the step's files[] (unless allowOutOfStepCreation), and shell
|
|
// tier files require risk='high'.
|
|
for (const hunk of hunks) {
|
|
const target = hunk.targetFile;
|
|
const source = hunk.sourceFile;
|
|
// For deletions, source carries the file id and target is null.
|
|
// For everything else, target is the touched file.
|
|
const touched = target ?? source;
|
|
if (!touched) {
|
|
return { status: 'failed', filesTouched: [], added: 0, removed: 0, reason: 'hunk missing both source and target file' };
|
|
}
|
|
const isCreate = source === null;
|
|
if (!allowedFiles.has(touched)) {
|
|
if (!(isCreate && input.allowOutOfStepCreation)) {
|
|
return { status: 'failed', filesTouched: [], added: 0, removed: 0,
|
|
reason: `hunk targets ${touched} which is not in step.files[]` };
|
|
}
|
|
}
|
|
const layer = ownershipMap.get(touched);
|
|
if (layer === 'shell' && step.risk !== 'high') {
|
|
return { status: 'failed', filesTouched: [], added: 0, removed: 0,
|
|
reason: `step risk='${step.risk}' may not touch shell-tier file ${touched}; promote risk to 'high' or move the change` };
|
|
}
|
|
}
|
|
|
|
// Apply each hunk.
|
|
const filesTouched = new Set<string>();
|
|
let added = 0;
|
|
let removed = 0;
|
|
for (const hunk of hunks) {
|
|
try {
|
|
const result = await applyOneFileHunks(cwd, hunk);
|
|
filesTouched.add(hunk.targetFile ?? hunk.sourceFile!);
|
|
added += result.added;
|
|
removed += result.removed;
|
|
} catch (err) {
|
|
const touched = hunk.targetFile ?? hunk.sourceFile ?? '<unknown>';
|
|
return { status: 'failed', filesTouched: [...filesTouched], added, removed,
|
|
reason: `hunk apply failed for ${touched}: ${(err as Error).message}` };
|
|
}
|
|
}
|
|
|
|
// Mark the step completed in plan/steps.json + write a receipt.
|
|
step.status = 'completed';
|
|
step.completedAt = new Date().toISOString();
|
|
if (input.rationale) step.rationale = input.rationale;
|
|
await atomicWriteFile(stepsPath, JSON.stringify(steps, null, 2) + '\n');
|
|
const receiptDir = path.join(cwd, 'plan', 'receipts');
|
|
await fsp.mkdir(receiptDir, { recursive: true });
|
|
const receipt: PatchReceiptEntry = {
|
|
step: step.id,
|
|
files: [...filesTouched].sort(),
|
|
added,
|
|
removed,
|
|
rationale: input.rationale ?? '',
|
|
completedAt: step.completedAt,
|
|
};
|
|
await atomicWriteFile(
|
|
path.join(receiptDir, `step-${step.id}.json`),
|
|
JSON.stringify(receipt, null, 2) + '\n',
|
|
);
|
|
|
|
return { status: 'completed', filesTouched: [...filesTouched], added, removed };
|
|
}
|
|
|
|
export async function skipStep(input: SkipStepInput): Promise<void> {
|
|
const cwd = path.resolve(input.cwd);
|
|
const stepsPath = path.join(cwd, 'plan', 'steps.json');
|
|
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'patch-edit: missing plan/steps.json');
|
|
const step = steps.find((s) => s.id === input.stepId);
|
|
if (!step) throw new Error(`patch-edit: unknown step '${input.stepId}'`);
|
|
step.status = 'skipped';
|
|
step.completedAt = new Date().toISOString();
|
|
step.rationale = input.rationale;
|
|
await atomicWriteFile(stepsPath, JSON.stringify(steps, null, 2) + '\n');
|
|
const receiptDir = path.join(cwd, 'plan', 'receipts');
|
|
await fsp.mkdir(receiptDir, { recursive: true });
|
|
const receipt: PatchReceiptEntry = {
|
|
step: step.id,
|
|
files: [],
|
|
added: 0,
|
|
removed: 0,
|
|
rationale: input.rationale,
|
|
completedAt: step.completedAt,
|
|
};
|
|
await atomicWriteFile(
|
|
path.join(receiptDir, `step-${step.id}.json`),
|
|
JSON.stringify(receipt, null, 2) + '\n',
|
|
);
|
|
}
|
|
|
|
// --- diff parsing -----------------------------------------------------
|
|
|
|
interface FileHunkBundle {
|
|
sourceFile: string | null; // null when /dev/null (file creation)
|
|
targetFile: string | null; // null when /dev/null (file deletion)
|
|
hunks: ParsedHunk[];
|
|
}
|
|
|
|
interface ParsedHunk {
|
|
oldStart: number;
|
|
oldLines: number;
|
|
newStart: number;
|
|
newLines: number;
|
|
body: string[]; // diff body lines (excluding the @@ header)
|
|
}
|
|
|
|
function parseUnifiedDiff(diff: string): FileHunkBundle[] {
|
|
// Split on '\n--- ' markers (keeping the first marker on the
|
|
// leading section). We also accept input that begins with '---'
|
|
// directly.
|
|
const lines = diff.split(/\r?\n/);
|
|
const bundles: FileHunkBundle[] = [];
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i] ?? '';
|
|
if (!line.startsWith('--- ')) { i++; continue; }
|
|
const sourcePath = parsePathLine(line);
|
|
const next = lines[i + 1] ?? '';
|
|
if (!next.startsWith('+++ ')) {
|
|
throw new Error(`expected '+++ ' after '--- ' at line ${i + 1}`);
|
|
}
|
|
const targetPath = parsePathLine(next);
|
|
i += 2;
|
|
const hunks: ParsedHunk[] = [];
|
|
while (i < lines.length) {
|
|
const cursor = lines[i] ?? '';
|
|
if (cursor.startsWith('--- ')) break;
|
|
if (cursor.startsWith('@@ ')) {
|
|
const header = parseHunkHeader(cursor);
|
|
i++;
|
|
const body: string[] = [];
|
|
while (i < lines.length) {
|
|
const c = lines[i] ?? '';
|
|
if (c.startsWith('--- ') || c.startsWith('@@ ')) break;
|
|
body.push(c);
|
|
i++;
|
|
}
|
|
hunks.push({ ...header, body });
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
bundles.push({
|
|
sourceFile: sourcePath === '/dev/null' ? null : sourcePath,
|
|
targetFile: targetPath === '/dev/null' ? null : targetPath,
|
|
hunks,
|
|
});
|
|
}
|
|
return bundles;
|
|
}
|
|
|
|
function parsePathLine(line: string): string {
|
|
const after = line.slice(4).trim();
|
|
if (after === '/dev/null') return '/dev/null';
|
|
// Strip the 'a/' or 'b/' prefix when present.
|
|
if (after.startsWith('a/')) return after.slice(2);
|
|
if (after.startsWith('b/')) return after.slice(2);
|
|
return after;
|
|
}
|
|
|
|
function parseHunkHeader(line: string): { oldStart: number; oldLines: number; newStart: number; newLines: number } {
|
|
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
if (!m) throw new Error(`malformed hunk header: ${line}`);
|
|
return {
|
|
oldStart: parseInt(m[1]!, 10),
|
|
oldLines: m[2] ? parseInt(m[2], 10) : 1,
|
|
newStart: parseInt(m[3]!, 10),
|
|
newLines: m[4] ? parseInt(m[4], 10) : 1,
|
|
};
|
|
}
|
|
|
|
function resolvePatchFile(cwd: string, file: string): string {
|
|
const unsafe = `unsafe path '${file}'`;
|
|
if (file.includes('\0')) throw new Error(unsafe);
|
|
if (/^[A-Za-z]:/.test(file)) throw new Error(unsafe);
|
|
if (path.isAbsolute(file) || path.win32.isAbsolute(file) || path.posix.isAbsolute(file)) {
|
|
throw new Error(unsafe);
|
|
}
|
|
if (file.replace(/\\/g, '/').split('/').some((segment) => segment === '..')) {
|
|
throw new Error(unsafe);
|
|
}
|
|
const abs = path.resolve(cwd, file);
|
|
const relative = path.relative(cwd, abs);
|
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
throw new Error(unsafe);
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
async function applyOneFileHunks(cwd: string, bundle: FileHunkBundle): Promise<{ added: number; removed: number }> {
|
|
if (bundle.sourceFile === null && bundle.targetFile === null) {
|
|
throw new Error('hunk has /dev/null on both sides');
|
|
}
|
|
const sourceAbs = bundle.sourceFile === null ? null : resolvePatchFile(cwd, bundle.sourceFile);
|
|
const targetAbs = bundle.targetFile === null ? null : resolvePatchFile(cwd, bundle.targetFile);
|
|
|
|
if (bundle.targetFile === null) {
|
|
// File deletion.
|
|
let body: string;
|
|
try { body = await fsp.readFile(sourceAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.sourceFile}`); }
|
|
await fsp.unlink(sourceAbs!);
|
|
return { added: 0, removed: body.split('\n').length };
|
|
}
|
|
|
|
if (bundle.sourceFile === null) {
|
|
// File creation.
|
|
await fsp.mkdir(path.dirname(targetAbs!), { recursive: true });
|
|
let added = 0;
|
|
const lines: string[] = [];
|
|
for (const hunk of bundle.hunks) {
|
|
for (const l of hunk.body) {
|
|
if (l.startsWith('+')) { lines.push(l.slice(1)); added++; }
|
|
else if (l === '' || l.startsWith('\\')) { /* trailing newline marker */ }
|
|
}
|
|
}
|
|
await atomicWriteFile(targetAbs!, lines.join('\n') + (lines.length > 0 ? '\n' : ''));
|
|
return { added, removed: 0 };
|
|
}
|
|
|
|
// Plain edit.
|
|
let original: string;
|
|
try { original = await fsp.readFile(targetAbs!, 'utf8'); } catch { throw new Error(`file not found: ${bundle.targetFile}`); }
|
|
const originalLines = original.split('\n');
|
|
// Trailing newline produces an empty last element after split; we
|
|
// preserve that and add it back at the end.
|
|
const trailingNL = original.endsWith('\n');
|
|
if (trailingNL) originalLines.pop();
|
|
|
|
// Apply hunks in reverse order so prior offsets stay valid.
|
|
const hunks = [...bundle.hunks].sort((a, b) => b.oldStart - a.oldStart);
|
|
let added = 0;
|
|
let removed = 0;
|
|
let working = originalLines.slice();
|
|
for (const hunk of hunks) {
|
|
const oldIndex = hunk.oldStart - 1;
|
|
const result = applyHunkBody(working, oldIndex, hunk.body);
|
|
added += result.added;
|
|
removed += result.removed;
|
|
working = result.working;
|
|
}
|
|
const final = working.join('\n') + (trailingNL ? '\n' : '');
|
|
await atomicWriteFile(targetAbs!, final);
|
|
return { added, removed };
|
|
}
|
|
|
|
function applyHunkBody(lines: string[], oldIndex: number, body: string[]): { working: string[]; added: number; removed: number } {
|
|
// Walk the body: '-' / ' ' lines must match `lines[i]`; '+' lines
|
|
// are inserts.
|
|
const before = lines.slice(0, oldIndex);
|
|
const after: string[] = [];
|
|
const replacement: string[] = [];
|
|
let cursor = oldIndex;
|
|
let added = 0;
|
|
let removed = 0;
|
|
for (const raw of body) {
|
|
if (raw === '' || raw.startsWith('\\')) continue;
|
|
const tag = raw[0];
|
|
const content = raw.slice(1);
|
|
if (tag === ' ') {
|
|
if (lines[cursor] !== content) {
|
|
throw new Error(`context mismatch at line ${cursor + 1}: expected ${JSON.stringify(content)} got ${JSON.stringify(lines[cursor])}`);
|
|
}
|
|
replacement.push(content);
|
|
cursor++;
|
|
} else if (tag === '-') {
|
|
if (lines[cursor] !== content) {
|
|
throw new Error(`removal mismatch at line ${cursor + 1}: expected ${JSON.stringify(content)} got ${JSON.stringify(lines[cursor])}`);
|
|
}
|
|
cursor++;
|
|
removed++;
|
|
} else if (tag === '+') {
|
|
replacement.push(content);
|
|
added++;
|
|
} else {
|
|
// Unknown line tag; treat as context for safety.
|
|
replacement.push(raw);
|
|
cursor++;
|
|
}
|
|
}
|
|
for (const l of lines.slice(cursor)) after.push(l);
|
|
return { working: [...before, ...replacement, ...after], added, removed };
|
|
}
|
|
|
|
async function readJson<T>(p: string, missingMsg: string): Promise<T> {
|
|
try {
|
|
return JSON.parse(await fsp.readFile(p, 'utf8')) as T;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new Error(missingMsg);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// --- aggregate progress helper ---------------------------------------
|
|
|
|
export async function readPlanProgress(cwd: string): Promise<{ total: number; terminal: number }> {
|
|
const stepsPath = path.join(cwd, 'plan', 'steps.json');
|
|
const steps = await readJson<PatchStepRecord[]>(stepsPath, 'plan/steps.json missing');
|
|
const terminal = steps.filter((s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed').length;
|
|
return { total: steps.length, terminal };
|
|
}
|