@atlaskit/eslint-plugin-platform 2.8.0 → 2.9.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cjs/index.js +6 -1
  3. package/dist/cjs/rules/ensure-use-sync-external-store-server-snapshot/index.js +41 -0
  4. package/dist/cjs/rules/import/no-barrel-entry-imports/index.js +475 -67
  5. package/dist/cjs/rules/import/no-barrel-entry-jest-mock/index.js +387 -112
  6. package/dist/cjs/rules/import/no-jest-mock-barrel-files/index.js +3 -2
  7. package/dist/cjs/rules/import/no-relative-barrel-file-imports/index.js +7 -3
  8. package/dist/cjs/rules/import/shared/jest-utils.js +62 -9
  9. package/dist/cjs/rules/import/shared/package-resolution.js +156 -23
  10. package/dist/cjs/rules/visit-example-type-import-required/index.js +409 -0
  11. package/dist/es2019/index.js +6 -1
  12. package/dist/es2019/rules/ensure-use-sync-external-store-server-snapshot/index.js +43 -0
  13. package/dist/es2019/rules/import/no-barrel-entry-imports/index.js +372 -15
  14. package/dist/es2019/rules/import/no-barrel-entry-jest-mock/index.js +245 -17
  15. package/dist/es2019/rules/import/no-jest-mock-barrel-files/index.js +3 -2
  16. package/dist/es2019/rules/import/no-relative-barrel-file-imports/index.js +7 -3
  17. package/dist/es2019/rules/import/shared/jest-utils.js +44 -0
  18. package/dist/es2019/rules/import/shared/package-resolution.js +97 -5
  19. package/dist/es2019/rules/visit-example-type-import-required/index.js +375 -0
  20. package/dist/esm/index.js +6 -1
  21. package/dist/esm/rules/ensure-use-sync-external-store-server-snapshot/index.js +35 -0
  22. package/dist/esm/rules/import/no-barrel-entry-imports/index.js +475 -67
  23. package/dist/esm/rules/import/no-barrel-entry-jest-mock/index.js +388 -113
  24. package/dist/esm/rules/import/no-jest-mock-barrel-files/index.js +3 -2
  25. package/dist/esm/rules/import/no-relative-barrel-file-imports/index.js +7 -3
  26. package/dist/esm/rules/import/shared/jest-utils.js +61 -9
  27. package/dist/esm/rules/import/shared/package-resolution.js +156 -25
  28. package/dist/esm/rules/visit-example-type-import-required/index.js +402 -0
  29. package/dist/types/index.d.ts +12 -0
  30. package/dist/types/rules/ensure-use-sync-external-store-server-snapshot/index.d.ts +3 -0
  31. package/dist/types/rules/import/shared/jest-utils.d.ts +8 -0
  32. package/dist/types/rules/import/shared/package-resolution.d.ts +22 -2
  33. package/dist/types/rules/visit-example-type-import-required/index.d.ts +4 -0
  34. package/dist/types-ts4.5/index.d.ts +12 -0
  35. package/dist/types-ts4.5/rules/ensure-use-sync-external-store-server-snapshot/index.d.ts +3 -0
  36. package/dist/types-ts4.5/rules/import/shared/jest-utils.d.ts +8 -0
  37. package/dist/types-ts4.5/rules/import/shared/package-resolution.d.ts +22 -2
  38. package/dist/types-ts4.5/rules/visit-example-type-import-required/index.d.ts +4 -0
  39. package/package.json +3 -1
@@ -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');
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
@@ -37,6 +37,8 @@ import noBarrelEntryJestMock from './rules/import/no-barrel-entry-jest-mock';
37
37
  import noJestMockBarrelFiles from './rules/import/no-jest-mock-barrel-files';
38
38
  import noRelativeBarrelFileImports from './rules/import/no-relative-barrel-file-imports';
39
39
  import noConversationAssistantBarrelImports from './rules/import/no-conversation-assistant-barrel-imports';
40
+ import visitExampleTypeImportRequired from './rules/visit-example-type-import-required';
41
+ import ensureUseSyncExternalStoreServerSnapshot from './rules/ensure-use-sync-external-store-server-snapshot';
40
42
  import { join, normalize } from 'node:path';
41
43
  import { readFileSync } from 'node:fs';
42
44
  var jiraRoot;
@@ -90,11 +92,14 @@ var rules = {
90
92
  'no-barrel-entry-jest-mock': noBarrelEntryJestMock,
91
93
  'no-jest-mock-barrel-files': noJestMockBarrelFiles,
92
94
  'no-relative-barrel-file-imports': noRelativeBarrelFileImports,
93
- 'no-conversation-assistant-barrel-imports': noConversationAssistantBarrelImports
95
+ 'no-conversation-assistant-barrel-imports': noConversationAssistantBarrelImports,
96
+ 'visit-example-type-import-required': visitExampleTypeImportRequired,
97
+ 'ensure-use-sync-external-store-server-snapshot': ensureUseSyncExternalStoreServerSnapshot
94
98
  };
95
99
  var commonConfig = {
96
100
  '@atlaskit/platform/ensure-test-runner-arguments': 'error',
97
101
  '@atlaskit/platform/ensure-test-runner-nested-count': 'warn',
102
+ '@atlaskit/platform/ensure-use-sync-external-store-server-snapshot': 'error',
98
103
  '@atlaskit/platform/no-invalid-feature-flag-usage': 'error',
99
104
  '@atlaskit/platform/no-invalid-storybook-decorator-usage': 'error',
100
105
  '@atlaskit/platform/ensure-atlassian-team': 'error',
@@ -0,0 +1,35 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+
3
+ var FUNCTION_NAME = 'useSyncExternalStore';
4
+ var rule = {
5
+ meta: {
6
+ type: 'problem',
7
+ docs: {
8
+ description: "Enforce that ".concat(FUNCTION_NAME, " is called with a third argument (getServerSnapshot) for SSR compatibility"),
9
+ recommended: true
10
+ },
11
+ messages: {
12
+ missingServerSnapshot: "'".concat(FUNCTION_NAME, "' must be called with a third argument (getServerSnapshot). Without it, React will throw during server-side rendering.\n\nIf your component relies on browser-only APIs (e.g. localStorage, WebRTC, WebGL) and must not render on the server, pass `() => null` (or another stable fallback) as the third argument \u2014 this is the correct way to opt out of SSR, not an omission.\n\nPrefer higher-level APIs that wrap ").concat(FUNCTION_NAME, " where available, as they handle SSR concerns for you.\n\nSee the React docs for usage guidance: https://react.dev/reference/react/useSyncExternalStore")
13
+ }
14
+ },
15
+ create: function create(context) {
16
+ return {
17
+ CallExpression: function CallExpression(node) {
18
+ var callee = node.callee,
19
+ args = node.arguments;
20
+ var isDirectCall = callee.type === 'Identifier' && callee.name === FUNCTION_NAME;
21
+ var isMemberCall = callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === FUNCTION_NAME;
22
+ if (!isDirectCall && !isMemberCall) {
23
+ return;
24
+ }
25
+ if (args.length < 3) {
26
+ context.report({
27
+ node: node,
28
+ messageId: 'missingServerSnapshot'
29
+ });
30
+ }
31
+ }
32
+ };
33
+ }
34
+ };
35
+ export default rule;