mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 10:27:11 +00:00
- 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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user