@atlaskit/eslint-plugin-platform 2.8.0 → 2.9.1
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 +16 -0
- package/dist/cjs/index.js +8 -1
- package/dist/cjs/rules/ensure-critical-dependency-resolutions/index.js +0 -1
- package/dist/cjs/rules/ensure-use-sync-external-store-server-snapshot/index.js +41 -0
- package/dist/cjs/rules/import/no-barrel-entry-imports/index.js +534 -74
- package/dist/cjs/rules/import/no-barrel-entry-jest-mock/index.js +428 -119
- package/dist/cjs/rules/import/no-jest-mock-barrel-files/index.js +3 -2
- package/dist/cjs/rules/import/no-relative-barrel-file-imports/index.js +7 -3
- package/dist/cjs/rules/import/shared/jest-utils.js +62 -9
- package/dist/cjs/rules/import/shared/package-resolution.js +300 -22
- package/dist/cjs/rules/no-restricted-fedramp-imports/index.js +65 -0
- package/dist/cjs/rules/visit-example-type-import-required/index.js +409 -0
- package/dist/es2019/index.js +8 -1
- package/dist/es2019/rules/ensure-critical-dependency-resolutions/index.js +0 -1
- package/dist/es2019/rules/ensure-use-sync-external-store-server-snapshot/index.js +43 -0
- package/dist/es2019/rules/import/no-barrel-entry-imports/index.js +431 -25
- package/dist/es2019/rules/import/no-barrel-entry-jest-mock/index.js +287 -25
- package/dist/es2019/rules/import/no-jest-mock-barrel-files/index.js +3 -2
- package/dist/es2019/rules/import/no-relative-barrel-file-imports/index.js +7 -3
- package/dist/es2019/rules/import/shared/jest-utils.js +44 -0
- package/dist/es2019/rules/import/shared/package-resolution.js +211 -4
- package/dist/es2019/rules/no-restricted-fedramp-imports/index.js +47 -0
- package/dist/es2019/rules/visit-example-type-import-required/index.js +375 -0
- package/dist/esm/index.js +8 -1
- package/dist/esm/rules/ensure-critical-dependency-resolutions/index.js +0 -1
- package/dist/esm/rules/ensure-use-sync-external-store-server-snapshot/index.js +35 -0
- package/dist/esm/rules/import/no-barrel-entry-imports/index.js +535 -75
- package/dist/esm/rules/import/no-barrel-entry-jest-mock/index.js +430 -121
- package/dist/esm/rules/import/no-jest-mock-barrel-files/index.js +3 -2
- package/dist/esm/rules/import/no-relative-barrel-file-imports/index.js +7 -3
- package/dist/esm/rules/import/shared/jest-utils.js +61 -9
- package/dist/esm/rules/import/shared/package-resolution.js +298 -24
- package/dist/esm/rules/no-restricted-fedramp-imports/index.js +59 -0
- package/dist/esm/rules/visit-example-type-import-required/index.js +402 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/rules/ensure-use-sync-external-store-server-snapshot/index.d.ts +3 -0
- package/dist/types/rules/import/shared/jest-utils.d.ts +8 -0
- package/dist/types/rules/import/shared/package-resolution.d.ts +47 -2
- package/dist/types/rules/no-restricted-fedramp-imports/index.d.ts +3 -0
- package/dist/types/rules/visit-example-type-import-required/index.d.ts +4 -0
- package/dist/types-ts4.5/index.d.ts +14 -0
- package/dist/types-ts4.5/rules/ensure-use-sync-external-store-server-snapshot/index.d.ts +3 -0
- package/dist/types-ts4.5/rules/import/shared/jest-utils.d.ts +8 -0
- package/dist/types-ts4.5/rules/import/shared/package-resolution.d.ts +47 -2
- package/dist/types-ts4.5/rules/no-restricted-fedramp-imports/index.d.ts +3 -0
- package/dist/types-ts4.5/rules/visit-example-type-import-required/index.d.ts +4 -0
- package/package.json +3 -1
|
@@ -1,6 +1,65 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
2
|
-
import
|
|
1
|
+
import { dirname, join } from 'path';
|
|
2
|
+
import * as ts from 'typescript';
|
|
3
|
+
import { isRelativeImport, readFileContent, resolveImportPath } from './file-system';
|
|
3
4
|
import { findPackageInRegistry } from './package-registry';
|
|
5
|
+
const ENTRY_POINT_FOLDER_NAMES = new Set(['entry-points', 'entrypoints', 'entrypoint', 'entry-point']);
|
|
6
|
+
function isInEntryPointsFolder(filePath) {
|
|
7
|
+
const parts = filePath.split(/[/\\]/);
|
|
8
|
+
return parts.some(part => ENTRY_POINT_FOLDER_NAMES.has(part));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parse an entry-point wrapper file and resolve the source files it re-exports from,
|
|
12
|
+
* along with name mappings (source export name → entry-point export name).
|
|
13
|
+
*/
|
|
14
|
+
function resolveEntryPointReExports({
|
|
15
|
+
entryPointFilePath,
|
|
16
|
+
fs
|
|
17
|
+
}) {
|
|
18
|
+
const content = readFileContent({
|
|
19
|
+
filePath: entryPointFilePath,
|
|
20
|
+
fs
|
|
21
|
+
});
|
|
22
|
+
if (!content) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const sourceFile = ts.createSourceFile(entryPointFilePath, content, ts.ScriptTarget.Latest, true);
|
|
27
|
+
const basedir = dirname(entryPointFilePath);
|
|
28
|
+
const results = [];
|
|
29
|
+
for (const statement of sourceFile.statements) {
|
|
30
|
+
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
31
|
+
const modulePath = statement.moduleSpecifier.text;
|
|
32
|
+
if (!isRelativeImport(modulePath)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const resolved = resolveImportPath({
|
|
36
|
+
basedir,
|
|
37
|
+
importPath: modulePath,
|
|
38
|
+
fs
|
|
39
|
+
});
|
|
40
|
+
if (!resolved) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const nameMap = new Map();
|
|
44
|
+
if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
45
|
+
for (const element of statement.exportClause.elements) {
|
|
46
|
+
const exportedName = element.name.text;
|
|
47
|
+
const sourceName = element.propertyName ? element.propertyName.text : exportedName;
|
|
48
|
+
nameMap.set(sourceName, exportedName);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
results.push({
|
|
52
|
+
sourcePath: resolved,
|
|
53
|
+
nameMap
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
4
63
|
/**
|
|
5
64
|
* Parse the package.json exports field and return a map of export paths to resolved file paths.
|
|
6
65
|
*/
|
|
@@ -76,18 +135,166 @@ export function parsePackageExports({
|
|
|
76
135
|
});
|
|
77
136
|
return exportsMap;
|
|
78
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Check whether a subpath export key (e.g. `"./checkbox-select"`) is kebab-case.
|
|
140
|
+
*
|
|
141
|
+
* A key is considered kebab-case when the portion after the leading `"./"` prefix
|
|
142
|
+
* consists only of lowercase letters, digits, hyphens, dots, and forward-slash
|
|
143
|
+
* separators — i.e. no uppercase letters, underscores, or camelCase humps.
|
|
144
|
+
*/
|
|
145
|
+
export function isKebabCaseExportKey(key) {
|
|
146
|
+
const body = key.replace(/^\.\//, '');
|
|
147
|
+
if (body.length === 0) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return /^[a-z0-9][a-z0-9\-./]*$/.test(body);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Given a list of candidate {@link ExportMatchResult}s that all resolve to the same
|
|
155
|
+
* source file, pick the best one. When any candidate's export path is kebab-case
|
|
156
|
+
* and points to an entry-point file, prefer it over non-kebab-case alternatives.
|
|
157
|
+
* Falls back to the first candidate if no kebab-case entry-point candidate is found.
|
|
158
|
+
*/
|
|
159
|
+
function pickBestMatch(candidates, exportsMap) {
|
|
160
|
+
if (candidates.length === 1) {
|
|
161
|
+
return candidates[0];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Among candidates whose value is an entry-point file, prefer kebab-case keys.
|
|
165
|
+
const entryPointKebab = candidates.filter(c => {
|
|
166
|
+
const resolved = exportsMap.get(c.exportPath);
|
|
167
|
+
return resolved && isInEntryPointsFolder(resolved) && isKebabCaseExportKey(c.exportPath);
|
|
168
|
+
});
|
|
169
|
+
if (entryPointKebab.length > 0) {
|
|
170
|
+
return entryPointKebab[0];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Fall back to the first candidate (preserves previous behaviour).
|
|
174
|
+
return candidates[0];
|
|
175
|
+
}
|
|
79
176
|
|
|
80
177
|
/**
|
|
81
178
|
* Find a matching export entry for a given source file path.
|
|
82
179
|
* Returns the export path (e.g., "./controllers/analytics") or null if not found.
|
|
180
|
+
*
|
|
181
|
+
* When multiple export paths resolve to the same source file **and** point to an
|
|
182
|
+
* entry-point file, kebab-case keys are preferred over other casing styles.
|
|
183
|
+
*
|
|
184
|
+
* When `fs` is provided, also checks entry-point wrapper files. If an export resolves
|
|
185
|
+
* to a file inside a recognized entry-points folder (entry-points, entrypoints, etc.),
|
|
186
|
+
* the wrapper is parsed to see if it re-exports from `sourceFilePath`.
|
|
187
|
+
*
|
|
188
|
+
* `sourceExportName` is the name under which the symbol is exported from the source file
|
|
189
|
+
* (e.g. `'default'`). Used to look up the corresponding entry-point export name so the
|
|
190
|
+
* caller can generate the correct import style.
|
|
83
191
|
*/
|
|
84
192
|
export function findExportForSourceFile({
|
|
85
193
|
sourceFilePath,
|
|
86
|
-
exportsMap
|
|
194
|
+
exportsMap,
|
|
195
|
+
fs,
|
|
196
|
+
sourceExportName
|
|
87
197
|
}) {
|
|
198
|
+
// --- Phase 1: direct matches (export value === sourceFilePath) ---
|
|
199
|
+
const directMatches = [];
|
|
88
200
|
for (const [exportPath, resolvedPath] of exportsMap) {
|
|
89
201
|
if (resolvedPath === sourceFilePath) {
|
|
90
|
-
|
|
202
|
+
directMatches.push({
|
|
203
|
+
exportPath
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (directMatches.length > 0) {
|
|
208
|
+
return pickBestMatch(directMatches, exportsMap);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Phase 2: entry-point wrapper re-export matches ---
|
|
212
|
+
if (fs) {
|
|
213
|
+
const entryPointMatches = [];
|
|
214
|
+
for (const [exportPath, resolvedPath] of exportsMap) {
|
|
215
|
+
if (isInEntryPointsFolder(resolvedPath)) {
|
|
216
|
+
const reExports = resolveEntryPointReExports({
|
|
217
|
+
entryPointFilePath: resolvedPath,
|
|
218
|
+
fs
|
|
219
|
+
});
|
|
220
|
+
for (const reExport of reExports) {
|
|
221
|
+
if (reExport.sourcePath === sourceFilePath) {
|
|
222
|
+
let entryPointExportName;
|
|
223
|
+
if (sourceExportName !== undefined && reExport.nameMap.has(sourceExportName)) {
|
|
224
|
+
entryPointExportName = reExport.nameMap.get(sourceExportName);
|
|
225
|
+
}
|
|
226
|
+
entryPointMatches.push({
|
|
227
|
+
exportPath,
|
|
228
|
+
entryPointExportName
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (entryPointMatches.length > 0) {
|
|
235
|
+
return pickBestMatch(entryPointMatches, exportsMap);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* When a symbol reaches the consumer through a barrel package that re-exports from
|
|
243
|
+
* `crossPackageName`, find a `package.json` export subpath of that barrel whose entry
|
|
244
|
+
* file directly re-exports the symbol from `crossPackageName` (named exports only).
|
|
245
|
+
*
|
|
246
|
+
* This enables rewriting imports to `@scope/barrel/subpath` instead of
|
|
247
|
+
* `@scope/cross-package/...` when the barrel exposes such a subpath (e.g. `@atlaskit/select/react-select`).
|
|
248
|
+
*/
|
|
249
|
+
export function findCrossPackageBridgeExportPath({
|
|
250
|
+
exportsMap,
|
|
251
|
+
crossPackageName,
|
|
252
|
+
exportedName,
|
|
253
|
+
fs
|
|
254
|
+
}) {
|
|
255
|
+
for (const [exportPath, resolvedPath] of exportsMap) {
|
|
256
|
+
const content = readFileContent({
|
|
257
|
+
filePath: resolvedPath,
|
|
258
|
+
fs
|
|
259
|
+
});
|
|
260
|
+
if (!content) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const sourceFile = ts.createSourceFile(resolvedPath, content, ts.ScriptTarget.Latest, true);
|
|
265
|
+
for (const statement of sourceFile.statements) {
|
|
266
|
+
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (statement.moduleSpecifier.text !== crossPackageName) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!statement.exportClause || ts.isNamespaceExport(statement.exportClause)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!ts.isNamedExports(statement.exportClause)) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
for (const element of statement.exportClause.elements) {
|
|
282
|
+
if (element.isTypeOnly) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const publicName = element.name.text;
|
|
286
|
+
if (publicName !== exportedName) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const entryPointExportName = element.propertyName ? element.propertyName.text : undefined;
|
|
290
|
+
return {
|
|
291
|
+
exportPath,
|
|
292
|
+
entryPointExportName
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Ignore parse errors for individual export entry files
|
|
91
298
|
}
|
|
92
299
|
}
|
|
93
300
|
return null;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
2
|
+
|
|
3
|
+
const RESTRICTED_IMPORTS = {
|
|
4
|
+
'@atlassian/atl-context': ['isFedRamp', 'isIsolatedCloud'],
|
|
5
|
+
'@atlaskit/atlassian-context': ['isFedRamp', 'isIsolatedCloud'],
|
|
6
|
+
'@atlassian/teams-common': ['isFedramp']
|
|
7
|
+
};
|
|
8
|
+
const rule = {
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
docs: {
|
|
12
|
+
description: 'Disallow importing deprecated FedRamp/IsolatedCloud context functions. Use isFeatureEnabled from AEM (Atlassian Environment Manager) instead.',
|
|
13
|
+
recommended: true
|
|
14
|
+
},
|
|
15
|
+
messages: {
|
|
16
|
+
noRestrictedFedrampImports: '{{name}} from {{source}} will be deprecated soon. Please use isFeatureEnabled from AEM (Atlassian Environment Manager) instead. See go/AEM for more details.'
|
|
17
|
+
},
|
|
18
|
+
schema: []
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
return {
|
|
22
|
+
ImportDeclaration(node) {
|
|
23
|
+
const source = node.source.value;
|
|
24
|
+
if (typeof source !== 'string') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const restrictedNames = RESTRICTED_IMPORTS[source];
|
|
28
|
+
if (!restrictedNames) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const specifier of node.specifiers) {
|
|
32
|
+
if (specifier.type === 'ImportSpecifier' && restrictedNames.includes(specifier.imported.name)) {
|
|
33
|
+
context.report({
|
|
34
|
+
node: specifier,
|
|
35
|
+
messageId: 'noRestrictedFedrampImports',
|
|
36
|
+
data: {
|
|
37
|
+
name: specifier.imported.name,
|
|
38
|
+
source
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
export default rule;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
4
|
+
import { simpleTraverse } from '@typescript-eslint/typescript-estree';
|
|
5
|
+
export const RULE_NAME = 'visit-example-type-import-required';
|
|
6
|
+
const messages = {
|
|
7
|
+
missingTypeofImport: 'visitExample must use typeof import(...) generic type parameter. ' + 'Use visitExample<typeof import("path/to/example.tsx")>(groupId, packageId, exampleId).',
|
|
8
|
+
invalidTypeParameter: 'visitExample generic type parameter must be a typeof import(...) expression.',
|
|
9
|
+
pathMismatch: 'The import path "{{ importPath }}" does not match the expected example file for ' + 'visitExample({{ groupId }}, {{ packageId }}, {{ exampleId }}). ' + 'Expected import to resolve to: {{ expectedPath }}',
|
|
10
|
+
noPackageImports: 'Package imports (e.g., @atlaskit/...) are not allowed in visitExample type parameters. Use a relative import path.',
|
|
11
|
+
typeAliasNotInlined: 'Type aliases for typeof import(...) must be inlined directly into the visitExample call. ' + 'Use visitExample<typeof import("...")>(...) instead of defining a type alias.',
|
|
12
|
+
suggestFixPath: 'Update import path to match visitExample arguments'
|
|
13
|
+
};
|
|
14
|
+
function isTargetFile(filename) {
|
|
15
|
+
return filename.endsWith('.spec.tsx') || filename.endsWith('.spec.ts');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extracts the import path string from a TSTypeQuery node of the form `typeof import('...')`.
|
|
20
|
+
* Returns null if the node doesn't match that shape.
|
|
21
|
+
*/
|
|
22
|
+
function extractImportPathFromTypeQuery(typeQuery) {
|
|
23
|
+
// TSTypeQuery { exprName: TSImportType { argument: TSLiteralType { literal: Literal } } }
|
|
24
|
+
const {
|
|
25
|
+
exprName
|
|
26
|
+
} = typeQuery;
|
|
27
|
+
if (exprName.type !== AST_NODE_TYPES.TSImportType) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const {
|
|
31
|
+
argument
|
|
32
|
+
} = exprName;
|
|
33
|
+
if (argument.type === AST_NODE_TYPES.TSLiteralType && argument.literal.type === AST_NODE_TYPES.Literal && typeof argument.literal.value === 'string') {
|
|
34
|
+
return argument.literal.value;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Builds a map of all TSTypeAliasDeclaration nodes in the file, keyed by name.
|
|
40
|
+
* Each entry records whether the alias is at the top level of the program (file-level).
|
|
41
|
+
*/
|
|
42
|
+
function collectTypeAliases(ast) {
|
|
43
|
+
const result = new Map();
|
|
44
|
+
|
|
45
|
+
// Cast through unknown to work around a version mismatch: @typescript-eslint/utils
|
|
46
|
+
// vendors its own copy of @typescript-eslint/types (v7) while the root node_modules
|
|
47
|
+
// has a different version (v5). The TSESTree types are structurally identical at
|
|
48
|
+
// runtime — the cast is safe.
|
|
49
|
+
simpleTraverse(ast, {
|
|
50
|
+
enter(node, parent) {
|
|
51
|
+
// A type alias is "file-level" if its immediate parent is the Program,
|
|
52
|
+
// or if it's the declaration of a top-level ExportNamedDeclaration.
|
|
53
|
+
if (node.type === AST_NODE_TYPES.TSTypeAliasDeclaration) {
|
|
54
|
+
const isFileLevel = (parent === null || parent === void 0 ? void 0 : parent.type) === AST_NODE_TYPES.Program || (parent === null || parent === void 0 ? void 0 : parent.type) === AST_NODE_TYPES.ExportNamedDeclaration;
|
|
55
|
+
result.set(node.id.name, {
|
|
56
|
+
node: node,
|
|
57
|
+
isFileLevel
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolves a top-level `const foo = 'literal'` declaration to its string value.
|
|
67
|
+
* Returns null for non-const, non-string, or not-found variables.
|
|
68
|
+
*/
|
|
69
|
+
function resolveVariableToConstant(programBody, variableName, cache) {
|
|
70
|
+
if (cache.has(variableName)) {
|
|
71
|
+
var _cache$get;
|
|
72
|
+
return (_cache$get = cache.get(variableName)) !== null && _cache$get !== void 0 ? _cache$get : null;
|
|
73
|
+
}
|
|
74
|
+
for (const node of programBody) {
|
|
75
|
+
if (node.type !== AST_NODE_TYPES.VariableDeclaration || node.kind !== 'const') {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const declarator of node.declarations) {
|
|
79
|
+
var _declarator$init;
|
|
80
|
+
if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.id.name === variableName && ((_declarator$init = declarator.init) === null || _declarator$init === void 0 ? void 0 : _declarator$init.type) === AST_NODE_TYPES.Literal && typeof declarator.init.value === 'string') {
|
|
81
|
+
cache.set(variableName, declarator.init.value);
|
|
82
|
+
return declarator.init.value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cache.set(variableName, null);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Extracts the (groupId, packageId, exampleId) string arguments from a visitExample call.
|
|
91
|
+
* Each argument may be a string literal or a reference to a top-level const string variable.
|
|
92
|
+
* Returns null for any argument that can't be statically resolved.
|
|
93
|
+
*/
|
|
94
|
+
function extractCallArgs(node, programBody, variableCache) {
|
|
95
|
+
if (node.arguments.length < 3) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function resolveArg(arg) {
|
|
99
|
+
if (arg.type === AST_NODE_TYPES.Literal && typeof arg.value === 'string') {
|
|
100
|
+
return arg.value;
|
|
101
|
+
}
|
|
102
|
+
if (arg.type === AST_NODE_TYPES.Identifier) {
|
|
103
|
+
return resolveVariableToConstant(programBody, arg.name, variableCache);
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const groupId = resolveArg(node.arguments[0]);
|
|
108
|
+
const packageId = resolveArg(node.arguments[1]);
|
|
109
|
+
const exampleId = resolveArg(node.arguments[2]);
|
|
110
|
+
if (!groupId || !packageId || !exampleId) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
groupId,
|
|
115
|
+
packageId,
|
|
116
|
+
exampleId
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function getPackagesBasePath(testFilePath) {
|
|
120
|
+
const testFileDir = path.dirname(testFilePath);
|
|
121
|
+
const testFileSegments = testFileDir.split(path.sep);
|
|
122
|
+
const packagesIndex = testFileSegments.findIndex(seg => seg === 'packages');
|
|
123
|
+
if (packagesIndex === -1) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const baseSegments = testFileSegments.slice(0, packagesIndex + 1);
|
|
127
|
+
const basePath = path.isAbsolute(testFilePath) ? path.resolve('/', ...baseSegments) : path.resolve(process.cwd(), ...baseSegments);
|
|
128
|
+
return {
|
|
129
|
+
basePath,
|
|
130
|
+
packagesIndex
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolves the expected example file path from visitExample arguments.
|
|
136
|
+
*
|
|
137
|
+
* visitExample('groupId', 'packageId', 'exampleId') maps to:
|
|
138
|
+
* packages/{groupId}/{packageId}/examples/{exampleId}.tsx
|
|
139
|
+
*
|
|
140
|
+
* Example files may also have a numeric sort prefix, e.g.:
|
|
141
|
+
* packages/{groupId}/{packageId}/examples/00-{exampleId}.tsx
|
|
142
|
+
*
|
|
143
|
+
* We scan the examples directory once and match against all candidates.
|
|
144
|
+
* Falls back to the bare `{exampleId}.tsx` name when the directory can't
|
|
145
|
+
* be read (e.g. in unit-test environments where the files don't exist).
|
|
146
|
+
*/
|
|
147
|
+
function resolveExamplePathFromArgs(groupId, packageId, exampleId, testFilePath) {
|
|
148
|
+
const packagesBase = getPackagesBasePath(testFilePath);
|
|
149
|
+
if (!packagesBase) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const examplesDir = path.resolve(packagesBase.basePath, groupId, packageId, 'examples');
|
|
153
|
+
const fallback = path.resolve(examplesDir, `${exampleId}.tsx`);
|
|
154
|
+
|
|
155
|
+
// Match: exact name OR numeric-prefixed variant, with optional `.examples` infix
|
|
156
|
+
const candidateRe = new RegExp(`^(?:\\d+-)?${exampleId}(?:\\.examples?)?\\.tsx$`);
|
|
157
|
+
try {
|
|
158
|
+
const match = fs.readdirSync(examplesDir).find(f => candidateRe.test(f));
|
|
159
|
+
if (match) {
|
|
160
|
+
return path.resolve(examplesDir, match);
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Directory doesn't exist or can't be read (e.g. in test environments)
|
|
164
|
+
}
|
|
165
|
+
return fallback;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Computes a relative import path from one file to another
|
|
170
|
+
*/
|
|
171
|
+
function computeRelativeImportPath(fromFile, toFile) {
|
|
172
|
+
const fromDir = path.dirname(fromFile);
|
|
173
|
+
let relativePath = path.relative(fromDir, toFile);
|
|
174
|
+
// Normalize to forward slashes for import statements (standard in JavaScript/TypeScript)
|
|
175
|
+
relativePath = relativePath.replace(/\\/g, '/');
|
|
176
|
+
// Ensure relative imports start with ./ or ../
|
|
177
|
+
if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) {
|
|
178
|
+
relativePath = `./${relativePath}`;
|
|
179
|
+
}
|
|
180
|
+
return relativePath;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Extracts the generic type argument from a `visitExample<...>(...)` call expression
|
|
184
|
+
* using the AST directly (no regex on source text).
|
|
185
|
+
*
|
|
186
|
+
* Returns:
|
|
187
|
+
* { type: 'inline', importPath } — for `visitExample<typeof import('...')>(...)`
|
|
188
|
+
* { type: 'alias', name } — for `visitExample<SomeTypeAlias>(...)`
|
|
189
|
+
* null — if no generic type parameter is present
|
|
190
|
+
*/
|
|
191
|
+
function extractGenericType(node) {
|
|
192
|
+
var _params, _ref, _node$typeArguments;
|
|
193
|
+
// `typeArguments` is the current property name; `typeParameters` is the deprecated alias.
|
|
194
|
+
// We fall back to `typeParameters` for compatibility with older parser versions.
|
|
195
|
+
const params = (_params = (_ref = (_node$typeArguments = node.typeArguments) !== null && _node$typeArguments !== void 0 ? _node$typeArguments : node.typeParameters) === null || _ref === void 0 ? void 0 : _ref.params) !== null && _params !== void 0 ? _params : [];
|
|
196
|
+
if (params.length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const [typeParam] = params;
|
|
200
|
+
|
|
201
|
+
// `typeof import('...')` → TSTypeQuery { exprName: TSImportType { ... } }
|
|
202
|
+
if (typeParam.type === AST_NODE_TYPES.TSTypeQuery) {
|
|
203
|
+
const importPath = extractImportPathFromTypeQuery(typeParam);
|
|
204
|
+
if (importPath !== null) {
|
|
205
|
+
return {
|
|
206
|
+
type: 'inline',
|
|
207
|
+
importPath
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `SomeTypeAlias` → TSTypeReference { typeName: Identifier { name } }
|
|
213
|
+
if (typeParam.type === AST_NODE_TYPES.TSTypeReference && typeParam.typeName.type === AST_NODE_TYPES.Identifier) {
|
|
214
|
+
return {
|
|
215
|
+
type: 'alias',
|
|
216
|
+
name: typeParam.typeName.name
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const rule = {
|
|
222
|
+
meta: {
|
|
223
|
+
type: 'problem',
|
|
224
|
+
docs: {
|
|
225
|
+
description: 'Ensures that visitExample uses a typeof import(...) generic and that the import path matches the example file resolved from the call arguments.'
|
|
226
|
+
},
|
|
227
|
+
fixable: 'code',
|
|
228
|
+
messages,
|
|
229
|
+
schema: []
|
|
230
|
+
},
|
|
231
|
+
create(context) {
|
|
232
|
+
const filename = context.filename;
|
|
233
|
+
const ast = context.sourceCode.ast;
|
|
234
|
+
const programBody = ast.body;
|
|
235
|
+
|
|
236
|
+
// Build the type alias map once per file (lazily on first visitExample call)
|
|
237
|
+
let typeAliases = null;
|
|
238
|
+
function getTypeAliases() {
|
|
239
|
+
if (!typeAliases) {
|
|
240
|
+
typeAliases = collectTypeAliases(ast);
|
|
241
|
+
}
|
|
242
|
+
return typeAliases;
|
|
243
|
+
}
|
|
244
|
+
const variableCache = new Map();
|
|
245
|
+
return {
|
|
246
|
+
CallExpression(estreeNode) {
|
|
247
|
+
if (!isTargetFile(filename)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const node = estreeNode;
|
|
251
|
+
// Only handle `<anything>.visitExample(...)` calls
|
|
252
|
+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression || node.callee.property.type !== AST_NODE_TYPES.Identifier || node.callee.property.name !== 'visitExample') {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Narrow callee — we've confirmed property is an Identifier above
|
|
257
|
+
const callee = node.callee;
|
|
258
|
+
// reportCallee is typed as estree.Node for context.report compatibility
|
|
259
|
+
const reportCallee = estreeNode.callee;
|
|
260
|
+
const genericType = extractGenericType(node);
|
|
261
|
+
|
|
262
|
+
// ── Case 1: No generic type parameter ────────────────────────────────
|
|
263
|
+
if (genericType === null) {
|
|
264
|
+
const args = extractCallArgs(node, programBody, variableCache);
|
|
265
|
+
context.report({
|
|
266
|
+
node: reportCallee,
|
|
267
|
+
messageId: 'missingTypeofImport',
|
|
268
|
+
fix(fixer) {
|
|
269
|
+
if (!args) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const examplePath = resolveExamplePathFromArgs(args.groupId, args.packageId, args.exampleId, filename);
|
|
273
|
+
if (!examplePath) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const importPath = computeRelativeImportPath(filename, examplePath);
|
|
277
|
+
const [start, end] = callee.property.range;
|
|
278
|
+
return fixer.insertTextAfterRange([start, end], `<typeof import('${importPath}')>`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Case 2: Generic is a type alias reference (`visitExample<Foo>`) ──
|
|
285
|
+
let importPath;
|
|
286
|
+
if (genericType.type === 'alias') {
|
|
287
|
+
const found = getTypeAliases().get(genericType.name);
|
|
288
|
+
if (!found) {
|
|
289
|
+
// Unknown type alias — not a typeof import
|
|
290
|
+
context.report({
|
|
291
|
+
node: reportCallee,
|
|
292
|
+
messageId: 'missingTypeofImport'
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (found.isFileLevel) {
|
|
297
|
+
// Top-level `type Foo = typeof import(...)` is disallowed
|
|
298
|
+
context.report({
|
|
299
|
+
node: reportCallee,
|
|
300
|
+
messageId: 'typeAliasNotInlined'
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const typeAnnotation = found.node.typeAnnotation;
|
|
305
|
+
if (typeAnnotation.type !== AST_NODE_TYPES.TSTypeQuery) {
|
|
306
|
+
context.report({
|
|
307
|
+
node: reportCallee,
|
|
308
|
+
messageId: 'missingTypeofImport'
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const resolved = extractImportPathFromTypeQuery(typeAnnotation);
|
|
313
|
+
if (!resolved) {
|
|
314
|
+
context.report({
|
|
315
|
+
node: reportCallee,
|
|
316
|
+
messageId: 'missingTypeofImport'
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
importPath = resolved;
|
|
321
|
+
} else {
|
|
322
|
+
// ── Case 3: Inline `typeof import('...')` ────────────────────────
|
|
323
|
+
importPath = genericType.importPath;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Package-scoped imports (e.g. @atlaskit/foo/examples/...) are not allowed
|
|
327
|
+
if (importPath.startsWith('@')) {
|
|
328
|
+
context.report({
|
|
329
|
+
node: reportCallee,
|
|
330
|
+
messageId: 'noPackageImports'
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Validate that the import path matches the arguments
|
|
336
|
+
const args = extractCallArgs(node, programBody, variableCache);
|
|
337
|
+
if (!args) {
|
|
338
|
+
// Dynamic arguments — can't validate statically
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const expectedPath = resolveExamplePathFromArgs(args.groupId, args.packageId, args.exampleId, filename);
|
|
342
|
+
if (!expectedPath) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const resolvedImport = path.normalize(path.resolve(path.dirname(filename), importPath));
|
|
346
|
+
const resolvedExpected = path.normalize(expectedPath);
|
|
347
|
+
|
|
348
|
+
// Compare without extensions so `.tsx` vs no extension doesn't matter
|
|
349
|
+
if (resolvedImport.replace(/\.(tsx?|jsx?)$/, '') !== resolvedExpected.replace(/\.(tsx?|jsx?)$/, '')) {
|
|
350
|
+
context.report({
|
|
351
|
+
node: estreeNode.arguments[0],
|
|
352
|
+
messageId: 'pathMismatch',
|
|
353
|
+
data: {
|
|
354
|
+
importPath,
|
|
355
|
+
groupId: args.groupId,
|
|
356
|
+
packageId: args.packageId,
|
|
357
|
+
exampleId: args.exampleId,
|
|
358
|
+
expectedPath: resolvedExpected
|
|
359
|
+
},
|
|
360
|
+
fix(fixer) {
|
|
361
|
+
var _node$typeArguments2;
|
|
362
|
+
const correctedPath = computeRelativeImportPath(filename, resolvedExpected);
|
|
363
|
+
const typeParams = (_node$typeArguments2 = node.typeArguments) !== null && _node$typeArguments2 !== void 0 ? _node$typeArguments2 : node.typeParameters;
|
|
364
|
+
if (!(typeParams !== null && typeParams !== void 0 && typeParams.range)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return fixer.replaceTextRange(typeParams.range, `<typeof import('${correctedPath}')>`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
export default rule;
|
package/dist/esm/index.js
CHANGED
|
@@ -32,11 +32,14 @@ import noSparseCheckout from './rules/no-sparse-checkout';
|
|
|
32
32
|
import noDirectDocumentUsage from './rules/no-direct-document-usage';
|
|
33
33
|
import noSetImmediate from './rules/no-set-immediate';
|
|
34
34
|
import preferCryptoRandomUuid from './rules/prefer-crypto-random-uuid';
|
|
35
|
+
import noRestrictedFedrampImports from './rules/no-restricted-fedramp-imports';
|
|
35
36
|
import noBarrelEntryImports from './rules/import/no-barrel-entry-imports';
|
|
36
37
|
import noBarrelEntryJestMock from './rules/import/no-barrel-entry-jest-mock';
|
|
37
38
|
import noJestMockBarrelFiles from './rules/import/no-jest-mock-barrel-files';
|
|
38
39
|
import noRelativeBarrelFileImports from './rules/import/no-relative-barrel-file-imports';
|
|
39
40
|
import noConversationAssistantBarrelImports from './rules/import/no-conversation-assistant-barrel-imports';
|
|
41
|
+
import visitExampleTypeImportRequired from './rules/visit-example-type-import-required';
|
|
42
|
+
import ensureUseSyncExternalStoreServerSnapshot from './rules/ensure-use-sync-external-store-server-snapshot';
|
|
40
43
|
import { join, normalize } from 'node:path';
|
|
41
44
|
import { readFileSync } from 'node:fs';
|
|
42
45
|
var jiraRoot;
|
|
@@ -86,15 +89,19 @@ var rules = {
|
|
|
86
89
|
'no-direct-document-usage': noDirectDocumentUsage,
|
|
87
90
|
'no-set-immediate': noSetImmediate,
|
|
88
91
|
'prefer-crypto-random-uuid': preferCryptoRandomUuid,
|
|
92
|
+
'no-restricted-fedramp-imports': noRestrictedFedrampImports,
|
|
89
93
|
'no-barrel-entry-imports': noBarrelEntryImports,
|
|
90
94
|
'no-barrel-entry-jest-mock': noBarrelEntryJestMock,
|
|
91
95
|
'no-jest-mock-barrel-files': noJestMockBarrelFiles,
|
|
92
96
|
'no-relative-barrel-file-imports': noRelativeBarrelFileImports,
|
|
93
|
-
'no-conversation-assistant-barrel-imports': noConversationAssistantBarrelImports
|
|
97
|
+
'no-conversation-assistant-barrel-imports': noConversationAssistantBarrelImports,
|
|
98
|
+
'visit-example-type-import-required': visitExampleTypeImportRequired,
|
|
99
|
+
'ensure-use-sync-external-store-server-snapshot': ensureUseSyncExternalStoreServerSnapshot
|
|
94
100
|
};
|
|
95
101
|
var commonConfig = {
|
|
96
102
|
'@atlaskit/platform/ensure-test-runner-arguments': 'error',
|
|
97
103
|
'@atlaskit/platform/ensure-test-runner-nested-count': 'warn',
|
|
104
|
+
'@atlaskit/platform/ensure-use-sync-external-store-server-snapshot': 'error',
|
|
98
105
|
'@atlaskit/platform/no-invalid-feature-flag-usage': 'error',
|
|
99
106
|
'@atlaskit/platform/no-invalid-storybook-decorator-usage': 'error',
|
|
100
107
|
'@atlaskit/platform/ensure-atlassian-team': 'error',
|