From 4205ae86cbb0e05f6c03cc05afd7c243646ecb0a Mon Sep 17 00:00:00 2001 From: Safi Date: Sun, 12 Apr 2026 12:59:15 +0100 Subject: [PATCH] fix bugs #256, #253, #244, #226, #254 and bump to 0.4.3 - extract.py: resolve relative JS/TS imports to full-path IDs (fixes 0 import edges on TS codebases) (#256) - extract.py: resolve relative Python imports to full-path IDs (#256) - watch.py: merge fresh AST with existing semantic nodes instead of overwriting (#253) - hooks.py: add python fallback after python3 for Windows; exit 0 if neither found (#244) - analyze.py: guard stale _src/_tgt hints with node membership check (#226) - detect.py + extract.py: add .vue and .svelte to CODE_EXTENSIONS and _DISPATCH (#254) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 ++++++++ graphify/analyze.py | 12 +++++++++++ graphify/detect.py | 2 +- graphify/extract.py | 52 +++++++++++++++++++++++++++++++++------------ graphify/hooks.py | 18 +++++++++++----- graphify/watch.py | 22 ++++++++++++++++++- pyproject.toml | 2 +- 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a69bb3..3f8758ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.4.3 (2026-04-12) + +- Fix: JS/TS relative imports now resolve to full-path node IDs — previously all `imports_from` edges were silently dropped on large TypeScript codebases (#256) +- Fix: Python relative imports (`from .foo import bar`) now resolve correctly to full-path node IDs (#256) +- Fix: `watch --rebuild_code` now merges fresh AST with existing semantic nodes from docs/papers instead of overwriting them (#253) +- Fix: Windows hooks now fall back to `python` if `python3` is not found; exits cleanly if neither has graphify installed (#244) +- Fix: `surprising_connections` / `suggest_questions` no longer crash with `KeyError` on stale `_src`/`_tgt` edge hints after node merges (#226) +- Add: `.vue` and `.svelte` files now recognized as code and included in extraction (#254) + ## 0.4.2 (2026-04-11) - Fix: same-basename files in different directories produced colliding node IDs — now uses full path (#211) diff --git a/graphify/analyze.py b/graphify/analyze.py index 28a8b3e8..f953d9be 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -218,7 +218,11 @@ def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n: score, reasons = _surprise_score(G, u, v, data, node_community, u_source, v_source) src_id = data.get("_src", u) + if src_id not in G.nodes: + src_id = u tgt_id = data.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v candidates.append({ "_score": score, "source": G.nodes[src_id].get("label", src_id), @@ -294,7 +298,11 @@ def _cross_community_surprises( # This edge crosses community boundaries - interesting confidence = data.get("confidence", "EXTRACTED") src_id = data.get("_src", u) + if src_id not in G.nodes: + src_id = u tgt_id = data.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v surprises.append({ "source": G.nodes[src_id].get("label", src_id), "target": G.nodes[tgt_id].get("label", tgt_id), @@ -392,7 +400,11 @@ def suggest_questions( others = [] for u, v, d in inferred[:2]: src_id = d.get("_src", u) + if src_id not in G.nodes: + src_id = u tgt_id = d.get("_tgt", v) + if tgt_id not in G.nodes: + tgt_id = v other_id = tgt_id if src_id == node_id else src_id others.append(G.nodes[other_id].get("label", other_id)) questions.append({ diff --git a/graphify/detect.py b/graphify/detect.py index c13196d8..8f71581d 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -18,7 +18,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte'} 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 dd8be4a2..065a664a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -112,8 +112,18 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s elif t == "import_from_statement": module_node = node.child_by_field_name("module_name") if module_node: - raw = _read_text(module_node, source).lstrip(".") - tgt_nid = _make_id(raw) + raw = _read_text(module_node, source) + if raw.startswith("."): + # Relative import - resolve to full path so IDs match file node IDs + dots = len(raw) - len(raw.lstrip(".")) + module_name = raw.lstrip(".") + base = Path(str_path).parent + for _ in range(dots - 1): + base = base.parent + rel = (module_name.replace(".", "/") + ".py") if module_name else "__init__.py" + tgt_nid = _make_id(str(base / rel)) + else: + tgt_nid = _make_id(raw) edges.append({ "source": file_nid, "target": tgt_nid, @@ -129,18 +139,32 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p for child in node.children: if child.type == "string": raw = _read_text(child, source).strip("'\"` ") - module_name = raw.lstrip("./").split("/")[-1] - if module_name: + if not raw: + break + if raw.startswith("."): + # Relative import - resolve to full path so IDs match file node IDs + resolved = Path(str_path).parent / raw + # TypeScript ESM: imports written as .js but actual file is .ts/.tsx + if resolved.suffix == ".js": + resolved = resolved.with_suffix(".ts") + elif resolved.suffix == ".jsx": + resolved = resolved.with_suffix(".tsx") + tgt_nid = _make_id(str(resolved)) + else: + # Bare/scoped import (node_modules) - use last segment; dropped as external + module_name = raw.split("/")[-1] + if not module_name: + break tgt_nid = _make_id(module_name) - edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports_from", - "confidence": "EXTRACTED", - "source_file": str_path, - "source_location": f"L{node.start_point[0] + 1}", - "weight": 1.0, - }) + edges.append({ + "source": file_nid, + "target": tgt_nid, + "relation": "imports_from", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) break @@ -2622,6 +2646,8 @@ def extract(paths: list[Path]) -> dict: ".m": extract_objc, ".mm": extract_objc, ".jl": extract_julia, + ".vue": extract_js, + ".svelte": extract_js, } total = len(paths) diff --git a/graphify/hooks.py b/graphify/hooks.py index d99a8c4a..c119dea6 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -20,13 +20,21 @@ if [ -n "$GRAPHIFY_BIN" ]; then # Allowlist: only keep characters valid in a filesystem path to prevent # injection if the shebang contains shell metacharacters case "$GRAPHIFY_PYTHON" in - *[!a-zA-Z0-9/_.-]*) GRAPHIFY_PYTHON="python3" ;; + *[!a-zA-Z0-9/_.-]*) GRAPHIFY_PYTHON="" ;; esac - if ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then - GRAPHIFY_PYTHON="python3" + if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then + GRAPHIFY_PYTHON="" + fi +fi +# Fall back: try python3, then python (Windows has no python3 shim) +if [ -z "$GRAPHIFY_PYTHON" ]; then + if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then + GRAPHIFY_PYTHON="python3" + elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then + GRAPHIFY_PYTHON="python" + else + exit 0 fi -else - GRAPHIFY_PYTHON="python3" fi """ diff --git a/graphify/watch.py b/graphify/watch.py index df2871f6..b9421e1d 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -34,6 +34,27 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: result = extract(code_files) + # Preserve semantic nodes/edges from a previous full run. + # AST-only rebuild replaces code nodes; doc/paper/image nodes are kept. + out = watch_path / "graphify-out" + existing_graph = out / "graph.json" + if existing_graph.exists(): + try: + existing = json.loads(existing_graph.read_text(encoding="utf-8")) + code_ids = {n["id"] for n in existing.get("nodes", []) if n.get("file_type") == "code"} + sem_nodes = [n for n in existing.get("nodes", []) if n.get("file_type") != "code"] + sem_edges = [e for e in existing.get("edges", []) + if e.get("source") not in code_ids and e.get("target") not in code_ids] + result = { + "nodes": result["nodes"] + sem_nodes, + "edges": result["edges"] + sem_edges, + "hyperedges": existing.get("hyperedges", []), + "input_tokens": 0, + "output_tokens": 0, + } + except Exception: + pass # corrupt graph.json - proceed with AST-only + detection = { "files": {"code": [str(f) for f in code_files], "document": [], "paper": [], "image": []}, "total_files": len(code_files), @@ -48,7 +69,6 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool: labels = {cid: "Community " + str(cid) for cid in communities} questions = suggest_questions(G, communities, labels) - out = watch_path / "graphify-out" out.mkdir(exist_ok=True) report = generate(G, communities, cohesion, labels, gods, surprises, detection, diff --git a/pyproject.toml b/pyproject.toml index 69e1b3b5..c370b446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.4.2" +version = "0.4.3" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" }