@intentius/chant 0.8.2 → 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.
- package/dist/cli/handlers/graph.d.ts +2 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-detail.d.ts +21 -0
- package/dist/graph-detail.d.ts.map +1 -0
- package/dist/graph-dot.d.ts +12 -0
- package/dist/graph-dot.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +78 -0
- package/dist/graph-ir.d.ts.map +1 -0
- package/dist/graph-layout.d.ts +39 -0
- package/dist/graph-layout.d.ts.map +1 -0
- package/dist/graph-lens.d.ts +30 -0
- package/dist/graph-lens.d.ts.map +1 -0
- package/dist/graph-mermaid.d.ts +17 -0
- package/dist/graph-mermaid.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/provenance.d.ts +8 -1
- package/dist/provenance.d.ts.map +1 -1
- package/dist/reconcile.d.ts +147 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.test.ts +166 -48
- package/src/cli/handlers/graph.ts +86 -1
- package/src/cli/main.test.ts +16 -0
- package/src/cli/main.ts +14 -1
- package/src/cli/registry.ts +8 -0
- package/src/discovery/collect.ts +2 -2
- package/src/graph-detail.test.ts +79 -0
- package/src/graph-detail.ts +149 -0
- package/src/graph-dot.test.ts +68 -0
- package/src/graph-dot.ts +58 -0
- package/src/graph-ir.test.ts +122 -0
- package/src/graph-ir.ts +285 -0
- package/src/graph-layout.ts +104 -0
- package/src/graph-lens.test.ts +80 -0
- package/src/graph-lens.ts +127 -0
- package/src/graph-mermaid.test.ts +61 -0
- package/src/graph-mermaid.ts +85 -0
- package/src/index.ts +6 -0
- package/src/provenance.ts +9 -1
- package/src/reconcile.test.ts +224 -0
- package/src/reconcile.ts +346 -0
|
@@ -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
|
+
});
|
package/src/graph-dot.ts
ADDED
|
@@ -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,122 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { buildGraphIr } from "./graph-ir";
|
|
3
|
+
import { DECLARABLE_MARKER, type Declarable } from "./declarable";
|
|
4
|
+
import { AttrRef } from "./attrref";
|
|
5
|
+
import { resolveAttrRefs } from "./discovery/resolve";
|
|
6
|
+
import { setProvenance } from "./provenance";
|
|
7
|
+
|
|
8
|
+
function decl<T extends object>(base: T): Declarable & T {
|
|
9
|
+
return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("buildGraphIr", () => {
|
|
13
|
+
test("emits a node per resource with kind, lexicon, and scrubbed attrs", () => {
|
|
14
|
+
const vpc = decl({ lexicon: "gcp", entityType: "Vpc", props: { autoCreateSubnetworks: false } });
|
|
15
|
+
const entities = new Map<string, Declarable>([["vpc", vpc]]);
|
|
16
|
+
resolveAttrRefs(entities);
|
|
17
|
+
|
|
18
|
+
const ir = buildGraphIr(entities);
|
|
19
|
+
expect(ir.nodes).toHaveLength(1);
|
|
20
|
+
expect(ir.nodes[0]).toMatchObject({ id: "vpc", kind: "Vpc", lexicon: "gcp" });
|
|
21
|
+
// `props` is flattened into attrs (field reads as the property, not props.x).
|
|
22
|
+
expect(ir.nodes[0].attrs).toEqual({ autoCreateSubnetworks: false });
|
|
23
|
+
// Framework fields are scrubbed out of attrs.
|
|
24
|
+
expect(ir.nodes[0].attrs).not.toHaveProperty("lexicon");
|
|
25
|
+
expect(ir.nodes[0].attrs).not.toHaveProperty("entityType");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("derives ref edges labelled with the consumer property", () => {
|
|
29
|
+
const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
30
|
+
const subnet = decl({ lexicon: "gcp", entityType: "Subnet", props: { network: new AttrRef(vpc, "id") } });
|
|
31
|
+
const entities = new Map<string, Declarable>([
|
|
32
|
+
["vpc", vpc],
|
|
33
|
+
["subnet", subnet],
|
|
34
|
+
]);
|
|
35
|
+
resolveAttrRefs(entities);
|
|
36
|
+
|
|
37
|
+
const ir = buildGraphIr(entities);
|
|
38
|
+
expect(ir.edges).toEqual([{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" }]);
|
|
39
|
+
// The ref also appears in the consumer's attrs as an auditable envelope.
|
|
40
|
+
expect(ir.nodes.find((n) => n.id === "subnet")!.attrs).toEqual({
|
|
41
|
+
network: { $ref: "vpc.id" },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("excludes property-kind declarables and keeps resources", () => {
|
|
46
|
+
const resource = decl({ lexicon: "k8s", entityType: "Deployment" });
|
|
47
|
+
const prop = decl({ lexicon: "k8s", entityType: "Probe", kind: "property" as const });
|
|
48
|
+
const entities = new Map<string, Declarable>([
|
|
49
|
+
["dep", resource],
|
|
50
|
+
["probe", prop],
|
|
51
|
+
]);
|
|
52
|
+
resolveAttrRefs(entities);
|
|
53
|
+
|
|
54
|
+
const ir = buildGraphIr(entities);
|
|
55
|
+
expect(ir.nodes.map((n) => n.id)).toEqual(["dep"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("populates compositeParent and byComposite from provenance", () => {
|
|
59
|
+
const sts = decl({ lexicon: "k8s", entityType: "StatefulSet" });
|
|
60
|
+
const svc = decl({ lexicon: "k8s", entityType: "Service" });
|
|
61
|
+
setProvenance(sts, { composite: "CockroachDbCluster", sourceFile: "/proj/src/db.ts" });
|
|
62
|
+
setProvenance(svc, { composite: "CockroachDbCluster", sourceFile: "/proj/src/db.ts" });
|
|
63
|
+
const entities = new Map<string, Declarable>([
|
|
64
|
+
["dbSts", sts],
|
|
65
|
+
["dbSvc", svc],
|
|
66
|
+
]);
|
|
67
|
+
resolveAttrRefs(entities);
|
|
68
|
+
|
|
69
|
+
const ir = buildGraphIr(entities, "/proj");
|
|
70
|
+
expect(ir.nodes.every((n) => n.compositeParent === "CockroachDbCluster")).toBe(true);
|
|
71
|
+
expect(ir.groups.byComposite).toEqual({ CockroachDbCluster: ["dbSts", "dbSvc"] });
|
|
72
|
+
// Source file is relativized to the project root.
|
|
73
|
+
expect(ir.nodes[0].sourceLoc).toEqual({ file: "src/db.ts" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reads non-enumerable props like real lexicon entities", () => {
|
|
77
|
+
// Real lexicon resources define lexicon/entityType/props as non-enumerable,
|
|
78
|
+
// so Object.entries(entity) is empty — attrs and edges must come from props.
|
|
79
|
+
const vpc = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
80
|
+
const subnet = { [DECLARABLE_MARKER]: true } as Declarable & { props: unknown };
|
|
81
|
+
Object.defineProperties(subnet, {
|
|
82
|
+
lexicon: { value: "gcp", enumerable: false },
|
|
83
|
+
entityType: { value: "Subnet", enumerable: false },
|
|
84
|
+
props: { value: { cidr: "10.0.0.0/16", network: new AttrRef(vpc, "id") }, enumerable: false },
|
|
85
|
+
});
|
|
86
|
+
const entities = new Map<string, Declarable>([
|
|
87
|
+
["vpc", vpc],
|
|
88
|
+
["subnet", subnet],
|
|
89
|
+
]);
|
|
90
|
+
resolveAttrRefs(entities);
|
|
91
|
+
|
|
92
|
+
const ir = buildGraphIr(entities);
|
|
93
|
+
const subnetNode = ir.nodes.find((n) => n.id === "subnet")!;
|
|
94
|
+
expect(subnetNode.attrs).toEqual({ cidr: "10.0.0.0/16", network: { $ref: "vpc.id" } });
|
|
95
|
+
expect(ir.edges).toEqual([{ from: "subnet", to: "vpc", kind: "ref", viaAttr: "network" }]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("groups nodes by lexicon and is deterministic", () => {
|
|
99
|
+
const a = decl({ lexicon: "gcp", entityType: "Vpc" });
|
|
100
|
+
const b = decl({ lexicon: "k8s", entityType: "Namespace" });
|
|
101
|
+
const c = decl({ lexicon: "gcp", entityType: "Subnet" });
|
|
102
|
+
const entities = new Map<string, Declarable>([
|
|
103
|
+
["zeta", c],
|
|
104
|
+
["alpha", a],
|
|
105
|
+
["mid", b],
|
|
106
|
+
]);
|
|
107
|
+
resolveAttrRefs(entities);
|
|
108
|
+
|
|
109
|
+
const ir = buildGraphIr(entities);
|
|
110
|
+
expect(ir.nodes.map((n) => n.id)).toEqual(["alpha", "mid", "zeta"]); // sorted
|
|
111
|
+
expect(ir.groups.byLexicon).toEqual({ gcp: ["alpha", "zeta"], k8s: ["mid"] });
|
|
112
|
+
|
|
113
|
+
// Same input (different insertion order) yields identical IR.
|
|
114
|
+
const reordered = new Map<string, Declarable>([
|
|
115
|
+
["alpha", a],
|
|
116
|
+
["mid", b],
|
|
117
|
+
["zeta", c],
|
|
118
|
+
]);
|
|
119
|
+
resolveAttrRefs(reordered);
|
|
120
|
+
expect(JSON.stringify(buildGraphIr(reordered))).toEqual(JSON.stringify(ir));
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/graph-ir.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { relative, isAbsolute } from "node:path";
|
|
2
|
+
import { AttrRef } from "./attrref";
|
|
3
|
+
import { type Declarable, isDeclarable } from "./declarable";
|
|
4
|
+
import { isLexiconOutput } from "./lexicon-output";
|
|
5
|
+
import { getProvenance } from "./provenance";
|
|
6
|
+
import { INTRINSIC_MARKER } from "./intrinsic";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Graph IR — the engine-neutral, lint-gated representation of a project's
|
|
10
|
+
* resolved infrastructure graph. Painters (mermaid, graphviz, custom SVG) and
|
|
11
|
+
* the agentic diagrammer consume this; it is a pure function of lint-clean
|
|
12
|
+
* source. Every node traces to the file that declared it; every edge is a real
|
|
13
|
+
* cross-resource reference (AttrRef).
|
|
14
|
+
*
|
|
15
|
+
* Emitted by `chant graph --format ir`. See issue #493 / epic #492.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Where in the source a node came from. Entity-level (file), not a line map. */
|
|
19
|
+
export interface SourceLoc {
|
|
20
|
+
/** Path of the declaring file, relative to the project root when possible. */
|
|
21
|
+
file: string;
|
|
22
|
+
/**
|
|
23
|
+
* Line number, when available. Provenance is entity-level today, so this is
|
|
24
|
+
* usually absent — reserved for a future source map.
|
|
25
|
+
*/
|
|
26
|
+
line?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A reference in an attribute projection: `<producer>.<attribute>`. */
|
|
30
|
+
export interface AttrRefEnvelope {
|
|
31
|
+
$ref: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One resource in the graph. */
|
|
35
|
+
export interface IRNode {
|
|
36
|
+
/** Logical name (the export name, or composite-expanded name). */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Resource type, e.g. "GkeCluster". */
|
|
39
|
+
kind: string;
|
|
40
|
+
/** Lexicon the resource belongs to, e.g. "gcp". */
|
|
41
|
+
lexicon: string;
|
|
42
|
+
/**
|
|
43
|
+
* Name of the composite *type* that expanded this node, when it came from one
|
|
44
|
+
* (e.g. "CockroachDbCluster").
|
|
45
|
+
*/
|
|
46
|
+
compositeParent?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The composite *instance* (export name) this node belongs to — shared by every
|
|
49
|
+
* node from the same composite call. Detail tiers collapse on this (#494).
|
|
50
|
+
*/
|
|
51
|
+
compositeInstance?: string;
|
|
52
|
+
/** Literal/const/ref-resolved props. References appear as `{ $ref }`. */
|
|
53
|
+
attrs: Record<string, unknown>;
|
|
54
|
+
/** Where the node was declared. */
|
|
55
|
+
sourceLoc?: SourceLoc;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A directed dependency: `from` references an attribute of `to`. */
|
|
59
|
+
export interface IREdge {
|
|
60
|
+
from: string;
|
|
61
|
+
to: string;
|
|
62
|
+
/** "ref" for an AttrRef-derived edge. */
|
|
63
|
+
kind: "ref";
|
|
64
|
+
/** The consumer-side property the reference flows through, when derivable. */
|
|
65
|
+
viaAttr?: string;
|
|
66
|
+
/** The producer-side attribute referenced (e.g. "id"). Added at detail T3. */
|
|
67
|
+
toAttr?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Grouping metadata for cluster/subgraph rendering. Maps group name -> node ids. */
|
|
71
|
+
export interface IRGroups {
|
|
72
|
+
byLexicon?: Record<string, string[]>;
|
|
73
|
+
byComposite?: Record<string, string[]>;
|
|
74
|
+
/** Reserved for stack grouping (#494). */
|
|
75
|
+
byStack?: Record<string, string[]>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The full graph IR for a project at the default (declarable) detail level. */
|
|
79
|
+
export interface GraphIR {
|
|
80
|
+
nodes: IRNode[];
|
|
81
|
+
edges: IREdge[];
|
|
82
|
+
groups: IRGroups;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A node is anything that serializes to a resource — not a property or output. */
|
|
86
|
+
function isNodeEntity(entity: Declarable): boolean {
|
|
87
|
+
if (isLexiconOutput(entity)) return false;
|
|
88
|
+
if (entity.kind === "property") return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function relFile(file: string | undefined, projectPath?: string): string | undefined {
|
|
93
|
+
if (!file) return undefined;
|
|
94
|
+
if (projectPath && isAbsolute(file)) {
|
|
95
|
+
const rel = relative(projectPath, file);
|
|
96
|
+
return rel.startsWith("..") ? file : rel;
|
|
97
|
+
}
|
|
98
|
+
return file;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve the producer (logical name) an AttrRef points at. */
|
|
102
|
+
function refTarget(ref: AttrRef, reverse: Map<object, string>): string | undefined {
|
|
103
|
+
// Nested refs (under `props`) aren't assigned a logical name by resolveAttrRefs,
|
|
104
|
+
// which only resolves top-level own props — so fall back to the parent's name.
|
|
105
|
+
const parent = ref.parent.deref();
|
|
106
|
+
return ref.getLogicalName() ?? (parent ? reverse.get(parent) : undefined);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Project a value into a JSON-safe, scrubbed form. References become `{ $ref }`. */
|
|
110
|
+
function project(value: unknown, seen: Set<unknown>, reverse: Map<object, string>): unknown {
|
|
111
|
+
if (value === null) return null;
|
|
112
|
+
const t = typeof value;
|
|
113
|
+
if (t === "string" || t === "number" || t === "boolean") return value;
|
|
114
|
+
if (t !== "object") return undefined; // functions, symbols, undefined
|
|
115
|
+
|
|
116
|
+
if (value instanceof AttrRef) {
|
|
117
|
+
const to = refTarget(value, reverse);
|
|
118
|
+
return { $ref: to ? `${to}.${value.attribute}` : value.attribute } satisfies AttrRefEnvelope;
|
|
119
|
+
}
|
|
120
|
+
// Other intrinsics (interpolations, pseudo-parameters): mark, don't inline.
|
|
121
|
+
if ((value as Record<symbol, unknown>)[INTRINSIC_MARKER] === true) {
|
|
122
|
+
return { $intrinsic: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (seen.has(value)) return undefined; // break cycles
|
|
126
|
+
seen.add(value);
|
|
127
|
+
|
|
128
|
+
// Nested declarables (e.g. an inlined property-kind resource) keep their
|
|
129
|
+
// config in a non-enumerable `props` bag, like top-level nodes — project that.
|
|
130
|
+
if (isDeclarable(value)) {
|
|
131
|
+
const out = projectConfig(value, seen, reverse);
|
|
132
|
+
seen.delete(value);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
const out = value.map((v) => project(v, seen, reverse)).filter((v) => v !== undefined);
|
|
138
|
+
seen.delete(value);
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const out: Record<string, unknown> = {};
|
|
143
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
144
|
+
const p = project(v, seen, reverse);
|
|
145
|
+
if (p !== undefined) out[k] = p;
|
|
146
|
+
}
|
|
147
|
+
seen.delete(value);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const SKIP_KEYS = new Set(["lexicon", "entityType", "kind", "attributes", "Ref"]);
|
|
152
|
+
|
|
153
|
+
/** The config bag of a node, paired with each key. Lexicon entities keep their
|
|
154
|
+
* declared props in a (usually non-enumerable) `props` object; simpler entities
|
|
155
|
+
* carry config as enumerable own properties. We surface both, flattening `props`
|
|
156
|
+
* so a field reads as e.g. `network`, not `props.network`. */
|
|
157
|
+
function configRoots(entity: Declarable): Array<[string, unknown]> {
|
|
158
|
+
const out: Array<[string, unknown]> = [];
|
|
159
|
+
for (const [k, v] of Object.entries(entity)) {
|
|
160
|
+
if (SKIP_KEYS.has(k) || k === "props") continue;
|
|
161
|
+
out.push([k, v]);
|
|
162
|
+
}
|
|
163
|
+
const props = (entity as unknown as { props?: unknown }).props;
|
|
164
|
+
if (props && typeof props === "object" && !Array.isArray(props)) {
|
|
165
|
+
for (const [k, v] of Object.entries(props as Record<string, unknown>)) out.push([k, v]);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Project a declarable's config bag (flattened props + enumerable own props). */
|
|
171
|
+
function projectConfig(
|
|
172
|
+
entity: Declarable,
|
|
173
|
+
seen: Set<unknown>,
|
|
174
|
+
reverse: Map<object, string>,
|
|
175
|
+
): Record<string, unknown> {
|
|
176
|
+
const out: Record<string, unknown> = {};
|
|
177
|
+
for (const [k, v] of configRoots(entity)) {
|
|
178
|
+
const p = project(v, seen, reverse);
|
|
179
|
+
if (p !== undefined) out[k] = p;
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Collect ref edges from one node, labelling each with its consumer property. */
|
|
185
|
+
function collectEdges(
|
|
186
|
+
entity: Declarable,
|
|
187
|
+
from: string,
|
|
188
|
+
nodeIds: Set<string>,
|
|
189
|
+
reverse: Map<object, string>,
|
|
190
|
+
): IREdge[] {
|
|
191
|
+
const edges: IREdge[] = [];
|
|
192
|
+
const seen = new Set<unknown>();
|
|
193
|
+
const visit = (value: unknown, viaAttr: string): void => {
|
|
194
|
+
if (value === null || typeof value !== "object") return;
|
|
195
|
+
if (value instanceof AttrRef) {
|
|
196
|
+
const to = refTarget(value, reverse);
|
|
197
|
+
if (to && to !== from && nodeIds.has(to)) {
|
|
198
|
+
edges.push({ from, to, kind: "ref", viaAttr });
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if ((value as Record<symbol, unknown>)[INTRINSIC_MARKER] === true) return;
|
|
203
|
+
if (seen.has(value)) return;
|
|
204
|
+
seen.add(value);
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
for (const item of value) visit(item, viaAttr);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
for (const v of Object.values(value as Record<string, unknown>)) visit(v, viaAttr);
|
|
210
|
+
};
|
|
211
|
+
for (const [k, v] of configRoots(entity)) visit(v, k);
|
|
212
|
+
return edges;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Stable key for deduping and sorting an edge. */
|
|
216
|
+
function edgeKey(e: IREdge): string {
|
|
217
|
+
return `${e.from}\0${e.to}\0${e.viaAttr ?? ""}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build the graph IR from resolved entities (the output of `discover`). Pure and
|
|
222
|
+
* deterministic: nodes and edges are sorted, so the same source yields identical
|
|
223
|
+
* IR. `projectPath` relativizes source-file paths for portable output.
|
|
224
|
+
*/
|
|
225
|
+
export function buildGraphIr(
|
|
226
|
+
entities: Map<string, Declarable>,
|
|
227
|
+
projectPath?: string,
|
|
228
|
+
): GraphIR {
|
|
229
|
+
// Reverse lookup for producers whose AttrRef logical name wasn't resolved.
|
|
230
|
+
const reverse = new Map<object, string>();
|
|
231
|
+
for (const [name, entity] of entities) reverse.set(entity, name);
|
|
232
|
+
|
|
233
|
+
const nodeIds = new Set<string>();
|
|
234
|
+
for (const [name, entity] of entities) {
|
|
235
|
+
if (isNodeEntity(entity)) nodeIds.add(name);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const nodes: IRNode[] = [];
|
|
239
|
+
const byLexicon: Record<string, string[]> = {};
|
|
240
|
+
const byComposite: Record<string, string[]> = {};
|
|
241
|
+
|
|
242
|
+
for (const [name, entity] of entities) {
|
|
243
|
+
if (!nodeIds.has(name)) continue;
|
|
244
|
+
const prov = getProvenance(entity);
|
|
245
|
+
const node: IRNode = {
|
|
246
|
+
id: name,
|
|
247
|
+
kind: entity.entityType,
|
|
248
|
+
lexicon: entity.lexicon,
|
|
249
|
+
attrs: projectConfig(entity, new Set(), reverse),
|
|
250
|
+
};
|
|
251
|
+
if (prov?.composite) node.compositeParent = prov.composite;
|
|
252
|
+
if (prov?.compositeInstance) node.compositeInstance = prov.compositeInstance;
|
|
253
|
+
const file = relFile(prov?.sourceFile, projectPath);
|
|
254
|
+
if (file) node.sourceLoc = { file };
|
|
255
|
+
nodes.push(node);
|
|
256
|
+
|
|
257
|
+
(byLexicon[entity.lexicon] ??= []).push(name);
|
|
258
|
+
if (prov?.composite) (byComposite[prov.composite] ??= []).push(name);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const edgeMap = new Map<string, IREdge>();
|
|
262
|
+
for (const [name, entity] of entities) {
|
|
263
|
+
if (!nodeIds.has(name)) continue;
|
|
264
|
+
for (const e of collectEdges(entity, name, nodeIds, reverse)) {
|
|
265
|
+
edgeMap.set(edgeKey(e), e);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
270
|
+
const edges = [...edgeMap.values()].sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)));
|
|
271
|
+
for (const ids of Object.values(byLexicon)) ids.sort();
|
|
272
|
+
for (const ids of Object.values(byComposite)) ids.sort();
|
|
273
|
+
|
|
274
|
+
const groups: IRGroups = {};
|
|
275
|
+
if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
|
|
276
|
+
if (Object.keys(byComposite).length) groups.byComposite = sortKeys(byComposite);
|
|
277
|
+
|
|
278
|
+
return { nodes, edges, groups };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
|
|
282
|
+
const out: Record<string, string[]> = {};
|
|
283
|
+
for (const k of Object.keys(rec).sort()) out[k] = rec[k];
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
@@ -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
|
+
}
|