@veda-hq/pariksha 0.5.17 → 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/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.17 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.17 --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
@@ -740,6 +740,60 @@ root-relative POSIX path, a check not in `checks`, an `exemptIn` group
740
740
  baseline, a root holding no tracked file, a group discovering nothing, no
741
741
  base without `--no-base`, a failed git call, or no `typescript`.
742
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
+
743
797
  ## `non-synced-resources`
744
798
 
745
799
  ```sh
@@ -869,6 +923,93 @@ Parsing those is the job of a command guard such as kavach's `git_safety`.
869
923
  project's settings and then also skips the user's own `defaultMode`, so it
870
924
  belongs in user settings only.
871
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
+
872
1013
  ## markdownlint rules
873
1014
 
874
1015
  ```jsonc
package/dist/cli.js CHANGED
@@ -7,11 +7,13 @@ import { fileURLToPath } from 'node:url';
7
7
  import { checkAbsolutePaths, DEFAULT_GUIDANCE as ABSOLUTE_PATHS_GUIDANCE } from "./absolute-paths.js";
8
8
  import { checkGates, DEFAULT_GATE_CONFIG, parseGateConfig as parseAggregateConfig } from "./aggregate-gate-needs.js";
9
9
  import { parseDocConfig, readDoc, selectDocs } from "./doc-files.js";
10
+ import { DEFAULT_MASKED_CONFIG, findMaskedExits, formatMasked, parseMaskedConfig, readerFor as maskedReaderFor, shellFiles } from "./masked-exit-codes.js";
10
11
  import { checkNonSyncedResources, DEFAULT_GUIDANCE as NON_SYNCED_GUIDANCE } from "./non-synced-resources.js";
12
+ import { ALL_CHECKS as SHELL_CHECKS, parseGatesConfig, runShellGates, shellcheckRunner } from "./shell-gates.js";
11
13
  import { checkPnpmSettings, DEFAULT_PNPM_CONFIG, formatParity, parsePnpmConfig } from "./pnpm-settings.js";
12
14
  import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
13
15
  import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
14
- import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
16
+ import { DEFAULT_CONFIG, entries, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
15
17
  import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
16
18
  import { checkBudgets } from "./doc-budgets.js";
17
19
  import { lycheeRunner } from "./doc-links.js";
@@ -42,9 +44,11 @@ const USAGE = [
42
44
  ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
43
45
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
44
46
  ' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
47
+ ' pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]',
45
48
  ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
46
49
  ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
47
50
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
51
+ ' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
48
52
  ' pariksha hash <word>',
49
53
  ' pariksha --version',
50
54
  ].join('\n');
@@ -120,6 +124,76 @@ const nonSyncedResources = docCheck('non-synced-resources', NON_SYNCED_GUIDANCE,
120
124
  throw new Error(`${base} is not in a git repository`);
121
125
  return top;
122
126
  }, config));
