mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 01:06:36 +00:00
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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
f52b8dbc69
commit
b401e8c29f
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user