From 9817f4d859eb9bca47666f8265b33ff327705949 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Fri, 12 Jun 2026 16:53:56 -0600 Subject: [PATCH 01/11] feat: add Haxe language support via tree-sitter-haxe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract_haxe(): extracts classes, interfaces, enums, enum abstracts, typedefs, and functions from .hx files using tree-sitter-haxe grammar - _haxe_recover_scattered(): fallback parser for files where the grammar produces scattered tokens instead of proper declaration nodes - CR/CRLF normalization before parsing (handles old Mac \r-only files) - detect.py: register .hx extension → Haxe language - pyproject.toml: add haxe optional dep group; add tree-sitter-haxe to all Tested against 5,490 .hx files; 2 empty files (both legitimately all-commented-out). Produces 82,867 nodes and 98,717 edges. --- graphify/detect.py | 2 +- graphify/extract.py | 343 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 + 3 files changed, 349 insertions(+), 1 deletion(-) diff --git a/graphify/detect.py b/graphify/detect.py index 92b773f7b..a9d3b4226 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.hx'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 698e192fe..d8e418653 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -15040,6 +15040,348 @@ def _body_of(block): return {"nodes": nodes, "edges": edges} +def extract_haxe(path: Path) -> dict: + """Extract classes, interfaces, typedefs, functions, imports, and inheritance from a .hx file.""" + try: + import tree_sitter_haxe as _tshaxe + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-haxe not installed"} + + try: + language = Language(_tshaxe.language()) + parser = Parser(language) + source = path.read_bytes() + # Normalize CR-only and CRLF line endings to LF so that the tree-sitter + # comment rule `seq('//', /.*/)` doesn't consume the rest of the file + # on old-Mac \r-only files (where .* matches \r and runs to EOF). + if b"\r" in source: + source = source.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + function_bodies: list[tuple[str, Any]] = [] + + def add_node(nid: str, label: str, line: int) -> None: + if nid and nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, + "label": label, + "file_type": "code", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED") -> None: + if src and tgt and src != tgt: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + + def ensure_type_node(name: str, line: int) -> str: + nid = _make_id(stem, name) + if nid in seen_ids: + return nid + nid = _make_id(name) + if nid not in seen_ids: + nodes.append({ + "id": nid, + "label": name, + "file_type": "code", + "source_file": "", + "source_location": "", + }) + seen_ids.add(nid) + return nid + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def _haxe_call_name(call_node) -> str: + """Return the bare function/method name from a call_expression node.""" + obj = call_node.child_by_field_name("object") + ctor = call_node.child_by_field_name("constructor") + if ctor is not None: + return _read_text(ctor, source) + if obj is None: + return "" + if obj.type == "identifier": + return _read_text(obj, source) + if obj.type == "member_expression": + # Last entry in the `member` field list is the method name + members = obj.children_by_field_name("member") + if members: + last = members[-1] + if last.type == "identifier": + return _read_text(last, source) + # nested member_expression — recurse one level + if last.type == "member_expression": + sub = last.children_by_field_name("member") + if sub: + return _read_text(sub[-1], source) + return "" + + def walk_calls(node, owner_nid: str) -> None: + """Walk a function body collecting call edges; stops at nested function boundaries.""" + if node.type == "function_declaration": + return + if node.type == "call_expression": + call_name = _haxe_call_name(node) + if call_name and call_name not in _LANGUAGE_BUILTIN_GLOBALS: + tgt_nid = _make_id(stem, call_name) + if tgt_nid not in seen_ids: + tgt_nid = _make_id(call_name) + line = node.start_point[0] + 1 + add_edge(owner_nid, tgt_nid, "calls", line) + for child in node.children: + walk_calls(child, owner_nid) + + def _haxe_dotted_path(node) -> str: + """Reconstruct dotted package path from an import/using statement.""" + parts = [ + _read_text(c, source) + for c in node.children + if c.type in ("package_name", "type_name") + ] + return ".".join(parts) + + def walk(node, parent_class_nid: "str | None" = None, + parent_class_name: "str | None" = None) -> None: + t = node.type + + if t in ("import_statement", "using_statement"): + dotted = _haxe_dotted_path(node) + if dotted: + tgt_nid = _make_id(dotted.replace(".", "_")) + add_edge(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + return + + if t in ("class_declaration", "interface_declaration"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + walk(child, parent_class_nid, parent_class_name) + return + class_name = _read_text(name_node, source) + line = node.start_point[0] + 1 + class_nid = _make_id(stem, class_name) + add_node(class_nid, class_name, line) + add_edge(file_nid, class_nid, "contains", line) + + # extends + for super_node in node.children_by_field_name("super_class_name"): + base = _read_text(super_node, source).strip() + if base: + add_edge(class_nid, ensure_type_node(base, line), "inherits", line) + + # implements / interface extends + for iface_node in node.children_by_field_name("interface_name"): + iface = _read_text(iface_node, source).strip() + if iface: + rel = "inherits" if t == "interface_declaration" else "implements" + add_edge(class_nid, ensure_type_node(iface, line), rel, line) + + body = node.child_by_field_name("body") + if body is not None: + for child in body.children: + walk(child, class_nid, class_name) + return + + if t in ("enum_declaration", "enum_abstract_declaration"): + name_node = node.child_by_field_name("name") + if name_node is None: + return + enum_name = _read_text(name_node, source) + line = node.start_point[0] + 1 + enum_nid = _make_id(stem, enum_name) + add_node(enum_nid, enum_name, line) + add_edge(file_nid, enum_nid, "contains", line) + # Walk body for nested function declarations (uncommon but possible) + body = node.child_by_field_name("body") + if body is not None: + for child in body.children: + walk(child, enum_nid, enum_name) + return + + if t == "typedef_declaration": + name_node = node.child_by_field_name("name") + if name_node is None: + return + typedef_name = _read_text(name_node, source) + line = node.start_point[0] + 1 + typedef_nid = _make_id(stem, typedef_name) + add_node(typedef_nid, typedef_name, line) + add_edge(file_nid, typedef_nid, "contains", line) + return + + if t == "function_declaration": + name_node = node.child_by_field_name("name") + if name_node is None: + return + func_name = _read_text(name_node, source) + line = node.start_point[0] + 1 + if parent_class_nid is not None and parent_class_name is not None: + func_nid = _make_id(stem, parent_class_name, 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) + fn_body = node.child_by_field_name("body") + if fn_body is not None: + function_bodies.append((func_nid, fn_body)) + return + + for child in node.children: + walk(child, parent_class_nid, parent_class_name) + + walk(root) + + # Fallback: recover class/enum names from scattered module-level tokens. + # When the grammar can't form a proper declaration node (e.g. minified files + # where everything is on one line, or unsupported preprocessor patterns), the + # parser emits bare 'class'/'enum' keyword tokens followed by an identifier. + # Walk the top-level children looking for that pattern and create nodes for + # any names that weren't already extracted. + if len(nodes) <= 1: + _haxe_recover_scattered(root, source, stem, file_nid, + add_node, add_edge, seen_ids, function_bodies) + + for func_nid, body in function_bodies: + walk_calls(body, func_nid) + + return {"nodes": nodes, "edges": edges} + + +def _haxe_recover_scattered( + root: Any, + source: bytes, + stem: str, + file_nid: str, + add_node: Any, + add_edge: Any, + seen_ids: set, + function_bodies: list, +) -> None: + """Extract class/enum names from module-level scattered tokens. + + When the grammar fails to form a declaration node (minified code, unsupported + preprocessor patterns), the parser emits 'class'/'enum' as bare keyword tokens + followed by an identifier. This pass recovers at least the type name so the + file has a meaningful node rather than just a file-level stub. + """ + children = list(root.children) + i = 0 + while i < len(children): + node = children[i] + t = node.type + raw = source[node.start_byte:node.end_byte].decode("utf-8", "replace").strip() + + # Pattern: 'class' token followed by identifier token + if raw == "class" and i + 1 < len(children): + next_node = children[i + 1] + if next_node.type == "identifier": + class_name = source[next_node.start_byte:next_node.end_byte].decode("utf-8", "replace").strip() + line = node.start_point[0] + 1 + class_nid = _make_id(stem, class_name) + add_node(class_nid, class_name, line) + add_edge(file_nid, class_nid, "contains", line) + # Collect any function_declaration siblings that follow before + # we hit another keyword or end of file + j = i + 2 + while j < len(children): + sib = children[j] + if sib.type == "function_declaration": + fn_name_node = sib.child_by_field_name("name") + if fn_name_node is not None: + fn_name = source[fn_name_node.start_byte:fn_name_node.end_byte].decode("utf-8", "replace") + fn_line = sib.start_point[0] + 1 + fn_nid = _make_id(stem, class_name, fn_name) + add_node(fn_nid, f"{fn_name}()", fn_line) + add_edge(class_nid, fn_nid, "method", fn_line) + fn_body = sib.child_by_field_name("body") + if fn_body is not None: + function_bodies.append((fn_nid, fn_body)) + elif sib.type in ("class_declaration", "interface_declaration", + "enum_declaration", "enum_abstract_declaration"): + break + elif source[sib.start_byte:sib.end_byte].decode("utf-8", "replace").strip() == "class": + break + j += 1 + i = j + continue + + # Pattern: 'enum' keyword token (ERROR node contains the rest) + if raw == "enum" and i + 1 < len(children): + # Try to pull the name out of the following ERROR node's text + next_node = children[i + 1] + err_text = source[next_node.start_byte:next_node.end_byte].decode("utf-8", "replace") + import re as _re + # Matches: [abstract] Name[(...)][from...][to...] — grab Name + m = _re.match(r"\s*(?:abstract\s+)?([A-Za-z_][A-Za-z0-9_]*)", err_text) + if m: + enum_name = m.group(1) + line = node.start_point[0] + 1 + enum_nid = _make_id(stem, enum_name) + add_node(enum_nid, enum_name, line) + add_edge(file_nid, enum_nid, "contains", line) + + # Pattern: bare 'typedef' token followed by identifier — handles struct + # typedefs with optional fields (?field:T) that the grammar can't parse. + if raw == "typedef" and i + 1 < len(children): + next_node = children[i + 1] + if next_node.type == "identifier": + td_name = source[next_node.start_byte:next_node.end_byte].decode("utf-8", "replace").strip() + line = node.start_point[0] + 1 + td_nid = _make_id(stem, td_name) + add_node(td_nid, td_name, line) + add_edge(file_nid, td_nid, "contains", line) + + # Pattern: ERROR node whose text contains a class/interface/enum declaration. + # Use re.search (not match) to skip leading metadata like @deprecated that + # precede the actual keyword and would otherwise block recognition. + if node.type == "ERROR": + import re as _re + err_text = source[node.start_byte:node.end_byte].decode("utf-8", "replace") + m = _re.search( + r"\b(class|interface|enum)\s+" + r"(?:abstract\s+)?" + r"([A-Za-z_][A-Za-z0-9_]*)", + err_text, + ) + if m: + decl_name = m.group(2) + line = node.start_point[0] + 1 + decl_nid = _make_id(stem, decl_name) + add_node(decl_nid, decl_name, line) + add_edge(file_nid, decl_nid, "contains", line) + # Extract function names from the ERROR text with a simple regex + for fn_m in _re.finditer(r"\bfunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*[\(<]", err_text): + fn_name = fn_m.group(1) + fn_line = line + err_text[:fn_m.start()].count("\n") + fn_nid = _make_id(stem, decl_name, fn_name) + add_node(fn_nid, f"{fn_name}()", fn_line) + add_edge(decl_nid, fn_nid, "method", fn_line) + + i += 1 + + _DISPATCH: dict[str, Any] = { ".py": extract_python, ".js": extract_js, @@ -15131,6 +15473,7 @@ def _body_of(block): ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".hx": extract_haxe, } diff --git a/pyproject.toml b/pyproject.toml index 876602bb9..787287155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,11 @@ sql = ["tree-sitter-sql"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] +# tree-sitter-haxe is not yet published on PyPI (upstream vantreeseba/tree-sitter-haxe +# hasn't cut a release); pull our patched fork straight from git in the meantime. +# Excluded from `all` — an unresolvable PyPI name would break `uv sync`/CI for +# everyone, not just fail to build (#1307). +haxe = ["tree-sitter-haxe @ git+https://github.com/masquepublishing/tree-sitter-haxe.git"] all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"] [project.scripts] From 473afb813b4026a534541342a728be44d8926fdb Mon Sep 17 00:00:00 2001 From: mallyskies Date: Sat, 13 Jun 2026 13:54:14 -0600 Subject: [PATCH 02/11] docs+tests: add Haxe documentation, fixture, and language tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: add .hx to supported languages table (36 → 37 grammars) - CHANGELOG.md: add Unreleased entry for Haxe support - tests/fixtures/sample.hx: fixture covering class, interface, enum, enum abstract, typedef, methods, inheritance, and implements - tests/test_languages.py: 9 tests for extract_haxe(); skipped when tree-sitter-haxe is not installed (mirrors [dm] skip pattern) --- CHANGELOG.md | 4 +++ README.md | 3 +- tests/fixtures/sample.hx | 54 +++++++++++++++++++++++++++++ tests/test_languages.py | 75 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/sample.hx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb835436..2262e2948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feat: Haxe (`.hx`) language support via `tree-sitter-haxe`. Extracts classes, interfaces, enums, enum abstracts, typedefs, and functions. Includes a fallback pass for files where the grammar emits scattered tokens instead of declaration nodes. Install with `pip install "graphifyy[haxe]"`. + ## 0.9.5 (2026-07-02) - Feat: the MCP server can serve many projects from one process via an optional `project_path` on every tool (#1594, thanks @joanfgarcia). Omit it and nothing changes — the server answers against the graph it was started with. Pass an absolute `project_path` and that call is routed to `//graph.json` instead, with its own mtime+size hot-reload, so one stdio/HTTP server backs a whole workspace of repos. Graphs load lazily and cache per resolved path; a missing/corrupt project graph is a tool error, not a process exit, and the server starts even when its default graph is absent. Backward-compatible and additive. diff --git a/README.md b/README.md index dbd8489a5..06954c0c4 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,8 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (37 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Haxe | `.hx` (requires `uv tool install graphifyy[haxe]`; classes, interfaces, enums, enum abstracts, typedefs, functions) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | diff --git a/tests/fixtures/sample.hx b/tests/fixtures/sample.hx new file mode 100644 index 000000000..07dc9e8d1 --- /dev/null +++ b/tests/fixtures/sample.hx @@ -0,0 +1,54 @@ +import com.masque.core.MApp; +import com.masque.net.NetMsg; + +interface ILoggable { + function log():Void; +} + +class BaseClient { + public function new() {} +} + +class HttpClient extends BaseClient implements ILoggable { + private var mBaseUrl:String; + + public function new(baseUrl:String) { + super(); + mBaseUrl = baseUrl; + } + + public function get(path:String):String { + return buildRequest("GET", path); + } + + public function post(path:String, body:String):String { + return buildRequest("POST", path); + } + + private function buildRequest(method:String, path:String):String { + return method + " " + mBaseUrl + path; + } + + public function log():Void {} +} + +enum CardSuit { + SPADES; + HEARTS; + DIAMONDS; + CLUBS; +} + +enum abstract Rank(Int) { + var ACE = 1; + var KING = 13; +} + +typedef Config = { + var baseUrl:String; + var timeout:Int; +} + +function createClient(cfg:Config):HttpClient { + return new HttpClient(cfg.baseUrl); +} diff --git a/tests/test_languages.py b/tests/test_languages.py index ca5169d54..797d2f35d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1,4 +1,4 @@ -"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML.""" +"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML, Haxe.""" from __future__ import annotations from pathlib import Path import pytest @@ -9,7 +9,7 @@ extract_groovy, extract_sln, extract_csproj, extract_xaml, extract_razor, extract_dm, extract_dmi, extract_dmm, extract_dmf, extract_powershell, extract_apex, extract_verilog, - extract_powershell_manifest, + extract_powershell_manifest, extract_haxe, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -2909,3 +2909,74 @@ def test_decldef_merge_does_not_merge_same_name_same_dir_distinct_files(): r = _corpus("cpp_samedir/Alpha.h", "cpp_samedir/Beta.h") dups = _nodes_with_label(r, "Dup") assert len(dups) == 2, f"same-dir distinct Dups must stay distinct, got {[n['id'] for n in dups]}" + + +# ── Haxe ────────────────────────────────────────────────────────────────────── + +_needs_haxe = pytest.mark.skipif( + _ilu.find_spec("tree_sitter_haxe") is None, + reason="tree-sitter-haxe not installed (optional [haxe] extra)", +) + +@_needs_haxe +def test_haxe_no_error(): + r = extract_haxe(FIXTURES / "sample.hx") + assert "error" not in r + +@_needs_haxe +def test_haxe_finds_class(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("HttpClient" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_finds_interface(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("ILoggable" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_finds_enum(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("CardSuit" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_finds_enum_abstract(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("Rank" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_finds_typedef(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("Config" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_finds_methods(): + r = extract_haxe(FIXTURES / "sample.hx") + labels = _labels(r) + assert any("get()" in l for l in labels) + assert any("post()" in l for l in labels) + +@_needs_haxe +def test_haxe_finds_top_level_function(): + r = extract_haxe(FIXTURES / "sample.hx") + assert any("createClient()" in l for l in _labels(r)) + +@_needs_haxe +def test_haxe_splits_inherits_and_implements(): + r = extract_haxe(FIXTURES / "sample.hx") + assert ("HttpClient", "BaseClient") in _edge_labels(r, "inherits") + assert ("HttpClient", "ILoggable") in _edge_labels(r, "implements") + +@_needs_haxe +def test_haxe_finds_imports(): + r = extract_haxe(FIXTURES / "sample.hx") + imports = _edge_labels(r, "imports") + assert ("sample.hx", "com_masque_core_mapp") in imports + assert ("sample.hx", "com_masque_net_netmsg") in imports + +@_needs_haxe +def test_haxe_finds_calls(): + r = extract_haxe(FIXTURES / "sample.hx") + calls = _edge_labels(r, "calls") + assert ("get", "buildrequest") in calls + assert ("post", "buildrequest") in calls + assert ("createClient", "HttpClient") in calls From ef2140549d51503790c015b87c65d9a5e8d72982 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Wed, 1 Jul 2026 18:29:27 -0600 Subject: [PATCH 03/11] fix: remove PyPI-blocking git-URL dependency for tree-sitter-haxe PyPI/Warehouse rejects any package upload whose metadata contains a direct URL/VCS dependency. graphifyy is actively published to PyPI, so the haxe extra's git+https dependency would block every future release of the package, not just fail to build for haxe users. Drop the extra entirely and document a manual pip install git+https://github.com/masquepublishing/tree-sitter-haxe.git step in the README instead, matching how the project treats every other grammar with install friction (real PyPI name, or nothing) - there is no existing precedent for a non-PyPI dependency in pyproject.toml. --- CHANGELOG.md | 2 +- README.md | 12 +++++++++++- pyproject.toml | 7 +++---- tests/test_languages.py | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2262e2948..3e7a8cca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased -- Feat: Haxe (`.hx`) language support via `tree-sitter-haxe`. Extracts classes, interfaces, enums, enum abstracts, typedefs, and functions. Includes a fallback pass for files where the grammar emits scattered tokens instead of declaration nodes. Install with `pip install "graphifyy[haxe]"`. +- Feat: Haxe (`.hx`) language support via `tree-sitter-haxe`. Extracts classes, interfaces, enums, enum abstracts, typedefs, and functions. Includes a fallback pass for files where the grammar emits scattered tokens instead of declaration nodes. `tree-sitter-haxe` has no PyPI release, so there is no `graphifyy` extra for it — install with `pip install git+https://github.com/masquepublishing/tree-sitter-haxe.git`. ## 0.9.5 (2026-07-02) diff --git a/README.md b/README.md index 06954c0c4..8560868f3 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,16 @@ Install only what you need: | `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` | | `all` | Everything above | `uv tool install "graphifyy[all]"` | +Haxe is not in this table: `tree-sitter-haxe` has no PyPI release (upstream +`vantreeseba/tree-sitter-haxe` hasn't cut one), and PyPI rejects any package +upload whose metadata contains a direct URL/VCS dependency — so it can't be +declared as a `graphifyy` extra without blocking every future release of this +package. Install the patched fork manually to enable `.hx` support: + +```bash +pip install git+https://github.com/masquepublishing/tree-sitter-haxe.git +``` + --- ## Make your assistant always use the graph @@ -246,7 +256,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| | Code (37 tree-sitter grammars) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | -| Haxe | `.hx` (requires `uv tool install graphifyy[haxe]`; classes, interfaces, enums, enum abstracts, typedefs, functions) | +| Haxe | `.hx` (requires `pip install git+https://github.com/masquepublishing/tree-sitter-haxe.git`; not a PyPI package, so no `graphifyy` extra exists for it — see below; classes, interfaces, enums, enum abstracts, typedefs, functions) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | diff --git a/pyproject.toml b/pyproject.toml index 787287155..7a6da3229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,10 +76,9 @@ sql = ["tree-sitter-sql"] dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] # tree-sitter-haxe is not yet published on PyPI (upstream vantreeseba/tree-sitter-haxe -# hasn't cut a release); pull our patched fork straight from git in the meantime. -# Excluded from `all` — an unresolvable PyPI name would break `uv sync`/CI for -# everyone, not just fail to build (#1307). -haxe = ["tree-sitter-haxe @ git+https://github.com/masquepublishing/tree-sitter-haxe.git"] +# hasn't cut a release). PyPI rejects uploads containing a direct URL/VCS dependency +# in Requires-Dist, so it cannot be declared as an extra here without blocking every +# future `graphifyy` release — install it manually, see README (#1307). all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"] [project.scripts] diff --git a/tests/test_languages.py b/tests/test_languages.py index 797d2f35d..b6c59e910 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -2915,7 +2915,7 @@ def test_decldef_merge_does_not_merge_same_name_same_dir_distinct_files(): _needs_haxe = pytest.mark.skipif( _ilu.find_spec("tree_sitter_haxe") is None, - reason="tree-sitter-haxe not installed (optional [haxe] extra)", + reason="tree-sitter-haxe not installed (no PyPI release; see README)", ) @_needs_haxe From cdbc72f451bbd4cbd88f9ca34960ac39ebcb237d Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 18:26:12 -0600 Subject: [PATCH 04/11] fix(haxe): stop resolving unmatched calls to a global bare-name id walk_calls() fell back to _make_id(call_name) -- a bare, language-unscoped id -- whenever a call target wasn't found in the same file. Every other language extractor in this codebase deliberately avoids this (see the generic call-walk's per-file label_to_nid lookup and the #543/#1219 god-node comments in _resolve_cross_file_*): a bare name lookup collides across the whole depot and produces wrong edges far more often than a real one. Measured against the Masque depot's graph.json before this fix: 34,823 Haxe `calls` edges, of which 8,081 (23%) targeted a node in an unrelated language by name coincidence (e.g. a Haxe `textSprite()` call resolving to an unrelated Dev/Poker/Client/GraphObjs.h), plus 1,451 more resolving to a dangling placeholder node. Fix: only resolve within the same file. Since methods are stored under a class-qualified id (_make_id(stem, class_name, method)), also try that scoped id for same-class sibling-method calls (the idiomatic case, covering both `this.foo()` and bare `foo()` self-calls) before giving up -- the original file-only bare check never matched these either. No cross-file resolver exists for Haxe, so a call unresolved after both checks is now dropped rather than guessed at. Verified: same-class calls (this.baz(), bare baz()) still resolve; unresolvable calls now produce no edge instead of a wrong one; a real depot file (com/masque/poker/GameInfo.hx) resolves its constructor dispatch correctly. tests/test_extract.py unchanged (19 pre-existing failures from missing local grammar packages, 90 passing, before and after). --- graphify/extract.py | 47 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index e41a809f0..b610b25b9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -15995,20 +15995,45 @@ def _haxe_call_name(call_node) -> str: return _read_text(sub[-1], source) return "" - def walk_calls(node, owner_nid: str) -> None: + def walk_calls(node, owner_nid: str, class_name: "str | None") -> None: """Walk a function body collecting call edges; stops at nested function boundaries.""" if node.type == "function_declaration": return if node.type == "call_expression": call_name = _haxe_call_name(node) if call_name and call_name not in _LANGUAGE_BUILTIN_GLOBALS: - tgt_nid = _make_id(stem, call_name) - if tgt_nid not in seen_ids: - tgt_nid = _make_id(call_name) - line = node.start_point[0] + 1 - add_edge(owner_nid, tgt_nid, "calls", line) + # Only resolve within the same file. Every other language's + # extractor in this codebase deliberately stops here too when + # it can't otherwise disambiguate (see the generic call-walk's + # per-file label_to_nid lookup and the #543/#1219 god-node + # comments in _resolve_cross_file_*): a bare, language-unscoped + # name lookup collides across the whole depot and produces + # wrong edges (e.g. a Haxe `textSprite()` call resolving to an + # unrelated `Dev/Poker/Client/GraphObjs.h` by name coincidence) + # far more often than it produces a real one. No cross-file + # resolver exists for Haxe, so an unresolved call here is + # simply dropped rather than guessed at. + # + # `_haxe_call_name` doesn't distinguish a bare call, a + # `this.foo()` call, and an `other.foo()` call — all three + # come back as just "foo" — so a same-class sibling-method + # call (the idiomatic case, since methods are stored under a + # class-qualified id) has to be tried explicitly here rather + # than falling out of a single file-scoped lookup. + tgt_nid = None + if class_name is not None: + candidate = _make_id(stem, class_name, call_name) + if candidate in seen_ids: + tgt_nid = candidate + if tgt_nid is None: + candidate = _make_id(stem, call_name) + if candidate in seen_ids: + tgt_nid = candidate + if tgt_nid is not None: + line = node.start_point[0] + 1 + add_edge(owner_nid, tgt_nid, "calls", line) for child in node.children: - walk_calls(child, owner_nid) + walk_calls(child, owner_nid, class_name) def _haxe_dotted_path(node) -> str: """Reconstruct dotted package path from an import/using statement.""" @@ -16104,7 +16129,7 @@ def walk(node, parent_class_nid: "str | None" = None, add_edge(file_nid, func_nid, "contains", line) fn_body = node.child_by_field_name("body") if fn_body is not None: - function_bodies.append((func_nid, fn_body)) + function_bodies.append((func_nid, fn_body, parent_class_name)) return for child in node.children: @@ -16122,8 +16147,8 @@ def walk(node, parent_class_nid: "str | None" = None, _haxe_recover_scattered(root, source, stem, file_nid, add_node, add_edge, seen_ids, function_bodies) - for func_nid, body in function_bodies: - walk_calls(body, func_nid) + for func_nid, body, class_name in function_bodies: + walk_calls(body, func_nid, class_name) return {"nodes": nodes, "edges": edges} @@ -16176,7 +16201,7 @@ def _haxe_recover_scattered( add_edge(class_nid, fn_nid, "method", fn_line) fn_body = sib.child_by_field_name("body") if fn_body is not None: - function_bodies.append((fn_nid, fn_body)) + function_bodies.append((fn_nid, fn_body, class_name)) elif sib.type in ("class_declaration", "interface_declaration", "enum_declaration", "enum_abstract_declaration"): break From 9c0422ae4ecec6a1030fd54cb310f77088c16ccb Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 18:26:21 -0600 Subject: [PATCH 05/11] fix(cache): fall back to git commit when package metadata is unavailable _EXTRACTOR_VERSION namespaces the AST cache so an extractor code change invalidates stale entries (see the comment above it). It was computed via importlib.metadata.version("graphifyy"), which only resolves for a pip -installed copy -- this repo's own documented workflow (code-map/CLAUDE.md in the depot that vendors this fork) uses it purely via PYTHONPATH, never pip install, so the lookup always raised and silently collapsed to a constant "unknown" bucket. That bucket never changes across extractor fixes, so a file whose content hasn't changed keeps serving an arbitrarily old cached extraction result forever, masking the effect of real fixes (e.g. #1581's stub-rewire case-guard) for any file that wasn't independently touched. Try the package-metadata lookup first, so a pip-installed copy keeps working exactly as designed; fall back to this checkout's git commit otherwise, so a PYTHONPATH-only consumer gets the same invalidate -on-change behavior a pip install gets for free. --- graphify/cache.py | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/graphify/cache.py b/graphify/cache.py index bb3f9d593..67cbc3eaa 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -23,12 +23,47 @@ # use. The semantic cache is deliberately NOT versioned — its entries are # produced by the LLM from file contents, and invalidating them on every # release would re-bill extraction for unchanged files. -try: - from importlib.metadata import version as _pkg_version +def _detect_extractor_version() -> str: + """Best-effort version string for AST cache namespacing. + + Installed-package metadata is tried first, so this keeps working exactly + as designed if graphify is ever `pip install`ed. But a checkout used + purely via PYTHONPATH (this repo's own documented workflow — see + code-map/CLAUDE.md's Setup section, which never runs `pip install` for + graphify itself) never registers package metadata, so that lookup always + raises and this used to collapse to a constant "unknown" bucket that + never invalidated across extractor code changes, silently serving + arbitrarily stale per-file results forever. Falling back to this + checkout's git commit gives PYTHONPATH-only use the same invalidate-on + -change behavior pip installs get for free. + """ + try: + from importlib.metadata import version as _pkg_version + + return _pkg_version("graphifyy") + except Exception: + pass + try: + import subprocess + + repo_root = Path(__file__).resolve().parent.parent + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=repo_root, + capture_output=True, + text=True, + timeout=5, + check=True, + ) + commit = result.stdout.strip() + if commit: + return f"git-{commit}" + except Exception: + pass + return "unknown" + - _EXTRACTOR_VERSION = _pkg_version("graphifyy") -except Exception: - _EXTRACTOR_VERSION = "unknown" +_EXTRACTOR_VERSION = _detect_extractor_version() # Version dirs already swept this process — cleanup runs once per (base, version). _cleaned_ast_dirs: set[str] = set() From 9350a8cbafda6b61290ad73840577b6813a45c64 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 19:34:03 -0600 Subject: [PATCH 06/11] fix(cpp): route base-class stub creation through ensure_named_node() The C++ base_class_clause handler built its own sourceless-stub dict inline instead of calling the shared ensure_named_node() helper every other language's inheritance/conformance handling in this file already uses. It predates ensure_named_node() picking up an origin_file tag on the stub it creates (needed so _disambiguate_colliding_node_ids can tell one file's unresolved reference apart from another's), and was never updated to match. Without that tag, every C++ file's same-named unresolved base class collapsed onto one shared bare id instead of being salted apart by origin file -- and a bare id can legitimately (per _rewire_unique_stub_nodes' otherwise-correct exact-case matching) collide with a same-spelled real definition in a completely different language. Concretely: ~163 different C++ files' `class Foo : public mTimer` references (a real, shared C++ timer base class in Dev/mShell2) all shared one unresolved stub id, and that stub's label happened to get overwritten by a same-normalized reference elsewhere spelled with capital lettering, which then exact-case-matched an unrelated Haxe class also named MTimer -- 218 wrong `inherits` edges into haxe/src/com/masque/core/MTimer.hx from unrelated C++ server code. Fix: delete the inline duplicate and call ensure_named_node(base, line) directly, matching the Python/Swift/PHP/Kotlin/C#/Java/Scala inheritance handlers already in this function. Verified against the real depot: the 218 wrong edges are gone, all fall back to correctly-scoped per-file stub ids as intended. --- graphify/extract.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index b610b25b9..9e05e2ced 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4153,18 +4153,18 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: continue if not base: continue - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) + # Was a hand-inlined copy of ensure_named_node()'s + # same-file-then-bare-stub logic that predated (and never + # picked up) the origin_file tag ensure_named_node() sets + # on the stub it creates. Without that tag, + # _disambiguate_colliding_node_ids can't tell one file's + # unresolved base-class reference apart from another's, + # so every C++ file's same-named unresolved base class + # collapsed onto one shared bare id -- which could then + # exact-case-collide with a same-named real class in a + # completely different language (e.g. a C++ `mTimer` + # base class merging with an unrelated Haxe `MTimer`). + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # Emit a generic_arg reference for each type argument on the # base (Base -> Car references Dep). _cpp_collect_type_refs From 324ec9a4373b86647e897dabda8a024d7081674a Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 19:54:22 -0600 Subject: [PATCH 07/11] fix(extract): tag inline base-class stubs with origin_file ensure_named_node() tags the sourceless stub it creates for an unresolved reference with origin_file, so _disambiguate_colliding_node_ids can tell one file's unresolved reference apart from another's instead of merging every file's same-named reference onto one shared bare id (which can then collide with an unrelated same-named real definition anywhere else in the corpus, since ids are case-normalized and global). Five call sites duplicated that stub-creation logic inline instead of calling ensure_named_node() -- Ruby's `Class.new(Super)` and `class Foo < Base` inheritance, Python inheritance, Kotlin delegation -specifier inheritance/conformance, and C++ base_class_clause inheritance -- and none of them were updated when origin_file was added, so all five still produce the un-disambiguated bare-id stub the fix was meant to eliminate. Four of the five sit directly inside _extract_generic, where ensure_named_node() is already in scope as a closure, so they're switched to call it directly. The fifth (Ruby's `Class.new(Super)`) is handled by a separate helper, _ruby_extra_walk(), which doesn't have that closure in scope; that one gets the same origin_file tag added directly to its own inline stub dict, matching what ensure_named_node() already does, without changing the helper's signature. (A sixth occurrence of the same inline pattern exists in extract_apex(), a fully separate regex-based extractor with no ensure_named_node() equivalent of its own -- left out of this fix, which is scoped to the shared _extract_generic path and its one directly-affiliated helper.) Added a regression test: two different C++ files each inheriting from the same undefined base class must produce two distinct stub nodes, not one shared one. Fails on main (one shared 'base' id for both files), passes with this fix. --- graphify/extract.py | 60 ++++++++----------------------------------- tests/test_extract.py | 31 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index dea5bd0b3..e5ee1c3df 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3424,10 +3424,16 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st if base_nid not in seen_ids: base_nid = _make_id(base) if base_nid not in seen_ids: + # origin_file lets _disambiguate_colliding_node_ids + # tell this file's unresolved reference apart from + # another file's same-named one, instead of every + # file's stub collapsing onto one shared bare id + # (see ensure_named_node(), which sets the same + # field for this exact reason). nodes.append({ "id": base_nid, "label": base, "file_type": "code", "source_file": "", - "source_location": "", + "source_location": "", "origin_file": str_path, }) seen_ids.add(base_nid) add_edge(class_nid, base_nid, "inherits", line) @@ -3681,18 +3687,7 @@ def walk(node, parent_class_nid: str | None = None) -> None: for arg in args.children: if arg.type == "identifier": base = _read_text(arg, source) - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # Swift-specific: conformance / inheritance @@ -3831,18 +3826,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: base = _kotlin_user_type_name(user_type_node, source) if not base: continue - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, relation, line) for arg_child in user_type_node.children: if arg_child.type != "type_arguments": @@ -3876,18 +3860,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None: base = _read_text(consts[-1], source) break if base: - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # `include`/`extend`/`prepend ` in the class/module body -> @@ -4153,18 +4126,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: continue if not base: continue - base_nid = _make_id(stem, base) - if base_nid not in seen_ids: - base_nid = _make_id(base) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) + base_nid = ensure_named_node(base, line) add_edge(class_nid, base_nid, "inherits", line) # Emit a generic_arg reference for each type argument on the # base (Base -> Car references Dep). _cpp_collect_type_refs diff --git a/tests/test_extract.py b/tests/test_extract.py index 7cae30776..073ed1e7f 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -100,6 +100,37 @@ def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path): assert edge["target"] in node_ids, f"Dangling structural target: {edge}" +def test_cpp_unresolved_base_class_stubs_stay_disambiguated_by_file(tmp_path): + """Two different files' same-named, otherwise-undefined base class must not + collapse onto one shared stub node. + + The C++ base_class_clause handler used to build its stub inline instead of + calling ensure_named_node(), so it never tagged the stub with origin_file. + Without that tag, _disambiguate_colliding_node_ids couldn't tell file A's + reference to unresolved `Base` apart from file B's, and every file's + unresolved base class merged onto one bare id -- which could then collide + with an unrelated same-named real definition anywhere else in the corpus. + """ + first = tmp_path / "a" / "Foo.cpp" + second = tmp_path / "b" / "Bar.cpp" + first.parent.mkdir(parents=True) + second.parent.mkdir(parents=True) + first.write_text("class Foo : public Base {};\n", encoding="utf-8") + second.write_text("class Bar : public Base {};\n", encoding="utf-8") + + result = extract([first, second], cache_root=tmp_path) + base_stubs = [ + node for node in result["nodes"] + if node["label"] == "Base" and not node.get("source_file") + ] + assert len(base_stubs) == 2 + assert len({node["id"] for node in base_stubs}) == 2 + + inherits_edges = [e for e in result["edges"] if e["relation"] == "inherits"] + assert len(inherits_edges) == 2 + assert len({e["target"] for e in inherits_edges}) == 2 + + def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path): """#1402: a class defined once but referenced via type annotations in N other files must NOT create 1+N phantom duplicate nodes (with the referencing file's From af338221130c178e09c6910491b53d85ed77bbe1 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 21:59:43 -0600 Subject: [PATCH 08/11] fix(explain): warn on cross-file label collisions, prefer exact case _find_node folded case unconditionally, so a query like "GameInfo" (a real Haxe class) and an unrelated field named "gameInfo" elsewhere were indistinguishable -- whichever the graph happened to iterate first won, silently. `explain` also never warned on ambiguous matches at all, unlike `path`'s existing score-gap check. Split _find_node into a tier-returning _find_node_tiers (same matching, tier boundaries preserved) plus _find_node as a thin flattening wrapper, so `explain` can detect "the top match wasn't unique" instead of just taking matches[0]. Within a tier, _prefer_case_exact now sorts an exact-case label match first. The warning only fires when tied candidates span multiple source files -- a class and its own same-named constructor method (e.g. the TableRules class vs. its TableRules() constructor) legitimately share a bare label from one file, and that's normal Haxe/C++ structure, not a collision worth flagging. Found via graphify-practice round 2: explain "GameInfo" was silently resolving to a struct field in Dev/Bingo/Server/BingoGameMaster.h instead of the Haxe class, and explain "Player" was silently picking one of 33 same-named nodes across 30 files with no indication others existed. --- graphify/__main__.py | 21 +++++++++++-- graphify/serve.py | 54 +++++++++++++++++++++++++++----- tests/test_explain_cli.py | 65 +++++++++++++++++++++++++++++++++++++++ tests/test_serve.py | 29 +++++++++++++++++ 4 files changed, 159 insertions(+), 10 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 8d37c911e..7f1283248 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -3265,7 +3265,7 @@ def main() -> None: if len(sys.argv) < 3: print('Usage: graphify explain "" [--graph path]', file=sys.stderr) sys.exit(1) - from graphify.serve import _find_node + from graphify.serve import _find_node_tiers from networkx.readwrite import json_graph label = sys.argv[2] @@ -3288,10 +3288,27 @@ def main() -> None: G = json_graph.node_link_graph(_raw, edges="links") except TypeError: G = json_graph.node_link_graph(_raw) - matches = _find_node(G, label) + tiers = _find_node_tiers(G, label) + matches = [nid for tier in tiers for nid in tier] if not matches: print(f"No node matching '{label}' found.") sys.exit(0) + top_tier = next(t for t in tiers if t) + # A class and its own same-named constructor method (e.g. "TableRules" + # the class and "TableRules()" the method) legitimately share a bare + # label and land in the same tier — that's not a meaningful collision, + # just Haxe/C++ constructor-naming convention, and warning on it would + # fire for nearly every class lookup depot-wide. Only warn when the + # tied candidates actually come from different source files. + distinct_sources = {G.nodes[n].get("source_file", "") for n in top_tier} + if len(top_tier) >= 2 and len(distinct_sources) >= 2: + print( + f"warning: '{label}' match was ambiguous ({len(top_tier)} " + f"equally-ranked matches across {len(distinct_sources)} files) — " + f"showing '{G.nodes[matches[0]].get('label', matches[0])}' from " + f"{G.nodes[matches[0]].get('source_file', '?') or '(no source)'}", + file=sys.stderr, + ) nid = matches[0] d = G.nodes[nid] print(f"Node: {d.get('label', nid)}") diff --git a/graphify/serve.py b/graphify/serve.py index 6d8b369df..5945a98ab 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -654,16 +654,38 @@ def _query_graph_text( return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget) -def _find_node(G: nx.Graph, label: str) -> list[str]: - """Return node IDs whose label or ID matches the search term (diacritic-insensitive). - - Results are ordered by precedence: exact source-file path match first, then - exact (label/ID) match, then prefix match, then substring match. Node-ID exact - matches are grouped with label exact matches. +def _prefer_case_exact(ids: list[str], G: nx.Graph, label: str) -> list[str]: + """Reorder same-tier matches so an exact-case label match sorts first. + + Every tier in _find_node_tiers folds case (via norm_label/norm_query), so a + query like "GameInfo" and an unrelated node labeled "gameInfo" land in the + same tier with no signal distinguishing them — whichever the graph happens + to iterate first wins arbitrarily. When one candidate matches the query's + exact casing, that's the strictly more likely intent. + """ + if len(ids) < 2: + return ids + case_exact, rest = [], [] + for nid in ids: + raw_label = str(G.nodes[nid].get("label") or "") + target = case_exact if raw_label == label or raw_label.rstrip("()") == label else rest + target.append(nid) + return case_exact + rest if case_exact else ids + + +def _find_node_tiers(G: nx.Graph, label: str) -> list[list[str]]: + """Same matching as _find_node, but with each precedence tier kept separate. + + Tiers, in precedence order: source-file exact match, exact (label/ID) + match, prefix match, substring match. A flattened list can't tell "the top + match is unique" apart from "the top match just happens to sort first + among several equally-ranked candidates" — callers that need to warn on + that collision (see `graphify explain`) should use this instead of + `_find_node` and check whether the first non-empty tier has >1 member. """ term = " ".join(_search_tokens(label)) if not term: - return [] + return [[], [], [], []] # Punctuation-preserving normalized query. `term` tokenizes on \w+ (so # "blockStream.ts" -> "blockstream ts", space where the '.' was), but a node's # stored `norm_label` keeps punctuation ("blockstream.ts"). Matching only via @@ -719,7 +741,23 @@ def _find_node(G: nx.Graph, label: str) -> list[str]: if len(preferred) == 1: source_exact = preferred + [nid for nid in source_exact if nid != preferred[0]] - return source_exact + exact + prefix + substring + return [ + source_exact, + _prefer_case_exact(exact, G, label), + _prefer_case_exact(prefix, G, label), + _prefer_case_exact(substring, G, label), + ] + + +def _find_node(G: nx.Graph, label: str) -> list[str]: + """Return node IDs whose label or ID matches the search term (diacritic-insensitive). + + Results are ordered by precedence: exact source-file path match first, then + exact (label/ID) match, then prefix match, then substring match. Node-ID exact + matches are grouped with label exact matches. See `_find_node_tiers` for the + same matching with tier boundaries preserved (needed to detect ambiguity). + """ + return [nid for tier in _find_node_tiers(G, label) for nid in tier] def _filter_blank_stdin() -> None: diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index 7759f30e3..739e33640 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -56,6 +56,71 @@ def test_caller_shows_callee_as_outbound(monkeypatch, tmp_path, capsys): assert "<-- " not in out +def test_explain_warns_on_ambiguous_match_and_resolves_case_exact(monkeypatch, tmp_path, capsys): + """Regression: explain silently picked matches[0] with no ambiguity signal, + unlike `path`'s existing score-gap warning (#found in graphify-practice + round 2). Two same-named nodes should trigger a stderr warning; when one + is an exact-case match, it should still be preferred deterministically.""" + graph_data = { + "directed": False, "multigraph": False, "graph": {}, + "nodes": [ + {"id": "field1", "label": "gameInfo", + "source_file": "Dev/Bingo/Server/BingoGameMaster.h", "community": 0}, + {"id": "class1", "label": "GameInfo", + "source_file": "haxe/src/com/masque/poker/GameInfo.hx", "community": 0}, + ], + "links": [], + } + p = tmp_path / "graph.json" + p.write_text(json.dumps(graph_data)) + + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", + ["graphify", "explain", "GameInfo", "--graph", str(p)]) + mainmod.main() + captured = capsys.readouterr() + assert "Node: GameInfo" in captured.out + assert "ID: class1" in captured.out + assert "ambiguous" in captured.err + + +def test_explain_no_ambiguity_warning_for_same_file_class_and_constructor(monkeypatch, tmp_path, capsys): + """A class and its own same-named constructor method (Haxe/C++ convention) + share a bare label and land in the same match tier — that's expected + structure, not a real collision, and must not trigger the ambiguity + warning (would otherwise fire on nearly every class lookup depot-wide).""" + graph_data = { + "directed": False, "multigraph": False, "graph": {}, + "nodes": [ + {"id": "class1", "label": "TableRules", + "source_file": "haxe/src/com/masque/cardGames/TableRules.hx", "community": 0}, + {"id": "ctor1", "label": "TableRules()", + "source_file": "haxe/src/com/masque/cardGames/TableRules.hx", "community": 0}, + ], + "links": [], + } + p = tmp_path / "graph.json" + p.write_text(json.dumps(graph_data)) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", + ["graphify", "explain", "TableRules", "--graph", str(p)]) + mainmod.main() + captured = capsys.readouterr() + assert "ambiguous" not in captured.err + assert "Node: TableRules" in captured.out + + +def test_explain_no_ambiguity_warning_for_unique_match(monkeypatch, tmp_path, capsys): + p = _write_graph(tmp_path) + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", + ["graphify", "explain", "validateSanitySession", "--graph", str(p)]) + mainmod.main() + captured = capsys.readouterr() + assert "ambiguous" not in captured.err + assert "Node: validateSanitySession()" in captured.out + + def test_explain_source_file_path_prefers_file_level_node(monkeypatch, tmp_path, capsys): source_file = "app/api/example/route.ts" graph_data = { diff --git a/tests/test_serve.py b/tests/test_serve.py index 9bf9aa6a8..7f6dc6cff 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -12,6 +12,7 @@ _bfs, _dfs, _find_node, + _find_node_tiers, _trigrams, _node_search_text, _get_trigram_index, @@ -147,6 +148,34 @@ def test_find_node_matches_punctuated_file_label_exactly(): assert _find_node(G, "blockStream.test.ts")[0] == "f2" +def test_find_node_prefers_exact_case_match_over_case_insensitive_collision(): + # _find_node folds case for matching (norm_label/norm_query), so a query for + # "GameInfo" (a real class) and a field elsewhere named "gameInfo" land in + # the same exact tier with no signal telling them apart — the exact-case + # candidate should win deterministically rather than whichever the graph + # happens to iterate first. + G = nx.Graph() + G.add_node("field1", label="gameInfo", norm_label="gameinfo", + source_file="Dev/Bingo/Server/BingoGameMaster.h", source_location="L40") + G.add_node("class1", label="GameInfo", norm_label="gameinfo", + source_file="haxe/src/com/masque/poker/GameInfo.hx", source_location="L1") + assert _find_node(G, "GameInfo")[0] == "class1" + # The case-insensitive-only match is still reachable, just ranked second. + assert set(_find_node(G, "GameInfo")) == {"field1", "class1"} + + +def test_find_node_tiers_exposes_genuinely_ambiguous_top_tier(): + # Two exact-case, same-labeled nodes from different files/languages: no + # signal disambiguates them, so the top tier should retain both entries + # for a caller (e.g. `graphify explain`) to detect and warn about. + G = nx.Graph() + G.add_node("p1", label="Player", norm_label="player", source_file="Dev/Big6/Server/Player.h") + G.add_node("p2", label="Player", norm_label="player", source_file="haxe/src/com/masque/poker/Player.hx") + tiers = _find_node_tiers(G, "Player") + top_tier = next(t for t in tiers if t) + assert set(top_tier) == {"p1", "p2"} + + def test_find_node_resolves_when_label_and_norm_label_diverge(): # #1704 hardening: the tokenized-label tier only rescues the match by # coincidence (label tokenizes the same as the query). When `label` and From 83820db46fe9270c58bb15657aa33c9fd7fc61d6 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 23:12:39 -0600 Subject: [PATCH 09/11] fix(build): don't let an ambiguous legacy-stem alias silently merge files build_from_json's "pre-migration alias index" (#1504) registers each real file's OLD-style bare-stem id (extension dropped) as an alias so a stale cached fragment referencing that old form still resolves after an id-scheme migration. It never checked for collisions: two unrelated real files easily compute the same bare alias (e.g. "ping.h" and "ping.php" in different directories both alias to "ping"), and dict.setdefault let whichever file happened to iterate first in a Python set win, arbitrarily. A dangling edge with a bare, deliberately-unscoped fallback target (e.g. the C/C++ extractor's last-resort id for an #include it couldn't resolve to a real path) would ride that alias onto whatever unrelated file won the collision -- silently wiring, say, a C++ server file to an unrelated PHP script, purely because both files happen to share a common basename somewhere in the corpus. Now every candidate for an alias is collected before any of them are committed to norm_to_id, and the alias is only trusted when exactly one real file claims it. Ambiguous aliases are dropped entirely, so the dangling edge correctly stays dangling (and gets discarded) instead of merging two unrelated files -- same "don't guess through ambiguity" principle already applied to stub-node disambiguation and cross-file call resolution elsewhere in this codebase. Found via graphify-practice round 2: a `path` query between two unrelated symbols routed through exactly this kind of bogus edge, traced back to Dev/Poker/TDServer/server.cpp's unresolved `#include "ping.h"` / `#include "utility.h"` landing on unrelated www.masque.com PHP scripts of the same bare name. --- graphify/build.py | 20 +++++++++++++-- tests/test_build.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 279eccbf4..077db58ca 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -507,7 +507,20 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # fragment (e.g. an incremental update whose fragment references a symbol in a # file that was NOT re-extracted) still resolves to the migrated node instead # of dangling. Only fills gaps — never overrides a real node id. + # + # The old-stem form drops the extension and (for the file node itself) every + # directory but the immediate parent, so it collapses easily: "ping.h" and + # "ping.php" in different directories both alias to bare "ping". Collecting + # every candidate for an alias BEFORE committing any of them — and only + # committing when exactly one candidate claims it — keeps this a precise + # re-keying aid instead of a silent cross-file (and cross-language) merge. + # Without this, a dangling edge to a bare, deliberately-unscoped fallback id + # (e.g. the C/C++ extractor's last-resort target for an #include it couldn't + # resolve to a real path) could ride this alias onto whichever unrelated + # same-stem file happened to be inserted first into ``node_set`` — a Python + # set, so "first" is hash-order, not anything meaningful. from graphify.extractors.base import _file_stem as _fs + _alias_candidates: dict[str, set[str]] = {} for nid in node_set: attrs = G.nodes[nid] sf = attrs.get("source_file") @@ -524,8 +537,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if old_stem == new_stem: continue alias = old_stem + suffix - norm_to_id.setdefault(_normalize_id(alias), nid) - norm_to_id.setdefault(alias, nid) + _alias_candidates.setdefault(_normalize_id(alias), set()).add(nid) + _alias_candidates.setdefault(alias, set()).add(nid) + for alias_key, candidates in _alias_candidates.items(): + if len(candidates) == 1: + norm_to_id.setdefault(alias_key, next(iter(candidates))) # Iterate edges in a deterministic order. The graph is undirected and stores # direction in _src/_tgt; when two edges collapse onto the same node pair the # last write wins, so an unstable iteration order flips _src/_tgt run-to-run diff --git a/tests/test_build.py b/tests/test_build.py index 2d8bfdd60..1e1d5b56e 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -505,6 +505,65 @@ def test_build_relativizes_absolute_source_file(tmp_path): assert sf == "src/main.py" +def test_build_from_json_ambiguous_old_stem_alias_stays_dangling(tmp_path): + """The #1504 old-stem alias (e.g. "ping.h" -> bare "ping") is meant to let a + stale-id edge from an un-re-extracted fragment still find its own file after + a rekey. But the old-stem form drops the extension and most of the path, so + two unrelated real files easily collapse onto the same bare alias (a C header + and a PHP script both named "ping", in different directories). A dangling + edge produced by an unrelated third file's own unscoped fallback id (e.g. the + C/C++ extractor's last-resort target for an #include it couldn't resolve to + a real path) must not silently ride that alias onto an arbitrary one of them + — it should stay dangling and get dropped, same as any other unresolvable + edge, rather than wire two unrelated files/languages together by accident.""" + root = tmp_path / "repo" + root.mkdir() + extraction = { + "nodes": [ + # Ids given in their canonical (post-extract.py, extension-stripped) + # form, matching what a real graphify update run would already have + # produced before build_from_json assembles the final graph. + {"id": "dev_monitoring_ping", "label": "ping.h", "file_type": "code", + "source_file": "Dev/monitoring/ping.h"}, + {"id": "www_pages_api_ping", "label": "ping.php", "file_type": "code", + "source_file": "www/pages/api/ping.php"}, + {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code", + "source_file": "Dev/poker/server.cpp"}, + ], + "edges": [ + # The unscoped, deliberately-unresolved fallback edge a C/C++ #include + # resolver leaves behind when it can't find the header on disk. + {"source": "dev_poker_server", "target": "ping", "relation": "imports", + "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"}, + ], + } + G = build_from_json(extraction, root=root) + assert not G.has_edge("dev_poker_server", "dev_monitoring_ping") + assert not G.has_edge("dev_poker_server", "www_pages_api_ping") + + +def test_build_from_json_unambiguous_old_stem_alias_still_resolves(tmp_path): + """Companion to the ambiguous case above: when exactly one real file claims + an old-stem alias, a dangling edge to that bare alias should still resolve + to it — the #1504 migration-compat behavior this index exists for.""" + root = tmp_path / "repo" + root.mkdir() + extraction = { + "nodes": [ + {"id": "dev_monitoring_utility", "label": "utility.h", "file_type": "code", + "source_file": "Dev/monitoring/utility.h"}, + {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code", + "source_file": "Dev/poker/server.cpp"}, + ], + "edges": [ + {"source": "dev_poker_server", "target": "utility", "relation": "imports", + "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"}, + ], + } + G = build_from_json(extraction, root=root) + assert G.has_edge("dev_poker_server", "dev_monitoring_utility") + + def test_build_from_json_relative_source_file_unchanged(tmp_path): """Already-relative source_file paths must not be modified.""" extraction = { From 0767a6fbba80c9bfa50b4e74848f0267a3ec36f0 Mon Sep 17 00:00:00 2001 From: mallyskies Date: Mon, 6 Jul 2026 23:54:14 -0600 Subject: [PATCH 10/11] fix(build): recognize a salted file node as its own alias claimant Follow-up to 83820db. That fix's ambiguity check missed a case: it detects "is this node the file itself" by checking whether the node's id starts with the file's plain new_stem, but a same-directory .h/.cpp pair that collides on their shared pre-extension id gets salted apart by _disambiguate_colliding_node_ids into ids like "tools_aolserver_utility_h_tools_aolserver_utility" -- no longer a clean new_stem prefix. That salted header silently failed to compute an empty suffix, so it never entered the bare "utility" alias race at all, leaving an unrelated wwwapi.masque.com/pages/utility.php as the lone (wrong) "unambiguous" winner -- reproduced exactly against the real depot's Tools/aolserver/utility.h and .cpp. Detect "this node IS the file" by label instead: every file node's label is its own basename regardless of what its id looks like after salting. That keeps a salted file node in the alias competition, so the real collision between the C header and the PHP file is correctly caught as ambiguous. --- graphify/build.py | 21 ++++++++++++++++++--- tests/test_build.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/graphify/build.py b/graphify/build.py index 077db58ca..b26ddcb61 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -519,6 +519,18 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat # resolve to a real path) could ride this alias onto whichever unrelated # same-stem file happened to be inserted first into ``node_set`` — a Python # set, so "first" is hash-order, not anything meaningful. + # + # A file node's OWN id is not always a clean ``new_stem`` prefix: when a + # same-directory ``.h``/``.cpp`` pair collides on their shared pre-extension + # id, _disambiguate_colliding_node_ids salts both apart into ids like + # ``tools_aolserver_utility_h_tools_aolserver_utility`` — which no longer + # string-prefixes cleanly for the suffix math below. Detecting "this IS the + # file node" by label (every file node's label is its own basename, + # regardless of id mangling) instead of by id shape keeps a salted file node + # in the alias competition, so a genuine collision (a C header AND an + # unrelated same-named PHP script) is still caught as ambiguous instead of + # the header silently dropping out of the race and leaving the PHP file as + # the lone (wrong) "unambiguous" winner. from graphify.extractors.base import _file_stem as _fs _alias_candidates: dict[str, set[str]] = {} for nid in node_set: @@ -530,9 +542,12 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat if rel.is_absolute(): continue new_stem = make_id(_fs(rel)) - suffix = "" - if _normalize_id(nid).startswith(new_stem): - suffix = _normalize_id(nid)[len(new_stem):] # leading "_entity" or "" + if str(attrs.get("label", "")) == rel.name: + suffix = "" # this node IS the file, whatever its (possibly salted) id + else: + suffix = "" + if _normalize_id(nid).startswith(new_stem): + suffix = _normalize_id(nid)[len(new_stem):] # leading "_entity" or "" for old_stem in _old_file_stems(rel): if old_stem == new_stem: continue diff --git a/tests/test_build.py b/tests/test_build.py index 1e1d5b56e..e8c0309a9 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -542,6 +542,40 @@ def test_build_from_json_ambiguous_old_stem_alias_stays_dangling(tmp_path): assert not G.has_edge("dev_poker_server", "www_pages_api_ping") +def test_build_from_json_ambiguous_alias_detected_despite_header_impl_salting(tmp_path): + """A same-directory .h/.cpp pair collides on their shared pre-extension id + and gets salted apart into ids like "tools_aolserver_utility_h_..." — no + longer a clean new_stem prefix. The ambiguity check must still recognize + the salted header as a legitimate claimant for the bare old-stem alias (by + label, not id shape), so a real collision with an unrelated same-named PHP + file is still caught instead of the header silently dropping out of the + race and leaving the PHP file as the lone "unambiguous" winner (this + reproduced against the real depot: Tools/aolserver/utility.h and .cpp, + salted apart, let wwwapi.masque.com/pages/utility.php win the bare + "utility" alias uncontested).""" + root = tmp_path / "repo" + root.mkdir() + extraction = { + "nodes": [ + {"id": "tools_aolserver_utility_h_tools_aolserver_utility", "label": "utility.h", + "file_type": "code", "source_file": "Tools/aolserver/utility.h"}, + {"id": "tools_aolserver_utility_cpp_tools_aolserver_utility", "label": "utility.cpp", + "file_type": "code", "source_file": "Tools/aolserver/utility.cpp"}, + {"id": "wwwapi_masque_com_pages_utility", "label": "utility.php", + "file_type": "code", "source_file": "wwwapi.masque.com/pages/utility.php"}, + {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code", + "source_file": "Dev/poker/server.cpp"}, + ], + "edges": [ + {"source": "dev_poker_server", "target": "utility", "relation": "imports", + "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"}, + ], + } + G = build_from_json(extraction, root=root) + assert not G.has_edge("dev_poker_server", "wwwapi_masque_com_pages_utility") + assert not G.has_edge("dev_poker_server", "tools_aolserver_utility_h_tools_aolserver_utility") + + def test_build_from_json_unambiguous_old_stem_alias_still_resolves(tmp_path): """Companion to the ambiguous case above: when exactly one real file claims an old-stem alias, a dangling edge to that bare alias should still resolve From 572fbd663a615cd8dad41ac45beae738645033fa Mon Sep 17 00:00:00 2001 From: mallyskies Date: Tue, 7 Jul 2026 00:26:58 -0600 Subject: [PATCH 11/11] test: keep the test suite out of the developer's real query log CLI-level tests (test_explain_cli.py etc.) call graphify.__main__.main() directly, which logs a real record per invocation to the same ~/.cache/graphify-queries.log a developer's actual usage writes to. Disables logging for the whole test session via GRAPHIFY_QUERY_LOG_DISABLE rather than backing up/restoring the log file: a restore step can be skipped by a killed test process (Ctrl-C, CI timeout, OOM), clobbering or losing the real log. Disabling never touches the file, so there's nothing to restore. --- tests/conftest.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 835ff5e52..34126a82c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from typing import Any import pytest @@ -18,3 +19,28 @@ def pytest_collection_modifyitems(items: list[Any]) -> None: continue for warning_filter in _ANALYZE_WARNING_FILTERS: item.add_marker(pytest.mark.filterwarnings(warning_filter)) + + +@pytest.fixture(autouse=True, scope="session") +def _isolate_graphify_query_log(): + """Keep the test suite out of the developer's real query log. + + CLI-level tests (test_explain_cli.py and friends) call + graphify.__main__.main() directly, which appends one real record per + invocation to ~/.cache/graphify-queries.log (or $GRAPHIFY_QUERY_LOG) — + querylog.py has no test-awareness. Disabling logging for the whole test + session, rather than backing up and restoring the log file around the + run, avoids a crash-safety gap: if the test process is killed mid-run + (Ctrl-C, a CI timeout, OOM), a restore step might never fire and the + developer's real log would be left clobbered or missing. Disabling never + touches the file at all, so there's nothing to restore. + """ + prior = os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE") + os.environ["GRAPHIFY_QUERY_LOG_DISABLE"] = "1" + try: + yield + finally: + if prior is None: + os.environ.pop("GRAPHIFY_QUERY_LOG_DISABLE", None) + else: + os.environ["GRAPHIFY_QUERY_LOG_DISABLE"] = prior