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,484 @@
1
+ // Parses AL UI-test procedures into deterministic documentation data.
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Language, Parser } from "web-tree-sitter";
7
+
8
+ const AL_TEST_PATTERN = /\[Test\](?:\s*\[[^\]]+\])*\s*procedure\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/giu;
9
+ const TEST_PAGE_PATTERN =
10
+ /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*TestPage\s+(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))\s*;/gimu;
11
+ const UI_OPERATION_PATTERN =
12
+ /^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.(OpenNew|OpenEdit|OpenView|Close|New|Invoke|SetValue|GoToRecord)\((.*?)\);\s*$/gimu;
13
+ const UI_OPERATION_DETECTOR =
14
+ /\.(?:OpenNew|OpenEdit|OpenView|Close|New|Invoke|SetValue|GoToRecord)\s*\(/iu;
15
+ const GRAMMAR_PATH = fileURLToPath(
16
+ new URL("../../vendor/tree-sitter-al.wasm", import.meta.url)
17
+ );
18
+ let languagePromise;
19
+
20
+ async function getLanguage() {
21
+ if (!languagePromise) {
22
+ languagePromise = (async () => {
23
+ await Parser.init();
24
+ return Language.load(GRAMMAR_PATH);
25
+ })();
26
+ }
27
+ return languagePromise;
28
+ }
29
+
30
+ function portablePath(filename) {
31
+ return filename.replaceAll("\\", "/");
32
+ }
33
+
34
+ function slug(value) {
35
+ return value
36
+ .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1-$2")
37
+ .replace(/([a-z0-9])([A-Z])/gu, "$1-$2")
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/gu, "-")
40
+ .replace(/^-|-$/gu, "");
41
+ }
42
+
43
+ function sourceReference(filename, procedure) {
44
+ const relativePath = portablePath(path.relative(process.cwd(), filename));
45
+ return `${relativePath}#${procedure}`;
46
+ }
47
+
48
+ function splitReference(reference, procedure) {
49
+ if (procedure) return { filename: reference, procedure };
50
+ const separator = reference.lastIndexOf("#");
51
+ if (separator > 0 && reference.slice(0, separator).toLowerCase().endsWith(".al")) {
52
+ return {
53
+ filename: reference.slice(0, separator),
54
+ procedure: reference.slice(separator + 1)
55
+ };
56
+ }
57
+ return { filename: reference, procedure: undefined };
58
+ }
59
+
60
+ function taggedComments(source) {
61
+ const tags = new Map();
62
+ let current = [];
63
+ for (const line of source.split(/\r?\n/u)) {
64
+ const comment = line.match(/^\s*\/\/\s?(.*)$/u);
65
+ if (!comment) {
66
+ current = [];
67
+ continue;
68
+ }
69
+ const tagged = comment[1].match(/^\[([A-Z]+)\](?:\/\[([A-Z]+)\])?\s*(.*)$/u);
70
+ if (tagged) {
71
+ current = [tagged[1], tagged[2]].filter(Boolean);
72
+ for (const tag of current) {
73
+ const items = tags.get(tag) ?? [];
74
+ items.push(tagged[3].trim());
75
+ tags.set(tag, items);
76
+ }
77
+ continue;
78
+ }
79
+ if (!current.length || !comment[1].trim()) continue;
80
+ for (const tag of current) {
81
+ const items = tags.get(tag);
82
+ items[items.length - 1] = `${items.at(-1)} ${comment[1].trim()}`;
83
+ }
84
+ }
85
+ return tags;
86
+ }
87
+
88
+ function commentValues(comments, tag) {
89
+ return (comments.get(tag) ?? []).map((item) => item.trim()).filter(Boolean);
90
+ }
91
+
92
+ function humanizeIdentifier(value) {
93
+ return value
94
+ .replace(/^"|"$/gu, "")
95
+ .replaceAll("_", " ")
96
+ .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2")
97
+ .replace(/([a-z0-9])([A-Z])/gu, "$1 $2")
98
+ .replace(/\s+/gu, " ")
99
+ .trim();
100
+ }
101
+
102
+ function singular(value) {
103
+ if (/ies$/iu.test(value)) return value.replace(/ies$/iu, "y");
104
+ if (/sses$/iu.test(value)) return value.replace(/es$/iu, "");
105
+ if (/s$/iu.test(value) && !/ss$/iu.test(value)) return value.slice(0, -1);
106
+ return value;
107
+ }
108
+
109
+ function exampleValue(argument) {
110
+ const value = argument.trim();
111
+ const alString = value.match(/^'(.*)'$/u);
112
+ if (alString) return { kind: "text", value: alString[1].replaceAll("''", "'") };
113
+ if (/^(true|false)$/iu.test(value)) {
114
+ return { kind: "boolean", value: value.toLowerCase() === "true" };
115
+ }
116
+ const enumValue = value.match(/::("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)$/u);
117
+ if (enumValue) return { kind: "choice", value: humanizeIdentifier(enumValue[1]) };
118
+ if (/^-?\d+(?:\.\d+)?$/u.test(value)) return { kind: "text", value };
119
+ return { kind: "variable", value: humanizeIdentifier(value) };
120
+ }
121
+
122
+ function contextPrefix(parts) {
123
+ if (!parts.length) return "";
124
+ return `In the **${parts.map(humanizeIdentifier).join(" / ")}** section, `;
125
+ }
126
+
127
+ function contextual(parts, instruction) {
128
+ const prefix = contextPrefix(parts);
129
+ if (prefix) return `${prefix}${instruction}`;
130
+ return instruction.replace(/^[a-z]/u, (letter) => letter.toUpperCase());
131
+ }
132
+
133
+ function collectTestPages(source, initial = new Map()) {
134
+ const testPages = new Map(initial);
135
+ for (const match of source.matchAll(TEST_PAGE_PATTERN)) {
136
+ testPages.set(match[1], match[2] ?? humanizeIdentifier(match[3]));
137
+ }
138
+ return testPages;
139
+ }
140
+
141
+ function operationGuidance(match, page) {
142
+ const parts = match[1].split(".");
143
+ parts.shift();
144
+ const operation = match[2];
145
+ if (operation === "OpenNew") {
146
+ return {
147
+ instruction: `Open **${page}** and create a new record.`,
148
+ creationTarget: singular(page)
149
+ };
150
+ }
151
+ if (operation === "OpenEdit") {
152
+ return { instruction: `Open **${page}** in edit mode.` };
153
+ }
154
+ if (operation === "OpenView") return { instruction: `Open **${page}**.` };
155
+ if (operation === "GoToRecord") {
156
+ return {
157
+ instruction: "Open the record you want to work with.",
158
+ record: humanizeIdentifier(match[3])
159
+ };
160
+ }
161
+ if (operation === "Close") {
162
+ return {
163
+ instruction: `Finish the entry and close **${page}**. Business Central saves the changes.`
164
+ };
165
+ }
166
+ if (operation === "New") {
167
+ return { instruction: contextual(parts, "add a new line.") };
168
+ }
169
+ if (operation === "Invoke") {
170
+ const action = parts.pop();
171
+ return { instruction: contextual(parts, `choose **${humanizeIdentifier(action)}**.`) };
172
+ }
173
+ if (operation === "SetValue") {
174
+ const field = humanizeIdentifier(parts.pop());
175
+ const example = exampleValue(match[3]);
176
+ if (example.kind === "boolean") {
177
+ return {
178
+ instruction: contextual(parts, `turn **${field}** ${example.value ? "on" : "off"}.`)
179
+ };
180
+ }
181
+ if (example.kind === "choice") {
182
+ return {
183
+ instruction: contextual(parts, `in **${field}**, select **${example.value}**.`),
184
+ hasExample: true
185
+ };
186
+ }
187
+ if (example.kind === "text") {
188
+ return {
189
+ instruction: contextual(
190
+ parts,
191
+ `in **${field}**, enter a suitable value (for example, **${example.value}**).`
192
+ ),
193
+ hasExample: true
194
+ };
195
+ }
196
+ return {
197
+ instruction: contextual(parts, `enter the required value in **${field}**.`)
198
+ };
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function repeatRanges(source) {
204
+ return [...source.matchAll(/\brepeat\b[\s\S]*?\buntil\b[^;]*;/giu)]
205
+ .map((match) => ({
206
+ start: match.index,
207
+ end: match.index + match[0].length
208
+ }));
209
+ }
210
+
211
+ function isConditionalOperation(source, range, operationIndex) {
212
+ const prefix = source.slice(range.start, operationIndex);
213
+ const boundary = Math.max(prefix.lastIndexOf(";"), prefix.lastIndexOf("repeat"));
214
+ return /\bif\b[\s\S]*\bthen\s*$/iu.test(prefix.slice(boundary + 1));
215
+ }
216
+
217
+ function lowerInstruction(value) {
218
+ return value
219
+ .replace(/\.$/u, "")
220
+ .replace(/^[A-Z]/u, (letter) => letter.toLowerCase());
221
+ }
222
+
223
+ function repeatedGuidance(source, range, operations) {
224
+ const record = operations.find(({ detail }) => detail.record)?.detail.record ?? "record";
225
+ const instructions = operations.map(({ detail, index }, itemIndex) => {
226
+ let instruction = lowerInstruction(detail.instruction);
227
+ if (isConditionalOperation(source, range, index)) {
228
+ instruction = `when applicable, ${instruction}`;
229
+ } else if (itemIndex === operations.length - 1 && operations.length > 1) {
230
+ instruction = `then ${instruction}`;
231
+ }
232
+ return instruction;
233
+ });
234
+ return `For each **${record}** record, ${instructions.join("; ")}.`;
235
+ }
236
+
237
+ function deriveGuidance(source, knownTestPages = new Map()) {
238
+ const testPages = collectTestPages(source, knownTestPages);
239
+
240
+ const steps = [];
241
+ let creationTarget;
242
+ let hasExamples = false;
243
+ const operations = [];
244
+ for (const match of source.matchAll(UI_OPERATION_PATTERN)) {
245
+ const root = match[1].split(".")[0];
246
+ const page = testPages.get(root);
247
+ if (!page) continue;
248
+ const detail = operationGuidance(match, page);
249
+ if (!detail) continue;
250
+ if (detail.creationTarget) creationTarget = detail.creationTarget;
251
+ if (detail.hasExample) hasExamples = true;
252
+ operations.push({ index: match.index, detail });
253
+ }
254
+
255
+ const ranges = repeatRanges(source);
256
+ const emittedRanges = new Set();
257
+ for (const operation of operations) {
258
+ const range = ranges.find(
259
+ ({ start, end }) => operation.index >= start && operation.index < end
260
+ );
261
+ if (!range) {
262
+ steps.push(operation.detail.instruction);
263
+ continue;
264
+ }
265
+ if (emittedRanges.has(range)) continue;
266
+ emittedRanges.add(range);
267
+ steps.push(repeatedGuidance(
268
+ source,
269
+ range,
270
+ operations.filter(({ index }) => index >= range.start && index < range.end)
271
+ ));
272
+ }
273
+
274
+ return {
275
+ steps,
276
+ hasExamples,
277
+ creationTarget,
278
+ title: creationTarget ? `Create a new ${creationTarget}` : undefined,
279
+ goal: creationTarget ? `Create a new ${creationTarget}.` : undefined
280
+ };
281
+ }
282
+
283
+ async function procedureSources(source) {
284
+ const language = await getLanguage();
285
+ const parser = new Parser();
286
+ parser.setLanguage(language);
287
+ const tree = parser.parse(source);
288
+ const procedures = new Map();
289
+ const visit = (node) => {
290
+ if (node.type === "procedure") {
291
+ const nameNode = node.childForFieldName("name");
292
+ if (nameNode) {
293
+ const name = source.slice(nameNode.startIndex, nameNode.endIndex).toLowerCase();
294
+ procedures.set(name, source.slice(node.startIndex, node.endIndex));
295
+ }
296
+ }
297
+ for (const child of node.namedChildren) visit(child);
298
+ };
299
+ visit(tree.rootNode);
300
+ return procedures;
301
+ }
302
+
303
+ function bareProcedureCalls(source) {
304
+ return [...source.matchAll(
305
+ /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\);\s*$/gimu
306
+ )];
307
+ }
308
+
309
+ function helperContainsUi(name, procedures, visiting = new Set(), depth = 0) {
310
+ if (depth >= 8 || visiting.has(name)) return false;
311
+ const source = procedures.get(name);
312
+ if (!source) return false;
313
+ if (UI_OPERATION_DETECTOR.test(source)) return true;
314
+ const next = new Set(visiting).add(name);
315
+ return bareProcedureCalls(source).some((match) =>
316
+ helperContainsUi(match[1].toLowerCase(), procedures, next, depth + 1)
317
+ );
318
+ }
319
+
320
+ function expandUiHelpers(source, procedures, visiting = new Set(), depth = 0) {
321
+ if (depth >= 8) return { source, expanded: [] };
322
+ const expanded = [];
323
+ const value = source.replace(
324
+ /^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\);\s*$/gimu,
325
+ (statement, indentation, rawName) => {
326
+ const name = rawName.toLowerCase();
327
+ if (
328
+ visiting.has(name) ||
329
+ !procedures.has(name) ||
330
+ !helperContainsUi(name, procedures)
331
+ ) return statement;
332
+ const nested = expandUiHelpers(
333
+ procedures.get(name),
334
+ procedures,
335
+ new Set(visiting).add(name),
336
+ depth + 1
337
+ );
338
+ expanded.push(rawName, ...nested.expanded);
339
+ return nested.source
340
+ .split(/\r?\n/u)
341
+ .map((line) => `${indentation}${line}`)
342
+ .join("\n");
343
+ }
344
+ );
345
+ return { source: value, expanded: [...new Set(expanded)] };
346
+ }
347
+
348
+ function whenBlocks(source) {
349
+ const markers = [...source.matchAll(
350
+ /^\s*\/\/\s*\[WHEN\](?:\/\[THEN\])?\s*(.*)$/gimu
351
+ )].filter((match) => match[1].trim());
352
+ return markers.map((marker, index) => {
353
+ const afterMarker = marker.index + marker[0].length;
354
+ const nextMarker = source.slice(afterMarker).search(
355
+ /^\s*\/\/\s*\[(?:WHEN|THEN)\](?:\/\[[A-Z]+\])?/gimu
356
+ );
357
+ const end = nextMarker < 0 ? source.length : afterMarker + nextMarker;
358
+ return {
359
+ title: marker[1].trim(),
360
+ source: source.slice(afterMarker, end),
361
+ index
362
+ };
363
+ });
364
+ }
365
+
366
+ function guidePrerequisites(items, creationTarget) {
367
+ return items.map((item) => {
368
+ if (creationTarget && /^No .+ exists yet\.$/iu.test(item)) {
369
+ return `Make sure the ${creationTarget} you want to create does not already exist.`;
370
+ }
371
+ return item;
372
+ });
373
+ }
374
+
375
+ function guideExpectedResults(items, creationTarget) {
376
+ return items.map((item) => {
377
+ if (creationTarget && /^The .+ persisted with the values entered in the list\.$/iu.test(item)) {
378
+ return `The ${creationTarget} is saved with the values you entered.`;
379
+ }
380
+ return item;
381
+ });
382
+ }
383
+
384
+ async function parseAlUiTest(source, filename, requestedProcedure) {
385
+ const tests = [...source.matchAll(AL_TEST_PATTERN)];
386
+ if (!tests.length) throw new Error(`${path.basename(filename)} contains no [Test] procedures`);
387
+
388
+ const available = tests.map((match) => match[1]);
389
+ const selected = requestedProcedure
390
+ ? tests.find((match) => match[1].toLowerCase() === requestedProcedure.toLowerCase())
391
+ : tests.length === 1 ? tests[0] : undefined;
392
+ if (!selected) {
393
+ if (requestedProcedure) {
394
+ throw new Error(
395
+ `${path.basename(filename)} has no [Test] procedure ${requestedProcedure}; available: ${available.join(", ")}`
396
+ );
397
+ }
398
+ throw new Error(
399
+ `${path.basename(filename)} contains multiple [Test] procedures; select one with --procedure: ${available.join(", ")}`
400
+ );
401
+ }
402
+
403
+ const remainder = source.slice(selected.index);
404
+ const boundary = remainder.slice(selected[0].length)
405
+ .search(/\n {4}(?=#(?:end)?region\b|\[[A-Za-z])/u);
406
+ const end = boundary < 0
407
+ ? source.length
408
+ : selected.index + selected[0].length + boundary;
409
+ const sourceText = source.slice(selected.index, end).trim().replace(/\n\s*\}\s*$/u, "");
410
+ const comments = taggedComments(sourceText);
411
+ const scenarios = commentValues(comments, "SCENARIO");
412
+ if (!scenarios.length) {
413
+ throw new Error(`${selected[1]} must have a // [SCENARIO] comment`);
414
+ }
415
+
416
+ const goal = scenarios.join(" ").trim();
417
+ const expected = commentValues(comments, "THEN");
418
+ const procedures = await procedureSources(source);
419
+ const expanded = expandUiHelpers(sourceText, procedures);
420
+ const testPages = collectTestPages(expanded.source);
421
+ const guidance = deriveGuidance(expanded.source, testPages);
422
+ const guideSections = whenBlocks(sourceText).map((section) => {
423
+ const sectionExpansion = expandUiHelpers(section.source, procedures);
424
+ return {
425
+ title: section.title,
426
+ steps: deriveGuidance(sectionExpansion.source, testPages).steps,
427
+ expandedHelpers: sectionExpansion.expanded
428
+ };
429
+ });
430
+ const prerequisites = commentValues(comments, "GIVEN");
431
+ const permissions = [...new Set([
432
+ ...commentValues(comments, "PERMISSIONS"),
433
+ ...commentValues(comments, "PERMISSION")
434
+ ])];
435
+ const expectedResults = expected.length ? expected : [goal];
436
+ return {
437
+ id: slug(selected[1]),
438
+ title: guidance.title ?? goal.replace(/[.\s]+$/u, ""),
439
+ goal,
440
+ guideGoal: guidance.goal ?? goal,
441
+ guideSteps: guidance.steps,
442
+ guideSections,
443
+ expandedHelpers: expanded.expanded,
444
+ hasExampleValues: guidance.hasExamples,
445
+ permissions,
446
+ prerequisites,
447
+ guidePrerequisites: guidePrerequisites(prerequisites, guidance.creationTarget),
448
+ actions: commentValues(comments, "WHEN"),
449
+ expected: expectedResults,
450
+ guideExpected: guideExpectedResults(expectedResults, guidance.creationTarget),
451
+ features: commentValues(comments, "FEATURE"),
452
+ procedure: selected[1],
453
+ sourceText,
454
+ sourceHash: createHash("sha256").update(sourceText).digest("hex")
455
+ };
456
+ }
457
+
458
+ export async function loadAlUiTest(reference, options = {}) {
459
+ const selected = splitReference(reference, options.procedure);
460
+ const absolutePath = path.resolve(selected.filename);
461
+ if (path.extname(absolutePath).toLowerCase() !== ".al") {
462
+ throw new Error(`documentation source must be an AL UI-test file: ${absolutePath}`);
463
+ }
464
+
465
+ let content;
466
+ try {
467
+ content = await fs.readFile(absolutePath, "utf8");
468
+ } catch (error) {
469
+ if (error.code === "ENOENT") throw new Error(`AL UI-test source not found: ${absolutePath}`);
470
+ throw new Error(`cannot read AL UI-test source ${absolutePath}: ${error.message}`);
471
+ }
472
+
473
+ const value = await parseAlUiTest(content, absolutePath, selected.procedure);
474
+ return {
475
+ path: absolutePath,
476
+ relativePath: portablePath(path.relative(process.cwd(), absolutePath)),
477
+ reference: sourceReference(absolutePath, value.procedure),
478
+ value
479
+ };
480
+ }
481
+
482
+ export function generatedDocumentationPath(value, outputDirectory = "docs/generated") {
483
+ return path.resolve(outputDirectory, `${value.id}.md`);
484
+ }
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { parseArgs } from "node:util";
3
+ import { writeDocumentation } from "./markdown.js";
4
+ import { loadAlUiTest } from "./al-ui-source.js";
5
+
6
+ const HELP = `BC Atlas docs - generate Markdown from an AL UI test
7
+
8
+ Usage:
9
+ bca docs generate [options] <ui-test.al>
10
+
11
+ Options:
12
+ --procedure <name> [Test] procedure to document
13
+ --output-dir <path> Markdown output directory (default: docs/generated)
14
+ -h, --help Show help
15
+ `;
16
+
17
+ const OPTIONS = {
18
+ procedure: { type: "string" },
19
+ "output-dir": { type: "string" },
20
+ help: { type: "boolean", short: "h" }
21
+ };
22
+
23
+ export async function docsMain(args) {
24
+ if (args[0] === "--help" || args[0] === "-h") return console.log(HELP);
25
+ const command = args[0];
26
+ const { values, positionals } = parseArgs({
27
+ args: args.slice(1),
28
+ allowPositionals: true,
29
+ strict: true,
30
+ options: OPTIONS
31
+ });
32
+ if (values.help || !command) return console.log(HELP);
33
+ if (command !== "generate") throw new Error(`unknown docs command: ${command}`);
34
+ if (positionals.length !== 1) {
35
+ throw new Error("docs generate expects one AL UI-test file");
36
+ }
37
+
38
+ const source = await loadAlUiTest(positionals[0], {
39
+ procedure: values.procedure
40
+ });
41
+ const filename = await writeDocumentation(source, values["output-dir"]);
42
+ console.log(`wrote ${path.relative(process.cwd(), filename).replaceAll("\\", "/")}`);
43
+ }
44
+
45
+ export { HELP as DOCS_HELP };
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { generatedDocumentationPath } from "./al-ui-source.js";
4
+
5
+ function bullets(items) {
6
+ return items.map((item) => `- ${item}`).join("\n");
7
+ }
8
+
9
+ export function renderDocumentation(source) {
10
+ const { value } = source;
11
+ const procedure = value.guideSteps.length ? value.guideSteps : value.actions;
12
+ const sourceFile = path.basename(source.path);
13
+ const beforeYouStart = [
14
+ ...value.permissions.map((permission) => `Required permission: **${permission}**`),
15
+ ...value.guidePrerequisites
16
+ ];
17
+ const lines = [
18
+ `<!-- Generated by BC Atlas docs from ${sourceFile}#${value.procedure}. Do not edit this file. -->`,
19
+ `<!-- Source SHA-256: ${value.sourceHash} -->`,
20
+ "",
21
+ `# ${value.title}`,
22
+ "",
23
+ `Use this guide to ${value.guideGoal.replace(/^[A-Z]/u, (letter) => letter.toLowerCase()).replace(/\.$/u, "")}.`,
24
+ ""
25
+ ];
26
+
27
+ if (beforeYouStart.length) {
28
+ lines.push("## Before you start", "", bullets(beforeYouStart), "");
29
+ }
30
+ if (procedure.length) {
31
+ lines.push("## Steps", "");
32
+ if (value.hasExampleValues) {
33
+ lines.push(
34
+ "> The values below are examples from the automated UI test. Use values appropriate for your environment.",
35
+ ""
36
+ );
37
+ }
38
+ if (value.guideSections?.length) {
39
+ for (const [index, section] of value.guideSections.entries()) {
40
+ lines.push(`${index + 1}. ${section.title}`);
41
+ for (const step of section.steps) lines.push(` - ${step}`);
42
+ }
43
+ lines.push("");
44
+ } else {
45
+ lines.push(
46
+ ...procedure.map((action, index) => `${index + 1}. ${action}`),
47
+ ""
48
+ );
49
+ }
50
+ }
51
+ lines.push(
52
+ "## What should happen",
53
+ "",
54
+ bullets(value.guideExpected),
55
+ "",
56
+ "<details>",
57
+ "<summary>Source and verification</summary>",
58
+ "",
59
+ `- File: \`${sourceFile}\``,
60
+ `- Function: \`${value.procedure}\``,
61
+ `- Source SHA-256: \`${value.sourceHash}\``,
62
+ "",
63
+ "</details>",
64
+ ""
65
+ );
66
+ return lines.join("\n");
67
+ }
68
+
69
+ export async function writeDocumentation(source, outputDirectory) {
70
+ const filename = generatedDocumentationPath(
71
+ source.value,
72
+ outputDirectory ?? process.env.BC_DOCS_OUTPUT ?? "docs/generated"
73
+ );
74
+ await fs.mkdir(path.dirname(filename), { recursive: true });
75
+ await fs.writeFile(filename, renderDocumentation(source));
76
+ return filename;
77
+ }
@@ -0,0 +1,102 @@
1
+ function wildcard(pattern) {
2
+ const escaped = String(pattern)
3
+ .replace(/[.+^${}()|[\]\\]/gu, "\\$&")
4
+ .replaceAll("**", "\u0000")
5
+ .replaceAll("*", ".*")
6
+ .replaceAll("\u0000", ".*");
7
+ return new RegExp(`^${escaped}$`, "iu");
8
+ }
9
+
10
+ function findCycles(objects, edges) {
11
+ const keys = new Set(objects.map(({ key }) => key));
12
+ const graph = new Map([...keys].map((key) => [key, []]));
13
+ for (const edge of edges) {
14
+ if (edge.to && keys.has(edge.from) && keys.has(edge.to)) {
15
+ graph.get(edge.from).push(edge.to);
16
+ }
17
+ }
18
+
19
+ let index = 0;
20
+ const stack = [];
21
+ const state = new Map();
22
+ const cycles = [];
23
+ function connect(key) {
24
+ const entry = { index, low: index, onStack: true };
25
+ index++;
26
+ state.set(key, entry);
27
+ stack.push(key);
28
+ for (const target of graph.get(key)) {
29
+ if (!state.has(target)) {
30
+ connect(target);
31
+ entry.low = Math.min(entry.low, state.get(target).low);
32
+ } else if (state.get(target).onStack) {
33
+ entry.low = Math.min(entry.low, state.get(target).index);
34
+ }
35
+ }
36
+ if (entry.low !== entry.index) return;
37
+ const component = [];
38
+ let current;
39
+ do {
40
+ current = stack.pop();
41
+ state.get(current).onStack = false;
42
+ component.push(current);
43
+ } while (current !== key);
44
+ if (component.length > 1 || graph.get(key).includes(key)) cycles.push(component.sort());
45
+ }
46
+ for (const key of keys) if (!state.has(key)) connect(key);
47
+ return cycles.sort((a, b) => a[0].localeCompare(b[0]));
48
+ }
49
+
50
+ export function addInsights(model, forbidden = []) {
51
+ const degree = new Map(model.objects.map(({ key }) => [key, { incoming: 0, outgoing: 0 }]));
52
+ for (const edge of model.edges) {
53
+ if (degree.has(edge.from)) degree.get(edge.from).outgoing++;
54
+ if (edge.to && degree.has(edge.to)) degree.get(edge.to).incoming++;
55
+ }
56
+ const hubs = [...degree]
57
+ .map(([key, value]) => ({ key, ...value, total: value.incoming + value.outgoing }))
58
+ .filter(({ total }) => total > 0)
59
+ .sort((a, b) => b.total - a.total || a.key.localeCompare(b.key))
60
+ .slice(0, 10);
61
+ const orphans = [...degree]
62
+ .filter(([, value]) => value.incoming + value.outgoing === 0)
63
+ .map(([key]) => key)
64
+ .sort();
65
+ const diagnostics = [...model.diagnostics];
66
+ const byKey = new Map(model.objects.map((object) => [object.key, object]));
67
+
68
+ for (const rule of forbidden) {
69
+ if (!rule.from || !rule.to) continue;
70
+ const fromPattern = wildcard(rule.from);
71
+ const toPattern = wildcard(rule.to);
72
+ for (const edge of model.edges) {
73
+ const from = byKey.get(edge.from);
74
+ const to = byKey.get(edge.to);
75
+ if (!from || !to) continue;
76
+ const fromName = `${from.namespace}:${from.type}:${from.name}`;
77
+ const toName = `${to.namespace}:${to.type}:${to.name}`;
78
+ if (fromPattern.test(fromName) && toPattern.test(toName)) {
79
+ diagnostics.push({
80
+ severity: rule.severity ?? "warning",
81
+ code: "forbidden-dependency",
82
+ message: rule.message ?? `Forbidden dependency from ${fromName} to ${toName}`,
83
+ file: edge.location?.file ?? from.file,
84
+ line: edge.location?.line
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ return {
91
+ ...model,
92
+ diagnostics,
93
+ insights: {
94
+ unresolvedEdges: model.edges.filter(({ to }) => !to).length,
95
+ orphans,
96
+ hubs,
97
+ cycles: findCycles(model.objects, model.edges)
98
+ }
99
+ };
100
+ }
101
+
102
+ export const testing = { findCycles };