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
41 changes: 41 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions tests/test_js_object_property_functions.py
Original file line number Diff line number Diff line change
@@ -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("()")]
Loading