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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,43 @@ until the table existed. A new kind is:
`.behold.json` names a kind as `{ "dir": "x", "kind": "<kind>" }`; a bare
string is `chant`. Anything behold boots for a kind goes through
`assertScratch` first.

### Rendering a Terraform estate

A Terraform estate reaches behold through chant, not through a member kind.
`@intentius/chant-lexicon-terraform` reads the HCL an estate already has and
emits one entity per block, so **a Terraform estate is a chant project whose
only lexicon is a reader** and `chant graph --format ir` serves it like any
other. behold parses no HCL and ships no HCL parser — the same posture
`src/carve-lens.ts` states for the carve report (#378).

Three passes turn what arrives into a picture (`src/terraform-lens.ts`), in this
order, guarded on the IR carrying terraform entities so every other estate gets
the identical object back:

1. **`normalizeTerraformNodes`** — a node's `kind` arrives as the entity class
(`Terraform::Resource`), not the resource type, so every card would be titled
and iconed the same. The type moves out of `attrs.address` into `kind` and
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.
3. **`filterTerraformCards`** — what is a card, below.

**What is a card (#382).** Measured on a real estate: 247 nodes for 43
resources, four fifths of it not infrastructure.

| tier | blocks | why |
|---|---|---|
| default (detail 0-2) | `resource`, `data`, `module` | the estate: what is declared, what it reads, what it composes |
| attributes (detail 3) | + `output`, `variable` | its interface — real, but a second question |
| never | `terraform`, `provider`, `locals` | settings, not estate |

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.

**Do not invent edges.** A stock Terraform estate has none until chant#2265
resolves a block's `"${…}"` references. The one relationship that looked
derivable — a cross-root read by name — was measured and refused (#381): both
ends carry the same unresolved interpolation, so a match would be a coincidence
of variable naming. A data source says what it reads as a row instead.
450 changes: 450 additions & 0 deletions src/__fixtures__/terraform-ir-legacy-tf.json

Large diffs are not rendered by default.

731 changes: 731 additions & 0 deletions src/__fixtures__/terraform-ir-two-roots.json

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions src/logical-terraform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* The Terraform lens (#380): the logical topology's projection for a Terraform
* estate chant read. One box per root — a Terraform project's only grouping —
* every card in it, and only the edges the graph actually states.
*
* No inference. A stock estate has no edges until chant#2265 resolves a block's
* `"${…}"` references, and the cross-root read that looked derivable was
* measured and refused (#381). A lens that drew one anyway would be inventing
* the one thing this estate's own tooling declines to claim.
*
* Every other projection filters on its own lexicon and ignores the rest, so a
* mixed estate merges byte-for-byte as before, and `retainCrossLensEdges`
* (#321) keeps an edge whose two ends are drawn by different lenses — which on
* a chant project beside a Terraform root is exactly the seam that matters.
*/
import type { GraphIR, IREdge } from "@intentius/chant";
import type { ByContainer, LogicalProjection } from "./logical.ts";
import { TERRAFORM_LEXICON, isTerraformEntity } from "./terraform-lens.ts";

export function rootBoxTitle(root: string): string {
return `root ${root}`;
}

/** The root a node belongs to: the lexicon's own `attrs.root`, else the
* `<root>/` prefix it mints on every id. */
export function rootOf(node: { id: string; attrs: Record<string, unknown> }): string | undefined {
if (typeof node.attrs.root === "string" && node.attrs.root) return node.attrs.root;
return node.id.includes("/") ? node.id.slice(0, node.id.indexOf("/")) : undefined;
}

export function projectTerraformLogical(ir: GraphIR): LogicalProjection {
// `normalizeTerraformNodes` has usually run by here, which moves the resource
// type into `kind` — so match on the lexicon plus the block attr it leaves,
// and fall back to the raw entity kinds for a caller that skipped it.
const cards = ir.nodes.filter((n) => n.lexicon === TERRAFORM_LEXICON && (typeof n.attrs.block === "string" || isTerraformEntity(n)));
if (cards.length === 0) return { ir: { nodes: [], edges: [], groups: {} }, byContainer: {} };
const kept = new Set(cards.map((n) => n.id));
const byContainer: ByContainer = {};
for (const n of cards) {
const root = rootOf(n);
if (!root) continue;
(byContainer[rootBoxTitle(root)] ??= []).push(n.id);
}
const edges: IREdge[] = [];
const seen = new Set<string>();
for (const e of ir.edges) {
if (!kept.has(e.from) || !kept.has(e.to) || seen.has(`${e.from}|${e.to}`)) continue;
seen.add(`${e.from}|${e.to}`);
edges.push(e);
}
return { ir: { nodes: cards, edges, groups: {} }, byContainer };
}
3 changes: 3 additions & 0 deletions src/logical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { projectHelmLogical } from "./logical-helm.ts";
import { projectKustomizeLogical } from "./logical-kustomize.ts";
import { projectFlyLogical } from "./logical-fly.ts";
import { projectChoudoufuLogical } from "./logical-choudoufu.ts";
import { projectTerraformLogical } from "./logical-terraform.ts";

/** The container-nesting map pinhole's `layoutArchitecture` consumes:
* `containerId → memberIds`, where a member may itself be a container id (the
Expand Down Expand Up @@ -357,6 +358,8 @@ export function projectTopology(ir: GraphIR, env?: string, boundContext?: string
projectFlyLogical(ir),
// #370: a choudoufu member's estate box, the tool's own edges.
projectChoudoufuLogical(ir),
// #380: a Terraform estate's roots as boxes — its only grouping.
projectTerraformLogical(ir),
// The kustomize lens probes for kustomization roots relative to whatever
// base `sourceLoc.file` was reported against — the graphed root on the
// declared path, the project dir on the live overlay path (see the lens's
Expand Down
15 changes: 14 additions & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { ByContainer } from "./logical.ts";
import { k8sIconFor, helmIconFor } from "./icon-packs.ts";
import { carveCardFields } from "./carve-lens.ts";
import { CHOUDOUFU_LEXICON, choudoufuCardFields } from "./choudoufu-member.ts";
import { terraformCardFields } from "./terraform-lens.ts";
import { carveProgress, splitCarveState, type CarveState } from "./carve-manifest.ts";
import { opCardFields } from "./ops-lens.ts";

Expand Down Expand Up @@ -50,7 +51,19 @@ registerPack({ lexicon: "helm", iconFor: helmIconFor });
// `iconFor` opinion: the keyword heuristic already resolves aws_s3_bucket,
// aws_vpc, aws_subnet and aws_lambda_function to sensible glyphs, and guessing
// per Terraform type here would be a worse picture than the one it produces.
registerPack({ lexicon: "terraform", iconFor: () => undefined, fields: carveCardFields });
// Two producers share the `terraform` lexicon and must not disagree about what
// lexicon they are: the carve report's scored resources (#252) and the blocks
// chant's terraform lexicon reads out of an estate's own HCL (#379). One pack,
// dispatching on what the node carries — a carve node has a `score`, a read
// block has an `attrs.block` — so a card is titled by its resource type either
// way and the keyword icon heuristic resolves aws_s3_bucket, aws_vpc and
// friends for both. `normalizeTerraformNodes` (src/terraform-lens.ts) is what
// puts the type in `kind` for the second producer; the first has always had it.
registerPack({
lexicon: "terraform",
iconFor: () => undefined,
fields: (node) => carveCardFields(node) ?? terraformCardFields(node),
});

// The ops lens (#284) registers its own `op` lexicon for the same reason: a step
// card must lead with the phase it sits in and the retry profile it runs under,
Expand Down
51 changes: 50 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ import { sourceCommits, openRollbackBranches } from "./history.ts";
import { composeEstate, composeEstateOverlay, estateMembers, withoutJoinedMembers } from "./estate.ts";
import { addEstateMemberEdges } from "./estate-edges.ts";
import { addChoudoufuReferenceEdges, liveCheckToIr, readLiveCheck, setChoudoufuSpawnEnv } from "./choudoufu-member.ts";
import {
filterTerraformCards,
groupTerraformByRoot,
hasTerraformEntities,
normalizeTerraformNodes,
terraformElisionNote,
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 } from "./member-kind.ts";
Expand Down Expand Up @@ -833,6 +841,21 @@ async function readoptDispatchedRun(
return { outcome: "readopted", run };
}

/**
* 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
* detail. Returns what the filter elided so the view can say so.
*
* Guarded on the IR carrying terraform entities at all, so every chant, k8s and
* choudoufu estate gets the identical object back — the same discipline
* `addChoudoufuReferenceEdges` follows.
*/
function applyTerraformPasses(ir: GraphIR, detail: number | undefined): TerraformElision {
if (!hasTerraformEntities(ir)) return { dropped: {}, total: 0 };
groupTerraformByRoot(normalizeTerraformNodes(ir));
return filterTerraformCards(ir, detail);
}

export function createApp(
cfg: ServerOptions,
broadcaster: Broadcaster = new Broadcaster(),
Expand Down Expand Up @@ -1852,6 +1875,8 @@ export function createApp(
// Multi-estate (#31): graph each project and compose into one IR (namespaced
// ids, per-project boundary boxes, cross-stack edges). Single project → as-is.
const multi = cfg.projectDirs && cfg.projectDirs.length > 1;
// #382: what the Terraform zoom filter elided, when the estate branch ran it.
let estateTfElision: TerraformElision = { dropped: {}, total: 0 };
let ir: GraphIR;
let mode: "component-status" | undefined;
let metaEnv = cfg.env ?? null;
Expand Down Expand Up @@ -1897,6 +1922,9 @@ export function createApp(
ir = markOperatorHome(ir);
const estateContext = await boundK8sContext(metaEnv ?? undefined);
ir = addClusterAnchorEdges(ir, estateContext);
// #379/#380/#382 — see the single-project branch below. An estate whose
// members are Terraform roots gets the same three passes.
estateTfElision = applyTerraformPasses(ir, opts.detail);
// #224: the logical lens over the COMPOSED IR. Every projection joins
// on attribute values, never node ids, so composeStacks' prefixed ids
// pass through exactly as the edge passes above do — and the k8s lens
Expand Down Expand Up @@ -1996,6 +2024,9 @@ export function createApp(
// `addK8sDeclaredEdges` into both logical branches but not this. No
// commit message, comment or test ever recorded the omission.
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);
// #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.
Expand Down Expand Up @@ -2040,6 +2071,15 @@ export function createApp(
// renders as loose nodes beside the cloud graph rather than one estate.
ir = addClusterAnchorEdges(ir, await boundK8sContext(metaEnv ?? undefined));
}
// #379/#380/#382: a Terraform estate chant read. The type moves into
// `kind` so a card is titled and iconed by what it is, the roots become
// the boxes, and the blocks that are settings rather than estate leave
// the canvas at this detail. A no-op on every other estate.
//
// The estate branch above already ran them — it has to, because its
// logical path returns before this line — so this is the single-project
// half of the same one call per request.
const tfElision = multi ? estateTfElision : applyTerraformPasses(ir, opts.detail);
// COMPOSITES (level 1) on the SOURCE view too (#138): the overlay branch
// below has joined the component DAG's dependsOn edges since #84, but a
// source-only serve (no env) rendered the tier with no component edges at
Expand Down Expand Up @@ -2098,7 +2138,9 @@ 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.
const srcNote = multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2);
const srcNote =
terraformElisionNote(tfElision, opts.detail) ??
(multi ? estateLensNote : notesFor(srcZoom, ir, srcCompositeEdgesAttached, undefined, opts.detail ?? 2));
return c.json({
ir,
svg,
Expand Down Expand Up @@ -2421,6 +2463,9 @@ export function createApp(
ir = markOperatorHome(ir);
const boundContext = await boundK8sContext(env);
ir = addClusterAnchorEdges(ir, boundContext);
// #379/#380/#382 — the same three, in the same order, as /api/graph's
// estate branch. A live overlay adds colour, never a different picture.
applyTerraformPasses(ir, detail);
const coverNote =
est.unobserved.length || est.dropped.length
? `live observe covered ${est.observed} of ${est.total} projects — ` +
Expand Down Expand Up @@ -2554,6 +2599,8 @@ export function createApp(
// /api/graph's was (see that branch), and the overlay is
// source-anchored, so this is the same derivation on both.
const projectionInput = addClusterAnchorEdges(addValueMatchEdges(addK8sDeclaredEdges(ir)), boundContext);
// #379/#380/#382 — as on the source logical path.
applyTerraformPasses(projectionInput, opts.detail);
const { ir: projected, byContainer, namespaceBoxes } = projectTopology(projectionInput, env, boundContext, [await graphPath(cfg.projectDir, opts), cfg.projectDir]);
// #234's free rider, the logical lens's half (pinhole#119): this route
// never calls `markOperatorHome` (it returns before the non-logical
Expand Down Expand Up @@ -2589,6 +2636,8 @@ export function createApp(
// rather than dropping the k8s half into the void. The overlay is
// source-anchored, so this is the same derivation, not a live-only one.
ir = addClusterAnchorEdges(ir, boundContext);
// #379/#380/#382 — as on the source path.
applyTerraformPasses(ir, opts.detail);
// At COMPOSITES (level 1), composites only wired via import sinks (now
// pruned) so they'd all float — overlay the authoritative component
// dependsOn graph so they read as a dependency graph (see addCompositeDeps).
Expand Down
Loading