@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/dist/cli.js CHANGED
@@ -4,11 +4,20 @@ import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
5
  import { join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { checkAbsolutePaths, DEFAULT_GUIDANCE as ABSOLUTE_PATHS_GUIDANCE } from "./absolute-paths.js";
8
+ import { checkGates, DEFAULT_GATE_CONFIG, parseGateConfig as parseAggregateConfig } from "./aggregate-gate-needs.js";
9
+ import { parseDocConfig, readDoc, selectDocs } from "./doc-files.js";
10
+ import { checkNonSyncedResources, DEFAULT_GUIDANCE as NON_SYNCED_GUIDANCE } from "./non-synced-resources.js";
11
+ import { checkPnpmSettings, DEFAULT_PNPM_CONFIG, formatParity, parsePnpmConfig } from "./pnpm-settings.js";
7
12
  import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
8
13
  import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
9
14
  import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
10
15
  import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
11
16
  import { checkBudgets } from "./doc-budgets.js";
17
+ import { lycheeRunner } from "./doc-links.js";
18
+ import { parseConfig as parseDocReferencesConfig } from "./doc-references-config.js";
19
+ import { formatReport as formatDocReferences, runGate } from "./doc-references-gate.js";
20
+ import { CHECK_IDS } from "./doc-references.js";
12
21
  import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
13
22
  import { loadTypescript, typescriptLoaders } from "./typescript.js";
14
23
  import { checkExportDeletions, formatReport } from "./export-deletions.js";
@@ -21,15 +30,20 @@ import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfi
21
30
  export const DENY_FILE = '.veda/deny.sha256';
22
31
  const USAGE = [
23
32
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
33
+ ' pariksha absolute-paths [--config <file>] [--files-from <file>|-] [--root <dir>]',
34
+ ' pariksha aggregate-gate-needs [--config <file>] [--root <dir>]',
24
35
  ' pariksha boundary [--root <dir>] [--deny <file>]',
25
36
  ' pariksha codec-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
26
37
  ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
27
38
  ' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
28
39
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
40
+ ' pariksha doc-references --config <file> (--staged | --base <ref> [--head <ref>] | --all [--update-baseline | --write-baseline]) [--checks <ids>] [--root <dir>]',
29
41
  ' pariksha enforcement-claims --config <file> [--root <dir>]',
30
42
  ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
31
43
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
32
44
  ' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
45
+ ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
46
+ ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
33
47
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
34
48
  ' pariksha hash <word>',
35
49
  ' pariksha --version',
@@ -76,6 +90,68 @@ export const flags = (rest, allowed, switches = []) => {
76
90
  }
77
91
  return out;
78
92
  };
93
+ /**
94
+ * absolute-paths and non-synced-resources: the documents `--files-from`
95
+ * lists (a file, or `-` for stdin), or every tracked one, each path read
96
+ * from `--root`; the report goes to stdout, as the shell checks printed it.
97
+ */
98
+ const docCheck = (name, defaultGuidance, check) => (f, io) => {
99
+ const P = `pariksha ${name}:`;
100
+ const base = resolve(f.get('--root') ?? '.');
101
+ try {
102
+ const configPath = f.get('--config');
103
+ const config = parseDocConfig(configPath === undefined ? undefined : readFileSync(resolve(base, configPath), 'utf8'), defaultGuidance);
104
+ const from = f.get('--files-from');
105
+ const listed = from === undefined ? undefined : from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8');
106
+ const docs = selectDocs(base, listed, config.include).map((file) => ({ file, text: () => readDoc(base, file) }));
107
+ const result = check(docs, base, config);
108
+ result.out.forEach(io.out);
109
+ return result.ok ? 0 : 1;
110
+ }
111
+ catch (e) {
112
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
113
+ return 2;
114
+ }
115
+ };
116
+ const absolutePaths = docCheck('absolute-paths', ABSOLUTE_PATHS_GUIDANCE, (docs, _base, config) => checkAbsolutePaths(docs, config));
117
+ const nonSyncedResources = docCheck('non-synced-resources', NON_SYNCED_GUIDANCE, (docs, base, config) => checkNonSyncedResources(docs, () => {
118
+ const top = topOf(base);
119
+ if (top === undefined)
120
+ throw new Error(`${base} is not in a git repository`);
121
+ return top;
122
+ }, config));
123
+ const aggregateGateNeeds = (f, io) => {
124
+ const root = resolve(f.get('--root') ?? '.');
125
+ const configPath = f.get('--config');
126
+ let config = DEFAULT_GATE_CONFIG;
127
+ try {
128
+ if (configPath !== undefined)
129
+ config = parseAggregateConfig(readFileSync(resolve(root, configPath), 'utf8'));
130
+ }
131
+ catch (e) {
132
+ io.err(`pariksha aggregate-gate-needs: ${e.message}; nothing was checked, so this is not a pass`);
133
+ return 2;
134
+ }
135
+ const result = checkGates(root, config);
136
+ result.out.forEach(io.out);
137
+ result.err.forEach(io.err);
138
+ return result.ok ? 0 : 1;
139
+ };
140
+ const pnpmSettings = (f, io) => {
141
+ const root = resolve(f.get('--root') ?? '.');
142
+ const configPath = f.get('--config');
143
+ try {
144
+ const config = configPath === undefined ? DEFAULT_PNPM_CONFIG : parsePnpmConfig(readFileSync(resolve(root, configPath), 'utf8'));
145
+ const result = formatParity(checkPnpmSettings(root, config, (io.now ?? Date.now)()), config.id);
146
+ result.err.forEach(io.err);
147
+ result.out.forEach(io.out);
148
+ return result.ok ? 0 : 1;
149
+ }
150
+ catch (e) {
151
+ io.err(`pariksha pnpm-settings: ${e.message}; nothing was checked, so this is not a pass`);
152
+ return 2;
153
+ }
154
+ };
79
155
  const boundary = (f, io) => {
80
156
  const root = resolve(f.get('--root') ?? '.');
81
157
  const denyPath = resolve(root, f.get('--deny') ?? DENY_FILE);
@@ -129,6 +205,55 @@ const docBudgets = (f, io) => {
129
205
  outcome.err.forEach(io.err);
130
206
  return outcome.ok ? 0 : 1;
131
207
  };
208
+ /** `--checks 01,DOC-DRIFT-04` as check ids, or every id that is not one. */
209
+ const checkList = (value) => {
210
+ const ids = value.split(',').map((id) => (id.startsWith('DOC-DRIFT-') ? id : `DOC-DRIFT-${id}`));
211
+ const unknown = ids.filter((id) => !CHECK_IDS.includes(id));
212
+ if (unknown.length === 0)
213
+ return ids;
214
+ return { unknown: `${unknown.join(', ')} ${unknown.length === 1 ? 'is not one' : 'are not'} of` };
215
+ };
216
+ const docReferences = (f, io) => {
217
+ const P = 'pariksha doc-references:';
218
+ const configPath = f.get('--config');
219
+ const base = f.get('--base');
220
+ const all = f.has('--all');
221
+ const baseline = f.has('--update-baseline') ? 'update' : f.has('--write-baseline') ? 'write' : undefined;
222
+ const modes = [f.has('--staged'), all, base !== undefined].filter(Boolean).length;
223
+ if (configPath === undefined || modes !== 1 || (f.has('--update-baseline') && f.has('--write-baseline')) || (baseline !== undefined && !all) || (f.has('--head') && base === undefined)) {
224
+ io.err(USAGE);
225
+ return 2;
226
+ }
227
+ const asked = f.get('--checks');
228
+ const listed = asked === undefined ? undefined : checkList(asked);
229
+ if (listed !== undefined && !Array.isArray(listed)) {
230
+ io.err(`${P} --checks: ${listed.unknown} ${CHECK_IDS.join(', ')}`);
231
+ return 2;
232
+ }
233
+ const dir = resolve(f.get('--root') ?? '.');
234
+ const root = topOf(dir);
235
+ if (root === undefined) {
236
+ io.err(`${P} ${dir} is not in a git repository`);
237
+ return 2;
238
+ }
239
+ const mode = base !== undefined ? { kind: 'range', base, head: f.get('--head') ?? 'HEAD' } : all ? { kind: 'all' } : { kind: 'staged' };
240
+ // The identifier search reads the whole tree: a push and CI, not a commit.
241
+ const checks = listed ?? (mode.kind === 'staged' ? ['DOC-DRIFT-01', 'DOC-DRIFT-02'] : [...CHECK_IDS]);
242
+ let config;
243
+ let report;
244
+ try {
245
+ config = parseDocReferencesConfig(readFileSync(resolve(root, configPath), 'utf8'));
246
+ report = runGate({ root, config, mode, checks, baseline, links: io.links ?? lycheeRunner() });
247
+ }
248
+ catch (e) {
249
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
250
+ return 2;
251
+ }
252
+ const result = formatDocReferences(report, { config, mode, checks }, configPath);
253
+ result.out.forEach(io.out);
254
+ result.err.forEach(io.err);
255
+ return result.ok ? 0 : 1;
256
+ };
132
257
  const enforcementClaims = async (f, io) => {
133
258
  const configPath = f.get('--config');
134
259
  if (configPath === undefined) {
@@ -499,10 +624,15 @@ const COMMANDS = {
499
624
  'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
500
625
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
501
626
  'fault-injection-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: faultInjectionRegistry },
627
+ 'absolute-paths': { allowed: ['--config', '--files-from', '--root'], run: absolutePaths },
628
+ 'aggregate-gate-needs': { allowed: ['--config', '--root'], run: aggregateGateNeeds },
629
+ 'non-synced-resources': { allowed: ['--config', '--files-from', '--root'], run: nonSyncedResources },
630
+ 'pnpm-settings': { allowed: ['--config', '--root'], run: pnpmSettings },
502
631
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
503
632
  boundary: { allowed: ['--root', '--deny'], run: boundary },
504
633
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
505
634
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
635
+ 'doc-references': { allowed: ['--config', '--base', '--head', '--checks', '--root'], switches: ['--staged', '--all', '--update-baseline', '--write-baseline'], run: docReferences },
506
636
  'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
507
637
  'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
508
638
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
@@ -0,0 +1,179 @@
1
+ // DOC-DRIFT-02, the command as written runs: `--filter` before the script,
2
+ // every flag known to the script it reaches, required flags present,
3
+ // enum-valued flags given a value the script accepts, and (when the config
4
+ // names a runner) a `.ts` file started through one.
5
+ //
6
+ // doc-commands.ts reads the commands, doc-command-targets.ts finds the script
7
+ // each runs, and doc-flag-spec.ts reads what that script declares.
8
+ import { posix } from 'node:path';
9
+ import { interpreterTarget, INTERPRETERS, nestedCommands, ownArgs, parsePnpm, scriptIndex, scriptTarget } from "./doc-command-targets.js";
10
+ import { documentedCommands } from "./doc-commands.js";
11
+ import { flagSpec } from "./doc-flag-spec.js";
12
+ /** Text of a script plus the relative modules it imports directly. */
13
+ const sourceWithImports = (context, path) => {
14
+ const own = context.read(path) ?? '';
15
+ const parts = [own];
16
+ // Only a JS/TS module's imports are its own code.
17
+ if (!/\.[cm]?[jt]sx?$/.test(path))
18
+ return own;
19
+ for (const match of own.matchAll(/from\s+['"](\.{1,2}\/[^'"]+)['"]/g)) {
20
+ const base = posix.join(posix.dirname(path), String(match[1]));
21
+ const imported = [base, `${base}.ts`, `${base}.mjs`, `${base}.js`, base.replace(/\.js$/, '.ts')].map((candidate) => context.read(candidate)).find((text) => text !== undefined);
22
+ if (imported !== undefined)
23
+ parts.push(imported);
24
+ }
25
+ return parts.join('\n');
26
+ };
27
+ const LOADER_OPTIONS = new Set(['--import', '--loader', '--experimental-loader', '--require', '-r']);
28
+ const STRIP_OPTIONS = new Set(['--experimental-strip-types', '--experimental-transform-types']);
29
+ /**
30
+ * The `.ts` script an interpreter starts without a TypeScript loader, if it
31
+ * starts one. The script is the first word after the interpreter's options.
32
+ * Only node takes a loader, and only a tsx one (`--import tsx`,
33
+ * `--loader=tsx/esm`, `-r tsx/cjs`) or a type-stripping flag before the
34
+ * script runs TypeScript; bash and python run the file as their own language.
35
+ */
36
+ const typeScriptStartedWithout = (words) => {
37
+ if (!['node', 'bash', 'sh', 'python', 'python3'].includes(String(words[0])))
38
+ return undefined;
39
+ const at = scriptIndex(words, 0);
40
+ const script = words[at];
41
+ if (script === undefined || !script.endsWith('.ts'))
42
+ return undefined;
43
+ if (words[0] !== 'node')
44
+ return script;
45
+ const options = words.slice(1, at);
46
+ const loader = options.some((word, index) => {
47
+ const [name = '', inline] = word.split(/=(.*)/s);
48
+ return STRIP_OPTIONS.has(name) || (LOADER_OPTIONS.has(name) && /^tsx(?:\/|$)/.test(String(inline ?? options[index + 1])));
49
+ });
50
+ return loader ? undefined : script;
51
+ };
52
+ /**
53
+ * The first positional word of `args`: options skipped, and the values the
54
+ * spec says each takes (`--out result destroy` names `destroy`). Values end
55
+ * at the next option.
56
+ */
57
+ const firstPositional = (args, spec) => {
58
+ for (let index = 0; index < args.length; index += 1) {
59
+ const word = String(args[index]);
60
+ if (!word.startsWith('-'))
61
+ return word;
62
+ for (let count = spec.valueCount(word); count > 0 && args[index + 1]?.startsWith('-') === false; count -= 1)
63
+ index += 1;
64
+ }
65
+ return undefined;
66
+ };
67
+ const FLAG = /^--[\w-]+(=|$)/;
68
+ /** Runs the DOC-DRIFT-02 command rules over the commands in `files`. */
69
+ export const findCommandProblems = (config, context, files) => {
70
+ const problems = [];
71
+ const runner = config.commands.typescriptRunner;
72
+ const nested = nestedCommands(config.commands.nested);
73
+ const specs = new Map();
74
+ const specFor = (path) => {
75
+ const cached = specs.get(path);
76
+ if (cached !== undefined)
77
+ return cached;
78
+ const spec = flagSpec(sourceWithImports(context, path), path);
79
+ specs.set(path, spec);
80
+ return spec;
81
+ };
82
+ // join normalizes, and never leaves a leading `./`.
83
+ const resolveFrom = (dir, path) => {
84
+ const candidate = posix.join(dir, path);
85
+ return context.exists(candidate) ? candidate : undefined;
86
+ };
87
+ for (const file of files) {
88
+ const content = context.read(file);
89
+ if (content === undefined)
90
+ continue;
91
+ for (const command of documentedCommands(file, content, config.exemptMarker)) {
92
+ const { words } = command;
93
+ const head = String(words[0]);
94
+ const report = (script, reason) => {
95
+ problems.push({ file, line: command.line, script, reason });
96
+ };
97
+ if (runner !== undefined) {
98
+ // A `.ts` file started by anything but the runner.
99
+ if (head.endsWith('.ts')) {
100
+ report(head, `\`${head}\` is TypeScript run as a program — start it with \`${runner} ${head}\``);
101
+ continue;
102
+ }
103
+ const script = typeScriptStartedWithout(words);
104
+ if (script !== undefined) {
105
+ report(script, `\`${head} ${script}\` does not run TypeScript here — use \`${runner} ${script}\``);
106
+ continue;
107
+ }
108
+ }
109
+ let target;
110
+ if (head === 'pnpm') {
111
+ const pnpm = parsePnpm(words);
112
+ if (pnpm.exec !== undefined) {
113
+ target = interpreterTarget(['exec', ...pnpm.exec], (path) => resolveFrom(posix.join(command.cwd, pnpm.dir ?? ''), path) ?? resolveFrom('', path));
114
+ }
115
+ else if (pnpm.script !== undefined) {
116
+ const scope = pnpm.filter ?? (command.cwd === '' ? '.' : command.cwd);
117
+ const body = context.scripts.get(scope)?.get(pnpm.script);
118
+ const dir = context.scopeDirs.get(scope);
119
+ // A package script's own options come first; `--filter` among them
120
+ // reaches the script, not pnpm. After a positional word it belongs
121
+ // to a nested command, and a bin such as turbo has a --filter of its own.
122
+ const misplaced = pnpm.args.findIndex((word) => /^(--filter|-F)(=|$)/.test(word));
123
+ const before = pnpm.args.slice(0, misplaced);
124
+ if (body !== undefined && misplaced !== -1 && before.every((word) => word.startsWith('-')) && !before.includes('--')) {
125
+ report(pnpm.script, `\`${String(pnpm.args[misplaced])}\` after \`${pnpm.script}\` is handed to the script, not to pnpm — write \`pnpm --filter <pkg> ${pnpm.script}\``);
126
+ continue;
127
+ }
128
+ if (body !== undefined && dir !== undefined) {
129
+ const inner = scriptTarget(body, dir, resolveFrom, nested);
130
+ if (inner !== undefined)
131
+ target = { path: inner.path, args: pnpm.args[0] === '--' ? pnpm.args.slice(1) : pnpm.args };
132
+ }
133
+ else if (INTERPRETERS.has(pnpm.script)) {
134
+ target = interpreterTarget([pnpm.script, ...pnpm.args], (path) => resolveFrom(command.cwd, path) ?? resolveFrom('', path));
135
+ }
136
+ }
137
+ }
138
+ else {
139
+ target = interpreterTarget(words, (path) => resolveFrom(command.cwd, path) ?? resolveFrom('', path));
140
+ }
141
+ if (target === undefined)
142
+ continue;
143
+ const { path } = target;
144
+ const spec = specFor(path);
145
+ const args = ownArgs(target.args, nested);
146
+ const flags = args.filter((word) => FLAG.test(word));
147
+ if (spec.closed) {
148
+ for (const word of flags) {
149
+ const flag = String(word.split('=')[0]);
150
+ if (!spec.known(flag))
151
+ report(flag, `\`${flag}\` is not a flag of \`${path}\` — the script never reads it`);
152
+ }
153
+ }
154
+ args.forEach((word, index) => {
155
+ if (!FLAG.test(word))
156
+ return;
157
+ const [flag = '', inline] = word.split(/=(.*)/s);
158
+ const allowed = spec.values(flag);
159
+ const value = inline ?? args[index + 1];
160
+ if (allowed !== undefined && value !== undefined && /^[\w.:/-]+$/.test(value) && !value.startsWith('-') && !allowed.has(value)) {
161
+ report(flag, `\`${flag} ${value}\` — \`${path}\` accepts ${[...allowed].join(', ')}`);
162
+ }
163
+ });
164
+ // `python3 x.py collects …` names the program; only a command with arguments, or one in a fence, is an invocation to complete.
165
+ if (!flags.includes('--help') && (command.fenced || args.length > 0)) {
166
+ for (const flag of spec.required) {
167
+ if (!flags.some((word) => word.split('=')[0] === flag))
168
+ report(flag, `\`${path}\` requires \`${flag}\`; the documented command omits it`);
169
+ }
170
+ }
171
+ if (spec.positional !== undefined) {
172
+ const first = firstPositional(args, spec);
173
+ if (first !== undefined && /^\w[\w-]*$/.test(first) && !spec.positional.has(first))
174
+ report(first, `\`${first}\` is not an action of \`${path}\` (${[...spec.positional].join(', ')})`);
175
+ }
176
+ }
177
+ }
178
+ return problems;
179
+ };
@@ -0,0 +1,122 @@
1
+ // The repository script a documented command runs (DOC-DRIFT-02): how pnpm
2
+ // reads its options, which words start an interpreter or a nested command,
3
+ // and the script file an interpreter or a package script reaches.
4
+ import { NPX_VALUE_OPTIONS, shellWords, simpleCommands, withoutAssignments } from "./doc-commands.js";
5
+ /** pnpm options that take a value, before the script name. */
6
+ const PNPM_VALUE_OPTIONS = new Set(['--filter', '-F', '--dir', '-C', '--workspace-concurrency', '--reporter']);
7
+ export const parsePnpm = (words) => {
8
+ let at = 1;
9
+ const out = { args: [] };
10
+ for (let word = words[at]; word?.startsWith('-') === true; word = words[at]) {
11
+ const [name = '', inline] = word.split(/=(.*)/s);
12
+ if (PNPM_VALUE_OPTIONS.has(name)) {
13
+ const value = inline ?? words[at + 1];
14
+ if (value !== undefined && (name === '--filter' || name === '-F'))
15
+ out.filter = value;
16
+ if (value !== undefined && (name === '--dir' || name === '-C'))
17
+ out.dir = value;
18
+ at += inline === undefined ? 2 : 1;
19
+ }
20
+ else {
21
+ at += 1;
22
+ }
23
+ }
24
+ if (words[at] === 'exec' || words[at] === 'dlx') {
25
+ out.exec = words.slice(at + 1);
26
+ return out;
27
+ }
28
+ if (words[at] === 'run')
29
+ at += 1;
30
+ const script = words[at];
31
+ if (script !== undefined)
32
+ out.script = script;
33
+ out.args = words.slice(at + 1);
34
+ return out;
35
+ };
36
+ /** Commands that run the script file named after them. */
37
+ export const INTERPRETERS = new Set(['tsx', 'node', 'python', 'python3', 'bash', 'sh']);
38
+ /** Words that start a nested command a wrapper script runs for you: interpreters, pnpm, npx and the config's `commands.nested`. */
39
+ export const nestedCommands = (nested) => new Set([...INTERPRETERS, 'pnpm', 'npx', ...nested]);
40
+ /**
41
+ * The arguments the script itself parses: everything before a `--` or a
42
+ * nested command (`test-db.ts vitest run --coverage` hands `--coverage` to
43
+ * vitest, not to test-db.ts).
44
+ */
45
+ export const ownArgs = (args, nested) => {
46
+ const end = args.findIndex((word) => word === '--' || nested.has(word));
47
+ return end === -1 ? [...args] : args.slice(0, end);
48
+ };
49
+ /** Options of node, and of tsx, which hands them to node, whose value may be the next word: that word is not the script. */
50
+ const NODE_VALUE_OPTIONS = new Set([
51
+ '--import',
52
+ '--loader',
53
+ '--experimental-loader',
54
+ '--require',
55
+ '-r',
56
+ '--env-file',
57
+ '--env-file-if-exists',
58
+ '--title',
59
+ '--conditions',
60
+ '-C',
61
+ '--input-type',
62
+ '--disable-warning',
63
+ '--redirect-warnings',
64
+ '--watch-path',
65
+ '--inspect-port',
66
+ '--test-reporter',
67
+ '--test-reporter-destination',
68
+ '--test-name-pattern',
69
+ '--test-skip-pattern',
70
+ ]);
71
+ /**
72
+ * Where the script an interpreter at `words[at]` runs sits: the first word
73
+ * after its options. Only node and tsx options take a separate value; bash's
74
+ * `-r` and `-C` take none. `--` ends the options.
75
+ */
76
+ export const scriptIndex = (words, at) => {
77
+ const valueOptions = words[at] === 'node' || words[at] === 'tsx' ? NODE_VALUE_OPTIONS : new Set();
78
+ let index = at + 1;
79
+ for (let word = words[index]; word?.startsWith('-') === true; word = words[index]) {
80
+ if (word === '--')
81
+ return index + 1;
82
+ index += valueOptions.has(word) ? 2 : 1;
83
+ }
84
+ return index;
85
+ };
86
+ /** Options of `pnpm exec` whose value is the next word; its `-c` is `--shell-mode`, a switch. */
87
+ const PNPM_EXEC_VALUE_OPTIONS = new Set(['--resume-from']);
88
+ /**
89
+ * The repository script an interpreter command runs, if it names one: `tsx
90
+ * scripts/x.ts --a`, `python3 scripts/y.py b`, `bash scripts/z.sh`. The
91
+ * runner's own options before the interpreter are skipped, a value option's
92
+ * value with them (`npx --yes tsx …`, `npx -p tsx tsx …`).
93
+ */
94
+ export const interpreterTarget = (words, resolve) => {
95
+ let at = 0;
96
+ if (words[0] === 'npx' || words[0] === 'pnpm')
97
+ at += 1;
98
+ if (words[at] === 'exec')
99
+ at += 1;
100
+ const valueOptions = words[0] === 'npx' ? NPX_VALUE_OPTIONS : PNPM_EXEC_VALUE_OPTIONS;
101
+ // The commands an `npx -c '…'` runs are documentedCommands' to read; here its value is skipped like any other.
102
+ for (let word = words[at]; word?.startsWith('-') === true; word = words[at])
103
+ at += valueOptions.has(word) ? 2 : 1;
104
+ if (!INTERPRETERS.has(String(words[at])))
105
+ return undefined;
106
+ at = scriptIndex(words, at);
107
+ const script = words[at];
108
+ if (script === undefined)
109
+ return undefined;
110
+ const path = resolve(script);
111
+ return path === undefined ? undefined : { path, args: words.slice(at + 1) };
112
+ };
113
+ /** The single command a package script runs, as a Target, if it is one. */
114
+ export const scriptTarget = (command, manifestDir, resolveFrom, nested) => {
115
+ if (simpleCommands(command).length > 1)
116
+ return undefined;
117
+ const target = interpreterTarget(withoutAssignments(shellWords(command)), (path) => resolveFrom(manifestDir, path));
118
+ // A script that wraps another command passes the doc's words on to it.
119
+ if (target === undefined || ownArgs(target.args, nested).length < target.args.length)
120
+ return undefined;
121
+ return target;
122
+ };