@sema-agent/wiki-gate 0.0.0-stage → 7.67.1

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/dist/check.js ADDED
@@ -0,0 +1,378 @@
1
+ import { createHash } from "node:crypto";
2
+ import { GEN_CLOSE, claimBlocks, correctionLineOf, inlineGensOf, markersOf, sectionIdOf, splitPage } from "./page.js";
3
+ import { coversKeysOf, releaseDistanceOf, repoOf, resolveRef } from "./tree.js";
4
+ import { compareVersions, parseRun, uAgeFinding, vAgeFinding } from "./versions.js";
5
+ import { GENRES, LEVELS, genreRow } from "./genre.js";
6
+ import { parseCritKey } from "./acceptance.js";
7
+ import { knobTableProblems } from "./knobs.js";
8
+ export const PAGE_KINDS = Object.freeze(["concept", "reference", "howto", "upgrade"]);
9
+ export const OWNER_REPOS = Object.freeze(["core", "server", "cli", "client-core"]);
10
+ const PAGE_ID_PREFIXES = Object.freeze([...OWNER_REPOS, ...Object.keys(GENRES)]);
11
+ const PAGE_ID = new RegExp(`^(?:${PAGE_ID_PREFIXES.join("|")})/[a-z0-9][a-z0-9./-]*$`);
12
+ const RECOMPUTED_KINDS = Object.freeze(["export", "set", "contract", "src", "catalog"]);
13
+ export const FRONTMATTER_KEYS = Object.freeze({
14
+ title: "one",
15
+ "page-id": "one",
16
+ audience: "many",
17
+ kind: "one",
18
+ "owner-repo": "one",
19
+ secondary: "one",
20
+ stable: "one",
21
+ feats: "many",
22
+ verified: "one",
23
+ anchor: "many",
24
+ falsified: "many",
25
+ });
26
+ const REQUIRED_KEYS = Object.freeze(["title", "page-id", "audience", "kind", "owner-repo", "stable", "feats", "verified"]);
27
+ export function publicnessBans(workItemName, extra = []) {
28
+ return Object.freeze([
29
+ ...(workItemName === undefined ? [] : [["an internal work-item name", new RegExp(`(?<![A-Za-z0-9-])${workItemName.source}`, workItemName.flags.replace(/[gy]/g, ""))]]),
30
+ ["a board post number", /\[\d{4,5}\]/],
31
+ ["a ruling number", /\bC-R\d+/],
32
+ ["a neighbour repository's ticket", /\b(?:S|L|CC)-\d+\b/],
33
+ ["a defect-record number", /\bB-\d+\b/],
34
+ ["a ticket number in prose", /(?<![\w&])#\d{3,}\b/],
35
+ ["a design-record number", /\bdesign\/\d+/],
36
+ ["a decoded reference bundle", /\bcli\d{3}\.js\b/],
37
+ ["an internal repository's name", /(?<![\w@/-])sema-(?:internal|comms)(?:-[a-z]+)?\b/],
38
+ ["a review tool's name", /\bcodex\b/i],
39
+ ["a review round", /\bround \d+\b/i],
40
+ ["an internal narrative word", /裁定|帖|看板|派车/],
41
+ ...extra.map(([what, rx]) => [what, new RegExp(rx.source, rx.flags.replace(/[gy]/g, ""))]),
42
+ ]);
43
+ }
44
+ const sha = (s) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
45
+ const genKey = (g) => `gen:${g.name}${g.args.length ? ` ${g.args.join(" ")}` : ""}`;
46
+ export const generatorOf = (tree, name) => (Object.hasOwn(tree.generators, name) ? tree.generators[name] : undefined);
47
+ export function generate(gen, args) {
48
+ const g = gen.render(...args);
49
+ if (g === null || typeof g !== "object" || typeof g.text !== "string" || !Array.isArray(g.refs) || g.refs.some((r) => typeof r !== "string")) {
50
+ throw new Error("a generator answers `{ text, refs }` (a string and an array of refs)");
51
+ }
52
+ return g;
53
+ }
54
+ export function checkPage(tree, rel, text, { stamp = false } = {}) {
55
+ const findings = [];
56
+ const repo = repoOf(tree);
57
+ const row = genreRow(tree.genre);
58
+ const { fm, body, bodyLine, fmLines } = splitPage(text);
59
+ const at = (gate, line, message, fixed) => {
60
+ findings.push({ gate, page: rel, line, message, ...(fixed ? { fixed } : {}) });
61
+ };
62
+ if (fm === null) {
63
+ at("anchors", 1, "the page has no frontmatter (`---` … `---`)");
64
+ return { findings, digests: {}, coords: {}, levels: {} };
65
+ }
66
+ const fileLine = (bodyRelative) => bodyLine + bodyRelative - 1;
67
+ for (const l of fmLines) {
68
+ if (l.key === undefined) {
69
+ if (l.raw.trim() !== "")
70
+ at("anchors", l.line, `a frontmatter line that is not \`key: value\`: ${JSON.stringify(l.raw.slice(0, 60))}`);
71
+ continue;
72
+ }
73
+ if (!Object.hasOwn(FRONTMATTER_KEYS, l.key))
74
+ at("anchors", l.line, `unknown frontmatter key \`${l.key}\` (the keys are ${Object.keys(FRONTMATTER_KEYS).join(", ")})`);
75
+ }
76
+ for (const [k, card] of Object.entries(FRONTMATTER_KEYS))
77
+ if (card === "one" && (fm._list[k]?.length ?? 0) > 1)
78
+ at("anchors", 1, `\`${k}\` is given ${fm._list[k].length} times; it is one value`);
79
+ for (const k of REQUIRED_KEYS)
80
+ if (fm[k] === undefined || fm[k] === "")
81
+ at("anchors", 1, `frontmatter lacks \`${k}\``);
82
+ for (const a of fm._list.audience ?? [])
83
+ if (!row.audiences.includes(a))
84
+ at("anchors", 1, `audience \`${a}\` is not one of ${row.audiences.join(", ")}`);
85
+ if (fm.kind !== undefined && !PAGE_KINDS.includes(fm.kind))
86
+ at("anchors", 1, `kind \`${fm.kind}\` is not one of ${PAGE_KINDS.join(", ")}`);
87
+ const owner = fm["owner-repo"];
88
+ if (owner !== undefined) {
89
+ if (!OWNER_REPOS.includes(owner))
90
+ at("anchors", 1, `owner-repo \`${owner}\` is not one of ${OWNER_REPOS.join(", ")}`);
91
+ else if (owner !== repo.word)
92
+ at("anchors", 1, `owner-repo \`${owner}\` is not this repository (${repo.word}): a page's refs resolve in its owner's tree, and a page lives in that tree`);
93
+ }
94
+ if (fm.stable !== undefined && fm.stable !== "true" && fm.stable !== "false")
95
+ at("anchors", 1, `stable must be true or false, got \`${fm.stable}\``);
96
+ if (fm["page-id"] !== undefined && !PAGE_ID.test(fm["page-id"]))
97
+ at("anchors", 1, `page-id \`${fm["page-id"]}\` must read <${PAGE_ID_PREFIXES.join("|")}>/<path>`);
98
+ const feats = fm._list.feats ?? [];
99
+ for (const f of feats)
100
+ if (f !== "none" && !/^F-\d{3}$/.test(f))
101
+ at("anchors", 1, `feats \`${f}\` is not F-<nnn> or none`);
102
+ if (feats.includes("none") && feats.length > 1)
103
+ at("anchors", 1, "feats names `none` and a feature row: one or the other");
104
+ if (fm.verified !== undefined) {
105
+ const vm = /^(@?[a-z0-9][a-z0-9./_-]*)@(\d+\.\d+\.\d+) ([0-9a-f]{7,40})$/.exec(fm.verified);
106
+ if (!vm)
107
+ at("anchors", 1, "verified must read `<package>@<X.Y.Z> <commit sha>`");
108
+ else if (vm[1] !== repo.name)
109
+ at("anchors", 1, `verified names ${vm[1]}, not this repository's package ${repo.name}`);
110
+ else if (compareVersions(vm[2], repo.version) > 0)
111
+ at("anchors", 1, `verified names ${vm[2]}, a version this tree (${repo.version}) has not reached`);
112
+ }
113
+ const falsified = [];
114
+ for (const f of fm._list.falsified ?? []) {
115
+ const fmm = /^(W-\d+) (\S+) \d{4}-\d{2}-\d{2}$/.exec(f);
116
+ if (!fmm)
117
+ at("anchors", 1, `falsified must read \`W-<n> <section-id> <YYYY-MM-DD>\`, got ${JSON.stringify(f)}`);
118
+ else
119
+ falsified.push({ id: fmm[1], section: fmm[2] });
120
+ }
121
+ const stamped = Object.create(null);
122
+ for (const v of fm._list.anchor ?? []) {
123
+ const am = /^(.+) (sha256:[0-9a-f]{16})$/.exec(v);
124
+ if (am)
125
+ stamped[am[1]] = am[2];
126
+ else
127
+ at("anchors", 1, `anchor must read \`<ref> sha256:<16 hex>\`, got ${JSON.stringify(v.slice(0, 60))}`);
128
+ }
129
+ const stable = fm.stable === "true";
130
+ const { blocks, gens, fences, units, headings, problems } = claimBlocks(body);
131
+ for (const p of problems)
132
+ at("anchors", fileLine(p.line), p.message);
133
+ const refs = new Map();
134
+ const cite = (r, line) => {
135
+ refs.set(r, [...(refs.get(r) ?? []), line]);
136
+ };
137
+ const levels = Object.fromEntries(LEVELS.map((l) => [l, 0]));
138
+ const judgeMarker = (line, mk, hasGen) => {
139
+ levels[mk.level]++;
140
+ if (!row.levels.includes(mk.level)) {
141
+ at("anchors", line, `level \`${mk.level}\` is not a level of the ${tree.genre} genre (its levels are ${row.levels.join(", ")})`);
142
+ return;
143
+ }
144
+ if (mk.level === "I" && stable)
145
+ at("anchors", line, "an `I` sentence on a stable page");
146
+ if (mk.level === "G" && !hasGen && !mk.refs.some((r) => RECOMPUTED_KINDS.includes(r.split(":")[0])))
147
+ at("anchors", line, `a \`G\` marker cites none of ${RECOMPUTED_KINDS.join(" / ")}`);
148
+ if (mk.level === "P" && !mk.refs.some((r) => r.startsWith("pin:")))
149
+ at("anchors", line, "a `P` marker cites no `pin:`");
150
+ if (mk.level === "V" && !mk.refs.some((r) => r.startsWith("run:")))
151
+ at("anchors", line, "a `V` marker cites no `run:`");
152
+ const crits = mk.refs.filter((r) => r.startsWith("crit:")).length;
153
+ if (mk.level === "U" && crits !== 1)
154
+ at("anchors", line, `a \`U\` marker cites exactly one \`crit:\` ref, this one cites ${crits}`);
155
+ if (mk.level !== "U" && crits > 0)
156
+ at("anchors", line, `a \`crit:\` ref is a \`U\` marker's evidence, not a \`${mk.level}\` marker's`);
157
+ for (const r of mk.refs)
158
+ cite(r, line);
159
+ };
160
+ const sectionAt = (bodyRelative) => {
161
+ const h = headings.filter((x) => x.line < bodyRelative).at(-1);
162
+ return h === undefined ? undefined : sectionIdOf(h.text);
163
+ };
164
+ const correctionLines = [];
165
+ for (const b of blocks) {
166
+ const line = fileLine(b.line);
167
+ const { stripped, markers } = markersOf(b.text);
168
+ const correction = correctionLineOf(b.text);
169
+ if (correction)
170
+ correctionLines.push({ id: correction.correction, section: sectionAt(b.line), line });
171
+ if (markers.length === 0) {
172
+ at("anchors", line, `a claim block with no marker: ${JSON.stringify(b.text.slice(0, 70))}`);
173
+ continue;
174
+ }
175
+ const last = markers[markers.length - 1];
176
+ const tail = stripped.slice(last.index + last.length).replace(/\|?\s*$/, "").trim();
177
+ if (tail.length > 0)
178
+ at("anchors", line, `text after the last marker is vouched by nothing: ${JSON.stringify(b.text.slice(last.index + last.length).trim().slice(0, 60))}`);
179
+ const uMarkers = markers.filter((mk) => mk.level === "U").length;
180
+ if (uMarkers > 1)
181
+ at("anchors", line, `a claim block holds at most one \`U\` marker, this one holds ${uMarkers}`);
182
+ const hasGen = inlineGensOf(b.text).length > 0;
183
+ for (const mk of markers)
184
+ judgeMarker(line, mk, hasGen);
185
+ }
186
+ for (const f of fences) {
187
+ const line = fileLine(f.line);
188
+ if (f.unclosed)
189
+ at("anchors", line, "a code fence that never closes");
190
+ const { markers } = markersOf(f.info);
191
+ if (markers.length === 0)
192
+ at("anchors", line, "a code fence with no marker in its info string");
193
+ for (const mk of markers)
194
+ judgeMarker(line, mk, false);
195
+ }
196
+ const generated = new Map();
197
+ const coversKeys = coversKeysOf(tree.config);
198
+ for (const g of gens) {
199
+ const line = fileLine(g.line);
200
+ const gen = generatorOf(tree, g.name);
201
+ if (!gen) {
202
+ at("anchors", line, `unknown generator \`${g.name}\` (the generators are ${Object.keys(tree.generators).join(", ")})`);
203
+ continue;
204
+ }
205
+ if (!g.closed) {
206
+ at("anchors", line, `the generated region \`${g.name}\` never closes`);
207
+ continue;
208
+ }
209
+ let fresh;
210
+ try {
211
+ fresh = generate(gen, g.args);
212
+ }
213
+ catch (e) {
214
+ at("anchors", line, `the generator \`${g.name}\` cannot read its source: ${e.message}`);
215
+ continue;
216
+ }
217
+ generated.set(g, fresh);
218
+ if (markersOf(fresh.text).markers.length > 0)
219
+ at("anchors", line, `the generated region \`${g.name}\` spells a marker: a generator hands its evidence over as refs, never as marker text`);
220
+ if (/<!--|-->/.test(fresh.text))
221
+ at("anchors", line, `the generated region \`${g.name}\` spells a comment delimiter: its text cannot be told from its fences`);
222
+ for (const r of fresh.refs)
223
+ cite(r, line);
224
+ if (gen.covers) {
225
+ try {
226
+ for (const k of Object.keys(gen.covers(...g.args)))
227
+ if (!coversKeys.includes(k))
228
+ at("anchors", line, `the generator \`${g.name}\` covers \`${k}\`, the covers key of no source this repository declares (${coversKeys.join(", ") || "none"})`);
229
+ }
230
+ catch (e) {
231
+ at("anchors", line, `the generator \`${g.name}\` cannot say what it covers: ${e.message}`);
232
+ }
233
+ }
234
+ if (gen.rowFields) {
235
+ try {
236
+ const fields = gen.rowFields(...g.args);
237
+ if (!Array.isArray(fields) || fields.some((f) => typeof f !== "string"))
238
+ throw new Error("it answers an array of knob field ids");
239
+ for (const p of knobTableProblems(g.name, fields, fresh.text))
240
+ at("anchors", line, p);
241
+ }
242
+ catch (e) {
243
+ at("anchors", line, `the generator \`${g.name}\` cannot say which knob fields it renders: ${e.message}`);
244
+ }
245
+ }
246
+ }
247
+ const bans = publicnessBans(tree.config.readers?.workItemName, tree.config.bans);
248
+ const scan = (raw, line, where) => {
249
+ const textOf = raw.replace(/pin:[^\s#"]+#/g, "pin:#");
250
+ for (const [what, rx] of bans) {
251
+ const hit = rx.exec(textOf);
252
+ if (hit)
253
+ at("anchors", line, `${what} in ${where} (${JSON.stringify(hit[0])}): the published pages carry no identifier of the internal record`);
254
+ }
255
+ };
256
+ [`title: ${fm.title ?? ""}`, ...body.split("\n")].forEach((raw, i) => scan(raw, i === 0 ? 1 : fileLine(i), "the page text"));
257
+ for (const [g, fresh] of generated)
258
+ for (const r of fresh.refs)
259
+ scan(r, fileLine(g.line), `a ref the generated region \`${g.name}\` hands over`);
260
+ if (tree.correctionsDeclared) {
261
+ const records = tree.corrections() ?? [];
262
+ const pageId = fm["page-id"];
263
+ const held = new Map(records.map((r) => [r.id, r]));
264
+ for (const r of records) {
265
+ if (r.repo !== repo.word || r.pageId !== pageId)
266
+ continue;
267
+ if (r.status === "open") {
268
+ at("anchors", 1, `correction ${r.id} is open against this page (section \`${r.section}\`): the page fails until the correction is closed`, "fail");
269
+ continue;
270
+ }
271
+ if (!falsified.some((f) => f.id === r.id))
272
+ at("anchors", 1, `correction ${r.id} is closed and the page carries no \`falsified: ${r.id} ${r.section} <date>\` line`);
273
+ if (!correctionLines.some((c) => c.id === r.id && c.section === r.section))
274
+ at("anchors", 1, `correction ${r.id} is closed and no correction line under section \`${r.section}\` names it`);
275
+ }
276
+ for (const f of falsified) {
277
+ const r = held.get(f.id);
278
+ if (r === undefined)
279
+ at("anchors", 1, `falsified names ${f.id}, which the correction record does not hold`);
280
+ else if (r.repo !== repo.word || r.pageId !== pageId)
281
+ at("anchors", 1, `falsified names ${f.id}, a correction of ${r.repo} ${r.pageId}, not of this page`);
282
+ else if (r.section !== f.section)
283
+ at("anchors", 1, `falsified names ${f.id} under section \`${f.section}\`; the record names \`${r.section}\``);
284
+ }
285
+ for (const c of correctionLines) {
286
+ const r = held.get(c.id);
287
+ if (r === undefined)
288
+ at("anchors", c.line, `a correction line names ${c.id}, which the correction record does not hold`);
289
+ else if (r.repo !== repo.word || r.pageId !== pageId)
290
+ at("anchors", c.line, `a correction line names ${c.id}, a correction of ${r.repo} ${r.pageId}, not of this page`);
291
+ }
292
+ }
293
+ const digests = { text: sha(units.map((u) => `${u.kind}:${u.text}`).join("\n\n")) };
294
+ const coords = {};
295
+ for (const [ref, lines] of refs) {
296
+ const r = resolveRef(tree, ref);
297
+ if (r === undefined) {
298
+ at("anchors", lines[0], `\`${ref}\` does not resolve in this tree`);
299
+ continue;
300
+ }
301
+ if ("error" in r) {
302
+ at("anchors", lines[0], r.error, "fixed" in r ? r.fixed : undefined);
303
+ continue;
304
+ }
305
+ if (ref.startsWith("run:")) {
306
+ const age = vAgeFinding(parseRun(ref.slice(4)), repo);
307
+ if (age)
308
+ at("freshness", lines[0], age.message, age.fixed);
309
+ continue;
310
+ }
311
+ if (ref.startsWith("crit:")) {
312
+ const age = uAgeFinding(parseCritKey(ref.slice(5)).version, repo, releaseDistanceOf(tree));
313
+ if (age)
314
+ at("freshness", lines[0], age.message, age.fixed);
315
+ }
316
+ digests[ref] = sha(r.text);
317
+ if (r.file && r.line)
318
+ coords[ref] = `${r.file}:${r.line}`;
319
+ }
320
+ for (const [g, fresh] of generated) {
321
+ digests[genKey(g)] = sha(fresh.text);
322
+ if (!stamp && fresh.text !== g.content)
323
+ at("freshness", fileLine(g.line), `the generated region \`${genKey(g).slice(4)}\` differs from a fresh generation (the source moved, or the region was edited by hand)`);
324
+ }
325
+ if (!stamp) {
326
+ for (const [k, d] of Object.entries(digests)) {
327
+ if (!(k in stamped))
328
+ at("freshness", 1, `\`${k}\` has no stamped digest (the page changed after its stamp)`);
329
+ else if (stamped[k] !== d)
330
+ at("freshness", 1, `\`${k}\` changed since the stamp (${stamped[k]} → ${d})${k === "text" ? ": the page's own text was edited without a new stamp" : ""}`);
331
+ }
332
+ for (const k of Object.keys(stamped))
333
+ if (!(k in digests))
334
+ at("freshness", 1, `\`${k}\` is stamped but no longer cited`);
335
+ }
336
+ return { findings, digests, coords, levels };
337
+ }
338
+ export function regenerate(tree, text) {
339
+ const { body } = splitPage(text);
340
+ const bodyAt = text.length - body.length;
341
+ let out = text;
342
+ const gens = claimBlocks(body)
343
+ .gens.filter((g) => g.closed)
344
+ .sort((a, b) => b.span[0] - a.span[0]);
345
+ for (const g of gens) {
346
+ const gen = generatorOf(tree, g.name);
347
+ if (!gen)
348
+ continue;
349
+ let fresh;
350
+ try {
351
+ fresh = generate(gen, g.args).text;
352
+ }
353
+ catch {
354
+ continue;
355
+ }
356
+ const [from, to] = [bodyAt + g.span[0], bodyAt + g.span[1]];
357
+ const region = out.slice(from, to);
358
+ const open = region.slice(0, region.indexOf("-->") + 3);
359
+ out = out.slice(0, from) + (g.inline ? `${open}${fresh}${GEN_CLOSE}` : `${open}\n${fresh}\n${GEN_CLOSE}`) + out.slice(to);
360
+ }
361
+ return out;
362
+ }
363
+ export function stampPage(tree, rel, text, { commit }) {
364
+ const fresh = regenerate(tree, text);
365
+ const r = checkPage(tree, rel, fresh, { stamp: true });
366
+ const anchorsFindings = r.findings.filter((f) => f.gate === "anchors");
367
+ if (anchorsFindings.length > 0)
368
+ return { refused: anchorsFindings };
369
+ const repo = repoOf(tree);
370
+ const end = fresh.indexOf("\n---\n", 4);
371
+ const head = fresh
372
+ .slice(0, end)
373
+ .split("\n")
374
+ .filter((l) => !l.startsWith("anchor: "))
375
+ .map((l) => (l.startsWith("verified: ") ? `verified: ${repo.name}@${repo.version} ${commit}` : l));
376
+ const anchorLines = Object.entries(r.digests).map(([k, d]) => `anchor: ${k} ${d}`);
377
+ return { text: [...head, ...anchorLines].join("\n") + fresh.slice(end), anchors: anchorLines.length };
378
+ }
@@ -0,0 +1,29 @@
1
+ /** Where a correction-record checkout holds its records. */
2
+ export declare const CORRECTIONS_DIR = "corrections";
3
+ /** The name a repository declares the correction record under among its required sources. */
4
+ export declare const CORRECTION_RECORD = "correction record";
5
+ /** One correction: the page it names (repository word and page-id), the section, its state and dates. */
6
+ export interface CorrectionRecord {
7
+ readonly id: string;
8
+ readonly repo: string;
9
+ readonly pageId: string;
10
+ readonly section: string;
11
+ readonly status: "open" | "closed";
12
+ readonly opened: string;
13
+ readonly closed?: string;
14
+ readonly file: string;
15
+ }
16
+ /**
17
+ * A record file read: its frontmatter holds `id` (the file's own `W-<n>`), `repo`, `page-id`, `section`, `status`
18
+ * (`open` or `closed`), `opened`, and `closed` exactly when the status is closed. A record that does not read this way
19
+ * throws: a gate never guesses a correction's state.
20
+ */
21
+ export declare function parseCorrectionRecord(file: string, text: string): CorrectionRecord;
22
+ /** How the records are read from a checkout named on the command line: undefined when none is named or it holds no record directory. */
23
+ export type CorrectionRecordReader = (correctionsRoot: string | undefined) => readonly CorrectionRecord[] | undefined;
24
+ /** The reader over `<checkout>/corrections/W-<n>.md`: a present, empty directory is a record with no correction. */
25
+ export declare const correctionRecordAt: CorrectionRecordReader;
26
+ /** The correction record as a required source: the repository names its reader. */
27
+ export interface CorrectionRecordSource {
28
+ readonly records: CorrectionRecordReader;
29
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { splitPage } from "./page.js";
4
+ export const CORRECTIONS_DIR = "corrections";
5
+ export const CORRECTION_RECORD = "correction record";
6
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
7
+ export function parseCorrectionRecord(file, text) {
8
+ const { fm } = splitPage(text);
9
+ if (fm === null)
10
+ throw new Error(`${file}: a correction record has no frontmatter`);
11
+ const want = /(W-\d+)\.md$/.exec(file)?.[1];
12
+ const get = (k) => {
13
+ const v = fm[k];
14
+ if (typeof v !== "string" || v === "")
15
+ throw new Error(`${file}: a correction record lacks \`${k}\``);
16
+ return v;
17
+ };
18
+ const id = get("id");
19
+ if (id !== want)
20
+ throw new Error(`${file}: id \`${id}\` is not the file's own ${want ?? "W-<n>"}`);
21
+ const status = get("status");
22
+ if (status !== "open" && status !== "closed")
23
+ throw new Error(`${file}: status \`${status}\` is not open or closed`);
24
+ const opened = get("opened");
25
+ if (!DATE.test(opened))
26
+ throw new Error(`${file}: opened \`${opened}\` is not YYYY-MM-DD`);
27
+ const closed = fm.closed;
28
+ if (status === "closed" && (closed === undefined || !DATE.test(closed)))
29
+ throw new Error(`${file}: a closed correction names no \`closed: YYYY-MM-DD\``);
30
+ if (status === "open" && closed !== undefined)
31
+ throw new Error(`${file}: an open correction names a \`closed:\` date`);
32
+ return { id, repo: get("repo"), pageId: get("page-id"), section: get("section"), status, opened, ...(closed === undefined ? {} : { closed }), file };
33
+ }
34
+ export const correctionRecordAt = (correctionsRoot) => {
35
+ if (correctionsRoot === undefined)
36
+ return undefined;
37
+ const dir = join(correctionsRoot, CORRECTIONS_DIR);
38
+ if (!existsSync(dir))
39
+ return undefined;
40
+ return readdirSync(dir)
41
+ .filter((f) => /^W-\d+\.md$/.test(f))
42
+ .sort()
43
+ .map((f) => parseCorrectionRecord(`${CORRECTIONS_DIR}/${f}`, readFileSync(join(dir, f), "utf8")));
44
+ };
@@ -0,0 +1,91 @@
1
+ import { type Finding } from "./check.js";
2
+ import { type CoverageSource, type WikiTree } from "./tree.js";
3
+ /** Feature-row statuses that put a row in the required coverage set (`verified` is a shipped row after its acceptance run). */
4
+ export declare const COVERED_FEAT_STATUSES: readonly string[];
5
+ /** The feature rows a comms checkout holds, or why there are none to read. */
6
+ export type FeatRows = {
7
+ readonly rows: ReadonlyArray<{
8
+ id: string;
9
+ status: string;
10
+ }>;
11
+ } | {
12
+ readonly absent: readonly string[];
13
+ } | {
14
+ readonly error: string;
15
+ };
16
+ /**
17
+ * The feature rows of the comms checkout, with their status for this repository. `commsRoot` names the checkout;
18
+ * otherwise the first of `candidates` that exists is read. `{ absent }` = no comms checkout at all (a release clone,
19
+ * a remote gate host); `{ error }` = a checkout without its rows.
20
+ */
21
+ export declare function readFeatRows(repoWord: string, { commsRoot, candidates }?: {
22
+ commsRoot?: string;
23
+ candidates?: readonly string[];
24
+ }): FeatRows;
25
+ /** The root exports of the frozen export surface; a page cites one by `export:`, a region covers them under `exports`. */
26
+ export declare function rootExportsSource(): CoverageSource;
27
+ /** The root exports of the stable tier, cited and covered as the root exports are. */
28
+ export declare function stableRootExportsSource(): CoverageSource;
29
+ /** The live rows of the closed-set registry; cited by `set:`, covered under `sets`. */
30
+ export declare function liveClosedSetsSource(): CoverageSource;
31
+ /** The contract sentences of the registry; cited by `contract:`. */
32
+ export declare function contractSentencesSource(): CoverageSource;
33
+ /**
34
+ * The configuration catalog's keys. A `catalog:` ref vouches for one sentence about a knob; only a generated region
35
+ * renders the knob, so only a region (under `configKeys`) covers one.
36
+ */
37
+ export declare function configurationKeysSource(): CoverageSource;
38
+ /** One row of the coverage report: a source, how many of its items some page cites, and its size. */
39
+ export interface CoverageReportRow {
40
+ readonly source: string;
41
+ readonly cited: number;
42
+ readonly size: number;
43
+ }
44
+ /**
45
+ * What the pages of a tree cite: the feature rows on their `feats:`, the ids cited through a ref of each kind (a
46
+ * marked sentence's `export:` / `set:` / `contract:` ref, and the refs a generated region hands over), and the ids a
47
+ * generated region renders in full under each `covers` key.
48
+ */
49
+ export interface CitedByPages {
50
+ readonly feats: ReadonlySet<string>;
51
+ /** The ids cited through a ref of this kind. */
52
+ refs(kind: string): ReadonlySet<string>;
53
+ /** The ids a generated region covers under this key. */
54
+ covers(key: string): ReadonlySet<string>;
55
+ /** Whether `id` is cited for this source: by a ref of its kind, or covered under its key. */
56
+ isCited(source: Pick<CoverageSource, "refKind" | "coversKey">, id: string): boolean;
57
+ }
58
+ /**
59
+ * What the given pages cite — the one reading of "cited" every consumer shares: the coverage gate below counts its
60
+ * required and reported sources from it, and a repository's release tooling asks it whether a name a release moved is
61
+ * on a page. A page cites an item through a ref on a marked sentence, and a generated region covers what its generator
62
+ * says it renders in full (`covers`, never counted from the rendered text) and hands over the refs it renders. A source
63
+ * with no ref kind (the configuration keys) is covered only by a region. A region whose generator cannot read its
64
+ * source covers nothing (the anchors gate names it).
65
+ */
66
+ export declare function citedByPages(tree: WikiTree, pages: ReadonlyArray<{
67
+ rel: string;
68
+ text: string;
69
+ }>): CitedByPages;
70
+ /** One required source as this run judged it: covered of size, or why it could not be read. */
71
+ export interface RequiredSummaryRow {
72
+ readonly source: string;
73
+ readonly covered: number;
74
+ readonly size: number;
75
+ readonly unread?: string;
76
+ }
77
+ /**
78
+ * The coverage gate's judgment over parsed pages. Required (one finding per gap): the genre's own sources — for the
79
+ * wiki, every feature row shipped or verified for this repository is on some page's `feats:` — and every source the
80
+ * repository declares required. Reported only: the sources the repository declares reported, counted.
81
+ */
82
+ export declare function coverageFindings(tree: WikiTree, pages: ReadonlyArray<{
83
+ rel: string;
84
+ text: string;
85
+ }>, feats: FeatRows): {
86
+ findings: Finding[];
87
+ report: CoverageReportRow[];
88
+ required: RequiredSummaryRow[];
89
+ };
90
+ /** The required sources in one sentence: each with its coverage, so a requirement never disappears from the verdict. */
91
+ export declare function requiredSummaryOf(rows: readonly RequiredSummaryRow[]): string;