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/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 be7e99448..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 @@ -15899,6 +15899,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 +16359,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 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/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 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