diff --git a/codegraph/parsers/typescript.py b/codegraph/parsers/typescript.py index 8ebc187..f5351d6 100644 --- a/codegraph/parsers/typescript.py +++ b/codegraph/parsers/typescript.py @@ -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 ( @@ -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( diff --git a/tests/fixtures/ts_sample/namespace_helpers.js b/tests/fixtures/ts_sample/namespace_helpers.js new file mode 100644 index 0000000..8bd0980 --- /dev/null +++ b/tests/fixtures/ts_sample/namespace_helpers.js @@ -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; }; diff --git a/tests/test_extractor_typescript.py b/tests/test_extractor_typescript.py index 50bdefc..3bb7a06 100644 --- a/tests/test_extractor_typescript.py +++ b/tests/test_extractor_typescript.py @@ -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"