repowise-dev / repowise-dev/repowise

Bug: Dynamic ESM Destructuring Emits Wildcard Instead of Named Bindings

Open
#2,230 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug help wanted
Dominant language
Python
Stars
6.7k
Forks
711
Avg merge
1d 13h
Merged PRs (30d)
439

Description

Describe the Bug

When a TypeScript or JavaScript file uses a dynamic ESM import with destructuring:

const { fn, calculate } = await import('./mod');

the parser emits imported_names = ["*"] (namespace wildcard) and bindings = []
instead of the correct imported_names = ["fn", "calculate"] with matching NamedBinding
objects.

Because "*" tells the dead-code analyzer to treat every exported symbol in the
target file as live, any exports in ./mod that are genuinely unused will never be
flagged. This creates false negatives in unused_export dead-code findings for
every module that is dynamically imported with destructuring.


Steps to Reproduce

  1. Add the following test to tests/unit/ingestion/parser/test_typescript.py:
def test_dynamic_import_destructuring_currently_broken(parser: ASTParser) -> None:
    src = b"""
    async function load() {
        const { fn, calculate } = await import('./mod');
    }
    """
    fi = _make_file_info("src/app.ts", "typescript")
    result = parser.parse_file(fi, src)
    imp = next(i for i in result.imports if i.module_path == "./mod")

    assert imp.imported_names == ["fn", "calculate"]
    assert {b.exported_name for b in imp.bindings} == {"fn", "calculate"}
  1. Run:
pytest tests/unit/ingestion/parser/test_typescript.py::test_dynamic_import_destructuring_currently_broken -v
  1. See assertion failure.

Expected Behavior

For const { fn, calculate } = await import('./mod'), the parser should emit:

imported_names = ["fn", "calculate"]
bindings = [
    NamedBinding(local_name="fn",       exported_name="fn"),
    NamedBinding(local_name="calculate", exported_name="calculate"),
]

This is identical to the behaviour already produced for the static equivalent:

import { fn, calculate } from './mod';
// → imported_names = ['fn', 'calculate']  ✅

and for CommonJS destructuring:

const { fn, calculate } = require('./mod');
// → imported_names = ['fn', 'calculate']  ✅

Actual Behavior

FAILED — AssertionError: Expected ['fn', 'calculate'] but got ['*'].

The parser hits the hardcoded wildcard branch in parser.py:L1918–1935:

# parser.py:L1928 — always emits wildcard, never reads the destructuring LHS
imported_names=["*"],
bindings=[],

The tree-sitter query in queries/typescript.scm captures the inner call_expression
(import('./mod')) as @import.statement. The destructuring LHS { fn, calculate }
lives in the parent variable_declarator node, which the parser never inspects.

Downstream effect in the UI

On the File Detail page's "Depends on" tab, the edge to ./mod shows * under
the file name instead of listing specific imported symbols. More critically, no
unused_export findings are raised for genuinely unused exports in ./mod, even
when only one symbol out of many was destructured.


Environment

  • OS: Windows 11
  • Python version: 3.12
  • Repowise version: 0.1.2 (repowise --version)
  • Installation method: pip (monorepo dev install via .venv)

Fix location & Scope

The fix is a parent-node walk inside parser.py:L1918–1935. After detecting the
dynamic import() call, walk up through any await_expression to find a
variable_declarator; if its name field is an object_pattern, extract the
property names exactly as _extract_require_bindings() already does in
extractors/bindings/ts_js.py:L11–67. This must also correctly handle aliased destructuring (e.g., const { fn: myFn } = await import(...)).

⚠️ Intentional Wildcard Exclusions

While destructuring should extract named bindings, the following forms MUST intentionally remain as wildcards (["*"]):

  1. Bare module assignments (const mod = await import('./mod'))
  2. Lazy route callbacks (() => import('./views/Profile'))

In these cases, the developer is deliberately capturing or forwarding the entire module namespace object at runtime. Emitting a wildcard ["*"] is strictly required here to prevent the dead-code analyzer from aggressively generating false positives.

Out of Scope

Forms not covered by the simple fix (out of scope for this issue):

  • import('./mod').then(({ fn }) => ...) — binding is in a callback parameter
  • const { fn } = await import('./mod').then(x => x) — chained call in between

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Run the proposed regression test in tests/unit/ingestion/parser/test_typescript.py, then read parser.py:L1918–1935, queries/typescript.scm, and _extract_require_bindings() in extractors/bindings/ts_js.py. Done means destructured dynamic imports report named bindings, including aliases, while bare assignments and lazy route callbacks remain wildcards.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, python, typescript
Domain
devtools
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.