bc-atlas 0.10.0 → 0.11.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/CHANGELOG.md +43 -0
- package/README.md +1 -1
- package/THIRD_PARTY_NOTICES.md +3 -2
- package/package.json +6 -3
- package/src/blueprint/cluster.js +120 -46
- package/src/docs/al-ui-source.js +1 -1
- package/src/docs/al-ui-writer.js +48 -24
- package/src/docs/render/markdown.js +21 -11
- package/src/graph/document.js +55 -0
- package/src/graph/html.js +2 -7
- package/src/graph/viewer/viewer.css +26 -0
- package/src/graph/viewer/viewer.js +365 -47
- package/vendor/README.md +15 -1
- package/vendor/graph-viewer.js +5498 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ All notable changes to BC Atlas will be documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
|
|
5
5
|
follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.11.0 - 2026-10-29
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Dependency explorer: without an architecture, objects are colored and
|
|
12
|
+
grouped by their relation clusters (the clustering of the architecture
|
|
13
|
+
proposal) instead of one color per app. Clusters float like galaxies
|
|
14
|
+
around their app candidate and their app, clusters of one app candidate
|
|
15
|
+
share a family of colors, each cluster shows its name at its center, links
|
|
16
|
+
inside a cluster take its color, and links between groups are faint arcs.
|
|
17
|
+
Labels that would overlap a more important one are hidden. On a dark
|
|
18
|
+
background the graph glows (**Glow** turns it off); dense graphs get
|
|
19
|
+
fainter links and less glow.
|
|
20
|
+
- Dependency explorer: a **Clusters** filter lists the clusters by app
|
|
21
|
+
candidate; **only** shows one cluster (or one app) and frames it, and so
|
|
22
|
+
does a click on a cluster name in the graph or in the legend, which lists
|
|
23
|
+
the largest clusters on screen. A second click or `Esc` shows all again.
|
|
24
|
+
Neighbors in other clusters stay visible in gray.
|
|
25
|
+
- Dependency explorer: the glow fades when you zoom in close, so large
|
|
26
|
+
spheres no longer bloom into white discs.
|
|
27
|
+
- The graph document (`bca export --view explorer --format json`) carries
|
|
28
|
+
the clusters: `clusters` with name, size, and app candidate, and `cluster`
|
|
29
|
+
on every workspace object.
|
|
30
|
+
- Clustering by relations is about twice as fast (4.5 instead of 9 seconds for
|
|
31
|
+
the Base Application) with the same result, and the explorer and the
|
|
32
|
+
architecture proposal share it: after a saved file, only the apps and
|
|
33
|
+
modules whose objects or relations changed are clustered again.
|
|
34
|
+
- The explorer's browser bundle (3d-force-graph with three.js and its bloom
|
|
35
|
+
pass) is built into `vendor/graph-viewer.js` (`npm run update:graph-vendor`);
|
|
36
|
+
3d-force-graph is no longer installed with the package.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- `bca docs set` and `unset` understand Microsoft's tags: `[SCENARIO 218101]`
|
|
41
|
+
and `[FEATURE] [A] [B]` are edited in place (the work item stays, one
|
|
42
|
+
feature of a list can be removed), setting `SCENARIO` again replaces the
|
|
43
|
+
line instead of adding a second one, and a value with a line break is
|
|
44
|
+
rejected instead of being written as AL code. Removing a feature that is
|
|
45
|
+
set for the whole codeunit says to change it in `OnRun`.
|
|
46
|
+
- Markdown guides and test cases show the author's text as written: `<Posted>`,
|
|
47
|
+
`<b>`, `*now*`, `_done_`, and `|` are escaped instead of disappearing or
|
|
48
|
+
turning into formatting.
|
|
49
|
+
|
|
7
50
|
## 0.10.0 - 2026-10-29
|
|
8
51
|
|
|
9
52
|
The npm package skips 0.9.0 so it shares its version with the VS Code
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ In VS Code, install the **BC Atlas** extension and open an AL project:
|
|
|
44
44
|
| **Export** | `bca export` | Everything the extension shows, as files: the BC Atlas pages as one offline HTML file to share (default), the Markdown report ([example](./examples/report/README.md)), the 3D explorer, one Markdown page per object, any of ten diagram views as D2, SVG, PNG, PDF, or JSON, with `--watch`. |
|
|
45
45
|
| | `bca docs` | User guides and test cases from AL UI tests (`[SCENARIO]`, `[GIVEN]`, `[WHEN]`, `[THEN]`) as Markdown, HTML, DITA 1.3, or Azure DevOps CSV, with real captions and translations, a flow diagram per scenario, and BPMN 2.0 processes per use case. [Example](./examples/docs/index.md) |
|
|
46
46
|
| **Integrate** | `bca run` | One entry point for Git hooks, schedules, and pipelines; verifies, stages, or publishes generated files. Also available as a [GitHub Action](./docs/automation-and-ci.md#github-actions). |
|
|
47
|
-
| | `bca update` | "BC Atlas 0.
|
|
47
|
+
| | `bca update` | "BC Atlas 0.12.0 is available": release notes, one-step update, and regenerated hooks, tasks, and pipelines. Dependabot pull requests on request. |
|
|
48
48
|
| | `bca mcp` | A Model Context Protocol server for AI agents; `bca help --format json` is the versioned command contract. |
|
|
49
49
|
|
|
50
50
|
Run `bca` for the same overview in your terminal and `bca help <command>` for
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -28,8 +28,9 @@ executable for PNG and PDF output.
|
|
|
28
28
|
authors. Both licensed under the MIT License. Sources:
|
|
29
29
|
<https://github.com/vasturiano/3d-force-graph>, <https://github.com/mrdoob/three.js>
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
A browser bundle of 3d-force-graph and three.js (including its bloom pass),
|
|
32
|
+
`vendor/graph-viewer.js`, is embedded in the explorer HTML files written by
|
|
33
|
+
`bca export` and shown by the VS Code extension.
|
|
33
34
|
|
|
34
35
|
## graphology
|
|
35
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bc-atlas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "BC Atlas maps Microsoft Dynamics 365 Business Central AL projects: architecture diagrams, health checks, reports, and documentation generated from source.",
|
|
5
5
|
"homepage": "https://github.com/SchulzOli/BC-Atlas#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"site:dev": "vitepress dev website",
|
|
38
38
|
"site:build": "vitepress build website && node ./scripts/site-assets.js",
|
|
39
39
|
"site:preview": "vitepress preview website",
|
|
40
|
-
"version:set": "node ./scripts/set-version.js"
|
|
40
|
+
"version:set": "node ./scripts/set-version.js",
|
|
41
|
+
"update:graph-vendor": "node ./scripts/build-graph-vendor.js"
|
|
41
42
|
},
|
|
42
43
|
"keywords": [
|
|
43
44
|
"al",
|
|
@@ -62,7 +63,6 @@
|
|
|
62
63
|
"dependencies": {
|
|
63
64
|
"@modelcontextprotocol/server": "^2.4.0",
|
|
64
65
|
"@terrastruct/d2": "^0.1.33",
|
|
65
|
-
"3d-force-graph": "^1.80.0",
|
|
66
66
|
"fflate": "^0.8.3",
|
|
67
67
|
"graphology": "^0.26.0",
|
|
68
68
|
"graphology-communities-louvain": "^2.0.2",
|
|
@@ -71,8 +71,11 @@
|
|
|
71
71
|
"zod": "^4.6.7"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
|
+
"3d-force-graph": "^1.80.0",
|
|
74
75
|
"@modelcontextprotocol/client": "^2.4.0",
|
|
75
76
|
"bpmn-moddle": "^10.3.1",
|
|
77
|
+
"esbuild": "^0.28.2",
|
|
78
|
+
"three": "^0.186.1",
|
|
76
79
|
"vitepress": "2.0.0-alpha.20"
|
|
77
80
|
}
|
|
78
81
|
}
|
package/src/blueprint/cluster.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// 4. the same relations at a coarser resolution say which components
|
|
14
14
|
// belong together in one app: each component goes to the app candidate
|
|
15
15
|
// most of its objects land in, so apps always hold whole components
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
16
17
|
import Graph from "graphology";
|
|
17
18
|
import louvain from "graphology-communities-louvain";
|
|
18
19
|
import { coupling } from "../graph/coupling.js";
|
|
@@ -51,7 +52,8 @@ function seededRandom(seed) {
|
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
/** The coupling graph for Louvain, merged per pair of objects, without self-loops. */
|
|
56
|
+
function couplingGraph(nodes, edges) {
|
|
55
57
|
const graph = new Graph({ type: "undirected", allowSelfLoops: false });
|
|
56
58
|
for (const id of nodes) graph.addNode(id);
|
|
57
59
|
for (const { source, target, weight } of edges) {
|
|
@@ -59,62 +61,101 @@ function louvainOn(nodes, edges, resolution = 1, seed = 7) {
|
|
|
59
61
|
if (graph.hasEdge(source, target)) graph.updateEdgeAttribute(source, target, "weight", (value) => value + weight);
|
|
60
62
|
else graph.addEdge(source, target, { weight });
|
|
61
63
|
}
|
|
62
|
-
return graph
|
|
64
|
+
return graph;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* The same graph as compact arrays (compressed sparse rows) for refining and
|
|
69
|
+
* scoring: objects in sorted order, and per object its neighbors and weights.
|
|
70
|
+
*/
|
|
66
71
|
function adjacency(nodes, edges) {
|
|
67
|
-
const
|
|
72
|
+
const order = [...nodes].sort();
|
|
73
|
+
const index = new Map(order.map((id, position) => [id, position]));
|
|
74
|
+
const lists = order.map(() => new Map());
|
|
68
75
|
for (const { source, target, weight } of edges) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
const from = index.get(source);
|
|
77
|
+
const to = index.get(target);
|
|
78
|
+
if (from === undefined || to === undefined || from === to || !weight) continue;
|
|
79
|
+
lists[from].set(to, (lists[from].get(to) ?? 0) + weight);
|
|
80
|
+
lists[to].set(from, (lists[to].get(from) ?? 0) + weight);
|
|
72
81
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
const offsets = new Int32Array(order.length + 1);
|
|
83
|
+
for (let node = 0; node < order.length; node++) offsets[node + 1] = offsets[node] + lists[node].size;
|
|
84
|
+
const neighbors = new Int32Array(offsets[order.length]);
|
|
85
|
+
const weights = new Float64Array(offsets[order.length]);
|
|
86
|
+
const strength = new Float64Array(order.length);
|
|
77
87
|
let total = 0;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
degree.set(community, (degree.get(community) ?? 0) + weight);
|
|
85
|
-
if (partition.get(other) === community) inside.set(community, (inside.get(community) ?? 0) + weight);
|
|
88
|
+
for (let node = 0; node < order.length; node++) {
|
|
89
|
+
let position = offsets[node];
|
|
90
|
+
for (const [other, weight] of lists[node]) {
|
|
91
|
+
neighbors[position] = other;
|
|
92
|
+
weights[position++] = weight;
|
|
93
|
+
strength[node] += weight;
|
|
86
94
|
}
|
|
95
|
+
total += strength[node];
|
|
87
96
|
}
|
|
97
|
+
return { order, index, offsets, neighbors, weights, strength, total };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Partition labels as consecutive integers, ordered like the labels themselves (ties break the same way). */
|
|
101
|
+
function numbered(labels) {
|
|
102
|
+
const distinct = [...new Set(labels)].sort();
|
|
103
|
+
const number = new Map(distinct.map((label, position) => [label, position]));
|
|
104
|
+
return { partition: Int32Array.from(labels, (label) => number.get(label)), count: distinct.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function modularity({ offsets, neighbors, weights, strength, total }, partition, count, resolution) {
|
|
88
108
|
if (!total) return 0;
|
|
109
|
+
const inside = new Float64Array(count);
|
|
110
|
+
const degree = new Float64Array(count);
|
|
111
|
+
for (let node = 0; node < partition.length; node++) {
|
|
112
|
+
const community = partition[node];
|
|
113
|
+
degree[community] += strength[node];
|
|
114
|
+
for (let position = offsets[node]; position < offsets[node + 1]; position++) {
|
|
115
|
+
if (partition[neighbors[position]] === community) inside[community] += weights[position];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
89
118
|
let score = 0;
|
|
90
|
-
for (
|
|
119
|
+
for (let community = 0; community < count; community++) score += inside[community] / total - resolution * (degree[community] / total) ** 2;
|
|
91
120
|
return score;
|
|
92
121
|
}
|
|
93
122
|
|
|
94
123
|
/** Moves single objects to the neighboring community that raises modularity most, until none does. */
|
|
95
|
-
function refine(neighbors, start, resolution) {
|
|
96
|
-
const partition =
|
|
97
|
-
const strength = new Map([...neighbors].map(([id, links]) => [id, [...links.values()].reduce((sum, weight) => sum + weight, 0)]));
|
|
98
|
-
const total = [...strength.values()].reduce((sum, value) => sum + value, 0);
|
|
124
|
+
function refine({ offsets, neighbors, weights, strength, total }, start, count, resolution) {
|
|
125
|
+
const partition = Int32Array.from(start);
|
|
99
126
|
if (!total) return partition;
|
|
100
|
-
const sums = new
|
|
101
|
-
for (
|
|
102
|
-
const
|
|
127
|
+
const sums = new Float64Array(count);
|
|
128
|
+
for (let node = 0; node < partition.length; node++) sums[partition[node]] += strength[node];
|
|
129
|
+
const links = new Float64Array(count);
|
|
130
|
+
const touched = [];
|
|
103
131
|
for (let pass = 0; pass < 20; pass++) {
|
|
104
132
|
let moved = false;
|
|
105
|
-
for (
|
|
106
|
-
const
|
|
107
|
-
const degree = strength.get(id);
|
|
133
|
+
for (let node = 0; node < partition.length; node++) {
|
|
134
|
+
const degree = strength[node];
|
|
108
135
|
if (!degree) continue;
|
|
109
|
-
const
|
|
110
|
-
for (
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
const own = partition[node];
|
|
137
|
+
for (let position = offsets[node]; position < offsets[node + 1]; position++) {
|
|
138
|
+
const community = partition[neighbors[position]];
|
|
139
|
+
if (!links[community]) touched.push(community);
|
|
140
|
+
links[community] += weights[position];
|
|
141
|
+
}
|
|
142
|
+
sums[own] -= degree;
|
|
143
|
+
const gain = (community) => links[community] - resolution * sums[community] * degree / total;
|
|
113
144
|
let best = own;
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
let bestGain = gain(own);
|
|
146
|
+
touched.sort((a, b) => a - b);
|
|
147
|
+
for (const community of touched) {
|
|
148
|
+
const value = gain(community);
|
|
149
|
+
if (value > bestGain + 1e-9) {
|
|
150
|
+
best = community;
|
|
151
|
+
bestGain = value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
sums[best] += degree;
|
|
155
|
+
for (const community of touched) links[community] = 0;
|
|
156
|
+
touched.length = 0;
|
|
116
157
|
if (best !== own) {
|
|
117
|
-
partition
|
|
158
|
+
partition[node] = best;
|
|
118
159
|
moved = true;
|
|
119
160
|
}
|
|
120
161
|
}
|
|
@@ -130,18 +171,20 @@ function refine(neighbors, start, resolution) {
|
|
|
130
171
|
* relations confirm it.
|
|
131
172
|
*/
|
|
132
173
|
function communities(nodes, edges, { resolution = 1, candidates = [] } = {}) {
|
|
133
|
-
const
|
|
174
|
+
const csr = adjacency(nodes, edges);
|
|
175
|
+
const graph = couplingGraph(nodes, edges);
|
|
134
176
|
const starts = [];
|
|
135
177
|
for (const seed of [7, 11, 13, 17, 19]) {
|
|
136
|
-
const result =
|
|
137
|
-
starts.push(
|
|
178
|
+
const result = graph.size ? louvain(graph, { getEdgeWeight: "weight", rng: seededRandom(seed), resolution }) : {};
|
|
179
|
+
starts.push(csr.order.map((id) => result[id] === undefined ? `s${id}` : `l${result[id]}`));
|
|
138
180
|
}
|
|
139
|
-
for (const read of candidates) starts.push(
|
|
181
|
+
for (const read of candidates) starts.push(csr.order.map((id) => read(id) === undefined ? `s${id}` : `k${read(id)}`));
|
|
140
182
|
let best;
|
|
141
183
|
let bestScore = -Infinity;
|
|
142
|
-
for (const
|
|
143
|
-
const partition =
|
|
144
|
-
const
|
|
184
|
+
for (const labels of starts) {
|
|
185
|
+
const { partition: start, count } = numbered(labels);
|
|
186
|
+
const partition = refine(csr, start, count, resolution);
|
|
187
|
+
const score = modularity(csr, partition, count, resolution);
|
|
145
188
|
if (score > bestScore + 1e-9) {
|
|
146
189
|
best = partition;
|
|
147
190
|
bestScore = score;
|
|
@@ -149,7 +192,9 @@ function communities(nodes, edges, { resolution = 1, candidates = [] } = {}) {
|
|
|
149
192
|
}
|
|
150
193
|
// Objects without coupling stay unassigned; the callers place them.
|
|
151
194
|
const result = {};
|
|
152
|
-
|
|
195
|
+
if (best) csr.order.forEach((id, node) => {
|
|
196
|
+
if (csr.offsets[node + 1] > csr.offsets[node]) result[id] = best[node];
|
|
197
|
+
});
|
|
153
198
|
return result;
|
|
154
199
|
}
|
|
155
200
|
|
|
@@ -233,15 +278,44 @@ const APP_RESOLUTION = 0.3;
|
|
|
233
278
|
* Clusters the objects of one app or module by coupling.
|
|
234
279
|
* @returns Map of object id to { key, shared?, label, fullName, alternatives, app: { key, label, fullName } }
|
|
235
280
|
*/
|
|
281
|
+
// The explorer and the architecture proposal cluster the same apps, and after
|
|
282
|
+
// a saved file most apps and modules are unchanged: results are kept by a
|
|
283
|
+
// fingerprint of everything they depend on.
|
|
284
|
+
const clusterCache = new Map();
|
|
285
|
+
const CLUSTER_CACHE_SIZE = 64;
|
|
286
|
+
|
|
236
287
|
export function clusterByRelations(nodes, levels, weights, { prefix = "" } = {}) {
|
|
237
288
|
const ids = new Set(nodes.map(({ id }) => id));
|
|
238
|
-
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
239
289
|
const edges = [];
|
|
240
290
|
for (const edge of levels.object.edges) {
|
|
241
291
|
if (!ids.has(edge.source) || !ids.has(edge.target) || edge.source === edge.target || !shapes(edge)) continue;
|
|
242
292
|
const weight = coupling(edge.kinds, weights);
|
|
243
293
|
if (weight) edges.push({ source: edge.source, target: edge.target, weight });
|
|
244
294
|
}
|
|
295
|
+
const fingerprint = createHash("sha1");
|
|
296
|
+
for (const { id, label, namespace, file, objectId } of nodes) fingerprint.update(`\u0000${id}\u0001${label}\u0001${namespace}\u0001${file}\u0001${objectId}`);
|
|
297
|
+
for (const { source, target, weight } of edges) fingerprint.update(`\u0002${source}\u0001${target}\u0001${weight}`);
|
|
298
|
+
const key = fingerprint.digest("hex");
|
|
299
|
+
let result = clusterCache.get(key);
|
|
300
|
+
if (result) clusterCache.delete(key);
|
|
301
|
+
else result = clusterFresh(nodes, edges);
|
|
302
|
+
clusterCache.set(key, result);
|
|
303
|
+
if (clusterCache.size > CLUSTER_CACHE_SIZE) clusterCache.delete(clusterCache.keys().next().value);
|
|
304
|
+
if (!prefix) return result;
|
|
305
|
+
// Keys are local to the call; the caller's prefix makes them unique across apps.
|
|
306
|
+
const apps = new Map();
|
|
307
|
+
const prefixed = new Map();
|
|
308
|
+
for (const [id, cluster] of result) {
|
|
309
|
+
if (!apps.has(cluster.app)) apps.set(cluster.app, { ...cluster.app, key: `${prefix}${cluster.app.key}` });
|
|
310
|
+
prefixed.set(id, { ...cluster, key: `${prefix}${cluster.key}`, app: apps.get(cluster.app) });
|
|
311
|
+
}
|
|
312
|
+
return prefixed;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function clusterFresh(nodes, edges) {
|
|
316
|
+
const prefix = "";
|
|
317
|
+
const ids = new Set(nodes.map(({ id }) => id));
|
|
318
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
245
319
|
|
|
246
320
|
// Today's namespaces and functional folders compete with Louvain; the relations decide.
|
|
247
321
|
const candidates = [
|
package/src/docs/al-ui-source.js
CHANGED
|
@@ -87,7 +87,7 @@ const TEST_KINDS = new Set(["UI", "UT"]);
|
|
|
87
87
|
* One tag comment: "[SCENARIO 218101] Text" (Microsoft's work item number),
|
|
88
88
|
* "[WHEN]/[THEN] Text", "[GIVEN] [SETUP] Text", "[FEATURE] [Sales] [Invoice]".
|
|
89
89
|
*/
|
|
90
|
-
function tagLine(text) {
|
|
90
|
+
export function tagLine(text) {
|
|
91
91
|
const head = text.match(/^\[([A-Z][A-Z-]*)(?:\s+([^\]]*?))?\](?:\/\[([A-Z][A-Z-]*)\])?\s*(.*)$/u);
|
|
92
92
|
if (!head) return undefined;
|
|
93
93
|
let rest = head[4];
|
package/src/docs/al-ui-writer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { parseAlUiTest } from "./al-ui-source.js";
|
|
4
|
+
import { parseAlUiTest, tagLine } from "./al-ui-source.js";
|
|
5
5
|
import { loadCorpus, validateCorpus } from "./model.js";
|
|
6
6
|
import { canonicalTag, DOCUMENTATION_TAGS, PREREQUISITE_TYPES } from "./tags.js";
|
|
7
7
|
|
|
@@ -9,23 +9,30 @@ function hash(value) {
|
|
|
9
9
|
return createHash("sha256").update(value).digest("hex");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Tag comments are read exactly as the documentation reads them, including
|
|
13
|
+
// Microsoft's "[SCENARIO 218101] Text" and "[FEATURE] [Sales] [Invoice]".
|
|
12
14
|
function parsedTagLine(line) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
);
|
|
16
|
-
|
|
15
|
+
const comment = line.match(/^(\s*)\/\/\s?(.*)$/u);
|
|
16
|
+
const tagged = comment && tagLine(comment[2].trim());
|
|
17
|
+
if (!tagged) return undefined;
|
|
18
|
+
const tag = canonicalTag(tagged.tags[0]) ?? tagged.tags[0];
|
|
19
|
+
const qualified = tag !== "FEATURE" && /^[A-Z][A-Z-]*$/u.test(tagged.groups[0] ?? "");
|
|
20
|
+
const rest = tag === "FEATURE" ? [] : tagged.groups.slice(qualified ? 1 : 0).map((group) => `[${group}]`);
|
|
17
21
|
return {
|
|
18
|
-
indentation:
|
|
19
|
-
rawTag:
|
|
20
|
-
tag
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
indentation: comment[1],
|
|
23
|
+
rawTag: tagged.tags[0],
|
|
24
|
+
tag,
|
|
25
|
+
reference: tagged.reference,
|
|
26
|
+
qualifier: qualified ? tagged.groups[0] : undefined,
|
|
27
|
+
// [FEATURE] [A] [B] lists several values on one line.
|
|
28
|
+
values: tag === "FEATURE" ? [...tagged.groups, ...(tagged.text ? [tagged.text] : [])] : [[...rest, tagged.text].filter(Boolean).join(" ")],
|
|
29
|
+
groups: tag === "FEATURE" ? tagged.groups : []
|
|
23
30
|
};
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
function metadataLine(indentation, tag, value, qualifier) {
|
|
33
|
+
function metadataLine(indentation, tag, value, qualifier, reference) {
|
|
27
34
|
const type = qualifier ? ` [${qualifier}]` : "";
|
|
28
|
-
return `${indentation}// [${tag}]${type} ${value}`;
|
|
35
|
+
return `${indentation}// [${tag}${reference ? ` ${reference}` : ""}]${type} ${value}`;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function insertionIndex(lines, tag) {
|
|
@@ -53,29 +60,44 @@ function editProcedure(segment, options) {
|
|
|
53
60
|
throw new Error(`unknown prerequisite type: ${options.qualifier}`);
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
// A value is one comment line; a line break would turn the rest into AL code.
|
|
64
|
+
if (/[\r\n]/u.test(options.value ?? "")) throw new Error(`the value of [${canonical}] must be a single line`);
|
|
65
|
+
|
|
66
|
+
const sameTag = lines
|
|
57
67
|
.map((line, index) => ({ index, parsed: parsedTagLine(line) }))
|
|
58
|
-
.filter(({ parsed }) =>
|
|
59
|
-
|
|
60
|
-
(!options.value || parsed.value === options.value) &&
|
|
61
|
-
(!options.qualifier || parsed.qualifier === options.qualifier)
|
|
62
|
-
);
|
|
68
|
+
.filter(({ parsed }) => parsed?.tag === canonical && (!options.qualifier || parsed.qualifier === options.qualifier));
|
|
69
|
+
const matches = sameTag.filter(({ parsed }) => !options.value || parsed.values.includes(options.value));
|
|
63
70
|
|
|
64
71
|
if (options.remove) {
|
|
65
|
-
if (!matches.length)
|
|
66
|
-
|
|
72
|
+
if (!matches.length) {
|
|
73
|
+
const where = options.codeunitTags?.some(({ tag, text }) => canonicalTag(tag) === canonical && (!options.value || text === options.value));
|
|
74
|
+
throw new Error(where
|
|
75
|
+
? `[${canonical}] ${options.value ?? ""} is set for the whole codeunit in its OnRun trigger; change it there`
|
|
76
|
+
: `[${canonical}] value was not found`);
|
|
77
|
+
}
|
|
78
|
+
for (const { index, parsed } of matches.reverse()) {
|
|
79
|
+
// From a [FEATURE] [A] [B] list, only the named feature goes.
|
|
80
|
+
const others = options.value && parsed.groups.length ? parsed.groups.filter((group) => group !== options.value) : [];
|
|
81
|
+
if (others.length) lines[index] = `${parsed.indentation}// [${parsed.rawTag}] ${others.map((group) => `[${group}]`).join(" ")}`;
|
|
82
|
+
else lines.splice(index, 1);
|
|
83
|
+
}
|
|
67
84
|
return lines.join(eol);
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
if (!options.value?.trim()) throw new Error(`--value is required for [${canonical}]`);
|
|
71
88
|
const singleton = DOCUMENTATION_TAGS[canonical].cardinality === "one";
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
// A tag that exists once is replaced, whatever its value was.
|
|
90
|
+
if (singleton && sameTag.length) {
|
|
91
|
+
const [current, ...continued] = sameTag;
|
|
92
|
+
// "[SCENARIO 396854] ..." over several lines becomes the one new sentence.
|
|
93
|
+
for (const { index } of [...continued].reverse()) lines.splice(index, 1);
|
|
94
|
+
// A work item number ([SCENARIO 218101]) stays with the tag.
|
|
74
95
|
lines[current.index] = metadataLine(
|
|
75
96
|
current.parsed.indentation,
|
|
76
97
|
canonical,
|
|
77
98
|
options.value.trim(),
|
|
78
|
-
options.qualifier
|
|
99
|
+
options.qualifier,
|
|
100
|
+
current.parsed.reference
|
|
79
101
|
);
|
|
80
102
|
return lines.join(eol);
|
|
81
103
|
}
|
|
@@ -107,7 +129,9 @@ export async function planMetadataEdit(root, documentId, options) {
|
|
|
107
129
|
}
|
|
108
130
|
const { start, end } = scenario.value.sourceRange;
|
|
109
131
|
const before = original.slice(start, end);
|
|
110
|
-
|
|
132
|
+
// Tags the test has without having them itself come from the codeunit's OnRun trigger.
|
|
133
|
+
const codeunitTags = scenario.value.metadata.map(({ tag, value }) => ({ tag, text: value }));
|
|
134
|
+
const after = editProcedure(before, { ...options, codeunitTags });
|
|
111
135
|
const content = `${original.slice(0, start)}${after}${original.slice(end)}`;
|
|
112
136
|
const value = await parseAlUiTest(content, scenario.path, scenario.value.procedure);
|
|
113
137
|
const replacement = { ...scenario, fileHash: hash(content), value };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { label, phrase } from "../phrases.js";
|
|
2
2
|
import { inlineStep } from "../steps.js";
|
|
3
3
|
import { humanizeFeature, sameAsTitle } from "../task.js";
|
|
4
|
-
import { escapeMarkdown, markdown
|
|
4
|
+
import { escapeMarkdown, markdown } from "./format.js";
|
|
5
5
|
|
|
6
6
|
const LINK_HEADINGS = { next: "heading.next", related: "heading.related", alternative: "heading.alternative" };
|
|
7
7
|
|
|
@@ -9,8 +9,18 @@ function link(target, extension = ".md") {
|
|
|
9
9
|
return target.exists ? `[${escapeMarkdown(target.title)}](./${target.id}${extension})` : `**${escapeMarkdown(target.id)}**`;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Tag text is author prose: escape it so `<Posted>`, `*now*` or `a | b` render as written.
|
|
13
|
+
function md(value) {
|
|
14
|
+
return markdown(value, { escape: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Pipes that md() or escapeMarkdown() already escaped stay as they are.
|
|
12
18
|
function cell(value) {
|
|
13
|
-
return String(value ?? "").
|
|
19
|
+
return String(value ?? "").replace(/(?<!\\)\|/gu, "\\|").replace(/\r?\n/gu, " ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function textCell(value) {
|
|
23
|
+
return cell(escapeMarkdown(value));
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
function header(task) {
|
|
@@ -68,7 +78,7 @@ function flowSection(task, flowImage) {
|
|
|
68
78
|
/** User guide in the shape of a DITA task: prerequisites, steps, result, links. */
|
|
69
79
|
export function renderGuide(task, { flowImage } = {}) {
|
|
70
80
|
const { language } = task;
|
|
71
|
-
const lines = [...header(task), `# ${task.title}`, "", md(task.shortdesc), "", ...flowSection(task, flowImage)];
|
|
81
|
+
const lines = [...header(task), `# ${escapeMarkdown(task.title)}`, "", md(task.shortdesc), "", ...flowSection(task, flowImage)];
|
|
72
82
|
if (task.prereqs.length) {
|
|
73
83
|
lines.push(`## ${label(language, "heading.prereq")}`, "",
|
|
74
84
|
...task.prereqs.map((item) => `- ${mdTokens(prereqLine(item, language))}`), "");
|
|
@@ -84,7 +94,7 @@ export function renderGuide(task, { flowImage } = {}) {
|
|
|
84
94
|
}
|
|
85
95
|
lines.push("");
|
|
86
96
|
}
|
|
87
|
-
lines.push(`## ${label(language, "heading.result")}`, "", ...task.result.map((item) => `- ${item}`), "");
|
|
97
|
+
lines.push(`## ${label(language, "heading.result")}`, "", ...task.result.map((item) => `- ${escapeMarkdown(item)}`), "");
|
|
88
98
|
for (const [kind, heading] of Object.entries(LINK_HEADINGS)) {
|
|
89
99
|
if (task.links[kind].length) {
|
|
90
100
|
lines.push(`## ${label(language, heading)}`, "", ...task.links[kind].map((target) => `- ${link(target)}`), "");
|
|
@@ -132,15 +142,15 @@ export function testCaseRows(task) {
|
|
|
132
142
|
export function renderTestCase(task, { flowImage } = {}) {
|
|
133
143
|
const { language } = task;
|
|
134
144
|
const t = (key, values) => label(language, key, values);
|
|
135
|
-
const lines = [...header(task), `# ${t("testcase.title", { id: task.id })}: ${task.title}`, ""];
|
|
145
|
+
const lines = [...header(task), `# ${t("testcase.title", { id: task.id })}: ${escapeMarkdown(task.title)}`, ""];
|
|
136
146
|
lines.push(
|
|
137
147
|
"| | |",
|
|
138
148
|
"| --- | --- |",
|
|
139
149
|
`| ${t("testcase.id")} | \`${task.id}\` |`,
|
|
140
|
-
`| ${t("testcase.objective")} | ${
|
|
150
|
+
`| ${t("testcase.objective")} | ${textCell(task.goal)} |`
|
|
141
151
|
);
|
|
142
|
-
if (task.features.length) lines.push(`| ${t("label.use-case")} | ${
|
|
143
|
-
if (task.permissions.length) lines.push(`| ${t("testcase.permissions")} | ${
|
|
152
|
+
if (task.features.length) lines.push(`| ${t("label.use-case")} | ${textCell(task.features.map(humanizeFeature).join(", "))} |`);
|
|
153
|
+
if (task.permissions.length) lines.push(`| ${t("testcase.permissions")} | ${textCell(task.permissions.join(", "))} |`);
|
|
144
154
|
lines.push(`| ${t("testcase.source")} | \`${task.source.file}#${task.source.procedure}\` |`, "");
|
|
145
155
|
lines.push(...flowSection(task, flowImage));
|
|
146
156
|
|
|
@@ -159,7 +169,7 @@ export function renderTestCase(task, { flowImage } = {}) {
|
|
|
159
169
|
"",
|
|
160
170
|
`## ${t("testcase.final")}`,
|
|
161
171
|
"",
|
|
162
|
-
...task.result.map((item) => `- ${item}`),
|
|
172
|
+
...task.result.map((item) => `- ${escapeMarkdown(item)}`),
|
|
163
173
|
"",
|
|
164
174
|
`## ${t("testcase.signoff")}`,
|
|
165
175
|
"",
|
|
@@ -223,11 +233,11 @@ export function renderIndex(catalog, { journeyImage } = {}) {
|
|
|
223
233
|
if (journeyImage) lines.push(`## ${t("heading.journey")}`, "", ``, "");
|
|
224
234
|
if (catalog.byPage.length) {
|
|
225
235
|
lines.push(`## ${t("heading.by-page")}`, "", `| ${t("column.page")} | ${t("column.scenarios")} |`, "| --- | --- |",
|
|
226
|
-
...catalog.byPage.map(({ name, tasks }) => `| ${
|
|
236
|
+
...catalog.byPage.map(({ name, tasks }) => `| ${textCell(name)} | ${tasks.map(taskLink).join(", ")} |`), "");
|
|
227
237
|
}
|
|
228
238
|
if (catalog.byPermission.length) {
|
|
229
239
|
lines.push(`## ${t("heading.by-permission")}`, "", `| ${t("column.permission-set")} | ${t("column.scenarios")} |`, "| --- | --- |",
|
|
230
|
-
...catalog.byPermission.map(({ name, tasks }) => `| ${
|
|
240
|
+
...catalog.byPermission.map(({ name, tasks }) => `| ${textCell(name)} | ${tasks.map(taskLink).join(", ")} |`), "");
|
|
231
241
|
}
|
|
232
242
|
if (catalog.coverage) {
|
|
233
243
|
const { coverage } = catalog;
|