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