diff --git a/AGENTS.md b/AGENTS.md index 7a331e2..599ebe1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,32 @@ To move a resource: confirm its band on `/api/carve`, then run chant's own applying the generated survivor rewrites stay a human gate — behold has no endpoint that writes Terraform, and adding one would break the invariant below. +### Carve state, from chant's manifests (#230 M3) + +chant ≥ 0.52.2 writes `
.carve.json` into `carve emit --output`; `carve +bridge` and `carve apply` add their own records to the same file. behold reads +those and never writes one, so the progression is on disk rather than in a +session. + +`GET /api/project`'s `carve.state` publishes it: `{manifests, progress: +{applied, bridged, emitted, inFlight, total, label, detail}, states[], apply: +{human, note}}`. Each entry carries `target`, `stage` +(`emitted`/`bridged`/`applied`), `graduated`, `note` and a retypeable +`applyCommand`. The same field appears on an ordinary `behold serve` whose +project directory carries a carveout — no report and no demo needed — and is +absent entirely when nothing has been carved. + +In the graph, an `applied` address draws inside the chant member box (keeping +its Terraform address as its node id) instead of in a band; `emitted` and +`bridged` stay banded with `attrs._status: "accent"` and the stage in +`attrs.carve`. Only `applied` means ownership moved. + +**There is no `/api/carve/apply`.** `chant carve apply` graduates ownership; +behold renders what the manifest records and echoes the command. Do not add the +endpoint — src/carve-manifest.ts `APPLY_IS_HUMAN` is the statement of it, and +src/carve-actions.ts and src/server.ts both carry the refusal where the route +would go. + ### The walkthrough (`behold demo carve`, #254) `behold demo carve` copies a bundled half-migrated estate (a chant project diff --git a/docs/src/content/docs/using/carve.mdx b/docs/src/content/docs/using/carve.mdx index fb9cc16..7027ea5 100644 --- a/docs/src/content/docs/using/carve.mdx +++ b/docs/src/content/docs/using/carve.mdx @@ -45,6 +45,30 @@ 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. +## The strangler fig: what has actually graduated + +`chant carve emit` (chant ≥ 0.52.2) writes a state manifest beside the source it emits — `
.carve.json` in the `--output` directory. `carve bridge` adds its record to the same file, and `carve apply` adds the ownership marker. behold reads those manifests and never writes one, which is what makes the picture survive a restart: the progression lives on disk, in chant's file, not in a behold session. + +Three stages, and only the last of them is a graduation: + +- **emitted** — typed chant source exists; the surviving Terraform still reads the resource directly. The card stays in its band, repainted in the in-flight blue with the words `emitted — not bridged`. +- **bridged** — the survivor patch is proposed. Still banded, still Terraform's: `bridged — apply is yours`. +- **applied** — `carve apply` recorded the ownership marker. The card leaves the Terraform bands and draws inside the chant project box, keeping its Terraform address, with `carved → chant` on it. + +The chant box's panel carries the count — `carved so far — 1 of 12 carved` — and the panel's **project** section lists each carved address with its stage. A resource that graduated in a previous session opens in the chant box; a partly-carved one opens back in its band, repainted, saying exactly how far it got. Nothing is inferred from what you did in this tab. + +Point `behold carve` at a report in a directory that also holds manifests and you get the same reading with no demo at all — the graduated pile becomes its own band above the ranking, since there is no chant member box to move a card into. An ordinary `behold serve ` whose directory carries a carveout surfaces the state in the same panel section, off `/api/project`. + +### `carve apply` is not a button + +`carve apply` graduates ownership. behold renders what the manifest records about it and echoes the command — no `--select`, because the manifest in the output directory already names the target: + +```sh +chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source +``` + +There is no `/api/carve/apply` endpoint and no apply button anywhere in the UI. That is the same line the Handoff step draws around `terraform state rm`: behold triggers delegated work, it does not decide when an estate changes hands. + ### The `--live` tier `npx behold demo carve --live` (docker + terraform on PATH) boots a scratch Floci — its own container name and port, refused if taken, deleted on exit — arms the estate's provider override into the demo copy, and really applies the starred resources into it. The advisor then reads a tfstate terraform wrote, and the Handoff step gains a read-only **terraform plan** button: run it after pasting `terraform state rm` and the carved resource is simply absent from the plan — `0 to destroy`, nothing blinked. The Emit step gains the observe beat: **watch chant read it live** runs `chant lifecycle diff --live` in the carveout against the scratch Floci, and the carved resource reads observed — `EXTERNAL`, ownership `foreign` — while Terraform still owns it (chant#1647's identity read path, chant ≥ 0.44.12). diff --git a/example-carve/README.md b/example-carve/README.md index 9d15aaa..f4131dd 100644 --- a/example-carve/README.md +++ b/example-carve/README.md @@ -163,6 +163,26 @@ Then the box slides out of the Terraform half and into `app/`, beside the log group and the SSM parameter that made the same trip last month. Around two minutes end to end. +## What the copy remembers + +Every beat above writes to `app/carveout/aws_s3_bucket-assets.carve.json` — +chant's carve state manifest (chant ≥ 0.52.2). `carve emit` creates it, `carve +bridge` adds its record, `carve apply` adds the ownership marker, and behold +reads it back on every request without ever writing to it. + +That is why the walkthrough survives a reload. After Emit, the bucket's card +stays in its band but repaints in the in-flight blue and reads `emitted — not +bridged`; after Bridge, `bridged — apply is yours`. Restart the server and it +still says so. Run `chant carve apply` yourself in the copy and the card leaves +the Terraform bands for the `app — chant` box, keeping its Terraform address, +and the panel counts `carved so far — 1 of 12 carved`. + +`carve apply` is the one beat that has no button — not in the stepper, not on +the API. It resolves the marker that makes chant the owner of a live resource, +which is a decision that stays with the person at the terminal. The Handoff +step echoes the command beside the two `terraform` ones, for the same reason +and in the same shape. + ## Two tiers **Offline is the default.** The synthetic `terraform.tfstate` is committed, so diff --git a/smoke/stub.mjs b/smoke/stub.mjs index 0b0f47c..78c12ae 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -16,6 +16,9 @@ import { helmIconFor } from "../src/icon-packs.ts"; // #254: the carve lens itself, so carve mode's stub graph is the real // conversion of the real committed report — see the carve block below. import { carveReportToIr } from "../src/carve-lens.ts"; +// #230 M3: the same reader the server uses, so the stub's carve state is the +// real shape rather than a hand-cut echo of it. +import { carveStateOf, carveStatePayload } from "../src/carve-manifest.ts"; const WEB = join(dirname(fileURLToPath(import.meta.url)), "..", "web"); @@ -344,6 +347,14 @@ export function startStub(port, { carve = false } = {}) { * so the smoke asserts the wire contract (the EXISTING op-signal route), not * just that a button existed. */ const signalPosts = []; + // #230 M3: the carve state manifests, in memory instead of on disk — the + // stub records what a step WOULD have written the way chant's `carve emit` + // and `carve bridge` write their sections, and `/api/project` reads them back + // through the real `carveStatePayload`. So the smoke exercises the actual + // reader, and the panel updating mid-walkthrough is the same code path a demo + // copy takes. Nothing here fakes an `apply`: that stage only ever arrives + // from a person running the command. + const carveManifests = new Map(); const readBody = (req) => new Promise((r) => { let s = ""; @@ -367,6 +378,15 @@ export function startStub(port, { carve = false } = {}) { if (!CARVE_REPORT.resources.some((r) => r.address === body.select)) { return json({ error: `\`select\` must name a resource this report ranks — ${JSON.stringify(body.select)} isn't one.`, code: "carve-select", remedy: "Pick a card in the graph." }, 400); } + const held = carveManifests.get(body.select) ?? { + version: 1, + target: body.select, + from: CARVE_DEMO.from, + boundary: {}, + }; + if (path.endsWith("emit")) held.emit = { source: "tfstate", files: [`${CARVE_DEMO.out}/src/assets.ts`], at: new Date().toISOString() }; + else held.bridge = { written: [], appliedInPlace: false, excised: [body.select], at: new Date().toISOString() }; + carveManifests.set(body.select, held); return json(path.endsWith("emit") ? CARVE_EMIT : CARVE_BRIDGE); } if (path === "/api/project") { @@ -384,6 +404,16 @@ export function startStub(port, { carve = false } = {}) { bands: CARVE_REPORT.bands, advisory: CARVE_REPORT.advisory, demo: CARVE_DEMO, + state: carveStatePayload( + new Map( + [...carveManifests].map(([target, m]) => [ + target, + carveStateOf(m, `${CARVE_DEMO.out}/${target.replace(/[^A-Za-z0-9_]+/g, "-")}.carve.json`), + ]), + ), + CARVE_REPORT.count, + () => ({ from: CARVE_DEMO.fromLabel, out: CARVE_DEMO.outLabel }), + ), }, }); } diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 948a2f0..b1a0a6f 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -832,6 +832,20 @@ try { check("the boundary report renders as a JSON tree, not a blob", (await carvePage.locator("#tab-carve .jsonv").count()) >= 1); check("a finished run does NOT skip past its own result", (await bodyStep()) === "emit"); check("bridge unblocks once emit has run", (await stepState("bridge")) !== "blocked"); + // #230 M3: the emit wrote a carve manifest, so the estate's own progress + // read moves — and it reads it back off /api/project rather than off what + // the POST happened to answer. + await step("advise").click(); + await carvePage.waitForTimeout(200); + const advised = await carveText(); + // `#panel h3` is uppercased in CSS, and innerText renders it — match the + // heading case-insensitively rather than against the styling. + check("Advise now shows the manifest-backed progress", /carved so far/i.test(advised) && advised.includes("0 of 12 carved")); + check("…naming the carve and its stage", advised.includes("aws_s3_bucket.assets") && advised.includes("emitted")); + check("…and does NOT call an emit a graduation", !advised.includes("1 of 12 carved")); + check("the scope panel carries the same read", /carved out of terraform/i.test(await carvePage.locator("#tab-scope").innerText())); + await step("emit").click(); + await carvePage.waitForTimeout(150); await carvePage.screenshot({ path: join(SHOTS, "8-carve-emit.png"), fullPage: true }); // 4. Bridge — proposals only, never --apply-rewrites. @@ -849,9 +863,17 @@ try { const handoff = await carveText(); check("Handoff renders the runbook's commands", handoff.includes("terraform state rm aws_s3_bucket.assets")); check("…deduped (the runbook prints `terraform plan` twice)", (handoff.match(/terraform plan/g) || []).length === 1); - check("Handoff has a copy button per command", (await carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").count()) === 3); + check("Handoff has a copy button per command", (await carvePage.locator("#tab-carve .carve-cmd-row:not(.carve-apply-row) .carve-copy").count()) === 3); check("Handoff has NO run button — the destructive middle stays human", (await carvePage.locator("#tab-carve button.carve-run").count()) === 0); check("…and the UI says why", handoff.includes("not buttons, on purpose") && handoff.includes("terraform state rm")); + // #230 M3: `carve apply` is echoed on the same terms — a command with a + // copy button, and no control that runs it. The stage comes off the carve + // manifest the two steps wrote, not off this session's memory of them. + check("Handoff echoes the graduation command off the manifest", handoff.includes("chant carve apply --from legacy-tf --output app/carveout")); + check("…composed from the manifest, so no --select", !/carve apply[^\n]*--select/.test(handoff)); + check("…and it says the trigger is a person, not an endpoint", handoff.includes("no apply endpoint and no apply button")); + check("the manifest says bridged, and does not call that carved", handoff.includes("bridged") && !handoff.includes("chant owns it")); + check("still no run button anywhere on the step", (await carvePage.locator("#tab-carve button.carve-run").count()) === 0); const copyCtl = carvePage.locator("#tab-carve .carve-cmd-row .carve-copy").first(); await copyCtl.click(); await carvePage.waitForTimeout(120); diff --git a/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json b/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json new file mode 100644 index 0000000..db89870 --- /dev/null +++ b/src/__fixtures__/carve-manifests/aws_cloudwatch_log_group-worker.carve.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "target": "aws_cloudwatch_log_group.worker", + "tfType": "aws_cloudwatch_log_group", + "from": "/tmp/carve-fixture/legacy-tf", + "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate", + "boundary": { + "target": "aws_cloudwatch_log_group.worker", + "carveSet": [ + { + "address": "aws_cloudwatch_log_group.worker", + "type": "aws_cloudwatch_log_group" + } + ], + "peelability": 100, + "inbound": [], + "outbound": [], + "reversible": true, + "diagnostics": [] + }, + "emit": { + "source": "tfstate", + "files": [ + "/tmp/carve-fixture/app/carveout/src/worker.ts" + ], + "at": "2026-08-30T04:13:19.378Z" + }, + "bridge": { + "written": [ + "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-runbook.md", + "/tmp/carve-fixture/app/carveout/observability.tf", + "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-bridge.patch" + ], + "appliedInPlace": false, + "patch": "/tmp/carve-fixture/app/carveout/aws_cloudwatch_log_group-worker-bridge.patch", + "excised": [ + "aws_cloudwatch_log_group.worker" + ], + "at": "2026-08-30T04:13:36.376Z" + } +} diff --git a/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json b/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json new file mode 100644 index 0000000..7be1242 --- /dev/null +++ b/src/__fixtures__/carve-manifests/aws_iam_role-api.carve.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "target": "aws_iam_role.api", + "tfType": "aws_iam_role", + "from": "/tmp/carve-fixture/legacy-tf", + "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate", + "boundary": { + "target": "aws_iam_role.api", + "carveSet": [ + { + "address": "aws_iam_role.api", + "type": "aws_iam_role" + } + ], + "peelability": 69, + "inbound": [ + { + "direction": "inbound", + "survivor": "aws_lambda_function.api", + "carved": "aws_iam_role.api", + "attrs": [ + "arn" + ], + "via": [ + "role" + ], + "bridge": "tf-data-source", + "required": "immediately" + } + ], + "outbound": [ + { + "direction": "outbound", + "survivor": "random_pet.suffix", + "carved": "aws_iam_role.api", + "attrs": [ + "id" + ], + "via": [ + "name" + ], + "bridge": "deferred-input", + "required": "at-apply" + } + ], + "reversible": true, + "diagnostics": [] + }, + "emit": { + "source": "tfstate", + "files": [ + "/tmp/carve-fixture/app/carveout/src/api.ts" + ], + "params": { + "name": { + "tfAttr": "name", + "survivor": "random_pet.suffix", + "attrs": [ + "id" + ], + "default": "acme-platform-api-gently-mutt" + } + }, + "at": "2026-08-30T04:13:37.282Z" + } +} diff --git a/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json b/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json new file mode 100644 index 0000000..7a50c24 --- /dev/null +++ b/src/__fixtures__/carve-manifests/aws_s3_bucket-assets.carve.json @@ -0,0 +1,98 @@ +{ + "version": 1, + "target": "aws_s3_bucket.assets", + "tfType": "aws_s3_bucket", + "from": "/tmp/carve-fixture/legacy-tf", + "statePath": "/tmp/carve-fixture/legacy-tf/terraform.tfstate", + "boundary": { + "target": "aws_s3_bucket.assets", + "carveSet": [ + { + "address": "aws_s3_bucket.assets", + "type": "aws_s3_bucket" + }, + { + "address": "aws_s3_bucket_public_access_block.assets", + "type": "aws_s3_bucket_public_access_block", + "foldedInto": "aws_s3_bucket.assets" + }, + { + "address": "aws_s3_bucket_versioning.assets", + "type": "aws_s3_bucket_versioning", + "foldedInto": "aws_s3_bucket.assets" + } + ], + "peelability": 84, + "inbound": [ + { + "direction": "inbound", + "survivor": "aws_lambda_function.api", + "carved": "aws_s3_bucket.assets", + "attrs": [ + "bucket" + ], + "via": [ + "environment" + ], + "bridge": "tf-data-source", + "required": "immediately" + }, + { + "direction": "inbound", + "survivor": "output.assets_bucket", + "carved": "aws_s3_bucket.assets", + "attrs": [ + "bucket" + ], + "via": [ + "value" + ], + "bridge": "tf-output-rewrite", + "required": "immediately" + } + ], + "outbound": [], + "reversible": true, + "diagnostics": [] + }, + "emit": { + "source": "tfstate", + "files": [ + "/tmp/carve-fixture/app/carveout/src/assets.ts" + ], + "at": "2026-08-30T04:12:44.246Z" + }, + "bridge": { + "written": [ + "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-runbook.md", + "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-datasources.tf", + "/tmp/carve-fixture/app/carveout/compute.tf", + "/tmp/carve-fixture/app/carveout/outputs.tf", + "/tmp/carve-fixture/app/carveout/storage.tf", + "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-bridge.patch" + ], + "appliedInPlace": false, + "patch": "/tmp/carve-fixture/app/carveout/aws_s3_bucket-assets-bridge.patch", + "excised": [ + "aws_s3_bucket.assets", + "aws_s3_bucket_versioning.assets", + "aws_s3_bucket_public_access_block.assets" + ], + "at": "2026-08-30T04:13:05.804Z" + }, + "apply": { + "marker": { + "stack": "assets", + "env": "prod" + }, + "ownershipTags": { + "chant:managed-by": "chant", + "chant:stack": "assets", + "chant:env": "prod" + }, + "stampedFiles": [ + "/tmp/carve-fixture/app/carveout/src/assets.ts" + ], + "at": "2026-08-30T04:13:10.754Z" + } +} diff --git a/src/carve-actions.ts b/src/carve-actions.ts index 763b1f8..7e6a685 100644 --- a/src/carve-actions.ts +++ b/src/carve-actions.ts @@ -24,6 +24,17 @@ * anyone's chant source. The demo copy is the user's own scratch directory, and * a carve emits into it exactly the way the CLI would. * + * NO `runCarveApply` LIVES HERE, and one should not be added (#230 M3). Every + * guard above would hold for it — a demo copy, a closed-set `--select`, one + * output directory — which is exactly why the refusal has to be written down + * next to the functions it would sit beside rather than left to be inferred + * from its absence. `chant carve apply` resolves the ownership marker that + * makes chant the owner of a live resource; the boundary is not that behold + * can't run it, it is that behold won't decide when an estate changes hands. + * behold renders what the carve manifest RECORDS about an apply + * (src/carve-manifest.ts) and echoes the command for a person to retype, the + * same posture the Handoff step already takes with `terraform state rm`. + * * Emit shows `chant lint`, never `chant build` — chant#1637's fold is applied * now (chant 0.44.7, chant PR #1640): the emitted bucket carries its folded * versioning/public-access-block sub-resources as native props, so `build` diff --git a/src/carve-manifest.test.ts b/src/carve-manifest.test.ts new file mode 100644 index 0000000..f0e8e75 --- /dev/null +++ b/src/carve-manifest.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + APPLY_IS_HUMAN, + applyCommandFor, + carveProgress, + carveStageNote, + carveStageOf, + carveStateNote, + carveStateOf, + carveStatePayload, + carveToneFor, + carveWordFor, + bandGraduated, + listCarveManifests, + parseCarveManifest, + readCarveManifest, + readCarveStates, + splitCarveState, + type CarveManifest, + type CarveManifestIo, + type CarveState, +} from "./carve-manifest.ts"; +import { carveReportToIr, type CarveReport } from "./carve-lens.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, "__fixtures__", "carve-manifests"); + +/** + * The fixtures are REAL manifests. This repo's own `example-carve` was copied + * to a scratch dir (so nothing in the checkout was touched), `npm install` in + * its `app/` pinned to `@intentius/chant@0.52.2` + + * `@intentius/chant-lexicon-aws@0.52.2` (chant#998's manifest, chant PR #1575, + * ships at 0.52.2), `npm install --no-save @cdktf/hcl2json` beside it for the + * HCL parse, then the three carve verbs run against `legacy-tf/` with + * `--output app/carveout`: + * + * aws_iam_role.api `carve emit` -> emitted + * aws_cloudwatch_log_group.worker `carve emit` + `carve bridge` -> bridged + * aws_s3_bucket.assets emit + bridge + `carve apply --env prod + * --stack assets --write --write-source` -> applied + * + * Committed as chant wrote them, with ONE substitution: the scratch directory's + * absolute prefix was rewritten to `/tmp/carve-fixture`, because the real one + * carried a session id and the operator's home path and neither is part of the + * shape under test. Nothing else is edited — the boundary reports, the emit + * params, the ownership tags and the timestamps are chant's own. + * + * `carve apply` was run BY HAND here, in a terminal, which is the only way it + * is ever run: behold has no apply endpoint and no apply button. Generating + * this fixture is the one place the applied stage exists in this repo at all. + */ +const manifestPath = (name: string) => join(FIXTURES, name); +const read = (name: string) => JSON.parse(readFileSync(manifestPath(name), "utf8")) as CarveManifest; + +const EMITTED = "aws_iam_role-api.carve.json"; +const BRIDGED = "aws_cloudwatch_log_group-worker.carve.json"; +const APPLIED = "aws_s3_bucket-assets.carve.json"; + +describe("the real chant carve manifests", () => { + it("are all v1, each naming one Terraform address", () => { + const files = readdirSync(FIXTURES).filter((f) => f.endsWith(".carve.json")); + expect(files.sort()).toEqual([BRIDGED, EMITTED, APPLIED].sort()); + for (const f of files) { + const m = parseCarveManifest(read(f)); + expect(m, f).not.toBeNull(); + expect(m!.version).toBe(1); + expect(typeof m!.target).toBe("string"); + expect(m!.boundary, f).toBeTruthy(); + } + }); + + it("stage off the sections chant recorded, cumulatively", () => { + expect(carveStageOf(read(EMITTED))).toBe("emitted"); + expect(carveStageOf(read(BRIDGED))).toBe("bridged"); + expect(carveStageOf(read(APPLIED))).toBe("applied"); + }); + + it("only the applied one counts as graduated", () => { + const state = (f: string) => carveStateOf(read(f), manifestPath(f)); + expect(state(EMITTED).graduated).toBe(false); + expect(state(BRIDGED).graduated).toBe(false); + expect(state(APPLIED).graduated).toBe(true); + // Ownership is chant's, recorded by the apply — behold reports it, never mints it. + expect(state(APPLIED).ownership).toEqual({ + stack: "assets", + env: "prod", + tags: { "chant:managed-by": "chant", "chant:stack": "assets", "chant:env": "prod" }, + }); + expect(state(EMITTED).ownership).toBeUndefined(); + }); + + it("flattens the emit params chant recorded as deferred inputs", () => { + // aws_iam_role.api reads random_pet.suffix.id for its name; chant's emit + // turned that outbound edge into a build parameter with a state-resolved + // default. + expect(carveStateOf(read(EMITTED), manifestPath(EMITTED)).deferredInputs).toEqual(["name ← random_pet.suffix"]); + }); + + it("carries the bridge's excision list on the bridged carve", () => { + const state = carveStateOf(read(BRIDGED), manifestPath(BRIDGED)); + expect(state.excised).toEqual(["aws_cloudwatch_log_group.worker"]); + // The bridge wrote proposals, not edits — the manifest says so, and behold + // repeats it rather than reading the .tf itself. + expect(read(BRIDGED).bridge?.appliedInPlace).toBe(false); + }); + + it("takes the age of the state from the last step chant stamped", () => { + const applied = read(APPLIED); + expect(carveStateOf(applied, manifestPath(APPLIED)).at).toBe(applied.apply!.at); + const emitted = read(EMITTED); + expect(carveStateOf(emitted, manifestPath(EMITTED)).at).toBe(emitted.emit!.at); + }); +}); + +describe("parseCarveManifest", () => { + it("refuses anything that isn't a v1 manifest", () => { + expect(parseCarveManifest(null)).toBeNull(); + expect(parseCarveManifest([])).toBeNull(); + expect(parseCarveManifest({})).toBeNull(); + expect(parseCarveManifest({ version: 1 })).toBeNull(); + expect(parseCarveManifest({ version: 1, target: "" })).toBeNull(); + // A future major means something else by these sections; half-reading it + // would put a wrong stage on a card. + expect(parseCarveManifest({ version: 2, target: "aws_s3_bucket.assets" })).toBeNull(); + }); + + it("accepts a minimal one — the fields the renderer reads, not every field chant emits", () => { + const m = parseCarveManifest({ version: 1, target: "aws_s3_bucket.assets", somethingNew: true }); + expect(m?.target).toBe("aws_s3_bucket.assets"); + }); +}); + +describe("readCarveManifest", () => { + it("reads a real one off disk", () => { + expect(readCarveManifest(manifestPath(APPLIED))?.target).toBe("aws_s3_bucket.assets"); + }); + + it("is null, never a throw, on an unreadable or unparseable file", () => { + expect( + readCarveManifest("/nope.carve.json", () => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + expect(readCarveManifest("x", () => "{not json")).toBeNull(); + }); +}); + +/** An in-memory tree, so discovery is tested without a temp dir. */ +function fakeIo(files: Record): CarveManifestIo { + const dirs = new Set(); + for (const p of Object.keys(files)) { + for (let d = dirname(p); d && d !== "/" && d !== "."; d = dirname(d)) dirs.add(d); + } + const children = (dir: string): string[] => { + const out = new Set(); + for (const p of [...Object.keys(files), ...dirs]) { + if (dirname(p) === dir) out.add(p.slice(dir.length + 1)); + } + return [...out]; + }; + return { + readdir: (dir) => children(dir), + isDirectory: (p) => dirs.has(p), + readFile: (p) => { + if (!(p in files)) throw new Error(`ENOENT ${p}`); + return files[p]; + }, + }; +} + +const manifestJson = (target: string, extra: Record = {}) => + JSON.stringify({ version: 1, target, ...extra }); + +describe("listCarveManifests", () => { + it("finds manifests two levels down — the carveout inside a project", () => { + const io = fakeIo({ + "/e/app/carveout/aws_s3_bucket-assets.carve.json": manifestJson("aws_s3_bucket.assets"), + "/e/app/src/main.ts": "//", + }); + expect(listCarveManifests("/e", io)).toEqual(["/e/app/carveout/aws_s3_bucket-assets.carve.json"]); + }); + + it("never walks vendored or dot directories", () => { + const io = fakeIo({ + "/e/node_modules/pkg/x.carve.json": manifestJson("aws_s3_bucket.vendored"), + "/e/.terraform/y.carve.json": manifestJson("aws_s3_bucket.hidden"), + "/e/out/z.carve.json": manifestJson("aws_s3_bucket.real"), + }); + expect(listCarveManifests("/e", io)).toEqual(["/e/out/z.carve.json"]); + }); + + it("stops at the documented depth rather than walking someone's repo", () => { + const io = fakeIo({ "/e/a/b/c/deep.carve.json": manifestJson("aws_s3_bucket.deep") }); + expect(listCarveManifests("/e", io)).toEqual([]); + }); + + it("is empty, not a throw, on a directory that isn't there", () => { + expect(listCarveManifests("/gone", fakeIo({}))).toEqual([]); + }); +}); + +describe("readCarveStates", () => { + it("keys every discovered manifest by its Terraform address", () => { + const io = fakeIo({ + "/e/out/a.carve.json": manifestJson("aws_s3_bucket.assets", { emit: { files: [], at: "t" } }), + "/e/out/b.carve.json": manifestJson("aws_iam_role.api"), + }); + const states = readCarveStates(["/e"], io); + expect([...states.keys()].sort()).toEqual(["aws_iam_role.api", "aws_s3_bucket.assets"]); + }); + + it("resolves a duplicated target to the further-along stage", () => { + const io = fakeIo({ + "/e/stale/a.carve.json": manifestJson("aws_s3_bucket.assets"), + "/e/out/a.carve.json": manifestJson("aws_s3_bucket.assets", { bridge: { at: "t" }, apply: { at: "t" } }), + }); + expect(readCarveStates(["/e"], io).get("aws_s3_bucket.assets")?.stage).toBe("applied"); + }); + + it("skips a file that isn't a manifest without failing the read", () => { + const io = fakeIo({ + "/e/out/broken.carve.json": "{ not json", + "/e/out/good.carve.json": manifestJson("aws_s3_bucket.assets"), + }); + expect([...readCarveStates(["/e"], io).keys()]).toEqual(["aws_s3_bucket.assets"]); + }); + + it("reads the real fixture directory", () => { + const states = readCarveStates([FIXTURES]); + expect(states.size).toBe(3); + expect(states.get("aws_s3_bucket.assets")?.stage).toBe("applied"); + expect(states.get("aws_cloudwatch_log_group.worker")?.stage).toBe("bridged"); + expect(states.get("aws_iam_role.api")?.stage).toBe("emitted"); + }); +}); + +describe("how a stage reads", () => { + it("gives each stage its own word", () => { + expect(carveWordFor("emitted")).toBe("emitted — not bridged"); + expect(carveWordFor("bridged")).toBe("bridged — apply is yours"); + expect(carveWordFor("applied")).toBe("carved → chant"); + }); + + it("paints only the graduated stage green — the partials share the in-flight tone", () => { + expect(carveToneFor("applied")).toBe("good"); + expect(carveToneFor("bridged")).toBe("accent"); + expect(carveToneFor("emitted")).toBe("accent"); + }); + + it("says who owns it, in words, at each stage", () => { + const state = (f: string) => carveStateOf(read(f), manifestPath(f)); + expect(carveStageNote(state(APPLIED))).toContain("chant owns it"); + expect(carveStageNote(state(APPLIED))).toContain("stack assets"); + expect(carveStageNote(state(BRIDGED))).toContain("Terraform still owns the resource"); + expect(carveStageNote(state(EMITTED))).toContain("still reads the resource directly"); + }); +}); + +describe("carveProgress", () => { + const states = () => [...readCarveStates([FIXTURES]).values()]; + + it("counts the graduated against the ranking, and names the rest as in flight", () => { + const p = carveProgress(states(), 12); + expect(p).toMatchObject({ applied: 1, bridged: 1, emitted: 1, inFlight: 2, total: 12 }); + expect(p.label).toBe("1 of 12 carved"); + expect(p.detail).toBe("1 emitted, 1 bridged — apply is yours"); + }); + + it("has no denominator to invent when there is no report", () => { + expect(carveProgress(states()).label).toBe("1 carved"); + }); + + it("is quiet about nothing", () => { + const p = carveProgress([], 12); + expect(p.label).toBe("0 of 12 carved"); + expect(p.detail).toBe(""); + }); +}); + +describe("carveStateNote", () => { + it("says nothing at all when no carve has been recorded", () => { + expect(carveStateNote(carveProgress([], 12), new Map())).toBe(""); + }); + + it("names the manifest count, so the reader knows where the claim comes from", () => { + const states = readCarveStates([FIXTURES]); + const note = carveStateNote(carveProgress(states.values(), 12), states); + expect(note).toContain("3 manifests"); + expect(note).toContain("1 of 12 carved"); + }); +}); + +describe("applyCommandFor", () => { + const state = () => carveStateOf(read(BRIDGED), manifestPath(BRIDGED)); + + it("composes off the manifest — no --select, which is what chant#998 shipped", () => { + const cmd = applyCommandFor(state()); + expect(cmd).toContain("chant carve apply"); + expect(cmd).not.toContain("--select"); + expect(cmd).toContain("--from /tmp/carve-fixture/legacy-tf"); + expect(cmd).toContain(`--output ${FIXTURES}`); + }); + + it("shows placeholders for what chant needs and the manifest doesn't have yet", () => { + expect(applyCommandFor(state())).toContain("--env --stack "); + }); + + it("uses the marker the apply recorded once there is one", () => { + const cmd = applyCommandFor(carveStateOf(read(APPLIED), manifestPath(APPLIED))); + expect(cmd).toContain("--env prod --stack assets"); + }); + + it("takes display labels without changing which carve it names", () => { + const cmd = applyCommandFor(state(), { from: "legacy-tf", out: "app/carveout" }); + expect(cmd).toContain("--from legacy-tf --output app/carveout"); + }); +}); + +describe("carveStatePayload", () => { + it("publishes one shape, sorted, with the apply boundary on the wire", () => { + const payload = carveStatePayload(readCarveStates([FIXTURES]), 12); + expect(payload.manifests).toBe(3); + expect(payload.states.map((s) => s.target)).toEqual([ + "aws_cloudwatch_log_group.worker", + "aws_iam_role.api", + "aws_s3_bucket.assets", + ]); + expect(payload.states[0].note).toContain("Terraform still owns"); + expect(payload.states[0].applyCommand).toContain("chant carve apply"); + expect(payload.apply).toEqual({ human: true, note: APPLY_IS_HUMAN }); + expect(payload.apply.note).toContain("no apply endpoint and no"); + }); +}); + +// --------------------------------------------------------------------------- +// The strangler-fig split — the picture #230 M3 is actually about. +// --------------------------------------------------------------------------- + +const REPORT = JSON.parse(readFileSync(join(HERE, "__fixtures__", "carve-sample-estate.json"), "utf8")) as CarveReport; + +const stateFor = (target: string, stage: "emitted" | "bridged" | "applied"): CarveState => + carveStateOf( + { + version: 1, + target, + ...(stage !== "emitted" ? { bridge: { at: "t" } } : {}), + ...(stage === "applied" ? { apply: { marker: { stack: "s", env: "e" }, at: "t" } } : {}), + emit: { files: [`/out/src/${target}.ts`], at: "t" }, + } as CarveManifest, + "/out/x.carve.json", + ); + +describe("splitCarveState", () => { + const ir = carveReportToIr(REPORT); + const target = REPORT.resources[0].address; + + it("is a no-op with no manifests — the #254 frame, unchanged", () => { + const { tf, graduated } = splitCarveState(ir, new Map()); + expect(tf).toBe(ir); + expect(graduated).toEqual([]); + }); + + it("moves a graduated address out of the bands and hands it back as a chant node", () => { + const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]])); + expect(tf.nodes.some((n) => n.id === target)).toBe(false); + expect(Object.values(tf.groups.byStack as Record).flat()).not.toContain(target); + // It keeps its Terraform ADDRESS as its id — the morph's identity + // continuity, and what makes a restart show the same card in the chant box. + expect(graduated.map((n) => n.id)).toEqual([target]); + expect(graduated[0].attrs._status).toBe("good"); + expect(graduated[0].attrs.carve).toBe("carved → chant"); + expect(graduated[0].attrs.ownedStack).toBe("s"); + }); + + it("drops the boundary edges of a graduated address — that cut has been made", () => { + const withEdges = { ...ir, edges: [{ from: "aws_vpc.main", to: target, kind: "ref" as const }] }; + const { tf } = splitCarveState(withEdges, new Map([[target, stateFor(target, "applied")]])); + expect(tf.edges).toEqual([]); + }); + + it("keeps a partial carve in its band, repainted with the stage's word", () => { + for (const stage of ["emitted", "bridged"] as const) { + const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, stage)]])); + expect(graduated).toEqual([]); + const node = tf.nodes.find((n) => n.id === target)!; + // Tone AND word, never tone alone — and never the band's own three tones. + expect(node.attrs._status).toBe("accent"); + expect(node.attrs.carve).toBe(carveWordFor(stage)); + expect(node.attrs.carveState).toBe(stage); + expect(node.attrs.chantSource).toContain("/out/src/"); + // Still banded: nothing has changed hands. + expect(Object.values(tf.groups.byStack as Record).flat()).toContain(target); + } + }); + + it("empties a band rather than leaving a titled hole when its last member graduates", () => { + const band = Object.entries(ir.groups.byStack as Record).find(([, m]) => m.length === 1); + if (!band) return; // the sample estate has no singleton band; nothing to assert + const [title, [only]] = band; + const { tf } = splitCarveState(ir, new Map([[only, stateFor(only, "applied")]])); + expect(Object.keys(tf.groups.byStack as Record)).not.toContain(title); + }); + + it("bands the graduated pile above the ranking when there is no chant box", () => { + const { tf, graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]])); + const single = bandGraduated(tf, graduated); + const bands = Object.keys(single.groups.byStack as Record); + expect(bands[0]).toBe("carved → chant"); + expect((single.groups.byStack as Record)["carved → chant"]).toEqual([target]); + expect(single.nodes).toHaveLength(ir.nodes.length); + // No graduation, no extra band — the #252 single view, unchanged. + expect(bandGraduated(ir, [])).toBe(ir); + }); + + it("trims the emitted source path for the card when a caller says how", () => { + const { graduated } = splitCarveState(ir, new Map([[target, stateFor(target, "applied")]]), { + shorten: (p) => p.replace("/out/", ""), + }); + expect(graduated[0].attrs.chantSource).toBe(`src/${target}.ts`); + }); + + it("ignores a manifest for an address this report never ranked", () => { + const { tf, graduated } = splitCarveState(ir, new Map([["aws_s3_bucket.not_here", stateFor("aws_s3_bucket.not_here", "applied")]])); + expect(graduated).toEqual([]); + expect(tf.nodes).toHaveLength(ir.nodes.length); + }); +}); diff --git a/src/carve-manifest.ts b/src/carve-manifest.ts new file mode 100644 index 0000000..4c871f9 --- /dev/null +++ b/src/carve-manifest.ts @@ -0,0 +1,545 @@ +/** + * The carve state manifest reader (#230 M3) — what makes the strangler-fig + * picture survive a restart. + * + * chant ≥ 0.52.2 (chant#998, chant PR #1575) persists a manifest beside the + * emitted source: `carve emit` writes `.carve.json` into its `--output` + * dir carrying the target address, the boundary report and an `emit` record; + * `carve bridge` adds a `bridge` record; `carve apply` adds an `apply` record + * with the ownership marker. Plain JSON on disk, one file per carved address — + * the durable record of which resources have graduated from Terraform to + * chant, which is exactly the progression source #230 M3 asked for. + * + * behold reads it and never writes it. The manifest is chant's file: it is + * written by the three carve verbs and by nothing else, and a reader that + * touched it would be inventing state the tool it describes did not record. + * + * The three sections are cumulative, so their presence IS the stage: + * + * emit only -> `emitted` — typed chant source exists; the + * surviving Terraform still reads the + * resource directly. + * emit + bridge -> `bridged` — the survivor patch is proposed; the + * resource has NOT changed hands. + * emit + bridge + apply-> `applied` — graduation is recorded: the ownership + * marker is resolved and chant owns it. + * + * Only `applied` is graduation. `emitted` and `bridged` are work in progress + * and are rendered as such — a card that read "carved" the moment source was + * emitted would claim an ownership transfer that has not happened, which is + * the one class of lie every behold view is built to avoid. + * + * `carve apply` is a HUMAN step here, always — see {@link APPLY_IS_HUMAN}. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { GraphIR, IRNode } from "@intentius/chant"; + +/** chant's own suffix (`CARVE_MANIFEST_SUFFIX` in its manifest.ts). */ +export const CARVE_MANIFEST_SUFFIX = ".carve.json"; + +/** + * THE APPLY BOUNDARY, stated once and referenced from every place the + * temptation arises (src/carve-actions.ts, carveRoutes in src/server.ts, + * web/carve-steps.js). + * + * `carve apply` resolves the ownership marker that makes chant the owner of a + * live resource. behold renders its state from the manifest and echoes the + * command a person could retype — the same posture the Handoff step already + * takes with `terraform state rm` — and offers no endpoint and no button for + * it. That is not an oversight to be fixed later: behold triggers delegated + * work, it does not decide when an estate changes hands. + */ +export const APPLY_IS_HUMAN = + "`chant carve apply` graduates ownership — it resolves the marker that makes chant the owner of a live " + + "resource. behold shows what the manifest records and echoes the command; it has no apply endpoint and no " + + "apply button, by design. Read the graduation plan, then run it yourself."; + +/** One carve's persisted state, as chant writes it. Every field past `version` + * and `target` is optional here even where chant's own type requires it — a + * reader should never harden a contract it does not own more than it must. */ +export interface CarveManifest { + version: 1; + /** Terraform address of the carved resource, e.g. `aws_s3_bucket.assets`. */ + target: string; + tfType?: string; + /** The Terraform estate the carve came from. */ + from?: string; + statePath?: string; + /** chant's boundary report for this carve, persisted at emit time. */ + boundary?: unknown; + emit?: { + source?: "tfstate" | "live" | string; + files?: string[]; + /** Deferred outbound inputs declared as build parameters (chant#998). */ + params?: Record; + at?: string; + }; + bridge?: { + written?: string[]; + appliedInPlace?: boolean; + patch?: string; + /** Carved addresses whose own `.tf` block the rewrites remove. */ + excised?: string[]; + at?: string; + }; + apply?: { + marker?: { stack?: string; env?: string }; + ownershipTags?: Record; + stampedFiles?: string[]; + at?: string; + }; +} + +/** How far along a carve is. Cumulative — see the module comment. */ +export type CarveStage = "emitted" | "bridged" | "applied"; + +/** How a stage paints. `accent` is the in-flight tone: an existing pinhole + * status token, distinct from the three band tones (`good`/`warn`/`neutral`) + * the ranking already spends, so a partly-carved card cannot be mistaken for a + * band verdict. Both partial stages share it and are told apart by their WORD + * ({@link carveWordFor}), which is on the card: a second tone would have to + * borrow a token that already means something else in this picture, and tone + * alone was never allowed to carry a state here anyway. */ +export type CarveTone = "good" | "accent"; + +/** One carved address, flattened for rendering. */ +export interface CarveState { + target: string; + tfType?: string; + stage: CarveStage; + /** True only at `applied`: chant owns it, so it draws in the chant box. */ + graduated: boolean; + /** When the LAST recorded step ran (ISO 8601), when chant stamped one. */ + at?: string; + /** Emitted chant source files, as the manifest records them. */ + files: string[]; + /** Terraform blocks the bridge patch would remove. */ + excised: string[]; + /** Deferred deploy-time inputs the emit recorded, ``. */ + deferredInputs: string[]; + /** The ownership marker `carve apply` resolved. Present iff `applied`. */ + ownership?: { stack?: string; env?: string; tags?: Record }; + /** The Terraform estate the carve came from, as the manifest recorded it. */ + from?: string; + /** The `--output` dir this manifest sits in — where the later verbs compose. */ + outDir: string; + /** Where the manifest was read from. */ + path: string; +} + +const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + +const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); + +/** + * A parsed manifest, or null when the value is not a v1 carve manifest. + * + * Same shallow discipline as `parseCarveReport`: the fields the renderer reads + * are checked, the rest is passed through. `version !== 1` is a refusal rather + * than a best-effort read — a future major would mean something else by these + * sections, and half-reading it would put a wrong stage on a card. + */ +export function parseCarveManifest(value: unknown): CarveManifest | null { + if (!isRecord(value)) return null; + if (value.version !== 1) return null; + if (typeof value.target !== "string" || !value.target) return null; + return value as unknown as CarveManifest; +} + +/** Read + parse one manifest file. `readFile` is injected so the reader stays + * pure for tests; anything unreadable or unparseable is null, never a throw — + * a stray file in a carveout is not a reason to fail a graph request. */ +export function readCarveManifest(path: string, readFile: (p: string) => string = (p) => readFileSync(p, "utf8")): CarveManifest | null { + let text: string; + try { + text = readFile(path); + } catch { + return null; + } + try { + return parseCarveManifest(JSON.parse(text)); + } catch { + return null; + } +} + +/** The stage a manifest's recorded sections add up to. */ +export function carveStageOf(m: CarveManifest): CarveStage { + if (m.apply) return "applied"; + if (m.bridge) return "bridged"; + return "emitted"; +} + +/** The stage's word, as it reads on a card and in the panel. The word is the + * state's carrier; the tone only reinforces it. */ +export function carveWordFor(stage: CarveStage): string { + switch (stage) { + case "applied": + return "carved → chant"; + case "bridged": + return "bridged — apply is yours"; + default: + return "emitted — not bridged"; + } +} + +/** The stage's tone. See {@link CarveTone} for why both partial stages share + * `accent`. */ +export function carveToneFor(stage: CarveStage): CarveTone { + return stage === "applied" ? "good" : "accent"; +} + +/** A one-line reading of a stage, for the panel and the inspect pane. */ +export function carveStageNote(state: CarveState): string { + switch (state.stage) { + case "applied": + return ( + `graduated — \`carve apply\` recorded the ownership marker` + + (state.ownership?.stack ? ` (stack ${state.ownership.stack}${state.ownership.env ? `, env ${state.ownership.env}` : ""})` : "") + + ". chant owns it." + ); + case "bridged": + return "emitted and bridged — the survivor patch is proposed. Terraform still owns the resource until `carve apply`."; + default: + return "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next."; + } +} + +/** Flatten a manifest into the shape the views render. */ +export function carveStateOf(m: CarveManifest, path: string): CarveState { + const stage = carveStageOf(m); + const deferred = Object.entries(m.emit?.params ?? {}).map(([name, p]) => `${name} ← ${p?.survivor ?? "a survivor"}`); + return { + target: m.target, + ...(m.tfType ? { tfType: m.tfType } : {}), + stage, + graduated: stage === "applied", + // The last stamped step wins — that is the age of the state, not the age + // of the carve. + ...(m.apply?.at || m.bridge?.at || m.emit?.at ? { at: m.apply?.at ?? m.bridge?.at ?? m.emit?.at } : {}), + files: strings(m.emit?.files), + excised: strings(m.bridge?.excised), + deferredInputs: deferred, + ...(m.apply + ? { + ownership: { + ...(m.apply.marker?.stack ? { stack: m.apply.marker.stack } : {}), + ...(m.apply.marker?.env ? { env: m.apply.marker.env } : {}), + ...(m.apply.ownershipTags ? { tags: m.apply.ownershipTags } : {}), + }, + } + : {}), + ...(m.from ? { from: m.from } : {}), + outDir: dirname(path), + path, + }; +} + +/** The filesystem calls discovery makes, injectable for tests. */ +export interface CarveManifestIo { + readdir: (dir: string) => string[]; + isDirectory: (p: string) => boolean; + readFile: (p: string) => string; +} + +export const nodeCarveIo: CarveManifestIo = { + readdir: (dir) => readdirSync(dir), + isDirectory: (p) => { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } + }, + readFile: (p) => readFileSync(p, "utf8"), +}; + +/** How deep discovery walks under a scanned PROJECT directory. + * + * chant writes the manifest flat into `--output`, and that dir is almost always + * a child of the project it belongs to (`app/carveout/` in the demo; whatever + * `--output` a person passed otherwise). Two levels finds `/carveout/` + * and `//carveout/` without turning a graph request into a + * tree walk of someone's repo. Deeper than that and the manifest is somewhere + * behold has no business guessing about. + * + * A directory that IS the output dir — the one a `--output` named, or the one + * a `behold carve /report.json` pointed at — is scanned flat (depth 0) + * instead. Walking down from a named output dir would be a guess; walking down + * from a project root is the convention. */ +export const MANIFEST_SCAN_DEPTH = 2; + +/** Directories discovery never descends into — vendored trees and dot-dirs + * hold nothing chant wrote, and walking them is the whole cost of the scan. */ +const SKIP_DIRS = new Set(["node_modules", "dist", "cdk.out", ".terraform", ".git"]); + +/** Every carve manifest path under `dir`, sorted, bounded by `depth` (default + * {@link MANIFEST_SCAN_DEPTH}; 0 scans `dir` itself and nothing under it). + * Unreadable directories are skipped, never fatal. */ +export function listCarveManifests(dir: string, io: CarveManifestIo = nodeCarveIo, depth = MANIFEST_SCAN_DEPTH): string[] { + const out: string[] = []; + const walk = (d: string, level: number): void => { + let entries: string[]; + try { + entries = io.readdir(d).sort(); + } catch { + return; + } + for (const name of entries) { + const full = join(d, name); + if (name.endsWith(CARVE_MANIFEST_SUFFIX)) { + out.push(full); + continue; + } + if (level >= depth) continue; + if (name.startsWith(".") || SKIP_DIRS.has(name)) continue; + if (io.isDirectory(full)) walk(full, level + 1); + } + }; + walk(dir, 0); + return out; +} + +/** + * Every carve state discoverable under `dirs`, keyed by Terraform address. + * + * One address can only be in one place at a time, so a duplicate (the same + * target carved into two output dirs) resolves to the FURTHER-ALONG stage: + * `applied` beats `bridged` beats `emitted`. A half-finished second copy of a + * carve that already graduated is a stale artifact, not a demotion. + */ +export function readCarveStates( + dirs: readonly string[], + io: CarveManifestIo = nodeCarveIo, + depth = MANIFEST_SCAN_DEPTH, +): Map { + const rank: Record = { emitted: 0, bridged: 1, applied: 2 }; + const byTarget = new Map(); + for (const dir of dirs) { + for (const path of listCarveManifests(dir, io, depth)) { + const manifest = readCarveManifest(path, io.readFile); + if (!manifest) continue; + const state = carveStateOf(manifest, path); + const held = byTarget.get(state.target); + if (!held || rank[state.stage] > rank[held.stage]) byTarget.set(state.target, state); + } + } + return byTarget; +} + +/** The strangler-fig progress read. `total` is the ranked-resource count from + * the carve report when there is one; without a report there is no denominator + * and the label says the numerator alone rather than inventing one. */ +export interface CarveProgress { + applied: number; + bridged: number; + emitted: number; + /** Carves started but not graduated — the partial states. */ + inFlight: number; + total: number | null; + /** "1 of 12 carved" / "1 carved". */ + label: string; + /** "1 emitted, 1 bridged — apply is yours", or "" when nothing is in flight. */ + detail: string; +} + +export function carveProgress(states: Iterable, total?: number | null): CarveProgress { + let applied = 0; + let bridged = 0; + let emitted = 0; + for (const s of states) { + if (s.stage === "applied") applied++; + else if (s.stage === "bridged") bridged++; + else emitted++; + } + const inFlight = bridged + emitted; + const parts: string[] = []; + if (emitted) parts.push(`${emitted} emitted`); + if (bridged) parts.push(`${bridged} bridged`); + return { + applied, + bridged, + emitted, + inFlight, + total: typeof total === "number" ? total : null, + label: typeof total === "number" ? `${applied} of ${total} carved` : `${applied} carved`, + detail: parts.length ? `${parts.join(", ")} — apply is yours` : "", + }; +} + +/** + * The retypeable `chant carve apply` line for a carve in hand. + * + * Echoed, never run — {@link APPLY_IS_HUMAN}. `--select` is deliberately + * absent: the manifest in `--output` already names the target, which is the + * composition chant#998 shipped and the shortest true command a person can + * paste. `--env`/`--stack` are shown as placeholders when the manifest has no + * marker yet, because chant needs them to resolve one and a command that + * silently omits them would fail in the viewer's terminal, not here. + * + * `labels` shortens the two paths for display (the demo prints them relative to + * its copy, the way every other echoed command in the walkthrough does). It + * changes what the line READS as, never which carve it names. + */ +export function applyCommandFor(state: CarveState, labels: { from?: string; out?: string } = {}): string { + const env = state.ownership?.env ?? ""; + const stack = state.ownership?.stack ?? ""; + const from = labels.from ?? state.from ?? ""; + const out = labels.out ?? state.outDir; + return `chant carve apply --from ${from} --output ${out} --env ${env} --stack ${stack} --write-source`; +} + +/** What `/api/project` publishes about the carve state — the same shape in + * carve mode and on an ordinary project serve, so the panel that renders it + * has one branch, not two. */ +export interface CarveStatePayload { + manifests: number; + progress: CarveProgress; + states: Array; + /** The apply boundary restated on the wire, so an agent reading this sees it + * without reading the source. There is no endpoint to pair it with — that is + * the point. */ + apply: { human: true; note: string }; +} + +/** Build the payload. `labels` shortens each carve's echoed paths for display + * (the demo prints them relative to its copy); it never changes which carve a + * state names. */ +export function carveStatePayload( + states: Map, + total?: number | null, + labels: (s: CarveState) => { from?: string; out?: string } = () => ({}), +): CarveStatePayload { + const list = [...states.values()].sort((a, b) => a.target.localeCompare(b.target)); + return { + manifests: list.length, + progress: carveProgress(list, total), + states: list.map((s) => ({ ...s, note: carveStageNote(s), applyCommand: applyCommandFor(s, labels(s)) })), + apply: { human: true, note: APPLY_IS_HUMAN }, + }; +} + +/** + * Split a Terraform ranking against the manifest state. + * + * A graduated address leaves the TF bands entirely and comes back as a chant + * node — it keeps its Terraform ADDRESS as its id (the morph's identity + * continuity, #230 M2b, depends on that), and carries the ownership marker in + * its attrs so the inspect pane can say who owns it and since when. + * + * A partial address stays in its band — nothing has changed hands — but its + * card is repainted: the in-flight tone plus the stage's word in the `carve` + * field the presentation pack already shows. + * + * Boundary edges to a graduated node are dropped along with it. The edge + * described a cut that has now been made; drawing it would claim the survivor + * still reads the Terraform resource, which after `carve bridge` + `apply` it + * does not. + */ +export function splitCarveState( + tfIr: GraphIR, + states: Map, + // The emitted source path lands on the card, and chant records it absolute. + // `shorten` is how a caller trims it to something a viewer learns from — + // `app/carveout/src/assets.ts` rather than the length of an operator's + // tmpdir. Identity, not redaction: the same presentation choice + // src/carve-actions.ts makes for every command it echoes. + opts: { shorten?: (path: string) => string } = {}, +): { tf: GraphIR; graduated: IRNode[] } { + if (states.size === 0) return { tf: tfIr, graduated: [] }; + const shorten = opts.shorten ?? ((p: string) => p); + const source = (files: string[]): string => files.map(shorten).join(", "); + const gone = new Set(); + const graduated: IRNode[] = []; + const nodes: IRNode[] = []; + + for (const node of tfIr.nodes) { + const state = states.get(node.id); + if (!state) { + nodes.push(node); + continue; + } + if (state.graduated) { + gone.add(node.id); + graduated.push({ + ...node, + attrs: { + ...node.attrs, + _status: "good", + carve: carveWordFor(state.stage), + carveState: state.stage, + ...(state.ownership?.stack ? { ownedStack: state.ownership.stack } : {}), + ...(state.ownership?.env ? { ownedEnv: state.ownership.env } : {}), + ...(state.at ? { carvedAt: state.at } : {}), + ...(state.files.length ? { chantSource: source(state.files) } : {}), + }, + }); + continue; + } + nodes.push({ + ...node, + attrs: { + ...node.attrs, + _status: carveToneFor(state.stage), + carve: carveWordFor(state.stage), + carveState: state.stage, + ...(state.at ? { carvedAt: state.at } : {}), + ...(state.files.length ? { chantSource: source(state.files) } : {}), + ...(state.excised.length ? { excisesOnApply: state.excised.join(", ") } : {}), + ...(state.deferredInputs.length ? { deferredInputs: state.deferredInputs.join(", ") } : {}), + }, + }); + } + + const byStack: Record = {}; + for (const [band, members] of Object.entries((tfIr.groups?.byStack ?? {}) as Record)) { + const left = members.filter((id) => !gone.has(id)); + if (left.length) byStack[band] = left; + } + + return { + tf: { + nodes, + edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)), + groups: { ...tfIr.groups, byStack }, + }, + graduated, + }; +} + +/** The band a graduated resource lands in when there is no chant box to move + * it into — the same words the estate frame paints on the card. */ +export const GRADUATED_BAND = "carved → chant"; + +/** + * The single-view answer to the split (`behold carve report.json`, no demo). + * + * There is no chant member box to move a graduated card into, and dropping it + * would be worse than either option — the resource did not stop existing, it + * stopped being Terraform's. So it gets its own band, ABOVE the ranking, which + * is where the picture reads as a progress bar: the graduated pile grows at the + * top while the bands below it drain. + */ +export function bandGraduated(tf: GraphIR, graduated: IRNode[]): GraphIR { + if (graduated.length === 0) return tf; + return { + nodes: [...graduated, ...tf.nodes], + edges: tf.edges, + groups: { + ...tf.groups, + byStack: { + [GRADUATED_BAND]: graduated.map((n) => n.id), + ...((tf.groups?.byStack ?? {}) as Record), + }, + }, + }; +} + +/** The manifest half of the statusbar's honesty line, or "" when no carve has + * been recorded at all. Appended to `carveNote`'s advisory reading. */ +export function carveStateNote(progress: CarveProgress, states: Map): string { + if (states.size === 0) return ""; + const head = ` Carve state (from ${states.size} manifest${states.size === 1 ? "" : "s"}): ${progress.label}`; + return progress.detail ? `${head}, ${progress.detail}.` : `${head}.`; +} diff --git a/src/carve-route.test.ts b/src/carve-route.test.ts index 051a14e..25ecdef 100644 --- a/src/carve-route.test.ts +++ b/src/carve-route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -131,6 +131,172 @@ describe("GET /api/project — carve mode", () => { }); }); +// --------------------------------------------------------------------------- +// #230 M3 — the manifest-driven strangler-fig, over the route. +// --------------------------------------------------------------------------- + +/** The sample report plus carve manifests, in one throwaway output dir. The + * applied manifest is the REAL one (src/__fixtures__/carve-manifests, whose + * provenance src/carve-manifest.test.ts states); `aws_s3_bucket.assets` is an + * address both it and the sample estate carry, which is what lets the two + * fixtures compose. The second is written here at the emitted stage, because + * no real manifest exists for an address this particular report ranks. */ +function servedWithManifests(): ReturnType { + const dir = mkdtempSync(join(tmpdir(), "behold-carve-state-")); + const report = join(dir, "carve-report.json"); + writeFileSync(report, readFileSync(FIXTURE, "utf8")); + writeFileSync( + join(dir, "aws_s3_bucket-assets.carve.json"), + readFileSync(join(HERE, "__fixtures__", "carve-manifests", "aws_s3_bucket-assets.carve.json"), "utf8"), + ); + writeFileSync( + join(dir, "aws_lambda_function-api.carve.json"), + JSON.stringify({ + version: 1, + target: "aws_lambda_function.api", + from: "/tmp/carve-fixture/legacy-tf", + boundary: {}, + emit: { source: "tfstate", files: ["/tmp/carve-fixture/out/src/api.ts"], at: "2026-08-30T04:12:44.246Z" }, + }), + ); + return carveApp(report); +} + +describe("the carve state manifests drive the picture (#230 M3)", () => { + it("draws a graduated address in its own band, out of the ranking", async () => { + const body = (await (await servedWithManifests().request("/api/graph")).json()) as { + ir: GraphIR; + svg: string; + meta: Record; + }; + const bands = body.ir.groups.byStack as Record; + expect(Object.keys(bands)[0]).toBe("carved → chant"); + expect(bands["carved → chant"]).toEqual(["aws_s3_bucket.assets"]); + expect(bands["carve now"]).not.toContain("aws_s3_bucket.assets"); + const carved = body.ir.nodes.find((n) => n.id === "aws_s3_bucket.assets")!; + expect(carved.attrs._status).toBe("good"); + expect(carved.attrs.carve).toBe("carved → chant"); + expect(carved.attrs.ownedStack).toBe("assets"); + // Actually drawn, not merely in the IR. + expect(body.svg).toContain("carved → chant"); + }); + + it("leaves a partial carve in its band, repainted with the stage's word", async () => { + const body = (await (await servedWithManifests().request("/api/graph")).json()) as { ir: GraphIR }; + const bands = body.ir.groups.byStack as Record; + const partial = body.ir.nodes.find((n) => n.id === "aws_lambda_function.api")!; + expect(partial.attrs._status).toBe("accent"); + expect(partial.attrs.carve).toBe("emitted — not bridged"); + expect(Object.values(bands).flat()).toContain("aws_lambda_function.api"); + expect(bands["carved → chant"]).not.toContain("aws_lambda_function.api"); + }); + + it("puts the progress read on the statusbar note, saying where the claim comes from", async () => { + const body = (await (await servedWithManifests().request("/api/graph")).json()) as { meta: { note: string } }; + expect(body.meta.note).toContain("1 of 8 carved"); + expect(body.meta.note).toContain("2 manifests"); + expect(body.meta.note).toContain("1 emitted"); + }); + + it("publishes the state on /api/project, with the apply boundary on the wire", async () => { + const body = (await (await servedWithManifests().request("/api/project")).json()) as { + carve: { + state: { + manifests: number; + progress: { label: string; applied: number; inFlight: number }; + states: Array<{ target: string; stage: string; graduated: boolean; applyCommand: string }>; + apply: { human: boolean; note: string }; + }; + }; + }; + const state = body.carve.state; + expect(state.manifests).toBe(2); + expect(state.progress).toMatchObject({ label: "1 of 8 carved", applied: 1, inFlight: 1 }); + expect(state.states.map((s) => s.stage)).toEqual(["emitted", "applied"]); + expect(state.states[0].applyCommand).toContain("chant carve apply"); + expect(state.states[0].applyCommand).not.toContain("--select"); + expect(state.apply.human).toBe(true); + }); + + it("has no apply endpoint — the graduation step is a person, not a POST", async () => { + const app = servedWithManifests(); + for (const path of ["/api/carve/apply", "/api/carve/graduate"]) { + const res = await app.request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(res.status, path).toBe(404); + } + // …and it isn't advertised either. + const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> }; + expect(api.routes.some((r) => r.path.includes("apply") && r.path.includes("carve"))).toBe(false); + }); + + it("says nothing about carve state when no manifest exists — the #252 view, unchanged", async () => { + const body = (await (await carveApp(FIXTURE).request("/api/graph")).json()) as { ir: GraphIR; meta: { note: string } }; + expect(Object.keys(body.ir.groups.byStack as Record)).not.toContain("carved → chant"); + expect(body.meta.note).not.toContain("Carve state"); + const project = (await (await carveApp(FIXTURE).request("/api/project")).json()) as { + carve: { state: { manifests: number } }; + }; + expect(project.carve.state.manifests).toBe(0); + }); +}); + +describe("a real project serve surfaces its carve state without the demo scaffolding (#230 M3)", () => { + /** A chant project with a carveout inside it, the way `chant carve emit + * --output ./carveout` leaves one. No report, no demo, no stepper. */ + function carvedProject(): string { + const dir = mkdtempSync(join(tmpdir(), "behold-carved-project-")); + writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["aws"] };\n'); + mkdirSync(join(dir, "carveout"), { recursive: true }); + for (const f of ["aws_s3_bucket-assets.carve.json", "aws_iam_role-api.carve.json"]) { + writeFileSync(join(dir, "carveout", f), readFileSync(join(HERE, "__fixtures__", "carve-manifests", f), "utf8")); + } + return dir; + } + + function projectApp(dir: string) { + const broadcaster = new Broadcaster(); + return createApp( + { projectDir: dir, port: 0 }, + broadcaster, + new FrameBuffer(), + new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} }), + ); + } + + it("finds the manifests in the project's carveout and publishes what they record", async () => { + const dir = carvedProject(); + try { + const body = (await (await projectApp(dir).request("/api/project")).json()) as { + carve?: { state: { manifests: number; progress: { label: string }; states: Array<{ target: string; stage: string }> } }; + }; + expect(body.carve?.state.manifests).toBe(2); + // No report, so there is no denominator to invent. + expect(body.carve?.state.progress.label).toBe("1 carved"); + expect(body.carve?.state.states.map((s) => `${s.target}:${s.stage}`)).toEqual([ + "aws_iam_role.api:emitted", + "aws_s3_bucket.assets:applied", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("omits `carve` entirely for a project that was never carved — no dead panel", async () => { + const dir = mkdtempSync(join(tmpdir(), "behold-uncarved-project-")); + writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["aws"] };\n'); + try { + const body = (await (await projectApp(dir).request("/api/project")).json()) as Record; + expect("carve" in body).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + 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); diff --git a/src/render.test.ts b/src/render.test.ts index 684722f..5f39acf 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { renderGraph, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts"; +import { carveStateOf } from "./carve-manifest.ts"; import type { GraphIR } from "@intentius/chant"; // M4: renderGraph gained an explicit `boxes: "byStack"` opt-in for the @@ -337,6 +338,78 @@ describe("renderCarveEstate — the carve estate frame (#254)", () => { expect(w / h).toBeLessThan(4); }); + // #230 M3: the same frame, driven by the carve state manifests. The + // manifest reader itself is covered in src/carve-manifest.test.ts against + // real chant output; these pin what the PICTURE does with it. + describe("the manifest-driven strangler-fig (#230 M3)", () => { + const state = (target: string, stage: "emitted" | "bridged" | "applied") => + carveStateOf( + { + version: 1, + target, + emit: { files: [`/out/src/${target}.ts`], at: "t" }, + ...(stage !== "emitted" ? { bridge: { at: "t" } } : {}), + ...(stage === "applied" ? { apply: { marker: { stack: "s", env: "e" }, at: "t" } } : {}), + }, + "/out/x.carve.json", + ); + const carvedEstate = (states: Map>) => + renderCarveEstate(bandedIr, appIr, { tfTitle: "legacy-tf — terraform", appTitle: "app — chant", carved: states }); + + it("draws a graduated resource inside the chant box, keeping its Terraform address", () => { + const target = bandedIr.nodes[0].id; + const { svg, ir } = carvedEstate(new Map([[target, state(target, "applied")]])); + const byStack = ir.groups.byStack as Record; + expect(byStack["app — chant"]).toContain(target); + expect(byStack["carve now"]).not.toContain(target); + // One card, one id — the morph's continuity, and what a restart shows. + expect((svg.match(new RegExp(`data-node-id="${target.replace(".", "\\.")}"`, "g")) || []).length).toBe(1); + }); + + it("puts the progress read on the chant panel's title, where the estate summarizes it", () => { + const target = bandedIr.nodes[0].id; + const { svg } = carvedEstate(new Map([[target, state(target, "applied")]])); + expect(svg).toContain(`carved so far — 1 of ${bandedIr.nodes.length} carved`); + }); + + it("leaves a partial carve in its band, repainted with the stage's word", () => { + const target = bandedIr.nodes[0].id; + const { ir } = carvedEstate(new Map([[target, state(target, "bridged")]])); + const byStack = ir.groups.byStack as Record; + expect(byStack["carve now"]).toContain(target); + expect(byStack["app — chant"]).not.toContain(target); + const node = ir.nodes.find((n) => n.id === target)!; + expect(node.attrs._status).toBe("accent"); + expect(node.attrs.carve).toBe("bridged — apply is yours"); + }); + + it("is the #254 frame exactly when no manifest exists", () => { + const plain = estate(); + const empty = carvedEstate(new Map()); + expect(empty.ir.groups).toEqual(plain.ir.groups); + expect(empty.svg).toContain("carved so far"); + expect(empty.svg).not.toContain("carved so far —"); + }); + + it("opens the morph with an already-graduated card in the chant box", () => { + const parseViews = (html: string) => JSON.parse(html.match(/const VIEWS = (\[[\s\S]*?\]);\n/)![1].replace(/\\u003c/g, "<")); + const already = bandedIr.nodes[1].id; + const VIEWS = parseViews( + renderCarveMorph(bandedIr, appIr, ["aws_s3_bucket.b0"], { + tfTitle: "legacy-tf — terraform", + appTitle: "app — chant", + carved: new Map([[already, state(already, "applied")]]), + }), + ); + const appBox = (v: { boxes: Array<{ key: string; x: number }> }) => v.boxes.find((b) => b.key === "app — chant")!; + // In the BEFORE frame already — the morph is about the one card moving. + expect(VIEWS[0].pos[already].x).toBeGreaterThan(appBox(VIEWS[0]).x); + expect(VIEWS[1].pos[already].x).toBeGreaterThan(appBox(VIEWS[1]).x); + // Both views keep the plain panel title, so the box morphs instead of swapping. + for (const v of VIEWS) expect(v.boxes.map((b: { key: string }) => b.key)).toContain("carved so far"); + }); + }); + // #230 M2b: the morph — two estate frames, the carved card gliding between // them. Identity continuity is the whole trick: same id in both views. describe("renderCarveMorph", () => { diff --git a/src/render.ts b/src/render.ts index 378c34d..2501016 100644 --- a/src/render.ts +++ b/src/render.ts @@ -21,6 +21,7 @@ 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"; +import { carveProgress, splitCarveState, type CarveState } from "./carve-manifest.ts"; import { opCardFields } from "./ops-lens.ts"; // Lexicon-native icons (#227), step 2 of 2. pinhole resolves a node's glyph @@ -260,13 +261,26 @@ function bandedPlan(ir: GraphIR, bands: Record): BandedPlan { * 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. */ + * member, so the client's box matcher sees every box it is shown. + * + * `carved` (#230 M3) is the carve state manifests chant persisted — the + * strangler-fig's memory. It decides which side of the estate a ranked address + * draws on, so the picture is the same after a restart as it was before one; + * see src/carve-manifest.ts. Absent (or empty) it is a no-op and this renders + * exactly the #254 frame. */ export function renderCarveEstate( tfIr: GraphIR, appIr: GraphIR, - opts: { tfTitle: string; appTitle: string; theme?: string }, + opts: { + tfTitle: string; + appTitle: string; + theme?: string; + carved?: Map; + shorten?: (path: string) => string; + }, ): RenderResult & { ir: GraphIR } { - const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts); + const staged = withCarveState(tfIr, appIr, opts); + const comp = composeCarveEstate(staged.tf, staged.appNs, { ...opts, carvedTitle: staged.carvedTitle }); const svg = renderSvg(comp.ir, comp.layout, { fit: true, hideTitle: true, @@ -276,6 +290,37 @@ export function renderCarveEstate( return { svg, ir: comp.ir }; } +/** + * The manifest overlay, applied to both halves of the estate at once (#230 M3). + * + * Graduated addresses come out of the TF ranking and go into the chant box + * keeping their Terraform address as their id — the same identity continuity + * the morph relies on, which is what makes a restart show the card ALREADY in + * the chant box rather than a different card in a similar place. Partial + * carves stay in their band, repainted (src/carve-manifest.ts `splitCarveState`). + * + * The chant panel's title carries the progress read, because the panel is where + * the estate already summarizes this member's contents. The denominator is the + * ranking's own size BEFORE the split: a graduated resource left the ranking, + * and counting it out of both halves would make the progress bar shrink as it + * filled. + */ +function withCarveState( + tfIr: GraphIR, + appIr: GraphIR, + opts: { appTitle: string; carved?: Map; shorten?: (path: string) => string }, +): { tf: GraphIR; appNs: GraphIR; carvedTitle: string } { + const states = opts.carved ?? new Map(); + const { tf, graduated } = splitCarveState(tfIr, states, { ...(opts.shorten ? { shorten: opts.shorten } : {}) }); + const ns = namespaceAppIr(appIr, opts.appTitle); + const progress = carveProgress(states.values(), tfIr.nodes.length); + return { + tf, + appNs: { ...ns, nodes: [...ns.nodes, ...graduated] }, + carvedTitle: states.size ? `carved so far — ${progress.label}` : "carved so far", + }; +} + /** App-side node ids get the member prefix (`app/`, composeStacks' * convention) so they can never collide with a Terraform address. The carved * nodes the morph moves INTO the app box deliberately skip this — they keep @@ -300,11 +345,16 @@ interface EstateComposition { boxes: GroupBox[]; } -function composeCarveEstate(tfIr: GraphIR, appNs: GraphIR, opts: { tfTitle: string; appTitle: string }): EstateComposition { +function composeCarveEstate( + tfIr: GraphIR, + appNs: GraphIR, + opts: { tfTitle: string; appTitle: string; carvedTitle?: string }, +): EstateComposition { 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(appNs, { "carved so far": appNs.nodes.map((n) => n.id) }); + // discipline, and the panel title says what these cards have in common — + // plus the manifest-backed progress read when there is one (#230 M3). + const appPlan = bandedPlan(appNs, { [opts.carvedTitle ?? "carved so far"]: appNs.nodes.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). @@ -362,28 +412,45 @@ export function renderCarveMorph( tfIr: GraphIR, appIr: GraphIR, carved: string[], - opts: { tfTitle: string; appTitle: string; title?: string }, + opts: { + tfTitle: string; + appTitle: string; + title?: string; + carved?: Map; + shorten?: (path: string) => string; + }, ): string { - const appNs = namespaceAppIr(appIr, opts.appTitle); - const before = composeCarveEstate(tfIr, appNs, opts); + // The BEFORE frame is the estate as it stands, manifests included (#230 M3): + // a resource that graduated in a previous session is already in the chant box + // when the morph opens, so the one card that moves is the one this morph is + // about. Selecting an address that has already graduated is therefore a + // no-movement morph — which is the truth, not a bug. + const staged = withCarveState(tfIr, appIr, opts); + const appNs = staged.appNs; + // Both views keep the PLAIN panel title, not the progress one the estate + // frame uses: a box morphs to the box with the same title, and a title that + // counted the cards would read "N of M" in a frame showing N+1 of them and + // break the FLIP into a swap. The progress read lives on the estate view. + const morphOpts = { ...opts, carvedTitle: "carved so far" }; + const before = composeCarveEstate(staged.tf, appNs, morphOpts); const gone = new Set(carved); - const bands = (tfIr.groups.byStack ?? {}) as Record; + const bands = (staged.tf.groups.byStack ?? {}) as Record; const bandsAfter: Record = {}; for (const [band, members] of Object.entries(bands)) { const left = members.filter((id) => !gone.has(id)); if (left.length) bandsAfter[band] = left; } const tfAfter: GraphIR = { - nodes: tfIr.nodes.filter((n) => !gone.has(n.id)), - edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)), + nodes: staged.tf.nodes.filter((n) => !gone.has(n.id)), + edges: staged.tf.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)), groups: { byStack: bandsAfter }, }; - const carvedNodes = tfIr.nodes + const carvedNodes = staged.tf.nodes .filter((n) => gone.has(n.id)) .map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved → chant" } })); const appAfter: GraphIR = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] }; - const after = composeCarveEstate(tfAfter, appAfter, opts); + const after = composeCarveEstate(tfAfter, appAfter, morphOpts); const view = (name: string, comp: EstateComposition): MorphView => ({ name, diff --git a/src/server.ts b/src/server.ts index 1dfcedf..0d41de0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -58,6 +58,16 @@ import { resourcesByComponent, nonResourceEntities } from "./resources.ts"; import { summarizePlan } from "./reconcile.ts"; import { renderGraph, renderArchitecture, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts"; import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts"; +import { + bandGraduated, + carveProgress, + carveStateNote, + carveStatePayload, + nodeCarveIo, + readCarveStates, + splitCarveState, + type CarveState, +} from "./carve-manifest.ts"; import { carveWriteBlock, runCarveBridge, @@ -65,6 +75,7 @@ import { runCarveObserve, runCarvePlan, selectFromReport, + shortenIn, BUILD_CAVEAT, type CarveDemo, } from "./carve-actions.ts"; @@ -413,6 +424,18 @@ async function captureFrame( function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { const load = () => readCarveReport(reportPath, (p) => readFileSync(p, "utf8")); + // The carve state manifests (#230 M3, chant#998) — read fresh per request, + // exactly like the report above, because emit and bridge write one while the + // walkthrough is open and a cached read would show a stale strangler-fig. + // + // Both scanned FLAT (depth 0), because both are output dirs rather than + // project roots: chant writes the manifest into `--output`, a demo's runs are + // handed `--output `, and `behold carve out/report.json` names the + // directory the report was written into. Read-only — behold never writes a + // manifest; see src/carve-manifest.ts. + const carveDirs = [dirname(reportPath), ...(demo ? [demo.out] : [])]; + const carveStates = (): Map => readCarveStates(carveDirs, nodeCarveIo, 0); + // #254's stepper needs to know, before it draws a button, whether this server // can actually run a step — a `behold carve report.json` looks identical // otherwise, and offering an Emit button that always 403s would be a lie in @@ -471,12 +494,30 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { // each one, because dagre lays an edgeless graph out along a single row (see // its doc comment for the numbers). const appSide = await appGraphOnce(); + // #230 M3: the manifests decide which side of the estate each ranked + // address draws on, so the picture survives a restart — a resource that + // graduated last session opens in the chant box, not back in its band. + const states = carveStates(); + // With no chant box to move a graduated card into (a bare `behold carve + // report.json`), it gets its own band above the ranking — `bandGraduated`. + // The emitted source path reaches the card; a demo trims it to the copy the + // way every echoed command in the walkthrough is trimmed. + const shorten = demo ? (p: string) => shortenIn(p, demo.root) : (p: string) => p; + const single = () => { + const split = splitCarveState(tfIr, states, { shorten }); + return bandGraduated(split.tf, split.graduated); + }; const { svg, ir } = appSide ? renderCarveEstate(tfIr, appSide.ir, { tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`, appTitle: `${appSide.label} — chant`, + carved: states, + shorten, }) - : { ...renderBanded(tfIr), ir: tfIr }; + : (() => { + const banded = single(); + return { ...renderBanded(banded), ir: banded }; + })(); return c.json({ ir, svg, @@ -488,7 +529,10 @@ 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, tfIr) + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""), + note: + carveNote(parsed.report, tfIr) + + carveStateNote(carveProgress(states.values(), tfIr.nodes.length), states) + + (demo?.degraded ? ` Degraded: ${demo.degraded}` : ""), }, }); }); @@ -532,12 +576,22 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`, appTitle: `${appSide.label} — chant`, title: `carve — ${select}`, + carved: carveStates(), + shorten: (p: string) => shortenIn(p, demo!.root), }); return c.html(html); }); app.get("/api/project", (c) => { const parsed = load(); + const total = parsed.ok ? (parsed.report.count ?? parsed.report.resources.length) : null; + // #230 M3: the manifest-backed state, in the same shape an ordinary project + // serve publishes (see `carveStateForDirs` below). Paths are echoed + // relative to the demo copy — nobody learns anything from the length of the + // operator's tmpdir. + const state = carveStatePayload(carveStates(), total, () => + demo ? { from: shortenIn(demo.from, demo.root), out: shortenIn(demo.out, demo.root) } : {}, + ); return c.json({ projectDir: reportPath, recents: [], @@ -552,12 +606,13 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { ? { report: reportPath, from: parsed.report.from ?? null, - count: parsed.report.count ?? parsed.report.resources.length, + count: total, bands: parsed.report.bands ?? {}, advisory: parsed.report.advisory ?? null, demo: demoInfo(), + state, } - : { report: reportPath, demo: demoInfo() }, + : { report: reportPath, demo: demoInfo(), state }, }); }); @@ -611,6 +666,16 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { app.post("/api/carve/emit", (c) => runStep(c, runCarveEmit)); app.post("/api/carve/bridge", (c) => runStep(c, runCarveBridge)); + // There is no `/api/carve/apply`, and adding one is not a follow-up (#230 M3). + // `chant carve apply` graduates ownership — it resolves the marker that makes + // chant the owner of a live resource. `runStep` above would take it in four + // lines and every guard already written would still hold, which is exactly + // why the refusal has to be stated here rather than assumed: the boundary is + // not "behold can't", it is "behold won't". What the manifest records about + // an apply is rendered (carveStatePayload above) and the command is echoed + // for a person to retype; the trigger stays a human in a terminal, the same + // posture the Handoff step takes with `terraform state rm`. See + // src/carve-manifest.ts `APPLY_IS_HUMAN`. // The observe beat (#254, chant#1647): chant reads the carved resource live // from the carveout, endpoint pointed at the scratch Floci. Same select // discipline as emit/bridge (membership in the report), refuses on a @@ -923,8 +988,22 @@ export function createApp( // the tab is open grows the ops stop on the next /api/project, without a // restart. const emittedOps = discoverOpIrs(estateDirs).length; + // #230 M3 (item 4): a real project whose directory carries `*.carve.json` + // manifests surfaces its carve state with none of the demo scaffolding — + // no report, no stepper, no write actions, just what chant recorded. Read + // at request time like `emittedOps` above, and gated on there being + // manifests at all, so a project that was never carved grows no dead panel. + // + // Deliberately NOT joined onto the graph. The manifest keys on a TERRAFORM + // address; these nodes key on chant entity names. Matching the two is the + // dedupe question #230's tier grading called unsolved, and getting it wrong + // means claiming two things are one resource. So this publishes what the + // manifest actually says — which Terraform addresses graduated, and how far + // the others got — beside the estate rather than painted onto it. + const carveState = carveStatePayload(readCarveStates(estateDirs)); return c.json({ projectDir: cfg.projectDir, + ...(carveState.manifests ? { carve: { state: carveState } } : {}), // #195: the full estate composition (multi-project serves) and the // switcher's recents, so the SPA's project section can show what's // loaded and offer where to go. Recents exclude nothing here — the SPA diff --git a/web/app.js b/web/app.js index b74ebf4..d209703 100644 --- a/web/app.js +++ b/web/app.js @@ -1064,6 +1064,55 @@ function primeDemoCatalog() { }); } +/** + * The manifest-backed carve state (#230 M3), as the scope panel shows it: the + * progress read, then one row per carved address with its stage. + * + * Stage is carried by the WORD, with the tone reinforcing it — green for + * graduated, the pending blue for a carve still in flight (the same blue + * pinhole paints `accent` cards with, so the panel row and the card agree). + * A no-op when the served estate carries no manifests: a project that was + * never carved grows no dead section. + * + * `chant carve apply` is echoed per row and never offered as a button — see + * src/carve-manifest.ts APPLY_IS_HUMAN, which `state.apply.note` carries here + * verbatim. + */ +function renderCarveState(host, state) { + if (!state || !state.manifests) return; + host.appendChild(panelHeading("carved out of terraform")); + const head = document.createElement("div"); + head.className = "count-row"; + const label = document.createElement("span"); + label.className = "grow"; + label.textContent = state.progress.label; + const tag = document.createElement("span"); + tag.className = "tag"; + tag.textContent = state.manifests + (state.manifests === 1 ? " manifest" : " manifests"); + tag.title = "chant's own *.carve.json state manifests — behold reads them and never writes one."; + head.append(label, tag); + host.appendChild(head); + if (state.progress.detail) host.appendChild(panelMuted(state.progress.detail)); + for (const s of state.states) { + const row = document.createElement("div"); + row.className = "count-row"; + const dot = document.createElement("span"); + dot.className = "dot"; + dot.style.background = s.graduated ? "var(--managed)" : "var(--pending)"; + const name = document.createElement("span"); + name.className = "grow"; + name.textContent = s.target; + name.title = s.note; + const stage = document.createElement("span"); + stage.className = "tag"; + stage.textContent = s.stage; + row.append(dot, name, stage); + host.appendChild(row); + if (!s.graduated) host.appendChild(panelMuted(s.applyCommand)); + } + host.appendChild(panelMuted(state.apply.note)); +} + function renderPanelScope() { const host = document.getElementById("tab-scope"); if (!host) return; @@ -1089,6 +1138,12 @@ function renderPanelScope() { host.appendChild(panelMuted(dir)); } if (!estateDirs.length) host.appendChild(panelMuted("no project loaded yet")); + // #230 M3: the strangler-fig progress, right where the panel already + // summarizes this estate's members. It reads off chant's own `*.carve.json` + // manifests (chant#998), so it says the same thing after a restart as before + // one — and it renders identically in carve mode and on an ordinary project + // serve, because /api/project publishes one shape for both. + renderCarveState(host, info.carve && info.carve.state); // #195: switching — recents first (server-persisted, validated), then a // free path input. Locked in preview mode (the demo's contract) and // meaningless in a static export. @@ -1417,6 +1472,27 @@ const carveActions = { /** Run one of the two safe steps. Both are POSTs with a `{select}` body; both * answer either their result or #193's `{error, code, remedy}`. */ +/** + * Re-read the manifest-backed carve state (#230 M3) after a step wrote one. + * + * One `/api/project` plus a graph reload: the panel's "carved so far" and the + * repainted card both come off the manifests on disk, so an in-session update + * and a post-restart read are the same read. A failed refresh leaves what was + * already shown alone — stale is better than a state change nobody made. + */ +async function refreshCarveState() { + try { + const info = await apiFetch("/api/project").then((r) => r.json()); + projectInfo = info; + carveInfo = info.carve || null; + } catch { + return; + } + renderPanelScope(); + renderPanelCarve(); + await load(); +} + async function runCarveStep(which) { if (carveState.busy || !carveState.pick) return; carveState.busy = which; @@ -1439,6 +1515,11 @@ async function runCarveStep(which) { // to the next button would hide the thing the run was for. The "next" // control unlocks; pressing it stays the viewer's move. showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true); + // #230 M3: the step just wrote a carve manifest. Re-read it rather than + // inferring the new state from the response — the file on disk is what + // the panel and the card claim to be showing, and after a reload it is + // the only thing left. + await refreshCarveState(); } } catch (err) { carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" }; diff --git a/web/carve-steps.js b/web/carve-steps.js index 06a506a..6514d1f 100644 --- a/web/carve-steps.js +++ b/web/carve-steps.js @@ -280,6 +280,30 @@ export function lintVerdict(lint) { return { tone: "bad", text: `chant lint exited ${lint.code} — the emitted source doesn't pass yet` }; } +/** + * The carve state manifest for one address, off `/api/project`'s + * `carve.state` (#230 M3). Null when nothing has been carved for it — which is + * the honest reading before Emit runs, and the reading a fresh clone gives. + */ +export function manifestStateFor(carveState, address) { + const states = (carveState && carveState.states) || []; + return states.find((s) => s.target === address) || null; +} + +/** + * A carve's recorded stage as one line + a tone. + * + * `applied` is the only green: it is the only stage at which ownership has + * actually moved. `emitted` and `bridged` are real progress and read as such + * (the pending blue, the same tone the card carries), but they are work in + * flight, not a graduation — and the line says which, in words, because tone + * alone was never allowed to carry a state here. + */ +export function manifestStageLine(state) { + if (!state) return null; + return { tone: state.graduated ? "good" : "pending", text: `${state.target}: ${state.note}` }; +} + /** * The observe verdict as one line + a tone (chant#1647). `observed` with * EXTERNAL/foreign IS the beat: live, outside every stack, still Terraform's — @@ -372,6 +396,18 @@ function artifactBlock(a, actions) { return wrap; } +/** A dot + a line, tone reinforcing the words (never replacing them). `good` is + * the managed green, `pending` the in-flight blue pinhole paints `accent` cards + * with, anything else the degraded red. */ +function toneRow(tone, text, tag) { + const row = el("div", "count-row"); + const dot = el("span", "dot"); + dot.style.background = tone === "good" ? "var(--managed)" : tone === "pending" ? "var(--pending)" : "var(--degraded)"; + row.append(dot, el("span", "grow", text)); + if (tag) row.appendChild(el("span", "tag", tag)); + return row; +} + function refusalBlock(err) { const wrap = el("div", "carve-refusal"); wrap.appendChild(el("div", "carve-refusal-title", err.error || "that step didn't run")); @@ -450,6 +486,16 @@ function renderAdvise(body, ctx) { row.append(dot, el("span", "grow", meaning), el("span", "tag", String(bands[band] ?? 0))); body.appendChild(row); } + // #230 M3: what the estate has ALREADY carved, off chant's own state + // manifests rather than off this session. Reload the page mid-walkthrough and + // this is unchanged, because the manifests on disk are unchanged. + const carveState = ctx.carve && ctx.carve.state; + if (carveState && carveState.manifests) { + body.appendChild(el("h3", null, "carved so far")); + body.appendChild(toneRow("good", carveState.progress.label, `${carveState.manifests} manifest(s)`)); + for (const s of carveState.states) body.appendChild(toneRow(s.graduated ? "good" : "pending", s.target, s.stage)); + if (carveState.progress.detail) body.appendChild(el("p", "panel-muted", carveState.progress.detail)); + } if (ctx.carve && ctx.carve.advisory) body.appendChild(el("p", "panel-muted", ctx.carve.advisory)); if (ctx.demo) { body.appendChild( @@ -669,6 +715,32 @@ function renderHandoff(body, state, ctx, actions) { if (c.note) body.appendChild(el("p", "panel-muted carve-cmd-note", c.note)); } + // The graduation step (#230 M3). `chant carve apply` resolves the ownership + // marker that makes chant the owner of a live resource, and it composes off + // the carve manifest — no --select needed, the emit left the target there. + // + // It is echoed here for the same reason `terraform state rm` is: this is + // where the temptation to add a button lives. There is no apply endpoint on + // the server and no apply button here, and neither is a gap waiting to be + // filled — behold triggers delegated work, it does not decide when an estate + // changes hands. What the manifest RECORDS about an apply is rendered (the + // Done step, and the Advise step's "carved so far"); the trigger is a person. + const applyState = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id); + if (applyState) { + body.appendChild(el("h3", null, "graduation")); + const line = manifestStageLine(applyState); + body.appendChild(toneRow(line.tone, line.text)); + if (!applyState.graduated) { + // Its own class, not the runbook's: this row is chant's command, not one + // of the runbook's terraform lines, and the two are counted separately. + const row = el("div", "prow carve-cmd-row carve-apply-row"); + row.appendChild(el("code", "grow carve-cmd-text", applyState.applyCommand)); + row.appendChild(copyButton("copy", applyState.applyCommand, actions)); + body.appendChild(row); + body.appendChild(el("p", "panel-muted carve-cmd-note", (ctx.carve.state.apply && ctx.carve.state.apply.note) || "")); + } + } + const ran = el("button", "act carve-ran", state.handoff ? "✓ marked as run" : "I ran these →"); ran.title = "Marks the handoff done in this walkthrough. behold has not checked — it can't, and won't pretend to."; ran.addEventListener("click", actions.markHandoff); @@ -693,6 +765,13 @@ function renderDone(body, state, ctx, actions) { ); body.appendChild(card); + // #230 M3: the durable half of the ending. The card above is the shape of the + // walkthrough; this is what chant's manifest actually records — and it is + // still here after a restart, when everything in `state` is gone. + const recorded = manifestStateFor(ctx.carve && ctx.carve.state, state.pick && state.pick.node.id); + const line = manifestStageLine(recorded); + if (line) body.appendChild(toneRow(line.tone, line.text, recorded.stage)); + if (!state.handoff) { body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate.")); } diff --git a/web/carve-steps.test.js b/web/carve-steps.test.js index b130378..09999d5 100644 --- a/web/carve-steps.test.js +++ b/web/carve-steps.test.js @@ -15,6 +15,8 @@ import { edgeLine, initialCarveState, lintVerdict, + manifestStageLine, + manifestStateFor, observeSummary, pickFacts, runbookCommands, @@ -277,3 +279,68 @@ describe("observeSummary", () => { expect(lintVerdict(null)).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// #230 M3 — the manifest-backed state the panel reads off /api/project. +// --------------------------------------------------------------------------- + +const CARVE_STATE = { + manifests: 2, + progress: { + applied: 1, + bridged: 0, + emitted: 1, + inFlight: 1, + total: 12, + label: "1 of 12 carved", + detail: "1 emitted — apply is yours", + }, + states: [ + { + target: "aws_iam_role.api", + stage: "emitted", + graduated: false, + note: "emitted — typed chant source exists, and the surviving Terraform still reads the resource directly. `carve bridge` is next.", + applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env --stack --write-source", + }, + { + target: "aws_s3_bucket.assets", + stage: "applied", + graduated: true, + note: "graduated — `carve apply` recorded the ownership marker (stack assets, env prod). chant owns it.", + applyCommand: "chant carve apply --from legacy-tf --output app/carveout --env prod --stack assets --write-source", + }, + ], + apply: { human: true, note: "behold has no apply endpoint and no apply button, by design." }, +}; + +describe("manifestStateFor", () => { + it("finds the carve recorded for an address", () => { + expect(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets").stage).toBe("applied"); + }); + + it("is null for an address nothing has been carved for — the honest read before Emit", () => { + expect(manifestStateFor(CARVE_STATE, "aws_vpc.main")).toBeNull(); + expect(manifestStateFor(null, "aws_s3_bucket.assets")).toBeNull(); + expect(manifestStateFor(CARVE_STATE, null)).toBeNull(); + }); +}); + +describe("manifestStageLine", () => { + it("greens only the graduated one — the only stage at which ownership moved", () => { + const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_s3_bucket.assets")); + expect(line.tone).toBe("good"); + expect(line.text).toContain("chant owns it"); + }); + + it("paints a partial carve as in flight, and says which stage in words", () => { + const line = manifestStageLine(manifestStateFor(CARVE_STATE, "aws_iam_role.api")); + expect(line.tone).toBe("pending"); + expect(line.text).toContain("aws_iam_role.api"); + expect(line.text).toContain("still reads the resource directly"); + }); + + it("is null with nothing recorded — no manifest, no claim", () => { + expect(manifestStageLine(null)).toBeNull(); + }); +});