Skip to content

JavaScript: honour the project's tsconfig when resolving modules - #8733

Merged
knutwannheden merged 3 commits into
mainfrom
javascriptparser-ignores-the-project-tsconfig
Sep 1, 2026
Merged

JavaScript: honour the project's tsconfig when resolving modules#8733
knutwannheden merged 3 commits into
mainfrom
javascriptparser-ignores-the-project-tsconfig

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

JavaScriptParser builds a fixed set of ts.CompilerOptions and never reads the project's tsconfig.json. A types entry naming a declaration package outside node_modules/@types — how UI5 ships its entire API surface — and every paths alias therefore resolve to nothing. Nothing in rewrite-javascript/rewrite/src/ calls ts.readConfigFile, ts.parseJsonConfigFileContent or ts.findConfigFile. Resolution failure is TS2307, outside the range checkSyntaxErrors gates on, so a type-aware recipe reports zero findings and exits successfully — indistinguishable from a clean codebase.

Measured through the parser, same files, same node_modules:

                                        before          after
types: ["ui5-types"]  Theming.setTheme  <unknown>       Theming{name=setTheme,return=void,parameters=[String]}
paths: {"@app/*"}     Greeter.greet     <unresolved>    src/greeter.Greeter{name=greet,return=String,parameters=[String]}

How it works

Each input is matched to the nearest tsconfig.json or jsconfig.json at or below relativeTo, inputs are grouped by it, and one ts.Program is built per group. Grouping forces two related fixes: JavaScriptTypeMapping deduplicates by ts.Type.id, which is only unique within the TypeChecker that issued it, so it becomes per-program rather than per-batch; and the retained program for incremental reuse becomes a map keyed by config.

Only resolution-affecting options are read — baseUrl, paths, pathsBasePath, rootDirs, types, typeRoots, moduleResolution, moduleSuffixes, customConditions, resolvePackageJsonExports/Imports, resolveJsonModule. target, lib and strict stay with the parser: they change the shape of types rather than where a specifier resolves, and each would only narrow attribution.

module is excluded for a sharper reason. A project's module kind classifies files as CJS or ESM, and an ESM-only import from a CJS file is then TS1479 — inside the critical range, so the file yields a ParseError and no LST at all. Losing every symbol in a file is worse than losing one import's types, and the diagnostic family is too large to allowlist: ts.Diagnostics has 37 codes in the 1000–2000 band tied to module-kind classification. Where a project states only module, the resolution TypeScript pairs with it is adopted without the module kind itself; Classic is declined, since it searches no node_modules.

Export conditions come from the usage location rather than from moduleResolution alone. Without that, a project stating node16 sends every import through the require branch of every dual package, because TypeScript only substitutes ESNext for an absent mode under Bundler — wrong attribution rather than missing attribution.

Discovery is gated on relativeTo

Without a project root there is no search to bound, and walking up from the process working directory would apply whatever config sits above it — under vitest, this repository's own tsconfig.json, which states module: Node16, to all 2262 tests. With no relativeTo or no config file the options are byte-identical to the defaults, so the change is inert for every project that has no tsconfig.

What this deliberately trades

