@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 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.18 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.18 --version
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
+ };