@veda-hq/pariksha 0.5.10 → 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 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.10 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.10 --version
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
@@ -477,7 +576,11 @@ is the permission baseline every repository agents work in keeps:
477
576
  - deny: `Bash(*--no-verify*)`;
478
577
  - ask: every push that rewrites or deletes published history (`--force`,
479
578
  `-f`, `--mirror`, `--delete`, `-d`, `--prune`, a `+` or empty-source `:`
480
- refspec, and the abbreviations git accepts), `git branch -D`, and the
579
+ refspec, and the abbreviations git accepts), a forced branch delete
580
+ (`git branch -D`, `-df`, `-fd`, and any `-d`/`--delete` with
581
+ `-f`/`--force` in either order: the two globs `*-d*-f*` and `*-f*-d*`
582
+ also ask for a plain `-d` of a branch whose name holds `-f`, which errs
583
+ on the side of asking), and the
481
584
  dependency commands `pnpm add`, `remove`, `update`, `npm install`,
482
585
  `uninstall`, `update`, `yarn add` and `remove`. Ask beats allow, so these
483
586
  hold inside the allowed `git push -u origin` and `git branch`. An edit of a
@@ -46,6 +46,10 @@
46
46
  "Bash(*git push* +*)",
47
47
  "Bash(*git push* :*)",
48
48
  "Bash(*git branch* -D*)",
49
+ "Bash(*git branch*-d*-f*)",
50
+ "Bash(*git branch*-f*-d*)",
51
+ "Bash(*git branch* -df*)",
52
+ "Bash(*git branch* -fd*)",
49
53
  "Bash(pnpm add:*)",
50
54
  "Bash(pnpm remove:*)",
51
55
  "Bash(pnpm update:*)",
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.10",
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": {