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 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-12 12:59:15 +01:00
parent 013313f8d6
commit 4205ae86cb
7 changed files with 96 additions and 21 deletions
+9
View File
@@ -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)
+12
View File
@@ -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({
+1 -1
View File
@@ -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'}
+39 -13
View File
@@ -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)
+13 -5
View File
@@ -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
"""
+21 -1
View File
@@ -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,
+1 -1
View File
@@ -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" }