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/.bca.example.json +38 -0
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/docs/al-ui-test-documentation.md +126 -0
- package/docs/cli-reference.md +242 -0
- package/examples/README.md +32 -0
- package/examples/output/project.d2 +86 -0
- package/examples/output/project.svg +225 -0
- package/examples/output/ui.d2 +47 -0
- package/examples/output/ui.svg +166 -0
- package/examples/output/workflow.d2 +56 -0
- package/examples/output/workflow.svg +194 -0
- package/examples/warehouse-app/Automation.al +24 -0
- package/examples/warehouse-app/Inventory.al +46 -0
- package/examples/warehouse-app/WarehouseUI.al +29 -0
- package/examples/warehouse-app/app.json +8 -0
- package/package.json +59 -0
- package/scripts/generate-examples.js +56 -0
- package/scripts/update-grammar.js +22 -0
- package/src/analyzer.js +571 -0
- package/src/cli.js +357 -0
- package/src/config.js +25 -0
- package/src/d2.js +264 -0
- package/src/docs/al-ui-source.js +484 -0
- package/src/docs/cli.js +45 -0
- package/src/docs/markdown.js +77 -0
- package/src/insights.js +102 -0
- package/src/resolver.js +114 -0
- package/src/svg.js +42 -0
- package/src/views.js +674 -0
- package/src/workflow.js +569 -0
- package/vendor/README.md +12 -0
- package/vendor/tree-sitter-al.wasm +0 -0
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
|
+
}
|