@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 +143 -2
- package/dist/cli.js +77 -1
- package/dist/hook-preamble.js +343 -0
- package/dist/masked-exit-codes.js +762 -0
- package/dist/shell-command.js +425 -0
- package/dist/shell-gates.js +283 -0
- package/dist/shell-pitfalls.js +408 -0
- package/dist/shell-sources.js +165 -0
- package/dist/shell-text.js +417 -22
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
npx @veda-hq/pariksha@0.5.
|
|
8
|
-
npx @veda-hq/pariksha@0.5.
|
|
7
|
+
npx @veda-hq/pariksha@0.5.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
|
+
};
|