diff --git a/AGENTS.md b/AGENTS.md index 5de86d8..2828600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,20 @@ graph. 3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it; edit there to change the estate (chant is the source of truth, not behold). +## The carve loop (Terraform → chant, #230) + +`behold carve ` serves a `chant carve advise --json` peelability +report instead of a chant project. Same SPA, same `/api/graph` shape; `attrs. +_status` carries the band (`good` = carve now, `warn` = has boundary work, +`neutral` = leave in Terraform) and `attrs` carries the score arithmetic +(`score`, `arithmetic`, `inbound`, `outbound`, `tier`, `mapsTo`). +`GET /api/carve` returns the raw report verbatim. + +To move a resource: confirm its band on `/api/carve`, then run chant's own +`carve emit --state` and `carve bridge` in the project. `terraform state rm` and +applying the generated survivor rewrites stay a human gate — behold has no +endpoint that writes Terraform, and adding one would break the invariant below. + ## The act loop (delegated, never direct) behold does not apply. To change the estate: diff --git a/README.md b/README.md index 809b5c9..e06a7ab 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,26 @@ behold shells the **project's own** chant (resolved from the project's `@intentius/chant ^0.18.1` or later for the live overlay (`graph --live` observed nothing before that fix). +## Terraform carve-out: `behold carve ` + +`chant carve advise` ranks a Terraform estate by **peelability** — how cleanly +each resource could be carved into native chant source. `behold carve` draws +that ranking: one card per resource, three panels (carve now / boundary work / +leave in Terraform) on the same `attrs._status` drift palette every other view +uses, and the score arithmetic behind each rank in the inspect pane. + +```sh +chant carve advise --from ./terraform --report carve.json +behold carve carve.json # → http://localhost:4600 +curl localhost:4600/api/carve # the raw report, for agents +``` + +behold parses no HCL and needs no Terraform tooling: the report **is** the +contract. A file that isn't a peelability report is refused with a structured +`{error, code: "carve-report", remedy}` — in the terminal, and from the routes — +never a blank graph. See `docs/using/carve` and issue #230 for the roadmap +(the post-emit diff, then Terraform as an estate member). + ## Configuration — `.behold.json` An optional `.behold.json` in the served project's root is **behold's own** diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ca9d59b..b44151d 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -28,6 +28,7 @@ export default defineConfig({ { label: 'Reading the graph', slug: 'using/reading-the-graph' }, { label: 'Acting on it', slug: 'using/acting' }, { label: 'Export a snapshot', slug: 'using/export' }, + { label: 'Look at a Terraform carve-out', slug: 'using/carve' }, { label: 'Driving it from an agent', slug: 'using/agents' }, ], }, diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx new file mode 100644 index 0000000..72b36a4 --- /dev/null +++ b/docs/src/content/docs/using/carve.mdx @@ -0,0 +1,42 @@ +--- +title: Look at a Terraform carve-out +description: Render a chant peelability report as a graph — what is cheap to move off Terraform, and what it would cost. +--- + +`chant carve advise` reads a Terraform estate and ranks each resource by **peelability**: how cleanly it could be carved into native chant source. `behold carve` draws that ranking. + +```sh +chant carve advise --from ./terraform --report carve.json +npx behold carve carve.json +``` + +Read-only twice over. chant's advisor emits no chant source, patches no Terraform and makes no cloud calls; behold only draws what the report says. behold parses no HCL at all and needs no Terraform tooling installed — the report is the whole contract between the two. + +## What you see + +One card per ranked resource or module, in three panels: + +- **carve now** (green) — score 80-100. A clean leaf with a 1:1 native mapping and little or no boundary work. +- **boundary work** (amber) — score 50-79. Carvable, but survivors depend on it, so carving means patching the surviving Terraform. +- **leave in Terraform** (grey) — score 0-49. Unmappable, or too load-bearing to be worth it. + +The bands ride the same `attrs._status` channel the live drift overlay paints, so the colours mean what they always mean here: green is the good outcome, amber is work, grey is "not a claim". + +## The arithmetic behind a rank + +Click a card. The inspect pane shows the score spelled out — `100 - 12x3 inbound = 64` — alongside the counts it came from: inbound edges (a Terraform `data`-source patch each, the expensive ones), outbound edges (a deferred deploy-time input each, cheap), the native tier the resource maps to, whether `count`/`for_each` is in play, and the instance count. + +Terraform sub-resources that inline into a parent — `aws_s3_bucket_versioning` into its bucket — are not ranked separately. They carve with the parent, so they never appear as their own card. + +## For agents + +```sh +curl localhost:4600/api/carve # the raw report, verbatim +curl localhost:4600/api/graph # the same thing as {ir, svg, meta} +``` + +A file that isn't a peelability report gets a structured `{error, code: "carve-report", remedy}` — in the terminal before the server starts, and from the routes if it goes bad later. It never renders as a blank graph. + +## What is not here yet + +Today's `carve advise --json` reports per-resource boundary **counts**, not the edge lists behind them, so no boundary edges are drawn and the graph says so on its status line. chant#1636 is the ask to publish the lists; behold already draws them (labelled `inbound`/`outbound`, with the survivors that would need a data-source patch named in the inspect pane) the moment a report carries them. diff --git a/src/__fixtures__/carve-sample-estate.json b/src/__fixtures__/carve-sample-estate.json new file mode 100644 index 0000000..d41a91c --- /dev/null +++ b/src/__fixtures__/carve-sample-estate.json @@ -0,0 +1,179 @@ +{ + "from": "sample-estate", + "advisory": "read-only — emits nothing, patches nothing, touches no live resource", + "count": 8, + "bands": { + "clean leaf": 6, + "carvable w/ edits": 1, + "leave in Terraform": 1 + }, + "resources": [ + { + "address": "aws_cloudwatch_log_group.api", + "kind": "resource", + "score": 100, + "band": "clean leaf", + "mapsTo": "AWS::Logs::LogGroup", + "breakdown": { + "inbound": 0, + "outbound": 0, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": 0, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_subnet.a", + "kind": "resource", + "score": 96, + "band": "clean leaf", + "mapsTo": "AWS::EC2::Subnet", + "breakdown": { + "inbound": 0, + "outbound": 1, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": -4, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_subnet.b", + "kind": "resource", + "score": 96, + "band": "clean leaf", + "mapsTo": "AWS::EC2::Subnet", + "breakdown": { + "inbound": 0, + "outbound": 1, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": -4, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_subnet.c", + "kind": "resource", + "score": 96, + "band": "clean leaf", + "mapsTo": "AWS::EC2::Subnet", + "breakdown": { + "inbound": 0, + "outbound": 1, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": -4, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_s3_bucket.assets", + "kind": "resource", + "score": 88, + "band": "clean leaf", + "mapsTo": "AWS::S3::Bucket", + "breakdown": { + "inbound": 1, + "outbound": 0, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": -12, + "outbound": 0, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_lambda_function.api", + "kind": "resource", + "score": 81, + "band": "clean leaf", + "mapsTo": "AWS::Lambda::Function", + "breakdown": { + "inbound": 0, + "outbound": 1, + "tier": 2, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": -4, + "tier": -15, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "aws_vpc.main", + "kind": "resource", + "score": 64, + "band": "carvable w/ edits", + "mapsTo": "AWS::EC2::VPC", + "breakdown": { + "inbound": 3, + "outbound": 0, + "tier": 1, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": -36, + "outbound": 0, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + }, + { + "address": "random_pet.suffix", + "kind": "resource", + "score": 0, + "band": "leave in Terraform", + "breakdown": { + "inbound": 0, + "outbound": 0, + "tier": null, + "hasDynamic": false, + "instances": 1, + "penalties": { + "inbound": 0, + "outbound": 0, + "tier": 0, + "dynamic": 0, + "instances": 0 + } + } + } + ] +} diff --git a/src/carve-lens.test.ts b/src/carve-lens.test.ts new file mode 100644 index 0000000..29a49b7 --- /dev/null +++ b/src/carve-lens.test.ts @@ -0,0 +1,344 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + parseCarveReport, + readCarveReport, + carveReportToIr, + carveNote, + statusForBand, + tfTypeOf, + scoreArithmetic, + carveCardFields, + type CarveReport, +} from "./carve-lens.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(HERE, "__fixtures__", "carve-sample-estate.json"); + +/** + * The fixture is a REAL report: `chant carve advise --from sample-estate --json` + * run against chant's own `packages/core/src/terraform/__fixtures__/sample-estate` + * (copied to a scratch dir so nothing in the chant checkout was touched). It is + * the contract as chant emits it today — no `version` field, and per-resource + * boundary COUNTS with no edge lists. Both facts are asserted below, so this + * test tells us the day chant#1636 changes either one. + */ +const report = JSON.parse(readFileSync(FIXTURE, "utf8")) as CarveReport; + +describe("the real chant carve advise --json fixture", () => { + it("is the shape the lens claims it is", () => { + const parsed = parseCarveReport(report); + expect(parsed.ok).toBe(true); + expect(report.count).toBe(8); + expect(report.resources).toHaveLength(8); + expect(report.bands).toEqual({ "clean leaf": 6, "carvable w/ edits": 1, "leave in Terraform": 1 }); + }); + + it("carries no schema version yet (chant#1636) — which the lens accepts", () => { + expect(report.version).toBeUndefined(); + expect(parseCarveReport(report).ok).toBe(true); + }); + + it("carries boundary COUNTS but no boundary edge LISTS — the gap chant#1636 closes", () => { + for (const r of report.resources) { + expect(typeof r.breakdown?.inbound).toBe("number"); + expect(typeof r.breakdown?.outbound).toBe("number"); + expect(r.boundary).toBeUndefined(); + } + // The counts are real work: 4 inbound and 4 outbound across the estate. + const inbound = report.resources.reduce((s, r) => s + (r.breakdown?.inbound ?? 0), 0); + const outbound = report.resources.reduce((s, r) => s + (r.breakdown?.outbound ?? 0), 0); + expect([inbound, outbound]).toEqual([4, 4]); + }); +}); + +describe("carveReportToIr — the sample estate", () => { + const ir = carveReportToIr(report); + + it("makes one node per ranked resource, kinded by Terraform type on the terraform lexicon", () => { + expect(ir.nodes).toHaveLength(8); + expect(ir.nodes.every((n) => n.lexicon === "terraform")).toBe(true); + const byId = new Map(ir.nodes.map((n) => [n.id, n])); + expect(byId.get("aws_s3_bucket.assets")!.kind).toBe("aws_s3_bucket"); + expect(byId.get("random_pet.suffix")!.kind).toBe("random_pet"); + // The folded sub-resource carves WITH its parent, so chant never ranks it + // and it must not appear as a card of its own. + expect(byId.has("aws_s3_bucket_versioning.assets")).toBe(false); + }); + + it("paints the bands on the drift palette via attrs._status", () => { + const status = (id: string) => ir.nodes.find((n) => n.id === id)!.attrs._status; + expect(status("aws_cloudwatch_log_group.api")).toBe("good"); // 100, clean leaf + expect(status("aws_vpc.main")).toBe("warn"); // 64, carvable w/ edits + expect(status("random_pet.suffix")).toBe("neutral"); // 0, leave in Terraform + }); + + it("puts the score arithmetic in attrs, where the inspect pane already renders it", () => { + const vpc = ir.nodes.find((n) => n.id === "aws_vpc.main")!; + expect(vpc.attrs.score).toBe(64); + expect(vpc.attrs.inbound).toBe(3); + expect(vpc.attrs.arithmetic).toBe("100 - 12x3 inbound = 64"); + expect(vpc.attrs.boundaryWork).toBe("3 inbound (a data-source patch each)"); + expect(vpc.attrs.mapsTo).toBe("AWS::EC2::VPC"); + expect(vpc.attrs.tier).toBe(1); + + const lambda = ir.nodes.find((n) => n.id === "aws_lambda_function.api")!; + expect(lambda.attrs.arithmetic).toBe("100 - 4x1 outbound - 15x1 tier2 = 81"); + + // A clean 1:1 map with no boundary edges has no penalty terms to print. + const logs = ir.nodes.find((n) => n.id === "aws_cloudwatch_log_group.api")!; + expect(logs.attrs.arithmetic).toBe("100 (no penalties) = 100"); + expect(logs.attrs.boundaryWork).toBe("clean 1:1 native map, no boundary edges"); + + // No native mapping is not a penalty sum — chant awards no partial credit. + const pet = ir.nodes.find((n) => n.id === "random_pet.suffix")!; + expect(pet.attrs.tier).toBe("none"); + expect(pet.attrs.arithmetic).toBe("0 (no known native mapping — nothing to carve into)"); + }); + + it("boxes the ranking by band, carve-now first", () => { + expect(ir.groups.byStack).toEqual({ + "carve now": [ + "aws_cloudwatch_log_group.api", + "aws_subnet.a", + "aws_subnet.b", + "aws_subnet.c", + "aws_s3_bucket.assets", + "aws_lambda_function.api", + ], + "boundary work": ["aws_vpc.main"], + "leave in Terraform": ["random_pet.suffix"], + }); + }); + + it("draws no boundary edges from a report that carries none — no phantom pairings", () => { + // The counts alone cannot be paired into edges (4 inbound and 4 outbound + // admit several matchings), and inventing one would be a lie about which + // survivors need patching. Nothing drawn is the honest answer until + // chant#1636 publishes the lists. + expect(ir.edges).toEqual([]); + }); + + it("says so on the note, rather than leaving an edgeless graph unexplained", () => { + const note = carveNote(report, ir); + expect(note).toContain("6 clean leaf"); + expect(note).toContain("sample-estate"); + expect(note).toContain("chant#1636"); + }); +}); + +describe("carveReportToIr — boundary edges, once chant publishes them (#1636)", () => { + // chant's own `BoundaryEdge` shape (packages/core/src/terraform/carve.ts), + // attached per resource. This is the forward half of the contract: the lens + // must already draw it, so the day chant emits the lists behold needs no + // change to light them up. + const withEdges: CarveReport = { + version: 1, + from: "sample-estate", + count: 3, + bands: { "clean leaf": 2, "carvable w/ edits": 1 }, + resources: [ + { + address: "aws_vpc.main", + kind: "resource", + score: 64, + band: "carvable w/ edits", + mapsTo: "AWS::EC2::VPC", + breakdown: { inbound: 1, outbound: 0, tier: 1, hasDynamic: false, instances: 1, penalties: { inbound: -12, outbound: 0, tier: 0, dynamic: 0, instances: 0 } }, + boundary: { + inbound: [ + { + direction: "inbound", + survivor: "aws_subnet.a", + carved: "aws_vpc.main", + attrs: ["id"], + via: ["vpc_id"], + bridge: "tf-data-source", + required: "immediately", + }, + ], + }, + }, + { + address: "aws_subnet.a", + kind: "resource", + score: 96, + band: "clean leaf", + mapsTo: "AWS::EC2::Subnet", + breakdown: { inbound: 0, outbound: 1, tier: 1, hasDynamic: false, instances: 1, penalties: { inbound: 0, outbound: -4, tier: 0, dynamic: 0, instances: 0 } }, + // The SAME edge, reported from the other end — it must not draw twice. + boundary: [ + { + direction: "inbound", + survivor: "aws_subnet.a", + carved: "aws_vpc.main", + attrs: ["id"], + via: ["vpc_id"], + }, + { + direction: "outbound", + survivor: "aws_iam_role.gone", + carved: "aws_subnet.a", + attrs: ["arn"], + }, + ], + }, + { + address: "aws_cloudwatch_log_group.api", + kind: "resource", + score: 100, + band: "clean leaf", + breakdown: { inbound: 0, outbound: 0, tier: 1, hasDynamic: false, instances: 1, penalties: {} }, + }, + ], + }; + + const ir = carveReportToIr(withEdges); + + it("draws each boundary edge once, in dependency direction, tagged inbound/outbound", () => { + expect(ir.edges).toEqual([ + { from: "aws_subnet.a", to: "aws_vpc.main", kind: "ref", viaAttr: "inbound", toAttr: "id" }, + ]); + }); + + it("drops an edge whose other end isn't a ranked node, rather than minting a phantom card", () => { + // `aws_iam_role.gone` is a survivor chant never ranked (it's not carvable), + // so there is no card to draw the edge to. + expect(ir.nodes.map((n) => n.id)).not.toContain("aws_iam_role.gone"); + expect(ir.edges.some((e) => e.from.includes("gone") || e.to.includes("gone"))).toBe(false); + }); + + it("names the predicted diff in attrs: who needs a data-source patch on carve", () => { + const vpc = ir.nodes.find((n) => n.id === "aws_vpc.main")!; + expect(vpc.attrs.patchOnCarve).toBe("aws_subnet.a"); + const subnet = ir.nodes.find((n) => n.id === "aws_subnet.a")!; + expect(subnet.attrs.deferredInputs).toBe("aws_iam_role.gone"); + // "not reported" and "none" are different claims — a resource with no + // boundary list carries neither key. + expect(ir.nodes.find((n) => n.id === "aws_cloudwatch_log_group.api")!.attrs.patchOnCarve).toBeUndefined(); + }); + + it("drops the chant#1636 caveat from the note once edges are actually drawn", () => { + expect(carveNote(withEdges, ir)).not.toContain("chant#1636"); + }); + + it("reads a directionless edge as inbound — the same way in the edge and in the attrs", () => { + // Nothing in the edge itself distinguishes the two directions (both name + // the same carve-set side), so the conservative reading wins: a survivor + // that needs an immediate data-source patch. The graph and the inspect + // pane must not disagree about which it chose. + const bare = carveReportToIr({ + resources: [ + { + address: "aws_vpc.main", + score: 64, + band: "carvable w/ edits", + boundary: [{ survivor: "aws_subnet.a", carved: "aws_vpc.main" }], + }, + { address: "aws_subnet.a", score: 96, band: "clean leaf" }, + ], + }); + expect(bare.edges).toEqual([{ from: "aws_subnet.a", to: "aws_vpc.main", kind: "ref", viaAttr: "inbound" }]); + const vpc = bare.nodes.find((n) => n.id === "aws_vpc.main")!; + expect(vpc.attrs.patchOnCarve).toBe("aws_subnet.a"); + expect(vpc.attrs.deferredInputs).toBeUndefined(); + }); +}); + +describe("polite refusal on an unrecognized shape (#193's standard)", () => { + const refusalOf = (v: unknown) => { + const p = parseCarveReport(v); + expect(p.ok).toBe(false); + return p.ok ? null : p.refusal; + }; + + it("refuses a non-object", () => { + expect(refusalOf([1, 2, 3])!.code).toBe("carve-report"); + expect(refusalOf("nope")!.error).toContain("isn't a JSON object"); + }); + + it("refuses JSON with no resources array, and says where a real report comes from", () => { + const r = refusalOf({ from: "./tf", count: 2 })!; + expect(r.error).toContain("no `resources` array"); + expect(r.remedy).toContain("chant carve advise"); + }); + + it("names the entry that isn't a scored resource", () => { + const r = refusalOf({ resources: [{ address: "aws_vpc.main", score: 1, band: "clean leaf" }, { address: "x" }] })!; + expect(r.error).toContain("entry 1"); + }); + + it("accepts an absent version (every chant to date) and a known major", () => { + expect(parseCarveReport({ resources: [] }).ok).toBe(true); + expect(parseCarveReport({ version: 1, resources: [] }).ok).toBe(true); + expect(parseCarveReport({ version: "1.2.0", resources: [] }).ok).toBe(true); + }); + + it("refuses a schema major it doesn't speak, instead of half-reading it", () => { + const r = refusalOf({ version: 2, resources: [] })!; + expect(r.error).toContain("version 2"); + expect(r.remedy).toContain("Upgrade behold"); + }); + + it("blames the shape, not the version, for JSON that was never a report", () => { + // A package.json carries a `version` and nothing else this lens wants; + // "behold reads version 1" would be a baffling thing to say about it. + const r = refusalOf({ name: "@intentius/behold", version: "0.8.0" })!; + expect(r.error).toContain("no `resources` array"); + expect(r.error).not.toContain("schema version"); + }); + + it("refuses an unreadable path and a non-JSON file by the same route", () => { + const missing = readCarveReport("/no/such/report.json", () => { + throw new Error("ENOENT: no such file or directory"); + }); + expect(missing.ok).toBe(false); + expect(missing.ok ? "" : missing.refusal.error).toContain("ENOENT"); + + const notJson = readCarveReport("/x.json", () => "resource \"aws_s3_bucket\" \"assets\" {}"); + expect(notJson.ok).toBe(false); + expect(notJson.ok ? "" : notJson.refusal.error).toContain("isn't valid JSON"); + }); +}); + +describe("the small pure helpers", () => { + it("maps chant's band names, and falls back to the documented score thresholds", () => { + expect(statusForBand("clean leaf", 88)).toBe("good"); + expect(statusForBand("carvable w/ edits", 64)).toBe("warn"); + expect(statusForBand("leave in Terraform", 0)).toBe("neutral"); + // A band chant renames or adds still colours honestly. + expect(statusForBand("brand new band", 90)).toBe("good"); + expect(statusForBand("brand new band", 60)).toBe("warn"); + expect(statusForBand("brand new band", 10)).toBe("neutral"); + }); + + it("reads the Terraform type out of an address", () => { + expect(tfTypeOf("aws_s3_bucket.assets")).toBe("aws_s3_bucket"); + expect(tfTypeOf("module.cdn")).toBe("module"); + expect(tfTypeOf("module.cdn", "module")).toBe("module"); + expect(tfTypeOf("weird")).toBe("weird"); + }); + + it("says when the score was clamped rather than printing arithmetic that doesn't add up", () => { + const clamped = scoreArithmetic({ + address: "aws_vpc.busy", + score: 0, + band: "leave in Terraform", + breakdown: { inbound: 10, outbound: 0, tier: 1, instances: 1, penalties: { inbound: -120 } }, + }); + expect(clamped).toBe("100 - 12x10 inbound = 0 (clamped from -20)"); + }); + + it("pins the card to score + verdict, whatever the attrs' alphabetical order", () => { + const ir = carveReportToIr(report); + const bucket = ir.nodes.find((n) => n.id === "aws_s3_bucket.assets")!; + expect(carveCardFields(bucket)).toEqual([ + { label: "score", value: "88" }, + { label: "carve", value: "carve now" }, + ]); + // A node that isn't a carve node falls through to pinhole's default. + expect(carveCardFields({ attrs: { name: "x" } })).toBeUndefined(); + }); +}); diff --git a/src/carve-lens.ts b/src/carve-lens.ts new file mode 100644 index 0000000..53cce68 --- /dev/null +++ b/src/carve-lens.ts @@ -0,0 +1,423 @@ +/** + * The carve lens (#252, M1 of #230) — render a chant Terraform peelability + * report as a behold graph. + * + * behold never parses Terraform. `chant carve advise --json` already ranks each + * resource/module by how cheaply it could be carved into native chant source, + * and that report IS the contract: addresses, scores, bands, the penalty + * arithmetic behind each score, and (once chant#1636 lands the edge lists) the + * boundary edges a carve would cut. This module converts that JSON into a + * `GraphIR` the existing painter and SPA already know how to draw — no new + * frontend, no Terraform tooling in behold's dependency tree. + * + * What the picture says: + * - one card per ranked resource/module, `kind` = the Terraform type + * (`aws_s3_bucket`), lexicon `terraform` + * - the band rides `attrs._status`, so the drift palette colours it: carve-now + * green (`good`), boundary-work amber (`warn`), leave-in-Terraform grey + * (`neutral`) — the same vocabulary every other behold view paints + * - one boundary box per band (`groups.byStack`), so the ranking reads as the + * banded list the CLI prints + * - the score arithmetic lands in `attrs`, which the inspect pane renders as + * its "declared" section with no client change + * + * Folded sub-resources (an `aws_s3_bucket_versioning` inlined into its bucket) + * never appear: chant already dropped them from the ranking, because they carve + * with their parent rather than on their own. + */ +import type { GraphIR, IRNode, IREdge } from "@intentius/chant"; + +/** How a band paints. The three `_status` tokens #252 specifies. */ +export type CarveStatus = "good" | "warn" | "neutral"; + +/** + * One dependency edge a carve would cut, as chant's own `boundaryReport` + * classifies it (packages/core/src/terraform/carve.ts `BoundaryEdge`). + * + * NOT in today's `carve advise --json` — that report carries only per-resource + * inbound/outbound COUNTS (see `CarveBreakdown`), so there is no pairing to + * draw from and this lens emits no edges for it. chant#1636 is the ask to + * publish the lists; the shape below is chant's existing one, so when they + * arrive the edges draw with no further work here. Every field is optional + * except the two endpoints — a renderer should never harden a contract it + * doesn't own more than it must. + */ +export interface CarveBoundaryEdge { + /** `inbound` = a survivor depends on the carve set; `outbound` = the reverse. */ + direction?: "inbound" | "outbound"; + /** The surviving-Terraform side of the edge. */ + survivor: string; + /** The carve-set side of the edge. */ + carved: string; + /** Producer-side attribute path(s) referenced, e.g. `["id", "arn"]`. */ + attrs?: string[]; + /** The referring block's own attribute(s) the reference sits in, e.g. `["vpc_id"]`. */ + via?: string[]; + /** `tf-data-source` (patch the survivor now) or `deferred-input` (at apply). */ + bridge?: string; + /** `immediately` or `at-apply`. */ + required?: string; +} + +/** The signed penalty contributions behind a score (chant's `PeelabilityBreakdown`). */ +export interface CarvePenalties { + inbound?: number; + outbound?: number; + tier?: number; + dynamic?: number; + instances?: number; +} + +/** What drove a resource's score (chant's `PeelabilityBreakdown`). */ +export interface CarveBreakdown { + /** Survivors that depend on this — a Terraform `data`-source patch each. */ + inbound?: number; + /** Survivors this depends on — a deferred deploy-time input each. */ + outbound?: number; + /** Native-spec map difficulty: 1 clean, 2 reshaping, 3 hard, null unmappable. */ + tier?: 1 | 2 | 3 | null; + /** `count` / `for_each` / a `data` source is present. */ + hasDynamic?: boolean; + /** State-expanded instance count. */ + instances?: number; + penalties?: CarvePenalties; +} + +/** One ranked resource or module (chant's `Peelability`). */ +export interface CarveResource { + /** Terraform address — `aws_s3_bucket.assets`, `module.cdn`. The node id. */ + address: string; + kind?: "resource" | "module"; + score: number; + band: string; + /** The native chant/spec type a carve would target. Absent for modules/unmappable. */ + mapsTo?: string; + breakdown?: CarveBreakdown; + /** + * The boundary edges this resource's carve would cut — chant#1636's ask. + * Either a flat list or split by direction; both are read (see the note on + * {@link CarveBoundaryEdge}). + */ + boundary?: CarveBoundaryEdge[] | { inbound?: CarveBoundaryEdge[]; outbound?: CarveBoundaryEdge[] }; +} + +/** The `chant carve advise --json` payload. */ +export interface CarveReport { + /** + * Schema version — chant#1636's ask. ABSENT on every chant that predates it, + * which this lens accepts (that is the whole point of asking for the field: + * a renderer can then refuse a future major politely instead of misreading it). + */ + version?: number | string; + /** The Terraform estate directory the advisor read. */ + from?: string; + advisory?: string; + count?: number; + /** band name -> how many resources landed in it. */ + bands?: Record; + resources: CarveResource[]; +} + +/** The one schema major this lens knows how to read. */ +const SUPPORTED_MAJOR = 1; + +/** A refusal: why the file isn't a carve report, and what to do instead. #193's + * structured-error standard — `{error, code, remedy}`, the shape the SPA's + * precondition card renders and the CLI prints. */ +export interface CarveRefusal { + error: string; + code: "carve-report"; + remedy: string; +} + +export type CarveParse = { ok: true; report: CarveReport } | { ok: false; refusal: CarveRefusal }; + +const refuse = (error: string, remedy: string): CarveParse => ({ ok: false, refusal: { error, code: "carve-report", remedy } }); + +/** The remedy every shape refusal ends with — where a real report comes from. */ +const HOW_TO_GET_ONE = + "Generate one with `chant carve advise --from --report report.json` " + + "(chant's read-only Terraform peelability advisor), then `behold carve report.json`."; + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Validate an already-parsed JSON value as a carve report, or refuse politely. + * + * Deliberately shallow: it checks the handful of fields the lens actually reads + * (a `resources` array whose entries carry an address, a score and a band), not + * every field chant emits. A renderer that demands more than it uses breaks on + * the first additive field the producer ships. + * + * The version check is the reverse of that leniency and is why chant#1636 was + * asked for: an ABSENT version is accepted (every chant to date), a version + * whose major this lens knows is accepted, and anything else is refused rather + * than half-read. + */ +export function parseCarveReport(value: unknown): CarveParse { + if (!isRecord(value)) { + return refuse("That file isn't a carve report — its top level isn't a JSON object.", HOW_TO_GET_ONE); + } + // Shape before version, deliberately. Plenty of JSON files carry a `version` + // (every package.json does), and "behold reads version 1" is a baffling thing + // to say about a file that was never a carve report at all. The version check + // only earns its keep once the thing in hand looks like one. + if (!Array.isArray(value.resources)) { + return refuse( + "That JSON doesn't look like a carve report — it has no `resources` array.", + HOW_TO_GET_ONE, + ); + } + const bad = (value.resources as unknown[]).findIndex( + (r) => !isRecord(r) || typeof r.address !== "string" || typeof r.score !== "number" || typeof r.band !== "string", + ); + if (bad >= 0) { + return refuse( + `That JSON has a \`resources\` array, but entry ${bad} isn't a scored resource (needs a string \`address\`, a numeric \`score\` and a string \`band\`).`, + HOW_TO_GET_ONE, + ); + } + const version = value.version ?? value.schemaVersion; + if (version !== undefined) { + const major = Number(String(version).split(".")[0]); + if (!Number.isFinite(major) || major !== SUPPORTED_MAJOR) { + return refuse( + `This carve report declares schema version ${String(version)}; behold reads version ${SUPPORTED_MAJOR}.`, + "Upgrade behold, or render the report with a behold that speaks its version.", + ); + } + } + return { ok: true, report: value as unknown as CarveReport }; +} + +/** Read + parse a report file, refusing politely on anything unreadable. */ +export function readCarveReport(path: string, readFile: (p: string) => string): CarveParse { + let text: string; + try { + text = readFile(path); + } catch (err) { + return refuse( + `Couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`, + HOW_TO_GET_ONE, + ); + } + let json: unknown; + try { + json = JSON.parse(text); + } catch (err) { + return refuse( + `${path} isn't valid JSON: ${err instanceof Error ? err.message : String(err)}`, + HOW_TO_GET_ONE, + ); + } + return parseCarveReport(json); +} + +/** + * Band -> paint. Keyed on chant's own band names, with a score-threshold + * fallback (the model documented in chant's carve CLI page: 80-100 carve now, + * 50-79 boundary work, 0-49 leave) so a renamed or added band still colours + * something honest instead of falling through to no status at all. + */ +export function statusForBand(band: string, score: number): CarveStatus { + switch (band) { + case "clean leaf": + return "good"; + case "carvable w/ edits": + return "warn"; + case "leave in Terraform": + return "neutral"; + default: + return score >= 80 ? "good" : score >= 50 ? "warn" : "neutral"; + } +} + +/** The band's verdict in plain words — the boundary-box title, and the card's + * second field. Short enough for both. */ +export function verdictForStatus(status: CarveStatus): string { + return status === "good" ? "carve now" : status === "warn" ? "boundary work" : "leave in Terraform"; +} + +/** + * The Terraform type out of an address: `aws_s3_bucket.assets` -> `aws_s3_bucket`, + * `module.cdn` -> `module`. The node's `kind`, so pinhole's keyword glyph + * heuristic gets something to work with (bucket, vpc, subnet, function all + * resolve) and the inspect pane's identity block names the real Terraform type. + */ +export function tfTypeOf(address: string, kind?: string): string { + if (kind === "module" || address.startsWith("module.")) return "module"; + const dot = address.indexOf("."); + return dot > 0 ? address.slice(0, dot) : address; +} + +/** + * An edge's direction, defaulting to `inbound` when the producer omitted it. + * + * The default is not a guess dressed up as a fact: a resource's boundary list + * carries both directions and every entry names the same carve-set side, so + * there is nothing in the edge itself to infer from. `inbound` is the + * conservative read — it claims the survivor needs an immediate data-source + * patch, which is the reading that gets someone to look rather than the one + * that lets a cut slip past as "deferred until apply". + */ +const directionOf = (e: CarveBoundaryEdge): "inbound" | "outbound" => e.direction ?? "inbound"; + +/** Every boundary edge a resource declares, whichever of the two shapes it uses. */ +function boundaryEdgesOf(r: CarveResource): CarveBoundaryEdge[] { + const b = r.boundary; + if (!b) return []; + const list = Array.isArray(b) ? b : [...(b.inbound ?? []), ...(b.outbound ?? [])]; + return list.filter((e) => isRecord(e) && typeof e.survivor === "string" && typeof e.carved === "string"); +} + +/** + * The score, spelled out. chant itemizes every penalty term in `breakdown. + * penalties`, so the arithmetic can be reproduced rather than re-derived: + * `100 - 12x1 inbound = 88`. A resource with no native mapping scored 0 with no + * penalties at all (chant awards no partial credit), which reads as itself. + */ +export function scoreArithmetic(r: CarveResource): string { + const b = r.breakdown; + if (!b || b.tier === null) return `${r.score} (no known native mapping — nothing to carve into)`; + const p = b.penalties ?? {}; + const terms: string[] = []; + if (p.inbound) terms.push(`- 12x${b.inbound ?? 0} inbound`); + if (p.outbound) terms.push(`- 4x${b.outbound ?? 0} outbound`); + if (p.tier) terms.push(`- 15x${(b.tier ?? 1) - 1} tier${b.tier}`); + if (p.dynamic) terms.push(`- 10 dynamic`); + if (p.instances) terms.push(`- 3x${(b.instances ?? 1) - 1} instances`); + if (!terms.length) return `100 (no penalties) = ${r.score}`; + const raw = 100 + Object.values(p).reduce((s, n) => s + (n ?? 0), 0); + // chant clamps to 0..100; say so rather than printing arithmetic that doesn't + // add up to the score beside it. + const clamped = raw !== r.score ? ` (clamped from ${raw})` : ""; + return `100 ${terms.join(" ")} = ${r.score}${clamped}`; +} + +/** The one-line "what drove this score" chant's own console report prints. */ +export function boundaryWorkOf(r: CarveResource): string { + const b = r.breakdown; + if (!b || b.tier === null) return "no known native mapping (unsupported provider/type)"; + const parts: string[] = []; + if (b.inbound) parts.push(`${b.inbound} inbound (a data-source patch each)`); + if (b.outbound) parts.push(`${b.outbound} outbound (a deferred input each)`); + if ((b.tier ?? 1) > 1) parts.push(`tier ${b.tier} map`); + if (b.hasDynamic) parts.push("count/for_each/data present"); + if ((b.instances ?? 1) > 1) parts.push(`${b.instances} instances`); + return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges"; +} + +/** The two fields a carve card shows (pinhole presentation pack, registered in + * src/render.ts) — the score and the verdict, in that order, whatever the attr + * map's alphabetical order happens to be. */ +export function carveCardFields(node: { attrs: Record }): Array<{ label: string; value: string }> | undefined { + const score = node.attrs.score; + const carve = node.attrs.carve; + if (typeof score !== "number") return undefined; + return [ + { label: "score", value: String(score) }, + ...(typeof carve === "string" ? [{ label: "carve", value: carve }] : []), + ]; +} + +/** + * Convert a peelability report into a graph IR. + * + * Nodes are the ranked resources/modules; edges are the boundary edges, drawn + * in the direction the dependency actually runs (a survivor points AT the piece + * it would lose) and tagged `viaAttr: "inbound" | "outbound"` — the SPA already + * renders `viaAttr` as the edge's hover tooltip and dashed style, so the + * predicted cut reads without a client change. Edges whose other end isn't a + * ranked node are dropped rather than minting a phantom card. + */ +export function carveReportToIr(report: CarveReport): GraphIR { + const resources = report.resources ?? []; + const known = new Set(resources.map((r) => r.address)); + + const nodes: IRNode[] = resources.map((r) => { + const status = statusForBand(r.band, r.score); + const b = r.breakdown ?? {}; + const edges = boundaryEdgesOf(r); + const patches = edges.filter((e) => e.carved === r.address && directionOf(e) === "inbound").map((e) => e.survivor); + const inputs = edges.filter((e) => e.carved === r.address && directionOf(e) === "outbound").map((e) => e.survivor); + return { + id: r.address, + kind: tfTypeOf(r.address, r.kind), + lexicon: "terraform", + attrs: { + // The drift palette's channel — `_`-prefixed, so it paints the card and + // stays out of both the card's fields and the inspect pane's list. + _status: status, + score: r.score, + carve: verdictForStatus(status), + band: r.band, + arithmetic: scoreArithmetic(r), + boundaryWork: boundaryWorkOf(r), + ...(r.mapsTo ? { mapsTo: r.mapsTo } : {}), + tier: b.tier === null || b.tier === undefined ? "none" : b.tier, + inbound: b.inbound ?? 0, + outbound: b.outbound ?? 0, + instances: b.instances ?? 1, + dynamic: b.hasDynamic ?? false, + // The predicted diff, when the report carries the edge lists: who needs + // a `data` source the moment this is carved, and what becomes a + // deploy-time input. Absent (not empty) when the report has no lists — + // "none" and "not reported" are different claims. + ...(patches.length ? { patchOnCarve: patches.join(", ") } : {}), + ...(inputs.length ? { deferredInputs: inputs.join(", ") } : {}), + }, + }; + }); + + // Boundary edges, deduped: the same edge is reported by both endpoints once + // chant publishes per-resource lists. + const seen = new Set(); + const edges: IREdge[] = []; + for (const r of resources) { + for (const e of boundaryEdgesOf(r)) { + const direction = directionOf(e); + // `from` references `to` — an inbound edge is a survivor reaching into + // the carve set, an outbound edge is the carve set reaching out. + const from = direction === "inbound" ? e.survivor : e.carved; + const to = direction === "inbound" ? e.carved : e.survivor; + if (!known.has(from) || !known.has(to)) continue; + const key = `${from}|${to}|${direction}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + from, + to, + kind: "ref", + viaAttr: direction, + ...(e.attrs?.length ? { toAttr: e.attrs.join(", ") } : {}), + }); + } + } + + // One boundary box per band, in carve-now -> leave order, so the picture + // reads top-to-bottom like the CLI's banded ranking. `byStack` (not `byWave`) + // because render.ts boxes `byWave` automatically and this is an explicit + // opt-in — see renderGraph's doc comment. + const byStack: Record = {}; + for (const order of ["good", "warn", "neutral"] as CarveStatus[]) { + const members = resources.filter((r) => statusForBand(r.band, r.score) === order).map((r) => r.address); + if (members.length) byStack[verdictForStatus(order)] = members; + } + + return { nodes, edges, groups: { byStack } }; +} + +/** The one-line summary the server puts on `meta.note` — the statusbar's + * honesty about what this graph is, including the missing edge lists. */ +export function carveNote(report: CarveReport, ir: GraphIR): string { + const counts = Object.entries(report.bands ?? {}) + .filter(([, n]) => n > 0) + .map(([band, n]) => `${n} ${band}`) + .join(" · "); + const head = `chant carve advisory${report.from ? ` for ${report.from}` : ""}${counts ? ` — ${counts}` : ""}`; + if (ir.edges.length) return `${head}. Read-only: nothing is emitted, patched, or applied.`; + return ( + `${head}. This report carries per-resource boundary COUNTS but no edge lists, ` + + `so no boundary edges are drawn (chant#1636). Read-only: nothing is emitted, patched, or applied.` + ); +} diff --git a/src/carve-route.test.ts b/src/carve-route.test.ts new file mode 100644 index 0000000..051a14e --- /dev/null +++ b/src/carve-route.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createApp } from "./server.ts"; +import { Broadcaster } from "./events.ts"; +import { FrameBuffer } from "./frames.ts"; +import { OpRunner } from "./op-runner.ts"; +import type { GraphIR } from "@intentius/chant"; + +// The HTTP half of #252: carve mode's routes must win /api/graph and +// /api/project from the project-shaped handlers, so the existing SPA renders a +// peelability report with (almost) no client change — it asks for {ir, svg, +// meta} and gets exactly that. src/carve-lens.test.ts covers the conversion +// itself; this proves it reaches a client through the route, SVG included, and +// that a bad report is #193's structured error rather than a blank graph. +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = join(HERE, "__fixtures__", "carve-sample-estate.json"); + +function carveApp(reportPath: string) { + const broadcaster = new Broadcaster(); + const dir = dirname(reportPath); + return createApp( + { projectDir: dir, carveReport: reportPath, port: 0 }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} }), + ); +} + +/** Write `body` to a throwaway file and serve it. */ +function servedText(body: string, name = "report.json") { + const path = join(mkdtempSync(join(tmpdir(), "behold-carve-")), name); + writeFileSync(path, body); + return carveApp(path); +} + +describe("GET /api/graph — carve mode (#252)", () => { + it("renders the real sample-estate report: banded nodes, band boxes, an SVG", async () => { + const res = await carveApp(FIXTURE).request("/api/graph"); + expect(res.status).toBe(200); + const body = (await res.json()) as { ir: GraphIR; svg: string; meta: Record }; + + expect(body.ir.nodes).toHaveLength(8); + expect(body.ir.nodes.every((n) => n.lexicon === "terraform")).toBe(true); + // The drift palette's own vocabulary — the SPA colours these everywhere. + const statuses = new Set(body.ir.nodes.map((n) => n.attrs._status)); + expect([...statuses].sort()).toEqual(["good", "neutral", "warn"]); + + // The bands are actually DRAWN, not merely present in the IR: renderGraph + // opts into `boxes: "byStack"` here, and pinhole labels each box. + expect(body.svg).toContain(" { + const res = await carveApp(FIXTURE).request("/api/graph?detail=3&components=1&env=prod&radial=1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { ir: GraphIR; meta: { env: unknown; tier: unknown } }; + expect(body.ir.nodes).toHaveLength(8); + expect(body.meta.env).toBeNull(); + expect(body.meta.tier).toBeNull(); + }); + + it("answers a malformed report with a structured error, not a blank graph (#193)", async () => { + const app = servedText(JSON.stringify({ hello: "world" })); + const res = await app.request("/api/graph"); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string; code: string; remedy: string }; + expect(body.code).toBe("carve-report"); + expect(body.error).toContain("no `resources` array"); + expect(body.remedy).toContain("chant carve advise"); + }); + + it("refuses a schema major it doesn't speak", async () => { + const app = servedText(JSON.stringify({ version: 9, resources: [] })); + const res = await app.request("/api/graph"); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: string }).error).toContain("version 9"); + }); +}); + +describe("GET /api/carve — the raw report, for agents", () => { + it("serves the report verbatim", async () => { + const res = await carveApp(FIXTURE).request("/api/carve"); + expect(res.status).toBe(200); + const body = (await res.json()) as { from: string; count: number; bands: Record }; + expect(body.from).toBe("sample-estate"); + expect(body.count).toBe(8); + expect(body.bands["clean leaf"]).toBe(6); + }); + + it("is listed on /api, so an agent finds it without reading source", async () => { + const res = await carveApp(FIXTURE).request("/api"); + const body = (await res.json()) as { routes: Array<{ path: string }> }; + expect(body.routes.map((r) => r.path)).toContain("/api/carve"); + }); + + it("is absent from /api on an ordinary project serve", async () => { + const broadcaster = new Broadcaster(); + const app = createApp( + { projectDir: "/work/loomster", port: 0 }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: "/work/loomster", broadcaster, onDone: () => {} }), + ); + const body = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> }; + expect(body.routes.map((r) => r.path)).not.toContain("/api/carve"); + }); +}); + +describe("GET /api/project — carve mode", () => { + it("offers no live axes and names the report, so every picker stays empty", async () => { + const res = await carveApp(FIXTURE).request("/api/project"); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.environments).toEqual([]); + expect(body.targets).toEqual([]); + expect(body.currentEnv).toBeNull(); + expect(body.lexicons).toEqual(["terraform"]); + expect(body.tiers).toBeUndefined(); + expect(body.stacks).toBeUndefined(); + expect(body.carve).toMatchObject({ from: "sample-estate", count: 8 }); + }); +}); + +describe("carve mode probes nothing and writes nothing", () => { + it("answers substrates and history empty rather than running Docker and git for a static file", async () => { + const app = carveApp(FIXTURE); + expect(await (await app.request("/api/substrates")).json()).toEqual({ substrates: [] }); + expect(await (await app.request("/api/history")).json()).toEqual({ commits: [] }); + }); + + it("refuses the hand-layout sidecar (#228) — the report's directory is not a project to write into", async () => { + const app = carveApp(FIXTURE); + const read = (await (await app.request("/api/layout")).json()) as { writable: boolean; reason: string }; + expect(read.writable).toBe(false); + expect(read.reason).toContain("isn't a project"); + + const write = await app.request("/api/layout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ lens: "resources", deltas: { "aws_vpc.main": { dx: 10, dy: 10 } } }), + }); + expect(write.status).toBe(403); + expect(((await write.json()) as { code: string }).code).toBe("read-only"); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 8aa09d7..55e1f57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ * behold leans on chant's MCP for the underlying graph/lifecycle data (see README). */ import { resolve, dirname, join, relative, sep } from "node:path"; -import { realpathSync, existsSync, cpSync } from "node:fs"; +import { realpathSync, existsSync, cpSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { startServer, beholdVersion } from "./server.ts"; @@ -13,6 +13,7 @@ import { runExport } from "./export.ts"; import { diagnose, formatReport } from "./doctor.ts"; import { isAutoSyncMode, type AutoSyncMode } from "./autosync.ts"; import { detectProjectShape } from "./project.ts"; +import { readCarveReport } from "./carve-lens.ts"; const USAGE = `behold — a live control plane on chant (read-only core) @@ -22,6 +23,16 @@ Usage: behold preview [project-dir] [--port ] [--emulator] behold export [project-dir] [--out ] [--env ] [--name ] [--emulator] behold serve [--port ] [--env ] [--poll ] [--local] + behold carve [--port ] + + carve Render a chant Terraform peelability report — the JSON from + \`chant carve advise --from --json\` — as a graph. One card + per ranked resource, coloured by band: green = carve now, amber = + has boundary work, grey = leave in Terraform. Click a card for the + score arithmetic behind its rank. Read-only twice over: chant's + advisor emits nothing, and behold only draws what it says. + \`GET /api/carve\` serves the raw report to agents. behold parses no + Terraform and needs no Terraform tooling — the report is the contract. doctor Why won't this project serve well? A read-only diagnosis of everything behold needs — the project's kind, its own chant install @@ -60,7 +71,7 @@ Usage: overlay, and rollback act on it). Options: - --port Port (default 4600). preview/serve. + --port Port (default 4600). preview/serve/carve. --env Environment name — turns on the live drift overlay. export/serve. --poll Re-query live drift every and push updates (needs --env). @@ -133,6 +144,11 @@ export async function run(argv: string[]): Promise { return; } + if (cmd === "carve") { + await runCarve(rest); + return; + } + if (cmd !== "serve") { process.stderr.write(`behold: unknown command '${cmd}'\n\n${USAGE}`); process.exit(2); @@ -227,6 +243,56 @@ function warnIfNotChantProject(dir: string): void { ); } +/** `behold carve ` (#252, M1 of #230) — render a chant Terraform + * peelability report. + * + * A separate verb rather than a `--carve-report` flag on serve, because it + * shares none of serve's arguments: there is no project, no env, no poll and no + * emulator behind a static analysis of foreign Terraform. The only thing the + * two have in common is a port and the SPA, and both come free from + * `startServer`. + * + * The report is validated HERE as well as per-request in the server, so a typo'd + * path or a JSON file that isn't a carve report is refused in the terminal + * (exit 2) rather than becoming a server that only fails once you open it. + */ +async function runCarve(rest: string[]): Promise { + let port = 4600; + let fileArg: string | undefined; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "--port") port = Number(rest[++i]); + else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE); + else if (!a.startsWith("-")) fileArg = a; + else { + process.stderr.write(`behold carve: unexpected argument '${a}'\n`); + process.exit(2); + } + } + if (!Number.isFinite(port)) { + process.stderr.write("behold carve: --port must be a number\n"); + process.exit(2); + } + if (!fileArg) { + process.stderr.write( + "behold carve: missing \n" + + " Generate one: chant carve advise --from --report report.json\n", + ); + process.exit(2); + } + const reportPath = resolve(fileArg); + const parsed = readCarveReport(reportPath, (p) => readFileSync(p, "utf8")); + if (!parsed.ok) { + process.stderr.write(`behold carve: ${parsed.refusal.error}\n ${parsed.refusal.remedy}\n`); + process.exit(2); + } + process.stdout.write( + `behold carve — ${parsed.report.resources.length} resource(s)/module(s) ranked` + + `${parsed.report.from ? ` from ${parsed.report.from}` : ""}\n`, + ); + await startServer({ projectDir: dirname(reportPath), carveReport: reportPath, port }); +} + /** `behold doctor` (#236) — the read-only first-touch diagnosis. Composes the * probes the server already uses (src/doctor.ts) into one console report; * starts nothing, writes nothing. Exit 1 (via `process.exitCode`, so stdout diff --git a/src/render.test.ts b/src/render.test.ts index 41285b2..b77f4d5 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderGraph } from "./render.ts"; +import { renderGraph, renderBanded } from "./render.ts"; import type { GraphIR } from "@intentius/chant"; // M4: renderGraph gained an explicit `boxes: "byStack"` opt-in for the @@ -152,3 +152,62 @@ describe("renderGraph — lexicon-native icons (#227)", () => { expect(svg).not.toMatch(/\s(?:inkscape|sodipodi|xlink):/); }); }); + +// #252: the carve lens paints a RANKING, not a topology, so it gets its own +// layout. These pin the reason renderBanded exists (dagre lays an edgeless +// graph out along a single row) and the invariants the carve view depends on. +const bandedIr: GraphIR = { + nodes: Array.from({ length: 30 }, (_, i) => ({ + id: `aws_s3_bucket.b${i}`, + kind: "aws_s3_bucket", + lexicon: "terraform", + attrs: { _status: i < 20 ? "good" : "neutral", score: 100 - i, carve: i < 20 ? "carve now" : "leave in Terraform" }, + })), + edges: [], + groups: { + byStack: { + "carve now": Array.from({ length: 20 }, (_, i) => `aws_s3_bucket.b${i}`), + "leave in Terraform": Array.from({ length: 10 }, (_, i) => `aws_s3_bucket.b${i + 20}`), + }, + }, +}; + +describe("renderBanded — the banded ranking layout (#252)", () => { + const viewBox = (svg: string) => (svg.match(/viewBox="0 0 (\d+) (\d+)"/) ?? []).slice(1).map(Number); + + it("grid-wraps instead of stringing an edgeless graph out along one row", () => { + const [wide] = viewBox(renderGraph(bandedIr, { boxes: "byStack" }).svg); + const [w, h] = viewBox(renderBanded(bandedIr).svg); + // dagre puts all 30 cards in rank 0 — one very long row. + expect(wide).toBeGreaterThan(6000); + expect(w).toBeLessThan(2000); + // Near a screen's shape, not a strip: no worse than 3:1 either way. + expect(w / h).toBeLessThan(3); + expect(h / w).toBeLessThan(3); + }); + + it("titles a panel per band and tints it with the status its members agree on", () => { + const svg = renderBanded(bandedIr).svg; + expect(svg).toContain(BOX_MARKER); + expect(svg).toContain("carve now"); + expect(svg).toContain("leave in Terraform"); + expect(svg).toContain("goodStroke"); // the carve-now panel's own border + }); + + it("draws every node, including one no band claims", () => { + const orphaned: GraphIR = { + ...bandedIr, + nodes: [...bandedIr.nodes, { id: "aws_vpc.stray", kind: "aws_vpc", lexicon: "terraform", attrs: {} }], + }; + const svg = renderBanded(orphaned).svg; + expect(svg).toContain('data-node-id="aws_vpc.stray"'); + expect(svg).toContain("unbanded"); + for (const n of bandedIr.nodes) expect(svg).toContain(`data-node-id="${n.id}"`); + }); + + it("survives an empty graph without a degenerate canvas", () => { + const [w, h] = viewBox(renderBanded({ nodes: [], edges: [], groups: {} }).svg); + expect(w).toBeGreaterThan(0); + expect(h).toBeGreaterThan(0); + }); +}); diff --git a/src/render.ts b/src/render.ts index 786ab7f..aa570fc 100644 --- a/src/render.ts +++ b/src/render.ts @@ -6,10 +6,11 @@ * src/overlay.ts. behold owns the live data + the server + (later) the * temporal/action layers around this. */ -import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack } from "@intentius/pinhole"; -import type { GraphIR, IRGroups } from "@intentius/chant"; +import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack, type GroupBox, type Status } from "@intentius/pinhole"; +import type { GraphIR, IRGroups, Layout } from "@intentius/chant"; import type { ByContainer } from "./logical.ts"; import { k8sIconFor, helmIconFor } from "./icon-packs.ts"; +import { carveCardFields } from "./carve-lens.ts"; // Lexicon-native icons (#227), step 2 of 2. pinhole resolves a node's glyph // through a chain — per-node override → lexicon pack → keyword heuristic → @@ -27,6 +28,16 @@ import { k8sIconFor, helmIconFor } from "./icon-packs.ts"; registerPack({ lexicon: "k8s", iconFor: k8sIconFor }); registerPack({ lexicon: "helm", iconFor: helmIconFor }); +// The carve lens (#252) registers a pack for its `terraform` lexicon to pin the +// two fields a peelability card shows — the score and the verdict. Without it +// pinhole's default template picks the first two short scalar attrs in +// ALPHABETICAL order, which on a carve node is "band" then "dynamic": true, and +// the number the whole report exists to communicate never reaches the card. No +// `iconFor` opinion: the keyword heuristic already resolves aws_s3_bucket, +// aws_vpc, aws_subnet and aws_lambda_function to sensible glyphs, and guessing +// per Terraform type here would be a worse picture than the one it produces. +registerPack({ lexicon: "terraform", iconFor: () => undefined, fields: carveCardFields }); + export interface RenderResult { svg: string; } @@ -111,6 +122,94 @@ export function renderGraph(ir: GraphIR, opts: { theme?: string; boxes?: "byStac return { svg }; } +/** + * Paint a BANDED graph — a ranking, not a topology — as stacked, titled panels + * with their members in a grid. The carve lens (#252) is the case: a peelability + * report is a ranked list in three bands, and (until chant#1636 publishes the + * boundary edge lists) it arrives with no edges at all. + * + * It needs its own layout because dagre has nothing to work with here. Every + * node is its own connected component, so they all land in rank 0 and stretch + * out along a single row: the eight-resource sample estate laid out 2433px wide + * and 316 tall, and a 150-resource estate 40680 x 316 — a picture no viewport + * can show. Grid-wrapping inside each band keeps the whole thing near a screen's + * aspect at any size, and stacking the bands top-to-bottom makes the graph read + * in the same order the report's own console output does. + * + * Groups come from `ir.groups.byStack` in insertion order, so the caller decides + * the band order. Each panel is tinted with its members' shared `attrs._status` + * when they agree, which is how the band colour reaches the panel border and its + * title as well as the cards. Any node no group claims lands in a trailing + * panel rather than vanishing. + */ +export function renderBanded(ir: GraphIR, opts: { theme?: string } = {}): RenderResult { + const bands = (ir.groups.byStack ?? {}) as Record; + const size = footprints(ir); + const dims = (id: string) => size.get(id) ?? { w: NODE_W, h: NODE_H }; + + // A uniform cell keeps the grid a grid (cards centre in their cell), and a + // uniform panel width stacks the bands as an aligned column. + 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)))); + const contentW = cols * cellW + (cols - 1) * GAP; + const panelW = contentW + PAD * 2; + + // Nodes no band claims still get drawn — a renderer that silently drops a node + // is worse than one that shows an unlabelled panel. + const claimed = new Set(Object.values(bands).flat()); + const orphans = ids.filter((id) => !claimed.has(id)); + const panels: Array<[string, string[]]> = [ + ...Object.entries(bands).map(([title, members]) => [title, members.filter((id) => size.has(id))] as [string, string[]]), + ...(orphans.length ? ([["unbanded", orphans]] as Array<[string, string[]]>) : []), + ].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; + for (const [title, members] of panels) { + const rows = Math.ceil(members.length / cols); + const panelH = TITLE + PAD + rows * cellH + (rows - 1) * GAP + PAD; + members.forEach((id, i) => { + placed.push({ + id, + x: PAD + (i % cols) * (cellW + GAP) + cellW / 2, + y: top + TITLE + PAD + Math.floor(i / cols) * (cellH + GAP) + cellH / 2, + }); + }); + // Tint only when the band speaks with one voice — a mixed panel gets the + // plain border rather than one member's colour standing for all of them. + const statuses = new Set(members.map((id) => statusOf.get(id))); + const status = statuses.size === 1 ? [...statuses][0] : undefined; + 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); + + const layout: Layout = { + width: panelW, + height, + nodes: 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 }; +} + /** Re-place a laid-out graph's nodes on concentric rings by dagre rank (the * layout's discrete Y levels), so a wide horizontal DAG becomes a compact radial * one. Each ring's radius grows enough to seat its nodes without crowding diff --git a/src/server.ts b/src/server.ts index 2f484d8..074ba6b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,7 +55,8 @@ import { addCompositeDepsCounted } from "./composite-deps.ts"; import { notesFor, tierMismatchNote, namespaceMismatchNote, type Zoom } from "./zoom-notes.ts"; import { resourcesByComponent, nonResourceEntities } from "./resources.ts"; import { summarizePlan } from "./reconcile.ts"; -import { renderGraph, renderArchitecture } from "./render.ts"; +import { renderGraph, renderArchitecture, renderBanded } from "./render.ts"; +import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts"; import { discoverEstateOps } from "./ops.ts"; import { LIVE_IMPORT_LEXICONS } from "./adopt.ts"; import { detectProject, loadBeholdConfig } from "./project.ts"; @@ -115,6 +116,12 @@ export interface ServerOptions { * strip to Docker+Floci, and tells the SPA to hide those controls. Local * deploy (apply/reset/bring-up/approve) and all reads stay on. */ previewMode?: boolean; + /** Carve mode (#252, M1 of #230): the path of a `chant carve advise --json` + * peelability report to render instead of a chant project. Mutually exclusive + * with everything project-shaped — the report IS the estate here, so no chant + * is shelled, no source is watched and no live state exists. See + * `carveRoutes` below. */ + carveReport?: string; /** #228: may this server write the hand-layout sidecar (`.behold/layout.json` * in the served project)? Default true for a live `serve`. `runExport` sets * it false: a static capture reads the sidecar (to bake it into the snapshot @@ -206,7 +213,7 @@ async function knownComponents(projectDir: string, opts: GraphOptions): Promise< * classified from chant's own stderr), plus "tier" — a non-default tier that * needed parameters this host doesn't have, generalized below from what used * to be a one-off `tierErrorNote`/`tierNote` bolted onto a plain error. */ -export type RouteErrorCode = ChantFailure["code"] | "tier" | "no-project"; +export type RouteErrorCode = ChantFailure["code"] | "tier" | "no-project" | "carve-report"; /** A read route's structured, typed error body (#72): a machine `code`, a * human `error` message, and a suggested `remedy` — what web/app.js's @@ -361,6 +368,86 @@ async function captureFrame( } } +/** + * Carve mode's routes (#252, M1 of #230) — behold pointed at a `chant carve + * advise --json` peelability report instead of at a chant project. + * + * The report is the whole estate: there is no source to watch, no environment + * to observe, no chant to shell. These are registered FIRST, so Hono's + * in-order matching gives them `/api/graph` and `/api/project` ahead of the + * project-shaped handlers below — which is the entire trick that lets the + * existing SPA render a carve graph with (almost) no client change: it asks + * `/api/graph` for `{ir, svg, meta}` and gets exactly that, bands riding + * `attrs._status` on the drift palette it already paints everywhere. + * + * The file is re-read per request rather than parsed once at boot, so a + * regenerated report shows up on reload — and so a report that goes bad + * answers with #193's structured `{error, code, remedy}` (422) instead of a + * blank graph. `behold carve` also validates once up front, so a bad path is + * refused in the terminal before a server ever starts. + */ +function carveRoutes(app: Hono, reportPath: string): void { + const load = () => readCarveReport(reportPath, (p) => readFileSync(p, "utf8")); + + // The raw report, for agents (#230's M4 workflow reads this to confirm a + // band before running `carve emit`). Verbatim — behold adds nothing to it. + app.get("/api/carve", (c) => { + const parsed = load(); + return parsed.ok ? c.json(parsed.report) : c.json(parsed.refusal, 422); + }); + + app.get("/api/graph", (c) => { + const parsed = load(); + if (!parsed.ok) return c.json(parsed.refusal, 422); + const ir = 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); + return c.json({ + ir, + svg, + meta: { + projectDir: reportPath, + env: null, + tier: null, + target: null, + carve: true, + note: carveNote(parsed.report, ir), + }, + }); + }); + + app.get("/api/project", (c) => { + const parsed = load(); + return c.json({ + projectDir: reportPath, + recents: [], + // No envs, tiers, stacks or targets: a peelability report is a static + // analysis of foreign Terraform, so every picker that would imply a live + // axis stays empty and the SPA renders none of them. + environments: [], + lexicons: ["terraform"], + currentEnv: null, + targets: [], + carve: parsed.ok + ? { + report: reportPath, + from: parsed.report.from ?? null, + count: parsed.report.count ?? parsed.report.resources.length, + bands: parsed.report.bands ?? {}, + } + : { report: reportPath }, + }); + }); + + // A static report has no substrates to probe and no git history that means + // anything (the file may sit anywhere). Answer empty instead of running a + // Docker probe and a `git log` for a picture that cannot use either. + app.get("/api/substrates", (c) => c.json({ substrates: [] })); + app.get("/api/history", (c) => c.json({ commits: [] })); +} + export function createApp( cfg: ServerOptions, broadcaster: Broadcaster = new Broadcaster(), @@ -373,6 +460,10 @@ export function createApp( ): Hono { const app = new Hono(); + // Carve mode (#252) claims /api/graph, /api/project and friends before the + // project-shaped handlers are registered — see carveRoutes. + if (cfg.carveReport) carveRoutes(app, cfg.carveReport); + // behold's own project-root config (#70) — `.behold.json`'s `tiers` block, // if any (src/project.ts `loadBeholdConfig`). Read once at app creation // (like `cfg.projectDir` itself): unlike `chant.config.ts`'s environments/ @@ -708,6 +799,11 @@ export function createApp( // remote authority over the browser you're looking at. const layoutWriteBlock = (): string | null => { if (cfg.previewMode) return "the layout sidecar is read-only in preview mode"; + // #252: carve mode's `projectDir` is wherever the report file happens to + // sit — a Downloads folder, someone's Terraform repo. There is no project + // there to keep a hand layout in, and dropping `.behold/` into a stranger's + // directory is not a thing to do quietly. + if (cfg.carveReport) return "a carve report isn't a project — there's nowhere to keep a hand layout"; if (cfg.layoutWrites === false) return "a static export captures a snapshot — it doesn't write to the project"; return unwritableReason(cfg.projectDir); }; @@ -809,6 +905,9 @@ export function createApp( { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" }, { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" }, { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} — params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" }, + ...(cfg.carveReport + ? [{ method: "GET", path: "/api/carve", desc: "carve mode: the raw `chant carve advise --json` peelability report this server is rendering" }] + : []), { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= — same shape/params as /api/graph, plus runtime=1" }, { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens= → {lens, deltas, writable}; no lens → every lens" }, { method: "POST", path: "/api/layout", desc: "store one lens's deltas: JSON body {lens, deltas: {: {dx,dy,dw,dh}}} (the only file behold writes in your project)" }, @@ -1791,9 +1890,13 @@ export async function startServer(cfg: ServerOptions): Promise { // Watch the served project's source (the dev loop) and, with an env + --poll, // poll live drift (#4) — the latter also drives auto-sync. `let` (#195): a // project switch re-aims the watcher and stops the poll. - let stopWatch = watchSource(cfg.projectDir, onEstateChange); + // Carve mode (#252) has no chant project behind it: nothing to watch, nothing + // to poll, and no baseline frame to capture (`captureFrame` shells `chant + // graph`, which would fail once a second on a directory that isn't a project). + const carve = !!cfg.carveReport; + let stopWatch = carve ? () => {} : watchSource(cfg.projectDir, onEstateChange); let stopPoll = - cfg.env && cfg.pollSecs + !carve && cfg.env && cfg.pollSecs ? startDriftPoll({ intervalMs: cfg.pollSecs * 1000, query: () => graphIr(cfg.projectDir, { live: true, overlay: true, env: cfg.env }), @@ -1813,7 +1916,7 @@ export async function startServer(cfg: ServerOptions): Promise { process.stdout.write(` switched → ${dir}\n`); void capture(); // baseline keyframe for the new project }; - void capture(); // baseline keyframe at startup + if (!carve) void capture(); // baseline keyframe at startup // Clean shutdown on both Ctrl-C (SIGINT) and `kill` (SIGTERM) — otherwise a // `kill`ed instance leaves its emulator container running, which the next launch // silently reuses (a stale-state trap). Guard against double-fire. @@ -1834,6 +1937,15 @@ export async function startServer(cfg: ServerOptions): Promise { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); const server = serve({ fetch: app.fetch, port: cfg.port }, (info) => { + if (carve) { + process.stdout.write( + `behold → http://localhost:${info.port}\n` + + ` carve report: ${cfg.carveReport}\n` + + ` green = carve now, amber = boundary work, grey = leave in Terraform.\n` + + ` Read-only advisory: behold emits nothing and touches no Terraform. Ctrl-C to stop.\n`, + ); + return; + } const poll = cfg.env && cfg.pollSecs ? `, polling drift every ${cfg.pollSecs}s` : ""; const auto = autoSync !== "off" ? ` auto-sync: ${autoSync}` : ""; const localTag = diff --git a/web/app.js b/web/app.js index e5bd517..fec13fc 100644 --- a/web/app.js +++ b/web/app.js @@ -2274,6 +2274,10 @@ const PRECONDITION_TITLE = { // #193: behold was pointed at a directory that isn't a chant project at all // — the first screen must say so, not draw a blank graph. "no-project": "This isn't a chant project", + // #252: carve mode is served a `chant carve advise --json` report, not a + // project — so a bad file is a bad REPORT, and the card must not blame chant + // for failing to evaluate a project that was never involved. + "carve-report": "This isn't a carve report", }; // A precondition failure — the lint gate, a not-installed/no-typegen project,