diff --git a/graphify/extract.py b/graphify/extract.py index 32552ffa..a6ac4852 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2587,6 +2587,56 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, return False +# ── TS extra walk for namespace / module declarations ───────────────────────── + +def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, + nodes: list, edges: list, seen_ids: set, function_bodies: list, + parent_class_nid: str | None, add_node_fn, add_edge_fn, + walk_fn) -> bool: + """Emit a container node for a TS `namespace`/`module` declaration. + + `namespace Foo {}` parses as `internal_module` (with `name`/`body` fields); + `module Bar {}` and ambient `declare module "pkg" {}` parse as a named + `module` node that exposes no fields, so its name and body are found + positionally. Without this the container was never a node — its members were + still reached by the default recurse but lost their namespace context. The + members stay file-contained (parity with C#'s `_csharp_extra_walk`); the + namespace becomes a sibling marker node so it is queryable. Returns True if + handled. + + The guard requires `is_named` because the anonymous `module` keyword token + shares the `module` type string and would otherwise match here. + """ + if node.is_named and node.type in ("internal_module", "module"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.is_named and child.type in ( + "identifier", "nested_identifier", "string"): + name_node = child + break + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "statement_block": + body = child + break + if name_node is not None: + ns_name = _read_text(name_node, source) + if name_node.type == "string": + ns_name = ns_name.strip("'\"`") + if ns_name: + ns_nid = _make_id(stem, ns_name) + line = node.start_point[0] + 1 + add_node_fn(ns_nid, ns_name, line) + add_edge_fn(file_nid, ns_nid, "contains", line) + if body is not None: + for child in body.children: + walk_fn(child, parent_class_nid) + return True + return False + + # ── C# extra walk for namespace declarations ────────────────────────────────── def _csharp_namespace_name(node, source: bytes) -> str: @@ -4441,6 +4491,13 @@ def _extract_generic( callable_def_nids, local_bound_names): return + # TS namespace / module containers (internal_module, module) + if config.ts_module == "tree_sitter_typescript": + if _ts_extra_walk(node, source, file_nid, stem, str_path, + nodes, edges, seen_ids, function_bodies, + parent_class_nid, add_node, add_edge, walk): + return + if config.ts_module == "tree_sitter_c_sharp": if _csharp_extra_walk(node, source, file_nid, stem, str_path, nodes, edges, seen_ids, function_bodies, diff --git a/tests/test_ts_namespace.py b/tests/test_ts_namespace.py new file mode 100644 index 00000000..ba50b7c0 --- /dev/null +++ b/tests/test_ts_namespace.py @@ -0,0 +1,73 @@ +"""Regression tests: TypeScript namespace/module container nodes. + +`namespace Foo {}` parses as `internal_module`, `module Bar {}` and ambient +`declare module "pkg" {}` as `module`. Neither was in `class_types`/`function_types` +nor handled by an extra-walk, so the container produced no node. Members were +still reached by the default recurse but the namespace itself was invisible. +""" +from pathlib import Path + +from graphify.extract import _file_stem, _make_id, extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _node_label(result: dict, file: str, sym: str): + nid = _make_id(_file_stem(Path(file)), sym) + return next((n["label"] for n in result["nodes"] if n["id"] == nid), None) + + +def _has_node(result: dict, file: str, sym: str) -> bool: + return _node_label(result, file, sym) is not None + + +def test_namespace_is_node(tmp_path): + f = _write(tmp_path / "src" / "n.ts", + "export namespace Geometry { export const PI = 3.14; }\n") + r = extract([f], cache_root=tmp_path) + assert _has_node(r, "src/n.ts", "Geometry") + + +def test_module_keyword_is_node(tmp_path): + f = _write(tmp_path / "src" / "m.ts", + "module Legacy { export class Thing {} }\n") + r = extract([f], cache_root=tmp_path) + assert _has_node(r, "src/m.ts", "Legacy") + + +def test_nested_namespace_name(tmp_path): + f = _write(tmp_path / "src" / "nn.ts", + "namespace App.Core.Util { export const v = 1; }\n") + r = extract([f], cache_root=tmp_path) + assert _node_label(r, "src/nn.ts", "App.Core.Util") == "App.Core.Util" + + +def test_namespace_members_still_extracted(tmp_path): + """The container node must not cost us the members the default recurse reached.""" + f = _write(tmp_path / "src" / "n.ts", + "namespace Shapes {\n" + " export class Circle {}\n" + " export function area() { return 0; }\n" + "}\n") + r = extract([f], cache_root=tmp_path) + assert _has_node(r, "src/n.ts", "Shapes") + assert _has_node(r, "src/n.ts", "Circle") + assert _has_node(r, "src/n.ts", "area") + + +def test_ambient_string_module_quotes_stripped(tmp_path): + f = _write(tmp_path / "src" / "amb.ts", + 'declare module "pkg-name" { export const z = 3; }\n') + r = extract([f], cache_root=tmp_path) + assert _node_label(r, "src/amb.ts", "pkg-name") == "pkg-name" + + +def test_namespace_node_not_emitted_in_js(tmp_path): + """The handler is TS-only; plain JS has no namespace syntax to confuse it.""" + f = _write(tmp_path / "src" / "p.js", "function ok() {}\n") + r = extract([f], cache_root=tmp_path) + assert _has_node(r, "src/p.js", "ok")