@veda-hq/pariksha 0.5.14 → 0.5.18

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.14 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.14 --version
7
+ npx @veda-hq/pariksha@0.5.18 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.18 --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
 
@@ -667,6 +740,131 @@ root-relative POSIX path, a check not in `checks`, an `exemptIn` group
667
740
  baseline, a root holding no tracked file, a group discovering nothing, no
668
741
  base without `--no-base`, a failed git call, or no `typescript`.
669
742
 
743
+ ## `masked-exit-codes`
744
+
745
+ ```sh
746
+ pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]
747
+ ```
748
+
749
+ A gate that keeps reporting success after it stopped checking passes
750
+ everything: a hook that runs a commit-message linter and then `exit 0`, a
751
+ review step whose failures read as "no issues found", a baseline read with
752
+ `2>/dev/null || true`. `|| true` has honest uses (an optional tool, grep's
753
+ "no matches"), so this refuses only the shapes that silence a gate command's
754
+ verdict:
755
+
756
+ 1. `exit 0` at the end of a script that ran a gate, with no branch on the
757
+ gate's status;
758
+ 2. `|| true`, a `||` handler that does not exit non-zero, or `2>/dev/null`
759
+ with the status unchecked, on a gate command, also in a script the line
760
+ hands a shell (`eval '…'`, `bash -c '…'`, what `echo`/`printf` pipes
761
+ into `sh`) and inside `$( … )`;
762
+ 3. in a file `pipefailScope` matches, `echo`/`printf` of a variable piped
763
+ into a reader that stops early (`grep -q`, `-m`, `-l`, `head`): under
764
+ pipefail the writer can fail after the match, which reads as a miss.
765
+
766
+ The files are those git tracks under the root ending `.sh` or under a hook
767
+ directory, outside `node_modules/`, read from the working tree, or the index
768
+ with `--staged`. Comments, quoted text, heredoc bodies and continuation lines
769
+ are read as bash reads them. The JSON `--config` (relative to the root) is
770
+ optional:
771
+
772
+ ```json
773
+ {
774
+ "id": "SH-001",
775
+ "gateCommands": ["eslint", "tsc", "vitest", "prettier --check", "commitlint", "run-validation.sh"],
776
+ "hookDirs": [".husky/"],
777
+ "pipefailScope": ["^\\.husky/", "^scripts/lib/.+\\.sh$"]
778
+ }
779
+ ```
780
+
781
+ - `id` (default `masked-exit-codes`) names the check in its messages.
782
+ - `gateCommands` (default `eslint`, `tsc`, `vitest`, `prettier --check`,
783
+ `commitlint`, `cubic review`, `gitleaks`, `actionlint`, `shellcheck`,
784
+ `depcruise`, `stryker`) are the commands whose status is a verdict; one of
785
+ several words must appear whole. Keep it narrow: a broad list fires on
786
+ every optional-tool probe.
787
+ - `hookDirs` (default `.husky/`) are path prefixes whose files are shell
788
+ whatever their name.
789
+ - `pipefailScope` (default `^\.husky/`) are path regexes: the files that run
790
+ under `set -o pipefail`.
791
+
792
+ A clean run prints `✅ <id>: no masked exit codes (N shell file(s) scanned)`
793
+ to stdout; each finding goes to stderr with its line and why, and exits 1.
794
+ No shell file at all exits 1 (`the file lookup failed`), as the original
795
+ did; a bad config or a failed git call exits 2.
796
+
797
+ ## `non-synced-resources`
798
+
799
+ ```sh
800
+ pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]
801
+ ```
802
+
803
+ A link to a file that was never committed works for its writer and for
804
+ nobody else. Every `[text](dest)` and `![alt](dest)` on a line of a document
805
+ (inside code blocks too) is read, except `http(s)://`, `mailto:` and `#`
806
+ links. The destination loses its `#fragment` and `?query`, then is the text
807
+ inside `<…>` or the text before a space (a title); one starting with `/` is
808
+ read from the repository's top folder. It is refused when its folder does
809
+ not exist, when it lands outside the repository, when nothing is there, or
810
+ when the name on disk differs in case (macOS opens `./README.MD` as
811
+ `./readme.md`; Linux and CI do not). An image over 1 MiB is warned about,
812
+ never refused. Paths are joined and split as the shell check this was ported
813
+ from joined them (`dirname`, `basename`, `cd && pwd`), so what counts as
814
+ inside the repository is unchanged.
815
+
816
+ The documents, the config (`include`, `guidance`) and the report on stdout
817
+ are as for [`absolute-paths`](#absolute-paths); a document listed by absolute
818
+ path has its links read from its own folder. With `--files-from`, a
819
+ repository is needed only when there is a document to read; without it, the
820
+ documents come from `git ls-files`, so a root that is not a repository exits
821
+ 2 even when it holds no document.
822
+
823
+ ## `pnpm-settings`
824
+
825
+ ```sh
826
+ pariksha pnpm-settings [--config <file>] [--root <dir>]
827
+ ```
828
+
829
+ pnpm 11 stopped reading the `pnpm` field of package.json without a warning
830
+ (pnpm/pnpm#11536): an `overrides` or `patchedDependencies` left there gives a
831
+ green install in which every security pin has reverted and every patch has
832
+ stopped applying. A green install proves nothing, so this reads the resolved
833
+ `pnpm-lock.yaml` (the last of its YAML documents, which pnpm 12 writes after
834
+ its own) and refuses:
835
+
836
+ 1. a setting pnpm 11 ignores still in `package.json#pnpm`;
837
+ 2. an override in `pnpm-workspace.yaml` the lockfile does not record, records
838
+ differently, or still records after it was removed;
839
+ 3. the same for a patch, a patch file that is not on disk, or one whose
840
+ sha256 is not the one the lockfile records;
841
+ 4. a resolved version inside the range an override excludes
842
+ (`tar@<=7.5.20`), or, for a bare-name override (`tar: '>=7.5.21'`),
843
+ outside the range it forces. Prereleases count: `7.5.20-beta.1` is inside
844
+ `<=7.5.20`;
845
+ 5. a `minimumReleaseAgeExclude` entry that is not one exact version the
846
+ lockfile still resolves, or whose list has no comment right above it, no
847
+ `EXPIRES: <ISO-8601>` line, or an expiry already past. `ownScopes` entries
848
+ are standing and exempt.
849
+
850
+ ```json
851
+ { "id": "CFG-005", "ownScopes": ["@your-scope/*"] }
852
+ ```
853
+
854
+ - `id` (default `pnpm-settings`) names the check in its messages.
855
+ - `ownScopes` (default none) lists `minimumReleaseAgeExclude` entries asked
856
+ for no expiry or exact version: a project's own scopes, released by its own
857
+ CI.
858
+
859
+ Ranges are read by pariksha's own matcher, a port of the `semver` package's
860
+ range reading (strict mode, prereleases included): comparator sets joined by
861
+ `||`, comparators joined by spaces, and the same sugar (`^`, `~`, `1.x`,
862
+ `*`, partial versions such as `<8`, hyphen ranges). A range `semver` would
863
+ refuse is not passed: it is printed as a selector not version-checked.
864
+ `semver`'s loose mode is not read. Notes and failures go to stderr, the clean
865
+ line to stdout; a missing input file is a failure (exit 1), a bad config or
866
+ an unreadable `package.json` exits 2.
867
+
670
868
  ## `required-settings`
671
869
 
672
870
  ```sh
@@ -725,6 +923,93 @@ Parsing those is the job of a command guard such as kavach's `git_safety`.
725
923
  project's settings and then also skips the user's own `defaultMode`, so it
726
924
  belongs in user settings only.
727
925
 
926
+ ## `shell-gates`
927
+
928
+ ```sh
929
+ pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]
930
+ ```
931
+
932
+ A shell script that works on its writer's Mac can fail on Linux CI, and a
933
+ git hook that picks up whatever `node` is on PATH can run a check under the
934
+ wrong runtime. This runs three gates over the shell files a change writes:
935
+
936
+ 1. ShellCheck, a binary on PATH (install it: <https://www.shellcheck.net>),
937
+ once per pass, by default `--severity=warning`, then `--severity=info
938
+ --include=SC2086,SC2013,SC2015` (an unquoted expansion, `for x in $(cat
939
+ f)`, `a && b || c`). Its targets are `*.sh`, `*.bash`, the hooks
940
+ directory's direct children without an extension, and extensionless
941
+ files whose first line is a sh, bash, dash or ksh `#!`. Staged mode
942
+ writes the index's copy of each to a temporary directory and judges
943
+ that; base mode judges the HEAD commit's copy of what the branch changed
944
+ since its merge base with `<ref>`; `--all` judges the working tree; files
945
+ mode passes the listed paths as they are.
946
+ 2. The hook preamble, over the hooks directory's direct children without an
947
+ extension. A hook must source `selectScript` and call `selectFunction`
948
+ (with `|| exit 1`, or a branch that exits) before it runs a runtime
949
+ (`runtimes`, by default `node`, `npx`, `pnpm`, `npm`, `tsx`, `corepack`,
950
+ `yarn`), here or in a function it calls; an opt-out (`[ "$X" = 0 ] &&
951
+ exit 0`) comes before that selection; it never edits PATH or drives
952
+ nvm, fnm or mise itself; it never `exec sh <hook>`s (that ignores the
953
+ target's shebang); and an INT, TERM, HUP or QUIT trap's handler ends in
954
+ `exit`, or bash resumes after it and a cancelled hook carries on.
955
+ 3. The pitfalls, over every shell target: `grep -E` or `egrep` with `\s`,
956
+ `\S`, `\d` or `\D` (not POSIX ERE: BSD grep matches nothing); GNU-only
957
+ `xargs -d`/`-r`; `xargs` without `-0` fed a file list; file operands
958
+ after `xargs`, or a list expansion to grep, without `--`; `<( … )` in a
959
+ file `validatorPaths` matches; a literal `/tmp/<name>` (use `mktemp`);
960
+ an unguarded `/opt/homebrew` or `/Users/` literal, a fixed `-avd
961
+ <name>`, a personal device name; `NVM_DIR` set without honouring an
962
+ existing one; `lockf` without `-k`; `$(( … ))` over a value from `read`
963
+ or `date` without `10#` (`08` is bad octal); `dirname "$0"` beside
964
+ `BASH_SOURCE`; `git worktree list` parsed without `--porcelain`; and
965
+ `grep -o` over a JSON `"key":`. Comments, quoted text and heredoc bodies
966
+ are read as bash reads them.
967
+
968
+ The preamble and pitfalls judge the same kind of target, less `exclude`
969
+ and symlinks: `--staged` (the default) the staged copies, `--base` the
970
+ files changed since the merge base with `<ref>` (read from the working
971
+ tree), `--all` every tracked one, `--files-from` the listed paths (one per
972
+ line or NUL-separated; a file, or `-` for stdin). A change under
973
+ `gateInputs` (staged, or on the branch) re-judges every target, and a
974
+ staged change to a `shellcheck.inputs` path makes ShellCheck judge the
975
+ whole index. `--only preamble,pitfalls` runs
976
+ only those checks, and no ShellCheck. The JSON `--config` (relative to the
977
+ root) is optional:
978
+
979
+ ```json
980
+ {
981
+ "hooksDir": ".husky/",
982
+ "exclude": ["node_modules/", ".husky/_/", "vendor/"],
983
+ "gateInputs": ["scripts/validations/shell/"],
984
+ "preamble": { "id": "SHELL-06", "selectScript": "scripts/lib/select-node.sh", "selectFunction": "select_node" },
985
+ "pitfalls": { "id": "SHELL-03", "validatorPaths": ["^scripts/validations/"] },
986
+ "shellcheck": { "inputs": ["scripts/run-validation.sh"], "install": "run the machine setup (or: brew install shellcheck)" },
987
+ "guidance": ["", " SHELL-06 and SHELL-03 are described in docs/shell.md."]
988
+ }
989
+ ```
990
+
991
+ - `hooksDir` (default `.husky/`, ending in `/`) is the git hooks directory.
992
+ - `exclude` (default `node_modules/`, `.husky/_/`) are path prefixes never
993
+ judged by the preamble and pitfalls; a `/node_modules/` segment never is.
994
+ - `gateInputs` (default none) are path prefixes of the gate's own inputs.
995
+ - `preamble` (default none, which leaves only the pitfalls to run):
996
+ `selectScript` and `selectFunction` are required; `id` defaults to
997
+ `hook-preamble`, `runtimes` to the list above.
998
+ - `pitfalls`: `id` (default `shell-pitfalls`) and `validatorPaths` (path
999
+ regexes, default none).
1000
+ - `shellcheck`: `passes` (lists of flags after `--norc`; `[]` runs no
1001
+ ShellCheck), `inputs` (default none) and `install`, the text a missing
1002
+ ShellCheck message gives.
1003
+ - `guidance` (default a pointer to this section) is printed after the
1004
+ findings.
1005
+
1006
+ ShellCheck's own report comes first, then `✅ Shell gates: N file(s) pass
1007
+ <checks>`, or `❌ Shell gates: N finding(s) in M file(s)` on stderr with
1008
+ each finding as `file:line <id> <rule>` and its message, then the
1009
+ guidance. It exits 1 on any ShellCheck or rule finding, or when ShellCheck
1010
+ is not installed; 2 on a bad config, two modes, an empty list, or a
1011
+ selection or git failure, since nothing was checked.
1012
+
728
1013
  ## markdownlint rules
729
1014
 
730
1015
  ```jsonc
@@ -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
+ };