---
README.md | 2 +-
docs/translations/README.he-IL.md | 849 ++++++++++++++++++++++++++++++
2 files changed, 850 insertions(+), 1 deletion(-)
create mode 100644 docs/translations/README.he-IL.md
diff --git a/README.md b/README.md
index ea3c862e3..9f1e11f06 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
diff --git a/docs/translations/README.he-IL.md b/docs/translations/README.he-IL.md
new file mode 100644
index 000000000..c1c193e84
--- /dev/null
+++ b/docs/translations/README.he-IL.md
@@ -0,0 +1,849 @@
+
+
+
+
+
+ 🇺🇸 English | 🇨🇳 简体中文 | 🇯🇵 日本語 | 🇰🇷 한국어 | 🇩🇪 Deutsch | 🇫🇷 Français | 🇪🇸 Español | 🇮🇳 हिन्दी | 🇧🇷 Português | 🇷🇺 Русский | 🇸🇦 العربية | 🇮🇷 فارسی | 🇮🇹 Italiano | 🇵🇱 Polski | 🇳🇱 Nederlands | 🇹🇷 Türkçe | 🇺🇦 Українська | 🇻🇳 Tiếng Việt | 🇮🇩 Bahasa Indonesia | 🇸🇪 Svenska | 🇬🇷 Ελληνικά | 🇷🇴 Română | 🇨🇿 Čeština | 🇫🇮 Suomi | 🇩🇰 Dansk | 🇳🇴 Norsk | 🇭🇺 Magyar | 🇹🇭 ภาษาไทย | 🇺🇿 Oʻzbekcha | 🇹🇼 繁體中文 | 🇵🇭 Filipino | 🇮🇱 עברית
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+הקלידו `/graphify` בעוזר ה-AI לכתיבת קוד שלכם, והוא ימפה את הפרויקט כולו — קוד, מסמכים, קובצי PDF, תמונות, סרטונים — לגרף ידע שאפשר לשאול עליו שאלות, במקום לחפש בקבצים עם grep.
+
+עובד ב-Claude Code, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, Amp, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi, Devin CLI ו-Google Antigravity.
+
+
+
+```
+/graphify .
+```
+
+
+
+זה הכול. מקבלים שלושה קבצים:
+
+
+
+```
+graphify-out/
+├── graph.html נפתח בכל דפדפן — לחיצה על צמתים, סינון, חיפוש
+├── GRAPH_REPORT.md עיקרי הדברים: מושגי מפתח, קשרים מפתיעים, שאלות מוצעות
+└── graph.json הגרף המלא — אפשר לשאול עליו בכל רגע בלי לקרוא שוב את הקבצים
+```
+
+
+
+לדף ארכיטקטורה קריא עם דיאגרמות זרימת-קריאות ב-Mermaid, הריצו:
+
+
+
+```bash
+graphify export callflow-html
+```
+
+---
+
+
+
+## דרישות מקדימות
+
+| דרישה | מינימום | בדיקה | התקנה |
+|---|---|---|---|
+| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
+| uv *(מומלץ)* | כל גרסה | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
+| pipx *(חלופה)* | כל גרסה | `pipx --version` | `pip install pipx` |
+
+**התקנה מהירה ב-macOS (עם Homebrew):**
+
+
+
+```bash
+brew install python@3.12 uv
+```
+
+
+
+**התקנה מהירה ב-Windows:**
+
+
+
+```powershell
+winget install astral-sh.uv
+```
+
+
+
+**Ubuntu/Debian:**
+
+
+
+```bash
+sudo apt install python3.12 python3-pip pipx
+# או התקנת uv:
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+---
+
+
+
+## התקנה
+
+> **החבילה הרשמית:** חבילת ה-PyPI היא `graphifyy` (עם y כפולה). חבילות `graphify*` אחרות ב-PyPI אינן קשורות לפרויקט. פקודת ה-CLI היא עדיין `graphify`.
+
+**שלב 1 — התקנת החבילה:**
+
+
+
+```bash
+# מומלץ (סביבה מבודדת; אם הפקודה 'graphify' לא נמצאת אחר כך, הריצו: uv tool update-shell):
+uv tool install graphifyy
+
+# חלופות:
+pipx install graphifyy
+pip install graphifyy # עשוי לדרוש הגדרת PATH — ראו הערה בהמשך
+```
+
+
+
+**שלב 2 — רישום המיומנות (skill) אצל עוזר ה-AI שלכם:**
+
+
+
+```bash
+graphify install
+```
+
+
+
+זהו. פתחו את עוזר ה-AI והקלידו `/graphify .`
+
+כדי להתקין את המיומנות בתוך המאגר הנוכחי במקום בפרופיל המשתמש, הוסיפו `--project`:
+
+
+
+```bash
+graphify install --project
+graphify install --project --platform codex
+```
+
+
+
+התקנה ברמת הפרויקט כותבת לתוך התיקייה הנוכחית, למשל `.claude/skills/graphify/SKILL.md` או `.agents/skills/graphify/SKILL.md` (בתוספת תיקיית `references/` שהמיומנות טוענת לפי הצורך), ומדפיסה רמז `git add` לקבצים שאפשר לבצע להם commit. פקודות פר-פלטפורמה שתומכות בהתקנה ברמת הפרויקט מקבלות את אותו דגל, למשל `graphify claude install --project` או `graphify codex install --project`.
+
+> **הערת PowerShell:** השתמשו ב-`graphify .` ולא ב-`/graphify .` — הלוכסן המוביל הוא מפריד נתיבים ב-PowerShell.
+
+> **`graphify: command not found`?** `uv tool install` / `pipx install` מציבים את פקודת `graphify` בתיקיית הכלים שלהם (`~/.local/bin`). אם המעטפת (shell) לא מוצאת אותה מיד אחרי ההתקנה — נפוץ בהתקנת macOS + zsh טרייה — התיקייה הזו עדיין לא ב-`PATH`: הריצו `uv tool update-shell` (או `pipx ensurepath`) ופתחו טרמינל חדש. עם `pip` רגיל, הוסיפו את `~/.local/bin` (בלינוקס) או `~/Library/Python/3.x/bin` (במק) ל-PATH, או הריצו `python -m graphify`.
+
+> **מריצים עם `uvx` / `uv tool run` בלי להתקין?** ציינו את שם החבילה, לא את שם הפקודה: `uvx --from graphifyy graphify install`. `uvx graphify …` רגיל נכשל (`No solution found … no versions of graphify`) כי `uv tool run` קורא את המילה הראשונה כשם *חבילה*, והחבילה היא `graphifyy` — פקודת `graphify` נמצאת בתוכה.
+
+> **הימנעו מ-`pip install` במק/Windows** אם אפשר. המיומנות מאתרת את Python בזמן ריצה מתוך `graphify-out/.graphify_python`; אם הוא מצביע על סביבה שונה מזו שבה `pip` התקין את החבילה, תקבלו `ModuleNotFoundError: No module named 'graphify'`. `uv tool install` ו-`pipx install` מבודדים את החבילה בסביבה משלהם ונמנעים מהבעיה לחלוטין.
+
+> **הוקים של Git עם uv tool / pipx:** `graphify hook install` מטמיע את נתיב המפרש הנוכחי ישירות בסקריפטי ההוק בזמן ההתקנה, כך שהוק ה-post-commit יופעל כראוי גם בלקוחות Git גרפיים וב-CI שבהם `~/.local/bin` אינו ב-PATH. אם התקנתם מחדש או שדרגתם את graphify, הריצו שוב `graphify hook install` כדי לרענן את הנתיב המוטמע.
+
+### בחרו את הפלטפורמה שלכם
+
+| פלטפורמה | פקודת התקנה |
+|----------|----------------|
+| Claude Code (לינוקס/מק) | `graphify install` |
+| Claude Code (Windows) | `graphify install` (זיהוי אוטומטי) או `graphify install --platform windows` |
+| CodeBuddy | `graphify install --platform codebuddy` |
+| Codex | `graphify install --platform codex` |
+| OpenCode | `graphify install --platform opencode` |
+| Kilo Code | `graphify install --platform kilo` |
+| GitHub Copilot CLI | `graphify install --platform copilot` |
+| VS Code Copilot Chat | `graphify vscode install` |
+| Aider | `graphify install --platform aider` |
+| OpenClaw | `graphify install --platform claw` |
+| Factory Droid | `graphify install --platform droid` |
+| Trae | `graphify install --platform trae` |
+| Trae CN | `graphify install --platform trae-cn` |
+| Gemini CLI | `graphify install --platform gemini` |
+| Hermes | `graphify install --platform hermes` |
+| Kimi Code | `graphify install --platform kimi` |
+| Amp | `graphify amp install` |
+| Agent Skills (חוצה-פלטפורמות) | `graphify install --platform agents` (כינוי: `--platform skills`) |
+| Kiro IDE/CLI | `graphify kiro install` |
+| Pi coding agent | `graphify install --platform pi` |
+| Cursor | `graphify cursor install` |
+| Devin CLI | `graphify devin install` |
+| Google Antigravity | `graphify antigravity install` |
+
+משתמשי Codex צריכים גם `multi_agent = true` תחת `[features]` בקובץ `~/.codex/config.toml` לצורך חילוץ מקבילי. CodeBuddy משתמש באותו מנגנון Agent tool והוק PreToolUse כמו Claude Code. Factory Droid משתמש בכלי `Task` לשיגור תת-סוכנים במקביל. OpenClaw ו-Aider משתמשים בחילוץ טורי (תמיכה בסוכנים מקביליים עדיין מוקדמת בפלטפורמות אלו). Trae משתמש ב-Agent tool לשיגור תת-סוכנים במקביל ו**אינו** תומך בהוקים מסוג PreToolUse — AGENTS.md הוא המנגנון הקבוע שם.
+
+`--platform agents` (כינוי: `--platform skills`) מכוון למיקומים הגנריים חוצי-הפלטפורמות של [Agent-Skills](https://github.com/anthropics/skills): `~/.agents/skills/` הגלובלי של המשתמש (נקרא על ידי `npx skills` ומסגרות תואמות-מפרט) בהתקנה גלובלית, ו-`./.agents/skills/` בהתקנת פרויקט (`--project`). `graphify install` החשוף נשאר חד-פלטפורמי (Claude Code) בכוונה — השתמשו בפלטפורמת `agents` כשתרצו שהמיומנות תהיה זמינה לכל מסגרת שקוראת `.agents/skills`.
+
+> Codex משתמש ב-`$graphify` במקום `/graphify`.
+
+### תוספים אופציונליים
+
+התקינו רק מה שצריך:
+
+| תוסף | מה הוא מוסיף | התקנה |
+|---|---|---|
+| `pdf` | חילוץ PDF | `uv tool install "graphifyy[pdf]"` |
+| `office` | תמיכה ב-`.docx` ו-`.xlsx` | `uv tool install "graphifyy[office]"` |
+| `google` | רינדור Google Sheets | `uv tool install "graphifyy[google]"` |
+| `video` | תמלול וידאו/אודיו (faster-whisper + yt-dlp) | `uv tool install "graphifyy[video]"` |
+| `mcp` | שרת MCP stdio | `uv tool install "graphifyy[mcp]"` |
+| `neo4j` | דחיפה ל-Neo4j | `uv tool install "graphifyy[neo4j]"` |
+| `falkordb` | דחיפה ל-FalkorDB | `uv tool install "graphifyy[falkordb]"` |
+| `svg` | ייצוא גרף ל-SVG | `uv tool install "graphifyy[svg]"` |
+| `leiden` | זיהוי קהילות Leiden (Python < 3.13 בלבד) | `uv tool install "graphifyy[leiden]"` |
+| `ollama` | הרצה מקומית עם Ollama | `uv tool install "graphifyy[ollama]"` |
+| `openai` | OpenAI / ממשקי API תואמי-OpenAI | `uv tool install "graphifyy[openai]"` |
+| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
+| `anthropic` | Anthropic Claude API (`--backend claude`, משתמש ב-`ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` |
+| `bedrock` | AWS Bedrock (משתמש ב-IAM, ללא מפתח API) | `uv tool install "graphifyy[bedrock]"` |
+| `azure` | Azure OpenAI Service (`--backend azure`, משתמש ב-`AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` |
+| `sql` | חילוץ סכמות SQL | `uv tool install "graphifyy[sql]"` |
+| `postgres` | אינטרוספקציה של PostgreSQL חי (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` |
+| `dm` | חילוץ AST של BYOND DreamMaker `.dm`/`.dme` (עשוי לדרוש קומפיילר C + `python3-dev` אם אין wheel מתאים לפלטפורמה) | `uv tool install "graphifyy[dm]"` |
+| `terraform` | חילוץ AST של Terraform / HCL `.tf`/`.tfvars`/`.hcl` | `uv tool install "graphifyy[terraform]"` |
+| `chinese` | פילוח שאילתות בסינית (jieba) | `uv tool install "graphifyy[chinese]"` |
+| `all` | כל מה שלמעלה | `uv tool install "graphifyy[all]"` |
+
+---
+
+## גרמו לעוזר שלכם להשתמש בגרף תמיד
+
+הריצו פעם אחת בפרויקט אחרי בניית גרף:
+
+| פלטפורמה | פקודה |
+|----------|---------|
+| Claude Code | `graphify claude install` |
+| CodeBuddy | `graphify codebuddy install` |
+| Codex | `graphify codex install` |
+| OpenCode | `graphify opencode install` |
+| Kilo Code | `graphify kilo install` |
+| GitHub Copilot CLI | `graphify copilot install` |
+| VS Code Copilot Chat | `graphify vscode install` |
+| Aider | `graphify aider install` |
+| OpenClaw | `graphify claw install` |
+| Factory Droid | `graphify droid install` |
+| Trae | `graphify trae install` |
+| Trae CN | `graphify trae-cn install` |
+| Cursor | `graphify cursor install` |
+| Gemini CLI | `graphify gemini install` |
+| Hermes | `graphify hermes install` |
+| Kimi Code | `graphify install --platform kimi` |
+| Amp | `graphify amp install` |
+| Agent Skills (חוצה-פלטפורמות) | `graphify agents install` (כינוי: `graphify skills install`) |
+| Kiro IDE/CLI | `graphify kiro install` |
+| Pi coding agent | `graphify pi install` |
+| Devin CLI | `graphify devin install` |
+| Google Antigravity | `graphify antigravity install` |
+
+הפקודה כותבת קובץ תצורה קטן שמנחה את העוזר שלכם להתייעץ עם גרף הידע בשאלות על בסיס הקוד — ולהעדיף שאילתות ממוקדות כמו `graphify query "<שאלה>"` על פני קריאת הדוח המלא או grep על קבצים גולמיים. בפלטפורמות שתומכות בהוקים נושאי-מטען (Claude Code, Gemini CLI), הוק מופעל אוטומטית לפני קריאות כלי בסגנון חיפוש (וב-Claude Code גם לפני קריאת קובצי מקור אחד-אחד דרך הכלים Read/Glob) ומכוון את העוזר לנתיב הגרף. באחרות (Codex, OpenCode, Cursor וכו'), קובצי ההנחיות הקבועים (`AGENTS.md`, `.cursor/rules/` וכו') מספקים את אותה הנחיית "קודם הגרף". `GRAPH_REPORT.md` עדיין זמין לסקירת ארכיטקטורה רחבה.
+
+**CodeBuddy** עושה את אותם שני דברים כמו Claude Code: כותב קטע `CODEBUDDY.md` שמנחה את CodeBuddy לקרוא את `graphify-out/GRAPH_REPORT.md` לפני מענה על שאלות ארכיטקטורה, ומתקין **הוקים מסוג PreToolUse** (`.codebuddy/settings.json`) שמופעלים לפני פקודות חיפוש ב-Bash וקריאת קבצים, ומכוונים ל-`graphify query` במקום.
+
+**Codex** כותב ל-`AGENTS.md` וגם מתקין **הוק PreToolUse** ב-`.codex/hooks.json` שמופעל לפני כל קריאת כלי Bash — אותו מנגנון קבוע כמו ב-Claude Code.
+
+להסרת graphify מכל הפלטפורמות בבת אחת: `graphify uninstall` (הוסיפו `--purge` כדי למחוק גם את `graphify-out/`). או השתמשו בפקודה הפר-פלטפורמית (למשל `graphify claude uninstall`).
+
+---
+
+**Kilo Code** מתקין את מיומנות Graphify ל-`~/.config/kilo/skills/graphify/SKILL.md` ופקודת `/graphify` נטיבית ל-`~/.config/kilo/command/graphify.md`. `graphify kilo install` כותב גם `AGENTS.md` וגם **תוסף `tool.execute.before` נטיבי** (`.kilo/plugins/graphify.js` + רישום ב-`.kilo/kilo.json` או `.kilo/kilo.jsonc`) כך ש-Kilo מקבל את אותה התנהגות תזכורת-גרף קבועה דרך תצורת `.kilo` נטיבית.
+
+**Cursor** כותב `.cursor/rules/graphify.mdc` עם `alwaysApply: true` — Cursor מכליל אותו בכל שיחה אוטומטית, ללא צורך בהוק.
+
+## מה יש בדוח
+
+- **צומתי מפתח (God nodes)** — המושגים המקושרים ביותר בפרויקט. הכול עובר דרכם.
+- **קשרים מפתיעים** — קישורים בין דברים שחיים בקבצים או מודולים שונים. מדורגים לפי מידת ההפתעה.
+- **ה"למה"** — הערות בקוד (`# NOTE:`, `# WHY:`, `# HACK:`), docstrings ורציונל עיצובי מהמסמכים מחולצים כצמתים נפרדים המקושרים לקוד שהם מסבירים.
+- **שאלות מוצעות** — 4–5 שאלות שהגרף נמצא בעמדה ייחודית לענות עליהן.
+- **תגי ביטחון** — כל קשר מוסק מסומן `EXTRACTED`, `INFERRED` או `AMBIGUOUS`. תמיד יודעים מה נמצא ומה נוחש.
+
+---
+
+## אילו קבצים הוא מטפל
+
+| סוג | סיומות |
+|------|-----------|
+| קוד (36 דקדוקי tree-sitter) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` דורש `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` ו-Metal `.metal` משתמשים בדקדוק C++) |
+| Salesforce Apex | `.cls .trigger` (מבוסס regex; מחלקות, ממשקים, enums, מתודות, טריגרים, קשתות SOQL/DML) |
+| Terraform / HCL | `.tf .tfvars .hcl` (דורש `uv tool install graphifyy[terraform]`) |
+| תצורות MCP | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — מחלץ צומתי שרתים, הפניות לחבילות, דרישות משתני סביבה |
+| מניפסטים של חבילות | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — צומת חבילה קנוני אחד לכל חבילה (לפי שם) בתוספת קשתות `depends_on`, כך שחבילה שמופנית מכמה מניפסטים היא מוקד (hub) יחיד |
+| מסמכים | `.md .mdx .qmd .html .txt .rst .yaml .yml` (קישורי markdown `[text](./other.md)` ו-`[[wikilinks]]` הופכים לקשתות `references` בין מסמכים) |
+| Office | `.docx .xlsx` (דורש `uv tool install graphifyy[office]`) |
+| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; דורש אימות `gws` ו-`--google-workspace`; Sheets דורש `uv tool install graphifyy[google]`) |
+| PDF | `.pdf` |
+| תמונות | `.png .jpg .webp .gif` |
+| וידאו / אודיו | `.mp4 .mov .mp3 .wav` ועוד (דורש `uv tool install graphifyy[video]`) |
+| YouTube / כתובות URL | כל כתובת וידאו (דורש `uv tool install graphifyy[video]`) |
+
+קוד מחולץ מקומית ללא קריאות API (AST באמצעות tree-sitter). כל השאר עובר דרך ה-API של מודל עוזר ה-AI שלכם.
+
+קובצי `.gdoc`, `.gsheet` ו-`.gslides` של Google Drive לשולחן העבודה הם קיצורי דרך, לא תוכן מסמכים. כדי לכלול מסמכי Google Docs, Sheets ו-Slides נטיביים בחילוץ headless, התקינו ואמתו את [`gws` CLI](https://github.com/googleworkspace/cli), ואז הריצו:
+
+
+
+```bash
+uv tool install "graphifyy[google]" # נדרש לרינדור טבלאות Google Sheets
+gws auth login -s drive
+graphify extract ./docs --google-workspace
+```
+
+
+
+אפשר גם להגדיר `GRAPHIFY_GOOGLE_WORKSPACE=1`. graphify מייצא קיצורי דרך אל `graphify-out/converted/` כקובצי Markdown נלווים, ואז מחלץ אותם.
+
+---
+
+## פקודות נפוצות
+
+
+
+```bash
+/graphify . # בניית גרף לתיקייה הנוכחית
+/graphify ./docs --update # חילוץ מחדש של קבצים שהשתנו בלבד
+/graphify . --cluster-only # הרצת אשכול מחדש בלי לחלץ מחדש
+/graphify . --cluster-only --resolution 1.5 # קהילות מפורטות יותר
+/graphify . --cluster-only --exclude-hubs 99 # הסתרת מוקדי-עזר מדירוג צומתי המפתח
+/graphify . --no-viz # דילוג על ה-HTML, רק הדוח וה-JSON
+/graphify . --wiki # בניית ויקי markdown מהגרף
+graphify export callflow-html # HTML ארכיטקטורה/זרימת-קריאות ב-Mermaid (מתחדש אוטומטית בכל commit אם ההוק מותקן)
+
+/graphify query "מה מחבר את האימות למסד הנתונים?"
+/graphify path "UserService" "DatabasePool"
+/graphify explain "RateLimiter"
+
+/graphify add https://arxiv.org/abs/1706.03762 # הבאת מאמר והוספתו
+/graphify add # תמלול והוספת סרטון
+
+graphify hook install # בנייה מחדש אוטומטית בכל commit
+graphify merge-graphs a.json b.json # מיזוג שני גרפים
+
+graphify prs # לוח PR: מצב CI, סטטוס ביקורת, מיפוי worktree
+graphify prs 42 # צלילה עמוקה ל-PR #42 עם השפעת גרף
+graphify prs --triage # AI מדרג את תור הביקורות שלכם (משתמש ב-backend המוגדר)
+graphify prs --conflicts # PRs שחולקים קהילות גרף — סיכון בסדר המיזוג
+```
+
+
+
+ראו את [רשימת הפקודות המלאה](#רשימת-הפקודות-המלאה) בהמשך.
+
+---
+
+## התעלמות מקבצים
+
+צרו קובץ `.graphifyignore` בשורש הפרויקט — אותו תחביר כמו `.gitignore`, כולל שלילה עם `!`.
+
+**`.gitignore` נאכף אוטומטית.** graphify קורא את ה-`.gitignore` בכל תיקייה. אם קיים גם `.graphifyignore`, השניים **ממוזגים** — תבניות `.graphifyignore` מוערכות אחרונות, כך שהן גוברות במקרה של התנגשות (כולל שלילות `!`). הוספת `.graphifyignore` רק מחריגה עוד; היא לעולם לא תחזיר קובץ שה-`.gitignore` כבר החריג. תחולת תת-תיקיות עובדת כמו ב-git — קובץ ignore משפיע רק על תת-העץ שלו.
+
+
+
+```
+# .graphifyignore
+node_modules/
+dist/
+*.generated.py
+
+# לאנדקס רק את src/, להתעלם מכל השאר
+*
+!src/
+!src/**
+```
+
+
+
+---
+
+## עבודת צוות
+
+`graphify-out/` מיועד להיכנס ל-git כדי שכל חברי הצוות יתחילו עם מפה מוכנה.
+
+**תוספות מומלצות ל-`.gitignore`:**
+
+
+
+```
+graphify-out/cost.json # מקומי בלבד
+# graphify-out/cache/ # אופציונלי: commit למהירות, דילוג לשמירה על ריפו קטן
+```
+
+
+
+> `manifest.json` הוא כעת נייד — המפתחות נשמרים כנתיבים יחסיים ומעוגנים מחדש בטעינה, כך שבטוח לבצע לו commit והדבר חוסך בנייה מלאה מחדש ב-checkout הראשון.
+
+**תהליך העבודה:**
+1. אחד מחברי הצוות מריץ `/graphify .` ומבצע commit ל-`graphify-out/`.
+2. כולם מושכים — העוזר שלהם קורא את הגרף מיד.
+3. הריצו `graphify hook install` לבנייה אוטומטית מחדש אחרי כל commit (AST בלבד, ללא עלות API). זה גם מגדיר merge driver של git כך ש-`graph.json` לעולם לא יישאר עם סימוני קונפליקט — שני מפתחים שמבצעים commit במקביל מקבלים מיזוג-איחוד אוטומטי של הגרפים.
+4. כשמסמכים או מאמרים משתנים, הריצו `/graphify --update` לרענון הצמתים הללו.
+
+---
+
+## שימוש ישיר בגרף
+
+
+
+```bash
+# שאילתת גרף מהטרמינל
+graphify query "הצג את זרימת האימות"
+graphify query "מה מחבר בין DigestAuth ל-Response?" --graph graphify-out/graph.json
+
+# חשיפת הגרף כשרת MCP (לגישת כלים חוזרת)
+python -m graphify.serve graphify-out/graph.json
+python -m graphify.serve --graph graphify-out/graph.json # גם הדגל --graph מתקבל
+
+# רישום ב-Kimi Code:
+kimi mcp add --transport stdio graphify -- python -m graphify.serve graphify-out/graph.json
+
+# או הגשה על HTTP כך שכל הצוות מצביע על URL אחד (בלי graphify מקומי):
+python -m graphify.serve graphify-out/graph.json --transport http --port 8080
+python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
+```
+
+
+
+שרת ה-MCP נותן לעוזר שלכם גישה מובנית: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`.
+
+### שרת HTTP משותף
+
+`--transport stdio` (ברירת המחדל) מריץ שרת מקומי אחד לכל מפתח. `--transport http` מגיש את אותם כלים על גבי MCP Streamable HTTP, כך שתהליך משותף אחד יכול לשרת את הגרף לכל הצוות — הלקוחות מכוונים את תצורת ה-MCP של ה-IDE אל `http://:8080/mcp` במקום להריץ graphify מקומית.
+
+| דגל | ברירת מחדל | תפקיד |
+|---|---|---|
+| `--transport {stdio,http}` | `stdio` | סוג התעבורה |
+| `--host` | `127.0.0.1` | כתובת ההאזנה ל-HTTP (השתמשו ב-`0.0.0.0` לחשיפה מעבר ל-localhost) |
+| `--port` | `8080` | פורט ההאזנה |
+| `--api-key` | משתנה סביבה `GRAPHIFY_API_KEY` | דרישת `Authorization: Bearer ` (או `X-API-Key`) |
+| `--path` | `/mcp` | נתיב העיגון ב-HTTP |
+| `--json-response` | כבוי | החזרת JSON רגיל במקום זרמי SSE |
+| `--stateless` | כבוי | ללא מצב פר-סשן (לפריסות מאוזנות-עומס / CI) |
+| `--session-timeout` | `3600` | ניקוי סשנים לא פעילים אחרי N שניות (`0` מבטל) |
+
+ברירת המחדל `127.0.0.1` היא loopback בלבד. הגדירו `--host 0.0.0.0` **וגם** `--api-key` יחד כשחושפים על מארח משותף. אפשר להריץ בקונטיינר:
+
+
+
+```bash
+docker build -t graphify .
+docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
+ /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
+```
+
+
+
+> **הערת WSL / לינוקס:** אובונטו מגיעה עם `python3`, לא `python`. השתמשו ב-venv כדי להימנע מהתנגשויות:
+
+
+
+```bash
+python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
+```
+
+
+
+---
+
+## משתני סביבה
+
+אלו נדרשים רק ל**חילוץ headless / CI** (`graphify extract`). בהרצה דרך מיומנות `/graphify` בתוך ה-IDE, ה-API של המודל מסופק על ידי סשן ה-IDE — לא נדרשים מפתחות נוספים.
+
+| משתנה | משמש עבור | מתי נדרש |
+|---|---|---|
+| `ANTHROPIC_API_KEY` | backend של Claude (Anthropic) | `--backend claude` |
+| `ANTHROPIC_BASE_URL` | כתובת endpoint תואם-Anthropic (פרוקסי LiteLLM, שערים, ...) | `--backend claude` (ברירת מחדל: `https://api.anthropic.com`) |
+| `ANTHROPIC_MODEL` | שם המודל ל-backend של Claude — ל-endpoints מותאמים, השתמשו בשם/כינוי שהשרת שלכם חושף | `--backend claude` (ברירת מחדל: `claude-sonnet-4-6`) |
+| `GEMINI_API_KEY` או `GOOGLE_API_KEY` | backend של Google Gemini | `--backend gemini` |
+| `OPENAI_API_KEY` | OpenAI או ממשקים תואמי-OpenAI | `--backend openai` (שרתים מקומיים מקבלים כל ערך לא ריק) |
+| `OPENAI_BASE_URL` | כתובת שרת תואם-OpenAI (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (ברירת מחדל: `https://api.openai.com/v1`) |
+| `OPENAI_MODEL` | שם המודל ל-backend של OpenAI — לשרתים בהרצה עצמית, השתמשו בשם/כינוי שהשרת חושף (בדקו את endpoint ה-`/v1/models` שלו) | `--backend openai` (ברירת מחדל: `gpt-4.1-mini`) |
+| `DEEPSEEK_API_KEY` | backend של DeepSeek | `--backend deepseek` |
+| `MOONSHOT_API_KEY` | backend של Kimi Code | `--backend kimi` |
+| `OLLAMA_BASE_URL` | כתובת הרצה מקומית של Ollama | `--backend ollama` (ברירת מחדל: `http://localhost:11434`) |
+| `OLLAMA_MODEL` | שם מודל Ollama | `--backend ollama` (ברירת מחדל: זיהוי אוטומטי) |
+| `GRAPHIFY_OLLAMA_NUM_CTX` | דריסת גודל חלון ה-KV-cache של Ollama | אופציונלי — מותאם אוטומטית כברירת מחדל |
+| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | דקות להשארת מודל Ollama טעון | אופציונלי — `0` לפריקה אחרי כל chunk |
+| `AZURE_OPENAI_API_KEY` | backend של Azure OpenAI Service | `--backend azure` |
+| `AZURE_OPENAI_ENDPOINT` | כתובת ה-endpoint של משאב Azure | `--backend azure` (נדרש יחד עם מפתח ה-API) |
+| `AZURE_OPENAI_API_VERSION` | דריסת גרסת ה-API של Azure | אופציונלי — ברירת מחדל `2024-12-01-preview` |
+| `AZURE_OPENAI_DEPLOYMENT` או `GRAPHIFY_AZURE_MODEL` | שם הפריסה ב-Azure | אופציונלי — ברירת מחדל `gpt-4o` |
+| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — שרשרת אישורים סטנדרטית | `--backend bedrock` (ללא מפתח API, משתמש ב-IAM) |
+| `GRAPHIFY_MAX_WORKERS` | מספר threads למקביליות AST | אופציונלי — גם דגל `--max-workers` |
+| `GRAPHIFY_MAX_OUTPUT_TOKENS` | העלאת תקרת הפלט לקורפוסים צפופים | אופציונלי — למשל `32768` לקבצים גדולים |
+| `GRAPHIFY_API_TIMEOUT` | timeout לקריאה בשניות עבור HTTP, claude-cli ו-Anthropic SDK (ברירת מחדל: 600) | אופציונלי — גם דגל `--api-timeout` |
+| `GRAPHIFY_MAX_RETRIES` | מספר ניסיונות חוזרים לבקשה שנחסמה בקצב (429) לפני ויתור (ברירת מחדל: 6; מכבד `Retry-After`) | אופציונלי — העלו למגבלות ארגוניות נוקשות; `0` מבטל |
+| `GRAPHIFY_FORCE` | כפיית בנייה מחדש של הגרף גם עם פחות צמתים | אופציונלי — גם דגל `--force` |
+| `GRAPHIFY_GOOGLE_WORKSPACE` | הפעלה אוטומטית של ייצוא Google Workspace | אופציונלי — הגדירו `1` |
+| `GRAPHIFY_TRIAGE_BACKEND` | backend עבור `graphify prs --triage` | אופציונלי — מזוהה אוטומטית מהמפתחות הזמינים |
+| `GRAPHIFY_TRIAGE_MODEL` | דריסת מודל לטריאז' | אופציונלי — למשל `claude-opus-4-7` |
+| `GRAPHIFY_QUERY_LOG` | דריסת נתיב יומן השאילתות (ברירת מחדל: `~/.cache/graphify-queries.log`) | אופציונלי — ערך ריק או `/dev/null` להשתקה |
+| `GRAPHIFY_QUERY_LOG_DISABLE` | הגדירו `1` לביטול מוחלט של יומן השאילתות | אופציונלי |
+| `GRAPHIFY_QUERY_LOG_RESPONSES` | הגדירו `1` לרישום גם של תשובות תת-גרף מלאות (כבוי כברירת מחדל) | אופציונלי |
+| `GRAPHIFY_MAX_GRAPH_BYTES` | דריסת תקרת הגודל של graph.json (512 MiB) — למשל `700MB`, `2GB` או בייטים | אופציונלי — שימושי לקורפוסים גדולים מאוד |
+| `GRAPHIFY_LLM_TEMPERATURE` | דריסת טמפרטורת ה-LLM לחילוץ סמנטי — למשל `0.7`, או `none` להשמטה | אופציונלי — מושמט אוטומטית למודלי היסק o1/o3/o4/gpt-5 |
+
+---
+
+## פרטיות
+
+- **קובצי קוד** — מעובדים מקומית עם tree-sitter. שום דבר לא עוזב את המחשב. קורפוס של קוד בלבד אינו דורש מפתח API — `graphify extract` רץ לגמרי offline.
+- **וידאו / אודיו** — מתומלל מקומית עם faster-whisper. שום דבר לא עוזב את המחשב.
+- **מסמכים, PDF, תמונות** — נשלחים לעוזר ה-AI שלכם לחילוץ סמנטי (דרך מיומנות `/graphify`, עם המודל שסשן ה-IDE שלכם מריץ). `graphify extract` בחילוץ headless דורש `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), מופע Ollama רץ (`OLLAMA_BASE_URL`), אישורי AWS דרך שרשרת הספקים הסטנדרטית (Bedrock — ללא מפתח API, משתמש ב-IAM), או קובץ ההרצה `claude` (Claude Code — ללא מפתח API, משתמש במנוי Claude שלכם). הדגל `--dedup-llm` משתמש באותו מפתח.
+- **מיקום נתונים (Data residency)** — `graphify extract` מזהה אוטומטית באיזה ספק להשתמש לפי המפתח שמוגדר (עדיפות: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). לקוד עם דרישות מיקום נתונים, השתמשו ב-`--backend ollama` (מקומי לחלוטין) או העבירו דגל `--backend` מפורש. Kimi (`MOONSHOT_API_KEY`) מנתב לשרתי Moonshot AI בסין.
+- ללא טלמטריה, ללא מעקב שימוש, ללא אנליטיקה.
+- **יומן שאילתות** — כל קריאת `graphify query`, `graphify path`, `graphify explain` ו-`query_graph` של MCP נרשמת ל-`~/.cache/graphify-queries.log` בפורמט JSON Lines (חותמת זמן, שאלה, קורפוס, צמתים שהוחזרו, משך). תשובות תת-גרף מלאות **אינן** נשמרות כברירת מחדל. הגדירו `GRAPHIFY_QUERY_LOG_DISABLE=1` לביטול, או `GRAPHIFY_QUERY_LOG=/dev/null` להשתקה בלי לבטל את המנגנון.
+
+---
+
+## פתרון בעיות
+
+**`graphify: command not found` אחרי ההתקנה**
+ה-CLI מותקן אבל תיקיית ה-bin שלו אינה ב-`PATH` של המעטפת. בחרו את התיקון לפי אופן ההתקנה:
+- **uv** (`uv tool install graphifyy`): הפקודה מגיעה לתיקיית הכלים של uv (`~/.local/bin`), שהתקנת macOS/zsh טרייה לרוב לא כוללת ב-`PATH`. הריצו `uv tool update-shell` ופתחו טרמינל חדש. (מצאו את התיקייה עם `uv tool dir --bin`.)
+- **pipx** (`pipx install graphifyy`): הריצו `pipx ensurepath` ופתחו טרמינל חדש.
+- **pip** (`pip install graphifyy`): pip מתקין סקריפטים לתיקיית bin של המשתמש שאולי אינה ב-`PATH` — הוסיפו את `~/Library/Python/3.x/bin` (macOS) או `~/.local/bin` (לינוקס) ל-`PATH` ב-`~/.zshrc`/`~/.bashrc`, או פשוט הריצו `python -m graphify`.
+
+**`uvx graphify …` או `uv tool run graphify …` לא מצליחים לפתור את `graphify`**
+חבילת ה-PyPI היא `graphifyy`; `graphify` הוא רק הפקודה שהיא מספקת. `uv tool run` מתייחס למילה הראשונה כשם *חבילה*, מחפש חבילה בשם `graphify` ומדווח `No solution found … no versions of graphify`. ציינו את החבילה במפורש: `uvx --from graphifyy graphify install` (זהה ל-`uv tool run --from graphifyy graphify install`). או התקינו פעם אחת עם `uv tool install graphifyy` וקראו ל-`graphify` ישירות.
+
+**`python -m graphify` עובד אבל פקודת `graphify` לא**
+ה-`PATH` של המעטפת לא כולל את תיקיית ה-bin שאליה הותקנה הפקודה. העדיפו `uv tool install` / `pipx install` על פני `pip` רגיל, ואז הריצו `uv tool update-shell` / `pipx ensurepath` ופתחו טרמינל חדש (ראו הערות ההתקנה לעיל).
+
+**`/graphify .` גורם ל-"path not recognized" ב-PowerShell**
+PowerShell מתייחס ל-`/` מוביל כמפריד נתיבים. השתמשו ב-`graphify .` (בלי לוכסן) ב-Windows.
+
+**לגרף יש פחות צמתים אחרי `--update` או בנייה מחדש**
+אם refactor מחק קבצים, הצמתים הישנים נשארים. העבירו `--force` (או הגדירו `GRAPHIFY_FORCE=1`) לדריסה גם כשהבנייה החדשה קטנה יותר.
+
+**לגרף יש צמתים כפולים לאותה ישות (ghost duplicates)**
+כפילויות רפאים (אותו סימבול מופיע פעמיים — פעם מחילוץ AST עם מיקום מקור, ופעם מחילוץ סמנטי בלעדיו) ממוזגות כעת אוטומטית בזמן הבנייה. אם אתם רואים זאת בגרף שנבנה לפני v0.8.33, הריצו חילוץ מלא מחדש לניקוי:
+
+
+
+```bash
+graphify extract . --force
+```
+
+
+
+**Ollama נגמר לו ה-VRAM / חריגה מחלון ההקשר**
+חלון ה-KV-cache מותאם אוטומטית אך עשוי להיות גדול מדי ל-GPU שלכם. הקטינו אותו:
+
+
+
+```bash
+GRAPHIFY_OLLAMA_NUM_CTX=8192 graphify extract ./docs --backend ollama --token-budget 4000
+```
+
+
+
+**אזהרות `LLM returned invalid JSON` / `Unterminated string`**
+תשובת ה-JSON של המודל פגעה במגבלת אסימוני הפלט ונחתכה באמצע מחרוזת. graphify מתאושש אוטומטית (הוא מפצל את ה-chunk ומחלץ מחדש את החצאים, ומסמך יחיד גדול מדי נחתך תחילה בגבולות כותרות/פסקאות כך שהקובץ כולו עדיין מכוסה), כך שהאזהרות רועשות אך אין אובדן נתונים. להפחתת התופעה, העלו את תקרת הפלט או הקטינו את פלט ה-chunk:
+
+
+
+```bash
+GRAPHIFY_MAX_OUTPUT_TOKENS=16384 graphify extract . --mode deep # הרמת התקרה
+graphify extract . --mode deep --token-budget 4000 # קלטים קטנים יותר -> פלט קטן יותר
+```
+
+
+
+עם שער ענן כמו OpenRouter, העדיפו `--backend openai` (הגדירו `OPENAI_BASE_URL`) על פני שכבת ה-Ollama — זה נתיב תואם-OpenAI נקי יותר. אם למודל יש תקרת פלט משלו, הורדת `--token-budget` היא המנוף האמין.
+
+**HTML של הגרף גדול מדי לפתיחה בדפדפן (מעל 5000 צמתים)**
+דלגו על יצירת ה-HTML והשתמשו ב-JSON ישירות:
+
+
+
+```bash
+graphify cluster-only ./my-project --no-viz
+graphify query "..."
+```
+
+
+
+**ל-`graph.json` יש סימוני קונפליקט אחרי ששני מפתחים ביצעו commit במקביל**
+הריצו `graphify hook install` — הוא מגדיר merge driver של git שממזג-מאחד את `graph.json` אוטומטית כך שקונפליקטים לא קורים בכלל.
+
+**החילוץ מחזיר צמתים/קשתות ריקים למסמכים או PDF**
+מסמכים, PDF ותמונות דורשים קריאת LLM — קורפוסים של קוד בלבד אינם דורשים מפתח. ודאו שמפתח ה-API מוגדר וה-backend נכון:
+
+
+
+```bash
+ANTHROPIC_API_KEY=sk-... graphify extract ./docs --backend claude
+```
+
+
+
+**אזהרת חוסר התאמה בגרסת המיומנות ב-IDE**
+גרסת graphify המותקנת שונה מקובץ המיומנות. עדכנו:
+
+
+
+```bash
+uv tool upgrade graphifyy
+graphify install # דורס את קובץ המיומנות
+```
+
+
+
+---
+
+## רשימת הפקודות המלאה
+
+
+
+```
+/graphify # הרצה על התיקייה הנוכחית
+/graphify ./raw # הרצה על תיקייה מסוימת
+/graphify ./raw --mode deep # חילוץ קשרים אגרסיבי יותר
+/graphify ./raw --update # חילוץ מחדש של קבצים שהשתנו בלבד
+/graphify ./raw --directed # שמירת כיוון הקשתות
+/graphify ./raw --cluster-only # הרצת אשכול מחדש על גרף קיים
+/graphify ./raw --no-viz # דילוג על ויזואליזציית HTML
+/graphify ./raw --obsidian # יצירת כספת Obsidian
+/graphify ./raw --obsidian --obsidian-dir ~/vault # כתיבה לכספת קיימת (לעולם לא דורס פתקים או תצורת .obsidian שלכם)
+/graphify ./raw --wiki # בניית ויקי markdown שסוכנים יכולים לסרוק
+/graphify ./raw --svg # ייצוא graph.svg
+/graphify ./raw --graphml # ייצוא ל-Gephi / yEd
+/graphify ./raw --neo4j # יצירת cypher.txt ל-Neo4j
+/graphify ./raw --neo4j-push bolt://localhost:7687
+/graphify ./raw --falkordb # יצירת cypher.txt ל-FalkorDB
+/graphify ./raw --falkordb-push falkordb://localhost:6379
+/graphify ./raw --watch # סנכרון אוטומטי כשקבצים משתנים
+/graphify ./raw --mcp # הפעלת שרת MCP stdio
+
+/graphify add https://arxiv.org/abs/1706.03762
+/graphify add
+/graphify add https://... --author "Name" --contributor "Name"
+
+/graphify query "מה מחבר את ה-attention לאופטימייזר?"
+/graphify query "..." --dfs --budget 1500
+/graphify path "DigestAuth" "Response"
+/graphify explain "SwinTransformer"
+
+graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful # תיעוד תוצאת שאלה-תשובה (זיכרון עבודה; outcome ∈ useful|dead_end|corrected)
+graphify reflect # איחוד תוצאות graphify-out/memory/ אל reflections/LESSONS.md
+graphify reflect --if-stale # לא עושה דבר אם LESSONS.md כבר חדש מכל הקלטים (זול להרצה בכל סשן)
+graphify reflect --out docs/LESSONS.md # כתיבת מסמך הלקחים למקום אחר
+graphify reflect --graph graphify-out/graph.json # קיבוץ לקחים לפי קהילה + כתיבת שכבת זיכרון העבודה (.graphify_learning.json)
+ # השכבה מתייגת צמתים preferred/tentative/contested (משוקלל-עדכניות, עם מקור);
+ # graphify explain / query מציגים אז רמז "Lesson:", מסומן "code changed — re-verify" כשהמקור התקדם
+
+graphify uninstall # הסרה מכל הפלטפורמות במכה אחת
+graphify uninstall --purge # מחיקה גם של graphify-out/
+graphify uninstall --project --platform codex # הסרת קובצי התקנה ברמת פרויקט בלבד
+
+graphify hook install # הוקים של post-commit + post-checkout
+graphify hook uninstall
+graphify hook status
+
+# הנחיות עוזר קבועות - פר פלטפורמה
+graphify claude install # CLAUDE.md + הוק PreToolUse (Claude Code)
+graphify claude uninstall
+graphify codebuddy install # CODEBUDDY.md + הוק PreToolUse (CodeBuddy)
+graphify codebuddy uninstall
+graphify codex install # AGENTS.md + הוק PreToolUse ב-.codex/hooks.json (Codex)
+graphify opencode install # AGENTS.md + תוסף tool.execute.before (OpenCode)
+graphify kilo install # מיומנות Kilo נטיבית + פקודת /graphify + AGENTS.md + תוסף .kilo
+graphify kilo uninstall
+graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
+graphify cursor uninstall
+graphify gemini install # GEMINI.md + הוק BeforeTool (Gemini CLI)
+graphify gemini uninstall
+graphify copilot install # קובץ מיומנות (GitHub Copilot CLI)
+graphify copilot uninstall
+graphify aider install # AGENTS.md (Aider)
+graphify aider uninstall
+graphify claw install # AGENTS.md (OpenClaw)
+graphify claw uninstall
+graphify droid install # AGENTS.md (Factory Droid)
+graphify droid uninstall
+graphify trae install # AGENTS.md (Trae)
+graphify trae uninstall
+graphify trae-cn install # AGENTS.md (Trae CN)
+graphify trae-cn uninstall
+graphify hermes install # AGENTS.md + ~/.hermes/skills/ (Hermes)
+graphify hermes uninstall
+graphify amp install # קובץ מיומנות (Amp)
+graphify amp uninstall
+graphify agents install # ~/.agents/skills/ + AGENTS.md (חוצה-פלטפורמות; כינוי: graphify skills)
+graphify agents uninstall
+graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md (Kiro IDE/CLI)
+graphify kiro uninstall
+graphify pi install # קובץ מיומנות (Pi coding agent)
+graphify pi uninstall
+graphify devin install # קובץ מיומנות + .windsurf/rules/graphify.md (Devin CLI)
+graphify devin uninstall
+graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
+graphify antigravity uninstall
+
+graphify extract ./docs # חילוץ LLM headless ל-CI (ללא IDE)
+graphify extract ./docs --backend gemini # backend מפורש: gemini, kimi, claude, openai, deepseek, ollama, bedrock או claude-cli
+graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
+graphify extract ./docs --backend ollama # Ollama מקומי (הגדירו OLLAMA_BASE_URL / OLLAMA_MODEL) - ללא מפתח API ל-loopback
+OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # כל שרת תואם-OpenAI (llama.cpp, vLLM, LM Studio)
+ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=my-model graphify extract ./docs --backend claude # כל endpoint תואם-Anthropic (פרוקסי LiteLLM, שערים)
+GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # דריסת חלון ה-KV-cache (מותאם אוטומטית כברירת מחדל)
+GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # פריקת המודל אחרי כל chunk (חוסך VRAM ב-GPU קטן)
+graphify extract ./docs --backend bedrock # AWS Bedrock דרך IAM - ללא מפתח API, שרשרת אישורי AWS
+graphify extract ./docs --backend claude-cli # ניתוב דרך Claude Code CLI - ללא מפתח API, דרך מנוי Claude שלכם
+graphify extract ./docs --backend azure # Azure OpenAI (הגדירו AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT)
+graphify extract ./docs --max-workers 16 # מקביליות AST (גם GRAPHIFY_MAX_WORKERS)
+graphify extract --postgres "postgresql://user:pass@host/db" # אינטרוספקציה ישירה של סכמת PostgreSQL חיה
+graphify extract ./my-workspace --cargo # אינטרוספקציה ישירה של תלויות Cargo workspace ברוסט
+graphify extract ./docs --token-budget 30000 # chunks סמנטיים קטנים יותר למודלים מקומיים/קטנים
+graphify extract ./docs --max-concurrency 2 # פחות קריאות LLM מקביליות (שימושי להרצה מקומית)
+graphify extract ./docs --api-timeout 900 # timeout ארוך יותר למודלים מקומיים איטיים (ברירת מחדל 600 שניות)
+graphify extract ./docs --google-workspace # ייצוא .gdoc/.gsheet/.gslides דרך gws לפני החילוץ
+graphify extract ./docs --mode deep # חילוץ סמנטי עשיר יותר עם system prompt מורחב
+graphify extract ./docs --no-cluster # חילוץ גולמי בלבד, דילוג על אשכול
+graphify extract ./docs --timing # הדפסת זמני ריצה פר-שלב ל-stderr (עובד גם ב-cluster-only)
+graphify extract ./docs --force # דריסת graph.json גם אם לגרף החדש פחות צמתים (אחרי refactors או לניקוי כפילויות רפאים)
+graphify extract ./docs --dedup-llm # LLM כמכריע לזוגות ישויות עמומים (משתמש באותו מפתח API)
+graphify extract ./docs --global --as myrepo # חילוץ ורישום בגרף הגלובלי חוצה-הפרויקטים
+GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # הרמת תקרת הפלט לקורפוסים צפופים
+
+graphify export callflow-html # graphify-out/-callflow.html
+graphify export callflow-html --max-sections 8 # הגבלת מספר קטעי הארכיטקטורה שנוצרים
+graphify export callflow-html --output docs/arch.html
+graphify export callflow-html ./some-repo/graphify-out
+
+graphify global add graphify-out/graph.json --as myrepo # רישום גרף פרויקט אל ~/.graphify/global-graph.json
+graphify global remove myrepo # הסרת פרויקט מהגרף הגלובלי
+graphify global list # הצגת כל המאגרים הרשומים + ספירות צמתים/קשתות
+graphify global path # הדפסת הנתיב לקובץ הגרף הגלובלי
+
+graphify prs # לוח PR: CI, ביקורת, worktree, השפעת גרף
+graphify prs 42 # צלילה עמוקה ל-PR #42
+graphify prs --triage # דירוג טריאז' AI (מזהה backend אוטומטית מהסביבה)
+graphify prs --worktrees # מיפוי worktree ← branch ← PR
+graphify prs --conflicts # PRs שחולקים קהילות גרף (סיכון בסדר המיזוג)
+graphify prs --base main # סינון ל-PRs שמכוונים ל-branch בסיס מסוים
+graphify prs --repo owner/repo # הרצה מול מאגר GitHub אחר
+GRAPHIFY_TRIAGE_BACKEND=kimi graphify prs --triage # backend מסוים לטריאז'
+
+graphify clone https://github.com/karpathy/nanoGPT
+graphify merge-graphs a.json b.json --out merged.json
+graphify --version # הדפסת הגרסה המותקנת
+graphify watch ./src
+graphify check-update ./src
+graphify update ./src
+graphify update ./src --no-cluster # דילוג על אשכול מחדש, כתיבת גרף AST גולמי בלבד
+graphify update ./src --force # דריסה גם אם לגרף החדש פחות צמתים
+graphify cluster-only ./my-project
+graphify cluster-only ./my-project --graph path/to/graph.json # מיקום גרף מותאם
+graphify cluster-only ./my-project --max-concurrency 16 --batch-size 200 # תיוג קהילות מקבילי (גרפים גדולים)
+graphify cluster-only ./my-project --resolution 1.5 # יותר קהילות, קטנות יותר
+graphify cluster-only ./my-project --exclude-hubs 99 # החרגת צומתי p99 מהחלוקה
+graphify cluster-only ./my-project --no-label # השארת מצייני "Community N"
+graphify cluster-only ./my-project --backend=gemini # backend לשמות הקהילות
+graphify cluster-only ./my-project --backend=gemini --model gemini-2.5-pro # מודל מסוים
+graphify label ./my-project # מתן שמות (מחדש) לקהילות עם ה-backend המוגדר
+graphify label ./my-project --backend=openai --model gpt-4o # כפיית backend ומודל מסוימים
+```
+
+
+
+> **שמות קהילות:** בתוך סוכן (Claude Code, Gemini CLI) הסוכן נותן שמות לקהילות בעצמו. בהרצת ה-CLI החשוף, `cluster-only` נותן שמות אוטומטית עם ה-backend המוגדר (מובנה או ספק תואם-OpenAI מותאם) — העבירו `--no-label` להשארת `Community N`, או הריצו `graphify label` ליצירת שמות מחדש לפי דרישה.
+
+---
+
+## ללמוד עוד
+
+- [איך זה עובד](../how-it-works.md) — צינור החילוץ, זיהוי קהילות, ניקוד ביטחון, מדדים
+- [ARCHITECTURE.md](../../ARCHITECTURE.md) — פירוק מודולים, איך מוסיפים שפה
+- [אינטגרציות אופציונליות](../docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
+
+---
+
+## נבנה על graphify — Penpax
+
+[**Penpax**](https://graphifylabs.ai) היא השכבה התמידית שנבנתה מעל graphify — היא מיישמת את אותה גישת גרף על כל חיי העבודה שלכם: פגישות, היסטוריית דפדפן, מיילים, קבצים וקוד, ומתעדכנת ברציפות ברקע.
+
+נבנתה לאנשים שהעבודה שלהם מפוזרת על פני מאות שיחות ומסמכים שהם לעולם לא יוכלו לשחזר במלואם. ללא ענן, לגמרי על המכשיר.
+
+**גרסת ניסיון חינמית תושק בקרוב.** [הצטרפו לרשימת ההמתנה ←](https://graphifylabs.ai)
+
+---
+
+
+תרומה לפרויקט
+
+### הקמת סביבת פיתוח
+
+הפרויקט משתמש ב-[uv](https://docs.astral.sh/uv/) לתהליך הפיתוח. התקינו אותו פעם אחת, ואז:
+
+
+
+```bash
+git clone https://github.com/safishamsi/graphify.git
+cd graphify
+git checkout v8 # branch הפיתוח הפעיל
+
+# יצירת venv לפרויקט והתקנת graphify + כל התוספים + קבוצת dev
+# (pytest). uv מתקין את קבוצת התלויות dev כברירת מחדל; העבירו --no-dev לדילוג.
+uv sync --all-extras
+```
+
+
+
+אימות ההתקנה במצב עריכה:
+
+
+
+```bash
+uv run graphify --version
+uv run python -c "import graphify; print(graphify.__file__)"
+```
+
+
+
+### הרצת בדיקות
+
+
+
+```bash
+uv run pytest tests/ -q # הרצת החבילה המלאה
+uv run pytest tests/test_extract.py -q # מודול אחד
+uv run pytest tests/ -q -k "python" # סינון לפי שם
+```
+
+
-
-
-
-
-
+
-
+
Type `/graphify` in your AI coding assistant and it maps your entire project (code, docs, PDFs, images, videos) into a **knowledge graph** you can **query instead of grepping** through files.
+- **Code maps for free, fully local.** Code is parsed with tree-sitter AST: deterministic, no LLM, nothing leaves your machine. (Docs, PDFs, images and video use your assistant's model, or a configured API key, for a semantic pass.)
+- **Every edge is explained.** Each connection is tagged `EXTRACTED` (explicit in the source) or `INFERRED` (resolved by graphify), so you can tell what was read directly from what was inferred.
+- **Not a vector index.** No embeddings, no vector store: a real graph you traverse. Ask a question, trace the path between two things, or explain one concept.
+
@@ -35,7 +35,14 @@ Type `/graphify` in your AI coding assistant and it maps your entire project (co
The FastAPI codebase mapped by graphify. Every node is a concept, colors are detected communities, and the whole thing is clickable in graph.html.
-**Works in:** Claude Code, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, Amp, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi, Devin CLI, and Google Antigravity.
+**Get started** (30 seconds):
+
+```bash
+uv tool install graphifyy # install the CLI (or: pipx install graphifyy)
+graphify install # register the skill with your AI assistant
+```
+
+Then, in your AI assistant:
```
/graphify .
@@ -50,21 +57,7 @@ graphify-out/
└── graph.json the full graph — query it anytime without re-reading your files
```
----
-
-## What it does
-
-A knowledge graph is not a vector index: it is **deterministic**, **every edge is explained**, and it costs **zero tokens to build**.
-
-| Capability | What you get |
-|---|---|
-| **God nodes** | The most-connected concepts, so you see what everything flows through |
-| **Communities** | The graph split into subsystems (Leiden), with LLM-free labels |
-| **Cross-file links** | `calls` / `imports` / `inherits` / `mixes_in` resolved across ~40 languages via tree-sitter AST |
-| **Query, path, explain** | Ask a question, trace the path between two things, or explain one concept, all against `graph.json` |
-| **Rationale + doc refs** | `# NOTE:` / `# WHY:` comments and ADR/RFC citations become first-class nodes linked to the code |
-| **Beyond code** | Docs, PDFs, images, and video/audio all map into the same graph |
-| **Local-first** | AST plus a local embedder build the graph with zero LLM credits; your code never leaves your machine |
+**Works in** Claude Code, Cursor, Codex, Gemini CLI, GitHub Copilot, and 15+ more — [pick your platform](#install).
---
@@ -99,6 +92,22 @@ Every edge carries a **confidence tag** (`EXTRACTED` = explicit in the source, `
---
+## What it does
+
+What you get out of the box:
+
+| Capability | What you get |
+|---|---|
+| **God nodes** | The most-connected concepts, so you see what everything flows through |
+| **Communities** | The graph split into subsystems (Leiden), with LLM-free labels |
+| **Cross-file links** | `calls` / `imports` / `inherits` / `mixes_in` resolved across ~40 languages via tree-sitter AST |
+| **Query, path, explain** | Ask a question, trace the path between two things, or explain one concept, all against `graph.json` |
+| **Rationale + doc refs** | `# NOTE:` / `# WHY:` comments and ADR/RFC citations become first-class nodes linked to the code |
+| **Beyond code** | Docs, PDFs, images, and video/audio all map into the same graph |
+| **Local-first** | Code is parsed locally with tree-sitter (no LLM, nothing leaves your machine); only the semantic pass over docs/media calls a backend, and only if you configure one |
+
+---
+
## Benchmarks
| Benchmark | Metric | graphify | Field |
@@ -757,6 +766,7 @@ graphify label ./my-project --backend=openai --model gpt-4o # force a specific
- [How it works](docs/how-it-works.md) — the extraction pipeline, community detection, confidence scoring, benchmarks
- [ARCHITECTURE.md](ARCHITECTURE.md) — module breakdown, how to add a language
- [Optional integrations](docs/docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
+- [The Memory Layer](https://safishamsi.gumroad.com/l/qetvlo) — the book on the ideas behind graphify, the architecture end to end
---
@@ -828,3 +838,14 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to ad
+
+---
+
+## Community and links
+
+
+
+
+
+
+
From d1d1f412b2826fbce216fb957c417049dd61fde7 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Mon, 6 Jul 2026 23:28:32 +0100
Subject: [PATCH 015/292] fix(serve): match a punctuated label against
norm_label symmetrically in _find_node (#1704)
`_find_node` built its search term with `_search_tokens` (\w+ tokenization), so
"blockStream.ts" became "blockstream ts" (space where the '.' was) while the
node's stored `norm_label` keeps punctuation ("blockstream.ts"). The verbatim
case is already rescued by the `term == label_tokens` tier (the node label
tokenizes the same way), but that is a coincidence: if `label` and `norm_label`
diverge, an exactly-typed punctuated label fails to resolve through `explain`
even though `path`/`query` find it.
Add a punctuation-preserving `norm_query` (`_strip_diacritics(label).lower()`)
matched against `norm_label`/`bare_label` across the exact/prefix/substring tiers
(and fed to the trigram prefilter so candidates are not missed). Purely additive,
symmetric with how norm_label is stored. Regression tests cover the verbatim
file-label case and the label/norm_label divergence case that only norm_query
resolves.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/serve.py | 18 +++++++++++++++---
tests/test_serve.py | 24 ++++++++++++++++++++++++
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/graphify/serve.py b/graphify/serve.py
index 28cb46616..6d8b369df 100644
--- a/graphify/serve.py
+++ b/graphify/serve.py
@@ -664,13 +664,20 @@ def _find_node(G: nx.Graph, label: str) -> list[str]:
term = " ".join(_search_tokens(label))
if not term:
return []
+ # Punctuation-preserving normalized query. `term` tokenizes on \w+ (so
+ # "blockStream.ts" -> "blockstream ts", space where the '.' was), but a node's
+ # stored `norm_label` keeps punctuation ("blockstream.ts"). Matching only via
+ # `term`/`label_tokens` works when the node label tokenizes the same way, but is
+ # fragile if `label` and `norm_label` diverge. `norm_query` matches `norm_label`
+ # symmetrically so an exactly-typed punctuated label always resolves (#1704).
+ norm_query = _strip_diacritics(str(label)).lower().strip()
source_exact: list[str] = []
exact: list[str] = []
prefix: list[str] = []
substring: list[str] = []
# Trigram prefilter (graph-iteration order preserved so exact/prefix/substring
# ordering — and thus matches[0] — is byte-identical to the full scan).
- candidate_ids = _trigram_candidates(G, [term])
+ candidate_ids = _trigram_candidates(G, [term, norm_query])
node_iter = (
G.nodes(data=True) if candidate_ids is None
else ((nid, G.nodes[nid]) for nid in candidate_ids)
@@ -683,16 +690,21 @@ def _find_node(G: nx.Graph, label: str) -> list[str]:
nid_lower = nid.lower()
if term == source_tokens:
source_exact.append(nid)
- elif term == norm_label or term == bare_label or term == label_tokens or term == nid_lower:
+ elif (
+ term == norm_label or term == bare_label or term == label_tokens or term == nid_lower
+ or norm_query == norm_label or norm_query == bare_label
+ ):
exact.append(nid)
elif (
norm_label.startswith(term)
or bare_label.startswith(term)
or label_tokens.startswith(term)
or nid_lower.startswith(term)
+ or norm_label.startswith(norm_query)
+ or bare_label.startswith(norm_query)
):
prefix.append(nid)
- elif term in norm_label or term in label_tokens:
+ elif term in norm_label or term in label_tokens or norm_query in norm_label:
substring.append(nid)
if source_exact:
diff --git a/tests/test_serve.py b/tests/test_serve.py
index 2647aa1a8..9bf9aa6a8 100644
--- a/tests/test_serve.py
+++ b/tests/test_serve.py
@@ -135,6 +135,30 @@ def test_find_node_matches_full_punctuated_unicode_label():
assert _find_node(G, "Skill /auditar — Auditoría inquisitiva de enlaces") == ["n1"]
+def test_find_node_matches_punctuated_file_label_exactly():
+ # #1704: an exactly-typed punctuated file label must resolve through explain,
+ # just like it does through path/query.
+ G = nx.Graph()
+ G.add_node("f1", label="blockStream.ts", norm_label="blockstream.ts",
+ source_file="lib/blockStream.ts", source_location="L1")
+ G.add_node("f2", label="blockStream.test.ts", norm_label="blockstream.test.ts",
+ source_file="lib/blockStream.test.ts", source_location="L1")
+ assert _find_node(G, "blockStream.ts")[0] == "f1"
+ assert _find_node(G, "blockStream.test.ts")[0] == "f2"
+
+
+def test_find_node_resolves_when_label_and_norm_label_diverge():
+ # #1704 hardening: the tokenized-label tier only rescues the match by
+ # coincidence (label tokenizes the same as the query). When `label` and
+ # `norm_label` diverge, only the symmetric `norm_query == norm_label` match
+ # resolves it. Here label tokenizes to "blockstream" but norm_label is
+ # "blockstream.ts" — this fails without the norm_query path.
+ G = nx.Graph()
+ G.add_node("n1", label="BlockStream", norm_label="blockstream.ts",
+ source_file="lib/x.ts", source_location="L1")
+ assert _find_node(G, "blockStream.ts") == ["n1"]
+
+
# --- trigram candidate prefilter (the trigram index that shrinks the O(N) scan) ---
From 377dc7f38403a56a70f406d9a6224742c87e9a01 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 00:06:49 +0100
Subject: [PATCH 016/292] fix(extract): warn when code files have no AST
extractor instead of silently dropping them (#1689)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extensions like .r/.R (also .ejs, .ets) are in CODE_EXTENSIONS, so those files
are classified as code and counted in the scan, but there is no entry for them
in the extractor dispatch — so they produce zero nodes and are silently absent
from the graph while the CLI still reports success. The #1666 zero-node warning
deliberately skips them (it only fires when an extractor exists), so nothing
surfaced the gap.
extract() now emits a warning listing the offending extensions and counts
("N file(s) are classified as code but graphify has no AST extractor ...: .r (2)")
so a primarily-R (or .ejs/.ets) corpus no longer looks fully mapped when it is
not. Grouped by extension, fires only for files actually present with no
extractor (today: .ejs, .ets, .r). Adding real grammars for these remains the
follow-up; this removes the silent-data-loss now.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/extract.py | 23 +++++++++++++++++++++++
tests/test_extract.py | 26 ++++++++++++++++++++++++++
2 files changed, 49 insertions(+)
diff --git a/graphify/extract.py b/graphify/extract.py
index be7e99448..3d73ee7d8 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -16403,6 +16403,29 @@ def extract(
file=sys.stderr, flush=True,
)
+ # #1689: a file counted as code (extension in CODE_EXTENSIONS) but with no AST
+ # extractor wired up (e.g. .r/.R — there is no tree-sitter-r dispatch) silently
+ # contributes zero nodes. The #1666 warning above deliberately skips these (it
+ # only fires when an extractor exists), so surface them explicitly, grouped by
+ # extension, rather than reporting success as if the language were mapped.
+ from graphify.detect import CODE_EXTENSIONS as _CODE_EXTS
+ _no_extractor: dict[str, int] = {}
+ for _p in paths:
+ _ext = _p.suffix.lower()
+ if _ext in _CODE_EXTS and _get_extractor(_p) is None:
+ _no_extractor[_ext] = _no_extractor.get(_ext, 0) + 1
+ if _no_extractor:
+ _by_count = ", ".join(
+ f"{ext} ({n})" for ext, n in sorted(_no_extractor.items(), key=lambda kv: (-kv[1], kv[0]))
+ )
+ _tot = sum(_no_extractor.values())
+ print(
+ f" warning: {_tot} file(s) are classified as code but graphify has no AST "
+ f"extractor for their language, so they contributed nothing to the graph: "
+ f"{_by_count}. Please open an issue to request support for these (#1689).",
+ file=sys.stderr, flush=True,
+ )
+
all_nodes: list[dict] = []
all_edges: list[dict] = []
all_raw_calls: list[dict] = []
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 4e76ad120..d67a763f2 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -1769,3 +1769,29 @@ def test_case_insensitive_suffix_filtering(tmp_path):
assert "myJSFunction()" in labels
assert "MyTSClass" in labels
+
+
+def test_extract_warns_on_code_files_with_no_ast_extractor(tmp_path, capsys):
+ # #1689: .r/.R is in CODE_EXTENSIONS (counted as code) but has no AST extractor,
+ # so R files silently contribute nothing. extract() must surface that instead of
+ # reporting success as if the language were mapped.
+ r1 = tmp_path / "analysis.R"; r1.write_text("f <- function(x) x + 1\n")
+ r2 = tmp_path / "helper.r"; r2.write_text("g <- function(y) y * 2\n")
+ py = tmp_path / "main.py"; py.write_text("def main():\n return 1\n")
+
+ result = extract([r1, r2, py], cache_root=tmp_path)
+ err = capsys.readouterr().err
+
+ assert "no AST extractor" in err
+ assert ".r (2)" in err # both R files grouped under the lowercased ext
+ assert "#1689" in err
+ # the Python file still extracts normally
+ labels = [n.get("label") for n in result["nodes"]]
+ assert any(str(l).startswith("main") for l in labels)
+
+
+def test_extract_no_warning_when_all_code_has_extractors(tmp_path, capsys):
+ py = tmp_path / "a.py"; py.write_text("def a():\n return 1\n")
+ extract([py], cache_root=tmp_path)
+ err = capsys.readouterr().err
+ assert "no AST extractor" not in err
From f5d50adbd003dba310f4111c24e3d67c3a5ba141 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 00:12:36 +0100
Subject: [PATCH 017/292] fix(extract): keep AST progress denominator
consistent to the end (#1693)
Intermediate progress lines count against len(uncached_work) ("X/Y uncached
files"), but the final line switched to total_files ("Y/Y files"), which includes
cached hits and files with no extractor that never entered uncached_work. On a
large corpus with unsupported-language files, the total jumped upward right after
99% with no explanation. Both the parallel and sequential final lines now report
the same uncached_work denominator, so the count no longer appears to change
mid-run.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/extract.py | 11 +++++++++--
tests/test_extract.py | 18 ++++++++++++++++++
2 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/graphify/extract.py b/graphify/extract.py
index 3d73ee7d8..dea5bd0b3 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -16254,8 +16254,13 @@ def _extract_parallel(
)
return False
if total_files >= _PROGRESS_INTERVAL:
+ # Report the same denominator the intermediate lines used (uncached files
+ # actually processed this run), not total_files — switching to the full
+ # corpus made the count jump upward at the end (cached hits + files with no
+ # extractor never entered uncached_work), which read as inconsistent (#1693).
+ _done = len(uncached_work)
print(
- f" AST extraction: {total_files}/{total_files} files (100%) [{max_workers} workers]",
+ f" AST extraction: {_done}/{_done} uncached files (100%) [{max_workers} workers]",
flush=True,
)
return True
@@ -16290,7 +16295,9 @@ def _extract_sequential(
save_cached(path, result, effective_root)
per_file[idx] = result
if total_files >= _PROGRESS_INTERVAL:
- print(f" AST extraction: {total_files}/{total_files} files (100%)", flush=True)
+ # Consistent denominator with the intermediate lines (#1693).
+ _done = len(uncached_work)
+ print(f" AST extraction: {_done}/{_done} uncached files (100%)", flush=True)
_PARALLEL_THRESHOLD = 20
diff --git a/tests/test_extract.py b/tests/test_extract.py
index d67a763f2..7cae30776 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -1795,3 +1795,21 @@ def test_extract_no_warning_when_all_code_has_extractors(tmp_path, capsys):
extract([py], cache_root=tmp_path)
err = capsys.readouterr().err
assert "no AST extractor" not in err
+
+
+def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsys):
+ # #1693: intermediate progress lines count against uncached_work; the final
+ # "100%" line must NOT switch to total_files (which includes cached hits and
+ # files with no extractor), or the count appears to jump upward at the end.
+ for i in range(100):
+ (tmp_path / f"m{i}.py").write_text(f"def f{i}():\n return {i}\n")
+ for i in range(5):
+ (tmp_path / f"s{i}.r").write_text(f"g{i} <- function(x) x\n") # no extractor
+ paths = sorted(tmp_path.glob("*.py")) + sorted(tmp_path.glob("*.r")) # total 105
+
+ extract(paths, cache_root=tmp_path, parallel=False)
+ out = capsys.readouterr().out
+
+ # final progress line reports the uncached count (100), not the total (105)
+ assert "100/100 uncached files (100%)" in out
+ assert "105/105 files" not in out, "final line must not switch to total_files (#1693)"
From f84a0af1bf7970d69c5b46fceceb55d444b5407e Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 10:57:52 +0100
Subject: [PATCH 018/292] fix(report): don't emit dangling [[_COMMUNITY_*]]
wikilinks by default (#1712)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GRAPH_REPORT.md rendered the Community Hubs section as Obsidian wikilinks, but
the `_COMMUNITY_*.md` notes they target are only created by the opt-in
`--obsidian` export — and the report is written at build time, before any export
runs. So on a default run every link dangled: inside an Obsidian vault they
spawned phantom `_COMMUNITY_*` nodes in the graph view, and outside Obsidian they
rendered as literal brackets that navigate nowhere.
`generate()` now takes `obsidian: bool = False`; by default the hubs render as a
plain list, and the wikilink form is emitted only when a caller opts in. The
Obsidian export's own community notes already cross-link each other, so the vault
stays navigable without the report's links. Mirrors the #1444/#1465 portability
fix that was applied to `export wiki`.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/report.py | 16 ++++++++++++----
tests/test_report.py | 19 +++++++++++++++++++
2 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/graphify/report.py b/graphify/report.py
index 5bac06d05..248bce9a1 100644
--- a/graphify/report.py
+++ b/graphify/report.py
@@ -82,6 +82,7 @@ def generate(
min_community_size: int = 3,
built_at_commit: str | None = None,
learning: dict | None = None,
+ obsidian: bool = False,
) -> str:
today = date.today().isoformat()
@@ -140,14 +141,21 @@ def generate(
"- Run `graphify update .` after code changes (no API cost).",
]
- # Community hub navigation - links to _COMMUNITY_*.md files in the Obsidian vault.
- # Without these, GRAPH_REPORT.md is a dead-end and the vault splits into disconnected components.
+ # 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}")
- safe = _safe_community_name(label)
- lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
+ if obsidian:
+ safe = _safe_community_name(label)
+ lines.append(f"- [[_COMMUNITY_{safe}|{label}]]")
+ else:
+ lines.append(f"- {label}")
lines += [
"",
diff --git a/tests/test_report.py b/tests/test_report.py
index 00be0f36d..767e2ba34 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -135,3 +135,22 @@ def test_import_cycles_section_absent_for_documents_only_corpus():
tokens = {"input": 0, "output": 0}
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project")
assert "## Import Cycles" not in report
+
+
+def test_report_hubs_are_plain_text_by_default():
+ # #1712: without --obsidian the _COMMUNITY_*.md notes don't exist, so wikilinks
+ # would dangle (and pollute an Obsidian vault's graph view). Default to plain text.
+ G, communities, cohesion, labels, gods, surprises, detection, tokens = make_inputs()
+ labels = {cid: f"Widget {cid}" for cid in communities}
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project", min_community_size=1)
+ assert "## Community Hubs (Navigation)" in report
+ assert "[[_COMMUNITY_" not in report, "must not emit dangling Obsidian wikilinks by default (#1712)"
+ assert any(f"- Widget {cid}" in report for cid in communities)
+
+
+def test_report_hubs_use_wikilinks_when_obsidian():
+ # The opt-in path keeps the vault-navigable wikilink form.
+ G, communities, cohesion, labels, gods, surprises, detection, tokens = make_inputs()
+ labels = {cid: f"Widget {cid}" for cid in communities}
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, "./project", min_community_size=1, obsidian=True)
+ assert "[[_COMMUNITY_" in report
From 23457d11ff3c01fd51413a2c9c22912ab7d295aa Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 11:19:18 +0100
Subject: [PATCH 019/292] feat(detect): surface unclassified files instead of
dropping them silently (#1692)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When classify_file() returned None — an extensionless, non-shebang file
(Dockerfile, Gemfile, Makefile, Rakefile, LICENSE, ...) or an unsupported
extension — the file left no trace at all: not counted, not listed, nothing.
A user had no way to tell from graphify's output that those files were even
considered.
detect() now collects these into an "unclassified" list in its result, and
`graphify extract` prints a one-line summary after the scan counts:
"N file(s) not classified (no supported extension or shebang), skipped:
Dockerfile, Makefile, ...". Real code/docs are unaffected. This is the
visibility half of the issue; wiring up extractors/manifest handling for
Dockerfile/Makefile-style files remains a separate feature.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/__main__.py | 11 +++++++++++
graphify/detect.py | 9 +++++++++
tests/test_detect.py | 22 ++++++++++++++++++++++
3 files changed, 42 insertions(+)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 8d37c911e..285d19c1b 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -4673,6 +4673,17 @@ def _parse_float(name: str, raw: str) -> float:
f"{len(doc_files)} docs, {len(paper_files)} papers, "
f"{len(image_files)} images"
)
+ # Surface files that were seen but not classified (extensionless non-shebang
+ # project files like Dockerfile/Makefile, or unsupported extensions), so they
+ # are no longer invisible in graphify's own output (#1692).
+ _unclassified = detection.get("unclassified", []) if isinstance(detection, dict) else []
+ if _unclassified:
+ _names = ", ".join(sorted({Path(p).name for p in _unclassified})[:6])
+ _more = f" (+{len(_unclassified) - 6} more)" if len(_unclassified) > 6 else ""
+ print(
+ f"[graphify extract] {len(_unclassified)} file(s) not classified "
+ f"(no supported extension or shebang), skipped: {_names}{_more}"
+ )
stages.mark("detect")
# Resolve the LLM backend only now that we know whether the corpus
diff --git a/graphify/detect.py b/graphify/detect.py
index c9d7792c7..85f4b8b8b 100644
--- a/graphify/detect.py
+++ b/graphify/detect.py
@@ -1086,6 +1086,7 @@ def _wc(path: Path) -> int:
return _cache.cached_word_count(path, root, count_words)
skipped_sensitive: list[str] = []
+ unclassified: list[str] = []
ignore_patterns = _load_graphifyignore(root)
ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan
# CLI --exclude patterns are anchored at the scan root and appended last
@@ -1171,6 +1172,13 @@ def _wc(path: Path) -> int:
skipped_sensitive.append(str(p))
continue
ftype = classify_file(p)
+ if not ftype:
+ # Considered but unclassifiable: an extension not in any supported set,
+ # or an extensionless, non-shebang file (Dockerfile, Gemfile, Makefile,
+ # Rakefile, LICENSE, ...). Previously these left no trace at all — not
+ # counted, not listed — so a user couldn't tell they were seen (#1692).
+ unclassified.append(str(p))
+ continue
if ftype:
if p.suffix.lower() in GOOGLE_WORKSPACE_EXTENSIONS:
if not google_workspace:
@@ -1236,6 +1244,7 @@ def _wc(path: Path) -> int:
"needs_graph": needs_graph,
"warning": warning,
"skipped_sensitive": skipped_sensitive,
+ "unclassified": sorted(unclassified),
"graphifyignore_patterns": len(ignore_patterns),
"scan_root": str(root.resolve()),
}
diff --git a/tests/test_detect.py b/tests/test_detect.py
index 0c07e24fd..b26d37ec5 100644
--- a/tests/test_detect.py
+++ b/tests/test_detect.py
@@ -1575,3 +1575,25 @@ def test_convert_office_file_does_not_rewrite_existing_sidecar(tmp_path, monkeyp
second = detect_mod.convert_office_file(src, out_dir)
assert second == first
assert second.stat().st_mtime_ns == mtime_before
+
+
+def test_detect_records_unclassified_extensionless_files(tmp_path):
+ # #1692: extensionless, non-shebang project files (Dockerfile, Makefile, ...)
+ # were considered but left no trace. detect() now lists them under
+ # "unclassified" so they can be surfaced instead of silently vanishing.
+ (tmp_path / "app.py").write_text("def f():\n return 1\n")
+ (tmp_path / "Dockerfile").write_text("FROM python:3.12\nRUN pip install x\n")
+ (tmp_path / "Makefile").write_text("build:\n\techo hi\n")
+ (tmp_path / "LICENSE").write_text("MIT License\n")
+ res = detect(tmp_path)
+ unclassified = sorted(Path(p).name for p in res.get("unclassified", []))
+ assert unclassified == ["Dockerfile", "LICENSE", "Makefile"]
+ # real code is still classified, not swept into unclassified
+ assert any("app.py" in f for f in res["files"].get("code", []))
+
+
+def test_detect_unclassified_empty_when_all_supported(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1\n")
+ (tmp_path / "README.md").write_text("# hi\n")
+ res = detect(tmp_path)
+ assert res.get("unclassified", []) == []
From ac6bb274a17d37a7ea20678ab722ae0aea747a67 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 11:34:54 +0100
Subject: [PATCH 020/292] docs(skill): fix stale /graphify usage comment that
claimed an Obsidian vault by default (#1681)
The Usage block's bare `/graphify` comment read "full pipeline on current
directory -> Obsidian vault", contradicting Step 6 (HTML viz always; Obsidian
vault only when --obsidian is explicitly given). The comment predated the
opt-in change and told agents a bare /graphify produces a vault. It now reads
"full pipeline on current directory (HTML viz; add --obsidian for a vault)".
Fixed at the skillgen source (fragments/core/core.md + the aider monolith
override), added a sanctioned-diff predicate so the monolith round-trip guard
allows the change, and re-blessed the expected/ snapshots. Every generated
skill-*.md now carries the corrected comment; the only content change across
all rendered files is this one line.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/skill-agents.md | 2 +-
graphify/skill-aider.md | 2 +-
graphify/skill-amp.md | 2 +-
graphify/skill-claw.md | 2 +-
graphify/skill-codex.md | 2 +-
graphify/skill-copilot.md | 2 +-
graphify/skill-droid.md | 2 +-
graphify/skill-kilo.md | 2 +-
graphify/skill-kiro.md | 2 +-
graphify/skill-opencode.md | 2 +-
graphify/skill-pi.md | 2 +-
graphify/skill-trae.md | 2 +-
graphify/skill-vscode.md | 2 +-
graphify/skill-windows.md | 2 +-
graphify/skill.md | 2 +-
tools/skillgen/expected/graphify__skill-agents.md | 2 +-
tools/skillgen/expected/graphify__skill-aider.md | 2 +-
tools/skillgen/expected/graphify__skill-amp.md | 2 +-
tools/skillgen/expected/graphify__skill-claw.md | 2 +-
tools/skillgen/expected/graphify__skill-codex.md | 2 +-
tools/skillgen/expected/graphify__skill-copilot.md | 2 +-
tools/skillgen/expected/graphify__skill-droid.md | 2 +-
tools/skillgen/expected/graphify__skill-kilo.md | 2 +-
tools/skillgen/expected/graphify__skill-kiro.md | 2 +-
tools/skillgen/expected/graphify__skill-opencode.md | 2 +-
tools/skillgen/expected/graphify__skill-pi.md | 2 +-
tools/skillgen/expected/graphify__skill-trae.md | 2 +-
tools/skillgen/expected/graphify__skill-vscode.md | 2 +-
tools/skillgen/expected/graphify__skill-windows.md | 2 +-
tools/skillgen/expected/graphify__skill.md | 2 +-
tools/skillgen/fragments/core/aider.md | 2 +-
tools/skillgen/fragments/core/core.md | 2 +-
tools/skillgen/gen.py | 12 ++++++++++++
33 files changed, 44 insertions(+), 32 deletions(-)
diff --git a/graphify/skill-agents.md b/graphify/skill-agents.md
index 2e19931fa..1b3da7c56 100644
--- a/graphify/skill-agents.md
+++ b/graphify/skill-agents.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md
index 2ccf5326c..3559b4802 100644
--- a/graphify/skill-aider.md
+++ b/graphify/skill-aider.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify --mode deep # thorough extraction, richer INFERRED edges
/graphify --update # incremental - re-extract only new/changed files
diff --git a/graphify/skill-amp.md b/graphify/skill-amp.md
index 2e19931fa..1b3da7c56 100644
--- a/graphify/skill-amp.md
+++ b/graphify/skill-amp.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md
index b3542438d..a53809df6 100644
--- a/graphify/skill-claw.md
+++ b/graphify/skill-claw.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md
index e6e411ea0..36cadce59 100644
--- a/graphify/skill-codex.md
+++ b/graphify/skill-codex.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md
index b3542438d..a53809df6 100644
--- a/graphify/skill-copilot.md
+++ b/graphify/skill-copilot.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md
index d1147b903..2bef076dc 100644
--- a/graphify/skill-droid.md
+++ b/graphify/skill-droid.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-kilo.md b/graphify/skill-kilo.md
index c45578165..d3aca08c2 100644
--- a/graphify/skill-kilo.md
+++ b/graphify/skill-kilo.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md
index b3542438d..a53809df6 100644
--- a/graphify/skill-kiro.md
+++ b/graphify/skill-kiro.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md
index 684e0e223..2aa66f5b3 100644
--- a/graphify/skill-opencode.md
+++ b/graphify/skill-opencode.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md
index b3542438d..a53809df6 100644
--- a/graphify/skill-pi.md
+++ b/graphify/skill-pi.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md
index ac16b925d..c1635cde7 100644
--- a/graphify/skill-trae.md
+++ b/graphify/skill-trae.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md
index 31a3352f7..30247498e 100644
--- a/graphify/skill-vscode.md
+++ b/graphify/skill-vscode.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md
index 5a55c577b..b298f29ab 100644
--- a/graphify/skill-windows.md
+++ b/graphify/skill-windows.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/graphify/skill.md b/graphify/skill.md
index b3542438d..a53809df6 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-agents.md b/tools/skillgen/expected/graphify__skill-agents.md
index 2e19931fa..1b3da7c56 100644
--- a/tools/skillgen/expected/graphify__skill-agents.md
+++ b/tools/skillgen/expected/graphify__skill-agents.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-aider.md b/tools/skillgen/expected/graphify__skill-aider.md
index 2ccf5326c..3559b4802 100644
--- a/tools/skillgen/expected/graphify__skill-aider.md
+++ b/tools/skillgen/expected/graphify__skill-aider.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify --mode deep # thorough extraction, richer INFERRED edges
/graphify --update # incremental - re-extract only new/changed files
diff --git a/tools/skillgen/expected/graphify__skill-amp.md b/tools/skillgen/expected/graphify__skill-amp.md
index 2e19931fa..1b3da7c56 100644
--- a/tools/skillgen/expected/graphify__skill-amp.md
+++ b/tools/skillgen/expected/graphify__skill-amp.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-claw.md b/tools/skillgen/expected/graphify__skill-claw.md
index b3542438d..a53809df6 100644
--- a/tools/skillgen/expected/graphify__skill-claw.md
+++ b/tools/skillgen/expected/graphify__skill-claw.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-codex.md b/tools/skillgen/expected/graphify__skill-codex.md
index e6e411ea0..36cadce59 100644
--- a/tools/skillgen/expected/graphify__skill-codex.md
+++ b/tools/skillgen/expected/graphify__skill-codex.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-copilot.md b/tools/skillgen/expected/graphify__skill-copilot.md
index b3542438d..a53809df6 100644
--- a/tools/skillgen/expected/graphify__skill-copilot.md
+++ b/tools/skillgen/expected/graphify__skill-copilot.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-droid.md b/tools/skillgen/expected/graphify__skill-droid.md
index d1147b903..2bef076dc 100644
--- a/tools/skillgen/expected/graphify__skill-droid.md
+++ b/tools/skillgen/expected/graphify__skill-droid.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-kilo.md b/tools/skillgen/expected/graphify__skill-kilo.md
index c45578165..d3aca08c2 100644
--- a/tools/skillgen/expected/graphify__skill-kilo.md
+++ b/tools/skillgen/expected/graphify__skill-kilo.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-kiro.md b/tools/skillgen/expected/graphify__skill-kiro.md
index b3542438d..a53809df6 100644
--- a/tools/skillgen/expected/graphify__skill-kiro.md
+++ b/tools/skillgen/expected/graphify__skill-kiro.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-opencode.md b/tools/skillgen/expected/graphify__skill-opencode.md
index 684e0e223..2aa66f5b3 100644
--- a/tools/skillgen/expected/graphify__skill-opencode.md
+++ b/tools/skillgen/expected/graphify__skill-opencode.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-pi.md b/tools/skillgen/expected/graphify__skill-pi.md
index b3542438d..a53809df6 100644
--- a/tools/skillgen/expected/graphify__skill-pi.md
+++ b/tools/skillgen/expected/graphify__skill-pi.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-trae.md b/tools/skillgen/expected/graphify__skill-trae.md
index ac16b925d..c1635cde7 100644
--- a/tools/skillgen/expected/graphify__skill-trae.md
+++ b/tools/skillgen/expected/graphify__skill-trae.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-vscode.md b/tools/skillgen/expected/graphify__skill-vscode.md
index 31a3352f7..30247498e 100644
--- a/tools/skillgen/expected/graphify__skill-vscode.md
+++ b/tools/skillgen/expected/graphify__skill-vscode.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill-windows.md b/tools/skillgen/expected/graphify__skill-windows.md
index 5a55c577b..b298f29ab 100644
--- a/tools/skillgen/expected/graphify__skill-windows.md
+++ b/tools/skillgen/expected/graphify__skill-windows.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/expected/graphify__skill.md b/tools/skillgen/expected/graphify__skill.md
index b3542438d..a53809df6 100644
--- a/tools/skillgen/expected/graphify__skill.md
+++ b/tools/skillgen/expected/graphify__skill.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/fragments/core/aider.md b/tools/skillgen/fragments/core/aider.md
index 2ccf5326c..3559b4802 100644
--- a/tools/skillgen/fragments/core/aider.md
+++ b/tools/skillgen/fragments/core/aider.md
@@ -10,7 +10,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify --mode deep # thorough extraction, richer INFERRED edges
/graphify --update # incremental - re-extract only new/changed files
diff --git a/tools/skillgen/fragments/core/core.md b/tools/skillgen/fragments/core/core.md
index 0516b3a6e..8d8aab7aa 100644
--- a/tools/skillgen/fragments/core/core.md
+++ b/tools/skillgen/fragments/core/core.md
@@ -7,7 +7,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti
## Usage
```
-/graphify # full pipeline on current directory → Obsidian vault
+/graphify # full pipeline on current directory (HTML viz; add --obsidian for a vault)
/graphify # full pipeline on specific path
/graphify https://github.com// # clone repo then run full pipeline on it
/graphify https://github.com// --branch # clone a specific branch
diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py
index 0082e9e51..e742a62c2 100644
--- a/tools/skillgen/gen.py
+++ b/tools/skillgen/gen.py
@@ -877,6 +877,17 @@ def _is_shebang_allowlist_fix_line(line: str) -> bool:
return "[!a-zA-Z0-9/_." in line
+def _is_obsidian_usage_comment_line(line: str) -> bool:
+ """Whether a line is part of the ``/graphify`` usage-comment fix (#1681).
+
+ The Usage block's bare ``/graphify`` comment said "full pipeline on current
+ directory -> Obsidian vault", contradicting Step 6 (HTML always; Obsidian vault
+ only when ``--obsidian`` is explicitly given). The comment now describes the
+ real default. Both the old (removed) and new (added) comment forms match here.
+ """
+ return "# full pipeline on current directory" in line
+
+
# Every line that may differ between a rendered monolith and its pristine v8
# baseline. Each predicate documents one sanctioned change-class; a blank line is
# allowed because the multi-line fix blocks insert spacing. Anything else failing
@@ -892,6 +903,7 @@ def _is_shebang_allowlist_fix_line(line: str) -> bool:
_is_manifest_root_fix_line,
_is_no_api_key_fix_line,
_is_shebang_allowlist_fix_line,
+ _is_obsidian_usage_comment_line,
)
From 733ad08852267c02a08ffadc82f6de31d09096f1 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 11:46:51 +0100
Subject: [PATCH 021/292] fix(extract): don't force-parse MATLAB .m through the
Objective-C grammar (#1702)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`.m` is shared by Objective-C implementation files and MATLAB, but the extractor
dispatch routed every `.m` to extract_objc unconditionally. Feeding real MATLAB
to the Objective-C tree-sitter grammar yields root.has_error and garbage
nodes/edges — worse than skipping, because it pollutes the graph with wrong data.
_get_extractor now content-sniffs `.m` the same way `.h` already is: a genuine
Objective-C `.m` carries an ObjC directive (@implementation/@interface/@import/
#import) and still routes to extract_objc; a `.m` without one (MATLAB, Octave)
gets no extractor, so it is surfaced by the no-AST-extractor warning (#1689)
instead of mis-parsed. `.mm` is unambiguously Objective-C++ and is left untouched.
This stops the wrong-by-omission garbage; wiring a real tree-sitter-matlab
extractor (the issue's primary ask) remains a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/extract.py | 21 +++++++++++++++++++++
tests/test_extract.py | 31 +++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/graphify/extract.py b/graphify/extract.py
index dea5bd0b3..652261ba6 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -16063,6 +16063,20 @@ def _is_objc_header(path: Path) -> bool:
)
+def _is_objc_source(path: Path) -> bool:
+ """Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702).
+
+ `.m` is shared by Objective-C implementation files and MATLAB (also Octave).
+ The suffix map routes `.m` to extract_objc unconditionally, which force-parses
+ MATLAB through the Objective-C tree-sitter grammar and emits garbage nodes/edges
+ (worse than skipping). A genuine ObjC `.m` always carries an ObjC directive
+ (@implementation/@interface/@import/#import); MATLAB has none of them. Reuses
+ the same marker set as the `.h` sniff. `.mm` is unambiguously Objective-C++ and
+ is not sniffed.
+ """
+ return _is_objc_header(path)
+
+
def _is_cpp_header(path: Path) -> bool:
"""Whether a `.h` file is C++ rather than plain C (#1547).
@@ -16106,6 +16120,13 @@ def _get_extractor(path: Path) -> Any | None:
# grammar has no class_specifier). Reroute to extract_cpp (#1547).
if _is_cpp_header(path):
return extract_cpp
+ # `.m` is Objective-C OR MATLAB. extract_objc unconditionally would force-parse
+ # MATLAB through the ObjC grammar into garbage (#1702). Route to extract_objc
+ # only when the file actually looks like Objective-C; otherwise leave it without
+ # an extractor (surfaced by the no-AST-extractor warning, #1689) rather than
+ # mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc.
+ if suffix == ".m" and not _is_objc_source(path):
+ return None
# Extensionless files: resolve by shebang, mirroring detect.classify_file.
# Without this, detect labels e.g. `#!/usr/bin/env bash` CLIs as code but
# extraction returns no extractor and the file silently contributes nothing.
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 7cae30776..04baf3926 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -1813,3 +1813,34 @@ def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsy
# final progress line reports the uncached count (100), not the total (105)
assert "100/100 uncached files (100%)" in out
assert "105/105 files" not in out, "final line must not switch to total_files (#1693)"
+
+
+def test_get_extractor_routes_matlab_m_away_from_objc(tmp_path):
+ # #1702: .m is shared by Objective-C and MATLAB. A real ObjC .m still routes to
+ # extract_objc, but a MATLAB .m must NOT be force-parsed by the ObjC grammar
+ # (which produces garbage) — it gets no extractor instead.
+ from graphify.extract import _get_extractor, extract_objc
+
+ objc = tmp_path / "Foo.m"
+ objc.write_text('#import "Foo.h"\n@implementation Foo\n- (void)bar {}\n@end\n')
+ matlab_fn = tmp_path / "solver.m"
+ matlab_fn.write_text("function y = solver(x)\n y = x + 1;\nend\n")
+ matlab_cls = tmp_path / "Model.m"
+ matlab_cls.write_text("classdef Model\n methods\n function run(obj); end\n end\nend\n")
+ mm = tmp_path / "x.mm"
+ mm.write_text("#import \n@implementation X\n@end\n")
+
+ assert _get_extractor(objc) is extract_objc # real ObjC .m -> objc
+ assert _get_extractor(matlab_fn) is None # MATLAB function -> no garbage
+ assert _get_extractor(matlab_cls) is None # MATLAB classdef -> no garbage
+ assert _get_extractor(mm) is extract_objc # .mm is unambiguously ObjC++
+
+
+def test_matlab_m_not_extracted_as_garbage(tmp_path, capsys):
+ # End to end: a MATLAB .m produces no (garbage) nodes and is surfaced by the
+ # no-AST-extractor warning (#1702 + #1689), rather than mis-parsed as ObjC.
+ m = tmp_path / "controller.m"
+ m.write_text("function u = controller(x)\n u = -x;\nend\n")
+ result = extract([m], cache_root=tmp_path)
+ assert result["nodes"] == [] # no garbage ObjC nodes
+ assert "no AST extractor" in capsys.readouterr().err # surfaced, not silent
From 53efaf89b68190d367feb73f9ef5dba15899377c Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Tue, 7 Jul 2026 12:36:16 +0100
Subject: [PATCH 022/292] Release 0.9.9
Fixes since 0.9.8: explain punctuated-label matching (#1704); surface code files
with no AST extractor instead of dropping them silently (#1689); consistent
AST-extraction progress denominator (#1693); no dangling Obsidian wikilinks in
GRAPH_REPORT.md by default (#1712); MATLAB .m no longer force-parsed by the
Objective-C grammar (#1702); corrected the /graphify usage comment in the skill
files (#1681); surface unclassified files (Dockerfile/Makefile/...) instead of
vanishing (#1692).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 10 ++++++++++
pyproject.toml | 2 +-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fc06fbcf..ad4860419 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
+## 0.9.9 (2026-07-07)
+
+- Fix: `graphify explain` resolves an exactly-typed punctuated label symmetrically against `norm_label` (#1704). The search term tokenized on `\w+` ("blockStream.ts" -> "blockstream ts", space where the '.' was) while a node's stored `norm_label` keeps punctuation ("blockstream.ts"). The verbatim case was already rescued by the tokenized-label tier, but that broke if a node's `label` and `norm_label` diverged; a punctuation-preserving `norm_query` is now matched against `norm_label` across the exact/prefix/substring tiers (and fed to the trigram prefilter), so it is robust by construction.
+- Fix: code files with no AST extractor are surfaced instead of silently dropped (#1689, thanks for the precise root-cause). `.r`/`.R` (also `.ejs`, `.ets`) are in `CODE_EXTENSIONS` so they are counted as code, but there is no extractor for them, so they produced zero nodes with no warning. `extract` now prints a grouped warning ("N file(s) are classified as code but graphify has no AST extractor ...: .r (17)"). Adding a real `tree-sitter-r` extractor remains a follow-up.
+- Fix: the AST-extraction progress line keeps a consistent denominator to the end (#1693). Intermediate lines counted against `len(uncached_work)` but the final line switched to `total_files` (which includes cached hits and no-extractor files), so on a large corpus the count appeared to jump upward right after 99%. Both the parallel and sequential final lines now use the `uncached_work` denominator.
+- Fix: `GRAPH_REPORT.md` no longer emits dangling `[[_COMMUNITY_*]]` Obsidian wikilinks by default (#1712). The `_COMMUNITY_*.md` notes those links target are only created by the opt-in `--obsidian` export, and the report is written at build time before any export, so on a default run every link dangled (spawning phantom nodes in a vault's graph view, literal brackets elsewhere). The Community Hubs section now renders as plain text by default; the wikilink form is behind an `obsidian=True` opt-in.
+- Fix: `.m` files are no longer force-parsed by the Objective-C grammar when they are MATLAB (#1702, thanks @catalystdream for the diagnosis). `.m` is shared by Objective-C and MATLAB, but the dispatch routed every `.m` to `extract_objc`, which turned real MATLAB into garbage nodes/edges. `.m` is now content-sniffed like `.h`: a genuine Objective-C `.m` (with `@implementation`/`@interface`/`@import`/`#import`) still routes to `extract_objc`; a MATLAB `.m` gets no extractor and is surfaced by the #1689 warning rather than mis-parsed. `.mm` is unchanged (unambiguously Objective-C++). A real `tree-sitter-matlab` extractor remains a follow-up.
+- Fix: the `/graphify` usage comment in the skill files no longer claims a bare `/graphify` produces an Obsidian vault by default (#1681, thanks for the audit). It now reads "full pipeline on current directory (HTML viz; add `--obsidian` for a vault)", matching Step 6. Fixed at the skillgen source so every generated `skill-*.md` variant carries the corrected comment.
+- Feat: files graphify sees but cannot classify are surfaced instead of vanishing (#1692). Extensionless, non-shebang project files (Dockerfile, Gemfile, Makefile, Rakefile, LICENSE, ...) and unsupported extensions previously left no trace at all. `detect` now collects them into an `unclassified` list, and `graphify extract` reports "N file(s) not classified (no supported extension or shebang), skipped: ...". Actually extracting Dockerfile/Makefile-style content remains a follow-up.
+
## 0.9.8 (2026-07-06)
- Fix: the Claude Code / Codebuddy `PreToolUse` and Gemini CLI `BeforeTool` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard ` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. The Gemini `BeforeTool` hook got the same treatment (`graphify hook-guard gemini`), which also removes its dependency on a bare `python` being on PATH. Codex stays a no-op there because Codex Desktop rejects `additionalContext`.
diff --git a/pyproject.toml b/pyproject.toml
index aa154a49b..2f3aa24ee 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.9.8"
+version = "0.9.9"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
From cf36d102924ff97db655f1389c7c5b44912b47a4 Mon Sep 17 00:00:00 2001
From: ivanzhilovich
Date: Tue, 7 Jul 2026 18:04:53 +0200
Subject: [PATCH 023/292] fix(extract): emit Java enum constants as nodes with
case_of edges
---
graphify/extract.py | 32 ++++++++++++++++++++++++++++++++
tests/fixtures/sample.java | 11 +++++++++++
tests/test_languages.py | 9 +++++++++
3 files changed, 52 insertions(+)
diff --git a/graphify/extract.py b/graphify/extract.py
index 652261ba6..b81245992 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -2933,6 +2933,32 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s
return False
+# ── Java extra walk for enum constants ───────────────────────────────────────
+
+def _java_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
+ nodes: list, edges: list, seen_ids: set, function_bodies: list,
+ parent_class_nid: str | None, add_node_fn, add_edge_fn,
+ walk_fn) -> bool:
+ """Handle enum_constant for Java. Returns True if handled."""
+ if node.type == "enum_constant" and parent_class_nid:
+ name_node = node.child_by_field_name("name")
+ if name_node is None:
+ return True
+ const_name = _read_text(name_node, source)
+ line = node.start_point[0] + 1
+ const_nid = _make_id(parent_class_nid, const_name)
+ add_node_fn(const_nid, const_name, line)
+ add_edge_fn(parent_class_nid, const_nid, "case_of", line)
+ # Anonymous-body constants (`MONDAY { void greet(){} }`): descend so the
+ # body's methods aren't dropped; const_nid attaches them to the constant.
+ for child in node.children:
+ if child.type == "class_body":
+ for member in child.children:
+ walk_fn(member, parent_class_nid=const_nid)
+ return True
+ return False
+
+
# ── Language configs ──────────────────────────────────────────────────────────
_PYTHON_CONFIG = LanguageConfig(
@@ -4855,6 +4881,12 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
ensure_named_node):
return
+ if config.ts_module == "tree_sitter_java":
+ if _java_extra_walk(node, source, file_nid, stem, str_path,
+ nodes, edges, seen_ids, function_bodies,
+ parent_class_nid, add_node, add_edge, walk):
+ return
+
if config.ts_module == "tree_sitter_ruby":
if _ruby_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
diff --git a/tests/fixtures/sample.java b/tests/fixtures/sample.java
index 826cfaae1..792fdf945 100644
--- a/tests/fixtures/sample.java
+++ b/tests/fixtures/sample.java
@@ -39,3 +39,14 @@ private List validate(List data) {
interface Processor {
List process();
}
+
+enum ErrorCode {
+ OK(0),
+ GAME_DONE(1001);
+
+ private final int code;
+
+ ErrorCode(int code) {
+ this.code = code;
+ }
+}
diff --git a/tests/test_languages.py b/tests/test_languages.py
index 9aa64b526..565bf0a29 100644
--- a/tests/test_languages.py
+++ b/tests/test_languages.py
@@ -115,6 +115,15 @@ def test_java_no_dangling_edges():
assert e["source"] in node_ids
+def test_java_enum_constants_have_case_of_edge():
+ r = extract_java(FIXTURES / "sample.java")
+ labels = _labels(r)
+ assert "OK" in labels
+ assert "GAME_DONE" in labels
+ assert ("ErrorCode", "OK") in _edge_labels(r, "case_of")
+ assert ("ErrorCode", "GAME_DONE") in _edge_labels(r, "case_of")
+
+
# ── C ────────────────────────────────────────────────────────────────────────
def test_c_no_error():
From 0ce8ea00e7bbc01672bbc2eb57407e6818486337 Mon Sep 17 00:00:00 2001
From: Thizeidler
Date: Tue, 7 Jul 2026 09:41:26 -0300
Subject: [PATCH 024/292] docs(security): clarify stdio-only claim now that
HTTP transport exists
SECURITY.md said graphify never runs a network listener and only
communicates over stdio. README.md documents an opt-in --transport http
mode (with --host/--api-key flags) for sharing one server across a team,
which does open a network listener. Update the claim to describe the
default (stdio, no listener) and the documented opt-in exception.
---
SECURITY.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/SECURITY.md b/SECURITY.md
index 297b7d8d3..795454b21 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -42,7 +42,7 @@ graphify is a **local development tool**. It runs as a Claude Code skill and opt
### What graphify does NOT do
-- Does not run a network listener (MCP server communicates over stdio only)
+- Does not run a network listener by default (stdio transport); `--transport http` is opt-in, documented in the README, and binds to `127.0.0.1` unless `--host 0.0.0.0` is passed
- Does not execute code from source files (tree-sitter parses ASTs - no eval/exec)
- Does not use `shell=True` in any subprocess call
- Does not store credentials or API keys
From 6c33e3aad73c6d37e789489c3dea691810247982 Mon Sep 17 00:00:00 2001
From: kaanrumin
Date: Wed, 8 Jul 2026 00:39:07 +0300
Subject: [PATCH 025/292] test(security): cover GRAPHIFY_MAX_GRAPH_BYTES
parsing; clarify unit docstring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_max_graph_file_bytes() resolves the graph-load memory-bomb cap and parses the
GRAPHIFY_MAX_GRAPH_BYTES env var (plain bytes, MB/GB suffix, fallbacks), but had
no behavioral tests — only the default constant was asserted.
- Add tests for: unset/blank -> default, plain integer, MB/GB suffix (binary),
case-insensitive and space-tolerant suffixes, unparseable -> default, and
non-positive -> default.
- Fix the docstring, which described the suffix as "decimal multipliers of 1024"
(self-contradictory). MB/GB are treated as binary (MiB/GiB); the tests lock
that behavior in.
No behavior change.
---
graphify/security.py | 3 ++-
tests/test_security.py | 52 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 54 insertions(+), 1 deletion(-)
diff --git a/graphify/security.py b/graphify/security.py
index d371e53c2..2dbe5bd77 100644
--- a/graphify/security.py
+++ b/graphify/security.py
@@ -38,7 +38,8 @@ def _max_graph_file_bytes() -> int:
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, decimal multipliers of 1024).
+ (``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.
diff --git a/tests/test_security.py b/tests/test_security.py
index d2e08c0d0..2f69be6b4 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -20,6 +20,7 @@
_MAX_FETCH_BYTES,
_MAX_GRAPH_FILE_BYTES,
_MAX_TEXT_BYTES,
+ _max_graph_file_bytes,
_METADATA_MAX_LIST_ITEMS,
_METADATA_MAX_VALUE_LEN,
_sanitize_metadata_string,
@@ -227,6 +228,57 @@ def test_graph_size_cap_default_is_512_mib():
assert _MAX_GRAPH_FILE_BYTES == 512 * 1024 * 1024
+# ---------------------------------------------------------------------------
+# _max_graph_file_bytes — GRAPHIFY_MAX_GRAPH_BYTES env-var parsing
+# ---------------------------------------------------------------------------
+
+def test_max_graph_bytes_default_when_unset(monkeypatch):
+ monkeypatch.delenv("GRAPHIFY_MAX_GRAPH_BYTES", raising=False)
+ assert _max_graph_file_bytes() == _MAX_GRAPH_FILE_BYTES
+
+
+def test_max_graph_bytes_default_when_blank(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", " ")
+ assert _max_graph_file_bytes() == _MAX_GRAPH_FILE_BYTES
+
+
+def test_max_graph_bytes_plain_integer(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", "671088640")
+ assert _max_graph_file_bytes() == 671088640
+
+
+def test_max_graph_bytes_mb_suffix_is_binary(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", "640MB")
+ assert _max_graph_file_bytes() == 640 * 1024 * 1024
+
+
+def test_max_graph_bytes_gb_suffix_is_binary(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", "2GB")
+ assert _max_graph_file_bytes() == 2 * 1024 * 1024 * 1024
+
+
+def test_max_graph_bytes_suffix_is_case_insensitive(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", "3gb")
+ assert _max_graph_file_bytes() == 3 * 1024 * 1024 * 1024
+
+
+def test_max_graph_bytes_tolerates_space_before_suffix(monkeypatch):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", "5 GB")
+ assert _max_graph_file_bytes() == 5 * 1024 * 1024 * 1024
+
+
+@pytest.mark.parametrize("bad", ["not-a-number", "1.5GB", "0x10", "640KB"])
+def test_max_graph_bytes_unparseable_falls_back(monkeypatch, bad):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", bad)
+ assert _max_graph_file_bytes() == _MAX_GRAPH_FILE_BYTES
+
+
+@pytest.mark.parametrize("nonpositive", ["0", "-1", "-4GB"])
+def test_max_graph_bytes_nonpositive_falls_back(monkeypatch, nonpositive):
+ monkeypatch.setenv("GRAPHIFY_MAX_GRAPH_BYTES", nonpositive)
+ assert _max_graph_file_bytes() == _MAX_GRAPH_FILE_BYTES
+
+
def test_graph_size_cap_under_limit_returns_none(tmp_path):
p = tmp_path / "graph.json"
p.write_text('{"nodes": [], "links": []}', encoding="utf-8")
From e5044c32532e49edbbf14bea1daeb4f0a418aa72 Mon Sep 17 00:00:00 2001
From: Francisco Javier Serna Barragan
<148145893+FranciscoJSBarragan@users.noreply.github.com>
Date: Mon, 6 Jul 2026 09:11:28 -0600
Subject: [PATCH 026/292] Fix rebuilds from deleted hook cwd
What changed
- Stabilize relative rebuild execution before graphify-out queue/lock setup.
- Recover from deleted transient hook CWDs when GRAPHIFY_REPO_ROOT points at the repository root.
- Fail cleanly when the current working directory is gone and no repo root fallback is available.
- Add regression coverage for both fallback and clean-failure paths.
Why
- Detached post-commit/post-checkout rebuilds can inherit a transient CWD that is deleted before the background process starts.
- _rebuild_code previously used relative graphify-out paths before Path.cwd()/watch_path resolution could be handled, causing FileNotFoundError: [Errno 2] No such file or directory.
Validation
- .venv/bin/pytest tests/test_watch.py -q: 54 passed, 2 skipped.
- .venv/bin/ruff check graphify/watch.py tests/test_watch.py: passed.
- .venv/bin/python -m py_compile graphify/watch.py tests/test_watch.py: passed.
- env -u PYTHONPATH -u PYTHONHOME PYTHONHASHSEED=0 .venv/bin/pytest -q: 2904 passed, 30 skipped.
Notes
- Full suite without PYTHONHASHSEED=0 exposed an unrelated ordering-sensitive labeling test; with the deterministic hash seed used by graphify hooks, the suite passes.
---
graphify/watch.py | 34 ++++++++++++++++++++++++++++++
tests/test_watch.py | 50 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 84 insertions(+)
diff --git a/graphify/watch.py b/graphify/watch.py
index ba3f09b17..94d4e8b20 100644
--- a/graphify/watch.py
+++ b/graphify/watch.py
@@ -657,6 +657,37 @@ def _json_text(data: dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
+def _stabilize_rebuild_cwd(watch_path: Path) -> bool:
+ """Ensure relative rebuild paths have a usable CWD before queue/lock setup.
+
+ Detached git hooks can inherit a transient working directory that is deleted
+ before the background rebuild starts. In that state Path.cwd(),
+ Path('.').resolve(), and relative graphify-out mkdirs raise FileNotFoundError
+ before the normal rebuild error handling can run. Hooks that know the repo
+ root export GRAPHIFY_REPO_ROOT so the rebuild can recover by chdir'ing there.
+ """
+ if watch_path.is_absolute():
+ return True
+
+ repo_root = os.environ.get("GRAPHIFY_REPO_ROOT", "").strip()
+ if repo_root and Path(repo_root).is_dir():
+ try:
+ os.chdir(repo_root)
+ return True
+ except OSError:
+ pass
+
+ try:
+ Path.cwd()
+ return True
+ except FileNotFoundError:
+ print(
+ "[graphify watch] Rebuild failed: current working directory "
+ "no longer exists and GRAPHIFY_REPO_ROOT is not set."
+ )
+ return False
+
+
def _rebuild_code(
watch_path: Path,
*,
@@ -690,6 +721,9 @@ def _rebuild_code(
Returns True on success, False on error or skipped-due-to-lock.
"""
+ if not _stabilize_rebuild_cwd(watch_path):
+ return False
+
out = watch_path / _GRAPHIFY_OUT
if acquire_lock:
# #1059: incremental (changed_paths is not None) hooks must not drop
diff --git a/tests/test_watch.py b/tests/test_watch.py
index fb9868677..f0a54690f 100644
--- a/tests/test_watch.py
+++ b/tests/test_watch.py
@@ -176,6 +176,56 @@ def test_graphify_root_preserves_absolute_when_user_supplied(tmp_path):
)
+def test_rebuild_code_deleted_cwd_without_repo_root_returns_false(tmp_path, monkeypatch, capsys):
+ """Detached hooks can inherit a CWD that no longer exists.
+
+ Without GRAPHIFY_REPO_ROOT, the rebuild should fail cleanly before creating
+ relative graphify-out queue/lock files.
+ """
+ from graphify.watch import _rebuild_code
+
+ old_cwd = Path.cwd()
+ gone = tmp_path / "gone"
+ gone.mkdir()
+ monkeypatch.delenv("GRAPHIFY_REPO_ROOT", raising=False)
+
+ os.chdir(gone)
+ gone.rmdir()
+ try:
+ assert _rebuild_code(Path("."), changed_paths=[Path("lib.py")]) is False
+ finally:
+ os.chdir(old_cwd)
+
+ out = capsys.readouterr().out
+ assert "current working directory no longer exists" in out
+
+
+def test_rebuild_code_deleted_cwd_uses_graphify_repo_root(tmp_path, monkeypatch):
+ """GRAPHIFY_REPO_ROOT lets detached hook rebuilds recover from a deleted CWD."""
+ from graphify.watch import _rebuild_code
+
+ old_cwd = Path.cwd()
+ corpus = tmp_path / "corpus"
+ corpus.mkdir()
+ (corpus / "lib.py").write_text("def f(): pass\n", encoding="utf-8")
+ gone = tmp_path / "gone"
+ gone.mkdir()
+ monkeypatch.setenv("GRAPHIFY_REPO_ROOT", str(corpus))
+
+ os.chdir(gone)
+ gone.rmdir()
+ try:
+ assert _rebuild_code(
+ Path("."),
+ changed_paths=[Path("lib.py")],
+ no_cluster=True,
+ ) is True
+ assert Path.cwd().resolve() == corpus.resolve()
+ assert (corpus / "graphify-out" / "graph.json").exists()
+ finally:
+ os.chdir(old_cwd)
+
+
def test_rebuild_code_evicts_nodes_from_deleted_files(tmp_path):
"""#1007: graphify update (_rebuild_code with no changed_paths) must remove
nodes and edges from files deleted since the last run."""
From 2ea37734c0f9adff317bb62cfadd942fb82024e5 Mon Sep 17 00:00:00 2001
From: "A.Levin"
Date: Tue, 7 Jul 2026 16:30:16 +0300
Subject: [PATCH 027/292] Checkpoint semantic cache per chunk so interrupted
runs resume
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Semantic extraction only wrote to the cache once, at the very end of the
run (save_semantic_cache in __main__ after extract_corpus_parallel returns).
A run interrupted partway — a crash, a kill, or a claude-cli/API run that
exits when it hits a rate limit — therefore lost every completed chunk and
restarted from scratch. On a large corpus with a slow local backend this can
throw away many hours of work.
Persist each chunk's results to the semantic cache as soon as it completes,
in both the serial and threaded paths of extract_corpus_parallel. Add a
merge_existing option to save_semantic_cache so a file split into slices
across several chunks accumulates its slices instead of the later chunk
overwriting the earlier one. The checkpoint is best-effort (a cache write
error never aborts extraction) and can be disabled with
GRAPHIFY_NO_INCREMENTAL_CACHE. Default behaviour of save_semantic_cache is
unchanged (merge_existing defaults to False).
---
graphify/cache.py | 14 ++++++++++++++
graphify/llm.py | 23 +++++++++++++++++++++++
2 files changed, 37 insertions(+)
diff --git a/graphify/cache.py b/graphify/cache.py
index bb3f9d593..35dc7cc11 100644
--- a/graphify/cache.py
+++ b/graphify/cache.py
@@ -533,12 +533,18 @@ def save_semantic_cache(
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
@@ -563,6 +569,14 @@ def save_semantic_cache(
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
diff --git a/graphify/llm.py b/graphify/llm.py
index f00ed0c84..560bbcb88 100644
--- a/graphify/llm.py
+++ b/graphify/llm.py
@@ -1903,6 +1903,27 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception |
# over session state. Force serial unless the user explicitly opts in.
if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1":
max_concurrency = 1
+ def _checkpoint_chunk(result: dict) -> None:
+ # Persist each chunk's semantic results to the cache as soon as it
+ # completes. Without this, the semantic cache is only written once, at
+ # the very end of the run (in __main__), so a run interrupted partway
+ # — a crash, a kill, or a claude-cli/API run that exits on a rate
+ # limit — loses every completed chunk and restarts from scratch. This
+ # is best-effort: a cache write failure must never abort extraction.
+ if os.environ.get("GRAPHIFY_NO_INCREMENTAL_CACHE"):
+ return
+ try:
+ from .cache import save_semantic_cache as _scs
+ _scs(
+ result.get("nodes", []),
+ result.get("edges", []),
+ result.get("hyperedges", []),
+ root=root,
+ merge_existing=True,
+ )
+ except Exception as _exc: # noqa: BLE001 — checkpoint is best-effort
+ print(f"[graphify] incremental cache checkpoint failed: {_exc}", file=sys.stderr)
+
workers = max(1, min(max_concurrency, total))
if workers == 1:
# Avoid thread pool overhead for single-worker runs (and keep
@@ -1915,6 +1936,7 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception |
continue
assert result is not None
_merge_into(merged, result)
+ _checkpoint_chunk(result)
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
else:
@@ -1939,6 +1961,7 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception |
continue
assert result is not None
results_by_idx[idx] = result
+ _checkpoint_chunk(result)
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
for idx in sorted(results_by_idx):
From 27b523e76981aa70f89400a300e39d8a0a721caf Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 01:14:12 +0100
Subject: [PATCH 028/292] test(cache): cover save_semantic_cache merge_existing
union + default overwrite (#1715)
The per-chunk checkpoint feature added merge_existing without test coverage.
Add tests asserting merge_existing=True unions a file's slices across chunks and
the default still overwrites (the authoritative final write in extract).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_cache.py | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 730265be4..9bf2cdc82 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -510,3 +510,33 @@ def test_semantic_prune_ignores_ast_and_tmp(tmp_path):
assert not (semantic_dir / "deadbeef.json").exists()
assert tmp_entry.exists(), "*.tmp temporaries must not be swept"
assert len(list(ast_dir.glob("*.json"))) == 1, "AST entries must not be touched"
+
+
+def test_save_semantic_cache_overwrites_by_default(tmp_path):
+ """Default save_semantic_cache replaces a file's cached entry (the final,
+ authoritative write in the extract pipeline)."""
+ from graphify.cache import save_semantic_cache
+ f = tmp_path / "doc.md"; f.write_text("# Doc\n")
+ save_semantic_cache([{"id": "a", "source_file": "doc.md"}], [], root=tmp_path)
+ save_semantic_cache([{"id": "b", "source_file": "doc.md"}], [], root=tmp_path)
+ cached = load_cached(f, root=tmp_path, kind="semantic")
+ ids = {n["id"] for n in cached["nodes"]}
+ assert ids == {"b"}, "default must overwrite, not accumulate"
+
+
+def test_save_semantic_cache_merge_existing_unions(tmp_path):
+ """#1715: merge_existing=True unions with the prior entry so a file split
+ across chunks (checkpointed per chunk) keeps every slice."""
+ from graphify.cache import save_semantic_cache
+ f = tmp_path / "big.md"; f.write_text("# Big\n")
+ # chunk 1 slice
+ save_semantic_cache([{"id": "a", "source_file": "big.md"}],
+ [{"source": "a", "target": "x", "source_file": "big.md"}],
+ root=tmp_path, merge_existing=True)
+ # chunk 2 slice for the same file
+ save_semantic_cache([{"id": "b", "source_file": "big.md"}], [],
+ root=tmp_path, merge_existing=True)
+ cached = load_cached(f, root=tmp_path, kind="semantic")
+ ids = {n["id"] for n in cached["nodes"]}
+ assert ids == {"a", "b"}, "merge_existing must union both chunk slices"
+ assert len(cached["edges"]) == 1
From bf7fa50f38180454b37e578f30629dcd03bdf628 Mon Sep 17 00:00:00 2001
From: edinaldoof
Date: Tue, 7 Jul 2026 12:48:35 -0300
Subject: [PATCH 029/292] fix(extract): never bind a cross-file call to a
definition in another language family
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cross-file call resolver matches raw-call callees against a repo-wide
label index with no language check. In a repo that mixes a web app with a
native Android app, a TSX callback passed by name (register(refreshHeading))
resolved to a same-named Kotlin method and shipped as an INFERRED
indirect_call edge — a phantom the extraction spec itself forbids ('calls
edges MUST stay within one language'). Direct calls from non-JS/TS callers
had the same hole with no gate at all: a bare Python call bound to the lone
same-named Kotlin fun. Found on a production Next.js + Android codebase,
where the phantom edge also inflated the Kotlin node's betweenness enough
to surface it as a top suggested question in GRAPH_REPORT.md.
Resolution candidates are now filtered by language interop family before
the single-candidate/import-evidence logic runs. Families are grouped by
REAL interop so legitimate cross-language resolution keeps working:
Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA share
headers (Swift bridges to ObjC), and JS/TS variants plus Vue/Svelte/Astro
SFCs compile into one module graph. Candidates whose family is unknown
(no source_file, non-code nodes) are never filtered, preserving the
previous permissive behavior, and callers with an unmapped extension skip
the guard entirely.
---
graphify/extract.py | 63 ++++++++++++
tests/test_cross_language_call_resolution.py | 103 +++++++++++++++++++
2 files changed, 166 insertions(+)
create mode 100644 tests/test_cross_language_call_resolution.py
diff --git a/graphify/extract.py b/graphify/extract.py
index b81245992..40b9c8b26 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -9860,6 +9860,52 @@ def _lang_is_case_insensitive(source_file: object) -> bool:
return Path(str(source_file)).suffix.lower() in _CASE_INSENSITIVE_EXTS
+# Language interop families for cross-file call resolution. A call in one language
+# can never bind by name to a definition in another family — a TSX component does
+# not invoke a Kotlin method, and a Python function does not invoke a Java one.
+# Families are grouped by REAL interop so legitimate cross-language resolution
+# keeps working: Kotlin/Java/Scala/Groovy share the JVM, C/C++/Objective-C/CUDA
+# share headers and symbols (Swift bridges to Objective-C), and JS/TS variants
+# (plus Vue/Svelte/Astro SFC script blocks) compile into one module graph.
+# Extensions absent from this map (docs, configs, unknown languages) resolve to
+# no family and are never filtered — same permissive default as before.
+_LANG_FAMILY_BY_EXT: dict[str, str] = {
+ # JS/TS module graph (SFCs embed JS/TS)
+ ".js": "jsts", ".jsx": "jsts", ".mjs": "jsts", ".cjs": "jsts",
+ ".ts": "jsts", ".tsx": "jsts", ".mts": "jsts", ".cts": "jsts",
+ ".vue": "jsts", ".svelte": "jsts", ".astro": "jsts",
+ # JVM interop
+ ".java": "jvm", ".kt": "jvm", ".kts": "jvm",
+ ".scala": "jvm", ".groovy": "jvm", ".gradle": "jvm",
+ # C-family: shared headers, Objective-C/C++ mix, Swift↔ObjC bridging
+ ".c": "native", ".h": "native", ".cpp": "native", ".cc": "native",
+ ".cxx": "native", ".hpp": "native", ".cu": "native", ".cuh": "native",
+ ".metal": "native", ".m": "native", ".mm": "native", ".swift": "native",
+ # Single-language families
+ ".py": "python",
+ ".go": "go",
+ ".rs": "rust",
+ ".rb": "ruby",
+ ".php": "php", ".phtml": "php", ".php3": "php", ".php4": "php",
+ ".php5": "php", ".php7": "php", ".phps": "php",
+ ".cs": "dotnet", ".razor": "dotnet", ".cshtml": "dotnet", ".xaml": "dotnet",
+ ".lua": "lua", ".luau": "lua",
+ ".zig": "zig",
+ ".ex": "elixir", ".exs": "elixir",
+ ".jl": "julia",
+ ".dart": "dart",
+ ".sh": "shell", ".bash": "shell",
+ ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
+}
+
+
+def _lang_family(source_file: object) -> str | None:
+ """Interop family of the file's language, or None when unknown/not code."""
+ if not source_file:
+ return None
+ return _LANG_FAMILY_BY_EXT.get(Path(str(source_file)).suffix.lower())
+
+
def _node_label_key(node: dict, fold: bool = False) -> str:
label = str(node.get("label", "")).strip()
key = re.sub(r"[^a-zA-Z0-9]+", "", label)
@@ -16780,6 +16826,23 @@ def extract(
candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
+ # Cross-language guard: never bind a call to a definition in a different
+ # language family. Name-only matching was resolving a TSX callback passed
+ # by name to a same-named Kotlin method in the Android half of the repo
+ # (and a Python call to a Kotlin fun) — phantom edges the extraction spec
+ # explicitly forbids. Candidates whose family is unknown (no source_file,
+ # non-code nodes) are kept, preserving the previous permissive behavior;
+ # real interop pairs (Kotlin↔Java, C↔C++↔ObjC, JS↔TS) share a family and
+ # still resolve.
+ caller_family = _lang_family(rc.get("source_file"))
+ if caller_family is not None:
+ candidates = [
+ c for c in candidates
+ if (candidate_family := _lang_family(nid_to_source_file.get(c))) is None
+ or candidate_family == caller_family
+ ]
+ if not candidates:
+ continue
caller = rc["caller_nid"]
# Resolve the caller's file via the raw_call's own source_file string,
# which is stable regardless of any caller_nid remap. An indirect
diff --git a/tests/test_cross_language_call_resolution.py b/tests/test_cross_language_call_resolution.py
new file mode 100644
index 000000000..62fe4c60f
--- /dev/null
+++ b/tests/test_cross_language_call_resolution.py
@@ -0,0 +1,103 @@
+"""Cross-language call resolution — a call in one language must never bind by
+name to a definition in another language family.
+
+The cross-file resolver matched raw-call callees against a repo-wide label
+index with no language check, so in a repo that mixes a web app with a native
+Android app a TSX callback passed by name (``register(refreshHeading)``)
+resolved to a same-named Kotlin method and shipped as an INFERRED
+``indirect_call`` edge — a phantom the extraction spec explicitly forbids
+("calls edges MUST stay within one language"). Direct calls from non-JS/TS
+languages had the same hole: a Python call bound to a Kotlin ``fun``.
+
+The fix filters resolution candidates by language interop family. Families are
+grouped by REAL interop so legitimate cross-language resolution keeps working:
+Kotlin/Java share the JVM, C/C++/Objective-C share headers, JS/TS variants
+compile into one module graph. Candidates with no known family (non-code
+nodes) are never filtered, preserving the previous permissive behavior.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+from graphify.extract import extract
+
+
+def _write(path: Path, text: str) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
+
+
+def _call_edges(files: list[Path], base: Path) -> set[tuple[str, str, str, str]]:
+ r = extract(files, cache_root=base, parallel=False)
+ lbl = {n["id"]: n["label"] for n in r["nodes"]}
+ return {
+ (lbl.get(e["source"], ""), lbl.get(e["target"], ""), e["relation"], e.get("confidence"))
+ for e in r["edges"] if e["relation"] in ("calls", "indirect_call")
+ }
+
+
+def test_tsx_callback_does_not_bind_to_kotlin_method(tmp_path: Path) -> None:
+ # The real-world symptom: a repo with a web app and a native Android app.
+ # A TSX component passes a callback by name; the only same-named definition
+ # repo-wide is a Kotlin method. No edge must be emitted.
+ _write(tmp_path / "web/Upcoming.tsx",
+ "declare function register(cb: () => void): void;\n"
+ "export function UpcomingPanel() {\n"
+ " register(refreshHeading);\n"
+ " return null;\n"
+ "}\n")
+ _write(tmp_path / "android/HeadingSensorBridge.kt",
+ "class HeadingSensorBridge {\n"
+ " fun refreshHeading() {\n"
+ " println(\"native sensor\")\n"
+ " }\n"
+ "}\n")
+ edges = _call_edges(sorted(tmp_path.rglob("*.tsx")) + sorted(tmp_path.rglob("*.kt")), tmp_path)
+ assert not any("refreshHeading" in t for _s, t, _r, _c in edges), edges
+
+
+def test_python_call_does_not_bind_to_kotlin_function(tmp_path: Path) -> None:
+ # Direct-call path (non-JS/TS callers have no import-evidence gate): a bare
+ # Python call must not resolve to the lone same-named Kotlin definition.
+ _write(tmp_path / "py/worker.py",
+ "def process():\n"
+ " return refreshHeading()\n")
+ _write(tmp_path / "android/HeadingSensorBridge.kt",
+ "class HeadingSensorBridge {\n"
+ " fun refreshHeading() {\n"
+ " println(\"native sensor\")\n"
+ " }\n"
+ "}\n")
+ edges = _call_edges(sorted(tmp_path.rglob("*.py")) + sorted(tmp_path.rglob("*.kt")), tmp_path)
+ assert not any("refreshHeading" in t for _s, t, _r, _c in edges), edges
+
+
+def test_same_language_callback_still_resolves(tmp_path: Path) -> None:
+ # Positive control: a TS callback passed by name with a same-language
+ # definition and import evidence keeps resolving as INFERRED indirect_call.
+ _write(tmp_path / "a.ts",
+ 'import { refreshHeading } from "./b";\n'
+ "declare function register(cb: () => void): void;\n"
+ "export function run() { register(refreshHeading); }\n")
+ _write(tmp_path / "b.ts",
+ "export function refreshHeading(): void {}\n")
+ edges = _call_edges([tmp_path / "a.ts", tmp_path / "b.ts"], tmp_path)
+ resolved = [e for e in edges if "refreshHeading" in e[1] and e[2] == "indirect_call"]
+ assert resolved, edges
+ assert resolved[0][3] == "INFERRED"
+
+
+def test_jvm_interop_kotlin_call_to_java_still_resolves(tmp_path: Path) -> None:
+ # Kotlin and Java share the JVM — same interop family, so a Kotlin call to a
+ # Java method must keep resolving exactly as it did before the guard.
+ _write(tmp_path / "Alarm.java",
+ "public class Alarm {\n"
+ " public static void ring() { System.out.println(\"ring\"); }\n"
+ "}\n")
+ _write(tmp_path / "Scheduler.kt",
+ "fun schedule() {\n"
+ " ring()\n"
+ "}\n")
+ edges = _call_edges([tmp_path / "Alarm.java", tmp_path / "Scheduler.kt"], tmp_path)
+ assert any("ring" in t for _s, t, _r, _c in edges), edges
From 9557bf67338d993c96b9e885e4508bbe9baee401 Mon Sep 17 00:00:00 2001
From: mallyskies
Date: Mon, 6 Jul 2026 19:54:22 -0600
Subject: [PATCH 030/292] fix(extract): tag inline base-class stubs with
origin_file
ensure_named_node() tags the sourceless stub it creates for an
unresolved reference with origin_file, so _disambiguate_colliding_node_ids
can tell one file's unresolved reference apart from another's instead of
merging every file's same-named reference onto one shared bare id
(which can then collide with an unrelated same-named real definition
anywhere else in the corpus, since ids are case-normalized and global).
Five call sites duplicated that stub-creation logic inline instead of
calling ensure_named_node() -- Ruby's `Class.new(Super)` and
`class Foo < Base` inheritance, Python inheritance, Kotlin delegation
-specifier inheritance/conformance, and C++ base_class_clause
inheritance -- and none of them were updated when origin_file was added,
so all five still produce the un-disambiguated bare-id stub the fix was
meant to eliminate.
Four of the five sit directly inside _extract_generic, where
ensure_named_node() is already in scope as a closure, so they're
switched to call it directly. The fifth (Ruby's `Class.new(Super)`) is
handled by a separate helper, _ruby_extra_walk(), which doesn't have
that closure in scope; that one gets the same origin_file tag added
directly to its own inline stub dict, matching what ensure_named_node()
already does, without changing the helper's signature.
(A sixth occurrence of the same inline pattern exists in extract_apex(),
a fully separate regex-based extractor with no ensure_named_node()
equivalent of its own -- left out of this fix, which is scoped to the
shared _extract_generic path and its one directly-affiliated helper.)
Added a regression test: two different C++ files each inheriting from
the same undefined base class must produce two distinct stub nodes, not
one shared one. Fails on main (one shared 'base' id for both files),
passes with this fix.
---
graphify/extract.py | 60 ++++++++-----------------------------------
tests/test_extract.py | 31 ++++++++++++++++++++++
2 files changed, 42 insertions(+), 49 deletions(-)
diff --git a/graphify/extract.py b/graphify/extract.py
index 40b9c8b26..ed0550432 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -3450,10 +3450,16 @@ def _ruby_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: st
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
+ # origin_file lets _disambiguate_colliding_node_ids
+ # tell this file's unresolved reference apart from
+ # another file's same-named one, instead of every
+ # file's stub collapsing onto one shared bare id
+ # (see ensure_named_node(), which sets the same
+ # field for this exact reason).
nodes.append({
"id": base_nid, "label": base,
"file_type": "code", "source_file": "",
- "source_location": "",
+ "source_location": "", "origin_file": str_path,
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
@@ -3707,18 +3713,7 @@ def walk(node, parent_class_nid: str | None = None) -> None:
for arg in args.children:
if arg.type == "identifier":
base = _read_text(arg, source)
- 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:
- nodes.append({
- "id": base_nid,
- "label": base,
- "file_type": "code",
- "source_file": "",
- "source_location": "",
- })
- seen_ids.add(base_nid)
+ base_nid = ensure_named_node(base, line)
add_edge(class_nid, base_nid, "inherits", line)
# Swift-specific: conformance / inheritance
@@ -3857,18 +3852,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None:
base = _kotlin_user_type_name(user_type_node, source)
if not base:
continue
- 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:
- nodes.append({
- "id": base_nid,
- "label": base,
- "file_type": "code",
- "source_file": "",
- "source_location": "",
- })
- seen_ids.add(base_nid)
+ base_nid = ensure_named_node(base, line)
add_edge(class_nid, base_nid, relation, line)
for arg_child in user_type_node.children:
if arg_child.type != "type_arguments":
@@ -3902,18 +3886,7 @@ def _php_emit_base(base_name: str, rel: str, at_line: int) -> None:
base = _read_text(consts[-1], source)
break
if base:
- 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:
- nodes.append({
- "id": base_nid,
- "label": base,
- "file_type": "code",
- "source_file": "",
- "source_location": "",
- })
- seen_ids.add(base_nid)
+ base_nid = ensure_named_node(base, line)
add_edge(class_nid, base_nid, "inherits", line)
# `include`/`extend`/`prepend ` in the class/module body ->
@@ -4179,18 +4152,7 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None:
continue
if not base:
continue
- 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:
- nodes.append({
- "id": base_nid,
- "label": base,
- "file_type": "code",
- "source_file": "",
- "source_location": "",
- })
- seen_ids.add(base_nid)
+ base_nid = ensure_named_node(base, line)
add_edge(class_nid, base_nid, "inherits", line)
# Emit a generic_arg reference for each type argument on the
# base (Base -> Car references Dep). _cpp_collect_type_refs
diff --git a/tests/test_extract.py b/tests/test_extract.py
index 04baf3926..9254a25f1 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -100,6 +100,37 @@ def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path):
assert edge["target"] in node_ids, f"Dangling structural target: {edge}"
+def test_cpp_unresolved_base_class_stubs_stay_disambiguated_by_file(tmp_path):
+ """Two different files' same-named, otherwise-undefined base class must not
+ collapse onto one shared stub node.
+
+ The C++ base_class_clause handler used to build its stub inline instead of
+ calling ensure_named_node(), so it never tagged the stub with origin_file.
+ Without that tag, _disambiguate_colliding_node_ids couldn't tell file A's
+ reference to unresolved `Base` apart from file B's, and every file's
+ unresolved base class merged onto one bare id -- which could then collide
+ with an unrelated same-named real definition anywhere else in the corpus.
+ """
+ first = tmp_path / "a" / "Foo.cpp"
+ second = tmp_path / "b" / "Bar.cpp"
+ first.parent.mkdir(parents=True)
+ second.parent.mkdir(parents=True)
+ first.write_text("class Foo : public Base {};\n", encoding="utf-8")
+ second.write_text("class Bar : public Base {};\n", encoding="utf-8")
+
+ result = extract([first, second], cache_root=tmp_path)
+ base_stubs = [
+ node for node in result["nodes"]
+ if node["label"] == "Base" and not node.get("source_file")
+ ]
+ assert len(base_stubs) == 2
+ assert len({node["id"] for node in base_stubs}) == 2
+
+ inherits_edges = [e for e in result["edges"] if e["relation"] == "inherits"]
+ assert len(inherits_edges) == 2
+ assert len({e["target"] for e in inherits_edges}) == 2
+
+
def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path):
"""#1402: a class defined once but referenced via type annotations in N other
files must NOT create 1+N phantom duplicate nodes (with the referencing file's
From 3b2ca2e55fa2cd4f12fafaf051c7cd594787590f Mon Sep 17 00:00:00 2001
From: mallyskies
Date: Mon, 6 Jul 2026 23:14:45 -0600
Subject: [PATCH 031/292] fix(build): don't let an ambiguous legacy-stem alias
silently merge files
build_from_json's "pre-migration alias index" (#1504) registers each real
file's OLD-style bare-stem id (extension dropped) as an alias so a stale
cached fragment referencing that old form still resolves after an id-scheme
migration. It never checked for collisions: two unrelated real files easily
compute the same bare alias (e.g. "ping.h" and "ping.php" in different
directories both alias to "ping"), and dict.setdefault let whichever file
happened to iterate first in a Python set win, arbitrarily.
A dangling edge with a bare, deliberately-unscoped fallback target (e.g. the
C/C++ extractor's last-resort id for an #include it couldn't resolve to a
real path) would ride that alias onto whatever unrelated file won the
collision -- silently wiring, say, a C++ server file to an unrelated PHP
script, purely because both files happen to share a common basename
somewhere in the corpus.
Now every candidate for an alias is collected before any of them are
committed to norm_to_id, and the alias is only trusted when exactly one real
file claims it. Ambiguous aliases are dropped entirely, so the dangling edge
correctly stays dangling (and gets discarded) instead of merging two
unrelated files -- same "don't guess through ambiguity" principle already
applied to stub-node disambiguation and cross-file call resolution
elsewhere in this codebase.
Found via graphify-practice round 2: a `path` query between two unrelated
symbols routed through exactly this kind of bogus edge, traced back to
Dev/Poker/TDServer/server.cpp's unresolved `#include "ping.h"` /
`#include "utility.h"` landing on unrelated www.masque.com PHP scripts of
the same bare name.
---
graphify/build.py | 20 +++++++++++++--
tests/test_build.py | 59 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 77 insertions(+), 2 deletions(-)
diff --git a/graphify/build.py b/graphify/build.py
index 279eccbf4..077db58ca 100644
--- a/graphify/build.py
+++ b/graphify/build.py
@@ -507,7 +507,20 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# fragment (e.g. an incremental update whose fragment references a symbol in a
# file that was NOT re-extracted) still resolves to the migrated node instead
# of dangling. Only fills gaps — never overrides a real node id.
+ #
+ # The old-stem form drops the extension and (for the file node itself) every
+ # directory but the immediate parent, so it collapses easily: "ping.h" and
+ # "ping.php" in different directories both alias to bare "ping". Collecting
+ # every candidate for an alias BEFORE committing any of them — and only
+ # committing when exactly one candidate claims it — keeps this a precise
+ # re-keying aid instead of a silent cross-file (and cross-language) merge.
+ # Without this, a dangling edge to a bare, deliberately-unscoped fallback id
+ # (e.g. the C/C++ extractor's last-resort target for an #include it couldn't
+ # resolve to a real path) could ride this alias onto whichever unrelated
+ # same-stem file happened to be inserted first into ``node_set`` — a Python
+ # set, so "first" is hash-order, not anything meaningful.
from graphify.extractors.base import _file_stem as _fs
+ _alias_candidates: dict[str, set[str]] = {}
for nid in node_set:
attrs = G.nodes[nid]
sf = attrs.get("source_file")
@@ -524,8 +537,11 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if old_stem == new_stem:
continue
alias = old_stem + suffix
- norm_to_id.setdefault(_normalize_id(alias), nid)
- norm_to_id.setdefault(alias, nid)
+ _alias_candidates.setdefault(_normalize_id(alias), set()).add(nid)
+ _alias_candidates.setdefault(alias, set()).add(nid)
+ for alias_key, candidates in _alias_candidates.items():
+ if len(candidates) == 1:
+ norm_to_id.setdefault(alias_key, next(iter(candidates)))
# Iterate edges in a deterministic order. The graph is undirected and stores
# direction in _src/_tgt; when two edges collapse onto the same node pair the
# last write wins, so an unstable iteration order flips _src/_tgt run-to-run
diff --git a/tests/test_build.py b/tests/test_build.py
index 2d8bfdd60..1e1d5b56e 100644
--- a/tests/test_build.py
+++ b/tests/test_build.py
@@ -505,6 +505,65 @@ def test_build_relativizes_absolute_source_file(tmp_path):
assert sf == "src/main.py"
+def test_build_from_json_ambiguous_old_stem_alias_stays_dangling(tmp_path):
+ """The #1504 old-stem alias (e.g. "ping.h" -> bare "ping") is meant to let a
+ stale-id edge from an un-re-extracted fragment still find its own file after
+ a rekey. But the old-stem form drops the extension and most of the path, so
+ two unrelated real files easily collapse onto the same bare alias (a C header
+ and a PHP script both named "ping", in different directories). A dangling
+ edge produced by an unrelated third file's own unscoped fallback id (e.g. the
+ C/C++ extractor's last-resort target for an #include it couldn't resolve to
+ a real path) must not silently ride that alias onto an arbitrary one of them
+ — it should stay dangling and get dropped, same as any other unresolvable
+ edge, rather than wire two unrelated files/languages together by accident."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ extraction = {
+ "nodes": [
+ # Ids given in their canonical (post-extract.py, extension-stripped)
+ # form, matching what a real graphify update run would already have
+ # produced before build_from_json assembles the final graph.
+ {"id": "dev_monitoring_ping", "label": "ping.h", "file_type": "code",
+ "source_file": "Dev/monitoring/ping.h"},
+ {"id": "www_pages_api_ping", "label": "ping.php", "file_type": "code",
+ "source_file": "www/pages/api/ping.php"},
+ {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code",
+ "source_file": "Dev/poker/server.cpp"},
+ ],
+ "edges": [
+ # The unscoped, deliberately-unresolved fallback edge a C/C++ #include
+ # resolver leaves behind when it can't find the header on disk.
+ {"source": "dev_poker_server", "target": "ping", "relation": "imports",
+ "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"},
+ ],
+ }
+ G = build_from_json(extraction, root=root)
+ assert not G.has_edge("dev_poker_server", "dev_monitoring_ping")
+ assert not G.has_edge("dev_poker_server", "www_pages_api_ping")
+
+
+def test_build_from_json_unambiguous_old_stem_alias_still_resolves(tmp_path):
+ """Companion to the ambiguous case above: when exactly one real file claims
+ an old-stem alias, a dangling edge to that bare alias should still resolve
+ to it — the #1504 migration-compat behavior this index exists for."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ extraction = {
+ "nodes": [
+ {"id": "dev_monitoring_utility", "label": "utility.h", "file_type": "code",
+ "source_file": "Dev/monitoring/utility.h"},
+ {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code",
+ "source_file": "Dev/poker/server.cpp"},
+ ],
+ "edges": [
+ {"source": "dev_poker_server", "target": "utility", "relation": "imports",
+ "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"},
+ ],
+ }
+ G = build_from_json(extraction, root=root)
+ assert G.has_edge("dev_poker_server", "dev_monitoring_utility")
+
+
def test_build_from_json_relative_source_file_unchanged(tmp_path):
"""Already-relative source_file paths must not be modified."""
extraction = {
From 96db75c8c29b7c9a7a5a4275dbb07f6e4593714b Mon Sep 17 00:00:00 2001
From: mallyskies
Date: Mon, 6 Jul 2026 23:55:30 -0600
Subject: [PATCH 032/292] fix(build): recognize a salted file node as its own
alias claimant
Follow-up to the previous commit on this branch. That fix's ambiguity check
missed a case: it detects "is this node the file itself" by checking
whether the node's id starts with the file's plain new_stem, but a
same-directory .h/.cpp pair that collides on their shared pre-extension id
gets salted apart by _disambiguate_colliding_node_ids into ids like
"tools_aolserver_utility_h_tools_aolserver_utility" -- no longer a clean
new_stem prefix.
That salted header silently failed to compute an empty suffix, so it never
entered the bare "utility" alias race at all, leaving an unrelated
wwwapi.masque.com/pages/utility.php as the lone (wrong) "unambiguous"
winner -- reproduced exactly against the real depot's
Tools/aolserver/utility.h and .cpp.
Detect "this node IS the file" by label instead: every file node's label is
its own basename regardless of what its id looks like after salting. That
keeps a salted file node in the alias competition, so the real collision
between the C header and the PHP file is correctly caught as ambiguous.
---
graphify/build.py | 21 ++++++++++++++++++---
tests/test_build.py | 34 ++++++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/graphify/build.py b/graphify/build.py
index 077db58ca..b26ddcb61 100644
--- a/graphify/build.py
+++ b/graphify/build.py
@@ -519,6 +519,18 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
# resolve to a real path) could ride this alias onto whichever unrelated
# same-stem file happened to be inserted first into ``node_set`` — a Python
# set, so "first" is hash-order, not anything meaningful.
+ #
+ # A file node's OWN id is not always a clean ``new_stem`` prefix: when a
+ # same-directory ``.h``/``.cpp`` pair collides on their shared pre-extension
+ # id, _disambiguate_colliding_node_ids salts both apart into ids like
+ # ``tools_aolserver_utility_h_tools_aolserver_utility`` — which no longer
+ # string-prefixes cleanly for the suffix math below. Detecting "this IS the
+ # file node" by label (every file node's label is its own basename,
+ # regardless of id mangling) instead of by id shape keeps a salted file node
+ # in the alias competition, so a genuine collision (a C header AND an
+ # unrelated same-named PHP script) is still caught as ambiguous instead of
+ # the header silently dropping out of the race and leaving the PHP file as
+ # the lone (wrong) "unambiguous" winner.
from graphify.extractors.base import _file_stem as _fs
_alias_candidates: dict[str, set[str]] = {}
for nid in node_set:
@@ -530,9 +542,12 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
if rel.is_absolute():
continue
new_stem = make_id(_fs(rel))
- suffix = ""
- if _normalize_id(nid).startswith(new_stem):
- suffix = _normalize_id(nid)[len(new_stem):] # leading "_entity" or ""
+ if str(attrs.get("label", "")) == rel.name:
+ suffix = "" # this node IS the file, whatever its (possibly salted) id
+ else:
+ suffix = ""
+ if _normalize_id(nid).startswith(new_stem):
+ suffix = _normalize_id(nid)[len(new_stem):] # leading "_entity" or ""
for old_stem in _old_file_stems(rel):
if old_stem == new_stem:
continue
diff --git a/tests/test_build.py b/tests/test_build.py
index 1e1d5b56e..e8c0309a9 100644
--- a/tests/test_build.py
+++ b/tests/test_build.py
@@ -542,6 +542,40 @@ def test_build_from_json_ambiguous_old_stem_alias_stays_dangling(tmp_path):
assert not G.has_edge("dev_poker_server", "www_pages_api_ping")
+def test_build_from_json_ambiguous_alias_detected_despite_header_impl_salting(tmp_path):
+ """A same-directory .h/.cpp pair collides on their shared pre-extension id
+ and gets salted apart into ids like "tools_aolserver_utility_h_..." — no
+ longer a clean new_stem prefix. The ambiguity check must still recognize
+ the salted header as a legitimate claimant for the bare old-stem alias (by
+ label, not id shape), so a real collision with an unrelated same-named PHP
+ file is still caught instead of the header silently dropping out of the
+ race and leaving the PHP file as the lone "unambiguous" winner (this
+ reproduced against the real depot: Tools/aolserver/utility.h and .cpp,
+ salted apart, let wwwapi.masque.com/pages/utility.php win the bare
+ "utility" alias uncontested)."""
+ root = tmp_path / "repo"
+ root.mkdir()
+ extraction = {
+ "nodes": [
+ {"id": "tools_aolserver_utility_h_tools_aolserver_utility", "label": "utility.h",
+ "file_type": "code", "source_file": "Tools/aolserver/utility.h"},
+ {"id": "tools_aolserver_utility_cpp_tools_aolserver_utility", "label": "utility.cpp",
+ "file_type": "code", "source_file": "Tools/aolserver/utility.cpp"},
+ {"id": "wwwapi_masque_com_pages_utility", "label": "utility.php",
+ "file_type": "code", "source_file": "wwwapi.masque.com/pages/utility.php"},
+ {"id": "dev_poker_server", "label": "server.cpp", "file_type": "code",
+ "source_file": "Dev/poker/server.cpp"},
+ ],
+ "edges": [
+ {"source": "dev_poker_server", "target": "utility", "relation": "imports",
+ "confidence": "EXTRACTED", "source_file": "Dev/poker/server.cpp"},
+ ],
+ }
+ G = build_from_json(extraction, root=root)
+ assert not G.has_edge("dev_poker_server", "wwwapi_masque_com_pages_utility")
+ assert not G.has_edge("dev_poker_server", "tools_aolserver_utility_h_tools_aolserver_utility")
+
+
def test_build_from_json_unambiguous_old_stem_alias_still_resolves(tmp_path):
"""Companion to the ambiguous case above: when exactly one real file claims
an old-stem alias, a dangling edge to that bare alias should still resolve
From 67f4f835f7634cf31ae4bdc2d859a3561efee1b9 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 11:25:28 +0100
Subject: [PATCH 033/292] fix(extract): skip builtin-global receiver types in
TS/JS member-call resolution (#1726)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`_resolve_typescript_member_calls` resolves a member call's receiver to a type
definition by casefolded label. For a builtin-typed receiver (`x: Date;
x.getTime()`), `_key("Date")` == "date" matched a same-named user `class DATE` /
`const DATE` in another file, binding the caller to it as a phantom
`references[call]` edge. In a real 3,368-file repo one module-local `const DATE`
accumulated 469 false cross-file edges (degree 472, betweenness 0.435) — a false
god node distorting path/god-node results.
Skip the resolution when the receiver type is an ECMAScript/Python builtin
global (Date, Promise, Map, ...), mirroring the guard the cross-file CALL
resolver already applies (#726). The guard is a strict no-op for genuine user
types, so legitimate constructor-injection member-call resolution (#1316) is
unaffected. Verified across new Date().method(), return-type, and var-decl-type
shapes: zero phantom edges, user DATE degree back to 1.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/extract.py | 7 ++++
tests/test_builtin_global_type_refs.py | 52 ++++++++++++++++++++++++++
2 files changed, 59 insertions(+)
create mode 100644 tests/test_builtin_global_type_refs.py
diff --git a/graphify/extract.py b/graphify/extract.py
index ed0550432..16a3f753a 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -12026,6 +12026,13 @@ def _key(label: str) -> str:
type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver)
if not type_name:
continue
+ # A builtin global receiver type (Date, Promise, Map, ...) must not resolve
+ # to a user symbol. _key() casefolds, so `x: Date; x.getTime()` would bind
+ # the caller to a same-named user `class DATE` in another file, inventing
+ # phantom `references[call]` edges and a false god node (#1726). The
+ # cross-file CALL resolver already skips these globals; do the same here.
+ if type_name in _LANGUAGE_BUILTIN_GLOBALS:
+ continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1:
continue
diff --git a/tests/test_builtin_global_type_refs.py b/tests/test_builtin_global_type_refs.py
new file mode 100644
index 000000000..0fe1fd72c
--- /dev/null
+++ b/tests/test_builtin_global_type_refs.py
@@ -0,0 +1,52 @@
+"""Builtin-global receiver types must not resolve to same-named user symbols.
+
+#1726: `x: Date; x.getTime()` had its caller bound (by casefolded label) to a
+user `class DATE` / `const DATE` in another file, inventing phantom
+`references[call]` edges and a false god node. The cross-file CALL resolver
+already skips ECMAScript/Python builtins; `_resolve_typescript_member_calls`
+must do the same.
+"""
+from pathlib import Path
+from graphify.extract import extract
+
+
+def _labels_by_id(r):
+ return {n["id"]: n.get("label") for n in r["nodes"]}
+
+
+def test_builtin_date_type_ref_does_not_bind_to_user_DATE(tmp_path):
+ (tmp_path / "model.ts").write_text('export class DATE {\n value: string = "";\n}\n')
+ (tmp_path / "a.ts").write_text('export function parse(x: Date): number { return x.getTime(); }\n')
+ (tmp_path / "b.ts").write_text('export function fmt(w: Date): string { return w.toISOString(); }\n')
+ r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
+ lbl = _labels_by_id(r)
+ date_ids = [n["id"] for n in r["nodes"] if n.get("label") == "DATE"]
+ assert date_ids, "the user class DATE must still exist as a node"
+ for e in r["edges"]:
+ if e.get("relation") == "references" and e.get("target") in date_ids:
+ src = lbl.get(e["source"])
+ assert False, f"phantom builtin-Date reference bound to user DATE from {src!r}"
+ # the user DATE node accumulates no phantom references — degree is just its file
+ deg = sum(1 for e in r["edges"] if date_ids[0] in (e["source"], e["target"]))
+ assert deg <= 1, f"user DATE should not be a god node; degree={deg}"
+
+
+def test_nonbuiltin_receiver_type_still_resolves(tmp_path):
+ # Guard must be a no-op for a genuine user type: a member call on a user-typed
+ # field still resolves cross-file (constructor-injection type table, #1316).
+ (tmp_path / "svc.ts").write_text(
+ "export class PaymentClient {\n charge(n: number): boolean { return true; }\n}\n")
+ (tmp_path / "order.ts").write_text(
+ 'import { PaymentClient } from "./svc";\n'
+ "export class Order {\n"
+ " constructor(private client: PaymentClient) {}\n"
+ " pay(): boolean { return this.client.charge(1); }\n"
+ "}\n")
+ r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
+ lbl = _labels_by_id(r)
+ resolved = {
+ (lbl.get(e["source"]), lbl.get(e["target"]), e["relation"])
+ for e in r["edges"] if "charge" in str(e.get("target", "")).lower()
+ }
+ assert any(t and "charge" in str(t).lower() for _, t, _ in resolved), \
+ f"user member-call must still resolve; got {resolved}"
From 781d1cd8ec5a0197aa8f3912af8d645eedba5413 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 11:39:39 +0100
Subject: [PATCH 034/292] Release 0.9.10
Correctness batch since 0.9.9: TS/JS builtin-typed receiver no longer collapses
onto a same-named user symbol (#1726); no cross-language calls edges (#1718);
build_merge ambiguous-alias no longer merges unrelated files (#1713); base-class
stubs tagged with origin_file (#1707); Java enum constants as nodes (#1719);
rebuild recovers from a deleted hook cwd (#1703); per-chunk semantic-cache
checkpoint (#1715); SECURITY.md http-transport doc + GRAPHIFY_MAX_GRAPH_BYTES
tests (#1714, #1722). Nine merged PRs plus #1726.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
CHANGELOG.md | 11 +++++++++++
pyproject.toml | 2 +-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad4860419..554bb7981 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
+## 0.9.10 (2026-07-08)
+
+- Fix: TS/JS member calls on a builtin-typed receiver no longer collapse onto a same-named user symbol (#1726). `_resolve_typescript_member_calls` matched a receiver's type to a definition by casefolded label, so `x: Date; x.getTime()` bound the caller to a user `class DATE`/`const DATE` in another file — inventing hundreds of phantom `references` edges and a false god node. Builtin-global receiver types (`Date`, `Promise`, `Map`, ...) are now skipped, mirroring the cross-file call guard; genuine user types are unaffected.
+- Fix: never bind a cross-file `calls` edge to a definition in a different language family (#1718, thanks @edinaldoof). Name-only matching resolved a TSX callback passed by name to a same-named Kotlin method (and a Python call to a Kotlin fun) — phantom edges the spec forbids. Candidates are now filtered by interop family (JVM, native C-family, JS/TS module graph, ...); unknown families stay permissive.
+- Fix: an ambiguous legacy-stem alias in `build_merge` no longer silently merges two unrelated files (#1713, thanks @mallyskies). The `#1504` old-stem alias (`ping.h`/`ping.php` → bare `ping`) resolved by hash-order, riding a dangling edge onto an arbitrary same-named file. Aliases are now committed only when exactly one file claims them; a salted `.h`/`.cpp` file node is recognized as its own claimant so a genuine collision stays ambiguous (and dropped) instead of picking a wrong winner.
+- Fix: inline base-class stubs are tagged with `origin_file` (#1707, thanks @mallyskies). Five inheritance handlers built cross-file base-class stubs without `origin_file`, so same-named bases across files collapsed onto one shared stub that could then merge with an unrelated real class (218 wrong `inherits` edges observed). They now route through `ensure_named_node`, which sets the tag.
+- Fix: Java enum constants are extracted as nodes with `case_of` edges to their enum (#1719, thanks @ivanzhl). Closes the Java half of #1700; `affected ErrorCode` / "where is ErrorCode.X used" now works for Java.
+- Fix: `graphify` rebuilds recover from a deleted hook working directory instead of crashing (#1703, thanks @FranciscoJSBarragan). A detached git hook can inherit a CWD that no longer exists; the rebuild now recovers via `GRAPHIFY_REPO_ROOT` or fails cleanly instead of raising `FileNotFoundError`.
+- Feat: the semantic cache is checkpointed per chunk so an interrupted extraction resumes instead of restarting (#1715, thanks @A-Levin). Each completed chunk is unioned into the cache immediately (opt out with `GRAPHIFY_NO_INCREMENTAL_CACHE`); the final write still overwrites authoritatively.
+- Docs: `SECURITY.md` no longer claims stdio-only now that an opt-in `--transport http` (binds `127.0.0.1` by default) exists (#1714, thanks @Thizeidler); added tests for `GRAPHIFY_MAX_GRAPH_BYTES` parsing and corrected its unit docstring to binary MiB/GiB (#1722, thanks @Cekaru).
+
## 0.9.9 (2026-07-07)
- Fix: `graphify explain` resolves an exactly-typed punctuated label symmetrically against `norm_label` (#1704). The search term tokenized on `\w+` ("blockStream.ts" -> "blockstream ts", space where the '.' was) while a node's stored `norm_label` keeps punctuation ("blockstream.ts"). The verbatim case was already rescued by the tokenized-label tier, but that broke if a node's `label` and `norm_label` diverged; a punctuation-preserving `norm_query` is now matched against `norm_label` across the exact/prefix/substring tiers (and fed to the trigram prefilter), so it is robust by construction.
diff --git a/pyproject.toml b/pyproject.toml
index 2f3aa24ee..7fbde83e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.9.9"
+version = "0.9.10"
description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
From 5c6f7272cd7d50c0528bd9b7f44da437c87d97c3 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 12:09:40 +0100
Subject: [PATCH 035/292] test(extract): cover builtin static-call shape
(Date.now()) for #1726
Adds a regression test for the Date.now()/static-call shape (credit PR #1727 /
@2loch-ness6, who independently found the same fix): a capitalized builtin
receiver must not bind cross-file to a same-spelled user const/class. The same
guard added in 67f4f83 already covers it (verified); this locks the static-call
shape in, while allowing the legitimate same-file const reference.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
tests/test_builtin_global_type_refs.py | 31 ++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/tests/test_builtin_global_type_refs.py b/tests/test_builtin_global_type_refs.py
index 0fe1fd72c..25ecb151d 100644
--- a/tests/test_builtin_global_type_refs.py
+++ b/tests/test_builtin_global_type_refs.py
@@ -50,3 +50,34 @@ def test_nonbuiltin_receiver_type_still_resolves(tmp_path):
}
assert any(t and "charge" in str(t).lower() for _, t, _ in resolved), \
f"user member-call must still resolve; got {resolved}"
+
+
+def test_builtin_static_call_does_not_bind_to_user_symbol(tmp_path):
+ # #1726 (static-call shape, credit PR #1727 / @2loch-ness6): `Date.now()` treats
+ # the capitalized receiver `Date` as a type name; without the builtin guard it
+ # binds to a same-spelled user `const DATE`, a false god node. A typed param in
+ # the same class arms the cross-file member-call resolver (the real service shape).
+ (tmp_path / "format.ts").write_text(
+ "const DATE = new Intl.DateTimeFormat('en-US', {});\n"
+ "export function fmt(x: number): string { return DATE.format(x); }\n")
+ (tmp_path / "svc.ts").write_text(
+ "export class Svc {\n"
+ " expiry(d: Date): Date { return d; }\n"
+ " stamp(): number { return Date.now(); }\n"
+ " when(): string { return new Date().toISOString(); }\n"
+ "}\n")
+ r = extract(sorted(tmp_path.glob("*.ts")), cache_root=tmp_path, parallel=False)
+ lbl = _labels_by_id(r)
+ by_id = {n["id"]: n for n in r["nodes"]}
+ date_ids = [n["id"] for n in r["nodes"] if n.get("label") == "DATE"]
+ date_sf = {str(by_id[i].get("source_file", "")) for i in date_ids}
+ # A same-file reference to the real const DATE (fmt() -> DATE in format.ts) is
+ # legitimate. The bug is a CROSS-FILE bind: svc.ts's Date.now()/new Date()
+ # resolving to format.ts's const DATE. Assert no such cross-file phantom.
+ for e in r["edges"]:
+ if e.get("target") in date_ids and e.get("relation") == "references":
+ src_sf = str(by_id.get(e["source"], {}).get("source_file", ""))
+ assert src_sf in date_sf, (
+ f"cross-file builtin phantom: {lbl.get(e['source'])!r} in {src_sf} "
+ f"bound to user DATE"
+ )
From 4be0f7b3bfe9ac32f6a8caca5bbd6cac632d20c8 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 12:45:08 +0100
Subject: [PATCH 036/292] fix(merge-graphs): give each input a distinct repo
tag so same-stem nodes don't collapse (#1729)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
merge-graphs prefixed each graph's node ids with `::` where repo was
`gp.parent.parent.name` — the graphify-out parent dir name. That tag is not
unique across inputs: `src/graphify-out` and `frontend/src/graphify-out` both
yield `src`, so a bare `app` node from a backend `src/app.js` and a frontend
`App.jsx` both became `src::app` and nx.compose silently merged them into one
node (one graph's label/source_file, both graphs' edges) — inventing false
cross-runtime `path` results with no warning.
New `distinct_repo_tags` guarantees a unique prefix per graph: colliding tags
are widened with their own parent dir (`frontend_src`), then an index suffix
backstops any residual duplicate. The handler prints a note when it disambiguates.
Verified end to end: the two `app` nodes now survive as `..._src::app` and
`frontend_src::app` instead of collapsing. Regression tests cover the merge case
and the tag uniquifier (pass-through, widening, and triple-collision fallback).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/__main__.py | 13 +++++++---
graphify/build.py | 28 +++++++++++++++++++++
tests/test_merge_graphs_cli.py | 46 ++++++++++++++++++++++++++++++++++
3 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 285d19c1b..b869e8653 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -3993,7 +3993,7 @@ def _load_graph(p: str):
sys.exit(1)
import networkx as _nx
from networkx.readwrite import json_graph as _jg
- from graphify.build import prefix_graph_for_global as _prefix
+ from graphify.build import prefix_graph_for_global as _prefix, distinct_repo_tags as _repo_tags
graphs = []
for gp in graph_paths:
if not gp.exists():
@@ -4025,9 +4025,16 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
if type(g) is not _nx.Graph:
return _nx.Graph(g)
return g
+ # Unique repo tag per graph. The bare `graphify-out/..` dir name is not
+ # unique across inputs (src/graphify-out and frontend/src/graphify-out both
+ # → "src"), which collides same-stem node ids and silently merges unrelated
+ # entities (#1729). distinct_repo_tags guarantees a distinct prefix per graph.
+ repo_tags = _repo_tags(graph_paths)
+ naive_tags = [gp.parent.parent.name for gp in graph_paths]
+ if len(set(naive_tags)) != len(naive_tags):
+ print(f" note: repo dir names collide; using distinct tags: {', '.join(repo_tags)}")
merged = _nx.Graph()
- for G, gp in zip(graphs, graph_paths):
- repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
+ for G, repo_tag in zip(graphs, repo_tags):
prefixed = _to_simple(_prefix(G, repo_tag))
merged = _nx.compose(merged, prefixed)
try:
diff --git a/graphify/build.py b/graphify/build.py
index b26ddcb61..f0676a8d5 100644
--- a/graphify/build.py
+++ b/graphify/build.py
@@ -956,6 +956,34 @@ def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph:
return H
+def distinct_repo_tags(graph_paths: "list[Path]") -> "list[str]":
+ """Return a unique, human-meaningful repo tag per input graph for merge-graphs.
+
+ The naive tag (the ``graphify-out`` parent dir name) is NOT unique across
+ inputs: ``src/graphify-out`` and ``frontend/src/graphify-out`` both yield
+ ``src``. Prefixing both node sets with ``src::`` then makes same-stem nodes
+ (a backend ``src/app.js`` and a frontend ``App.jsx``, both bare ``app``)
+ collide, so ``nx.compose`` silently merges two unrelated entities and invents
+ cross-runtime edges (#1729). Colliding tags are widened with their own parent
+ dir (``frontend_src``), then an index suffix guarantees uniqueness so no two
+ graphs ever share a prefix.
+ """
+ repo_dirs = [p.parent.parent for p in graph_paths] # graphify-out/.. → repo dir
+ tags = [d.name or "repo" for d in repo_dirs]
+ if len(set(tags)) != len(tags):
+ widened: list[str] = []
+ for d in repo_dirs:
+ parent = d.parent.name
+ widened.append(f"{parent}_{d.name}" if parent and d.name else (d.name or "repo"))
+ tags = widened
+ seen: dict[str, int] = {}
+ unique: list[str] = []
+ for t in tags:
+ seen[t] = seen.get(t, 0) + 1
+ unique.append(t if seen[t] == 1 else f"{t}-{seen[t]}")
+ return unique
+
+
def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int:
"""Remove all nodes tagged with repo_tag from G in-place. Returns count removed."""
to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag]
diff --git a/tests/test_merge_graphs_cli.py b/tests/test_merge_graphs_cli.py
index f3e0e05d2..5ff06b391 100644
--- a/tests/test_merge_graphs_cli.py
+++ b/tests/test_merge_graphs_cli.py
@@ -46,3 +46,49 @@ def test_merge_graphs_mixed_directed_and_multigraph(tmp_path):
assert {"r1::x", "r2::y", "r3::z"} <= ids or len(ids) == 3
assert data.get("directed") is False
assert data.get("multigraph") is False
+
+
+def test_merge_graphs_same_named_repo_dirs_do_not_collapse(tmp_path):
+ # #1729: two graphs under a same-named repo dir (src/graphify-out and
+ # frontend/src/graphify-out both → tag "src") share the `src::` prefix, so a
+ # bare `app` node from each collapsed into one — silently merging unrelated
+ # entities and inventing cross-runtime edges. Distinct tags must keep them apart.
+ a = tmp_path / "src" / "graphify-out" / "graph.json"
+ b = tmp_path / "frontend" / "src" / "graphify-out" / "graph.json"
+ a.parent.mkdir(parents=True, exist_ok=True)
+ b.parent.mkdir(parents=True, exist_ok=True)
+ a.write_text(json.dumps({"directed": False, "multigraph": False, "nodes": [
+ {"id": "app", "label": "app.js", "source_file": "app.js"}], "links": []}))
+ b.write_text(json.dumps({"directed": False, "multigraph": False, "nodes": [
+ {"id": "app", "label": "App.jsx", "source_file": "App.jsx"}], "links": []}))
+ out = tmp_path / "merged.json"
+
+ r = _run(["merge-graphs", str(a), str(b), "--out", str(out)], tmp_path)
+ assert r.returncode == 0, r.stderr
+ data = json.loads(out.read_text())
+ app_nodes = [n for n in data["nodes"] if n["id"].endswith("::app")]
+ assert len(app_nodes) == 2, f"both app nodes must survive; got {[n['id'] for n in app_nodes]}"
+ labels = {n.get("label") for n in app_nodes}
+ assert labels == {"app.js", "App.jsx"}, f"both entities preserved; got {labels}"
+
+
+def test_distinct_repo_tags_unit(tmp_path):
+ from graphify.build import distinct_repo_tags
+ # distinct repo dirs pass through unchanged
+ assert distinct_repo_tags([
+ Path("backend/graphify-out/graph.json"),
+ Path("web/graphify-out/graph.json"),
+ ]) == ["backend", "web"]
+ # same-named repo dirs are widened to stay distinct
+ tags = distinct_repo_tags([
+ Path("proj/src/graphify-out/graph.json"),
+ Path("proj/frontend/src/graphify-out/graph.json"),
+ ])
+ assert len(set(tags)) == 2, tags
+ # a repeated dir name triple still yields all-distinct tags (index fallback)
+ tags3 = distinct_repo_tags([
+ Path("a/src/graphify-out/graph.json"),
+ Path("b/src/graphify-out/graph.json"),
+ Path("c/src/graphify-out/graph.json"),
+ ])
+ assert len(set(tags3)) == 3, tags3
From 0d206cbe111be4316978fadb08a023f15e615f42 Mon Sep 17 00:00:00 2001
From: tpateeq
Date: Wed, 8 Jul 2026 19:05:18 +0530
Subject: [PATCH 037/292] fix(uninstall): remove graphify hook/section from
Claude local-only files (#1731)
`graphify uninstall` (and `graphify claude uninstall`) only looked at
`.claude/settings.json` and root `CLAUDE.md`. A user who relocates the
PreToolUse hook into `.claude/settings.local.json` and the `## graphify`
instructions into `CLAUDE.local.md` / `.claude/CLAUDE.local.md` - so they
are not committed to a shared repo - was left with both behind after
uninstall, which reported "nothing to do".
- `_uninstall_claude_hook` now strips the hook from both `settings.json`
and `settings.local.json` (factored into `_strip_graphify_hook`).
- `claude_uninstall` now strips the `## graphify` section from `CLAUDE.md`,
root `CLAUDE.local.md`, and `.claude/CLAUDE.local.md` (factored into
`_strip_graphify_md_section`), cleaning every present file rather than the
first, and always runs the hook cleanup.
- `_strip_graphify_md_section` reads the two newly-covered files defensively
so a non-UTF-8 or otherwise unreadable file can't abort uninstall.
Unrelated local-only files (no graphify content) are left byte-for-byte
untouched. The `--local` install flag suggested in the issue is left out of
scope; this change makes uninstall symmetric with wherever the config lives.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/__main__.py | 67 ++++++++++++++++++++++++++++++---------
tests/test_claude_md.py | 69 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 122 insertions(+), 14 deletions(-)
diff --git a/graphify/__main__.py b/graphify/__main__.py
index b869e8653..9aa2cf806 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -2053,8 +2053,19 @@ def _install_claude_hook(project_dir: Path) -> None:
def _uninstall_claude_hook(project_dir: Path) -> None:
- """Remove graphify PreToolUse hook from .claude/settings.json."""
- settings_path = project_dir / ".claude" / "settings.json"
+ """Remove the graphify PreToolUse hook from .claude/settings.json and its
+ local-only sibling .claude/settings.local.json.
+
+ A user may relocate the hook into settings.local.json so it is not committed
+ to a shared repo, so uninstall has to clean whichever file holds it (#1731).
+ """
+ claude_dir = project_dir / ".claude"
+ for name in ("settings.json", "settings.local.json"):
+ _strip_graphify_hook(claude_dir / name)
+
+
+def _strip_graphify_hook(settings_path: Path) -> None:
+ """Drop graphify PreToolUse hooks from a single Claude settings file, if present."""
if not settings_path.exists():
return
try:
@@ -2067,7 +2078,7 @@ def _uninstall_claude_hook(project_dir: Path) -> None:
return
settings["hooks"]["PreToolUse"] = filtered
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
- print(f" .claude/settings.json -> PreToolUse hook removed")
+ print(f" .claude/{settings_path.name} -> PreToolUse hook removed")
def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
@@ -2116,25 +2127,54 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
def claude_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
- """Remove the graphify skill tree (SKILL.md + references/) and the CLAUDE.md section.
+ """Remove the graphify skill tree (SKILL.md + references/) and the graphify
+ section from CLAUDE.md and its local-only variants, plus the PreToolUse hook.
Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude
uninstall` must remove the installed skill, not just strip CLAUDE.md, or the
progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121).
+
+ A user may relocate the section/hook into the local-only files Claude Code
+ supports so they are not committed to a shared repo, so uninstall also cleans
+ CLAUDE.local.md, .claude/CLAUDE.local.md and .claude/settings.local.json (#1731).
"""
project_dir = project_dir or Path(".")
_remove_skill_file("claude", project=project, project_dir=project_dir)
- target = project_dir / "CLAUDE.md"
- if not target.exists():
+ md_targets = [
+ project_dir / "CLAUDE.md",
+ project_dir / "CLAUDE.local.md",
+ project_dir / ".claude" / "CLAUDE.local.md",
+ ]
+ existing = [t for t in md_targets if t.exists()]
+ removed_any = False
+ for target in existing:
+ # Not short-circuited: every present file must be cleaned, not just the first.
+ if _strip_graphify_md_section(target):
+ removed_any = True
+
+ if not existing:
print("No CLAUDE.md found in current directory - nothing to do")
- return
-
- content = target.read_text(encoding="utf-8")
- if _CLAUDE_MD_MARKER not in content:
+ elif not removed_any:
print("graphify section not found in CLAUDE.md - nothing to do")
- return
+ _uninstall_claude_hook(project_dir)
+
+
+def _strip_graphify_md_section(target: Path) -> bool:
+ """Strip the ## graphify section from one CLAUDE.md-style file.
+
+ Returns True if a section was removed. Deletes the file if nothing else
+ remains after removal.
+ """
+ try:
+ content = target.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ # An unreadable/undecodable CLAUDE.md-style file (e.g. non-UTF-8, or a
+ # directory of that name) must not abort uninstall - nothing to strip.
+ return False
+ if _CLAUDE_MD_MARKER not in content:
+ return False
# Remove the ## graphify section: from the marker to the next ## heading or EOF
cleaned = re.sub(
r"\n*## graphify\n.*?(?=\n## |\Z)",
@@ -2147,9 +2187,8 @@ def claude_uninstall(project_dir: Path | None = None, *, project: bool = False)
print(f"graphify section removed from {target.resolve()}")
else:
target.unlink()
- print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}")
-
- _uninstall_claude_hook(project_dir or Path("."))
+ print(f"{target.name} was empty after removal - deleted {target.resolve()}")
+ return True
def codebuddy_install(project_dir: Path | None = None) -> None:
diff --git a/tests/test_claude_md.py b/tests/test_claude_md.py
index f81f10dd3..55b13e5ee 100644
--- a/tests/test_claude_md.py
+++ b/tests/test_claude_md.py
@@ -134,3 +134,72 @@ def test_uninstall_removes_settings_hook(tmp_path):
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any(h.get("matcher") == "Bash" and "graphify" in str(h) for h in hooks)
+
+
+# ---------------------------------------------------------------------------
+# local-only variants: settings.local.json / CLAUDE.local.md (#1731)
+# ---------------------------------------------------------------------------
+
+def test_uninstall_removes_hook_from_settings_local_json(tmp_path):
+ """A hook relocated to .claude/settings.local.json is removed on uninstall."""
+ import json
+ claude_install(tmp_path)
+ # User moved the hook out of the committed settings.json into the local-only file.
+ (tmp_path / ".claude" / "settings.json").rename(tmp_path / ".claude" / "settings.local.json")
+ claude_uninstall(tmp_path)
+ local = tmp_path / ".claude" / "settings.local.json"
+ hooks = json.loads(local.read_text()).get("hooks", {}).get("PreToolUse", [])
+ assert not any("graphify" in str(h) for h in hooks)
+
+
+def test_uninstall_removes_section_from_dot_claude_local_md(tmp_path):
+ """Instructions relocated to .claude/CLAUDE.local.md are removed on uninstall."""
+ claude_install(tmp_path)
+ local_md = tmp_path / ".claude" / "CLAUDE.local.md"
+ local_md.write_text((tmp_path / "CLAUDE.md").read_text())
+ (tmp_path / "CLAUDE.md").unlink()
+ claude_uninstall(tmp_path)
+ assert not local_md.exists() or _CLAUDE_MD_MARKER not in local_md.read_text()
+
+
+def test_uninstall_removes_section_from_root_claude_local_md(tmp_path):
+ """Instructions relocated to root CLAUDE.local.md are removed on uninstall."""
+ claude_install(tmp_path)
+ local_md = tmp_path / "CLAUDE.local.md"
+ local_md.write_text((tmp_path / "CLAUDE.md").read_text())
+ (tmp_path / "CLAUDE.md").unlink()
+ claude_uninstall(tmp_path)
+ assert not local_md.exists() or _CLAUDE_MD_MARKER not in local_md.read_text()
+
+
+def test_uninstall_cleans_both_standard_and_local(tmp_path):
+ """When the section lives in both CLAUDE.md and a local variant, both are cleaned."""
+ claude_install(tmp_path)
+ claude_md = tmp_path / "CLAUDE.md"
+ local_md = tmp_path / ".claude" / "CLAUDE.local.md"
+ local_md.write_text(claude_md.read_text()) # duplicated into the local file too
+ claude_uninstall(tmp_path)
+ for f in (claude_md, local_md):
+ assert not f.exists() or _CLAUDE_MD_MARKER not in f.read_text()
+
+
+def test_uninstall_preserves_other_content_in_local_md(tmp_path):
+ """Uninstall keeps non-graphify content in CLAUDE.local.md."""
+ claude_install(tmp_path)
+ local_md = tmp_path / ".claude" / "CLAUDE.local.md"
+ local_md.write_text("# Local notes\n\nkeep me\n\n" + (tmp_path / "CLAUDE.md").read_text())
+ claude_uninstall(tmp_path)
+ assert local_md.exists()
+ content = local_md.read_text()
+ assert "Local notes" in content
+ assert "keep me" in content
+ assert _CLAUDE_MD_MARKER not in content
+
+
+def test_uninstall_tolerates_unreadable_local_md(tmp_path):
+ """A non-UTF-8 CLAUDE.local.md must not abort uninstall (it has no marker to strip)."""
+ claude_install(tmp_path)
+ local_md = tmp_path / ".claude" / "CLAUDE.local.md"
+ local_md.write_bytes(b"\xff\xfe not valid utf-8 \x80\x81")
+ claude_uninstall(tmp_path) # must not raise
+ assert local_md.read_bytes() == b"\xff\xfe not valid utf-8 \x80\x81" # left untouched
From 20bfdf60ac7187edc2f8594252222dc8e9b96399 Mon Sep 17 00:00:00 2001
From: safishamsi
Date: Wed, 8 Jul 2026 17:07:31 +0100
Subject: [PATCH 038/292] feat(extract): add --code-only to index code without
an LLM key on a mixed repo (#1734)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`graphify extract` on a repo containing docs/papers/images hard-failed when no
LLM backend was configured — even for a user who only wants the code graph. The
only workaround was hand-building a .graphifyignore of everything non-code, which
is onerous (the "not code" set is far larger than the code set).
`--code-only` skips the semantic (doc/paper/image) pass entirely: it indexes the
code via pure local AST (no key required) and reports what it skipped ("skipping
N non-code file(s) ...") rather than silently dropping it. The no-key error on a
mixed repo now also points users at the flag. Code-only was always keyless; this
just makes a *mixed* repo usable without a key instead of failing outright.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/__main__.py | 24 ++++++++++++-
tests/test_extract_code_only_cli.py | 55 +++++++++++++++++++++++++++++
2 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 tests/test_extract_code_only_cli.py
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 9aa2cf806..2e0a71c75 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -2472,6 +2472,7 @@ def main() -> None:
print(" --out DIR output dir (default: ); writes /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")
@@ -4526,6 +4527,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
dedup_llm = False
google_workspace = False
global_merge = False
+ code_only = False
global_repo_tag: str | None = None
# Performance/tuning knobs (issue #792). None means "use library default".
cli_max_workers: int | None = None
@@ -4584,6 +4586,8 @@ def _parse_float(name: str, raw: str) -> float:
no_cluster = True; i += 1
elif a == "--dedup-llm":
dedup_llm = True; i += 1
+ elif a == "--code-only":
+ code_only = True; i += 1
elif a == "--google-workspace":
google_workspace = True; i += 1
elif a == "--global":
@@ -4707,6 +4711,20 @@ def _parse_float(name: str, raw: str) -> float:
unchanged_total = 0
semantic_files = doc_files + paper_files + image_files
+ # --code-only: index code (pure local AST, no key) and skip the semantic
+ # (doc/paper/image) pass entirely, so a mixed repo doesn't hard-fail when no
+ # LLM backend is configured (#1734). Report what was skipped rather than
+ # silently dropping it.
+ if code_only and semantic_files:
+ print(
+ f"[graphify extract] --code-only: skipping {len(semantic_files)} "
+ f"non-code file(s) ({len(doc_files)} docs, {len(paper_files)} papers, "
+ f"{len(image_files)} images) — no LLM extraction"
+ )
+ semantic_files = []
+ doc_files = []
+ paper_files = []
+ image_files = []
if incremental_mode:
print(
f"[graphify extract] {len(code_files)} code, {len(doc_files)} docs, "
@@ -4762,12 +4780,16 @@ def _parse_float(name: str, raw: str) -> float:
)
if dedup_llm:
reasons.append("--dedup-llm was passed")
+ hint = ""
+ if semantic_files:
+ hint = (" Or pass --code-only to index just the code "
+ "(local AST, no key) and skip the non-code files.")
print(
"error: no LLM API key found (" + "; ".join(reasons) + "). "
"Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), MOONSHOT_API_KEY "
"(kimi), ANTHROPIC_API_KEY (claude), OPENAI_API_KEY (openai), "
"DEEPSEEK_API_KEY (deepseek), or pass --backend. A code-only "
- "corpus needs no key.",
+ "corpus needs no key." + hint,
file=sys.stderr,
)
sys.exit(1)
diff --git a/tests/test_extract_code_only_cli.py b/tests/test_extract_code_only_cli.py
new file mode 100644
index 000000000..2fcc8a7e3
--- /dev/null
+++ b/tests/test_extract_code_only_cli.py
@@ -0,0 +1,55 @@
+"""`graphify extract --code-only` indexes code without an LLM key (#1734).
+
+A mixed repo (code + docs) with no API key configured used to hard-fail on the
+doc/paper/image files. `--code-only` skips the semantic pass so the code graph
+still builds, and the no-key error now points users at the flag.
+"""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+PYTHON = sys.executable
+_KEY_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY", "MOONSHOT_API_KEY", "DEEPSEEK_API_KEY")
+
+
+def _mixed_repo(tmp_path: Path) -> Path:
+ repo = tmp_path / "repo"
+ repo.mkdir()
+ (repo / "app.py").write_text("def hello():\n return 1\n")
+ (repo / "README.md").write_text("# Design\n\nHow it works.\n")
+ (repo / "NOTES.txt").write_text("Architecture notes and rationale.\n")
+ return repo
+
+
+def _run(repo: Path, *extra: str):
+ env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS}
+ env["GRAPHIFY_OUT"] = str(repo / "graphify-out")
+ return subprocess.run(
+ [PYTHON, "-m", "graphify", "extract", ".", *extra],
+ cwd=repo, capture_output=True, text=True, env=env,
+ )
+
+
+def test_code_only_succeeds_without_key(tmp_path):
+ repo = _mixed_repo(tmp_path)
+ r = _run(repo, "--code-only")
+ assert r.returncode == 0, f"--code-only should succeed with no key: {r.stderr}"
+ out = r.stdout + r.stderr
+ assert "--code-only: skipping" in out
+ graph = repo / "graphify-out" / "graph.json"
+ assert graph.exists(), "code graph must still be written"
+ import json
+ g = json.loads(graph.read_text())
+ labels = [n.get("label") for n in g["nodes"]]
+ assert any(str(l).startswith("hello") for l in labels), "code was indexed"
+
+
+def test_mixed_repo_without_key_errors_and_points_at_code_only(tmp_path):
+ repo = _mixed_repo(tmp_path)
+ r = _run(repo) # no --code-only, no key
+ assert r.returncode != 0, "mixed repo with no key should still error without the flag"
+ assert "--code-only" in r.stderr, "the no-key error must point users at --code-only"
From cc2d3c13a62870bca95f14f27652daa83bcba894 Mon Sep 17 00:00:00 2001
From: tpateeq
Date: Thu, 9 Jul 2026 00:20:48 +0530
Subject: [PATCH 039/292] refactor(main): extract install/uninstall subsystem
into graphify/install.py
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
__main__.py was 5,368 LOC, more than half of it per-platform install/uninstall
machinery interleaved with the CLI dispatcher. Move that subsystem — 68
functions (all *_install/*_uninstall, _copy_skill_file, _platform_skill_destination,
_always_on, etc.) plus 21 module constants (_PLATFORM_CONFIG and the platform
skill/hook payload constants) — into a new graphify/install.py, extracted verbatim.
Behavior-preserving:
- install.py lives in the same package dir as __main__ so packaged-asset lookups
via Path(__file__).parent ("always_on"/"skills") resolve unchanged.
- __main__ re-exports all 89 moved names, so `from graphify.__main__ import
claude_install` (and every other import, incl. private helpers used by tests)
keeps working, with object identity preserved.
- The install cluster was verified (AST) to have zero back-references into
__main__, so no circular import; __main__ imports _PLATFORM_CONFIG et al. one-way.
__main__.py drops 5,368 -> 3,642 LOC. Full suite unchanged: 3036 passed, 29
skipped (excluding the env-only openai tests). ruff check clean; skillgen --check OK.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
graphify/__main__.py | 1917 +++---------------------------------------
graphify/install.py | 1853 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 1948 insertions(+), 1822 deletions(-)
create mode 100644 graphify/install.py
diff --git a/graphify/__main__.py b/graphify/__main__.py
index 2e0a71c75..dc4f855a7 100644
--- a/graphify/__main__.py
+++ b/graphify/__main__.py
@@ -23,30 +23,101 @@
# 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 ` keeps working unchanged.
+from graphify.install import ( # noqa: E402,F401
+ _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,
+)
+
-@functools.lru_cache(maxsize=None)
-def _always_on(basename: str) -> str:
- """Read a packaged always-on instruction block from graphify/always_on/.
-
- The six always-on blocks (CLAUDE.md / AGENTS.md / GEMINI.md / VS Code
- Copilot instructions / Antigravity rules / Kiro steering) live as committed
- markdown next to this module, generated by tools/skillgen from a single
- human-edited fragment and guarded against drift by ``skillgen --check``. The
- installer injects them verbatim via ``_replace_or_append_section``, so the
- bytes here must match the former triple-quoted constant exactly — the
- always-on-roundtrip validator proves that.
- """
- path = Path(__file__).parent / "always_on" / f"{basename}.md"
- try:
- return path.read_text(encoding="utf-8")
- except OSError as exc:
- # Defer to use-time so a missing/corrupt packaged block can't crash module
- # import (which would brick every CLI command, not just install). Reached
- # only by an install/integration path that actually needs this block.
- raise RuntimeError(
- f"graphify install is incomplete: missing always-on block '{basename}' "
- f"at {path}. Reinstall graphifyy (e.g. `uv tool install --reinstall graphifyy`)."
- ) from exc
_ALWAYS_ON_ALIASES = {
@@ -185,254 +256,22 @@ def _version_tuple(version: str) -> tuple[int, ...]:
return tuple(parts)
-def _refresh_all_version_stamps() -> None:
- """After a successful install, update .graphify_version in all other known skill dirs.
- Prevents stale-version warnings from platforms that were installed previously
- but not explicitly re-installed during this upgrade.
- """
- for name in _PLATFORM_CONFIG:
- skill_dst = _platform_skill_destination(name)
- vf = skill_dst.parent / ".graphify_version"
- if skill_dst.exists():
- vf.write_text(__version__, encoding="utf-8")
-
-
-def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
- """Return the skill destination for a platform and scope."""
- if platform_name == "gemini":
- if project:
- return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md"
- if platform.system() == "Windows":
- return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name == "opencode":
- if project:
- return (project_dir or Path(".")) / ".opencode" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name == "hermes":
- if project:
- return (project_dir or Path(".")) / ".hermes" / "skills" / "graphify" / "SKILL.md"
- # On Windows, Hermes scans %LOCALAPPDATA%\hermes\skills, not ~/.hermes (#1403).
- if platform.system() == "Windows":
- local_appdata = Path(os.environ.get("LOCALAPPDATA") or (Path.home() / "AppData" / "Local"))
- return local_appdata / "hermes" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".hermes" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name == "devin":
- if project:
- return (project_dir or Path(".")) / ".devin" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".config" / "devin" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name == "amp":
- if project:
- return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name == "agents":
- # The generic Agent-Skills target: project ./.agents/skills, global the
- # spec's user-global ~/.agents/skills (read by `npx skills` and compliant
- # frameworks), NOT amp's ~/.config/agents/skills.
- if project:
- return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
- return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
-
- if platform_name in ("antigravity", "antigravity-windows"):
- if project:
- return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
- # Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/
- return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
-
- cfg = _PLATFORM_CONFIG[platform_name]
- if project:
- return (project_dir or Path(".")) / cfg["skill_dst"]
-
- if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"):
- return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md"
- return Path.home() / cfg["skill_dst"]
-
-
-def _packaged_skill_refs_dir(platform_name: str) -> Path | None:
- """Return the packaged references source dir for a progressive platform, else None.
-
- A platform opts into progressive disclosure by setting ``skill_refs`` in its
- ``_PLATFORM_CONFIG`` entry. The value names a bundle under
- ``graphify/skills//references/``. Reuse keys (e.g. trae-cn) point at
- their twin's bundle.
-
- ``gemini`` has no ``_PLATFORM_CONFIG`` entry: it installs claude's
- ``skill.md`` body verbatim (see ``_copy_skill_file``). Since that body is the
- lean progressive core that links to ``references/``, gemini needs claude's
- references/ sidecar too, or its SKILL.md ships with dead pointers. So gemini
- resolves to the claude bundle rather than opting out.
-
- Bundles ship one platform-group at a time. A host whose bundle directory
- ``graphify/skills//`` is not in this build has not gone progressive
- yet, so this returns None and the host installs today's monolithic SKILL.md
- with no references/ sidecar. Only when the bundle directory IS present does
- this return the references path; if that directory then lacks its
- ``references/`` subdir, ``_copy_skill_file`` hard-fails (a malformed bundle,
- the empty-sidecar regression the wheel-content test also guards).
- """
- if platform_name == "gemini":
- bundle = "claude"
- else:
- bundle = _PLATFORM_CONFIG[platform_name].get("skill_refs")
- if not bundle:
- return None
- bundle_dir = Path(__file__).parent / "skills" / bundle
- if not bundle_dir.is_dir():
- return None
- return bundle_dir / "references"
-
-
-def _install_skill_references(skill_dst: Path, refs_src: Path) -> None:
- """Atomically install a packaged references/ sidecar next to SKILL.md.
-
- Stages the packaged dir into ``references.tmp`` (copytree), drops any stale
- ``references/`` already on disk, then ``os.replace``-renames the staged dir
- into place. The rename is atomic on the same filesystem, so an interrupted
- install never leaves a half-written references/ visible to the agent.
- """
- refs_dst = skill_dst.parent / "references"
- refs_staged = skill_dst.parent / "references.tmp"
- if refs_staged.exists():
- shutil.rmtree(refs_staged)
- try:
- shutil.copytree(refs_src, refs_staged)
- if refs_dst.exists():
- shutil.rmtree(refs_dst)
- os.replace(refs_staged, refs_dst)
- except Exception:
- if refs_staged.exists():
- shutil.rmtree(refs_staged, ignore_errors=True)
- raise
-def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
- """Copy a packaged skill file and write its version stamp.
- For progressive platforms (those with ``skill_refs`` set), the packaged
- ``references/`` sidecar is installed alongside SKILL.md and the single
- ``.graphify_version`` stamp covers both. For monolith platforms (no
- ``skill_refs``), any orphan ``references/`` left by a prior progressive
- install is removed so the on-disk layout matches the package.
- """
- skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"]
- skill_src = Path(__file__).parent / skill_file
- if not skill_src.exists():
- print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr)
- sys.exit(1)
- refs_src = _packaged_skill_refs_dir(platform_name)
- if refs_src is not None and not refs_src.exists():
- # Progressive platform declared a references bundle that is missing from
- # the package. Fail loud rather than silently shipping an empty sidecar.
- print(
- f"error: references for '{platform_name}' not found in package "
- f"({refs_src}) - reinstall graphify",
- file=sys.stderr,
- )
- sys.exit(1)
- skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
- skill_dst.parent.mkdir(parents=True, exist_ok=True)
- # Install the references/ sidecar (or clear an orphan one) BEFORE writing
- # SKILL.md, so SKILL.md is the last artifact laid down. An install that is
- # interrupted partway then leaves no SKILL.md rather than a SKILL.md that
- # points at an absent references/ dir.
- if refs_src is not None:
- _install_skill_references(skill_dst, refs_src)
- print(f" references -> {skill_dst.parent / 'references'}")
- else:
- # Monolith (or progressive-with-no-refs): clear any orphan references/.
- orphan_refs = skill_dst.parent / "references"
- if orphan_refs.exists():
- shutil.rmtree(orphan_refs)
- # SKILL.md last (crash-safety), via an atomic temp + rename.
- tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
- try:
- shutil.copy(skill_src, tmp_dst)
- os.replace(tmp_dst, skill_dst)
- except Exception:
- try:
- tmp_dst.unlink(missing_ok=True)
- except OSError:
- pass
- raise
- (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
- print(f" skill installed -> {skill_dst}")
- return skill_dst
-def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool:
- """Remove a platform skill file and its version stamp without touching other scopes."""
- skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
- removed = False
- if skill_dst.exists():
- skill_dst.unlink()
- print(f" skill removed -> {skill_dst}")
- removed = True
- version_file = skill_dst.parent / ".graphify_version"
- if version_file.exists():
- version_file.unlink()
- removed = True
- refs_dir = skill_dst.parent / "references"
- if refs_dir.exists():
- shutil.rmtree(refs_dir)
- removed = True
- for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
- try:
- d.rmdir()
- except OSError:
- break
- return removed
-def _project_scope_root(path: Path, project_dir: Path) -> Path:
- """Return the top-level project artifact for a project-scoped skill path."""
- try:
- rel = path.relative_to(project_dir)
- except ValueError:
- return path
- return project_dir / rel.parts[0] if rel.parts else path
-def _remove_claude_skill_registration(project_dir: Path) -> None:
- """Remove the project-scoped Claude skill registration file/section."""
- claude_md = project_dir / ".claude" / "CLAUDE.md"
- if not claude_md.exists():
- return
- content = claude_md.read_text(encoding="utf-8")
- if "# graphify" not in content:
- return
- cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip()
- if cleaned:
- claude_md.write_text(cleaned + "\n", encoding="utf-8")
- print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
- else:
- claude_md.unlink()
- print(f" CLAUDE.md -> deleted {claude_md}")
-
-
-def _print_project_git_add_hint(paths: list[Path]) -> None:
- unique: list[str] = []
- for path in paths:
- text = path.as_posix().rstrip("/")
- if path.exists() and path.is_dir():
- text += "/"
- if text not in unique:
- unique.append(text)
- if not unique:
- return
- print()
- print("Project-scoped install. Add to version control:")
- print(f" git add {' '.join(unique)}")
+
# PreToolUse nudge payloads, emitted verbatim by the shell-agnostic
# `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks
@@ -480,359 +319,20 @@ def _print_project_git_add_hint(paths: list[Path]) -> None:
)
-def _claude_pretooluse_hooks() -> "list[dict]":
- """graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time.
- The command invokes `graphify hook-guard