fix(js): extract nested function declarations (#2653)

A named `function`/`generator_function` declaration nested inside another
function body now gets its own node, a `contains` edge from the enclosing
function, and its own call-attribution scope, so calls made from inside such a
function are no longer dropped as dangling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
himanshupatro-334
2026-08-14 14:49:56 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent f52b8dbc69
commit b401e8c29f
3 changed files with 128 additions and 7 deletions
+41 -3
View File
@@ -4111,6 +4111,44 @@ def _extract_generic(
if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid:
csharp_method_scopes[id(body)] = (node, parent_class_nid)
function_bodies.append((func_nid, body))
if config.ts_module in (
"tree_sitter_javascript", "tree_sitter_typescript"
):
def _scan_js_nested_functions(parent_nid: str, container_node) -> None:
if container_node is None:
return
for child in container_node.children:
if child.type in (
"function_declaration",
"generator_function_declaration",
):
name_node = child.child_by_field_name(config.name_field)
if name_node is None:
for c in child.children:
if c.type in config.name_fallback_child_types:
name_node = c
break
func_name = _read_text(name_node, source) if name_node else None
if func_name and normalize_id(func_name):
line = child.start_point[0] + 1
nested_nid = _make_id(parent_nid, func_name)
add_node(nested_nid, f"{func_name}()", line)
add_edge(parent_nid, nested_nid, "contains", line)
callable_def_nids.add(nested_nid)
if local_bound_names is not None:
local_bound_names[nested_nid] = _js_local_bound_names(
child, source
)
nested_body = _find_body(child, config)
if nested_body:
function_bodies.append((nested_nid, nested_body))
_scan_js_nested_functions(nested_nid, nested_body)
elif child.type in _JS_FUNCTION_VALUE_TYPES:
continue
else:
_scan_js_nested_functions(parent_nid, child)
_scan_js_nested_functions(func_nid, body)
if config.ts_module == "tree_sitter_kotlin":
# #2347: Kotlin anonymous objects (`object : Foo { … }`,
# node type `object_literal`). The function branch never
@@ -4518,7 +4556,7 @@ def _extract_generic(
return None
return _read_text(scope, source)
_tracked_body_ids: set[int] = set()
_tracked_body_ids: set[object] = set()
_JS_CLOSURE_TYPES = ("arrow_function", "function_expression")
# #2575: nested NAMED functions get the same descent as closures. walk()
# appends only the OUTER declaration's body to function_bodies and never
@@ -4548,7 +4586,7 @@ def _extract_generic(
if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
and node.type in _JS_DESCEND_TYPES):
body = node.child_by_field_name("body")
if body is not None and id(body) not in _tracked_body_ids:
if body is not None and body not in _tracked_body_ids:
# This closure's own params/locals (`(r) => c.get(r)`) are
# scoped to it, not to the enclosing caller_nid — but its
# calls ARE attributed to caller_nid right here, so a bare
@@ -5195,7 +5233,7 @@ def _extract_generic(
# skipped at the arrow boundary in walk_calls, losing its calls — so let
# walk_calls descend into such untracked closures with the enclosing caller
# (#1630 Pattern B). Guarding on the tracked set prevents double-walking.
_tracked_body_ids.update(id(b) for _, b in function_bodies)
_tracked_body_ids.update(b for _, b in function_bodies)
# Body ids are unique (one language per file), so the Java (flat) and C#
# (scoped, #2472) per-method receiver tables merge without collision — the
+82
View File
@@ -888,6 +888,88 @@ def test_extract_js_arbitrary_member_assignment_not_captured(tmp_path):
assert ".whatever()" not in labels
def test_extract_js_nested_function_declarations(tmp_path):
"""#2653: function declarations nested inside another function emit nodes,
source contains edges from the enclosing function, and attribute call edges correctly."""
from graphify.extract import extract
f = tmp_path / "Panel.tsx"
f.write_text(
"function doThing() {}\n"
"export function Panel() {\n"
" function handleClick() {\n"
" doThing()\n"
" }\n"
" return <button onClick={handleClick} />\n"
"}\n"
)
result = extract([f], root=tmp_path)
by_label = {n["label"]: n for n in result["nodes"]}
assert "handleClick()" in by_label
assert by_label["handleClick()"]["id"] == "panel_panel_handleclick"
edges = [(e["source"], e["target"], e["relation"]) for e in result["edges"]]
panel_id = by_label["Panel()"]["id"]
handle_id = by_label["handleClick()"]["id"]
dothing_id = by_label["doThing()"]["id"]
assert (panel_id, handle_id, "contains") in edges
assert (handle_id, dothing_id, "calls") in edges
assert (panel_id, dothing_id, "calls") not in edges
def test_extract_js_deeply_nested_function_declarations(tmp_path):
"""#2653: arbitrary depth nested named function declarations establish hierarchical containment and correct call attribution."""
from graphify.extract import extract
f = tmp_path / "Deep.ts"
f.write_text(
"function doThing() {}\n"
"function Panel() {\n"
" function outer() {\n"
" function inner() {\n"
" doThing()\n"
" }\n"
" }\n"
"}\n"
)
result = extract([f], root=tmp_path)
by_label = {n["label"]: n for n in result["nodes"]}
panel_id = by_label["Panel()"]["id"]
outer_id = by_label["outer()"]["id"]
inner_id = by_label["inner()"]["id"]
dothing_id = by_label["doThing()"]["id"]
edges = [(e["source"], e["target"], e["relation"]) for e in result["edges"]]
assert (panel_id, outer_id, "contains") in edges
assert (outer_id, inner_id, "contains") in edges
assert (inner_id, dothing_id, "calls") in edges
assert (panel_id, dothing_id, "calls") not in edges
assert (outer_id, dothing_id, "calls") not in edges
def test_extract_js_nested_function_local_variable_preservation(tmp_path):
"""#2653 / #1077: extracting nested named functions must preserve local variable suppression."""
from graphify.extract import extract_js
f = tmp_path / "LocalVar.ts"
f.write_text(
"function doThing() {}\n"
"function Panel() {\n"
" const localValue = 123;\n"
" function handleClick() {\n"
" doThing();\n"
" }\n"
"}\n"
)
res = extract_js(f)
labels = [n["label"] for n in res["nodes"]]
assert "handleClick()" in labels
assert "localValue" not in labels
def by_label_by_id(result, node_id):
for n in result["nodes"]:
if n["id"] == node_id:
+5 -4
View File
@@ -241,9 +241,7 @@ def test_line_commented_dynamic_import_is_not_matched(tmp_path: Path):
def test_nested_named_function_calls_resolve(tmp_path: Path):
"""The durable half of #2575: ordinary calls inside a nested named function
were dropped at the same boundary. They now attribute to the enclosing
function, exactly like untracked closures (#1630)."""
"""ordinary calls inside a nested named function attribute to that inner function now that #2653 emits nested nodes."""
f = _write(
tmp_path / "src/mod.ts",
"export function helper() { return 1 }\n"
@@ -258,7 +256,10 @@ def test_nested_named_function_calls_resolve(tmp_path: Path):
by_id = {n["id"]: n["label"].rstrip("()") for n in result["nodes"]}
calls = {(by_id.get(e["source"]), by_id.get(e["target"]))
for e in result["edges"] if e["relation"] == "calls"}
assert ("outer", "helper") in calls, f"calls found: {calls}"
contains = {(by_id.get(e["source"]), by_id.get(e["target"]))
for e in result["edges"] if e["relation"] == "contains"}
assert ("inner", "helper") in calls, f"calls found: {calls}"
assert ("outer", "inner") in contains, f"contains found: {contains}"
def test_dynamic_import_is_traversed_by_affected():