@intentius/chant 0.8.1 → 0.9.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.
@@ -0,0 +1,149 @@
1
+ import type { GraphIR, IRNode, IREdge } from "./graph-ir";
2
+
3
+ /**
4
+ * Detail tiers — the diagram "detail dial". Each level is a pure IR → IR
5
+ * transform over the base graph IR (no re-discovery), so every emitter and the
6
+ * painter get them for free. See issue #494 / epic #492.
7
+ *
8
+ * - 0 STACKS — one node per lexicon; edges are cross-lexicon dependencies
9
+ * - 1 COMPOSITES — composite instances collapsed to a single node each
10
+ * - 2 DECLARABLES — every resource (the base produced by buildGraphIr)
11
+ * - 3 ATTRIBUTES — declarables plus the producer attribute on each edge
12
+ */
13
+ export type DetailLevel = 0 | 1 | 2 | 3;
14
+
15
+ export const DETAIL = {
16
+ STACKS: 0,
17
+ COMPOSITES: 1,
18
+ DECLARABLES: 2,
19
+ ATTRIBUTES: 3,
20
+ } as const;
21
+
22
+ /** Apply a detail tier to the base (declarable-level) IR. */
23
+ export function applyDetail(ir: GraphIR, level: DetailLevel): GraphIR {
24
+ switch (level) {
25
+ case 0:
26
+ return toStacks(ir);
27
+ case 1:
28
+ return toComposites(ir);
29
+ case 3:
30
+ return toAttributes(ir);
31
+ case 2:
32
+ default:
33
+ return ir;
34
+ }
35
+ }
36
+
37
+ function edgeSortKey(e: IREdge): string {
38
+ return `${e.from}\0${e.to}\0${e.viaAttr ?? ""}`;
39
+ }
40
+
41
+ function sortEdges(edges: IREdge[]): IREdge[] {
42
+ return edges.sort((a, b) => edgeSortKey(a).localeCompare(edgeSortKey(b)));
43
+ }
44
+
45
+ function byLexiconOf(nodes: IRNode[]): Record<string, string[]> {
46
+ const out: Record<string, string[]> = {};
47
+ for (const n of nodes) (out[n.lexicon] ??= []).push(n.id);
48
+ const sorted: Record<string, string[]> = {};
49
+ for (const k of Object.keys(out).sort()) sorted[k] = out[k].sort();
50
+ return sorted;
51
+ }
52
+
53
+ /** T0 — collapse every resource to its lexicon; edges become cross-lexicon deps. */
54
+ function toStacks(ir: GraphIR): GraphIR {
55
+ const lexOf = new Map(ir.nodes.map((n) => [n.id, n.lexicon]));
56
+ const lexicons = [...new Set(ir.nodes.map((n) => n.lexicon))].sort();
57
+ const nodes: IRNode[] = lexicons.map((l) => ({ id: l, kind: "stack", lexicon: l, attrs: {} }));
58
+
59
+ const seen = new Map<string, IREdge>();
60
+ for (const e of ir.edges) {
61
+ const from = lexOf.get(e.from);
62
+ const to = lexOf.get(e.to);
63
+ if (!from || !to || from === to) continue;
64
+ const key = `${from}\0${to}`;
65
+ if (!seen.has(key)) seen.set(key, { from, to, kind: "ref" });
66
+ }
67
+ return { nodes, edges: sortEdges([...seen.values()]), groups: {} };
68
+ }
69
+
70
+ /** T1 — collapse each composite instance to one node; internal edges disappear. */
71
+ function toComposites(ir: GraphIR): GraphIR {
72
+ const idMap = new Map<string, string>();
73
+ for (const n of ir.nodes) idMap.set(n.id, n.compositeInstance ?? n.id);
74
+
75
+ const instances = new Map<string, IRNode[]>();
76
+ const plain: IRNode[] = [];
77
+ for (const n of ir.nodes) {
78
+ if (n.compositeInstance) {
79
+ const arr = instances.get(n.compositeInstance) ?? [];
80
+ arr.push(n);
81
+ instances.set(n.compositeInstance, arr);
82
+ } else {
83
+ plain.push({ ...n });
84
+ }
85
+ }
86
+
87
+ const nodes: IRNode[] = [...plain];
88
+ for (const [inst, members] of instances) {
89
+ const types = new Set(members.map((m) => m.compositeParent).filter(Boolean) as string[]);
90
+ const lexicons = new Set(members.map((m) => m.lexicon));
91
+ nodes.push({
92
+ id: inst,
93
+ kind: types.size === 1 ? [...types][0] : "Composite",
94
+ lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
95
+ attrs: { members: members.length },
96
+ });
97
+ }
98
+
99
+ const seen = new Map<string, IREdge>();
100
+ for (const e of ir.edges) {
101
+ const from = idMap.get(e.from) ?? e.from;
102
+ const to = idMap.get(e.to) ?? e.to;
103
+ if (from === to) continue; // edge internal to a composite
104
+ // A property label only makes sense when neither endpoint was collapsed.
105
+ const collapsed = from !== e.from || to !== e.to;
106
+ const edge: IREdge = collapsed
107
+ ? { from, to, kind: "ref" }
108
+ : { from, to, kind: "ref", viaAttr: e.viaAttr };
109
+ const key = `${from}\0${to}\0${edge.viaAttr ?? ""}`;
110
+ if (!seen.has(key)) seen.set(key, edge);
111
+ }
112
+
113
+ nodes.sort((a, b) => a.id.localeCompare(b.id));
114
+ return { nodes, edges: sortEdges([...seen.values()]), groups: { byLexicon: byLexiconOf(nodes) } };
115
+ }
116
+
117
+ /** T3 — annotate each edge with the producer attribute it references. */
118
+ function toAttributes(ir: GraphIR): GraphIR {
119
+ const nodeById = new Map(ir.nodes.map((n) => [n.id, n]));
120
+ const edges = ir.edges.map((e) => {
121
+ const node = nodeById.get(e.from);
122
+ const toAttr = node ? findRefAttr(node.attrs, e.to) : undefined;
123
+ return toAttr ? { ...e, toAttr } : { ...e };
124
+ });
125
+ return { ...ir, edges };
126
+ }
127
+
128
+ /** Find the attribute in a `{ $ref: "producer.attribute" }` envelope under attrs. */
129
+ function findRefAttr(attrs: Record<string, unknown>, producer: string): string | undefined {
130
+ let found: string | undefined;
131
+ const visit = (v: unknown): void => {
132
+ if (found !== undefined || v === null || typeof v !== "object") return;
133
+ if (Array.isArray(v)) {
134
+ for (const item of v) visit(item);
135
+ return;
136
+ }
137
+ const ref = (v as { $ref?: unknown }).$ref;
138
+ if (typeof ref === "string") {
139
+ const dot = ref.indexOf(".");
140
+ if (dot > 0 && ref.slice(0, dot) === producer) {
141
+ found = ref.slice(dot + 1);
142
+ return;
143
+ }
144
+ }
145
+ for (const val of Object.values(v as Record<string, unknown>)) visit(val);
146
+ };
147
+ visit(attrs);
148
+ return found;
149
+ }
@@ -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
+ });
@@ -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
+ }
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export * from "./discovery/graph";
25
25
  export * from "./discovery/index";
