@veda-hq/pariksha 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,8 @@
4
4
  test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
5
5
 
6
6
  ```sh
7
- npx @veda-hq/pariksha@0.5.17 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.17 --version
7
+ npx @veda-hq/pariksha@0.5.19 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.19 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
@@ -467,6 +467,44 @@ config (an unknown, missing or bad key or pattern), a malformed baseline, no
467
467
  `node_modules/.bin` (DOC-DRIFT-02 would know no bin), a missing or failing
468
468
  lychee, a `--base` with no merge base, or a failed git call.
469
469
 
470
+ ## `duplicate-tests`
471
+
472
+ ```sh
473
+ pariksha duplicate-tests [--config <file>] [--files-from <file>|-] [--root <dir>]
474
+ ```
475
+
476
+ A test copied rather than written: two tests whose matchers, names and
477
+ describe paths score at or above the threshold (default 0.85; across two
478
+ files never below 0.95). The score weighs the matcher set (`expect()` and
479
+ every `.to…()` call in the test, Jaccard) at 50%, the name at 20% and the
480
+ describe path at 30% (edit distance); `describe(X.name)` names its block `X`,
481
+ and two tests in no describe block compare their file stems. A pair is not
482
+ compared when either test has fewer than three matcher calls, either name is
483
+ generic (`should return`, …), the names are opposite cases (an opposite
484
+ pair, other HTTP methods or statuses, other uppercase `AND`/`OR`, another
485
+ endpoint), or, in one file, either name has a word the other lacks or the
486
+ same words come in another order.
487
+
488
+ Without `--files-from` every tracked file on disk is listed; with it, the
489
+ listed paths (one per line or NUL-separated, relative to the root or
490
+ absolute; `-` reads stdin). A listed path counts when `include` matches it
491
+ relative to the root. A file that cannot be read warns on stderr and is
492
+ skipped; a file that does not parse prints `❌ Parse error:` on stderr and
493
+ gives no tests. The report goes to stdout. The JSON `--config` (relative to
494
+ the root) replaces each default it names:
495
+
496
+ - `include` (default `\.(test|spec)\.(ts|tsx|js|jsx)$`): which paths are tests.
497
+ - `threshold` (default `0.85`).
498
+ - `commonNames`: names too generic to compare, lowercased.
499
+ - `oppositePairs`: `[word, word]` pairs that mark two names as opposite
500
+ cases (the default holds fifteen, such as `success`/`failure` and
501
+ `valid`/`invalid`). Names are lowercased before they are matched, so a pair
502
+ must be lowercase to match.
503
+
504
+ Needs `typescript` `>=5.4 <6`, as `coverage-by-type` does. A `.ts` file is
505
+ read as TypeScript and the rest as TSX; a `.ts` file that only parses as
506
+ TSX is read so, and one with a `<T>value` assertion gives no tests.
507
+
470
508
  ## `enforcement-claims`
471
509
 
472
510
  ```sh
@@ -740,6 +778,88 @@ root-relative POSIX path, a check not in `checks`, an `exemptIn` group
740
778
  baseline, a root holding no tracked file, a group discovering nothing, no
741
779
  base without `--no-base`, a failed git call, or no `typescript`.
742
780
 
