Skip to content

fix(js,ts): a declare module answers its own package, behind a real module - #3157

Merged
colbymchenry merged 1 commit into
mainfrom
claude/ambient-own-package
Oct 31, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/ambient-own-package

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Main must not regress from published 1.6.2, and #2718 did: a declare module stub in a .d.ts took imports away from the real file, even from another package. On react, packages/eslint-plugin-react-hooks stubs shared/ReactFeatureFlags for its own tsc, and 94 imports across the reconciler, react-dom and the rest landed on that stub instead of packages/shared/ReactFeatureFlags.js. Test-only stubs in packages/react/src/__tests__/testDefinitions/ collected imports from the whole repository.

Now a declare module counts only where the checker can see it, and a module of one of the repository's own packages comes before it. What #2718 added stays: a project whose declare module 'x' is the only answer for x still links. This never shipped, so the CHANGELOG change is a sentence on #2718's [Unreleased] bullet.

Root cause

findAmbientModuleExport and findAmbientModuleFile answered every bare specifier from every script .d.ts in the repository, ahead of any file, because TypeScript looks at ambient modules first. That holds inside one program, and a monorepo has one per package. Going back to "file first" is no fix either: copilot maps vscode to a test shim through paths, and there the checker does take the declared module.

Fix

  • Which block counts (reachesProgram, ambient-modules.ts). A block counts for a file in its own package, in a package nested under that one (vscode's extensions and the root package's src/vscode-dts), or in a package that depends on it (astro:assets in an Astro project, from astro/client.d.ts). A package is a folder whose package.json names one or lists dependencies; a {"type":"module"} marker is not. A test's or a fixture's block counts only for tests in its own package.
  • What outranks a block (findImportedExport, declaredModuleFile, isRepositoryModule, import-resolver.ts). A module of a package this repository holds, a workspace member or a path dependency, comes first, in the stub's own package too: the stub describes that module, and the module is what runs. A name only the stub declares still resolves to the stub. A paths target stays behind the block, as the checker has it, and so does a package whose module is only a declaration file.
  • The bare-name gate (reachesAmbientModule). A name bound to the module reaches a block only if the block counts for the file.
  • The candidate pool (matchByExactName). A block that doesn't count for a file is no candidate. fix(js,ts): an import of a package the repository declares itself links to that declaration #2718 gave these blocks function nodes that 1.6.2's crowd never held: react's test stub createRef won the ranking, the gate then dropped the reference, and an edge 1.6.2 had was lost. This restores that crowd rather than manufacturing a guess (fix(resolution): fuzzy matching skips a nested function the reference cannot reach #1709).
  • Sync (outrankedImports). An import's module edge into a declaration file is revisited when a real module appears.

