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.
@@ -0,0 +1,569 @@
1
+ import { normalizeIdentifier } from "./resolver.js";
2
+
3
+ const DEFAULT_EDGE_TYPES = ["calls", "events", "writes"];
4
+ const WRITE_OPERATIONS = /^(insert|modify|modifyall|delete|deleteall|rename)$/iu;
5
+ const READ_OPERATIONS = /^(get|find|findfirst|findlast|findset|isempty|count|calcfields|calcsums|next)$/iu;
6
+
7
+ function asArray(value) {
8
+ if (value === undefined || value === null) return [];
9
+ return Array.isArray(value) ? value : [value];
10
+ }
11
+
12
+ function positiveInteger(value, name, fallback) {
13
+ const number = value === undefined ? fallback : Number(value);
14
+ if (!Number.isInteger(number) || number < 1) {
15
+ throw new Error(`${name} must be a positive integer`);
16
+ }
17
+ return number;
18
+ }
19
+
20
+ function globToRegExp(pattern) {
21
+ const escaped = String(pattern)
22
+ .replace(/[.+^${}()|[\]\\]/gu, "\\$&")
23
+ .replaceAll("**", "\u0000")
24
+ .replaceAll("*", ".*")
25
+ .replaceAll("\u0000", ".*")
26
+ .replaceAll("?", ".");
27
+ return new RegExp(`^${escaped}$`, "iu");
28
+ }
29
+
30
+ function selectorParts(selector) {
31
+ const match = String(selector).match(/^(procedure|trigger|action|event):(.*)$/iu);
32
+ return match
33
+ ? { type: match[1].toLowerCase(), pattern: match[2] }
34
+ : { pattern: String(selector) };
35
+ }
36
+
37
+ function nodeMatches(node, selector) {
38
+ const { type, pattern } = selectorParts(selector);
39
+ if (type && node.type !== type) return false;
40
+ const normalizedPattern = normalizeIdentifier(pattern);
41
+ const values = [
42
+ node.name,
43
+ node.ownerName ? `${node.ownerName}.${node.name}` : undefined,
44
+ node.key
45
+ ].filter(Boolean).map(normalizeIdentifier);
46
+ if (normalizedPattern.includes("*") || normalizedPattern.includes("?")) {
47
+ const expression = globToRegExp(normalizedPattern);
48
+ return values.some((value) => expression.test(value));
49
+ }
50
+ return values.some((value) => value === normalizedPattern);
51
+ }
52
+
53
+ function normalizeEdgeTypes(value) {
54
+ const types = asArray(value)
55
+ .flatMap((item) => String(item).split(","))
56
+ .map((item) => item.trim().toLowerCase())
57
+ .filter(Boolean);
58
+ const allowed = new Set(["calls", "events", "writes", "reads"]);
59
+ const result = types.length ? types : DEFAULT_EDGE_TYPES;
60
+ const invalid = result.filter((kind) => !allowed.has(kind));
61
+ if (invalid.length) {
62
+ throw new Error(
63
+ `Unsupported workflow edge type(s): ${invalid.join(", ")}; use calls, events, writes, or reads`
64
+ );
65
+ }
66
+ return new Set(result);
67
+ }
68
+
69
+ function procedureKey(owner, procedure, index) {
70
+ const suffix = procedure.location?.line ?? index;
71
+ return `${owner.key}::${procedure.kind}::${normalizeIdentifier(procedure.name)}::${suffix}`;
72
+ }
73
+
74
+ function actionKey(owner, action, index) {
75
+ const suffix = action.location?.line ?? index;
76
+ return `${owner.key}::action::${normalizeIdentifier(action.name)}::${suffix}`;
77
+ }
78
+
79
+ function makeNode(owner, member, key, type = member.kind) {
80
+ return {
81
+ key,
82
+ name: member.name,
83
+ ownerName: owner.name,
84
+ type,
85
+ namespace: owner.namespace,
86
+ file: owner.file,
87
+ location: member.location,
88
+ relations: []
89
+ };
90
+ }
91
+
92
+ function targetOwnerFor(call, owner, objectIndex) {
93
+ if (!call.receiver) return owner;
94
+ const variable = (owner.variables ?? []).find(
95
+ ({ name }) => normalizeIdentifier(name) === normalizeIdentifier(call.receiver)
96
+ );
97
+ if (variable?.target) {
98
+ return objectIndex.get(`${variable.targetType}:${normalizeIdentifier(variable.target)}`)
99
+ ?? objectIndex.get(`${variable.targetType}:${variable.target}`);
100
+ }
101
+ return objectIndex.get(`codeunit:${normalizeIdentifier(call.receiver)}`);
102
+ }
103
+
104
+ function recordVariableFor(call, owner) {
105
+ if (!call.receiver) return undefined;
106
+ return (owner.variables ?? []).find(
107
+ (variable) =>
108
+ variable.targetType === "record" &&
109
+ normalizeIdentifier(variable.name) === normalizeIdentifier(call.receiver)
110
+ );
111
+ }
112
+
113
+ function addCallEdges({
114
+ edges,
115
+ from,
116
+ calls,
117
+ owner,
118
+ objectIndex,
119
+ procedureIndex,
120
+ edgeTypes
121
+ }) {
122
+ for (const [index, call] of (calls ?? []).entries()) {
123
+ const record = recordVariableFor(call, owner);
124
+ const operationKind = WRITE_OPERATIONS.test(call.name)
125
+ ? "writes"
126
+ : READ_OPERATIONS.test(call.name)
127
+ ? "reads"
128
+ : undefined;
129
+ if (record && operationKind) {
130
+ if (!edgeTypes.has(operationKind)) continue;
131
+ const table = objectIndex.get(`table:${normalizeIdentifier(record.target)}`)
132
+ ?? objectIndex.get(`table:${record.target}`);
133
+ edges.push({
134
+ id: `workflow${edges.length}`,
135
+ from,
136
+ to: table?.key,
137
+ unresolved: table ? undefined : { name: record.target, type: "table" },
138
+ kind: operationKind,
139
+ label: `${call.name} #${index + 1}`,
140
+ confidence: table ? "resolved" : "syntactic",
141
+ sequence: "definite",
142
+ order: index + 1,
143
+ location: call.location
144
+ });
145
+ continue;
146
+ }
147
+ if (!edgeTypes.has("calls")) continue;
148
+ const targetOwner = targetOwnerFor(call, owner, objectIndex);
149
+ const candidates = targetOwner
150
+ ? procedureIndex.get(`${targetOwner.key}:${normalizeIdentifier(call.name)}`) ?? []
151
+ : [];
152
+ const to = candidates.length === 1 ? candidates[0] : undefined;
153
+ edges.push({
154
+ id: `workflow${edges.length}`,
155
+ from,
156
+ to: to?.key,
157
+ unresolved: to ? undefined : {
158
+ name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
159
+ type: candidates.length > 1 ? "ambiguous procedure" : "procedure"
160
+ },
161
+ kind: "calls",
162
+ label: `calls #${index + 1}`,
163
+ confidence: to ? "resolved" : candidates.length > 1 ? "ambiguous" : "syntactic",
164
+ sequence: "definite",
165
+ order: index + 1,
166
+ location: call.location
167
+ });
168
+ }
169
+ }
170
+
171
+ function phaseRules(phases) {
172
+ if (Array.isArray(phases)) {
173
+ return phases.map((phase) => ({
174
+ label: phase.label ?? phase.name,
175
+ match: asArray(phase.match ?? phase.matches ?? phase.pattern)
176
+ })).filter(({ label, match }) => label && match.length);
177
+ }
178
+ if (phases && typeof phases === "object") {
179
+ return Object.entries(phases).map(([label, match]) => ({
180
+ label,
181
+ match: asArray(match)
182
+ }));
183
+ }
184
+ return [];
185
+ }
186
+
187
+ function applyPhases(objects, phases) {
188
+ const rules = phaseRules(phases);
189
+ return objects.map((object) => {
190
+ const phase = rules.find((rule) =>
191
+ rule.match.some((selector) => nodeMatches(object, selector))
192
+ );
193
+ return { ...object, viewGroup: phase?.label ?? object.viewGroup ?? "Workflow" };
194
+ });
195
+ }
196
+
197
+ function stopSelectors(condition) {
198
+ if (
199
+ condition &&
200
+ typeof condition === "object" &&
201
+ !Array.isArray(condition)
202
+ ) {
203
+ return asArray(condition.match ?? condition.pattern);
204
+ }
205
+ return asArray(condition);
206
+ }
207
+
208
+ function collapsedGraph(objects, edges, selectors, entryKeys) {
209
+ const collapsed = new Set(
210
+ objects
211
+ .filter((node) =>
212
+ !entryKeys.has(node.key) &&
213
+ !["action", "event", "table"].includes(node.type) &&
214
+ selectors.some((selector) => nodeMatches(node, selector))
215
+ )
216
+ .map(({ key }) => key)
217
+ );
218
+ if (!collapsed.size) return { objects, edges, collapsed: [] };
219
+
220
+ let result = [...edges];
221
+ for (const key of collapsed) {
222
+ const node = objects.find((candidate) => candidate.key === key);
223
+ const incoming = result.filter(({ to }) => to === key);
224
+ const outgoing = result.filter(({ from }) => from === key);
225
+ const bypasses = [];
226
+ for (const before of incoming) {
227
+ for (const after of outgoing) {
228
+ if (!after.to || before.from === after.to) continue;
229
+ bypasses.push({
230
+ ...after,
231
+ id: `workflow-collapse-${key}-${bypasses.length}`,
232
+ from: before.from,
233
+ label: `via ${node.name}: ${after.label ?? after.kind}`,
234
+ confidence: "inferred",
235
+ sequence: "inferred",
236
+ collapsed: [node.key]
237
+ });
238
+ }
239
+ }
240
+ result = result
241
+ .filter(({ from, to }) => from !== key && to !== key)
242
+ .concat(bypasses);
243
+ }
244
+ return {
245
+ objects: objects.filter(({ key }) => !collapsed.has(key)),
246
+ edges: result,
247
+ collapsed: [...collapsed]
248
+ };
249
+ }
250
+
251
+ function selectProjection(objects, edges, seeds, options) {
252
+ const depthLimit = positiveInteger(options.depth, "workflow depth", 8);
253
+ const nodeLimit = positiveInteger(options.maxNodes, "workflow maxNodes", 100);
254
+ const edgeLimit = positiveInteger(options.maxEdges, "workflow maxEdges", 250);
255
+ const stop = asArray(options.stop ?? options.stopConditions);
256
+ const objectIndex = new Map(objects.map((object) => [object.key, object]));
257
+ const outgoing = new Map();
258
+ for (const edge of edges) {
259
+ const list = outgoing.get(edge.from) ?? [];
260
+ list.push(edge);
261
+ outgoing.set(edge.from, list);
262
+ }
263
+ for (const list of outgoing.values()) {
264
+ list.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
265
+ }
266
+
267
+ const selected = new Set();
268
+ const selectedEdges = [];
269
+ const queue = seeds.map((key) => ({ key, depth: 0 }));
270
+ let truncatedByNodes = false;
271
+ let truncatedByEdges = false;
272
+ while (queue.length) {
273
+ const { key, depth } = queue.shift();
274
+ if (selected.has(key)) continue;
275
+ if (selected.size >= nodeLimit) {
276
+ truncatedByNodes = true;
277
+ break;
278
+ }
279
+ selected.add(key);
280
+ const node = objectIndex.get(key);
281
+ if (
282
+ depth >= depthLimit ||
283
+ (node && stop.some((condition) =>
284
+ stopSelectors(condition)
285
+ .some((selector) => nodeMatches(node, selector))
286
+ ))
287
+ ) continue;
288
+ for (const edge of outgoing.get(key) ?? []) {
289
+ if (selectedEdges.length >= edgeLimit) {
290
+ truncatedByEdges = true;
291
+ break;
292
+ }
293
+ selectedEdges.push(edge);
294
+ if (edge.to && !selected.has(edge.to)) queue.push({ key: edge.to, depth: depth + 1 });
295
+ }
296
+ if (truncatedByEdges) break;
297
+ }
298
+ return {
299
+ objects: objects.filter(({ key }) => selected.has(key)),
300
+ edges: selectedEdges
301
+ .filter(({ from }) => selected.has(from))
302
+ .map((edge) => {
303
+ if (!edge.to || selected.has(edge.to)) return edge;
304
+ const target = objectIndex.get(edge.to);
305
+ return {
306
+ ...edge,
307
+ to: undefined,
308
+ unresolved: {
309
+ name: target?.ownerName
310
+ ? `${target.ownerName}.${target.name}`
311
+ : target?.name ?? "truncated branch",
312
+ type: "workflow limit"
313
+ }
314
+ };
315
+ }),
316
+ limits: {
317
+ depth: depthLimit,
318
+ maxNodes: nodeLimit,
319
+ maxEdges: edgeLimit,
320
+ truncatedByNodes,
321
+ truncatedByEdges
322
+ }
323
+ };
324
+ }
325
+
326
+ function markCycles(objects, edges) {
327
+ const keys = new Set(objects.map(({ key }) => key));
328
+ const adjacency = new Map([...keys].map((key) => [key, []]));
329
+ for (const edge of edges) {
330
+ if (edge.to && keys.has(edge.from) && keys.has(edge.to)) {
331
+ adjacency.get(edge.from).push(edge.to);
332
+ }
333
+ }
334
+ let currentIndex = 0;
335
+ const indexes = new Map();
336
+ const lowLinks = new Map();
337
+ const stack = [];
338
+ const onStack = new Set();
339
+ const components = [];
340
+
341
+ function visit(key) {
342
+ indexes.set(key, currentIndex);
343
+ lowLinks.set(key, currentIndex++);
344
+ stack.push(key);
345
+ onStack.add(key);
346
+ for (const target of adjacency.get(key)) {
347
+ if (!indexes.has(target)) {
348
+ visit(target);
349
+ lowLinks.set(key, Math.min(lowLinks.get(key), lowLinks.get(target)));
350
+ } else if (onStack.has(target)) {
351
+ lowLinks.set(key, Math.min(lowLinks.get(key), indexes.get(target)));
352
+ }
353
+ }
354
+ if (lowLinks.get(key) !== indexes.get(key)) return;
355
+ const component = [];
356
+ let member;
357
+ do {
358
+ member = stack.pop();
359
+ onStack.delete(member);
360
+ component.push(member);
361
+ } while (member !== key);
362
+ if (
363
+ component.length > 1 ||
364
+ edges.some(({ from, to }) => from === key && to === key)
365
+ ) components.push(component);
366
+ }
367
+
368
+ for (const key of keys) if (!indexes.has(key)) visit(key);
369
+ const cycleByKey = new Map();
370
+ components.forEach((component, index) => {
371
+ for (const key of component) cycleByKey.set(key, index + 1);
372
+ });
373
+ return {
374
+ objects: objects.map((object) =>
375
+ cycleByKey.has(object.key)
376
+ ? { ...object, cycle: cycleByKey.get(object.key) }
377
+ : object
378
+ ),
379
+ edges: edges.map((edge) =>
380
+ edge.to &&
381
+ cycleByKey.get(edge.from) === cycleByKey.get(edge.to) &&
382
+ cycleByKey.has(edge.from)
383
+ ? { ...edge, isCycle: true }
384
+ : edge
385
+ ),
386
+ cycles: components
387
+ };
388
+ }
389
+
390
+ export function workflowView(model, options = {}) {
391
+ const edgeTypes = normalizeEdgeTypes(options.edgeTypes);
392
+ const objectIndex = new Map();
393
+ const nodes = [];
394
+ const procedureIndex = new Map();
395
+ const procedureRecords = [];
396
+ const actionRecords = [];
397
+
398
+ for (const owner of model.objects) {
399
+ objectIndex.set(`${owner.type}:${normalizeIdentifier(owner.name)}`, owner);
400
+ if (owner.id) objectIndex.set(`${owner.type}:${owner.id}`, owner);
401
+ for (const [index, procedure] of (owner.procedures ?? []).entries()) {
402
+ const node = makeNode(owner, procedure, procedureKey(owner, procedure, index));
403
+ nodes.push(node);
404
+ procedureRecords.push({ owner, procedure, node });
405
+ const lookup = `${owner.key}:${normalizeIdentifier(procedure.name)}`;
406
+ const candidates = procedureIndex.get(lookup) ?? [];
407
+ candidates.push(node);
408
+ procedureIndex.set(lookup, candidates);
409
+ }
410
+ for (const [index, action] of (owner.actions ?? []).entries()) {
411
+ const node = makeNode(owner, action, actionKey(owner, action, index), "action");
412
+ nodes.push(node);
413
+ actionRecords.push({ owner, action, node });
414
+ }
415
+ }
416
+
417
+ const edges = [];
418
+ for (const { owner, procedure, node } of procedureRecords) {
419
+ addCallEdges({
420
+ edges,
421
+ from: node.key,
422
+ calls: procedure.calls,
423
+ owner,
424
+ objectIndex,
425
+ procedureIndex,
426
+ edgeTypes
427
+ });
428
+ }
429
+ for (const { owner, action, node } of actionRecords) {
430
+ const trigger = procedureRecords.find(
431
+ (record) =>
432
+ record.owner.key === owner.key &&
433
+ record.procedure.kind === "trigger" &&
434
+ normalizeIdentifier(record.procedure.action) === normalizeIdentifier(action.name)
435
+ );
436
+ if (trigger) {
437
+ edges.push({
438
+ id: `workflow${edges.length}`,
439
+ from: node.key,
440
+ to: trigger.node.key,
441
+ kind: "starts",
442
+ label: "starts",
443
+ confidence: "resolved",
444
+ sequence: "definite",
445
+ order: 0
446
+ });
447
+ } else {
448
+ addCallEdges({
449
+ edges,
450
+ from: node.key,
451
+ calls: action.calls,
452
+ owner,
453
+ objectIndex,
454
+ procedureIndex,
455
+ edgeTypes
456
+ });
457
+ }
458
+ }
459
+
460
+ if (edgeTypes.has("events")) {
461
+ for (const { owner, procedure, node } of procedureRecords) {
462
+ for (const attribute of procedure.attributes ?? []) {
463
+ const match = attribute.match(
464
+ /EventSubscriber\s*\(\s*ObjectType::(\w+)\s*,\s*(?:\w+::)?(?:"([^"]+)"|([^,\s]+))\s*,\s*'([^']+)'/iu
465
+ );
466
+ if (!match) continue;
467
+ const publisherOwner = objectIndex.get(
468
+ `${match[1].toLowerCase()}:${normalizeIdentifier(match[2] ?? match[3])}`
469
+ );
470
+ const publisherCandidates = publisherOwner
471
+ ? procedureIndex.get(
472
+ `${publisherOwner.key}:${normalizeIdentifier(match[4])}`
473
+ ) ?? []
474
+ : [];
475
+ const publisher = publisherCandidates.length === 1 ? publisherCandidates[0] : undefined;
476
+ edges.push({
477
+ id: `workflow${edges.length}`,
478
+ from: publisher?.key ?? node.key,
479
+ to: publisher ? node.key : undefined,
480
+ unresolved: publisher ? undefined : {
481
+ name: `${match[2] ?? match[3]}.${match[4]}`,
482
+ type: "event publisher"
483
+ },
484
+ kind: "events",
485
+ label: publisher ? "dispatches event" : "subscribes to unresolved event",
486
+ confidence: publisher ? "inferred" : "syntactic",
487
+ sequence: "inferred",
488
+ location: procedure.location
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ const dataKeys = new Set(
495
+ edges
496
+ .filter(({ kind, to }) => ["writes", "reads"].includes(kind) && to)
497
+ .map(({ to }) => to)
498
+ );
499
+ for (const object of model.objects) {
500
+ if (dataKeys.has(object.key)) {
501
+ nodes.push({ ...object, ownerName: object.name, viewGroup: "Data mutations" });
502
+ }
503
+ }
504
+
505
+ const entrySelectors = asArray(options.entries ?? options.entry);
506
+ let seeds = entrySelectors.length
507
+ ? nodes
508
+ .filter((node) => entrySelectors.some((selector) => nodeMatches(node, selector)))
509
+ .map(({ key }) => key)
510
+ : [];
511
+ if (entrySelectors.length && !seeds.length) {
512
+ throw new Error(`Workflow entry did not match: ${entrySelectors.join(", ")}`);
513
+ }
514
+ if (!seeds.length) {
515
+ const inbound = new Set(edges.map(({ to }) => to).filter(Boolean));
516
+ seeds = nodes
517
+ .filter((node) =>
518
+ node.type === "action" ||
519
+ node.type === "trigger" ||
520
+ node.type === "event" ||
521
+ (node.type === "procedure" && !inbound.has(node.key))
522
+ )
523
+ .map(({ key }) => key);
524
+ }
525
+ const entryKeys = new Set(seeds);
526
+ const collapsed = collapsedGraph(
527
+ nodes,
528
+ edges,
529
+ asArray(options.collapse ?? options.collapseUtilities),
530
+ entryKeys
531
+ );
532
+ const projection = selectProjection(
533
+ collapsed.objects,
534
+ collapsed.edges,
535
+ seeds.filter((key) => collapsed.objects.some((node) => node.key === key)),
536
+ options
537
+ );
538
+ const cycled = markCycles(projection.objects, projection.edges);
539
+ const inboundCount = new Map();
540
+ for (const edge of cycled.edges) {
541
+ if (edge.to) inboundCount.set(edge.to, (inboundCount.get(edge.to) ?? 0) + 1);
542
+ }
543
+ const objects = applyPhases(
544
+ cycled.objects.map((object) => ({
545
+ ...object,
546
+ isFocus: entryKeys.has(object.key),
547
+ workflowEntry: entryKeys.has(object.key),
548
+ shared: (inboundCount.get(object.key) ?? 0) > 1
549
+ ? inboundCount.get(object.key)
550
+ : undefined
551
+ })),
552
+ options.phases
553
+ );
554
+
555
+ return {
556
+ ...model,
557
+ titleSuffix: entrySelectors.length ? entrySelectors.join(", ") : "inferred entry points",
558
+ emptyMessage: "No workflow entry points found",
559
+ objects,
560
+ edges: cycled.edges,
561
+ workflow: {
562
+ entries: seeds,
563
+ edgeTypes: [...edgeTypes],
564
+ collapsed: collapsed.collapsed,
565
+ cycles: cycled.cycles,
566
+ ...projection.limits
567
+ }
568
+ };
569
+ }
@@ -0,0 +1,12 @@
1
+ # Vendored parser artifact
2
+
3
+ `tree-sitter-al.wasm` is built and published by
4
+ [`SShadowS/tree-sitter-al`](https://github.com/SShadowS/tree-sitter-al).
5
+
6
+ - Pinned version: `v3.0.1`
7
+ - License: MIT
8
+ - Update command: `npm run update:grammar -- vX.Y.Z`
9
+
10
+ The binary is vendored so users do not need a Rust/C++ compiler or a network
11
+ request at runtime. When updating it, run the full AST fixture test suite and
12
+ update the pinned version above.
Binary file