127
+ /** Standard input, or the file `from` names, read whole. */
128
+ const listFrom = (from, io) => (from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8'));
129
+ /**
130
+ * masked-exit-codes: the shell files git tracks under `--root`, read from
131
+ * the working tree, or the index with `--staged`. The report goes to stdout
132
+ * when clean and stderr when not, as the original printed it.
133
+ */
134
+ const maskedExitCodes = (f, io) => {
135
+ const root = resolve(f.get('--root') ?? '.');
136
+ try {
137
+ const configPath = f.get('--config');
138
+ const config = configPath === undefined ? DEFAULT_MASKED_CONFIG : parseMaskedConfig(readFileSync(resolve(root, configPath), 'utf8'));
139
+ const files = shellFiles(root, config);
140
+ const problems = files.length === 0 ? [] : findMaskedExits(root, files, maskedReaderFor(f.has('--staged')), config);
141
+ const result = formatMasked(problems, files.length, config.id);
142
+ result.out.forEach(io.out);
143
+ result.err.forEach(io.err);
144
+ return result.ok ? 0 : 1;
145
+ }
146
+ catch (e) {
147
+ io.err(`pariksha masked-exit-codes: ${e.message}; nothing was checked, so this is not a pass`);
148
+ return 2;
149
+ }
150
+ };
151
+ /**
152
+ * shell-gates: one of `--staged` (the default), `--all`, `--base <ref>` or
153
+ * `--files-from <file>|-`; `--only` names the checks to run, and then
154
+ * ShellCheck sits the run out, as the original did.
155
+ */
156
+ const shellGates = (f, io) => {
157
+ const P = '❌ Shell gates could not run:';
158
+ const root = resolve(f.get('--root') ?? '.');
159
+ const modes = ['--staged', '--all', '--base', '--files-from'].filter((name) => f.has(name));
160
+ if (modes.length > 1) {
161
+ io.err(`${P} choose one of --staged, --all, --base, --files-from (got ${modes.join(', ')})`);
162
+ return 2;
163
+ }
164
+ let config;
165
+ let mode;
166
+ try {
167
+ const configPath = f.get('--config');
168
+ config = parseGatesConfig(configPath === undefined ? '{}' : readFileSync(resolve(root, configPath), 'utf8'));
169
+ const base = f.get('--base');
170
+ const from = f.get('--files-from');
171
+ if (from !== undefined) {
172
+ const files = entries(listFrom(from, io));
173
+ if (files.length === 0)
174
+ throw new Error('the --files-from list names no file, and a check of nothing is no pass');
175
+ mode = { kind: 'files', files };
176
+ }
177
+ else
178
+ mode = base !== undefined ? { kind: 'base', base } : f.has('--all') ? { kind: 'all' } : { kind: 'staged' };
179
+ }
180
+ catch (e) {
181
+ io.err(`${P} ${e.message}`);
182
+ return 2;
183
+ }
184
+ const only = f.get('--only');
185
+ let checks = SHELL_CHECKS.filter((name) => name !== 'preamble' || config.preamble !== undefined);
186
+ if (only !== undefined) {
187
+ const names = only.split(',').filter(Boolean);
188
+ const unknown = names.filter((name) => !SHELL_CHECKS.some((check) => check === name));
189
+ if (names.length === 0 || unknown.length > 0) {
190
+ io.err(`${P} --only takes ${SHELL_CHECKS.join(',')}; got ${names.join(',') || 'nothing'}`);
191
+ return 2;
192
+ }
193
+ checks = SHELL_CHECKS.filter((name) => names.includes(name));
194
+ }
195
+ return runShellGates({ root, mode, checks, only: only !== undefined }, config, { out: io.out, err: io.err, shellcheck: io.shellcheck ?? shellcheckRunner() });
196
+ };
123
197
  const aggregateGateNeeds = (f, io) => {
124
198
  const root = resolve(f.get('--root') ?? '.');
125
199
  const configPath = f.get('--config');
@@ -636,7 +710,9 @@ const COMMANDS = {
636
710
  'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
637
711
  'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
638
712
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
713
+ 'masked-exit-codes': { allowed: ['--config', '--root'], switches: ['--staged'], run: maskedExitCodes },
639
714
  'required-settings': { allowed: ['--require', '--settings', '--root'], run: requiredSettings },
715
+ 'shell-gates': { allowed: ['--config', '--base', '--files-from', '--only', '--root'], switches: ['--staged', '--all'], run: shellGates },
640
716
  };
641
717
  /** The exit code; a Promise of it for the one check that is asynchronous (enforcement-claims, which loads ESLint). */
642
718
  export const run = (args, io, version = ownVersion(), env = process.env) => {
@@ -0,0 +1,343 @@
1
+ // The git hook preamble contract: every hook follows one preamble, so the
2
+ // runtime a hook uses is decided in one place (the config's `selectScript`,
3
+ // which defines `selectFunction`) and a cancelled hook stays cancelled.
4
+ //
5
+ // runtime-before-select-node node/npx/pnpm/npm/tsx/corepack/yarn runs before
6
+ // the hook sources the select script and calls
7
+ // its function
8
+ // opt-out-after-select-node an opt-out (`[ "$VAR" = 0 ] && exit 0`) comes
9
+ // after runtime selection, so opting out still
10
+ // pays for (and can fail on) selection
11
+ // own-runtime-path the hook edits PATH or touches nvm/fnm/mise
12
+ // itself; the select script owns that
13
+ // exec-sh-trampoline `exec sh <hook>` ignores the target's bash
14
+ // shebang
15
+ // signal-trap-continues an INT/TERM/HUP/QUIT trap whose handler does
16
+ // not end in `exit`: bash resumes after the
17
+ // handler, so a cancelled gate carries on
18
+ import { isHook } from "./shell-sources.js";
19
+ import { codeLines, commandAt, commandPositions, maskQuoted, messageView, words } from "./shell-text.js";
20
+ export const DEFAULT_RUNTIMES = ['node', 'npx', 'pnpm', 'npm', 'tsx', 'corepack', 'yarn'];
21
+ const STOP_SIGNALS = new Set(['INT', 'TERM', 'HUP', 'QUIT', '1', '2', '3', '15']);
22
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
23
+ const finding = (config, source, rule, line, message) => ({ check: config.id, rule, file: source.path, line, message });
24
+ /** Whether the first word of `rest` (a command's operands) matches `pattern`; no word, no match. */
25
+ const firstWordMatches = (rest, pattern) => words(rest)
26
+ .slice(0, 1)
27
+ .some((word) => pattern.test(word));
28
+ /**
29
+ * Quote-masked text after a command at this level only: each `( … )` or
30
+ * `$( … )` group becomes one space, so `&(:)&` stays two separate `&`, and
31
+ * the text is cut at a `)` that closes an enclosing group. Only this level's
32
+ * separators remain; offsets are not kept.
33
+ */
34
+ const outerLevel = (masked) => {
35
+ let depth = 0;
36
+ const kept = [];
37
+ for (const char of masked) {
38
+ if (char === ')') {
39
+ if (depth === 0)
40
+ break;
41
+ depth -= 1;
42
+ }
43
+ else if (char === '(') {
44
+ if (depth === 0)
45
+ kept.push(' ');
46
+ depth += 1;
47
+ }
48
+ else if (depth === 0)
49
+ kept.push(char);
50
+ }
51
+ return kept.join('');
52
+ };
53
+ /**
54
+ * Whether the command at `start..end` runs in a child shell, so whatever it
55
+ * defines or selects is gone afterwards: a pipeline stage (`x | cmd`,
56
+ * `cmd | cat`, `|&` too) or a list sent to the background (`cmd &`).
57
+ */
58
+ const runsInChildShell = (code, start, end) => {
59
+ if (/(?:^|[^|])\|&?\s*$/.test(code.slice(0, start)))
60
+ return true;
61
+ // The rest of its list (to `;` or a newline) and of its pipeline (to `&&`
62
+ // or `||` too, which bind looser than `|`), read at this level only: a `;`
63
+ // inside `"$(a; b)"` ends nothing.
64
+ const list = outerLevel(maskQuoted(code).slice(end)).split(/[;\n]/)[0];
65
+ if (/(?:^|[^&>])&(?![&>])/.test(list))
66
+ return true;
67
+ const pipeline = list.split(/&&|\|\|/)[0];
68
+ return /\|/.test(pipeline);
69
+ };
70
+ /**
71
+ * Offsets of each `.`/`source` command, where it runs (never in a message or
72
+ * quoted text), whose file operand is the select script.
73
+ */
74
+ const selectSources = (code, script) => ['.', 'source'].flatMap((name) => commandPositions(code, name).filter((end) => firstWordMatches(code.slice(end), script) && !runsInChildShell(code, end - name.length, end)));
75
+ /**
76
+ * `[ "$VAR" ... ] && exit 0` or `if [ ... ]; then exit 0`, on one line or
77
+ * several (`then` and `exit 0` on lines of their own), read over the logical
78
+ * lines joined with newlines.
79
+ */
80
+ const OPT_OUT = /\$\{?[A-Z][A-Z0-9_]*\b[^\]\n]*\]\]?\s*(?:&&|(?:;|\n)\s*then)\s*exit\s+0\b/g;
81
+ const PATH_EDIT = /(?:^|[\s;&|(])(?:export\s+)?PATH\+?=/;
82
+ const NVM = /\bNVM_DIR\b|\bnvm\.sh\b/;
83
+ const FNM_USE = /(?:^|[\s;&|("])fnm\s+(?:use|env)\b/;
84
+ /** `mise activate`, `mise env`, `mise exec`/`x` or `mise shell`: each edits the runtime PATH. */
85
+ const MISE_USE = /(?:^|[\s;&|("])mise\s+(?:activate|env|exec|x|shell)\b/;
86
+ /** `. "$NVM_DIR/nvm.sh"`, `\. ~/.nvm/nvm.sh` or `source …nvm.sh`: the path is often quoted. */
87
+ const sourcesNvm = (code) => ['.', '\\.', 'source'].some((name) => commandPositions(code, name).some((end) => firstWordMatches(code.slice(end), /nvm\.sh\b/)));
88
+ /** The first position, in line then offset order, that `find` yields and `keep` accepts. */
89
+ const firstAt = (lines, find, keep) => {
90
+ for (const [index, line] of lines.entries()) {
91
+ const kept = find(line.code).filter((offset) => keep({ index, offset }));
92
+ if (kept.length > 0)
93
+ return { index, offset: Math.min(...kept) };
94
+ }
95
+ return undefined;
96
+ };
97
+ const NAME = String.raw `[A-Za-z_][\w-]*`;
98
+ /** `f() {`, `function f {` or `function f() {`, capturing the name and the rest. */
99
+ const FUNCTION_HEADER = new RegExp(String.raw `^\s*(?:function\s+(${NAME})\s*(?:\(\s*\))?|(${NAME})\s*\(\s*\))\s*\{(.*)$`);
100
+ /** `f()`, `function f` or `function f()` alone: the `{` opens the next line. */
101
+ const BARE_HEADER = new RegExp(String.raw `^\s*(?:function\s+(${NAME})\s*(?:\(\s*\))?|(${NAME})\s*\(\s*\))\s*$`);
102
+ /**
103
+ * Statements split at unquoted `;` and newlines only: `echo "a; exit 1"` is
104
+ * one statement, and `cleanup && exit 130` is one whose exit may not run.
105
+ */
106
+ const splitStatements = (code) => {
107
+ const masked = maskQuoted(code);
108
+ const parts = [];
109
+ let from = 0;
110
+ for (let at = 0; at <= code.length; at += 1) {
111
+ if (at === code.length || masked[at] === ';' || masked[at] === '\n') {
112
+ parts.push(code.slice(from, at).trim());
113
+ from = at + 1;
114
+ }
115
+ }
116
+ return parts.filter(Boolean);
117
+ };
118
+ /** Unquoted code with `${…}` blanked, offsets preserved, for brace counting. */
119
+ const braceView = (code) => maskQuoted(code).replace(/\$\{[^}]*\}/g, (match) => ' '.repeat(match.length));
120
+ /** A function header at `index`: its name, the line holding its `{`, and the code after that `{`. */
121
+ const headerAt = (lines, index) => {
122
+ const code = lines[index].code;
123
+ const match = FUNCTION_HEADER.exec(code);
124
+ if (match !== null)
125
+ return { name: (match[1] ?? match[2]), braceLine: index, rest: match[3] };
126
+ // `f()` alone, its `{` opening the next line, is a definition too.
127
+ const bare = BARE_HEADER.exec(code);
128
+ const next = lines.at(index + 1);
129
+ if (bare === null || next === undefined)
130
+ return undefined;
131
+ const brace = /^\s*\{(.*)$/.exec(next.code);
132
+ if (brace === null)
133
+ return undefined;
134
+ return { name: (bare[1] ?? bare[2]), braceLine: index + 1, rest: brace[1] };
135
+ };
136
+ /**
137
+ * Every function definition, from its header to the `}` that brings the
138
+ * brace depth back to zero, so a nested `{ …; }` group does not end it and
139
+ * code after the closing brace on that line stays top level.
140
+ */
141
+ const functionDefinitions = (lines) => {
142
+ const out = [];
143
+ for (let index = 0; index < lines.length; index += 1) {
144
+ const start = index;
145
+ const header = headerAt(lines, index);
146
+ if (header === undefined)
147
+ continue;
148
+ const { name, braceLine, rest } = header;
149
+ index = braceLine;
150
+ // Where the body starts: just past the `{`, on the line holding it.
151
+ const bodyStart = lines[braceLine].code.length - rest.length;
152
+ let depth = 0;
153
+ let end;
154
+ for (; index < lines.length && end === undefined; index += 1) {
155
+ for (const [offset, char] of braceView(lines[index].code)
156
+ .split('')
157
+ .entries()) {
158
+ if (char === '{')
159
+ depth += 1;
160
+ else if (char === '}' && --depth === 0) {
161
+ end = { index, offset };
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ index -= 1; // back to the closing line; the outer loop steps past it
167
+ const close = end ?? { index, offset: lines[index].code.length };
168
+ const parts = [];
169
+ for (let line = braceLine; line <= close.index; line += 1)
170
+ parts.push(lines[line].code.slice(line === braceLine ? bodyStart : 0, line === close.index ? close.offset : undefined));
171
+ const body = parts.join(';');
172
+ out.push({ name, start, end: close, body, statements: splitStatements(body).filter((statement) => statement !== '}' && statement !== '{') });
173
+ }
174
+ return out;
175
+ };
176
+ /** Strictly earlier: by logical line, then by offset within it. */
177
+ export const isBefore = (a, b) => a.index < b.index || (a.index === b.index && a.offset < b.offset);
178
+ // A command ends before the `}` that closes its function: `node}` is one word.
179
+ const insideFunction = (functions, at) => functions.some((fn) => at.index >= fn.start && isBefore(at, fn.end));
180
+ /**
181
+ * Whether `at` runs in the hook's own shell: no unquoted `(` left open before
182
+ * it, across lines, and no open backtick substitution. Selection inside
183
+ * `( … )`, `$( … )` or `` `…` `` is lost when the subshell exits.
184
+ */
185
+ const inHookShell = (lines, at) => {
186
+ let depth = 0;
187
+ let inBacktick = false;
188
+ const before = lines
189
+ .slice(0, at.index)
190
+ .map((line) => maskQuoted(line.code))
191
+ .concat(maskQuoted(lines[at.index].code).slice(0, at.offset));
192
+ for (const text of before) {
193
+ for (const char of text) {
194
+ if (char === '(')
195
+ depth += 1;
196
+ else if (char === ')')
197
+ depth = Math.max(0, depth - 1);
198
+ else if (char === '`')
199
+ inBacktick = !inBacktick;
200
+ }
201
+ }
202
+ return depth === 0 && !inBacktick;
203
+ };
204
+ /** `'x'` or `"x"` without its quotes; anything else as it is. */
205
+ const unquote = (word) => /^(['"])([\s\S]*)\1$/.exec(word)?.[2] ?? word;
206
+ /** Leading redirections (`2>&1`, `>/dev/null`) of a command's remainder. */
207
+ const REDIRECTIONS = /^(?:\s*[0-9]*[<>]{1,2}&?\s*[^\s;&|]+)+/;
208
+ /**
209
+ * `exit` or `exit N`, plus any redirections (`exit 130 >&2` still exits the
210
+ * hook); `exit 130 &` or `exit 130 | cat` exits a child shell instead.
211
+ */
212
+ const EXIT = String.raw `exit(?:\s+[^\s&|;<>]+)?(?:\s*[0-9]*[<>]{1,2}&?\s*[^\s;&|<>]+)*`;
213
+ const EXIT_STATEMENT = new RegExp(String.raw `^${EXIT}\s*$`);
214
+ /** Whether the last statement of a handler string, or of a named function's body, exits. */
215
+ const handlerEndsInExit = (handler, functions) => {
216
+ // `trap - SIG` restores the default (the hook dies); `trap '' SIG` ignores
217
+ // the signal, so a cancelled hook runs on.
218
+ if (handler === '-')
219
+ return true;
220
+ const body = unquote(handler).trim();
221
+ // A function name ends in exit when its body's last statement does.
222
+ // Otherwise split on `;` and newlines only: `cleanup && exit 130` is one
223
+ // statement whose exit is skipped when cleanup fails. An empty handler
224
+ // (`trap '' SIG`) has no last statement.
225
+ const last = /^[A-Za-z_][A-Za-z0-9_]*$/.test(body) ? functions.find((entry) => entry.name === body)?.statements.at(-1) : splitStatements(body).at(-1);
226
+ return last !== undefined && EXIT_STATEMENT.test(last);
227
+ };
228
+ const trapFindings = (config, source, lines, functions) => {
229
+ const out = [];
230
+ for (const entry of lines) {
231
+ // Every trap on the line: `trap 'exit 130' INT; trap '' TERM` has two.
232
+ for (const at of commandPositions(entry.code, 'trap')) {
233
+ let args = words(entry.code.slice(at));
234
+ // `trap -- handler SIG` ends options; `-p` and `-l` only list.
235
+ // `trap - SIG` restores the default, so it needs no check either.
236
+ if (args[0] === '--')
237
+ args = args.slice(1);
238
+ else if (args[0]?.startsWith('-'))
239
+ continue;
240
+ if (args.length < 2)
241
+ continue;
242
+ const [handler, ...signals] = args;
243
+ const stops = signals.some((signal) => STOP_SIGNALS.has(unquote(signal).toUpperCase().replace(/^SIG/, '')));
244
+ if (stops && !handlerEndsInExit(handler, functions)) {
245
+ out.push(finding(config, source, 'signal-trap-continues', entry.line, `trap ${handler} on ${signals.join(' ')} does not end in exit: bash resumes after the handler, so a cancelled hook keeps running`));
246
+ }
247
+ }
248
+ }
249
+ return out;
250
+ };
251
+ export const checkHookPreamble = (source, config) => {
252
+ if (!isHook(source.path, config.hooksDir))
253
+ return [];
254
+ const lines = codeLines(source.text);
255
+ const out = [];
256
+ const select = config.selectFunction;
257
+ // Absolute or repository-relative: hooks run from the repository root.
258
+ const script = new RegExp(String.raw `(?:^["']?|\/)${escapeRegex(config.selectScript)}\b`);
259
+ // A function body runs where the function is called, not where it is
260
+ // defined: selection inside one does not count as the preamble selecting,
261
+ // and a runtime inside one counts at each call of that function.
262
+ const functions = functionDefinitions(lines);
263
+ const outsideFunctions = (at) => !insideFunction(functions, at);
264
+ const runtimeOffsets = (code, names) => names.flatMap((name) => commandPositions(code, name));
265
+ // A function that runs a runtime, or calls a function that does, is a
266
+ // runner. A Set's iteration also visits what is added during it, so each
267
+ // new runner is in turn checked for callers, and each name once.
268
+ const runners = new Set(functions.filter((definition) => runtimeOffsets(definition.body, config.runtimes).length > 0).map((definition) => definition.name));
269
+ for (const runner of runners) {
270
+ for (const definition of functions)
271
+ if (runtimeOffsets(definition.body, [runner]).length > 0)
272
+ runners.add(definition.name);
273
+ }
274
+ // Selection must also run in the hook's own shell: inside `( … )` or
275
+ // `$( … )` its PATH change is gone once the subshell exits.
276
+ const topLevel = (at) => outsideFunctions(at) && inHookShell(lines, at);
277
+ const sourcedAt = firstAt(lines, (code) => selectSources(code, script), topLevel);
278
+ // And its failure must stop the hook: `<select> || exit 1`, or the
279
+ // condition of an `if`. A bare call, `|| true`, or a top-level `|| return`
280
+ // (which fails and carries on) runs on unselected.
281
+ const calledAt = firstAt(lines, (code) => commandPositions(code, select).filter((end) => {
282
+ const start = end - select.length;
283
+ // A pipeline stage runs in a subshell: `x | select`; so does a list
284
+ // sent to the background: `select || exit 1 &`.
285
+ if (runsInChildShell(code, start, end))
286
+ return false;
287
+ // Redirections may sit between the call and `||`: `2>&1 || exit 1`.
288
+ const rest = code.slice(end).replace(REDIRECTIONS, '');
289
+ return (
290
+ // `|| exit`, or a `|| { …; }` block whose last statement exits. The
291
+ // exit must end the list: `|| exit 1 | cat` exits a pipeline child.
292
+ new RegExp(String.raw `^\s*\|\|\s*${EXIT}\s*(?:$|;|&&)`).test(rest) ||
293
+ new RegExp(String.raw `^\s*\|\|\s*\{(?:[^}]*;)?\s*${EXIT}\s*;\s*\}\s*(?:$|;|&&)`).test(rest) ||
294
+ /(?:^|[\s;&])if\s+(?:!\s*)?$/.test(code.slice(0, start)));
295
+ }), topLevel);
296
+ const runtimeAt = firstAt(lines, (code) => runtimeOffsets(code, [...config.runtimes, ...runners]), outsideFunctions);
297
+ if (runtimeAt !== undefined) {
298
+ // Ordered by logical line, then by offset within it, so
299
+ // `. select.sh; select; node x` on one line is in order.
300
+ const selectedBefore = sourcedAt !== undefined && calledAt !== undefined && isBefore(sourcedAt, calledAt) && isBefore(calledAt, runtimeAt);
301
+ if (!selectedBefore) {
302
+ out.push(finding(config, source, 'runtime-before-select-node', lines[runtimeAt.index].line, `runs a Node tool before sourcing ${config.selectScript} and calling ${select}, so it uses whatever node PATH happens to hold`));
303
+ }
304
+ }
305
+ if (sourcedAt !== undefined) {
306
+ // Everything after the source, its own line's rest included:
307
+ // `. select.sh; [ "$X" ] && exit 0` opts out after selecting.
308
+ const sourceLine = lines[sourcedAt.index];
309
+ const after = [{ line: sourceLine.line, code: sourceLine.code.slice(sourcedAt.offset) }, ...lines.slice(sourcedAt.index + 1)];
310
+ // Messages blanked: `echo '[ "$X" ] && exit 0'` shows an opt-out.
311
+ const texts = after.map((entry) => `${messageView(entry.code)}\n`);
312
+ const joined = texts.join('');
313
+ const starts = texts.map((_, index) => texts.slice(0, index).join('').length);
314
+ const masked = maskQuoted(joined);
315
+ for (const match of joined.matchAll(OPT_OUT)) {
316
+ // The exit must run: in `HELP='[ "$X" ] && exit 0'` it is quoted text.
317
+ const exitAt = match.index + match[0].lastIndexOf('exit');
318
+ if (!masked.startsWith('exit', exitAt))
319
+ continue;
320
+ // Reported on the condition's line: the last starting at or before it.
321
+ const owner = starts.findLastIndex((start) => start <= match.index);
322
+ out.push(finding(config, source, 'opt-out-after-select-node', after[owner].line, 'opt-out comes after runtime selection; check it first so an opted-out hook does no work and cannot fail'));
323
+ }
324
+ }
325
+ for (const entry of lines) {
326
+ // Code text, not command positions, with quoted literals blanked: a
327
+ // message (echo/printf) is judged with its words blanked too, so
328
+ // `echo export PATH=...` and `HELP="export PATH=..."` name an edit
329
+ // without making one, while `eval "$(fnm env)"` keeps its substitution.
330
+ const view = messageView(entry.code);
331
+ const code = maskQuoted(view);
332
+ if (PATH_EDIT.test(code) || NVM.test(code) || sourcesNvm(view) || commandAt(entry.code, 'nvm') !== -1 || FNM_USE.test(code) || MISE_USE.test(code)) {
333
+ out.push(finding(config, source, 'own-runtime-path', entry.line, `hook edits PATH or drives nvm/fnm/mise itself; ${config.selectScript} owns runtime and tool PATH`));
334
+ }
335
+ // `exec` at a command position, never in a message; sh and dash only:
336
+ // `exec bash <hook>` names the dialect the hook needs.
337
+ const masked = maskQuoted(entry.code);
338
+ if (commandPositions(entry.code, 'exec').some((end) => /^\s+(?:\/usr\/bin\/env\s+)?(?:\/bin\/)?(?:da)?sh(?=\s|$)/.test(masked.slice(end)))) {
339
+ out.push(finding(config, source, 'exec-sh-trampoline', entry.line, '`exec sh <hook>` runs the target under sh whatever its shebang says; exec the hook itself'));
340
+ }
341
+ }
342
+ return [...out, ...trapFindings(config, source, lines, functions)].sort((a, b) => a.line - b.line);
343
+ };