feat: Add require-emitted-extensions ESLint rule and companion Jest resolver - #77
Open
TrevorBurnham wants to merge 5 commits into
Open
Conversation
TrevorBurnham
force-pushed
the
feat-require-emitted-extensions
branch
from
August 30, 2026 01:13
241fcc3 to
f3dc6eb
Compare
Node's ESM resolver neither probes extensions nor falls back to a directory index, so a package that emits `import "./interfaces"` cannot be loaded by native Node or by Vite's SSR pipeline. TypeScript passes relative specifiers through to the emitted JavaScript verbatim, so this has to be fixed in source. The rule requires relative specifiers to name the emitted file (`./x.js`, `./x/index.js`) and autofixes them. It appends only from positive evidence on disk: a specifier it cannot resolve is reported without a fix, and one where both `./x.ts` and `./x/index.ts` exist is reported as ambiguous. The existing plugins do not work for this. `import-x/extensions` enforces the on-disk `.ts`, which TypeScript then rejects with TS5097, and `n/file-extension-in-import` invents `.js` for specifiers it cannot resolve, which is the wrong target for 1153 of the 1585 it guesses at in the components repo. Measured over cloudscape-design/components: 5124 fixes across 1402 files, 0 unresolvable, 0 ambiguous, every fix resolving to a real file in the built package, unchanged tsc error counts, and a second --fix pass that changes nothing.
Three ESLint rules import micromatch, which was declared nowhere in the manifest: it resolved only by hoisting from fast-glob and lint-staged, both devDependencies, so a consumer's tree can be missing it entirely. minimatch was declared in its place and is imported by nothing.
require-emitted-extensions rewrites relative specifiers to name the file the
build emits, so `../button` becomes `../button/index.js`. Both rules match the
specifier against paths on disk, where the file is still `.tsx`, and neither
looked through the emitted extension.
ban-files then stops reporting any ban whose pattern has no `.js` twin, and
reports nothing to say so. Measured against the components repo's own config,
`./src/index.ts` and `./src/i18n/{index,provider}.tsx` go silent, while
`./src/*/index.tsx` keeps firing only because a `./src/*/index.js` pattern
happens to sit beside it.
no-internal-in-public-interfaces treats `../button/interfaces.js` as a
disallowed source: 55 false errors across 38 public interface files in the
components repo.
Candidate paths are now checked in order and report once per pattern, so a
specifier naming a file that really is `.js` on disk still reports that file
rather than the stripped form.
Jest resolves `./leaf.js` against the files on disk, where the file is
`leaf.ts`, so rewriting specifiers to name the emitted file breaks every test
that imports one.
The resolver tries the literal request first and drops the extension only when
that fails. A moduleNameMapper rewrites unconditionally, so mapping
`^(\.{1,2}/.*)\.js$` to `$1` also rewrites the 504 distinct specifiers in the
components repo that already name an emitted file: `./styles.css.js` becomes
`./styles.css`, which is a stylesheet or nothing at all.
CommonJS because Jest loads the resolver synchronously. Verified against the
components repo's own jest.unit.config.js, resolved through the package exports
map.
TrevorBurnham
force-pushed
the
feat-require-emitted-extensions
branch
from
September 3, 2026 13:46
f3dc6eb to
ff42440
Compare
TrevorBurnham
marked this pull request as ready for review
September 3, 2026 13:50
The rule's own docs recommended `ignore: ["/lib/", "/generated/"]`, which is the opposite of what the measurement supports. Against components at fc0c49606, adding `"/generated/"` silences 56 reports across 55 git-tracked files, every one an import into `src/internal/generated/**`. Those specifiers ship broken in the published package, so they are the last ones worth silencing. The docs are the durable half of this guidance, so they should not disagree with the PR description that measured it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of cloudscape-design/components#4948, which has the full problem statement, the verbatim resolver errors and the scope of what is broken today.
Node's ESM resolver neither probes extensions nor falls back to a directory index, so a package that emits
import "./interfaces"cannot be loaded by native Node, or handed to Node by Vite's SSR pipeline. In@cloudscape-design/components@3.0.1352, 111 of the 326 non-JSONexportssubpaths fail to load for this reason, including the root entry. TypeScript passes relative specifiers through to the emitted JavaScript verbatim, so this has to be fixed in source, which at repo scale needs a lint rule with an autofix.This PR is the tooling half only: the rule, plus the three fixes a repo needs before it can turn the rule on. Nothing here rewrites any source or changes any published artifact. It does change one existing rule's behaviour,
ban-files, but as of cloudscape-design/components#4949 that no longer changes any repo's lint output. Both halves of that are quantified under "Effect on downstream repos".The rule
require-emitted-extensionsrequires relative specifiers to name the emitted file (./x.js,./x/index.js) and autofixes them. Fixes come only from positive evidence on disk: a specifier it cannot resolve is reported without a fix, and one where both./x.tsand./x/index.tsexist is reported as ambiguous. That matters for a codemod of this size, because a rule that guesses turns a loud build error into a silent runtime one.Measured over
cloudscape-design/componentsat 9f3d9af89, rule enabled on top of that repo's own ESLint config,eslint .scope,ignore: ["/lib/"], against a built tree:--fixrewrites 2093 files, and a second pass changes nothingtsc --noEmitunchanged, 1 error before and after, identical error-code profileThe general-purpose plugins fail here by design, not by degree.
import-x/extensionsenforces the on-disk extension, so it produces./x.ts, which TypeScript rejects with TS5097.n/file-extension-in-importappends an extension to the specifier as written, so a directory import becomes../button.jswhere the emitted file is../button/index.js. Neither consults disk to tell a file from a directory, which is the only thing that makes this codemod safe to automate.Effect on downstream repos
@cloudscape-design/build-toolsis not published to npm. All 10 consuming repos declare it asgithub:cloudscape-design/build-tools#main, and none of their committed lockfiles contain any@cloudscape-design/*entry, so there is no version boundary: merging tomainreaches every consumer on its next install. Hence the detail below.The new rule is inert.
lib/eslint/index.jsexports a rule map and no shareable config, consumers enable rules individually by name, and no consumer enablesrequire-emitted-extensions. Adopting it stays a separate, per-repo decision. The Jest resolver is likewise opt-in viaresolverin a Jest config, which no consumer sets today.no-internal-in-public-interfacescannot add an error anywhere. The change only widens an allowlist, so it can remove reports and never introduce one.ban-filesis the only rule whose reports can change, in the two repos that enable it, and measured it changes none of them. New candidate paths are derived only for specifiers that already carry an emitted extension; for an extensionless specifier the candidate set is unchanged, and the only difference is that one specifier now reports once per banned pattern rather than once per matching candidate.Measured by swapping the installed
build-toolsfrommainto this branch and running each repo's own lint, everything else untouched:ban-filesno-internalDropping the
minimatchdeclaration is safe: no consumer declares or imports it.Three fixes included
Both rules break on codemodded source, which is why they ship with the rule rather than after it. Both match specifiers against paths on disk, where the file is still
.tsx, and neither looked through the emitted extension.Measured by applying the codemod to
componentsatfc0c49606(2120 files rewritten, 0 unresolvable, 0 ambiguous) and linting the rewritten source with the repo's own config, once withmain's rules and once with this branch's:mainno-internal-in-public-interfacesban-filesno-internal-in-public-interfacesis the blocker. It treats../button/interfaces.jsas a disallowed source, because its allowlist regex is anchored on/interfaces$. That is 55 false errors across 38 public interface files, and it is what stops the codemod from landing.ban-fileschanges no report anywhere, before or after the codemod, now that components#4949 has removed the only two violations it was blind to. What it fixes is a silent disarm rather than anything currently reported: every ban whose pattern names a.tsor.tsxpath stops matching once specifiers name the emitted file, and the rule reports nothing to say so. On codemoddedfc0c49606, a file importing the banned../index.jsand../i18n/provider.jsgets nothing frommain's rule for thesrc/i18n/provider.tsxban, and for thesrc/index.tsban gets two reports naming the nonexistent pathsrc/index.js/index.tsxinstead of the real one. This branch reports both correctly. So this half is regression prevention, and the unit tests carry the demonstration. Candidate paths are now checked in order and report once per pattern, so a specifier naming a file that really is.json disk still reports that file../leaf.jsagainst the files on disk, where the file isleaf.ts. It tries the literal request first and drops the extension only when that fails. AmoduleNameMapperis not a substitute, even thoughcomponent-toolkituses one today ('^(\\.{1,2}/.*)\\.js$': '$1', added alongside the equivalent codemod in chore: Use type: "module" and build to cjs/mjs component-toolkit#190). It works there because that repo has no specifier that already names an emitted file. The components repo has 506 distinct ones over 1317 occurrences, so the same mapping rewrites./styles.css.jsto./styles.css, which is a stylesheet or nothing at all. Verified against the components repo's ownjest.unit.config.js, resolved through the package exports map.Each fix has a test that reproduced the bug first, plus end-to-end confirmation through the components repo's real ESLint and Jest configs.
micromatchis now declaredThree rules import it and it was declared nowhere in the manifest, resolving only by hoisting from
fast-globandlint-staged, both devDependencies, so a consumer's tree can be missing it.minimatchwas declared in its place and is imported by nothing.For whoever adopts the rule later
Nothing in this PR enables the rule or runs a codemod anywhere, so none of this is a task on this PR. It is the guidance that the adopting PR, in each consuming repo, will need. It also lives in the rule's own JSDoc so it outlasts this description.
ignore: ["/lib/"], and run the codemod against a built tree. Adding"/generated/"looks tidy but silences 56 reports across 55 git-tracked source files such assrc/alert/style.tsx, all of them imports intosrc/internal/generated/**. Those specifiers ship broken in the published package, so they are the last ones worth silencing. Note thatignorematches the specifier, not the importing file.src/internal/generated/custom-css-propertiesis gitignored build output, so on a clean checkout 49 of those specifiers report asunresolvablewith no fix. Run the codemod after a build and they resolve and get fixed. Using the rule as a CI guard on a clean checkout needs either a build first orallowUnresolved: true.resolverin the same PR that runs the codemod, not after it, or every test importing a rewritten specifier fails.Notes for the reviewer
.eslintrchasignorePatterns: ["lib/*"], sonpm run lintcovers exactly one file (vite.config.ts) and nothing in the source tree. Both test files I touched were already Prettier-dirty onmain; I left their formatting alone rather than bury the change in reformatting. Enabling lint coverage needs a CommonJS override for.cjsand is its own change.stylelintis an optional peerDependency absent from devDependencies. Pre-existing.ban-filesstill reports speculative paths that do not exist, e.g.../index.jsalso yields a report namingsrc/index.js/index.tsx, because it never touches disk. Pre-existing, and the emitted name is probed last so a real path wins where one exists.