@intentius/chant 0.9.0 → 0.11.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,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
+ });