chore: import upstream snapshot with attribution
CI / test (3.10) (push) Failing after 1s
CI / test (3.12) (push) Failing after 0s
CI / skillgen-check (push) Failing after 0s
CI / security-scan (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:09:14 +08:00
commit d88fd01084
727 changed files with 235247 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
"""graphify - extract · build · cluster · analyze · report."""
def __getattr__(name):
# Lazy imports so `graphify install` works before heavy deps are in place.
_map = {
"extract": ("graphify.extract", "extract"),
"collect_files": ("graphify.extract", "collect_files"),
"build_from_json": ("graphify.build", "build_from_json"),
"cluster": ("graphify.cluster", "cluster"),
"score_all": ("graphify.cluster", "score_all"),
"cohesion_score": ("graphify.cluster", "cohesion_score"),
"god_nodes": ("graphify.analyze", "god_nodes"),
"surprising_connections": ("graphify.analyze", "surprising_connections"),
"suggest_questions": ("graphify.analyze", "suggest_questions"),
"generate": ("graphify.report", "generate"),
"to_json": ("graphify.export", "to_json"),
"to_html": ("graphify.export", "to_html"),
"to_svg": ("graphify.export", "to_svg"),
"to_canvas": ("graphify.export", "to_canvas"),
"to_wiki": ("graphify.wiki", "to_wiki"),
"reflect": ("graphify.reflect", "reflect"),
"save_query_result": ("graphify.ingest", "save_query_result"),
}
if name in _map:
import importlib
mod_name, attr = _map[name]
mod = importlib.import_module(mod_name)
return getattr(mod, attr)
raise AttributeError(f"module 'graphify' has no attribute {name!r}")
+673
View File
@@ -0,0 +1,673 @@
"""graphify CLI - `graphify install` sets up the Claude Code skill."""
from __future__ import annotations
import functools
import json
import os
import platform
import re
import shutil
import sys
from pathlib import Path
try:
from importlib.metadata import version as _pkg_version
__version__ = _pkg_version("graphifyy")
except Exception:
__version__ = "unknown"
# Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups.
# Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out").
# Defined once in graphify.paths so the security/callflow path guards honour the
# same override (#1423).
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
# Install/uninstall subsystem moved to graphify/install.py; re-exported here so
# `from graphify.__main__ import <name>` keeps working unchanged.
from graphify.install import ( # noqa: E402,F401
dispatch_install_cli,
_agents_install,
_agents_platform_install,
_agents_platform_uninstall,
_agents_uninstall,
_always_on,
_amp_install,
_amp_legacy_cleanup,
_amp_uninstall,
_antigravity_finalize,
_antigravity_install,
_antigravity_uninstall,
_canonical_platform,
_claude_pretooluse_hooks,
_copy_skill_file,
_cursor_install,
_cursor_uninstall,
_devin_rules_install,
_devin_rules_uninstall,
_gemini_hook,
_install_claude_hook,
_install_codebuddy_hook,
_install_codex_hook,
_install_gemini_hook,
_install_kilo_plugin,
_install_opencode_plugin,
_install_skill_references,
_kilo_config_path,
_kilo_config_write_path,
_kilo_install,
_kilo_uninstall,
_kilo_uninstall_global,
_kiro_install,
_kiro_uninstall,
_load_json_like,
_packaged_skill_refs_dir,
_platform_skill_destination,
_print_banner,
_print_install_usage,
_print_project_git_add_hint,
_project_install,
_project_scope_root,
_project_uninstall,
_project_uninstall_all,
_refresh_all_version_stamps,
_remove_claude_skill_registration,
_remove_skill_file,
_replace_or_append_section,
_resolve_graphify_exe,
_skill_registration,
_strip_graphify_hook,
_strip_graphify_md_section,
_strip_json_comments,
_uninstall_claude_hook,
_uninstall_codebuddy_hook,
_uninstall_codex_hook,
_uninstall_gemini_hook,
_uninstall_kilo_plugin,
_uninstall_opencode_plugin,
claude_install,
claude_uninstall,
codebuddy_install,
codebuddy_uninstall,
gemini_install,
gemini_uninstall,
install,
uninstall_all,
vscode_install,
vscode_uninstall,
_PLATFORM_ALIASES,
_CLAUDE_MD_MARKER,
_CODEBUDDY_MD_MARKER,
_AGENTS_MD_MARKER,
_GEMINI_MD_MARKER,
_VSCODE_INSTRUCTIONS_MARKER,
_ANTIGRAVITY_RULES_PATH,
_ANTIGRAVITY_WORKFLOW_PATH,
_ANTIGRAVITY_WORKFLOW,
_CURSOR_RULE_PATH,
_CURSOR_RULE,
_DEVIN_RULES_PATH,
_DEVIN_RULES,
_KILO_PLUGIN_JS,
_KILO_PLUGIN_PATH,
_KILO_CONFIG_JSON_PATH,
_KILO_CONFIG_JSONC_PATH,
_OPENCODE_PLUGIN_JS,
_OPENCODE_PLUGIN_PATH,
_OPENCODE_CONFIG_PATH,
_PLATFORM_CONFIG,
)
from graphify.cli import ( # noqa: E402,F401
dispatch_command,
_StageTimer,
_clone_repo,
_default_graph_path,
_enforce_graph_size_cap_or_exit,
_run_hook_guard,
_SEARCH_NUDGE,
_READ_NUDGE,
_HOOK_SOURCE_EXTS,
_GEMINI_NUDGE_TEXT,
)
_ALWAYS_ON_ALIASES = {
"_CLAUDE_MD_SECTION": "claude-md",
"_AGENTS_MD_SECTION": "agents-md",
"_GEMINI_MD_SECTION": "gemini-md",
"_VSCODE_INSTRUCTIONS_SECTION": "vscode-instructions",
"_ANTIGRAVITY_RULES": "antigravity-rules",
"_KIRO_STEERING": "kiro-steering",
}
def __getattr__(name: str) -> str:
# PEP 562: lazily resolve the legacy always-on section constants for external
# importers (e.g. the install-string tests). In-module code calls _always_on()
# directly; nothing is read at import time, so a missing block can no longer
# brick the CLI on `import graphify.__main__` (#1121 follow-up).
base = _ALWAYS_ON_ALIASES.get(name)
if base is not None:
return _always_on(base)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _check_skill_version(skill_dst: Path) -> None:
"""Warn if the installed skill is from an older graphify version."""
version_file = skill_dst.parent / ".graphify_version"
try:
if not version_file.exists():
return
except OSError:
return
try:
skill_exists = skill_dst.exists()
except OSError:
return
if not skill_exists:
print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.")
return
# A progressive SKILL.md links to its references/ sidecar. If the body points
# at references/ but the dir is gone (manual delete, partial upgrade), the
# on-demand fragments won't load — flag it for repair.
try:
body = skill_dst.read_text(encoding="utf-8")
except OSError:
body = ""
if "references/" in body and not (skill_dst.parent / "references").exists():
print(" warning: skill references/ sidecar is missing. Run 'graphify install' to repair.", file=sys.stderr)
try:
installed = version_file.read_text(encoding="utf-8").strip()
except OSError:
return
if installed != __version__:
if _version_tuple(installed) > _version_tuple(__version__):
# The skill on disk is NEWER than the running package. `graphify install`
# writes the package's OWN (older) bundled skill and re-stamps the version,
# so following the old "run install" advice would silently DOWNGRADE the
# skill. The real fix is to upgrade the package (#1568). Common for a stale
# `uv tool` CLI, or a contributor whose dev checkout stamped a newer skill.
print(
f" warning: skill is from graphify {installed}, but the package is "
f"{__version__} (older). Upgrade the package "
f"(e.g. 'uv tool upgrade graphifyy' or 'pip install -U graphifyy'); "
f"running 'graphify install' would downgrade the skill.",
file=sys.stderr,
)
else:
print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)
def _version_tuple(version: str) -> tuple[int, ...]:
"""Parse a version string into a comparable integer tuple (``0.9.2`` -> ``(0, 9, 2)``).
Reads the leading digits of each dot-segment, so pre/post-release suffixes
(``1.0.0rc1``) compare by their numeric core. A non-numeric or empty segment
becomes 0, so a malformed stamp degrades to a conservative comparison rather
than raising.
"""
parts: list[int] = []
for segment in str(version).split("."):
digits = ""
for ch in segment:
if ch.isdigit():
digits += ch
else:
break
parts.append(int(digits) if digits else 0)
return tuple(parts)
# PreToolUse nudge payloads, emitted verbatim by the shell-agnostic
# `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks
# inlined POSIX bash (case/esac, [ -f ], single-quoted echo) which Windows
# cmd.exe/PowerShell cannot parse, so on Windows the hook failed and the nudge
# silently vanished — users had to invoke /graphify by hand (#522). Moving the
# logic into a Python subcommand invoked via an absolute exe path makes the hook
# parse identically under sh, cmd.exe and PowerShell. Claude Code accepts
# additionalContext on PreToolUse (Codex Desktop does not — that path stays a
# no-op via `hook-check`). Compact separators keep the payload byte-for-byte the
# same JSON the old `echo` emitted.
# Source/doc extensions the Read|Glob guard nudges on (verbatim from the old hook).
# The trailing-extension test (real final path segment, then its last '.') means
# '.json' never false-matches '.js', and framework files like '.astro' are kept.
# The always-on instruction blocks are packaged markdown under graphify/always_on/,
# generated by tools/skillgen and guarded by `skillgen --check`. Reading them at
# load keeps the install-string / issue-#580 contract byte-for-byte while letting
# a human edit one fragment instead of a triple-quoted literal here.
# AGENTS.md section for Codex, OpenCode, and OpenClaw.
# All three platforms read AGENTS.md in the project root for persistent instructions.
# Gemini CLI BeforeTool hook nudge text. The hook always returns
# {"decision":"allow"} (never blocks a tool) and appends this as additionalContext
# when a graph exists. Emitted by `graphify hook-guard gemini`. The old hook was a
# `python -c "..."` one-liner that depended on a bare `python` on PATH (often
# `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that
# Windows PowerShell mangles (#522 follow-up); the subcommand form has no such
# dependency and parses under every shell.
_KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project"
_CODEX_HOOK = {
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
# 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",
}
],
}
]
}
}
def main() -> None:
for _stream in (sys.stdout, sys.stderr):
if _stream is not None and hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# Check all known skill install locations for a stale version stamp.
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
# Skip during hook-check — it runs on every editor tool use and must be silent.
# Deduplicate paths so platforms sharing the same install dir don't warn twice.
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"}
if not any(arg in _silent_cmds for arg in sys.argv):
# Resolve each platform's real user-scope destination so per-platform
# overrides (gemini, opencode, devin, antigravity, amp) check the dir
# they actually install into, not the bare cfg['skill_dst'].
for skill_dst in {_platform_skill_destination(name) for name in _PLATFORM_CONFIG}:
_check_skill_version(skill_dst)
if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"):
print(f"graphify {__version__}")
return
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"):
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
print(" uninstall remove graphify from all detected platforms in one shot")
print(" --purge also delete graphify-out/ directory")
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")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
print(" --graph <path> path to graph/extraction JSON")
print(" (default graphify-out/graph.json)")
print(" --json emit machine-readable JSON")
print(" --max-examples N max same-endpoint examples to print (default 5)")
print(" --directed force directed post-build simulation")
print(" --undirected force undirected post-build simulation")
print(" (default follows JSON directed flag;")
print(" raw extraction with no flag defaults directed)")
print(" --extract-path PATH extractor source for suppression scan")
print(" clone <github-url> clone a GitHub repo locally and print its path for /graphify")
print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
print(" --out <path> output path (default: graphify-out/merged-graph.json)")
print(" --branch <branch> checkout a specific branch (default: repo default)")
print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
print(" add <url> fetch a URL and save it to ./raw, then update the graph")
print(" --author \"Name\" tag the author of the content")
print(" --contributor \"Name\" tag who added it to the corpus")
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(" --no-cluster skip clustering, write raw extraction only")
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(" --graph <path> path to graph.json (default <path>/graphify-out/graph.json)")
print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
print(" --backend=<name> backend to use for community naming (default: auto-detect)")
print(" --model=<name> model to use for community naming")
print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
print(" --batch-size=N communities per labeling LLM call (default 100)")
print(" label <path> (re)name communities with the configured LLM backend, regenerate report")
print(" --missing-only keep existing labels and only name missing/placeholder communities")
print(" --backend=<name> backend to use (default: auto-detect from API keys)")
print(" --model=<name> model to use for community naming")
print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
print(" --batch-size=N communities per labeling LLM call (default 100)")
print(" query \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
print(" --context C explicit edge-context filter (repeatable)")
print(" --budget N cap output at N tokens (default 2000)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" affected \"X\" reverse traversal to find nodes impacted by X")
print(" --relation R edge relation to traverse in reverse (repeatable)")
print(" --depth N reverse traversal depth (default 2)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
print(" --question Q the question asked")
print(" --answer A the answer to save")
print(
" --type T query type: query|path_query|explain (default: query)"
)
print(" --nodes N1 N2 ... source node labels cited in the answer")
print(" --outcome O work-memory signal: useful|dead_end|corrected")
print(" --correction TEXT what the right answer was (pairs with --outcome corrected)")
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
print(" reflect aggregate graphify-out/memory/ outcomes into a deterministic lessons doc")
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
print(" --out FILE output path (default: graphify-out/reflections/LESSONS.md)")
print(" --graph PATH graph.json, for community grouping + dropping stale nodes (optional)")
print(" --analysis PATH .graphify_analysis.json (optional, auto-detected next to --graph)")
print(" --labels PATH .graphify_labels.json (optional, auto-detected next to --graph)")
print(" --half-life-days N signal weight halves every N days (default 30)")
print(" --min-corroboration N distinct useful results to prefer a node (default 2)")
print(" check-update <path> check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
print(" --root PATH filesystem root for the hierarchy")
print(" --max-children N cap children per node (default 200)")
print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
print(" --label NAME project label in header")
print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)")
print(" openai also reaches self-hosted OpenAI-compatible servers (llama.cpp,")
print(" vLLM, LM Studio): set OPENAI_BASE_URL (e.g. http://localhost:8080/v1)")
print(" and OPENAI_MODEL to the model name your server serves")
print(" claude also reaches custom Anthropic-compatible endpoints (LiteLLM")
print(" proxy, gateways): set ANTHROPIC_BASE_URL and ANTHROPIC_MODEL")
print(" --model M override backend default model")
print(" --mode deep aggressive INFERRED-edge semantic extraction")
print(" --max-workers N AST extraction subprocess count (default: cpu_count)")
print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)")
print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)")
print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
print(" --no-cluster skip clustering, write raw extraction only")
print(" --code-only index code (local AST, no API key) and skip doc/paper/image files")
print(" --postgres DSN extract schema from a live PostgreSQL database")
print(" maps tables, views, functions + FK relationships;")
print(" column-level detail is not represented in the graph")
print(" --cargo extract crate→crate deps from Cargo.toml")
print(" --global also merge the resulting graph into the global graph")
print(" --as <tag> repo tag for --global (default: target directory name)")
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
print(" --as <tag> repo tag (default: parent directory name)")
print(" global remove <tag> remove a repo's nodes from the global graph")
print(" global list list repos in the global graph")
print(" global path print path to the global graph file")
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" hook uninstall remove git hooks")
print(" hook status check if git hooks are installed")
print(
" gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)"
)
print(" gemini uninstall remove GEMINI.md section + BeforeTool hook")
print(" cursor install write .cursor/rules/graphify.mdc (Cursor)")
print(" cursor uninstall remove .cursor/rules/graphify.mdc")
print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)")
print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook")
print(" codex install write graphify section to AGENTS.md (Codex)")
print(" codex uninstall remove graphify section from AGENTS.md")
print(
" opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)"
)
print(
" opencode uninstall remove graphify section from AGENTS.md + plugin"
)
print(
" kilo install install native Kilo skill + command + AGENTS.md + .kilo plugin"
)
print(
" kilo uninstall remove native Kilo skill + command + AGENTS.md + .kilo plugin"
)
print(" aider install write graphify section to AGENTS.md (Aider)")
print(" aider uninstall remove graphify section from AGENTS.md")
print(
" copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)"
)
print(" copilot uninstall remove graphify skill from ~/.copilot/skills")
print(
" vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)"
)
print(" vscode uninstall remove VS Code Copilot Chat configuration")
print(
" claw install write graphify section to AGENTS.md (OpenClaw)"
)
print(" claw uninstall remove graphify section from AGENTS.md")
print(
" droid install write graphify section to AGENTS.md (Factory Droid)"
)
print(" droid uninstall remove graphify section from AGENTS.md")
print(" trae install write graphify section to AGENTS.md (Trae)")
print(" trae uninstall remove graphify section from AGENTS.md")
print(" trae-cn install write graphify section to AGENTS.md (Trae CN)")
print(" trae-cn uninstall remove graphify section from AGENTS.md")
print(
" antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)"
)
print(
" antigravity uninstall remove .agents/rules, .agents/workflows, and skill"
)
print(
" hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)"
)
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(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)")
print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/")
print()
return
cmd = sys.argv[1]
# Universal help guard: -h/--help/-? anywhere after the command shows help
# and stops — prevents flags from silently triggering destructive subcommands
# (e.g. "cursor install --help" was silently installing into Cursor, #821).
# Exempt: free-text commands (user string may contain these tokens), and
# "install"/"uninstall" which have their own per-subcommand help handlers.
_FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
print(f"Run 'graphify --help' for full usage.")
return
if dispatch_install_cli(cmd):
return
dispatch_command(cmd)
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
"""MinHash + band-LSH — datasketch-compatible drop-in (no scipy).
datasketch.lsh has `from scipy.integrate import quad` at module level.
scipy's array_api_compat layer then lazily loads numpy.testing, which calls
platform.machine() at import time to set test-skip decorator constants — and
that in turn spawns cmd.exe via subprocess, hanging for minutes under EDR
software in corporate Windows environments.
Covers the exact MinHash/MinHashLSH API surface used by dedup.py.
Hash family (Mersenne-prime permutations) and LSH band structure are
equivalent to datasketch so dedup quality is unchanged.
"""
from __future__ import annotations
import hashlib
import struct
import numpy as np
_MP = np.uint64((1 << 61) - 1) # Mersenne prime for the hash family
_MH = np.uint64(0xFFFF_FFFF) # mask to 32-bit values
# One (a, b) coefficient array per num_perm, shared across all instances.
_MH_COEFFS: dict[int, tuple[np.ndarray, np.ndarray]] = {}
def _mh_coeffs(num_perm: int) -> tuple[np.ndarray, np.ndarray]:
if num_perm not in _MH_COEFFS:
rng = np.random.RandomState(1)
a = rng.randint(1, int(_MP), num_perm, dtype=np.uint64)
b = rng.randint(0, int(_MP), num_perm, dtype=np.uint64)
_MH_COEFFS[num_perm] = (a, b)
return _MH_COEFFS[num_perm]
class MinHash:
"""MinHash sketch — same API as datasketch.MinHash for the subset used here."""
__slots__ = ("num_perm", "hashvalues", "_a", "_b")
def __init__(self, num_perm: int = 128) -> None:
self.num_perm = num_perm
self.hashvalues = np.full(num_perm, int(_MH), dtype=np.uint64)
self._a, self._b = _mh_coeffs(num_perm)
def update(self, v: bytes) -> None:
hv = np.uint64(struct.unpack("<I", hashlib.sha1(v).digest()[:4])[0])
phv = np.bitwise_and((self._a * hv + self._b) % _MP, _MH)
self.hashvalues = np.minimum(self.hashvalues, phv)
def _lsh_integrate(f, lo: float, hi: float, n: int = 128) -> float:
"""Numerical integration — replaces scipy.integrate.quad for LSH param search."""
h = (hi - lo) / n
return h * sum(f(lo + i * h) for i in range(n))
_LSH_PARAMS_CACHE: dict[tuple[float, int], tuple[int, int]] = {}
def _optimal_lsh_params(threshold: float, num_perm: int) -> tuple[int, int]:
"""Find (bands, rows) that minimise weighted FP+FN error, without scipy."""
key = (threshold, num_perm)
if key in _LSH_PARAMS_CACHE:
return _LSH_PARAMS_CACHE[key]
best_err, best = float("inf"), (1, 1)
for b in range(1, num_perm + 1):
for r in range(1, num_perm // b + 1):
fp = _lsh_integrate(
lambda s, _b=float(b), _r=float(r): 1 - (1 - s ** _r) ** _b,
0.0, threshold,
)
fn = _lsh_integrate(
lambda s, _b=float(b), _r=float(r): 1 - (1 - (1 - s ** _r) ** _b),
threshold, 1.0,
)
err = 0.5 * fp + 0.5 * fn
if err < best_err:
best_err, best = err, (b, r)
_LSH_PARAMS_CACHE[key] = best
return best
class MinHashLSH:
"""Band-hashing LSH — same API as datasketch.MinHashLSH for the subset used here."""
def __init__(self, threshold: float = 0.5, num_perm: int = 128) -> None:
self.b, self.r = _optimal_lsh_params(threshold, num_perm)
self._tables: list[dict[bytes, list[str]]] = [{} for _ in range(self.b)]
self._keys: set[str] = set()
def insert(self, key: str, minhash: MinHash) -> None:
if key in self._keys:
raise ValueError(f"Key {key!r} already exists in MinHashLSH")
self._keys.add(key)
hv = minhash.hashvalues
for i, table in enumerate(self._tables):
band = hv[i * self.r : (i + 1) * self.r].tobytes()
table.setdefault(band, []).append(key)
def query(self, minhash: MinHash) -> list[str]:
hv = minhash.hashvalues
candidates: set[str] = set()
for i, table in enumerate(self._tables):
band = hv[i * self.r : (i + 1) * self.r].tobytes()
candidates.update(table.get(band, []))
return list(candidates)
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import unicodedata
import networkx as nx
DEFAULT_AFFECTED_RELATIONS = (
"calls",
"indirect_call",
"references",
"imports",
"imports_from",
"re_exports",
"inherits",
"extends",
"implements",
"uses",
"mixes_in",
"embeds",
)
@dataclass(frozen=True)
class AffectedHit:
node_id: str
depth: int
via_relation: str
def _node_label(graph: nx.Graph, node_id: str) -> str:
data = graph.nodes[node_id]
return str(data.get("label") or node_id)
def _format_location(data: dict) -> str:
source_file = data.get("source_file") or "-"
source_location = data.get("source_location")
if source_location:
return f"{source_file}:{source_location}"
return str(source_file)
def _bare_name(label: str) -> str:
"""Lowercased label with the callable decoration (trailing "()") removed."""
label = _normalize_label(label)
return label[:-2] if label.endswith("()") else label
def _normalize_label(label: str) -> str:
return unicodedata.normalize("NFC", label).casefold()
def _prefer_file_node(
graph: nx.Graph,
node_ids: list[str],
query: str,
) -> str | None:
"""Return the file-level node when a source_file query matches many nodes."""
query_basename = _normalize_label(Path(query).name)
exact_file_nodes = [
node_id
for node_id in node_ids
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
and _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
]
if len(exact_file_nodes) == 1:
return exact_file_nodes[0]
l1_nodes = [
node_id
for node_id in node_ids
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
]
if len(l1_nodes) == 1:
return l1_nodes[0]
basename_nodes = [
node_id
for node_id in node_ids
if _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
]
if len(basename_nodes) == 1:
return basename_nodes[0]
return None
def resolve_seed(graph: nx.Graph, query: str) -> str | None:
# A trailing path separator must not change a source-file match — serve's
# _find_node tokenizes the path (which drops it), so strip it here for parity
# (otherwise `affected "src/x.ts/"` returned None while `explain` resolved it).
query = query.rstrip("/\\") or query
if query in graph:
return query
query_lower = _normalize_label(query)
exact_label_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("label", ""))) == query_lower
]
if len(exact_label_matches) == 1:
return exact_label_matches[0]
# Callable labels are decorated ("name()"), so a bare "name" query falls
# through exact matching and then ties with any "name*" sibling in the
# contains pass. Match on the undecorated name before giving up.
query_bare = _bare_name(query_lower)
bare_name_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _bare_name(str(data.get("label", ""))) == query_bare
]
if len(bare_name_matches) == 1:
return bare_name_matches[0]
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("source_file", ""))) == query_lower
]
if len(exact_source_matches) == 1:
return exact_source_matches[0]
if exact_source_matches:
preferred_file_node = _prefer_file_node(graph, exact_source_matches, query)
if preferred_file_node is not None:
return preferred_file_node
contains_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if query_lower in _normalize_label(str(data.get("label", "")))
]
if len(contains_matches) == 1:
return contains_matches[0]
return None
def affected_nodes(
graph: nx.Graph,
seed: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> list[AffectedHit]:
relation_set = set(relations)
seen = {seed}
queue: deque[tuple[str, int]] = deque([(seed, 0)])
hits: list[AffectedHit] = []
# #1669: seed the reverse walk with the root's own member nodes (one outward
# `method`/`contains` hop). A caller can bind to a class's method node rather
# than the class node itself (e.g. `Service.call` resolves to the `def
# self.call` node, #1634), so those callers are unreachable from the class
# otherwise. The member nodes are seeds only (not reported as hits), and
# `method`/`contains` stay out of the general relation-filtered walk, so this
# adds no forward noise anywhere else.
if hasattr(graph, "out_edges"):
member_edges = graph.out_edges(seed, data=True)
else:
member_edges = (
(s, t, d) for s, t, d in graph.edges(data=True) if s == seed
)
for _s, member, data in member_edges:
if str(data.get("relation", "")) not in ("method", "contains"):
continue
member = str(member)
if member not in seen:
seen.add(member)
queue.append((member, 0))
while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
continue
if hasattr(graph, "in_edges"):
incoming = graph.in_edges(current, data=True)
else:
incoming = (
(source, target, data)
for source, target, data in graph.edges(data=True)
if target == current
)
for source, _target, data in incoming:
relation = str(data.get("relation", ""))
if relation not in relation_set:
continue
source = str(source)
if source in seen:
continue
seen.add(source)
hit = AffectedHit(source, current_depth + 1, relation)
hits.append(hit)
queue.append((source, current_depth + 1))
return hits
def format_affected(
graph: nx.Graph,
query: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> str:
relation_list = tuple(relations)
seed = resolve_seed(graph, query)
if seed is None:
return f"No unique node match for {query}"
hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)
lines = [
f"Affected nodes for {_node_label(graph, seed)}",
f"Relations: {', '.join(relation_list)}",
f"Depth: {depth}",
]
if not hits:
lines.append("No affected nodes found.")
return "\n".join(lines)
for hit in hits:
data = graph.nodes[hit.node_id]
lines.append(
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {_format_location(data)}"
)
return "\n".join(lines)
def load_graph(path: Path) -> nx.Graph:
import json
from networkx.readwrite import json_graph
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Cannot read graph file {path}: {exc}. "
"Re-run 'graphify extract' to regenerate it."
) from exc
# Force directed so stored caller→callee direction survives the round-trip;
# mirrors serve.py and __main__.py (#1174).
raw = {**raw, "directed": True}
# Normalize the edge key: graphify's `extract` output uses "edges" while
# networkx's node_link_data default is "links". Without this, an edges-keyed
# graph.json raises an uncaught KeyError: 'links' here — every other loader
# (__main__.py) already normalizes this (#738; same class as #1198).
if "links" not in raw and "edges" in raw:
raw = dict(raw, links=raw["edges"])
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
return json_graph.node_link_graph(raw)
+12
View File
@@ -0,0 +1,12 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+14
View File
@@ -0,0 +1,14 @@
---
trigger: always_on
description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions.
---
## graphify
This project has a graphify knowledge graph at graphify-out/.
Rules:
- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
+9
View File
@@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+9
View File
@@ -0,0 +1,9 @@
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
+5
View File
@@ -0,0 +1,5 @@
---
inclusion: always
---
graphify: A knowledge graph of this project lives in `graphify-out/`. For codebase, architecture, or dependency questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. Read `GRAPH_REPORT.md` only for broad architecture review or when those commands do not surface enough context.
+17
View File
@@ -0,0 +1,17 @@
## graphify
For any question about this repo's architecture, structure, components, or how to add/modify/find
code, your first action should be `graphify query "<question>"` when `graphify-out/graph.json`
exists. Use `graphify path "<A>" "<B>"` for relationship questions and `graphify explain "<concept>"`
for focused-concept questions. These return a scoped subgraph, usually much smaller than the full
report or raw grep output.
Triggers: "how do I…", "where is…", "what does … do", "add/modify a <component>",
"explain the architecture", or anything that depends on how files or classes relate.
If `graphify-out/wiki/index.md` exists, use it for broad navigation. Read `graphify-out/GRAPH_REPORT.md`
only for broad architecture review or when query/path/explain do not surface enough context. Only read
source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or
(c) the graph is missing or stale.
Type `/graphify` in Copilot Chat to build or update the graph.
+740
View File
@@ -0,0 +1,740 @@
"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions."""
from __future__ import annotations
from pathlib import Path
import networkx as nx
from graphify.build import edge_data
# Builtin/mock names that can appear as annotation-derived nodes in pre-existing
# graphs. Excluded from god-node ranking so they don't displace real abstractions
# even if they weren't filtered at extraction time (#1147).
_BUILTIN_NOISE_LABELS = frozenset({
"str", "int", "float", "bool", "bytes", "bytearray", "complex", "object",
"True", "False",
"MagicMock", "Mock", "AsyncMock", "NonCallableMock",
"NonCallableMagicMock", "PropertyMock", "patch", "sentinel",
# Python stdlib types commonly confused for project symbols
"Path", "Any", "Optional", "List", "Dict", "Set", "Tuple", "Union",
"Callable", "Type", "ClassVar", "Final", "Literal", "Protocol",
"Counter", "defaultdict", "OrderedDict", "datetime", "Enum",
"os", "sys", "re", "json", "io", "abc", "typing",
})
# Language families — extensions sharing a runtime can legitimately call each other
_LANG_FAMILY: dict[str, str] = {
**{e: "python" for e in (".py", ".pyw")},
**{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte")},
**{e: "go" for e in (".go",)},
**{e: "rust" for e in (".rs",)},
**{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")},
**{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")},
**{e: "ruby" for e in (".rb", ".rake")},
**{e: "swift" for e in (".swift",)},
**{e: "dotnet" for e in (".cs",)},
**{e: "php" for e in (".php",)},
**{e: "r" for e in (".r",)},
}
def _cross_language(src_a: str, src_b: str) -> bool:
"""Return True if two source files belong to different language families."""
ext_a = Path(src_a).suffix.lower()
ext_b = Path(src_b).suffix.lower()
fam_a = _LANG_FAMILY.get(ext_a)
fam_b = _LANG_FAMILY.get(ext_b)
if fam_a is None or fam_b is None:
return False
return fam_a != fam_b
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
"""Invert communities dict: node_id -> community_id."""
return {n: cid for cid, nodes in communities.items() for n in nodes}
def _is_file_node(G: nx.Graph, node_id: str) -> bool:
"""
Return True if this node is a file-level hub node (e.g. 'client', 'models')
or an AST method stub (e.g. '.auth_flow()', '.__init__()').
These are synthetic nodes created by the AST extractor and should be excluded
from god nodes, surprising connections, and knowledge gap reporting.
"""
attrs = G.nodes[node_id]
label = attrs.get("label", "")
if not label:
return False
# File-level hub: label matches the actual source filename (not just any label ending in .py)
source_file = attrs.get("source_file", "")
if source_file:
from pathlib import Path as _Path
if label == _Path(source_file).name:
return True
# Method stub: AST extractor labels methods as '.method_name()'
if label.startswith(".") and label.endswith("()"):
return True
# Module-level function stub: labeled 'function_name()' - only has a contains edge
# These are real functions but structurally isolated by definition; not a gap worth flagging
if label.endswith("()") and G.degree(node_id) <= 1:
return True
return False
_JSON_NOISE_LABELS: frozenset[str] = frozenset({
"start", "end", "name", "id", "type", "properties",
"value", "key", "data", "items", "title", "description", "version",
"dependencies", "devdependencies", "peerdependencies",
"optionaldependencies", "bundleddependencies", "bundledependencies",
})
def _is_json_key_node(G: nx.Graph, node_id: str) -> bool:
attrs = G.nodes[node_id]
src = (attrs.get("source_file") or "").lower()
if not src.endswith(".json"):
return False
label = (attrs.get("label") or "").strip().lower()
return label in _JSON_NOISE_LABELS
def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
"""Return the top_n most-connected real entities - the core abstractions.
File-level hub nodes are excluded: they accumulate import/contains edges
mechanically and don't represent meaningful architectural abstractions.
"""
degree = dict(G.degree())
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
result = []
for node_id, deg in sorted_nodes:
if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id):
continue
if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS:
continue
result.append({
"id": node_id,
"label": G.nodes[node_id].get("label", node_id),
"degree": deg,
})
if len(result) >= top_n:
break
return result
def surprising_connections(
G: nx.Graph,
communities: dict[int, list[str]] | None = None,
top_n: int = 5,
) -> list[dict]:
"""
Find connections that are genuinely surprising - not obvious from file structure.
Strategy:
- Multi-file corpora: cross-file edges between real entities (not concept nodes).
Sorted AMBIGUOUS → INFERRED → EXTRACTED.
- Single-file / single-source corpora: cross-community edges that bridge
distant parts of the graph (betweenness centrality on edges).
These reveal non-obvious structural couplings.
Concept nodes (empty source_file, or injected semantic annotations) are excluded
from surprising connections because they are intentional, not discovered.
"""
# Identify unique source files (ignore empty/null source_file)
source_files = {
data.get("source_file", "")
for _, data in G.nodes(data=True)
if data.get("source_file", "")
}
is_multi_source = len(source_files) > 1
if is_multi_source:
return _cross_file_surprises(G, communities or {}, top_n)
else:
return _cross_community_surprises(G, communities or {}, top_n)
def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
"""
Return True if this node is a manually-injected semantic concept node
rather than a real entity found in source code.
Signals:
- Empty source_file
- source_file doesn't look like a real file path (no extension)
"""
data = G.nodes[node_id]
source = data.get("source_file", "")
if not source:
return True
# Has no file extension → probably a concept label, not a real file
if "." not in source.split("/")[-1]:
return True
return False
from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
def _file_category(path: str) -> str:
ext = ("." + path.rsplit(".", 1)[-1].lower()) if "." in path else ""
if ext in CODE_EXTENSIONS:
return "code"
if ext in PAPER_EXTENSIONS:
return "paper"
if ext in IMAGE_EXTENSIONS:
return "image"
return "doc"
def _top_level_dir(path: str) -> str:
"""Return the first path component - used to detect cross-repo edges."""
return path.split("/")[0] if "/" in path else path
def _surprise_score(
G: nx.Graph,
u: str,
v: str,
data: dict,
node_community: dict[str, int],
u_source: str,
v_source: str,
degrees: dict[str, int] | None = None,
) -> tuple[int, list[str]]:
"""Score how surprising a cross-file edge is. Returns (score, reasons)."""
score = 0
reasons: list[str] = []
# 1. Confidence weight - uncertain connections are more noteworthy
conf = data.get("confidence", "EXTRACTED")
relation = data.get("relation", "")
conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1)
cat_u = _file_category(u_source)
cat_v = _file_category(v_source)
# Suppress all structural bonuses for INFERRED calls/uses that cross language
# boundaries or connect code to a doc file. Both cases are resolver pollution:
# label-matching fires across language families in monorepos, and code→doc
# "calls" edges are extraction artefacts, not real architecture.
# Excludes `semantically_similar_to` (genuine cross-boundary insight) and all
# AMBIGUOUS/EXTRACTED edges (not from the resolver path).
_suppress_structural = (
conf == "INFERRED"
and relation in ("calls", "uses")
and (_cross_language(u_source, v_source) or {cat_u, cat_v} == {"code", "doc"})
)
if _suppress_structural:
conf_bonus = 0
score += conf_bonus
if conf in ("AMBIGUOUS", "INFERRED"):
reasons.append(f"{conf.lower()} connection - not explicitly stated in source")
# 2. Cross file-type bonus - code↔paper or code↔image is non-obvious
if cat_u != cat_v and not _suppress_structural:
score += 2
reasons.append(f"crosses file types ({cat_u}{cat_v})")
# 3. Cross-repo bonus - different top-level directory
if _top_level_dir(u_source) != _top_level_dir(v_source) and not _suppress_structural:
score += 2
reasons.append("connects across different repos/directories")
# 4. Cross-community bonus - Leiden says these are structurally distant
cid_u = node_community.get(u)
cid_v = node_community.get(v)
if cid_u is not None and cid_v is not None and cid_u != cid_v and not _suppress_structural:
score += 1
reasons.append("bridges separate communities")
# 4b. Semantic similarity bonus - non-obvious conceptual links score higher
if data.get("relation") == "semantically_similar_to":
score = int(score * 1.5)
reasons.append("semantically similar concepts with no structural link")
# 5. Peripheral→hub: a low-degree node connecting to a high-degree one
deg_u = degrees[u] if degrees is not None else G.degree(u)
deg_v = degrees[v] if degrees is not None else G.degree(v)
if min(deg_u, deg_v) <= 2 and max(deg_u, deg_v) >= 5:
score += 1
peripheral = G.nodes[u].get("label", u) if deg_u <= 2 else G.nodes[v].get("label", v)
hub = G.nodes[v].get("label", v) if deg_u <= 2 else G.nodes[u].get("label", u)
reasons.append(f"peripheral node `{peripheral}` unexpectedly reaches hub `{hub}`")
return score, reasons
def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n: int) -> list[dict]:
"""
Cross-file edges between real code/doc entities, ranked by a composite
surprise score rather than confidence alone.
Surprise score accounts for:
- Confidence (AMBIGUOUS > INFERRED > EXTRACTED)
- Cross file-type (code↔paper is more surprising than code↔code)
- Cross-repo (different top-level directory)
- Cross-community (Leiden says structurally distant)
- Peripheral→hub (low-degree node reaching a god node)
Each result includes a 'why' field explaining what makes it non-obvious.
"""
node_community = _node_community_map(communities)
degrees = dict(G.degree())
candidates = []
for u, v, data in G.edges(data=True):
relation = data.get("relation", "")
if relation in ("imports", "imports_from", "contains", "method"):
continue
if _is_concept_node(G, u) or _is_concept_node(G, v):
continue
if _is_file_node(G, u) or _is_file_node(G, v):
continue
u_source = G.nodes[u].get("source_file", "")
v_source = G.nodes[v].get("source_file", "")
if not u_source or not v_source or u_source == v_source:
continue
score, reasons = _surprise_score(G, u, v, data, node_community, u_source, v_source, degrees)
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),
"target": G.nodes[tgt_id].get("label", tgt_id),
"source_files": [
G.nodes[src_id].get("source_file", ""),
G.nodes[tgt_id].get("source_file", ""),
],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": relation,
"why": "; ".join(reasons) if reasons else "cross-file semantic connection",
})
candidates.sort(key=lambda x: x["_score"], reverse=True)
for c in candidates:
c.pop("_score")
if candidates:
return candidates[:top_n]
return _cross_community_surprises(G, communities, top_n)
def _cross_community_surprises(
G: nx.Graph,
communities: dict[int, list[str]],
top_n: int,
) -> list[dict]:
"""
For single-source corpora: find edges that bridge different communities.
These are surprising because Leiden grouped everything else tightly -
these edges cut across the natural structure.
Falls back to high-betweenness edges if no community info is provided.
"""
if not communities:
# No community info - use edge betweenness centrality
if G.number_of_edges() == 0:
return []
if G.number_of_nodes() > 5000:
return []
betweenness = nx.edge_betweenness_centrality(G)
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
result = []
for (u, v), score in top_edges:
data = edge_data(G, u, v)
result.append({
"source": G.nodes[u].get("label", u),
"target": G.nodes[v].get("label", v),
"source_files": [
G.nodes[u].get("source_file", ""),
G.nodes[v].get("source_file", ""),
],
"confidence": data.get("confidence", "EXTRACTED"),
"relation": data.get("relation", ""),
"note": f"Bridges graph structure (betweenness={score:.3f})",
})
return result
# Build node → community map
node_community = _node_community_map(communities)
surprises = []
for u, v, data in G.edges(data=True):
cid_u = node_community.get(u)
cid_v = node_community.get(v)
if cid_u is None or cid_v is None or cid_u == cid_v:
continue
# Skip file hub nodes and plain structural edges
if _is_file_node(G, u) or _is_file_node(G, v):
continue
relation = data.get("relation", "")
if relation in ("imports", "imports_from", "contains", "method"):
continue
# 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),
"source_files": [
G.nodes[src_id].get("source_file", ""),
G.nodes[tgt_id].get("source_file", ""),
],
"confidence": confidence,
"relation": relation,
"note": f"Bridges community {cid_u} → community {cid_v}",
"_pair": tuple(sorted([cid_u, cid_v])),
})
# Sort: AMBIGUOUS first, then INFERRED, then EXTRACTED
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
# Deduplicate by community pair - one representative edge per (A→B) boundary.
# Without this, a single high-betweenness god node dominates all results.
seen_pairs: set[tuple] = set()
deduped = []
for s in surprises:
pair = s.pop("_pair")
if pair not in seen_pairs:
seen_pairs.add(pair)
deduped.append(s)
return deduped[:top_n]
def suggest_questions(
G: nx.Graph,
communities: dict[int, list[str]],
community_labels: dict[int, str],
top_n: int = 7,
) -> list[dict]:
"""
Generate questions the graph is uniquely positioned to answer.
Based on: AMBIGUOUS edges, bridge nodes, underexplored god nodes, isolated nodes.
Each question has a 'type', 'question', and 'why' field.
"""
if community_labels:
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
questions = []
node_community = _node_community_map(communities)
# 1. AMBIGUOUS edges → unresolved relationship questions
for u, v, data in G.edges(data=True):
if data.get("confidence") == "AMBIGUOUS":
ul = G.nodes[u].get("label", u)
vl = G.nodes[v].get("label", v)
relation = data.get("relation", "related to")
questions.append({
"type": "ambiguous_edge",
"question": f"What is the exact relationship between `{ul}` and `{vl}`?",
"why": f"Edge tagged AMBIGUOUS (relation: {relation}) - confidence is low.",
})
# 2. Bridge nodes (high betweenness) → cross-cutting concern questions
if G.number_of_edges() > 0:
k = min(100, G.number_of_nodes()) if G.number_of_nodes() > 1000 else None
betweenness = nx.betweenness_centrality(G, k=k, seed=42)
# Top bridge nodes that are NOT file-level hubs
bridges = sorted(
[(n, s) for n, s in betweenness.items()
if not _is_file_node(G, n) and not _is_concept_node(G, n) and s > 0],
key=lambda x: x[1],
reverse=True,
)[:3]
for node_id, score in bridges:
label = G.nodes[node_id].get("label", node_id)
cid = node_community.get(node_id)
comm_label = community_labels.get(cid, f"Community {cid}") if cid is not None else "unknown"
neighbors = list(G.neighbors(node_id))
neighbor_comms = {node_community.get(n) for n in neighbors if node_community.get(n) != cid}
if neighbor_comms:
other_labels = [community_labels.get(c, f"Community {c}") for c in neighbor_comms]
questions.append({
"type": "bridge_node",
"question": f"Why does `{label}` connect `{comm_label}` to {', '.join(f'`{l}`' for l in other_labels)}?",
"why": f"High betweenness centrality ({score:.3f}) - this node is a cross-community bridge.",
})
# 3. God nodes with many INFERRED edges → verification questions
degree = dict(G.degree())
top_nodes = sorted(
[(n, d) for n, d in degree.items() if not _is_file_node(G, n)],
key=lambda x: x[1],
reverse=True,
)[:5]
for node_id, _ in top_nodes:
inferred = [
(u, v, d) for u, v, d in G.edges(node_id, data=True)
if d.get("confidence") == "INFERRED"
]
if len(inferred) >= 2:
label = G.nodes[node_id].get("label", node_id)
# Use _src/_tgt to get the correct direction; fall back to v (the other node)
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({
"type": "verify_inferred",
"question": f"Are the {len(inferred)} inferred relationships involving `{label}` (e.g. with `{others[0]}` and `{others[1]}`) actually correct?",
"why": f"`{label}` has {len(inferred)} INFERRED edges - model-reasoned connections that need verification.",
})
# 4. Isolated or weakly-connected nodes → exploration questions
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1
and not _is_file_node(G, n)
and not _is_concept_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
]
if isolated:
labels = [G.nodes[n].get("label", n) for n in isolated[:3]]
questions.append({
"type": "isolated_nodes",
"question": f"What connects {', '.join(f'`{l}`' for l in labels)} to the rest of the system?",
"why": f"{len(isolated)} weakly-connected nodes found - possible documentation gaps or missing edges.",
})
# 5. Low-cohesion communities → structural questions
from .cluster import cohesion_score
for cid, nodes in communities.items():
score = cohesion_score(G, nodes)
if score < 0.15 and len(nodes) >= 5:
label = community_labels.get(cid, f"Community {cid}")
questions.append({
"type": "low_cohesion",
"question": f"Should `{label}` be split into smaller, more focused modules?",
"why": f"Cohesion score {score} - nodes in this community are weakly interconnected.",
})
if not questions:
return [{
"type": "no_signal",
"question": None,
"why": (
"Not enough signal to generate questions. "
"This usually means the corpus has no AMBIGUOUS edges, no bridge nodes, "
"no INFERRED relationships, and all communities are tightly cohesive. "
"Add more files or run with --mode deep to extract richer edges."
),
}]
return questions[:top_n]
def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict:
"""Compare two graph snapshots and return what changed.
Returns:
{
"new_nodes": [{"id": ..., "label": ...}],
"removed_nodes": [{"id": ..., "label": ...}],
"new_edges": [{"source": ..., "target": ..., "relation": ..., "confidence": ...}],
"removed_edges": [...],
"summary": "3 new nodes, 5 new edges, 1 node removed"
}
"""
old_nodes = set(G_old.nodes())
new_nodes = set(G_new.nodes())
added_node_ids = new_nodes - old_nodes
removed_node_ids = old_nodes - new_nodes
new_nodes_list = [
{"id": n, "label": G_new.nodes[n].get("label", n)}
for n in added_node_ids
]
removed_nodes_list = [
{"id": n, "label": G_old.nodes[n].get("label", n)}
for n in removed_node_ids
]
def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple:
if G.is_directed():
return (u, v, data.get("relation", ""))
return (min(u, v), max(u, v), data.get("relation", ""))
old_edge_keys = {
edge_key(G_old, u, v, d)
for u, v, d in G_old.edges(data=True)
}
new_edge_keys = {
edge_key(G_new, u, v, d)
for u, v, d in G_new.edges(data=True)
}
added_edge_keys = new_edge_keys - old_edge_keys
removed_edge_keys = old_edge_keys - new_edge_keys
new_edges_list = []
for u, v, d in G_new.edges(data=True):
if edge_key(G_new, u, v, d) in added_edge_keys:
new_edges_list.append({
"source": u,
"target": v,
"relation": d.get("relation", ""),
"confidence": d.get("confidence", ""),
})
removed_edges_list = []
for u, v, d in G_old.edges(data=True):
if edge_key(G_old, u, v, d) in removed_edge_keys:
removed_edges_list.append({
"source": u,
"target": v,
"relation": d.get("relation", ""),
"confidence": d.get("confidence", ""),
})
parts = []
if new_nodes_list:
parts.append(f"{len(new_nodes_list)} new node{'s' if len(new_nodes_list) != 1 else ''}")
if new_edges_list:
parts.append(f"{len(new_edges_list)} new edge{'s' if len(new_edges_list) != 1 else ''}")
if removed_nodes_list:
parts.append(f"{len(removed_nodes_list)} node{'s' if len(removed_nodes_list) != 1 else ''} removed")
if removed_edges_list:
parts.append(f"{len(removed_edges_list)} edge{'s' if len(removed_edges_list) != 1 else ''} removed")
summary = ", ".join(parts) if parts else "no changes"
return {
"new_nodes": new_nodes_list,
"removed_nodes": removed_nodes_list,
"new_edges": new_edges_list,
"removed_edges": removed_edges_list,
"summary": summary,
}
def find_import_cycles(
G: nx.Graph,
max_cycle_length: int = 5,
top_n: int = 20,
) -> list[dict]:
"""Detect circular import dependencies at the file level.
Collapses symbol-level nodes to their parent file (using source_file attr
or 'contains' edges), builds a directed file-level graph from imports_from
edges, then finds simple cycles.
Args:
G: The full knowledge graph (may be undirected or directed).
max_cycle_length: Only report cycles with at most this many files.
top_n: Maximum number of cycles to return (shortest first).
Returns:
List of cycle records with stable structure:
{
"cycle": ["a.ts", "b.ts"],
"length": 2,
"why": "circular dependency"
}
"""
def _endpoint_source_file(node_id: str) -> str:
attrs = G.nodes.get(node_id, {})
src_file = attrs.get("source_file", "")
return src_file if isinstance(src_file, str) else ""
# Step 1: Build a directed file-level graph from import/re-export edges.
# IMPORTANT: resolve endpoints using source_file only; never infer from label/id.
file_graph = nx.DiGraph()
for u, v, data in G.edges(data=True):
rel = data.get("relation", "")
if rel not in ("imports_from", "re_exports"):
continue
# Deferred `import(...)` edges are real dependencies but do not form a
# hard file-level cycle, so they are excluded from cycle detection (#1241).
if data.get("deferred"):
continue
src_file_attr = data.get("source_file", "")
if not isinstance(src_file_attr, str) or not src_file_attr:
continue
u_file = _endpoint_source_file(u)
v_file = _endpoint_source_file(v)
# Works for both DiGraph and Graph inputs:
# orient edge from edge.source_file endpoint to the opposite endpoint.
if u_file == src_file_attr:
tgt_file = v_file
elif v_file == src_file_attr:
tgt_file = u_file
else:
# Fallback: if source endpoint cannot be matched exactly,
# still treat edge.source_file as source and pick the opposite endpoint
# only if one endpoint has a real source_file.
tgt_file = v_file if v_file and v_file != src_file_attr else u_file
if not tgt_file:
continue
file_graph.add_edge(src_file_attr, tgt_file)
if not file_graph.edges():
return []
# Step 2: Find simple cycles, bounded by length.
# Pass length_bound so networkx prunes during enumeration rather than
# enumerating all elementary cycles and post-filtering — avoids exponential
# blowup on dense graphs with many long cycles (#1196).
cycles: list[list[str]] = []
for cycle in nx.simple_cycles(file_graph, length_bound=max_cycle_length):
if len(cycle) <= max_cycle_length:
cycles.append(cycle)
if len(cycles) >= top_n * 10:
# Stop early to avoid combinatorial explosion
break
# Step 3: Sort by length (shortest = tightest coupling), then deduplicate.
cycles.sort(key=len)
# Deduplicate rotations: normalize each cycle by starting from the
# lexicographically smallest element.
seen: set[tuple[str, ...]] = set()
unique_cycles: list[list[str]] = []
for cycle in cycles:
core = list(cycle)
if not core:
continue
min_idx = core.index(min(core))
normalized = tuple(core[min_idx:] + core[:min_idx])
if normalized not in seen:
seen.add(normalized)
unique_cycles.append(list(normalized))
if len(unique_cycles) >= top_n:
break
result: list[dict] = []
for cycle in unique_cycles:
result.append({
"cycle": cycle,
"length": len(cycle),
"why": "circular dependency",
})
return result
+157
View File
@@ -0,0 +1,157 @@
"""Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
from graphify.build import edge_data
from graphify.serve import _query_terms
from graphify.paths import default_graph_json as _default_graph_json
_CHARS_PER_TOKEN = 4 # standard approximation
def _safe(unicode_char: str, ascii_fallback: str) -> str:
"""Return unicode_char if stdout can encode it, else ascii_fallback.
Windows consoles often default to cp1252 which cannot encode box-drawing
or arrow glyphs; printing them raises UnicodeEncodeError mid-output.
"""
encoding = getattr(sys.stdout, "encoding", None) or ""
try:
unicode_char.encode(encoding)
return unicode_char
except (UnicodeEncodeError, LookupError):
return ascii_fallback
def _hr(width: int = 50) -> str:
"""Horizontal rule that survives non-UTF-8 stdout (e.g. Windows cp1252 console)."""
return _safe("", "-") * width
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 = _query_terms(question)
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 = edge_data(G, 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 | None = None,
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
"""
graph_path = graph_path or _default_graph_json()
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(Path(graph_path))
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
try:
G = json_graph.node_link_graph(data, edges="links")
except TypeError:
G = json_graph.node_link_graph(data)
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(_hr(50))
arrow = _safe("", "->")
print(f" Corpus: {result['corpus_words']:,} words {arrow} ~{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()
+1097
View File
File diff suppressed because it is too large Load Diff
+590
View File
@@ -0,0 +1,590 @@
# per-file extraction cache - skip unchanged files on re-run
from __future__ import annotations
import atexit
import hashlib
import json
import os
import re
import tempfile
from pathlib import Path
# Output directory name — override with GRAPHIFY_OUT env var for worktrees or
# shared-output setups. Accepts a relative name ("graphify-out-feature") or an
# absolute path ("/shared/graphify-out"). Single source of truth in graphify.paths
# (#1423); re-exported here as _GRAPHIFY_OUT for the existing call sites.
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
# AST cache entries are the output of graphify's own extractor code, so they
# are only valid for the version that wrote them: keying purely on file
# content means extractor fixes shipped in a new release keep serving stale
# pre-fix results. The AST cache is therefore namespaced by package version
# (cache/ast/v{version}/), with entries from other versions removed on first
# use. The semantic cache is deliberately NOT versioned — its entries are
# produced by the LLM from file contents, and invalidating them on every
# release would re-bill extraction for unchanged files.
try:
from importlib.metadata import version as _pkg_version
_EXTRACTOR_VERSION = _pkg_version("graphifyy")
except Exception:
_EXTRACTOR_VERSION = "unknown"
# Version dirs already swept this process — cleanup runs once per (base, version).
_cleaned_ast_dirs: set[str] = set()
def _cleanup_stale_ast_entries(ast_base: Path, current_dir: Path) -> None:
"""Remove AST cache entries left behind by other graphify versions.
Sweeps sibling ``v*/`` directories and unversioned ``*.json`` entries
(the pre-versioning layout) under ``cache/ast/``. Best-effort: failures
are ignored, stragglers are retried on the next run.
"""
key = str(current_dir)
if key in _cleaned_ast_dirs:
return
_cleaned_ast_dirs.add(key)
if not ast_base.is_dir():
return
import shutil
for child in ast_base.iterdir():
if child == current_dir:
continue
try:
if child.is_dir() and child.name.startswith("v"):
shutil.rmtree(child, ignore_errors=True)
elif child.suffix == ".json":
child.unlink()
except OSError:
pass
# A frontmatter delimiter is a whole line of exactly three dashes (optional
# trailing whitespace). Substring checks like startswith("---") /
# find("\n---") also match `----` thematic breaks and `--- text` prose,
# silently dropping everything above them from the hash (#1259).
_FRONTMATTER_DELIM = re.compile(r"^---[ \t]*\r?$", re.MULTILINE)
def _body_content(content: bytes) -> bytes:
"""Strip YAML frontmatter from Markdown content, returning only the body."""
text = content.decode(errors="replace")
opener = _FRONTMATTER_DELIM.match(text)
if opener is None:
return content
closer = _FRONTMATTER_DELIM.search(text, opener.end())
if closer is None:
return content
# Slice right after the closing `---` (not after its line) so the output
# stays byte-identical with the historical implementation for well-formed
# frontmatter -- existing semantic-cache hashes must not churn.
return text[closer.start() + 3:].encode()
# Stat-based index: maps absolute path → {size, mtime_ns, hash}.
# Loaded once per process, flushed via atexit. Skips full file reads when
# size+mtime_ns are unchanged — same trade-off as make(1).
# Correctness risks: `touch` causes a harmless extra re-hash; same-size edits
# within NFS second-resolution mtime have a 1-second window (same as make).
# Use `graphify extract --force` to bypass when needed.
_stat_index: dict[str, dict] = {}
_stat_index_root: Path | None = None
_stat_index_dirty: bool = False
def _stat_index_file(root: Path) -> Path:
_out = Path(_GRAPHIFY_OUT)
base = _out if _out.is_absolute() else Path(root).resolve() / _out
return base / "cache" / "stat-index.json"
def _ensure_stat_index(root: Path, cache_root: "Path | None" = None) -> None:
global _stat_index, _stat_index_root, _stat_index_dirty
if _stat_index_root is not None:
return
# The stat index only determines the cache FILE location (entry keys are
# absolute paths), so honoring an explicit cache_root keeps detect()'s
# word-count cache under the requested --out dir instead of polluting the
# scanned corpus with a stray graphify-out/ (#1747).
_stat_index_root = Path(cache_root if cache_root is not None else root).resolve()
p = _stat_index_file(_stat_index_root)
if p.exists():
try:
_stat_index = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
_stat_index = {}
else:
_stat_index = {}
atexit.register(_flush_stat_index)
def _flush_stat_index() -> None:
global _stat_index_dirty, _stat_index_root
if not _stat_index_dirty or _stat_index_root is None:
return
p = _stat_index_file(_stat_index_root)
try:
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=p.parent, prefix="stat-index.", suffix=".tmp")
try:
os.write(fd, json.dumps(_stat_index, separators=(",", ":")).encode())
os.close(fd)
os.replace(tmp, p)
except Exception:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
except OSError:
pass
_stat_index_dirty = False
def _normalize_path(path: Path) -> Path:
"""Normalize path for consistent cache keys across Windows path spellings."""
import sys
if sys.platform != "win32":
return path
s = str(path)
if s.startswith("\\\\?\\"):
s = s[4:] # strip extended-length prefix \\?\
return Path(os.path.normcase(s))
def file_hash(path: Path, root: Path = Path(".")) -> str:
"""SHA256 of file contents + path relative to root.
Uses a stat-based fastpath (size + mtime_ns) to skip full reads when the
file hasn't changed. Falls through to full SHA256 on first encounter or
when stat changes. Index is flushed atomically at process exit.
Using a relative path (not absolute) makes cache entries portable across
machines and checkout directories, so shared caches and CI work correctly.
Falls back to the resolved absolute path if the file is outside root.
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
"""
global _stat_index_dirty
p = _normalize_path(Path(path))
root = _normalize_path(Path(root))
if not p.is_file():
raise IsADirectoryError(f"file_hash requires a file, got: {p}")
_ensure_stat_index(root)
abs_key = str(p.resolve())
st: "os.stat_result | None" = None
try:
st = p.stat()
entry = _stat_index.get(abs_key)
if (entry
and entry.get("hash") is not None # word-count-only entries carry no hash
and entry.get("size") == st.st_size
and entry.get("mtime_ns") == st.st_mtime_ns):
return entry["hash"]
except OSError:
pass
raw = p.read_bytes()
content = _body_content(raw) if p.suffix.lower() == ".md" else raw
h = hashlib.sha256()
h.update(content)
h.update(b"\x00")
try:
rel = p.resolve().relative_to(Path(root).resolve())
h.update(rel.as_posix().lower().encode())
except ValueError:
h.update(p.resolve().as_posix().lower().encode())
digest = h.hexdigest()
if st is not None:
entry = _stat_index.get(abs_key)
if (entry is not None
and entry.get("size") == st.st_size
and entry.get("mtime_ns") == st.st_mtime_ns):
entry["hash"] = digest # preserve a co-located word_count
else:
_stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest}
_stat_index_dirty = True
return digest
def cached_word_count(path: Path, root: Path, compute, cache_root: "Path | None" = None) -> int:
"""Word count with the same (size, mtime_ns) stat-fastpath cache as
:func:`file_hash`, persisted in the shared stat index.
``detect()`` counts words in every PDF/docx/text file to size the corpus,
which re-opens and re-parses every binary on each run — minutes on a large
docs corpus even when only a handful of files changed (#1656). This caches
the count against the file's stat signature so an unchanged file is counted
once and read from the index thereafter. ``compute(path)`` produces the
count on a miss. A file that can't be stat'd (e.g. a Windows long path the
index normalization can't reach) simply recomputes and isn't cached —
correct, just not accelerated.
"""
global _stat_index_dirty
p = _normalize_path(Path(path))
root = _normalize_path(Path(root))
_ensure_stat_index(root, cache_root=cache_root)
abs_key = str(p.resolve())
st: "os.stat_result | None" = None
try:
st = p.stat()
entry = _stat_index.get(abs_key)
if (entry
and entry.get("size") == st.st_size
and entry.get("mtime_ns") == st.st_mtime_ns
and "word_count" in entry):
return entry["word_count"]
except OSError:
pass
wc = compute(Path(path))
if st is not None:
entry = _stat_index.get(abs_key)
if (entry
and entry.get("size") == st.st_size
and entry.get("mtime_ns") == st.st_mtime_ns):
entry["word_count"] = wc # augment the existing hash entry in place
else:
_stat_index[abs_key] = {
"size": st.st_size, "mtime_ns": st.st_mtime_ns, "word_count": wc,
}
_stat_index_dirty = True
return wc
def _relativize_source_files_in(payload: dict, root: Path) -> None:
"""Mutate ``payload`` to rewrite absolute ``source_file`` fields as
forward-slash relative paths from ``root``.
Mirror of :func:`graphify.watch._relativize_source_files` so cached
extraction fragments persist in portable form (#777). Already-relative
fields and out-of-root paths pass through unchanged.
Only ``root`` is resolved — ``source_file`` itself is relativized
symbolically so in-root symlinks keep their original name rather than
pointing at the resolved target. Same reasoning as
:func:`graphify.detect._to_relative_for_storage`.
"""
try:
root_resolved = Path(root).resolve()
except OSError:
return
# raw_calls (#: Pascal/Delphi cross-file inherited-call resolution) carries
# source_file the same way nodes/edges/hyperedges do, so it needs the same
# portable-path treatment for cache entries to round-trip correctly across
# machines/checkout directories.
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
for item in payload.get(bucket, []):
if not isinstance(item, dict):
continue
source = item.get("source_file")
if not source:
continue
sp = Path(source)
if not sp.is_absolute():
continue
try:
rel = os.path.relpath(sp, root_resolved)
except (ValueError, OSError):
continue # out-of-root (e.g. Windows cross-drive)
if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"):
continue # escaped root — keep absolute
item["source_file"] = rel.replace(os.sep, "/")
def _absolutize_source_files_in(payload: dict, root: Path) -> None:
"""Inverse of :func:`_relativize_source_files_in`.
Re-anchor relative ``source_file`` fields against ``root`` so callers
that load a cached fragment see the same absolute-path shape that a
fresh in-process extraction would produce. Legacy cache entries with
absolute ``source_file`` values pass through unchanged.
"""
try:
root_resolved = Path(root).resolve()
except OSError:
return
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
for item in payload.get(bucket, []):
if not isinstance(item, dict):
continue
source = item.get("source_file")
if not source:
continue
sp = Path(source)
if sp.is_absolute():
continue
try:
item["source_file"] = str(root_resolved / sp)
except (TypeError, OSError):
continue
def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path:
"""Returns the cache directory for ``kind`` - creates it if needed.
kind is "ast" or "semantic". Separate subdirectories prevent semantic cache
entries from overwriting AST cache entries for the same source_file (#582).
AST entries live in graphify-out/cache/ast/v{version}/ — namespaced by
graphify version because they depend on extractor code, not just file
contents. Semantic entries live unversioned in graphify-out/cache/semantic/
(re-extraction costs LLM calls).
"""
_out = Path(_GRAPHIFY_OUT)
base = _out if _out.is_absolute() else Path(root).resolve() / _out
d = base / "cache" / kind
if kind == "ast":
d = d / f"v{_EXTRACTOR_VERSION}"
_cleanup_stale_ast_entries(d.parent, d)
d.mkdir(parents=True, exist_ok=True)
return d
def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | None:
"""Return cached extraction for this file if hash matches, else None.
Cache key: SHA256 of file contents.
Cache value: stored as graphify-out/cache/{kind}/{hash}.json (AST entries
under the per-version subdirectory, see :func:`cache_dir`).
AST entries written by other graphify versions — including the legacy
flat cache/ layout (pre-0.5.3) and the unversioned cache/ast/ layout —
are deliberately not consulted: they were produced by a different
extractor and may be stale.
Returns None if no cache entry or file has changed.
"""
try:
h = file_hash(path, root)
except OSError:
return None
entry = cache_dir(root, kind) / f"{h}.json"
if entry.exists():
try:
result = json.loads(entry.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
# Re-anchor relative source_file fields so callers see the same
# absolute-path shape that a fresh in-process extraction produces
# (#777). Legacy entries with absolute source_file pass through.
if isinstance(result, dict):
_absolutize_source_files_in(result, root)
return result
return None
def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast") -> None:
"""Save extraction result for this file.
Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents.
result should be a dict with 'nodes' and 'edges' lists.
No-ops if `path` is not a regular file. Subagent-produced semantic fragments
occasionally carry a directory path in `source_file`; skipping them prevents
IsADirectoryError from aborting the whole batch.
"""
p = Path(path)
if not p.is_file():
return
# Relativize source_file fields against ``root`` before write so the
# cache file on disk is portable across machines and checkout
# directories (#777). The cache key is content-hashed so lookup is
# already path-independent; this fixes the embedded path leak.
#
# Serialize a relativized copy rather than mutating the caller's dict —
# downstream pipeline steps (notably extract.py's AST prefix remap, which
# looks up Path(source_file).resolve() in a prefix table) depend on the
# source_file field's original absolute form. Mutating the input here would
# silently break those remaps on the first extraction pass.
on_disk = result
if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges", "raw_calls")):
import copy as _copy
on_disk = _copy.deepcopy(result)
_relativize_source_files_in(on_disk, root)
h = file_hash(p, root)
target_dir = cache_dir(root, kind)
entry = target_dir / f"{h}.json"
fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp")
try:
os.write(fd, json.dumps(on_disk).encode())
os.close(fd)
try:
os.replace(tmp_path, entry)
except PermissionError:
# Windows: os.replace can fail with WinError 5 if the target is
# briefly locked. Fall back to copy-then-delete.
import shutil
shutil.copy2(tmp_path, entry)
os.unlink(tmp_path)
except Exception:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def cached_files(root: Path = Path(".")) -> set[str]:
"""Return set of file hashes that have a valid cache entry (any kind)."""
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
hashes: set[str] = set()
# Legacy flat entries
if base.is_dir():
hashes.update(p.stem for p in base.glob("*.json"))
# Namespaced entries (ast/ recursively, covering per-version subdirs)
for kind, pattern in (("ast", "**/*.json"), ("semantic", "*.json")):
d = base / kind
if d.is_dir():
hashes.update(p.stem for p in d.glob(pattern))
return hashes
def clear_cache(root: Path = Path(".")) -> None:
"""Delete all cache entries (ast/, semantic/, and legacy flat entries)."""
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
# Legacy flat entries
if base.is_dir():
for f in base.glob("*.json"):
f.unlink()
# Namespaced entries (ast/ recursively, covering per-version subdirs)
for kind, pattern in (("ast", "**/*.json"), ("semantic", "*.json")):
d = base / kind
if d.is_dir():
for f in d.glob(pattern):
f.unlink()
def prune_semantic_cache(root: Path, live_hashes: set[str]) -> int:
"""Remove orphaned semantic cache entries, returning the count pruned.
The semantic cache is content-hash-keyed (``{file_hash}.json`` under
``cache/semantic/``) and deliberately UNVERSIONED — entries are produced by
the LLM from file contents, so invalidating them on every release would
re-bill extraction. Because it is unversioned it is also never swept by the
AST version-cleanup, so every content change or file deletion leaves a
permanent orphan entry that accumulates unbounded.
This sweeps ``cache/semantic/*.json`` and deletes any entry whose stem (the
content hash) is not in ``live_hashes`` — the hashes of the current live
document set. ``*.tmp`` atomic-write temporaries are skipped, and only this
directory is touched (never ``cache/ast/**`` or anything else). The
unversioned design is preserved: we prune by liveness, not by version.
Best-effort, mirroring :func:`_cleanup_stale_ast_entries`: each unlink is
wrapped in ``try/except OSError`` and a failure is ignored. The worst-case
failure mode is benign — a surviving orphan costs only one re-extraction of
one doc on a future run, never incorrect output.
"""
_out = Path(_GRAPHIFY_OUT)
base = _out if _out.is_absolute() else Path(root).resolve() / _out
semantic_dir = base / "cache" / "semantic"
if not semantic_dir.is_dir():
return 0
pruned = 0
for entry in semantic_dir.glob("*.json"):
if entry.stem in live_hashes:
continue
try:
entry.unlink()
pruned += 1
except OSError:
pass
return pruned
def check_semantic_cache(
files: list[str],
root: Path = Path("."),
) -> tuple[list[dict], list[dict], list[dict], list[str]]:
"""Check semantic extraction cache for a list of absolute file paths.
Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files).
Uncached files need Claude extraction; cached files are merged directly.
"""
cached_nodes: list[dict] = []
cached_edges: list[dict] = []
cached_hyperedges: list[dict] = []
uncached: list[str] = []
for fpath in files:
p = Path(fpath)
if not p.is_absolute():
p = Path(root) / p
result = load_cached(p, root, kind="semantic")
if result is not None:
cached_nodes.extend(result.get("nodes", []))
cached_edges.extend(result.get("edges", []))
cached_hyperedges.extend(result.get("hyperedges", []))
else:
uncached.append(fpath)
return cached_nodes, cached_edges, cached_hyperedges, uncached
def save_semantic_cache(
nodes: list[dict],
edges: list[dict],
hyperedges: list[dict] | None = None,
root: Path = Path("."),
merge_existing: bool = False,
) -> int:
"""Save semantic extraction results to cache, keyed by source_file.
Groups nodes and edges by source_file, then saves one cache entry per file
under cache/semantic/ (separate from AST entries in cache/ast/) to prevent
hash-key collisions (#582).
When ``merge_existing`` is True, any already-cached entry for a file is
unioned with the new results before saving instead of being overwritten.
This lets callers checkpoint incrementally (e.g. once per chunk) without
dropping a prior slice of a large file that was split across chunks.
Returns the number of files cached.
"""
from collections import defaultdict
by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": [], "hyperedges": []})
for n in nodes:
src = n.get("source_file", "")
if src:
by_file[src]["nodes"].append(n)
for e in edges:
src = e.get("source_file", "")
if src:
by_file[src]["edges"].append(e)
for h in (hyperedges or []):
src = h.get("source_file", "")
if src:
by_file[src]["hyperedges"].append(h)
saved = 0
for fpath, result in by_file.items():
p = Path(fpath)
if not p.is_absolute():
p = Path(root) / p
if p.is_file():
if merge_existing:
prev = load_cached(p, root, kind="semantic")
if prev:
result = {
"nodes": (prev.get("nodes", []) or []) + result["nodes"],
"edges": (prev.get("edges", []) or []) + result["edges"],
"hyperedges": (prev.get("hyperedges", []) or []) + result["hyperedges"],
}
save_cached(p, result, root, kind="semantic")
saved += 1
return saved
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""Cargo manifest introspection for workspace-internal crate dependencies."""
from __future__ import annotations
from pathlib import Path
from typing import Any
_CONFIDENCE_EXTRACTED = "EXTRACTED"
def _load_toml(path: Path) -> dict[str, Any]:
try:
import tomllib # type: ignore[import-not-found]
except ModuleNotFoundError:
try:
import tomli as tomllib # type: ignore[import-not-found,no-redef]
except ModuleNotFoundError:
raise ImportError(
"--cargo on Python 3.10 needs tomli. Install with: pip install tomli"
) from None
with path.open("rb") as manifest:
return tomllib.load(manifest)
def _member_manifest_paths(root: Path, root_data: dict[str, Any]) -> list[Path]:
paths: list[Path] = []
if isinstance(root_data.get("package"), dict):
paths.append(root / "Cargo.toml")
workspace = root_data.get("workspace")
members = workspace.get("members", []) if isinstance(workspace, dict) else []
if not isinstance(members, list):
return paths
for pattern in members:
if not isinstance(pattern, str):
continue
for member in sorted(root.glob(pattern)):
manifest = member / "Cargo.toml"
if manifest.is_file() and manifest not in paths:
paths.append(manifest)
return paths
def introspect_cargo(root: str | Path) -> dict[str, Any]:
"""Return crate nodes and internal dependency edges from Cargo manifests."""
root_path = Path(root).resolve()
root_manifest = root_path / "Cargo.toml"
root_data = _load_toml(root_manifest)
manifests = _member_manifest_paths(root_path, root_data)
crates: dict[str, tuple[str, Path, dict[str, Any]]] = {}
for manifest in manifests:
data = root_data if manifest == root_manifest else _load_toml(manifest)
package = data.get("package")
if not isinstance(package, dict):
continue
name = package.get("name")
if isinstance(name, str):
crates[name] = (f"crate:{name}", manifest, data)
nodes = [
{
"id": crate_id,
"label": name,
"source_file": manifest.relative_to(root_path).as_posix(),
"source_location": "L1",
}
for name, (crate_id, manifest, _data) in sorted(crates.items())
]
edges: list[dict[str, Any]] = []
for source_name, (source_id, manifest, data) in sorted(crates.items()):
dependencies = data.get("dependencies", {})
if not isinstance(dependencies, dict):
continue
source_file = manifest.relative_to(root_path).as_posix()
for dependency_name in sorted(dependencies):
target = crates.get(dependency_name)
if target is None:
continue
edges.append(
{
"source": source_id,
"target": target[0],
"relation": "crate_depends_on",
"context": "cargo_dependency",
"weight": 1.0,
"confidence": _CONFIDENCE_EXTRACTED,
"source_file": source_file,
"source_location": "L1",
}
)
return {"nodes": nodes, "edges": edges}
+2772
View File
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
"""Community detection on NetworkX graphs. Uses Leiden (graspologic) if available, falls back to Louvain (networkx). Splits oversized communities. Returns cohesion scores."""
from __future__ import annotations
import contextlib
import inspect
import io
import json
import sys
import networkx as nx
def _suppress_output():
"""Context manager to suppress stdout/stderr during library calls.
graspologic's leiden() emits ANSI escape sequences (progress bars,
colored warnings) that corrupt PowerShell 5.1's scroll buffer on
Windows (see issue #19). Redirecting stdout/stderr to devnull during
the call prevents this without losing any graphify output.
"""
return contextlib.redirect_stdout(io.StringIO())
def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
"""Run community detection. Returns {node_id: community_id}.
Tries Leiden (graspologic) first — best quality.
Falls back to Louvain (built into networkx) if graspologic is not installed.
resolution > 1.0 → more, smaller communities.
resolution < 1.0 → fewer, larger communities.
Output from graspologic is suppressed to prevent ANSI escape codes
from corrupting terminal scroll buffers on Windows PowerShell 5.1.
"""
stable = nx.Graph()
stable.add_nodes_from(sorted(G.nodes(), key=str))
edge_rows = sorted(
G.edges(data=True),
key=lambda row: (
str(row[0]),
str(row[1]),
json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str),
),
)
for src, tgt, attrs in edge_rows:
stable.add_edge(src, tgt, **attrs)
try:
from graspologic.partition import leiden
lsig = inspect.signature(leiden).parameters
kwargs: dict = {}
if "random_seed" in lsig:
kwargs["random_seed"] = 42
if "trials" in lsig:
kwargs["trials"] = 1
if "resolution" in lsig:
kwargs["resolution"] = resolution
# Suppress graspologic output to prevent ANSI escape codes from
# corrupting PowerShell 5.1 scroll buffer (issue #19)
old_stderr = sys.stderr
try:
sys.stderr = io.StringIO()
with _suppress_output():
result = leiden(stable, **kwargs)
finally:
sys.stderr = old_stderr
return result
except ImportError:
pass
# Fallback: networkx louvain (available since networkx 2.7).
# Inspect kwargs to stay compatible across NetworkX versions — max_level
# was added in a later release and prevents hangs on large sparse graphs.
kwargs: dict = {"seed": 42, "threshold": 1e-4, "resolution": resolution}
if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
kwargs["max_level"] = 10
communities = nx.community.louvain_communities(stable, **kwargs)
return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
def label_communities_by_hub(
G: nx.Graph, communities: dict[int, list[str]]
) -> dict[int, str]:
"""Deterministic, LLM-free community labels: name each community after its
highest-degree member — the structural hub — so a report reads ``auth`` /
``log_action`` instead of ``Community 70``. Degree is measured on the full graph
``G``; ties break by node id for run-to-run stability. A community whose members
are all absent from ``G`` falls back to ``Community {cid}``.
Used as the default (no-backend) labeler; an LLM naming pass, when configured,
overrides these with richer names.
"""
labels: dict[int, str] = {}
for cid, members in communities.items():
present = [n for n in members if n in G]
if not present:
labels[cid] = f"Community {cid}"
continue
# highest degree wins; ties broken by node id (ascending) for determinism
hub = min(present, key=lambda n: (-G.degree(n), str(n)))
name = str(G.nodes[hub].get("label") or hub).strip()
if name.endswith("()"):
name = name[:-2]
labels[cid] = name or f"Community {cid}"
return labels
def community_member_sigs(communities: dict[int, list[str]]) -> dict[int, str]:
"""Per-community membership fingerprints: ``{cid: sha256(sorted member ids)}``.
Persisted next to ``.graphify_labels.json`` so a later ``cluster-only`` can tell
which communities actually changed since labeling. A cid whose members no longer
hash the same is a different community — reusing its old (LLM) label there is the
"stale label after re-scoping" bug this guards against. Deterministic; independent
of cid index, node order, and machine.
"""
import hashlib
sigs: dict[int, str] = {}
for cid, members in communities.items():
h = hashlib.sha256()
for nid in sorted(str(n) for n in members):
h.update(nid.encode("utf-8", "replace"))
h.update(b"\x00")
sigs[cid] = h.hexdigest()[:16]
return sigs
def cluster(
G: nx.Graph,
resolution: float = 1.0,
exclude_hubs_percentile: float | None = None,
) -> dict[int, list[str]]:
"""Run Leiden community detection. Returns {community_id: [node_ids]}.
Community IDs are stable across runs: 0 = largest community after splitting.
Oversized communities (> 25% of graph nodes, min 10) are split by running
a second Leiden pass on the subgraph.
Accepts directed or undirected graphs. DiGraphs are converted to undirected
internally since Louvain/Leiden require undirected input.
resolution: passed to Leiden/Louvain. >1.0 = more smaller communities,
<1.0 = fewer larger communities. Default 1.0.
exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this
percentile are excluded from partitioning and reattached to their
majority-vote neighbour community afterwards. Useful for staging/utility
super-hubs that inflate god-node rankings (#919).
"""
if G.number_of_nodes() == 0:
return {}
if G.is_directed():
G = G.to_undirected()
if G.number_of_edges() == 0:
return {i: [n] for i, n in enumerate(sorted(G.nodes))}
# Compute hub exclusion set before removing anything so degree is based on full graph
hub_nodes: set[str] = set()
if exclude_hubs_percentile is not None:
degrees = sorted(d for _, d in G.degree())
if degrees:
idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
threshold = degrees[idx]
hub_nodes = {n for n, d in G.degree() if d > threshold}
# Leiden warns and drops isolates - handle them separately
# Also exclude hub nodes from partitioning so they don't pull unrelated
# subsystems into the same community
excluded = hub_nodes
isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded]
connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded]
connected = G.subgraph(connected_nodes)
raw: dict[int, list[str]] = {}
if connected.number_of_nodes() > 0:
partition = _partition(connected, resolution=resolution)
for node, cid in partition.items():
raw.setdefault(cid, []).append(node)
# Each isolate becomes its own single-node community
next_cid = max(raw.keys(), default=-1) + 1
for node in isolates:
raw[next_cid] = [node]
next_cid += 1
# Reattach excluded hubs by majority-vote neighbour community
if hub_nodes:
node_community: dict[str, int] = {n: cid for cid, nodes in raw.items() for n in nodes}
for hub in sorted(hub_nodes):
votes: dict[int, int] = {}
for nb in G.neighbors(hub):
cid = node_community.get(nb)
if cid is not None:
votes[cid] = votes.get(cid, 0) + 1
if votes:
best = min(votes, key=lambda c: (-votes[c], c))
raw.setdefault(best, []).append(hub)
node_community[hub] = best
else:
raw[next_cid] = [hub]
node_community[hub] = next_cid
next_cid += 1
# Split oversized communities
max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION))
final_communities: list[list[str]] = []
for nodes in raw.values():
if len(nodes) > max_size:
final_communities.extend(_split_community(G, nodes))
else:
final_communities.append(nodes)
# Second pass: re-split low-cohesion communities caused by doc-hub nodes
# that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
second_pass: list[list[str]] = []
for nodes in final_communities:
if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
splits = _split_community(G, nodes)
second_pass.extend(splits if len(splits) > 1 else [nodes])
else:
second_pass.append(nodes)
final_communities = second_pass
# Re-index by size descending. The tuple(sorted(nodes)) tiebreak makes this a
# TOTAL order, so an identical grouping always gets identical community IDs.
# Without it, the hundreds of equal-sized small communities are ordered by the
# partitioner's (not seed-stable) enumeration order, so their integer IDs
# permute run-to-run - which reads as massive "community churn" in a per-node
# cid diff even though the actual grouping is reproducible (#1090 follow-up).
final_communities.sort(key=lambda nodes: (-len(nodes), tuple(sorted(map(str, nodes)))))
return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]:
"""Run a second Leiden pass on a community subgraph to split it further."""
subgraph = G.subgraph(nodes)
if subgraph.number_of_edges() == 0:
# No edges - split into individual nodes
return [[n] for n in sorted(nodes)]
try:
sub_partition = _partition(subgraph)
sub_communities: dict[int, list[str]] = {}
for node, cid in sub_partition.items():
sub_communities.setdefault(cid, []).append(node)
if len(sub_communities) <= 1:
return [sorted(nodes)]
return [sorted(v) for v in sub_communities.values()]
except Exception:
return [sorted(nodes)]
def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float:
"""Ratio of actual intra-community edges to maximum possible."""
n = len(community_nodes)
if n <= 1:
return 1.0
subgraph = G.subgraph(community_nodes)
actual = subgraph.number_of_edges()
possible = n * (n - 1) / 2
return actual / possible if possible > 0 else 0.0
def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]:
return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()}
def remap_communities_to_previous(
communities: dict[int, list[str]],
previous_node_community: dict[str, int],
) -> dict[int, list[str]]:
"""Remap community IDs to maximize overlap with a previous assignment.
Uses greedy one-to-one matching by intersection size, then assigns fresh IDs
to unmatched communities in deterministic order (size desc, lexical tie-break).
"""
if not communities:
return {}
new_sets = {cid: set(nodes) for cid, nodes in communities.items()}
old_sets: dict[int, set[str]] = {}
for node, old_cid in previous_node_community.items():
old_sets.setdefault(old_cid, set()).add(node)
overlaps: list[tuple[int, int, int]] = []
for old_cid, old_nodes in old_sets.items():
for new_cid, new_nodes in new_sets.items():
overlap = len(old_nodes & new_nodes)
if overlap > 0:
overlaps.append((overlap, old_cid, new_cid))
overlaps.sort(key=lambda x: (-x[0], x[1], x[2]))
new_to_final: dict[int, int] = {}
used_old_ids: set[int] = set()
matched_new_ids: set[int] = set()
for _overlap, old_cid, new_cid in overlaps:
if old_cid in used_old_ids or new_cid in matched_new_ids:
continue
new_to_final[new_cid] = old_cid
used_old_ids.add(old_cid)
matched_new_ids.add(new_cid)
unmatched = [cid for cid in communities if cid not in matched_new_ids]
unmatched.sort(key=lambda cid: (-len(communities[cid]), tuple(sorted(communities[cid]))))
next_id = 0
for new_cid in unmatched:
while next_id in used_old_ids:
next_id += 1
new_to_final[new_cid] = next_id
used_old_ids.add(next_id)
next_id += 1
remapped: dict[int, list[str]] = {}
for new_cid, nodes in communities.items():
remapped[new_to_final[new_cid]] = sorted(nodes)
return dict(sorted(remapped.items(), key=lambda kv: kv[0]))
+15
View File
@@ -0,0 +1,15 @@
---
description: Build or query a graphify knowledge graph
---
Invoke the `graphify` skill immediately.
Pass the full `/graphify` argument string through unchanged.
If no arguments were supplied, treat the target path as `.`.
Examples:
- `/graphify`
- `/graphify src --update`
- `/graphify query "what connects auth to billing?"`
Do not answer from raw files before handing off to the `graphify` skill.
+568
View File
@@ -0,0 +1,568 @@
"""Entity deduplication pipeline for graphify knowledge graphs.
Pipeline: exact normalization → entropy gate → MinHash/LSH blocking →
Jaro-Winkler verification → same-community boost → union-find merge.
"""
from __future__ import annotations
import math
import re
import sys
import unicodedata
from collections import defaultdict
from graphify._minhash import MinHash, MinHashLSH
from rapidfuzz.distance import Jaro, JaroWinkler
# ── helpers ───────────────────────────────────────────────────────────────────
def _norm(label: str | None) -> str:
"""Lowercase + collapse non-alphanumeric runs to space (Unicode-aware)."""
if not isinstance(label, str):
label = "" if label is None else str(label)
label = unicodedata.normalize("NFKC", label)
return re.sub(r"[\W_]+", " ", label.casefold(), flags=re.UNICODE).strip()
def _entropy(label: str) -> float:
"""Shannon entropy in bits/char of the normalised label."""
s = _norm(label)
if not s:
return 0.0
freq: dict[str, int] = defaultdict(int)
for ch in s:
freq[ch] += 1
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in freq.values())
def _shingles(text: str, k: int = 3) -> set[str]:
"""Return k-gram character shingles of text."""
if len(text) < k:
return {text}
return {text[i : i + k] for i in range(len(text) - k + 1)}
def _make_minhash(text: str, num_perm: int = 128) -> MinHash:
# Strip spaces so "graph extractor" and "graphextractor" share shingles
m = MinHash(num_perm=num_perm)
for shingle in _shingles(text.replace(" ", "")):
m.update(shingle.encode("utf-8"))
return m
# Matches labels whose trailing token is a version/variant suffix:
# digits optionally followed by letters (chip SKUs: ASR1603, M1, Cortex-A55)
# or 2+ letters (codename revisions: cranelr vs cranel).
# Requires the stem to end in a letter so plain words don't accidentally match.
_VARIANT_SUFFIX = re.compile(r"^(.*[a-z])([0-9]+[a-z]*|[a-z]{2,})$")
def _is_variant_pair(a: str, b: str) -> bool:
"""True if a and b are sibling model/SKU variants (same stem, different suffix).
Only applied to short labels (< 12 chars); long labels go through JW normally.
"""
if a == b:
return False
if max(len(a), len(b)) >= 12:
return False
ma, mb = _VARIANT_SUFFIX.match(a), _VARIANT_SUFFIX.match(b)
if not (ma and mb):
return False
return ma.group(1) == mb.group(1) and ma.group(2) != mb.group(2)
def _short_label_blocked(a: str, b: str, jw_score: float) -> bool:
"""Block fuzzy merge for short labels unless it's a same-length single-char substitution.
Insertions/deletions on short strings (cranel/cranelr, M1/M1 Pro) produce
high Jaro-Winkler scores due to the prefix bonus but are almost never true
duplicates — they're abbreviations or variants.
"""
if max(len(a), len(b)) >= 12:
return False
from rapidfuzz.distance import DamerauLevenshtein
# Allow only same-length single-char substitutions (true typos like "Extractor"/"Extractar").
# Block length-differing pairs regardless of score.
if jw_score >= 97.0 and len(a) == len(b) and DamerauLevenshtein.distance(a, b) <= 1:
return False
return True
_DIGIT_RUN = re.compile(r"\d+")
def _numeric_tokens_differ(a: str, b: str) -> bool:
"""True when two labels carry different embedded numbers (#1284).
Long labels that differ only in their digit runs ("ADR 0011 §D5" vs
"ADR 0013 D4", "3.1 Product Goals" vs "1.1 Product Goals", "block3" vs
"block13", "40%+ retention" vs "<20% retention") are numbered/versioned
siblings, not duplicates -- but the long shared boilerplate keeps
Jaro-Winkler above _MERGE_THRESHOLD, and _is_variant_pair only covers
short trailing suffixes. Digit runs are compared as multisets with
leading zeros stripped, so zero-padding ("09" vs "9") does not count as
a difference. (String comparison, not int(): a pathological label with a
>4300-digit run would crash int() on Python's conversion limit.) Labels
with identical numbers, or none at all, are unaffected.
"""
if a == b:
return False
return sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(a)) != \
sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(b))
# file_type values whose identity is anchored to their source location, not
# their label text. Like code (#1205), these must not be label-merged across
# files: rationale = module/class docstrings, document = headings/positional
# content. `concept` is intentionally excluded -- it is the type meant to unify
# across files (protected from over-merge by the numeric/Jaro guards instead).
_FILE_ANCHORED_NONCODE = frozenset({"rationale", "document"})
def _crossfile_fileanchored_blocked(node: dict, neighbor: dict) -> bool:
"""Block label-based merging of file-anchored non-code nodes across files (#1284).
rationale/document nodes are docstring- and heading-derived and as
file-anchored as the code they describe (#1205's reasoning, one layer up):
parallel modules carry near-identical boilerplate ("Django app config for
apps.<name>. No business logic here...") that differs by one word and sails
past the JW threshold. Same-file duplicates of these types may still merge.
"""
if (node.get("file_type") not in _FILE_ANCHORED_NONCODE
and neighbor.get("file_type") not in _FILE_ANCHORED_NONCODE):
return False
return (node.get("source_file") or "") != (neighbor.get("source_file") or "")
# ── union-find ────────────────────────────────────────────────────────────────
class _UF:
def __init__(self) -> None:
self._parent: dict[str, str] = {}
def find(self, x: str) -> str:
self._parent.setdefault(x, x)
while self._parent[x] != x:
self._parent[x] = self._parent[self._parent[x]]
x = self._parent[x]
return x
def union(self, x: str, y: str) -> None:
self._parent.setdefault(x, x)
self._parent.setdefault(y, y)
rx, ry = self.find(x), self.find(y)
if rx != ry:
self._parent[ry] = rx
def components(self) -> dict[str, list[str]]:
groups: dict[str, list[str]] = defaultdict(list)
for x in self._parent:
groups[self.find(x)].append(x)
return dict(groups)
# ── constants ─────────────────────────────────────────────────────────────────
_ENTROPY_THRESHOLD = 2.5
_LSH_THRESHOLD = 0.7
_MERGE_THRESHOLD = 92.0 # rapidfuzz normalized_similarity * 100
_COMMUNITY_BOOST = 5.0 # score bonus when both nodes share community
_NUM_PERM = 128
_CHUNK_SUFFIX = re.compile(r"_c\d+$")
def _is_code(node: dict) -> bool:
"""True for AST-extracted code symbols.
Code-node identity is the node ID (which already encodes the fully
qualified path: module/class/symbol). The label is only a display name
(e.g. a bare ``.draw()`` method name, or a function name shared by two
parallel backends), so label-based merging conflates distinct symbols
(#1205). Genuine duplicates — the same symbol re-extracted — share an ID
and are already collapsed by the exact-ID ``seen_ids`` pre-dedup above,
so code never needs label-based merging.
"""
return node.get("file_type") == "code"
# ── main entry point ──────────────────────────────────────────────────────────
def deduplicate_entities(
nodes: list[dict],
edges: list[dict],
*,
communities: dict[str, int],
dedup_llm_backend: str | None = None,
) -> tuple[list[dict], list[dict]]:
"""Deduplicate near-identical entities in a knowledge graph.
Args:
nodes: list of node dicts with at minimum {"id": str, "label": str}
edges: list of edge dicts with {"source": str, "target": str, ...}
communities: mapping of node_id -> community_id (from cluster())
dedup_llm_backend: if set, use LLM to resolve ambiguous pairs
Returns:
(deduped_nodes, deduped_edges) with edges rewired to survivors
"""
# Guard: cross-project dedup is not supported — nodes from different repos
# share label names by coincidence and must never be merged by string similarity.
# If you need to dedup a global graph, run deduplicate_entities per-repo first.
repos_seen = {n.get("repo") for n in nodes if n.get("repo")}
if len(repos_seen) > 1:
raise ValueError(
f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. "
f"Cross-project dedup is disabled — run dedup per-repo before merging."
)
if len(nodes) <= 1:
return nodes, edges
# Pre-deduplicate: keep first occurrence of each id.
# Warn when two nodes share an ID but originate from different source files —
# this indicates a cross-chunk ID collision (#1504) where silent data loss occurs.
seen_ids: dict[str, dict] = {}
for node in nodes:
nid = node.get("id", "")
if not nid:
continue
if nid not in seen_ids:
seen_ids[nid] = node
else:
existing_sf = seen_ids[nid].get("source_file") or ""
new_sf = node.get("source_file") or ""
if existing_sf != new_sf:
print(
f"[graphify] WARNING: node '{nid}' from '{new_sf}' collides with "
f"node from '{existing_sf}' — the second node will be dropped. "
f"This is a cross-chunk ID collision caused by two files with the "
f"same name in different directories. To avoid data loss, run "
f"'graphify extract' per subfolder and merge with "
f"'graphify merge-graphs'.",
file=sys.stderr,
)
unique_nodes = list(seen_ids.values())
if len(unique_nodes) <= 1:
return unique_nodes, edges
# ── pass 1: exact normalization ───────────────────────────────────────────
norm_to_nodes: dict[str, list[dict]] = defaultdict(list)
for node in unique_nodes:
# Code symbols are keyed by ID, never by label — skip them entirely so
# distinct same-named symbols are never merged by string similarity (#1205).
if _is_code(node):
continue
key = _norm(node.get("label", node.get("id", "")))
if key:
norm_to_nodes[key].append(node)
uf = _UF()
exact_merges = 0
for key, group in norm_to_nodes.items():
if len(group) <= 1:
continue
# Partition by source_file — only merge within the same file in Pass 1.
# Cross-file matches fall through to Pass 2 fuzzy matching.
by_file: dict[str, list[dict]] = defaultdict(list)
for node in group:
sf = node.get("source_file") or ""
by_file[sf].append(node)
for sf, file_group in by_file.items():
if not sf:
# No source_file — cannot prove same symbol; skip to avoid
# collapsing distinct nodes that happen to share a label (#1178).
continue
if len(file_group) > 1:
winner = _pick_winner(file_group)
for node in file_group:
uf.union(winner["id"], node["id"])
exact_merges += len(file_group) - 1
# ── pass 2: MinHash/LSH + Jaro-Winkler (high-entropy nodes only) ─────────
candidates: list[dict] = []
seen_norms: set[str] = set()
for node in unique_nodes:
# Code symbols are excluded from fuzzy matching too: two functions with
# similar long names in different files (parallel backends, sibling
# classes) must not be fuzzy-merged, and a code↔concept fuzzy match must
# not transitively union two distinct code symbols via a concept (#1205).
if _is_code(node):
continue
key = _norm(node.get("label", node.get("id", "")))
if key and key not in seen_norms:
seen_norms.add(key)
if _entropy(node.get("label", "")) >= _ENTROPY_THRESHOLD:
candidates.append(node)
fuzzy_merges = 0
if len(candidates) >= 2:
lsh = MinHashLSH(threshold=_LSH_THRESHOLD, num_perm=_NUM_PERM)
minhashes: dict[str, MinHash] = {}
# Pre-build O(1) lookup structures so the query loop below doesn't scan
# the candidates list linearly for every LSH neighbor (was O(n²×B)).
candidates_by_id: dict[str, dict] = {}
norm_cache: dict[str, str] = {}
for node in candidates:
node_id = node["id"]
candidates_by_id[node_id] = node
nl = _norm(node.get("label", node.get("id", "")))
norm_cache[node_id] = nl
m = _make_minhash(nl)
minhashes[node_id] = m
try:
lsh.insert(node_id, m)
except ValueError:
pass # duplicate key in LSH — already inserted
for node in candidates:
node_id = node["id"]
norm_label = norm_cache[node_id]
neighbors = lsh.query(minhashes[node_id])
for neighbor_id in neighbors:
if neighbor_id == node_id:
continue
if uf.find(node_id) == uf.find(neighbor_id):
continue
neighbor = candidates_by_id.get(neighbor_id)
if neighbor is None:
continue
neighbor_norm = norm_cache.get(neighbor_id) or _norm(neighbor.get("label", neighbor.get("id", "")))
# Cross-file long labels score on plain Jaro (no prefix bonus).
# Jaro-Winkler's leading-prefix bonus lifts pairs that share a
# prefix but diverge in a distinguishing token ("testing-library
# jest-native" vs "react-native") past threshold, fabricating
# destructive cross-file merges; on Jaro alone they fall short
# while true cross-file duplicates still clear it (#1243). Same-file
# near-duplicates keep Jaro-Winkler (low-risk, and a mid-string
# stopword insertion needs the prefix bonus to merge); short labels
# keep Jaro-Winkler too (gated by _short_label_blocked).
_xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
if _xfile and max(len(norm_label), len(neighbor_norm)) >= 12:
score = Jaro.normalized_similarity(norm_label, neighbor_norm) * 100
else:
score = JaroWinkler.normalized_similarity(norm_label, neighbor_norm) * 100
if _is_variant_pair(norm_label, neighbor_norm):
continue
if _short_label_blocked(norm_label, neighbor_norm, score):
continue
# Prefix-extension pairs (getActiveSession / getActiveSessions,
# parseConfig / parseConfigFile) are almost never duplicates —
# one is a strict suffix-extension of the other. Block the merge
# regardless of JW score (#1201).
_lo, _hi = sorted((norm_label, neighbor_norm), key=len)
if _hi.startswith(_lo) and _hi != _lo:
continue
# Numbered/versioned siblings and cross-file file-anchored
# boilerplate (rationale/document) are decisively distinct
# regardless of score (#1284).
if _numeric_tokens_differ(norm_label, neighbor_norm):
continue
if _crossfile_fileanchored_blocked(node, neighbor):
continue
c1 = communities.get(node_id)
c2 = communities.get(neighbor_id)
if (c1 is not None and c2 is not None and c1 == c2
and min(len(norm_label), len(neighbor_norm)) >= 12):
score += _COMMUNITY_BOOST
if score >= _MERGE_THRESHOLD:
# Identical labels across different source files almost always
# means same-named-but-different symbols (trait impls, wrapper
# methods, common type names). Mirror Pass 1's source_file
# partition for this sub-case. (#1046, leaks #895's fix)
if norm_label == neighbor_norm:
sf_a = node.get("source_file") or ""
sf_b = neighbor.get("source_file") or ""
if sf_a != sf_b:
continue
# Pick the winner from the verified pair only. Selecting it
# from the union of both normalized-label groups pulls
# never-compared nodes (same label, different source_file)
# into the merge, bypassing the #1046/#1178 guards.
winner = _pick_winner([node, neighbor])
uf.union(winner["id"], node_id)
uf.union(winner["id"], neighbor_id)
fuzzy_merges += 1
# ── pass 3: LLM tiebreaker for ambiguous pairs (opt-in) ──────────────────
if dedup_llm_backend is not None:
_llm_tiebreak(candidates, uf, communities, backend=dedup_llm_backend)
# ── build remap table from union-find components ──────────────────────────
components = uf.components()
remap: dict[str, str] = {}
for root, members in components.items():
if len(members) == 1:
continue
group_nodes = [n for n in unique_nodes if n["id"] in members]
winner = _pick_winner(group_nodes) if group_nodes else {"id": root}
winner_id = winner["id"]
for member in members:
if member != winner_id:
remap[member] = winner_id
# ── apply remap ───────────────────────────────────────────────────────────
if not remap:
return unique_nodes, edges
total = len(remap)
msg = f"[graphify] Deduplicated {total} node(s)"
if exact_merges:
msg += f" ({exact_merges} exact"
if fuzzy_merges:
msg += f", {fuzzy_merges} fuzzy"
msg += ")"
print(msg + ".", flush=True)
deduped_nodes = [n for n in unique_nodes if n["id"] not in remap]
deduped_edges = []
for edge in edges:
e = dict(edge)
# Tolerate "from"/"to" keys from LLM backends that don't follow the
# schema exactly — build_from_json normalises later but dedup runs
# first so bracket access would KeyError here (#803).
# Use explicit key presence check (not `or`) so empty-string src/tgt
# aren't silently replaced by the fallback key.
src = e["source"] if "source" in e else e.get("from")
tgt = e["target"] if "target" in e else e.get("to")
if src is None or tgt is None:
continue
e["source"] = remap.get(src, src)
e["target"] = remap.get(tgt, tgt)
# Remove legacy keys so they don't leak into edge attrs in graph.json.
e.pop("from", None)
e.pop("to", None)
if e["source"] != e["target"]:
deduped_edges.append(e)
return deduped_nodes, deduped_edges
def _pick_winner(nodes: list[dict]) -> dict:
"""Pick the canonical survivor: prefer no chunk suffix, then shorter ID."""
if not nodes:
raise ValueError("Cannot pick winner from empty list")
def _score(n: dict) -> tuple[int, int]:
has_suffix = bool(_CHUNK_SUFFIX.search(n["id"]))
return (1 if has_suffix else 0, len(n["id"]))
return min(nodes, key=_score)
def _llm_tiebreak(
candidates: list[dict],
uf: _UF,
communities: dict[str, int],
*,
backend: str,
batch_size: int = 30,
low: float = 75.0,
high: float = 92.0,
) -> None:
"""Batch-resolve ambiguous pairs (score in [low, high)) via LLM."""
try:
from graphify.llm import BACKENDS, _format_backend_env_keys, _get_backend_api_key
if backend not in BACKENDS:
print(f"[graphify] --dedup-llm: unknown backend {backend!r}, skipping LLM tiebreaker.", flush=True)
return
if not _get_backend_api_key(backend):
env_keys = _format_backend_env_keys(backend)
print(f"[graphify] --dedup-llm: {env_keys} not set, skipping LLM tiebreaker.", flush=True)
return
except ImportError:
return
ambiguous: list[tuple[dict, dict, float]] = []
for i, node in enumerate(candidates):
norm_i = _norm(node.get("label", node.get("id", "")))
for j in range(i + 1, len(candidates)):
neighbor = candidates[j]
if uf.find(node["id"]) == uf.find(neighbor["id"]):
continue
norm_j = _norm(neighbor.get("label", neighbor.get("id", "")))
# Mirror pass 2: plain Jaro for cross-file long labels (#1243).
_xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
if _xfile and max(len(norm_i), len(norm_j)) >= 12:
score = Jaro.normalized_similarity(norm_i, norm_j) * 100
else:
score = JaroWinkler.normalized_similarity(norm_i, norm_j) * 100
if _is_variant_pair(norm_i, norm_j):
continue
if _short_label_blocked(norm_i, norm_j, score):
continue
_lo, _hi = sorted((norm_i, norm_j), key=len)
if _hi.startswith(_lo) and _hi != _lo:
continue
# Mirror pass 2: decisively-distinct pairs never reach the LLM (#1284).
if _numeric_tokens_differ(norm_i, norm_j):
continue
if _crossfile_fileanchored_blocked(node, neighbor):
continue
c1 = communities.get(node["id"])
c2 = communities.get(neighbor["id"])
if (c1 is not None and c2 is not None and c1 == c2
and min(len(norm_i), len(norm_j)) >= 12):
score += _COMMUNITY_BOOST
if low <= score < high:
ambiguous.append((node, neighbor, score))
if not ambiguous:
return
try:
from graphify.llm import _call_llm
except ImportError as exc:
# F-038: previously this silent fallback hid the fact that `_call_llm`
# didn't exist in `graphify.llm` at all, so `--dedup-llm` was a no-op.
# Surface the import failure so future regressions are visible.
print(
f"[graphify] --dedup-llm: cannot import _call_llm ({exc}); skipping LLM tiebreaker.",
flush=True,
)
return
for batch_start in range(0, len(ambiguous), batch_size):
batch = ambiguous[batch_start : batch_start + batch_size]
pairs_text = "\n".join(
f"{i+1}. \"{a['label']}\" vs \"{b['label']}\""
for i, (a, b, _) in enumerate(batch)
)
prompt = (
"For each pair below, answer only 'yes' or 'no': are they the same real-world concept?\n\n"
f"{pairs_text}\n\n"
"Reply with one line per pair: '1. yes', '2. no', etc."
)
try:
response = _call_llm(prompt, backend=backend, max_tokens=200)
lines = response.strip().splitlines()
for line in lines:
line = line.strip()
if not line:
continue
parts = line.split(".", 1)
if len(parts) != 2:
continue
try:
idx = int(parts[0].strip()) - 1
except ValueError:
continue
if 0 <= idx < len(batch):
answer = parts[1].strip().lower()
if answer.startswith("yes"):
a, b, _ = batch[idx]
winner = _pick_winner([a, b])
uf.union(winner["id"], a["id"])
uf.union(winner["id"], b["id"])
except Exception as exc:
print(f"[graphify] --dedup-llm batch failed: {exc}", flush=True)
+1573
View File
File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
"""Read-only diagnostics for MultiDiGraph readiness."""
from __future__ import annotations
import json
import re
from collections import Counter, defaultdict
from copy import deepcopy
from pathlib import Path
from typing import Any
import networkx as nx
_SUPPRESSION_DECL_RE = re.compile(r"^\s*(?P<name>seen_[A-Za-z0-9_]+)\s*[:=]")
_TYPE_TUPLE_RE = re.compile(r"set\[tuple\[(?P<inside>[^\]]+)\]\]")
def _safe_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (str, int, float, bool)):
return str(value)
return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False)
def _edge_list(extraction: dict[str, Any]) -> list[Any]:
edges = extraction.get("edges")
if edges is None:
edges = extraction.get("links")
return edges if isinstance(edges, list) else []
def _node_ids(extraction: dict[str, Any]) -> set[str]:
nodes = extraction.get("nodes", [])
if not isinstance(nodes, list):
return set()
return {
str(node["id"])
for node in nodes
if isinstance(node, dict) and "id" in node and node.get("id") is not None
}
def _canonical_edge(edge: Any) -> dict[str, str]:
if not isinstance(edge, dict):
return {
"source": "",
"target": "",
"relation": "",
"confidence": "",
"source_file": "",
"source_location": "",
"context": "",
"_invalid": "non_object_edge",
}
source = edge.get("source", edge.get("from"))
target = edge.get("target", edge.get("to"))
return {
"source": _safe_text(source),
"target": _safe_text(target),
"relation": _safe_text(edge.get("relation")),
"confidence": _safe_text(edge.get("confidence")),
"source_file": _safe_text(edge.get("source_file")),
"source_location": _safe_text(edge.get("source_location")),
"context": _safe_text(edge.get("context")),
"_invalid": "",
}
def _exact_signature(edge: Any) -> str:
if not isinstance(edge, dict):
return "<non-object>"
normalized = dict(edge)
if "source" not in normalized and "from" in normalized:
normalized["source"] = normalized["from"]
if "target" not in normalized and "to" in normalized:
normalized["target"] = normalized["to"]
normalized.pop("from", None)
normalized.pop("to", None)
return json.dumps(
normalized,
sort_keys=True,
default=str,
ensure_ascii=False,
separators=(",", ":"),
)
def _count_extra(counter: Counter[Any]) -> int:
return sum(count - 1 for count in counter.values() if count > 1)
def _variant_group_count(
grouped_edges: dict[tuple[str, str], list[dict[str, str]]],
field: str,
*,
relation_sensitive: bool = False,
) -> int:
groups = 0
for edges in grouped_edges.values():
if relation_sensitive:
by_relation: dict[str, set[str]] = defaultdict(set)
for edge in edges:
by_relation[edge["relation"]].add(edge[field])
groups += sum(1 for values in by_relation.values() if len(values) > 1)
elif len({edge[field] for edge in edges}) > 1:
groups += 1
return groups
def _tuple_arity_from_annotation(line: str) -> int:
match = _TYPE_TUPLE_RE.search(line)
if not match:
return 0
inside = match.group("inside").strip()
if not inside:
return 0
return inside.count(",") + 1
def scan_producer_suppression_sites(path: str | Path) -> dict[str, Any]:
"""Find likely `seen_*` producer-suppression sets in an extractor file."""
source_path = Path(path)
if not source_path.exists():
return {
"path": str(source_path),
"total_sites": 0,
"sites": [],
"error": "file not found",
}
sites: list[dict[str, Any]] = []
lines = source_path.read_text(encoding="utf-8").splitlines()
for lineno, line in enumerate(lines, start=1):
match = _SUPPRESSION_DECL_RE.match(line)
if not match:
continue
sites.append(
{
"line": lineno,
"name": match.group("name"),
"tuple_arity": _tuple_arity_from_annotation(line),
"sample": line.strip()[:120],
}
)
return {
"path": str(source_path),
"total_sites": len(sites),
"sites": sites,
"error": "",
}
def diagnose_extraction(
extraction: dict[str, Any],
*,
directed: bool = True,
root: str | Path | None = None,
max_examples: int = 5,
extract_path: str | Path | None = None,
) -> dict[str, Any]:
"""Summarize same-endpoint edge-collapse risk for one JSON graph/extraction dict."""
from graphify.build import build_from_json
node_ids = _node_ids(extraction)
raw_edges = _edge_list(extraction)
canonical_edges = [_canonical_edge(edge) for edge in raw_edges]
exact_counts: Counter[str] = Counter(_exact_signature(edge) for edge in raw_edges)
directed_pairs: Counter[tuple[str, str]] = Counter()
undirected_pairs: Counter[tuple[str, str]] = Counter()
grouped: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
non_object_edges = 0
missing_endpoint_edges = 0
dangling_endpoint_edges = 0
self_loop_edges = 0
valid_candidate_edges = 0
for edge in canonical_edges:
if edge["_invalid"]:
non_object_edges += 1
continue
source = edge["source"]
target = edge["target"]
if not source or not target:
missing_endpoint_edges += 1
continue
if source not in node_ids or target not in node_ids:
dangling_endpoint_edges += 1
continue
if source == target:
self_loop_edges += 1
valid_candidate_edges += 1
directed_pair = (source, target)
undirected_pair = (source, target) if source <= target else (target, source)
directed_pairs[directed_pair] += 1
undirected_pairs[undirected_pair] += 1
grouped[directed_pair].append(edge)
examples: list[dict[str, Any]] = []
if max_examples > 0:
for (source, target), count in directed_pairs.most_common():
if count < 2:
continue
edges = grouped[(source, target)]
examples.append(
{
"source": source,
"target": target,
"edge_count": count,
"relations": sorted({edge["relation"] for edge in edges}),
"source_files": sorted({edge["source_file"] for edge in edges}),
"source_locations": sorted({edge["source_location"] for edge in edges}),
"contexts": sorted({edge["context"] for edge in edges}),
}
)
if len(examples) >= max_examples:
break
build_error = ""
graph_type = ""
post_build_edge_count: int | None = None
post_build_node_count: int | None = None
try:
graph_input = deepcopy(extraction)
graph: nx.Graph = build_from_json(graph_input, directed=directed, root=root)
graph_type = type(graph).__name__
post_build_edge_count = graph.number_of_edges()
post_build_node_count = graph.number_of_nodes()
except Exception as exc:
build_error = f"{type(exc).__name__}: {exc}"
suppression_path = (
Path(extract_path) if extract_path else Path(__file__).with_name("extract.py")
)
return {
"node_count": len(node_ids),
"raw_edge_count": len(raw_edges),
"non_object_edges": non_object_edges,
"missing_endpoint_edges": missing_endpoint_edges,
"dangling_endpoint_edges": dangling_endpoint_edges,
"self_loop_edges": self_loop_edges,
"valid_candidate_edges": valid_candidate_edges,
"exact_duplicate_edges": _count_extra(exact_counts),
"directed_unique_endpoint_pairs": len(directed_pairs),
"directed_same_endpoint_collapsed_edges": _count_extra(directed_pairs),
"undirected_unique_endpoint_pairs": len(undirected_pairs),
"undirected_same_endpoint_collapsed_edges": _count_extra(undirected_pairs),
"same_endpoint_group_count": sum(1 for count in directed_pairs.values() if count > 1),
"relation_variant_groups": _variant_group_count(grouped, "relation"),
"source_file_variant_groups": _variant_group_count(
grouped, "source_file", relation_sensitive=True
),
"source_location_variant_groups": _variant_group_count(
grouped, "source_location", relation_sensitive=True
),
"context_variant_groups": _variant_group_count(grouped, "context", relation_sensitive=True),
"post_build_graph_type": graph_type,
"post_build_node_count": post_build_node_count,
"post_build_edge_count": post_build_edge_count,
"post_build_error": build_error,
"producer_suppression": scan_producer_suppression_sites(suppression_path),
"examples": examples,
}
def _read_json_file(path: str | Path) -> dict[str, Any]:
"""Read a JSON graph after applying Graphify's graph-load size cap."""
from graphify.security import check_graph_file_size_cap
json_path = Path(path)
check_graph_file_size_cap(json_path)
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Cannot parse {json_path}: {exc}. "
"The file may be corrupted — re-run 'graphify extract'."
) from exc
if not isinstance(data, dict):
raise ValueError("diagnostic input must be a JSON object")
return data
def diagnose_file(
path: str | Path,
*,
directed: bool | None = None,
root: str | Path | None = None,
max_examples: int = 5,
extract_path: str | Path | None = None,
) -> dict[str, Any]:
"""Diagnose a graph/extraction JSON file without mutating it.
When `directed` is None, the JSON's "directed" flag is honored. Raw
extraction JSON that has no "directed" flag defaults to directed analysis.
"""
data = _read_json_file(path)
if directed is None:
raw_directed = data.get("directed")
effective_directed = raw_directed if isinstance(raw_directed, bool) else True
else:
effective_directed = directed
summary = diagnose_extraction(
data,
directed=effective_directed,
root=root,
max_examples=max_examples,
extract_path=extract_path,
)
summary["input_path"] = str(path)
summary["effective_directed"] = effective_directed
return summary
def format_diagnostic_json(summary: dict[str, Any]) -> dict[str, Any]:
return {
"schema_version": 1,
"summary": {
key: value
for key, value in summary.items()
if key not in {"examples", "producer_suppression"}
},
"examples": summary.get("examples", []),
"producer_suppression": summary.get("producer_suppression", {}),
"notes": [
"Diagnostics are read-only.",
"A normal graph.json is already post-build and cannot recover raw producer edges.",
"Producer suppression sites are heuristic source-code evidence.",
],
}
def format_diagnostic_report(summary: dict[str, Any]) -> str:
suppression = summary.get("producer_suppression", {})
lines = [
"[graphify] MultiDiGraph edge-collapse diagnostic",
f"input: {summary.get('input_path', '<in-memory>')}",
"input_stage: provided JSON (normal graph.json is post-build)",
f"effective_directed: {summary.get('effective_directed', '<direct-call>')}",
f"nodes: {summary['node_count']}",
f"raw_edges: {summary['raw_edge_count']}",
f"valid_candidate_edges: {summary['valid_candidate_edges']}",
f"missing_endpoint_edges: {summary['missing_endpoint_edges']}",
f"dangling_endpoint_edges: {summary['dangling_endpoint_edges']}",
f"self_loop_edges: {summary['self_loop_edges']}",
f"exact_duplicate_edges: {summary['exact_duplicate_edges']}",
f"directed_unique_endpoint_pairs: {summary['directed_unique_endpoint_pairs']}",
(
"directed_same_endpoint_collapsed_edges: "
f"{summary['directed_same_endpoint_collapsed_edges']}"
),
f"undirected_unique_endpoint_pairs: {summary['undirected_unique_endpoint_pairs']}",
(
"undirected_same_endpoint_collapsed_edges: "
f"{summary['undirected_same_endpoint_collapsed_edges']}"
),
f"same_endpoint_group_count: {summary['same_endpoint_group_count']}",
f"relation_variant_groups: {summary['relation_variant_groups']}",
f"source_file_variant_groups: {summary['source_file_variant_groups']}",
f"source_location_variant_groups: {summary['source_location_variant_groups']}",
f"context_variant_groups: {summary['context_variant_groups']}",
f"post_build_graph_type: {summary['post_build_graph_type']}",
f"post_build_edges: {summary['post_build_edge_count']}",
f"producer_suppression_sites: {suppression.get('total_sites', 0)}",
]
if summary.get("post_build_error"):
lines.append(f"post_build_error: {summary['post_build_error']}")
if suppression.get("error"):
lines.append(f"producer_suppression_error: {suppression['error']}")
if suppression.get("sites"):
lines.append("producer_suppression_examples:")
for site in suppression["sites"][:8]:
lines.append(
f" - L{site['line']} {site['name']} arity={site['tuple_arity'] or 'unknown'}"
)
if summary.get("examples"):
lines.append("examples:")
for example in summary["examples"]:
lines.append(
" - "
f"{example['source']} -> {example['target']} "
f"edges={example['edge_count']} "
f"relations={example['relations']} "
f"locations={example['source_locations']} "
f"contexts={example['contexts']}"
)
lines.append(
"note: normal graph.json is post-build; raw producer loss must be measured earlier."
)
return "\n".join(lines)
+993
View File
@@ -0,0 +1,993 @@
# write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher
from __future__ import annotations
import hashlib
import html as _html
import json
import math
import os
import re
import shutil
import sys
from collections import Counter
from datetime import date
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
from graphify.security import sanitize_label
from graphify.analyze import _node_community_map
from graphify.build import edge_data
from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j # noqa: E402,F401
# Artifacts worth preserving across rebuilds (non-regenerable without LLM or curation).
_BACKUP_ARTIFACTS = [
"graph.json",
"GRAPH_REPORT.md",
".graphify_labels.json",
".graphify_analysis.json",
"manifest.json",
".graphify_semantic_marker",
"cost.json",
]
def backup_if_protected(out_dir: Path) -> "Path | None":
"""Snapshot graph artifacts to a dated subfolder before an overwrite.
Triggers when graph.json exists AND either:
- .graphify_semantic_marker is present (graph cost real LLM tokens), or
- .graphify_labels.json contains at least one non-default community label
(graph has been curated by a human or skill).
Returns the backup folder path, or None if no backup was taken.
Never raises — backup failure prints a warning but never blocks the write.
Set GRAPHIFY_NO_BACKUP=1 to disable.
"""
if os.environ.get("GRAPHIFY_NO_BACKUP"):
return None
out = Path(out_dir)
if not (out / "graph.json").exists():
return None
is_semantic = (out / ".graphify_semantic_marker").exists()
is_curated = False
labels_file = out / ".graphify_labels.json"
if labels_file.exists():
try:
labels = json.loads(labels_file.read_text(encoding="utf-8"))
is_curated = any(v != f"Community {k}" for k, v in labels.items())
except Exception:
pass
if not is_semantic and not is_curated:
return None
reason = "+".join(filter(None, ["semantic" if is_semantic else "", "curated" if is_curated else ""]))
today = date.today().isoformat()
backup_dir = out / today
graph_src = out / "graph.json"
# Skip re-copying if today's backup already has identical graph.json content.
# If content differs (graph changed since the last backup today), overwrite
# the backup in place — one folder per day, always the latest pre-overwrite state.
if backup_dir.exists() and (backup_dir / "graph.json").exists():
src_hash = hashlib.sha256(graph_src.read_bytes()).hexdigest()
bak_hash = hashlib.sha256((backup_dir / "graph.json").read_bytes()).hexdigest()
if src_hash == bak_hash:
return backup_dir # identical content, nothing to do
try:
backup_dir.mkdir(parents=True, exist_ok=True)
copied = 0
for name in _BACKUP_ARTIFACTS:
src = out / name
if src.exists():
try:
shutil.copy2(src, backup_dir / name)
copied += 1
except Exception:
pass
if copied:
print(f"[graphify] backed up {reason} graph ({copied} files) -> {backup_dir.name}/")
return backup_dir
except Exception as exc:
import sys
print(f"[graphify] warning: backup failed ({exc}) - continuing with overwrite", file=sys.stderr)
return None
def _obsidian_tag(name: str) -> str:
"""Sanitize a community name for use as an Obsidian tag.
Obsidian tags only allow alphanumerics, hyphens, underscores, and slashes.
Spaces become underscores; everything else is stripped.
"""
return re.sub(r"[^a-zA-Z0-9_\-/]", "", name.replace(" ", "_"))
def _strip_diacritics(text: str | None) -> str:
import unicodedata
if not isinstance(text, str):
text = "" if text is None else str(text)
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c))
def _yaml_str(s: str) -> str:
"""Escape a value for safe embedding in a YAML double-quoted scalar (F-009).
See `graphify.ingest._yaml_str` for the full rationale; duplicated here to
avoid pulling the URL-fetching `ingest` module into export's dependency
graph. Handles backslash, double-quote, all line breaks (\\n, \\r,
U+2028, U+2029), tab, NUL, and other C0/DEL control characters that
would otherwise let a hostile `source_file` / `community` / etc. break
out of the YAML scalar and inject sibling keys.
"""
if s is None:
return ""
out: list[str] = []
for ch in str(s):
cp = ord(ch)
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif ch == "\0":
out.append("\\0")
elif cp == 0x2028:
out.append("\\L")
elif cp == 0x2029:
out.append("\\P")
elif cp < 0x20 or cp == 0x7F:
out.append(f"\\x{cp:02x}")
else:
out.append(ch)
return "".join(out)
from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
from graphify.exporters.html import to_html # noqa: E402,F401
_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2}
def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
"""Store hyperedges in the graph's metadata dict."""
existing = G.graph.get("hyperedges", [])
seen_ids = {h["id"] for h in existing}
for h in hyperedges:
if h.get("id") and h["id"] not in seen_ids:
existing.append(h)
seen_ids.add(h["id"])
G.graph["hyperedges"] = existing
def _git_head() -> str | None:
"""Return the current git HEAD commit hash, or None if not in a git repo."""
import subprocess as _sp
try:
r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3)
return r.stdout.strip() if r.returncode == 0 else None
except Exception:
return None
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:
# Safety check: refuse to silently shrink an existing graph (#479)
existing_path = Path(output_path)
if not force and existing_path.exists():
from graphify.security import check_graph_file_size_cap
try:
check_graph_file_size_cap(existing_path)
except Exception:
# Existing graph.json trips the size cap; reading it to compare would
# be the very DoS the cap guards against. Can't verify — let the new
# graph replace the oversized file.
oversized = True
else:
oversized = False
if not oversized:
try:
raw = existing_path.read_text(encoding="utf-8")
except Exception:
raw = ""
if not raw.strip():
# Empty/whitespace existing file (e.g. a freshly touched path):
# no nodes to lose, so any new graph is a growth — proceed.
existing_n = 0
else:
try:
existing_data = json.loads(raw)
existing_n = len(existing_data.get("nodes", []))
except Exception as exc:
# Non-empty but unparseable existing graph (corrupt or a
# mid-write): we cannot verify the new graph is not a silent
# shrink. Fail SAFE — refuse rather than overwrite. A
# fail-OPEN here (the prior behavior) is the silent data-loss
# path #479 exists to prevent: a transiently unreadable
# graph.json would let a partial rebuild clobber a good one.
import sys as _sys
print(
f"[graphify] WARNING: existing {existing_path} could not be "
f"read to verify the new graph is not smaller ({exc}). "
f"Refusing to overwrite; pass force=True to override.",
file=_sys.stderr,
)
return False
new_n = G.number_of_nodes()
if new_n < existing_n:
import sys as _sys
print(
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
f"graph.json has {existing_n} (net -{existing_n - new_n}). "
f"Refusing to overwrite. Possible causes: missing chunk files from "
f"a previous session, or fuzzy dedup collapsed same-named symbols "
f"across files during an --update on an already-current graph. "
f"Run a full rebuild (/graphify .) to be safe, or pass force=True "
f"only if you have verified the reduction is legitimate.",
file=_sys.stderr,
)
return False
node_community = _node_community_map(communities)
_labels: dict[int, str] = {int(k): v for k, v in (community_labels or {}).items()}
try:
data = json_graph.node_link_data(G, edges="links")
except TypeError:
data = json_graph.node_link_data(G)
for node in data["nodes"]:
cid = node_community.get(node["id"])
node["community"] = cid
if cid is not None and _labels:
node["community_name"] = _labels.get(cid, f"Community {cid}")
node["norm_label"] = _strip_diacritics(node.get("label", "")).lower()
for link in data["links"]:
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", [])
commit = built_at_commit if built_at_commit is not None else _git_head()
if commit:
data["built_at_commit"] = commit
with open(output_path, "w", encoding="utf-8") as f: # nosec
json.dump(data, f, indent=2)
return True
def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]:
"""Remove edges whose source or target node is not in the node set.
Returns the cleaned graph_data dict and the number of pruned edges.
"""
node_ids = {n["id"] for n in graph_data["nodes"]}
links_key = "links" if "links" in graph_data else "edges"
before = len(graph_data[links_key])
graph_data[links_key] = [
e for e in graph_data[links_key]
if e["source"] in node_ids and e["target"] in node_ids
]
return graph_data, before - len(graph_data[links_key])
def _cypher_escape(s: str) -> str:
"""Escape a string for safe embedding in a Cypher single-quoted literal.
Handles all characters that could prematurely terminate the literal or
inject control sequences:
- `\\` and `'` (literal terminators)
- newlines/CRs (would break the per-line statement framing)
- NUL/control bytes (defensive — Neo4j errors on raw NULs)
Also strips any leading/trailing whitespace that would let an attacker
break the `;`-terminated statement boundary used by `cypher-shell`.
Closing `}` and `)` are NOT special inside a single-quoted Cypher string,
so escaping the quote and backslash correctly is sufficient (a `}` inside
a properly-closed `'...'` literal is just a character) — but we previously
missed `\\n` / `\\r` which DO let a payload break out of the statement
line and inject a fresh MATCH/DELETE on the following line. See F-008.
"""
# First normalise: drop NUL and other C0 control chars except tab.
s = "".join(ch for ch in s if ch >= " " or ch == "\t")
return (
s.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
)
# Restrict identifier-position values (labels and relationship types are NOT
# quoted in Cypher and so cannot be safely escaped — they must be allowlisted).
_CYPHER_IDENT_RE = re.compile(r"[^A-Za-z0-9_]")
def _cypher_label(raw: str, fallback: str) -> str:
"""Sanitise a value used in identifier position (node label / rel type).
Cypher does not provide a way to escape `:Foo` label syntax, so we must
strip everything except `[A-Za-z0-9_]` and require the result to start
with a letter; otherwise we fall back to a safe constant.
"""
cleaned = _CYPHER_IDENT_RE.sub("", raw or "")
if not cleaned or not cleaned[0].isalpha():
return fallback
return cleaned
def to_cypher(G: nx.Graph, output_path: str) -> None:
lines = ["// Neo4j Cypher import - generated by /graphify", ""]
for node_id, data in G.nodes(data=True):
label = _cypher_escape(data.get("label", node_id))
node_id_esc = _cypher_escape(node_id)
ftype = _cypher_label(
(data.get("file_type", "unknown") or "unknown").capitalize(),
"Entity",
)
lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});")
lines.append("")
for u, v, data in G.edges(data=True):
rel = _cypher_label(
(data.get("relation", "RELATES_TO") or "RELATES_TO").upper(),
"RELATES_TO",
)
conf = _cypher_escape(data.get("confidence", "EXTRACTED"))
u_esc = _cypher_escape(u)
v_esc = _cypher_escape(v)
lines.append(
f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) "
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
)
with open(output_path, "w", encoding="utf-8") as f: # nosec
f.write("\n".join(lines))
# Keep backward-compatible alias - skill.md calls generate_html
generate_html = to_html
def _cap_filename(s: str, limit: int = 200) -> str:
"""Cap a filename stem to ``limit`` UTF-8 bytes so it stays under the 255-byte
filesystem limit even after the ``.md`` extension and dedup suffix are added
(#1094). The cap is on BYTES, not chars, because a label of multibyte
characters (CJK, accented) can exceed 255 bytes well under 255 chars. When
truncation happens, an 8-char hash of the full label is appended so two
distinct labels sharing a long prefix produce distinct, deterministic
filenames instead of colliding."""
b = s.encode("utf-8")
if len(b) <= limit:
return s
digest = hashlib.sha1(s.encode("utf-8")).hexdigest()[:8] # nosec - not security
keep = limit - 9 # "_" + 8 hex chars
truncated = b[:keep].decode("utf-8", "ignore") # "ignore" drops a split trailing char
return f"{truncated}_{digest}"
def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]:
"""Map each node_id to a unique note filename, appending a numeric suffix on
collision. The collision set is keyed on the lowercased name so two labels
differing only by case (e.g. "References" vs "references") still get distinct
filenames - on case-insensitive filesystems (macOS/APFS, Windows/NTFS) they
would otherwise resolve to one path and silently overwrite each other on disk.
The suffixed candidate is itself re-checked, so a generated "base_1" never
silently overwrites a node whose literal label is already "base_1"."""
node_filenames: dict[str, str] = {}
used: set[str] = set()
for node_id, data in G.nodes(data=True):
base = safe_name(data.get("label", node_id))
candidate = base
n = 1
while candidate.lower() in used:
candidate = f"{base}_{n}"
n += 1
used.add(candidate.lower())
node_filenames[node_id] = candidate
return node_filenames
def to_obsidian(
G: nx.Graph,
communities: dict[int, list[str]],
output_dir: str,
community_labels: dict[int, str] | None = None,
cohesion: dict[int, float] | None = None,
) -> int:
"""Export graph as an Obsidian vault - one .md file per node with [[wikilinks]],
plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix).
Open the output directory as a vault in Obsidian to get an interactive
graph view with community colors and full-text search over node metadata.
Returns the number of node notes + community notes written.
"""
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# #1506: when the export target is an existing Obsidian vault (a user pointed
# --obsidian-dir at one), we must not clobber the user's own notes or their
# .obsidian/ config. Track the files graphify owns in a manifest; a pre-existing
# file NOT in the manifest is the user's and is never overwritten.
_manifest_path = out / ".graphify_obsidian_manifest.json"
try:
_owned: set[str] = set(json.loads(_manifest_path.read_text(encoding="utf-8")).get("files", []))
except (OSError, ValueError):
_owned = set()
_written: list[str] = []
_skipped: list[str] = []
def _owned_write(rel_name: str, content: str) -> bool:
"""Write a graphify-owned file, refusing to overwrite a pre-existing file
graphify didn't create. Returns True if written."""
target = out / rel_name
if target.exists() and rel_name not in _owned:
_skipped.append(rel_name)
return False
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8") # nosec
_written.append(rel_name)
return True
node_community = _node_community_map(communities)
# Map node_id → safe filename so wikilinks stay consistent.
# Deduplicate: if two nodes produce the same filename, append a numeric suffix.
def safe_name(label: str) -> str:
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
# Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md"
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
# strip above but is empty once a downstream tool re-slugs on word chars
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
# `qmd update`). Require at least one word char; else fall back so we never
# emit a "@.md"-style filename. (#1409)
if not re.search(r"\w", cleaned, flags=re.UNICODE):
return "unnamed"
return _cap_filename(cleaned)
node_filename = _dedup_node_filenames(G, safe_name)
# Helper: compute dominant confidence for a node across all its edges
def _dominant_confidence(node_id: str) -> str:
confs = []
for u, v, edata in G.edges(node_id, data=True):
confs.append(edata.get("confidence", "EXTRACTED"))
if not confs:
return "EXTRACTED"
return Counter(confs).most_common(1)[0][0]
# Map file_type → graphify tag
_FTYPE_TAG = {
"code": "graphify/code",
"document": "graphify/document",
"paper": "graphify/paper",
"image": "graphify/image",
}
# Write one .md file per node
node_notes_written = 0
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id)
cid = node_community.get(node_id)
community_name = (
community_labels.get(cid, f"Community {cid}")
if community_labels and cid is not None
else f"Community {cid}"
)
# Build tags for this node
ftype = data.get("file_type", "")
ftype_tag = _FTYPE_TAG.get(ftype, f"graphify/{ftype}" if ftype else "graphify/document")
dom_conf = _dominant_confidence(node_id)
conf_tag = f"graphify/{dom_conf}"
comm_tag = f"community/{_obsidian_tag(community_name)}"
node_tags = [ftype_tag, conf_tag, comm_tag]
lines: list[str] = []
# YAML frontmatter - readable in Obsidian's properties panel.
# All scalars pass through _yaml_str so a hostile source_file or
# community label cannot break out and inject sibling keys (F-009).
lines += [
"---",
f'source_file: "{_yaml_str(data.get("source_file", ""))}"',
f'type: "{_yaml_str(ftype)}"',
f'community: "{_yaml_str(community_name)}"',
]
if data.get("source_location"):
lines.append(f'location: "{_yaml_str(str(data["source_location"]))}"')
# Add tags list to frontmatter
lines.append("tags:")
for tag in node_tags:
lines.append(f" - {tag}")
lines += ["---", "", f"# {label}", ""]
# Outgoing edges as wikilinks
neighbors = list(G.neighbors(node_id))
if neighbors:
lines.append("## Connections")
for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
edata = edge_data(G, node_id, neighbor)
neighbor_label = node_filename[neighbor]
relation = edata.get("relation", "")
confidence = edata.get("confidence", "EXTRACTED")
lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]")
lines.append("")
# Inline tags at bottom of note body (for Obsidian tag panel)
inline_tags = " ".join(f"#{t}" for t in node_tags)
lines.append(inline_tags)
fname = node_filename[node_id] + ".md"
if _owned_write(fname, "\n".join(lines)):
node_notes_written += 1
# Write one _COMMUNITY_name.md overview note per community
# Build inter-community edge counts for "Connections to other communities"
inter_community_edges: dict[int, dict[int, int]] = {}
for cid in communities:
inter_community_edges[cid] = {}
for u, v in G.edges():
cu = node_community.get(u)
cv = node_community.get(v)
if cu is not None and cv is not None and cu != cv:
inter_community_edges.setdefault(cu, {})
inter_community_edges.setdefault(cv, {})
inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1
inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1
# Precompute per-node community reach (number of distinct communities a node connects to)
def _community_reach(node_id: str) -> int:
neighbor_cids = {
node_community[nb]
for nb in G.neighbors(node_id)
if nb in node_community and node_community[nb] != node_community.get(node_id)
}
return len(neighbor_cids)
def _community_name(cid) -> str:
return (
community_labels.get(cid, f"Community {cid}")
if community_labels and cid is not None
else f"Community {cid}"
)
# One case-folded-deduped filename per community, computed once so the note we
# write and every [[_COMMUNITY_...]] cross-reference resolve to the same file.
# Two community labels differing only by case (e.g. LLM labels "API" vs "Api")
# would otherwise overwrite each other on case-insensitive filesystems - and
# this path had no dedup at all, so even same-case duplicate labels collided.
community_filename: dict = {}
used_community: set[str] = set()
for cid in communities:
base = f"_COMMUNITY_{safe_name(_community_name(cid))}"
candidate = base
n = 1
while candidate.lower() in used_community:
candidate = f"{base}_{n}"
n += 1
used_community.add(candidate.lower())
community_filename[cid] = candidate
community_notes_written = 0
for cid, all_members in communities.items():
community_name = _community_name(cid)
# A community's member list can contain ids with no backing node in G
# (e.g. pruned nodes, stale community assignments from a prior run, or
# synthesized/merge-artifact ids). Dereferencing those via G.nodes[n] or
# node_filename[n] raises KeyError and aborts the whole vault export, so
# skip dangling members rather than crashing (issue #1236).
members = [m for m in all_members if m in G and m in node_filename]
n_members = len(members)
coh_value = cohesion.get(cid) if cohesion else None
lines: list[str] = []
# YAML frontmatter
lines.append("---")
lines.append("type: community")
if coh_value is not None:
lines.append(f"cohesion: {coh_value:.2f}")
lines.append(f"members: {n_members}")
lines.append("---")
lines.append("")
lines.append(f"# {community_name}")
lines.append("")
# Cohesion + member count summary
if coh_value is not None:
cohesion_desc = (
"tightly connected" if coh_value >= 0.7
else "moderately connected" if coh_value >= 0.4
else "loosely connected"
)
lines.append(f"**Cohesion:** {coh_value:.2f} - {cohesion_desc}")
lines.append(f"**Members:** {n_members} nodes")
lines.append("")
# Members section
lines.append("## Members")
for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)):
data = G.nodes[node_id]
node_label = node_filename[node_id]
ftype = data.get("file_type", "")
source = data.get("source_file", "")
entry = f"- [[{node_label}]]"
if ftype:
entry += f" - {ftype}"
if source:
entry += f" - {source}"
lines.append(entry)
lines.append("")
# Dataview live query (improvement 2)
comm_tag_name = _obsidian_tag(community_name)
lines.append("## Live Query (requires Dataview plugin)")
lines.append("")
lines.append("```dataview")
lines.append(f"TABLE source_file, type FROM #community/{comm_tag_name}")
lines.append("SORT file.name ASC")
lines.append("```")
lines.append("")
# Connections to other communities
cross = inter_community_edges.get(cid, {})
if cross:
lines.append("## Connections to other communities")
for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
other_fname = community_filename.get(other_cid) or f"_COMMUNITY_{safe_name(_community_name(other_cid))}"
lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[{other_fname}]]")
lines.append("")
# Top bridge nodes - highest degree nodes that connect to other communities
bridge_nodes = [
(node_id, G.degree(node_id), _community_reach(node_id))
for node_id in members
if _community_reach(node_id) > 0
]
bridge_nodes.sort(key=lambda x: (-x[2], -x[1]))
top_bridges = bridge_nodes[:5]
if top_bridges:
lines.append("## Top bridge nodes")
for node_id, degree, reach in top_bridges:
node_label = node_filename[node_id]
lines.append(
f"- [[{node_label}]] - degree {degree}, connects to {reach} "
f"{'community' if reach == 1 else 'communities'}"
)
fname = community_filename[cid] + ".md"
if _owned_write(fname, "\n".join(lines)):
community_notes_written += 1
# Improvement 4: write .obsidian/graph.json to color nodes by community in graph
# view — but never clobber an existing .obsidian/graph.json graphify doesn't own
# (the user's graph-view settings live there). _owned_write handles that and
# creates the .obsidian/ dir only when it actually writes.
graph_config = {
"colorGroups": [
{
"query": f"tag:#community/{label.replace(' ', '_')}",
"color": {"a": 1, "rgb": int(COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)].lstrip('#'), 16)}
}
for cid, label in sorted((community_labels or {}).items())
]
}
_owned_write(".obsidian/graph.json", json.dumps(graph_config, indent=2))
# Persist the manifest of files graphify owns, so a re-run can safely update its
# own notes while still refusing to touch the user's. Warn (once, aggregated)
# about anything skipped to avoid clobbering a pre-existing file.
try:
_manifest_path.write_text(json.dumps({"files": sorted(set(_written))}, indent=2), encoding="utf-8")
except OSError:
pass
if _skipped:
shown = ", ".join(_skipped[:5]) + (f" (+{len(_skipped) - 5} more)" if len(_skipped) > 5 else "")
print(
f"[graphify] WARNING: skipped {len(_skipped)} pre-existing file(s) graphify "
f"did not create, to avoid overwriting your notes: {shown}. "
f"Export into an empty directory (or the default graphify-out/obsidian) "
f"to get the full vault.",
file=sys.stderr,
)
return node_notes_written + community_notes_written
def to_canvas(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
node_filenames: dict[str, str] | None = None,
) -> None:
"""Export graph as an Obsidian Canvas file - communities as groups, nodes as cards.
Generates a structured layout: communities arranged in a grid, nodes within
each community arranged in rows. Edges shown between connected nodes.
Opens in Obsidian as an infinite canvas with community groupings visible.
"""
# Obsidian canvas color codes (cycle through for communities)
CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple
def safe_name(label: str) -> str:
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
# strip above but is empty once a downstream tool re-slugs on word chars
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
# `qmd update`). Require at least one word char; else fall back so we never
# emit a "@.md"-style filename. (#1409)
if not re.search(r"\w", cleaned, flags=re.UNICODE):
return "unnamed"
return _cap_filename(cleaned)
# Build node_filenames if not provided (same dedup logic as to_obsidian)
if node_filenames is None:
node_filenames = _dedup_node_filenames(G, safe_name)
# Fallback: with no community data (e.g. --no-cluster builds or a missing
# analysis sidecar) the grid below produces nothing and the canvas is written
# as an empty 32-byte shell on an otherwise populated graph. Emit every node
# into one synthetic community so the canvas always reflects the graph (#1324).
if not communities and G.number_of_nodes() > 0:
communities = {0: [str(n) for n in G.nodes()]}
num_communities = len(communities)
cols = math.ceil(math.sqrt(num_communities)) if num_communities > 0 else 1
rows = math.ceil(num_communities / cols) if num_communities > 0 else 1
canvas_nodes: list[dict] = []
canvas_edges: list[dict] = []
# Lay out communities in a grid
gap = 80
group_x_offsets: list[int] = []
group_y_offsets: list[int] = []
# Precompute group sizes so we can calculate offsets.
# inner_cols is the per-community grid width; the box dimensions AND the node
# placement loop below both derive from it, so the cards always fill the box
# instead of wrapping into a narrow strip inside an oversized box.
sorted_cids = sorted(communities.keys())
group_sizes: dict[int, tuple[int, int]] = {}
group_cols: dict[int, int] = {}
for cid in sorted_cids:
# Skip dangling community members with no backing node / filename, so box
# sizing matches the cards actually laid out and `G.nodes[m]` never
# KeyErrors below — mirrors the to_obsidian guard (#1236).
members = [m for m in communities[cid] if m in G and m in node_filenames]
n = len(members)
inner_cols = max(1, math.ceil(math.sqrt(n)))
w = max(600, 220 * inner_cols)
h = max(400, 100 * math.ceil(n / inner_cols) + 120)
group_sizes[cid] = (w, h)
group_cols[cid] = inner_cols
# Compute cumulative row heights and col widths for grid placement
# Each grid cell uses the max width/height in its col/row
col_widths: list[int] = []
row_heights: list[int] = []
for col_idx in range(cols):
max_w = 0
for row_idx in range(rows):
linear = row_idx * cols + col_idx
if linear < len(sorted_cids):
cid = sorted_cids[linear]
w, _ = group_sizes[cid]
max_w = max(max_w, w)
col_widths.append(max_w)
for row_idx in range(rows):
max_h = 0
for col_idx in range(cols):
linear = row_idx * cols + col_idx
if linear < len(sorted_cids):
cid = sorted_cids[linear]
_, h = group_sizes[cid]
max_h = max(max_h, h)
row_heights.append(max_h)
# Map from cid → (group_x, group_y, group_w, group_h)
group_layout: dict[int, tuple[int, int, int, int]] = {}
for idx, cid in enumerate(sorted_cids):
col_idx = idx % cols
row_idx = idx // cols
gx = sum(col_widths[:col_idx]) + col_idx * gap
gy = sum(row_heights[:row_idx]) + row_idx * gap
gw, gh = group_sizes[cid]
group_layout[cid] = (gx, gy, gw, gh)
# Build set of all node_ids in canvas for edge filtering
all_canvas_nodes: set[str] = set()
for members in communities.values():
all_canvas_nodes.update(members)
# Generate group and node canvas entries
for idx, cid in enumerate(sorted_cids):
members = communities[cid]
community_name = (
community_labels.get(cid, f"Community {cid}")
if community_labels and cid is not None
else f"Community {cid}"
)
gx, gy, gw, gh = group_layout[cid]
canvas_color = CANVAS_COLORS[idx % len(CANVAS_COLORS)]
# Group node
canvas_nodes.append({
"id": f"g{cid}",
"type": "group",
"label": community_name,
"x": gx,
"y": gy,
"width": gw,
"height": gh,
"color": canvas_color,
})
# Node cards inside the group - laid out in the same ceil(sqrt(n))-column
# grid the box was sized for (group_cols[cid]), so cards fill the box.
inner_cols = group_cols[cid]
# Same dangling-member guard as the sizing loop and to_obsidian (#1236):
# a community id absent from G / node_filenames would KeyError the sort.
members = [m for m in members if m in G and m in node_filenames]
sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
for m_idx, node_id in enumerate(sorted_members):
col = m_idx % inner_cols
row = m_idx // inner_cols
nx_x = gx + 20 + col * (180 + 20)
nx_y = gy + 80 + row * (60 + 20)
fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id)))
canvas_nodes.append({
"id": f"n_{node_id}",
"type": "file",
"file": f"{fname}.md",
"x": nx_x,
"y": nx_y,
"width": 180,
"height": 60,
})
# Generate edges - only between nodes both in canvas, cap at 200 highest-weight
all_edges_weighted: list[tuple[float, str, str, str]] = []
for u, v, edata in G.edges(data=True):
if u in all_canvas_nodes and v in all_canvas_nodes:
weight = edata.get("weight", 1.0)
relation = edata.get("relation", "")
conf = edata.get("confidence", "EXTRACTED")
label = f"{relation} [{conf}]" if relation else f"[{conf}]"
all_edges_weighted.append((weight, u, v, label))
all_edges_weighted.sort(key=lambda x: -x[0])
for weight, u, v, label in all_edges_weighted[:200]:
canvas_edges.append({
"id": f"e_{u}_{v}",
"fromNode": f"n_{u}",
"toNode": f"n_{v}",
"label": label,
})
canvas_data = {"nodes": canvas_nodes, "edges": canvas_edges}
Path(output_path).write_text(json.dumps(canvas_data, indent=2), encoding="utf-8") # nosec
def to_graphml(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
) -> None:
"""Export graph as GraphML - opens in Gephi, yEd, and any GraphML-compatible tool.
Community IDs are written as a node attribute so Gephi can colour by community.
Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute.
"""
H = G.copy()
node_community = _node_community_map(communities)
for node_id in H.nodes():
H.nodes[node_id]["community"] = node_community.get(node_id, -1)
# Drop internal markers (e.g. the AST-provenance "_origin" tag, #1116, and
# the "_src"/"_tgt" direction markers) — they are persistence/runtime details,
# not graph data, and should not leak into the exported file.
for _, attrs in H.nodes(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
for _, _, attrs in H.edges(data=True):
for k in [k for k in attrs if k.startswith("_")]:
del attrs[k]
# nx.write_graphml raises ValueError on None attribute values; replace with "".
for node_id in H.nodes():
for key, val in list(H.nodes[node_id].items()):
if val is None:
H.nodes[node_id][key] = ""
for u, v in H.edges():
for key, val in list(H.edges[u, v].items()):
if val is None:
H.edges[u, v][key] = ""
nx.write_graphml(H, output_path)
def to_svg(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
figsize: tuple[int, int] = (20, 14),
) -> None:
"""Export graph as an SVG file using matplotlib + spring layout.
Lightweight and embeddable - works in Obsidian notes, Notion, GitHub READMEs,
and any markdown renderer. No JavaScript required.
Node size scales with degree. Community colors match the HTML output.
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
except ImportError as e:
raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e
node_community = _node_community_map(communities)
fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
ax.set_facecolor("#1a1a2e")
ax.axis("off")
pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1))
degree = dict(G.degree())
max_deg = max(degree.values(), default=1) or 1
node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()]
node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()]
# Draw edges - dashed for non-EXTRACTED
for u, v, data in G.edges(data=True):
conf = data.get("confidence", "EXTRACTED")
style = "solid" if conf == "EXTRACTED" else "dashed"
alpha = 0.6 if conf == "EXTRACTED" else 0.3
x0, y0 = pos[u]
x1, y1 = pos[v]
ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8,
linestyle=style, alpha=alpha, zorder=1)
nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
node_size=node_sizes, alpha=0.9)
nx.draw_networkx_labels(G, pos, ax=ax,
labels={n: G.nodes[n].get("label", n) for n in G.nodes()},
font_size=7, font_color="white")
# Legend
if community_labels:
patches = [
mpatches.Patch(
color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)],
label=f"{label} ({len(communities.get(cid, []))})",
)
for cid, label in sorted(community_labels.items())
]
ax.legend(handles=patches, loc="upper left", framealpha=0.7,
facecolor="#2a2a4e", labelcolor="white", fontsize=8)
plt.tight_layout()
plt.savefig(output_path, format="svg", bbox_inches="tight",
facecolor=fig.get_facecolor())
plt.close(fig)
+1
View File
@@ -0,0 +1 @@
"""exporters package."""
+14
View File
@@ -0,0 +1,14 @@
"""Shared constants/helpers for the graphify exporters package.
Symbols used by more than one exporter live here so each exporter module can be
split out of graphify/export.py without a circular import (export.py and the
per-format modules both import from here, never from each other).
"""
from __future__ import annotations
# Categorical palette for community coloring, shared by the HTML, SVG, and
# Obsidian exporters. Moved verbatim from graphify/export.py.
COMMUNITY_COLORS = [
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
]
+173
View File
@@ -0,0 +1,173 @@
"""graphdb — moved verbatim from graphify/export.py."""
from __future__ import annotations
from graphify.analyze import _node_community_map
import networkx as nx
import re
def push_to_neo4j(
G: nx.Graph,
uri: str,
user: str,
password: str,
communities: dict[int, list[str]] | None = None,
) -> dict[str, int]:
"""Push graph directly to a running Neo4j instance via the Python driver.
Requires: pip install neo4j
Uses MERGE so re-running is safe - nodes and edges are upserted, not duplicated.
Returns a dict with counts of nodes and edges pushed.
"""
try:
from neo4j import GraphDatabase
except ImportError as e:
raise ImportError(
"neo4j driver not installed. Run: pip install neo4j"
) from e
node_community = _node_community_map(communities) if communities else {}
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
def _safe_label(label: str) -> str:
"""Sanitize a Neo4j node label to prevent Cypher injection."""
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
return sanitized if sanitized else "Entity"
driver = GraphDatabase.driver(uri, auth=(user, password))
nodes_pushed = 0
edges_pushed = 0
with driver.session() as session:
for node_id, data in G.nodes(data=True):
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
props["community"] = cid
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
session.run(
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
id=node_id,
props=props,
)
nodes_pushed += 1
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
session.run(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
src=u,
tgt=v,
props=props,
)
edges_pushed += 1
driver.close()
return {"nodes": nodes_pushed, "edges": edges_pushed}
def push_to_falkordb(
G: nx.Graph,
uri: str,
user: str | None = None,
password: str | None = None,
communities: dict[int, list[str]] | None = None,
graph_name: str = "graphify",
) -> dict[str, int]:
"""Push graph directly to a running FalkorDB instance via the Python SDK.
Requires: pip install falkordb
FalkorDB is OpenCypher-compatible, so the MERGE/SET upsert queries are
identical to push_to_neo4j. Differences from the Neo4j path:
- connects with FalkorDB(host, port, username, password) instead of a bolt
driver; only the host/port are read from the URI, so the scheme is
informational - "falkordb://localhost:6379", "redis://localhost:6379"
and a bare "localhost:6379" are all equivalent (default port 6379).
- a named graph is selected via db.select_graph(graph_name) (default
"graphify"); FalkorDB keys each graph by name in the same instance.
- queries run via graph.query(cypher, params) - there is no session object.
- auth is optional (FalkorDB runs without credentials by default), so user
and password may be None.
- no APOC: the Neo4j path does not use APOC either, so nothing to port.
Uses MERGE so re-running is safe - nodes and edges are upserted, not
duplicated. Returns a dict with counts of nodes and edges pushed.
"""
try:
from falkordb import FalkorDB
except ImportError as e:
raise ImportError(
"falkordb SDK not installed. Run: pip install falkordb"
) from e
from urllib.parse import urlparse
node_community = _node_community_map(communities) if communities else {}
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
def _safe_label(label: str) -> str:
"""Sanitize a FalkorDB node label to prevent Cypher injection."""
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
return sanitized if sanitized else "Entity"
parsed = urlparse(uri if "://" in uri else f"redis://{uri}")
# FalkorDB auth is optional. Only send credentials when a password is
# provided; otherwise connect anonymously and ignore any bolt-style default
# username (e.g. Neo4j's "neo4j"), which FalkorDB rejects as an unknown ACL
# user. Credentials embedded in the URI take precedence over the args.
connect_user = parsed.username or (user if password else None)
connect_password = parsed.password or (password or None)
db = FalkorDB(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
username=connect_user,
password=connect_password,
)
graph = db.select_graph(graph_name)
nodes_pushed = 0
edges_pushed = 0
for node_id, data in G.nodes(data=True):
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
props["community"] = cid
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
graph.query(
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
{"id": node_id, "props": props},
)
nodes_pushed += 1
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
graph.query(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
{"src": u, "tgt": v, "props": props},
)
edges_pushed += 1
return {"nodes": nodes_pushed, "edges": edges_pushed}
+547
View File
@@ -0,0 +1,547 @@
"""html — moved verbatim from graphify/export.py."""
from __future__ import annotations
from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
from pathlib import Path
import html as _html
from graphify.analyze import _node_community_map
import json
import networkx as nx
from graphify.security import sanitize_label
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; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
#graph { flex: 1; }
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
#search:focus { border-color: #4E79A7; }
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-item:hover { background: #2a2a4e; }
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
#info-content .field { margin-bottom: 5px; }
#info-content .field b { color: #e0e0e0; }
#info-content .empty { color: #555; font-style: italic; }
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
.neighbor-link:hover { background: #2a2a4e; }
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
.legend-item.dimmed { opacity: 0.35; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.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; 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>"""
def _hyperedge_script(hyperedges_json: str) -> str:
return f"""<script>
// Render hyperedges as shaded regions
const hyperedges = {hyperedges_json};
// afterDrawing passes ctx already transformed to network coordinate space.
// Draw node positions raw — no manual pan/zoom/DPR math needed.
network.on('afterDrawing', function(ctx) {{
hyperedges.forEach(h => {{
const positions = h.nodes
.map(nid => network.getPositions([nid])[nid])
.filter(p => p !== undefined);
if (positions.length < 2) return;
ctx.save();
ctx.globalAlpha = 0.12;
ctx.fillStyle = '#6366f1';
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.beginPath();
// Centroid and expanded hull in network coordinates
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
const expanded = positions.map(p => ({{
x: cx + (p.x - cx) * 1.15,
y: cy + (p.y - cy) * 1.15
}}));
ctx.moveTo(expanded[0].x, expanded[0].y);
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 0.4;
ctx.stroke();
// Label
ctx.globalAlpha = 0.8;
ctx.fillStyle = '#4f46e5';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(h.label, cx, cy - 5);
ctx.restore();
}});
}});
</script>"""
def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
return f"""<script>
const RAW_NODES = {nodes_json};
const RAW_EDGES = {edges_json};
const LEGEND = {legend_json};
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {{
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}}
// Build vis datasets
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
id: n.id, label: n.label, color: n.color, size: n.size,
font: n.font, title: n.title,
_community: n.community, _community_name: n.community_name,
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
}})));
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
id: i, from: e.from, to: e.to,
label: '',
title: e.title,
dashes: e.dashes,
width: e.width,
color: e.color,
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
}})));
const container = document.getElementById('graph');
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
physics: {{
enabled: true,
solver: 'forceAtlas2Based',
forceAtlas2Based: {{
gravitationalConstant: -60,
centralGravity: 0.005,
springLength: 120,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0.8,
}},
stabilization: {{ iterations: 200, fit: true }},
}},
interaction: {{
hover: true,
tooltipDelay: 100,
hideEdgesOnDrag: true,
navigationButtons: false,
keyboard: false,
}},
nodes: {{ shape: 'dot', borderWidth: 1.5 }},
edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
}});
network.once('stabilizationIterationsDone', () => {{
network.setOptions({{ physics: {{ enabled: false }} }});
}});
function showInfo(nodeId) {{
const n = nodesDS.get(nodeId);
if (!n) return;
const neighborIds = network.getConnectedNodes(nodeId);
const neighborItems = neighborIds.map(nid => {{
const nb = nodesDS.get(nid);
const color = nb ? nb.color.background : '#555';
return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" onclick="focusNode(${{JSON.stringify(nid)}})">${{esc(nb ? nb.label : nid)}}</span>`;
}}).join('');
document.getElementById('info-content').innerHTML = `
<div class="field"><b>${{esc(n.label)}}</b></div>
<div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
<div class="field">Community: ${{esc(n._community_name)}}</div>
<div class="field">Source: ${{esc(n._source_file || '-')}}</div>
<div class="field">Degree: ${{n._degree}}</div>
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
`;
}}
function focusNode(nodeId) {{
network.focus(nodeId, {{ scale: 1.4, animation: true }});
network.selectNodes([nodeId]);
showInfo(nodeId);
}}
// Track hovered node — hover detection is more reliable than click params
let hoveredNodeId = null;
network.on('hoverNode', params => {{
hoveredNodeId = params.node;
container.style.cursor = 'pointer';
}});
network.on('blurNode', () => {{
hoveredNodeId = null;
container.style.cursor = 'default';
}});
container.addEventListener('click', () => {{
if (hoveredNodeId !== null) {{
showInfo(hoveredNodeId);
network.selectNodes([hoveredNodeId]);
}}
}});
network.on('click', params => {{
if (params.nodes.length > 0) {{
showInfo(params.nodes[0]);
}} else if (hoveredNodeId === null) {{
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
}}
}});
const searchInput = document.getElementById('search');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('input', () => {{
const q = searchInput.value.toLowerCase().trim();
searchResults.innerHTML = '';
if (!q) {{ searchResults.style.display = 'none'; return; }}
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
searchResults.style.display = 'block';
matches.forEach(n => {{
const el = document.createElement('div');
el.className = 'search-item';
el.textContent = n.label;
el.style.borderLeft = `3px solid ${{n.color.background}}`;
el.style.paddingLeft = '8px';
el.onclick = () => {{
network.focus(n.id, {{ scale: 1.5, animation: true }});
network.selectNodes([n.id]);
showInfo(n.id);
searchResults.style.display = 'none';
searchInput.value = '';
}};
searchResults.appendChild(el);
}});
}});
document.addEventListener('click', e => {{
if (!searchResults.contains(e.target) && e.target !== searchInput)
searchResults.style.display = 'none';
}});
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';
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 {{
hiddenCommunities.add(c.cid);
item.classList.add('dimmed');
}}
const updates = RAW_NODES
.filter(n => n.community === 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);
}});
</script>"""
def to_html(
G: nx.Graph,
communities: dict[int, list[str]],
output_path: str,
community_labels: dict[int, str] | None = None,
member_counts: dict[int, int] | None = None,
node_limit: int | None = None,
learning_overlay: dict | None = None,
) -> None:
"""Generate an interactive vis.js HTML visualization of the graph.
Features: node size by degree, click-to-inspect panel, search box,
community filter, physics clustering by community, confidence-styled edges.
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.
If node_limit is set and the graph exceeds it, automatically builds an
aggregated community-level meta-graph instead of raising ValueError.
"""
limit = node_limit if node_limit is not None else _viz_node_limit()
if G.number_of_nodes() > limit:
if node_limit is not None:
# Build aggregated community meta-graph
from collections import Counter as _Counter
import networkx as _nx
print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...")
node_to_community = {nid: cid for cid, members in communities.items() for nid in members}
meta = _nx.Graph()
for cid, members in communities.items():
meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))
edge_counts = _Counter()
for u, v in G.edges():
cu, cv = node_to_community.get(u), node_to_community.get(v)
if cu is not None and cv is not None and cu != cv:
edge_counts[(min(cu, cv), max(cu, cv))] += 1
for (cu, cv), w in edge_counts.items():
meta.add_edge(str(cu), str(cv), weight=w,
relation=f"{w} cross-community edges", confidence="AGGREGATED")
if meta.number_of_nodes() <= 1:
print("Single community - aggregated view not useful. Skipping graph.html.")
return
meta_communities = {cid: [str(cid)] for cid in communities}
mc = {cid: len(members) for cid, members in communities.items()}
# Remap hyperedges from semantic node IDs to community IDs
raw_hyperedges = G.graph.get("hyperedges", [])
if raw_hyperedges:
remapped = []
for he in raw_hyperedges:
he_members = he.get("nodes", [])
comm_ids, seen = [], set()
for nid in he_members:
c = node_to_community.get(nid)
if c is None:
continue
s = str(c)
if s in seen:
continue
seen.add(s)
comm_ids.append(s)
if len(comm_ids) < 2:
continue
remapped.append({
"id": he.get("id", ""),
"label": he.get("label") or he.get("relation", "").replace("_", " "),
"nodes": comm_ids,
})
meta.graph["hyperedges"] = remapped
to_html(meta, meta_communities, output_path,
community_labels=community_labels, member_counts=mc)
print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
print("Tip: run with --obsidian for full node-level detail.")
return
raise ValueError(
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)
degree = dict(G.degree())
max_deg = max(degree.values(), default=1) or 1
max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1
# Work-memory overlay (derived sidecar). When not passed explicitly, load it
# best-effort from the sibling .graphify_learning.json next to the output
# graph.html (which lives beside graph.json). Empty/missing => no learning
# fields, so the un-annotated render is byte-identical to pre-feature.
if learning_overlay is None:
learning_overlay = {}
try:
from graphify.reflect import load_learning_overlay as _llo
learning_overlay = _llo(Path(output_path))
except Exception:
learning_overlay = {}
# Status -> ring color. preferred=green, contested=amber. Tentative gets no
# ring (it's not yet trustworthy enough to highlight in the map).
_RING = {"preferred": "#22c55e", "contested": "#f59e0b"}
# Build nodes list for vis.js
vis_nodes = []
for node_id, data in G.nodes(data=True):
cid = node_community.get(node_id, 0)
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
label = sanitize_label(data.get("label", node_id))
deg = degree.get(node_id, 1)
if member_counts:
mc = member_counts.get(cid, 1)
size = 10 + 30 * (mc / max_mc)
font_size = 12
else:
size = 10 + 30 * (deg / max_deg)
# Only show label for high-degree nodes by default; others show on hover
font_size = 12 if deg >= max_deg * 0.15 else 0
node = {
"id": node_id,
"label": label,
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
"size": round(size, 1),
"font": {"size": font_size, "color": "#ffffff"},
"title": _html.escape(label),
"community": cid,
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
"source_file": sanitize_label(str(data.get("source_file") or "")),
"file_type": data.get("file_type", ""),
"degree": deg,
}
# Conditional learning fields — only present for annotated nodes, so
# un-annotated output keeps the exact pre-feature node dict shape.
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
if entry:
status = sanitize_label(str(entry.get("status", "")))
stale = bool(entry.get("stale"))
node["learning_status"] = status
node["learning_stale"] = stale
ring = _RING.get(status)
if ring:
# Status-colored ring via the border; stale => desaturated +
# dashed (vis.js supports per-node `shapeProperties.borderDashes`).
if stale:
ring = "#9ca3af"
node["shapeProperties"] = {"borderDashes": [4, 4]}
node["borderWidth"] = 3
node["color"] = {
"background": color, "border": ring,
"highlight": {"background": "#ffffff", "border": ring},
}
# Lesson line appended to the hover title.
if status == "contested":
lesson = f"Lesson: contested (useful {entry.get('uses', 0)} / dead-end {entry.get('neg', 0)})"
elif status == "preferred":
lesson = f"Lesson: preferred source ({entry.get('uses', 0)} useful, score={entry.get('score', 0)})"
else:
lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
if stale:
lesson += " [code changed — re-verify]"
node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
vis_nodes.append(node)
# 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": true_src,
"to": true_tgt,
"label": relation,
"title": _html.escape(f"{relation} [{confidence}]"),
"dashes": confidence != "EXTRACTED",
"width": 2 if confidence == "EXTRACTED" else 1,
"color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
"confidence": confidence,
})
# Build community legend data
legend_data = []
for cid in sorted((community_labels or {}).keys()):
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}")))
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
# Escape </script> sequences so embedded JSON cannot break out of the script tag
def _js_safe(obj) -> str:
return json.dumps(obj).replace("</", "<\\/")
nodes_json = _js_safe(vis_nodes)
edges_json = _js_safe(vis_edges)
legend_json = _js_safe(legend_data)
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
title = _html.escape(sanitize_label(str(output_path)))
stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>graphify - {title}</title>
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
crossorigin="anonymous"></script>
{_html_styles()}
</head>
<body>
<div id="graph"></div>
<div id="sidebar">
<div id="search-wrap">
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
<div id="search-results"></div>
</div>
<div id="info-panel">
<h3>Node Info</h3>
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
</div>
<div id="legend-wrap">
<h3>Communities</h3>
<div id="legend-controls">
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
</div>
<div id="legend"></div>
</div>
<div id="stats">{stats}</div>
</div>
{_html_script(nodes_json, edges_json, legend_json)}
{_hyperedge_script(hyperedges_json)}
</body>
</html>"""
Path(output_path).write_text(html, encoding="utf-8") # nosec
+4941
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
# Migrating a language extractor out of extract.py
`graphify/extract.py` is being split into this package, one language per PR
(upstream issue #1212). This is the playbook for porting ONE language. It is
written so an AI agent can execute it in a single session.
## Status
| module | migrated |
|---|---|
| blade | yes |
| zig | yes |
| elixir | yes |
| razor | yes |
| dart | yes |
| rust | yes |
| go | yes |
| powershell (ps1 + psd1 manifest) | yes |
| fortran | yes |
| sql | yes |
| dm (dm/dmm/dmi/dmf) | yes |
| bash | yes |
| apex | yes |
| terraform | yes |
| sln | yes |
| pascal_forms (dfm + lfm) | yes |
| json_config | yes |
| (config-driven core: python, js, java, c, cpp, csharp, kotlin, scala, php, lua, swift, groovy, vue, svelte, astro, xaml, groovy) | no — shared _extract_generic core, move as one batch |
| (other bespoke: julia, verilog, markdown, objc, csproj, slnx, lazarus_package, pascal) | no |
Note: config-driven extractors (python, js, java, c, cpp, ruby, csharp,
kotlin, scala, php, lua, swift, groovy) depend on the shared
`_extract_generic` core (~1,300 lines). Do NOT port them one-by-one; the core
must move first as its own coordinated batch. Pick a bespoke extractor.
## Invariants (non-negotiable)
1. **Verbatim moves only.** No renames, no docstring edits, no reformatting,
no added annotations, no "improvements". Verify: save the block before
cutting and confirm the pasted block is byte-identical.
2. **One language per PR.** Small diffs keep review trivial and avoid
conflicts with other in-flight ports.
3. **Facade re-export is mandatory.** `extract.py` must keep exporting every
moved name (`from graphify.extractors.<mod> import extract_<lang> # noqa: F401`
in the marked migration block, kept alphabetical). Existing importers
(`__main__.py`, `watch.py`, `pg_introspect.py`, tests) must not change.
4. **Never import from `graphify.extract` inside this package.** Import
direction is strictly extract.py -> extractors/. If you need a helper that
lives in extract.py, classify it (below) and move it.
5. **Zero test edits** outside `tests/test_extractors_registry.py`. The
untouched language tests passing IS the proof of behavior preservation.
## Helper classification
For every `_name` your function references that is defined OUTSIDE it:
- run `grep -c '_name' graphify/extract.py` AFTER your candidate move;
- remaining uses > 0 -> **shared**: move it to `base.py` and add it to the
facade re-import in extract.py;
- remaining uses = 0 -> **private**: move it into your language module.
Closures, constants, and `import` statements defined INSIDE your function
move with it for free — leave them exactly where they are. Only add a
module-header import for names the pasted code references at module scope
that are not satisfied internally, and verify each header import is used.
## Pre-flight
1. Check upstream for conflicts: open PRs/issues mentioning your language,
and churn: `git log --oneline --since="3 months ago" upstream/<default> | grep -i <lang>`.
High churn -> pick another language.
2. Confirm your extractor is bespoke (its `extract_<lang>` is a full function,
not a 5-line `_extract_generic(path, LanguageConfig(...))` wrapper).
3. Check whether tests/ exercises your language's behavior (grep for
`test_<lang>`). If it has no behavioral tests, the byte-identity check in
step 3 below is the ENTIRE proof of preservation — include the
`git diff --color-moved` evidence in your PR description.
## Steps
1. Append a failing test to `tests/test_extractors_registry.py`:
module import + facade identity + registry identity (copy an existing
`test_<lang>_migrated` as the template).
2. `grep -n 'def extract_<lang>' graphify/extract.py`; the span ends at the
line before the next top-level statement (`^def ` or `^_CONST`). Beware
neighbors: top-level constants AFTER your function may belong to the NEXT
function (e.g. `_CONFIG_JSON_*` sit after where extract_razor used to be
but were never razor's).
3. Save the span to a temp file. Create `graphify/extractors/<lang>.py` with
module docstring (`"""<Lang> extractor. Moved verbatim from graphify/extract.py."""`),
`from __future__ import annotations`, minimal stdlib imports, base imports,
then paste the function. Verify byte-identity against the temp file.
4. Delete the span from extract.py, leaving exactly two blank lines between
the now-adjacent top-level definitions; add the facade re-import; add the
registry entry in `__init__.py` (alphabetical); update the Status table
above.
5. `uv run pytest -q` -> 0 failures, no test file changed except the registry
test. If ImportError/NameError: a helper was misclassified — go to
Helper classification.
6. One commit: `refactor(extract): move extract_<lang> to extractors/<lang>.py (verbatim)`.
## What NOT to do
- Do not rewire dispatch, add classes, or add lazy imports — mechanism layers
come later, by separate agreement (see #1212 discussion).
- Do not port two languages in one PR "while you're at it".
- Do not touch `__main__.py`.
+64
View File
@@ -0,0 +1,64 @@
"""Per-language extractors, incrementally migrated out of graphify/extract.py.
Dispatch still flows through graphify.extract (the facade re-exports every
moved name), so importing from graphify.extract keeps working unchanged.
LANGUAGE_EXTRACTORS is the registry seed; wiring dispatch through it is a
later, separate step. See MIGRATION.md for how to port another language.
"""
from __future__ import annotations
from pathlib import Path
from typing import Callable
from graphify.extractors.apex import extract_apex
from graphify.extractors.bash import extract_bash
from graphify.extractors.blade import extract_blade
from graphify.extractors.dart import extract_dart
from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm
from graphify.extractors.elixir import extract_elixir
from graphify.extractors.fortran import extract_fortran
from graphify.extractors.go import extract_go
from graphify.extractors.json_config import extract_json
from graphify.extractors.julia import extract_julia
from graphify.extractors.markdown import extract_markdown
from graphify.extractors.objc import extract_objc
from graphify.extractors.pascal import extract_pascal
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
from graphify.extractors.razor import extract_razor
from graphify.extractors.rust import extract_rust
from graphify.extractors.sln import extract_sln
from graphify.extractors.sql import extract_sql
from graphify.extractors.terraform import extract_terraform
from graphify.extractors.verilog import extract_verilog
from graphify.extractors.zig import extract_zig
LANGUAGE_EXTRACTORS: dict[str, Callable[[Path], dict]] = {
"apex": extract_apex,
"bash": extract_bash,
"blade": extract_blade,
"dart": extract_dart,
"delphi_form": extract_delphi_form,
"dm": extract_dm,
"dmf": extract_dmf,
"dmi": extract_dmi,
"dmm": extract_dmm,
"elixir": extract_elixir,
"fortran": extract_fortran,
"go": extract_go,
"json": extract_json,
"julia": extract_julia,
"lazarus_form": extract_lazarus_form,
"markdown": extract_markdown,
"objc": extract_objc,
"pascal": extract_pascal,
"powershell": extract_powershell,
"powershell_manifest": extract_powershell_manifest,
"razor": extract_razor,
"rust": extract_rust,
"sln": extract_sln,
"sql": extract_sql,
"terraform": extract_terraform,
"verilog": extract_verilog,
"zig": extract_zig,
}
+215
View File
@@ -0,0 +1,215 @@
"""Apex extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
def extract_apex(path: Path) -> dict:
"""Extract classes, interfaces, enums, methods, and Salesforce constructs from
Apex .cls and .trigger files using regex (no tree-sitter grammar on PyPI)."""
import re as _re
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED") -> None:
edges.append({
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
add_node(file_nid, path.name, 1)
lines = source.splitlines()
_ACCESS = r"(?:public|private|protected|global|webService)?"
_SHARING = r"(?:\s+(?:with|without|inherited)\s+sharing)?"
_MOD = r"(?:\s+(?:abstract|virtual|override|static|final|transient|testMethod))?"
_ANNOTATION = r"(?:\s*@\w+(?:\s*\([^)]*\))?\s*)*"
cls_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*class\s+(\w+)"
rf"(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w,\s]+))?\s*\{{?",
_re.IGNORECASE,
)
iface_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*interface\s+(\w+)"
rf"(?:\s+extends\s+([\w,\s]+))?\s*\{{?",
_re.IGNORECASE,
)
enum_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*enum\s+(\w+)\s*\{{?",
_re.IGNORECASE,
)
trigger_re = _re.compile(
r"^\s*trigger\s+(\w+)\s+on\s+(\w+)\s*\(",
_re.IGNORECASE,
)
method_re = _re.compile(
rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?",
_re.IGNORECASE,
)
annotation_re = _re.compile(r"@(\w+)", _re.IGNORECASE)
soql_re = _re.compile(r"\[\s*SELECT\b[^\]]+FROM\s+(\w+)", _re.IGNORECASE)
dml_re = _re.compile(r"\b(insert|update|delete|upsert|merge|undelete)\s+\w", _re.IGNORECASE)
_CONTROL_FLOW = frozenset({
"if", "else", "for", "while", "do", "switch", "try", "catch",
"finally", "return", "throw", "new", "void", "null",
"true", "false", "this", "super", "class", "interface", "enum",
"trigger", "on",
})
current_class_nid: str | None = None
pending_annotations: list[str] = []
for lineno, line_text in enumerate(lines, start=1):
stripped = line_text.strip()
if stripped.startswith("@"):
for m in annotation_re.finditer(stripped):
pending_annotations.append(m.group(1).lower())
continue
tm = trigger_re.match(stripped)
if tm:
trig_name, sobject = tm.group(1), tm.group(2)
trig_nid = _make_id(stem, trig_name)
add_node(trig_nid, trig_name, lineno)
add_edge(file_nid, trig_nid, "contains", lineno)
sob_nid = _make_id(sobject)
if sob_nid not in seen_ids:
add_node(sob_nid, sobject, lineno)
add_edge(trig_nid, sob_nid, "uses", lineno, confidence="INFERRED")
current_class_nid = trig_nid
pending_annotations = []
continue
cm = cls_re.match(stripped)
if cm:
class_name = cm.group(1)
if class_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name, lineno)
add_edge(file_nid, class_nid, "contains", lineno)
if cm.group(2):
base = cm.group(2).strip()
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
add_node(base_nid, base, lineno)
add_edge(class_nid, base_nid, "extends", lineno, confidence="INFERRED")
if cm.group(3):
for iface in cm.group(3).split(","):
iface = iface.strip()
if iface:
iface_nid = _make_id(stem, iface)
if iface_nid not in seen_ids:
iface_nid = _make_id(iface)
if iface_nid not in seen_ids:
add_node(iface_nid, iface, lineno)
add_edge(class_nid, iface_nid, "implements", lineno, confidence="INFERRED")
current_class_nid = class_nid
pending_annotations = []
continue
im = iface_re.match(stripped)
if im:
iface_name = im.group(1)
if iface_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
iface_nid = _make_id(stem, iface_name)
add_node(iface_nid, iface_name, lineno)
add_edge(file_nid if current_class_nid is None else current_class_nid,
iface_nid, "contains", lineno)
if im.group(2):
for parent in im.group(2).split(","):
parent = parent.strip()
if parent:
parent_nid = _make_id(stem, parent)
if parent_nid not in seen_ids:
parent_nid = _make_id(parent)
if parent_nid not in seen_ids:
add_node(parent_nid, parent, lineno)
add_edge(iface_nid, parent_nid, "extends", lineno, confidence="INFERRED")
pending_annotations = []
continue
em = enum_re.match(stripped)
if em:
enum_name = em.group(1)
if enum_name.lower() in _CONTROL_FLOW:
pending_annotations = []
continue
enum_nid = _make_id(stem, enum_name)
add_node(enum_nid, enum_name, lineno)
add_edge(file_nid if current_class_nid is None else current_class_nid,
enum_nid, "contains", lineno)
pending_annotations = []
continue
if current_class_nid is not None:
mm = method_re.match(stripped)
if mm:
method_name = mm.group(1)
if method_name.lower() not in _CONTROL_FLOW:
method_nid = _make_id(current_class_nid, method_name)
method_label = f".{method_name}()"
add_node(method_nid, method_label, lineno)
add_edge(current_class_nid, method_nid, "method", lineno)
if "auraenabled" in pending_annotations or "invocablemethod" in pending_annotations:
add_edge(file_nid, method_nid, "contains", lineno, confidence="INFERRED")
pending_annotations = []
continue
pending_annotations = []
for sm in soql_re.finditer(line_text):
sobject = sm.group(1)
sob_nid = _make_id(sobject)
if sob_nid not in seen_ids:
add_node(sob_nid, sobject, lineno)
src = current_class_nid or file_nid
add_edge(src, sob_nid, "uses", lineno, confidence="INFERRED")
for dm in dml_re.finditer(line_text):
dml_op = dm.group(1).lower()
dml_nid = _make_id(f"dml_{dml_op}")
if dml_nid not in seen_ids:
add_node(dml_nid, dml_op, lineno)
src = current_class_nid or file_nid
add_edge(src, dml_nid, "uses", lineno, confidence="INFERRED")
return {"nodes": nodes, "edges": edges}
+66
View File
@@ -0,0 +1,66 @@
# DO NOT import from graphify.extract here — direction is extract.py → extractors/ only.
from __future__ import annotations
from pathlib import Path
from graphify.ids import make_id
# Language built-in globals that AST may classify as call targets when used as
# constructors or coercion functions (e.g. String(x), Number(x), Boolean(x)).
# Without this filter they become god-nodes accumulating spurious edges from
# every call site. Filter applied at same-file and cross-file resolution.
# See issue #726.
_LANGUAGE_BUILTIN_GLOBALS: frozenset[str] = frozenset({
# JavaScript / TypeScript ECMAScript built-ins
"String", "Number", "Boolean", "Object", "Array", "Symbol", "BigInt",
"Date", "RegExp", "Error", "TypeError", "RangeError", "SyntaxError",
"ReferenceError", "EvalError", "URIError",
"Promise", "Map", "Set", "WeakMap", "WeakSet", "JSON", "Math",
"Reflect", "Proxy", "Intl",
"parseInt", "parseFloat", "isNaN", "isFinite",
"encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI",
# Browser / Node common globals
"URL", "URLSearchParams", "FormData", "Blob", "File",
"Headers", "Request", "Response", "AbortController", "AbortSignal",
"TextEncoder", "TextDecoder", "console",
# Python built-in callables
"str", "int", "float", "bool", "list", "dict", "set", "tuple", "bytes",
"len", "range", "enumerate", "zip", "map", "filter", "sum", "min", "max",
"print", "open", "isinstance", "type", "super", "sorted", "reversed",
"any", "all", "abs", "round", "next", "iter", "hash", "id", "repr",
"callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir",
})
def _make_id(*parts: str) -> str:
return make_id(*parts)
def _file_stem(path: Path) -> str:
"""Stem used as the node-ID prefix for a file and its symbols.
The full path (extension dropped) is preserved as path segments; ``make_id``
later collapses the separators to underscores. Using every segment — not just
the immediate parent dir (#1504) — means same-named files in different
directories get distinct IDs instead of colliding into one
last-writer-wins node:
docs/v1/api/README.md -> docs/v1/api/README -> docs_v1_api_readme
docs/v2/api/README.md -> docs/v2/api/README -> docs_v2_api_readme
Top-level files keep a bare stem (``setup.py`` -> ``setup``). When passed an
absolute path the whole path is encoded; the extract() id-remap post-pass
re-derives the canonical repo-relative form from ``source_file`` so the on-disk
location can't leak into the persisted IDs (#502).
Returns "" for a path with no name (``Path('.')`` — a source_file that equals
the scan root, so it has no per-file stem). Guarding here keeps
``path.with_suffix("")`` from raising ``ValueError: '.' has an empty name`` and
protects every caller, not just ``_semantic_id_remap`` (#1618)."""
if not path.name:
return ""
return path.with_suffix("").as_posix()
def _read_text(node, source: bytes) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
+248
View File
@@ -0,0 +1,248 @@
"""Bash extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id, _read_text
def extract_bash(path: Path) -> dict:
"""Extract functions, source imports, and cross-function calls from a .sh file."""
try:
import tree_sitter_bash as tsbash
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-bash not installed"}
try:
language = Language(tsbash.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any]] = []
defined_functions: set[str] = set()
from graphify.security import sanitize_metadata # module-level cached import
def add_node(nid: str, label: str, line: int, kind: str = "code") -> None:
if nid and 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}",
"metadata": sanitize_metadata({"language": "bash", "kind": kind})}) # noqa: E501
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
if not src or not tgt or src == tgt:
return
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
# file_nid is fully path-derived and never produced by _make_id(stem, func_name),
# so appending "__entry" guarantees a distinct ID from any function node.
entry_nid = file_nid + "__entry"
add_node(file_nid, path.name, 1, kind="file")
add_node(entry_nid, f"{path.name} script", 1, kind="bash_entrypoint")
add_edge(file_nid, entry_nid, "contains", 1)
_BASH_SOURCE_COMMANDS = frozenset({"source", "."})
_BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"})
# Parent node types that mean a contained command is part of a substitution
# or expansion, not a real function call. Token-level filtering misses
# these because `$(build)` exposes `build` as a child command whose name
# token has no metacharacters — only the parent does.
_BASH_EXPANSION_PARENTS = frozenset({
"command_substitution",
"process_substitution",
})
def text(node) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
def is_inside_expansion(node) -> bool:
parent = node.parent
while parent is not None:
if parent.type in _BASH_EXPANSION_PARENTS:
return True
parent = parent.parent
return False
def literal(node) -> str | None:
# Token-level filter: rejects names containing shell metacharacters.
# Combined with `is_inside_expansion` for parent-context rejection.
raw = text(node).strip()
if not raw:
return None
if raw[0:1] in {"'", '"'} and raw[-1:] == raw[0]:
raw = raw[1:-1]
if any(token in raw for token in ("$", "`", "$(", "<(", ">", "|", ";", "&")):
return None
return raw
def _bash_func_name(node) -> str | None:
"""Get the name from a function_definition node."""
# bash grammar: function_definition has a word child (the name)
for child in node.children:
if child.type == "word":
return literal(child)
return None
def walk_calls(body_node, func_nid: str, seen_calls: set) -> None:
if body_node is None:
return
for child in body_node.children:
if child.type == "function_definition":
# Skip nested function definitions — their bodies are walked
# separately, so we don't attribute their calls to the
# enclosing scope.
continue
if child.type == "command" and not is_inside_expansion(child):
cmd_name_node = child.child_by_field_name("name")
if cmd_name_node is None and child.children:
cmd_name_node = child.children[0]
if cmd_name_node:
name = literal(cmd_name_node)
# Defined-functions wins. Skip-lists for external commands
# would create false negatives when a user defines a
# function shadowing an external (`install`, `find`, etc.).
if name and name in defined_functions:
tgt = _make_id(stem, name)
key = (func_nid, tgt)
if tgt and key not in seen_calls:
seen_calls.add(key)
add_edge(func_nid, tgt, "calls",
child.start_point[0] + 1,
confidence="EXTRACTED", context="call")
walk_calls(child, func_nid, seen_calls)
def walk(node, parent_nid: str) -> None:
t = node.type
if t == "function_definition":
name = _bash_func_name(node)
if name:
fn_nid = _make_id(stem, name)
line = node.start_point[0] + 1
add_node(fn_nid, f"{name}()", line, kind="bash_function")
add_edge(parent_nid, fn_nid, "defines", line)
defined_functions.add(name)
# find the compound_statement body
body = None
for child in node.children:
if child.type == "compound_statement":
body = child
break
function_bodies.append((fn_nid, body))
# Recurse into the body so nested function definitions are discovered
# and added to function_bodies for the second-pass walk_calls.
if body is not None:
walk(body, fn_nid)
return
if t == "command":
if is_inside_expansion(node):
return
cmd_name_node = node.child_by_field_name("name")
if cmd_name_node is None and node.children:
cmd_name_node = node.children[0]
if cmd_name_node:
cmd = literal(cmd_name_node)
args = [c for c in node.children
if c.type in ("word", "string", "concatenation")
and c != cmd_name_node]
if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions:
# find the path argument (first word after command name)
if args:
raw = _read_text(args[0], source).strip().strip("'\"")
line = node.start_point[0] + 1
if raw.startswith((".", "/")):
resolved = (path.parent / raw).resolve()
# Only emit the edge if the target actually exists on
# disk — prevents graph pollution from crafted paths
# like `source ../../etc/passwd` that traverse outside
# the project tree (B-1).
if resolved.exists():
tgt_nid = _make_id(str(resolved))
add_edge(file_nid, tgt_nid, "imports_from", line,
context="import")
else:
tgt_nid = _make_id(raw)
if tgt_nid:
add_edge(file_nid, tgt_nid, "imports", line,
context="import")
elif cmd and cmd not in defined_functions:
raw = cmd if cmd.endswith(".sh") else None
if cmd in _BASH_SCRIPT_RUNNERS and args:
raw = literal(args[0])
if raw and raw.endswith(".sh"):
resolved = (path.parent / raw).resolve()
if resolved.is_file():
target_path = resolved
if not path.is_absolute():
try:
target_path = resolved.relative_to(Path.cwd().resolve())
except ValueError:
pass
caller_nid = entry_nid if parent_nid == file_nid else parent_nid
add_edge(caller_nid, _make_id(str(target_path)) + "__entry",
"calls", node.start_point[0] + 1,
context="script_invocation")
return
if t == "declaration_command":
# export/declare/readonly VAR=value at program level
if node.parent and node.parent.type == "program":
for child in node.children:
if child.type == "variable_assignment":
var_node = child.child_by_field_name("name")
if var_node:
var = _read_text(var_node, source).strip()
if var:
var_nid = _make_id(stem, var)
line = child.start_point[0] + 1
add_node(var_nid, var, line)
add_edge(file_nid, var_nid, "defines", line)
return
for child in node.children:
walk(child, parent_nid)
# Pre-pass: collect all defined function names so the source-command handler
# in walk() can detect user-defined functions that shadow 'source' / '.'
# regardless of definition order in the file.
def _prescan_functions(node) -> None:
if node.type == "function_definition":
name = _bash_func_name(node)
if name:
defined_functions.add(name)
for child in node.children:
_prescan_functions(child)
else:
for child in node.children:
_prescan_functions(child)
_prescan_functions(root)
walk(root, file_nid)
# Second pass: cross-function calls
top_seen: set = set()
walk_calls(root, entry_nid, top_seen) # top-level calls attributed to the entrypoint
for fn_nid, body in function_bodies:
walk_calls(body, fn_nid, set())
return {"nodes": nodes, "edges": edges}
+53
View File
@@ -0,0 +1,53 @@
"""Laravel Blade template extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _make_id
def extract_blade(path: Path) -> dict:
"""Extract @include, <livewire:> components, and wire:click bindings from Blade templates."""
import re
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"error": f"cannot read {path}"}
file_nid = _make_id(str(path))
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str(path), "source_location": None}]
edges = []
# @include('path.to.partial') or @include("path.to.partial")
for m in re.finditer(r"@include\(['\"]([^'\"]+)['\"]", src):
tgt = m.group(1).replace(".", "/")
tgt_nid = _make_id(tgt)
if tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": tgt_nid, "relation": "includes",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
# <livewire:component.name /> or <livewire:component.name>
for m in re.finditer(r"<livewire:([\w.\-]+)", src):
tgt_nid = _make_id(m.group(1))
if tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": tgt_nid, "relation": "uses_component",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
# wire:click="methodName"
for m in re.finditer(r'wire:click=["\']([^"\']+)["\']', src):
tgt_nid = _make_id(m.group(1))
if tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": tgt_nid, "relation": "binds_method",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
+393
View File
@@ -0,0 +1,393 @@
"""C# cross-file resolution.
The config-driven C# *extractor* (``extract_csharp`` → ``_extract_generic``)
still lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the
config-driven languages cannot be ported one-by-one until the shared
``_extract_generic`` core moves as its own coordinated batch. This module is
the C# home for the parts that *are* cleanly separable — today, the cross-file
type-reference resolver below — and is where ``extract_csharp`` will land when
the core migration happens.
"""
from __future__ import annotations
import html
from pathlib import Path
from graphify.extractors.base import _make_id
def _build_csharp_type_def_index(all_nodes: list[dict]) -> dict[tuple[str, str], str]:
"""Return deterministic ``(namespace, name) -> node_id`` C# type definitions."""
candidates: dict[tuple[str, str], list[dict]] = {}
for node in all_nodes:
if node.get("type") == "namespace":
continue
metadata = node.get("metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
if metadata.get("is_nested_type"):
continue
nid = node.get("id")
label = node.get("label")
if not (isinstance(nid, str) and nid and isinstance(label, str) and label):
continue
source_file = node.get("source_file")
if (
not isinstance(source_file, str)
or not source_file.endswith(".cs")
or node.get("file_type") != "code"
):
continue
if label.endswith(")") or label.startswith(".") or "." in label:
continue
namespace = metadata.get("namespace", "")
if not isinstance(namespace, str):
namespace = ""
candidates.setdefault((namespace, label), []).append(node)
return {
key: sorted(
nodes,
key=lambda node: (
str(node.get("source_file") or ""),
str(node.get("source_location") or ""),
str(node.get("id") or ""),
),
)[0]["id"]
for key, nodes in candidates.items()
}
def _strip_trailing_csharp_generic_args(target_fqn: str) -> str:
target_fqn = target_fqn.strip()
if not target_fqn.endswith(">"):
return target_fqn
depth = 0
for index in range(len(target_fqn) - 1, -1, -1):
char = target_fqn[index]
if char == ">":
depth += 1
elif char == "<":
depth -= 1
if depth == 0:
return target_fqn[:index].strip()
return target_fqn
def _resolve_cross_file_csharp_imports(
per_file: list[dict],
paths: list[Path],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Re-point resolvable C# ``using`` import edges to canonical internal nodes.
Namespace imports resolve only to canonical C# namespace nodes. Alias imports
resolve only when the alias target's prefix is a known canonical namespace and
the simple type name exists in the shared C# type-definition index. ``using
static`` and nested type aliases remain deliberate gaps because they need
member/nested-type modeling beyond this import pass.
"""
_ = (per_file, paths)
namespace_id_by_label: dict[str, str] = {}
for node in sorted(
all_nodes,
key=lambda node: (
str(node.get("source_file") or ""),
str(node.get("source_location") or ""),
str(node.get("id") or ""),
),
):
if node.get("type") != "namespace":
continue
label = node.get("label")
nid = node.get("id")
if isinstance(label, str) and label and isinstance(nid, str) and nid:
namespace_id_by_label.setdefault(label, nid)
type_def_index = _build_csharp_type_def_index(all_nodes)
if not namespace_id_by_label and not type_def_index:
return
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") != "imports":
continue
metadata = edge.get("metadata") or {}
if not isinstance(metadata, dict):
continue
using_kind = metadata.get("using_kind")
target_fqn = metadata.get("target_fqn")
if not using_kind or not isinstance(target_fqn, str) or not target_fqn:
continue
resolved = None
if using_kind == "namespace":
resolved = namespace_id_by_label.get(target_fqn)
elif using_kind == "alias":
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
prefix, sep, name = base_fqn.rpartition(".")
if sep and prefix in namespace_id_by_label:
resolved = type_def_index.get((prefix, name))
old_target = edge.get("target")
if resolved and resolved != old_target:
edge["target"] = resolved
if isinstance(old_target, str) and old_target:
repointed_from.add(old_target)
if not repointed_from:
return
still_referenced: set[str] = set()
for edge in all_edges:
still_referenced.add(edge.get("source"))
still_referenced.add(edge.get("target"))
all_nodes[:] = [
node for node in all_nodes
if node.get("id") not in repointed_from or node.get("id") in still_referenced
]
def _resolve_csharp_type_references(
per_file: list[dict],
paths: list[Path],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Arbitrate all C# ``inherits``/``implements``/``references`` targets.
The extractor emits provisional same-file bindings and sourceless stubs. This
pass is the single soundness gate: it uses only graph-stamped namespace/import
facts, keeps a binding only when the referenced simple name resolves to one
in-scope real type definition, and otherwise leaves the edge on a dangling stub.
"""
_ = (per_file, paths)
def _is_cs_file(value: object) -> bool:
return isinstance(value, str) and value.endswith(".cs")
def _metadata(value: object) -> dict:
return value if isinstance(value, dict) else {}
def _namespace(node: dict | None) -> str:
metadata = _metadata((node or {}).get("metadata"))
namespace = metadata.get("namespace", "")
return namespace if isinstance(namespace, str) else ""
def _append_unique(items: list[str], value: str) -> None:
if value not in items:
items.append(value)
node_by_id = {
node["id"]: node
for node in all_nodes
if isinstance(node.get("id"), str) and node.get("id")
}
type_def_index = _build_csharp_type_def_index(all_nodes)
known_namespaces = {
node.get("label")
for node in all_nodes
if node.get("type") == "namespace" and isinstance(node.get("label"), str)
}
# Each using carries its lexical scope: ("file", None) applies file-wide;
# ("namespace", scope_id) applies only where scope_id is in the ref's scope_chain.
namespace_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {}
aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {}
for edge in all_edges:
if edge.get("relation") != "imports":
continue
source_node = node_by_id.get(edge.get("source"))
if not (
source_node
and isinstance(source_node.get("label"), str)
and source_node.get("label", "").endswith(".cs")
):
continue
source_file = source_node.get("source_file")
if not _is_cs_file(source_file):
continue
metadata = _metadata(edge.get("metadata"))
target_fqn = metadata.get("target_fqn")
if not isinstance(target_fqn, str) or not target_fqn:
continue
scope_kind = metadata.get("scope_kind") or "file"
scope_id = metadata.get("scope_id")
using_kind = metadata.get("using_kind")
if using_kind == "namespace":
entry = (target_fqn, scope_kind, scope_id)
bucket = namespace_usings_by_file.setdefault(source_file, [])
if entry not in bucket:
bucket.append(entry)
elif using_kind == "alias":
alias = metadata.get("alias")
if isinstance(alias, str) and alias:
entry = (target_fqn, scope_kind, scope_id)
bucket = aliases_by_file.setdefault(source_file, {}).setdefault(alias, [])
if entry not in bucket:
bucket.append(entry)
def _scope_chain(node: dict) -> list[str]:
chain = _metadata(node.get("metadata")).get("scope_chain")
return chain if isinstance(chain, list) else []
def _using_in_scope(scope_kind: str, scope_id: str | None, source_node: dict) -> bool:
if scope_kind == "file":
return True
return scope_id is not None and scope_id in _scope_chain(source_node)
def _scopes_for(source_node: dict, source_file: str) -> list[str]:
scopes: list[str] = []
_append_unique(scopes, _namespace(source_node))
_append_unique(scopes, "")
for namespace, scope_kind, scope_id in namespace_usings_by_file.get(source_file, []):
if _using_in_scope(scope_kind, scope_id, source_node):
_append_unique(scopes, namespace)
return scopes
def _resolve_alias(label: str, source_node: dict, source_file: str) -> str | None:
hits = set()
for target_fqn, scope_kind, scope_id in aliases_by_file.get(source_file, {}).get(label, []):
if not _using_in_scope(scope_kind, scope_id, source_node):
continue
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
namespace, sep, simple_name = base_fqn.rpartition(".")
if not sep:
simple_name = namespace
namespace = ""
if not simple_name:
continue
hit = type_def_index.get((namespace, simple_name))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
def _resolve_label(label: str, source_node: dict, source_file: str) -> str | None:
if label in aliases_by_file.get(source_file, {}):
return _resolve_alias(label, source_node, source_file)
candidates: list[str] = []
for namespace in _scopes_for(source_node, source_file):
hit = type_def_index.get((namespace, label))
if hit and hit not in candidates:
candidates.append(hit)
return candidates[0] if len(candidates) == 1 else None
def _resolve_qualified(label: str, qualifier: object, source_node: dict, source_file: str) -> str | None:
# Sound qualified resolution: an in-scope alias for Q shadows the namespace Q. For a qualified
# ref Q.label, look up (alias_target_namespace, label). If no in-scope alias, fall through to an
# exact known namespace. Dangle on ambiguity / no hit / unknown qualifier.
if not isinstance(qualifier, str) or not qualifier:
return None
in_scope = [
entry for entry in aliases_by_file.get(source_file, {}).get(qualifier, [])
if _using_in_scope(entry[1], entry[2], source_node)
]
if in_scope:
hits = set()
for target_fqn, _scope_kind, _scope_id in in_scope:
alias_ns = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
hit = type_def_index.get((alias_ns, label))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
if qualifier in known_namespaces:
return type_def_index.get((qualifier, label))
return None
def _is_placeholder(node: dict | None) -> bool:
return bool(node) and not node.get("source_file")
def _is_csharp_relevant_target(node: dict) -> bool:
if node.get("type") == "namespace":
return True
source_file = node.get("source_file")
return not source_file or _is_cs_file(source_file)
def _label_for_type_ref_target(target_node: dict, source_file: str) -> str | None:
label = target_node.get("label")
if not isinstance(label, str) or not label:
return None
if not label.endswith(".cs"):
return label
stem = label[:-3]
for alias in aliases_by_file.get(source_file, {}):
if alias.lower() == stem.lower() or _make_id(alias) == _make_id(stem):
return alias
return stem or None
def _dangling_stub_id(label: str, current_target: object) -> str:
current = node_by_id.get(current_target)
if _is_placeholder(current) and current.get("label") == label:
return str(current_target)
for node in all_nodes:
nid = node.get("id")
if (
isinstance(nid, str)
and node.get("label") == label
and _is_placeholder(node)
):
return nid
stem = _make_id(label)
stub_id = stem
if stub_id in node_by_id:
stub_id = _make_id("csharp_type_ref", label)
suffix = 2
while stub_id in node_by_id:
stub_id = _make_id("csharp_type_ref", label, str(suffix))
suffix += 1
node = {
"id": stub_id,
"label": label,
"file_type": "code",
"source_file": "",
"source_location": "",
}
all_nodes.append(node)
node_by_id[stub_id] = node
return stub_id
REPOINT_RELATIONS = {"implements", "inherits", "references"}
repointed_from: set[str] = set()
for edge in all_edges:
if edge.get("relation") not in REPOINT_RELATIONS:
continue
source_file = edge.get("source_file")
if not _is_cs_file(source_file):
continue
source_node = node_by_id.get(edge.get("source"))
target_node = node_by_id.get(edge.get("target"))
if not source_node or not target_node:
continue
if not _is_csharp_relevant_target(target_node):
continue
metadata = _metadata(edge.get("metadata"))
label = metadata.get("ref_token") or _label_for_type_ref_target(target_node, source_file)
if not label:
continue
if metadata.get("qualified"):
resolved = _resolve_qualified(label, metadata.get("ref_qualifier"), source_node, source_file)
else:
resolved = _resolve_label(label, source_node, source_file)
target = edge.get("target")
desired = resolved or _dangling_stub_id(label, target)
if desired != target:
edge["target"] = desired
if isinstance(target, str) and _is_placeholder(target_node):
repointed_from.add(target)
if not repointed_from:
return
still_referenced: set[str] = set()
for edge in all_edges:
still_referenced.add(edge.get("source"))
still_referenced.add(edge.get("target"))
all_nodes[:] = [
node for node in all_nodes
if node.get("id") not in repointed_from or node.get("id") in still_referenced
]
+528
View File
@@ -0,0 +1,528 @@
"""Dart extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
def extract_dart(path: Path) -> dict:
"""Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex."""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"error": f"cannot read {path}"}
# Remove inline and multi-line comments while leaving string literals untouched to prevent stripping URLs/paths inside strings
comment_string_pattern = re.compile(
r'"""(?:\\.|[\s\S])*?"""'
r"|'''(?:\\.|[\s\S])*?'''"
r'|"(?:\\.|[^"\\])*"'
r"|'(?:\\.|[^'\\])*'"
r"|/\*[\s\S]*?\*/"
r"|//[^\n]*"
)
def _comment_replace(match: re.Match) -> str:
token = match.group(0)
if token.startswith("/"):
return ""
return token
src_clean = comment_string_pattern.sub(_comment_replace, src)
stem = _file_stem(path)
file_nid = _make_id(str(path))
# Check if this is a part-of file and redirect to parent
part_of_match = re.search(r"^\s*part\s+of\s+['\"]([^'\"]+)['\"]", src_clean, re.MULTILINE)
is_part = False
if part_of_match:
parent_ref = part_of_match.group(1)
if parent_ref.endswith(".dart"):
try:
parent_path = (path.parent / parent_ref).resolve()
if parent_path.exists():
stem = _file_stem(parent_path)
file_nid = _make_id(str(parent_path))
is_part = True
except Exception:
pass
nodes = []
if not is_part:
nodes.append({"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str(path), "source_location": None})
edges = []
defined: set[str] = set()
def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None:
if nid not in defined:
nodes.append({"id": nid, "label": label, "file_type": ftype,
"source_file": source_file, "source_location": None})
defined.add(nid)
def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None:
edge = {"source": src_id, "target": tgt_id, "relation": relation,
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
def _split_types(text: str) -> list[str]:
parts = []
current = []
depth = 0
for char in text:
if char == "<":
depth += 1
current.append(char)
elif char == ">":
depth -= 1
current.append(char)
elif char == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(char)
if current:
parts.append("".join(current).strip())
return [p for p in parts if p]
def _find_matching_brace(text: str, start_pos: int) -> int:
brace_count = 0
in_double_quote = False
in_single_quote = False
escape = False
first_brace = text.find("{", start_pos)
if first_brace == -1:
return len(text)
brace_count = 1
i = first_brace + 1
n = len(text)
while i < n:
char = text[i]
if escape:
escape = False
i += 1
continue
if char == "\\":
escape = True
i += 1
continue
if text[i:i+3] == '"""' and not in_single_quote:
i += 3
end = text.find('"""', i)
i = end + 3 if end != -1 else n
continue
if text[i:i+3] == "'''" and not in_double_quote:
i += 3
end = text.find("'''", i)
i = end + 3 if end != -1 else n
continue
if char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
elif char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
elif not in_double_quote and not in_single_quote:
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
if brace_count == 0:
return i + 1
i += 1
return len(text)
# 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
# Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
for m in re.finditer(class_pattern, src_clean, re.MULTILINE):
class_name = m.group(1)
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name)
add_edge(file_nid, class_nid, "defines")
# Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
start_idx = m.end()
rest = src_clean[start_idx : start_idx + 500]
# Skip class generic parameters
if rest.lstrip().startswith("<"):
offset = rest.find("<")
depth = 1
i = offset + 1
while i < len(rest) and depth > 0:
if rest[i] == "<": depth += 1
elif rest[i] == ">": depth -= 1
i += 1
rest = rest[i:]
# Skip primary constructor (e.g. extension type MyExt(int id))
if rest.lstrip().startswith("("):
offset = rest.find("(")
depth = 1
i = offset + 1
while i < len(rest) and depth > 0:
if rest[i] == "(": depth += 1
elif rest[i] == ")": depth -= 1
i += 1
rest = rest[i:]
header_end = rest.find("{")
if header_end == -1:
header_end = rest.find(";")
if header_end == -1:
header_end = len(rest)
header = rest[:header_end]
base_class = None
generics = None
mixins_list = []
interfaces_list = []
# Parse extends or on
extends_m = re.search(r"^\s*(?:extends|on)\s+([a-zA-Z0-9_.]+)", header)
if extends_m:
base_class = extends_m.group(1)
rest_header = header[extends_m.end():]
if rest_header.strip().startswith("<"):
start_idx = rest_header.find("<")
depth = 1
i = start_idx + 1
while i < len(rest_header) and depth > 0:
if rest_header[i] == "<":
depth += 1
elif rest_header[i] == ">":
depth -= 1
if depth == 0:
generics = rest_header[start_idx + 1 : i]
break
i += 1
if generics is not None:
header = rest_header[i + 1:]
else:
header = rest_header
else:
header = rest_header
# Parse with
with_m = re.search(r"^\s*with\s+", header)
if with_m:
rest_header = header[with_m.end():]
impl_idx = rest_header.find("implements")
if impl_idx != -1:
mixins_str = rest_header[:impl_idx]
header = rest_header[impl_idx:]
else:
mixins_str = rest_header
header = ""
mixins_list = _split_types(mixins_str)
# Parse implements
impl_m = re.search(r"^\s*implements\s+", header)
if impl_m:
interfaces_list = _split_types(header[impl_m.end():])
# Map extends inheritance relation
if base_class:
base_nid = _make_id(base_class)
add_node(base_nid, base_class, source_file=None)
add_edge(class_nid, base_nid, "inherits")
# Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
if generics:
for gen in _split_types(generics):
gen_clean = gen.split("<")[0].strip()
if gen_clean not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
gen_nid = _make_id(gen_clean)
add_node(gen_nid, gen_clean, source_file=None)
add_edge(class_nid, gen_nid, "references")
# Map mixins
for mixin in mixins_list:
mixin_clean = mixin.split("<")[0].strip()
mixin_nid = _make_id(mixin_clean)
add_node(mixin_nid, mixin_clean, source_file=None)
add_edge(class_nid, mixin_nid, "mixes_in")
# Map interfaces
for interface in interfaces_list:
interface_clean = interface.split("<")[0].strip()
interface_nid = _make_id(interface_clean)
add_node(interface_nid, interface_clean, source_file=None)
add_edge(class_nid, interface_nid, "implements")
# Extract class body for precise framework dependencies and event handling
start_idx = m.start()
brace_pos = src_clean.find("{", start_idx)
semi_pos = src_clean.find(";", start_idx)
has_body = brace_pos != -1
if has_body and semi_pos != -1 and semi_pos < brace_pos:
has_body = False
if has_body:
end_pos = _find_matching_brace(src_clean, start_idx)
class_body = src_clean[brace_pos:end_pos]
# Bloc event registration: on<MyEvent>()
for em in re.finditer(r"\bon<(\w+)>\s*\(", class_body):
event_name = em.group(1)
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(class_nid, event_nid, "calls", context="bloc_event")
# Bloc state emissions: emit(MyState) or yield MyState
for sm in re.finditer(r"\b(?:emit|yield)\s*\(?\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
state_name = sm.group(1)
if state_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
state_nid = _make_id(state_name)
add_node(state_nid, state_name, source_file=None)
add_edge(class_nid, state_nid, "calls", context="emit_state")
# Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
event_name = am.group(1)
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(class_nid, event_nid, "calls", context="bloc_add_event")
# Riverpod provider references: ref.watch(provider)
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", class_body):
provider_name = rm.group(1)
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, source_file=None)
add_edge(class_nid, provider_nid, "references", context="riverpod_reference")
# Widget to Bloc references: BlocBuilder<MyBloc, ...>
for bm in re.finditer(r"\bBloc(?:Builder|Listener|Consumer|Provider|Selector)\s*<\s*([a-zA-Z0-9_]+)\b", class_body):
bloc_name = bm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(class_nid, bloc_nid, "references", context="bloc_widget_binding")
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", class_body):
bloc_name = lm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(class_nid, bloc_nid, "references", context="bloc_lookup")
# 2. Annotations mapping (class, mixin, enum, or function level annotations)
# Support: @riverpod, @Riverpod(...), @injectable, @singleton, @RoutePage(), @HiveType(typeId: 0), @RestApi()
# Matches `@annotation` and links it to the next class/mixin/enum/function declaration in the file
annotation_pattern = r"@(\w+)(?:\([^)]*\))?"
for am in re.finditer(annotation_pattern, src_clean):
annotation_name = am.group(1)
if annotation_name in {"override", "deprecated", "required", "protected", "mustCallSuper"}:
continue
annotation_pos = am.end()
intervening_text = src_clean[annotation_pos : annotation_pos + 300]
class_m = re.search(r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)", intervening_text, re.MULTILINE)
func_m = re.search(r"^\s*(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+)\s*\(", intervening_text, re.MULTILINE)
target_nid = None
target_name = None
target_type = None
if class_m and func_m:
if class_m.start() < func_m.start():
target_name = class_m.group(1)
target_type = "class"
target_nid = _make_id(stem, target_name)
else:
target_name = func_m.group(1)
target_type = "function"
target_nid = _make_id(stem, target_name)
elif class_m:
target_name = class_m.group(1)
target_type = "class"
target_nid = _make_id(stem, target_name)
elif func_m:
target_name = func_m.group(1)
target_type = "function"
target_nid = _make_id(stem, target_name)
if target_nid and target_name:
actual_intervening = intervening_text[:min(class_m.start() if class_m else 300, func_m.start() if func_m else 300)]
if ";" not in actual_intervening and "}" not in actual_intervening and "{" not in actual_intervening:
annotation_nid = _make_id("annotation", annotation_name.lower())
add_node(annotation_nid, f"@{annotation_name}", ftype="concept", source_file=None)
add_edge(target_nid, annotation_nid, "configures")
# Riverpod specific provider generation mapping (supports camelCase class and functional providers)
if annotation_name.lower() == "riverpod":
if target_type == "class":
provider_name = target_name[0].lower() + target_name[1:] + "Provider" if len(target_name) > 1 else target_name.lower() + "Provider"
else:
provider_name = target_name + "Provider"
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, ftype="concept", source_file=str(path))
add_edge(target_nid, provider_nid, "defines", context="riverpod_provider")
# 2.5 Typedefs (Type Aliases)
typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
for m in re.finditer(typedef_pattern, src_clean, re.MULTILINE):
typedef_name = m.group(1)
target_type = m.group(2).split("<")[0].split(".")[-1].strip()
if target_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void", "Function"}:
typedef_nid = _make_id(stem, typedef_name)
add_node(typedef_nid, typedef_name)
add_edge(file_nid, typedef_nid, "defines")
target_nid = _make_id(target_type)
add_node(target_nid, target_type, source_file=None)
add_edge(typedef_nid, target_nid, "references", context="typedef")
# 3. Extensions (extension MyExt on MyClass)
ext_pattern = r"^\s{0,4}extension\s+(\w+)?(?:<[^>]+>)?\s+on\s+(\w+)"
for m in re.finditer(ext_pattern, src_clean, re.MULTILINE):
ext_name = m.group(1) or f"{stem}_anonymous_extension"
target_class = m.group(2)
ext_nid = _make_id(stem, ext_name)
label = m.group(1) or f"Extension on {target_class}"
add_node(ext_nid, label)
add_edge(file_nid, ext_nid, "defines")
target_nid = _make_id(target_class)
add_node(target_nid, target_class, source_file=None)
add_edge(ext_nid, target_nid, "extends")
# 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
# Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
var_pattern = r"^\s{0,2}(?:late\s+)?(?:(?:final|const|var)\s+)?(?:\([^)]+\)\s+|([a-zA-Z0-9_<>,.?]+(?:\s+[a-zA-Z0-9_<>,.?]+){0,3})\s+)?(?:(\w+)|(?:\w+\s*)?\(([^)]+)\))\s*(?:=|$|;)"
for m in re.finditer(var_pattern, src_clean, re.MULTILINE):
var_type = m.group(1)
single_name = m.group(2)
destructured_names = m.group(3)
if not re.match(r"^\s*(?:late|final|const|var)\b", m.group(0)) and not var_type:
continue
if single_name:
if single_name not in {"if", "for", "while", "switch", "catch", "return"}:
var_nid = _make_id(stem, single_name)
add_node(var_nid, single_name)
add_edge(file_nid, var_nid, "defines")
if var_type and var_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void"}:
clean_type = var_type.split("<")[0].split(".")[-1].strip()
type_nid = _make_id(clean_type)
add_node(type_nid, clean_type, source_file=None)
add_edge(file_nid, type_nid, "references", context="variable_type")
elif destructured_names:
for name in [n.strip() for n in destructured_names.split(",") if n.strip()]:
if ":" in name:
name = name.split(":")[-1].strip()
if re.match(r"^[a-zA-Z_]\w*$", name) and not re.match(r"^[A-Z]", name):
if name not in {"if", "for", "while", "switch", "catch", "return"}:
var_nid = _make_id(stem, name)
add_node(var_nid, name)
add_edge(file_nid, var_nid, "defines")
# 5. Top-level and member functions/methods (supports typed/generic/record return types and Riverpod/Bloc references)
# Restrict indentation to 0-2 spaces to avoid matching nested local functions or methods inside multiline switch statements
method_pattern = r"^\s{0,2}(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+(?:\.\w+)?)\s*\("
for m in re.finditer(method_pattern, src_clean, re.MULTILINE):
raw_name = m.group(1)
name = raw_name.split(".")[-1]
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set"}:
continue
if re.match(r"^[A-Z]", name):
continue
nid = _make_id(stem, name)
add_node(nid, name)
add_edge(file_nid, nid, "defines")
# Get function body using matching brace to extract Riverpod reference patterns
start_idx = m.start()
brace_pos = src_clean.find("{", start_idx)
semi_pos = src_clean.find(";", start_idx)
arrow_pos = src_clean.find("=>", start_idx)
has_body = brace_pos != -1
if has_body and semi_pos != -1 and semi_pos < brace_pos:
has_body = False
if has_body and arrow_pos != -1 and arrow_pos < brace_pos:
has_body = False
if has_body:
end_pos = _find_matching_brace(src_clean, start_idx)
func_body = src_clean[brace_pos:end_pos]
# Extract Riverpod provider references: ref.watch(provider)
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", func_body):
provider_name = rm.group(1)
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, source_file=None)
add_edge(nid, provider_nid, "references", context="riverpod_reference")
# Extract Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", func_body):
event_name = am.group(1)
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(nid, event_nid, "calls", context="bloc_add_event")
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", func_body):
bloc_name = lm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(nid, bloc_nid, "references", context="bloc_lookup")
# Universal Navigation Patters (GoRouter, AutoRoute, Navigator)
for nm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?['\"]([a-zA-Z0-9_/?=&%-]+)['\"]", func_body):
route_path = nm.group(1)
route_nid = _make_id("route", route_path.replace("/", "_").replace("?", "_").replace("=", "_").replace("&", "_"))
add_node(route_nid, f"Route {route_path}", ftype="concept", source_file=None)
add_edge(nid, route_nid, "navigates", context="route_path")
for cm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?([A-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+)", func_body):
route_const = cm.group(1)
route_nid = _make_id("route", route_const.replace(".", "_"))
add_node(route_nid, route_const, ftype="concept", source_file=None)
add_edge(nid, route_nid, "navigates", context="route_const")
for om in re.finditer(r"\b(?:push|replace)\s*\(\s*(?:context\s*,\s*)?.*?\b([A-Z]\w*(?:Route|Screen|Page))\b", func_body):
route_class = om.group(1)
route_nid = _make_id(route_class)
add_node(route_nid, route_class, source_file=None)
add_edge(nid, route_nid, "navigates", context="route_object")
# 6. Imports and Exports
for m in re.finditer(r"""^\s*import\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
pkg = m.group(1)
tgt_nid = _make_id(pkg)
add_node(tgt_nid, pkg, source_file=None)
add_edge(file_nid, tgt_nid, "imports")
for m in re.finditer(r"""^\s*export\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
pkg = m.group(1)
tgt_nid = _make_id(pkg)
add_node(tgt_nid, pkg, source_file=None)
add_edge(file_nid, tgt_nid, "exports")
# 7. Generic Invocations / Type Lookups (Universal Dependency Lookup)
# Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
# Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
type_blacklist = {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "Future", "Stream", "void"}
for m in re.finditer(generic_call_pattern, src_clean):
type_name = m.group(1).split(".")[-1].strip()
clean_name = type_name.split("<")[0].strip()
if clean_name not in type_blacklist:
target_nid = _make_id(clean_name)
add_node(target_nid, clean_name, source_file=None)
add_edge(file_nid, target_nid, "references", context="type_lookup")
return {"nodes": nodes, "edges": edges}
+494
View File
@@ -0,0 +1,494 @@
"""Dm extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id, _read_text
def extract_dm(path: Path) -> dict:
"""Extract types, procs, includes, and calls from a .dm/.dme file."""
try:
import tree_sitter_dm as tsdm
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-dm not installed"}
try:
language = Language(tsdm.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any, "str | None"]] = []
def add_node(nid: str, label: str, line: int) -> None:
if nid and 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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
if not src or not tgt or src == tgt:
return
edge: dict = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def _type_path_text(node) -> str:
return _read_text(node, source).strip()
def _ensure_type(path_text: str, line: int) -> str:
nid = _make_id(stem, path_text)
add_node(nid, path_text, line)
return nid
def _find_child(node, type_name: str):
for c in node.children:
if c.type == type_name:
return c
return None
def _read_include_path(file_node) -> str:
if file_node is None:
return ""
if file_node.type == "string_literal":
parts = []
for c in file_node.children:
if c.type == "string_content":
parts.append(_read_text(c, source))
return "".join(parts)
return _read_text(file_node, source).strip("'\"")
def walk(node, parent_type_path: "str | None" = None,
parent_type_nid: "str | None" = None) -> None:
t = node.type
line = node.start_point[0] + 1
if t == "preproc_include":
file_node = node.child_by_field_name("file")
raw = _read_include_path(file_node)
if raw:
norm = raw.replace("\\", "/").lstrip("./")
resolved = (path.parent / norm).resolve()
edge: dict = {
"source": file_nid,
"target": _make_id(str(resolved)) if resolved.exists() else _make_id(norm),
"relation": "imports_from" if resolved.exists() else "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
}
if not resolved.exists():
edge["external"] = True
edges.append(edge)
return
if t == "type_definition":
tp_node = _find_child(node, "type_path")
if tp_node is None:
return
type_path_str = _type_path_text(tp_node)
type_nid = _ensure_type(type_path_str, line)
add_edge(file_nid, type_nid, "contains", line)
body = _find_child(node, "type_body")
if body is not None:
for c in body.children:
walk(c, parent_type_path=type_path_str, parent_type_nid=type_nid)
return
if t in ("type_body_intended", "type_body_braced"):
for c in node.children:
walk(c, parent_type_path, parent_type_nid)
return
if t in ("type_proc_definition", "type_proc_override"):
if parent_type_nid is None or parent_type_path is None:
return
name_node = node.child_by_field_name("name")
if name_node is None:
return
proc_name = _read_text(name_node, source)
proc_nid = _make_id(stem, parent_type_path, proc_name)
add_node(proc_nid, f"{parent_type_path}/{proc_name}()", line)
add_edge(parent_type_nid, proc_nid, "method", line)
block = _find_child(node, "block")
if block is not None:
function_bodies.append((proc_nid, block, parent_type_path))
return
if t in ("proc_definition", "proc_override"):
tp_node = _find_child(node, "type_path")
owner_path: "str | None" = None
owner_nid: "str | None" = None
if tp_node is not None:
owner_path = _type_path_text(tp_node)
owner_nid = _ensure_type(owner_path, line)
add_edge(file_nid, owner_nid, "contains", line)
name_node = node.child_by_field_name("name")
if name_node is None:
return
proc_name = _read_text(name_node, source)
if owner_path and owner_nid:
proc_nid = _make_id(stem, owner_path, proc_name)
add_node(proc_nid, f"{owner_path}/{proc_name}()", line)
add_edge(owner_nid, proc_nid, "method", line)
else:
proc_nid = _make_id(stem, proc_name)
add_node(proc_nid, f"{proc_name}()", line)
add_edge(file_nid, proc_nid, "contains", line)
block = _find_child(node, "block")
if block is not None:
function_bodies.append((proc_nid, block, owner_path))
return
if t in ("operator_override", "type_operator_override"):
return
for child in node.children:
walk(child, parent_type_path, parent_type_nid)
walk(root)
label_to_nids: dict[str, list[str]] = {}
path_to_nids: dict[str, list[str]] = {}
for n in nodes:
label = n["label"].strip("()")
last = label.rsplit("/", 1)[-1] if "/" in label else label
if last:
label_to_nids.setdefault(last.lower(), []).append(n["id"])
if label.startswith("/"):
path_to_nids.setdefault(label.lower(), []).append(n["id"])
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def _emit_call(caller_nid: str, callee: str, line: int, is_member: bool) -> None:
candidates = label_to_nids.get(callee.lower(), [])
tgt_nid = candidates[0] if len(candidates) == 1 else None
if tgt_nid and tgt_nid != caller_nid:
pair = (caller_nid, tgt_nid)
if pair in seen_call_pairs:
return
seen_call_pairs.add(pair)
edges.append({
"source": caller_nid, "target": tgt_nid, "relation": "calls",
"context": "call", "confidence": "EXTRACTED",
"source_file": str_path, "source_location": f"L{line}", "weight": 1.0,
})
else:
raw_calls.append({
"caller_nid": caller_nid, "callee": callee,
"is_member_call": is_member, "source_file": str_path,
"source_location": f"L{line}",
})
def walk_calls(body_node, caller_nid: str) -> None:
if body_node is None:
return
t = body_node.type
if t in ("proc_definition", "proc_override", "type_proc_definition",
"type_proc_override", "type_definition"):
return
if t == "call_expression":
name_node = body_node.child_by_field_name("name")
if name_node is not None:
callee = _read_text(name_node, source)
if callee and callee != "..":
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
is_member=False)
elif t == "field_proc_expression":
proc_field = body_node.child_by_field_name("proc")
if proc_field is not None:
callee = _read_text(proc_field, source)
if callee:
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
is_member=True)
elif t == "new_expression":
tp_node = _find_child(body_node, "type_path")
if tp_node is not None:
target_text = _type_path_text(tp_node)
candidates = path_to_nids.get(target_text.lower(), [])
tgt_nid = candidates[0] if len(candidates) == 1 else None
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)
edges.append({
"source": caller_nid, "target": tgt_nid,
"relation": "instantiates", "context": "call",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{body_node.start_point[0] + 1}",
"weight": 1.0,
})
for child in body_node.children:
walk_calls(child, caller_nid)
for proc_nid, block, _owner_path in function_bodies:
walk_calls(block, proc_nid)
return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls}
def _read_dmi_description(data: bytes) -> str:
"""Pull the BYOND metadata text out of a .dmi PNG, or empty string on failure."""
import struct
import zlib as _zlib
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return ""
i = 8
while i + 8 <= len(data):
length = struct.unpack(">I", data[i:i + 4])[0]
chunk_type = data[i + 4:i + 8]
payload = data[i + 8:i + 8 + length]
if chunk_type in (b"tEXt", b"zTXt"):
try:
null = payload.index(b"\x00")
except ValueError:
return ""
keyword = payload[:null]
if keyword == b"Description":
if chunk_type == b"zTXt":
return _zlib.decompressobj().decompress(payload[null + 2:], max_length=1024 * 1024).decode("utf-8", errors="replace")
return payload[null + 1:].decode("utf-8", errors="replace")
i += 8 + length + 4
return ""
def extract_dmi(path: Path) -> dict:
"""Extract icon state names from a .dmi (BYOND PNG icon sheet)."""
try:
data = path.read_bytes()
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str(path))
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": "L1"}]
edges: list[dict] = []
seen: set[str] = {file_nid}
description = _read_dmi_description(data)
if not description:
return {"nodes": nodes, "edges": edges}
line_no = 0
for raw_line in description.splitlines():
line_no += 1
stripped = raw_line.strip()
if not stripped.startswith("state ="):
continue
value = stripped.split("=", 1)[1].strip()
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
state_name = value[1:-1]
else:
state_name = value
if not state_name:
continue
nid = _make_id(stem, "state", state_name)
if nid in seen:
continue
seen.add(nid)
nodes.append({"id": nid, "label": f'"{state_name}"', "file_type": "code",
"source_file": str_path, "source_location": f"L{line_no}"})
edges.append({"source": file_nid, "target": nid, "relation": "contains",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line_no}", "weight": 1.0})
return {"nodes": nodes, "edges": edges}
_DMM_GRID_RE = re.compile(r"^\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)\s*=", re.MULTILINE)
def _split_dmm_tile(body: str) -> list[str]:
out: list[str] = []
buf: list[str] = []
depth = 0
in_string = False
escape = False
for ch in body:
if escape:
buf.append(ch)
escape = False
continue
if in_string:
buf.append(ch)
if ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
buf.append(ch)
elif ch in "({[":
depth += 1
buf.append(ch)
elif ch in ")}]":
depth -= 1
buf.append(ch)
elif ch == "," and depth == 0:
out.append("".join(buf).strip())
buf = []
else:
buf.append(ch)
tail = "".join(buf).strip()
if tail:
out.append(tail)
return out
def _dmm_type_path(entry: str) -> str:
brace = entry.find("{")
if brace != -1:
entry = entry[:brace]
return entry.strip()
def extract_dmm(path: Path) -> dict:
"""Extract type-path references from a .dmm map file's tile dictionary."""
try:
if path.stat().st_size > 50 * 1024 * 1024:
return {"nodes": [], "edges": [], "error": "file too large (>50 MB)"}
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
str_path = str(path)
file_nid = _make_id(str(path))
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": "L1"}]
edges: list[dict] = []
grid_match = _DMM_GRID_RE.search(text)
dict_text = text[:grid_match.start()] if grid_match else text
seen_targets: set[str] = set()
buf: list[str] = []
open_line = 0
depth = 0
in_string = False
escape = False
for line_idx, line in enumerate(dict_text.splitlines(), start=1):
for ch in line:
if escape:
escape = False
elif in_string:
if ch == "\\":
escape = True
elif ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "(":
if depth == 0:
open_line = line_idx
depth += 1
elif ch == ")":
depth -= 1
buf.append(ch)
buf.append("\n")
if depth == 0 and buf:
chunk = "".join(buf)
buf = []
lp = chunk.find("(")
rp = chunk.rfind(")")
if lp == -1 or rp == -1 or rp <= lp:
continue
inner = chunk[lp + 1:rp]
for entry in _split_dmm_tile(inner):
tpath = _dmm_type_path(entry)
if not tpath.startswith("/"):
continue
tgt = _make_id(tpath)
if tgt in seen_targets:
continue
seen_targets.add(tgt)
edges.append({"source": file_nid, "target": tgt, "relation": "uses",
"context": "map", "confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{open_line}", "weight": 1.0})
return {"nodes": nodes, "edges": edges}
_DMF_WINDOW_RE = re.compile(r'^\s*window\s+"([^"]+)"\s*$')
_DMF_ELEM_RE = re.compile(r'^\s*elem\s+"([^"]+)"\s*$')
_DMF_TYPE_RE = re.compile(r'^\s*type\s*=\s*(\S+)\s*$')
def extract_dmf(path: Path) -> dict:
"""Extract windows and controls from a .dmf interface file."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
str_path = str(path)
stem = _file_stem(path)
file_nid = _make_id(str(path))
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": "L1"}]
edges: list[dict] = []
seen: set[str] = {file_nid}
current_window_nid: str | None = None
current_elem_nid: str | None = None
current_elem_name: str | None = None
for line_idx, line in enumerate(text.splitlines(), start=1):
m = _DMF_WINDOW_RE.match(line)
if m:
name = m.group(1)
nid = _make_id(stem, "window", name)
if nid not in seen:
seen.add(nid)
nodes.append({"id": nid, "label": f'window "{name}"', "file_type": "code",
"source_file": str_path, "source_location": f"L{line_idx}"})
edges.append({"source": file_nid, "target": nid, "relation": "contains",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line_idx}", "weight": 1.0})
current_window_nid = nid
current_elem_nid = None
current_elem_name = None
continue
m = _DMF_ELEM_RE.match(line)
if m and current_window_nid is not None:
name = m.group(1)
nid = _make_id(stem, "elem", current_window_nid, name)
if nid not in seen:
seen.add(nid)
nodes.append({"id": nid, "label": f'elem "{name}"', "file_type": "code",
"source_file": str_path, "source_location": f"L{line_idx}"})
edges.append({"source": current_window_nid, "target": nid,
"relation": "contains", "confidence": "EXTRACTED",
"source_file": str_path, "source_location": f"L{line_idx}",
"weight": 1.0})
current_elem_nid = nid
current_elem_name = name
continue
m = _DMF_TYPE_RE.match(line)
if m and current_elem_nid is not None and current_elem_name is not None:
ctype = m.group(1)
for n in nodes:
if n["id"] == current_elem_nid and " [" not in n["label"]:
n["label"] = f'elem "{current_elem_name}" [{ctype}]'
break
return {"nodes": nodes, "edges": edges}
+228
View File
@@ -0,0 +1,228 @@
"""Elixir extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id
def extract_elixir(path: Path) -> dict:
"""Extract modules, functions, imports, and calls from a .ex/.exs file."""
try:
import tree_sitter_elixir as tselixir
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_elixir not installed"}
try:
language = Language(tselixir.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
_IMPORT_KEYWORDS = frozenset({"alias", "import", "require", "use"})
def _get_alias_text(node) -> str | None:
for child in node.children:
if child.type == "alias":
return source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
return None
def _get_alias_modules(node) -> list[str]:
"""Every module named by an alias/import/require/use argument.
Handles the single form (``alias Foo.Bar`` -> ``["Foo.Bar"]``) and the
multi-alias brace form (``alias Foo.{Bar, Baz}`` ->
``["Foo.Bar", "Foo.Baz"]``), which the grammar represents as a ``dot``
node holding the base alias and a trailing ``tuple`` of member aliases.
"""
def _text(n) -> str:
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
for child in node.children:
if child.type == "alias":
return [_text(child)]
if child.type == "dot":
base = None
tuple_node = None
for sub in child.children:
if sub.type == "alias" and base is None:
base = _text(sub)
elif sub.type == "tuple":
tuple_node = sub
if base and tuple_node is not None:
members = [_text(m) for m in tuple_node.children if m.type == "alias"]
if members:
return [f"{base}.{m}" for m in members]
return [_text(child)]
return []
def walk(node, parent_module_nid: str | None = None) -> None:
if node.type != "call":
for child in node.children:
walk(child, parent_module_nid)
return
identifier_node = None
arguments_node = None
do_block_node = None
for child in node.children:
if child.type == "identifier":
identifier_node = child
elif child.type == "arguments":
arguments_node = child
elif child.type == "do_block":
do_block_node = child
if identifier_node is None:
for child in node.children:
walk(child, parent_module_nid)
return
keyword = source[identifier_node.start_byte:identifier_node.end_byte].decode("utf-8", errors="replace")
line = node.start_point[0] + 1
if keyword == "defmodule":
module_name = _get_alias_text(arguments_node) if arguments_node else None
if not module_name:
return
module_nid = _make_id(stem, module_name)
add_node(module_nid, module_name, line)
add_edge(file_nid, module_nid, "contains", line)
if do_block_node:
for child in do_block_node.children:
walk(child, parent_module_nid=module_nid)
return
if keyword in ("def", "defp"):
func_name = None
if arguments_node:
for child in arguments_node.children:
if child.type == "call":
for sub in child.children:
if sub.type == "identifier":
func_name = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
break
elif child.type == "identifier":
func_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
break
if not func_name:
return
container = parent_module_nid or file_nid
func_nid = _make_id(container, func_name)
add_node(func_nid, f"{func_name}()", line)
if parent_module_nid:
add_edge(parent_module_nid, func_nid, "method", line)
else:
add_edge(file_nid, func_nid, "contains", line)
if do_block_node:
function_bodies.append((func_nid, do_block_node))
return
if keyword in _IMPORT_KEYWORDS and arguments_node:
for module_name in _get_alias_modules(arguments_node):
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports", line, context="import")
return
for child in node.children:
walk(child, parent_module_nid)
walk(root)
label_to_nid: dict[str, str] = {}
for n in nodes:
normalised = n["label"].strip("()").lstrip(".")
label_to_nid[normalised] = n["id"]
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
_SKIP_KEYWORDS = frozenset({
"def", "defp", "defmodule", "defmacro", "defmacrop",
"defstruct", "defprotocol", "defimpl", "defguard",
"alias", "import", "require", "use",
"if", "unless", "case", "cond", "with", "for",
})
def walk_calls(node, caller_nid: str) -> None:
if node.type != "call":
for child in node.children:
walk_calls(child, caller_nid)
return
for child in node.children:
if child.type == "identifier":
kw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
if kw in _SKIP_KEYWORDS:
for c in node.children:
walk_calls(c, caller_nid)
return
break
callee_name: str | None = None
is_member_call: bool = False
for child in node.children:
if child.type == "dot":
is_member_call = True
dot_text = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
parts = dot_text.rstrip(".").split(".")
if parts:
callee_name = parts[-1]
break
if child.type == "identifier":
callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
break
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
tgt_nid = label_to_nid.get(callee_name)
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)
add_edge(caller_nid, tgt_nid, "calls",
node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0,
context="call")
else:
raw_calls.append({
"caller_nid": caller_nid,
"callee": callee_name,
"is_member_call": is_member_call,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
for child in node.children:
walk_calls(child, caller_nid)
for caller_nid, body in function_bodies:
walk_calls(body, caller_nid)
clean_edges = [e for e in edges if e["source"] in seen_ids and
(e["target"] in seen_ids or e["relation"] == "imports")]
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, "input_tokens": 0, "output_tokens": 0}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
"""Fortran extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id, _read_text
_FORTRAN_CPP_EXTS = {".F", ".F90", ".F95", ".F03", ".F08"}
def _cpp_preprocess(path: Path) -> bytes:
"""Run cpp -w -P on a capital-F Fortran file and return preprocessed bytes.
Falls back to raw file bytes if cpp is not available. Capital-F extensions
conventionally require C preprocessor expansion (#ifdef MPI, #define REAL8, etc.)
before parsing.
Security (F-007): we pass `-nostdinc` and `-I /dev/null` so a malicious
source file containing `#include "/home/victim/.ssh/id_rsa"` (or any other
include directive) cannot inline arbitrary host files into the output that
we then ship to an LLM. Without these flags `cpp` happily resolves any
relative or absolute include path it can read, which is a corpus-side
file-exfiltration vector.
"""
import shutil
import subprocess
if not shutil.which("cpp"):
return path.read_bytes()
try:
# Pass an absolute path so a corpus file named like "-I/etc/x.F90" cannot
# be parsed by cpp as an option (cpp does not accept a "--" end-of-options
# terminator). An absolute path always begins with "/".
result = subprocess.run(
["cpp", "-w", "-P", "-nostdinc", "-I", "/dev/null", str(path.resolve())],
capture_output=True,
timeout=30,
)
if result.returncode == 0 and result.stdout:
return result.stdout
except Exception:
pass
return path.read_bytes()
def extract_fortran(path: Path) -> dict:
"""Extract programs, modules, subroutines, functions, use statements, and calls from Fortran files.
Capital-F extensions (.F, .F90, etc.) are run through the C preprocessor before
parsing so #ifdef/#define macros are resolved.
"""
try:
import tree_sitter_fortran as tsfortran
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-fortran not installed"}
try:
language = Language(tsfortran.language())
parser = Parser(language)
source = _cpp_preprocess(path) if path.suffix in _FORTRAN_CPP_EXTS else path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
scope_bodies: list[tuple[str, object]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def _fortran_name(stmt_node) -> str | None:
"""Extract name from a *_statement node. Fortran is case-insensitive; lowercase."""
for child in stmt_node.children:
if child.type in ("name", "identifier"):
return _read_text(child, source).lower()
return None
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def emit_signature_refs(scope_node, fn_nid: str, is_function: bool) -> None:
"""Emit references[parameter_type] / references[return_type] edges for
a subroutine/function based on its variable_declaration siblings."""
stmt_type = "function_statement" if is_function else "subroutine_statement"
stmt = next((c for c in scope_node.children if c.type == stmt_type), None)
if stmt is None:
return
param_names: set[str] = set()
params_node = next((c for c in stmt.children if c.type == "parameters"), None)
if params_node is not None:
for c in params_node.children:
if c.type == "identifier":
param_names.add(_read_text(c, source).lower())
result_name: str | None = None
if is_function:
result_node = next((c for c in stmt.children if c.type == "function_result"), None)
if result_node is not None:
res_id = next((c for c in result_node.children if c.type == "identifier"), None)
if res_id is not None:
result_name = _read_text(res_id, source).lower()
else:
# implicit result variable: same name as the function
result_name = _fortran_name(stmt)
for child in scope_node.children:
if child.type != "variable_declaration":
continue
derived = next((c for c in child.children if c.type == "derived_type"), None)
if derived is None:
continue
type_name_node = next((c for c in derived.children if c.type == "type_name"), None)
if type_name_node is None:
continue
type_name = _read_text(type_name_node, source).lower()
for var in child.children:
if var.type != "identifier":
continue
var_name = _read_text(var, source).lower()
var_line = var.start_point[0] + 1
if var_name in param_names:
tgt = ensure_named_node(type_name, var_line)
if tgt != fn_nid:
add_edge(fn_nid, tgt, "references", var_line, context="parameter_type")
elif is_function and var_name == result_name:
tgt = ensure_named_node(type_name, var_line)
if tgt != fn_nid:
add_edge(fn_nid, tgt, "references", var_line, context="return_type")
def walk_calls(node, scope_nid: str) -> None:
if node is None:
return
t = node.type
if t in ("subroutine", "function", "module", "program", "internal_procedures"):
return
# call FOO(args) — tree-sitter-fortran uses subroutine_call
if t == "subroutine_call":
name_node = next((c for c in node.children if c.type == "identifier"), None)
if name_node:
callee = _read_text(name_node, source).lower()
target_nid = _make_id(stem, callee)
add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1,
confidence="EXTRACTED", context="call")
# x = compute(args) — function invocations are `call_expression`, which
# shares Fortran's `name(...)` syntax with array indexing. Only emit a
# call edge when the callee resolves to a procedure defined in this file
# (an array variable produces no matching node), so array accesses can't
# fabricate spurious `calls` edges.
elif t == "call_expression":
name_node = next((c for c in node.children if c.type == "identifier"), None)
if name_node:
callee = _read_text(name_node, source).lower()
target_nid = _make_id(stem, callee)
if target_nid in seen_ids and target_nid != scope_nid:
add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1,
confidence="EXTRACTED", context="call")
for child in node.children:
walk_calls(child, scope_nid)
def walk(node, scope_nid: str) -> None:
t = node.type
if t == "program":
stmt = next((c for c in node.children if c.type == "program_statement"), None)
name = _fortran_name(stmt) if stmt else None
if name:
nid = _make_id(stem, name)
line = node.start_point[0] + 1
add_node(nid, name, line)
add_edge(file_nid, nid, "defines", line)
scope_bodies.append((nid, node))
for child in node.children:
walk(child, nid)
return
if t == "module":
stmt = next((c for c in node.children if c.type == "module_statement"), None)
name = _fortran_name(stmt) if stmt else None
if name:
nid = _make_id(stem, name)
line = node.start_point[0] + 1
add_node(nid, name, line)
add_edge(file_nid, nid, "defines", line)
for child in node.children:
walk(child, nid)
return
# subroutines/functions inside a module live under internal_procedures
if t == "internal_procedures":
for child in node.children:
walk(child, scope_nid)
return
if t == "derived_type_definition":
stmt = next((c for c in node.children if c.type == "derived_type_statement"), None)
if stmt is not None:
name_node = next((c for c in stmt.children if c.type == "type_name"), None)
if name_node is not None:
type_name = _read_text(name_node, source).lower()
type_nid = _make_id(stem, type_name)
line = node.start_point[0] + 1
add_node(type_nid, type_name, line)
add_edge(scope_nid, type_nid, "defines", line)
return
if t == "subroutine":
stmt = next((c for c in node.children if c.type == "subroutine_statement"), None)
name = _fortran_name(stmt) if stmt else None
if name:
nid = _make_id(stem, name)
line = node.start_point[0] + 1
add_node(nid, f"{name}()", line)
add_edge(scope_nid, nid, "defines", line)
scope_bodies.append((nid, node))
emit_signature_refs(node, nid, is_function=False)
for child in node.children:
walk(child, nid)
return
if t == "function":
stmt = next((c for c in node.children if c.type == "function_statement"), None)
name = _fortran_name(stmt) if stmt else None
if name:
nid = _make_id(stem, name)
line = node.start_point[0] + 1
add_node(nid, f"{name}()", line)
add_edge(scope_nid, nid, "defines", line)
scope_bodies.append((nid, node))
emit_signature_refs(node, nid, is_function=True)
for child in node.children:
walk(child, nid)
return
if t == "use_statement":
line = node.start_point[0] + 1
# tree-sitter-fortran uses module_name node for the used module
name_node = next((c for c in node.children if c.type in ("module_name", "name", "identifier")), None)
if name_node:
mod_name = _read_text(name_node, source).lower()
imp_nid = _make_id(mod_name)
add_node(imp_nid, mod_name, line)
add_edge(scope_nid, imp_nid, "imports", line, context="use")
return
for child in node.children:
walk(child, scope_nid)
walk(root, file_nid)
_stmt_headers = {
"subroutine_statement", "function_statement",
"program_statement", "module_statement",
}
for scope_nid, body_node in scope_bodies:
for child in body_node.children:
if child.type not in _stmt_headers:
walk_calls(child, scope_nid)
return {"nodes": nodes, "edges": edges}
+396
View File
@@ -0,0 +1,396 @@
"""Go extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
_GO_PREDECLARED_TYPES = frozenset({
"bool", "byte", "complex64", "complex128", "error", "float32", "float64",
"int", "int8", "int16", "int32", "int64", "rune", "string",
"uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "any", "comparable",
})
def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Go type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t == "type_identifier":
text = _read_text(node, source)
if text and text not in _GO_PREDECLARED_TYPES:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "qualified_type":
text = _read_text(node, source).rsplit(".", 1)[-1]
if text and text not in _GO_PREDECLARED_TYPES:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "generic_type":
type_field = node.child_by_field_name("type")
if type_field is not None:
sub: list[tuple[str, str]] = []
_go_collect_type_refs(type_field, source, generic, sub)
out.extend(sub)
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.is_named:
_go_collect_type_refs(arg, source, True, out)
return
if t in ("pointer_type", "slice_type", "array_type", "map_type",
"channel_type", "parenthesized_type"):
for c in node.children:
if c.is_named:
_go_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_go_collect_type_refs(c, source, generic, out)
def extract_go(path: Path) -> dict:
"""Extract functions, methods, type declarations, and imports from a .go file."""
try:
import tree_sitter_go as tsgo
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-go not installed"}
try:
language = Language(tsgo.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 = _file_stem(path)
# Use directory name as package scope so methods on the same type across
# multiple files in a package share one canonical type node.
pkg_scope = path.parent.name or stem
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object]] = []
go_imported_pkgs: set[str] = set() # local names of imported packages
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(pkg_scope, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't declared in this file, so this is a cross-file reference
# (e.g. a type defined in another file of the package). Emit a SOURCELESS
# stub — like the inheritance-base path in the other extractors — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def emit_go_method_refs(func_node, func_nid: str, line: int) -> None:
params = func_node.child_by_field_name("parameters")
if params is not None:
for p in params.children:
if p.type != "parameter_declaration":
continue
type_node = p.child_by_field_name("type")
refs: list[tuple[str, str]] = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
result = func_node.child_by_field_name("result")
if result is not None:
if result.type == "parameter_list":
for p in result.children:
if p.type != "parameter_declaration":
continue
type_node = p.child_by_field_name("type")
if type_node is None:
for c in p.children:
if c.is_named:
type_node = c
break
refs = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
else:
refs = []
_go_collect_type_refs(result, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
def walk(node) -> None:
t = node.type
if t == "function_declaration":
name_node = node.child_by_field_name("name")
if name_node:
func_name = _read_text(name_node, source)
line = node.start_point[0] + 1
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
emit_go_method_refs(node, func_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
return
if t == "method_declaration":
receiver = node.child_by_field_name("receiver")
receiver_type: str | None = None
if receiver:
for param in receiver.children:
if param.type == "parameter_declaration":
type_node = param.child_by_field_name("type")
if type_node:
receiver_type = _read_text(type_node, source).lstrip("*").strip()
break
name_node = node.child_by_field_name("name")
if not name_node:
return
method_name = _read_text(name_node, source)
line = node.start_point[0] + 1
if receiver_type:
parent_nid = _make_id(pkg_scope, receiver_type)
add_node(parent_nid, receiver_type, line)
method_nid = _make_id(parent_nid, method_name)
add_node(method_nid, f".{method_name}()", line)
add_edge(parent_nid, method_nid, "method", line)
else:
method_nid = _make_id(stem, method_name)
add_node(method_nid, f"{method_name}()", line)
add_edge(file_nid, method_nid, "contains", line)
emit_go_method_refs(node, method_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((method_nid, body))
return
if t == "type_declaration":
for child in node.children:
if child.type != "type_spec":
continue
name_node = child.child_by_field_name("name")
if not name_node:
continue
type_name = _read_text(name_node, source)
line = child.start_point[0] + 1
type_nid = _make_id(pkg_scope, type_name)
add_node(type_nid, type_name, line)
add_edge(file_nid, type_nid, "contains", line)
# Type body: struct fields (with embeds) or interface embedding.
type_body = None
for tc in child.children:
if tc.type in ("struct_type", "interface_type"):
type_body = tc
break
if type_body is None:
continue
if type_body.type == "struct_type":
for fdl in type_body.children:
if fdl.type != "field_declaration_list":
continue
for field in fdl.children:
if field.type != "field_declaration":
continue
has_name = any(
fc.type == "field_identifier" for fc in field.children
)
type_node = field.child_by_field_name("type")
if type_node is None:
for fc in field.children:
if fc.is_named and fc.type != "field_identifier":
type_node = fc
break
refs: list[tuple[str, str]] = []
_go_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
if tgt == type_nid:
continue
if not has_name and role == "type":
add_edge(type_nid, tgt, "embeds",
field.start_point[0] + 1)
else:
ctx = "generic_arg" if role == "generic_arg" else "field"
add_edge(type_nid, tgt, "references",
field.start_point[0] + 1, context=ctx)
elif type_body.type == "interface_type":
for elem in type_body.children:
if elem.type != "type_elem":
continue
refs = []
for sub in elem.children:
if sub.is_named:
_go_collect_type_refs(sub, source, False, refs)
for ref_name, role in refs:
tgt = ensure_named_node(ref_name, elem.start_point[0] + 1)
if tgt == type_nid:
continue
if role == "type":
add_edge(type_nid, tgt, "embeds",
elem.start_point[0] + 1)
else:
add_edge(type_nid, tgt, "references",
elem.start_point[0] + 1, context="generic_arg")
return
if t == "import_declaration":
for child in node.children:
if child.type == "import_spec_list":
for spec in child.children:
if spec.type == "import_spec":
path_node = spec.child_by_field_name("path")
if path_node:
raw = _read_text(path_node, source).strip('"')
# Prefix with go_pkg_ so stdlib names (e.g. "context")
# don't collide with local files of the same basename.
tgt_nid = _make_id("go", "pkg", raw)
add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1, context="import")
# Track local name (alias or last path segment)
alias = spec.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
if local_name and local_name != "_" and local_name != ".":
go_imported_pkgs.add(local_name)
elif child.type == "import_spec":
path_node = child.child_by_field_name("path")
if path_node:
raw = _read_text(path_node, source).strip('"')
tgt_nid = _make_id("go", "pkg", raw)
add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1, context="import")
alias = child.child_by_field_name("name")
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
if local_name and local_name != "_" and local_name != ".":
go_imported_pkgs.add(local_name)
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] = n["id"]
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def walk_calls(node, caller_nid: str) -> None:
if node.type in ("function_declaration", "method_declaration"):
return
if node.type == "call_expression":
func_node = node.child_by_field_name("function")
callee_name: str | None = None
is_member_call: bool = False
if func_node:
if func_node.type == "identifier":
callee_name = _read_text(func_node, source)
elif func_node.type == "selector_expression":
field = func_node.child_by_field_name("field")
operand = func_node.child_by_field_name("operand")
receiver_name = _read_text(operand, source) if operand else ""
# Package-qualified call (e.g. fmt.Println) → allow cross-file resolution.
# Receiver method call (e.g. s.logger.Log) → skip, no import evidence.
is_member_call = receiver_name not in go_imported_pkgs
if field:
callee_name = _read_text(field, source)
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
tgt_nid = label_to_nid.get(callee_name)
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",
"context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
elif callee_name:
raw_calls.append({
"caller_nid": caller_nid,
"callee": callee_name,
"is_member_call": is_member_call,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
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, "raw_calls": raw_calls}
+209
View File
@@ -0,0 +1,209 @@
"""Json_config extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id, _read_text
_CONFIG_JSON_NAMES = frozenset({
"package.json", "tsconfig.json", "jsconfig.json", "composer.json",
"deno.json", "deno.jsonc", "bower.json", "manifest.json",
"app.json", "now.json", "vercel.json", "angular.json", "nest-cli.json",
"biome.json", "biome.jsonc", "renovate.json", ".babelrc", ".babelrc.json",
".eslintrc.json", ".prettierrc.json", ".prettierrc", "babel.config.json",
})
_CONFIG_JSON_KEYS = frozenset({
"dependencies", "devDependencies", "peerDependencies",
"optionalDependencies", "bundleDependencies", "bundledDependencies",
"extends", "$ref", "$schema", "compilerOptions",
})
def _is_config_json(path: Path, obj_node, source: bytes) -> bool:
"""True if a .json file is a recognized config/manifest worth AST-extracting.
Matches by filename first (cheap), then falls back to a top-level key probe
so arbitrarily-named config files (e.g. ``api.tsconfig.json``,
``foo.eslintrc.json``) are still picked up. Returns False for data JSON so it
is skipped by the structural pass (#1224)."""
name = path.name.casefold()
if name in _CONFIG_JSON_NAMES:
return True
# Common compound config names: *.eslintrc.json, *.prettierrc.json, etc.
if name.endswith((".eslintrc.json", ".prettierrc.json", ".babelrc.json",
"tsconfig.json", "jsconfig.json")):
return True
# Top-level key probe: scan the root object's immediate keys (no deep walk).
for top_key in obj_node.children:
if top_key.type != "pair":
continue
key_node = top_key.child_by_field_name("key")
if key_node is None:
continue
kc = key_node.child_by_field_name("string_content")
text = _read_text(kc, source) if kc else _read_text(key_node, source).strip('"\'')
if text in _CONFIG_JSON_KEYS:
return True
return False
def extract_json(path: Path) -> dict:
"""Extract structure and dependency edges from a *config/manifest* .json file.
Data-shaped JSON (eval fixtures, datasets, GeoJSON, API response dumps) is
deliberately skipped — AST-walking it produced hundreds of orphan key-nodes
and duplicate communities that swamped real structure (#1224). Recognition
is by filename (package.json, tsconfig.json, …) or a top-level key probe
(dependencies / extends / $ref / $schema / compilerOptions)."""
_JSON_MAX_BYTES = 1_048_576 # 1 MiB — skip large fixture dumps / GeoJSON blobs
try:
import tree_sitter_json as tsjson
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-json not installed"}
try:
# Bounded read instead of stat()+read() to eliminate TOCTOU (J-1):
# read one byte beyond the limit so we can detect oversized files even
# if the file grows between stat and read.
with path.open("rb") as _f:
source = _f.read(_JSON_MAX_BYTES + 1)
if len(source) > _JSON_MAX_BYTES:
return {"nodes": [], "edges": [], "error": "json file too large to index"}
language = Language(tsjson.language())
parser = Parser(language)
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
# Keys whose string values become imports (package.json dep blocks)
_DEP_KEYS = frozenset({
"dependencies", "devDependencies", "peerDependencies",
"optionalDependencies", "bundleDependencies", "bundledDependencies",
})
def add_node(nid: str, label: str, line: int, file_type: str = "code") -> None:
if nid and nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": file_type,
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(src: str, tgt: str, relation: str, line: int,
context: str | None = None) -> None:
if not src or not tgt or src == tgt:
return
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def _key_text(pair_node) -> str | None:
"""Extract the string content of a pair's key."""
key_node = pair_node.child_by_field_name("key")
if key_node is None:
return None
if key_node.type == "string":
content = key_node.child_by_field_name("string_content")
if content:
return _read_text(content, source)
# fallback: strip surrounding quotes
raw = _read_text(key_node, source)
return raw.strip('"\'')
return _read_text(key_node, source)
def _val_node(pair_node):
return pair_node.child_by_field_name("value")
def walk_object(obj_node, parent_nid: str, parent_key: str | None,
depth: int, pair_count: list) -> None:
if depth > 6:
return
for child in obj_node.children:
if child.type != "pair":
continue
if pair_count[0] >= 500: # check per-pair so the cap is honoured exactly (J-3)
return
pair_count[0] += 1
key = _key_text(child)
if not key:
continue
key_nid = _make_id(stem, *(([parent_key] if parent_key else []) + [key]))
if not key_nid:
continue
line = child.start_point[0] + 1
add_node(key_nid, key, line)
add_edge(parent_nid, key_nid, "contains", line)
val = _val_node(child)
if val is None:
continue
if val.type == "object":
walk_object(val, key_nid, key, depth + 1, pair_count)
elif val.type == "array":
# For "extends" arrays (tsconfig, eslint): each string element.
# Prefix with "ref_" so external refs don't collide with real
# code/file node IDs that share the same collapsed _make_id (J-4).
for item in val.children:
if item.type == "string":
content = item.child_by_field_name("string_content")
ref = _read_text(content, source) if content else _read_text(item, source).strip('"\'')
if ref:
ref_nid = _make_id("ref", ref)
if ref_nid:
add_node(ref_nid, ref, line, file_type="concept")
add_edge(key_nid, ref_nid, "extends", line, context="import")
elif val.type == "string":
content = val.child_by_field_name("string_content")
val_text = _read_text(content, source) if content else _read_text(val, source).strip('"\'')
if key == "extends" and val_text:
# Namespace external refs to avoid ID collision with file nodes (J-4)
ref_nid = _make_id("ref", val_text)
if ref_nid:
add_node(ref_nid, val_text, line, file_type="concept")
add_edge(file_nid, ref_nid, "extends", line, context="import")
elif key == "$ref" and val_text:
# Namespace $ref values to prevent edge hijacking into code nodes (J-4)
ref_nid = _make_id("ref", val_text)
if ref_nid:
add_edge(parent_nid, ref_nid, "references", line)
elif parent_key in _DEP_KEYS and val_text:
dep_nid = _make_id(key)
if dep_nid:
add_node(dep_nid, key, line, file_type="concept")
add_edge(key_nid, dep_nid, "imports", line, context="import")
# Entry: find root document → object
doc = root
if doc.type == "document" and doc.child_count > 0:
doc = doc.children[0]
if doc.type == "object":
# Only AST-extract recognized config/manifest JSON. Data JSON (fixtures,
# datasets, GeoJSON, API dumps) is skipped so it doesn't explode into
# orphan key-nodes (#1224); it's left to the LLM semantic pass.
if not _is_config_json(path, doc, source):
return {"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
walk_object(doc, file_nid, None, 0, [0])
else:
# Top-level array or scalar => data JSON, never a config/manifest.
return {"nodes": [], "edges": [], "skipped": "data json (non-object root)"}
return {"nodes": nodes, "edges": edges}
+275
View File
@@ -0,0 +1,275 @@
"""julia — moved verbatim from graphify/extract.py."""
from __future__ import annotations
from graphify.extractors.base import _file_stem, _make_id, _read_text
from graphify.extractors.engine import _semantic_reference_edge
from pathlib import Path
def extract_julia(path: Path) -> dict:
"""Extract modules, structs, functions, imports, and calls from a .jl file."""
try:
import tree_sitter_julia as tsjulia
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-julia not installed"}
try:
language = Language(tsjulia.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def _func_name_from_signature(sig_node) -> str | None:
"""Extract function name from a Julia signature node (call_expression > identifier)."""
for child in sig_node.children:
if child.type == "call_expression":
callee = child.children[0] if child.children else None
if callee and callee.type == "identifier":
return _read_text(callee, source)
return None
def walk_calls(body_node, func_nid: str) -> None:
if body_node is None:
return
t = body_node.type
if t in ("function_definition", "short_function_definition"):
return
if t == "call_expression" and body_node.children:
callee = body_node.children[0]
# Direct call: foo(...)
if callee.type == "identifier":
callee_name = _read_text(callee, source)
target_nid = _make_id(stem, callee_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
confidence="EXTRACTED", context="call")
# Method call: obj.method(...)
elif callee.type == "field_expression" and len(callee.children) >= 3:
method_node = callee.children[-1]
method_name = _read_text(method_node, source)
target_nid = _make_id(stem, method_name)
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
confidence="EXTRACTED", context="call")
for child in body_node.children:
walk_calls(child, func_nid)
def walk(node, scope_nid: str) -> None:
t = node.type
# Module
if t == "module_definition":
name_node = next((c for c in node.children if c.type == "identifier"), None)
if name_node:
mod_name = _read_text(name_node, source)
mod_nid = _make_id(stem, mod_name)
line = node.start_point[0] + 1
add_node(mod_nid, mod_name, line)
add_edge(file_nid, mod_nid, "defines", line)
for child in node.children:
walk(child, mod_nid)
return
# Struct (struct / mutable struct — both map to struct_definition in tree-sitter-julia)
if t == "struct_definition":
# type_head may contain: identifier (simple) or binary_expression (Foo <: Bar)
type_head = next((c for c in node.children if c.type == "type_head"), None)
if not type_head:
return
struct_name: str | None = None
super_name: str | None = None
bin_expr = next((c for c in type_head.children if c.type == "binary_expression"), None)
if bin_expr:
identifiers = [c for c in bin_expr.children if c.type == "identifier"]
if identifiers:
struct_name = _read_text(identifiers[0], source)
if len(identifiers) >= 2:
super_name = _read_text(identifiers[-1], source)
else:
name_node = next((c for c in type_head.children if c.type == "identifier"), None)
if name_node:
struct_name = _read_text(name_node, source)
if not struct_name:
return
struct_nid = _make_id(stem, struct_name)
line = node.start_point[0] + 1
add_node(struct_nid, struct_name, line)
add_edge(scope_nid, struct_nid, "defines", line)
if super_name:
add_edge(struct_nid, ensure_named_node(super_name, line),
"inherits", line, confidence="EXTRACTED")
# Field types: each `name::Type` lowers to a typed_expression child of struct_definition
for child in node.children:
if child.type == "typed_expression":
type_ids = [c for c in child.children if c.type == "identifier"]
if len(type_ids) >= 2:
field_line = child.start_point[0] + 1
type_name = _read_text(type_ids[-1], source)
type_nid = ensure_named_node(type_name, field_line)
edges.append(_semantic_reference_edge(
struct_nid, type_nid, "field", str_path, field_line))
return
# Abstract type
if t == "abstract_definition":
# type_head > identifier
type_head = next((c for c in node.children if c.type == "type_head"), None)
if type_head:
name_node = next((c for c in type_head.children if c.type == "identifier"), None)
if name_node:
abs_name = _read_text(name_node, source)
abs_nid = _make_id(stem, abs_name)
line = node.start_point[0] + 1
add_node(abs_nid, abs_name, line)
add_edge(scope_nid, abs_nid, "defines", line)
return
# Function: function foo(...) ... end
if t == "function_definition":
sig_node = next((c for c in node.children if c.type == "signature"), None)
if sig_node:
func_name = _func_name_from_signature(sig_node)
if func_name:
func_nid = _make_id(stem, func_name)
line = node.start_point[0] + 1
add_node(func_nid, f"{func_name}()", line)
add_edge(scope_nid, func_nid, "defines", line)
function_bodies.append((func_nid, node))
return
# Short function: foo(x) = expr
if t == "assignment":
lhs = node.children[0] if node.children else None
if lhs and lhs.type == "call_expression" and lhs.children:
callee = lhs.children[0]
if callee.type == "identifier":
func_name = _read_text(callee, source)
func_nid = _make_id(stem, func_name)
line = node.start_point[0] + 1
add_node(func_nid, f"{func_name}()", line)
add_edge(scope_nid, func_nid, "defines", line)
# Only walk the RHS (index 2 after lhs and operator) to avoid self-loops
rhs = node.children[-1] if len(node.children) >= 3 else None
if rhs:
function_bodies.append((func_nid, rhs))
return
# Using / Import
if t in ("using_statement", "import_statement"):
line = node.start_point[0] + 1
def _julia_mod_name(n):
# identifier (`Foo`), scoped_identifier (`Base.Threads`), or
# import_path (relative `..Sibling`) -> the module name. Only bare
# identifiers were handled, so qualified/relative imports — and the
# scoped package of a `selected_import` — were silently dropped.
if n.type == "import_path":
ids = [c for c in n.children if c.type == "identifier"]
return _read_text(ids[-1], source) if ids else None
if n.type in ("identifier", "scoped_identifier"):
return _read_text(n, source)
return None
def _emit_import(name):
if not name:
return
imp_nid = _make_id(name)
add_node(imp_nid, name, line)
add_edge(scope_nid, imp_nid, "imports", line, context="import")
for child in node.children:
if child.type in ("identifier", "scoped_identifier", "import_path"):
_emit_import(_julia_mod_name(child))
elif child.type == "selected_import":
# `import Base.Threads: nthreads` — the package (first named
# child) may itself be a scoped_identifier/import_path.
pkg = next(
(c for c in child.children
if c.type in ("identifier", "scoped_identifier", "import_path")),
None,
)
if pkg is not None:
_emit_import(_julia_mod_name(pkg))
return
for child in node.children:
walk(child, scope_nid)
walk(root, file_nid)
for func_nid, body_node in function_bodies:
# For function_definition nodes, walk children directly to avoid
# the boundary check returning early on the top-level node itself.
# Skip the "signature" child — it contains the function's own call_expression
# which would create a self-loop.
if body_node.type == "function_definition":
for child in body_node.children:
if child.type != "signature":
walk_calls(child, func_nid)
else:
walk_calls(body_node, func_nid)
return {"nodes": nodes, "edges": edges}
+176
View File
@@ -0,0 +1,176 @@
"""Markdown extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
import os
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
_MD_INLINE_LINK_RE = re.compile(r'(?<!\!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+[^)]*)?\)')
_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)>?')
_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]')
_MD_LINKABLE_EXTS = {".md", ".mdx", ".qmd", ".markdown", ".rst", ".txt"}
def _resolve_markdown_link(raw: str, source_dir: Path) -> "Path | None":
"""Resolve a markdown link target to the absolute path of a sibling document.
Returns the resolved (normalized, not necessarily existing) path when the
target is a *local* relative/absolute file-path link to a document, or None
when it should be skipped: external URLs (http/https/mailto/protocol-
relative/data), pure in-page anchors (``#section``), and links to non-doc
file types (code/assets are handled by their own extractors).
The anchor fragment (``#section``) and query (``?x=1``) are stripped before
resolution so ``./repo.md#setup`` resolves to the same node as ``./repo.md``.
Extension-less targets (typical of wikilinks) are treated as sibling ``.md``.
"""
target = raw.strip()
if not target:
return None
# Drop anchor / query so #section links still resolve to the target doc.
target = target.split("#", 1)[0].split("?", 1)[0].strip()
if not target:
return None
low = target.lower()
if "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")):
return None
suffix = Path(target).suffix.lower()
if suffix == "":
target = target + ".md"
suffix = ".md"
if suffix not in _MD_LINKABLE_EXTS:
return None
candidate = Path(target)
if not candidate.is_absolute():
candidate = source_dir / candidate
return Path(os.path.normpath(str(candidate)))
def extract_markdown(path: Path) -> dict:
"""Extract structural nodes and edges from a Markdown file.
Produces nodes for:
- The file itself
- Each heading (# / ## / ### etc.)
Produces edges for:
- file --contains--> heading
- parent heading --contains--> child heading (nesting by level)
- heading --references--> other node (when backtick `Name` matches a known pattern)
- file --references--> linked document, for inline ``[text](./other.md)``,
reference-style ``[label]: ./other.md`` and ``[[wikilink]]`` links, so a
hub doc (``index.md`` / ``table-of-contents.md``) becomes a real hub node
instead of an under-connected orphan (#1376). The target node ID is built
from the resolved target path with the same recipe as the target file's
own node, so the edge merges into that node (no ghost node). External
URLs, in-page anchors, images and non-document targets are skipped.
Fenced code blocks (``` ... ```) are skipped during parsing so their
contents don't get treated as headings, but no node is emitted for
them — they were always orphans (only a single contains edge to the
parent doc) and inflated the disconnected-component count (#1077).
No tree-sitter dependency — pure line-by-line parsing.
"""
try:
source = path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
def add_node(nid: str, label: str, line: int, file_type: str = "document") -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": file_type,
"source_file": str_path, "source_location": f"L{line}"})
def add_edge(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(str(path))
add_node(file_nid, path.name, 1)
source_dir = path.parent
# Dedup link edges by resolved target node so a hub doc that links to the
# same sibling many times yields one edge, not N (keeps weights meaningful).
linked_targets: set[str] = set()
def add_link(raw: str, line: int) -> None:
resolved = _resolve_markdown_link(raw, source_dir)
if resolved is None:
return
# Build the target ID with the SAME recipe as the target file's own
# node (_make_id(str(path)) at extract time, canonicalized to
# _file_node_id(rel) by the extract() post-pass). Using the absolute
# resolved path means both endpoints get remapped identically, so the
# edge merges into the existing doc node instead of spawning a ghost.
tgt_nid = _make_id(str(resolved))
if tgt_nid == file_nid or tgt_nid in linked_targets:
return
linked_targets.add(tgt_nid)
add_edge(file_nid, tgt_nid, "references", line)
# Track heading stack for nesting: [(level, nid), ...]
heading_stack: list[tuple[int, str]] = []
in_code_block = False
lines = source.splitlines()
for line_num_0, line_text in enumerate(lines):
line_num = line_num_0 + 1
# Skip over fenced code blocks so their contents are not parsed as
# headings, but do not emit nodes/edges for them (#1077).
stripped = line_text.strip()
if stripped.startswith("```"):
in_code_block = not in_code_block
continue
if in_code_block:
continue
# Markdown links -> document references (#1376). Scanned on every
# non-fenced line (including heading lines, which the heading branch
# below `continue`s past) so links anywhere in the doc are captured.
for m in _MD_INLINE_LINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
for m in _MD_WIKILINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
# Detect headings: # Heading, ## Heading, etc.
heading_match = re.match(r'^(#{1,6})\s+(.+)', line_text)
if heading_match:
level = len(heading_match.group(1))
title = heading_match.group(2).strip()
h_nid = _make_id(stem, title)
# Avoid duplicate heading IDs by appending line number
if h_nid in seen_ids:
h_nid = _make_id(stem, title, str(line_num))
add_node(h_nid, title, line_num)
# Pop headings at same or deeper level
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
# Connect to parent heading or file
parent = heading_stack[-1][1] if heading_stack else file_nid
add_edge(parent, h_nid, "contains", line_num)
heading_stack.append((level, h_nid))
continue
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
+119
View File
@@ -0,0 +1,119 @@
"""models — moved verbatim from graphify/extract.py."""
from __future__ import annotations
from typing import Any, Callable
from pathlib import Path
from dataclasses import dataclass, field
_WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {}
_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte"}
@dataclass
class LanguageConfig:
ts_module: str # e.g. "tree_sitter_python"
ts_language_fn: str = "language" # attr to call: e.g. tslang.language()
class_types: frozenset = frozenset()
function_types: frozenset = frozenset()
import_types: frozenset = frozenset()
call_types: frozenset = frozenset()
static_prop_types: frozenset = frozenset()
helper_fn_names: frozenset = frozenset()
container_bind_methods: frozenset = frozenset()
event_listener_properties: frozenset = frozenset()
# Name extraction
name_field: str = "name"
name_fallback_child_types: tuple = ()
# Body detection
body_field: str = "body"
body_fallback_child_types: tuple = () # e.g. ("declaration_list", "compound_statement")
# Call name extraction
call_function_field: str = "function" # field on call node for callee
call_accessor_node_types: frozenset = frozenset() # member/attribute nodes
call_accessor_field: str = "attribute" # field on accessor for method name
call_accessor_object_field: str = "" # field on accessor for the receiver/object
# Stop recursion at these types in walk_calls
function_boundary_types: frozenset = frozenset()
# Import handler: called for import nodes instead of generic handling
import_handler: Callable | None = None
# Optional custom name resolver for functions (C, C++ declarator unwrapping)
resolve_function_name_fn: Callable | None = None
# Extra label formatting for functions: if True, functions get "name()" label
function_label_parens: bool = True
# Extra walk hook called after generic dispatch (for JS arrow functions, C# namespaces, etc.)
extra_walk_fn: Callable | None = None
@dataclass(frozen=True)
class _SymbolDeclarationFact:
file_path: Path
name: str
line: int
@dataclass(frozen=True)
class _SymbolImportFact:
file_path: Path
local_name: str
target_path: Path
imported_name: str
line: int
@dataclass(frozen=True)
class _SymbolAliasFact:
file_path: Path
alias: str
target_name: str
line: int
@dataclass(frozen=True)
class _SymbolExportFact:
file_path: Path
exported_name: str
line: int
local_name: str | None = None
target_path: Path | None = None
target_name: str | None = None
@dataclass(frozen=True)
class _StarExportFact:
file_path: Path
target_path: Path
line: int
@dataclass(frozen=True)
class _NamespaceExportFact:
file_path: Path
exported_name: str
target_path: Path
line: int
@dataclass(frozen=True)
class _SymbolUseFact:
file_path: Path
source_id: str
local_name: str
relation: str
context: str
line: int
@dataclass
class _SymbolResolutionFacts:
declarations: list[_SymbolDeclarationFact] = field(default_factory=list)
imports: list[_SymbolImportFact] = field(default_factory=list)
aliases: list[_SymbolAliasFact] = field(default_factory=list)
exports: list[_SymbolExportFact] = field(default_factory=list)
star_exports: list[_StarExportFact] = field(default_factory=list)
namespace_exports: list[_NamespaceExportFact] = field(default_factory=list)
uses: list[_SymbolUseFact] = field(default_factory=list)
# File-to-file submodule imports from `from pkg import submod` (#1146).
# Each entry is (importing_file, submodule_file, line).
module_imports: list[tuple[Path, Path, int]] = field(default_factory=list)
+430
View File
@@ -0,0 +1,430 @@
"""objc — moved verbatim from graphify/extract.py."""
from __future__ import annotations
from graphify.extractors.base import _file_stem, _make_id, _read_text
from graphify.extractors.engine import _cpp_declarator_name, _semantic_reference_edge
from graphify.extractors.resolution import _resolve_c_include_path
from pathlib import Path
from typing import Any
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
"""Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``)
in a method body, for receiver typing in the cross-file message-send pass
(#1556). Only a capitalized ``type_identifier`` with a single named declarator
is recorded; a built-in/lower-cased type or an un-nameable declarator is skipped
(precision over recall). Reuses the C++ declarator unwrapper (identical grammar).
"""
stack = [body_node]
while stack:
n = stack.pop()
if n.type == "method_definition" and n is not body_node:
continue
if n.type == "declaration":
type_node = n.child_by_field_name("type")
if type_node is None:
for c in n.children:
if c.type == "type_identifier":
type_node = c
break
if type_node is not None and type_node.type == "type_identifier":
type_name = _read_text(type_node, source).strip()
declarators = [
c for c in n.children
if c.type in ("identifier", "pointer_declarator", "init_declarator")
]
if type_name and type_name[:1].isupper() and len(declarators) == 1:
var = _cpp_declarator_name(declarators[0], source)
if var and var not in table:
table[var] = type_name
for c in n.children:
stack.append(c)
def extract_objc(path: Path) -> dict:
"""Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files."""
try:
import tree_sitter_objc as tsobjc
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_objc not installed"}
try:
language = Language(tsobjc.language())
parser = Parser(language)
source = path.read_bytes()
# tree-sitter-objc cannot expand these argument-less annotation macros (no
# trailing ';'), and their presence before @interface makes the parser fail to
# emit a class_interface node (#1475). Blank them to equal-length spaces so byte
# offsets / line numbers are preserved and the interface parses.
_OBJC_BLANK_MACROS = (b"NS_ASSUME_NONNULL_BEGIN", b"NS_ASSUME_NONNULL_END")
for _m in _OBJC_BLANK_MACROS:
source = source.replace(_m, b" " * len(_m))
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
method_bodies: list[tuple[str, Any, str]] = []
# #1556: unresolved message sends saved for the cross-file ObjC resolver, plus a
# per-file `var -> ClassName` table from `Foo *f = ...;` local declarations.
raw_calls: list[dict] = []
objc_type_table: dict[str, str] = {}
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def _read(node) -> str:
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
def _get_name(node, field: str) -> str | None:
n = node.child_by_field_name(field)
return _read(n) if n else None
def _type_identifiers(node):
"""Yield every type_identifier under a property's type node, descending
through generic_specifier/type_name so NSArray<Product *> yields both
NSArray and the element type Product (the generic case was invisible
because the type was wrapped in a generic_specifier, not a bare
type_identifier child) (#1475)."""
if node.type == "type_identifier":
yield node
return
for c in node.children:
yield from _type_identifiers(c)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def walk(node, parent_nid: str | None = None) -> None:
t = node.type
line = node.start_point[0] + 1
if t == "preproc_include":
# #import <Foundation/Foundation.h> or #import "MyClass.h"
for child in node.children:
if child.type == "system_lib_string":
raw = _read(child).strip("<>")
module = raw.split("/")[-1].replace(".h", "")
if module:
tgt_nid = _make_id(module)
add_edge(file_nid, tgt_nid, "imports", line, context="import")
elif child.type == "string_literal":
# recurse into string_literal to find string_content
for sub in child.children:
if sub.type == "string_content":
raw = _read(sub)
# Resolve the quoted include to a real file so the target id
# matches the (possibly disambiguated) node id _make_id gives
# that file; the bare-stem id never survives
# _disambiguate_colliding_node_ids when a .h/.m pair exists,
# so the edge dangled and was dropped (#1475).
resolved = _resolve_c_include_path(raw, str_path)
if resolved is not None:
add_edge(file_nid, _make_id(str(resolved)), "imports", line, context="import")
else:
module = raw.split("/")[-1].replace(".h", "")
if module:
add_edge(file_nid, _make_id(module), "imports", line, context="import")
return
if t == "module_import":
# @import Foundation; / @import Foundation.NSString;
path_node = node.child_by_field_name("path")
if path_node is not None:
module = _read(path_node).split(".")[0].strip()
if module:
add_edge(file_nid, _make_id(module), "imports", line, context="import")
return
if t == "class_interface":
# @interface ClassName : SuperClass <Protocols>
# children: @interface, identifier(name), ':', identifier(super), parameterized_arguments, ...
identifiers = [c for c in node.children if c.type == "identifier"]
if not identifiers:
for child in node.children:
walk(child, parent_nid)
return
name = _read(identifiers[0])
cls_nid = _make_id(stem, name)
add_node(cls_nid, name, line)
add_edge(file_nid, cls_nid, "contains", line)
# superclass is second identifier after ':'
colon_seen = False
for child in node.children:
if child.type == ":":
colon_seen = True
elif colon_seen and child.type == "identifier":
super_nid = ensure_named_node(_read(child), line)
add_edge(cls_nid, super_nid, "inherits", line)
colon_seen = False
elif child.type == "parameterized_arguments":
# protocols adopted: @interface Foo : Bar <Proto1, Proto2>
for sub in child.children:
if sub.type == "type_name":
for s in sub.children:
if s.type == "type_identifier":
proto_nid = ensure_named_node(_read(s), line)
add_edge(cls_nid, proto_nid, "implements", line)
elif child.type == "property_declaration":
prop_line = child.start_point[0] + 1
for sub in child.children:
if sub.type == "struct_declaration":
# The type is either a direct type_identifier
# (NSString *x) or wrapped in a generic_specifier
# (NSArray<Product *> *xs). Walk every type name in the
# type portion, skipping the declarator (the *field
# name), so generic collections are no longer invisible.
seen_types: set[str] = set()
for s in sub.children:
if s.type in ("struct_declarator", ";"):
continue
for ti in _type_identifiers(s):
tname = _read(ti)
if tname in seen_types:
continue
seen_types.add(tname)
type_nid = ensure_named_node(tname, prop_line)
edges.append(_semantic_reference_edge(
cls_nid, type_nid, "field", str_path, prop_line))
elif child.type == "method_declaration":
walk(child, cls_nid)
return
if t == "class_implementation":
# @implementation ClassName
name = None
for child in node.children:
if child.type == "identifier":
name = _read(child)
break
if not name:
for child in node.children:
walk(child, parent_nid)
return
impl_nid = _make_id(stem, name)
if impl_nid not in seen_ids:
add_node(impl_nid, name, line)
add_edge(file_nid, impl_nid, "contains", line)
for child in node.children:
if child.type == "implementation_definition":
for sub in child.children:
walk(sub, impl_nid)
return
if t == "protocol_declaration":
name = None
for child in node.children:
if child.type == "identifier":
name = _read(child)
break
if name:
proto_nid = _make_id(stem, name)
add_node(proto_nid, f"<{name}>", line)
add_edge(file_nid, proto_nid, "contains", line)
# Adopted protocols: `@protocol Derived <Base, Other>`. These
# nest under a protocol_reference_list node (distinct from the
# parameterized_arguments node used by @interface adoption), so
# they were never emitted. Emit an `implements` edge for each,
# matching how @interface protocol adoption is handled.
for child in node.children:
if child.type == "protocol_reference_list":
for sub in child.children:
if sub.type == "identifier":
base_nid = ensure_named_node(_read(sub), line)
if base_nid != proto_nid:
add_edge(proto_nid, base_nid, "implements", line)
for child in node.children:
walk(child, proto_nid)
return
if t in ("method_declaration", "method_definition"):
container = parent_nid or file_nid
# Class methods start with '+', instance methods with '-' (the grammar
# emits the sigil as the first child). The selector is the concatenation
# of the direct identifier children: one for a simple selector (-go),
# several for a compound one (-tableView:numberOfRowsInSection: ->
# "tableViewnumberOfRowsInSection"); method_parameter holds the arg
# types/names, not selector keywords, so it is correctly skipped.
prefix = "-"
for child in node.children:
if child.type in ("+", "-"):
prefix = child.type
break
parts = [_read(c) for c in node.children if c.type == "identifier"]
method_name = "".join(parts) if parts else None
if method_name:
method_nid = _make_id(container, method_name)
add_node(method_nid, f"{prefix}{method_name}", line)
add_edge(container, method_nid, "method", line)
if t == "method_definition":
method_bodies.append((method_nid, node, container))
return
for child in node.children:
walk(child, parent_nid)
walk(root)
# Second pass: resolve calls inside method bodies
all_method_nids = {n["id"] for n in nodes if n["id"] != file_nid}
class_method_nids: dict[str, set[str]] = {}
for m_nid, _, container_nid in method_bodies:
class_method_nids.setdefault(container_nid, set()).add(m_nid)
seen_calls: set[tuple[str, str]] = set()
# #1556: per-file `var -> ClassName` table from local declarations in every
# method body, so the cross-file resolver can type a `[f doThing]` receiver.
for _m_nid, body_node, _container in method_bodies:
_objc_local_var_types(body_node, source, objc_type_table)
for caller_nid, body_node, container_nid in method_bodies:
sibling_nids = class_method_nids.get(container_nid, set())
def walk_calls(n) -> None:
if n.type == "message_expression":
# `[[Foo alloc] init]` is a message_expression whose method is the
# identifier `alloc` and whose receiver is the bare class identifier
# `Foo`; resolve that class name and emit a `references` edge so the
# allocating method links to the allocated type. ensure_named_node
# emits a sourceless stub for unknown names, which the corpus rewire
# collapses ONLY when exactly one real class of that name exists, so an
# unknown/ambiguous class produces no false resolved edge (#1475).
meth = n.child_by_field_name("method")
recv = n.child_by_field_name("receiver")
if (meth is not None and meth.type == "identifier" and _read(meth) == "alloc"
and recv is not None and recv.type == "identifier"):
tname = _read(recv)
ref_line = n.start_point[0] + 1
type_nid = ensure_named_node(tname, ref_line)
if type_nid != caller_nid:
edges.append(_semantic_reference_edge(
caller_nid, type_nid, "type", str_path, ref_line))
# [receiver sel] and [receiver kw1:a kw2:b] both parse to a
# message_expression whose selector parts carry the field name
# "method" (one for a simple selector, several for a compound one);
# the receiver carries field name "receiver". Reconstruct the
# selector from every "method" child so self/super/ClassName
# receivers are never mistaken for a selector, and compound sends
# resolve too (the whole second pass was previously dead code for
# ObjC because the grammar emits these as `identifier`, not
# `selector`/`keyword_argument_list`) (#1475).
sel_parts = [
_read(child)
for i, child in enumerate(n.children)
if n.field_name_for_child(i) == "method" and child.type == "identifier"
]
method_name = "".join(sel_parts)
if method_name:
needle = _make_id("", method_name).lstrip("_")
for candidate in all_method_nids:
if candidate.endswith(needle):
pair = (caller_nid, candidate)
if pair not in seen_calls and caller_nid != candidate:
seen_calls.add(pair)
add_edge(caller_nid, candidate, "calls", n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0, context="call")
# #1556: also emit a raw_call so the cross-file resolver can type
# the receiver and link to a method in ANOTHER file. A bare
# identifier receiver (`f`, `self`, `Foo`) is captured; a nested
# message send (`[[Foo alloc] init]`) has no simple receiver name
# to type, so it is left to the alloc/init `references` edge above.
if recv is not None and recv.type == "identifier":
raw_calls.append({
"caller_nid": caller_nid,
"callee": method_name,
"is_member_call": True,
"source_file": str_path,
"source_location": f"L{n.start_point[0] + 1}",
"receiver": _read(recv),
"lang": "objc",
})
elif n.type == "field_expression":
# self.name / self.product.name — dot-syntax sugar for [self name].
# Resolve to a sibling method of the SAME class, matched by EXACT
# node id (a method id is _make_id(container, name)). A suffix
# substring match would mis-resolve self.name -> -surname and would
# let a substring-colliding sibling (-surname) suppress the real
# -name edge, so it must be an exact match (#1475).
for child in n.children:
if child.type == "field_identifier":
field_name = _read(child)
target = _make_id(container_nid, field_name)
if target in sibling_nids and target != caller_nid:
pair = (caller_nid, target)
if pair not in seen_calls:
seen_calls.add(pair)
add_edge(caller_nid, target, "accesses",
n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
elif n.type == "selector_expression":
# @selector(doSomething:withParam:) — compile-time method ref.
# Match the selector name EXACTLY (a method id is
# _make_id(container, name)) against every class's methods, and emit
# only when exactly one method matches, to avoid ambiguous fan-out.
# Exact match (not a suffix) keeps -doThing distinct from
# -reallyDoThing (#1475).
sel_parts = [_read(c) for c in n.children if c.type == "identifier"]
sel_name = "".join(sel_parts)
if sel_name:
matches = sorted({
m for m, _, cont in method_bodies
if m == _make_id(cont, sel_name) and m != caller_nid
})
if len(matches) == 1:
pair = (caller_nid, matches[0])
if pair not in seen_calls:
seen_calls.add(pair)
add_edge(caller_nid, matches[0], "calls",
n.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0,
context="call")
for child in n.children:
walk_calls(child)
walk_calls(body_node)
result = {"nodes": nodes, "edges": edges, "raw_calls": raw_calls,
"input_tokens": 0, "output_tokens": 0}
if objc_type_table:
result["objc_type_table"] = {"path": str_path, "table": objc_type_table}
return result
+688
View File
@@ -0,0 +1,688 @@
"""pascal — moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from graphify.extractors.base import _file_stem, _make_id
from graphify.extractors.resolution import _pascal_resolve_class, _pascal_resolve_unit
from pathlib import Path
from typing import Any, Callable
_PAS_TOKEN_RE = re.compile(
r"'(?:''|[^'])*'"
r"|\{[^}]*\}"
r"|\(\*.*?\*\)"
r"|//[^\n]*",
re.DOTALL,
)
_PAS_MODULE_RE = re.compile(
r"\b(unit|program|library)\s+([A-Za-z_][\w.]*)\s*;",
re.IGNORECASE,
)
_PAS_USES_RE = re.compile(
r"\buses\b\s*([^;]+);",
re.IGNORECASE | re.DOTALL,
)
_PAS_TYPE_HEADER_RE = re.compile(
r"\b(?P<name>[A-Za-z_]\w*)(?:\s*<[^>]+>)?\s*=\s*(?:packed\s+)?"
r"(?P<kind>class|interface)\b"
r"(?:\s*\(\s*(?P<bases>[^)]*)\s*\))?",
re.IGNORECASE,
)
_PAS_END_SEMI_RE = re.compile(r"\bend\s*;", re.IGNORECASE)
_PAS_METHOD_DECL_RE = re.compile(
r"\b(?:procedure|function|constructor|destructor)\s+"
r"(?P<name>[A-Za-z_]\w*)"
r"(?:\s*\([^)]*\))?"
r"(?:\s*:\s*[\w<>,\s.]+)?"
r"\s*;",
re.IGNORECASE,
)
_PAS_IMPL_HEADER_RE = re.compile(
r"\b(?:procedure|function|constructor|destructor)\s+"
r"(?P<qual>[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?)"
r"(?:\s*<[^>]+>)?"
r"(?:\s*\([^)]*\))?"
r"(?:\s*:\s*[\w<>,\s.]+)?"
r"\s*;",
re.IGNORECASE,
)
_PAS_BEGIN_END_TOKEN_RE = re.compile(
r"\b(begin|end|case|try|asm|record)\b", re.IGNORECASE
)
_PAS_CALL_RE = re.compile(r"\b([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*[(;]")
_PAS_KEYWORDS = frozenset({
"begin", "end", "if", "then", "else", "while", "do", "for", "to",
"downto", "repeat", "until", "case", "of", "try", "finally", "except",
"with", "inherited", "result", "var", "const", "type", "nil", "true",
"false", "exit", "break", "continue", "uses", "unit", "program",
"library", "interface", "implementation", "initialization", "finalization",
"procedure", "function", "constructor", "destructor", "class", "record",
"object", "array", "string", "integer", "boolean", "real", "char",
"writeln", "write", "readln", "read", "assigned", "length", "high",
"low", "inc", "dec", "new", "dispose", "setlength", "copy", "pos",
"trim", "format", "inttostr", "strtoint", "ord", "chr", "sizeof",
"create", "free", "destroy",
})
def _pascal_strip_comments(text: str) -> str:
"""Strip Pascal comments ({}, (* *), //) while preserving newlines."""
def _sub(m: re.Match) -> str:
tok = m.group(0)
if tok.startswith("'"):
return tok
return "".join(c if c == "\n" else " " for c in tok)
return _PAS_TOKEN_RE.sub(_sub, text)
def _pascal_split_sections(text: str) -> tuple[str, int, str, int]:
"""Split into (iface_text, iface_offset, impl_text, impl_offset).
Files without interface/implementation sections (dpr/lpr/inc) return
the whole text as impl with offset 0.
"""
iface_m = re.search(r"\binterface\b", text, re.IGNORECASE)
impl_m = re.search(r"\bimplementation\b", text, re.IGNORECASE)
if iface_m and impl_m:
iface_off = iface_m.end()
impl_off = impl_m.end()
end_m = re.search(
r"\b(initialization|finalization)\b", text[impl_off:], re.IGNORECASE
)
impl_end = impl_off + end_m.start() if end_m else len(text)
return text[iface_off:impl_m.start()], iface_off, text[impl_off:impl_end], impl_off
return "", 0, text, 0
def _pascal_split_uses(s: str) -> list[str]:
"""Split a uses list string, handling 'Foo in ''bar.pas''' syntax."""
out = []
for chunk in s.split(","):
name = re.split(r"\s+in\s+", chunk.strip(), maxsplit=1, flags=re.IGNORECASE)[0]
name = name.strip().strip(";")
if name and re.match(r"[A-Za-z_][\w.]*$", name):
out.append(name)
return out
def _pascal_split_bases(s: str) -> list[str]:
"""Split inheritance list, handling generics like TList<T, U>."""
out, depth, buf = [], 0, []
for ch in s:
if ch == "<":
depth += 1
buf.append(ch)
elif ch == ">":
depth -= 1
buf.append(ch)
elif ch == "," and depth == 0:
name = re.sub(r"<.*$", "", "".join(buf).strip())
if name:
out.append(name)
buf = []
else:
buf.append(ch)
name = re.sub(r"<.*$", "", "".join(buf).strip())
if name:
out.append(name)
return [n for n in out if re.match(r"[A-Za-z_]\w*$", n)]
def _pascal_find_body(text: str, start: int) -> tuple[int, int]:
"""Find balanced begin..end after start. Returns (body_start, body_end).
Returns (0, 0) if no begin found.
"""
m = re.search(r"\bbegin\b", text[start:], re.IGNORECASE)
if not m:
return (0, 0)
body_start = start + m.end()
depth = 1
for tok in _PAS_BEGIN_END_TOKEN_RE.finditer(text, body_start):
kw = tok.group(1).lower()
if kw in ("begin", "case", "try", "asm", "record"):
depth += 1
elif kw == "end":
depth -= 1
if depth == 0:
return (body_start, tok.start())
return (body_start, len(text))
def _resolve_pascal_callee_factory(
records: list[tuple],
edges: list[dict],
module_nid: str,
) -> Callable[[str, str], str | None]:
"""Build a scoped call resolver for a single Pascal/Delphi file.
``records`` is the list of raw per-procedure tuples produced by either
Pascal extractor; only the trailing ``(..., container, name_lower)``
fields and the leading ``proc_nid`` are used, so both extractors' tuple
shapes work unmodified (regex: proc_nid, line, body_text, container,
name_lower; tree-sitter: proc_nid, body_node, container, name_lower).
Resolution order for a call to ``name_lower`` from ``caller_nid``:
1. A method declared on the caller's own class.
2. A method declared on an ancestor class (BFS up ``inherits`` edges,
which are already resolved by this point).
3. A file-level free function (declared directly under the module).
4. A global by-name match, but only when unambiguous (exactly one
procedure with that name anywhere in the file).
Returns None (no edge emitted) when the name is ambiguous at every
level -- guessing at a same-named method on an unrelated class is worse
than omitting the edge. Same-named methods on unrelated classes are a
common Pascal/Delphi pattern (property accessors, generated wrapper
classes such as TLB import units): without this scoping, a flat
file-wide by-name lookup silently collapses onto whichever declaration
happens to be inserted last, producing wrong cross-class edges. Mirrors
the "god-node guard" already used by ``resolve_ruby_member_calls`` for
the analogous Ruby ambiguous-method-name problem.
"""
class_bases: dict[str, list[str]] = {}
for e in edges:
if e.get("relation") == "inherits":
class_bases.setdefault(e["source"], []).append(e["target"])
class_procs: dict[str, dict[str, list[str]]] = {}
module_procs: dict[str, list[str]] = {}
global_procs: dict[str, list[str]] = {}
proc_owner: dict[str, str] = {}
for rec in records:
proc_nid, container, name_lower = rec[0], rec[-2], rec[-1]
proc_owner[proc_nid] = container
global_procs.setdefault(name_lower, []).append(proc_nid)
if container == module_nid:
module_procs.setdefault(name_lower, []).append(proc_nid)
else:
class_procs.setdefault(container, {}).setdefault(name_lower, []).append(proc_nid)
def _resolve(caller_nid: str, name_lower: str) -> str | None:
owner = proc_owner.get(caller_nid)
if owner is not None:
candidates = class_procs.get(owner, {}).get(name_lower)
if candidates:
return candidates[0] if len(candidates) == 1 else None
seen_bases: set[str] = set()
queue = list(class_bases.get(owner, []))
while queue:
base = queue.pop(0)
if base in seen_bases:
continue
seen_bases.add(base)
candidates = class_procs.get(base, {}).get(name_lower)
if candidates:
return candidates[0] if len(candidates) == 1 else None
queue.extend(class_bases.get(base, []))
candidates = module_procs.get(name_lower)
if candidates:
return candidates[0] if len(candidates) == 1 else None
candidates = global_procs.get(name_lower)
if candidates and len(candidates) == 1:
return candidates[0]
return None
return _resolve
def _extract_pascal_regex(path: Path) -> dict:
"""Regex fallback for Pascal/Delphi extraction when tree-sitter-pascal
is unavailable. Produces the same node/edge schema as the tree-sitter pass.
"""
try:
raw = path.read_text(encoding="utf-8", errors="replace")
except Exception as exc:
return {"nodes": [], "edges": [], "error": str(exc)}
str_path = str(path)
stem = _file_stem(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_call_pairs: set[tuple[str, str]] = set()
seen_edges: set[tuple[str, str, 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(src: str, tgt: str, relation: str, line: int, context: str | None = None) -> None:
# A class method declared in the interface section and defined in the
# implementation section both emit a `method` edge to the same node, so
# dedup on (src, tgt, relation) to keep the graph from carrying doubled
# method/contains/inherits edges (mirrors _add_node's seen_ids guard).
key = (src, tgt, relation)
if key in seen_edges:
return
seen_edges.add(key)
edge: dict = {
"source": src,
"target": tgt,
"relation": relation,
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
def _lineno(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
file_nid = _make_id(str_path)
_add_node(file_nid, path.name, 1)
stripped = _pascal_strip_comments(raw)
# Module header
module_nid = file_nid
mod_m = _PAS_MODULE_RE.search(stripped)
if mod_m:
mod_name = mod_m.group(2)
module_nid = _make_id(stem, mod_name)
_add_node(module_nid, mod_name, _lineno(stripped, mod_m.start()))
_add_edge(file_nid, module_nid, "contains", _lineno(stripped, mod_m.start()))
iface_text, iface_off, impl_text, impl_off = _pascal_split_sections(stripped)
# Uses clauses
for section_text, section_off in ((iface_text, iface_off), (impl_text, impl_off)):
for um in _PAS_USES_RE.finditer(section_text):
line = _lineno(stripped, section_off + um.start())
for unit_name in _pascal_split_uses(um.group(1)):
tgt_nid = _pascal_resolve_unit(path, unit_name)
_add_edge(module_nid, tgt_nid, "imports", line, context="import")
# Type declarations (classes / interfaces) in interface section
search_text = iface_text if iface_text else stripped
search_off = iface_off if iface_text else 0
pos = 0
while pos < len(search_text):
hm = _PAS_TYPE_HEADER_RE.search(search_text, pos)
if not hm:
break
type_name = hm.group("name")
bases_raw = hm.group("bases") or ""
line = _lineno(stripped, search_off + hm.start())
cls_nid = _make_id(stem, type_name)
_add_node(cls_nid, type_name, line)
_add_edge(module_nid, cls_nid, "contains", line)
for base_name in _pascal_split_bases(bases_raw):
same_file_nid = _make_id(stem, base_name)
if same_file_nid in seen_ids:
# Base class already declared earlier in this same file --
# reuse its real node instead of the cross-file/stub lookup
# below (which assumes one-class-per-file and would create a
# duplicate node for a base class that shares this file).
base_nid = same_file_nid
else:
resolved = _pascal_resolve_class(path, base_name)
if resolved:
# Cross-file base class found on disk -- its real node
# arrives via THAT file's own extraction. Do not add a
# duplicate stub here: it would carry this file's
# source_file (wrong -- it belongs to the base class's
# own file) and collide with the real node under
# cross-file id disambiguation, producing two different
# salted ids for what should be one class (breaks
# cross-file `inherits`-chain resolution downstream).
base_nid = resolved
else:
base_nid = _make_id(base_name)
if base_nid not in seen_ids:
_add_node(base_nid, base_name, line)
_add_edge(cls_nid, base_nid, "inherits", line)
# Find class body (up to next end;)
end_m = _PAS_END_SEMI_RE.search(search_text, hm.end())
body_text = search_text[hm.end():end_m.start()] if end_m else ""
body_off = search_off + hm.end()
# Forward method declarations inside the class body
for mm in _PAS_METHOD_DECL_RE.finditer(body_text):
mname = mm.group("name")
mline = _lineno(stripped, body_off + mm.start())
method_nid = _make_id(cls_nid, mname)
_add_node(method_nid, f"{mname}()", mline)
_add_edge(cls_nid, method_nid, "method", mline)
pos = end_m.end() if end_m else len(search_text)
# Implementation headers (procedure/function/constructor/destructor)
impl_records: list[tuple[str, int, str, str, str]] = []
# (proc_nid, line, body_text, container, name_lower)
for fm in _PAS_IMPL_HEADER_RE.finditer(impl_text):
qualified = fm.group("qual")
line = _lineno(stripped, impl_off + fm.start())
if "." in qualified:
cls_part, method_part = qualified.split(".", 1)
cls_nid = _make_id(stem, cls_part)
container = cls_nid if cls_nid in seen_ids else module_nid
relation = "method" if cls_nid in seen_ids else "contains"
label = f"{method_part}()"
name_lower = method_part.lower()
else:
container, relation = module_nid, "contains"
label = f"{qualified}()"
name_lower = qualified.lower()
proc_nid = _make_id(stem, qualified)
_add_node(proc_nid, label, line)
_add_edge(container, proc_nid, relation, line)
body_start, body_end = _pascal_find_body(impl_text, fm.end())
body_text = impl_text[body_start:body_end] if body_start else ""
impl_records.append((proc_nid, line, body_text, container, name_lower))
# Intra-file call edges, scoped by the caller's own class, then its
# ancestor chain (via `inherits` edges already emitted above), then
# file-level free functions; fall back to a global by-name match only
# when it is unambiguous (single owner across the file). Prevents
# same-named methods on unrelated classes (property accessors, generated
# wrapper classes such as TLB import units, etc. -- a common Pascal/Delphi
# pattern) from collapsing into an arbitrary cross-class edge.
callee_nid = _resolve_pascal_callee_factory(impl_records, edges, module_nid)
raw_calls: list[dict] = []
for caller_nid, caller_line, body_text, _container, _name_lower in impl_records:
for cm in _PAS_CALL_RE.finditer(body_text):
callee_name = cm.group(1).split(".")[-1].lower()
if callee_name in _PAS_KEYWORDS:
continue
call_line = caller_line + body_text.count("\n", 0, cm.start())
target_nid = callee_nid(caller_nid, callee_name)
if target_nid == caller_nid:
continue
if not target_nid:
# Not resolvable within this file (e.g. inherited from a base
# class declared in another file) -- report for the
# cross-file resolver (graphify.pascal_resolution) instead of
# guessing or dropping it silently.
raw_calls.append({
"source_file": str_path,
"source_location": f"L{call_line}",
"caller_nid": caller_nid,
"callee": callee_name,
})
continue
pair = (caller_nid, target_nid)
if pair in seen_call_pairs:
continue
seen_call_pairs.add(pair)
_add_edge(caller_nid, target_nid, "calls", call_line, context="call")
return {
"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0,
"raw_calls": raw_calls,
}
def extract_pascal(path: Path) -> dict:
"""Extract units, classes, procedures, uses-imports, and calls from Pascal/Delphi files.
Produces nodes for:
- The file itself
- unit / program / library declarations
- class and interface type declarations
- procedure / function implementations (including qualified TClass.Method names)
Produces edges for:
- file --contains--> module
- module --imports--> other file node (via uses clause, resolved to path-based IDs)
- class --inherits--> base class
- class/module --contains--> method forward declaration
- class/module --contains--> procedure/function implementation
- procedure --calls--> other procedure (within the same file)
Uses tree-sitter-pascal when available; falls back to a regex-based extractor
(_extract_pascal_regex) when it isn't installed or fails to parse, so Pascal
extraction works out of the box without an extra pip install.
"""
try:
import tree_sitter_pascal as tspascal
from tree_sitter import Language, Parser
except ImportError:
return _extract_pascal_regex(path)
try:
language = Language(tspascal.language())
parser = Parser(language)
source = path.read_bytes()
tree = parser.parse(source)
root = tree.root_node
except Exception:
return _extract_pascal_regex(path)
stem = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_edges: set[tuple[str, str, str]] = set()
proc_bodies: list[tuple[str, Any, str, str]] = []
# (proc_nid, body_node, container, name_lower)
def _read(node) -> str: # type: ignore[no-untyped-def]
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
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(
src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None,
) -> None:
# A class method declared in the interface section and defined in the
# implementation section both emit a `method` edge to the same node, so
# dedup on (src, tgt, relation) to keep the graph from carrying doubled
# method/contains/inherits edges (mirrors add_node's seen_ids guard).
key = (src, tgt, relation)
if key in seen_edges:
return
seen_edges.add(key)
edge: dict[str, Any] = {
"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
module_nid = file_nid
def _proc_name(header_node) -> str | None: # type: ignore[no-untyped-def]
name_node = header_node.child_by_field_name("name")
if name_node:
return _read(name_node)
for child in header_node.children:
if child.type in ("identifier", "genericDot", "genericTpl"):
return _read(child)
return None
def walk(node, parent_nid: str) -> None: # type: ignore[no-untyped-def]
nonlocal module_nid
t = node.type
line = node.start_point[0] + 1
if t in ("unit", "program", "library"):
name_node = next((c for c in node.children if c.type == "moduleName"), None)
mod_name = _read(name_node) if name_node else path.stem
mod_nid = _make_id(stem, mod_name)
add_node(mod_nid, mod_name, line)
add_edge(file_nid, mod_nid, "contains", line)
module_nid = mod_nid
for child in node.children:
walk(child, mod_nid)
return
if t == "declUses":
for child in node.children:
if child.type == "moduleName":
mod_name = _read(child)
tgt_nid = _pascal_resolve_unit(path, mod_name)
add_edge(parent_nid, tgt_nid, "imports", line, context="import")
return
if t == "declType":
type_name = None
kind_node = None
for child in node.children:
if child.type == "identifier" and type_name is None:
type_name = _read(child)
elif child.type in ("declClass", "declIntf", "declHelper") and kind_node is None:
kind_node = child
if type_name and kind_node:
cls_nid = _make_id(stem, type_name)
add_node(cls_nid, type_name, line)
add_edge(parent_nid, cls_nid, "contains", line)
for child in kind_node.children:
if child.type == "typeref":
base_name = _read(child)
base_nid = _make_id(stem, base_name)
if base_nid not in seen_ids:
# Try cross-file resolution (TFooBar → FooBar.pas)
resolved = _pascal_resolve_class(path, base_name)
if resolved:
# Cross-file base class found on disk -- its
# real node arrives via THAT file's own
# extraction. Do not add a duplicate stub
# here: it would carry this file's
# source_file (wrong) and collide with the
# real node under cross-file id
# disambiguation, producing two different
# salted ids for what should be one class.
base_nid = resolved
else:
base_nid = _make_id(base_name)
if base_nid not in seen_ids:
# Stub for RTL/external base classes.
add_node(base_nid, base_name, line)
add_edge(cls_nid, base_nid, "inherits", line)
for child in kind_node.children:
walk(child, cls_nid)
return
for child in node.children:
walk(child, parent_nid)
return
if t == "declProcFwd":
header = next((c for c in node.children if c.type == "declProc"), None)
if header:
name = _proc_name(header)
if name and "." not in name:
method_nid = _make_id(parent_nid, name)
add_node(method_nid, f"{name}()", line)
add_edge(parent_nid, method_nid, "method", line)
return
if t == "defProc":
header = next((c for c in node.children if c.type == "declProc"), None)
body_node = next((c for c in node.children if c.type == "block"), None)
if not header:
for child in node.children:
walk(child, parent_nid)
return
name = _proc_name(header)
if not name:
for child in node.children:
walk(child, parent_nid)
return
container = parent_nid
if "." in name:
parts = name.split(".", 1)
cls_nid = _make_id(stem, parts[0])
if cls_nid in seen_ids:
container = cls_nid
label = f"{parts[-1]}()"
else:
label = f"{name}()"
proc_nid = _make_id(stem, name)
add_node(proc_nid, label, line)
add_edge(
container, proc_nid,
"method" if container != parent_nid else "contains",
line,
)
if body_node:
proc_bodies.append((proc_nid, body_node, container, label.removesuffix("()").lower()))
return
for child in node.children:
walk(child, parent_nid)
walk(root, file_nid)
# Second pass: resolve calls inside procedure/function bodies, scoped by
# the caller's own class, then its ancestor chain, then file-level free
# functions, falling back to an unambiguous global match (see
# _resolve_pascal_callee_factory).
resolve_callee = _resolve_pascal_callee_factory(proc_bodies, edges, module_nid)
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def _emit_or_report(caller_nid: str, name_lower: str, line: int) -> None:
target = resolve_callee(caller_nid, name_lower)
if target == caller_nid:
return
if not target:
# Not resolvable within this file (e.g. inherited from a base
# class declared in another file) -- report for the cross-file
# resolver (graphify.pascal_resolution) instead of guessing or
# dropping it silently.
raw_calls.append({
"source_file": str_path,
"source_location": f"L{line}",
"caller_nid": caller_nid,
"callee": name_lower,
})
return
pair = (caller_nid, target)
if pair not in seen_call_pairs:
seen_call_pairs.add(pair)
add_edge(caller_nid, target, "calls", line, context="call")
def walk_calls(node, caller_nid: str) -> None: # type: ignore[no-untyped-def]
if node.type == "exprCall":
callee_text = None
for child in node.children:
if child.is_named and child.type not in ("exprArgs",):
callee_text = _read(child).split(".")[-1]
break
if callee_text:
_emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1)
elif node.type == "statement":
# Pascal bare procedure calls with no args: `Reset;`
# tree-sitter represents these as statement → identifier (no exprCall wrapper)
named = [c for c in node.children if c.is_named]
if len(named) == 1 and named[0].type == "identifier":
callee_text = _read(named[0])
_emit_or_report(caller_nid, callee_text.lower(), node.start_point[0] + 1)
for child in node.children:
walk_calls(child, caller_nid)
for proc_nid, body_node, _container, _name_lower in proc_bodies:
walk_calls(body_node, proc_nid)
return {
"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0,
"raw_calls": raw_calls,
}
+196
View File
@@ -0,0 +1,196 @@
"""Pascal_forms extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id
def extract_lazarus_form(path: Path) -> dict:
"""Extract component hierarchy from Lazarus .lfm form files.
.lfm is a text-based declarative format for UI component trees, structured as:
object ComponentName: TClassName
PropertyName = Value
OnEvent = HandlerName
object ChildName: TChildClass
...
end
end
Produces nodes for:
- The form file itself
- Each component class encountered (TForm1, TButton, TPanel, ...)
- Event handler names referenced by OnXxx properties
Produces edges for:
- file --contains--> root form class
- parent component --contains--> child component class
- component --references--> event handler (context: "event")
"""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
import re
str_path = str(path)
stem = _file_stem(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_edge_pairs: set[tuple[str, str, 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(
src: str, tgt: str, relation: str, line: int,
context: str | None = None,
) -> None:
key = (src, tgt, relation)
if key in seen_edge_pairs:
return
seen_edge_pairs.add(key)
edge: dict[str, Any] = {
"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
obj_re = re.compile(r"^\s*object\s+\w+\s*:\s*(\w+)", re.IGNORECASE)
event_re = re.compile(r"^\s*On\w+\s*=\s*(\w+)", re.IGNORECASE)
end_re = re.compile(r"^\s*end\s*$", re.IGNORECASE)
# Stack of node IDs representing the nesting of object...end blocks
stack: list[str] = [file_nid]
for lineno, line in enumerate(text.splitlines(), 1):
m = obj_re.match(line)
if m:
class_name = m.group(1)
nid = _make_id(stem, class_name)
add_node(nid, class_name, lineno)
add_edge(stack[-1], nid, "contains", lineno)
stack.append(nid)
continue
m = event_re.match(line)
if m and len(stack) > 1:
handler = m.group(1)
handler_nid = _make_id(stem, handler)
add_node(handler_nid, f"{handler}()", lineno)
add_edge(stack[-1], handler_nid, "references", lineno, context="event")
continue
if end_re.match(line) and len(stack) > 1:
stack.pop()
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
def extract_delphi_form(path: Path) -> dict:
"""Extract component hierarchy from Delphi .dfm form files.
.dfm files come in two formats:
- Text (same `object Name: TClassName ... end` syntax as .lfm)
- Binary (starts with a TPF0/FF0A magic header — unreadable as text)
Binary .dfm files are skipped gracefully: an empty result is returned
so the rest of the pipeline is unaffected. Convert binary forms to
text in the Delphi IDE via File → Save As (Text DFM) if you want them
indexed.
Text .dfm files are parsed identically to .lfm: component containment
(`contains`) and event handler references (`references`, context "event").
"""
try:
raw = path.read_bytes()
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
# Detect binary DFM: Delphi binary resource streams start with FF 0A
if raw[:2] == b"\xff\x0a":
return {
"nodes": [], "edges": [],
"error": f"binary DFM (convert to text in Delphi IDE to index): {path.name}",
}
# Text DFM — delegate to the shared form parser (same syntax as .lfm)
try:
text = raw.decode("utf-8", errors="replace")
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
import re
str_path = str(path)
stem = _file_stem(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
seen_edge_pairs: set[tuple[str, str, 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(
src: str, tgt: str, relation: str, line: int,
context: str | None = None,
) -> None:
key = (src, tgt, relation)
if key in seen_edge_pairs:
return
seen_edge_pairs.add(key)
edge: dict[str, Any] = {
"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
obj_re = re.compile(r"^\s*object\s+\w+\s*:\s*(\w+)", re.IGNORECASE)
event_re = re.compile(r"^\s*On\w+\s*=\s*(\w+)", re.IGNORECASE)
end_re = re.compile(r"^\s*end\s*$", re.IGNORECASE)
stack: list[str] = [file_nid]
for lineno, line in enumerate(text.splitlines(), 1):
m = obj_re.match(line)
if m:
class_name = m.group(1)
nid = _make_id(stem, class_name)
add_node(nid, class_name, lineno)
add_edge(stack[-1], nid, "contains", lineno)
stack.append(nid)
continue
m = event_re.match(line)
if m and len(stack) > 1:
handler = m.group(1)
handler_nid = _make_id(stem, handler)
add_node(handler_nid, f"{handler}()", lineno)
add_edge(stack[-1], handler_nid, "references", lineno, context="event")
continue
if end_re.match(line) and len(stack) > 1:
stack.pop()
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
+496
View File
@@ -0,0 +1,496 @@
"""Powershell extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id, _read_text
def extract_powershell(path: Path) -> dict:
"""Extract functions, classes, methods, and using statements from a .ps1 file."""
try:
import tree_sitter_powershell as tsps
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_powershell not installed"}
try:
language = Language(tsps.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
_PS_SKIP = frozenset({
"using", "return", "if", "else", "elseif", "foreach", "for",
"while", "do", "switch", "try", "catch", "finally", "throw",
"break", "continue", "exit", "param", "begin", "process", "end",
# Import commands — handled as import edges, not function calls
"import-module",
})
def _find_script_block_body(node):
for child in node.children:
if child.type == "script_block":
for sc in child.children:
if sc.type == "script_block_body":
return sc
return child
return None
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def _ps_type_name(type_literal_node) -> str | None:
"""Drill into a type_literal node and return the inner type_identifier text."""
if type_literal_node is None:
return None
for spec in type_literal_node.children:
if spec.type != "type_spec":
continue
for tname in spec.children:
if tname.type != "type_name":
continue
for tid in tname.children:
if tid.type == "type_identifier":
return _read_text(tid, source)
return None
def walk(node, parent_class_nid: str | None = None) -> None:
t = node.type
if t == "function_statement":
name_node = next((c for c in node.children if c.type == "function_name"), None)
if name_node:
func_name = _read_text(name_node, source)
line = node.start_point[0] + 1
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
body = _find_script_block_body(node)
if body:
function_bodies.append((func_nid, body))
# Also walk the body during the main pass so that
# Import-Module / dot-source inside functions emit
# file-level imports_from edges (#1331).
walk(body, parent_class_nid)
return
if t == "class_statement":
name_node = next((c for c in node.children if c.type == "simple_name"), None)
if name_node:
class_name = _read_text(name_node, source)
line = node.start_point[0] + 1
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name, line)
add_edge(file_nid, class_nid, "contains", line)
# Base type(s) after ':'. PowerShell has no syntactic base vs
# interface split, so (matching the C# convention) treat the
# first base as the superclass (inherits) and the rest as
# interfaces (implements). Bases are the simple_name children
# after the ':' token.
colon_seen = False
base_index = 0
for child in node.children:
if child.type == ":":
colon_seen = True
elif colon_seen and child.type == "simple_name":
base_nid = ensure_named_node(_read_text(child, source), line)
if base_nid != class_nid:
rel = "inherits" if base_index == 0 else "implements"
add_edge(class_nid, base_nid, rel, line)
base_index += 1
for child in node.children:
walk(child, parent_class_nid=class_nid)
return
if t == "class_property_definition" and parent_class_nid:
type_literal = next((c for c in node.children if c.type == "type_literal"), None)
type_name = _ps_type_name(type_literal)
if type_name:
line = node.start_point[0] + 1
target_nid = ensure_named_node(type_name, line)
if target_nid != parent_class_nid:
add_edge(parent_class_nid, target_nid, "references",
line, context="field")
return
if t == "class_method_definition":
name_node = next((c for c in node.children if c.type == "simple_name"), None)
if name_node:
method_name = _read_text(name_node, source)
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(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(file_nid, method_nid, "contains", line)
# Return type: type_literal sibling of simple_name
return_type_literal = next(
(c for c in node.children if c.type == "type_literal"), None)
return_type_name = _ps_type_name(return_type_literal)
if return_type_name:
target_nid = ensure_named_node(return_type_name, line)
if target_nid != method_nid:
add_edge(method_nid, target_nid, "references",
line, context="return_type")
# Parameter types: class_method_parameter_list
param_list = next(
(c for c in node.children if c.type == "class_method_parameter_list"), None)
if param_list is not None:
for p in param_list.children:
if p.type != "class_method_parameter":
continue
ptype_literal = next(
(c for c in p.children if c.type == "type_literal"), None)
ptype_name = _ps_type_name(ptype_literal)
if not ptype_name:
continue
p_line = p.start_point[0] + 1
target_nid = ensure_named_node(ptype_name, p_line)
if target_nid != method_nid:
add_edge(method_nid, target_nid, "references",
p_line, context="parameter_type")
body = _find_script_block_body(node)
if body:
function_bodies.append((method_nid, body))
return
if t == "command":
# Dot-sourcing: `. ./Shared.psm1`
# Uses command_invokation_operator '.' + command_name_expr (not command_name)
invoke_op = next(
(c for c in node.children if c.type == "command_invokation_operator"), None
)
if invoke_op is not None and _read_text(invoke_op, source).strip() == ".":
name_expr = next(
(c for c in node.children if c.type == "command_name_expr"), None
)
if name_expr is not None:
name_node = next(
(c for c in name_expr.children if c.type == "command_name"), None
)
if name_node:
raw_path = _read_text(name_node, source)
# Strip relative path prefix (./ or .\ or just the dot)
module_stem = re.sub(r'^[./\\]+', '', raw_path)
# Drop extension to get bare module name
module_stem = re.sub(r'\.[^.]+$', '', module_stem).replace('\\', '/')
module_name = module_stem.split('/')[-1]
if module_name:
add_edge(file_nid, _make_id(module_name), "imports_from",
node.start_point[0] + 1)
return
cmd_name_node = next((c for c in node.children if c.type == "command_name"), None)
if cmd_name_node:
cmd_text = _read_text(cmd_name_node, source).lower()
if cmd_text == "using":
tokens = []
for child in node.children:
if child.type == "command_elements":
for el in child.children:
if el.type == "generic_token":
tokens.append(_read_text(el, source))
module_tokens = [t for t in tokens
if t.lower() not in ("namespace", "module", "assembly")]
if module_tokens:
module_name = module_tokens[-1].split(".")[-1]
add_edge(file_nid, _make_id(module_name), "imports_from",
node.start_point[0] + 1)
elif cmd_text == "import-module":
# Collect generic_token args; skip command_parameter flags like -Name
# The module name is the first generic_token (or the one after -Name)
module_name: str | None = None
expect_name = False
for child in node.children:
if child.type != "command_elements":
continue
for el in child.children:
if el.type == "command_parameter":
param_text = _read_text(el, source).lstrip("-").lower()
expect_name = param_text in ("name", "n")
elif el.type == "generic_token":
token = _read_text(el, source)
if module_name is None or expect_name:
module_name = token
expect_name = False
if module_name:
# Strip extension; keep only the stem for the node ID
bare = re.sub(r'\.[^.]+$', '', module_name).split('/')[-1].split('\\')[-1]
if bare:
add_edge(file_nid, _make_id(bare), "imports_from",
node.start_point[0] + 1)
return
for child in node.children:
walk(child, parent_class_nid)
walk(root)
label_to_nid = {n["label"].strip("()").lstrip(".").lower(): n["id"] for n in nodes}
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def walk_calls(node, caller_nid: str) -> None:
if node.type in ("function_statement", "class_statement"):
return
if node.type == "command":
cmd_name_node = next((c for c in node.children if c.type == "command_name"), None)
if cmd_name_node:
cmd_text = _read_text(cmd_name_node, source)
if cmd_text.lower() not in _PS_SKIP:
tgt_nid = label_to_nid.get(cmd_text.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)
add_edge(caller_nid, tgt_nid, "calls",
node.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
elif cmd_text:
raw_calls.append({
"caller_nid": caller_nid,
"callee": cmd_text,
"is_member_call": False,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
for child in node.children:
walk_calls(child, caller_nid)
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
clean_edges = [e for e in edges if e["source"] in seen_ids and
(e["target"] in seen_ids or e["relation"] in ("imports_from", "imports"))]
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
_PSD1_IMPORT_KEYS = frozenset({"RootModule", "NestedModules", "RequiredModules"})
def _psd1_collect_string_literals(node, source: bytes) -> list[str]:
"""Recursively collect all string_literal text values under *node*."""
results: list[str] = []
def _walk(n) -> None:
if n.type == "string_literal":
raw = source[n.start_byte:n.end_byte].decode(errors="replace")
# Strip surrounding quote chars (' or ")
results.append(raw.strip("'\""))
return
for child in n.children:
_walk(child)
_walk(node)
return results
def _psd1_module_name(raw: str) -> str:
"""Derive a bare module name from a raw string value.
e.g. 'MyModule.psm1''MyModule', './sub/Util.psm1''Util', 'PSReadLine''PSReadLine'
"""
# Strip path prefix and extension
name = raw.replace("\\", "/").split("/")[-1]
name = re.sub(r"\.[^.]+$", "", name) # remove last extension
return name.strip()
def extract_powershell_manifest(path: Path) -> dict:
"""Extract module dependency edges from a PowerShell .psd1 manifest file.
.psd1 files are PowerShell data hashtables, not scripts. tree-sitter-powershell
parses them correctly (they are syntactically valid PS). We walk the AST looking
for RootModule, NestedModules, and RequiredModules keys and emit imports_from
edges for every referenced module.
RequiredModules supports two forms:
- Simple string: 'PSReadLine'
- Module specification: @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
For the hashtable form we only follow the ModuleName key.
"""
try:
import tree_sitter_powershell as tsps
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_powershell not installed"}
try:
language = Language(tsps.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)}
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_import_edge(src: str, module_raw: str, line: int) -> None:
name = _psd1_module_name(module_raw)
if not name:
return
tgt_nid = _make_id(name)
edges.append({
"source": src,
"target": tgt_nid,
"relation": "imports_from",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
"context": "import",
})
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def walk_manifest(node) -> None:
"""Walk the AST and emit edges for import-relevant hash_entry nodes."""
if node.type != "hash_entry":
for child in node.children:
walk_manifest(child)
return
# Identify the key
key_node = next((c for c in node.children if c.type == "key_expression"), None)
if key_node is None:
return
key_text = source[key_node.start_byte:key_node.end_byte].decode(errors="replace").strip()
if key_text not in _PSD1_IMPORT_KEYS:
# Still recurse in case there are nested hashes (e.g. ModuleVersion entries
# contain sub-hashes, but we only care about top-level keys for imports)
return
line = node.start_point[0] + 1
value_node = next((c for c in node.children if c.type == "pipeline"), None)
if value_node is None:
return
if key_text == "RootModule":
# Value is a single string
strings = _psd1_collect_string_literals(value_node, source)
for s in strings:
add_import_edge(file_nid, s, line)
elif key_text == "NestedModules":
# Value is a string or @('a', 'b', ...) array — collect all string literals
strings = _psd1_collect_string_literals(value_node, source)
for s in strings:
add_import_edge(file_nid, s, line)
elif key_text == "RequiredModules":
# Two forms:
# 1) 'SimpleModule' — direct string literals in the array
# 2) @{ ModuleName = 'Foo'; ModuleVersion = '2.0' } — use ModuleName only
#
# Strategy: walk the value for hash_entry nodes whose key is 'ModuleName';
# collect their string values. For the remaining string_literal nodes that
# are NOT inside a hash_entry subtree, treat them as simple module names.
module_name_strings: list[str] = []
inside_hash_entries: set[int] = set() # byte offsets of handled strings
def find_modulename_entries(n) -> None:
if n.type == "hash_entry":
sub_key = next((c for c in n.children if c.type == "key_expression"), None)
if sub_key is not None:
sk_text = source[sub_key.start_byte:sub_key.end_byte].decode(errors="replace").strip()
# Collect strings inside *all* sub-keys so we can exclude them
for c in n.children:
if c.type == "pipeline":
for s_node in _collect_string_nodes(c):
inside_hash_entries.add(s_node.start_byte)
if sk_text == "ModuleName":
for c in n.children:
if c.type == "pipeline":
for s in _psd1_collect_string_literals(c, source):
module_name_strings.append(s)
return # don't recurse further into this hash_entry
for child in n.children:
find_modulename_entries(child)
def _collect_string_nodes(n):
"""Return all string_literal nodes in subtree."""
if n.type == "string_literal":
yield n
return
for child in n.children:
yield from _collect_string_nodes(child)
find_modulename_entries(value_node)
# Now gather direct string literals not inside hash entries
direct_strings: list[str] = []
for s_node in _collect_string_nodes(value_node):
if s_node.start_byte not in inside_hash_entries:
raw = source[s_node.start_byte:s_node.end_byte].decode(errors="replace")
direct_strings.append(raw.strip("'\""))
for s in direct_strings + module_name_strings:
add_import_edge(file_nid, s, line)
walk_manifest(root)
return {"nodes": nodes, "edges": edges, "raw_calls": []}
+119
View File
@@ -0,0 +1,119 @@
"""ASP.NET Razor component extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
def extract_razor(path: Path) -> dict:
"""Extract directives, component refs, and @code methods from .razor/.cshtml."""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
file_nid = _make_id(str(path))
str_path = str(path)
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = set()
seen_ids.add(file_nid)
def _add_ref(target_name: str, relation: str, line: int) -> None:
tgt_nid = _make_id(target_name)
if not tgt_nid:
return
if tgt_nid not in seen_ids:
seen_ids.add(tgt_nid)
nodes.append({"id": tgt_nid, "label": target_name,
"file_type": "code", "source_file": str_path,
"source_location": f"L{line}"})
edges.append({"source": file_nid, "target": tgt_nid,
"relation": relation, "confidence": "EXTRACTED",
"source_file": str_path, "source_location": f"L{line}",
"weight": 1.0})
for i, line in enumerate(src.splitlines(), 1):
m = re.match(r'@using\s+([\w.]+)', line)
if m:
_add_ref(m.group(1), "imports", i)
continue
m = re.match(r'@inject\s+([\w.<>\[\]]+)\s+(\w+)', line)
if m:
_add_ref(m.group(1), "imports", i)
continue
m = re.match(r'@inherits\s+([\w.<>\[\]]+)', line)
if m:
_add_ref(m.group(1), "inherits", i)
continue
m = re.match(r'@model\s+([\w.<>\[\]]+)', line)
if m:
_add_ref(m.group(1), "references", i)
continue
m = re.match(r'@page\s+"([^"]+)"', line)
if m:
route = m.group(1)
route_nid = _make_id("route", route)
if route_nid and route_nid not in seen_ids:
seen_ids.add(route_nid)
nodes.append({"id": route_nid, "label": f"route:{route}",
"file_type": "concept", "source_file": str_path,
"source_location": f"L{i}"})
edges.append({"source": file_nid, "target": route_nid,
"relation": "references", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
continue
_COMPONENT_RE = re.compile(r'<([A-Z][A-Za-z0-9]+)[\s/>]')
_HTML_TAGS = frozenset({
"DOCTYPE", "Html", "Head", "Body", "Div", "Span", "Table", "Form",
"Input", "Button", "Select", "Option", "Label", "Textarea",
"Script", "Style", "Link", "Meta", "Title", "Header", "Footer",
"Nav", "Main", "Section", "Article", "Aside",
})
for m in _COMPONENT_RE.finditer(src):
comp_name = m.group(1)
if comp_name in _HTML_TAGS:
continue
line_num = src[:m.start()].count("\n") + 1
_add_ref(comp_name, "calls", line_num)
_CODE_BLOCK_RE = re.compile(r'@code\s*\{', re.MULTILINE)
for m in _CODE_BLOCK_RE.finditer(src):
block_start = m.end()
depth = 1
pos = block_start
while pos < len(src) and depth > 0:
if src[pos] == '{':
depth += 1
elif src[pos] == '}':
depth -= 1
pos += 1
code_block = src[block_start:pos - 1] if depth == 0 else ""
_METHOD_RE = re.compile(
r'(?:public|private|protected|internal|static|async|override|virtual|abstract)\s+'
r'[\w<>\[\],\s]+\s+(\w+)\s*\('
)
for mm in _METHOD_RE.finditer(code_block):
method_name = mm.group(1)
abs_pos = block_start + mm.start()
method_line = src[:abs_pos].count("\n") + 1
method_nid = _make_id(_file_stem(path), method_name)
if method_nid and method_nid not in seen_ids:
seen_ids.add(method_nid)
nodes.append({"id": method_nid, "label": method_name,
"file_type": "code", "source_file": str_path,
"source_location": f"L{method_line}"})
edges.append({"source": file_nid, "target": method_nid,
"relation": "contains", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
"""Rust extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
def _rust_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
"""Walk a Rust type expression; append (name, role) tuples."""
if node is None:
return
t = node.type
if t == "primitive_type":
return
if t == "type_identifier":
text = _read_text(node, source)
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "scoped_type_identifier":
text = _read_text(node, source).rsplit("::", 1)[-1]
if text:
out.append((text, "generic_arg" if generic else "type"))
return
if t == "generic_type":
name_node = node.child_by_field_name("type")
if name_node is None:
for c in node.children:
if c.type in ("type_identifier", "scoped_type_identifier"):
name_node = c
break
if name_node is not None:
text = _read_text(name_node, source).rsplit("::", 1)[-1]
if text:
out.append((text, "generic_arg" if generic else "type"))
for c in node.children:
if c.type == "type_arguments":
for arg in c.children:
if arg.is_named:
_rust_collect_type_refs(arg, source, True, out)
return
if t in ("reference_type", "pointer_type", "array_type", "tuple_type", "slice_type"):
for c in node.children:
if c.is_named:
_rust_collect_type_refs(c, source, generic, out)
return
if node.is_named:
for c in node.children:
if c.is_named:
_rust_collect_type_refs(c, source, generic, out)
_RUST_TRAIT_METHOD_BLOCKLIST: frozenset[str] = frozenset({
"new", "default", "parse", "from_str", "now", "clone", "into", "from",
"to_string", "to_owned", "len", "is_empty", "iter", "next", "build",
"start", "run", "init", "app", "get", "set", "push", "pop", "insert",
"remove", "contains", "collect", "map", "filter", "unwrap", "expect",
"ok", "err", "some", "none", "send", "recv", "lock", "read", "write",
})
def extract_rust(path: Path) -> dict:
"""Extract functions, structs, enums, traits, impl methods, and use declarations from a .rs file."""
try:
import tree_sitter_rust as tsrust
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree-sitter-rust not installed"}
try:
language = Language(tsrust.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, object]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {
"source": src,
"target": tgt,
"relation": relation,
"confidence": confidence,
"source_file": str_path,
"source_location": f"L{line}",
"weight": weight,
}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def ensure_named_node(name: str, line: int) -> str:
nid = _make_id(stem, name)
if nid in seen_ids:
return nid
nid = _make_id(name)
if nid not in seen_ids:
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
"origin_file": str_path,
})
return nid
def emit_param_return_refs(func_node, func_nid: str, line: int) -> None:
params = func_node.child_by_field_name("parameters")
if params is not None:
for p in params.children:
if p.type != "parameter":
continue
type_node = p.child_by_field_name("type")
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
return_type = func_node.child_by_field_name("return_type")
if return_type is not None:
refs = []
_rust_collect_type_refs(return_type, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "return_type"
tgt = ensure_named_node(ref_name, line)
if tgt != func_nid:
add_edge(func_nid, tgt, "references", line, context=ctx)
def walk(node, parent_impl_nid: str | None = None) -> None:
t = node.type
if t == "function_item":
name_node = node.child_by_field_name("name")
if name_node:
func_name = _read_text(name_node, source)
line = node.start_point[0] + 1
if parent_impl_nid:
func_nid = _make_id(parent_impl_nid, func_name)
add_node(func_nid, f".{func_name}()", line)
add_edge(parent_impl_nid, func_nid, "method", line)
else:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
emit_param_return_refs(node, func_nid, line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
return
if t in ("struct_item", "enum_item", "trait_item"):
name_node = node.child_by_field_name("name")
if name_node:
item_name = _read_text(name_node, source)
line = node.start_point[0] + 1
item_nid = _make_id(stem, item_name)
add_node(item_nid, item_name, line)
add_edge(file_nid, item_nid, "contains", line)
if t == "trait_item":
for c in node.children:
if c.type != "trait_bounds":
continue
for sub in c.children:
if not sub.is_named:
continue
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(sub, source, False, refs)
for idx, (ref_name, _role) in enumerate(refs):
tgt = ensure_named_node(ref_name, line)
if tgt == item_nid:
continue
rel = "inherits" if idx == 0 else "references"
if rel == "inherits":
add_edge(item_nid, tgt, "inherits", line)
else:
add_edge(item_nid, tgt, "references", line,
context="generic_arg")
if t == "struct_item":
for c in node.children:
if c.type != "field_declaration_list":
continue
for field in c.children:
if field.type != "field_declaration":
continue
type_node = field.child_by_field_name("type")
if type_node is None:
for fc in field.children:
if fc.type in ("type_identifier", "generic_type",
"scoped_type_identifier",
"reference_type", "primitive_type"):
type_node = fc
break
refs = []
_rust_collect_type_refs(type_node, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
if tgt != item_nid:
add_edge(item_nid, tgt, "references",
field.start_point[0] + 1, context=ctx)
# Tuple structs (`struct Wrapper(pub Logger, Config);`) nest their
# positional field types directly under ordered_field_declaration_list
# with no field_declaration wrapper -- the same shape handled for tuple
# enum variants below. Without this branch these field type references
# are silently dropped.
for c in node.children:
if c.type != "ordered_field_declaration_list":
continue
fline = c.start_point[0] + 1
for tc in c.children:
if tc.type not in ("type_identifier", "generic_type",
"scoped_type_identifier", "reference_type",
"primitive_type", "tuple_type", "array_type"):
continue
refs = []
_rust_collect_type_refs(tc, source, False, refs)
for ref_name, role in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
tgt = ensure_named_node(ref_name, fline)
if tgt != item_nid:
add_edge(item_nid, tgt, "references", fline, context=ctx)
if t == "enum_item":
# Variant payload types nest under enum_variant_list ->
# enum_variant -> ordered_field_declaration_list (tuple variant,
# `Click(Logger)`) | field_declaration_list (struct variant,
# `Resize { size: Dim }`). Neither was traversed, so every
# enum-variant type reference was silently dropped.
_TYPE_NODES = ("type_identifier", "generic_type",
"scoped_type_identifier", "reference_type",
"primitive_type", "tuple_type", "array_type")
def _emit_enum_type(type_node, at_line):
if type_node is None:
return
refs2: list[tuple[str, str]] = []
_rust_collect_type_refs(type_node, source, False, refs2)
for ref_name, role in refs2:
ctx = "generic_arg" if role == "generic_arg" else "field"
tgt = ensure_named_node(ref_name, at_line)
if tgt != item_nid:
add_edge(item_nid, tgt, "references", at_line, context=ctx)
for c in node.children:
if c.type != "enum_variant_list":
continue
for variant in c.children:
if variant.type != "enum_variant":
continue
vline = variant.start_point[0] + 1
for vc in variant.children:
if vc.type == "ordered_field_declaration_list":
for tc in vc.children:
if tc.type in _TYPE_NODES:
_emit_enum_type(tc, vline)
elif vc.type == "field_declaration_list":
for field in vc.children:
if field.type != "field_declaration":
continue
type_node = field.child_by_field_name("type")
_emit_enum_type(type_node, field.start_point[0] + 1)
return
if t == "impl_item":
type_node = node.child_by_field_name("type")
trait_node = node.child_by_field_name("trait")
impl_nid: str | None = None
if type_node:
type_name = _read_text(type_node, source).strip()
impl_nid = _make_id(stem, type_name)
add_node(impl_nid, type_name, node.start_point[0] + 1)
if trait_node is not None and impl_nid is not None:
refs: list[tuple[str, str]] = []
_rust_collect_type_refs(trait_node, source, False, refs)
for idx, (ref_name, _role) in enumerate(refs):
tgt = ensure_named_node(ref_name, node.start_point[0] + 1)
if tgt == impl_nid:
continue
if idx == 0:
add_edge(impl_nid, tgt, "implements", node.start_point[0] + 1)
else:
add_edge(impl_nid, tgt, "references", node.start_point[0] + 1,
context="generic_arg")
body = node.child_by_field_name("body")
if body:
for child in body.children:
walk(child, parent_impl_nid=impl_nid)
return
if t == "use_declaration":
arg = node.child_by_field_name("argument")
if arg:
raw = _read_text(arg, source)
clean = raw.split("{")[0].rstrip(":").rstrip("*").rstrip(":")
module_name = clean.split("::")[-1].strip()
if module_name:
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import")
return
for child in node.children:
walk(child, parent_impl_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] = n["id"]
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def walk_calls(node, caller_nid: str) -> None:
if node.type == "function_item":
return
if node.type == "call_expression":
func_node = node.child_by_field_name("function")
callee_name: str | None = None
is_member_call: bool = False
is_scoped_call: bool = False
if func_node:
if func_node.type == "identifier":
callee_name = _read_text(func_node, source)
elif func_node.type == "field_expression":
is_member_call = True
field = func_node.child_by_field_name("field")
if field:
callee_name = _read_text(field, source)
elif func_node.type == "scoped_identifier":
# Type::method() — still allow in-file EXTRACTED match, but
# skip cross-file resolution: bare last-segment lookup ignores
# crate boundaries and produces spurious INFERRED edges (#908).
is_scoped_call = True
name = func_node.child_by_field_name("name")
if name:
callee_name = _read_text(name, source)
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
tgt_nid = label_to_nid.get(callee_name)
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",
"context": "call",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
elif not is_scoped_call and callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST:
raw_calls.append({
"caller_nid": caller_nid,
"callee": callee_name,
"is_member_call": is_member_call,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
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, "raw_calls": raw_calls}
+81
View File
@@ -0,0 +1,81 @@
"""Sln extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import _make_id
def extract_sln(path: Path) -> dict:
"""Extract projects and inter-project dependencies from a .sln file."""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": [], "error": f"cannot read {path}"}
file_nid = _make_id(str(path))
str_path = str(path)
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = set()
seen_ids.add(file_nid)
_PROJECT_RE = re.compile(
r'Project\("[^"]*"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"'
)
_DEP_RE = re.compile(r'\{([0-9a-fA-F-]+)\}\s*=\s*\{([0-9a-fA-F-]+)\}')
guid_to_nid: dict[str, str] = {}
for m in _PROJECT_RE.finditer(src):
proj_name = m.group(1)
proj_path = m.group(2).replace("\\", "/")
proj_guid = m.group(3).strip("{}")
try:
abs_proj = str((path.parent / proj_path).resolve())
except Exception:
abs_proj = proj_path
proj_nid = _make_id(abs_proj)
if proj_nid and proj_nid not in seen_ids:
seen_ids.add(proj_nid)
nodes.append({"id": proj_nid, "label": proj_name,
"file_type": "code", "source_file": abs_proj,
"source_location": None})
edges.append({"source": file_nid, "target": proj_nid,
"relation": "contains", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
if proj_guid:
guid_to_nid[proj_guid.lower()] = proj_nid
in_dep_section = False
current_proj_guid: str | None = None
_PROJECT_LINE_RE = re.compile(r'Project\("[^"]*"\)\s*=\s*"[^"]+"\s*,\s*"[^"]+"\s*,\s*"\{([^}]+)\}"')
for line in src.splitlines():
proj_line_m = _PROJECT_LINE_RE.search(line)
if proj_line_m:
current_proj_guid = proj_line_m.group(1).lower()
continue
if line.strip() == "EndProject":
current_proj_guid = None
continue
if "ProjectSection(ProjectDependencies)" in line:
in_dep_section = True
continue
if in_dep_section and "EndProjectSection" in line:
in_dep_section = False
continue
if in_dep_section and current_proj_guid:
dep_m = _DEP_RE.search(line)
if dep_m:
to_guid = dep_m.group(1).lower()
from_nid = guid_to_nid.get(current_proj_guid)
to_nid = guid_to_nid.get(to_guid)
if from_nid and to_nid and from_nid != to_nid:
edges.append({"source": from_nid, "target": to_nid,
"relation": "imports", "confidence": "EXTRACTED",
"source_file": str_path, "weight": 1.0})
return {"nodes": nodes, "edges": edges}
+276
View File
@@ -0,0 +1,276 @@
"""Sql extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id
def extract_sql(path: Path, content: str | bytes | None = None) -> dict:
"""Extract tables, views, functions, and relationships from .sql files via tree-sitter."""
try:
import tree_sitter_sql as tssql
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_sql not installed. Run: pip install tree-sitter-sql"}
try:
language = Language(tssql.language())
parser = Parser(language)
source = (
content.encode("utf-8") if isinstance(content, str)
else content if content is not None
else path.read_bytes()
)
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
return {"nodes": [], "edges": [], "error": str(e)}
stem = _file_stem(path)
str_path = str(path)
file_nid = _make_id(str_path)
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = {file_nid}
table_nids: dict[str, str] = {} # name → nid for reference resolution
def _read(n) -> str:
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
def _obj_name(n) -> str | None:
for c in n.children:
if c.type == "object_reference":
return _read(c)
return None
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}"})
edges.append({"source": file_nid, "target": nid, "relation": "contains",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})
def _add_edge(src: str, tgt: str, relation: str, line: int) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})
def walk(node) -> None:
t = node.type
line = node.start_point[0] + 1
if t == "create_table":
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, name, line)
table_nids[name.lower()] = nid
# Foreign key REFERENCES
for col in node.children:
if col.type == "column_definitions":
has_error = any(cd.type == "ERROR" for cd in col.children)
seen_refs: set[str] = set()
for cd in col.children:
if cd.type == "column_definition":
# Inline column-level REFERENCES
ref_name: str | None = None
found_ref = False
for cc in cd.children:
if cc.type == "keyword_references":
found_ref = True
elif found_ref and cc.type == "object_reference":
ref_name = _read(cc)
break
if ref_name:
ref_nid = table_nids.get(ref_name.lower()) or _make_id(stem, ref_name)
_add_edge(nid, ref_nid, "references", line)
seen_refs.add(ref_name.lower())
elif cd.type == "constraints":
# Table-level FOREIGN KEY ... REFERENCES ... constraints
for constraint in cd.children:
if constraint.type != "constraint":
continue
ref_name = None
found_ref = False
for cc in constraint.children:
if cc.type == "keyword_references":
found_ref = True
elif found_ref and cc.type == "object_reference":
ref_name = _read(cc)
break
if ref_name:
ref_nid = table_nids.get(ref_name.lower()) or _make_id(stem, ref_name)
_add_edge(nid, ref_nid, "references", line)
seen_refs.add(ref_name.lower())
if has_error:
# Dialect-specific syntax (e.g. Firebird COMPUTED BY) causes ERROR
# nodes that make the parser drop the trailing constraints block.
# Regex-scan the raw column_definitions text as fallback.
col_text = _read(col)
for rm in re.finditer(r"\bREFERENCES\s+([\w$]+)", col_text, re.IGNORECASE):
ref_name = rm.group(1)
if ref_name.lower() not in seen_refs:
ref_nid = table_nids.get(ref_name.lower()) or _make_id(stem, ref_name)
_add_edge(nid, ref_nid, "references", line)
seen_refs.add(ref_name.lower())
elif t == "create_view":
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, name, line)
table_nids[name.lower()] = nid
# FROM/JOIN table references inside view body
_walk_from_refs(node, nid, line)
elif t == "create_function":
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, f"{name}()", line)
_walk_from_refs(node, nid, line)
elif t == "create_procedure":
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, f"{name}()", line)
_walk_from_refs(node, nid, line)
elif t == "alter_table":
name = _obj_name(node)
if name:
src_nid = table_nids.get(name.lower())
if not src_nid:
src_nid = _make_id(stem, name)
_add_node(src_nid, name, line)
table_nids[name.lower()] = src_nid
for child in node.children:
if child.type == "add_constraint":
for cc in child.children:
if cc.type != "constraint":
continue
found_ref = False
ref_name: str | None = None
for ccc in cc.children:
if ccc.type == "keyword_references":
found_ref = True
elif found_ref and ccc.type == "object_reference":
ref_name = _read(ccc)
break
if ref_name:
ref_nid = table_nids.get(ref_name.lower())
if not ref_nid:
ref_nid = _make_id(stem, ref_name)
_add_edge(src_nid, ref_nid, "references", line)
elif t == "create_trigger":
trig_name: str | None = None
tbl_name: str | None = None
after_trigger = False
after_for = False
for c in node.children:
if c.type == "keyword_trigger":
after_trigger = True
elif after_trigger and not trig_name and c.type == "object_reference":
trig_name = _read(c)
elif c.type == "keyword_for":
after_for = True
elif after_for and not tbl_name and c.type == "object_reference":
tbl_name = _read(c)
if trig_name:
trig_nid = _make_id(stem, trig_name)
_add_node(trig_nid, trig_name, line)
if tbl_name:
tbl_nid = table_nids.get(tbl_name.lower()) or _make_id(stem, tbl_name)
_add_edge(trig_nid, tbl_nid, "triggers", line)
elif t == "fb_proc_or_trigger":
text = _read(node)
m = re.match(
r"CREATE\s+(?:OR\s+(?:REPLACE|ALTER)\s+)?"
r"(PROCEDURE|TRIGGER|FUNCTION)\s+([\w$]+)",
text, re.IGNORECASE,
)
if m:
obj_type = m.group(1).upper()
obj_name = m.group(2)
obj_nid = _make_id(stem, obj_name)
label = obj_name if obj_type == "TRIGGER" else f"{obj_name}()"
_add_node(obj_nid, label, line)
if obj_type == "TRIGGER":
fm = re.search(r"\bFOR\s+([\w$]+)", text, re.IGNORECASE)
if fm:
tbl = fm.group(1)
tbl_nid = table_nids.get(tbl.lower()) or _make_id(stem, tbl)
_add_edge(obj_nid, tbl_nid, "triggers", line)
_NON_TABLES = {
"select", "where", "set", "dual", "null", "true", "false",
"first", "skip", "rows", "next", "only", "lateral",
}
seen_tbls: set[str] = set()
for rm in re.finditer(r"\b(?:FROM|JOIN|INTO)\s+([\w$]+)", text, re.IGNORECASE):
tbl = rm.group(1)
if tbl.lower() not in _NON_TABLES and tbl.lower() not in seen_tbls:
seen_tbls.add(tbl.lower())
tbl_nid = table_nids.get(tbl.lower()) or _make_id(stem, tbl)
_add_edge(obj_nid, tbl_nid, "reads_from", line)
for rm in re.finditer(r"\bUPDATE\s+([\w$]+)", text, re.IGNORECASE):
tbl = rm.group(1)
if tbl.lower() not in _NON_TABLES and tbl.lower() not in seen_tbls:
seen_tbls.add(tbl.lower())
tbl_nid = table_nids.get(tbl.lower()) or _make_id(stem, tbl)
_add_edge(obj_nid, tbl_nid, "reads_from", line)
for child in node.children:
walk(child)
def _walk_from_refs(node, caller_nid: str, line: int) -> None:
"""Recursively find FROM/JOIN table references inside a node."""
if node.type in ("from", "join"):
for c in node.children:
if c.type == "relation":
for cc in c.children:
if cc.type == "object_reference":
tbl = _read(cc)
tbl_nid = _make_id(stem, tbl)
_add_edge(caller_nid, tbl_nid, "reads_from",
c.start_point[0] + 1)
for child in node.children:
_walk_from_refs(child, caller_nid, line)
for stmt in root.children:
if stmt.type == "statement":
for child in stmt.children:
walk(child)
elif stmt.type in ("fb_proc_or_trigger", "set_term", "declare_external_function"):
walk(stmt)
# Global regex fallback: catch any REFERENCES missed due to ERROR nodes in the parse tree
# (e.g. Firebird COMPUTED BY columns push constraints out of the tree entirely).
# Snapshot after tree walk so we don't re-emit edges already captured above.
emitted = {(e["source"], e["target"]) for e in edges if e["relation"] == "references"}
src_text = source.decode("utf-8", errors="replace")
for m in re.finditer(r"CREATE\s+TABLE\s+([\w$]+)\s*\(", src_text, re.IGNORECASE):
tbl_name = m.group(1)
tbl_nid = table_nids.get(tbl_name.lower())
if tbl_nid is None:
continue
tbl_line = src_text[: m.start()].count("\n") + 1
tail = src_text[m.start():]
end = re.search(r"(?:^|\n)(?:CREATE|SET\s+TERM|ALTER)\s", tail[1:], re.IGNORECASE)
block = tail[: end.start() + 1] if end else tail
for rm in re.finditer(r"\bREFERENCES\s+([\w$]+)", block, re.IGNORECASE):
ref_name = rm.group(1)
ref_nid = table_nids.get(ref_name.lower()) or _make_id(stem, ref_name)
if (tbl_nid, ref_nid) not in emitted:
_add_edge(tbl_nid, ref_nid, "references", tbl_line)
emitted.add((tbl_nid, ref_nid))
return {"nodes": nodes, "edges": edges}
+181
View File
@@ -0,0 +1,181 @@
"""Terraform extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from graphify.extractors.base import _make_id
_TF_META_HEADS = frozenset({"count", "each", "self", "path", "terraform"})
def extract_terraform(path: Path) -> dict:
"""Extract Terraform/HCL blocks and the references between them via tree-sitter.
Nodes: resources, data sources, modules, variables, outputs, providers, and
locals. Edges: `contains` (file -> block), `references` (block -> the blocks
it interpolates, e.g. `aws_instance.web` -> `var.region`), and `depends_on`
(explicit dependency edges).
Node IDs are scoped by the parent directory, not the file stem, because
Terraform resources are module(directory)-scoped: a resource defined in
main.tf is referenced from other .tf files in the same directory. Directory
scoping lets those cross-file references resolve when per-file extractions
are merged (stem scoping would split a definition from its references).
"""
try:
import tree_sitter_hcl as tshcl
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_hcl not installed. Run: pip install tree-sitter-hcl"}
try:
language = Language(tshcl.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)}
str_path = str(path)
file_nid = _make_id(str_path)
scope = path.parent.name or "tf"
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str_path, "source_location": None}]
edges: list[dict] = []
seen_ids: set[str] = {file_nid}
seen_edges: set[tuple[str, str, str]] = set()
def _read(n) -> str:
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
def _label_text(n) -> str:
return _read(n).strip().strip('"')
def _add_node(address: str, label: str, line: int) -> str:
nid = _make_id(scope, address)
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}"})
edges.append({"source": file_nid, "target": nid, "relation": "contains",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})
return nid
def _add_edge(src: str, address: str, relation: str, line: int) -> None:
tgt = _make_id(scope, address)
if src == tgt:
return
key = (src, tgt, relation)
if key in seen_edges:
return
seen_edges.add(key)
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})
def _block_parts(block) -> tuple:
btype = None
labels: list[str] = []
for c in block.children:
if c.type in ("block_start", "body", "block_end"):
break
if c.type == "identifier" and btype is None:
btype = _read(c)
elif c.type in ("string_lit", "identifier"):
labels.append(_label_text(c))
return btype, labels
def _ref_address(expr):
head = _read(expr)
parent = expr.parent
attrs: list[str] = []
if parent is not None:
seen_self = False
for c in parent.children:
if c.id == expr.id:
seen_self = True
continue
if seen_self and c.type == "get_attr":
name = None
for gc in c.children:
if gc.type == "identifier":
name = _read(gc)
break
if name is None:
break
attrs.append(name)
elif seen_self and c.type not in ("get_attr",):
break
if head in _TF_META_HEADS or not head:
return None
if head == "var":
return f"var.{attrs[0]}" if attrs else None
if head == "local":
return f"local.{attrs[0]}" if attrs else None
if head == "module":
return f"module.{attrs[0]}" if attrs else None
if head == "data":
return f"data.{attrs[0]}.{attrs[1]}" if len(attrs) >= 2 else None
return f"{head}.{attrs[0]}" if attrs else None
def _collect_refs(node, owner_nid: str, relation: str) -> None:
rel = relation
if node.type == "attribute":
key_node = node.child_by_field_name("key") or (
node.children[0] if node.children else None
)
if key_node is not None and _read(key_node) == "depends_on":
rel = "depends_on"
if node.type == "variable_expr":
addr = _ref_address(node)
if addr:
_add_edge(owner_nid, addr, rel, node.start_point[0] + 1)
for c in node.children:
if c.is_named:
_collect_refs(c, owner_nid, rel)
def _body_of(block):
for c in block.children:
if c.type == "body":
return c
return None
body = next((c for c in root.children if c.type == "body"), root)
for block in body.children:
if block.type != "block":
continue
btype, labels = _block_parts(block)
line = block.start_point[0] + 1
blk_body = _body_of(block)
if btype == "resource" and len(labels) >= 2:
owner = _add_node(f"{labels[0]}.{labels[1]}", f"{labels[0]}.{labels[1]}", line)
elif btype == "data" and len(labels) >= 2:
owner = _add_node(f"data.{labels[0]}.{labels[1]}", f"data.{labels[0]}.{labels[1]}", line)
elif btype == "module" and labels:
owner = _add_node(f"module.{labels[0]}", f"module.{labels[0]}", line)
elif btype == "variable" and labels:
owner = _add_node(f"var.{labels[0]}", f"var.{labels[0]}", line)
elif btype == "output" and labels:
owner = _add_node(f"output.{labels[0]}", f"output.{labels[0]}", line)
elif btype == "provider" and labels:
owner = _add_node(f"provider.{labels[0]}", f"provider.{labels[0]}", line)
elif btype == "locals" and blk_body is not None:
for attr in blk_body.children:
if attr.type != "attribute":
continue
key_node = attr.children[0] if attr.children else None
if key_node is None:
continue
key = _read(key_node)
lnid = _add_node(f"local.{key}", f"local.{key}", attr.start_point[0] + 1)
_collect_refs(attr, lnid, "references")
continue
else:
continue
if blk_body is not None:
_collect_refs(blk_body, owner, "references")
return {"nodes": nodes, "edges": edges}
+329
View File
@@ -0,0 +1,329 @@
"""Verilog extractor. Moved verbatim from graphify/extract.py."""
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import _file_stem, _make_id, _read_text
def _sv_first_identifier(node, source: bytes) -> str | None:
"""First `simple_identifier` under node in pre-order, or None.
tree-sitter-verilog 1.0.3 nests declaration names a few levels deep instead
of exposing a `name` field. Scope the search to the right child node (e.g.
`function_identifier`) or this returns the return-type instead of the name.
"""
if node is None:
return None
for child in node.children:
if child.type == "simple_identifier":
return _read_text(child, source)
found = _sv_first_identifier(child, source)
if found:
return found
return None
def _sv_child(node, type_name: str) -> object | None:
if node is None:
return None
for child in node.children:
if child.type == type_name:
return child
return None
_SV_BUILTIN_TYPES = frozenset({
"bit", "logic", "reg", "wire", "int", "integer", "shortint", "longint",
"byte", "time", "real", "shortreal", "void", "string", "type", "event",
"mailbox", "semaphore", "process", "chandle",
})
_SV_NON_TYPE_WORDS = frozenset({
"return", "if", "else", "for", "foreach", "while", "case", "begin", "end",
"function", "task", "class", "endclass", "endfunction", "endtask",
})
_SV_PARENS_INNER = r"(?:[^()]|\([^()]*\))*"
_SV_PARENS = r"\(" + _SV_PARENS_INNER + r"\)"
_SV_FUNC_RE = re.compile(
r"\bfunction\s+([A-Za-z_]\w*(?:\s*#\s*" + _SV_PARENS + r")?)\s+(\w+)\s*"
r"\((" + _SV_PARENS_INNER + r")\)\s*;",
re.MULTILINE,
)
_SV_PARAM_RE = re.compile(
r"\s*(?:input|output|inout|ref|const\s+ref)?\s*"
r"([A-Za-z_]\w*(?:\s*#\s*" + _SV_PARENS + r")?)\s+\w+"
)
def _sv_strip_comments(text: str) -> str:
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
return re.sub(r"//.*", "", text)
def _sv_split_type_list(text: str) -> list[str]:
parts: list[str] = []
depth = 0
start = 0
for idx, ch in enumerate(text):
if ch == "(":
depth += 1
elif ch == ")":
depth = max(0, depth - 1)
elif ch == "," and depth == 0:
item = text[start:idx].strip()
if item:
parts.append(item)
start = idx + 1
item = text[start:].strip()
if item:
parts.append(item)
return parts
def _sv_collect_type_refs(type_text: str, generic: bool = False,
skip: frozenset[str] = frozenset()) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
text = type_text.strip()
if not text:
return refs
head = re.match(r"([A-Za-z_]\w*)", text)
if head:
name = head.group(1)
# `skip` carries the enclosing class's `#(type T = ...)` parameters so
# they are not mistaken for referenced types.
if name not in _SV_BUILTIN_TYPES and name not in _SV_NON_TYPE_WORDS and name not in skip:
refs.append((name, "generic_arg" if generic else "type"))
params = re.search(r"#\s*\((" + _SV_PARENS_INNER + r")\)", text)
if params:
for arg in _sv_split_type_list(params.group(1)):
refs.extend(_sv_collect_type_refs(arg, generic=True, skip=skip))
return refs
def _augment_systemverilog_semantics(
raw: str,
stem: str,
str_path: str,
file_nid: str,
nodes: list[dict],
edges: list[dict],
seen_ids: set[str],
) -> None:
label_to_nid = {node["label"]: node["id"] for node in nodes}
def line_for(offset: int) -> int:
return raw.count("\n", 0, offset) + 1
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}",
"confidence_score": 1.0})
label_to_nid[label] = nid
def ensure_type(label: str, line: int) -> str:
if label in label_to_nid:
return label_to_nid[label]
nid = _make_id(stem, label)
add_node(nid, label, line)
return nid
def add_edge(src: str, target_label: str, relation: str, line: int, context: str | None = None) -> None:
tgt = ensure_type(target_label, line)
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str_path, "source_location": f"L{line}", "weight": 1.0}
if context:
edge["context"] = context
edges.append(edge)
text = _sv_strip_comments(raw)
# Consuming `endclass` (rather than a lookahead) makes each match own its
# terminator, so back-to-back or malformed classes cannot bleed bodies.
class_re = re.compile(
r"\b(?:(interface)\s+)?class\s+(\w+)([^;{]*)\s*;(.*?)\bendclass\b",
re.DOTALL,
)
for match in class_re.finditer(text):
class_name = match.group(2)
header = match.group(3) or ""
body = match.group(4) or ""
line = line_for(match.start())
# `#(type T = Payload)` declares `T` as a class type parameter, not a
# referenced type — collect these to skip below.
type_params = frozenset(re.findall(r"\btype\s+(\w+)", header))
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name, line)
edges.append({"source": file_nid, "target": class_nid, "relation": "defines",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str_path, "source_location": f"L{line}", "weight": 1.0})
ext = re.search(r"\bextends\s+(\w+)", header)
if ext:
add_edge(class_nid, ext.group(1), "inherits", line)
impl = re.search(r"\bimplements\s+([^;{]+)", header)
if impl:
for iface_name in _sv_split_type_list(impl.group(1)):
add_edge(class_nid, iface_name.split("#", 1)[0].strip(), "implements", line)
body_without_functions = re.sub(
r"\bfunction\b.*?\bendfunction\b",
lambda m: "\n" * m.group(0).count("\n"),
body,
flags=re.DOTALL,
)
# Optional leading class-property qualifiers (rand/local/protected/etc.)
# must be consumed: otherwise a qualified field like `rand Config x;`
# (three tokens) fails the `<type> <name>;` shape and its type reference
# is silently dropped.
for field in re.finditer(r"^\s*(?:(?:rand|randc|local|protected|static|const|automatic|var)\s+)*([A-Za-z_]\w*(?:\s*#\s*\([^;]+?\))?)\s+\w+\s*;", body_without_functions, re.MULTILINE):
# Count to the start of the type token (group 1), not the match
# start: `^\s*` consumes the leading newline(s), so field.start()
# would resolve to the class's line instead of the field's.
field_line = line + body_without_functions.count("\n", 0, field.start(1))
for ref_name, role in _sv_collect_type_refs(field.group(1), skip=type_params):
add_edge(class_nid, ref_name, "references", field_line, "generic_arg" if role == "generic_arg" else "field")
for fm in _SV_FUNC_RE.finditer(body):
return_type, func_name, params = fm.group(1), fm.group(2), fm.group(3)
func_line = line + body.count("\n", 0, fm.start())
func_nid = _make_id(class_nid, func_name)
add_node(func_nid, func_name, func_line)
edges.append({"source": class_nid, "target": func_nid, "relation": "method",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str_path, "source_location": f"L{func_line}", "weight": 1.0})
for ref_name, role in _sv_collect_type_refs(return_type, skip=type_params):
add_edge(func_nid, ref_name, "references", func_line, "generic_arg" if role == "generic_arg" else "return_type")
for param in _sv_split_type_list(params):
pm = _SV_PARAM_RE.match(param)
if not pm:
continue
for ref_name, role in _sv_collect_type_refs(pm.group(1), skip=type_params):
add_edge(func_nid, ref_name, "references", func_line, "generic_arg" if role == "generic_arg" else "parameter_type")
def extract_verilog(path: Path) -> dict:
"""Extract modules, functions, tasks, package imports, instantiations, and
SystemVerilog class semantics (inherits/implements edges, field/parameter/
return-type references) from .v/.sv files."""
try:
import tree_sitter_verilog as tsverilog
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_verilog not installed"}
try:
language = Language(tsverilog.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 = _file_stem(path)
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}",
"confidence_score": 1.0})
def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", score: float = 1.0) -> None:
edges.append({"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "confidence_score": score,
"source_file": str_path, "source_location": f"L{line}", "weight": 1.0})
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def walk(node, module_nid: str | None = None) -> None:
t = node.type
# SystemVerilog class bodies are handled by _augment_systemverilog_semantics
# (regex over source text). Skip their subtrees so in-class methods are not
# double-emitted here — and with the wrong, return-type-derived name.
if t in ("class_declaration", "interface_class_declaration"):
return
if t == "module_declaration":
mod_name = _sv_first_identifier(_sv_child(node, "module_header"), source)
if mod_name:
line = node.start_point[0] + 1
nid = _make_id(stem, mod_name)
add_node(nid, mod_name, line)
add_edge(file_nid, nid, "defines", line)
for child in node.children:
walk(child, nid)
return
# `function_prototype` only appears inside class/interface-class bodies
# (skipped above) and nests its name differently; it is intentionally not
# handled here.
elif t == "function_declaration":
fn_body = _sv_child(node, "function_body_declaration")
func_name = _sv_first_identifier(_sv_child(fn_body, "function_identifier"), source)
if func_name:
line = node.start_point[0] + 1
parent = module_nid or file_nid
nid = _make_id(parent, func_name)
add_node(nid, f"{func_name}()", line)
add_edge(parent, nid, "contains", line)
elif t == "task_declaration":
tk_body = _sv_child(node, "task_body_declaration")
task_name = _sv_first_identifier(_sv_child(tk_body, "task_identifier"), source)
if task_name:
line = node.start_point[0] + 1
parent = module_nid or file_nid
nid = _make_id(parent, task_name)
add_node(nid, task_name, line)
add_edge(parent, nid, "contains", line)
elif t == "package_import_declaration":
for child in node.children:
if child.type == "package_import_item":
pkg_text = _read_text(child, source)
pkg_name = pkg_text.split("::")[0].strip()
if pkg_name:
line = node.start_point[0] + 1
tgt_nid = _make_id(pkg_name)
add_node(tgt_nid, pkg_name, line)
src_nid = module_nid or file_nid
add_edge(src_nid, tgt_nid, "imports_from", line)
elif t in ("module_instantiation", "checker_instantiation"):
# `leaf u_leaf();` parses as checker_instantiation in 1.0.3;
# module_instantiation (when it occurs) exposes a `module_type` field.
# Both reduce to the first identifier under the node — the instantiated
# type, not the instance name (which appears later).
if module_nid:
type_node = node.child_by_field_name("module_type")
inst_type = (_read_text(type_node, source).strip() if type_node
else _sv_first_identifier(node, source))
if inst_type:
line = node.start_point[0] + 1
tgt_nid = _make_id(inst_type)
add_node(tgt_nid, inst_type, line)
add_edge(module_nid, tgt_nid, "instantiates", line)
for child in node.children:
walk(child, module_nid)
walk(root)
_augment_systemverilog_semantics(
source.decode("utf-8", errors="replace"),
stem,
str_path,
file_nid,
nodes,
edges,
seen_ids,
)
return {"nodes": nodes, "edges": edges}
+175
View File
@@ -0,0 +1,175 @@
"""Zig extractor (tree-sitter). Moved verbatim from graphify/extract.py."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from graphify.extractors.base import _file_stem, _make_id, _read_text
def extract_zig(path: Path) -> dict:
"""Extract functions, structs, enums, unions, and imports from a .zig file."""
try:
import tree_sitter_zig as tszig
from tree_sitter import Language, Parser
except ImportError:
return {"nodes": [], "edges": [], "error": "tree_sitter_zig not installed"}
try:
language = Language(tszig.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 = _file_stem(path)
str_path = str(path)
nodes: list[dict] = []
edges: list[dict] = []
seen_ids: set[str] = set()
function_bodies: list[tuple[str, Any]] = []
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(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
context: str | None = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
file_nid = _make_id(str(path))
add_node(file_nid, path.name, 1)
def _extract_import(node) -> None:
for child in node.children:
if child.type == "builtin_function":
bi = None
args = None
for c in child.children:
if c.type == "builtin_identifier":
bi = _read_text(c, source)
elif c.type == "arguments":
args = c
if bi in ("@import", "@cImport") and args:
for arg in args.children:
if arg.type in ("string_literal", "string"):
raw = _read_text(arg, source).strip('"')
module_name = raw.split("/")[-1].split(".")[0]
if module_name:
tgt_nid = _make_id(module_name)
add_edge(file_nid, tgt_nid, "imports_from",
node.start_point[0] + 1)
return
elif child.type == "field_expression":
_extract_import(child)
return
def walk(node, parent_struct_nid: str | None = None) -> None:
t = node.type
if t == "function_declaration":
name_node = node.child_by_field_name("name")
if name_node:
func_name = _read_text(name_node, source)
line = node.start_point[0] + 1
if parent_struct_nid:
func_nid = _make_id(parent_struct_nid, func_name)
add_node(func_nid, f".{func_name}()", line)
add_edge(parent_struct_nid, func_nid, "method", line)
else:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
add_edge(file_nid, func_nid, "contains", line)
body = node.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
return
if t == "variable_declaration":
name_node = None
value_node = None
for child in node.children:
if child.type == "identifier":
name_node = child
elif child.type in ("struct_declaration", "enum_declaration",
"union_declaration", "builtin_function",
"field_expression"):
value_node = child
if value_node and value_node.type == "struct_declaration":
if name_node:
struct_name = _read_text(name_node, source)
line = node.start_point[0] + 1
struct_nid = _make_id(stem, struct_name)
add_node(struct_nid, struct_name, line)
add_edge(file_nid, struct_nid, "contains", line)
for child in value_node.children:
walk(child, parent_struct_nid=struct_nid)
return
if value_node and value_node.type in ("enum_declaration", "union_declaration"):
if name_node:
type_name = _read_text(name_node, source)
line = node.start_point[0] + 1
type_nid = _make_id(stem, type_name)
add_node(type_nid, type_name, line)
add_edge(file_nid, type_nid, "contains", line)
return
if value_node and value_node.type in ("builtin_function", "field_expression"):
_extract_import(node)
return
for child in node.children:
walk(child, parent_struct_nid)
walk(root)
seen_call_pairs: set[tuple[str, str]] = set()
raw_calls: list[dict] = []
def walk_calls(node, caller_nid: str) -> None:
if node.type == "function_declaration":
return
if node.type == "call_expression":
fn = node.child_by_field_name("function")
if fn:
fn_text = _read_text(fn, source)
callee = fn_text.split(".")[-1]
is_member_call = "." in fn_text
tgt_nid = next((n["id"] for n in nodes if n["label"] in
(f"{callee}()", f".{callee}()")), None)
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)
add_edge(caller_nid, tgt_nid, "calls",
node.start_point[0] + 1,
confidence="EXTRACTED", weight=1.0)
elif callee:
raw_calls.append({
"caller_nid": caller_nid,
"callee": callee,
"is_member_call": is_member_call,
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
})
for child in node.children:
walk_calls(child, caller_nid)
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
clean_edges = [e for e in edges if e["source"] in seen_ids and
(e["target"] in seen_ids or e["relation"] == "imports_from")]
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
+162
View File
@@ -0,0 +1,162 @@
"""Intra-file slicing for oversized text documents (#1369).
The extraction packer (`_pack_chunks_by_tokens`) treats each file as atomic and
`_read_files` caps every file at ``_FILE_CHAR_CAP`` characters, so a document
larger than that cap had everything past the cap silently dropped — the model
never saw it, and nothing in the adaptive-retry path could recover it ("a single
file larger than the budget ... packing can't shrink one big file").
This module splits an oversized *splittable text* document (Markdown, plain
text, reStructuredText) into contiguous ``FileSlice`` units at heading /
paragraph / line boundaries so the whole file gets extracted across several
units. Every slice of a file reports the **parent file path** as its source, so
the resulting nodes are never fragmented per-slice — they merge by source_file
exactly as if the file had been extracted in one pass.
Only plain-text documents are sliced: code files need whole-symbol context, and
PDFs/images are read through their own extractors and have no char-offset model.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
# Plain-text document types where boundary-based slicing is meaningful and where
# `_file_to_text` is a straight ``read_text`` (so a char range matches the bytes
# the model is shown). Deliberately excludes code (.py, .ts, ...) and binary
# docs (.pdf) — those are never sliced.
_SPLITTABLE_TEXT_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".txt", ".rst"})
# Boundary preferences, strongest first. A Markdown heading (``\n#``) keeps a
# section with its title; a blank line keeps a paragraph intact; a bare newline
# avoids cutting mid-line. If none is found in the window we hard-cut.
_BOUNDARY_SEPARATORS = ("\n#", "\n\n", "\n")
@dataclass(frozen=True)
class FileSlice:
"""A contiguous ``[start, end)`` character range of a splittable text file.
``index``/``total`` are for logging only. ``path`` is the real file on disk;
the slice always reports ``path`` as its source so slices don't fragment the
graph.
"""
path: Path
start: int
end: int
index: int
total: int
# A unit of extraction work: either a whole file (``Path``) or one slice of one.
Unit = "Path | FileSlice"
def unit_path(unit: "Path | FileSlice") -> Path:
"""The on-disk path a unit belongs to (the parent file for a slice)."""
return unit.path if isinstance(unit, FileSlice) else unit
def is_splittable_text(path: Path) -> bool:
"""True for plain-text document types that may be sliced."""
return path.suffix.lower() in _SPLITTABLE_TEXT_SUFFIXES
def _best_cut(text: str, start: int, end: int) -> int:
"""Return a cut index in ``(start, end]`` at the strongest nearby boundary.
Searches the window ``text[start:end]`` for the latest heading, then blank
line, then newline, and returns the index just *after* it (a heading cuts
just *before* the ``#`` so the heading leads the next slice). Falls back to a
hard cut at ``end`` when the window has no usable boundary, which still makes
forward progress because ``end > start``.
"""
window = text[start:end]
for sep in _BOUNDARY_SEPARATORS:
idx = window.rfind(sep)
if idx > 0: # a boundary strictly inside the window (non-empty prev slice)
if sep == "\n#":
return start + idx + 1 # keep the newline with the previous slice
return start + idx + len(sep)
return end
def slice_boundaries(text: str, max_chars: int) -> list[tuple[int, int]]:
"""Contiguous ``(start, end)`` ranges covering all of ``text``, each ≤ max_chars.
Ranges are gap-free and non-overlapping, so concatenating the slices
reproduces ``text`` exactly — no content is dropped.
"""
n = len(text)
if n <= max_chars:
return [(0, n)]
bounds: list[tuple[int, int]] = []
pos = 0
while pos < n:
hard = min(pos + max_chars, n)
end = _best_cut(text, pos, hard) if hard < n else n
if end <= pos: # defensive: never stall
end = hard
bounds.append((pos, end))
pos = end
return bounds
def expand_oversized_files(
files: list[Path], max_chars: int
) -> list["Path | FileSlice"]:
"""Replace each oversized splittable-text file with a list of ``FileSlice``s.
Files at or below ``max_chars`` (and all non-splittable files) pass through
unchanged as ``Path``, so behaviour is identical for everything that already
fit. Unreadable files pass through untouched (the reader handles the error).
"""
out: list["Path | FileSlice"] = []
for f in files:
if not is_splittable_text(f):
out.append(f)
continue
try:
text = f.read_text(encoding="utf-8", errors="replace")
except OSError:
out.append(f)
continue
if len(text) <= max_chars:
out.append(f)
continue
ranges = slice_boundaries(text, max_chars)
total = len(ranges)
for i, (s, e) in enumerate(ranges):
out.append(FileSlice(path=f, start=s, end=e, index=i, total=total))
return out
def read_slice_text(fs: FileSlice) -> str:
"""Read just this slice's characters from its parent file."""
text = fs.path.read_text(encoding="utf-8", errors="replace")
return text[fs.start:fs.end]
def bisect_slice(fs: FileSlice) -> tuple[FileSlice, FileSlice] | None:
"""Split a slice into two halves at a newline near its midpoint, or None.
Used by the adaptive-retry path when a single slice still overflows the
model's output: halving it produces a smaller response. Returns None when the
slice is already too small to split meaningfully.
"""
if fs.end - fs.start <= 1:
return None
try:
text = fs.path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
mid = (fs.start + fs.end) // 2
nl = text.find("\n", mid, fs.end)
cut = nl + 1 if (nl != -1 and fs.start < nl + 1 < fs.end) else mid
if not (fs.start < cut < fs.end):
return None
left = FileSlice(fs.path, fs.start, cut, fs.index, fs.total)
right = FileSlice(fs.path, cut, fs.end, fs.index, fs.total)
return left, right
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import json
import hashlib
import sys
from datetime import datetime, timezone
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph as _jg
_GLOBAL_DIR = Path.home() / ".graphify"
_GLOBAL_GRAPH = _GLOBAL_DIR / "global-graph.json"
_GLOBAL_MANIFEST = _GLOBAL_DIR / "global-manifest.json"
def _load_manifest() -> dict:
if _GLOBAL_MANIFEST.exists():
try:
return json.loads(_GLOBAL_MANIFEST.read_text(encoding="utf-8"))
except Exception as exc:
# Don't silently wipe the user's manifest on a parse error: that
# deletes every tracked repo. Back the bad file up and surface the
# error so the user can recover or report it.
backup = _GLOBAL_MANIFEST.with_suffix(
_GLOBAL_MANIFEST.suffix + f".corrupt.{int(datetime.now(timezone.utc).timestamp())}"
)
try:
_GLOBAL_MANIFEST.rename(backup)
print(
f"[graphify global] manifest at {_GLOBAL_MANIFEST} failed to parse ({exc}); "
f"moved to {backup} and starting fresh. Restore from the backup if this was "
f"unexpected.",
file=sys.stderr,
)
except Exception as rename_exc:
print(
f"[graphify global] manifest at {_GLOBAL_MANIFEST} failed to parse ({exc}) "
f"and could not be backed up ({rename_exc}). Starting fresh.",
file=sys.stderr,
)
return {"version": 1, "repos": {}}
def _save_manifest(manifest: dict) -> None:
_GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
_GLOBAL_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
def _load_global_graph() -> nx.Graph:
if _GLOBAL_GRAPH.exists():
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(_GLOBAL_GRAPH)
data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8"))
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
try:
return _jg.node_link_graph(data, edges="links")
except TypeError:
return _jg.node_link_graph(data)
return nx.Graph()
def _save_global_graph(G: nx.Graph) -> None:
_GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
try:
data = _jg.node_link_data(G, edges="links")
except TypeError:
data = _jg.node_link_data(G)
_GLOBAL_GRAPH.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _file_hash(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()[:16]
def global_add(source_path: Path, repo_tag: str) -> dict:
"""Add or update a project graph in the global graph.
Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped.
Skipped=True means the source graph hasn't changed since last add.
"""
from graphify.build import prefix_graph_for_global, prune_repo_from_graph
if not source_path.exists():
raise FileNotFoundError(f"graph not found: {source_path}")
manifest = _load_manifest()
src_hash = _file_hash(source_path)
existing = manifest["repos"].get(repo_tag, {})
existing_path = existing.get("source_path", "")
if existing_path and existing_path != str(source_path.resolve()):
print(
f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to "
f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. "
f"Use --as <tag> to give it a different name.",
file=sys.stderr,
)
if existing.get("source_hash") == src_hash:
return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True}
# Load source graph
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(source_path)
data = json.loads(source_path.read_text(encoding="utf-8"))
if "links" not in data and "edges" in data:
data = dict(data, links=data["edges"])
try:
src_G = _jg.node_link_graph(data, edges="links")
except TypeError:
src_G = _jg.node_link_graph(data)
# Prefix IDs for cross-project isolation
prefixed = prefix_graph_for_global(src_G, repo_tag)
# Load global graph and prune stale nodes for this repo
G = _load_global_graph()
removed = prune_repo_from_graph(G, repo_tag)
# Merge external-library nodes (no source_file) by label to avoid duplication
external_labels = {
d.get("label", ""): n
for n, d in G.nodes(data=True)
if not d.get("source_file") and d.get("label")
}
# Map each deduplicated external onto the existing global node so that
# edges incident to it can be rewired instead of dropped.
remap = {}
for node, data in prefixed.nodes(data=True):
if not data.get("source_file") and data.get("label") in external_labels:
remap[node] = external_labels[data["label"]]
# Compose: add prefixed nodes (except deduplicated externals) into global graph
for node, data in prefixed.nodes(data=True):
if node not in remap:
G.add_node(node, **data)
for u, v, data in prefixed.edges(data=True):
u = remap.get(u, u)
v = remap.get(v, v)
if u != v: # don't introduce self-loops via remapping
G.add_edge(u, v, **data)
added = prefixed.number_of_nodes() - len(remap)
_save_global_graph(G)
manifest["repos"][repo_tag] = {
"added_at": datetime.now(timezone.utc).isoformat(),
"source_path": str(source_path.resolve()),
"node_count": added,
"edge_count": prefixed.number_of_edges(),
"source_hash": src_hash,
}
_save_manifest(manifest)
return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False}
def global_remove(repo_tag: str) -> int:
"""Remove all nodes for repo_tag from the global graph. Returns count removed."""
from graphify.build import prune_repo_from_graph
manifest = _load_manifest()
if repo_tag not in manifest["repos"]:
raise KeyError(f"repo '{repo_tag}' not in global graph")
G = _load_global_graph()
removed = prune_repo_from_graph(G, repo_tag)
_save_global_graph(G)
del manifest["repos"][repo_tag]
_save_manifest(manifest)
return removed
def global_list() -> dict:
"""Return the manifest repos dict."""
return _load_manifest().get("repos", {})
def global_path() -> Path:
return _GLOBAL_GRAPH
+223
View File
@@ -0,0 +1,223 @@
"""Optional Google Workspace shortcut export support.
Google Drive for desktop stores native Docs, Sheets, and Slides as small JSON
shortcut files (.gdoc, .gsheet, .gslides). Those files are pointers, not the
document content. This module exports them to Markdown sidecars via the
googleworkspace CLI (`gws`) so Graphify can extract their actual contents.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
import urllib.parse
from pathlib import Path
from typing import Callable, Any
GOOGLE_WORKSPACE_EXTENSIONS = {".gdoc", ".gsheet", ".gslides"}
def google_workspace_enabled(value: str | None = None) -> bool:
"""Return True when Google Workspace shortcut export is enabled."""
raw = value if value is not None else os.environ.get("GRAPHIFY_GOOGLE_WORKSPACE", "")
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _safe_yaml_str(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
def _extract_file_id_from_url(url: str) -> str | None:
"""Extract a Drive file ID from common Google Docs/Drive URL shapes."""
if not url:
return None
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("id"):
return query["id"][0]
match = re.search(r"/(?:document|spreadsheets|presentation|file)/d/([^/?#]+)", parsed.path)
if match:
return match.group(1)
return None
def _extract_resource_key(url: str, data: dict[str, Any]) -> str | None:
for key in ("resource_key", "resourceKey"):
value = data.get(key)
if value:
return str(value)
if not url:
return None
parsed = urllib.parse.urlparse(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("resourcekey"):
return query["resourcekey"][0]
return None
def read_google_shortcut(path: Path) -> dict[str, str | None]:
"""Read a .gdoc/.gsheet/.gslides shortcut and return export metadata."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"could not read Google Workspace shortcut {path}: {exc}") from exc
url = str(data.get("url") or "")
file_id = (
data.get("doc_id")
or data.get("file_id")
or data.get("fileId")
or data.get("id")
or _extract_file_id_from_url(url)
)
if not file_id:
resource_id = str(data.get("resource_id") or "")
if ":" in resource_id:
file_id = resource_id.split(":", 1)[1]
if not file_id:
raise RuntimeError(f"Google Workspace shortcut {path} does not include a Drive file ID")
return {
"file_id": str(file_id),
"url": url or None,
"resource_key": _extract_resource_key(url, data),
"account": str(data.get("email")) if data.get("email") else None,
}
def _run_gws_export(file_id: str, mime_type: str, output: Path, resource_key: str | None = None) -> None:
exe = shutil.which("gws")
if not exe:
raise RuntimeError(
"gws is required for Google Workspace export. Install it from "
"https://github.com/googleworkspace/cli and run `gws auth login -s drive`."
)
params: dict[str, str] = {"fileId": file_id, "mimeType": mime_type}
# Drive resource keys are sent via X-Goog-Drive-Resource-Keys. The current
# gws export command has no custom-header flag, so do not pass resourceKey
# as an unsupported query parameter.
_ = resource_key
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
timeout = int(os.environ.get("GRAPHIFY_GOOGLE_WORKSPACE_TIMEOUT", "120"))
result = subprocess.run(
[exe, "drive", "files", "export", "--params", json.dumps(params), "-o", output.name],
capture_output=True,
cwd=output.parent,
text=True,
timeout=timeout,
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
if len(stderr) > 1200:
stderr = stderr[:1200] + "..."
raise RuntimeError(f"gws export failed for {file_id}: {stderr}")
def _sidecar_path(path: Path, out_dir: Path) -> Path:
name_hash = hashlib.sha256(str(path.resolve()).encode()).hexdigest()[:8]
return out_dir / f"{path.stem}_{name_hash}.md"
def _with_frontmatter(path: Path, shortcut: dict[str, str | None], body: str, exported_mime_type: str) -> str:
source_url = shortcut.get("url") or ""
account = shortcut.get("account") or ""
account_line = ""
if account:
account_hash = hashlib.sha256(account.encode()).hexdigest()[:12]
account_line = f'google_account_hash: "{account_hash}"\n'
return (
"---\n"
f'source_file: "{_safe_yaml_str(str(path))}"\n'
'source_type: "google_workspace"\n'
f'google_file_id: "{_safe_yaml_str(shortcut["file_id"] or "")}"\n'
f'google_export_mime_type: "{_safe_yaml_str(exported_mime_type)}"\n'
f'source_url: "{_safe_yaml_str(source_url)}"\n'
f"{account_line}"
"---\n\n"
f"<!-- converted from Google Workspace shortcut: {path.name} -->\n\n"
f"{body.strip()}\n"
)
def convert_google_workspace_file(
path: Path,
out_dir: Path,
*,
xlsx_to_markdown: Callable[[Path], str] | None = None,
) -> Path | None:
"""Export a Google Workspace shortcut to a Markdown sidecar.
Returns the converted Markdown path, or None when conversion is unsupported
or produced no readable content.
"""
ext = path.suffix.lower()
if ext not in GOOGLE_WORKSPACE_EXTENSIONS:
return None
shortcut = read_google_shortcut(path)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = _sidecar_path(path, out_dir)
if ext == ".gdoc":
with tempfile.NamedTemporaryFile("w+b", suffix=".md", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(shortcut["file_id"] or "", "text/markdown", tmp_path, shortcut.get("resource_key"))
body = tmp_path.read_text(encoding="utf-8", errors="replace")
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(_with_frontmatter(path, shortcut, body, "text/markdown"), encoding="utf-8")
return out_path
if ext == ".gslides":
with tempfile.NamedTemporaryFile("w+b", suffix=".txt", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(shortcut["file_id"] or "", "text/plain", tmp_path, shortcut.get("resource_key"))
body = tmp_path.read_text(encoding="utf-8", errors="replace")
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(_with_frontmatter(path, shortcut, body, "text/plain"), encoding="utf-8")
return out_path
if ext == ".gsheet":
if xlsx_to_markdown is None:
raise RuntimeError("Google Sheets export requires the office extra: pip install graphifyy[office,google]")
with tempfile.NamedTemporaryFile("w+b", suffix=".xlsx", delete=False, dir=out_dir) as tmp:
tmp_path = Path(tmp.name)
try:
_run_gws_export(
shortcut["file_id"] or "",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
tmp_path,
shortcut.get("resource_key"),
)
body = xlsx_to_markdown(tmp_path)
finally:
tmp_path.unlink(missing_ok=True)
if not body.strip():
return None
out_path.write_text(
_with_frontmatter(
path,
shortcut,
body,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
encoding="utf-8",
)
return out_path
return None
+548
View File
@@ -0,0 +1,548 @@
# git hook integration - install/uninstall graphify post-commit and post-checkout hooks
from __future__ import annotations
import configparser
import os
import re
import sys
from pathlib import Path
_HOOK_MARKER = "# graphify-hook-start"
_HOOK_MARKER_END = "# graphify-hook-end"
_CHECKOUT_MARKER = "# graphify-checkout-hook-start"
_CHECKOUT_MARKER_END = "# graphify-checkout-hook-end"
# __PINNED_PYTHON__ is replaced at install time with the absolute path of the
# Python interpreter that ran `graphify hook install`. For uv-tool and pipx
# installs the interpreter lives inside an isolated venv, so the launcher on
# PATH is the only entry point — and GUI git clients / CI runners often have a
# minimal PATH that omits ~/.local/bin. Pinning sys.executable at install time
# makes the hook work regardless of PATH at git-trigger time.
_PYTHON_DETECT = """\
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
# _PINNED was recorded at hook-install time; tried first so the hook works even
# when the graphify launcher is not on PATH (common in GUI clients and CI).
#
# Probes check availability with importlib.util.find_spec instead of importing
# the package: a probe that imports graphify wholesale executes the full package
# import (10s+ cold on machines with AV-scanned or large site-packages) and used
# to run up to FOUR times synchronously, stalling every commit before the
# detached launch even started. find_spec locates the package without executing
# it, so each probe costs interpreter startup only. The detached rebuild still
# fails loudly in the log if the package is broken under that interpreter.
_GFY_PROBE="import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('graphify') else 1)"
GRAPHIFY_PYTHON=""
_PINNED='__PINNED_PYTHON__'
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="$_PINNED"
fi
# Second probe: read graphify-out/.graphify_python (written by the skill and
# CLI; survives uv-tool reinstalls and is the same source the README documents).
if [ -z "$GRAPHIFY_PYTHON" ]; then
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
if [ -f "$_GFY_PYTHON_FILE" ]; then
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
case "$_FROM_FILE" in
*[!a-zA-Z0-9/_.@:\\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
esac
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="$_FROM_FILE"
fi
fi
fi
# Third probe: resolve via the graphify launcher on PATH.
if [ -z "$GRAPHIFY_PYTHON" ]; then
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
# Windows pip layout: Scripts/graphify(.exe) sits beside ..\\python.exe
# (or .\\python.exe inside a venv's Scripts dir). NOTE: command -v may
# return the launcher path WITHOUT the .exe suffix, so this cannot key
# on the extension.
_GFY_BINDIR=$(dirname "$GRAPHIFY_BIN")
if [ -x "$_GFY_BINDIR/../python.exe" ] && "$_GFY_BINDIR/../python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="$_GFY_BINDIR/../python.exe"
elif [ -x "$_GFY_BINDIR/python.exe" ] && "$_GFY_BINDIR/python.exe" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="$_GFY_BINDIR/python.exe"
fi
fi
if [ -z "$GRAPHIFY_PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
# POSIX launcher: parse the shebang. head -c + tr strip NUL bytes first —
# when the launcher is a Windows binary reached without its .exe suffix,
# a raw `head -1` reads binary into the command substitution and the
# shell warns about ignored null bytes on every commit.
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -c 256 "$GRAPHIFY_BIN" 2>/dev/null | tr -d '\\000' | head -n 1 | sed 's/^#![[:space:]]*//') ;;
esac
case "$_SHEBANG" in
*/env\\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
esac
# 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="" ;;
esac
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON=""
fi
fi
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="python3"
elif command -v python >/dev/null 2>&1 && python -c "$_GFY_PROBE" 2>/dev/null; then
GRAPHIFY_PYTHON="python"
else
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
exit 0
fi
fi
"""
# The Python that the rebuild runs, shared by both hooks. Embedded verbatim into
# the launcher below and re-executed in the detached child. Must not contain the
# double-quote, $, backtick or backslash characters: it is carried inside a
# shell double-quoted `-c "..."` argument (see _detached_launch).
_REBUILD_BODY_COMMIT = """\
import os, signal, sys
from pathlib import Path
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
if not changed:
sys.exit(0)
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
from graphify.watch import _rebuild_code, _apply_resource_limits
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_root = Path('.')
_out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
_saved = Path(_out) / '.graphify_root'
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, changed_paths=changed, force=_force)
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
# (best-effort; never fails the hook).
try:
_md = (_root / _out) / 'memory'
if _md.is_dir() and any(_md.glob('*.md')):
from graphify.reflect import reflect as _reflect
_gj = (_root / _out) / 'graph.json'
_reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
graph_path=_gj if _gj.exists() else None)
except Exception:
pass
except TimeoutError as exc:
print(f'[graphify hook] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
"""
_REBUILD_BODY_CHECKOUT = """\
from graphify.watch import _rebuild_code, _apply_resource_limits
from pathlib import Path
import os, signal, sys
try:
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
# post-checkout: branch switch can touch arbitrary files; full rebuild path
# (no changed_paths) is correct here. The flock inside _rebuild_code still
# prevents pile-ups when commit + checkout fire back-to-back.
_root = Path('.')
_out = os.environ.get('GRAPHIFY_OUT', 'graphify-out')
_saved = Path(_out) / '.graphify_root'
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, force=_force)
# Refresh the work-memory lessons doc when saved Q&A outcomes exist
# (best-effort; never fails the hook).
try:
_md = (_root / _out) / 'memory'
if _md.is_dir() and any(_md.glob('*.md')):
from graphify.reflect import reflect as _reflect
_gj = (_root / _out) / 'graph.json'
_reflect(memory_dir=_md, out_path=(_root / _out) / 'reflections' / 'LESSONS.md',
graph_path=_gj if _gj.exists() else None)
except Exception:
pass
except TimeoutError as exc:
print(f'[graphify] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
"""
# Cross-platform detached-launch shim (#1161). The hooks used to background the
# rebuild with `nohup "$GRAPHIFY_PYTHON" -c "..." &`, but Git for Windows' bundled
# MSYS shell ships no nohup (nor setsid), so that line died with
# 'nohup: command not found' and the rebuild silently never ran — git commit/pull
# still returned 0, so the graph just went stale with no signal. graphify already
# requires Python, so we let Python do the detaching: a tiny outer process spawns
# the real rebuild fully detached and returns immediately, so the hook never
# blocks. POSIX uses start_new_session (the setsid equivalent); Windows uses
# DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, breaking away from any job object
# when allowed. This payload is carried inside a shell double-quoted -c argument,
# so it deliberately uses only single-quoted Python strings (no ", $, ` or \\).
_LAUNCHER_TEMPLATE = """\
import os, subprocess, sys
_src = '''
__REBUILD_BODY__
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"""
def _detached_launch(rebuild_body: str) -> str:
"""Return a POSIX-sh line that runs ``rebuild_body`` as a detached background
Python process via ``$GRAPHIFY_PYTHON``.
Replaces the old ``nohup ... &`` form, which failed on Git for Windows'
shell (no nohup/setsid) and let the rebuild silently never run (#1161).
The launcher writes the child's output to ``$GRAPHIFY_REBUILD_LOG`` and
returns the instant the child is spawned, so the git hook never blocks.
"""
launcher = _LAUNCHER_TEMPLATE.replace("__REBUILD_BODY__", rebuild_body)
return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n'
_HOOK_SCRIPT = """\
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
fi
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
# git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
# hand (each git exec costs 1s+ on AV-scanned Windows machines).
GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
if [ -z "$CHANGED" ]; then
exit 0
fi
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
if [ -z "$_NON_GRAPH" ]; then
exit 0
fi
""" + _PYTHON_DETECT + """
export GRAPHIFY_CHANGED="$CHANGED"
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
# can take hours; blocking the post-commit hook stalls the shell. The Python
# launcher below detaches the child cross-platform, so it works on Git for
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
""" + _detached_launch(_REBUILD_BODY_COMMIT) + """# graphify-hook-end
"""
_CHECKOUT_SCRIPT = """\
# graphify-checkout-hook-start
# Auto-rebuilds the knowledge graph (code only) when switching branches.
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients
# and agent shells. Keep hook-triggered rebuilds sequential by default there;
# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism.
if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then
export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}"
fi
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_SWITCH=$3
# Only run on branch switches, not file checkouts
if [ "$BRANCH_SWITCH" != "1" ]; then
exit 0
fi
# Only run if graphify-out/ exists (graph has been built before)
if [ ! -d "graphify-out" ]; then
exit 0
fi
# Skip during rebase/merge/cherry-pick
# git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked by
# hand (each git exec costs 1s+ on AV-scanned Windows machines).
GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
""" + _PYTHON_DETECT + """
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
""" + _detached_launch(_REBUILD_BODY_CHECKOUT) + """# graphify-checkout-hook-end
"""
def _git_root(path: Path) -> Path | None:
"""Walk up to find .git directory."""
current = path.resolve()
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
return None
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _reject_windows_path(value: str, source: str) -> None:
"""Raise if a hooks path looks like a Windows absolute path (#1385).
On POSIX/WSL ``Path("C:\\Users\\...").is_absolute()`` is False, so an absolute
Windows hooks path gets joined under the repo root and mkdir'd as a literal
junk directory (backslashes and all), while install reports success and the
real ``.git/hooks`` gets nothing. Fail loudly instead so the user can fix it.
"""
if os.name == "nt":
return
if _WINDOWS_DRIVE_RE.match(value) or "\\" in value:
raise RuntimeError(
f"git hooks path from {source} looks like a Windows path: {value!r}. "
f"On WSL/POSIX this can't resolve to a real directory. Unset it with "
f"`git config --local --unset core.hooksPath`, or set a POSIX path."
)
def _hooks_dir(root: Path) -> Path:
"""Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky)."""
try:
cfg = configparser.RawConfigParser()
cfg.read(root / ".git" / "config", encoding="utf-8")
# configparser lowercases option names; git's hooksPath becomes hookspath
custom = cfg.get("core", "hookspath", fallback="").strip()
if custom:
_reject_windows_path(custom, "core.hooksPath")
p = Path(custom).expanduser()
if not p.is_absolute():
p = root / p
# Validate the resolved path stays within the repository root
# to prevent supply-chain attacks via malicious core.hooksPath values
try:
p.resolve().relative_to(root.resolve())
except ValueError:
pass # Path escapes repo root; fall through to default .git/hooks
else:
p.mkdir(parents=True, exist_ok=True)
return p
except (configparser.Error, OSError) as exc:
# Narrow the exception (PR747-NEW-2): a bare `except Exception: pass`
# was hiding tampering signals (corrupt .git/config, permission flips
# by another tool). Surface them on stderr instead of silently
# falling through to the default hooks directory.
print(
f"[graphify hooks] could not read core.hooksPath from "
f"{root / '.git' / 'config'}: {exc}",
file=sys.stderr,
)
# In a linked worktree .git is a file not a directory, so constructing
# root/.git/hooks directly fails. Ask git for the real hooks path instead.
# NOTE: do NOT pass --path-format=absolute — added in git 2.31; older git
# echoes it back as a literal argument, contaminating stdout and causing a
# phantom directory to be created (#907). git -C <root> already returns an
# absolute path for worktree/external-gitdir cases, and a path relative to
# <root> for normal repos — anchoring on root covers both.
import subprocess as _sp
try:
res = _sp.run(
["git", "-C", str(root), "rev-parse", "--git-path", "hooks"],
capture_output=True, text=True,
)
raw = res.stdout.strip()
# A valid hooks path can never contain newlines or NUL. Their presence
# means git echoed an unrecognised flag back (old git behaviour).
if res.returncode == 0 and raw and not any(c in raw for c in ("\n", "\r", "\x00")):
_reject_windows_path(raw, "git rev-parse --git-path hooks")
d = (root / raw).resolve()
d.mkdir(parents=True, exist_ok=True)
return d
except (OSError, FileNotFoundError):
pass
d = root / ".git" / "hooks"
d.mkdir(parents=True, exist_ok=True)
return d
def _install_hook(hooks_dir: Path, name: str, script: str, marker: str) -> str:
"""Install a single git hook, appending if an existing hook is present."""
hook_path = hooks_dir / name
if hook_path.exists():
content = hook_path.read_text(encoding="utf-8")
if marker in content:
return f"already installed at {hook_path}"
hook_path.write_text(content.rstrip() + "\n\n" + script, encoding="utf-8", newline="\n")
return f"appended to existing {name} hook at {hook_path}"
hook_path.write_text("#!/bin/sh\n" + script, encoding="utf-8", newline="\n")
hook_path.chmod(0o755)
return f"installed at {hook_path}"
def _uninstall_hook(hooks_dir: Path, name: str, marker: str, marker_end: str) -> str:
"""Remove graphify section from a git hook using start/end markers."""
hook_path = hooks_dir / name
if not hook_path.exists():
return f"no {name} hook found - nothing to remove."
content = hook_path.read_text(encoding="utf-8")
if marker not in content:
return f"graphify hook not found in {name} - nothing to remove."
new_content = re.sub(
rf"{re.escape(marker)}.*?{re.escape(marker_end)}\n?",
"",
content,
flags=re.DOTALL,
).strip()
if not new_content or new_content in ("#!/bin/bash", "#!/bin/sh"):
hook_path.unlink()
return f"removed {name} hook at {hook_path}"
hook_path.write_text(new_content + "\n", encoding="utf-8", newline="\n")
return f"graphify removed from {name} at {hook_path} (other hook content preserved)"
def _user_hooks_dir(hooks_dir: Path) -> Path:
"""Return the user-editable hooks directory.
Husky 9 sets core.hooksPath to .husky/_ (wrapper scripts auto-generated by
Husky), while user-editable hooks live in the parent .husky/. Return the
parent when the resolved dir ends in '_' so install/status/uninstall target
the correct location (#987).
"""
if hooks_dir.name == "_":
return hooks_dir.parent
return hooks_dir
def install(path: Path = Path(".")) -> str:
"""Install graphify post-commit and post-checkout hooks in the nearest git repo."""
root = _git_root(path)
if root is None:
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
hooks_dir = _user_hooks_dir(_hooks_dir(root))
# Pin the current interpreter so the hook works even when the graphify
# launcher is not on PATH at git-trigger time (uv tool / pipx isolation).
# sys.executable is the Python running this very install command, so it is
# always the correct isolated-venv interpreter. The placeholder is replaced
# in both scripts before writing; the allowlist in _PYTHON_DETECT strips any
# characters unsafe in a shell path, and import-verification catches a stale
# pinned path so it safely falls through to the dynamic detection.
# Apply the same allowlist used in _PYTHON_DETECT for all other probes.
# This rejects any character that is not a valid plain filesystem path
# character, preventing $(...), backtick, double-quote, semicolon, etc.
# from being injected into the generated shell scripts. The allowlist
# includes ':' and '\' so Windows paths (C:\...) are accepted.
import re as _re
_safe = sys.executable
if _re.search(r"[^a-zA-Z0-9/_.@:\\-]", _safe):
# Path contains characters outside the allowlist (spaces, quotes, etc.).
# Embed an empty string so the pinned probe is skipped and the hook
# falls through to the dynamic detection — safe degradation.
_safe = ""
pinned = _safe
hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned)
checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned)
commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER)
checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER)
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
def uninstall(path: Path = Path(".")) -> str:
"""Remove graphify post-commit and post-checkout hooks."""
root = _git_root(path)
if root is None:
raise RuntimeError(f"No git repository found at or above {path.resolve()}")
hooks_dir = _user_hooks_dir(_hooks_dir(root))
commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END)
checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END)
return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}"
def status(path: Path = Path(".")) -> str:
"""Check if graphify hooks are installed."""
root = _git_root(path)
if root is None:
return "Not in a git repository."
hooks_dir = _user_hooks_dir(_hooks_dir(root))
def _check(name: str, marker: str) -> str:
p = hooks_dir / name
if not p.exists():
return "not installed"
return "installed" if marker in p.read_text(encoding="utf-8") else "not installed (hook exists but graphify not found)"
commit = _check("post-commit", _HOOK_MARKER)
checkout = _check("post-checkout", _CHECKOUT_MARKER)
return f"post-commit: {commit}\npost-checkout: {checkout}"
+50
View File
@@ -0,0 +1,50 @@
"""Single source of truth for node-ID normalization.
Three independent producers must agree on node IDs or the graph splits a single
entity into disconnected ghost nodes:
1. The AST extractor (``extract._make_id``) — deterministic, per-language.
2. The semantic subagents (LLM) — follow the node-ID spec in the skill prompt.
3. The graph builder (``build._normalize_id``) — reconciles edge endpoints when
the LLM emits IDs with slightly different punctuation or casing than the AST.
Historically the normalization recipe was copy-pasted into ``extract._make_id``
and ``build._normalize_id`` and kept in sync only by mirrored docstrings, which
is exactly how the recurring ID-drift bug class crept in (#811 Unicode collapse,
#550 same-filename collisions, #1033 AST-vs-LLM file-node mismatch, #1104). This
module exists so the recipe lives in one place and the two callers can no longer
diverge.
The recipe: NFKC-normalize (so composed/decomposed Unicode forms collapse),
replace runs of non-word characters with a single underscore (``re.UNICODE`` so
CJK/Cyrillic/Arabic/accented-Latin letters survive instead of collapsing to a
per-file node), collapse repeated underscores, strip leading/trailing
underscores, and casefold.
"""
from __future__ import annotations
import re
import unicodedata
__all__ = ["normalize_id", "make_id"]
def normalize_id(s: str) -> str:
r"""Normalize a single ID string to its canonical form.
Idempotent: ``normalize_id(normalize_id(s)) == normalize_id(s)``.
"""
s = unicodedata.normalize("NFKC", s)
s = re.sub(r"[^\w]+", "_", s, flags=re.UNICODE)
s = re.sub(r"_+", "_", s)
return s.strip("_").casefold()
def make_id(*parts: str) -> str:
"""Build a canonical node ID from one or more name parts.
Parts are joined with ``_`` (after stripping stray ``_``/``.`` edges from each
part) and then run through :func:`normalize_id`, so the result is identical to
what the builder produces from the joined string.
"""
return normalize_id("_".join(p.strip("_.") for p in parts if p))
+353
View File
@@ -0,0 +1,353 @@
# fetch URLs (tweet/arxiv/pdf/web) and save as annotated markdown
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from graphify.security import safe_fetch, safe_fetch_text, validate_url
def _yaml_str(s: str) -> str:
"""Escape a string for embedding in a YAML double-quoted scalar.
Handles every YAML 1.1/1.2 line-break and control character that could
let a hostile value (e.g. a fetched page title) break out of the quoted
scalar and inject sibling YAML keys (F-009 / F-019). The previous
implementation missed `\\t`, `\\0`, the unicode line-separator U+2028 and
paragraph-separator U+2029 — all of which YAML treats as line breaks.
We intentionally do not depend on PyYAML (not in pyproject deps) and
instead emit safely-escaped double-quoted scalars by hand: the YAML
double-quoted form recognises `\\\\`, `\\"`, `\\n`, `\\r`, `\\t`, `\\0`,
`\\L` (U+2028), `\\P` (U+2029), and `\\xNN`/`\\uNNNN` numeric escapes.
"""
if s is None:
return ""
out: list[str] = []
for ch in str(s):
cp = ord(ch)
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif ch == "\0":
out.append("\\0")
elif cp == 0x2028:
out.append("\\L")
elif cp == 0x2029:
out.append("\\P")
elif cp < 0x20 or cp == 0x7F:
out.append(f"\\x{cp:02x}")
else:
out.append(ch)
return "".join(out)
def _safe_filename(url: str, suffix: str) -> str:
"""Turn a URL into a safe filename."""
parsed = urllib.parse.urlparse(url)
name = parsed.netloc + parsed.path
name = re.sub(r"[^\w\-]", "_", name).strip("_")
name = re.sub(r"_+", "_", name)[:80]
return name + suffix
def _detect_url_type(url: str) -> str:
"""Classify the URL for targeted extraction."""
lower = url.lower()
if "twitter.com" in lower or "x.com" in lower:
return "tweet"
if "arxiv.org" in lower:
return "arxiv"
if "github.com" in lower:
return "github"
if "youtube.com" in lower or "youtu.be" in lower:
return "youtube"
parsed = urllib.parse.urlparse(url)
path = parsed.path.lower()
if path.endswith(".pdf"):
return "pdf"
if any(path.endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".webp", ".gif")):
return "image"
return "webpage"
def _fetch_html(url: str) -> str:
return safe_fetch_text(url)
def _html_to_markdown(html: str, url: str) -> str:
"""Convert HTML to clean markdown. Uses markdownify if available, else basic strip."""
# Always pre-strip script/style so their text content never leaks into output
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.DOTALL | re.IGNORECASE)
try:
from markdownify import markdownify
return markdownify(html, heading_style="ATX", bullets="-", strip=["img"])
except ImportError:
# Fallback: basic tag strip
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).strip()
return text[:8000]
def _fetch_tweet(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch a tweet URL. Returns (content, filename)."""
# Normalize to twitter.com for oEmbed
oembed_url = url.replace("x.com", "twitter.com")
oembed_api = f"https://publish.twitter.com/oembed?url={urllib.parse.quote(oembed_url)}&omit_script=true"
try:
data = json.loads(safe_fetch_text(oembed_api))
tweet_text = re.sub(r"<[^>]+>", "", data.get("html", "")).strip()
tweet_author = data.get("author_name", "unknown")
except Exception:
# oEmbed failed - save URL stub
tweet_text = f"Tweet at {url} (could not fetch content)"
tweet_author = "unknown"
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: "{_yaml_str(url)}"
type: tweet
author: "{_yaml_str(tweet_author)}"
captured_at: {now}
contributor: "{_yaml_str(contributor or author or 'unknown')}"
---
# Tweet by @{tweet_author}
{tweet_text}
Source: {url}
"""
filename = _safe_filename(url, ".md")
return content, filename
def _fetch_webpage(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch a generic webpage and convert to markdown."""
html = _fetch_html(url)
# Extract title
title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else url
markdown = _html_to_markdown(html, url)
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: "{_yaml_str(url)}"
type: webpage
title: "{_yaml_str(title)}"
captured_at: {now}
contributor: "{_yaml_str(contributor or author or 'unknown')}"
---
# {title}
Source: {url}
---
{markdown[:12000]}
"""
filename = _safe_filename(url, ".md")
return content, filename
def _fetch_arxiv(url: str, author: str | None, contributor: str | None) -> tuple[str, str]:
"""Fetch arXiv abstract page."""
# Convert /abs/ or /pdf/ to abs for the API
arxiv_id = re.search(r"(\d{4}\.\d{4,5})", url)
if arxiv_id:
api_url = f"https://export.arxiv.org/abs/{arxiv_id.group(1)}"
try:
html = _fetch_html(api_url)
abstract_match = re.search(r'class="abstract[^"]*"[^>]*>(.*?)</blockquote>', html, re.DOTALL | re.IGNORECASE)
abstract = re.sub(r"<[^>]+>", "", abstract_match.group(1)).strip() if abstract_match else ""
title_match = re.search(r'class="title[^"]*"[^>]*>(.*?)</h1>', html, re.DOTALL | re.IGNORECASE)
title = re.sub(r"<[^>]+>", " ", title_match.group(1)).strip() if title_match else arxiv_id.group(1)
authors_match = re.search(r'class="authors"[^>]*>(.*?)</div>', html, re.DOTALL | re.IGNORECASE)
paper_authors = re.sub(r"<[^>]+>", "", authors_match.group(1)).strip() if authors_match else ""
except Exception:
title, abstract, paper_authors = arxiv_id.group(1), "", ""
else:
return _fetch_webpage(url, author, contributor)
now = datetime.now(timezone.utc).isoformat()
content = f"""---
source_url: "{_yaml_str(url)}"
arxiv_id: "{_yaml_str(arxiv_id.group(1) if arxiv_id else '')}"
type: paper
title: "{_yaml_str(title)}"
paper_authors: "{_yaml_str(paper_authors)}"
captured_at: {now}
contributor: "{_yaml_str(contributor or author or 'unknown')}"
---
# {title}
**Authors:** {paper_authors}
**arXiv:** {arxiv_id.group(1) if arxiv_id else url}
## Abstract
{abstract}
Source: {url}
"""
filename = f"arxiv_{arxiv_id.group(1).replace('.', '_')}.md" if arxiv_id else _safe_filename(url, ".md")
return content, filename
def _download_binary(url: str, suffix: str, target_dir: Path) -> Path:
"""Download a binary file (PDF, image) directly."""
filename = _safe_filename(url, suffix)
out_path = target_dir / filename
out_path.write_bytes(safe_fetch(url))
return out_path
def ingest(url: str, target_dir: Path, author: str | None = None, contributor: str | None = None) -> Path:
"""
Fetch a URL and save it into target_dir as a graphify-ready file.
Returns the path of the saved file.
"""
target_dir.mkdir(parents=True, exist_ok=True)
url_type = _detect_url_type(url)
try:
validate_url(url)
except ValueError as exc:
raise ValueError(f"ingest: {exc}") from exc
try:
if url_type == "pdf":
out = _download_binary(url, ".pdf", target_dir)
print(f"Downloaded PDF: {out.name}")
return out
if url_type == "image":
suffix = Path(urllib.parse.urlparse(url).path).suffix or ".jpg"
out = _download_binary(url, suffix, target_dir)
print(f"Downloaded image: {out.name}")
return out
if url_type == "youtube":
from graphify.transcribe import download_audio
out = download_audio(url, target_dir)
print(f"Downloaded audio: {out.name}")
return out
if url_type == "tweet":
content, filename = _fetch_tweet(url, author, contributor)
elif url_type == "arxiv":
content, filename = _fetch_arxiv(url, author, contributor)
else:
content, filename = _fetch_webpage(url, author, contributor)
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
raise RuntimeError(f"ingest: failed to fetch {url!r}: {exc}") from exc
out_path = target_dir / filename
# Avoid overwriting - append counter if needed
counter = 1
while out_path.exists() and counter < 1000:
stem = Path(filename).stem
out_path = target_dir / f"{stem}_{counter}.md"
counter += 1
out_path.write_text(content, encoding="utf-8")
print(f"Saved {url_type}: {out_path.name}")
return out_path
OUTCOMES = ("useful", "dead_end", "corrected")
def save_query_result(
question: str,
answer: str,
memory_dir: Path,
query_type: str = "query",
source_nodes: list[str] | None = None,
outcome: str | None = None,
correction: str | None = None,
) -> Path:
"""Save a Q&A result as markdown so it gets extracted into the graph on next --update.
Files are stored in memory_dir (typically graphify-out/memory/) with YAML frontmatter
that graphify's extractor reads as node metadata. This closes the feedback loop:
the system grows smarter from both what you add AND what you ask.
``outcome`` (one of :data:`OUTCOMES`) and ``correction`` are optional work-memory
signals: they are written both to the frontmatter (so `graphify reflect` can
aggregate them deterministically) and to an ``## Outcome`` body section (so the
signal round-trips into the graph on the next semantic re-extraction).
"""
if outcome is not None and outcome not in OUTCOMES:
raise ValueError(f"outcome must be one of {OUTCOMES}, got {outcome!r}")
memory_dir = Path(memory_dir)
memory_dir.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc)
slug = re.sub(r"[^\w]", "_", question.lower())[:50].strip("_")
filename = f"query_{now.strftime('%Y%m%d_%H%M%S')}_{slug}.md"
frontmatter_lines = [
"---",
f'type: "{query_type}"',
f'date: "{now.isoformat()}"',
f'question: "{_yaml_str(question)}"',
'contributor: "graphify"',
]
if outcome:
frontmatter_lines.append(f'outcome: "{_yaml_str(outcome)}"')
if correction:
frontmatter_lines.append(f'correction: "{_yaml_str(correction)}"')
if source_nodes:
nodes_str = ", ".join(f'"{_yaml_str(n)}"' for n in source_nodes[:10])
frontmatter_lines.append(f"source_nodes: [{nodes_str}]")
frontmatter_lines.append("---")
body_lines = [
"",
f"# Q: {question}",
"",
"## Answer",
"",
answer,
]
if outcome or correction:
body_lines += ["", "## Outcome", ""]
if outcome:
body_lines.append(f"- Signal: {outcome}")
if correction:
body_lines.append(f"- Correction: {correction}")
if source_nodes:
body_lines += ["", "## Source Nodes", ""]
body_lines += [f"- {n}" for n in source_nodes]
content = "\n".join(frontmatter_lines + body_lines)
out_path = memory_dir / filename
out_path.write_text(content, encoding="utf-8")
return out_path
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Fetch a URL into a graphify /raw folder")
parser.add_argument("url", help="URL to fetch")
parser.add_argument("target_dir", nargs="?", default="./raw", help="Target directory (default: ./raw)")
parser.add_argument("--author", help="Your name (stored as node metadata)")
parser.add_argument("--contributor", help="Contributor name for team graphs")
args = parser.parse_args()
out = ingest(args.url, Path(args.target_dir), author=args.author, contributor=args.contributor)
print(f"Ready for graphify: {out}")
+2148
View File
File diff suppressed because it is too large Load Diff
+2593
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# re-export manifest helpers from detect for backwards compatibility
from graphify.detect import save_manifest, load_manifest, detect_incremental
__all__ = ["save_manifest", "load_manifest", "detect_incremental"]
+247
View File
@@ -0,0 +1,247 @@
"""Deterministic package-manifest ingestion (#1377).
Package manifests (``apm.yml``, ``pyproject.toml``, ``go.mod``, ``pom.xml``)
declare a package and its dependencies. Left to the LLM document path, the same
package gets a different file-anchored node id from its own manifest than from
each dependent's dependency reference, so it splits into duplicate nodes. This
module parses manifests deterministically and emits ONE canonical package node
per package -- keyed by NAME via :func:`graphify.ids.make_id` -- plus
``depends_on`` edges, so a package referenced from N manifests collapses to a
single hub node (the dependency stub and the package's own definition node share
the canonical id and merge at build time).
Mirrors ``mcp_ingest``: recognized by filename, routed to the deterministic AST
path (never the LLM), so a manifest is extracted exactly once.
"""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from graphify.ids import make_id
__all__ = ["is_package_manifest_path", "extract_package_manifest", "PACKAGE_MANIFEST_NAMES"]
# manifest filename (lowercased) -> ecosystem tag
PACKAGE_MANIFEST_NAMES: dict[str, str] = {
"apm.yml": "apm",
"apm.yaml": "apm",
"pyproject.toml": "python",
"go.mod": "go",
"pom.xml": "maven",
}
_MAX_MANIFEST_BYTES = 2_000_000 # 2 MB cap — manifests are small; this rejects junk
def is_package_manifest_path(path: Path) -> bool:
"""True if ``path`` is a recognized package manifest (by filename)."""
return path.name.lower() in PACKAGE_MANIFEST_NAMES
def _pkg_id(name: str) -> str:
"""Canonical package node id, keyed by package NAME so every reference to the
same package -- its own manifest and any dependent's dependency line -- maps
to one node."""
return make_id("pkg", name)
def extract_package_manifest(path: Path) -> dict[str, Any]:
"""Parse a package manifest into a canonical package node + ``depends_on`` edges."""
try:
if path.stat().st_size > _MAX_MANIFEST_BYTES:
return {"nodes": [], "edges": [], "error": "manifest too large to index"}
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"nodes": [], "edges": [], "error": f"manifest read error: {exc}"}
eco = PACKAGE_MANIFEST_NAMES[path.name.lower()]
try:
info = _PARSERS[eco](text)
except Exception as exc: # noqa: BLE001 — a malformed manifest must not abort extraction
return {"nodes": [], "edges": [], "error": f"manifest parse error: {exc}"}
if not info or not info.get("name"):
return {"nodes": [], "edges": []}
name = info["name"]
str_path = str(path)
pkg_nid = _pkg_id(name)
node: dict[str, Any] = {
"id": pkg_nid,
"label": name,
"file_type": "code", # valid schema type; `type` distinguishes packages
"type": "package",
"ecosystem": eco,
"source_file": str_path,
"source_location": "L1",
}
if info.get("version"):
node["version"] = info["version"]
nodes: list[dict] = [node]
edges: list[dict] = []
seen: set[str] = set()
for dep in info.get("deps", []):
if not dep:
continue
dep_nid = _pkg_id(dep)
if dep_nid == pkg_nid or dep_nid in seen:
continue
seen.add(dep_nid)
# The edge targets the dependency's canonical package id. If that package's
# own manifest is in the corpus, the edge resolves to its (single) node; if
# the dependency is external, build_from_json prunes the dangling edge. We
# deliberately do NOT emit a stub node — a stub with an empty source_file
# would risk clobbering the real node's source_file under id-dedup.
edges.append({
"source": pkg_nid,
"target": dep_nid,
"relation": "depends_on",
"context": "dependency",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": str_path,
"source_location": "L1",
"weight": 1.0,
})
return {"nodes": nodes, "edges": edges}
# ── per-ecosystem parsers: text -> {"name", "version"?, "deps": [str]} | None ──
def _coerce_deps(value: Any) -> list[str]:
"""A dependency block may be a list of names or a name->spec map."""
if isinstance(value, dict):
return [str(k) for k in value]
if isinstance(value, list):
out: list[str] = []
for item in value:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict) and item:
out.append(str(next(iter(item))))
return out
return []
def _parse_apm(text: str) -> dict | None:
try:
import yaml
except ImportError:
return _parse_apm_fallback(text)
data = yaml.safe_load(text)
if not isinstance(data, dict):
return None
return {
"name": data.get("name"),
"version": data.get("version"),
"deps": _coerce_deps(data.get("dependencies")),
}
def _parse_apm_fallback(text: str) -> dict | None:
"""Minimal line parser for apm.yml when PyYAML is unavailable: a top-level
``name:`` plus a simple ``dependencies:`` block (list items or a name map)."""
name = None
deps: list[str] = []
in_deps = False
for line in text.splitlines():
if not in_deps:
m = re.match(r'^name:\s*["\']?([^"\'\s#]+)', line)
if m:
name = m.group(1)
continue
if re.match(r'^dependencies:\s*$', line):
in_deps = True
continue
if in_deps:
dm = (re.match(r'^\s*-\s*["\']?([^"\'\s#:]+)', line)
or re.match(r'^\s{2,}([A-Za-z0-9._/@-]+)\s*:', line))
if dm:
deps.append(dm.group(1))
elif re.match(r'^\S', line): # next top-level key ends the block
in_deps = False
return {"name": name, "version": None, "deps": deps} if name else None
def _pep508_name(spec: str) -> str:
"""`requests>=2.0` -> `requests`; `pkg[extra]==1; python_version<'3.9'` -> `pkg`."""
return re.split(r'[\s<>=!~;\[\(]', spec.strip(), maxsplit=1)[0]
def _parse_pyproject(text: str) -> dict | None:
try:
import tomllib as _toml
except ImportError:
try:
import tomli as _toml # type: ignore
except ImportError:
return None
data = _toml.loads(text)
proj = data.get("project", {}) if isinstance(data.get("project"), dict) else {}
poetry = (data.get("tool", {}) or {}).get("poetry", {}) if isinstance(data.get("tool"), dict) else {}
name = proj.get("name") or (poetry.get("name") if isinstance(poetry, dict) else None)
if not name:
return None
deps: list[str] = [_pep508_name(s) for s in (proj.get("dependencies") or []) if isinstance(s, str)]
if isinstance(poetry, dict):
for dep in (poetry.get("dependencies") or {}):
if str(dep).lower() != "python":
deps.append(str(dep))
return {"name": name, "version": proj.get("version") or (poetry.get("version") if isinstance(poetry, dict) else None), "deps": deps}
def _parse_gomod(text: str) -> dict | None:
name = None
deps: list[str] = []
in_block = False
for line in text.splitlines():
s = line.strip()
if name is None:
m = re.match(r'^module\s+(\S+)', s)
if m:
name = m.group(1)
continue
if re.match(r'^require\s*\(', s):
in_block = True
continue
if in_block:
if s.startswith(')'):
in_block = False
continue
dm = re.match(r'^(\S+)\s+v\S+', s)
if dm:
deps.append(dm.group(1))
else:
dm = re.match(r'^require\s+(\S+)\s+v\S+', s)
if dm:
deps.append(dm.group(1))
return {"name": name, "version": None, "deps": deps} if name else None
def _parse_pom(text: str) -> dict | None:
# Drop the default namespace so findtext/findall don't need the {uri} prefix.
text = re.sub(r'\sxmlns="[^"]*"', '', text, count=1)
root = ET.fromstring(text)
aid = root.findtext("artifactId")
gid = root.findtext("groupId")
if not aid:
return None
name = f"{gid}:{aid}" if gid else aid
deps: list[str] = []
for dep in root.findall(".//dependencies/dependency"):
da = dep.findtext("artifactId")
dg = dep.findtext("groupId")
if da:
deps.append(f"{dg}:{da}" if dg else da)
return {"name": name, "version": root.findtext("version"), "deps": deps}
_PARSERS = {
"apm": _parse_apm,
"python": _parse_pyproject,
"go": _parse_gomod,
"maven": _parse_pom,
}
+386
View File
@@ -0,0 +1,386 @@
"""mcp_ingest.py — Extract MCP (Model Context Protocol) server configuration files.
Reads `.mcp.json` / `claude_desktop_config.json` / `mcp.json` / `mcp_servers.json`
and turns the `mcpServers` map into Graphify nodes and edges.
Symmetry with `serve.py`: Graphify exposes itself AS an MCP server. This module
indexes MCP servers AS a corpus type, completing the loop an agent that runs
graphify with `--mcp` can now query its own configured MCP layer.
Entry point:
extract_mcp_config(path: Path) -> dict[str, list[dict]]
Returns `{"nodes": [...], "edges": [...]}` compatible with Graphify's
extraction-result format. Returns `{"nodes": [...], "edges": [...], "error": "..."}`
when the file is malformed, too large, or has no `mcpServers` map the empty
result keeps it indistinguishable from "no MCP config here" for downstream
callers.
Detected filenames (case-sensitive, matched on basename):
- .mcp.json (Claude Code project config)
- claude_desktop_config.json (Claude Desktop)
- mcp.json (generic / per-tool)
- mcp_servers.json (alternate naming)
Schema emitted:
Node kinds:
- file the config file itself (label = filename)
- mcp_server one per entry under mcpServers
- mcp_command executable (npx, uvx, node, python, ...) global ID
- mcp_package npm / pypi package id parsed from args global ID
- env_var env variable NAME only global ID. VALUES ARE NEVER READ.
Edge relations:
- contains file -> mcp_server
- references mcp_server -> mcp_command
- references mcp_server -> mcp_package
- requires_env mcp_server -> env_var (new relation; distinguishes
env dependencies from generic refs)
Security:
- Env var VALUES are never read, persisted, labelled, or surfaced. Only env
var NAMES become nodes. (`env: {"API_KEY": "sk-..."}` -> node "API_KEY" only.)
- File size capped at 1 MiB (matches extract_json).
- All labels go through `sanitize_label` (control characters stripped, length
capped) before emission.
- Args are NOT persisted as nodes/edges to avoid leaking paths or secrets that
some servers embed as positional args.
Cross-config emergent edges:
Because `mcp_command`, `mcp_package`, and `env_var` nodes use global IDs (no
per-file stem prefix), the same package or env var across two MCP configs
produces shared nodes naturally surfacing "what configs depend on this
thing?" via graph traversal. Server nodes ARE stem-scoped so two configs
declaring different servers under the same key (e.g., both have "filesystem")
do not collide.
"""
from __future__ import annotations
import json
import re
import unicodedata
from pathlib import Path
from typing import Any
from graphify.ids import make_id as _shared_make_id
from graphify.security import sanitize_label
MCP_CONFIG_FILENAMES: frozenset[str] = frozenset({
".mcp.json",
"claude_desktop_config.json",
"mcp.json",
"mcp_servers.json",
})
_MAX_BYTES = 1_048_576 # 1 MiB — same cap as extract_json
_MAX_SERVERS_PER_FILE = 200 # generous; flags pathological configs
def is_mcp_config_path(path: Path) -> bool:
"""Return True when ``path`` is a recognised MCP config filename."""
return path.name in MCP_CONFIG_FILENAMES
def extract_mcp_config(path: Path) -> dict[str, Any]:
"""Parse an MCP config file into Graphify nodes and edges.
Behaviour matches other extractors in `extract.py`:
- returns ``{"nodes": [...], "edges": [...]}`` on success
- returns ``{"nodes": [], "edges": [], "error": "<reason>"}`` on parse
failure, oversize file, or missing ``mcpServers`` map
"""
try:
with path.open("rb") as fh:
raw = fh.read(_MAX_BYTES + 1)
except OSError as exc:
return {"nodes": [], "edges": [], "error": f"mcp_ingest read error: {exc}"}
if len(raw) > _MAX_BYTES:
return {"nodes": [], "edges": [], "error": "mcp config too large to index"}
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return {"nodes": [], "edges": [], "error": f"mcp_ingest decode error: {exc}"}
try:
doc = json.loads(text)
except json.JSONDecodeError as exc:
return {"nodes": [], "edges": [], "error": f"mcp_ingest json error: {exc}"}
if not isinstance(doc, dict):
return {"nodes": [], "edges": [], "error": "mcp_ingest: root is not an object"}
servers = doc.get("mcpServers")
if not isinstance(servers, dict):
# Some tools nest the map (e.g., {"mcp": {"servers": {...}}}). Try one
# well-known alternate shape but do not search exhaustively.
nested = doc.get("mcp")
if isinstance(nested, dict):
servers = nested.get("servers")
if not isinstance(servers, dict):
return {"nodes": [], "edges": [], "error": "mcp_ingest: no mcpServers map"}
str_path = str(path)
file_nid = _make_id(str_path)
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
seen_node_ids: set[str] = set()
seen_edge_keys: set[tuple[str, str, str]] = set()
_add_node(
nodes, seen_node_ids,
nid=file_nid,
label=path.name,
kind="mcp_config_file",
source_file=str_path,
line=1,
)
file_stem = _file_stem(path)
server_count = 0
for server_name, spec in servers.items():
if not isinstance(server_name, str) or not server_name:
continue
if not isinstance(spec, dict):
# Skip non-object server entries silently — the broken entry is
# the user's, not ours.
continue
if server_count >= _MAX_SERVERS_PER_FILE:
break
server_count += 1
_emit_server(
server_name=server_name,
spec=spec,
file_nid=file_nid,
file_stem=file_stem,
source_file=str_path,
nodes=nodes,
edges=edges,
seen_node_ids=seen_node_ids,
seen_edge_keys=seen_edge_keys,
)
return {"nodes": nodes, "edges": edges}
def _emit_server(
*,
server_name: str,
spec: dict[str, Any],
file_nid: str,
file_stem: str,
source_file: str,
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
seen_node_ids: set[str],
seen_edge_keys: set[tuple[str, str, str]],
) -> None:
"""Emit nodes/edges for one entry under ``mcpServers``."""
server_nid = _make_id(file_stem, "mcp_server", server_name)
_add_node(
nodes, seen_node_ids,
nid=server_nid,
label=server_name,
kind="mcp_server",
source_file=source_file,
line=1, # JSON doesn't expose line numbers without a parser pass
)
_add_edge(
edges, seen_edge_keys,
source=file_nid,
target=server_nid,
relation="contains",
source_file=source_file,
line=1,
)
command = spec.get("command")
if isinstance(command, str) and command.strip():
cmd_label = command.strip()
cmd_nid = _make_id("mcp_command", cmd_label)
_add_node(
nodes, seen_node_ids,
nid=cmd_nid,
label=cmd_label,
kind="mcp_command",
source_file=source_file,
line=1,
)
_add_edge(
edges, seen_edge_keys,
source=server_nid,
target=cmd_nid,
relation="references",
source_file=source_file,
line=1,
context="command",
)
args = spec.get("args")
if isinstance(args, list):
package = _detect_package_from_args(args)
if package:
pkg_nid = _make_id("mcp_package", package)
_add_node(
nodes, seen_node_ids,
nid=pkg_nid,
label=package,
kind="mcp_package",
source_file=source_file,
line=1,
)
_add_edge(
edges, seen_edge_keys,
source=server_nid,
target=pkg_nid,
relation="references",
source_file=source_file,
line=1,
context="package",
)
env = spec.get("env")
if isinstance(env, dict):
# ONLY KEYS. Values may contain secrets and are never read here.
for env_name in env.keys():
if not isinstance(env_name, str) or not env_name:
continue
env_nid = _make_id("env_var", env_name)
_add_node(
nodes, seen_node_ids,
nid=env_nid,
label=env_name,
kind="env_var",
source_file=source_file,
line=1,
)
_add_edge(
edges, seen_edge_keys,
source=server_nid,
target=env_nid,
relation="requires_env",
source_file=source_file,
line=1,
)
# ── Package detection from args ───────────────────────────────────────────────
# Patterns observed in real MCP server configs:
# ["-y", "@modelcontextprotocol/server-filesystem", "/data"] (npx)
# ["-y", "@org/pkg@1.2.3"]
# ["mcp-server-fetch"] (uvx / python)
# ["mcp-server-time", "--local-timezone=UTC"]
# ["@scoped/some-mcp"] (pnpx)
# ["mcp-server-fetch"] (uvx direct)
_NPM_PKG_RE = re.compile(r"^@[a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*(?:@[\w.\-+]+)?$")
_PY_MCP_PKG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*-mcp(?:-[a-z0-9._-]+)?$|^mcp-[a-z0-9][a-z0-9._-]*$")
_ARG_FLAG_RE = re.compile(r"^-{1,2}\w")
def _detect_package_from_args(args: list[Any]) -> str | None:
"""Return the first arg that looks like an npm or pypi package id, else None.
Skips short flags (-y, --yes) and option arguments (--local-timezone=UTC).
"""
for raw in args:
if not isinstance(raw, str):
continue
arg = raw.strip()
if not arg or _ARG_FLAG_RE.match(arg):
continue
if _NPM_PKG_RE.match(arg):
return _strip_version(arg)
if _PY_MCP_PKG_RE.match(arg):
return arg
return None
def _strip_version(pkg: str) -> str:
"""Drop the ``@version`` suffix from an npm package id, preserving the scope.
Scoped: ``@scope/name`` or ``@scope/name@1.2.3`` there are at most two
``@`` chars; the second is the version separator.
Unscoped: ``name`` or ``name@1.2.3``.
"""
if pkg.startswith("@"):
version_at = pkg.find("@", 1)
return pkg if version_at == -1 else pkg[:version_at]
version_at = pkg.find("@")
return pkg if version_at == -1 else pkg[:version_at]
# ── Node / edge construction (Graphify schema) ────────────────────────────────
def _add_node(
nodes: list[dict[str, Any]],
seen: set[str],
*,
nid: str,
label: str,
kind: str,
source_file: str,
line: int,
) -> None:
"""Append a node if not already present. ``kind`` is metadata, not file_type."""
if not nid or nid in seen:
return
seen.add(nid)
nodes.append({
"id": nid,
"label": sanitize_label(label),
"file_type": "code",
"source_file": source_file,
"source_location": f"L{line}",
"metadata": {"mcp_kind": kind},
})
def _add_edge(
edges: list[dict[str, Any]],
seen: set[tuple[str, str, str]],
*,
source: str,
target: str,
relation: str,
source_file: str,
line: int,
context: str | None = None,
) -> None:
"""Append an edge if (source, target, relation) is not already present."""
if not source or not target or source == target:
return
key = (source, target, relation)
if key in seen:
return
seen.add(key)
edge: dict[str, Any] = {
"source": source,
"target": target,
"relation": relation,
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": source_file,
"source_location": f"L{line}",
"weight": 1.0,
}
if context:
edge["context"] = context
edges.append(edge)
# ── ID helpers (kept local; mirror extract.py shape) ──────────────────────────
def _make_id(*parts: str) -> str:
"""Build a stable node ID via the single shared recipe (#1378)."""
return _shared_make_id(*parts)
# Canonical recipe imported directly (no import cycle: extractors.base imports
# only graphify.ids), so this can no longer drift from extract._file_stem.
from graphify.extractors.base import _file_stem # noqa: E402
+212
View File
@@ -0,0 +1,212 @@
"""Runtime compatibility probe for Graphify MultiDiGraph mode.
Verifies that the current NetworkX runtime supports the behaviors a future
opt-in --multigraph build will rely on. The probe is BEHAVIOR-based, not
version-based both NX 3.4.2 (Py 3.10 lane) and NX 3.6.1+ (Py 3.11+ lane)
pass. The probe result is cached for the process lifetime via lru_cache.
No call sites added yet; downstream multigraph PRs will gate on
require_multigraph_capabilities() before enabling MDG mode.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import lru_cache
import sys
from typing import Any
import networkx as nx
from networkx.readwrite import json_graph
@dataclass(frozen=True)
class CapabilityCheck:
name: str
ok: bool
detail: str
@dataclass(frozen=True)
class MultigraphCapabilityResult:
python_version: str
networkx_version: str
checks: tuple[CapabilityCheck, ...]
@property
def ok(self) -> bool:
return all(check.ok for check in self.checks)
@property
def failed(self) -> tuple[CapabilityCheck, ...]:
return tuple(check for check in self.checks if not check.ok)
def error_message(self) -> str:
if self.ok:
return (
"Graphify MultiDiGraph capability probe passed "
f"(Python {self.python_version}, NetworkX {self.networkx_version})."
)
failed = "; ".join(f"{check.name}: {check.detail}" for check in self.failed)
return (
"error: --multigraph requires NetworkX keyed MultiDiGraph node-link "
"round-trip support. "
f"Detected Python {self.python_version}, NetworkX {self.networkx_version}. "
f"Failed capability check(s): {failed}. "
"Default simple graph mode remains available."
)
def _check(name: str, func: Callable[[], bool | str]) -> CapabilityCheck:
try:
detail = func()
except Exception as exc:
return CapabilityCheck(name, False, f"{type(exc).__name__}: {exc}")
if detail is True:
return CapabilityCheck(name, True, "ok")
if isinstance(detail, str):
return CapabilityCheck(name, False, detail)
return CapabilityCheck(name, False, f"unexpected result {detail!r}")
def _build_probe_graph() -> nx.MultiDiGraph:
graph = nx.MultiDiGraph()
graph.add_node("a", label="A")
graph.add_node("b", label="B")
graph.add_edge("a", "b", key="calls:a.py:L1", relation="calls", source_file="a.py")
graph.add_edge("a", "b", key="imports:a.py:L2", relation="imports", source_file="a.py")
return graph
def _probe_keyed_parallel_edges() -> bool | str:
graph = _build_probe_graph()
if not graph.is_multigraph() or not graph.is_directed():
return f"probe graph type was {type(graph).__name__}"
if graph.number_of_edges("a", "b") != 2:
return f"expected 2 keyed parallel edges, got {graph.number_of_edges('a', 'b')}"
keys = set(graph["a"]["b"].keys())
expected = {"calls:a.py:L1", "imports:a.py:L2"}
if keys != expected:
return f"expected keys {sorted(expected)}, got {sorted(keys)}"
return True
def _probe_node_link_round_trip() -> bool | str:
graph = _build_probe_graph()
data = json_graph.node_link_data(graph, edges="links")
if data.get("multigraph") is not True:
return f"serialized multigraph flag was {data.get('multigraph')!r}"
if data.get("directed") is not True:
return f"serialized directed flag was {data.get('directed')!r}"
links = data.get("links")
if not isinstance(links, list) or len(links) != 2:
length = 0 if not isinstance(links, list) else len(links)
return f"serialized links length was {length}"
serialized_keys: set[str] = set()
for edge in links:
if isinstance(edge, dict):
edge_key = edge.get("key")
if isinstance(edge_key, str):
serialized_keys.add(edge_key)
expected = {"calls:a.py:L1", "imports:a.py:L2"}
if serialized_keys != expected:
return f"serialized keys {sorted(serialized_keys)} did not match {sorted(expected)}"
loaded = json_graph.node_link_graph(data, edges="links")
if not isinstance(loaded, nx.MultiDiGraph):
return f"round-trip graph type was {type(loaded).__name__}"
if loaded.number_of_edges("a", "b") != 2:
return f"round-trip edge count was {loaded.number_of_edges('a', 'b')}"
loaded_keys = set(loaded["a"]["b"].keys())
if loaded_keys != expected:
return f"round-trip keys {sorted(loaded_keys)} did not match {sorted(expected)}"
return True
def _probe_duplicate_key_overwrite_semantics() -> bool | str:
graph = nx.MultiDiGraph()
graph.add_edge("x", "y", key="same", marker="first")
graph.add_edge("x", "y", key="same", marker="second")
edges = list(graph.edges(keys=True, data=True))
if len(edges) != 1:
return f"expected one edge after duplicate-key add, got {len(edges)}"
if edges[0][3].get("marker") != "second":
return f"expected second attr overwrite, got {edges[0][3].get('marker')!r}"
return True
def _probe_reserved_key_attr_rejected() -> bool | str:
"""Verify the Python language guarantee that NetworkX add_edge inherits.
Python forbids passing the same keyword argument twice once explicitly
and once via **kwargs. This probe confirms that protection still applies
to nx.MultiDiGraph.add_edge: a future loader that builds attrs from JSON
will be reliably protected from accidentally setting `key` via attrs while
also passing `key=` explicitly.
The probe always passes on any Python 3.x version. Its purpose is to
document the invariant explicitly in the probe suite so that if a future
Python version relaxes this rule (extremely unlikely), the probe surfaces
the regression.
"""
graph = nx.MultiDiGraph()
attrs: dict[str, Any] = {"key": "attr-key", "relation": "calls"}
try:
graph.add_edge("a", "b", key="schema-key", **attrs)
except TypeError:
return True
return "add_edge accepted duplicate key keyword and attr; loader must not rely on this"
def _probe_remove_edges_from_two_tuple_semantics() -> bool | str:
graph = nx.MultiDiGraph()
graph.add_edge("a", "b", key="one")
graph.add_edge("a", "b", key="two")
graph.remove_edges_from([("a", "b")])
remaining = graph.number_of_edges("a", "b")
if remaining != 1:
return f"expected one remaining edge after two-tuple removal, got {remaining}"
return True
def _probe_to_undirected_preserves_multigraph_type() -> bool | str:
graph = _build_probe_graph()
undirected = graph.to_undirected()
undirected_view = graph.to_undirected(as_view=True)
if not isinstance(undirected, nx.MultiGraph):
return f"to_undirected() returned {type(undirected).__name__}"
if not isinstance(undirected_view, nx.MultiGraph):
return f"to_undirected(as_view=True) returned {type(undirected_view).__name__}"
return True
@lru_cache(maxsize=1)
def probe_multigraph_capabilities() -> MultigraphCapabilityResult:
checks = (
_check("keyed_parallel_edges", _probe_keyed_parallel_edges),
_check("node_link_edges_links_round_trip", _probe_node_link_round_trip),
_check("duplicate_key_overwrite_semantics", _probe_duplicate_key_overwrite_semantics),
_check("reserved_key_attr_rejected", _probe_reserved_key_attr_rejected),
_check(
"remove_edges_from_two_tuple_semantics",
_probe_remove_edges_from_two_tuple_semantics,
),
_check(
"to_undirected_preserves_multigraph_type",
_probe_to_undirected_preserves_multigraph_type,
),
)
return MultigraphCapabilityResult(
python_version=(
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
),
networkx_version=nx.__version__,
checks=checks,
)
def require_multigraph_capabilities() -> MultigraphCapabilityResult:
result = probe_multigraph_capabilities()
if not result.ok:
raise RuntimeError(result.error_message())
return result
+129
View File
@@ -0,0 +1,129 @@
"""Cross-file resolution for Pascal/Delphi calls to inherited methods.
The per-file Pascal/Delphi extractors (``extract_pascal``,
``_extract_pascal_regex``) resolve calls to a method on the caller's own
class, its ancestor chain, or a file-level free function -- but only within
the single file being extracted (each file is extracted independently; see
``_resolve_pascal_callee_factory`` in ``extract.py``). Real Delphi/MTM-style
codebases very commonly split a class across two files -- a code-generator
base class (e.g. Sistec's ``Th0Xxx``) and a manual descendant that extends it
in a separate unit (``Th5Xxx``) -- so a call from the manual descendant to a
method it inherits from the generated base falls outside any one file's own
scope. The per-file pass reports these as ``raw_calls`` instead of guessing.
This resolver runs after all files are extracted (registered in
``graphify.resolver_registry``), with the full merged node/edge corpus
available, so it can walk an ``inherits`` chain across file boundaries. It
intentionally does NOT fall back to a global by-name match the way the
per-file pass's final tier does -- an unqualified call resolving to a
specific ancestor mirrors Delphi's actual method-lookup semantics (nearest
ancestor in the chain), so walking `inherits` is a structurally justified
resolution, not a heuristic guess; guessing by name across an entire
multi-thousand-file corpus is not the same bet.
"""
from __future__ import annotations
_PASCAL_SUFFIXES = (".pas", ".pp", ".dpr", ".dpk", ".inc")
def _pascal_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
if not isinstance(result, dict):
continue
for rc in result.get("raw_calls", []):
if not isinstance(rc, dict):
continue
if str(rc.get("source_file", "")).endswith(_PASCAL_SUFFIXES):
calls.append(rc)
return calls
def resolve_pascal_inherited_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Pascal/Delphi calls to a method inherited across file boundaries.
Purely additive: only emits edges for raw calls the per-file pass could
not resolve locally (see module docstring). Each emission requires a
single owning class at the nearest matching level of the caller's
``inherits`` chain (god-node guard, same principle as
``resolve_ruby_member_calls``) -- an ambiguous or unresolved name
produces no edge rather than a guess.
"""
node_by_id = {n.get("id"): n for n in all_nodes}
class_bases: dict[str, list[str]] = {}
# method_nid -> its owning class nid, so a raw call's caller_nid (a method
# or free-function nid) can be mapped to the CLASS whose inherits chain
# should be walked. Derived from `all_edges` (already remapped/finalized
# by the id-disambiguation passes that run before resolvers, same as
# caller_nid itself) rather than carried as a separate field on the raw
# call -- a field the generic id-remap machinery would not know to update.
owner_of: dict[str, str] = {}
class_procs: dict[str, dict[str, list[str]]] = {}
for e in all_edges:
if e.get("relation") == "inherits":
class_bases.setdefault(e["source"], []).append(e["target"])
elif e.get("relation") == "method":
owner, method_nid = e.get("source"), e.get("target")
owner_of[method_nid] = owner
mnode = node_by_id.get(method_nid)
if mnode is None:
continue
name_lower = str(mnode.get("label", "")).removesuffix("()").lower()
# Count DISTINCT methods, not edge multiplicity: the tree-sitter
# Pascal extractor emits one `method` edge for the interface
# declaration and one for the implementation, so the same
# method_nid arrives twice. Deduping keeps the single-owner
# god-node guard below (`len(candidates) == 1`) measuring real
# same-name collisions across classes, not the same method
# double-counted -- otherwise every inherited call looks ambiguous.
bucket = class_procs.setdefault(owner, {}).setdefault(name_lower, [])
if method_nid not in bucket:
bucket.append(method_nid)
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
def _resolve(owner: str, name_lower: str) -> str | None:
seen_bases: set[str] = set()
queue = list(class_bases.get(owner, []))
while queue:
base = queue.pop(0)
if base in seen_bases:
continue
seen_bases.add(base)
candidates = class_procs.get(base, {}).get(name_lower)
if candidates:
return candidates[0] if len(candidates) == 1 else None
queue.extend(class_bases.get(base, []))
return None
for rc in _pascal_raw_calls(per_file):
caller = rc.get("caller_nid")
name_lower = rc.get("callee")
if not caller or not name_lower:
continue
owner = owner_of.get(caller)
if not owner:
continue
target = _resolve(str(owner), str(name_lower))
if not target or target == caller:
continue
pair = (caller, target)
if pair in existing_pairs:
continue
existing_pairs.add(pair)
all_edges.append({
"source": caller,
"target": target,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
+234
View File
@@ -0,0 +1,234 @@
"""Single source of truth for the graphify output-directory name.
The output directory is ``graphify-out`` by default and overridable with the
``GRAPHIFY_OUT`` env var (worktrees or shared-output setups, #686). It accepts a
relative name (``"graphify-out-feature"``) or an absolute path
(``"/shared/graphify-out"``).
This used to be duplicated as an identical ``_GRAPHIFY_OUT`` constant in
``__main__``, ``cache``, and ``watch``, while ``security`` and ``callflow_html``
hardcoded the literal ``"graphify-out"`` and silently ignored the override
(#1423). Centralising it here keeps the name in one place. The value is read
once at import time, matching the previous per-module constants set
``GRAPHIFY_OUT`` before the process starts (the normal worktree/shared-output
flow) and every reader honours it.
"""
from __future__ import annotations
import os
import re
from pathlib import Path, PurePosixPath
GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
# Directory segments that, when they appear as a whole path component, mark the
# whole path as a test location. Matched against path *segments* (not raw
# substrings) so "src/contest.py" / "latest/x.py" / "src/greatest/x.py" do NOT
# match — only a segment that *equals* one of these names (case-insensitively).
_TEST_DIR_SEGMENTS = frozenset({"tests", "test", "spec", "specs", "__tests__"})
# Filename patterns marking a file as a test, matched against the *filename*
# only (case-insensitive). These are conventions across ecosystems:
# test_*.py pytest / unittest
# *_test.* Go / Python / Rust
# *.test.* JS/TS (jest, vitest)
# *.spec.* / *_spec.* Jasmine / RSpec / Karma
# *.Tests.ps1 PowerShell Pester
# *Test.java / *Tests.cs (case-sensitive convention, handled below)
_TEST_FILENAME_PATTERNS = (
re.compile(r"^test_.*", re.IGNORECASE),
re.compile(r".*_test\..+$", re.IGNORECASE),
re.compile(r".*\.test\..+$", re.IGNORECASE),
re.compile(r".*\.spec\..+$", re.IGNORECASE),
re.compile(r".*_spec\..+$", re.IGNORECASE),
re.compile(r".*\.tests\.ps1$", re.IGNORECASE),
# Java `FooTest.java` / `FooTests.java`, C# `FooTests.cs` style. Require an
# uppercase-led `Test`/`Tests` immediately before the extension so plain
# words like "greatest"/"contest.cs" do not match.
re.compile(r".*Test\.java$"),
re.compile(r".*Tests\.java$"),
re.compile(r".*Tests\.cs$"),
)
def _is_test_path(path: str) -> bool:
"""Classify a source path as a test path (case-insensitive, segment-aware).
Shared by extract.py and symbol_resolution.py so cross-file call resolution
treats test mocks/stubs identically. A path is a test path when:
* any whole path segment equals a known test dir name
(``tests``/``test``/``spec``/``specs``/``__tests__``), or
* the filename matches a known test-file naming convention.
Conservative on purpose: matches segments/filenames, never raw substrings,
so ``latest.py``, ``src/contest.py`` and ``src/greatest/x.py`` are NON-test.
"""
if not path:
return False
# Accept both POSIX and Windows separators regardless of host OS so the
# classifier is stable across the mixed paths that flow through extraction.
norm = str(path).replace("\\", "/")
pure = PurePosixPath(norm)
segments = list(pure.parts)
# Strip a leading drive/anchor segment (e.g. "C:/") that PureWindowsPath
# would surface; with the manual "\\"->"/" swap above PurePosixPath keeps
# the path body intact, but guard against a Windows drive embedded as a
# segment just in case.
for segment in segments:
if segment.lower() in _TEST_DIR_SEGMENTS:
return True
# A drive-letter colon segment like "c:" is never a test dir.
filename = pure.name
if not filename:
return False
for pattern in _TEST_FILENAME_PATTERNS:
if pattern.match(filename):
return True
return False
def _path_proximity_winner(call_site_file: str, candidate_files: dict[str, str]) -> str | None:
"""Pick the candidate whose source file is closest to the call site.
``candidate_files`` maps candidate id -> its source_file. Returns a single
winning candidate id, or ``None`` when no proximity tier yields a unique
winner. Tiers, in order:
1. same file as the call site,
2. same directory,
3. longest common path-prefix (must be a strict, unique maximum).
Used only as a secondary tie-break after the test/non-test filter, so the
god-node guard still holds when proximity is genuinely ambiguous.
"""
if not call_site_file:
return None
call_norm = str(call_site_file).replace("\\", "/")
call_dir = PurePosixPath(call_norm).parent
# Tier 1: exact same file.
same_file = [cid for cid, f in candidate_files.items()
if str(f).replace("\\", "/") == call_norm]
if len(same_file) == 1:
return same_file[0]
if len(same_file) > 1:
return None # genuinely ambiguous within one file; bail
# Tier 2: same directory.
same_dir = [cid for cid, f in candidate_files.items()
if PurePosixPath(str(f).replace("\\", "/")).parent == call_dir]
if len(same_dir) == 1:
return same_dir[0]
if len(same_dir) > 1:
return None
# Tier 3: longest common path-prefix, computed over path segments. The
# winner must be a strict unique maximum, else we bail (guard holds).
call_parts = call_dir.parts
def _common_prefix_len(f: str) -> int:
parts = PurePosixPath(str(f).replace("\\", "/")).parent.parts
n = 0
for a, b in zip(call_parts, parts):
if a != b:
break
n += 1
return n
scored = sorted(
((cid, _common_prefix_len(f)) for cid, f in candidate_files.items()),
key=lambda kv: kv[1],
reverse=True,
)
if not scored:
return None
best = scored[0][1]
winners = [cid for cid, score in scored if score == best]
if len(winners) == 1 and best > 0:
return winners[0]
return None
def disambiguate_ambiguous_candidates(
candidates: list[str],
candidate_files: dict[str, str],
call_site_file: str,
) -> str | None:
"""Resolve an ambiguous bare-name call to one candidate, or ``None``.
Shared god-node tie-breaker (#1553) used by both the inline cross-file call
pass in ``extract.py`` and ``symbol_resolution.resolve_cross_file_raw_calls``
so the heuristics stay aligned across languages. ``candidates`` is the list
of node ids sharing the callee's name; ``candidate_files`` maps each id ->
its source_file. Returns the surviving candidate id only when exactly one
survives; otherwise ``None`` (caller keeps the god-node guard / ``continue``).
Tie-breakers, in order:
1. NON-TEST preference. Classify the call site and each candidate as
test/non-test. When the call site is NON-test, drop test candidates.
When the call site IS a test file, prefer test-local candidates
(same file first, then any test candidate); fall back to the full set
only if no test candidate exists.
2. PATH PROXIMITY over whatever survived step 1.
"""
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
call_is_test = _is_test_path(call_site_file)
test_cands = [c for c in candidates if _is_test_path(candidate_files.get(c, ""))]
nontest_cands = [c for c in candidates if c not in set(test_cands)]
if call_is_test:
# Prefer a test-local definition (same file) first.
call_norm = str(call_site_file).replace("\\", "/")
same_file_test = [
c for c in test_cands
if str(candidate_files.get(c, "")).replace("\\", "/") == call_norm
]
if len(same_file_test) == 1:
return same_file_test[0]
if test_cands:
survivors = test_cands
else:
survivors = nontest_cands or candidates
else:
# Non-test call site: drop test mocks/stubs entirely.
survivors = nontest_cands
if len(survivors) == 1:
return survivors[0]
if not survivors:
return None
# Step 2: path proximity over the survivors.
return _path_proximity_winner(
call_site_file,
{c: candidate_files.get(c, "") for c in survivors},
)
# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by the
# path guards that walk parents looking for the output dir by name, and by the
# detect scan-exclude so a custom output dir is never re-ingested as source.
GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT))
def out_path(*parts: str) -> Path:
"""A path inside the configured output dir, e.g. ``out_path("cache")``.
``Path(GRAPHIFY_OUT) / ...`` resolves correctly for both a relative name
("graphify-out") and an absolute override ("/shared/graphify-out").
"""
return Path(GRAPHIFY_OUT, *parts)
def default_graph_json() -> str:
"""Default ``graph.json`` path under the configured output dir.
The package-wide fallback used by serve/build/benchmark/prs and the CLI read
commands so a ``GRAPHIFY_OUT`` override is honoured everywhere, not just where
the path is passed explicitly (#1423).
"""
return str(out_path("graph.json"))
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
from pathlib import Path, PurePosixPath
from graphify.extract import extract_sql
def _quote_ident(name: str) -> str:
"""Double-quote a PostgreSQL identifier, escaping embedded double-quotes."""
return '"' + name.replace('"', '""') + '"'
def introspect_postgres(dsn: str | None = None) -> dict:
"""Connect to PostgreSQL, reconstruct DDL, and extract via extract_sql()."""
try:
import psycopg
except ModuleNotFoundError:
raise ImportError(
"psycopg is required for --postgres. "
"Install with: pip install 'graphify[postgres]'"
)
try:
conn = psycopg.connect(dsn or "") # empty string = PG* env vars
except psycopg.OperationalError as exc:
# Sanitize: strip the DSN/credentials that psycopg may embed in the
# OperationalError message (e.g. "connection to server … failed: …\nDETAIL: …")
msg = str(exc).split("\n")[0]
raise ConnectionError(f"could not connect to PostgreSQL: {msg}") from None
try:
conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE")
# 1. Query tables
with conn.cursor() as cur:
cur.execute("""
SELECT table_schema, table_name, table_type
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;
""")
tables = cur.fetchall()
# 2. Query views
cur.execute("""
SELECT table_schema, table_name, view_definition
FROM information_schema.views
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name;
""")
views = cur.fetchall()
# 3. Query routines (functions/procedures), including language
cur.execute("""
SELECT routine_schema, routine_name, routine_type,
routine_definition, external_language
FROM information_schema.routines
WHERE routine_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY routine_schema, routine_name;
""")
routines = cur.fetchall()
# 4. Query foreign keys — grouped by constraint to handle composites.
# Read pg_catalog.pg_constraint, NOT information_schema.referential_
# constraints: that view only shows constraints where the current
# user has WRITE access to the referencing table (owner or a
# privilege other than SELECT), so a read-only introspection role
# sees zero FK rows while tables/views/routines all appear — the
# graph then silently loses every 'references' edge (#1746).
# pg_constraint is not privilege-filtered. It also keys constraints
# by oid rather than by name (constraint names are only unique per
# table, so the old name-based key_column_usage joins could
# cross-match same-named constraints on sibling tables).
cur.execute("""
SELECT
con.conname AS constraint_name,
ns.nspname AS table_schema,
rel.relname AS table_name,
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
FROM UNNEST(con.conkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_catalog.pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = k.attnum
) AS columns,
fns.nspname AS foreign_table_schema,
frel.relname AS foreign_table_name,
(SELECT ARRAY_AGG(att.attname ORDER BY k.ord)
FROM UNNEST(con.confkey) WITH ORDINALITY AS k(attnum, ord)
JOIN pg_catalog.pg_attribute att
ON att.attrelid = con.confrelid AND att.attnum = k.attnum
) AS foreign_columns
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
JOIN pg_catalog.pg_class frel ON frel.oid = con.confrelid
JOIN pg_catalog.pg_namespace fns ON fns.oid = frel.relnamespace
WHERE con.contype = 'f'
AND ns.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY ns.nspname, rel.relname, con.conname;
""")
fks = cur.fetchall()
finally:
conn.close()
ddl = []
# Tables — quote identifiers to handle reserved words, hyphens, mixed-case
for schema, name, ttype in tables:
if ttype == "BASE TABLE":
ddl.append(f"CREATE TABLE {_quote_ident(schema)}.{_quote_ident(name)} (id INT);")
# Views — real body if available, stub if NULL (permission denied)
for schema, name, body in views:
if body:
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS {body};")
else:
ddl.append(f"CREATE VIEW {_quote_ident(schema)}.{_quote_ident(name)} AS SELECT 1;")
# Functions & Procedures — real body if available, stub if NULL
# Use $gfx$ as the dollar-quote tag to avoid collision with $$ inside bodies.
# Use external_language from the catalog; fall back to plpgsql if NULL/blank.
for schema, name, rtype, body, ext_lang in routines:
lang = (ext_lang or "plpgsql").lower()
fn_sig = f"{_quote_ident(schema)}.{_quote_ident(name)}()"
stub_body = "BEGIN SELECT 1; END;"
if rtype in ("FUNCTION", "PROCEDURE"):
actual_body = body if body else stub_body
# Represent PROCEDUREs as FUNCTION so tree-sitter-sql can parse them
ddl.append(
f"CREATE FUNCTION {fn_sig} RETURNS void"
f" AS $gfx$ {actual_body} $gfx$ LANGUAGE {lang};"
)
# FK edges — one ALTER TABLE per constraint (handles composite FKs correctly)
for constraint_name, t_schema, t_name, cols, r_schema, r_name, r_cols in fks:
col_list = ", ".join(_quote_ident(c) for c in cols)
ref_col_list = ", ".join(_quote_ident(c) for c in r_cols)
ddl.append(
f"ALTER TABLE {_quote_ident(t_schema)}.{_quote_ident(t_name)} "
f"ADD CONSTRAINT {_quote_ident(constraint_name)} "
f"FOREIGN KEY ({col_list}) REFERENCES {_quote_ident(r_schema)}.{_quote_ident(r_name)}({ref_col_list});"
)
ddl_string = "\n".join(ddl)
# Determine host/dbname for virtual path DSN sanitization
info = psycopg.conninfo.conninfo_to_dict(dsn or "")
host = info.get("host", "localhost")
dbname = info.get("dbname", "db")
virtual_path = PurePosixPath(f"postgresql://{host}/{dbname}")
# Pass virtual path and in-memory DDL content to extract_sql
result = extract_sql(virtual_path, content=ddl_string)
return result
+757
View File
@@ -0,0 +1,757 @@
"""graphify prs — graph-aware PR dashboard.
Fast terminal overview of open PRs with CI/review state, worktree mapping,
and optional graph-impact analysis (which communities a PR touches) and
Opus-powered triage ranking.
Usage:
graphify prs # dashboard of all open PRs
graphify prs <number> # deep dive on one PR
graphify prs --triage # Opus ranks your review queue
graphify prs --worktrees # show worktree → branch → PR mapping
graphify prs --conflicts # PRs sharing graph communities (merge-order risk)
graphify prs --base <branch> # filter to PRs targeting this base (default: v8)
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import networkx as nx
from graphify.paths import default_graph_json as _default_graph_json
# ── ANSI colours ─────────────────────────────────────────────────────────────
_NO_COLOR = not sys.stdout.isatty() or os.environ.get("NO_COLOR")
def _c(code: str, text: str) -> str:
if _NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def green(t: str) -> str: return _c("32", t)
def red(t: str) -> str: return _c("31", t)
def yellow(t: str) -> str: return _c("33", t)
def cyan(t: str) -> str: return _c("36", t)
def bold(t: str) -> str: return _c("1", t)
def dim(t: str) -> str: return _c("2", t)
def magenta(t: str) -> str: return _c("35", t)
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
def _pad(s: str, width: int) -> str:
"""Pad an ANSI-colored string to visible width (strips escape codes for length calc)."""
visible_len = len(_ANSI_RE.sub("", s))
return s + " " * max(0, width - visible_len)
# ── Data model ────────────────────────────────────────────────────────────────
@dataclass
class PRInfo:
number: int
title: str
branch: str
base_branch: str
author: str
is_draft: bool
review_decision: str # APPROVED | CHANGES_REQUESTED | ""
ci_status: str # SUCCESS | FAILURE | PENDING | NONE
updated_at: datetime
expected_base: str = "main" # set by fetch_prs via _detect_default_branch
worktree_path: str | None = None
# Graph impact — populated when graph.json exists
communities_touched: list[int] = field(default_factory=list)
nodes_affected: int = 0
files_changed: list[str] = field(default_factory=list)
@property
def status(self) -> str:
return _classify(self, self.expected_base)
@property
def days_old(self) -> int:
return (datetime.now(timezone.utc) - self.updated_at).days
@property
def blast_radius(self) -> str:
if not self.nodes_affected:
return ""
n = self.nodes_affected
c = len(self.communities_touched)
return f"{n} node{'s' if n != 1 else ''} / {c} communit{'ies' if c != 1 else 'y'}"
# ── Classification ────────────────────────────────────────────────────────────
_STATUS_ORDER = ["WRONG-BASE", "CI-FAIL", "CHANGES-REQ", "DRAFT", "STALE", "PENDING", "APPROVED", "READY"]
_STALE_DAYS = 14
def _classify(pr: "PRInfo", base: str = "v8") -> str:
if pr.base_branch != base:
return "WRONG-BASE"
if pr.ci_status == "FAILURE":
return "CI-FAIL"
if pr.review_decision == "CHANGES_REQUESTED":
return "CHANGES-REQ"
if pr.is_draft:
return "DRAFT"
if pr.days_old >= _STALE_DAYS:
return "STALE"
if pr.review_decision == "APPROVED":
return "APPROVED"
if pr.ci_status == "PENDING":
return "PENDING"
return "READY"
def _status_color(status: str) -> str:
return {
"READY": green(status),
"APPROVED": bold(green(status)),
"CI-FAIL": red(status),
"CHANGES-REQ": red(status),
"WRONG-BASE": dim(status),
"STALE": dim(status),
"DRAFT": yellow(status),
"PENDING": yellow(status),
}.get(status, status)
def _ci_icon(status: str) -> str:
return {"SUCCESS": green(""), "FAILURE": red(""), "PENDING": yellow(""), "NONE": dim("")}.get(status, "?")
# ── GitHub data fetching ──────────────────────────────────────────────────────
def _gh(*args: str) -> list | dict | None:
try:
result = subprocess.run(
["gh", *args],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
return None
def _detect_default_branch(repo: str | None = None) -> str:
"""Auto-detect the repo's default branch via gh, then git, then fall back to 'main'."""
# Try gh first — works for any repo, not just the current directory
args = ["repo", "view", "--json", "defaultBranchRef"]
if repo:
args += ["--repo", repo]
data = _gh(*args)
if data and data.get("defaultBranchRef", {}).get("name"):
return data["defaultBranchRef"]["name"]
# Fall back to git symbolic-ref for the current repo
try:
result = subprocess.run(
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
# refs/remotes/origin/main → main
ref = result.stdout.strip()
return ref.split("/")[-1] if ref else "main"
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return "main"
_CI_FAILURE_CONCLUSIONS = frozenset({"FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"})
def _parse_ci(rollup: list) -> str:
if not rollup:
return "NONE"
conclusions = {r.get("conclusion") for r in rollup if r.get("conclusion")}
if conclusions & _CI_FAILURE_CONCLUSIONS:
return "FAILURE"
statuses = {r.get("status") for r in rollup}
if "IN_PROGRESS" in statuses or "QUEUED" in statuses:
return "PENDING"
if "SUCCESS" in conclusions:
return "SUCCESS"
return "NONE"
def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) -> list[PRInfo]:
resolved_base = base or _detect_default_branch(repo)
args = [
"pr", "list", "--state", "open", "--limit", str(limit),
"--json", "number,title,headRefName,baseRefName,author,isDraft,"
"reviewDecision,statusCheckRollup,updatedAt",
]
if repo:
args += ["--repo", repo]
raw = _gh(*args)
if raw is None:
raise RuntimeError("gh CLI not found or not authenticated. Run: gh auth login")
prs = []
for item in raw:
updated = datetime.fromisoformat(item["updatedAt"].replace("Z", "+00:00"))
prs.append(PRInfo(
number=item["number"],
title=item["title"],
branch=item["headRefName"],
base_branch=item["baseRefName"],
author=item["author"]["login"] if item.get("author") else "?",
is_draft=item.get("isDraft", False),
review_decision=item.get("reviewDecision") or "",
ci_status=_parse_ci(item.get("statusCheckRollup") or []),
updated_at=updated,
expected_base=resolved_base,
))
return prs
def fetch_pr_files(number: int, repo: str | None = None) -> list[str]:
args = ["pr", "diff", str(number), "--name-only"]
if repo:
args += ["--repo", repo]
try:
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return []
return [l.strip() for l in result.stdout.splitlines() if l.strip()]
except (subprocess.TimeoutExpired, FileNotFoundError):
return []
# ── Graph-native impact (used by MCP tools — works on nx.Graph directly) ─────
def _path_match(graph_src: str, pr_file: str) -> bool:
"""True if graph_src and pr_file refer to the same file (path-boundary safe)."""
if graph_src == pr_file:
return True
return graph_src.endswith("/" + pr_file) or pr_file.endswith("/" + graph_src)
def compute_pr_impact(files: list[str], G: "nx.Graph") -> tuple[list[int], int]:
"""Return (communities_touched, nodes_affected) for a set of changed files.
Builds a file(communities, count) index first so lookup is O(nodes + files)
rather than O(nodes × files).
"""
# Build index once
file_comms: dict[str, set[int]] = {}
file_count: dict[str, int] = {}
for _, data in G.nodes(data=True):
src = data.get("source_file") or ""
if not src:
continue
if src not in file_comms:
file_comms[src] = set()
file_count[src] = 0
c = data.get("community")
if c is not None:
file_comms[src].add(int(c))
file_count[src] += 1
comms: set[int] = set()
nodes = 0
matched: set[str] = set()
for f in files:
for src, src_comms in file_comms.items():
if src not in matched and _path_match(src, f):
comms |= src_comms
nodes += file_count[src]
matched.add(src)
return sorted(comms), nodes
def format_prs_text(prs: list["PRInfo"], base: str) -> str:
"""Plain-text PR summary for MCP output (no ANSI)."""
actionable = [p for p in prs if p.base_branch == base]
wrong = len(prs) - len(actionable)
lines = [f"Open PRs targeting {base}: {len(actionable)} ({wrong} on wrong base, not shown)\n"]
for p in sorted(actionable, key=lambda x: (_STATUS_ORDER.index(x.status) if x.status in _STATUS_ORDER else 99, x.days_old)):
impact = f" blast_radius={p.blast_radius}" if p.blast_radius else ""
lines.append(
f"#{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} "
f"age={p.days_old}d author={p.author}{impact}\n {p.title}"
)
return "\n\n".join(lines)
# ── Worktree mapping ──────────────────────────────────────────────────────────
def fetch_worktrees() -> dict[str, str]:
"""Returns {branch: worktree_path}."""
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
return {}
except (subprocess.TimeoutExpired, FileNotFoundError):
return {}
mapping: dict[str, str] = {}
current_path = None
for line in result.stdout.splitlines():
if not line:
current_path = None # blank line = record separator; reset to avoid leaking across detached HEADs
elif line.startswith("worktree "):
current_path = line[9:]
elif line.startswith("branch refs/heads/") and current_path:
mapping[line[18:]] = current_path
return mapping
# ── Graph impact analysis ─────────────────────────────────────────────────────
def _load_graph_json(graph_path: Path) -> dict | None:
if not graph_path.exists():
return None
from graphify.security import check_graph_file_size_cap
try:
check_graph_file_size_cap(graph_path)
return json.loads(graph_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
return None
def build_community_labels(data: dict, top_n: int = 4) -> dict[int, list[str]]:
"""Return {community_id: [top_labels]} extracted from graph node data."""
comm_labels: dict[int, list[str]] = defaultdict(list)
for node in data.get("nodes", []):
c = node.get("community")
if c is None:
continue
label = node.get("label") or node.get("id") or ""
if label:
comm_labels[int(c)].append(label)
return {c: labels[:top_n] for c, labels in comm_labels.items()}
def attach_graph_impact(
prs: list[PRInfo], graph_path: Path, repo: str | None = None
) -> dict[int, list[str]]:
"""Fetch PR file lists concurrently, compute graph impact, return community labels."""
data = _load_graph_json(graph_path)
if not data:
return {}
# Build file → {community, node_count} index
file_to_communities: dict[str, set[int]] = {}
file_to_nodes: dict[str, int] = {}
for node in data.get("nodes", []):
src = node.get("source_file") or ""
if not src:
continue
comm = node.get("community")
if src not in file_to_communities:
file_to_communities[src] = set()
file_to_nodes[src] = 0
if comm is not None:
file_to_communities[src].add(int(comm))
file_to_nodes[src] += 1
# Fetch diffs concurrently — gh pr diff is the bottleneck (network I/O)
actionable = [pr for pr in prs if pr.status != "WRONG-BASE"]
workers = min(8, len(actionable)) if actionable else 1
with ThreadPoolExecutor(max_workers=workers) as pool:
future_to_pr = {
pool.submit(fetch_pr_files, pr.number, repo): pr
for pr in actionable
}
for fut in as_completed(future_to_pr):
pr = future_to_pr[fut]
try:
files = fut.result()
except Exception:
files = []
pr.files_changed = files
comms: set[int] = set()
nodes = 0
matched: set[str] = set()
for f in files:
for gf, gcomms in file_to_communities.items():
if gf not in matched and _path_match(gf, f):
comms |= gcomms
nodes += file_to_nodes.get(gf, 0)
matched.add(gf)
pr.communities_touched = sorted(comms)
pr.nodes_affected = nodes
return build_community_labels(data)
# ── Dashboard rendering ───────────────────────────────────────────────────────
def _truncate(s: str, n: int) -> str:
return s if len(s) <= n else s[:n - 1] + ""
def render_dashboard(prs: list[PRInfo], base: str = "v8", show_wrong_base: bool = False) -> None:
actionable = [p for p in prs if p.base_branch == base]
wrong_base = [p for p in prs if p.base_branch != base]
# Sort: READY first, then by status order, then by recency
actionable.sort(key=lambda p: (_STATUS_ORDER.index(p.status) if p.status in _STATUS_ORDER else 99, p.days_old))
print()
print(bold(f" graphify prs · base: {base} · {len(actionable)} PRs"))
print()
if not actionable:
print(dim(" No open PRs targeting this base branch."))
else:
# Header
print(f" {'#':>4} {'CI':2} {'STATUS':13} {'UPDATED':8} {'IMPACT':22} TITLE")
print(f" {''*4} {''*2} {''*13} {''*8} {''*22} {''*40}")
for pr in actionable:
status_str = _pad(_status_color(pr.status), 13)
ci_str = _ci_icon(pr.ci_status)
age = f"{pr.days_old}d" if pr.days_old > 0 else "today"
impact = _pad(dim(_truncate(pr.blast_radius, 22)), 22) if pr.blast_radius else _pad(dim(""), 22)
wt = f" {cyan('')}" if pr.worktree_path else " "
draft = dim(" [draft]") if pr.is_draft else ""
title = _truncate(pr.title, 52)
num = _pad(bold(f"#{pr.number}"), 6)
print(f" {num}{wt} {ci_str} {status_str} {age:>6} {impact} {title}{draft}")
# Summary line
by_status: dict[str, int] = {}
for p in actionable:
by_status[p.status] = by_status.get(p.status, 0) + 1
parts = []
if by_status.get("READY"): parts.append(green(f"{by_status['READY']} ready"))
if by_status.get("APPROVED"): parts.append(bold(green(f"{by_status['APPROVED']} approved")))
if by_status.get("PENDING"): parts.append(yellow(f"{by_status['PENDING']} pending CI"))
if by_status.get("CI-FAIL"): parts.append(red(f"{by_status['CI-FAIL']} CI failing"))
if by_status.get("CHANGES-REQ"):parts.append(red(f"{by_status['CHANGES-REQ']} changes requested"))
if by_status.get("DRAFT"): parts.append(yellow(f"{by_status['DRAFT']} draft"))
if by_status.get("STALE"): parts.append(dim(f"{by_status['STALE']} stale"))
if wrong_base:
parts.append(dim(f"{len(wrong_base)} wrong base"))
print()
print(f" {' · '.join(parts)}")
print()
if wrong_base and show_wrong_base:
print(dim(f" ── {len(wrong_base)} PRs targeting wrong base ──"))
for pr in sorted(wrong_base, key=lambda p: p.number, reverse=True):
print(dim(f" #{pr.number:4} base={pr.base_branch:12} {_truncate(pr.title, 60)}"))
print()
def render_worktrees(prs: list[PRInfo], worktrees: dict[str, str]) -> None:
print()
print(bold(" Worktrees"))
print()
if not worktrees:
print(dim(" No active worktrees found."))
print()
return
pr_by_branch = {p.branch: p for p in prs}
for branch, path in sorted(worktrees.items()):
pr = pr_by_branch.get(branch)
if pr:
status = _status_color(pr.status)
print(f" {cyan(path)}")
print(f" {dim('branch:')} {branch} -> PR {bold(f'#{pr.number}')} [{status}] {_truncate(pr.title, 50)}")
else:
print(f" {cyan(path)}")
print(f" {dim('branch:')} {branch} {dim('(no open PR)')}")
print()
def render_conflicts(
prs: list[PRInfo],
base: str = "v8",
community_labels: dict[int, list[str]] | None = None,
) -> None:
actionable = [p for p in prs if p.base_branch == base and p.communities_touched]
if not actionable:
print(dim("\n No graph impact data - run with a valid graph.json to detect conflicts.\n"))
return
# Build community → [PRs] map
comm_to_prs: dict[int, list[PRInfo]] = {}
for pr in actionable:
for c in pr.communities_touched:
comm_to_prs.setdefault(c, []).append(pr)
conflicts = {c: ps for c, ps in comm_to_prs.items() if len(ps) > 1}
if not conflicts:
print(green("\n No community overlap between open PRs - safe to merge in any order.\n"))
return
print()
print(bold(" Community conflicts (PRs sharing the same graph community)"))
print()
labels = community_labels or {}
for comm, ps in sorted(conflicts.items(), key=lambda x: -len(x[1])):
comm_label_str = ""
if comm in labels and labels[comm]:
comm_label_str = dim("" + ", ".join(labels[comm]))
print(f" {yellow(f'Community {comm}')}{comm_label_str} ({len(ps)} PRs overlap)")
for pr in ps:
print(f" #{pr.number:4} {_pad(_status_color(pr.status), 13)} {_truncate(pr.title, 55)}")
print()
def render_pr_detail(pr: PRInfo, repo: str | None = None) -> None:
print()
print(bold(f" PR #{pr.number} · {_status_color(pr.status)}"))
print(f" {pr.title}")
print()
print(f" {dim('branch:')} {pr.branch} -> {pr.base_branch}")
print(f" {dim('author:')} {pr.author}")
print(f" {dim('updated:')} {pr.days_old}d ago")
print(f" {dim('CI:')} {_ci_icon(pr.ci_status)} {pr.ci_status}")
if pr.review_decision:
print(f" {dim('review:')} {pr.review_decision}")
if pr.worktree_path:
print(f" {dim('worktree:')} {cyan(pr.worktree_path)}")
if pr.blast_radius:
print()
print(f" {bold('Graph impact:')} {pr.blast_radius}")
print(f" {dim('communities:')} {pr.communities_touched}")
if pr.files_changed:
print(f" {dim('files changed:')} {len(pr.files_changed)}")
for f in pr.files_changed[:10]:
print(f" {dim(f)}")
if len(pr.files_changed) > 10:
print(dim(f" … and {len(pr.files_changed) - 10} more"))
print()
# ── Triage (multi-backend) ────────────────────────────────────────────────────
# Best model per backend for reasoning tasks (different from extraction defaults)
_TRIAGE_MODEL_DEFAULTS: dict[str, str] = {
"claude": "claude-opus-4-7",
"kimi": "kimi-k2.6",
"openai": "gpt-4.1-mini",
"gemini": "gemini-3-flash-preview",
}
def _resolve_triage_backend() -> tuple[str, str]:
"""Return (backend, model) using GRAPHIFY_TRIAGE_BACKEND or first available key."""
from graphify.llm import BACKENDS, _get_backend_api_key, _default_model_for_backend
explicit = os.environ.get("GRAPHIFY_TRIAGE_BACKEND", "").strip()
if explicit in BACKENDS:
model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL")
or _TRIAGE_MODEL_DEFAULTS.get(explicit)
or _default_model_for_backend(explicit))
return explicit, model
for b in ("claude", "kimi", "openai", "gemini"):
if _get_backend_api_key(b):
model = (os.environ.get("GRAPHIFY_TRIAGE_MODEL")
or _TRIAGE_MODEL_DEFAULTS.get(b)
or _default_model_for_backend(b))
return b, model
import shutil
if shutil.which("claude"):
return "claude-cli", "claude-code-plan"
return "ollama", _default_model_for_backend("ollama")
def triage_with_opus(prs: list[PRInfo], base: str) -> None:
try:
from graphify.llm import BACKENDS, _get_backend_api_key
except ImportError:
print(red(" graphify.llm not available - cannot run triage."), file=sys.stderr)
sys.exit(1)
candidates = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")]
if not candidates:
print(dim(" No actionable PRs to triage."))
return
lines = []
for pr in candidates:
impact = f", blast_radius={pr.blast_radius}" if pr.blast_radius else ""
lines.append(
f"PR #{pr.number} [{pr.status}] CI={pr.ci_status} review={pr.review_decision or 'none'} "
f"age={pr.days_old}d author={pr.author}{impact}\n title: {pr.title}"
)
prompt = (
"You are a senior engineer helping triage a PR review queue. "
"Given these open PRs, rank them by review priority for the repo maintainer. "
"For each PR give: priority number, one sentence on what action to take and why. "
"Be direct and specific. Format each as: #<number> — <action>.\n\n"
+ "\n\n".join(lines)
)
try:
backend, model = _resolve_triage_backend()
except Exception as e:
print(red(f" Could not resolve triage backend: {e}"), file=sys.stderr)
sys.exit(1)
print()
print(bold(" Triage") + dim(f" ({backend} / {model})"))
print()
try:
if backend == "claude":
import anthropic
client = anthropic.Anthropic(api_key=_get_backend_api_key("claude"))
with client.messages.stream(
model=model, max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
print(" ", end="", flush=True)
for text in stream.text_stream:
print(text.replace("\n", "\n "), end="", flush=True)
print("\n")
elif backend in ("kimi", "openai", "gemini", "ollama"):
from openai import OpenAI
cfg = BACKENDS[backend]
api_key = _get_backend_api_key(backend) or "ollama"
client = OpenAI(api_key=api_key, base_url=cfg.get("base_url", ""))
with client.chat.completions.create(
model=model, max_tokens=1024, stream=True,
messages=[{"role": "user", "content": prompt}],
) as stream:
print(" ", end="", flush=True)
for chunk in stream:
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
print(delta.replace("\n", "\n "), end="", flush=True)
print("\n")
elif backend == "claude-cli":
import platform as _platform, shutil as _shutil, subprocess as _sp
_claude = "claude"
if _platform.system() == "Windows":
_claude = _shutil.which("claude.cmd") or _shutil.which("claude") or "claude"
proc = _sp.run(
[_claude, "-p", "--no-session-persistence"],
input=prompt, capture_output=True, text=True, timeout=120,
)
if proc.returncode != 0:
print(red(f" claude -p failed: {proc.stderr.strip()[:300]}"), file=sys.stderr)
else:
try:
result = json.loads(proc.stdout).get("result") or proc.stdout
except json.JSONDecodeError:
result = proc.stdout
for line in result.splitlines():
print(f" {line}")
print()
except Exception as e:
print(f"\n\n {red(f'Triage failed: {e}')}", file=sys.stderr)
# ── Entry point ───────────────────────────────────────────────────────────────
def cmd_prs(argv: list[str]) -> None:
base: str | None = None # auto-detected from repo if not given
repo: str | None = None
do_triage = False
do_worktrees = False
do_conflicts = False
show_wrong_base = False
pr_number: int | None = None
graph_path = Path(_default_graph_json())
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--triage":
do_triage = True
elif arg == "--worktrees":
do_worktrees = True
elif arg == "--conflicts":
do_conflicts = True
elif arg == "--wrong-base":
show_wrong_base = True
elif arg in ("--base", "-b") and i + 1 < len(argv):
base = argv[i + 1]; i += 1
elif arg.startswith("--base="):
base = arg.split("=", 1)[1]
elif arg in ("--repo", "-R") and i + 1 < len(argv):
repo = argv[i + 1]; i += 1
elif arg.startswith("--graph="):
graph_path = Path(arg.split("=", 1)[1])
elif arg == "--graph" and i + 1 < len(argv):
graph_path = Path(argv[i + 1]); i += 1
elif arg.lstrip("#").isdigit():
pr_number = int(arg.lstrip("#"))
elif arg in ("-h", "--help"):
print(__doc__)
return
i += 1
if base is None:
base = _detect_default_branch(repo)
try:
prs = fetch_prs(repo=repo, base=base)
except RuntimeError as e:
print(red(f" Error: {e}"), file=sys.stderr)
sys.exit(1)
worktrees = fetch_worktrees()
for pr in prs:
pr.worktree_path = worktrees.get(pr.branch)
# Graph impact is expensive (concurrent gh pr diff calls) — only fetch when
# the user actually needs it: deep dive, triage, and conflict detection.
community_labels: dict[int, list[str]] = {}
needs_impact = graph_path.exists() and (pr_number is not None or do_triage or do_conflicts)
if needs_impact:
community_labels = attach_graph_impact(prs, graph_path, repo)
if pr_number is not None:
match = next((p for p in prs if p.number == pr_number), None)
if not match:
print(red(f" PR #{pr_number} not found in open PRs."), file=sys.stderr)
sys.exit(1)
render_pr_detail(match, repo)
return
if do_triage:
render_dashboard(prs, base, show_wrong_base)
triage_with_opus(prs, base)
return
if do_worktrees:
render_worktrees(prs, worktrees)
return
if do_conflicts:
render_dashboard(prs, base, show_wrong_base)
render_conflicts(prs, base, community_labels)
return
render_dashboard(prs, base, show_wrong_base)
+80
View File
@@ -0,0 +1,80 @@
"""Query logging for graphify — append-only JSONL, fail-silent."""
from __future__ import annotations
import json
import os
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_NODES_RE = re.compile(r"(\d+)\s+nodes?\s+found")
def _log_path() -> Path | None:
# Opt-in only (#1797). The log records every query/path/explain question and
# corpus path (and full responses if GRAPHIFY_QUERY_LOG_RESPONSES) in a
# plaintext file under ~/.cache — outside any repo's .gitignore/retention. A
# default-on record of proprietary queries contradicts graphify's on-device,
# no-telemetry posture, so it is OFF unless explicitly enabled:
# GRAPHIFY_QUERY_LOG=<path> log to that path, or
# GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log.
# GRAPHIFY_QUERY_LOG_DISABLE=1 still forces it off (back-compat, wins).
if os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE", "").lower() in ("1", "true", "yes"):
return None
override = os.environ.get("GRAPHIFY_QUERY_LOG", "").strip()
if override:
return Path(override).expanduser()
if os.environ.get("GRAPHIFY_QUERY_LOG_ENABLE", "").lower() in ("1", "true", "yes"):
return Path.home() / ".cache" / "graphify-queries.log"
return None
def _log_responses() -> bool:
return os.environ.get("GRAPHIFY_QUERY_LOG_RESPONSES", "").lower() in ("1", "true", "yes")
def nodes_from_result(result: str) -> int | None:
m = _NODES_RE.search(result or "")
return int(m.group(1)) if m else None
def log_query(
*,
kind: str,
question: str,
corpus: str,
result: str | None = None,
nodes_returned: int | None = None,
duration_ms: float | None = None,
**extra: Any,
) -> None:
"""Append one JSONL record to the query log. Never raises."""
try:
path = _log_path()
if path is None:
return
if nodes_returned is None and result is not None:
nodes_returned = nodes_from_result(result)
rec: dict[str, Any] = {
"ts": datetime.now(timezone.utc).isoformat(),
"kind": kind,
"question": question,
"corpus": corpus,
"nodes_returned": nodes_returned,
}
if result is not None:
rec["result_chars"] = len(result)
if duration_ms is not None:
rec["duration_ms"] = round(duration_ms, 3)
for k, v in extra.items():
if v is not None:
rec[k] = v
if result is not None and _log_responses():
rec["response"] = result
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
+882
View File
@@ -0,0 +1,882 @@
"""Deterministic "work memory" reflection over graphify-out/memory/.
`graphify reflect` reads the Q&A memory docs that `graphify save-result` files back
into the graph, aggregates their outcome signals (useful / dead_end / corrected), and
writes a single lessons artifact an agent can load at the start of the next session:
- **Preferred sources** nodes corroborated by multiple ``useful`` answers.
- **Tentative** nodes seen useful only once (not yet corroborated).
- **Contested** nodes with both positive and negative signals; recency decides.
- **Known dead ends** questions/sources marked ``dead_end``; don't re-derive them.
- **Corrections** answers the user corrected, and what the right answer was.
Source nodes are scored, not counted: each citation contributes a signed,
time-decayed value (``useful`` positive, ``dead_end``/``corrected`` negative, with a
half-life so a fresh dead end outweighs a months-old useful). A node is only promoted
to "preferred" once corroborated by enough distinct results; one save can't mint a
trusted lesson. When a graph is in hand, source nodes that no longer exist are dropped.
It is deterministic: no LLM, stable sort orders, byte-stable output for a given input
and a given ``now``. When a graph (`graph.json` + `.graphify_analysis.json`) is available
the lessons are also grouped by community label; without it they degrade to a single
flat section.
The artifact lands at ``graphify-out/reflections/LESSONS.md`` rather than inside the wiki
because ``graphify export wiki`` deletes every ``wiki/*.md`` on each run a lessons file
written there would be clobbered on the next export.
"""
from __future__ import annotations
import json
import re
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from graphify.ingest import OUTCOMES
from graphify.paths import GRAPHIFY_OUT_NAME
_UNCATEGORIZED = "Uncategorized"
# Derived experiential layer written alongside graph.json (a SIDECAR, kept
# separate from the durable structural truth in graph.json — no learning_*
# fields are ever stamped into the graph itself). Read-surface annotations are
# merged in at display time from this file.
LEARNING_SIDECAR_NAME = ".graphify_learning.json"
_LEARNING_SCHEMA_VERSION = 1
_PROVENANCE_CAP = 5 # most-recent (question, date, outcome) entries per node
# Scoring defaults (both exposed as CLI flags).
_DEFAULT_HALF_LIFE_DAYS = 30.0 # a signal's weight halves every 30 days
_DEFAULT_MIN_CORROBORATION = 2 # distinct useful results needed to "prefer" a node
# Rounding for the signed score keeps sort order and the contested verdict stable
# across platforms (C pow can differ in the last ULP).
_SCORE_NDIGITS = 9
# --- frontmatter parsing -------------------------------------------------------
#
# save_query_result writes a tiny, hand-built YAML subset (no PyYAML dependency),
# so we parse the same subset by hand rather than adding a dependency: scalar
# `key: "value"` lines and a `source_nodes: ["a", "b"]` flow list. Anything we
# don't recognise is ignored, so foreign .md files in memory/ are skipped cleanly.
_SCALAR_RE = re.compile(r'^([A-Za-z_][\w-]*):\s*"(.*)"\s*$')
_LIST_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*\[(.*)\]\s*$")
_DQ_ITEM_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
def _yaml_unescape(s: str) -> str:
"""Reverse the double-quoted escaping that ingest._yaml_str applies."""
out: list[str] = []
i = 0
simple = {"n": "\n", "r": "\r", "t": "\t", "0": "\0", '"': '"', "\\": "\\",
"L": "\u2028", "P": "\u2029"} # YAML line/paragraph separators
while i < len(s):
ch = s[i]
if ch == "\\" and i + 1 < len(s):
nxt = s[i + 1]
if nxt in simple:
out.append(simple[nxt])
i += 2
continue
if nxt == "x" and i + 3 < len(s):
try:
out.append(chr(int(s[i + 2:i + 4], 16)))
i += 4
continue
except ValueError:
pass
if nxt == "u" and i + 5 < len(s):
try:
out.append(chr(int(s[i + 2:i + 6], 16)))
i += 6
continue
except ValueError:
pass
out.append(ch)
i += 1
return "".join(out)
def parse_memory_doc(text: str) -> dict[str, Any] | None:
"""Parse the frontmatter of a memory doc into a dict, or None if it has none.
Returns the recognised fields (``type``, ``date``, ``question``, ``outcome``,
``correction``, ``source_nodes``). ``source_nodes`` is always a list.
"""
if not text.startswith("---"):
return None
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return None
fields: dict[str, Any] = {"source_nodes": []}
for line in lines[1:]:
if line.strip() == "---":
break
m = _LIST_RE.match(line)
if m and m.group(1) == "source_nodes":
fields["source_nodes"] = [
_yaml_unescape(item) for item in _DQ_ITEM_RE.findall(m.group(2))
]
continue
m = _SCALAR_RE.match(line)
if m:
key, val = m.group(1), _yaml_unescape(m.group(2))
if key in ("type", "date", "question", "outcome", "correction", "contributor"):
fields[key] = val
return fields
def load_memory_docs(memory_dir: Path) -> list[dict[str, Any]]:
"""Parse every memory doc under ``memory_dir``, sorted by date then filename.
Each record is the parsed frontmatter plus ``_path`` (the source file). Docs
without recognisable frontmatter (foreign .md files, the LESSONS.md artifact)
are skipped.
"""
memory_dir = Path(memory_dir)
if not memory_dir.exists():
return []
docs: list[dict[str, Any]] = []
for path in sorted(memory_dir.glob("*.md")):
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
parsed = parse_memory_doc(text)
if parsed is None:
continue
parsed["_path"] = path.name
docs.append(parsed)
# Stable order: by (date, filename) so output is deterministic across runs.
docs.sort(key=lambda d: (d.get("date", ""), d["_path"]))
return docs
# --- graph / community lookup (optional) ---------------------------------------
def _load_node_community(graph_path: Path, analysis_path: Path,
labels_path: Path) -> dict[str, str] | None:
"""Build a lookup from node id AND node label -> community label, or None if the
graph isn't available.
Mirrors how `graphify export wiki` reads graph.json + .graphify_analysis.json +
.graphify_labels.json. Community membership in the analysis sidecar is keyed by
node id, but `save-result` cites nodes by label, so both are mapped otherwise a
cited ``build_from_json()`` never finds its community and every lesson collapses
into Uncategorized. Best-effort: any missing/unparseable artifact disables grouping.
"""
if not graph_path.exists() or not analysis_path.exists():
return None
try:
analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
communities = analysis.get("communities", {})
if not communities:
return None
labels: dict[str, str] = {}
if labels_path.exists():
try:
labels = json.loads(labels_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
labels = {}
# id -> label from the graph, so a label-form citation resolves to a community too.
id_to_label: dict[str, str] = {}
try:
gdata = json.loads(graph_path.read_text(encoding="utf-8"))
for n in gdata.get("nodes", []):
if isinstance(n, dict) and n.get("id") is not None and n.get("label") is not None:
id_to_label[str(n["id"])] = str(n["label"])
except (OSError, ValueError):
id_to_label = {}
# Sorted cid iteration + setdefault makes any label collision resolve
# deterministically (smallest community id wins).
node_community: dict[str, str] = {}
for cid in sorted(communities, key=str):
label = labels.get(str(cid)) or labels.get(cid) or f"Community {cid}"
for nid in communities[cid]:
nid = str(nid)
node_community.setdefault(nid, label)
nlabel = id_to_label.get(nid)
if nlabel is not None:
node_community.setdefault(nlabel, label)
return node_community
def _load_known_nodes(graph_path: Path) -> set[str] | None:
"""The set of node ids AND labels in the current graph, or None if unavailable.
Used to drop source nodes from lessons once the code they pointed at is gone
(deleted/renamed) a stale lesson shouldn't keep getting recommended. Both ids
and labels are collected because `save-result` records source nodes by their
human-readable label (what an agent cites, e.g. ``build_from_json()``), while
graph nodes are keyed by id (e.g. ``module_build_from_json``). Matching on either
keeps a still-present node and only drops one that survives under neither name
indexing ids alone silently dropped every label-form citation (the common case).
"""
try:
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
nodes = data.get("nodes")
if not isinstance(nodes, list):
return None
known: set[str] = set()
for n in nodes:
if not isinstance(n, dict):
continue
if n.get("id") is not None:
known.add(str(n["id"]))
if n.get("label") is not None:
known.add(str(n["label"]))
return known or None
def _doc_community(nodes: list[str],
node_community: dict[str, str] | None) -> str:
"""The community a doc belongs to: the plurality community of its source nodes.
Ties break to the lexicographically-smallest label, so the result is
deterministic regardless of source-node order. Docs with no resolvable
community (no source nodes, or no graph) fall into the Uncategorized bucket.
"""
if not node_community:
return _UNCATEGORIZED
labels = [node_community[n] for n in nodes if n in node_community]
if not labels:
return _UNCATEGORIZED
counts = Counter(labels)
# Highest count wins; on a tie, the smaller label (most-negative count first,
# then ascending label) — a plain min() over (-count, label).
return min(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0]
# --- scoring helpers -----------------------------------------------------------
def _parse_dt(date_str: str) -> datetime | None:
"""Parse an ISO date/datetime to an aware UTC datetime, or None if unparseable."""
if not date_str:
return None
try:
dt = datetime.fromisoformat(date_str)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _decay(date_str: str, now: datetime, half_life_days: float) -> float:
"""Time-decay weight in (0, 1]: halves every ``half_life_days``.
Undated/unparseable signals keep full weight (1.0); future-dated ones are
clamped to age 0 (also 1.0).
"""
dt = _parse_dt(date_str)
if dt is None or half_life_days <= 0:
return 1.0
age_days = max(0.0, (now - dt).total_seconds() / 86400.0)
return 0.5 ** (age_days / half_life_days)
# --- aggregation ---------------------------------------------------------------
def _empty_bucket() -> dict[str, Any]:
return {
"counts": {k: 0 for k in (*OUTCOMES, "unmarked")},
# node -> running signed, time-decayed score
"node_score": {},
# node -> distinct positive / negative result counts (for corroboration)
"node_pos": Counter(),
"node_neg": Counter(),
# node -> most recent event date seen (for the contested verdict line)
"node_last": {},
# node -> list of (date, question, outcome) for useful/corrected citations.
# Feeds the sidecar overlay's per-node provenance; never read by LESSONS.md,
# so it doesn't touch the aggregate's public shape.
"node_provenance": {},
"dead_ends": [],
"corrections": [],
}
def _record_node(bucket: dict[str, Any], node: str, sign: int,
weight: float, date: str, *, outcome: str | None = None,
question: str = "") -> None:
bucket["node_score"][node] = bucket["node_score"].get(node, 0.0) + sign * weight
if sign > 0:
bucket["node_pos"][node] += 1
elif sign < 0:
bucket["node_neg"][node] += 1
if date > bucket["node_last"].get(node, ""):
bucket["node_last"][node] = date
# Provenance: only useful/corrected events are recorded (the experiential
# trail an agent cares about — what cited this node, and how it turned out).
if outcome in ("useful", "corrected"):
bucket["node_provenance"].setdefault(node, []).append(
(date, question, outcome))
def _finalize_sources(bucket: dict[str, Any],
min_corroboration: int) -> dict[str, list]:
"""Split a bucket's scored nodes into preferred / tentative / contested lists."""
preferred, tentative, contested = [], [], []
for node in bucket["node_score"]:
pos = bucket["node_pos"][node]
neg = bucket["node_neg"][node]
score = round(bucket["node_score"][node], _SCORE_NDIGITS)
if pos and neg:
verdict = "useful" if score > 0 else "dead end" if score < 0 else "even"
contested.append({"node": node, "pos": pos, "neg": neg,
"score": score, "verdict": verdict,
"last": bucket["node_last"].get(node, "")})
elif pos: # positive-only
entry = {"node": node, "n": pos, "score": score}
(preferred if pos >= min_corroboration else tentative).append(entry)
# negative-only nodes are surfaced via the dead-ends questions, not here.
preferred.sort(key=lambda e: (-e["score"], e["node"]))
tentative.sort(key=lambda e: (-e["score"], e["node"]))
contested.sort(key=lambda e: (-e["score"], e["node"]))
return {"preferred": preferred, "tentative": tentative, "contested": contested}
def _dedupe_by_question(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Collapse repeated questions to one entry. Docs are processed oldest-first, so
the last write per question wins (recency e.g. the most recent correction text).
Output is deterministically ordered by (date, question). Without this, saving the
same Q&A twice duplicated lines in the dead-ends / corrections lists, even though
node scoring already dedups by node.
"""
latest: dict[str, dict[str, Any]] = {}
for it in items:
latest[it.get("question", "")] = it
return sorted(latest.values(),
key=lambda it: (it.get("date", ""), it.get("question", "")))
def aggregate_lessons(docs: list[dict[str, Any]],
node_community: dict[str, str] | None = None,
*,
now: datetime | None = None,
half_life_days: float = _DEFAULT_HALF_LIFE_DAYS,
min_corroboration: int = _DEFAULT_MIN_CORROBORATION,
known_nodes: set[str] | None = None) -> dict[str, Any]:
"""Aggregate parsed memory docs into a deterministic lessons structure.
``now`` anchors the time-decay (pass it explicitly for byte-stable output).
``known_nodes`` (when given) gates out source nodes no longer in the graph.
Returns ``{"total", "counts", "min_corroboration", "preferred", "tentative",
"contested", "dead_ends", "corrections", "by_community"}``; ``by_community`` is
empty unless a graph is supplied.
"""
if now is None:
now = datetime.now(timezone.utc)
elif now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
overall = _empty_bucket()
by_community: dict[str, dict[str, Any]] = {}
for doc in docs:
outcome = doc.get("outcome")
date = doc.get("date", "")
# One event per node per doc; drop nodes the graph no longer knows about.
raw = doc.get("source_nodes", [])
nodes = list(dict.fromkeys(
n for n in raw if known_nodes is None or n in known_nodes))
community = _doc_community(nodes, node_community)
bucket = by_community.setdefault(community, _empty_bucket())
sign = 1 if outcome == "useful" else -1 if outcome in ("dead_end", "corrected") else 0
weight = _decay(date, now, half_life_days) if sign else 0.0
for target in (overall, bucket):
target["counts"][outcome if outcome in OUTCOMES else "unmarked"] += 1
if sign:
for n in nodes:
_record_node(target, n, sign, weight, date,
outcome=outcome, question=doc.get("question", ""))
if outcome == "dead_end":
target["dead_ends"].append(
{"question": doc.get("question", ""), "nodes": nodes, "date": date})
elif outcome == "corrected":
target["corrections"].append(
{"question": doc.get("question", ""),
"correction": doc.get("correction", ""), "date": date})
# Only surface per-community grouping when a graph was actually supplied;
# without one every doc falls into Uncategorized and the section would just
# duplicate the flat "Lessons" block.
community_out: dict[str, dict[str, Any]] = {}
if node_community:
community_out = {
label: {"counts": b["counts"], **_finalize_sources(b, min_corroboration),
"dead_ends": _dedupe_by_question(b["dead_ends"]),
"corrections": _dedupe_by_question(b["corrections"])}
for label, b in by_community.items()
}
return {
"total": len(docs),
"counts": overall["counts"],
"min_corroboration": min_corroboration,
**_finalize_sources(overall, min_corroboration),
"dead_ends": _dedupe_by_question(overall["dead_ends"]),
"corrections": _dedupe_by_question(overall["corrections"]),
"by_community": community_out,
# Private: per-node (date, question, outcome) trail for the sidecar
# overlay's provenance. Underscore-prefixed and not rendered by
# render_lessons_md, so the public aggregate shape is unchanged.
"_node_provenance": overall["node_provenance"],
}
# --- rendering -----------------------------------------------------------------
def _render_bucket(out: list[str], data: dict[str, Any], k: int) -> None:
preferred = data["preferred"]
tentative = data["tentative"]
contested = data["contested"]
dead_ends = data["dead_ends"]
corrections = data["corrections"]
if preferred:
out += [f"**Preferred sources** — corroborated by ≥{k} useful results; "
"start here.", ""]
for e in preferred:
out.append(f"- `{e['node']}` ({e['n']}× useful)")
out.append("")
if tentative:
out += [f"**Tentative** — useful in fewer than {k} results; verify before "
"relying.", ""]
for e in tentative:
out.append(f"- `{e['node']}` ({e['n']}× useful)")
out.append("")
if contested:
out += ["**Contested** — mixed signals; recency decides.", ""]
for e in contested:
day = e["last"][:10]
verdict = ("evenly split" if e["verdict"] == "even"
else f"recency leans **{e['verdict']}**")
out.append(
f"- `{e['node']}` — {e['pos']}× useful, {e['neg']}× "
f"dead end/corrected → {verdict}"
+ (f" (latest {day})" if day else ""))
out.append("")
if dead_ends:
out += ["**Known dead ends** — led nowhere; don't re-derive.", ""]
for d in dead_ends:
nodes = ", ".join(f"`{n}`" for n in d["nodes"])
out.append(f"- \"{d['question']}\"" + (f"{nodes}" if nodes else ""))
out.append("")
if corrections:
out += ["**Corrections** — do these differently.", ""]
for c in corrections:
out.append(f"- \"{c['question']}\"{c['correction']}")
out.append("")
if not (preferred or tentative or contested or dead_ends or corrections):
out += ["_No marked outcomes yet._", ""]
def render_lessons_md(agg: dict[str, Any]) -> str:
"""Render the aggregate into the deterministic LESSONS.md markdown body."""
c = agg["counts"]
k = agg.get("min_corroboration", _DEFAULT_MIN_CORROBORATION)
out: list[str] = [
"# Lessons",
"",
f"_Auto-generated by `graphify reflect` from {agg['total']} session "
f"{'memory' if agg['total'] == 1 else 'memories'} in graphify-out/memory/. "
"Deterministic; no LLM. Use for orientation — verify before relying, and "
"revisit dead ends if the code has changed since._",
"",
"## Summary",
"",
f"- {c['useful']} useful · {c['dead_end']} dead ends · "
f"{c['corrected']} corrected · {c['unmarked']} unmarked",
"",
"## Lessons",
"",
]
_render_bucket(out, agg, k)
if agg["by_community"]:
out += ["## By topic", ""]
# Uncategorized sorts last; everything else alphabetically.
def _topic_key(label: str) -> tuple[int, str]:
return (1 if label == _UNCATEGORIZED else 0, label)
for label in sorted(agg["by_community"], key=_topic_key):
out += [f"### {label}", ""]
_render_bucket(out, agg["by_community"][label], k)
# Single trailing newline, no trailing whitespace lines.
return "\n".join(out).rstrip("\n") + "\n"
# --- orchestrator --------------------------------------------------------------
def lessons_fresh(out_path: Path, memory_dir: Path,
graph_path: Path | None = None,
analysis_path: Path | None = None,
labels_path: Path | None = None) -> bool:
"""True if ``out_path`` exists and is at least as new as every input that
feeds it (the memory docs, and the graph/sidecars when one is used).
Lets ``graphify reflect --if-stale`` skip a redundant run e.g. when the git
post-commit hook just regenerated ``LESSONS.md`` and an agent then runs reflect
again at the start of a session. A missing output is never fresh (it must be
built). Mtime-based and best-effort; it only gates whether to *recompute*, not
what the recomputation produces (that stays deterministic).
"""
out_path = Path(out_path)
try:
out_mtime = out_path.stat().st_mtime
except OSError:
return False # missing/unreadable -> must build
newest = 0.0
md = Path(memory_dir)
if md.is_dir():
for f in md.glob("*.md"):
try:
newest = max(newest, f.stat().st_mtime)
except OSError:
pass
for input_path in (graph_path, analysis_path, labels_path):
if input_path is None:
continue
gp = Path(input_path)
try:
newest = max(newest, gp.stat().st_mtime)
except OSError:
pass
return out_mtime >= newest
def reflect(memory_dir: Path, out_path: Path,
graph_path: Path | None = None,
analysis_path: Path | None = None,
labels_path: Path | None = None,
*,
now: datetime | None = None,
half_life_days: float = _DEFAULT_HALF_LIFE_DAYS,
min_corroboration: int = _DEFAULT_MIN_CORROBORATION,
) -> tuple[Path, dict[str, Any]]:
"""Scan ``memory_dir``, write the lessons doc to ``out_path``, return (path, agg).
If ``graph_path`` is given lessons are grouped by community and source nodes no
longer in the graph are dropped; otherwise the doc is a single flat section.
"""
docs = load_memory_docs(memory_dir)
node_community = None
known_nodes = None
if graph_path is not None:
graph_path = Path(graph_path)
analysis_path = Path(analysis_path) if analysis_path else (
graph_path.parent / ".graphify_analysis.json")
labels_path = Path(labels_path) if labels_path else (
graph_path.parent / ".graphify_labels.json")
node_community = _load_node_community(graph_path, analysis_path, labels_path)
known_nodes = _load_known_nodes(graph_path)
if now is None:
now = datetime.now(timezone.utc)
agg = aggregate_lessons(docs, node_community, now=now,
half_life_days=half_life_days,
min_corroboration=min_corroboration,
known_nodes=known_nodes)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(render_lessons_md(agg), encoding="utf-8")
# Also project a derived experiential sidecar next to graph.json when a graph
# is in hand. Best-effort: a sidecar failure must never break LESSONS.md.
if graph_path is not None:
try:
write_learning_sidecar(agg, Path(graph_path), now=now)
except Exception:
pass
return out_path, agg
# --- work-memory overlay sidecar ------------------------------------------------
#
# A derived, experiential projection of the reflect aggregate, written next to
# graph.json as ``.graphify_learning.json``. It carries which nodes have proven
# preferred/tentative/contested, a code fingerprint for staleness detection, and
# a short provenance trail. graph.json (durable structural truth) is never
# touched — read surfaces merge this overlay in only at display time.
def _build_id_label_maps(graph_path: Path) -> tuple[dict[str, str], dict[str, list[str]],
dict[str, dict[str, Any]]]:
"""From graph.json build:
- ``id_set``: id -> id (every node id, so an id-form citation resolves to itself)
- ``label_to_ids``: label -> [ids] (so a label-form citation can be resolved,
and ambiguity one label, many ids can be detected and skipped)
- ``node_by_id``: id -> node dict (for source_file lookup)
Best-effort; an unreadable/garbage graph yields empty maps.
"""
id_set: dict[str, str] = {}
label_to_ids: dict[str, list[str]] = {}
node_by_id: dict[str, dict[str, Any]] = {}
try:
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return id_set, label_to_ids, node_by_id
for n in data.get("nodes", []):
if not isinstance(n, dict) or n.get("id") is None:
continue
nid = str(n["id"])
id_set[nid] = nid
node_by_id[nid] = n
label = n.get("label")
if label is not None:
label_to_ids.setdefault(str(label), []).append(nid)
return id_set, label_to_ids, node_by_id
def _resolve_canonical_id(cited: str, id_set: dict[str, str],
label_to_ids: dict[str, list[str]]) -> str | None:
"""Resolve a cited node (a label OR an id) to a single canonical node id.
Returns None if the citation is unresolved (stale gone from the graph) or
ambiguous (a label shared by >1 node id). Such citations can't be displayed
against a single node, so the caller skips them.
"""
if cited in id_set:
return id_set[cited]
ids = label_to_ids.get(cited)
if ids and len(ids) == 1:
return ids[0]
return None
def _resolve_source_path(src: str, graph_path: Path) -> Path | None:
"""Locate a node's ``source_file`` on disk, returning an existing file or None.
``source_file`` is stored relative to the PROJECT root, but graph.json may
live in ``<root>/graphify-out/`` (so its own dir is not the root) or directly
at the root (``extract --out .``). Resolve the root in the most-likely order
and return the first candidate where the file actually exists, so a defeated
heuristic or a stale marker can never strand the file (every node would then
look "changed"). The same search runs at write and read time, so the writer
and reader resolve to the same file.
Order: the committed ``.graphify_root`` marker (#686/#1423 — authoritative for
an absolute/elsewhere ``GRAPHIFY_OUT`` override); then the layout-appropriate
root *first* graph.json's parent's parent for the ``graphify-out`` layout,
or graph.json's own dir for a flat layout — which avoids matching a same-named
file one directory up; then the other of the two; then the cwd.
"""
if not src:
return None
p = Path(src)
if p.is_absolute():
return p if p.is_file() else None
gp = Path(graph_path)
out_dir = gp.parent
candidates: list[Path] = []
try:
recorded = (out_dir / ".graphify_root").read_text(encoding="utf-8").strip()
if recorded:
candidates.append(Path(recorded))
except (OSError, ValueError):
pass # unreadable/non-UTF-8 marker -> fall through (best-effort)
# Layout-appropriate root first (precision), then the other (robustness).
if out_dir.name == GRAPHIFY_OUT_NAME:
candidates += [out_dir.parent, out_dir]
else:
candidates += [out_dir, out_dir.parent]
candidates.append(Path("."))
seen: set[str] = set()
for base in candidates:
key = str(base)
if key in seen:
continue
seen.add(key)
cand = base / p
if cand.is_file():
return cand
return None
def _content_hash(path: Path) -> str:
"""SHA256 of file CONTENT only (no path mixed in), so the fingerprint is
independent of which root resolved the file write and read agree, and a
committed sidecar stays valid across machines/checkouts."""
import hashlib
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return ""
def _code_fingerprint(node: dict[str, Any] | None, graph_path: Path) -> str:
"""Content hash of the node's ``source_file``, or '' if unavailable.
Coarse on purpose a file-level hash over-flags (any edit to the file marks
every node in it stale) rather than under-flags, which is the safe direction
for a "re-verify" hint.
"""
if not node:
return ""
sp = _resolve_source_path(node.get("source_file") or "", graph_path)
return _content_hash(sp) if sp is not None else ""
def _provenance_for(node: str, prov_map: dict[str, list],
fallback_outcome: str) -> list[dict[str, str]]:
"""Most-recent-first, capped provenance entries for a node.
``prov_map`` is the aggregate's private per-node (date, question, outcome)
trail. ``fallback_outcome`` covers an entry with no recorded trail (shouldn't
happen for preferred/tentative/contested, which all have 1 positive event).
"""
events = prov_map.get(node, [])
# Sort recent-first; (date desc, then question for a stable tiebreak).
ordered = sorted(events, key=lambda e: (e[0], e[1]), reverse=True)
out: list[dict[str, str]] = []
for date, question, outcome in ordered[:_PROVENANCE_CAP]:
out.append({"q": question, "date": date, "outcome": outcome})
return out
def build_learning_overlay(agg: dict[str, Any], graph_path: Path,
*, now: datetime | None = None) -> dict[str, Any]:
"""Project the reflect aggregate into the sidecar's ``{version, generated_at,
nodes}`` structure, keyed by canonical node id.
Built from preferred + tentative + contested (NOT dead_ends those stay
query-scoped, surfaced only in the report). Citations that don't resolve to
exactly one node id are skipped.
"""
if now is None:
now = datetime.now(timezone.utc)
elif now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
graph_path = Path(graph_path)
id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path)
prov_map = agg.get("_node_provenance", {})
# id -> entry; a canonical id can be cited under both its id and label form,
# but the aggregate dedups per node string, so collisions here are benign and
# resolved deterministically by iteration order (preferred, tentative, contested).
nodes_out: dict[str, dict[str, Any]] = {}
def _add(entry_src: dict[str, Any], status: str) -> None:
cited = entry_src["node"]
cid = _resolve_canonical_id(cited, id_set, label_to_ids)
if cid is None:
return # ambiguous or stale — can't display against a single node
if cid in nodes_out:
return # first status wins (preferred > tentative > contested order)
node = node_by_id.get(cid)
out: dict[str, Any] = {
"status": status,
"score": entry_src["score"],
"uses": entry_src.get("n", entry_src.get("pos", 0)),
"last": entry_src.get("last", ""),
"label": str(node.get("label", cited)) if node else str(cited),
"source_file": str(node.get("source_file") or "") if node else "",
"code_fingerprint": _code_fingerprint(node, graph_path),
"provenance": _provenance_for(cited, prov_map, status),
}
if status == "contested":
out["verdict"] = entry_src.get("verdict", "even")
out["neg"] = entry_src.get("neg", 0)
else:
# preferred/tentative carry no contested verdict; derive `last` from
# provenance if the finalizer didn't (positive-only buckets do track it
# via node_last for contested only).
if not out["last"] and out["provenance"]:
out["last"] = out["provenance"][0]["date"]
nodes_out[cid] = out
for e in agg.get("preferred", []):
_add(e, "preferred")
for e in agg.get("tentative", []):
_add(e, "tentative")
for e in agg.get("contested", []):
_add(e, "contested")
return {
"version": _LEARNING_SCHEMA_VERSION,
"generated_at": now.isoformat(),
"nodes": nodes_out,
}
def write_learning_sidecar(agg: dict[str, Any], graph_path: Path,
*, now: datetime | None = None) -> Path:
"""Write ``.graphify_learning.json`` next to ``graph_path`` deterministically.
Sorted keys + indent=2 so re-runs on identical input (and a fixed ``now``)
are byte-identical. Returns the sidecar path.
"""
overlay = build_learning_overlay(agg, graph_path, now=now)
sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME
sidecar.write_text(
json.dumps(overlay, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return sidecar
def load_learning_overlay(graph_path: Path) -> dict[str, dict[str, Any]]:
"""Load the sidecar next to ``graph_path`` and return ``{node_id -> entry}``
with a recomputed ``stale: bool`` per entry. Best-effort -> {} on any error.
Staleness: recompute ``file_hash(source_file)`` and compare to the entry's
stored ``code_fingerprint``. Matching fingerprints -> not stale. Differing,
missing-but-recomputable, or a vanished file -> stale (the safe, over-flagging
direction). An entry with no stored fingerprint AND no current file is not
marked stale (nothing to re-verify).
"""
sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME
try:
data = json.loads(sidecar.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
nodes = data.get("nodes")
if not isinstance(nodes, dict):
return {}
out: dict[str, dict[str, Any]] = {}
for nid, entry in nodes.items():
if not isinstance(entry, dict):
continue
merged = dict(entry)
merged["stale"] = _is_stale(entry, graph_path)
out[str(nid)] = merged
return out
def _is_stale(entry: dict[str, Any], graph_path: Path) -> bool:
"""True if the node's source file changed (or vanished) since the fingerprint
was taken. Uses the same file resolution + content hash as the writer, so a
freshly-written verdict on unchanged code is never spuriously stale."""
src = entry.get("source_file", "")
if not src:
# No file to track — nothing to re-verify.
return False
sp = _resolve_source_path(src, graph_path)
if sp is None:
return True # file gone / unfindable — re-verify
stored = entry.get("code_fingerprint", "")
if not stored:
return True # had a file but never fingerprinted it -> can't trust -> stale
return _content_hash(sp) != stored
+300
View File
@@ -0,0 +1,300 @@
# generate GRAPH_REPORT.md - the human-readable audit trail
from __future__ import annotations
import re
from datetime import date
import networkx as nx
def _safe_community_name(label: str) -> str:
"""Mirrors export.safe_name so community hub filenames and report wikilinks always agree."""
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
cleaned = re.sub(r"\.(md|mdx|markdown)$", "", cleaned, flags=re.IGNORECASE)
return cleaned or "unnamed"
def load_learning_for_report(graph_path) -> dict | None:
"""Assemble the report's work-memory inputs from sibling artifacts.
Reads the ``.graphify_learning.json`` overlay (preferred sources) next to
``graph_path`` and re-aggregates the memory docs for the query-scoped
dead-ends. Best-effort: returns None if neither is available, so the report
simply omits the section. Never raises.
"""
from pathlib import Path as _Path
try:
gp = _Path(graph_path)
from graphify.reflect import load_learning_overlay, load_memory_docs, aggregate_lessons
overlay = load_learning_overlay(gp)
dead_ends: list[dict] = []
mem = gp.parent / "memory"
if mem.is_dir():
agg = aggregate_lessons(load_memory_docs(mem))
dead_ends = agg.get("dead_ends", [])
if not overlay and not dead_ends:
return None
return {"overlay": overlay, "dead_ends": dead_ends}
except Exception:
return None
def _learning_section(lines: list, learning: dict | None, top_n: int = 10) -> None:
"""Append the ``## Work-memory lessons`` section, or nothing when empty."""
if not learning:
return
overlay = learning.get("overlay") or {}
dead_ends = learning.get("dead_ends") or []
preferred = [
(nid, e) for nid, e in overlay.items()
if isinstance(e, dict) and e.get("status") == "preferred"
]
# Most-corroborated first (uses desc), then by score, then id for stability.
preferred.sort(key=lambda kv: (-kv[1].get("uses", 0),
-float(kv[1].get("score", 0) or 0), kv[0]))
if not preferred and not dead_ends:
return
lines += ["", "## Work-memory lessons"]
if preferred:
lines += ["", "**Preferred sources** — corroborated by past sessions; start here."]
for nid, e in preferred[:top_n]:
label = e.get("label") or nid
stale = " _(code changed — re-verify)_" if e.get("stale") else ""
lines.append(f"- `{label}` ({e.get('uses', 0)}× useful, "
f"score={e.get('score', 0)}){stale}")
if dead_ends:
lines += ["", "**Known dead ends** — questions that led nowhere; don't re-derive."]
for d in dead_ends:
nodes = ", ".join(f"`{n}`" for n in d.get("nodes", []))
lines.append(f"- \"{d.get('question', '')}\""
+ (f" -> {nodes}" if nodes else ""))
def generate(
G: nx.Graph,
communities: dict[int, list[str]],
cohesion_scores: dict[int, float],
community_labels: dict[int, str],
god_node_list: list[dict],
surprise_list: list[dict],
detection_result: dict,
token_cost: dict,
root: str,
suggested_questions: list[dict] | None = None,
min_community_size: int = 3,
built_at_commit: str | None = None,
learning: dict | None = None,
obsidian: bool = False,
) -> str:
today = date.today().isoformat()
# JSON deserialization produces string keys; normalize to int so .get(cid) works.
if community_labels:
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
confidences = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
total = len(confidences) or 1
ext_pct = round(confidences.count("EXTRACTED") / total * 100)
inf_pct = round(confidences.count("INFERRED") / total * 100)
amb_pct = round(confidences.count("AMBIGUOUS") / total * 100)
inf_edges = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "INFERRED"]
inf_scores = [d.get("confidence_score", 0.5) for _, _, d in inf_edges]
inf_avg = round(sum(inf_scores) / len(inf_scores), 2) if inf_scores else None
lines = [
f"# Graph Report - {root} ({today})",
"",
"## Corpus Check",
]
if detection_result.get("warning"):
lines.append(f"- {detection_result['warning']}")
else:
lines += [
f"- {detection_result['total_files']} files · ~{detection_result['total_words']:,} words",
"- Verdict: corpus is large enough that graph structure adds value.",
]
from .analyze import _is_file_node as _ifn
non_empty = {cid: nodes for cid, nodes in communities.items()
if any(not _ifn(G, n) for n in nodes)}
thin_count_summary = sum(
1 for nodes in communities.values()
if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size
)
shown_count = len(communities) - thin_count_summary
lines += [
"",
"## Summary",
f"- {G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
+ (f" ({shown_count} shown, {thin_count_summary} thin omitted)" if thin_count_summary else ""),
f"- Extraction: {ext_pct}% EXTRACTED · {inf_pct}% INFERRED · {amb_pct}% AMBIGUOUS"
+ (f" · INFERRED: {len(inf_edges)} edges (avg confidence: {inf_avg})" if inf_avg is not None else ""),
f"- Token cost: {token_cost.get('input', 0):,} input · {token_cost.get('output', 0):,} output",
]
if built_at_commit:
lines += [
"",
"## Graph Freshness",
f"- Built from commit: `{built_at_commit[:8]}`",
"- Run `git rev-parse HEAD` and compare to check if the graph is stale.",
"- Run `graphify update .` after code changes (no API cost).",
]
# Community hub navigation. The `_COMMUNITY_*.md` notes these wikilinks target
# are only created by the opt-in `--obsidian` export, and the report is written
# at build time (before any export runs), so emitting wikilinks by default left
# every link dangling — polluting an Obsidian vault's graph view and rendering as
# literal brackets everywhere else (#1712). Emit wikilinks only when the caller
# signals Obsidian output; otherwise a plain list, which navigates nowhere-to-break.
if non_empty:
lines += ["", "## Community Hubs (Navigation)"]
for cid in non_empty:
label = community_labels.get(cid, f"Community {cid}")
if obsidian:
safe = _safe_community_name(label)
lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
else:
lines.append(f"- {label}")
lines += [
"",
"## God Nodes (most connected - your core abstractions)",
]
for i, node in enumerate(god_node_list, 1):
lines.append(f"{i}. `{node['label']}` - {node['degree']} edges")
lines += ["", "## Surprising Connections (you probably didn't know these)"]
if surprise_list:
for s in surprise_list:
relation = s.get("relation", "related_to")
note = s.get("note", "")
files = s.get("source_files", ["", ""])
conf = s.get("confidence", "EXTRACTED")
cscore = s.get("confidence_score")
if conf == "INFERRED" and cscore is not None:
conf_tag = f"INFERRED {cscore:.2f}"
else:
conf_tag = conf
sem_tag = " [semantically similar]" if relation == "semantically_similar_to" else ""
lines += [
f"- `{s['source']}` --{relation}--> `{s['target']}` [{conf_tag}]{sem_tag}",
f" {files[0]}{files[1]}" + (f" _{note}_" if note else ""),
]
else:
lines.append("- None detected - all connections are within the same source files.")
# Circular imports surfaced from file-level dependency graph. Only meaningful
# for code — a documents-only corpus has no imports, so the section is pure
# noise there ("None detected" on every run). Emit it only when the graph
# actually contains code (#1657).
_has_code = any(
d.get("file_type") == "code" for _, d in G.nodes(data=True)
) or any(
d.get("relation") in ("imports", "imports_from")
for *_e, d in G.edges(data=True)
)
if _has_code:
from .analyze import find_import_cycles
cycles = find_import_cycles(G)
lines += ["", "## Import Cycles"]
if cycles:
for c in cycles:
cycle = c.get("cycle", [])
length = c.get("length", len(cycle))
if not cycle:
continue
cycle_path = " -> ".join(cycle + [cycle[0]])
lines.append(f"- {length}-file cycle: `{cycle_path}`")
else:
lines.append("- None detected.")
hyperedges = G.graph.get("hyperedges", [])
if hyperedges:
lines += ["", "## Hyperedges (group relationships)"]
for h in hyperedges:
node_labels = ", ".join(h.get("nodes", []))
conf = h.get("confidence", "INFERRED")
cscore = h.get("confidence_score")
conf_tag = f"{conf} {cscore:.2f}" if cscore is not None else conf
lines.append(f"- **{h.get('label', h.get('id', ''))}** — {node_labels} [{conf_tag}]")
lines += ["", f"## Communities ({len(communities)} total, {thin_count_summary} thin omitted)"]
for cid, nodes in communities.items():
label = community_labels.get(cid, f"Community {cid}")
score = cohesion_scores.get(cid, 0.0)
# Filter method/function stubs from display - they're structural noise
real_nodes = [n for n in nodes if not _ifn(G, n)]
if not real_nodes:
continue
if len(real_nodes) < min_community_size:
continue
display = [G.nodes[n].get("label", n) for n in real_nodes[:8]]
suffix = f" (+{len(real_nodes)-8} more)" if len(real_nodes) > 8 else ""
lines += [
"",
f"### Community {cid} - \"{label}\"",
f"Cohesion: {score:.2f}",
f"Nodes ({len(real_nodes)}): {', '.join(display)}{suffix}",
]
ambiguous = [(u, v, d) for u, v, d in G.edges(data=True) if d.get("confidence") == "AMBIGUOUS"]
if ambiguous:
lines += ["", "## Ambiguous Edges - Review These"]
for u, v, d in ambiguous:
ul = G.nodes[u].get("label", u)
vl = G.nodes[v].get("label", v)
lines += [
f"- `{ul}` → `{vl}` [AMBIGUOUS]",
f" {d.get('source_file', '')} · relation: {d.get('relation', 'unknown')}",
]
# --- Gaps section ---
from .analyze import _is_file_node, _is_concept_node
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1
and not _is_file_node(G, n)
and not _is_concept_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
]
thin_communities = {
cid: nodes for cid, nodes in communities.items()
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3
}
gap_count = len(isolated) + len(thin_communities)
if gap_count > 0 or amb_pct > 20:
lines += ["", "## Knowledge Gaps"]
if isolated:
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
lines.append(" These have ≤1 connection - possible missing edges or undocumented components.")
if thin_communities:
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
if amb_pct > 20:
lines.append(f"- **High ambiguity: {amb_pct}% of edges are AMBIGUOUS.** Review the Ambiguous Edges section above.")
# --- Work-memory lessons (derived overlay) ---
# Preferred sources come from the .graphify_learning.json sidecar; the
# query-scoped dead-ends come from the reflect aggregate. Section omitted
# entirely when neither is present, so a graph with no work-memory is
# byte-identical to the pre-feature report.
_learning_section(lines, learning)
if suggested_questions:
lines += ["", "## Suggested Questions"]
no_signal = len(suggested_questions) == 1 and suggested_questions[0].get("type") == "no_signal"
if no_signal:
lines.append(f"_{suggested_questions[0]['why']}_")
else:
lines.append("_Questions this graph is uniquely positioned to answer:_")
lines.append("")
for q in suggested_questions:
if q.get("question"):
lines.append(f"- **{q['question']}**")
lines.append(f" _{q['why']}_")
return "\n".join(lines)
+85
View File
@@ -0,0 +1,85 @@
"""Registry for cross-file, language-specific resolution passes.
Some call/reference edges can only be resolved with language-specific knowledge
(receiver typing, qualified member calls, framework conventions). Historically
these ran as a hand-wired sequence of suffix-gated
``if <lang>_paths: try: _resolve_<lang>(...)`` blocks at the tail of
``extract.extract()``. That pattern is the de-facto extension point for
per-language resolution; this module formalizes it so a new language plugs in by
registering one ``LanguageResolver`` instead of editing ``extract()``'s body.
This module deliberately knows nothing about any specific language languages
register themselves (see ``extract.py``), keeping the dependency direction one
way (``extract`` ``resolver_registry``) and this seam small and reviewable on
its own, separate from the multi-thousand-line ``extract`` module.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class LanguageResolver:
"""One cross-file, language-specific resolution pass.
``resolve`` has the signature ``(per_file, all_nodes, all_edges) -> None`` and
mutates ``all_nodes`` / ``all_edges`` in place, matching the existing
member-call resolvers. ``suffixes`` gates activation: the pass runs only when
the corpus contains at least one file with one of these extensions.
"""
name: str
suffixes: frozenset
resolve: Callable
# Module-level registry, populated by callers via register(). Ordered: resolvers
# run in registration order, preserving any required sequencing between passes.
_REGISTRY: list[LanguageResolver] = []
def register(resolver: LanguageResolver) -> LanguageResolver:
"""Append a resolver to the global registry and return it (for inline use)."""
_REGISTRY.append(resolver)
return resolver
def registered_resolvers() -> list[LanguageResolver]:
"""Return a copy of the registered resolvers, in registration order."""
return list(_REGISTRY)
def run_language_resolvers(
paths: Sequence[Path],
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
*,
resolvers: Sequence[LanguageResolver] | None = None,
) -> None:
"""Run every resolver whose suffix appears in ``paths``.
Behaviorally identical to the prior hand-wired sequence of suffix-gated,
try/except-wrapped passes: same activation rule (suffix present), same
failure handling (log a warning and continue to the next pass), same
execution order (registration order).
``resolvers`` defaults to the global registry; tests pass an explicit list to
exercise the driver in isolation.
"""
active = _REGISTRY if resolvers is None else resolvers
suffixes_present = {p.suffix for p in paths}
for resolver in active:
if not (resolver.suffixes & suffixes_present):
continue
try:
resolver.resolve(per_file, all_nodes, all_edges)
except Exception as exc:
_LOG.warning("%s resolution failed, skipping: %s", resolver.name, exc)
+169
View File
@@ -0,0 +1,169 @@
"""Type-aware cross-file resolution for Ruby member calls.
Ruby has no type annotations and reuses method names heavily, so resolving
``obj.method()`` by globally-unique name is both lossy (drops on collision) and
unsafe (can attach to the wrong same-named method). This resolver instead uses
the receiver's *type*, inferred at extraction time from local
``var = ClassName.new`` bindings and carried on each member-call raw_call as
``receiver_type``.
It resolves two shapes, both at EXTRACTED (1.0) confidence and only when the
target is certain (single owning class, single owned method) bail otherwise:
* ``Processor.new`` -> a ``calls`` edge to the ``Processor`` class
* ``p.run`` where ``p`` is a ``Processor`` -> a ``calls`` edge to ``Processor#run``
Registered into graphify.resolver_registry and run by extract() after id
disambiguation, so node ids and raw_call caller_nids are final.
"""
from __future__ import annotations
import re
from typing import Any
def _key(label: str) -> str:
"""Normalize a class/method label to a comparison key (drop punctuation)."""
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
# A Ruby class/module container node is labelled with a bare constant
# (``Processor``, ``TaxCalculator``); methods end in ``()`` and files in ``.rb``.
# Lets us register method-less containers (a ``Class.new(StandardError)`` error
# class, an empty module) that have no `method` edge to be found by.
_BARE_CONST_RE = re.compile(r"^[A-Z][A-Za-z0-9_]*$")
def _ruby_raw_calls(per_file: list[dict]) -> list[dict]:
calls: list[dict] = []
for result in per_file:
if not isinstance(result, dict):
continue
for rc in result.get("raw_calls", []):
if not isinstance(rc, dict):
continue
sf = str(rc.get("source_file", ""))
if sf.endswith((".rb", ".rake")):
calls.append(rc)
return calls
def resolve_ruby_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Ruby ``Class.new`` and typed ``var.method`` calls by receiver type.
Purely additive: only emits edges the shared (name-based) call pass skips
because they are member calls. Each emission requires a single owning class
(god-node guard) so an ambiguous class name resolves to nothing rather than a
wrong edge.
"""
node_by_id: dict[str, dict] = {n.get("id"): n for n in all_nodes}
# class label key -> [class node ids]; (class_node_id, method_key) -> method id
class_def_nids: dict[str, list[str]] = {}
method_index: dict[tuple[str, str], str] = {}
for e in all_edges:
if e.get("relation") != "method":
continue
src, tgt = e.get("source"), e.get("target")
cnode = node_by_id.get(src)
if cnode is not None:
class_def_nids.setdefault(_key(cnode.get("label", "")), []).append(str(src))
tnode = node_by_id.get(tgt)
if tnode is not None:
method_index[(str(src), _key(tnode.get("label", "")))] = str(tgt)
# Also register class/module container nodes that own no `method` edge — a
# method-less `Class.new(StandardError)` or an empty module — so a constant
# receiver still resolves to a real node (#1640/#1634). External base stubs
# carry an empty source_file, so the `.rb` filter keeps them out.
for n in all_nodes:
nid = n.get("id")
sf = str(n.get("source_file", ""))
if nid and sf.endswith((".rb", ".rake")) and _BARE_CONST_RE.match(str(n.get("label", ""))):
class_def_nids.setdefault(_key(n.get("label", "")), []).append(str(nid))
for k in list(class_def_nids):
class_def_nids[k] = sorted(set(class_def_nids[k]))
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
def _unique_class(name: str) -> str | None:
nids = class_def_nids.get(_key(name), [])
return nids[0] if len(nids) == 1 else None
def _emit(caller: str, target: str, rc: dict[str, Any],
relation: str = "calls", context: str = "call") -> None:
if not caller or not target or caller == target:
return
if (caller, target) in existing_pairs:
return
existing_pairs.add((caller, target))
all_edges.append({
"source": caller,
"target": target,
"relation": relation,
"context": context,
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": rc.get("source_file", ""),
"source_location": rc.get("source_location"),
"weight": 1.0,
})
# `include`/`extend`/`prepend <Const>` mixins (#1668): resolve the module by
# its constant name to the single owning module/class node and emit a
# `mixes_in` edge, under the same single-definition god-node guard. An
# ambiguous or unresolved constant produces no edge.
for rc in _ruby_raw_calls(per_file):
if not rc.get("is_mixin"):
continue
caller = str(rc.get("caller_nid", ""))
module_name = rc.get("callee")
if not caller or not module_name:
continue
target = _unique_class(str(module_name))
if target is not None:
_emit(caller, target, rc, relation="mixes_in", context="mixin")
for rc in _ruby_raw_calls(per_file):
if not rc.get("is_member_call"):
continue
caller = str(rc.get("caller_nid", ""))
callee = rc.get("callee")
if not caller or not callee:
continue
# Constant receiver: `Processor.new` (instantiation) or `Service.call` /
# `Model.where` (singleton / class method). The bare method name would
# collide with unrelated same-named methods, so we resolve by the
# receiver's class under the single-owning-class god-node guard.
receiver = rc.get("receiver")
if receiver and str(receiver)[:1].isupper():
class_nid = _unique_class(str(receiver))
if class_nid is not None:
if callee == "new":
_emit(caller, class_nid, rc)
else:
# Emit to the singleton/instance method the class owns
# (`def self.call`, which the extractor indexes); otherwise
# to the class node itself, so inherited/dynamic class methods
# like ActiveRecord `where`/`find_by` still give correct
# blast-radius. An ambiguous receiver bails to nothing.
method_nid = method_index.get((class_nid, _key(str(callee))))
_emit(caller, method_nid or class_nid, rc)
continue
# `p.run` where p's type is known -> edge to that class's method.
receiver_type = rc.get("receiver_type")
if not receiver_type:
continue
class_nid = _unique_class(str(receiver_type))
if class_nid is None:
continue
method_nid = method_index.get((class_nid, _key(str(callee))))
if method_nid is None:
continue
_emit(caller, method_nid, rc)
+363
View File
@@ -0,0 +1,363 @@
"""scip_ingest.py — SCIP JSON ingestion (simplified subset).
Reads a simplified SCIP-style JSON structure and converts it into
Graphify nodes and edges. NOT a full SCIP protobuf implementation
this is a skeleton that consumes the simplified shape described below.
Not wired to the CLI in this phase.
Entry point:
ingest_scip_json(doc: object, source_file: str = "",
language: str = "python") -> dict[str, Any]
Returns {"nodes": [...], "edges": [...]} compatible with Graphify's
extraction result format. All edges emitted are endpoint-safe the
function builds a symbol node_id index in a first pass and either
resolves relationship targets via that index or creates a stub
external node so `build_from_json()` will keep the edge.
Supported (simplified) JSON shape:
documents[]: { relative_path, language, symbols[] }
symbols[]: { symbol, kind, display_name, documentation[],
relationships[], occurrences[] }
relationships[]: { symbol, is_reference, is_implementation,
is_type_definition, is_definition }
occurrences[]: { range[], symbol, symbol_roles }
This shape diverges from the official SCIP protobuf (where occurrences
live on the document, not on each symbol). We consume the simplified
shape that LLM-generated SCIP-style JSON commonly produces. Future
cycles may add document-level occurrence support.
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from graphify.security import sanitize_metadata
def ingest_scip_json(
doc: object,
source_file: str = "",
language: str = "python",
) -> dict[str, Any]:
"""Convert a SCIP-style JSON document into Graphify nodes and edges.
Parameter ``doc`` is ``object`` (not ``dict[str, Any]``) because SCIP
documents come from external tools we may be handed arbitrary
deserialized JSON. The first check rejects anything that isn't a dict
and returns the empty result.
Two-pass design:
1. Build a ``symbol_str node_id`` index across every valid symbol
in every valid document, plus collect per-symbol metadata.
2. Emit nodes for every indexed symbol and then emit relationship
edges. Relationship targets are resolved via the index when
present; otherwise a stub ``scip_external`` node is added so
edges never dangle.
"""
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
seen_node_ids: set[str] = set()
seen_edges: set[tuple[str, str, str, str | None]] = set()
if not isinstance(doc, dict):
return {"nodes": nodes, "edges": edges}
documents = doc.get("documents", [])
if not isinstance(documents, list):
return {"nodes": nodes, "edges": edges}
# ---- pass 1: build symbol → node_id indices -----------------------------
# Two indices so relationship resolution can be document-aware:
# per_doc: (symbol_id, doc_path) → node_id (same-document precedence)
# global: symbol_id → list[node_id] (cross-document fallback,
# used only when unambiguous)
per_doc_index: dict[tuple[str, str], str] = {}
global_index: dict[str, list[str]] = {}
# Per-symbol metadata kept for pass-2 node emission (avoids re-walking
# the document tree).
symbol_records: list[dict[str, Any]] = []
for document in documents:
if not isinstance(document, dict):
continue
doc_path = _coerce_str(document.get("relative_path"), source_file)
doc_language = _coerce_str(document.get("language"), language)
symbols = document.get("symbols", [])
if not isinstance(symbols, list):
continue
for symbol in symbols:
if not isinstance(symbol, dict):
continue
symbol_id = _coerce_str(symbol.get("symbol"), "")
if not symbol_id:
continue
node_id = _make_scip_node_id(symbol_id, doc_path)
per_doc_index.setdefault((symbol_id, doc_path), node_id)
# Dedupe node_ids in the global index — duplicate symbol records
# within the SAME document produce identical node_ids, and we
# don't want them to look like cross-document ambiguity.
candidates = global_index.setdefault(symbol_id, [])
if node_id not in candidates:
candidates.append(node_id)
symbol_records.append(
{
"node_id": node_id,
"symbol_id": symbol_id,
"doc_path": doc_path,
"language": doc_language,
"raw": symbol,
}
)
# ---- pass 2: emit nodes + relationship edges -----------------------------
for record in symbol_records:
_emit_symbol_node(record, nodes, seen_node_ids)
_emit_relationships(
record,
per_doc_index,
global_index,
nodes,
edges,
seen_node_ids,
seen_edges,
)
return {"nodes": nodes, "edges": edges}
def _emit_symbol_node(
record: dict[str, Any],
nodes: list[dict[str, Any]],
seen_node_ids: set[str],
) -> None:
"""Append the canonical node for a SCIP symbol record."""
node_id = record["node_id"]
if node_id in seen_node_ids:
return
raw = record["raw"]
symbol_id = record["symbol_id"]
doc_path = record["doc_path"]
kind = _coerce_str(raw.get("kind"), "unknown")
display_name = _coerce_str(raw.get("display_name"), "")
documentation = raw.get("documentation", [])
description = ""
if isinstance(documentation, list) and documentation:
first = documentation[0]
if isinstance(first, str):
description = first
occurrences = raw.get("occurrences", [])
sourceline = _first_occurrence_line(occurrences)
suffix = symbol_id.split("#")[-1] if "#" in symbol_id else symbol_id
label = display_name or suffix or symbol_id
seen_node_ids.add(node_id) # label uses display_name or suffix (never empty for valid symbols)
nodes.append(
{
"id": node_id,
"label": label,
"file_type": _scip_kind_to_file_type(kind),
"source_file": doc_path,
"source_location": f"L{sourceline}" if sourceline else "",
"metadata": sanitize_metadata(_build_scip_metadata(symbol_id, kind, description)),
}
)
def _emit_relationships(
record: dict[str, Any],
per_doc_index: dict[tuple[str, str], str],
global_index: dict[str, list[str]],
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
seen_node_ids: set[str],
seen_edges: set[tuple[str, str, str, str | None]],
) -> None:
"""Append edges (and stub nodes when needed) for a symbol's relationships.
Relationship target resolution order:
1. Same-document `(target_symbol, doc_path)` duplicate local symbol
names across files route to THIS file's symbol, not another's.
2. Unique cross-document match when the symbol exists in exactly
one document and that document is different from the source.
3. Stub external node for symbols not declared in any document
OR ambiguous duplicates across multiple documents (refusing to
guess silently).
"""
raw = record["raw"]
source_node_id = record["node_id"]
doc_path = record["doc_path"]
occurrences = raw.get("occurrences", [])
sourceline = _first_occurrence_line(occurrences)
relationships = raw.get("relationships")
if not isinstance(relationships, list):
return
for rel in relationships:
if not isinstance(rel, dict):
continue
target_symbol = _coerce_str(rel.get("symbol"), "")
if not target_symbol:
continue
target_node_id = _resolve_relationship_target(
target_symbol,
doc_path,
per_doc_index,
global_index,
)
if target_node_id is None:
# External relationship target: emit a stub node so the edge
# is never dangling. The stub uses the source document's path
# as its host context.
target_node_id = _make_scip_node_id(target_symbol, doc_path)
if target_node_id not in seen_node_ids:
seen_node_ids.add(target_node_id)
suffix = target_symbol.split("#")[-1] if "#" in target_symbol else target_symbol
nodes.append(
{
"id": target_node_id,
"label": suffix or target_symbol,
"file_type": "code",
"source_file": doc_path,
"source_location": "",
"metadata": sanitize_metadata(
_build_scip_metadata(target_symbol, "external", "")
),
}
)
relation = _scip_relation_for(rel)
source_location = f"L{sourceline}" if sourceline else ""
key = (source_node_id, target_node_id, relation, source_location)
if key in seen_edges:
continue
seen_edges.add(key)
edges.append(
{
"source": source_node_id,
"target": target_node_id,
"relation": relation,
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"source_file": doc_path,
"source_location": source_location,
"weight": 1.0,
"context": "scip",
"metadata": sanitize_metadata({"scip_relationship": rel}),
}
)
def _resolve_relationship_target(
target_symbol: str,
source_doc_path: str,
per_doc_index: dict[tuple[str, str], str],
global_index: dict[str, list[str]],
) -> str | None:
"""Resolve a SCIP relationship target to an emitted node id, or None.
Resolution order:
1. Same-document match `(target_symbol, source_doc_path)`.
2. Unique cross-document match exactly one node id in the global
index for this symbol AND it isn't the same document we already
tried.
3. None symbol is either absent globally OR ambiguous (defined in
multiple documents). The caller emits a stub external node.
"""
same_doc = per_doc_index.get((target_symbol, source_doc_path))
if same_doc is not None:
return same_doc
candidates = global_index.get(target_symbol, [])
if len(candidates) == 1:
return candidates[0]
return None
def _is_true(value: object) -> bool:
"""Return True only when value is exactly the boolean True.
Used for SCIP relationship flags. Truthy strings like ``"false"`` are
common in untrusted external JSON and must NOT count as a set flag.
"""
return value is True
def _scip_relation_for(rel: dict[str, Any]) -> str:
"""Pick the Graphify relation tag for a SCIP relationship dict.
Flags are accepted only when the value is exactly ``True`` protects
against truthy-but-misleading values like ``"false"`` in external JSON.
"""
if _is_true(rel.get("is_implementation")):
return "scip_impl"
if _is_true(rel.get("is_type_definition")):
return "scip_typed"
if _is_true(rel.get("is_definition")):
return "scip_def"
return "scip_ref"
def _first_occurrence_line(occurrences: object) -> int:
"""Read the 1-based line number from the first occurrence range, defensively.
Note: ``bool`` is a subclass of ``int`` in Python ``isinstance(True, int)``
is True. We explicitly exclude booleans so a malformed ``range: [True, ]``
cannot produce ``source_location = "LTrue"``.
"""
if not isinstance(occurrences, list) or not occurrences:
return 0
first = occurrences[0]
if not isinstance(first, dict):
return 0
rng = first.get("range", [])
if not isinstance(rng, list) or len(rng) < 1:
return 0
line = rng[0]
if isinstance(line, bool) or not isinstance(line, int) or line < 0:
return 0
return line
def _coerce_str(value: object, default: str) -> str:
"""Return ``value`` if it is a string, else the ``default`` (also a string)."""
if isinstance(value, str):
return value
if isinstance(default, str):
return default
return ""
def _make_scip_node_id(symbol: str, source_file: str) -> str:
"""Derive a stable Graphify node ID from a SCIP symbol identifier.
Uses SHA-1 truncated to 12 hex chars (48 bits). This is an identifier,
not a security boundary collision risk is acceptable at this scale
given the per-document scoping prefix.
"""
raw = f"{source_file}:{symbol}"
h = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()[:12]
parts = symbol.split("#")
suffix = parts[-1] if parts else symbol
suffix = re.sub(r"[^a-zA-Z0-9_]", "_", suffix).strip("_").lower()
if suffix:
return f"scip_{suffix}_{h}"
return f"scip_{h}"
def _scip_kind_to_file_type(kind: str) -> str:
"""Map SCIP symbol kind to a Graphify file_type."""
# All SCIP symbols are code entities (functions, methods, classes, …);
# the `kind` is preserved in metadata for downstream consumers.
_ = kind # acknowledged but not currently used for file_type routing
return "code"
def _build_scip_metadata(symbol_id: str, kind: str, description: str) -> dict[str, str]:
"""Build metadata for a SCIP node."""
meta: dict[str, str] = {
"scip_symbol": symbol_id,
"scip_kind": kind,
}
if description:
meta["scip_description"] = description
return meta
+460
View File
@@ -0,0 +1,460 @@
# Security helpers - URL validation, safe fetch, path guards, label sanitisation
from __future__ import annotations
import html
import http.client
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import ipaddress
import socket
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME
_ALLOWED_SCHEMES = {"http", "https"}
_MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads
_MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text
# Graph-load memory-bomb cap: reject .json files larger than this before
# JSON-parsing them into a dict. Without this, a multi-gigabyte (or
# specifically crafted) graph.json can exhaust process memory during
# json.loads + node_link_graph rehydration.
# Default fallback cap. Kept as a module-level constant so the value is
# discoverable and so existing callers/tests that reference it directly keep
# working; the effective cap is resolved at call time by
# ``_max_graph_file_bytes`` (which lets ``GRAPHIFY_MAX_GRAPH_BYTES`` override it).
_MAX_GRAPH_FILE_BYTES = 512 * 1024 * 1024 # 512 MiB
def _max_graph_file_bytes() -> int:
"""Return the graph.json size cap in bytes.
Honors the ``GRAPHIFY_MAX_GRAPH_BYTES`` environment variable so users with
large codebases can raise the limit without editing source. The value may
be plain bytes (``671088640``) or carry an ``MB`` / ``GB`` suffix
(``640MB``, ``2GB`` case-insensitive, binary multipliers: ``MB`` is
1024*1024 and ``GB`` is 1024*1024*1024, i.e. MiB / GiB).
Falls back to ``_MAX_GRAPH_FILE_BYTES`` (512 MiB) when the env var is unset,
blank, or unparseable.
Read fresh on every call so the env var can be set before import and still
take effect.
"""
raw = os.environ.get("GRAPHIFY_MAX_GRAPH_BYTES", "").strip()
if not raw:
return _MAX_GRAPH_FILE_BYTES
text = raw.upper()
multiplier = 1
if text.endswith("GB"):
multiplier = 1024 * 1024 * 1024
text = text[:-2].strip()
elif text.endswith("MB"):
multiplier = 1024 * 1024
text = text[:-2].strip()
try:
value = int(text)
except ValueError:
return _MAX_GRAPH_FILE_BYTES
if value <= 0:
return _MAX_GRAPH_FILE_BYTES
return value * multiplier
# AWS metadata, link-local, and common cloud metadata endpoints
_BLOCKED_HOSTS = {"metadata.google.internal", "metadata.google.com"}
# RFC 6598 Shared Address Space (CGN) -- is_private misses this on Python <3.11
_CGN_NETWORK = ipaddress.ip_network("100.64.0.0/10")
# RFC 6052 NAT64 Well-Known Prefix -- is_reserved=True in Python but these embed
# public IPv4 addresses and are legitimate public internet traffic, not SSRF vectors.
_NAT64_WKP = ipaddress.ip_network("64:ff9b::/96")
# ---------------------------------------------------------------------------
# URL validation
# ---------------------------------------------------------------------------
def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Return True if *ip* falls in a private/reserved/internal range.
Shared by validate_url (pre-flight DNS check) and the SSRF-guarded
connection classes (connect-time check) so both use identical logic.
NAT64 well-known-prefix addresses are unwrapped to their embedded IPv4
before the check, since those carry legitimate public traffic.
"""
# For NAT64 addresses, check the embedded IPv4 instead of the wrapper
if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP:
ip = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
return (
ip.is_private
or ip.is_reserved
or ip.is_loopback
or ip.is_link_local
or ip in _CGN_NETWORK
)
def validate_url(url: str) -> str:
"""Raise ValueError if *url* is not http or https, or targets a private/internal IP.
Blocks file://, ftp://, data:, and any other scheme that could be used
for SSRF or local file access. Also blocks requests to private/reserved
IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints
to prevent SSRF in cloud environments.
"""
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
raise ValueError(
f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. "
f"Got: {url!r}"
)
hostname = parsed.hostname
if hostname:
# Block known cloud metadata hostnames
if hostname.lower() in _BLOCKED_HOSTS:
raise ValueError(
f"Blocked cloud metadata endpoint '{hostname}'. "
f"Got: {url!r}"
)
# Resolve hostname and block private/reserved IP ranges
try:
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
for info in infos:
addr = info[4][0]
ip = ipaddress.ip_address(addr)
if _ip_is_blocked(ip):
raise ValueError(
f"Blocked private/internal IP {addr} (resolved from '{hostname}'). "
f"Got: {url!r}"
)
except socket.gaierror as exc:
raise ValueError(
f"DNS resolution failed for '{hostname}': {exc}. Got: {url!r}"
) from exc
return url
# ---------------------------------------------------------------------------
# SSRF-guarded connections
#
# Instead of monkey-patching the process-global socket.getaddrinfo (a
# non-thread-safe TOCTOU hazard when multiple fetches run concurrently),
# we subclass the HTTP(S) connection so each connection resolves DNS exactly
# once, validates the resulting IP, and then connects to that exact IP. There
# is no second resolution, so a DNS-rebind attack cannot swap in a private
# address (e.g. 169.254.169.254) between validation and connection.
# ---------------------------------------------------------------------------
def _resolve_and_validate(host: str, port: int) -> tuple[int, str]:
"""Resolve *host* once and return (family, validated_ip) for the first
address that is not in a blocked range.
Raises OSError if every resolved address is private/reserved/internal,
matching the failure mode urllib/http.client expect from connect().
"""
infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
for family, _type, _proto, _canon, sockaddr in infos:
addr = sockaddr[0]
try:
ip = ipaddress.ip_address(addr)
except ValueError:
continue
if _ip_is_blocked(ip):
raise OSError(
f"SSRF blocked: IP {addr} resolved from '{host}' is private/reserved"
)
return family, addr
raise OSError(f"SSRF blocked: no usable address resolved from '{host}'")
class _SSRFGuardedHTTPConnection(http.client.HTTPConnection):
"""HTTPConnection that resolves + validates DNS once, then connects to the
exact validated IP (no second resolution = no DNS-rebind TOCTOU)."""
def connect(self) -> None:
family, ip = _resolve_and_validate(self.host, self.port)
self.sock = socket.create_connection(
(ip, self.port),
self.timeout,
self.source_address,
)
if self._tunnel_host:
self._tunnel()
class _SSRFGuardedHTTPSConnection(http.client.HTTPSConnection):
"""HTTPSConnection variant of _SSRFGuardedHTTPConnection.
Connects to the validated IP but performs the TLS handshake with
server_hostname set to the original hostname so SNI / certificate
validation work correctly (validating against the IP would break TLS).
"""
def connect(self) -> None:
family, ip = _resolve_and_validate(self.host, self.port)
sock = socket.create_connection(
(ip, self.port),
self.timeout,
self.source_address,
)
if self._tunnel_host:
self.sock = sock
self._tunnel()
sock = self.sock
self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
class _SSRFGuardedHTTPHandler(urllib.request.HTTPHandler):
"""urllib handler that routes http:// through _SSRFGuardedHTTPConnection."""
def http_open(self, req):
return self.do_open(_SSRFGuardedHTTPConnection, req)
class _SSRFGuardedHTTPSHandler(urllib.request.HTTPSHandler):
"""urllib handler that routes https:// through _SSRFGuardedHTTPSConnection."""
def https_open(self, req):
return self.do_open(_SSRFGuardedHTTPSConnection, req)
class _NoFileRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Redirect handler that re-validates every redirect target.
Prevents open-redirect SSRF attacks where an http:// URL redirects
to file:// or an internal address.
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
validate_url(newurl) # raises ValueError if scheme is wrong
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _build_opener() -> urllib.request.OpenerDirector:
# build_opener replaces the default HTTP(S)Handlers with our SSRF-guarded
# subclasses, so every connection resolves+validates DNS once and connects
# to that exact IP. Thread-safe: no process-global state is mutated.
return urllib.request.build_opener(
_SSRFGuardedHTTPHandler,
_SSRFGuardedHTTPSHandler,
_NoFileRedirectHandler,
)
# ---------------------------------------------------------------------------
# Safe fetch
# ---------------------------------------------------------------------------
def safe_fetch(url: str, max_bytes: int = _MAX_FETCH_BYTES, timeout: int = 30) -> bytes:
"""Fetch *url* and return raw bytes.
Protections applied:
- URL scheme validated (http / https only)
- Redirects re-validated via _NoFileRedirectHandler
- Response body capped at *max_bytes* (streaming read)
- Non-2xx status raises urllib.error.HTTPError
- Network errors propagate as urllib.error.URLError / OSError
Raises:
ValueError - disallowed scheme or redirect target
urllib.error.HTTPError - non-2xx HTTP status
urllib.error.URLError - DNS / connection failure
OSError - size cap exceeded
"""
validate_url(url)
opener = _build_opener()
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 graphify/1.0"})
with opener.open(req, timeout=timeout) as resp:
# urllib raises HTTPError for non-2xx when using urlopen directly;
# with a custom opener we check manually to be safe.
status = getattr(resp, "status", None) or getattr(resp, "code", None)
if status is not None and not (200 <= status < 300):
raise urllib.error.HTTPError(url, status, f"HTTP {status}", {}, None)
chunks: list[bytes] = []
total = 0
while True:
chunk = resp.read(65_536)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise OSError(
f"Response from {url!r} exceeds size limit "
f"({max_bytes // 1_048_576} MB). Aborting download."
)
chunks.append(chunk)
return b"".join(chunks)
def safe_fetch_text(url: str, max_bytes: int = _MAX_TEXT_BYTES, timeout: int = 15) -> str:
"""Fetch *url* and return decoded text (UTF-8, replacing bad bytes).
Wraps safe_fetch with tighter defaults for HTML / text content.
"""
raw = safe_fetch(url, max_bytes=max_bytes, timeout=timeout)
return raw.decode("utf-8", errors="replace")
# ---------------------------------------------------------------------------
# Path validation
# ---------------------------------------------------------------------------
def validate_graph_path(path: str | Path, base: Path | None = None) -> Path:
"""Resolve *path* and verify it stays inside *base*.
*base* defaults to the `graphify-out` directory relative to CWD.
Also requires the base directory to exist, so a caller cannot
trick graphify into reading files before any graph has been built.
Raises:
ValueError - path escapes base, or base does not exist
FileNotFoundError - resolved path does not exist
"""
if base is None:
resolved_hint = Path(path).resolve()
for candidate in [resolved_hint, *resolved_hint.parents]:
if candidate.name == GRAPHIFY_OUT_NAME:
base = candidate
break
if base is None:
base = Path(GRAPHIFY_OUT).resolve()
base = base.resolve()
if not base.exists():
raise ValueError(
f"Graph base directory does not exist: {base}. "
"Run /graphify first to build the graph."
)
resolved = Path(path).resolve()
try:
resolved.relative_to(base)
except ValueError:
raise ValueError(
f"Path {path!r} escapes the allowed directory {base}. "
"Only paths inside graphify-out/ are permitted."
)
if not resolved.exists():
raise FileNotFoundError(f"Graph file not found: {resolved}")
return resolved
def check_graph_file_size_cap(path: Path) -> None:
"""Reject *path* if its size exceeds the configured graph-file cap.
Protects callers from memory bombs by failing fast before a multi-GiB
graph.json is read into memory and JSON-parsed. Silently returns when
``path.stat()`` cannot be read the caller's own existence/path check
is expected to surface a clearer error in that case.
The cap is resolved on every call via :func:`_max_graph_file_bytes`, so the
``GRAPHIFY_MAX_GRAPH_BYTES`` env var can be set before import and still
apply.
Raises:
ValueError - file size exceeds the cap. The message includes the
observed size, the cap, and how to raise the limit.
"""
cap = _max_graph_file_bytes()
try:
size = path.stat().st_size
except OSError:
return
if size > cap:
raise ValueError(
f"graph file {path} is {size:_d} bytes, exceeds {cap:_d}-byte cap\n"
f"(set GRAPHIFY_MAX_GRAPH_BYTES=<bytes> or "
f"GRAPHIFY_MAX_GRAPH_BYTES=<N>GB to raise the limit)"
)
# ---------------------------------------------------------------------------
# Label sanitisation (mirrors code-review-graph's _sanitize_name pattern)
# ---------------------------------------------------------------------------
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
_MAX_LABEL_LEN = 256
def sanitize_label(text: str | None) -> str:
"""Strip control characters and cap length.
Safe for embedding in JSON data (inside <script> tags) and plain text.
For direct HTML injection, wrap the result with html.escape().
"""
if text is None:
return ""
text = _CONTROL_CHAR_RE.sub("", str(text))
if len(text) > _MAX_LABEL_LEN:
text = text[:_MAX_LABEL_LEN]
return text
# ---------------------------------------------------------------------------
# Metadata sanitisation (recursive, bounded, HTML-safe)
# ---------------------------------------------------------------------------
_METADATA_MAX_VALUE_LEN = 512
_METADATA_MAX_LIST_ITEMS = 50
def _sanitize_metadata_string(value: object) -> str:
"""Return a control-character-free, HTML-escaped, bounded string."""
text = _CONTROL_CHAR_RE.sub("", str(value))
text = html.escape(text, quote=True)
if len(text) > _METADATA_MAX_VALUE_LEN:
text = text[:_METADATA_MAX_VALUE_LEN]
return text # html is imported at module level (line 5)
def _sanitize_metadata_value(value: object) -> object:
"""Sanitize a metadata value while preserving simple JSON-compatible types."""
if isinstance(value, bool):
# bool is a subclass of int — must be checked first to avoid coercion.
return value
if isinstance(value, str):
return _sanitize_metadata_string(value)
if isinstance(value, dict):
return sanitize_metadata(value)
if isinstance(value, (list, tuple)):
return [_sanitize_metadata_value(item) for item in value[:_METADATA_MAX_LIST_ITEMS]]
if isinstance(value, (int, float)) or value is None:
return value
return _sanitize_metadata_string(value)
def sanitize_metadata(metadata: Mapping[str, Any] | None) -> dict[str, object]:
"""Sanitize metadata keys and values before graph export.
Metadata is less constrained than node labels: it can contain nested
dicts, lists, source snippets, external index symbols, and docstring
text. This helper keeps the data JSON-compatible, strips control
characters, escapes HTML-sensitive characters in strings, caps long
strings/lists, and drops entries whose key becomes empty after
sanitization.
"""
if metadata is None:
return {}
result: dict[str, object] = {}
for key, value in metadata.items():
clean_key = _sanitize_metadata_string(key)
if not clean_key:
continue
result[clean_key] = _sanitize_metadata_value(value)
return result
+329
View File
@@ -0,0 +1,329 @@
# Semantic fragment sanitizer — converts sentence-like rationale nodes into
# attributes on related nodes and removes invalid file_type values.
#
# Currently called from the skill merge scripts (skill-opencode.md,
# skill-codex.md) so that rationale text never leaks into the knowledge
# graph as standalone nodes. (Future: graphify.llm may wire this into
# _parse_llm_json / _merge_into for non-skill code paths; not done in
# this cycle.)
from __future__ import annotations
import json
import re
from pathlib import Path
from .build import _normalize_hyperedge_members
# Labels longer than this many characters, or containing >= this many words,
# are candidates for being sentence-like rationale text rather than entity names.
_RATIONALE_MIN_CHARS = 80
_RATIONALE_MIN_WORDS = 8
# Validation limits for untrusted semantic-fragment payloads. See
# validate_semantic_fragment(). Issue #825: returned-JSON normalization for
# OpenCode and Codex agents requires a Python enforcement boundary so a
# malicious or runaway agent response cannot exhaust memory or escape the
# graphify-out chunk directory via crafted node/edge IDs.
MAX_SEMANTIC_FRAGMENT_BYTES = 25 * 1024 * 1024
MAX_SEMANTIC_FRAGMENT_NODES = 10_000
MAX_SEMANTIC_FRAGMENT_EDGES = 100_000
MAX_SEMANTIC_FRAGMENT_HYPEREDGES = 10_000
MAX_SEMANTIC_HYPEREDGE_NODES = 256
MAX_SEMANTIC_ID_LENGTH = 256
VALID_SEMANTIC_FILE_TYPES = frozenset({"code", "document", "paper", "image", "rationale", "concept"})
_SEMANTIC_ID_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
def validate_semantic_fragment(fragment: object) -> list[str]:
"""Return validation errors for an untrusted semantic extraction fragment.
Empty list means valid. Called by skill merge code before
sanitize_semantic_fragment() so malformed or malicious agent JSON is
rejected before it touches the graph. Parameter is `object` (not `dict`)
because we may be handed arbitrary deserialized JSON the first check
rejects anything that isn't a dict.
"""
if not isinstance(fragment, dict):
return ["fragment must be a JSON object"]
errors: list[str] = []
try:
payload = json.dumps(fragment, ensure_ascii=False).encode("utf-8")
except (TypeError, ValueError) as exc:
return [f"fragment is not JSON-serializable: {exc}"]
if len(payload) > MAX_SEMANTIC_FRAGMENT_BYTES:
errors.append(f"payload is {len(payload)} bytes; max is {MAX_SEMANTIC_FRAGMENT_BYTES}")
nodes = fragment.get("nodes", [])
edges = fragment.get("edges", [])
if not isinstance(nodes, list):
errors.append("nodes must be a list")
nodes = []
elif len(nodes) > MAX_SEMANTIC_FRAGMENT_NODES:
errors.append(f"nodes has {len(nodes)} entries; max is {MAX_SEMANTIC_FRAGMENT_NODES}")
if not isinstance(edges, list):
errors.append("edges must be a list")
edges = []
elif len(edges) > MAX_SEMANTIC_FRAGMENT_EDGES:
errors.append(f"edges has {len(edges)} entries; max is {MAX_SEMANTIC_FRAGMENT_EDGES}")
for i, node in enumerate(nodes):
if not isinstance(node, dict):
errors.append(f"nodes[{i}] must be an object")
continue
_validate_semantic_id(errors, f"nodes[{i}].id", node.get("id"))
file_type = node.get("file_type")
if file_type is not None and file_type not in VALID_SEMANTIC_FILE_TYPES:
errors.append(
f"nodes[{i}].file_type {file_type!r} is not one of "
f"{sorted(VALID_SEMANTIC_FILE_TYPES)}"
) # validate file_type before any sanitize path can run
for i, edge in enumerate(edges):
if not isinstance(edge, dict):
errors.append(f"edges[{i}] must be an object")
continue
_validate_semantic_id(errors, f"edges[{i}].source", edge.get("source"))
_validate_semantic_id(errors, f"edges[{i}].target", edge.get("target"))
hyperedges = fragment.get("hyperedges", [])
if hyperedges is None:
hyperedges = []
if not isinstance(hyperedges, list):
errors.append("hyperedges must be a list")
else:
if len(hyperedges) > MAX_SEMANTIC_FRAGMENT_HYPEREDGES:
errors.append(
f"hyperedges has {len(hyperedges)} entries; "
f"max is {MAX_SEMANTIC_FRAGMENT_HYPEREDGES}"
)
for i, he in enumerate(hyperedges):
if not isinstance(he, dict):
errors.append(f"hyperedges[{i}] must be an object")
continue
# Fold alias member keys (members/node_ids) onto `nodes` (#1561) so
# an alias-keyed hyperedge isn't rejected here for "nodes must be a
# list" before it ever reaches build's normalization.
_normalize_hyperedge_members(he)
_validate_semantic_id(errors, f"hyperedges[{i}].id", he.get("id"))
he_nodes = he.get("nodes")
if not isinstance(he_nodes, list):
errors.append(f"hyperedges[{i}].nodes must be a list")
continue
if len(he_nodes) > MAX_SEMANTIC_HYPEREDGE_NODES:
errors.append(
f"hyperedges[{i}].nodes has {len(he_nodes)} entries; "
f"max is {MAX_SEMANTIC_HYPEREDGE_NODES}"
)
for j, ref in enumerate(he_nodes):
_validate_semantic_id(errors, f"hyperedges[{i}].nodes[{j}]", ref)
return errors
def load_validated_semantic_fragment(path: Path) -> tuple[dict | None, list[str]]:
"""Load and validate a semantic chunk, rejecting oversize files before parsing.
The size guard runs against `path.stat().st_size` so an attacker-supplied
multi-gigabyte chunk file cannot blow up memory at `read_text()` time.
JSON decode errors are returned as validation errors rather than raised,
so callers can `continue` past bad chunks without a try/except.
"""
try:
size = path.stat().st_size
except OSError as exc:
return None, [f"could not stat {path}: {exc}"]
if size > MAX_SEMANTIC_FRAGMENT_BYTES:
return None, [f"payload is {size} bytes; max is {MAX_SEMANTIC_FRAGMENT_BYTES}"]
try:
fragment = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return None, [f"invalid JSON: {exc}"]
except OSError as exc:
return None, [f"could not read {path}: {exc}"]
errors = validate_semantic_fragment(fragment)
return (None, errors) if errors else (fragment, [])
def _validate_semantic_id(errors: list[str], field: str, value: object) -> None:
if not isinstance(value, str):
errors.append(f"{field} must be a string")
return
if not value:
errors.append(f"{field} must not be empty")
return
if len(value) > MAX_SEMANTIC_ID_LENGTH:
errors.append(f"{field} is {len(value)} chars; max is {MAX_SEMANTIC_ID_LENGTH}")
if "/" in value or "\\" in value or ".." in value:
errors.append(f"{field} must not contain path separators or '..'")
if not _SEMANTIC_ID_RE.fullmatch(value):
errors.append(f"{field} contains unsupported characters")
def sanitize_semantic_fragment(fragment: dict) -> dict:
"""Clean up a semantic extraction fragment in-place.
Operations:
1. Removes nodes with ``file_type: "rationale"`` or ``file_type: "concept"``
that were emitted by an LLM (these are not valid semantic entity types).
2. Detects nodes whose label reads like a sentence / rationale paragraph
AND that participate in a ``rationale_for`` edge, then converts the
label into a ``rationale`` attribute on the target node and removes
the source-node + its edges. The ``rationale_for`` edge signal applies
regardless of the source node's ``file_type`` — sentence-like nodes
with allowed types (``document``, ``code``) are still cleaned up when
they're explicitly marked as rationale.
3. Strips nodes whose only distinguishing field is the label itself
(empty id likely LLM hallucination).
4. Filters hyperedges so they cannot reference removed or unknown node
IDs after the cleanup passes above. A hyperedge with fewer than two
surviving members is dropped.
Returns the same dict for convenience.
"""
_invalid_ft = frozenset({"rationale", "concept"})
nodes: list[dict] = fragment.get("nodes", [])
edges: list[dict] = fragment.get("edges", [])
hyperedges: list[dict] = fragment.get("hyperedges", []) or []
# ---- build lookup maps --------------------------------------------------
node_by_id: dict[str, dict] = {}
for n in nodes:
nid = n.get("id", "")
if nid:
node_by_id[nid] = n
# Pre-collect node IDs that source a `rationale_for` edge — these are
# candidates for sentence-like cleanup even when file_type is allowed.
rationale_for_sources: set[str] = set()
for e in edges:
if e.get("relation") == "rationale_for":
src = e.get("source", "")
if src:
rationale_for_sources.add(src)
# ---- pass 1: identify nodes to remove + rationale candidates -----------
rationale_candidates: list[dict] = []
remove_ids: set[str] = set()
keep_nodes: list[dict] = []
for n in nodes:
nid = n.get("id", "")
if not nid:
# Node without an id cannot be referenced — discard.
continue
ft = n.get("file_type", "")
label = n.get("label", "")
if ft in _invalid_ft:
# Explicitly-invalid file_type ("rationale" or "concept"): if
# the label looks like a sentence we may convert to attribute.
if _is_sentence_like_rationale_label(label):
rationale_candidates.append(n)
remove_ids.add(nid)
continue
if nid in rationale_for_sources and _is_sentence_like_rationale_label(label):
# Allowed file_type, but the node sources a `rationale_for` edge
# AND its label is sentence-like prose. Treat it as rationale
# cleanup material rather than a real graph entity.
rationale_candidates.append(n)
remove_ids.add(nid)
continue
keep_nodes.append(n)
# ---- pass 2: convert sentence-nodes → rationale attributes --------------
# Only `rationale_for` edges propagate the rationale text. Other outgoing
# edges (e.g. references, conceptually_related_to) are NOT used as
# attribute-propagation paths — that would corrupt unrelated nodes by
# attaching rationale meant for a different target.
rationale_attrs: dict[str, list[str]] = {}
for rn in rationale_candidates:
rn_id = rn.get("id", "")
text = rn.get("label", "").strip()
for e in edges:
if e.get("relation") != "rationale_for":
continue
if e.get("source") != rn_id:
continue
target_id = e.get("target")
if target_id not in node_by_id or target_id in remove_ids:
continue
rationale_attrs.setdefault(target_id, []).append(text)
for target_id, texts in rationale_attrs.items():
if target_id in node_by_id and target_id not in remove_ids:
_append_rationale_attr(node_by_id[target_id], texts)
# ---- pass 3: strip edges referencing removed nodes ----------------------
keep_edges: list[dict] = []
for e in edges:
src = e.get("source", "")
tgt = e.get("target", "")
if src in remove_ids or tgt in remove_ids:
continue
keep_edges.append(e)
# ---- pass 4: filter hyperedges to surviving node IDs --------------------
surviving_ids: set[str] = {n.get("id", "") for n in keep_nodes}
surviving_ids.discard("")
keep_hyperedges: list[dict] = []
for he in hyperedges:
if not isinstance(he, dict):
continue
# Fold alias member keys (members/node_ids) onto `nodes` (#1561) so an
# alias-keyed hyperedge isn't silently dropped below for a missing
# `nodes` list before build can canonicalize it.
_normalize_hyperedge_members(he)
he_nodes = he.get("nodes")
if not isinstance(he_nodes, list):
continue
filtered = [ref for ref in he_nodes if isinstance(ref, str) and ref in surviving_ids]
if len(filtered) < 2:
# A hyperedge needs at least two surviving members to be meaningful.
continue
if len(filtered) != len(he_nodes):
he = dict(he)
he["nodes"] = filtered
keep_hyperedges.append(he)
fragment["nodes"] = keep_nodes
fragment["edges"] = keep_edges
fragment["hyperedges"] = keep_hyperedges
return fragment
def _is_sentence_like_rationale_label(label: str) -> bool:
"""Return True if *label* looks like prose / rationale text rather than an
entity or concept name.
Heuristics (no false positives on short-concept-edge-cases):
- Longer than *_RATIONALE_MIN_CHARS* chars, OR
- At least *_RATIONALE_MIN_WORDS* whitespace-delimited tokens, AND
- Contains at least one sentence-ending punctuation mark (``. ! ?``) or a
colon (common in "Decision: ..." rationales).
"""
if not label:
return False
label = label.strip()
if len(label) < _RATIONALE_MIN_CHARS:
word_count = len(label.split())
if word_count < _RATIONALE_MIN_WORDS:
return False
# Must look like actual prose: has sentence-ending punctuation or a colon.
return bool(re.search(r"[.!?:]", label))
def _append_rationale_attr(node: dict, texts: list[str]) -> None:
"""Append one or more rationale strings to *node*'s ``rationale`` attribute.
If the attribute already exists the new texts are appended with a
double-newline separator so downstream consumers can distinguish distinct
rationale fragments.
"""
existing = node.get("rationale", "")
new_text = "\n\n".join(texts).strip()
if existing:
node["rationale"] = existing + "\n\n" + new_text
else:
node["rationale"] = new_text
+1693
View File
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
> Uses the `Task` tool for parallel subagent dispatch.
> Call `Task` once per chunk — ALL in the same response so they run in parallel.
Pass the extraction prompt as the task description:
```
Task(description="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
Each subagent writes its result to its own `graphify-out/.graphify_chunk_NN.json`. Collect results as each `Task` completes and parse each as JSON.
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native AGENTS.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's AGENTS.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
> Uses the `Task` tool for parallel subagent dispatch.
> Call `Task` once per chunk — ALL in the same response so they run in parallel.
Pass the extraction prompt as the task description:
```
Task(description="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
Each subagent writes its result to its own `graphify-out/.graphify_chunk_NN.json`. Collect results as each `Task` completes and parse each as JSON.
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native AGENTS.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's AGENTS.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+677
View File
@@ -0,0 +1,677 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+674
View File
@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message (Codex)**
> **Codex platform:** Uses `spawn_agent` + `wait_agent` + `close_agent` instead of the Agent tool.
> Requires `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
> If `spawn_agent` is unavailable, tell the user to add that config and restart Codex.
Call `spawn_agent` once per chunk — ALL in the same response so they run in parallel. Build the message by wrapping the extraction prompt in task-delegation framing:
```
spawn_agent(agent_type="worker", message="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
After all agents are dispatched, collect results sequentially in memory:
```
result = wait_agent(handle); close_agent(handle) # repeat per handle
```
Parse each result as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. Codex collects in memory, so there are no per-chunk files on disk; the disk-based success checks in Step B3 do not apply — a chunk that returns invalid JSON is the failure signal instead.
Subagent prompt template:
See `references/extraction-spec.md` for the compact subagent prompt (rules, node-ID format, confidence rubric, hyperedge and vision rules, JSON schema). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and have it return the JSON inline.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+677
View File
@@ -0,0 +1,677 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
> Uses the `Task` tool for parallel subagent dispatch.
> Call `Task` once per chunk — ALL in the same response so they run in parallel.
Pass the extraction prompt as the task description:
```
Task(description="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
Each subagent writes its result to its own `graphify-out/.graphify_chunk_NN.json`. Collect results as each `Task` completes and parse each as JSON.
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+686
View File
@@ -0,0 +1,686 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Kilo-specific rules
- Use the native `Task` tool for semantic extraction fan-out.
- Launch all chunk tasks in the same response so they run in parallel.
- Always use `subagent_type="general"` for extraction chunks.
- After modifying code files during the session, run `graphify update .`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+677
View File
@@ -0,0 +1,677 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+669
View File
@@ -0,0 +1,669 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message (OpenCode)**
> **OpenCode platform:** Uses `@mention` dispatch instead of the Agent tool. All mentions in a single message run in parallel.
Dispatch one `@mention` per chunk — ALL in the same response:
```
@agent Chunk CHUNK_NUM of TOTAL_CHUNKS: [extraction prompt with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]
@agent Chunk 2 of TOTAL_CHUNKS: [next chunk]
```
Wait for all agents to return. Parse each response as JSON. Accumulate nodes/edges/hyperedges across all results and write to `graphify-out/.graphify_semantic_new.json`. If the `@agent` path cannot write chunk files, fall back to the serial path that writes each `graphify-out/.graphify_chunk_NN.json` before merge.
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each agent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+677
View File
@@ -0,0 +1,677 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+675
View File
@@ -0,0 +1,675 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
> Uses the `Task` tool for parallel subagent dispatch.
> Call `Task` once per chunk — ALL in the same response so they run in parallel.
> Trae does NOT support PreToolUse hooks — AGENTS.md rules are the always-on mechanism instead.
Pass the extraction prompt as the task description:
```
Task(description="Your task is to perform the following. Follow the instructions below exactly.\n\n<agent-instructions>\n[extraction prompt, with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE substituted]\n</agent-instructions>\n\nExecute this now. Output ONLY the structured JSON response.")
```
Each subagent writes its result to its own `graphify-out/.graphify_chunk_NN.json`. Collect results as each `Task` completes and parse each as JSON.
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native AGENTS.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's AGENTS.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+673
View File
@@ -0,0 +1,673 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch subagents and paste their responses**
> **No automated subagent tool:** this host has no parallel Agent/Task API, so
> extraction is driven by hand. Dispatch a subagent per chunk however the host
> allows (a fresh conversation, a parallel pane), then paste each response back.
For each chunk of uncached files (20-25 per chunk), give a subagent the extraction prompt below. When it returns, paste its JSON response and write it to that chunk's file so Step B3 can collect it:
```bash
# After pasting a subagent's JSON for chunk N, save it (replace N and PASTED_JSON):
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
cat > "${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json" <<'CHUNK_JSON'
PASTED_JSON
CHUNK_JSON
```
Repeat for every chunk. Each chunk's JSON must land in its own `graphify-out/.graphify_chunk_NN.json` before Step B3 runs.
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, and DEEP_MODE substituted, and write its response to that chunk's file.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+712
View File
@@ -0,0 +1,712 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```powershell
# Detect Python with graphify — uv/pipx-aware (fixes #831)
New-Item -ItemType Directory -Force -Path graphify-out | Out-Null
$GRAPHIFY_PYTHON = $null
function Find-GraphifyPython {
# 1. uv tool install — 'uv tool dir' is authoritative, respects UV_TOOL_DIR automatically
if (Get-Command uv -ErrorAction SilentlyContinue) {
$uvDir = (uv tool dir 2>$null).Trim()
if ($uvDir) {
$py = Join-Path $uvDir "graphifyy\Scripts\python.exe"
if (Test-Path $py) {
& $py -c "import graphify" 2>$null
if ($LASTEXITCODE -eq 0) { return $py }
}
}
}
# 2. pipx install — 'pipx environment' respects PIPX_HOME automatically
if (Get-Command pipx -ErrorAction SilentlyContinue) {
$venvs = (pipx environment --value PIPX_LOCAL_VENVS 2>$null).Trim()
if ($venvs) {
$py = Join-Path $venvs "graphifyy\Scripts\python.exe"
if (Test-Path $py) {
& $py -c "import graphify" 2>$null
if ($LASTEXITCODE -eq 0) { return $py }
}
}
}
# 3. Active venv / conda / pip-into-current-env
$pyCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pyCmd) {
& $pyCmd.Source -c "import graphify" 2>$null
if ($LASTEXITCODE -eq 0) {
return (& $pyCmd.Source -c "import sys; print(sys.executable)").Trim()
}
}
return $null
}
# Try to find the right Python (uv → pipx → active env)
$GRAPHIFY_PYTHON = Find-GraphifyPython
# Not found — install then re-detect
if (-not $GRAPHIFY_PYTHON) {
if (Get-Command uv -ErrorAction SilentlyContinue) {
uv tool install --upgrade graphifyy -q 2>&1 | Select-Object -Last 3
} else {
pip install graphifyy -q 2>&1 | Select-Object -Last 3
}
$GRAPHIFY_PYTHON = Find-GraphifyPython
}
# Save interpreter path — all subsequent steps read this
$GRAPHIFY_PYTHON | Out-File -FilePath graphify-out\.graphify_python -Encoding utf8 -NoNewline
# Save scan root so `graphify update` (no args) knows where to look next time
(Resolve-Path INPUT_PATH).Path | Out-File -FilePath graphify-out\.graphify_root -Encoding utf8 -NoNewline
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent block, run Python through the saved interpreter — `& (Get-Content graphify-out\.graphify_python)` in place of a bare `python3` — so every step uses the interpreter that actually has graphify.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```powershell
$PROJECT_ROOT = (Get-Location).Path # cwd — where Part C globs graphify-out\ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: $CHUNK_PATH = Join-Path $PROJECT_ROOT "graphify-out\.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Troubleshooting
### PowerShell 5.1: Vertical scrolling stops working
If vertical scrolling breaks in PowerShell after running graphify, this is caused by ANSI escape sequences from the `graspologic` library. Graphify v0.3.10+ suppresses this output, but if you still see the issue:
1. **Upgrade graphify**: `pip install --upgrade graphifyy`
2. **Use Windows Terminal** instead of the legacy PowerShell console — Windows Terminal handles ANSI codes correctly
3. **Reset your terminal**: close and reopen PowerShell
4. **Skip graspologic**: uninstall it (`pip uninstall graspologic`) and graphify will fall back to NetworkX's built-in Louvain algorithm, which produces no ANSI output
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
+677
View File
@@ -0,0 +1,677 @@
---
name: graphify
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
---
# /graphify
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
## Usage
```
/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify <path> # full pipeline on specific path
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
/graphify <path> --update # incremental - re-extract only new/changed files
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
/graphify <path> --cluster-only # rerun clustering on existing graph
/graphify <path> --no-viz # skip visualization, just report + JSON
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
/graphify <path> --falkordb # generate graphify-out/cypher.txt for FalkorDB
/graphify <path> --falkordb-push falkordb://localhost:6379 # push directly to FalkorDB
/graphify <path> --mcp # start MCP stdio server for agent access
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
/graphify add <url> # fetch URL, save to ./raw, update graph
/graphify add <url> --author "Name" # tag who wrote it
/graphify add <url> --contributor "Name" # tag who added it to the corpus
/graphify query "<question>" # BFS traversal - broad context
/graphify query "<question>" --dfs # DFS - trace a specific path
/graphify query "<question>" --budget 1500 # cap answer at N tokens
/graphify path "AuthModule" "Database" # shortest path between two concepts
/graphify explain "SwinTransformer" # plain-language explanation of a node
```
## What graphify is for
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
## What You Must Do When Invoked
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 15 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
If no path was given, use `.` (current directory). Do not ask the user for a path.
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
Follow these steps in order. Do not skip steps.
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
### Step 1 - Ensure graphify is installed
```bash
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
PYTHON=""
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
# 1. uv tool installs — most reliable on modern Mac/Linux
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
fi
# 2. Read shebang from graphify binary (pipx and direct pip installs)
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$_SHEBANG" in
*[!a-zA-Z0-9/_.@-]*) ;;
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
esac
fi
# 3. Fall back to python3
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
if command -v uv >/dev/null 2>&1; then
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
_UV_PY=$(uv tool run --from graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
else
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
fi
fi
# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
# Save scan root so `graphify update` (no args) knows where to look next time
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
```
If the import succeeds, print nothing and move straight to Step 2.
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
### Step 2 - Detect files
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.detect import detect
from pathlib import Path
result = detect(Path('INPUT_PATH'))
print(json.dumps(result, ensure_ascii=False))
" > graphify-out/.graphify_detect.json
```
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
```
Corpus: X files · ~Y words
code: N files (.py .ts .go ...)
docs: N files (.md .txt ...)
papers: N files (.pdf ...)
images: N files
video: N files (.mp4 .mp3 ...)
```
Omit any category with 0 files from the summary.
Then act on it:
- If `total_files` is 0: stop with "No supported files found in [path]."
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
### Step 2.5 - Video and audio (only if video files detected)
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
### 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) and **semantic extraction** (LLM, costs tokens).
> **graphify needs no API key. Never ask the user for one, and never block on one.** Code is extracted structurally (AST) with no LLM and no key at all — a code-only corpus (the common `/graphify .` on a repo) skips semantic extraction entirely, so it needs nothing here: go straight to Part A and skip Part B. Semantic extraction (only for docs, papers, and images) uses Gemini **only if** `GEMINI_API_KEY`/`GOOGLE_API_KEY` is already set; otherwise the host agent itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key. If you catch yourself about to prompt for, wait on, or stop because of a missing API key, that is a misread of this skill — proceed without one.
**Before semantic extraction:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
Print it once, then continue — do not wait for the user to supply a key. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
> **No other API keys are read.** When `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, semantic extraction falls to the host agent itself — the running session is the LLM. On a host that dispatches subagents (e.g. Claude Code), dispatch them as written in Part B. On a host that runs the CLI directly in a terminal and cannot dispatch subagents, do not stall: a code-only corpus has no semantic work, so write the empty semantic file (Part B "Fast path") and continue to Part C; for a corpus with docs/papers/images, either set a Gemini key or extract those inline yourself, but in no case prompt for `ANTHROPIC_API_KEY` — that prompt is a misread of this skill.
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
#### Part A - Structural extraction for code files
For any code files detected, run AST extraction in parallel with Part B subagents:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.extract import collect_files, extract
from pathlib import Path
import json
code_files = []
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
for f in detect.get('files', {}).get('code', []):
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
if code_files:
result = extract(code_files, cache_root=Path('INPUT_PATH'))
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
else:
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
print('No code files - skipping AST extraction')
"
```
#### Part B - Semantic extraction (parallel subagents)
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do. **First write an empty semantic file** so Part C's merge has its input (it reads `.graphify_semantic.json` unconditionally; without this a code-only run hits `FileNotFoundError`):
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
"
```
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
Before dispatching subagents, print a timing estimate:
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
**Step B0 - Check extraction cache first**
Before dispatching any subagents, check which files already have cached extraction results:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import check_semantic_cache
from pathlib import Path
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# Only content files go to semantic extraction. Code is already covered structurally
# by the AST pass (Part A); flattening every category here makes subagents re-read
# every source file (#1392). Video is transcribed to a document in Step 2.5 first.
all_files = [f for cat in ('document', 'paper', 'image') for f in detect['files'].get(cat, [])]
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files, root='INPUT_PATH')
# Always (re)write the cache file: write hits, else DELETE any leftover from a prior
# run so Part C never merges a stale .graphify_cached.json (#1392).
if cached_nodes or cached_edges or cached_hyperedges:
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
else:
Path('graphify-out/.graphify_cached.json').unlink(missing_ok=True)
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
"
```
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
**Step B1 - Split into chunks**
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
**Step B2 - Dispatch ALL subagents in a single message**
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
Concrete example for 3 chunks:
```
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
```
All three in one message. Not three separate messages.
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
```bash
PROJECT_ROOT=$(pwd) # cwd — where Part C globs graphify-out/ (NOT .graphify_root/scan dir, #1392)
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
```
Subagent prompt template:
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
**Step B3 - Collect, cache, and merge**
Wait for all subagents. For each result:
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
```bash
$(cat graphify-out/.graphify_python) -c "
import json, glob
from pathlib import Path
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
all_nodes, all_edges, all_hyperedges = [], [], []
total_in, total_out = 0, 0
for c in chunks:
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
all_nodes += d.get('nodes', [])
all_edges += d.get('edges', [])
all_hyperedges += d.get('hyperedges', [])
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
'input_tokens': total_in, 'output_tokens': total_out,
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
"
```
Save new results to cache:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from graphify.cache import save_semantic_cache
from pathlib import Path
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []), root='INPUT_PATH')
print(f'Cached {saved} files')
"
```
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
all_nodes = cached['nodes'] + new.get('nodes', [])
all_edges = cached['edges'] + new.get('edges', [])
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
seen = set()
deduped = []
for n in all_nodes:
if n['id'] not in seen:
seen.add(n['id'])
deduped.append(n)
merged = {
'nodes': deduped,
'edges': all_edges,
'hyperedges': all_hyperedges,
'input_tokens': new.get('input_tokens', 0),
'output_tokens': new.get('output_tokens', 0),
}
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
"
```
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
#### Part C - Merge AST + semantic into final extraction
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from pathlib import Path
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
# Merge: AST nodes first, semantic nodes deduplicated by id
seen = {n['id'] for n in ast['nodes']}
merged_nodes = list(ast['nodes'])
for n in sem['nodes']:
if n['id'] not in seen:
merged_nodes.append(n)
seen.add(n['id'])
merged_edges = ast['edges'] + sem['edges']
merged_hyperedges = sem.get('hyperedges', [])
merged = {
'nodes': merged_nodes,
'edges': merged_edges,
'hyperedges': merged_hyperedges,
'input_tokens': sem.get('input_tokens', 0),
'output_tokens': sem.get('output_tokens', 0),
}
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
total = len(merged_nodes)
edges = len(merged_edges)
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
"
```
### Step 4 - Build graph, cluster, analyze, generate outputs
**Before starting:** the code blocks below pass `directed=IS_DIRECTED` to `build_from_json()`. Replace `IS_DIRECTED` with `True` if `--directed` was given (builds a `DiGraph` preserving edge direction source→target), otherwise `False` (the default undirected `Graph`). Substitute it the same way you substitute `INPUT_PATH` — do not leave the literal `IS_DIRECTED` in the code.
```bash
mkdir -p graphify-out
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from graphify.export import to_json
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# root= mirrors the --update runbook (#1361): relativize source_file to the same
# base so the full build and incremental --update never drift apart on re-extract.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
# Guard BEFORE any write: an empty extraction must not clobber a good graph.json /
# GRAPH_REPORT.md / analysis sidecar. Check immediately after build (#1392).
if G.number_of_nodes() == 0:
print('ERROR: Graph is empty - extraction produced no nodes.')
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
raise SystemExit(1)
communities = cluster(G)
cohesion = score_all(G, communities)
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
gods = god_nodes(G)
surprises = surprising_connections(G, communities)
labels = {cid: 'Community ' + str(cid) for cid in communities}
# Placeholder questions - regenerated with real labels in Step 5
questions = suggest_questions(G, communities, labels)
# Export FIRST and honor the #479 shrink-guard: to_json returns False (writing
# nothing) when the new graph is smaller than the existing graph.json. Only write
# GRAPH_REPORT.md + the analysis sidecar when the graph was actually written, so
# they never describe a graph that graph.json doesn't contain (#1392).
wrote = to_json(G, communities, 'graphify-out/graph.json')
if not wrote:
print('ERROR: refused to shrink graphify-out/graph.json (existing graph has more nodes; #479).')
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
'gods': gods,
'surprises': surprises,
'questions': questions,
}
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
"
```
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
Replace INPUT_PATH with the actual path.
### Step 4.5 - Graph health check (read-only integrity gate)
A non-destructive diagnostic on the extraction, before labeling. It surfaces edge collapse, dangling/missing endpoints, and self-loops — the silent-corruption modes of incremental updates and AST/LLM id mismatches. Read-only; never aborts.
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from graphify.diagnostics import diagnose_extraction, format_diagnostic_report
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
summary = diagnose_extraction(extraction, directed=IS_DIRECTED, root='INPUT_PATH')
print(format_diagnostic_report(summary))
flags = [f'{summary[k]} {label}' for k, label in (
('dangling_endpoint_edges', 'dangling-endpoint edges'),
('missing_endpoint_edges', 'missing-endpoint edges'),
('self_loop_edges', 'self-loop edges'),
('directed_same_endpoint_collapsed_edges', 'collapsed (directed) edges'),
('undirected_same_endpoint_collapsed_edges', 'collapsed (undirected) edges'),
) if summary.get(k, 0)]
print('GRAPH HEALTH WARNING: ' + '; '.join(flags) + ' - graph may be incomplete/corrupt.' if flags else 'Graph health: OK (no dangling/missing/collapsed edges).')
"
```
Substitute `IS_DIRECTED` and `INPUT_PATH` as in Step 4. If a `GRAPH HEALTH WARNING` prints, surface it in the final summary (do not abort — the graph is still usable, but the integrity issue must be visible, per the Honesty Rules).
### Step 5 - Label communities
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
Then regenerate the report and save the labels for the visualizer:
```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
from graphify.build import build_from_json
from graphify.cluster import score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
from graphify.report import generate
from pathlib import Path
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
# root= as in Step 4 / the --update runbook (#1361) — same base for node-key parity.
G = build_from_json(extraction, root='INPUT_PATH', directed=IS_DIRECTED)
communities = {int(k): v for k, v in analysis['communities'].items()}
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
# LABELS - replace these with the names you chose above
labels = LABELS_DICT
# Regenerate questions with real community labels (labels affect question phrasing)
questions = suggest_questions(G, communities, labels)
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
print('Report updated with community labels')
"
```
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
Replace INPUT_PATH with the actual path.
### Step 6 - Generate Obsidian vault (opt-in) + HTML
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
If `--obsidian` was given:
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
```bash
graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```
Generate the HTML graph (always, unless `--no-viz`):
```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
```
### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--falkordb`/`--falkordb-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
---
### Step 9 - Save manifest, update cost tracker, clean up, and report
```bash
$(cat graphify-out/.graphify_python) -c "
import json
from pathlib import Path
from datetime import datetime, timezone
from graphify.detect import save_manifest
# Save manifest for --update
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
# root= relativizes the manifest keys to the scan root (same base as the build),
# so the on-disk manifest is portable across clones/machines and a later --update
# matches cached files instead of missing every one (#1417).
save_manifest(detect.get('all_files') or detect['files'], root='INPUT_PATH')
# Update cumulative cost tracker
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
input_tok = extract.get('input_tokens', 0)
output_tok = extract.get('output_tokens', 0)
cost_path = Path('graphify-out/cost.json')
if cost_path.exists():
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
else:
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
cost['runs'].append({
'date': datetime.now(timezone.utc).isoformat(),
'input_tokens': input_tok,
'output_tokens': output_tok,
'files': detect.get('total_files', 0),
})
cost['total_input_tokens'] += input_tok
cost['total_output_tokens'] += output_tok
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```
Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.
Tell the user (omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
```
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
Then paste these sections from GRAPH_REPORT.md directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Do NOT paste the full report - just those three sections. Keep it concise.
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
The graph is the map. Your job after the pipeline is to be the guide.
---
## Interpreter guard for subcommands
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
```bash
if [ ! -f graphify-out/.graphify_python ]; then
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
case "$PYTHON" in *[!a-zA-Z0-9/_.@-]*) PYTHON="python3" ;; esac
else
PYTHON="python3"
fi
mkdir -p graphify-out
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
fi
```
## For --update and --cluster-only
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
---
## For /graphify query
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, answer from the graph rather than rebuilding it:
```bash
graphify query "<question>"
```
Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. If the `graphify query` CLI is unavailable, fall back to an inline NetworkX traversal of `graphify-out/graph.json`. Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. For that vocab-expansion step, the BFS/DFS traversal modes, the `--budget` cap, the NetworkX fallback, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
---
## For /graphify add and --watch
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
---
## For the commit hook and native CLAUDE.md integration
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
---
## Honesty Rules
- Never invent an edge. If unsure, use AMBIGUOUS.
- Never skip the corpus check warning.
- Always show token cost in the report.
- Never hide cohesion scores behind symbols - show the raw number.
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
@@ -0,0 +1,56 @@
# graphify reference: add a URL and watch a folder
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
## For /graphify add
Fetch a URL and add it to the corpus, then update the graph.
```bash
$(cat graphify-out/.graphify_python) -c "
import sys
from graphify.ingest import ingest
from pathlib import Path
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. 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):
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
- arXiv → abstract + metadata saved as `.md`
- PDF → downloaded as `.pdf`
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
- Any webpage → converted to markdown via html2text
---
## For --watch
Start a background watcher that monitors a folder and auto-updates the graph when files change.
```bash
$(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3
```
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
Press Ctrl+C to stop.
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.
@@ -0,0 +1,87 @@
# graphify reference: extra exports and benchmark
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--falkordb`, `--falkordb-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
### Step 6b - Wiki (only if --wiki flag)
**Only run this step if `--wiki` was explicitly given in the original command.**
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
```bash
graphify export wiki
```
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
**If `--neo4j`** - generate a Cypher file for manual import:
```bash
graphify export neo4j
```
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
```bash
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
```
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7a - FalkorDB export (only if --falkordb or --falkordb-push flag)
**If `--falkordb`** - generate a Cypher file. The statements are OpenCypher, but FalkorDB's `GRAPH.QUERY` runs one statement at a time (no bulk script import like Neo4j's `cypher-shell`), so prefer `--falkordb-push` to load a graph. Use this only when you want the portable `cypher.txt` artifact:
```bash
graphify export falkordb
```
**If `--falkordb-push <uri>`** - push directly to a running FalkorDB instance. Credentials are optional; ask the user only if the instance requires auth:
```bash
graphify export falkordb --push falkordb://localhost:6379
```
Default URI is `falkordb://localhost:6379` (the scheme is informational - `redis://` or a bare `host:port` work too), auth is optional, and the target graph defaults to `graphify`. Uses MERGE - safe to re-run without creating duplicates.
### Step 7b - SVG export (only if --svg flag)
```bash
graphify export svg
```
### Step 7c - GraphML export (only if --graphml flag)
```bash
graphify export graphml
```
### Step 7d - MCP server (only if --mcp flag)
```bash
$(cat graphify-out/.graphify_python) -m graphify.serve graphify-out/graph.json
```
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`. Claude Desktop can't run `$(...)`, and under `uv tool install` the system `python3` can't import graphify — so set `command` to the **absolute interpreter path** printed by `cat graphify-out/.graphify_python`:
```json
{
"mcpServers": {
"graphify": {
"command": "<absolute path from: cat graphify-out/.graphify_python>",
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
}
}
}
```
### Step 8 - Token reduction benchmark (only if total_words > 5000)
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
```bash
graphify benchmark
```
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.

Some files were not shown because too many files have changed in this diff Show More