@intentius/chant 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@ import type { CommandContext } from "../registry.js";
2
2
  /**
3
3
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
4
4
  * cross-stack apply-ordering graph (edges, order, waves) chant computes from
5
- * cross-lexicon references; `--format ir` emits the full entity-graph IR
6
- * (lint-gated) for diagram painters and the agentic diagrammer (#493).
5
+ * cross-lexicon references; `--format ir|mermaid` emits the lint-gated
6
+ * entity-graph IR (or a Mermaid flowchart of it) for diagrams (#493/#496).
7
7
  */
8
8
  export declare function runGraph(ctx: CommandContext): Promise<number>;
9
9
  //# sourceMappingURL=graph.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAInE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAOnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAKA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAe9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CAuIpD"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAKA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAe9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA6IpD"}
@@ -58,6 +58,12 @@ export interface ParsedArgs {
58
58
  stacks?: boolean;
59
59
  /** `chant graph --format ir --detail <0..3>` — graph IR detail tier */
60
60
  detail?: number;
61
+ /** `chant graph --lens <kind>:<target>` — focus the graph IR on a slice */
62
+ lens?: string;
63
+ /** `chant graph --lens blast:<node> --up` — include upstream producers */
64
+ up?: boolean;
65
+ /** `chant graph --lens blast:<node> --down` — include downstream dependents */
66
+ down?: boolean;
61
67
  /** `chant lifecycle affected --base <ref>` — base git ref to diff against */
62
68
  base?: string;
63
69
  /** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/cli/registry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/cli/registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import type { GraphIR } from "./graph-ir.js";
2
+ /**
3
+ * Render the graph IR as Graphviz DOT. Pure text — no `dot` needed to produce
4
+ * it. Two consumers: render directly with `dot -Tsvg` (mingrammer-style), or
5
+ * feed it to a layout engine for node positions that a custom painter draws
6
+ * (the rackattack pattern; see {@link toLayout}). See issue #497 / epic #492.
7
+ *
8
+ * Consumes whatever IR it is given, so `--detail` (and future `--lens`) flow
9
+ * through for free.
10
+ */
11
+ export declare function toDot(ir: GraphIR): string;
12
+ //# sourceMappingURL=graph-dot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-dot.d.ts","sourceRoot":"","sources":["../src/graph-dot.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAkB,MAAM,YAAY,CAAC;AAE1D;;;;;;;;GAQG;AACH,wBAAgB,KAAK,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CA2BzC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Layout-position export — node coordinates a custom painter consumes (the
3
+ * rackattack pattern: Graphviz lays out, the painter draws). Graphviz is used
4
+ * for LAYOUT ONLY; its rendering is discarded. See issue #497 / epic #492.
5
+ *
6
+ * The {@link LayoutEngine} interface exists so a pure-JS engine (elkjs/dagre)
7
+ * can drop in later for a zero-native-dependency path — closing the install gap
8
+ * so a painter can run without `dot`.
9
+ */
10
+ /** A laid-out position in the engine's coordinate space. */
11
+ export interface Point {
12
+ x: number;
13
+ y: number;
14
+ }
15
+ /** Node positions plus the overall canvas size a painter needs. */
16
+ export interface Layout {
17
+ /** Canvas width in the engine's coordinate space. */
18
+ width: number;
19
+ /** Canvas height in the engine's coordinate space. */
20
+ height: number;
21
+ /** Each node's centre position, ordered by id for deterministic output. */
22
+ nodes: Array<{
23
+ id: string;
24
+ } & Point>;
25
+ }
26
+ /** Turns DOT into node positions. The painter consumes the result; it never
27
+ * asks the engine to paint. */
28
+ export interface LayoutEngine {
29
+ readonly name: string;
30
+ layout(dot: string): Promise<Layout>;
31
+ }
32
+ /** Layout via `dot -Tjson`. Requires Graphviz (`brew install graphviz`). */
33
+ export declare class GraphvizLayout implements LayoutEngine {
34
+ readonly name = "graphviz";
35
+ layout(dot: string): Promise<Layout>;
36
+ }
37
+ /** Parse `dot -Tjson` output into a {@link Layout}. Pure; exported for testing. */
38
+ export declare function parseDotJson(json: string): Layout;
39
+ //# sourceMappingURL=graph-layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-layout.d.ts","sourceRoot":"","sources":["../src/graph-layout.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,mEAAmE;AACnE,MAAM,WAAW,MAAM;IACrB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAC,CAAC;CACtC;AAED;+BAC+B;AAC/B,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACtC;AAED,4EAA4E;AAC5E,qBAAa,cAAe,YAAW,YAAY;IACjD,QAAQ,CAAC,IAAI,cAAc;IAErB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAG3C;AAsCD,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAiBjD"}
@@ -0,0 +1,30 @@
1
+ import type { GraphIR } from "./graph-ir.js";
2
+ /**
3
+ * Lenses — focus the graph IR on a slice without touching the source. Pure
4
+ * IR → IR filters, composable with detail tiers (apply a lens, then view the
5
+ * result at any `--detail`). See issue #495 / epic #492.
6
+ *
7
+ * - `lexicon:<name>` — only nodes in a lexicon
8
+ * - `stack:<name>` — only one stack's nodes (stacks map to lexicon partitions today)
9
+ * - `blast:<nodeId>` — the transitive neighbourhood of a node: what it depends
10
+ * on (`--up`), what depends on it (`--down`), or both (default)
11
+ *
12
+ * Every lens drops edges that would dangle and rebuilds group metadata from the
13
+ * surviving nodes, so the result is always a self-consistent graph.
14
+ */
15
+ export interface LensSpec {
16
+ kind: "lexicon" | "stack" | "blast";
17
+ target: string;
18
+ /** blast: include upstream producers (what the node depends on). */
19
+ up: boolean;
20
+ /** blast: include downstream dependents (what depends on the node). */
21
+ down: boolean;
22
+ }
23
+ /** Parse a `--lens` spec. Throws a descriptive Error on a malformed spec. */
24
+ export declare function parseLens(spec: string, opts?: {
25
+ up?: boolean;
26
+ down?: boolean;
27
+ }): LensSpec;
28
+ /** Apply a lens to the IR. Throws a descriptive Error when the target matches nothing. */
29
+ export declare function applyLens(ir: GraphIR, lens: LensSpec): GraphIR;
30
+ //# sourceMappingURL=graph-lens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-lens.d.ts","sourceRoot":"","sources":["../src/graph-lens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAkB,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,EAAE,EAAE,OAAO,CAAC;IACZ,uEAAuE;IACvE,IAAI,EAAE,OAAO,CAAC;CACf;AAID,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE;IAAE,EAAE,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,QAAQ,CAkB7F;AAED,0FAA0F;AAC1F,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAI9D"}
@@ -0,0 +1,17 @@
1
+ import type { GraphIR } from "./graph-ir.js";
2
+ /**
3
+ * Render the graph IR as a Mermaid `flowchart`. Mermaid is the zero-install
4
+ * default — it renders in GitHub, docs, and browsers with no native dependency,
5
+ * so `chant graph --format mermaid` gives a diagram out of the box without the
6
+ * standalone painter. Lower fidelity than a custom painter, but portable.
7
+ *
8
+ * Consumes whatever IR it is given, so it honours `--detail` and `--lens` for
9
+ * free (those are IR → IR transforms). See issue #496 / epic #492.
10
+ *
11
+ * Known limits: Mermaid owns layout, so there is little control over node
12
+ * placement, and very large graphs get hard to read — that is the trade-off for
13
+ * zero-install portability. Reach for the graphviz/custom-painter path (#497,
14
+ * pinhole) when fidelity matters.
15
+ */
16
+ export declare function toMermaid(ir: GraphIR): string;
17
+ //# sourceMappingURL=graph-mermaid.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph-mermaid.d.ts","sourceRoot":"","sources":["../src/graph-mermaid.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAkB,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAgC7C"}
package/dist/index.d.ts CHANGED
@@ -26,6 +26,10 @@ export * from "./discovery/cache.js";
26
26
  export * from "./build.js";
