@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 +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/dist/stage-reach.js +5 -1
- package/package.json +3 -2
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.
|
|
8
|
-
npx @veda-hq/pariksha@0.5.
|
|
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
|
+
};
|