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,571 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Language, Parser } from "web-tree-sitter";
5
+
6
+ const GRAMMAR_PATH = fileURLToPath(
7
+ new URL("../vendor/tree-sitter-al.wasm", import.meta.url)
8
+ );
9
+ let languagePromise;
10
+
11
+ async function getLanguage() {
12
+ if (!languagePromise) {
13
+ languagePromise = (async () => {
14
+ await Parser.init();
15
+ return Language.load(GRAMMAR_PATH);
16
+ })();
17
+ }
18
+ return languagePromise;
19
+ }
20
+
21
+ const OBJECT_TYPES = new Map([
22
+ ["table_declaration", "table"],
23
+ ["tableextension_declaration", "tableextension"],
24
+ ["page_declaration", "page"],
25
+ ["pageextension_declaration", "pageextension"],
26
+ ["pagecustomization_declaration", "pagecustomization"],
27
+ ["codeunit_declaration", "codeunit"],
28
+ ["report_declaration", "report"],
29
+ ["reportextension_declaration", "reportextension"],
30
+ ["query_declaration", "query"],
31
+ ["xmlport_declaration", "xmlport"],
32
+ ["enum_declaration", "enum"],
33
+ ["enumextension_declaration", "enumextension"],
34
+ ["interface_declaration", "interface"],
35
+ ["controladdin_declaration", "controladdin"],
36
+ ["profile_declaration", "profile"],
37
+ ["profileextension_declaration", "profileextension"],
38
+ ["permissionset_declaration", "permissionset"],
39
+ ["permissionsetextension_declaration", "permissionsetextension"],
40
+ ["entitlement_declaration", "entitlement"]
41
+ ]);
42
+
43
+ const EXTENSION_TARGET = {
44
+ tableextension: "table",
45
+ pageextension: "page",
46
+ reportextension: "report",
47
+ enumextension: "enum",
48
+ profileextension: "profile",
49
+ permissionsetextension: "permissionset",
50
+ pagecustomization: "page"
51
+ };
52
+
53
+ const IGNORED_DIRECTORIES = new Set([
54
+ ".git",
55
+ ".alpackages",
56
+ ".snapshots",
57
+ "node_modules",
58
+ "dist",
59
+ "out"
60
+ ]);
61
+
62
+ function cleanName(value) {
63
+ const text = value.trim();
64
+ return text.startsWith('"') && text.endsWith('"')
65
+ ? text.slice(1, -1).replaceAll('""', '"')
66
+ : text;
67
+ }
68
+
69
+ function fieldText(node, field, source) {
70
+ const child = node.childForFieldName(field);
71
+ return child ? cleanName(source.slice(child.startIndex, child.endIndex)) : undefined;
72
+ }
73
+
74
+ function locationFor(node, file) {
75
+ return {
76
+ file,
77
+ line: node.startPosition.row + 1,
78
+ column: node.startPosition.column + 1,
79
+ endLine: node.endPosition.row + 1,
80
+ endColumn: node.endPosition.column + 1
81
+ };
82
+ }
83
+
84
+ function walk(node, visit) {
85
+ visit(node);
86
+ for (const child of node.namedChildren) {
87
+ walk(child, visit);
88
+ }
89
+ }
90
+
91
+ function tableRelationTargets(value) {
92
+ const targets = [];
93
+ const seen = new Set();
94
+ const add = (raw) => {
95
+ const target = cleanName(raw);
96
+ const normalized = target.toLowerCase();
97
+ if (!target || ["if", "else", "where", "const", "field"].includes(normalized)) return;
98
+ if (!seen.has(normalized)) {
99
+ seen.add(normalized);
100
+ targets.push(target);
101
+ }
102
+ };
103
+
104
+ for (const match of value.matchAll(/("[^"]*(?:""[^"]*)*"|[A-Za-z_][\w]*)\s*\./gu)) {
105
+ add(match[1]);
106
+ }
107
+ if (!targets.length) {
108
+ const simple = value.match(/^\s*(?:"((?:""|[^"])*)"|([A-Za-z_][\w]*))/u);
109
+ if (simple) add(simple[1] ?? simple[2]);
110
+ }
111
+ return targets;
112
+ }
113
+
114
+ function permissionRelations(value, node, add) {
115
+ const pattern =
116
+ /\btabledata\s+(?:"((?:""|[^"])*)"|([A-Za-z_][\w]*))\s*=\s*([RIMDX]+)/giu;
117
+ for (const match of value.matchAll(pattern)) {
118
+ add(node, {
119
+ target: (match[1] ?? match[2]).replaceAll('""', '"'),
120
+ targetType: "table",
121
+ kind: "permits",
122
+ access: match[3].toUpperCase()
123
+ });
124
+ }
125
+ }
126
+
127
+ function enclosingMember(node, source, types) {
128
+ let current = node.parent;
129
+ while (current) {
130
+ if (types.includes(current.type)) {
131
+ return fieldText(
132
+ current,
133
+ current.type === "enum_value_declaration" ? "value_name" : "name",
134
+ source
135
+ );
136
+ }
137
+ current = current.parent;
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ function implementationRelations(value, node, source, add) {
143
+ const identifier = String.raw`(?:"((?:""|[^"])*)"|([A-Za-z_][\w.]*))`;
144
+ const pattern = new RegExp(`${identifier}\\s*=\\s*${identifier}`, "gu");
145
+ const member = enclosingMember(node, source, ["enum_value_declaration"]);
146
+ for (const match of value.matchAll(pattern)) {
147
+ const contract = (match[1] ?? match[2]).replaceAll('""', '"');
148
+ const target = (match[3] ?? match[4]).replaceAll('""', '"');
149
+ add(node, {
150
+ target,
151
+ targetType: "codeunit",
152
+ kind: "selects",
153
+ contract,
154
+ member
155
+ });
156
+ }
157
+ }
158
+
159
+ function referencesFor(objectNode, source, file) {
160
+ const references = [];
161
+ const add = (node, relation) => {
162
+ references.push({ ...relation, location: locationFor(node, file) });
163
+ };
164
+ walk(objectNode, (node) => {
165
+ if (node === objectNode) return;
166
+
167
+ if (node.type === "part_section") {
168
+ const target = fieldText(node, "source", source);
169
+ if (target) {
170
+ add(node, {
171
+ target,
172
+ targetType: "page",
173
+ kind: "part",
174
+ member: fieldText(node, "name", source)
175
+ });
176
+ }
177
+ return;
178
+ }
179
+
180
+ if (node.type === "implements_clause") {
181
+ const interfaces = typeof node.childrenForFieldName === "function"
182
+ ? node.childrenForFieldName("interface")
183
+ : [node.childForFieldName("interface")].filter(Boolean);
184
+ for (const interfaceNode of interfaces) {
185
+ const target = cleanName(source.slice(interfaceNode.startIndex, interfaceNode.endIndex));
186
+ if (target) add(interfaceNode, { target, targetType: "interface", kind: "implements" });
187
+ }
188
+ return;
189
+ }
190
+
191
+ if (node.type === "record_type") {
192
+ const target = fieldText(node, "reference", source);
193
+ if (target) add(node, { target, targetType: "table", kind: "uses" });
194
+ return;
195
+ }
196
+
197
+ if (node.type === "object_reference_type") {
198
+ const target = fieldText(node, "reference", source);
199
+ const text = source.slice(node.startIndex, node.endIndex).trim();
200
+ const type = text.split(/\s+/u)[0]?.toLowerCase();
201
+ if (target) add(node, { target, targetType: type, kind: "uses" });
202
+ return;
203
+ }
204
+
205
+ if (node.type === "database_reference") {
206
+ const target = fieldText(node, "table_name", source);
207
+ if (target) add(node, { target, targetType: "table", kind: "reads" });
208
+ return;
209
+ }
210
+
211
+ if (node.type === "property") {
212
+ const name = fieldText(node, "name", source)?.toLowerCase();
213
+ const valueNode = node.childForFieldName("value");
214
+ if (!name || !valueNode) return;
215
+ const value = source.slice(valueNode.startIndex, valueNode.endIndex).trim();
216
+ const quoted = value.match(/^"((?:""|[^"])*)"/u);
217
+ const plain = value.match(/^([A-Za-z_][\w.]*)|^(\d+)/u);
218
+ const target = quoted?.[1]?.replaceAll('""', '"') ?? plain?.[1] ?? plain?.[2];
219
+ if (["sourcetable", "dataitemtable"].includes(name) && target) {
220
+ add(node, {
221
+ target,
222
+ targetType: "table",
223
+ kind: "reads",
224
+ property: name
225
+ });
226
+ }
227
+ if (name === "tablerelation") {
228
+ for (const table of tableRelationTargets(value)) {
229
+ add(node, { target: table, targetType: "table", kind: "relates" });
230
+ }
231
+ }
232
+ if (name === "permissions") {
233
+ permissionRelations(value, node, add);
234
+ }
235
+ if (name === "includedpermissionsets" && target) {
236
+ add(node, { target, targetType: "permissionset", kind: "includes" });
237
+ }
238
+ if (name === "implementation") {
239
+ implementationRelations(value, node, source, add);
240
+ }
241
+ if (name === "runobject") {
242
+ const match = value.match(
243
+ /^(codeunit|page|report|query|xmlport|table)\s+(.+)$/iu
244
+ );
245
+ if (match) {
246
+ add(node, {
247
+ target: cleanName(match[2]),
248
+ targetType: match[1].toLowerCase(),
249
+ kind: "runs",
250
+ member: enclosingMember(node, source, [
251
+ "action_declaration",
252
+ "customaction_declaration",
253
+ "systemaction_declaration"
254
+ ])
255
+ });
256
+ }
257
+ }
258
+ }
259
+ });
260
+ return references;
261
+ }
262
+
263
+ function callsFor(node, source, file) {
264
+ const calls = [];
265
+ walk(node, (child) => {
266
+ if (child.type !== "call_expression") return;
267
+ const functionNode = child.childForFieldName("function");
268
+ if (!functionNode) return;
269
+ const raw = source.slice(functionNode.startIndex, functionNode.endIndex).trim();
270
+ const parts = raw.split(".");
271
+ calls.push({
272
+ name: cleanName(parts.at(-1)),
273
+ receiver: parts.length > 1 ? cleanName(parts.slice(0, -1).join(".")) : undefined,
274
+ location: locationFor(child, file)
275
+ });
276
+ });
277
+ return calls;
278
+ }
279
+
280
+ function attributesFor(node, source) {
281
+ const prefix = source.slice(Math.max(0, node.startIndex - 4000), node.startIndex);
282
+ const block = prefix.match(/(?:\[[^\]\r\n]+\]\s*)+$/u)?.[0] ?? "";
283
+ return [...block.matchAll(/\[[^\]\r\n]+\]/gu)].map(([attribute]) => attribute);
284
+ }
285
+
286
+ function enclosingActionName(node, source) {
287
+ const actionTypes = new Set([
288
+ "action_declaration",
289
+ "customaction_declaration",
290
+ "systemaction_declaration"
291
+ ]);
292
+ let parent = node.parent;
293
+ while (parent) {
294
+ if (actionTypes.has(parent.type)) return fieldText(parent, "name", source);
295
+ parent = parent.parent;
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ function membersFor(objectNode, source, file) {
301
+ const procedures = [];
302
+ const fields = [];
303
+ const actions = [];
304
+ const variables = [];
305
+ walk(objectNode, (node) => {
306
+ if (["procedure", "trigger_declaration", "event_declaration", "interface_procedure"].includes(node.type)) {
307
+ const name = fieldText(node, "name", source);
308
+ if (name) {
309
+ const attributes = [
310
+ ...node.namedChildren
311
+ .filter((child) => child.type === "attribute_item")
312
+ .map((child) => source.slice(child.startIndex, child.endIndex)),
313
+ ...attributesFor(node, source)
314
+ ];
315
+ procedures.push({
316
+ name,
317
+ kind: node.type === "trigger_declaration"
318
+ ? "trigger"
319
+ : node.type === "event_declaration"
320
+ ? "event"
321
+ : attributes.some((attribute) => /\[(?:Integration|Business)Event\b/iu.test(attribute))
322
+ ? "event"
323
+ : "procedure",
324
+ location: locationFor(node, file),
325
+ calls: callsFor(node, source, file),
326
+ attributes,
327
+ action: node.type === "trigger_declaration"
328
+ ? enclosingActionName(node, source)
329
+ : undefined
330
+ });
331
+ }
332
+ }
333
+ if (node.type === "field_declaration" || node.type === "page_field") {
334
+ const name = fieldText(node, "name", source);
335
+ fields.push({
336
+ name: name ?? `(field ${fields.length + 1})`,
337
+ kind: node.type === "page_field" ? "page-field" : "field",
338
+ location: locationFor(node, file)
339
+ });
340
+ }
341
+ if (["action_declaration", "customaction_declaration", "systemaction_declaration"].includes(node.type)) {
342
+ const name = fieldText(node, "name", source);
343
+ actions.push({
344
+ name: name ?? `(action ${actions.length + 1})`,
345
+ location: locationFor(node, file),
346
+ calls: callsFor(node, source, file)
347
+ });
348
+ }
349
+ if (node.type === "variable_declaration") {
350
+ const names = typeof node.childrenForFieldName === "function"
351
+ ? node.childrenForFieldName("name")
352
+ : [node.childForFieldName("name")].filter(Boolean);
353
+ const typeNode = node.childForFieldName("type");
354
+ const typeText = typeNode
355
+ ? source.slice(typeNode.startIndex, typeNode.endIndex).trim()
356
+ : undefined;
357
+ const match = typeText?.match(
358
+ /^(record|codeunit|page|report|query|xmlport|interface|enum)\s+(.+)$/iu
359
+ );
360
+ for (const nameNode of names) {
361
+ variables.push({
362
+ name: cleanName(source.slice(nameNode.startIndex, nameNode.endIndex)),
363
+ type: typeText,
364
+ targetType: match?.[1]?.toLowerCase(),
365
+ target: match ? cleanName(match[2]) : undefined,
366
+ location: locationFor(node, file)
367
+ });
368
+ }
369
+ }
370
+ });
371
+ return { procedures, fields, actions, variables };
372
+ }
373
+
374
+ function dataOperationRelations(members) {
375
+ const reads = /^(get|find|findfirst|findlast|findset|isempty|count|calcfields|calcsums|next)$/iu;
376
+ const writes = /^(insert|modify|modifyall|delete|deleteall|rename)$/iu;
377
+ const variables = new Map(
378
+ members.variables
379
+ .filter(({ targetType, target }) => targetType === "record" && target)
380
+ .map((variable) => [variable.name.toLowerCase(), variable])
381
+ );
382
+ const relations = [];
383
+
384
+ for (const procedure of members.procedures) {
385
+ for (const call of procedure.calls) {
386
+ if (!call.receiver) continue;
387
+ const variable = variables.get(call.receiver.toLowerCase());
388
+ if (!variable) continue;
389
+ const kind = reads.test(call.name) ? "reads" : writes.test(call.name) ? "writes" : undefined;
390
+ if (!kind) continue;
391
+ relations.push({
392
+ target: variable.target,
393
+ targetType: "table",
394
+ kind,
395
+ operation: call.name,
396
+ location: call.location
397
+ });
398
+ }
399
+ }
400
+ return relations;
401
+ }
402
+
403
+ async function objectsFromSource(source, file) {
404
+ const language = await getLanguage();
405
+ const parser = new Parser();
406
+ parser.setLanguage(language);
407
+ const tree = parser.parse(source);
408
+ const namespaceNode = tree.rootNode.namedChildren.find(
409
+ (node) => node.type === "namespace_declaration"
410
+ );
411
+ const namespace = namespaceNode
412
+ ? fieldText(namespaceNode, "name", source) ?? "(global)"
413
+ : "(global)";
414
+ const usings = tree.rootNode.namedChildren
415
+ .filter((node) => node.type === "using_statement")
416
+ .map((node) => fieldText(node, "namespace", source))
417
+ .filter(Boolean);
418
+ const objects = [];
419
+
420
+ walk(tree.rootNode, (node) => {
421
+ const type = OBJECT_TYPES.get(node.type);
422
+ if (!type) return;
423
+
424
+ const name = fieldText(node, "object_name", source);
425
+ if (!name) return;
426
+
427
+ const id = fieldText(node, "object_id", source);
428
+ let base = fieldText(node, "base_object", source);
429
+ if (type === "pagecustomization") base = fieldText(node, "target_page", source);
430
+ if (type === "interface") base = fieldText(node, "extends_interface", source);
431
+
432
+ const relations = referencesFor(node, source, file);
433
+ if (base) {
434
+ relations.push({
435
+ target: base,
436
+ targetType: type === "interface" ? "interface" : EXTENSION_TARGET[type],
437
+ kind: "extends",
438
+ location: locationFor(node, file)
439
+ });
440
+ }
441
+ const members = membersFor(node, source, file);
442
+ relations.push(...dataOperationRelations(members));
443
+
444
+ objects.push({
445
+ id,
446
+ name,
447
+ type,
448
+ namespace,
449
+ usings,
450
+ file,
451
+ relations,
452
+ procedures: members.procedures,
453
+ fields: members.fields,
454
+ actions: members.actions,
455
+ variables: members.variables,
456
+ location: locationFor(node, file),
457
+ parseError: node.hasError
458
+ });
459
+ });
460
+
461
+ const result = { objects, hasError: tree.rootNode.hasError };
462
+ tree.delete();
463
+ parser.delete();
464
+ return result;
465
+ }
466
+
467
+ async function collectAlFiles(input) {
468
+ const absolute = path.resolve(input);
469
+ const stat = await fs.stat(absolute);
470
+ if (stat.isFile()) {
471
+ if (path.extname(absolute).toLowerCase() !== ".al") {
472
+ throw new Error(`Input file is not an AL source file: ${absolute}`);
473
+ }
474
+ return [absolute];
475
+ }
476
+
477
+ const files = [];
478
+ async function visit(directory) {
479
+ const entries = await fs.readdir(directory, { withFileTypes: true });
480
+ for (const entry of entries) {
481
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue;
482
+ const entryPath = path.join(directory, entry.name);
483
+ if (entry.isDirectory()) await visit(entryPath);
484
+ else if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".al") {
485
+ files.push(entryPath);
486
+ }
487
+ }
488
+ }
489
+ await visit(absolute);
490
+ return files.sort();
491
+ }
492
+
493
+ async function readAppManifest(file, root, cache) {
494
+ let directory = path.dirname(file);
495
+ const boundary = (await fs.stat(root)).isDirectory() ? root : path.dirname(root);
496
+ while (true) {
497
+ const candidate = path.join(directory, "app.json");
498
+ if (cache.has(candidate)) return cache.get(candidate);
499
+ try {
500
+ const data = JSON.parse(await fs.readFile(candidate, "utf8"));
501
+ const app = {
502
+ id: data.id,
503
+ name: data.name,
504
+ publisher: data.publisher,
505
+ version: data.version,
506
+ path: path.relative(boundary, candidate) || "app.json",
507
+ dependencies: data.dependencies ?? []
508
+ };
509
+ cache.set(candidate, app);
510
+ return app;
511
+ } catch (error) {
512
+ if (error.code !== "ENOENT") {
513
+ cache.set(candidate, undefined);
514
+ return undefined;
515
+ }
516
+ }
517
+ const parent = path.dirname(directory);
518
+ if (parent === directory) break;
519
+ directory = parent;
520
+ }
521
+ return undefined;
522
+ }
523
+
524
+ export async function analyze(input) {
525
+ const root = path.resolve(input);
526
+ const rootStat = await fs.stat(root);
527
+ const base = rootStat.isDirectory() ? root : path.dirname(root);
528
+ const files = await collectAlFiles(root);
529
+ const objects = [];
530
+ const parseErrors = [];
531
+ const diagnostics = [];
532
+ const manifestCache = new Map();
533
+ const apps = new Map();
534
+
535
+ for (const file of files) {
536
+ const source = await fs.readFile(file, "utf8");
537
+ const relativeFile = path.relative(
538
+ base,
539
+ file
540
+ );
541
+ const result = await objectsFromSource(source, relativeFile || path.basename(file));
542
+ objects.push(...result.objects);
543
+ const app = await readAppManifest(file, root, manifestCache);
544
+ if (app) {
545
+ apps.set(app.id ?? app.name, app);
546
+ for (const object of result.objects) object.app = app;
547
+ }
548
+ if (result.hasError) {
549
+ const reportedFile = relativeFile || path.basename(file);
550
+ parseErrors.push(reportedFile);
551
+ diagnostics.push({
552
+ severity: "error",
553
+ code: "parse-error",
554
+ message: "tree-sitter reported one or more syntax errors",
555
+ file: reportedFile
556
+ });
557
+ }
558
+ }
559
+
560
+ return {
561
+ schemaVersion: 1,
562
+ root,
563
+ files: files.length,
564
+ apps: [...apps.values()],
565
+ objects,
566
+ parseErrors,
567
+ diagnostics
568
+ };
569
+ }
570
+
571
+ export const testing = { objectsFromSource };