@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/source.js ADDED
@@ -0,0 +1,193 @@
1
+ import ts from "typescript";
2
+ export function lineOf(sf, node) {
3
+ return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
4
+ }
5
+ export function propertyNameOf(name) {
6
+ return name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) ? name.text : undefined;
7
+ }
8
+ function membersOfDeclaration(node) {
9
+ if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node) || ts.isEnumDeclaration(node))
10
+ return node.members;
11
+ if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type))
12
+ return node.type.members;
13
+ return [];
14
+ }
15
+ export function declarationOf(tree, rel, path) {
16
+ const sf = tree.sourceFile(rel);
17
+ const [head, ...rest] = path.split(".");
18
+ let found = [];
19
+ for (const st of sf.statements) {
20
+ if (ts.isVariableStatement(st)) {
21
+ if (st.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === head))
22
+ found.push(st);
23
+ continue;
24
+ }
25
+ const named = ts.isFunctionDeclaration(st) || ts.isInterfaceDeclaration(st) || ts.isTypeAliasDeclaration(st) || ts.isClassDeclaration(st) || ts.isEnumDeclaration(st);
26
+ if (named && st.name?.text === head)
27
+ found.push(st);
28
+ }
29
+ for (const member of rest) {
30
+ const next = [];
31
+ for (const node of found)
32
+ for (const m of membersOfDeclaration(node))
33
+ if (propertyNameOf(m.name) === member)
34
+ next.push(m);
35
+ found = next;
36
+ }
37
+ if (found.length === 0)
38
+ return undefined;
39
+ const printer = ts.createPrinter({ removeComments: true });
40
+ return { text: found.map((n) => printer.printNode(ts.EmitHint.Unspecified, n, sf)).join("\n"), line: lineOf(sf, found[0]), nodes: found, sf };
41
+ }
42
+ function runnerCallee(expr, sf) {
43
+ const plain = /^(describe|suite|it|test)((?:\.[A-Za-z]+)*)$/;
44
+ if (ts.isCallExpression(expr)) {
45
+ const inner = plain.exec(expr.expression.getText(sf));
46
+ if (!inner)
47
+ return undefined;
48
+ const mods = inner[2].split(".").filter(Boolean);
49
+ if (!["skipIf", "runIf", "each", "for"].includes(mods.at(-1) ?? ""))
50
+ return undefined;
51
+ return { head: inner[1], mods };
52
+ }
53
+ const m = plain.exec(expr.getText(sf));
54
+ if (!m)
55
+ return undefined;
56
+ const mods = m[2].split(".").filter(Boolean);
57
+ if (mods.includes("each") || mods.includes("for"))
58
+ return undefined;
59
+ return { head: m[1], mods };
60
+ }
61
+ const VOIDING_MODIFIERS = Object.freeze(["skip", "todo", "fails", "skipIf", "runIf"]);
62
+ function optionModifiers(call) {
63
+ const out = [];
64
+ for (const a of call.arguments.slice(1)) {
65
+ if (!ts.isObjectLiteralExpression(a))
66
+ continue;
67
+ for (const p of a.properties) {
68
+ if (ts.isSpreadAssignment(p))
69
+ out.push("…spread");
70
+ const name = ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) ? propertyNameOf(p.name) : undefined;
71
+ if (name === undefined || !VOIDING_MODIFIERS.includes(name))
72
+ continue;
73
+ if (ts.isPropertyAssignment(p) && p.initializer.kind === ts.SyntaxKind.FalseKeyword)
74
+ continue;
75
+ out.push(name);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ export function pinOf(tree, rel, fragment) {
81
+ if (!tree.exists(rel))
82
+ return undefined;
83
+ const sf = tree.sourceFile(rel);
84
+ const hits = [];
85
+ const visit = (n, chain) => {
86
+ if (ts.isCallExpression(n)) {
87
+ const callee = runnerCallee(n.expression, sf);
88
+ const a = n.arguments[0];
89
+ const title = a && (ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a)) ? a.text : null;
90
+ if (callee && title !== null) {
91
+ const voids = [...callee.mods.filter((m) => VOIDING_MODIFIERS.includes(m)), ...optionModifiers(n)];
92
+ const step = { label: `${callee.head}${callee.mods.map((m) => `.${m}`).join("")}(${JSON.stringify(title)})`, voids };
93
+ if (callee.head === "it" || callee.head === "test") {
94
+ if (title.includes(fragment))
95
+ hits.push({ n, chain: [...chain, step] });
96
+ return;
97
+ }
98
+ ts.forEachChild(n, (c) => visit(c, [...chain, step]));
99
+ return;
100
+ }
101
+ }
102
+ ts.forEachChild(n, (c) => visit(c, chain));
103
+ };
104
+ visit(sf, []);
105
+ if (hits.length === 0)
106
+ return undefined;
107
+ if (hits.length > 1)
108
+ return { error: `the title fragment "${fragment}" names ${hits.length} tests in ${rel}; a pin names one` };
109
+ const { n, chain } = hits[0];
110
+ const voiding = chain.find((s) => s.voids.length > 0);
111
+ if (voiding)
112
+ return { error: `the pinned test may not run, or runs inverted (${voiding.label}: ${voiding.voids.join(", ")})` };
113
+ const printed = ts.createPrinter({ removeComments: true }).printNode(ts.EmitHint.Unspecified, n, sf);
114
+ return { text: `${chain.map((s) => s.label).join(" > ")}\n${printed}`, file: rel, line: lineOf(sf, n) };
115
+ }
116
+ export function literalTableReader(unwrap) {
117
+ return (files, rel, table) => {
118
+ const sf = files.sourceFile(rel);
119
+ for (const st of sf.statements) {
120
+ if (!ts.isVariableStatement(st))
121
+ continue;
122
+ for (const d of st.declarationList.declarations) {
123
+ if (!ts.isIdentifier(d.name) || d.name.text !== table || !d.initializer)
124
+ continue;
125
+ const { init } = unwrap(d.initializer);
126
+ if (init === undefined || !ts.isObjectLiteralExpression(init))
127
+ throw new Error(`${rel}: ${table} is not an object literal`);
128
+ const out = {};
129
+ for (const p of init.properties) {
130
+ const key = ts.isPropertyAssignment(p) ? propertyNameOf(p.name) : undefined;
131
+ if (key === undefined || !ts.isPropertyAssignment(p) || !ts.isStringLiteral(p.initializer))
132
+ throw new Error(`${rel}: ${table} has an entry that is not a literal string pair (${p.getText(sf).slice(0, 60)})`);
133
+ out[key] = p.initializer.text;
134
+ }
135
+ return out;
136
+ }
137
+ }
138
+ throw new Error(`${rel}: no top-level ${table}`);
139
+ };
140
+ }
141
+ export function literalKeyCatalog({ file, declaration }) {
142
+ return {
143
+ file,
144
+ knobs: (tree) => {
145
+ if (!tree.exists(file))
146
+ throw new Error(`the configuration catalog ${file} is not in this tree`);
147
+ const decl = declarationOf(tree, file, declaration);
148
+ if (decl === undefined)
149
+ throw new Error(`${file} declares no ${declaration}`);
150
+ const { sf } = decl;
151
+ const knobs = [];
152
+ const printer = ts.createPrinter({ removeComments: true });
153
+ const visit = (n) => {
154
+ if (ts.isObjectLiteralExpression(n)) {
155
+ const props = new Map();
156
+ for (const p of n.properties) {
157
+ const name = ts.isPropertyAssignment(p) ? propertyNameOf(p.name) : undefined;
158
+ if (ts.isPropertyAssignment(p) && name !== undefined)
159
+ props.set(name, p.initializer);
160
+ else if (ts.isShorthandPropertyAssignment(p) && p.name.text === "key")
161
+ throw new Error(`${file}: a knob's key is not written as a literal (${p.getText(sf)})`);
162
+ }
163
+ const key = props.get("key");
164
+ if (key !== undefined) {
165
+ if (!ts.isStringLiteral(key))
166
+ throw new Error(`${file}: a knob's key is not a string literal (${key.getText(sf)})`);
167
+ const lit = (k) => {
168
+ const v = props.get(k);
169
+ return v === undefined ? undefined : ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v) ? v.text : printer.printNode(ts.EmitHint.Unspecified, v, sf);
170
+ };
171
+ const recommended = props.get("recommended");
172
+ knobs.push({
173
+ key: key.text,
174
+ kind: lit("kind"),
175
+ unit: lit("unit"),
176
+ recommended: recommended === undefined ? "—" : printer.printNode(ts.EmitHint.Unspecified, recommended, sf),
177
+ precedence: lit("precedence"),
178
+ text: printer.printNode(ts.EmitHint.Unspecified, n, sf),
179
+ line: lineOf(sf, n),
180
+ });
181
+ return;
182
+ }
183
+ }
184
+ ts.forEachChild(n, visit);
185
+ };
186
+ for (const node of decl.nodes)
187
+ visit(node);
188
+ if (knobs.length === 0)
189
+ throw new Error(`${file}: ${declaration} declares no knob with a literal key`);
190
+ return knobs;
191
+ },
192
+ };
193
+ }
package/dist/tree.d.ts ADDED
@@ -0,0 +1,234 @@
1
+ import ts from "typescript";
2
+ import { type CatalogKnob, type CatalogReader, type Resolved, type TableValuesReader, type TreeFiles, type Unresolvable } from "./source.js";
3
+ import { type Frontmatter } from "./page.js";
4
+ import { type ReleaseDistance, type RepoIdentity, type Severity } from "./versions.js";
5
+ import { type Genre, type GenreRow } from "./genre.js";
6
+ import { type AcceptanceIndex, type AcceptanceIndexReader } from "./acceptance.js";
7
+ import { type CorrectionRecord, type CorrectionRecordSource } from "./corrections.js";
8
+ /** The ref kinds a page may cite. */
9
+ export declare const REF_KINDS: readonly string[];
10
+ /** A repository's frozen export surface: name → kind, name → tier. */
11
+ export interface ExportSurface {
12
+ readonly exports: Record<string, string>;
13
+ readonly tiers: Record<string, string>;
14
+ }
15
+ /** One row of a repository's closed-set registry, as the gates and generators read it. */
16
+ export interface ClosedSetRow {
17
+ readonly set: string;
18
+ readonly file: string;
19
+ readonly memberKind: string;
20
+ readonly memberSource: string;
21
+ readonly status: string;
22
+ }
23
+ /** One row of a repository's contract-sentence registry. */
24
+ export interface ContractRow {
25
+ readonly id: string;
26
+ readonly file: string;
27
+ }
28
+ /** One contract tag found in a source file: its id, its sentences, its line. */
29
+ export interface ContractTag {
30
+ readonly id: string;
31
+ readonly sentences: readonly string[];
32
+ readonly line: number;
33
+ }
34
+ /**
35
+ * How a repository reads a `pin:` ref: the one check whose title holds the fragment, its suite path and its call printed
36
+ * without comments as its text; undefined when no check holds it; an error when more than one does, or when the check
37
+ * might not run. The package's own reader is vitest's ({@link pinOf}); a repository whose tests are another form hands
38
+ * in its own, held to the same contract.
39
+ */
40
+ export type PinReader = (files: TreeFiles, rel: string, fragment: string) => Resolved | Unresolvable | undefined;
41
+ /**
42
+ * The repository's own readings, each optional: a repository configures the readers of the things it has. A ref whose
43
+ * kind needs a reader the repository does not configure is an anchors finding that says so; a reader that is configured
44
+ * reads exactly as it always has.
45
+ */
46
+ export interface WikiReaders {
47
+ /** The frozen export surface (`export:` refs, the root-export coverage sources). */
48
+ readonly snapshot?: (files: TreeFiles) => ExportSurface;
49
+ /** The closed-set registry (`set:` refs, generators, the closed-set coverage source). */
50
+ readonly closedSets?: {
51
+ readonly rows: (files: TreeFiles) => readonly ClosedSetRow[];
52
+ /** The members a row names, read from its source file; undefined when the row names members its reader cannot read. */
53
+ readonly members: (sf: ts.SourceFile, row: ClosedSetRow) => readonly string[] | undefined;
54
+ };
55
+ /** The contract-sentence registry (`contract:` refs, the contract coverage source). */
56
+ readonly contracts?: {
57
+ readonly rows: (files: TreeFiles) => readonly ContractRow[];
58
+ readonly tags: (sf: ts.SourceFile, file: string) => readonly ContractTag[];
59
+ };
60
+ /** The configuration catalog (`catalog:` refs, the configuration-key coverage source). */
61
+ readonly catalog?: CatalogReader;
62
+ /** A literal table reader, for generators that render a table of the tree. */
63
+ readonly tableValues?: TableValuesReader;
64
+ /** The internal record's work-item name form: the published wiki never carries one. Its flags hold (`i` reads any case). */
65
+ readonly workItemName?: RegExp;
66
+ /** The `pin:` reader, when this repository's tests are not vitest. */
67
+ readonly pin?: PinReader;
68
+ /** The acceptance index (`crit:` refs), read from the checkout named by `--test <dir>`. */
69
+ readonly acceptanceIndex?: AcceptanceIndexReader;
70
+ /** A cell's criterion as its publisher states it now, by key; undefined when the publisher's record holds no such criterion. */
71
+ readonly criterionText?: (files: TreeFiles, key: {
72
+ readonly repo: string;
73
+ readonly version: string;
74
+ readonly cell: string;
75
+ }) => string | undefined;
76
+ }
77
+ /** The readers a generator is handed: it reads nothing itself, so a table on a page and the gate read the tree one way. */
78
+ export interface GeneratorReaders {
79
+ setMembers(set: string): readonly string[] | undefined;
80
+ tableValues(file: string, table: string): Record<string, string>;
81
+ closedSetRows(): readonly ClosedSetRow[];
82
+ configKnobs(): CatalogKnob[];
83
+ /** The pages of the genre under check (its index page excluded), each with its path under the genre's directory. */
84
+ wikiPages(): Array<{
85
+ rel: string;
86
+ fm: Frontmatter;
87
+ }>;
88
+ /** The row of the genre under check. */
89
+ genre(): GenreRow;
90
+ }
91
+ /**
92
+ * What a generated region holds: its text, and the refs that text relies on. The refs join the page's own: each must
93
+ * resolve, is held to what a page never carries, has its digest stamped, and is counted by coverage.
94
+ */
95
+ export interface Generated {
96
+ readonly text: string;
97
+ readonly refs: readonly string[];
98
+ }
99
+ /**
100
+ * One generator: `render(...args)` is its region; `covers(...args)`, when present, is what the region renders in full —
101
+ * items by the covers key of a declared coverage source, which the coverage gate counts as covered; `rowFields(...args)`,
102
+ * when present, says the region is a table of knobs and names the knob fields its columns render, in column order, out
103
+ * of the package's table of knob fields. A reading it cannot make throws.
104
+ */
105
+ export interface Generator {
106
+ render(...args: string[]): Generated;
107
+ covers?(...args: string[]): Readonly<Record<string, readonly string[]>>;
108
+ rowFields?(...args: string[]): readonly string[];
109
+ }
110
+ /** A repository's generators, made over the readers the gate hands them. */
111
+ export type GeneratorFactory = (read: GeneratorReaders) => Record<string, Generator>;
112
+ /** One item of a coverage source: its id, and where it stands when it stands in a file. */
113
+ export interface CoverageItem {
114
+ readonly id: string;
115
+ readonly file?: string;
116
+ readonly line?: number;
117
+ }
118
+ /**
119
+ * A coverage source: its items read from the tree (a reading that cannot be made throws), the ref kind a page cites an
120
+ * item by (`export:<name>`) when pages may, and the key a generator's `covers` names its items under when regions may.
121
+ */
122
+ export interface CoverageSource {
123
+ items(tree: WikiTree): readonly CoverageItem[];
124
+ readonly refKind?: string;
125
+ readonly coversKey?: string;
126
+ }
127
+ /** A required source: a coverage source, or the correction record. */
128
+ export type RequiredSource = CoverageSource | CorrectionRecordSource;
129
+ /**
130
+ * What a gate entry runs against: the repository root and the genre, and the repository's profile.
131
+ *
132
+ * The gate holds every page directory it is pointed at — `docs/wiki` and `docs/manual` alike — to one rule set: the
133
+ * genre, and with it the directory, is chosen here, by the entry's configuration object, never by a page's own header,
134
+ * and the package opens no second interface for another genre.
135
+ *
136
+ * A source named in `required` or `reported` with no reader (`undefined`) cannot be judged: the gate exits 2 rather
137
+ * than leave the requirement out.
138
+ */
139
+ export interface WikiGateConfig {
140
+ /** The repository root every path is relative to. */
141
+ readonly root: string;
142
+ /** The genre: its row of the genre table names the page directory under the root (`wiki` = `docs/wiki`). */
143
+ readonly genre: Genre;
144
+ /** This repository's package name, the version its releases carry, and the word its pages name it by; `package.json` when absent. */
145
+ readonly identity?: RepoIdentity & {
146
+ readonly word: string;
147
+ };
148
+ /** The version from which this repository's findings fail, per genre: the genre under check must be named. */
149
+ readonly redFrom: Readonly<Partial<Record<Genre, string>>>;
150
+ /** How many releases separate two of this repository's versions (a `U` sentence's age); minor versions when absent. */
151
+ readonly releaseDistance?: ReleaseDistance;
152
+ readonly readers?: WikiReaders;
153
+ readonly generators?: GeneratorFactory;
154
+ /** The sources this repository's pages must cover, by name, besides what the genre requires. */
155
+ readonly required?: Readonly<Record<string, RequiredSource | undefined>>;
156
+ /** The sources the coverage gate counts and reports, by name. */
157
+ readonly reported?: Readonly<Record<string, CoverageSource | undefined>>;
158
+ /** Forms this repository's pages never carry, besides the shared ones: `[what, pattern]`. */
159
+ readonly bans?: ReadonlyArray<readonly [string, RegExp]>;
160
+ /** Run before any judgment; a throw means the gate cannot judge (the inputs a repository's readers need are not readable). */
161
+ readonly preflight?: (tree: WikiTree) => void;
162
+ }
163
+ /** Where the trees outside this one sit: the acceptance index's checkout and the correction record's. */
164
+ export interface TreeOptions {
165
+ /** Repo-relative path → text, standing in for files on disk — how a pin checks a page against a changed source. */
166
+ readonly overrides?: ReadonlyMap<string, string>;
167
+ readonly testRoot?: string;
168
+ readonly correctionsRoot?: string;
169
+ }
170
+ /** The tree: the configuration's files, readers and generators, read once each. */
171
+ export interface WikiTree extends TreeFiles {
172
+ readonly config: WikiGateConfig;
173
+ readonly genre: Genre;
174
+ /** The genre's page directory. */
175
+ readonly wikiDir: string;
176
+ readonly overrides: ReadonlyMap<string, string>;
177
+ snapshot(): ExportSurface;
178
+ closedSetRows(): readonly ClosedSetRow[];
179
+ contractRows(): readonly ContractRow[];
180
+ packageJson(): {
181
+ name?: unknown;
182
+ version?: unknown;
183
+ };
184
+ configKnobs(): CatalogKnob[];
185
+ /** Every page file under the genre's directory (repo-relative, sorted); `notes/` directories hold annotation files, not pages. */
186
+ wikiPageFiles(): string[];
187
+ /** Every page file under the directory of every genre: a page-id is unique across a repository's genres. */
188
+ allPageFiles(): string[];
189
+ /** The acceptance index as this check reads it; undefined when no checkout is named or it holds no index. */
190
+ acceptanceIndex(): AcceptanceIndex | undefined;
191
+ /** Whether the correction record is among this repository's required sources. */
192
+ readonly correctionsDeclared: boolean;
193
+ /** The correction records; undefined when the record is not declared, or declared and not in this check. */
194
+ corrections(): readonly CorrectionRecord[] | undefined;
195
+ readonly readers: GeneratorReaders;
196
+ readonly generators: Record<string, Generator>;
197
+ }
198
+ /** The correction-record source among the required ones, if declared (its value may be undefined: declared without a reader). */
199
+ export declare function correctionSourceOf(config: Pick<WikiGateConfig, "required">): {
200
+ declared: boolean;
201
+ source: CorrectionRecordSource | undefined;
202
+ };
203
+ /**
204
+ * The tree a page is checked against. `overrides` (repo-relative path → text) stands in for files on disk — how a pin
205
+ * checks a page against a changed source without writing one. A repository generator spelled like a generator the
206
+ * package holds is refused: one name, one generator.
207
+ */
208
+ export declare function createTree(config: WikiGateConfig, { overrides, testRoot, correctionsRoot }?: TreeOptions): WikiTree;
209
+ /** The declared coverage sources, required then reported, each with its name (a source declared without a reader is skipped). */
210
+ export declare function coverageSourcesOf(config: Pick<WikiGateConfig, "required" | "reported">): Array<{
211
+ name: string;
212
+ source: CoverageSource;
213
+ required: boolean;
214
+ }>;
215
+ /** The keys a generator's `covers` may name: the covers key of a declared coverage source. */
216
+ export declare function coversKeysOf(config: Pick<WikiGateConfig, "required" | "reported">): string[];
217
+ /** This repository as the gate sees it: its package name, version, and the word its pages name it by in `owner-repo`. */
218
+ export declare function repoOf(tree: Pick<WikiTree, "packageJson"> & {
219
+ readonly config?: Pick<WikiGateConfig, "identity">;
220
+ }): RepoIdentity & {
221
+ word: string;
222
+ };
223
+ /** The distance this repository reads releases by. */
224
+ export declare function releaseDistanceOf(tree: Pick<WikiTree, "config">): ReleaseDistance;
225
+ /** A ref that reads as its kind but is no evidence: `fixed` is a severity no version moves. */
226
+ export type RefRefusal = Unresolvable & {
227
+ readonly fixed?: Severity;
228
+ };
229
+ /**
230
+ * One reading per ref. Undefined = the ref does not resolve in this tree; `{ error, fixed? }` = it is malformed, names
231
+ * something that is not evidence, or needs a reader this repository does not configure; otherwise `{ text, file?, line? }`
232
+ * where `text` is what the freshness digest is taken of.
233
+ */
234
+ export declare function resolveRef(tree: WikiTree, ref: string): Resolved | RefRefusal | undefined;
package/dist/tree.js ADDED
@@ -0,0 +1,240 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import ts from "typescript";
4
+ import { declarationOf, pinOf } from "./source.js";
5
+ import { splitPage } from "./page.js";
6
+ import { minorReleaseDistance, parseRun } from "./versions.js";
7
+ import { GENRES, genreRow } from "./genre.js";
8
+ import { cellRefusal, keyReached, parseCritKey } from "./acceptance.js";
9
+ import { CORRECTION_RECORD } from "./corrections.js";
10
+ import { builtInGenerators } from "./generators.js";
11
+ export const REF_KINDS = Object.freeze(["export", "set", "contract", "src", "pin", "catalog", "run", "crit"]);
12
+ function noReader(what) {
13
+ return `this repository configures no ${what} reader`;
14
+ }
15
+ export function correctionSourceOf(config) {
16
+ const required = config.required ?? {};
17
+ if (!Object.hasOwn(required, CORRECTION_RECORD))
18
+ return { declared: false, source: undefined };
19
+ const v = required[CORRECTION_RECORD];
20
+ return { declared: true, source: v !== undefined && "records" in v ? v : undefined };
21
+ }
22
+ export function createTree(config, { overrides = new Map(), testRoot, correctionsRoot } = {}) {
23
+ const { root, genre } = config;
24
+ const wikiDir = genreRow(genre).dir;
25
+ const repoReaders = config.readers ?? {};
26
+ const texts = new Map();
27
+ const sources = new Map();
28
+ const once = new Map();
29
+ const memo = (key, f) => {
30
+ if (!once.has(key))
31
+ once.set(key, f());
32
+ return once.get(key);
33
+ };
34
+ const required = (reader, what) => {
35
+ if (reader === undefined)
36
+ throw new Error(noReader(what));
37
+ return reader;
38
+ };
39
+ const pagesUnder = (dir) => {
40
+ const out = new Set();
41
+ const walk = (rel) => {
42
+ const abs = join(root, rel);
43
+ if (!existsSync(abs))
44
+ return;
45
+ for (const e of readdirSync(abs, { withFileTypes: true })) {
46
+ const child = `${rel}/${e.name}`;
47
+ if (e.isDirectory()) {
48
+ if (e.name !== "notes")
49
+ walk(child);
50
+ }
51
+ else if (e.name.endsWith(".md"))
52
+ out.add(child);
53
+ }
54
+ };
55
+ walk(dir);
56
+ for (const rel of overrides.keys())
57
+ if (rel.startsWith(`${dir}/`) && rel.endsWith(".md") && !rel.includes("/notes/"))
58
+ out.add(rel);
59
+ return [...out].sort();
60
+ };
61
+ const corrections = correctionSourceOf(config);
62
+ const tree = {
63
+ root,
64
+ config,
65
+ genre,
66
+ wikiDir,
67
+ overrides,
68
+ exists: (rel) => overrides.has(rel) || existsSync(join(root, rel)),
69
+ read: (rel) => {
70
+ if (overrides.has(rel))
71
+ return overrides.get(rel);
72
+ if (!texts.has(rel))
73
+ texts.set(rel, readFileSync(join(root, rel), "utf8"));
74
+ return texts.get(rel);
75
+ },
76
+ sourceFile: (rel) => {
77
+ if (!sources.has(rel))
78
+ sources.set(rel, ts.createSourceFile(rel, tree.read(rel), ts.ScriptTarget.ES2022, true));
79
+ return sources.get(rel);
80
+ },
81
+ snapshot: () => memo("snapshot", () => required(repoReaders.snapshot, "export-surface")(tree)),
82
+ closedSetRows: () => memo("closed", () => required(repoReaders.closedSets, "closed-set registry").rows(tree)),
83
+ contractRows: () => memo("contracts", () => required(repoReaders.contracts, "contract-sentence registry").rows(tree)),
84
+ packageJson: () => memo("package", () => JSON.parse(tree.read("package.json"))),
85
+ configKnobs: () => memo("knobs", () => required(repoReaders.catalog, "configuration catalog").knobs(tree)),
86
+ wikiPageFiles: () => memo("pages", () => pagesUnder(wikiDir)),
87
+ allPageFiles: () => memo("all-pages", () => Object.values(GENRES).flatMap((row) => pagesUnder(row.dir))),
88
+ acceptanceIndex: () => memo("acceptance", () => required(repoReaders.acceptanceIndex, "acceptance-index")(testRoot)),
89
+ correctionsDeclared: corrections.declared,
90
+ corrections: () => memo("corrections", () => (corrections.declared ? required(corrections.source, "correction-record").records(correctionsRoot) : undefined)),
91
+ };
92
+ tree.readers = readersOf(tree);
93
+ const own = (config.generators ?? (() => ({})))(tree.readers);
94
+ const builtIn = builtInGenerators(tree.readers);
95
+ for (const name of Object.keys(own))
96
+ if (Object.hasOwn(builtIn, name))
97
+ throw new Error(`the repository's generator \`${name}\` is spelled like the package's own: one name, one generator`);
98
+ tree.generators = Object.assign(Object.create(null), builtIn, own);
99
+ return tree;
100
+ }
101
+ export function coverageSourcesOf(config) {
102
+ const out = [];
103
+ for (const [name, v] of Object.entries(config.required ?? {}))
104
+ if (v !== undefined && "items" in v)
105
+ out.push({ name, source: v, required: true });
106
+ for (const [name, v] of Object.entries(config.reported ?? {}))
107
+ if (v !== undefined)
108
+ out.push({ name, source: v, required: false });
109
+ return out;
110
+ }
111
+ export function coversKeysOf(config) {
112
+ return [...new Set(coverageSourcesOf(config).flatMap(({ source }) => (source.coversKey === undefined ? [] : [source.coversKey])))];
113
+ }
114
+ export function repoOf(tree) {
115
+ const identity = tree.config?.identity;
116
+ if (identity !== undefined)
117
+ return { name: identity.name, version: identity.version, word: identity.word };
118
+ const pkg = tree.packageJson();
119
+ const word = String(pkg.name ?? "").replace(/^@sema-agent\//, "");
120
+ return { name: pkg.name, version: pkg.version, word };
121
+ }
122
+ export function releaseDistanceOf(tree) {
123
+ return tree.config.releaseDistance ?? minorReleaseDistance;
124
+ }
125
+ function readersOf(tree) {
126
+ const repoReaders = tree.config.readers ?? {};
127
+ return {
128
+ setMembers: (name) => {
129
+ const row = tree.closedSetRows().find((r) => r.set === name);
130
+ if (!row || !tree.exists(row.file))
131
+ return undefined;
132
+ return repoReaders.closedSets.members(tree.sourceFile(row.file), row) ?? undefined;
133
+ },
134
+ tableValues: (rel, table) => {
135
+ if (repoReaders.tableValues === undefined)
136
+ throw new Error(noReader("literal-table"));
137
+ return repoReaders.tableValues(tree, rel, table);
138
+ },
139
+ closedSetRows: () => tree.closedSetRows(),
140
+ configKnobs: () => tree.configKnobs(),
141
+ wikiPages: () => tree
142
+ .wikiPageFiles()
143
+ .filter((rel) => rel !== `${tree.wikiDir}/README.md`)
144
+ .map((rel) => ({ rel: rel.slice(tree.wikiDir.length + 1), fm: splitPage(tree.read(rel)).fm }))
145
+ .filter((p) => p.fm !== null && typeof p.fm["page-id"] === "string"),
146
+ genre: () => genreRow(tree.genre),
147
+ };
148
+ }
149
+ function missingReaderOf(tree, kind) {
150
+ const r = tree.config.readers ?? {};
151
+ if (kind === "export" && r.snapshot === undefined)
152
+ return "export-surface";
153
+ if (kind === "set" && r.closedSets === undefined)
154
+ return "closed-set registry";
155
+ if (kind === "contract" && r.contracts === undefined)
156
+ return "contract-sentence registry";
157
+ if (kind === "catalog" && r.catalog === undefined)
158
+ return "configuration catalog";
159
+ if (kind === "crit" && r.acceptanceIndex === undefined)
160
+ return "acceptance-index";
161
+ if (kind === "crit" && r.criterionText === undefined)
162
+ return "criterion-text";
163
+ return undefined;
164
+ }
165
+ export function resolveRef(tree, ref) {
166
+ const m = /^([a-z]+):(.+)$/.exec(ref);
167
+ if (!m)
168
+ return { error: `ref "${ref}" names no kind (one of ${REF_KINDS.join(", ")})` };
169
+ const kind = m[1];
170
+ const rest = m[2];
171
+ const missing = missingReaderOf(tree, kind);
172
+ if (missing !== undefined)
173
+ return { error: `\`${kind}:\` refs are not read here: ${noReader(missing)}` };
174
+ switch (kind) {
175
+ case "export": {
176
+ const s = tree.snapshot();
177
+ return Object.hasOwn(s.exports, rest) ? { text: `${rest}:${s.exports[rest]}:${s.tiers[rest]}` } : undefined;
178
+ }
179
+ case "set": {
180
+ const row = tree.closedSetRows().find((r) => r.set === rest);
181
+ if (!row)
182
+ return undefined;
183
+ const members = tree.readers.setMembers(rest);
184
+ return members === undefined ? { error: `the registry row of ${rest} names members its reader cannot read` } : { text: JSON.stringify(members), file: row.file };
185
+ }
186
+ case "contract": {
187
+ const row = tree.contractRows().find((r) => r.id === rest);
188
+ if (!row || !tree.exists(row.file))
189
+ return undefined;
190
+ const tag = tree.config.readers.contracts.tags(tree.sourceFile(row.file), row.file).find((t) => t.id === rest);
191
+ return tag ? { text: tag.sentences.join(" "), file: row.file, line: tag.line } : undefined;
192
+ }
193
+ case "src": {
194
+ const sm = /^([^#]+)#([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/.exec(rest);
195
+ if (!sm)
196
+ return { error: `src ref "${ref}" must read src:<file>#<Declaration>[.<member>…]` };
197
+ if (!tree.exists(sm[1]))
198
+ return undefined;
199
+ const d = declarationOf(tree, sm[1], sm[2]);
200
+ return d ? { text: d.text, file: sm[1], line: d.line } : undefined;
201
+ }
202
+ case "pin": {
203
+ const pm = /^([^#]+)#"(.+)"$/.exec(rest);
204
+ if (!pm)
205
+ return { error: `pin ref "${ref}" must read pin:<test file>#"<title fragment>"` };
206
+ return (tree.config.readers?.pin ?? pinOf)(tree, pm[1], pm[2]);
207
+ }
208
+ case "catalog": {
209
+ const knob = tree.configKnobs().find((k) => k.key === rest);
210
+ return knob ? { text: knob.text, file: tree.config.readers.catalog.file, line: knob.line } : undefined;
211
+ }
212
+ case "run":
213
+ return parseRun(rest) ? { text: rest } : { error: `run ref "${ref}" must read run:<who>@<package>@<X.Y.Z>@<YYYY-MM-DD>` };
214
+ case "crit":
215
+ return resolveCrit(tree, rest);
216
+ default:
217
+ return { error: `unknown ref kind "${kind}" (one of ${REF_KINDS.join(", ")})` };
218
+ }
219
+ }
220
+ function resolveCrit(tree, key) {
221
+ const refuse = (error) => ({ error, fixed: "fail" });
222
+ const k = parseCritKey(key);
223
+ if (k === undefined)
224
+ return refuse(`crit ref "crit:${key}" must read crit:<repo>@<X.Y.Z>:<cell> (G<n>[.<sub>] or G-<id>)`);
225
+ const repo = repoOf(tree);
226
+ if (k.repo !== repo.word)
227
+ return refuse(`crit:${key} is a cell of ${k.repo}, not of this repository (${repo.word}): not evidence for this page`);
228
+ if (!keyReached(k, repo.version))
229
+ return refuse(`crit:${key} names ${k.version}, a release this tree (${repo.version}) has not reached`);
230
+ const index = tree.acceptanceIndex();
231
+ if (index === undefined)
232
+ return { error: `crit:${key} cannot be judged: no acceptance index in this check (name its checkout with --test <dir>)` };
233
+ const refusal = cellRefusal(index, key);
234
+ if (refusal !== undefined)
235
+ return refuse(refusal);
236
+ const criterion = tree.config.readers.criterionText(tree, k);
237
+ if (criterion === undefined)
238
+ return refuse(`crit:${key}: the publisher's record states no such criterion`);
239
+ return { text: `${key}\n${index.cells.get(key).textSha}\n${criterion}` };
240
+ }