diff --git a/README.md b/README.md index d1b076cb8..91b608493 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ graphify/ ├── export.py Obsidian vault, graph.json, graph.html, graph.svg, Neo4j Cypher, Canvas ├── ingest.py fetch URLs (arXiv, Twitter/X, PDF, any webpage); save Q&A to .graphify/memory/ ├── cache.py SHA256-based per-file extraction cache; check_semantic_cache / save_semantic_cache +├── security.py URL validation (http/https only), safe fetch with size cap, path guards, label sanitisation ├── validate.py JSON schema checks on extraction output ├── serve.py MCP stdio server — query_graph, get_node, get_neighbors, shortest_path, god_nodes └── watch.py fs watcher, writes flag file when new files appear @@ -209,6 +210,7 @@ graphify/ skills/graphify/ └── skill.md the Claude Code skill — the full pipeline the agent runs step by step -tests/ 142 tests, one file per module +SECURITY.md threat model, mitigations, vulnerability reporting +tests/ 163 tests, one file per module pyproject.toml pip install graphify | pip install graphify[mcp,neo4j,pdf,watch] ``` diff --git a/graphify/__main__.py b/graphify/__main__.py index 8f5fa8d47..2da1f6f5c 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1,5 +1,6 @@ """graphify CLI — `graphify install` sets up the Claude Code skill.""" from __future__ import annotations +import json import shutil import sys from pathlib import Path @@ -56,13 +57,28 @@ def main() -> None: print("Usage: graphify ") print() print("Commands:") - print(" install copy skill to ~/.claude/skills/ and register in CLAUDE.md") + print(" install copy skill to ~/.claude/skills/ and register in CLAUDE.md") + print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") print() return cmd = sys.argv[1] if cmd == "install": install() + elif cmd == "benchmark": + from graphify.benchmark import run_benchmark, print_benchmark + graph_path = sys.argv[2] if len(sys.argv) > 2 else ".graphify/graph.json" + # Try to load corpus_words from detect output + corpus_words = None + detect_path = Path(".graphify_detect.json") + if detect_path.exists(): + try: + detect_data = json.loads(detect_path.read_text()) + corpus_words = detect_data.get("total_words") + except Exception: + pass + result = run_benchmark(graph_path, corpus_words=corpus_words) + print_benchmark(result) else: print(f"error: unknown command '{cmd}'", file=sys.stderr) print("Run 'graphify --help' for usage.", file=sys.stderr) diff --git a/graphify/benchmark.py b/graphify/benchmark.py new file mode 100644 index 000000000..e3085e681 --- /dev/null +++ b/graphify/benchmark.py @@ -0,0 +1,126 @@ +"""Token-reduction benchmark — measures how much context graphify saves vs naive full-corpus approach.""" +from __future__ import annotations +import json +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph + + +_CHARS_PER_TOKEN = 4 # standard approximation + + +def _estimate_tokens(text: str) -> int: + return max(1, len(text) // _CHARS_PER_TOKEN) + + +def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int: + """Run BFS from best-matching nodes and return estimated tokens in the subgraph context.""" + terms = [t.lower() for t in question.split() if len(t) > 2] + scored = [] + for nid, data in G.nodes(data=True): + label = data.get("label", "").lower() + score = sum(1 for t in terms if t in label) + if score > 0: + scored.append((score, nid)) + scored.sort(reverse=True) + start_nodes = [nid for _, nid in scored[:3]] + if not start_nodes: + return 0 + + visited: set[str] = set(start_nodes) + frontier = set(start_nodes) + edges_seen: list[tuple] = [] + for _ in range(depth): + next_frontier: set[str] = set() + for n in frontier: + for neighbor in G.neighbors(n): + if neighbor not in visited: + next_frontier.add(neighbor) + edges_seen.append((n, neighbor)) + visited.update(next_frontier) + frontier = next_frontier + + lines = [] + for nid in visited: + d = G.nodes[nid] + lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}") + for u, v in edges_seen: + if u in visited and v in visited: + d = G.edges[u, v] + lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}") + + return _estimate_tokens("\n".join(lines)) + + +_SAMPLE_QUESTIONS = [ + "how does authentication work", + "what is the main entry point", + "how are errors handled", + "what connects the data layer to the api", + "what are the core abstractions", +] + + +def run_benchmark( + graph_path: str = ".graphify/graph.json", + corpus_words: int | None = None, + questions: list[str] | None = None, +) -> dict: + """Measure token reduction: corpus tokens vs graphify query tokens. + + Args: + graph_path: path to the built graph + corpus_words: total word count from detect() output; if None, estimated from graph + questions: list of questions to benchmark; defaults to _SAMPLE_QUESTIONS + + Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question + """ + data = json.loads(Path(graph_path).read_text()) + G = json_graph.node_link_graph(data, edges="links") + + if corpus_words is None: + # Rough estimate: each node label is ~3 words, plus source context + corpus_words = G.number_of_nodes() * 50 + + corpus_tokens = corpus_words * 100 // 75 # words → tokens (100 words ≈ 133 tokens) + + qs = questions or _SAMPLE_QUESTIONS + per_question = [] + for q in qs: + qt = _query_subgraph_tokens(G, q) + if qt > 0: + per_question.append({"question": q, "query_tokens": qt, "reduction": round(corpus_tokens / qt, 1)}) + + if not per_question: + return {"error": "No matching nodes found for sample questions. Build the graph first."} + + avg_query_tokens = sum(p["query_tokens"] for p in per_question) // len(per_question) + reduction_ratio = round(corpus_tokens / avg_query_tokens, 1) if avg_query_tokens > 0 else 0 + + return { + "corpus_tokens": corpus_tokens, + "corpus_words": corpus_words, + "nodes": G.number_of_nodes(), + "edges": G.number_of_edges(), + "avg_query_tokens": avg_query_tokens, + "reduction_ratio": reduction_ratio, + "per_question": per_question, + } + + +def print_benchmark(result: dict) -> None: + """Print a human-readable benchmark report.""" + if "error" in result: + print(f"Benchmark error: {result['error']}") + return + + print(f"\ngraphify token reduction benchmark") + print(f"{'─' * 50}") + print(f" Corpus: {result['corpus_words']:,} words → ~{result['corpus_tokens']:,} tokens (naive)") + print(f" Graph: {result['nodes']:,} nodes, {result['edges']:,} edges") + print(f" Avg query cost: ~{result['avg_query_tokens']:,} tokens") + print(f" Reduction: {result['reduction_ratio']}x fewer tokens per query") + print(f"\n Per question:") + for p in result["per_question"]: + print(f" [{p['reduction']}x] {p['question'][:55]}") + print() diff --git a/graphify/detect.py b/graphify/detect.py index 70bbbeb49..c1a90d869 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -15,7 +15,7 @@ class FileType(str, Enum): _MANIFEST_PATH = ".graphify/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.go', '.rs', '.java', '.cpp', '.c', '.rb', '.swift', '.kt'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php'} DOC_EXTENSIONS = {'.md', '.txt', '.rst'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index a0a1bffc4..c56199c46 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -752,6 +752,1374 @@ def extract_rust(path: Path) -> dict: return {"nodes": nodes, "edges": clean_edges} +def extract_java(path: Path) -> dict: + """Extract classes, interfaces, methods, constructors, and imports from a .java file.""" + try: + import tree_sitter_java as tsjava + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-java not installed"} + + try: + language = Language(tsjava.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def _walk_scoped_identifier(node) -> str: + """Reconstruct a dotted import path from nested scoped_identifier nodes.""" + parts: list[str] = [] + cur = node + while cur: + if cur.type == "scoped_identifier": + name_node = cur.child_by_field_name("name") + if name_node: + parts.append(source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace")) + cur = cur.child_by_field_name("scope") + elif cur.type == "identifier": + parts.append(source[cur.start_byte:cur.end_byte].decode("utf-8", errors="replace")) + break + else: + break + parts.reverse() + return ".".join(parts) + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "import_declaration": + # Find scoped_identifier or identifier child + for child in node.children: + if child.type in ("scoped_identifier", "identifier"): + path_str = _walk_scoped_identifier(child) + module_name = path_str.split(".")[-1].strip("*").strip(".") or path_str.split(".")[-2] + if module_name: + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t in ("class_declaration", "interface_declaration"): + name_node = node.child_by_field_name("name") + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t in ("method_declaration", "constructor_declaration"): + name_node = node.child_by_field_name("name") + if not name_node: + return + method_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + method_nid = _make_id(parent_class_nid, method_name) + add_node(method_nid, f".{method_name}()", line) + add_edge_raw(parent_class_nid, method_nid, "method", line) + else: + method_nid = _make_id(stem, method_name) + add_node(method_nid, f"{method_name}()", line) + add_edge_raw(file_nid, method_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + function_bodies.append((method_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type in ("method_declaration", "constructor_declaration"): + return + if node.type == "method_invocation": + name_node = node.child_by_field_name("name") + callee_name: str | None = None + if name_node: + callee_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_c(path: Path) -> dict: + """Extract functions and includes from a .c/.h file.""" + try: + import tree_sitter_c as tsc + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-c not installed"} + + try: + language = Language(tsc.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def _get_func_name_from_declarator(node) -> str | None: + """Recursively unwrap declarator to find the innermost identifier.""" + if node.type == "identifier": + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + decl = node.child_by_field_name("declarator") + if decl: + return _get_func_name_from_declarator(decl) + # fallback: search children for identifier + for child in node.children: + if child.type == "identifier": + return source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + return None + + def walk(node) -> None: + t = node.type + + if t == "preproc_include": + # path child or string child + for child in node.children: + if child.type in ("string_literal", "system_lib_string", "string"): + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace").strip('"<> ') + module_name = raw.split("/")[-1].split(".")[0] + if module_name: + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t == "function_definition": + declarator = node.child_by_field_name("declarator") + func_name: str | None = None + if declarator: + func_name = _get_func_name_from_declarator(declarator) + if func_name: + line = node.start_point[0] + 1 + func_nid = _make_id(stem, func_name) + add_node(func_nid, f"{func_name}()", line) + add_edge_raw(file_nid, func_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + function_bodies.append((func_nid, body)) + return + + for child in node.children: + walk(child) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "function_definition": + return + if node.type == "call_expression": + func_node = node.child_by_field_name("function") + callee_name: str | None = None + if func_node: + if func_node.type == "identifier": + callee_name = source[func_node.start_byte:func_node.end_byte].decode("utf-8", errors="replace") + elif func_node.type == "field_expression": + field = func_node.child_by_field_name("field") + if field: + callee_name = source[field.start_byte:field.end_byte].decode("utf-8", errors="replace") + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_cpp(path: Path) -> dict: + """Extract functions, classes, and includes from a .cpp/.cc/.cxx/.hpp file.""" + try: + import tree_sitter_cpp as tscpp + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-cpp not installed"} + + try: + language = Language(tscpp.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def _get_func_name_from_declarator(node) -> str | None: + """Recursively unwrap declarator to find the innermost identifier.""" + if node.type == "identifier": + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + if node.type == "qualified_identifier": + name_node = node.child_by_field_name("name") + if name_node: + return source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + decl = node.child_by_field_name("declarator") + if decl: + return _get_func_name_from_declarator(decl) + for child in node.children: + if child.type == "identifier": + return source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + return None + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "preproc_include": + for child in node.children: + if child.type in ("string_literal", "system_lib_string", "string"): + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace").strip('"<> ') + module_name = raw.split("/")[-1].split(".")[0] + if module_name: + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t == "class_specifier": + name_node = node.child_by_field_name("name") + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t == "function_definition": + declarator = node.child_by_field_name("declarator") + func_name: str | None = None + if declarator: + func_name = _get_func_name_from_declarator(declarator) + if func_name: + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge_raw(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_raw(file_nid, func_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + function_bodies.append((func_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "function_definition": + return + if node.type == "call_expression": + func_node = node.child_by_field_name("function") + callee_name: str | None = None + if func_node: + if func_node.type == "identifier": + callee_name = source[func_node.start_byte:func_node.end_byte].decode("utf-8", errors="replace") + elif func_node.type in ("field_expression", "qualified_identifier"): + name = func_node.child_by_field_name("field") or func_node.child_by_field_name("name") + if name: + callee_name = source[name.start_byte:name.end_byte].decode("utf-8", errors="replace") + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_ruby(path: Path) -> dict: + """Extract classes, methods, singleton methods, and calls from a .rb file.""" + try: + import tree_sitter_ruby as tsruby + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-ruby not installed"} + + try: + language = Language(tsruby.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "class": + # name is a child node (not a field in all versions) + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type in ("constant", "scope_resolution"): + name_node = child + break + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + # body may not be a named field — walk all children except first/last + for child in node.children: + if child.type == "body_statement": + body = child + break + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t in ("method", "singleton_method"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "identifier": + name_node = child + break + if not name_node: + return + method_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + method_nid = _make_id(parent_class_nid, method_name) + add_node(method_nid, f".{method_name}()", line) + add_edge_raw(parent_class_nid, method_nid, "method", line) + else: + method_nid = _make_id(stem, method_name) + add_node(method_nid, f"{method_name}()", line) + add_edge_raw(file_nid, method_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "body_statement": + body = child + break + if body: + function_bodies.append((method_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type in ("method", "singleton_method"): + return + if node.type == "call": + method_node = node.child_by_field_name("method") + callee_name: str | None = None + if method_node: + callee_name = source[method_node.start_byte:method_node.end_byte].decode("utf-8", errors="replace") + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_csharp(path: Path) -> dict: + """Extract classes, interfaces, methods, namespaces, and usings from a .cs file.""" + try: + import tree_sitter_c_sharp as tscsharp + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-c-sharp not installed"} + + try: + language = Language(tscsharp.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "using_directive": + # Extract the namespace name from the using directive + for child in node.children: + if child.type in ("qualified_name", "identifier", "name_equals"): + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + module_name = raw.split(".")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t == "namespace_declaration": + name_node = node.child_by_field_name("name") + if name_node: + ns_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + ns_nid = _make_id(stem, ns_name) + line = node.start_point[0] + 1 + add_node(ns_nid, ns_name, line) + add_edge_raw(file_nid, ns_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + for child in body.children: + walk(child, parent_class_nid=parent_class_nid) + return + + if t in ("class_declaration", "interface_declaration"): + name_node = node.child_by_field_name("name") + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t == "method_declaration": + name_node = node.child_by_field_name("name") + if not name_node: + return + method_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + method_nid = _make_id(parent_class_nid, method_name) + add_node(method_nid, f".{method_name}()", line) + add_edge_raw(parent_class_nid, method_nid, "method", line) + else: + method_nid = _make_id(stem, method_name) + add_node(method_nid, f"{method_name}()", line) + add_edge_raw(file_nid, method_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + function_bodies.append((method_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "method_declaration": + return + if node.type == "invocation_expression": + callee_name: str | None = None + name_node = node.child_by_field_name("name") + if name_node: + callee_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + else: + # Try first named child + for child in node.children: + if child.is_named: + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + # member_access_expression: strip the object prefix + if "." in raw: + callee_name = raw.split(".")[-1] + else: + callee_name = raw + break + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_kotlin(path: Path) -> dict: + """Extract classes, objects, functions, and imports from a .kt/.kts file.""" + try: + import tree_sitter_kotlin as tskotlin + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-kotlin not installed"} + + try: + language = Language(tskotlin.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "import_header": + for child in node.children: + if child.type == "identifier": + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + tgt_nid = _make_id(raw) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t in ("class_declaration", "object_declaration"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "simple_identifier": + name_node = child + break + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "class_body": + body = child + break + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t == "function_declaration": + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "simple_identifier": + name_node = child + break + if not name_node: + return + func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge_raw(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_raw(file_nid, func_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "function_body": + body = child + break + if body: + function_bodies.append((func_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "function_declaration": + return + if node.type == "call_expression": + callee_name: str | None = None + # Try first child (the callable) then look for simple_identifier + first = node.children[0] if node.children else None + if first: + if first.type == "simple_identifier": + callee_name = source[first.start_byte:first.end_byte].decode("utf-8", errors="replace") + elif first.type == "navigation_expression": + # obj.method — get the suffix + for child in reversed(first.children): + if child.type == "simple_identifier": + callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + break + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_scala(path: Path) -> dict: + """Extract classes, objects, functions, and imports from a .scala file.""" + try: + import tree_sitter_scala as tsscala + from tree_sitter import Language, Parser + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-scala not installed"} + + try: + language = Language(tsscala.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "import_declaration": + for child in node.children: + if child.type in ("stable_id", "identifier"): + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + module_name = raw.split(".")[-1].strip("{} ") + if module_name and module_name != "_": + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t in ("class_definition", "object_definition"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "identifier": + name_node = child + break + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "template_body": + body = child + break + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t == "function_definition": + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "identifier": + name_node = child + break + if not name_node: + return + func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge_raw(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_raw(file_nid, func_nid, "contains", line) + body = node.child_by_field_name("body") + if body: + function_bodies.append((func_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "function_definition": + return + if node.type == "call_expression": + callee_name: str | None = None + # First child is the function being called + first = node.children[0] if node.children else None + if first: + if first.type == "identifier": + callee_name = source[first.start_byte:first.end_byte].decode("utf-8", errors="replace") + elif first.type == "field_expression": + field = first.child_by_field_name("field") + if field: + callee_name = source[field.start_byte:field.end_byte].decode("utf-8", errors="replace") + else: + for child in reversed(first.children): + if child.type == "identifier": + callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + break + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + +def extract_php(path: Path) -> dict: + """Extract classes, functions, methods, namespace uses, and calls from a .php file.""" + try: + import tree_sitter_php as tsphp + from tree_sitter import Language, Parser + try: + from tree_sitter_php import language_php + language = Language(language_php()) + except (ImportError, AttributeError): + language = Language(tsphp.language()) + except ImportError: + return {"nodes": [], "edges": [], "error": "tree-sitter-php not installed"} + + try: + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + + def add_node(nid: str, label: str, line: int) -> None: + if 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_raw(src: str, tgt: str, relation: str, line: int, confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, + "target": tgt, + "relation": relation, + "confidence": confidence, + "source_file": str_path, + "source_location": f"L{line}", + "weight": weight, + }) + + file_nid = _make_id(stem) + add_node(file_nid, path.name, 1) + + function_bodies: list[tuple[str, object]] = [] + + def walk(node, parent_class_nid: str | None = None) -> None: + t = node.type + + if t == "namespace_use_clause": + for child in node.children: + if child.type in ("qualified_name", "name", "identifier"): + raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + module_name = raw.split("\\")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + add_edge_raw(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t == "class_declaration": + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "name": + name_node = child + break + if not name_node: + return + class_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + class_nid = _make_id(stem, class_name) + line = node.start_point[0] + 1 + add_node(class_nid, class_name, line) + add_edge_raw(file_nid, class_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "declaration_list": + body = child + break + if body: + for child in body.children: + walk(child, parent_class_nid=class_nid) + return + + if t in ("function_definition", "method_declaration"): + name_node = node.child_by_field_name("name") + if name_node is None: + for child in node.children: + if child.type == "name": + name_node = child + break + if not name_node: + return + func_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + line = node.start_point[0] + 1 + if parent_class_nid: + func_nid = _make_id(parent_class_nid, func_name) + add_node(func_nid, f".{func_name}()", line) + add_edge_raw(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_raw(file_nid, func_nid, "contains", line) + body = node.child_by_field_name("body") + if body is None: + for child in node.children: + if child.type == "compound_statement": + body = child + break + if body: + function_bodies.append((func_nid, body)) + return + + for child in node.children: + walk(child, parent_class_nid=None) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + raw = n["label"] + normalised = raw.strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + + def walk_calls(node, caller_nid: str) -> None: + if node.type in ("function_definition", "method_declaration"): + return + if node.type in ("function_call_expression", "member_call_expression"): + callee_name: str | None = None + if node.type == "function_call_expression": + func_node = node.child_by_field_name("function") + if func_node: + callee_name = source[func_node.start_byte:func_node.end_byte].decode("utf-8", errors="replace") + else: + # member_call_expression: obj->method(args) + name_node = node.child_by_field_name("name") + if name_node: + callee_name = source[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="replace") + if callee_name: + tgt_nid = label_to_nid.get(callee_name.lower()) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "confidence": "INFERRED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 0.8, + }) + for child in node.children: + walk_calls(child, caller_nid) + + for caller_nid, body_node in function_bodies: + walk_calls(body_node, caller_nid) + + valid_ids = seen_ids + clean_edges = [] + for edge in edges: + src, tgt = edge["source"], edge["target"] + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")): + clean_edges.append(edge) + + return {"nodes": nodes, "edges": clean_edges} + + def _resolve_cross_file_imports( per_file: list[dict], paths: list[Path], @@ -949,6 +2317,78 @@ def extract(paths: list[Path]) -> dict: if "error" not in result: save_cached(path, result, root) per_file.append(result) + elif path.suffix == ".java": + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_java(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix in {".c", ".h"}: + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_c(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix in {".cpp", ".cc", ".cxx", ".hpp"}: + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_cpp(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix == ".rb": + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_ruby(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix == ".cs": + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_csharp(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix in {".kt", ".kts"}: + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_kotlin(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix == ".scala": + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_scala(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) + elif path.suffix == ".php": + cached = load_cached(path, root) + if cached is not None: + per_file.append(cached) + continue + result = extract_php(path) + if "error" not in result: + save_cached(path, result, root) + per_file.append(result) all_nodes: list[dict] = [] all_edges: list[dict] = [] @@ -973,7 +2413,11 @@ def extract(paths: list[Path]) -> dict: def collect_files(target: Path) -> list[Path]: if target.is_file(): return [target] - _EXTENSIONS = ("*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs") + _EXTENSIONS = ( + "*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs", + "*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp", + "*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php", + ) results: list[Path] = [] for pattern in _EXTENSIONS: results.extend( diff --git a/graphify/skill.md b/graphify/skill.md index 174dc5a29..cddaa60e8 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -92,6 +92,8 @@ Then act on it: ### Step 3 — Extract entities and relationships +**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation — do not lose it. + This step has two parts: **structural extraction** (deterministic, free) then **semantic extraction** (Claude, costs tokens). #### Part A — Structural extraction for code files @@ -496,28 +498,9 @@ print('graph.svg written — embeds in Obsidian, Notion, GitHub READMEs') " ``` -### Step 7c — Obsidian export (only if --obsidian flag) +### Step 7c — SVG export already covered in Step 7b above -```bash -python3 -c " -import sys, json -from graphify.build import build_from_json -from graphify.export import to_obsidian -from pathlib import Path - -extraction = json.loads(Path('.graphify_extract.json').read_text()) -analysis = json.loads(Path('.graphify_analysis.json').read_text()) -labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {} - -G = build_from_json(extraction) -communities = {int(k): v for k, v in analysis['communities'].items()} -labels = {int(k): v for k, v in labels_raw.items()} - -n = to_obsidian(G, communities, '.graphify/obsidian', community_labels=labels or None, cohesion=cohesion) -print(f'Obsidian vault written: {n} notes in .graphify/obsidian/') -print('Open .graphify/obsidian/ as a vault in Obsidian to explore the graph.') -" -``` +_(No separate --obsidian flag — Obsidian vault is always generated in Step 6 by default.)_ ### Step 7d — MCP server (only if --mcp flag) @@ -525,7 +508,7 @@ print('Open .graphify/obsidian/ as a vault in Obsidian to explore the graph.') python3 -m graphify.serve .graphify/graph.json ``` -This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. +This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. To configure in Claude Desktop, add to `claude_desktop_config.json`: ```json @@ -1007,12 +990,19 @@ import sys from graphify.ingest import ingest from pathlib import Path -out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR') -print(f'Saved to {out}') +try: + out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR') + print(f'Saved to {out}') +except ValueError as e: + print(f'error: {e}', file=sys.stderr) + sys.exit(1) +except RuntimeError as e: + print(f'error: {e}', file=sys.stderr) + sys.exit(1) " ``` -Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. After saving, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. +Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong — do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. Supported URL types (auto-detected): - Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author diff --git a/graphify/watch.py b/graphify/watch.py index 282ec2e18..d83e3e637 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -6,6 +6,7 @@ from pathlib import Path _WATCHED_EXTENSIONS = { ".py", ".ts", ".js", ".go", ".rs", ".java", ".cpp", ".c", ".rb", ".swift", ".kt", + ".cs", ".scala", ".php", ".cc", ".cxx", ".hpp", ".h", ".kts", ".md", ".txt", ".rst", ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", } diff --git a/pyproject.toml b/pyproject.toml index e9ba9e58c..a350a405b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,14 @@ dependencies = [ "tree-sitter-typescript", "tree-sitter-go", "tree-sitter-rust", + "tree-sitter-java", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-ruby", + "tree-sitter-c-sharp", + "tree-sitter-kotlin", + "tree-sitter-scala", + "tree-sitter-php", ] [project.optional-dependencies] diff --git a/skills/graphify/skill.md b/skills/graphify/skill.md index 174dc5a29..cddaa60e8 100644 --- a/skills/graphify/skill.md +++ b/skills/graphify/skill.md @@ -92,6 +92,8 @@ Then act on it: ### Step 3 — Extract entities and relationships +**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation — do not lose it. + This step has two parts: **structural extraction** (deterministic, free) then **semantic extraction** (Claude, costs tokens). #### Part A — Structural extraction for code files @@ -496,28 +498,9 @@ print('graph.svg written — embeds in Obsidian, Notion, GitHub READMEs') " ``` -### Step 7c — Obsidian export (only if --obsidian flag) +### Step 7c — SVG export already covered in Step 7b above -```bash -python3 -c " -import sys, json -from graphify.build import build_from_json -from graphify.export import to_obsidian -from pathlib import Path - -extraction = json.loads(Path('.graphify_extract.json').read_text()) -analysis = json.loads(Path('.graphify_analysis.json').read_text()) -labels_raw = json.loads(Path('.graphify_labels.json').read_text()) if Path('.graphify_labels.json').exists() else {} - -G = build_from_json(extraction) -communities = {int(k): v for k, v in analysis['communities'].items()} -labels = {int(k): v for k, v in labels_raw.items()} - -n = to_obsidian(G, communities, '.graphify/obsidian', community_labels=labels or None, cohesion=cohesion) -print(f'Obsidian vault written: {n} notes in .graphify/obsidian/') -print('Open .graphify/obsidian/ as a vault in Obsidian to explore the graph.') -" -``` +_(No separate --obsidian flag — Obsidian vault is always generated in Step 6 by default.)_ ### Step 7d — MCP server (only if --mcp flag) @@ -525,7 +508,7 @@ print('Open .graphify/obsidian/ as a vault in Obsidian to explore the graph.') python3 -m graphify.serve .graphify/graph.json ``` -This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. +This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live. To configure in Claude Desktop, add to `claude_desktop_config.json`: ```json @@ -1007,12 +990,19 @@ import sys from graphify.ingest import ingest from pathlib import Path -out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR') -print(f'Saved to {out}') +try: + out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR') + print(f'Saved to {out}') +except ValueError as e: + print(f'error: {e}', file=sys.stderr) + sys.exit(1) +except RuntimeError as e: + print(f'error: {e}', file=sys.stderr) + sys.exit(1) " ``` -Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. After saving, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. +Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong — do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph. Supported URL types (auto-detected): - Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author diff --git a/tests/fixtures/sample.c b/tests/fixtures/sample.c new file mode 100644 index 000000000..0768cac21 --- /dev/null +++ b/tests/fixtures/sample.c @@ -0,0 +1,27 @@ +#include +#include +#include + +#define MAX_SIZE 256 + +static int validate(const char *input) { + return input != NULL && strlen(input) > 0; +} + +char *process(const char *input) { + if (!validate(input)) { + return NULL; + } + char *result = malloc(MAX_SIZE); + strncpy(result, input, MAX_SIZE - 1); + return result; +} + +int main(int argc, char *argv[]) { + char *out = process("hello"); + if (out) { + printf("%s\n", out); + free(out); + } + return 0; +} diff --git a/tests/fixtures/sample.cpp b/tests/fixtures/sample.cpp new file mode 100644 index 000000000..88f4013b2 --- /dev/null +++ b/tests/fixtures/sample.cpp @@ -0,0 +1,30 @@ +#include +#include +#include + +class HttpClient { +public: + HttpClient(const std::string& baseUrl) : baseUrl_(baseUrl) {} + + std::string get(const std::string& path) { + return buildRequest("GET", path); + } + + std::string post(const std::string& path, const std::string& body) { + return buildRequest("POST", path); + } + +private: + std::string baseUrl_; + + std::string buildRequest(const std::string& method, const std::string& path) { + return method + " " + baseUrl_ + path; + } +}; + +int main() { + HttpClient client("https://api.example.com"); + std::string response = client.get("/users"); + std::cout << response << std::endl; + return 0; +} diff --git a/tests/fixtures/sample.cs b/tests/fixtures/sample.cs new file mode 100644 index 000000000..11fa5b0e7 --- /dev/null +++ b/tests/fixtures/sample.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace GraphifyDemo +{ + public interface IProcessor + { + List Process(List items); + } + + public class DataProcessor : IProcessor + { + private readonly HttpClient _client; + + public DataProcessor() + { + _client = new HttpClient(); + } + + public List Process(List items) + { + return Validate(items); + } + + private List Validate(List items) + { + var result = new List(); + foreach (var item in items) + { + if (!string.IsNullOrEmpty(item)) + result.Add(item.Trim()); + } + return result; + } + } +} diff --git a/tests/fixtures/sample.java b/tests/fixtures/sample.java new file mode 100644 index 000000000..f161f74eb --- /dev/null +++ b/tests/fixtures/sample.java @@ -0,0 +1,32 @@ +import java.util.List; +import java.util.ArrayList; + +public class DataProcessor { + private List items; + + public DataProcessor() { + this.items = new ArrayList<>(); + } + + public void addItem(String item) { + items.add(item); + } + + public List process() { + return validate(items); + } + + private List validate(List data) { + List result = new ArrayList<>(); + for (String s : data) { + if (s != null && !s.isEmpty()) { + result.add(s.trim()); + } + } + return result; + } +} + +interface Processor { + List process(); +} diff --git a/tests/fixtures/sample.kt b/tests/fixtures/sample.kt new file mode 100644 index 000000000..0f6284631 --- /dev/null +++ b/tests/fixtures/sample.kt @@ -0,0 +1,23 @@ +import kotlinx.coroutines.delay +import kotlin.math.max + +data class Config(val baseUrl: String, val timeout: Int) + +class HttpClient(private val config: Config) { + fun get(path: String): String { + return buildRequest("GET", path) + } + + fun post(path: String, body: String): String { + return buildRequest("POST", path) + } + + private fun buildRequest(method: String, path: String): String { + return "$method ${config.baseUrl}$path" + } +} + +fun createClient(baseUrl: String): HttpClient { + val config = Config(baseUrl, 30) + return HttpClient(config) +} diff --git a/tests/fixtures/sample.php b/tests/fixtures/sample.php new file mode 100644 index 000000000..636d49f85 --- /dev/null +++ b/tests/fixtures/sample.php @@ -0,0 +1,39 @@ +baseUrl = $baseUrl; + $this->auth = new Authenticator(); + } + + public function get(string $path): string + { + return $this->fetch($path, 'GET'); + } + + public function post(string $path, string $body): string + { + return $this->fetch($path, 'POST'); + } + + private function fetch(string $path, string $method): string + { + $token = $this->auth->getToken(); + return $method . ' ' . $this->baseUrl . $path; + } +} + +function parseResponse(string $raw): array +{ + return json_decode($raw, true); +} diff --git a/tests/fixtures/sample.rb b/tests/fixtures/sample.rb new file mode 100644 index 000000000..429bf78c7 --- /dev/null +++ b/tests/fixtures/sample.rb @@ -0,0 +1,27 @@ +require 'json' +require 'net/http' + +class ApiClient + def initialize(base_url) + @base_url = base_url + end + + def get(path) + fetch(path, 'GET') + end + + def post(path, body) + fetch(path, 'POST') + end + + private + + def fetch(path, method) + uri = URI(@base_url + path) + Net::HTTP.get(uri) + end +end + +def parse_response(raw) + JSON.parse(raw) +end diff --git a/tests/fixtures/sample.scala b/tests/fixtures/sample.scala new file mode 100644 index 000000000..fe23724fa --- /dev/null +++ b/tests/fixtures/sample.scala @@ -0,0 +1,23 @@ +import scala.collection.mutable.ListBuffer + +case class Config(baseUrl: String, timeout: Int) + +class HttpClient(config: Config) { + def get(path: String): String = { + buildRequest("GET", path) + } + + def post(path: String, body: String): String = { + buildRequest("POST", path) + } + + private def buildRequest(method: String, path: String): String = { + s"$method ${config.baseUrl}$path" + } +} + +object HttpClientFactory { + def create(baseUrl: String): HttpClient = { + new HttpClient(Config(baseUrl, 30)) + } +} diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 000000000..d5e18084a --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,119 @@ +"""Tests for graphify/benchmark.py.""" +from __future__ import annotations +import json +import pytest +import networkx as nx +from networkx.readwrite import json_graph + +from graphify.benchmark import run_benchmark, print_benchmark, _query_subgraph_tokens, _SAMPLE_QUESTIONS + + +def _make_graph() -> nx.Graph: + G = nx.Graph() + G.add_node("n1", label="authentication", source_file="auth.py", source_location="L1", community=0) + G.add_node("n2", label="api_handler", source_file="api.py", source_location="L5", community=0) + G.add_node("n3", label="main_entry", source_file="main.py", source_location="L1", community=1) + G.add_node("n4", label="error_handler", source_file="errors.py", source_location="L1", community=1) + G.add_node("n5", label="database_layer", source_file="db.py", source_location="L1", community=2) + G.add_edge("n1", "n2", relation="calls", confidence="INFERRED") + G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED") + G.add_edge("n3", "n4", relation="uses", confidence="EXTRACTED") + G.add_edge("n5", "n2", relation="provides", confidence="EXTRACTED") + return G + + +def _write_graph(G: nx.Graph, path) -> None: + data = json_graph.node_link_data(G, edges="links") + path.write_text(json.dumps(data)) + + +# --- _query_subgraph_tokens --- + +def test_query_returns_positive_for_matching_question(): + G = _make_graph() + tokens = _query_subgraph_tokens(G, "how does authentication work") + assert tokens > 0 + +def test_query_returns_zero_for_no_match(): + G = _make_graph() + tokens = _query_subgraph_tokens(G, "xyzzy plugh zorkmid") + assert tokens == 0 + +def test_query_bfs_expands_neighbors(): + G = _make_graph() + # "authentication" matches n1, BFS depth=3 should reach n2, n3, n4 + tokens_deep = _query_subgraph_tokens(G, "authentication", depth=3) + tokens_shallow = _query_subgraph_tokens(G, "authentication", depth=1) + assert tokens_deep >= tokens_shallow + + +# --- run_benchmark --- + +def test_run_benchmark_returns_reduction(tmp_path): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=10_000) + assert "reduction_ratio" in result + assert result["reduction_ratio"] > 1.0 + +def test_run_benchmark_corpus_tokens_proportional(tmp_path): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + r1 = run_benchmark(str(graph_file), corpus_words=1_000) + r2 = run_benchmark(str(graph_file), corpus_words=10_000) + # corpus_tokens scales linearly with corpus_words (within integer-division rounding) + assert abs(r2["corpus_tokens"] - r1["corpus_tokens"] * 10) <= r1["corpus_tokens"] + +def test_run_benchmark_per_question_list(tmp_path): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=5_000, + questions=["how does authentication work", "what is the main entry"]) + assert len(result["per_question"]) >= 1 + for p in result["per_question"]: + assert "question" in p + assert "query_tokens" in p + assert "reduction" in p + +def test_run_benchmark_estimates_corpus_if_no_words(tmp_path): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=None) + assert result["corpus_words"] > 0 + +def test_run_benchmark_error_on_empty_graph(tmp_path): + G = nx.Graph() + graph_file = tmp_path / "empty.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=1_000) + assert "error" in result + +def test_run_benchmark_includes_node_edge_counts(tmp_path): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=5_000) + assert result["nodes"] == G.number_of_nodes() + assert result["edges"] == G.number_of_edges() + + +# --- print_benchmark --- + +def test_print_benchmark_no_crash(tmp_path, capsys): + G = _make_graph() + graph_file = tmp_path / "graph.json" + _write_graph(G, graph_file) + result = run_benchmark(str(graph_file), corpus_words=5_000) + print_benchmark(result) + out = capsys.readouterr().out + assert "reduction" in out.lower() + assert "x" in out + +def test_print_benchmark_error_message(capsys): + print_benchmark({"error": "test error message"}) + out = capsys.readouterr().out + assert "test error message" in out diff --git a/tests/test_extract.py b/tests/test_extract.py index 9ec9faa13..2dd2e3e9d 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -58,7 +58,9 @@ def test_extract_merges_multiple_files(): def test_collect_files_from_dir(): files = collect_files(FIXTURES) - supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs"} + supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs", + ".java", ".c", ".cpp", ".cc", ".cxx", ".rb", + ".cs", ".kt", ".kts", ".scala", ".php", ".h", ".hpp"} assert all(f.suffix in supported for f in files) assert len(files) > 0 diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 000000000..3bd2d56ac --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,219 @@ +"""Tests for the 8 new language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP.""" +from __future__ import annotations +from pathlib import Path +import pytest +from graphify.extract import ( + extract_java, extract_c, extract_cpp, extract_ruby, + extract_csharp, extract_kotlin, extract_scala, extract_php, +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _labels(r): + return [n["label"] for n in r["nodes"]] + +def _relations(r): + return {e["relation"] for e in r["edges"]} + +def _calls(r): + node_by_id = {n["id"]: n["label"] for n in r["nodes"]} + return { + (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"])) + for e in r["edges"] if e["relation"] == "calls" + } + + +# ── Java ────────────────────────────────────────────────────────────────────── + +def test_java_no_error(): + r = extract_java(FIXTURES / "sample.java") + assert "error" not in r + +def test_java_finds_class(): + r = extract_java(FIXTURES / "sample.java") + assert any("DataProcessor" in l for l in _labels(r)) + +def test_java_finds_interface(): + r = extract_java(FIXTURES / "sample.java") + assert any("Processor" in l for l in _labels(r)) + +def test_java_finds_methods(): + r = extract_java(FIXTURES / "sample.java") + labels = _labels(r) + assert any("addItem" in l for l in labels) + assert any("process" in l for l in labels) + +def test_java_finds_imports(): + r = extract_java(FIXTURES / "sample.java") + assert "imports" in _relations(r) + +def test_java_no_dangling_edges(): + r = extract_java(FIXTURES / "sample.java") + node_ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + assert e["source"] in node_ids + + +# ── C ──────────────────────────────────────────────────────────────────────── + +def test_c_no_error(): + r = extract_c(FIXTURES / "sample.c") + assert "error" not in r + +def test_c_finds_functions(): + r = extract_c(FIXTURES / "sample.c") + labels = _labels(r) + assert any("process" in l for l in labels) + assert any("main" in l for l in labels) + +def test_c_finds_includes(): + r = extract_c(FIXTURES / "sample.c") + assert "imports" in _relations(r) + +def test_c_emits_calls(): + r = extract_c(FIXTURES / "sample.c") + assert any(e["relation"] == "calls" for e in r["edges"]) + +def test_c_calls_are_inferred(): + r = extract_c(FIXTURES / "sample.c") + for e in r["edges"]: + if e["relation"] == "calls": + assert e["confidence"] == "INFERRED" + + +# ── C++ ─────────────────────────────────────────────────────────────────────── + +def test_cpp_no_error(): + r = extract_cpp(FIXTURES / "sample.cpp") + assert "error" not in r + +def test_cpp_finds_class(): + r = extract_cpp(FIXTURES / "sample.cpp") + assert any("HttpClient" in l for l in _labels(r)) + +def test_cpp_finds_methods(): + r = extract_cpp(FIXTURES / "sample.cpp") + labels = _labels(r) + # C++ extractor captures the constructor and public-visible methods + assert any("HttpClient" in l for l in labels) + +def test_cpp_finds_includes(): + r = extract_cpp(FIXTURES / "sample.cpp") + assert "imports" in _relations(r) + + +# ── Ruby ───────────────────────────────────────────────────────────────────── + +def test_ruby_no_error(): + r = extract_ruby(FIXTURES / "sample.rb") + assert "error" not in r + +def test_ruby_finds_class(): + r = extract_ruby(FIXTURES / "sample.rb") + assert any("ApiClient" in l for l in _labels(r)) + +def test_ruby_finds_methods(): + r = extract_ruby(FIXTURES / "sample.rb") + labels = _labels(r) + assert any("get" in l for l in labels) + assert any("post" in l for l in labels) + +def test_ruby_finds_function(): + r = extract_ruby(FIXTURES / "sample.rb") + assert any("parse_response" in l for l in _labels(r)) + + +# ── C# ─────────────────────────────────────────────────────────────────────── + +def test_csharp_no_error(): + r = extract_csharp(FIXTURES / "sample.cs") + assert "error" not in r + +def test_csharp_finds_class(): + r = extract_csharp(FIXTURES / "sample.cs") + assert any("DataProcessor" in l for l in _labels(r)) + +def test_csharp_finds_interface(): + r = extract_csharp(FIXTURES / "sample.cs") + assert any("IProcessor" in l for l in _labels(r)) + +def test_csharp_finds_methods(): + r = extract_csharp(FIXTURES / "sample.cs") + labels = _labels(r) + assert any("Process" in l for l in labels) + +def test_csharp_finds_usings(): + r = extract_csharp(FIXTURES / "sample.cs") + assert "imports" in _relations(r) + + +# ── Kotlin ─────────────────────────────────────────────────────────────────── + +def test_kotlin_no_error(): + r = extract_kotlin(FIXTURES / "sample.kt") + assert "error" not in r + +def test_kotlin_finds_class(): + r = extract_kotlin(FIXTURES / "sample.kt") + assert any("HttpClient" in l for l in _labels(r)) + +def test_kotlin_finds_data_class(): + r = extract_kotlin(FIXTURES / "sample.kt") + assert any("Config" in l for l in _labels(r)) + +def test_kotlin_finds_methods(): + r = extract_kotlin(FIXTURES / "sample.kt") + labels = _labels(r) + assert any("get" in l for l in labels) + assert any("post" in l for l in labels) + +def test_kotlin_finds_function(): + r = extract_kotlin(FIXTURES / "sample.kt") + assert any("createClient" in l for l in _labels(r)) + + +# ── Scala ───────────────────────────────────────────────────────────────────── + +def test_scala_no_error(): + r = extract_scala(FIXTURES / "sample.scala") + assert "error" not in r + +def test_scala_finds_class(): + r = extract_scala(FIXTURES / "sample.scala") + assert any("HttpClient" in l for l in _labels(r)) + +def test_scala_finds_object(): + r = extract_scala(FIXTURES / "sample.scala") + assert any("HttpClientFactory" in l for l in _labels(r)) + +def test_scala_finds_methods(): + r = extract_scala(FIXTURES / "sample.scala") + labels = _labels(r) + assert any("get" in l for l in labels) + assert any("post" in l for l in labels) + + +# ── PHP ─────────────────────────────────────────────────────────────────────── + +def test_php_no_error(): + r = extract_php(FIXTURES / "sample.php") + assert "error" not in r + +def test_php_finds_class(): + r = extract_php(FIXTURES / "sample.php") + assert any("ApiClient" in l for l in _labels(r)) + +def test_php_finds_methods(): + r = extract_php(FIXTURES / "sample.php") + labels = _labels(r) + assert any("get" in l for l in labels) + assert any("post" in l for l in labels) + +def test_php_finds_function(): + r = extract_php(FIXTURES / "sample.php") + assert any("parseResponse" in l for l in _labels(r)) + +def test_php_finds_imports(): + r = extract_php(FIXTURES / "sample.php") + assert "imports" in _relations(r)