fix(extract): emit a node for TS namespace / module containers

`namespace Foo {}` parses as `internal_module` and `module Bar {}` (and
ambient `declare module "pkg" {}`) as a named `module` node. Neither kind
was in `class_types`/`function_types` nor handled by an extra-walk, so the
container produced no node — its members were still reached by the default
recurse, but the namespace/module itself was invisible to the graph and its
members lost their namespace context.

Add `_ts_extra_walk`, dispatched for TypeScript after `_js_extra_walk`,
mirroring `_csharp_extra_walk`: it emits a container node + a file→container
`contains` edge and recurses the body, leaving members file-contained as
before. `internal_module` exposes `name`/`body` fields; `module` exposes
none, so name (identifier / nested_identifier / quote-stripped string) and
body (`statement_block`) are found positionally. The `is_named` guard skips
the anonymous `module` keyword token, which shares the `module` type string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paulo Pinto
2026-06-26 11:13:33 -07:00
committed by safishamsi
parent 09aeb979c8
commit 869aaf7502
2 changed files with 130 additions and 0 deletions
+57
View File
@@ -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,
+73
View File
@@ -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")