@veda-hq/pariksha 0.5.18 → 0.5.20
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 +109 -2
- package/dist/cli.js +100 -0
- package/dist/duplicate-tests.js +367 -0
- package/dist/flaky-tests.js +252 -0
- package/dist/python-floor.js +32 -0
- package/dist/vitest-json-report.js +129 -0
- package/package.json +3 -2
- package/python/python-floor.py +631 -0
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
npx @veda-hq/pariksha@0.5.
|
|
8
|
-
npx @veda-hq/pariksha@0.5.
|
|
7
|
+
npx @veda-hq/pariksha@0.5.20 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.20 --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,34 @@ 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
|
+
|
|
743
809
|
## `masked-exit-codes`
|
|
744
810
|
|
|
745
811
|
```sh
|
|
@@ -865,6 +931,47 @@ refuse is not passed: it is printed as a selector not version-checked.
|
|
|
865
931
|
line to stdout; a missing input file is a failure (exit 1), a bad config or
|
|
866
932
|
an unreadable `package.json` exits 2.
|
|
867
933
|
|
|
934
|
+
## `python-floor`
|
|
935
|
+
|
|
936
|
+
```sh
|
|
937
|
+
pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
Every tracked Python source runs on the Python the repository declares. The
|
|
941
|
+
floor drifts unseen: CI runners ship a new Python, and a PEP 604 union or a
|
|
942
|
+
fresh stdlib import reads as ordinary code until it fails on an older
|
|
943
|
+
interpreter, inside whatever was running it. Sources are `*.py` files, and
|
|
944
|
+
heredocs and single-quoted `python3 -c '...'` payloads in `*.sh` files and
|
|
945
|
+
under `.husky/`, read from the index (the commit being made, not the working
|
|
946
|
+
tree). Each is refused for:
|
|
947
|
+
|
|
948
|
+
1. syntax the floor cannot parse (`ast` with `feature_version` at the floor);
|
|
949
|
+
2. a stdlib module added after the floor, or removed at or before it;
|
|
950
|
+
3. on a floor below 3.10, a PEP 604 `X | Y` that is evaluated.
|
|
951
|
+
|
|
952
|
+
A source it cannot read (an unknown encoding, a symlink, conflict stages) is
|
|
953
|
+
refused, never skipped.
|
|
954
|
+
|
|
955
|
+
- `--python` names the interpreter, at or above the floor; pariksha never
|
|
956
|
+
chooses or bundles one. Below the floor it refuses to judge (exit 2).
|
|
957
|
+
- `--floor-file` is the file, relative to the root and tracked, holding one
|
|
958
|
+
`MAJOR.MINOR` (`#` comments allowed).
|
|
959
|
+
- `--exclude` (default none) is a Python regular expression matched from the
|
|
960
|
+
start of each path; what it matches is not checked. One that does not
|
|
961
|
+
compile exits 2.
|
|
962
|
+
- `--selector` (default none) names what picks an interpreter at or above
|
|
963
|
+
the floor in this repository, for the message given when `--python` is
|
|
964
|
+
below it.
|
|
965
|
+
|
|
966
|
+
The check is a stdlib-only script shipped in the package
|
|
967
|
+
(`python/python-floor.py`), run as `<interpreter> python/python-floor.py`;
|
|
968
|
+
its stdout (the clean line), stderr (findings) and exit code are passed
|
|
969
|
+
through unchanged. An interpreter that cannot be started, or that is stopped
|
|
970
|
+
by a signal, exits 2. Two input errors exit 1, as the script always has: a
|
|
971
|
+
floor file that is not in the index, not a regular file there, or not one
|
|
972
|
+
`MAJOR.MINOR` (one line on stderr), and a `--root` outside a git repository
|
|
973
|
+
(a Python traceback).
|
|
974
|
+
|
|
868
975
|
## `required-settings`
|
|
869
976
|
|
|
870
977
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,9 @@ import { discover as discoverCodecs, fileOf as codecFileOf, findings as codecFin
|
|
|
29
29
|
import { discoverGroup, findings as faultFindings, formatFindings as formatFaultFindings, parseBaseline as parseFaultBaseline, parseConfig as parseFaultConfig } from "./fault-injection-registry.js";
|
|
30
30
|
import { emptyRoots, growthSince, readBaseline, wholeId } from "./registry-ratchet.js";
|
|
31
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";
|
|
34
|
+
import { inheritRunner, runPythonFloor } from "./python-floor.js";
|
|
32
35
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
33
36
|
const USAGE = [
|
|
34
37
|
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
@@ -40,13 +43,16 @@ const USAGE = [
|
|
|
40
43
|
' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
|
|
41
44
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
42
45
|
' pariksha doc-references --config <file> (--staged | --base <ref> [--head <ref>] | --all [--update-baseline | --write-baseline]) [--checks <ids>] [--root <dir>]',
|
|
46
|
+
' pariksha duplicate-tests [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
43
47
|
' pariksha enforcement-claims --config <file> [--root <dir>]',
|
|
44
48
|
' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
|
|
45
49
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
46
50
|
' pariksha fault-injection-registry --config <file> [--base <ref> | --no-base] [--root <dir>]',
|
|
51
|
+
' pariksha flaky-tests [--config <file>] [--files-from <file>|-] [--runs <n>] [--root <dir>]',
|
|
47
52
|
' pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]',
|
|
48
53
|
' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
49
54
|
' pariksha pnpm-settings [--config <file>] [--root <dir>]',
|
|
55
|
+
' pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]',
|
|
50
56
|
' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
|
|
51
57
|
' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
|
|
52
58
|
' pariksha hash <word>',
|
|
@@ -694,6 +700,97 @@ const requiredSettings = (f, io) => {
|
|
|
694
700
|
result.err.forEach(io.err);
|
|
695
701
|
return result.ok ? 0 : 1;
|
|
696
702
|
};
|
|
703
|
+
/**
|
|
704
|
+
* The test files duplicate-tests and flaky-tests judge, relative to the root:
|
|
705
|
+
* those `--files-from` lists (mode `files`; a relative entry is read from the
|
|
706
|
+
* root), or every tracked file on disk (mode `all`), kept when `include`
|
|
707
|
+
* matches the root-relative path.
|
|
708
|
+
*/
|
|
709
|
+
const testFiles = (f, io, root, include) => {
|
|
710
|
+
const from = f.get('--files-from');
|
|
711
|
+
const listed = from === undefined
|
|
712
|
+
? must(gitAt(root), ['ls-files', '-z'])
|
|
713
|
+
.split('\0')
|
|
714
|
+
.filter((p) => p && existsSync(join(root, p)))
|
|
715
|
+
: entries(from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8')).map((e) => relative(root, resolve(root, e)));
|
|
716
|
+
return { mode: from === undefined ? 'all' : 'files', files: listed.filter((p) => include.test(p)) };
|
|
717
|
+
};
|
|
718
|
+
/** duplicate-tests: the report on stdout, warnings and parse errors on stderr, as the original printed them. */
|
|
719
|
+
const duplicateTests = (f, io) => {
|
|
720
|
+
const P = 'pariksha duplicate-tests:';
|
|
721
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
722
|
+
const root = topOf(dir);
|
|
723
|
+
if (root === undefined) {
|
|
724
|
+
io.err(`${P} ${dir} is not in a git repository`);
|
|
725
|
+
return 2;
|
|
726
|
+
}
|
|
727
|
+
const ts = (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))();
|
|
728
|
+
if (typeof ts === 'string') {
|
|
729
|
+
io.err(`${P} ${ts}`);
|
|
730
|
+
return 2;
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
const configPath = f.get('--config');
|
|
734
|
+
const config = parseDuplicateConfig(configPath === undefined ? undefined : readFileSync(resolve(root, configPath), 'utf8'));
|
|
735
|
+
const { mode, files } = testFiles(f, io, root, config.include);
|
|
736
|
+
const listed = files.map((file) => ({ file, path: resolve(root, file), read: () => readFileSync(resolve(root, file), 'utf8') }));
|
|
737
|
+
const result = runDuplicateTests(ts, config, mode, listed);
|
|
738
|
+
result.out.forEach(io.out);
|
|
739
|
+
result.err.forEach(io.err);
|
|
740
|
+
return result.code;
|
|
741
|
+
}
|
|
742
|
+
catch (e) {
|
|
743
|
+
io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
|
|
744
|
+
return 2;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
/** flaky-tests: `--runs` passes (default 5) of Vitest over the test files, from each package root. */
|
|
748
|
+
const flakyTests = (f, io) => {
|
|
749
|
+
const P = 'pariksha flaky-tests:';
|
|
750
|
+
const runsText = f.get('--runs');
|
|
751
|
+
const runs = runsText === undefined ? DEFAULT_RUNS : parseRuns(runsText);
|
|
752
|
+
if (runs === null) {
|
|
753
|
+
io.err('--runs must be an integer from 1 to 1000');
|
|
754
|
+
return 2;
|
|
755
|
+
}
|
|
756
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
757
|
+
const root = topOf(dir);
|
|
758
|
+
if (root === undefined) {
|
|
759
|
+
io.err(`${P} ${dir} is not in a git repository`);
|
|
760
|
+
return 2;
|
|
761
|
+
}
|
|
762
|
+
let selected;
|
|
763
|
+
try {
|
|
764
|
+
const configPath = f.get('--config');
|
|
765
|
+
const config = parseFlakyConfig(configPath === undefined ? undefined : readFileSync(resolve(root, configPath), 'utf8'));
|
|
766
|
+
selected = { config, ...testFiles(f, io, root, config.include) };
|
|
767
|
+
}
|
|
768
|
+
catch (e) {
|
|
769
|
+
io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
|
|
770
|
+
return 2;
|
|
771
|
+
}
|
|
772
|
+
return runFlakyTests(selected.config, runs, selected.mode, root, selected.files, io.vitest ?? npxVitest, io.out);
|
|
773
|
+
};
|
|
774
|
+
/** python-floor: the packaged script under the `--python` the caller names, its output and exit code passed through. */
|
|
775
|
+
const pythonFloor = (f, io) => {
|
|
776
|
+
const python = f.get('--python');
|
|
777
|
+
const floorFile = f.get('--floor-file');
|
|
778
|
+
if (python === undefined || floorFile === undefined) {
|
|
779
|
+
io.err(USAGE);
|
|
780
|
+
return 2;
|
|
781
|
+
}
|
|
782
|
+
const root = f.get('--root');
|
|
783
|
+
const exclude = f.get('--exclude');
|
|
784
|
+
const selector = f.get('--selector');
|
|
785
|
+
const options = {
|
|
786
|
+
python,
|
|
787
|
+
floorFile,
|
|
788
|
+
...(root === undefined ? {} : { root }),
|
|
789
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
790
|
+
...(selector === undefined ? {} : { selector }),
|
|
791
|
+
};
|
|
792
|
+
return runPythonFloor(options, io.python ?? inheritRunner, io.err);
|
|
793
|
+
};
|
|
697
794
|
const COMMANDS = {
|
|
698
795
|
'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
|
|
699
796
|
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
@@ -702,12 +799,15 @@ const COMMANDS = {
|
|
|
702
799
|
'aggregate-gate-needs': { allowed: ['--config', '--root'], run: aggregateGateNeeds },
|
|
703
800
|
'non-synced-resources': { allowed: ['--config', '--files-from', '--root'], run: nonSyncedResources },
|
|
704
801
|
'pnpm-settings': { allowed: ['--config', '--root'], run: pnpmSettings },
|
|
802
|
+
'python-floor': { allowed: ['--python', '--floor-file', '--exclude', '--selector', '--root'], run: pythonFloor },
|
|
705
803
|
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
706
804
|
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
707
805
|
'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
|
|
708
806
|
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
709
807
|
'doc-references': { allowed: ['--config', '--base', '--head', '--checks', '--root'], switches: ['--staged', '--all', '--update-baseline', '--write-baseline'], run: docReferences },
|
|
808
|
+
'duplicate-tests': { allowed: ['--config', '--files-from', '--root'], run: duplicateTests },
|
|
710
809
|
'enforcement-claims': { allowed: ['--config', '--root'], run: enforcementClaims },
|
|
810
|
+
'flaky-tests': { allowed: ['--config', '--files-from', '--runs', '--root'], run: flakyTests },
|
|
711
811
|
'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
|
|
712
812
|
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
713
813
|
'masked-exit-codes': { allowed: ['--config', '--root'], switches: ['--staged'], run: maskedExitCodes },
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// duplicate-tests: test cases that look copied, by a weighted similarity of
|
|
2
|
+
// their assertion matchers, their names and their describe paths.
|
|
3
|
+
//
|
|
4
|
+
// Read with the TypeScript compiler API (the optional peer), walking the nodes
|
|
5
|
+
// a Babel CallExpression visitor would reach: an optional-chain call is not
|
|
6
|
+
// one, and parentheses are not nodes.
|
|
7
|
+
import { basename } from 'node:path';
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
include: /\.(test|spec)\.(ts|tsx|js|jsx)$/,
|
|
10
|
+
threshold: 0.85,
|
|
11
|
+
commonNames: ['should be defined', 'should exist', 'should return', 'should throw', 'should create', 'should update', 'should delete', 'should get', 'should set'],
|
|
12
|
+
oppositePairs: [
|
|
13
|
+
['success', 'failure'],
|
|
14
|
+
['success', 'error'],
|
|
15
|
+
['pass', 'fail'],
|
|
16
|
+
['valid', 'invalid'],
|
|
17
|
+
['correct', 'incorrect'],
|
|
18
|
+
['positive', 'negative'],
|
|
19
|
+
['zero', 'negative'],
|
|
20
|
+
['true', 'false'],
|
|
21
|
+
['enabled', 'disabled'],
|
|
22
|
+
['before', 'after'],
|
|
23
|
+
['start', 'end'],
|
|
24
|
+
['to', 'from'],
|
|
25
|
+
['forward', 'backward'],
|
|
26
|
+
['add', 'remove'],
|
|
27
|
+
['create', 'delete'],
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
31
|
+
const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
32
|
+
/** The config a product passes; each field replaces its default, and an unknown field is refused. */
|
|
33
|
+
export const parseConfig = (text) => {
|
|
34
|
+
if (text === undefined)
|
|
35
|
+
return DEFAULT_CONFIG;
|
|
36
|
+
const raw = JSON.parse(text);
|
|
37
|
+
if (!isRecord(raw))
|
|
38
|
+
throw new Error('the config is not a JSON object');
|
|
39
|
+
const known = ['include', 'threshold', 'commonNames', 'oppositePairs'];
|
|
40
|
+
const unknown = Object.keys(raw).filter((k) => !known.includes(k));
|
|
41
|
+
if (unknown.length > 0)
|
|
42
|
+
throw new Error(`the config has unknown field(s): ${unknown.join(', ')}`);
|
|
43
|
+
const { include, threshold, commonNames, oppositePairs } = raw;
|
|
44
|
+
if (include !== undefined && typeof include !== 'string')
|
|
45
|
+
throw new Error('include is not a regex string');
|
|
46
|
+
if (threshold !== undefined && (typeof threshold !== 'number' || !Number.isFinite(threshold)))
|
|
47
|
+
throw new Error('threshold is not a number');
|
|
48
|
+
if (commonNames !== undefined && !isStrings(commonNames))
|
|
49
|
+
throw new Error('commonNames is not a list of strings');
|
|
50
|
+
if (oppositePairs !== undefined && !(Array.isArray(oppositePairs) && oppositePairs.every((p) => isStrings(p) && p.length === 2))) {
|
|
51
|
+
throw new Error('oppositePairs is not a list of [word, word] pairs');
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
include: include === undefined ? DEFAULT_CONFIG.include : new RegExp(include),
|
|
55
|
+
threshold: threshold ?? DEFAULT_CONFIG.threshold,
|
|
56
|
+
commonNames: commonNames ?? DEFAULT_CONFIG.commonNames,
|
|
57
|
+
oppositePairs: oppositePairs ?? DEFAULT_CONFIG.oppositePairs,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/** Through parentheses, which Babel does not keep as nodes. */
|
|
61
|
+
const unwrap = (ts, node) => {
|
|
62
|
+
let n = node;
|
|
63
|
+
while (ts.isParenthesizedExpression(n))
|
|
64
|
+
n = n.expression;
|
|
65
|
+
return n;
|
|
66
|
+
};
|
|
67
|
+
const inChain = (ts, node) => (node.flags & ts.NodeFlags.OptionalChain) !== 0;
|
|
68
|
+
/** A call Babel reads as a CallExpression: not one inside an optional chain. */
|
|
69
|
+
const isPlainCall = (ts, node) => ts.isCallExpression(node) && !inChain(ts, node);
|
|
70
|
+
const calleeName = (ts, call) => {
|
|
71
|
+
const callee = unwrap(ts, call.expression);
|
|
72
|
+
return ts.isIdentifier(callee) ? callee.text : undefined;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* The matcher a call names, as Babel's MemberExpression with an Identifier
|
|
76
|
+
* property: `a.toBe()`, and `a[toBe]()` too; never `a?.toBe()` or `a['toBe']()`.
|
|
77
|
+
*/
|
|
78
|
+
const memberProperty = (ts, call) => {
|
|
79
|
+
const callee = unwrap(ts, call.expression);
|
|
80
|
+
if (inChain(ts, callee))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (ts.isPropertyAccessExpression(callee))
|
|
83
|
+
return ts.isIdentifier(callee.name) ? callee.name.text : undefined;
|
|
84
|
+
if (ts.isElementAccessExpression(callee)) {
|
|
85
|
+
const arg = unwrap(ts, callee.argumentExpression);
|
|
86
|
+
return ts.isIdentifier(arg) ? arg.text : undefined;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
};
|
|
90
|
+
const sourceText = (sf, node) => sf.text.slice(node.getStart(sf), node.end);
|
|
91
|
+
/**
|
|
92
|
+
* The name a describe/test/it call gives its block. A string literal (or a
|
|
93
|
+
* template without expressions) yields its value; `describe(X.name)` yields
|
|
94
|
+
* `X`, the unit under test; any other argument its source text.
|
|
95
|
+
*/
|
|
96
|
+
export const extractTestName = (ts, sf, call) => {
|
|
97
|
+
const [arg] = call.arguments;
|
|
98
|
+
if (arg === undefined)
|
|
99
|
+
return 'unnamed test';
|
|
100
|
+
const first = unwrap(ts, arg);
|
|
101
|
+
if (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first))
|
|
102
|
+
return first.text;
|
|
103
|
+
const named = ts.isPropertyAccessExpression(first) && !inChain(ts, first) && ts.isIdentifier(first.name) && first.name.text === 'name' ? unwrap(ts, first.expression) : first;
|
|
104
|
+
return sourceText(sf, named);
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* TS for a `.ts` file, TSX for the rest. Babel had JSX on everywhere but reads
|
|
108
|
+
* `<T>(x) => …` in a `.ts` file as a generic arrow; TypeScript under TSX reads
|
|
109
|
+
* it as an element and loses the rest of the file.
|
|
110
|
+
*/
|
|
111
|
+
const scriptKind = (ts, file) => (/\.[cm]?ts$/.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.TSX);
|
|
112
|
+
/** The syntax errors the parse recorded; `parseDiagnostics` is the source file's own list, read without a program. */
|
|
113
|
+
const syntaxErrors = (sf) => sf.parseDiagnostics ?? [];
|
|
114
|
+
/**
|
|
115
|
+
* Syntax TypeScript reads and the Babel parse this replaces (plugins
|
|
116
|
+
* typescript, jsx and decorators-legacy) threw on, so the file gave no
|
|
117
|
+
* signatures: a `<T>value` assertion in a `.ts` file, which JSX reads as an
|
|
118
|
+
* unterminated element, and an `accessor` field, which needs a plugin it did
|
|
119
|
+
* not have.
|
|
120
|
+
*/
|
|
121
|
+
const babelRefusal = (ts, kind, node) => {
|
|
122
|
+
if (kind === ts.ScriptKind.TS && ts.isTypeAssertionExpression(node))
|
|
123
|
+
return 'a <T>value type assertion, which a JSX parse reads as an unterminated element';
|
|
124
|
+
if (ts.isPropertyDeclaration(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AccessorKeyword) === true)
|
|
125
|
+
return 'an accessor field, which needs the decoratorAutoAccessors parser plugin';
|
|
126
|
+
if (ts.isClassLike(node)) {
|
|
127
|
+
const modifiers = node.modifiers ?? [];
|
|
128
|
+
const exported = modifiers.findIndex((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
129
|
+
// `export default @dec class` is a decorated class expression, which they do take.
|
|
130
|
+
const isDefault = modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
131
|
+
if (exported >= 0 && !isDefault && modifiers.slice(exported).some((m) => ts.isDecorator(m)))
|
|
132
|
+
return 'a decorator after export, which legacy decorators do not take';
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
/** Why the file gives no signatures: a syntax error, or what Babel refused; undefined when it reads. */
|
|
137
|
+
const parseError = (ts, sf, refused) => {
|
|
138
|
+
const [first] = syntaxErrors(sf);
|
|
139
|
+
const at = (pos) => {
|
|
140
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(pos);
|
|
141
|
+
return `(${String(line + 1)}:${String(character)})`;
|
|
142
|
+
};
|
|
143
|
+
if (first !== undefined)
|
|
144
|
+
return `${ts.flattenDiagnosticMessageText(first.messageText, '\n')} ${at(first.start)}`;
|
|
145
|
+
if (refused !== undefined)
|
|
146
|
+
return `${refused.why} ${at(refused.node.getStart(sf))}`;
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
/** Every test with an assertion in one file, in the order a depth-first walk meets them. */
|
|
150
|
+
export const extractTestSignatures = (ts, file, content) => {
|
|
151
|
+
const parse = (k) => ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, k);
|
|
152
|
+
let kind = scriptKind(ts, file);
|
|
153
|
+
let sf = parse(kind);
|
|
154
|
+
if (kind === ts.ScriptKind.TS && syntaxErrors(sf).length > 0) {
|
|
155
|
+
// JSX in a `.ts` file, which Babel read: take the TSX parse when it is clean.
|
|
156
|
+
const tsx = parse(ts.ScriptKind.TSX);
|
|
157
|
+
if (syntaxErrors(tsx).length === 0) {
|
|
158
|
+
kind = ts.ScriptKind.TSX;
|
|
159
|
+
sf = tsx;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const signatures = [];
|
|
163
|
+
const describeStack = [];
|
|
164
|
+
let refused;
|
|
165
|
+
const assertions = (call) => {
|
|
166
|
+
const pattern = [];
|
|
167
|
+
const visit = (node) => {
|
|
168
|
+
if (isPlainCall(ts, node)) {
|
|
169
|
+
if (calleeName(ts, node) === 'expect')
|
|
170
|
+
pattern.push('expect()');
|
|
171
|
+
const matcher = memberProperty(ts, node);
|
|
172
|
+
if (matcher?.startsWith('to') === true)
|
|
173
|
+
pattern.push(matcher);
|
|
174
|
+
}
|
|
175
|
+
ts.forEachChild(node, visit);
|
|
176
|
+
};
|
|
177
|
+
ts.forEachChild(call, visit);
|
|
178
|
+
return pattern;
|
|
179
|
+
};
|
|
180
|
+
const walk = (node) => {
|
|
181
|
+
if (refused === undefined) {
|
|
182
|
+
const why = babelRefusal(ts, kind, node);
|
|
183
|
+
if (why !== undefined)
|
|
184
|
+
refused = { node, why };
|
|
185
|
+
}
|
|
186
|
+
const call = isPlainCall(ts, node) ? node : undefined;
|
|
187
|
+
const name = call === undefined ? undefined : calleeName(ts, call);
|
|
188
|
+
if (call !== undefined && name === 'describe')
|
|
189
|
+
describeStack.push(extractTestName(ts, sf, call));
|
|
190
|
+
if (call !== undefined && (name === 'test' || name === 'it')) {
|
|
191
|
+
const assertionPattern = assertions(call);
|
|
192
|
+
if (assertionPattern.length > 0) {
|
|
193
|
+
signatures.push({
|
|
194
|
+
file,
|
|
195
|
+
testName: extractTestName(ts, sf, call),
|
|
196
|
+
describePath: [...describeStack],
|
|
197
|
+
assertionPattern,
|
|
198
|
+
line: sf.getLineAndCharacterOfPosition(call.getStart(sf)).line + 1,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
ts.forEachChild(node, walk);
|
|
203
|
+
if (name === 'describe')
|
|
204
|
+
describeStack.pop();
|
|
205
|
+
};
|
|
206
|
+
walk(sf);
|
|
207
|
+
const error = parseError(ts, sf, refused);
|
|
208
|
+
return error === undefined ? { signatures } : { signatures: [], parseError: error };
|
|
209
|
+
};
|
|
210
|
+
const levenshtein = (a, b) => {
|
|
211
|
+
let prev = Array.from({ length: a.length + 1 }, (_, j) => j);
|
|
212
|
+
for (let i = 1; i <= b.length; i++) {
|
|
213
|
+
const row = [i];
|
|
214
|
+
for (let j = 1; j <= a.length; j++) {
|
|
215
|
+
row[j] = b.charAt(i - 1) === a.charAt(j - 1) ? prev[j - 1] : Math.min(prev[j - 1] + 1, row[j - 1] + 1, prev[j] + 1);
|
|
216
|
+
}
|
|
217
|
+
prev = row;
|
|
218
|
+
}
|
|
219
|
+
return prev[a.length];
|
|
220
|
+
};
|
|
221
|
+
/** 1 minus the edit distance over the longer length, case-insensitive; 1 for two empty strings. */
|
|
222
|
+
export const stringSimilarity = (a, b) => {
|
|
223
|
+
const max = Math.max(a.length, b.length);
|
|
224
|
+
return max > 0 ? 1 - levenshtein(a.toLowerCase(), b.toLowerCase()) / max : 1;
|
|
225
|
+
};
|
|
226
|
+
/** Jaccard similarity of two matcher lists, as sets; 0 when both are empty. */
|
|
227
|
+
export const patternSimilarity = (a, b) => {
|
|
228
|
+
const setA = new Set(a);
|
|
229
|
+
const setB = new Set(b);
|
|
230
|
+
const union = new Set([...setA, ...setB]);
|
|
231
|
+
return union.size > 0 ? [...setA].filter((x) => setB.has(x)).length / union.size : 0;
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Two tests in no describe block: full credit in one file; across files the
|
|
235
|
+
* file stems (the basename up to its first dot) are the only names they have.
|
|
236
|
+
*/
|
|
237
|
+
const neitherDescribed = (file1, file2) => {
|
|
238
|
+
if (file1 === file2)
|
|
239
|
+
return 1;
|
|
240
|
+
const stem = (f) => basename(f).split('.')[0];
|
|
241
|
+
return stringSimilarity(stem(file1), stem(file2));
|
|
242
|
+
};
|
|
243
|
+
/** 50% matchers, 20% name, 30% describe path. */
|
|
244
|
+
export const compositeSimilarity = (a, b) => {
|
|
245
|
+
const path1 = a.describePath.join(' > ');
|
|
246
|
+
const path2 = b.describePath.join(' > ');
|
|
247
|
+
const describeSim = path1.length > 0 && path2.length > 0 ? stringSimilarity(path1, path2) : path1.length === 0 && path2.length === 0 ? neitherDescribed(a.file, b.file) : 0;
|
|
248
|
+
return patternSimilarity(a.assertionPattern, b.assertionPattern) * 0.5 + stringSimilarity(a.testName, b.testName) * 0.2 + describeSim * 0.3;
|
|
249
|
+
};
|
|
250
|
+
const MIN_ASSERTIONS = 3;
|
|
251
|
+
const HTTP_INDICATORS = /\b(GET|POST|PUT|DELETE|PATCH|200|201|400|401|403|404|500|502)\b/gi;
|
|
252
|
+
// Case-sensitive on purpose: lowercase "and"/"or" are prose, uppercase AND/OR are domain tokens.
|
|
253
|
+
const COMBINATOR_INDICATORS = /\b(AND|OR)\b/g;
|
|
254
|
+
const isCommon = (config, name) => {
|
|
255
|
+
const normalized = name.toLowerCase().trim();
|
|
256
|
+
return config.commonNames.some((p) => normalized === p || normalized.startsWith(`${p} `));
|
|
257
|
+
};
|
|
258
|
+
/** Two names for opposite or complementary cases: an opposite pair, other HTTP tokens, other AND/OR tokens, another endpoint. */
|
|
259
|
+
export const areOppositeScenarios = (config, name1, name2) => {
|
|
260
|
+
const lower1 = name1.toLowerCase();
|
|
261
|
+
const lower2 = name2.toLowerCase();
|
|
262
|
+
for (const [w1, w2] of config.oppositePairs) {
|
|
263
|
+
if ((lower1.includes(w1) && lower2.includes(w2)) || (lower1.includes(w2) && lower2.includes(w1)))
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
const http1 = Array.from(name1.matchAll(HTTP_INDICATORS), (m) => m[0]);
|
|
267
|
+
const http2 = Array.from(name2.matchAll(HTTP_INDICATORS), (m) => m[0]);
|
|
268
|
+
if (http1.length > 0 && http2.length > 0 && http1.some((m) => !http2.includes(m)))
|
|
269
|
+
return true;
|
|
270
|
+
const comb1 = Array.from(name1.matchAll(COMBINATOR_INDICATORS), (m) => m[0]);
|
|
271
|
+
const comb2 = Array.from(name2.matchAll(COMBINATOR_INDICATORS), (m) => m[0]);
|
|
272
|
+
if (comb1.length > 0 && comb2.length > 0 && (comb1.some((c) => !comb2.includes(c)) || comb2.some((c) => !comb1.includes(c))))
|
|
273
|
+
return true;
|
|
274
|
+
const end1 = name1.match(/\/\w+/g) ?? [];
|
|
275
|
+
const end2 = name2.match(/\/\w+/g) ?? [];
|
|
276
|
+
return end1.length > 0 && end2.length > 0 && end1[0] !== end2[0];
|
|
277
|
+
};
|
|
278
|
+
const FILLER = new Set(['should', 'when', 'with', 'the', 'a', 'an', 'it', 'is', 'are', 'be', 'to', 'for', 'of', 'in', 'on', 'and', 'or', 'not', 'if', 'that', 'this', 'can', 'does']);
|
|
279
|
+
const words = (name) => name
|
|
280
|
+
.toLowerCase()
|
|
281
|
+
.replace(/[^a-z0-9 ]/g, ' ')
|
|
282
|
+
.split(/\s+/)
|
|
283
|
+
.filter((w) => w.length > 1 && !FILLER.has(w));
|
|
284
|
+
/**
|
|
285
|
+
* Two tests in one file that are meant to differ: opposite names, the same
|
|
286
|
+
* name (so different describe blocks), a word only one has, or the same words
|
|
287
|
+
* in another order (a mirrored case).
|
|
288
|
+
*/
|
|
289
|
+
export const areSameFileComplementary = (config, file1, file2, name1, name2) => {
|
|
290
|
+
if (file1 !== file2)
|
|
291
|
+
return false;
|
|
292
|
+
if (areOppositeScenarios(config, name1, name2))
|
|
293
|
+
return true;
|
|
294
|
+
if (name1 === name2)
|
|
295
|
+
return true;
|
|
296
|
+
const words1 = new Set(words(name1));
|
|
297
|
+
const words2 = new Set(words(name2));
|
|
298
|
+
if ([...words1].some((w) => !words2.has(w)) || [...words2].some((w) => !words1.has(w)))
|
|
299
|
+
return true;
|
|
300
|
+
const seq1 = words(name1);
|
|
301
|
+
const seq2 = words(name2);
|
|
302
|
+
return [...seq1].sort().join(' ') === [...seq2].sort().join(' ') && seq1.join(' ') !== seq2.join(' ');
|
|
303
|
+
};
|
|
304
|
+
/** Every pair at or above the threshold, in signature order. */
|
|
305
|
+
export const findDuplicates = (config, signatures) => {
|
|
306
|
+
const matches = [];
|
|
307
|
+
const processed = new Set();
|
|
308
|
+
for (let i = 0; i < signatures.length; i++) {
|
|
309
|
+
for (let j = i + 1; j < signatures.length; j++) {
|
|
310
|
+
const a = signatures[i];
|
|
311
|
+
const b = signatures[j];
|
|
312
|
+
if (a.assertionPattern.length < MIN_ASSERTIONS || b.assertionPattern.length < MIN_ASSERTIONS)
|
|
313
|
+
continue;
|
|
314
|
+
if (isCommon(config, a.testName) || isCommon(config, b.testName))
|
|
315
|
+
continue;
|
|
316
|
+
if (areOppositeScenarios(config, a.testName, b.testName))
|
|
317
|
+
continue;
|
|
318
|
+
if (areSameFileComplementary(config, a.file, b.file, a.testName, b.testName))
|
|
319
|
+
continue;
|
|
320
|
+
const key = `${a.file}:${a.testName}-${b.file}:${b.testName}`;
|
|
321
|
+
if (processed.has(key))
|
|
322
|
+
continue;
|
|
323
|
+
const similarity = compositeSimilarity(a, b);
|
|
324
|
+
// Different files test different modules: a higher bar.
|
|
325
|
+
const bar = a.file !== b.file ? Math.max(config.threshold, 0.95) : config.threshold;
|
|
326
|
+
if (similarity >= bar) {
|
|
327
|
+
matches.push({ file1: a.file, test1: a.testName, line1: a.line, file2: b.file, test2: b.testName, line2: b.line, similarity });
|
|
328
|
+
processed.add(key);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return matches;
|
|
333
|
+
};
|
|
334
|
+
/** The report, line by line, and the exit code; reading failures warn on stderr and the file is skipped. */
|
|
335
|
+
export const runDuplicateTests = (ts, config, mode, files) => {
|
|
336
|
+
const out = [`🔍 Searching for duplicate tests (Threshold: ${String(config.threshold)})...`, ` Mode: ${mode}`];
|
|
337
|
+
const err = [];
|
|
338
|
+
if (files.length === 0) {
|
|
339
|
+
out.push('No files found to analyze.');
|
|
340
|
+
return { code: 0, out, err };
|
|
341
|
+
}
|
|
342
|
+
out.push(` Analyzing ${String(files.length)} files...`);
|
|
343
|
+
const signatures = [];
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
try {
|
|
346
|
+
const read = extractTestSignatures(ts, f.file, f.read());
|
|
347
|
+
if (read.parseError !== undefined)
|
|
348
|
+
err.push(`❌ Parse error: ${read.parseError}`);
|
|
349
|
+
signatures.push(...read.signatures);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
err.push(`⚠️ Failed to parse ${f.path}: ${e.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
out.push(` Extracted ${String(signatures.length)} test signatures.`);
|
|
356
|
+
const duplicates = findDuplicates(config, signatures);
|
|
357
|
+
if (duplicates.length === 0) {
|
|
358
|
+
out.push('', '✅ No duplicates found.');
|
|
359
|
+
return { code: 0, out, err };
|
|
360
|
+
}
|
|
361
|
+
out.push('', '❌ Found potential duplicate tests:');
|
|
362
|
+
for (const d of [...duplicates].sort((x, y) => y.similarity - x.similarity)) {
|
|
363
|
+
out.push('', ` ${(d.similarity * 100).toFixed(0)}% Match:`, ` A: ${d.file1}:${String(d.line1)} "${d.test1}"`, ` B: ${d.file2}:${String(d.line2)} "${d.test2}"`);
|
|
364
|
+
}
|
|
365
|
+
out.push('', `Total potential duplicates: ${String(duplicates.length)}`);
|
|
366
|
+
return { code: 1, out, err };
|
|
367
|
+
};
|