oxlint-plugin-react-doctor 0.2.0-beta.2 → 0.2.0-beta.3

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 (2) hide show
  1. package/dist/index.js +347 -9
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
1
3
  //#region src/plugin/constants/library.ts
2
4
  const HEAVY_LIBRARIES = new Set([
3
5
  "@monaco-editor/react",
@@ -47,13 +49,6 @@ const MUTATING_HTTP_METHODS = new Set([
47
49
  ]);
48
50
  //#endregion
49
51
  //#region src/plugin/constants/dom.ts
50
- const BARREL_INDEX_SUFFIXES = [
51
- "/index",
52
- "/index.js",
53
- "/index.ts",
54
- "/index.tsx",
55
- "/index.mjs"
56
- ];
57
52
  const PASSIVE_EVENT_NAMES = new Set([
58
53
  "scroll",
59
54
  "wheel",
@@ -2276,7 +2271,345 @@ const noArrayIndexAsKey = defineRule({
2276
2271
  } })
2277
2272
  });
2278
2273
  //#endregion
2274
+ //#region src/plugin/utils/create-relative-import-source.ts
2275
+ const createRelativeImportSource = (filename, targetFilePath) => {
2276
+ const targetPathWithoutExtension = targetFilePath.slice(0, targetFilePath.length - path.extname(targetFilePath).length);
2277
+ const targetModulePath = path.basename(targetPathWithoutExtension) === "index" ? path.dirname(targetPathWithoutExtension) : targetPathWithoutExtension;
2278
+ const relativePath = path.relative(path.dirname(filename), targetModulePath).split(path.sep).join("/");
2279
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
2280
+ };
2281
+ //#endregion
2282
+ //#region src/plugin/utils/parse-export-specifiers.ts
2283
+ const getSpecifierName = (rawName) => rawName.replace(/^type\s+/, "").trim();
2284
+ const parseExportSpecifiers = (specifiersText, declarationIsTypeOnly) => specifiersText.split(",").map((specifierText) => specifierText.trim()).filter(Boolean).map((specifierText) => {
2285
+ const isTypeOnly = declarationIsTypeOnly || specifierText.startsWith("type ");
2286
+ const [rawLocalName, rawExportedName] = specifierText.split(/\s+as\s+/);
2287
+ const localName = getSpecifierName(rawLocalName ?? "");
2288
+ return {
2289
+ localName,
2290
+ exportedName: getSpecifierName(rawExportedName ?? localName),
2291
+ isTypeOnly
2292
+ };
2293
+ });
2294
+ //#endregion
2295
+ //#region src/plugin/utils/strip-js-comments.ts
2296
+ const BLOCK_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
2297
+ const LINE_COMMENT_PATTERN = /^\s*\/\/.*$/gm;
2298
+ const stripJsComments = (sourceText) => sourceText.replace(BLOCK_COMMENT_PATTERN, "").replace(LINE_COMMENT_PATTERN, "");
2299
+ //#endregion
2300
+ //#region src/plugin/utils/is-barrel-index-module.ts
2301
+ const INDEX_MODULE_FILE_PATTERN = /^index\.(?:[cm]?[jt]sx?|mjs)$/;
2302
+ const BINDING_IMPORT_DECLARATION_PATTERN = /^\s*import\s+(type\s+)?(?!["'])([^;]*?)\s+from\s+["']([^"']+)["']\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
2303
+ const BARREL_REEXPORT_DECLARATION_PATTERN = /^\s*export\s+(type\s+)?(?:\*(?:\s+as\s+([\w$]+))?|\{([\s\S]*?)\})\s+from\s+["']([^"']+)["']\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
2304
+ const LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1 = /^\s*export\s+(type\s+)?\{([\s\S]*?)\}\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
2305
+ const barrelIndexModuleInfoCache = /* @__PURE__ */ new Map();
2306
+ const isIndexModuleFilePath = (filePath) => INDEX_MODULE_FILE_PATTERN.test(path.basename(filePath));
2307
+ const createNonBarrelInfo = () => ({
2308
+ isBarrel: false,
2309
+ exportsByName: /* @__PURE__ */ new Map(),
2310
+ starExportSources: []
2311
+ });
2312
+ const addImportedBinding = (importedBindings, binding) => {
2313
+ importedBindings.set(binding.localName, {
2314
+ ...binding,
2315
+ didExport: false
2316
+ });
2317
+ };
2318
+ const collectNamedImportBindings = (namedSpecifiersText, source, declarationIsTypeOnly, importedBindings) => {
2319
+ for (const specifier of parseExportSpecifiers(namedSpecifiersText, declarationIsTypeOnly)) addImportedBinding(importedBindings, {
2320
+ localName: specifier.exportedName,
2321
+ importedName: specifier.localName,
2322
+ source,
2323
+ isTypeOnly: specifier.isTypeOnly
2324
+ });
2325
+ };
2326
+ const collectImportBindings = (importClause, source, declarationIsTypeOnly, importedBindings) => {
2327
+ const trimmedImportClause = importClause.trim();
2328
+ const namespaceMatch = trimmedImportClause.match(/(?:^|,\s*)\*\s+as\s+([\w$]+)/);
2329
+ if (namespaceMatch?.[1]) addImportedBinding(importedBindings, {
2330
+ localName: namespaceMatch[1],
2331
+ importedName: "*",
2332
+ source,
2333
+ isTypeOnly: declarationIsTypeOnly
2334
+ });
2335
+ const namedImportMatch = trimmedImportClause.match(/\{([\s\S]*?)\}/);
2336
+ if (namedImportMatch?.[1]) collectNamedImportBindings(namedImportMatch[1], source, declarationIsTypeOnly, importedBindings);
2337
+ const defaultImportName = trimmedImportClause.split(",")[0]?.trim();
2338
+ if (defaultImportName && !defaultImportName.startsWith("{") && !defaultImportName.startsWith("*")) addImportedBinding(importedBindings, {
2339
+ localName: defaultImportName,
2340
+ importedName: "default",
2341
+ source,
2342
+ isTypeOnly: declarationIsTypeOnly
2343
+ });
2344
+ };
2345
+ const replaceKnownDeclarations = (sourceText, importedBindings, exportsByName, starExportSources) => {
2346
+ let withoutKnownDeclarations = sourceText.replace(BINDING_IMPORT_DECLARATION_PATTERN, (_match, typeKeyword, importClause, source) => {
2347
+ collectImportBindings(importClause, source, Boolean(typeKeyword), importedBindings);
2348
+ return "";
2349
+ });
2350
+ withoutKnownDeclarations = withoutKnownDeclarations.replace(BARREL_REEXPORT_DECLARATION_PATTERN, (_match, typeKeyword, namespaceExportName, specifiersText, source) => {
2351
+ const isTypeOnly = Boolean(typeKeyword);
2352
+ if (namespaceExportName) {
2353
+ exportsByName.set(namespaceExportName, {
2354
+ exportedName: namespaceExportName,
2355
+ importedName: "*",
2356
+ source,
2357
+ isTypeOnly
2358
+ });
2359
+ return "";
2360
+ }
2361
+ if (specifiersText) {
2362
+ for (const specifier of parseExportSpecifiers(specifiersText, isTypeOnly)) exportsByName.set(specifier.exportedName, {
2363
+ exportedName: specifier.exportedName,
2364
+ importedName: specifier.localName,
2365
+ source,
2366
+ isTypeOnly: specifier.isTypeOnly
2367
+ });
2368
+ return "";
2369
+ }
2370
+ starExportSources.push(source);
2371
+ return "";
2372
+ });
2373
+ withoutKnownDeclarations = withoutKnownDeclarations.replace(LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN$1, (_match, typeKeyword, specifiersText) => {
2374
+ for (const specifier of parseExportSpecifiers(specifiersText, Boolean(typeKeyword))) {
2375
+ const importedBinding = importedBindings.get(specifier.localName);
2376
+ if (!importedBinding) return _match;
2377
+ importedBinding.didExport = true;
2378
+ exportsByName.set(specifier.exportedName, {
2379
+ exportedName: specifier.exportedName,
2380
+ importedName: importedBinding.importedName,
2381
+ source: importedBinding.source,
2382
+ isTypeOnly: specifier.isTypeOnly || importedBinding.isTypeOnly
2383
+ });
2384
+ }
2385
+ return "";
2386
+ });
2387
+ return withoutKnownDeclarations;
2388
+ };
2389
+ const hasUnexportedRuntimeImport = (importedBindings) => {
2390
+ for (const binding of importedBindings.values()) if (!binding.isTypeOnly && !binding.didExport) return true;
2391
+ return false;
2392
+ };
2393
+ const classifyBarrelModule = (sourceText) => {
2394
+ const strippedSource = stripJsComments(sourceText).trim();
2395
+ if (!strippedSource) return createNonBarrelInfo();
2396
+ const importedBindings = /* @__PURE__ */ new Map();
2397
+ const exportsByName = /* @__PURE__ */ new Map();
2398
+ const starExportSources = [];
2399
+ if (replaceKnownDeclarations(strippedSource, importedBindings, exportsByName, starExportSources).trim() || hasUnexportedRuntimeImport(importedBindings)) return createNonBarrelInfo();
2400
+ return {
2401
+ isBarrel: exportsByName.size > 0 || starExportSources.length > 0,
2402
+ exportsByName,
2403
+ starExportSources
2404
+ };
2405
+ };
2406
+ const getBarrelIndexModuleInfo = (filePath) => {
2407
+ if (!isIndexModuleFilePath(filePath)) return createNonBarrelInfo();
2408
+ const cachedResult = barrelIndexModuleInfoCache.get(filePath);
2409
+ if (cachedResult !== void 0) return cachedResult;
2410
+ let moduleInfo = createNonBarrelInfo();
2411
+ try {
2412
+ moduleInfo = classifyBarrelModule(fs.readFileSync(filePath, "utf8"));
2413
+ } catch {
2414
+ moduleInfo = createNonBarrelInfo();
2415
+ }
2416
+ barrelIndexModuleInfoCache.set(filePath, moduleInfo);
2417
+ return moduleInfo;
2418
+ };
2419
+ const isBarrelIndexModule = (filePath) => getBarrelIndexModuleInfo(filePath).isBarrel;
2420
+ //#endregion
2421
+ //#region src/plugin/utils/does-module-export-name.ts
2422
+ const DEFAULT_EXPORT_DECLARATION_PATTERN = /^\s*export\s+default\b/m;
2423
+ const NAMED_EXPORT_DECLARATION_PATTERN = /^\s*export\s+(?:declare\s+)?(?:(?:async\s+)?function|(?:abstract\s+)?class|const|let|var|enum|interface|type)\s+([\w$]+)/gm;
2424
+ const LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN = /^\s*export\s+(?:type\s+)?\{([\s\S]*?)\}(?:\s+from\s+["'][^"']+["'])?\s*;?\s*(?:(?:\/\/[^\n]*)?\s*)/gm;
2425
+ const doesSourceTextExportName = (sourceText, exportedName) => {
2426
+ const strippedSource = stripJsComments(sourceText);
2427
+ if (exportedName === "default" && DEFAULT_EXPORT_DECLARATION_PATTERN.test(strippedSource)) return true;
2428
+ for (const match of strippedSource.matchAll(NAMED_EXPORT_DECLARATION_PATTERN)) if (match[1] === exportedName) return true;
2429
+ for (const match of strippedSource.matchAll(LOCAL_EXPORT_SPECIFIER_DECLARATION_PATTERN)) if (parseExportSpecifiers(match[1] ?? "", false).map((specifier) => specifier.exportedName).includes(exportedName)) return true;
2430
+ return false;
2431
+ };
2432
+ const doesModuleExportName = (filePath, exportedName) => {
2433
+ try {
2434
+ return doesSourceTextExportName(fs.readFileSync(filePath, "utf8"), exportedName);
2435
+ } catch {
2436
+ return false;
2437
+ }
2438
+ };
2439
+ //#endregion
2440
+ //#region src/plugin/utils/resolve-relative-import-path.ts
2441
+ const MODULE_FILE_EXTENSIONS = [
2442
+ ".ts",
2443
+ ".tsx",
2444
+ ".js",
2445
+ ".jsx",
2446
+ ".mjs",
2447
+ ".cjs",
2448
+ ".mts",
2449
+ ".cts"
2450
+ ];
2451
+ const PACKAGE_EXPORT_CONDITIONS = [
2452
+ "import",
2453
+ "default",
2454
+ "module",
2455
+ "browser",
2456
+ "require"
2457
+ ];
2458
+ const PACKAGE_ENTRY_FIELDS = [
2459
+ "module",
2460
+ "main",
2461
+ "browser"
2462
+ ];
2463
+ const getExistingFilePath = (filePath) => {
2464
+ try {
2465
+ return fs.statSync(filePath).isFile() ? filePath : null;
2466
+ } catch {
2467
+ return null;
2468
+ }
2469
+ };
2470
+ const getExistingDirectoryPath = (directoryPath) => {
2471
+ try {
2472
+ return fs.statSync(directoryPath).isDirectory() ? directoryPath : null;
2473
+ } catch {
2474
+ return null;
2475
+ }
2476
+ };
2477
+ const getModuleFilePathCandidates = (modulePath) => {
2478
+ const extension = path.extname(modulePath);
2479
+ if (!extension) return MODULE_FILE_EXTENSIONS.map((moduleExtension) => `${modulePath}${moduleExtension}`);
2480
+ const modulePathWithoutExtension = modulePath.slice(0, -extension.length);
2481
+ if (extension === ".js") return [
2482
+ modulePath,
2483
+ `${modulePathWithoutExtension}.ts`,
2484
+ `${modulePathWithoutExtension}.tsx`,
2485
+ `${modulePathWithoutExtension}.jsx`
2486
+ ];
2487
+ if (extension === ".jsx") return [modulePath, `${modulePathWithoutExtension}.tsx`];
2488
+ if (extension === ".mjs") return [modulePath, `${modulePathWithoutExtension}.mts`];
2489
+ if (extension === ".cjs") return [modulePath, `${modulePathWithoutExtension}.cts`];
2490
+ return [modulePath];
2491
+ };
2492
+ const isObjectRecord = (value) => typeof value === "object" && value !== null;
2493
+ const getConditionalExportEntry = (exportEntry) => {
2494
+ if (typeof exportEntry === "string") return exportEntry;
2495
+ if (Array.isArray(exportEntry)) {
2496
+ for (const fallbackEntry of exportEntry) {
2497
+ const resolvedFallbackEntry = getConditionalExportEntry(fallbackEntry);
2498
+ if (resolvedFallbackEntry) return resolvedFallbackEntry;
2499
+ }
2500
+ return null;
2501
+ }
2502
+ if (!isObjectRecord(exportEntry)) return null;
2503
+ for (const condition of PACKAGE_EXPORT_CONDITIONS) {
2504
+ const nestedEntry = getConditionalExportEntry(exportEntry[condition]);
2505
+ if (nestedEntry) return nestedEntry;
2506
+ }
2507
+ return null;
2508
+ };
2509
+ const getPackageExportEntry = (packageJson) => {
2510
+ const exportsField = packageJson.exports;
2511
+ if (!exportsField) return null;
2512
+ const directExportEntry = getConditionalExportEntry(exportsField);
2513
+ if (directExportEntry) return directExportEntry;
2514
+ if (!isObjectRecord(exportsField)) return null;
2515
+ return getConditionalExportEntry(exportsField["."]);
2516
+ };
2517
+ const resolveModulePathWithIndexFallback = (modulePath) => {
2518
+ const filePath = resolveModuleFilePath(modulePath);
2519
+ if (filePath) return filePath;
2520
+ return resolveModuleFilePath(path.join(modulePath, "index"));
2521
+ };
2522
+ const resolvePackageDirectoryEntry = (directoryPath) => {
2523
+ const existingDirectoryPath = getExistingDirectoryPath(directoryPath);
2524
+ if (!existingDirectoryPath) return null;
2525
+ const packageJsonPath = path.join(existingDirectoryPath, "package.json");
2526
+ try {
2527
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
2528
+ const packageEntry = getPackageExportEntry(packageJson) ?? PACKAGE_ENTRY_FIELDS.map((fieldName) => packageJson[fieldName]).find((value) => typeof value === "string");
2529
+ if (!packageEntry) return null;
2530
+ return resolveModulePathWithIndexFallback(path.resolve(existingDirectoryPath, packageEntry));
2531
+ } catch {
2532
+ return null;
2533
+ }
2534
+ };
2535
+ const resolveModuleFilePath = (modulePath) => {
2536
+ const exactFilePath = getExistingFilePath(modulePath);
2537
+ if (exactFilePath) return exactFilePath;
2538
+ for (const candidateFilePath of getModuleFilePathCandidates(modulePath)) {
2539
+ const filePath = getExistingFilePath(candidateFilePath);
2540
+ if (filePath) return filePath;
2541
+ }
2542
+ return null;
2543
+ };
2544
+ const resolveRelativeImportPath = (filename, source) => {
2545
+ const importPath = path.resolve(path.dirname(filename), source);
2546
+ const directFilePath = resolveModuleFilePath(importPath);
2547
+ if (directFilePath) return directFilePath;
2548
+ const packageEntryFilePath = resolvePackageDirectoryEntry(importPath);
2549
+ if (packageEntryFilePath) return packageEntryFilePath;
2550
+ return resolveModuleFilePath(path.join(importPath, "index"));
2551
+ };
2552
+ //#endregion
2553
+ //#region src/plugin/utils/resolve-barrel-export-file-path.ts
2554
+ const getUniqueFilePath = (filePaths) => {
2555
+ const uniqueFilePaths = new Set(filePaths);
2556
+ if (uniqueFilePaths.size !== 1) return null;
2557
+ const [filePath] = uniqueFilePaths;
2558
+ return filePath ?? null;
2559
+ };
2560
+ const resolveStarExportFilePath = (barrelFilePath, exportedName, source, visitedFilePaths) => {
2561
+ const resolvedTargetPath = resolveRelativeImportPath(barrelFilePath, source);
2562
+ if (!resolvedTargetPath) return null;
2563
+ const nestedTargetPath = resolveBarrelExportFilePath(resolvedTargetPath, exportedName, new Set(visitedFilePaths));
2564
+ if (nestedTargetPath) return nestedTargetPath;
2565
+ return doesModuleExportName(resolvedTargetPath, exportedName) ? resolvedTargetPath : null;
2566
+ };
2567
+ const resolveBarrelExportFilePath = (barrelFilePath, exportedName, visitedFilePaths = /* @__PURE__ */ new Set()) => {
2568
+ if (visitedFilePaths.has(barrelFilePath)) return null;
2569
+ visitedFilePaths.add(barrelFilePath);
2570
+ const moduleInfo = getBarrelIndexModuleInfo(barrelFilePath);
2571
+ if (!moduleInfo.isBarrel) return null;
2572
+ const target = moduleInfo.exportsByName.get(exportedName);
2573
+ if (target) {
2574
+ const resolvedTargetPath = resolveRelativeImportPath(barrelFilePath, target.source);
2575
+ if (!resolvedTargetPath) return null;
2576
+ return resolveBarrelExportFilePath(resolvedTargetPath, target.importedName, visitedFilePaths) ?? resolvedTargetPath;
2577
+ }
2578
+ if (exportedName === "default") return null;
2579
+ return getUniqueFilePath(moduleInfo.starExportSources.map((source) => resolveStarExportFilePath(barrelFilePath, exportedName, source, visitedFilePaths)).filter((filePath) => Boolean(filePath)));
2580
+ };
2581
+ //#endregion
2279
2582
  //#region src/plugin/rules/bundle-size/no-barrel-import.ts
2583
+ const getLiteralName = (node) => {
2584
+ if (node.type === "Identifier" && typeof node.name === "string") return node.name;
2585
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
2586
+ return null;
2587
+ };
2588
+ const getRuntimeImportRequests = (node) => {
2589
+ if (node.importKind === "type") return [];
2590
+ return node.specifiers.flatMap((specifier) => {
2591
+ if (specifier.type === "ImportSpecifier") {
2592
+ if (specifier.importKind === "type") return [];
2593
+ return [{ importedName: getLiteralName(specifier.imported) }];
2594
+ }
2595
+ if (specifier.type === "ImportDefaultSpecifier") return [{ importedName: "default" }];
2596
+ return [{ importedName: null }];
2597
+ });
2598
+ };
2599
+ const buildReportMessage = (filename, barrelFilePath, importRequests) => {
2600
+ const directImportSources = /* @__PURE__ */ new Set();
2601
+ for (const request of importRequests) {
2602
+ if (!request.importedName) continue;
2603
+ const directFilePath = resolveBarrelExportFilePath(barrelFilePath, request.importedName);
2604
+ if (directFilePath) directImportSources.add(createRelativeImportSource(filename, directFilePath));
2605
+ }
2606
+ if (directImportSources.size === 1) {
2607
+ const [directImportSource] = directImportSources;
2608
+ return `Import from barrel/index file — import directly from "${directImportSource}" for better tree-shaking`;
2609
+ }
2610
+ if (directImportSources.size > 1) return `Import from barrel/index file — import directly from source modules: ${[...directImportSources].map((source) => `"${source}"`).join(", ")}`;
2611
+ return "Import from barrel/index file — import directly from the source module for better tree-shaking";
2612
+ };
2280
2613
  const noBarrelImport = defineRule({
2281
2614
  id: "no-barrel-import",
2282
2615
  severity: "warn",
@@ -2287,11 +2620,16 @@ const noBarrelImport = defineRule({
2287
2620
  if (didReportForFile) return;
2288
2621
  const source = node.source?.value;
2289
2622
  if (typeof source !== "string" || !source.startsWith(".")) return;
2290
- if (BARREL_INDEX_SUFFIXES.some((suffix) => source.endsWith(suffix))) {
2623
+ const filename = context.getFilename?.() ?? "";
2624
+ if (!filename) return;
2625
+ const importRequests = getRuntimeImportRequests(node);
2626
+ if (importRequests.length === 0) return;
2627
+ const resolvedImportPath = resolveRelativeImportPath(filename, source);
2628
+ if (resolvedImportPath && isBarrelIndexModule(resolvedImportPath)) {
2291
2629
  didReportForFile = true;
2292
2630
  context.report({
2293
2631
  node,
2294
- message: "Import from barrel/index file — import directly from the source module for better tree-shaking"
2632
+ message: buildReportMessage(filename, resolvedImportPath, importRequests)
2295
2633
  });
2296
2634
  }
2297
2635
  } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-react-doctor",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "description": "oxlint plugin for React Doctor: diagnose React codebases for security, performance, correctness, accessibility, bundle-size, and architecture issues",
5
5
  "keywords": [
6
6
  "accessibility",