feat: add structural patch receipts

This commit is contained in:
artale 2026-07-01 22:28:46 +02:00
parent 60d7edff00
commit 9db357f3d1
3 changed files with 188 additions and 0 deletions

View File

@ -81,6 +81,9 @@ export type { ResearchMapOptions, ResearchMapReceipt } from "./research-map-rece
export { createUastRuntimeReceipt, writeUastRuntimeReceipt } from "./uast-runtime-receipt.js"; export { createUastRuntimeReceipt, writeUastRuntimeReceipt } from "./uast-runtime-receipt.js";
export type { UastClaim, UastClaimDecision, UastLanguage, UastParserRow, UastParserStatus, UastRuntimeReceipt, UastRuntimeReceiptOptions } from "./uast-runtime-receipt.js"; export type { UastClaim, UastClaimDecision, UastLanguage, UastParserRow, UastParserStatus, UastRuntimeReceipt, UastRuntimeReceiptOptions } from "./uast-runtime-receipt.js";
export { createStructuralPatchReceipt, writeStructuralPatchReceipt } from "./structural-patch-receipt.js";
export type { StructuralNodeCoordinate, StructuralNodeKind, StructuralPatchCheck, StructuralPatchDecision, StructuralPatchReceipt, StructuralPatchReceiptOptions } from "./structural-patch-receipt.js";
export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js"; export { channelPath, emitChannelEvent, readChannelEvents } from "./channel.js";
export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; export type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js";

View File

@ -0,0 +1,91 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { createStructuralPatchReceipt, writeStructuralPatchReceipt } from "./structural-patch-receipt.js";
const target = {
file: "src/app.tsx",
language: "node_tsx" as const,
nodeKind: "ControlNode" as const,
languageKind: "IfStatement",
path: [2, 1, 0],
start: 42,
end: 88,
baseSha: "abc123",
};
const checks = [{ name: "typecheck", command: "npm run -s build", status: "pass" as const }];
describe("structural patch receipts", () => {
it("marks exact coordinate patches as surgical", () => {
const receipt = createStructuralPatchReceipt({
now: new Date("2026-07-01T00:00:00.000Z"),
taskLabel: "deep-block-edit",
target,
parserReceipt: "docs/receipts/parser.json",
serializerReceipt: "docs/receipts/serializer.json",
checks,
});
expect(receipt).toMatchObject({
schema: "fable.structural_patch.receipt.v1",
decision: "surgical",
lockedSurroundingTree: true,
reasons: [],
deployAttempted: false,
});
expect(receipt.target.path).toEqual([2, 1, 0]);
expect(receipt.skipped).toContain("raw string concatenation");
});
it("blocks patches without coordinates and spans", () => {
const receipt = createStructuralPatchReceipt({
taskLabel: "bad",
target: { ...target, path: [], start: 10, end: 10 },
parserReceipt: "docs/receipts/parser.json",
serializerReceipt: "docs/receipts/serializer.json",
checks,
});
expect(receipt.decision).toBe("blocked");
expect(receipt.reasons).toContain("missing node path coordinates");
expect(receipt.reasons).toContain("invalid target span");
});
it("blocks raw string patches and missing parser or serializer support", () => {
const receipt = createStructuralPatchReceipt({ taskLabel: "raw", target, checks, rawStringPatch: true });
expect(receipt.decision).toBe("blocked");
expect(receipt.reasons).toContain("missing parser support receipt");
expect(receipt.reasons).toContain("missing serializer support receipt");
expect(receipt.reasons).toContain("raw string patch is not surgical");
});
it("blocks failed validation checks and deploy attempts", () => {
const receipt = createStructuralPatchReceipt({
taskLabel: "unsafe",
target,
parserReceipt: "docs/receipts/parser.json",
serializerReceipt: "docs/receipts/serializer.json",
checks: [{ name: "typecheck", command: "npm run -s build", status: "fail" }],
deployAttempted: true,
});
expect(receipt.decision).toBe("blocked");
expect(receipt.reasons).toContain("validation checks must pass");
expect(receipt.reasons).toContain("deploy is out of scope for structural patch receipts");
expect(receipt.deployAttempted).toBe(false);
});
it("writes receipts", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "structural-patch-"));
const file = path.join(dir, "receipt.json");
const receipt = createStructuralPatchReceipt({ taskLabel: "x", target, parserReceipt: "p", serializerReceipt: "s", checks });
writeStructuralPatchReceipt(file, receipt);
expect(JSON.parse(fs.readFileSync(file, "utf-8"))).toMatchObject({ schema: "fable.structural_patch.receipt.v1" });
fs.rmSync(dir, { recursive: true, force: true });
});
});

