Skip to content

Commit ebb4389

Browse files
dx: Lint rule for imports (#2345)
1 parent 547153b commit ebb4389

20 files changed

Lines changed: 241 additions & 21 deletions

oxlint.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import eslintPlugin from 'eslint-plugin-eslint-plugin';
44

55
export default defineConfig({
66
plugins: ['eslint', 'typescript', 'import', 'unicorn', 'oxc'],
7-
jsPlugins: ['eslint-plugin-typegpu', 'eslint-plugin-eslint-plugin'],
7+
jsPlugins: ['eslint-plugin-typegpu', 'eslint-plugin-eslint-plugin', 'eslint-plugin-internal'],
88
categories: {
99
correctness: 'warn',
1010
suspicious: 'warn',
@@ -21,6 +21,7 @@ export default defineConfig({
2121
'eslint-plugin-import/no-named-as-default': 'off',
2222
'eslint-plugin-import/no-named-as-default-member': 'off',
2323
'eslint-plugin-import/extensions': ['error', 'always', { ignorePackages: true }],
24+
'eslint-plugin-internal/no-useless-path-segments': 'error',
2425
},
2526
ignorePatterns: ['**/*.astro', '**/*.mjs'],
2627
overrides: [

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"bun": "catalog:",
4545
"dpdm": "^3.14.0",
4646
"eslint-plugin-eslint-plugin": "^7.3.2",
47+
"eslint-plugin-internal": "workspace:*",
4748
"eslint-plugin-typegpu": "workspace:*",
4849
"jiti": "catalog:build",
4950
"oxfmt": "^0.35.0",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div align="center">
2+
3+
# eslint-plugin-internal
4+
5+
Internal ESLint rules used by this repository.
6+
7+
</div>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "eslint-plugin-internal",
3+
"version": "0.11.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"main": "./src/index.ts",
8+
"scripts": {
9+
"test:types": "pnpm tsc --p ./tsconfig.json --noEmit",
10+
"test": "vitest"
11+
},
12+
"dependencies": {
13+
"@typescript-eslint/utils": "^8.57.2"
14+
},
15+
"devDependencies": {
16+
"@types/node": "catalog:types",
17+
"@typescript-eslint/rule-tester": "^8.57.2",
18+
"eslint": "^9.39.2",
19+
"typescript": "catalog:types",
20+
"vitest": "catalog:test"
21+
},
22+
"peerDependencies": {
23+
"eslint": "^9.0.0"
24+
}
25+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import pkg from '../package.json' with { type: 'json' };
2+
import type { TSESLint } from '@typescript-eslint/utils';
3+
import { noUselessPathSegments } from './rules/noUselessPathSegments.ts';
4+
5+
const plugin = {
6+
meta: {
7+
name: pkg.name,
8+
version: pkg.version,
9+
},
10+
rules: {
11+
'no-useless-path-segments': noUselessPathSegments,
12+
},
13+
} satisfies TSESLint.FlatConfig.Plugin;
14+
15+
export default plugin;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ESLintUtils } from '@typescript-eslint/utils';
2+
3+
export const createRule = ESLintUtils.RuleCreator(
4+
() => `https://docs.swmansion.com/TypeGPU/getting-started/`,
5+
);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createRule } from '../ruleCreator.ts';
2+
import * as path from 'node:path';
3+
4+
export const noUselessPathSegments = createRule({
5+
name: 'no-useless-path-segments',
6+
meta: {
7+
type: 'suggestion',
8+
fixable: 'code',
9+
docs: {
10+
description: 'Disallow redundant parent folder lookups in relative import paths',
11+
},
12+
messages: {
13+
redundant: "Import path '{{path}}' can be simplified to '{{simplified}}'",
14+
},
15+
schema: [],
16+
},
17+
defaultOptions: [],
18+
19+
create(context) {
20+
return {
21+
ImportDeclaration(node) {
22+
const importPath = node.source.value;
23+
if (!importPath.startsWith('.')) {
24+
return;
25+
}
26+
27+
const filename = context.filename; // e.g. `/Users/me/typegpu-monorepo/packages/typegpu/tests/buffer.test.ts`
28+
const dir = path.dirname(filename); // e.g. `/Users/me/typegpu-monorepo/packages/typegpu/tests`
29+
const resolved = path.resolve(dir, importPath); // e.g. `/Users/me/typegpu-monorepo/packages/typegpu/src/data/index.ts`
30+
let simplified = path
31+
.relative(dir, resolved) // e.g. `../src/data/index.ts`, or `subfolder/helper.ts`
32+
.replaceAll('\\', '/'); // Windows compatibility
33+
34+
if (!simplified.startsWith('..')) {
35+
simplified = `./${simplified}`;
36+
}
37+
38+
if (importPath !== simplified) {
39+
context.report({
40+
node,
41+
messageId: 'redundant',
42+
data: { path: importPath, simplified },
43+
fix(fixer) {
44+
const quote = context.sourceCode.getText(node.source)[0];
45+
return fixer.replaceText(node.source, `${quote}${simplified}${quote}`);
46+
},
47+
});
48+
}
49+
},
50+
};
51+
},
52+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe } from 'vitest';
2+
import { ruleTester } from '../utils/ruleTester.ts';
3+
import { noUselessPathSegments } from '../../src/rules/noUselessPathSegments.ts';
4+
import path from 'path';
5+
6+
const filename = path.join(process.cwd(), 'packages', 'typegpu', 'tests', 'buffer.test.ts');
7+
8+
describe('noUselessPathSegments', () => {
9+
ruleTester.run('noUselessPathSegments', noUselessPathSegments, {
10+
valid: [
11+
{ code: "import item from './file.ts';", filename },
12+
{ code: 'import item from "./file.ts";', filename },
13+
{ code: "import item from '../file.ts';", filename },
14+
{ code: "import item from '../../file.ts';", filename },
15+
{ code: "import item from '../folder/file.ts';", filename },
16+
17+
{ code: "import item from 'eslint-plugin-typegpu';", filename },
18+
{ code: "import item from '@eslint-plugin/typegpu';", filename },
19+
],
20+
invalid: [
21+
{
22+
code: "import item from '../tests/file.ts';",
23+
filename,
24+
errors: [
25+
{
26+
messageId: 'redundant',
27+
data: { path: '../tests/file.ts', simplified: './file.ts' },
28+
},
29+
],
30+
output: "import item from './file.ts';",
31+
},
32+
{
33+
code: 'import item from "../tests/file.ts";',
34+
filename,
35+
errors: [
36+
{
37+
messageId: 'redundant',
38+
data: { path: '../tests/file.ts', simplified: './file.ts' },
39+
},
40+
],
41+
output: 'import item from "./file.ts";',
42+
},
43+
{
44+
code: "import item from './../file.ts';",
45+
filename,
46+
errors: [
47+
{
48+
messageId: 'redundant',
49+
data: { path: './../file.ts', simplified: '../file.ts' },
50+
},
51+
],
52+
output: "import item from '../file.ts';",
53+
},
54+
{
55+
code: "import item from '../../typegpu/folder/file.ts';",
56+
filename,
57+
errors: [
58+
{
59+
messageId: 'redundant',
60+
data: { path: '../../typegpu/folder/file.ts', simplified: '../folder/file.ts' },
61+
},
62+
],
63+
output: "import item from '../folder/file.ts';",
64+
},
65+
],
66+
});
67+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { RuleTester } from '@typescript-eslint/rule-tester';
2+
import { afterAll, describe, it } from 'vitest';
3+
4+
// RuleTester relies on global hooks for tests.
5+
// Vitest doesn't make the hooks available globally, so we need to bind them.
6+
RuleTester.describe = describe;
7+
RuleTester.it = it;
8+
RuleTester.afterAll = afterAll;
9+
10+
export const ruleTester = new RuleTester();
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["src/**/*", "tests/**/*"],
7+
"exclude": ["node_modules"]
8+
}

0 commit comments

Comments
 (0)