From fd9b1cdb401a125a26b1b4f76288526ffdb7addc Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 14:45:59 +0530 Subject: [PATCH] Emit one node per exported destructured name export const { auth, handlers, signIn, signOut } = NextAuth(config) is NextAuth version five's own documented boilerplate, and createNavigation from next intl follows the exact same shape. The extractor collapsed the whole pattern into one combined node, id joining every destructured name and label the literal pattern syntax, so a single name import elsewhere could never find a node named just auth and the edge, and any call resolved through it, dangled. Each shorthand and renamed property in the pattern now becomes its own node sharing the statement's line, exactly what the export would have produced had it been written as N separate statements. A renamed property exports under its key, since that is what an importer binds by, never its local alias. A rest element or a default valued entry does not correspond to one discrete exported name and is skipped rather than guessed at. The split only applies to an actually exported statement. An unexported destructure of the identical shape, const doWork equals require of lib being the common one, is a local import binding, not a module export, and giving it a bare named node in the importing file would collide with the real definition it only imports, turning a clean resolution into a false ambiguous name. Caught this against the existing suite before it ever reached a commit. Fixes #2604's class 2. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh (cherry picked from commit b1614da58aa29db8262acaa8a03b2bbca4a9867c) --- graphify/extractors/engine.py | 52 ++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 54d51110f..0ffb14aa4 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2580,7 +2580,57 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, ): # Simple exported identifiers are part of the module API # regardless of initializer shape. Keep other scalar noise suppressed. - if name_node: + if name_node is not None and name_node.type == "object_pattern" and is_exported: + # #2604 Class 2: `export const { auth, handlers } = + # NextAuth(config)` (NextAuth v5's own documented + # boilerplate; next-intl's createNavigation() is the + # same shape) used to fall through to the generic + # branch below, which reads the WHOLE pattern as one + # combined node -- id `..._auth_handlers`, label + # "{ auth, handlers }". A single-name import + # (`import { auth } from './auth'`) looks for a node + # named `auth` alone, which never exists, so the + # import edge dangles. Emit one node per exported + # name instead, all sharing this statement's line, so + # each import resolves to its own node the way it + # would if the export had been written as N separate + # `export const auth = ...` statements. + # + # Gated on `is_exported`: an UN-exported destructure + # of the same shape (`const { doWork } = require(...)`) + # is a LOCAL import binding, not a module export. The + # old combined-node label ("{ doWork }") never matched + # the bare-name index cross-file resolution builds, so + # it was silently inert; giving it a bare-name node + # here (id `caller_dowork`, label "doWork") would make + # it collide with the real `doWork` definition it is + # ONLY importing, turning a clean single-candidate + # resolution into a false "ambiguous name" and losing + # the real calls edge (caught by + # test_cross_file_call_promoted_to_extracted_with_import_evidence). + line = child.start_point[0] + 1 + for prop in name_node.named_children: + if prop.type == "shorthand_property_identifier_pattern": + export_name = _read_text(prop, source) + elif prop.type == "pair_pattern": + # `handlers: h` exports as `handlers` (the + # key) -- `import { handlers }` binds by the + # property name, never by the local alias. + key_node = prop.child_by_field_name("key") + export_name = _read_text(key_node, source) if key_node else None + else: + # `...rest` and default-valued patterns + # (`{ auth = fallback }`) don't correspond to + # one discrete exported name; skip rather + # than guess. + export_name = None + if not export_name or not normalize_id(export_name): + continue + prop_nid = _make_id(stem, export_name) + add_node_fn(prop_nid, export_name, line) + add_edge_fn(file_nid, prop_nid, "contains", line) + const_found = True + elif name_node: const_name = _read_text(name_node, source) line = child.start_point[0] + 1 const_nid = _make_id(stem, const_name)