Skip to content

fix: forbid relative .js imports in TS sources#1953

Open
tabcat wants to merge 10 commits into
ipfs:mainfrom
tabcat:lint-relative-js-imports
Open

fix: forbid relative .js imports in TS sources#1953
tabcat wants to merge 10 commits into
ipfs:mainfrom
tabcat:lint-relative-js-imports

Conversation

@tabcat

@tabcat tabcat commented May 10, 2026

Copy link
Copy Markdown

Summary

Since the node test target skips the build step (#1945, #1950) and runs .ts source directly via --experimental-strip-types, any relative .js import inside a .ts file fails at test-time — Node resolves the path literally and there's no .js file on disk.

This rule catches that at lint time.

Rule

addRule('neostandard/ts', 'no-restricted-syntax', ['error', {
  selector: 'ImportDeclaration[source.value=/^\\.\\.?\\/.*\\.js$/]',
  message: "Relative imports must use the '.ts' extension."
}])

The selector matches only relative paths (./ or ../) ending in .js:

Import Flagged?
from '@noble/curves/ed25519.js' no — bare module
from './foo.ts' no — already .ts
from '../foo.js' yes
import './foo.js' (side-effect) yes

Fixture

test/fixtures/js+ts/src/another.ts was importing ./typed.js (legacy convention from when tsc didn't rewrite specifiers). Updated to ./typed.ts. The fixture's purpose — exercising mixed-language linting — is preserved by the project still containing typed.ts, another.ts, and some.js.

Breaking change

Any .ts source with a relative .js import will fail lint after this lands. Per-line // eslint-disable-next-line no-restricted-syntax is the escape hatch for deliberate cases.

tabcat and others added 2 commits May 10, 2026 19:04
Now that the `node` test target skips the build step (ipfs#1945, ipfs#1950) and
runs `.ts` source directly via `--experimental-strip-types`, any relative
`.js` import inside a `.ts` file fails at test-time because Node resolves
the path literally.

The new rule catches this at lint time. The selector matches only
relative paths (`./` or `../`) ending in `.js`; bare-module imports like
`@noble/curves/ed25519.js` are unaffected.

BREAKING CHANGE: TS sources may not import relative `.js` paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legacy `.js` import was a workaround for tsc not rewriting import
specifiers. With the new lint rule that intent is no longer needed and
the fixture's purpose (mixing .ts and .js files in one project) is
preserved by the surrounding files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tabcat tabcat marked this pull request as draft May 10, 2026 12:42
@tabcat tabcat marked this pull request as ready for review May 10, 2026 12:44
Comment thread test/fixtures/js+ts/src/another.ts

@achingbrain achingbrain left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was looking for something like this, but as configured it doesn't allow importing .js files from .ts files at all - it should be allowed if they exist.

@tabcat

tabcat commented May 11, 2026

Copy link
Copy Markdown
Author

I'll check if there is a way to do this, my thought was just to add an ignore comment in those cases but thats not the best.

tabcat added 2 commits May 11, 2026 17:11
The AST-only no-restricted-syntax check rejected every relative `.js`
import; that's wrong for mixed-language projects where the `.js` file
genuinely exists. The new aegir/no-legacy-js-import rule resolves the
import target on disk and only flags when the `.js` is missing and a
`.ts` sibling exists. The fix is autofixable.

Lives at src/eslint/no-legacy-js-import.js with RuleTester coverage.
@tabcat

tabcat commented May 11, 2026

Copy link
Copy Markdown
Author

Updated per your feedback — new aegir/no-legacy-js-import rule (src/eslint/no-legacy-js-import.js) resolves the import target on disk. Only flags when the .js is missing AND a .ts sibling exists, so real .js files in mixed-language projects pass through.

Covers ImportDeclaration / ExportNamedDeclaration / ExportAllDeclaration / ImportExpression — catches re-exports and dynamic imports too. Bare-module specifiers are untouched. Autofixable, so eslint --fix will batch-rewrite .js.ts.

RuleTester coverage in test/eslint-no-legacy-js-import.js: 9 cases (4 valid / 5 invalid).

Open question: scoped to .js → .ts only. Easy to extend to .jsx → .tsx, .cjs → .cts, .mjs → .mts — happy to fold in here or punt to a follow-up.

@tabcat

tabcat commented May 11, 2026

Copy link
Copy Markdown
Author

think the title could be changed to just fix: ? think this belongs in v48, not a new major.

tabcat added 2 commits May 11, 2026 22:12
Drop the custom no-legacy-js-import rule and its tests in favour of
n/file-extension-in-import, which is bundled transitively via neostandard
and does the same filesystem-aware extension check with broader maturity.

The typescriptExtensionMap is set via settings instead of options so the
v17 rule (shipped with neostandard) picks it up.
@tabcat

tabcat commented May 11, 2026

Copy link
Copy Markdown
Author

Realized eslint-plugin-n's n/file-extension-in-import does the same filesystem-aware check, and it's already loaded transitively via neostandard. Swapped in 00a436ed — drops the custom rule + its tests in favour of a settings + addRule config.

One quirk: the v17 of eslint-plugin-n bundled with neostandard doesn't accept typescriptExtensionMap as a rule option (added in v18), but does read it from settings.n. The map uses two entries: ['.ts', '.js'] seeds the resolver's .js → .ts fallback (so it finds the .ts file when the import says .js), then ['.ts', '.ts'] overrides the expected import extension to .ts. Behaviour matches the custom rule — legacy .js-aliased imports flagged + autofixed, real .js in mixed-language projects untouched.

Extensionless relative imports (./foo) currently pass through — the resolver's tryExtensions doesn't include .ts so they don't resolve and the rule skips. Easy to add tryExtensions: ['.ts', ...] to the settings if catching extensionless is in scope.

@achingbrain

Copy link
Copy Markdown
Member

This picks up import type { Foo } from './bar.js' as well which running the tests with type stripping misses 👍

@achingbrain achingbrain changed the title feat(eslint)!: forbid relative .js imports in TS sources fix: forbid relative .js imports in TS sources May 27, 2026
@tabcat

tabcat commented May 27, 2026

Copy link
Copy Markdown
Author

i think we should catch extensionless imports. i can add that change.

tabcat added 2 commits May 28, 2026 18:25
The TS-context default tryExtensions in eslint-plugin-n is
['.js', '.json', '.node', '.mjs', '.cjs'] — no '.ts' — so a relative
extensionless import like './foo' couldn't be resolved when only
'./foo.ts' existed on disk, and n/file-extension-in-import skipped
silently. Adding '.ts' (and keeping the rest of the defaults) lets the
resolver find those siblings so the rule reports and the autofix
inserts the correct extension.
n/file-extension-in-import's built-in fixer always inserts the expected
extension before the closing quote. For extensionless imports that's
correct (./foo → ./foo.ts), but for a .js-aliased import resolving
through the backward extensionMap to a .ts sibling, it produces
./foo.js.ts — an unreachable path.

Wrap the upstream rule via Object.create(context, { report }) so that
when the import already carries a JS/TS extension we swap n's appender
for a replaceTextRange that overwrites the existing extension. Other
cases (extensionless, dot-in-name) fall through to the upstream fix
unchanged.

Registered under a small aegir-n plugin to keep n/'s namespace clean;
the rule reference is pulled from neostandard's already-loaded plugin
map so no new dependencies are added.
@tabcat

tabcat commented May 28, 2026

Copy link
Copy Markdown
Author

Follow-up: extensionless detection + autofix fix for .js-aliased imports.

Extensionless: added tryExtensions: ['.ts', '.js', '.json', '.node', '.mjs', '.cjs'] to settings.n. The TS-context default omits .ts, so ./foo (with only ./foo.ts on disk) didn't resolve and the rule skipped silently. With .ts in the list, those cases now report.

Autofix: n/file-extension-in-import always inserts before the closing quote — correct for extensionless (./foo./foo.ts), but for a .js-aliased import resolving through the backward map to a .ts sibling, it produces ./foo.js.ts (unreachable). Wrapped the upstream rule to swap the appender for replaceTextRange when the import already carries a JS/TS extension. Registered under a small aegir-n plugin; rule reference pulled from neostandard's already-loaded plugin map, no new deps.

Behavior:

Import written target.ts? target.js? Lint Upstream autofix Wrapped autofix
./target.ts pass
./target.ts pass
./target.ts pass
./target.js pass
./target.js FAIL ./target.js.ts ./target.ts
./target.js FAIL ./target.js.ts ./target.ts
./target FAIL ./target.ts ./target.ts
./target FAIL ./target.js ./target.js
./target FAIL ./target.ts ./target.ts
./target pass

Doesn't cover .mts/.cts/.tsx siblings yet — same shape of silent skip applies there; can follow up if it comes up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants