@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.
@@ -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,74 @@
1
+ // The documents absolute-paths and non-synced-resources read, and the config
2
+ // both take. A product's hook hands them its own list (`--files-from`); with
3
+ // none, they read every file git tracks.
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { entries } from "./coverage-by-type.js";
7
+ import { gitAt, must } from "./git.js";
8
+ /** Markdown and MDX. */
9
+ export const DEFAULT_INCLUDE = '\\.(md|mdx)$';
10
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ /**
12
+ * `{ "include"?: "<regex>", "guidance"?: ["<line>", …] }`; absent keys take
13
+ * the defaults. Throws on anything else, so a mistyped key never passes as
14
+ * the default.
15
+ */
16
+ export const parseDocConfig = (text, defaultGuidance) => {
17
+ if (text === undefined)
18
+ return { include: new RegExp(DEFAULT_INCLUDE), guidance: defaultGuidance };
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(text);
22
+ }
23
+ catch (e) {
24
+ throw new Error(`not JSON: ${e.message}`);
25
+ }
26
+ if (!isRecord(raw))
27
+ throw new Error('the config must be a JSON object');
28
+ const unknown = Object.keys(raw).find((k) => k !== 'include' && k !== 'guidance');
29
+ if (unknown !== undefined)
30
+ throw new Error(`unknown key "${unknown}"`);
31
+ // Absent takes the default; a present `null` is refused like any other wrong value.
32
+ const include = 'include' in raw ? raw['include'] : DEFAULT_INCLUDE;
33
+ if (typeof include !== 'string' || include === '')
34
+ throw new Error('"include" must be a non-empty regex string');
35
+ let pattern;
36
+ try {
37
+ pattern = new RegExp(include);
38
+ }
39
+ catch (e) {
40
+ throw new Error(`"include" is not a regex: ${e.message}`);
41
+ }
42
+ const guidance = 'guidance' in raw ? raw['guidance'] : defaultGuidance;
43
+ if (!Array.isArray(guidance) || guidance.some((line) => typeof line !== 'string'))
44
+ throw new Error('"guidance" must be a list of lines');
45
+ return { include: pattern, guidance: guidance };
46
+ };
47
+ /** Whether `path` (under `base` when relative) names a regular file, a symlink to one included. */
48
+ export const isFileAt = (base, path) => {
49
+ try {
50
+ return statSync(resolve(base, path)).isFile();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ /**
57
+ * The documents to check, as listed: each entry of `listed` that names a
58
+ * regular file under `base` and matches `include`, in order; a named path
59
+ * that is not a file is dropped. With no list, every file git tracks under
60
+ * `base`, as git names it.
61
+ */
62
+ export const selectDocs = (base, listed, include) => {
63
+ const paths = listed === undefined ? must(gitAt(base), ['ls-files', '-z']).split('\0').filter(Boolean) : entries(listed);
64
+ return paths.filter((path) => isFileAt(base, path) && include.test(path));
65
+ };
66
+ /** A document's lines as a shell `read` loop sees them: split on newlines, a last line without one included. */
67
+ export const linesOf = (text) => {
68
+ const lines = text.split('\n');
69
+ if (lines.at(-1) === '')
70
+ lines.pop();
71
+ return lines;
72
+ };
73
+ /** A listed document's text. */
74
+ export const readDoc = (base, path) => readFileSync(resolve(base, path), 'utf8');
@@ -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
+ };