27
27
  export * from "./graph-ir.js";
28
28
  export * from "./graph-detail.js";
29
+ export * from "./graph-mermaid.js";
30
+ export * from "./graph-dot.js";
31
+ export * from "./graph-layout.js";
32
+ export * from "./graph-lens.js";
29
33
  export * from "./detectLexicon.js";
30
34
  export * from "./lint/parser.js";
31
35
  export * from "./lint/rule.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,QAAQ,CAAC;AACvB,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC;AACxC,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAElC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAClE,cAAc,EAAE,iBAAiB,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC5F,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC;AACxB,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,QAAQ,CAAC;AACvB,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC;AACxC,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAElC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAClE,cAAc,EAAE,iBAAiB,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC5F,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -1,11 +1,33 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
2
  import type { ParsedArgs } from "../registry";
3
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
+ import { AttrRef } from "../../attrref";
3
5
 
4
6
  const discoverOpsMock = vi.fn();
5
7
  vi.mock("../../op/discover", () => ({
6
8
  discoverOps: () => discoverOpsMock(),
7
9
  }));
8
10
 
11
+ const discoverMock = vi.fn();
12
+ vi.mock("../../discovery/index", () => ({
13
+ discover: () => discoverMock(),
14
+ }));
15
+
16
+ const lintMock = vi.fn();
17
+ vi.mock("../commands/lint", () => ({
18
+ lintCommand: () => lintMock(),
19
+ }));
20
+
21
+ // Avoid shelling out to graphviz in tests; the format dispatch is what matters.
22
+ const layoutMock = vi.fn();
23
+ vi.mock("../../graph-layout", () => ({
24
+ GraphvizLayout: class {
25
+ layout() {
26
+ return layoutMock();
27
+ }
28
+ },
29
+ }));
30
+
9
31
  const { runGraph } = await import("./graph");
10
32
 
11
33
  function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