781
+ ## `flaky-tests`
782
+
783
+ ```sh
784
+ pariksha flaky-tests [--config <file>] [--files-from <file>|-] [--runs <n>] [--root <dir>]
785
+ ```
786
+
787
+ Runs the test files `--runs` times (default 5, at most 1000) with
788
+ `npx vitest run --reporter=json --passWithNoTests`, the files under each
789
+ package root together from that root and the rest from the repository root,
790
+ and judges every run's JSON report strictly (a report that breaks Vitest's
791
+ contract, or a clean exit with no report or one saying `success: false`, is
792
+ not trusted). The verdicts, each exiting 1 but the last:
793
+
794
+ 1. a run exited non-zero without collecting a test case (an unloadable file,
795
+ a crashed runner): the runner output is printed, and any flaky test seen
796
+ in the other runs below it;
797
+ 2. a test passed in some runs and failed in others: flaky;
798
+ 3. every run failed, or a test failed in every run: not flaky, but broken;
799
+ 4. every run passed: `✅ No flaky tests found across N test cases.`
800
+
801
+ The files are listed as for `duplicate-tests`. The JSON `--config` (relative
802
+ to the root) replaces each default it names:
803
+
804
+ - `include` (default `\.(test|spec)\.(ts|tsx|js|jsx)$`): which paths are tests.
805
+ - `packageRoots` (default none): directories, relative to the root, whose
806
+ files Vitest runs from that directory, with its config.
807
+ - `exclude` (default none): globs passed to Vitest as `--exclude`.
808
+
809
+ ## `masked-exit-codes`
810
+
811
+ ```sh
812
+ pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]
813
+ ```
814
+
815
+ A gate that keeps reporting success after it stopped checking passes
816
+ everything: a hook that runs a commit-message linter and then `exit 0`, a
817
+ review step whose failures read as "no issues found", a baseline read with
818
+ `2>/dev/null || true`. `|| true` has honest uses (an optional tool, grep's
819
+ "no matches"), so this refuses only the shapes that silence a gate command's
820
+ verdict:
821
+
822
+ 1. `exit 0` at the end of a script that ran a gate, with no branch on the
823
+ gate's status;
824
+ 2. `|| true`, a `||` handler that does not exit non-zero, or `2>/dev/null`
825
+ with the status unchecked, on a gate command, also in a script the line
826
+ hands a shell (`eval '…'`, `bash -c '…'`, what `echo`/`printf` pipes
827
+ into `sh`) and inside `$( … )`;
828
+ 3. in a file `pipefailScope` matches, `echo`/`printf` of a variable piped
829
+ into a reader that stops early (`grep -q`, `-m`, `-l`, `head`): under
830
+ pipefail the writer can fail after the match, which reads as a miss.
831
+
832
+ The files are those git tracks under the root ending `.sh` or under a hook
833
+ directory, outside `node_modules/`, read from the working tree, or the index
834
+ with `--staged`. Comments, quoted text, heredoc bodies and continuation lines
835
+ are read as bash reads them. The JSON `--config` (relative to the root) is
836
+ optional:
837
+
838
+ ```json
839
+ {
840
+ "id": "SH-001",
841
+ "gateCommands": ["eslint", "tsc", "vitest", "prettier --check", "commitlint", "run-validation.sh"],
842
+ "hookDirs": [".husky/"],
843
+ "pipefailScope": ["^\\.husky/", "^scripts/lib/.+\\.sh$"]
844
+ }
845
+ ```
846
+
847
+ - `id` (default `masked-exit-codes`) names the check in its messages.
848
+ - `gateCommands` (default `eslint`, `tsc`, `vitest`, `prettier --check`,
849
+ `commitlint`, `cubic review`, `gitleaks`, `actionlint`, `shellcheck`,
850
+ `depcruise`, `stryker`) are the commands whose status is a verdict; one of
851
+ several words must appear whole. Keep it narrow: a broad list fires on
852
+ every optional-tool probe.
853
+ - `hookDirs` (default `.husky/`) are path prefixes whose files are shell
854
+ whatever their name.
855
+ - `pipefailScope` (default `^\.husky/`) are path regexes: the files that run
856
+ under `set -o pipefail`.
857
+
858
+ A clean run prints `✅ <id>: no masked exit codes (N shell file(s) scanned)`
859
+ to stdout; each finding goes to stderr with its line and why, and exits 1.
860
+ No shell file at all exits 1 (`the file lookup failed`), as the original
861
+ did; a bad config or a failed git call exits 2.
862
+
743
863
  ## `non-synced-resources`
744
864
 
