@veda-hq/pariksha 0.5.13 → 0.5.17
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/NOTICE +6 -0
- package/README.md +280 -5
- package/dist/absolute-paths.js +59 -0
- package/dist/aggregate-gate-needs.js +153 -0
- package/dist/cli.js +130 -0
- package/dist/doc-command-rules.js +179 -0
- package/dist/doc-command-targets.js +122 -0
- package/dist/doc-commands.js +187 -0
- package/dist/doc-files.js +74 -0
- package/dist/doc-flag-spec.js +87 -0
- package/dist/doc-identifiers.js +348 -0
- package/dist/doc-links.js +183 -0
- package/dist/doc-paths.js +267 -0
- package/dist/doc-references-config.js +172 -0
- package/dist/doc-references-gate.js +338 -0
- package/dist/doc-references.js +535 -0
- package/dist/doc-scripts.js +224 -0
- package/dist/non-synced-resources.js +164 -0
- package/dist/pnpm-settings.js +350 -0
- package/dist/semver-range.js +320 -0
- package/dist/shell-text.js +193 -0
- package/package.json +5 -3
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// DOC-DRIFT-04: a code identifier a doc names exists in the code. Every
|
|
2
|
+
// single-identifier code span in a doc's prose is asked of the code:
|
|
3
|
+
//
|
|
4
|
+
// - env vars, constants and symbols (`DATABASE_URL`, `parseArgs()`,
|
|
5
|
+
// `OrderService`): a whole word somewhere in the tracked code; when the doc
|
|
6
|
+
// says "`x` in `path`", in that file;
|
|
7
|
+
// - Prisma `Model.field`, `@@unique([a, b])`, migration folders and
|
|
8
|
+
// `@Roles(…)` values (`identifiers.prisma`);
|
|
9
|
+
// - HTTP routes (`GET /stores/:id`) against controller decorators
|
|
10
|
+
// (`identifiers.routes`);
|
|
11
|
+
// - CI jobs ("the `build` job") against workflow files (`identifiers.jobs`);
|
|
12
|
+
// - packages (`@acme/core`, `zod@4.1.0`) against workspaces and the
|
|
13
|
+
// lockfile (`identifiers.packages`).
|
|
14
|
+
//
|
|
15
|
+
// The code search reads the whole tree, so it runs at push and in CI, not at
|
|
16
|
+
// commit.
|
|
17
|
+
import { posix } from 'node:path';
|
|
18
|
+
import { load } from 'js-yaml';
|
|
19
|
+
import { codeSpans, docKind, scanMarkdown } from "./doc-references.js";
|
|
20
|
+
import { isRecord } from "./registry-ratchet.js";
|
|
21
|
+
const UPPER_SNAKE = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
|
|
22
|
+
const CAMEL = /^[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+$/;
|
|
23
|
+
const PASCAL = /^[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+$/;
|
|
24
|
+
const CALL = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\(\)$/;
|
|
25
|
+
const DOTTED = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/;
|
|
26
|
+
const MIGRATION = /^\d{14}_[a-z0-9_]+$/;
|
|
27
|
+
const ROUTE = /^(GET|POST|PUT|PATCH|DELETE)\s+(\/\S*)$/;
|
|
28
|
+
const ROLES = /^@Roles\((.*)\)$/;
|
|
29
|
+
const SCOPED_PACKAGE = /^(@[a-z0-9][\w.-]*\/[a-z0-9][\w.-]*)(?:@(\d+\.\d+\.\d+))?$/;
|
|
30
|
+
const VERSIONED_PACKAGE = /^([a-z0-9][\w.-]*)@(\d+\.\d+\.\d+)$/;
|
|
31
|
+
/** What a single code span names, if it names an identifier; `notIdentifiers` are products and prose. */
|
|
32
|
+
export const identifierRef = (span, notIdentifiers) => {
|
|
33
|
+
const text = span.trim();
|
|
34
|
+
if (/\s{2,}/.test(text))
|
|
35
|
+
return null;
|
|
36
|
+
if (MIGRATION.test(text))
|
|
37
|
+
return { kind: 'migration', text };
|
|
38
|
+
if (ROUTE.test(text))
|
|
39
|
+
return { kind: 'route', text };
|
|
40
|
+
if (ROLES.test(text))
|
|
41
|
+
return { kind: 'roles', text };
|
|
42
|
+
if (/^@@unique\(\[[\w\s,]+\]\)$/.test(text))
|
|
43
|
+
return { kind: 'prisma-unique', text };
|
|
44
|
+
if (SCOPED_PACKAGE.test(text) || VERSIONED_PACKAGE.test(text))
|
|
45
|
+
return { kind: 'package', text };
|
|
46
|
+
if (/\s/.test(text) || notIdentifiers.has(text))
|
|
47
|
+
return null;
|
|
48
|
+
const call = CALL.exec(text);
|
|
49
|
+
if (call !== null) {
|
|
50
|
+
const word = String(call[1]).replace(/^.*\./, '');
|
|
51
|
+
return word.length < 3 || notIdentifiers.has(word) ? null : { kind: 'symbol', text, word };
|
|
52
|
+
}
|
|
53
|
+
const dotted = DOTTED.exec(text);
|
|
54
|
+
if (dotted !== null && /^[A-Z]/.test(String(dotted[1])) && /^[a-z]/.test(String(dotted[2])))
|
|
55
|
+
return { kind: 'prisma-field', text };
|
|
56
|
+
// Lower snake_case is deliberately absent: it is dominated by names that are
|
|
57
|
+
// not the repository's code (tool names, database settings, table keys).
|
|
58
|
+
if (UPPER_SNAKE.test(text) || CAMEL.test(text) || PASCAL.test(text))
|
|
59
|
+
return { kind: 'symbol', text, word: text };
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
/** Text of every tracked file whose path matches `pattern`. */
|
|
63
|
+
const contents = (snapshot, pattern) => [...snapshot.readMany([...snapshot.files].filter((file) => pattern.test(file))).values()];
|
|
64
|
+
const prismaModels = (snapshot, schemas) => {
|
|
65
|
+
const models = new Map();
|
|
66
|
+
for (const text of contents(snapshot, schemas)) {
|
|
67
|
+
for (const block of text.matchAll(/^(model|enum|type|view)\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
|
|
68
|
+
const fields = new Set();
|
|
69
|
+
const uniques = [];
|
|
70
|
+
for (const raw of String(block[3]).split('\n')) {
|
|
71
|
+
// A blank line, a `//` comment or an `@@` attribute starts with no word, so it names no field.
|
|
72
|
+
const line = raw.trim();
|
|
73
|
+
const unique = /^@@(?:unique|id)\(\s*\[([^\]]+)\]/.exec(line);
|
|
74
|
+
if (unique !== null) {
|
|
75
|
+
uniques.push(String(unique[1])
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((f) => String(f.trim().split(/[\s(]/)[0]))
|
|
78
|
+
.join(','));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const name = /^(\w+)/.exec(line);
|
|
82
|
+
if (name !== null)
|
|
83
|
+
fields.add(String(name[1]));
|
|
84
|
+
}
|
|
85
|
+
models.set(String(block[2]), { fields, uniques });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return models;
|
|
89
|
+
};
|
|
90
|
+
const splitRoute = (path) => path
|
|
91
|
+
.split('/')
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((segment) => (/^[:{]/.test(segment) ? ':' : segment.toLowerCase()));
|
|
94
|
+
/** Routes the controllers' decorators serve (`@Controller('x')`, `@Get(':id')`). */
|
|
95
|
+
const controllerRoutes = (snapshot, controllers) => {
|
|
96
|
+
const routes = [];
|
|
97
|
+
for (const text of contents(snapshot, controllers)) {
|
|
98
|
+
let prefix = '';
|
|
99
|
+
for (const match of text.matchAll(/@(Controller|Get|Post|Put|Patch|Delete)\(\s*(?:['"`]([^'"`]*)['"`])?[^)]*\)/g)) {
|
|
100
|
+
const path = match[2] ?? '';
|
|
101
|
+
if (match[1] === 'Controller') {
|
|
102
|
+
prefix = path;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
routes.push({ method: String(match[1]).toUpperCase(), segments: splitRoute(`${prefix}/${path}`) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return routes;
|
|
109
|
+
};
|
|
110
|
+
/** A controller parameter takes any documented value (`/stores/42`); a documented parameter names only a parameter. */
|
|
111
|
+
const routeMatches = (route, method, segments) => route.method === method && route.segments.length === segments.length && route.segments.every((segment, index) => segment === ':' || segment === segments[index]);
|
|
112
|
+
const roleValues = (snapshot, schema, name) => {
|
|
113
|
+
const roles = new Set();
|
|
114
|
+
const text = snapshot.read(schema);
|
|
115
|
+
if (text === undefined)
|
|
116
|
+
return roles;
|
|
117
|
+
for (const [, block] of text.matchAll(new RegExp(`^enum ${name}\\s*\\{([\\s\\S]*?)^\\}`, 'gm'))) {
|
|
118
|
+
for (const [, role] of String(block).matchAll(/^[ \t]*([A-Z_]+)\s*$/gm))
|
|
119
|
+
roles.add(String(role));
|
|
120
|
+
}
|
|
121
|
+
return roles;
|
|
122
|
+
};
|
|
123
|
+
/** Job ids and job names in the workflow files; one that does not parse is its linter's to fail. */
|
|
124
|
+
const workflowJobs = (snapshot, workflows) => {
|
|
125
|
+
const jobs = new Set();
|
|
126
|
+
for (const text of contents(snapshot, workflows)) {
|
|
127
|
+
let workflow;
|
|
128
|
+
try {
|
|
129
|
+
workflow = load(text);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const all = isRecord(workflow) ? workflow['jobs'] : undefined;
|
|
135
|
+
if (!isRecord(all))
|
|
136
|
+
continue;
|
|
137
|
+
for (const [id, job] of Object.entries(all)) {
|
|
138
|
+
jobs.add(id);
|
|
139
|
+
if (isRecord(job) && typeof job['name'] === 'string')
|
|
140
|
+
jobs.add(job['name']);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return jobs;
|
|
144
|
+
};
|
|
145
|
+
/** A manifest's parsed JSON as an object, or undefined: a malformed manifest is another check's to fail. */
|
|
146
|
+
export const manifestObject = (text) => {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(text);
|
|
149
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const packageIndex = (snapshot, manifests, lockfile) => {
|
|
156
|
+
const names = new Set();
|
|
157
|
+
const versions = new Set();
|
|
158
|
+
for (const text of contents(snapshot, manifests)) {
|
|
159
|
+
const name = manifestObject(text)?.['name'];
|
|
160
|
+
if (typeof name === 'string')
|
|
161
|
+
names.add(name);
|
|
162
|
+
}
|
|
163
|
+
const lock = lockfile === undefined ? undefined : snapshot.read(lockfile);
|
|
164
|
+
for (const match of (lock ?? '').matchAll(/^ {2}'?((?:@[\w.-]+\/)?[\w.-]+)@(\d+\.\d+\.\d+[^:'(]*)/gm)) {
|
|
165
|
+
names.add(String(match[1]));
|
|
166
|
+
versions.add(`${String(match[1])}@${String(match[2])}`);
|
|
167
|
+
}
|
|
168
|
+
return { names, versions };
|
|
169
|
+
};
|
|
170
|
+
const BINARY = /\.(png|jpe?g|gif|webp|ico|icns|ttf|otf|woff2?|jar|keystore|zip|gz|tgz|pdf|mp4|mov|mp3|wav|tflite|bin|so|a|dylib|car|lockb)$/i;
|
|
171
|
+
/** Tracked text that can define a name: everything but binaries, the baseline and the config's `searchExclude`. */
|
|
172
|
+
export const isCode = (config, path) => !BINARY.test(path) && path !== config.baseline && !config.identifiers.searchExclude.some((p) => p.test(path));
|
|
173
|
+
/** Which of `words` occur as whole words in the snapshot's code. */
|
|
174
|
+
export const wordsInCode = (config, snapshot, words) => {
|
|
175
|
+
const wanted = new Set(words);
|
|
176
|
+
const found = new Set();
|
|
177
|
+
if (wanted.size === 0)
|
|
178
|
+
return found;
|
|
179
|
+
// One tokenising pass over the code, in process: `git grep -F -w -f` with
|
|
180
|
+
// thousands of patterns runs for minutes on a large tree.
|
|
181
|
+
for (const text of snapshot.readMany([...snapshot.files].filter((path) => isCode(config, path))).values()) {
|
|
182
|
+
for (const match of text.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
|
183
|
+
if (wanted.has(match[0])) {
|
|
184
|
+
found.add(match[0]);
|
|
185
|
+
if (found.size === wanted.size)
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
};
|
|
192
|
+
/** `\b` cannot bound a `$`: a word bounded by "no identifier character" on either side. */
|
|
193
|
+
export const boundedWord = (word) => new RegExp(`(?<![\\w$])${word.replace(/\$/g, '\\$')}(?![\\w$])`);
|
|
194
|
+
/** Runs DOC-DRIFT-04 over `files` in `snapshot`. */
|
|
195
|
+
export const findIdentifierProblems = (config, snapshot, files) => {
|
|
196
|
+
const { notIdentifiers, prisma, routes: routeConfig, jobs: jobConfig, packages: packageConfig } = config.identifiers;
|
|
197
|
+
const pending = [];
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
if (docKind(config, file) !== 'markdown')
|
|
200
|
+
continue;
|
|
201
|
+
const content = snapshot.read(file);
|
|
202
|
+
if (content === undefined)
|
|
203
|
+
continue;
|
|
204
|
+
for (const line of scanMarkdown(content, config.exemptMarker)) {
|
|
205
|
+
if (line.exempt || line.fence !== null || line.frontmatter)
|
|
206
|
+
continue;
|
|
207
|
+
const spans = codeSpans(line.text);
|
|
208
|
+
spans.forEach((span, index) => {
|
|
209
|
+
const ref = identifierRef(span.content, notIdentifiers);
|
|
210
|
+
if (ref === null)
|
|
211
|
+
return;
|
|
212
|
+
// A CI job is named as one: "the `build` job".
|
|
213
|
+
if (ref.kind === 'symbol' && /^\s+jobs?\b/.test(line.text.slice(span.end + 1))) {
|
|
214
|
+
pending.push({ file, line: line.number, ref: { kind: 'job', text: ref.text }, citedFile: undefined });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const next = spans[index + 1];
|
|
218
|
+
let citedFile;
|
|
219
|
+
if (next !== undefined && /^\s*(?:in|from)\s*\[?$/.test(line.text.slice(span.end + 1, next.start - 1))) {
|
|
220
|
+
const path = next.content.replace(/:\d+(?:-\d+)?$/, '');
|
|
221
|
+
citedFile = [posix.join(posix.dirname(file), path), path].map((p) => posix.normalize(p)).find((p) => snapshot.files.has(p));
|
|
222
|
+
}
|
|
223
|
+
pending.push({ file, line: line.number, ref, citedFile });
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const inCode = wordsInCode(config, snapshot, [...new Set(pending.flatMap((p) => (p.ref.word === undefined ? [] : [p.ref.word])))]);
|
|
228
|
+
let models;
|
|
229
|
+
let routes;
|
|
230
|
+
let roles;
|
|
231
|
+
let jobs;
|
|
232
|
+
let packages;
|
|
233
|
+
const problems = [];
|
|
234
|
+
const flag = (p, reason) => {
|
|
235
|
+
problems.push({ check: 'DOC-DRIFT-04', file: p.file, line: p.line, ref: p.ref.text, reason });
|
|
236
|
+
};
|
|
237
|
+
for (const p of pending) {
|
|
238
|
+
const { ref } = p;
|
|
239
|
+
switch (ref.kind) {
|
|
240
|
+
case 'symbol': {
|
|
241
|
+
const word = ref.word ?? ref.text;
|
|
242
|
+
if (p.citedFile !== undefined) {
|
|
243
|
+
const text = snapshot.read(p.citedFile);
|
|
244
|
+
if (text !== undefined && !boundedWord(word).test(text))
|
|
245
|
+
flag(p, `\`${word}\` does not occur in \`${p.citedFile}\`, the file the doc cites for it`);
|
|
246
|
+
}
|
|
247
|
+
else if (!inCode.has(word)) {
|
|
248
|
+
flag(p, `\`${word}\` occurs nowhere in the code — renamed, removed, or never written`);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'prisma-field': {
|
|
253
|
+
if (prisma === undefined)
|
|
254
|
+
break;
|
|
255
|
+
models ??= prismaModels(snapshot, prisma.schemas);
|
|
256
|
+
const [model = '', field = ''] = ref.text.split('.');
|
|
257
|
+
const found = models.get(model);
|
|
258
|
+
// Every `Capital.lower` reads as a column (`Object.keys`), so only a model the schema declares is judged.
|
|
259
|
+
if (found !== undefined && !found.fields.has(field))
|
|
260
|
+
flag(p, `Prisma model \`${model}\` has no field \`${field}\``);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case 'prisma-unique': {
|
|
264
|
+
if (prisma === undefined)
|
|
265
|
+
break;
|
|
266
|
+
models ??= prismaModels(snapshot, prisma.schemas);
|
|
267
|
+
const wanted = ref.text
|
|
268
|
+
.replace(/^@@unique\(\[|\]\)$/g, '')
|
|
269
|
+
.split(',')
|
|
270
|
+
.map((f) => f.trim())
|
|
271
|
+
.join(',');
|
|
272
|
+
if (![...models.values()].some((model) => model.uniques.includes(wanted)))
|
|
273
|
+
flag(p, `no Prisma model declares \`${ref.text}\``);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case 'migration': {
|
|
277
|
+
const dir = prisma?.migrations;
|
|
278
|
+
if (dir !== undefined && !snapshot.dirs.has(`${dir}/${ref.text}`))
|
|
279
|
+
flag(p, `\`${ref.text}\` is not a directory under ${dir}/`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'route': {
|
|
283
|
+
if (routeConfig === undefined)
|
|
284
|
+
break;
|
|
285
|
+
routes ??= controllerRoutes(snapshot, routeConfig.controllers);
|
|
286
|
+
const [, method = '', rawPath = ''] = ROUTE.exec(ref.text) ?? [];
|
|
287
|
+
const path = rawPath.replace(/[?#].*$/, '');
|
|
288
|
+
if (/[{][^}]*,/.test(path))
|
|
289
|
+
break; // a brace expansion lists several
|
|
290
|
+
const all = splitRoute(path);
|
|
291
|
+
const known = new Set(routes.map((route) => route.segments[0]));
|
|
292
|
+
// Clients and proxies prefix the served paths (`/api/v1`); the server does not.
|
|
293
|
+
const start = all.findIndex((segment) => known.has(segment) || !routeConfig.prefixes.test(segment));
|
|
294
|
+
const segments = all.slice(start === -1 ? all.length : start);
|
|
295
|
+
const head = segments[0];
|
|
296
|
+
if (head === undefined || !known.has(head))
|
|
297
|
+
break; // not this repository's
|
|
298
|
+
if (!routes.some((served) => routeMatches(served, method, segments)))
|
|
299
|
+
flag(p, `no controller serves \`${method} ${path}\``);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case 'roles': {
|
|
303
|
+
const rolesConfig = prisma?.roles;
|
|
304
|
+
if (rolesConfig === undefined)
|
|
305
|
+
break;
|
|
306
|
+
roles ??= roleValues(snapshot, rolesConfig.schema, rolesConfig.enum);
|
|
307
|
+
// No enum to read is a failure, not a pass: every role would go unchecked.
|
|
308
|
+
if (roles.size === 0) {
|
|
309
|
+
flag(p, `no \`${rolesConfig.enum}\` in the Prisma schema to check roles against`);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
for (const match of ref.text.replace(/^@Roles\(|\)$/g, '').matchAll(/(?:\w+\.)?([A-Z][A-Z_]+)/g)) {
|
|
313
|
+
const role = String(match[1]);
|
|
314
|
+
if (!roles.has(role))
|
|
315
|
+
flag(p, `\`${role}\` is not a value of ${rolesConfig.enum}`);
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'job': {
|
|
320
|
+
if (jobConfig === undefined)
|
|
321
|
+
break;
|
|
322
|
+
jobs ??= workflowJobs(snapshot, jobConfig.workflows);
|
|
323
|
+
if (!jobs.has(ref.text))
|
|
324
|
+
flag(p, `no workflow has a job \`${ref.text}\``);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case 'package': {
|
|
328
|
+
if (packageConfig === undefined)
|
|
329
|
+
break;
|
|
330
|
+
packages ??= packageIndex(snapshot, packageConfig.manifests, packageConfig.lockfile);
|
|
331
|
+
const scoped = SCOPED_PACKAGE.exec(ref.text);
|
|
332
|
+
const versioned = VERSIONED_PACKAGE.exec(ref.text);
|
|
333
|
+
const name = scoped?.[1] ?? versioned?.[1] ?? ref.text;
|
|
334
|
+
const version = scoped?.[2] ?? versioned?.[2];
|
|
335
|
+
if (!packages.names.has(name)) {
|
|
336
|
+
// Only the repository's own scopes are knowable; a third-party name the lockfile lacks may be installed elsewhere.
|
|
337
|
+
if (packageConfig.ownScopes.some((scope) => name.startsWith(scope)))
|
|
338
|
+
flag(p, `no workspace is named \`${name}\``);
|
|
339
|
+
}
|
|
340
|
+
else if (version !== undefined && !packages.versions.has(`${name}@${version}`)) {
|
|
341
|
+
flag(p, `the lockfile has no \`${name}@${version}\``);
|
|
342
|
+
}
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return problems;
|
|
348
|
+
};
|
|
@@ -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
|
+
};
|