Skip to content

fix(js): emit one node per exported destructured name (#2604) - #3552

Closed
ayushcodes10 wants to merge 6 commits into
Graphify-Labs:v8from
ayushcodes10:fix-2604-destructured-export-split
Closed

ayushcodes10 wants to merge 6 commits into
Graphify-Labs:v8from
ayushcodes10:fix-2604-destructured-export-split

Conversation

@ayushcodes10

Copy link
Copy Markdown
Contributor

Summary

Fixes #2604's "Class 2" (52/1657, 3%, of the reported dangling-endpoint edges — the smaller, genuinely-fixable half; the larger "Class 1" is a health-check reporting-classification request, not a bug, and is left out of scope here).

export const { auth, handlers, signIn, signOut } = NextAuth(config) is NextAuth v5's own documented boilerplate, and next-intl's createNavigation() follows the exact same shape. The extractor collapsed the whole destructuring pattern into one combined node — id joining every destructured name (stem_auth_handlers_signin_signout), label the literal pattern syntax ("{ auth, handlers, signIn, signOut }") — so a single-name import elsewhere (import { auth } from './auth') could never find a node named just auth, and the import edge (plus any call resolved through it) dangled.

Each shorthand and renamed property in the pattern now emits its own node, sharing the statement's line — exactly what the export would have produced had it been written as N separate export const auth = ... statements. A renamed property (handlers: h) exports under its key (handlers), since that's what an importer binds by, never the local alias. A rest element (...rest) or a default-valued entry doesn't correspond to one discrete exported name and is skipped rather than guessed at.

Caught and fixed a real regression during testing, not after: the split is gated to only fire on an actually-exported statement. An un-exported destructure of the identical shape — const { doWork } = require('./lib'), the ordinary CommonJS import pattern — is a local import binding, not a module export. My first pass didn't have this gate, and running the existing test suite immediately caught it breaking test_cross_file_call_promoted_to_extracted_with_import_evidence: giving the import binding a bare-named node in the importing file made it collide with the real definition it merely imports, turning a clean single-candidate cross-file resolution into a false "ambiguous name" and silently losing the real calls edge. Fixed before ever landing a broken commit — see the commit history for how this played out.

Test plan

  • New tests/test_js_destructured_export.py, 5 tests: the core per-name split; cross-file import + call resolution through a split node; renamed-property key-not-alias naming; rest-pattern skip without breaking siblings; and a dedicated regression guard reproducing the exact require()-collision interaction the fix had to be gated against.
  • Verified the 4 split-behavior tests fail against the pre-fix code and pass with the fix; the regression-guard test passes both before and after (it pins behavior that already worked and that the gate keeps working).
  • Ran the full existing JS/TS + test_extract.py suite (416 tests) — all pass, no regressions, including the specific test my first ungated attempt broke.
  • Full suite: python3 -m pytest -q — 5491 passed, only the pre-existing unrelated failures (test_ollama_retry_cap.py missing openai in this env, one flaky timing assertion in test_ts_import_type_arguments.py).
  • python3 -m tools.skillgen --check — OK.

🤖 Generated with Claude Code

https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

ayushcodes10 and others added 6 commits September 14, 2026 14:45
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 Graphify-Labs#2604's
class 2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Four destructured names on one export statement must each get their
own node, and the old combined pattern syntax label must not survive
anywhere in the output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
A single name import must resolve to its own split node, and a call
made through it must resolve too, not just the import edge on its
own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
A renamed destructured property must export under its property key,
never under the local alias it is bound to, since that alias is not
what an importer would ever bind by.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
A rest element does not correspond to one discrete exported name, so
it must be skipped rather than mangled into a bogus node, and its
sibling names must still extract cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
Pins the exact interaction the fix commit already describes: a
CommonJS const equals require of a module destructure must still
resolve to exactly one EXTRACTED calls edge, not zero, confirming
the export only gate keeps it out of the per name split entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Emits one node per exported name for an exported object-destructure like export const { auth, handlers } = NextAuth(config) in _js_extra_walk, each on the statement's line, so a single-name import (import { auth }) resolves to its own node instead of dangling against the old combined _auth_handlers node. Renamed properties (handlers: h) export under their key, while rest/default-valued patterns are skipped rather than mangled. Gates the split on is_exported so an un-exported const { doWork } = require(...) stays a local import binding and doesn't collide with the real definition it imports.

Worth a look

  • const_found may be undefined in new object_pattern branchgraphify/extractors/engine.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Aliased destructuring exports the local binding, not the object keygraphify/extractors/engine.py:2599 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Default-valued destructuring export is skipped entirelygraphify/extractors/engine.py:2601 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Aliased destructuring exports the property key instead of the declared bindinggraphify/extractors/engine.py:2602 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Default and rest destructuring bindings are skipped as exportsgraphify/extractors/engine.py:2606 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 667 functions depend on the 223 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 26 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 29 callers, 3 callees
  • new: extract_vue() — 10 callers, 7 callees
  • new: walk() — 1 callers, 59 callees
  • …and 8 more — each is listed as a finding

Verification — 667 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 607 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

23 of 276 test file(s) selected (8%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_destructured_export.py — impact, changed-test
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/test_vue_extraction.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify \_js\_extra\_walk.

The verifier did not have enough to check \_js\_extra\_walk, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)

· 16 more finding(s) on lines outside this diff (see the check run).

@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.62 (now on PyPI: uv tool install graphifyy==0.9.62). Your commits were cherry-picked with authorship preserved, so this shows up under your GitHub contributions. Thanks @ayushcodes10!

@safishamsi safishamsi closed this Sep 15, 2026
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.

JS/TS extractor produces a large number of dangling-endpoint edges for external imports + destructuring exports

2 participants