chant graph
Synopsis
Section titled “Synopsis”chant graph [path] [--stacks] [--json]chant graph [path] [--components] [--json]chant graph [path] --format ir|mermaid|dot|layout [--detail 0..3] [--lens <kind>:<target>] [--up] [--down]chant graph [path] --format layout [--node-sizes <json|-|@file>] [--layout-engine dagre|graphviz]chant graph [path] --components --format ir --projection gitlab|github|forgejochant graph --format ir --live --env <name>chant graph --format ir --live --env <name> --overlaychant graph --format ir --at <ref> --env <name>chant graph [path] [--env <name>] [--param name=value] [--params-file <path>] [--src <dir>] [--sandbox]Description
Section titled “Description”chant graph has four modes:
- default — the Op dependency graph (edges from each Op’s
dependsfield). --stacks— the cross-stack apply-ordering graph.--components— the component dependency graph, from each discovered component’sdependsOn.--format ir|mermaid|dot|layout— the infrastructure graph chant resolves during synthesis, emitted for diagrams. This is the graph of your declared resources and the references between them.
The infra-graph formats are computed from lint-clean source alone. Every node traces to a source file, and every edge is a real cross-resource reference. They are lint-gated: chant refuses to emit a graph for source that does not pass chant lint, because the graph represents valid infrastructure.
Declared Ops are part of the infra graph. *.op.ts files are collected the way chant graph (the Op view) and chant run find them, from the git root, so an op kept outside sourceDir (ops/, or the project root beside sourceDir: "src") still appears as a Chant::Op node. Its attrs carry the full OpConfig (phases, steps, gates, depends) at --detail 3; lower tiers keep only the scalar fields.
--live — the provisioned graph
Section titled “--live — the provisioned graph”--format ir --live --env <name> emits the graph of what’s actually deployed in that environment, not what source declares. It queries each lexicon’s live observation (describeResources) for the chant-managed resources and projects them into the same IR. Nodes carry their observed physicalId and ownership marker.
chant graph --format ir --live --env prod # the provisioned resources as IREdges are reconstructed from the live references: observed resources point at each other by physical id (a subnet’s VpcId, an ALB listener’s TargetGroupArn), and each lexicon ships a reference catalog that turns those into graph edges (peer references) and containment (subnet ∈ VPC). A reference whose target isn’t in the observed set is reported as dangling, never a wrong edge.
--live reads only managed resources, the ones carrying chant’s ownership marker, and does not scan the full account. Unlike the source graph, --live is not lint-gated (it reads the cloud, not source).
Edge reconstruction is only as rich as the observed node attributes. AWS’s plain describe-stack-resources metadata is too thin, so the AWS lexicon enriches from the deployed CloudFormation template (its {Ref}/{Fn::GetAtt} references, resolved to node ids) before reconstruction, so a managed AWS stack produces its topology. A lexicon without that enrichment yields nodes with fewer edges.
--at <ref>, a recorded snapshot
Section titled “--at <ref>, a recorded snapshot”--format ir --at <ref> --env <name> graphs a snapshot chant lifecycle snapshot recorded, instead of reading the estate now. ref is a commit on the chant/lifecycle orphan branch, or latest. It is the same projection --live produces, from a recorded observation, so it answers “what was deployed then” without credentials. --live and --at are two different observations with no rule for which wins, so passing both is refused.
chant graph --format ir --at latest --env prod--overlay — declared vs provisioned
Section titled “--overlay — declared vs provisioned”--live --overlay also reads your declared source and classifies every resource, tagging each node so a renderer colours the drift:
- managed — declared and provisioned
- foreign — provisioned but not declared (in the cloud, not in your source)
- pending — declared but not yet provisioned (appended to the graph)
chant graph --format ir --live --env prod --overlayAt --detail 1 a collapsed composite takes the _status of its members (#4227). It is good when every member is managed. Otherwise a pending member (accent) outranks one chant could not read (neutral, with its reason on the member’s _unobserved), and _memberStatus lists the ids of the members that are not good under their status. A foreign resource belongs to no composite, so it stays a node of its own. A composite in a graph read without --overlay has no _status.
{ "id": "web", "kind": "FargateAlb", "attrs": { "members": 11, "_status": "accent", "_memberStatus": { "accent": ["three-cloud-aws::webService"] } } }--overlay-anchor <source|live> — which graph is the canvas
Section titled “--overlay-anchor <source|live> — which graph is the canvas”The overlay merges two graphs; the anchor decides which one’s edges survive.
source(default) — the declared graph is the canvas, so its edges are kept. This matters because cross-substrate edges (an ECS service wired to a k8s workload) are a source-graph property: live edges are reconstructed per-substrate by identifier match and never cross providers, so the mixed topology exists only in the declared graph. Each declared node is taggedmanagedorpendingand carries the observedphysicalId/ownership; foreign resources are appended with any live edges that touch them.live— the provisioned graph is the canvas, keeping its reconstructed live edges (the original behaviour). Use it when the provisioned graph itself is the subject and per-substrate islands are acceptable.
chant graph --format ir --live --env prod --overlay # source-anchored (keeps cross-substrate edges)chant graph --format ir --live --env prod --overlay --overlay-anchor live # provisioned graph's own edges--traffic "<level>" for a prediction on the graph
Section titled “--traffic "<level>" for a prediction on the graph”--traffic "<level>" asks the project’s predicting lexicon what the estate does at that traffic level, and carries the answer on the IR. Each entity’s block rides on attrs._behaviour. The graph-level half rides on meta._behaviour: which engine answered, at what version and level, with the estate totals.
It works on either graph, and the two answer different questions. With --live the prediction is about the account as it stands, drift included. Without it, the prediction is about the declared source, which is the file rather than anything deployed, so no environment is named. Run both to see the delta:
chant graph --format ir --traffic "100 rps, p50" # the filechant graph --format ir --live --env prod --overlay --traffic "100 rps, p50" # the accountWhich entities carry figures, which are declared unmapped and why, and what a type with no row is reported as: Behaviour Coverage, generated from the rows each lexicon contributes.
On a live root, the graph’s own read and the prediction’s read are one read: the command shares each live-plan document and live-ls listing between them, so the statuses and the figures describe the same moment in the account.
The level is passed to the engine verbatim. chant neither parses it nor supplies a default of its own. Every figure an engine returns is at a stated traffic, so inventing one would silently decide what every number on the graph means. That is why the flag is the trigger rather than the engine variable:
attrs._behaviour | meta._behaviour | |
|---|---|---|
| flag, and an engine answered | the block, per priced entity | the report’s meta |
| flag, and no engine or an unreachable one | absent | the whole refusal report |
| no flag | absent | absent |
Absence of both means chant did not look, which is a different statement from a refusal. A refusal names a cause and a remedy. An entity the engine could not price carries no block rather than a zeroed one, since “costs nothing” and “was not priced” are different claims.
The engine sees no credentials. Every request is screened before it is sent, and an engine run as a command is started with a fixed environment and none of the caller’s.
Predictions need predictBehaviour from an installed lexicon. With none the graph is served unchanged, and says so once. The Terraform lexicon implements it (chant #2495). Under --live it makes one read of its own: a live graph’s nodes carry an address and a provider type and no body, so an instance arrives with no instance_type for an engine to price. The lexicon declares each observed root again from terraform.roots and runs live-ls and live-plan on it, the two reads describeResources already makes. Both sides of the delta then reach the engine with the same sizes and the same containment, and what differs between them is the account.
--namespace <ns> — where to read entities that declare none
Section titled “--namespace <ns> — where to read entities that declare none”A live read resolves each entity’s address from its own declaration. For a Kubernetes object whose namespace is stamped by a controller at apply time (a Flux/Argo estate where the binding lives in a different project than the objects), there is nothing to resolve, so the read goes to default and every node paints pending. --namespace <ns> supplies the missing default:
chant graph --format ir --live --env prod --overlay --namespace app-bAn entity that declares its own namespace keeps it; the option only fills holes. Lexicons with no namespace-like scope ignore it.
Infra graph: --format
Section titled “Infra graph: --format”chant graph ./infra --format mermaid # paste-able Mermaid flowchart (renders in GitHub)chant graph ./infra --format ir # the graph IR as JSON (the contract for tools)chant graph ./infra --format dot # Graphviz DOT (render with `dot -Tsvg`)chant graph ./infra --format layout # node positions (dagre by default — no native dep)| Format | Output | Needs |
|---|---|---|
mermaid | A Mermaid flowchart with one subgraph per lexicon. The zero-install default — renders in GitHub, docs, and browsers. | nothing |
ir | The graph IR as JSON: version, nodes (id, kind, lexicon, attrs, sourceLoc, composite info), edges (real references, labelled with the consumer property and a kind), groups, and dangling when a CI reference names nothing declared. The stable contract downstream diagram tools consume. | nothing |
dot | Graphviz DOT text, with lexicon clusters and labelled edges. Render directly with dot -Tsvg, or feed to a layout engine. | nothing to produce; dot to render |
layout | Node positions for a custom painter that does its own drawing: { width, height, nodes: [{id, x, y, w, h}] } (y-up, origin bottom-left). | nothing (dagre); dot only with --layout-engine graphviz |
Versioning: additive changes only
Section titled “Versioning: additive changes only”The IR’s first field is version, currently 1. Every --format ir output carries it, including --live, --overlay and --components. Output from a chant older than the field has no version and reads as version 1.
The format changes only additively. A release may add a field, an edge kind or a group, and none of those moves the number. A consumer must ignore any field and any edge kind it does not recognise, and keep reading the rest of the document. version moves only for a change a consumer could not safely ignore, such as a removed or renamed field or a changed meaning, and a consumer that sees a version it does not know should stop rather than guess.
Groups: the boxes a renderer draws
Section titled “Groups: the boxes a renderer draws”groups carries the memberships a renderer turns into boundary boxes, keyed by group name to node ids.
byLexicon records which lexicon declared each node, which is what a renderer colours by.
byStack is the deployable unit, and it has three sources. A project declaring side-by-side stacks in config gets those declared names. A lexicon whose entities name their own unit gets those names, one entry per unit: the terraform lexicon does this, so a project naming five terraform.roots graphs as five boxes rather than one, since a root is what one terraform apply runs against. Anything else falls back to the lexicon partition, on the reasoning that one lexicon serialises to one deployable stack.
byComposite groups the nodes one composite instance expanded into, and byContainer (live graphs only) carries observed containment, a VPC’s subnets and their instances, nested flatly so a renderer can recurse it.
byPipeline (CI lexicons) maps each workflow or pipeline to the nodes that run in it: a GitHub workflow and its jobs, or every job of a GitLab .gitlab-ci.yml. Mermaid and DOT draw each one as a box inside its lexicon’s box. At --detail 1 a job inside a collapsed composite is listed as that composite. A pipeline left with no members is dropped.
--detail 0 has no byPipeline. A pipeline never spans lexicons, so every pipeline there would hold only its lexicon’s stack node.
A lens keeps the members it keeps.
Edges from a lexicon that parses foreign source
Section titled “Edges from a lexicon that parses foreign source”Most edges come from resolving an AttrRef in a lexicon’s typed props. A lexicon that reads an estate someone else wrote has no such object: the terraform lexicon keeps each HCL block verbatim, where a reference is the string "${aws_vpc.main.id}". Those lexicons resolve their own references against their own key space and publish the result, and the IR turns each one into an ordinary edge. A consumer sees the same edge whichever way it was found.
The azure lexicon does the same for references written as ARM expressions: resourceId(...), reference(...) and listKeys(...), in a value or an object key such as identity.userAssignedIdentities. It reads them with the same function the serializer uses to write dependsOn (lexicons/azure/src/graph-edges.ts), so the graph and the template name the same dependencies. A reference matches a declared resource by its ARM name, and a name nothing declares, an existing resource the template reads, makes no edge.
These edges are on the declared side of --live --overlay too.
CI edges
Section titled “CI edges”A CI lexicon writes its dependencies as strings: needs: ["build"], uses: ./.github/workflows/ci.yml, include: ci/templates.yml. The github, forgejo and gitlab lexicons resolve those strings against the project’s declared entities, the way their serializers place jobs in workflows, and report each one as an edge with a kind saying what it means:
kind | From | To | Written as |
|---|---|---|---|
needs | job | job it waits for | GitHub and GitLab needs: (a name, { job }, or the Job itself). A GitLab job with no needs: waits for the stage before its own, reported with viaAttr: "stage" |
calls | job | reusable workflow | uses: ./.github/workflows/<file> (or .forgejo/workflows/), matched to the declared workflow written to that file. A step using a local composite action by path is a calls too |
triggers | job or workflow | pipeline | GitLab trigger: (the job starts the child or multi-project pipeline). GitHub on.workflow_run.workflows (the workflow named finishing starts this one), matched by workflow name |
includes | pipeline | file | GitLab include: |
extends | job | job it inherits from | GitLab extends: |
Every edge points from the entity that wrote the reference. Some edges leave their pipeline. A reusable-workflow call or workflow_run does, as does a GitLab trigger or include or a cross-project needs. They carry crossPipeline: true, and Mermaid and DOT draw them dashed. The rest render labelled with their kind, plus the attribute when it adds something (needs (stage)).
A string that names nothing declared in the project is never dropped and never matched to a near name. It goes on dangling, as written:
"dangling": [ { "from": "deploy", "kind": "needs", "path": "needs.project", "value": "group/lib@main:package", "crossPipeline": true }, { "from": "build", "kind": "calls", "path": "steps.uses", "value": "./.github/actions/setup" }]Another repository’s workflow lands here, and so does a GitLab child pipeline or included file (chant generates one .gitlab-ci.yml per project). A hidden extends template from an included file lands here too. The same holds for a composite action, which chant does not declare, and for a typo’d job name. Mermaid and DOT draw each as a dashed edge to a stub labelled (not declared).
A lexicon reports these through the plugin’s graphEdges(entities) hook; see the lexicon authoring overview.
--format layout: size-aware positions
Section titled “--format layout: size-aware positions”The layout step exists for custom painters (the pinhole diagrammer is one): chant computes positions and the painter draws. By default it uses dagre, a pure-JS engine, so --format layout needs no native dependency.
A painter draws its nodes (cards, icons and labels) at a real size. It can pass those footprints in and get spacing that fits them, so no two nodes overlap.
# {id: {w, h}} in layout units (≈ px). Inline, or '-' for stdin, or '@file.json'.chant graph ./infra --format layout --node-sizes '{"vpc":{"w":180,"h":68}, ...}'echo "$SIZES" | chant graph ./infra --format layout --node-sizes -Without --node-sizes, nodes fall back to a default box (the layout still works, but a painter with larger nodes may need to space them itself).
To use Graphviz for layout instead of dagre (e.g. to match dot’s routing), pass --layout-engine graphviz. Only this path needs dot (brew install graphviz), and it honours lexicon clusters.
Detail tiers: --detail 0..3
Section titled “Detail tiers: --detail 0..3”The detail dial controls how much of the graph each format shows. It is a transform over the same IR, so it works with every --format. It moves two things at once: the graph’s shape (what collapses) and each node’s attribute payload, which only grows. A node’s attrs at one level are always a subset of its attrs at the next.
| Level | Name | Shows |
|---|---|---|
0 | stacks | one node per lexicon; edges are cross-lexicon dependencies; no attrs and no groups |
1 | composites | each composite instance collapsed to a single node; topology only — no per-resource properties. Edge kinds, dangling, exports, imports and groups.byPipeline carry through, mapped to the collapsed nodes. An instance is its export name plus its composite type, so two composites exported under one name get the ids <name>@<Composite> |
2 | declarables | every resource with the resource view of its attrs — identity and other scalar fields, plus reference envelopes (the default) |
3 | attributes | the full property tree on every node (a k8s spec, a Tags list), plus the referenced producer attribute on each edge |
chant graph ./infra --format mermaid --detail 0 # high-level: lexicons onlychant graph ./infra --format mermaid --detail 1 # composites as single nodeschant graph ./infra --format ir --detail 3 # every resource + attribute-level edgesA handful of composites at --detail 2 expands to every resource they declare. Turn the dial down to 0/1 for an overview.
Lenses: --lens <kind>:<target>
Section titled “Lenses: --lens <kind>:<target>”Lenses focus the graph on a slice. They compose with --detail (the lens is applied first, then the detail tier).
| Lens | Keeps |
|---|---|
lexicon:<name> | only that lexicon’s nodes |
stack:<name> | only one stack’s nodes (stacks map to lexicon partitions today) |
blast:<node> | the transitive neighbourhood of a node |
For blast:, choose a direction (default is both):
--up— what the node depends on (its producers)--down— what depends on the node (its dependents)
chant graph ./infra --format mermaid --lens lexicon:gcpchant graph ./infra --format mermaid --lens blast:vpc --down # everything that depends on the VPCA lens drops edges that would dangle and rebuilds group metadata, so the result is always a self-consistent graph. An unknown or no-match target exits non-zero with a message.
--stacks — cross-stack apply order
Section titled “--stacks — cross-stack apply order”chant graph --stacks [path] renders the cross-stack apply-ordering graph instead. chant resolves cross-lexicon references during build, so it knows the order. A stack that exports a value must apply before the stack that imports it. --stacks prints that order as data for your orchestrator. chant exposes the order and leaves the apply to you.
chant graph --stacks ./infrachant graph --stacks ./infra --jsonThe output is the stacks, their dependency edges (consumer -> producer), a topological apply order, and waves (levels whose stacks have no inter-dependency and may apply concurrently). --json emits the full graph (nodes, edges, order, waves, cycles). A dependency cycle is reported and exits non-zero.
Apply order (waves apply top-to-bottom; a wave's stacks are parallel-safe): 1. aws 2. k8s
Dependencies (consumer → producer): k8s → aws--components — component dependency order
Section titled “--components — component dependency order”chant graph --components [path] renders the component dependency graph: every *.component.ts declaration under path, ordered by dependsOn. Unlike --stacks (which infers edges from cross-lexicon attribute references), a component’s dependency is always an explicit name in its own dependsOn list, so nothing is inferred. It is the same dependsOn the interpret driver uses to order a real run.
chant graph --components ./infrachant graph --components ./infra --jsonThe output is the same shape as --stacks: a topological order, waves (components with no inter-dependency, safe to run concurrently), and dependency edges (consumer -> producer). --json emits the full result. A dependency cycle, or a dependsOn naming an undiscovered component, is reported and exits non-zero.
Deploy order (waves apply top-to-bottom; a wave's components are parallel-safe): 1. shared-alb, orders-table 2. search-service
Dependencies (consumer → producer): search-service → shared-alb--projection <lexicon> — the CI/pipeline projection (#989)
Section titled “--projection <lexicon> — the CI/pipeline projection (#989)”chant graph --components --format ir --projection gitlab|github|forgejo adds the CI/pipeline projection alongside the component-graph IR: the same stages/jobs/needs a CI-provider lexicon’s generateComponentPipeline synthesizes for chant build --components --generate <lexicon> (waves become stages, components become jobs, dependsOn becomes needs:). The projection reuses that output as is and exposes it as IR nodes and edges, so a consumer does not parse it back out of generated CI YAML.
chant graph --components --format ir --projection gitlabThe projection is in ir.pipeline, alongside the component graph’s own nodes/edges/groups.byWave:
{ "nodes": [ /* one per component */ ], "edges": [ /* dependsOn, consumer → producer */ ], "groups": { "byWave": { "wave-1": ["shared-alb"], "wave-2": ["search-service"] } }, "pipeline": { "provider": "gitlab", "stages": ["wave-1", "wave-2"], "nodes": [ { "id": "shared-alb", "kind": "CIJob", "component": "shared-alb", "stage": "wave-1" }, { "id": "search-service", "kind": "CIJob", "component": "search-service", "stage": "wave-2" } ], "edges": [ { "from": "search-service", "to": "shared-alb", "kind": "needs" } ] }}pipeline.nodes/pipeline.edges are a distinct id space from the component graph’s own nodes/edges (a CI-provider’s job name isn’t always identical to the component name), so a consumer renders them as two related graphs sharing one component join key rather than merging them into one. --projection applies only to --format ir. mermaid/dot/layout don’t read pipeline yet, and chant graph --components without --format never builds an IR at all, so either combination is rejected with an error.
Any lexicon whose plugin implements generateComponentPipeline works, not just gitlab/github/forgejo; an unsupported name fails with the same descriptive error chant build --components --generate gives.
Flags shared with chant build
Section titled “Flags shared with chant build”The infra-graph formats build the project the way chant build does, so the build’s inputs apply here too:
| Flag | Meaning |
|---|---|
--env <name> | Active environment. Sets CHANT_ENV so env-aware source re-evaluates for it. Required with --live and --at |
--param <name=value> | Bind a declared build-time parameter; repeatable. Same precedence as chant build --param |
--params-file <path> | JSON file of build-time parameter values |
--src <dir> | Build root override, the same as sourceDir in chant.config.ts |
--sandbox | Run any source the build has to execute in one isolated child process. See Sandboxed Execution |
Op graph (default)
Section titled “Op graph (default)”With no format flag, chant graph discovers all *.op.ts files under the given path and prints the dependency edges declared via each Op’s depends field.
chant graph # Ops in the current directorychant graph ./infra # Ops in a specific directoryEach dependency prints as one edge per line, <dependency> → <dependent>. The left Op must complete before the right Op can run. If no Ops declare dependencies the output is No Op dependencies; if no *.op.ts files are found, No Ops found.
Exit Codes
Section titled “Exit Codes”| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Discovery errors; lint errors (infra-graph formats); a dependency cycle (--stacks, --components) or unknown dependency (--components); an invalid --detail/--lens; malformed --node-sizes; a layout-engine failure (e.g. missing dot with --layout-engine graphviz); --projection used without --components --format ir; or --projection <lexicon> naming a lexicon with no generateComponentPipeline |
See Also
Section titled “See Also”- Ops guide — define Op dependencies with
depends chant lint— the gate the infra-graph formats run firstchant build— synthesize the artifacts the graph describes