View File

@ -0,0 +1,94 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { UastLanguage } from "./uast-runtime-receipt.js";
export type StructuralNodeKind = "ScopeNode" | "ControlNode" | "StateNode" | "LeafNode";
export type StructuralPatchDecision = "surgical" | "blocked";
export interface StructuralNodeCoordinate {
file: string;
language: UastLanguage;
nodeKind: StructuralNodeKind;
languageKind: string;
path: number[];
start: number;
end: number;
baseSha: string;
}
export interface StructuralPatchCheck {
name: string;
command: string;
status: "pass" | "fail";
}
export interface StructuralPatchReceiptOptions {
taskLabel: string;
target: StructuralNodeCoordinate;
parserReceipt?: string;
serializerReceipt?: string;
checks: StructuralPatchCheck[];
rawStringPatch?: boolean;
deployAttempted?: boolean;
now?: Date;
}
export interface StructuralPatchReceipt {
schema: "fable.structural_patch.receipt.v1";
createdAt: string;
taskLabel: string;
target: StructuralNodeCoordinate;
parserReceipt?: string;
serializerReceipt?: string;
checks: StructuralPatchCheck[];
lockedSurroundingTree: boolean;
decision: StructuralPatchDecision;
reasons: string[];
skipped: string[];
deployAttempted: false;
}
function hasText(value: string | undefined): boolean {
return Boolean(value?.trim());
}
export function createStructuralPatchReceipt(opts: StructuralPatchReceiptOptions): StructuralPatchReceipt {
const reasons: string[] = [];
if (!hasText(opts.taskLabel)) reasons.push("missing task label");
if (!hasText(opts.target.file)) reasons.push("missing target file");
if (!hasText(opts.target.languageKind)) reasons.push("missing language node kind");
if (!hasText(opts.target.baseSha)) reasons.push("missing base sha");
if (opts.target.path.length === 0) reasons.push("missing node path coordinates");
if (opts.target.path.some((segment) => !Number.isInteger(segment) || segment < 0)) reasons.push("invalid node path coordinates");
if (!Number.isInteger(opts.target.start) || !Number.isInteger(opts.target.end) || opts.target.start < 0 || opts.target.end <= opts.target.start) {
reasons.push("invalid target span");
}
if (!hasText(opts.parserReceipt)) reasons.push("missing parser support receipt");
if (!hasText(opts.serializerReceipt)) reasons.push("missing serializer support receipt");
if (opts.checks.length === 0) reasons.push("missing validation checks");
if (opts.checks.some((check) => !hasText(check.name) || !hasText(check.command))) reasons.push("validation checks need names and commands");
if (opts.checks.some((check) => check.status !== "pass")) reasons.push("validation checks must pass");
if (opts.rawStringPatch) reasons.push("raw string patch is not surgical");
if (opts.deployAttempted) reasons.push("deploy is out of scope for structural patch receipts");
return {
schema: "fable.structural_patch.receipt.v1",
createdAt: (opts.now ?? new Date()).toISOString(),
taskLabel: opts.taskLabel,
target: opts.target,
parserReceipt: opts.parserReceipt,
serializerReceipt: opts.serializerReceipt,
checks: opts.checks,
lockedSurroundingTree: reasons.length === 0,
decision: reasons.length === 0 ? "surgical" : "blocked",
reasons,
skipped: ["raw string concatenation", "full universal UAST runtime", "cross-language pristine writer", "deploy"],
deployAttempted: false,
};
}
export function writeStructuralPatchReceipt(file: string, receipt: StructuralPatchReceipt): string {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
return file;
}