From 0068d9da42af8e0cdd26f7beaa2ba34d7c70e878 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:58:23 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20the=20carve=20estate=20frame=20?= =?UTF-8?q?=E2=80=94=20the=20chant=20box=20beside=20the=20Terraform=20box?= =?UTF-8?q?=20(#254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's carve graph opens on the estate, not the ranking alone: bandedPlan extracted from renderBanded, renderCarveEstate composes the band panels inside a titled terraform member box with the demo copy's chant project boxed beside it. App ids namespaced /; TF addresses keep their ids so the morph (M2b) keeps identity continuity. Bare 'behold carve report.json' still renders the single-view ranking. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXwApfRA4Pvoou5Ajj8qHV --- docs/src/content/docs/using/carve.mdx | 6 +- src/render.test.ts | 44 +++++++++- src/render.ts | 113 +++++++++++++++++++++++--- src/server.ts | 32 ++++++-- 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx index 80df175..e79b4b8 100644 --- a/docs/src/content/docs/using/carve.mdx +++ b/docs/src/content/docs/using/carve.mdx @@ -30,7 +30,9 @@ Terraform sub-resources that inline into a parent — `aws_s3_bucket_versioning` ## The walkthrough: `behold demo carve` -`npx behold demo carve` copies a bundled half-migrated estate — a small chant project beside a Terraform one, both describing the same AWS account — installs the chant it will shell, runs the advisor over the copy, and opens the banded graph with a six-step stepper on the panel's **Carve** tab. No Docker, no cloud account, no `terraform` binary. +`npx behold demo carve` copies a bundled half-migrated estate — a small chant project beside a Terraform one, both describing the same AWS account — installs the chant it will shell, runs the advisor over the copy, and opens the graph with a six-step stepper on the panel's **Carve** tab. No Docker, no cloud account, no `terraform` binary. + +The demo's graph is an **estate frame**: the banded ranking sits inside a `legacy-tf — terraform` box, and the copy's chant project draws as an `app — chant` box beside it, holding the pieces carved last month. The migration reads as half-done from the first frame, which is the truth the demo estate was authored to tell. A plain `behold carve report.json` has no chant project to show, so it draws the banded ranking alone. 1. **Advise** — the bands, with what each one means and how many landed in it. 2. **Pick** — click a green card. The inspect pane shows the score arithmetic; the step names the boundary the cut crosses (and says plainly when the report carries counts rather than edge lists). @@ -43,7 +45,7 @@ The Emit step reports **`chant lint`, not `chant build`**. chant#1637: `carve em behold writes only into the copy it just made, and only into `app/carveout/` inside it. Both steps refuse outside a demo copy; a plain `behold carve report.json` shows the same six steps with the runs greyed out and a reason. -Deferred to a follow-up: the Floci `--live` tier (`terraform apply` into a scratch emulator, live observe beats, a real `terraform plan` showing no destroy) and the morph animation that slides the carved card out of the Terraform boundary and into the chant project. +Deferred to a follow-up: the Floci `--live` tier (`terraform apply` into a scratch emulator, live observe beats, a real `terraform plan` showing no destroy) and the morph animation that slides the carved card out of the Terraform boundary and into the chant project box the estate frame now draws. ## For agents diff --git a/src/render.test.ts b/src/render.test.ts index b77f4d5..b92ce8c 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderGraph, renderBanded } from "./render.ts"; +import { renderGraph, renderBanded, renderCarveEstate } from "./render.ts"; import type { GraphIR } from "@intentius/chant"; // M4: renderGraph gained an explicit `boxes: "byStack"` opt-in for the @@ -211,3 +211,45 @@ describe("renderBanded — the banded ranking layout (#252)", () => { expect(h).toBeGreaterThan(0); }); }); + +// #254: the estate frame — the banded ranking inside a Terraform member box, +// the demo's chant project boxed beside it. These pin the composition rules: +// TF addresses keep their ids (the morph's identity continuity), app ids are +// namespaced, and nothing either side owns is dropped. +describe("renderCarveEstate — the carve estate frame (#254)", () => { + const viewBox = (svg: string) => (svg.match(/viewBox="0 0 (\d+) (\d+)"/) ?? []).slice(1).map(Number); + const appIr: GraphIR = { + nodes: [ + { id: "apiLogs", kind: "LogGroup", lexicon: "aws", attrs: {} }, + { id: "assetsCdnDomain", kind: "SsmParameter", lexicon: "aws", attrs: {} }, + ], + edges: [], + groups: { byStack: { aws: ["apiLogs", "assetsCdnDomain"] } }, + }; + const estate = () => renderCarveEstate(bandedIr, appIr, { tfTitle: "legacy-tf — terraform", appTitle: "app — chant" }); + + it("boxes both members and keeps the band panels inside the TF side", () => { + const svg = estate().svg; + expect(svg).toContain("legacy-tf — terraform"); + expect(svg).toContain("app — chant"); + expect(svg).toContain("carve now"); + expect(svg).toContain("leave in Terraform"); + }); + + it("keeps TF ids untouched and namespaces app ids past collision reach", () => { + const { svg, ir } = estate(); + for (const n of bandedIr.nodes) expect(svg).toContain(`data-node-id="${n.id}"`); + expect(svg).toContain('data-node-id="app/apiLogs"'); + expect(svg).toContain('data-node-id="app/assetsCdnDomain"'); + const byStack = ir.groups.byStack as Record; + expect(byStack["app — chant"]).toEqual(["app/apiLogs", "app/assetsCdnDomain"]); + expect(byStack["carve now"]).toHaveLength(20); + }); + + it("stays near a screen's shape with both members side by side", () => { + const [w, h] = viewBox(estate().svg); + expect(w).toBeGreaterThan(0); + expect(h).toBeGreaterThan(0); + expect(w / h).toBeLessThan(4); + }); +}); diff --git a/src/render.ts b/src/render.ts index aa570fc..e76d6c6 100644 --- a/src/render.ts +++ b/src/render.ts @@ -143,7 +143,42 @@ export function renderGraph(ir: GraphIR, opts: { theme?: string; boxes?: "byStac * panel rather than vanishing. */ export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): RenderResult { - const bands = (ir.groups.byStack ?? {}) as Record; + const plan = bandedPlan(ir, (ir.groups.byStack ?? {}) as Record); + const height = plan.height; + const layout: Layout = { + width: plan.width, + height, + nodes: plan.placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y })), + }; + const svg = renderSvg(ir, layout, { + fit: true, + hideTitle: true, + groups: plan.boxes.map((b) => ({ ...b, y: height - b.y })), + ...(opts.theme ? { theme: opts.theme as never } : {}), + }); + return { svg }; +} + +// Shared metrics for the banded grid and the estate frame that wraps it. +const GAP = 28; // between cards +const PAD = 24; // panel inner padding +const TITLE = 34; // panel title band (pinhole draws its title at y + 23) +const BAND_GAP = 26; // between panels +const MEMBER_GAP = 48; // between the estate's member boxes + +/** The banded grid, before it becomes a picture: positions and panel boxes in + * y-DOWN coordinates (first band on top, the way the ranking reads) with a + * local (0,0) origin, so a caller can either flip and paint it as-is + * (`renderBanded`) or offset it into a larger composition first + * (`renderCarveEstate`). */ +interface BandedPlan { + placed: Array<{ id: string; x: number; y: number }>; + boxes: GroupBox[]; + width: number; + height: number; +} + +function bandedPlan(ir: GraphIR, bands: Record): BandedPlan { const size = footprints(ir); const dims = (id: string) => size.get(id) ?? { w: NODE_W, h: NODE_H }; @@ -152,10 +187,6 @@ export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): Render const ids = ir.nodes.map((n) => n.id); const cellW = Math.max(NODE_W, ...ids.map((id) => dims(id).w)); const cellH = Math.max(NODE_H, ...ids.map((id) => dims(id).h)); - const GAP = 28; // between cards - const PAD = 24; // panel inner padding - const TITLE = 34; // panel title band (pinhole draws its title at y + 23) - const BAND_GAP = 26; // between panels // Aim at a landscape picture rather than a square one — the graph pane is // wider than it is tall once the side panel takes its share. const cols = Math.max(1, Math.round(Math.sqrt((ids.length * (cellH + GAP) * 2) / (cellW + GAP)))); @@ -172,8 +203,6 @@ export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): Render ].filter(([, members]) => members.length > 0); const statusOf = new Map(ir.nodes.map((n) => [n.id, n.attrs?._status as Status | undefined])); - // Positions are computed y-DOWN (first band on top, the way it reads), then - // flipped once at the end: renderSvg consumes a y-up plane. const placed: Array<{ id: string; x: number; y: number }> = []; const boxes: GroupBox[] = []; let top = 0; @@ -194,12 +223,74 @@ export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): Render boxes.push({ title, x: panelW / 2, y: top + panelH / 2, w: panelW, h: panelH, ...(status ? { status } : {}) }); top += panelH + BAND_GAP; } - const height = Math.max(1, top - BAND_GAP); + return { placed, boxes, width: panelW, height: Math.max(1, top - BAND_GAP) }; +} + +/** The carve estate frame (#254): the banded peelability ranking inside a + * titled Terraform member box, with the served demo's chant project in its own + * box beside it — the estate view opens on a migration already half-done, and + * the morph at the end of the walkthrough has somewhere to land. + * + * The composed IR is returned alongside the SVG because the caller serves it + * to the SPA: app-side node ids are namespaced `/` (composeStacks' + * convention) so they can never collide with a Terraform address, while the + * ranked TF addresses keep their ids untouched — the morph's identity + * continuity (#230 M2b) depends on the carved card keeping its id across + * views. `groups.byStack` on the composed IR carries the bands plus the app + * member, so the client's box matcher sees every box it is shown. */ +export function renderCarveEstate( + tfIr: GraphIR, + appIr: GraphIR, + opts: { tfTitle: string; appTitle: string; theme?: string }, +): RenderResult & { ir: GraphIR } { + const prefix = `${opts.appTitle.split(" ")[0]}/`; + const appId = (id: string) => `${prefix}${id}`; + const appNodes = appIr.nodes.map((n) => ({ ...n, id: appId(n.id) })); + const appEdges = appIr.edges.map((e) => ({ ...e, from: appId(e.from), to: appId(e.to) })); + const namespacedApp: GraphIR = { ...appIr, nodes: appNodes, edges: appEdges, groups: {} }; + + const tfPlan = bandedPlan(tfIr, (tfIr.groups.byStack ?? {}) as Record); + // The app side reuses the banded grid as a single panel: same cell metrics + // discipline, and the panel title says what these cards have in common. + const appPlan = bandedPlan(namespacedApp, { "carved so far": appNodes.map((n) => n.id) }); + // Two member boxes, top-aligned, TF on the left where the ranking's weight + // is. Content sits inside each member at (PAD, TITLE + PAD). + const memberH = (plan: BandedPlan) => TITLE + PAD + plan.height + PAD; + const memberW = (plan: BandedPlan) => plan.width + PAD * 2; + const appX0 = memberW(tfPlan) + MEMBER_GAP; + const width = appX0 + memberW(appPlan); + const height = Math.max(memberH(tfPlan), memberH(appPlan)); + + const offset = (plan: BandedPlan, x0: number) => ({ + placed: plan.placed.map((p) => ({ id: p.id, x: x0 + PAD + p.x, y: TITLE + PAD + p.y })), + boxes: plan.boxes.map((b) => ({ ...b, x: x0 + PAD + b.x, y: TITLE + PAD + b.y })), + }); + const tf = offset(tfPlan, 0); + const app = offset(appPlan, appX0); + + // Member boxes first so the inner band panels draw on top of them. + const boxes: GroupBox[] = [ + { title: opts.tfTitle, x: memberW(tfPlan) / 2, y: memberH(tfPlan) / 2, w: memberW(tfPlan), h: memberH(tfPlan) }, + { title: opts.appTitle, x: appX0 + memberW(appPlan) / 2, y: memberH(appPlan) / 2, w: memberW(appPlan), h: memberH(appPlan) }, + ...tf.boxes, + ...app.boxes, + ]; + + const ir: GraphIR = { + nodes: [...tfIr.nodes, ...appNodes], + edges: [...tfIr.edges, ...appEdges], + groups: { + byStack: { + ...((tfIr.groups.byStack ?? {}) as Record), + [opts.appTitle]: appNodes.map((n) => n.id), + }, + }, + }; const layout: Layout = { - width: panelW, + width, height, - nodes: placed.map((p) => ({ id: p.id, x: p.x, y: height - p.y })), + nodes: [...tf.placed, ...app.placed].map((p) => ({ id: p.id, x: p.x, y: height - p.y })), }; const svg = renderSvg(ir, layout, { fit: true, @@ -207,7 +298,7 @@ export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): Render groups: boxes.map((b) => ({ ...b, y: height - b.y })), ...(opts.theme ? { theme: opts.theme as never } : {}), }); - return { svg }; + return { svg, ir }; } /** Re-place a laid-out graph's nodes on concentric rings by dagre rank (the diff --git a/src/server.ts b/src/server.ts index 759ee5e..9612748 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,7 +55,7 @@ import { addCompositeDepsCounted } from "./composite-deps.ts"; import { notesFor, tierMismatchNote, namespaceMismatchNote, namespaceJoinNote, type Zoom } from "./zoom-notes.ts"; import { resourcesByComponent, nonResourceEntities } from "./resources.ts"; import { summarizePlan } from "./reconcile.ts"; -import { renderGraph, renderArchitecture, renderBanded } from "./render.ts"; +import { renderGraph, renderArchitecture, renderBanded, renderCarveEstate } from "./render.ts"; import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts"; import { carveWriteBlock, @@ -437,14 +437,36 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422); }); - app.get("/api/graph", (c) => { + // The estate frame (#254): a demo copy carries a real chant project (`app/`, + // the pieces "carved last month"), so the graph opens on a chant box beside + // the Terraform box instead of the ranking alone. The app graph is one + // `chant graph` shell-out, cached for the server's lifetime — the demo copy's + // source doesn't change under a running walkthrough, and a reload shouldn't + // pay the shell-out again. Failure degrades to the single-view ranking: a + // bare `behold carve report.json` has no project at all and lands here too. + let appGraph: Promise<{ ir: GraphIR; label: string } | null> | undefined; + const appGraphOnce = () => { + if (!demo) return Promise.resolve(null); + appGraph ??= graphIr(demo.project) + .then((ir) => (ir.nodes.length ? { ir, label: relative(demo.root, demo.project).split(sep).join("/") } : null)) + .catch(() => null); + return appGraph; + }; + + app.get("/api/graph", async (c) => { const parsed = load(); if (!parsed.ok) return c.json(parsed.refusal, 422); - const ir = carveReportToIr(parsed.report); + const tfIr = carveReportToIr(parsed.report); // A ranking, not a topology — `renderBanded` stacks the bands and grid-wraps // each one, because dagre lays an edgeless graph out along a single row (see // its doc comment for the numbers). - const { svg } = renderBanded(ir); + const appSide = await appGraphOnce(); + const { svg, ir } = appSide + ? renderCarveEstate(tfIr, appSide.ir, { + tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`, + appTitle: `${appSide.label} — chant`, + }) + : { ...renderBanded(tfIr), ir: tfIr }; return c.json({ ir, svg, @@ -456,7 +478,7 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { carve: true, // A demo whose own advisor run failed says so on the statusbar, not // only in the terminal the viewer isn't looking at. - note: carveNote(parsed.report, ir) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""), + note: carveNote(parsed.report, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""), }, }); });