@veda-hq/pariksha 0.5.13 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ @veda-hq/pariksha includes third-party code:
2
+
3
+ - src/semver-range.ts (dist/semver-range.js) is ported from semver 7.8.1
4
+ (npm `semver`, https://github.com/npm/node-semver), ISC licence,
5
+ Copyright (c) Isaac Z. Schlueter and Contributors. The licence text is in
6
+ the file's header.
package/README.md CHANGED
@@ -4,14 +4,87 @@
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.13 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.13 --version
7
+ npx @veda-hq/pariksha@0.5.17 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.17 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
12
- (each finding on stderr, with how to fix it), and 2 on a usage or input
13
- error. `--root <dir>` (default: the current directory) names the repository.
14
- Node 24 or later. MIT.
12
+ (each finding on stderr, with how to fix it; `absolute-paths` and
13
+ `non-synced-resources` print theirs on stdout, as the shell checks they were
14
+ ported from did), and 2 on a usage or input error. `--root <dir>` (default:
15
+ the current directory) names the repository. Node 24 or later. MIT; one
16
+ ported file is ISC, see `NOTICE`.
17
+
18
+ ## `absolute-paths`
19
+
20
+ ```sh
21
+ pariksha absolute-paths [--config <file>] [--files-from <file>|-] [--root <dir>]
22
+ ```
23
+
24
+ A document that points at `/Users/me/notes.md` points at something only its
25
+ writer has. This refuses a line naming a path on a local machine: `file://`,
26
+ `/Users/`, `/home/<name>/`, `/tmp/<x>`, `/var/<x>`, `/private/var`, `~/`, or a
27
+ drive (`C:\x`). Not refused: a line inside a fenced block (between lines that
28
+ start with three backticks), a path in an inline code span, and a line that
29
+ says it is an example (`forbidden`, `example`, `not allowed`, `should not`,
30
+ `must not`, in any case). Only the first path on a line is tested for a code
31
+ span, and a `/tmp/<x>` or `/var/<x>` path runs to the next `/`, so
32
+ `` `/tmp/x` `` alone on a line is refused: the shell check this was ported
33
+ from read it so.
34
+
35
+ The documents are the ones `--files-from` lists, one path per line (a file,
36
+ or `-` for stdin; relative paths are read from the root), or with no list
37
+ every file git tracks; a listed path that is not a regular file is dropped.
38
+ The JSON `--config` (relative to the root) is optional:
39
+
40
+ ```json
41
+ {
42
+ "include": "\\.(md|mdx)$",
43
+ "guidance": ["Please use relative paths only.", "See docs/WRITING.md."]
44
+ }
45
+ ```
46
+
47
+ - `include` (default `\.(md|mdx)$`) is the regex a listed path must match to
48
+ be a document.
49
+ - `guidance` (default one line asking for relative paths) is printed after a
50
+ refusal.
51
+
52
+ The report goes to stdout: `→ Checking …`, `Checking N documentation
53
+ file(s)...`, then each finding as `file:line:text`. It exits 1 on a finding,
54
+ 0 with none or nothing to check, and 2 on a bad config, an unreadable list,
55
+ or no list outside a git repository.
56
+
57
+ ## `aggregate-gate-needs`
58
+
59
+ ```sh
60
+ pariksha aggregate-gate-needs [--config <file>] [--root <dir>]
61
+ ```
62
+
63
+ A merge queue that requires one aggregate status, a workflow's "CI Gate",
64
+ lets through any job that gate does not wait on: a job missing from its
65
+ `needs:` cannot block a merge however it fails, and an upstream failure turns
66
+ into a skip downstream that the gate accepts. Each shows a green required
67
+ check. In every `.yml` and `.yaml` workflow, a gate (a job whose `name:`, or
68
+ id, matches `gateName`) is refused when its `needs:` leaves out a job other
69
+ than another gate or a `nonGating` one, or when it reads a job's result
70
+ without declaring it: `needs.<id>` or `needs['<id>']` in the job's `if:`, a
71
+ step's `if:`, or any `${{ … }}` in the job, outside quoted literals. Text in
72
+ a `run:` script and an action input named `if` are not reads. A non-gating
73
+ job (a change detector) need not be gated, but must be declared once read.
74
+
75
+ ```json
76
+ { "id": "CI-001", "workflowsDir": ".github/workflows", "gateName": "gate$", "nonGating": ["detect-changes"] }
77
+ ```
78
+
79
+ - `id` (default `aggregate-gate-needs`) names the check in its messages: a
80
+ project's rule id, say.
81
+ - `workflowsDir` (default `.github/workflows`, relative to the root).
82
+ - `gateName` (default `gate$`) is a regex, matched case-insensitively.
83
+ - `nonGating` (default none) lists job ids that gate nothing.
84
+
85
+ A workflow that does not parse is reported as a gap, never passed. No
86
+ workflow to read exits 1 (`no workflow files found — the lookup failed`),
87
+ as the original did; a bad config exits 2.
15
88
 
16
89
  ## `authority-citations`
17
90
 
@@ -263,6 +336,137 @@ row that is not `` | `path` | bytes | ``, since nothing would enforce it.
263
336
  The table file is read on every check too, so it must budget itself: a row
264
337
  for its own path (relative to `--root`) is required.
265
338
 
339
+ ## `doc-references`
340
+
341
+ ```sh
342
+ pariksha doc-references --config <file> --staged [--checks <ids>] [--root <dir>]
343
+ pariksha doc-references --config <file> --base <ref> [--head <ref>] [--checks <ids>] [--root <dir>]
344
+ pariksha doc-references --config <file> --all [--update-baseline | --write-baseline] [--checks <ids>] [--root <dir>]
345
+ ```
346
+
347
+ A doc that names a file, a script or a function the repository no longer
348
+ has reads as true and sends its reader into a broken step. Three checks read
349
+ the docs and code comments the config's `files` select:
350
+
351
+ - `DOC-DRIFT-01`, paths and links. A path in backticks (in prose, a shell
352
+ fence, frontmatter or a code comment), a glob, or `file.ts:12` resolves
353
+ beside the doc, above it, at the root or from a workspace; a cited line
354
+ exists, and a backticked symbol beside it is near that line. Relative
355
+ Markdown links and their `#anchor`s are checked by
356
+ [lychee](https://lychee.cli.rs), offline, which must be on `PATH` (set
357
+ `links` to `null` to leave links out). A path the tracked `.gitignore`
358
+ rules cover is out of scope.
359
+ - `DOC-DRIFT-02`, commands. A `pnpm <script>` exists in the scope it runs
360
+ from (a workspace script needs `--filter`; bins in `node_modules/.bin`
361
+ count everywhere), and a verification step does not run a script that
362
+ rewrites the tree (`mutating`). A command a doc shows runs as written:
363
+ `--filter` before the script name, every `--flag` read by the script it
364
+ reaches (through a package script or an interpreter), required argparse
365
+ flags present, enum values and first-positional actions the script
366
+ accepts, and, with `typescriptRunner` set, a `.ts` file started through it.
367
+ - `DOC-DRIFT-04`, identifiers. A single-identifier code span in prose
368
+ (`DATABASE_URL`, `parseArgs()`, `OrderService`) occurs as a whole word in
369
+ the tracked code, or in the file the doc cites for it ("`x` in `y.ts`").
370
+ Optional kinds: Prisma `Model.field`, `@@unique([…])`, migration folders and
371
+ `@Roles(…)` values; routes (`GET /stores/:id`) against controller
372
+ decorators; CI jobs ("the `build` job"); packages against workspace names
373
+ and the lockfile.
374
+
375
+ A line ending in `<!-- doc-ref-ok: <why> -->` (in code, a comment holding
376
+ `doc-ref-ok: <why>`; the word is the config's `exemptMarker`) names
377
+ something absent on purpose. Placeholders (`<id>`, `NNN`) and illustrative
378
+ names (`src/foo.ts`) are never references.
379
+
380
+ Modes. `--staged` (a commit) reads the index: staged docs and comments,
381
+ and any doc naming a path the commit deletes or renames, under
382
+ DOC-DRIFT-01 and -02. During a merge, what the merge took unchanged from the
383
+ merged branch is that branch's to judge. `--base` (a push, CI) reads the
384
+ `--head` tree (default `HEAD`) against its merge base with `--base`: changed
385
+ docs, and docs naming a deleted or renamed path, a changed file by
386
+ `name:line`, or an identifier, script or `--flag` the diff removed, under
387
+ all three checks; a finding the merge base already had is not this push's.
388
+ `--all` reads every reference file in the worktree; it alone may lower the
389
+ baseline (`--update-baseline`) or seed it (`--write-baseline`). `--checks`
390
+ takes a comma-separated subset (`01,02`).
391
+
392
+ The baseline is a JSON file of per-file counts, `{ "version": 1, "files":
393
+ { "docs/old.md": { "DOC-DRIFT-01": 3 } } }`, for references that predate
394
+ the check. A checked file may not exceed its count; a file a change touches
395
+ must lower a count it no longer needs; the baseline may only lose entries or
396
+ lower counts against the merge base; an entry for a file the change removed
397
+ must go. The JSON `--config` (relative to the root):
398
+
399
+ ```json
400
+ {
401
+ "baseline": "scripts/doc-references-baseline.json",
402
+ "exemptMarker": "doc-ref-ok",
403
+ "files": {
404
+ "markdown": ["\\.(md|mdx)$"],
405
+ "html": ["^(?:.*/)?docs/.*\\.html$"],
406
+ "code": ["\\.(ts|tsx|js|mjs|sh|py)$"],
407
+ "codeExclude": ["\\.(test|spec)\\.[jt]sx?$"],
408
+ "exclude": ["(^|/)node_modules/", "(^|/)fixtures?/", "^docs/history/"],
409
+ "hashComments": "\\.(sh|py)$"
410
+ },
411
+ "placeholder": "[<>${}%…]|\\.\\.\\.|NNN",
412
+ "paths": {
413
+ "extensions": ["ts", "js", "json", "md", "sh", "py", "yml", "yaml"],
414
+ "examples": { "pattern": "(^|/)(foo|bar|example)(\\.[\\w.]+)?$", "flags": "i" },
415
+ "untracked": [".claude/worktrees/"],
416
+ "workspaces": "^(apps|packages)/[^/]+/package\\.json$",
417
+ "layouts": [{ "pattern": "^evidence/[A-Z]-\\d{2}", "except": "^docs/work/" }]
418
+ },
419
+ "links": { "include": "\\.md$" },
420
+ "scripts": {
421
+ "manifests": ["package.json", "apps/api/package.json"],
422
+ "mutating": { "lint": "lint:ci", "format": "format:check" },
423
+ "verification": { "pattern": "verif|check|gate", "flags": "i" },
424
+ "wrappers": []
425
+ },
426
+ "commands": { "typescriptRunner": "pnpm exec tsx", "nested": ["vitest", "turbo", "git"] },
427
+ "identifiers": {
428
+ "notIdentifiers": ["GitHub", "TypeScript"],
429
+ "searchExclude": ["\\.(md|mdx|html)$", "^docs/", "^pnpm-lock\\.yaml$"],
430
+ "diffExclude": ["*.md", "docs/**"],
431
+ "prisma": { "schemas": "^apps/api/prisma/.*\\.prisma$", "migrations": "apps/api/prisma/migrations", "roles": { "schema": "apps/api/prisma/schema.prisma", "enum": "Role" } },
432
+ "routes": { "controllers": "^apps/api/src/.*\\.controller\\.ts$", "prefixes": "^(api|v\\d+)$" },
433
+ "jobs": { "workflows": "^\\.github/workflows/[^/]+\\.ya?ml$" },
434
+ "packages": { "manifests": "^(?!(?:.*/)?node_modules/)(?:.*/)?package\\.json$", "lockfile": "pnpm-lock.yaml", "ownScopes": ["@acme/"] }
435
+ }
436
+ }
437
+ ```
438
+
439
+ - `files`: what a tracked path is, first match winning after `exclude`:
440
+ Markdown (and Markdown-like XML), HTML pages, or code whose comments are
441
+ read (`codeExclude` drops tests and fixtures, which name absent paths on
442
+ purpose). `hashComments` is the code whose comments start with `#`.
443
+ - `paths`: the extensions a backticked path may end in; `examples` and
444
+ `untracked` names that are never a tracked file; `workspaces`, the
445
+ manifests whose folders (and `src`) a path also resolves from;
446
+ `layouts`, paths that name a folder layout rather than a file outside the
447
+ docs `except` matches.
448
+ - `scripts.wrappers`: repository scripts that run the rest of a line as a
449
+ command (`pnpm dev:exec pnpm x`). `commands.nested`: programs a wrapper
450
+ script hands its remaining words to, so they are not its own flags.
451
+ - `identifiers.searchExclude`: tracked text that defines no name (prose,
452
+ data, sources that quote wrong names on purpose); binaries and the
453
+ baseline are never read. `diffExclude`: pathspecs the removed-identifier
454
+ diff leaves out.
455
+ - `links`, `commands.typescriptRunner`, and each of `identifiers.prisma`
456
+ (and its `migrations` and `roles`), `routes`, `jobs`, `packages` (and its
457
+ `lockfile`) may be `null`, which leaves that part unchecked and says so on
458
+ stdout. Absent, each is refused.
459
+
460
+ On a pass, stdout has one line, `pariksha doc-references: <n> reference
461
+ file(s) checked (<staged|pushed|all>), no new problem; the baseline does not
462
+ grow`. Each refusal is a stderr line, `pariksha doc-references:
463
+ <file>:<line>: [<check>] <ref>: <reason>`, then what to do. Refused (exit 1):
464
+ a reference over its file's count, a count a touched file no longer needs,
465
+ baseline growth, and an entry for a removed file. Exit 2: a bad flag set or
466
+ config (an unknown, missing or bad key or pattern), a malformed baseline, no
467
+ `node_modules/.bin` (DOC-DRIFT-02 would know no bin), a missing or failing
468
+ lychee, a `--base` with no merge base, or a failed git call.
469
+
266
470
  ## `enforcement-claims`
267
471
 
268
472
  ```sh
@@ -536,6 +740,77 @@ root-relative POSIX path, a check not in `checks`, an `exemptIn` group
536
740
  baseline, a root holding no tracked file, a group discovering nothing, no
537
741
  base without `--no-base`, a failed git call, or no `typescript`.
538
742
 
743
+ ## `non-synced-resources`
744
+
745
+ ```sh
746
+ pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]
747
+ ```
748
+
749
+ A link to a file that was never committed works for its writer and for
750
+ nobody else. Every `[text](dest)` and `![alt](dest)` on a line of a document
751
+ (inside code blocks too) is read, except `http(s)://`, `mailto:` and `#`
752
+ links. The destination loses its `#fragment` and `?query`, then is the text
753
+ inside `<…>` or the text before a space (a title); one starting with `/` is
754
+ read from the repository's top folder. It is refused when its folder does
755
+ not exist, when it lands outside the repository, when nothing is there, or
756
+ when the name on disk differs in case (macOS opens `./README.MD` as
757
+ `./readme.md`; Linux and CI do not). An image over 1 MiB is warned about,
758
+ never refused. Paths are joined and split as the shell check this was ported
759
+ from joined them (`dirname`, `basename`, `cd && pwd`), so what counts as
760
+ inside the repository is unchanged.
761
+
762
+ The documents, the config (`include`, `guidance`) and the report on stdout
763
+ are as for [`absolute-paths`](#absolute-paths); a document listed by absolute
764
+ path has its links read from its own folder. With `--files-from`, a
765
+ repository is needed only when there is a document to read; without it, the
766
+ documents come from `git ls-files`, so a root that is not a repository exits
767
+ 2 even when it holds no document.
768
+
769
+ ## `pnpm-settings`
770
+
771
+ ```sh
772
+ pariksha pnpm-settings [--config <file>] [--root <dir>]
773
+ ```
774
+
775
+ pnpm 11 stopped reading the `pnpm` field of package.json without a warning
776
+ (pnpm/pnpm#11536): an `overrides` or `patchedDependencies` left there gives a
777
+ green install in which every security pin has reverted and every patch has
778
+ stopped applying. A green install proves nothing, so this reads the resolved
779
+ `pnpm-lock.yaml` (the last of its YAML documents, which pnpm 12 writes after
780
+ its own) and refuses:
781
+
782
+ 1. a setting pnpm 11 ignores still in `package.json#pnpm`;
783
+ 2. an override in `pnpm-workspace.yaml` the lockfile does not record, records
784
+ differently, or still records after it was removed;
785
+ 3. the same for a patch, a patch file that is not on disk, or one whose
786
+ sha256 is not the one the lockfile records;
787
+ 4. a resolved version inside the range an override excludes
788
+ (`tar@<=7.5.20`), or, for a bare-name override (`tar: '>=7.5.21'`),
789
+ outside the range it forces. Prereleases count: `7.5.20-beta.1` is inside
790
+ `<=7.5.20`;
791
+ 5. a `minimumReleaseAgeExclude` entry that is not one exact version the
792
+ lockfile still resolves, or whose list has no comment right above it, no
793
+ `EXPIRES: <ISO-8601>` line, or an expiry already past. `ownScopes` entries
794
+ are standing and exempt.
795
+
796
+ ```json
797
+ { "id": "CFG-005", "ownScopes": ["@your-scope/*"] }
798
+ ```
799
+
800
+ - `id` (default `pnpm-settings`) names the check in its messages.
801
+ - `ownScopes` (default none) lists `minimumReleaseAgeExclude` entries asked
802
+ for no expiry or exact version: a project's own scopes, released by its own
803
+ CI.
804
+
805
+ Ranges are read by pariksha's own matcher, a port of the `semver` package's
806
+ range reading (strict mode, prereleases included): comparator sets joined by
807
+ `||`, comparators joined by spaces, and the same sugar (`^`, `~`, `1.x`,
808
+ `*`, partial versions such as `<8`, hyphen ranges). A range `semver` would
809
+ refuse is not passed: it is printed as a selector not version-checked.
810
+ `semver`'s loose mode is not read. Notes and failures go to stderr, the clean
811
+ line to stdout; a missing input file is a failure (exit 1), a bad config or
812
+ an unreadable `package.json` exits 2.
813
+
539
814
  ## `required-settings`
540
815
 
541
816
  ```sh
@@ -0,0 +1,59 @@
1
+ // absolute-paths: no document names a path on the writer's own machine.
2
+ //
3
+ // A doc that says `see /Users/me/code/app/notes.md` or `file:///home/me/x`
4
+ // points at something nobody else has. This check refuses a line of prose
5
+ // that holds such a path: `file://`, `/Users/`, `/home/<name>/`, `/tmp/<x>`,
6
+ // `/var/<x>`, `/private/var`, `~/`, or a drive (`C:\x`). Not refused: a line
7
+ // inside a fenced block (between lines that start with three backticks), a
8
+ // path inside an inline code span, and a line that says it is an example
9
+ // ("forbidden", "example", "not allowed", "should not", "must not", any case).
10
+ //
11
+ // Ported from a shell check unchanged, quirks included: only the first path on
12
+ // a line is tested for a code span, and that path runs to the next `/`, so a
13
+ // span's closing backtick may fall inside it.
14
+ import { linesOf } from "./doc-files.js";
15
+ /** A local path, as the shell check's grep and awk matched it. */
16
+ export const LOCAL_PATH = /(file:\/\/|\/Users\/|\/home\/[^/]+\/|C:\\[^/]|\/tmp\/[^/]+|\/var\/[^/]+|\/private\/var|~\/|[A-Z]:\\[^/])/;
17
+ /** A line that says it shows what not to do. */
18
+ export const EXAMPLE_LINE = /(forbidden|example|not allowed|should not|must not)/i;
19
+ export const DEFAULT_GUIDANCE = ['Please use relative paths only.'];
20
+ const FENCE = /^```/;
21
+ const backticks = (text) => text.split('`').length - 1;
22
+ /**
23
+ * Whether the first local path on `line` sits inside an inline code span: an
24
+ * odd number of backticks before it and at least one after it.
25
+ */
26
+ export const inBackticks = (line) => {
27
+ const match = LOCAL_PATH.exec(line);
28
+ if (match === null)
29
+ return false;
30
+ const before = backticks(line.slice(0, match.index));
31
+ const after = backticks(line.slice(match.index + match[0].length));
32
+ return before > 0 && after > 0 && before % 2 === 1;
33
+ };
34
+ /** `file:line:text` for each refused line of one document. */
35
+ export const findInDoc = (file, text) => {
36
+ const found = [];
37
+ let inFence = false;
38
+ linesOf(text).forEach((line, index) => {
39
+ if (FENCE.test(line)) {
40
+ inFence = !inFence;
41
+ return;
42
+ }
43
+ if (inFence || !LOCAL_PATH.test(line) || inBackticks(line) || EXAMPLE_LINE.test(line))
44
+ return;
45
+ found.push(`${file}:${index + 1}:${line}`);
46
+ });
47
+ return found;
48
+ };
49
+ /** The report for `docs` (path and text, in order), printed as the shell check printed it. */
50
+ export const checkAbsolutePaths = (docs, config) => {
51
+ const out = ['→ Checking for absolute paths in documentation...'];
52
+ if (docs.length === 0)
53
+ return { ok: true, out: [...out, '✅ No documentation files to check.'] };
54
+ out.push(` Checking ${docs.length} documentation file(s)...`);
55
+ const found = docs.flatMap((doc) => findInDoc(doc.file, doc.text()));
56
+ if (found.length === 0)
57
+ return { ok: true, out: [...out, '✅ No absolute paths found in documentation'] };
58
+ return { ok: false, out: [...out, '', '❌ Error: Absolute paths or local file references found:', '', ...found, '', ...config.guidance] };
59
+ };
@@ -0,0 +1,153 @@
1
+ // aggregate-gate-needs: an aggregate gate depends on every job it gates.
2
+ //
3
+ // A merge queue that requires one status, a workflow's "CI Gate", lets
4
+ // through anything that gate does not wait on: a job missing from its
5
+ // `needs:` cannot block a merge however it fails, and an upstream failure
6
+ // becomes a downstream skip the gate accepts. Every such mistake shows a green
7
+ // required check, so this refuses, in each workflow, a gate (a job whose id or
8
+ // `name:` matches the config's `gateName`) whose `needs:` leaves out a job
9
+ // other than another gate or one the config calls non-gating. A non-gating job
10
+ // (a change detector, say) is exempt from "must be gated", not from "must be
11
+ // declared when read": a gate whose `if:` or `${{ }}` reads `needs.<id>` or
12
+ // `needs['<id>']` without declaring it gets an undefined output and skips.
13
+ //
14
+ // Read as conditions: the job's `if:` and each step's `if:`, and every
15
+ // `${{ … }}` in the job. Not read: text in a `run:` script, quoted literals in
16
+ // an expression, and an action input that happens to be called `if`. A
17
+ // workflow that does not parse is reported, never read as having no gaps.
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { load as parseYaml } from 'js-yaml';
21
+ export const DEFAULT_GATE_CONFIG = {
22
+ workflowsDir: '.github/workflows',
23
+ gateName: /gate$/i,
24
+ nonGating: new Set(),
25
+ id: 'aggregate-gate-needs',
26
+ };
27
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
28
+ /**
29
+ * `{ "id"?: "<label>", "workflowsDir"?: "<dir>", "gateName"?: "<regex>",
30
+ * "nonGating"?: ["<job id>", …] }`; `gateName` matches case-insensitively.
31
+ * Absent keys take the defaults; anything else throws.
32
+ */
33
+ export const parseGateConfig = (text) => {
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(text);
37
+ }
38
+ catch (e) {
39
+ throw new Error(`not JSON: ${e.message}`);
40
+ }
41
+ if (!isRecord(raw))
42
+ throw new Error('the config must be a JSON object');
43
+ const unknown = Object.keys(raw).find((k) => !['id', 'workflowsDir', 'gateName', 'nonGating'].includes(k));
44
+ if (unknown !== undefined)
45
+ throw new Error(`unknown key "${unknown}"`);
46
+ const text_ = (key, fallback) => {
47
+ // Absent takes the default; a present `null` is refused like any other wrong value.
48
+ const value = key in raw ? raw[key] : fallback;
49
+ if (typeof value !== 'string' || value.trim() === '')
50
+ throw new Error(`"${key}" must be a non-empty string`);
51
+ return value;
52
+ };
53
+ const id = text_('id', DEFAULT_GATE_CONFIG.id);
54
+ const workflowsDir = text_('workflowsDir', DEFAULT_GATE_CONFIG.workflowsDir);
55
+ const source = text_('gateName', DEFAULT_GATE_CONFIG.gateName.source);
56
+ let gateName;
57
+ try {
58
+ gateName = new RegExp(source, 'i');
59
+ }
60
+ catch (e) {
61
+ throw new Error(`"gateName" is not a regex: ${e.message}`);
62
+ }
63
+ const nonGating = 'nonGating' in raw ? raw['nonGating'] : [];
64
+ if (!Array.isArray(nonGating) || nonGating.some((j) => typeof j !== 'string' || j === ''))
65
+ throw new Error('"nonGating" must be a list of job ids');
66
+ return { id, workflowsDir, gateName, nonGating: new Set(nonGating) };
67
+ };
68
+ /** The `.yml` and `.yaml` files in the workflows folder, as root-relative paths; none when it cannot be read. */
69
+ export const workflowFiles = (root, dir) => {
70
+ try {
71
+ return readdirSync(join(root, dir))
72
+ .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
73
+ .map((name) => join(dir, name));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ };
79
+ /** The job's `if:` and each step's `if:`: the two places GitHub reads a condition. */
80
+ const ifExpressions = (job) => {
81
+ const out = [];
82
+ if (typeof job.if === 'string')
83
+ out.push(job.if);
84
+ for (const step of job.steps ?? []) {
85
+ if (step && typeof step === 'object' && typeof step.if === 'string')
86
+ out.push(step.if);
87
+ }
88
+ return out;
89
+ };
90
+ /** An expression with its quoted literals emptied: `'needs.x'` is an argument, not a read. */
91
+ const stripLiterals = (expression) => expression.replace(/'(?:[^']|'')*'/g, "''");
92
+ /** The job ids a gate reads, by `needs.<id>` or `needs['<id>']`, in its conditions and `${{ }}` blocks. */
93
+ export const consumedNeeds = (job) => {
94
+ if (!job)
95
+ return [];
96
+ const expressions = ifExpressions(job);
97
+ for (const m of JSON.stringify(job).matchAll(/\$\{\{([\s\S]*?)\}\}/g))
98
+ expressions.push(m[1] ?? '');
99
+ const ids = new Set();
100
+ for (const raw of expressions) {
101
+ // Brackets first, off the raw text: stripping literals would erase the quoted id.
102
+ for (const m of raw.matchAll(/needs\[\s*'([A-Za-z0-9_-]+)'\s*\]/g))
103
+ ids.add(m[1] ?? '');
104
+ for (const m of stripLiterals(raw).matchAll(/needs\.([A-Za-z0-9_-]+)/g))
105
+ ids.add(m[1] ?? '');
106
+ }
107
+ return [...ids];
108
+ };
109
+ const asArray = (needs) => (needs === undefined ? [] : Array.isArray(needs) ? needs : [needs]);
110
+ /** Each gate, in each of `files` (root-relative), whose `needs:` leaves out a job it gates or reads. */
111
+ export const findGateGaps = (root, files, config) => {
112
+ const gaps = [];
113
+ for (const relative of files) {
114
+ let document;
115
+ try {
116
+ // A workflow that parses to a scalar or null is no job map: read as empty.
117
+ const parsed = parseYaml(readFileSync(join(root, relative), 'utf8'));
118
+ document = typeof parsed === 'object' && parsed !== null ? parsed : {};
119
+ }
120
+ catch {
121
+ gaps.push({ workflow: relative, gate: '(unparseable)', missing: ['<file did not parse>'] });
122
+ continue;
123
+ }
124
+ const jobs = document.jobs ?? {};
125
+ const jobIds = Object.keys(jobs);
126
+ const gateIds = jobIds.filter((id) => config.gateName.test(jobs[id]?.name ?? id) || config.gateName.test(id));
127
+ for (const gateId of gateIds) {
128
+ const declared = new Set(asArray(jobs[gateId]?.needs));
129
+ const missing = jobIds.filter((id) => id !== gateId && !gateIds.includes(id) && !config.nonGating.has(id) && !declared.has(id));
130
+ const undeclared = consumedNeeds(jobs[gateId]).filter((id) => jobIds.includes(id) && !declared.has(id));
131
+ const all = [...new Set([...missing, ...undeclared])];
132
+ if (all.length > 0)
133
+ gaps.push({ workflow: relative, gate: gateId, missing: all });
134
+ }
135
+ }
136
+ return gaps;
137
+ };
138
+ /** The verdict on the workflows under `root`, worded as the original check worded it. */
139
+ export const checkGates = (root, config) => {
140
+ const files = workflowFiles(root, config.workflowsDir);
141
+ if (files.length === 0)
142
+ return { ok: false, out: [], err: [`❌ ${config.id}: no workflow files found — the lookup failed`] };
143
+ const gaps = findGateGaps(root, files, config);
144
+ if (gaps.length === 0)
145
+ return { ok: true, out: [`✅ ${config.id}: every aggregate gate depends on all its jobs (${files.length} workflow(s))`], err: [] };
146
+ const err = [`\n❌ ${config.id}: ${gaps.length} aggregate gate(s) with an incomplete needs list\n`];
147
+ for (const gap of gaps)
148
+ err.push(` ${gap.workflow} → job "${gap.gate}"`, ` not depended on: ${gap.missing.join(', ')}\n`);
149
+ err.push('The merge queue checks only the gate. A job absent from its `needs:` cannot\n' +
150
+ 'block a merge however it fails — and a failure upstream turns into a SKIP\n' +
151
+ 'downstream, which the gate then accepts.\n');
152
+ return { ok: false, out: [], err };
153
+ };