Skip to content

fix: give the published declarations explicit .js extensions - #78

Merged
soimy merged 2 commits into
masterfrom
fix/declaration-extensions
Sep 29, 2026
Merged

soimy merged 2 commits into
masterfrom
fix/declaration-extensions

Conversation

@soimy

@soimy soimy commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Closes the node16/nodenext item in DEFERRED_WORK.md: the published declarations were written in a form only a tolerant resolver accepts, so the strictest consumers failed inside this package's own files.

The defect, measured

Compiling the documented imports by package name against the built package, before the fix:

consumer mode (skipLibCheck: false) before after
bundler (Vite/webpack) pass pass (gated)
node16 fail, 5 errors pass (gated)
nodenext fail, 5 errors pass (gated)
nodenext with skipLibCheck: true pass pass
node10 + CommonJS pass pass (measured by hand)
node10 + CommonJS on TypeScript 4.6 pass pass (measured by hand)
dist/index.d.ts(1,39): error TS2834: Relative import paths need explicit file extensions in
  ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
dist/index.d.ts(2,56): error TS2835: … Did you mean './maxrects-packer.mjs'?

The cause is structural: @rollup/plugin-typescript emits one declaration per source module and keeps the relative specifiers extensionless, while the package is "type": "module".

The fix

scripts/fix-declaration-extensions.mjs, run from postbuild, rewrites relative specifiers in dist/**/*.d.ts to end in .js — the ESM-correct form: TypeScript maps ./x.js to ./x.d.ts, and the runtime artifacts are self-contained bundles with no relative imports, so the extension only ever has to resolve as a declaration. 17 specifiers in 5 files; no .js/.mjs/.cjs artifact is touched.

The script is defensive in the two ways that matter for a build step: it refuses to write a specifier that has no sibling declaration to resolve to (that would ship a broken path silently), and after rewriting it asserts nothing was left extensionless. Both were exercised on purpose — export * from "./missing" produces Error: …/dist/__probe.d.ts: "./missing" has no sibling declaration to resolve to. A second run is a no-op.

The gate

scripts/verify-package.mjs now compiles the type fixture under bundler, node16 and nodenext, with skipLibCheck: false, in a consumer project marked "type": "module" so the two strict modes read the fixture as ESM — so every mode this description claims is a continuous CI gate, not a one-off measurement. Teeth verified per entry by removing one .js from dist/index.d.ts: the gate prints the TS2834 message and exits non-zero, and because node16 is the first strict mode it reaches, the failure is reported as under node16. node10 stays ungated on purpose — it never needed the extension, and a wrong .mjs fix would only show up there (see AGENTS.md).

Compatibility

The extension is not a node10 problem: that resolver maps ./x.js to ./x.d.ts too, which the table above pins with TypeScript 4.6 — the oldest version anyone is realistically still building against. No exports field is involved, so deep imports keep working exactly as before.

AGENTS.md documents the step, the matrix and the "three gates" wording; the item is removed from DEFERRED_WORK.md. Local gates: lint 0/0, format:check, typecheck, npm test (98 passed / 2 skipped, exercising the postbuild step end to end), cover — still 100% on statements, branches, functions and lines — and verify:package.

`@rollup/plugin-typescript` emits one `.d.ts` per source module and keeps the relative specifiers
extensionless, while the package is `"type": "module"`. A consumer on `moduleResolution: node16` or
`nodenext` with `skipLibCheck: false` therefore fails inside this package's own declarations:

    dist/index.d.ts(1,39): error TS2834: Relative import paths need explicit file extensions in
      ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
    dist/index.d.ts(2,56): error TS2835: … Did you mean './maxrects-packer.mjs'?

Measured before the fix, compiling the documented imports by package name: `node16` and `nodenext`
each reported 5 errors, `bundler` and `node10` passed, and `nodenext` passed only with
`skipLibCheck: true`. After the fix all of them pass with `skipLibCheck: false`.

- `scripts/fix-declaration-extensions.mjs` (new, from `postbuild`) rewrites the relative specifiers
  in `dist/**/*.d.ts` to end in `.js` — the ESM-correct form, since TypeScript maps `./x.js` to
  `./x.d.ts` and the runtime artifacts are bundles without relative imports at all. It fails loudly on
  a specifier that has no sibling declaration and on anything it could not rewrite, rather than
  writing a path that resolves nowhere, and it is idempotent (a second run rewrites nothing).
- `scripts/verify-package.mjs` compiles the type fixture under **both** `bundler` and `nodenext`
  now, with the consumer project marked `"type": "module"` so nodenext reads the fixture as ESM.
  Verified to have teeth: with one `.js` extension removed from `dist/index.d.ts`, the gate reports
  the TS2834 message and exits non-zero.
- `AGENTS.md` documents the step and the measured matrix; the corresponding item is gone from
  `DEFERRED_WORK.md`.

No runtime artifact changes: 17 specifiers in 5 declaration files, no `.js`/`.mjs`/`.cjs` bundle
touched. `node10` (and TypeScript 4.6 with it) resolves `./x.js` to `./x.d.ts` as well, so legacy
consumers are unaffected; `npm test`, `cover` (100% on all four metrics) and the rest of the gates
pass.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Build process now rewrites declaration files after compilation.

No outstanding findings block merging.

Summary

The PR rewrites relative imports in published declarations to use .js extensions and verifies the packed package’s types under bundler, node16, and nodenext. No outstanding findings remain.

Reviews (2) · Last reviewed commit: "test: gate node16 in the package type fi..."

Comment thread AGENTS.md Outdated
The PR promises `node16` and `nodenext` both compile the published declarations with
`skipLibCheck: false`, but only `nodenext` was in the gate's MODES. `node16` joins it, so the promise
is enforced rather than measured once. The fixture is compiled in order `bundler`, `node16`,
`nodenext`, and the success line lists all three.

Verified to have teeth on its own entry, not just through the loop: removing one `.js` extension from
`dist/index.d.ts` makes the gate fail under `node16` (the first strict mode it reaches) with
`error TS2834 … when '--moduleResolution' is 'node16' or 'nodenext'`. `node10` stays ungated and
documented as measured by hand in AGENTS.md, with the reason: the extension only ever had to be `.js`
rather than the `.mjs` TS2835 suggests, so a wrong fix there would only be caught by re-measuring it.
@soimy
soimy merged commit 203bb96 into master Sep 29, 2026
4 checks passed
@soimy
soimy deleted the fix/declaration-extensions branch September 29, 2026 13:18
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