Skip to content

feat: Add require-emitted-extensions ESLint rule and companion Jest resolver - #77

Open
TrevorBurnham wants to merge 5 commits into
cloudscape-design:mainfrom
TrevorBurnham:feat-require-emitted-extensions
Open

feat: Add require-emitted-extensions ESLint rule and companion Jest resolver#77
TrevorBurnham wants to merge 5 commits into
cloudscape-design:mainfrom
TrevorBurnham:feat-require-emitted-extensions

Conversation

@TrevorBurnham

@TrevorBurnham TrevorBurnham commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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-JSON exports subpaths 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-extensions requires 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.ts and ./x/index.ts exist 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/components at 9f3d9af89, rule enabled on top of that repo's own ESLint config, eslint . scope, ignore: ["/lib/"], against a built tree:

  • 6836 reports across 2093 files, every one fixable, 0 ambiguous, 0 unresolvable
  • --fix rewrites 2093 files, and a second pass changes nothing
  • tsc --noEmit unchanged, 1 error before and after, identical error-code profile

The general-purpose plugins fail here by design, not by degree. import-x/extensions enforces the on-disk extension, so it produces ./x.ts, which TypeScript rejects with TS5097. n/file-extension-in-import appends an extension to the specifier as written, so a directory import becomes ../button.js where 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-tools is not published to npm. All 10 consuming repos declare it as github:cloudscape-design/build-tools#main, and none of their committed lockfiles contain any @cloudscape-design/* entry, so there is no version boundary: merging to main reaches every consumer on its next install. Hence the detail below.

The new rule is inert. lib/eslint/index.js exports a rule map and no shareable config, consumers enable rules individually by name, and no consumer enables require-emitted-extensions. Adopting it stays a separate, per-repo decision. The Jest resolver is likewise opt-in via resolver in a Jest config, which no consumer sets today.

no-internal-in-public-interfaces cannot add an error anywhere. The change only widens an allowlist, so it can remove reports and never introduce one.

ban-files is 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-tools from main to this branch and running each repo's own lint, everything else untouched:

repo ban-files no-internal lint delta
components on on none, since components#4949 (was +2 errors)
board-components on on none
code-view off on none (measured)
component-toolkit off off none (measured)
theming-core off off none (measured)
chart-components, chat-components, collection-hooks, demos, test-utils off off none (neither rule enabled)

Dropping the minimatch declaration 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 components at fc0c49606 (2120 files rewritten, 0 unresolvable, 0 ambiguous) and linting the rewritten source with the repo's own config, once with main's rules and once with this branch's:

rule on main on this branch
no-internal-in-public-interfaces 55 reports across 38 files, all false 0 reports
ban-files 0 reports 0 reports
  • no-internal-in-public-interfaces is the blocker. It treats ../button/interfaces.js as 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-files changes 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 .ts or .tsx path stops matching once specifiers name the emitted file, and the rule reports nothing to say so. On codemodded fc0c49606, a file importing the banned ../index.js and ../i18n/provider.js gets nothing from main's rule for the src/i18n/provider.tsx ban, and for the src/index.ts ban gets two reports naming the nonexistent path src/index.js/index.tsx instead 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 .js on disk still reports that file.
  • A Jest resolver, because Jest resolves ./leaf.js against the files on disk, where the file is leaf.ts. It tries the literal request first and drops the extension only when that fails. A moduleNameMapper is not a substitute, even though component-toolkit uses 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.js to ./styles.css, which is a stylesheet or nothing at all. Verified against the components repo's own jest.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.

micromatch is now declared

Three rules import it and it was declared nowhere in the manifest, resolving only by hoisting from fast-glob and lint-staged, both devDependencies, so a consumer's tree can be missing it. minimatch was 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.

  • Adopt with 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 as src/alert/style.tsx, all of them imports into src/internal/generated/**. Those specifiers ship broken in the published package, so they are the last ones worth silencing. Note that ignore matches the specifier, not the importing file.
  • Relatedly, src/internal/generated/custom-css-properties is gitignored build output, so on a clean checkout 49 of those specifiers report as unresolvable with 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 or allowUnresolved: true.
  • Set the Jest resolver in the same PR that runs the codemod, not after it, or every test importing a rewritten specifier fails.

Notes for the reviewer

  • .eslintrc has ignorePatterns: ["lib/*"], so npm run lint covers exactly one file (vite.config.ts) and nothing in the source tree. Both test files I touched were already Prettier-dirty on main; I left their formatting alone rather than bury the change in reformatting. Enabling lint coverage needs a CommonJS override for .cjs and is its own change.
  • The 4 stylelint suites cannot run in a clean checkout, because stylelint is an optional peerDependency absent from devDependencies. Pre-existing.
  • ban-files still reports speculative paths that do not exist, e.g. ../index.js also yields a report naming src/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.

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
TrevorBurnham force-pushed the feat-require-emitted-extensions branch from f3dc6eb to ff42440 Compare September 3, 2026 13:46
@TrevorBurnham
TrevorBurnham marked this pull request as ready for review September 3, 2026 13:50
@TrevorBurnham
TrevorBurnham requested a review from a team as a code owner September 3, 2026 13:50
@TrevorBurnham
TrevorBurnham requested review from pan-kot and removed request for a team 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.
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.

1 participant