-
-
Notifications
You must be signed in to change notification settings - Fork 11.3k
fix(ts): ignore non-runtime dynamic import text #3343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v8
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| import re | ||
| import sys | ||
| import textwrap | ||
| import warnings | ||
| from collections import Counter | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path, PurePath | ||
|
|
@@ -1366,6 +1367,55 @@ def extract_js(path: Path) -> dict: | |
| return result | ||
|
|
||
|
|
||
| def _js_runtime_import_starts(path: Path, source: bytes) -> set[int] | None: | ||
| """Return byte offsets of parsed runtime ``import(...)`` calls. | ||
|
|
||
| The rescue needs module-scope calls, not a second lexical parser. Parsing | ||
| the same source lets tree-sitter distinguish comments, strings, regexes, | ||
| and template text. TypeScript import types are excluded by their enclosing | ||
| type-only AST nodes because that grammar can represent ``typeof import('…')`` | ||
| as a call even though it is not runtime code. | ||
| Returns ``None`` when the validator cannot initialize or parse, so the | ||
| caller can preserve the pre-filter rescue behavior with a visible warning. | ||
| """ | ||
| try: | ||
| from tree_sitter import Language, Parser | ||
|
|
||
| suffix = path.suffix.lower() | ||
| if suffix == ".tsx": | ||
| module_name = "tree_sitter_typescript" | ||
| language_name = "language_tsx" | ||
| elif suffix in (".ts", ".mts", ".cts"): | ||
| module_name = "tree_sitter_typescript" | ||
| language_name = "language_typescript" | ||
| else: | ||
| module_name = "tree_sitter_javascript" | ||
| language_name = "language" | ||
| module = importlib.import_module(module_name) | ||
| language = Language(getattr(module, language_name)()) | ||
| tree = Parser(language).parse(source) | ||
| except Exception: | ||
| return None | ||
|
|
||
| starts: set[int] = set() | ||
|
|
||
| non_runtime_ancestors = { | ||
| "ERROR", "type_alias_declaration", "interface_declaration", | ||
| "type_annotation", "type_arguments", "type_parameter", | ||
| } | ||
| pending = [(tree.root_node, frozenset())] | ||
| while pending: | ||
| node, ancestors = pending.pop() | ||
| if node.type == "call_expression": | ||
| function = node.child_by_field_name("function") | ||
| if (function is not None and function.type == "import" | ||
| and not ancestors.intersection(non_runtime_ancestors)): | ||
| starts.add(node.start_byte) | ||
| child_ancestors = ancestors | {node.type} | ||
| pending.extend((child, child_ancestors) for child in node.children) | ||
| return starts | ||
|
|
||
|
|
||
| def _rescue_js_dynamic_imports(path: Path, result: dict) -> None: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
fans out to 7 callees (efferent coupling). Grounded coupling-delta finding (deterministic), not an LLM guess.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I checked the coupling increase in |
||
| """Recover ``import('…')`` edges the AST pass does not emit for plain JS/TS. | ||
|
|
||
|
|
@@ -1388,12 +1438,14 @@ def _rescue_js_dynamic_imports(path: Path, result: dict) -> None: | |
| written inside one, and that is a different fact from "this file depends on | ||
| that module" — the only one file-level traversal can use (#2584). | ||
|
|
||
| Regex false positives in comments/strings are the precedented trade of | ||
| the Svelte/Vue rescues; a ``//``-prefix guard covers the common case. | ||
| The AST filter is safer than a raw regex search: comment and string text can | ||
| contain ``import('…')`` verbatim without making a dependency. Template | ||
| interpolation is parsed as code, so ```${import('./x')}``` remains live. | ||
| """ | ||
| try: | ||
| import re as _re | ||
| src = path.read_text(encoding="utf-8", errors="replace") | ||
| source_bytes = path.read_bytes() | ||
| src = source_bytes.decode("utf-8", errors="replace") | ||
| if "import(" not in src: # cheap bail — most files have none | ||
| return | ||
| existing_ids = {n["id"] for n in result.get("nodes", [])} | ||
|
|
@@ -1431,20 +1483,38 @@ def _rescue_js_dynamic_imports(path: Path, result: dict) -> None: | |
| deferred_files.add(str(Path(tf).resolve())) | ||
| except OSError: | ||
| deferred_files.add(str(tf)) | ||
| matches = list(_re.finditer( | ||
| rb"(?<!\w)import\(\s*(?:'([^'\r\n]+)'|\"([^\"\r\n]+)\"|`([^`$\r\n]+)`)\s*\)", | ||
| source_bytes, | ||
| )) | ||
| if not matches: | ||
| return | ||
| runtime_import_starts = _js_runtime_import_starts(path, source_bytes) | ||
| parser_failed = runtime_import_starts is None | ||
| if parser_failed: | ||
| warnings.warn( | ||
| f"tree-sitter could not validate dynamic import candidates in {path}; " | ||
| "falling back to the guarded lexical rescue, which may include " | ||
| "text in comments or strings", | ||
| RuntimeWarning, | ||
| stacklevel=2, | ||
| ) | ||
| runtime_import_starts = set() | ||
| # `(?<!\w)` so `fooimport('x')` and `_import('x')` do not match. The | ||
| # backtick alternative mirrors _dynamic_import_js's template-string | ||
| # handling: a literal `import(`./x`)` resolves, `${`-substituted ones | ||
| # are excluded (no `$` in the class) as statically unresolvable. | ||
| for m in _re.finditer( | ||
| r"""(?<!\w)import\(\s*(?:'([^'\n]+)'|"([^"\n]+)"|`([^`$\n]+)`)\s*\)""", | ||
| src, | ||
| ): | ||
| raw = m.group(1) or m.group(2) or m.group(3) | ||
| for m in matches: | ||
| if parser_failed: | ||
| line_start = source_bytes.rfind(b"\n", 0, m.start()) + 1 | ||
| if b"//" in source_bytes[line_start:m.start()]: | ||
| continue | ||
| elif m.start() not in runtime_import_starts: | ||
| continue | ||
| raw_bytes = m.group(1) or m.group(2) or m.group(3) | ||
| raw = raw_bytes.decode("utf-8", errors="replace") if raw_bytes else "" | ||
| if not raw: | ||
| continue | ||
| line_start = src.rfind("\n", 0, m.start()) + 1 | ||
| if "//" in src[line_start:m.start()]: | ||
| continue # line-commented-out import | ||
| resolution = _resolve_rescued_specifier(path, raw, aliases, base_url) | ||
| if resolution is None: | ||
| continue | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_rescue_js_dynamic_imports()fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This coupling delta is confined to the rescue helper’s grammar selection and iterative tree-sitter walk.
The helper runs only after the byte matcher finds candidates, and the reparse is required to align those byte offsets with runtime call nodes while excluding comments, strings, regexes, and type contexts.
No unrelated extraction refactor was added; the existing normal-path and parser-failure regressions cover the behavior described above.