@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 +6 -0
- package/README.md +290 -5
- package/dist/absolute-paths.js +59 -0
- package/dist/aggregate-gate-needs.js +153 -0
- package/dist/cli.js +152 -1
- package/dist/doc-files.js +74 -0
- package/dist/hook-preamble.js +343 -0
- package/dist/masked-exit-codes.js +762 -0
- package/dist/non-synced-resources.js +164 -0
- package/dist/pnpm-settings.js +350 -0
- package/dist/semver-range.js +320 -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 +4 -3
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.
|
|
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
|
|
12
|
-
(each finding on stderr, with how to fix it
|
|
13
|
-
|
|
14
|
-
|
|
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 `` 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
|
+
};
|