Merge branch 'safishamsi:v6' into feat/parallel-ast-extraction

This commit is contained in:
Hanzala Sohrab
2026-05-02 21:11:02 +05:30
committed by GitHub
17 changed files with 1730 additions and 84 deletions
+31
View File
@@ -2,6 +2,37 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.6.6 (2026-05-02)
- Fix: `skill-windows.md` rewritten from PowerShell to bash — Claude Code on Windows uses git-bash so PowerShell syntax (`$null`, `$LASTEXITCODE`, `Select-Object`, `& (Get-Content ...)`, `Remove-Item`) caused exit code 49 failures; now mirrors `skill.md` structure with `python` added as fallback after `python3` for Windows Conda (#39)
- Fix: wiki `to_wiki()` now clears stale articles before regenerating, preventing orphan .md accumulation (#558)
- Fix: `_safe_filename()` in wiki.py now strips Windows-reserved characters (`< > : " / \ | ? *`) and caps length at 200 chars (#594)
- Fix: rationale-node leakage in cross-file INFERRED call resolution — rationale nodes now excluded from name lookup; edge direction (`calls`, `rationale_for`) preserved correctly at JSON export (#576)
- Feat: `.graphifyinclude` hidden path allowlist — opt specific hidden dirs into traversal (e.g. `.hermes/plans/**/*.md`) (#583)
- Feat: `--no-viz` flag wired in `cluster-only`; `GRAPHIFY_VIZ_NODE_LIMIT` env var overrides 5000-node HTML threshold (#565)
- Fix: stray colon SyntaxError in `skill-trae.md` `--cluster-only` block (#603)
- Docs: skill INFERRED confidence score guidance changed to discrete rubric (0.55/0.65/0.75/0.85/0.95) backed by calibration data (#546)
- Docs: skill `--update` prune output clarified — splits no-drift vs drift cases (#544)
- Docs: skill `--update` merge step now calls `save_manifest` to prevent deleted files reappearing (#545)
## 0.6.5 (2026-05-02)
- Fix: Kotlin call-walker now accepts both `simple_identifier` and `identifier` node types — PyPI's `tree_sitter_kotlin` grammar uses `identifier` while older forks use `simple_identifier`, causing zero `calls` edges to be emitted (#659)
- Feat: community sidebar now uses checkbox-based multi-select instead of show/hide buttons — supports indeterminate "select all" state (#647)
- Feat: `graphify update --force` and `GRAPHIFY_FORCE=1` env var — bypass the node-count safety check after refactors that legitimately shrink the graph (#639)
- Fix: Codex PreToolUse hook on Windows — replaced `python3 -c "..."` inline command (fails on Conda where only `python` exists, and breaks PowerShell JSON parsing) with `graphify hook-check`, a new shell-agnostic subcommand. Re-run `graphify codex install` to regenerate the hook (#651, #522)
## 0.6.4 (2026-05-02)
- Fix: Codex PreToolUse hook failed on Windows — `[ -f ]` is bash-only and crashes on `cmd.exe`; replaced with a cross-platform Python one-liner (`pathlib.Path.exists()`) (#651)
## 0.6.3 (2026-05-02)
- Fix: incremental rebuild (`graphify update`, post-commit hook) dropped INFERRED/AMBIGUOUS semantic nodes extracted from code files — node preservation now filters by ID membership in the new AST output instead of `file_type`, so LLM-extracted call/data-flow edges survive code-only rebuilds (#653)
- Fix: post-commit and post-checkout hooks blocked `git commit` for the full rebuild duration (hours on large repos) — rebuilds now detach via `nohup & disown`, git returns in ~100ms, log written to `~/.cache/graphify-rebuild.log` (#650)
- Fix: cross-file INFERRED `calls` resolution used a last-write-wins name map, causing common short names (`log`, `execute`, `find`) to accumulate hundreds of spurious edges and dominate god_nodes ranking — resolution now skips any callee name that matches 2+ candidates (ambiguous, no import evidence to pick the right target) (#543)
- Fix: `cluster-only` command crashed on graphs with >5000 nodes due to unguarded `to_html` call — now wrapped in try/except ValueError matching the watch/hook path (#541)
## 0.6.2 (2026-05-01)
- Fix: Kimi K2.6 reasoning mode consumed entire token budget leaving `content` empty — thinking now disabled on Moonshot calls so graphs actually populate (#623)
+7 -3
View File
@@ -21,7 +21,7 @@
</a>
</p>
**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, or Google Antigravity - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions.
**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, or Google Antigravity - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions.
Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboard photos, images in other languages, or video and audio files - graphify extracts concepts and relationships from all of it and connects them into one graph. Videos are transcribed with Whisper using a domain-aware prompt derived from your corpus. YAML/YML files (Kubernetes, Kustomize, Helm, config) are indexed for semantic extraction. SQL files are AST-extracted deterministically — tables, views, functions, foreign keys, and FROM/JOIN relationships map directly into the graph with no LLM needed. 25 languages supported via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
@@ -61,7 +61,7 @@ Every relationship is tagged `EXTRACTED` (found directly in source), `INFERRED`
## Install
**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes, or [Google Antigravity](https://antigravity.google)
**Requires:** Python 3.10+ and one of: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), [Pi](https://pi.dev), Hermes, or [Google Antigravity](https://antigravity.google)
```bash
# Recommended — works on Mac and Linux with no PATH setup needed
@@ -94,6 +94,7 @@ pip install graphifyy && graphify install
| Gemini CLI | `graphify install --platform gemini` |
| Hermes | `graphify install --platform hermes` |
| Kiro IDE/CLI | `graphify kiro install` |
| Pi coding agent | `graphify install --platform pi` |
| Cursor | `graphify cursor install` |
| Google Antigravity | `graphify antigravity install` |
@@ -127,6 +128,7 @@ After building a graph, run this once in your project:
| Gemini CLI | `graphify gemini install` |
| Hermes | `graphify hermes install` |
| Kiro IDE/CLI | `graphify kiro install` |
| Pi coding agent | `graphify pi install` |
| Google Antigravity | `graphify antigravity install` |
**Claude Code** does two things: writes a `CLAUDE.md` section telling Claude to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs a **PreToolUse hook** (`settings.json`) that fires before every Glob and Grep call. If a knowledge graph exists, Claude sees: _"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ — so Claude navigates via the graph instead of grepping through every file.
@@ -141,6 +143,8 @@ After building a graph, run this once in your project:
**Aider, OpenClaw, Factory Droid, Trae, and Hermes** write the same rules to `AGENTS.md` in your project root and copy the skill to the platform's global skill directory. These platforms don't support tool hooks, so AGENTS.md is the always-on mechanism.
**Pi coding agent** copies the skill to `~/.pi/agent/skills/graphify/SKILL.md`. Run `graphify pi install` to set it up.
**Kiro IDE/CLI** writes the skill to `.kiro/skills/graphify/SKILL.md` (invoked via `/graphify`) and a steering file to `.kiro/steering/graphify.md` with `inclusion: always` — Kiro injects this into every conversation automatically, no hook needed.
**Google Antigravity** writes `.agents/rules/graphify.md` (always-on rules) and `.agents/workflows/graphify.md` (registers `/graphify` as a slash command). No hook equivalent exists in Antigravity — rules are the always-on mechanism.
@@ -408,7 +412,7 @@ Audio never leaves your machine. All transcription runs locally.
**Auto-sync** (`--watch`) - run in a background terminal and the graph updates itself as your codebase changes. Code file saves trigger an instant rebuild (AST only, no LLM). Doc/image changes notify you to run `--update` for the LLM re-pass.
**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. If a rebuild fails, the hook exits with a non-zero code so git surfaces the error instead of silently continuing. No background process needed.
**Git hooks** (`graphify hook install`) - installs post-commit and post-checkout hooks. Graph rebuilds automatically after every commit and every branch switch. Rebuilds run detached in the background so `git commit` returns instantly — log written to `~/.cache/graphify-rebuild.log` (`tail -f` for status).
**Wiki** (`--wiki`) - Wikipedia-style markdown articles per community and god node, with an `index.md` entry point. Point any agent at `index.md` and it can navigate the knowledge base by reading files instead of parsing JSON.
+83 -11
View File
@@ -124,6 +124,11 @@ _PLATFORM_CONFIG: dict[str, dict] = {
"skill_dst": Path(".kiro") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"pi": {
"skill_file": "skill-pi.md",
"skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
},
"antigravity": {
"skill_file": "skill.md",
"skill_dst": Path(".agents") / "skills" / "graphify" / "SKILL.md",
@@ -718,11 +723,11 @@ _CODEX_HOOK = {
"hooks": [
{
"type": "command",
"command": (
"[ -f graphify-out/graph.json ] && "
r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}}' """
"|| true"
),
# Use the graphify CLI itself so the hook is shell-agnostic:
# no [ -f ] bash syntax, no python3 vs python Conda issue,
# no JSON escaping inside PowerShell strings. Works on
# Windows (PowerShell/cmd.exe), macOS, and Linux.
"command": "graphify hook-check",
}
],
}
@@ -989,7 +994,7 @@ def main() -> None:
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro)")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi)")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
@@ -1005,7 +1010,10 @@ def main() -> None:
print(" --dir <path> target directory (default: ./raw)")
print(" watch <path> watch a folder and rebuild the graph on code changes")
print(" update <path> re-extract code files and update the graph (no LLM needed)")
print(" --force overwrite graph.json even if the rebuild has fewer nodes")
print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --budget N cap output at N tokens (default 2000)")
@@ -1051,6 +1059,8 @@ def main() -> None:
print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/")
print(" kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)")
print(" kiro uninstall remove skill + steering file")
print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)")
print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/")
print()
return
@@ -1138,6 +1148,26 @@ def main() -> None:
else:
print("Usage: graphify kiro [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "pi":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
install("pi")
elif subcmd == "uninstall":
skill_dst = Path.home() / ".pi" / "agent" / "skills" / "graphify" / "SKILL.md"
if skill_dst.exists():
skill_dst.unlink()
print(f" skill removed -> {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
try:
d.rmdir()
except OSError:
break
else:
print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
@@ -1383,6 +1413,7 @@ def main() -> None:
elif cmd == "cluster-only":
watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".")
no_viz = "--no-viz" in sys.argv
graph_json = watch_path / "graphify-out" / "graph.json"
if not graph_json.exists():
print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr)
@@ -1412,12 +1443,34 @@ def main() -> None:
out = watch_path / "graphify-out"
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
to_json(G, communities, str(out / "graph.json"))
to_html(G, communities, str(out / "graph.html"), community_labels=labels or None)
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
# Mirror watch.py pattern: gate to_html so core outputs (graph.json +
# GRAPH_REPORT.md) always land. Honor --no-viz explicitly; otherwise
# fall back to ValueError handling so an oversized graph doesn't crash
# the CLI mid-write and leave a stale graph.html on disk.
html_target = out / "graph.html"
if no_viz:
if html_target.exists():
html_target.unlink()
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated (--no-viz; graph.html removed).")
else:
try:
to_html(G, communities, str(html_target), community_labels=labels or None)
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
except ValueError as viz_err:
if html_target.exists():
html_target.unlink()
print(f"Skipped graph.html: {viz_err}")
print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.")
elif cmd == "update":
if len(sys.argv) > 2:
watch_path = Path(sys.argv[2])
force = os.environ.get("GRAPHIFY_FORCE", "").lower() in ("1", "true", "yes")
argv = list(sys.argv)
if "--force" in argv[2:]:
force = True
argv = [a for a in argv if a != "--force"]
if len(argv) > 2:
watch_path = Path(argv[2])
else:
# Try to recover the scan root saved by the last full build
saved = Path("graphify-out/.graphify_root")
@@ -1430,7 +1483,7 @@ def main() -> None:
sys.exit(1)
from graphify.watch import _rebuild_code
print(f"Re-extracting code files in {watch_path} (no LLM needed)...")
ok = _rebuild_code(watch_path)
ok = _rebuild_code(watch_path, force=force)
if ok:
print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.")
if not os.environ.get("MOONSHOT_API_KEY") and not os.environ.get("GRAPHIFY_NO_TIPS"):
@@ -1439,6 +1492,25 @@ def main() -> None:
print("Nothing to update or rebuild failed — check output above.", file=sys.stderr)
sys.exit(1)
elif cmd == "hook-check":
# Shell-agnostic PreToolUse hook entry point for Codex (and any platform
# where embedding Python/bash inline in a JSON hook command is fragile).
# Prints the hookSpecificOutput JSON if graph.json exists, exits 0 silently
# if not. Works on Windows PowerShell, cmd.exe, macOS, and Linux.
graph = Path("graphify-out") / "graph.json"
if graph.exists():
import json as _json
print(_json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": (
"graphify: Knowledge graph exists. "
"Read graphify-out/GRAPH_REPORT.md for god nodes and "
"community structure before searching raw files."
),
}
}))
sys.exit(0)
elif cmd == "check-update":
if len(sys.argv) < 3:
print("Usage: graphify check-update <path>", file=sys.stderr)
+118 -5
View File
@@ -509,6 +509,115 @@ def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> boo
return result
def _load_graphifyinclude(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyinclude allowlist patterns from root and ancestors.
Include patterns opt matching hidden files/dirs into traversal. Sensitive
files and hard-skipped noise directories are still excluded later.
Uses the same VCS-root ceiling logic as _load_graphifyignore.
"""
root = root.resolve()
ceiling = _find_vcs_root(root) or root
dirs: list[Path] = []
current = root
while True:
dirs.append(current)
if current == ceiling:
break
current = current.parent
dirs.reverse()
patterns: list[tuple[Path, str]] = []
for d in dirs:
include_file = d / ".graphifyinclude"
if include_file.exists():
for raw in include_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((d, line))
return patterns
def _is_included(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
"""Return True if path matches any .graphifyinclude allowlist pattern."""
if not patterns:
return False
def _matches(rel: str, p: str) -> bool:
parts = rel.split("/")
if fnmatch.fnmatch(rel, p):
return True
if fnmatch.fnmatch(path.name, p):
return True
for i, part in enumerate(parts):
if fnmatch.fnmatch(part, p):
return True
if fnmatch.fnmatch("/".join(parts[:i + 1]), p):
return True
return False
for anchor, pattern in patterns:
anchored = pattern.startswith("/")
p = pattern.strip("/")
if not p:
continue
if anchored:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
else:
try:
rel = str(path.relative_to(root)).replace(os.sep, "/")
if _matches(rel, p):
return True
except ValueError:
pass
if anchor != root:
try:
rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/")
if _matches(rel_anchor, p):
return True
except ValueError:
pass
return False
def _could_contain_included_path(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool:
"""Return True if a directory may contain files matched by .graphifyinclude."""
if not patterns:
return False
rels: list[str] = []
try:
rels.append(str(path.relative_to(root)).replace(os.sep, "/"))
except ValueError:
pass
for anchor, _ in patterns:
if anchor != root:
try:
rels.append(str(path.relative_to(anchor)).replace(os.sep, "/"))
except ValueError:
pass
for rel in rels:
rel = rel.strip("/")
if not rel:
return True
for _, pattern in patterns:
p = pattern.strip("/")
if not p:
continue
if p == rel or p.startswith(rel + "/"):
return True
if fnmatch.fnmatch(rel, p):
return True
return False
def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
root = root.resolve()
files: dict[FileType, list[str]] = {
@@ -522,6 +631,7 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
skipped_sensitive: list[str] = []
ignore_patterns = _load_graphifyignore(root)
include_patterns = _load_graphifyinclude(root)
# Always include graphify-out/memory/ - query results filed back into the graph
memory_dir = root / "graphify-out" / "memory"
@@ -543,10 +653,12 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
dirnames.clear()
continue
if not in_memory_tree:
# Prune noise dirs in-place so os.walk never descends into them
# Prune noise dirs in-place so os.walk never descends into them.
# Hidden dirs are allowed through if they could contain an
# explicitly included path (.graphifyinclude allowlist).
dirnames[:] = [
d for d in dirnames
if not d.startswith(".")
if (not d.startswith(".") or _could_contain_included_path(dp / d, root, include_patterns))
and not _is_noise_dir(d)
and not _is_ignored(dp / d, root, ignore_patterns)
]
@@ -565,8 +677,9 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
in_memory = memory_dir.exists() and str(p).startswith(str(memory_dir))
if not in_memory:
# Hidden files are already excluded via dir pruning above,
# but catch hidden files at the root level
if p.name.startswith("."):
# but catch hidden files at the root level. A .graphifyinclude
# entry can opt a specific hidden file back in.
if p.name.startswith(".") and not _is_included(p, root, include_patterns):
continue
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
if str(p).startswith(str(converted_dir)):
@@ -623,7 +736,7 @@ def detect(root: Path, *, follow_symlinks: bool = False) -> dict:
def _md5_file(path: Path) -> str:
"""MD5 of file contents streamed in 64KB chunks — for change detection only."""
import hashlib as _hl
h = _hl.md5()
h = _hl.md5(usedforsecurity=False)
try:
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
+78 -17
View File
@@ -25,6 +25,22 @@ COMMUNITY_COLORS = [
MAX_NODES_FOR_VIZ = 5_000
def _viz_node_limit() -> int:
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
Set to 0 to disable HTML viz unconditionally (useful for CI runners).
"""
import os
raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
if raw is None or not raw.strip():
return MAX_NODES_FOR_VIZ
try:
return int(raw)
except ValueError:
return MAX_NODES_FOR_VIZ
def _html_styles() -> str:
return """<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -55,9 +71,14 @@ def _html_styles() -> str:
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend-count { color: #666; font-size: 11px; }
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
#legend-controls { display: flex; gap: 6px; margin-bottom: 8px; }
#legend-controls button { flex: 1; background: #0f0f1a; border: 1px solid #3a3a5e; color: #aaa; padding: 4px 0; border-radius: 4px; font-size: 11px; cursor: pointer; }
#legend-controls button:hover { border-color: #4E79A7; color: #e0e0e0; }
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
#legend-controls label:hover { color: #e0e0e0; }
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
</style>"""
@@ -244,26 +265,41 @@ document.addEventListener('click', e => {{
const hiddenCommunities = new Set();
const selectAllCb = document.getElementById('select-all-cb');
function updateSelectAllState() {{
const total = LEGEND.length;
const hidden = hiddenCommunities.size;
selectAllCb.checked = hidden === 0;
selectAllCb.indeterminate = hidden > 0 && hidden < total;
}}
function toggleAllCommunities(hide) {{
document.querySelectorAll('.legend-item').forEach(item => {{
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
}});
document.querySelectorAll('.legend-cb').forEach(cb => {{
cb.checked = !hide;
}});
LEGEND.forEach(c => {{
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
}});
const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }}));
nodesDS.update(updates);
updateSelectAllState();
}}
const legendEl = document.getElementById('legend');
LEGEND.forEach(c => {{
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
<span class="legend-label">${{c.label}}</span>
<span class="legend-count">${{c.count}}</span>`;
item.onclick = () => {{
if (hiddenCommunities.has(c.cid)) {{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'legend-cb';
cb.checked = true;
cb.addEventListener('change', (e) => {{
e.stopPropagation();
if (cb.checked) {{
hiddenCommunities.delete(c.cid);
item.classList.remove('dimmed');
}} else {{
@@ -272,8 +308,18 @@ LEGEND.forEach(c => {{
}}
const updates = RAW_NODES
.filter(n => n.community === c.cid)
.map(n => ({{ id: n.id, hidden: hiddenCommunities.has(c.cid) }}));
.map(n => ({{ id: n.id, hidden: !cb.checked }}));
nodesDS.update(updates);
updateSelectAllState();
}});
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
<span class="legend-label">${{c.label}}</span>
<span class="legend-count">${{c.count}}</span>`;
item.prepend(cb);
item.onclick = (e) => {{
if (e.target === cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
}};
legendEl.appendChild(item);
}});
@@ -327,6 +373,15 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *,
if "confidence_score" not in link:
conf = link.get("confidence", "EXTRACTED")
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
# Restore original edge direction. Undirected NetworkX storage may
# canonicalize endpoint order, flipping `calls` and other directional
# edges in graph.json. The build path stashes the true endpoints in
# _src/_tgt for exactly this purpose (#563).
true_src = link.pop("_src", None)
true_tgt = link.pop("_tgt", None)
if true_src is not None and true_tgt is not None:
link["source"] = true_src
link["target"] = true_tgt
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
with open(output_path, "w", encoding="utf-8") as f: # nosec
json.dump(data, f, indent=2)
@@ -391,10 +446,12 @@ def to_html(
If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.
"""
if G.number_of_nodes() > MAX_NODES_FOR_VIZ:
limit = _viz_node_limit()
if G.number_of_nodes() > limit:
raise ValueError(
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz. "
f"Use --no-viz or reduce input size."
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
f"or reduce input size."
)
node_community = _node_community_map(communities)
@@ -431,14 +488,19 @@ def to_html(
"degree": deg,
})
# Build edges list
# Build edges list. Restore original edge direction from _src/_tgt
# (stashed by build.py for exactly this reason): undirected NetworkX
# canonicalizes endpoint order, which would otherwise flip the arrow
# for `calls` and `rationale_for` in the rendered graph (#563).
vis_edges = []
for u, v, data in G.edges(data=True):
confidence = data.get("confidence", "EXTRACTED")
relation = data.get("relation", "")
true_src = data.get("_src", u)
true_tgt = data.get("_tgt", v)
vis_edges.append({
"from": u,
"to": v,
"from": true_src,
"to": true_tgt,
"label": relation,
"title": _html.escape(f"{relation} [{confidence}]"),
"dashes": confidence != "EXTRACTED",
@@ -488,8 +550,7 @@ def to_html(
<div id="legend-wrap">
<h3>Communities</h3>
<div id="legend-controls">
<button onclick="toggleAllCommunities(false)">Show All</button>
<button onclick="toggleAllCommunities(true)">Hide All</button>
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
</div>
<div id="legend"></div>
</div>
+75 -11
View File
@@ -182,6 +182,7 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
resolved_path: "Path | None" = None
for child in node.children:
if child.type == "string":
raw = _read_text(child, source).strip("'\"` ")
@@ -197,6 +198,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
elif resolved.suffix == ".jsx":
resolved = resolved.with_suffix(".tsx")
tgt_nid = _make_id(str(resolved))
resolved_path = resolved
else:
# Check tsconfig.json path aliases (e.g. "@/" → "src/") before treating as external (#575)
aliases = _load_tsconfig_aliases(Path(str_path).parent)
@@ -208,6 +210,7 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
break
if resolved_alias is not None:
tgt_nid = _make_id(str(resolved_alias))
resolved_path = resolved_alias
else:
# Bare/scoped import (node_modules) - use last segment; dropped as external
module_name = raw.split("/")[-1]
@@ -225,6 +228,32 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p
})
break
# Emit symbol-level edges for named imports from local/aliased files.
# e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED)
# Uses the same _make_id(target_stem, name) key that _extract_generic emits when
# defining the symbol, so these edges wire importers directly to existing symbol nodes.
if resolved_path is not None:
target_stem = _file_stem(resolved_path)
line = node.start_point[0] + 1
for child in node.children:
if child.type == "import_clause":
for sub in child.children:
if sub.type == "named_imports":
for spec in sub.children:
if spec.type == "import_specifier":
name_node = spec.child_by_field_name("name")
if name_node:
sym = _read_text(name_node, source)
edges.append({
"source": file_nid,
"target": _make_id(target_stem, sym),
"relation": "imports",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
def _import_java(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
def _walk_scoped(n) -> str:
@@ -590,7 +619,11 @@ _KOTLIN_CONFIG = LanguageConfig(
call_function_field="",
call_accessor_node_types=frozenset({"navigation_expression"}),
call_accessor_field="",
name_fallback_child_types=("simple_identifier",),
# Different tree-sitter-kotlin grammar versions name plain identifier
# nodes differently: PyPI's `tree_sitter_kotlin` uses `identifier`,
# older forks use `simple_identifier`. Accept both so the extractor
# works across grammar generations.
name_fallback_child_types=("simple_identifier", "identifier"),
body_fallback_child_types=("function_body", "class_body"),
function_boundary_types=frozenset({"function_declaration"}),
import_handler=_import_kotlin,
@@ -1069,15 +1102,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
if sc.type == "simple_identifier":
callee_name = _read_text(sc, source)
elif config.ts_module == "tree_sitter_kotlin":
# Kotlin: first child may be simple_identifier or navigation_expression
# Kotlin: first child may be simple_identifier/identifier or
# navigation_expression. PyPI's `tree_sitter_kotlin` produces
# `identifier` for plain identifier nodes; older grammar
# versions (including the JVM `io.github.bonede:tree-sitter-kotlin`
# binding) produce `simple_identifier`. Accept both.
first = node.children[0] if node.children else None
if first:
if first.type == "simple_identifier":
if first.type in ("simple_identifier", "identifier"):
callee_name = _read_text(first, source)
elif first.type == "navigation_expression":
is_member_call = True
for child in reversed(first.children):
if child.type == "simple_identifier":
if child.type in ("simple_identifier", "identifier"):
callee_name = _read_text(child, source)
break
elif config.ts_module == "tree_sitter_scala":
@@ -2830,8 +2867,17 @@ def _resolve_cross_file_imports(
stem = Path(src).stem
label = node.get("label", "")
nid = node.get("id", "")
# Only index real classes/functions (not file nodes, not method stubs)
if label and not label.endswith((")", ".py")) and "_" not in label[:1]:
# Index class-level entities only. Function/method labels end in "()"
# so are excluded by the `endswith(")")` filter; file nodes end in ".py";
# private/internal labels start with "_"; rationale nodes carry
# file_type=="rationale" and must never participate in cross-file
# import resolution (#563).
if (
label
and not label.endswith((")", ".py"))
and "_" not in label[:1]
and node.get("file_type") != "rationale"
):
stem_to_entities.setdefault(stem, {})[label] = nid
# Pass 2: for each file, find `from .X import A, B, C` and resolve
@@ -2842,12 +2888,15 @@ def _resolve_cross_file_imports(
stem = _file_stem(path)
str_path = str(path)
# Find all classes defined in this file (the importers)
# Find all classes defined in this file (the importers).
# Excludes rationale nodes whose labels happen not to end in ")" or ".py"
# but which must never be treated as importing entities (#563).
local_classes = [
n["id"] for n in file_result.get("nodes", [])
if n.get("source_file") == str_path
and not n["label"].endswith((")", ".py"))
and n["id"] != _make_id(stem) # exclude file-level node
and n.get("file_type") != "rationale"
]
if not local_classes:
continue
@@ -3697,12 +3746,21 @@ def extract(
# Cross-file call resolution for all languages
# Each extractor saved unresolved calls in raw_calls. Now that we have all
# nodes from all files, resolve any callee that exists in another file.
global_label_to_nid: dict[str, str] = {}
# Build name → ALL matching node IDs so we can skip ambiguous common names
# (e.g. "log", "execute", "find") that appear in multiple files — resolving
# those inflates god_nodes ranking with spurious cross-file edges.
# Build label -> node_id index for cross-file call resolution.
# Skip rationale nodes (their labels are docstring text, not callable
# identifiers, and they were polluting matches for short names — #563).
global_label_to_nids: dict[str, list[str]] = {}
for n in all_nodes:
if n.get("file_type") == "rationale":
continue
raw = n.get("label", "")
normalised = raw.strip("()").lstrip(".")
if normalised:
global_label_to_nid[normalised.lower()] = n["id"]
key = normalised.lower()
global_label_to_nids.setdefault(key, []).append(n["id"])
existing_pairs = {(e["source"], e["target"]) for e in all_edges}
for result in per_file:
@@ -3714,9 +3772,15 @@ def extract(
# and collides with any top-level function named "log" in the corpus.
if rc.get("is_member_call"):
continue
tgt = global_label_to_nid.get(callee.lower())
candidates = global_label_to_nids.get(callee.lower(), [])
# Skip ambiguous names that resolve to multiple nodes — these are
# common short names (log, execute, find) with no import evidence
# to pick the right target; emitting all edges inflates god_nodes.
if len(candidates) != 1:
continue
tgt = candidates[0]
caller = rc["caller_nid"]
if tgt and tgt != caller and (caller, tgt) not in existing_pairs:
if tgt != caller and (caller, tgt) not in existing_pairs:
existing_pairs.add((caller, tgt))
all_edges.append({
"source": caller,
+21 -8
View File
@@ -61,7 +61,13 @@ fi
""" + _PYTHON_DETECT + """
export GRAPHIFY_CHANGED="$CHANGED"
$GRAPHIFY_PYTHON -c "
# Run rebuild detached so git commit returns immediately.
# Full repo rebuilds can take hours; blocking the post-commit hook stalls the shell.
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
nohup $GRAPHIFY_PYTHON -c "
import os, sys
from pathlib import Path
@@ -74,12 +80,15 @@ if not changed:
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
import os as _os
from graphify.watch import _rebuild_code
_rebuild_code(Path('.'))
_force = _os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_rebuild_code(Path('.'), force=_force)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
"
" > "$_GRAPHIFY_LOG" 2>&1 < /dev/null &
disown 2>/dev/null || true
# graphify-hook-end
"""
@@ -111,17 +120,21 @@ GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
""" + _PYTHON_DETECT + """
echo "[graphify] Branch switched - rebuilding knowledge graph (code files)..."
$GRAPHIFY_PYTHON -c "
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
nohup $GRAPHIFY_PYTHON -c "
from graphify.watch import _rebuild_code
from pathlib import Path
import sys
import os, sys
try:
_rebuild_code(Path('.'))
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_rebuild_code(Path('.'), force=_force)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
"
" > "$_GRAPHIFY_LOG" 2>&1 < /dev/null &
disown 2>/dev/null || true
# graphify-checkout-hook-end
"""
+1183
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -826,7 +826,7 @@ G = json_graph.node_link_graph(data, edges='links')
detection = {'total_files': 0, 'total_words': 99999, 'needs_graph': True, 'warning': None,
'files': {'code': [], 'document': [], 'paper': []}}
tokens = {'input': 0, 'output':': 0}
tokens = {'input': 0, 'output': 0}
communities = cluster(G)
cohesion = score_all(G, communities)
+29 -4
View File
@@ -279,10 +279,16 @@ If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, auth
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
- EXTRACTED edges: confidence_score = 1.0 always
- INFERRED edges: reason about each edge individually.
Direct structural evidence (shared data structure, clear dependency): 0.8-0.9.
Reasonable inference with some uncertainty: 0.6-0.7.
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
0.95 direct structural evidence (shared data structure, named cross-file reference).
0.85 strong inference (clear functional alignment, no direct symbol link).
0.75 reasonable inference (shared problem domain + similar shape, requires interpretation).
0.65 weak inference (thematically related, no shape evidence).
0.55 speculative but plausible (surface-level co-occurrence only).
Models follow discrete rubrics better than continuous ranges; the bimodal
distribution observed in production (>50% at 0.5, >40% at 0.85+) shows the
range guidance is being collapsed to a binary. If no value above fits, mark
the edge AMBIGUOUS rather than picking 0.4 or below.
- AMBIGUOUS edges: 0.1-0.3
Output exactly this JSON (no other text):
@@ -785,9 +791,28 @@ G_existing = json_graph.node_link_graph(existing_data, edges='links')
new_extraction = json.loads(Path('.graphify_extract.json').read_text())
G_new = build_from_json(new_extraction)
# Prune nodes from deleted files
incremental = json.loads(Path('.graphify_incremental.json').read_text())
deleted = set(incremental.get('deleted_files', []))
if deleted:
to_remove = [n for n, d in G_existing.nodes(data=True) if d.get('source_file') in deleted]
G_existing.remove_nodes_from(to_remove)
if to_remove:
print(f'Pruned {len(to_remove)} ghost node(s) from {len(deleted)} deleted file(s) — drift detected and corrected.')
else:
print(f'{len(deleted)} file(s) deleted since last run, but no ghost nodes were present in the graph — no drift.')
# Merge: new nodes/edges into existing graph
G_existing.update(G_new)
print(f'Merged: {G_existing.number_of_nodes()} nodes, {G_existing.number_of_edges()} edges')
# Save manifest with the CURRENT full file list so the next --update
# diffs against today's filesystem state, not the prior --update's
# baseline. Without this, deleted files get reported as ghosts again
# on every subsequent --update until a full rebuild runs.
from graphify.detect import save_manifest
save_manifest(incremental['files'])
print('[graphify update] Manifest saved.')
"
```
+22 -5
View File
@@ -337,10 +337,16 @@ If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, auth
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
- EXTRACTED edges: confidence_score = 1.0 always
- INFERRED edges: reason about each edge individually.
Direct structural evidence (shared data structure, clear dependency): 0.8-0.9.
Reasonable inference with some uncertainty: 0.6-0.7.
Weak or speculative: 0.4-0.5. Most edges should be 0.6-0.9, not 0.5.
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
0.95 direct structural evidence (shared data structure, named cross-file reference).
0.85 strong inference (clear functional alignment, no direct symbol link).
0.75 reasonable inference (shared problem domain + similar shape, requires interpretation).
0.65 weak inference (thematically related, no shape evidence).
0.55 speculative but plausible (surface-level co-occurrence only).
Models follow discrete rubrics better than continuous ranges; the bimodal
distribution observed in production (>50% at 0.5, >40% at 0.85+) shows the
range guidance is being collapsed to a binary. If no value above fits, mark
the edge AMBIGUOUS rather than picking 0.4 or below.
- AMBIGUOUS edges: 0.1-0.3
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is the filename without extension and entity is the symbol name, both normalized (lowercase, non-alphanumeric chars replaced with `_`). Example: `src/auth/session.py` + `ValidateToken``session_validatetoken`. This must match the ID the AST extractor generates so cross-references between code and semantic nodes connect correctly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
@@ -923,7 +929,10 @@ deleted = set(incremental.get('deleted_files', []))
if deleted:
to_remove = [n for n, d in G_existing.nodes(data=True) if d.get('source_file') in deleted]
G_existing.remove_nodes_from(to_remove)
print(f'Pruned {len(to_remove)} ghost nodes from {len(deleted)} deleted file(s)')
if to_remove:
print(f'Pruned {len(to_remove)} ghost node(s) from {len(deleted)} deleted file(s) — drift detected and corrected.')
else:
print(f'{len(deleted)} file(s) deleted since last run, but no ghost nodes were present in the graph — no drift.')
# Merge: new nodes/edges into existing graph
G_existing.update(G_new)
@@ -939,6 +948,14 @@ merged_out = {
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out))
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
# Save manifest with the CURRENT full file list so the next --update
# diffs against today's filesystem state, not the prior --update's
# baseline. Without this, deleted files get reported as ghosts again
# on every subsequent --update until a full rebuild runs.
from graphify.detect import save_manifest
save_manifest(incremental['files'])
print('[graphify update] Manifest saved.')
"
```
+1 -1
View File
@@ -58,7 +58,7 @@ def download_audio(url: str, output_dir: Path) -> Path:
# yt-dlp uses %(title)s which can be long/weird — use a stable name based on URL hash
import hashlib
url_hash = hashlib.sha1(url.encode()).hexdigest()[:12]
url_hash = hashlib.sha1(url.encode(), usedforsecurity=False).hexdigest()[:12]
out_template = str(output_dir / f"yt_{url_hash}.%(ext)s")
# Check for already-downloaded file
+19 -10
View File
@@ -33,9 +33,13 @@ def _relativize_source_files(payload: dict, root: Path) -> None:
continue
def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False, force: bool = False) -> bool:
"""Re-run AST extraction + build + cluster + report for code files. No LLM needed.
When ``force`` is True the node-count safety check in ``to_json`` is bypassed
so the rebuilt graph overwrites graph.json even if it has fewer nodes.
Use this after refactors that legitimately delete code.
Returns True on success, False on error.
"""
watch_root = watch_path.resolve()
@@ -60,20 +64,25 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
result = extract(code_files, cache_root=watch_root)
# Preserve semantic nodes/edges from a previous full run.
# AST-only rebuild replaces code nodes; doc/paper/image nodes are kept.
# AST-only rebuild replaces nodes for changed files; everything else is kept.
# Filter by node ID membership in the new AST output, not by file_type —
# INFERRED/AMBIGUOUS nodes extracted from code files also carry file_type="code"
# and would be wrongly dropped by a file_type-based filter.
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("links", existing.get("edges", []))
if e.get("confidence") in ("INFERRED", "AMBIGUOUS")
or (e.get("source") not in code_ids and e.get("target") not in code_ids)]
new_ast_ids = {n["id"] for n in result["nodes"]}
preserved_nodes = [n for n in existing.get("nodes", []) if n["id"] not in new_ast_ids]
all_ids = new_ast_ids | {n["id"] for n in preserved_nodes}
preserved_edges = [
e for e in existing.get("links", existing.get("edges", []))
if e.get("source") in all_ids and e.get("target") in all_ids
]
result = {
"nodes": result["nodes"] + sem_nodes,
"edges": result["edges"] + sem_edges,
"nodes": result["nodes"] + preserved_nodes,
"edges": result["edges"] + preserved_edges,
"hyperedges": existing.get("hyperedges", []),
"input_tokens": 0,
"output_tokens": 0,
@@ -100,7 +109,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
out.mkdir(exist_ok=True)
(out / ".graphify_root").write_text(str(watch_root), encoding="utf-8")
json_written = to_json(G, communities, str(out / "graph.json"))
json_written = to_json(G, communities, str(out / "graph.json"), force=force)
if not json_written:
return False
+21 -1
View File
@@ -7,7 +7,18 @@ import networkx as nx
def _safe_filename(name: str) -> str:
return name.replace("/", "-").replace(" ", "_").replace(":", "-")
"""Make a label safe for use as a filename across platforms.
Substitutes characters that Windows reserves in filenames
(< > : " / \\ | ? *) and strips trailing dots/spaces, also reserved.
Falls back to 'unnamed' for empty results and caps length at 200
chars to stay well under common filesystem limits.
"""
import re
s = name.replace("/", "-").replace(" ", "_").replace(":", "-")
s = re.sub(r'[<>:"/\\|?*]', '_', s)
s = s.strip('. ')
return s[:200] if s else 'unnamed'
def _cross_community_links(G: nx.Graph, nodes: list[str], own_cid: int, labels: dict[int, str]) -> list[tuple[str, int]]:
@@ -185,6 +196,15 @@ def to_wiki(
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# Clear stale .md files from previous runs to prevent orphan accumulation.
# Community labels are LLM-generated (per skill.md Step 5) and non-deterministic
# across runs — the same conceptual community may be named differently each time
# (e.g. "AutoAgent Skills" → "AutoAgent Methodology"), leaving the previous file
# as an orphan. Since to_wiki() owns wiki/ entirely (always writes the full set),
# it can safely clear .md files at the start of each call.
for old_article in out.glob("*.md"):
old_article.unlink()
labels = community_labels or {cid: f"Community {cid}" for cid in communities}
cohesion = cohesion or {}
god_nodes_data = god_nodes_data or []
+7 -7
View File
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.6.2"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
version = "0.6.6"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
keywords = ["claude", "claude-code", "codex", "opencode", "cursor", "gemini", "aider", "kiro", "knowledge-graph", "rag", "graphrag", "obsidian", "community-detection", "tree-sitter", "leiden", "llm"]
keywords = ["claude", "claude-code", "codex", "opencode", "cursor", "gemini", "aider", "kiro", "pi", "knowledge-graph", "rag", "graphrag", "obsidian", "community-detection", "tree-sitter", "leiden", "llm"]
requires-python = ">=3.10"
dependencies = [
"networkx",
@@ -62,9 +62,9 @@ graphify = "graphify.__main__:main"
# Run without installing: uvx graphifyy install
package = true
[tool.setuptools.packages.find]
where = ["."]
include = ["graphify*"]
[tool.setuptools]
packages = ["graphify"]
include-package-data = false
[tool.setuptools.package-data]
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md"]
graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md"]
+22
View File
@@ -168,3 +168,25 @@ def test_calls_deduplication():
result = extract_python(FIXTURES / "sample_calls.py")
call_pairs = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"]
assert len(call_pairs) == len(set(call_pairs)), "Duplicate calls edges found"
def test_cross_file_calls_skip_ambiguous_duplicate_labels(tmp_path):
"""Unqualified cross-file calls must not guess between duplicate helper names."""
caller = tmp_path / "caller.py"
helper_a = tmp_path / "a.py"
helper_b = tmp_path / "b.py"
caller.write_text("def run():\n log()\n")
helper_a.write_text("def log():\n return 'a'\n")
helper_b.write_text("def log():\n return 'b'\n")
result = extract([caller, helper_a, helper_b], cache_root=tmp_path)
nodes = {n["id"]: n for n in result["nodes"]}
calls = [
e for e in result["edges"]
if e["relation"] == "calls" and e["confidence"] == "INFERRED"
]
assert not any(
nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "log()"
for e in calls
)
+12
View File
@@ -188,6 +188,18 @@ def test_kotlin_finds_function():
r = extract_kotlin(FIXTURES / "sample.kt")
assert any("createClient" in l for l in _labels(r))
def test_kotlin_emits_in_file_calls():
"""Regression test for the call-walker `simple_identifier` /
`identifier` rename see graphify-kmp's PythonParityTest."""
r = extract_kotlin(FIXTURES / "sample.kt")
calls = _calls(r)
# In sample.kt: get() and post() both call buildRequest(), and
# createClient() invokes Config and HttpClient (constructor calls).
assert (".get()", ".buildRequest()") in calls
assert (".post()", ".buildRequest()") in calls
assert ("createClient()", "Config") in calls
assert ("createClient()", "HttpClient") in calls
# ── Scala ─────────────────────────────────────────────────────────────────────