@@ -20,6 +42,18 @@ function makeOp(name: string, depends: string[] = []): [string, { config: { name
20
42
  return [name, { config: { name, depends } }];
21
43
  }
22
44
 
45
+ function decl<T extends object>(base: T): Declarable & T {
46
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
47
+ }
48
+
49
+ /** A small two-lexicon graph: vpc <- subnet (gcp), subnet <- pod (k8s). */
50
+ function sampleEntities(): Map<string, Declarable> {
51
+ const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
52
+ const subnet = decl({ lexicon: "gcp", entityType: "Subnet", props: { network: new AttrRef(vpc, "id") } });
53
+ const pod = decl({ lexicon: "k8s", entityType: "Pod", props: { net: new AttrRef(subnet, "id") } });
54
+ return new Map<string, Declarable>([["vpc", vpc], ["subnet", subnet], ["pod", pod]]);
55
+ }
56
+
23
57
  describe("runGraph", () => {
24
58
  let stdoutBuf: string[];
25
59
  let stderrBuf: string[];
@@ -30,62 +64,146 @@ describe("runGraph", () => {
30
64
  vi.spyOn(console, "log").mockImplementation((s: string) => { stdoutBuf.push(s); });
31
65
  vi.spyOn(console, "error").mockImplementation((s: string) => { stderrBuf.push(s); });
32
66
  discoverOpsMock.mockReset();
67
+ discoverMock.mockReset();
68
+ lintMock.mockReset();
69
+ layoutMock.mockReset();
33
70
  });
34
71
 
35
- test("prints 'No Ops found' when discovery is empty", async () => {
36
- discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
37
- const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
38
- expect(exit).toBe(0);
39
- expect(stdoutBuf.join("\n")).toContain("No Ops found");
40
- });
72
+ describe("Op graph (default)", () => {
73
+ test("prints 'No Ops found' when discovery is empty", async () => {
74
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
75
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
76
+ expect(exit).toBe(0);
77
+ expect(stdoutBuf.join("\n")).toContain("No Ops found");
78
+ });
41
79
 
42
- test("prints 'No Op dependencies' when ops have no depends", async () => {
43
- discoverOpsMock.mockResolvedValue({
44
- ops: new Map([makeOp("solo")]),
45
- errors: [],
80
+ test("prints 'No Op dependencies' when ops have no depends", async () => {
81
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("solo")]), errors: [] });
82
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
83
+ expect(exit).toBe(0);
84
+ expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
46
85
  });
47
- const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
48
- expect(exit).toBe(0);
49
- expect(stdoutBuf.join("\n")).toContain("No Op dependencies");
50
- });
51
86
 
52
- test("prints `dep -> name` edge per dependency", async () => {
53
- discoverOpsMock.mockResolvedValue({
54
- ops: new Map([
55
- makeOp("infra"),
56
- makeOp("app", ["infra"]),
57
- ]),
58
- errors: [],
59
- });
60
- const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
61
- expect(exit).toBe(0);
62
- const out = stdoutBuf.join("\n");
63
- expect(out).toContain("infra → app");
64
- });
87
+ test("prints `dep -> name` edge per dependency", async () => {
88
+ discoverOpsMock.mockResolvedValue({
89
+ ops: new Map([makeOp("infra"), makeOp("app", ["infra"])]),
90
+ errors: [],
91
+ });
92
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
93
+ expect(exit).toBe(0);
94
+ expect(stdoutBuf.join("\n")).toContain("infra → app");
95
+ });
65
96
 
66
- test("handles multi-edge graphs", async () => {
67
- discoverOpsMock.mockResolvedValue({
68
- ops: new Map([
69
- makeOp("a"),
70
- makeOp("b", ["a"]),
71
- makeOp("c", ["a", "b"]),
72
- ]),
73
- errors: [],
74
- });
75
- await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
76
- const out = stdoutBuf.join("\n");
77
- expect(out).toContain("a → b");
78
- expect(out).toContain("a c");
79
- expect(out).toContain("b c");
97
+ test("handles multi-edge graphs", async () => {
98
+ discoverOpsMock.mockResolvedValue({
99
+ ops: new Map([makeOp("a"), makeOp("b", ["a"]), makeOp("c", ["a", "b"])]),
100
+ errors: [],
101
+ });
102
+ await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
103
+ const out = stdoutBuf.join("\n");
104
+ expect(out).toContain("a → b");
105
+ expect(out).toContain("a → c");
106
+ expect(out).toContain("b c");
107
+ });
108
+
109
+ test("forwards discovery errors to stderr", async () => {
110
+ discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: ["failed to parse ops/bad.op.ts"] });
111
+ const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
112
+ expect(exit).toBe(0);
113
+ expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
114
+ });
80
115
  });
81
116
 
82
- test("forwards discovery errors to stderr", async () => {
83
- discoverOpsMock.mockResolvedValue({
84
- ops: new Map(),
85
- errors: ["failed to parse ops/bad.op.ts"],
117
+ describe("graph IR views (--format ir|mermaid|dot|layout)", () => {
118
+ const lintClean = (): void => { lintMock.mockResolvedValue({ success: true }); };
119
+ const discovered = (): void => {
120
+ discoverMock.mockResolvedValue({ entities: sampleEntities(), errors: [], dependencies: new Map(), sourceFiles: [] });
121
+ };
122
+
123
+ test("--format ir emits the graph IR as JSON", async () => {
124
+ lintClean(); discovered();
125
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
126
+ expect(exit).toBe(0);
127
+ const ir = JSON.parse(stdoutBuf.join("\n"));
128
+ expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["pod", "subnet", "vpc"]);
129
+ expect(ir.edges).toContainEqual({ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" });
130
+ });
131
+
132
+ test("lint gate: refuses to emit when source has lint errors", async () => {
133
+ lintMock.mockResolvedValue({ success: false });
134
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
135
+ expect(exit).toBe(1);
136
+ expect(stdoutBuf.join("\n")).toBe("");
137
+ expect(stderrBuf.join("\n")).toMatch(/lint errors/i);
138
+ expect(discoverMock).not.toHaveBeenCalled();
139
+ });
140
+
141
+ test("--format mermaid emits a flowchart", async () => {
142
+ lintClean(); discovered();
143
+ const exit = await runGraph({ args: makeArgs({ format: "mermaid" }), plugins: [], serializers: [] });
144
+ expect(exit).toBe(0);
145
+ expect(stdoutBuf.join("\n")).toContain("flowchart TD");
146
+ });
147
+
148
+ test("--format dot emits a digraph", async () => {
149
+ lintClean(); discovered();
150
+ const exit = await runGraph({ args: makeArgs({ format: "dot" }), plugins: [], serializers: [] });
151
+ expect(exit).toBe(0);
152
+ expect(stdoutBuf.join("\n")).toContain("digraph chant {");
153
+ });
154
+
155
+ test("--format layout emits positions from the layout engine", async () => {
156
+ lintClean(); discovered();
157
+ layoutMock.mockResolvedValue({ width: 100, height: 50, nodes: [{ id: "vpc", x: 1, y: 2 }] });
158
+ const exit = await runGraph({ args: makeArgs({ format: "layout" }), plugins: [], serializers: [] });
159
+ expect(exit).toBe(0);
160
+ expect(JSON.parse(stdoutBuf.join("\n"))).toMatchObject({ width: 100, nodes: [{ id: "vpc", x: 1, y: 2 }] });
161
+ });
162
+
163
+ test("--format layout reports a clear error when the engine fails (e.g. dot missing)", async () => {
164
+ lintClean(); discovered();
165
+ layoutMock.mockRejectedValue(new Error("could not run 'dot'"));
166
+ const exit = await runGraph({ args: makeArgs({ format: "layout" }), plugins: [], serializers: [] });
167
+ expect(exit).toBe(1);
168
+ expect(stderrBuf.join("\n")).toContain("could not run 'dot'");
169
+ });
170
+
171
+ test("--detail 0 collapses to one node per lexicon", async () => {
172
+ lintClean(); discovered();
173
+ const exit = await runGraph({ args: makeArgs({ format: "ir", detail: 0 }), plugins: [], serializers: [] });
174
+ expect(exit).toBe(0);
175
+ const ir = JSON.parse(stdoutBuf.join("\n"));
176
+ expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["gcp", "k8s"]);
177
+ });
178
+
179
+ test("rejects an out-of-range --detail", async () => {
180
+ const exit = await runGraph({ args: makeArgs({ format: "ir", detail: 9 }), plugins: [], serializers: [] });
181
+ expect(exit).toBe(1);
182
+ expect(stderrBuf.join("\n")).toMatch(/detail/i);
183
+ expect(lintMock).not.toHaveBeenCalled();
184
+ });
185
+
186
+ test("--lens lexicon:gcp filters to that lexicon", async () => {
187
+ lintClean(); discovered();
188
+ const exit = await runGraph({ args: makeArgs({ format: "ir", lens: "lexicon:gcp" }), plugins: [], serializers: [] });
189
+ expect(exit).toBe(0);
190
+ const ir = JSON.parse(stdoutBuf.join("\n"));
191
+ expect(ir.nodes.map((n: { id: string }) => n.id).sort()).toEqual(["subnet", "vpc"]);
192
+ });
193
+
194
+ test("--lens with a bad spec errors out", async () => {
195
+ lintClean(); discovered();
196
+ const exit = await runGraph({ args: makeArgs({ format: "ir", lens: "nope" }), plugins: [], serializers: [] });
197
+ expect(exit).toBe(1);
198
+ expect(stderrBuf.join("\n")).toMatch(/lens/i);
199
+ });
200
+
201
+ test("--format ir forwards discovery errors and exits non-zero", async () => {
202
+ lintClean();
203
+ discoverMock.mockResolvedValue({ entities: new Map(), errors: [{ message: "boom" }], dependencies: new Map(), sourceFiles: [] });
204
+ const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
205
+ expect(exit).toBe(1);
206
+ expect(stderrBuf.join("\n")).toContain("boom");
86
207
  });
87
- const exit = await runGraph({ args: makeArgs(), plugins: [], serializers: [] });
88
- expect(exit).toBe(0);
89
- expect(stderrBuf.join("\n")).toContain("failed to parse ops/bad.op.ts");
90
208
  });
91
209
  });
@@ -2,8 +2,12 @@ import { resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
4
  import { partitionByLexicon, computeStackGraph } from "../../build";
5
- import { buildGraphIr } from "../../graph-ir";
5
+ import { buildGraphIr, type GraphIR } from "../../graph-ir";
6
6
  import { applyDetail, type DetailLevel } from "../../graph-detail";
7
+ import { applyLens, parseLens } from "../../graph-lens";
8
+ import { toMermaid } from "../../graph-mermaid";
9
+ import { toDot } from "../../graph-dot";
10
+ import { GraphvizLayout } from "../../graph-layout";
7
11
  import { lintCommand } from "../commands/lint";
8
12
  import { formatError, formatWarning, formatBold } from "../format";
9
13
  import type { CommandContext } from "../registry";
@@ -11,21 +15,29 @@ import type { CommandContext } from "../registry";
11
15
  /**
12
16
  * `chant graph` — the Op dependency graph by default; `--stacks` renders the
13
17
  * cross-stack apply-ordering graph (edges, order, waves) chant computes from
14
- * cross-lexicon references; `--format ir` emits the full entity-graph IR
15
- * (lint-gated) for diagram painters and the agentic diagrammer (#493).
18
+ * cross-lexicon references; `--format ir|mermaid` emits the lint-gated
19
+ * entity-graph IR (or a Mermaid flowchart of it) for diagrams (#493/#496).
16
20
  */
17
21
  export async function runGraph(ctx: CommandContext): Promise<number> {
18
- if (ctx.args.format === "ir") return runGraphIr(ctx);
22
+ const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
23
+ if ((viewFormats as readonly string[]).includes(ctx.args.format)) {
24
+ return runGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
25
+ }
19
26
  if (ctx.args.stacks) return runStackGraph(ctx);
20
27
  return runOpGraph();
21
28
  }
22
29
 
23
30
  /**
24
- * `chant graph --format ir` — emit the graph IR as JSON. Lint-gated: the IR is a
25
- * representation of valid infra, so we refuse to emit it for source that does
26
- * not pass lint (EVL + lexicon rules). Non-zero exit on discovery errors.
31
+ * `chant graph --format ir|mermaid|dot|layout` — build the graph IR (honouring
32
+ * `--detail`) and emit it as JSON, a Mermaid flowchart, Graphviz DOT, or node
33
+ * positions from a layout engine. Lint-gated: the IR represents valid infra, so
34
+ * we refuse to emit for source that does not pass lint. Non-zero on discovery
35
+ * errors, or on a missing `dot` for `--format layout`.
27
36
  */
28
- async function runGraphIr(ctx: CommandContext): Promise<number> {
37
+ async function runGraphView(
38
+ ctx: CommandContext,
39
+ format: "ir" | "mermaid" | "dot" | "layout",
40
+ ): Promise<number> {
29
41
  const projectPath = resolve(ctx.args.path === "." ? "." : ctx.args.path);
30
42
 
31
43
  const level = ctx.args.detail ?? 2;
@@ -34,13 +46,13 @@ async function runGraphIr(ctx: CommandContext): Promise<number> {
34
46
  return 1;
35
47
  }
36
48
 
37
- // Gate: only emit an IR for lint-clean source.
49
+ // Gate: only emit for lint-clean source.
38
50
  const lint = await lintCommand({ path: ctx.args.path, format: "stylish" });
39
51
  if (!lint.success) {
40
52
  console.error(
41
53
  formatError({
42
54
  message:
43
- "Refusing to emit graph IR: source has lint errors. Run `chant lint` and fix them first.",
55
+ "Refusing to emit graph: source has lint errors. Run `chant lint` and fix them first.",
44
56
  }),
45
57
  );
46
58
  return 1;
@@ -52,9 +64,40 @@ async function runGraphIr(ctx: CommandContext): Promise<number> {
52
64
  return 1;
53
65
  }
54
66
 
55
- const ir = applyDetail(buildGraphIr(result.entities, projectPath), level as DetailLevel);
56
- console.log(JSON.stringify(ir, null, 2));
57
- return 0;
67
+ // Build the base IR, focus with a lens (declarable-level, most precise), then
68
+ // apply the detail tier — so e.g. blast:<resource> works before any collapse.
69
+ let ir: GraphIR = buildGraphIr(result.entities, projectPath);
70
+ if (ctx.args.lens) {
71
+ try {
72
+ ir = applyLens(ir, parseLens(ctx.args.lens, { up: ctx.args.up, down: ctx.args.down }));
73
+ } catch (err) {
74
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
75
+ return 1;
76
+ }
77
+ }
78
+ ir = applyDetail(ir, level as DetailLevel);
79
+
80
+ switch (format) {
81
+ case "mermaid":
82
+ console.log(toMermaid(ir));
83
+ return 0;
84
+ case "dot":
85
+ console.log(toDot(ir));
86
+ return 0;
87
+ case "layout":
88
+ try {
89
+ const layout = await new GraphvizLayout().layout(toDot(ir));
90
+ console.log(JSON.stringify(layout, null, 2));
91
+ return 0;
92
+ } catch (err) {
93
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
94
+ return 1;
95
+ }
96
+ case "ir":
97
+ default:
98
+ console.log(JSON.stringify(ir, null, 2));
99
+ return 0;
100
+ }
58
101
  }
59
102
 
60
103
  async function runOpGraph(): Promise<number> {
@@ -72,6 +72,22 @@ describe("parseArgs", () => {
72
72
  expect(result.format).toBe("invalid"); // format is passed as-is to main
73
73
  });
74
74
 
75
+ test("parses graph --detail as a number", () => {
76
+ const result = parseArgs(["graph", "--format", "ir", "--detail", "1"]);
77
+ expect(result.detail).toBe(1);
78
+ });
79
+
80
+ test("parses graph --lens with a kind:target value", () => {
81
+ const result = parseArgs(["graph", "--format", "ir", "--lens", "blast:vpc"]);
82
+ expect(result.lens).toBe("blast:vpc");
83
+ });
84
+
85
+ test("parses graph --up and --down flags", () => {
86
+ const result = parseArgs(["graph", "--lens", "blast:vpc", "--up", "--down"]);
87
+ expect(result.up).toBe(true);
88
+ expect(result.down).toBe(true);
89
+ });
90
+
75
91
  test("combines multiple options", () => {
76
92
  const result = parseArgs([
77
93
  "build",
package/src/cli/main.ts CHANGED
@@ -128,6 +128,12 @@ export function parseArgs(args: string[]): ParsedArgs {
128
128
  result.stacks = true;
129
129
  } else if (arg === "--detail") {
130
130
  result.detail = Number(args[++i]);
131
+ } else if (arg === "--lens") {
132
+ result.lens = args[++i];
133
+ } else if (arg === "--up") {
134
+ result.up = true;
135
+ } else if (arg === "--down") {
136
+ result.down = true;
131
137
  } else if (arg === "--base") {
132
138
  result.base = args[++i];
133
139
  } else if (arg === "--head") {
@@ -193,8 +199,11 @@ Ops:
193
199
  run log <name> Show run history for an Op
194
200
 
195
201
  graph Show Op dependency graph (--stacks for cross-stack order,
196
- --format ir for the lint-gated entity-graph IR;
197
- --detail 0..3: stacks|composites|declarables|attributes)
202
+ --format ir|mermaid|dot|layout for the lint-gated graph IR,
203
+ a Mermaid flowchart, Graphviz DOT, or node positions
204
+ (layout needs graphviz);
205
+ --detail 0..3: stacks|composites|declarables|attributes;
206
+ --lens lexicon:<n>|stack:<n>|blast:<node> (--up/--down))
198
207
 
199
208
  Lifecycle (alias: lc):
200
209
  lifecycle snapshot <env> Query API, save metadata to orphan branch
@@ -59,6 +59,12 @@ export interface ParsedArgs {
59
59
  stacks?: boolean;
60
60
  /** `chant graph --format ir --detail <0..3>` — graph IR detail tier */
61
61
  detail?: number;
62
+ /** `chant graph --lens <kind>:<target>` — focus the graph IR on a slice */
63
+ lens?: string;
64
+ /** `chant graph --lens blast:<node> --up` — include upstream producers */
65
+ up?: boolean;
66
+ /** `chant graph --lens blast:<node> --down` — include downstream dependents */
67
+ down?: boolean;
62
68
  /** `chant lifecycle affected --base <ref>` — base git ref to diff against */
63
69
  base?: string;
64
70
  /** `chant lifecycle affected --head <ref>` — head git ref (default: working tree) */
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { toDot } from "./graph-dot";
3
+ import { parseDotJson } from "./graph-layout";
4
+ import type { GraphIR } from "./graph-ir";
5
+
6
+ const ir: GraphIR = {
7
+ nodes: [
8
+ { id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
9
+ { id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: {} },
10
+ { id: "ns", kind: "Namespace", lexicon: "k8s", attrs: {} },
11
+ ],
12
+ edges: [
13
+ { from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
14
+ { from: "ns", to: "subnet", kind: "ref", viaAttr: "subnet", toAttr: "selfLink" },
15
+ ],
16
+ groups: { byLexicon: { gcp: ["subnet", "vpc"], k8s: ["ns"] } },
17
+ };
18
+
19
+ describe("toDot", () => {
20
+ test("emits a digraph with lexicon clusters, nodes, and labelled edges", () => {
21
+ const dot = toDot(ir);
22
+ expect(dot).toContain("digraph chant {");
23
+ expect(dot).toContain('subgraph "cluster_gcp" {');
24
+ expect(dot).toContain('label="gcp";');
25
+ expect(dot).toContain('"vpc" [label="vpc\\nVpc"];');
26
+ expect(dot).toContain('"subnet" -> "vpc" [label="network"];');
27
+ expect(dot).toContain('"ns" -> "subnet" [label="subnet → selfLink"];');
28
+ });
29
+
30
+ test("is deterministic", () => {
31
+ expect(toDot(ir)).toEqual(toDot(ir));
32
+ });
33
+
34
+ test("places ungrouped nodes at the top level", () => {
35
+ const dot = toDot({
36
+ nodes: [{ id: "loner", kind: "Thing", lexicon: "x", attrs: {} }],
37
+ edges: [],
38
+ groups: {},
39
+ });
40
+ expect(dot).toContain('"loner" [label="loner\\nThing"];');
41
+ expect(dot).not.toContain("subgraph");
42
+ });
43
+ });
44
+
45
+ describe("parseDotJson", () => {
46
+ test("parses bounding box and node positions, sorted by id", () => {
47
+ const json = JSON.stringify({
48
+ bb: "0,0,200,300",
49
+ objects: [
50
+ { name: "vpc", pos: "100,280" },
51
+ { name: "subnet", pos: "100,20" },
52
+ ],
53
+ });
54
+ const layout = parseDotJson(json);
55
+ expect(layout).toEqual({
56
+ width: 200,
57
+ height: 300,
58
+ nodes: [
59
+ { id: "subnet", x: 100, y: 20 },
60
+ { id: "vpc", x: 100, y: 280 },
61
+ ],
62
+ });
63
+ });
64
+
65
+ test("throws on a malformed bounding box", () => {
66
+ expect(() => parseDotJson(JSON.stringify({ bb: "0,0", objects: [] }))).toThrow();
67
+ });
68
+ });
@@ -0,0 +1,58 @@
1
+ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
2
+
3
+ /**
4
+ * Render the graph IR as Graphviz DOT. Pure text — no `dot` needed to produce
5
+ * it. Two consumers: render directly with `dot -Tsvg` (mingrammer-style), or
6
+ * feed it to a layout engine for node positions that a custom painter draws
7
+ * (the rackattack pattern; see {@link toLayout}). See issue #497 / epic #492.
8
+ *
9
+ * Consumes whatever IR it is given, so `--detail` (and future `--lens`) flow
10
+ * through for free.
11
+ */
12
+ export function toDot(ir: GraphIR): string {
13
+ const lines: string[] = ["digraph chant {", " rankdir=TB;", ' node [shape=box];'];
14
+
15
+ const byLexicon = ir.groups.byLexicon;
16
+ const grouped = new Set<string>();
17
+ if (byLexicon) {
18
+ for (const [lexicon, members] of Object.entries(byLexicon)) {
19
+ lines.push(` subgraph ${q(`cluster_${lexicon}`)} {`);
20
+ lines.push(` label=${q(lexicon)};`);
21
+ for (const id of members) {
22
+ const node = ir.nodes.find((n) => n.id === id);
23
+ if (!node) continue;
24
+ grouped.add(id);
25
+ lines.push(` ${nodeLine(node)}`);
26
+ }
27
+ lines.push(" }");
28
+ }
29
+ }
30
+ for (const node of ir.nodes) {
31
+ if (grouped.has(node.id)) continue;
32
+ lines.push(` ${nodeLine(node)}`);
33
+ }
34
+
35
+ for (const e of ir.edges) lines.push(` ${edgeLine(e)}`);
36
+
37
+ lines.push("}");
38
+ return lines.join("\n") + "\n";
39
+ }
40
+
41
+ function nodeLine(node: IRNode): string {
42
+ const label = node.kind && node.kind !== node.id ? `${node.id}\n${node.kind}` : node.id;
43
+ return `${q(node.id)} [label=${q(label)}];`;
44
+ }
45
+
46
+ function edgeLine(e: IREdge): string {
47
+ const from = q(e.from);
48
+ const to = q(e.to);
49
+ const label = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
50
+ return label ? `${from} -> ${to} [label=${q(label)}];` : `${from} -> ${to};`;
51
+ }
52
+
53
+ /** Quote a DOT identifier/label. Graphviz accepts any string in double quotes;
54
+ * a real newline becomes the DOT line-break escape. */
55
+ function q(s: string): string {
56
+ const esc = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
57
+ return `"${esc}"`;
58
+ }
@@ -0,0 +1,104 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /**
4
+ * Layout-position export — node coordinates a custom painter consumes (the
5
+ * rackattack pattern: Graphviz lays out, the painter draws). Graphviz is used
6
+ * for LAYOUT ONLY; its rendering is discarded. See issue #497 / epic #492.
7
+ *
8
+ * The {@link LayoutEngine} interface exists so a pure-JS engine (elkjs/dagre)
9
+ * can drop in later for a zero-native-dependency path — closing the install gap
10
+ * so a painter can run without `dot`.
11
+ */
12
+
13
+ /** A laid-out position in the engine's coordinate space. */
14
+ export interface Point {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ /** Node positions plus the overall canvas size a painter needs. */
20
+ export interface Layout {
21
+ /** Canvas width in the engine's coordinate space. */
22
+ width: number;
23
+ /** Canvas height in the engine's coordinate space. */
24
+ height: number;
25
+ /** Each node's centre position, ordered by id for deterministic output. */
26
+ nodes: Array<{ id: string } & Point>;
27
+ }
28
+
29
+ /** Turns DOT into node positions. The painter consumes the result; it never
30
+ * asks the engine to paint. */
31
+ export interface LayoutEngine {
32
+ readonly name: string;
33
+ layout(dot: string): Promise<Layout>;
34
+ }
35
+
36
+ /** Layout via `dot -Tjson`. Requires Graphviz (`brew install graphviz`). */
37
+ export class GraphvizLayout implements LayoutEngine {
38
+ readonly name = "graphviz";
39
+
40
+ async layout(dot: string): Promise<Layout> {
41
+ return parseDotJson(await runDot(dot));
42
+ }
43
+ }
44
+
45
+ function runDot(dot: string): Promise<string> {
46
+ return new Promise((resolve, reject) => {
47
+ let proc;
48
+ try {
49
+ proc = spawn("dot", ["-Tjson"], { stdio: ["pipe", "pipe", "pipe"] });
50
+ } catch (err) {
51
+ reject(installHint(err));
52
+ return;
53
+ }
54
+ let out = "";
55
+ let errOut = "";
56
+ proc.stdout.on("data", (d) => (out += d));
57
+ proc.stderr.on("data", (d) => (errOut += d));
58
+ proc.on("error", (err) => reject(installHint(err)));
59
+ proc.on("close", (code) => {
60
+ if (code !== 0) reject(new Error(`dot exited ${code}: ${errOut.trim()}`));
61
+ else resolve(out);
62
+ });
63
+ proc.stdin.write(dot);
64
+ proc.stdin.end();
65
+ });
66
+ }
67
+
68
+ function installHint(err: unknown): Error {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ return new Error(
71
+ `could not run 'dot' (${msg}). Graphviz is required for --format layout — ` +
72
+ `install it with 'brew install graphviz', or use --format mermaid, which needs no native dependency.`,
73
+ );
74
+ }
75
+
76
+ interface DotJson {
77
+ bb?: string;
78
+ objects?: Array<{ name?: string; pos?: string }>;
79
+ }
80
+
81
+ /** Parse `dot -Tjson` output into a {@link Layout}. Pure; exported for testing. */
82
+ export function parseDotJson(json: string): Layout {
83
+ const parsed = JSON.parse(json) as DotJson;
84
+ const bb = (parsed.bb ?? "").split(",");
85
+ if (bb.length !== 4) throw new Error(`bad bounding box ${parsed.bb}`);
86
+ const width = num(bb[2]);
87
+ const height = num(bb[3]);
88
+ if (width === 0 || height === 0) throw new Error("zero graph bounds");
89
+
90
+ const nodes: Array<{ id: string } & Point> = [];
91
+ for (const o of parsed.objects ?? []) {
92
+ if (!o.name || !o.pos) continue;
93
+ const p = o.pos.split(",");
94
+ if (p.length !== 2) continue;
95
+ nodes.push({ id: o.name, x: num(p[0]), y: num(p[1]) });
96
+ }
97
+ nodes.sort((a, b) => a.id.localeCompare(b.id));
98
+ return { width, height, nodes };
99
+ }
100
+
101
+ function num(s: string): number {
102
+ const f = Number.parseFloat(s.trim());
103
+ return Number.isFinite(f) ? f : 0;
104
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { parseLens, applyLens } from "./graph-lens";
3
+ import type { GraphIR } from "./graph-ir";
4
+
5
+ // vpc <- subnet <- cluster (gcp), cluster <- pod (k8s). A linear dependency
6
+ // chain crossing two lexicons.
7
+ const ir: GraphIR = {
8
+ nodes: [
9
+ { id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
10
+ { id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: {} },
11
+ { id: "cluster", kind: "GkeCluster", lexicon: "gcp", attrs: {} },
12
+ { id: "pod", kind: "Pod", lexicon: "k8s", attrs: {} },
13
+ ],
14
+ edges: [
15
+ { from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
16
+ { from: "cluster", to: "subnet", kind: "ref", viaAttr: "subnetwork" },
17
+ { from: "pod", to: "cluster", kind: "ref", viaAttr: "cluster" },
18
+ ],
19
+ groups: { byLexicon: { gcp: ["cluster", "subnet", "vpc"], k8s: ["pod"] } },
20
+ };
21
+
22
+ describe("parseLens", () => {
23
+ test("parses kind:target", () => {
24
+ expect(parseLens("lexicon:gcp")).toMatchObject({ kind: "lexicon", target: "gcp" });
25
+ });
26
+ test("blast defaults to both directions", () => {
27
+ expect(parseLens("blast:cluster")).toMatchObject({ up: true, down: true });
28
+ });
29
+ test("blast honours --up / --down", () => {
30
+ expect(parseLens("blast:cluster", { up: true })).toMatchObject({ up: true, down: false });
31
+ });
32
+ test("rejects malformed and unknown lenses", () => {
33
+ expect(() => parseLens("gcp")).toThrow();
34
+ expect(() => parseLens("bogus:x")).toThrow();
35
+ });
36
+ });
37
+
38
+ describe("applyLens", () => {
39
+ test("lexicon: keeps only that lexicon's nodes and internal edges", () => {
40
+ const out = applyLens(ir, parseLens("lexicon:gcp"));
41
+ expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "subnet", "vpc"]);
42
+ // cross-lexicon pod→cluster edge dropped; gcp-internal edges kept
43
+ expect(out.edges.map((e) => `${e.from}->${e.to}`).sort()).toEqual([
44
+ "cluster->subnet",
45
+ "subnet->vpc",
46
+ ]);
47
+ expect(out.groups.byLexicon).toEqual({ gcp: ["cluster", "subnet", "vpc"] });
48
+ });
49
+
50
+ test("lexicon: count matches the byLexicon partition", () => {
51
+ const out = applyLens(ir, parseLens("lexicon:gcp"));
52
+ expect(out.nodes.length).toBe(ir.groups.byLexicon!.gcp.length);
53
+ });
54
+
55
+ test("blast --up returns the producer chain above a node", () => {
56
+ const out = applyLens(ir, parseLens("blast:cluster", { up: true }));
57
+ expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "subnet", "vpc"]);
58
+ });
59
+
60
+ test("blast --down returns the dependents below a node", () => {
61
+ const out = applyLens(ir, parseLens("blast:cluster", { down: true }));
62
+ expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "pod"]);
63
+ });
64
+
65
+ test("blast both returns the whole connected chain", () => {
66
+ const out = applyLens(ir, parseLens("blast:cluster"));
67
+ expect(out.nodes.map((n) => n.id).sort()).toEqual(["cluster", "pod", "subnet", "vpc"]);
68
+ });
69
+
70
+ test("blast on a leaf returns just its chain", () => {
71
+ const out = applyLens(ir, parseLens("blast:vpc", { up: true }));
72
+ expect(out.nodes.map((n) => n.id)).toEqual(["vpc"]); // vpc depends on nothing
73
+ expect(out.edges).toEqual([]);
74
+ });
75
+
76
+ test("throws when the target matches nothing", () => {
77
+ expect(() => applyLens(ir, parseLens("lexicon:aws"))).toThrow();
78
+ expect(() => applyLens(ir, parseLens("blast:ghost"))).toThrow();
79
+ });
80
+ });
@@ -0,0 +1,127 @@
1
+ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
2
+
3
+ /**
4
+ * Lenses — focus the graph IR on a slice without touching the source. Pure
5
+ * IR → IR filters, composable with detail tiers (apply a lens, then view the
6
+ * result at any `--detail`). See issue #495 / epic #492.
7
+ *
8
+ * - `lexicon:<name>` — only nodes in a lexicon
9
+ * - `stack:<name>` — only one stack's nodes (stacks map to lexicon partitions today)
10
+ * - `blast:<nodeId>` — the transitive neighbourhood of a node: what it depends
11
+ * on (`--up`), what depends on it (`--down`), or both (default)
12
+ *
13
+ * Every lens drops edges that would dangle and rebuilds group metadata from the
14
+ * surviving nodes, so the result is always a self-consistent graph.
15
+ */
16
+ export interface LensSpec {
17
+ kind: "lexicon" | "stack" | "blast";
18
+ target: string;
19
+ /** blast: include upstream producers (what the node depends on). */
20
+ up: boolean;
21
+ /** blast: include downstream dependents (what depends on the node). */
22
+ down: boolean;
23
+ }
24
+
25
+ const KINDS = new Set(["lexicon", "stack", "blast"]);
26
+
27
+ /** Parse a `--lens` spec. Throws a descriptive Error on a malformed spec. */
28
+ export function parseLens(spec: string, opts: { up?: boolean; down?: boolean } = {}): LensSpec {
29
+ const colon = spec.indexOf(":");
30
+ if (colon <= 0 || colon === spec.length - 1) {
31
+ throw new Error(`Invalid --lens "${spec}". Expected <kind>:<target>, e.g. lexicon:gcp or blast:vpc.`);
32
+ }
33
+ const kind = spec.slice(0, colon);
34
+ const target = spec.slice(colon + 1);
35
+ if (!KINDS.has(kind)) {
36
+ throw new Error(`Unknown lens "${kind}". Expected one of: ${[...KINDS].join(", ")}.`);
37
+ }
38
+ // For blast, default to both directions when neither flag is given.
39
+ const both = !opts.up && !opts.down;
40
+ return {
41
+ kind: kind as LensSpec["kind"],
42
+ target,
43
+ up: opts.up ?? both,
44
+ down: opts.down ?? both,
45
+ };
46
+ }
47
+
48
+ /** Apply a lens to the IR. Throws a descriptive Error when the target matches nothing. */
49
+ export function applyLens(ir: GraphIR, lens: LensSpec): GraphIR {
50
+ const keep =
51
+ lens.kind === "blast" ? blastSet(ir, lens) : partitionSet(ir, lens);
52
+ return subgraph(ir, keep);
53
+ }
54
+
55
+ /** Node ids for a lexicon/stack lens. */
56
+ function partitionSet(ir: GraphIR, lens: LensSpec): Set<string> {
57
+ if (lens.kind === "stack") {
58
+ const members = ir.groups.byStack?.[lens.target] ?? ir.groups.byLexicon?.[lens.target];
59
+ if (members && members.length) return new Set(members);
60
+ // Fall through to a direct scan if groups are absent.
61
+ }
62
+ const keep = new Set<string>();
63
+ for (const n of ir.nodes) if (n.lexicon === lens.target) keep.add(n.id);
64
+ if (keep.size === 0) {
65
+ throw new Error(`No nodes match lens ${lens.kind}:${lens.target}.`);
66
+ }
67
+ return keep;
68
+ }
69
+
70
+ /** Node ids in the transitive neighbourhood of a node. */
71
+ function blastSet(ir: GraphIR, lens: LensSpec): Set<string> {
72
+ if (!ir.nodes.some((n) => n.id === lens.target)) {
73
+ throw new Error(`No node "${lens.target}" for lens blast:${lens.target}.`);
74
+ }
75
+ const out = new Map<string, string[]>(); // from -> [to] (depends-on)
76
+ const inc = new Map<string, string[]>(); // to -> [from] (depended-on-by)
77
+ const push = (m: Map<string, string[]>, k: string, v: string): void => {
78
+ const arr = m.get(k);
79
+ if (arr) arr.push(v);
80
+ else m.set(k, [v]);
81
+ };
82
+ for (const e of ir.edges) {
83
+ push(out, e.from, e.to);
84
+ push(inc, e.to, e.from);
85
+ }
86
+ const keep = new Set<string>([lens.target]);
87
+ if (lens.up) walk(lens.target, out, keep);
88
+ if (lens.down) walk(lens.target, inc, keep);
89
+ return keep;
90
+ }
91
+
92
+ function walk(start: string, adj: Map<string, string[]>, keep: Set<string>): void {
93
+ const stack = [start];
94
+ while (stack.length) {
95
+ const cur = stack.pop()!;
96
+ for (const next of adj.get(cur) ?? []) {
97
+ if (!keep.has(next)) {
98
+ keep.add(next);
99
+ stack.push(next);
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Restrict the IR to a node set: keep internal edges, rebuild groups. */
106
+ function subgraph(ir: GraphIR, keep: Set<string>): GraphIR {
107
+ const nodes: IRNode[] = ir.nodes.filter((n) => keep.has(n.id));
108
+ const edges: IREdge[] = ir.edges.filter((e) => keep.has(e.from) && keep.has(e.to));
109
+
110
+ const byLexicon: Record<string, string[]> = {};
111
+ const byComposite: Record<string, string[]> = {};
112
+ for (const n of nodes) {
113
+ (byLexicon[n.lexicon] ??= []).push(n.id);
114
+ if (n.compositeInstance) (byComposite[n.compositeInstance] ??= []).push(n.id);
115
+ }
116
+ const groups: GraphIR["groups"] = {};
117
+ if (Object.keys(byLexicon).length) groups.byLexicon = sortGroups(byLexicon);
118
+ if (Object.keys(byComposite).length) groups.byComposite = sortGroups(byComposite);
119
+
120
+ return { nodes, edges, groups };
121
+ }
122
+
123
+ function sortGroups(rec: Record<string, string[]>): Record<string, string[]> {
124
+ const out: Record<string, string[]> = {};
125
+ for (const k of Object.keys(rec).sort()) out[k] = rec[k].sort();
126
+ return out;
127
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { toMermaid } from "./graph-mermaid";
3
+ import type { GraphIR } from "./graph-ir";
4
+
5
+ const ir: GraphIR = {
6
+ nodes: [
7
+ { id: "vpc", kind: "Vpc", lexicon: "gcp", attrs: {} },
8
+ { id: "subnet", kind: "Subnet", lexicon: "gcp", attrs: {} },
9
+ { id: "ns", kind: "Namespace", lexicon: "k8s", attrs: {} },
10
+ ],
11
+ edges: [
12
+ { from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" },
13
+ { from: "ns", to: "subnet", kind: "ref", viaAttr: "subnet", toAttr: "selfLink" },
14
+ ],
15
+ groups: { byLexicon: { gcp: ["subnet", "vpc"], k8s: ["ns"] } },
16
+ };
17
+
18
+ describe("toMermaid", () => {
19
+ test("renders a flowchart with lexicon subgraphs, nodes, and labelled edges", () => {
20
+ const out = toMermaid(ir);
21
+ expect(out).toContain("flowchart TD");
22
+ expect(out).toContain('subgraph lex_gcp["gcp"]');
23
+ expect(out).toContain('subgraph lex_k8s["k8s"]');
24
+ // node label = name + kind on two lines
25
+ expect(out).toContain('vpc["vpc<br/>Vpc"]');
26
+ // edge with consumer-property label
27
+ expect(out).toContain("subnet -->|\"network\"| vpc");
28
+ // T3 edge shows consumer → producer attribute
29
+ expect(out).toContain('ns -->|"subnet → selfLink"| subnet');
30
+ });
31
+
32
+ test("is deterministic", () => {
33
+ expect(toMermaid(ir)).toEqual(toMermaid(ir));
34
+ });
35
+
36
+ test("sanitizes ids that aren't Mermaid-safe but keeps the human label", () => {
37
+ const dirty: GraphIR = {
38
+ nodes: [
39
+ { id: "east/db-0", kind: "StatefulSet", lexicon: "k8s", attrs: {} },
40
+ { id: "east/db-1", kind: "StatefulSet", lexicon: "k8s", attrs: {} },
41
+ ],
42
+ edges: [{ from: "east/db-1", to: "east/db-0", kind: "ref" }],
43
+ groups: {},
44
+ };
45
+ const out = toMermaid(dirty);
46
+ // ids are sanitized and unique
47
+ expect(out).toContain('east_db_0["east/db-0<br/>StatefulSet"]');
48
+ expect(out).toContain('east_db_1["east/db-1<br/>StatefulSet"]');
49
+ expect(out).toContain("east_db_1 --> east_db_0");
50
+ });
51
+
52
+ test("places ungrouped nodes at the top level", () => {
53
+ const out = toMermaid({
54
+ nodes: [{ id: "loner", kind: "Thing", lexicon: "x", attrs: {} }],
55
+ edges: [],
56
+ groups: {},
57
+ });
58
+ expect(out).toContain('loner["loner<br/>Thing"]');
59
+ expect(out).not.toContain("subgraph");
60
+ });
61
+ });
@@ -0,0 +1,85 @@
1
+ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
2
+
3
+ /**
4
+ * Render the graph IR as a Mermaid `flowchart`. Mermaid is the zero-install
5
+ * default — it renders in GitHub, docs, and browsers with no native dependency,
6
+ * so `chant graph --format mermaid` gives a diagram out of the box without the
7
+ * standalone painter. Lower fidelity than a custom painter, but portable.
8
+ *
9
+ * Consumes whatever IR it is given, so it honours `--detail` and `--lens` for
10
+ * free (those are IR → IR transforms). See issue #496 / epic #492.
11
+ *
12
+ * Known limits: Mermaid owns layout, so there is little control over node
13
+ * placement, and very large graphs get hard to read — that is the trade-off for
14
+ * zero-install portability. Reach for the graphviz/custom-painter path (#497,
15
+ * pinhole) when fidelity matters.
16
+ */
17
+ export function toMermaid(ir: GraphIR): string {
18
+ const ids = new Map<string, string>(); // logical name -> mermaid-safe id
19
+ for (const n of ir.nodes) safeId(n.id, ids);
20
+
21
+ const lines: string[] = ["flowchart TD"];
22
+
23
+ // Cluster by lexicon when grouping is available; nodes outside any group fall
24
+ // through to the top level. byLexicon is sorted, so output is deterministic.
25
+ const byLexicon = ir.groups.byLexicon;
26
+ const grouped = new Set<string>();
27
+ if (byLexicon) {
28
+ for (const [lexicon, members] of Object.entries(byLexicon)) {
29
+ lines.push(` subgraph ${safeId(`lex_${lexicon}`, ids)}[${quote(lexicon)}]`);
30
+ for (const id of members) {
31
+ const node = ir.nodes.find((n) => n.id === id);
32
+ if (!node) continue;
33
+ grouped.add(id);
34
+ lines.push(` ${nodeLine(node, ids)}`);
35
+ }
36
+ lines.push(" end");
37
+ }
38
+ }
39
+ for (const node of ir.nodes) {
40
+ if (grouped.has(node.id)) continue;
41
+ lines.push(` ${nodeLine(node, ids)}`);
42
+ }
43
+
44
+ for (const e of ir.edges) {
45
+ lines.push(` ${edgeLine(e, ids)}`);
46
+ }
47
+
48
+ return lines.join("\n") + "\n";
49
+ }
50
+
51
+ function nodeLine(node: IRNode, ids: Map<string, string>): string {
52
+ const id = safeId(node.id, ids);
53
+ const parts = [node.id];
54
+ if (node.kind && node.kind !== node.id) parts.push(node.kind);
55
+ return `${id}[${quote(parts.join("\n"))}]`;
56
+ }
57
+
58
+ function edgeLine(e: IREdge, ids: Map<string, string>): string {
59
+ const from = safeId(e.from, ids);
60
+ const to = safeId(e.to, ids);
61
+ const label = [e.viaAttr, e.toAttr].filter(Boolean).join(" → ");
62
+ return label ? `${from} -->|${quote(label)}| ${to}` : `${from} --> ${to}`;
63
+ }
64
+
65
+ /** Map an arbitrary logical name to a stable, unique Mermaid-safe node id. */
66
+ function safeId(raw: string, ids: Map<string, string>): string {
67
+ const existing = ids.get(raw);
68
+ if (existing) return existing;
69
+ let base = raw.replace(/[^A-Za-z0-9_]/g, "_");
70
+ if (base === "" || /^[0-9]/.test(base)) base = `n_${base}`;
71
+ const taken = new Set(ids.values());
72
+ let candidate = base;
73
+ let i = 1;
74
+ while (taken.has(candidate)) candidate = `${base}_${i++}`;
75
+ ids.set(raw, candidate);
76
+ return candidate;
77
+ }
78
+
79
+ /** Quote a Mermaid label, escaping markup and turning newlines into <br/>. */
80
+ function quote(text: string): string {
81
+ const esc = (s: string): string =>
82
+ s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
+ const body = text.split("\n").map(esc).join("<br/>");
84
+ return `"${body}"`;
85
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,10 @@ export * from "./discovery/cache";
27
27
  export * from "./build";
28
28
  export * from "./graph-ir";
29
29
  export * from "./graph-detail";
30
+ export * from "./graph-mermaid";
31
+ export * from "./graph-dot";
32
+ export * from "./graph-layout";
33
+ export * from "./graph-lens";
30
34
  export * from "./detectLexicon";
31
35
  export * from "./lint/parser";
32
36
  export * from "./lint/rule";