avouch 0.1.22

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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +6 -0
  3. package/README.md +356 -0
  4. package/SPEC.md +379 -0
  5. package/dist/cli/avouch.d.ts +2 -0
  6. package/dist/cli/avouch.js +68 -0
  7. package/dist/cli/check.d.ts +86 -0
  8. package/dist/cli/check.js +333 -0
  9. package/dist/cli/facts.d.ts +3 -0
  10. package/dist/cli/facts.js +157 -0
  11. package/dist/cli/gate.d.ts +3 -0
  12. package/dist/cli/gate.js +149 -0
  13. package/dist/cli/init.d.ts +21 -0
  14. package/dist/cli/init.js +282 -0
  15. package/dist/cli/jsonl.d.ts +5 -0
  16. package/dist/cli/jsonl.js +20 -0
  17. package/dist/cli/query.d.ts +2 -0
  18. package/dist/cli/query.js +138 -0
  19. package/dist/cli/reconcile.d.ts +9 -0
  20. package/dist/cli/reconcile.js +131 -0
  21. package/dist/src/checker.d.ts +3 -0
  22. package/dist/src/checker.js +1136 -0
  23. package/dist/src/contract.d.ts +135 -0
  24. package/dist/src/contract.js +3 -0
  25. package/dist/src/coverage.d.ts +65 -0
  26. package/dist/src/coverage.js +237 -0
  27. package/dist/src/evaluate.d.ts +66 -0
  28. package/dist/src/evaluate.js +535 -0
  29. package/dist/src/expr.d.ts +209 -0
  30. package/dist/src/expr.js +497 -0
  31. package/dist/src/index.d.ts +11 -0
  32. package/dist/src/index.js +11 -0
  33. package/dist/src/ledger.d.ts +96 -0
  34. package/dist/src/ledger.js +241 -0
  35. package/dist/src/markdown.d.ts +66 -0
  36. package/dist/src/markdown.js +701 -0
  37. package/dist/src/patch.d.ts +6 -0
  38. package/dist/src/patch.js +37 -0
  39. package/dist/src/query.d.ts +333 -0
  40. package/dist/src/query.js +739 -0
  41. package/dist/src/reconcile.d.ts +61 -0
  42. package/dist/src/reconcile.js +151 -0
  43. package/dist/src/stage1-check.d.ts +31 -0
  44. package/dist/src/stage1-check.js +329 -0
  45. package/dist/src/touched.d.ts +1 -0
  46. package/dist/src/touched.js +9 -0
  47. package/dist/src/unknown.d.ts +10 -0
  48. package/dist/src/unknown.js +173 -0
  49. package/docs/adapter-guide.md +187 -0
  50. package/docs/markdown-convention.md +635 -0
  51. package/ontology.schema.json +1757 -0
  52. package/package.json +57 -0
  53. package/templates/agents-block.md +12 -0
  54. package/vocab.ttl +524 -0
