@githonllc/avouch 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,476 @@
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/adapter-guide.md section 7 states them.
3
+ import { IDENT, RULES } from "./contract.js";
4
+ const FACT_RULE = { storeCatalog: "R2", eventCatalog: "R8", contextMembers: "R5", permissions: "R10" };
5
+ const DEFAULT_DISABLED = {
6
+ R3: "the Markdown adapter does not parse state machines or transition triggers",
7
+ R7: "the Markdown adapter reads no execution evidence",
8
+ R9: "the Markdown adapter does not parse scenarios",
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 (`avouch.json`). Throws `<file>: <json path>: <problem>` on any error. */
15
+ export function parseMarkdownConfig(text, file) {
16
+ const fail = (path, problem) => {
17
+ throw new Error(`${file}: ${path}: ${problem}`);
18
+ };
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(text);
22
+ }
23
+ catch (error) {
24
+ return fail("(root)", `not valid JSON: ${error.message}`);
25
+ }
26
+ if (!isObj(raw))
27
+ return fail("(root)", "must be an object");
28
+ for (const k of Object.keys(raw))
29
+ if (!["docs", "facts", "profile"].includes(k))
30
+ fail(k, "unknown key");
31
+ if (!Object.hasOwn(raw, "docs"))
32
+ fail("docs", "missing");
33
+ if (!Array.isArray(raw.docs) || raw.docs.length === 0)
34
+ fail("docs", "must be a non-empty array of strings");
35
+ const docs = raw.docs;
36
+ docs.forEach((d, i) => {
37
+ if (!isStr(d))
38
+ fail(`docs[${i}]`, "must be a non-empty string");
39
+ const j = docs.indexOf(d);
40
+ if (j !== i)
41
+ fail(`docs[${i}]`, `duplicate of docs[${j}]`);
42
+ });
43
+ const facts = {};
44
+ if (Object.hasOwn(raw, "facts")) {
45
+ if (!isObj(raw.facts))
46
+ fail("facts", "must be an object");
47
+ for (const [k, v] of Object.entries(raw.facts)) {
48
+ if (!Object.hasOwn(FACT_RULE, k))
49
+ fail(`facts.${k}`, "unknown key");
50
+ if (!isStr(v))
51
+ fail(`facts.${k}`, "must be a non-empty string");
52
+ facts[k] = v;
53
+ }
54
+ }
55
+ const p = {};
56
+ if (Object.hasOwn(raw, "profile")) {
57
+ if (!isObj(raw.profile))
58
+ fail("profile", "must be an object");
59
+ for (const [k, v] of Object.entries(raw.profile)) {
60
+ if (!PROFILE_KEYS.includes(k))
61
+ fail(`profile.${k}`, "unknown key");
62
+ p[k] = v;
63
+ }
64
+ }
65
+ const regex = (key, fallback) => {
66
+ if (!Object.hasOwn(p, key))
67
+ return fallback;
68
+ if (!isStr(p[key]))
69
+ return fail(`profile.${key}`, "must be a non-empty string");
70
+ try {
71
+ return new RegExp(p[key]);
72
+ }
73
+ catch (error) {
74
+ return fail(`profile.${key}`, `invalid regular expression: ${error.message}`);
75
+ }
76
+ };
77
+ const bool = (key) => {
78
+ if (!Object.hasOwn(p, key))
79
+ return undefined;
80
+ if (typeof p[key] !== "boolean")
81
+ fail(`profile.${key}`, "must be true or false");
82
+ return p[key];
83
+ };
84
+ if (Object.hasOwn(p, "stateProperty") && !isStr(p.stateProperty))
85
+ fail("profile.stateProperty", "must be a non-empty string");
86
+ if (Object.hasOwn(p, "crossContextMechanisms") && (!Array.isArray(p.crossContextMechanisms) || !p.crossContextMechanisms.every(isStr)))
87
+ fail("profile.crossContextMechanisms", "must be an array of non-empty strings");
88
+ if (Object.hasOwn(p, "scenarioCoverage") && p.scenarioCoverage !== "printed" && p.scenarioCoverage !== "enforced")
89
+ fail("profile.scenarioCoverage", 'must be "printed" or "enforced"');
90
+ const rules = Object.fromEntries(RULES.map((r) => [r, DEFAULT_DISABLED[r] === undefined ? true : { disabled: DEFAULT_DISABLED[r] }]));
91
+ if (Object.hasOwn(p, "rules")) {
92
+ if (!isObj(p.rules))
93
+ fail("profile.rules", "must be an object");
94
+ for (const [r, sw] of Object.entries(p.rules)) {
95
+ if (!RULES.includes(r))
96
+ fail(`profile.rules.${r}`, "unknown rule");
97
+ const ok = sw === true || (isObj(sw) && Object.keys(sw).length === 1 && isStr(sw.disabled));
98
+ if (!ok)
99
+ fail(`profile.rules.${r}`, 'must be true or {"disabled": "<reason>"}');
100
+ rules[r] = sw;
101
+ }
102
+ }
103
+ const profile = {
104
+ actionIdPattern: regex("actionIdPattern", /^[A-Z][A-Z_]*$/),
105
+ linkFieldPattern: regex("linkFieldPattern", /_id$/),
106
+ stateProperty: Object.hasOwn(p, "stateProperty") ? p.stateProperty : "state",
107
+ crossContextMechanisms: Object.hasOwn(p, "crossContextMechanisms") ? [...p.crossContextMechanisms] : [],
108
+ requiredSource: facts.permissions !== undefined ? { R10: facts.permissions } : {},
109
+ rules,
110
+ };
111
+ if (Object.hasOwn(p, "scenarioCoverage"))
112
+ profile.scenarioCoverage = p.scenarioCoverage;
113
+ const idem = bool("idempotencyDeclarationRequired");
114
+ if (idem !== undefined)
115
+ profile.idempotencyDeclarationRequired = idem;
116
+ const ev = bool("evidenceRequired");
117
+ if (ev !== undefined)
118
+ profile.evidenceRequired = ev;
119
+ return { docs: docs, facts, profile };
120
+ }
121
+ // ------------------------------------------------------------------------------------------ text structure
122
+ /**
123
+ * Which lines are inside a ``` fence (opening and closing lines included), and the closed fenced blocks (info string and
124
+ * body lines). A fence opens on a line that, after at most three leading spaces, starts with n >= 3 backticks; it closes
125
+ * only on a line of >= n backticks (after at most three leading spaces) followed by nothing but white space.
126
+ * `open`: the text ends inside a fence.
127
+ */
128
+ function fences(lines) {
129
+ const inFence = [];
130
+ const blocks = [];
131
+ let n = 0, info = "", body = [];
132
+ for (const line of lines) {
133
+ if (n === 0) {
134
+ const m = /^ {0,3}(`{3,})(.*)$/.exec(line);
135
+ if (m)
136
+ [n, info, body] = [m[1].length, m[2].trim(), []];
137
+ inFence.push(m !== null);
138
+ }
139
+ else {
140
+ const m = /^ {0,3}(`{3,})\s*$/.exec(line);
141
+ if (m && m[1].length >= n) {
142
+ n = 0;
143
+ blocks.push({ info, body });
144
+ }
145
+ else
146
+ body.push(line);
147
+ inFence.push(true);
148
+ }
149
+ }
150
+ return { inFence, open: n !== 0, blocks };
151
+ }
152
+ const BEGIN = /^<!-- BEGIN GENERATED: ([A-Za-z0-9._-]+)(.*) -->$/;
153
+ const END = /^<!-- END GENERATED: ([A-Za-z0-9._-]+) -->$/;
154
+ /**
155
+ * The text without its generated blocks (marker lines included), and `breaks`: the indexes of the kept lines that follow a
156
+ * removed block (a pipe table does not run across one). Throws on a malformed, nested, mismatched or unclosed marker.
157
+ */
158
+ function withoutGenerated(text) {
159
+ const lines = text.split("\n");
160
+ const { inFence } = fences(lines);
161
+ const kept = [];
162
+ const breaks = new Set();
163
+ let open = null, removed = false;
164
+ lines.forEach((line, i) => {
165
+ const t = line.trim();
166
+ if (!inFence[i] && (t.startsWith("<!-- BEGIN GENERATED") || t.startsWith("<!-- END GENERATED"))) {
167
+ const b = BEGIN.exec(t), e = END.exec(t);
168
+ if (b) {
169
+ if (open !== null)
170
+ throw new Error(`line ${i + 1}: generated block ${b[1]} opens inside ${open}`);
171
+ open = b[1];
172
+ }
173
+ else if (e) {
174
+ if (open !== e[1])
175
+ throw new Error(`line ${i + 1}: generated block end ${e[1]} does not close ${open ?? "an open block"}`);
176
+ open = null;
177
+ }
178
+ else
179
+ throw new Error(`line ${i + 1}: malformed generated-block marker: ${t}`);
180
+ removed = true;
181
+ return;
182
+ }
183
+ if (open !== null)
184
+ return;
185
+ if (removed)
186
+ breaks.add(kept.length);
187
+ removed = false;
188
+ kept.push(line);
189
+ });
190
+ if (open !== null)
191
+ throw new Error(`generated block ${open} is not closed`);
192
+ return { text: kept.join("\n"), breaks };
193
+ }
194
+ /** ATX headings outside fences. Each heading has a short anchor (first word, one trailing `.` removed) and a full-title anchor. */
195
+ function headings(text, breaks) {
196
+ const lines = text.split("\n");
197
+ const { inFence, open } = fences(lines);
198
+ const heads = [];
199
+ lines.forEach((line, i) => {
200
+ const m = inFence[i] ? null : /^(#{1,6}) (.+)$/.exec(line);
201
+ if (m)
202
+ heads.push({ line: i, level: m[1].length, title: m[2] });
203
+ });
204
+ const list = heads.map((h, k) => {
205
+ let end = lines.length;
206
+ for (const n of heads.slice(k + 1))
207
+ if (n.level <= h.level) {
208
+ end = n.line;
209
+ break;
210
+ }
211
+ const t = h.title.trim();
212
+ const short = t.split(/\s+/)[0].replace(/\.$/, "");
213
+ const full = t.replace(/\s+#+$/, "").trim();
214
+ const own = new Set([...breaks].filter((b) => b > h.line && b < end).map((b) => b - h.line));
215
+ return { title: h.title, text: lines.slice(h.line, end).join("\n"), anchors: [...new Set([short, full])].filter((a) => a !== ""), breaks: own };
216
+ });
217
+ return { list, fenceOpen: open };
218
+ }
219
+ /** Bodies of the closed top-level fenced blocks whose info string is exactly `text` (a fence inside another fence is body text). */
220
+ const textBlocks = (text) => fences(text.split("\n")).blocks.filter((b) => b.info === "text").map((b) => b.body.join("\n"));
221
+ /** Lines of the section text that are outside fences. */
222
+ const unfenced = (text) => {
223
+ const lines = text.split("\n");
224
+ const { inFence } = fences(lines);
225
+ return lines.filter((_, i) => !inFence[i]);
226
+ };
227
+ /**
228
+ * 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
229
+ * 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.
230
+ */
231
+ function spans(s) {
232
+ const out = [];
233
+ let plain = "", i = 0;
234
+ const run = (from) => {
235
+ let k = from;
236
+ while (s[k] === "`")
237
+ k++;
238
+ return k - from;
239
+ };
240
+ while (i < s.length) {
241
+ if (s[i] === "\\" && (s[i + 1] === "`" || s[i + 1] === "|")) {
242
+ plain += s.slice(i, i + 2);
243
+ i += 2;
244
+ }
245
+ else if (s[i] === "`") {
246
+ const n = run(i);
247
+ let close = -1;
248
+ for (let j = i + n; j < s.length && close < 0;) {
249
+ const m = run(j);
250
+ if (m === n)
251
+ close = j;
252
+ j += Math.max(m, 1);
253
+ }
254
+ if (close < 0)
255
+ plain += s.slice(i, i + n);
256
+ else {
257
+ if (plain !== "")
258
+ out.push({ code: false, text: plain });
259
+ plain = "";
260
+ out.push({ code: true, text: s.slice(i, close + n) });
261
+ }
262
+ i = close < 0 ? i + n : close + n;
263
+ }
264
+ else
265
+ plain += s[i++];
266
+ }
267
+ if (plain !== "")
268
+ out.push({ code: false, text: plain });
269
+ return out;
270
+ }
271
+ /** Contents of the code spans of a text (CommonMark: one leading and one trailing space are dropped when both are present). */
272
+ function codeContents(s) {
273
+ return spans(s)
274
+ .filter((t) => t.code)
275
+ .map((t) => {
276
+ const n = t.text.length - t.text.replace(/^`+/, "").length;
277
+ const c = t.text.slice(n, -n);
278
+ return /^ .* $/s.test(c) && c.trim() !== "" ? c.slice(1, -1) : c;
279
+ });
280
+ }
281
+ /** 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. */
282
+ function cells(row) {
283
+ const s = row.trim();
284
+ const out = [];
285
+ let cur = "", endsWithSeparator = false;
286
+ for (const t of spans(s)) {
287
+ endsWithSeparator = false;
288
+ if (t.code) {
289
+ cur += t.text.replace(/\\\|/g, "|");
290
+ continue;
291
+ }
292
+ for (let i = 0; i < t.text.length; i++) {
293
+ endsWithSeparator = false;
294
+ if (t.text[i] === "\\" && t.text[i + 1] === "|") {
295
+ cur += "|";
296
+ i++;
297
+ }
298
+ else if (t.text[i] === "|") {
299
+ out.push(cur);
300
+ cur = "";
301
+ endsWithSeparator = true;
302
+ }
303
+ else
304
+ cur += t.text[i];
305
+ }
306
+ }
307
+ if (!endsWithSeparator)
308
+ out.push(cur);
309
+ if (s.startsWith("|"))
310
+ out.shift();
311
+ return out.map((c) => c.trim());
312
+ }
313
+ const ALIGNMENT = /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
314
+ /**
315
+ * Pipe tables outside fences: runs of lines that start with `|`, alignment rows dropped; each table is its rows of cells.
316
+ * A run ends at any other line, at a fence line and where a generated block was removed (`breaks`, section line indexes).
317
+ */
318
+ function tables(h) {
319
+ const out = [];
320
+ const lines = h.text.split("\n");
321
+ const { inFence } = fences(lines);
322
+ let cur = null;
323
+ lines.forEach((line, i) => {
324
+ const row = !inFence[i] && line.startsWith("|");
325
+ if (!row || h.breaks.has(i))
326
+ cur = null;
327
+ if (!row)
328
+ return;
329
+ if (!cur)
330
+ out.push((cur = []));
331
+ if (!ALIGNMENT.test(line))
332
+ cur.push(cells(line));
333
+ });
334
+ return out;
335
+ }
336
+ // ------------------------------------------------------------------------------------------ facts
337
+ /** Facts from plain Markdown documents. Optional facts are present exactly when their anchor is configured. */
338
+ export function markdownFacts(docs, config) {
339
+ const diagnostics = [];
340
+ const texts = [];
341
+ const index = new Map();
342
+ for (const d of docs) {
343
+ let text = d.text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
344
+ let breaks = new Set();
345
+ try {
346
+ ({ text, breaks } = withoutGenerated(text));
347
+ }
348
+ catch (error) {
349
+ diagnostics.push({ rule: "R2", kind: "markdown_generated_blocks", key: `doc:${d.path}`, msg: `${d.path}: ${error.message}` });
350
+ }
351
+ texts.push(text);
352
+ const { list, fenceOpen } = headings(text, breaks);
353
+ if (fenceOpen)
354
+ 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` });
355
+ for (const h of list)
356
+ for (const a of h.anchors)
357
+ index.set(a, [...(index.get(a) ?? []), h]);
358
+ }
359
+ const lookup = (anchor) => {
360
+ const hs = index.get(anchor) ?? [];
361
+ return hs.length === 0 ? "missing" : hs.length > 1 ? "ambiguous" : hs[0];
362
+ };
363
+ const section = (anchor) => {
364
+ const h = lookup(anchor);
365
+ return typeof h === "string" ? h : { title: h.title, text: h.text };
366
+ };
367
+ const f = (anchor, value) => ({ value, source: { anchor } });
368
+ const parseError = (name, msg) => diagnostics.push({ rule: FACT_RULE[name], kind: "markdown_parse", key: `facts.${name}`, msg });
369
+ // the section text of a configured fact, or null with a markdown_anchor diagnostic
370
+ const configured = (name, anchor) => {
371
+ const h = lookup(anchor);
372
+ if (typeof h !== "string")
373
+ return h;
374
+ diagnostics.push({ rule: FACT_RULE[name], kind: "markdown_anchor", key: `facts.${name}`, msg: `anchor ${anchor} of facts.${name} is ${h}` });
375
+ return null;
376
+ };
377
+ const catalog = (name, text) => {
378
+ const set = new Set();
379
+ if (text === null)
380
+ return set;
381
+ const blocks = textBlocks(text);
382
+ if (blocks.length !== 1) {
383
+ parseError(name, `facts.${name} needs exactly one text code block in its section, found ${blocks.length}`);
384
+ return set;
385
+ }
386
+ for (const line of blocks[0].split("\n")) {
387
+ const t = line.split("//")[0].trim();
388
+ if (t === "")
389
+ continue;
390
+ if (/\s/.test(t))
391
+ parseError(name, `facts.${name}: catalog line has white space: ${t}`);
392
+ else
393
+ set.add(t);
394
+ }
395
+ return set;
396
+ };
397
+ const facts = {
398
+ config: config.profile,
399
+ diagnostics,
400
+ info: [],
401
+ section,
402
+ containsTerm: (term) => texts.some((t) => IDENT(term).test(t)),
403
+ fieldList: (anchor, objectType) => {
404
+ const h = lookup(anchor);
405
+ if (typeof h === "string")
406
+ return null;
407
+ const blocks = textBlocks(h.text).filter((b) => b.split("\n")[0].trim() === objectType);
408
+ if (blocks.length !== 1)
409
+ return null;
410
+ const fields = new Map();
411
+ for (const line of blocks[0].split("\n")) {
412
+ const m = /^- (.+?)(?:\s*\/\/.*)?$/.exec(line);
413
+ if (m)
414
+ for (const part of m[1].split(" / ")) {
415
+ const t = part.trim();
416
+ const name = t.replace(/\?$/, "");
417
+ fields.set(name, { nullable: t.endsWith("?") || (fields.get(name)?.nullable ?? false) });
418
+ }
419
+ }
420
+ return f(anchor, fields);
421
+ },
422
+ };
423
+ const a = config.facts;
424
+ if (a.storeCatalog !== undefined)
425
+ facts.storeCatalog = f(a.storeCatalog, catalog("storeCatalog", configured("storeCatalog", a.storeCatalog)?.text ?? null));
426
+ if (a.eventCatalog !== undefined)
427
+ facts.eventCatalog = f(a.eventCatalog, catalog("eventCatalog", configured("eventCatalog", a.eventCatalog)?.text ?? null));
428
+ if (a.contextMembers !== undefined) {
429
+ const anchor = a.contextMembers;
430
+ const h = configured("contextMembers", anchor);
431
+ const members = new Map();
432
+ if (h !== null) {
433
+ for (const line of unfenced(h.text)) {
434
+ const m = /^- ([^:]+): (.+)$/.exec(line);
435
+ if (!m)
436
+ continue;
437
+ const context = m[1].trim();
438
+ if (members.has(context))
439
+ parseError("contextMembers", `facts.contextMembers: context ${context} is listed more than once`);
440
+ else
441
+ members.set(context, f(anchor, new Set(m[2].split(",").map((x) => x.trim()).filter((x) => x !== ""))));
442
+ }
443
+ if (members.size === 0)
444
+ parseError("contextMembers", "facts.contextMembers: the section has no `- <context>: <members>` line");
445
+ }
446
+ facts.contextMembers = members;
447
+ }
448
+ if (a.permissions !== undefined) {
449
+ const anchor = a.permissions;
450
+ const h = configured("permissions", anchor);
451
+ const rows = new Map();
452
+ if (h !== null) {
453
+ const ts = tables(h);
454
+ if (ts.length !== 1)
455
+ parseError("permissions", `facts.permissions needs exactly one pipe table in its section, found ${ts.length}`);
456
+ else if (ts[0].length === 0 || ts[0][0].length !== 2)
457
+ parseError("permissions", `facts.permissions: the table header has ${ts[0][0]?.length ?? 0} columns, expected 2`);
458
+ else
459
+ for (const row of ts[0].slice(1)) {
460
+ if (row.length !== 2) {
461
+ parseError("permissions", `facts.permissions: row has ${row.length} columns, expected 2: ${row.join(" | ")}`);
462
+ continue;
463
+ }
464
+ const keys = new Set(codeContents(row[1]));
465
+ for (const name of row[0].split(",").map((x) => x.trim()).filter((x) => x !== "")) {
466
+ if (rows.has(name))
467
+ parseError("permissions", `facts.permissions: row ${name} is listed more than once; the first row is kept`);
468
+ else
469
+ rows.set(name, { keys, text: row[1] });
470
+ }
471
+ }
472
+ }
473
+ facts.permissions = { catalog: f(anchor, catalog("permissions", h?.text ?? null)), rows: f(anchor, rows), quoteNamesKey: (q, k) => q.includes("`" + k + "`") };
474
+ }
475
+ return facts;
476
+ }
@@ -0,0 +1,6 @@
1
+ export type Op = {
2
+ op: "remove" | "replace" | "add";
3
+ path: string;
4
+ value?: unknown;
5
+ };
6
+ export declare function applyPatch(doc: any, ops: Op[]): any;
@@ -0,0 +1,37 @@
1
+ export function applyPatch(doc, ops) {
2
+ const out = structuredClone(doc);
3
+ for (const o of ops) {
4
+ const parts = o.path.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
5
+ const last = parts.pop();
6
+ let cur = out;
7
+ for (const k of parts) {
8
+ if (cur === null || typeof cur !== "object" || !(k in cur))
9
+ throw new Error(`patch path not found: ${o.path}`);
10
+ cur = cur[k];
11
+ }
12
+ if (Array.isArray(cur)) {
13
+ if (o.op === "add" && last === "-")
14
+ cur.push(o.value);
15
+ else {
16
+ const i = Number(last);
17
+ if (!Number.isInteger(i) || i < 0 || i >= cur.length + (o.op === "add" ? 1 : 0))
18
+ throw new Error(`bad array index: ${o.path}`);
19
+ if (o.op === "remove")
20
+ cur.splice(i, 1);
21
+ else if (o.op === "replace")
22
+ cur[i] = o.value;
23
+ else
24
+ cur.splice(i, 0, o.value);
25
+ }
26
+ }
27
+ else {
28
+ if (o.op !== "add" && !(last in cur))
29
+ throw new Error(`patch path not found: ${o.path}`);
30
+ if (o.op === "remove")
31
+ delete cur[last];
32
+ else
33
+ cur[last] = o.value;
34
+ }
35
+ }
36
+ return out;
37
+ }