Skip to content

Commit 06e8add

Browse files
authored
ESLint: Add custom rule to detect cross package imports (#17029)
This has come up a few times recently, where despite our best efforts to configure VSCode quick fixes, it can still end up adding cross package relative imports, which cause build and/or runtime errors that are hard to understand and diagnose. This change introduces a new custom ESLint plugin (thanks Claude!) that detects when this happens and uses the path mappings from our tsconfig.json to suggest a corrected import: <img width="818" height="130" alt="image" src="https://github.com/user-attachments/assets/b1db607e-4f50-4e43-94f2-3471e6a558eb" /> <img width="745" height="304" alt="image" src="https://github.com/user-attachments/assets/7ace3e76-479f-46e1-93e4-7d463a852d3f" /> <img width="436" height="93" alt="image" src="https://github.com/user-attachments/assets/637cfae9-e7cc-4c2d-9740-21aa43b57a75" />
1 parent 45b1ab5 commit 06e8add

File tree

2 files changed

+108
-0
lines changed

2 files changed

+108
-0
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ const rules = {
600600
"no-async-promise-executor": "warn",
601601
"no-throw-literal": "error",
602602
curly: "error",
603+
"babylonjs/no-cross-package-relative-imports": "error",
603604
},
604605
};
605606

packages/tools/eslintBabylonPlugin/src/index.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { TSDocConfiguration, TSDocParser, TextRange } from "@microsoft/tsdoc";
66
import * as tsdoc from "@microsoft/tsdoc";
77
import type { TSDocConfigFile } from "@microsoft/tsdoc-config";
88
import * as ts from "typescript";
9+
import * as fs from "fs";
10+
import * as path from "path";
911

1012
// import { Debug } from "./Debug";
1113
import { ConfigCache } from "./ConfigCache";
@@ -141,6 +143,68 @@ function walkCompilerAstAndFindComments(node: ts.Node, indent: string, notFoundC
141143
return node.forEachChild((child) => walkCompilerAstAndFindComments(child, indent + " ", notFoundComments, sourceText, getterSetterFound));
142144
}
143145

146+
type TsConfig = {
147+
compilerOptions: {
148+
baseUrl: string;
149+
paths: Record<string, string[]>;
150+
};
151+
};
152+
153+
let tsConfig: TsConfig | null = null;
154+
function loadTsConfig(projectRoot: string): TsConfig | null {
155+
if (tsConfig) {
156+
return tsConfig;
157+
}
158+
159+
try {
160+
const tsconfigPath = path.join(projectRoot, "tsconfig.json");
161+
const tsconfigContent = fs.readFileSync(tsconfigPath, "utf8");
162+
// Remove comments and parse JSON
163+
const cleanJson = tsconfigContent.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "");
164+
tsConfig = JSON.parse(cleanJson);
165+
} catch (error) {
166+
// eslint-disable-next-line no-console
167+
console.warn(`BabylonJS custom eslint plugin failed to load tsconfig.json: ${error.message}`);
168+
}
169+
170+
return tsConfig;
171+
}
172+
173+
function shouldUsePathMapping(importPath: string, filename: string, tsConfig: TsConfig) {
174+
if (!importPath.startsWith("../") || !tsConfig?.compilerOptions?.paths) {
175+
return null;
176+
}
177+
178+
const { baseUrl = ".", paths } = tsConfig.compilerOptions;
179+
const projectRoot = path.dirname(path.join(path.dirname(filename), "..", "..", ".."));
180+
181+
// Resolve the relative import to an absolute path
182+
const resolvedImportPath = path.resolve(path.dirname(filename), importPath);
183+
184+
// Check if this resolved path matches any of the path mappings
185+
for (const [pathKey, pathValues] of Object.entries(paths)) {
186+
for (const pathValue of pathValues) {
187+
// Convert tsconfig path to absolute path
188+
const absolutePathPattern = path.resolve(projectRoot, baseUrl, pathValue);
189+
190+
// Check if the resolved import matches this path mapping
191+
if (resolvedImportPath.startsWith(absolutePathPattern.replace("*", ""))) {
192+
// Calculate what the import should be
193+
const relativePart = path.relative(absolutePathPattern.replace("*", ""), resolvedImportPath);
194+
195+
const suggestedImport = pathKey
196+
.replace("*", relativePart)
197+
.replace(/\\/g, "/") // Normalize to forward slashes
198+
.replace(/\.(ts|tsx)$/, ""); // Remove extension
199+
200+
return suggestedImport;
201+
}
202+
}
203+
}
204+
205+
return null;
206+
}
207+
144208
const plugin: IPlugin = {
145209
rules: {
146210
// NOTE: The actual ESLint rule name will be "tsdoc/syntax". It is calculated by deleting "eslint-plugin-"
@@ -388,6 +452,49 @@ const plugin: IPlugin = {
388452
};
389453
},
390454
},
455+
"no-cross-package-relative-imports": {
456+
meta: {
457+
type: "problem",
458+
docs: {
459+
description: "Prevent relative imports that should use TypeScript path mappings",
460+
},
461+
fixable: "code",
462+
messages: {
463+
usePathMapping: 'Use path mapping "{{suggestion}}" instead of relative import "{{importPath}}".',
464+
},
465+
},
466+
create(context) {
467+
return {
468+
Program() {
469+
// Load tsconfig once per file
470+
const filename = context.filename;
471+
const projectRoot = filename.split("packages")[0];
472+
tsConfig = loadTsConfig(projectRoot);
473+
},
474+
475+
ImportDeclaration(node) {
476+
const importPath = node.source.value as string;
477+
const filename = context.filename;
478+
479+
const suggestion = shouldUsePathMapping(importPath, filename, tsConfig!);
480+
481+
if (suggestion) {
482+
context.report({
483+
node,
484+
messageId: "usePathMapping",
485+
data: {
486+
importPath,
487+
suggestion,
488+
},
489+
fix(fixer) {
490+
return fixer.replaceText(node.source, `"${suggestion}"`);
491+
},
492+
});
493+
}
494+
},
495+
};
496+
},
497+
},
391498
},
392499
};
393500

0 commit comments

Comments
 (0)