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/workflow.js
ADDED
|
@@ -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
|
+
}
|
package/vendor/README.md
ADDED
|
@@ -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
|