@veda-hq/pariksha 0.5.11 → 0.5.12
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 +101 -2
- package/dist/cli.js +32 -1
- package/dist/enforcement-claims.js +334 -0
- package/dist/stage-reach.js +337 -0
- package/package.json +10 -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.12 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.12 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
@@ -263,6 +263,105 @@ 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
|
+
## `enforcement-claims`
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
pariksha enforcement-claims --config <file> [--root <dir>]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
A standard that says how it is enforced is believed, and nothing else checks
|
|
273
|
+
it: a standard can claim an ESLint rule that was never configured, or one
|
|
274
|
+
configured only in a workspace it does not apply to, or a validator nothing
|
|
275
|
+
runs, and the corpus reads as enforced while nothing runs. This check holds
|
|
276
|
+
each claim to the repository's real configuration. The JSON `--config`
|
|
277
|
+
(relative to the root) names every repository-specific path:
|
|
278
|
+
|
|
279
|
+
```json
|
|
280
|
+
{
|
|
281
|
+
"id": "STD-001",
|
|
282
|
+
"standards": "docs/standards",
|
|
283
|
+
"workspaces": [
|
|
284
|
+
{ "dir": ".", "probes": ["scripts/probe.ts", "scripts/probe.test.ts"] },
|
|
285
|
+
{ "dir": "apps/api", "probes": ["src/probe.ts", "src/probe.test.ts"] }
|
|
286
|
+
],
|
|
287
|
+
"dispatcher": { "path": "scripts/run-validation.sh" },
|
|
288
|
+
"packageRunner": { "path": "scripts/validate-package.sh", "call": "run_validator", "validatorsDir": "scripts/validators" },
|
|
289
|
+
"stageRoots": { "hooks": [".husky"], "workflows": [".github/workflows"], "actions": [".github/actions"] },
|
|
290
|
+
"runnable": { "prefixes": ["scripts/", ".claude/scripts/", "apps/*/scripts/"], "extensions": ["sh", "ts", "mjs", "js"], "bin": "node_modules/.bin" },
|
|
291
|
+
"workspaceList": { "pnpm": "pnpm-workspace.yaml", "turbo": "turbo.json" },
|
|
292
|
+
"tests": { "file": "\\.test\\.tsx?$", "runner": "\\bvitest\\b|\\s--test\\b" }
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The standards are the `.md` files under `standards`, read recursively; their
|
|
297
|
+
YAML frontmatter is the contract:
|
|
298
|
+
|
|
299
|
+
| Field | Meaning |
|
|
300
|
+
| --- | --- |
|
|
301
|
+
| `id` | the standard's id, printed with its problems |
|
|
302
|
+
| `enforcement` | `eslint-rule` and `validator` are checked; other values (`guidance`, …) claim no gate. A file with none is not a standard |
|
|
303
|
+
| `enforcement_config` | for `eslint-rule`, the rules, comma-separated; for `validator`, may cite `<dispatcher file name> <target>` |
|
|
304
|
+
| `partial_gates` | rules that gate part of a standard, on any `enforcement`; checked like `enforcement_config` |
|
|
305
|
+
| `applies_to` | workspace paths or prefixes, a list or one string |
|
|
306
|
+
| `enforcement_script` | for `validator`, the script or test file the claim runs |
|
|
307
|
+
| `status: deprecated` | a retired standard claims nothing |
|
|
308
|
+
|
|
309
|
+
Frontmatter is parsed as YAML (block lists, multiline flow lists and a bare
|
|
310
|
+
scalar `applies_to` all read as lists); frontmatter that does not parse is
|
|
311
|
+
skipped, not counted.
|
|
312
|
+
|
|
313
|
+
- **eslint-rule.** Each workspace's flat config is resolved through ESLint
|
|
314
|
+
(`new ESLint({ cwd: dir }).calculateConfigForFile` on each probe, so
|
|
315
|
+
plugins load from the repository), and a rule is on at error or warn; `off`
|
|
316
|
+
is a false claim. A rule name is one token (`eqeqeq`, `no-console`,
|
|
317
|
+
`@scope/plugin/rule`; a parenthetical is dropped), so prose names nothing,
|
|
318
|
+
and a claim naming no rule is refused. `linterOptions.noInlineConfig` may
|
|
319
|
+
be claimed like a rule. Each named rule must be on in EVERY workspace an
|
|
320
|
+
`applies_to` target covers: a prefix covers every workspace under it, and
|
|
321
|
+
a path under none is the root workspace's (`.`).
|
|
322
|
+
- **validator.** A cited `<dispatcher> <target>` must be a case of the
|
|
323
|
+
dispatcher, a shell `case` whose lines read `name)` or `a|b)` (any alias
|
|
324
|
+
counts). Then every path the claim names (`enforcement_script`, and the
|
|
325
|
+
paths the target's case names) must exist, and at least one of them must be
|
|
326
|
+
reached from a stage: the files directly in
|
|
327
|
+
each `hooks` folder (not those starting with `_`) and each workflow under
|
|
328
|
+
`workflows`, with the local actions they `uses: ./<actions folder>/<name>`.
|
|
329
|
+
The walk follows a path a command names under a `runnable.prefixes`
|
|
330
|
+
folder (a `*` segment is one folder) with one of its `extensions`, resolved
|
|
331
|
+
from the caller's folder; a `<bin>/<name>` binary in command position (not
|
|
332
|
+
`test -x <bin>/<name>`); a dispatcher call, to that target's case only (the
|
|
333
|
+
dispatcher file is never followed whole); a package runner's `<call> "X"`,
|
|
334
|
+
to `<validatorsDir>/X.sh`; `pnpm [run] <script>`, to the calling
|
|
335
|
+
workspace's script (workspaces from the pnpm file's `packages:`, `dir/*`
|
|
336
|
+
expanded, `!` exclusions removed); and `turbo run <task>`, to every
|
|
337
|
+
workspace's script of that name and the root's when the turbo config names
|
|
338
|
+
`//#<task>`. Shell scripts and hooks are followed; nothing else is
|
|
339
|
+
(TypeScript included). In a workflow or action only a step's `run:` and
|
|
340
|
+
`uses:` are read (not `paths:` filters, `name:` fields or `with:` inputs),
|
|
341
|
+
each run from the step's `working-directory`, else the job's or workflow's
|
|
342
|
+
`defaults.run.working-directory`, else the root; in shell, comment lines and
|
|
343
|
+
`echo`/`printf` lines are not calls. A cited test
|
|
344
|
+
file (`tests.file`) counts as run when a reached script of its workspace
|
|
345
|
+
matches `tests.runner`.
|
|
346
|
+
|
|
347
|
+
`id` is optional and only labels the output. `packageRunner`, `bin` and
|
|
348
|
+
`turbo` are optional (absent or `null`: none, so less is reached). Exit 0
|
|
349
|
+
prints `pariksha enforcement-claims (<id>): every eslint-rule claim is real
|
|
350
|
+
and every validator claim runs at a stage (<eslint-rule> + <validator> of
|
|
351
|
+
<standards> rule(s))`. Exit 1 prints one line per problem, ``<file> (<id>):
|
|
352
|
+
`<rule or path>` <reason>``, and a count. Exit 2: a bad config (an unknown,
|
|
353
|
+
missing or bad key, a path that is not a normalized root-relative POSIX path,
|
|
354
|
+
a prefix that is not a folder path, a workspace listed twice), no standard
|
|
355
|
+
declaring an enforcement (the scan failed), a missing standards folder,
|
|
356
|
+
dispatcher or workspace folder, a workspace whose every probe fails to
|
|
357
|
+
resolve a config, or no `eslint`. The stage walk runs only when some live
|
|
358
|
+
standard claims `validator`; then a missing pnpm workspace file or turbo
|
|
359
|
+
config, a workflow that is not YAML and a `package.json` that is not JSON
|
|
360
|
+
exit 2 too.
|
|
361
|
+
|
|
362
|
+
Needs `eslint` `>=9.24 <10` installed beside pariksha (an optional peer
|
|
363
|
+
dependency: only this check loads it).
|
|
364
|
+
|
|
266
365
|
## `env-gate-switches`
|
|
267
366
|
|
|
268
367
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf, trackedUnde
|
|
|
17
17
|
import { discover as discoverCodecs, fileOf as codecFileOf, findings as codecFindings, formatFindings as formatCodecFindings, parseBaseline as parseCodecBaseline, parseConfig as parseCodecConfig } from "./codec-registry.js";
|
|
18
18
|
import { discoverGroup, findings as faultFindings, formatFindings as formatFaultFindings, parseBaseline as parseFaultBaseline, parseConfig as parseFaultConfig } from "./fault-injection-registry.js";
|
|
19
19
|
import { emptyRoots, growthSince, readBaseline, wholeId } from "./registry-ratchet.js";
|
|
20
|
+
import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfig } from "./enforcement-claims.js";
|
|
20
21
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
21
22
|
const USAGE = [
|
|
22
23
|
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
@@ -25,6 +26,7 @@ const USAGE = [
|
|
|
25
26
|
' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
26
27
|
' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
|
|
27
28
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
29
|
+
' pariksha enforcement-claims --config <file> [--root <dir>]',
|
|
28
30
|
' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
|
|
29
31
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
30
32
|
' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
|
|
@@ -127,6 +129,33 @@ const docBudgets = (f, io) => {
|
|
|
127
129
|
outcome.err.forEach(io.err);
|
|
128
130
|
return outcome.ok ? 0 : 1;
|
|
129
131
|
};
|
|
132
|
+
const enforcementClaims = async (f, io) => {
|
|
133
|
+
const configPath = f.get('--config');
|
|
134
|
+
if (configPath === undefined) {
|
|
135
|
+
io.err(USAGE);
|
|
136
|
+
return 2;
|
|
137
|
+
}
|
|
138
|
+
const root = resolve(f.get('--root') ?? '.');
|
|
139
|
+
let P = 'pariksha enforcement-claims:';
|
|
140
|
+
try {
|
|
141
|
+
const config = parseClaimsConfig(readFileSync(resolve(root, configPath), 'utf8'));
|
|
142
|
+
if (config.id !== undefined)
|
|
143
|
+
P = `pariksha enforcement-claims (${config.id}):`;
|
|
144
|
+
const ESLint = await (io.eslint ?? loadEslint)();
|
|
145
|
+
if (typeof ESLint === 'string') {
|
|
146
|
+
io.err(`${P} ${ESLint}`);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
const result = formatOutcome(P, await checkClaims(root, config, ESLint));
|
|
150
|
+
result.out.forEach(io.out);
|
|
151
|
+
result.err.forEach(io.err);
|
|
152
|
+
return result.ok ? 0 : 1;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
130
159
|
const exportDeletions = (f, io) => {
|
|
131
160
|
const dir = resolve(f.get('--root') ?? '.');
|
|
132
161
|
const git = gitAtTop(dir);
|
|
@@ -474,10 +503,12 @@ const COMMANDS = {
|
|
|
474
503
|
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
475
504
|
'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
|
|
476
505
|
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
506
|
+
'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
|
|
477
507
|
'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
|
|
478
508
|
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
479
509
|
'required-settings': { allowed: ['--require', '--settings', '--root'], run: requiredSettings },
|
|
480
510
|
};
|
|
511
|
+
/** The exit code; a Promise of it for the one check that is asynchronous (enforcement-claims, which loads ESLint). */
|
|
481
512
|
export const run = (args, io, version = ownVersion(), env = process.env) => {
|
|
482
513
|
const [command, ...rest] = args;
|
|
483
514
|
if (command === '--version' && rest.length === 0) {
|
|
@@ -501,7 +532,7 @@ const isMain = () => {
|
|
|
501
532
|
return entry !== undefined && realpathSync(entry) === fileURLToPath(import.meta.url);
|
|
502
533
|
};
|
|
503
534
|
if (isMain()) {
|
|
504
|
-
process.exitCode = run(process.argv.slice(2), {
|
|
535
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
505
536
|
out: (line) => console.info(line),
|
|
506
537
|
err: (line) => console.error(line),
|
|
507
538
|
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// enforcement-claims: a standard's frontmatter says how it is enforced, and
|
|
2
|
+
// nothing else checks that the description is true. A standard can claim
|
|
3
|
+
// `enforcement: eslint-rule` naming a rule that was never configured, or one
|
|
4
|
+
// configured in one workspace and not the one it `applies_to`, or claim
|
|
5
|
+
// `enforcement: validator` naming a script nothing runs; the corpus then reads
|
|
6
|
+
// as enforced while nothing runs. The document asserting the guarantee is the
|
|
7
|
+
// one nobody can verify by reading.
|
|
8
|
+
//
|
|
9
|
+
// This resolves each configured workspace's REAL flat config through the
|
|
10
|
+
// ESLint API, and holds every rule a standard names to being on (error or
|
|
11
|
+
// warn) in every workspace it applies to; a rule at `off` is a false claim.
|
|
12
|
+
// Validator claims must name a dispatcher target that exists, and something
|
|
13
|
+
// a stage runs (stage-reach.ts).
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { basename, join, posix, relative, resolve } from 'node:path';
|
|
16
|
+
import { load } from 'js-yaml';
|
|
17
|
+
import { isRecord, isRepoPath, list, object, onlyKeys, optionalText, parseJson, path, paths, records, regex, text } from "./registry-ratchet.js";
|
|
18
|
+
import { dispatcherTargets, findUncalledValidators, runnablePatterns } from "./stage-reach.js";
|
|
19
|
+
const KEYS = ['id', 'standards', 'workspaces', 'dispatcher', 'packageRunner', 'stageRoots', 'runnable', 'workspaceList', 'tests'];
|
|
20
|
+
/** A list of folder paths, empty when absent or empty. */
|
|
21
|
+
const folders = (raw, key, where) => {
|
|
22
|
+
const v = list(raw, key, where);
|
|
23
|
+
return v.length === 0 ? [] : paths(raw, key, where);
|
|
24
|
+
};
|
|
25
|
+
/** An optional object key: absent or null is none. */
|
|
26
|
+
const optional = (raw, key) => (raw[key] === undefined || raw[key] === null ? undefined : object(raw, key, ''));
|
|
27
|
+
const PREFIX = /^(?:(?:[\w.-]+|\*)\/)+$/;
|
|
28
|
+
/** The config text: every repository-specific literal of the check. Throws naming the first bad key. */
|
|
29
|
+
export const parseConfig = (source) => {
|
|
30
|
+
const raw = parseJson(source);
|
|
31
|
+
onlyKeys(raw, KEYS, '');
|
|
32
|
+
const workspaces = records(list(raw, 'workspaces', ''), 'workspaces').map(({ raw: w, where }) => {
|
|
33
|
+
onlyKeys(w, ['dir', 'probes'], where);
|
|
34
|
+
const dir = text(w, 'dir', where);
|
|
35
|
+
if (dir !== '.' && !isRepoPath(dir))
|
|
36
|
+
throw new Error(`${where}"dir": "${dir}" is not "." or a normalized root-relative path`);
|
|
37
|
+
return { dir, probes: paths(w, 'probes', where) };
|
|
38
|
+
});
|
|
39
|
+
if (workspaces.length === 0)
|
|
40
|
+
throw new Error('"workspaces" is empty, so no rule could be found enabled');
|
|
41
|
+
const dirs = workspaces.map((w) => w.dir);
|
|
42
|
+
const twice = dirs.find((d, i) => dirs.indexOf(d) !== i);
|
|
43
|
+
if (twice !== undefined)
|
|
44
|
+
throw new Error(`workspace "${twice}" is listed more than once`);
|
|
45
|
+
const dispatcherRaw = object(raw, 'dispatcher', '');
|
|
46
|
+
onlyKeys(dispatcherRaw, ['path'], 'dispatcher: ');
|
|
47
|
+
const dispatcher = path(dispatcherRaw, 'path', 'dispatcher: ');
|
|
48
|
+
const runnerRaw = optional(raw, 'packageRunner');
|
|
49
|
+
let packageRunner;
|
|
50
|
+
if (runnerRaw !== undefined) {
|
|
51
|
+
onlyKeys(runnerRaw, ['path', 'call', 'validatorsDir'], 'packageRunner: ');
|
|
52
|
+
const call = text(runnerRaw, 'call', 'packageRunner: ');
|
|
53
|
+
if (!/^[A-Za-z_][\w-]*$/.test(call))
|
|
54
|
+
throw new Error('packageRunner: "call" must be a shell function name');
|
|
55
|
+
packageRunner = { path: path(runnerRaw, 'path', 'packageRunner: '), call, validatorsDir: path(runnerRaw, 'validatorsDir', 'packageRunner: ') };
|
|
56
|
+
}
|
|
57
|
+
const stages = object(raw, 'stageRoots', '');
|
|
58
|
+
onlyKeys(stages, ['hooks', 'workflows', 'actions'], 'stageRoots: ');
|
|
59
|
+
const hooks = folders(stages, 'hooks', 'stageRoots: ');
|
|
60
|
+
const workflows = folders(stages, 'workflows', 'stageRoots: ');
|
|
61
|
+
if (hooks.length + workflows.length === 0)
|
|
62
|
+
throw new Error('stageRoots: "hooks" and "workflows" are both empty, so no stage would be walked');
|
|
63
|
+
const runnableRaw = object(raw, 'runnable', '');
|
|
64
|
+
onlyKeys(runnableRaw, ['prefixes', 'extensions', 'bin'], 'runnable: ');
|
|
65
|
+
const prefixes = list(runnableRaw, 'prefixes', 'runnable: ').map((p, i) => {
|
|
66
|
+
if (typeof p !== 'string' || !PREFIX.test(p) || p.split('/').some((s) => s === '.' || s === '..')) {
|
|
67
|
+
throw new Error(`runnable.prefixes[${i}]: ${JSON.stringify(p)} is not a folder path ending in "/" (a "*" segment is one folder)`);
|
|
68
|
+
}
|
|
69
|
+
return p;
|
|
70
|
+
});
|
|
71
|
+
if (prefixes.length === 0)
|
|
72
|
+
throw new Error('runnable: "prefixes" is empty, so no command would name a script');
|
|
73
|
+
const extensions = list(runnableRaw, 'extensions', 'runnable: ').map((e, i) => {
|
|
74
|
+
if (typeof e !== 'string' || !/^[A-Za-z0-9]+$/.test(e))
|
|
75
|
+
throw new Error(`runnable.extensions[${i}]: ${JSON.stringify(e)} is not a file extension without its dot`);
|
|
76
|
+
return e;
|
|
77
|
+
});
|
|
78
|
+
if (extensions.length === 0)
|
|
79
|
+
throw new Error('runnable: "extensions" is empty, so no command would name a script');
|
|
80
|
+
const bin = runnableRaw['bin'] === undefined || runnableRaw['bin'] === null ? undefined : path(runnableRaw, 'bin', 'runnable: ');
|
|
81
|
+
const listRaw = object(raw, 'workspaceList', '');
|
|
82
|
+
onlyKeys(listRaw, ['pnpm', 'turbo'], 'workspaceList: ');
|
|
83
|
+
const turbo = listRaw['turbo'] === undefined || listRaw['turbo'] === null ? undefined : path(listRaw, 'turbo', 'workspaceList: ');
|
|
84
|
+
const tests = object(raw, 'tests', '');
|
|
85
|
+
onlyKeys(tests, ['file', 'runner'], 'tests: ');
|
|
86
|
+
return {
|
|
87
|
+
id: optionalText(raw, 'id', ''),
|
|
88
|
+
standards: path(raw, 'standards', ''),
|
|
89
|
+
workspaces,
|
|
90
|
+
dispatcher,
|
|
91
|
+
dispatchCall: new RegExp(`${basename(dispatcher).replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')}\\s+([a-z][a-z0-9-]*)`),
|
|
92
|
+
packageRunner,
|
|
93
|
+
hooks,
|
|
94
|
+
workflows,
|
|
95
|
+
actions: folders(stages, 'actions', 'stageRoots: '),
|
|
96
|
+
runnable: runnablePatterns(prefixes, extensions, bin),
|
|
97
|
+
workspaceFile: path(listRaw, 'pnpm', 'workspaceList: '),
|
|
98
|
+
turbo,
|
|
99
|
+
testFile: regex(tests['file'], 'tests.file'),
|
|
100
|
+
testRunner: regex(tests['runner'], 'tests.runner'),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const markdown = (dir, out = []) => {
|
|
104
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
105
|
+
const full = join(dir, entry);
|
|
106
|
+
if (statSync(full).isDirectory())
|
|
107
|
+
markdown(full, out);
|
|
108
|
+
else if (entry.endsWith('.md'))
|
|
109
|
+
out.push(full);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* The standards under `dir` that declare an enforcement, sorted by file.
|
|
115
|
+
*
|
|
116
|
+
* Frontmatter is PARSED AS YAML, not matched with regexes: a regex read the
|
|
117
|
+
* block form's first entry as `- apps/api` and dropped the rest, and its
|
|
118
|
+
* repair still read a multiline flow list as [], which is indistinguishable
|
|
119
|
+
* from "declares no workspace": a silent skip of the very check. Frontmatter
|
|
120
|
+
* that does not parse is skipped, never counted as a standard that passed.
|
|
121
|
+
*/
|
|
122
|
+
export const readStandards = (root, dir) => {
|
|
123
|
+
const standards = [];
|
|
124
|
+
for (const file of markdown(join(root, dir))) {
|
|
125
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(readFileSync(file, 'utf8'));
|
|
126
|
+
if (match === null)
|
|
127
|
+
continue;
|
|
128
|
+
let frontmatter;
|
|
129
|
+
try {
|
|
130
|
+
const loaded = load(match[1]);
|
|
131
|
+
if (!isRecord(loaded))
|
|
132
|
+
continue;
|
|
133
|
+
frontmatter = loaded;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const field = (name) => {
|
|
139
|
+
const value = frontmatter[name];
|
|
140
|
+
if (value === undefined || value === null)
|
|
141
|
+
return '';
|
|
142
|
+
return typeof value === 'string' ? value.trim() : String(value);
|
|
143
|
+
};
|
|
144
|
+
// A bare scalar is a one-entry list: `applies_to: apps/api` claims one
|
|
145
|
+
// workspace, and reading it as no claim would be the silent skip again.
|
|
146
|
+
const listField = (name) => {
|
|
147
|
+
const value = frontmatter[name];
|
|
148
|
+
return (Array.isArray(value) ? value : [value])
|
|
149
|
+
.filter((entry) => typeof entry === 'string')
|
|
150
|
+
.map((entry) => entry.trim())
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
};
|
|
153
|
+
const enforcement = field('enforcement');
|
|
154
|
+
if (enforcement === '')
|
|
155
|
+
continue;
|
|
156
|
+
standards.push({
|
|
157
|
+
file: relative(root, file).split('\\').join('/'),
|
|
158
|
+
id: field('id'),
|
|
159
|
+
enforcement,
|
|
160
|
+
enforcementConfig: field('enforcement_config'),
|
|
161
|
+
partialGates: field('partial_gates'),
|
|
162
|
+
appliesTo: listField('applies_to'),
|
|
163
|
+
enforcementScript: field('enforcement_script'),
|
|
164
|
+
deprecated: field('status') === 'deprecated',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return standards.sort((a, b) => a.file.localeCompare(b.file));
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* The rule names a claim names. Entries are prose as often as not, and a
|
|
171
|
+
* parenthetical narrows a rule rather than naming one (`no-restricted-globals
|
|
172
|
+
* (Buffer)`). A rule name is one unbroken token, hyphen or not (`eqeqeq`,
|
|
173
|
+
* `curly`); prose is a sentence. An empty claim names nothing.
|
|
174
|
+
*/
|
|
175
|
+
export const claimedRules = (claim) =>
|
|
176
|
+
// Parentheticals go first: `no-restricted-globals (Buffer, process)` is one rule.
|
|
177
|
+
(claim.replace(/\([^)]*\)/g, '').match(/[^,]+/g) ?? []).map((part) => part.trim()).filter((part) => /^[@a-z][\w@/.-]*$/i.test(part));
|
|
178
|
+
/** Is one resolved rule setting on (error or warn), as a number or a word, bare or an options array's head? */
|
|
179
|
+
export const ruleIsOn = (setting) => {
|
|
180
|
+
const severity = Array.isArray(setting) ? setting[0] : setting;
|
|
181
|
+
return severity !== 0 && severity !== 'off';
|
|
182
|
+
};
|
|
183
|
+
/** The claim token for `linterOptions.noInlineConfig`, which refuses every inline directive as a rule would. */
|
|
184
|
+
export const NO_INLINE_CONFIG = 'linterOptions.noInlineConfig';
|
|
185
|
+
/** ESLint's class from pariksha's resolution of the `eslint` peer; a message naming why not when it cannot be loaded. */
|
|
186
|
+
export const loadEslint = async (load = () => import('eslint')) => {
|
|
187
|
+
try {
|
|
188
|
+
const mod = await load();
|
|
189
|
+
if (typeof mod.ESLint === 'function')
|
|
190
|
+
return mod.ESLint;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// not installed beside pariksha
|
|
194
|
+
}
|
|
195
|
+
return 'needs the eslint package (>=9.24 <10) installed beside pariksha';
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Every rule that is on in each workspace's resolved config, by workspace. A
|
|
199
|
+
* probe ESLint ignores contributes nothing; one whose config does not load is
|
|
200
|
+
* skipped while another probe of the workspace resolves, and a workspace no
|
|
201
|
+
* probe resolves exits 2, since its rules would read as all off.
|
|
202
|
+
*/
|
|
203
|
+
export const activeRules = async (ESLint, root, workspaces) => {
|
|
204
|
+
const resolved = await Promise.all(workspaces.map(async (workspace) => {
|
|
205
|
+
const cwd = resolve(root, workspace.dir);
|
|
206
|
+
if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory())
|
|
207
|
+
throw new Error(`workspace "${workspace.dir}" is not a folder under the root`);
|
|
208
|
+
const eslint = new ESLint({ cwd });
|
|
209
|
+
const settled = await Promise.allSettled(workspace.probes.map((probe) => eslint.calculateConfigForFile(resolve(cwd, probe))));
|
|
210
|
+
const failed = settled.find((s) => s.status === 'rejected');
|
|
211
|
+
if (failed !== undefined && settled.every((s) => s.status === 'rejected')) {
|
|
212
|
+
throw new Error(`no probe of workspace "${workspace.dir}" resolves a config: ${failed.reason.message}`);
|
|
213
|
+
}
|
|
214
|
+
const active = new Set();
|
|
215
|
+
for (const s of settled) {
|
|
216
|
+
if (s.status === 'rejected' || !isRecord(s.value))
|
|
217
|
+
continue;
|
|
218
|
+
const rules = s.value['rules'];
|
|
219
|
+
if (isRecord(rules))
|
|
220
|
+
for (const [name, setting] of Object.entries(rules))
|
|
221
|
+
if (ruleIsOn(setting))
|
|
222
|
+
active.add(name);
|
|
223
|
+
const options = s.value['linterOptions'];
|
|
224
|
+
if (isRecord(options) && options['noInlineConfig'] === true)
|
|
225
|
+
active.add(NO_INLINE_CONFIG);
|
|
226
|
+
}
|
|
227
|
+
return [workspace.dir, active];
|
|
228
|
+
}));
|
|
229
|
+
return new Map(resolved);
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Every workspace an `applies_to` target covers. A target is a path prefix
|
|
233
|
+
* and names a SET: `packages` covers every workspace under it, and each must
|
|
234
|
+
* enforce the rule. A target under no workspace belongs to the root one,
|
|
235
|
+
* where root-level folders live.
|
|
236
|
+
*/
|
|
237
|
+
export const ownersOf = (target, workspaces) => {
|
|
238
|
+
const matches = [...workspaces].filter((dir) => target === dir || target.startsWith(`${dir}/`) || dir.startsWith(`${target}/`));
|
|
239
|
+
return matches.length > 0 ? matches : ['.'];
|
|
240
|
+
};
|
|
241
|
+
const verifyRules = (standard, rules, field, problems, anywhere, byWorkspace) => {
|
|
242
|
+
for (const rule of rules) {
|
|
243
|
+
if (!anywhere.has(rule)) {
|
|
244
|
+
problems.push({ file: standard.file, id: standard.id, rule, reason: `(${field}) is not enabled in ANY workspace config — the claim is false` });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const scopes = standard.appliesTo
|
|
248
|
+
.flatMap((target) => ownersOf(target, byWorkspace.keys()))
|
|
249
|
+
.map((dir) => byWorkspace.get(dir))
|
|
250
|
+
.filter((active) => active !== undefined);
|
|
251
|
+
// EVERY, not some: a rule enabled in one of two claimed workspaces is
|
|
252
|
+
// unenforced in the other. No applies_to has no scope, and passes.
|
|
253
|
+
if (!scopes.every((active) => active.has(rule))) {
|
|
254
|
+
problems.push({
|
|
255
|
+
file: standard.file,
|
|
256
|
+
id: standard.id,
|
|
257
|
+
rule,
|
|
258
|
+
reason: `(${field}) is enabled elsewhere but NOT in ${standard.appliesTo.join(', ')}, which is where this rule claims to apply`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
/** The eslint-rule, partial_gates and dispatcher-target problems, against each workspace's active rules. */
|
|
264
|
+
export const findEnforcementProblems = (standards, byWorkspace, dispatch, config) => {
|
|
265
|
+
const anywhere = new Set([...byWorkspace.values()].flatMap((rules) => [...rules]));
|
|
266
|
+
const targets = dispatcherTargets(dispatch);
|
|
267
|
+
const problems = [];
|
|
268
|
+
for (const standard of standards) {
|
|
269
|
+
// A retired standard claims nothing.
|
|
270
|
+
if (standard.deprecated)
|
|
271
|
+
continue;
|
|
272
|
+
// partial_gates is verified whatever the enforcement mode: the field
|
|
273
|
+
// exists to be usable on a guidance standard. Prose there names no rule,
|
|
274
|
+
// would check nothing, and is refused like an empty eslint-rule claim.
|
|
275
|
+
const partial = claimedRules(standard.partialGates);
|
|
276
|
+
if (standard.partialGates !== '' && partial.length === 0) {
|
|
277
|
+
problems.push({ file: standard.file, id: standard.id, rule: standard.partialGates, reason: '(partial_gates) names no rule this gate can adjudicate — a partial gate that cannot be checked is not a gate' });
|
|
278
|
+
}
|
|
279
|
+
verifyRules(standard, partial, 'partial_gates', problems, anywhere, byWorkspace);
|
|
280
|
+
// A validator claim naming a dispatcher target must name a real one, by
|
|
281
|
+
// any alias on its case line; otherwise relabelling an eslint-rule claim
|
|
282
|
+
// as a validator would be the cheapest way past this check.
|
|
283
|
+
if (standard.enforcement === 'validator') {
|
|
284
|
+
const target = config.dispatchCall.exec(standard.enforcementConfig);
|
|
285
|
+
if (target !== null && !targets.has(target[1])) {
|
|
286
|
+
problems.push({ file: standard.file, id: standard.id, rule: target[1], reason: `is not a case in ${config.dispatcher} — the command does not exist` });
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (standard.enforcement !== 'eslint-rule')
|
|
291
|
+
continue;
|
|
292
|
+
const rules = claimedRules(standard.enforcementConfig);
|
|
293
|
+
if (rules.length === 0) {
|
|
294
|
+
problems.push({
|
|
295
|
+
file: standard.file,
|
|
296
|
+
id: standard.id,
|
|
297
|
+
rule: standard.enforcementConfig || '(empty)',
|
|
298
|
+
reason: 'claims eslint-rule enforcement but enforcement_config names no rule — nothing here can be checked, by a reader or by this gate',
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
verifyRules(standard, rules, 'enforcement_config', problems, anywhere, byWorkspace);
|
|
303
|
+
}
|
|
304
|
+
return problems;
|
|
305
|
+
};
|
|
306
|
+
/** Reads the standards and checks every claim; throws on anything that leaves the check unable to run. */
|
|
307
|
+
export const checkClaims = async (root, config, ESLint) => {
|
|
308
|
+
const dir = statSync(join(root, config.standards), { throwIfNoEntry: false });
|
|
309
|
+
if (!dir?.isDirectory())
|
|
310
|
+
throw new Error(`the standards folder ${config.standards} is not a folder under the root`);
|
|
311
|
+
const standards = readStandards(root, config.standards);
|
|
312
|
+
if (standards.length === 0)
|
|
313
|
+
throw new Error(`no standard under ${config.standards} declares an enforcement — the scan failed`);
|
|
314
|
+
const dispatch = readFileSync(posix.join(root, config.dispatcher), 'utf8');
|
|
315
|
+
const byWorkspace = await activeRules(ESLint, root, config.workspaces);
|
|
316
|
+
return { standards, problems: [...findEnforcementProblems(standards, byWorkspace, dispatch, config), ...findUncalledValidators(root, standards, config)] };
|
|
317
|
+
};
|
|
318
|
+
/** The verdict lines: each problem, or the counts of the claims that held. */
|
|
319
|
+
export const formatOutcome = (prefix, outcome) => {
|
|
320
|
+
const { standards, problems } = outcome;
|
|
321
|
+
if (problems.length > 0) {
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
out: [],
|
|
325
|
+
err: [
|
|
326
|
+
...problems.map((p) => `${prefix} ${p.file} (${p.id || 'no id'}): \`${p.rule}\` ${p.reason}`),
|
|
327
|
+
`${prefix} ${problems.length} untrue enforcement claim(s); a standard whose enforcement is described wrongly reads as enforced while nothing runs`,
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
const claimed = standards.filter((s) => s.enforcement === 'eslint-rule' && !s.deprecated).length;
|
|
332
|
+
const run = standards.filter((s) => s.enforcement === 'validator' && !s.deprecated).length;
|
|
333
|
+
return { ok: true, out: [`${prefix} every eslint-rule claim is real and every validator claim runs at a stage (${claimed} + ${run} of ${standards.length} rule(s))`], err: [] };
|
|
334
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// enforcement-claims, second half: a validator claim must name something a
|
|
2
|
+
// stage RUNS. A dispatcher target that exists proves nothing: a standard can
|
|
3
|
+
// name a working validator that no hook, workflow or task ever calls, and
|
|
4
|
+
// that validator can fail on the default branch for months unseen.
|
|
5
|
+
//
|
|
6
|
+
// This walks what the stages execute, from the roots out:
|
|
7
|
+
//
|
|
8
|
+
// roots the files directly in each hooks folder (not those starting
|
|
9
|
+
// with `_`) and the `.yml`/`.yaml` workflows, plus the local
|
|
10
|
+
// actions a workflow or action `uses: ./<actions folder>/<name>`
|
|
11
|
+
// paths a path a command names under a configured prefix, with a
|
|
12
|
+
// configured extension, resolved against the caller's working
|
|
13
|
+
// directory (a package script's `../../scripts/x.sh` is the
|
|
14
|
+
// root's `scripts/x.sh`); shell scripts and hooks are followed,
|
|
15
|
+
// nothing else is (a runner that spawns any command would make
|
|
16
|
+
// everything reachable); a `<bin>/<name>` binary in command
|
|
17
|
+
// position is reached the same way
|
|
18
|
+
// dispatcher `<dispatcher> <target>` resolves to that target's case block
|
|
19
|
+
// only; the dispatcher FILE is never followed, since it names
|
|
20
|
+
// every target
|
|
21
|
+
// package `<call> "X"` in the package runner resolves to
|
|
22
|
+
// `<validatorsDir>/X.sh`
|
|
23
|
+
// pnpm `pnpm [run] <script>` resolves to the calling workspace's script
|
|
24
|
+
// turbo `turbo run <task>` resolves to every workspace's script of that
|
|
25
|
+
// name, and to the root's when the turbo config declares
|
|
26
|
+
// `//#<task>`
|
|
27
|
+
//
|
|
28
|
+
// A cited test file counts as run when a reached script of its workspace runs
|
|
29
|
+
// a test runner. A runner's include and exclude globs are not modelled.
|
|
30
|
+
//
|
|
31
|
+
// Only code lines are read: full-line comments and lines starting with `echo`
|
|
32
|
+
// or `printf` are dropped, since hooks print commands for a person to run. A
|
|
33
|
+
// workflow or action is parsed as YAML and only its `run:` steps are read: a
|
|
34
|
+
// `paths:` filter or a step `name:` that mentions a script watches or
|
|
35
|
+
// describes it, it does not run it.
|
|
36
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
37
|
+
import { posix } from 'node:path';
|
|
38
|
+
import { load } from 'js-yaml';
|
|
39
|
+
/** The repository root, both as a working directory and as a workspace key. */
|
|
40
|
+
export const ROOT_DIR = '.';
|
|
41
|
+
const read = (root, path) => readFileSync(posix.join(root, path), 'utf8');
|
|
42
|
+
/** Drops comments and printed text, keeping the lines a shell or runner executes. */
|
|
43
|
+
export const codeLines = (text) => text
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((line) => !/^\s*(?:#|echo\b|printf\b)/.test(line))
|
|
46
|
+
.join('\n');
|
|
47
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
|
|
48
|
+
const isNode = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
49
|
+
/**
|
|
50
|
+
* A `working-directory` as a root-relative folder (GitHub resolves it against
|
|
51
|
+
* the checkout); `fallback` when there is none, or when it is an expression,
|
|
52
|
+
* absolute, or climbs out of the root.
|
|
53
|
+
*/
|
|
54
|
+
const workingDir = (value, fallback) => {
|
|
55
|
+
if (typeof value !== 'string' || value.trim() === '' || value.includes('${{') || posix.isAbsolute(value.trim()))
|
|
56
|
+
return fallback;
|
|
57
|
+
const dir = posix.normalize(value.trim()).replace(/\/$/, '');
|
|
58
|
+
return dir === '..' || dir.startsWith('../') ? fallback : dir;
|
|
59
|
+
};
|
|
60
|
+
/** A workflow's or job's `defaults.run.working-directory`, else `fallback`. */
|
|
61
|
+
const defaultsDir = (node, fallback) => {
|
|
62
|
+
const defaults = node['defaults'];
|
|
63
|
+
const run = isNode(defaults) ? defaults['run'] : undefined;
|
|
64
|
+
return isNode(run) ? workingDir(run['working-directory'], fallback) : fallback;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* What a workflow or action executes: the `run:` of each step (an entry of a
|
|
68
|
+
* `steps` list, in the folder its `working-directory` or the enclosing
|
|
69
|
+
* `defaults.run.working-directory` names), and the local actions a step
|
|
70
|
+
* `uses:`. A `run` or `uses` anywhere else (an action input under `with:`, an
|
|
71
|
+
* env value) executes nothing.
|
|
72
|
+
*/
|
|
73
|
+
export const yamlCommands = (text, actionDirs) => {
|
|
74
|
+
const runs = [];
|
|
75
|
+
const actions = [];
|
|
76
|
+
const local = actionDirs.map((dir) => new RegExp(`\\./(${escape(dir)}/[\\w-]+)`));
|
|
77
|
+
const step = (s, dir) => {
|
|
78
|
+
if (typeof s['run'] === 'string')
|
|
79
|
+
runs.push({ text: s['run'], dir: workingDir(s['working-directory'], dir) });
|
|
80
|
+
const uses = s['uses'];
|
|
81
|
+
if (typeof uses !== 'string')
|
|
82
|
+
return;
|
|
83
|
+
for (const pattern of local) {
|
|
84
|
+
const hit = pattern.exec(uses);
|
|
85
|
+
if (hit !== null)
|
|
86
|
+
actions.push(hit[1]);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const walk = (node, key, dir) => {
|
|
90
|
+
if (Array.isArray(node)) {
|
|
91
|
+
for (const item of node) {
|
|
92
|
+
if (key === 'steps' && isNode(item))
|
|
93
|
+
step(item, dir);
|
|
94
|
+
walk(item, undefined, dir);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!isNode(node))
|
|
99
|
+
return;
|
|
100
|
+
const here = defaultsDir(node, dir);
|
|
101
|
+
for (const [child, value] of Object.entries(node))
|
|
102
|
+
walk(value, child, here);
|
|
103
|
+
};
|
|
104
|
+
walk(load(text), undefined, ROOT_DIR);
|
|
105
|
+
return { runs, actions };
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The workspace directories the pnpm workspace file declares: a literal folder,
|
|
109
|
+
* or `dir/*` expanded to its folders holding a package.json; an exclusion
|
|
110
|
+
* (`!dir` or `!dir/*`) removes what it names. Other globs match nothing.
|
|
111
|
+
*/
|
|
112
|
+
export const workspaceDirs = (root, workspaceFile) => {
|
|
113
|
+
const parsed = load(read(root, workspaceFile));
|
|
114
|
+
const packages = isNode(parsed) ? parsed['packages'] : undefined;
|
|
115
|
+
if (!Array.isArray(packages))
|
|
116
|
+
return [];
|
|
117
|
+
const patterns = packages.filter((p) => typeof p === 'string');
|
|
118
|
+
const matches = (pattern, dir) => (pattern.endsWith('/*') ? dir.startsWith(pattern.slice(0, -1)) && !dir.slice(pattern.length - 1).includes('/') : dir === pattern);
|
|
119
|
+
const excluded = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
|
|
120
|
+
const dirs = [];
|
|
121
|
+
for (const pattern of patterns.filter((p) => !p.startsWith('!'))) {
|
|
122
|
+
if (!pattern.endsWith('/*')) {
|
|
123
|
+
dirs.push(pattern);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const parent = pattern.slice(0, -2);
|
|
127
|
+
const full = posix.join(root, parent);
|
|
128
|
+
if (!existsSync(full))
|
|
129
|
+
continue;
|
|
130
|
+
for (const entry of readdirSync(full).sort()) {
|
|
131
|
+
if (existsSync(posix.join(full, entry, 'package.json')))
|
|
132
|
+
dirs.push(`${parent}/${entry}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return dirs.filter((dir) => !excluded.some((pattern) => matches(pattern, dir)));
|
|
136
|
+
};
|
|
137
|
+
const scriptsOf = (root, dir) => {
|
|
138
|
+
const file = posix.join(dir, 'package.json');
|
|
139
|
+
if (!existsSync(posix.join(root, file)))
|
|
140
|
+
return {};
|
|
141
|
+
let parsed;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(read(root, file));
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
throw new Error(`${file} is not JSON: ${e.message}`);
|
|
147
|
+
}
|
|
148
|
+
const scripts = typeof parsed === 'object' && parsed !== null ? parsed.scripts : undefined;
|
|
149
|
+
if (typeof scripts !== 'object' || scripts === null)
|
|
150
|
+
return {};
|
|
151
|
+
return Object.fromEntries(Object.entries(scripts).filter((entry) => typeof entry[1] === 'string'));
|
|
152
|
+
};
|
|
153
|
+
/** The body of the dispatcher case that names `target` by any of its aliases, or '' when none does. */
|
|
154
|
+
export const dispatcherCase = (dispatch, target) => {
|
|
155
|
+
for (const match of dispatch.matchAll(/^\s*([a-z][a-z0-9|-]*)\)\n([\s\S]*?)^\s*;;/gm)) {
|
|
156
|
+
if (match[1].split('|').includes(target))
|
|
157
|
+
return match[2];
|
|
158
|
+
}
|
|
159
|
+
return '';
|
|
160
|
+
};
|
|
161
|
+
/** Every alias a dispatcher case line offers: `a|b)` offers both. */
|
|
162
|
+
export const dispatcherTargets = (dispatch) => new Set([...dispatch.matchAll(/^\s*([a-z][a-z0-9|-]*)\)/gm)].flatMap((line) => line[1].split('|')));
|
|
163
|
+
/**
|
|
164
|
+
* The patterns for what a command can name. `prefixes` are folder paths
|
|
165
|
+
* ending in `/`, where a `*` segment is one folder name; `bin`, when set, is
|
|
166
|
+
* the folder of installed binaries, read only in command position: a line's
|
|
167
|
+
* first word, or one after a separator, `!`, a shell keyword that starts a
|
|
168
|
+
* command (`if`, `elif`, `else`, `then`, `do`, `while`, `until`) or `exec`,
|
|
169
|
+
* past any VAR=value prefixes. `test -x <bin>/x` runs nothing.
|
|
170
|
+
*/
|
|
171
|
+
export const runnablePatterns = (prefixes, extensions, bin) => {
|
|
172
|
+
const folder = (prefix) => prefix
|
|
173
|
+
.slice(0, -1)
|
|
174
|
+
.split('/')
|
|
175
|
+
.map((segment) => (segment === '*' ? '[\\w-]+' : escape(segment)))
|
|
176
|
+
.join('\\/');
|
|
177
|
+
const alternatives = [...prefixes].sort((a, b) => b.length - a.length).map((p) => `${folder(p)}\\/`);
|
|
178
|
+
const patterns = [new RegExp(`(?<![\\w./-])((?:\\.\\.?\\/)*(?:${alternatives.join('|')})[\\w./-]*[\\w-]\\.(?:${extensions.map(escape).join('|')}))(?![\\w])`, 'g')];
|
|
179
|
+
if (bin !== undefined) {
|
|
180
|
+
patterns.push(new RegExp(`(?:^|[;&|(!]|\\b(?:if|elif|else|then|do|while|until|exec)\\b)[^\\S\\n]*(?:\\w+=\\S*[^\\S\\n]+)*["']?((?:\\.\\.?\\/)*${escape(bin)}\\/[\\w-]+)(?![\\w./-])`, 'gm'));
|
|
181
|
+
}
|
|
182
|
+
return patterns;
|
|
183
|
+
};
|
|
184
|
+
/** Root-relative paths a command names, resolved from the directory it runs in; a path that climbs out of the root is dropped. */
|
|
185
|
+
export const pathsIn = (text, cwd, runnable) => {
|
|
186
|
+
const paths = [];
|
|
187
|
+
for (const pattern of runnable) {
|
|
188
|
+
for (const match of text.matchAll(pattern)) {
|
|
189
|
+
const resolved = posix.normalize(posix.join(cwd, match[1]));
|
|
190
|
+
if (!resolved.startsWith('../'))
|
|
191
|
+
paths.push(resolved);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return paths;
|
|
195
|
+
};
|
|
196
|
+
/** Is `path` a file directly in one of the hooks folders? */
|
|
197
|
+
const isHook = (path, hooks) => hooks.some((dir) => path.startsWith(`${dir}/`) && !path.slice(dir.length + 1).includes('/'));
|
|
198
|
+
/** Walks every stage root and returns what it reaches. */
|
|
199
|
+
export const resolveStageReach = (root, config) => {
|
|
200
|
+
const workspaces = workspaceDirs(root, config.workspaceFile);
|
|
201
|
+
const turbo = config.turbo === undefined ? '' : read(root, config.turbo);
|
|
202
|
+
const dispatch = read(root, config.dispatcher);
|
|
203
|
+
const reach = { files: new Set(), scripts: new Map(), workspaces };
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
const runner = config.packageRunner;
|
|
206
|
+
const runnerCall = new RegExp(`${escape(runner?.call ?? '')}\\s+"([\\w-]+)"`, 'g');
|
|
207
|
+
const visitScript = (dir, name) => {
|
|
208
|
+
const scripts = scriptsOf(root, dir);
|
|
209
|
+
// Own keys only: `pnpm constructor` names no script, not Object's.
|
|
210
|
+
const command = Object.hasOwn(scripts, name) ? scripts[name] : undefined;
|
|
211
|
+
const key = `${dir}\t${name}`;
|
|
212
|
+
if (command === undefined || reach.scripts.has(key))
|
|
213
|
+
return;
|
|
214
|
+
reach.scripts.set(key, command);
|
|
215
|
+
visitText(command, dir);
|
|
216
|
+
};
|
|
217
|
+
const visitFile = (path, cwd) => {
|
|
218
|
+
const key = `${path}\t${cwd}`;
|
|
219
|
+
if (path === config.dispatcher || seen.has(key))
|
|
220
|
+
return;
|
|
221
|
+
seen.add(key);
|
|
222
|
+
const full = posix.join(root, path);
|
|
223
|
+
if (!existsSync(full) || !statSync(full).isFile())
|
|
224
|
+
return;
|
|
225
|
+
reach.files.add(path);
|
|
226
|
+
if (/\.ya?ml$/.test(path)) {
|
|
227
|
+
let commands;
|
|
228
|
+
try {
|
|
229
|
+
commands = yamlCommands(read(root, path), config.actions);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
throw new Error(`${path} is not YAML: ${e.message}`);
|
|
233
|
+
}
|
|
234
|
+
for (const action of commands.actions) {
|
|
235
|
+
for (const name of ['action.yml', 'action.yaml'])
|
|
236
|
+
visitFile(`${action}/${name}`, ROOT_DIR);
|
|
237
|
+
}
|
|
238
|
+
// A workflow or action runs from the checkout, so a step's folder is root-relative.
|
|
239
|
+
for (const step of commands.runs)
|
|
240
|
+
visitText(step.text, step.dir);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (!path.endsWith('.sh') && !isHook(path, config.hooks))
|
|
244
|
+
return;
|
|
245
|
+
const text = codeLines(read(root, path));
|
|
246
|
+
if (runner !== undefined && path === runner.path) {
|
|
247
|
+
for (const match of text.matchAll(runnerCall))
|
|
248
|
+
visitFile(`${runner.validatorsDir}/${match[1]}.sh`, cwd);
|
|
249
|
+
}
|
|
250
|
+
visitText(text, cwd);
|
|
251
|
+
};
|
|
252
|
+
const visitText = (raw, cwd) => {
|
|
253
|
+
const text = codeLines(raw);
|
|
254
|
+
for (const path of pathsIn(text, cwd, config.runnable))
|
|
255
|
+
visitFile(path, cwd);
|
|
256
|
+
for (const match of text.matchAll(new RegExp(config.dispatchCall.source, 'g'))) {
|
|
257
|
+
visitText(dispatcherCase(dispatch, match[1]), ROOT_DIR);
|
|
258
|
+
}
|
|
259
|
+
for (const match of text.matchAll(/\bpnpm\s+(?:run\s+)?([a-z][\w:.-]*)/g))
|
|
260
|
+
visitScript(cwd, match[1]);
|
|
261
|
+
for (const match of text.matchAll(/\bturbo\s+run((?:\s+[^-\s|&;>][^\s|&;>]*)+)/g)) {
|
|
262
|
+
for (const task of match[1].trim().split(/\s+/)) {
|
|
263
|
+
if (task.startsWith('//#')) {
|
|
264
|
+
visitScript(ROOT_DIR, task.slice(3));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
for (const dir of workspaces)
|
|
268
|
+
visitScript(dir, task);
|
|
269
|
+
if (turbo.includes(`"//#${task}"`))
|
|
270
|
+
visitScript(ROOT_DIR, task);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const roots = [];
|
|
275
|
+
for (const [dirs, isRoot] of [
|
|
276
|
+
[config.hooks, (entry) => !entry.startsWith('_')],
|
|
277
|
+
[config.workflows, (entry) => /\.ya?ml$/.test(entry)],
|
|
278
|
+
]) {
|
|
279
|
+
for (const dir of dirs) {
|
|
280
|
+
const full = posix.join(root, dir);
|
|
281
|
+
if (!existsSync(full))
|
|
282
|
+
continue;
|
|
283
|
+
for (const entry of readdirSync(full).sort())
|
|
284
|
+
if (isRoot(entry))
|
|
285
|
+
roots.push(`${dir}/${entry}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const path of roots)
|
|
289
|
+
visitFile(path, ROOT_DIR);
|
|
290
|
+
return reach;
|
|
291
|
+
};
|
|
292
|
+
/** The workspace a path belongs to: the longest declared prefix, else the root. */
|
|
293
|
+
const ownerOf = (path, workspaces) => workspaces.filter((dir) => path.startsWith(`${dir}/`)).sort((a, b) => b.length - a.length)[0] ?? ROOT_DIR;
|
|
294
|
+
/** Is a cited test file run by a test runner some stage starts in its workspace? */
|
|
295
|
+
export const testFileRuns = (path, reach, config) => {
|
|
296
|
+
if (!config.testFile.test(path))
|
|
297
|
+
return false;
|
|
298
|
+
const owner = ownerOf(path, reach.workspaces);
|
|
299
|
+
return [...reach.scripts].some(([key, command]) => key.startsWith(`${owner}\t`) && config.testRunner.test(command));
|
|
300
|
+
};
|
|
301
|
+
/** Every live validator claim whose named scripts no stage reaches. */
|
|
302
|
+
export const findUncalledValidators = (root, standards, config, given) => {
|
|
303
|
+
const claims = standards.filter((s) => s.enforcement === 'validator' && !s.deprecated);
|
|
304
|
+
// Walked only when some claim needs it: a corpus with no live validator
|
|
305
|
+
// claim has nothing to resolve, and its tree need not be a workspace.
|
|
306
|
+
if (claims.length === 0)
|
|
307
|
+
return [];
|
|
308
|
+
const reach = given ?? resolveStageReach(root, config);
|
|
309
|
+
const dispatch = read(root, config.dispatcher);
|
|
310
|
+
const problems = [];
|
|
311
|
+
for (const standard of claims) {
|
|
312
|
+
const target = config.dispatchCall.exec(standard.enforcementConfig);
|
|
313
|
+
// Once each: the script and the dispatcher target usually name one file.
|
|
314
|
+
const named = [
|
|
315
|
+
...new Set([
|
|
316
|
+
...(standard.enforcementScript === '' ? [] : [posix.normalize(standard.enforcementScript)]),
|
|
317
|
+
...(target === null ? [] : pathsIn(dispatcherCase(dispatch, target[1]), ROOT_DIR, config.runnable)),
|
|
318
|
+
]),
|
|
319
|
+
];
|
|
320
|
+
const report = (rule, reason) => {
|
|
321
|
+
problems.push({ file: standard.file, id: standard.id, rule, reason });
|
|
322
|
+
};
|
|
323
|
+
if (named.length === 0) {
|
|
324
|
+
report(standard.enforcementConfig || '(empty)', 'claims validator enforcement but names no script — nothing here can run');
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const missing = named.filter((path) => !existsSync(posix.join(root, path)));
|
|
328
|
+
for (const path of missing)
|
|
329
|
+
report(path, 'does not exist');
|
|
330
|
+
if (missing.length > 0)
|
|
331
|
+
continue;
|
|
332
|
+
if (!named.some((path) => reach.files.has(path) || testFileRuns(path, reach, config))) {
|
|
333
|
+
report(named.join(', '), 'is never run by a hook or workflow — the claim names a validator nothing calls');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return problems;
|
|
337
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/pariksha",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, codec-registry, coverage-by-type, coverage-exemptions, doc-budgets, env-gate-switches, export-deletions, fault-injection-registry, required-settings. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
|
|
3
|
+
"version": "0.5.12",
|
|
4
|
+
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, codec-registry, coverage-by-type, coverage-exemptions, doc-budgets, enforcement-claims, env-gate-switches, export-deletions, fault-injection-registry, required-settings. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|
|
@@ -38,16 +38,24 @@
|
|
|
38
38
|
"dist",
|
|
39
39
|
"baselines"
|
|
40
40
|
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"js-yaml": "4.3.2"
|
|
43
|
+
},
|
|
41
44
|
"peerDependencies": {
|
|
45
|
+
"eslint": ">=9.24 <10",
|
|
42
46
|
"typescript": ">=5.4 <6"
|
|
43
47
|
},
|
|
44
48
|
"peerDependenciesMeta": {
|
|
49
|
+
"eslint": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
45
52
|
"typescript": {
|
|
46
53
|
"optional": true
|
|
47
54
|
}
|
|
48
55
|
},
|
|
49
56
|
"devDependencies": {
|
|
50
57
|
"@types/node": "24.19.2",
|
|
58
|
+
"eslint": "9.39.1",
|
|
51
59
|
"typescript": "5.9.3"
|
|
52
60
|
},
|
|
53
61
|
"scripts": {
|