@adamhl8/eslint-plugin-clean-modules
v0.1.5
Published
oxlint/ESLint plugin: require subpath imports, direct exports, and explicit import extensions
Maintainers
Readme
@adamhl8/eslint-plugin-clean-modules
An oxlint (ESLint-compatible) plugin with three rules for keeping module imports and exports clean in TypeScript projects:
require-subpath-imports-> ban relative imports in favor of native Node.js subpath importsrequire-direct-exports-> requireexporton the declaration instead of a separateexport { ... }require-import-extensions-> require the correct explicit file extension on local imports
All three rules are auto-fixable.
Installation
bun add -D @adamhl8/eslint-plugin-clean-modulesThis plugin targets oxlint's JavaScript plugins (currently in alpha). It's authored against the ESLint v9 plugin API, so it also works as a regular ESLint plugin.
Usage
Add the plugin to jsPlugins and enable the rules in your .oxlintrc.json. The rule prefix is clean-modules:
{
"jsPlugins": ["@adamhl8/eslint-plugin-clean-modules"],
"rules": {
"clean-modules/require-subpath-imports": "error",
"clean-modules/require-direct-exports": "error",
"clean-modules/require-import-extensions": "error"
}
}Run oxlint --fix to apply fixes. Because require-subpath-imports and require-import-extensions can both rewrite the same import (./foo -> #foo -> #foo.ts), full convergence may take more than one --fix pass.
Rules
require-subpath-imports
Relative imports (., .., ./, ../) are banned. Imports of modules within the same package should use a native Node.js subpath import, defined by the imports field in package.json.
The fix resolves the relative import to the matching # subpath. A directory import resolves to the directory's index file (index.ts, index.tsx, or index.d.ts), since ESM has no directory resolution. If no subpath entry matches, the import is reported without a fix.
// package.json
{ "imports": { "#*": "./src/*" } }import { foo } from "./foo" // error
import { foo } from "#foo" // autofixed
import { foo } from "." // error (directory import)
import { foo } from "#index.ts" // autofixedApplies to import, dynamic import(), export ... from, and export *.
Options
ignore: string[]- skip any import whose specifier contains one of these substrings (useful for cross-package relative imports that legitimately can't map to a subpath).
{ "clean-modules/require-subpath-imports": ["error", { "ignore": ["@generated"] }] }require-direct-exports
Exports should be declared directly on the declaration. Indirect exports (export { foo }, export { foo as bar }) and re-exports (export { x } from "...", export * from "...") are banned. This is the inverse of import/no-named-export.
index.* files are exempt, since barrel re-exports legitimately live there.
The fix only handles the safe case: a local, non-renamed export { foo } where foo is a top-level declaration in the same file. It prepends export to the declaration and removes the statement. Renamed exports, re-exports, and multi-declarator cases are reported without a fix.
const foo = 1
export { foo } // error
export const foo = 1 // autofixedOptions
allowEmpty: boolean(defaultfalse) - allow the emptyexport {}module marker.
require-import-extensions
Local imports (relative and # subpath) must use the correct file extension as it exists on disk (.ts, .tsx, or .d.ts). Extensions are resolved by checking the filesystem, following TypeScript's extension substitution. Non-TypeScript extensions (.json, .css, ...) pass through unchanged. A directory import is rewritten to its explicit index file (e.g. ./dir -> ./dir/index.ts); a directory with no index.{ts,tsx,d.ts} is reported without a fix. If the target file can't be found, the import is reported without a fix.
import { foo } from "./foo" // error (foo.ts on disk)
import { foo } from "./foo.ts" // autofixed