@sema-agent/wiki-gate 0.0.0-stage → 7.67.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.
- package/LICENSE +107 -0
- package/README.md +95 -2
- package/dist/acceptance.d.ts +45 -0
- package/dist/acceptance.js +79 -0
- package/dist/check.d.ts +57 -0
- package/dist/check.js +378 -0
- package/dist/corrections.d.ts +29 -0
- package/dist/corrections.js +44 -0
- package/dist/coverage.d.ts +91 -0
- package/dist/coverage.js +151 -0
- package/dist/generators.d.ts +12 -0
- package/dist/generators.js +45 -0
- package/dist/genre.d.ts +46 -0
- package/dist/genre.js +15 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +12 -0
- package/dist/knobs.d.ts +19 -0
- package/dist/knobs.js +58 -0
- package/dist/page.d.ts +128 -0
- package/dist/page.js +175 -0
- package/dist/run.d.ts +81 -0
- package/dist/run.js +231 -0
- package/dist/source.d.ts +82 -0
- package/dist/source.js +193 -0
- package/dist/tree.d.ts +234 -0
- package/dist/tree.js +240 -0
- package/dist/versions.d.ts +64 -0
- package/dist/versions.js +53 -0
- package/package.json +36 -4
package/dist/page.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const MARKER = /\{\{([GUPVI])((?:\s+(?:"[^"]*"|[^\s{}])+)*)\s*\}\}/g;
|
|
2
|
+
const GEN_OPEN_LINE = /^<!-- wiki:gen ([a-z][a-z-]*)((?: [^ <>]+)*) -->$/;
|
|
3
|
+
const GEN_OPEN_ANY = /<!-- wiki:gen /;
|
|
4
|
+
const GEN_INLINE = /<!-- wiki:gen ([a-z][a-z-]*)((?: [^ <>]+)*) -->([\s\S]*?)<!-- \/wiki:gen -->/g;
|
|
5
|
+
export const GEN_CLOSE = "<!-- /wiki:gen -->";
|
|
6
|
+
const INLINE_CODE = /`[^`\n]*`/g;
|
|
7
|
+
export function splitPage(text) {
|
|
8
|
+
const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
|
|
9
|
+
if (!m)
|
|
10
|
+
return { fm: null, body: text, bodyLine: 1, fmLines: [] };
|
|
11
|
+
const fm = Object.assign(Object.create(null), { _list: Object.create(null) });
|
|
12
|
+
const fmLines = [];
|
|
13
|
+
m[1].split("\n").forEach((line, i) => {
|
|
14
|
+
const kv = /^([a-z][a-z-]*):[ \t]*(.*)$/.exec(line);
|
|
15
|
+
fmLines.push({ line: i + 2, key: kv?.[1], value: kv?.[2], raw: line });
|
|
16
|
+
if (!kv)
|
|
17
|
+
return;
|
|
18
|
+
(fm._list[kv[1]] ??= []).push(kv[2]);
|
|
19
|
+
fm[kv[1]] ??= kv[2];
|
|
20
|
+
});
|
|
21
|
+
return { fm, body: m[2], bodyLine: m[1].split("\n").length + 3, fmLines };
|
|
22
|
+
}
|
|
23
|
+
export function claimBlocks(body) {
|
|
24
|
+
const lines = body.split("\n");
|
|
25
|
+
const starts = [];
|
|
26
|
+
for (let i = 0, at = 0; i < lines.length; at += lines[i].length + 1, i++)
|
|
27
|
+
starts.push(at);
|
|
28
|
+
const blocks = [];
|
|
29
|
+
const gens = [];
|
|
30
|
+
const fences = [];
|
|
31
|
+
const units = [];
|
|
32
|
+
const headings = [];
|
|
33
|
+
const problems = [];
|
|
34
|
+
let cur = [];
|
|
35
|
+
let inFence = null;
|
|
36
|
+
const flush = (end) => {
|
|
37
|
+
if (cur.length) {
|
|
38
|
+
const b = { line: end - cur.length + 1, text: cur.join("\n") };
|
|
39
|
+
blocks.push(b);
|
|
40
|
+
units.push({ kind: "block", text: replaceInlineGens(b.text, (m) => `<gen ${m[1]}${m[2]}>`) });
|
|
41
|
+
}
|
|
42
|
+
cur = [];
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const L = lines[i];
|
|
46
|
+
const n = i + 1;
|
|
47
|
+
if (inFence) {
|
|
48
|
+
if (/^```\s*$/.test(L)) {
|
|
49
|
+
fences.push(inFence);
|
|
50
|
+
units.push({ kind: "fence", text: `${inFence.lang} ${inFence.info}\n${inFence.body.join("\n")}` });
|
|
51
|
+
inFence = null;
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
inFence.body.push(L);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const f = /^```(\S*)\s*(.*)$/.exec(L);
|
|
58
|
+
if (f) {
|
|
59
|
+
flush(n - 1);
|
|
60
|
+
inFence = { line: n, lang: f[1], info: f[2], body: [] };
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const g = GEN_OPEN_LINE.exec(L);
|
|
64
|
+
if (g) {
|
|
65
|
+
flush(n - 1);
|
|
66
|
+
let j = i + 1;
|
|
67
|
+
const content = [];
|
|
68
|
+
while (j < lines.length && lines[j] !== GEN_CLOSE)
|
|
69
|
+
content.push(lines[j++]);
|
|
70
|
+
const closed = j < lines.length;
|
|
71
|
+
gens.push({ line: n, name: g[1], args: g[2].trim().split(" ").filter(Boolean), content: content.join("\n"), inline: false, closed, span: [starts[i], closed ? starts[j] + GEN_CLOSE.length : body.length] });
|
|
72
|
+
i = j;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (GEN_OPEN_ANY.test(maskCode(L)) && !maskCode(L).includes(GEN_CLOSE))
|
|
76
|
+
problems.push({ line: n, message: "a generated region opens inside a line and does not close on it (a region of its own starts on a line holding only its opening comment)" });
|
|
77
|
+
if (/^\s*$/.test(L)) {
|
|
78
|
+
flush(n - 1);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (/^#{1,6} /.test(L)) {
|
|
82
|
+
flush(n - 1);
|
|
83
|
+
units.push({ kind: "heading", text: L });
|
|
84
|
+
headings.push({ line: n, text: L });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (/^\|[\s:|-]+\|\s*$/.test(L))
|
|
88
|
+
continue;
|
|
89
|
+
if (/^<!--(?:(?!-->)[\s\S])*-->\s*$/.test(L) && !GEN_OPEN_ANY.test(L)) {
|
|
90
|
+
flush(n - 1);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (/^\|/.test(L) && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1] ?? "")) {
|
|
94
|
+
flush(n - 1);
|
|
95
|
+
units.push({ kind: "header", text: L });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/^\s*([-*]|\d+\.)\s/.test(L) || /^\|/.test(L)) {
|
|
99
|
+
flush(n - 1);
|
|
100
|
+
cur.push(L);
|
|
101
|
+
flush(n);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
cur.push(L);
|
|
105
|
+
}
|
|
106
|
+
flush(lines.length);
|
|
107
|
+
if (inFence)
|
|
108
|
+
fences.push({ ...inFence, unclosed: true });
|
|
109
|
+
for (const b of blocks) {
|
|
110
|
+
for (const g of inlineGensOf(b.text)) {
|
|
111
|
+
const at = starts[b.line - 1] + g.index;
|
|
112
|
+
gens.push({ line: b.line, name: g.m[1], args: g.m[2].trim().split(" ").filter(Boolean), content: g.m[3], inline: true, closed: true, span: [at, at + g.length] });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { blocks, gens, fences, units, headings, problems };
|
|
116
|
+
}
|
|
117
|
+
function maskCode(text) {
|
|
118
|
+
return text.replace(INLINE_CODE, (s) => "x".repeat(s.length));
|
|
119
|
+
}
|
|
120
|
+
function maskQuote(view, text) {
|
|
121
|
+
const c = correctionLineOf(text);
|
|
122
|
+
return c ? view.slice(0, c.quoteSpan[0]) + "x".repeat(c.quoteSpan[1] - c.quoteSpan[0]) + view.slice(c.quoteSpan[1]) : view;
|
|
123
|
+
}
|
|
124
|
+
export function inlineGensOf(text) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const v of maskQuote(maskCode(text), text).matchAll(GEN_INLINE))
|
|
127
|
+
out.push({ index: v.index, length: v[0].length, m: new RegExp(GEN_INLINE.source).exec(text.slice(v.index, v.index + v[0].length)) });
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function replaceInlineGens(text, f) {
|
|
131
|
+
let out = "";
|
|
132
|
+
let at = 0;
|
|
133
|
+
for (const g of inlineGensOf(text)) {
|
|
134
|
+
out += text.slice(at, g.index) + f(g.m);
|
|
135
|
+
at = g.index + g.length;
|
|
136
|
+
}
|
|
137
|
+
return out + text.slice(at);
|
|
138
|
+
}
|
|
139
|
+
function refTokens(refText) {
|
|
140
|
+
return [...refText.matchAll(/(?:[^\s"]+"[^"]*"[^\s"]*|[^\s"]+)/g)].map((m) => m[0]);
|
|
141
|
+
}
|
|
142
|
+
const CORRECTION_LINE = /^((?:[-*]|\d+\.)\s+)?"([\s\S]*?)" — corrected by (W-\d+) on (\d{4}-\d{2}-\d{2}) at (\d+\.\d+\.\d+): /;
|
|
143
|
+
export function correctionLineOf(text) {
|
|
144
|
+
const m = CORRECTION_LINE.exec(text);
|
|
145
|
+
if (!m)
|
|
146
|
+
return undefined;
|
|
147
|
+
const open = (m[1] ?? "").length;
|
|
148
|
+
return { correction: m[3], date: m[4], version: m[5], quoted: m[2], quoteSpan: [open, open + m[2].length + 2] };
|
|
149
|
+
}
|
|
150
|
+
export function sectionIdOf(heading) {
|
|
151
|
+
return heading
|
|
152
|
+
.replace(/^#{1,6}\s+/, "")
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
155
|
+
.replace(/^-+|-+$/g, "");
|
|
156
|
+
}
|
|
157
|
+
export function markersOf(text) {
|
|
158
|
+
const view = maskQuote(maskCode(text), text).replace(GEN_INLINE, (s) => "x".repeat(s.length));
|
|
159
|
+
const markers = [...view.matchAll(MARKER)].map((v) => ({ level: v[1], refs: refTokens(text.slice(v.index + 3, v.index + v[0].length - 2)), index: v.index, length: v[0].length }));
|
|
160
|
+
return { stripped: view.trimEnd(), markers };
|
|
161
|
+
}
|
|
162
|
+
export function citationsOf(text) {
|
|
163
|
+
const { fm, body } = splitPage(text);
|
|
164
|
+
const { blocks, gens, fences } = claimBlocks(body);
|
|
165
|
+
const refs = new Set();
|
|
166
|
+
for (const b of blocks)
|
|
167
|
+
for (const mk of markersOf(b.text).markers)
|
|
168
|
+
for (const r of mk.refs)
|
|
169
|
+
refs.add(r);
|
|
170
|
+
for (const f of fences)
|
|
171
|
+
for (const mk of markersOf(f.info).markers)
|
|
172
|
+
for (const r of mk.refs)
|
|
173
|
+
refs.add(r);
|
|
174
|
+
return { feats: fm?._list.feats ?? [], refs: [...refs], gens };
|
|
175
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type Finding } from "./check.js";
|
|
2
|
+
import { type CoverageReportRow, type RequiredSummaryRow } from "./coverage.js";
|
|
3
|
+
import { type WikiGateConfig } from "./tree.js";
|
|
4
|
+
import { type Severity } from "./versions.js";
|
|
5
|
+
/** A finding with the severity this run judged it at. */
|
|
6
|
+
export type JudgedFinding = Finding & {
|
|
7
|
+
readonly severity: Severity;
|
|
8
|
+
};
|
|
9
|
+
/** The verdict of a gate run: how many failures and warnings, the exit code, and the line that always speaks. */
|
|
10
|
+
export interface Verdict {
|
|
11
|
+
readonly judged: JudgedFinding[];
|
|
12
|
+
readonly failures: number;
|
|
13
|
+
readonly warnings: number;
|
|
14
|
+
readonly exit: 0 | 1;
|
|
15
|
+
readonly line: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A run's verdict. `redFrom` is the version this repository's findings fail from, for the genre under check; `notes`
|
|
19
|
+
* are what the line says besides the counts (the required sources, the correction record, the acceptance index).
|
|
20
|
+
*/
|
|
21
|
+
export declare function verdictOf(gate: string, findings: readonly Finding[], { version, strict, pages, redFrom, notes }: {
|
|
22
|
+
version: string;
|
|
23
|
+
strict?: boolean;
|
|
24
|
+
pages: number;
|
|
25
|
+
redFrom: string;
|
|
26
|
+
notes?: readonly string[];
|
|
27
|
+
}): Verdict;
|
|
28
|
+
/** How one gate entry runs: strict or not, which pages (repo-relative; every page when absent), where the other checkouts are. */
|
|
29
|
+
export interface GateOptions {
|
|
30
|
+
readonly strict?: boolean;
|
|
31
|
+
readonly pages?: readonly string[];
|
|
32
|
+
/** The comms checkout the coverage gate reads feature rows from. */
|
|
33
|
+
readonly commsRoot?: string;
|
|
34
|
+
/** Where to look for a comms checkout when `commsRoot` is not named, in order. */
|
|
35
|
+
readonly commsCandidates?: readonly string[];
|
|
36
|
+
/** The acceptance-index checkout `crit:` refs are read in. */
|
|
37
|
+
readonly testRoot?: string;
|
|
38
|
+
/** The correction-record checkout. */
|
|
39
|
+
readonly correctionsRoot?: string;
|
|
40
|
+
}
|
|
41
|
+
/** A gate entry's answer: a verdict and the coverage report — or exit 2 and the reason it could not judge. */
|
|
42
|
+
export type GateResult = (Verdict & {
|
|
43
|
+
readonly report: CoverageReportRow[];
|
|
44
|
+
readonly required: RequiredSummaryRow[];
|
|
45
|
+
readonly pages: number;
|
|
46
|
+
}) | {
|
|
47
|
+
readonly exit: 2;
|
|
48
|
+
readonly couldNotJudge: string;
|
|
49
|
+
};
|
|
50
|
+
/** The anchors gate: page shape, every claim marked, every ref resolves, nothing internal, no open correction. */
|
|
51
|
+
export declare function wikiAnchors(config: WikiGateConfig, opts?: GateOptions): GateResult;
|
|
52
|
+
/** The freshness gate: every stamped digest and every generated region against the tree now. */
|
|
53
|
+
export declare function wikiFreshness(config: WikiGateConfig, opts?: GateOptions): GateResult;
|
|
54
|
+
/** The coverage gate: every required source covered; the reported sources counted. */
|
|
55
|
+
export declare function wikiCoverage(config: WikiGateConfig, opts?: GateOptions): GateResult;
|
|
56
|
+
/** What the command-line runner reads besides the configuration. */
|
|
57
|
+
export interface WikiGateCommandLine {
|
|
58
|
+
readonly argv: readonly string[];
|
|
59
|
+
/** The directory page arguments are relative to (the process's own when absent). */
|
|
60
|
+
readonly cwd?: string;
|
|
61
|
+
/** How the usage line names the command. */
|
|
62
|
+
readonly invocation?: string;
|
|
63
|
+
/** Where to look for a comms checkout when `--comms` is not given, in order. */
|
|
64
|
+
readonly commsCandidates?: () => readonly string[];
|
|
65
|
+
/** The commit a stamp vouches for: the repository's head. */
|
|
66
|
+
readonly headCommit?: () => string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The command line over the entries and the stamp:
|
|
70
|
+
*
|
|
71
|
+
* anchors [--strict] [--test <dir>] [--corrections <dir>] [<page.md> …]
|
|
72
|
+
* freshness [--strict] [--test <dir>] [--corrections <dir>] [<page.md> …]
|
|
73
|
+
* coverage [--strict] [--comms <dir>] [--test <dir>] [--corrections <dir>] [--report]
|
|
74
|
+
* stamp [--test <dir>] [--corrections <dir>] <page.md> …
|
|
75
|
+
*
|
|
76
|
+
* `--report` prints the coverage report as a table (source, cited, size, cited %) in place of the plain `report:` lines.
|
|
77
|
+
* An option a command does not take is refused by name (exit 2), so a misspelled `--strcit` cannot pass as a lenient run.
|
|
78
|
+
* Exit 0 = no failure (warnings are printed), 1 = at least one failure, 2 = cannot judge. Every run prints a verdict
|
|
79
|
+
* line: a gate that judged nothing must not look like a green one.
|
|
80
|
+
*/
|
|
81
|
+
export declare function runWikiGate(config: WikiGateConfig, cli: WikiGateCommandLine): number;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { checkPage, stampPage } from "./check.js";
|
|
4
|
+
import { coverageFindings, readFeatRows, requiredSummaryOf } from "./coverage.js";
|
|
5
|
+
import { splitPage } from "./page.js";
|
|
6
|
+
import { correctionSourceOf, createTree, repoOf } from "./tree.js";
|
|
7
|
+
import { compareVersions, severityOf } from "./versions.js";
|
|
8
|
+
import { CORRECTION_RECORD } from "./corrections.js";
|
|
9
|
+
export function verdictOf(gate, findings, { version, strict = false, pages, redFrom, notes = [] }) {
|
|
10
|
+
const judged = findings.map((f) => ({ ...f, severity: severityOf(f, { version, strict, redFrom }) }));
|
|
11
|
+
const failures = judged.filter((f) => f.severity === "fail").length;
|
|
12
|
+
const warnings = judged.length - failures;
|
|
13
|
+
const period = strict ? "strict: no version grace, a finding's own severity holds" : compareVersions(version, redFrom) >= 0 ? `findings fail (this tree is ${version}, failing since ${redFrom})` : `warning period: findings fail from ${redFrom}, this tree is ${version}`;
|
|
14
|
+
const line = `wiki-${gate}: ${pages} page(s), ${failures} failure(s), ${warnings} warning(s) — ${period}${notes.map((n) => ` · ${n}`).join("")}`;
|
|
15
|
+
return { judged, failures, warnings, exit: failures > 0 ? 1 : 0, line };
|
|
16
|
+
}
|
|
17
|
+
function isFile(abs) {
|
|
18
|
+
try {
|
|
19
|
+
return statSync(abs).isFile();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function prepared(config, opts) {
|
|
26
|
+
try {
|
|
27
|
+
const tree = createTree(config, { testRoot: opts.testRoot, correctionsRoot: opts.correctionsRoot });
|
|
28
|
+
const repo = repoOf(tree);
|
|
29
|
+
const redFrom = config.redFrom?.[config.genre];
|
|
30
|
+
if (redFrom === undefined)
|
|
31
|
+
throw new Error(`the configuration names no red-from version for the ${config.genre} genre`);
|
|
32
|
+
compareVersions(repo.version, redFrom);
|
|
33
|
+
for (const [which, sources] of [["required", config.required], ["reported", config.reported]]) {
|
|
34
|
+
for (const [name, v] of Object.entries(sources ?? {}))
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
throw new Error(`the ${which} source \`${name}\` is declared and this repository configures no reader for it`);
|
|
37
|
+
}
|
|
38
|
+
const corrections = correctionSourceOf(config);
|
|
39
|
+
if (corrections.declared && corrections.source === undefined)
|
|
40
|
+
throw new Error(`the required source \`${CORRECTION_RECORD}\` is not a correction-record reader`);
|
|
41
|
+
config.preflight?.(tree);
|
|
42
|
+
const notes = [];
|
|
43
|
+
if (!corrections.declared)
|
|
44
|
+
notes.push("corrections: not declared");
|
|
45
|
+
else {
|
|
46
|
+
const records = tree.corrections();
|
|
47
|
+
if (records === undefined)
|
|
48
|
+
throw new Error("the correction record is a required source and is not in this check (name its checkout with --corrections <dir>)");
|
|
49
|
+
notes.push(`corrections: ${records.length} record(s), ${records.filter((r) => r.status === "open").length} open`);
|
|
50
|
+
}
|
|
51
|
+
if (config.readers?.acceptanceIndex !== undefined) {
|
|
52
|
+
const index = tree.acceptanceIndex();
|
|
53
|
+
notes.push(index === undefined ? "acceptance index: not in this check" : `acceptance index at ${index.commit}`);
|
|
54
|
+
}
|
|
55
|
+
return { tree, repo, redFrom, notes };
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return { couldNotJudge: e.message };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function pageIdTwins(tree) {
|
|
62
|
+
const byId = new Map();
|
|
63
|
+
for (const rel of tree.allPageFiles()) {
|
|
64
|
+
const id = splitPage(tree.read(rel)).fm?.["page-id"];
|
|
65
|
+
if (id !== undefined)
|
|
66
|
+
byId.set(id, [...(byId.get(id) ?? []), rel]);
|
|
67
|
+
}
|
|
68
|
+
return byId;
|
|
69
|
+
}
|
|
70
|
+
function runGate(gate, config, opts) {
|
|
71
|
+
const ready = prepared(config, opts);
|
|
72
|
+
if ("couldNotJudge" in ready)
|
|
73
|
+
return { exit: 2, couldNotJudge: ready.couldNotJudge };
|
|
74
|
+
const { tree, repo, redFrom, notes } = ready;
|
|
75
|
+
const strict = opts.strict ?? false;
|
|
76
|
+
const pages = opts.pages === undefined || opts.pages.length === 0 ? tree.wikiPageFiles() : [...opts.pages];
|
|
77
|
+
const notFiles = pages.filter((p) => !isFile(join(config.root, p)));
|
|
78
|
+
if (pages.length === 0 || notFiles.length > 0)
|
|
79
|
+
return { exit: 2, couldNotJudge: pages.length === 0 ? `no page under ${tree.wikiDir}` : `not a page file: ${notFiles.join(", ")}` };
|
|
80
|
+
try {
|
|
81
|
+
let findings = [];
|
|
82
|
+
const report = [];
|
|
83
|
+
const required = [];
|
|
84
|
+
if (gate === "coverage") {
|
|
85
|
+
const all = tree.wikiPageFiles().map((rel) => ({ rel, text: tree.read(rel) }));
|
|
86
|
+
const r = coverageFindings(tree, all, readFeatRows(repo.word, { commsRoot: opts.commsRoot, candidates: opts.commsCandidates }));
|
|
87
|
+
findings = r.findings;
|
|
88
|
+
report.push(...r.report);
|
|
89
|
+
required.push(...r.required);
|
|
90
|
+
notes.unshift(requiredSummaryOf(r.required));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const twins = gate === "anchors" ? pageIdTwins(tree) : new Map();
|
|
94
|
+
for (const rel of pages) {
|
|
95
|
+
const text = tree.read(rel);
|
|
96
|
+
findings.push(...checkPage(tree, rel, text).findings.filter((f) => f.gate === gate));
|
|
97
|
+
const id = splitPage(text).fm?.["page-id"];
|
|
98
|
+
const others = id === undefined ? [] : (twins.get(id) ?? []).filter((p) => p !== rel);
|
|
99
|
+
if (others.length > 0)
|
|
100
|
+
findings.push({ gate: "anchors", page: rel, line: 1, message: `page-id \`${id}\` is also the id of ${others.join(", ")}: a page-id is unique across a repository's genres` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ...verdictOf(gate, findings, { version: repo.version, strict, pages: pages.length, redFrom, notes }), report, required, pages: pages.length };
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return { exit: 2, couldNotJudge: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function wikiAnchors(config, opts = {}) {
|
|
110
|
+
return runGate("anchors", config, opts);
|
|
111
|
+
}
|
|
112
|
+
export function wikiFreshness(config, opts = {}) {
|
|
113
|
+
return runGate("freshness", config, opts);
|
|
114
|
+
}
|
|
115
|
+
export function wikiCoverage(config, opts = {}) {
|
|
116
|
+
return runGate("coverage", config, opts);
|
|
117
|
+
}
|
|
118
|
+
const GATES = Object.freeze({ anchors: wikiAnchors, freshness: wikiFreshness, coverage: wikiCoverage });
|
|
119
|
+
const VALUED_FLAGS = Object.freeze(["--comms", "--test", "--corrections"]);
|
|
120
|
+
const OPTIONS = Object.freeze({
|
|
121
|
+
anchors: ["--strict", "--test", "--corrections"],
|
|
122
|
+
freshness: ["--strict", "--test", "--corrections"],
|
|
123
|
+
coverage: ["--strict", "--comms", "--test", "--corrections", "--report"],
|
|
124
|
+
stamp: ["--test", "--corrections"],
|
|
125
|
+
});
|
|
126
|
+
function coverageTable(rows) {
|
|
127
|
+
const out = ["| source | cited | size | cited % |", "|---|---|---|---|"];
|
|
128
|
+
for (const row of rows)
|
|
129
|
+
out.push(`| ${row.source} | ${row.cited} | ${row.size} | ${row.size === 0 ? "—" : `${((100 * row.cited) / row.size).toFixed(1)}%`} |`);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
export function runWikiGate(config, cli) {
|
|
133
|
+
const [cmd, ...rest] = cli.argv;
|
|
134
|
+
const usage = `usage: ${cli.invocation ?? "wiki-gate"} anchors|freshness|coverage [--strict] [--comms <dir>] [--test <dir>] [--corrections <dir>] [--report] [<page.md> …] | stamp <page.md> …`;
|
|
135
|
+
if (cmd === undefined || !Object.hasOwn(OPTIONS, cmd)) {
|
|
136
|
+
console.error(usage);
|
|
137
|
+
return 2;
|
|
138
|
+
}
|
|
139
|
+
const taken = OPTIONS[cmd];
|
|
140
|
+
const flags = new Set();
|
|
141
|
+
const values = new Map();
|
|
142
|
+
const args = [];
|
|
143
|
+
for (let i = 0; i < rest.length; i++) {
|
|
144
|
+
const a = rest[i];
|
|
145
|
+
if (!a.startsWith("--")) {
|
|
146
|
+
args.push(a);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!taken.includes(a)) {
|
|
150
|
+
console.error(`wiki-${cmd}: unknown option ${a} — \`${cmd}\` takes ${taken.join(", ")}\n${usage}`);
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
if (VALUED_FLAGS.includes(a)) {
|
|
154
|
+
const v = rest[++i];
|
|
155
|
+
if (v === undefined || v.startsWith("--")) {
|
|
156
|
+
console.error(`wiki-${cmd}: ${a} needs a directory\n${usage}`);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
values.set(a, v);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
flags.add(a);
|
|
163
|
+
}
|
|
164
|
+
const strict = flags.has("--strict");
|
|
165
|
+
const cwd = cli.cwd ?? process.cwd();
|
|
166
|
+
const pages = args.map((a) => relative(config.root, isAbsolute(a) ? a : resolve(cwd, a)).split("\\").join("/"));
|
|
167
|
+
const at = (flag) => {
|
|
168
|
+
const v = values.get(flag);
|
|
169
|
+
return v === undefined ? undefined : resolve(cwd, v);
|
|
170
|
+
};
|
|
171
|
+
const where = { testRoot: at("--test"), correctionsRoot: at("--corrections") };
|
|
172
|
+
if (cmd === "stamp")
|
|
173
|
+
return runStamp(config, cli, pages, where);
|
|
174
|
+
const commsRoot = at("--comms");
|
|
175
|
+
const commsCandidates = cmd === "coverage" && !commsRoot ? cli.commsCandidates?.() : undefined;
|
|
176
|
+
const r = GATES[cmd](config, { strict, pages, commsRoot, commsCandidates, ...where });
|
|
177
|
+
if ("couldNotJudge" in r) {
|
|
178
|
+
console.error(`wiki-${cmd} COULD NOT JUDGE (exit 2): ${r.couldNotJudge}`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
for (const f of r.judged)
|
|
182
|
+
console.log(`${f.severity === "fail" ? "FAIL" : "WARN"} ${f.page}${f.line ? `:${f.line}` : ""}: ${f.message}`);
|
|
183
|
+
if (flags.has("--report"))
|
|
184
|
+
for (const line of coverageTable(r.report))
|
|
185
|
+
console.log(line);
|
|
186
|
+
else
|
|
187
|
+
for (const row of r.report)
|
|
188
|
+
console.log(`report: ${row.source} cited ${row.cited} / ${row.size}`);
|
|
189
|
+
console.log(r.line);
|
|
190
|
+
return r.exit;
|
|
191
|
+
}
|
|
192
|
+
function runStamp(config, cli, pages, where) {
|
|
193
|
+
const ready = prepared(config, where);
|
|
194
|
+
if ("couldNotJudge" in ready) {
|
|
195
|
+
console.error(`wiki-stamp COULD NOT JUDGE (exit 2): ${ready.couldNotJudge}`);
|
|
196
|
+
return 2;
|
|
197
|
+
}
|
|
198
|
+
const { tree, repo, notes } = ready;
|
|
199
|
+
const listed = pages.length === 0 ? tree.wikiPageFiles() : pages;
|
|
200
|
+
const notFiles = listed.filter((p) => !isFile(join(config.root, p)));
|
|
201
|
+
if (listed.length === 0 || notFiles.length > 0) {
|
|
202
|
+
console.error(`wiki-stamp COULD NOT JUDGE (exit 2): ${listed.length === 0 ? `no page under ${tree.wikiDir}` : `not a page file: ${notFiles.join(", ")}`}`);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
if (pages.length === 0) {
|
|
206
|
+
console.error("wiki-stamp: name the page(s) to stamp — a stamp re-vouches sentences somebody re-read, never the whole wiki at once");
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
if (cli.headCommit === undefined)
|
|
211
|
+
throw new Error("the command line names no head commit for a stamp to vouch for");
|
|
212
|
+
const commit = cli.headCommit();
|
|
213
|
+
let refused = 0;
|
|
214
|
+
for (const rel of pages) {
|
|
215
|
+
const r = stampPage(tree, rel, tree.read(rel), { commit });
|
|
216
|
+
if ("refused" in r) {
|
|
217
|
+
refused++;
|
|
218
|
+
for (const f of r.refused)
|
|
219
|
+
console.error(`REFUSED ${f.page}:${f.line}: ${f.message}`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
writeFileSync(join(config.root, rel), r.text);
|
|
223
|
+
console.log(`stamped ${rel}: ${r.anchors} anchor line(s), verified ${repo.name}@${repo.version} ${commit}${notes.map((n) => ` · ${n}`).join("")}`);
|
|
224
|
+
}
|
|
225
|
+
return refused > 0 ? 1 : 0;
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
console.error(`wiki-stamp COULD NOT JUDGE (exit 2): ${e.message}`);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The by-name readers of TypeScript source: a declaration, a pinned test, a literal table, a literal-key catalog. Each
|
|
3
|
+
* reads through the tree's file access ({@link TreeFiles}), so a page checked against a changed source (an override)
|
|
4
|
+
* reads the changed text.
|
|
5
|
+
*/
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
/** The file access every reader goes through: repository-relative paths, overrides first. */
|
|
8
|
+
export interface TreeFiles {
|
|
9
|
+
/** The repository root the paths are relative to. */
|
|
10
|
+
readonly root: string;
|
|
11
|
+
exists(rel: string): boolean;
|
|
12
|
+
read(rel: string): string;
|
|
13
|
+
sourceFile(rel: string): ts.SourceFile;
|
|
14
|
+
}
|
|
15
|
+
/** What a ref resolves to: the text its digest is taken of, and where it stands. */
|
|
16
|
+
export interface Resolved {
|
|
17
|
+
readonly text: string;
|
|
18
|
+
readonly file?: string;
|
|
19
|
+
readonly line?: number;
|
|
20
|
+
}
|
|
21
|
+
/** A ref that is malformed, or names something that is not evidence. */
|
|
22
|
+
export interface Unresolvable {
|
|
23
|
+
readonly error: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function lineOf(sf: ts.SourceFile, node: ts.Node): number;
|
|
26
|
+
/** A property or member name as written, quoted or not (`key` and `"key"` name the same thing); undefined when computed. */
|
|
27
|
+
export declare function propertyNameOf(name: ts.Node | undefined): string | undefined;
|
|
28
|
+
/** A declaration read by name: its text without comments, its first line, its nodes, its source file. */
|
|
29
|
+
export interface DeclarationReading {
|
|
30
|
+
readonly text: string;
|
|
31
|
+
readonly line: number;
|
|
32
|
+
readonly nodes: readonly ts.Node[];
|
|
33
|
+
readonly sf: ts.SourceFile;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The declaration `path` (`Name` or `Name.member…`) of a source file, found by name — never by line — and printed with
|
|
37
|
+
* every comment removed: a declaration's JSDoc, and its members', is not evidence. All declarations of the name are
|
|
38
|
+
* read (overloads, a merged interface). Undefined when the name is not declared at the top level.
|
|
39
|
+
*/
|
|
40
|
+
export declare function declarationOf(tree: Pick<TreeFiles, "sourceFile">, rel: string, path: string): DeclarationReading | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* The one test whose title contains `fragment`, its suite path, and its call printed without comments. A fragment that
|
|
43
|
+
* names no test is undefined; one that names more than one, or a test that a `skip` / `todo` / `fails` / conditional on
|
|
44
|
+
* itself or any enclosing suite may keep from running, is an error.
|
|
45
|
+
*/
|
|
46
|
+
export declare function pinOf(tree: Pick<TreeFiles, "exists" | "sourceFile">, rel: string, fragment: string): Resolved | Unresolvable | undefined;
|
|
47
|
+
/** How a repository unwraps a table's initializer (`as const`, `satisfies`, `Object.freeze(…)`) to the literal under it. */
|
|
48
|
+
export type InitializerUnwrap = (initializer: ts.Expression) => {
|
|
49
|
+
init: ts.Expression | undefined;
|
|
50
|
+
};
|
|
51
|
+
/** A reader of a top-level `const` object table: string keys to string values. */
|
|
52
|
+
export type TableValuesReader = (files: TreeFiles, rel: string, table: string) => Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* The table reader over a repository's own unwrap rule: a top-level `const` object table read literally, string keys to
|
|
55
|
+
* string values. Anything not literal throws.
|
|
56
|
+
*/
|
|
57
|
+
export declare function literalTableReader(unwrap: InitializerUnwrap): TableValuesReader;
|
|
58
|
+
/** One knob of a configuration catalog, as the coverage gate and the generators read it. */
|
|
59
|
+
export interface CatalogKnob {
|
|
60
|
+
readonly key: string;
|
|
61
|
+
readonly kind: string | undefined;
|
|
62
|
+
readonly unit: string | undefined;
|
|
63
|
+
readonly recommended: string;
|
|
64
|
+
readonly precedence: string | undefined;
|
|
65
|
+
/** The knob's object literal printed without comments: what a `catalog:` ref's digest is taken of. */
|
|
66
|
+
readonly text: string;
|
|
67
|
+
readonly line: number;
|
|
68
|
+
}
|
|
69
|
+
/** A repository's configuration catalog: the file it is declared in, and its knobs read from the tree. */
|
|
70
|
+
export interface CatalogReader {
|
|
71
|
+
readonly file: string;
|
|
72
|
+
knobs(files: TreeFiles): CatalogKnob[];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The catalog reader for a catalog written as object literals: every object literal under `declaration` in `file` that
|
|
76
|
+
* carries a literal `key` is one knob, read literally. A key not written as a literal, and a catalog with no such knob,
|
|
77
|
+
* throw.
|
|
78
|
+
*/
|
|
79
|
+
export declare function literalKeyCatalog({ file, declaration }: {
|
|
80
|
+
readonly file: string;
|
|
81
|
+
readonly declaration: string;
|
|
82
|
+
}): CatalogReader;
|