From 9db357f3d1ab1b71bf71671fdb6875789df72c91 Mon Sep 17 00:00:00 2001 From: artale Date: Wed, 1 Jul 2026 22:28:46 +0200 Subject: [PATCH] feat: add structural patch receipts --- src/fable5/index.ts | 3 + src/fable5/structural-patch-receipt.test.ts | 91 ++++++++++++++++++++ src/fable5/structural-patch-receipt.ts | 94 +++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 src/fable5/structural-patch-receipt.test.ts create mode 100644 src/fable5/structural-patch-receipt.ts diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 675aff0..e266f92 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -81,6 +81,9 @@ export type { ResearchMapOptions, ResearchMapReceipt } from "./research-map-rece export { createUastRuntimeReceipt, writeUastRuntimeReceipt } 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 type { ChannelEvent, ChannelEventSource, ChannelEventType, NewChannelEvent } from "./channel.js"; diff --git a/src/fable5/structural-patch-receipt.test.ts b/src/fable5/structural-patch-receipt.test.ts new file mode 100644 index 0000000..de7aa60 --- /dev/null +++ b/src/fable5/structural-patch-receipt.test.ts @@ -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 }); + }); +}); diff --git a/src/fable5/structural-patch-receipt.ts b/src/fable5/structural-patch-receipt.ts new file mode 100644 index 0000000..6539259 --- /dev/null +++ b/src/fable5/structural-patch-receipt.ts @@ -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; +}