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.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +356 -0
- package/SPEC.md +379 -0
- package/dist/cli/avouch.d.ts +2 -0
- package/dist/cli/avouch.js +68 -0
- package/dist/cli/check.d.ts +86 -0
- package/dist/cli/check.js +333 -0
- package/dist/cli/facts.d.ts +3 -0
- package/dist/cli/facts.js +157 -0
- package/dist/cli/gate.d.ts +3 -0
- package/dist/cli/gate.js +149 -0
- package/dist/cli/init.d.ts +21 -0
- package/dist/cli/init.js +282 -0
- package/dist/cli/jsonl.d.ts +5 -0
- package/dist/cli/jsonl.js +20 -0
- package/dist/cli/query.d.ts +2 -0
- package/dist/cli/query.js +138 -0
- package/dist/cli/reconcile.d.ts +9 -0
- package/dist/cli/reconcile.js +131 -0
- package/dist/src/checker.d.ts +3 -0
- package/dist/src/checker.js +1136 -0
- package/dist/src/contract.d.ts +135 -0
- package/dist/src/contract.js +3 -0
- package/dist/src/coverage.d.ts +65 -0
- package/dist/src/coverage.js +237 -0
- package/dist/src/evaluate.d.ts +66 -0
- package/dist/src/evaluate.js +535 -0
- package/dist/src/expr.d.ts +209 -0
- package/dist/src/expr.js +497 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +11 -0
- package/dist/src/ledger.d.ts +96 -0
- package/dist/src/ledger.js +241 -0
- package/dist/src/markdown.d.ts +66 -0
- package/dist/src/markdown.js +701 -0
- package/dist/src/patch.d.ts +6 -0
- package/dist/src/patch.js +37 -0
- package/dist/src/query.d.ts +333 -0
- package/dist/src/query.js +739 -0
- package/dist/src/reconcile.d.ts +61 -0
- package/dist/src/reconcile.js +151 -0
- package/dist/src/stage1-check.d.ts +31 -0
- package/dist/src/stage1-check.js +329 -0
- package/dist/src/touched.d.ts +1 -0
- package/dist/src/touched.js +9 -0
- package/dist/src/unknown.d.ts +10 -0
- package/dist/src/unknown.js +173 -0
- package/docs/adapter-guide.md +187 -0
- package/docs/markdown-convention.md +635 -0
- package/ontology.schema.json +1757 -0
- package/package.json +57 -0
- package/templates/agents-block.md +12 -0
- 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
|
+
}
|