Skip to content

fix(js,ts): a default import is what export default declares, not the first exported function - #2432

Merged
colbymchenry merged 6 commits into
mainfrom
claude/hopeful-hodgkin-156996
Oct 10, 2026
Merged

colbymchenry merged 6 commits into
mainfrom
claude/hopeful-hodgkin-156996

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

A JS/TS default import resolved to the wrong symbol whenever the module exported a function above its export default function X() / export default class X. findExportedSymbolWalk's default branch took defaultComponent ?? defaultExportBindingNode(...) ?? defaultFnClass:

  • defaultExportBindingNode only read a separate export default NAME statement, and
  • defaultFnClass is the file's first exported function or class (ORDER BY start_line).

A React Router 6.4+ data-router page exports its loader/action above the page component, so in the reported repro (BlazejBatko/react-router-v6-learning-project) import Vans, { loader as vansLoader } from "./pages/Vans/Vans" bound Vans to loader. On main /vans, /vans/:id, /host, /host/vans and the three HostVanDetail layout edges all pointed at loader, and /login at action.

Two related guesses failed the same way:

  • defaultComponent (the first exported component node) beat the statement, so a .tsx file exporting a styled/memo component lost its default function or binding to that component.
  • An anonymous default (export default function () {…}, export default class {…}, export default () => …) took whatever exported function came first, including one nested inside the default itself, because the extractor's isExported walks the whole parent chain.

The fix

statedDefaultExport (in src/resolution/import-resolver.ts) reads the file's own export default statement and replaces the old binding-only reader:

Comments are stripped before scanning, but strings are deliberately not masked. blankStringContents gets out of step on a template whose ${…} holds another template or a regex containing a backtick. In validation it blanked the real statement in 6 files across outline/excalidraw/folo/dokploy: outline's headingToSlug.ts and its 2,900-line styled-components Styles.ts, where the fallback guess then produced a wrong Styles → pulse edge. A statement now has to name a node of the file to count, so template text that merely says export default function X names nothing. Of several statements, one that names a node wins.

Kernel

There is no parallel path to keep in step. codegraph-kernel only extracts; import resolution is TS-only. The change reads the nodes both extractors emit, so I checked that they emit the same ones for every shape involved: 31 default-export shapes (named/async/generator/class/abstract/decorated/anonymous/arrow/multiline/overloads/in-comment/in-template/styled-first, …) are 31/31 byte-parity under scripts/kernel-parity.mjs. The new test passes with the kernel (CODEGRAPH_KERNEL_EXPECT=1) and on wasm (CODEGRAPH_KERNEL=0).

Tests

__tests__/default-export-declaration.test.ts (new), red on main (4 of 5 failing) → green:

  • vanlife-shaped data router: /vans, /host/vans (an export const loader = async () => arrow), /login (export async function action) bind to the page components.
  • calls through default imports reach export default async function handler (Next.js API route), export default class Store, export default function* stream written across a line break, and headingToSlug below a regex-with-backtick template (fails if strings are masked).
  • the declared default and an export default Banner binding beat an exported styled component.
  • anonymous function/arrow defaults link to nothing, not to helper/inner/useThing/handle.
  • control: export default new Service() still reaches Service.run (passes on main too).

On the branch merged with main, the default-export and resolution suites pass with the kernel staged: namespace-object-resolution, esm-unexported-binding, store-exported-later, react-router, react-router-nested-lazy, expo-router, nextjs, extraction, resolution and kernel-tsjs-parity, 1,238 tests. tsc --noEmit is clean.

The full parallel suite ran on a saturated machine (100% CPU, 43 node processes from other sessions; 25 minutes). It reported 734 failures in 181 files. 722 were timeouts, 6 were EBUSY/EPERM teardown errors, and 6 were timing assertions in daemon, watchdog, writer-lock and MCP-lifecycle tests (e.g. expected 100.36 to be less than 100 ms). A serial rerun of those 181 files (--no-file-parallelism --testTimeout=120000, 94 minutes at the same load) left 7 failures, all timing or teardown: function-ref's #1820 case (its own 60 s limit), orphaned-refs-sweep (its own 15 s limit), mcp-subproject-adoption (a 5 s handshake timer), mcp-writer-lock (a liveness check read unverified), mpeg-ts-not-typescript (2359 < 2000 ms), ui-server-api (156.8 < 100 ms) and daemon-pid-reuse (EBUSY removing its temp dir). Run alone, all 7 pass; the two in-file limits were raised for that run only.

