Skip to content
Merged
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
97 changes: 97 additions & 0 deletions codegraph/parsers/typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,13 @@ def _visit(
self._handle_lexical_decl(
child, rel, parent_qualname, parent_id, lang, src, nodes, edges
)
elif ct == "expression_statement":
for sub in child.children:
if sub.type == "assignment_expression":
self._handle_assigned_function(
sub, rel, parent_qualname, parent_id, lang,
src, nodes, edges,
)
elif ct == "export_statement":
for sub in child.children:
if sub.type in (
Expand Down Expand Up @@ -1061,6 +1068,96 @@ def _handle_lexical_decl(
self._collect_calls(body, rel, func_id, src, edges)
self._collect_fetches(body, rel, func_id, src, nodes, edges)

def _handle_assigned_function(
self,
node: tree_sitter.Node,
rel: str,
parent_qualname: str,
parent_id: str,
lang: str,
src: bytes,
nodes: list[Node],
edges: list[Edge],
) -> None:
"""Emit a FUNCTION node for ``Namespace.name = function (...)``.

Plain-script JS (no ES modules) attaches its API to namespace
objects — ``CGUI.esc = function esc(s) {...}``,
``window.CGViews.flows = (host) => {...}``. Without this handler
those functions are invisible to the graph: review flagged the
dashboard helper move as removed-referenced because the new
definitions produced no nodes at all.
"""
left = node.child_by_field_name("left")
right = node.child_by_field_name("right")
if left is None or right is None:
return
if left.type != "member_expression":
return
if right.type not in ("arrow_function", "function", "function_expression"):
return
lhs_path = node_text(left, src)
# ``window.`` is an environment artifact, not a namespace.
if lhs_path.startswith("window."):
lhs_path = lhs_path[len("window."):]
# Computed members (``obj[key] = ...``) render with brackets; skip.
if not lhs_path or not all(
part.isidentifier() for part in lhs_path.split(".")
):
return
name = lhs_path.rsplit(".", 1)[-1]
qualname = f"{parent_qualname}.{lhs_path}" if parent_qualname else lhs_path
func_id = make_node_id(NodeKind.FUNCTION, qualname, rel)

params_node = right.child_by_field_name("parameters")
if params_node is None:
for c in right.children:
if c.type == "formal_parameters":
params_node = c
break
params_list = _extract_params(params_node, src)
return_type = _extract_return_type(right, params_node, src)

func_md: dict[str, Any] = {
"assigned": True,
"params": params_list,
"returns": return_type,
}
fn_name_node = right.child_by_field_name("name")
if fn_name_node is not None:
inner = node_text(fn_name_node, src)
if inner and inner != name:
func_md["function_name"] = inner
any_params = [
p["name"]
for p in params_list
if isinstance(p, dict) and _type_mentions_any(p.get("type"))
]
if any_params:
func_md["any_params"] = any_params
if _type_mentions_any(return_type):
func_md["any_return"] = True

nodes.append(Node(
id=func_id,
kind=NodeKind.FUNCTION,
name=name,
qualname=qualname,
file=rel,
line_start=node.start_point[0] + 1,
line_end=node.end_point[0] + 1,
language=lang,
metadata=func_md,
))
edges.append(Edge(
src=func_id, dst=parent_id, kind=EdgeKind.DEFINED_IN,
file=rel, line=node.start_point[0] + 1,
))
body = right.child_by_field_name("body")
if body is not None:
self._collect_calls(body, rel, func_id, src, edges)
self._collect_fetches(body, rel, func_id, src, nodes, edges)

# Higher-order iteration methods whose function-typed arguments are treated
# as implicit loop bodies for loop-depth tracking purposes.
_ITER_METHODS: frozenset[str] = frozenset(
Expand Down
18 changes: 18 additions & 0 deletions tests/fixtures/ts_sample/namespace_helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* Plain-script namespace API pattern (no ES modules). */
'use strict';

window.CGUI = window.CGUI || {};

CGUI.esc = function esc(s) {
return String(s ?? '');
};

CGUI.short = (qn) => String(qn).split('.').pop();

window.CGViews.flows = function renderFlows(host) {
CGUI.esc(host.id);
return host;
};

CGUI.VERSION = '1.0';
CGUI[dynamicKey] = function hidden() { return 1; };
54 changes: 54 additions & 0 deletions tests/test_extractor_typescript.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,57 @@ def test_any_return_absent_for_typed_return(
assert not funcs["typedHandler"].metadata.get("any_return")
# arrow with any param but typed return should NOT set any_return
assert not funcs["handleInput"].metadata.get("any_return")


def test_namespace_assigned_functions_emit_nodes(
extractor: TypeScriptExtractor,
) -> None:
"""Regression test: ``CGUI.esc = function esc(s)`` style namespace
assignments produced NO graph nodes, so the dashboard helper move
(app.js -> ui/helpers.js) was flagged removed-referenced by review —
the new definitions were invisible."""
nodes, edges = extractor.parse_file(
FIXTURE_DIR / "namespace_helpers.js", FIXTURE_DIR
)
funcs = {n.qualname: n for n in nodes if n.kind == NodeKind.FUNCTION}
assert "namespace_helpers.CGUI.esc" in funcs
assert funcs["namespace_helpers.CGUI.esc"].name == "esc"
# Arrow assignment works too.
assert "namespace_helpers.CGUI.short" in funcs
# window. prefix is stripped; property name wins over the inner
# function-expression name (callers use CGViews.flows).
assert "namespace_helpers.CGViews.flows" in funcs
flows = funcs["namespace_helpers.CGViews.flows"]
assert flows.name == "flows"
assert flows.metadata.get("function_name") == "renderFlows"
assert flows.metadata.get("assigned") is True
# Each assigned function is DEFINED_IN the module.
defined = {
e.src for e in edges if e.kind == EdgeKind.DEFINED_IN
}
assert flows.id in defined


def test_namespace_assignment_non_function_and_computed_skipped(
extractor: TypeScriptExtractor,
) -> None:
nodes, _ = extractor.parse_file(
FIXTURE_DIR / "namespace_helpers.js", FIXTURE_DIR
)
names = {n.name for n in nodes if n.kind == NodeKind.FUNCTION}
assert "VERSION" not in names # value assignment, not a function
assert "hidden" not in names # computed member target


def test_namespace_assigned_function_body_emits_calls(
extractor: TypeScriptExtractor,
) -> None:
nodes, edges = extractor.parse_file(
FIXTURE_DIR / "namespace_helpers.js", FIXTURE_DIR
)
funcs = {n.qualname: n for n in nodes if n.kind == NodeKind.FUNCTION}
flows_id = funcs["namespace_helpers.CGViews.flows"].id
calls_from_flows = [
e for e in edges if e.kind == EdgeKind.CALLS and e.src == flows_id
]
assert calls_from_flows, "body calls must be attributed to the function"
Loading