From 837757c7c0dcdd45bf11b169731f25a4336fca34 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:43:39 -0600 Subject: [PATCH 01/10] fix(license): Apache-2.0, declared and shipped, and the export carries the notices it redistributes (#394) @intentius/behold has been on npm since 0.17.0 with no grant anywhere: no LICENSE, no package.json field, nothing in the tarball. Apache-2.0 to match chant, which behold is a control plane on. LICENSE, THIRD_PARTY.md and licenses/ join the files list and are asserted there by test, and `behold export` copies them into the bundle, since whoever serves an export redistributes the vendored themes, artwork and icons. Republishing so the registry metadata stops being blank is a release, not this commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- LICENSE | 190 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- src/export.test.ts | 24 +++++- src/export.ts | 37 ++++++++- 4 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e42e966 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Intentius + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/package.json b/package.json index 976d8df..94a967b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "@intentius/behold", "version": "0.17.0", + "license": "Apache-2.0", "type": "module", "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.", "bin": { @@ -17,7 +18,10 @@ "example-carve", "example-choudoufu-estate", "demos.json", - "AGENTS.md" + "AGENTS.md", + "LICENSE", + "THIRD_PARTY.md", + "licenses" ], "publishConfig": { "access": "public", diff --git a/src/export.test.ts b/src/export.test.ts index e587ed4..7cac7dc 100644 --- a/src/export.test.ts +++ b/src/export.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { canonicalKey, captureKeys } from "./export.ts"; +import { canonicalKey, captureKeys, copyNotices } from "./export.ts"; describe("canonicalKey", () => { it("is independent of param order (sorted by the lens whitelist)", () => { @@ -108,3 +108,25 @@ describe("the ops lens in a static bundle (#284)", () => { expect(captureKeys({ environments: ["local"], tiers: [], ops: 0 }).some((k) => k.includes("ops=1"))).toBe(false); }); }); + +describe("copyNotices — the export carries the grants it redistributes (#394)", () => { + it("copies LICENSE, THIRD_PARTY.md and every file under licenses/ into the bundle", async () => { + const { mkdtempSync, existsSync, readdirSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const out = mkdtempSync(join(tmpdir(), "behold-export-notices-")); + const copied = copyNotices(out); + expect(copied).toContain("LICENSE"); + expect(copied).toContain("THIRD_PARTY.md"); + expect(copied.filter((f) => f.startsWith("licenses/")).length).toBeGreaterThanOrEqual(3); + expect(existsSync(join(out, "LICENSE"))).toBe(true); + expect(readdirSync(join(out, "licenses")).length).toBe(copied.filter((f) => f.startsWith("licenses/")).length); + }); + + it("the package ships them too: LICENSE, THIRD_PARTY.md and licenses/ are in package.json files, and the grant is declared", async () => { + const { readFileSync } = await import("node:fs"); + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { license?: string; files: string[] }; + expect(pkg.license).toBe("Apache-2.0"); + for (const f of ["LICENSE", "THIRD_PARTY.md", "licenses"]) expect(pkg.files).toContain(f); + }); +}); diff --git a/src/export.ts b/src/export.ts index 7225dd3..c26c8fc 100644 --- a/src/export.ts +++ b/src/export.ts @@ -10,7 +10,7 @@ * (reclassify / prune / value-match / composite-deps / radial all included), no * logic duplicated. */ -import { mkdirSync, writeFileSync, copyFileSync, readFileSync, readdirSync } from "node:fs"; +import { mkdirSync, writeFileSync, copyFileSync, readFileSync, readdirSync, existsSync } from "node:fs"; import { join, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; import { createApp, type ServerOptions } from "./server.ts"; @@ -102,6 +102,35 @@ function webDir(): string { return join(dirname(fileURLToPath(import.meta.url)), "..", "web"); } +/** The package root: where LICENSE, THIRD_PARTY.md and licenses/ sit, one + * level above web/ and dist/ alike. */ +function pkgRoot(): string { + return join(dirname(fileURLToPath(import.meta.url)), ".."); +} + +/** The notice files an export carries (#394): behold's own LICENSE, the + * third-party summary, and every vendored licence under licenses/. Exported + * for the test; a missing file is skipped rather than fatal, because a + * checkout mid-edit must still export. */ +export const NOTICE_FILES = ["LICENSE", "THIRD_PARTY.md"] as const; +export function copyNotices(outDir: string, root: string = pkgRoot()): string[] { + const copied: string[] = []; + for (const f of NOTICE_FILES) { + if (!existsSync(join(root, f))) continue; + copyFileSync(join(root, f), join(outDir, f)); + copied.push(f); + } + const lic = join(root, "licenses"); + if (existsSync(lic)) { + mkdirSync(join(outDir, "licenses"), { recursive: true }); + for (const f of readdirSync(lic)) { + copyFileSync(join(lic, f), join(outDir, "licenses", f)); + copied.push(`licenses/${f}`); + } + } + return copied; +} + /** A Cloudflare Worker name: lowercase, alnum + hyphens, ≤ 63 chars. */ function workerName(project: string, override?: string): string { const raw = override ?? `behold-${basename(project)}`; @@ -164,6 +193,12 @@ export async function runExport(cfg: ServerOptions, outDir: string, opts: { name copyFileSync(join(webDir(), f), join(outDir, f)); } writeFileSync(join(outDir, "README.md"), BUNDLE_README); + // #394: the bundle redistributes what the SPA vendors — the iTerm2 colour + // schemes in themes.js, the CNCF artwork and Kubernetes icons under + // web/icons — so the notices travel with it. Whoever serves an export is + // the redistributor, and the notices left behind in this repo would not + // reach them. behold's own grant rides along for the same reason. + copyNotices(outDir); // Deploy-ready: an assets-only Cloudflare Worker config (no server code — the // bundle is pure static), so `cd && wrangler deploy` hosts it on From 7a964366f77e80b9c48760645a0421041d18a276 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:44:30 -0600 Subject: [PATCH 02/10] =?UTF-8?q?fix(ui):=20the=20audit's=20zoom,=20note?= =?UTF-8?q?=20and=20console=20fixes=20=E2=80=94=20boot=20on=20resources=20?= =?UTF-8?q?for=20non-chant=20estates,=20runtime=20only=20where=20it=20appl?= =?UTF-8?q?ies,=20no=20500=20on=20/api/resources,=20the=20roots=20note=20e?= =?UTF-8?q?verywhere=20it=20belongs=20(#393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of #393's findings, all of them things a person meets in the first screen of a choudoufu or Terraform estate. `/api/project` grows two facts, and the SPA reads both before its first fetch: - `memberKinds` — the served members' kinds, in composition order. When none of them is `chant` the SPA opens on `resources`. `components` is a chant projection of a chant project's own component DAG, so a choudoufu estate or a bare Terraform directory opened on "the components lens doesn't apply to a composed estate yet" — an apology, every time. #182's zero-node fallback still covers a chant project that declares no components, and picking components later still gets the honest note: it now leads the note instead of being displaced by the estate's own. - `runtimeCapable` — whether any member declares a lexicon whose live read has an owner-reference chain to descend (`RUNTIME_LEXICONS`: `k8s`). chant paints a node `runtime` only where it stamps `runtimeOwner`, and that is a Kubernetes mechanism, so the stop on every choudoufu estate could only ever answer "nothing below the declaration boundary". The palette and the View tab read one list, so a stop is offered on both surfaces or on neither. `/api/resources` answers `{byComponent: {}}` for a primary that is not a chant member instead of shelling chant at an estate chant cannot read — that 500 (`No lexicon detected`) was in the console on every choudoufu boot. The same shape carve mode returns, and the one the SPA already reads as "no facet here". The roots note (#384) is built by one function in `/api/graph` and returned by all three of its branches. The logical lens returns before the bottom of the route, so the one line that explained a Terraform picture vanished at exactly the zoom that needed it most. Both notes come in two forms now: `note` as before and `noteShort` (`5 roots · 2 skipped · 189 blocks not drawn`) for the 260px strip, with the long one on the strip's tooltip and printed whole on the Model tab. The server writes both — the SPA authors no notes, and truncating this one on a sentence boundary would keep the list of root names and drop the counts. `groupTerraformByRoot` drops a box every node has left rather than drawing it empty: a served directory arrives composed, `composeStacks` boxes the whole member, the root boxes then take those same nodes, and a node sits in exactly one box — which is what put an empty box named `access` on water park's canvas. Nesting was not on offer (`boxes: "byStack"` is flat; only the architecture lens nests), so what the member box was there to say moves into the root box's title: `/` when the estate holds more than one member, the bare root name when it does not — two members with a root apiece named `prod` would otherwise merge silently. And an estate of one member reads "estate of 1 project". The page declares its own icon as a data URI too: "nothing in the console" included the 404 every load had always fetched for itself. Verified against the real estates, not only the stub: `behold serve ../waterpark/access` (5 roots, 58 cards, 219 at attributes) and the `choudoufu-cohort-iam-ecr` workbench entry over a scratch floci, driven in headless Chrome — the boot zoom, both zoom surfaces, the strip, the tooltip, the Model tab, the box list and a clean console on each. `smoke/stub.mjs` grows a third mode (`nonChant`) carrying water park's own note, so `npm run smoke:ui` asserts what that driver asserted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- AGENTS.md | 42 ++++++++++- smoke/stub.mjs | 59 +++++++++++++++- smoke/ui-smoke.mjs | 65 +++++++++++++++++ src/choudoufu-route.test.ts | 18 +++++ src/server.ts | 136 +++++++++++++++++++++++++++++++----- src/terraform-lens.test.ts | 68 ++++++++++++++++++ src/terraform-lens.ts | 71 ++++++++++++++++--- src/terraform-member.ts | 10 +++ src/terraform-route.test.ts | 77 ++++++++++++++++++++ web/app.js | 65 +++++++++++++++-- web/index.html | 8 +++ 11 files changed, 585 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8efc715..9d64941 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -404,6 +404,29 @@ until the table existed. A new kind is: string is `chant`. Anything behold boots for a kind goes through `assertScratch` first. +**What the first screen owes a non-chant member (#393).** Three chant-shaped +things used to be offered to every member whatever it was, and a new kind gets +all three answered for free: + +- `/api/project` publishes `memberKinds` — the served members' kinds, in + composition order. The SPA opens on the `resources` zoom when none of them is + `chant`, because `components` is a projection of a chant project's own + component DAG and a member without one opens on "the components lens doesn't + apply to a composed estate yet". #182's zero-node fallback still covers a + chant project that declares no components. +- `/api/project` publishes `runtimeCapable`, and the palette and the View tab + offer `zoom: runtime` only then. The tier below the declaration boundary is + the owner-referenced children chant stamps `runtimeOwner` on, and only a + Kubernetes read has an owner chain to resolve — `RUNTIME_LEXICONS` in + `src/server.ts` is the one word (`k8s`), read off the members' DECLARED + lexicons so the stop exists or not before anyone picks it. +- A chant-only facet answers emptily rather than 500ing for a non-chant + primary: `/api/resources` returns `{byComponent: {}}`, the shape carve mode + already returns and the SPA already reads as "no resource facet here". + +A kind that grows components, or a substrate with an owner chain, changes those +answers where they are decided — never per kind in a route. + ### The workbench catalog `demos.json` is the catalog that ships. `workbench.json`, read beside it and @@ -524,7 +547,14 @@ the identical object back: the block class lands in `attrs.block`. That is the shape a carve node already has, which is why one presentation pack serves both. 2. **`groupTerraformByRoot`** — roots are a Terraform project's only grouping. - It retires itself when chant#2266 groups upstream. + It retires itself when chant#2266 groups upstream. A node sits in exactly one + box, so a box every node has left is dropped rather than drawn empty (#393): + a served directory arrives composed, `composeStacks` boxes the whole member, + and the root boxes then take those same nodes — which is what put an empty + box named `access` on water park's canvas. What the member box was there to + say moves into the root box's title, which is `/` whenever the + estate holds more than one member and the bare root name when it does not + (two members with a root apiece named `prod` would otherwise merge silently). 3. **`filterTerraformCards`** — what is a card, below. **What is a card (#382).** Measured on a real estate: 247 nodes for 43 @@ -539,6 +569,16 @@ resources, four fifths of it not infrastructure. Nothing is dropped silently: `terraformElisionNote` says what is not drawn and where to see it, the way `edgelessNote` says why a view has no edges. +**The note, at every zoom and in a 260px strip (#393).** The roots note and the +elision note are built by one function in `/api/graph` and returned by all three +of its branches, the logical lens included — it was the lens that most needed +the line and the only one that dropped it. Both routes send `note` and, when +there is a shorter true form, `noteShort` (`5 roots · 2 skipped · 189 blocks not +drawn`). The strip shows the short one with the long one on its tooltip, and the +panel's Model tab prints it whole. The server writes both: the SPA does not +author notes, and truncating this one on a sentence boundary would keep the list +of root names and drop the counts. + **Do not invent edges.** They arrive from chant or not at all: the fixtures here were recorded when a Terraform IR carried none, and lexicon 0.61.0 (chant#2265, which resolves a block's `"${…}"` references) draws 390 over water park's five diff --git a/smoke/stub.mjs b/smoke/stub.mjs index be1c5f8..1f84b2b 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -259,6 +259,11 @@ const JSON_ROUTES = { targets: [{ endpoint: "http://localhost:4566" }], tier: "dev", target: "http://localhost:4566", + // #393 item 2: the runtime stop exists here — this estate declares `k8s`, + // whose live read is the only one with an owner-reference chain to descend. + // `nonChant` below is the estate that does not, and must not be offered it. + runtimeCapable: true, + memberKinds: ["chant"], // #284: the ops zoom stop only exists once the estate has emitted Ops. The // count is what opens it — and it is also what the operator strip (#234) // needs on screen, since the strip lives on that lens. @@ -431,7 +436,37 @@ export const CARVE_BRIDGE = { * records what the page sent, so the smoke can assert the wire contract * (a JSON body carrying the picked address) and not just the pixels. */ -export function startStub(port, { carve = false } = {}) { +/** + * #393 — a served estate whose members are NOT chant projects (a choudoufu + * estate, a bare Terraform directory). Three things about it differ from every + * other project the SPA has seen, and all three were audit findings: + * + * - it has no components to project, so the boot zoom must be `resources` + * rather than a first screen that apologises; + * - it declares an env (`live`) but no substrate with an owner chain, so the + * `runtime` stop must not be offered even though an env is picked; + * - its note is a paragraph, so the strip gets `noteShort` and the long form + * stays on the tooltip and the Model tab. + * + * The note is water park's own, verbatim from `/api/graph` over + * `../waterpark/access`. + */ +const NON_CHANT_NOTE = + "5 roots — baseline, prod, github, identity, waterpark-runner; skipped backends (no resource, data or module block — nothing to draw), " + + "modules/persona (called as a module, never applied on its own); showing the estate — 108 variables, 53 outputs, 14 terraform blocks, " + + "10 locals blocks, 4 providers not drawn (outputs and variables appear at detail 3 — ⌘K → attributes)"; +const NON_CHANT_NOTE_SHORT = "5 roots · 2 skipped · 189 blocks not drawn"; +const NON_CHANT_PROJECT = { + projectDir: "/estates/waterpark/access", + recents: [], + environments: ["live"], + lexicons: [], + currentEnv: "live", + targets: [], + memberKinds: ["terraform"], +}; + +export function startStub(port, { carve = false, nonChant = false } = {}) { // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a // `writable` flag on the read), so the smoke drives the client's whole sync @@ -539,6 +574,28 @@ export function startStub(port, { carve = false } = {}) { if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a carve report isn't a project", deltas: {} }); } + if (nonChant) { + const json = (body) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/api/project") return json(NON_CHANT_PROJECT); + // The same cards the ordinary stub paints — what differs is the meta, + // which is the whole point here: one member, a long note, a short one. + if (path === "/api/graph" || path === "/api/overlay") { + return json({ + ir: irFor("live"), + svg, + meta: { projectDir: NON_CHANT_PROJECT.projectDir, env: "live", tier: null, target: null, estate: 1, note: NON_CHANT_NOTE, noteShort: NON_CHANT_NOTE_SHORT }, + }); + } + if (path === "/api/substrates") return json({ substrates: [] }); + if (path === "/api/resources") return json({ byComponent: {} }); + if (path === "/api/ci") return json({ stages: [], jobs: [], forge: null }); + if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); + if (path === "/api/history") return json({ commits: [] }); + if (path === "/api/demos") return json({ demos: [] }); + } if (path === "/api/layout") { res.writeHead(200, { "content-type": "application/json" }); if (req.method === "POST") { diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index b113fff..a1b594f 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -1054,6 +1054,71 @@ try { carveServer.close(); } + // ---- #393: an estate with no chant member ------------------------------- + // A third stub (smoke/stub.mjs `{nonChant: true}`) serving what the audit + // measured on water park and on every choudoufu estate: one member, no + // components to project, an env with no owner chain under it, and a note that + // does not fit a 260px strip. Four of the audit's findings are visible in the + // first screen alone, so the first screen is what this drives. + const wbServer = await startStub(PORT + 2, { nonChant: true }); + const wbPage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const wbErrors = []; + wbPage.on("pageerror", (e) => wbErrors.push(String(e))); + wbPage.on("console", (m) => { + if (m.type() === "error" && !/favicon/i.test(m.text())) wbErrors.push(m.text()); + }); + try { + // The main stub's estate DOES declare k8s, so the stop it must keep is + // asserted here too — a gate that hides the stop everywhere is not a fix. + await page.click('#panel-tabs button[data-tab="view"]'); + await page.waitForTimeout(100); + check("a k8s estate with an env keeps the runtime zoom", (await page.locator("#panel-zoom button", { hasText: /^runtime$/ }).count()) === 1); + + await wbPage.goto(`http://localhost:${PORT + 2}/`); + await wbPage.waitForSelector("#graph svg [data-node-id]", { timeout: 20000 }); + + // Item 1: it opens on a picture, not on "the components lens doesn't apply + // to a composed estate yet". + check("an estate with no chant member boots on resources", (await wbPage.locator("#statusbar").innerText()).startsWith("zoom: resources")); + + // Item 2: no runtime stop, on either surface — the palette and the View tab + // read the same list, and a stop must not appear in one and not the other. + await wbPage.click('#panel-tabs button[data-tab="view"]'); + await wbPage.waitForTimeout(100); + const zooms = await wbPage.locator("#panel-zoom button").allInnerTexts(); + check("the View tab offers no runtime zoom", !zooms.includes("runtime")); + check("…and still offers the granularity stops", ["components", "logical", "composites", "resources", "attributes"].every((z) => zooms.includes(z))); + await wbPage.click("#hintk"); + await wbPage.fill("#pal-input", "zoom"); + await wbPage.waitForTimeout(100); + const rows = await wbPage.locator("#pal-list .row").allInnerTexts(); + check("⌘K offers no runtime zoom either", !rows.some((r) => /zoom: runtime/.test(r))); + await wbPage.keyboard.press("Escape"); + + // Item 7: the strip carries the short form, the long one is a hover away, + // and the Model tab prints it whole. + const strip = await wbPage.locator("#statusbar").innerText(); + check("the strip shows the note's short form", strip.includes("5 roots · 2 skipped · 189 blocks not drawn")); + check("…and not the paragraph", !strip.includes("modules/persona")); + check("the full note is on the strip's tooltip", (await wbPage.locator("#statusbar .statusbar-note").getAttribute("title")).includes("modules/persona (called as a module")); + await wbPage.click('#panel-tabs button[data-tab="model"]'); + await wbPage.waitForTimeout(100); + check("the Model tab prints the note in full", (await wbPage.locator("#tab-model").innerText()).includes("108 variables, 53 outputs")); + + // Item 4: one member is a project. + const metaLine = await wbPage.locator("#meta").innerText(); + check("a lone member reads 'estate of 1 project'", metaLine.includes("estate of 1 project") && !metaLine.includes("1 projects")); + + // Item 3, from the browser's own side: nothing red in the console on the + // way to that first screen. + check("no console errors on a non-chant estate's boot", wbErrors.length === 0); + if (wbErrors.length) console.error("non-chant page errors:", wbErrors); + await wbPage.screenshot({ path: join(SHOTS, "9-non-chant-estate.png") }); + } finally { + await wbPage.close(); + wbServer.close(); + } + check("no page errors", pageErrors.length === 0); if (pageErrors.length) console.error("page errors:", pageErrors); } finally { diff --git a/src/choudoufu-route.test.ts b/src/choudoufu-route.test.ts index fdb4272..c3ec63b 100644 --- a/src/choudoufu-route.test.ts +++ b/src/choudoufu-route.test.ts @@ -101,6 +101,24 @@ describe("GET /api/choudoufu/moves (#371)", () => { expect(notOne.status).toBe(422); }); + // #393 items 1, 2 and 3: what a choudoufu estate's first screen asked for, + // and what it was told. The estate here is the two-member one above — no + // chant member, no k8s lexicon, no components to project. + it("says its members are choudoufu, offers no runtime zoom, and answers /api/resources empty", async () => { + const { app } = served(); + + const info = (await (await app.request("/api/project")).json()) as { memberKinds: string[]; runtimeCapable?: boolean }; + expect(info.memberKinds).toEqual(["choudoufu"]); + expect(info.runtimeCapable).toBeUndefined(); + + // Before this the panel's resources facet asked chant for the component + // resources of an estate chant cannot read, and every boot logged a 500 + // (`No lexicon detected`) in the browser console. + const res = await app.request("/api/resources?env=live"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ byComponent: {} }); + }); + it("is advertised on /api, and /api/project lists the plans found", async () => { const { app } = served(); const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> }; diff --git a/src/server.ts b/src/server.ts index d53e8d2..109a62a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -138,12 +138,13 @@ import { hasTerraformEntities, normalizeTerraformNodes, terraformElisionNote, + terraformElisionNoteShort, type TerraformElision, } from "./terraform-lens.ts"; import { choudoufuDiffNodes, readChoudoufuLive, type Runner as ChoudoufuRunner } from "./choudoufu-live.ts"; import { discoverCarvePlans, moveMembers, moveReceipt, movesPayload, readCarvePlan, type MoveMorphMoveInput } from "./choudoufu-moves.ts"; import { memberKindOf, memberKindSpec, servesAsEstate } from "./member-kind.ts"; -import { TerraformReadError, discoverTerraformRoots, terraformRootsNote } from "./terraform-member.ts"; +import { TerraformReadError, discoverTerraformRoots, terraformRootsNote, terraformRootsNoteShort } from "./terraform-member.ts"; import { invalidateMember, memberIr } from "./member-ir.ts"; import { carveStatesFor, carveStatesUnder } from "./carve-discovery.ts"; import { foreignNote, type GraphIRWithForeign } from "./foreign.ts"; @@ -848,6 +849,24 @@ async function readoptDispatchedRun( return { outcome: "readopted", run }; } +/** + * The lexicons whose live read can report a node BELOW the declaration + * boundary (#86, #393). chant paints a node `runtime` only when it carries + * `runtimeOwner` — the declared entity a live object's OWNER-REFERENCE chain + * resolves to (chant#1077) — and owner references are a Kubernetes API + * mechanism. Every other substrate behold reads (AWS, Azure, GCP, Fly, Helm's + * releases, a Terraform root, a choudoufu estate) has no owner chain at all, so + * the `runtime` stop there could only ever answer "nothing below the + * declaration boundary": a stop that exists to be refused. `/api/project` + * publishes whether any member declares one of these, and the SPA's palette and + * View tab offer the stop only then. + * + * Kustomize and Helm declare Kubernetes objects, but a chant project that + * reaches a cluster to read them declares `k8s` as well — that is what the + * cluster connection lives on — so one word is enough. + */ +const RUNTIME_LEXICONS = new Set(["k8s"]); + /** * The three Terraform passes (#379, #380, #382), in the one order they make * sense: name the cards, box them by root, then drop what is not estate at this @@ -1356,6 +1375,31 @@ export function createApp( (cfg.projectDirs ?? [cfg.projectDir]).filter((d) => memberKindOf(d) === "choudoufu"), cfg.projectDir, ); + // #393 item 1: what each served member IS, in composition order — the one + // fact that decides where the SPA can honestly BOOT. `components` is a + // chant projection of a chant project's own component DAG (`chant graph + // --components`); a member that is not a chant project has no components to + // project, so the boot zoom lands on an apology ("the components lens + // doesn't apply to a composed estate yet") for every choudoufu and + // Terraform estate. #182's fallback only fires on a zero-node answer, i.e. + // after the round-trip. The SPA reads this and boots on `resources` + // instead; an explicit later pick of components still gets the honest note. + // An unclaimed directory reads `chant`, exactly as `estateMembers` resolves + // it — chant is the reader for a directory no kind claims. + const memberKinds = [...new Set(estateMembers(estateDirs).map((m) => m.kind))]; + // #393 item 2: whether `runtime` is a zoom this estate can answer. The + // tier below the declaration boundary is the owner-referenced children a + // substrate maintains — chant stamps `runtimeOwner` on exactly those + // (src/overlay.ts), and only a Kubernetes read has an owner-reference chain + // to resolve: AWS, Azure, GCP, Terraform and choudoufu have none, so the + // stop was offered on every choudoufu estate and always answered "nothing + // below the declaration boundary". Read off the DECLARED lexicons of every + // member, not off a live read: a stop must exist or not before anyone picks + // it. Chant members only — a non-chant kind declares no lexicons at all. + const memberLexicons = await Promise.all( + estateDirs.map((d) => (memberKindOf(d) === "chant" ? detectProject(d).then((p) => p.lexicons).catch(() => []) : Promise.resolve([] as string[]))), + ); + const runtimeCapable = memberLexicons.flat().some((l) => RUNTIME_LEXICONS.has(l)); return c.json({ projectDir: cfg.projectDir, ...(carveState.manifests ? { carve: { state: carveState } } : {}), @@ -1404,6 +1448,11 @@ export function createApp( // stop and no dead ⌘K entry. The count, not a bare flag — the SPA shows // it, and "ops: 0" is not a thing this can ever say. ...(emittedOps ? { ops: emittedOps } : {}), + // #393: what the served members are, and whether `runtime` applies to any + // of them. Both gated exactly like `ops` above — a flag that is only ever + // true is a flag the SPA can read as "absent means no". + memberKinds, + ...(runtimeCapable ? { runtimeCapable: true } : {}), targets: deployTargets(lexicons, k8sTarget), // Where the k8s binding came from, so the SPA never implies behold chose // it (#106). Absent for a project with no k8s lexicon or no resolvable @@ -1897,6 +1946,31 @@ export function createApp( const ownKindVia = multi ? undefined : memberKindSpec(memberKindOf(cfg.projectDir) ?? "chant")?.via; // #382: what the Terraform zoom filter elided, when the estate branch ran it. let estateTfElision: TerraformElision = { dropped: {}, total: 0 }; + // #384: a served Terraform directory says which roots it found and which + // directories of `.tf` it skipped, so a root missing from the picture is + // visible here rather than by counting boxes. Ahead of the elision note, + // which is about the same estate's zoom: what is drawn, then what isn't. + // A served directory normally arrives composed (#389), so the terraform + // members are read off the composed list; the lone `projectDir` case is a + // caller that set it alone. + // + // #393 items 5 and 7. Item 5: this used to be built at the bottom of the + // route, which the logical branches return above — so the one note that + // explained the picture vanished at exactly the zoom that most needed it. + // It is a function now, called from all three. Item 7: the long form is + // ~60 words and repeats at every zoom in a 260px panel, so the SERVER + // sends both. The server, not the SPA truncating on a sentence boundary, + // because the counts are the server's own — the first sentence IS the + // list of root names, so cutting at the first period keeps the longest + // clause and drops the elision entirely, and the SPA would have to + // re-derive `5 roots · 2 skipped` from prose it did not write. The rule + // src/zoom-notes.ts states holds: the server decides what a note says. + const terraformNotes = (elision: TerraformElision): { note?: string; noteShort?: string } => { + const scans = (multi ? cfg.projectDirs! : [cfg.projectDir]).filter((d) => memberKindOf(d) === "terraform").map((d) => discoverTerraformRoots(d)); + const note = [...scans.map((s) => terraformRootsNote(s)), terraformElisionNote(elision, opts.detail)].filter(Boolean).join("; "); + const noteShort = [...scans.map((s) => terraformRootsNoteShort(s)), terraformElisionNoteShort(elision)].filter(Boolean).join(" · "); + return note ? { note, ...(noteShort && noteShort !== note ? { noteShort } : {}) } : {}; + }; let ir: GraphIR; let mode: "component-status" | undefined; let metaEnv = cfg.env ?? null; @@ -1959,7 +2033,15 @@ export function createApp( // KEYS the projection just minted — see src/operator.ts's // `operatorHomeBoxMarks`. const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(ir, namespaceBoxes ?? {}), groupBadges: operatorHomeBoxBadges(ir, namespaceBoxes ?? {}) }); - const logicalNote = notesFor("logical", projected, undefined, logicalBefore); + // #393 item 5: the roots note rides the logical lens too. The lens + // re-projects the same cards into the same boxes it was given, so + // which roots those are is no less true here than at `resources` — + // and on a Terraform estate it is the only line that explains the + // picture at all. + const tf = terraformNotes(estateTfElision); + const lensNote = notesFor("logical", projected, undefined, logicalBefore); + const logicalNote = [tf.note, lensNote].filter(Boolean).join(" · "); + const logicalNoteShort = tf.note ? [tf.noteShort ?? tf.note, lensNote].filter(Boolean).join(" · ") : undefined; return c.json({ ir: projected, svg, @@ -1972,6 +2054,7 @@ export function createApp( mode: "logical", estate: cfg.projectDirs!.length, ...(logicalNote ? { note: logicalNote } : {}), + ...(logicalNoteShort && logicalNoteShort !== logicalNote ? { noteShort: logicalNoteShort } : {}), }, }); } @@ -2046,7 +2129,7 @@ export function createApp( const base = addClusterAnchorEdges(addValueMatchEdges(addK8sDeclaredEdges(raw)), logicalContext); // #379/#380/#382: name and box the Terraform cards before the lens // projects them, so its own boxes hold cards rather than block classes. - applyTerraformPasses(base, opts.detail); + const singleTfElision = applyTerraformPasses(base, opts.detail); // #102: the lens follows the substrate — AWS nests region/VPC/subnet, // Azure nests resource group/VNet/subnet. `metaEnv` names the resource // group on Azure, which ARM never declares as a resource. @@ -2063,8 +2146,12 @@ export function createApp( // primary output, and until now it was only observable by reading the // rendered SVG, which is not something an acceptance run can assert on. // The SPA ignores it and paints the svg as before. - const logicalNote = notesFor("logical", projected, undefined, base.nodes.length); - return c.json({ ir: projected, svg, byContainer, meta: { projectDir: cfg.projectDir, env: metaEnv, tier: opts.tier ?? null, target: opts.target ?? null, mode: "logical", ...(logicalNote ? { note: logicalNote } : {}) } }); + // #393 item 5 — see the estate branch's logical note. + const tf = terraformNotes(singleTfElision); + const lensNote = notesFor("logical", projected, undefined, base.nodes.length); + const logicalNote = [tf.note, lensNote].filter(Boolean).join(" · "); + const logicalNoteShort = tf.note ? [tf.noteShort ?? tf.note, lensNote].filter(Boolean).join(" · ") : undefined; + return c.json({ ir: projected, svg, byContainer, meta: { projectDir: cfg.projectDir, env: metaEnv, tier: opts.tier ?? null, target: opts.target ?? null, mode: "logical", ...(logicalNote ? { note: logicalNote } : {}), ...(logicalNoteShort && logicalNoteShort !== logicalNote ? { noteShort: logicalNoteShort } : {}) } }); } else if (ownKindVia) { // #384: the served directory is itself a member of a kind that is not // chant — a bare Terraform directory, a choudoufu estate — so it is @@ -2168,18 +2255,19 @@ export function createApp( // (see /api/overlay's single-project branch, which already passed this) // — without it, example-k8s's `/api/graph` asserted "nothing in this // estate references anything else" at detail 2 while detail 3 has 2. - // #384: a served Terraform directory says which roots it found and which - // directories of `.tf` it skipped, so a root missing from the picture is - // visible here rather than by counting boxes. Ahead of the elision note, - // which is about the same estate's zoom: what is drawn, then what isn't. - // A served directory normally arrives composed (#389), so the terraform - // members are read off the composed list; the lone `projectDir` case is - // a caller that set it alone. - const tfDirs = (multi ? cfg.projectDirs! : [cfg.projectDir]).filter((d) => memberKindOf(d) === "terraform"); - const rootsNote = tfDirs.length ? tfDirs.map((d) => terraformRootsNote(discoverTerraformRoots(d))).join("; ") : undefined; + // #384 / #393 item 5: the roots and what this zoom left out — see + // `terraformNotes` above, which the two logical branches call as well. + const tf = terraformNotes(tfElision); + // #393 item 1: whoever picks `components` on an estate is owed the reason + // it is not a components picture, and is owed it FIRST — a Terraform + // estate's roots note used to displace it entirely, so the picker looked + // applied and wasn't. `estateLensNote` is undefined unless components was + // actually asked for on a composed estate, so this reads as it did for + // every other view. const srcNote = - [rootsNote, terraformElisionNote(tfElision, opts.detail)].filter(Boolean).join("; ") || - (multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2)); + [estateLensNote, tf.note].filter(Boolean).join(" · ") || + (multi ? undefined : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2)); + const srcNoteShort = tf.noteShort ? [estateLensNote, tf.noteShort].filter(Boolean).join(" · ") : undefined; return c.json({ ir, svg, @@ -2187,6 +2275,10 @@ export function createApp( projectDir: cfg.projectDir, env: metaEnv, ...(srcNote ? { note: srcNote } : {}), + // #393 item 7: the strip's form of the same note, when there is a + // shorter true one. Absent means "the note fits" — the SPA shows + // `note` then. + ...(srcNoteShort && srcNoteShort !== srcNote ? { noteShort: srcNoteShort } : {}), // The picked tier/target (M2, #54), echoed back so the SPA can keep // its header's axes display in sync with what it's actually looking // at, not just the launch-time value. null when neither was picked. @@ -2381,6 +2473,18 @@ export function createApp( app.get("/api/resources", async (c) => { const opts = optsFromQuery(new URL(c.req.url), tierEnvVar, cfg.projectDir); const env = opts.env ?? cfg.env; + // #393 item 3: this facet is a chant-project convention match — it groups + // the entity graph by the `src//` directory each node was + // declared in — and every step of it (`chant graph --live --overlay`, + // `knownComponents`) is a chant shell-out against the primary. A primary + // that is a member of another kind has no chant to ask: a choudoufu estate + // answered `No lexicon detected` and this route 500'd on every boot the + // SPA spent in components mode, which was every boot (item 1). There is + // nothing to spawn and nothing to say, so it says nothing — the same + // `{ byComponent: {} }` carve mode returns, and the same shape the SPA's + // `loadResources` already treats as "this project has no resource facet". + const kind = memberKindOf(cfg.projectDir); + if (kind && kind !== "chant") return c.json({ byComponent: {} }); try { const [ir, known] = await Promise.all([ graphIr( diff --git a/src/terraform-lens.test.ts b/src/terraform-lens.test.ts index 460692c..ba02672 100644 --- a/src/terraform-lens.test.ts +++ b/src/terraform-lens.test.ts @@ -14,6 +14,7 @@ import { normalizeTerraformNodes, terraformCardFields, terraformElisionNote, + terraformElisionNoteShort, } from "./terraform-lens.ts"; import { projectTerraformLogical, rootBoxTitle } from "./logical-terraform.ts"; @@ -125,6 +126,73 @@ describe("groupTerraformByRoot — the roots are the boxes (#380)", () => { groupTerraformByRoot(ir); expect(JSON.stringify(ir.groups)).toBe(after); }); + + // #393 item 6. The composed shape: `composeStacks` namespaces every id under + // the member and boxes the member's whole IR, then this pass boxes the same + // nodes by root. A node sits in ONE box, so the member box drew empty — which + // is what `behold serve ../waterpark/access` put at the top right of the + // canvas: a box named `access` with nothing in it, beside the five roots. + const composed = (member: string, roots: Record): GraphIR => ({ + nodes: Object.entries(roots).flatMap(([root, addrs]) => + addrs.map((a) => ({ id: `${member}/${root}/${a}`, kind: "Terraform::Resource", lexicon: "terraform", attrs: { address: a, root } })), + ), + edges: [], + groups: { byStack: { [member]: Object.entries(roots).flatMap(([root, addrs]) => addrs.map((a) => `${member}/${root}/${a}`)) } }, + }); + + it("drops a member box every node has left, rather than drawing it empty", () => { + const ir = groupTerraformByRoot(composed("access", { baseline: ["aws_iam_policy.boundary"], prod: ["aws_s3_bucket.artifacts"] })); + const boxes = ir.groups.byStack as Record; + expect(Object.keys(boxes).sort()).toEqual(["baseline", "prod"]); + expect(boxes.access).toBeUndefined(); + }); + + it("keeps a member box that kept nodes of its own, holding only those", () => { + const ir = composed("access", { prod: ["aws_s3_bucket.artifacts"] }); + ir.nodes.push({ id: "access/README", kind: "Doc", lexicon: "docs", attrs: {} }); + (ir.groups.byStack as Record).access.push("access/README"); + const boxes = groupTerraformByRoot(ir).groups.byStack as Record; + expect(boxes.access).toEqual(["access/README"]); + expect(boxes["access/prod"] ?? boxes.prod).toEqual(["access/prod/aws_s3_bucket.artifacts"]); + }); + + it("qualifies the root box with its member once the estate holds more than one", () => { + // Two Terraform members, each with a root called `prod`: unqualified they + // merge into one box, and with the member box gone nothing would say which + // estate a card came from. + const a = composed("alpha", { prod: ["aws_s3_bucket.a"] }); + const b = composed("beta", { prod: ["aws_s3_bucket.b"] }); + const ir: GraphIR = { + nodes: [...a.nodes, ...b.nodes], + edges: [], + groups: { byStack: { ...(a.groups.byStack as Record), ...(b.groups.byStack as Record) } }, + }; + const boxes = groupTerraformByRoot(ir).groups.byStack as Record; + expect(Object.keys(boxes).sort()).toEqual(["alpha/prod", "beta/prod"]); + expect(boxes["alpha/prod"]).toEqual(["alpha/prod/aws_s3_bucket.a"]); + }); + + it("qualifies it beside a member of another kind, so both members stay named", () => { + const ir = composed("access", { prod: ["aws_s3_bucket.artifacts"] }); + ir.nodes.push({ id: "tlmig/aws_iam_role.app", kind: "aws_iam_role", lexicon: "choudoufu", attrs: {} }); + (ir.groups.byStack as Record).tlmig = ["tlmig/aws_iam_role.app"]; + const boxes = groupTerraformByRoot(ir).groups.byStack as Record; + expect(Object.keys(boxes).sort()).toEqual(["access/prod", "tlmig"]); + }); +}); + +describe("the note the strip can hold (#393 item 7)", () => { + it("counts what a zoom left out instead of naming every block class", () => { + const ir = normalizeTerraformNodes(legacy()); + const elision = filterTerraformCards(ir, 2); + expect(terraformElisionNote(elision, 2)).toContain("showing the estate —"); + expect(terraformElisionNoteShort(elision)).toBe(`${elision.total} blocks not drawn`); + }); + + it("says nothing when nothing was dropped", () => { + expect(terraformElisionNoteShort({ dropped: {}, total: 0 })).toBeUndefined(); + expect(terraformElisionNoteShort({ dropped: { output: 1 }, total: 1 })).toBe("1 block not drawn"); + }); }); describe("filterTerraformCards — what is a card (#382)", () => { diff --git a/src/terraform-lens.ts b/src/terraform-lens.ts index 820c755..bd4f5e8 100644 --- a/src/terraform-lens.ts +++ b/src/terraform-lens.ts @@ -147,25 +147,68 @@ export function normalizeTerraformNodes(ir: GraphIR): GraphIR { /** * Roots become the boxes. Reads `attrs.root` — the lexicon's own field — and - * falls back to the `/` id prefix it also mints. Leaves an IR alone when - * something upstream has already grouped by root (chant#2266), so this pass - * retires itself rather than fighting the producer. + * falls back to the `/` id prefix it also mints. Leaves the KEYING alone + * when something upstream has already grouped by root (chant#2266), so this + * pass retires itself rather than fighting the producer. + * + * #393: a node sits in exactly ONE box (pinhole's `layoutIr`: "a node may sit + * in one group"), so every box a regrouped node has left must give it up. + * Composition puts one box per member around a member's whole IR + * (`composeStacks`, src/estate.ts) and until now this pass added the root boxes + * beside it — so `behold serve ` drew five root boxes + * AND an empty box named after the served directory. Both readings were + * available and the empty one is what it drew. + * + * Of the two fixes — nest the roots inside the member box, or drop a member box + * every node has left — nesting is not on offer here: `boxes: "byStack"` is a + * flat set of titled boundary boxes and only the architecture lens + * (`groups.byContainer`) nests. So the emptied box goes, and the thing it was + * there to say — WHICH member these roots came from — moves into the root box's + * own title: `/` when the estate holds more than that one member, + * the bare root name when it does not. Without that, two Terraform members with + * a root apiece named `prod` would silently merge into one box, which is a + * worse lie than the empty box this fixes. */ export function groupTerraformByRoot(ir: GraphIR): GraphIR { - const roots = new Map(); + /** Each root's nodes, keyed by the composed member prefix its ids carry + * (`""` when nothing composed them) together with the root name. */ + const roots = new Map(); for (const n of ir.nodes) { if (n.lexicon !== TERRAFORM_LEXICON) continue; const root = typeof n.attrs.root === "string" && n.attrs.root ? n.attrs.root : n.id.includes("/") ? n.id.slice(0, n.id.indexOf("/")) : undefined; - if (root) (roots.get(root) ?? roots.set(root, []).get(root)!).push(n.id); + if (!root) continue; + // `//
` once composed, `/
` otherwise. + const at = n.id.indexOf(`/${root}/`); + const prefix = at > 0 ? n.id.slice(0, at) : ""; + const key = `${prefix} ${root}`; + (roots.get(key) ?? roots.set(key, { prefix, root, ids: [] }).get(key)!).ids.push(n.id); } if (roots.size === 0) return ir; const byStack = { ...(ir.groups.byStack ?? {}) } as Record; // Already grouped by root upstream: every root is its own entry and the - // lexicon-wide bucket is gone. Nothing to do. - const grouped = [...roots.keys()].every((r) => byStack[r]?.length === roots.get(r)!.length); - if (grouped) return ir; - delete byStack[TERRAFORM_LEXICON]; - for (const [root, ids] of roots) byStack[root] = ids; + // lexicon-wide bucket is gone. The keying below is then a no-op — the strip + // under it is not, so only the keying is skipped. + const grouped = [...roots.values()].every((r) => byStack[r.root]?.length === r.ids.length); + const rootBoxes = new Set(); + if (grouped) { + for (const r of roots.values()) rootBoxes.add(r.root); + } else { + delete byStack[TERRAFORM_LEXICON]; + const members = new Set([...roots.values()].map((r) => r.prefix)); + const qualify = members.size > 1 || [...members].some((p) => p && Object.keys(byStack).some((b) => b !== p)); + for (const { prefix, root, ids } of roots.values()) { + const box = qualify && prefix ? `${prefix}/${root}` : root; + byStack[box] = ids; + rootBoxes.add(box); + } + } + const regrouped = new Set([...roots.values()].flatMap((r) => r.ids)); + for (const [box, ids] of Object.entries(byStack)) { + if (rootBoxes.has(box)) continue; + const left = ids.filter((id) => !regrouped.has(id)); + if (left.length) byStack[box] = left; + else delete byStack[box]; + } ir.groups = { ...ir.groups, byStack }; return ir; } @@ -234,6 +277,14 @@ export function terraformElisionNote(e: TerraformElision, detail: number | undef return `showing the estate — ${parts.join(", ")} not drawn${interfaceHidden ? " (outputs and variables appear at detail 3 — ⌘K → attributes)" : ""}`; } +/** The same fact as a chip rather than a sentence (#393): the statusbar note + * lives in a 260px panel, and the full sentence — every block class, its count, + * and how to see it — is a paragraph there. The long form stays reachable (the + * strip's tooltip, and the panel's Model tab); this is what the strip shows. */ +export function terraformElisionNoteShort(e: TerraformElision): string | undefined { + return e.total === 0 ? undefined : `${e.total} block${e.total === 1 ? "" : "s"} not drawn`; +} + /** * The two fields a Terraform card leads with: which root it belongs to, and * what it reads when it is a data source. The type and the name are already the diff --git a/src/terraform-member.ts b/src/terraform-member.ts index 3e0c735..7da29d3 100644 --- a/src/terraform-member.ts +++ b/src/terraform-member.ts @@ -239,6 +239,16 @@ export function terraformRootsNote(scan: TerraformRootScan): string | undefined return `${roots}; skipped ${named.join(", ")}${more}`; } +/** The same scan as a chip (#393) — `5 roots · 2 skipped`. The statusbar note + * sits in a 260px panel and the sentence above is ~60 words of it, repeated at + * every zoom; the full text stays on the strip's tooltip and in the Model tab, + * and this is what the strip itself carries. */ +export function terraformRootsNoteShort(scan: TerraformRootScan): string | undefined { + if (!scan.roots.length) return undefined; + const roots = `${scan.roots.length} root${scan.roots.length === 1 ? "" : "s"}`; + return scan.skipped.length ? `${roots} · ${scan.skipped.length} skipped` : roots; +} + // --------------------------------------------------------------------------- // The reader: is the lexicon here at all? // --------------------------------------------------------------------------- diff --git a/src/terraform-route.test.ts b/src/terraform-route.test.ts index daf11f8..aa4398d 100644 --- a/src/terraform-route.test.ts +++ b/src/terraform-route.test.ts @@ -142,6 +142,83 @@ describe("GET /api/graph over a bare Terraform directory (#384)", () => { ); }); + // ------------------------------------------------------------------------- + // #393 — what the audit found on the water park estate, at the routes. + // ------------------------------------------------------------------------- + it("carries the roots note through the logical zoom, and a short form for the strip (items 5, 7)", async () => { + const dir = estate(); + registerMemberKind({ kind: "terraform", probe: hasTerraformRoots, expects: "a Terraform root", via: { tool: () => "lexicon\0v1", read: (async () => fixture()) as never } }); + + const app = serve(dir); + const resources = (await (await app.request("/api/graph?detail=2")).json()) as { meta: { note?: string; noteShort?: string } }; + const logical = (await (await app.request("/api/graph?logical=1")).json()) as { meta: { note?: string; noteShort?: string } }; + + // The one line that explains the picture does not vanish with the lens. + expect(logical.meta.note).toContain("2 roots — baseline, prod"); + expect(logical.meta.note).toContain("skipped backends"); + // And both zooms hand the 260px strip something that fits, with the counts + // the long sentence spells out. + expect(resources.meta.noteShort).toBe("2 roots · 2 skipped · 26 blocks not drawn"); + expect(logical.meta.noteShort).toBe(resources.meta.noteShort); + }); + + it("says what its members are, and never offers the runtime zoom (items 1, 2)", async () => { + const dir = estate(); + registerMemberKind({ kind: "terraform", probe: hasTerraformRoots, expects: "a Terraform root", via: { tool: () => "lexicon\0v1", read: (async () => fixture()) as never } }); + + const info = (await (await serve(dir).request("/api/project")).json()) as { memberKinds: string[]; runtimeCapable?: boolean }; + + // The SPA boots on `resources` off this: no chant member, no components. + expect(info.memberKinds).toEqual(["terraform"]); + // A Terraform root has no owner-reference chain, so there is no tier below + // the declaration boundary to descend to. + expect(info.runtimeCapable).toBeUndefined(); + }); + + it("answers /api/resources with the empty facet instead of 500ing (item 3)", async () => { + const dir = estate(); + registerMemberKind({ kind: "terraform", probe: hasTerraformRoots, expects: "a Terraform root", via: { tool: () => "lexicon\0v1", read: (async () => fixture()) as never } }); + + const res = await serve(dir).request("/api/resources"); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ byComponent: {} }); + // Nothing was asked of chant: there is no chant here to ask. + expect(graphIr).not.toHaveBeenCalled(); + }); + + it("draws no empty member box for the directory it composed (item 6)", async () => { + const dir = estate(); + registerMemberKind({ kind: "terraform", probe: hasTerraformRoots, expects: "a Terraform root", via: { tool: () => "lexicon\0v1", read: (async () => fixture()) as never } }); + + // How `behold serve ` actually arrives (#389): the + // lone directory is composed as a one-member estate, so `composeStacks` + // puts a box named after it around every node — and the root boxes then + // take those same nodes. + const broadcaster = new Broadcaster(); + const app = createApp({ projectDir: dir, projectDirs: [dir], port: 0 }, broadcaster, new FrameBuffer(), new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} })); + const { ir } = (await (await app.request("/api/graph?detail=2")).json()) as { ir: GraphIR }; + + const boxes = ir.groups.byStack as Record; + expect(Object.values(boxes).every((ids) => ids.length > 0)).toBe(true); + expect(Object.keys(boxes).sort()).toEqual(["baseline", "runner"]); + }); + + it("still says the components lens does not apply when someone picks it (item 1)", async () => { + const dir = estate(); + registerMemberKind({ kind: "terraform", probe: hasTerraformRoots, expects: "a Terraform root", via: { tool: () => "lexicon\0v1", read: (async () => fixture()) as never } }); + + const broadcaster = new Broadcaster(); + const app = createApp({ projectDir: dir, projectDirs: [dir], port: 0 }, broadcaster, new FrameBuffer(), new OpRunner({ projectDir: dir, broadcaster, onDone: () => {} })); + const { meta } = (await (await app.request("/api/graph?components=1")).json()) as { meta: { note?: string } }; + + // The SPA no longer BOOTS here (item 1), but the stop is still pickable and + // the picker must not look applied: the reason comes first, and the + // estate's own note follows it rather than replacing it. + expect(meta.note).toMatch(/^the components lens doesn't apply to a composed estate yet/); + expect(meta.note).toContain("2 roots — baseline, prod"); + }); + it("answers the reader's own refusal, with the install line, when the lexicon is not there", async () => { const dir = estate(); const refusal = { diff --git a/web/app.js b/web/app.js index 174795c..c2f32f1 100644 --- a/web/app.js +++ b/web/app.js @@ -998,6 +998,20 @@ const view = { env: null, detail: 2, components: true, logical: false, runtime: // or ⌘K) still shows the honest empty view + the server's note. let autoZoomFallback = true; +// #393 item 1: for an estate whose members are NONE of them chant projects, the +// fallback above is a round-trip too late and one degree too vague. The +// components zoom is a chant projection of a chant project's own component DAG; +// a choudoufu estate or a bare Terraform directory has no components to +// project, so the first screen was "the components lens doesn't apply to a +// composed estate yet" — an apology, every time, before the person had picked +// anything. The kinds come from /api/project (`memberKinds`, in composition +// order), so the decision is made from what the server already knows rather +// than from a zero-node answer. A chant member anywhere in the estate keeps +// #182's behaviour exactly, and picking components later still shows the note. +function bootZoom(memberKinds) { + return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components"; +} + // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on. @@ -1035,11 +1049,19 @@ const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 }; /** How many emitted Ops the served estate has (0 = the stop doesn't exist). * Seeded from /api/project in initPickers(). */ let opsAvailable = 0; -/** The zoom stops this project can actually offer — `runtime` needs an env, - * `ops` needs emitted op.json files. Both the panel and ⌘K read this, so a stop - * never appears in one surface and not the other. */ +/** #393 item 2: whether ANY served member could have owner-referenced children + * — /api/project's `runtimeCapable`, decided from the members' declared + * lexicons (src/server.ts's RUNTIME_LEXICONS). Before this the stop needed only + * an env, so every choudoufu estate (which declares `live`) offered a + * Kubernetes tier that could only ever answer "nothing below the declaration + * boundary". */ +let runtimeCapable = false; +/** The zoom stops this project can actually offer — `runtime` needs an env AND + * a member whose substrate has an owner chain, `ops` needs emitted op.json + * files. Both the panel and ⌘K read this, so a stop never appears in one + * surface and not the other. */ function availableZooms() { - return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : z === "ops" ? opsAvailable > 0 : true)); + return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true)); } /** Current zoom value from (components, logical, ops, detail). */ function zoomValue() { @@ -1578,6 +1600,15 @@ function renderPanelModel() { host.appendChild(panelMuted("no graph loaded yet")); return; } + // #393 item 7: the note in full, where there is room for it. The strip may be + // showing the server's short form (a 260px strip cannot hold five root names, + // two skip reasons and five block counts), so the whole sentence has to be + // readable somewhere that is not a tooltip — and this tab is where the panel + // already explains the picture. + if (lastNote && lastNoteShort) { + host.appendChild(panelHeading("note")); + host.appendChild(panelMuted(lastNote)); + } const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env); const componentStatus = m.mode === "component-status"; const count = (statuses) => { @@ -2002,7 +2033,15 @@ function renderStatusbar() { if (lastNote) { const note = document.createElement("span"); note.className = "statusbar-note"; - note.textContent = " — " + lastNote; + // #393 item 7: the strip is a strip. A Terraform estate's note names every + // root, every skipped directory with its reason, and every block class the + // zoom left out — ~60 words, in a 260px panel, at every zoom. The server + // sends the short form beside the long one (`meta.noteShort`) when it has + // one; the full text stays one hover away here and is printed in full on + // the panel's Model tab. Nothing is truncated client-side: the SPA does not + // write notes, and a note with no short form is already a line. + note.textContent = " — " + (lastNoteShort || lastNote); + if (lastNoteShort) note.title = lastNote; el.appendChild(note); } } @@ -2011,6 +2050,9 @@ function renderStatusbar() { // or null. Set on every load so a level that stops degrading stops explaining // itself. let lastNote = null; +// #393 item 7: the same note as the strip can hold (`meta.noteShort`), or null +// when the long one already fits. Never derived here — see renderStatusbar(). +let lastNoteShort = null; // The `meta` of the last rendered graph — the panel's Model tab reads its // mode/env to pick the status vocabulary (drift vs component live status). @@ -2684,6 +2726,7 @@ function render(ir, svg, m) { // #131: set before anything can early-return, so a level that stopped // degrading stops explaining itself on the very next render. lastNote = m.note || null; + lastNoteShort = m.noteShort || null; // The panel's Model tab reads both of these via renderStatusbar() → // renderPanel() below — set them first so it renders THIS graph, not the // previous one (recolorNodesByCategory also sets lastGraphIr; harmless). @@ -2749,8 +2792,9 @@ function render(ir, svg, m) { // Multi-estate (#31): note the composed project count; the graph draws one box per project. // #186: the meta line lives in the panel's 272px footer now — the directory // basename reads better than an absolute path (full path in the tooltip). + // #393 item 4: one member is a project, not "1 projects". const scope = m.estate - ? `estate of ${m.estate} projects` + ? `estate of ${m.estate} project${m.estate === 1 ? "" : "s"}` : String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir; // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with // what this response actually observed (falls back to the launch-time value @@ -3762,6 +3806,15 @@ async function initPickers() { // way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50 // never grows a stop that would refuse. opsAvailable = info.ops || 0; + // #393: whether the runtime stop exists here, and where this estate can + // honestly open. Both are read before the first load() below, so a non-chant + // estate never paints the components apology on its way to a picture. + runtimeCapable = !!info.runtimeCapable; + const boot = bootZoom(info.memberKinds); + if (boot !== "components") { + applyZoom(boot); + autoZoomFallback = false; + } // #254: carve mode declares itself here. The Carve tab is mounted at runtime // (panel.js's addPanelTab), so nothing else grows a dead tab. carveInfo = info.carve || null; diff --git a/web/index.html b/web/index.html index 7445093..38984c4 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,14 @@ behold + + + + + terralith-4 + ${nodeSvg("terralith-4/aws_ecs_cluster.main", 40, "good", "aws_ecs_cluster.main")} + ${nodeSvg("terralith-4/aws_cloudwatch_log_group.extra", 230, "warn", "aws_cloudwatch_log_group.extra")} + ${nodeSvg("terralith-4/aws_iam_role.quiet", 420, "neutral", "aws_iam_role.quiet")} +`; +const CHOUDOUFU_IR = { + nodes: [ + { + id: "terralith-4/aws_ecs_cluster.main", + kind: "aws_ecs_cluster", + lexicon: "choudoufu", + attrs: { estate: "terralith-4", bound: "by derived identity (the name the configuration states) — no marker on the object yet", _status: "good" }, + }, + { + id: "terralith-4/aws_cloudwatch_log_group.extra", + kind: "aws_cloudwatch_log_group", + lexicon: "choudoufu", + attrs: { + estate: "terralith-4", + adopt: CHOUDOUFU_ADOPT, + omission: "UNOWNED", + detail: "a live object stands at this identity and carries no marker of ours; adopt it by writing the two tags, or let the plan create a second one", + _status: "warn", + }, + }, + { id: "terralith-4/aws_iam_role.quiet", kind: "aws_iam_role", lexicon: "choudoufu", attrs: { estate: "terralith-4", _status: "neutral", _unobserved: "not mentioned by live-plan or live-ls" } }, + ], + edges: [], +}; +const CHOUDOUFU_VOCABULARY = { of: "choudoufu", labels: { good: "bound", warn: "unowned", accent: "pending", neutral: "not observed", runtime: "runtime child" } }; +const CHOUDOUFU_PROJECT = { + projectDir: "/estates/terralith-4", + recents: [], + environments: ["live"], + lexicons: [], + currentEnv: "live", + targets: [], + memberKinds: ["choudoufu"], +}; + +export function startStub(port, { carve = false, nonChant = false, choudoufu = false } = {}) { // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a // `writable` flag on the read), so the smoke drives the client's whole sync @@ -574,6 +631,46 @@ export function startStub(port, { carve = false, nonChant = false } = {}) { if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a carve report isn't a project", deltas: {} }); } + if (choudoufu) { + const json = (body) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); + }; + if (path === "/api/project") return json(CHOUDOUFU_PROJECT); + if (path === "/api/graph" || path === "/api/overlay") { + return json({ + ir: CHOUDOUFU_IR, + svg: CHOUDOUFU_SVG, + meta: { projectDir: CHOUDOUFU_PROJECT.projectDir, env: "live", tier: null, target: null, estate: 1, mode: "overlay", vocabulary: CHOUDOUFU_VOCABULARY }, + }); + } + if (path === "/api/diff") { + return json({ + env: "live", + nodes: { + "terralith-4/aws_cloudwatch_log_group.extra": { + observed: { + type: "aws_cloudwatch_log_group", + physicalId: "/terralith-4/extra", + ownership: "foreign", + attributes: { adopt: CHOUDOUFU_ADOPT, omission: "UNOWNED", detail: CHOUDOUFU_IR.nodes[1].attrs.detail, rung: "tag-governable" }, + }, + diff: null, + health: "degraded", + healthDetail: "UNOWNED: a live object stands at this identity and carries no marker of ours", + fieldDrift: null, + }, + }, + }); + } + if (path === "/api/substrates") return json({ substrates: [] }); + if (path === "/api/resources") return json({ byComponent: {} }); + if (path === "/api/ci") return json({ stages: [], jobs: [], forge: null }); + if (path === "/api/ops") return json({ ops: [], adoptLexicons: [], autoSync: "off" }); + if (path === "/api/history") return json({ commits: [] }); + if (path === "/api/demos") return json({ demos: [] }); + if (path === "/api/layout") return json({ lens: url.searchParams.get("lens"), writable: false, reason: "a stub", deltas: {} }); + } if (nonChant) { const json = (body) => { res.writeHead(200, { "content-type": "application/json" }); diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index a1b594f..694a0e9 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -1119,6 +1119,52 @@ try { wbServer.close(); } + // ---- #393 items 8 and 10: an estate that speaks choudoufu ---------------- + // The legend, the statusbar counts and the inspect status row all render + // from the `vocabulary` the server puts on the overlay meta, and an UNOWNED + // card's first declared row is the line that adopts it, with a copy button. + const chdfServer = await startStub(PORT + 3, { choudoufu: true }); + const chdfPage = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + const chdfErrors = []; + chdfPage.on("pageerror", (e) => chdfErrors.push(String(e))); + try { + await chdfPage.goto(`http://localhost:${PORT + 3}/`); + await chdfPage.waitForSelector("#graph svg [data-node-id]", { timeout: 20000 }); + + const meta = await chdfPage.locator("#meta").innerText(); + check("the statusbar counts read in choudoufu's words", meta.includes("1 bound") && meta.includes("1 unowned") && meta.includes("1 not observed")); + check("…and not in chant's", !/managed|foreign|unobserved/.test(meta)); + + await chdfPage.click('#panel-tabs button[data-tab="model"]'); + await chdfPage.waitForTimeout(100); + const legend = await chdfPage.locator("#tab-model").innerText(); + check("the legend says bound / unowned / pending", legend.includes("bound") && legend.includes("unowned") && legend.includes("pending")); + check("…and never says managed", !legend.includes("managed")); + + // The UNOWNED card: the line to run, first, copyable. + await chdfPage.click('[data-node-id="terralith-4/aws_cloudwatch_log_group.extra"]'); + await chdfPage.waitForTimeout(200); + const pane = chdfPage.locator("#inspect-body"); + check("the inspect status row speaks choudoufu too", (await pane.innerText()).includes("unowned")); + const declaredKeys = await pane.locator("h3:text-is('declared') + dl dt").allInnerTexts(); + check("the adoption line is the declared section's first row", declaredKeys[0] === "adopt"); + check("…and the paragraph is below it", declaredKeys.indexOf("adopt") < declaredKeys.indexOf("detail")); + const adoptRow = pane.locator("h3:text-is('declared') + dl dd").first(); + check("the row carries the two tags choudoufu named", (await adoptRow.innerText()).includes("tofu-estate=terralith-4 tofu-address=aws_cloudwatch_log_group.extra")); + const copy = adoptRow.locator("button"); + check("…with a copy button beside it", (await copy.count()) === 1); + await copy.click(); + await chdfPage.waitForTimeout(100); + check("the copy button confirms", (await copy.innerText()).includes("copied")); + + check("no console errors on a choudoufu estate", chdfErrors.length === 0); + if (chdfErrors.length) console.error("choudoufu page errors:", chdfErrors); + await chdfPage.screenshot({ path: join(SHOTS, "10-choudoufu-vocabulary.png") }); + } finally { + await chdfPage.close(); + chdfServer.close(); + } + check("no page errors", pageErrors.length === 0); if (pageErrors.length) console.error("page errors:", pageErrors); } finally { diff --git a/src/choudoufu-live.test.ts b/src/choudoufu-live.test.ts index a91e527..b1d9255 100644 --- a/src/choudoufu-live.test.ts +++ b/src/choudoufu-live.test.ts @@ -8,6 +8,7 @@ import { BOUND_SOURCES, GAP_RUNGS, OMISSION_REASONS, + adoptLine, choudoufuDiffNodes, paintChoudoufu, parseLiveLs, @@ -145,7 +146,10 @@ describe("verdictFor and paintChoudoufu — the palette (#370)", () => { const extra = ir.nodes.find((n) => n.id === "aws_cloudwatch_log_group.extra")!; expect(extra.attrs._status).toBe("warn"); expect(extra.attrs.omission).toBe("UNOWNED"); - expect(extra.attrs.adopt).toEqual({ "tofu-estate": "tlmig-sample-monolith", "tofu-address": "aws_cloudwatch_log_group.extra" }); + // #393 item 10: one line to copy, and it is the FIRST row of the pane's + // declared section — the paragraph that explains the omission follows it. + expect(extra.attrs.adopt).toBe("tofu-estate=tlmig-sample-monolith tofu-address=aws_cloudwatch_log_group.extra"); + expect(Object.keys(extra.attrs).indexOf("adopt")).toBeLessThan(Object.keys(extra.attrs).indexOf("detail")); expect(extra.physicalId).toBe("/tlmig-sample/extra"); expect(extra.ownership).toBe("foreign"); const zero = ir.nodes.find((n) => n.id === "aws_cloudwatch_log_group.team_a_zero")!; @@ -292,3 +296,36 @@ describe("projectChoudoufuLogical — the estate box (#370)", () => { expect(projectChoudoufuLogical({ nodes: [], edges: [], groups: {} })).toEqual({ ir: { nodes: [], edges: [], groups: {} }, byContainer: {} }); }); }); + +// #393 item 10: the UNOWNED inspect leads with the line to run. +describe("adoptLine — the write a person actually makes", () => { + it("names the two marker tags, with the address escaped the way the tag stores it", () => { + expect(adoptLine({ addr: "aws_iam_role.r[0]", type: "aws_iam_role", identity: "r-0", adopt_tofu_estate: "e", adopt_tofu_address: "aws_iam_role.r:0" })).toBe( + "tofu-estate=e tofu-address=aws_iam_role.r:0", + ); + }); + + it("prefers choudoufu's own one-line command if a release ever sends one", () => { + const composed = "aws ecs tag-resource --resource-arn ARN --tags key=tofu-estate,value=e"; + expect(adoptLine({ addr: "a", type: "t", identity: "i", adopt_tofu_estate: "e", adopt_tofu_address: "a", hint: composed })).toBe(composed); + }); + + it("offers nothing for an object that belongs to another estate — adoption is not this run's to give", () => { + expect(adoptLine({ addr: "a", type: "t", identity: "i", tofu_estate: "neighbour" })).toBeUndefined(); + }); +}); + +// #393 item 8: a card bound by derived identity is good and IS bound — and +// carries no marker yet, which the pane has to say out loud. +describe("bound by derived identity says the marker is still missing", () => { + it("spells the consequence in the card's row and in the diff's health detail", () => { + const doc = check("choudoufu-live-check-monolith.json"); + const listing = ls("choudoufu-live-ls-monolith.json"); + const planDoc = plan("choudoufu-live-plan-monolith-clean.json"); + const ir = paintChoudoufu(liveCheckToIr(doc), listing, planDoc); + const inline = ir.nodes.find((n) => n.id === "aws_iam_role_policy.team_a_inline")!; + expect(inline.attrs._status).toBe("good"); + expect(inline.attrs.bound).toBe("by derived identity (the name the configuration states) — no marker on the object yet"); + expect(choudoufuDiffNodes(doc, listing, planDoc)["aws_iam_role_policy.team_a_inline"].healthDetail).toContain("no marker on the object yet"); + }); +}); diff --git a/src/choudoufu-live.ts b/src/choudoufu-live.ts index e99c9bb..dec6b5f 100644 --- a/src/choudoufu-live.ts +++ b/src/choudoufu-live.ts @@ -130,6 +130,16 @@ export interface LivePlanUnowned { tofu_estate?: string; adopt_tofu_estate?: string; adopt_tofu_address?: string; + /** The one-line command that writes those two tags, when choudoufu composed + * one. Its human render prints it after `adopt with:` and its views call the + * field `Hint`, but on 0.16.0 no such field is marshalled onto an `unowned[]` + * entry — `StatelessUnowned` carries the two marker values and nothing else + * (verified against a real `live-plan -json`, choudoufu v0.16.0). Read here + * so the day one arrives behold shows choudoufu's own line instead of the + * tags; behold composes no command of its own, because the API that writes a + * tag differs per resource type and guessing it is worse than saying nothing. */ + hint?: string; + adopt_command?: string; } export interface LivePlanDiagnostic { @@ -240,11 +250,27 @@ const UNOBSERVED_REASONS = new Set(["FAILED", "CYCLE", "SUPERSEDED", "UNREADABLE const BOUND_WORDS: Record = { marker: "by its marker (the estate-wide tag sweep)", - derived: "by derived identity (the name the configuration states)", + // #393 item 8: this is the whole of an unadopted estate's green. 84 of the + // terralith's cards are bound this way and none of them carries a marker — + // choudoufu matched the live object by the identity the configuration + // states, which is correct and is NOT ownership. The card is painted good + // and counted bound, per choudoufu; the pane has to say the consequence out + // loud, or a reader takes a third of the estate for already adopted. + derived: "by derived identity (the name the configuration states) — no marker on the object yet", record: "from the estate's record store", cache: "from the local state cache", }; +/** The two tags that adopt an unowned object, as the one line a person writes: + * choudoufu's own composed command when the document carries one, else the + * marker pair itself. Never a command behold made up — see `LivePlanUnowned.hint`. */ +export function adoptLine(u: LivePlanUnowned): string | undefined { + const composed = u.hint || u.adopt_command; + if (composed) return composed; + if (!u.adopt_tofu_estate) return undefined; + return `tofu-estate=${u.adopt_tofu_estate} tofu-address=${u.adopt_tofu_address ?? u.addr}`; +} + /** * The verdict for one declared address, from the plan and the listing. The * plan decides first (it is the claim about ownership); the listing adds @@ -271,12 +297,17 @@ export function verdictFor(address: string, ls: LiveLsDocument | undefined, plan const tone = REASON_TONE[omission.reason] ?? "neutral"; const base: Record = { omission: omission.reason, detail: omission.detail, ...listing }; if (omission.reason === "UNOWNED" && unowned) { - if (unowned.adopt_tofu_estate) { + const adopt = adoptLine(unowned); + if (adopt) { + // #393 item 10: the adoption line FIRST. What a person wants off an + // UNOWNED card is the write to run; the paragraph explaining why the + // plan left the instance out is the second thing, not the first, and + // it used to be the whole thing with the tags under it as JSON. return { _status: "warn", physicalId: unowned.identity, ownership: "foreign", - attrs: { ...base, adopt: { "tofu-estate": unowned.adopt_tofu_estate, "tofu-address": unowned.adopt_tofu_address } }, + attrs: { adopt, ...base }, }; } return { @@ -469,7 +500,11 @@ export function choudoufuDiffNodes(check: LiveCheckDocument, ls: LiveLsDocument, type: i.type, ...(v.physicalId ? { physicalId: v.physicalId } : {}), ...(v.ownership ? { ownership: v.ownership } : {}), - attributes: { ...(i.rung ? { rung: i.rung } : {}), ...v.attrs }, + // #393 item 10: the verdict's own rows first — an UNOWNED + // instance leads with the line that adopts it — and the rung, the + // one fact that is true of the declaration whatever the account + // holds, last. + attributes: { ...v.attrs, ...(i.rung ? { rung: i.rung } : {}) }, } : null, diff: null, diff --git a/src/choudoufu-route.test.ts b/src/choudoufu-route.test.ts index c3ec63b..227817b 100644 --- a/src/choudoufu-route.test.ts +++ b/src/choudoufu-route.test.ts @@ -119,6 +119,16 @@ describe("GET /api/choudoufu/moves (#371)", () => { expect(await res.json()).toEqual({ byComponent: {} }); }); + // #393 item 8: the legend speaks the members' own words. + it("carries choudoufu's vocabulary on the overlay meta", async () => { + const { app } = served(); + const body = (await (await app.request("/api/overlay?env=live")).json()) as { meta: { vocabulary: { of: string; labels: Record; note?: string } } }; + expect(body.meta.vocabulary.of).toBe("choudoufu"); + expect(body.meta.vocabulary.labels).toMatchObject({ good: "bound", warn: "unowned", accent: "pending", neutral: "not observed" }); + // Nothing to explain: every member speaks this vocabulary. + expect(body.meta.vocabulary.note).toBeUndefined(); + }); + it("is advertised on /api, and /api/project lists the plans found", async () => { const { app } = served(); const api = (await (await app.request("/api")).json()) as { routes: Array<{ path: string }> }; diff --git a/src/server.ts b/src/server.ts index 109a62a..25d194a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -130,6 +130,7 @@ import { detectSubstrates, projectLexicons } from "./substrates.ts"; import { pickAutoSyncOps, splitForgeRouted, suspendedByRollback, type AutoSyncMode } from "./autosync.ts"; import { sourceCommits, openRollbackBranches } from "./history.ts"; import { composeEstate, composeEstateOverlay, estateMembers, withoutJoinedMembers } from "./estate.ts"; +import { statusVocabulary } from "./status-vocabulary.ts"; import { addEstateMemberEdges } from "./estate-edges.ts"; import { addChoudoufuReferenceEdges, liveCheckToIr, readLiveCheck, setChoudoufuSpawnEnv } from "./choudoufu-member.ts"; import { @@ -2555,6 +2556,12 @@ export function createApp( // and the single-project branch didn't read it until after its `graphIr` // call had already gone out at the wrong detail. const runtime = new URL(c.req.url).searchParams.get("runtime") === "1"; + // #393 item 8: whose words the legend, the statusbar counts and the Model + // tab's drift block speak — derived from what the served members ARE, once + // per read, and carried on the meta beside the counts the SPA computes off + // the IR. The colours are unchanged; only the naming is. A mixed estate + // keeps chant's words and says why (src/status-vocabulary.ts). + const vocabulary = statusVocabulary(estateMembers(estateDirs).map((m) => m.kind)); try { // #189: the estate-wide overlay. The single-project pipeline below only // ever observed the primary, so an N-project estate was coloured 1/N — @@ -2640,7 +2647,7 @@ export function createApp( ir: projected, svg, byContainer, - meta: { projectDir: cfg.projectDir, env, mode: "logical", estate: est.total, ...(note ? { note } : {}) }, + meta: { projectDir: cfg.projectDir, env, mode: "logical", estate: est.total, vocabulary, ...(note ? { note } : {}) }, }); } // One box per node: pinhole's `layoutIr` parents a node to a single @@ -2671,7 +2678,7 @@ export function createApp( return c.json({ ir, svg, - meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, ...(note ? { note } : {}) }, + meta: { projectDir: cfg.projectDir, env, mode: "overlay", estate: est.total, vocabulary, ...(note ? { note } : {}) }, }); } // #261: `runtime` forces detail 3 exactly as `logical` does, and for the @@ -2757,7 +2764,7 @@ export function createApp( // the detail tiers do, and until now only they said why. const logicalTierNote = tierMismatchNote(projected, beholdConfig.tiers, query.tier); const logicalNote = [logicalTierNote, notesFor("logical", projected, undefined, logicalBefore)].filter(Boolean).join(" · "); - return c.json({ ir: projected, svg, byContainer, meta: { projectDir: cfg.projectDir, env, mode: "logical", ...(logicalNote ? { note: logicalNote } : {}) } }); + return c.json({ ir: projected, svg, byContainer, meta: { projectDir: cfg.projectDir, env, mode: "logical", vocabulary, ...(logicalNote ? { note: logicalNote } : {}) } }); } // Below the ATTRIBUTES tier, hide cross-stack import handles — they're // value plumbing, not resources, and float off to the side (see @@ -2831,7 +2838,7 @@ export function createApp( // that never had the attrs to derive edges from in the first place. const zoomNotes = notesFor(zoom, ir, compositeEdgesAttached, undefined, opts.detail ?? 2); const note = [tierNote, nsNote, zoomNotes].filter(Boolean).join(" · "); - return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", ...(note ? { note } : {}) } }); + return c.json({ ir, svg, meta: { projectDir: cfg.projectDir, env, mode: "overlay", vocabulary, ...(note ? { note } : {}) } }); } catch (err) { // #72: the same structured {error, code, remedy} the other read routes // return — this is in fact where a picked tier's creds gate USUALLY diff --git a/src/status-vocabulary.test.ts b/src/status-vocabulary.test.ts new file mode 100644 index 0000000..e6d9e85 --- /dev/null +++ b/src/status-vocabulary.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { CHANT_STATUS_LABELS, CHOUDOUFU_STATUS_LABELS, statusVocabulary } from "./status-vocabulary.ts"; + +describe("statusVocabulary (#393 item 8)", () => { + it("speaks choudoufu's words on an estate whose members are all choudoufu", () => { + const v = statusVocabulary(["choudoufu", "choudoufu"]); + expect(v.of).toBe("choudoufu"); + expect(v.labels).toEqual(CHOUDOUFU_STATUS_LABELS); + expect(v.labels.good).toBe("bound"); + expect(v.labels.warn).toBe("unowned"); + expect(v.note).toBeUndefined(); + }); + + it("keeps chant's words for a chant estate, a Terraform one, and no members at all", () => { + for (const kinds of [["chant"], ["terraform"], ["chant", "chant"], []]) { + expect(statusVocabulary(kinds).labels).toEqual(CHANT_STATUS_LABELS); + expect(statusVocabulary(kinds).note).toBeUndefined(); + } + }); + + it("keeps chant's words on a mixed estate and says why, because one colour cannot carry two meanings", () => { + const v = statusVocabulary(["chant", "choudoufu"]); + expect(v.of).toBe("mixed"); + expect(v.labels).toEqual(CHANT_STATUS_LABELS); + expect(v.note).toContain("chant and choudoufu"); + expect(v.note).toContain("bound reads as managed"); + }); + + it("paints the same four colours either way — this is naming, not a second classification", () => { + expect(Object.keys(CHOUDOUFU_STATUS_LABELS)).toEqual(Object.keys(CHANT_STATUS_LABELS)); + }); +}); diff --git a/src/status-vocabulary.ts b/src/status-vocabulary.ts new file mode 100644 index 0000000..9b48c08 --- /dev/null +++ b/src/status-vocabulary.ts @@ -0,0 +1,84 @@ +/** + * The words the legend, the statusbar and the Model tab put on the four + * overlay colours (#393 item 8). + * + * behold paints one palette — `good | warn | accent | neutral` — and until now + * spoke one vocabulary over it: managed / foreign / pending / unobserved, + * chant's words for a chant estate. On an estate whose members are choudoufu + * those words are wrong in a way that matters: choudoufu's `bound` is not + * "managed" (a resource bound BY DERIVED IDENTITY carries no marker yet, and + * "managed" claims it does), and its `unowned` is the adoption case, not a + * foreign one. The unadopted terralith reads 84 cards as "managed" before any + * adoption has happened — a third of the estate claimed as already yours. + * + * So the labels are derived from what the estate's members ARE. The colours do + * not move: this is naming, not a second classification. + * + * The neutral bucket's word is the one choice here that is not simply + * choudoufu's own. choudoufu has no single word for it: behold paints neutral + * for live-ls's `gaps[]` (the listing cannot see it), for the live-plan + * omission reasons that mean "could not answer" (FAILED, CYCLE, + * NEEDS_DISCOVERY, …) and for an address neither document mentions at all. + * `omitted` — choudoufu's own noun for an `omissions[]` entry — is the near + * miss, and it is a miss: `ABSENT` is an omission too, and behold paints that + * one `accent`/pending. So the bucket keeps a plain phrase, "not observed", + * which is what every member of it has in common and what the inspect pane's + * `_unobserved` row already says. + */ + +/** The four colours a card can wear, plus chant's fifth runtime bucket. */ +export type StatusKey = "good" | "warn" | "accent" | "neutral" | "runtime"; + +export type StatusLabels = Record; + +/** chant's words — the default, and what a mixed estate keeps. */ +export const CHANT_STATUS_LABELS: StatusLabels = { + good: "managed", + warn: "foreign", + accent: "pending", + neutral: "unobserved", + runtime: "runtime child", +}; + +/** choudoufu's words: `bound[]`, `unowned[]`, an absent instance the plan + * would create, and everything the tool did not answer for. */ +export const CHOUDOUFU_STATUS_LABELS: StatusLabels = { + good: "bound", + warn: "unowned", + accent: "pending", + neutral: "not observed", + runtime: "runtime child", +}; + +/** What the SPA renders the legend, the counts and the inspect status row + * from. `note` is the legend's tooltip — present only when the choice needs + * explaining, which is exactly the mixed estate. */ +export interface StatusVocabulary { + /** Whose words these are: a member kind, or `mixed`. */ + of: string; + labels: StatusLabels; + note?: string; +} + +const CHANT: StatusVocabulary = { of: "chant", labels: CHANT_STATUS_LABELS }; + +/** + * The vocabulary for an estate of these member kinds. + * + * One kind, and behold knows its words: those words. Anything else — several + * kinds, or a kind with no vocabulary of its own — keeps chant's, because two + * vocabularies on one legend would put two meanings on one colour, and the + * legend says so in its tooltip rather than leaving the reader to notice. + */ +export function statusVocabulary(kinds: readonly string[]): StatusVocabulary { + const distinct = [...new Set(kinds)]; + if (distinct.length === 1 && distinct[0] === "choudoufu") return { of: "choudoufu", labels: CHOUDOUFU_STATUS_LABELS }; + if (distinct.length > 1 && distinct.includes("choudoufu")) { + return { + of: "mixed", + labels: CHANT_STATUS_LABELS, + note: `This estate mixes ${distinct.join(" and ")} members, so the legend keeps chant's words — bound reads as managed and unowned as foreign on the choudoufu members.`, + }; + } + return CHANT; +} diff --git a/web/app.js b/web/app.js index c2f32f1..0137c6a 100644 --- a/web/app.js +++ b/web/app.js @@ -188,6 +188,24 @@ function apiFetch(url) { // reaches a declared entity — expected runtime (a Pod its Deployment // created), never foreign and never drift. Additive the same way. const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" }; +// #393 item 8: the same four colours, in the words of whatever the estate's +// members ARE. The server derives the set from the member kinds and puts it on +// the overlay meta (`vocabulary`, src/status-vocabulary.ts) — on a choudoufu +// estate the legend reads bound / unowned / pending / not observed, which is +// what choudoufu itself calls those states. A mixed estate keeps chant's words +// and carries a `note` the legend shows as its tooltip. No meta, or a meta from +// a behold that predates this: chant's words, exactly as before. +function statusLabels() { + const v = lastMeta && lastMeta.vocabulary; + return (v && v.labels) || STATUS_LABEL; +} +function statusLabel(status) { + return statusLabels()[status]; +} +function vocabularyNote() { + const v = lastMeta && lastMeta.vocabulary; + return (v && v.note) || ""; +} // M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join // paints the same `_status` vocabulary (good/warn/accent/neutral) but with // different meaning — a stack-health reading, not "managed" — so the inspect @@ -432,7 +450,7 @@ function inspect(node) { "status", driftLabel || (node.lexicon === "op" ? opStatusLabel(node) : undefined) || - (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) || + (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : statusLabel(st)) || st, ); if (node.attrs && node.attrs._artifact) { @@ -639,7 +657,19 @@ function inspect(node) { const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release)); if (attrKeys.length) { const decl = section("declared"); - for (const k of attrKeys) decl(k, fmtValue(node.attrs[k])); + // #393 item 10: an UNOWNED choudoufu card leads with the write that adopts + // the object — the two marker tags choudoufu named (`adopt_tofu_estate` / + // `adopt_tofu_address`), or its own one-line command if a release ever + // sends one. behold composes neither (src/choudoufu-live.ts); this puts + // the line first and makes it copyable, the way the moves panel already + // does for a `live-mv` line. The paragraph explaining the omission — which + // used to be the whole section, with the tags under it as JSON — follows. + const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null; + if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object"); + for (const k of attrKeys) { + if (k === adoptable) continue; + decl(k, fmtValue(node.attrs[k])); + } } // CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME @@ -803,11 +833,15 @@ function renderObserved(panel, o, health, healthDetail) { // sends the raw condition object gets the tree instead of `[object Object]`. for (const c of conditions) add("condition", valueCell(c), "var(--degraded)"); } + // #393 item 10: the adoption line is a line to run, here as in the declared + // section — first, and with the same copy button. + if (typeof o.attributes?.adopt === "string") copyableRow(add, "adopt", o.attributes.adopt, "Copy the tags that adopt this object"); // #259: an observed attribute is whatever the substrate reported — a k8s // `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible // now, instead of one flat JSON.stringify line per key. for (const [k, v] of Object.entries(o.attributes || {})) { if (k === "conditions") continue; // rendered above, one line each + if (k === "adopt" && typeof v === "string") continue; // led with, above add(k, valueCell(v)); } panel.appendChild(dl); @@ -1293,6 +1327,25 @@ function renderCarveState(host, state) { host.appendChild(panelMuted(state.apply.note)); } +/** + * One inspect row whose value is a line to run: the line itself, monospaced, + * and a copy button beside it — the affordance the moves panel already gives a + * `live-mv` line (#371), reused here for the tags that adopt an unowned object + * (#393 item 10). `add` is a section's own row adder, so the row sits in the + * section it belongs to rather than in a box of its own. + */ +function copyableRow(add, key, text, title) { + const wrap = document.createElement("span"); + wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;min-width:0"; + const line = document.createElement("code"); + line.className = "grow"; + line.style.cssText = "flex:1;min-width:0;overflow-wrap:anywhere"; + line.textContent = text; + const copy = actButton("copy", () => copyToClipboard(text, copy), title); + wrap.append(line, copy); + add(key, wrap); +} + /** Copy `text` to the clipboard and say so on the button for a moment. */ function copyToClipboard(text, el) { const done = () => { @@ -1631,9 +1684,16 @@ function renderPanelModel() { host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id))); } } else if (drift) { - host.appendChild(panelHeading(`drift · ${m.env}`)); - const c = count(STATUS_LABEL); - for (const [k, label] of Object.entries(STATUS_LABEL)) { + // #393 item 8: the legend proper. The heading carries the mixed-estate + // sentence as its tooltip — the one case where the words on these rows are + // not the words the members would use for themselves. + const heading = panelHeading(`drift · ${m.env}`); + const why = vocabularyNote(); + if (why) heading.title = why; + host.appendChild(heading); + const words = statusLabels(); + const c = count(words); + for (const [k, label] of Object.entries(words)) { // The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay // hidden until a chant actually emits them — same as the old legend. if ((k === "neutral" || k === "runtime") && !c[k]) continue; @@ -1648,7 +1708,7 @@ function renderPanelModel() { host.appendChild(panelHeading("needs attention")); for (const n of attention.slice(0, 40)) { const s = n.attrs._status; - host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id))); + host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, statusLabel(s), () => selectNode(n.id))); } if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`)); } @@ -2773,8 +2833,12 @@ function render(ir, svg, m) { const s = n.attrs && n.attrs._status; if (s in c) c[s]++; } - tail = ` · ${c.good} managed · ${c.warn} foreign · ${c.accent} pending`; - if (c.neutral) tail += ` · ${c.neutral} unobserved`; + // #393 item 8: the estate's own words for these four colours — chant's + // managed/foreign/pending/unobserved on a chant estate, choudoufu's + // bound/unowned/pending/not observed on one whose members are choudoufu. + const w = statusLabels(); + tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`; + if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`; if (c.runtime) tail += ` · ${c.runtime} runtime`; // Nothing observed live in this env — explain the all-blue rather than let it // read as a bug (#32). From 2ee2938446272208714fcc88eb03e63821cc58b0 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:17:27 -0600 Subject: [PATCH 07/10] fix(choudoufu, terraform): a card says what its box does not (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A choudoufu card inside the `terralith-4` box was five lines, three of which the box already said: terralith-4/aws_ecs_cluster.main aws_ecs_cluster · choudoufu rung: tag-governable estate: terralith-4 On 301 cards. The member name is the box's title, the estate is the box's estate, `tag-governable` is what all but a handful of instances are, and `choudoufu` is the member kind - one fact about the whole box, none about this card. A Terraform card had the same shape (`access/baseline/ aws_iam_policy.boundary` inside the `baseline` box). Now: the address, the type, and whatever is unusual - a rare rung, an estate the box does not stand for, and, new here, `owned by: ` on a card whose live object belongs to a neighbour, which is the case the estate row was meant to carry and never did. The id does not move. `/
` is what the overlay joins on, what /api/diff keys, what the move plans name and what a click looks up. pinhole 0.3.8 titles a card from `node.id` and has no per-node title channel, so src/card-face.ts feeds the paint a display IR and puts the real ids back on the finished SVG. That sets the one limit: a label two cards would share is used for neither, because a `data-node-id` two cards claim restores to one of them. The live-mv workbench is one estate split four ways and every one of its 42 addresses is declared twice, so it keeps its full ids and reads as it did; a member whose addresses are its own shortens every card. Measured on a real unadopted terralith (79 cards, stock terraform applied, choudoufu 0.16.0): every title short, one field row left on 41 of 79. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- AGENTS.md | 15 ++- src/card-face.test.ts | 149 ++++++++++++++++++++++++++++ src/card-face.ts | 185 +++++++++++++++++++++++++++++++++++ src/choudoufu-member.test.ts | 34 +++++-- src/choudoufu-member.ts | 45 ++++++--- src/render.ts | 18 +++- src/terraform-lens.ts | 8 ++ 7 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 src/card-face.test.ts create mode 100644 src/card-face.ts diff --git a/AGENTS.md b/AGENTS.md index 9d64941..3e67376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,11 +390,20 @@ until the table existed. A new kind is: `/api/overlay`'s — run the same passes in the same order and must not fork per kind; a kind's differences live inside its `read`. 3. A `registerPack({ lexicon, iconFor, fields })` in `src/render.ts`, or the - kind's cards lead with the alphabetically first two short attrs. -4. A `DoctorCheck` line when the kind needs a tool on PATH or a per-member + kind's cards lead with the alphabetically first two short attrs. A `fields` + function takes the box the card is drawn in as its second argument, so a + row the box already carries can be left off (#393 item 9, + `src/card-face.ts`). +4. A row in `statusVocabulary` (`src/status-vocabulary.ts`) when the kind has + its own words for the four overlay colours — choudoufu's `bound` is not + chant's `managed`, and a legend that says `managed` over a card carrying no + marker is a claim behold has no right to make. The colours never move; only + the naming does, and an estate of several kinds keeps chant's words and says + so in the legend's tooltip. +5. A `DoctorCheck` line when the kind needs a tool on PATH or a per-member precondition (a version floor checked before the spawn, the way `carveStatusReader` does; a PATH probe the way `src/demos.ts` does). -5. Tests: the probe and the object form in `src/project.test.ts`; dispatch +6. Tests: the probe and the object form in `src/project.test.ts`; dispatch in `src/estate.test.ts`'s "#368" block, which registers a fake kind and asserts chant members still go through exactly the calls they did; the kind's own reader off recorded documents in `src/__fixtures__/`, with diff --git a/src/card-face.test.ts b/src/card-face.test.ts new file mode 100644 index 0000000..5e78c0e --- /dev/null +++ b/src/card-face.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import type { GraphIR } from "@intentius/chant"; +import { cardFaces, cardLabel } from "./card-face.ts"; +import { renderGraph } from "./render.ts"; + +/** The painted card titles, in document order: pinhole draws the title at + * font-size 15, weight 700, and nothing else on a card does. */ +const titles = (svg: string): string[] => [...svg.matchAll(/font-size="15" font-weight="700">([^<]*) m[1]); +/** The second line — `kind · lexicon` before this rule, the kind alone after. */ +const subs = (svg: string): string[] => [...svg.matchAll(/fill="var\(--pin-textFaint[^"]*\)" font-size="11">([^<]*) m[1]); +const rows = (svg: string): string[] => [...svg.matchAll(/([^<]*)<\/tspan>/g)].map((m) => m[1].replace(/: $/, "")); + +const estateIr = (): GraphIR => ({ + nodes: [ + { id: "terralith-4/aws_ecs_cluster.main", kind: "aws_ecs_cluster", lexicon: "choudoufu", attrs: { estate: "terralith-4", rung: "tag-governable", _status: "good" } }, + { id: "terralith-4/aws_iam_role.odd", kind: "aws_iam_role", lexicon: "choudoufu", attrs: { estate: "terralith-4", rung: "record-only", _status: "neutral" } }, + ], + edges: [], + groups: { byStack: { "terralith-4": ["terralith-4/aws_ecs_cluster.main", "terralith-4/aws_iam_role.odd"] } }, +}); + +describe("cardLabel (#393 item 9)", () => { + it("takes the box's own name off a choudoufu card's title", () => { + expect(cardLabel(estateIr().nodes[0], "terralith-4")).toBe("aws_ecs_cluster.main"); + }); + + it("titles a Terraform card with the address, not //
", () => { + const node = { + id: "access/baseline/aws_iam_policy.boundary", + kind: "aws_iam_policy", + lexicon: "terraform", + attrs: { block: "resource", address: "aws_iam_policy.boundary", root: "baseline" }, + }; + // The box is the bare root, so the id does not start with it — the address + // the lexicon itself put on the node is the answer. + expect(cardLabel(node, "baseline")).toBe("aws_iam_policy.boundary"); + // And when the box IS qualified, the prefix rule reaches the same string. + expect(cardLabel(node, "access/baseline")).toBe("aws_iam_policy.boundary"); + }); + + it("leaves a chant card alone", () => { + expect(cardLabel({ id: "loomster/loomDb", kind: "AWS::RDS::DBInstance", lexicon: "aws", attrs: {} }, "loomster")).toBeUndefined(); + }); +}); + +describe("cardFaces", () => { + it("hands a chant estate its own IR back, with nothing to restore", () => { + const ir: GraphIR = { + nodes: [{ id: "loomster/loomDb", kind: "AWS::RDS::DBInstance", lexicon: "aws", attrs: {} }], + edges: [], + groups: { byStack: { loomster: ["loomster/loomDb"] } }, + }; + const face = cardFaces(ir, ir.groups.byStack); + expect(face.ir).toBe(ir); + expect(face.overrides).toEqual({}); + expect(face.restore('')).toBe(''); + }); + + it("keeps the full id on both cards when two members declare the same address", () => { + const ir: GraphIR = { + nodes: [ + { id: "a/aws_iam_role.shared", kind: "aws_iam_role", lexicon: "choudoufu", attrs: { estate: "a" } }, + { id: "b/aws_iam_role.shared", kind: "aws_iam_role", lexicon: "choudoufu", attrs: { estate: "b" } }, + ], + edges: [], + groups: { byStack: { a: ["a/aws_iam_role.shared"], b: ["b/aws_iam_role.shared"] } }, + }; + const face = cardFaces(ir, ir.groups.byStack); + expect(face.ir.nodes.map((n) => n.id)).toEqual(["a/aws_iam_role.shared", "b/aws_iam_role.shared"]); + }); + + it("remaps edge endpoints with the nodes, and puts every id back", () => { + const ir: GraphIR = { + nodes: [ + { id: "m/aws_iam_role.r", kind: "aws_iam_role", lexicon: "choudoufu", attrs: { estate: "m" } }, + { id: "m/data.aws_vpc.net", kind: "data.aws_vpc", lexicon: "choudoufu", attrs: { estate: "m", producer: { estate: "other", address: "aws_vpc.net" } } }, + ], + edges: [{ from: "m/aws_iam_role.r", to: "m/data.aws_vpc.net", kind: "ref", viaAttr: "reads" }], + groups: { byStack: { m: ["m/aws_iam_role.r", "m/data.aws_vpc.net"] } }, + }; + const face = cardFaces(ir, ir.groups.byStack); + expect(face.ir.edges[0]).toMatchObject({ from: "aws_iam_role.r", to: "data.aws_vpc.net" }); + expect(face.ir.groups.byStack).toEqual({ m: ["aws_iam_role.r", "data.aws_vpc.net"] }); + expect(face.restore('')).toBe( + '', + ); + }); +}); + +describe("renderGraph paints the short face and keeps the real ids (#393 item 9)", () => { + it("titles a choudoufu card with the address alone", () => { + const { svg } = renderGraph(estateIr(), { boxes: "byStack" }); + expect(titles(svg)).toEqual(expect.arrayContaining(["aws_ecs_cluster.main", "aws_iam_role.odd"])); + expect(svg).not.toContain("terralith-4/aws_ecs_cluster.main<"); + }); + + it("keeps /
as the node id the SPA joins on", () => { + const { svg } = renderGraph(estateIr(), { boxes: "byStack" }); + expect(svg).toContain('data-node-id="terralith-4/aws_ecs_cluster.main"'); + expect(svg).toContain('data-node-id="terralith-4/aws_iam_role.odd"'); + }); + + it("drops the word choudoufu from line two and keeps the type", () => { + const { svg } = renderGraph(estateIr(), { boxes: "byStack" }); + expect(subs(svg)).toEqual(expect.arrayContaining(["aws_ecs_cluster", "aws_iam_role"])); + expect(svg).not.toContain("choudoufu"); + }); + + it("drops the estate row the box already stands for, and the rung all but a handful sit on", () => { + const { svg } = renderGraph(estateIr(), { boxes: "byStack" }); + // The common rung says nothing; the rare one is the whole reason to look. + expect(rows(svg)).toEqual(["rung"]); + expect(svg).toContain("record-only"); + }); + + it("keeps the estate row when a box holds cards from two estates", () => { + const ir = estateIr(); + ir.nodes[0].attrs.estate = "somewhere-else"; + const { svg } = renderGraph(ir, { boxes: "byStack" }); + expect(rows(svg)).toContain("estate"); + expect(svg).toContain("somewhere-else"); + }); + + it("names the estate that owns an unowned card", () => { + const ir = estateIr(); + ir.nodes[0].attrs = { estate: "terralith-4", rung: "tag-governable", ownedBy: "neighbour", _status: "warn" }; + const { svg } = renderGraph(ir, { boxes: "byStack" }); + expect(rows(svg)).toContain("owned by"); + expect(svg).toContain("neighbour"); + }); + + it("titles a Terraform card with its address inside its root box", () => { + const ir: GraphIR = { + nodes: [ + { + id: "access/baseline/aws_iam_policy.boundary", + kind: "aws_iam_policy", + lexicon: "terraform", + attrs: { block: "resource", address: "aws_iam_policy.boundary", root: "baseline" }, + }, + ], + edges: [], + groups: { byStack: { baseline: ["access/baseline/aws_iam_policy.boundary"] } }, + }; + const { svg } = renderGraph(ir, { boxes: "byStack" }); + expect(titles(svg)).toEqual(["aws_iam_policy.boundary"]); + expect(svg).toContain('data-node-id="access/baseline/aws_iam_policy.boundary"'); + }); +}); diff --git a/src/card-face.ts b/src/card-face.ts new file mode 100644 index 0000000..bb01f15 --- /dev/null +++ b/src/card-face.ts @@ -0,0 +1,185 @@ +/** + * What a card says that its box does not (#393 item 9). + * + * A choudoufu card inside the `terralith-4` box read: + * + * terralith-4/aws_ecs_cluster.main + * aws_ecs_cluster · choudoufu + * rung: tag-governable + * estate: terralith-4 + * + * Five lines, three of which the box already says — on 301 cards. The member + * name is the box's title, the estate is the box's estate, `tag-governable` is + * what all but a handful of instances are, and the word `choudoufu` is the + * member kind, which is one fact about the whole box and none about this card. + * A Terraform card had the same shape (`access/baseline/aws_iam_policy.boundary` + * inside the `baseline` box). + * + * So the card keeps its address, its type and whatever is unusual about it. + * + * **The id does not move.** `/
` is what the overlay joins on, + * what `/api/diff` keys, what the move plans name and what the SPA looks a + * clicked card up by. Only the painted label changes. + * + * That distinction is the awkward part: pinhole 0.3.8 titles a card from + * `node.id` and has no per-node title channel (`NodeOverride` carries `fields` + * and nothing else), and the sub-line is `kind · lexicon`, also unconditional. + * So the paint is fed a *display* IR — the same graph with the shortened id and + * the lexicon dropped where the pack has already said what it means — and the + * ids are put back on the finished SVG, on the three attributes that carry them + * (`data-node-id`, `data-edge-from`, `data-edge-to`). Everything else about the + * render is unchanged, and the IR the route returns to the SPA is the original. + * + * That trick sets the one limit here: a label two cards would share is not used + * for either of them. Not for looks — the boxes would tell those two apart on + * the canvas — but because the id is how the label gets back to being an id + * again, and a `data-node-id` that two cards claim restores to one of them. + * Two members declaring the same address is a real estate, not a corner: the + * live-mv workbench is one estate split four ways, and every one of its 42 + * addresses is declared twice, so it keeps the full ids and reads exactly as it + * did. A member whose addresses are its own — the terraliths, a Terraform + * root — shortens every card. + */ +import type { GraphIR, IRNode } from "@intentius/chant"; +import type { NodeOverride } from "@intentius/pinhole"; +import { CHOUDOUFU_LEXICON, choudoufuCardFields } from "./choudoufu-member.ts"; +import { isTerraformCard } from "./terraform-lens.ts"; + +/** The three SVG attributes pinhole stamps a node id onto. */ +const ID_ATTRS = ["data-node-id", "data-edge-from", "data-edge-to"] as const; + +/** pinhole's own attribute escaping (`esc`, paint/svg.ts), so the ids put back + * on the SVG are byte-identical to the ones taken off it. */ +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** The box each node is drawn in, from the boxes actually drawn — not from + * `groups.byStack` when the render asked for different ones, because the rule + * here is "what the BOX already says", and an undrawn group says nothing. */ +function boxOf(boxes: Record | undefined): Map { + const out = new Map(); + for (const [box, ids] of Object.entries(boxes ?? {})) for (const id of ids) if (!out.has(id)) out.set(id, box); + return out; +} + +/** + * The estate each box stands for: the one every choudoufu card in it declares. + * + * A box's TITLE is the member's short directory name, which is often the + * estate's name and is never guaranteed to be (the member behold was audited + * against sits in `behold-audit-t1` and declares `behold-audit-inspect`). What + * the box actually IS, though, is one estate — that is what a choudoufu member + * is — so the fact a card would be repeating is the estate its neighbours all + * carry, not the string on the title row. A box holding two estates gets no + * answer and every card in it keeps its `estate` row, which is the one case + * where that row is telling the reader something. + */ +function estateOfBox(ir: GraphIR, boxes: Record | undefined): Map { + const byId = new Map(ir.nodes.map((n) => [n.id, n] as const)); + const out = new Map(); + for (const [box, ids] of Object.entries(boxes ?? {})) { + const estates = new Set(); + for (const id of ids) { + const n = byId.get(id); + if (n?.lexicon === CHOUDOUFU_LEXICON && typeof n.attrs.estate === "string") estates.add(n.attrs.estate); + } + if (estates.size === 1) out.set(box, [...estates][0]!); + } + return out; +} + +/** + * The label a card paints instead of its id, or undefined to keep the id. + * + * The general rule is the box prefix: a composed id is `/`, and the + * box's own title is on the box. Terraform's roots are the boxes but its ids + * are `//
`, and the box is titled `` alone unless + * two members made it ambiguous — so a terraform entity falls back to the + * address the lexicon put in `attrs.address`, which is the same string either + * way. + */ +export function cardLabel(node: Pick, box?: string): string | undefined { + const choudoufu = node.lexicon === CHOUDOUFU_LEXICON; + const terraform = isTerraformCard(node); + if (!choudoufu && !terraform) return undefined; + if (box && node.id.startsWith(`${box}/`) && node.id.length > box.length + 1) return node.id.slice(box.length + 1); + if (terraform && typeof node.attrs.address === "string" && node.attrs.address && node.attrs.address !== node.id) return node.attrs.address; + return undefined; +} + +export interface CardFaces { + /** The IR to lay out and paint — shortened labels, no more. */ + ir: GraphIR; + /** Per-node field overrides, keyed by the DISPLAY id, for `layoutIr` and + * `renderSvg` both (they must agree, or spacing and drawing diverge). */ + overrides: Record; + /** Put the real ids back on a finished SVG. */ + restore(svg: string): string; +} + +/** + * The display IR, the field overrides and the id restore for one render. + * + * A no-op — the same IR object back, no overrides, an identity restore — for a + * graph with no choudoufu or Terraform cards in it, which is every chant + * estate. + */ +export function cardFaces(ir: GraphIR, boxes?: Record): CardFaces { + const box = boxOf(boxes); + const boxEstate = estateOfBox(ir, boxes); + const wanted = new Map(); + for (const n of ir.nodes) { + const label = cardLabel(n, box.get(n.id)); + if (label) wanted.set(n.id, label); + } + // A label that names two cards names neither: drop the shortening for every + // node that would land on a taken name, including one an untouched id holds. + const taken = new Map(); + for (const n of ir.nodes) { + const display = wanted.get(n.id) ?? n.id; + taken.set(display, (taken.get(display) ?? 0) + 1); + } + for (const [id, label] of [...wanted]) if ((taken.get(label) ?? 0) > 1) wanted.delete(id); + + const overrides: Record = {}; + const choudoufu = ir.nodes.some((n) => n.lexicon === CHOUDOUFU_LEXICON); + if (wanted.size === 0 && !choudoufu) return { ir, overrides, restore: (svg) => svg }; + const nodes = ir.nodes.map((n) => { + const display = wanted.get(n.id) ?? n.id; + if (n.lexicon !== CHOUDOUFU_LEXICON) return display === n.id ? n : { ...n, id: display }; + // The card's second line is `kind · lexicon`, and for a choudoufu card the + // kind IS the Terraform type — the thing the icon already stands for — so + // the lexicon is one more repetition of what the box is. Dropped from the + // paint only; the inspect pane still names the lexicon, and pinhole's icon + // chain lands on the same glyph either way (the choudoufu pack has no + // `iconFor` opinion, so both routes reach the keyword heuristic). + const inBox = box.get(n.id); + const fields = choudoufuCardFields(n, inBox ? { boxEstate: boxEstate.get(inBox) } : {}); + overrides[display] = { fields: fields ?? [] }; + return { ...n, id: display, lexicon: "" }; + }); + const back = new Map(); + for (const [id, label] of wanted) back.set(esc(label), esc(id)); + const display: GraphIR = { + ...ir, + nodes, + edges: ir.edges.map((e) => ({ ...e, from: wanted.get(e.from) ?? e.from, to: wanted.get(e.to) ?? e.to })), + groups: Object.fromEntries( + Object.entries(ir.groups ?? {}).map(([key, value]) => [ + key, + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value as Record).map(([g, ids]) => [g, (ids ?? []).map((id) => wanted.get(id) ?? id)])) + : value, + ]), + ) as GraphIR["groups"], + }; + const restore = (svg: string): string => + back.size === 0 + ? svg + : svg.replace(new RegExp(`(${ID_ATTRS.join("|")})="([^"]*)"`, "g"), (whole, attr: string, value: string) => { + const real = back.get(value); + return real === undefined ? whole : `${attr}="${real}"`; + }); + return { ir: display, overrides, restore }; +} diff --git a/src/choudoufu-member.test.ts b/src/choudoufu-member.test.ts index 9230bc0..1293ae7 100644 --- a/src/choudoufu-member.test.ts +++ b/src/choudoufu-member.test.ts @@ -234,15 +234,37 @@ describe("addChoudoufuReferenceEdges — the cross-member edge (#369, #366's ope }); }); -describe("choudoufuCardFields — the presentation pack (#369)", () => { - it("leads with rung and estate for an instance, and the producer for a data source", () => { - expect(choudoufuCardFields({ attrs: { estate: "e", rung: "tag-governable", schemas: "builtin" } })).toEqual([ - { label: "rung", value: "tag-governable" }, - { label: "estate", value: "e" }, - ]); +describe("choudoufuCardFields — the presentation pack (#369, #393 item 9)", () => { + it("shows the producer for a data source", () => { expect(choudoufuCardFields({ attrs: { estate: "app", producer: { estate: "net", address: "aws_vpc.main" } } })).toEqual([{ label: "reads", value: "net aws_vpc.main" }]); expect(choudoufuCardFields({ attrs: { score: 3 } })).toBeUndefined(); }); + + it("keeps the estate row only when the box is not already that estate", () => { + const attrs = { estate: "e", rung: "record-only", schemas: "builtin" }; + // No box named: nothing is being repeated, so nothing is dropped. + expect(choudoufuCardFields({ attrs })).toEqual([ + { label: "rung", value: "record-only" }, + { label: "estate", value: "e" }, + ]); + expect(choudoufuCardFields({ attrs }, { boxEstate: "e" })).toEqual([{ label: "rung", value: "record-only" }]); + expect(choudoufuCardFields({ attrs }, { boxEstate: "other" })).toEqual([ + { label: "rung", value: "record-only" }, + { label: "estate", value: "e" }, + ]); + }); + + it("drops the rung all but a handful of instances sit on", () => { + expect(choudoufuCardFields({ attrs: { estate: "e", rung: "tag-governable" } }, { boxEstate: "e" })).toBeUndefined(); + expect(choudoufuCardFields({ attrs: { estate: "e", rung: "declaration-carried" } }, { boxEstate: "e" })).toEqual([{ label: "rung", value: "declaration-carried" }]); + }); + + it("names the estate that holds an unowned object, and says when nothing declares a marked one", () => { + expect(choudoufuCardFields({ attrs: { estate: "e", rung: "tag-governable", ownedBy: "neighbour" } }, { boxEstate: "e" })).toEqual([{ label: "owned by", value: "neighbour" }]); + expect(choudoufuCardFields({ attrs: { estate: "e", marked: "carries this estate's marker, declared nowhere in its configuration" } }, { boxEstate: "e" })).toEqual([ + { label: "declared", value: "nowhere" }, + ]); + }); }); // #372: the demo's scratch emulator reaches the spawns through a seam, never diff --git a/src/choudoufu-member.ts b/src/choudoufu-member.ts index 8585158..9c9ce1e 100644 --- a/src/choudoufu-member.ts +++ b/src/choudoufu-member.ts @@ -334,22 +334,45 @@ export function addChoudoufuReferenceEdges(ir: GraphIR): GraphIR { return ir; } -/** The two fields a choudoufu card leads with (pinhole presentation pack, - * registered in src/render.ts): the rung and the estate for an instance, the - * producer for a data source. Without a pack the card would lead with the - * first two short attrs alphabetically — `estate` then `rung` by accident - * today, `reason` the day a refusal appears. */ -export function choudoufuCardFields(node: { attrs: Record }): Array<{ label: string; value: string }> | undefined { +/** The rung all but a handful of instances sit on: a type whose ownership the + * account can be asked about directly. It is the default answer, so a card + * that says it says nothing; the two rare rungs are the interesting ones. */ +export const COMMON_RUNG = "tag-governable"; + +/** + * The rows a choudoufu card shows (pinhole presentation pack, registered in + * src/render.ts): the producer for a data source, and for an instance whatever + * the box it sits in does NOT already say (#393 item 9). Without a pack the + * card would lead with the first two short attrs alphabetically — `estate` + * then `rung` by accident today, `reason` the day a refusal appears. + * + * `ctx.boxEstate` is the estate the box this card is drawn in already stands + * for — the one every card in it declares (src/card-face.ts works it out from + * the boxes actually drawn). Absent — an unboxed render, a box holding two + * estates, or any caller that reaches the pack directly — means nothing is + * being repeated and every row is kept, which is what the card said before + * this rule existed. + */ +export function choudoufuCardFields( + node: { attrs: Record }, + ctx: { boxEstate?: string } = {}, +): Array<{ label: string; value: string }> | undefined { const a = node.attrs; const producer = a.producer as { estate?: unknown; address?: unknown } | undefined; if (isRecord(producer) && typeof producer.estate === "string") { return [{ label: "reads", value: typeof producer.address === "string" ? `${producer.estate} ${producer.address}` : producer.estate }]; } - if (typeof a.rung !== "string" && typeof a.estate !== "string") return undefined; - return [ - ...(typeof a.rung === "string" ? [{ label: "rung", value: a.rung }] : []), - ...(typeof a.estate === "string" ? [{ label: "estate", value: a.estate }] : []), - ]; + const rows: Array<{ label: string; value: string }> = []; + // The live object at this address belongs to a DIFFERENT estate (#370's + // `unowned[].tofu_estate`) — the one estate name on a card that is not the + // box's, and the reason the card is yellow. + if (typeof a.ownedBy === "string") rows.push({ label: "owned by", value: a.ownedBy }); + if (typeof a.rung === "string" && a.rung !== COMMON_RUNG) rows.push({ label: "rung", value: a.rung }); + if (typeof a.estate === "string" && a.estate !== ctx.boxEstate) rows.push({ label: "estate", value: a.estate }); + // Marked for this estate and declared nowhere: a warn card among bound ones + // whose reason is otherwise only in the inspect pane. + if (!rows.length && typeof a.marked === "string") rows.push({ label: "declared", value: "nowhere" }); + return rows.length ? rows : undefined; } // --------------------------------------------------------------------------- diff --git a/src/render.ts b/src/render.ts index 1112ac2..54a2bd5 100644 --- a/src/render.ts +++ b/src/render.ts @@ -22,6 +22,7 @@ import type { GraphIR, IRGroups, IRNode, Layout } from "@intentius/chant"; import type { ByContainer } from "./logical.ts"; import { k8sIconFor, helmIconFor } from "./icon-packs.ts"; import { carveCardFields } from "./carve-lens.ts"; +import { cardFaces } from "./card-face.ts"; import { CHOUDOUFU_LEXICON, choudoufuCardFields } from "./choudoufu-member.ts"; import { terraformCardFields } from "./terraform-lens.ts"; import { carveProgress, splitCarveState, type CarveState } from "./carve-manifest.ts"; @@ -165,11 +166,19 @@ type ExtraGroups = IRGroups & { byWave?: Record; byStack?: Rec * on the live-overlay render path, which passes this explicitly — same * "caller knows" discipline as `byStack`, since a source-only or component-DAG * graph never carries a meaningful `byContainer` to box. */ -export function renderGraph(ir: GraphIR, opts: { theme?: string; boxes?: "byStack" | "byContainer"; radial?: boolean } = {}): RenderResult { +export function renderGraph(irIn: GraphIR, opts: { theme?: string; boxes?: "byStack" | "byContainer"; radial?: boolean } = {}): RenderResult { + const groupsIn = irIn.groups as ExtraGroups; + const boxKey = groupsIn.byWave ? "byWave" : opts.boxes; + // #393 item 9: a choudoufu or Terraform card is painted with the address + // alone and the rows the box does not already carry. The ids are unchanged — + // `cardFaces` hands back a display IR to paint from and puts the real ids + // back on the finished SVG (src/card-face.ts) — and every other estate gets + // the same object it passed in. + const face = cardFaces(irIn, boxKey ? groupsIn[boxKey] : undefined); + const ir = face.ir; const groups = ir.groups as ExtraGroups; - const boxKey = groups.byWave ? "byWave" : opts.boxes; const boxes = boxKey ? groups[boxKey] : undefined; - const layout = layoutIr(ir, { fit: true, ...(boxes ? { groups: boxes } : {}) }); + const layout = layoutIr(ir, { fit: true, overrides: face.overrides, ...(boxes ? { groups: boxes } : {}) }); // Radial layout (opt-in): dagre lays a wide DAG out in horizontal ranks that // sprawl off-screen. Re-place the same nodes on concentric rings — one ring // per rank — so the graph curls around a centre and far more fits in view. Only @@ -188,10 +197,11 @@ export function renderGraph(ir: GraphIR, opts: { theme?: string; boxes?: "byStac const svg = renderSvg(ir, layout, { fit: true, hideTitle: true, + overrides: face.overrides, ...(boxes ? { groups: layout.groups } : {}), ...(opts.theme ? { theme: opts.theme as never } : {}), }); - return { svg }; + return { svg: face.restore(svg) }; } /** diff --git a/src/terraform-lens.ts b/src/terraform-lens.ts index bd4f5e8..b1c36e9 100644 --- a/src/terraform-lens.ts +++ b/src/terraform-lens.ts @@ -67,6 +67,14 @@ export function isTerraformEntity(node: Pick): boole return node.lexicon === TERRAFORM_LEXICON && node.kind in BLOCK_OF; } +/** Is this a terraform entity the way it looks AFTER `normalizeTerraformNodes` + * — the resource type in `kind`, the block class in `attrs.block`? The two + * predicates are the two sides of that pass, and a caller that runs on a + * rendered graph (src/card-face.ts) sees only this side. */ +export function isTerraformCard(node: Pick): boolean { + return node.lexicon === TERRAFORM_LEXICON && typeof node.attrs.block === "string" && node.attrs.block in BLOCK_LABEL; +} + /** Does this IR carry any of them? Every pass below is a no-op otherwise, and * this is the guard that keeps a chant or k8s estate byte-identical. */ export function hasTerraformEntities(ir: Pick): boolean { From 493cb4e5cf781999d078bc49574f94082db66b64 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:19 -0600 Subject: [PATCH 08/10] feat(choudoufu): intra-estate edges from the terraform lexicon, joined to the roster by address (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `choudoufu live-check -json` states the roster and `references[]`, and that second list is cross-estate by construction — data sources filtered on another estate's marker tags. So `terralith-4` (301 roles, policies, attachments and profiles) drew no edge at all and the graph asserted "nothing in this estate references anything else", which is false about every one of them. Nothing was reading the references. chant's terraform lexicon reads the same HCL and, at 0.61 (chant#2265), resolves a block's `"${…}"` interpolations. A choudoufu member's read now also runs the terraform kind's reader over its own directory — one root, the estate itself, through the scratch-project machinery src/terraform-member.ts already has, so nothing is written under the estate — and joins the two documents by address (src/choudoufu-refs.ts). behold still parses no HCL: the join is two lists of strings. The lexicon names BLOCKS in a path of module CALLS; choudoufu names INSTANCES in a path of module INSTANCES. One lexicon edge is therefore a product, and four rules cut it down, in decreasing order of what Terraform itself guarantees: kept inside one module instance (scoping, not a guess); joined key to key when both ends expand over the same keys (266 edges on terralith-4, against 666 of which ~400 would say a role is attached to a policy it is not); landing on a module call's whole interior; dropped whole when either end is a `var`, a `locals` or anything else the roster declares no card for (17 of the estate's 175 lexicon edges). Every edge is `inferred` and carries the lexicon's own attribute name, so the card says `role` or `policy_arn` rather than "ref". Without the lexicon there are no edges and the note says so, carrying the terraform kind's own install line rather than a claim behold has no reader to make; the lexicon's version joins the member's cache stamp, so installing it invalidates the edgeless IR instead of serving it forever. The layout half is `packBoxComponents` (src/render.ts). src/edgeless.ts wraps a box whose cards reference NOTHING, so a box that has edges is never wrapped and terralith-4 came back a 110996 x 628 strip the day the join landed. The pass packs a box's components into shelves and resizes the box around them, re-laying each component on its own first (inside a cluster dagre interleaves them — a six-card cluster's bounding box spanned 46598 units) and wrapping a component that is a strip either way (the DNS fan is 19564 x 400 upright, 900 x 10046 on its side). A box under 4:1 is left exactly as dagre laid it, so every existing picture is unchanged. Measured on the workbench: terralith-4 7660 x 5162 with 266 edges and both module sub-boxes still drawn, 8186 x 3436 unchanged with the lexicon absent; waterpark 3536 x 3138 at detail 2 and 7142 x 6974 at detail 3, from 4.2:1 and 15.1:1 — the case #393's wrap did not answer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- AGENTS.md | 43 +++ e2e/workbench-e2e.sh | 12 + .../choudoufu-lexicon-terralith.json | 343 +++++++++++++++++ .../choudoufu-live-check-terralith.json | 357 ++++++++++++++++++ src/choudoufu-live.ts | 17 +- src/choudoufu-refs.test.ts | 258 +++++++++++++ src/choudoufu-refs.ts | 354 +++++++++++++++++ src/edgeless.ts | 13 +- src/render.test.ts | 80 ++++ src/render.ts | 296 ++++++++++++++- src/server.ts | 30 +- src/zoom-notes.test.ts | 24 ++ src/zoom-notes.ts | 11 +- 13 files changed, 1820 insertions(+), 18 deletions(-) create mode 100644 src/__fixtures__/choudoufu-lexicon-terralith.json create mode 100644 src/__fixtures__/choudoufu-live-check-terralith.json create mode 100644 src/choudoufu-refs.test.ts create mode 100644 src/choudoufu-refs.ts diff --git a/AGENTS.md b/AGENTS.md index 10169f7..51d7b98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -653,3 +653,46 @@ absolute paths cost every `../modules/x` call the estate makes — 72 nodes and resources on water park, against 247 and 43 through the symlink. Nothing in the answer mentions the scratch path (a terraform entity carries `attrs.file` relative to its root, and no `sourceLoc`). + +### A choudoufu estate's own references (#393) + +`choudoufu live-check -json` states the roster and `references[]`, and that +second list is *cross-estate by construction* — data sources filtered on +another estate's marker tags. So a 301-resource terralith of roles, policies +and attachments drew no edge at all and the graph asserted "nothing in this +estate references anything else", which is false about every one of them. + +The references were never missing; nothing was reading them. **A choudoufu +member's read now also runs the terraform kind's reader over its own +directory** — one root, the estate directory itself, through the same scratch +project machinery above (nothing written under the estate) — and joins the two +documents by address. behold still parses no HCL: `src/choudoufu-refs.ts` is +two lists of strings and the rules that match them, and its header is the +argument for each. In short: the lexicon names *blocks* in a path of module +*calls* (`estate/module.team_pod/aws_iam_role.pod_role`) and choudoufu names +*instances* in a path of module *instances* +(`module.team_pod["pod-a"].aws_iam_role.pod_role[0]`), so one lexicon edge is a +product — kept inside one module instance (Terraform's scoping, not a guess), +joined key to key when both ends expand over the same keys (266 true edges on +`terralith-4` against 666 of which ~400 would be false), landing on a module +call's whole interior, and dropped whole when either end is a `var`, a +`locals` or anything else the roster declares no card for. + +Every edge is `inferred` and carries the lexicon's own attribute name (`role`, +`policy_arn`), so the card says what made the reference. **Without the +lexicon there are no edges and the note says so** — carrying the terraform +kind's own install line rather than the sentence behold has no reader to +assert — and the lexicon's version is in the member's cache stamp, so +installing it invalidates the edgeless IR rather than serving it forever. + +The layout half is `packBoxComponents` (src/render.ts). src/edgeless.ts wraps a +box whose cards reference *nothing*; a box that has edges is never wrapped, so +`terralith-4` came back as a 110996 x 628 strip the day the join landed — +dagre lays 42 connected components side by side on three ranks. The pass packs +a box's components into shelves and resizes the box around them, re-laying each +component on its own first (inside a cluster dagre interleaves them: a six-card +cluster's bounding box spanned 46598 units) and wrapping a component that is a +strip either way — the DNS fan is 19564 x 400 upright and 900 x 10046 on its +side. A box under 4:1 is left exactly as it laid out. Measured: `terralith-4` +7660 x 5162 with 266 edges, waterpark 3536 x 3138 at detail 2 and 7142 x 6974 +at detail 3 (from 4.2:1 and 15.1:1, the case #393's wrap did not answer). diff --git a/e2e/workbench-e2e.sh b/e2e/workbench-e2e.sh index cd4ba93..bf32d07 100755 --- a/e2e/workbench-e2e.sh +++ b/e2e/workbench-e2e.sh @@ -198,6 +198,18 @@ while IFS=$'\x1f' read -r name env inplace local reason <&3; do jq_assert "$G2" "(.svg | capture(\"viewBox=\\\"0 0 (?[0-9.]+) (?[0-9.]+)\\\"\") | (.w|tonumber) / (.h|tonumber)) < 4" \ "$(printf 'the graph is %.2f:1 at detail 2 — not a strip' "$ratio")" jq_assert "$G2" '.svg | test("301 resources")' "the member box is badged with its count" + # #393 item 1: the estate's own references, joined from chant's terraform + # lexicon to choudoufu's roster by address (src/choudoufu-refs.ts). 266 on + # this estate with the lexicon installed; without it there are none and the + # note says which install line would find them, so this asserts whichever + # of the two is true rather than skipping the entry. + if [ -n "$(printf '%s' "$G2" | jq -r '.meta.note // "" | select(test("terraform lexicon"))')" ]; then + jq_assert "$G2" '(.ir.edges | length) == 0 and (.meta.note | test("chant-lexicon-terraform"))' \ + "no terraform lexicon beside behold — the note names the install line instead of claiming the estate references nothing" + else + jq_assert "$G2" "(.ir.edges | length) > 0" \ + "$(printf '%s intra-estate edges at detail 2 — the roles and their attachments' "$(printf '%s' "$G2" | jq -r '.ir.edges|length')")" + fi C="$(api "$port" "/api/graph?detail=2&collapse=1")" jq_assert "$C" '(.ir.nodes | length) == 1 and (.ir.nodes[0].id | startswith("box:"))' "?collapse=1 draws the 301-card box as one summary card" fi diff --git a/src/__fixtures__/choudoufu-lexicon-terralith.json b/src/__fixtures__/choudoufu-lexicon-terralith.json new file mode 100644 index 0000000..fc26a0c --- /dev/null +++ b/src/__fixtures__/choudoufu-lexicon-terralith.json @@ -0,0 +1,343 @@ +{ + "nodes": [ + { + "id": "estate/aws_iam_instance_profile.team_0000_profile", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_instance_profile.team_0000_profile", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_iam_policy.count_team_policy", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_policy.count_team_policy", + "file": "iam.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/aws_iam_policy.team_0000_policy", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_policy.team_0000_policy", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_iam_role_policy_attachment.count_team_custom_attach", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach", + "file": "iam.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/aws_iam_role_policy_attachment.team_0000_custom_attach", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy_attachment.team_0000_custom_attach", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_iam_role_policy_attachment.team_0000_managed_attach", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy_attachment.team_0000_managed_attach", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_iam_role_policy.count_team_inline", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy.count_team_inline", + "file": "iam.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/aws_iam_role_policy.team_0000_inline", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy.team_0000_inline", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_iam_role.count_team", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role.count_team", + "file": "iam.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/aws_iam_role.team_0000_role", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role.team_0000_role", + "file": "iam.tf", + "root": "estate" + } + }, + { + "id": "estate/aws_route53_record.record", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_route53_record.record", + "file": "dns.tf", + "root": "estate", + "expansion": "for_each" + } + }, + { + "id": "estate/aws_route53_zone.main", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_route53_zone.main", + "file": "dns.tf", + "root": "estate" + } + }, + { + "id": "estate/locals", + "kind": "Terraform::Locals", + "lexicon": "terraform", + "attrs": { + "address": "locals", + "file": "dns.tf", + "root": "estate" + } + }, + { + "id": "estate/locals~2", + "kind": "Terraform::Locals", + "lexicon": "terraform", + "attrs": { + "address": "locals", + "file": "main.tf", + "root": "estate" + } + }, + { + "id": "estate/module.team_pod", + "kind": "Terraform::Module", + "lexicon": "terraform", + "attrs": { + "address": "module.team_pod", + "file": "pods.tf", + "root": "estate", + "expansion": "for_each" + } + }, + { + "id": "estate/module.team_pod/aws_iam_policy.pod_policy", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_policy.pod_policy", + "file": "main.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/module.team_pod/aws_iam_role_policy_attachment.pod_custom_attach", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role_policy_attachment.pod_custom_attach", + "file": "main.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/module.team_pod/aws_iam_role.pod_role", + "kind": "Terraform::Resource", + "lexicon": "terraform", + "attrs": { + "address": "aws_iam_role.pod_role", + "file": "main.tf", + "root": "estate", + "expansion": "count" + } + }, + { + "id": "estate/module.team_pod/var.pod_size", + "kind": "Terraform::Variable", + "lexicon": "terraform", + "attrs": { + "address": "var.pod_size", + "file": "variables.tf", + "root": "estate" + } + }, + { + "id": "estate/module.team_pod/var.prefix", + "kind": "Terraform::Variable", + "lexicon": "terraform", + "attrs": { + "address": "var.prefix", + "file": "variables.tf", + "root": "estate" + } + } + ], + "edges": [ + { + "from": "estate/aws_iam_instance_profile.team_0000_profile", + "to": "estate/aws_iam_role.team_0000_role", + "kind": "ref", + "viaAttr": "role", + "toAttr": "name" + }, + { + "from": "estate/aws_iam_role_policy_attachment.count_team_custom_attach", + "to": "estate/aws_iam_policy.count_team_policy", + "kind": "ref", + "viaAttr": "policy_arn" + }, + { + "from": "estate/aws_iam_role_policy_attachment.count_team_custom_attach", + "to": "estate/aws_iam_role.count_team", + "kind": "ref", + "viaAttr": "role" + }, + { + "from": "estate/aws_iam_role_policy_attachment.team_0000_custom_attach", + "to": "estate/aws_iam_policy.team_0000_policy", + "kind": "ref", + "viaAttr": "policy_arn", + "toAttr": "arn" + }, + { + "from": "estate/aws_iam_role_policy_attachment.team_0000_custom_attach", + "to": "estate/aws_iam_role.team_0000_role", + "kind": "ref", + "viaAttr": "role", + "toAttr": "name" + }, + { + "from": "estate/aws_iam_role_policy_attachment.team_0000_managed_attach", + "to": "estate/aws_iam_role.team_0000_role", + "kind": "ref", + "viaAttr": "role", + "toAttr": "name" + }, + { + "from": "estate/aws_iam_role_policy.count_team_inline", + "to": "estate/aws_iam_role.count_team", + "kind": "ref", + "viaAttr": "role" + }, + { + "from": "estate/aws_iam_role_policy.team_0000_inline", + "to": "estate/aws_iam_role.team_0000_role", + "kind": "ref", + "viaAttr": "role", + "toAttr": "name" + }, + { + "from": "estate/aws_route53_record.record", + "to": "estate/aws_route53_zone.main", + "kind": "ref", + "viaAttr": "name", + "toAttr": "name" + }, + { + "from": "estate/aws_route53_record.record", + "to": "estate/aws_route53_zone.main", + "kind": "ref", + "viaAttr": "zone_id", + "toAttr": "zone_id" + }, + { + "from": "estate/aws_route53_record.record", + "to": "estate/locals", + "kind": "ref", + "viaAttr": "for_each" + }, + { + "from": "estate/module.team_pod/aws_iam_policy.pod_policy", + "to": "estate/module.team_pod/var.pod_size", + "kind": "ref", + "viaAttr": "count" + }, + { + "from": "estate/module.team_pod/aws_iam_policy.pod_policy", + "to": "estate/module.team_pod/var.prefix", + "kind": "ref", + "viaAttr": "name" + }, + { + "from": "estate/module.team_pod/aws_iam_role_policy_attachment.pod_custom_attach", + "to": "estate/module.team_pod/aws_iam_policy.pod_policy", + "kind": "ref", + "viaAttr": "policy_arn" + }, + { + "from": "estate/module.team_pod/aws_iam_role_policy_attachment.pod_custom_attach", + "to": "estate/module.team_pod/aws_iam_role.pod_role", + "kind": "ref", + "viaAttr": "role" + }, + { + "from": "estate/module.team_pod/aws_iam_role_policy_attachment.pod_custom_attach", + "to": "estate/module.team_pod/var.pod_size", + "kind": "ref", + "viaAttr": "count" + }, + { + "from": "estate/module.team_pod/aws_iam_role.pod_role", + "to": "estate/module.team_pod/var.pod_size", + "kind": "ref", + "viaAttr": "count" + }, + { + "from": "estate/module.team_pod/aws_iam_role.pod_role", + "to": "estate/module.team_pod/var.prefix", + "kind": "ref", + "viaAttr": "name" + }, + { + "from": "estate/module.team_pod", + "to": "estate/locals~2", + "kind": "ref", + "viaAttr": "pod_size" + }, + { + "from": "estate/module.team_pod", + "to": "estate/locals~2", + "kind": "ref", + "viaAttr": "prefix" + } + ], + "groups": {} +} diff --git a/src/__fixtures__/choudoufu-live-check-terralith.json b/src/__fixtures__/choudoufu-live-check-terralith.json new file mode 100644 index 0000000..9d8c4fe --- /dev/null +++ b/src/__fixtures__/choudoufu-live-check-terralith.json @@ -0,0 +1,357 @@ +{ + "dir": ".", + "estate": "behold-terralith-4", + "blocked": false, + "exit_code": 0, + "schemas": "provider", + "instances": [ + { + "address": "aws_iam_instance_profile.team_0000_profile", + "type": "aws_iam_instance_profile", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[0]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[1]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[2]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[3]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[4]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[5]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[6]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.count_team_policy[7]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_policy.team_0000_policy", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[0]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[1]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[2]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[3]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[4]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[5]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[6]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.count_team[7]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role.team_0000_role", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "aws_iam_role_policy.count_team_inline[0]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[1]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[2]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[3]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[4]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[5]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[6]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.count_team_inline[7]", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy.team_0000_inline", + "type": "aws_iam_role_policy", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[0]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[1]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[2]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[3]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[4]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[5]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[6]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.count_team_custom_attach[7]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.team_0000_custom_attach", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_iam_role_policy_attachment.team_0000_managed_attach", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "aws_route53_record.record[\"host-0000\"]", + "type": "aws_route53_record", + "rung": "declaration-carried" + }, + { + "address": "aws_route53_record.record[\"host-0001\"]", + "type": "aws_route53_record", + "rung": "declaration-carried" + }, + { + "address": "aws_route53_record.record[\"host-0002\"]", + "type": "aws_route53_record", + "rung": "declaration-carried" + }, + { + "address": "aws_route53_record.record[\"host-0003\"]", + "type": "aws_route53_record", + "rung": "declaration-carried" + }, + { + "address": "aws_route53_zone.main", + "type": "aws_route53_zone", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_policy.pod_policy[0]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_policy.pod_policy[1]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_policy.pod_policy[2]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_policy.pod_policy[3]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role.pod_role[0]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role.pod_role[1]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role.pod_role[2]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role.pod_role[3]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role_policy_attachment.pod_custom_attach[0]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role_policy_attachment.pod_custom_attach[1]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role_policy_attachment.pod_custom_attach[2]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-a\"].aws_iam_role_policy_attachment.pod_custom_attach[3]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_policy.pod_policy[0]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_policy.pod_policy[1]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_policy.pod_policy[2]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_policy.pod_policy[3]", + "type": "aws_iam_policy", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role.pod_role[0]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role.pod_role[1]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role.pod_role[2]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role.pod_role[3]", + "type": "aws_iam_role", + "rung": "tag-governable" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role_policy_attachment.pod_custom_attach[0]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role_policy_attachment.pod_custom_attach[1]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role_policy_attachment.pod_custom_attach[2]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + }, + { + "address": "module.team_pod[\"pod-b\"].aws_iam_role_policy_attachment.pod_custom_attach[3]", + "type": "aws_iam_role_policy_attachment", + "rung": "declaration-carried" + } + ], + "references": [], + "checked": [ + "lint", + "identity", + "dataread", + "stamp" + ], + "partial": [ + "projection (2 of 47 refusals; the rest need a cloud)" + ], + "unchecked": [ + "discovery" + ] +} diff --git a/src/choudoufu-live.ts b/src/choudoufu-live.ts index e99c9bb..b79b512 100644 --- a/src/choudoufu-live.ts +++ b/src/choudoufu-live.ts @@ -49,6 +49,7 @@ import { type ChoudoufuRefusal, type LiveCheckDocument, } from "./choudoufu-member.ts"; +import { addIntraEstateEdges, lexiconStamp } from "./choudoufu-refs.ts"; import type { MemberVia } from "./member-ir.ts"; import type { MemberKindSpec } from "./member-kind.ts"; @@ -406,17 +407,25 @@ export async function choudoufuLiveIr(dir: string, opts: GraphOptions, run: Runn } /** How a choudoufu member is read (the `via` of its kind): the declared half - * for a source read, the painted graph for a live one. */ + * for a source read, the painted graph for a live one — and, on both, the + * estate's own references (#393, src/choudoufu-refs.ts), which come from + * chant's terraform lexicon over the same HCL and not from choudoufu at all. + * The colour is the live half's; the topology is the same either way, which is + * the rule /api/graph and /api/overlay are already held to. */ export const choudoufuVia: MemberVia = { tool: () => { const v = choudoufuVersion(); - return `choudoufu\0${v ? v.version || "dev" : "absent"}`; + // The lexicon is half of what answers this member now: installing it turns + // an edgeless estate into one with 266 edges off byte-identical source, so + // it has to be a different cache key or the estate serves the pre-install + // IR until something else invalidates it (src/member-ir.ts, rule 3). + return `choudoufu\0${v ? v.version || "dev" : "absent"}\0${lexiconStamp()}`; }, read: async (dir: string, opts: GraphOptions): Promise => { - if (opts.live || opts.overlay) return choudoufuLiveIr(dir, opts); + if (opts.live || opts.overlay) return addIntraEstateEdges(await choudoufuLiveIr(dir, opts), dir); const parsed = await readLiveCheck(dir); if (!parsed.ok) throw new ChoudoufuReadError(parsed.refusal, dir); - return liveCheckToIr(parsed.doc); + return addIntraEstateEdges(liveCheckToIr(parsed.doc), dir); }, }; diff --git a/src/choudoufu-refs.test.ts b/src/choudoufu-refs.test.ts new file mode 100644 index 0000000..64fdf6f --- /dev/null +++ b/src/choudoufu-refs.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { GraphIR, IREdge } from "@intentius/chant"; +import { liveCheckToIr, parseLiveCheck, type LiveCheckDocument } from "./choudoufu-member.ts"; +import { + addIntraEstateEdges, + choudoufuLexiconNote, + decomposeAddress, + indexRoster, + joinLexiconEdges, + lexiconPath, + lexiconStamp, +} from "./choudoufu-refs.ts"; +import type { TerraformReaderState } from "./terraform-member.ts"; + +// --------------------------------------------------------------------------- +// PROVENANCE. Both documents were recorded on the `terralith-4` workbench entry +// (AGENTS.md, "The workbench catalog") on 2026-09-09 — choudoufu v0.16.0, +// @intentius/chant-lexicon-terraform 0.61.0, @cdktf/hcl2json 0.21.0, over the +// estate `tools/terralith-gen` renders at scale 4: +// +// - choudoufu-lexicon-terralith.json — `chant graph --format ir` through the +// generated reader config (src/terraform-member.ts's scratch project) with +// the estate directory as the single root `estate`. The full read is 186 +// nodes and 175 edges; this keeps the 20 nodes that make every rule in +// src/choudoufu-refs.ts's header visible, and the 20 edges among them. +// `attrs.source` is dropped from each node — the lexicon carries the whole +// FILE a block came from, ~60KB of HCL per node here, and the join reads +// none of it. +// - choudoufu-live-check-terralith.json — `choudoufu live-check -json .` in +// the same directory, cut to the instances of those same blocks. The estate +// declares 301; this keeps 67. `aws_route53_record.record` really has 40 +// instances and is cut to 4, which is what makes it the case where two ends +// expand over key sets that do not correspond. `references` is emptied: the +// real document's is already empty (a terralith reads no other estate), and +// that emptiness is the whole reason this feature exists. +// +// What each half is here to show, block by block: +// +// team_0000_* five 1:1 references, nothing expanded +// count_team* two blocks over `count = 8`, joined key to key +// record → zone 4 keys against 1, so the scope's full product +// module.team_pod/* two module INSTANCES of two `count`-expanded blocks +// module.team_pod the module CALL, whose only edges go to `locals~2` +// var.* / locals* the ends the roster declares no card for +// --------------------------------------------------------------------------- +const fixture = (name: string): T => JSON.parse(readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8")) as T; +const lexicon = (): GraphIR => fixture("choudoufu-lexicon-terralith.json"); +const roster = (): LiveCheckDocument => { + const parsed = parseLiveCheck(fixture("choudoufu-live-check-terralith.json")); + if (!parsed.ok) throw new Error(parsed.refusal.error); + return parsed.doc; +}; +const join2 = (): IREdge[] => joinLexiconEdges(lexicon().edges, indexRoster(roster().instances.map((i) => i.address))); +const between = (edges: readonly IREdge[], from: string, to: string): IREdge | undefined => edges.find((e) => e.from === from && e.to === to); + +describe("decomposeAddress — a roster address as the lexicon addresses it (#393)", () => { + it("splits a module-nested, count-expanded address into path, scope and key", () => { + expect(decomposeAddress('module.team_pod["pod-a"].aws_iam_role.pod_role[0]')).toEqual({ + address: 'module.team_pod["pod-a"].aws_iam_role.pod_role[0]', + blockPath: "module.team_pod/aws_iam_role.pod_role", + scope: 'module.team_pod["pod-a"]', + key: "[0]", + }); + }); + + it("leaves a root-module address in the root scope with no key", () => { + expect(decomposeAddress("aws_ecs_cluster.main")).toMatchObject({ blockPath: "aws_ecs_cluster.main", scope: "", key: "" }); + }); + + it("keeps a for_each key verbatim, quotes and all", () => { + expect(decomposeAddress('aws_route53_record.record["host-0007"]')).toMatchObject({ blockPath: "aws_route53_record.record", key: '["host-0007"]' }); + }); + + it("nests: only the outermost call keys the scope's first segment, and both are in the path", () => { + expect(decomposeAddress('module.a["x"].module.b[0].aws_s3_bucket.c')).toMatchObject({ + blockPath: "module.a/module.b/aws_s3_bucket.c", + scope: 'module.a["x"].module.b[0]', + key: "", + }); + }); + + it("reads a data source as its own block", () => { + expect(decomposeAddress("data.aws_vpc.network")).toMatchObject({ blockPath: "data.aws_vpc.network", scope: "" }); + }); +}); + +describe("lexiconPath — the root prefix off a lexicon id", () => { + it("strips the single root this reader names", () => { + expect(lexiconPath("estate/module.team_pod/aws_iam_role.pod_role")).toBe("module.team_pod/aws_iam_role.pod_role"); + }); + it("leaves an id under no root alone, so it matches nothing", () => { + expect(lexiconPath("aws_iam_role.x")).toBe("aws_iam_role.x"); + }); +}); + +describe("joinLexiconEdges — the lexicon's blocks joined to the roster's instances (#393)", () => { + it("joins the recorded terralith documents to 49 edges over 67 cards", () => { + const edges = join2(); + expect(edges).toHaveLength(49); + // Every end is a card the roster really declares — the whole point of the + // join, and the assertion that catches a mapping that invented an address. + const declared = new Set(roster().instances.map((i) => i.address)); + for (const e of edges) expect(declared.has(e.from) && declared.has(e.to), `${e.from} -> ${e.to}`).toBe(true); + }); + + it("carries the attribute the lexicon named, and is flagged inferred", () => { + const e = between(join2(), "aws_iam_instance_profile.team_0000_profile", "aws_iam_role.team_0000_role")!; + expect(e).toMatchObject({ kind: "ref", viaAttr: "role", inferred: true }); + }); + + it("draws an unexpanded reference once", () => { + const team0000 = join2().filter((e) => e.from.includes("team_0000") || e.to.includes("team_0000")); + expect(team0000).toHaveLength(5); + }); + + it("joins two count-expanded blocks key to key — 8 edges, not the 64 of the full product", () => { + const attach = join2().filter((e) => e.from.startsWith("aws_iam_role_policy_attachment.count_team_custom_attach") && e.to.startsWith("aws_iam_role.count_team")); + expect(attach).toHaveLength(8); + for (let i = 0; i < 8; i++) { + expect(between(attach, `aws_iam_role_policy_attachment.count_team_custom_attach[${i}]`, `aws_iam_role.count_team[${i}]`)).toBeDefined(); + } + }); + + it("falls back to the scope's full product when the two key sets do not correspond", () => { + // 4 recorded `for_each` records against one zone: there is no key on the + // zone to match, so every record reads it. + const dns = join2().filter((e) => e.to === "aws_route53_zone.main"); + expect(dns).toHaveLength(4); + expect(dns.every((e) => e.from.startsWith("aws_route53_record.record["))).toBe(true); + }); + + it("keeps a module instance's references inside that instance", () => { + const edges = join2(); + const pods = edges.filter((e) => e.from.startsWith("module.")); + expect(pods).toHaveLength(16); // two calls x two blocks referenced x four count instances + for (const e of pods) { + const scope = (a: string): string => a.slice(0, a.indexOf("]") + 1); + expect(scope(e.from), `${e.from} -> ${e.to}`).toBe(scope(e.to)); + } + expect(between(edges, 'module.team_pod["pod-a"].aws_iam_role_policy_attachment.pod_custom_attach[1]', 'module.team_pod["pod-a"].aws_iam_role.pod_role[1]')).toBeDefined(); + expect(between(edges, 'module.team_pod["pod-a"].aws_iam_role_policy_attachment.pod_custom_attach[1]', 'module.team_pod["pod-b"].aws_iam_role.pod_role[1]')).toBeUndefined(); + }); + + it("drops an end the roster declares no card for — variables, locals, the module call itself", () => { + const edges = join2(); + for (const e of edges) { + expect(e.from.includes("var.") || e.to.includes("var."), `${e.from} -> ${e.to}`).toBe(false); + expect(e.from.includes("locals") || e.to.includes("locals"), `${e.from} -> ${e.to}`).toBe(false); + } + // Eight of the recorded twenty lexicon edges end on one of those. + expect(lexicon().edges.filter((e) => /var\.|locals/.test(`${e.from}${e.to}`))).toHaveLength(8); + }); + + it("collapses two attributes between the same pair to one edge, keeping the first", () => { + // The recorded lexicon states `record -> zone` twice, through `name` and + // through `zone_id`. One card pair is one edge on the canvas. + expect(lexicon().edges.filter((e) => e.from.endsWith("record") && e.to.endsWith("zone.main"))).toHaveLength(2); + expect(join2().filter((e) => e.from.startsWith("aws_route53_record") && e.to === "aws_route53_zone.main").map((e) => e.viaAttr)).toEqual(["name", "name", "name", "name"]); + }); + + it("maps an edge that ENDS on a module call onto every roster address inside it", () => { + // Synthetic, because lexicon 0.61 descends into called modules and this + // estate's only module-call edges go to `locals~2`. It is the shape a root + // block reading `module.team_pod[…].some_output` produces, and the rule has + // to exist before the reader that emits one does. + const index = indexRoster(roster().instances.map((i) => i.address)); + const edges = joinLexiconEdges([{ from: "estate/aws_iam_role.team_0000_role", to: "estate/module.team_pod", kind: "ref", viaAttr: "assume_role_policy" }], index); + const inside = roster().instances.filter((i) => i.address.startsWith("module.team_pod")).length; + expect(edges).toHaveLength(inside); + expect(edges.every((e) => e.from === "aws_iam_role.team_0000_role" && e.to.startsWith("module.team_pod"))).toBe(true); + }); + + it("never draws a card to itself", () => { + const index = indexRoster(["aws_iam_role.solo"]); + expect(joinLexiconEdges([{ from: "estate/aws_iam_role.solo", to: "estate/aws_iam_role.solo", kind: "ref", viaAttr: "name" }], index)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- + +const present: TerraformReaderState = { + lexicon: { pkg: "@intentius/chant-lexicon-terraform", range: "^0.61.0", version: "0.61.0" }, + parser: { pkg: "@cdktf/hcl2json", range: "^0.21.0", version: "0.21.0" }, + from: "/behold/src", +}; +const absent: TerraformReaderState = { + lexicon: { pkg: "@intentius/chant-lexicon-terraform", range: "^0.61.0" }, + parser: { pkg: "@cdktf/hcl2json", range: "^0.21.0" }, + from: "/behold/src", + refusal: { + error: "Reading a Terraform estate needs chant's terraform lexicon, which behold does not install: … are not resolvable from /behold/src.", + code: "terraform-lexicon", + remedy: "Install @intentius/chant-lexicon-terraform@^0.61.0 @cdktf/hcl2json@^0.21.0 beside behold, then reload.", + }, +}; + +describe("addIntraEstateEdges — the member's read (#393)", () => { + it("adds the join to the roster's own IR, on the member's own ids", async () => { + const ir = liveCheckToIr(roster()); + expect(ir.edges).toHaveLength(0); + const out = await addIntraEstateEdges(ir, "/estate", present, async () => lexicon()); + expect(out).toBe(ir); + expect(out.edges).toHaveLength(49); + const ids = new Set(out.nodes.map((n) => n.id)); + for (const e of out.edges) expect(ids.has(e.from) && ids.has(e.to)).toBe(true); + }); + + it("adds nothing at all when the lexicon is not resolvable — and never reads", async () => { + const ir = liveCheckToIr(roster()); + let read = 0; + const out = await addIntraEstateEdges(ir, "/estate", absent, async () => { + read++; + return lexicon(); + }); + expect(read).toBe(0); + expect(out.edges).toHaveLength(0); + }); + + it("keeps the roster when the reader throws — an unparseable estate is not a reason to lose the cards", async () => { + const ir = liveCheckToIr(roster()); + const out = await addIntraEstateEdges(ir, "/estate", present, async () => { + throw new Error("hcl2json: unexpected token"); + }); + expect(out.nodes).toHaveLength(67); + expect(out.edges).toHaveLength(0); + }); + + it("does not duplicate an edge the member already stated", async () => { + const ir = liveCheckToIr(roster()); + ir.edges.push({ from: "aws_iam_instance_profile.team_0000_profile", to: "aws_iam_role.team_0000_role", kind: "ref", viaAttr: "reads" }); + const out = await addIntraEstateEdges(ir, "/estate", present, async () => lexicon()); + expect(out.edges.filter((e) => e.from === "aws_iam_instance_profile.team_0000_profile" && e.to === "aws_iam_role.team_0000_role")).toHaveLength(1); + expect(out.edges).toHaveLength(49); + }); +}); + +describe("the absent-lexicon note and the cache stamp (#393)", () => { + it("says what is missing and carries the terraform kind's own install line", () => { + const note = choudoufuLexiconNote(absent)!; + expect(note).toContain("no edges"); + expect(note).toContain("chant's terraform lexicon beside behold"); + expect(note).toContain(absent.refusal!.remedy); + // …and never the sentence it replaces. + expect(note).not.toContain("nothing in this estate references anything else"); + }); + + it("is silent when the lexicon is there — an estate with edges needs no caption", () => { + expect(choudoufuLexiconNote(present)).toBeUndefined(); + }); + + it("stamps the two peers, so installing them is a different cache key", () => { + expect(lexiconStamp(present)).not.toBe(lexiconStamp(absent)); + expect(lexiconStamp(absent)).toContain("absent"); + expect(lexiconStamp(present)).toContain("0.61.0"); + }); +}); diff --git a/src/choudoufu-refs.ts b/src/choudoufu-refs.ts new file mode 100644 index 0000000..177076e --- /dev/null +++ b/src/choudoufu-refs.ts @@ -0,0 +1,354 @@ +/** + * Intra-estate edges for a choudoufu member (#393, "Missing" item 1). + * + * `choudoufu live-check -json` states two things: the roster (every declared + * instance, by address) and `references[]` — data sources filtered on ANOTHER + * estate's marker tags. That second list is a cross-estate list by + * construction, so a 301-resource terralith of roles, policies, attachments + * and their profiles produced not one edge, `edgelessNote` asserted "nothing + * in this estate references anything else", and the audit measured a 455:1 + * strip because dagre had nothing to rank by. + * + * The references are not missing; nobody was reading them. chant's terraform + * lexicon reads the same HCL the estate already has and, at 0.61 (chant#2265), + * resolves a block's `"${…}"` interpolations into edges. So a choudoufu + * member's source read now runs the terraform kind's read over its own + * directory as well and JOINS the two documents by address. behold still + * parses no HCL: the join is two lists of strings. + * + * --------------------------------------------------------------------------- + * THE TWO DOCUMENTS DO NOT NAME THE SAME THINGS, and the whole of this module + * is that mismatch. + * + * The lexicon names BLOCKS, in a path of module CALLS: + * + * estate/aws_iam_role.count_team + * estate/module.team_pod/aws_iam_role.pod_role + * estate/module.team_pod (the call itself) + * + * choudoufu names INSTANCES, in a path of module INSTANCES: + * + * aws_iam_role.count_team[0] … [7] + * module.team_pod["pod-a"].aws_iam_role.pod_role[0] … [3] + * module.team_pod["pod-b"].aws_iam_role.pod_role[0] … [3] + * + * So one lexicon node is 0, 1 or many roster cards, and one lexicon edge is + * that product. Three rules turn it into edges a person can read, in + * decreasing order of how much Terraform itself guarantees them: + * + * 1. **Same module instance, always.** A reference written inside + * `modules/team_pod/main.tf` resolves inside the instance that is + * evaluating it — `module.team_pod["pod-a"]`'s attachment names + * `pod-a`'s role and cannot name `pod-b`'s. That is Terraform's scoping + * rule, not an inference, and it is what keeps the two pods two clusters + * instead of one 8x8 mesh. + * 2. **Key to key when both ends expand over the same keys.** Two blocks in + * one scope, expanded over `[0…3]` or over `["a","b"]`, are joined by that + * key. The lexicon states the reference between the BLOCKS and drops the + * index expression, so this is the one rule here that Terraform does not + * hand us: `role = aws_iam_role.count_team[count.index].name` and + * `role = aws_iam_role.count_team[0].name` reach this module identically. + * It is taken because the alternative is measurably worse on both counts — + * on `terralith-4` the full product is 666 edges of which ~400 say a role + * is attached to a policy it is not, against 266 that are each true — and + * because every edge here is `inferred`, which is the flag the pane and + * the overlay already read as "behold joined this, chant did not state it". + * Where the key sets differ (a `for_each` over one set referencing a + * `count` over another) there is no correspondence to take, and the scope's + * full product is drawn instead. + * 3. **A module CALL is its whole interior.** An edge whose end is + * `module.team_pod` is a reference to one of the module's outputs, and an + * output is produced by the resources inside it — so the edge lands on + * every roster address under that call, in every instance of it. Lexicon + * 0.61 descends into called modules, so this is rare rather than the common + * case #393 expected: on `terralith-4` the module call's only two edges go + * to `locals`, and rule 4 drops them. + * 4. **An end with no roster card is dropped, whole.** `var.pod_size`, + * `locals~2`, a provider block, a `data` source the roster does not + * declare: real HCL, not estate, and choudoufu draws no card for any of + * them. 17 of `terralith-4`'s 175 lexicon edges go this way. + * + * `viaAttr` is the lexicon's own — `role`, `policy_arn`, `task_definition` — + * so the inspect pane says which attribute made the reference rather than + * "ref". + * --------------------------------------------------------------------------- + * + * WHERE THIS RUNS. Inside the member's `read` (src/choudoufu-live.ts), on the + * member's OWN ids — plain addresses, before `composeStacks` prefixes them + * with the member name — so the estate passes downstream see a member that + * simply has edges, and `addChoudoufuReferenceEdges`' cross-estate join is + * untouched. The overlay paints the same node ids it always did. + * + * WHEN THE LEXICON IS ABSENT there are no edges and {@link choudoufuLexiconNote} + * is the line that says so, carrying the terraform kind's own install remedy + * so the two cannot drift. Saying nothing would leave the old sentence + * standing, and "nothing in this estate references anything else" is a claim + * behold has no reader to make. + */ +import type { GraphIR, IREdge } from "@intentius/chant"; +import type { GraphOptions } from "./chant.ts"; +import { + TERRAFORM_LEXICON_PKG, + terraformReaderState, + writeTerraformScratchProject, + type TerraformReaderState, +} from "./terraform-member.ts"; + +/** + * The single root the generated reader config names for a choudoufu estate: + * the estate directory itself, reached through the scratch project's `estate` + * symlink. Fixed rather than derived from the directory's basename, because it + * is only ever a prefix this module strips straight back off, and a constant + * cannot collide with the `/` a module path uses as its separator. + */ +export const LEXICON_ROOT = "estate"; + +/** One declared instance, decomposed. */ +export interface RosterInstance { + /** The roster's own address, verbatim — the node id in the member's IR. */ + address: string; + /** The chain of module INSTANCES it sits in, keys included, or `""` for the + * root module: `module.team_pod["pod-a"]`. Two ends of an edge must agree on + * this (rule 1). */ + scope: string; + /** Its own expansion key with the brackets, or `""` when the block is not + * expanded: `[0]`, `["host-0007"]`. */ + key: string; +} + +/** The roster, indexed the way a lexicon id addresses it. */ +export interface RosterIndex { + /** The lexicon's path for a block (`module.team_pod/aws_iam_role.pod_role`) + * → every instance of it, in roster order. */ + blocks: Map; + /** A module CALL path (`module.team_pod`) → every instance under it, keyed + * by the scope the CALL is written in (rule 3). */ + calls: Map; +} + +/** A module instance at the head of an address: the call, its key, and the + * dot that ends it. */ +const MODULE_SEGMENT = /^module\.([A-Za-z0-9_-]+)(\[[^\]]*\])?\./; +/** A trailing expansion key. A block name cannot hold a bracket, so this is + * unambiguous without a parse. */ +const TRAILING_KEY = /(\[[^\]]*\])$/; + +/** One roster address, decomposed into the lexicon's path for its block, the + * module instances it sits in, and its own expansion key. */ +export function decomposeAddress(address: string): RosterInstance & { blockPath: string } { + let rest = address; + const calls: string[] = []; + const instances: string[] = []; + for (;;) { + const m = MODULE_SEGMENT.exec(rest); + if (!m) break; + calls.push(`module.${m[1]}`); + instances.push(`module.${m[1]}${m[2] ?? ""}`); + rest = rest.slice(m[0].length); + } + const k = TRAILING_KEY.exec(rest); + return { + address, + scope: instances.join("."), + key: k ? k[1] : "", + blockPath: [...calls, k ? rest.slice(0, -k[1].length) : rest].join("/"), + }; +} + +/** The scope a module call is WRITTEN in — its parent — given the instance + * chain of something inside it. `module.a["x"].module.b[0]` under call path + * `module.a/module.b` is written in `module.a["x"]`. */ +function scopeAbove(scope: string, depth: number): string { + return scope ? scope.split(".").slice(0, depth).join(".") : ""; +} + +/** Index a roster (the addresses `liveCheckToIr` made nodes of) for the join. */ +export function indexRoster(addresses: readonly string[]): RosterIndex { + const blocks = new Map(); + const calls = new Map(); + for (const address of addresses) { + const { blockPath, ...instance } = decomposeAddress(address); + const at = blocks.get(blockPath); + if (at) at.push(instance); + else blocks.set(blockPath, [instance]); + // Every module call this address sits under, so an edge that lands on the + // call lands on the whole interior (rule 3). The scope recorded is the + // one the CALL is written in, not the one the card sits in. + const segments = blockPath.split("/"); + for (let depth = 1; depth < segments.length; depth++) { + const callPath = segments.slice(0, depth).join("/"); + const written = { address, scope: scopeAbove(instance.scope, depth - 1), key: "" }; + const list = calls.get(callPath); + if (list) list.push(written); + else calls.set(callPath, [written]); + } + } + return { blocks, calls }; +} + +/** Is this lexicon path a module CALL rather than a block inside one? */ +function isModuleCall(path: string): boolean { + return path.slice(path.lastIndexOf("/") + 1).startsWith("module."); +} + +/** The roster cards one end of a lexicon edge means, grouped by the scope the + * reference is written in. Empty for an end the roster does not declare — + * a variable, a locals block, a provider, a data source nobody declared + * (rule 4). */ +function endOf(path: string, index: RosterIndex): Map { + const found = index.blocks.get(path) ?? (isModuleCall(path) ? index.calls.get(path) : undefined); + const byScope = new Map(); + for (const i of found ?? []) { + const at = byScope.get(i.scope); + if (at) at.push(i); + else byScope.set(i.scope, [i]); + } + return byScope; +} + +/** Do these two expansions carry the same set of keys, one card each (rule 2)? */ +function sameKeys(from: readonly RosterInstance[], to: readonly RosterInstance[]): boolean { + if (from.length < 2 || from.length !== to.length) return false; + const keys = new Set(from.map((i) => i.key)); + if (keys.size !== from.length || keys.has("")) return false; + return to.every((i) => keys.has(i.key)) && new Set(to.map((i) => i.key)).size === to.length; +} + +/** The lexicon's id with its single root stripped: `estate/module.a/x.y` → + * `module.a/x.y`. An id that is not under the root (nothing this reader + * produces) is returned whole and simply matches no roster block. */ +export function lexiconPath(id: string, root = LEXICON_ROOT): string { + return id.startsWith(`${root}/`) ? id.slice(root.length + 1) : id; +} + +/** + * The join: lexicon edges over the lexicon's block ids, as edges over the + * roster's instance addresses. Pure — the whole of rules 1 to 4 — so it is + * tested off a recorded lexicon IR and a recorded live-check document with no + * HCL parser present. + * + * Every edge is `inferred`, the flag behold already uses for a relationship it + * joined rather than one its reader stated, and carries the lexicon's own + * `viaAttr`. Self-edges (a block that references itself, which an expanded one + * legitimately can) and duplicates are dropped. + */ +export function joinLexiconEdges(edges: readonly IREdge[], index: RosterIndex, root = LEXICON_ROOT): IREdge[] { + const out: IREdge[] = []; + const seen = new Set(); + const add = (from: string, to: string, viaAttr: string | undefined): void => { + if (from === to) return; + const key = `${from}\0${to}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ from, to, kind: "ref", ...(viaAttr ? { viaAttr } : {}), inferred: true } as IREdge); + }; + for (const e of edges) { + const fromPath = lexiconPath(e.from, root); + const toPath = lexiconPath(e.to, root); + const from = endOf(fromPath, index); + const to = endOf(toPath, index); + if (from.size === 0 || to.size === 0) continue; // rule 4 + for (const [scope, fromCards] of from) { + const toCards = to.get(scope); // rule 1 + if (!toCards) continue; + if (!isModuleCall(fromPath) && !isModuleCall(toPath) && sameKeys(fromCards, toCards)) { + const byKey = new Map(toCards.map((i) => [i.key, i.address])); + for (const f of fromCards) add(f.address, byKey.get(f.key)!, e.viaAttr); // rule 2 + continue; + } + for (const f of fromCards) for (const t of toCards) add(f.address, t.address, e.viaAttr); // rules 2 (fallback) and 3 + } + } + return out; +} + +// --------------------------------------------------------------------------- +// The read. +// --------------------------------------------------------------------------- + +/** How the lexicon half is read, injectable so the join above is testable off + * a recorded IR with no lexicon installed. */ +export type LexiconRead = (dir: string, opts: GraphOptions) => Promise; + +/** + * Read `dir` through the terraform kind's reader as ONE root — the estate + * directory itself. + * + * Root DISCOVERY is deliberately not used: a choudoufu estate is a Terraform + * root by definition (choudoufu applies it), and a walk would additionally + * find `modules/team_pod` and refuse it, or find a nested root and box it + * separately — neither of which the roster knows anything about. One root, one + * id prefix ({@link LEXICON_ROOT}), and the scratch-project machinery + * (src/terraform-member.ts) unchanged: the config, the `node_modules` symlink + * and the `estate` symlink all land under the OS temp directory, and nothing + * is written under the estate. + */ +export async function readEstateLexicon(dir: string, opts: GraphOptions = {}): Promise { + const project = writeTerraformScratchProject(dir, { roots: [{ name: LEXICON_ROOT, dir: "." }], skipped: [] }); + // Detail 2 whatever the caller asked for: the join reads ids and `viaAttr` + // and nothing else, so a detail-3 read would parse every block's body for an + // answer that cannot change. Source only, for the reason `readTerraformMember` + // gives — a reader lexicon has no live half. + const { graphIr } = await import("./chant.ts"); + return graphIr(project, { detail: 2 }); +} + +/** + * Add the intra-estate edges to a choudoufu member's IR, in place, and return + * it. A no-op — the identical object, unchanged — when the lexicon is not + * resolvable, when the estate holds no addresses, or when the read throws + * (a directory the lexicon cannot parse is not a reason to refuse the roster + * behold already has; the note says the reader is absent, and a reader that + * broke says nothing louder than an empty edge set). + */ +export async function addIntraEstateEdges( + ir: GraphIR, + dir: string, + state: TerraformReaderState = terraformReaderState(), + read: LexiconRead = readEstateLexicon, +): Promise { + if (state.refusal || ir.nodes.length === 0) return ir; + let lexicon: GraphIR; + try { + lexicon = await read(dir, {}); + } catch { + return ir; + } + const index = indexRoster(ir.nodes.map((n) => n.id)); + const have = new Set(ir.edges.map((e) => `${e.from}\0${e.to}`)); + for (const e of joinLexiconEdges(lexicon.edges, index)) { + if (have.has(`${e.from}\0${e.to}`)) continue; + have.add(`${e.from}\0${e.to}`); + ir.edges.push(e); + } + return ir; +} + +// --------------------------------------------------------------------------- +// The note. +// --------------------------------------------------------------------------- + +/** + * What an edgeless choudoufu estate says when the reader that would have found + * its references is not installed — in place of `edgelessNote`'s + * "nothing in this estate references anything else", which behold has nothing + * to base on here. + * + * The install line is the terraform kind's own refusal remedy, read from + * behold's manifest through {@link terraformReaderState}, so the ranges quoted + * here and the ones that reader refuses with cannot drift apart. + */ +export function choudoufuLexiconNote(state: TerraformReaderState = terraformReaderState()): string | undefined { + if (!state.refusal) return undefined; + return ( + "no edges — a choudoufu roster states only its cross-estate references; the ones inside the estate " + + `need chant's terraform lexicon beside behold. ${state.refusal.remedy}` + ); +} + +/** The version half of the member's cache stamp: an install of the lexicon has + * to be a different key, or the estate keeps serving the edgeless IR it cached + * before the install. Absent stamps as absent rather than throwing. */ +export function lexiconStamp(state: TerraformReaderState = terraformReaderState()): string { + return `${TERRAFORM_LEXICON_PKG}\0${state.lexicon.version ?? "absent"}\0${state.parser.version ?? "absent"}`; +} diff --git a/src/edgeless.ts b/src/edgeless.ts index 3fb0362..9a2c070 100644 --- a/src/edgeless.ts +++ b/src/edgeless.ts @@ -51,13 +51,16 @@ * detail 2 before 15833 x 1164 13.6:1 after 7499 x 1798 4.2:1 * detail 3 before 60230 x 1404 42.9:1 after 29958 x 1982 15.1:1 * - * waterpark at detail 3 is the case this module does NOT answer, and the - * number says so. Its `prod` root is 104 cards in 37 connected components — + * waterpark at detail 3 was the case this module does NOT answer, and the + * number said so. Its `prod` root is 104 cards in 37 connected components — * five clusters and 32 loose cards — and dagre puts all 37 side by side on * three ranks. That is a wide rank, not a single one: the box has plenty of - * edges and every one of them is real. Packing a box's components the way - * `packComponents` packs the whole canvas is the fix, and it is a - * post-layout move with a box resize behind it rather than an edge. + * edges and every one of them is real. `packBoxComponents` (src/render.ts, + * #393 item 1) is that fix, a post-layout move with a box resize behind it + * rather than an edge, and it took the same two pictures to 3536 x 3138 and + * 7142 x 6974. It had to exist the moment a choudoufu member grew its own + * references: an estate with edges never reaches this module at all, so + * `terralith-4` came back a 176:1 strip the day the join landed. */ import type { GraphIR, IREdge } from "@intentius/chant"; diff --git a/src/render.test.ts b/src/render.test.ts index 3d9ae16..832a6b8 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -565,3 +565,83 @@ describe("module sub-boxes inside a choudoufu member (#393 B)", () => { expect(rects(renderGraph(terralith(60, 0), { boxes: "byStack" }).svg).map((b) => b.id)).toEqual(["terralith-4"]); }); }); + +describe("packing a box that HAS edges (#393 item 1)", () => { + /** A member shaped like `terralith-4` once its own references arrive: N + * small clusters — a role with its inline policy, two attachments and a + * profile — plus a `for_each` fan into one zone, plus two module instances. + * Every edge is real, which is exactly why src/edgeless.ts's wrap does not + * apply and dagre goes back to laying the components out side by side. */ + const wired = (clusters: number, fan: number, perModule: number): GraphIR => { + const ids: string[] = []; + const nodes: GraphIR["nodes"] = []; + const edges: GraphIR["edges"] = []; + const card = (address: string): string => { + const id = `terralith-4/${address}`; + ids.push(id); + nodes.push({ id, kind: "aws_iam_role", lexicon: "choudoufu", attrs: { rung: "tag-governable", estate: "e" } }); + return id; + }; + for (let i = 0; i < clusters; i++) { + const role = card(`aws_iam_role.team_${i}_role`); + for (const sat of ["inline", "managed_attach", "custom_attach", "profile"]) { + edges.push({ from: card(`aws_iam_role_policy.team_${i}_${sat}`), to: role, kind: "ref", viaAttr: "role" }); + } + } + const zone = card("aws_route53_zone.main"); + for (let i = 0; i < fan; i++) edges.push({ from: card(`aws_route53_record.record["host-${i}"]`), to: zone, kind: "ref", viaAttr: "zone_id" }); + for (const pod of ["pod-a", "pod-b"]) { + for (let i = 0; i < perModule; i++) { + const role = card(`module.team_pod["${pod}"].aws_iam_role.pod_role[${i}]`); + edges.push({ from: card(`module.team_pod["${pod}"].aws_iam_role_policy.pod_inline[${i}]`), to: role, kind: "ref", viaAttr: "role" }); + } + } + return { nodes, edges, groups: { byStack: { "terralith-4": ids } } }; + }; + const canvas = (svg: string): { w: number; h: number } => { + const m = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg)!; + return { w: Number(m[1]), h: Number(m[2]) }; + }; + + it("keeps a 290-card member with real edges under the audit's 4:1", () => { + const { w, h } = canvas(renderGraph(wired(40, 40, 12), { boxes: "byStack" }).svg); + expect(w / h).toBeLessThan(4); + expect(w / h).toBeGreaterThan(0.25); // and not a column either + }); + + it("wraps the fan rather than letting one 40-way star set the box's width", () => { + const wide = canvas(renderGraph(wired(4, 40, 0), { boxes: "byStack" }).svg); + expect(wide.w / wide.h).toBeLessThan(4); + }); + + it("still draws a sub-box per module instance — the bands survive the edges", () => { + const svg = renderGraph(wired(40, 40, 12), { boxes: "byStack" }).svg; + expect(svg).toContain(">module.team_pod["pod-a"]"); + expect(svg).toContain(">module.team_pod["pod-b"]"); + }); + + it("draws every card and every edge it was given", () => { + const ir = wired(12, 8, 4); + const svg = renderGraph(ir, { boxes: "byStack" }).svg; + for (const n of ir.nodes) expect(svg).toContain(`data-node-id="${n.id.replace(/"/g, """)}"`); + const drawn = new Set([...svg.matchAll(/data-edge-from="([^"]*)" data-edge-to="([^"]*)"/g)].map((m) => `${m[1]}\0${m[2]}`)); + expect(drawn.size).toBe(ir.edges.length); + }); + + it("leaves a box that already reads as a picture exactly as dagre laid it", () => { + // Two three-card chains: more than one component, and nothing to fix. + const ids = ["a1", "a2", "a3", "b1", "b2", "b3"]; + const ir: GraphIR = { + nodes: ids.map((id) => ({ id, kind: "aws_iam_role", lexicon: "choudoufu", attrs: {} })), + edges: [ + { from: "a1", to: "a2", kind: "ref" }, + { from: "a2", to: "a3", kind: "ref" }, + { from: "b1", to: "b2", kind: "ref" }, + { from: "b2", to: "b3", kind: "ref" }, + ], + groups: { byStack: { m: ids } }, + }; + const { w, h } = canvas(renderGraph(ir, { boxes: "byStack" }).svg); + expect(w / h).toBeLessThan(4); + }); +}); diff --git a/src/render.ts b/src/render.ts index b3cbc0f..75c0770 100644 --- a/src/render.ts +++ b/src/render.ts @@ -26,7 +26,7 @@ import { CHOUDOUFU_LEXICON, choudoufuCardFields, moduleInstanceOf } from "./chou import { terraformCardFields } from "./terraform-lens.ts"; import { carveProgress, splitCarveState, type CarveState } from "./carve-manifest.ts"; import { opCardFields } from "./ops-lens.ts"; -import { withRowChains, type RowGrid } from "./edgeless.ts"; +import { ROW_ASPECT, ROW_MIN_CARDS, rowColumns, withRowChains, type RowBand, type RowGrid } from "./edgeless.ts"; import { SUMMARY_LEXICON, summaryCardFields } from "./collapse-lens.ts"; // Lexicon-native icons (#227), step 2 of 2. pinhole resolves a node's glyph @@ -211,11 +211,17 @@ export function renderGraph( // A composed estate's member boxes get the same treatment (#296): dagre lays // the member clusters out along one horizontal band, so an 11-member estate // rendered ~45k units wide and 316 tall. Wrap the boxes into rows instead. - else if (boxKey === "byStack" && boxes) packMemberBoxes(layout, ir, boxes); + // #393 item 1: a box that HAS edges is never wrapped, and dagre lays its + // components out side by side — the strip comes back the moment the estate's + // own references arrive. Pack them first, so the member shelves below see the + // sizes the boxes really need. + const boxGrids = boxes ? packBoxComponents(layout, ir, boxes, moduleBandOf(ir)) : new Map(); + if (boxKey === "byStack" && boxes) packMemberBoxes(layout, ir, boxes); // The module sub-boxes (#393 B) and the count badges (#393 C), both over the - // boxes dagre just produced. + // boxes dagre just produced. A box's bands come from whichever pass shaped it + // — the wrap when it is edgeless, the component pack when it is not. if (boxes) { - addBandBoxes(layout, chains.grids, footprints(ir)); + addBandBoxes(layout, new Map([...boxGrids, ...chains.grids]), footprints(ir)); applyBadges(layout.groups, opts.groupBadges); } const svg = renderSvg(ir, layout, { @@ -985,6 +991,288 @@ function packMemberBoxes(layout: RadialLayout & { groups?: GroupBox[] }, ir: Gra for (const b of boxes) b.y = maxY - b.y; } +/** The widest a box may lie before {@link packBoxComponents} takes it apart — + * #393's own ceiling for the whole picture, applied per box. A box already + * under it is left exactly as dagre laid it, which is what keeps every small + * estate in the suite byte-identical. */ +const BOX_ASPECT_MAX = 4; + +/** Between two of a box's connected components, and between two of its bands. */ +const COMPONENT_GAP = 56; + +/** How far from {@link ROW_ASPECT} a laid-out block reads, as a ratio in both + * directions — 40:1 and 1:40 are the same distance from 2.4:1. */ +function offAspect(l: { width: number; height: number }): number { + const a = l.width / Math.max(l.height, 1); + return a > ROW_ASPECT ? a / ROW_ASPECT : ROW_ASPECT / Math.max(a, 1e-6); +} + +/** Past this much off-aspect — either way — a component's ranks have stopped + * paying for the width they cost, and it wraps into a grid instead. Three is a + * shape a person still reads as a picture; a 40-way fan is 8 times worse than + * that in both orientations. */ +const GRID_COMPONENT = 3; + +/** + * A component's cards in a grid, in the order its ranks put them — the same + * answer {@link withRowChains} gives a box whose cards no rank orders, one + * level down. Returns y-DOWN local coordinates and the block's size. + * + * Reading order is rank then position within it, so the hub of a fan lands in + * the run of its own leaves rather than a screen away from them, and the + * component's edges are drawn over the grid rather than deciding it. + */ +function gridComponent( + one: RadialLayout, + sizes: ReadonlyMap, +): { local: Map; w: number; h: number } { + const order = [...one.nodes].sort((a, b) => b.y - a.y || a.x - b.x); // y is up here: the top rank first + const cell = order.reduce( + (m, n) => { + const s = sizes.get(n.id) ?? { w: NODE_W, h: NODE_H }; + return { w: Math.max(m.w, s.w), h: Math.max(m.h, s.h) }; + }, + { w: NODE_W, h: NODE_H }, + ); + const sep = { x: 48, y: 60 }; + const cols = rowColumns(order.length, cell, sep); + const local = new Map(); + order.forEach((n, i) => { + local.set(n.id, { + x: (i % cols) * (cell.w + sep.x) + cell.w / 2, + y: Math.floor(i / cols) * (cell.h + sep.y) + cell.h / 2, + }); + }); + const rows = Math.ceil(order.length / cols); + return { local, w: Math.min(cols, order.length) * (cell.w + sep.x) - sep.x, h: rows * (cell.h + sep.y) - sep.y }; +} + +/** + * Pack a BOX's connected components into shelves, and resize the box around + * them (#393 item 1's layout half). + * + * src/edgeless.ts wraps a box whose cards reference nothing; this is the case + * its header named and did not answer — a box with plenty of real edges whose + * components dagre then lays side by side on a handful of ranks. `terralith-4` + * became exactly that the moment its 266 intra-estate edges arrived: 47 + * clusters (a role, its inline policy, its managed and custom attachments, its + * instance profile) strung out along three ranks, 110996 x 628. waterpark's + * `prod` root at detail 3 is the same shape and was measured at 15.1:1 before + * this existed. + * + * The move is `packComponents`', one box at a time and in the box's own local + * frame, plus the box resize that pass does not need because it owns the whole + * canvas. Components are shelf-packed to {@link ROW_ASPECT}, in BANDS — a + * choudoufu module instance's cards keep their own contiguous rows, so the + * sub-boxes #393 B draws survive the arrival of edges rather than vanishing + * with the wrap that used to produce them. The returned grids are what + * {@link addBandBoxes} draws those from, in exactly the shape + * {@link withRowChains} returns. + * + * A box is left alone unless all three hold: it holds at least + * {@link ROW_MIN_CARDS} cards, it lies wider than {@link BOX_ASPECT_MAX}, and + * its cards fall into more than one component. One connected component IS the + * structure dagre ranked, and re-shelving it would break the reading order the + * edges gave it. + */ +function packBoxComponents( + layout: RadialLayout & { groups?: GroupBox[] }, + ir: GraphIR, + boxes: Readonly>, + bandOf?: (id: string) => string | undefined, +): Map { + const grids = new Map(); + const groupBoxes = layout.groups ?? []; + const nodes = layout.nodes; + if (!groupBoxes.length || !Array.isArray(nodes) || nodes.length < 2) return grids; + const byId = new Map(nodes.map((n) => [n.id, n])); + const boxByKey = new Map(groupBoxes.flatMap((b) => (b.id === undefined ? [] : [[b.id, b] as const]))); + // The same ownership rule the layout used and `withRowChains` reads: a node + // listed by two boxes belongs to the LAST that claims it (pinhole's + // `setParent`), which is what stops a served Terraform directory's member box + // from claiming every card its root boxes really hold. + const ownerOf = new Map(); + for (const [key, ids] of Object.entries(boxes)) for (const id of ids) if (byId.has(id)) ownerOf.set(id, key); + + const size = footprints(ir); + const halfW = (n: { id: string }): number => (size.get(n.id)?.w ?? NODE_W) / 2; + const halfH = (n: { id: string }): number => (size.get(n.id)?.h ?? NODE_H) / 2; + + // Screen space (y-down) so the shelves read top-to-bottom once renderSvg + // flips y — `packMemberBoxes` works the same way, for the same reason. + for (const n of nodes) n.y = layout.height - n.y; + for (const b of groupBoxes) b.y = layout.height - b.y; + let packed = false; + + for (const [key, ids] of Object.entries(boxes)) { + const box = boxByKey.get(key); + if (!box) continue; + const own = ids.filter((id) => ownerOf.get(id) === key).flatMap((id) => byId.get(id) ?? []); + if (own.length < ROW_MIN_CARDS || box.h <= 0 || box.w / box.h <= BOX_ASPECT_MAX) continue; + + // Connected components among this box's OWN cards — union-find over the + // edges with both ends inside it, the same pass `packComponents` runs over + // the whole canvas. + const idxOf = new Map(own.map((n, i) => [n.id, i])); + const parent = own.map((_, i) => i); + const find = (x: number): number => { + while (parent[x] !== x) x = parent[x] = parent[parent[x]]; + return x; + }; + for (const e of ir.edges) { + const a = idxOf.get(e.from); + const b = idxOf.get(e.to); + if (a !== undefined && b !== undefined) parent[find(a)] = find(b); + } + const comps = new Map(); + own.forEach((n, i) => (comps.get(find(i)) ?? comps.set(find(i), []).get(find(i))!).push(n)); + if (comps.size < 2) continue; + + // Each component RE-LAID on its own, and this is the part that cannot be + // skipped. `packComponents` translates a component as a rigid block out of + // the layout it is given, which assumes dagre kept each one to itself. + // Inside a cluster it does not: measured on `terralith-4`'s 42 components, + // a six-card cluster's bounding box spanned 46598 units because the other + // 41 were interleaved through its ranks. One dagre call per component (the + // components are 1 to 20 cards; 42 of them cost ~40ms) gives each a tight + // box that a shelf can actually place. + const nodeById = new Map(ir.nodes.map((n) => [n.id, n])); + const laid = new Map; w: number; h: number; ns: RadialLayout["nodes"] }>(); + for (const [root, ns] of comps) { + const inside = new Set(ns.map((n) => n.id)); + const sub: GraphIR = { + nodes: ns.flatMap((n) => nodeById.get(n.id) ?? []), + edges: ir.edges.filter((e) => inside.has(e.from) && inside.has(e.to)), + groups: {}, + }; + let one = layoutIr(sub, { fit: true }) as unknown as RadialLayout; + // A star turned on its side. `terralith-4`'s DNS fan is one zone and 40 + // records on a single rank: 19564 units of card in a 400-tall strip, and + // one component that wide sets the shelf's width for all 42. `rankdir` + // is the one lever pinhole exposes (src/edgeless.ts's header) and here it + // applies — to a component, not to a box. Taken only when the sideways + // layout reads closer to {@link ROW_ASPECT} than the upright one does, + // measured as a ratio either way so 40:1 and 1:40 are equally bad. + if (one.width > one.height * BOX_ASPECT_MAX) { + const sideways = layoutIr(sub, { fit: true, rankdir: "LR" }) as unknown as RadialLayout; + if (offAspect(sideways) < offAspect(one)) one = sideways; + } + // …and a star is a strip whichever way it is turned: the DNS fan is + // 19564 x 400 upright and 900 x 10046 on its side, and the second is no + // more readable than the first — it just makes the box tall instead of + // wide. Past {@link GRID_COMPONENT} off-aspect the ranks stop being worth + // keeping and the component wraps into a grid, which is the answer + // src/edgeless.ts already gives for cards no rank orders. Its edges are + // then drawn over the grid rather than by it. + // Local coordinates are y-DOWN, the frame this pass shelves in. + const placed = + offAspect(one) > GRID_COMPONENT + ? gridComponent(one, size) + : { local: new Map(one.nodes.map((n) => [n.id, { x: n.x, y: one.height - n.y }] as const)), w: one.width, h: one.height }; + laid.set(root, { ...placed, ns }); + } + + // A component's band is its first card's — a module instance is a scope, so + // a reference inside one cannot leave it and a component never straddles + // two. The remainder (no band) sorts first, then the bands in + // first-appearance order: the same rule `bandsOf` follows in src/edgeless.ts, + // so a wrapped box and a packed one read alike. + const order: (string | undefined)[] = [undefined]; + const banded = new Map ? V : never)[]>([[undefined, []]]); + for (const c of laid.values()) { + const band = bandOf?.(c.ns[0].id); + if (!banded.has(band)) { + banded.set(band, []); + order.push(band); + } + banded.get(band)!.push(c); + } + + const all = [...laid.values()]; + const area = all.reduce((s, c) => s + (c.w + COMPONENT_GAP) * (c.h + COMPONENT_GAP), 0); + const targetW = Math.max(Math.max(...all.map((c) => c.w)), Math.sqrt(area * ROW_ASPECT)); + + // The box's own origin, so the packed block lands where the box already is + // and every other box on the canvas stays put. The title row is the box's, + // and no card may sit in it. + const originX = box.x - box.w / 2 + BAND_PAD; + const originY = box.y - box.h / 2 + TITLE; + let shelfX = 0; + let shelfY = 0; + let shelfH = 0; + const bands: RowBand[] = []; + for (const band of order) { + const list = banded.get(band); + if (!list?.length) continue; + // Every band starts its own row, so its cards are the contiguous + // rectangle `addBandBoxes` draws a sub-box around. + if (shelfH > 0) { + shelfX = 0; + shelfY += shelfH + COMPONENT_GAP; + shelfH = 0; + } + const ids: string[] = []; + for (const c of list) { + if (shelfX > 0 && shelfX + c.w > targetW) { + shelfX = 0; + shelfY += shelfH + COMPONENT_GAP; + shelfH = 0; + } + for (const n of c.ns) { + const at = c.local.get(n.id); + if (!at) continue; + n.x = originX + shelfX + at.x; + n.y = originY + shelfY + at.y; + ids.push(n.id); + } + shelfX += c.w + COMPONENT_GAP; + shelfH = Math.max(shelfH, c.h); + } + bands.push({ key: band, ids }); + } + + // The box is now whatever its cards occupy, plus its padding and its title. + const x0 = Math.min(...own.map((n) => n.x - halfW(n))) - BAND_PAD; + const x1 = Math.max(...own.map((n) => n.x + halfW(n))) + BAND_PAD; + const y0 = Math.min(...own.map((n) => n.y - halfH(n))) - TITLE; + const y1 = Math.max(...own.map((n) => n.y + halfH(n))) + BAND_PAD; + box.x = (x0 + x1) / 2; + box.y = (y0 + y1) / 2; + box.w = x1 - x0; + box.h = y1 - y0; + grids.set(key, { cols: 0, rows: bands.length, bands }); + packed = true; + } + + if (!packed) { + for (const n of nodes) n.y = layout.height - n.y; + for (const b of groupBoxes) b.y = layout.height - b.y; + return grids; + } + + // Re-normalise: a shrunken box leaves the canvas bigger than what is on it, + // and a taller one can reach past the bottom. Everything moves as one rigid + // block, so nothing that was inside a box leaves it. + const pad = 40; + const minX = Math.min(...nodes.map((n) => n.x - halfW(n)), ...groupBoxes.map((b) => b.x - b.w / 2)); + const minY = Math.min(...nodes.map((n) => n.y - halfH(n)), ...groupBoxes.map((b) => b.y - b.h / 2)); + for (const n of nodes) { + n.x += pad - minX; + n.y += pad - minY; + } + for (const b of groupBoxes) { + b.x += pad - minX; + b.y += pad - minY; + } + const maxX = Math.max(...nodes.map((n) => n.x + halfW(n)), ...groupBoxes.map((b) => b.x + b.w / 2)) + pad; + const maxY = Math.max(...nodes.map((n) => n.y + halfH(n)), ...groupBoxes.map((b) => b.y + b.h / 2)) + pad; + layout.width = maxX; + layout.height = maxY; + for (const n of nodes) n.y = maxY - n.y; + for (const b of groupBoxes) b.y = maxY - b.y; + return grids; +} + /** Group key per node id — the `src//` a node is declared under, with * `src/examples/…` folded to "examples" and non-src nodes bucketed by lexicon. * This is the grouping the radial layout clusters into wedges. */ diff --git a/src/server.ts b/src/server.ts index fad8100..98607f4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -143,6 +143,7 @@ import { type TerraformElision, } from "./terraform-lens.ts"; import { choudoufuDiffNodes, readChoudoufuLive, type Runner as ChoudoufuRunner } from "./choudoufu-live.ts"; +import { choudoufuLexiconNote } from "./choudoufu-refs.ts"; import { discoverCarvePlans, moveMembers, moveReceipt, movesPayload, readCarvePlan, type MoveMorphMoveInput } from "./choudoufu-moves.ts"; import { memberKindOf, memberKindSpec, servesAsEstate } from "./member-kind.ts"; import { TerraformReadError, discoverTerraformRoots, terraformRootsNote, terraformRootsNoteShort } from "./terraform-member.ts"; @@ -1972,6 +1973,16 @@ export function createApp( const noteShort = [...scans.map((s) => terraformRootsNoteShort(s)), terraformElisionNoteShort(elision)].filter(Boolean).join(" · "); return note ? { note, ...(noteShort && noteShort !== note ? { noteShort } : {}) } : {}; }; + // #393 item 1: a choudoufu estate's own references come from chant's + // terraform lexicon (src/choudoufu-refs.ts), and with it absent there are + // none. Said once, in place of `edgelessNote`'s "nothing in this estate + // references anything else" — which behold has no reader to assert — + // and only when the picture really has no edges, so an estate that got + // them says nothing at all. + const lexiconNote = (graph: { edges: readonly unknown[] }): string | undefined => + graph.edges.length === 0 && (multi ? cfg.projectDirs! : [cfg.projectDir]).some((d) => memberKindOf(d) === "choudoufu") + ? choudoufuLexiconNote() + : undefined; let ir: GraphIR; let mode: "component-status" | undefined; let metaEnv = cfg.env ?? null; @@ -2279,14 +2290,16 @@ export function createApp( // applied and wasn't. `estateLensNote` is undefined unless components was // actually asked for on a composed estate, so this reads as it did for // every other view. + const cdNote = lexiconNote(ir); const srcNote = [ - [estateLensNote, tf.note].filter(Boolean).join(" · ") || (multi ? undefined : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2)), + [estateLensNote, tf.note, cdNote].filter(Boolean).join(" · ") || + (multi ? undefined : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2, cdNote)), collapsedNote, ] .filter(Boolean) .join(" · ") || undefined; - const srcNoteShort = tf.noteShort ? [estateLensNote, tf.noteShort, collapsedNote].filter(Boolean).join(" · ") : undefined; + const srcNoteShort = tf.noteShort ? [estateLensNote, tf.noteShort, cdNote, collapsedNote].filter(Boolean).join(" · ") : undefined; return c.json({ ir, svg, @@ -2688,7 +2701,18 @@ export function createApp( coverNote, namespaceJoinNote(est.joined), namespaceMismatchNote(withoutJoinedMembers(ir.nodes, est.joined)), - notesFor(zoom, ir, undefined, undefined, detail ?? 2), + // #393 item 1: the same substitution /api/graph's estate branch + // makes — with chant's terraform lexicon absent a choudoufu estate + // has no reader for its own references, and the note says that + // rather than asserting there are none. + notesFor( + zoom, + ir, + undefined, + undefined, + detail ?? 2, + ir.edges.length === 0 && cfg.projectDirs.some((d) => memberKindOf(d) === "choudoufu") ? choudoufuLexiconNote() : undefined, + ), collapsed ? collapseNote(collapsed.collapsed) : undefined, ] .filter(Boolean) diff --git a/src/zoom-notes.test.ts b/src/zoom-notes.test.ts index 08f78e9..1bfd589 100644 --- a/src/zoom-notes.test.ts +++ b/src/zoom-notes.test.ts @@ -127,6 +127,30 @@ describe("edgelessNote", () => { expect(edgelessNote("resources", graph(11, 3), 2)).toBeUndefined(); }); }); + + // #393 item 1: a choudoufu estate's references are read by chant's terraform + // lexicon, and with that reader absent BOTH sentences above are wrong — the + // detail tier is not what is missing, and nobody established that nothing + // references anything. + describe("a caller that knows why there are no edges (#393)", () => { + const why = "no edges — … needs chant's terraform lexicon beside behold."; + + it("says the caller's reason instead of the estate fact", () => { + expect(edgelessNote("resources", graph(11, 0), 3, why)).toBe(why); + }); + + it("…and instead of the detail-tier line, which is a different wrong answer", () => { + expect(edgelessNote("resources", graph(11, 0), 2, why)).toBe(why); + }); + + it("is ignored where there ARE edges — an estate that got them needs no caption", () => { + expect(edgelessNote("resources", graph(11, 3), 2, why)).toBeUndefined(); + }); + + it("reaches the statusbar through notesFor", () => { + expect(notesFor("resources", graph(11, 0), undefined, undefined, 2, why)).toBe(why); + }); + }); }); describe("notesFor", () => { diff --git a/src/zoom-notes.ts b/src/zoom-notes.ts index c1bc7c7..0e3ec76 100644 --- a/src/zoom-notes.ts +++ b/src/zoom-notes.ts @@ -127,9 +127,15 @@ export function logicalKept(before: number, after: number): string | undefined { * `undefined` keeps the plain estate-fact wording, since a caller that * cannot say what tier it fetched at has no narrower claim to make. */ -export function edgelessNote(zoom: Zoom, ir: NotableGraph, detail?: number): string | undefined { +export function edgelessNote(zoom: Zoom, ir: NotableGraph, detail?: number, instead?: string): string | undefined { if (zoom === "components" || zoom === "logical") return undefined; if (ir.nodes.length === 0 || ir.edges.length > 0) return undefined; + // #393 item 1: a caller that knows WHY there are no edges says so instead. + // A choudoufu estate's own references are read by chant's terraform lexicon + // (src/choudoufu-refs.ts), and with that reader absent both sentences below + // are claims behold cannot make — the detail tier is not what is missing, + // and "nothing references anything else" is a fact nobody established. + if (instead) return instead; if (detail !== undefined && detail < 3) { return "no edges at this detail — sourceRef/dependsOn and other attrs-derived references only appear at detail 3 (⌘K → attributes, or add &detail=3)"; } @@ -211,6 +217,7 @@ export function notesFor( compositeEdgesAttached?: number, logicalBefore?: number, detail?: number, + edgelessInstead?: string, ): string | undefined { // When the caller can say what logical was given, that reading wins: it // catches the partial projection the empty-only check cannot see. @@ -218,7 +225,7 @@ export function notesFor( zoom === "logical" && logicalBefore !== undefined ? logicalKept(logicalBefore, ir.nodes.length) : zoomNote(zoom, ir, compositeEdgesAttached); - const notes = [primary, edgelessNote(zoom, ir, detail)].filter((n): n is string => n !== undefined); + const notes = [primary, edgelessNote(zoom, ir, detail, edgelessInstead)].filter((n): n is string => n !== undefined); return notes.length ? notes.join(" · ") : undefined; } From 1b0bee5626ffc3789ccb81ab0c93f9ac3d43d075 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:49:11 -0600 Subject: [PATCH 09/10] =?UTF-8?q?feat(ui):=20=E2=8C=98K=20takes=20an=20add?= =?UTF-8?q?ress=20=E2=80=94=20node=20rows=20over=20the=20painted=20graph,?= =?UTF-8?q?=20and=20a=20pan=20that=20lands=20on=20the=20card=20(#393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette had zoom, env, panel and deploy commands and nothing that took a node, which on a 301-card estate meant there was no way to reach a card except to find it by eye. Two characters in, ⌘K now matches the ids and addresses of the graph the page is currently showing — the SPA already holds the IR it painted — and offers up to twelve `node:
` rows with the member and the kind on a second line. That second line is not decoration: two members of one estate can declare the same address, and the address alone cannot tell them apart. Prefix matches rank first, so `aws_iam_role.team_00` reaches `team_0007_role` before a card that merely mentions it, and the rows are built per keystroke rather than on open — 301 rows in front of somebody who typed nothing would bury every command. Enter takes the same path a click on the card does (`selectNode`: the inspect pane and the highlight) and then pans the graph onto it. There was no programmatic pan before this, only "⤢ fit"'s reset, so `revealNode` is the smallest one: the same viewBox the wheel and the drag drive, centred on the card's own rectangle read off the DOM, at a window nine card-widths across and never wider than the fit. A node the current zoom elided says so in a toast rather than panning to nowhere. Nothing here fetches — the IR is the one the page painted and the pan is a viewBox — so a static export answers ⌘K exactly as a served project does. Driven in headless Chrome against the stub (smoke/stub.mjs's `nonChant` estate now paints composed ids on an 8000 x 3000 canvas with one card in the far corner, and two members declaring one address) and by hand against the terralith-4 workbench entry: typing `aws_iam_role.team_0007_role` offers one row reading `t4 · aws_iam_role · choudoufu`, and Enter moves the viewBox from `0 0 7660 5162` to `289 744 2808 1892` with the card selected and inside it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- AGENTS.md | 8 ++++ smoke/stub.mjs | 41 ++++++++++++++++- smoke/ui-smoke.mjs | 43 +++++++++++++++++- web/app.js | 111 ++++++++++++++++++++++++++++++++++++++++++++- web/index.html | 6 ++- 5 files changed, 204 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 51d7b98..d42d78a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,14 @@ preview-locked, and one load runs at a time (409 otherwise). "Collapse large boxes" / "Expand all". Below the limit it changes nothing. 3. **inspect** — a node's `sourceLoc.file` is the typed source that declared it; edit there to change the estate (chant is the source of truth, not behold). +4. **find** — ⌘K also takes an ADDRESS (#393). Two characters in, the palette + matches the ids and addresses of the graph the page is currently showing and + offers up to twelve `node:
` rows, prefix matches first, each with + its member and kind on a second line (two members of one estate can declare + the same address). Enter takes the same path a click on the card does — the + inspect pane and the highlight — and pans the graph onto it: `revealNode` in + web/app.js drives the same viewBox the wheel, the drag and "⤢ fit" drive. + Nothing here fetches, so it works in a static export too. ## The carve loop (Terraform → chant, #230) diff --git a/smoke/stub.mjs b/smoke/stub.mjs index 1f84b2b..eb4cdcc 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -466,6 +466,43 @@ const NON_CHANT_PROJECT = { memberKinds: ["terraform"], }; +// #393 item 4 (⌘K takes an address): an estate at the scale that made the audit +// ask for it — composed ids (`/
`), one card per team, on a +// canvas far wider than the pane. The last card sits in the far corner of an +// 8000 x 3000 viewBox, so a palette pick that only SELECTED it would leave it a +// pixel at the edge of the fit: the assertion is that the viewBox moves. +// +// Addresses are the terralith's own shape, and there are two members declaring +// the same address on purpose — `aws_iam_role.shared` — because that pair is +// the reason a node row needs a second line at all. +export const PAL_MEMBER = "terralith-4"; +export const PAL_FAR = { id: `${PAL_MEMBER}/aws_iam_role.team_0007_role`, x: 7200, y: 2600 }; +const palCards = [ + ...Array.from({ length: 8 }, (_, i) => ({ + id: `${PAL_MEMBER}/aws_iam_role.team_000${i}_role`, + kind: "aws_iam_role", + x: i === 7 ? PAL_FAR.x : 120 + i * 420, + y: i === 7 ? PAL_FAR.y : 900, + })), + { id: `${PAL_MEMBER}/aws_iam_role.shared`, kind: "aws_iam_role", x: 120, y: 1500 }, + { id: "cohort-iam-ecr/aws_iam_role.shared", kind: "aws_iam_role", x: 620, y: 1500 }, +]; +const palCardSvg = (c) => ` + + + ${c.id} + ${c.kind} + `; +const NON_CHANT_SVG = ` + + +${palCards.map(palCardSvg).join("\n")} +`; +const NON_CHANT_IR = { + nodes: palCards.map((c) => ({ id: c.id, kind: c.kind, lexicon: "choudoufu", attrs: { _status: "good", rung: "tag-governable", estate: "behold-terralith-4" } })), + edges: [], +}; + export function startStub(port, { carve = false, nonChant = false } = {}) { // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a @@ -584,8 +621,8 @@ export function startStub(port, { carve = false, nonChant = false } = {}) { // which is the whole point here: one member, a long note, a short one. if (path === "/api/graph" || path === "/api/overlay") { return json({ - ir: irFor("live"), - svg, + ir: NON_CHANT_IR, + svg: NON_CHANT_SVG, meta: { projectDir: NON_CHANT_PROJECT.projectDir, env: "live", tier: null, target: null, estate: 1, note: NON_CHANT_NOTE, noteShort: NON_CHANT_NOTE_SHORT }, }); } diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index a1b594f..be144ca 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -11,7 +11,7 @@ import { chromium } from "playwright"; import { mkdirSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { startStub, JSON_FIXTURE, BOX, EDGE_VIA } from "./stub.mjs"; +import { startStub, JSON_FIXTURE, BOX, EDGE_VIA, PAL_FAR, PAL_MEMBER } from "./stub.mjs"; import { THEMES, DEFAULT_THEME } from "../web/themes.js"; import { tokensFor, pinTokensFor, colorForCategory, setTheme, hexToOklch, contrast } from "../web/theme.js"; import { helmIconFor, PLATE_FILL } from "../src/icon-packs.ts"; @@ -1109,6 +1109,47 @@ try { const metaLine = await wbPage.locator("#meta").innerText(); check("a lone member reads 'estate of 1 project'", metaLine.includes("estate of 1 project") && !metaLine.includes("1 projects")); + // Item 4: ⌘K takes an address, and lands on the card. + const fitBox = () => wbPage.locator("#graph svg").getAttribute("viewBox"); + const atFit = await fitBox(); + await wbPage.click("#hintk"); + await wbPage.fill("#pal-input", "aws_iam_role.team_0007"); + await wbPage.waitForTimeout(120); + const nodeRows = wbPage.locator("#pal-list .row"); + check("typing an address offers the card", (await nodeRows.first().innerText()).startsWith(`node: aws_iam_role.team_0007_role`)); + check( + "…with the member and the kind on its second line", + (await nodeRows.first().locator(".sub").innerText()).includes(PAL_MEMBER) && (await nodeRows.first().locator(".sub").innerText()).includes("aws_iam_role"), + ); + // Two members declare `aws_iam_role.shared`; both are offered, and the + // second line is the only thing that tells them apart. + await wbPage.fill("#pal-input", "aws_iam_role.shared"); + await wbPage.waitForTimeout(120); + const shared = await wbPage.locator("#pal-list .row .sub").allInnerTexts(); + check("two members declaring one address are two rows", shared.filter((s) => s.includes("aws_iam_role")).length === 2); + + // Prefix matches rank first, and the list is capped. + await wbPage.fill("#pal-input", "aws_iam_role."); + await wbPage.waitForTimeout(120); + const many = await wbPage.locator("#pal-list .row").allInnerTexts(); + check("the node rows are capped", many.filter((r) => r.startsWith("node: ")).length <= 12); + check("a prefix match leads", many[0].startsWith("node: aws_iam_role.")); + + await wbPage.fill("#pal-input", "aws_iam_role.team_0007"); + await wbPage.waitForTimeout(120); + await wbPage.keyboard.press("Enter"); + await wbPage.waitForTimeout(200); + check("Enter closes the palette", (await wbPage.locator("#palette.on").count()) === 0); + check("…and the inspect pane shows the card", (await wbPage.locator("#inspect").innerText()).includes(PAL_FAR.id)); + check("…and the card is highlighted, the same as a click", (await wbPage.locator(`#graph svg [data-node-id="${PAL_FAR.id}"].sel`).count()) === 1); + const landed = (await fitBox()).split(/\s+/).map(Number); + check("…and the graph panned: the viewBox is no longer the fit", (await fitBox()) !== atFit); + check( + "…onto the card, which is inside the viewport", + PAL_FAR.x >= landed[0] && PAL_FAR.x + 312 <= landed[0] + landed[2] && PAL_FAR.y >= landed[1] && PAL_FAR.y + 84 <= landed[1] + landed[3], + ); + await wbPage.screenshot({ path: join(SHOTS, "11-palette-address.png") }); + // Item 3, from the browser's own side: nothing red in the console on the // way to that first screen. check("no console errors on a non-chant estate's boot", wbErrors.length === 0); diff --git a/web/app.js b/web/app.js index 0c4ac19..99c3c68 100644 --- a/web/app.js +++ b/web/app.js @@ -2956,6 +2956,45 @@ function fitGraph() { applyVB(); } } + +// How much of the viewBox one card should take when ⌘K lands on it (#393): the +// window is this many card-widths across, so a 301-card estate arrives readable +// rather than at whatever zoom the last gesture left. Never wider than the fit, +// which is the whole graph — there is nothing beyond it to show. +const REVEAL_CARDS_ACROSS = 9; + +/** + * Pan (and zoom) the graph so one card sits in the middle of the pane (#393 + * item 4). The same viewBox the wheel/drag handlers drive and "⤢ fit" resets — + * there was no programmatic pan before this, only the reset. + * + * The card's rectangle comes from the DOM rather than from the IR: pinhole + * paints a card as a `` whose first `` carries absolute + * viewBox coordinates (no transform on the estate view, unlike the radial one), + * and `getBBox()` covers whichever it is. Returns false when there is no such + * card on the canvas — a node the current zoom elided — so the caller can say + * so instead of panning to nowhere. + */ +function revealNode(id) { + const svg = currentSvg(); + if (!svg || !vb || !vbInit) return false; + const g = svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`); + if (!g) return false; + let box; + try { + box = g.getBBox(); + } catch { + const r = g.querySelector("rect"); + if (!r) return false; + box = { x: +r.getAttribute("x"), y: +r.getAttribute("y"), width: +r.getAttribute("width"), height: +r.getAttribute("height") }; + } + if (!box.width || !box.height) return false; + const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60)); + const h = w * (vbInit[3] / vbInit[2]); + vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h]; + applyVB(); + return true; +} function ensureZoomControls(host) { let btn = document.getElementById("zoom-toggle"); if (!btn || btn.parentElement !== host) { @@ -4705,9 +4744,73 @@ function paletteCommands() { return c.map(([label, run]) => ({ label, run })); } +// --- ⌘K takes an address (#393 item 4) --------------------------------------- +// The audit's finding was that the palette had zoom, env, panel and deploy +// commands and nothing that took a node. The SPA already holds the IR it +// painted (`lastGraphIr`), so the whole feature is a filter over it. +// +// Built per keystroke rather than baked into `paletteCommands()` on open: a +// 301-card estate would otherwise put 301 rows in front of somebody who typed +// nothing, and push every command below them. Nothing at all until two +// characters are typed, and at most {@link PAL_NODE_ROWS} rows after that. +// +// Outside `paletteCommands()` for a second reason: that function returns early +// in a static export (no writes there at all), and finding a card is a READ. +// Nothing below fetches anything — the IR is the one the page painted and the +// pan is a viewBox — so a bundle answers ⌘K exactly as a served project does. + +/** How many node rows the palette will show. A cap, not a ranking: past a + * dozen the reader is scrolling a list rather than recognising a name, and the + * answer is a longer query. */ +const PAL_NODE_ROWS = 12; + +/** The address inside a composed id — `terralith-4/aws_iam_role.x` is the id, + * `aws_iam_role.x` is what a person types. A member name cannot hold a slash + * (composeStacks' `shortStackNames`), so the FIRST one splits it. */ +function nodeAddress(id) { + const slash = id.indexOf("/"); + return slash >= 0 ? id.slice(slash + 1) : id; +} +function nodeMember(id) { + const slash = id.indexOf("/"); + return slash >= 0 ? id.slice(0, slash) : ""; +} + +/** The node rows for a query: matches on the composed id and on the address, + * with anything that STARTS with the query first — typing `aws_iam_role.team_00` + * should reach `aws_iam_role.team_0007_role` before a card that merely mentions + * it. Stable within each half (the IR's own order), so the list does not + * reshuffle as a query grows. */ +function paletteNodes(q) { + if (!lastGraphIr || q.length < 2) return []; + const prefix = []; + const rest = []; + for (const n of lastGraphIr.nodes) { + const address = nodeAddress(n.id); + const id = n.id.toLowerCase(); + const a = address.toLowerCase(); + if (a.startsWith(q) || id.startsWith(q)) prefix.push({ n, address }); + else if (a.includes(q) || id.includes(q)) rest.push({ n, address }); + if (prefix.length >= PAL_NODE_ROWS) break; + } + return [...prefix, ...rest].slice(0, PAL_NODE_ROWS).map(({ n, address }) => ({ + label: `node: ${address}`, + // What the address alone does not say: which member it is in, and what it + // is. Two members of one estate can declare the same address. + sub: [nodeMember(n.id), n.kind, n.lexicon].filter(Boolean).join(" · "), + run: () => { + selectNode(n.id); // the same path a graph click takes — inspect included + if (!revealNode(n.id)) showToast(`${address} is not on this view — it may be elided at this zoom`, false); + }, + })); +} + function palRender() { const q = palInput.value.toLowerCase().trim(); - palCurrent = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds; + const cmds = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds; + // Nodes first: a query that matches a card is nearly always somebody looking + // for that card, and a query that matches no card costs nothing. + palCurrent = [...paletteNodes(q), ...cmds]; palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1)); palList.replaceChildren(); if (!palCurrent.length) { @@ -4721,6 +4824,12 @@ function palRender() { const d = document.createElement("div"); d.className = "row" + (i === palSel ? " sel" : ""); d.textContent = c.label; + if (c.sub) { + const sub = document.createElement("div"); + sub.className = "sub"; + sub.textContent = c.sub; + d.appendChild(sub); + } d.onmousedown = (ev) => { ev.preventDefault(); closePalette(); diff --git a/web/index.html b/web/index.html index 38984c4..68ceaa7 100644 --- a/web/index.html +++ b/web/index.html @@ -523,6 +523,10 @@ font: var(--t-body)/1.5 var(--font-mono); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #pal-list .row.sel { background: var(--pending); color: var(--pal-sel-fg, var(--fg)); } + /* A node row's second line (#393): the member and the kind, which is what + tells two same-named addresses in two members apart. Inherits the row's + colour so the selected row stays readable on --pending. */ + #pal-list .row .sub { font-size: var(--t-caption); opacity: 0.66; margin-top: 1px; } #pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: var(--t-body); } @@ -585,7 +589,7 @@

theme

-
+
From f68b8919a7d05696ede4123cfd496b0cdaaa7e52 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:05:48 -0600 Subject: [PATCH 10/10] test(choudoufu): the member via reads through the test seam too, so the overlay vocabulary test needs no binary CI has no choudoufu; the overlay of a choudoufu estate read through captureChoudoufu directly and refused there, so the vocabulary assertion met an error body. createApp now hands a test's runner to the via as well, and the route test's fake answers live-plan from the recorded documents. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DoXg2GDWwXJRPQo76t9Nrq --- src/choudoufu-member.ts | 12 ++++++++++++ src/choudoufu-route.test.ts | 1 + src/server.ts | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/choudoufu-member.ts b/src/choudoufu-member.ts index ad7f4a4..349a380 100644 --- a/src/choudoufu-member.ts +++ b/src/choudoufu-member.ts @@ -479,7 +479,19 @@ export function choudoufuSpawnEnv(base: NodeJS.ProcessEnv = process.env): NodeJS * once at close, as `runChantRaw` does — coercing per chunk corrupts a * multi-byte character straddling the 64KB highWaterMark. Never rejects: a * missing binary is code 127, a failing exit is data. */ +/** The runner every choudoufu read goes through when a test hands one in + * (`createApp({ choudoufu: { run } })`): the moves routes took it as an + * argument since #371, but the member's own via reads through + * `captureChoudoufu` directly, so the overlay of a choudoufu estate could not + * be exercised without a binary on PATH — CI has none. Undefined outside a + * test; set beside `setChoudoufuSpawnEnv`. */ +let runnerOverride: ((args: string[], cwd: string) => Promise) | undefined; +export function setChoudoufuRunner(run: ((args: string[], cwd: string) => Promise) | undefined): void { + runnerOverride = run; +} + export function captureChoudoufu(args: string[], cwd: string, bin: string = choudoufuBinary()): Promise { + if (runnerOverride) return runnerOverride(args, cwd); return new Promise((resolvePromise) => { const out: Buffer[] = []; const err: Buffer[] = []; diff --git a/src/choudoufu-route.test.ts b/src/choudoufu-route.test.ts index 227817b..30269a8 100644 --- a/src/choudoufu-route.test.ts +++ b/src/choudoufu-route.test.ts @@ -41,6 +41,7 @@ function fakeChoudoufu(spawns: string[][]) { if (verb === "live-check") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-check-monolith.json") : raw("choudoufu-live-check-team-a.json") }; if (verb === "live-mv") return { code: 0, stderr: "", stdout: args[4] === "aws_iam_role.team_b" ? raw("choudoufu-live-mv-refused.json") : raw("choudoufu-live-mv-cross-estate-dry-run.json") }; if (verb === "live-ls") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-ls-monolith.json") : raw("choudoufu-live-ls-team-a-after-split.json") }; + if (verb === "live-plan") return { code: 0, stderr: "", stdout: inMono ? raw("choudoufu-live-plan-monolith-clean.json") : raw("choudoufu-live-plan-team-a-after-split.json") }; return { code: 2, stderr: `unexpected ${verb}`, stdout: "" }; }; } diff --git a/src/server.ts b/src/server.ts index 3809b8e..bb9326e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -133,7 +133,7 @@ import { sourceCommits, openRollbackBranches } from "./history.ts"; import { composeEstate, composeEstateOverlay, estateMembers, withoutJoinedMembers } from "./estate.ts"; import { statusVocabulary } from "./status-vocabulary.ts"; import { addEstateMemberEdges } from "./estate-edges.ts"; -import { addChoudoufuReferenceEdges, liveCheckToIr, readLiveCheck, setChoudoufuSpawnEnv } from "./choudoufu-member.ts"; +import { addChoudoufuReferenceEdges, liveCheckToIr, readLiveCheck, setChoudoufuRunner, setChoudoufuSpawnEnv } from "./choudoufu-member.ts"; import { filterTerraformCards, groupTerraformByRoot, @@ -896,6 +896,9 @@ export function createApp( }), ): Hono { const app = new Hono(); + // A test's fake choudoufu answers every read, the member via's included + // (src/choudoufu-member.ts `setChoudoufuRunner`); undefined in production. + setChoudoufuRunner(cfg.choudoufu?.run); // Carve mode (#252) claims /api/graph, /api/project and friends before the // project-shaped handlers are registered — see carveRoutes.