One cost, measured. types is restrictive as well as additive: the parser defaults to types: ["*"], and a project stating the key replaces that rather than adding to it, so a repo whose tsconfig says "types": ["node", "jest"] loses the ambient declarations of every other installed @types/* package. That is what tsc does for them, and it is the same mechanism that makes UI5 work, but it is a narrowing. Loading a large declaration package also roughly doubles to triples one-time program construction, per batch rather than per file.

A stated moduleResolution: node/node10 is honoured literally rather than floored at the parser default. Those projects keep deep-subpath imports into packages that wall them off behind an exports map — which the Bundler default refuses — and in exchange stay unable to see packages that publish their types only through an exports condition. Flooring them would swap one loss for the other, so it is a trade rather than a fix, and honouring what the project declares is the consistent answer. It can change later without touching this design.

Worth stating what is not a cost here, since it is easy to assume otherwise: no project loses deep-subpath resolution relative to main. The Bundler default already refuses those, from #8672; this PR only ever recovers them, for projects that declare node10.

Tests

Seven tests in test/javascript/tsconfig.test.ts, each mutation-checked to confirm it is killed by removing the line it claims to defend, and by no other: the types entry in both directions, baseUrl, per-project grouping, a stated moduleResolution that resolves differently from the default, a module kind that sets resolution without being adopted, the absence of a Node10 fallback, and jsconfig.json with tsconfig.json outranking it. Two further tests were written and then dropped once mutation testing showed they were subsumed.

The fixtures are hand-built packages rather than real dependencies, so they need no install: a declaration package outside @types/ using the interface-and-const shape @sapui5/types uses for sap/ui/core/Theming, a package walling its subpaths behind exports, one publishing different typings per condition, and one whose subpath needs a directory-index lookup.

Also fixed

The compiler host now answers directoryExists for directories holding only in-memory inputs, which module resolution probes before the files in them. Without it, any in-memory source under a directory absent from disk was unreachable — silently losing cross-file attribution in tests that do not write sources to disk, which is every test using the npm() helper.

JavaScriptParser built a fixed set of ts.CompilerOptions and never read the
project's tsconfig.json, so a `types` entry naming a declaration package
outside node_modules/@types — how UI5 ships its API surface — and every
`paths` alias resolved to nothing. Resolution failure is TS2307, outside the
range checkSyntaxErrors gates on, so type-aware recipes reported zero
findings and exited successfully.

Each input file is matched to the nearest tsconfig.json or jsconfig.json at
or below relativeTo, inputs are grouped by it, and one ts.Program is built
per group. Grouping forces two related fixes: JavaScriptTypeMapping
deduplicates by ts.Type.id, which is only unique within the TypeChecker that
issued it, so it becomes per-program rather than per-batch; and the old
program kept for incremental reuse becomes a map keyed by config.

Only resolution-affecting options are read. `target`, `lib` and `strict`
change what types look like rather than what a specifier finds, and each
would only narrow attribution. `module` is excluded for a sharper reason: a
project's own module kind classifies files as CJS or ESM, and an ESM-only
import from a CJS file is then TS1479, which is inside the critical range and
costs the whole file its LST. Where a project states only `module`, the
resolution TypeScript pairs with it is adopted without the module kind
itself; `Classic` is declined, searching no node_modules.

A default baseUrl cannot be carried over a project's `paths`, since baseUrl
takes precedence over pathsBasePath and would resolve a nested project's
aliases against the repository root.

Two trades this makes, both measured. A project declaring a modern
moduleResolution loses deep-subpath imports into packages that wall them off
behind an `exports` map, which Node10 reached — the project's own tsc
refuses them too, and Node enforces the map at runtime. And `types` is
restrictive as well as additive: stating it disables the implicit @types
sweep, so a project narrowing `types` loses ambient declarations it gets
today. Loading a large declaration package also roughly doubles to triples
one-time program construction, per batch rather than per file.

Discovery is gated on an explicit relativeTo. Without one the search would
walk up from the process working directory and apply whatever config sits
above it — under vitest, this repository's own tsconfig. With no relativeTo
or no config file the options are byte-identical to the defaults.

The compiler host also answers directoryExists for directories holding only
in-memory inputs, which module resolution probes before the files in them.
…res-the-project-tsconfig

# Conflicts:
#	rewrite-javascript/rewrite/src/javascript/parser.ts
A project that states `moduleResolution: node16` was sending every import
through the `require` branch of every dual package: `ts.resolveModuleName`
was called with no resolution mode, and TypeScript only substitutes ESNext
for an absent mode under `Bundler`. An ESM consumer that tsc types against a
package's `import` branch was typed against its `require` branch instead —
wrong attribution rather than missing attribution, which counts cannot see.
The mode now comes from the usage location, which under the pinned
`preserve` module kind is ESNext for every file, so a project reads the same
branch whatever resolution it declares.

The shared `ts.SourceFile` cache is keyed by module format alongside path. A
file's format is baked in at creation and follows from the requesting
project's `moduleResolution`, so with several projects per batch the first
one to load a dependency otherwise fixed its format for the rest.

A config that cannot be read now leaves its files on the parser defaults,
grouped with every other file that has no config. `parseJsonConfigFileContent`
could throw out of the generator and lose the batch, and the error path
returned options that differed from the no-config project only by retaining
`baseUrl`, which bought that project a second identical program.

Also: `parseJsonConfigFileContent` gets a host whose `readDirectory` answers
empty, since only `options` is read and `ts.sys` would walk the whole tree
under each config; root names are collected from the deduplicated inputs
rather than the raw list; and a directory named `..cache` is no longer read
as an escape from the project root.
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Sep 1, 2026
@knutwannheden
knutwannheden merged commit 633dce1 into main Sep 1, 2026
1 check passed
@knutwannheden
knutwannheden deleted the javascriptparser-ignores-the-project-tsconfig branch September 1, 2026 08:06
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant