@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,187 @@
|
|
|
1
|
+
// The commands a Markdown document shows (DOC-DRIFT-02): each simple command
|
|
2
|
+
// in a shell fence or an inline code span, read into the words bash would
|
|
3
|
+
// hand the program. doc-command-rules.ts judges them.
|
|
4
|
+
import { posix } from 'node:path';
|
|
5
|
+
import { codeSpans, isShellFence, scanMarkdown } from "./doc-references.js";
|
|
6
|
+
import { maskQuoted, stripComment } from "./shell-text.js";
|
|
7
|
+
/** A command word: what makes a code span or a bare fence line a command. */
|
|
8
|
+
const COMMAND_HEAD = /^(?:cd\s+\S+\s*&&\s*)?(?:[A-Z_][A-Z0-9_]*=\S*\s+)*(?:pnpm|npx|node|tsx|python3?|bash|sh)\s/;
|
|
9
|
+
/**
|
|
10
|
+
* In a fence declared as shell, a script path may itself be the command
|
|
11
|
+
* (`./scripts/x.ts --flag`). Elsewhere a lone path is a mention.
|
|
12
|
+
*/
|
|
13
|
+
const SCRIPT_HEAD = /^(?:[A-Z_][A-Z0-9_]*=\S*\s+)*\.{0,2}\/?[\w./-]+\.(?:ts|mjs|js|sh|py)(?:[\s;&|]|$)/;
|
|
14
|
+
/** Fence info strings that declare a block as shell (a bare fence only may be). */
|
|
15
|
+
const DECLARED_SHELL = new Set(['bash', 'sh', 'shell', 'zsh', 'console', 'terminal']);
|
|
16
|
+
/** The value of digits a pattern already matched in base `radix`. */
|
|
17
|
+
const digitsValue = (digits, radix) => {
|
|
18
|
+
let value = 0;
|
|
19
|
+
for (const digit of digits.toLowerCase())
|
|
20
|
+
value = value * radix + '0123456789abcdef'.indexOf(digit);
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
/** The named escapes of ANSI-C quoting. */
|
|
24
|
+
const ANSI_C_NAMED = { a: '\u0007', b: '\b', e: '\u001b', E: '\u001b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' };
|
|
25
|
+
/**
|
|
26
|
+
* The value of a `$'…'` body, as bash decodes it: the named escapes, octal
|
|
27
|
+
* `\N` to `\NNN` (low eight bits), `\xH`/`\xHH`, `\uHHHH`, `\UHHHHHHHH` and
|
|
28
|
+
* `\cX` (X's low five bits); an unknown escape keeps its backslash, and the
|
|
29
|
+
* value ends at its first NUL.
|
|
30
|
+
*/
|
|
31
|
+
const ansiC = (body) => String(body
|
|
32
|
+
.replace(/\\(?:([0-7]{1,3})|x([\da-fA-F]{1,2})|u([\da-fA-F]{1,4})|U([\da-fA-F]{1,8})|c([^])|[^])/g, (escape, octal, x, u, bigU, control) => {
|
|
33
|
+
if (octal !== undefined)
|
|
34
|
+
return String.fromCharCode(digitsValue(octal, 8) & 0xff);
|
|
35
|
+
const hex = x ?? u ?? bigU;
|
|
36
|
+
if (hex !== undefined) {
|
|
37
|
+
const code = digitsValue(hex, 16);
|
|
38
|
+
// Past Unicode bash writes no character; keep the escape.
|
|
39
|
+
return code > 0x10ffff ? escape : String.fromCodePoint(code);
|
|
40
|
+
}
|
|
41
|
+
if (control !== undefined)
|
|
42
|
+
return String.fromCharCode(control.charCodeAt(0) & 0x1f);
|
|
43
|
+
return ANSI_C_NAMED[escape.slice(1)] ?? escape;
|
|
44
|
+
})
|
|
45
|
+
.split('\0')[0]);
|
|
46
|
+
/**
|
|
47
|
+
* Shell words of one simple command, as bash hands them to the program. A
|
|
48
|
+
* word runs to unquoted whitespace and joins its parts (`--mode="a b"` is one
|
|
49
|
+
* word): single quotes keep every character, double quotes every character
|
|
50
|
+
* but a backslash before `$`, `` ` ``, `"` or `\`, and outside quotes a
|
|
51
|
+
* backslash keeps the character after it. `$'…'` is ANSI-C quoting, its
|
|
52
|
+
* escapes decoded, and `$"…"` a double-quoted string. A quote that never
|
|
53
|
+
* closes stays a character (bash refuses the line). Expansions, operators and
|
|
54
|
+
* line continuations are the callers' or out of reach.
|
|
55
|
+
*/
|
|
56
|
+
export const shellWords = (text) => Array.from(text.matchAll(/(?:\$'(?:[^'\\]|\\[^])*'|'[^']*'|\$?"(?:[^"\\]|\\[^])*"|\\[^]|[^\s'"\\]|['"\\])+/g), ([word]) => Array.from(word.matchAll(/\$'((?:[^'\\]|\\[^])*)'|'([^']*)'|\$?"((?:[^"\\]|\\[^])*)"|\\([^])|([^])/g), (part) => (part[1] === undefined ? undefined : ansiC(part[1])) ?? part[2] ?? part[3]?.replace(/\\([$`"\\])/g, '$1') ?? part[4] ?? String(part[5])).join(''));
|
|
57
|
+
/** Whether code ends in a backslash that escapes what follows: an odd run of them. */
|
|
58
|
+
const continues = (code) => /(?:^|[^\\])(?:\\\\)*\\$/.test(code);
|
|
59
|
+
const withoutCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
60
|
+
/** `code` without its outer blanks, but one a backslash escapes (`a\ `). */
|
|
61
|
+
const trimCode = (code) => {
|
|
62
|
+
const trimmed = code.trim();
|
|
63
|
+
if (!continues(trimmed))
|
|
64
|
+
return trimmed;
|
|
65
|
+
const end = code.length - code.trimStart().length + trimmed.length;
|
|
66
|
+
return `${trimmed}${code.charAt(end)}`;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The simple commands of one shell line, as written: split at `&&`, `||`, `;`
|
|
70
|
+
* and `|` outside quotes and escapes (found in the quote-masked line, which
|
|
71
|
+
* keeps every index).
|
|
72
|
+
*/
|
|
73
|
+
export const simpleCommands = (line) => {
|
|
74
|
+
const masked = maskQuoted(line);
|
|
75
|
+
const commands = [];
|
|
76
|
+
let start = 0;
|
|
77
|
+
for (const match of masked.matchAll(/&&|\|\||;|\|/g)) {
|
|
78
|
+
commands.push(trimCode(line.slice(start, match.index)));
|
|
79
|
+
start = match.index + match[0].length;
|
|
80
|
+
}
|
|
81
|
+
commands.push(trimCode(line.slice(start)));
|
|
82
|
+
return commands;
|
|
83
|
+
};
|
|
84
|
+
/** Words after any leading `NAME=value` environment assignments. */
|
|
85
|
+
export const withoutAssignments = (words) => {
|
|
86
|
+
const first = words.findIndex((word) => !/^[A-Z_][A-Z0-9_]*=/.test(word));
|
|
87
|
+
return first === -1 ? [] : words.slice(first);
|
|
88
|
+
};
|
|
89
|
+
/** The directory a `cd` command's words name: after bash's own options (`-L`, `-P`, `-e`, `-@`) and a `--`. `cd -` is no option. */
|
|
90
|
+
const cdOperand = (words) => {
|
|
91
|
+
let at = 1;
|
|
92
|
+
while (/^-[LPe@]+$/.test(String(words[at])))
|
|
93
|
+
at += 1;
|
|
94
|
+
if (words[at] === '--')
|
|
95
|
+
at += 1;
|
|
96
|
+
return words[at];
|
|
97
|
+
};
|
|
98
|
+
/** Options of npx (npm exec) whose value is the next word, `-c`/`--call` among them. */
|
|
99
|
+
export const NPX_VALUE_OPTIONS = new Set(['--package', '-p', '--cache', '--userconfig', '--shell', '--call', '-c', '--workspace', '-w']);
|
|
100
|
+
/**
|
|
101
|
+
* The shell command an `npx -c '…'` runs, if `words` are one: the value of
|
|
102
|
+
* `-c`/`--call` after npx's other options. npm refuses a call with words
|
|
103
|
+
* after it, so `npx -c tsx x.ts` runs nothing.
|
|
104
|
+
*/
|
|
105
|
+
export const npxCall = (words) => {
|
|
106
|
+
if (words[0] !== 'npx')
|
|
107
|
+
return undefined;
|
|
108
|
+
let at = 1;
|
|
109
|
+
for (let word = words[at]; word?.startsWith('-') === true; word = words[at]) {
|
|
110
|
+
const [name, inline] = word.split(/=(.*)/s);
|
|
111
|
+
if (name === '--call' || name === '-c') {
|
|
112
|
+
const call = inline ?? words[at + 1];
|
|
113
|
+
const next = at + (inline === undefined ? 2 : 1);
|
|
114
|
+
// npm reads an empty call as none: its words run.
|
|
115
|
+
return call === '' || next < words.length ? undefined : call;
|
|
116
|
+
}
|
|
117
|
+
at += NPX_VALUE_OPTIONS.has(word) ? 2 : 1;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
};
|
|
121
|
+
/** Every simple command shown in a Markdown document. */
|
|
122
|
+
export const documentedCommands = (file, content, exemptMarker) => {
|
|
123
|
+
const commands = [];
|
|
124
|
+
/** Reads `text` from directory `from`, recording its commands when `shown`; returns the directory its `cd`s leave the shell in. */
|
|
125
|
+
const read = (line, text, fenced, from, shown) => {
|
|
126
|
+
let cwd = from;
|
|
127
|
+
for (const part of simpleCommands(text)) {
|
|
128
|
+
const words = withoutAssignments(shellWords(part));
|
|
129
|
+
const dir = words[0] === 'cd' ? cdOperand(words) : undefined;
|
|
130
|
+
if (dir !== undefined) {
|
|
131
|
+
// An absolute operand replaces the directory.
|
|
132
|
+
cwd = posix.normalize(posix.isAbsolute(dir) ? dir : posix.join(cwd, dir)).replace(/^\.$/, '');
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
// `npx -c '…'` runs its call's commands, each one judged; npm runs the
|
|
136
|
+
// call in a shell of its own, so a `cd` in it moves only the call's later commands.
|
|
137
|
+
const call = npxCall(words);
|
|
138
|
+
if (call !== undefined) {
|
|
139
|
+
read(line, call, fenced, cwd, shown);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (shown && words.length > 0)
|
|
143
|
+
commands.push({ file, line, words, text: part, cwd, fenced });
|
|
144
|
+
}
|
|
145
|
+
return cwd;
|
|
146
|
+
};
|
|
147
|
+
const lines = scanMarkdown(content, exemptMarker);
|
|
148
|
+
// A fence's lines are one shell session: a `cd` on its own line moves the
|
|
149
|
+
// lines after it. The next fence starts from the root again.
|
|
150
|
+
let fenceCwd = '';
|
|
151
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
152
|
+
const line = lines[index];
|
|
153
|
+
if (line === undefined)
|
|
154
|
+
break;
|
|
155
|
+
if (line.fence === null) {
|
|
156
|
+
if (line.exempt || line.frontmatter)
|
|
157
|
+
continue;
|
|
158
|
+
// An inline span is a command of its own, run from the root.
|
|
159
|
+
for (const span of codeSpans(line.text)) {
|
|
160
|
+
if (COMMAND_HEAD.test(`${span.content} `))
|
|
161
|
+
read(line.number, span.content, false, '', true);
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (isShellFence(line.fence)) {
|
|
166
|
+
// A `#` outside quotes starts a comment, and a backslash inside one
|
|
167
|
+
// continues nothing, so the comment goes before a trailing backslash is
|
|
168
|
+
// read. That backslash joins the next line as bash joins it, and the
|
|
169
|
+
// joined text is read for a comment again. A CRLF line's CR is its line break.
|
|
170
|
+
let text = stripComment(withoutCr(line.text).replace(/^\s*\$\s+/, ''));
|
|
171
|
+
const start = line.number;
|
|
172
|
+
// Only a line of the same fence continues it.
|
|
173
|
+
for (let next = lines[index + 1]; continues(text) && next?.block === line.block; next = lines[index + 1]) {
|
|
174
|
+
index += 1;
|
|
175
|
+
text = stripComment(`${text.slice(0, -1)}${withoutCr(next.text)}`);
|
|
176
|
+
}
|
|
177
|
+
text = trimCode(text);
|
|
178
|
+
// Every line's `cd` counts, an exempt line's included (the shell still
|
|
179
|
+
// runs it); only a line that runs something, unexempt, is judged.
|
|
180
|
+
fenceCwd = read(start, text, true, fenceCwd, !line.exempt && (COMMAND_HEAD.test(`${text} `) || (DECLARED_SHELL.has(line.fence) && SCRIPT_HEAD.test(text))));
|
|
181
|
+
}
|
|
182
|
+
// The fence's last line: the next fence starts from the root.
|
|
183
|
+
if (lines[index + 1]?.block !== line.block)
|
|
184
|
+
fenceCwd = '';
|
|
185
|
+
}
|
|
186
|
+
return commands;
|
|
187
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// What a script's own source declares about its flags (DOC-DRIFT-02): whether
|
|
2
|
+
// it reads its own argv, which flags it knows, the values an enum-valued flag
|
|
3
|
+
// takes, its required flags and its first positional's choices.
|
|
4
|
+
// doc-command-rules.ts holds a documented command to them.
|
|
5
|
+
/** argparse actions that take no value: the flag alone is the input. */
|
|
6
|
+
const FLAG_ONLY_ACTIONS = ['store_true', 'store_false', 'store_const', 'append_const', 'count', 'help', 'version'];
|
|
7
|
+
/** The keyword arguments in an `add_argument` call's text, each value unquoted. A string is read whole, so `help="nargs=0"` is one argument. */
|
|
8
|
+
const keywordArguments = (text) => {
|
|
9
|
+
const out = new Map();
|
|
10
|
+
for (const match of text.matchAll(/"[^"]*"|'[^']*'|(\w+)\s*=\s*("[^"]*"|'[^']*'|[^,"']*)/g)) {
|
|
11
|
+
if (match[1] !== undefined)
|
|
12
|
+
out.set(match[1], String(match[2]).trim().replace(/^["']|["']$/g, ''));
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
};
|
|
16
|
+
/** Words an argparse option with these keyword arguments takes. */
|
|
17
|
+
const argparseValueCount = (kwargs) => {
|
|
18
|
+
const action = kwargs.get('action');
|
|
19
|
+
if (action !== undefined && (FLAG_ONLY_ACTIONS.includes(action) || action.endsWith('BooleanOptionalAction')))
|
|
20
|
+
return 0;
|
|
21
|
+
const nargs = kwargs.get('nargs');
|
|
22
|
+
if (nargs === undefined || nargs === '?')
|
|
23
|
+
return 1;
|
|
24
|
+
return /^\d+$/.test(nargs) ? Number(nargs) : Infinity;
|
|
25
|
+
};
|
|
26
|
+
const quotedValues = (text) => new Set([...text.matchAll(/["']([^"']+)["']/g)].map((m) => String(m[1])));
|
|
27
|
+
/** What a script's own source declares about its flags. */
|
|
28
|
+
export const flagSpec = (source, path) => {
|
|
29
|
+
const shell = /\.(sh|bash)$/.test(path) || !/\.\w+$/.test(path);
|
|
30
|
+
const python = path.endsWith('.py');
|
|
31
|
+
// A script that hands its arguments on cannot be judged from its own text.
|
|
32
|
+
// Nor can a shell script that sources a file: the file runs with the
|
|
33
|
+
// script's own arguments, so its flags may be read there.
|
|
34
|
+
const forwards = shell
|
|
35
|
+
? /"\$@"|\$\*|"\$\{@\}"|\$@|^\s*(?:source|\.)\s/m.test(source)
|
|
36
|
+
: python
|
|
37
|
+
? /parse_known_args|sys\.argv\[1:\]\s*\)/.test(source) && !/add_argument/.test(source)
|
|
38
|
+
: /strict:\s*false/.test(source);
|
|
39
|
+
const values = (flag) => {
|
|
40
|
+
const usage = new RegExp(`${flag}[ =]<([\\w.:-]+(?:\\|[\\w.:-]+)+)>`).exec(source);
|
|
41
|
+
if (usage !== null)
|
|
42
|
+
return new Set(String(usage[1]).split('|'));
|
|
43
|
+
const braces = new RegExp(`${flag}[ =]\\{([\\w.:-]+(?:,[\\w.:-]+)+)\\}`).exec(source);
|
|
44
|
+
if (braces !== null)
|
|
45
|
+
return new Set(String(braces[1]).split(','));
|
|
46
|
+
const choices = new RegExp(`add_argument\\(\\s*["']${flag}["'][^)]*?choices\\s*=\\s*[[(]([^\\])]*)[\\])]`).exec(source);
|
|
47
|
+
return choices === null ? undefined : quotedValues(String(choices[1]));
|
|
48
|
+
};
|
|
49
|
+
// With subparsers, an argument a subcommand's parser adds (`x =
|
|
50
|
+
// subs.add_parser(…)`, or chained on one) is that subcommand's: not
|
|
51
|
+
// required of every invocation, and not the first word.
|
|
52
|
+
const subcommands = /add_subparsers/.test(source);
|
|
53
|
+
const local = new Set([...source.matchAll(/(\w+)\s*=\s*[\w.]+\.add_parser\(/g)].map((m) => String(m[1])));
|
|
54
|
+
const calls = python
|
|
55
|
+
? [...source.matchAll(/(?:(\w+)|\))\.add_argument\(\s*["']([\w-]+)["']((?:\s*,\s*["'][\w-]+["'])*)([^)]*)/g)]
|
|
56
|
+
.filter((match) => !subcommands || (match[1] !== undefined && !local.has(match[1])))
|
|
57
|
+
.map((match) => ({
|
|
58
|
+
name: String(match[2]),
|
|
59
|
+
// Every option string: `add_argument("-o", "--out")` is both.
|
|
60
|
+
aliases: [String(match[2]), ...Array.from(String(match[3]).matchAll(/["']([\w-]+)["']/g), (m) => String(m[1]))],
|
|
61
|
+
rest: String(match[4]),
|
|
62
|
+
}))
|
|
63
|
+
: [];
|
|
64
|
+
const required = calls.filter((call) => call.name.startsWith('--') && /required\s*=\s*True/.test(call.rest)).map((call) => call.name);
|
|
65
|
+
// The first positional's own choices. A call's text ends at its first `)`, so a tuple of choices runs to the end of it.
|
|
66
|
+
const first = calls.find((call) => !call.name.startsWith('-'));
|
|
67
|
+
const choices = first === undefined ? null : /choices\s*=\s*[[(]([^\])]*)/.exec(first.rest);
|
|
68
|
+
return {
|
|
69
|
+
closed: !forwards,
|
|
70
|
+
known: (flag) => {
|
|
71
|
+
if (flag === '--help' || flag === '-h')
|
|
72
|
+
return true;
|
|
73
|
+
if (new RegExp(`${flag}(?![\\w-])`).test(source))
|
|
74
|
+
return true;
|
|
75
|
+
// node:util parseArgs declares `name: { type: … }` without dashes.
|
|
76
|
+
const key = flag.replace(/^--/, '');
|
|
77
|
+
return new RegExp(`(?:^|[\\s{,])['"]?${key}['"]?\\s*:\\s*\\{[^}]*\\btype\\s*:`).test(source);
|
|
78
|
+
},
|
|
79
|
+
values,
|
|
80
|
+
required,
|
|
81
|
+
valueCount: (flag) => {
|
|
82
|
+
const call = calls.find((each) => each.aliases.includes(flag));
|
|
83
|
+
return call === undefined ? 0 : argparseValueCount(keywordArguments(call.rest));
|
|
84
|
+
},
|
|
85
|
+
positional: choices === null ? undefined : quotedValues(String(choices[1])),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -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
|
+
};
|