One call to review: the brief said "only importers in its own package". I kept nested packages and dependents as well, because the numbers say a strict rule would cut true links: 390 on astro (every examples/* project and integration reading astro/client.d.ts), 56 on SvelteKit, and vscode's 18,847 through the root package. React is identical under both rules.

Validation

Tests. __tests__/ambient-module-imports.test.ts: 17 new tests, 48 in all. 13 of the 17 fail on main's source; the other 4 guard against over-correcting and pass on both, and none of the 31 existing tests changes. Each of the 12 safeguards fails at least one test when disabled, most of them exactly one (a mutation of the source, restored after each run).

A/B against main, both arms on 9d3abb84, kernel included, 13 repos. Nodes are identical everywhere, as this touches no extraction.

repo commit edges moved lost added
facebook/react c5ec5c6d 199,191 711 49 0
sveltejs/kit 04aac59b 26,703 146 0 0
vuejs/core a copy without .git 47,958 27 0 0
withastro/astro e3bec3d2 83,359 5 0 0
microsoft/vscode: extensions/, src/vscode-dts, src/typings 69e9e92f 324,231 0 0 0
bitwarden/clients 6a041c95 344,360 0 0 0
outline/outline d3b09435 113,251 0 0 0
dubinc/dub 6b463b4b 48,473 0 0 0
excalidraw/excalidraw a copy without .git 60,652 0 0 0
withastro/starlight 6c9ca431 6,680 0 0 0
shadcn-ui/taxonomy 298a8857 2,214 0 0 0
gin-gonic/gin (control) 0f09c3a9 8,073 0 0 0
pallets/flask (control) d086db85 5,292 0 0 0

Unresolved references differ only on react: 44 more, the names of the lost edges below. Metadata-only changes: 9 on react, 2 on astro, confidence 0.7 to 0.9 on the same target, because a stub left the crowd.

react, against REG's PUB index. Every changed edge was checked against what 1.6.2 had for that name at that line.

  • REG's 203 are all back on 1.6.2's target: 94 shared/ReactFeatureFlags, 91 react, 18 react-dom/client.
  • Imports into the test stubs: 609 to 0 (React.d.ts 554, ReactDOMClient.d.ts 24, ReactDOM.d.ts 23, PropTypes.d.ts 8). Into ReactFeatureFlags.d.ts: 94 to 0. 13 method calls from tests in packages/react stay on React.d.ts, its own package.
  • Moved, 711: 545 React.d.ts to packages/react/index.js; 94 the flags stub to the real file; 21 and 18 ReactDOM.d.ts and ReactDOMClient.d.ts to packages/react-dom; 19 fixtures/flight-parcel/types.d.ts to the real react-server-dom-parcel and react-dom modules; 6 createRef to ReactCreateRef.js; 8 to a fixture namesake at 0.4, as on 1.6.2 (below).
  • Lost, 49: all names 1.6.2 had no edge for either. 37 react, react-dom and react-dom/client names the real barrels don't hand over (below); 8 prop-types in fixtures/dom, an npm package; 2 hermes-eslint from a sibling's stub; 2 synthesized constructor edges from the stub's class to fixture classes.
  • REG's caveat holds, counted from each importer's manifest: of the 711, 554 are in importers that declare an npm version of the package, so the workspace copy is the closest in-repo target, not the exact one, the choice 1.6.2 made. 149 really get the workspace copy, and 8 are the namesakes.
  • 4 of the moved differ from 1.6.2, all flight-parcel: main's exports handling picks client.node.js over client.js, a choice between two real files. One import of a name that module lacks stays on the fixture's own stub.

The others, read against the source.

  • kit: 146 from the generated types/index.d.ts to the implementations: sveltekit to src/exports/vite/index.js, fail to src/exports/index.js.
  • vue core: 27 module edges of @vue/runtime-core from types/globalComponents.d.ts to the package entry.
  • astro: 3 astro/jsx-runtime from the language server's stub to src/jsx-runtime/index.ts. 2 got worse, in one fixture (netlify/test/functions/fixtures/middleware): its manifest lists @astrojs/netlify but not astro, so astro/client.d.ts no longer answers, and a name match takes another fixture's Image.astro at 0.7. Only astro's own repository can do this: elsewhere astro: is declared outside the project.
  • vscode: 18,847 edges from 1,443 extension files into src/vscode-dts are untouched, and about 9,400 references still take the declaration over copilot's paths shim.

Sync. On vue core, removing a file, indexing, restoring it and syncing matches a fresh index edge for edge in both directions. Restoring the stub is byte-identical. Restoring the real entry leaves 24 exact-match vs import metadata differences on the same targets, which main's build shows too.

Cost. Within noise. CPU across all threads, interleaved: vscode's extensions, the worst case with 162 declaration files, 122.5 s to 122.8 s (+0.2%, median of 3; wall 32.6 s to 32.4 s); core 23.7 s to 23.2 s and excalidraw 34.1 s to 33.9 s (median of 5). One wall-clock pass on react: 48.4 s to 47.8 s. Peak memory stays in the same range, with runs overlapping (vscode 3.75 to 3.93 GB against 3.79 to 3.87 GB). Each folder's package.json is read once, and only for specifiers a block declares.

Suite. On this commit, Vitest 4, CODEGRAPH_KERNEL_EXPECT=1, the kernel built from this tree, --maxWorkers=4: 848 files passed, 6 skipped; 10,552 tests passed, 2 expected failures (it.fails, in unrelated files), 87 skipped, 0 failed, nothing rerun. tsc --noEmit is clean.

One crash, not reproduced. The first bitwarden run on the fix arm died 8.7 s in, during extraction, with a libuv assertion (!(handle->flags & UV_HANDLE_CLOSING), exit 0xC0000409). It's in worker teardown, before any code this PR touches runs; the re-run exited 0 and is the row above.

Left alone

  • A sibling's stub reached only by a tsconfig include or /// <reference path>, with no dependency between the packages, no longer links. Nothing here reads tsconfig include, and none of the 13 repos needed it. For the same reason bitwarden's bit-common/.../sdk-alias.d.ts, in the root package, keeps answering libs/*, as on main.
  • Barrels. import { Component } from 'react' stays unresolved, as on 1.6.2, because findExportedSymbol doesn't follow import { x } plus export { x } through ReactClient.js. That's why 37 of the lost edges have no real target to land on, and why 8 go to a fixture namesake at 0.4 (class Chrome extends Component to fixtures/stacks/Components.js), wrong on 1.6.2 too. It deserves its own PR.
  • Global declare functions, like scripts/jest/typescript/jest.d.ts's it, aren't declare module blocks and are untouched.
  • A package.json edit that only changes dependencies doesn't re-open importers on a sync, the same limit the out-of-repo guard has.

🤖 Generated with Claude Code

…odule

A declare module stub in a .d.ts took imports away from the real file, even
from another package, which main must not do against 1.6.2. On react,
packages/eslint-plugin-react-hooks stubs shared/ReactFeatureFlags for its own
tsc, and 94 imports across the reconciler, react-dom and the rest landed on
that stub. Test-only stubs under packages/react/src/__tests__ took 478 imports
from the whole repository. Regression from PR #2718.

Root cause: every script declaration in the repository answered every bare
specifier, ahead of any file, because TypeScript looks at ambient modules
first. That holds inside one program, and a monorepo has one per package.

Fix:
- A block counts for a file in its own package, in a package nested under that
  one (vscode's extensions, the root package's src/vscode-dts), or in a package
  depending on it (astro:assets from astro/client.d.ts). A package.json that
  only says {"type":"module"} makes no package. A test's block counts only for
  tests in its own package.
- A module of a package the repository holds, a workspace member or a path
  dependency, comes before a block, in the stub's own package too: the stub
  describes that module, and the module is what runs. A name only the stub
  declares still resolves to it. A paths target stays behind the block, as the
  checker has it (copilot's vscode shim), and so does a package whose module is
  only a declaration file.
- A name bound to a module reaches a block only if the block counts for the
  file, and a block that doesn't is no candidate in the exact-name pool: its
  bodiless functions, which PR #2718 added and 1.6.2's crowd never held, took
  react's createRef in the ranking and were then dropped by the gate.
- Sync revisits an import's module edge into a declaration file when a real
  module appears.

Validation: A/B against main with both arms on the same commit. react: 711
edges moved, 49 lost, 0 added; REG's 203 are all back on 1.6.2's target,
imports into the test stubs go 609 to 0 and into ReactFeatureFlags.d.ts 94 to 0,
and none of the 49 lost is a name 1.6.2 linked. kit: 146 moved from the
generated types/index.d.ts to the implementations. vue core: 27 moved to the
package entry. astro: 5 moved, 390 links through dependents kept. Byte-identical:
vscode's extensions (324,231 edges, 18,847 into src/vscode-dts), outline, dub,
excalidraw, starlight, taxonomy, and the controls gin and flask. Red to green:
13 of 17 new tests fail on main's source; each of the 12 safeguards fails a test
when it is disabled. On vue core a sync matches a fresh index edge for edge in
both directions. Index cost: CPU -2.1% on core, -0.6% on excalidraw, median of 5,
interleaved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 4499d00 into main Oct 31, 2026
@colbymchenry
colbymchenry deleted the claude/ambient-own-package branch October 31, 2026 20:38
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