@atlaskit/eslint-plugin-platform 2.7.1 → 2.8.0
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/CHANGELOG.md +14 -0
- package/dist/cjs/index.js +17 -9
- package/dist/cjs/rules/constants.js +1 -1
- package/dist/cjs/rules/ensure-critical-dependency-resolutions/index.js +5 -5
- package/dist/cjs/rules/ensure-no-private-dependencies/index.js +48 -66
- package/dist/cjs/rules/feature-gating/inline-usage/index.js +14 -3
- package/dist/cjs/rules/feature-gating/no-alias/index.js +1 -1
- package/dist/cjs/rules/feature-gating/no-module-level-eval/index.js +1 -1
- package/dist/cjs/rules/feature-gating/no-module-level-eval-nav4/index.js +1 -1
- package/dist/cjs/rules/feature-gating/no-preconditioning/index.js +4 -1
- package/dist/cjs/rules/feature-gating/prefer-fg/index.js +1 -1
- package/dist/cjs/rules/feature-gating/static-feature-flags/index.js +2 -2
- package/dist/cjs/rules/feature-gating/use-recommended-utils/index.js +1 -1
- package/dist/cjs/rules/feature-gating/valid-gate-name/index.js +60 -0
- package/dist/cjs/rules/import/no-barrel-entry-imports/index.js +871 -0
- package/dist/cjs/rules/import/no-barrel-entry-jest-mock/index.js +1384 -0
- package/dist/cjs/rules/import/no-conversation-assistant-barrel-imports/index.js +43 -0
- package/dist/cjs/rules/import/no-jest-mock-barrel-files/index.js +1401 -0
- package/dist/cjs/rules/import/no-relative-barrel-file-imports/index.js +777 -0
- package/dist/cjs/rules/import/shared/barrel-parsing.js +511 -0
- package/dist/cjs/rules/import/shared/file-system.js +186 -0
- package/dist/cjs/rules/import/shared/jest-utils.js +191 -0
- package/dist/cjs/rules/import/shared/package-registry.js +263 -0
- package/dist/cjs/rules/import/shared/package-resolution.js +185 -0
- package/dist/cjs/rules/import/shared/perf.js +89 -0
- package/dist/cjs/rules/import/shared/types.js +67 -0
- package/dist/cjs/rules/no-invalid-storybook-decorator-usage/index.js +1 -1
- package/dist/cjs/rules/no-sparse-checkout/index.js +1 -1
- package/dist/cjs/rules/prefer-crypto-random-uuid/index.js +87 -0
- package/dist/cjs/rules/use-entrypoints-in-examples/index.js +1 -1
- package/dist/es2019/index.js +17 -9
- package/dist/es2019/rules/constants.js +1 -1
- package/dist/es2019/rules/ensure-critical-dependency-resolutions/index.js +5 -5
- package/dist/es2019/rules/ensure-no-private-dependencies/index.js +10 -9
- package/dist/es2019/rules/feature-gating/inline-usage/index.js +14 -3
- package/dist/es2019/rules/feature-gating/no-alias/index.js +1 -1
- package/dist/es2019/rules/feature-gating/no-module-level-eval/index.js +1 -1
- package/dist/es2019/rules/feature-gating/no-module-level-eval-nav4/index.js +1 -1
- package/dist/es2019/rules/feature-gating/no-preconditioning/index.js +4 -1
- package/dist/es2019/rules/feature-gating/prefer-fg/index.js +1 -1
- package/dist/es2019/rules/feature-gating/static-feature-flags/index.js +2 -2
- package/dist/es2019/rules/feature-gating/use-recommended-utils/index.js +1 -1
- package/dist/es2019/rules/feature-gating/valid-gate-name/index.js +52 -0
- package/dist/es2019/rules/import/no-barrel-entry-imports/index.js +801 -0
- package/dist/es2019/rules/import/no-barrel-entry-jest-mock/index.js +1113 -0
- package/dist/es2019/rules/import/no-conversation-assistant-barrel-imports/index.js +37 -0
- package/dist/es2019/rules/import/no-jest-mock-barrel-files/index.js +1179 -0
- package/dist/es2019/rules/import/no-relative-barrel-file-imports/index.js +738 -0
- package/dist/es2019/rules/import/shared/barrel-parsing.js +433 -0
- package/dist/es2019/rules/import/shared/file-system.js +174 -0
- package/dist/es2019/rules/import/shared/jest-utils.js +159 -0
- package/dist/es2019/rules/import/shared/package-registry.js +240 -0
- package/dist/es2019/rules/import/shared/package-resolution.js +161 -0
- package/dist/es2019/rules/import/shared/perf.js +83 -0
- package/dist/es2019/rules/import/shared/types.js +57 -0
- package/dist/es2019/rules/no-invalid-storybook-decorator-usage/index.js +1 -1
- package/dist/es2019/rules/no-sparse-checkout/index.js +1 -1
- package/dist/es2019/rules/prefer-crypto-random-uuid/index.js +81 -0
- package/dist/es2019/rules/use-entrypoints-in-examples/index.js +1 -1
- package/dist/esm/index.js +17 -9
- package/dist/esm/rules/constants.js +1 -1
- package/dist/esm/rules/ensure-critical-dependency-resolutions/index.js +5 -5
- package/dist/esm/rules/ensure-no-private-dependencies/index.js +48 -65
- package/dist/esm/rules/feature-gating/inline-usage/index.js +14 -3
- package/dist/esm/rules/feature-gating/no-alias/index.js +1 -1
- package/dist/esm/rules/feature-gating/no-module-level-eval/index.js +1 -1
- package/dist/esm/rules/feature-gating/no-module-level-eval-nav4/index.js +1 -1
- package/dist/esm/rules/feature-gating/no-preconditioning/index.js +4 -1
- package/dist/esm/rules/feature-gating/prefer-fg/index.js +1 -1
- package/dist/esm/rules/feature-gating/static-feature-flags/index.js +2 -2
- package/dist/esm/rules/feature-gating/use-recommended-utils/index.js +1 -1
- package/dist/esm/rules/feature-gating/valid-gate-name/index.js +53 -0
- package/dist/esm/rules/import/no-barrel-entry-imports/index.js +864 -0
- package/dist/esm/rules/import/no-barrel-entry-jest-mock/index.js +1375 -0
- package/dist/esm/rules/import/no-conversation-assistant-barrel-imports/index.js +37 -0
- package/dist/esm/rules/import/no-jest-mock-barrel-files/index.js +1391 -0
- package/dist/esm/rules/import/no-relative-barrel-file-imports/index.js +770 -0
- package/dist/esm/rules/import/shared/barrel-parsing.js +500 -0
- package/dist/esm/rules/import/shared/file-system.js +176 -0
- package/dist/esm/rules/import/shared/jest-utils.js +179 -0
- package/dist/esm/rules/import/shared/package-registry.js +256 -0
- package/dist/esm/rules/import/shared/package-resolution.js +175 -0
- package/dist/esm/rules/import/shared/perf.js +80 -0
- package/dist/esm/rules/import/shared/types.js +61 -0
- package/dist/esm/rules/no-invalid-storybook-decorator-usage/index.js +1 -1
- package/dist/esm/rules/no-sparse-checkout/index.js +1 -1
- package/dist/esm/rules/prefer-crypto-random-uuid/index.js +81 -0
- package/dist/esm/rules/use-entrypoints-in-examples/index.js +1 -1
- package/dist/types/index.d.ts +18 -16
- package/dist/types/rules/import/no-barrel-entry-imports/index.d.ts +9 -0
- package/dist/types/rules/import/no-barrel-entry-jest-mock/index.d.ts +9 -0
- package/dist/types/rules/import/no-conversation-assistant-barrel-imports/index.d.ts +3 -0
- package/dist/types/rules/import/no-jest-mock-barrel-files/index.d.ts +22 -0
- package/dist/types/rules/import/no-relative-barrel-file-imports/index.d.ts +5 -0
- package/dist/types/rules/import/shared/barrel-parsing.d.ts +30 -0
- package/dist/types/rules/import/shared/file-system.d.ts +38 -0
- package/dist/types/rules/import/shared/jest-utils.d.ts +47 -0
- package/dist/types/rules/import/shared/package-registry.d.ts +26 -0
- package/dist/types/rules/import/shared/package-resolution.d.ts +38 -0
- package/dist/types/rules/import/shared/perf.d.ts +13 -0
- package/dist/types/rules/import/shared/types.d.ts +131 -0
- package/dist/types/rules/prefer-crypto-random-uuid/index.d.ts +3 -0
- package/dist/types-ts4.5/index.d.ts +18 -28
- package/dist/types-ts4.5/rules/import/no-barrel-entry-imports/index.d.ts +9 -0
- package/dist/types-ts4.5/rules/import/no-barrel-entry-jest-mock/index.d.ts +9 -0
- package/dist/types-ts4.5/rules/import/no-jest-mock-barrel-files/index.d.ts +22 -0
- package/dist/types-ts4.5/rules/import/no-relative-barrel-file-imports/index.d.ts +5 -0
- package/dist/types-ts4.5/rules/import/shared/barrel-parsing.d.ts +30 -0
- package/dist/types-ts4.5/rules/import/shared/file-system.d.ts +38 -0
- package/dist/types-ts4.5/rules/import/shared/jest-utils.d.ts +47 -0
- package/dist/types-ts4.5/rules/import/shared/package-registry.d.ts +26 -0
- package/dist/types-ts4.5/rules/import/shared/package-resolution.d.ts +38 -0
- package/dist/types-ts4.5/rules/import/shared/perf.d.ts +13 -0
- package/dist/types-ts4.5/rules/import/shared/types.d.ts +131 -0
- package/package.json +4 -5
- package/dist/cjs/rules/ensure-feature-flag-prefix/index.js +0 -75
- package/dist/cjs/rules/ensure-native-and-af-exports-synced/index.js +0 -158
- package/dist/es2019/rules/ensure-feature-flag-prefix/index.js +0 -65
- package/dist/es2019/rules/ensure-native-and-af-exports-synced/index.js +0 -146
- package/dist/esm/rules/ensure-feature-flag-prefix/index.js +0 -69
- package/dist/esm/rules/ensure-native-and-af-exports-synced/index.js +0 -151
- /package/dist/types/rules/{ensure-native-and-af-exports-synced → feature-gating/valid-gate-name}/index.d.ts +0 -0
- /package/dist/types-ts4.5/rules/{ensure-feature-flag-prefix → feature-gating/valid-gate-name}/index.d.ts +0 -0
- /package/dist/types-ts4.5/rules/{ensure-native-and-af-exports-synced → import/no-conversation-assistant-barrel-imports}/index.d.ts +0 -0
- /package/dist/{types/rules/ensure-feature-flag-prefix → types-ts4.5/rules/prefer-crypto-random-uuid}/index.d.ts +0 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import { dirname, relative } from 'path';
|
|
2
|
+
import * as ts from 'typescript';
|
|
3
|
+
import { hasReExportsFromOtherFiles, parseBarrelExports } from '../shared/barrel-parsing';
|
|
4
|
+
import { findWorkspaceRoot, isRelativeImport, resolveImportPath } from '../shared/file-system';
|
|
5
|
+
import { extractImportPath, findJestRequireMockCalls, isJestMockCall, isJestRequireActual, resolveNewPathForRequireMock } from '../shared/jest-utils';
|
|
6
|
+
import { findPackageInRegistry } from '../shared/package-registry';
|
|
7
|
+
import { findExportForSourceFile, parsePackageExports } from '../shared/package-resolution';
|
|
8
|
+
import { realFileSystem } from '../shared/types';
|
|
9
|
+
|
|
10
|
+
// Cache per source package name to avoid repeated exports parsing during a single lint run.
|
|
11
|
+
// This is keyed by fs instance to avoid test pollution.
|
|
12
|
+
const sourcePackageExportsMapsByFs = new WeakMap();
|
|
13
|
+
function getSourcePackageExportsMaps(fs) {
|
|
14
|
+
let map = sourcePackageExportsMapsByFs.get(fs);
|
|
15
|
+
if (!map) {
|
|
16
|
+
map = new Map();
|
|
17
|
+
sourcePackageExportsMapsByFs.set(fs, map);
|
|
18
|
+
}
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Information about a mock factory's preamble (statements before the return)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract identifiers defined by a statement (e.g., variable declarations)
|
|
28
|
+
* Uses TypeScript AST to find declared identifiers.
|
|
29
|
+
*/
|
|
30
|
+
function extractDefinedIdentifiers(statementText) {
|
|
31
|
+
const identifiers = new Set();
|
|
32
|
+
try {
|
|
33
|
+
// Parse the statement as a mini source file
|
|
34
|
+
const sourceFile = ts.createSourceFile('temp.ts', statementText, ts.ScriptTarget.Latest, true);
|
|
35
|
+
const visit = node => {
|
|
36
|
+
if (ts.isVariableStatement(node)) {
|
|
37
|
+
for (const decl of node.declarationList.declarations) {
|
|
38
|
+
if (ts.isIdentifier(decl.name)) {
|
|
39
|
+
identifiers.add(decl.name.text);
|
|
40
|
+
} else if (ts.isObjectBindingPattern(decl.name)) {
|
|
41
|
+
// Handle destructuring: const { a, b } = ...
|
|
42
|
+
for (const element of decl.name.elements) {
|
|
43
|
+
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
|
|
44
|
+
identifiers.add(element.name.text);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else if (ts.isArrayBindingPattern(decl.name)) {
|
|
48
|
+
// Handle array destructuring: const [a, b] = ...
|
|
49
|
+
for (const element of decl.name.elements) {
|
|
50
|
+
if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
|
|
51
|
+
identifiers.add(element.name.text);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else if (ts.isFunctionDeclaration(node) && node.name) {
|
|
57
|
+
identifiers.add(node.name.text);
|
|
58
|
+
} else if (ts.isClassDeclaration(node) && node.name) {
|
|
59
|
+
identifiers.add(node.name.text);
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(node, visit);
|
|
62
|
+
};
|
|
63
|
+
ts.forEachChild(sourceFile, visit);
|
|
64
|
+
} catch {
|
|
65
|
+
// Ignore parsing errors
|
|
66
|
+
}
|
|
67
|
+
return identifiers;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find all identifiers used in a given text string.
|
|
72
|
+
* Uses a simple regex approach to find potential identifier references.
|
|
73
|
+
*/
|
|
74
|
+
function findUsedIdentifiers(text, potentialIdentifiers) {
|
|
75
|
+
const used = new Set();
|
|
76
|
+
for (const identifier of potentialIdentifiers) {
|
|
77
|
+
// Use word boundary matching to find identifier usage
|
|
78
|
+
// This matches the identifier as a whole word (not part of another word)
|
|
79
|
+
const regex = new RegExp(`\\b${escapeRegExpForIdentifier(identifier)}\\b`);
|
|
80
|
+
if (regex.test(text)) {
|
|
81
|
+
used.add(identifier);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return used;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Escape special regex characters for identifier matching
|
|
89
|
+
*/
|
|
90
|
+
function escapeRegExpForIdentifier(str) {
|
|
91
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Filter preamble statements to only include those whose defined identifiers
|
|
96
|
+
* are used in the given property texts.
|
|
97
|
+
*/
|
|
98
|
+
function filterPreambleForProperties(preamble, propertyTexts) {
|
|
99
|
+
if (!preamble.hasPreamble || preamble.statements.length === 0) {
|
|
100
|
+
return preamble;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Collect all identifiers defined in the preamble
|
|
104
|
+
const allDefinedIdentifiers = new Set();
|
|
105
|
+
for (const stmt of preamble.statements) {
|
|
106
|
+
for (const id of stmt.definedIdentifiers) {
|
|
107
|
+
allDefinedIdentifiers.add(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Find which identifiers are used in the property texts
|
|
112
|
+
const combinedPropertyText = propertyTexts.join('\n');
|
|
113
|
+
const usedIdentifiers = findUsedIdentifiers(combinedPropertyText, allDefinedIdentifiers);
|
|
114
|
+
|
|
115
|
+
// Filter statements to only those that define used identifiers
|
|
116
|
+
const filteredStatements = preamble.statements.filter(stmt => {
|
|
117
|
+
// Include statement if any of its defined identifiers are used
|
|
118
|
+
for (const id of stmt.definedIdentifiers) {
|
|
119
|
+
if (usedIdentifiers.has(id)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
});
|
|
125
|
+
if (filteredStatements.length === 0) {
|
|
126
|
+
return {
|
|
127
|
+
text: '',
|
|
128
|
+
hasPreamble: false,
|
|
129
|
+
statements: []
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
text: filteredStatements.map(s => s.text).join('\n\t'),
|
|
134
|
+
hasPreamble: true,
|
|
135
|
+
statements: filteredStatements
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert absolute file path to an import path, handling cross-package resolution.
|
|
141
|
+
* If the export comes from a cross-package source, returns the package path (e.g., '@atlassian/package-b/utils').
|
|
142
|
+
* Otherwise, returns a relative path.
|
|
143
|
+
*/
|
|
144
|
+
function getImportPathForSourceFile({
|
|
145
|
+
sourceFilePath,
|
|
146
|
+
basedir,
|
|
147
|
+
originalImportPath,
|
|
148
|
+
exportInfo,
|
|
149
|
+
workspaceRoot,
|
|
150
|
+
fs
|
|
151
|
+
}) {
|
|
152
|
+
var _exportInfo$crossPack;
|
|
153
|
+
const crossPackageName = exportInfo === null || exportInfo === void 0 ? void 0 : (_exportInfo$crossPack = exportInfo.crossPackageSource) === null || _exportInfo$crossPack === void 0 ? void 0 : _exportInfo$crossPack.packageName;
|
|
154
|
+
if (crossPackageName) {
|
|
155
|
+
const sourcePackageExportsMaps = getSourcePackageExportsMaps(fs);
|
|
156
|
+
let exportsMap = sourcePackageExportsMaps.get(crossPackageName);
|
|
157
|
+
if (!exportsMap) {
|
|
158
|
+
const pkgDir = findPackageInRegistry({
|
|
159
|
+
packageName: crossPackageName,
|
|
160
|
+
workspaceRoot,
|
|
161
|
+
fs
|
|
162
|
+
});
|
|
163
|
+
if (pkgDir) {
|
|
164
|
+
exportsMap = parsePackageExports({
|
|
165
|
+
packageDir: pkgDir,
|
|
166
|
+
fs
|
|
167
|
+
});
|
|
168
|
+
sourcePackageExportsMaps.set(crossPackageName, exportsMap);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const targetExportPath = exportsMap ? findExportForSourceFile({
|
|
172
|
+
sourceFilePath,
|
|
173
|
+
exportsMap
|
|
174
|
+
}) : null;
|
|
175
|
+
return targetExportPath ? crossPackageName + targetExportPath.slice(1) : crossPackageName;
|
|
176
|
+
}
|
|
177
|
+
return getRelativeImportPath({
|
|
178
|
+
basedir,
|
|
179
|
+
absolutePath: sourceFilePath,
|
|
180
|
+
originalImportPath
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert absolute file path back to relative import path
|
|
186
|
+
*/
|
|
187
|
+
function getRelativeImportPath({
|
|
188
|
+
basedir,
|
|
189
|
+
absolutePath,
|
|
190
|
+
originalImportPath
|
|
191
|
+
}) {
|
|
192
|
+
let relativePath = relative(basedir, absolutePath);
|
|
193
|
+
// Normalize to use forward slashes
|
|
194
|
+
relativePath = relativePath.replace(/\\/g, '/');
|
|
195
|
+
|
|
196
|
+
// Check for extension in original path
|
|
197
|
+
const extMatch = originalImportPath.match(/\.(js|jsx|ts|tsx|mjs|cjs)$/);
|
|
198
|
+
const originalExt = extMatch ? extMatch[0] : '';
|
|
199
|
+
|
|
200
|
+
// Get extension from the resolved absolute path
|
|
201
|
+
const targetExtMatch = absolutePath.match(/\.(js|jsx|ts|tsx|mjs|cjs)$/);
|
|
202
|
+
const targetExt = targetExtMatch ? targetExtMatch[0] : '';
|
|
203
|
+
|
|
204
|
+
// Remove file extension from the target path
|
|
205
|
+
relativePath = relativePath.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
|
|
206
|
+
|
|
207
|
+
// If original had extension, append it
|
|
208
|
+
if (originalExt) {
|
|
209
|
+
// If original was a TypeScript source extension, use the actual target extension
|
|
210
|
+
if (['.ts', '.tsx'].includes(originalExt) && targetExt) {
|
|
211
|
+
relativePath += targetExt;
|
|
212
|
+
} else {
|
|
213
|
+
relativePath += originalExt;
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// Remove /index suffix only if no extension was present
|
|
217
|
+
if (relativePath.endsWith('/index')) {
|
|
218
|
+
relativePath = relativePath.slice(0, -6);
|
|
219
|
+
} else if (relativePath === 'index') {
|
|
220
|
+
relativePath = '.';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Ensure it starts with .. or .
|
|
225
|
+
if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) {
|
|
226
|
+
relativePath = './' + relativePath;
|
|
227
|
+
}
|
|
228
|
+
return relativePath;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if a node is an Object.assign call
|
|
233
|
+
*/
|
|
234
|
+
function isObjectAssignCall(node) {
|
|
235
|
+
if (node.type !== 'CallExpression') {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const callee = node.callee;
|
|
239
|
+
if (callee.type === 'MemberExpression') {
|
|
240
|
+
return callee.object.type === 'Identifier' && callee.object.name === 'Object' && callee.property.type === 'Identifier' && callee.property.name === 'assign';
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Extract mock object from Object.assign pattern
|
|
247
|
+
* Pattern: Object.assign({}, jest.requireActual(...), { mockProps })
|
|
248
|
+
* Returns the properties object and whether it has requireActual
|
|
249
|
+
*/
|
|
250
|
+
function extractObjectAssignMock(node) {
|
|
251
|
+
const args = node.arguments;
|
|
252
|
+
|
|
253
|
+
// Object.assign typically has at least 2 arguments: target and source(s)
|
|
254
|
+
// Pattern: Object.assign({}, jest.requireActual(...), { mockProps })
|
|
255
|
+
// or: Object.assign({}, jest.requireActual(...), { mockProps1 }, { mockProps2 })
|
|
256
|
+
if (args.length < 2) {
|
|
257
|
+
return {
|
|
258
|
+
propertiesObject: null,
|
|
259
|
+
hasRequireActual: false
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
let hasRequireActual = false;
|
|
263
|
+
let lastObjectExpression = null;
|
|
264
|
+
|
|
265
|
+
// Scan through arguments to find jest.requireActual and the last object literal
|
|
266
|
+
for (const arg of args) {
|
|
267
|
+
if (isJestRequireActual(arg)) {
|
|
268
|
+
hasRequireActual = true;
|
|
269
|
+
}
|
|
270
|
+
if (arg.type === 'ObjectExpression') {
|
|
271
|
+
// Skip empty objects (the first {} in Object.assign({}, ...))
|
|
272
|
+
if (arg.properties.length > 0) {
|
|
273
|
+
lastObjectExpression = arg;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
propertiesObject: lastObjectExpression,
|
|
279
|
+
hasRequireActual
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Extract mock object properties from jest.mock call
|
|
285
|
+
* Returns a map of property name -> { node, text } and whether there's a jest.requireActual spread
|
|
286
|
+
*/
|
|
287
|
+
function extractMockProperties({
|
|
288
|
+
sourceCode,
|
|
289
|
+
mockObjectNode
|
|
290
|
+
}) {
|
|
291
|
+
const properties = new Map();
|
|
292
|
+
let hasRequireActual = false;
|
|
293
|
+
|
|
294
|
+
// Handle Object.assign pattern: Object.assign({}, jest.requireActual(...), { props })
|
|
295
|
+
if (isObjectAssignCall(mockObjectNode)) {
|
|
296
|
+
const {
|
|
297
|
+
propertiesObject,
|
|
298
|
+
hasRequireActual: objectAssignHasRequireActual
|
|
299
|
+
} = extractObjectAssignMock(mockObjectNode);
|
|
300
|
+
if (propertiesObject) {
|
|
301
|
+
// Recursively extract properties from the properties object
|
|
302
|
+
const result = extractMockProperties({
|
|
303
|
+
sourceCode,
|
|
304
|
+
mockObjectNode: propertiesObject
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
properties: result.properties,
|
|
308
|
+
hasRequireActual: objectAssignHasRequireActual || result.hasRequireActual
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
properties,
|
|
313
|
+
hasRequireActual: objectAssignHasRequireActual
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (mockObjectNode.type === 'ObjectExpression') {
|
|
317
|
+
for (const prop of mockObjectNode.properties) {
|
|
318
|
+
if (prop.type === 'SpreadElement') {
|
|
319
|
+
// Check if this is ...jest.requireActual(...)
|
|
320
|
+
if (isJestRequireActual(prop.argument)) {
|
|
321
|
+
hasRequireActual = true;
|
|
322
|
+
}
|
|
323
|
+
} else if (prop.type === 'Property') {
|
|
324
|
+
let keyName;
|
|
325
|
+
if (prop.key.type === 'Identifier') {
|
|
326
|
+
keyName = prop.key.name;
|
|
327
|
+
} else if (prop.key.type === 'Literal') {
|
|
328
|
+
keyName = String(prop.key.value);
|
|
329
|
+
} else {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const propText = sourceCode.getText(prop);
|
|
333
|
+
const valueText = sourceCode.getText(prop.value);
|
|
334
|
+
properties.set(keyName, {
|
|
335
|
+
node: prop,
|
|
336
|
+
text: propText,
|
|
337
|
+
valueText
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
properties,
|
|
344
|
+
hasRequireActual
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Validate and resolve a barrel file from an import path
|
|
350
|
+
* Returns null if not a valid relative barrel import
|
|
351
|
+
*/
|
|
352
|
+
export function validateAndResolveBarrelFile({
|
|
353
|
+
importPath,
|
|
354
|
+
basedir,
|
|
355
|
+
workspaceRoot,
|
|
356
|
+
fs
|
|
357
|
+
}) {
|
|
358
|
+
if (!isRelativeImport(importPath)) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const resolvedPath = resolveImportPath({
|
|
362
|
+
basedir,
|
|
363
|
+
importPath,
|
|
364
|
+
fs
|
|
365
|
+
});
|
|
366
|
+
if (!resolvedPath) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const exportMap = parseBarrelExports({
|
|
370
|
+
barrelFilePath: resolvedPath,
|
|
371
|
+
workspaceRoot,
|
|
372
|
+
fs
|
|
373
|
+
});
|
|
374
|
+
if (exportMap.size === 0) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A file is considered a barrel file if it has re-exports from other files.
|
|
379
|
+
// This is the semantic check - we don't care about the filename.
|
|
380
|
+
if (!hasReExportsFromOtherFiles({
|
|
381
|
+
exportMap,
|
|
382
|
+
sourceFilePath: resolvedPath
|
|
383
|
+
})) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
resolvedPath,
|
|
388
|
+
exportMap
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Extract the mock implementation object from the jest.mock call
|
|
394
|
+
*/
|
|
395
|
+
function extractMockImplementation(mockImpl) {
|
|
396
|
+
if (mockImpl.type === 'ArrowFunctionExpression') {
|
|
397
|
+
if (mockImpl.body.type === 'ObjectExpression') {
|
|
398
|
+
return mockImpl.body;
|
|
399
|
+
}
|
|
400
|
+
// Handle arrow functions that return a call expression directly (e.g., Object.assign)
|
|
401
|
+
if (mockImpl.body.type === 'CallExpression') {
|
|
402
|
+
return mockImpl.body;
|
|
403
|
+
}
|
|
404
|
+
if (mockImpl.body.type === 'BlockStatement') {
|
|
405
|
+
const returnStmt = mockImpl.body.body.find(s => s.type === 'ReturnStatement');
|
|
406
|
+
if (returnStmt !== null && returnStmt !== void 0 && returnStmt.argument) {
|
|
407
|
+
return returnStmt.argument;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (mockImpl.type === 'FunctionExpression' && mockImpl.body.type === 'BlockStatement') {
|
|
412
|
+
const returnStmt = mockImpl.body.body.find(s => s.type === 'ReturnStatement');
|
|
413
|
+
if (returnStmt !== null && returnStmt !== void 0 && returnStmt.argument) {
|
|
414
|
+
return returnStmt.argument;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return mockImpl;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Extract the preamble (statements before the return) from a mock factory function.
|
|
422
|
+
* This captures variable declarations, assignments, etc. that need to be preserved.
|
|
423
|
+
*/
|
|
424
|
+
function extractMockFactoryPreamble({
|
|
425
|
+
mockImpl,
|
|
426
|
+
sourceCode
|
|
427
|
+
}) {
|
|
428
|
+
const emptyPreamble = {
|
|
429
|
+
text: '',
|
|
430
|
+
hasPreamble: false,
|
|
431
|
+
statements: []
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Get the block statement body from the mock factory
|
|
435
|
+
let blockBody = null;
|
|
436
|
+
if ((mockImpl.type === 'ArrowFunctionExpression' || mockImpl.type === 'FunctionExpression') && mockImpl.body.type === 'BlockStatement') {
|
|
437
|
+
blockBody = mockImpl.body.body;
|
|
438
|
+
}
|
|
439
|
+
if (!blockBody) {
|
|
440
|
+
return emptyPreamble;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Find the return statement index
|
|
444
|
+
const returnIndex = blockBody.findIndex(s => s.type === 'ReturnStatement');
|
|
445
|
+
if (returnIndex <= 0) {
|
|
446
|
+
// No preamble (return is first statement or not found)
|
|
447
|
+
return emptyPreamble;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Extract all statements before the return
|
|
451
|
+
const preambleStatements = blockBody.slice(0, returnIndex);
|
|
452
|
+
const statementsWithIdentifiers = preambleStatements.map(stmt => {
|
|
453
|
+
const text = sourceCode.getText(stmt);
|
|
454
|
+
const definedIdentifiers = extractDefinedIdentifiers(text);
|
|
455
|
+
return {
|
|
456
|
+
text,
|
|
457
|
+
definedIdentifiers
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
const preambleTexts = statementsWithIdentifiers.map(s => s.text);
|
|
461
|
+
return {
|
|
462
|
+
text: preambleTexts.join('\n\t'),
|
|
463
|
+
hasPreamble: true,
|
|
464
|
+
statements: statementsWithIdentifiers
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Rewrite jest.requireActual paths in a text string from the original barrel path to a new path.
|
|
470
|
+
*/
|
|
471
|
+
function rewriteRequireActualPaths({
|
|
472
|
+
text,
|
|
473
|
+
originalPath,
|
|
474
|
+
newPath,
|
|
475
|
+
quote
|
|
476
|
+
}) {
|
|
477
|
+
// Match jest.requireActual('originalPath') or jest.requireActual("originalPath")
|
|
478
|
+
// Also handle the 'as Object' or 'as any' type assertions
|
|
479
|
+
const patterns = [
|
|
480
|
+
// With single quotes
|
|
481
|
+
new RegExp(`jest\\.requireActual\\(\\s*'${escapeRegExp(originalPath)}'\\s*\\)(?:\\s+as\\s+\\w+)?`, 'g'),
|
|
482
|
+
// With double quotes
|
|
483
|
+
new RegExp(`jest\\.requireActual\\(\\s*"${escapeRegExp(originalPath)}"\\s*\\)(?:\\s+as\\s+\\w+)?`, 'g')];
|
|
484
|
+
let result = text;
|
|
485
|
+
for (const pattern of patterns) {
|
|
486
|
+
result = result.replace(pattern, `jest.requireActual(${quote}${newPath}${quote})`);
|
|
487
|
+
}
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Escape special regex characters in a string
|
|
493
|
+
*/
|
|
494
|
+
function escapeRegExp(str) {
|
|
495
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Group mocked properties by their source file
|
|
500
|
+
*/
|
|
501
|
+
function groupPropertiesBySource({
|
|
502
|
+
mockProperties,
|
|
503
|
+
exportMap
|
|
504
|
+
}) {
|
|
505
|
+
const propertiesBySource = new Map();
|
|
506
|
+
for (const [propName] of mockProperties) {
|
|
507
|
+
const exportInfo = exportMap.get(propName);
|
|
508
|
+
if (!exportInfo) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (!propertiesBySource.has(exportInfo.path)) {
|
|
512
|
+
propertiesBySource.set(exportInfo.path, []);
|
|
513
|
+
}
|
|
514
|
+
propertiesBySource.get(exportInfo.path).push(propName);
|
|
515
|
+
}
|
|
516
|
+
return propertiesBySource;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Determine if we should report a barrel mock violation
|
|
521
|
+
*/
|
|
522
|
+
function shouldReportBarrelMock({
|
|
523
|
+
propertiesBySource,
|
|
524
|
+
barrelFilePath
|
|
525
|
+
}) {
|
|
526
|
+
// Report if any mocked property is a re-export (comes from a different file than the barrel)
|
|
527
|
+
// This catches both:
|
|
528
|
+
// 1. Properties from multiple source files
|
|
529
|
+
// 2. Properties from a single source file that isn't the barrel itself
|
|
530
|
+
for (const sourcePath of propertiesBySource.keys()) {
|
|
531
|
+
if (sourcePath !== barrelFilePath) {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Generate auto-fix for auto-mock case (no mock implementation)
|
|
540
|
+
*/
|
|
541
|
+
function generateAutoMockFix({
|
|
542
|
+
exportMap,
|
|
543
|
+
basedir,
|
|
544
|
+
importPath,
|
|
545
|
+
quote,
|
|
546
|
+
workspaceRoot,
|
|
547
|
+
fs
|
|
548
|
+
}) {
|
|
549
|
+
// Group exports by source file, filtering out type-only source files
|
|
550
|
+
// Also track the ExportInfo for cross-package resolution
|
|
551
|
+
const sourceFilesWithInfo = new Map();
|
|
552
|
+
for (const [, info] of exportMap) {
|
|
553
|
+
if (!info.isTypeOnly && !sourceFilesWithInfo.has(info.path)) {
|
|
554
|
+
sourceFilesWithInfo.set(info.path, info);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const sourceFileArray = Array.from(sourceFilesWithInfo.entries());
|
|
558
|
+
return sourceFileArray.map(([sourceFile, exportInfo]) => {
|
|
559
|
+
const mockPath = getImportPathForSourceFile({
|
|
560
|
+
sourceFilePath: sourceFile,
|
|
561
|
+
basedir,
|
|
562
|
+
originalImportPath: importPath,
|
|
563
|
+
exportInfo,
|
|
564
|
+
workspaceRoot,
|
|
565
|
+
fs
|
|
566
|
+
});
|
|
567
|
+
return `jest.mock(${quote}${mockPath}${quote})`;
|
|
568
|
+
}).join(';\n');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Normalize a path for comparison (resolve to absolute path)
|
|
573
|
+
*/
|
|
574
|
+
function normalizePathForComparison({
|
|
575
|
+
basedir,
|
|
576
|
+
importPath,
|
|
577
|
+
fs
|
|
578
|
+
}) {
|
|
579
|
+
if (!isRelativeImport(importPath)) {
|
|
580
|
+
// For non-relative imports, just return as-is for comparison
|
|
581
|
+
return importPath;
|
|
582
|
+
}
|
|
583
|
+
const resolved = resolveImportPath({
|
|
584
|
+
basedir,
|
|
585
|
+
importPath,
|
|
586
|
+
fs
|
|
587
|
+
});
|
|
588
|
+
return resolved || importPath;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Scan the entire file for all existing jest.mock calls
|
|
593
|
+
* Returns a map of normalized path -> { node, properties, hasRequireActual }
|
|
594
|
+
*/
|
|
595
|
+
function findAllJestMocksInFile({
|
|
596
|
+
context,
|
|
597
|
+
basedir,
|
|
598
|
+
fs
|
|
599
|
+
}) {
|
|
600
|
+
const allMocks = new Map();
|
|
601
|
+
const sourceCode = context.getSourceCode();
|
|
602
|
+
const ast = sourceCode.ast;
|
|
603
|
+
|
|
604
|
+
// Use a visited set to prevent infinite recursion
|
|
605
|
+
const visited = new Set();
|
|
606
|
+
|
|
607
|
+
// Properties to skip to avoid circular references
|
|
608
|
+
const skipKeys = new Set(['parent', 'loc', 'range', 'tokens', 'comments']);
|
|
609
|
+
function visitNode(node) {
|
|
610
|
+
// Prevent revisiting nodes
|
|
611
|
+
if (visited.has(node)) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
visited.add(node);
|
|
615
|
+
if (node.type === 'CallExpression' && isJestMockCall(node)) {
|
|
616
|
+
const importPath = extractImportPath(node);
|
|
617
|
+
if (importPath) {
|
|
618
|
+
const normalizedPath = normalizePathForComparison({
|
|
619
|
+
basedir,
|
|
620
|
+
importPath,
|
|
621
|
+
fs
|
|
622
|
+
});
|
|
623
|
+
const mockImpl = node.arguments[1];
|
|
624
|
+
if (mockImpl) {
|
|
625
|
+
const mockObjectNode = extractMockImplementation(mockImpl);
|
|
626
|
+
const {
|
|
627
|
+
properties,
|
|
628
|
+
hasRequireActual
|
|
629
|
+
} = extractMockProperties({
|
|
630
|
+
sourceCode,
|
|
631
|
+
mockObjectNode
|
|
632
|
+
});
|
|
633
|
+
allMocks.set(normalizedPath, {
|
|
634
|
+
node,
|
|
635
|
+
importPath,
|
|
636
|
+
properties,
|
|
637
|
+
hasRequireActual
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Recursively visit child nodes
|
|
644
|
+
for (const key in node) {
|
|
645
|
+
if (skipKeys.has(key)) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const value = node[key];
|
|
649
|
+
if (value && typeof value === 'object') {
|
|
650
|
+
if (Array.isArray(value)) {
|
|
651
|
+
value.forEach(child => {
|
|
652
|
+
if (child && typeof child === 'object' && 'type' in child) {
|
|
653
|
+
visitNode(child);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
} else if ('type' in value) {
|
|
657
|
+
visitNode(value);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
visitNode(ast);
|
|
663
|
+
return allMocks;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Merge mock properties from multiple sources for the same file
|
|
668
|
+
*/
|
|
669
|
+
function mergeMockProperties({
|
|
670
|
+
existingProperties,
|
|
671
|
+
newProperties
|
|
672
|
+
}) {
|
|
673
|
+
const merged = new Map(existingProperties);
|
|
674
|
+
for (const [key, value] of newProperties) {
|
|
675
|
+
merged.set(key, value);
|
|
676
|
+
}
|
|
677
|
+
return merged;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Generate mock call text for a specific file with given properties
|
|
682
|
+
*/
|
|
683
|
+
function generateMockCallText({
|
|
684
|
+
relativePath,
|
|
685
|
+
properties,
|
|
686
|
+
hasRequireActual,
|
|
687
|
+
quote,
|
|
688
|
+
exportMap,
|
|
689
|
+
sourceFile,
|
|
690
|
+
preamble,
|
|
691
|
+
originalImportPath
|
|
692
|
+
}) {
|
|
693
|
+
const propNames = Array.from(properties.keys());
|
|
694
|
+
|
|
695
|
+
// Separate props by whether they're from default exports
|
|
696
|
+
const defaultExportProps = [];
|
|
697
|
+
const namedExportProps = [];
|
|
698
|
+
for (const prop of propNames) {
|
|
699
|
+
const exportInfo = Array.from(exportMap.entries()).find(([exportName, info]) => exportName === prop && info.path === sourceFile);
|
|
700
|
+
if (exportInfo && exportInfo[1].isDefaultExport) {
|
|
701
|
+
defaultExportProps.push(prop);
|
|
702
|
+
} else {
|
|
703
|
+
namedExportProps.push(prop);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Collect all property texts for filtering the preamble
|
|
708
|
+
const allPropertyTexts = [];
|
|
709
|
+
for (const prop of namedExportProps) {
|
|
710
|
+
var _properties$get;
|
|
711
|
+
const propText = (_properties$get = properties.get(prop)) === null || _properties$get === void 0 ? void 0 : _properties$get.text;
|
|
712
|
+
if (propText) {
|
|
713
|
+
allPropertyTexts.push(propText);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
for (const prop of defaultExportProps) {
|
|
717
|
+
var _properties$get2;
|
|
718
|
+
const propText = (_properties$get2 = properties.get(prop)) === null || _properties$get2 === void 0 ? void 0 : _properties$get2.valueText;
|
|
719
|
+
if (propText) {
|
|
720
|
+
allPropertyTexts.push(propText);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Filter preamble to only include statements used by this mock's properties
|
|
725
|
+
const filteredPreamble = preamble ? filterPreambleForProperties(preamble, allPropertyTexts) : undefined;
|
|
726
|
+
|
|
727
|
+
// If we have a preamble, we need to use block body syntax with return statement
|
|
728
|
+
if (filteredPreamble !== null && filteredPreamble !== void 0 && filteredPreamble.hasPreamble) {
|
|
729
|
+
// Rewrite any jest.requireActual paths in the preamble
|
|
730
|
+
let preambleText = filteredPreamble.text;
|
|
731
|
+
if (originalImportPath) {
|
|
732
|
+
preambleText = rewriteRequireActualPaths({
|
|
733
|
+
text: preambleText,
|
|
734
|
+
originalPath: originalImportPath,
|
|
735
|
+
newPath: relativePath,
|
|
736
|
+
quote
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Rewrite any jest.requireActual paths in property values
|
|
741
|
+
const rewrittenMockObjectProps = namedExportProps.map(p => {
|
|
742
|
+
var _properties$get3;
|
|
743
|
+
let propText = (_properties$get3 = properties.get(p)) === null || _properties$get3 === void 0 ? void 0 : _properties$get3.text;
|
|
744
|
+
if (propText && originalImportPath) {
|
|
745
|
+
propText = rewriteRequireActualPaths({
|
|
746
|
+
text: propText,
|
|
747
|
+
originalPath: originalImportPath,
|
|
748
|
+
newPath: relativePath,
|
|
749
|
+
quote
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return propText;
|
|
753
|
+
}).filter(p => !!p);
|
|
754
|
+
const mockContentLines = [];
|
|
755
|
+
if (hasRequireActual) {
|
|
756
|
+
mockContentLines.push(`...jest.requireActual(${quote}${relativePath}${quote})`);
|
|
757
|
+
}
|
|
758
|
+
mockContentLines.push(...rewrittenMockObjectProps);
|
|
759
|
+
const formattedContent = mockContentLines.map(line => `\t\t${line},`).join('\n');
|
|
760
|
+
return `jest.mock(${quote}${relativePath}${quote}, () => {\n\t${preambleText}\n\treturn {\n${formattedContent}\n\t};\n})`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Generate the mock (original logic for simple cases without preamble)
|
|
764
|
+
let mockCall;
|
|
765
|
+
if (defaultExportProps.length > 0 && namedExportProps.length === 0) {
|
|
766
|
+
// All props are from default export
|
|
767
|
+
if (defaultExportProps.length === 1) {
|
|
768
|
+
var _properties$get4;
|
|
769
|
+
// Single default export - use __esModule pattern
|
|
770
|
+
const mockText = ((_properties$get4 = properties.get(defaultExportProps[0])) === null || _properties$get4 === void 0 ? void 0 : _properties$get4.valueText) || '';
|
|
771
|
+
mockCall = `jest.mock(${quote}${relativePath}${quote}, () => ({\n\t__esModule: true,\n\tdefault: ${mockText}\n}))`;
|
|
772
|
+
} else {
|
|
773
|
+
// Multiple props from same default - shouldn't happen, but handle it
|
|
774
|
+
const mockTexts = defaultExportProps.map(p => {
|
|
775
|
+
var _properties$get5;
|
|
776
|
+
return (_properties$get5 = properties.get(p)) === null || _properties$get5 === void 0 ? void 0 : _properties$get5.text;
|
|
777
|
+
}).join(',\n\t');
|
|
778
|
+
mockCall = `jest.mock(${quote}${relativePath}${quote}, () => ({\n\t${mockTexts}\n}))`;
|
|
779
|
+
}
|
|
780
|
+
} else if (defaultExportProps.length === 0 && namedExportProps.length > 0) {
|
|
781
|
+
// All props are named exports
|
|
782
|
+
const mockObjectProps = namedExportProps.map(p => {
|
|
783
|
+
var _properties$get6;
|
|
784
|
+
return (_properties$get6 = properties.get(p)) === null || _properties$get6 === void 0 ? void 0 : _properties$get6.text;
|
|
785
|
+
}).filter(p => !!p);
|
|
786
|
+
const mockContentLines = [];
|
|
787
|
+
if (hasRequireActual) {
|
|
788
|
+
mockContentLines.push(`...jest.requireActual(${quote}${relativePath}${quote})`);
|
|
789
|
+
}
|
|
790
|
+
mockContentLines.push(...mockObjectProps);
|
|
791
|
+
if (mockContentLines.length === 1 && mockContentLines[0].length < 60) {
|
|
792
|
+
mockCall = `jest.mock(${quote}${relativePath}${quote}, () => ({ ${mockContentLines[0]} }))`;
|
|
793
|
+
} else {
|
|
794
|
+
const formattedContent = mockContentLines.map(line => `\t${line},`).join('\n');
|
|
795
|
+
mockCall = `jest.mock(${quote}${relativePath}${quote}, () => ({\n${formattedContent}\n}))`;
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
// Mixed: has both default and named exports
|
|
799
|
+
const defaultMock = defaultExportProps.map(p => {
|
|
800
|
+
var _properties$get7;
|
|
801
|
+
return (_properties$get7 = properties.get(p)) === null || _properties$get7 === void 0 ? void 0 : _properties$get7.valueText;
|
|
802
|
+
}).join(', ');
|
|
803
|
+
const namedMocks = namedExportProps.map(p => {
|
|
804
|
+
var _properties$get8;
|
|
805
|
+
return (_properties$get8 = properties.get(p)) === null || _properties$get8 === void 0 ? void 0 : _properties$get8.text;
|
|
806
|
+
}).filter(p => !!p);
|
|
807
|
+
const mockContentLines = [`__esModule: true`, `default: ${defaultMock}`, ...namedMocks];
|
|
808
|
+
if (hasRequireActual) {
|
|
809
|
+
mockContentLines.unshift(`...jest.requireActual(${quote}${relativePath}${quote})`);
|
|
810
|
+
}
|
|
811
|
+
const formattedContent = mockContentLines.map(line => `\t${line},`).join('\n');
|
|
812
|
+
mockCall = `jest.mock(${quote}${relativePath}${quote}, () => ({\n${formattedContent}\n}))`;
|
|
813
|
+
}
|
|
814
|
+
return mockCall;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Generate auto-fix for mock with implementation
|
|
819
|
+
*/
|
|
820
|
+
function generateMockImplementationFix({
|
|
821
|
+
propertiesBySource,
|
|
822
|
+
mockProperties,
|
|
823
|
+
hasRequireActual,
|
|
824
|
+
basedir,
|
|
825
|
+
importPath,
|
|
826
|
+
quote,
|
|
827
|
+
exportMap,
|
|
828
|
+
context,
|
|
829
|
+
currentNode,
|
|
830
|
+
preamble,
|
|
831
|
+
workspaceRoot,
|
|
832
|
+
fs
|
|
833
|
+
}) {
|
|
834
|
+
const sourceFilesToMock = Array.from(propertiesBySource.entries());
|
|
835
|
+
|
|
836
|
+
// Find all existing jest.mock calls in the file
|
|
837
|
+
const allExistingMocks = findAllJestMocksInFile({
|
|
838
|
+
context,
|
|
839
|
+
basedir,
|
|
840
|
+
fs
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Track which nodes we need to remove and what mock calls to generate
|
|
844
|
+
const nodesToRemove = new Set();
|
|
845
|
+
const mergedMocks = new Map();
|
|
846
|
+
|
|
847
|
+
// Always remove the current barrel mock node
|
|
848
|
+
nodesToRemove.add(currentNode);
|
|
849
|
+
|
|
850
|
+
// Process each source file we're creating mocks for
|
|
851
|
+
for (const [sourceFile, props] of sourceFilesToMock) {
|
|
852
|
+
// Find the ExportInfo for this source file to get cross-package info
|
|
853
|
+
const exportInfoForSource = Array.from(exportMap.values()).find(info => info.path === sourceFile);
|
|
854
|
+
const mockPath = getImportPathForSourceFile({
|
|
855
|
+
sourceFilePath: sourceFile,
|
|
856
|
+
basedir,
|
|
857
|
+
originalImportPath: importPath,
|
|
858
|
+
exportInfo: exportInfoForSource !== null && exportInfoForSource !== void 0 ? exportInfoForSource : null,
|
|
859
|
+
workspaceRoot,
|
|
860
|
+
fs
|
|
861
|
+
});
|
|
862
|
+
const normalizedPath = normalizePathForComparison({
|
|
863
|
+
basedir,
|
|
864
|
+
importPath: mockPath,
|
|
865
|
+
fs
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// Get properties for this source file from the barrel mock
|
|
869
|
+
const newProperties = new Map();
|
|
870
|
+
for (const prop of props) {
|
|
871
|
+
const propInfo = mockProperties.get(prop);
|
|
872
|
+
if (propInfo) {
|
|
873
|
+
newProperties.set(prop, propInfo);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Check if there's already a mock for this path
|
|
878
|
+
const existingMock = allExistingMocks.get(normalizedPath);
|
|
879
|
+
if (existingMock && existingMock.node !== currentNode) {
|
|
880
|
+
// Merge properties from existing mock with new properties
|
|
881
|
+
const mergedProperties = mergeMockProperties({
|
|
882
|
+
existingProperties: existingMock.properties,
|
|
883
|
+
newProperties
|
|
884
|
+
});
|
|
885
|
+
mergedMocks.set(normalizedPath, {
|
|
886
|
+
mockPath,
|
|
887
|
+
properties: mergedProperties,
|
|
888
|
+
hasRequireActual: existingMock.hasRequireActual || hasRequireActual
|
|
889
|
+
});
|
|
890
|
+
// Mark the existing mock node for removal
|
|
891
|
+
nodesToRemove.add(existingMock.node);
|
|
892
|
+
} else {
|
|
893
|
+
// No existing mock, just use the new properties
|
|
894
|
+
// For newly created mocks from barrel file splits, always include jest.requireActual.
|
|
895
|
+
// This ensures that any properties not explicitly mocked will still be included from the original module.
|
|
896
|
+
mergedMocks.set(normalizedPath, {
|
|
897
|
+
mockPath,
|
|
898
|
+
properties: newProperties,
|
|
899
|
+
hasRequireActual: true
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Generate mock calls for all merged mocks
|
|
905
|
+
const replacementParts = [];
|
|
906
|
+
for (const [, mockInfo] of mergedMocks) {
|
|
907
|
+
// Find the source file for this mock path (may be relative or cross-package)
|
|
908
|
+
// For cross-package paths (starting with @), we don't need to resolve
|
|
909
|
+
const isCrossPackagePath = mockInfo.mockPath.startsWith('@');
|
|
910
|
+
const absolutePath = isCrossPackagePath ? null : resolveImportPath({
|
|
911
|
+
basedir,
|
|
912
|
+
importPath: mockInfo.mockPath,
|
|
913
|
+
fs
|
|
914
|
+
});
|
|
915
|
+
if (!isCrossPackagePath && !absolutePath) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const mockCall = generateMockCallText({
|
|
919
|
+
relativePath: mockInfo.mockPath,
|
|
920
|
+
properties: mockInfo.properties,
|
|
921
|
+
hasRequireActual: mockInfo.hasRequireActual,
|
|
922
|
+
quote,
|
|
923
|
+
exportMap,
|
|
924
|
+
sourceFile: absolutePath !== null && absolutePath !== void 0 ? absolutePath : mockInfo.mockPath,
|
|
925
|
+
preamble,
|
|
926
|
+
originalImportPath: importPath
|
|
927
|
+
});
|
|
928
|
+
replacementParts.push(mockCall);
|
|
929
|
+
}
|
|
930
|
+
const replacementText = replacementParts.join(';\n');
|
|
931
|
+
|
|
932
|
+
// Build a map of symbol name -> new mock path for jest.requireMock() rewriting
|
|
933
|
+
const symbolToNewMockPath = new Map();
|
|
934
|
+
for (const [, mockInfo] of mergedMocks) {
|
|
935
|
+
for (const propName of mockInfo.properties.keys()) {
|
|
936
|
+
symbolToNewMockPath.set(propName, mockInfo.mockPath);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Create fixes: remove all nodes except the first, replace the first with merged mocks
|
|
941
|
+
const fixes = [];
|
|
942
|
+
const sortedNodesToRemove = Array.from(nodesToRemove).sort((a, b) => {
|
|
943
|
+
var _a$range$, _a$range, _b$range$, _b$range;
|
|
944
|
+
return ((_a$range$ = (_a$range = a.range) === null || _a$range === void 0 ? void 0 : _a$range[0]) !== null && _a$range$ !== void 0 ? _a$range$ : 0) - ((_b$range$ = (_b$range = b.range) === null || _b$range === void 0 ? void 0 : _b$range[0]) !== null && _b$range$ !== void 0 ? _b$range$ : 0);
|
|
945
|
+
});
|
|
946
|
+
if (sortedNodesToRemove.length > 0) {
|
|
947
|
+
// Replace the first node with all the merged mocks
|
|
948
|
+
const firstNode = sortedNodesToRemove[0];
|
|
949
|
+
fixes.push({
|
|
950
|
+
range: firstNode.range,
|
|
951
|
+
text: replacementText
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// Remove all other nodes (subsequent duplicates)
|
|
955
|
+
for (let i = 1; i < sortedNodesToRemove.length; i++) {
|
|
956
|
+
const nodeToRemove = sortedNodesToRemove[i];
|
|
957
|
+
// Find the statement that contains this node to remove the entire line
|
|
958
|
+
const sourceCode = context.getSourceCode();
|
|
959
|
+
const tokenAfter = sourceCode.getTokenAfter(nodeToRemove);
|
|
960
|
+
|
|
961
|
+
// Try to remove the entire statement including semicolon and newline
|
|
962
|
+
let startPos = nodeToRemove.range[0];
|
|
963
|
+
let endPos = nodeToRemove.range[1];
|
|
964
|
+
|
|
965
|
+
// Include trailing semicolon if present
|
|
966
|
+
if (tokenAfter && tokenAfter.type === 'Punctuator' && tokenAfter.value === ';') {
|
|
967
|
+
endPos = tokenAfter.range[1];
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Include trailing/leading whitespace and newlines
|
|
971
|
+
const text = sourceCode.getText();
|
|
972
|
+
while (endPos < text.length && /[\s\n]/.test(text[endPos])) {
|
|
973
|
+
endPos++;
|
|
974
|
+
}
|
|
975
|
+
fixes.push({
|
|
976
|
+
range: [startPos, endPos],
|
|
977
|
+
text: ''
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Fix jest.requireMock() calls that reference the old barrel path.
|
|
983
|
+
// When we split a jest.mock('./barrel') into jest.mock('./specific-file'),
|
|
984
|
+
// any jest.requireMock('./barrel') calls also need to be updated.
|
|
985
|
+
const ast = context.getSourceCode().ast;
|
|
986
|
+
const normalizedTarget = normalizePathForComparison({
|
|
987
|
+
basedir,
|
|
988
|
+
importPath,
|
|
989
|
+
fs
|
|
990
|
+
});
|
|
991
|
+
const requireMockCalls = findJestRequireMockCalls({
|
|
992
|
+
ast,
|
|
993
|
+
matchPath: candidatePath => normalizePathForComparison({
|
|
994
|
+
basedir,
|
|
995
|
+
importPath: candidatePath,
|
|
996
|
+
fs
|
|
997
|
+
}) === normalizedTarget
|
|
998
|
+
});
|
|
999
|
+
for (const requireMockNode of requireMockCalls) {
|
|
1000
|
+
const requireMockArg = requireMockNode.arguments[0];
|
|
1001
|
+
if (!requireMockArg) {
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
const newPath = resolveNewPathForRequireMock({
|
|
1005
|
+
requireMockNode,
|
|
1006
|
+
symbolToNewPath: symbolToNewMockPath
|
|
1007
|
+
});
|
|
1008
|
+
if (newPath) {
|
|
1009
|
+
fixes.push({
|
|
1010
|
+
range: requireMockArg.range,
|
|
1011
|
+
text: `${quote}${newPath}${quote}`
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return fixes;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Metadata for the ESLint rule
|
|
1020
|
+
*/
|
|
1021
|
+
const ruleMeta = {
|
|
1022
|
+
type: 'problem',
|
|
1023
|
+
docs: {
|
|
1024
|
+
description: 'Warn when jest.mock is used on a relative import path from a barrel file, and provide an auto-fix to split mocks by source file.',
|
|
1025
|
+
category: 'Best Practices',
|
|
1026
|
+
recommended: false
|
|
1027
|
+
},
|
|
1028
|
+
fixable: 'code',
|
|
1029
|
+
messages: {
|
|
1030
|
+
barrelMock: "jest.mock('{{path}}') is mocking a barrel file. This should be split into separate mocks for each source file to improve performance. Use auto-fix to resolve."
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Factory function to create the ESLint rule with a given file system.
|
|
1036
|
+
* This enables testing with mock file systems.
|
|
1037
|
+
*/
|
|
1038
|
+
export function createRule(fs) {
|
|
1039
|
+
return {
|
|
1040
|
+
meta: ruleMeta,
|
|
1041
|
+
create(context) {
|
|
1042
|
+
return {
|
|
1043
|
+
CallExpression(rawNode) {
|
|
1044
|
+
const node = rawNode;
|
|
1045
|
+
|
|
1046
|
+
// Step 1: Validate this is a jest.mock call
|
|
1047
|
+
if (!isJestMockCall(node)) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Step 2: Extract the import path
|
|
1052
|
+
const importPath = extractImportPath(node);
|
|
1053
|
+
if (!importPath) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Step 3: Validate and resolve barrel file
|
|
1058
|
+
const basedir = dirname(context.filename);
|
|
1059
|
+
const workspaceRoot = findWorkspaceRoot({
|
|
1060
|
+
startPath: basedir,
|
|
1061
|
+
fs
|
|
1062
|
+
});
|
|
1063
|
+
const barrelInfo = validateAndResolveBarrelFile({
|
|
1064
|
+
importPath,
|
|
1065
|
+
basedir,
|
|
1066
|
+
workspaceRoot,
|
|
1067
|
+
fs
|
|
1068
|
+
});
|
|
1069
|
+
if (!barrelInfo) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const {
|
|
1073
|
+
exportMap,
|
|
1074
|
+
resolvedPath: barrelFilePath
|
|
1075
|
+
} = barrelInfo;
|
|
1076
|
+
const sourceCode = context.getSourceCode();
|
|
1077
|
+
const firstArg = node.arguments[0];
|
|
1078
|
+
|
|
1079
|
+
// Step 4: Handle auto-mock case (no mock implementation)
|
|
1080
|
+
const mockImpl = node.arguments[1];
|
|
1081
|
+
if (!mockImpl) {
|
|
1082
|
+
// Group exports by source file, filtering out type-only source files
|
|
1083
|
+
const sourceFilesWithNonTypeExports = new Set();
|
|
1084
|
+
for (const [, info] of exportMap) {
|
|
1085
|
+
if (!info.isTypeOnly) {
|
|
1086
|
+
sourceFilesWithNonTypeExports.add(info.path);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (sourceFilesWithNonTypeExports.size === 0) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
context.report({
|
|
1093
|
+
node: node,
|
|
1094
|
+
messageId: 'barrelMock',
|
|
1095
|
+
data: {
|
|
1096
|
+
path: importPath
|
|
1097
|
+
},
|
|
1098
|
+
fix(fixer) {
|
|
1099
|
+
const quote = sourceCode.getText(firstArg)[0];
|
|
1100
|
+
const replacement = generateAutoMockFix({
|
|
1101
|
+
exportMap,
|
|
1102
|
+
basedir,
|
|
1103
|
+
importPath,
|
|
1104
|
+
quote,
|
|
1105
|
+
workspaceRoot,
|
|
1106
|
+
fs
|
|
1107
|
+
});
|
|
1108
|
+
return fixer.replaceText(node, replacement);
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Step 5: Extract mock implementation and properties
|
|
1115
|
+
const mockObjectNode = extractMockImplementation(mockImpl);
|
|
1116
|
+
const {
|
|
1117
|
+
properties: mockProperties,
|
|
1118
|
+
hasRequireActual
|
|
1119
|
+
} = extractMockProperties({
|
|
1120
|
+
sourceCode,
|
|
1121
|
+
mockObjectNode
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
// Extract preamble (variable declarations, etc.) from the mock factory
|
|
1125
|
+
const preamble = extractMockFactoryPreamble({
|
|
1126
|
+
mockImpl: mockImpl,
|
|
1127
|
+
sourceCode
|
|
1128
|
+
});
|
|
1129
|
+
if (mockProperties.size === 0) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Step 6: Group properties by their source files
|
|
1134
|
+
const propertiesBySource = groupPropertiesBySource({
|
|
1135
|
+
mockProperties,
|
|
1136
|
+
exportMap
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// Step 7: Determine if we should report
|
|
1140
|
+
if (!shouldReportBarrelMock({
|
|
1141
|
+
propertiesBySource,
|
|
1142
|
+
barrelFilePath
|
|
1143
|
+
})) {
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Step 8: Report with auto-fix
|
|
1148
|
+
context.report({
|
|
1149
|
+
node: node,
|
|
1150
|
+
messageId: 'barrelMock',
|
|
1151
|
+
data: {
|
|
1152
|
+
path: importPath
|
|
1153
|
+
},
|
|
1154
|
+
fix(_fixer) {
|
|
1155
|
+
const quote = sourceCode.getText(firstArg)[0];
|
|
1156
|
+
const fixes = generateMockImplementationFix({
|
|
1157
|
+
propertiesBySource,
|
|
1158
|
+
mockProperties,
|
|
1159
|
+
hasRequireActual,
|
|
1160
|
+
basedir,
|
|
1161
|
+
importPath,
|
|
1162
|
+
quote,
|
|
1163
|
+
exportMap,
|
|
1164
|
+
context,
|
|
1165
|
+
currentNode: node,
|
|
1166
|
+
preamble,
|
|
1167
|
+
workspaceRoot,
|
|
1168
|
+
fs
|
|
1169
|
+
});
|
|
1170
|
+
return fixes;
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
const rule = createRule(realFileSystem);
|
|
1179
|
+
export default rule;
|