repowise-dev / repowise-dev/repowise
Bug: Dynamic ESM Destructuring Emits Wildcard Instead of Named Bindings
Nobody has claimed this yet.
- 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
- 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"}
- Run:
pytest tests/unit/ingestion/parser/test_typescript.py::test_dynamic_import_destructuring_currently_broken -v
- 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 (["*"]):
- Bare module assignments (
const mod = await import('./mod')) - 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 parameterconst { fn } = await import('./mod').then(x => x)— chained call in between
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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