feat: add structural patch receipts
This commit is contained in:
parent
60d7edff00
commit
9db357f3d1
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue