bc-atlas 0.5.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/src/cli.js ADDED
@@ -0,0 +1,357 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { watch as fsWatch } from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { parseArgs } from "node:util";
8
+ import { analyze } from "./analyzer.js";
9
+ import { loadConfig } from "./config.js";
10
+ import { renderD2 } from "./d2.js";
11
+ import { addInsights } from "./insights.js";
12
+ import { resolveModel } from "./resolver.js";
13
+ import { renderSvg } from "./svg.js";
14
+ import { createView, filterModel } from "./views.js";
15
+ import { docsMain } from "./docs/cli.js";
16
+
17
+ const HELP = `BC Atlas - generate architecture diagrams from AL source
18
+
19
+ Usage:
20
+ bca [graph] [options] <file-or-directory>
21
+ bca inspect [options] <file-or-directory>
22
+ bca watch [options] <directory>
23
+ bca docs generate [options] <ui-test.al>
24
+
25
+ Views:
26
+ project (default) AL objects grouped by namespace
27
+ module Aggregated namespace dependencies
28
+ object One object plus incoming/outgoing neighbors
29
+ data Tables and table-oriented dependencies
30
+ call Procedure and trigger call graph
31
+ boundary Dependencies crossing a selected scope
32
+ contracts Interfaces and direct/enum implementations
33
+ events Event publishers and subscribers
34
+ ui Pages, source tables, parts, actions, and navigation
35
+ workflow Trace calls, events, and data mutations from entry points
36
+
37
+ Options:
38
+ -o, --output <path> Output path (default: bc-atlas.d2)
39
+ -f, --format <format> d2, json, svg, png, or pdf
40
+ --view <view> project, module, object, data, call, boundary,
41
+ contracts, events, ui, or workflow
42
+ --entry <selector> Workflow entry procedure, trigger, action, or event
43
+ (repeatable)
44
+ --workflow-depth <n> Workflow traversal depth (default: 8)
45
+ --workflow-max-nodes <n>
46
+ Workflow node cap (default: 100)
47
+ --workflow-edge-types <types>
48
+ calls,events,writes,reads (default: calls,events,writes)
49
+ --object <selector> Object selector, e.g. codeunit:50100
50
+ --scope <selector> Boundary scope: namespace:, folder:, app:, or
51
+ object: (repeatable)
52
+ --focus <text> Focus contracts, events, or UI on a matching name
53
+ --namespace <glob> Namespace filter (repeatable)
54
+ --type <types> Object type filter, comma-separated/repeatable
55
+ --include <glob> Include file/object glob (repeatable)
56
+ --exclude <glob> Exclude file/object glob (repeatable)
57
+ --group-by <mode> namespace, folder, type, or role
58
+ --module-depth <n> Namespace segments, or auto (default: auto)
59
+ --folder-depth <n> Folder segments in module view (default: 1)
60
+ --include-unresolved-calls
61
+ Include unresolved calls and isolated procedures
62
+ --max-edges <n> Diagram edge density cap (default: 500)
63
+ --direction <value> right, down, left, or up
64
+ --title <text> Diagram title
65
+ --source-url <tmpl> Node link template with {file} and {line}
66
+ --details Show member counts in nodes
67
+ --no-external Hide unresolved/external dependencies
68
+ --config <path> Configuration file (default: .bca.json)
69
+ --strict Fail on parse or resolution diagnostics
70
+ --debounce <ms> Watch rebuild debounce (default: 250)
71
+ -h, --help Show help
72
+ -V, --version Show version
73
+
74
+ SVG is rendered by the bundled D2 WebAssembly package.
75
+ PNG and PDF rendering require the d2 executable on PATH.
76
+ `;
77
+
78
+ const OPTIONS = {
79
+ output: { type: "string", short: "o" },
80
+ format: { type: "string", short: "f" },
81
+ view: { type: "string" },
82
+ entry: { type: "string", multiple: true },
83
+ object: { type: "string" },
84
+ scope: { type: "string", multiple: true },
85
+ focus: { type: "string" },
86
+ namespace: { type: "string", multiple: true },
87
+ type: { type: "string", multiple: true },
88
+ include: { type: "string", multiple: true },
89
+ exclude: { type: "string", multiple: true },
90
+ "group-by": { type: "string" },
91
+ "module-depth": { type: "string" },
92
+ "folder-depth": { type: "string" },
93
+ "include-unresolved-calls": { type: "boolean" },
94
+ "workflow-depth": { type: "string" },
95
+ "workflow-max-nodes": { type: "string" },
96
+ "workflow-edge-types": { type: "string" },
97
+ "max-edges": { type: "string" },
98
+ direction: { type: "string" },
99
+ title: { type: "string" },
100
+ "source-url": { type: "string" },
101
+ details: { type: "boolean" },
102
+ "no-external": { type: "boolean" },
103
+ config: { type: "string" },
104
+ strict: { type: "boolean" },
105
+ debounce: { type: "string" },
106
+ help: { type: "boolean", short: "h" },
107
+ version: { type: "boolean", short: "V" }
108
+ };
109
+
110
+ function fail(message, code = 1) {
111
+ console.error(`bca: ${message}`);
112
+ process.exitCode = code;
113
+ }
114
+
115
+ function positiveInteger(value, name, fallback) {
116
+ const number = value === undefined ? fallback : Number(value);
117
+ if (!Number.isInteger(number) || number < 1) {
118
+ throw new Error(`${name} must be a positive integer`);
119
+ }
120
+ return number;
121
+ }
122
+
123
+ function mergeOptions(config, values) {
124
+ return {
125
+ ...config,
126
+ ...Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined)),
127
+ namespaces: values.namespace ?? config.namespaces ?? [],
128
+ types: values.type ?? config.types ?? [],
129
+ include: values.include ?? config.include ?? [],
130
+ exclude: values.exclude ?? config.exclude ?? [],
131
+ scope: values.scope ?? config.scope ?? [],
132
+ entry: values.entry ?? config.entry ?? config.entries
133
+ };
134
+ }
135
+
136
+ function runD2(source, output, options) {
137
+ return new Promise((resolve, reject) => {
138
+ const args = [];
139
+ if (options.layout) args.push("--layout", options.layout);
140
+ if (options.theme !== undefined) args.push("--theme", String(options.theme));
141
+ args.push(source, output);
142
+ const child = spawn("d2", args, { stdio: "inherit", shell: false });
143
+ child.on("error", (error) => {
144
+ reject(
145
+ error.code === "ENOENT"
146
+ ? new Error("D2 is not installed or is not on PATH; use --format d2 or install D2")
147
+ : error
148
+ );
149
+ });
150
+ child.on("exit", (code) => {
151
+ if (code === 0) resolve();
152
+ else reject(new Error(`D2 renderer exited with code ${code}`));
153
+ });
154
+ });
155
+ }
156
+
157
+ function outputPaths(options, command) {
158
+ const requested = options.output ? path.resolve(options.output) : undefined;
159
+ const inferred = requested ? path.extname(requested).slice(1).toLowerCase() : "";
160
+ const format = (
161
+ options.format ?? (inferred || (command === "inspect" ? "json" : "d2"))
162
+ ).toLowerCase();
163
+ if (!["d2", "json", "svg", "png", "pdf"].includes(format)) {
164
+ throw new Error(`unsupported format: ${format}`);
165
+ }
166
+ let finalOutput = requested ?? (
167
+ command === "inspect" && !options.output
168
+ ? undefined
169
+ : path.resolve(`bc-atlas.${format}`)
170
+ );
171
+ if (finalOutput && options.format && inferred !== format) {
172
+ finalOutput = inferred
173
+ ? finalOutput.slice(0, -path.extname(finalOutput).length) + `.${format}`
174
+ : `${finalOutput}.${format}`;
175
+ }
176
+ const d2Output = format === "d2"
177
+ ? finalOutput
178
+ : finalOutput?.replace(/\.[^.]+$/u, "") + ".d2";
179
+ return { format, finalOutput, d2Output };
180
+ }
181
+
182
+ async function build(input, command, cliValues, quiet = false) {
183
+ const config = await loadConfig(input, cliValues.config);
184
+ const options = mergeOptions(config.values, cliValues);
185
+ const direction = options.direction ?? "right";
186
+ const view = options.view ?? "project";
187
+ const groupBy = options["group-by"] ?? options.groupBy ?? (
188
+ view === "project" ? "role" : "namespace"
189
+ );
190
+ if (!["right", "down", "left", "up"].includes(direction)) {
191
+ throw new Error(`unsupported direction: ${direction}`);
192
+ }
193
+ if (!["namespace", "folder", "type", "role"].includes(groupBy)) {
194
+ throw new Error(`unsupported group-by mode: ${groupBy}`);
195
+ }
196
+ if (view === "module" && !["namespace", "folder"].includes(groupBy)) {
197
+ throw new Error("module view supports --group-by namespace or folder");
198
+ }
199
+ const requestedModuleDepth = options["module-depth"] ?? options.moduleDepth ?? "auto";
200
+ const moduleDepth = requestedModuleDepth === "auto"
201
+ ? "auto"
202
+ : positiveInteger(requestedModuleDepth, "module-depth");
203
+
204
+ let model = resolveModel(await analyze(input));
205
+ model = filterModel(model, options);
206
+ model = addInsights(model, options.forbiddenDependencies ?? []);
207
+ const workflow = options.workflow ?? {};
208
+ model = createView(model, view, {
209
+ object: options.object,
210
+ groupBy,
211
+ moduleDepth,
212
+ folderDepth: positiveInteger(
213
+ options["folder-depth"] ?? options.folderDepth,
214
+ "folder-depth",
215
+ 1
216
+ ),
217
+ includeUnresolvedCalls:
218
+ options["include-unresolved-calls"] ?? options.includeUnresolvedCalls ?? false,
219
+ scope: options.scope,
220
+ focus: options.focus,
221
+ entry: options.entry ?? workflow.entry ?? workflow.entries,
222
+ depth: options["workflow-depth"] ?? options.workflowDepth ?? workflow.depth,
223
+ maxNodes:
224
+ options["workflow-max-nodes"] ?? options.workflowMaxNodes ?? workflow.maxNodes,
225
+ maxEdges:
226
+ cliValues["max-edges"] ?? workflow.maxEdges ??
227
+ options["max-edges"] ?? options.maxEdges,
228
+ edgeTypes:
229
+ options["workflow-edge-types"] ?? options.workflowEdgeTypes ?? workflow.edgeTypes,
230
+ phases: options.phases ?? workflow.phases,
231
+ stop: options.stop ?? options.stopConditions ?? workflow.stop ?? workflow.stopConditions,
232
+ collapse:
233
+ options.collapse ?? options.collapseUtilities ??
234
+ workflow.collapse ?? workflow.collapseUtilities
235
+ });
236
+ model = addInsights(model);
237
+ if (!model.objects.length && !model.emptyMessage) {
238
+ throw new Error("no AL objects matched");
239
+ }
240
+
241
+ const seriousDiagnostics = model.diagnostics.filter(
242
+ ({ severity }) => severity === "error" || severity === "warning"
243
+ );
244
+ if (options.strict && seriousDiagnostics.length) {
245
+ throw new Error(
246
+ `${seriousDiagnostics.length} diagnostic(s) in strict mode; run inspect for details`
247
+ );
248
+ }
249
+
250
+ const paths = outputPaths(options, command);
251
+ if (paths.format === "json") {
252
+ const json = JSON.stringify(model, null, 2) + "\n";
253
+ if (paths.finalOutput) {
254
+ await fs.mkdir(path.dirname(paths.finalOutput), { recursive: true });
255
+ await fs.writeFile(paths.finalOutput, json);
256
+ } else {
257
+ process.stdout.write(json);
258
+ }
259
+ } else {
260
+ const source = renderD2(model, {
261
+ direction,
262
+ title: options.title ?? `AL ${view} architecture`,
263
+ includeExternal: !(options["no-external"] ?? options.noExternal ?? false),
264
+ details: options.details ?? false,
265
+ memberNames: view === "object",
266
+ groupBy: view === "module" ? "namespace" : groupBy,
267
+ sourceUrlTemplate: options["source-url"] ?? options.sourceUrl,
268
+ maxEdges: positiveInteger(options["max-edges"] ?? options.maxEdges, "max-edges", 500)
269
+ });
270
+ await fs.mkdir(path.dirname(paths.d2Output), { recursive: true });
271
+ await fs.writeFile(paths.d2Output, source);
272
+ if (paths.format === "svg") {
273
+ const svg = await renderSvg(source, options);
274
+ await fs.mkdir(path.dirname(paths.finalOutput), { recursive: true });
275
+ await fs.writeFile(paths.finalOutput, svg);
276
+ } else if (paths.format !== "d2") {
277
+ await fs.mkdir(path.dirname(paths.finalOutput), { recursive: true });
278
+ await runD2(paths.d2Output, paths.finalOutput, options);
279
+ }
280
+ }
281
+
282
+ if (!quiet && command !== "inspect") {
283
+ const warnings = seriousDiagnostics.length
284
+ ? `, ${seriousDiagnostics.length} diagnostic(s)`
285
+ : "";
286
+ console.log(
287
+ `Analyzed ${model.files} file(s); ${model.objects.length} ${view} node(s), ${model.edges.length} edge(s)${warnings}.`
288
+ );
289
+ if (paths.finalOutput) console.log(`Wrote ${paths.finalOutput}`);
290
+ if (paths.format !== "d2" && paths.format !== "json") {
291
+ console.log(`Kept D2 source at ${paths.d2Output}`);
292
+ }
293
+ }
294
+ return { model, paths, options };
295
+ }
296
+
297
+ async function watch(input, values) {
298
+ const initial = await build(input, "graph", values);
299
+ const debounce = positiveInteger(values.debounce ?? initial.options.debounce, "debounce", 250);
300
+ let timer;
301
+ let building = false;
302
+ let pending = false;
303
+
304
+ const rebuild = async () => {
305
+ if (building) {
306
+ pending = true;
307
+ return;
308
+ }
309
+ building = true;
310
+ try {
311
+ await build(input, "graph", values);
312
+ } catch (error) {
313
+ console.error(`bca: rebuild failed: ${error.message}`);
314
+ } finally {
315
+ building = false;
316
+ if (pending) {
317
+ pending = false;
318
+ void rebuild();
319
+ }
320
+ }
321
+ };
322
+
323
+ const watcher = fsWatch(path.resolve(input), { recursive: true }, (_event, filename) => {
324
+ if (filename && !filename.toLowerCase().endsWith(".al") &&
325
+ path.basename(filename) !== "app.json" &&
326
+ path.basename(filename) !== ".bca.json") return;
327
+ clearTimeout(timer);
328
+ timer = setTimeout(rebuild, debounce);
329
+ });
330
+ watcher.on("error", (error) => console.error(`bca: watcher error: ${error.message}`));
331
+ console.log("Watching for AL changes. Press Ctrl+C to stop.");
332
+ }
333
+
334
+ async function main() {
335
+ if (process.argv[2] === "docs") return docsMain(process.argv.slice(3));
336
+ const { values, positionals } = parseArgs({
337
+ allowPositionals: true,
338
+ strict: true,
339
+ options: OPTIONS
340
+ });
341
+ if (values.help) return console.log(HELP);
342
+ if (values.version) {
343
+ const pkg = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url)));
344
+ return console.log(pkg.version);
345
+ }
346
+
347
+ const commands = new Set(["graph", "inspect", "watch"]);
348
+ const command = commands.has(positionals[0]) ? positionals.shift() : "graph";
349
+ if (positionals.length !== 1) {
350
+ console.log(HELP);
351
+ throw new Error("expected exactly one AL file or project directory");
352
+ }
353
+ if (command === "watch") return watch(positionals[0], values);
354
+ return build(positionals[0], command, values);
355
+ }
356
+
357
+ main().catch((error) => fail(error.message, 2));
package/src/config.js ADDED
@@ -0,0 +1,25 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function loadConfig(input, explicitPath) {
5
+ const inputPath = path.resolve(input);
6
+ const stat = await fs.stat(inputPath);
7
+ const base = stat.isDirectory() ? inputPath : path.dirname(inputPath);
8
+ const configPath = explicitPath
9
+ ? path.resolve(explicitPath)
10
+ : path.join(base, ".bca.json");
11
+ try {
12
+ const text = await fs.readFile(configPath, "utf8");
13
+ const config = JSON.parse(text);
14
+ if (!config || Array.isArray(config) || typeof config !== "object") {
15
+ throw new Error("configuration root must be an object");
16
+ }
17
+ return { path: configPath, values: config };
18
+ } catch (error) {
19
+ if (error.code === "ENOENT" && !explicitPath) return { values: {} };
20
+ if (error instanceof SyntaxError) {
21
+ throw new Error(`Invalid JSON in ${configPath}: ${error.message}`);
22
+ }
23
+ throw error;
24
+ }
25
+ }
package/src/d2.js ADDED
@@ -0,0 +1,264 @@
1
+ import { resolveModel } from "./resolver.js";
2
+
3
+ const TYPE_STYLES = {
4
+ table: ["#DCEBFF", "#2362A2"],
5
+ tableextension: ["#DCEBFF", "#2362A2"],
6
+ page: ["#E5F6E8", "#31783B"],
7
+ pageextension: ["#E5F6E8", "#31783B"],
8
+ pagecustomization: ["#E5F6E8", "#31783B"],
9
+ codeunit: ["#FFF0D6", "#A96300"],
10
+ report: ["#F5E5FF", "#77449A"],
11
+ reportextension: ["#F5E5FF", "#77449A"],
12
+ query: ["#E1F7F5", "#14766E"],
13
+ xmlport: ["#FFE2E2", "#A23636"],
14
+ enum: ["#EEEEEE", "#555555"],
15
+ enumextension: ["#EEEEEE", "#555555"],
16
+ interface: ["#FFF8C9", "#8B7400"],
17
+ module: ["#E8ECF3", "#4E5D78"],
18
+ procedure: ["#F3F6FA", "#55708F"],
19
+ trigger: ["#FFF0D6", "#A96300"],
20
+ event: ["#F5E5FF", "#77449A"],
21
+ subscriber: ["#FCE8F3", "#A33A78"],
22
+ action: ["#E5F6E8", "#31783B"]
23
+ };
24
+
25
+ const RELATION_STYLES = {
26
+ calls: { color: "#4C78A8" },
27
+ reads: { color: "#0072B2" },
28
+ writes: { color: "#D55E00" },
29
+ relates: { color: "#009E73" },
30
+ uses: { color: "#6B7280", dash: 3 },
31
+ extends: { color: "#CC79A7" },
32
+ implements: { color: "#E69F00" },
33
+ selects: { color: "#EA580C" },
34
+ publishes: { color: "#A21CAF" },
35
+ subscribes: { color: "#A21CAF", dash: 3 },
36
+ runs: { color: "#15803D" },
37
+ part: { color: "#0891B2", dash: 3 },
38
+ contains: { color: "#0891B2", dash: 3 },
39
+ permits: { color: "#7C3AED" },
40
+ includes: { color: "#8B5CF6" },
41
+ starts: { color: "#15803D" },
42
+ events: { color: "#A21CAF", dash: 3 }
43
+ };
44
+
45
+ function quote(value) {
46
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n")}"`;
47
+ }
48
+
49
+ function normalized(value) {
50
+ return String(value).replace(/^"|"$/gu, "").trim().toLowerCase();
51
+ }
52
+
53
+ function uniqueRelations(relations) {
54
+ const combined = new Map();
55
+ for (const relation of relations) {
56
+ const key =
57
+ `${relation.from}|${relation.to}|${relation.kind}|${relation.access ?? ""}|` +
58
+ `${relation.label ?? ""}|${relation.sequence ?? ""}|${relation.isCycle ?? ""}`;
59
+ const existing = combined.get(key);
60
+ if (existing) existing.weight = (existing.weight ?? 1) + (relation.weight ?? 1);
61
+ else combined.set(key, { ...relation });
62
+ }
63
+ return [...combined.values()];
64
+ }
65
+
66
+ function roleFor(type) {
67
+ if (["table", "tableextension"].includes(type)) return "Data";
68
+ if (["page", "pageextension", "pagecustomization"].includes(type)) return "UI";
69
+ if (["codeunit"].includes(type)) return "Services";
70
+ if (["interface", "enum", "enumextension"].includes(type)) return "Contracts";
71
+ if (["permissionset", "permissionsetextension", "entitlement"].includes(type)) return "Security";
72
+ if (["report", "reportextension", "query", "xmlport"].includes(type)) {
73
+ return "Reporting & Integration";
74
+ }
75
+ return "Other";
76
+ }
77
+
78
+ function groupFor(object, groupBy) {
79
+ if (object.viewGroup) return object.viewGroup;
80
+ if (groupBy === "type") return object.type;
81
+ if (groupBy === "role") return roleFor(object.type);
82
+ if (groupBy === "folder") {
83
+ const parts = object.file.replaceAll("\\", "/").split("/");
84
+ return parts.length > 1 ? parts[0] : "(root)";
85
+ }
86
+ return object.namespace;
87
+ }
88
+
89
+ function memberSummary(items, limit = 8) {
90
+ const names = items.map(({ name }) => name).filter(Boolean);
91
+ const shown = names.slice(0, limit);
92
+ if (names.length > limit) shown.push(`+${names.length - limit} more`);
93
+ return shown.join(", ");
94
+ }
95
+
96
+ function relationStyle(kind, sequence, isCycle) {
97
+ const style = RELATION_STYLES[kind] ?? { color: "#64748B" };
98
+ const properties = [`style.stroke: ${quote(isCycle ? "#B91C1C" : style.color)}`];
99
+ if (style.dash || sequence === "inferred") {
100
+ properties.push(`style.stroke-dash: ${style.dash ?? 4}`);
101
+ }
102
+ if (isCycle) properties.push("style.stroke-width: 3");
103
+ return ` {${properties.join("; ")}}`;
104
+ }
105
+
106
+ export function renderD2(model, options = {}) {
107
+ if (!model.edges) model = resolveModel(model);
108
+ const direction = options.direction ?? "right";
109
+ const includeExternal = options.includeExternal ?? true;
110
+ const maxEdges = Number(options.maxEdges ?? 500);
111
+ const lines = [
112
+ `direction: ${direction}`,
113
+ `title: ${quote(options.title ?? "AL architecture")} {`,
114
+ " shape: text",
115
+ " near: top-center",
116
+ " style.font-size: 28",
117
+ "}",
118
+ ""
119
+ ];
120
+ const nodes = new Map();
121
+ const keyIndex = new Map();
122
+
123
+ model.objects.forEach((object, index) => {
124
+ const key = `n${index}`;
125
+ nodes.set(object, key);
126
+ keyIndex.set(object.key, object);
127
+ });
128
+
129
+ if (!model.objects.length && model.emptyMessage) {
130
+ lines.push(`empty: ${quote(model.emptyMessage)} {`);
131
+ lines.push(" shape: text");
132
+ lines.push(' style.font-color: "#666666"');
133
+ lines.push("}", "");
134
+ }
135
+
136
+ const namespaces = new Map();
137
+ for (const object of model.objects) {
138
+ const group = groupFor(object, options.groupBy ?? "namespace");
139
+ const list = namespaces.get(group) ?? [];
140
+ list.push(object);
141
+ namespaces.set(group, list);
142
+ }
143
+
144
+ let groupIndex = 0;
145
+ for (const [namespace, objects] of [...namespaces].sort(([a], [b]) => a.localeCompare(b))) {
146
+ const group = `g${groupIndex++}`;
147
+ lines.push(`${group}: ${quote(namespace)} {`);
148
+ lines.push(" style.stroke-dash: 3");
149
+ for (const object of objects.sort((a, b) => a.name.localeCompare(b.name))) {
150
+ const key = nodes.get(object);
151
+ const label = object.id
152
+ ? `${object.type} ${object.id}\n${object.name}`
153
+ : `${object.type}\n${object.name}`;
154
+ const details = options.details
155
+ ? [
156
+ object.members?.length ? `${object.members.length} objects` : undefined,
157
+ object.fields?.length ? `${object.fields.length} fields` : undefined,
158
+ object.actions?.length ? `${object.actions.length} actions` : undefined,
159
+ object.procedures?.length ? `${object.procedures.length} procedures` : undefined,
160
+ options.memberNames && object.isFocus && object.fields?.length
161
+ ? `Fields: ${memberSummary(object.fields)}`
162
+ : undefined,
163
+ options.memberNames && object.isFocus && object.actions?.length
164
+ ? `Actions: ${memberSummary(object.actions)}`
165
+ : undefined,
166
+ options.memberNames && object.isFocus && object.procedures?.length
167
+ ? `Procedures: ${memberSummary(object.procedures, 10)}`
168
+ : undefined
169
+ ].filter(Boolean)
170
+ : [];
171
+ const annotations = [
172
+ object.workflowEntry ? "entry" : undefined,
173
+ object.cycle ? `cycle ${object.cycle}` : undefined,
174
+ object.shared ? `shared by ${object.shared} branches` : undefined
175
+ ].filter(Boolean);
176
+ const fullDetails = [...details, ...annotations];
177
+ const fullLabel = fullDetails.length ? `${label}\n${fullDetails.join(" • ")}` : label;
178
+ const [fill, stroke] = TYPE_STYLES[object.type] ?? ["#F5F5F5", "#666666"];
179
+ lines.push(` ${key}: ${quote(fullLabel)} {`);
180
+ lines.push(" shape: rectangle");
181
+ const source = object.location?.line
182
+ ? `${object.file}:${object.location.line}`
183
+ : object.file;
184
+ if (source) lines.push(` tooltip: ${quote(source)}`);
185
+ if (options.sourceUrlTemplate && object.file) {
186
+ const link = options.sourceUrlTemplate
187
+ .replaceAll("{file}", object.file.replaceAll("\\", "/"))
188
+ .replaceAll("{line}", String(object.location?.line ?? 1));
189
+ lines.push(` link: ${quote(link)}`);
190
+ }
191
+ lines.push(` style.fill: ${quote(fill)}`);
192
+ lines.push(` style.stroke: ${quote(stroke)}`);
193
+ if (object.type.endsWith("extension")) lines.push(" style.stroke-dash: 4");
194
+ lines.push(" }");
195
+ }
196
+ lines.push("}", "");
197
+ for (const object of objects) nodes.set(object, `${group}.${nodes.get(object)}`);
198
+ }
199
+
200
+ const external = new Map();
201
+ const edges = [];
202
+ for (const edge of model.edges.slice(0, maxEdges)) {
203
+ const source = keyIndex.get(edge.from);
204
+ const target = edge.to ? keyIndex.get(edge.to) : undefined;
205
+ if (!source) continue;
206
+ let targetKey = target ? nodes.get(target) : undefined;
207
+ if (!targetKey && includeExternal && edge.unresolved) {
208
+ const externalKey = `${edge.unresolved.type}:${normalized(edge.unresolved.name)}`;
209
+ if (!external.has(externalKey)) external.set(externalKey, `external.e${external.size}`);
210
+ targetKey = external.get(externalKey);
211
+ }
212
+ if (targetKey) {
213
+ edges.push({
214
+ from: nodes.get(source),
215
+ to: targetKey,
216
+ kind: edge.kind,
217
+ access: edge.access,
218
+ label: edge.label,
219
+ weight: edge.weight,
220
+ sequence: edge.sequence,
221
+ isCycle: edge.isCycle
222
+ });
223
+ }
224
+ }
225
+
226
+ if (external.size) {
227
+ lines.push(`external: ${quote("External / unresolved")} {`);
228
+ lines.push(" style.stroke-dash: 5");
229
+ for (const [lookup, key] of external) {
230
+ const [type, ...nameParts] = lookup.split(":");
231
+ const localKey = key.split(".")[1];
232
+ lines.push(` ${localKey}: ${quote(`${type}\n${nameParts.join(":")}`)} {`);
233
+ lines.push(` style.fill: ${quote("#FAFAFA")}`);
234
+ lines.push(` style.stroke: ${quote("#999999")}`);
235
+ lines.push(" }");
236
+ }
237
+ lines.push("}", "");
238
+ }
239
+
240
+ for (const edge of uniqueRelations(edges)) {
241
+ const arrow = edge.kind === "extends" || edge.kind === "implements" ? "-->" : "->";
242
+ const relationLabel = edge.label ?? edge.kind;
243
+ const certainty = edge.sequence ? ` [${edge.sequence}]` : "";
244
+ const cycle = edge.isCycle ? " [cycle]" : "";
245
+ const baseLabel =
246
+ `${edge.access ? `${relationLabel} [${edge.access}]` : relationLabel}${certainty}${cycle}`;
247
+ const label = edge.weight && edge.weight > 1
248
+ ? `${baseLabel} (${edge.weight})`
249
+ : baseLabel;
250
+ lines.push(
251
+ `${edge.from} ${arrow} ${edge.to}: ${quote(label)}${relationStyle(
252
+ edge.kind,
253
+ edge.sequence,
254
+ edge.isCycle
255
+ )}`
256
+ );
257
+ }
258
+
259
+ if (model.edges.length > maxEdges) {
260
+ lines.push("", `# ${model.edges.length - maxEdges} edges omitted by maxEdges=${maxEdges}`);
261
+ }
262
+ lines.push("");
263
+ return lines.join("\n");
264
+ }