Skip to content

fix(js,ts): an import of a package the repository declares itself links to that declaration - #2718

Merged
colbymchenry merged 1 commit into
mainfrom
claude/focused-torvalds-c6c900
Oct 20, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/focused-torvalds-c6c900

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

An import of a package the repository declares itself, with declare module '<name>' { … } in a script .d.ts (a declaration file with no top-level import or export), now links to that declaration. For example, cord-field's typings/mammoth/index.d.ts and VS Code's own src/vscode-dts/vscode.d.ts. TypeScript's checker looks a specifier up among the ambient modules before it resolves the specifier to a file, so import { MammothOptions } from 'mammoth' means the repository's declaration, whatever the package ships.

Before this change, nothing mapped a package name to a file in the repository:

  • The import resolver followed paths and aliases only.
  • The out-of-repo guard declined every name a file binds from a package its package.json declares.

So these imports linked nothing. Or, where the guard didn't fire (VS Code's extensions don't list vscode as a dependency), they linked by bare name to whichever namesake ranked first: copilot's vendored copies of VS Code internals, src/vs implementation classes, or test fixtures.

A function such a file declares without a body (export function convertToHtml(…): Promise<Result>;, a tree-sitter function_signature) produced no node, so convertToHtml had nothing to link to even when the import resolved.

Cause

  • resolveViaImport, resolveModuleImportToFile and jsModuleFile read a bare specifier only as a path to resolve. Members of declare module bodies have been isExported since fix(dead-code): a type that declare module or declare global merges outward is not dead #2418, but no lookup reached them by module name.
  • Neither engine extracted a function_signature. Skipping it everywhere was safe for overloads (function f(a: number): void; before function f(a) {…}), but it dropped every ambient function.

