@veda-hq/pariksha 0.5.13 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ // DOC-DRIFT-01, links and anchors: a relative Markdown link, reference
2
+ // definition or image names a file that exists, and its `#anchor` a heading
3
+ // in that file. The checking is lychee's, offline; this module only chooses
4
+ // the tree it reads, parses its report, and applies the exemptions.
5
+ //
6
+ // lychee reads files from disk. A worktree or index snapshot is read where it
7
+ // stands; a ref snapshot (a push's head or merge base) is extracted to a
8
+ // temporary directory first. Backticked paths are not links: doc-paths.ts
9
+ // reads those.
10
+ import { execFileSync, spawnSync } from 'node:child_process';
11
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join, posix, relative, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { normalizeRepoPath } from "./doc-references.js";
16
+ import { gitEnv, PINNED } from "./git.js";
17
+ import { isRecord } from "./registry-ratchet.js";
18
+ const optionalString = (v, where) => {
19
+ if (v === undefined || typeof v === 'string')
20
+ return v;
21
+ throw new Error(`lychee's report: ${where} is not a string`);
22
+ };
23
+ /** The part of lychee's `--format json` report read here (`error_map`); the rest is ignored. */
24
+ const errorMap = (report) => {
25
+ const parsed = JSON.parse(report);
26
+ if (!isRecord(parsed))
27
+ throw new Error("lychee's report is not a JSON object");
28
+ const map = parsed['error_map'];
29
+ if (map === undefined)
30
+ return [];
31
+ if (!isRecord(map))
32
+ throw new Error("lychee's report: error_map is not an object");
33
+ return Object.entries(map).map(([source, entries]) => {
34
+ if (!Array.isArray(entries))
35
+ throw new Error(`lychee's report: error_map["${source}"] is not a list`);
36
+ return [
37
+ source,
38
+ entries.map((entry, i) => {
39
+ const where = `error_map["${source}"][${i}]`;
40
+ if (!isRecord(entry))
41
+ throw new Error(`lychee's report: ${where} is not an object`);
42
+ const status = entry['status'];
43
+ const span = entry['span'];
44
+ if (status !== undefined && !isRecord(status))
45
+ throw new Error(`lychee's report: ${where}.status is not an object`);
46
+ if (span !== undefined && !isRecord(span))
47
+ throw new Error(`lychee's report: ${where}.span is not an object`);
48
+ const line = span?.['line'];
49
+ if (line !== undefined && (typeof line !== 'number' || !Number.isSafeInteger(line) || line < 1))
50
+ throw new Error(`lychee's report: ${where}.span.line is not a positive integer`);
51
+ return { url: optionalString(entry['url'], `${where}.url`), status: optionalString(status?.['text'], `${where}.status.text`) ?? '', line };
52
+ }),
53
+ ];
54
+ });
55
+ };
56
+ const reasonFor = (text, path, hash) => {
57
+ if (/fragment/i.test(text))
58
+ return path === '' ? `Cannot find heading for \`${hash}\`` : `Cannot find heading for \`${hash}\` in \`${path}\``;
59
+ if (/not found/i.test(text))
60
+ return `Cannot find file \`${path}\``;
61
+ return `${text || 'lychee could not check it'}: \`${path}${hash}\``;
62
+ };
63
+ /**
64
+ * Reads lychee's `--format json` report of a run in `dir`. Only `file://`
65
+ * links are this check's; a URL is not (offline, lychee blocks them). Each
66
+ * target is named from its doc's directory, as the link was written.
67
+ */
68
+ export const parseLycheeReport = (report, dir) => {
69
+ const entries = errorMap(report);
70
+ const root = realpathSync(dir);
71
+ const findings = [];
72
+ for (const [source, list] of entries) {
73
+ const file = relative(root, resolve(root, source));
74
+ for (const entry of list) {
75
+ if (entry.url === undefined || !entry.url.startsWith('file:'))
76
+ continue;
77
+ const url = new URL(entry.url);
78
+ // fileURLToPath reads the path alone: the #anchor stays on `url`.
79
+ const target = fileURLToPath(url);
80
+ const path = target === join(root, file) ? '' : relative(join(root, file, '..'), target);
81
+ findings.push({ file, line: entry.line ?? 1, ref: `${path}${url.hash}`, reason: reasonFor(entry.status, path, url.hash) });
82
+ }
83
+ }
84
+ return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
85
+ };
86
+ /**
87
+ * How lychee's `--files-from` must name `file` to read that file and no
88
+ * other. It takes a line as a URL (`https:x.md`) unless it starts `./`, and
89
+ * as a glob when it holds `*`, `?` or `[`, so those are bracketed. A line
90
+ * cannot hold a newline: that path is refused, never left unchecked.
91
+ */
92
+ export const lycheeInputLine = (file) => {
93
+ if (/[\n\r]/.test(file))
94
+ throw new Error(`lychee reads its file list a line at a time, so it cannot check ${JSON.stringify(file)}: rename it`);
95
+ return `./${file.replace(/[*?[]/g, (char) => `[${char}]`)}`;
96
+ };
97
+ /**
98
+ * The real runner: lychee, offline, anchors included. `--config /dev/null`
99
+ * keeps a lychee.toml in the tree out of it; `--root-dir` resolves a
100
+ * `/docs/x.md` link from the repository root. A missing lychee fails, never
101
+ * passes. `command` is the executable, `lychee` on PATH by default.
102
+ */
103
+ export const lycheeRunner = (command = 'lychee') => (dir, files) => {
104
+ const result = spawnSync(command, ['--offline', '--include-fragments', '--format', 'json', '--no-progress', '--config', '/dev/null', '--root-dir', realpathSync(dir), '--files-from', '-'], {
105
+ cwd: dir,
106
+ encoding: 'utf8',
107
+ input: `${files.map(lycheeInputLine).join('\n')}\n`,
108
+ maxBuffer: 256 * 1024 * 1024,
109
+ });
110
+ if (result.error !== undefined) {
111
+ if (result.error.code === 'ENOENT')
112
+ throw new Error(`${command} is not installed; the link check needs lychee (https://lychee.cli.rs)`);
113
+ throw result.error;
114
+ }
115
+ // 0: every link resolves; 2: some do not, listed in the report.
116
+ if (result.status !== 0 && result.status !== 2)
117
+ throw new Error(`lychee exited ${String(result.status)}: ${result.stderr}`);
118
+ return result.stdout;
119
+ };
120
+ /** A directory holding the snapshot's tree, and how to let it go. */
121
+ export const treeFor = (snapshot) => {
122
+ if (snapshot.source.kind !== 'ref')
123
+ return { dir: snapshot.root, release: () => undefined };
124
+ const dir = mkdtempSync(join(tmpdir(), 'doc-links-'));
125
+ try {
126
+ const tar = join(dir, '.tree.tar');
127
+ execFileSync('git', [...PINNED, 'archive', '-o', tar, snapshot.source.ref], { cwd: snapshot.root, env: gitEnv(process.env), stdio: 'pipe' });
128
+ execFileSync('tar', ['-xf', tar, '-C', dir], { stdio: 'pipe' });
129
+ rmSync(tar);
130
+ }
131
+ catch (e) {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ throw e;
134
+ }
135
+ return { dir, release: () => rmSync(dir, { recursive: true, force: true }) };
136
+ };
137
+ /**
138
+ * The broken links in `files`' Markdown (those the config's `links.include`
139
+ * takes), as DOC-DRIFT-01 problems. A finding stays out when its line carries
140
+ * the exemption marker, its target is a placeholder, or the repository's
141
+ * ignore rules cover the target.
142
+ */
143
+ export const findLinkProblems = (config, snapshot, files, run) => {
144
+ const include = config.links?.include;
145
+ if (include === undefined)
146
+ return [];
147
+ const docs = files.filter((file) => include.test(file));
148
+ if (docs.length === 0)
149
+ return [];
150
+ const tree = treeFor(snapshot);
151
+ let findings;
152
+ try {
153
+ // Parsed while the tree stands: its targets are named from tree.dir.
154
+ findings = parseLycheeReport(run(tree.dir, docs), tree.dir);
155
+ }
156
+ finally {
157
+ tree.release();
158
+ }
159
+ const asked = new Set(docs);
160
+ const kept = findings.filter((finding) => {
161
+ if (!asked.has(finding.file))
162
+ return false;
163
+ const text = snapshot.read(finding.file)?.split('\n')[finding.line - 1];
164
+ if (text !== undefined && config.exemptMarker.test(text))
165
+ return false;
166
+ return !config.placeholder.test(finding.ref);
167
+ });
168
+ const targetOf = (finding) => {
169
+ const path = finding.ref.replace(/#.*$/, '');
170
+ return path === '' ? null : normalizeRepoPath(posix.join(posix.dirname(finding.file), path));
171
+ };
172
+ const targets = kept.flatMap((finding) => {
173
+ const target = targetOf(finding);
174
+ return target === null ? [] : [target, `${target}/`];
175
+ });
176
+ const ignored = snapshot.ignored([...new Set(targets)]);
177
+ return kept
178
+ .filter((finding) => {
179
+ const target = targetOf(finding);
180
+ return target === null || (!ignored.has(target) && !ignored.has(`${target}/`));
181
+ })
182
+ .map((finding) => ({ check: 'DOC-DRIFT-01', file: finding.file, line: finding.line, ref: finding.ref, reason: finding.reason }));
183
+ };
@@ -0,0 +1,267 @@
1
+ // DOC-DRIFT-01, backticked paths: a repository path written in backticks, in
2
+ // prose, a shell fence, frontmatter or a code comment (`dir/file.ext`,
3
+ // `dir/`, `dir/**/*.ts`), resolves; so does `file.ext:12`, and a backticked
4
+ // symbol cited beside it is at those lines. No link checker reads code spans,
5
+ // so this is the repository's own check. It needs only the snapshot's file
6
+ // list, so it runs at commit with the link check. A path the repository's
7
+ // ignore rules cover is out of scope.
8
+ import { posix } from 'node:path';
9
+ import { ancestorDirs, codeSpans, commentText, docKind, globToRegExp, isShellFence, normalizeRepoPath, scanMarkdown } from "./doc-references.js";
10
+ import { boundedWord, manifestObject } from "./doc-identifiers.js";
11
+ /** `0123abcd:path` and `origin/main:path` name a blob in history, not the tree. */
12
+ const HISTORICAL_BLOB = /^(?:[0-9a-f]{7,40}|HEAD[~^\d]*|origin\/[\w./-]+|main):/;
13
+ const DOMAIN = /\.(com|org|io|dev|net|ai|app|co|so|sh)$/i;
14
+ /**
15
+ * Trims wrapping punctuation from a token cut out of prose or a command:
16
+ * opening brackets and quotes before it, closing ones and sentence
17
+ * punctuation (a final `.` or `:` included) after it.
18
+ */
19
+ const trimToken = (token) => token.replace(/^[("'[]+/, '').replace(/[)"'\],;!?.:]+$/, '');
20
+ /** Digits as a line number, or null past a safe integer: no line this check can count to. */
21
+ const lineNumber = (digits) => {
22
+ const n = Number(digits);
23
+ return Number.isSafeInteger(n) ? n : null;
24
+ };
25
+ /**
26
+ * The repository path a single token names, or null when it is not one.
27
+ * `requireSlash`: a bare name (`spec.md`) is too generic to resolve unless a
28
+ * line number pins it to one file, so most callers require a directory.
29
+ */
30
+ export const pathRef = (config, raw, requireSlash = true) => {
31
+ const { placeholder, paths } = config;
32
+ if (placeholder.test(raw))
33
+ return null;
34
+ let token = trimToken(raw);
35
+ // `--root=docs/x.md` names its value; `:docs/x.md` names a staged path.
36
+ const assigned = /^-{1,2}[\w-]+=(.+)$/.exec(token);
37
+ if (assigned !== null)
38
+ token = String(assigned[1]);
39
+ if (HISTORICAL_BLOB.test(token))
40
+ return null;
41
+ const blob = /^:([^:\d].*)$/.exec(token);
42
+ if (blob !== null)
43
+ token = String(blob[1]);
44
+ // A scoped package (`@acme/core/…`) or a flag (`-x/…`). An empty token, a
45
+ // URL, a leading `/` or `~`, `=` and `\` fail the segment test below.
46
+ if (/^[@-]/.test(token))
47
+ return null;
48
+ let lines;
49
+ const lineSuffix = /^(.+?\.[A-Za-z0-9]+)(?::(\d+)(?:-(\d+))?(?::\d+)?|#L(\d+)(?:-L(\d+))?)$/.exec(token);
50
+ if (lineSuffix !== null) {
51
+ token = String(lineSuffix[1]);
52
+ const startText = String(lineSuffix[2] ?? lineSuffix[4]);
53
+ const start = lineNumber(startText);
54
+ const end = lineNumber(lineSuffix[3] ?? lineSuffix[5] ?? startText);
55
+ if (start !== null && end !== null)
56
+ lines = [start, end];
57
+ }
58
+ const glob = /[*?]/.test(token);
59
+ const dir = token.endsWith('/');
60
+ const segments = token.replace(/\/$/, '').split('/');
61
+ // An empty segment (`a//b`, a leading `/`) fails here too; `{` never gets this far: a placeholder.
62
+ if (!segments.every((segment) => /^[\w.@+*?,-]+$/.test(segment)))
63
+ return null;
64
+ if (segments.length > 1 && DOMAIN.test(String(segments[0])))
65
+ return null;
66
+ if (requireSlash && segments.length < 2 && lines === undefined)
67
+ return null;
68
+ const extension = /\.([A-Za-z0-9]+)$/.exec(String(segments.at(-1)))?.[1]?.toLowerCase();
69
+ const hasExtension = extension !== undefined && paths.extensions.has(extension);
70
+ if (!dir && !hasExtension && !(glob && segments.length > 1))
71
+ return null;
72
+ // `0.1/2.5` and friends: a path segment has at least one letter.
73
+ if (!segments.some((segment) => /[A-Za-z]/.test(segment)))
74
+ return null;
75
+ if (paths.examples.test(token.replace(/\/$/, '')))
76
+ return null;
77
+ if (paths.untracked.some((prefix) => `${token}/`.startsWith(prefix)))
78
+ return null;
79
+ return { path: token, glob, dir, lines };
80
+ };
81
+ const DEPENDENCY_LISTS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
82
+ const resolverFor = (config, snapshot) => {
83
+ const manifests = [...snapshot.files].filter((file) => file === 'package.json' || config.paths.workspaces.test(file));
84
+ // A workspace's source root too: code comments cite `src`-relative paths.
85
+ const workspaces = manifests.filter((file) => file !== 'package.json').flatMap((file) => [posix.dirname(file), `${posix.dirname(file)}/src`]);
86
+ // Package names any tracked manifest depends on: `pg-pool/index.js` is a file inside one.
87
+ const dependencies = new Set();
88
+ for (const text of snapshot.readMany(manifests).values()) {
89
+ const pkg = manifestObject(text);
90
+ for (const list of DEPENDENCY_LISTS) {
91
+ const deps = pkg?.[list];
92
+ // A malformed list names no dependencies; the manifest's other lists count.
93
+ if (typeof deps === 'object' && deps !== null && !Array.isArray(deps))
94
+ for (const name of Object.keys(deps))
95
+ dependencies.add(name);
96
+ }
97
+ }
98
+ const globs = new Map();
99
+ const globMatches = (glob) => {
100
+ const cached = globs.get(glob);
101
+ if (cached !== undefined)
102
+ return cached;
103
+ const pattern = globToRegExp(glob);
104
+ const found = [...snapshot.files, ...snapshot.dirs].some((path) => pattern.test(path));
105
+ globs.set(glob, found);
106
+ return found;
107
+ };
108
+ const exists = (path) => snapshot.files.has(path) || snapshot.dirs.has(path);
109
+ return {
110
+ inDependency: (path) => dependencies.has(String(path.split('/')[0])),
111
+ // A path as a reader would resolve it: next to the doc, then upwards, then from each workspace.
112
+ resolveLoose: (doc, ref) => {
113
+ const tried = [...new Set([...ancestorDirs(doc), ...workspaces].map((base) => normalizeRepoPath(posix.join(base, ref.path))))].filter((candidate) => candidate !== null);
114
+ const path = tried.find((candidate) => (ref.glob ? globMatches(candidate) : exists(candidate))) ?? null;
115
+ // A directory is asked about as one, so `dir/` ignore rules apply.
116
+ return { path, tried: ref.dir ? tried.map((t) => `${t}/`) : tried };
117
+ },
118
+ // A directory or glob is anchored when its first segment exists from some base:
119
+ // `refs/heads/*` and branch prefixes are not paths. `..` above the root is anchored.
120
+ anchored: (doc, ref) => {
121
+ const first = String(ref.path.split('/')[0]);
122
+ if (first === '..')
123
+ return true;
124
+ return [...ancestorDirs(doc), ...workspaces].some((base) => {
125
+ const candidate = normalizeRepoPath(posix.join(base, first));
126
+ return candidate !== null && snapshot.dirs.has(candidate);
127
+ });
128
+ },
129
+ };
130
+ };
131
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\(\))?$/;
132
+ /** The backticked symbol cited beside a span: "`name` in `file.ts:12`", or "`file.ts:12` (`name`)". */
133
+ const symbolNear = (text, spans, index) => {
134
+ const span = spans[index];
135
+ if (span === undefined)
136
+ return null;
137
+ const before = spans[index - 1];
138
+ if (before !== undefined && IDENTIFIER.test(before.content) && /^\s*(?:\(|at|in|—|–|-|:)?\s*\[?$/.test(text.slice(before.end + 1, span.start - 1)))
139
+ return before.content;
140
+ const after = spans[index + 1];
141
+ if (after !== undefined && IDENTIFIER.test(after.content) && /^\]?(?:\([^)]*\))?\s*\(\s*$/.test(text.slice(span.end + 1, after.start - 1)))
142
+ return after.content;
143
+ return null;
144
+ };
145
+ /** DOC-DRIFT-01's backticked paths over `files` in `snapshot`. Links and anchors are lychee's (doc-links.ts). */
146
+ export const findPathProblems = (config, snapshot, files) => {
147
+ const resolver = resolverFor(config, snapshot);
148
+ const pending = [];
149
+ const flag = (file, line, ref, reason, tried = []) => {
150
+ pending.push({ file, line, ref, reason, tried });
151
+ };
152
+ /** Checks a resolved file's line range and the symbol cited beside it. */
153
+ const checkLines = (file, line, shown, target, lines, symbol) => {
154
+ // A directory has no lines to count.
155
+ const text = snapshot.read(target);
156
+ if (text === undefined)
157
+ return;
158
+ const content = text.split('\n');
159
+ const count = text.endsWith('\n') ? content.length - 1 : content.length;
160
+ const [start, end] = lines;
161
+ if (start < 1 || end < start || end > count) {
162
+ flag(file, line, shown, `\`${target}\` has ${count} lines; line ${end > count ? end : start} does not exist`);
163
+ return;
164
+ }
165
+ if (symbol === null)
166
+ return;
167
+ // `a.b.run()` is looked for as `run`.
168
+ const name = symbol.replace(/\(\)$/, '').replace(/^.*\./, '');
169
+ const window = content.slice(Math.max(0, start - 4), end + 3).join('\n');
170
+ if (!boundedWord(name).test(window))
171
+ flag(file, line, shown, `\`${symbol}\` is not at \`${target}:${start}${end === start ? '' : `-${end}`}\` — the citation drifted`);
172
+ };
173
+ /** A backticked or fenced token: resolve loosely, then lines and symbol. */
174
+ const checkToken = (file, line, token, symbol) => {
175
+ const ref = pathRef(config, token, true);
176
+ if (ref === null || config.paths.layouts.some((l) => l.pattern.test(ref.path) && !(l.except?.test(file) ?? false)))
177
+ return;
178
+ let target;
179
+ let tried;
180
+ if (!ref.path.includes('/')) {
181
+ const near = resolver.resolveLoose(file, ref);
182
+ tried = near.tried;
183
+ if (near.path !== null) {
184
+ target = near.path;
185
+ }
186
+ else {
187
+ const matches = snapshot.byBasename.get(ref.path) ?? [];
188
+ if (matches.length > 1)
189
+ return; // ambiguous: cannot tell which file
190
+ target = matches[0] ?? null;
191
+ }
192
+ }
193
+ else {
194
+ if ((ref.dir || ref.glob) && !resolver.anchored(file, ref))
195
+ return;
196
+ if (resolver.inDependency(ref.path))
197
+ return;
198
+ const resolved = resolver.resolveLoose(file, ref);
199
+ target = resolved.path;
200
+ tried = resolved.tried;
201
+ }
202
+ if (target === null) {
203
+ flag(file, line, token, ref.glob ? `\`${ref.path}\` matches no tracked file` : `\`${ref.path}\` is not tracked (looked beside the doc, above it and at the root)`, tried);
204
+ return;
205
+ }
206
+ if (ref.dir && snapshot.files.has(target)) {
207
+ flag(file, line, token, `\`${ref.path}\` is a file, not a directory`);
208
+ return;
209
+ }
210
+ if (ref.lines !== undefined)
211
+ checkLines(file, line, token, target, ref.lines, symbol);
212
+ };
213
+ const checkSpans = (file, line, text, skipLinkLabels) => {
214
+ const spans = codeSpans(text);
215
+ spans.forEach((span, index) => {
216
+ // A span that is the label of a link is checked through its link.
217
+ if (skipLinkLabels && /\]\(/.test(text.slice(span.end, span.end + 3)))
218
+ return;
219
+ const symbol = symbolNear(text, spans, index);
220
+ for (const token of span.content.split(/\s+/))
221
+ checkToken(file, line, token, symbol);
222
+ });
223
+ };
224
+ const checkMarkdownLine = (file, line) => {
225
+ if (line.exempt)
226
+ return;
227
+ if (line.fence !== null) {
228
+ if (!isShellFence(line.fence))
229
+ return;
230
+ // A `$ ` prompt is no path (a placeholder); a `#` word starts a comment that runs to the end of the line.
231
+ for (const token of line.text.split(/\s+/)) {
232
+ if (token.startsWith('#'))
233
+ break;
234
+ checkToken(file, line.number, token, null);
235
+ }
236
+ return;
237
+ }
238
+ if (line.frontmatter) {
239
+ for (const [, value] of line.text.matchAll(/^\s*(?:-\s+|[\w-]+:\s+)(.+)$/g)) {
240
+ for (const token of String(value).split(/[\s,]+/))
241
+ checkToken(file, line.number, token, null);
242
+ }
243
+ return;
244
+ }
245
+ checkSpans(file, line.number, line.text, true);
246
+ };
247
+ for (const file of files) {
248
+ const kind = docKind(config, file);
249
+ const content = snapshot.read(file);
250
+ if (kind === null || content === undefined)
251
+ continue;
252
+ if (kind === 'markdown') {
253
+ for (const line of scanMarkdown(content, config.exemptMarker))
254
+ checkMarkdownLine(file, line);
255
+ }
256
+ else if (kind === 'code') {
257
+ content.split('\n').forEach((text, index) => {
258
+ const comment = commentText(config.files.hashComments, file, text);
259
+ if (comment === null || config.exemptMarker.test(comment))
260
+ return;
261
+ checkSpans(file, index + 1, comment, false);
262
+ });
263
+ }
264
+ }
265
+ const ignored = snapshot.ignored([...new Set(pending.flatMap((problem) => problem.tried))]);
266
+ return pending.filter((problem) => !problem.tried.some((path) => ignored.has(path))).map(({ file, line, ref, reason }) => ({ check: 'DOC-DRIFT-01', file, line, ref, reason }));
267
+ };
@@ -0,0 +1,172 @@
1
+ // The doc-references config: every fact about one repository the checks read
2
+ // (which files carry references, where scripts and identifiers are defined,
3
+ // what reads as a placeholder) lives here, never in the engine.
4
+ import { isRecord, isRepoPath, list, object, onlyKeys, parseJson, path, regex, required, text } from "./registry-ratchet.js";
5
+ const strings = (raw, key, where) => list(raw, key, where).map((s, i) => {
6
+ if (typeof s !== 'string' || s === '')
7
+ throw new Error(`${where}${key}[${i}] must be a non-empty string`);
8
+ return s;
9
+ });
10
+ /** A list of regular expressions, empty allowed. */
11
+ const patterns = (raw, key, where, flags = '') => list(raw, key, where).map((s, i) => regex(s, `${where}${key}[${i}]`, flags));
12
+ const nonEmpty = (items, key, where) => {
13
+ if (items.length === 0)
14
+ throw new Error(`${where}"${key}" is empty, so nothing would match`);
15
+ return items;
16
+ };
17
+ /** A regex at `raw[key]`, flags optional as `{ "pattern", "flags" }`. */
18
+ const pattern = (raw, key, where) => {
19
+ const v = raw[key];
20
+ if (isRecord(v)) {
21
+ onlyKeys(v, ['pattern', 'flags'], `${where}${key}: `);
22
+ const flags = v['flags'] ?? '';
23
+ if (typeof flags !== 'string' || !/^[imsu]*$/.test(flags))
24
+ throw new Error(`${where}${key}.flags must be some of i, m, s, u`);
25
+ return regex(v['pattern'], `${where}${key}.pattern`, flags);
26
+ }
27
+ return regex(v, `${where}"${key}"`);
28
+ };
29
+ const MARKER_WORD = /^[\w-]+$/;
30
+ export const parseConfig = (source) => {
31
+ const raw = parseJson(source);
32
+ onlyKeys(raw, ['baseline', 'exemptMarker', 'files', 'placeholder', 'paths', 'links', 'scripts', 'commands', 'identifiers'], '');
33
+ const skipped = [];
34
+ const exemptWord = text(raw, 'exemptMarker', '');
35
+ if (!MARKER_WORD.test(exemptWord))
36
+ throw new Error('"exemptMarker" must be one word of letters, digits, _ and -');
37
+ const filesRaw = object(raw, 'files', '');
38
+ const fw = 'files: ';
39
+ onlyKeys(filesRaw, ['markdown', 'html', 'code', 'codeExclude', 'exclude', 'hashComments'], fw);
40
+ const files = {
41
+ markdown: nonEmpty(patterns(filesRaw, 'markdown', fw), 'markdown', fw),
42
+ html: patterns(filesRaw, 'html', fw),
43
+ code: patterns(filesRaw, 'code', fw),
44
+ codeExclude: patterns(filesRaw, 'codeExclude', fw),
45
+ exclude: patterns(filesRaw, 'exclude', fw),
46
+ hashComments: pattern(filesRaw, 'hashComments', fw),
47
+ };
48
+ const pathsRaw = object(raw, 'paths', '');
49
+ const pw = 'paths: ';
50
+ onlyKeys(pathsRaw, ['extensions', 'examples', 'untracked', 'workspaces', 'layouts'], pw);
51
+ const extensions = nonEmpty(strings(pathsRaw, 'extensions', pw), 'extensions', pw).map((e, i) => {
52
+ if (!/^[a-z0-9]+$/.test(e))
53
+ throw new Error(`${pw}extensions[${i}]: "${e}" must be lowercase letters and digits, no dot`);
54
+ return e;
55
+ });
56
+ const untracked = strings(pathsRaw, 'untracked', pw).map((p, i) => {
57
+ if (!p.endsWith('/') || !isRepoPath(p.slice(0, -1)))
58
+ throw new Error(`${pw}untracked[${i}]: "${p}" must be a root-relative folder ending in /`);
59
+ return p;
60
+ });
61
+ const layouts = list(pathsRaw, 'layouts', pw).map((l, i) => {
62
+ const where = `${pw}layouts[${i}]: `;
63
+ if (!isRecord(l))
64
+ throw new Error(`${pw}layouts[${i}] must be an object`);
65
+ onlyKeys(l, ['pattern', 'except'], where);
66
+ return { pattern: regex(l['pattern'], `${where}"pattern"`), except: l['except'] === undefined ? undefined : regex(l['except'], `${where}"except"`) };
67
+ });
68
+ const paths = {
69
+ extensions: new Set(extensions),
70
+ examples: pattern(pathsRaw, 'examples', pw),
71
+ untracked,
72
+ workspaces: pattern(pathsRaw, 'workspaces', pw),
73
+ layouts,
74
+ };
75
+ const links = required(raw, 'links', '', skipped, () => {
76
+ const l = object(raw, 'links', '');
77
+ onlyKeys(l, ['include'], 'links: ');
78
+ return { include: pattern(l, 'include', 'links: ') };
79
+ });
80
+ const scriptsRaw = object(raw, 'scripts', '');
81
+ const sw = 'scripts: ';
82
+ onlyKeys(scriptsRaw, ['manifests', 'mutating', 'verification', 'wrappers'], sw);
83
+ const manifests = nonEmpty(strings(scriptsRaw, 'manifests', sw), 'manifests', sw).map((m, i) => {
84
+ if (!isRepoPath(m) || !/(?:^|\/)package\.json$/.test(m))
85
+ throw new Error(`${sw}manifests[${i}]: "${m}" must be a root-relative package.json`);
86
+ return m;
87
+ });
88
+ const mutatingRaw = object(scriptsRaw, 'mutating', sw);
89
+ const mutating = new Map();
90
+ for (const [name, safer] of Object.entries(mutatingRaw)) {
91
+ if (typeof safer !== 'string' || safer === '')
92
+ throw new Error(`${sw}mutating.${name} must name the script to use instead`);
93
+ mutating.set(name, safer);
94
+ }
95
+ const scripts = {
96
+ manifests,
97
+ mutating,
98
+ verification: pattern(scriptsRaw, 'verification', sw),
99
+ wrappers: strings(scriptsRaw, 'wrappers', sw),
100
+ };
101
+ const commandsRaw = object(raw, 'commands', '');
102
+ const cw = 'commands: ';
103
+ onlyKeys(commandsRaw, ['typescriptRunner', 'nested'], cw);
104
+ const commands = {
105
+ typescriptRunner: required(commandsRaw, 'typescriptRunner', cw, skipped, () => text(commandsRaw, 'typescriptRunner', cw)),
106
+ nested: strings(commandsRaw, 'nested', cw),
107
+ };
108
+ const idRaw = object(raw, 'identifiers', '');
109
+ const iw = 'identifiers: ';
110
+ onlyKeys(idRaw, ['notIdentifiers', 'searchExclude', 'diffExclude', 'prisma', 'routes', 'jobs', 'packages'], iw);
111
+ const prisma = required(idRaw, 'prisma', iw, skipped, () => {
112
+ const p = object(idRaw, 'prisma', iw);
113
+ const w = 'identifiers.prisma: ';
114
+ onlyKeys(p, ['schemas', 'migrations', 'roles'], w);
115
+ return {
116
+ schemas: pattern(p, 'schemas', w),
117
+ migrations: required(p, 'migrations', w, skipped, () => path(p, 'migrations', w)),
118
+ roles: required(p, 'roles', w, skipped, () => {
119
+ const r = object(p, 'roles', w);
120
+ onlyKeys(r, ['schema', 'enum'], `${w}roles: `);
121
+ const name = text(r, 'enum', `${w}roles: `);
122
+ if (!/^\w+$/.test(name))
123
+ throw new Error(`${w}roles.enum must be an enum name`);
124
+ return { schema: path(r, 'schema', `${w}roles: `), enum: name };
125
+ }),
126
+ };
127
+ });
128
+ const routes = required(idRaw, 'routes', iw, skipped, () => {
129
+ const r = object(idRaw, 'routes', iw);
130
+ const w = 'identifiers.routes: ';
131
+ onlyKeys(r, ['controllers', 'prefixes'], w);
132
+ return { controllers: pattern(r, 'controllers', w), prefixes: pattern(r, 'prefixes', w) };
133
+ });
134
+ const jobs = required(idRaw, 'jobs', iw, skipped, () => {
135
+ const j = object(idRaw, 'jobs', iw);
136
+ onlyKeys(j, ['workflows'], 'identifiers.jobs: ');
137
+ return { workflows: pattern(j, 'workflows', 'identifiers.jobs: ') };
138
+ });
139
+ const packages = required(idRaw, 'packages', iw, skipped, () => {
140
+ const p = object(idRaw, 'packages', iw);
141
+ const w = 'identifiers.packages: ';
142
+ onlyKeys(p, ['manifests', 'lockfile', 'ownScopes'], w);
143
+ const ownScopes = strings(p, 'ownScopes', w).map((s, i) => {
144
+ if (!/^@[a-z0-9][\w.-]*\/$/.test(s))
145
+ throw new Error(`${w}ownScopes[${i}]: "${s}" must be a scope ending in / (@acme/)`);
146
+ return s;
147
+ });
148
+ return { manifests: pattern(p, 'manifests', w), lockfile: required(p, 'lockfile', w, skipped, () => path(p, 'lockfile', w)), ownScopes };
149
+ });
150
+ const identifiers = {
151
+ notIdentifiers: new Set(strings(idRaw, 'notIdentifiers', iw)),
152
+ searchExclude: patterns(idRaw, 'searchExclude', iw),
153
+ diffExclude: strings(idRaw, 'diffExclude', iw),
154
+ prisma,
155
+ routes,
156
+ jobs,
157
+ packages,
158
+ };
159
+ return {
160
+ baseline: path(raw, 'baseline', ''),
161
+ exemptMarker: new RegExp(`${exemptWord}:\\s*\\S`),
162
+ exemptWord,
163
+ files,
164
+ placeholder: pattern(raw, 'placeholder', ''),
165
+ paths,
166
+ links,
167
+ scripts,
168
+ commands,
169
+ identifiers,
170
+ skipped,
171
+ };
172
+ };