diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc06fbcf..27f73625e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: an unresolved bare npm import no longer aliases onto an unrelated same-named local file (#1638, thanks @EveX1). `import colors from "tailwindcss/colors"` in a `.tsx` file emitted an `imports_from` edge to the bare id `colors`, and build.py's pre-migration alias index (which registers every local file's bare stem) then remapped it onto an unrelated `backend/utils/colors.py` — a confident (`EXTRACTED`) cross-language phantom edge, and one per `.tsx` file sharing the import. In a real monorepo eight unrelated `.tsx` files all landed on a single Python module. Common package subpaths (`colors`, `utils`, `types`, `config`, `client`) collide this way constantly. The external-import fallback now namespaces its target with the `ref` prefix (the same J-4 convention used for tsconfig `extends`/`$ref` externals), so it can never collapse to a local file/symbol id; the ref-namespaced target has no node, so build drops it as an external reference — the correct outcome for a third-party import. - Fix: `graph.json` node/edge ordering is now stable run-to-run for document/semantic corpora (#1632, thanks @umeshpsatwe). With a parallel LLM backend, `extract_corpus_parallel` merged chunk results in completion order, so which network call happened to return first reordered the nodes and edges even when the model returned identical content — churning `graph.json` between otherwise-identical runs. Chunks are now merged in deterministic submission order after the pool drains (matching the serial path); the progress callback still fires in completion order so long local runs aren't silent. Note: the semantic content the LLM extracts is itself nondeterministic run-to-run — this fix removes the pipeline's own ordering churn, not the model's variance. +- 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`. - Fix: `graphify export obsidian` no longer crashes in `to_canvas` on a dangling community member (#1236 follow-up, thanks @swells808). The original #1236 fix guarded `to_obsidian` but not `to_canvas`, so a community member id with no backing node in the graph still raised `KeyError` while writing `graph.canvas` — after the notes had exported, leaving a partial mirror. `to_canvas` now applies the same dangling-member filter (`m in G and m in node_filenames`) in both the box-sizing and card-layout loops. - Feat: TS/JS member calls on a local `new` binding or a type-annotated parameter now resolve (#1630, thanks @DanielC000). `const s = new Svc(); s.doThing()` and a call on a typed param — including inside a returned closure (`(svc: Svc) => () => svc.doThing()`) — now emit `calls` edges to the receiver type's method, so `affected` no longer silently under-reports. Extends the #1316 `this.field` resolver: the per-file type table now also learns local `new` bindings and bare-typed parameters, and `walk_calls` descends into inline/returned closures (attributing their calls to the enclosing function) instead of stopping at the arrow boundary. Resolution keeps the single-definition guard; an untyped or non-bare-typed (array/union/generic) receiver produces no edge. diff --git a/README.md b/README.md index bd1dff423..18796f584 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,16 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `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 +``` + --- @@ -327,7 +337,8 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .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]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .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]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| 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/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/build.py b/graphify/build.py index 279eccbf4..b26ddcb61 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -507,7 +507,32 @@ 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. + # + # 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: attrs = G.nodes[nid] sf = attrs.get("source_file") @@ -517,15 +542,21 @@ 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 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/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() diff --git a/graphify/detect.py b/graphify/detect.py index c9d7792c7..7d7f33c7b 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', '.mts', '.cts', '.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', '.mts', '.cts', '.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 dea5bd0b3..5a59d1c1a 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,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 @@ -15899,6 +15872,373 @@ 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, 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: + # 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, class_name) + + 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, parent_class_name)) + 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, class_name in function_bodies: + walk_calls(body, func_nid, class_name) + + 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, class_name)) + 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, @@ -15992,6 +16332,7 @@ def _body_of(block): ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".hx": extract_haxe, } 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/pyproject.toml b/pyproject.toml index aa154a49b..2cbc1c39b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,10 @@ pascal = ["tree-sitter-pascal"] # 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). 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", "tree-sitter-pascal"] [project.scripts] 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 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_build.py b/tests/test_build.py index 2d8bfdd60..e8c0309a9 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -505,6 +505,99 @@ 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_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 + 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 = { 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_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 diff --git a/tests/test_languages.py b/tests/test_languages.py index 9aa64b526..ec5df6243 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" @@ -2928,3 +2928,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 (no PyPI release; see README)", +) + +@_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 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