diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ab6ed0c90..318c66bdd 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4029,6 +4029,47 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: add_edge(parent_class_nid, field_nid, "defines", line, context="field") return + # JS/TS: a function-valued property in an object literal defines a + # callable member of the same API surface as the shorthand form. The + # shorthand `{ m(){} }` is a `method_definition` and lands in the + # function branch below, but `{ m: () => {} }` and + # `{ m: function(){} }` parse as `pair` nodes, so the arrow and + # function-expression spellings vanished from the graph while the + # shorthand spelling was captured. Mirror the function branch exactly: + # same walk position, same scoping, same body tracking. + if (t == "pair" + and config.ts_module in ("tree_sitter_javascript", + "tree_sitter_typescript")): + pair_value = node.child_by_field_name("value") + # `{ m: (() => {}) }` wraps the function in parenthesized_expression + # nodes; unwrap them so the parenthesized spelling matches too. + while (pair_value is not None + and pair_value.type == "parenthesized_expression"): + pair_value = next( + (c for c in pair_value.children if c.is_named), None) + if pair_value is not None and pair_value.type in _JS_FUNCTION_VALUE_TYPES: + pair_key = node.child_by_field_name("key") + if pair_key is not None and pair_key.type == "property_identifier": + func_name = _read_text(pair_key, source) + # Same #1899 guard as the function branch: a name that + # normalizes to nothing would collapse onto the prefix. + if func_name and normalize_id(func_name): + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge(parent_class_nid, func_nid, "method", line) + else: + func_nid = _make_id(stem, func_name) + add_node(func_nid, f"{func_name}()", line) + add_edge(file_nid, func_nid, "contains", line) + callable_def_nids.add(func_nid) + local_bound_names[func_nid] = _js_local_bound_names(pair_value, source) + pair_body = pair_value.child_by_field_name("body") + if pair_body: + function_bodies.append((func_nid, pair_body)) + return + # Function types if t in config.function_types: # Swift deinit/subscript have no name field — resolve before generic fallback diff --git a/tests/test_js_object_property_functions.py b/tests/test_js_object_property_functions.py new file mode 100644 index 000000000..3e2d69030 --- /dev/null +++ b/tests/test_js_object_property_functions.py @@ -0,0 +1,98 @@ +"""Function-valued object properties are captured like shorthand methods. + +`{ m(){} }` parses as a `method_definition`, which the generic function +branch captures, but the same API surface written as `{ m: () => {} }` or +`{ m: function(){} }` parses as a `pair`, which was skipped. Two files with +identical runtime exports produced different graphs: the shorthand spelling +kept its symbols, the arrow and function-expression spellings vanished. + +These lock the parity: all three spellings of an exported object member emit +the same node shape, bodies are tracked so calls made inside the property +resolve, and the scoping baseline is unchanged (an object bound with +`const api = { ... }` still emits only the const node, matching shorthand). +""" +from __future__ import annotations + +from pathlib import Path + + +def _labels(r): + return sorted( + n["label"] for n in r["nodes"] if not n["label"].endswith((".js", ".ts")) + ) + + +def _extract(tmp_path: Path, name: str, src: str): + from graphify.extract import extract_js + + p = tmp_path / name + p.write_text(src, encoding="utf-8") + return extract_js(p) + + +def test_arrow_property_matches_shorthand(tmp_path): + short = _extract(tmp_path, "short.js", "module.exports = { m(){ return 1; } };\n") + arrow = _extract(tmp_path, "arrow.js", "module.exports = { m: () => 1 };\n") + assert _labels(short) == ["m()"] + assert _labels(arrow) == ["m()"] + + +def test_function_expression_property_matches_shorthand(tmp_path): + r = _extract( + tmp_path, "fnexpr.js", "module.exports = { m: function(){ return 1; } };\n" + ) + assert _labels(r) == ["m()"] + + +def test_mixed_spellings_all_captured(tmp_path): + r = _extract( + tmp_path, + "mixed.js", + "module.exports = { m: () => 1, async n(){ return 2; } };\n", + ) + assert _labels(r) == ["m()", "n()"] + + +def test_call_argument_object_parity(tmp_path): + # `register({ m(){} })` already emitted m(); the arrow spelling now matches. + r = _extract(tmp_path, "callarg.js", "register({ m: () => 1 });\n") + assert _labels(r) == ["m()"] + + +def test_property_body_calls_resolve(tmp_path): + r = _extract( + tmp_path, + "calls.js", + "function helper(){}\nmodule.exports = { run: () => helper() };\n", + ) + assert _labels(r) == ["helper()", "run()"] + calls = [e for e in r["edges"] if e["relation"] == "calls"] + assert any("run" in e["source"] and "helper" in e["target"] for e in calls) + + +def test_const_object_scoping_baseline_unchanged(tmp_path): + # Shorthand inside a const object emits only the const node; the arrow + # spelling must not emit more than the shorthand baseline does. + short = _extract(tmp_path, "cshort.js", "const api = { m(){ return 1; } };\n") + arrow = _extract(tmp_path, "carrow.js", "const api = { m: () => 1 };\n") + assert _labels(short) == ["api"] + assert _labels(arrow) == ["api"] + + +def test_parenthesized_function_values_captured(tmp_path): + r = _extract(tmp_path, "paren.js", "module.exports = { m: (() => 1) };\n") + assert _labels(r) == ["m()"] + # a parenthesized non-function value stays out of the graph + r2 = _extract(tmp_path, "parencall.js", "module.exports = { m: (fn()) };\n") + assert not [label for label in _labels(r2) if label.endswith("()")] + + +def test_computed_and_string_keys_still_skipped(tmp_path): + # Only plain identifier keys are named symbols; computed and string keys + # stay out of the graph exactly as before. + r = _extract( + tmp_path, + "keys.js", + 'const k = "x";\nmodule.exports = { [k]: () => 1, "quoted-key": () => 2 };\n', + ) + assert not [label for label in _labels(r) if label.endswith("()")]