26
26
  export * from "./discovery/cache";
27
27
  export * from "./build";
28
+ export * from "./graph-ir";
29
+ export * from "./graph-detail";
28
30
  export * from "./detectLexicon";
29
31
  export * from "./lint/parser";
30
32
  export * from "./lint/rule";
package/src/provenance.ts CHANGED
@@ -15,8 +15,15 @@ const PROVENANCE = Symbol.for("chant.provenance");
15
15
  export interface EntityProvenance {
16
16
  /** Absolute path of the source file that declared (or exported) the entity. */
17
17
  sourceFile?: string;
18
- /** The composite that expanded this entity, when it came from one. */
18
+ /** The composite (type) that expanded this entity, when it came from one. */
19
19
  composite?: string;
20
+ /**
21
+ * The composite *instance* this entity belongs to — the export name of the
22
+ * top-level composite, shared by every member it expanded to. Distinguishes
23
+ * two instances of the same composite type, which `composite` cannot. Used to
24
+ * collapse a composite to a single node at coarse diagram detail levels (#494).
25
+ */
26
+ compositeInstance?: string;
20
27
  }
21
28
 
22
29
  /**
@@ -30,6 +37,7 @@ export function setProvenance(entity: object, prov: EntityProvenance): void {
30
37
  if (existing) {
31
38
  existing.sourceFile ??= prov.sourceFile;
32
39
  existing.composite ??= prov.composite;
40
+ existing.compositeInstance ??= prov.compositeInstance;
33
41
  return;
34
42
  }
35
43
  Object.defineProperty(entity, PROVENANCE, {