745
865
  ```sh
@@ -869,6 +989,93 @@ Parsing those is the job of a command guard such as kavach's `git_safety`.
869
989
  project's settings and then also skips the user's own `defaultMode`, so it
870
990
  belongs in user settings only.
871
991
 
992
+ ## `shell-gates`
993
+
994
+ ```sh
995
+ pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]
996
+ ```
997
+
998
+ A shell script that works on its writer's Mac can fail on Linux CI, and a
999
+ git hook that picks up whatever `node` is on PATH can run a check under the
1000
+ wrong runtime. This runs three gates over the shell files a change writes:
1001
+
1002
+ 1. ShellCheck, a binary on PATH (install it: <https://www.shellcheck.net>),
1003
+ once per pass, by default `--severity=warning`, then `--severity=info
1004
+ --include=SC2086,SC2013,SC2015` (an unquoted expansion, `for x in $(cat
1005
+ f)`, `a && b || c`). Its targets are `*.sh`, `*.bash`, the hooks
1006
+ directory's direct children without an extension, and extensionless
1007
+ files whose first line is a sh, bash, dash or ksh `#!`. Staged mode
1008
+ writes the index's copy of each to a temporary directory and judges
1009
+ that; base mode judges the HEAD commit's copy of what the branch changed
1010
+ since its merge base with `<ref>`; `--all` judges the working tree; files
1011
+ mode passes the listed paths as they are.
1012
+ 2. The hook preamble, over the hooks directory's direct children without an
1013
+ extension. A hook must source `selectScript` and call `selectFunction`
1014
+ (with `|| exit 1`, or a branch that exits) before it runs a runtime
1015
+ (`runtimes`, by default `node`, `npx`, `pnpm`, `npm`, `tsx`, `corepack`,
1016
+ `yarn`), here or in a function it calls; an opt-out (`[ "$X" = 0 ] &&
1017
+ exit 0`) comes before that selection; it never edits PATH or drives
1018
+ nvm, fnm or mise itself; it never `exec sh <hook>`s (that ignores the
1019
+ target's shebang); and an INT, TERM, HUP or QUIT trap's handler ends in
1020
+ `exit`, or bash resumes after it and a cancelled hook carries on.
1021
+ 3. The pitfalls, over every shell target: `grep -E` or `egrep` with `\s`,
1022
+ `\S`, `\d` or `\D` (not POSIX ERE: BSD grep matches nothing); GNU-only
1023
+ `xargs -d`/`-r`; `xargs` without `-0` fed a file list; file operands
1024
+ after `xargs`, or a list expansion to grep, without `--`; `<( … )` in a
1025
+ file `validatorPaths` matches; a literal `/tmp/<name>` (use `mktemp`);
1026
+ an unguarded `/opt/homebrew` or `/Users/` literal, a fixed `-avd
1027
+ <name>`, a personal device name; `NVM_DIR` set without honouring an
1028
+ existing one; `lockf` without `-k`; `$(( … ))` over a value from `read`
1029
+ or `date` without `10#` (`08` is bad octal); `dirname "$0"` beside
1030
+ `BASH_SOURCE`; `git worktree list` parsed without `--porcelain`; and
1031
+ `grep -o` over a JSON `"key":`. Comments, quoted text and heredoc bodies
1032
+ are read as bash reads them.
1033
+
1034
+ The preamble and pitfalls judge the same kind of target, less `exclude`
1035
+ and symlinks: `--staged` (the default) the staged copies, `--base` the
1036
+ files changed since the merge base with `<ref>` (read from the working
1037
+ tree), `--all` every tracked one, `--files-from` the listed paths (one per
1038
+ line or NUL-separated; a file, or `-` for stdin). A change under
1039
+ `gateInputs` (staged, or on the branch) re-judges every target, and a
1040
+ staged change to a `shellcheck.inputs` path makes ShellCheck judge the
1041
+ whole index. `--only preamble,pitfalls` runs
1042
+ only those checks, and no ShellCheck. The JSON `--config` (relative to the
1043
+ root) is optional:
1044
+
1045
+ ```json
1046
+ {
1047
+ "hooksDir": ".husky/",
1048
+ "exclude": ["node_modules/", ".husky/_/", "vendor/"],
1049
+ "gateInputs": ["scripts/validations/shell/"],
1050
+ "preamble": { "id": "SHELL-06", "selectScript": "scripts/lib/select-node.sh", "selectFunction": "select_node" },
1051
+ "pitfalls": { "id": "SHELL-03", "validatorPaths": ["^scripts/validations/"] },
1052
+ "shellcheck": { "inputs": ["scripts/run-validation.sh"], "install": "run the machine setup (or: brew install shellcheck)" },
1053
+ "guidance": ["", " SHELL-06 and SHELL-03 are described in docs/shell.md."]
1054
+ }
1055
+ ```
1056
+
1057
+ - `hooksDir` (default `.husky/`, ending in `/`) is the git hooks directory.
1058
+ - `exclude` (default `node_modules/`, `.husky/_/`) are path prefixes never
1059
+ judged by the preamble and pitfalls; a `/node_modules/` segment never is.
1060
+ - `gateInputs` (default none) are path prefixes of the gate's own inputs.
1061
+ - `preamble` (default none, which leaves only the pitfalls to run):
1062
+ `selectScript` and `selectFunction` are required; `id` defaults to
1063
+ `hook-preamble`, `runtimes` to the list above.
1064
+ - `pitfalls`: `id` (default `shell-pitfalls`) and `validatorPaths` (path
1065
+ regexes, default none).
1066
+ - `shellcheck`: `passes` (lists of flags after `--norc`; `[]` runs no
1067
+ ShellCheck), `inputs` (default none) and `install`, the text a missing
1068
+ ShellCheck message gives.
1069
+ - `guidance` (default a pointer to this section) is printed after the
1070
+ findings.
1071
+
1072
+ ShellCheck's own report comes first, then `✅ Shell gates: N file(s) pass
1073
+ <checks>`, or `❌ Shell gates: N finding(s) in M file(s)` on stderr with
1074
+ each finding as `file:line <id> <rule>` and its message, then the
1075
+ guidance. It exits 1 on any ShellCheck or rule finding, or when ShellCheck
1076
+ is not installed; 2 on a bad config, two modes, an empty list, or a
1077
+ selection or git failure, since nothing was checked.
1078
+
872
1079
  ## markdownlint rules
873
1080
 
874
1081
  ```jsonc
package/dist/cli.js CHANGED
@@ -7,11 +7,13 @@ import { fileURLToPath } from 'node:url';
7
7
  import { checkAbsolutePaths, DEFAULT_GUIDANCE as ABSOLUTE_PATHS_GUIDANCE } from "./absolute-paths.js";
8
8
  import { checkGates, DEFAULT_GATE_CONFIG, parseGateConfig as parseAggregateConfig } from "./aggregate-gate-needs.js";
9
9
  import { parseDocConfig, readDoc, selectDocs } from "./doc-files.js";
10
+ import { DEFAULT_MASKED_CONFIG, findMaskedExits, formatMasked, parseMaskedConfig, readerFor as maskedReaderFor, shellFiles } from "./masked-exit-codes.js";
10
11
  import { checkNonSyncedResources, DEFAULT_GUIDANCE as NON_SYNCED_GUIDANCE } from "./non-synced-resources.js";
12
+ import { ALL_CHECKS as SHELL_CHECKS, parseGatesConfig, runShellGates, shellcheckRunner } from "./shell-gates.js";
11
13
  import { checkPnpmSettings, DEFAULT_PNPM_CONFIG, formatParity, parsePnpmConfig } from "./pnpm-settings.js";
12
14
  import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
13
15
  import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
14
- import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
16
+ import { DEFAULT_CONFIG, entries, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
15
17
  import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
16
18
  import { checkBudgets } from "./doc-budgets.js";
17
19
  import { lycheeRunner } from "./doc-links.js";
@@ -27,6 +29,8 @@ import { discover as discoverCodecs, fileOf as codecFileOf, findings as codecFin
27
29
  import { discoverGroup, findings as faultFindings, formatFindings as formatFaultFindings, parseBaseline as parseFaultBaseline, parseConfig as parseFaultConfig } from "./fault-injection-registry.js";
28
30
  import { emptyRoots, growthSince, readBaseline, wholeId } from "./registry-ratchet.js";
29
31
  import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfig } from "./enforcement-claims.js";
32
+ import { parseConfig as parseDuplicateConfig, runDuplicateTests } from "./duplicate-tests.js";
33
+ import { DEFAULT_RUNS, npxVitest, parseConfig as parseFlakyConfig, parseRuns, runFlakyTests } from "./flaky-tests.js";
30
34
  export const DENY_FILE = '.veda/deny.sha256';
31
35
  const USAGE = [
32
36
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
@@ -38,13 +42,17 @@ const USAGE = [
38
42
  ' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
39
43
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
40
44
  ' pariksha doc-references --config <file> (--staged | --base <ref> [--head <ref>] | --all [--update-baseline | --write-baseline]) [--checks <ids>] [--root <dir>]',
45
+ ' pariksha duplicate-tests [--config <file>] [--files-from <file>|-] [--root <dir>]',
41
46
  ' pariksha enforcement-claims --config <file> [--root <dir>]',
42
47
  ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
43
48
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
44
49
  ' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
50
+ ' pariksha flaky-tests [--config <file>] [--files-from <file>|-] [--runs <n>] [--root <dir>]',
51
+ ' pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]',
45
52
  ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
46
53
  ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
47
54
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
55
+ ' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
48
56
  ' pariksha hash <word>',
49
57
  ' pariksha --version',
50
58
  ].join('\n');
@@ -120,6 +128,76 @@ const nonSyncedResources = docCheck('non-synced-resources', NON_SYNCED_GUIDANCE,
120
128
  throw new Error(`${base} is not in a git repository`);
121
129
  return top;
122
130
  }, config));
131
+ /** Standard input, or the file `from` names, read whole. */
132
+ const listFrom = (from, io) => (from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8'));
133
+ /**
134
+ * masked-exit-codes: the shell files git tracks under `--root`, read from
135
+ * the working tree, or the index with `--staged`. The report goes to stdout
136
+ * when clean and stderr when not, as the original printed it.
137
+ */
138
+ const maskedExitCodes = (f, io) => {
139
+ const root = resolve(f.get('--root') ?? '.');
140
+ try {
141
+ const configPath = f.get('--config');
142
+ const config = configPath === undefined ? DEFAULT_MASKED_CONFIG : parseMaskedConfig(readFileSync(resolve(root, configPath), 'utf8'));
143
+ const files = shellFiles(root, config);
144
+ const problems = files.length === 0 ? [] : findMaskedExits(root, files, maskedReaderFor(f.has('--staged')), config);
145
+ const result = formatMasked(problems, files.length, config.id);
146
+ result.out.forEach(io.out);
147
+ result.err.forEach(io.err);
148
+ return result.ok ? 0 : 1;
149
+ }
150
+ catch (e) {
151
+ io.err(`pariksha masked-exit-codes: ${e.message}; nothing was checked, so this is not a pass`);
152
+ return 2;
153
+ }
154
+ };
155
+ /**
156
+ * shell-gates: one of `--staged` (the default), `--all`, `--base <ref>` or
157
+ * `--files-from <file>|-`; `--only` names the checks to run, and then
158
+ * ShellCheck sits the run out, as the original did.
159
+ */
160
+ const shellGates = (f, io) => {
161
+ const P = '❌ Shell gates could not run:';
162
+ const root = resolve(f.get('--root') ?? '.');
163
+ const modes = ['--staged', '--all', '--base', '--files-from'].filter((name) => f.has(name));
164
+ if (modes.length > 1) {
165
+ io.err(`${P} choose one of --staged, --all, --base, --files-from (got ${modes.join(', ')})`);
166
+ return 2;
167
+ }
168
+ let config;
169
+ let mode;
170
+ try {
171
+ const configPath = f.get('--config');
172
+ config = parseGatesConfig(configPath === undefined ? '{}' : readFileSync(resolve(root, configPath), 'utf8'));
173
+ const base = f.get('--base');
174
+ const from = f.get('--files-from');
175
+ if (from !== undefined) {
176
+ const files = entries(listFrom(from, io));
177
+ if (files.length === 0)
178
+ throw new Error('the --files-from list names no file, and a check of nothing is no pass');
179
+ mode = { kind: 'files', files };
180
+ }
181
+ else
182
+ mode = base !== undefined ? { kind: 'base', base } : f.has('--all') ? { kind: 'all' } : { kind: 'staged' };
183
+ }
184
+ catch (e) {
185
+ io.err(`${P} ${e.message}`);
186
+ return 2;
187
+ }
188
+ const only = f.get('--only');
189
+ let checks = SHELL_CHECKS.filter((name) => name !== 'preamble' || config.preamble !== undefined);
190
+ if (only !== undefined) {
191
+ const names = only.split(',').filter(Boolean);
192
+ const unknown = names.filter((name) => !SHELL_CHECKS.some((check) => check === name));
193
+ if (names.length === 0 || unknown.length > 0) {
194
+ io.err(`${P} --only takes ${SHELL_CHECKS.join(',')}; got ${names.join(',') || 'nothing'}`);
195
+ return 2;
196
+ }
197
+ checks = SHELL_CHECKS.filter((name) => names.includes(name));
198
+ }
199
+ return runShellGates({ root, mode, checks, only: only !== undefined }, config, { out: io.out, err: io.err, shellcheck: io.shellcheck ?? shellcheckRunner() });
200
+ };
123
201
  const aggregateGateNeeds = (f, io) => {
124
202
  const root = resolve(f.get('--root') ?? '.');
125
203
  const configPath = f.get('--config');
@@ -620,6 +698,77 @@ const requiredSettings = (f, io) => {
620
698
  result.err.forEach(io.err);
621
699
  return result.ok ? 0 : 1;
622
700
  };
701
+ /**
702
+ * The test files duplicate-tests and flaky-tests judge, relative to the root:
703
+ * those `--files-from` lists (mode `files`; a relative entry is read from the
704
+ * root), or every tracked file on disk (mode `all`), kept when `include`
705
+ * matches the root-relative path.
706
+ */
707
+ const testFiles = (f, io, root, include) => {
708
+ const from = f.get('--files-from');
709
+ const listed = from === undefined
710
+ ? must(gitAt(root), ['ls-files', '-z'])
711
+ .split('\0')
712
+ .filter((p) => p && existsSync(join(root, p)))
713
+ : entries(from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8')).map((e) => relative(root, resolve(root, e)));
714
+ return { mode: from === undefined ? 'all' : 'files', files: listed.filter((p) => include.test(p)) };
715
+ };
716
+ /** duplicate-tests: the report on stdout, warnings and parse errors on stderr, as the original printed them. */
717
+ const duplicateTests = (f, io) => {
718
+ const P = 'pariksha duplicate-tests:';
719
+ const dir = resolve(f.get('--root') ?? '.');
720
+ const root = topOf(dir);
721
+ if (root === undefined) {
722
+ io.err(`${P} ${dir} is not in a git repository`);
723
+ return 2;
724
+ }
725
+ const ts = (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))();
726
+ if (typeof ts === 'string') {
727
+ io.err(`${P} ${ts}`);
728
+ return 2;
729
+ }
730
+ try {
731
+ const configPath = f.get('--config');
732
+ const config = parseDuplicateConfig(configPath === undefined ? undefined : readFileSync(resolve(root, configPath), 'utf8'));
733
+ const { mode, files } = testFiles(f, io, root, config.include);
734
+ const listed = files.map((file) => ({ file, path: resolve(root, file), read: () => readFileSync(resolve(root, file), 'utf8') }));
735
+ const result = runDuplicateTests(ts, config, mode, listed);
736
+ result.out.forEach(io.out);
737
+ result.err.forEach(io.err);
738
+ return result.code;
739
+ }
740
+ catch (e) {
741
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
742
+ return 2;
743
+ }
744
+ };
745
+ /** flaky-tests: `--runs` passes (default 5) of Vitest over the test files, from each package root. */
746
+ const flakyTests = (f, io) => {
747
+ const P = 'pariksha flaky-tests:';
748
+ const runsText = f.get('--runs');
749
+ const runs = runsText === undefined ? DEFAULT_RUNS : parseRuns(runsText);
750
+ if (runs === null) {
751
+ io.err('--runs must be an integer from 1 to 1000');
752
+ return 2;
753
+ }
754
+ const dir = resolve(f.get('--root') ?? '.');
755
+ const root = topOf(dir);
756
+ if (root === undefined) {
757
+ io.err(`${P} ${dir} is not in a git repository`);
758
+ return 2;
759
+ }
760
+ let selected;
761
+ try {
762
+ const configPath = f.get('--config');
763
+ const config = parseFlakyConfig(configPath === undefined ? undefined : readFileSync(resolve(root, configPath), 'utf8'));
764
+ selected = { config, ...testFiles(f, io, root, config.include) };
765
+ }
766
+ catch (e) {
767
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
768
+ return 2;
769
+ }
770
+ return runFlakyTests(selected.config, runs, selected.mode, root, selected.files, io.vitest ?? npxVitest, io.out);
771
+ };
623
772
  const COMMANDS = {
624
773
  'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
625
774
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
@@ -633,10 +782,14 @@ const COMMANDS = {
633
782
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
634
783
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
635
784
  'doc-references': { allowed: ['--config', '--base', '--head', '--checks', '--root'], switches: ['--staged', '--all', '--update-baseline', '--write-baseline'], run: docReferences },
785
+ 'duplicate-tests': { allowed: ['--config', '--files-from', '--root'], run: duplicateTests },
636
786
  'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
787
+ 'flaky-tests': { allowed: ['--config', '--files-from', '--runs', '--root'], run: flakyTests },
637
788
  'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
638
789
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
790
+ 'masked-exit-codes': { allowed: ['--config', '--root'], switches: ['--staged'], run: maskedExitCodes },
639
791
  'required-settings': { allowed: ['--require', '--settings', '--root'], run: requiredSettings },
792
+ 'shell-gates': { allowed: ['--config', '--base', '--files-from', '--only', '--root'], switches: ['--staged', '--all'], run: shellGates },
640
793
  };
641
794
  /** The exit code; a Promise of it for the one check that is asynchronous (enforcement-claims, which loads ESLint). */
642
795
  export const run = (args, io, version = ownVersion(), env = process.env) => {