@veda-hq/pariksha 0.5.14 → 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 ADDED
@@ -0,0 +1,6 @@
1
+ @veda-hq/pariksha includes third-party code:
2
+
3
+ - src/semver-range.ts (dist/semver-range.js) is ported from semver 7.8.1
4
+ (npm `semver`, https://github.com/npm/node-semver), ISC licence,
5
+ Copyright (c) Isaac Z. Schlueter and Contributors. The licence text is in
6
+ the file's header.
package/README.md CHANGED
@@ -4,14 +4,87 @@
4
4
  test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
5
5
 
6
6
  ```sh
7
- npx @veda-hq/pariksha@0.5.14 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.14 --version
7
+ npx @veda-hq/pariksha@0.5.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), and 2 on a usage or input
13
- error. `--root <dir>` (default: the current directory) names the repository.
14
- Node 24 or later. MIT.
12
+ (each finding on stderr, with how to fix it; `absolute-paths` and
13
+ `non-synced-resources` print theirs on stdout, as the shell checks they were
14
+ ported from did), and 2 on a usage or input error. `--root <dir>` (default:
15
+ the current directory) names the repository. Node 24 or later. MIT; one
16
+ ported file is ISC, see `NOTICE`.
17
+
18
+ ## `absolute-paths`
19
+
20
+ ```sh
21
+ pariksha absolute-paths [--config <file>] [--files-from <file>|-] [--root <dir>]
22
+ ```
23
+
24
+ A document that points at `/Users/me/notes.md` points at something only its
25
+ writer has. This refuses a line naming a path on a local machine: `file://`,
26
+ `/Users/`, `/home/<name>/`, `/tmp/<x>`, `/var/<x>`, `/private/var`, `~/`, or a
27
+ drive (`C:\x`). Not refused: a line inside a fenced block (between lines that
28
+ start with three backticks), a path in an inline code span, and a line that
29
+ says it is an example (`forbidden`, `example`, `not allowed`, `should not`,
30
+ `must not`, in any case). Only the first path on a line is tested for a code
31
+ span, and a `/tmp/<x>` or `/var/<x>` path runs to the next `/`, so
32
+ `` `/tmp/x` `` alone on a line is refused: the shell check this was ported
33
+ from read it so.
34
+
35
+ The documents are the ones `--files-from` lists, one path per line (a file,
36
+ or `-` for stdin; relative paths are read from the root), or with no list
37
+ every file git tracks; a listed path that is not a regular file is dropped.
38
+ The JSON `--config` (relative to the root) is optional:
39
+
40
+ ```json
41
+ {
42
+ "include": "\\.(md|mdx)$",
43
+ "guidance": ["Please use relative paths only.", "See docs/WRITING.md."]
44
+ }
45
+ ```
46
+
47
+ - `include` (default `\.(md|mdx)$`) is the regex a listed path must match to
48
+ be a document.
49
+ - `guidance` (default one line asking for relative paths) is printed after a
50
+ refusal.
51
+
52
+ The report goes to stdout: `→ Checking …`, `Checking N documentation
53
+ file(s)...`, then each finding as `file:line:text`. It exits 1 on a finding,
54
+ 0 with none or nothing to check, and 2 on a bad config, an unreadable list,
55
+ or no list outside a git repository.
56
+
57
+ ## `aggregate-gate-needs`
58
+
59
+ ```sh
60
+ pariksha aggregate-gate-needs [--config <file>] [--root <dir>]
61
+ ```
62
+
63
+ A merge queue that requires one aggregate status, a workflow's "CI Gate",
64
+ lets through any job that gate does not wait on: a job missing from its
65
+ `needs:` cannot block a merge however it fails, and an upstream failure turns
66
+ into a skip downstream that the gate accepts. Each shows a green required
67
+ check. In every `.yml` and `.yaml` workflow, a gate (a job whose `name:`, or
68
+ id, matches `gateName`) is refused when its `needs:` leaves out a job other
69
+ than another gate or a `nonGating` one, or when it reads a job's result
70
+ without declaring it: `needs.<id>` or `needs['<id>']` in the job's `if:`, a
71
+ step's `if:`, or any `${{ … }}` in the job, outside quoted literals. Text in
72
+ a `run:` script and an action input named `if` are not reads. A non-gating
73
+ job (a change detector) need not be gated, but must be declared once read.
74
+
75
+ ```json
76
+ { "id": "CI-001", "workflowsDir": ".github/workflows", "gateName": "gate$", "nonGating": ["detect-changes"] }
77
+ ```
78
+
79
+ - `id` (default `aggregate-gate-needs`) names the check in its messages: a
80
+ project's rule id, say.
81
+ - `workflowsDir` (default `.github/workflows`, relative to the root).
82
+ - `gateName` (default `gate$`) is a regex, matched case-insensitively.
83
+ - `nonGating` (default none) lists job ids that gate nothing.
84
+
85
+ A workflow that does not parse is reported as a gap, never passed. No
86
+ workflow to read exits 1 (`no workflow files found — the lookup failed`),
87
+ as the original did; a bad config exits 2.
15
88
 
16
89
  ## `authority-citations`
17
90
 
@@ -667,6 +740,77 @@ 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
+ ## `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 `![alt](dest)` 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
+
670
814
  ## `required-settings`
671
815
 
672
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
+ };
package/dist/cli.js CHANGED
@@ -4,6 +4,11 @@ import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
5
  import { join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { checkAbsolutePaths, DEFAULT_GUIDANCE as ABSOLUTE_PATHS_GUIDANCE } from "./absolute-paths.js";
8
+ import { checkGates, DEFAULT_GATE_CONFIG, parseGateConfig as parseAggregateConfig } from "./aggregate-gate-needs.js";
9
+ import { parseDocConfig, readDoc, selectDocs } from "./doc-files.js";
10
+ import { checkNonSyncedResources, DEFAULT_GUIDANCE as NON_SYNCED_GUIDANCE } from "./non-synced-resources.js";
11
+ import { checkPnpmSettings, DEFAULT_PNPM_CONFIG, formatParity, parsePnpmConfig } from "./pnpm-settings.js";
7
12
  import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
8
13
  import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
9
14
  import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
@@ -25,6 +30,8 @@ import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfi
25
30
  export const DENY_FILE = '.veda/deny.sha256';
26
31
  const USAGE = [
27
32
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
33
+ ' pariksha absolute-paths [--config <file>] [--files-from <file>|-] [--root <dir>]',
34
+ ' pariksha aggregate-gate-needs [--config <file>] [--root <dir>]',
28
35
  ' pariksha boundary [--root <dir>] [--deny <file>]',
29
36
  ' pariksha codec-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
30
37
  ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
@@ -35,6 +42,8 @@ const USAGE = [
35
42
  ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
36
43
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
37
44
  ' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
45
+ ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
46
+ ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
38
47
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
39
48
  ' pariksha hash <word>',
40
49
  ' pariksha --version',
@@ -81,6 +90,68 @@ export const flags = (rest, allowed, switches = []) => {
81
90
  }
82
91
  return out;
83
92
  };
93
+ /**
94
+ * absolute-paths and non-synced-resources: the documents `--files-from`
95
+ * lists (a file, or `-` for stdin), or every tracked one, each path read
96
+ * from `--root`; the report goes to stdout, as the shell checks printed it.
97
+ */
98
+ const docCheck = (name, defaultGuidance, check) => (f, io) => {
99
+ const P = `pariksha ${name}:`;
100
+ const base = resolve(f.get('--root') ?? '.');
101
+ try {
102
+ const configPath = f.get('--config');
103
+ const config = parseDocConfig(configPath === undefined ? undefined : readFileSync(resolve(base, configPath), 'utf8'), defaultGuidance);
104
+ const from = f.get('--files-from');
105
+ const listed = from === undefined ? undefined : from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8');
106
+ const docs = selectDocs(base, listed, config.include).map((file) => ({ file, text: () => readDoc(base, file) }));
107
+ const result = check(docs, base, config);
108
+ result.out.forEach(io.out);
109
+ return result.ok ? 0 : 1;
110
+ }
111
+ catch (e) {
112
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
113
+ return 2;
114
+ }
115
+ };
116
+ const absolutePaths = docCheck('absolute-paths', ABSOLUTE_PATHS_GUIDANCE, (docs, _base, config) => checkAbsolutePaths(docs, config));
117
+ const nonSyncedResources = docCheck('non-synced-resources', NON_SYNCED_GUIDANCE, (docs, base, config) => checkNonSyncedResources(docs, () => {
118
+ const top = topOf(base);
119
+ if (top === undefined)
120
+ throw new Error(`${base} is not in a git repository`);
121
+ return top;
122
+ }, config));
123
+ const aggregateGateNeeds = (f, io) => {
124
+ const root = resolve(f.get('--root') ?? '.');
125
+ const configPath = f.get('--config');
126
+ let config = DEFAULT_GATE_CONFIG;
127
+ try {
128
+ if (configPath !== undefined)
129
+ config = parseAggregateConfig(readFileSync(resolve(root, configPath), 'utf8'));
130
+ }
131
+ catch (e) {
132
+ io.err(`pariksha aggregate-gate-needs: ${e.message}; nothing was checked, so this is not a pass`);
133
+ return 2;
134
+ }
135
+ const result = checkGates(root, config);
136
+ result.out.forEach(io.out);
137
+ result.err.forEach(io.err);
138
+ return result.ok ? 0 : 1;
139
+ };
140
+ const pnpmSettings = (f, io) => {
141
+ const root = resolve(f.get('--root') ?? '.');
142
+ const configPath = f.get('--config');
143
+ try {
144
+ const config = configPath === undefined ? DEFAULT_PNPM_CONFIG : parsePnpmConfig(readFileSync(resolve(root, configPath), 'utf8'));
145
+ const result = formatParity(checkPnpmSettings(root, config, (io.now ?? Date.now)()), config.id);
146
+ result.err.forEach(io.err);
147
+ result.out.forEach(io.out);
148
+ return result.ok ? 0 : 1;
149
+ }
150
+ catch (e) {
151
+ io.err(`pariksha pnpm-settings: ${e.message}; nothing was checked, so this is not a pass`);
152
+ return 2;
153
+ }
154
+ };
84
155
  const boundary = (f, io) => {
85
156
  const root = resolve(f.get('--root') ?? '.');
86
157
  const denyPath = resolve(root, f.get('--deny') ?? DENY_FILE);
@@ -553,6 +624,10 @@ const COMMANDS = {
553
624
  'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
554
625
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
555
626
  'fault-injection-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: faultInjectionRegistry },
627
+ 'absolute-paths': { allowed: ['--config', '--files-from', '--root'], run: absolutePaths },
628
+ 'aggregate-gate-needs': { allowed: ['--config', '--root'], run: aggregateGateNeeds },
629
+ 'non-synced-resources': { allowed: ['--config', '--files-from', '--root'], run: nonSyncedResources },
630
+ 'pnpm-settings': { allowed: ['--config', '--root'], run: pnpmSettings },
556
631
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
557
632
  boundary: { allowed: ['--root', '--deny'], run: boundary },
558
633
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
@@ -0,0 +1,74 @@
1
+ // The documents absolute-paths and non-synced-resources read, and the config
2
+ // both take. A product's hook hands them its own list (`--files-from`); with
3
+ // none, they read every file git tracks.
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { entries } from "./coverage-by-type.js";
7
+ import { gitAt, must } from "./git.js";
8
+ /** Markdown and MDX. */
9
+ export const DEFAULT_INCLUDE = '\\.(md|mdx)$';
10
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ /**
12
+ * `{ "include"?: "<regex>", "guidance"?: ["<line>", …] }`; absent keys take
13
+ * the defaults. Throws on anything else, so a mistyped key never passes as
14
+ * the default.
15
+ */
16
+ export const parseDocConfig = (text, defaultGuidance) => {
17
+ if (text === undefined)
18
+ return { include: new RegExp(DEFAULT_INCLUDE), guidance: defaultGuidance };
19
+ let raw;
20
+ try {
21
+ raw = JSON.parse(text);
22
+ }
23
+ catch (e) {
24
+ throw new Error(`not JSON: ${e.message}`);
25
+ }
26
+ if (!isRecord(raw))
27
+ throw new Error('the config must be a JSON object');
28
+ const unknown = Object.keys(raw).find((k) => k !== 'include' && k !== 'guidance');
29
+ if (unknown !== undefined)
30
+ throw new Error(`unknown key "${unknown}"`);
31
+ // Absent takes the default; a present `null` is refused like any other wrong value.
32
+ const include = 'include' in raw ? raw['include'] : DEFAULT_INCLUDE;
33
+ if (typeof include !== 'string' || include === '')
34
+ throw new Error('"include" must be a non-empty regex string');
35
+ let pattern;
36
+ try {
37
+ pattern = new RegExp(include);
38
+ }
39
+ catch (e) {
40
+ throw new Error(`"include" is not a regex: ${e.message}`);
41
+ }
42
+ const guidance = 'guidance' in raw ? raw['guidance'] : defaultGuidance;
43
+ if (!Array.isArray(guidance) || guidance.some((line) => typeof line !== 'string'))
44
+ throw new Error('"guidance" must be a list of lines');
45
+ return { include: pattern, guidance: guidance };
46
+ };
47
+ /** Whether `path` (under `base` when relative) names a regular file, a symlink to one included. */
48
+ export const isFileAt = (base, path) => {
49
+ try {
50
+ return statSync(resolve(base, path)).isFile();
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ /**
57
+ * The documents to check, as listed: each entry of `listed` that names a
58
+ * regular file under `base` and matches `include`, in order; a named path
59
+ * that is not a file is dropped. With no list, every file git tracks under
60
+ * `base`, as git names it.
61
+ */
62
+ export const selectDocs = (base, listed, include) => {
63
+ const paths = listed === undefined ? must(gitAt(base), ['ls-files', '-z']).split('\0').filter(Boolean) : entries(listed);
64
+ return paths.filter((path) => isFileAt(base, path) && include.test(path));
65
+ };
66
+ /** A document's lines as a shell `read` loop sees them: split on newlines, a last line without one included. */
67
+ export const linesOf = (text) => {
68
+ const lines = text.split('\n');
69
+ if (lines.at(-1) === '')
70
+ lines.pop();
71
+ return lines;
72
+ };
73
+ /** A listed document's text. */
74
+ export const readDoc = (base, path) => readFileSync(resolve(base, path), 'utf8');