@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.
- package/README.md +133 -2
- package/dist/cli.js +55 -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-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/shell-text.js +193 -0
- package/package.json +3 -2
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
// The doc-references engine's shared part: which snapshot of the repository a
|
|
2
|
+
// reference resolves against, how a Markdown line splits into prose, code
|
|
3
|
+
// spans and fences, which tracked files carry references, and the per-file
|
|
4
|
+
// baseline whose counts only fall.
|
|
5
|
+
//
|
|
6
|
+
// A reference resolves against the SNAPSHOT being committed or pushed, never
|
|
7
|
+
// the worktree: a doc naming a file that exists on disk but is not staged
|
|
8
|
+
// would pass a worktree check and land broken.
|
|
9
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
10
|
+
import { lstatSync, readFileSync, readlinkSync } from 'node:fs';
|
|
11
|
+
import { join, posix, resolve } from 'node:path';
|
|
12
|
+
import { gitEnv, PINNED } from "./git.js";
|
|
13
|
+
import { isRecord } from "./registry-ratchet.js";
|
|
14
|
+
/** The three checks: paths and links, commands and scripts, code identifiers. */
|
|
15
|
+
export const CHECK_IDS = ['DOC-DRIFT-01', 'DOC-DRIFT-02', 'DOC-DRIFT-04'];
|
|
16
|
+
/** Every git read, a whole-tree `cat-file --batch` included, fits in this. */
|
|
17
|
+
const MAX_BUFFER = 1 << 30;
|
|
18
|
+
const execGit = (root, args, input) => execFileSync('git', [...PINNED, ...args], {
|
|
19
|
+
cwd: root,
|
|
20
|
+
env: gitEnv(process.env),
|
|
21
|
+
maxBuffer: MAX_BUFFER,
|
|
22
|
+
...(input === undefined ? {} : { input }),
|
|
23
|
+
// Captured, not inherited: a failure's stderr travels on the error.
|
|
24
|
+
stdio: 'pipe',
|
|
25
|
+
});
|
|
26
|
+
/** Git's stdout as text; throws (stderr on the error) when git exits non-zero. */
|
|
27
|
+
export const git = (root, args) => execGit(root, args).toString('utf8');
|
|
28
|
+
/** Git's stdout, or undefined when git exits non-zero. */
|
|
29
|
+
export const gitOrUndefined = (root, args) => {
|
|
30
|
+
try {
|
|
31
|
+
return git(root, args);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const listFiles = (root, source) => (source.kind === 'ref' ? git(root, ['ls-tree', '-r', '-z', '--name-only', source.ref]) : git(root, ['ls-files', '-z'])).split('\0').filter(Boolean);
|
|
38
|
+
/** path → blob id for an index or ref snapshot (gitlinks excluded). */
|
|
39
|
+
const blobIds = (root, source) => {
|
|
40
|
+
const ids = new Map();
|
|
41
|
+
const output = source.kind === 'ref' ? git(root, ['ls-tree', '-r', '-z', source.ref]) : git(root, ['ls-files', '-s', '-z']);
|
|
42
|
+
for (const entry of output.split('\0')) {
|
|
43
|
+
const tab = entry.indexOf('\t');
|
|
44
|
+
if (tab === -1)
|
|
45
|
+
continue;
|
|
46
|
+
const meta = entry.slice(0, tab).split(' ');
|
|
47
|
+
if (meta[0] === '160000')
|
|
48
|
+
continue;
|
|
49
|
+
// ls-tree: mode type oid; ls-files -s: mode oid stage
|
|
50
|
+
ids.set(entry.slice(tab + 1), String(source.kind === 'ref' ? meta[2] : meta[1]));
|
|
51
|
+
}
|
|
52
|
+
return ids;
|
|
53
|
+
};
|
|
54
|
+
/** Contents of `oids` from one `git cat-file --batch`, in order. */
|
|
55
|
+
export const catFileBatch = (root, oids) => {
|
|
56
|
+
const buffer = execGit(root, ['cat-file', '--batch'], `${oids.join('\n')}\n`);
|
|
57
|
+
const out = [];
|
|
58
|
+
let offset = 0;
|
|
59
|
+
for (let i = 0; i < oids.length; i += 1) {
|
|
60
|
+
const headerEnd = buffer.indexOf(0x0a, offset);
|
|
61
|
+
const header = buffer.subarray(offset, headerEnd).toString('utf8');
|
|
62
|
+
const size = Number(header.split(' ')[2]);
|
|
63
|
+
if (!Number.isSafeInteger(size))
|
|
64
|
+
throw new Error(`git cat-file --batch: unexpected header "${header}"`);
|
|
65
|
+
out.push(buffer.subarray(headerEnd + 1, headerEnd + 1 + size).toString('utf8'));
|
|
66
|
+
offset = headerEnd + 1 + size + 1;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
export const createSnapshot = (root, source) => {
|
|
71
|
+
const files = new Set(listFiles(root, source));
|
|
72
|
+
const dirs = new Set(['']);
|
|
73
|
+
const byBasename = new Map();
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
let dir = posix.dirname(file);
|
|
76
|
+
while (dir !== '.' && !dirs.has(dir)) {
|
|
77
|
+
dirs.add(dir);
|
|
78
|
+
dir = posix.dirname(dir);
|
|
79
|
+
}
|
|
80
|
+
const base = posix.basename(file);
|
|
81
|
+
const list = byBasename.get(base);
|
|
82
|
+
if (list === undefined)
|
|
83
|
+
byBasename.set(base, [file]);
|
|
84
|
+
else
|
|
85
|
+
list.push(file);
|
|
86
|
+
}
|
|
87
|
+
// A tracked file the worktree holds unchanged is read from disk; only paths
|
|
88
|
+
// whose worktree copy differs from the snapshot go through git. A tracked
|
|
89
|
+
// symlink is read as its link text, which is what git stores.
|
|
90
|
+
let dirty;
|
|
91
|
+
const fromDisk = (path) => {
|
|
92
|
+
if (source.kind === 'worktree')
|
|
93
|
+
return true;
|
|
94
|
+
// Named, not defaulted: only the index compares with no revision.
|
|
95
|
+
dirty ??= new Set(git(root, ['diff', '--name-only', '-z', '--no-renames', '--no-ext-diff', ...(source.kind === 'index' ? [] : [source.ref]), '--']).split('\0').filter(Boolean));
|
|
96
|
+
return !dirty.has(path);
|
|
97
|
+
};
|
|
98
|
+
const diskRead = (path) => {
|
|
99
|
+
const full = join(root, path);
|
|
100
|
+
try {
|
|
101
|
+
return lstatSync(full).isSymbolicLink() ? readlinkSync(full) : readFileSync(full, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// An unreadable disk copy falls back to the git blob below.
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// path → content; undefined when a tracked path has nothing to read (a
|
|
109
|
+
// submodule entry, or a worktree file deleted after it was listed).
|
|
110
|
+
const cache = new Map();
|
|
111
|
+
let oids;
|
|
112
|
+
const blobId = (path) => (oids ??= blobIds(root, source)).get(path);
|
|
113
|
+
const readMany = (paths) => {
|
|
114
|
+
const viaGit = [];
|
|
115
|
+
for (const path of paths.filter((p) => files.has(p) && !cache.has(p))) {
|
|
116
|
+
const content = fromDisk(path) ? diskRead(path) : undefined;
|
|
117
|
+
// A worktree snapshot has no blob behind its files: absent is absent.
|
|
118
|
+
if (content === undefined && source.kind !== 'worktree')
|
|
119
|
+
viaGit.push(path);
|
|
120
|
+
else
|
|
121
|
+
cache.set(path, content);
|
|
122
|
+
}
|
|
123
|
+
const wanted = viaGit.flatMap((path) => {
|
|
124
|
+
const oid = blobId(path);
|
|
125
|
+
return oid === undefined ? [] : [{ path, oid }];
|
|
126
|
+
});
|
|
127
|
+
if (wanted.length !== 0) {
|
|
128
|
+
const texts = catFileBatch(root, wanted.map((w) => w.oid));
|
|
129
|
+
wanted.forEach((w, i) => cache.set(w.path, texts[i]));
|
|
130
|
+
}
|
|
131
|
+
const out = new Map();
|
|
132
|
+
for (const path of paths) {
|
|
133
|
+
const content = cache.get(path);
|
|
134
|
+
if (content !== undefined)
|
|
135
|
+
out.set(path, content);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
const read = (path) => readMany([path]).get(path);
|
|
140
|
+
let rules;
|
|
141
|
+
const ignored = (paths) => {
|
|
142
|
+
rules ??= ignoreRules(files, readMany);
|
|
143
|
+
const matcher = rules;
|
|
144
|
+
return new Set(paths.filter((path) => isIgnored(matcher, path)));
|
|
145
|
+
};
|
|
146
|
+
return { root, source, files, dirs, byBasename, read, readMany, ignored };
|
|
147
|
+
};
|
|
148
|
+
const ignoreRules = (files, readMany) => {
|
|
149
|
+
// Lexical order puts every directory before its descendants, so a nested
|
|
150
|
+
// file's rules come later and override.
|
|
151
|
+
const bases = [...files]
|
|
152
|
+
.filter((file) => /(?:^|\/)\.gitignore$/.test(file))
|
|
153
|
+
.map((file) => (file === '.gitignore' ? '' : posix.dirname(file)))
|
|
154
|
+
.sort();
|
|
155
|
+
const texts = readMany(bases.map((base) => (base === '' ? '.gitignore' : `${base}/.gitignore`)));
|
|
156
|
+
const rules = [];
|
|
157
|
+
for (const [source, text] of texts) {
|
|
158
|
+
const base = posix.dirname(source) === '.' ? '' : posix.dirname(source);
|
|
159
|
+
for (const raw of text.split('\n')) {
|
|
160
|
+
let line = raw.replace(/(?<!\\)\s+$/, '');
|
|
161
|
+
// Blank lines and `#` comments hold no rule.
|
|
162
|
+
if (!/^[^#]/.test(line))
|
|
163
|
+
continue;
|
|
164
|
+
const negated = line.startsWith('!');
|
|
165
|
+
if (negated)
|
|
166
|
+
line = line.slice(1);
|
|
167
|
+
const dirOnly = line.endsWith('/');
|
|
168
|
+
if (dirOnly)
|
|
169
|
+
line = line.slice(0, -1);
|
|
170
|
+
const anchored = line.includes('/');
|
|
171
|
+
if (line.startsWith('/'))
|
|
172
|
+
line = line.slice(1);
|
|
173
|
+
const body = globToRegExp(line.replace(/\\(.)/g, '$1')).source.slice(1, -1);
|
|
174
|
+
rules.push({ base, pattern: new RegExp(anchored ? `^${body}$` : `^(?:.*/)?${body}$`), negated, dirOnly });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return rules;
|
|
178
|
+
};
|
|
179
|
+
/** Whether `path` (a trailing `/` marks a directory) is ignored. */
|
|
180
|
+
const isIgnored = (rules, path) => {
|
|
181
|
+
const isDir = path.endsWith('/');
|
|
182
|
+
const segments = path.replace(/\/$/, '').split('/');
|
|
183
|
+
for (let depth = 1; depth <= segments.length; depth += 1) {
|
|
184
|
+
const prefix = segments.slice(0, depth).join('/');
|
|
185
|
+
const prefixIsDir = depth < segments.length || isDir;
|
|
186
|
+
let verdict = false;
|
|
187
|
+
for (const rule of rules) {
|
|
188
|
+
if (rule.dirOnly && !prefixIsDir)
|
|
189
|
+
continue;
|
|
190
|
+
if (rule.base !== '' && !prefix.startsWith(`${rule.base}/`))
|
|
191
|
+
continue;
|
|
192
|
+
if (rule.pattern.test(posix.relative(rule.base, prefix)))
|
|
193
|
+
verdict = !rule.negated;
|
|
194
|
+
}
|
|
195
|
+
// Git never looks inside an ignored directory, so nothing below it can be re-included.
|
|
196
|
+
if (verdict)
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
};
|
|
201
|
+
const any = (patterns, path) => patterns.some((p) => p.test(path));
|
|
202
|
+
/** What a tracked path is to the checks, by the config's `files`; null when it carries no references. */
|
|
203
|
+
export const docKind = (config, path) => {
|
|
204
|
+
const { files } = config;
|
|
205
|
+
if (any(files.exclude, path))
|
|
206
|
+
return null;
|
|
207
|
+
if (any(files.markdown, path))
|
|
208
|
+
return 'markdown';
|
|
209
|
+
if (any(files.html, path))
|
|
210
|
+
return 'html';
|
|
211
|
+
if (any(files.code, path))
|
|
212
|
+
return any(files.codeExclude, path) ? null : 'code';
|
|
213
|
+
return null;
|
|
214
|
+
};
|
|
215
|
+
/** Every tracked file a reference check reads. */
|
|
216
|
+
export const referenceFiles = (config, snapshot) => [...snapshot.files].filter((path) => docKind(config, path) !== null).sort();
|
|
217
|
+
/**
|
|
218
|
+
* A fence line: the marker, the info word, and the rest. CommonMark: a
|
|
219
|
+
* backtick fence's info string holds no backtick, and a closer carries
|
|
220
|
+
* nothing after its marker but spaces.
|
|
221
|
+
*/
|
|
222
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*(\S*)([\s\S]*)$/;
|
|
223
|
+
/** The first `<!--` in `text` outside every code span, or -1. */
|
|
224
|
+
const commentOpener = (text) => {
|
|
225
|
+
// A span's interior holds no backtick, so no `<!--` straddles its edge.
|
|
226
|
+
const masked = codeSpans(text).reduce((out, span) => out.slice(0, span.start) + ' '.repeat(span.end - span.start) + out.slice(span.end), text);
|
|
227
|
+
return masked.indexOf('<!--');
|
|
228
|
+
};
|
|
229
|
+
/** `rest` with its HTML comments cut out, and whether a comment is still open at its end. */
|
|
230
|
+
const stripComments = (rest, comment) => {
|
|
231
|
+
if (comment) {
|
|
232
|
+
const end = rest.indexOf('-->');
|
|
233
|
+
return end === -1 ? { text: '', comment: true } : stripComments(rest.slice(end + 3), false);
|
|
234
|
+
}
|
|
235
|
+
// Code spans first: a `<!--` quoted in a span opens no comment.
|
|
236
|
+
const start = commentOpener(rest);
|
|
237
|
+
if (start === -1)
|
|
238
|
+
return { text: rest, comment: false };
|
|
239
|
+
const after = stripComments(rest.slice(start + 4), true);
|
|
240
|
+
return { text: rest.slice(0, start) + after.text, comment: after.comment };
|
|
241
|
+
};
|
|
242
|
+
/** Every line of a Markdown document, placed: prose, fence, frontmatter, exempt. */
|
|
243
|
+
export const scanMarkdown = (content, exemptMarker) => {
|
|
244
|
+
const lines = content.split('\n');
|
|
245
|
+
const out = [];
|
|
246
|
+
let fence = null;
|
|
247
|
+
let blocks = 0;
|
|
248
|
+
let frontmatter = lines[0]?.trim() === '---';
|
|
249
|
+
let comment = false;
|
|
250
|
+
lines.forEach((raw, index) => {
|
|
251
|
+
const number = index + 1;
|
|
252
|
+
const exempt = exemptMarker.test(raw);
|
|
253
|
+
if (frontmatter) {
|
|
254
|
+
// Only an unindented `---` ends the YAML document.
|
|
255
|
+
if (index > 0 && raw.trimEnd() === '---') {
|
|
256
|
+
frontmatter = false;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (index > 0)
|
|
260
|
+
out.push({ number, text: raw, fence: null, block: null, frontmatter: true, exempt });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const opener = FENCE.exec(raw);
|
|
264
|
+
const marker = opener?.[1] ?? '';
|
|
265
|
+
const info = opener?.[2] ?? '';
|
|
266
|
+
if (fence !== null) {
|
|
267
|
+
if (opener !== null && marker[0] === fence.marker[0] && marker.length >= fence.marker.length && info === '') {
|
|
268
|
+
fence = null;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
out.push({ number, text: raw, fence: fence.info, block: fence.block, frontmatter: false, exempt });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// A fence line inside an HTML comment is part of the comment.
|
|
275
|
+
if (opener !== null && !comment && !(marker[0] === '`' && `${info}${opener[3] ?? ''}`.includes('`'))) {
|
|
276
|
+
blocks += 1;
|
|
277
|
+
fence = { marker, info: info.toLowerCase(), block: blocks };
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// HTML comments, possibly spanning lines, carry no references.
|
|
281
|
+
const stripped = stripComments(raw, comment);
|
|
282
|
+
comment = stripped.comment;
|
|
283
|
+
// Struck-through text records what used to be true.
|
|
284
|
+
out.push({ number, text: stripped.text.replace(/~~[^~]+~~/g, ''), fence: null, block: null, frontmatter: false, exempt });
|
|
285
|
+
});
|
|
286
|
+
return out;
|
|
287
|
+
};
|
|
288
|
+
/** An escaped ASCII punctuation character, or a whole backtick run. */
|
|
289
|
+
const ESCAPE_OR_RUN = /\\[!-/:-@[-`{-~]|`+/g;
|
|
290
|
+
/**
|
|
291
|
+
* Every opening backtick run in `text`, by CommonMark's rule: a span opens
|
|
292
|
+
* at a whole backtick run no backslash escapes and closes at the next run of
|
|
293
|
+
* the same length; escapes do not apply inside a span.
|
|
294
|
+
*/
|
|
295
|
+
export const backtickRuns = (text) => {
|
|
296
|
+
const runs = [];
|
|
297
|
+
const scan = new RegExp(ESCAPE_OR_RUN);
|
|
298
|
+
for (let match = scan.exec(text); match !== null; match = scan.exec(text)) {
|
|
299
|
+
if (match[0].startsWith('\\'))
|
|
300
|
+
continue;
|
|
301
|
+
const open = match.index;
|
|
302
|
+
const { length } = match[0];
|
|
303
|
+
const closer = new RegExp(`(?<!\`)\`{${length}}(?!\`)`, 'g');
|
|
304
|
+
closer.lastIndex = open + length;
|
|
305
|
+
const close = closer.exec(text);
|
|
306
|
+
runs.push({ open, length, close: close?.index ?? -1 });
|
|
307
|
+
// Past the closing run; a failed search resets closer to 0, so an unclosed run resumes right after itself.
|
|
308
|
+
scan.lastIndex = Math.max(scan.lastIndex, closer.lastIndex);
|
|
309
|
+
}
|
|
310
|
+
return runs;
|
|
311
|
+
};
|
|
312
|
+
/** Inline code spans that close: interior offsets and trimmed content. */
|
|
313
|
+
export const codeSpans = (text) => backtickRuns(text).flatMap((run) => {
|
|
314
|
+
if (run.close === -1)
|
|
315
|
+
return [];
|
|
316
|
+
const start = run.open + run.length;
|
|
317
|
+
return [{ start, end: run.close, delimiter: run.length, content: text.slice(start, run.close).trim() }];
|
|
318
|
+
});
|
|
319
|
+
/** Text with every code span, delimiter runs included, blanked to spaces, so offsets survive. */
|
|
320
|
+
export const proseOnly = (text, spans) => {
|
|
321
|
+
let out = text;
|
|
322
|
+
for (const span of spans) {
|
|
323
|
+
const from = span.start - span.delimiter;
|
|
324
|
+
const to = span.end + span.delimiter;
|
|
325
|
+
out = out.slice(0, from) + ' '.repeat(to - from) + out.slice(to);
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
};
|
|
329
|
+
/** Fence info strings that mark a block as shell; '' (a bare fence) among them. */
|
|
330
|
+
const SHELL_FENCES = new Set(['', 'bash', 'sh', 'shell', 'zsh', 'console', 'terminal']);
|
|
331
|
+
/** Whether a fence's info string marks its lines as shell commands. */
|
|
332
|
+
export const isShellFence = (info) => SHELL_FENCES.has(info);
|
|
333
|
+
/**
|
|
334
|
+
* Comment text of a source line, or null when the line has no comment. Only
|
|
335
|
+
* full-line comments and JSDoc bodies count: a `//` inside a string is far
|
|
336
|
+
* more often a URL than a remark.
|
|
337
|
+
*/
|
|
338
|
+
export const commentText = (hashComments, path, line) => {
|
|
339
|
+
const trimmed = line.trimStart();
|
|
340
|
+
if (hashComments.test(path)) {
|
|
341
|
+
if (trimmed.startsWith('#!'))
|
|
342
|
+
return null;
|
|
343
|
+
return trimmed.startsWith('#') ? trimmed.slice(1) : null;
|
|
344
|
+
}
|
|
345
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/*'))
|
|
346
|
+
return trimmed.slice(2);
|
|
347
|
+
if (trimmed.startsWith('*') && !trimmed.startsWith('*/'))
|
|
348
|
+
return trimmed.slice(1);
|
|
349
|
+
return null;
|
|
350
|
+
};
|
|
351
|
+
export const emptyBaseline = () => ({ version: 1, files: Object.create(null) });
|
|
352
|
+
/** The baseline at `where` (named in messages) from its text. */
|
|
353
|
+
export const parseBaseline = (text, where) => {
|
|
354
|
+
let raw;
|
|
355
|
+
try {
|
|
356
|
+
raw = JSON.parse(text);
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
throw new Error(`${where}: not JSON: ${e.message}`);
|
|
360
|
+
}
|
|
361
|
+
if (!isRecord(raw) || raw['version'] !== 1 || !isRecord(raw['files']) || Object.keys(raw).some((k) => k !== 'version' && k !== 'files')) {
|
|
362
|
+
throw new Error(`${where}: expected { "version": 1, "files": {} }`);
|
|
363
|
+
}
|
|
364
|
+
const files = emptyBaseline().files;
|
|
365
|
+
for (const [file, counts] of Object.entries(raw['files'])) {
|
|
366
|
+
if (!isRecord(counts))
|
|
367
|
+
throw new Error(`${where}: ${file} must map check ids to counts`);
|
|
368
|
+
const entry = {};
|
|
369
|
+
for (const [check, count] of Object.entries(counts)) {
|
|
370
|
+
if (!CHECK_IDS.includes(check))
|
|
371
|
+
throw new Error(`${where}: ${file} names unknown check ${check}`);
|
|
372
|
+
if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 1)
|
|
373
|
+
throw new Error(`${where}: ${file} ${check} must be a positive integer`);
|
|
374
|
+
entry[check] = count;
|
|
375
|
+
}
|
|
376
|
+
files[file] = entry;
|
|
377
|
+
}
|
|
378
|
+
return { version: 1, files };
|
|
379
|
+
};
|
|
380
|
+
export const serializeBaseline = (baseline) => {
|
|
381
|
+
const sorted = {};
|
|
382
|
+
for (const file of Object.keys(baseline.files).sort()) {
|
|
383
|
+
const counts = baseline.files[file] ?? {};
|
|
384
|
+
const entry = {};
|
|
385
|
+
for (const check of CHECK_IDS) {
|
|
386
|
+
const count = counts[check];
|
|
387
|
+
if (count !== undefined && count > 0)
|
|
388
|
+
entry[check] = count;
|
|
389
|
+
}
|
|
390
|
+
if (Object.keys(entry).length > 0)
|
|
391
|
+
sorted[file] = entry;
|
|
392
|
+
}
|
|
393
|
+
return `${JSON.stringify({ version: 1, files: sorted }, null, 2)}\n`;
|
|
394
|
+
};
|
|
395
|
+
export const countProblems = (problems) => {
|
|
396
|
+
const counts = new Map();
|
|
397
|
+
for (const problem of problems) {
|
|
398
|
+
const byCheck = counts.get(problem.file) ?? new Map();
|
|
399
|
+
counts.set(problem.file, byCheck);
|
|
400
|
+
byCheck.set(problem.check, (byCheck.get(problem.check) ?? 0) + 1);
|
|
401
|
+
}
|
|
402
|
+
return counts;
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Applies the baseline to problems found in `checked` files for `checks`. A
|
|
406
|
+
* file over its count fails with every problem it has (the new one cannot be
|
|
407
|
+
* told apart); a file under its count is slack the baseline must give up.
|
|
408
|
+
*/
|
|
409
|
+
export const applyBaseline = (problems, baseline, checked, checks) => {
|
|
410
|
+
const counts = countProblems(problems);
|
|
411
|
+
const failures = [];
|
|
412
|
+
const slack = [];
|
|
413
|
+
for (const file of checked) {
|
|
414
|
+
for (const check of checks) {
|
|
415
|
+
const allowed = baseline.files[file]?.[check] ?? 0;
|
|
416
|
+
const actual = counts.get(file)?.get(check) ?? 0;
|
|
417
|
+
if (actual > allowed)
|
|
418
|
+
failures.push(...problems.filter((p) => p.file === file && p.check === check));
|
|
419
|
+
else if (actual < allowed)
|
|
420
|
+
slack.push({ file, check, baseline: allowed, actual });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { failures, slack };
|
|
424
|
+
};
|
|
425
|
+
/** A baseline may only lose entries and lower counts: one message per entry added or raised since `previous`. */
|
|
426
|
+
export const baselineGrowth = (previous, next) => {
|
|
427
|
+
const growth = [];
|
|
428
|
+
for (const [file, counts] of Object.entries(next.files)) {
|
|
429
|
+
for (const check of CHECK_IDS) {
|
|
430
|
+
const now = counts[check] ?? 0;
|
|
431
|
+
const before = previous.files[file]?.[check] ?? 0;
|
|
432
|
+
if (now > before)
|
|
433
|
+
growth.push(`${file} ${check}: ${before} → ${now} (a baseline only falls; fix the reference instead)`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return growth;
|
|
437
|
+
};
|
|
438
|
+
// ── a merge in progress ─────────────────────────────────────────────────────
|
|
439
|
+
const names = (root, args) => git(root, args).split('\0').filter(Boolean);
|
|
440
|
+
/** Every commit MERGE_HEAD names, one per merged head of an octopus merge; empty outside a merge. */
|
|
441
|
+
export const mergeHeads = (root) => {
|
|
442
|
+
const status = spawnSync('git', [...PINNED, 'rev-parse', '-q', '--verify', 'MERGE_HEAD^{commit}'], { cwd: root, env: gitEnv(process.env), stdio: 'ignore' }).status;
|
|
443
|
+
if (status !== 0)
|
|
444
|
+
return [];
|
|
445
|
+
const file = git(root, ['rev-parse', '--git-path', 'MERGE_HEAD']).trim();
|
|
446
|
+
return readFileSync(resolve(root, file), 'utf8').split('\n').filter(Boolean);
|
|
447
|
+
};
|
|
448
|
+
/**
|
|
449
|
+
* Staged paths a merge took from one of its merged heads unchanged; empty
|
|
450
|
+
* outside a merge. A merge stages every file the other branch changed, and
|
|
451
|
+
* its gates judged them: a path is taken when it differs from HEAD and
|
|
452
|
+
* matches some merged head (the same blob, or absent from both).
|
|
453
|
+
*/
|
|
454
|
+
export const takenFromMerge = (root) => {
|
|
455
|
+
const heads = mergeHeads(root);
|
|
456
|
+
if (heads.length === 0)
|
|
457
|
+
return new Set();
|
|
458
|
+
const differsFromEach = heads.map((head) => new Set(names(root, ['diff', '--cached', '--name-only', '--no-renames', '-z', head])));
|
|
459
|
+
return new Set(names(root, ['diff', '--cached', '--name-only', '--no-renames', '-z', '--diff-filter=ADMT', 'HEAD']).filter((path) => differsFromEach.some((differs) => !differs.has(path))));
|
|
460
|
+
};
|
|
461
|
+
// ── small shared helpers ────────────────────────────────────────────────────
|
|
462
|
+
/** A glob character class: it closes at the first `]` after its first character, so `[]a]` holds `]` and `a`. */
|
|
463
|
+
const CLASS = /\[([\s\S][^\]]*)\]/y;
|
|
464
|
+
/** Glob → anchored RegExp over repo-relative paths (`**`, `*`, `?`, `[…]`, `{a,b}`). */
|
|
465
|
+
export const globToRegExp = (glob) => {
|
|
466
|
+
let out = '';
|
|
467
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
468
|
+
const char = glob.charAt(i);
|
|
469
|
+
if (char === '*') {
|
|
470
|
+
if (glob[i + 1] === '*') {
|
|
471
|
+
i += 1;
|
|
472
|
+
if (glob[i + 1] === '/') {
|
|
473
|
+
i += 1;
|
|
474
|
+
out += '(?:.*/)?';
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
out += '.*';
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
out += '[^/]*';
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (char === '?') {
|
|
485
|
+
out += '[^/]';
|
|
486
|
+
}
|
|
487
|
+
else if (char === '[') {
|
|
488
|
+
CLASS.lastIndex = i;
|
|
489
|
+
const match = CLASS.exec(glob);
|
|
490
|
+
if (match === null) {
|
|
491
|
+
out += '\\[';
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
// `]` and `\` are literal in a glob class; a RegExp class needs them escaped.
|
|
495
|
+
out += `[${String(match[1])
|
|
496
|
+
.replace(/^!/, '^')
|
|
497
|
+
.replace(/[\\\]]/g, '\\$&')}]`;
|
|
498
|
+
i = CLASS.lastIndex - 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else if (char === '{') {
|
|
502
|
+
const close = glob.indexOf('}', i);
|
|
503
|
+
if (close === -1) {
|
|
504
|
+
out += '\\{';
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
const options = glob
|
|
508
|
+
.slice(i + 1, close)
|
|
509
|
+
.split(',')
|
|
510
|
+
.map((option) => option.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
|
511
|
+
out += `(?:${options.join('|')})`;
|
|
512
|
+
i = close;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
out += char.replace(/[.+^$()|[\]\\]/g, '\\$&');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return new RegExp(`^${out}$`);
|
|
520
|
+
};
|
|
521
|
+
/** Repo-relative normalisation; null when the path escapes the root. */
|
|
522
|
+
export const normalizeRepoPath = (path) => {
|
|
523
|
+
const normalized = posix.normalize(path).replace(/\/$/, '');
|
|
524
|
+
if (normalized === '.')
|
|
525
|
+
return '';
|
|
526
|
+
// A repo path from a git snapshot: after normalize, it escapes exactly when its first component is `..`.
|
|
527
|
+
if (normalized.split('/')[0] === '..')
|
|
528
|
+
return null;
|
|
529
|
+
return normalized;
|
|
530
|
+
};
|
|
531
|
+
/** The directory of `file` and each of its ancestors, nearest first. */
|
|
532
|
+
export const ancestorDirs = (file) => {
|
|
533
|
+
const parts = file.split('/').slice(0, -1);
|
|
534
|
+
return [...parts.map((_, depth) => parts.slice(0, parts.length - depth).join('/')), ''];
|
|
535
|
+
};
|