chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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]))
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""exporters package."""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}}
|
||||
|
||||
// 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 · {G.number_of_edges()} edges · {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
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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": []}
|
||||
@@ -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
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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))
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2593
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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"))
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 1–5 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
@@ -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 1–5 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.
|
||||
@@ -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 1–5 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,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 1–5 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.
|
||||
@@ -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 1–5 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
@@ -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 1–5 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.
|
||||
@@ -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 1–5 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.
|
||||
@@ -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 1–5 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,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 1–5 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.
|
||||
@@ -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 1–5 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,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 1–5 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.
|
||||
@@ -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 1–5 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.
|
||||
@@ -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 1–5 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.
|
||||
@@ -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 1–5 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
Reference in New Issue
Block a user