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,241 @@
1
+ // Document review ledger (`avouch gate docs`). A rule of the tool, not of the format. Pure functions: they read only their
2
+ // arguments. The caller gives the sha256 function (hex digest of the UTF-8 text), so this file imports no runtime module.
3
+ import { isObj } from "./expr.js";
4
+ import { atxHeadings } from "./markdown.js";
5
+ const HEX = /^[0-9a-f]{64}$/;
6
+ const show = (x) => JSON.stringify(x) ?? String(x);
7
+ const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
8
+ /** The text of a key: `<doc> <path as JSON> <n>`. */
9
+ export const unitKeyText = (k) => `${k.doc} ${JSON.stringify(k.path)} ${k.n}`;
10
+ /**
11
+ * The units of one document. The text is normalized first: the BOM is removed and CRLF becomes LF. Generated blocks
12
+ * are not removed: their text is hashed as other text, so a change in it re-opens its unit (fail closed). Then the text
13
+ * is cut at each ATX heading outside fences. The units keep their line breaks, so together they are exactly the
14
+ * normalized text. The text before the first heading is the unit with heading null and path []; it is always the first unit.
15
+ */
16
+ export function docUnits(d, sha256) {
17
+ const text = d.text.replace(/^/, "").replace(/\r\n/g, "\n");
18
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
19
+ const { heads } = atxHeadings(lines.map((l) => l.replace(/\n$/, "")));
20
+ const starts = [{ line: 0, level: 0, title: null }, ...heads];
21
+ const stack = [];
22
+ const seen = new Map();
23
+ return starts.map((h, k) => {
24
+ const end = k + 1 < starts.length ? starts[k + 1].line : lines.length;
25
+ const body = lines.slice(h.line, end);
26
+ let path = [];
27
+ if (h.title !== null) {
28
+ while (stack.length > 0 && stack[stack.length - 1].level >= h.level)
29
+ stack.pop();
30
+ const title = h.title.trim().replace(/\s+#+$/, "").trim();
31
+ stack.push({ level: h.level, title });
32
+ path = stack.map((s) => s.title);
33
+ }
34
+ const id = JSON.stringify(path);
35
+ const n = (seen.get(id) ?? 0) + 1;
36
+ seen.set(id, n);
37
+ const unitText = body.join("");
38
+ const empty = body.slice(h.title === null ? 0 : 1).every((l) => l.trim() === "");
39
+ return { doc: d.doc, path, n, heading: h.title, text: unitText, sha256: sha256(unitText), empty };
40
+ });
41
+ }
42
+ /**
43
+ * The outline of a document: the sha256 of the JSON text of the ordered list of `[path, n, heading line]` of all its
44
+ * units, empty units included. The heading line is the exact first line (null for the text before the first heading),
45
+ * so a change of level or of closing #s changes the outline, also for an empty unit.
46
+ */
47
+ export const outlineHash = (units, sha256) => sha256(JSON.stringify(units.map((u) => [u.path, u.n, u.heading === null ? null : u.text.split("\n")[0]])));
48
+ /** Ledger order: doc, then the outline entry before the unit entries, then path element by element (a prefix first), then n. */
49
+ export function compareEntries(a, b) {
50
+ const d = cmpStr(a.doc, b.doc);
51
+ if (d !== 0)
52
+ return d;
53
+ const ao = "outline" in a, bo = "outline" in b;
54
+ if (ao || bo)
55
+ return ao && bo ? 0 : ao ? -1 : 1;
56
+ const pa = a.path, pb = b.path;
57
+ for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
58
+ const c = cmpStr(pa[i], pb[i]);
59
+ if (c !== 0)
60
+ return c;
61
+ }
62
+ return pa.length !== pb.length ? pa.length - pb.length : a.n - b.n;
63
+ }
64
+ function checkKeys(o, required, allowed) {
65
+ for (const k of required)
66
+ if (!Object.hasOwn(o, k))
67
+ throw new Error(`${k}: missing`);
68
+ for (const k of Object.keys(o))
69
+ if (!allowed.includes(k))
70
+ throw new Error(`${k}: unknown key`);
71
+ }
72
+ // The shape of one entry line (not the header). Throws `<reason>` without the line prefix.
73
+ function entry(x) {
74
+ if (!isObj(x))
75
+ throw new Error("not an object");
76
+ if (typeof x.doc !== "string" || x.doc === "")
77
+ throw new Error("doc: must be a non-empty string");
78
+ if (Object.hasOwn(x, "outline")) {
79
+ checkKeys(x, ["doc", "outline"], ["doc", "outline"]);
80
+ if (typeof x.outline !== "string" || !HEX.test(x.outline))
81
+ throw new Error("outline: must be a sha256 in lower-case hex");
82
+ return { doc: x.doc, outline: x.outline };
83
+ }
84
+ checkKeys(x, ["doc", "path", "n", "sha256"], ["doc", "path", "n", "sha256", "claims", "noClaim"]);
85
+ if (!Array.isArray(x.path) || !x.path.every((t) => typeof t === "string"))
86
+ throw new Error("path: must be an array of strings");
87
+ if (typeof x.n !== "number" || !Number.isSafeInteger(x.n) || x.n < 1)
88
+ throw new Error("n: must be an integer from 1");
89
+ if (typeof x.sha256 !== "string" || !HEX.test(x.sha256))
90
+ throw new Error("sha256: must be a sha256 in lower-case hex");
91
+ const key = { doc: x.doc, path: [...x.path], n: x.n, sha256: x.sha256 };
92
+ if (Object.hasOwn(x, "claims") === Object.hasOwn(x, "noClaim"))
93
+ throw new Error("must have exactly one of claims and noClaim");
94
+ if (Object.hasOwn(x, "noClaim")) {
95
+ if (typeof x.noClaim !== "string" || x.noClaim.trim() === "")
96
+ throw new Error("noClaim: must be a non-empty reason");
97
+ return { ...key, noClaim: x.noClaim };
98
+ }
99
+ const c = x.claims;
100
+ if (!Array.isArray(c) || c.length === 0)
101
+ throw new Error("claims: must be a non-empty array of JSON Pointers");
102
+ c.forEach((p, i) => {
103
+ if (p === "")
104
+ throw new Error(`claims[${i}]: the root is not a claim`);
105
+ if (typeof p !== "string" || !p.startsWith("/"))
106
+ throw new Error(`claims[${i}]: ${show(p)} is not a JSON Pointer that starts with /`);
107
+ if (/~(?![01])/.test(p))
108
+ throw new Error(`claims[${i}]: ${show(p)}: ~ must be followed by 0 or 1`);
109
+ const j = c.indexOf(p);
110
+ if (j !== i)
111
+ throw new Error(`claims[${i}]: duplicate of claims[${j}]`);
112
+ });
113
+ return { ...key, claims: [...c] };
114
+ }
115
+ /**
116
+ * Checks the shape of the ledger lines (`{line, value}` from a JSON Lines file, blank lines skipped) and returns the
117
+ * entries. The first line is `{"avouchLedger":1}`; the entries follow in ledger order, each key once.
118
+ * Throws `<line>: <reason>` on the first error.
119
+ */
120
+ export function parseLedger(lines) {
121
+ const at = (line, reason) => {
122
+ throw new Error(`${line}: ${reason}`);
123
+ };
124
+ if (lines.length === 0)
125
+ return at(1, 'missing header {"avouchLedger":1}');
126
+ const [head, ...rest] = lines;
127
+ const h = head.value;
128
+ if (!isObj(h) || !Object.hasOwn(h, "avouchLedger"))
129
+ return at(head.line, 'the first line must be {"avouchLedger":1}');
130
+ if (h.avouchLedger !== 1)
131
+ return at(head.line, `unknown version ${show(h.avouchLedger)}`);
132
+ for (const k of Object.keys(h))
133
+ if (k !== "avouchLedger")
134
+ at(head.line, `${k}: unknown key`);
135
+ const out = [];
136
+ for (const { line, value } of rest) {
137
+ if (isObj(value) && Object.hasOwn(value, "avouchLedger"))
138
+ at(line, "the header is allowed on the first line only");
139
+ let e;
140
+ try {
141
+ e = entry(value);
142
+ }
143
+ catch (error) {
144
+ return at(line, error.message);
145
+ }
146
+ const prev = out.at(-1);
147
+ if (prev !== undefined) {
148
+ const c = compareEntries(prev, e);
149
+ if (c === 0)
150
+ at(line, `duplicate key of line ${prev.line}`);
151
+ if (c > 0)
152
+ at(line, `not sorted: this entry must come before line ${prev.line} (order: doc, its outline entry, path element by element, n)`);
153
+ }
154
+ out.push({ ...e, line });
155
+ }
156
+ return out;
157
+ }
158
+ /**
159
+ * Resolves a JSON Pointer (RFC 6901: `~1` is `/`, `~0` is `~`; a `~` before another character, and the root "", do not resolve) in a value. One extension: on an array, a segment
160
+ * selects the element whose `id` is the segment, or else the string element equal to it. A numeric index does not select.
161
+ */
162
+ export function resolvePointer(value, pointer) {
163
+ if (!pointer.startsWith("/") || /~(?![01])/.test(pointer))
164
+ return false;
165
+ let cur = value;
166
+ for (const raw of pointer.slice(1).split("/")) {
167
+ const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~");
168
+ if (Array.isArray(cur)) {
169
+ const hit = cur.find((e) => isObj(e) && e.id === seg) ?? cur.find((e) => e === seg);
170
+ if (hit === undefined)
171
+ return false;
172
+ cur = hit;
173
+ }
174
+ else if (isObj(cur) && Object.hasOwn(cur, seg))
175
+ cur = cur[seg];
176
+ else
177
+ return false;
178
+ }
179
+ return true;
180
+ }
181
+ /** The gate: the current units of the documents against the ledger entries, and the claims against the ontology. */
182
+ export function verifyLedger(docs, entries, ontology, sha256) {
183
+ const r = { ok: false, docs: docs.length, units: 0, emptyUnits: 0, changed: [], unreviewed: [], stale: [], outline: [], dangling: [] };
184
+ const id = (k) => JSON.stringify([k.doc, k.path, k.n]);
185
+ const fail = (u) => ({
186
+ doc: u.doc, path: u.path, n: u.n, sha256: u.sha256, firstLine: u.text.split("\n")[0],
187
+ entry: JSON.stringify({ doc: u.doc, path: u.path, n: u.n, sha256: u.sha256 }),
188
+ });
189
+ const current = new Map();
190
+ const outlines = new Map();
191
+ for (const d of docs) {
192
+ const units = docUnits(d, sha256);
193
+ outlines.set(d.doc, outlineHash(units, sha256));
194
+ for (const u of units) {
195
+ if (u.empty)
196
+ r.emptyUnits++;
197
+ else {
198
+ r.units++;
199
+ current.set(id(u), u);
200
+ }
201
+ }
202
+ }
203
+ const covered = new Set();
204
+ const outlineSeen = new Set();
205
+ for (const e of entries) {
206
+ if ("outline" in e) {
207
+ if (!outlines.has(e.doc))
208
+ r.stale.push({ doc: e.doc, outline: true });
209
+ else {
210
+ outlineSeen.add(e.doc);
211
+ if (e.outline !== outlines.get(e.doc))
212
+ r.outline.push({ doc: e.doc, sha256: outlines.get(e.doc) });
213
+ }
214
+ continue;
215
+ }
216
+ const key = { doc: e.doc, path: e.path, n: e.n };
217
+ const u = current.get(id(key));
218
+ if (u === undefined)
219
+ r.stale.push(key);
220
+ else {
221
+ covered.add(id(key));
222
+ if (u.sha256 !== e.sha256)
223
+ r.changed.push(fail(u));
224
+ }
225
+ if ("claims" in e)
226
+ for (const p of e.claims)
227
+ if (!resolvePointer(ontology, p))
228
+ r.dangling.push({ ...key, pointer: p });
229
+ }
230
+ for (const [k, u] of current)
231
+ if (!covered.has(k))
232
+ r.unreviewed.push(fail(u));
233
+ for (const d of docs)
234
+ if (!outlineSeen.has(d.doc))
235
+ r.outline.push({ doc: d.doc, sha256: outlines.get(d.doc) });
236
+ r.outline.sort((a, b) => cmpStr(a.doc, b.doc));
237
+ for (const list of [r.changed, r.unreviewed, r.stale, r.dangling])
238
+ list.sort(compareEntries); // ledger order (stable: pointers stay in line order)
239
+ r.ok = r.changed.length + r.unreviewed.length + r.stale.length + r.outline.length + r.dangling.length === 0;
240
+ return r;
241
+ }
@@ -0,0 +1,66 @@
1
+ import { type FormatConfig, type SourceFacts } from "./contract.js";
2
+ export interface MarkdownDoc {
3
+ path: string;
4
+ text: string;
5
+ }
6
+ export type MarkdownFactName = "storeCatalog" | "eventCatalog" | "contextMembers" | "permissions" | "scenarios" | "transitionTriggers";
7
+ /** `stateMachines`: object type -> anchor of the section that holds its state diagram (R3); `facts` holds the string-valued anchors only. */
8
+ export interface MarkdownConfig {
9
+ docs: string[];
10
+ facts: Partial<Record<MarkdownFactName, string>>;
11
+ stateMachines?: Record<string, string>;
12
+ profile: FormatConfig;
13
+ }
14
+ /** Parses the JSON configuration (the `.json` file of `--adapter`). Throws `<file>: <json path>: <problem>` on any error. */
15
+ export declare function parseMarkdownConfig(text: string, file: string): MarkdownConfig;
16
+ /**
17
+ * Validates a parsed Markdown configuration: the content of a `.json` file, or the `adapter.markdown` object of
18
+ * `avouch.config.json`. Throws `<where>: <json path>: <problem>` on any error.
19
+ */
20
+ export declare function validateMarkdownConfig(value: unknown, where: string): MarkdownConfig;
21
+ /**
22
+ * Which lines are inside a ``` fence (opening and closing lines included), and the closed fenced blocks (info string and
23
+ * body lines). A fence opens on a line that, after at most three leading spaces, starts with n >= 3 backticks; it closes
24
+ * only on a line of >= n backticks (after at most three leading spaces) followed by nothing but white space.
25
+ * `start`: the index of the opening line. `open`: the text ends inside a fence.
26
+ */
27
+ export declare function fences(lines: string[]): {
28
+ inFence: boolean[];
29
+ open: boolean;
30
+ blocks: {
31
+ info: string;
32
+ body: string[];
33
+ start: number;
34
+ }[];
35
+ };
36
+ /** The ATX heading lines outside fences: line index (from 0), level and title as written. `open`: the text ends inside a fence. */
37
+ export declare function atxHeadings(lines: string[]): {
38
+ heads: {
39
+ line: number;
40
+ level: number;
41
+ title: string;
42
+ }[];
43
+ open: boolean;
44
+ };
45
+ /** A top-level `text` code block of a document, as `avouch facts` reports it. */
46
+ export interface TextBlock {
47
+ doc: string;
48
+ /** the short anchor of the innermost section that holds the block, or "" before the first heading */
49
+ anchor: string;
50
+ /** the first line of the block, trimmed (the object type of a field list) */
51
+ first: string;
52
+ /** line (1-based, in the document as written) of the opening fence */
53
+ line: number;
54
+ fields: Map<string, {
55
+ nullable: boolean;
56
+ }>;
57
+ /** lines after the first that are not empty, not a `- ` line and not only a `//` comment */
58
+ strays: {
59
+ line: number;
60
+ text: string;
61
+ }[];
62
+ }
63
+ /** The top-level `text` code blocks of the documents, in the text that the adapter reads (generated blocks removed). */
64
+ export declare function markdownTextBlocks(docs: MarkdownDoc[]): TextBlock[];
65
+ /** Facts from plain Markdown documents. Optional facts are present exactly when their anchor is configured. */
66
+ export declare function markdownFacts(docs: MarkdownDoc[], config: MarkdownConfig): SourceFacts;