Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions codebase_rag/constants/ast_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
JS_THIS_CALL_PREFIX = "this."

JS_TS_PARENT_REF_TYPES = (TS_IDENTIFIER, TS_MEMBER_EXPRESSION)
# The `*` of `export * from './m'`. The JavaScript and TypeScript grammars
# spell it as an anonymous token, not Java's named `asterisk` node.
TS_JS_STAR = "*"
# JSX element nodes that carry a component name (javascript and tsx grammars
# share these); the closing element repeats the name and must not double-emit.
TS_JSX_SELF_CLOSING_ELEMENT = "jsx_self_closing_element"
Expand Down
2 changes: 2 additions & 0 deletions codebase_rag/constants/ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@
FIELD_BOUNDS = "bounds"
TS_RS_TRAIT_BOUNDS = "trait_bounds"
FIELD_VALUE = "value"
FIELD_DECLARATION = "declaration"
FIELD_SOURCE = "source"
FIELD_LEFT = "left"
FIELD_RIGHT = "right"
# A C-style for's post-iteration clause: Java/C++ hold it in an `update`
Expand Down
9 changes: 9 additions & 0 deletions codebase_rag/constants/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,15 @@ class DiffMarker:
"Rename failed its postcondition ({reasons}) and was not rolled back "
"({error}); renamed files may remain modified; check the working tree"
)
# A JS/TS use bound through a barrel's `export *`: the rename cannot reach
# the importer's statement through the star, so no leave makes it safe
# (issue #2464).
RENAME_STAR_REEXPORT = (
"Cannot rename {qn}: {count} site(s) import it through an `export *` in "
"{barrels}, which the rename cannot follow; their imports would keep naming "
"what the barrel no longer exports"
)
RENAME_SITE_STAR_REEXPORT = "through export *"
RENAME_ROLLBACK_UNKNOWN = (
"Rename kept: its postcondition failed ({reasons}) and its transaction is "
"no longer in the edit history, so whether it was already reversed cannot "
Expand Down
146 changes: 146 additions & 0 deletions codebase_rag/editing/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,154 @@ def _collect(self, qn: str) -> tuple[list[RenameSite], list[str], str, str | Non
old_name,
patcher,
)
sites = self._js_import_bound(qn, path, sites, old_name, patcher)
return sites, unlocatable, old_name, definition["label"]

def _js_import_bound(
self,
qn: str,
path: str,
sites: list[RenameSite],
old_name: str,
patcher: Patcher,
) -> list[RenameSite]:
"""The sites, as the JS/TS imports binding each one let it be renamed.

The graph binds `mul(8, 9)` to `mul` through a default import, and
`add(1, 2)` through a barrel's `export *`: the token equals the
definition's name either way (issue #2464). A bare use in another
file follows the rename only when its file imports the name by the
definition's own name, so the import is rewritten with it. One whose
file binds a name of its own is left as it is. One whose import goes
through an `export *` refuses outright: the rename cannot rewrite that
import, so rewriting the use would leave it naming what the barrel no
longer exports, whatever leave the caller gives. Any other is a guess.
"""
bindings: tuple[set[str], set[str], dict[str, str]] | None = None
kept: list[RenameSite] = []
starred: list[RenameSite] = []
barrels: set[str] = set()
for site in sites:
if (
site.kind not in ("call", "reference")
or site.path == path
or get_language_for_extension(Path(site.path).suffix)
not in cs.JS_TS_LANGUAGES
or self._is_member_token(patcher, site)
):
kept.append(site)
continue
if bindings is None:
bindings = self._js_name_bindings(qn, old_name)
by_name, aliased, star_bound = bindings
if site.path in by_name:
kept.append(site)
elif (barrel := star_bound.get(site.path)) is not None:
starred.append(site._replace(resolution=cs.RENAME_SITE_STAR_REEXPORT))
barrels.add(barrel)
elif site.path not in aliased:
kept.append(site._replace(resolution=cs.EdgeResolution.HEURISTIC.value))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if starred:
raise RenameRefused(
cs.RENAME_STAR_REEXPORT.format(
qn=qn, count=len(starred), barrels=", ".join(sorted(barrels))
),
starred,
[],
)
return kept

@staticmethod
def _is_member_token(patcher: Patcher, site: RenameSite) -> bool:
# `ns.helper(1)` names the member by its own name whatever binds
# `ns`; only a bare `helper(1)` is bound by an import of that name.
try:
source = patcher.source(site.path)
offset = line_col_to_byte(source, site.line, site.col)
except PatcherError:
return False
return source[:offset].rstrip().endswith(b".")

def _js_name_bindings(
self, qn: str, name: str
) -> tuple[set[str], set[str], dict[str, str]]:
"""(files importing `name` by the definition's own name, files whose
`name` is a binding of their own for the definition, files importing
it through an `export *` with that barrel's path)."""
module_qn, _path = self._module_of(qn)
by_name = {
site.path
for site, _module in self._import_sites(qn, name)
if (site.alias or site.imported_name) == name
}
# `import mul from`, `const mul = require(...)` and a barrel's
# `export { default as mul } from` name the binding as the importer
# chose, and so does every module importing that `mul` from such a
# barrel: the definition's rename changes none of them.
aliased: set[str] = set()
pending = [(module_qn, False)]
seen = {module_qn}
while pending:
source, chosen = pending.pop()
for row in graph_query.importers(self.fetch_all, self.project, source):
# The definition's own name is rewritten with its import; a
# barrel's chosen name only passes on under that same name.
if (
not row["path"]
or row["alias"] != name
or (row["imported_name"] == name) != chosen
):
continue
aliased.add(row["path"])
if row["module"] not in seen:
seen.add(row["module"])
pending.append((row["module"], True))
return by_name, aliased, self._js_star_bound(module_qn, name)

def _js_star_bound(self, module_qn: str, name: str) -> dict[str, str]:
"""Files importing `name` through a chain of re-exports that passes
an `export *`, each with the path of the first such barrel.

The chain may name the definition before the star
(`export { foo } from "../a"` in `b`, then `export * from "../b"` in
`c`): the import walk rewrites `b`, but not the consumer importing
`foo` from `c`.
"""
bound: dict[str, str] = {}
pending = [(module_qn, "")]
seen = {(module_qn, False)}
while pending:
source, barrel = pending.pop()
for row in graph_query.importers(self.fetch_all, self.project, source):
following = self._js_star_following(row, name, barrel, bound)
if following is None:
continue
step = (row["module"], bool(following))
if step not in seen:
seen.add(step)
pending.append((row["module"], following))
return bound

@staticmethod
def _js_star_following(
row: graph_query.ImporterRow, name: str, barrel: str, bound: dict[str, str]
) -> str | None:
"""The first `export *` barrel on the chain once it passes `row`, empty
while there is none, or None when `row` does not pass `name` on. A
file binding `name` past such a barrel is recorded in `bound`."""
path = row["path"]
if not path:
return None
# `export * from` records `*` and binds no alias.
if row["imported_name"] == cs.IMPORTED_NAME_WILDCARD and row["alias"] is None:
return barrel or path
if row["alias"] != name or row["imported_name"] != name:
return None
# A consumer, or a barrel passing the name on by name.
if barrel:
bound.setdefault(path, barrel)
return barrel

def _definition_site(
self,
qn: str,
Expand Down
33 changes: 33 additions & 0 deletions codebase_rag/parsers/call_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2297,6 +2297,34 @@ def _is_super_call(self, call_name: str) -> bool:
or call_name.startswith(f"{cs.KEYWORD_SUPER}()")
)

def _try_resolve_js_reexport(
self,
call_name: str,
import_map: dict[str, str],
language: cs.SupportedLanguage | None,
module_qn: str,
) -> tuple[str, str] | None:
# `import { plus } from "./lib"` maps `plus` to `lib.plus`, where a
# barrel registers nothing: its `export { add as plus } from` names
# the definition, possibly through further barrels. Without this the
# call fell to the name-only fallback, which found no `plus` at all,
# or only guessed the right `add` (issue #2464).
if language not in cs.JS_TS_LANGUAGES or call_name not in import_map:
return None
imported_qn = import_map[call_name]
followed = self.import_processor.follow_js_reexports(
imported_qn,
self.type_inference.module_qn_to_file_path,
self.function_registry,
)
if followed == imported_qn:
return None
# The chain's end resolves as a direct import of it would, so a
# CommonJS whole-module export there is still reached.
return self._try_resolve_direct_import(
call_name, {call_name: followed}, language, module_qn
)

def _try_resolve_via_imports(
self,
call_name: str,
Expand All @@ -2321,6 +2349,11 @@ def _try_resolve_via_imports(
):
return result

if result := self._try_resolve_js_reexport(
call_name, import_map, language, module_qn
):
return result

if result := self._try_resolve_qualified_call(
call_name, import_map, module_qn, local_var_types, language
):
Expand Down
Loading
Loading