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 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.11.0 is available": release notes, one-step update, and regenerated hooks, tasks, and pipelines. Dependabot pull requests on request. |
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
@@ -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
- The 3d-force-graph browser bundle, which includes three.js, is embedded in the
32
- explorer HTML files written by `bca export` and shown by the VS Code extension.
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.10.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
  }
@@ -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
- function louvainOn(nodes, edges, resolution = 1, seed = 7) {
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.size ? louvain(graph, { getEdgeWeight: "weight", rng: seededRandom(seed), resolution }) : {};
64
+ return graph;
63
65
  }
64
66
 
65
- /** Undirected weighted adjacency without self-loops. */
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 neighbors = new Map([...nodes].map((id) => [id, new Map()]));
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
- if (!neighbors.has(source) || !neighbors.has(target) || source === target || !weight) continue;
70
- neighbors.get(source).set(target, (neighbors.get(source).get(target) ?? 0) + weight);
71
- neighbors.get(target).set(source, (neighbors.get(target).get(source) ?? 0) + weight);
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
- return neighbors;
74
- }
75
-
76
- function modularity(neighbors, partition, resolution) {
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
- const inside = new Map();
79
- const degree = new Map();
80
- for (const [id, links] of neighbors) {
81
- const community = partition.get(id);
82
- for (const [other, weight] of links) {
83
- total += weight;
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 (const [community, sum] of degree) score += (inside.get(community) ?? 0) / total - resolution * (sum / total) ** 2;
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 = new Map(start);
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 Map();
101
- for (const [id, community] of partition) sums.set(community, (sums.get(community) ?? 0) + strength.get(id));
102
- const order = [...neighbors.keys()].sort();
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 (const id of order) {
106
- const own = partition.get(id);
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 links = new Map();
110
- for (const [other, weight] of neighbors.get(id)) links.set(partition.get(other), (links.get(partition.get(other)) ?? 0) + weight);
111
- sums.set(own, sums.get(own) - degree);
112
- const gain = (community) => (links.get(community) ?? 0) - resolution * (sums.get(community) ?? 0) * degree / total;
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
- for (const community of [...links.keys()].sort()) if (gain(community) > gain(best) + 1e-9) best = community;
115
- sums.set(best, (sums.get(best) ?? 0) + degree);
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.set(id, best);
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 neighbors = adjacency(nodes, edges);
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 = louvainOn(nodes, edges, resolution, seed);
137
- starts.push(new Map([...nodes].map((id) => [id, result[id] === undefined ? `s${id}` : `l${result[id]}`])));
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(new Map([...nodes].map((id) => [id, read(id) === undefined ? `s${id}` : `k${read(id)}`])));
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 start of starts) {
143
- const partition = refine(neighbors, start, resolution);
144
- const score = modularity(neighbors, partition, resolution);
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
- for (const [id, community] of best ?? []) if (neighbors.get(id).size) result[id] = community;
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 = [
@@ -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];
@@ -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 match = line.match(
14
- /^(\s*)\/\/\s*\[([A-Z-]+)\](?:\/\[([A-Z-]+)\])?(?:\s+\[([A-Z-]+)\])?\s*(.*)$/u
15
- );
16
- if (!match) return undefined;
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: match[1],
19
- rawTag: match[2],
20
- tag: canonicalTag(match[2]) ?? match[2],
21
- qualifier: match[4],
22
- value: match[5].trim()
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
- const matches = lines
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
- parsed?.tag === canonical &&
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) throw new Error(`[${canonical}] value was not found`);
66
- for (const { index } of matches.reverse()) lines.splice(index, 1);
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
- if (singleton && matches.length) {
73
- const current = matches[0];
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
- const after = editProcedure(before, options);
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 as md } from "./format.js";
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 ?? "").replaceAll("|", "\\|").replace(/\r?\n/gu, " ");
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")} | ${cell(task.goal)} |`
150
+ `| ${t("testcase.objective")} | ${textCell(task.goal)} |`
141
151
  );
142
- if (task.features.length) lines.push(`| ${t("label.use-case")} | ${cell(task.features.map(humanizeFeature).join(", "))} |`);
143
- if (task.permissions.length) lines.push(`| ${t("testcase.permissions")} | ${cell(task.permissions.join(", "))} |`);
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")}`, "", `![${t("heading.journey")}](./${journeyImage})`, "");
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 }) => `| ${cell(name)} | ${tasks.map(taskLink).join(", ")} |`), "");
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 }) => `| ${cell(name)} | ${tasks.map(taskLink).join(", ")} |`), "");
240
+ ...catalog.byPermission.map(({ name, tasks }) => `| ${textCell(name)} | ${tasks.map(taskLink).join(", ")} |`), "");
231
241
  }
232
242
  if (catalog.coverage) {
233
243
  const { coverage } = catalog;