@@ -0,0 +1,701 @@
1
+ // Built-in adapter for plain Markdown sources: document text in, SourceFacts out. It reads no file (the command line reads
2
+ // the configuration and the documents). The parse conventions are fixed here; docs/markdown-convention.md states them.
3
+ import { IDENT, RULES } from "./contract.js";
4
+ const FACT_RULE = { storeCatalog: "R2", eventCatalog: "R8", contextMembers: "R5", permissions: "R10", scenarios: "R9", transitionTriggers: "R3" };
5
+ const DEFAULT_DISABLED = {
6
+ R3: "facts.stateMachines and facts.transitionTriggers are not set: the Markdown adapter reads state machines only from those sections",
7
+ R7: "the Markdown adapter reads no execution evidence",
8
+ R9: "facts.scenarios is not set: the Markdown adapter reads scenarios only from that section",
9
+ };
10
+ const PROFILE_KEYS = ["actionIdPattern", "linkFieldPattern", "stateProperty", "crossContextMechanisms", "scenarioCoverage", "idempotencyDeclarationRequired", "evidenceRequired", "rules"];
11
+ // ------------------------------------------------------------------------------------------ configuration
12
+ const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
13
+ const isStr = (x) => typeof x === "string" && x.length > 0;
14
+ /** Parses the JSON configuration (the `.json` file of `--adapter`). Throws `<file>: <json path>: <problem>` on any error. */
15
+ export function parseMarkdownConfig(text, file) {
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(text);
19
+ }
20
+ catch (error) {
21
+ throw new Error(`${file}: (root): not valid JSON: ${error.message}`);
22
+ }
23
+ return validateMarkdownConfig(raw, file);
24
+ }
25
+ /**
26
+ * Validates a parsed Markdown configuration: the content of a `.json` file, or the `adapter.markdown` object of
27
+ * `avouch.config.json`. Throws `<where>: <json path>: <problem>` on any error.
28
+ */
29
+ export function validateMarkdownConfig(value, where) {
30
+ const fail = (path, problem) => {
31
+ throw new Error(`${where}: ${path}: ${problem}`);
32
+ };
33
+ const raw = value;
34
+ if (!isObj(raw))
35
+ return fail("(root)", "must be an object");
36
+ for (const k of Object.keys(raw))
37
+ if (!["docs", "facts", "profile"].includes(k))
38
+ fail(k, "unknown key");
39
+ if (!Object.hasOwn(raw, "docs"))
40
+ fail("docs", "missing");
41
+ if (!Array.isArray(raw.docs) || raw.docs.length === 0)
42
+ fail("docs", "must be a non-empty array of strings");
43
+ const docs = raw.docs;
44
+ docs.forEach((d, i) => {
45
+ if (!isStr(d))
46
+ fail(`docs[${i}]`, "must be a non-empty string");
47
+ const j = docs.indexOf(d);
48
+ if (j !== i)
49
+ fail(`docs[${i}]`, `duplicate of docs[${j}]`);
50
+ });
51
+ const facts = {};
52
+ let stateMachines;
53
+ if (Object.hasOwn(raw, "facts")) {
54
+ if (!isObj(raw.facts))
55
+ fail("facts", "must be an object");
56
+ for (const [k, v] of Object.entries(raw.facts)) {
57
+ if (k === "stateMachines") {
58
+ if (!isObj(v) || Object.keys(v).length === 0)
59
+ fail("facts.stateMachines", "must be a non-empty object of anchors");
60
+ const entries = Object.entries(v);
61
+ for (const [o, anchor] of entries)
62
+ if (!isStr(anchor))
63
+ fail(`facts.stateMachines.${o}`, "must be a non-empty string");
64
+ stateMachines = Object.fromEntries(entries);
65
+ continue;
66
+ }
67
+ if (!Object.hasOwn(FACT_RULE, k))
68
+ fail(`facts.${k}`, "unknown key");
69
+ if (!isStr(v))
70
+ fail(`facts.${k}`, "must be a non-empty string");
71
+ facts[k] = v;
72
+ }
73
+ }
74
+ const p = {};
75
+ if (Object.hasOwn(raw, "profile")) {
76
+ if (!isObj(raw.profile))
77
+ fail("profile", "must be an object");
78
+ for (const [k, v] of Object.entries(raw.profile)) {
79
+ if (!PROFILE_KEYS.includes(k))
80
+ fail(`profile.${k}`, "unknown key");
81
+ p[k] = v;
82
+ }
83
+ }
84
+ const regex = (key, fallback) => {
85
+ if (!Object.hasOwn(p, key))
86
+ return fallback;
87
+ if (!isStr(p[key]))
88
+ return fail(`profile.${key}`, "must be a non-empty string");
89
+ try {
90
+ return new RegExp(p[key]);
91
+ }
92
+ catch (error) {
93
+ return fail(`profile.${key}`, `invalid regular expression: ${error.message}`);
94
+ }
95
+ };
96
+ const bool = (key) => {
97
+ if (!Object.hasOwn(p, key))
98
+ return undefined;
99
+ if (typeof p[key] !== "boolean")
100
+ fail(`profile.${key}`, "must be true or false");
101
+ return p[key];
102
+ };
103
+ if (Object.hasOwn(p, "stateProperty") && !isStr(p.stateProperty))
104
+ fail("profile.stateProperty", "must be a non-empty string");
105
+ if (Object.hasOwn(p, "crossContextMechanisms") && (!Array.isArray(p.crossContextMechanisms) || !p.crossContextMechanisms.every(isStr)))
106
+ fail("profile.crossContextMechanisms", "must be an array of non-empty strings");
107
+ if (Object.hasOwn(p, "scenarioCoverage") && p.scenarioCoverage !== "printed" && p.scenarioCoverage !== "enforced")
108
+ fail("profile.scenarioCoverage", 'must be "printed" or "enforced"');
109
+ if (stateMachines !== undefined && facts.transitionTriggers === undefined)
110
+ fail("facts.stateMachines", "requires facts.transitionTriggers");
111
+ if (stateMachines === undefined && facts.transitionTriggers !== undefined)
112
+ fail("facts.transitionTriggers", "requires facts.stateMachines");
113
+ // a rule is off by default only while the adapter has no anchor for the facts of the rule
114
+ const supplied = (r) => Object.keys(facts).some((k) => FACT_RULE[k] === r);
115
+ const rules = Object.fromEntries(RULES.map((r) => [r, DEFAULT_DISABLED[r] === undefined || supplied(r) ? true : { disabled: DEFAULT_DISABLED[r] }]));
116
+ if (Object.hasOwn(p, "rules")) {
117
+ if (!isObj(p.rules))
118
+ fail("profile.rules", "must be an object");
119
+ for (const [r, sw] of Object.entries(p.rules)) {
120
+ if (!RULES.includes(r))
121
+ fail(`profile.rules.${r}`, "unknown rule");
122
+ const ok = sw === true || (isObj(sw) && Object.keys(sw).length === 1 && isStr(sw.disabled));
123
+ if (!ok)
124
+ fail(`profile.rules.${r}`, 'must be true or {"disabled": "<reason>"}');
125
+ rules[r] = sw;
126
+ }
127
+ }
128
+ const profile = {
129
+ actionIdPattern: regex("actionIdPattern", /^[A-Z][A-Z_]*$/),
130
+ linkFieldPattern: regex("linkFieldPattern", /_id$/),
131
+ stateProperty: Object.hasOwn(p, "stateProperty") ? p.stateProperty : "state",
132
+ crossContextMechanisms: Object.hasOwn(p, "crossContextMechanisms") ? [...p.crossContextMechanisms] : [],
133
+ requiredSource: facts.permissions !== undefined ? { R10: facts.permissions } : {},
134
+ rules,
135
+ };
136
+ if (Object.hasOwn(p, "scenarioCoverage"))
137
+ profile.scenarioCoverage = p.scenarioCoverage;
138
+ const idem = bool("idempotencyDeclarationRequired");
139
+ if (idem !== undefined)
140
+ profile.idempotencyDeclarationRequired = idem;
141
+ const ev = bool("evidenceRequired");
142
+ if (ev !== undefined)
143
+ profile.evidenceRequired = ev;
144
+ return { docs: docs, facts, ...(stateMachines !== undefined ? { stateMachines } : {}), profile };
145
+ }
146
+ // ------------------------------------------------------------------------------------------ text structure
147
+ /**
148
+ * Which lines are inside a ``` fence (opening and closing lines included), and the closed fenced blocks (info string and
149
+ * body lines). A fence opens on a line that, after at most three leading spaces, starts with n >= 3 backticks; it closes
150
+ * only on a line of >= n backticks (after at most three leading spaces) followed by nothing but white space.
151
+ * `start`: the index of the opening line. `open`: the text ends inside a fence.
152
+ */
153
+ export function fences(lines) {
154
+ const inFence = [];
155
+ const blocks = [];
156
+ let n = 0, info = "", body = [], start = 0;
157
+ for (const [i, line] of lines.entries()) {
158
+ if (n === 0) {
159
+ const m = /^ {0,3}(`{3,})(.*)$/.exec(line);
160
+ if (m)
161
+ [n, info, body, start] = [m[1].length, m[2].trim(), [], i];
162
+ inFence.push(m !== null);
163
+ }
164
+ else {
165
+ const m = /^ {0,3}(`{3,})\s*$/.exec(line);
166
+ if (m && m[1].length >= n) {
167
+ n = 0;
168
+ blocks.push({ info, body, start });
169
+ }
170
+ else
171
+ body.push(line);
172
+ inFence.push(true);
173
+ }
174
+ }
175
+ return { inFence, open: n !== 0, blocks };
176
+ }
177
+ const BEGIN = /^<!-- BEGIN GENERATED: ([A-Za-z0-9._-]+)(.*) -->$/;
178
+ const END = /^<!-- END GENERATED: ([A-Za-z0-9._-]+) -->$/;
179
+ /**
180
+ * The text without its generated blocks (marker lines included), and `breaks`: the indexes of the kept lines that follow a
181
+ * removed block (a pipe table does not run across one), and `origin`: the index in the given text of each kept line. Throws on a malformed, nested, mismatched or unclosed marker.
182
+ */
183
+ function withoutGenerated(text) {
184
+ const lines = text.split("\n");
185
+ const { inFence } = fences(lines);
186
+ const kept = [];
187
+ const origin = [];
188
+ const breaks = new Set();
189
+ let open = null, removed = false;
190
+ lines.forEach((line, i) => {
191
+ const t = line.trim();
192
+ if (!inFence[i] && (t.startsWith("<!-- BEGIN GENERATED") || t.startsWith("<!-- END GENERATED"))) {
193
+ const b = BEGIN.exec(t), e = END.exec(t);
194
+ if (b) {
195
+ if (open !== null)
196
+ throw new Error(`line ${i + 1}: generated block ${b[1]} opens inside ${open}`);
197
+ open = b[1];
198
+ }
199
+ else if (e) {
200
+ if (open !== e[1])
201
+ throw new Error(`line ${i + 1}: generated block end ${e[1]} does not close ${open ?? "an open block"}`);
202
+ open = null;
203
+ }
204
+ else
205
+ throw new Error(`line ${i + 1}: malformed generated-block marker: ${t}`);
206
+ removed = true;
207
+ return;
208
+ }
209
+ if (open !== null)
210
+ return;
211
+ if (removed)
212
+ breaks.add(kept.length);
213
+ removed = false;
214
+ kept.push(line);
215
+ origin.push(i);
216
+ });
217
+ if (open !== null)
218
+ throw new Error(`generated block ${open} is not closed`);
219
+ return { text: kept.join("\n"), breaks, origin };
220
+ }
221
+ /** The ATX heading lines outside fences: line index (from 0), level and title as written. `open`: the text ends inside a fence. */
222
+ export function atxHeadings(lines) {
223
+ const { inFence, open } = fences(lines);
224
+ const heads = [];
225
+ lines.forEach((line, i) => {
226
+ const m = inFence[i] ? null : /^(#{1,6}) (.+)$/.exec(line);
227
+ if (m)
228
+ heads.push({ line: i, level: m[1].length, title: m[2] });
229
+ });
230
+ return { heads, open };
231
+ }
232
+ /** ATX headings outside fences. Each heading has a short anchor (first word, one trailing `.` removed) and a full-title anchor. */
233
+ function headings(text, breaks) {
234
+ const lines = text.split("\n");
235
+ const { heads, open } = atxHeadings(lines);
236
+ const list = heads.map((h, k) => {
237
+ let end = lines.length;
238
+ for (const n of heads.slice(k + 1))
239
+ if (n.level <= h.level) {
240
+ end = n.line;
241
+ break;
242
+ }
243
+ const t = h.title.trim();
244
+ const short = t.split(/\s+/)[0].replace(/\.$/, "");
245
+ const full = t.replace(/\s+#+$/, "").trim();
246
+ const own = new Set([...breaks].filter((b) => b > h.line && b < end).map((b) => b - h.line));
247
+ return { title: h.title, text: lines.slice(h.line, end).join("\n"), anchors: [...new Set([short, full])].filter((a) => a !== ""), breaks: own, line: h.line };
248
+ });
249
+ return { list, fenceOpen: open };
250
+ }
251
+ /** Bodies of the closed top-level fenced blocks whose info string is exactly `text` (a fence inside another fence is body text). */
252
+ const textBlocks = (text) => fences(text.split("\n")).blocks.filter((b) => b.info === "text").map((b) => b.body.join("\n"));
253
+ /** Lines of the section text that are outside fences. */
254
+ const unfenced = (text) => {
255
+ const lines = text.split("\n");
256
+ const { inFence } = fences(lines);
257
+ return lines.filter((_, i) => !inFence[i]);
258
+ };
259
+ /**
260
+ * A line split into code spans and plain text. A span opens with a run of n backticks and closes with the next run of
261
+ * exactly n; a run without such a close is plain text. Outside a span, a backslash before a backtick or a pipe is an escape, and both characters stay as written.
262
+ */
263
+ function spans(s) {
264
+ const out = [];
265
+ let plain = "", i = 0;
266
+ const run = (from) => {
267
+ let k = from;
268
+ while (s[k] === "`")
269
+ k++;
270
+ return k - from;
271
+ };
272
+ while (i < s.length) {
273
+ if (s[i] === "\\" && (s[i + 1] === "`" || s[i + 1] === "|")) {
274
+ plain += s.slice(i, i + 2);
275
+ i += 2;
276
+ }
277
+ else if (s[i] === "`") {
278
+ const n = run(i);
279
+ let close = -1;
280
+ for (let j = i + n; j < s.length && close < 0;) {
281
+ const m = run(j);
282
+ if (m === n)
283
+ close = j;
284
+ j += Math.max(m, 1);
285
+ }
286
+ if (close < 0)
287
+ plain += s.slice(i, i + n);
288
+ else {
289
+ if (plain !== "")
290
+ out.push({ code: false, text: plain });
291
+ plain = "";
292
+ out.push({ code: true, text: s.slice(i, close + n) });
293
+ }
294
+ i = close < 0 ? i + n : close + n;
295
+ }
296
+ else
297
+ plain += s[i++];
298
+ }
299
+ if (plain !== "")
300
+ out.push({ code: false, text: plain });
301
+ return out;
302
+ }
303
+ /** Contents of the code spans of a text (CommonMark: one leading and one trailing space are dropped when both are present). */
304
+ function codeContents(s) {
305
+ return spans(s)
306
+ .filter((t) => t.code)
307
+ .map((t) => {
308
+ const n = t.text.length - t.text.replace(/^`+/, "").length;
309
+ const c = t.text.slice(n, -n);
310
+ return /^ .* $/s.test(c) && c.trim() !== "" ? c.slice(1, -1) : c;
311
+ });
312
+ }
313
+ /** Cells of one pipe-table row: an escaped pipe and a `|` inside a code span are not separators; the leading and the optional trailing `|` are dropped. */
314
+ function cells(row) {
315
+ const s = row.trim();
316
+ const out = [];
317
+ let cur = "", endsWithSeparator = false;
318
+ for (const t of spans(s)) {
319
+ endsWithSeparator = false;
320
+ if (t.code) {
321
+ cur += t.text.replace(/\\\|/g, "|");
322
+ continue;
323
+ }
324
+ for (let i = 0; i < t.text.length; i++) {
325
+ endsWithSeparator = false;
326
+ if (t.text[i] === "\\" && t.text[i + 1] === "|") {
327
+ cur += "|";
328
+ i++;
329
+ }
330
+ else if (t.text[i] === "|") {
331
+ out.push(cur);
332
+ cur = "";
333
+ endsWithSeparator = true;
334
+ }
335
+ else
336
+ cur += t.text[i];
337
+ }
338
+ }
339
+ if (!endsWithSeparator)
340
+ out.push(cur);
341
+ if (s.startsWith("|"))
342
+ out.shift();
343
+ return out.map((c) => c.trim());
344
+ }
345
+ const ALIGNMENT = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
346
+ /**
347
+ * Pipe tables outside fences: runs of lines that start with `|`, alignment rows dropped; each table is its rows of cells.
348
+ * A run ends at any other line, at a fence line and where a generated block was removed (`breaks`, section line indexes).
349
+ */
350
+ function tables(h) {
351
+ const out = [];
352
+ const lines = h.text.split("\n");
353
+ const { inFence } = fences(lines);
354
+ let cur = null;
355
+ lines.forEach((line, i) => {
356
+ const row = !inFence[i] && line.startsWith("|");
357
+ if (!row || h.breaks.has(i))
358
+ cur = null;
359
+ if (!row)
360
+ return;
361
+ if (!cur)
362
+ out.push((cur = []));
363
+ if (!ALIGNMENT.test(line))
364
+ cur.push(cells(line));
365
+ });
366
+ return out;
367
+ }
368
+ // ------------------------------------------------------------------------------------------ state diagrams
369
+ const NAME = "[A-Za-z_][A-Za-z0-9_]*";
370
+ const EDGE = new RegExp(`^(\\[\\*\\]|${NAME}) *--> *(\\[\\*\\]|${NAME})( *:.*)?$`); // `A --> B : x`, `A-->B`, mermaid `A --> B: x`
371
+ const TRANSITION = new RegExp(`^(${NAME}(?: / ${NAME})*) (?:→|->) (${NAME}(?: / ${NAME})*)$`);
372
+ /** The first line of a mermaid body that is not empty and not a `%%` comment, trimmed ("" when there is none). */
373
+ const firstMermaidLine = (body) => body.map((l) => l.trim()).find((l) => l !== "" && !l.startsWith("%%")) ?? "";
374
+ /** The state machine of one diagram body (see docs/markdown-convention.md), or null after one `err` call for each line outside the grammar. */
375
+ function stateMachine(body, err) {
376
+ const nodes = [], initial = [], edges = [];
377
+ const add = (list, x) => void (list.includes(x) || list.push(x));
378
+ let bad = false, inNote = false;
379
+ for (const raw of body) {
380
+ const line = raw.trim();
381
+ if (inNote) {
382
+ inNote = line !== "end note";
383
+ continue;
384
+ }
385
+ if (line === "" || line === "@startuml" || line === "@enduml" || line.startsWith("'") || line.startsWith("%%") || line === "stateDiagram" || line === "stateDiagram-v2")
386
+ continue;
387
+ if (/^(hide|skinparam|title|direction) /.test(line) || /^note .*:/.test(line))
388
+ continue;
389
+ if (new RegExp(`^note (left|right|top|bottom) of ${NAME}$`).test(line)) {
390
+ inNote = true;
391
+ continue;
392
+ }
393
+ const m = EDGE.exec(line);
394
+ if (m && !(m[1] === "[*]" && m[2] === "[*]")) {
395
+ const [, from, to] = m;
396
+ if (from === "[*]") {
397
+ add(initial, to);
398
+ add(nodes, to);
399
+ }
400
+ else if (to === "[*]")
401
+ add(nodes, from);
402
+ else {
403
+ add(nodes, from);
404
+ add(nodes, to);
405
+ add(edges, `${from}->${to}`);
406
+ }
407
+ continue;
408
+ }
409
+ bad = true;
410
+ err(`state diagram line is outside the grammar: ${line}`);
411
+ }
412
+ if (inNote) {
413
+ bad = true;
414
+ err("state diagram: a `note` block is not closed by `end note`");
415
+ }
416
+ return bad ? null : { nodes, initial, edges };
417
+ }
418
+ // ------------------------------------------------------------------------------------------ facts
419
+ /** The text that the adapter reads: no BOM, LF line ends, generated blocks removed (when they are malformed, none is removed and `error` is the message). */
420
+ function prepared(d) {
421
+ const text = d.text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
422
+ try {
423
+ return { ...withoutGenerated(text), error: null };
424
+ }
425
+ catch (error) {
426
+ return { text, breaks: new Set(), origin: text.split("\n").map((_, i) => i), error: error.message };
427
+ }
428
+ }
429
+ /** The fields of a field-list block: `- name`, `- a / b?`, with an optional trailing `// comment`. The first line (the object type) and any other line are not fields. */
430
+ function fieldLines(lines) {
431
+ const fields = new Map();
432
+ for (const line of lines) {
433
+ const m = /^- (.+?)(?:\s*\/\/.*)?$/.exec(line);
434
+ if (m)
435
+ for (const part of m[1].split(" / ")) {
436
+ const t = part.trim();
437
+ const name = t.replace(/\?$/, "");
438
+ fields.set(name, { nullable: t.endsWith("?") || (fields.get(name)?.nullable ?? false) });
439
+ }
440
+ }
441
+ return fields;
442
+ }
443
+ /** The top-level `text` code blocks of the documents, in the text that the adapter reads (generated blocks removed). */
444
+ export function markdownTextBlocks(docs) {
445
+ const out = [];
446
+ for (const d of docs) {
447
+ const { text, breaks, origin } = prepared(d);
448
+ const lines = text.split("\n");
449
+ const { list } = headings(text, breaks);
450
+ for (const b of fences(lines).blocks) {
451
+ if (b.info !== "text")
452
+ continue;
453
+ const heading = list.filter((h) => h.line < b.start).at(-1);
454
+ const strays = [];
455
+ b.body.forEach((l, i) => {
456
+ const t = l.trim();
457
+ if (i > 0 && t !== "" && !l.startsWith("- ") && !t.startsWith("//"))
458
+ strays.push({ line: origin[b.start + 1 + i] + 1, text: t });
459
+ });
460
+ out.push({ doc: d.path, anchor: heading?.anchors[0] ?? "", first: (b.body[0] ?? "").trim(), line: origin[b.start] + 1, fields: fieldLines(b.body), strays });
461
+ }
462
+ }
463
+ return out;
464
+ }
465
+ /** Facts from plain Markdown documents. Optional facts are present exactly when their anchor is configured. */
466
+ export function markdownFacts(docs, config) {
467
+ const diagnostics = [];
468
+ const texts = [];
469
+ const index = new Map();
470
+ for (const d of docs) {
471
+ const { text, breaks, error } = prepared(d);
472
+ if (error !== null)
473
+ diagnostics.push({ rule: "R2", kind: "markdown_generated_blocks", key: `doc:${d.path}`, msg: `${d.path}: ${error}` });
474
+ texts.push(text);
475
+ const { list, fenceOpen } = headings(text, breaks);
476
+ if (fenceOpen)
477
+ diagnostics.push({ rule: "R2", kind: "markdown_fence", key: `doc:${d.path}`, msg: `${d.path}: a fence is not closed at the end of the document` });
478
+ for (const h of list)
479
+ for (const a of h.anchors)
480
+ index.set(a, [...(index.get(a) ?? []), h]);
481
+ }
482
+ const lookup = (anchor) => {
483
+ // an anchor with a leading section sign falls back to the anchor without it, only when no heading has the anchor as written
484
+ let hs = index.get(anchor) ?? [];
485
+ if (hs.length === 0 && anchor.startsWith("§"))
486
+ hs = index.get(anchor.slice(1)) ?? [];
487
+ return hs.length === 0 ? "missing" : hs.length > 1 ? "ambiguous" : hs[0];
488
+ };
489
+ const section = (anchor) => {
490
+ const h = lookup(anchor);
491
+ return typeof h === "string" ? h : { title: h.title, text: h.text };
492
+ };
493
+ const f = (anchor, value) => ({ value, source: { anchor } });
494
+ const parseError = (name, msg) => diagnostics.push({ rule: FACT_RULE[name], kind: "markdown_parse", key: `facts.${name}`, msg });
495
+ // the section text of a configured fact, or null with a markdown_anchor diagnostic; `rule` and `key` default to those of the fact
496
+ const configured = (name, anchor, rule = FACT_RULE[name], key = `facts.${name}`) => {
497
+ const h = lookup(anchor);
498
+ if (typeof h !== "string")
499
+ return h;
500
+ diagnostics.push({ rule, kind: "markdown_anchor", key, msg: `anchor ${anchor} of ${key} is ${h}` });
501
+ return null;
502
+ };
503
+ const catalog = (name, text) => {
504
+ const set = new Set();
505
+ if (text === null)
506
+ return set;
507
+ const blocks = textBlocks(text);
508
+ if (blocks.length !== 1) {
509
+ parseError(name, `facts.${name} needs exactly one text code block in its section, found ${blocks.length}`);
510
+ return set;
511
+ }
512
+ for (const line of blocks[0].split("\n")) {
513
+ const t = line.split("//")[0].trim();
514
+ if (t === "")
515
+ continue;
516
+ // " / " separates names, as in a field list; `a/b` stays one name
517
+ const parts = t.split(" / ").map((x) => x.trim());
518
+ if (parts.some((x) => x === "" || /\s/.test(x)))
519
+ parseError(name, `facts.${name}: catalog line has an empty name or white space in a name: ${t}`);
520
+ else
521
+ for (const x of parts)
522
+ set.add(x);
523
+ }
524
+ return set;
525
+ };
526
+ const facts = {
527
+ config: config.profile,
528
+ diagnostics,
529
+ info: [],
530
+ section,
531
+ containsTerm: (term) => texts.some((t) => IDENT(term).test(t)),
532
+ fieldList: (anchor, objectType) => {
533
+ const h = lookup(anchor);
534
+ if (typeof h === "string")
535
+ return null;
536
+ const blocks = textBlocks(h.text).filter((b) => b.split("\n")[0].trim() === objectType);
537
+ if (blocks.length !== 1)
538
+ return null;
539
+ return f(anchor, fieldLines(blocks[0].split("\n")));
540
+ },
541
+ };
542
+ const a = config.facts;
543
+ if (a.storeCatalog !== undefined)
544
+ facts.storeCatalog = f(a.storeCatalog, catalog("storeCatalog", configured("storeCatalog", a.storeCatalog)?.text ?? null));
545
+ if (a.eventCatalog !== undefined)
546
+ facts.eventCatalog = f(a.eventCatalog, catalog("eventCatalog", configured("eventCatalog", a.eventCatalog)?.text ?? null));
547
+ if (a.contextMembers !== undefined) {
548
+ const anchor = a.contextMembers;
549
+ const h = configured("contextMembers", anchor);
550
+ const members = new Map();
551
+ if (h !== null) {
552
+ for (const line of unfenced(h.text)) {
553
+ const m = /^- ([^:]+): (.+)$/.exec(line);
554
+ if (!m)
555
+ continue;
556
+ const context = m[1].trim();
557
+ if (members.has(context))
558
+ parseError("contextMembers", `facts.contextMembers: context ${context} is listed more than once`);
559
+ else
560
+ members.set(context, f(anchor, new Set(m[2].split(",").map((x) => x.trim()).filter((x) => x !== ""))));
561
+ }
562
+ if (members.size === 0)
563
+ parseError("contextMembers", "facts.contextMembers: the section has no `- <context>: <members>` line");
564
+ }
565
+ facts.contextMembers = members;
566
+ }
567
+ if (a.permissions !== undefined) {
568
+ const anchor = a.permissions;
569
+ const h = configured("permissions", anchor);
570
+ const rows = new Map();
571
+ if (h !== null) {
572
+ const ts = tables(h);
573
+ if (ts.length !== 1)
574
+ parseError("permissions", `facts.permissions needs exactly one pipe table in its section, found ${ts.length}`);
575
+ else if (ts[0].length === 0 || ts[0][0].length !== 2)
576
+ parseError("permissions", `facts.permissions: the table header has ${ts[0][0]?.length ?? 0} columns, expected 2`);
577
+ else
578
+ for (const row of ts[0].slice(1)) {
579
+ if (row.length !== 2) {
580
+ parseError("permissions", `facts.permissions: row has ${row.length} columns, expected 2: ${row.join(" | ")}`);
581
+ continue;
582
+ }
583
+ const keys = new Set(codeContents(row[1]));
584
+ for (const name of row[0].split(/[,、]/).map((x) => x.trim()).filter((x) => x !== "")) {
585
+ if (rows.has(name))
586
+ parseError("permissions", `facts.permissions: row ${name} is listed more than once; the first row is kept`);
587
+ else
588
+ rows.set(name, { keys, text: row[1] });
589
+ }
590
+ }
591
+ }
592
+ facts.permissions = { catalog: f(anchor, catalog("permissions", h?.text ?? null)), rows: f(anchor, rows), quoteNamesKey: (q, k) => q.includes("`" + k + "`") };
593
+ }
594
+ if (a.scenarios !== undefined) {
595
+ const anchor = a.scenarios;
596
+ const h = configured("scenarios", anchor);
597
+ const ids = new Set();
598
+ const cites = new Map();
599
+ if (h !== null) {
600
+ for (const line of unfenced(h.text)) {
601
+ const m = /^- (\S+?): (.*)$/.exec(line);
602
+ if (!m)
603
+ continue;
604
+ const [, id, rest] = m;
605
+ if (ids.has(id)) {
606
+ parseError("scenarios", `facts.scenarios: scenario ${id} is listed more than once; the first is kept`);
607
+ continue;
608
+ }
609
+ ids.add(id);
610
+ const cited = new Set();
611
+ cites.set(id, cited);
612
+ // the cited action ids are the items of the last bracket pair at the end of the line, one trailing full stop removed
613
+ const tail = rest.trimEnd().replace(/[.。]$/, "").trimEnd();
614
+ const b = /\(([^()()]*)\)$/.exec(tail) ?? /(([^()()]*))$/.exec(tail);
615
+ if (b)
616
+ for (const x of b[1].split(/[,,]/).map((y) => y.trim()))
617
+ if (config.profile.actionIdPattern.test(x))
618
+ cited.add(x);
619
+ else
620
+ parseError("scenarios", `facts.scenarios: scenario ${id} cites ${JSON.stringify(x)}, which does not match actionIdPattern`);
621
+ }
622
+ if (ids.size === 0)
623
+ parseError("scenarios", "facts.scenarios: the section has no `- <id>: <text> (<action>, ...)` line");
624
+ }
625
+ facts.scenarios = { ids, cites };
626
+ }
627
+ if (config.stateMachines !== undefined) {
628
+ const machines = new Map();
629
+ for (const [object, anchor] of Object.entries(config.stateMachines)) {
630
+ const key = `facts.stateMachines.${object}`;
631
+ const h = configured("transitionTriggers", anchor, "R3", key);
632
+ let value = null;
633
+ if (h !== null) {
634
+ const err = (msg) => diagnostics.push({ rule: "R3", kind: "markdown_parse", key, msg });
635
+ const blocks = fences(h.text.split("\n")).blocks.filter((b) => b.info === "plantuml" || (b.info === "mermaid" && /^stateDiagram(-v2)?$/.test(firstMermaidLine(b.body))));
636
+ if (blocks.length !== 1)
637
+ err(`${key} needs exactly one state diagram block (plantuml, or mermaid stateDiagram) in its section, found ${blocks.length}`);
638
+ else
639
+ value = stateMachine(blocks[0].body, err);
640
+ }
641
+ machines.set(object, f(anchor, value));
642
+ }
643
+ facts.stateMachines = machines;
644
+ }
645
+ if (a.transitionTriggers !== undefined) {
646
+ const anchor = a.transitionTriggers;
647
+ const h = configured("transitionTriggers", anchor);
648
+ const triggers = new Map();
649
+ if (h !== null) {
650
+ const ts = tables(h);
651
+ if (ts.length !== 1)
652
+ parseError("transitionTriggers", `facts.transitionTriggers needs exactly one pipe table in its section, found ${ts.length}`);
653
+ else if (ts[0].length === 0 || ts[0][0].length !== 3)
654
+ parseError("transitionTriggers", `facts.transitionTriggers: the table header has ${ts[0][0]?.length ?? 0} columns, expected 3`);
655
+ else
656
+ for (const row of ts[0].slice(1)) {
657
+ if (row.length !== 3) {
658
+ parseError("transitionTriggers", `facts.transitionTriggers: row has ${row.length} columns, expected 3: ${row.join(" | ")}`);
659
+ continue;
660
+ }
661
+ const [object, transition, trigger] = row;
662
+ const edges = [];
663
+ const ids = [];
664
+ let ok = true;
665
+ // the object must be one of facts.stateMachines, so a row for a misspelled object or an object without a configured machine is not dropped silently
666
+ if (!Object.hasOwn(config.stateMachines ?? {}, object)) {
667
+ parseError("transitionTriggers", `facts.transitionTriggers: object ${JSON.stringify(object)} is not a key of facts.stateMachines: ${row.join(" | ")}`);
668
+ ok = false;
669
+ }
670
+ for (const part of transition.split(/[,,]/).map((x) => x.trim())) {
671
+ const m = TRANSITION.exec(part);
672
+ if (!m) {
673
+ parseError("transitionTriggers", `facts.transitionTriggers: ${JSON.stringify(part)} is not a transition \`<state> → <state>\``);
674
+ ok = false;
675
+ }
676
+ else
677
+ for (const from of m[1].split(" / "))
678
+ for (const to of m[2].split(" / "))
679
+ edges.push(`${from}->${to}`);
680
+ }
681
+ for (const x of trigger.split(/[,,]/).map((y) => y.trim()))
682
+ if (config.profile.actionIdPattern.test(x))
683
+ ids.push(x);
684
+ else {
685
+ parseError("transitionTriggers", `facts.transitionTriggers: trigger ${JSON.stringify(x)} does not match actionIdPattern`);
686
+ ok = false;
687
+ }
688
+ if (!ok)
689
+ continue;
690
+ const byEdge = triggers.get(object) ?? triggers.set(object, new Map()).get(object);
691
+ for (const e of edges) {
692
+ const fact = byEdge.get(e) ?? byEdge.set(e, f(anchor, new Set())).get(e);
693
+ for (const x of ids)
694
+ fact.value.add(x);
695
+ }
696
+ }
697
+ }
698
+ facts.transitionTriggers = triggers;
699
+ }
700
+ return facts;
701
+ }