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 { 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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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