Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/src/content/docs/using/carve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down
44 changes: 43 additions & 1 deletion src/render.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, string[]>;
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);
});
});
113 changes: 102 additions & 11 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
const plan = bandedPlan(ir, (ir.groups.byStack ?? {}) as Record<string, string[]>);
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<string, string[]>): BandedPlan {
const size = footprints(ir);
const dims = (id: string) => size.get(id) ?? { w: NODE_W, h: NODE_H };

Expand All @@ -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))));
Expand All @@ -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;
Expand All @@ -194,20 +223,82 @@ 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 `<member>/<id>` (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<string, string[]>);
// 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<string, string[]>),
[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,
hideTitle: true,
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
Expand Down
32 changes: 27 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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}` : ""),
},
});
});
Expand Down
Loading