Fix

  • src/resolution/ambient-modules.ts (new):

    • scanAmbientModules reads a declaration file's declare module blocks with a small token lexer that skips strings, comments and template literals. For each block it records the line range, the default export (export default X, export default class X, export = X), the names its export clause gives (export { Hidden as Shown }) and whether the file is a script.
    • The per-context index covers the project's .d.ts files only.
    • findAmbientModuleExport binds a named, default or namespace-member import to the declaration at the block's own level, by position. Same-named declarations (overloads, an interface and a value) give the one the reference's kind means.
  • import-resolver.ts:

    • Ambient first. A bare specifier's script declaration is consulted before any file the specifier resolves to, as TypeScript does. Copilot maps vscode to a runtime shim through paths, but its src/extension/vscode-api.d.ts references src/vscode-dts/vscode.d.ts, and the checker takes the ambient module.
    • The import statement's module ref and a namespace or default binding link to the declaring file, as a relative import's do.
    • isBoundToOutOfRepoImport keeps the extends edge of a class whose base the repository declares.
  • Extraction (languages/typescript.ts isAmbientFunctionSignature, mirrored in tsjs/mod.rs). A bodiless function is a function node in these places:

    • under declare (declare function f(): T;);
    • in a declare global body;
    • at a declaration file's top level;
    • directly in a declaration file's declare module body.

    Two places are left out:

    • A namespace's function (declare namespace semver { function compare… }, napi's export declare namespace clipboards { function write… }, VS Code's window.showInformationMessage). It is only reached through the namespace, which the index doesn't model. On its own it was only a bare name for unrelated calls to land on: an early version of this PR linked bitwarden's isAvailable(m.format), a callback parameter, to napi's biometrics.isAvailable.
    • An ordinary .ts file's declare module body. That body is an augmentation (below).
  • resolution/index.ts: a function a declaration file's declare module declares is reached from another file only through the import resolver. It stays in the name matcher's candidate crowd, because reachability may reject a guess but never manufacture one (fix(resolution): fuzzy matching skips a nested function the reference cannot reach #1709).

  • name-matcher.ts: a type merged across the blocks that declare one module isn't treated as one declaration's when typing a receiver. VS Code's proposed API files add members to vscode.d.ts's interfaces, so copilot's this._progress.thinkingProgress() on a ChatResponseStream lives in vscode.proposed.chatParticipantAdditions.d.ts. Applies in tsImportedTypeFile, tsNamedType, tsNamedClass and call-result typing.

  • Sync (module-importers.ts reopenReferencesThroughDeclaredPackages, called from sync). When a sync adds or edits a declaration file that declares a module, the files importing that package get their failed references, and the edges bound before the sync, re-opened through those imports. They import the package by name, which no import edge into the file records, and an aliased, default or namespace binding waits under a name the declaration never carries.

Decisions (made without asking, as asked; open to change)

  • Augmentations don't bind. A declare module 'x' in a module file merges into the package's own module (happa's src/@types/grommet.d.ts, MUI theme files), and the import still names the package's declaration. Binding a package import to an in-repo fragment of its type would show the fragment as the definition. Finding augmentations would also mean reading every .ts file each resolution pass: all 16 of cord-field's are in .ts/.tsx theme and grid files.
  • Test and fixture declarations count only for test importers (isTestFile). Copilot keeps fixture copies of proposed VS Code typings that its tsconfig excludes. Before this rule they took 781 imports from the real ones by being nearer.
  • Several declaring files: the nearest one wins (a monorepo's apps each declaring the package).
  • Wildcard patterns (declare module '*.svg') and shorthands (declare module 'x';) bind nothing, as before.
  • tsOutsideTypeDeclaration is left alone. It is fix(ts): a call on a known class goes through that class's own bases #2656's receiver-typing check, "a file of the project declares this package and names the type", and it asks a looser question on purpose: it also reads Flow libdefs (flow-typed/npm/*.js) and augmentations, which this index doesn't. Converging the two would drop those.

Validation

Arms: dist snapshots of main 4286083a and this branch merged onto it (kernel built for each). Each repo got codegraph init -y per arm. Edges were diffed on natural keys and each removed edge was triaged at its site (source, kind, line, col). A first run on f5158fac gave the same results, apart from 33 more retargeted vscode sites that #1935 changed on main. #1926, which landed afterwards, is Python-only.

repo (commit) new nodes edges −/+ import into the block binding → declaring file module ref → declaring file onto a new function sites retargeted sites lost
microsoft/vscode b3d1e667 41 −9,982 / +12,492 8,422 1,986 731 27 7,736 5
bitwarden/clients ce5386b9 6 −0 / +378 0 8 362 2 0 0
home-assistant/frontend a321b07b 3 −0 / +75 27 20 22 2 0 0
SeedCompany/cord-field 066be79f 3 −0 / +58 7 21 21 0 0 0
outline/outline c9635289 2 −0 / +46 15 16 13 0 0 0
excalidraw/excalidraw 6f0a4d19 4 −10 / +28 3 3 3 0 0 2
giantswarm/happa 8b6aee80 6 −2 / +19 3 2 3 0 0 0

Byte-identical (nodes, edges and refs): LegendApp/legend-state, metabrainz/bookbrainz-site, hypha-dao/dho-web-client, ghostfolio/ghostfolio and mastodon/mastodon, whose declare module blocks are all augmentations or wildcards (9, 2, 2, 1 and 3 + 5 patterns). The controls without any: taniarascia/takenote, oldboyxx/jira_clone, nextjs/saas-starter.

The remaining added edges come from the new functions' own signatures (references to types the same .d.ts declares): 30 on vscode and 19 across the rest. Each new node also has its contains edge.

Triage

Precision. An edge in the "import into the block" column is, by construction, a declaration at the level of a script block whose specifier is exactly the import's. The diff script recomputes that from the target's file, using its own copy of the scanner, for every one of them. The same holds for the binding and module-ref columns: each target file declares that exact specifier.

vscode. Of the 7,736 sites whose edge now points elsewhere, 7,727 land in src/vscode-dts. 6,594 of those are imported from 'vscode'; the rest are member calls on values of those types and interface dispatch from them. Their old targets:

  • 6,444 in copilot: its vendored copies of VS Code internals (src/util/vs/...), and its paths shim vscodeTypesShim.ts, which its vscode-api.d.ts overrides for the type checker;
  • 1,210 in src/vs: implementation classes, and monaco.d.ts's Uri::joinPath for Uri.joinPath from 'vscode'. import type * as vscode from 'vscode' in src/vs/workbench/api now links vscode.d.ts instead of a constant named vscode in languageModelToolsService.ts;
  • 73 in other extensions' namesakes.

The other 9 sites are corrections too:

  • emmet's typings import BufferStream / HtmlNode from 'EmmetNode'. They bound by name to EmmetFlatNode.d.ts and now bind to EmmetNode.d.ts.
  • testRunner.configure bound to a workbench NullTestProfileService::configure and now binds to the imported test/integration/electron/testrunner.d.ts.
  • Two interface-dispatch edges inside a copilot test fixture changed implementations.

The 5 sites that lost their edge were all wrong on main:

  • copilot's own TelemetrySender constructor was dispatched to implementations of VS Code's same-named interface (2 edges);
  • /\w$/.test(…) bound to a CurrentTestRunInfo.test property;
  • html-language-features' own FileSystemProvider.stat / readDirectory fanned out to copilot's NewWorkspacePreviewFileSystemProvider.

The 2,241 other removed edges are the same edge with new metadata (exact-match → import).

Onto new functions (vscode 27, frontend 2, bitwarden 2):

  • testRunner.configure from the imported testrunner.d.ts (16);
  • same-file declare functions: acquireVsCodeApi, requestIdleCallback, __import;
  • calls of globals the repository declares itself in a script .d.ts: frontend's AudioWorklet registerProcessor and service-worker skipWaiting, bitwarden's unescape (its apps' globals.d.ts each declare it);
  • clearImmediate in three language servers, which reach the global clearImmediate declared in php-language-features' node.additions.d.ts.

None is a module's function reached by bare name: the gate holds. An earlier version without the namespace rule and the gate linked 14 such calls on vscode: getExtension, createStatusBarItem, semver's compare, sort and patch, and a verify declared in an ordinary .ts file's declare module. It also linked bitwarden's isAvailable(m.format), a parameter call, to napi's namespace function.

excalidraw. Its packages/excalidraw/global.d.ts now has functions (encode, decode, extract of the png-chunks-* packages). They count in the name matcher's crowd, as everything does (#1709):

  • 2 wrong edges (new TextEncoder().encode(data) → the project's encode) decline;
  • 8 calls keep their target at confidence 0.4 instead of 0.9.

Measured on the f5158fac base, before the merge of #1935 and three Rust PRs:

Sync convergence on real repos (sync-check.sh):

  • happa: 3 src/@types/*.d.ts deleted and committed, indexed, restored, then codegraph sync;
  • cord-field: 4 typings/**/*.d.ts, same procedure. The new step re-opened 46 references there that the name-based retry can't find.

Both synced graphs are byte-identical on nodes and edges to a fresh index of the same tree.

Kernel parity (scripts/kernel-parity.mjs): 214/214 declaration-heavy files at byte parity (vscode's src/vscode-dts, bitwarden's napi, the typings above). codegraph's own src/ and outline + happa have 0 diffs, with the same deferral counts as main (3 and 122).

Tests

__tests__/ambient-module-imports.test.ts (new, 30 tests):

  • Scanner: block ranges, script vs module (import x = N.y and import('y') types are not module markers), default names, export clauses, braces in strings, comments and template literal types, CRLF.

  • Extraction, kernel and wasm, LF and CRLF, plus a parity check: ambient functions under declare and in declare global; none in a namespace or in an ordinary module file's declare module; a declaration file's top level and declare module body; tsx.

  • Resolution on indexed fixtures:

    • named, default and export = imports, export clauses, overloads, aliased imports, namespace members, extends, new;
    • a project namesake never taken;
    • augmentation, wildcard, shorthand and undeclared names left unbound;
    • the nearest of two apps' declarations;
    • a catch-all paths alias mapping the package to its typings folder;
    • the bare-name gate (excalidraw's encode);
    • a test fixture's copy ignored;
    • ambient-first over a paths shim;
    • members of an interface merged across files, through a namespace import and through a named type import.
  • Sync: the declaration file added after its importers, and an edit giving it its declarations, each compared with a fresh index.

  • Red run on main: 24 of the 26 non-scanner tests fail; the scanner tests exercise the new module. The two that pass on main are the kernel/wasm parity check, where neither engine has the nodes, and the merged-declaration case. Main gets that one right only because it never resolves the import; the test guards against this change breaking it (see Teeth).

  • Teeth: disabling each safeguard in turn (gate, fixture rule, ambient-first, the merged-declaration rule in tsImportedTypeFile and in tsNamedType, the sync step) fails exactly its test. With the sync step disabled, the oracle comparison alone fails (34 vs 39 edges).

  • Full suite on this commit, on main 642c7bb3 (--maxWorkers=4, kernel built, CODEGRAPH_KERNEL_EXPECT=1): 8,111 tests, 8,029 passed, 0 failed, 82 skipped. tsc --noEmit is clean.

Cost

Interleaved runs of codegraph init -y, process CPU time including the worker threads:

  • happa: median of 3, main 23.9 s → this branch 24.2 s;
  • outline: median of 3, 58.7 s → 58.2 s;
  • vscode: 2 runs each, main 858 / 809 s → 809 / 834 s. The runs overlap.

Wall time is unchanged within noise. The index reads only the declaration files that contain declare module, once per resolver context. The bare-name gate is one set lookup per reference. The per-file and per-node answers are memoized: a first version without the memos cost happa about 5%.

Follow-ups (each its own PR, next)

🤖 Generated with Claude Code

…ks to that declaration

A `declare module 'mammoth' { ... }` in a script .d.ts declares the module
outright, and TypeScript takes it before any file the specifier resolves to.
Nothing mapped a package name to a file in the repository, so an import of
such a package linked nothing, or, where the out-of-repo guard did not fire
(VS Code's extensions list no `vscode` dependency), a project namesake. A
bodiless function in an ambient context produced no node either, so
`convertToHtml` had nothing to link to.

- ambient-modules.ts: a token scanner for `declare module` blocks, a
  per-context index of the project's declaration files, and the lookup the
  import resolver makes: named, default (`export default`, `export =`),
  export-clause and namespace-member imports, by position in the block.
- The import resolver consults a script declaration before a file a path
  alias maps the specifier to (copilot's `paths` shim), and the import's
  module reference and namespace or default binding link the declaring file.
- Extraction, both engines: a function_signature under `declare`, in
  `declare global`, at a declaration file's top level or directly in its
  `declare module` body is a function; never in a namespace, nor in an
  ordinary module file's `declare module`, an augmentation.
- A module's function is reached from another file only through an import.
- A type merged across the files declaring one module is no single
  declaration when a receiver is typed.
- Sync re-opens the importers of a package a changed declaration file
  declares.

Augmentations, wildcard patterns, shorthands and test-fixture declarations
are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit a33040f into main Oct 20, 2026
@colbymchenry
colbymchenry deleted the claude/focused-torvalds-c6c900 branch October 20, 2026 21:35
colbymchenry added a commit that referenced this pull request Oct 21, 2026
…ts module (#2728)

A declaration file's declare module 'x' { ... } declares the members of
x, which no other file names by a bare name: an import of x reaches them.
The functions of such a block have been held to that since #2718, but its
other declarations, the types, classes, constants and enums, still took
bare-name matches from any file. On vscode, Node's
import { readFileSync } from 'fs' linked to the fs that vscode.d.ts's
workspace namespace declares, electron's clipboard and shell to env's, a
ProcessEnv to the proposed API's, and an LSP TextDocument re-exported
through a project barrel to the API's own.

Every block-level declaration now takes a match from another file only
when the reference is written in a block declaring the same module (the
proposed API files merge into vscode.d.ts), is a type position in a file
that imports the module (vscode.TextDocument under a namespace import,
which the extractor records by its last name), or names what the file
imports from a project file that imports the module and re-exports it
(copilot's vscodeTypes.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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