@veda-hq/pariksha 0.5.12 → 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 CHANGED
@@ -4,8 +4,8 @@
4
4
  test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
5
5
 
6
6
  ```sh
7
- npx @veda-hq/pariksha@0.5.12 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.12 --version
7
+ npx @veda-hq/pariksha@0.5.14 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.14 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
@@ -263,6 +263,137 @@ row that is not `` | `path` | bytes | ``, since nothing would enforce it.
263
263
  The table file is read on every check too, so it must budget itself: a row
264
264
  for its own path (relative to `--root`) is required.
265
265
 
266
+ ## `doc-references`
267
+
268
+ ```sh
269
+ pariksha doc-references --config <file> --staged [--checks <ids>] [--root <dir>]
270
+ pariksha doc-references --config <file> --base <ref> [--head <ref>] [--checks <ids>] [--root <dir>]
271
+ pariksha doc-references --config <file> --all [--update-baseline | --write-baseline] [--checks <ids>] [--root <dir>]
272
+ ```
273
+
274
+ A doc that names a file, a script or a function the repository no longer
275
+ has reads as true and sends its reader into a broken step. Three checks read
276
+ the docs and code comments the config's `files` select:
277
+
278
+ - `DOC-DRIFT-01`, paths and links. A path in backticks (in prose, a shell
279
+ fence, frontmatter or a code comment), a glob, or `file.ts:12` resolves
280
+ beside the doc, above it, at the root or from a workspace; a cited line
281
+ exists, and a backticked symbol beside it is near that line. Relative
282
+ Markdown links and their `#anchor`s are checked by
283
+ [lychee](https://lychee.cli.rs), offline, which must be on `PATH` (set
284
+ `links` to `null` to leave links out). A path the tracked `.gitignore`
285
+ rules cover is out of scope.
286
+ - `DOC-DRIFT-02`, commands. A `pnpm <script>` exists in the scope it runs
287
+ from (a workspace script needs `--filter`; bins in `node_modules/.bin`
288
+ count everywhere), and a verification step does not run a script that
289
+ rewrites the tree (`mutating`). A command a doc shows runs as written:
290
+ `--filter` before the script name, every `--flag` read by the script it
291
+ reaches (through a package script or an interpreter), required argparse
292
+ flags present, enum values and first-positional actions the script
293
+ accepts, and, with `typescriptRunner` set, a `.ts` file started through it.
294
+ - `DOC-DRIFT-04`, identifiers. A single-identifier code span in prose
295
+ (`DATABASE_URL`, `parseArgs()`, `OrderService`) occurs as a whole word in
296
+ the tracked code, or in the file the doc cites for it ("`x` in `y.ts`").
297
+ Optional kinds: Prisma `Model.field`, `@@unique([…])`, migration folders and
298
+ `@Roles(…)` values; routes (`GET /stores/:id`) against controller
299
+ decorators; CI jobs ("the `build` job"); packages against workspace names
300
+ and the lockfile.
301
+
302
+ A line ending in `<!-- doc-ref-ok: <why> -->` (in code, a comment holding
303
+ `doc-ref-ok: <why>`; the word is the config's `exemptMarker`) names
304
+ something absent on purpose. Placeholders (`<id>`, `NNN`) and illustrative
305
+ names (`src/foo.ts`) are never references.
306
+
307
+ Modes. `--staged` (a commit) reads the index: staged docs and comments,
308
+ and any doc naming a path the commit deletes or renames, under
309
+ DOC-DRIFT-01 and -02. During a merge, what the merge took unchanged from the
310
+ merged branch is that branch's to judge. `--base` (a push, CI) reads the
311
+ `--head` tree (default `HEAD`) against its merge base with `--base`: changed
312
+ docs, and docs naming a deleted or renamed path, a changed file by
313
+ `name:line`, or an identifier, script or `--flag` the diff removed, under
314
+ all three checks; a finding the merge base already had is not this push's.
315
+ `--all` reads every reference file in the worktree; it alone may lower the
316
+ baseline (`--update-baseline`) or seed it (`--write-baseline`). `--checks`
317
+ takes a comma-separated subset (`01,02`).
318
+
319
+ The baseline is a JSON file of per-file counts, `{ "version": 1, "files":
320
+ { "docs/old.md": { "DOC-DRIFT-01": 3 } } }`, for references that predate
321
+ the check. A checked file may not exceed its count; a file a change touches
322
+ must lower a count it no longer needs; the baseline may only lose entries or
323
+ lower counts against the merge base; an entry for a file the change removed
324
+ must go. The JSON `--config` (relative to the root):
325
+
326
+ ```json
327
+ {
328
+ "baseline": "scripts/doc-references-baseline.json",
329
+ "exemptMarker": "doc-ref-ok",
330
+ "files": {
331
+ "markdown": ["\\.(md|mdx)$"],
332
+ "html": ["^(?:.*/)?docs/.*\\.html$"],
333
+ "code": ["\\.(ts|tsx|js|mjs|sh|py)$"],
334
+ "codeExclude": ["\\.(test|spec)\\.[jt]sx?$"],
335
+ "exclude": ["(^|/)node_modules/", "(^|/)fixtures?/", "^docs/history/"],
336
+ "hashComments": "\\.(sh|py)$"
337
+ },
338
+ "placeholder": "[<>${}%…]|\\.\\.\\.|NNN",
339
+ "paths": {
340
+ "extensions": ["ts", "js", "json", "md", "sh", "py", "yml", "yaml"],
341
+ "examples": { "pattern": "(^|/)(foo|bar|example)(\\.[\\w.]+)?$", "flags": "i" },
342
+ "untracked": [".claude/worktrees/"],
343
+ "workspaces": "^(apps|packages)/[^/]+/package\\.json$",
344
+ "layouts": [{ "pattern": "^evidence/[A-Z]-\\d{2}", "except": "^docs/work/" }]
345
+ },
346
+ "links": { "include": "\\.md$" },
347
+ "scripts": {
348
+ "manifests": ["package.json", "apps/api/package.json"],
349
+ "mutating": { "lint": "lint:ci", "format": "format:check" },
350
+ "verification": { "pattern": "verif|check|gate", "flags": "i" },
351
+ "wrappers": []
352
+ },
353
+ "commands": { "typescriptRunner": "pnpm exec tsx", "nested": ["vitest", "turbo", "git"] },
354
+ "identifiers": {
355
+ "notIdentifiers": ["GitHub", "TypeScript"],
356
+ "searchExclude": ["\\.(md|mdx|html)$", "^docs/", "^pnpm-lock\\.yaml$"],
357
+ "diffExclude": ["*.md", "docs/**"],
358
+ "prisma": { "schemas": "^apps/api/prisma/.*\\.prisma$", "migrations": "apps/api/prisma/migrations", "roles": { "schema": "apps/api/prisma/schema.prisma", "enum": "Role" } },
359
+ "routes": { "controllers": "^apps/api/src/.*\\.controller\\.ts$", "prefixes": "^(api|v\\d+)$" },
360
+ "jobs": { "workflows": "^\\.github/workflows/[^/]+\\.ya?ml$" },
361
+ "packages": { "manifests": "^(?!(?:.*/)?node_modules/)(?:.*/)?package\\.json$", "lockfile": "pnpm-lock.yaml", "ownScopes": ["@acme/"] }
362
+ }
363
+ }
364
+ ```
365
+
366
+ - `files`: what a tracked path is, first match winning after `exclude`:
367
+ Markdown (and Markdown-like XML), HTML pages, or code whose comments are
368
+ read (`codeExclude` drops tests and fixtures, which name absent paths on
369
+ purpose). `hashComments` is the code whose comments start with `#`.
370
+ - `paths`: the extensions a backticked path may end in; `examples` and
371
+ `untracked` names that are never a tracked file; `workspaces`, the
372
+ manifests whose folders (and `src`) a path also resolves from;
373
+ `layouts`, paths that name a folder layout rather than a file outside the
374
+ docs `except` matches.
375
+ - `scripts.wrappers`: repository scripts that run the rest of a line as a
376
+ command (`pnpm dev:exec pnpm x`). `commands.nested`: programs a wrapper
377
+ script hands its remaining words to, so they are not its own flags.
378
+ - `identifiers.searchExclude`: tracked text that defines no name (prose,
379
+ data, sources that quote wrong names on purpose); binaries and the
380
+ baseline are never read. `diffExclude`: pathspecs the removed-identifier
381
+ diff leaves out.
382
+ - `links`, `commands.typescriptRunner`, and each of `identifiers.prisma`
383
+ (and its `migrations` and `roles`), `routes`, `jobs`, `packages` (and its
384
+ `lockfile`) may be `null`, which leaves that part unchecked and says so on
385
+ stdout. Absent, each is refused.
386
+
387
+ On a pass, stdout has one line, `pariksha doc-references: <n> reference
388
+ file(s) checked (<staged|pushed|all>), no new problem; the baseline does not
389
+ grow`. Each refusal is a stderr line, `pariksha doc-references:
390
+ <file>:<line>: [<check>] <ref>: <reason>`, then what to do. Refused (exit 1):
391
+ a reference over its file's count, a count a touched file no longer needs,
392
+ baseline growth, and an entry for a removed file. Exit 2: a bad flag set or
393
+ config (an unknown, missing or bad key or pattern), a malformed baseline, no
394
+ `node_modules/.bin` (DOC-DRIFT-02 would know no bin), a missing or failing
395
+ lychee, a `--base` with no merge base, or a failed git call.
396
+
266
397
  ## `enforcement-claims`
267
398
 
268
399
  ```sh
package/dist/cli.js CHANGED
@@ -9,6 +9,10 @@ import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
9
9
  import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
10
10
  import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
11
11
  import { checkBudgets } from "./doc-budgets.js";
12
+ import { lycheeRunner } from "./doc-links.js";
13
+ import { parseConfig as parseDocReferencesConfig } from "./doc-references-config.js";
14
+ import { formatReport as formatDocReferences, runGate } from "./doc-references-gate.js";
15
+ import { CHECK_IDS } from "./doc-references.js";
12
16
  import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
13
17
  import { loadTypescript, typescriptLoaders } from "./typescript.js";
14
18
  import { checkExportDeletions, formatReport } from "./export-deletions.js";
@@ -26,6 +30,7 @@ const USAGE = [
26
30
  ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
27
31
  ' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
28
32
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
33
+ ' pariksha doc-references --config <file> (--staged | --base <ref> [--head <ref>] | --all [--update-baseline | --write-baseline]) [--checks <ids>] [--root <dir>]',
29
34
  ' pariksha enforcement-claims --config <file> [--root <dir>]',
30
35
  ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
31
36
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
@@ -129,6 +134,55 @@ const docBudgets = (f, io) => {
129
134
  outcome.err.forEach(io.err);
130
135
  return outcome.ok ? 0 : 1;
131
136
  };
137
+ /** `--checks 01,DOC-DRIFT-04` as check ids, or every id that is not one. */
138
+ const checkList = (value) => {
139
+ const ids = value.split(',').map((id) => (id.startsWith('DOC-DRIFT-') ? id : `DOC-DRIFT-${id}`));
140
+ const unknown = ids.filter((id) => !CHECK_IDS.includes(id));
141
+ if (unknown.length === 0)
142
+ return ids;
143
+ return { unknown: `${unknown.join(', ')} ${unknown.length === 1 ? 'is not one' : 'are not'} of` };
144
+ };
145
+ const docReferences = (f, io) => {
146
+ const P = 'pariksha doc-references:';
147
+ const configPath = f.get('--config');
148
+ const base = f.get('--base');
149
+ const all = f.has('--all');
150
+ const baseline = f.has('--update-baseline') ? 'update' : f.has('--write-baseline') ? 'write' : undefined;
151
+ const modes = [f.has('--staged'), all, base !== undefined].filter(Boolean).length;
152
+ if (configPath === undefined || modes !== 1 || (f.has('--update-baseline') && f.has('--write-baseline')) || (baseline !== undefined && !all) || (f.has('--head') && base === undefined)) {
153
+ io.err(USAGE);
154
+ return 2;
155
+ }
156
+ const asked = f.get('--checks');
157
+ const listed = asked === undefined ? undefined : checkList(asked);
158
+ if (listed !== undefined && !Array.isArray(listed)) {
159
+ io.err(`${P} --checks: ${listed.unknown} ${CHECK_IDS.join(', ')}`);
160
+ return 2;
161
+ }
162
+ const dir = resolve(f.get('--root') ?? '.');
163
+ const root = topOf(dir);
164
+ if (root === undefined) {
165
+ io.err(`${P} ${dir} is not in a git repository`);
166
+ return 2;
167
+ }
168
+ const mode = base !== undefined ? { kind: 'range', base, head: f.get('--head') ?? 'HEAD' } : all ? { kind: 'all' } : { kind: 'staged' };
169
+ // The identifier search reads the whole tree: a push and CI, not a commit.
170
+ const checks = listed ?? (mode.kind === 'staged' ? ['DOC-DRIFT-01', 'DOC-DRIFT-02'] : [...CHECK_IDS]);
171
+ let config;
172
+ let report;
173
+ try {
174
+ config = parseDocReferencesConfig(readFileSync(resolve(root, configPath), 'utf8'));
175
+ report = runGate({ root, config, mode, checks, baseline, links: io.links ?? lycheeRunner() });
176
+ }
177
+ catch (e) {
178
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
179
+ return 2;
180
+ }
181
+ const result = formatDocReferences(report, { config, mode, checks }, configPath);
182
+ result.out.forEach(io.out);
183
+ result.err.forEach(io.err);
184
+ return result.ok ? 0 : 1;
185
+ };
132
186
  const enforcementClaims = async (f, io) => {
133
187
  const configPath = f.get('--config');
134
188
  if (configPath === undefined) {
@@ -503,6 +557,7 @@ const COMMANDS = {
503
557
  boundary: { allowed: ['--root', '--deny'], run: boundary },
504
558
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
505
559
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
560
+ 'doc-references': { allowed: ['--config', '--base', '--head', '--checks', '--root'], switches: ['--staged', '--all', '--update-baseline', '--write-baseline'], run: docReferences },
506
561
  'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
507
562
  'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
508
563
  '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
+ };