Validation (before/after scripts/dump-graph.mjs diffs)

Arms: main 2f2afea vs main + this fix, same kernel, codegraph init -y per repo. After merging main up to ed199e6 (#2413 and #2417, C++/Go only), vanlife, epic-stack and hyperdx re-index byte-identical to the pre-merge after arm.

repo shape edges re-resolved removed added refs now resolving / now unresolved
BlazejBatko/react-router-v6-learning-project (vanlife, the repro) data router 8 0 0 0 / 0
remix-run/react-router examples/ @6.29.0 data router (notes) 3 0 0 0 / 0
epicweb-dev/epic-stack Remix / RR7 framework 1 0 0 0 / 0
hyperdxio/hyperdx Next.js pages router 73 3 0 0 / 3
outline/outline React + Koa 230 13 35 23 / 13
react-boilerplate/react-boilerplate React + redux-saga 18 8 0 0 / 8
folo (RSSNext/Folo) Electron + Expo 0 1 0 0 / 1
binner (replaysMike/Binner) React Router v6 8 (same targets) 0 0 0 / 0
remix-run/indie-stack, reactjs/react.dev (Next.js pages), Dokploy/dokploy, excalidraw, berry, redux-realworld, refine examples/ identical
controls: bradtraversy/proshop_mern, leerob/next-saas-starter, t3-oss/create-t3-turbo byte-identical

Every one of the 341 re-resolutions was checked against an independent oracle (a raw-source regex for the target file's export default name, no resolver code): 316 land on the file's declared default, 2 on members of a default class (outline ToggleBlock.isEmpty / isHeadEmpty, previously toggleStorageKey), and 23 keep the same target with new metadata. Of those 23, 15 are upgrades: outline/hyperdx function refs that had fallen back to name matching at 0.8, and 4 client.userInfo() calls in outline's OAuth test at 0.7, now resolve through the import at 0.9, because a styled/memo component or a wrong guess no longer intercepts the default. The other 8 are binner downgrades (below). None were unexplained.

Highlights: vanlife's 5 routes + 3 layouts and rr-examples' notes route + 2 layouts reach the page (Vans, Login, VanDetail, Dashboard, HostVans, HostVanDetail, Root) instead of loader/action. epic-stack's test import { default as UsernameRoute, loader } (a default import since #2412) reaches ProfileRoute. hyperdx's useSidePanelStack ×30, useRowWhere ×21, CheckAlertTask ×9 and ProvisionDashboardsTask ×7, and outline's Relation ×56, pagination ×45, lazyWithRetry ×36 and documentCreator ×18, previously all went to the file's first exported helper. On main, outline's POST documents.create route had no edge to documentCreator: its call was guessed as authorizeDocumentCreate and merged into the route's own direct call to that function.

Every removed edge (25), each a wrong edge the guess made:

  • react-boilerplate (8): injectReducer(...) / injectSaga(...) (×2 copies, app + internals template) are anonymous HOC factories, export default ({ key, reducer }) => WrappedComponent => { class ReducerInjector … }. The calls were recorded as instantiates of the class declared inside the factory. They are now unresolved.
  • hyperdx (3): rateLimiter(...) (export default (config?: Partial<Options>) => rateLimit(...)) went to rateLimiterKeyGenerator. Now unresolved.
  • folo (1): the test's createExpoConfig(...) (export default ({ config }: ConfigContext): ExpoConfig => …) went to resolveRuntimeVersion, which the same test imports separately by name. Now unresolved.
  • outline (13): 11 function refs to classes used as values, editor extension lists like [CodeFence, Image, ToggleBlock] plus DeliverWebhookTask. They went to an unrelated exported helper (expandCodeBlockAt, downloadImageNode, toggleStorageKey, isExpectedNetworkError). The import now resolves to the class, which a TS function ref deliberately doesn't target (bareClassOk is Python-only), so they are unresolved. The other 2: calls to markRule, export default function (options) {…}, went to emphasisPlugin.

Added edges (35, all outline): 7 extends that failed on main (OAuthClient ×5 including the test's subclass, CodeFence, MarkdownAPIImportTask; the guess returned a function, which extends rejects), the 11 interface-impl override calls those extends edges enable, 16 function refs to Button/ListItem that resolve through the import now, and documents.create → documentCreator.

binner: export default (props) => <Search {...props} params={useParams()} … /> is an anonymous wrapper around the file's own export function Search. Main's first-exported guess happened to land on Search. Now the route is bound by name to the same node (exact-match 0.5 instead of import 0.9). A survey of ~35 local React repos found this forwarding-wrapper shape only in binner's 6 page files, so I left the anonymous rule as specified rather than add a look-through here.

Merged with #2433, re-validated on main cd97bb3

#2433 (require('./x').default) landed first and built its fallback on the old guess chain (esmDefaultExport → defaultExportBindingNode → first exported function). The merge keeps #2433's gates (hasEsmDefaultExport, depth 0, JS family) and has esmDefaultExport read the export default statement first, so a require().default reaches the declared default too. The binding-only reader is gone. require-default, import-default-as and this PR's tests pass on both the kernel and wasm paths. The JS/TS suites that landed since (catch-all alias, import-binding sync, lazy barrels, JSX disambiguation, HOC, kernel parity and others) pass 569/569 with the kernel rebuilt.

The A/B was re-run on main cd97bb3 against main + this PR, adding #2433's react-native and topcoder (that session's survey found this PR's shape in 7 react-native and 4 topcoder files):

  • The 18 repos above give byte-identical change sets to the first run.
  • react-native: 137 re-resolved (111 to the declared default, 18 to members of a default class, 8 same-target upgrades from name matching at 0.7/0.8 to the import at 0.9, all createAnimatedComponent). 4 removed: function refs to export default class AnimatedValue/AnimatedColor used as values, which the guess had sent to flushValue/getRgbaValueAndNativeColor.
  • topcoder: 3 re-resolved to the declared default. 2 removed: LivePreview (export default class ThriveArticleLivePreview at L152) sent to isThriveLivePreview, and createCustomPlugin(...), an anonymous export default ({ editor }) => {…}, sent to updateEntityData, a function nested inside it.

Overlap with open PRs

Not in this PR

🤖 Generated with Claude Code

colbymchenry and others added 4 commits October 7, 2026 04:38
…he first exported function

A default import resolved to the module's first exported function or
component whenever the `export default` statement wrote a declaration
(`export default function Vans()`, `export default class Store`): only a
separate `export default NAME` statement was read. A React Router 6.4+
data-router page exports its `loader` or `action` above the page
component, so `import Vans, { loader as vansLoader } from './Vans'` bound
the route `/vans` (and every call through `Vans`) to `loader`.

The resolver now reads the file's own `export default` statement, with
comments and strings masked: a declaration it writes is found by
position, a binding it names by name, and an anonymous function, class or
arrow function is no node at all (the guess took an exported function
beside it, or one nested inside it). The statement also beats an
exported styled/memo component above it. Only an expression default
(`memo(Card)`, `new Service()`), a binding the file doesn't declare, or
no statement keeps the old guess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode only

Masking strings too (blankStringContents) loses step on a template whose
`${…}` holds another template or a regex with a backtick, and blanks the
code up to the next backtick. On outline it blanked `export default
EditorContainer` at the end of a 2,900-line styled-components file and
`export default function headingToSlug` below such a regex, and the guess
that followed bound `Styles` to an unrelated exported `pulse`. Comments are
still stripped; a statement must name a node of the file, so template text
that only reads like one names nothing.

Adds the headingToSlug shape to the test, and the CHANGELOG entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 7, 2026
…ode is the module's

A template can hold statement-shaped text: react.dev's
SandpackWithHTMLOutput.tsx writes two sandbox files' `export default
function` lines above its own `export default memo(function ...)`, and
reading only the first line left the module's default import with
nothing. Every line-initial statement outside a block comment is now
read, the first that names a node wins, and a function or class a
statement declares binds only the node that starts there, so a
template's `export default function formatHTML` can't reach the file's
real formatHTML. The block-comment check reads the file once for all of
its statements.

Carries #2432's default-export tests unchanged (all pass), and adapts
its multi-statement and declaration-position rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-156996

# Conflicts:
#	CHANGELOG.md
#	src/resolution/import-resolver.ts
@colbymchenry
colbymchenry merged commit 33c1436 into main Oct 10, 2026
@colbymchenry
colbymchenry deleted the claude/hopeful-hodgkin-156996 branch October 10, 2026 04:51
bompus added a commit to bompus/codegraph that referenced this pull request Oct 10, 2026
…eceivers (#430)

* fix(context): call paths label synthesized hops like explore, not as events (colbymchenry#2428)

Two surfaces label a synthesized (dynamic-dispatch) `calls` hop:
codegraph_explore's Flow and dynamic-dispatch links plus the
codegraph_node trail (`dynamic: interface → impl @file:line`), and
ContextBuilder's "## Call paths", which `codegraph context` prints
(`→[…]`). Each kept its own list. The call paths' list stopped at
callback, react-render, jsx-render, vue-handler, http-client, queue-job
and socket messages, so every other synthesizer fell through to
`event ${m.event ?? ''}`: an interface-impl hop read
`→[event  @src/pdf-exporter.ts:5]`, and so did a C++ override, a redux
thunk, a gin middleware chain or a closure collection.

The wording now lives once, in src/graph/synthesized-hop.ts
(`describeSynthesizedHop`: a summary, the detail only the call paths
print, and the wiring site), as AGENTS.md asks of a derivation two
surfaces render. ToolHandler renders `dynamic: <summary> @site`, the
call paths `<summary><detail> @site`. Explore and the node trail print
exactly what they printed before; the call paths keep their wording for
the kinds they already labelled, including the queue name, a socket's
direction and the HTTP clause, and now name the rest the way explore
does. A bus event reads `bus event` there too, and the section's
footnote no longer says hops are marked `[callback/event …]`.

The helper also holds the wording colbymchenry#2414 gives a Go method a struct gets
by embedding (`metadata.promotedInto`), so that arm moves here too. The
unused long `label` form of ToolHandler.synthEdgeNote (last read by the
removed codegraph_trace) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges (colbymchenry#2452)

A route that renders `const Docs = lazy(() => import('./pages/Docs'))` binds
to the component that module exports, and to the declaration while the module
is missing or exports none (colbymchenry#2400's declaredComponent). The reference had
resolved, so no failed-ref retry saw it, and CG-33's rebind reached it only
when the module's component shared the declaration's name: a sync that added
the module, gave it its default export or moved that export left the route
on its old answer. A data router's `lazy` route kept a moved default export
the same way.

FrameworkResolver.lazyModules names the modules a route's answer reads,
including the files a barrel forwards it through (colbymchenry#2436); React implements it
for lazy-import references and the same-file lazy values Pattern 1 reads.
After the CG-33 rebind, sync puts back the route references whose modules it
added or changed (reopenRouteModuleReaders), in the order their edges were
written, for the orphan sweep.

The jsx-render refresh read only each changed file's own content, so a new
component without JSX of its own never got the edge from `<Team />` in an
unchanged file. A sync whose definition delta holds a JSX-renderable node now
refreshes synthesis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): a range-based for loop's variable has the type the loop declares (colbymchenry#2439)

C++ receiver inference read a declaration only when the declared name was
followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so a range-based for's
`Type name :` was never read. In protocolbuffers/protobuf's conformance
runner, `suite->SetVerbose(…)`, `suite->RunSuite(…)` and
`suite->GetFailureListFlagName()` inside `for (ConformanceTestSuite *suite :
suites)` fell through to Strategy 3's guess by the receiver's name, and in
RocksDB `log->file()` on a `log::Writer` loop variable went to
`BlobLogWriter::file`.

A line the declarator regex doesn't match is now read as a range-based for
when its `for (` header declares the receiver before a single `:` (not
`::`), and the loop's variable is taken only when the call is inside the
loop's body: past the header, up to the `}` closing a braced body or the end
of a single-statement body, with comments and literals skipped. After the
loop, or in a later function, the same name is another variable, and the
scan goes on as before. A loop over `auto` elements has nothing to read and
keeps the existing fallback. Bit-fields and the scope operator are never
read as a loop's declaration.

A loop's declaration is recorded like any other (colbymchenry#2413's
noteCppDeclaration), so the std-type gate applies to
`for (const std::string& s : …)` too, and a loop that doesn't enclose the
call still marks the receiver as shadowed. With colbymchenry#2421, the 13 conformance
runner calls reach ConformanceTestSuite's methods at 0.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js,ts): a default import is what `export default` declares, not the first exported function (colbymchenry#2432)

A default import resolved to the module's first exported function or
component whenever the `export default` statement wrote a declaration
(`export default function Vans()`, `export default class Store`): only a
separate `export default NAME` statement was read. A React Router 6.4+
data-router page exports its `loader` or `action` above the page
component, so `import Vans, { loader as vansLoader } from './Vans'` bound
the route `/vans` (and every call through `Vans`) to `loader`.

The resolver now reads the file's own `export default` statement from
comment-stripped code. A declaration it writes is found by position, a
binding it names by name, and an anonymous function, class or arrow
function is no node at all (the guess took an exported function beside it,
or one nested inside it). The statement also beats an exported styled/memo
component above it. Every line-initial statement is read and the first one
that names a node of the file wins, so template text that only reads like
a statement names nothing. Strings are not masked: a masker loses step on a
template whose `${…}` holds another template or a regex with a backtick,
and on outline it blanked the real statement below one. Only an expression
default (`memo(Card)`, `new Service()`), a binding the file doesn't
declare, or no statement keeps the old guess.

`require('./x').default` (colbymchenry#2433) reads the same statement first, so it
reaches the declared default too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): a `->` call on a smart pointer or optional reaches the type it holds (colbymchenry#2440)

A C++ call made with `->` on a receiver the calling function or its class
declares as a `std::unique_ptr<T>`, `std::shared_ptr<T>` or
`std::optional<T>` now resolves on `T`: on the class C++ name lookup finds for
it where the call is written, or on a class that class derives from. Before,
receiver inference read the type as `unique_ptr`, found no method, and the
call fell through to the guess by method name: rocksdb's
`std::unique_ptr<Iterator> iter; iter->Valid()` reached
`ArenaWrappedDBIter::Valid`.

The held type goes through colbymchenry#2407's lookup (`cppClassWritten`, extracted from
`matchCppSupertype`), not `resolveMethodOnType` on its last name: rocksdb has
22 classes named `Iterator`, and the one the calls mean inherits its methods
from `IteratorBase`, so a last-name lookup reaches `MemTableRep::Iterator`.
The method is taken from that class or its bases through the class's own
extends edges (`getSupertypeNodes`). A template parameter, a library type or
an alias of one gets no edge; a type the project doesn't declare, or an alias
the lookup can't follow, leaves the call as it was.

The namespace-macro helpers move from name-matcher.ts to cpp-namespaces.ts
unchanged, so name-matcher can import cpp-supertypes without a cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): a barrel edited to forward another page moves the routes that lazily load it

---------

Co-authored-by: Colby Mchenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 10, 2026
…ed default (#2481)

#2432 made a default import read the module's own `export default`
statement, and its merge pointed #2433's `require('./x').default`
fallback (esmDefaultExport) at the same reading. Nothing in the suite
pinned that path with a guessable helper above the default: #2433's
fixture keeps its helper unexported.

Two cases:
- React Native's AnimatedColor shape, an exported helper above
  `export default class AnimatedColor`, loaded with
  `require("./AnimatedColor").default` and
  `const { default: Color } = require("./AnimatedColor")`. With the
  first-exported guess both `new` calls instantiate the helper.
- `export default abstract class Repository` below an exported
  `connect()`, extended by `class Users extends Repository`. With the
  guess the `extends` edge is missing.

Both fail with e47cb25's resolver (before #2432). On main, putting the
guess back on the require path fails only the first, and dropping
`abstract` from the declaration pattern fails only the second.

The CHANGELOG's default-import bullet now says `require('./x').default`
is read the same way.

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