Skip to content
Open
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
44 changes: 41 additions & 3 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4101,6 +4101,44 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
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:
Comment thread
hopstreax marked this conversation as resolved.
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
Expand Down Expand Up @@ -4508,7 +4546,7 @@ def _php_class_const_scope(n) -> str | None:
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
Expand Down Expand Up @@ -4538,7 +4576,7 @@ def walk_calls(
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
Expand Down Expand Up @@ -5185,7 +5223,7 @@ def walk_calls(
# 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
Expand Down
82 changes: 82 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions tests/test_js_dynamic_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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():
Expand Down
Loading