chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:18 +08:00
commit 05f60106aa
288 changed files with 76871 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
# Dolt database (managed by Dolt, not git)
dolt/
# Runtime files
bd.sock
bd.sock.startlock
sync-state.json
last-touched
.exclusive-lock
# Daemon runtime (lock, log, pid)
daemon.*
# Interactions log (runtime, not versioned)
interactions.jsonl
# Push state (runtime, per-machine)
push-state.json
# Lock files (various runtime locks)
*.lock
# Credential key (encryption key for federation peer auth — never commit)
.beads-credential-key
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Sync state (local-only, per-machine)
# These files are machine-specific and should not be shared across clones
.sync.lock
export-state/
export-state.json
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
ephemeral.sqlite3
ephemeral.sqlite3-journal
ephemeral.sqlite3-wal
ephemeral.sqlite3-shm
# Dolt server management (auto-started by bd)
dolt-server.pid
dolt-server.log
dolt-server.lock
dolt-server.port
dolt-server.activity
# Corrupt backup directories (created by bd doctor --fix recovery)
*.corrupt.backup/
# Backup data (auto-exported JSONL, local-only)
backup/
# Per-project environment file (Dolt connection config, GH#2520)
.env
# Legacy files (from pre-Dolt versions)
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
db.sqlite
bd.db
# NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default
# since no pattern above ignores them.
+81
View File
@@ -0,0 +1,81 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --claim
bd update <issue-id> --status done
# Sync with Dolt remote
bd dolt push
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in Dolt database with version control and branching
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
**AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Dolt-native three-way merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought*
+54
View File
@@ -0,0 +1,54 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: JSONL-only, no Dolt database
# When true, bd will use .beads/issues.jsonl as the source of truth
# no-db: false
# Enable JSON output by default
# json: false
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
# 0 = hide titles, N > 0 = truncate to N characters
# output:
# title-length: 255
# Default actor for audit trails (overridden by BEADS_ACTOR or --actor)
# actor: ""
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
# When enabled, new events are appended incrementally using a high-water mark.
# Use 'bd export --events' to trigger manually regardless of this setting.
# events-export: false
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct database
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# JSONL backup (periodic export for off-machine recovery)
# Auto-enabled when a git remote exists. Override explicitly:
# backup:
# enabled: false # Disable auto-backup entirely
# interval: 15m # Minimum time between auto-exports
# git-push: false # Disable git push (export locally only)
# git-repo: "" # Separate git repo for backups (default: project repo)
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-checkout "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run post-checkout "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-checkout'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run post-merge "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run post-merge "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'post-merge'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-commit "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run pre-commit "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-commit'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run pre-push "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run pre-push "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'pre-push'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v1.0.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
_bd_timeout=${BEADS_HOOK_TIMEOUT:-300}
if command -v timeout >/dev/null 2>&1; then
timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
if [ $_bd_exit -eq 124 ]; then
echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads"
_bd_exit=0
fi
else
bd hooks run prepare-commit-msg "$@"
_bd_exit=$?
fi
if [ $_bd_exit -eq 3 ]; then
echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'"
_bd_exit=0
fi
if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION v1.0.0 ---
+7
View File
@@ -0,0 +1,7 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "embedded",
"dolt_database": "code_review_graph",
"project_id": "487c4722-5abe-4a00-92da-9e60064d65d0"
}
+94
View File
@@ -0,0 +1,94 @@
name: Bug report
description: Report a defect in code-review-graph (CLI, MCP server, parser, or graph store)
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Before filing, please check the
[troubleshooting guide](https://github.com/tirth8205/code-review-graph/blob/main/docs/TROUBLESHOOTING.md)
and search existing issues for duplicates.
- type: input
id: crg-version
attributes:
label: code-review-graph version
description: >-
Output of `pip show code-review-graph` (or the version reported by
`code-review-graph status`).
placeholder: "2.3.5"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other (describe in "Additional context")
validations:
required: true
- type: input
id: python-version
attributes:
label: Python version
description: Output of `python --version`. Python 3.10+ is required.
placeholder: "3.12.4"
validations:
required: true
- type: dropdown
id: ai-platform
attributes:
label: AI platform
description: Which AI coding tool were you using when you hit the bug?
options:
- claude-code
- cursor
- codex
- windsurf
- zed
- opencode
- copilot
- other (describe in "Additional context")
validations:
required: true
- type: textarea
id: status-output
attributes:
label: Output of `code-review-graph status`
description: >-
Run `code-review-graph status` in the affected repository and paste the full
output. If the command itself fails, paste the error instead.
render: shell
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Steps to reproduce
description: Exact commands or actions, in order, that trigger the bug.
placeholder: |
1. code-review-graph build
2. code-review-graph detect-changes --brief
3. ...
validations:
required: true
- type: textarea
id: expected-actual
attributes:
label: Expected vs actual behavior
description: What did you expect to happen, and what happened instead?
placeholder: |
Expected: ...
Actual: ...
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Logs, stack traces, screenshots, or anything else that helps.
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and ideas (GitHub Discussions)
url: https://github.com/tirth8205/code-review-graph/discussions
about: Ask questions, share setups, and discuss ideas before filing an issue.
- name: Troubleshooting guide
url: https://github.com/tirth8205/code-review-graph/blob/main/docs/TROUBLESHOOTING.md
about: Fixes for common installation, build, and MCP connection problems.
@@ -0,0 +1,54 @@
name: Feature request
description: Suggest a new capability or an improvement to code-review-graph
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for the suggestion! For requests to support a new AI coding tool,
please use the **Platform support request** template instead.
- type: textarea
id: problem
attributes:
label: Problem
description: >-
What problem are you trying to solve? What is painful or impossible today?
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed solution
description: How would you like this to work? CLI flags, MCP tool shape, etc.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
options:
- Parser / language support
- Graph store / SQLite
- MCP tools / server
- CLI
- Visualization
- Embeddings / search
- Docs
- Other
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other approaches or workarounds you have tried or considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Links, examples from other tools, or anything else that helps.
validations:
required: false
@@ -0,0 +1,62 @@
name: Platform support request
description: Request support for a new AI coding tool (MCP config, hooks, or skills)
title: "[Platform]: "
labels: ["enhancement", "platform-support"]
body:
- type: markdown
attributes:
value: |
code-review-graph already configures Claude Code, Codex, Cursor, Windsurf, Zed,
Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Kiro, Qoder, and GitHub
Copilot. Use this form to request support for another AI coding tool.
- type: input
id: platform-name
attributes:
label: Platform name
description: The AI coding tool you want supported.
placeholder: "e.g. Aider"
validations:
required: true
- type: input
id: platform-docs
attributes:
label: Link to the platform's MCP / configuration docs
description: Official documentation describing how the tool consumes MCP servers.
placeholder: "https://..."
validations:
required: true
- type: dropdown
id: mcp-support
attributes:
label: Does the platform support MCP?
options:
- Yes — stdio transport
- Yes — HTTP / SSE transport
- Yes — both transports
- "No"
- Unknown
validations:
required: true
- type: textarea
id: config-location
attributes:
label: Where does its MCP configuration live?
description: >-
Config file path(s) and format, if known — e.g. `~/.tool/mcp.json`,
project-level `.tool/settings.json`, TOML, etc.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Hooks/skills support, rules-file injection points, or other details.
validations:
required: false
- type: checkboxes
id: testing
attributes:
label: Testing
options:
- label: I have this tool installed and can test a pre-release integration.
required: false
+34
View File
@@ -0,0 +1,34 @@
# Pull Request
## Linked issue
<!-- Link the issue this PR addresses, e.g. "Closes #123".
If there is no issue, briefly say why (e.g. trivial typo fix). -->
Closes #
## What & why
<!-- What does this PR change, and why is the change needed? -->
## How it was tested
<!-- Paste the exact commands you ran and summarise their results. Typical commands
(see CONTRIBUTING.md "Running Tests" and "Linting and Type Checking"): -->
```bash
uv run pytest tests/ --tb=short -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
```
## Checklist
<!-- Mirrors the requirements in CONTRIBUTING.md ("Making Changes" and "Code Style"). -->
- [ ] Tests added for new functionality
- [ ] All tests pass: `uv run pytest tests/ --tb=short -q`
- [ ] Linting passes: `uv run ruff check code_review_graph/`
- [ ] Type checking passes: `uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional`
- [ ] Lines are at most 100 characters
- [ ] Docs updated where behavior changed (README, `docs/`, docstrings)
+43
View File
@@ -0,0 +1,43 @@
---
applyTo: '**'
description: Use code-review-graph MCP tools for token-efficient codebase exploration and code review instead of built-in file/search tools.
---
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using #tool:read/readFile #tool:search/fileSearch #tool:search/textSearch to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context_tool` | Need source snippets for review — token-efficient |
| `get_impact_radius_tool` | Understanding blast radius of a change |
| `get_affected_flows_tool` | Finding which execution paths are impacted |
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes_tool` for code review.
3. Use `get_affected_flows_tool` to understand impact.
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
+38
View File
@@ -0,0 +1,38 @@
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context_tool` | Need source snippets for review — token-efficient |
| `get_impact_radius_tool` | Understanding blast radius of a change |
| `get_affected_flows_tool` | Finding which execution paths are impacted |
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes_tool` for code review.
3. Use `get_affected_flows_tool` to understand impact.
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
+20
View File
@@ -0,0 +1,20 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
commit-message:
prefix: "deps"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
labels:
- "dependencies"
commit-message:
prefix: "ci"
+88
View File
@@ -0,0 +1,88 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Lint with ruff
run: ruff check code_review_graph/
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install -e ".[dev]" mypy types-networkx
- name: Run mypy
run: mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install bandit
run: pip install bandit[toml]
- name: Run bandit security scan
run: bandit -r code_review_graph/ -c pyproject.toml
schema-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Python/VSCode schema versions match
run: |
PY_VER=$(grep -oP 'LATEST_VERSION\s*=\s*max\(MIGRATIONS\.keys\(\)\)' code_review_graph/migrations.py > /dev/null && python3 -c "
import re, ast
src = open('code_review_graph/migrations.py').read()
m = re.search(r'MIGRATIONS:\s*dict\[.*?\]\s*=\s*\{([^}]+)\}', src)
keys = [int(k.strip().rstrip(':')) for k in re.findall(r'(\d+):', m.group(1))]
print(max(keys))
")
TS_VER=$(grep -oP 'SUPPORTED_SCHEMA_VERSION\s*=\s*\K\d+' code-review-graph-vscode/src/backend/sqlite.ts)
echo "Python LATEST_VERSION: $PY_VER"
echo "VSCode SUPPORTED_SCHEMA_VERSION: $TS_VER"
if [ "$PY_VER" != "$TS_VER" ]; then
echo "::error::Schema version mismatch! Python=$PY_VER, VSCode=$TS_VER"
exit 1
fi
echo "Schema versions in sync."
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e ".[dev]" pytest-cov
- name: Run tests with coverage
run: pytest --tb=short -q --cov=code_review_graph --cov-report=term-missing --cov-fail-under=65
+65
View File
@@ -0,0 +1,65 @@
name: Weekly Eval
# Report-only benchmark run. This workflow surfaces benchmark drift in the
# job summary and the uploaded CSV artifact, but it must NOT fail the default
# branch on regressions (yet) — eval failures are informational until the
# co-change baseline has enough history to set thresholds against.
on:
schedule:
- cron: "23 6 * * 1" # Mondays 06:23 UTC (off-minute to dodge load spikes)
workflow_dispatch:
permissions:
contents: read
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install with eval extras
run: pip install -e ".[eval]"
- name: Run benchmarks (2 smallest pinned configs)
# httpx (~60 files) and flask (~83 files) are the two smallest
# pinned repos. Report-only: `|| true` keeps regressions and
# transient clone failures from failing the default branch.
run: |
code-review-graph eval \
--repo httpx,flask \
--benchmark token_efficiency,impact_accuracy,agent_baseline \
--output-dir evaluate/results || true
- name: Upload result CSVs
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-results-${{ github.run_id }}
path: evaluate/results/*.csv
if-no-files-found: warn
retention-days: 90
- name: Write job summary
if: always()
run: |
python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
from code_review_graph.eval.reporter import generate_full_report
print("# Weekly eval (report-only)")
print()
print(
"Configs: `httpx`, `flask` (the two smallest pinned repos). "
"Regressions are reported here and in the CSV artifact but do "
"not fail CI."
)
print()
print(generate_full_report("evaluate/results"))
PY
+21
View File
@@ -0,0 +1,21 @@
# Dogfoods the local composite action (action.yml at the repo root) on PRs.
name: PR Review
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run code-review-graph review
uses: ./
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-risk: none
+32
View File
@@ -0,0 +1,32 @@
name: Publish to PyPI
on:
release:
types: [published]
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build tools
run: pip install build twine
- name: Build package
run: python -m build
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
+94
View File
@@ -0,0 +1,94 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
*.egg
.eggs/
# Virtual environments
.venv/
venv/
env/
# Graph database
*.db
*.db-journal
*.db-wal
*.db-shm
.code-review-graph/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node
node_modules/
# VS Code extension build artifacts
code-review-graph-vscode/dist/
*.vsix
# Claude Code
.claude/
.claude-plugin/
# Qoder
.qoder/
QODER.md
# Coverage
htmlcov/
.coverage
.coverage.*
# pytest
.pytest_cache/
# mypy
.mypy_cache/
# Excalidraw source files (PNGs are tracked, sources are not)
*.excalidraw
diagrams/export_pngs.mjs
# Evaluation (generated output — test repos and reports are local; canonical
# CSVs under evaluate/results/ are tracked as evidence for the numbers in
# docs/REPRODUCING.md, so contributors can verify without rerunning).
evaluate/test_repos/
evaluate/reports/
evaluate/standalone_token_benchmark.json
evaluate/eval-run.log
# Superpowers brainstorm docs
.superpowers/
docs/superpowers/
# Draft/duplicate assets
docs/assets/marketing-diagram*
# One-off docs (audits, analyses, plans, articles)
medium/
Quality-Audit-Report.docx
accessibility-audit.md
cross-audit-synthesis.md
design-critique.md
design-handoff.md
design-system-audit.md
research-synthesis.md
code-review-graph-analysis.md
SCALING_AND_TOKEN_EFFICIENCY_PLAN.md
# Beads / Dolt files (added by bd init)
.dolt/
.beads-credential-key
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": ["code-review-graph", "serve"]
}
}
}
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+154
View File
@@ -0,0 +1,154 @@
# the name by which the project can be referenced within Serena
project_name: "code-review-graph"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# haxe java julia kotlin lua
# markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- python
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
#
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project based on the project name or path.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
# for example by saying that the information retrieved from a memory file is no longer correct
# or no longer relevant for the project.
# * `edit_memory`: Replaces content matching a regular expression in a memory.
# * `execute_shell_command`: Executes a shell command.
# * `find_file`: Finds files in the given relative paths
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
# * `find_symbol`: Performs a global (or local) search using the language server backend.
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
# for clients that do not read the initial instructions when the MCP server is connected.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
# is relevant to the current task. You can infer whether the information
# is relevant from the memory file name.
# You should not read the same memory file multiple times in the same conversation.
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
# For JB, we use a separate tool.
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
# * `safe_delete_symbol`:
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
# The memory name should be meaningful.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
+123
View File
@@ -0,0 +1,123 @@
# Agent Instructions
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
## Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work atomically
bd close <id> # Complete work
bd dolt push # Push beads data to remote
```
## Non-Interactive Shell Commands
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
**Use these forms instead:**
```bash
# Force overwrite without prompting
cp -f source dest # NOT: cp source dest
mv -f source dest # NOT: mv source dest
rm -f file # NOT: rm file
# For recursive operations
rm -rf directory # NOT: rm -r directory
cp -rf source dest # NOT: cp -r source dest
```
**Other commands that may prompt:**
- `scp` - use `-o BatchMode=yes` for non-interactive
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
- `apt-get` - use `-y` flag
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## Beads Issue Tracker
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
### Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
```
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
## Session Completion
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd dolt push
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION -->
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context_tool` | Need source snippets for review — token-efficient |
| `get_impact_radius_tool` | Understanding blast radius of a change |
| `get_affected_flows_tool` | Finding which execution paths are impacted |
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes_tool` for code review.
3. Use `get_affected_flows_tool` to understand impact.
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
+821
View File
@@ -0,0 +1,821 @@
# Changelog
## [Unreleased]
## [2.3.6] - 2026-06-10
**Community-response release.** Built from a full audit of every open PR,
issue, and discussion: community fixes merged with credit, verified defects
fixed (including two open Windows bugs), benchmark claims made independently
checkable, and the project's first self-hosted PR review bot — this repo now
reviews its own pull requests with its own graph. No breaking changes.
### Added
- **Custom languages without forking** (#320): drop a
`.code-review-graph/languages.toml` into your repo to index any grammar
shipped by tree-sitter-language-pack (extension map + node-type lists,
validated and capped, built-ins always win). See docs/CUSTOM_LANGUAGES.md.
- **GitHub Action** for risk-scored PR review comments: composite `action.yml`
builds/restores the graph from CI cache, runs `detect-changes` against the
PR base, and upserts a sticky comment with risk table, affected flows, test
gaps, and the Token Savings line. Dogfooded on this repo via
`.github/workflows/pr-review.yml`. See docs/GITHUB_ACTION.md.
- **`agent_baseline` eval benchmark**: compares graph queries against a
realistic grep-and-read-top-k agent baseline instead of the whole-corpus
strawman; wired into all six pinned eval configs.
- **Co-change ground truth for `impact_accuracy`**: predictions are now also
graded against files actually co-changed in the same commit; the legacy
metric is explicitly labelled "graph-derived (circular — upper bound)".
- **Weekly eval CI** (`.github/workflows/eval.yml`): report-only cron run of
the two smallest pinned configs with CSV artifacts and a job summary.
- **docs/FAQ.md**: how CRG compares to LSP, RAG, grep/agentic search, and
adjacent tools; when NOT to use it; verification steps; monorepo/worktree
and registry guidance. Linked from the README.
- GitHub issue forms (bug/feature/platform), a PR template mirroring the
CONTRIBUTING checklist, and dependabot config for pip + GitHub Actions.
### Fixed
- `store_file_batch` is now guarded against open transactions like its sibling
(#489, merged from community PR #529 by @Devilthelegend — thank you).
- **Windows: `daemon status` no longer crashes with WinError 87** (#511):
PID liveness now uses `OpenProcess`/`WaitForSingleObject` on win32 instead
of `os.kill(pid, 0)`.
- **Windows: CLI `detect-changes` mapped 0 functions** (#528): diff paths are
now remapped to absolute native paths before node lookup, matching the MCP
tool's behavior; also prevents the misleading "~100% token savings" line on
an empty result.
- Eval benchmarks no longer record failed runs as inflated wins: thrown
`get_review_context`/`analyze_changes` calls are marked `status=error` and
excluded from aggregates instead of producing naive/1 ratios or recall=1.0.
- Unknown embedding provider names now raise a clear error listing valid
providers instead of silently falling back to the local model.
- The five analysis MCP tools and the wiki-page tool no longer leak SQLite
connections (try/finally `store.close()`).
- `install` git hooks now resolve the real hooks directory via
`git rev-parse --git-path hooks`, so linked worktrees and `core.hooksPath`
(husky) setups get a working pre-commit hook (#313 residue).
- Shipped `hooks/hooks.json` and `hooks/session-start.sh` now drain stdin,
matching the generated configs (#493 class).
- `fastmcp` is now capped `<4` so the next major cannot silently break the
server (the #488 failure mode).
### Changed
- README benchmarks section now leads with the ~82x median per-question
reduction (528x presented as the best case, not the headline), the
limitations block is visible instead of collapsed, and "100% impact recall"
is reframed as a graph-derived upper bound alongside the new co-change
metric.
- Stale translated READMEs (zh-CN, ja-JP, ko-KR, hi-IN) carry a staleness
banner; the zh-CN benchmark captions and docs/USAGE.md no longer contradict
the English README.
- SECURITY.md now points to GitHub private vulnerability reporting as the
canonical channel.
## [2.3.5] - 2026-05-25
**Real-time token savings, visible to humans.** The estimated context-savings
metric introduced in 2.3.4 was JSON-only. In 2.3.5 it surfaces as a clean
boxed panel on the CLI and is verifiable against a real tokenizer in one
flag — so when you reach for `code-review-graph` to review a change, you
can immediately *see* how much of your context window the graph just kept
out. No breaking changes.
### Added — Token Savings (headline feature)
- **Boxed `Token Savings` panel on every `--brief` CLI call.** Both
`code-review-graph detect-changes --brief` and the new
`code-review-graph update --brief` print a four-line panel: the full-context
baseline, the graph response size, total saved tokens with percent, and a
per-category breakdown (Functions / Tests / Risk / Other) that **sums
exactly** to the graph response size — no padding, no rounding magic.
```text
┌─────────────────────── Token Savings ────────────────────────┐
│ Full context would be: 12,921 tokens │
│ Graph context used: 762 tokens │
│ Saved: 12,159 tokens (~94%) │
│ Breakdown: Functions 244 · Tests 191 · Risk 244 · Other 83 │
└──────────────────────────────────────────────────────────────┘
```
- **`--verify` flag** cross-checks the displayed numbers against OpenAI's
`cl100k_base` tokenizer (the GPT-4 family). Adds a second
`Verified (tiktoken)` row to the panel showing the real token counts.
Requires `pip install tiktoken`. A one-time calibration across 222 mixed
source files (Python/JS/TS/Go/Rust/RST/MD) committed in
`docs/REPRODUCING.md` shows the `chars/4` approximation stays within
**+0.5%** of real tokens in aggregate; per-repo bias is bounded to ±12%
and the **ratio** stays stable because both sides of the divide are
equally biased.
- **`code-review-graph update --brief`** — incremental update plus the same
risk + Token Savings panel in one command. Distinct from
`detect-changes --brief` (which is read-only against the existing graph).
Use `update --brief` when the graph might be stale (post-rebase, large
change set); use `detect-changes --brief` when hooks/`crg-daemon` have
already kept the graph fresh.
### Added — Reproducible benchmarks
- **`docs/REPRODUCING.md`** — end-to-end reproduction recipe with canonical
numbers, the tiktoken calibration table, and an explicit explanation of
the three different "token" benchmarks in the codebase and what each
measures. Two people running the recipe on different machines on
different days now produce **identical** numbers, within float rounding.
- **`multi_hop_retrieval` benchmark** — 11 hand-curated 2-step tool-chain
tasks (semantic_search → query_graph) across the 6 test repos. Average
score **0.909**. Per-task CSV in `evaluate/results/`.
- **`code-review-graph embed` CLI subcommand** — explicit shell-level access
to embedding generation. Previously only reachable via MCP, which made
the benchmark recipe awkward.
### Changed — Deterministic eval pipeline
- **Every config under `code_review_graph/eval/configs/*.yaml` now pins an
upstream SHA.** Previously every config used `commit: HEAD`, which made
benchmarks drift whenever upstream pushed. Pinned SHAs: express
`b4ab7d65`, fastapi `0227991a`, flask `a29f88ce`, gin `5c00df8a`, httpx
`b55d4635`, code-review-graph `84bde354`.
- **`nextjs.yaml` renamed to `code-review-graph.yaml`.** The historical
"nextjs" entry pointed at this repo, not a Next.js codebase. Renamed to
match reality.
- **`eval/runner.py` uses full clones with explicit `returncode` checks.**
Previously `--depth 50` silently fell back to `HEAD~1..HEAD` whenever a
pinned test-commit SHA was past the shallow window, producing benchmark
numbers tied to whichever HEAD the clone happened to grab.
- **Leiden community detection seeded** (`CRG_LEIDEN_SEED`, default `42`).
Previously unseeded — community IDs and sizes drifted run-to-run on the
same graph, breaking benchmark comparability.
- **`eval/runner.py` resolves repo paths absolutely before storing.** The
parser previously stored file_path as the path you passed in, so eval
builds and CLI/MCP builds could disagree, producing duplicate nodes for
the same source location. Fixed by `.resolve()` in the runner.
- **`eval/runner.py` calls `run_post_processing` after `full_build`.**
Previously the eval framework left FTS5 unpopulated (shadow tables
`nodes_fts_idx` and `nodes_fts_docsize` empty), so downstream search and
multi-hop benchmarks silently returned no results.
### Changed — Search and embeddings
- **`embeddings._node_to_text` is richer.** Embedded text per node now
includes the dotted form (`Parent.name`, e.g. `APIRoute.get_route_handler`),
the identifier split into words (`get route handler`), and the enclosing
module directory (`routing`, `dependencies`). Forces an automatic
re-embedding because the text hash changes. Lifts multi-hop benchmark
accuracy from **0.545 → 0.818**.
- **Identifier-aware search boost** (`search.extract_query_identifiers`).
Natural-language queries like *"Who advances the gin middleware chain
via Context.Next"* now have their dotted / snake_case / CamelCase tokens
extracted and used to boost matching qualified-names by 2.0× in hybrid
search. Combined with the richer embed text, multi-hop accuracy reaches
**0.909** (10 of 11 tasks pass).
### Fixed
- **Test-gap dedup in the brief summary.** If duplicate `qualified_names`
ever slip into the graph (e.g. after a path-normalization mismatch),
the `Untested:` line in the human summary now collapses to unique names.
The underlying `test_gaps` list still carries every entry.
- **`token_benchmark.py` warns when embeddings are missing.** The standalone
benchmark's default NL questions need semantic search to match anything;
without embeddings the benchmark used to silently report 0× reduction
ratios. Now logs an explicit warning pointing users to `embed`.
### Documentation
- **`docs/REPRODUCING.md`** (new). End-to-end recipe, canonical numbers,
the tiktoken calibration table, and a side-by-side explanation of the
three "token" benchmarks in the codebase.
- **`README.md` Token Savings section** (new collapsible block under
Usage). Plain-English explanation of `detect-changes --brief` vs
`update --brief` — read-only vs re-parses-first — with a side-by-side
decision table.
- **`docs/COMMANDS.md`** lists the new `--brief`, `--verify`, and `embed`
forms with an inline "which one?" comment block on the analysis pair.
- **Updated benchmark headline** to reflect today's pinned-SHA snapshot:
range **38× 528×** (median ~82×) across the 6 repos, **100% impact
recall**, **F1 0.71** across 13 commits. Old numbers (73× 895×)
reflected pre-fix conditions (leftover build artifacts, smaller graph
responses) and have been superseded.
- **9 Excalidraw diagrams** regenerated with current canonical numbers
(`diagrams/*.excalidraw`, source kept locally; PNG re-exports manual).
### Demo
- **`diagrams/context-savings-demo.gif`** — 44 s screencast showing both
CLI surfaces and the `--verify` cross-check. Rendered from
`diagrams/context-savings-demo.tape` (regenerable with `vhs`).
## [2.3.4] - 2026-05-25
Focused reliability and token-efficiency release for MCP/CLI review workflows. No breaking changes.
### Added
- **Estimated context savings metadata** for graph-filtered review/impact/architecture responses. The new `context_savings` field is intentionally compact (`estimated`, `saved_tokens`, `saved_percent`) and uses the existing conservative character-count approximation rather than claiming exact tokenization.
- **CLI estimated savings line** for `code-review-graph detect-changes --brief`; full JSON output includes the same compact `context_savings` metadata.
### Changed
- **Architecture overview is compact by default**: `get_architecture_overview_tool` now defaults to `detail_level="minimal"`, dropping per-community member lists and aggregating cross-community edges by community pair. Full per-edge output remains available with `detail_level="standard"`.
- **Bounded change analysis**: `detect_changes_tool` can now cap very large changed-function and transitive-test frontiers with `CRG_MAX_CHANGED_FUNCS` and `CRG_MAX_TRANSITIVE_FRONTIER`, and can return a structured timeout error via `CRG_TOOL_TIMEOUT`.
### Fixed
- **Windows semantic search deadlock** (#508/#507): local embedding models are pre-warmed on the main thread on Windows before FastMCP starts worker dispatch.
- **Rust test detection** (#503/#502): Rust `#[test]` and common async test attributes now produce `Test` nodes.
- **Generated hook stdin handling** (#494/#493): Codex and Claude hook commands drain stdin to avoid caller-side broken pipes on large hook payloads.
- **Cross-file callers** (#486/#472): `callers_of` now returns cross-file callers even when same-file callers exist.
- **Graph path lookup** (#469): review, impact, and file-summary tools resolve user-facing paths to the path format stored in the graph.
- **Bundled MCP docs** (#485/#480): `get_docs_section` can load the packaged `LLM-OPTIMIZED-REFERENCE.md` from installed wheels.
- **Local embedding provider availability** (#484/#448): missing `sentence-transformers` now reports local provider unavailability instead of silently producing zero embeddings.
- **Dead-code response fields** (#481/#447): dead-code results now include `file_path`, `relative_path`, and `language` while preserving the legacy `file` key.
- **SVN root validation** (#456): MCP/daemon/registry root validation now accepts `.svn` working copies consistently.
- **CLI postprocess flags** (#487): `build --skip-postprocess` and `update --skip-flows` no longer run an extra full post-processing pass.
### Documentation
- Updated stale release-facing version references for 2.3.4.
- Replaced fragile language-count wording with current broad language and notebook support wording.
- Added the missing VS Code extension `0.2.2` changelog entry without changing the extension package version.
### Tests
- Added regression coverage for compact architecture overview output and #476 mitigation.
- Added tests for estimated context savings calculation, compact metadata shape, MCP metadata, CLI brief/JSON output, Rust test parsing, hook stdin draining, graph path resolution, dead-code fields, SVN root validation, CLI postprocess flags, embedding availability, and bounded detect-changes behavior.
## [2.3.3] - 2026-05-08
Large additive release accumulated since v2.3.2 — 141 non-merge commits, 8 new languages/extensions, 5 new platform install targets, 6 new framework call resolvers, comprehensive Windows hardening, VS Code accessibility pass, and a full sweep of community PRs.
### Added
#### Languages and extensions
- **Nix support** (flake-aware): `.nix` files are parsed via the `nix` tree-sitter grammar shipped with `tree-sitter-language-pack`. Top-level and nested attrset bindings become `Function` nodes with flattened dotted names (e.g. `packages.default`, `devShells.default`). In `flake.nix`, `inputs.<name>.url = "..."` strings emit `IMPORTS_FROM` edges to the URL; `import <path>` and `callPackage <path> <args>` applications in any `.nix` file emit `IMPORTS_FROM` edges (relative paths are resolved against the caller's directory). Adds 7 tests (`TestNixParsing`) and fixtures `tests/fixtures/sample.nix`, `tests/fixtures/sample_module.nix`.
- **GDScript support** (Godot, PR #316): `.gd` files are parsed via the `gdscript` tree-sitter grammar. Extracts inner classes (`class Name:`), the file-level `class_name` identity, functions (including `static func`), `extends` parent class as an IMPORTS_FROM edge, direct calls and method calls. Adds 10 tests and `tests/fixtures/sample.gd`.
- **Verilog / SystemVerilog support** (PR #428): `.v`, `.sv`, `.svh` files parse modules, classes, packages, interfaces, programs, functions, and tasks via the `verilog` tree-sitter grammar. Per-construct extractors with dedicated unit tests.
- **SQL support** (PR #398): `.sql` files parse `CREATE FUNCTION`, `CREATE PROCEDURE`, `CREATE TABLE`, and `CREATE VIEW` statements; emits CALLS edges for function invocations.
- **ReScript support** (PR #309/323): `.res`/`.resi` parsing for modules, let-bindings, and external declarations.
- **`.hh` extension support**: C++ header variants now resolve into the C++ parser path.
- **`.ksh` extension and shebang-based detection** (PR #276): `.ksh` files parsed as shell; extension-less scripts detected via `#!/usr/bin/env <lang>` shebang lines.
- **Julia improvements**: parametric constructors, `@enum` declarations, and `public` module exports now produce graph nodes.
#### Platforms and install targets
- **GitHub Copilot platform support** (PR #445): `code-review-graph install --platform copilot` writes Copilot-CLI-compatible MCP config without generating Claude-specific skill artifacts.
- **Gemini CLI platform support** (PR #391): `--platform gemini-cli` skips Claude skills and writes Gemini-native MCP config.
- **Qoder platform support** (PR #245): `--platform qoder` adds MCP server registration for Qoder.
- **OpenCode plugin support** (PR #198 via #366): `--platform opencode` registers the MCP server with the OpenCode plugin manifest.
- **Cursor hooks support** (PR #196): `install` now writes Cursor hook entries (gated behind `~/.cursor` detection so non-Cursor users are not affected).
- **Codex install alignment**: native Codex integration path; no Claude skill files generated for Codex targets.
#### MCP server and CLI features
- **`crg-daemon`**: new multi-repo watch daemon that supervises per-repo file watchers via `subprocess.Popen` child processes. Documented in README, COMMANDS.md, and ROADMAP.md. 35 dedicated tests.
- **Streamable HTTP transport** (PR #277): MCP server can now run over streamable HTTP in addition to stdio.
- **`serve --tools` flag and `CRG_TOOLS` env var**: MCP tool filtering at startup so callers can expose only the subset they need.
- **`--repo` precedence and validation in `get_docs_section`** (PR #378): honors `serve --repo` and validates path containment before returning section content.
- **Search enrichment via PreToolUse hooks** (PR #248): hook-driven search index enrichment ahead of tool calls.
- **External database directory support**: graph DB can now live on a network filesystem via the existing `CRG_DATA_DIR` mechanism, with the file locking path adjusted accordingly.
- **SVN support** (PR #255): basic Subversion working-copy detection alongside git for change analysis.
#### Parser and resolver improvements
- **Spring DI call resolution** (PR #413): receiver method calls (`this.userService.find(...)`) resolve through `@Autowired`/constructor-injected fields to the concrete `InjectedType.method`. Emits `INJECTS` edges and stereotype metadata (`@Service`, `@Component`, `@Repository`, `@Controller`); writes fully-qualified `target_qualified` so `callers_of` queries work.
- **Temporal workflow/activity call resolution**: `WorkflowStub.start(...)` and `ActivityStub.execute(...)` resolve to their concrete workflow/activity implementations.
- **Kafka consumer/producer detection**: `@KafkaListener`-annotated methods and `KafkaTemplate.send(...)` calls emit `CONSUMES` and `PRODUCES` edges keyed on topic.
- **Jedi-based Python call resolution** (PR #247): improved cross-file Python call resolution using the Jedi static-analysis library.
- **Python callback REFERENCES edges** (PR #363): function names passed as callback arguments (`schedule(my_handler)`) now emit `REFERENCES` edges instead of being dropped.
- **Mocha TDD `suite()` recognition** (PR #423): files using Mocha's TDD interface now classify as tests.
- **Bun test runtime support** (PR #421): files importing `bun:test` are detected as tests.
- **`__tests__/` directory detection** (PR #422): all files under `__tests__/` are classified as test files regardless of name.
#### Embeddings
- **OpenAI-compatible embedding provider** (PR #321): pluggable provider supporting OpenAI, Azure OpenAI, and any OpenAI-API-compatible endpoint, with configurable batch size.
- **Localized embedding READMEs**: provider docs translated for non-English users.
#### Visualization, accessibility, and VS Code extension
- **WCAG 2.1 AA contrast pass**: 4.5:1 minimum text contrast across the standalone HTML and VS Code webview.
- **Distinct `d3.symbol` shapes per node kind**: colorblind-friendly differentiation in both the standalone visualization and the VS Code webview.
- **Keyboard navigation**: tab/arrow/enter/escape navigation across nodes, with focus styles and a skip-link to bypass the legend.
- **ARIA roles and labels**: tooltip, detail panel, legend, search results, communities button, edge-pill keyboard activation, search input label.
- **Help overlay**: interaction guide for both the standalone HTML and the keyboard-help overlay.
- **Empty-state webview** in VS Code with a contextual depth slider and tooltip.
- **Edge filter popover** in the VS Code toolbar — fixes density on narrow panels.
- **Detail panel relocated to the left** so it no longer occludes top controls; close button restyled to match the toolbar.
- **CONTAINS edge opacity** raised from 0.08 → 0.14 for visibility on dense graphs.
- **GitHub Dark palette** unified across the VS Code extension.
- **`IMPLEMENTS`, `TESTED_BY`, `DEPENDS_ON` edge types** rendered in the standalone HTML visualization.
### Fixed
#### `__version__` reporting
- **`code_review_graph.__version__` now matches `pyproject.toml`** (was `2.1.0` since the v2.1.0 release). The User-Agent header that `embeddings.py` sends on cloud HTTP requests is built from this string, so cloud-embedding traffic was being mis-attributed across all releases between v2.1.0 and v2.3.2.
#### C++ / Java / PHP parsing
- **C++ scoped/destructor/operator method names** (PR #371, PR #403): `void Foo::bar()`, `Foo::~Foo()`, `Foo::operator==(...)` now extract the correct member name instead of the qualifier or the operator token.
- **Java method name extraction** (PR #275): method names are now read from the `identifier` child of `method_declaration` rather than the return-type child (which was producing names like `int64`).
- **Java superclass / super-interfaces** (PR #278): `extends Foo` and `implements Bar, Baz` now extract bare type names from the `superclass`/`super_interfaces` AST nodes.
- **Java import resolution to file paths** (PR #280): `import com.example.foo.Bar` resolves through `src/main/java/...` and configured source roots to the actual file.
- **PHP `CALL` extraction** (PR #298): method calls (`$obj->foo()`), static calls (`Foo::bar()`), and unqualified function calls now produce CALLS edges.
- **Module-scope `CALLS` edges** (PR #285): top-level executable statements emit CALLS edges (previously only function/method bodies did).
#### Windows
- **Windows MCP stdio hang on long-running tools** (PR #400, PR #292): thread-pool selection now auto-selects on Windows MCP stdio so build/embed do not deadlock.
- **Windows MCP stdin hang** (PR #425): all `git`/`svn` subprocesses now run with `stdin=DEVNULL`, preventing the FastMCP-stdio buffer from filling on Windows.
- **Windows non-UTF-8 locale**: `subprocess.run` calls now pass `encoding="utf-8"` so cp1252 hosts no longer mis-decode git output.
- **Windows test failures** (PR #274): UTF-8 encoding, CRLF normalization, and `stop_at` boundary handling fixes for Windows CI.
#### Hooks and install
- **Hooks JSON schema** (PR #288): `hooks.json` validation no longer fails on the wrapper layout — `matcher` is required and the wrapper is removed.
- **Hooks merge instead of overwrite** (PR #114, PR #145, PR #203): `install_hooks` now merges into existing hook arrays and creates a `settings.json.bak` backup before modifying user config.
- **Pre-commit hook adds `update` command** (PR #315): generated pre-commit hook runs `code-review-graph update` rather than the obsolete subcommand.
- **Skip hooks gracefully outside git** (PR #293): `install` no longer fails when invoked from a non-git directory.
- **Poetry / uv environment detection** (PR #287): `install` now generates the correct MCP serve command for projects using Poetry or uv.
- **Hook quoting and `docs` repo_root** (PR #192): hook commands now quote repo paths with spaces, and the docs repo path is restored on install.
#### MCP server
- **fastmcp 3.x compatibility**: `_apply_tool_filter` restored on fastmcp ≥3, dependency floor bumped to `fastmcp>=3.2.4` to pick up the upstream Windows stdio EOF fixes.
- **FastMCP banner suppressed for stdio transport** (PR #290): the startup banner no longer corrupts the stdio handshake.
- **MCP config `cwd`, skills path, and JSONC parsing**: install now writes `cwd` into MCP config, points skills at the correct project path, and tolerates JSONC (comments + trailing commas) in existing config files.
#### SQLite and post-processing
- **SQLite transaction safety, FTS5 sync, and atomic operations** (PR #94, PR #279): nested-transaction handling, FTS5 content-table synchronization, and resource cleanup on error paths.
- **CLI build/update/watch run post-processing** (PR #98): signatures, FTS, flows, and communities are now refreshed after every CLI graph mutation (was previously only refreshed by the MCP server).
- **`reconcile()` auto-builds graphs and registers new repos**: cold-start path no longer requires a manual `build` before `reconcile`.
- **Flow trace adjacency in-memory** (PR #296): `trace_flows` loads adjacency once instead of querying SQLite per hop.
#### Other
- **`UnicodeDecodeError` in `read_text`** (PR #303): all text reads now use `errors="replace"`.
- **Dead-code callback references** (PR #424): functions referenced as callbacks no longer mis-classify as dead code.
- **Skills.py table formatting** (PR #302).
- **Search.py duplicate logger** removed.
- **`status` command reports alive/dead** from the persisted state file.
### Security
- **Embeddings RCE hardening** (PR #397): remote code execution paths in the embedding provider are gated behind an explicit env var; cloud HTTP requests now send a versioned User-Agent (PR #390) and refuse to mix indexes built with different providers.
### Documentation
- **MCP tools documentation** (PR #306): catalog of all MCP tools with usage examples.
- **venv usage guide** (PR #307).
- **Windows setup guide** for Claude Code MCP integration.
- **pipx / PyPI failure troubleshooting** with a `diagnose_pypi_connectivity.py` diagnostic script.
- **MseeP.ai badge** added to README (PR #399).
### Maintenance
- **Beads (`bd`) issue tracking** initialized for the project (`bd prime` for workflow context).
- **iCloud sync duplicate files** removed from the working tree.
- **Working spec docs** moved out of git (already in `.gitignore`).
- **CI lint and test failures** swept across multiple merged PRs.
### Upgrade notes
- `uvx --reinstall code-review-graph` or `pip install -U code-review-graph`.
- Re-run `code-review-graph install` once after upgrading to pick up the JSONC-tolerant config writer and the corrected `cwd` / skills path in `.mcp.json`.
- The `__version__` fix changes the User-Agent string emitted by cloud embedding providers from `code-review-graph/2.1.0` to `code-review-graph/2.3.3`. Anyone allow-listing the old User-Agent on a proxy needs to update their rule.
- VS Code extension still ships separately — repackage and republish the `.vsix` if you want the v2.3.3 a11y improvements in the Marketplace build.
## [2.3.2] - 2026-04-14
Major feature release — 15 new capabilities, 6 community PRs merged, 6 new MCP tools, 4 new languages, multi-format export, and graph analysis suite.
### Added
- **Hub node detection** (`get_hub_nodes_tool`): find the most-connected nodes in the codebase (architectural hotspots) by in+out degree, excluding File nodes.
- **Bridge node detection** (`get_bridge_nodes_tool`): find architectural chokepoints via betweenness centrality with sampling approximation for graphs >5000 nodes.
- **Knowledge gap analysis** (`get_knowledge_gaps_tool`): identify structural weaknesses — isolated nodes, thin communities (<3 members), untested hotspots, and single-file communities.
- **Surprise scoring** (`get_surprising_connections_tool`): composite scoring for unexpected architectural coupling (cross-community, cross-language, peripheral-to-hub, cross-test-boundary).
- **Suggested questions** (`get_suggested_questions_tool`): auto-generate prioritized review questions from graph analysis (bridge nodes, untested hubs, surprising connections, thin communities).
- **BFS/DFS traversal** (`traverse_graph_tool`): free-form graph exploration from any node with configurable depth (1-6) and token budget.
- **Edge confidence scoring**: three-tier system (EXTRACTED/INFERRED/AMBIGUOUS) with float confidence scores on all edges. Schema migration v9.
- **Export formats**: GraphML (Gephi/yEd/Cytoscape), Neo4j Cypher statements, Obsidian vault (wikilinks + YAML frontmatter + community pages), SVG static graph. CLI: `visualize --format graphml|cypher|obsidian|svg`.
- **Graph diff**: snapshot/compare graph state over time — new/removed nodes, edges, community membership changes.
- **Token reduction benchmark**: measure naive full-corpus tokens vs graph query tokens with per-question reduction ratios.
- **Memory/feedback loop**: persist Q&A results as markdown for re-ingestion via `save_result` / `list_memories` / `clear_memories`.
- **Oversized community auto-splitting**: communities exceeding 25% of graph are recursively split via Leiden algorithm.
- **4 new languages**: Zig, PowerShell, Julia, Svelte SFC (23 total).
- **Visualization enhancements**: node size scaled by degree, community legend with toggle visibility, improved interactivity.
- **README translations**: Simplified Chinese, Japanese, Korean, Hindi.
### Merged community PRs
- **#127** (xtfer): SQLite compound edge indexes for query performance.
- **#184** (realkotob): batch `_compute_summaries` — fixes build hangs on large repos.
- **#202** (lngyeen): Swift extension detection, inheritance edges, type kind metadata.
- **#249** (gzenz): community detection resolution scaling (21x speedup), expanded framework patterns, framework-aware dead code detection (56 new tests).
- **#253** (cwoolum): automatic graph build for new worktrees in Claude Code.
- **#267** (jindalarpit): Kiro platform support with 9 tests.
### Changed
- MCP tool count: 22 → 28.
- Schema version: 8 → 9 (edge confidence columns).
- Community detection uses resolution scaling for large graphs.
- Risk scoring uses weighted flow criticality and graduated test coverage.
- Dead code detection is framework-aware (ORM models, Pydantic, CDK constructs filtered).
- Flow entry points expanded with 30+ framework decorator patterns.
## [2.3.1] - 2026-04-11
Hotfix for the Windows long-running-MCP-tool hang that v2.2.4 only partially fixed.
### Fixed
- **Windows MCP hang on long-running tools** (PR #231, fixes #46, #136): follow-up to v2.2.4. [@dev-limucc reported on #136](https://github.com/tirth8205/code-review-graph/issues/136) that the `WindowsSelectorEventLoopPolicy` fix from v2.2.4 was necessary but not sufficient — read-only tools worked, but `build_or_update_graph_tool(full_rebuild=True)` and `embed_graph_tool` still hung indefinitely on Windows 11 / Python 3.14. Root cause: FastMCP 2.x dispatches sync handlers inline on the only event-loop thread, so handlers that run for more than a few seconds (especially those that spawn subprocesses or do CPU-bound inference) stop the loop from pumping stdin/stdout. **Fix**: converted the five heavy tools (`build_or_update_graph_tool`, `run_postprocess_tool`, `embed_graph_tool`, `detect_changes_tool`, `generate_wiki_tool`) to `async def` and offloaded the blocking work via `asyncio.to_thread`. The other 19 tools are fast SQLite-read paths and stay sync. Zero config, works on every platform. New regression tests assert the five tools are registered as coroutines AND that each one's source literally contains `asyncio.to_thread` as a defense-in-depth lock-in.
## [2.3.0] - 2026-04-11
Additive feature release — new language parsers, new platform install target, MCP tool UX improvements, and out-of-tree graph storage. No breaking changes from v2.2.4.
### Added
- **Elixir parser** (PR #228, closes #112): `.ex` and `.exs` files now produce modules as Class nodes, `def`/`defp`/`defmacro`/`defmacrop` as Function/Test nodes attached to their enclosing module, `alias`/`import`/`require`/`use` as `IMPORTS_FROM` edges, and everything else as `CALLS` edges. Internal call resolution walks into `do_block` bodies so `MathHelpers.double` correctly resolves its call to `Calculator.compute`.
- **Objective-C parser** (PR #227, closes #88): `.m` files parse classes (`@interface`, `@implementation`, `@protocol`), instance and class methods, `[receiver message:args]` message expressions, C-style `main()`, and `#import`/`#include`. Multi-part selectors like `add:to:` keep `add` as the canonical method name.
- **Bash/Shell parser** (PR #227, closes #197): `.sh`, `.bash`, and `.zsh` files parse functions, `command` invocations as `CALLS`, and `source path` / `. path` as `IMPORTS_FROM` edges with path resolution when the target file exists.
- **Qwen Code as a supported MCP install platform** (PR #227, closes #83): `code-review-graph install --platform qwen` writes a merged `~/.qwen/settings.json` using the same `mcpServers` schema as Cursor/Windsurf — it does not clobber existing Qwen config.
- **`apply_refactor_tool` dry-run mode** (PR #228, closes #176): new `dry_run: bool = False` parameter on the MCP tool and underlying `apply_refactor()` function. When true, returns a unified diff per file without touching disk and leaves the `refactor_id` valid for a follow-up real apply. Multi-edit files now apply sequentially against updated content in both modes (fixes a subtle bug where separate edits on the same file could stomp each other).
- **`CRG_DATA_DIR` environment variable** (PR #228, closes #155): when set, replaces the default `<repo>/.code-review-graph` directory verbatim. Useful for ephemeral workspaces, Docker volumes, shared CI caches, and multi-repo orchestrators. Supported by the CLI, MCP tools, and the registry.
- **`CRG_REPO_ROOT` environment variable** (PR #228, closes #155): `find_project_root()` now checks `CRG_REPO_ROOT` before the usual git-root walk — useful for anyone scripting the CLI from a cwd outside the target repo.
- **`install --no-instructions` and `-y`/`--yes` flags** (PR #228, closes #173): new flags on `code-review-graph install` to opt out of the `CLAUDE.md`/`AGENTS.md`/`.cursorrules`/`.windsurfrules` injection entirely (`--no-instructions`) or auto-confirm it without an interactive prompt (`-y`/`--yes`). The CLI also now prints the list of files it will touch before writing, so even without `--dry-run` users see what's coming.
- **Cloud embeddings stderr warning** (PR #228, closes #174): `get_provider()` now prints an explicit warning to stderr before returning a Google Gemini or MiniMax provider, explaining that source code will be sent to an external API. `CRG_ACCEPT_CLOUD_EMBEDDINGS=1` suppresses the warning for scripted workflows. The warning is on stderr only — it never writes to stdout or reads from stdin, so the MCP stdio transport remains uncorrupted.
- **TROUBLESHOOTING quick-reference** (PR #228): new top section in `docs/TROUBLESHOOTING.md` covering the four most common support questions — hook schema errors, `command not found` after pip install, project-vs-user scoping, and "built the graph but Claude Code doesn't see it".
### Fixed
- **Multi-edit refactor correctness** (PR #228): when a single `apply_refactor` call had multiple edits targeting the same file, the previous implementation re-read the file once per edit and could silently stomp earlier changes. The plan-computation step now groups edits by file and applies them sequentially against the updated content; this fix applies to both the real-write and the new dry-run path.
### Changed
- `install` and `init` commands now preview instruction-file targets before writing (no-op if nothing would change). This is always-on and does not require `--dry-run`.
- Default embedding path remains fully local (`sentence-transformers`); no behavior change unless you explicitly opt in to a cloud provider.
### Deprecated
Nothing.
### Security
- The cloud-embedding stderr warning (#174) is a privacy improvement; it does not change the behavior of offline local embeddings, which remain the default.
### Upgrade notes
- Nothing to do beyond `uvx --reinstall code-review-graph` or `pip install -U code-review-graph`. If you're coming from v2.2.2 or earlier, re-run `code-review-graph install` once to pick up the v2.2.3 hook schema rewrite.
- `CRG_DATA_DIR` is optional — if you don't set it, graphs continue to live at `<repo>/.code-review-graph` as before.
- VS Code extension v0.2.2 (from v2.2.4) still needs to be **repackaged and republished** separately; the PyPI `publish.yml` workflow does not cover it.
### Superseded PRs
- PR #204 (install preview, @lngyeen) — reimplemented cleanly in #228 with `isatty()`-guarded confirmation.
- PR #207 (`CRG_DATA_DIR`/`CRG_REPO_ROOT`, @yashmewada9618) — reimplemented cleanly in #228 without `input()`-on-stdio and `mcp._local_only` fragility.
- PR #179 (cloud embeddings warning, @Bakul2006) — reimplemented cleanly in #228 with stderr-only messaging and no stdio reads.
Credit to @lngyeen, @yashmewada9618, and @Bakul2006 for the original designs.
## [2.2.4] - 2026-04-11
Ships the 11 bugs from PR #222 plus the `v2.2.3.1` smoke-test hotfixes, for users upgrading directly from `v2.2.3` or earlier.
### Security
- **fastmcp bumped from 1.0 → ≥2.14.0** (PR #222, fixes #139, #195): closes CVE-2025-62800 (XSS), CVE-2025-62801 (command injection via server_name), CVE-2025-66416 (Confused Deputy). Transitively drops the `docket → fakeredis` chain that was broken by a `FakeConnection` → `FakeRedisConnection` rename in recent fakeredis releases (#195). The FastMCP public API (`FastMCP(name, instructions=...)`, `@mcp.tool()`, `@mcp.prompt()`, `mcp.run(transport="stdio")`) is unchanged across the 1 → 2 bump, so no source changes were needed beyond the pin. All 24 tools verified to register on fastmcp 2.14.6 and round-trip real per-repo data via stdio MCP in a 6-repo smoke test.
### Fixed
- **Windows build/embed hangs** (PR #222, fixes #46, #136): `main()` now sets `WindowsSelectorEventLoopPolicy` before `mcp.run()` on `sys.platform == "win32"`. The default `ProactorEventLoop` on Windows Python 3.8+ deadlocks with `ProcessPoolExecutor` (used by `full_build`) over a stdio MCP transport — producing the silent "Synthesizing…" hangs on `build` and `embed_graph_tool`. This is a no-op on macOS/Linux. **Note**: the fix was applied blind; maintainer could not verify on Windows. Please open a fresh issue if you still see a hang on v2.2.4 Windows with either `sentence-transformers` or Gemini providers.
- **Go method receivers** (PR #222, fixes #190): `func (s *T) Foo()` now attaches `Foo` to `T` as a member (`parent_name="T"`) with the usual `CONTAINS` edge instead of appearing as a top-level function. New `_get_go_receiver_type()` helper walks the method_declaration's first parameter_list to extract the receiver type name.
- **Dart parser — three bugs** (PR #222, fixes #87):
- Dart `CALLS` edges (`_extract_dart_calls_from_children()`) — tree-sitter-dart doesn't wrap calls in a single `call_expression` node; the pattern is `identifier + selector > argument_part`. New walker handles both direct (`print('x')`) and method-chained (`obj.foo()`) shapes.
- Dart `package:` URI resolution in `_do_resolve_module()` — `package:<pkgname>/<sub_path>` now walks up to a `pubspec.yaml` whose `name:` declaration matches `<pkgname>` and resolves to `<root>/lib/<sub_path>`.
- `inheritors_of` bare-vs-qualified name mismatch in `tools/query.py` — falls back to `search_edges_by_target_name(node.name, kind=...)` for `INHERITS`/`IMPLEMENTS` when the qualified-name lookup returns nothing. Affects all languages (INHERITS targets are stored as bare strings for every language), not just Dart.
- **Nested `node_modules` and framework ignore defaults** (PR #222, fixes #91): `_should_ignore()` now treats single-segment `<dir>/**` patterns as "this directory at any depth", so `node_modules/**` also matches `packages/app/node_modules/react/index.js` inside monorepos. Extended `DEFAULT_IGNORE_PATTERNS` with Laravel/Composer (`vendor/**`, `bootstrap/cache/**`, `public/build/**`), Ruby (`.bundle/**`), Gradle (`.gradle/**`, `*.jar`), Flutter/Dart (`.dart_tool/**`, `.pub-cache/**`), and generic `coverage/**`, `.cache/**`. Deliberately did **not** add `packages/**` or `bin/**`/`obj/**` — those are false positives in yarn/pnpm workspace monorepos and .NET source trees respectively.
- **Bare `except Exception` cleanup** (PR #222, fixes #194): Replaced with specific exception classes + `logger.debug(...)` in 11 files (`cli.py`, `graph.py`, `migrations.py`, `parser.py`, `registry.py`, `tools/context.py`, `tsconfig_resolver.py`, `visualization.py`, `wiki.py`, `eval/benchmarks/search_quality.py`). No behavioral change; debuggability improvement.
- **Visualization auto-collapse hiding all edges** (PR #222, fixes #132): `visualization.py` no longer unconditionally auto-collapses every File node on page load. Auto-collapse now only kicks in above 2000 nodes — previously any graph above ~300 nodes would silently hide every CALLS/IMPORTS/INHERITS edge because they connect Functions/Classes nested inside the collapsed Files.
- **`eval` command crashes on `yaml.safe_load`** (PR #222, fixes #212): `eval.runner.load_all_configs()` now calls `_require_yaml()` before reading YAML, so users without `code-review-graph[eval]` installed get `ImportError: pyyaml is required: pip install code-review-graph[eval]` instead of `AttributeError: 'NoneType' object has no attribute 'safe_load'`.
### VS Code extension (0.2.2)
- **`better-sqlite3` bumped 11.x → 12.x** (PR #222, fixes #218): VS Code 1.115 ships Electron 39 / V8 14.2 which removed `v8::Context::GetIsolate()`, the C++ API used by `better-sqlite3@11`. The extension couldn't activate at all — every command was undefined. `better-sqlite3@12.4.1+` (installs 12.8.0) uses the new V8 API and ships Electron 39 prebuilds. `@types/better-sqlite3: ^7.6.8 → ^7.6.13`, plus type-import adjustments in `src/backend/sqlite.ts` for the `Node16` module resolution and the new CJS `export =` types. Extension version bumped to 0.2.2. **Remember to repackage and republish the `.vsix`** — the existing `publish.yml` workflow only covers PyPI.
### Carried forward from 2.2.3.1
- `serve --repo <X>` is now honored by all 24 MCP tools (was only read by `get_docs_section_tool`). See #223.
- Wiki slug collisions no longer silently overwrite pages (~70% data loss on real repos). See #223.
### Upgrade notes
- `uvx --reinstall code-review-graph` or `pip install -U code-review-graph`, then re-run `code-review-graph install` (the 2.2.3 hook-schema rewrite is still a requirement if you're coming from 2.2.2 or earlier).
- VS Code extension needs to be repackaged + republished separately; the Python release does not include it.
## [2.2.3.1] - 2026-04-11
Hotfix on top of 2.2.3 for two bugs surfaced by a full first-time-user smoke test against six real OSS repos (express, fastapi, flask, gin, httpx, next.js).
### Fixed
- **`serve --repo <X>` was ignored by 21 of 24 MCP tools** (PR #223): `main.py` captured the `--repo` CLI flag into `_default_repo_root`, but only `get_docs_section_tool` read it. The other 21 `@mcp.tool()` wrappers all took `repo_root: Optional[str] = None` and passed that straight through to the impl, which fell back to `find_repo_root()` from cwd. The real-world blast radius is small — the `install` command writes `.mcp.json` without a `--repo` flag and Claude Code launches the server with `cwd=<repo>` — but anyone scripting `serve` manually or running a multi-repo orchestrator would silently get the wrong graph. Added a single `_resolve_repo_root()` helper with explicit precedence (client arg > `--repo` flag > `None → cwd`) and threaded it through all 24 wrappers. New unit tests cover the precedence rules.
- **Wiki slug collisions silently overwrote pages** (PR #223): `_slugify()` folds non-alphanumerics to dashes and truncates to 80 chars, so similar community names collided (`"Data Processing"`, `"data processing"`, `"Data Processing"` all → `data-processing.md`). `generate_wiki()` wrote each community to `<slug>.md` regardless, so later iterations overwrote earlier files while the counter reported them as "updated". On the express smoke test this was **~70% silent data loss** (32 real files vs 107 claimed pages). Fixed by tracking used slugs per-run and appending `-2`, `-3`, … until unique. Every community now gets its own page; the counter matches the physical file count; `get_wiki_page()` still resolves by name via the existing partial-match fallback. New regression test monkey-patches three colliding names and asserts no content loss.
## [2.2.3] - 2026-04-11
### Fixed
- **Claude Code hook schema** (PR #208, fixes #97, #138, #163, #168, #172, #182, #188, #191, #201): `generate_hooks_config()` now emits the valid v1.x+ Claude Code schema — every hook entry has `matcher` + a nested `hooks: [{type, command, timeout}]` array, and timeouts are in seconds. The invalid `PreCommit` event has been removed; pre-commit checks are now installed as a real git hook via `install_git_hook()`. Users upgrading from 2.2.2 must re-run `code-review-graph install` to rewrite `.claude/settings.json`.
- **SQLite transaction nesting** (PR #205, fixes #110, #135, #181): `GraphStore.__init__` now connects with `isolation_level=None`, disabling Python's implicit transactions that were the root cause of `sqlite3.OperationalError: cannot start a transaction within a transaction` on `update`. `store_file_nodes_edges` adds a defensive `in_transaction` flush before `BEGIN IMMEDIATE`.
- **Go method receivers** (PR #166): `_extract_name_from_node` now resolves Go method names from `field_identifier` inside `method_declaration`, fixing method names that were previously picked up as the result type (e.g. `int64`) instead of the method name.
- **UTF-8 decode errors in `detect_changes`** (PR #170, fixes #169): Diff parsing now uses `errors="replace"` so diffs containing binary files no longer crash the tool.
- **`--platform` target scope** (PR #142, fixes #133): `code-review-graph install --platform <target>` now correctly filters skills, hooks, and instruction files so you only get configuration for the requested platform.
- **Large-repo community detection hangs** (PR #213, PR #183): Removed recursive sub-community splitting, capped Leiden at `n_iterations=2`, and batched `store_communities` writes. 100k+ node graphs no longer hang in `_compute_summaries`.
- **CI**: ruff lint + `tomllib` on Python 3.10 (PR #220) — `tests/test_skills.py` now uses a conditional `tomli` backport on 3.10, `N806`/`E501`/`W291` fixes in `skills.py`/`communities.py`/`parser.py`, and the embedded `noqa` reference in `visualization.py` was rephrased so ruff stops parsing it as a directive.
- **Missing dev dependencies** (PR #159): `pytest-cov` added to dev extras, 50 ruff errors swept, one failing test fixed.
- **JSX component CALLS edges** (PR #154): JSX component usage now produces CALLS edges so component-to-component relationships appear in the graph.
### Added
- **Codex platform install support** (PR #177): `code-review-graph install --platform codex` appends a `mcp_servers.code-review-graph` section to `~/.codex/config.toml` without overwriting existing Codex settings.
- **Luau language support** (PR #165, closes #153): Roblox Luau (`.luau`) parsing — functions, classes, local functions, requires, tests.
- **REFERENCES edge type** (PR #217): New edge kind for symbol references that aren't direct calls (map/dispatch lookups, string-keyed handlers), including Python and TypeScript patterns.
- **`recurse_submodules` build option** (PR #215): Build/update can now optionally recurse into git submodules.
- **`.gitignore` default for `.code-review-graph/`** (PR #185): Fresh installs automatically add the SQLite DB directory to `.gitignore` so the database isn't accidentally committed.
- **Clearer gitignore docs** (PR #171, closes #157): Documentation now spells out that `code-review-graph` already respects `.gitignore` via `git ls-files`.
### Changed
- Community detection is now bounded — large repos complete in reasonable time instead of hanging indefinitely.
### Fixed
- **`install_hooks` now merges instead of overwriting** (PR #203, fixes #114): `install_hooks()` previously used `dict.update()` which clobbered any user-defined hooks in `.claude/settings.json`. Now merges new entries into existing hook arrays, preserving user hooks. Creates a backup (`settings.json.bak`) before modification.
## [2.2.2] - 2026-04-08
### Added
- **Kotlin call extraction**: `simple_identifier` + `navigation_expression` support for Kotlin method calls (PR #107)
- **JUnit/Kotlin test detection**: Annotation-based test classification (`@Test`, `@ParameterizedTest`, etc.) for Java/Kotlin/C# (PR #107)
### Fixed
- **Windows encoding crash**: All `write_text`/`read_text` calls in `skills.py` now use `encoding='utf-8'` explicitly (PR #152, fixes #147, #148)
- **Invalid `--quiet` flag in hooks**: Removed non-existent `--quiet` and `--json` flags from generated hook commands (PR #152, fixes #149)
### Housekeeping
- Untracked `.claude-plugin/` directory and added to `.gitignore`
- GitHub issue triage: responded to 30+ issues, closed 14, reviewed 24 PRs
## [2.2.1] - 2026-04-07
### Added
- **Parallel parsing**: `ProcessPoolExecutor` for 3-5x faster builds (`CRG_PARSE_WORKERS`, `CRG_SERIAL_PARSE`)
- **Lazy post-processing**: `postprocess="full"|"minimal"|"none"` parameter, `run_postprocess` MCP tool + CLI command
- **SQLite-native BFS**: Recursive CTE replaces NetworkX for impact analysis (`CRG_BFS_ENGINE`)
- **Configurable limits**: `CRG_MAX_IMPACT_NODES`, `CRG_MAX_IMPACT_DEPTH`, `CRG_MAX_BFS_DEPTH`, `CRG_MAX_SEARCH_RESULTS`
- **Multi-hop dependents**: N-hop `find_dependents()` with `CRG_DEPENDENT_HOPS` (default 2) and 500-file cap
- **Token-efficient output**: `detail_level="minimal"` on 8 tools for 40-60% token reduction
- **`get_minimal_context` tool**: Ultra-compact entry point (~100 tokens) with task-based tool routing
- **Token-efficient prompts**: All 5 MCP prompts rewritten with minimal-first workflows
- **Incremental flow/community updates**: `incremental_trace_flows()`, `incremental_detect_communities()`
- **Visualization aggregation**: Community/file/auto modes with drill-down for large graphs (`--mode`)
- **Token-efficiency benchmarks**: 5 workflow benchmarks in `eval/token_benchmark.py`
- **DB schema v6**: Pre-computed `community_summaries`, `flow_snapshots`, `risk_index` tables
- **Token Efficiency Rules** in all skill templates and CLAUDE.md
### Changed
- CLI `build`/`update` support `--skip-flows`, `--skip-postprocess` flags
- PostToolUse hook uses `--skip-flows` for faster incremental updates
- VS Code extension schema version bumped to v6
### Fixed
- mypy type errors in parallel parsing and context tool
- Bandit false positive on prompt preamble string
- Import sorting in graph.py, main.py, tools/__init__.py
- Unused imports cleaned up in cli.py
### Housekeeping
- Gitignore: untrack `marketing-diagram.excalidraw`, `evaluate/results/`, `evaluate/reports/`
- Updated FEATURES.md, LLM-OPTIMIZED-REFERENCE.md, CHANGELOG.md for v2.2.1
## [2.1.0] - 2026-04-03
### Added
- **Jupyter notebook parsing**: Parse `.ipynb` files — extract functions, classes, imports across Python, R, and SQL cells
- **Databricks notebook parsing**: Parse Databricks `.py` notebook exports with `# COMMAND ----------` cell boundaries
- **Lua language support**: Full parsing for `.lua` files (functions, local functions, method calls, requires) — 20th language
- **Perl XS support**: Parse `.xs` files with improved Perl call detection and test coverage
- **Zero-config onboarding**: `install` now sets up skills, hooks, and CLAUDE.md by default so the graph is used automatically
- **Platform rule injection**: Graph instructions injected into all platform rule files (CLAUDE.md, .cursorrules, etc.) on install
- **Smart install detection**: Auto-detects whether installed via uvx or pip and generates correct `.mcp.json`
- **`--platform claude-code` alias**: Accepts both `claude` and `claude-code` as platform names
### Fixed
- **JS/TS arrow functions indexed**: `const foo = () => {}` and `const bar = function() {}` now correctly appear as nodes (#66)
- **`importers_of` path resolution**: Normalized with `resolve()` to match stored edge targets (#65)
- **Custom embedding models**: Support for custom model architectures and restored model param wiring in search (#79)
## [2.0.0] - 2026-03-27
### Added
- **12 new features**: flows, communities, hybrid search, change analysis, refactoring, hints, prompts, skills, wiki, multi-repo registry, migrations, eval framework
- **14 new modules** (~10,000 lines): `flows.py`, `communities.py`, `search.py`, `changes.py`, `refactor.py`, `hints.py`, `prompts.py`, `skills.py`, `wiki.py`, `registry.py`, `migrations.py`, `eval/`
- **15 new MCP tools**: `list_flows`, `get_flow`, `get_affected_flows`, `list_communities`, `get_community`, `get_architecture_overview`, `detect_changes`, `refactor`, `apply_refactor`, `generate_wiki`, `get_wiki_page`, `list_repos`, `cross_repo_search`, `find_large_functions`, `semantic_search_nodes`
- **5 MCP prompts**: `review_changes`, `architecture_map`, `debug_issue`, `onboard_developer`, `pre_merge_check`
- **7 new CLI commands**: `detect-changes`, `wiki`, `eval`, `register`, `unregister`, `repos`, `install --skills/--hooks/--all`
- **Interactive visualization upgrade**: Detail panel, community coloring, flow path highlighting, search-to-zoom, kind filters
### Security
- Fix path traversal in wiki page reader
- Add regex allowlist for git ref validation
- Add explicit SSL context for MiniMax API
### Fixed
- Fix git diff argument ordering (broke incremental updates)
- Fix `node_qualified_name` schema mismatch in wiki flow query
- Batch N+1 queries in `get_impact_radius` and risk scoring
### Architecture
- Decompose `_extract_from_tree` into 6 focused methods
- Add 17 public query methods to `GraphStore`
- Split `tools.py` into 10 themed sub-modules
## [1.8.4] - 2026-03-20
### Added
- **Vue SFC parsing**: Parse `.vue` Single File Components by extracting `<script>` blocks with automatic `lang="ts"` detection
- **Solidity support**: Full parsing for `.sol` files (functions, events, modifiers, inheritance)
- **`find_large_functions_tool`**: New MCP tool to find functions, classes, or files exceeding a line-count threshold
- **Call target resolution**: Bare call targets resolved to qualified names using same-file definitions (`_resolve_call_targets`)
- **Multi-word AND search**: `search_nodes` now requires all words to match (case-insensitive)
- **Impact radius pagination**: `get_impact_radius` returns `truncated` flag, `total_impacted` count, and accepts `max_results` parameter
### Changed
- Language count updated from 12 to 14 across all documentation
- MCP tool count updated from 8 to 9 across all documentation
- VS Code extension updated to v0.2.0 with 5 new commands documented
### Fixed
- Test assertions updated to handle qualified call targets from `_resolve_call_targets`
## [1.8.3] - 2026-03-20
### Fixed
- **Parser recursion guard**: Added `_MAX_AST_DEPTH = 180` limit to `_extract_from_tree()` preventing stack overflow on deeply nested ASTs
- **Module cache bound**: Added `_MODULE_CACHE_MAX = 15_000` with automatic eviction to prevent unbounded memory growth in `_module_file_cache`
- **Embeddings thread safety**: Added `check_same_thread=False` to `EmbeddingStore` SQLite connection
- **Embeddings retry logic**: Added `_call_with_retry()` with exponential backoff for Google Gemini API calls
- **Visualization XSS hardening**: Added `</` to `<\/` replacement in JSON serialization to prevent script injection
- **CLI error handling**: Split broad `except` into specific `json.JSONDecodeError` and `(KeyError, TypeError)` handlers
- **Git timeout**: Made configurable via `CRG_GIT_TIMEOUT` environment variable (default 30s)
### Added
- **Governance files**: Added CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md
- **Project URLs**: Added Homepage, Repository, Issues, Changelog URLs to pyproject.toml metadata
## [1.8.2] - 2026-03-17
### Fixed
- **C# parsing broken**: Renamed language identifier from `c_sharp` to `csharp` to match `tree-sitter-language-pack`'s actual identifier. Previously, all C# files were silently skipped because `_get_parser()` swallowed the `LookupError`.
## [1.8.1] - 2026-03-17
### Fixed
- Add missing `max_nodes` parameter to `get_impact_radius` method signature (caused `NameError` at runtime)
- Fix `.gitignore` test assertion to match expanded comment format
## [1.8.0] - 2026-03-17
### Security
- **Prompt injection mitigation**: Node names are now sanitized (control characters stripped, length capped at 256) before appearing in MCP tool responses, preventing graph-laundered prompt injection attacks
- **Path traversal protection**: `repo_root` parameter now validates that the target directory contains a `.git` or `.code-review-graph` directory, preventing arbitrary file exfiltration via MCP tools
- **VSCode RCE fix**: `cliPath` setting is now scoped to `machine` level only, preventing malicious workspace settings from pointing to attacker-controlled binaries
- **XSS fix in visualization**: `escH()` now escapes quotes and backticks in addition to angle brackets, closing stored XSS via crafted node names in generated HTML
- **SRI for CDN assets**: D3.js script tag now includes `integrity` and `crossorigin` attributes to prevent CDN compromise
- **Secure nonce generation**: VSCode webview CSP nonces now use `crypto.randomBytes()` instead of `Math.random()`
- **Symlink protection**: Build, watch mode, and file collection now skip symbolic links to prevent parsing files outside the repository
- **TOCTOU elimination**: File bytes are now read once, then hashed and parsed from the same buffer, closing the time-of-check-to-time-of-use gap
### Fixed
- **Thread-safe NetworkX cache**: Added `threading.Lock` around graph cache reads/writes to prevent race conditions between watch mode and MCP request handling
- **BFS resource limits**: Impact radius traversal now caps at 500 nodes to prevent memory exhaustion on dense graphs
- **SQL parameter batching**: `get_edges_among` now batches queries to stay under SQLite's variable limit on large node sets
- **Database path leakage**: Improved `.gitignore` inside `.code-review-graph/` with explicit warnings about absolute paths in the database
### Changed
- **Pinned dependency bounds**: All dependencies now have upper-bound version constraints to mitigate supply-chain risks
## [1.7.2] - 2026-03-09
### Fixed
- **Watch mode thread safety**: SQLite connections now use `check_same_thread=False` for Python 3.10/3.11 compatibility with watchdog's background threads
- **Full rebuild stale data**: `full_build` now purges nodes/edges from files deleted since last build
- **Removed unused dependency**: `gitpython` was listed in dependencies but never imported — removed to shrink install footprint
- **Stale Docker reference**: Removed non-existent Docker image suggestion from Python version check
## [1.7.0] - 2026-03-09
### Added
- **`install` command** — primary entry point for new users (`code-review-graph install`). `init` remains as an alias for backwards compatibility.
- **`--dry-run` flag** on `install`/`init` — shows what would be written without modifying files
- **PyPI publish workflow** — GitHub releases now automatically publish to PyPI via API token
- **Professional README** — complete rewrite with real benchmark data:
- Code reviews: 6.8x average token reduction (tested on httpx, FastAPI, Next.js)
- Live coding tasks: 14.1x average, up to 49.1x on large repos
### Changed
- README restructured around the install-and-forget user experience
- CLI banner now shows `install` as the primary command
## [1.6.4] - 2026-03-06
### Changed
- **Portable MCP config**: `init` now generates `uvx`-based `.mcp.json` instead of absolute Python paths — works on any machine with `uv` installed
- Removed `_safe_path` symlink workaround (no longer needed with `uvx`)
## [1.6.3] - 2026-03-06
### Added
- **SessionStart hook** — Claude Code now automatically prefers graph MCP tools over full codebase scans at the start of every session, saving tokens on general queries
- `homepage` and `author.url` fields in plugin.json for marketplace discoverability
### Fixed
- plugin.json schema: renamed `tags` to `keywords`, removed invalid `skills` path (auto-discovered from default location)
- Removed screenshot placeholder section from README
## [1.6.2] - 2026-02-27
### Fixed
- **Critical**: Incremental hash comparison bug — `file_hash` read from wrong field, causing every file to re-parse
- Watch mode `on_deleted` handler now filters by ignore patterns
- Removed dead code in `full_build` and duplicate `main()` in `incremental.py`
- `get_staged_and_unstaged` handles git renamed files (`R old -> new`)
- TROUBLESHOOTING.md hook config path corrected
### Added
- **Parser: C/C++ support** — full node extraction (structs, classes, functions, includes, calls, inheritance)
- **Parser: name extraction** fixes for Kotlin/Swift (`simple_identifier`), Ruby (`constant`), C/C++ nested `function_declarator`
- `GraphStore` context manager (`__enter__`/`__exit__`)
- `get_all_edges()` and `get_edges_among()` public methods on `GraphStore`
- NetworkX graph caching with automatic invalidation on writes
- Subprocess timeout (30s) on all git calls
- Progress logging every 50 files in full build
- SHA-256 hashing in embeddings (replaced MD5)
- Chunked embedding search (`fetchmany(500)`)
- Batch edge collection in `get_impact_radius` (single SQL query)
- ARIA labels throughout D3.js visualization
- **CI**: Coverage enforcement (`--cov-fail-under=50`), bandit security scanning, mypy type checking
- **Tests**: `test_incremental.py` (24 tests), `test_embeddings.py` (16 tests)
- **Test fixtures**: C, C++, C#, Ruby, PHP, Kotlin, Swift with multilang test classes
- **Docs**: API response schemas in COMMANDS.md, ignore patterns in USAGE.md
## [1.5.3] - 2026-02-27
### Fixed
- `init` now auto-creates symlinks when paths contain spaces (macOS iCloud, OneDrive, etc.)
- `build`, `status`, `visualize`, `watch` work without a git repository (falls back to cwd)
- Skills discoverable via plugin.json (`name` field added to SKILL.md frontmatter)
## [1.5.0] - 2026-02-26
### Added
- **File organization**: All generated files now live in `.code-review-graph/` directory instead of repo root
- Auto-created `.gitignore` inside the directory prevents accidental commits
- Automatic migration from legacy `.code-review-graph.db` at repo root
- **Visualization: start collapsed**: Only File nodes visible on load; click to expand children
- **Visualization: search bar**: Filter nodes by name or qualified name in real-time
- **Visualization: edge type toggles**: Click legend items to show/hide edge types (Calls, Imports, Inherits, Contains)
- **Visualization: scale-aware layout**: Force simulation adapts charge, distance, and decay for large graphs (300+ nodes)
### Changed
- Database path: `.code-review-graph.db` → `.code-review-graph/graph.db`
- HTML visualization path: `.code-review-graph.html` → `.code-review-graph/graph.html`
- `.code-review-graph/**` added to default ignore patterns (prevents self-indexing)
### Removed
- `references/` directory (duplicate of `docs/`, caused stale path references)
- `agents/` directory (unused, not wired into any code)
- `settings.json` at repo root (decorative, not loaded by code)
## [1.4.0] - 2026-02-26
### Added
- `init` command: automatic `.mcp.json` setup for Claude Code integration
- `visualize` command: interactive D3.js force-directed graph visualization
- `serve` command: start MCP server directly from CLI
### Changed
- Comprehensive documentation overhaul across all reference files
## [1.3.0] - 2026-02-26
### Added
- Universal installation: now works with `pip install code-review-graph[embeddings]` on Python 3.10+
- CLI entry point (`code-review-graph` command works after normal pip install)
- Clear Python version check with helpful Docker fallback for older Python users
- Improved README installation section with one-command + Docker option
### Changed
- Minimum Python requirement lowered from 3.11 → 3.10 (covers ~90% of users)
### Fixed
- Installation friction for most developers
+206
View File
@@ -0,0 +1,206 @@
# CLAUDE.md - Project Context for Claude Code
## Project Overview
**code-review-graph** is a persistent, incrementally updated, local-first knowledge graph for token-efficient code review through MCP and the CLI. It parses codebases using Tree-sitter and targeted fallbacks, builds a structural graph in SQLite, and exposes compact context to AI coding tools including Claude Code, Codex, Cursor, Windsurf, Zed, Continue, OpenCode, Gemini CLI, Qwen, Kiro, Qoder, and GitHub Copilot.
## Graph Tool Usage (Token-Efficient)
When using code-review-graph MCP tools, follow these rules:
1. First call: `get_minimal_context(task="<description>")` — costs ~100 tokens, gives you the full picture.
2. All subsequent calls: use `detail_level="minimal"` unless you need more.
3. Prefer `query_graph_tool` with a specific target over broad `list_*` calls.
4. The `next_tool_suggestions` field in every response tells you the optimal next step.
5. Target: ≤5 tool calls per task, ≤800 total tokens of graph context.
## Architecture
- **Core Package**: `code_review_graph/` (Python 3.10+)
- `parser.py` — Tree-sitter multi-language AST parser plus targeted fallbacks for broad source-language and notebook support
- `custom_languages.py` — Config-driven custom language support (`.code-review-graph/languages.toml`, see docs/CUSTOM_LANGUAGES.md)
- `graph.py` — SQLite-backed graph store (nodes, edges, BFS impact analysis)
- `tools/` — 30 MCP tool implementations split by domain
- `main.py` — FastMCP server entry point, registers 30 tools + 5 prompts
- `incremental.py` — Git-based change detection, file watching
- `embeddings.py` — Optional vector embeddings (local sentence-transformers, OpenAI-compatible endpoints, Google Gemini, MiniMax)
- `visualization.py` — D3.js interactive HTML graph generator
- `cli.py` — CLI entry point (install/init, build, update, postprocess, embed, watch, status, visualize, serve/mcp, wiki, detect-changes, register, unregister, repos, eval, daemon)
- `flows.py` — Execution flow detection and criticality scoring
- `communities.py` — Community detection (Leiden algorithm or file-based grouping) and architecture overview
- `search.py` — FTS5 hybrid search (keyword + vector)
- `changes.py` — Risk-scored change impact analysis (detect-changes)
- `refactor.py` — Rename preview, dead code detection, refactoring suggestions
- `hints.py` — Review hint generation
- `prompts.py` — 5 MCP prompt templates (review_changes, architecture_map, debug_issue, onboard_developer, pre_merge_check)
- `wiki.py` — Markdown wiki generation from community structure
- `skills.py` — Multi-platform install/config generation and shipped skill metadata
- `registry.py` — Multi-repo registry helpers
- `migrations.py` — Database schema migrations (v1-v9)
- `tsconfig_resolver.py` — TypeScript path alias resolution
- **VS Code Extension**: `code-review-graph-vscode/` (TypeScript)
- Separate subproject with its own `package.json`, `tsconfig.json`
- Reads from `.code-review-graph/graph.db` via SQLite
- **Database**: `.code-review-graph/graph.db` (SQLite, WAL mode)
## Key Commands
```bash
# Development
uv run pytest tests/ --tb=short -q # Run tests
uv run ruff check code_review_graph/ # Lint
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
# Build & test
uv run code-review-graph build # Full graph build
uv run code-review-graph update # Incremental update
uv run code-review-graph status # Show stats
uv run code-review-graph serve # Start MCP server
uv run code-review-graph wiki # Generate markdown wiki
uv run code-review-graph detect-changes # Risk-scored change analysis
uv run code-review-graph register <path> # Register repo in multi-repo registry
uv run code-review-graph repos # List registered repos
uv run code-review-graph eval # Run evaluation benchmarks
```
## Code Conventions
- **Line length**: 100 chars (ruff)
- **Python target**: 3.10+
- **SQL**: Always use parameterized queries (`?` placeholders), never f-string values
- **Error handling**: Catch specific exceptions, log with `logger.warning/error`
- **Thread safety**: `threading.Lock` for shared caches, `check_same_thread=False` for SQLite
- **Node names**: Always sanitize via `_sanitize_name()` before returning to MCP clients
- **File reads**: Read bytes once, hash, then parse (TOCTOU-safe pattern)
## Security Invariants
- No `eval()`, `exec()`, `pickle`, or `yaml.unsafe_load()`
- No `shell=True` in subprocess calls
- `_validate_repo_root()` prevents path traversal via repo_root parameter
- `_sanitize_name()` strips control characters, caps at 256 chars (prompt injection defense)
- `escH()` in visualization escapes HTML entities including quotes and backticks
- SRI hash on D3.js CDN script tag
- API keys only from environment variables, never hardcoded
## Test Structure
- `tests/test_parser.py` — Parser correctness, cross-file resolution
- `tests/test_graph.py` — Graph CRUD, stats, impact radius
- `tests/test_tools.py` — MCP tool integration tests
- `tests/test_visualization.py` — Export, HTML generation, C++ resolution
- `tests/test_incremental.py` — Build, update, migration, git ops
- `tests/test_multilang.py` — Broad language parsing tests, including SFCs, notebooks, SQL, Perl XS, and modern systems/web languages
- `tests/test_custom_languages.py` — Config-driven custom languages (languages.toml loader + end-to-end Erlang parse)
- `tests/test_embeddings.py` — Vector encode/decode, similarity, store
- `tests/test_flows.py` — Execution flow detection and criticality
- `tests/test_communities.py` — Community detection, architecture overview
- `tests/test_changes.py` — Risk-scored change analysis
- `tests/test_refactor.py` — Rename preview, dead code, suggestions
- `tests/test_search.py` — FTS5 hybrid search
- `tests/test_hints.py` — Review hint generation
- `tests/test_prompts.py` — MCP prompt template tests
- `tests/test_wiki.py` — Wiki generation
- `tests/test_context_savings.py` — Estimated context-savings metadata
- `tests/test_skills.py` — Install/config generation and shipped skill metadata
- `tests/test_registry.py` — Multi-repo registry
- `tests/test_migrations.py` — Database migrations
- `tests/test_eval.py` — Evaluation framework
- `tests/test_tsconfig_resolver.py` — TypeScript path resolution
- `tests/test_integration_v2.py` — v2 pipeline integration test
- `tests/test_action_render.py` — GitHub Action PR comment renderer (`scripts/render_pr_comment.py`)
- `tests/fixtures/` — Sample files for each supported language
## CI Pipeline
- **lint**: ruff on Python 3.10
- **type-check**: mypy
- **security**: bandit scan
- **test**: pytest matrix (3.10, 3.11, 3.12, 3.13) with 65% coverage minimum
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
## Beads Issue Tracker
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
### Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work
bd close <id> # Complete work
```
### Rules
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
- Run `bd prime` for detailed command reference and session close protocol
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
## Session Completion
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd dolt push
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION -->
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context_tool` | Need source snippets for review — token-efficient |
| `get_impact_radius_tool` | Understanding blast radius of a change |
| `get_affected_flows_tool` | Finding which execution paths are impacted |
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes_tool` for code review.
3. Use `get_affected_flows_tool` to understand impact.
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
+16
View File
@@ -0,0 +1,16 @@
# Code of Conduct
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
All participants in this project are expected to follow the Code of Conduct.
Please report any concerns to the project maintainers.
## Scope
This Code of Conduct applies within all project spaces, including issues,
pull requests, discussions, and any other communication channels.
## Reporting
Instances of unacceptable behavior may be reported to the project maintainers.
All complaints will be reviewed and investigated promptly and fairly.
+91
View File
@@ -0,0 +1,91 @@
# Contributing to code-review-graph
Thank you for your interest in contributing! This guide will help you get started.
## Development Setup
```bash
# Clone the repository
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
# Install with dev dependencies (requires uv)
uv sync --extra dev
# Verify setup
uv run pytest tests/ --tb=short -q
```
## Running Tests
```bash
# All tests
uv run pytest tests/ --tb=short -q
# With coverage
uv run pytest --cov=code_review_graph --cov-report=term-missing --cov-fail-under=65
# Single test file
uv run pytest tests/test_parser.py -v
```
## Linting and Type Checking
```bash
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
```
## Code Style
- **Line length**: 100 characters
- **Target**: Python 3.10+
- **Linter**: ruff (rules: E, F, I, N, W)
- **SQL**: Always parameterized queries (`?` placeholders)
- **Imports**: Sorted by ruff (isort-compatible)
## Making Changes
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Make your changes
4. Add tests for new functionality
5. Ensure all tests pass: `uv run pytest`
6. Ensure linting passes: `uv run ruff check code_review_graph/`
7. Submit a pull request
## Project Structure
```
code_review_graph/ # Core Python package
parser.py # Tree-sitter multi-language parser
graph.py # SQLite graph store
tools/ # MCP tool implementations
context_savings.py # Compact estimated context-savings metadata
incremental.py # Git diff + file watch logic
embeddings.py # Vector embedding support
visualization.py # D3.js HTML generator
cli.py # CLI entry point
main.py # MCP server entry point
tests/ # Test suite
fixtures/ # Language sample files
```
## Adding Language Support
If you just need a language for your own repo, you may not need to contribute at all: drop a `.code-review-graph/languages.toml` into your project mapping extensions and node types to any grammar in tree-sitter-language-pack — see [docs/CUSTOM_LANGUAGES.md](docs/CUSTOM_LANGUAGES.md). To add built-in support upstream:
1. Add the extension mapping to `EXTENSION_TO_LANGUAGE` in `parser.py`
2. Add tree-sitter node types to `_CLASS_TYPES`, `_FUNCTION_TYPES`, `_IMPORT_TYPES`, `_CALL_TYPES`
3. Add a sample fixture file in `tests/fixtures/`
4. Add parsing tests in `tests/test_multilang.py`
## Reporting Issues
- Open an issue via the issue forms: https://github.com/tirth8205/code-review-graph/issues/new/choose (bug report, feature request, or platform request — blank issues are disabled)
- For questions and ideas, use GitHub Discussions instead: https://github.com/tirth8205/code-review-graph/discussions
- Include: Python version, OS, steps to reproduce, error output
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
+38
View File
@@ -0,0 +1,38 @@
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes_tool` or `query_graph_tool` instead of Grep
- **Understanding impact**: `get_impact_radius_tool` instead of manually tracing imports
- **Code review**: `detect_changes_tool` + `get_review_context_tool` instead of reading entire files
- **Finding relationships**: `query_graph_tool` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview_tool` + `list_communities_tool`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes_tool` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context_tool` | Need source snippets for review — token-efficient |
| `get_impact_radius_tool` | Understanding blast radius of a change |
| `get_affected_flows_tool` | Finding which execution paths are impacted |
| `query_graph_tool` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes_tool` | Finding functions/classes by name or keyword |
| `get_architecture_overview_tool` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes_tool` for code review.
3. Use `get_affected_flows_tool` to understand impact.
4. Use `query_graph_tool` pattern="tests_for" to check coverage.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tirth Kanani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+326
View File
@@ -0,0 +1,326 @@
<h1 align="center">code-review-graph</h1>
> **नोट:** यह अनुवाद एक पुराने रिलीज़ पर आधारित है; बेंचमार्क आंकड़े और प्लेटफ़ॉर्म सूचियाँ [अंग्रेज़ी README](README.md) से पीछे हो सकती हैं।
<p align="center">
<strong>टोकन बर्बाद करना बंद करें। स्मार्ट रिव्यू शुरू करें।</strong>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ja-JP.md">日本語</a> |
<a href="README.ko-KR.md">한국어</a> |
<a href="README.hi-IN.md">हिन्दी</a>
</p>
<p align="center">
<a href="https://pypi.org/project/code-review-graph/"><img src="https://img.shields.io/pypi/v/code-review-graph?style=flat-square&color=blue" alt="PyPI"></a>
<a href="https://pepy.tech/project/code-review-graph"><img src="https://img.shields.io/pepy/dt/code-review-graph?style=flat-square" alt="Downloads"></a>
<a href="https://github.com/tirth8205/code-review-graph/stargazers"><img src="https://img.shields.io/github/stars/tirth8205/code-review-graph?style=flat-square" alt="Stars"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="MIT Licence"></a>
<a href="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml"><img src="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg?style=flat-square" alt="Python 3.10+"></a>
<a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-compatible-green.svg?style=flat-square" alt="MCP"></a>
<a href="https://code-review-graph.com"><img src="https://img.shields.io/badge/website-code--review--graph.com-blue?style=flat-square" alt="Website"></a>
<a href="https://discord.gg/3p58KXqGFN"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<br>
AI कोडिंग टूल्स रिव्यू टास्क में आपके कोडबेस के बड़े हिस्से दोबारा पढ़ सकते हैं। `code-review-graph` इस समस्या को हल करता है। यह [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) से आपके कोड का स्ट्रक्चरल मैप बनाता है, बदलावों को इंक्रीमेंटली ट्रैक करता है, और [MCP](https://modelcontextprotocol.io/) के ज़रिए आपके AI असिस्टेंट को सटीक कॉन्टेक्स्ट देता है ताकि वह केवल ज़रूरी कोड ही पढ़े।
<p align="center">
<img src="diagrams/diagram1_before_vs_after.png" alt="टोकन समस्या: 6 वास्तविक रिपॉज़िटरीज़ में औसतन 8.2 गुना टोकन कमी" width="85%" />
</p>
---
## त्वरित शुरुआत
```bash
pip install code-review-graph # या: pipx install code-review-graph
code-review-graph install # सभी समर्थित प्लेटफ़ॉर्म को स्वचालित रूप से पहचानता और कॉन्फ़िगर करता है
code-review-graph build # अपना कोडबेस पार्स करें
```
एक कमांड सब कुछ सेट कर देता है। `install` पहचान लेता है कि आपके पास कौन से AI कोडिंग टूल हैं, प्रत्येक के लिए सही MCP कॉन्फ़िगरेशन लिखता है, और आपके प्लेटफ़ॉर्म रूल्स में ग्राफ-अवेयर निर्देश जोड़ता है। यह स्वचालित रूप से पहचानता है कि आपने `uvx` या `pip`/`pipx` से इंस्टॉल किया है और उसके अनुसार कॉन्फ़िग बनाता है। इंस्टॉल के बाद अपना एडिटर/टूल रीस्टार्ट करें।
<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="एक इंस्टॉल में समर्थित AI कोडिंग टूल्स को स्वचालित रूप से पहचानता और कॉन्फ़िगर करता है" width="85%" />
</p>
किसी विशेष प्लेटफ़ॉर्म को टार्गेट करने के लिए:
```bash
code-review-graph install --platform codex # केवल Codex कॉन्फ़िगर करें
code-review-graph install --platform cursor # केवल Cursor कॉन्फ़िगर करें
code-review-graph install --platform claude-code # केवल Claude Code कॉन्फ़िगर करें
code-review-graph install --platform kiro # केवल Kiro कॉन्फ़िगर करें
```
Python 3.10+ आवश्यक है। सबसे अच्छे अनुभव के लिए [uv](https://docs.astral.sh/uv/) इंस्टॉल करें (MCP कॉन्फ़िग उपलब्ध होने पर `uvx` का उपयोग करेगा, अन्यथा सीधे `code-review-graph` कमांड पर फ़ॉलबैक करेगा)।
फिर अपना प्रोजेक्ट खोलें और अपने AI असिस्टेंट से कहें:
```
Build the code review graph for this project
```
प्रारंभिक बिल्ड 500 फ़ाइलों के प्रोजेक्ट के लिए लगभग 10 सेकंड लेता है। उसके बाद, watch mode और समर्थित platform hooks से ग्राफ स्वचालित रूप से अपडेट हो सकता है।
---
## यह कैसे काम करता है
<p align="center">
<img src="diagrams/diagram7_mcp_integration_flow.png" alt="आपका AI असिस्टेंट ग्राफ का उपयोग कैसे करता है: यूज़र रिव्यू मांगता है, AI MCP टूल्स चेक करता है, ग्राफ ब्लास्ट रेडियस और रिस्क स्कोर लौटाता है, AI केवल ज़रूरी कोड पढ़ता है" width="80%" />
</p>
आपकी रिपॉज़िटरी को Tree-sitter से AST में पार्स किया जाता है, नोड्स (फ़ंक्शन, क्लासेज़, इम्पोर्ट्स) और एज़ेज़ (कॉल्स, इनहेरिटेंस, टेस्ट कवरेज) के ग्राफ के रूप में स्टोर किया जाता है, और फिर रिव्यू के समय क्वेरी करके उन फ़ाइलों का न्यूनतम सेट निकाला जाता है जो आपके AI असिस्टेंट को पढ़ने की ज़रूरत है।
<p align="center">
<img src="diagrams/diagram2_architecture_pipeline.png" alt="आर्किटेक्चर पाइपलाइन: रिपॉज़िटरी से Tree-sitter पार्सर, SQLite ग्राफ, ब्लास्ट रेडियस, न्यूनतम रिव्यू सेट" width="100%" />
</p>
### ब्लास्ट-रेडियस विश्लेषण
जब कोई फ़ाइल बदलती है, तो ग्राफ हर कॉलर, डिपेंडेंट, और टेस्ट को ट्रेस करता है जो प्रभावित हो सकता है। यह बदलाव का "ब्लास्ट रेडियस" है। आपका AI पूरे प्रोजेक्ट को स्कैन करने की बजाय केवल इन फ़ाइलों को पढ़ता है।
<p align="center">
<img src="diagrams/diagram3_blast_radius.png" alt="ब्लास्ट रेडियस विज़ुअलाइज़ेशन: login() में बदलाव कॉलर्स, डिपेंडेंट्स, और टेस्ट्स तक कैसे फैलता है" width="70%" />
</p>
### इंक्रीमेंटल अपडेट < 2 सेकंड में
हुक्स या watch mode सक्षम होने पर फ़ाइल सेव और समर्थित commit hooks incremental updates शुरू करते हैं। ग्राफ SHA-256 हैश चेक के ज़रिए बदली हुई फ़ाइलों और उनके डिपेंडेंट्स को ढूंढता है, और केवल बदले हुए कोड को री-पार्स करता है। 2,900 फ़ाइलों का प्रोजेक्ट 2 सेकंड से कम में री-इंडेक्स होता है।
<p align="center">
<img src="diagrams/diagram4_incremental_update.png" alt="इंक्रीमेंटल अपडेट फ़्लो: git कमिट ट्रिगर करता है, डिफ़ ढूंढता है, केवल 5 फ़ाइलें री-पार्स होती हैं जबकि 2,910 स्किप होती हैं" width="90%" />
</p>
### मोनोरिपो समस्या, हल
बड़े मोनोरिपो में टोकन बर्बादी सबसे ज़्यादा होती है। ग्राफ शोर को काटता है — 27,700+ फ़ाइलें रिव्यू कॉन्टेक्स्ट से बाहर, केवल ~15 फ़ाइलें वास्तव में पढ़ी गईं।
<p align="center">
<img src="diagrams/diagram6_monorepo_funnel.png" alt="Next.js मोनोरिपो: 27,732 फ़ाइलें code-review-graph से होकर ~15 फ़ाइलों तक — 49 गुना कम टोकन" width="80%" />
</p>
### व्यापक भाषा सपोर्ट + Jupyter नोटबुक
<p align="center">
<img src="diagrams/diagram9_language_coverage.png" alt="श्रेणी के अनुसार भाषा सपोर्ट: वेब, बैकेंड, सिस्टम्स, मोबाइल, स्क्रिप्टिंग, और Jupyter/Databricks नोटबुक सपोर्ट" width="90%" />
</p>
मौजूदा पार्सर जिन सतहों को सपोर्ट करता है, उनमें फ़ंक्शन, क्लासेज़, इम्पोर्ट्स, कॉल साइट्स, इनहेरिटेंस, और टेस्ट डिटेक्शन के लिए स्ट्रक्चरल एक्सट्रैक्शन मिलता है। जहाँ उपलब्ध हो वहाँ Tree-sitter इस्तेमाल होता है, और ज़रूरत पड़ने पर targeted fallback parsers इस्तेमाल होते हैं। सपोर्ट में Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, TypeScript parser से parse होने वाली Astro files, Jupyter/Databricks नोटबुक (`.ipynb`), और Perl XS फ़ाइलें (`.xs`) शामिल हैं।
---
## बेंचमार्क
<p align="center">
<img src="diagrams/diagram5_benchmark_board.png" alt="वास्तविक रिपॉज़ में बेंचमार्क: 4.9 गुना से 27.3 गुना तक कम टोकन और conservative impact analysis" width="85%" />
</p>
सभी आंकड़े 6 वास्तविक ओपन-सोर्स रिपॉज़िटरीज़ (कुल 13 कमिट्स) पर स्वचालित मूल्यांकन रनर से आते हैं। `code-review-graph eval --all` से पुनः प्राप्त करें। विस्तृत बेंचमार्क डेटा के लिए [अंग्रेज़ी README](README.md) देखें।
---
## विशेषताएं
| विशेषता | विवरण |
|---------|--------|
| **इंक्रीमेंटल अपडेट** | केवल बदली हुई फ़ाइलों को री-पार्स करता है। बाद के अपडेट 2 सेकंड से कम में पूरे होते हैं। |
| **व्यापक भाषा सपोर्ट + नोटबुक** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed as TypeScript, Jupyter/Databricks (.ipynb) |
| **ब्लास्ट-रेडियस विश्लेषण** | दिखाता है कि किसी भी बदलाव से कौन से फ़ंक्शन, क्लासेज़, और फ़ाइलें प्रभावित होती हैं |
| **ऑटो-अपडेट हुक्स** | बिना मैन्युअल हस्तक्षेप के हर फ़ाइल एडिट और git कमिट पर ग्राफ अपडेट होता है |
| **सिमेंटिक सर्च** | sentence-transformers, Google Gemini, MiniMax, या किसी भी OpenAI-compatible एंडपॉइंट (असली OpenAI, Azure, new-api, LiteLLM, vLLM, LocalAI) के ज़रिए वैकल्पिक वेक्टर एम्बेडिंग |
| **इंटरैक्टिव विज़ुअलाइज़ेशन** | सर्च, कम्युनिटी लीजेंड टॉगल, और डिग्री-स्केल्ड नोड्स के साथ D3.js फ़ोर्स-डायरेक्टेड ग्राफ |
| **हब और ब्रिज डिटेक्शन** | betweenness centrality के ज़रिए सबसे ज़्यादा कनेक्टेड नोड्स और आर्किटेक्चरल चोकपॉइंट्स खोजें |
| **सरप्राइज़ स्कोरिंग** | अप्रत्याशित कपलिंग का पता लगाएं: क्रॉस-कम्युनिटी, क्रॉस-लैंग्वेज, पेरीफ़ेरल-टू-हब एज़ेज़ |
| **नॉलेज गैप विश्लेषण** | अलग-थलग नोड्स, अनटेस्टेड हॉटस्पॉट्स, पतली कम्युनिटीज़, और स्ट्रक्चरल कमज़ोरियों की पहचान |
| **सुझाए गए प्रश्न** | ग्राफ विश्लेषण (ब्रिजेज़, हब्स, सरप्राइज़ेज़) से स्वतः-जनित रिव्यू प्रश्न |
| **एज कॉन्फ़िडेंस** | एज़ेज़ पर फ़्लोट स्कोर के साथ तीन-स्तरीय कॉन्फ़िडेंस स्कोरिंग (EXTRACTED/INFERRED/AMBIGUOUS) |
| **ग्राफ ट्रैवर्सल** | कॉन्फ़िगर करने योग्य डेप्थ और टोकन बजट के साथ किसी भी नोड से फ़्री-फ़ॉर्म BFS/DFS एक्सप्लोरेशन |
| **एक्सपोर्ट फ़ॉर्मैट** | GraphML (Gephi/yEd), Neo4j Cypher, विकीलिंक्स के साथ Obsidian वॉल्ट, SVG स्टैटिक ग्राफ |
| **ग्राफ डिफ़** | समय के साथ ग्राफ स्नैपशॉट्स की तुलना: नए/हटाए गए नोड्स, एज़ेज़, कम्युनिटी बदलाव |
| **टोकन बेंचमार्किंग** | प्रति-प्रश्न अनुपात के साथ नैव फ़ुल-कॉर्पस टोकन बनाम ग्राफ क्वेरी टोकन मापें |
| **मेमोरी लूप** | री-इन्जेशन के लिए Q&A परिणामों को मार्कडाउन के रूप में सहेजें, ताकि ग्राफ क्वेरीज़ से बढ़े |
| **कम्युनिटी ऑटो-स्प्लिट** | बड़ी कम्युनिटीज़ (ग्राफ का >25%) को Leiden के ज़रिए पुनरावर्ती रूप से विभाजित किया जाता है |
| **एक्ज़ीक्यूशन फ़्लोज़** | भारित क्रिटिकैलिटी के अनुसार क्रमबद्ध, एंट्री पॉइंट्स से कॉल चेन ट्रेस करें |
| **कम्युनिटी डिटेक्शन** | बड़े ग्राफ़ के लिए रेज़ोल्यूशन स्केलिंग के साथ Leiden एल्गोरिदम से संबंधित कोड क्लस्टर करें |
| **आर्किटेक्चर ओवरव्यू** | कपलिंग चेतावनियों के साथ स्वतः-जनित आर्किटेक्चर मैप |
| **रिस्क-स्कोर्ड रिव्यूज़** | `detect_changes` डिफ़ को प्रभावित फ़ंक्शन, फ़्लोज़, और टेस्ट गैप्स से मैप करता है |
| **रिफ़ैक्टरिंग टूल्स** | रीनेम प्रीव्यू, फ़्रेमवर्क-अवेयर डेड कोड डिटेक्शन, कम्युनिटी-ड्रिवन सुझाव |
| **विकी जनरेशन** | कम्युनिटी संरचना से स्वतः मार्कडाउन विकी जनरेट करें |
| **मल्टी-रिपो रजिस्ट्री** | कई रिपॉज़ रजिस्टर करें, सभी में सर्च करें |
| **MCP प्रॉम्प्ट्स** | 5 वर्कफ़्लो टेम्प्लेट: review, architecture, debug, onboard, pre-merge |
| **फ़ुल-टेक्स्ट सर्च** | कीवर्ड और वेक्टर सिमिलैरिटी को मिलाकर FTS5-संचालित हाइब्रिड सर्च |
| **लोकल स्टोरेज** | `.code-review-graph/` में SQLite फ़ाइल। core graph storage के लिए बाहरी डेटाबेस या क्लाउड सर्विस की ज़रूरत नहीं। |
| **वॉच मोड** | काम करते समय लगातार ग्राफ अपडेट |
---
## उपयोग
<details>
<summary><strong>स्लैश कमांड</strong></summary>
<br>
| कमांड | विवरण |
|-------|--------|
| `/code-review-graph:build-graph` | कोड ग्राफ बनाएं या रीबिल्ड करें |
| `/code-review-graph:review-delta` | पिछले कमिट के बाद से बदलावों की समीक्षा करें |
| `/code-review-graph:review-pr` | ब्लास्ट-रेडियस विश्लेषण के साथ पूर्ण PR रिव्यू |
</details>
<details>
<summary><strong>CLI संदर्भ</strong></summary>
<br>
```bash
code-review-graph install # सभी प्लेटफ़ॉर्म को स्वचालित रूप से पहचानें और कॉन्फ़िगर करें
code-review-graph install --platform <name> # किसी विशेष प्लेटफ़ॉर्म को टार्गेट करें
code-review-graph build # पूरा कोडबेस पार्स करें
code-review-graph update # इंक्रीमेंटल अपडेट (केवल बदली हुई फ़ाइलें)
code-review-graph status # ग्राफ़ आंकड़े
code-review-graph watch # फ़ाइल बदलाव पर ऑटो-अपडेट
code-review-graph visualize # इंटरैक्टिव HTML ग्राफ जनरेट करें
code-review-graph visualize --format graphml # GraphML के रूप में एक्सपोर्ट
code-review-graph visualize --format svg # SVG के रूप में एक्सपोर्ट
code-review-graph visualize --format obsidian # Obsidian वॉल्ट के रूप में एक्सपोर्ट
code-review-graph visualize --format cypher # Neo4j Cypher के रूप में एक्सपोर्ट
code-review-graph wiki # कम्युनिटीज़ से मार्कडाउन विकी जनरेट करें
code-review-graph detect-changes # रिस्क-स्कोर्ड चेंज इम्पैक्ट विश्लेषण
code-review-graph register <path> # मल्टी-रिपो रजिस्ट्री में रिपो रजिस्टर करें
code-review-graph unregister <id> # रजिस्ट्री से रिपो हटाएं
code-review-graph repos # रजिस्टर्ड रिपॉज़िटरीज़ की सूची
code-review-graph eval # मूल्यांकन बेंचमार्क चलाएं
code-review-graph serve # MCP सर्वर शुरू करें
```
</details>
<details>
<summary><strong>30 MCP टूल्स</strong></summary>
<br>
ग्राफ बनने के बाद आपका AI असिस्टेंट इन्हें स्वचालित रूप से उपयोग करता है।
| टूल | विवरण |
|-----|--------|
| `build_or_update_graph_tool` | ग्राफ बनाएं या इंक्रीमेंटली अपडेट करें |
| `run_postprocess_tool` | एक्ज़ीक्यूशन फ़्लोज़, कम्युनिटीज़ और फुल-टेक्स्ट इंडेक्स की पोस्ट-प्रोसेसिंग फिर चलाएं |
| `get_minimal_context_tool` | अल्ट्रा-कॉम्पैक्ट कॉन्टेक्स्ट (~100 टोकन) — इसे पहले कॉल करें |
| `get_impact_radius_tool` | बदली हुई फ़ाइलों का ब्लास्ट रेडियस |
| `get_review_context_tool` | स्ट्रक्चरल सारांश के साथ टोकन-ऑप्टिमाइज़्ड रिव्यू कॉन्टेक्स्ट |
| `query_graph_tool` | कॉलर्स, कॉलीज़, टेस्ट, इम्पोर्ट्स, इनहेरिटेंस क्वेरीज़ |
| `traverse_graph_tool` | टोकन बजट के साथ किसी भी नोड से BFS/DFS ट्रैवर्सल |
| `semantic_search_nodes_tool` | नाम या अर्थ से कोड एंटिटीज़ खोजें |
| `embed_graph_tool` | सिमेंटिक सर्च के लिए वेक्टर एम्बेडिंग कम्प्यूट करें |
| `list_graph_stats_tool` | ग्राफ़ का आकार और स्वास्थ्य |
| `get_docs_section_tool` | दस्तावेज़ सेक्शन प्राप्त करें |
| `find_large_functions_tool` | लाइन-काउंट सीमा से अधिक फ़ंक्शन/क्लासेज़ खोजें |
| `list_flows_tool` | क्रिटिकैलिटी के अनुसार क्रमबद्ध एक्ज़ीक्यूशन फ़्लोज़ की सूची |
| `get_flow_tool` | किसी एक एक्ज़ीक्यूशन फ़्लो का विवरण प्राप्त करें |
| `get_affected_flows_tool` | बदली हुई फ़ाइलों से प्रभावित फ़्लोज़ खोजें |
| `list_communities_tool` | पहचानी गई कोड कम्युनिटीज़ की सूची |
| `get_community_tool` | किसी एक कम्युनिटी का विवरण प्राप्त करें |
| `get_architecture_overview_tool` | कम्युनिटी संरचना से आर्किटेक्चर ओवरव्यू |
| `detect_changes_tool` | कोड रिव्यू के लिए रिस्क-स्कोर्ड चेंज इम्पैक्ट विश्लेषण |
| `get_hub_nodes_tool` | सबसे ज़्यादा कनेक्टेड नोड्स (आर्किटेक्चरल हॉटस्पॉट्स) खोजें |
| `get_bridge_nodes_tool` | betweenness centrality से चोकपॉइंट्स खोजें |
| `get_knowledge_gaps_tool` | स्ट्रक्चरल कमज़ोरियों और अनटेस्टेड हॉटस्पॉट्स की पहचान |
| `get_surprising_connections_tool` | अप्रत्याशित क्रॉस-कम्युनिटी कपलिंग का पता लगाएं |
| `get_suggested_questions_tool` | विश्लेषण से स्वतः-जनित रिव्यू प्रश्न |
| `refactor_tool` | रीनेम प्रीव्यू, डेड कोड डिटेक्शन, सुझाव |
| `apply_refactor_tool` | पहले प्रीव्यू किए गए रिफ़ैक्टरिंग को लागू करें |
| `generate_wiki_tool` | कम्युनिटीज़ से मार्कडाउन विकी जनरेट करें |
| `get_wiki_page_tool` | कोई विशेष विकी पेज प्राप्त करें |
| `list_repos_tool` | रजिस्टर्ड रिपॉज़िटरीज़ की सूची |
| `cross_repo_search_tool` | सभी रजिस्टर्ड रिपॉज़िटरीज़ में सर्च करें |
**MCP प्रॉम्प्ट्स** (5 वर्कफ़्लो टेम्प्लेट):
`review_changes`, `architecture_map`, `debug_issue`, `onboard_developer`, `pre_merge_check`
</details>
<details>
<summary><strong>कॉन्फ़िगरेशन</strong></summary>
<br>
इंडेक्सिंग से पथ बाहर करने के लिए, अपनी रिपॉज़िटरी रूट में `.code-review-graphignore` फ़ाइल बनाएं:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
नोट: git रिपॉज़ में, केवल ट्रैक की गई फ़ाइलें इंडेक्स होती हैं (`git ls-files`), इसलिए gitignore की गई फ़ाइलें स्वचालित रूप से छोड़ दी जाती हैं। `.code-review-graphignore` का उपयोग ट्रैक की गई फ़ाइलों को बाहर करने या git उपलब्ध न होने पर करें।
वैकल्पिक डिपेंडेंसी ग्रुप:
```bash
pip install code-review-graph[embeddings] # लोकल वेक्टर एम्बेडिंग (sentence-transformers)
pip install code-review-graph[google-embeddings] # Google Gemini एम्बेडिंग
pip install code-review-graph[communities] # कम्युनिटी डिटेक्शन (igraph)
pip install code-review-graph[enrichment] # Python call-resolution enrichment (Jedi)
pip install code-review-graph[eval] # मूल्यांकन बेंचमार्क (matplotlib)
pip install code-review-graph[wiki] # LLM सारांश के साथ विकी जनरेशन (ollama)
pip install code-review-graph[all] # सभी वैकल्पिक डिपेंडेंसीज़
```
OpenAI-compatible एम्बेडिंग्स (असली OpenAI, Azure, या सेल्फ-होस्टेड गेटवे जैसे new-api / LiteLLM / vLLM / LocalAI / Ollama openai मोड) के लिए कोई अतिरिक्त इंस्टॉल की ज़रूरत नहीं — बस एनवायरनमेंट वेरिएबल्स सेट करें और `embed_graph` को `provider="openai"` पास करें:
```bash
export CRG_OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # या https://api.openai.com/v1
export CRG_OPENAI_API_KEY=sk-...
export CRG_OPENAI_MODEL=text-embedding-3-small # आपके गेटवे पर उपलब्ध मॉडल
# वैकल्पिक:
export CRG_OPENAI_DIMENSION=1536 # डाइमेंशन पिन करें (v3 मॉडल्स डाइमेंशन रिडक्शन सपोर्ट करते हैं)
export CRG_OPENAI_BATCH_SIZE=100 # टाइट बैच लिमिट वाले गेटवे के लिए कम करें
# (जैसे Qwen text-embedding-v4 की लिमिट 10 है)
```
जब base URL localhost (`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`) की ओर इशारा करता है, तो क्लाउड-egress चेतावनी अपने आप स्किप हो जाती है।
> **मॉडल चुनने की सलाह।** लंबे समय के उपयोग के लिए `-preview` / `-beta` / `-exp` वाले model ID (जैसे `google/gemini-embedding-2-preview`) से बचें — preview मॉडल्स के वज़न बदल सकते हैं (डाइमेंशन बदलने पर पूरा re-embed करना पड़ेगा) या बिना नोटिस deprecate हो सकते हैं। स्टेबल GA मॉडल्स की सलाह दी जाती है: `text-embedding-3-small` / `text-embedding-3-large` (OpenAI), `Qwen/Qwen3-Embedding-8B` (vLLM / LocalAI सेल्फ-होस्टेड के ज़रिए), या `gemini-embedding-001` (नेटिव Gemini provider के ज़रिए, `GOOGLE_API_KEY` चाहिए).
>
> साथ ही ध्यान दें: वर्तमान में `code-review-graph` केवल **फ़ंक्शन सिग्नेचर** एम्बेड करता है (प्रति नोड ~10 tokens, जैसे `"parse_file function (path: str) returns Tree"`). जिन मॉडल्स की क्वालिटी का मुख्य source लंबे context में function body को समझना है (जैसे Gemini 2 या Qwen3-8B के MTEB-code SOTA स्कोर्स), वे इस इनपुट लंबाई पर छोटे मॉडल्स से कम अंतर दिखाएंगे। Body / docstring एम्बेडिंग को फ़ॉलो-अप एन्हांसमेंट के रूप में ट्रैक किया जा रहा है।
</details>
---
## योगदान
```bash
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
<details>
<summary><strong>नई भाषा जोड़ना</strong></summary>
<br>
`code_review_graph/parser.py` में अपना एक्सटेंशन `EXTENSION_TO_LANGUAGE` में जोड़ें, साथ ही `_CLASS_TYPES`, `_FUNCTION_TYPES`, `_IMPORT_TYPES`, और `_CALL_TYPES` में नोड टाइप मैपिंग जोड़ें। एक टेस्ट फ़िक्सचर शामिल करें और PR खोलें।
</details>
## लाइसेंस
MIT। [LICENSE](LICENSE) देखें।
<p align="center">
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Kiro, Qoder, और GitHub Copilot सहित समर्थित AI कोडिंग टूल्स को स्वचालित रूप से पहचानता और कॉन्फ़िगर करता है</sub>
</p>
+328
View File
@@ -0,0 +1,328 @@
<h1 align="center">code-review-graph</h1>
> **注意:** この翻訳は古いリリースに基づいています。ベンチマーク数値や対応プラットフォームの一覧は[英語版 README](README.md)より古い場合があります。
<p align="center">
<strong>トークンの無駄遣いをやめて、スマートなレビューを。</strong>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ja-JP.md">日本語</a> |
<a href="README.ko-KR.md">한국어</a> |
<a href="README.hi-IN.md">हिन्दी</a>
</p>
<p align="center">
<a href="https://pypi.org/project/code-review-graph/"><img src="https://img.shields.io/pypi/v/code-review-graph?style=flat-square&color=blue" alt="PyPI"></a>
<a href="https://pepy.tech/project/code-review-graph"><img src="https://img.shields.io/pepy/dt/code-review-graph?style=flat-square" alt="Downloads"></a>
<a href="https://github.com/tirth8205/code-review-graph/stargazers"><img src="https://img.shields.io/github/stars/tirth8205/code-review-graph?style=flat-square" alt="Stars"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="MIT Licence"></a>
<a href="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml"><img src="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg?style=flat-square" alt="Python 3.10+"></a>
<a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-compatible-green.svg?style=flat-square" alt="MCP"></a>
<a href="https://code-review-graph.com"><img src="https://img.shields.io/badge/website-code--review--graph.com-blue?style=flat-square" alt="Website"></a>
<a href="https://discord.gg/3p58KXqGFN"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<br>
AIコーディングツールはレビュータスクでコードベースの大きな範囲を読み直しがちです。`code-review-graph` はその問題を解決します。[Tree-sitter](https://tree-sitter.github.io/tree-sitter/) でコードの構造マップを構築し、変更を差分で追跡し、[MCP](https://modelcontextprotocol.io/) を通じてAIアシスタントに必要最小限のコンテキストだけを提供します。
<p align="center">
<img src="diagrams/diagram1_before_vs_after.png" alt="トークン問題:6つの実リポジトリで平均8.2倍のトークン削減" width="85%" />
</p>
---
## クイックスタート
```bash
pip install code-review-graph # または: pipx install code-review-graph
code-review-graph install # 対応プラットフォームを自動検出して設定
code-review-graph build # コードベースを解析
```
1つのコマンドですべてが完了します。`install` は使用中のAIコーディングツールを検出し、各ツールに適切なMCP設定を書き込み、プラットフォームルールにグラフ対応の指示を注入します。`uvx``pip`/`pipx` のどちらでインストールしたかを自動判別し、適切な設定を生成します。インストール後はエディタ/ツールを再起動してください。
<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="ワンインストールで対応するAIコーディングツールを自動検出して設定" width="85%" />
</p>
特定のプラットフォームのみを設定する場合:
```bash
code-review-graph install --platform codex # Codexのみ設定
code-review-graph install --platform cursor # Cursorのみ設定
code-review-graph install --platform claude-code # Claude Codeのみ設定
code-review-graph install --platform kiro # Kiroのみ設定
```
Python 3.10以上が必要です。最良の体験のためには [uv](https://docs.astral.sh/uv/) のインストールを推奨します(MCP設定は利用可能な場合 `uvx` を使用し、そうでない場合は `code-review-graph` コマンドに直接フォールバックします)。
セットアップ後、プロジェクトを開いてAIアシスタントに聞いてみましょう:
```
Build the code review graph for this project
```
初回ビルドは500ファイルのプロジェクトで約10秒です。以降はwatchモードや対応しているプラットフォームフックでグラフを自動更新できます。
---
## 仕組み
<p align="center">
<img src="diagrams/diagram7_mcp_integration_flow.png" alt="AIアシスタントがグラフを活用する流れ:ユーザーがレビューを依頼、AIがMCPツールを確認、グラフが影響範囲とリスクスコアを返却、AIは必要なものだけを読む" width="80%" />
</p>
リポジトリはTree-sitterでASTに解析され、ノード(関数、クラス、インポート)とエッジ(呼び出し、継承、テストカバレッジ)のグラフとしてSQLiteに保存されます。レビュー時にはこのグラフを参照して、AIアシスタントが読むべきファイルの最小セットを算出します。
<p align="center">
<img src="diagrams/diagram2_architecture_pipeline.png" alt="アーキテクチャパイプライン:リポジトリ → Tree-sitterパーサー → SQLiteグラフ → 影響範囲 → 最小レビューセット" width="100%" />
</p>
### 影響範囲分析(ブラストラディウス)
ファイルが変更されると、グラフは影響を受ける可能性のあるすべての呼び出し元、依存先、テストをトレースします。これが変更の「影響範囲(ブラストラディウス)」です。AIはプロジェクト全体をスキャンする代わりに、これらのファイルだけを読みます。
<p align="center">
<img src="diagrams/diagram3_blast_radius.png" alt="影響範囲の可視化:login()への変更が呼び出し元、依存先、テストにどう伝播するか" width="70%" />
</p>
### 2秒以内のインクリメンタル更新
フックまたはwatchモードを有効にすると、ファイル保存や対応しているコミットフックでインクリメンタル更新が起動します。グラフは変更ファイルの差分を取り、SHA-256ハッシュで依存先を特定し、変更されたものだけを再解析します。2,900ファイルのプロジェクトでも2秒以内で再インデックスが完了します。
<p align="center">
<img src="diagrams/diagram4_incremental_update.png" alt="インクリメンタル更新フロー:gitコミットが差分をトリガー、依存先を検出、5ファイルのみ再解析、2,910ファイルはスキップ" width="90%" />
</p>
### モノレポ問題の解決
大規模モノレポこそトークンの無駄が最も深刻な場所です。グラフがノイズを除去し、27,700以上のファイルをレビューコンテキストから除外、実際に読むのは約15ファイルだけです。
<p align="center">
<img src="diagrams/diagram6_monorepo_funnel.png" alt="Next.jsモノレポ:27,732ファイルをcode-review-graphで絞り込み、約15ファイルに - トークン49分の1" width="80%" />
</p>
### 幅広い言語対応 + Jupyterノートブック
<p align="center">
<img src="diagrams/diagram9_language_coverage.png" alt="カテゴリ別の言語サポート:Web、バックエンド、システム、モバイル、スクリプト、さらにJupyter/Databricksノートブック対応" width="90%" />
</p>
現在のパーサーが対応する範囲で、関数、クラス、インポート、呼び出し箇所、継承、テスト検出を抽出します。利用できる場合はTree-sitterを使い、必要な箇所では専用のフォールバック解析を使います。対応範囲には Python、JavaScript/TypeScript/TSX、Go、Rust、Java、C/C++、C#、Ruby、Kotlin、Swift、PHP、Scala、Solidity、Dart、R、Perl、Lua/Luau、Objective-C、shell scripts、Elixir、Zig、PowerShell、Julia、ReScript、GDScript、Nix、Verilog/SystemVerilog、SQL、Vue/Svelte SFC、TypeScriptパーサーで扱うAstroファイル、Jupyter/Databricksノートブック(`.ipynb`)、Perl XSファイル(`.xs`)が含まれます。
---
## ベンチマーク
<p align="center">
<img src="diagrams/diagram5_benchmark_board.png" alt="実リポジトリでのベンチマーク:トークン4.9倍から27.3倍削減、保守的な影響分析" width="85%" />
</p>
すべての数値は6つの実際のオープンソースリポジトリ(合計13コミット)に対する自動評価ランナーの結果です。`code-review-graph eval --all` で再現可能です。完全な再現手順と正規の数値は [`docs/REPRODUCING.md`](docs/REPRODUCING.md) をご覧ください。
> 詳細なベンチマーク結果(トークン効率、影響精度、ビルド性能、既知の制限事項)については [英語版README](README.md) を参照してください。
---
## 機能一覧
| 機能 | 詳細 |
|------|------|
| **インクリメンタル更新** | 変更されたファイルのみを再解析。更新は2秒以内に完了。 |
| **幅広い言語対応 + ノートブック** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed as TypeScript, Jupyter/Databricks (.ipynb) |
| **影響範囲分析** | 変更によって影響を受ける可能性のある関数、クラス、ファイルを表示 |
| **自動更新フック** | ファイル編集やgitコミットのたびに手動操作なしでグラフを更新 |
| **セマンティック検索** | sentence-transformers、Google Gemini、MiniMax、またはOpenAI互換エンドポイント(本家OpenAI、Azure、new-api、LiteLLM、vLLM、LocalAI)によるオプションのベクトル埋め込み |
| **インタラクティブ可視化** | D3.js力学レイアウトグラフ。検索、コミュニティ凡例切替、次数スケーリングノード対応 |
| **ハブ・ブリッジ検出** | 最も接続の多いノードと媒介中心性によるアーキテクチャのボトルネックを発見 |
| **サプライズスコアリング** | 予期しない結合を検出:コミュニティ間、言語間、周辺からハブへのエッジ |
| **ナレッジギャップ分析** | 孤立ノード、テストされていないホットスポット、薄いコミュニティ、構造的弱点を特定 |
| **レビュー質問の自動生成** | グラフ分析(ブリッジ、ハブ、サプライズ)からレビュー質問を自動生成 |
| **エッジ信頼度** | エッジに3段階の信頼度スコアリング(EXTRACTED/INFERRED/AMBIGUOUS)とfloatスコア |
| **グラフ走査** | 任意のノードからBFS/DFSで自由に探索。深さとトークン予算を設定可能 |
| **エクスポート形式** | GraphMLGephi/yEd)、Neo4j Cypher、Obsidianボールト(ウィキリンク付き)、SVG静的グラフ |
| **グラフ差分** | グラフのスナップショットを時系列で比較:ノード・エッジの追加/削除、コミュニティの変更 |
| **トークンベンチマーク** | ナイーブな全ファイル読み込みとグラフクエリのトークン数を質問ごとに比較 |
| **メモリループ** | Q&A結果をMarkdownとして保存し再取り込み。クエリからグラフが成長 |
| **コミュニティ自動分割** | グラフの25%を超えるコミュニティはLeidenアルゴリズムで再帰的に分割 |
| **実行フロー** | エントリーポイントからの呼び出しチェーンを重み付き重要度でソートしてトレース |
| **コミュニティ検出** | Leidenアルゴリズムで関連コードをクラスタリング。大規模グラフ向け解像度スケーリング対応 |
| **アーキテクチャ概要** | コミュニティ構造から自動生成されるアーキテクチャマップ(結合度警告付き) |
| **リスクスコア付きレビュー** | `detect_changes` が差分を影響する関数、フロー、テストギャップにマッピング |
| **リファクタリングツール** | リネームプレビュー、フレームワーク対応のデッドコード検出、コミュニティ駆動の提案 |
| **Wiki生成** | コミュニティ構造からMarkdown Wikiを自動生成 |
| **マルチリポジトリ管理** | 複数リポジトリを登録し、横断検索が可能 |
| **MCPプロンプト** | 5つのワークフローテンプレート:レビュー、アーキテクチャ、デバッグ、オンボーディング、マージ前チェック |
| **全文検索** | FTS5によるハイブリッド検索(キーワードとベクトル類似度の組み合わせ) |
| **ローカルストレージ** | `.code-review-graph/` 内のSQLiteファイル。コアのグラフ保存に外部DBやクラウドサービスは不要。 |
| **ウォッチモード** | 作業中にグラフを継続的に更新 |
---
## 使い方
<details>
<summary><strong>スラッシュコマンド</strong></summary>
<br>
| コマンド | 説明 |
|---------|------|
| `/code-review-graph:build-graph` | コードグラフのビルドまたは再ビルド |
| `/code-review-graph:review-delta` | 最後のコミット以降の変更をレビュー |
| `/code-review-graph:review-pr` | 影響範囲分析付きのフルPRレビュー |
</details>
<details>
<summary><strong>CLIリファレンス</strong></summary>
<br>
```bash
code-review-graph install # 全プラットフォームを自動検出して設定
code-review-graph install --platform <name> # 特定のプラットフォームのみ設定
code-review-graph build # コードベース全体を解析
code-review-graph update # インクリメンタル更新(変更ファイルのみ)
code-review-graph status # グラフの統計情報
code-review-graph watch # ファイル変更時に自動更新
code-review-graph visualize # インタラクティブHTMLグラフを生成
code-review-graph visualize --format graphml # GraphML形式でエクスポート
code-review-graph visualize --format svg # SVG形式でエクスポート
code-review-graph visualize --format obsidian # Obsidianボールトとしてエクスポート
code-review-graph visualize --format cypher # Neo4j Cypher形式でエクスポート
code-review-graph wiki # コミュニティからMarkdown Wikiを生成
code-review-graph detect-changes # リスクスコア付き変更影響分析
code-review-graph register <path> # マルチリポジトリレジストリにリポジトリを登録
code-review-graph unregister <id> # レジストリからリポジトリを削除
code-review-graph repos # 登録済みリポジトリの一覧表示
code-review-graph eval # 評価ベンチマークの実行
code-review-graph serve # MCPサーバーの起動
```
</details>
<details>
<summary><strong>30のMCPツール</strong></summary>
<br>
グラフのビルド後、AIアシスタントがこれらのツールを自動的に使用します。
| ツール | 説明 |
|--------|------|
| `build_or_update_graph_tool` | グラフのビルドまたはインクリメンタル更新 |
| `run_postprocess_tool` | 実行フロー、コミュニティ、全文検索インデックスの後処理を再実行 |
| `get_minimal_context_tool` | 超コンパクトなコンテキスト(約100トークン) -- 最初にこれを呼び出す |
| `get_impact_radius_tool` | 変更ファイルの影響範囲 |
| `get_review_context_tool` | 構造サマリー付きトークン最適化レビューコンテキスト |
| `query_graph_tool` | 呼び出し元、呼び出し先、テスト、インポート、継承のクエリ |
| `traverse_graph_tool` | 任意のノードからトークン予算付きBFS/DFS走査 |
| `semantic_search_nodes_tool` | 名前や意味でコードエンティティを検索 |
| `embed_graph_tool` | セマンティック検索用のベクトル埋め込みを計算 |
| `list_graph_stats_tool` | グラフのサイズと健全性 |
| `get_docs_section_tool` | ドキュメントセクションの取得 |
| `find_large_functions_tool` | 行数閾値を超える関数/クラスの検出 |
| `list_flows_tool` | 重要度順の実行フロー一覧 |
| `get_flow_tool` | 単一の実行フローの詳細取得 |
| `get_affected_flows_tool` | 変更ファイルに影響するフローの検出 |
| `list_communities_tool` | 検出されたコードコミュニティの一覧 |
| `get_community_tool` | 単一コミュニティの詳細取得 |
| `get_architecture_overview_tool` | コミュニティ構造からのアーキテクチャ概要 |
| `detect_changes_tool` | コードレビュー用のリスクスコア付き変更影響分析 |
| `get_hub_nodes_tool` | 最も接続の多いノード(アーキテクチャのホットスポット)の検出 |
| `get_bridge_nodes_tool` | 媒介中心性によるボトルネックの検出 |
| `get_knowledge_gaps_tool` | 構造的弱点とテストされていないホットスポットの特定 |
| `get_surprising_connections_tool` | 予期しないコミュニティ間結合の検出 |
| `get_suggested_questions_tool` | 分析から自動生成されたレビュー質問 |
| `refactor_tool` | リネームプレビュー、デッドコード検出、提案 |
| `apply_refactor_tool` | プレビュー済みリファクタリングの適用 |
| `generate_wiki_tool` | コミュニティからMarkdown Wikiを生成 |
| `get_wiki_page_tool` | 特定のWikiページの取得 |
| `list_repos_tool` | 登録済みリポジトリの一覧 |
| `cross_repo_search_tool` | 全登録リポジトリを横断検索 |
**MCPプロンプト**5つのワークフローテンプレート):
`review_changes`, `architecture_map`, `debug_issue`, `onboard_developer`, `pre_merge_check`
</details>
<details>
<summary><strong>設定</strong></summary>
<br>
インデックス対象から除外するパスを指定するには、リポジトリルートに `.code-review-graphignore` ファイルを作成します:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
注意:gitリポジトリでは追跡対象ファイルのみがインデックスされます(`git ls-files`)。そのため、gitignoreされたファイルは自動的にスキップされます。`.code-review-graphignore` は追跡対象ファイルの除外や、gitが利用できない環境で使用してください。
オプションの依存グループ:
```bash
pip install code-review-graph[embeddings] # ローカルベクトル埋め込み (sentence-transformers)
pip install code-review-graph[google-embeddings] # Google Gemini埋め込み
pip install code-review-graph[communities] # コミュニティ検出 (igraph)
pip install code-review-graph[enrichment] # Python呼び出し解決の補強 (Jedi)
pip install code-review-graph[eval] # 評価ベンチマーク (matplotlib)
pip install code-review-graph[wiki] # LLMサマリー付きWiki生成 (ollama)
pip install code-review-graph[all] # 全オプション依存
```
OpenAI互換の埋め込み(本家OpenAI、Azure、または自前のゲートウェイ: new-api / LiteLLM / vLLM / LocalAI / Ollama openaiモード)は追加インストール不要です。環境変数を設定し、`embed_graph``provider="openai"` を渡すだけで動作します:
```bash
export CRG_OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # または https://api.openai.com/v1
export CRG_OPENAI_API_KEY=sk-...
export CRG_OPENAI_MODEL=text-embedding-3-small # ゲートウェイが提供するモデル名
# 任意:
export CRG_OPENAI_DIMENSION=1536 # 次元を固定(v3モデルは次元削減対応)
export CRG_OPENAI_BATCH_SIZE=100 # バッチ上限が厳しいゲートウェイで下げる
# (例: Qwen text-embedding-v4 は上限10
```
base URLがlocalhost`127.0.0.1``localhost``0.0.0.0``::1`)を指している場合、クラウドegress警告は自動的にスキップされます。
> **モデル選択のヒント。** `-preview` / `-beta` / `-exp` 付きのmodel ID(例:`google/gemini-embedding-2-preview`)は長期運用には避けてください。preview モデルは重みが変更される(次元が変わると全ノード re-embed 必須)か、予告なく deprecate される可能性があります。安定版 GA モデル推奨:`text-embedding-3-small` / `text-embedding-3-large`OpenAI)、`Qwen/Qwen3-Embedding-8B`vLLM / LocalAI 自前ホスト経由)、または `gemini-embedding-001`(ネイティブ Gemini provider 経由、`GOOGLE_API_KEY` が必要)。
>
> また注意:現状 `code-review-graph` は**関数シグネチャのみ**を埋め込みます(ノードあたり約10トークン、例:`"parse_file function (path: str) returns Tree"`)。長 context で関数 body を理解する能力で差をつけるモデル(Gemini 2 や Qwen3-8B の MTEB-code SOTA スコア)は、この入力長では小型モデルとの差がかなり縮まります。Body / docstring 埋め込みはフォローアップ拡張として追跡中です。
</details>
---
## コントリビュート
```bash
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
<details>
<summary><strong>新しい言語の追加</strong></summary>
<br>
`code_review_graph/parser.py` を編集し、`EXTENSION_TO_LANGUAGE` に拡張子を追加します。合わせて `_CLASS_TYPES``_FUNCTION_TYPES``_IMPORT_TYPES``_CALL_TYPES` にノードタイプのマッピングを追加してください。テストフィクスチャを含めてPRを作成してください。
</details>
## ライセンス
MIT。詳細は [LICENSE](LICENSE) を参照してください。
<p align="center">
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>Codex、Claude Code、Cursor、Windsurf、Zed、Continue、OpenCode、Antigravity、Gemini CLI、Qwen、Kiro、Qoder、GitHub Copilotなど、対応するAIコーディングツールを自動検出して設定</sub>
</p>
+328
View File
@@ -0,0 +1,328 @@
<h1 align="center">code-review-graph</h1>
> **참고:** 이 번역은 이전 릴리스를 기준으로 합니다. 벤치마크 수치와 플랫폼 목록은 [영문 README](README.md)보다 오래되었을 수 있습니다.
<p align="center">
<strong>토큰 낭비를 멈추세요. 더 스마트하게 리뷰하세요.</strong>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ja-JP.md">日本語</a> |
<a href="README.ko-KR.md">한국어</a> |
<a href="README.hi-IN.md">हिन्दी</a>
</p>
<p align="center">
<a href="https://pypi.org/project/code-review-graph/"><img src="https://img.shields.io/pypi/v/code-review-graph?style=flat-square&color=blue" alt="PyPI"></a>
<a href="https://pepy.tech/project/code-review-graph"><img src="https://img.shields.io/pepy/dt/code-review-graph?style=flat-square" alt="Downloads"></a>
<a href="https://github.com/tirth8205/code-review-graph/stargazers"><img src="https://img.shields.io/github/stars/tirth8205/code-review-graph?style=flat-square" alt="Stars"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="MIT Licence"></a>
<a href="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml"><img src="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg?style=flat-square" alt="Python 3.10+"></a>
<a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-compatible-green.svg?style=flat-square" alt="MCP"></a>
<a href="https://code-review-graph.com"><img src="https://img.shields.io/badge/website-code--review--graph.com-blue?style=flat-square" alt="Website"></a>
<a href="https://discord.gg/3p58KXqGFN"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<br>
AI 코딩 도구는 리뷰 작업에서 코드베이스의 큰 부분을 반복해서 읽게 될 수 있습니다. `code-review-graph`는 이 문제를 해결합니다. [Tree-sitter](https://tree-sitter.github.io/tree-sitter/)로 코드의 구조적 맵을 구축하고, 변경 사항을 점진적으로 추적하며, [MCP](https://modelcontextprotocol.io/)를 통해 AI 어시스턴트에게 정확한 컨텍스트를 제공하여 필요한 부분만 읽도록 합니다.
<p align="center">
<img src="diagrams/diagram1_before_vs_after.png" alt="토큰 문제: 6개 실제 저장소에서 평균 8.2배 토큰 절감" width="85%" />
</p>
---
## 빠른 시작
```bash
pip install code-review-graph # 또는: pipx install code-review-graph
code-review-graph install # 지원되는 모든 플랫폼을 자동 감지하고 설정
code-review-graph build # 코드베이스 파싱
```
하나의 명령으로 모든 설정이 완료됩니다. `install`은 사용 중인 AI 코딩 도구를 감지하고, 각 도구에 맞는 MCP 설정을 작성하며, 플랫폼 규칙에 그래프 인식 지침을 주입합니다. `uvx` 또는 `pip`/`pipx` 중 어떤 방식으로 설치했는지 자동 감지하여 올바른 설정을 생성합니다. 설치 후 에디터/도구를 재시작하세요.
<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="한 번의 설치로 지원되는 AI 코딩 도구를 자동 감지하고 설정" width="85%" />
</p>
특정 플랫폼만 설정하려면:
```bash
code-review-graph install --platform codex # Codex만 설정
code-review-graph install --platform cursor # Cursor만 설정
code-review-graph install --platform claude-code # Claude Code만 설정
code-review-graph install --platform kiro # Kiro만 설정
```
Python 3.10 이상이 필요합니다. 최상의 경험을 위해 [uv](https://docs.astral.sh/uv/)를 설치하세요 (MCP 설정은 `uvx`가 있으면 이를 사용하고, 없으면 `code-review-graph` 명령을 직접 사용합니다).
프로젝트를 열고 AI 어시스턴트에게 다음과 같이 요청하세요:
```
Build the code review graph for this project
```
초기 빌드는 500개 파일 프로젝트 기준 약 10초가 소요됩니다. 이후에는 watch 모드와 지원되는 플랫폼 훅으로 그래프를 자동 업데이트할 수 있습니다.
---
## 작동 원리
<p align="center">
<img src="diagrams/diagram7_mcp_integration_flow.png" alt="AI 어시스턴트의 그래프 활용: 사용자가 리뷰 요청, AI가 MCP 도구 확인, 그래프가 영향 범위와 위험 점수 반환, AI가 필요한 부분만 읽음" width="80%" />
</p>
저장소를 Tree-sitter로 AST로 파싱하고, 노드(함수, 클래스, import)와 엣지(호출, 상속, 테스트 커버리지)의 그래프로 SQLite에 저장한 후, 리뷰 시점에 쿼리하여 AI 어시스턴트가 읽어야 할 최소한의 파일 집합을 계산합니다.
<p align="center">
<img src="diagrams/diagram2_architecture_pipeline.png" alt="아키텍처 파이프라인: 저장소에서 Tree-sitter 파서, SQLite 그래프, 영향 범위, 최소 리뷰 세트까지" width="100%" />
</p>
### 영향 범위 분석
파일이 변경되면, 그래프는 영향을 받을 수 있는 모든 호출자, 의존 대상, 테스트를 추적합니다. 이것이 변경의 "영향 범위"입니다. AI는 전체 프로젝트를 스캔하는 대신 이 파일들만 읽습니다.
<p align="center">
<img src="diagrams/diagram3_blast_radius.png" alt="login() 변경이 호출자, 의존 대상, 테스트로 전파되는 영향 범위 시각화" width="70%" />
</p>
### 2초 미만의 점진적 업데이트
훅 또는 watch 모드를 사용하면 파일 저장과 지원되는 커밋 훅에서 점진적 업데이트가 실행됩니다. 그래프는 변경된 파일을 비교하고, SHA-256 해시 검사를 통해 의존 대상을 찾으며, 변경된 부분만 다시 파싱합니다. 2,900개 파일 프로젝트의 재인덱싱이 2초 이내에 완료됩니다.
<p align="center">
<img src="diagrams/diagram4_incremental_update.png" alt="점진적 업데이트 흐름: git 커밋이 diff를 트리거하고, 의존 대상을 찾고, 5개 파일만 다시 파싱하며 2,910개는 건너뜀" width="90%" />
</p>
### 모노레포 문제 해결
대규모 모노레포에서 토큰 낭비가 가장 심합니다. 그래프는 불필요한 파일을 제거합니다 -- 27,700개 이상의 파일이 리뷰 컨텍스트에서 제외되고, 실제로 읽는 파일은 약 15개뿐입니다.
<p align="center">
<img src="diagrams/diagram6_monorepo_funnel.png" alt="Next.js 모노레포: 27,732개 파일이 code-review-graph를 거쳐 약 15개 파일로 -- 49배 적은 토큰" width="80%" />
</p>
### 폭넓은 언어 지원 + Jupyter 노트북
<p align="center">
<img src="diagrams/diagram9_language_coverage.png" alt="카테고리별 언어 지원: 웹, 백엔드, 시스템, 모바일, 스크립팅, 그리고 Jupyter/Databricks 노트북 지원" width="90%" />
</p>
현재 파서가 지원하는 범위에서 함수, 클래스, import, 호출 위치, 상속, 테스트 감지를 추출합니다. 가능한 경우 Tree-sitter를 사용하고 필요한 곳에서는 대상별 fallback 파서를 사용합니다. 지원 범위에는 Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFC, TypeScript 파서로 처리되는 Astro 파일, Jupyter/Databricks 노트북(`.ipynb`), Perl XS 파일(`.xs`)이 포함됩니다.
---
## 벤치마크
<p align="center">
<img src="diagrams/diagram5_benchmark_board.png" alt="실제 저장소 벤치마크: 4.9배에서 27.3배 적은 토큰과 보수적인 영향 분석" width="85%" />
</p>
모든 수치는 6개 실제 오픈소스 저장소(총 13개 커밋)에 대한 자동화된 평가 실행 결과입니다. `code-review-graph eval --all`로 재현할 수 있습니다. 전체 재현 절차와 기준 수치는 [`docs/REPRODUCING.md`](docs/REPRODUCING.md)에 있습니다.
전체 벤치마크 결과는 [영문 README](README.md#benchmarks)를 참조하세요.
---
## 기능
| 기능 | 세부 사항 |
|------|-----------|
| **점진적 업데이트** | 변경된 파일만 다시 파싱합니다. 이후 업데이트는 2초 이내에 완료됩니다. |
| **폭넓은 언어 지원 + 노트북** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed as TypeScript, Jupyter/Databricks (.ipynb) |
| **영향 범위 분석** | 변경에 의해 영향 받을 가능성이 있는 함수, 클래스, 파일을 보여줍니다 |
| **자동 업데이트 훅** | 수동 개입 없이 파일 편집 및 git 커밋마다 그래프가 업데이트됩니다 |
| **시맨틱 검색** | sentence-transformers, Google Gemini, MiniMax, 또는 OpenAI 호환 엔드포인트(실제 OpenAI, Azure, new-api, LiteLLM, vLLM, LocalAI)를 통한 선택적 벡터 임베딩 |
| **인터랙티브 시각화** | 검색, 커뮤니티 범례 토글, 차수 기반 노드 크기 조정이 가능한 D3.js 포스 다이렉티드 그래프 |
| **허브 및 브릿지 감지** | 가장 많이 연결된 노드와 매개 중심성을 통한 아키텍처 병목 지점 탐색 |
| **서프라이즈 스코어링** | 예상치 못한 결합 감지: 커뮤니티 간, 언어 간, 주변부-허브 엣지 |
| **지식 격차 분석** | 고립된 노드, 테스트되지 않은 핫스팟, 얇은 커뮤니티, 구조적 약점 식별 |
| **질문 제안** | 그래프 분석(브릿지, 허브, 서프라이즈)을 기반으로 자동 생성되는 리뷰 질문 |
| **엣지 신뢰도** | 엣지에 실수 점수를 포함한 3단계 신뢰도 평가 (EXTRACTED/INFERRED/AMBIGUOUS) |
| **그래프 탐색** | 임의 노드에서 설정 가능한 깊이와 토큰 예산으로 자유 형식 BFS/DFS 탐색 |
| **내보내기 형식** | GraphML (Gephi/yEd), Neo4j Cypher, Obsidian vault (위키링크), SVG 정적 그래프 |
| **그래프 비교** | 시간에 따른 그래프 스냅샷 비교: 새로운/삭제된 노드, 엣지, 커뮤니티 변경 |
| **토큰 벤치마킹** | 전체 코퍼스 토큰 대비 그래프 쿼리 토큰을 질문별 비율로 측정 |
| **메모리 루프** | Q&A 결과를 마크다운으로 저장하여 재수집, 쿼리로 그래프가 성장 |
| **커뮤니티 자동 분할** | 과대 커뮤니티(그래프의 25% 초과)를 Leiden 알고리즘으로 재귀적 분할 |
| **실행 흐름** | 가중 중요도 순으로 정렬된 진입점에서의 호출 체인 추적 |
| **커뮤니티 감지** | 대규모 그래프를 위한 해상도 스케일링이 포함된 Leiden 알고리즘으로 관련 코드 클러스터링 |
| **아키텍처 개요** | 결합 경고가 포함된 자동 생성 아키텍처 맵 |
| **위험 점수 리뷰** | `detect_changes`가 diff를 영향 받는 함수, 흐름, 테스트 격차에 매핑 |
| **리팩토링 도구** | 이름 변경 미리보기, 프레임워크 인식 데드 코드 감지, 커뮤니티 기반 제안 |
| **위키 생성** | 커뮤니티 구조에서 마크다운 위키 자동 생성 |
| **멀티 레포 레지스트리** | 여러 저장소를 등록하고 모든 저장소에서 검색 |
| **MCP 프롬프트** | 5개 워크플로 템플릿: 리뷰, 아키텍처, 디버그, 온보딩, 사전 머지 검사 |
| **전문 검색** | FTS5 기반 키워드와 벡터 유사도를 결합한 하이브리드 검색 |
| **로컬 스토리지** | `.code-review-graph/`에 SQLite 파일 저장. 핵심 그래프 저장에는 외부 데이터베이스나 클라우드 서비스가 필요 없습니다. |
| **감시 모드** | 작업 중 지속적인 그래프 업데이트 |
---
## 사용법
<details>
<summary><strong>슬래시 명령</strong></summary>
<br>
| 명령 | 설명 |
|------|------|
| `/code-review-graph:build-graph` | 코드 그래프 빌드 또는 재빌드 |
| `/code-review-graph:review-delta` | 마지막 커밋 이후 변경 사항 리뷰 |
| `/code-review-graph:review-pr` | 영향 범위 분석을 포함한 전체 PR 리뷰 |
</details>
<details>
<summary><strong>CLI 레퍼런스</strong></summary>
<br>
```bash
code-review-graph install # 모든 플랫폼 자동 감지 및 설정
code-review-graph install --platform <name> # 특정 플랫폼 지정
code-review-graph build # 전체 코드베이스 파싱
code-review-graph update # 점진적 업데이트 (변경 파일만)
code-review-graph status # 그래프 통계
code-review-graph watch # 파일 변경 시 자동 업데이트
code-review-graph visualize # 인터랙티브 HTML 그래프 생성
code-review-graph visualize --format graphml # GraphML로 내보내기
code-review-graph visualize --format svg # SVG로 내보내기
code-review-graph visualize --format obsidian # Obsidian vault로 내보내기
code-review-graph visualize --format cypher # Neo4j Cypher로 내보내기
code-review-graph wiki # 커뮤니티에서 마크다운 위키 생성
code-review-graph detect-changes # 위험 점수 기반 변경 영향 분석
code-review-graph register <path> # 멀티 레포 레지스트리에 저장소 등록
code-review-graph unregister <id> # 레지스트리에서 저장소 제거
code-review-graph repos # 등록된 저장소 목록
code-review-graph eval # 평가 벤치마크 실행
code-review-graph serve # MCP 서버 시작
```
</details>
<details>
<summary><strong>30개 MCP 도구</strong></summary>
<br>
그래프가 빌드되면 AI 어시스턴트가 이 도구들을 자동으로 사용합니다.
| 도구 | 설명 |
|------|------|
| `build_or_update_graph_tool` | 그래프 빌드 또는 점진적 업데이트 |
| `run_postprocess_tool` | 실행 흐름, 커뮤니티, 전체 텍스트 색인 후처리 다시 실행 |
| `get_minimal_context_tool` | 초소형 컨텍스트 (~100 토큰) -- 이것을 먼저 호출 |
| `get_impact_radius_tool` | 변경된 파일의 영향 범위 |
| `get_review_context_tool` | 구조적 요약이 포함된 토큰 최적화 리뷰 컨텍스트 |
| `query_graph_tool` | 호출자, 피호출자, 테스트, import, 상속 쿼리 |
| `traverse_graph_tool` | 토큰 예산으로 임의 노드에서 BFS/DFS 탐색 |
| `semantic_search_nodes_tool` | 이름이나 의미로 코드 엔티티 검색 |
| `embed_graph_tool` | 시맨틱 검색을 위한 벡터 임베딩 계산 |
| `list_graph_stats_tool` | 그래프 크기 및 상태 |
| `get_docs_section_tool` | 문서 섹션 조회 |
| `find_large_functions_tool` | 줄 수 임계값을 초과하는 함수/클래스 찾기 |
| `list_flows_tool` | 중요도 순으로 정렬된 실행 흐름 목록 |
| `get_flow_tool` | 단일 실행 흐름의 세부 정보 |
| `get_affected_flows_tool` | 변경된 파일에 영향 받는 흐름 찾기 |
| `list_communities_tool` | 감지된 코드 커뮤니티 목록 |
| `get_community_tool` | 단일 커뮤니티의 세부 정보 |
| `get_architecture_overview_tool` | 커뮤니티 구조의 아키텍처 개요 |
| `detect_changes_tool` | 코드 리뷰를 위한 위험 점수 기반 변경 영향 분석 |
| `get_hub_nodes_tool` | 가장 많이 연결된 노드 (아키텍처 핫스팟) 찾기 |
| `get_bridge_nodes_tool` | 매개 중심성을 통한 병목 지점 찾기 |
| `get_knowledge_gaps_tool` | 구조적 약점 및 테스트되지 않은 핫스팟 식별 |
| `get_surprising_connections_tool` | 예상치 못한 커뮤니티 간 결합 감지 |
| `get_suggested_questions_tool` | 분석에서 자동 생성된 리뷰 질문 |
| `refactor_tool` | 이름 변경 미리보기, 데드 코드 감지, 제안 |
| `apply_refactor_tool` | 미리보기한 리팩토링 적용 |
| `generate_wiki_tool` | 커뮤니티에서 마크다운 위키 생성 |
| `get_wiki_page_tool` | 특정 위키 페이지 조회 |
| `list_repos_tool` | 등록된 저장소 목록 |
| `cross_repo_search_tool` | 등록된 모든 저장소에서 검색 |
**MCP 프롬프트** (5개 워크플로 템플릿):
`review_changes`, `architecture_map`, `debug_issue`, `onboard_developer`, `pre_merge_check`
</details>
<details>
<summary><strong>설정</strong></summary>
<br>
인덱싱에서 경로를 제외하려면 저장소 루트에 `.code-review-graphignore` 파일을 생성하세요:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
참고: git 저장소에서는 추적되는 파일만 인덱싱됩니다 (`git ls-files`). gitignore된 파일은 자동으로 건너뜁니다. `.code-review-graphignore`는 추적되는 파일을 제외하거나 git을 사용할 수 없을 때 사용합니다.
선택적 의존성 그룹:
```bash
pip install code-review-graph[embeddings] # 로컬 벡터 임베딩 (sentence-transformers)
pip install code-review-graph[google-embeddings] # Google Gemini 임베딩
pip install code-review-graph[communities] # 커뮤니티 감지 (igraph)
pip install code-review-graph[enrichment] # Python 호출 해결 보강 (Jedi)
pip install code-review-graph[eval] # 평가 벤치마크 (matplotlib)
pip install code-review-graph[wiki] # LLM 요약 위키 생성 (ollama)
pip install code-review-graph[all] # 모든 선택적 의존성
```
OpenAI 호환 임베딩(실제 OpenAI, Azure, 또는 자체 호스팅 게이트웨이 new-api / LiteLLM / vLLM / LocalAI / Ollama openai 모드)은 추가 설치가 필요하지 않습니다. 환경 변수만 설정하고 `embed_graph``provider="openai"`를 전달하면 됩니다:
```bash
export CRG_OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # 또는 https://api.openai.com/v1
export CRG_OPENAI_API_KEY=sk-...
export CRG_OPENAI_MODEL=text-embedding-3-small # 게이트웨이에서 제공하는 모델
# 선택:
export CRG_OPENAI_DIMENSION=1536 # 차원 고정 (v3 모델은 차원 축소 지원)
export CRG_OPENAI_BATCH_SIZE=100 # 배치 제한이 엄격한 게이트웨이에서 낮추기
# (예: Qwen text-embedding-v4 는 최대 10)
```
base URL이 localhost(`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`)를 가리킬 때는 클라우드 egress 경고가 자동으로 생략됩니다.
> **모델 선택 팁.** `-preview` / `-beta` / `-exp` 접미사가 붙은 model ID(예: `google/gemini-embedding-2-preview`)는 장기 운영용으로 피하세요. preview 모델은 가중치가 바뀌거나(차원 변경 시 전체 re-embed 필수) 예고 없이 deprecate될 수 있습니다. 안정 GA 모델 권장: `text-embedding-3-small` / `text-embedding-3-large`(OpenAI), `Qwen/Qwen3-Embedding-8B`(vLLM / LocalAI 자체 호스팅 경유), 또는 `gemini-embedding-001`(네이티브 Gemini provider 경유, `GOOGLE_API_KEY` 필요).
>
> 참고로 현재 `code-review-graph`는 **함수 시그니처만** 임베딩합니다(노드당 약 10 토큰, 예: `"parse_file function (path: str) returns Tree"`). 긴 context로 함수 body를 이해하는 능력으로 차별화되는 모델(Gemini 2 또는 Qwen3-8B의 MTEB-code SOTA 점수)은 이 입력 길이에서 소형 모델과의 품질 차이가 훨씬 좁아집니다. Body / docstring 임베딩은 후속 개선 과제로 추적 중입니다.
</details>
---
## 기여
```bash
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
<details>
<summary><strong>새 언어 추가</strong></summary>
<br>
`code_review_graph/parser.py`를 편집하여 `EXTENSION_TO_LANGUAGE`에 확장자를 추가하고, `_CLASS_TYPES`, `_FUNCTION_TYPES`, `_IMPORT_TYPES`, `_CALL_TYPES`에 노드 타입 매핑을 추가하세요. 테스트 픽스처를 포함하여 PR을 제출하세요.
</details>
## 라이선스
MIT. [LICENSE](LICENSE)를 참조하세요.
<p align="center">
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Kiro, Qoder, GitHub Copilot 등 지원되는 AI 코딩 도구를 자동 감지하고 설정합니다</sub>
</p>
+650
View File
@@ -0,0 +1,650 @@
<h1 align="center">code-review-graph</h1>
<p align="center">
<strong>Stop burning tokens. Start reviewing smarter.</strong>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ja-JP.md">日本語</a> |
<a href="README.ko-KR.md">한국어</a> |
<a href="README.hi-IN.md">हिन्दी</a>
</p>
<p align="center">
<a href="https://pypi.org/project/code-review-graph/"><img src="https://img.shields.io/pypi/v/code-review-graph?style=flat-square&color=blue" alt="PyPI"></a>
<a href="https://pepy.tech/project/code-review-graph"><img src="https://img.shields.io/pepy/dt/code-review-graph?style=flat-square" alt="Downloads"></a>
<a href="https://github.com/tirth8205/code-review-graph/stargazers"><img src="https://img.shields.io/github/stars/tirth8205/code-review-graph?style=flat-square" alt="Stars"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="MIT Licence"></a>
<a href="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml"><img src="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg?style=flat-square" alt="Python 3.10+"></a>
<a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-compatible-green.svg?style=flat-square" alt="MCP"></a>
<a href="https://code-review-graph.com"><img src="https://img.shields.io/badge/website-code--review--graph.com-blue?style=flat-square" alt="Website"></a>
<a href="https://discord.gg/3p58KXqGFN"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<p align="center">
<a href="docs/USAGE.md">Usage</a> ·
<a href="docs/COMMANDS.md">Commands</a> ·
<a href="docs/FAQ.md">FAQ</a> ·
<a href="docs/TROUBLESHOOTING.md">Troubleshooting</a> ·
<a href="docs/GITHUB_ACTION.md">GitHub Action</a> ·
<a href="docs/REPRODUCING.md">Reproducing the benchmarks</a> ·
<a href="docs/ROADMAP.md">Roadmap</a>
</p>
<br>
AI coding tools can end up re-reading large parts of your codebase on review tasks. `code-review-graph` fixes that. It builds a structural map of your code with [Tree-sitter](https://tree-sitter.github.io/tree-sitter/), tracks changes incrementally, and gives your AI assistant precise context via [MCP](https://modelcontextprotocol.io/) so it reads only what matters.
<p align="center">
<img src="diagrams/diagram1_before_vs_after.png" alt="The Token Problem: 38x to 528x token reduction across 6 real repositories" width="85%" />
</p>
---
## Quick Start
```bash
pip install code-review-graph # or: pipx install code-review-graph
code-review-graph install # auto-detects and configures all supported platforms
code-review-graph build # parse your codebase
```
One command sets up everything. `install` detects which AI coding tools you have, writes the correct MCP configuration for each one, installs platform-native hooks/skills where supported, and injects graph-aware instructions into your platform rules. It auto-detects whether you installed via `uvx` or `pip`/`pipx` and generates the right config. Restart your editor/tool after installing.
<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, Kiro, and GitHub Copilot" width="85%" />
</p>
To target a specific platform:
```bash
code-review-graph install --platform codex # configure only Codex
code-review-graph install --platform cursor # configure only Cursor
code-review-graph install --platform claude-code # configure only Claude Code
code-review-graph install --platform gemini-cli # configure only Gemini CLI
code-review-graph install --platform kiro # configure only Kiro
code-review-graph install --platform copilot # configure only GitHub Copilot (VS Code)
code-review-graph install --platform copilot-cli # configure only GitHub Copilot CLI
```
Requires Python 3.10+. For the best experience, install [uv](https://docs.astral.sh/uv/) (the MCP config will use `uvx` if available, otherwise falls back to the `code-review-graph` command directly).
Then open your project and ask your AI assistant:
```
Build the code review graph for this project
```
The initial build takes ~10 seconds for a 500-file project. After that, watch mode and supported hooks can keep the graph updated automatically.
## How It Works
<p align="center">
<img src="diagrams/diagram7_mcp_integration_flow.png" alt="How your AI assistant uses the graph: User asks for review, AI checks MCP tools, graph returns blast radius and risk scores, AI reads only what matters" width="80%" />
</p>
Your repository is parsed into an AST with Tree-sitter, stored as a graph of nodes (functions, classes, imports) and edges (calls, inheritance, test coverage), then queried at review time to compute the minimal set of files your AI assistant needs to read.
<p align="center">
<img src="diagrams/diagram2_architecture_pipeline.png" alt="Architecture pipeline: Repository to Tree-sitter Parser to SQLite Graph to Blast Radius to Minimal Review Set" width="100%" />
</p>
### Blast-radius analysis
When a file changes, the graph traces every caller, dependent, and test that could be affected. This is the "blast radius" of the change. Your AI reads only these files instead of scanning the whole project.
<p align="center">
<img src="diagrams/diagram3_blast_radius.png" alt="Blast radius visualization showing how a change to login() propagates to callers, dependents, and tests" width="70%" />
</p>
### Incremental updates in < 2 seconds
When hooks or watch mode are enabled, file saves and supported commit hooks trigger incremental updates. The graph diffs changed files, finds their dependents via SHA-256 hash checks, and re-parses only what changed. A 2,900-file project re-indexes in under 2 seconds.
<p align="center">
<img src="diagrams/diagram4_incremental_update.png" alt="Incremental update flow: supported hook or watch update triggers diff, finds dependents, re-parses only 5 files while 2,910 are skipped" width="90%" />
</p>
### The monorepo problem, solved
Large monorepos are where token waste is most painful. The graph cuts through the noise — 27,700+ files excluded from review context, only ~15 files actually read.
<p align="center">
<img src="diagrams/diagram6_monorepo_funnel.png" alt="code-review-graph repo: 208,821 source tokens funnel down to ~2,495 token graph responses — 93x fewer tokens per question" width="80%" />
</p>
### Broad language coverage + Jupyter notebooks
<p align="center">
<img src="diagrams/diagram9_language_coverage.png" alt="Language coverage organized by category: Web, Backend, Systems, Mobile, Scripting, Config, plus Jupyter and Databricks notebook support" width="90%" />
</p>
Parser support covers functions, classes, imports, call sites, inheritance, and test detection across the current parser surface, using Tree-sitter where available and targeted fallbacks where needed. Current support includes Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks notebooks (`.ipynb`), and Perl XS files (`.xs`).
### Add your own language (no fork needed)
If your repo uses a language the parser does not cover yet, drop a `languages.toml` into `.code-review-graph/` mapping file extensions to any grammar bundled in `tree_sitter_language_pack`, plus the tree-sitter node types for functions, classes, imports, and calls:
```toml
[languages.erlang]
extensions = [".erl"]
grammar = "erlang"
function_node_types = ["function_clause"]
class_node_types = ["record_decl"]
import_node_types = ["import_attribute"]
call_node_types = ["call"]
```
The generic tree-sitter walker handles extraction from there — no code changes, and built-in languages can never be overridden. See [docs/CUSTOM_LANGUAGES.md](docs/CUSTOM_LANGUAGES.md) for the schema reference, validation rules, and a worked end-to-end example.
### Risk-scored PR reviews in CI (GitHub Action)
The same analysis runs as a composite GitHub Action — and it stays local-first: the knowledge graph is built and queried entirely on your CI runner, with no source code sent to any external service. On each pull request the action posts a single sticky comment with risk-scored functions, affected execution flows, and test gaps, updated in place on every push. An optional `fail-on-risk` input turns the review into a merge gate.
```yaml
# .github/workflows/code-review-graph.yml
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: tirth8205/code-review-graph@v2.3.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
```
See [docs/GITHUB_ACTION.md](docs/GITHUB_ACTION.md) for inputs, risk levels, and caching details, or the dogfood workflow this repo runs on itself in [`.github/workflows/pr-review.yml`](.github/workflows/pr-review.yml).
---
## Benchmarks
<p align="center">
<img src="diagrams/diagram5_benchmark_board.png" alt="Benchmarks across 6 real repositories: ~82x median per-question token reduction (528x max), 0.71 average impact F1 against graph-derived ground truth" width="85%" />
</p>
**Headline number: the median per-question token reduction across the 6 repos is ~82x** (whole-corpus baseline vs graph query). The frequently quoted **528x is the maximum** — a single best-case repo (fastapi) — not the typical result.
All numbers come from the automated evaluation runner against 6 real open-source repositories (13 commits total). Every config pins an upstream SHA, the Leiden community detector runs with a fixed seed, and embeddings are deterministic on CPU — so two runs on different machines produce identical numbers. The full reproduction recipe with expected outputs is in [`docs/REPRODUCING.md`](docs/REPRODUCING.md). A weekly report-only run on the two smallest configs lives in [`.github/workflows/eval.yml`](.github/workflows/eval.yml).
<details>
<summary><strong>Token efficiency: ~82x median per-question reduction (range 38x 528x; whole-corpus vs graph query)</strong></summary>
<br>
For a typical agent question (`"how does authentication work"`, `"what is the main entry point"`, etc.), the graph returns ~2,0003,500 tokens of targeted search hits + neighbor edges instead of forcing the agent to read every source file. The table below averages over the 5 sample questions defined in `code_review_graph/token_benchmark.py`.
| Repo | Snapshot SHA | naive_corpus_tokens | avg graph_tokens | Reduction |
|------|---|-----------------:|----------------:|----------:|
| fastapi | `0227991a` | 951,071 | 2,169 | **528.4x** |
| code-review-graph | `84bde354` | 208,821 | 2,495 | **93.0x** |
| gin | `5c00df8a` | 166,868 | 1,990 | **91.8x** |
| flask | `a29f88ce` | 125,022 | 1,986 | **71.4x** |
| express | `b4ab7d65` | 135,955 | 3,465 | **40.6x** |
| httpx | `b55d4635` | 89,492 | 2,438 | **38.0x** |
Median per-question reduction across the 6 repos: **~82x**. The range is 38x 528x, where **528x is the best case** (fastapi, the largest corpus), not the headline.
The whole-corpus baseline above is an upper bound no real agent pays: a competent agent greps for identifiers and reads only the best-matching files. The `agent_baseline` eval benchmark measures that realistic baseline — a pure-python grep over the corpus, top-3 files by match count, token-counted and compared to the graph query cost (`evaluate/results/<repo>_agent_baseline_*.csv`).
The formal `eval/benchmarks/token_efficiency.py` benchmark measures a different scenario — full `get_review_context()` JSON versus just the changed-file content of a commit — and reports ratios below 1 for small commits, because the review-context response carries impact-radius edges plus source snippets that exceed a tiny single-file diff. That is not a bug; the two benchmarks answer different questions. See [`docs/REPRODUCING.md`](docs/REPRODUCING.md) for the full methodology.
Since v2.3.4, review and impact tools attach a compact `context_savings` estimate so MCP clients can see the approximate context saved per call. In v2.3.5 the CLI surfaces this as the boxed `Token Savings` panel shown above (see "Token Savings panel" in the Usage section) and adds `--verify` to cross-check against OpenAI's `cl100k_base` tokenizer. Calibration data in [`docs/REPRODUCING.md`](docs/REPRODUCING.md) shows the estimate is within ~1% of real GPT-4 tokens in aggregate across 222 sample files.
</details>
<details>
<summary><strong>Impact accuracy: 0.71 average F1 against graph-derived ground truth (recall 1.0 is a circular upper bound, not "100% recall")</strong></summary>
<br>
Blast-radius analysis recovers every file in the ground truth on all 13 evaluation commits — **but read that as an upper bound, not as "100% recall"**: in this mode the ground truth (changed files + files with call/import edges into them) is derived from the same graph the predictor traverses, so it is circular by construction. The over-prediction visible in the precision column is a deliberate trade-off: better to flag too many files than miss a broken dependency.
| Repo | Commits | Avg F1 | Avg Precision | Recall (graph-derived upper bound) |
|------|--------:|-------:|--------------:|-------:|
| httpx | 2 | 0.864 | 0.786 | 1.0 |
| fastapi | 2 | 0.834 | 0.750 | 1.0 |
| code-review-graph | 2 | 0.734 | 0.584 | 1.0 |
| express | 2 | 0.667 | 0.500 | 1.0 |
| flask | 2 | 0.628 | 0.481 | 1.0 |
| gin | 3 | 0.609 | 0.439 | 1.0 |
| **Average** | **13** | **0.714** | **0.578** | **1.000** |
The benchmark also runs an honest **co-change mode**: the predictor is seeded with a single changed file and graded against the *other* files the author actually touched in the same commit — independent-ish evidence from git history, not from the graph. Both modes appear side by side in the result CSVs (`ground_truth_mode` column). Co-change numbers will be added to the canonical stats once captured by the eval runner; we do not quote them before measuring.
</details>
<details>
<summary><strong>Build performance</strong></summary>
<br>
| Repo | Files | Nodes | Edges | Flow Detection | Search Latency |
|------|------:|------:|------:|---------------:|---------------:|
| express | 141 | 1,910 | 17,553 | 106ms | 0.7ms |
| fastapi | 1,122 | 6,285 | 27,117 | 128ms | 1.5ms |
| flask | 83 | 1,446 | 7,974 | 95ms | 0.7ms |
| gin | 99 | 1,286 | 16,762 | 111ms | 0.5ms |
| httpx | 60 | 1,253 | 7,896 | 96ms | 0.4ms |
</details>
### Limitations and known weaknesses
- **Impact "recall 1.0" is graph-derived and circular:** the historical ground truth comes from the same graph edges the predictor walks, so it is an upper bound by construction. The honest co-change mode (grade against files actually co-changed in the same commit) is measured alongside it; expect those numbers to be substantially lower.
- **Small single-file changes:** Graph context can exceed naive file reads for trivial edits (see express results above). The overhead is the structural metadata that enables multi-file analysis.
- **Search quality (MRR 0.35):** Keyword search finds the right result in the top-4 for most queries, but ranking needs improvement. Express queries return 0 hits due to module-pattern naming.
- **Flow detection (33% recall):** Only reliably detects entry points in Python repos (fastapi, httpx) where framework patterns are recognized. JavaScript and Go flow detection needs work.
- **Precision vs recall trade-off:** Impact analysis is deliberately conservative. It flags files that *might* be affected, which means some false positives in large dependency graphs.
---
## Features
| Feature | Details |
|---------|---------|
| **Incremental updates** | Re-parses only changed files. Subsequent updates complete in under 2 seconds. |
| **Broad language + notebook support** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell scripts, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed through the TypeScript parser, Jupyter/Databricks (.ipynb), and Perl XS (.xs) |
| **Blast-radius analysis** | Shows which functions, classes, and files are likely affected by a change |
| **Auto-update hooks** | Hooks and watch mode can update the graph on file saves and supported commit hooks |
| **Semantic search** | Optional vector embeddings via sentence-transformers, Google Gemini, MiniMax, or any OpenAI-compatible endpoint (real OpenAI, Azure, new-api, LiteLLM, vLLM, LocalAI) |
| **Interactive visualisation** | D3.js force-directed graph with search, community legend toggles, and degree-scaled nodes |
| **Hub & bridge detection** | Find most-connected nodes and architectural chokepoints via betweenness centrality |
| **Surprise scoring** | Detect unexpected coupling: cross-community, cross-language, peripheral-to-hub edges |
| **Knowledge gap analysis** | Identify isolated nodes, untested hotspots, thin communities, and structural weaknesses |
| **Suggested questions** | Auto-generated review questions from graph analysis (bridges, hubs, surprises) |
| **Edge confidence** | Three-tier confidence scoring (EXTRACTED/INFERRED/AMBIGUOUS) with float scores on edges |
| **Graph traversal** | Free-form BFS/DFS exploration from any node with configurable depth and token budget |
| **Export formats** | GraphML (Gephi/yEd), Neo4j Cypher, Obsidian vault with wikilinks, SVG static graph |
| **Graph diff** | Compare graph snapshots over time: new/removed nodes, edges, community changes |
| **Token benchmarking** | Measure naive full-corpus tokens vs graph query tokens with per-question ratios |
| **Estimated context savings** | Compact `context_savings` metadata on relevant MCP/CLI review outputs, labelled as estimated and kept to three small fields |
| **Memory loop** | Persist Q&A results as markdown for re-ingestion, so the graph grows from queries |
| **Community auto-split** | Oversized communities (>25% of graph) are recursively split via Leiden |
| **Execution flows** | Trace call chains from entry points, sorted by weighted criticality |
| **Community detection** | Cluster related code via Leiden algorithm with resolution scaling for large graphs |
| **Architecture overview** | Auto-generated architecture map with coupling warnings |
| **Risk-scored reviews** | `detect_changes` maps diffs to affected functions, flows, and test gaps |
| **Custom languages** | Add new languages via `.code-review-graph/languages.toml` — no fork or code changes needed |
| **GitHub Action** | Sticky risk-scored PR review comments in CI, with an optional `fail-on-risk` merge gate |
| **Refactoring tools** | Rename preview, framework-aware dead code detection, community-driven suggestions |
| **Wiki generation** | Auto-generate markdown wiki from community structure |
| **Multi-repo registry** | Register multiple repos, search across all of them |
| **Multi-repo daemon** | `crg-daemon` watches multiple repos as child processes, with health checks and auto-restart |
| **MCP prompts** | 5 workflow templates: review, architecture, debug, onboard, pre-merge |
| **Full-text search** | FTS5-powered hybrid search combining keyword and vector similarity |
| **Local storage** | SQLite file in `.code-review-graph/`. Core graph storage needs no external database or cloud service. |
| **Watch mode** | Continuous graph updates as you work |
---
## Usage
<details>
<summary><strong>Slash commands</strong></summary>
<br>
| Command | Description |
|---------|-------------|
| `/code-review-graph:build-graph` | Build or rebuild the code graph |
| `/code-review-graph:review-delta` | Review changes since last commit |
| `/code-review-graph:review-pr` | Full PR review with blast-radius analysis |
</details>
<details>
<summary><strong>CLI reference</strong></summary>
<br>
```bash
code-review-graph install # Auto-detect and configure all platforms
code-review-graph install --platform <name> # Target a specific platform
code-review-graph build # Parse entire codebase
code-review-graph update # Incremental update (changed files only)
code-review-graph status # Graph statistics
code-review-graph watch # Auto-update on file changes
code-review-graph visualize # Generate interactive HTML graph
code-review-graph visualize --format graphml # Export as GraphML
code-review-graph visualize --format svg # Export as SVG
code-review-graph visualize --format obsidian # Export as Obsidian vault
code-review-graph visualize --format cypher # Export as Neo4j Cypher
code-review-graph wiki # Generate markdown wiki from communities
code-review-graph detect-changes --brief # Risk panel + token savings (read-only)
code-review-graph update --brief # Refresh graph + same panel
code-review-graph detect-changes --brief --verify # Cross-check vs tiktoken
code-review-graph register <path> # Register repo in multi-repo registry
code-review-graph unregister <id> # Remove repo from registry
code-review-graph repos # List registered repositories
code-review-graph daemon start # Start multi-repo watch daemon
code-review-graph daemon stop # Stop the daemon
code-review-graph daemon status # Show daemon status and repos
code-review-graph eval # Run evaluation benchmarks
code-review-graph serve # Start MCP server
```
</details>
<details>
<summary><strong>Token Savings panel: <code>detect-changes --brief</code> vs <code>update --brief</code></strong></summary>
<br>
Both commands print the same compact panel showing how many tokens the
graph saved you compared to handing the changed files to an agent raw.
They differ in **one** thing: whether the graph gets refreshed first.
```text
┌─────────────────────── Token Savings ────────────────────────┐
│ Full context would be: 12,921 tokens │
│ Graph context used: 762 tokens │
│ Saved: 12,159 tokens (~94%) │
│ Breakdown: Functions 244 · Tests 191 · Risk 244 · Other 83 │
└──────────────────────────────────────────────────────────────┘
```
| Command | What it does | When to use |
|---|---|---|
| `detect-changes --brief` | **Read-only.** Looks at your current changes, queries the **existing** graph, prints the panel. ~1 sec. | Most of the time — the hooks (or `crg-daemon`) keep the graph fresh in the background, so this is enough. |
| `update --brief` | **Re-parses your changed files into the graph first**, then prints the same panel. ~5 sec. | After a rebase, a large change set, or any time you suspect the graph is stale. |
Both end with the **same panel** because both call the same `analyze_changes()` step at the end. The difference is whether the graph itself got refreshed before that analysis ran.
Add `--verify` to either command to cross-check the displayed numbers against OpenAI's `cl100k_base` tokenizer (the GPT-4 family). Requires `pip install tiktoken`. The estimate stays within ~1% of real tokens on a typical change set — see [`docs/REPRODUCING.md`](docs/REPRODUCING.md) for the calibration data.
The same `context_savings` metadata is also attached automatically to the JSON responses of `get_impact_radius`, `get_review_context`, `detect_changes`, and `get_architecture_overview` MCP tools, so AI agents can surface the savings to humans in chat without any extra prompting.
</details>
<details>
<summary><strong>Multi-repo daemon</strong></summary>
<br>
If your editor doesn't support hooks (e.g. Cursor, OpenCode), or you just want your
graph to stay fresh in the background without any editor integration, the daemon is
for you. It watches your repos for file changes and automatically rebuilds the graph
— no manual `build` or `update` commands needed.
The daemon is included with `code-review-graph` — no separate install required.
**Quick setup:**
```bash
# 1. Register the repos you want to watch
crg-daemon add ~/project-a --alias proj-a
crg-daemon add ~/project-b
# 2. Start the daemon (runs in the background)
crg-daemon start
# 3. That's it — graphs stay up to date automatically
crg-daemon status # check daemon and per-repo watcher status
crg-daemon logs --repo proj-a -f # tail logs for a specific repo
crg-daemon stop # stop daemon and all watcher processes
```
Also available as `code-review-graph daemon start|stop|status|...`.
Under the hood, `crg-daemon add` writes to a TOML config file at
`~/.code-review-graph/watch.toml`. You can also edit this file directly:
```toml
[[repos]]
path = "/home/user/project-a"
alias = "proj-a"
[[repos]]
path = "/home/user/project-b"
alias = "project-b"
```
The daemon monitors this config file for changes and automatically starts/stops
watcher processes as repos are added or removed. Health checks every 30 seconds
restart dead watchers. No external dependencies required.
See [docs/COMMANDS.md](docs/COMMANDS.md#standalone-daemon-cli-crg-daemon) for the
full config reference and all available options.
</details>
<details>
<summary><strong>30 MCP tools</strong></summary>
<br>
Your AI assistant uses these automatically once the graph is built.
| Tool | Description |
|------|-------------|
| `build_or_update_graph_tool` | Build or incrementally update the graph |
| `run_postprocess_tool` | Re-run flow detection, community detection, and FTS indexing |
| `get_minimal_context_tool` | Ultra-compact context (~100 tokens) — call this first |
| `get_impact_radius_tool` | Blast radius of changed files |
| `get_review_context_tool` | Token-optimised review context with structural summary |
| `query_graph_tool` | Callers, callees, tests, imports, inheritance queries |
| `traverse_graph_tool` | BFS/DFS traversal from any node with token budget |
| `semantic_search_nodes_tool` | Search code entities by name or meaning |
| `embed_graph_tool` | Compute vector embeddings for semantic search |
| `list_graph_stats_tool` | Graph size and health |
| `get_docs_section_tool` | Retrieve documentation sections |
| `find_large_functions_tool` | Find functions/classes exceeding a line-count threshold |
| `list_flows_tool` | List execution flows sorted by criticality |
| `get_flow_tool` | Get details of a single execution flow |
| `get_affected_flows_tool` | Find flows affected by changed files |
| `list_communities_tool` | List detected code communities |
| `get_community_tool` | Get details of a single community |
| `get_architecture_overview_tool` | Architecture overview from community structure |
| `detect_changes_tool` | Risk-scored change impact analysis for code review |
| `get_hub_nodes_tool` | Find most-connected nodes (architectural hotspots) |
| `get_bridge_nodes_tool` | Find chokepoints via betweenness centrality |
| `get_knowledge_gaps_tool` | Identify structural weaknesses and untested hotspots |
| `get_surprising_connections_tool` | Detect unexpected cross-community coupling |
| `get_suggested_questions_tool` | Auto-generated review questions from analysis |
| `refactor_tool` | Rename preview, dead code detection, suggestions |
| `apply_refactor_tool` | Apply a previously previewed refactoring |
| `generate_wiki_tool` | Generate markdown wiki from communities |
| `get_wiki_page_tool` | Retrieve a specific wiki page |
| `list_repos_tool` | List registered repositories |
| `cross_repo_search_tool` | Search across all registered repositories |
**MCP Prompts** (5 workflow templates):
`review_changes`, `architecture_map`, `debug_issue`, `onboard_developer`, `pre_merge_check`
</details>
<details>
<summary><strong>Configuration</strong></summary>
<br>
To exclude paths from indexing, create a `.code-review-graphignore` file in your repository root:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
Note: in git repos, only tracked files are indexed (`git ls-files`), so gitignored files are skipped automatically. Use `.code-review-graphignore` to exclude tracked files or when git isn't available.
Optional dependency groups:
```bash
pip install code-review-graph[embeddings] # Local vector embeddings (sentence-transformers)
pip install code-review-graph[google-embeddings] # Google Gemini embeddings
pip install code-review-graph[communities] # Community detection (igraph)
pip install code-review-graph[enrichment] # Python call-resolution enrichment (Jedi)
pip install code-review-graph[eval] # Evaluation benchmarks (matplotlib)
pip install code-review-graph[wiki] # Wiki generation with LLM summaries (ollama)
pip install code-review-graph[all] # All optional dependencies
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `CRG_GIT_TIMEOUT` | Timeout in seconds for Git operations | `30` |
| `CRG_DATA_DIR` | Override directory for graph databases and generated graph artefacts | - |
| `CRG_EMBEDDING_MODEL` | Default model for vector embeddings | `all-MiniLM-L6-v2` |
| `CRG_ACCEPT_CLOUD_EMBEDDINGS` | Suppress the cloud embedding egress warning after explicit acknowledgement | - |
| `CRG_ALLOW_REMOTE_CODE` | Allow HuggingFace models that require `trust_remote_code=True` | `0` |
| `CRG_MAX_IMPACT_NODES` | Maximum nodes to include in impact analysis | `500` |
| `CRG_MAX_IMPACT_DEPTH` | Search depth for blast-radius analysis | `2` |
| `CRG_MAX_BFS_DEPTH` | Maximum depth for graph traversal | `15` |
| `CRG_MAX_CHANGED_FUNCS` | Maximum changed functions analysed in one change report | `500` |
| `CRG_MAX_TRANSITIVE_FRONTIER` | Maximum frontier size for transitive caller/callee expansion | `50` |
| `CRG_TOOL_TIMEOUT` | Optional timeout in seconds for bounded MCP tools (`0` disables timeout) | `0` |
| `CRG_RECURSE_SUBMODULES` | Include git submodules in file collection when set to `1`, `true`, or `yes` | - |
| `CRG_TOOLS` | Comma-separated allowlist of MCP tools to expose when serving | - |
| `GOOGLE_API_KEY` | API key for Google Gemini embeddings | - |
| `MINIMAX_API_KEY` | API key for MiniMax embeddings | - |
| `CRG_OPENAI_BASE_URL` | OpenAI-compatible embeddings endpoint | - |
| `CRG_OPENAI_API_KEY` | API key for OpenAI-compatible embeddings | - |
| `CRG_OPENAI_MODEL` | Model name for OpenAI-compatible embeddings | - |
| `CRG_OPENAI_DIMENSION` | Pin embedding dimension (v3 models support reduction) | - |
| `NO_COLOR` | If set, disables ANSI colors in terminal | - |
| `CRG_SERIAL_PARSE` | If `1`, disables parallel parsing (use for debugging) | - |
OpenAI-compatible embeddings (real OpenAI, Azure, or any self-hosted gateway like
new-api / LiteLLM / vLLM / LocalAI / Ollama in openai mode) need no extra install —
just set the environment variables and pass `provider="openai"` to `embed_graph`:
```bash
export CRG_OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # or https://api.openai.com/v1
export CRG_OPENAI_API_KEY=sk-...
export CRG_OPENAI_MODEL=text-embedding-3-small # whatever your gateway serves
# optional:
export CRG_OPENAI_DIMENSION=1536 # pin dim (v3 models support reduction)
export CRG_OPENAI_BATCH_SIZE=100 # lower for gateways with tight limits
# (e.g. Qwen text-embedding-v4 caps at 10)
```
The cloud-egress warning is auto-skipped when the base URL points to localhost
(`127.0.0.1`, `localhost`, `0.0.0.0`, `::1`).
> **Model selection tip.** Avoid `-preview` / `-beta` / `-exp` model IDs
> (e.g. `google/gemini-embedding-2-preview`) for anything you plan to keep
> long-term — preview models can change weights (different dimension → full
> re-embed required) or be deprecated without notice. Prefer stable GA
> releases such as `text-embedding-3-small` / `text-embedding-3-large` (OpenAI),
> `Qwen/Qwen3-Embedding-8B` (via self-hosted vLLM / LocalAI), or
> `gemini-embedding-001` (via the native Gemini provider, which requires
> `GOOGLE_API_KEY` instead of the OpenAI-compatible path).
>
> Also note: `code-review-graph` currently embeds **function signatures only**
> (~10 tokens per node, e.g. `"parse_file function (path: str) returns Tree"`).
> Models whose headline quality comes from long-context body understanding
> (such as Gemini 2 or Qwen3-8B at their MTEB-code SOTA scores) will see a
> much narrower quality gap against smaller models at this input length.
> Body/docstring embedding is tracked as a follow-up enhancement.
#### Tool Filtering
CRG exposes 30 MCP tools by default. In token-constrained environments, you can
limit the server to a subset of tools using `--tools` or the `CRG_TOOLS`
environment variable:
```bash
# Via CLI flag
code-review-graph serve --tools query_graph_tool,semantic_search_nodes_tool,detect_changes_tool
# Via environment variable
CRG_TOOLS=query_graph_tool,semantic_search_nodes_tool code-review-graph serve
```
The CLI flag takes precedence over the environment variable. When neither is set,
all tools are available. This is especially useful for MCP client configurations:
```json
{
"mcpServers": {
"code-review-graph": {
"command": "code-review-graph",
"args": ["serve", "--tools", "query_graph_tool,semantic_search_nodes_tool,detect_changes_tool,get_review_context_tool"]
}
}
}
```
</details>
---
## FAQ & how it compares
Short, honest answers in [docs/FAQ.md](docs/FAQ.md):
- [vs LSP / language servers](docs/FAQ.md#how-is-this-different-from-lsp-and-language-servers) — one persistent cross-language graph instead of per-language daemons; LSP stays more precise per symbol.
- [vs RAG / embeddings](docs/FAQ.md#isnt-this-just-rag) — structural edges parsed from the AST, not similarity chunks; embeddings are optional and only assist search.
- [vs grep / agentic search](docs/FAQ.md#why-not-just-grep) — grep wins on one-hop lookups; the graph wins on multi-hop questions (impact radius, callers-of-callers, tests-for, affected flows).
- [vs Serena, codegraph, claude-context, repomix](docs/FAQ.md#how-does-it-compare-to-serena-codegraph-claude-context-and-repomix) — factual comparison table.
- [When NOT to use it](docs/FAQ.md#when-should-i-not-use-it) — small repos, trivial single-file diffs, one-off questions.
- [Does it phone home?](docs/FAQ.md#does-it-phone-home) — no; zero telemetry, cloud embeddings are opt-in.
- [How do I verify it is working?](docs/FAQ.md#how-do-i-verify-it-is-working) — `status`, `detect-changes --brief`, `/mcp`.
## Troubleshooting
### `pip` / `pipx` cannot download `hatchling` (or `Errno 9` / `Bad file descriptor` to PyPI)
Installing from a **source tree** (for example `pipx install .`) needs build dependencies from **PyPI** (for example `hatchling`). If you see `Could not find a version that satisfies the requirement hatchling` after connection warnings, the Python/pip in that **terminal** may not be able to open an HTTPS client to `pypi.org` (sometimes seen in an integrated editor terminal; less often system-wide with VPN, firewall, or proxy).
**Options:**
1. Run the same command from **macOS Terminal.app** (or iTerm) instead of the IDEs terminal, then retry `pipx install .` or `pipx install "git+https://..."` .
2. Use **[uv](https://docs.astral.sh/uv/)** to install the CLI from a checkout (uses different download machinery than `pip` in many cases):
```bash
cd /path/to/code-review-graph
uv tool install . --force
```
3. For **development in a clone** without a global install, use `uv sync` and `uv run code-review-graph …` (or activate `.venv` after `uv sync`).
**Diagnose (optional):** `python3 scripts/diagnose_pypi_connectivity.py` — if it prints `FAILED`, the issue is environment/network, not a wrong package name in this repo.
### Windows Configuration Issues (Invalid JSON / Connection Closed)
If you are using Windows and encounter `Invalid JSON: EOF while parsing` or `MCP error -32000: Connection closed` when connecting via Claude Code, do not use the `cmd /c` wrapper in your config.
Ensure `fastmcp` is updated to at least `3.2.4+`. Then, configure your `~/.claude.json` to execute the `.exe` directly and pass the UTF-8 environment variable via the config:
```json
"code-review-graph": {
"command": "C:\\path\\to\\your\\venv\\Scripts\\code-review-graph.exe",
"args": ["serve", "--repo", "C:\\path\\to\\your\\project"],
"env": { "PYTHONUTF8": "1" }
}
```
## Contributing
```bash
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
<details>
<summary><strong>Adding a new language</strong></summary>
<br>
Edit `code_review_graph/parser.py` and add your extension to `EXTENSION_TO_LANGUAGE` along with node type mappings in `_CLASS_TYPES`, `_FUNCTION_TYPES`, `_IMPORT_TYPES`, and `_CALL_TYPES`. Include a test fixture and open a PR.
</details>
## Licence
MIT. See [LICENSE](LICENSE).
<p align="center">
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, Kiro, GitHub Copilot, and GitHub Copilot CLI</sub>
</p>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`tirth8205/code-review-graph`
- 原始仓库:https://github.com/tirth8205/code-review-graph
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+326
View File
@@ -0,0 +1,326 @@
<h1 align="center">code-review-graph</h1>
> **注意:** 本翻译对应较早的版本;基准测试数据和平台列表可能落后于[英文 README](README.md)。
<p align="center">
<strong>不再浪费 token,让代码审查更智能。</strong>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh-CN.md">简体中文</a> |
<a href="README.ja-JP.md">日本語</a> |
<a href="README.ko-KR.md">한국어</a> |
<a href="README.hi-IN.md">हिन्दी</a>
</p>
<p align="center">
<a href="https://pypi.org/project/code-review-graph/"><img src="https://img.shields.io/pypi/v/code-review-graph?style=flat-square&color=blue" alt="PyPI"></a>
<a href="https://pepy.tech/project/code-review-graph"><img src="https://img.shields.io/pepy/dt/code-review-graph?style=flat-square" alt="Downloads"></a>
<a href="https://github.com/tirth8205/code-review-graph/stargazers"><img src="https://img.shields.io/github/stars/tirth8205/code-review-graph?style=flat-square" alt="Stars"></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="MIT Licence"></a>
<a href="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml"><img src="https://github.com/tirth8205/code-review-graph/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.10%2B-blue.svg?style=flat-square" alt="Python 3.10+"></a>
<a href="https://modelcontextprotocol.io/"><img src="https://img.shields.io/badge/MCP-compatible-green.svg?style=flat-square" alt="MCP"></a>
<a href="https://code-review-graph.com"><img src="https://img.shields.io/badge/website-code--review--graph.com-blue?style=flat-square" alt="Website"></a>
<a href="https://discord.gg/3p58KXqGFN"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"></a>
</p>
<br>
AI 编码工具在审查任务中可能会反复读取代码库的大量内容。`code-review-graph` 解决了这个问题。它使用 [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) 构建代码的结构化映射,增量跟踪变更,并通过 [MCP](https://modelcontextprotocol.io/) 为 AI 助手提供精准的上下文,使其只读取真正需要的内容。
<p align="center">
<img src="diagrams/diagram1_before_vs_after.png" alt="Token 问题:在 6 个真实仓库中实现 38 倍到 528 倍的 token 削减" width="85%" />
</p>
---
## 快速开始
```bash
pip install code-review-graph # 或: pipx install code-review-graph
code-review-graph install # 自动检测并配置所有支持的平台
code-review-graph build # 解析代码库
```
一条命令完成所有配置。`install` 会检测你安装了哪些 AI 编码工具,为每个工具写入正确的 MCP 配置,并将图感知指令注入平台规则。它会自动判断你是通过 `uvx` 还是 `pip`/`pipx` 安装的,并生成相应的配置。安装后请重启编辑器或工具。
<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="一次安装即可自动检测并配置支持的 AI 编码工具" width="85%" />
</p>
如需指定特定平台:
```bash
code-review-graph install --platform codex # 仅配置 Codex
code-review-graph install --platform cursor # 仅配置 Cursor
code-review-graph install --platform claude-code # 仅配置 Claude Code
code-review-graph install --platform kiro # 仅配置 Kiro
```
需要 Python 3.10+。为获得最佳体验,建议安装 [uv](https://docs.astral.sh/uv/)(如果可用,MCP 配置将使用 `uvx`,否则直接使用 `code-review-graph` 命令)。
然后打开项目,向 AI 助手发出指令:
```
Build the code review graph for this project
```
首次构建在 500 个文件的项目上大约需要 10 秒。此后,可通过 watch 模式以及支持的平台钩子自动更新图。
---
## 工作原理
<p align="center">
<img src="diagrams/diagram7_mcp_integration_flow.png" alt="AI 助手如何使用图:用户请求审查,AI 查询 MCP 工具,图返回影响范围和风险评分,AI 仅读取关键内容" width="80%" />
</p>
代码库通过 Tree-sitter 解析为 AST,以节点(函数、类、导入)和边(调用、继承、测试覆盖)的形式存储为图,然后在审查时查询,计算 AI 助手需要读取的最小文件集。
<p align="center">
<img src="diagrams/diagram2_architecture_pipeline.png" alt="架构流程:代码库 -> Tree-sitter 解析器 -> SQLite 图 -> 影响半径 -> 最小审查集" width="100%" />
</p>
### 影响半径分析
当文件发生变更时,图会追踪所有可能受影响的调用者、依赖项和测试。这就是变更的"影响半径"。AI 只需读取这些文件,而无需扫描整个项目。
<p align="center">
<img src="diagrams/diagram3_blast_radius.png" alt="影响半径可视化:展示 login() 的变更如何传播到调用者、依赖项和测试" width="70%" />
</p>
### 增量更新,不到 2 秒
启用钩子或 watch 模式后,文件保存和受支持的提交钩子会触发增量更新。图对变更文件做差异比较,通过 SHA-256 哈希校验找到相关依赖,仅重新解析变更部分。一个 2,900 文件的项目重新索引不到 2 秒。
<p align="center">
<img src="diagrams/diagram4_incremental_update.png" alt="增量更新流程:git 提交触发差异比较,找到依赖项,仅重新解析 5 个文件,跳过 2,910 个文件" width="90%" />
</p>
### 解决 monorepo 难题
大型 monorepo 是 token 浪费最严重的场景。图能穿透噪音——排除 27,700+ 个文件,只读取约 15 个文件。
<p align="center">
<img src="diagrams/diagram6_monorepo_funnel.png" alt="code-review-graph 仓库:208,821 个源码 token 收敛为约 2,495 token 的图响应——每个问题的 token 减少 93 倍" width="80%" />
</p>
### 广泛语言覆盖 + Jupyter 笔记本
<p align="center">
<img src="diagrams/diagram9_language_coverage.png" alt="按类别组织的语言覆盖:Web、后端、系统、移动端、脚本,外加 Jupyter/Databricks 笔记本支持" width="90%" />
</p>
解析器支持覆盖当前解析面中的函数、类、导入、调用点、继承和测试检测:能用 Tree-sitter 的地方使用 Tree-sitter,需要时使用有针对性的回退解析。支持范围包括 Python、JavaScript/TypeScript/TSX、Go、Rust、Java、C/C++、C#、Ruby、Kotlin、Swift、PHP、Scala、Solidity、Dart、R、Perl、Lua/Luau、Objective-C、shell 脚本、Elixir、Zig、PowerShell、Julia、ReScript、GDScript、Nix、Verilog/SystemVerilog、SQL、Vue/Svelte 单文件组件、按 TypeScript 解析的 Astro 文件、Jupyter/Databricks 笔记本(`.ipynb`)和 Perl XS 文件(`.xs`)。
---
## 基准测试
<p align="center">
<img src="diagrams/diagram5_benchmark_board.png" alt="对 6 个真实仓库的基准测试:token 减少 38 倍到 528 倍,影响分析召回率 100%,平均 F1 0.71" width="85%" />
</p>
所有数据来自针对 6 个真实开源仓库(共 13 次提交)的自动化评估。可通过 `code-review-graph eval --all` 复现。完整基准测试数据请参阅[英文 README](README.md)。
---
## 功能一览
| 功能 | 说明 |
|------|------|
| **增量更新** | 仅重新解析变更文件,后续更新不到 2 秒完成 |
| **广泛语言覆盖 + 笔记本** | Python, JavaScript/TypeScript/TSX, Go, Rust, Java, C/C++, C#, Ruby, Kotlin, Swift, PHP, Scala, Solidity, Dart, R, Perl, Lua/Luau, Objective-C, shell, Elixir, Zig, PowerShell, Julia, ReScript, GDScript, Nix, Verilog/SystemVerilog, SQL, Vue/Svelte SFCs, Astro files parsed as TypeScript, Jupyter/Databricks (.ipynb) |
| **影响半径分析** | 展示某次变更可能影响的函数、类和文件 |
| **自动更新钩子** | 每次文件编辑和 git 提交时自动更新图,无需手动干预 |
| **语义搜索** | 可选的向量嵌入,支持 sentence-transformers、Google Gemini、MiniMax,或任何 OpenAI 兼容端点(真实 OpenAI、Azure、new-api、LiteLLM、vLLM、LocalAI |
| **交互式可视化** | D3.js 力导向图,支持搜索、社区图例切换和按度数缩放的节点 |
| **Hub 与 Bridge 检测** | 查找连接最多的节点和通过介数中心性发现架构瓶颈 |
| **异常评分** | 检测意外耦合:跨社区、跨语言、外围到核心的边 |
| **知识缺口分析** | 识别孤立节点、未测试热点、薄弱社区和结构性弱点 |
| **智能提问** | 基于图分析(桥接点、枢纽、异常)自动生成审查问题 |
| **边置信度** | 三级置信度评分(EXTRACTED/INFERRED/AMBIGUOUS),边上附带浮点分数 |
| **图遍历** | 从任意节点进行自由 BFS/DFS 探索,可配置深度和 token 预算 |
| **导出格式** | GraphML (Gephi/yEd)、Neo4j Cypher、Obsidian 知识库(含 wikilinks)、SVG 静态图 |
| **图差异** | 比较不同时间的图快照:新增/删除的节点、边和社区变化 |
| **Token 基准测试** | 测量朴素全量 token 与图查询 token,附带逐题比率 |
| **记忆循环** | 将问答结果持久化为 Markdown 以供重新摄入,使图从查询中不断成长 |
| **社区自动分割** | 过大的社区(>图的 25%)通过 Leiden 算法递归分割 |
| **执行流** | 从入口点追踪调用链,按加权关键度排序 |
| **社区检测** | 通过 Leiden 算法聚类相关代码,大型图自动调节分辨率 |
| **架构概览** | 自动生成架构图,附带耦合警告 |
| **风险评分审查** | `detect_changes` 将差异映射到受影响的函数、执行流和测试缺口 |
| **重构工具** | 重命名预览、框架感知的死代码检测、基于社区的重构建议 |
| **Wiki 生成** | 从社区结构自动生成 Markdown Wiki |
| **多仓库注册** | 注册多个仓库,跨仓库搜索 |
| **MCP 提示模板** | 5 种工作流模板:审查、架构、调试、入职引导、合并前检查 |
| **全文搜索** | 基于 FTS5 的混合搜索,结合关键词和向量相似度 |
| **本地存储** | SQLite 文件存储在 `.code-review-graph/` 中,核心图存储无需外部数据库或云服务 |
| **监听模式** | 工作时持续更新图 |
---
## 使用方式
<details>
<summary><strong>斜杠命令</strong></summary>
<br>
| 命令 | 说明 |
|------|------|
| `/code-review-graph:build-graph` | 构建或重新构建代码图 |
| `/code-review-graph:review-delta` | 审查自上次提交以来的变更 |
| `/code-review-graph:review-pr` | 完整的 PR 审查,含影响半径分析 |
</details>
<details>
<summary><strong>CLI 参考</strong></summary>
<br>
```bash
code-review-graph install # 自动检测并配置所有平台
code-review-graph install --platform <name> # 指定特定平台
code-review-graph build # 解析整个代码库
code-review-graph update # 增量更新(仅变更文件)
code-review-graph status # 图统计信息
code-review-graph watch # 文件变更时自动更新
code-review-graph visualize # 生成交互式 HTML 图
code-review-graph visualize --format graphml # 导出为 GraphML
code-review-graph visualize --format svg # 导出为 SVG
code-review-graph visualize --format obsidian # 导出为 Obsidian 知识库
code-review-graph visualize --format cypher # 导出为 Neo4j Cypher
code-review-graph wiki # 从社区结构生成 Markdown Wiki
code-review-graph detect-changes # 风险评分的变更影响分析
code-review-graph register <path> # 将仓库注册到多仓库注册表
code-review-graph unregister <id> # 从注册表移除仓库
code-review-graph repos # 列出已注册的仓库
code-review-graph eval # 运行评估基准测试
code-review-graph serve # 启动 MCP 服务器
```
</details>
<details>
<summary><strong>30 个 MCP 工具</strong></summary>
<br>
图构建完成后,AI 助手会自动使用这些工具。
| 工具 | 说明 |
|------|------|
| `build_or_update_graph_tool` | 构建或增量更新图 |
| `run_postprocess_tool` | 重新运行执行流、社区和全文索引后处理 |
| `get_minimal_context_tool` | 超紧凑上下文(约 100 tokens)——首先调用此工具 |
| `get_impact_radius_tool` | 变更文件的影响半径 |
| `get_review_context_tool` | Token 优化的审查上下文,附带结构摘要 |
| `query_graph_tool` | 查询调用者、被调用者、测试、导入、继承关系 |
| `traverse_graph_tool` | 从任意节点进行 BFS/DFS 遍历,可设置 token 预算 |
| `semantic_search_nodes_tool` | 按名称或语义搜索代码实体 |
| `embed_graph_tool` | 计算向量嵌入以支持语义搜索 |
| `list_graph_stats_tool` | 图的规模和健康状态 |
| `get_docs_section_tool` | 获取文档章节 |
| `find_large_functions_tool` | 查找超过行数阈值的函数/类 |
| `list_flows_tool` | 列出按关键度排序的执行流 |
| `get_flow_tool` | 获取单个执行流的详情 |
| `get_affected_flows_tool` | 查找受变更文件影响的执行流 |
| `list_communities_tool` | 列出检测到的代码社区 |
| `get_community_tool` | 获取单个社区的详情 |
| `get_architecture_overview_tool` | 基于社区结构的架构概览 |
| `detect_changes_tool` | 面向代码审查的风险评分变更影响分析 |
| `get_hub_nodes_tool` | 查找连接最多的节点(架构热点) |
| `get_bridge_nodes_tool` | 通过介数中心性查找架构瓶颈 |
| `get_knowledge_gaps_tool` | 识别结构性弱点和未测试热点 |
| `get_surprising_connections_tool` | 检测意外的跨社区耦合 |
| `get_suggested_questions_tool` | 基于分析自动生成审查问题 |
| `refactor_tool` | 重命名预览、死代码检测、重构建议 |
| `apply_refactor_tool` | 应用先前预览的重构 |
| `generate_wiki_tool` | 从社区结构生成 Markdown Wiki |
| `get_wiki_page_tool` | 获取特定 Wiki 页面 |
| `list_repos_tool` | 列出已注册的仓库 |
| `cross_repo_search_tool` | 跨所有注册仓库搜索 |
**MCP 提示模板**5 种工作流模板):
`review_changes``architecture_map``debug_issue``onboard_developer``pre_merge_check`
</details>
<details>
<summary><strong>配置</strong></summary>
<br>
要排除特定路径不被索引,请在仓库根目录创建 `.code-review-graphignore` 文件:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
注意:在 git 仓库中,仅索引已跟踪的文件(`git ls-files`),因此 gitignore 中的文件会自动跳过。`.code-review-graphignore` 用于排除已跟踪的文件,或在没有 git 的环境中使用。
可选依赖组:
```bash
pip install code-review-graph[embeddings] # 本地向量嵌入 (sentence-transformers)
pip install code-review-graph[google-embeddings] # Google Gemini 嵌入
pip install code-review-graph[communities] # 社区检测 (igraph)
pip install code-review-graph[enrichment] # Python 调用解析增强 (Jedi)
pip install code-review-graph[eval] # 评估基准测试 (matplotlib)
pip install code-review-graph[wiki] # 使用 LLM 摘要生成 Wiki (ollama)
pip install code-review-graph[all] # 所有可选依赖
```
OpenAI 兼容嵌入(真实 OpenAI、Azure,或自建网关如 new-api / LiteLLM / vLLM / LocalAI / Ollama openai 模式)无需额外安装 —— 只需设置环境变量并在 `embed_graph` 中传入 `provider="openai"`
```bash
export CRG_OPENAI_BASE_URL=http://127.0.0.1:3000/v1 # 或 https://api.openai.com/v1
export CRG_OPENAI_API_KEY=sk-...
export CRG_OPENAI_MODEL=text-embedding-3-small # 取决于你网关提供的模型
# 可选:
export CRG_OPENAI_DIMENSION=1536 # 固定维度(v3 模型支持维度缩减)
export CRG_OPENAI_BATCH_SIZE=100 # 某些网关有更严格的批次限制时下调
# (如 Qwen text-embedding-v4 上限为 10
```
当 base URL 指向 localhost`127.0.0.1``localhost``0.0.0.0``::1`)时,会自动跳过云出口警告。
> **模型选择提示。** 避免用 `-preview` / `-beta` / `-exp` 结尾的 model ID(例如 `google/gemini-embedding-2-preview`)做长期使用——preview 模型可能更换权重(维度一变就要全量 re-embed)或被无预警下架。建议改用正式 GA 模型:`text-embedding-3-small` / `text-embedding-3-large`OpenAI)、`Qwen/Qwen3-Embedding-8B`(经 vLLM / LocalAI 自宿主)、或 `gemini-embedding-001`(经原生 Gemini provider,需要 `GOOGLE_API_KEY`)。
>
> 另外请注意:目前 `code-review-graph` 只嵌入**函数签名**(每节点约 10 tokens,例如 `"parse_file function (path: str) returns Tree"`)。那些靠长 context 理解函数 body 来拉开差距的模型(Gemini 2 或 Qwen3-8B 在 MTEB-code 的 SOTA 分数)在这个输入长度下跟小模型的品质差距会小很多。Body / docstring 嵌入已列为后续增强任务。
</details>
---
## 参与贡献
```bash
git clone https://github.com/tirth8205/code-review-graph.git
cd code-review-graph
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
<details>
<summary><strong>添加新语言支持</strong></summary>
<br>
编辑 `code_review_graph/parser.py`,将你的文件扩展名添加到 `EXTENSION_TO_LANGUAGE`,并在 `_CLASS_TYPES``_FUNCTION_TYPES``_IMPORT_TYPES``_CALL_TYPES` 中添加节点类型映射。附上测试用例文件,然后提交 PR。
</details>
## 许可证
MIT。详见 [LICENSE](LICENSE)。
<p align="center">
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>自动检测并配置支持的 AI 编码工具,包括 Codex、Claude Code、Cursor、Windsurf、Zed、Continue、OpenCode、Antigravity、Gemini CLI、Qwen、Kiro、Qoder 和 GitHub Copilot</sub>
</p>
+62
View File
@@ -0,0 +1,62 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| 2.3.x | Yes |
| < 2.3 | No |
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do NOT open a public GitHub issue**
2. Use [GitHub private vulnerability reporting](https://github.com/tirth8205/code-review-graph/security/advisories/new)
— the "Report a vulnerability" button under the repository's **Security** tab. This is the
canonical reporting channel.
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will acknowledge receipt within 48 hours and aim to release a fix within 7 days for critical issues.
## Security Model
### Threat Surface
code-review-graph is a **local development tool**. It:
- Runs as a local MCP server via stdio or localhost-bound streamable HTTP
- Stores data in a local SQLite database (`.code-review-graph/graph.db`)
- Makes no network calls during normal graph and review workflows
- Only reads source files within the validated repository root
### Mitigations
| Vector | Mitigation |
|--------|------------|
| SQL Injection | All queries use parameterized `?` placeholders |
| Path Traversal | `_validate_repo_root()` requires `.git`, `.svn`, or `.code-review-graph` directory |
| Prompt Injection | `_sanitize_name()` strips control characters, caps at 256 chars |
| XSS (visualization) | `escH()` escapes HTML entities; `</script>` escaped in JSON |
| Subprocess Injection | No `shell=True`; all git commands use list arguments |
| Supply Chain | Dependencies pinned with upper bounds; `uv.lock` has SHA256 hashes |
| CDN Tampering | D3.js loaded with Subresource Integrity (SRI) hash |
| API Key Leakage | Cloud embedding credentials are loaded from environment/configuration only, never hardcoded |
### Optional Network Calls
- **Cloud embeddings**: Only when explicitly configured with OpenAI-compatible, Google Gemini, or MiniMax providers. Cloud providers emit an egress warning unless `CRG_ACCEPT_CLOUD_EMBEDDINGS=1` is set.
- **Local embeddings model download**: One-time download from HuggingFace on first use of `sentence-transformers`
- **D3.js CDN**: Visualization HTML loads D3.js v7 from `d3js.org` (with SRI verification)
## Security Scanning
The CI pipeline runs:
- **Bandit** security scanner on every PR
- **Ruff** linter for code quality
- **mypy** type checker
Bandit exemptions are documented in `pyproject.toml` with justifications for each skip.
+127
View File
@@ -0,0 +1,127 @@
# Composite GitHub Action: risk-scored, graph-aware PR review comments.
# Local-first — the analysis runs entirely on the runner; no source code is
# sent to any external service. See docs/GITHUB_ACTION.md for usage.
name: "code-review-graph PR Review"
description: >-
Post a risk-scored, graph-aware review comment on pull requests.
Local-first: builds a Tree-sitter knowledge graph on the runner and
analyzes change impact without sending code to external services.
author: "Tirth"
branding:
icon: "git-pull-request"
color: "purple"
inputs:
github-token:
description: >-
Token used to post the sticky PR comment via the GitHub API.
Needs `pull-requests: write` (the default GITHUB_TOKEN works).
required: true
comment:
description: "Post (and keep updated) a sticky PR comment with the report."
required: false
default: "true"
fail-on-risk:
description: >-
Fail the job when the overall risk score reaches this level:
none (never fail), high (risk >= 0.70), or critical (risk >= 0.85).
required: false
default: "none"
python-version:
description: "Python version used to run code-review-graph."
required: false
default: "3.12"
runs:
using: "composite"
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install code-review-graph
shell: bash
run: python -m pip install --quiet code-review-graph
# Cache the SQLite knowledge graph between runs. The "schema9" segment
# tracks LATEST_VERSION in code_review_graph/migrations.py — bump it when
# the database schema changes so stale caches are not restored.
- name: Cache knowledge graph
uses: actions/cache@v4
with:
path: .code-review-graph
key: code-review-graph-schema9-${{ runner.os }}-${{ hashFiles('**/uv.lock', '**/poetry.lock', '**/requirements*.txt', '**/Pipfile.lock', '**/package-lock.json', '**/pnpm-lock.yaml', '**/yarn.lock', '**/go.sum', '**/Cargo.lock', '**/Gemfile.lock', '**/composer.lock') }}
restore-keys: |
code-review-graph-schema9-${{ runner.os }}-
- name: Resolve diff base
shell: bash
env:
BASE_REF: ${{ github.base_ref }}
run: |
if [ -n "${BASE_REF}" ]; then
git fetch --no-tags --depth=1 origin \
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
echo "CRG_BASE=origin/${BASE_REF}" >> "${GITHUB_ENV}"
else
# Not a pull_request event — fall back to the previous commit.
echo "CRG_BASE=HEAD~1" >> "${GITHUB_ENV}"
fi
- name: Build or update the graph
shell: bash
run: |
if [ -f .code-review-graph/graph.db ]; then
# Cache hit: re-parse only the files that differ from the base ref.
# If the restored database is unusable, fall back to a full build.
code-review-graph update --base "${CRG_BASE}" || code-review-graph build
else
code-review-graph build
fi
- name: Run risk-scored change analysis
shell: bash
run: |
code-review-graph detect-changes --base "${CRG_BASE}" \
> "${RUNNER_TEMP}/crg-report.json"
- name: Render markdown report
shell: bash
run: |
python "${GITHUB_ACTION_PATH}/scripts/render_pr_comment.py" \
--input "${RUNNER_TEMP}/crg-report.json" \
--output "${RUNNER_TEMP}/crg-comment.md"
- name: Upsert sticky PR comment
if: ${{ inputs.comment == 'true' && github.event_name == 'pull_request' }}
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
marker='<!-- code-review-graph-report -->'
comment_id=$(gh api \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--paginate \
--jq ".[] | select(.body | contains(\"${marker}\")) | .id" | head -n 1)
if [ -n "${comment_id}" ]; then
gh api --method PATCH --silent \
"repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" \
-F body=@"${RUNNER_TEMP}/crg-comment.md"
else
gh api --method POST --silent \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
-F body=@"${RUNNER_TEMP}/crg-comment.md"
fi
- name: Enforce risk gate
if: ${{ inputs.fail-on-risk != 'none' }}
shell: bash
env:
FAIL_ON_RISK: ${{ inputs.fail-on-risk }}
run: |
python "${GITHUB_ACTION_PATH}/scripts/render_pr_comment.py" \
--input "${RUNNER_TEMP}/crg-report.json" \
--fail-on-risk "${FAIL_ON_RISK}" \
--quiet
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
out/
*.vsix
+10
View File
@@ -0,0 +1,10 @@
src/**
test/**
node_modules/**
.vscode/**
*.ts
!*.d.ts
.gitignore
tsconfig.json
esbuild.mjs
**/*.map
+47
View File
@@ -0,0 +1,47 @@
# Changelog
## 0.2.2 - 2026-04-11
### Fixed
- Compatible with VS Code 1.115 / Electron 39 by updating the extension SQLite dependency stack.
## 0.2.1 — 2026-04-08
### Fixed
- Compatible with Python backend schema v6 (no extension-side schema changes in this release)
## 0.2.0 — 2026-03-20
### Added
- **Query Graph** command with 8 query patterns (callers_of, callees_of, imports_of, etc.)
- **Find Callees** command to trace all functions called by a target
- **Find Large Functions** command to identify oversized functions/classes
- **Compute Embeddings** command to generate vector embeddings
- **Watch Mode** command for continuous graph updates
- Cursor-aware resolution for blast radius and navigation commands
- Fuzzy fallback search when exact node matches fail
- SCM decorations for git-aware file status
### Changed
- Updated README with complete command table (13 commands)
- All 13 commands now documented
## 0.1.1 — 2026-03-17
### Fixed
- CLI path setting scoped to `machine` level (security fix)
- Secure nonce generation using `crypto.randomBytes()`
## 0.1.0 — 2026-03-17
Initial release.
- Code Graph tree view with file, class, function, type, and test nodes
- Interactive D3.js graph visualisation in a webview panel
- Blast radius analysis from cursor position
- Find callers and find tests commands
- Search across all graph nodes
- Review changes with git-aware impact analysis
- Auto-update graph on file save
- CLI auto-detection and guided installation
- Getting Started walkthrough
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tirth Kanani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
# Code Review Graph for VS Code
Visualize code dependencies, blast radius, and review context from your code graph -- directly in VS Code.
## Features
- **Code Graph Explorer** -- Browse files, classes, functions, and their relationships in a tree view
- **Blast Radius** -- See which files and symbols are impacted when you change code
- **Review Changes** -- Automatically detect git changes and show their blast radius
- **Find Callers / Callees** -- Trace all callers or callees of any function
- **Find Tests** -- Locate tests for any symbol
- **Query Graph** -- Run semantic queries (callers, callees, imports, inheritance, tests) with 8 patterns
- **Find Large Functions** -- Identify functions or classes exceeding a line-count threshold
- **Interactive Graph** -- Force-directed D3.js visualization of your code dependencies
- **Live Search** -- Fuzzy search across your entire code graph with instant results
- **Compute Embeddings** -- Generate vector embeddings for semantic search
- **Watch Mode** -- Continuous graph updates as you work
- **Auto-Update** -- Graph rebuilds in the background when you save files
## Quick Start
### 1. Install the Extension
Install **Code Review Graph** from the VS Code Marketplace.
### 2. Install the Backend
The extension requires the `code-review-graph` Python CLI to parse your codebase.
```bash
# Recommended
uv pip install code-review-graph
# Alternatives
pipx install code-review-graph
pip install code-review-graph
```
Requires Python 3.10+.
### 3. Build Your Graph
Open the Command Palette (`Ctrl+Shift+P`) and run **Code Graph: Build Graph**.
The graph database is stored locally at `.code-review-graph/graph.db` and updates automatically on file save.
## Commands
| Command | Description |
|---|---|
| `Code Graph: Build Graph` | Parse the codebase and create the graph database |
| `Code Graph: Update Graph` | Incrementally update the graph |
| `Code Graph: Show Blast Radius` | Show the blast radius for a symbol |
| `Code Graph: Review Changes` | Analyze git changes and show impacted files |
| `Code Graph: Find Callers` | Find all callers of a function |
| `Code Graph: Find Callees` | Find all functions called by a target |
| `Code Graph: Find Tests` | Find tests for a symbol |
| `Code Graph: Find Large Functions` | Find functions/classes exceeding a size threshold |
| `Code Graph: Query Graph` | Run semantic queries (8 patterns: callers_of, callees_of, etc.) |
| `Code Graph: Search` | Search the code graph |
| `Code Graph: Show Graph` | Open the interactive graph visualization |
| `Code Graph: Compute Embeddings` | Generate vector embeddings for semantic search |
| `Code Graph: Watch Mode` | Run graph in watch mode for continuous updates |
## Settings
| Setting | Default | Description |
|---|---|---|
| `codeReviewGraph.cliPath` | `""` | Path to the CLI binary. Leave empty to use the bundled version or one found on `PATH`. |
| `codeReviewGraph.autoUpdate` | `true` | Auto-update the graph on file save. |
| `codeReviewGraph.blastRadiusDepth` | `2` | Max traversal depth for blast radius (1--10). |
| `codeReviewGraph.graphTheme` | `"auto"` | Graph color theme: `auto`, `light`, or `dark`. |
| `codeReviewGraph.graph.maxNodes` | `500` | Max nodes in the graph visualization (10--5000). |
| `codeReviewGraph.graph.defaultEdges` | All except CONTAINS | Edge types shown by default. |
| `codeReviewGraph.treeView.showFiles` | `true` | Show file nodes in the tree view. |
| `codeReviewGraph.treeView.showClasses` | `true` | Show class nodes in the tree view. |
| `codeReviewGraph.treeView.showFunctions` | `true` | Show function nodes in the tree view. |
| `codeReviewGraph.treeView.showTypes` | `true` | Show type nodes in the tree view. |
| `codeReviewGraph.treeView.showTests` | `true` | Show test nodes in the tree view. |
## Requirements
- VS Code 1.85+
- Python 3.10+ (for the backend CLI)
- A workspace with source code to analyze
## Links
- [Main Repository](https://github.com/tirth8205/code-review-graph)
- [Report an Issue](https://github.com/tirth8205/code-review-graph/issues)
## License
MIT
+51
View File
@@ -0,0 +1,51 @@
import * as esbuild from "esbuild";
const isWatch = process.argv.includes("--watch");
const isProduction = process.argv.includes("--production");
/** @type {esbuild.BuildOptions} */
const extensionConfig = {
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
external: ["vscode", "better-sqlite3"],
format: "cjs",
platform: "node",
target: "node18",
sourcemap: !isProduction,
minify: isProduction,
logLevel: "info",
};
/** @type {esbuild.BuildOptions} */
const webviewConfig = {
entryPoints: ["src/webview/graph.ts"],
bundle: true,
outfile: "dist/webview/graph.js",
format: "iife",
platform: "browser",
target: "es2022",
sourcemap: !isProduction,
minify: isProduction,
logLevel: "info",
};
async function main() {
if (isWatch) {
const extensionCtx = await esbuild.context(extensionConfig);
const webviewCtx = await esbuild.context(webviewConfig);
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
console.log("[watch] Build started. Watching for changes...");
} else {
await Promise.all([
esbuild.build(extensionConfig),
esbuild.build(webviewConfig),
]);
console.log("Build complete.");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- edges -->
<line x1="6" y1="6" x2="18" y2="6" />
<line x1="6" y1="6" x2="6" y2="18" />
<line x1="18" y1="6" x2="12" y2="18" />
<line x1="6" y1="18" x2="12" y2="18" />
<!-- nodes -->
<circle cx="6" cy="6" r="2.5" fill="currentColor" />
<circle cx="18" cy="6" r="2.5" fill="currentColor" />
<circle cx="6" cy="18" r="2.5" fill="currentColor" />
<circle cx="12" cy="18" r="2.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,7 @@
## Build Your Graph
Click the button above to parse your codebase and create a knowledge graph.
This usually takes ~10 seconds for a 500-file project. The graph will be stored locally in `.code-review-graph/graph.db`.
After the initial build, the graph updates automatically when you save files.
@@ -0,0 +1,10 @@
## Explore Your Code
Open the **Code Graph** panel in the activity bar to browse your codebase structure.
**Try these commands** (Ctrl+Shift+P):
- **Code Graph: Show Blast Radius** -- See what's impacted when you change code
- **Code Graph: Find Callers** -- Find all callers of a function
- **Code Graph: Find Tests** -- Find tests for any function
- **Code Graph: Search** -- Search across your entire code graph
- **Code Graph: Show Graph** -- Open the interactive graph visualization
@@ -0,0 +1,17 @@
## Install the Backend
code-review-graph needs a Python backend to parse your codebase.
**Requirements:** Python 3.10+
**Recommended:** Install via [uv](https://docs.astral.sh/uv/):
```bash
uv pip install code-review-graph
```
**Alternatives:**
```bash
pipx install code-review-graph
# or
pip install code-review-graph
```
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
{
"name": "code-review-graph",
"displayName": "Code Review Graph",
"description": "Visualize code dependencies, blast radius, and review context from your code-review-graph database directly in VS Code.",
"version": "0.2.2",
"publisher": "tirth8205",
"license": "MIT",
"icon": "media/icons/icon.png",
"keywords": [
"code analysis",
"graph",
"visualization",
"dependencies",
"blast radius",
"code review",
"tree-sitter"
],
"repository": {
"type": "git",
"url": "https://github.com/tirth8205/code-review-graph"
},
"engines": {
"vscode": "^1.85.0"
},
"extensionKind": [
"workspace"
],
"categories": [
"Visualization",
"Other"
],
"activationEvents": [
"workspaceContains:.code-review-graph/graph.db",
"onCommand:codeReviewGraph.*",
"onView:codeReviewGraph.*"
],
"main": "./dist/extension.js",
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "codeReviewGraph",
"title": "Code Graph",
"icon": "media/icons/graph.svg"
}
]
},
"views": {
"codeReviewGraph": [
{
"id": "codeReviewGraph.codeGraph",
"name": "Code Graph"
},
{
"id": "codeReviewGraph.blastRadius",
"name": "Blast Radius"
},
{
"id": "codeReviewGraph.stats",
"name": "Stats"
}
]
},
"commands": [
{
"command": "codeReviewGraph.showBlastRadius",
"title": "Code Graph: Show Blast Radius",
"icon": "$(pulse)"
},
{
"command": "codeReviewGraph.findCallers",
"title": "Code Graph: Find Callers",
"icon": "$(references)"
},
{
"command": "codeReviewGraph.findTests",
"title": "Code Graph: Find Tests",
"icon": "$(beaker)"
},
{
"command": "codeReviewGraph.queryGraph",
"title": "Code Graph: Query Graph",
"icon": "$(symbol-keyword)"
},
{
"command": "codeReviewGraph.findCallees",
"title": "Code Graph: Find Callees",
"icon": "$(call-outgoing)"
},
{
"command": "codeReviewGraph.findLargeFunctions",
"title": "Code Graph: Find Large Functions",
"icon": "$(warning)"
},
{
"command": "codeReviewGraph.showGraph",
"title": "Code Graph: Show Graph",
"icon": "$(type-hierarchy)"
},
{
"command": "codeReviewGraph.search",
"title": "Code Graph: Search",
"icon": "$(search)"
},
{
"command": "codeReviewGraph.reviewChanges",
"title": "Code Graph: Review Changes",
"icon": "$(git-compare)"
},
{
"command": "codeReviewGraph.embedGraph",
"title": "Code Graph: Compute Embeddings",
"icon": "$(sparkle)"
},
{
"command": "codeReviewGraph.watchGraph",
"title": "Code Graph: Watch Mode",
"icon": "$(eye)"
},
{
"command": "codeReviewGraph.buildGraph",
"title": "Code Graph: Build Graph",
"icon": "$(database)"
},
{
"command": "codeReviewGraph.updateGraph",
"title": "Code Graph: Update Graph",
"icon": "$(sync)"
}
],
"menus": {
"scm/title": [
{
"command": "codeReviewGraph.reviewChanges",
"group": "navigation",
"when": "scmProvider == git"
}
],
"view/item/context": [
{
"command": "codeReviewGraph.showBlastRadius",
"when": "viewItem =~ /node-/",
"group": "codeGraph@1"
},
{
"command": "codeReviewGraph.findCallers",
"when": "viewItem =~ /node-/",
"group": "codeGraph@2"
},
{
"command": "codeReviewGraph.findCallees",
"when": "viewItem =~ /node-/",
"group": "codeGraph@3"
},
{
"command": "codeReviewGraph.findTests",
"when": "viewItem =~ /node-/",
"group": "codeGraph@4"
},
{
"command": "codeReviewGraph.showGraph",
"when": "viewItem =~ /node-/",
"group": "codeGraph@5"
}
]
},
"configuration": {
"title": "Code Review Graph",
"properties": {
"codeReviewGraph.cliPath": {
"type": "string",
"default": "",
"scope": "machine",
"description": "Path to the code-review-graph CLI binary. Leave empty to use the bundled version or the one found on PATH."
},
"codeReviewGraph.autoUpdate": {
"type": "boolean",
"default": true,
"description": "Automatically update the graph database when files are saved."
},
"codeReviewGraph.blastRadiusDepth": {
"type": "number",
"default": 2,
"minimum": 1,
"maximum": 10,
"description": "Maximum depth for blast radius traversal."
},
"codeReviewGraph.graphTheme": {
"type": "string",
"default": "auto",
"enum": [
"auto",
"light",
"dark"
],
"description": "Color theme for the graph visualization. 'auto' follows the VS Code theme."
},
"codeReviewGraph.treeView.showFunctions": {
"type": "boolean",
"default": true,
"description": "Show function nodes in the tree view."
},
"codeReviewGraph.treeView.showClasses": {
"type": "boolean",
"default": true,
"description": "Show class nodes in the tree view."
},
"codeReviewGraph.treeView.showFiles": {
"type": "boolean",
"default": true,
"description": "Show file nodes in the tree view."
},
"codeReviewGraph.treeView.showTypes": {
"type": "boolean",
"default": true,
"description": "Show type nodes in the tree view."
},
"codeReviewGraph.treeView.showTests": {
"type": "boolean",
"default": true,
"description": "Show test nodes in the tree view."
},
"codeReviewGraph.graph.defaultEdges": {
"type": "array",
"default": [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"TESTED_BY",
"DEPENDS_ON"
],
"items": {
"type": "string",
"enum": [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"CONTAINS",
"TESTED_BY",
"DEPENDS_ON"
]
},
"description": "Edge types shown by default in the graph visualization."
},
"codeReviewGraph.graph.maxNodes": {
"type": "number",
"default": 500,
"minimum": 10,
"maximum": 5000,
"description": "Maximum number of nodes to display in the graph visualization."
}
}
},
"walkthroughs": [
{
"id": "codeReviewGraph.welcome",
"title": "Get Started with Code Review Graph",
"description": "Build a code graph and explore your codebase visually.",
"steps": [
{
"id": "codeReviewGraph.welcome.install",
"title": "Install the CLI",
"description": "Install the code-review-graph CLI tool to build your graph database.\n\n[Install CLI](command:codeReviewGraph.walkthrough.install)",
"media": {
"markdown": "media/walkthrough/install.md"
}
},
{
"id": "codeReviewGraph.welcome.build",
"title": "Build Your Graph",
"description": "Run the build command to analyze your codebase and create a graph database.\n\n[Build Graph](command:codeReviewGraph.buildGraph)",
"media": {
"markdown": "media/walkthrough/build.md"
}
},
{
"id": "codeReviewGraph.welcome.explore",
"title": "Explore the Graph",
"description": "Open the Code Graph panel in the activity bar to explore your code's structure, blast radius, and review context.\n\n[Open Code Graph](command:codeReviewGraph.walkthrough.explore)",
"media": {
"markdown": "media/walkthrough/explore.md"
}
}
]
}
]
},
"scripts": {
"compile": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"package": "vsce package",
"lint": "tsc --noEmit",
"test": "node --experimental-vm-modules node_modules/@vscode/test-electron/out/runTest.js"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/d3": "^7.4.3",
"@types/node": "^20.11.0",
"@types/vscode": "^1.85.0",
"@vscode/test-electron": "^2.3.8",
"@vscode/vsce": "^2.22.0",
"esbuild": "^0.20.0",
"typescript": "^5.3.3"
},
"dependencies": {
"better-sqlite3": "^12.4.1",
"d3": "^7.9.0"
}
}
+220
View File
@@ -0,0 +1,220 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import * as vscode from 'vscode';
const execFileAsync = promisify(execFile);
const CLI_TIMEOUT_MS = 60_000;
const INSTALL_TIMEOUT_MS = 120_000;
export interface CliResult {
success: boolean;
stdout: string;
stderr: string;
}
export class CliWrapper {
private readonly cliPath: string;
constructor() {
this.cliPath = this.getCliPath();
}
/**
* Check whether the CLI binary is reachable.
*/
async isInstalled(): Promise<boolean> {
try {
await execFileAsync(this.cliPath, ['--version'], { timeout: 10_000 });
return true;
} catch (err: unknown) {
if (isEnoent(err)) {
return false;
}
// Non-zero exit or other transient error — treat as not installed.
return false;
}
}
/**
* Return the CLI version string, or undefined when the CLI is not available.
*/
async getVersion(): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync(this.cliPath, ['--version'], {
timeout: 10_000,
});
return stdout.trim();
} catch {
return undefined;
}
}
/**
* Build (or fully rebuild) the graph database for a workspace.
*/
async buildGraph(
workspaceRoot: string,
options?: { fullRebuild?: boolean },
): Promise<CliResult> {
const args = ['build'];
if (options?.fullRebuild) {
args.push('--full');
}
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Review Graph: Building graph\u2026',
cancellable: false,
},
() => this.exec(args, workspaceRoot),
);
}
/**
* Incrementally update the graph database for a workspace.
*/
async updateGraph(workspaceRoot: string): Promise<CliResult> {
return this.exec(['update'], workspaceRoot);
}
/**
* Start the watch daemon for continuous file monitoring.
*/
async watchGraph(workspaceRoot: string): Promise<CliResult> {
return this.exec(['watch'], workspaceRoot);
}
/**
* Compute embeddings for all graph nodes.
*/
async embedGraph(workspaceRoot: string): Promise<CliResult> {
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Review Graph: Computing embeddings\u2026',
cancellable: false,
},
() => this.exec(['embed'], workspaceRoot),
);
}
/**
* Detect which Python package installer is available on the system.
* Checks in preference order: uv, pipx, pip3.
*/
async detectPythonInstaller(): Promise<'uv' | 'pipx' | 'pip' | null> {
const candidates: Array<{ bin: string; result: 'uv' | 'pipx' | 'pip' }> = [
{ bin: 'uv', result: 'uv' },
{ bin: 'pipx', result: 'pipx' },
{ bin: 'pip3', result: 'pip' },
];
for (const { bin, result } of candidates) {
try {
await execFileAsync(bin, ['--version'], { timeout: 10_000 });
return result;
} catch {
// Not found or errored — try next.
}
}
return null;
}
/**
* Install the `code-review-graph` package using the specified installer.
*/
async installBackend(installer: 'uv' | 'pipx' | 'pip'): Promise<CliResult> {
const commandMap: Record<typeof installer, { bin: string; args: string[] }> = {
uv: { bin: 'uv', args: ['pip', 'install', 'code-review-graph'] },
pipx: { bin: 'pipx', args: ['install', 'code-review-graph'] },
pip: { bin: 'pip3', args: ['install', 'code-review-graph'] },
};
const { bin, args } = commandMap[installer];
return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Code Review Graph: Installing via ${installer}\u2026`,
cancellable: false,
},
async () => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, {
timeout: INSTALL_TIMEOUT_MS,
});
return { success: true, stdout, stderr };
} catch (err: unknown) {
return toCliResult(err);
}
},
);
}
// ------------------------------------------------------------------ private
private getCliPath(): string {
const configured = vscode.workspace
.getConfiguration('codeReviewGraph')
.get<string>('cliPath', '');
return configured || 'code-review-graph';
}
/**
* Execute the CLI with the given arguments inside `cwd`.
*/
private async exec(args: string[], cwd?: string): Promise<CliResult> {
try {
const { stdout, stderr } = await execFileAsync(this.cliPath, args, {
cwd,
timeout: CLI_TIMEOUT_MS,
});
return { success: true, stdout, stderr };
} catch (err: unknown) {
return toCliResult(err);
}
}
}
// --------------------------------------------------------------------- helpers
interface ExecError {
code?: string | number;
killed?: boolean;
stdout?: string;
stderr?: string;
message?: string;
}
function isEnoent(err: unknown): boolean {
return (err as ExecError)?.code === 'ENOENT';
}
function toCliResult(err: unknown): CliResult {
const e = err as ExecError;
if (isEnoent(err)) {
return {
success: false,
stdout: '',
stderr: 'CLI binary not found. Is code-review-graph installed?',
};
}
if (e.killed) {
return {
success: false,
stdout: e.stdout ?? '',
stderr: 'Command timed out.',
};
}
return {
success: false,
stdout: e.stdout ?? '',
stderr: e.stderr ?? e.message ?? 'Unknown error',
};
}
@@ -0,0 +1,595 @@
/**
* Read-only SQLite reader for the code-review-graph database.
*
* Opens the database created by the Python backend and provides typed
* query methods. All writes are performed by the Python side; this
* module never mutates the database.
*
* Uses `better-sqlite3` with prepared statements for performance.
*/
import type BetterSqlite3 from 'better-sqlite3';
type DatabaseType = BetterSqlite3.Database;
// Load better-sqlite3 with graceful error handling for ABI mismatches.
// On WSL or mismatched Node.js versions, the native module may fail to load.
// better-sqlite3 uses `export =` so we import the value via require() and
// type it as the DatabaseConstructor.
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
const isAbiMismatch = msg.includes('NODE_MODULE_VERSION')
|| msg.includes('was compiled against')
|| msg.includes('not a valid Win32');
if (isAbiMismatch) {
console.error(
'[code-review-graph] better-sqlite3 ABI mismatch. '
+ 'Your VS Code uses a different Node.js version than the one '
+ 'this extension was built for. '
+ 'Try: cd ~/.vscode/extensions/code-review-graph-* && npm rebuild better-sqlite3'
);
}
throw err;
}
// ---------------------------------------------------------------------------
// Interfaces
// ---------------------------------------------------------------------------
export type NodeKind = 'File' | 'Class' | 'Function' | 'Type' | 'Test';
export type EdgeKind =
| 'CALLS'
| 'IMPORTS_FROM'
| 'INHERITS'
| 'IMPLEMENTS'
| 'CONTAINS'
| 'TESTED_BY'
| 'DEPENDS_ON';
export interface GraphNode {
id: number;
kind: NodeKind;
name: string;
qualifiedName: string;
filePath: string;
lineStart: number | null;
lineEnd: number | null;
language: string | null;
parentName: string | null;
params: string | null;
returnType: string | null;
modifiers: string | null;
isTest: boolean;
fileHash: string | null;
}
export interface GraphEdge {
id: number;
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
filePath: string;
line: number;
}
export interface GraphStats {
totalNodes: number;
totalEdges: number;
nodesByKind: Record<string, number>;
edgesByKind: Record<string, number>;
languages: string[];
filesCount: number;
lastUpdated: string | null;
embeddingsCount: number;
}
export interface ImpactRadius {
changedNodes: GraphNode[];
impactedNodes: GraphNode[];
impactedFiles: string[];
edges: GraphEdge[];
}
// ---------------------------------------------------------------------------
// Raw row types returned by better-sqlite3
// ---------------------------------------------------------------------------
interface NodeRow {
id: number;
kind: string;
name: string;
qualified_name: string;
file_path: string;
line_start: number | null;
line_end: number | null;
language: string | null;
parent_name: string | null;
params: string | null;
return_type: string | null;
modifiers: string | null;
is_test: number;
file_hash: string | null;
extra: string;
updated_at: number;
}
interface EdgeRow {
id: number;
kind: string;
source_qualified: string;
target_qualified: string;
file_path: string;
line: number;
extra: string;
updated_at: number;
}
interface CountRow {
cnt: number;
}
interface KindCountRow {
kind: string;
cnt: number;
}
interface LanguageRow {
language: string;
}
interface FilePathRow {
file_path: string;
}
interface MetadataRow {
value: string;
}
// ---------------------------------------------------------------------------
// SqliteReader
// ---------------------------------------------------------------------------
const MAX_OPEN_RETRIES = 3;
const RETRY_BACKOFF_MS = 100;
export class SqliteReader {
private db: DatabaseType | null = null;
/**
* Create a SqliteReader with retry logic that does not block the event loop.
* Prefer this over the constructor when calling from async code.
*/
static async create(dbPath: string): Promise<SqliteReader> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_OPEN_RETRIES; attempt++) {
try {
return new SqliteReader(dbPath);
} catch (err) {
lastError = err;
if (attempt < MAX_OPEN_RETRIES - 1) {
await new Promise(resolve =>
setTimeout(resolve, RETRY_BACKOFF_MS * (attempt + 1))
);
}
}
}
throw lastError;
}
constructor(dbPath: string) {
this.db = new Database(dbPath, { readonly: true });
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
}
/**
* Check if the database schema is compatible with this extension version.
* Returns a warning message if incompatible, or undefined if OK.
*/
checkSchemaCompatibility(): string | undefined {
if (!this.db) { return 'Database is not open'; }
try {
// Check that required tables exist
const tables = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as Array<{ name: string }>;
const tableNames = new Set(tables.map((t) => t.name));
if (!tableNames.has('nodes') || !tableNames.has('edges')) {
return 'Database is missing required tables (nodes/edges). Rebuild required.';
}
// Check for schema_version in metadata if it exists
if (tableNames.has('metadata')) {
const row = this.db
.prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
.get() as { value: string } | undefined;
if (row) {
const version = parseInt(row.value, 10);
// Must match LATEST_VERSION in code_review_graph/migrations.py
const SUPPORTED_SCHEMA_VERSION = 9;
if (!isNaN(version) && version > SUPPORTED_SCHEMA_VERSION) {
return `Database was created with a newer version (schema v${version}). Update the extension.`;
}
}
}
return undefined;
} catch {
return 'Could not verify database schema.';
}
}
/** Close the database connection. Safe to call multiple times. */
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
/** Returns true if the database is open and contains the nodes table. */
isValid(): boolean {
if (!this.db) { return false; }
try {
const row = this.db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='nodes'"
)
.get() as { name: string } | undefined;
return row !== undefined;
} catch {
return false;
}
}
// -----------------------------------------------------------------------
// Node queries
// -----------------------------------------------------------------------
/** All file paths (kind='File'), ordered by file_path. */
getAllFiles(): string[] {
const rows = this._db()
.prepare(
"SELECT DISTINCT file_path FROM nodes WHERE kind = 'File' ORDER BY file_path"
)
.all() as FilePathRow[];
return rows.map((r) => r.file_path);
}
/** All nodes in a file, ordered by line_start. */
getNodesByFile(filePath: string): GraphNode[] {
const rows = this._db()
.prepare(
'SELECT * FROM nodes WHERE file_path = ? ORDER BY line_start'
)
.all(filePath) as NodeRow[];
return rows.map((r) => this._rowToNode(r));
}
/** Single node lookup by qualified_name. */
getNode(qualifiedName: string): GraphNode | undefined {
const row = this._db()
.prepare('SELECT * FROM nodes WHERE qualified_name = ?')
.get(qualifiedName) as NodeRow | undefined;
return row ? this._rowToNode(row) : undefined;
}
/**
* Innermost node at a cursor position.
*
* Returns the node whose line range contains `line` with the smallest
* span (i.e. the most specific / innermost enclosing node).
*/
getNodeAtCursor(filePath: string, line: number): GraphNode | undefined {
const row = this._db()
.prepare(
`SELECT * FROM nodes
WHERE file_path = ? AND line_start <= ? AND line_end >= ?
ORDER BY (line_end - line_start) ASC
LIMIT 1`
)
.get(filePath, line, line) as NodeRow | undefined;
return row ? this._rowToNode(row) : undefined;
}
/** LIKE search on name and qualified_name. */
searchNodes(query: string, limit: number = 20): GraphNode[] {
const pattern = `%${query}%`;
const rows = this._db()
.prepare(
'SELECT * FROM nodes WHERE name LIKE ? OR qualified_name LIKE ? LIMIT ?'
)
.all(pattern, pattern, limit) as NodeRow[];
return rows.map((r) => this._rowToNode(r));
}
// -----------------------------------------------------------------------
// Edge queries
// -----------------------------------------------------------------------
/** Outgoing edges from a node. */
getEdgesBySource(qualifiedName: string): GraphEdge[] {
const rows = this._db()
.prepare('SELECT * FROM edges WHERE source_qualified = ?')
.all(qualifiedName) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
/** Incoming edges to a node. */
getEdgesByTarget(qualifiedName: string): GraphEdge[] {
const rows = this._db()
.prepare('SELECT * FROM edges WHERE target_qualified = ?')
.all(qualifiedName) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
/**
* Edges where both source and target are in the given set.
*
* Uses a parameterised IN clause -- safe for arbitrary set sizes
* (better-sqlite3 handles large parameter lists efficiently).
*/
getEdgesAmong(qualifiedNames: Set<string>): GraphEdge[] {
if (qualifiedNames.size === 0) { return []; }
const qns = [...qualifiedNames];
const placeholders = qns.map(() => '?').join(',');
const rows = this._db()
.prepare(
`SELECT * FROM edges
WHERE source_qualified IN (${placeholders})
AND target_qualified IN (${placeholders})`
)
.all(...qns, ...qns) as EdgeRow[];
return rows.map((r) => this._rowToEdge(r));
}
// -----------------------------------------------------------------------
// Statistics & metadata
// -----------------------------------------------------------------------
/** Aggregate counts, languages, last_updated, and embeddings count. */
getStats(): GraphStats {
const db = this._db();
const totalNodes = (
db.prepare('SELECT COUNT(*) AS cnt FROM nodes').get() as CountRow
).cnt;
const totalEdges = (
db.prepare('SELECT COUNT(*) AS cnt FROM edges').get() as CountRow
).cnt;
const nodesByKind: Record<string, number> = {};
const nkRows = db
.prepare('SELECT kind, COUNT(*) AS cnt FROM nodes GROUP BY kind')
.all() as KindCountRow[];
for (const r of nkRows) { nodesByKind[r.kind] = r.cnt; }
const edgesByKind: Record<string, number> = {};
const ekRows = db
.prepare('SELECT kind, COUNT(*) AS cnt FROM edges GROUP BY kind')
.all() as KindCountRow[];
for (const r of ekRows) { edgesByKind[r.kind] = r.cnt; }
const languages = (
db
.prepare(
"SELECT DISTINCT language FROM nodes WHERE language IS NOT NULL AND language != ''"
)
.all() as LanguageRow[]
).map((r) => r.language);
const filesCount = (
db
.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE kind = 'File'")
.get() as CountRow
).cnt;
const lastUpdated = this.getMetadata('last_updated') ?? null;
// Embeddings count -- table may not exist
let embeddingsCount = 0;
try {
embeddingsCount = (
db.prepare('SELECT COUNT(*) AS cnt FROM embeddings').get() as CountRow
).cnt;
} catch {
// embeddings table does not exist -- that is fine
}
return {
totalNodes,
totalEdges,
nodesByKind,
edgesByKind,
languages,
filesCount,
lastUpdated,
embeddingsCount,
};
}
/** Read a single key from the metadata table. */
getMetadata(key: string): string | undefined {
const row = this._db()
.prepare('SELECT value FROM metadata WHERE key = ?')
.get(key) as MetadataRow | undefined;
return row?.value;
}
// -----------------------------------------------------------------------
// Impact radius (BFS traversal)
// -----------------------------------------------------------------------
/**
* BFS from changed files to find all impacted nodes within `maxDepth` hops.
*
* Matches the Python `GraphStore.get_impact_radius` logic exactly:
* 1. Collect all nodes in `changedFiles` as seed set.
* 2. BFS forward (outgoing) AND backward (incoming) edges up to `maxDepth`.
* 3. Return impacted nodes (excluding seeds), impacted files, and edges
* among all involved nodes.
*/
getImpactRadius(
changedFiles: string[],
maxDepth: number = 2,
): ImpactRadius {
// 1. Seed: all qualified names in changed files
const seeds = new Set<string>();
for (const f of changedFiles) {
for (const node of this.getNodesByFile(f)) {
seeds.add(node.qualifiedName);
}
}
// 2. BFS outward through all edge types (forward + backward)
const visited = new Set<string>();
let frontier = new Set(seeds);
const impacted = new Set<string>();
let depth = 0;
while (frontier.size > 0 && depth < maxDepth) {
const nextFrontier = new Set<string>();
for (const qn of frontier) {
visited.add(qn);
// Forward edges (things this node affects)
for (const e of this.getEdgesBySource(qn)) {
if (!visited.has(e.targetQualified)) {
nextFrontier.add(e.targetQualified);
impacted.add(e.targetQualified);
}
}
// Reverse edges (things that depend on this node)
for (const e of this.getEdgesByTarget(qn)) {
if (!visited.has(e.sourceQualified)) {
nextFrontier.add(e.sourceQualified);
impacted.add(e.sourceQualified);
}
}
}
frontier = nextFrontier;
depth++;
}
// 3. Resolve to full node info
const changedNodes: GraphNode[] = [];
for (const qn of seeds) {
const node = this.getNode(qn);
if (node) { changedNodes.push(node); }
}
const impactedNodes: GraphNode[] = [];
for (const qn of impacted) {
if (seeds.has(qn)) { continue; }
const node = this.getNode(qn);
if (node) { impactedNodes.push(node); }
}
const impactedFiles = [
...new Set(impactedNodes.map((n) => n.filePath)),
];
// Collect relevant edges among all involved nodes
const allQns = new Set([...seeds, ...impacted]);
const edges = allQns.size > 0 ? this.getEdgesAmong(allQns) : [];
return { changedNodes, impactedNodes, impactedFiles, edges };
}
// -----------------------------------------------------------------------
// Size-based queries
// -----------------------------------------------------------------------
/**
* Find nodes exceeding a line-count threshold.
*
* Mirrors the Python `GraphStore.get_nodes_by_size()` method.
*/
getNodesBySize(
minLines: number = 50,
kind?: string,
filePathPattern?: string,
limit: number = 50,
): Array<GraphNode & { lineCount: number }> {
const conditions = ['(line_end - line_start + 1) >= ?'];
const params: Array<string | number> = [minLines];
if (kind) {
conditions.push('kind = ?');
params.push(kind);
}
if (filePathPattern) {
conditions.push('file_path LIKE ?');
params.push(`%${filePathPattern}%`);
}
params.push(limit);
const where = conditions.join(' AND ');
const rows = this._db()
.prepare(
`SELECT * FROM nodes WHERE ${where} ` + // nosec
'ORDER BY (line_end - line_start + 1) DESC LIMIT ?',
)
.all(...params) as NodeRow[];
return rows.map((r) => ({
...this._rowToNode(r),
lineCount:
r.line_start != null && r.line_end != null
? r.line_end - r.line_start + 1
: 0,
}));
}
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
/** Return the open database handle or throw. */
private _db(): DatabaseType {
if (!this.db) {
throw new Error('SqliteReader: database is closed');
}
return this.db;
}
/** Convert a raw node row (snake_case) to a typed GraphNode (camelCase). */
private _rowToNode(row: NodeRow): GraphNode {
return {
id: row.id,
kind: row.kind as NodeKind,
name: row.name,
qualifiedName: row.qualified_name,
filePath: row.file_path,
lineStart: row.line_start,
lineEnd: row.line_end,
language: row.language ?? null,
parentName: row.parent_name ?? null,
params: row.params ?? null,
returnType: row.return_type ?? null,
modifiers: row.modifiers ?? null,
isTest: row.is_test === 1,
fileHash: row.file_hash ?? null,
};
}
/** Convert a raw edge row (snake_case) to a typed GraphEdge (camelCase). */
private _rowToEdge(row: EdgeRow): GraphEdge {
return {
id: row.id,
kind: row.kind as EdgeKind,
sourceQualified: row.source_qualified,
targetQualified: row.target_qualified,
filePath: row.file_path,
line: row.line ?? 0,
};
}
}
@@ -0,0 +1,60 @@
import * as path from 'path';
import * as vscode from 'vscode';
/**
* Return a debounced version of `fn` that delays invocation until `ms`
* milliseconds have elapsed since the last call. The returned function
* has the same signature as the original.
*/
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = (...args: Parameters<T>): void => {
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = undefined;
fn(...args);
}, ms);
};
return debounced as unknown as T;
}
/**
* Watches a `graph.db` file on disk and fires a callback whenever the
* file is created or modified (debounced to avoid rapid successive events).
*/
export class GraphWatcher implements vscode.Disposable {
private readonly watcher: vscode.FileSystemWatcher;
private readonly disposables: vscode.Disposable[] = [];
/**
* @param dbPath Absolute path to the `graph.db` file to watch.
* @param onChanged Callback invoked (at most once per 500 ms) when the file changes.
*/
constructor(dbPath: string, onChanged: () => void) {
const dir = path.dirname(dbPath);
const filename = path.basename(dbPath);
this.watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(dir), filename),
);
const debouncedOnChanged = debounce(onChanged, 500);
this.disposables.push(
this.watcher.onDidChange(() => debouncedOnChanged()),
this.watcher.onDidCreate(() => debouncedOnChanged()),
this.watcher,
);
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
this.disposables.length = 0;
}
}
+983
View File
@@ -0,0 +1,983 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as fs from "node:fs";
import { SqliteReader } from "./backend/sqlite";
import type { GraphNode } from "./backend/sqlite";
import { CliWrapper } from "./backend/cli";
import {
CodeGraphTreeProvider,
BlastRadiusTreeProvider,
StatsTreeProvider,
} from "./views/treeView";
import { GraphWebviewPanel } from "./views/graphWebview";
import { Installer } from "./onboarding/installer";
import { registerWalkthroughCommands, showWelcomeIfNeeded } from "./onboarding/welcome";
import { StatusBar } from "./views/statusBar";
import { ScmDecorationProvider } from "./features/scmDecorations";
let sqliteReader: SqliteReader | undefined;
let autoUpdateTimer: ReturnType<typeof setTimeout> | undefined;
let scmDecorationProvider: ScmDecorationProvider | undefined;
/**
* Locate the graph database file in the workspace.
* Checks `.code-review-graph/graph.db` first, then falls back to `.code-review-graph.db`.
*/
function findGraphDb(workspaceRoot: string): string | undefined {
const primary = path.join(workspaceRoot, ".code-review-graph", "graph.db");
if (fs.existsSync(primary)) {
return primary;
}
const fallback = path.join(workspaceRoot, ".code-review-graph.db");
if (fs.existsSync(fallback)) {
return fallback;
}
return undefined;
}
/**
* Get the workspace root folder path, or undefined if no workspace is open.
* Checks all workspace folders for a graph database (multi-root support).
*/
function getWorkspaceRoot(): string | undefined {
const folders = vscode.workspace.workspaceFolders;
if (!folders) { return undefined; }
// Prefer the folder that has a graph database
for (const folder of folders) {
if (findGraphDb(folder.uri.fsPath)) {
return folder.uri.fsPath;
}
}
// Fall back to first folder
return folders[0]?.uri.fsPath;
}
/**
* Navigate to a node's source file location.
*/
async function navigateToNode(node: GraphNode): Promise<void> {
const workspaceRoot = getWorkspaceRoot();
const filePath = workspaceRoot
? path.join(workspaceRoot, node.filePath)
: node.filePath;
const doc = await vscode.workspace.openTextDocument(filePath);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
/**
* Register all extension commands.
*/
function registerCommands(
context: vscode.ExtensionContext,
cli: CliWrapper
): void {
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.buildGraph",
async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
const result = await cli.buildGraph(workspaceRoot);
if (result.success) {
await reinitialize(context);
vscode.window.showInformationMessage("Code Graph: Build complete.");
} else {
vscode.window.showErrorMessage(
`Code Graph: Build failed. ${result.stderr}`
);
}
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.updateGraph",
async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Code Graph: Updating graph...",
cancellable: false,
},
async () => {
const result = await cli.updateGraph(workspaceRoot);
if (!result.success) {
vscode.window.showErrorMessage(
`Code Graph: Update failed. ${result.stderr}`
);
}
}
);
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.showBlastRadius",
async (qualifiedNameOrUri?: string | vscode.Uri) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
let qualifiedName: string | undefined;
if (typeof qualifiedNameOrUri === "string") {
qualifiedName = qualifiedNameOrUri;
} else {
qualifiedName = await vscode.window.showInputBox({
prompt:
"Enter the qualified name (e.g., my_module.MyClass.my_method)",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
// Find the file for this node and compute impact radius
const node = sqliteReader.getNode(qualifiedName);
if (!node) {
vscode.window.showInformationMessage(
`Code Graph: Node "${qualifiedName}" not found.`
);
return;
}
const config = vscode.workspace.getConfiguration("codeReviewGraph");
const depth = config.get<number>("blastRadiusDepth", 2);
const impact = sqliteReader.getImpactRadius([node.filePath], depth);
if (impact.impactedNodes.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No blast radius found for "${qualifiedName}".`
);
return;
}
await vscode.commands.executeCommand(
"codeReviewGraph.blastRadius.focus"
);
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findCallers",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find callers for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
const callerEdges = edges.filter((e) => e.kind === "CALLS");
if (callerEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callers found for "${qualifiedName}".`
);
return;
}
const items = callerEdges.map((e) => {
const callerNode = sqliteReader!.getNode(e.sourceQualified);
return {
label: callerNode?.name ?? e.sourceQualified,
description: callerNode?.filePath ?? e.filePath,
detail: `Line ${callerNode?.lineStart ?? e.line}`,
node: callerNode,
edge: e,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callers of ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findTests",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find tests for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) {
return;
}
// Find tests via TESTED_BY edges
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
const testEdges = edges.filter((e) => e.kind === "TESTED_BY");
// Also check reverse: source is the node, target is the test
const outEdges = sqliteReader.getEdgesBySource(qualifiedName);
const outTestEdges = outEdges.filter((e) => e.kind === "TESTED_BY");
const allTestQualifiedNames = new Set([
...testEdges.map((e) => e.sourceQualified),
...outTestEdges.map((e) => e.targetQualified),
]);
if (allTestQualifiedNames.size === 0) {
vscode.window.showInformationMessage(
`Code Graph: No tests found for "${qualifiedName}".`
);
return;
}
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const tqn of allTestQualifiedNames) {
const testNode = sqliteReader.getNode(tqn);
items.push({
label: testNode?.name ?? tqn,
description: testNode?.filePath ?? "",
detail: `Line ${testNode?.lineStart ?? "?"}`,
node: testNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Tests for ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.queryGraph — expose all 8 query patterns
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.queryGraph", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
const patterns = [
{ label: "callers_of", description: "Find functions calling the target" },
{ label: "callees_of", description: "Find functions called by the target" },
{ label: "imports_of", description: "Find modules imported by a file" },
{ label: "importers_of", description: "Find files importing from the target" },
{ label: "children_of", description: "Find nodes contained in a file or class" },
{ label: "tests_for", description: "Find tests for a function or class" },
{ label: "inheritors_of", description: "Find classes inheriting/implementing the target" },
{ label: "file_summary", description: "List all nodes in a file" },
];
const pattern = await vscode.window.showQuickPick(patterns, {
placeHolder: "Select a query pattern",
});
if (!pattern) { return; }
const target = await vscode.window.showInputBox({
prompt: `Enter the target for ${pattern.label}`,
placeHolder: "e.g., my_module.py::my_function or path/to/file.py",
});
if (!target) { return; }
// Map pattern to edge kind + direction
type QueryDef = { edgeKind: string; direction: "incoming" | "outgoing"; nodeFilter?: string };
const queryMap: Record<string, QueryDef> = {
callers_of: { edgeKind: "CALLS", direction: "incoming" },
callees_of: { edgeKind: "CALLS", direction: "outgoing" },
imports_of: { edgeKind: "IMPORTS_FROM", direction: "outgoing" },
importers_of: { edgeKind: "IMPORTS_FROM", direction: "incoming" },
children_of: { edgeKind: "CONTAINS", direction: "outgoing" },
tests_for: { edgeKind: "TESTED_BY", direction: "incoming" },
inheritors_of: { edgeKind: "INHERITS", direction: "incoming" },
file_summary: { edgeKind: "CONTAINS", direction: "outgoing" },
};
const qdef = queryMap[pattern.label];
if (!qdef) { return; }
// Try exact match, then search
let node = sqliteReader.getNode(target);
if (!node) {
const matches = sqliteReader.searchNodes(target, 5);
if (matches.length === 1) {
node = matches[0];
} else if (matches.length > 1) {
const selected = await vscode.window.showQuickPick(
matches.map(m => ({
label: m.name,
description: `${m.kind} · ${m.filePath}`,
node: m,
})),
{ placeHolder: `Multiple matches for "${target}" — select one` },
);
if (!selected) { return; }
node = selected.node;
}
}
if (!node) {
vscode.window.showInformationMessage(`Code Graph: "${target}" not found.`);
return;
}
const edges = qdef.direction === "incoming"
? sqliteReader.getEdgesByTarget(node.qualifiedName)
: sqliteReader.getEdgesBySource(node.qualifiedName);
const filtered = edges.filter(e => e.kind === qdef.edgeKind);
if (filtered.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No ${pattern.label} results for "${node.name}".`
);
return;
}
const items = filtered.map(e => {
const relatedQn = qdef.direction === "incoming" ? e.sourceQualified : e.targetQualified;
const relatedNode = sqliteReader!.getNode(relatedQn);
return {
label: relatedNode?.name ?? relatedQn,
description: relatedNode ? `${relatedNode.kind} · ${relatedNode.filePath}` : "",
detail: `Line ${relatedNode?.lineStart ?? e.line}`,
node: relatedNode,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `${pattern.label}: ${node.name} (${filtered.length} results)`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
})
);
// -----------------------------------------------------------------
// codeReviewGraph.findCallees
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findCallees",
async (qualifiedName?: string) => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
if (!qualifiedName) {
qualifiedName = await vscode.window.showInputBox({
prompt: "Enter the qualified name to find callees for",
placeHolder: "my_module.my_function",
});
}
if (!qualifiedName) { return; }
const edges = sqliteReader.getEdgesBySource(qualifiedName);
const calleeEdges = edges.filter(e => e.kind === "CALLS");
if (calleeEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callees found for "${qualifiedName}".`
);
return;
}
const items = calleeEdges.map(e => {
const calleeNode = sqliteReader!.getNode(e.targetQualified);
return {
label: calleeNode?.name ?? e.targetQualified,
description: calleeNode?.filePath ?? e.filePath,
detail: `Line ${calleeNode?.lineStart ?? e.line}`,
node: calleeNode,
};
});
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callees of ${qualifiedName}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.findLargeFunctions
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.findLargeFunctions",
async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
return;
}
const minLinesStr = await vscode.window.showInputBox({
prompt: "Minimum line count threshold",
placeHolder: "50",
value: "50",
});
if (!minLinesStr) { return; }
const minLines = parseInt(minLinesStr, 10);
if (isNaN(minLines) || minLines < 1) {
vscode.window.showWarningMessage("Code Graph: Invalid line count.");
return;
}
const results = sqliteReader.getNodesBySize(minLines, undefined, undefined, 50);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No functions found with ${minLines}+ lines.`
);
return;
}
const items = results.map(r => ({
label: `${r.name} (${r.lineCount} lines)`,
description: `${r.kind} · ${r.filePath}`,
detail: `Lines ${r.lineStart ?? "?"}${r.lineEnd ?? "?"}`,
node: r as GraphNode,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `${results.length} nodes with ${minLines}+ lines`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}
)
);
// -----------------------------------------------------------------
// codeReviewGraph.embedGraph
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.embedGraph", async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
const result = await cli.embedGraph(workspaceRoot);
if (result.success) {
vscode.window.showInformationMessage("Code Graph: Embeddings computed.");
} else {
const msg = result.stderr.includes("not installed")
? "Install embeddings support: pip install code-review-graph[embeddings]"
: `Embedding failed: ${result.stderr}`;
vscode.window.showErrorMessage(`Code Graph: ${msg}`);
}
})
);
// -----------------------------------------------------------------
// codeReviewGraph.watchGraph
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.watchGraph", async () => {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
vscode.window.showInformationMessage("Code Graph: Watch mode started.");
const result = await cli.watchGraph(workspaceRoot);
if (!result.success) {
vscode.window.showErrorMessage(
`Code Graph: Watch failed. ${result.stderr}`
);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.showGraph", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded. Run 'Code Graph: Build Graph' first."
);
return;
}
GraphWebviewPanel.createOrShow(context.extensionUri, sqliteReader);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("codeReviewGraph.search", async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
const query = await vscode.window.showInputBox({
prompt: "Search the code graph",
placeHolder: "Enter a function, class, or module name",
});
if (!query) {
return;
}
const results = sqliteReader.searchNodes(query);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No results found for "${query}".`
);
return;
}
const items = results.map((r) => ({
label: r.name,
description: r.kind,
detail: r.filePath
? `${r.filePath}:${r.lineStart ?? ""}`
: undefined,
result: r,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Results for "${query}"`,
});
if (selected?.result) {
await navigateToNode(selected.result);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.reviewChanges",
async () => {
if (!sqliteReader) {
vscode.window.showWarningMessage(
"Code Graph: No graph database loaded."
);
return;
}
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
vscode.window.showErrorMessage("No workspace folder is open.");
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Code Graph: Analyzing changes...",
cancellable: false,
},
async () => {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
let changedFiles: string[] = [];
try {
const r1 = await execFileAsync(
"git", ["diff", "--name-only", "HEAD"],
{ cwd: workspaceRoot }
);
const r2 = await execFileAsync(
"git", ["diff", "--cached", "--name-only"],
{ cwd: workspaceRoot }
);
changedFiles = [...new Set([
...r1.stdout.trim().split("\n").filter(Boolean),
...r2.stdout.trim().split("\n").filter(Boolean),
])];
} catch {
// git not available or not a git repo
}
if (changedFiles.length === 0) {
vscode.window.showInformationMessage(
"Code Graph: No changes detected."
);
return;
}
const absFiles = changedFiles.map(f => path.join(workspaceRoot, f));
const impact = sqliteReader!.getImpactRadius(absFiles);
// --- Generate review guidance ---
const guidance: string[] = [];
const impactedFileCount = new Set(
impact.impactedNodes.map(n => n.filePath)
).size;
// Test coverage check
const untestedFns: string[] = [];
for (const node of impact.changedNodes) {
if (node.kind !== "Function" || node.isTest) { continue; }
const edges = sqliteReader!.getEdgesByTarget(node.qualifiedName);
const hasCoverage = edges.some(e => e.kind === "TESTED_BY");
if (!hasCoverage) {
const out = sqliteReader!.getEdgesBySource(node.qualifiedName);
if (!out.some(e => e.kind === "TESTED_BY")) {
untestedFns.push(node.name);
}
}
}
if (untestedFns.length > 0) {
guidance.push(
`\u26a0\ufe0f **${untestedFns.length} changed function(s) lack test coverage**: ${untestedFns.slice(0, 5).join(", ")}${untestedFns.length > 5 ? "..." : ""}`
);
}
// Wide blast radius warning
if (impactedFileCount > 10) {
guidance.push(
`\u26a0\ufe0f **Wide blast radius**: ${impactedFileCount} files impacted — consider splitting this change.`
);
}
// Inheritance changes
const inheritanceChanges = impact.edges.filter(
e => e.kind === "INHERITS" || e.kind === "IMPLEMENTS"
);
if (inheritanceChanges.length > 0) {
guidance.push(
`\u26a0\ufe0f **Inheritance chain affected**: ${inheritanceChanges.length} inheritance/implementation edge(s) touched.`
);
}
// Cross-file impacts
if (impact.impactedNodes.length > 0) {
guidance.push(
`\u2139\ufe0f ${impact.impactedNodes.length} nodes in ${impactedFileCount} file(s) may be affected by these changes.`
);
}
// Show guidance in output channel
const channel = vscode.window.createOutputChannel("Code Graph Review", { log: true });
channel.appendLine("# Review Guidance");
channel.appendLine("");
channel.appendLine(`Changed files: ${changedFiles.length}`);
channel.appendLine(`Changed nodes: ${impact.changedNodes.length}`);
channel.appendLine(`Impacted nodes: ${impact.impactedNodes.length}`);
channel.appendLine(`Impacted files: ${impactedFileCount}`);
channel.appendLine("");
if (guidance.length > 0) {
for (const g of guidance) { channel.appendLine(g); }
} else {
channel.appendLine("\u2705 No concerns detected.");
}
channel.show(true);
// Also show in graph
GraphWebviewPanel.createOrShow(
context.extensionUri,
sqliteReader!,
impact
);
// Update SCM decorations
if (scmDecorationProvider && sqliteReader) {
await scmDecorationProvider.update(sqliteReader, workspaceRoot);
}
}
);
}
)
);
}
/**
* Reinitialize the reader and tree providers after a graph rebuild.
*/
async function reinitialize(
context: vscode.ExtensionContext
): Promise<void> {
const workspaceRoot = getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
const dbPath = findGraphDb(workspaceRoot);
if (!dbPath) {
return;
}
sqliteReader?.close();
sqliteReader = new SqliteReader(dbPath);
// Refresh tree views
await vscode.commands.executeCommand(
"codeReviewGraph.codeGraph.refresh"
);
}
/**
* Set up a FileSystemWatcher to detect changes to the graph database.
*/
function watchGraphDb(context: vscode.ExtensionContext): void {
const watcher = vscode.workspace.createFileSystemWatcher(
"**/.code-review-graph/graph.db"
);
const dbPathRef = { current: "" };
const workspaceRoot = getWorkspaceRoot();
if (workspaceRoot) {
const dbPath = findGraphDb(workspaceRoot);
if (dbPath) {
dbPathRef.current = dbPath;
}
}
watcher.onDidChange(() => {
// Close and reopen to pick up external writes
if (sqliteReader && dbPathRef.current) {
sqliteReader.close();
sqliteReader = new SqliteReader(dbPathRef.current);
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
}
});
watcher.onDidCreate(async () => {
const wsRoot = getWorkspaceRoot();
if (wsRoot && !sqliteReader) {
const dbPath = findGraphDb(wsRoot);
if (dbPath) {
dbPathRef.current = dbPath;
sqliteReader = new SqliteReader(dbPath);
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
}
}
});
watcher.onDidDelete(() => {
sqliteReader?.close();
sqliteReader = undefined;
dbPathRef.current = "";
});
context.subscriptions.push(watcher);
}
/**
* Set up debounced auto-update on file save.
*/
function setupAutoUpdate(
context: vscode.ExtensionContext,
cli: CliWrapper
): void {
const AUTO_UPDATE_DEBOUNCE_MS = 2000;
const onSave = vscode.workspace.onDidSaveTextDocument(() => {
const config = vscode.workspace.getConfiguration("codeReviewGraph");
if (!config.get<boolean>("autoUpdate", true)) {
return;
}
if (autoUpdateTimer) {
clearTimeout(autoUpdateTimer);
}
autoUpdateTimer = setTimeout(async () => {
const wsRoot = getWorkspaceRoot();
if (!wsRoot || !sqliteReader) {
return;
}
try {
await cli.updateGraph(wsRoot);
} catch {
// Silently ignore update errors on save; user can manually update
}
}, AUTO_UPDATE_DEBOUNCE_MS);
});
context.subscriptions.push(onSave);
}
/**
* Extension activation entry point.
*/
export async function activate(
context: vscode.ExtensionContext
): Promise<void> {
const cli = new CliWrapper();
const installer = new Installer(cli);
// Register walkthrough commands
registerWalkthroughCommands(context, cli, installer);
const workspaceRoot = getWorkspaceRoot();
if (workspaceRoot) {
const dbPath = findGraphDb(workspaceRoot);
if (dbPath) {
// Graph database found - initialize
sqliteReader = new SqliteReader(dbPath);
// Schema compatibility check
const schemaWarning = sqliteReader.checkSchemaCompatibility();
if (schemaWarning) {
const choice = await vscode.window.showWarningMessage(
`Code Graph: ${schemaWarning}`,
"Rebuild Graph",
"Dismiss"
);
if (choice === "Rebuild Graph") {
await vscode.commands.executeCommand("codeReviewGraph.buildGraph");
}
}
// Register tree view providers
const codeGraphProvider = new CodeGraphTreeProvider(
sqliteReader,
workspaceRoot
);
const blastRadiusProvider = new BlastRadiusTreeProvider();
const statsProvider = new StatsTreeProvider(sqliteReader);
context.subscriptions.push(
vscode.window.registerTreeDataProvider(
"codeReviewGraph.codeGraph",
codeGraphProvider
),
vscode.window.registerTreeDataProvider(
"codeReviewGraph.blastRadius",
blastRadiusProvider
),
vscode.window.registerTreeDataProvider(
"codeReviewGraph.stats",
statsProvider
)
);
// Create status bar
const statusBar = new StatusBar();
statusBar.update(sqliteReader);
statusBar.show();
context.subscriptions.push(statusBar);
// Register SCM file decoration provider
scmDecorationProvider = new ScmDecorationProvider();
context.subscriptions.push(
vscode.window.registerFileDecorationProvider(scmDecorationProvider)
);
} else {
// No graph database found - show welcome
showWelcomeIfNeeded(context);
}
}
// Register revealInTree command for bidirectional graph→tree sync
context.subscriptions.push(
vscode.commands.registerCommand(
"codeReviewGraph.revealInTree",
(_qualifiedName: string) => {
// When a node is clicked in the graph, highlight it in the graph
// The graph webview already calls this; the tree view sync relies
// on the file navigation that nodeClicked also triggers.
// This command is a hook for future tree reveal integration.
GraphWebviewPanel.highlightNode(_qualifiedName);
}
)
);
// Register commands (always, even without a database)
registerCommands(context, cli);
// Watch for graph.db changes
watchGraphDb(context);
// Set up auto-update on save
setupAutoUpdate(context, cli);
}
/**
* Extension deactivation cleanup.
*/
export function deactivate(): void {
if (autoUpdateTimer) {
clearTimeout(autoUpdateTimer);
autoUpdateTimer = undefined;
}
sqliteReader?.close();
sqliteReader = undefined;
}
@@ -0,0 +1,70 @@
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
import { BlastRadiusTreeProvider } from '../views/treeView';
import { resolveNodeAtCursor } from './cursorResolver';
/**
* Register the cursor-aware blast radius command.
*
* When invoked the command:
* 1. Gets the active editor's file path and cursor line.
* 2. Resolves the innermost node at cursor via the graph database.
* 3. Falls back to the file-level node when no specific node is found.
* 4. Runs a BFS impact radius query up to the configured depth.
* 5. Updates the BlastRadiusTreeProvider with the results.
* 6. Focuses the blast radius tree view.
*/
export function registerBlastRadiusCommand(
context: vscode.ExtensionContext,
getReader: () => SqliteReader | undefined,
blastRadiusProvider: BlastRadiusTreeProvider,
workspaceRoot: string,
): void {
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.showBlastRadius', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// --- Active editor check ---
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('Open a file first');
return;
}
// --- Resolve file path and cursor position ---
const absFilePath = editor.document.uri.fsPath;
const cursorLine = editor.selection.active.line + 1; // 1-based
// --- Resolve node at cursor ---
const nodeAtCursor = reader.getNodeAtCursor(absFilePath, cursorLine);
// Determine the file path to feed into getImpactRadius.
// If we found a node, use its filePath (which is the canonical path
// stored in the database). Otherwise fall back to the editor path.
const filePath = nodeAtCursor ? nodeAtCursor.filePath : absFilePath;
// --- Read depth from settings ---
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
// --- Compute blast radius ---
const impact = reader.getImpactRadius([filePath], depth);
// --- Update tree provider ---
blastRadiusProvider.setResults(impact.changedNodes, impact.impactedNodes);
// --- Focus the blast radius view ---
await vscode.commands.executeCommand('codeReviewGraph.blastRadius.focus');
// --- Summary message ---
const impactedFileCount = new Set(impact.impactedNodes.map((n) => n.filePath)).size;
vscode.window.showInformationMessage(
`Blast radius: ${impact.impactedNodes.length} nodes impacted across ${impactedFileCount} files`,
);
}),
);
}
@@ -0,0 +1,37 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode } from '../backend/sqlite';
/**
* Resolve the innermost graph node at the current cursor position.
*
* Returns `undefined` when there is no active editor or no node spans the
* cursor line in the graph database.
*/
export function resolveNodeAtCursor(
reader: SqliteReader,
): GraphNode | undefined {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return undefined;
}
const filePath = editor.document.uri.fsPath;
const line = editor.selection.active.line + 1; // VS Code is 0-based, SQLite data is 1-based
return reader.getNodeAtCursor(filePath, line);
}
/**
* Open a document and scroll to the node's start line.
*
* The node's `filePath` is treated as an absolute path. If `lineStart` is
* null the file is opened at the top.
*/
export async function navigateToNode(node: GraphNode): Promise<void> {
const uri = vscode.Uri.file(node.filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
@@ -0,0 +1,196 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode } from '../backend/sqlite';
import { resolveNodeAtCursor, navigateToNode } from './cursorResolver';
/**
* Register the navigation commands: findCallers, findTests, and search.
*/
export function registerNavigationCommands(
context: vscode.ExtensionContext,
getReader: () => SqliteReader | undefined,
): void {
// -----------------------------------------------------------------
// codeReviewGraph.findCallers
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.findCallers', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// Resolve node at cursor
const node = resolveNodeAtCursor(reader);
if (!node) {
vscode.window.showWarningMessage(
'Code Graph: No graph node found at the current cursor position.',
);
return;
}
// Query incoming CALLS edges
const edges = reader.getEdgesByTarget(node.qualifiedName);
const callerEdges = edges.filter((e) => e.kind === 'CALLS');
if (callerEdges.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No callers found for "${node.name}".`,
);
return;
}
// Build QuickPick items, resolving each caller to its full node
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const edge of callerEdges) {
const callerNode = reader.getNode(edge.sourceQualified);
items.push({
label: callerNode?.name ?? edge.sourceQualified,
description: callerNode?.filePath ?? edge.filePath,
detail: `Line ${callerNode?.lineStart ?? edge.line}`,
node: callerNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Callers of ${node.name}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}),
);
// -----------------------------------------------------------------
// codeReviewGraph.findTests
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.findTests', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
// Resolve node at cursor
const node = resolveNodeAtCursor(reader);
if (!node) {
vscode.window.showWarningMessage(
'Code Graph: No graph node found at the current cursor position.',
);
return;
}
// --- Collect test qualified names from TESTED_BY edges (both directions) ---
const incomingEdges = reader.getEdgesByTarget(node.qualifiedName);
const incomingTestEdges = incomingEdges.filter((e) => e.kind === 'TESTED_BY');
const outgoingEdges = reader.getEdgesBySource(node.qualifiedName);
const outgoingTestEdges = outgoingEdges.filter((e) => e.kind === 'TESTED_BY');
const testQualifiedNames = new Set<string>([
...incomingTestEdges.map((e) => e.sourceQualified),
...outgoingTestEdges.map((e) => e.targetQualified),
]);
// --- Also search by naming convention: test_{name}, Test{name} ---
const conventionPatterns = [`test_${node.name}`, `Test${node.name}`];
for (const pattern of conventionPatterns) {
const matches = reader.searchNodes(pattern, 10);
for (const match of matches) {
if (match.isTest || match.kind === 'Test') {
testQualifiedNames.add(match.qualifiedName);
}
}
}
if (testQualifiedNames.size === 0) {
vscode.window.showInformationMessage(
`Code Graph: No tests found for "${node.name}".`,
);
return;
}
// --- Build QuickPick items ---
const items: Array<{
label: string;
description: string;
detail: string;
node: GraphNode | undefined;
}> = [];
for (const tqn of testQualifiedNames) {
const testNode = reader.getNode(tqn);
items.push({
label: testNode?.name ?? tqn,
description: testNode?.filePath ?? '',
detail: `Line ${testNode?.lineStart ?? '?'}`,
node: testNode,
});
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Tests for ${node.name}`,
});
if (selected?.node) {
await navigateToNode(selected.node);
}
}),
);
// -----------------------------------------------------------------
// codeReviewGraph.search
// -----------------------------------------------------------------
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewGraph.search', async () => {
const reader = getReader();
if (!reader) {
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
return;
}
const query = await vscode.window.showInputBox({
prompt: 'Search the code graph',
placeHolder: 'Enter a function, class, or module name',
});
if (!query) {
return;
}
const results = reader.searchNodes(query, 30);
if (results.length === 0) {
vscode.window.showInformationMessage(
`Code Graph: No results found for "${query}".`,
);
return;
}
const items = results.map((r) => ({
label: r.name,
description: r.kind,
detail: r.filePath
? `${r.filePath}:${r.lineStart ?? ''}`
: undefined,
result: r,
}));
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `Results for "${query}"`,
});
if (selected?.result) {
await navigateToNode(selected.result);
}
}),
);
}
@@ -0,0 +1,117 @@
/**
* SCM integration for code review.
*
* Detects staged and unstaged changes via git, computes the blast radius
* for those files, and populates the Blast Radius tree view so the reviewer
* can see what is impacted before committing.
*/
import * as vscode from 'vscode';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { SqliteReader } from '../backend/sqlite';
import { BlastRadiusTreeProvider } from '../views/treeView';
const execFileAsync = promisify(execFile);
/** Timeout for git commands (milliseconds). */
const GIT_TIMEOUT_MS = 10_000;
/**
* Run a git command in the given working directory and return trimmed stdout
* lines. Returns an empty array on any error (e.g. git not installed, not a
* git repo, etc.).
*/
async function gitLines(
args: string[],
cwd: string,
): Promise<string[]> {
try {
const { stdout } = await execFileAsync('git', args, {
cwd,
timeout: GIT_TIMEOUT_MS,
});
return stdout
.trim()
.split('\n')
.filter((line) => line.length > 0);
} catch {
return [];
}
}
/**
* Register the `codeReviewGraph.reviewChanges` command.
*
* The command:
* 1. Runs `git diff --name-only HEAD` and `git diff --cached --name-only`
* to collect changed + staged files.
* 2. Computes the blast radius for those files.
* 3. Updates the BlastRadiusTreeProvider with the results.
* 4. Focuses the blast radius view.
*/
export function registerReviewCommand(
context: vscode.ExtensionContext,
reader: SqliteReader,
blastRadiusProvider: BlastRadiusTreeProvider,
): void {
const disposable = vscode.commands.registerCommand(
'codeReviewGraph.reviewChanges',
async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showErrorMessage('No workspace folder is open.');
return;
}
const workspaceRoot = workspaceFolder.uri.fsPath;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Graph: Analyzing changes...',
cancellable: false,
},
async () => {
// 1. Collect changed files (unstaged + staged, deduplicated)
const [unstaged, staged] = await Promise.all([
gitLines(['diff', '--name-only', 'HEAD'], workspaceRoot),
gitLines(['diff', '--cached', '--name-only'], workspaceRoot),
]);
const changedFiles = [...new Set([...unstaged, ...staged])];
if (changedFiles.length === 0) {
vscode.window.showInformationMessage(
'No changes detected.',
);
return;
}
// 2. Compute blast radius
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
const impact = reader.getImpactRadius(changedFiles, depth);
// 3. Update tree provider
blastRadiusProvider.setResults(
impact.changedNodes,
impact.impactedNodes,
);
// 4. Focus the blast radius view
await vscode.commands.executeCommand(
'codeReviewGraph.blastRadius.focus',
);
// 5. Show summary
vscode.window.showInformationMessage(
`Review: ${changedFiles.length} changed file(s) impact ${impact.impactedNodes.length} additional file(s).`,
);
},
);
},
);
context.subscriptions.push(disposable);
}
@@ -0,0 +1,160 @@
/**
* SCM file decoration provider.
*
* Adds badges to files in the Explorer and SCM views:
* - IMPACTED (orange) — file is in the blast radius of staged/unstaged changes
* - TESTED (green) — changed functions in this file have test coverage
* - UNTESTED (red) — changed functions lack test coverage
*/
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
export class ScmDecorationProvider
implements vscode.FileDecorationProvider
{
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
readonly onDidChangeFileDecorations = this._onDidChange.event;
/** Files directly changed (staged + unstaged). */
private changedFiles = new Set<string>();
/** Files in the blast radius but not directly changed. */
private impactedFiles = new Set<string>();
/** Changed files whose functions all have TESTED_BY edges. */
private testedFiles = new Set<string>();
/** Changed files with at least one function lacking TESTED_BY edges. */
private untestedFiles = new Set<string>();
/**
* Recompute decorations from git state and the graph database.
*/
async update(
reader: SqliteReader,
workspaceRoot: string,
): Promise<void> {
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const execFileAsync = promisify(execFile);
const path = await import('node:path');
// 1. Collect changed files
let unstaged: string[] = [];
let staged: string[] = [];
try {
const r1 = await execFileAsync('git', ['diff', '--name-only', 'HEAD'], {
cwd: workspaceRoot,
timeout: 10_000,
});
unstaged = r1.stdout.trim().split('\n').filter(Boolean);
} catch { /* ignore */ }
try {
const r2 = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
cwd: workspaceRoot,
timeout: 10_000,
});
staged = r2.stdout.trim().split('\n').filter(Boolean);
} catch { /* ignore */ }
const changedRelative = [...new Set([...unstaged, ...staged])];
const changedAbsolute = changedRelative.map((f) => path.join(workspaceRoot, f));
// 2. Compute impact radius
const config = vscode.workspace.getConfiguration('codeReviewGraph');
const depth = config.get<number>('blastRadiusDepth', 2);
const impact = reader.getImpactRadius(changedAbsolute, depth);
// 3. Classify files
this.changedFiles = new Set(changedAbsolute);
this.impactedFiles = new Set(
impact.impactedNodes
.map((n) => n.filePath)
.filter((f) => !this.changedFiles.has(f)),
);
// 4. Test coverage classification
this.testedFiles = new Set<string>();
this.untestedFiles = new Set<string>();
for (const filePath of this.changedFiles) {
const nodes = reader.getNodesByFile(filePath);
const functions = nodes.filter(
(n) => n.kind === 'Function' && !n.isTest,
);
if (functions.length === 0) {
continue;
}
let allTested = true;
for (const fn of functions) {
const edges = reader.getEdgesByTarget(fn.qualifiedName);
const hasTest = edges.some((e) => e.kind === 'TESTED_BY');
if (!hasTest) {
// Also check outgoing TESTED_BY (reverse direction)
const outEdges = reader.getEdgesBySource(fn.qualifiedName);
const hasOutTest = outEdges.some((e) => e.kind === 'TESTED_BY');
if (!hasOutTest) {
allTested = false;
break;
}
}
}
if (allTested) {
this.testedFiles.add(filePath);
} else {
this.untestedFiles.add(filePath);
}
}
// 5. Fire change event
this._onDidChange.fire(undefined);
}
/** Clear all decorations. */
clear(): void {
this.changedFiles.clear();
this.impactedFiles.clear();
this.testedFiles.clear();
this.untestedFiles.clear();
this._onDidChange.fire(undefined);
}
provideFileDecoration(
uri: vscode.Uri,
): vscode.FileDecoration | undefined {
const filePath = uri.fsPath;
if (this.untestedFiles.has(filePath)) {
return {
badge: '!',
color: new vscode.ThemeColor('editorError.foreground'),
tooltip: 'Code Graph: Changed functions lack test coverage',
propagate: false,
};
}
if (this.testedFiles.has(filePath)) {
return {
badge: '\u2713',
color: new vscode.ThemeColor('testing.iconPassed'),
tooltip: 'Code Graph: All changed functions have test coverage',
propagate: false,
};
}
if (this.impactedFiles.has(filePath)) {
return {
badge: '\u25CF',
color: new vscode.ThemeColor('editorWarning.foreground'),
tooltip: 'Code Graph: In blast radius of current changes',
propagate: false,
};
}
return undefined;
}
dispose(): void {
this._onDidChange.dispose();
}
}
@@ -0,0 +1,131 @@
/**
* Quick search command with live filtering.
*
* Shows a QuickPick that queries the graph database as the user types,
* then navigates to the selected node's source location.
*/
import * as vscode from 'vscode';
import * as path from 'node:path';
import { SqliteReader, GraphNode } from '../backend/sqlite';
// ---------------------------------------------------------------------------
// Kind-to-icon mapping (uses VS Code codicon identifiers)
// ---------------------------------------------------------------------------
const KIND_ICON: Record<string, string> = {
Function: '$(symbol-method)',
Class: '$(symbol-class)',
File: '$(file)',
Test: '$(beaker)',
Type: '$(symbol-interface)',
};
/**
* Build a QuickPickItem from a GraphNode.
*/
function nodeToQuickPickItem(
node: GraphNode,
workspaceRoot: string | undefined,
): vscode.QuickPickItem & { node: GraphNode } {
const icon = KIND_ICON[node.kind] ?? '$(symbol-misc)';
const relativePath = workspaceRoot
? path.relative(workspaceRoot, node.filePath)
: node.filePath;
const lineInfo = node.lineStart != null ? `:${node.lineStart}` : '';
return {
label: `${icon} ${node.name}`,
description: node.kind,
detail: `${relativePath}${lineInfo}`,
node,
};
}
/**
* Navigate to a node's source location.
*/
async function navigateToNode(
node: GraphNode,
workspaceRoot: string | undefined,
): Promise<void> {
const filePath = workspaceRoot
? path.join(workspaceRoot, node.filePath)
: node.filePath;
const uri = vscode.Uri.file(filePath);
const doc = await vscode.workspace.openTextDocument(uri);
const line = Math.max(0, (node.lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
selection: new vscode.Range(line, 0, line, 0),
});
}
/**
* Register the `codeReviewGraph.search` command.
*
* Opens a QuickPick with live filtering:
* - As the user types, `reader.searchNodes(value, 20)` is called.
* - Results are displayed with kind-specific icons.
* - On accept, the editor navigates to the selected node.
*/
export function registerSearchCommand(
context: vscode.ExtensionContext,
reader: SqliteReader,
): void {
const disposable = vscode.commands.registerCommand(
'codeReviewGraph.search',
async () => {
const workspaceRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const quickPick = vscode.window.createQuickPick<
vscode.QuickPickItem & { node: GraphNode }
>();
quickPick.placeholder = 'Search for functions, classes, files, types...';
quickPick.matchOnDescription = true;
quickPick.matchOnDetail = true;
// Debounce timer to avoid querying on every keystroke
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
quickPick.onDidChangeValue((value) => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (!value) {
quickPick.items = [];
return;
}
debounceTimer = setTimeout(() => {
const results = reader.searchNodes(value, 20);
quickPick.items = results.map((node) =>
nodeToQuickPickItem(node, workspaceRoot),
);
}, 100);
});
quickPick.onDidAccept(async () => {
const selected = quickPick.selectedItems[0];
quickPick.dispose();
if (selected?.node) {
await navigateToNode(selected.node, workspaceRoot);
}
});
quickPick.onDidHide(() => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
quickPick.dispose();
});
quickPick.show();
},
);
context.subscriptions.push(disposable);
}
@@ -0,0 +1,114 @@
import * as vscode from 'vscode';
import { CliWrapper } from '../backend/cli';
/**
* Handles auto-detection and installation of the Python backend.
*
* Checks whether the `code-review-graph` CLI is available and, if not,
* guides the user through installation via pip/pipx or manual instructions.
*/
export class Installer {
constructor(private cli: CliWrapper) {}
/**
* Check whether the backend is installed and prompt the user if it is not.
*
* @returns `true` if the backend is installed (or was just installed
* successfully), `false` if the user dismissed the prompt or
* installation failed.
*/
async checkAndPrompt(): Promise<boolean> {
const installed = await this.cli.isInstalled();
if (installed) {
return true;
}
const selection = await vscode.window.showInformationMessage(
'Code Review Graph backend is not installed.',
'Install Now',
'Manual Instructions',
'Dismiss',
);
if (selection === 'Install Now') {
return this.autoInstall();
}
if (selection === 'Manual Instructions') {
const terminal = vscode.window.createTerminal('Code Review Graph Setup');
terminal.show();
terminal.sendText('echo "=== Code Review Graph - Manual Installation ==="');
terminal.sendText('echo ""');
terminal.sendText('echo "Option 1: Install with pip"');
terminal.sendText('echo " pip install code-review-graph"');
terminal.sendText('echo ""');
terminal.sendText('echo "Option 2: Install with pipx (recommended)"');
terminal.sendText('echo " pipx install code-review-graph"');
terminal.sendText('echo ""');
terminal.sendText('echo "After installation, reload the VS Code window."');
return false;
}
// Dismissed
return false;
}
/**
* Attempt to automatically install the backend using the first available
* Python package installer (pip or pipx).
*
* @returns `true` if installation succeeded, `false` otherwise.
*/
async autoInstall(): Promise<boolean> {
const installer = await this.cli.detectPythonInstaller();
if (!installer) {
const openLink = 'Download Python';
const response = await vscode.window.showErrorMessage(
'Python 3.10+ is required. Install Python first.',
openLink,
);
if (response === openLink) {
vscode.env.openExternal(
vscode.Uri.parse('https://www.python.org/downloads/'),
);
}
return false;
}
let success = false;
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Installing code-review-graph via ${installer}...`,
cancellable: false,
},
async () => {
try {
await this.cli.installBackend(installer);
success = true;
} catch {
success = false;
}
},
);
if (success) {
vscode.window.showInformationMessage(
'Backend installed successfully!',
);
return true;
}
vscode.window.showErrorMessage(
`Failed to install code-review-graph via ${installer}. ` +
'Check the terminal output for details or try installing manually: ' +
`\`${installer} install code-review-graph\``,
);
return false;
}
}
@@ -0,0 +1,113 @@
import * as vscode from 'vscode';
import { Installer } from './installer';
import { CliWrapper } from '../backend/cli';
/**
* Register command handlers for the walkthrough steps defined in
* `package.json` contributes.walkthroughs.
*
* Each walkthrough step button triggers one of these commands so the user
* can install the CLI, build the graph, and explore the sidebar without
* leaving the walkthrough.
*/
export function registerWalkthroughCommands(
context: vscode.ExtensionContext,
cli: CliWrapper,
installer: Installer,
): void {
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.install',
async () => {
await installer.autoInstall();
},
),
);
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.build',
async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
vscode.window.showWarningMessage(
'No workspace folder is open. Open a folder first.',
);
return;
}
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: 'Code Graph: Building graph...',
cancellable: false,
},
async () => {
await cli.buildGraph(workspaceFolder.uri.fsPath);
},
);
vscode.window.showInformationMessage(
'Code Graph: Build complete.',
);
},
),
);
context.subscriptions.push(
vscode.commands.registerCommand(
'codeReviewGraph.walkthrough.explore',
async () => {
await vscode.commands.executeCommand(
'codeReviewGraph.codeGraph.focus',
);
},
),
);
}
/**
* Show a welcome notification if no graph database has been built yet
* in any of the open workspace folders.
*
* Checks for `.code-review-graph/graph.db` in every workspace folder.
* When none is found, a notification is shown with a button that opens
* the built-in walkthrough.
*/
export async function showWelcomeIfNeeded(
context: vscode.ExtensionContext,
): Promise<void> {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
return;
}
for (const folder of workspaceFolders) {
const dbUri = vscode.Uri.joinPath(
folder.uri,
'.code-review-graph',
'graph.db',
);
try {
await vscode.workspace.fs.stat(dbUri);
// Database exists in at least one folder -- no need to prompt.
return;
} catch {
// File does not exist in this folder -- continue checking.
}
}
// No graph.db found in any workspace folder.
const selection = await vscode.window.showInformationMessage(
'Welcome to Code Review Graph! Get started by building your code graph.',
'Get Started',
);
if (selection === 'Get Started') {
await vscode.commands.executeCommand(
'workbench.action.openWalkthrough',
'tirth8205.code-review-graph#codeReviewGraph.welcome',
);
}
}
@@ -0,0 +1,633 @@
/**
* Webview panel for the interactive graph visualization.
* Uses D3.js (bundled via esbuild) to render a force-directed graph.
*
* Hosts the toolbar HTML, CSS, and manages communication with the
* browser-side graph.ts script.
*/
import * as vscode from "vscode";
import * as path from "node:path";
import * as crypto from "node:crypto";
import type { SqliteReader, ImpactRadius } from "../backend/sqlite";
export class GraphWebviewPanel {
private static currentPanel: GraphWebviewPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private readonly reader: SqliteReader;
private readonly impactRadius?: ImpactRadius;
private readonly highlightQualifiedName?: string;
private disposables: vscode.Disposable[] = [];
private constructor(
panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
reader: SqliteReader,
impactRadius?: ImpactRadius,
highlightQualifiedName?: string
) {
this.panel = panel;
this.reader = reader;
this.impactRadius = impactRadius;
this.highlightQualifiedName = highlightQualifiedName;
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.html = this.getHtmlContent(
this.panel.webview,
extensionUri
);
this.panel.webview.onDidReceiveMessage(
(message) => this.handleMessage(message),
null,
this.disposables
);
// Listen for theme changes
this.disposables.push(
vscode.window.onDidChangeActiveColorTheme((theme) => {
const themeKind =
theme.kind === vscode.ColorThemeKind.Light ||
theme.kind === vscode.ColorThemeKind.HighContrastLight
? "light"
: "dark";
this.panel.webview.postMessage({
command: "setTheme",
theme: themeKind,
});
})
);
}
static createOrShow(
extensionUri: vscode.Uri,
reader: SqliteReader,
impactRadius?: ImpactRadius,
highlightQualifiedName?: string
): void {
const column = vscode.ViewColumn.Beside;
if (GraphWebviewPanel.currentPanel) {
GraphWebviewPanel.currentPanel.panel.reveal(column);
// Re-send data if a new highlight is requested
if (highlightQualifiedName) {
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
command: "highlightNode",
qualifiedName: highlightQualifiedName,
});
}
return;
}
const panel = vscode.window.createWebviewPanel(
"codeReviewGraph.graph",
"Code Graph",
column,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")],
}
);
GraphWebviewPanel.currentPanel = new GraphWebviewPanel(
panel,
extensionUri,
reader,
impactRadius,
highlightQualifiedName
);
}
private dispose(): void {
GraphWebviewPanel.currentPanel = undefined;
this.panel.dispose();
for (const d of this.disposables) {
d.dispose();
}
this.disposables = [];
}
// -----------------------------------------------------------------------
// Message handling
// -----------------------------------------------------------------------
private handleMessage(message: {
command: string;
[key: string]: unknown;
}): void {
switch (message.command) {
case "ready":
this.sendGraphData();
break;
case "nodeClicked":
this.openFileAtLine(
message.filePath as string,
message.lineStart as number
);
// Bidirectional sync: reveal in tree view
if (message.qualifiedName) {
vscode.commands.executeCommand(
"codeReviewGraph.revealInTree",
message.qualifiedName as string
);
}
break;
case "exportSvg":
this.exportSvgToClipboard(message.svg as string);
break;
case "exportPng":
this.savePngToFile(message.data as string);
break;
}
}
/**
* Send full graph data to the webview.
* If an impact radius was provided, send only those nodes/edges.
* Otherwise send the full graph.
*/
private sendGraphData(): void {
let nodes;
let edges;
if (this.impactRadius) {
nodes = [
...this.impactRadius.changedNodes,
...this.impactRadius.impactedNodes,
];
edges = this.impactRadius.edges;
} else {
// Load all nodes and edges
const files = this.reader.getAllFiles();
nodes = files.flatMap((f) => this.reader.getNodesByFile(f));
const qualifiedNames = new Set(nodes.map((n) => n.qualifiedName));
edges = this.reader.getEdgesAmong(qualifiedNames);
}
// Enforce maxNodes setting
const config = vscode.workspace.getConfiguration("codeReviewGraph");
const maxNodes = config.get<number>("graph.maxNodes", 500);
let truncated = false;
if (nodes.length > maxNodes) {
truncated = true;
nodes = nodes.slice(0, maxNodes);
const nodeQns = new Set(nodes.map((n: { qualifiedName: string }) => n.qualifiedName));
edges = edges.filter(
(e: { sourceQualified: string; targetQualified: string }) =>
nodeQns.has(e.sourceQualified) && nodeQns.has(e.targetQualified)
);
}
this.panel.webview.postMessage({
command: "setData",
nodes,
edges,
truncated,
maxNodes,
});
// Send theme
const themeKind =
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
vscode.window.activeColorTheme.kind ===
vscode.ColorThemeKind.HighContrastLight
? "light"
: "dark";
this.panel.webview.postMessage({
command: "setTheme",
theme: themeKind,
});
// Highlight node if requested
if (this.highlightQualifiedName) {
// Small delay to let the graph render first
setTimeout(() => {
this.panel.webview.postMessage({
command: "highlightNode",
qualifiedName: this.highlightQualifiedName,
});
}, 1000);
}
}
/**
* Open a file in the editor at a specific line.
*/
private async openFileAtLine(
filePath: string,
lineStart: number
): Promise<void> {
const workspaceRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const fullPath = workspaceRoot
? path.join(workspaceRoot, filePath)
: filePath;
try {
const doc = await vscode.workspace.openTextDocument(fullPath);
const line = Math.max(0, (lineStart ?? 1) - 1);
await vscode.window.showTextDocument(doc, {
viewColumn: vscode.ViewColumn.One,
selection: new vscode.Range(line, 0, line, 0),
preserveFocus: false,
});
} catch {
vscode.window.showWarningMessage(
`Code Graph: Could not open file ${filePath}`
);
}
}
/**
* Copy SVG string to clipboard.
*/
private async exportSvgToClipboard(svgString: string): Promise<void> {
await vscode.env.clipboard.writeText(svgString);
vscode.window.showInformationMessage(
"Code Graph: SVG copied to clipboard."
);
}
/**
* Save PNG data URL to a file.
*/
private async savePngToFile(dataUrl: string): Promise<void> {
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file("code-graph.png"),
filters: { "PNG Image": ["png"] },
});
if (!uri) { return; }
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
const buffer = Buffer.from(base64, "base64");
await vscode.workspace.fs.writeFile(uri, buffer);
vscode.window.showInformationMessage("Code Graph: PNG saved.");
}
/**
* Highlight a node by qualified name from external code (tree view click).
*/
static highlightNode(qualifiedName: string): void {
if (GraphWebviewPanel.currentPanel) {
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
command: "highlightNode",
qualifiedName,
});
}
}
// -----------------------------------------------------------------------
// HTML content
// -----------------------------------------------------------------------
private getHtmlContent(
webview: vscode.Webview,
extensionUri: vscode.Uri
): string {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(extensionUri, "dist", "webview", "graph.js")
);
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src ${webview.cspSource};">
<title>Code Graph</title>
<style>
/* ------------------------------------------------------------------ */
/* CSS variables from VS Code theme */
/* ------------------------------------------------------------------ */
:root {
--bg: var(--vscode-editor-background, #1e1e2e);
--fg: var(--vscode-editor-foreground, #cdd6f4);
--toolbar-bg: var(--vscode-sideBar-background, #181825);
--toolbar-border: var(--vscode-panel-border, #313244);
--input-bg: var(--vscode-input-background, #313244);
--input-fg: var(--vscode-input-foreground, #cdd6f4);
--input-border: var(--vscode-input-border, #45475a);
--btn-bg: var(--vscode-button-background, #89b4fa);
--btn-fg: var(--vscode-button-foreground, #1e1e2e);
--btn-hover: var(--vscode-button-hoverBackground, #74c7ec);
--badge-bg: var(--vscode-badge-background, #45475a);
--badge-fg: var(--vscode-badge-foreground, #cdd6f4);
--font: var(--vscode-font-family, 'Segoe UI', sans-serif);
--font-size: var(--vscode-font-size, 13px);
--font-mono: var(--vscode-editor-font-family, 'Fira Code', monospace);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
font-size: var(--font-size);
background: var(--bg);
color: var(--fg);
overflow: hidden;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ------------------------------------------------------------------ */
/* Toolbar */
/* ------------------------------------------------------------------ */
#toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: var(--toolbar-bg);
border-bottom: 1px solid var(--toolbar-border);
flex-shrink: 0;
flex-wrap: wrap;
min-height: 40px;
}
#toolbar .toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
#toolbar .toolbar-label {
font-size: 11px;
color: var(--fg);
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
#toolbar .toolbar-separator {
width: 1px;
height: 20px;
background: var(--toolbar-border);
margin: 0 4px;
}
/* Search */
#search-input {
background: var(--input-bg);
color: var(--input-fg);
border: 1px solid var(--input-border);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-family: var(--font);
width: 180px;
outline: none;
}
#search-input:focus {
border-color: var(--btn-bg);
}
#search-input::placeholder {
color: var(--fg);
opacity: 0.4;
}
/* Edge pills */
.edge-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
user-select: none;
border: 1px solid transparent;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.edge-pill.active {
opacity: 1;
border-color: currentColor;
}
.edge-pill:focus-visible { outline: 2px solid var(--btn-bg, #89b4fa); outline-offset: 2px; }
.edge-pill .pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Edge filter popover */
#edge-popover {
display: none;
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--toolbar-bg);
border: 1px solid var(--toolbar-border);
border-radius: 6px;
padding: 8px 12px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
min-width: 160px;
}
#edge-popover.visible { display: flex; flex-wrap: wrap; gap: 6px; }
#edge-filter-wrap { position: relative; }
/* Depth slider */
#depth-slider {
width: 80px;
accent-color: var(--btn-bg);
cursor: pointer;
}
#depth-slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#depth-value {
font-size: 11px;
font-family: var(--font-mono);
min-width: 24px;
text-align: center;
}
/* Toolbar buttons */
.toolbar-btn {
background: var(--badge-bg);
color: var(--badge-fg);
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
font-family: var(--font);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.toolbar-btn:hover {
background: var(--btn-bg);
color: var(--btn-fg);
}
/* Node count badge */
#node-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 11px;
color: var(--fg);
opacity: 0.6;
white-space: nowrap;
z-index: 5;
}
/* ------------------------------------------------------------------ */
/* Graph area */
/* ------------------------------------------------------------------ */
#graph-area {
flex: 1;
overflow: hidden;
position: relative;
}
#graph-area svg {
display: block;
}
/* ------------------------------------------------------------------ */
/* Tooltip */
/* ------------------------------------------------------------------ */
#tooltip {
display: none;
position: fixed;
z-index: 1000;
background: var(--toolbar-bg);
border: 1px solid var(--toolbar-border);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--fg);
max-width: 360px;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
line-height: 1.5;
}
#tooltip strong {
font-size: 13px;
}
#tooltip .tooltip-kind {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 6px;
border-radius: 3px;
background: var(--badge-bg);
color: var(--badge-fg);
margin: 2px 0;
}
#tooltip .tooltip-path {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.7;
word-break: break-all;
}
#tooltip .tooltip-params {
font-family: var(--font-mono);
font-size: 11px;
color: var(--vscode-debugTokenExpression-string, #a6e3a1);
}
#tooltip .tooltip-return {
font-family: var(--font-mono);
font-size: 11px;
color: var(--vscode-debugTokenExpression-number, #89b4fa);
}
/* ------------------------------------------------------------------ */
/* Pulse animation for highlighted nodes */
/* ------------------------------------------------------------------ */
@keyframes pulse {
0% { r: 16; stroke-opacity: 1; }
100% { r: 30; stroke-opacity: 0; }
}
.pulse-ring {
animation: pulse 0.6s ease-out;
}
path.node-shape:focus { outline: none; }
path.node-shape:focus-visible { stroke: var(--btn-bg, #89b4fa) !important; stroke-width: 3 !important; }
</style>
</head>
<body>
<!-- Toolbar -->
<div id="toolbar">
<!-- Search -->
<div class="toolbar-group">
<input id="search-input" type="text" placeholder="Search nodes..." spellcheck="false" aria-label="Search nodes" />
</div>
<div class="toolbar-separator"></div>
<!-- Edge type filter -->
<div class="toolbar-group" id="edge-filter-wrap">
<button id="btn-edge-filter" class="toolbar-btn" title="Toggle edge type filters" aria-label="Toggle edge type filters">Edges</button>
<div id="edge-popover">
<span id="edge-CALLS" class="edge-pill active" style="color:#3fb950" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Calls edges" title="Toggle CALLS edges"><span class="pill-dot" style="background:#3fb950"></span>Calls</span>
<span id="edge-IMPORTS_FROM" class="edge-pill active" style="color:#f0883e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Imports edges" title="Toggle IMPORTS_FROM edges"><span class="pill-dot" style="background:#f0883e"></span>Imports</span>
<span id="edge-INHERITS" class="edge-pill active" style="color:#d2a8ff" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Inherits edges" title="Toggle INHERITS edges"><span class="pill-dot" style="background:#d2a8ff"></span>Inherits</span>
<span id="edge-IMPLEMENTS" class="edge-pill active" style="color:#f9e2af" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Implements edges" title="Toggle IMPLEMENTS edges"><span class="pill-dot" style="background:#f9e2af"></span>Implements</span>
<span id="edge-TESTED_BY" class="edge-pill active" style="color:#f38ba8" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Tested edges" title="Toggle TESTED_BY edges"><span class="pill-dot" style="background:#f38ba8"></span>Tested</span>
<span id="edge-CONTAINS" class="edge-pill active" style="color:#8b949e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Contains edges" title="Toggle CONTAINS edges"><span class="pill-dot" style="background:#8b949e"></span>Contains</span>
<span id="edge-DEPENDS_ON" class="edge-pill active" style="color:#fab387" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Depends edges" title="Toggle DEPENDS_ON edges"><span class="pill-dot" style="background:#fab387"></span>Depends</span>
</div>
</div>
<div class="toolbar-separator"></div>
<!-- Depth slider -->
<div class="toolbar-group">
<span class="toolbar-label">Depth</span>
<input id="depth-slider" type="range" min="0" max="10" value="0" aria-label="Graph depth limit" disabled title="Select a node first, then adjust depth" />
<span id="depth-value">All</span>
</div>
<div class="toolbar-separator"></div>
<!-- Action buttons -->
<div class="toolbar-group">
<button id="btn-fit" class="toolbar-btn">Fit</button>
<button id="btn-export" class="toolbar-btn">Export SVG</button>
<button id="btn-export-png" class="toolbar-btn">Export PNG</button>
</div>
</div>
<!-- Graph -->
<div id="graph-area">
<span id="node-count" aria-live="polite"></span>
<span id="truncation-warning" style="display:none;color:var(--btn-bg);font-size:11px;cursor:pointer;" title="Increase codeReviewGraph.graph.maxNodes in settings"></span>
<div id="empty-state" style="display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--fg);opacity:0.6">
<p style="font-size:16px;margin-bottom:12px">No graph data available</p>
<p style="font-size:13px">Run <strong>Code Graph: Build Graph</strong> from the Command Palette to get started.</p>
</div>
</div>
<!-- Tooltip -->
<div id="tooltip" role="tooltip" aria-live="polite"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
}
function getNonce(): string {
return crypto.randomBytes(16).toString("hex");
}
@@ -0,0 +1,89 @@
import * as vscode from 'vscode';
import { SqliteReader } from '../backend/sqlite';
/** Number of milliseconds in one hour. */
const ONE_HOUR_MS = 60 * 60 * 1000;
/**
* Manages a status bar item that shows a summary of the code graph
* database and its staleness.
*
* Clicking the status bar item triggers `codeReviewGraph.updateGraph`.
*/
export class StatusBar implements vscode.Disposable {
private item: vscode.StatusBarItem;
constructor() {
this.item = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left,
100,
);
this.item.command = 'codeReviewGraph.updateGraph';
}
/**
* Update the status bar text, icon, and tooltip based on the current
* state of the graph database.
*
* @param reader The open SQLite reader, or `undefined` if no database
* is loaded.
*/
update(reader: SqliteReader | undefined): void {
if (!reader) {
this.item.text = '$(warning) Code Graph: Not built';
this.item.tooltip = 'Click to build';
return;
}
const stats = reader.getStats();
const lastUpdated = stats.lastUpdated;
const isOutdated = this.isOlderThanOneHour(lastUpdated);
if (isOutdated) {
this.item.text = '$(warning) Code Graph: Outdated';
this.item.tooltip =
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
`Last updated: ${lastUpdated || 'unknown'}`;
} else {
this.item.text = `$(database) ${stats.totalNodes} nodes`;
this.item.tooltip =
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
`Last updated: ${lastUpdated || 'unknown'}`;
}
}
/** Show the status bar item. */
show(): void {
this.item.show();
}
/** Hide the status bar item. */
hide(): void {
this.item.hide();
}
/** Dispose the status bar item. */
dispose(): void {
this.item.dispose();
}
/**
* Determine whether `lastUpdated` is more than one hour in the past.
*
* Returns `true` if the timestamp is missing, unparseable, or older
* than one hour.
*/
private isOlderThanOneHour(lastUpdated: string | null): boolean {
if (!lastUpdated) {
return true;
}
const updatedTime = new Date(lastUpdated).getTime();
if (isNaN(updatedTime)) {
return true;
}
return Date.now() - updatedTime > ONE_HOUR_MS;
}
}
@@ -0,0 +1,234 @@
import * as vscode from 'vscode';
import * as path from 'path';
// ---------------------------------------------------------------------------
// FileTreeItem represents a source file in the code graph
// ---------------------------------------------------------------------------
export class FileTreeItem extends vscode.TreeItem {
public readonly filePath: string;
public readonly qualifiedName: string;
constructor(filePath: string, workspaceRoot: string) {
const fileName = path.basename(filePath);
super(fileName, vscode.TreeItemCollapsibleState.Collapsed);
this.filePath = filePath;
this.qualifiedName = filePath;
const relativePath = path.relative(workspaceRoot, filePath);
this.description = relativePath !== fileName ? relativePath : '';
this.iconPath = new vscode.ThemeIcon('file');
this.contextValue = 'node-file';
this.tooltip = filePath;
this.command = {
title: 'Open File',
command: 'vscode.open',
arguments: [vscode.Uri.file(filePath)],
};
}
}
// ---------------------------------------------------------------------------
// SymbolTreeItem represents a class, function, type, or test node
// ---------------------------------------------------------------------------
const KIND_ICON_MAP: Record<string, string> = {
Function: 'symbol-method',
Class: 'symbol-class',
Type: 'symbol-interface',
Test: 'testing-run-icon',
};
const KIND_CONTEXT_MAP: Record<string, string> = {
Function: 'node-function',
Class: 'node-class',
Type: 'node-type',
Test: 'node-test',
};
function formatSymbolLabel(name: string, kind: string): string {
if (kind === 'Function' || kind === 'Test') {
return `${name}()`;
}
return name;
}
function formatSymbolDescription(kind: string, lineStart: number | null, lineEnd: number | null): string {
const kindLower = kind.toLowerCase();
if (lineStart != null && lineEnd != null) {
return `${kindLower} \u00b7 L${lineStart}\u2013${lineEnd}`;
}
if (lineStart != null) {
return `${kindLower} \u00b7 L${lineStart}`;
}
return kindLower;
}
export class SymbolTreeItem extends vscode.TreeItem {
public readonly qualifiedName: string;
public readonly filePath: string;
public readonly lineStart: number | null;
public readonly kind: string;
constructor(
qualifiedName: string,
name: string,
kind: string,
filePath: string,
lineStart: number | null,
lineEnd: number | null,
) {
const label = formatSymbolLabel(name, kind);
super(label, vscode.TreeItemCollapsibleState.Collapsed);
this.qualifiedName = qualifiedName;
this.filePath = filePath;
this.lineStart = lineStart;
this.kind = kind;
this.description = formatSymbolDescription(kind, lineStart, lineEnd);
this.iconPath = new vscode.ThemeIcon(KIND_ICON_MAP[kind] ?? 'symbol-misc');
this.contextValue = KIND_CONTEXT_MAP[kind] ?? 'node-function';
this.tooltip = qualifiedName;
const line = lineStart != null ? lineStart - 1 : 0;
this.command = {
title: 'Go to Symbol',
command: 'vscode.open',
arguments: [
vscode.Uri.file(filePath),
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
],
};
}
}
// ---------------------------------------------------------------------------
// EdgeTreeItem represents a relationship edge (leaf node)
// ---------------------------------------------------------------------------
const OUTGOING_EDGE_LABELS: Record<string, string> = {
CALLS: 'calls',
IMPORTS_FROM: 'imports',
INHERITS: 'inherits from',
IMPLEMENTS: 'implements',
TESTED_BY: 'tested by',
CONTAINS: 'contains',
DEPENDS_ON: 'depends on',
};
const INCOMING_EDGE_LABELS: Record<string, string> = {
CALLS: 'called by',
IMPORTS_FROM: 'imported by',
INHERITS: 'inherited by',
IMPLEMENTS: 'implemented by',
TESTED_BY: 'tests',
CONTAINS: 'contained in',
DEPENDS_ON: 'depended on by',
};
const EDGE_ICON_MAP_OUTGOING: Record<string, string> = {
CALLS: 'arrow-right',
IMPORTS_FROM: 'package',
INHERITS: 'type-hierarchy',
IMPLEMENTS: 'symbol-interface',
TESTED_BY: 'testing-run-icon',
CONTAINS: 'symbol-namespace',
DEPENDS_ON: 'references',
};
const EDGE_ICON_MAP_INCOMING: Record<string, string> = {
CALLS: 'arrow-left',
IMPORTS_FROM: 'package',
INHERITS: 'type-hierarchy',
IMPLEMENTS: 'symbol-interface',
TESTED_BY: 'testing-run-icon',
CONTAINS: 'symbol-namespace',
DEPENDS_ON: 'references',
};
function extractShortName(qualifiedName: string): string {
// Qualified names are like "/path/to/file.py::ClassName.method" or "/path/to/file.py"
const colonIdx = qualifiedName.lastIndexOf('::');
if (colonIdx >= 0) {
return qualifiedName.substring(colonIdx + 2);
}
return path.basename(qualifiedName);
}
export class EdgeTreeItem extends vscode.TreeItem {
public readonly targetQualifiedName: string;
public readonly targetFilePath: string;
public readonly targetLine: number;
constructor(
edgeKind: string,
direction: 'outgoing' | 'incoming',
targetQualifiedName: string,
targetFilePath: string,
targetLine: number,
) {
const shortName = extractShortName(targetQualifiedName);
const verb = direction === 'outgoing'
? (OUTGOING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase())
: (INCOMING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase());
const arrow = direction === 'outgoing' ? '\u2192' : '\u2190';
const label = `${arrow} ${verb} ${shortName}`;
super(label, vscode.TreeItemCollapsibleState.None);
this.targetQualifiedName = targetQualifiedName;
this.targetFilePath = targetFilePath;
this.targetLine = targetLine;
const iconMap = direction === 'outgoing' ? EDGE_ICON_MAP_OUTGOING : EDGE_ICON_MAP_INCOMING;
this.iconPath = new vscode.ThemeIcon(iconMap[edgeKind] ?? 'arrow-right');
this.contextValue = 'edge';
this.tooltip = `${arrow} ${verb} ${targetQualifiedName}`;
const line = targetLine > 0 ? targetLine - 1 : 0;
this.command = {
title: 'Go to Target',
command: 'vscode.open',
arguments: [
vscode.Uri.file(targetFilePath),
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
],
};
}
}
// ---------------------------------------------------------------------------
// BlastRadiusGroupItem groups "Changed" and "Impacted" results
// ---------------------------------------------------------------------------
export class BlastRadiusGroupItem extends vscode.TreeItem {
public readonly groupKind: 'changed' | 'impacted';
constructor(groupKind: 'changed' | 'impacted', count: number) {
const label = groupKind === 'changed' ? `Changed (${count})` : `Impacted (${count})`;
super(label, vscode.TreeItemCollapsibleState.Expanded);
this.groupKind = groupKind;
this.iconPath = new vscode.ThemeIcon(groupKind === 'changed' ? 'flame' : 'broadcast');
this.contextValue = `blast-radius-${groupKind}`;
this.tooltip = groupKind === 'changed'
? `${count} directly changed node(s)`
: `${count} transitively impacted node(s)`;
}
}
// ---------------------------------------------------------------------------
// StatsItem displays a single statistic line (leaf node)
// ---------------------------------------------------------------------------
export class StatsItem extends vscode.TreeItem {
constructor(label: string, value: string) {
super(label, vscode.TreeItemCollapsibleState.None);
this.description = value;
this.contextValue = 'stat';
this.tooltip = `${label}: ${value}`;
}
}
@@ -0,0 +1,237 @@
import * as vscode from 'vscode';
import { SqliteReader, GraphNode, GraphEdge } from '../backend/sqlite';
import {
FileTreeItem,
SymbolTreeItem,
EdgeTreeItem,
BlastRadiusGroupItem,
StatsItem,
} from './treeItems';
// ---------------------------------------------------------------------------
// CodeGraphTreeProvider -- main file > symbol > edge tree
// ---------------------------------------------------------------------------
export class CodeGraphTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
constructor(
private readonly reader: SqliteReader,
private readonly workspaceRoot: string,
) {}
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
if (!element) {
return this.getRootChildren();
}
if (element instanceof FileTreeItem) {
return this.getFileChildren(element);
}
if (element instanceof SymbolTreeItem) {
return this.getSymbolChildren(element);
}
return [];
}
// -- Root level: one FileTreeItem per file --------------------------------
private getRootChildren(): vscode.TreeItem[] {
const files = this.reader.getAllFiles();
return files
.slice()
.sort((a, b) => a.localeCompare(b))
.map((filePath) => new FileTreeItem(filePath, this.workspaceRoot));
}
// -- File level: symbols (non-File nodes) sorted by line ------------------
private getFileChildren(fileItem: FileTreeItem): vscode.TreeItem[] {
const nodes = this.reader.getNodesByFile(fileItem.filePath);
return nodes
.filter((n) => n.kind !== 'File')
.sort((a, b) => (a.lineStart ?? 0) - (b.lineStart ?? 0))
.map(
(n) =>
new SymbolTreeItem(
n.qualifiedName,
n.name,
n.kind,
n.filePath,
n.lineStart,
n.lineEnd,
),
);
}
// -- Symbol level: outgoing + incoming edges (skip CONTAINS) --------------
private getSymbolChildren(symbolItem: SymbolTreeItem): vscode.TreeItem[] {
const items: vscode.TreeItem[] = [];
// Outgoing edges
const outgoing = this.reader.getEdgesBySource(symbolItem.qualifiedName);
for (const edge of outgoing) {
if (edge.kind === 'CONTAINS') {
continue;
}
const targetNode = this.reader.getNode(edge.targetQualified);
const targetFile = targetNode?.filePath ?? edge.filePath;
const targetLine = targetNode?.lineStart ?? edge.line;
items.push(
new EdgeTreeItem(
edge.kind,
'outgoing',
edge.targetQualified,
targetFile,
targetLine,
),
);
}
// Incoming edges
const incoming = this.reader.getEdgesByTarget(symbolItem.qualifiedName);
for (const edge of incoming) {
if (edge.kind === 'CONTAINS') {
continue;
}
const sourceNode = this.reader.getNode(edge.sourceQualified);
const sourceFile = sourceNode?.filePath ?? edge.filePath;
const sourceLine = sourceNode?.lineStart ?? edge.line;
items.push(
new EdgeTreeItem(
edge.kind,
'incoming',
edge.sourceQualified,
sourceFile,
sourceLine,
),
);
}
return items;
}
}
// ---------------------------------------------------------------------------
// BlastRadiusTreeProvider -- shows changed + impacted nodes
// ---------------------------------------------------------------------------
export class BlastRadiusTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
private changedNodes: GraphNode[] = [];
private impactedNodes: GraphNode[] = [];
setResults(changed: GraphNode[], impacted: GraphNode[]): void {
this.changedNodes = changed;
this.impactedNodes = impacted;
this._onDidChangeTreeData.fire(undefined);
}
clear(): void {
this.changedNodes = [];
this.impactedNodes = [];
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
if (!element) {
return this.getRootChildren();
}
if (element instanceof BlastRadiusGroupItem) {
return this.getGroupChildren(element);
}
return [];
}
private getRootChildren(): vscode.TreeItem[] {
if (this.changedNodes.length === 0 && this.impactedNodes.length === 0) {
return [];
}
const groups: vscode.TreeItem[] = [];
if (this.changedNodes.length > 0) {
groups.push(new BlastRadiusGroupItem('changed', this.changedNodes.length));
}
if (this.impactedNodes.length > 0) {
groups.push(new BlastRadiusGroupItem('impacted', this.impactedNodes.length));
}
return groups;
}
private getGroupChildren(group: BlastRadiusGroupItem): vscode.TreeItem[] {
const nodes = group.groupKind === 'changed' ? this.changedNodes : this.impactedNodes;
return nodes.map(
(n) =>
new SymbolTreeItem(
n.qualifiedName,
n.name,
n.kind,
n.filePath,
n.lineStart,
n.lineEnd,
),
);
}
}
// ---------------------------------------------------------------------------
// StatsTreeProvider -- graph statistics overview
// ---------------------------------------------------------------------------
export class StatsTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
constructor(private readonly reader: SqliteReader) {}
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
return element;
}
getChildren(): vscode.ProviderResult<vscode.TreeItem[]> {
const stats = this.reader.getStats();
const items: StatsItem[] = [];
items.push(new StatsItem('Files', stats.filesCount.toLocaleString()));
items.push(new StatsItem('Total Nodes', stats.totalNodes.toLocaleString()));
items.push(new StatsItem('Total Edges', stats.totalEdges.toLocaleString()));
items.push(
new StatsItem(
'Languages',
stats.languages.length > 0 ? stats.languages.join(', ') : 'none',
),
);
items.push(
new StatsItem(
'Last Updated',
stats.lastUpdated ?? 'unknown',
),
);
items.push(
new StatsItem(
'Embeddings',
stats.embeddingsCount > 0 ? stats.embeddingsCount.toLocaleString() : 'none',
),
);
return items;
}
}
@@ -0,0 +1,990 @@
/**
* Webview entry point for the D3.js force-directed graph visualization.
* Runs in the browser context inside the VS Code webview panel.
*
* Communicates with the extension host via postMessage / addEventListener.
* NO Node.js APIs are available here.
*/
import * as d3 from "d3";
declare function acquireVsCodeApi(): {
postMessage(msg: unknown): void;
getState(): unknown;
setState(state: unknown): void;
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type NodeKind = "File" | "Class" | "Function" | "Test" | "Type";
type EdgeKind =
| "CALLS"
| "IMPORTS_FROM"
| "INHERITS"
| "IMPLEMENTS"
| "TESTED_BY"
| "CONTAINS"
| "DEPENDS_ON";
interface GraphNode {
id: number;
kind: NodeKind;
name: string;
qualifiedName: string;
filePath: string;
lineStart: number | null;
lineEnd: number | null;
language: string | null;
parentName: string | null;
params: string | null;
returnType: string | null;
modifiers: string | null;
isTest: boolean;
fileHash: string | null;
}
interface GraphEdge {
id: number;
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
filePath: string;
line: number;
}
/** D3 simulation node extends GraphNode with x/y/vx/vy. */
interface SimNode extends d3.SimulationNodeDatum, GraphNode {}
/** D3 simulation link with resolved source/target. */
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
kind: EdgeKind;
sourceQualified: string;
targetQualified: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NODE_RADIUS: Record<NodeKind, number> = {
File: 18,
Class: 12,
Function: 6,
Test: 6,
Type: 5,
};
const NODE_COLOR: Record<NodeKind, string> = {
File: "#58a6ff",
Class: "#f0883e",
Function: "#3fb950",
Test: "#d2a8ff",
Type: "#8b949e",
};
const NODE_SHAPE: Record<NodeKind, d3.SymbolType> = {
File: d3.symbolCircle,
Class: d3.symbolSquare,
Function: d3.symbolTriangle,
Test: d3.symbolDiamond,
Type: d3.symbolCross,
};
const NODE_AREA: Record<NodeKind, number> = {
File: 616,
Class: 452,
Function: 314,
Test: 314,
Type: 314,
};
const EDGE_COLOR: Record<EdgeKind, string> = {
CALLS: "#3fb950",
IMPORTS_FROM: "#f0883e",
INHERITS: "#d2a8ff",
IMPLEMENTS: "#f9e2af",
TESTED_BY: "#f38ba8",
CONTAINS: "rgba(139,148,158,0.15)",
DEPENDS_ON: "#fab387",
};
const ALL_EDGE_KINDS: EdgeKind[] = [
"CALLS",
"IMPORTS_FROM",
"INHERITS",
"IMPLEMENTS",
"TESTED_BY",
"CONTAINS",
"DEPENDS_ON",
];
// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const vscodeApi = acquireVsCodeApi();
let allNodes: SimNode[] = [];
let allEdges: SimLink[] = [];
let nodeMap = new Map<string, SimNode>();
let visibleEdgeKinds = new Set<EdgeKind>(ALL_EDGE_KINDS);
let selectedNode: SimNode | null = null;
let depthLimit = 0; // 0 = show all
let simulation: d3.Simulation<SimNode, SimLink> | null = null;
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
let container: d3.Selection<SVGGElement, unknown, null, undefined>;
let linkGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let nodeGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let labelGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown>;
let linkSelection: d3.Selection<SVGLineElement, SimLink, SVGGElement, unknown>;
let nodeSelection: d3.Selection<SVGPathElement, SimNode, SVGGElement, unknown>;
let labelSelection: d3.Selection<SVGTextElement, SimNode, SVGGElement, unknown>;
let currentTheme: "dark" | "light" = "dark";
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
function init(): void {
createSvg();
bindToolbarEvents();
bindExtensionMessages();
vscodeApi.postMessage({ command: "ready" });
}
// ---------------------------------------------------------------------------
// SVG setup
// ---------------------------------------------------------------------------
function createSvg(): void {
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth || window.innerWidth;
const height = graphEl.clientHeight || window.innerHeight;
svg = d3
.select(graphEl)
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${width} ${height}`);
// Arrow marker definitions -- one per edge kind
const defs = svg.append("defs");
for (const kind of ALL_EDGE_KINDS) {
defs
.append("marker")
.attr("id", `arrow-${kind}`)
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", EDGE_COLOR[kind]);
}
container = svg.append("g").attr("class", "graph-container");
linkGroup = container.append("g").attr("class", "links");
nodeGroup = container.append("g").attr("class", "nodes");
labelGroup = container.append("g").attr("class", "labels");
// Initialize empty selections
linkSelection = linkGroup.selectAll<SVGLineElement, SimLink>("line");
nodeSelection = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape");
labelSelection = labelGroup.selectAll<SVGTextElement, SimNode>("text");
// Zoom + pan
zoomBehavior = d3
.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.05, 8])
.on("zoom", (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
container.attr("transform", event.transform.toString());
});
svg.call(zoomBehavior);
// Resize handler
const resizeObserver = new ResizeObserver(() => {
const w = graphEl.clientWidth;
const h = graphEl.clientHeight;
svg.attr("viewBox", `0 0 ${w} ${h}`);
});
resizeObserver.observe(graphEl);
}
// ---------------------------------------------------------------------------
// Data ingestion
// ---------------------------------------------------------------------------
function setData(nodes: GraphNode[], edges: GraphEdge[]): void {
// Build SimNodes
allNodes = nodes.map((n) => ({ ...n } as SimNode));
nodeMap = new Map(allNodes.map((n) => [n.qualifiedName, n]));
// Build SimLinks, filtering to edges where both endpoints exist
allEdges = [];
for (const e of edges) {
const src = nodeMap.get(e.sourceQualified);
const tgt = nodeMap.get(e.targetQualified);
if (src && tgt) {
allEdges.push({
source: src,
target: tgt,
kind: e.kind,
sourceQualified: e.sourceQualified,
targetQualified: e.targetQualified,
});
}
}
// Reset depth filter
depthLimit = 0;
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (slider) {
slider.value = "0";
}
const depthValue = document.getElementById("depth-value");
if (depthValue) {
depthValue.textContent = "All";
}
// Show/hide empty state
const emptyState = document.getElementById("empty-state");
const graphArea = document.getElementById("graph-area");
if (nodes.length === 0) {
if (emptyState) emptyState.style.display = "block";
if (graphArea) {
// Hide the SVG but keep the container
const svgHide = graphArea.querySelector("svg");
if (svgHide) svgHide.style.display = "none";
}
updateDepthSliderState();
return;
}
if (emptyState) emptyState.style.display = "none";
const svgEl = graphArea?.querySelector("svg");
if (svgEl) svgEl.style.display = "";
buildGraph();
updateDepthSliderState();
}
// ---------------------------------------------------------------------------
// Graph construction
// ---------------------------------------------------------------------------
function getVisibleData(): { nodes: SimNode[]; links: SimLink[] } {
// Filter edges by visible kinds
let links = allEdges.filter((e) => visibleEdgeKinds.has(e.kind));
let nodes: SimNode[];
if (selectedNode && depthLimit > 0) {
// BFS from selected node up to depthLimit
const reachable = new Set<string>();
reachable.add(selectedNode.qualifiedName);
let frontier = new Set<string>([selectedNode.qualifiedName]);
for (let d = 0; d < depthLimit; d++) {
const next = new Set<string>();
for (const qn of frontier) {
for (const link of links) {
const srcQn =
typeof link.source === "object"
? (link.source as SimNode).qualifiedName
: link.sourceQualified;
const tgtQn =
typeof link.target === "object"
? (link.target as SimNode).qualifiedName
: link.targetQualified;
if (srcQn === qn && !reachable.has(tgtQn)) {
reachable.add(tgtQn);
next.add(tgtQn);
}
if (tgtQn === qn && !reachable.has(srcQn)) {
reachable.add(srcQn);
next.add(srcQn);
}
}
}
frontier = next;
if (frontier.size === 0) break;
}
nodes = allNodes.filter((n) => reachable.has(n.qualifiedName));
const reachableSet = reachable;
links = links.filter((l) => {
const srcQn =
typeof l.source === "object"
? (l.source as SimNode).qualifiedName
: l.sourceQualified;
const tgtQn =
typeof l.target === "object"
? (l.target as SimNode).qualifiedName
: l.targetQualified;
return reachableSet.has(srcQn) && reachableSet.has(tgtQn);
});
} else {
nodes = [...allNodes];
}
// Apply search filter
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
const query = searchInput?.value?.trim().toLowerCase() ?? "";
if (query.length > 0) {
const matchingQns = new Set(
nodes
.filter((n) => n.name.toLowerCase().includes(query) || n.qualifiedName.toLowerCase().includes(query))
.map((n) => n.qualifiedName)
);
// Keep matching nodes + their direct neighbors
const expanded = new Set(matchingQns);
for (const link of links) {
const srcQn =
typeof link.source === "object"
? (link.source as SimNode).qualifiedName
: link.sourceQualified;
const tgtQn =
typeof link.target === "object"
? (link.target as SimNode).qualifiedName
: link.targetQualified;
if (matchingQns.has(srcQn)) expanded.add(tgtQn);
if (matchingQns.has(tgtQn)) expanded.add(srcQn);
}
nodes = nodes.filter((n) => expanded.has(n.qualifiedName));
links = links.filter((l) => {
const srcQn =
typeof l.source === "object"
? (l.source as SimNode).qualifiedName
: l.sourceQualified;
const tgtQn =
typeof l.target === "object"
? (l.target as SimNode).qualifiedName
: l.targetQualified;
return expanded.has(srcQn) && expanded.has(tgtQn);
});
}
return { nodes, links };
}
function buildGraph(): void {
const { nodes, links } = getVisibleData();
// Stop existing simulation
if (simulation) {
simulation.stop();
}
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth || window.innerWidth;
const height = graphEl.clientHeight || window.innerHeight;
// --- Links ---
linkSelection = linkGroup
.selectAll<SVGLineElement, SimLink>("line")
.data(links, (d) => `${d.sourceQualified}-${d.targetQualified}-${d.kind}`)
.join("line")
.attr("stroke", (d) => EDGE_COLOR[d.kind])
.attr("stroke-width", 1.5)
.attr("stroke-opacity", 0.4)
.attr("marker-end", (d) => `url(#arrow-${d.kind})`);
// --- Nodes ---
nodeSelection = nodeGroup
.selectAll<SVGPathElement, SimNode>("path.node-shape")
.data(nodes, (d) => d.qualifiedName)
.join("path")
.attr("class", "node-shape")
.attr("d", (d) => d3.symbol().type(NODE_SHAPE[d.kind] ?? d3.symbolCircle).size(NODE_AREA[d.kind] ?? 314)()!)
.attr("fill", (d) => NODE_COLOR[d.kind] ?? "#cdd6f4")
.attr("stroke", "none")
.attr("stroke-width", 2)
.attr("cursor", "pointer")
.on("click", (_event, d) => {
selectNode(d);
vscodeApi.postMessage({
command: "nodeClicked",
qualifiedName: d.qualifiedName,
filePath: d.filePath,
lineStart: d.lineStart ?? 1,
});
})
.on("dblclick", (_event, d) => {
// Center on node and expand depth by 1
selectNode(d);
depthLimit = Math.min(depthLimit + 1, 10);
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (slider) slider.value = String(depthLimit);
const depthValue = document.getElementById("depth-value");
if (depthValue) depthValue.textContent = String(depthLimit);
buildGraph();
centerOnNode(d);
})
.on("mouseenter", (_event, d) => {
showTooltip(d);
highlightConnected(d);
})
.on("mouseleave", () => {
hideTooltip();
unhighlightAll();
})
.call(
d3
.drag<SVGPathElement, SimNode>()
.on("start", (event, d) => {
if (!event.active) simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
})
)
.attr("tabindex", 0)
.attr("role", "button")
.attr("aria-label", (d) => `${d.kind}: ${d.name}`)
.on("keydown", (event: KeyboardEvent, d: SimNode) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectNode(d);
vscodeApi.postMessage({
command: "nodeClicked",
qualifiedName: d.qualifiedName,
filePath: d.filePath,
lineStart: d.lineStart ?? 1,
});
} else if (event.key === "Escape") {
event.preventDefault();
selectedNode = null;
unhighlightAll();
nodeSelection.attr("stroke", "none");
} else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
const visibleNodes = nodeSelection.data();
let best: SimNode | null = null;
let bestDist = Infinity;
for (const n of visibleNodes) {
if (n.qualifiedName === d.qualifiedName || n.x == null || n.y == null || d.x == null || d.y == null) continue;
const dx = n.x - d.x;
const dy = n.y - d.y;
const dist = Math.sqrt(dx * dx + dy * dy);
let ok = false;
if (event.key === "ArrowRight" && dx > 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
if (event.key === "ArrowLeft" && dx < 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
if (event.key === "ArrowDown" && dy > 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
if (event.key === "ArrowUp" && dy < 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
if (ok && dist < bestDist) {
best = n;
bestDist = dist;
}
}
if (best) {
const target = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape")
.filter((n) => n.qualifiedName === best!.qualifiedName)
.node();
if (target) (target as HTMLElement).focus();
}
}
})
.on("focus", (_event: FocusEvent, d: SimNode) => {
showTooltip(d);
highlightConnected(d);
})
.on("blur", () => {
hideTooltip();
unhighlightAll();
});
// Highlight search matches
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
const query = searchInput?.value?.trim().toLowerCase() ?? "";
if (query.length > 0) {
nodeSelection.attr("stroke", (d) => {
const matches =
d.name.toLowerCase().includes(query) ||
d.qualifiedName.toLowerCase().includes(query);
return matches ? "#e6edf3" : "none";
});
}
// Highlight selected node
if (selectedNode) {
nodeSelection.attr("stroke", (d) => {
if (d.qualifiedName === selectedNode!.qualifiedName) return "#e6edf3";
if (query.length > 0) {
const matches =
d.name.toLowerCase().includes(query) ||
d.qualifiedName.toLowerCase().includes(query);
return matches ? "#e6edf3" : "none";
}
return "none";
});
}
// --- Labels ---
labelSelection = labelGroup
.selectAll<SVGTextElement, SimNode>("text")
.data(nodes, (d) => d.qualifiedName)
.join("text")
.text((d) => d.name)
.attr("font-size", 10)
.attr("fill", currentTheme === "dark" ? "#cdd6f4" : "#4c4f69")
.attr("text-anchor", "middle")
.attr("dy", (d) => (NODE_RADIUS[d.kind] ?? 10) + 14)
.attr("pointer-events", "none");
// --- Force simulation ---
simulation = d3
.forceSimulation<SimNode>(nodes)
.alphaDecay(0.02)
.force(
"link",
d3
.forceLink<SimNode, SimLink>(links)
.id((d) => d.qualifiedName)
.distance(100)
)
.force("charge", d3.forceManyBody<SimNode>().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
.force(
"collide",
d3.forceCollide<SimNode>().radius((d) => (NODE_RADIUS[d.kind] ?? 10) + 5)
)
.on("tick", () => {
linkSelection
.attr("x1", (d) => (d.source as SimNode).x!)
.attr("y1", (d) => (d.source as SimNode).y!)
.attr("x2", (d) => (d.target as SimNode).x!)
.attr("y2", (d) => (d.target as SimNode).y!);
nodeSelection.attr("transform", (d) => `translate(${d.x},${d.y})`);
labelSelection.attr("x", (d) => d.x!).attr("y", (d) => d.y!);
});
// Update node count display
const countEl = document.getElementById("node-count");
if (countEl) {
countEl.textContent = `${nodes.length} nodes, ${links.length} edges`;
}
}
// ---------------------------------------------------------------------------
// Selection & highlight
// ---------------------------------------------------------------------------
function selectNode(node: SimNode): void {
selectedNode = node;
nodeSelection.attr("stroke", (d) =>
d.qualifiedName === node.qualifiedName ? "#e6edf3" : "none"
);
updateDepthSliderState();
}
function updateDepthSliderState(): void {
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
const depthValue = document.getElementById("depth-value");
if (slider) {
if (selectedNode) {
slider.disabled = false;
} else {
slider.disabled = true;
if (depthValue) depthValue.textContent = "N/A";
}
}
}
function highlightConnected(node: SimNode): void {
const connectedQns = new Set<string>();
connectedQns.add(node.qualifiedName);
linkSelection.attr("stroke-opacity", (d) => {
const srcQn = (d.source as SimNode).qualifiedName;
const tgtQn = (d.target as SimNode).qualifiedName;
if (srcQn === node.qualifiedName || tgtQn === node.qualifiedName) {
connectedQns.add(srcQn);
connectedQns.add(tgtQn);
return 0.8;
}
return 0.1;
});
nodeSelection.attr("opacity", (d) =>
connectedQns.has(d.qualifiedName) ? 1 : 0.2
);
labelSelection.attr("opacity", (d) =>
connectedQns.has(d.qualifiedName) ? 1 : 0.2
);
}
function unhighlightAll(): void {
linkSelection.attr("stroke-opacity", 0.4);
nodeSelection.attr("opacity", 1);
labelSelection.attr("opacity", 1);
}
// ---------------------------------------------------------------------------
// Tooltip
// ---------------------------------------------------------------------------
function showTooltip(node: SimNode): void {
const tooltip = document.getElementById("tooltip")!;
tooltip.style.display = "block";
let html = `<strong>${escapeHtml(node.name)}</strong><br/>`;
html += `<span class="tooltip-kind">${escapeHtml(node.kind)}</span><br/>`;
html += `<span class="tooltip-path">${escapeHtml(node.filePath)}</span>`;
if (node.lineStart != null) {
html += `<br/>Lines ${node.lineStart}`;
if (node.lineEnd != null && node.lineEnd !== node.lineStart) {
html += `-${node.lineEnd}`;
}
}
if (node.params) {
html += `<br/><span class="tooltip-params">${escapeHtml(node.params)}</span>`;
}
if (node.returnType) {
html += ` <span class="tooltip-return">&rarr; ${escapeHtml(node.returnType)}</span>`;
}
tooltip.innerHTML = html;
// Position near cursor -- we'll update on mousemove too
document.addEventListener("mousemove", positionTooltip);
}
function positionTooltip(event: MouseEvent): void {
const tooltip = document.getElementById("tooltip")!;
const x = event.clientX + 12;
const y = event.clientY + 12;
// Keep tooltip in viewport
const rect = tooltip.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 8;
const maxY = window.innerHeight - rect.height - 8;
tooltip.style.left = `${Math.min(x, maxX)}px`;
tooltip.style.top = `${Math.min(y, maxY)}px`;
}
function hideTooltip(): void {
const tooltip = document.getElementById("tooltip")!;
tooltip.style.display = "none";
document.removeEventListener("mousemove", positionTooltip);
}
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// ---------------------------------------------------------------------------
// Highlight node (from extension message)
// ---------------------------------------------------------------------------
function highlightNodeByName(qualifiedName: string): void {
const node = nodeMap.get(qualifiedName);
if (!node) return;
selectNode(node);
centerOnNode(node);
// Add pulsing ring animation
const ring = nodeGroup
.append("circle")
.attr("cx", node.x ?? 0)
.attr("cy", node.y ?? 0)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
.attr("fill", "none")
.attr("stroke", "#e6edf3")
.attr("stroke-width", 3)
.attr("class", "pulse-ring");
// Remove after animation completes
ring
.transition()
.duration(600)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
.attr("stroke-opacity", 0)
.on("end", function () {
d3.select(this).remove();
});
// Second pulse
setTimeout(() => {
if (!node.x) return;
const ring2 = nodeGroup
.append("circle")
.attr("cx", node.x)
.attr("cy", node.y ?? 0)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
.attr("fill", "none")
.attr("stroke", "#e6edf3")
.attr("stroke-width", 3);
ring2
.transition()
.duration(600)
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
.attr("stroke-opacity", 0)
.on("end", function () {
d3.select(this).remove();
});
}, 300);
}
// ---------------------------------------------------------------------------
// Camera
// ---------------------------------------------------------------------------
function centerOnNode(node: SimNode): void {
if (!node.x || !node.y) return;
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth;
const height = graphEl.clientHeight;
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(1.5)
.translate(-node.x, -node.y);
svg
.transition()
.duration(500)
.call(zoomBehavior.transform, transform);
}
function fitToView(): void {
const graphEl = document.getElementById("graph-area")!;
const width = graphEl.clientWidth;
const height = graphEl.clientHeight;
if (allNodes.length === 0) return;
// Find bounding box of visible nodes
const visibleNodes = nodeSelection.data();
if (visibleNodes.length === 0) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of visibleNodes) {
if (n.x == null || n.y == null) continue;
const r = NODE_RADIUS[n.kind] ?? 10;
minX = Math.min(minX, n.x - r);
maxX = Math.max(maxX, n.x + r);
minY = Math.min(minY, n.y - r);
maxY = Math.max(maxY, n.y + r);
}
if (!isFinite(minX)) return;
const padding = 60;
const bboxWidth = maxX - minX + padding * 2;
const bboxHeight = maxY - minY + padding * 2;
const scale = Math.min(width / bboxWidth, height / bboxHeight, 2);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-cx, -cy);
svg
.transition()
.duration(500)
.call(zoomBehavior.transform, transform);
}
// ---------------------------------------------------------------------------
// Toolbar events
// ---------------------------------------------------------------------------
function bindToolbarEvents(): void {
// Search
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
if (searchInput) {
let debounceTimer: ReturnType<typeof setTimeout>;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
buildGraph();
}, 250);
});
}
// Edge toggle pills
for (const kind of ALL_EDGE_KINDS) {
const pill = document.getElementById(`edge-${kind}`);
if (pill) {
const toggle = () => {
if (visibleEdgeKinds.has(kind)) {
visibleEdgeKinds.delete(kind);
pill.classList.remove("active");
pill.setAttribute("aria-pressed", "false");
} else {
visibleEdgeKinds.add(kind);
pill.classList.add("active");
pill.setAttribute("aria-pressed", "true");
}
buildGraph();
};
pill.addEventListener("click", toggle);
pill.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); }
});
}
}
// Edge filter popover toggle
const edgeFilterBtn = document.getElementById("btn-edge-filter");
const edgePopover = document.getElementById("edge-popover");
if (edgeFilterBtn && edgePopover) {
edgeFilterBtn.addEventListener("click", (e) => {
e.stopPropagation();
edgePopover.classList.toggle("visible");
});
document.addEventListener("click", (e) => {
if (!edgePopover.contains(e.target as Node) && e.target !== edgeFilterBtn) {
edgePopover.classList.remove("visible");
}
});
}
// Depth slider
const depthSlider = document.getElementById("depth-slider") as HTMLInputElement | null;
if (depthSlider) {
depthSlider.addEventListener("input", () => {
depthLimit = parseInt(depthSlider.value, 10);
const depthValue = document.getElementById("depth-value");
if (depthValue) {
depthValue.textContent = depthLimit === 0 ? "All" : String(depthLimit);
}
buildGraph();
});
}
// Fit button
const fitBtn = document.getElementById("btn-fit");
if (fitBtn) {
fitBtn.addEventListener("click", () => {
fitToView();
});
}
// Export SVG button
const exportBtn = document.getElementById("btn-export");
if (exportBtn) {
exportBtn.addEventListener("click", () => {
const svgEl = document.querySelector("#graph-area svg");
if (svgEl) {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgEl);
vscodeApi.postMessage({
command: "exportSvg",
svg: svgString,
});
}
});
}
// Export PNG button
const exportPngBtn = document.getElementById("btn-export-png");
if (exportPngBtn) {
exportPngBtn.addEventListener("click", () => {
const svgEl = document.querySelector("#graph-area svg") as SVGSVGElement | null;
if (!svgEl) { return; }
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgEl);
const canvas = document.createElement("canvas");
const bbox = svgEl.getBoundingClientRect();
canvas.width = bbox.width * 2; // 2x for retina
canvas.height = bbox.height * 2;
const ctx = canvas.getContext("2d");
if (!ctx) { return; }
ctx.scale(2, 2);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0);
const pngData = canvas.toDataURL("image/png");
vscodeApi.postMessage({ command: "exportPng", data: pngData });
};
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString)));
});
}
}
// ---------------------------------------------------------------------------
// Extension message handling
// ---------------------------------------------------------------------------
function bindExtensionMessages(): void {
window.addEventListener("message", (event) => {
const message = event.data;
switch (message.command) {
case "setData":
setData(
message.nodes as GraphNode[],
message.edges as GraphEdge[]
);
// Auto-fit after simulation settles a bit
setTimeout(() => fitToView(), 800);
// Show truncation warning if needed
if (message.truncated) {
const warn = document.getElementById("truncation-warning");
if (warn) {
warn.style.display = "inline";
warn.textContent = `\u26a0 Showing ${message.maxNodes} of more nodes. Increase maxNodes in settings.`;
}
}
break;
case "highlightNode":
highlightNodeByName(message.qualifiedName as string);
break;
case "setTheme":
currentTheme = message.theme as "dark" | "light";
applyTheme();
break;
}
});
}
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
function applyTheme(): void {
const textColor = currentTheme === "dark" ? "#cdd6f4" : "#4c4f69";
labelSelection.attr("fill", textColor);
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
init();
@@ -0,0 +1,499 @@
/**
* Tests for the SqliteReader module.
*
* Creates a temporary SQLite database with the exact schema used by the
* Python backend, inserts representative test data, and validates every
* public method of SqliteReader.
*/
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import Database from 'better-sqlite3';
import { SqliteReader, GraphNode, GraphEdge } from '../src/backend/sqlite';
// ---------------------------------------------------------------------------
// Schema (mirrors the Python backend exactly)
// ---------------------------------------------------------------------------
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
name TEXT NOT NULL,
qualified_name TEXT NOT NULL UNIQUE,
file_path TEXT NOT NULL,
line_start INTEGER,
line_end INTEGER,
language TEXT,
parent_name TEXT,
params TEXT,
return_type TEXT,
modifiers TEXT,
is_test INTEGER DEFAULT 0,
file_hash TEXT,
extra TEXT DEFAULT '{}',
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
source_qualified TEXT NOT NULL,
target_qualified TEXT NOT NULL,
file_path TEXT NOT NULL,
line INTEGER DEFAULT 0,
extra TEXT DEFAULT '{}',
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
CREATE INDEX IF NOT EXISTS idx_nodes_qualified ON nodes(qualified_name);
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_qualified);
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_qualified);
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
CREATE INDEX IF NOT EXISTS idx_edges_file ON edges(file_path);
`;
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const NOW = Date.now() / 1000;
interface TestNode {
kind: string;
name: string;
qualified_name: string;
file_path: string;
line_start: number;
line_end: number;
language: string;
parent_name: string | null;
params: string | null;
return_type: string | null;
modifiers: string | null;
is_test: number;
file_hash: string;
extra: string;
updated_at: number;
}
interface TestEdge {
kind: string;
source_qualified: string;
target_qualified: string;
file_path: string;
line: number;
extra: string;
updated_at: number;
}
const TEST_NODES: TestNode[] = [
// auth.py -- File node + 2 functions
{
kind: 'File', name: 'auth.py', qualified_name: 'src/auth.py',
file_path: 'src/auth.py', line_start: 1, line_end: 50,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'login', qualified_name: 'src/auth.py::login',
file_path: 'src/auth.py', line_start: 5, line_end: 20,
language: 'python', parent_name: null, params: '(username, password)',
return_type: 'bool', modifiers: null, is_test: 0, file_hash: 'aaa',
extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'logout', qualified_name: 'src/auth.py::logout',
file_path: 'src/auth.py', line_start: 22, line_end: 35,
language: 'python', parent_name: null, params: '(session)',
return_type: 'None', modifiers: null, is_test: 0, file_hash: 'aaa',
extra: '{}', updated_at: NOW,
},
// routes.py -- File node + 1 function
{
kind: 'File', name: 'routes.py', qualified_name: 'src/routes.py',
file_path: 'src/routes.py', line_start: 1, line_end: 40,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'bbb', extra: '{}', updated_at: NOW,
},
{
kind: 'Function', name: 'handle_login', qualified_name: 'src/routes.py::handle_login',
file_path: 'src/routes.py', line_start: 10, line_end: 30,
language: 'python', parent_name: null, params: '(request)',
return_type: 'Response', modifiers: null, is_test: 0, file_hash: 'bbb',
extra: '{}', updated_at: NOW,
},
// test_auth.py -- File node + 1 test function
{
kind: 'File', name: 'test_auth.py', qualified_name: 'tests/test_auth.py',
file_path: 'tests/test_auth.py', line_start: 1, line_end: 30,
language: 'python', parent_name: null, params: null, return_type: null,
modifiers: null, is_test: 0, file_hash: 'ccc', extra: '{}', updated_at: NOW,
},
{
kind: 'Test', name: 'test_login', qualified_name: 'tests/test_auth.py::test_login',
file_path: 'tests/test_auth.py', line_start: 5, line_end: 25,
language: 'python', parent_name: null, params: '()',
return_type: 'None', modifiers: null, is_test: 1, file_hash: 'ccc',
extra: '{}', updated_at: NOW,
},
];
const TEST_EDGES: TestEdge[] = [
// routes.py::handle_login CALLS auth.py::login
{
kind: 'CALLS', source_qualified: 'src/routes.py::handle_login',
target_qualified: 'src/auth.py::login', file_path: 'src/routes.py',
line: 15, extra: '{}', updated_at: NOW,
},
// routes.py IMPORTS_FROM auth.py
{
kind: 'IMPORTS_FROM', source_qualified: 'src/routes.py',
target_qualified: 'src/auth.py', file_path: 'src/routes.py',
line: 1, extra: '{}', updated_at: NOW,
},
// auth.py CONTAINS login
{
kind: 'CONTAINS', source_qualified: 'src/auth.py',
target_qualified: 'src/auth.py::login', file_path: 'src/auth.py',
line: 5, extra: '{}', updated_at: NOW,
},
// auth.py CONTAINS logout
{
kind: 'CONTAINS', source_qualified: 'src/auth.py',
target_qualified: 'src/auth.py::logout', file_path: 'src/auth.py',
line: 22, extra: '{}', updated_at: NOW,
},
// test_auth.py::test_login TESTED_BY (reverse: login is tested by test_login)
{
kind: 'TESTED_BY', source_qualified: 'src/auth.py::login',
target_qualified: 'tests/test_auth.py::test_login', file_path: 'tests/test_auth.py',
line: 5, extra: '{}', updated_at: NOW,
},
];
// ---------------------------------------------------------------------------
// Helper: create a populated temp database
// ---------------------------------------------------------------------------
function createTestDb(): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crg-test-'));
const dbPath = path.join(tmpDir, 'graph.db');
const db = new Database(dbPath);
db.exec(SCHEMA_SQL);
const insertNode = db.prepare(`
INSERT INTO nodes
(kind, name, qualified_name, file_path, line_start, line_end,
language, parent_name, params, return_type, modifiers, is_test,
file_hash, extra, updated_at)
VALUES
(@kind, @name, @qualified_name, @file_path, @line_start, @line_end,
@language, @parent_name, @params, @return_type, @modifiers, @is_test,
@file_hash, @extra, @updated_at)
`);
const insertEdge = db.prepare(`
INSERT INTO edges
(kind, source_qualified, target_qualified, file_path, line, extra, updated_at)
VALUES
(@kind, @source_qualified, @target_qualified, @file_path, @line, @extra, @updated_at)
`);
const insertMeta = db.prepare(
'INSERT INTO metadata (key, value) VALUES (?, ?)'
);
const insertMany = db.transaction(() => {
for (const n of TEST_NODES) { insertNode.run(n); }
for (const e of TEST_EDGES) { insertEdge.run(e); }
insertMeta.run('last_updated', '2025-06-15T10:30:00Z');
});
insertMany();
db.close();
return dbPath;
}
function cleanup(dbPath: string): void {
try {
const dir = path.dirname(dbPath);
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best effort
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SqliteReader', () => {
let dbPath: string;
let reader: SqliteReader;
before(() => {
dbPath = createTestDb();
reader = new SqliteReader(dbPath);
});
after(() => {
reader.close();
cleanup(dbPath);
});
// -- isValid ------------------------------------------------------------
it('isValid() returns true for a properly initialised database', () => {
assert.strictEqual(reader.isValid(), true);
});
// -- getAllFiles ---------------------------------------------------------
it('getAllFiles() returns file paths ordered alphabetically', () => {
const files = reader.getAllFiles();
assert.deepStrictEqual(files, [
'src/auth.py',
'src/routes.py',
'tests/test_auth.py',
]);
});
// -- getNodesByFile -----------------------------------------------------
it('getNodesByFile() returns nodes ordered by line_start', () => {
const nodes = reader.getNodesByFile('src/auth.py');
assert.strictEqual(nodes.length, 3); // File + login + logout
// Verify ordering
assert.strictEqual(nodes[0].name, 'auth.py');
assert.strictEqual(nodes[1].name, 'login');
assert.strictEqual(nodes[2].name, 'logout');
// Verify camelCase conversion
assert.strictEqual(nodes[1].qualifiedName, 'src/auth.py::login');
assert.strictEqual(nodes[1].lineStart, 5);
assert.strictEqual(nodes[1].lineEnd, 20);
assert.strictEqual(nodes[1].returnType, 'bool');
assert.strictEqual(nodes[1].isTest, false);
});
// -- getNode ------------------------------------------------------------
it('getNode() returns a single node by qualified name', () => {
const node = reader.getNode('src/auth.py::login');
assert.ok(node);
assert.strictEqual(node.kind, 'Function');
assert.strictEqual(node.name, 'login');
assert.strictEqual(node.params, '(username, password)');
});
it('getNode() returns undefined for non-existent qualified name', () => {
const node = reader.getNode('src/auth.py::nonexistent');
assert.strictEqual(node, undefined);
});
// -- getNodeAtCursor ----------------------------------------------------
it('getNodeAtCursor() returns the innermost node at the cursor', () => {
// Line 10 is inside login (5-20) and inside auth.py (1-50).
// login has the smaller span so it should be returned.
const node = reader.getNodeAtCursor('src/auth.py', 10);
assert.ok(node);
assert.strictEqual(node.name, 'login');
});
it('getNodeAtCursor() returns the File node when cursor is outside functions', () => {
// Line 45 is inside auth.py (1-50) but outside both functions.
const node = reader.getNodeAtCursor('src/auth.py', 45);
assert.ok(node);
assert.strictEqual(node.kind, 'File');
assert.strictEqual(node.name, 'auth.py');
});
it('getNodeAtCursor() returns undefined when no node covers the line', () => {
const node = reader.getNodeAtCursor('src/auth.py', 999);
assert.strictEqual(node, undefined);
});
// -- getEdgesBySource ---------------------------------------------------
it('getEdgesBySource() returns outgoing edges', () => {
const edges = reader.getEdgesBySource('src/routes.py::handle_login');
assert.strictEqual(edges.length, 1);
assert.strictEqual(edges[0].kind, 'CALLS');
assert.strictEqual(edges[0].targetQualified, 'src/auth.py::login');
assert.strictEqual(edges[0].line, 15);
});
// -- getEdgesByTarget ---------------------------------------------------
it('getEdgesByTarget() returns incoming edges', () => {
const edges = reader.getEdgesByTarget('src/auth.py::login');
// CALLS from handle_login + CONTAINS from auth.py
assert.strictEqual(edges.length, 2);
const kinds = edges.map((e) => e.kind).sort();
assert.deepStrictEqual(kinds, ['CALLS', 'CONTAINS']);
});
// -- getEdgesAmong ------------------------------------------------------
it('getEdgesAmong() returns only edges within the given set', () => {
const qns = new Set([
'src/routes.py::handle_login',
'src/auth.py::login',
'src/auth.py',
]);
const edges = reader.getEdgesAmong(qns);
// Should include: CALLS handle_login->login, CONTAINS auth.py->login,
// IMPORTS_FROM routes.py->auth.py only if routes.py is in set (it's not).
assert.ok(edges.length >= 2);
for (const e of edges) {
assert.ok(qns.has(e.sourceQualified), `source ${e.sourceQualified} should be in set`);
assert.ok(qns.has(e.targetQualified), `target ${e.targetQualified} should be in set`);
}
});
it('getEdgesAmong() returns empty array for empty set', () => {
const edges = reader.getEdgesAmong(new Set());
assert.deepStrictEqual(edges, []);
});
// -- searchNodes --------------------------------------------------------
it('searchNodes() finds nodes by name substring', () => {
const results = reader.searchNodes('login');
assert.ok(results.length >= 2); // login, handle_login, test_login
const names = results.map((n) => n.name);
assert.ok(names.includes('login'));
assert.ok(names.includes('handle_login'));
});
it('searchNodes() respects limit', () => {
const results = reader.searchNodes('login', 1);
assert.strictEqual(results.length, 1);
});
it('searchNodes() returns empty for no match', () => {
const results = reader.searchNodes('zzz_no_match_zzz');
assert.deepStrictEqual(results, []);
});
// -- getStats -----------------------------------------------------------
it('getStats() returns correct aggregate counts', () => {
const stats = reader.getStats();
assert.strictEqual(stats.totalNodes, TEST_NODES.length);
assert.strictEqual(stats.totalEdges, TEST_EDGES.length);
assert.strictEqual(stats.filesCount, 3); // 3 File nodes
assert.deepStrictEqual(stats.languages, ['python']);
assert.strictEqual(stats.lastUpdated, '2025-06-15T10:30:00Z');
assert.strictEqual(stats.embeddingsCount, 0); // no embeddings table data
// Nodes by kind
assert.strictEqual(stats.nodesByKind['File'], 3);
assert.strictEqual(stats.nodesByKind['Function'], 3);
assert.strictEqual(stats.nodesByKind['Test'], 1);
// Edges by kind
assert.strictEqual(stats.edgesByKind['CALLS'], 1);
assert.strictEqual(stats.edgesByKind['IMPORTS_FROM'], 1);
assert.strictEqual(stats.edgesByKind['CONTAINS'], 2);
assert.strictEqual(stats.edgesByKind['TESTED_BY'], 1);
});
// -- getMetadata --------------------------------------------------------
it('getMetadata() returns stored value', () => {
const val = reader.getMetadata('last_updated');
assert.strictEqual(val, '2025-06-15T10:30:00Z');
});
it('getMetadata() returns undefined for missing key', () => {
const val = reader.getMetadata('nonexistent_key');
assert.strictEqual(val, undefined);
});
// -- getImpactRadius ----------------------------------------------------
it('getImpactRadius() finds changed and impacted nodes', () => {
const result = reader.getImpactRadius(['src/auth.py'], 2);
// Changed nodes: everything in auth.py (File + login + logout)
assert.strictEqual(result.changedNodes.length, 3);
const changedNames = result.changedNodes.map((n) => n.name).sort();
assert.deepStrictEqual(changedNames, ['auth.py', 'login', 'logout']);
// Impacted nodes: handle_login (calls login), test_login (tested_by),
// routes.py (imports_from auth.py), test_auth.py file (contains test_login)
assert.ok(
result.impactedNodes.length > 0,
'should have impacted nodes'
);
const impactedNames = result.impactedNodes.map((n) => n.name);
assert.ok(
impactedNames.includes('handle_login'),
'handle_login should be impacted (calls login)'
);
assert.ok(
impactedNames.includes('test_login'),
'test_login should be impacted (tested_by)'
);
// Impacted files should include routes.py and/or tests/test_auth.py
assert.ok(
result.impactedFiles.length > 0,
'should have impacted files'
);
// Edges among all involved nodes
assert.ok(
result.edges.length > 0,
'should have connecting edges'
);
});
it('getImpactRadius() with depth 0 returns only seeds, no impacted nodes', () => {
const result = reader.getImpactRadius(['src/auth.py'], 0);
assert.strictEqual(result.changedNodes.length, 3);
assert.strictEqual(result.impactedNodes.length, 0);
});
it('getImpactRadius() for non-existent file returns empty', () => {
const result = reader.getImpactRadius(['nonexistent.py']);
assert.strictEqual(result.changedNodes.length, 0);
assert.strictEqual(result.impactedNodes.length, 0);
assert.strictEqual(result.impactedFiles.length, 0);
});
// -- close / isValid after close ----------------------------------------
it('isValid() returns false after close()', () => {
const tmpPath = createTestDb();
const tmpReader = new SqliteReader(tmpPath);
assert.strictEqual(tmpReader.isValid(), true);
tmpReader.close();
assert.strictEqual(tmpReader.isValid(), false);
cleanup(tmpPath);
});
// -- constructor retry on bad path --------------------------------------
it('constructor throws after retries for a non-existent path', () => {
assert.throws(() => {
new SqliteReader('/nonexistent/path/to/database.db');
});
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "src/webview"]
}
+20
View File
@@ -0,0 +1,20 @@
"""Code Review Graph - MCP server for persistent incremental code knowledge graphs."""
from .context_savings import (
attach_context_savings,
estimate_context_savings,
estimate_file_tokens,
estimate_tokens,
format_context_savings,
)
__version__ = "2.3.6"
__all__ = [
"__version__",
"attach_context_savings",
"estimate_context_savings",
"estimate_file_tokens",
"estimate_tokens",
"format_context_savings",
]
+4
View File
@@ -0,0 +1,4 @@
"""Allow running as: python -m code_review_graph"""
from .cli import main
main()
+410
View File
@@ -0,0 +1,410 @@
"""Graph analysis: hub detection, bridge nodes, knowledge gaps,
surprise scoring, suggested questions."""
from __future__ import annotations
import logging
from collections import Counter, defaultdict
from .graph import GraphStore, _sanitize_name
logger = logging.getLogger(__name__)
def find_hub_nodes(store: GraphStore, top_n: int = 10) -> list[dict]:
"""Find the most connected nodes (highest in+out degree), excluding File nodes.
Returns list of dicts with: name, qualified_name, kind, file,
in_degree, out_degree, total_degree, community_id
"""
# Build degree counts from all edges
edges = store.get_all_edges()
in_degree: dict[str, int] = Counter()
out_degree: dict[str, int] = Counter()
for e in edges:
out_degree[e.source_qualified] += 1
in_degree[e.target_qualified] += 1
# Get all non-File nodes
nodes = store.get_all_nodes(exclude_files=True)
community_map = store.get_all_community_ids()
scored = []
for n in nodes:
qn = n.qualified_name
ind = in_degree.get(qn, 0)
outd = out_degree.get(qn, 0)
total = ind + outd
if total == 0:
continue
scored.append({
"name": _sanitize_name(n.name),
"qualified_name": n.qualified_name,
"kind": n.kind,
"file": n.file_path,
"in_degree": ind,
"out_degree": outd,
"total_degree": total,
"community_id": community_map.get(qn),
})
scored.sort(
key=lambda x: x.get("total_degree", 0), # type: ignore[arg-type,return-value]
reverse=True,
)
return scored[:top_n]
def find_bridge_nodes(
store: GraphStore, top_n: int = 10
) -> list[dict]:
"""Find nodes with highest betweenness centrality.
These are architectural chokepoints that sit on shortest paths
between many node pairs. If they break, multiple communities
lose connectivity.
Returns list of dicts with: name, qualified_name, kind, file,
betweenness, community_id
"""
import networkx as nx
# Build the graph — use cached version if available
nxg = store._build_networkx_graph()
# Compute betweenness centrality (approximate for large graphs)
n_nodes = nxg.number_of_nodes()
if n_nodes > 5000:
# Sample-based approximation for large graphs
k = min(500, n_nodes)
bc = nx.betweenness_centrality(nxg, k=k, normalized=True)
elif n_nodes > 0:
bc = nx.betweenness_centrality(nxg, normalized=True)
else:
return []
community_map = store.get_all_community_ids()
node_map = {
n.qualified_name: n
for n in store.get_all_nodes(exclude_files=True)
}
results = []
for qn, score in bc.items():
if score <= 0 or qn not in node_map:
continue
n = node_map[qn]
if n.kind == "File":
continue
results.append({
"name": _sanitize_name(n.name),
"qualified_name": n.qualified_name,
"kind": n.kind,
"file": n.file_path,
"betweenness": round(score, 6),
"community_id": community_map.get(qn),
})
results.sort(
key=lambda x: float(x.get("betweenness", 0)), # type: ignore[arg-type,return-value]
reverse=True,
)
return results[:top_n]
def find_knowledge_gaps(store: GraphStore) -> dict[str, list[dict]]:
"""Identify structural weaknesses in the codebase graph.
Returns dict with categories:
- isolated_nodes: degree <= 1, disconnected from graph
- thin_communities: fewer than 3 members
- untested_hotspots: high-degree nodes with no TESTED_BY edges
- single_file_communities: entire community in one file
"""
edges = store.get_all_edges()
nodes = store.get_all_nodes(exclude_files=True)
community_map = store.get_all_community_ids()
# Build degree map
degree: dict[str, int] = Counter()
tested_nodes: set[str] = set()
for e in edges:
degree[e.source_qualified] += 1
degree[e.target_qualified] += 1
if e.kind == "TESTED_BY":
tested_nodes.add(e.source_qualified)
# 1. Isolated nodes (degree <= 1, not File)
isolated = []
for n in nodes:
d = degree.get(n.qualified_name, 0)
if d <= 1:
isolated.append({
"name": _sanitize_name(n.name),
"qualified_name": n.qualified_name,
"kind": n.kind,
"file": n.file_path,
"degree": d,
})
# 2. Build community sizes and file maps from node data
comm_sizes: Counter[int] = Counter()
comm_files: dict[int, set[str]] = defaultdict(set)
for n in nodes:
cid = community_map.get(n.qualified_name)
if cid is not None:
comm_sizes[cid] += 1
comm_files[cid].add(n.file_path)
# Thin communities (< 3 members)
communities = store.get_communities_list()
thin = []
for c in communities:
cid = int(c["id"])
size = comm_sizes.get(cid, 0)
if size < 3:
thin.append({
"community_id": cid,
"name": str(c["name"]),
"size": size,
})
# 3. Untested hotspots (degree >= 5, no TESTED_BY)
untested_hotspots = []
for n in nodes:
d = degree.get(n.qualified_name, 0)
if (d >= 5
and n.qualified_name not in tested_nodes
and not n.is_test):
untested_hotspots.append({
"name": _sanitize_name(n.name),
"qualified_name": n.qualified_name,
"kind": n.kind,
"file": n.file_path,
"degree": d,
})
untested_hotspots.sort(
key=lambda x: x.get("degree", 0), # type: ignore[arg-type,return-value]
reverse=True,
)
# 4. Single-file communities
single_file = []
for c in communities:
cid = int(c["id"])
files = comm_files.get(cid, set())
size = comm_sizes.get(cid, 0)
if len(files) == 1 and size >= 3:
single_file.append({
"community_id": cid,
"name": str(c["name"]),
"size": size,
"file": next(iter(files)),
})
return {
"isolated_nodes": isolated[:50],
"thin_communities": thin,
"untested_hotspots": untested_hotspots[:20],
"single_file_communities": single_file,
}
def find_surprising_connections(
store: GraphStore, top_n: int = 15
) -> list[dict]:
"""Find edges with high surprise scores.
Detects unexpected architectural coupling based on:
- Cross-community: source and target in different communities
- Cross-language: different file languages
- Peripheral-to-hub: low-degree node to high-degree node
- Cross-file-type: test calling production or vice versa
- Non-standard edge kind for the node types
"""
edges = store.get_all_edges()
nodes = store.get_all_nodes(exclude_files=True)
community_map = store.get_all_community_ids()
node_map = {n.qualified_name: n for n in nodes}
# Build degree map
degree: dict[str, int] = Counter()
for e in edges:
degree[e.source_qualified] += 1
degree[e.target_qualified] += 1
# Median degree for peripheral detection
degrees = [d for d in degree.values() if d > 0]
if not degrees:
return []
median_deg = sorted(degrees)[len(degrees) // 2]
high_deg_threshold = max(median_deg * 3, 10)
scored_edges = []
for e in edges:
src = node_map.get(e.source_qualified)
tgt = node_map.get(e.target_qualified)
if not src or not tgt:
continue
if src.kind == "File" or tgt.kind == "File":
continue
score = 0.0
reasons = []
# Cross-community (+0.3)
src_cid = community_map.get(e.source_qualified)
tgt_cid = community_map.get(e.target_qualified)
if (src_cid is not None
and tgt_cid is not None
and src_cid != tgt_cid):
score += 0.3
reasons.append("cross-community")
# Cross-language (+0.2)
src_lang = (
src.file_path.rsplit(".", 1)[-1]
if "." in src.file_path else ""
)
tgt_lang = (
tgt.file_path.rsplit(".", 1)[-1]
if "." in tgt.file_path else ""
)
if src_lang and tgt_lang and src_lang != tgt_lang:
score += 0.2
reasons.append("cross-language")
# Peripheral-to-hub (+0.2)
src_deg = degree.get(e.source_qualified, 0)
tgt_deg = degree.get(e.target_qualified, 0)
if ((src_deg <= 2 and tgt_deg >= high_deg_threshold)
or (tgt_deg <= 2
and src_deg >= high_deg_threshold)):
score += 0.2
reasons.append("peripheral-to-hub")
# Cross-file-type: test <-> non-test (+0.15)
if src.is_test != tgt.is_test and e.kind == "CALLS":
score += 0.15
reasons.append("cross-test-boundary")
# Non-standard edge kind (+0.15)
if e.kind == "CALLS" and src.kind == "Type":
score += 0.15
reasons.append("unusual-edge-kind")
if score > 0:
scored_edges.append({
"source": _sanitize_name(src.name),
"source_qualified": e.source_qualified,
"target": _sanitize_name(tgt.name),
"target_qualified": e.target_qualified,
"edge_kind": e.kind,
"surprise_score": round(score, 2),
"reasons": reasons,
"source_community": src_cid,
"target_community": tgt_cid,
})
scored_edges.sort(
key=lambda x: float(x.get("surprise_score", 0)), # type: ignore[arg-type,return-value]
reverse=True,
)
return scored_edges[:top_n]
def generate_suggested_questions(
store: GraphStore,
) -> list[dict]:
"""Auto-generate review questions from graph analysis.
Categories:
- bridge_node: Why does X connect communities A and B?
- isolated_node: Is X dead code or dynamically invoked?
- low_cohesion: Should community X be split?
- hub_risk: Does hub node X have adequate test coverage?
- surprising: Why does A call B across community boundary?
"""
questions = []
# Bridge node questions
bridges = find_bridge_nodes(store, top_n=3)
for b in bridges:
questions.append({
"category": "bridge_node",
"question": (
f"'{b['name']}' is a critical connector "
f"between multiple code regions. Is it "
f"adequately tested and documented?"
),
"target": b["qualified_name"],
"priority": "high",
})
# Hub risk questions
hubs = find_hub_nodes(store, top_n=3)
edges = store.get_all_edges()
tested = {
e.source_qualified
for e in edges if e.kind == "TESTED_BY"
}
for h in hubs:
if h["qualified_name"] not in tested:
questions.append({
"category": "hub_risk",
"question": (
f"Hub node '{h['name']}' has "
f"{h['total_degree']} connections but no "
f"direct test coverage. Should it be "
f"tested?"
),
"target": h["qualified_name"],
"priority": "high",
})
# Surprising connection questions
surprises = find_surprising_connections(store, top_n=3)
for s in surprises:
if "cross-community" in s["reasons"]:
questions.append({
"category": "surprising_connection",
"question": (
f"'{s['source']}' (community "
f"{s['source_community']}) calls "
f"'{s['target']}' (community "
f"{s['target_community']}). Is this "
f"coupling intentional?"
),
"target": s["source_qualified"],
"priority": "medium",
})
# Knowledge gap questions
gaps = find_knowledge_gaps(store)
for c in gaps["thin_communities"][:2]:
questions.append({
"category": "thin_community",
"question": (
f"Community '{c['name']}' has only "
f"{c['size']} member(s). Should it be "
f"merged with a neighbor?"
),
"target": f"community:{c['community_id']}",
"priority": "low",
})
for h in gaps["untested_hotspots"][:2]:
questions.append({
"category": "untested_hotspot",
"question": (
f"'{h['name']}' has {h['degree']} "
f"connections but no test coverage. "
f"Is this a risk?"
),
"target": h["qualified_name"],
"priority": "medium",
})
return questions
+409
View File
@@ -0,0 +1,409 @@
"""Change impact analysis for code review.
Maps git/svn diffs to affected functions, flows, communities, and test coverage
gaps. Produces risk-scored, priority-ordered review guidance.
"""
from __future__ import annotations
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Any
from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
from .flows import get_affected_flows
from .graph import GraphNode, GraphStore, _sanitize_name, node_to_dict
logger = logging.getLogger(__name__)
_GIT_TIMEOUT = int(os.environ.get("CRG_GIT_TIMEOUT", "30")) # seconds, configurable
_SAFE_GIT_REF = re.compile(r"^[A-Za-z0-9_.~^/@{}\-]+$")
_SAFE_SVN_REV = re.compile(r"^r?\d+(:r?\d+|:HEAD|:BASE|:COMMITTED)?$", re.IGNORECASE)
# ---------------------------------------------------------------------------
# 1. parse_git_diff_ranges / parse_svn_diff_ranges
# ---------------------------------------------------------------------------
def parse_git_diff_ranges(
repo_root: str,
base: str = "HEAD~1",
) -> dict[str, list[tuple[int, int]]]:
"""Run ``git diff --unified=0`` and extract changed line ranges per file.
Args:
repo_root: Absolute path to the repository root.
base: Git ref to diff against (default: ``HEAD~1``).
Returns:
Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
Returns an empty dict on error.
"""
if not _SAFE_GIT_REF.match(base):
logger.warning("Invalid git ref rejected: %s", base)
return {}
try:
result = subprocess.run(
["git", "diff", "--unified=0", base, "--"],
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
cwd=repo_root,
timeout=_GIT_TIMEOUT,
)
if result.returncode != 0:
logger.warning("git diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
return {}
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("git diff error: %s", exc)
return {}
return _parse_unified_diff(result.stdout)
def parse_svn_diff_ranges(
repo_root: str,
rev_range: str | None = None,
) -> dict[str, list[tuple[int, int]]]:
"""Run ``svn diff`` and extract changed line ranges per file.
Args:
repo_root: Absolute path to the SVN working copy root.
rev_range: Optional SVN revision range in ``rXXX:HEAD`` format.
When *None*, diffs the working copy against BASE (local changes).
Returns:
Mapping of file paths to lists of ``(start_line, end_line)`` tuples.
Returns an empty dict on error.
"""
cmd = ["svn", "diff", "--non-interactive"]
if rev_range:
if not _SAFE_SVN_REV.match(rev_range):
logger.warning("Invalid SVN revision range rejected: %s", rev_range)
return {}
cmd.extend(["-r", rev_range])
try:
result = subprocess.run(
cmd,
capture_output=True,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
cwd=repo_root,
timeout=_GIT_TIMEOUT,
)
if result.returncode != 0:
logger.warning("svn diff failed (rc=%d): %s", result.returncode, result.stderr[:200])
return {}
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("svn diff error: %s", exc)
return {}
return _parse_unified_diff(result.stdout)
def parse_diff_ranges(
repo_root: str,
base: str = "HEAD~1",
) -> dict[str, list[tuple[int, int]]]:
"""Auto-detect VCS and return changed line ranges per file.
Dispatches to :func:`parse_git_diff_ranges` for Git repositories and
:func:`parse_svn_diff_ranges` for SVN working copies.
Args:
repo_root: Absolute path to the repository/working-copy root.
base: For Git: the ref to diff against (default ``HEAD~1``).
For SVN: an optional revision range (e.g. ``"r100:HEAD"``);
when *base* is not a valid SVN revision, working-copy changes
(``svn diff``) are used instead.
"""
root_path = Path(repo_root)
if (root_path / ".svn").exists():
rev_range = base if _SAFE_SVN_REV.match(base) else None
return parse_svn_diff_ranges(repo_root, rev_range)
return parse_git_diff_ranges(repo_root, base)
def _parse_unified_diff(diff_text: str) -> dict[str, list[tuple[int, int]]]:
"""Parse unified diff output into file -> line-range mappings.
Handles the ``@@ -old,count +new,count @@`` hunk header format.
"""
ranges: dict[str, list[tuple[int, int]]] = {}
current_file: str | None = None
# Match "+++ b/path/to/file"
file_pattern = re.compile(r"^\+\+\+ b/(.+)$")
# Match "@@ ... +start,count @@" or "@@ ... +start @@"
hunk_pattern = re.compile(r"^@@ .+? \+(\d+)(?:,(\d+))? @@")
for line in diff_text.splitlines():
file_match = file_pattern.match(line)
if file_match:
current_file = file_match.group(1)
continue
hunk_match = hunk_pattern.match(line)
if hunk_match and current_file is not None:
start = int(hunk_match.group(1))
count = int(hunk_match.group(2)) if hunk_match.group(2) else 1
if count == 0:
# Pure deletion hunk (no lines added); still note the position.
end = start
else:
end = start + count - 1
ranges.setdefault(current_file, []).append((start, end))
return ranges
# ---------------------------------------------------------------------------
# 2. map_changes_to_nodes
# ---------------------------------------------------------------------------
def map_changes_to_nodes(
store: GraphStore,
changed_ranges: dict[str, list[tuple[int, int]]],
) -> list[GraphNode]:
"""Find graph nodes whose line ranges overlap the changed lines.
Args:
store: The graph store.
changed_ranges: Mapping of file paths to ``(start, end)`` tuples.
Returns:
Deduplicated list of overlapping graph nodes.
"""
seen: set[str] = set()
result: list[GraphNode] = []
for file_path, ranges in changed_ranges.items():
# Try the path as-is, then also try all nodes to match relative paths.
nodes = store.get_nodes_by_file(file_path)
if not nodes:
# The graph may store absolute paths; try a suffix match.
matched_paths = store.get_files_matching(file_path)
for mp in matched_paths:
nodes.extend(store.get_nodes_by_file(mp))
for node in nodes:
if node.qualified_name in seen:
continue
if node.line_start is None or node.line_end is None:
continue
# Check overlap with any changed range.
for start, end in ranges:
if node.line_start <= end and node.line_end >= start:
result.append(node)
seen.add(node.qualified_name)
break
return result
# ---------------------------------------------------------------------------
# 3. compute_risk_score
# ---------------------------------------------------------------------------
def compute_risk_score(store: GraphStore, node: GraphNode) -> float:
"""Compute a risk score (0.0 - 1.0) for a single node.
Scoring factors:
- Flow participation: 0.05 per flow membership, capped at 0.25
- Community crossing: 0.05 per caller from a different community, capped at 0.15
- Test coverage: 0.30 (untested) scaling down to 0.05 (5+ TESTED_BY edges)
- Security sensitivity: 0.20 if name matches security keywords
- Caller count: callers / 20, capped at 0.10
"""
score = 0.0
# --- Flow participation (cap 0.25), weighted by criticality ---
flow_criticalities = store.get_flow_criticalities_for_node(node.id)
if flow_criticalities:
score += min(sum(flow_criticalities), 0.25)
else:
flow_count = store.count_flow_memberships(node.id)
score += min(flow_count * 0.05, 0.25)
# --- Community crossing (cap 0.15) ---
callers = store.get_edges_by_target(node.qualified_name)
caller_edges = [e for e in callers if e.kind == "CALLS"]
cross_community = 0
node_cid = store.get_node_community_id(node.id)
if node_cid is not None and caller_edges:
caller_qns = [edge.source_qualified for edge in caller_edges]
cid_map = store.get_community_ids_by_qualified_names(caller_qns)
for cid in cid_map.values():
if cid is not None and cid != node_cid:
cross_community += 1
score += min(cross_community * 0.05, 0.15)
# --- Test coverage (direct + transitive) ---
transitive_tests = store.get_transitive_tests(node.qualified_name)
test_count = len(transitive_tests)
score += 0.30 - (min(test_count / 5.0, 1.0) * 0.25)
# --- Security sensitivity ---
name_lower = node.name.lower()
qn_lower = node.qualified_name.lower()
if any(kw in name_lower or kw in qn_lower for kw in _SECURITY_KEYWORDS):
score += 0.20
# --- Caller count (cap 0.10) ---
caller_count = len(caller_edges)
score += min(caller_count / 20.0, 0.10)
return round(min(max(score, 0.0), 1.0), 4)
# ---------------------------------------------------------------------------
# 4. analyze_changes
# ---------------------------------------------------------------------------
def analyze_changes(
store: GraphStore,
changed_files: list[str],
changed_ranges: dict[str, list[tuple[int, int]]] | None = None,
repo_root: str | None = None,
base: str = "HEAD~1",
) -> dict[str, Any]:
"""Analyze changes and produce risk-scored review guidance.
Args:
store: The graph store.
changed_files: List of changed file paths.
changed_ranges: Optional pre-parsed diff ranges. If not provided and
``repo_root`` is given, they are computed via the detected VCS
(Git or SVN).
repo_root: Repository root (for git/svn diff).
base: Git ref or SVN revision range to diff against.
Returns:
Dict with ``summary``, ``risk_score``, ``changed_functions``,
``affected_flows``, ``test_gaps``, and ``review_priorities``.
"""
# Compute changed ranges if not provided.
if changed_ranges is None and repo_root is not None:
# Diff keys are forward-slash paths relative to the repo root, but
# the graph stores absolute native paths. Remap so lookups work on
# Windows, where the LIKE-suffix fallback cannot bridge
# "src/app.py" to "C:\repo\src\app.py" (#528). Keys that are
# already absolute pass through pathlib joining unchanged. The
# explicit changed_ranges path (MCP) is untouched — tools/review.py
# remaps before calling, and remapping twice would corrupt keys.
root_path = Path(repo_root)
changed_ranges = {
str(root_path / key): ranges
for key, ranges in parse_diff_ranges(repo_root, base).items()
}
# Map changes to nodes.
if changed_ranges:
changed_nodes = map_changes_to_nodes(store, changed_ranges)
else:
# Fallback: all nodes in changed files.
changed_nodes = []
for fp in changed_files:
changed_nodes.extend(store.get_nodes_by_file(fp))
# Filter to functions/tests for risk scoring (skip File nodes).
changed_funcs = [
n for n in changed_nodes
if n.kind in ("Function", "Test", "Class")
]
# Cap to prevent O(N*M) query explosion on large PRs.
_max_funcs = int(os.environ.get("CRG_MAX_CHANGED_FUNCS", "500"))
funcs_truncated = len(changed_funcs) > _max_funcs
if funcs_truncated:
changed_funcs = changed_funcs[:_max_funcs]
# Compute per-node risk scores.
node_risks: list[dict[str, Any]] = []
for node in changed_funcs:
risk = compute_risk_score(store, node)
node_risks.append({
**node_to_dict(node),
"risk_score": risk,
})
# Overall risk score: max of individual risks, or 0.
overall_risk = max((nr["risk_score"] for nr in node_risks), default=0.0)
# Affected flows.
affected = get_affected_flows(store, changed_files)
# Detect test gaps: changed functions without TESTED_BY edges.
test_gaps: list[dict[str, Any]] = []
for node in changed_funcs:
if node.is_test:
continue
tested = store.get_edges_by_target(node.qualified_name)
if not any(e.kind == "TESTED_BY" for e in tested):
test_gaps.append({
"name": _sanitize_name(node.name),
"qualified_name": _sanitize_name(node.qualified_name),
"file": node.file_path,
"line_start": node.line_start,
"line_end": node.line_end,
})
# Review priorities: top 10 by risk score.
review_priorities = sorted(node_risks, key=lambda x: x["risk_score"], reverse=True)[:10]
# Build summary.
summary_parts = [
f"Analyzed {len(changed_files)} changed file(s):",
f" - {len(changed_funcs)} changed function(s)/class(es)",
f" - {affected['total']} affected flow(s)",
f" - {len(test_gaps)} test gap(s)",
f" - Overall risk score: {overall_risk:.2f}",
]
if test_gaps:
# Dedup by bare name in the human summary. The underlying test_gaps
# list keeps every entry (a downstream consumer needs precision via
# qualified_name), but a graph that ended up with the same function
# stored under two qualified_names (e.g. relative + absolute path
# variants) would otherwise print "X, X, Y, Y" — surfacing graph
# corruption as a UX bug. The root cause is path normalization;
# this is the defensive last line.
seen_names: set[str] = set()
gap_names: list[str] = []
for g in test_gaps:
n = g["name"]
if n in seen_names:
continue
seen_names.add(n)
gap_names.append(n)
if len(gap_names) >= 5:
break
summary_parts.append(f" - Untested: {', '.join(gap_names)}")
if funcs_truncated:
summary_parts.append(
f" - Warning: analysis capped at {_max_funcs} functions "
f"(set CRG_MAX_CHANGED_FUNCS to adjust)"
)
return {
"summary": "\n".join(summary_parts),
"risk_score": overall_risk,
"changed_functions": node_risks,
"affected_flows": affected["affected_flows"],
"test_gaps": test_gaps,
"review_priorities": review_priorities,
"functions_truncated": funcs_truncated,
}
File diff suppressed because it is too large Load Diff
+874
View File
@@ -0,0 +1,874 @@
"""Community/cluster detection for the code knowledge graph.
Detects communities of related code nodes using the Leiden algorithm (via igraph,
optional) with a file-based grouping fallback when igraph is not installed.
"""
from __future__ import annotations
import logging
import random
import re
from collections import Counter, defaultdict
from typing import Any
from .graph import GraphEdge, GraphNode, GraphStore, _sanitize_name
# Fixed seed for igraph's RNG so Leiden community detection is reproducible
# across runs. Without this, two builds of the same graph produce different
# community IDs / sizes, breaking benchmark comparability. Override with
# CRG_LEIDEN_SEED env var if you need a different seed.
_LEIDEN_SEED = 42
logger = logging.getLogger(__name__)
# Stay well under SQLite's default 999-variable limit per statement.
_SQL_BATCH = 450
# ---------------------------------------------------------------------------
# Optional igraph import
# ---------------------------------------------------------------------------
try:
import igraph as ig # type: ignore[import-untyped]
IGRAPH_AVAILABLE = True
except ImportError:
ig = None # type: ignore[assignment]
IGRAPH_AVAILABLE = False
# ---------------------------------------------------------------------------
# Edge weight mapping
# ---------------------------------------------------------------------------
EDGE_WEIGHTS: dict[str, float] = {
"CALLS": 1.0,
"IMPORTS_FROM": 0.5,
"INHERITS": 0.8,
"IMPLEMENTS": 0.7,
"CONTAINS": 0.3,
"TESTED_BY": 0.4,
"DEPENDS_ON": 0.6,
}
# Common words to filter when generating community names
_COMMON_WORDS = frozenset({
"get", "set", "self", "init", "new", "create", "update", "delete",
"add", "remove", "make", "build", "from", "to", "for", "with",
"the", "and", "test", "main", "run", "do", "is", "has", "on",
"of", "in", "at", "by", "my", "this", "that", "all", "none",
})
# ---------------------------------------------------------------------------
# Community naming
# ---------------------------------------------------------------------------
def _generate_community_name(members: list[GraphNode]) -> str:
"""Generate a meaningful name for a community of nodes.
Algorithm:
1. Find most common module/file prefix among members
2. If a dominant class exists (>40% of nodes), use its name
3. Fallback: most frequent keyword in function/class names
4. Format: "{prefix}-{keyword}"
"""
if not members:
return "empty"
# 1. Find common file prefix
file_paths = [m.file_path for m in members]
prefix = _extract_file_prefix(file_paths)
# 2. Check for dominant class
class_names = [m.name for m in members if m.kind == "Class"]
if class_names:
class_counts = Counter(class_names)
top_class, top_count = class_counts.most_common(1)[0]
if top_count > len(members) * 0.4:
if prefix:
return f"{prefix}-{_to_slug(top_class)}"
return _to_slug(top_class)
# 3. Most frequent keyword from function/class names
keywords = _extract_keywords(members)
keyword = keywords[0] if keywords else ""
if prefix and keyword:
return f"{prefix}-{keyword}"
if prefix:
return prefix
if keyword:
return keyword
return "cluster"
def _extract_file_prefix(file_paths: list[str]) -> str:
"""Find the most common short directory or module name from file paths."""
if not file_paths:
return ""
# Extract the parent directory or file stem
parts: list[str] = []
for fp in file_paths:
# Use the last directory component or file stem
segments = fp.replace("\\", "/").split("/")
# Take the parent dir if it exists, otherwise the file stem
if len(segments) >= 2:
parts.append(segments[-2])
else:
stem = segments[-1].rsplit(".", 1)[0]
parts.append(stem)
counts = Counter(parts)
top_part, _ = counts.most_common(1)[0]
return _to_slug(top_part)
def _extract_keywords(members: list[GraphNode]) -> list[str]:
"""Extract the most frequent meaningful keywords from member names."""
word_counts: Counter[str] = Counter()
for m in members:
if m.kind in ("Function", "Class", "Test", "Type"):
words = _split_name(m.name)
for w in words:
wl = w.lower()
if wl not in _COMMON_WORDS and len(wl) > 1:
word_counts[wl] += 1
if not word_counts:
return []
return [w for w, _ in word_counts.most_common(5)]
def _split_name(name: str) -> list[str]:
"""Split a camelCase or snake_case name into words."""
# Insert boundary before uppercase letters for camelCase
s = re.sub(r"([a-z])([A-Z])", r"\1_\2", name)
# Split on underscores, hyphens, dots
return [p for p in re.split(r"[_\-.\s]+", s) if p]
def _to_slug(s: str) -> str:
"""Convert a string to a short lowercase slug."""
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")[:30]
# ---------------------------------------------------------------------------
# Cohesion calculation
# ---------------------------------------------------------------------------
def _compute_cohesion_batch(
community_member_qns: list[set[str]],
all_edges: list[GraphEdge],
) -> list[float]:
"""Compute cohesion for multiple communities in a single O(edges) pass.
Builds a ``qualified_name -> community_index`` reverse map (each node
appears in at most one community since all callers produce partitions),
then walks every edge exactly once, bucketing it into internal/external
counters per community.
Total work: O(edges + sum(|members|)) instead of
O(edges * communities) for naive per-community cohesion.
Returns a list of cohesion scores aligned with ``community_member_qns``.
"""
qn_to_idx: dict[str, int] = {}
for idx, members in enumerate(community_member_qns):
for qn in members:
qn_to_idx[qn] = idx
n = len(community_member_qns)
internal = [0] * n
external = [0] * n
for e in all_edges:
sc = qn_to_idx.get(e.source_qualified)
tc = qn_to_idx.get(e.target_qualified)
if sc is None and tc is None:
continue
if sc == tc:
# Safe: sc is not None here (sc == tc and not both None).
assert sc is not None
internal[sc] += 1
else:
if sc is not None:
external[sc] += 1
if tc is not None:
external[tc] += 1
results: list[float] = []
for i in range(n):
total = internal[i] + external[i]
results.append(internal[i] / total if total > 0 else 0.0)
return results
def _build_adjacency(edges: list[GraphEdge]) -> dict[str, list[str]]:
"""Build adjacency list from edges (one pass over all edges)."""
adj: dict[str, list[str]] = defaultdict(list)
for e in edges:
adj[e.source_qualified].append(e.target_qualified)
adj[e.target_qualified].append(e.source_qualified)
return adj
def _compute_cohesion(
member_qns: set[str],
all_edges: list[GraphEdge],
adj: dict[str, list[str]] | None = None,
) -> float:
"""Compute cohesion: internal_edges / (internal_edges + external_edges).
For multiple communities, prefer :func:`_compute_cohesion_batch`, which
runs in O(edges) total instead of O(edges) per community.
"""
return _compute_cohesion_batch([member_qns], all_edges)[0]
# ---------------------------------------------------------------------------
# Leiden-based community detection (igraph)
# ---------------------------------------------------------------------------
def _detect_leiden(
nodes: list[GraphNode],
edges: list[GraphEdge],
min_size: int,
adj: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Detect communities using Leiden algorithm via igraph.
Caps Leiden at ``n_iterations=2`` (sufficient for code dependency graphs)
and skips the recursive sub-community splitting pass that caused
exponential blow-up on large repos (>100k nodes).
"""
if ig is None:
return []
qn_to_idx: dict[str, int] = {}
idx_to_node: dict[int, GraphNode] = {}
for i, node in enumerate(nodes):
qn_to_idx[node.qualified_name] = i
idx_to_node[i] = node
if not qn_to_idx:
return []
logger.info("Building igraph with %d nodes...", len(qn_to_idx))
g = ig.Graph(n=len(qn_to_idx), directed=False)
edge_list: list[tuple[int, int]] = []
weights: list[float] = []
seen_edges: set[tuple[int, int]] = set()
for e in edges:
src_idx = qn_to_idx.get(e.source_qualified)
tgt_idx = qn_to_idx.get(e.target_qualified)
if src_idx is not None and tgt_idx is not None and src_idx != tgt_idx:
pair = (min(src_idx, tgt_idx), max(src_idx, tgt_idx))
if pair not in seen_edges:
seen_edges.add(pair)
edge_list.append(pair)
weights.append(EDGE_WEIGHTS.get(e.kind, 0.5))
if not edge_list:
return _detect_file_based(nodes, edges, min_size, adj=adj)
g.add_edges(edge_list)
g.es["weight"] = weights
# Run Leiden -- scale resolution inversely with graph size to get
# coarser clusters on large repos. Default resolution=1.0 produces
# thousands of tiny communities for 30k+ node graphs.
import math
n_nodes = g.vcount()
resolution = max(0.05, 1.0 / math.log10(max(n_nodes, 10)))
logger.info(
"Running Leiden on %d nodes, %d edges...",
g.vcount(), g.ecount(),
)
import os
seed = int(os.environ.get("CRG_LEIDEN_SEED", _LEIDEN_SEED))
# Deterministic seeding for benchmark reproducibility — community
# detection is not a security-sensitive context. nosec B311.
ig.set_random_number_generator(random.Random(seed)) # nosec B311
partition = g.community_leiden(
objective_function="modularity",
weights="weight",
resolution=resolution,
n_iterations=2,
)
logger.info(
"Leiden complete, found %d partitions. Computing cohesion...",
len(partition),
)
pending: list[tuple[list[GraphNode], set[str]]] = []
for cluster_ids in partition:
if len(cluster_ids) < min_size:
continue
members = [idx_to_node[i] for i in cluster_ids if i in idx_to_node]
if len(members) < min_size:
continue
member_qns = {m.qualified_name for m in members}
pending.append((members, member_qns))
cohesions = _compute_cohesion_batch([p[1] for p in pending], edges)
communities: list[dict[str, Any]] = []
for (members, member_qns), cohesion in zip(pending, cohesions):
lang_counts = Counter(m.language for m in members if m.language)
dominant_lang = lang_counts.most_common(1)[0][0] if lang_counts else ""
name = _generate_community_name(members)
communities.append({
"name": name,
"level": 0,
"size": len(members),
"cohesion": round(cohesion, 4),
"dominant_language": dominant_lang,
"description": f"Community of {len(members)} nodes",
"members": [m.qualified_name for m in members],
"member_qns": member_qns,
})
logger.info("Community detection complete: %d communities", len(communities))
return communities
# ---------------------------------------------------------------------------
# File-based fallback community detection
# ---------------------------------------------------------------------------
def _detect_file_based(
nodes: list[GraphNode],
edges: list[GraphEdge],
min_size: int,
adj: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Group nodes by directory when Leiden is unavailable or over-fragments.
Strips the longest common directory prefix from all file paths, then
adaptively picks a grouping depth that yields 10-200 communities.
"""
# Collect all directory paths (normalized, without filename)
all_dir_parts: list[list[str]] = []
for n in nodes:
parts = n.file_path.replace("\\", "/").split("/")
all_dir_parts.append([p for p in parts[:-1] if p])
# Find the longest common prefix among directory parts
prefix_len = 0
if all_dir_parts:
shortest = min(len(p) for p in all_dir_parts)
for i in range(shortest):
seg = all_dir_parts[0][i]
if all(p[i] == seg for p in all_dir_parts):
prefix_len = i + 1
else:
break
def _group_at_depth(depth: int) -> dict[str, list[GraphNode]]:
groups: dict[str, list[GraphNode]] = defaultdict(list)
for n in nodes:
parts = n.file_path.replace("\\", "/").split("/")
dir_parts = [p for p in parts[:-1] if p]
remainder = dir_parts[prefix_len:]
if remainder:
key = "/".join(remainder[:depth])
else:
key = parts[-1].rsplit(".", 1)[0] if parts else "root"
groups[key].append(n)
return groups
# Try increasing depths until we get 10-200 qualifying groups
max_depth = max((len(p) - prefix_len for p in all_dir_parts), default=0)
best_groups = _group_at_depth(1) # depth=1 always works (file stem fallback)
for depth in range(1, max_depth + 1):
groups = _group_at_depth(depth)
qualifying = sum(1 for v in groups.values() if len(v) >= min_size)
best_groups = groups
if qualifying >= 10:
break
by_dir = best_groups
# Pre-filter to communities meeting min_size and collect their member
# sets so we can batch-compute all cohesions in a single O(edges) pass.
# Without this, per-community cohesion is O(edges * files), which makes
# community detection effectively hang on large repos.
pending: list[tuple[str, list[GraphNode], set[str]]] = []
for dir_path, members in by_dir.items():
if len(members) < min_size:
continue
member_qns = {m.qualified_name for m in members}
pending.append((dir_path, members, member_qns))
cohesions = _compute_cohesion_batch([p[2] for p in pending], edges)
communities: list[dict[str, Any]] = []
for (dir_path, members, member_qns), cohesion in zip(pending, cohesions):
lang_counts = Counter(m.language for m in members if m.language)
dominant_lang = lang_counts.most_common(1)[0][0] if lang_counts else ""
name = _generate_community_name(members)
communities.append({
"name": name,
"level": 0,
"size": len(members),
"cohesion": round(cohesion, 4),
"dominant_language": dominant_lang,
"description": f"Directory-based community: {dir_path}",
"members": [m.qualified_name for m in members],
"member_qns": member_qns,
})
return communities
# ---------------------------------------------------------------------------
# Oversized community splitting
# ---------------------------------------------------------------------------
def _split_oversized(
communities: list[dict],
nodes: list[GraphNode],
edges: list[GraphEdge],
threshold_pct: float = 0.25,
min_split_size: int = 10,
) -> list[dict]:
"""Recursively split communities that exceed threshold_pct of total.
Uses Leiden on the subgraph of oversized communities. If igraph is
not available, returns communities unchanged.
"""
if not IGRAPH_AVAILABLE:
return communities
total = sum(
c.get("size", len(c.get("members", [])))
for c in communities
)
if total == 0:
return communities
threshold = max(int(total * threshold_pct), min_split_size)
result: list[dict] = []
next_id = max(
(c.get("id", 0) for c in communities), default=0
) + 1
for comm in communities:
members = set(comm.get("members", []))
if len(members) <= threshold:
result.append(comm)
continue
# Build subgraph for this community
member_nodes = [
n for n in nodes
if n.qualified_name in members
]
member_edges = [
e for e in edges
if (
e.source_qualified in members
and e.target_qualified in members
)
]
if len(member_nodes) < min_split_size:
result.append(comm)
continue
# Run Leiden on subgraph
qn_to_idx = {
n.qualified_name: i
for i, n in enumerate(member_nodes)
}
ig_edges: list[tuple[int, int]] = []
ig_weights: list[float] = []
for e in member_edges:
si = qn_to_idx.get(e.source_qualified)
ti = qn_to_idx.get(e.target_qualified)
if si is not None and ti is not None and si != ti:
ig_edges.append((si, ti))
ig_weights.append(
EDGE_WEIGHTS.get(e.kind, 0.5)
)
if not ig_edges:
result.append(comm)
continue
try:
g = ig.Graph(
n=len(member_nodes),
edges=ig_edges,
directed=False,
)
g.es["weight"] = ig_weights
import os
seed = int(os.environ.get("CRG_LEIDEN_SEED", _LEIDEN_SEED))
# Deterministic seeding for benchmark reproducibility — community
# detection is not a security-sensitive context. nosec B311.
ig.set_random_number_generator(random.Random(seed)) # nosec B311
partition = g.community_leiden(
objective_function="modularity",
weights="weight",
resolution=0.5,
)
sub_communities: dict[int, list[str]] = {}
for idx, cid in enumerate(partition.membership):
sub_communities.setdefault(cid, []).append(
member_nodes[idx].qualified_name
)
if len(sub_communities) <= 1:
result.append(comm)
continue
parent_id = comm.get("id", 0)
comm_name = comm.get("name", "")
for sub_members in sub_communities.values():
sub_comm = {
"id": next_id,
"name": comm_name + f"-sub{next_id}",
"level": comm.get("level", 0) + 1,
"parent_id": parent_id,
"members": sub_members,
"size": len(sub_members),
"cohesion": 0.0,
"dominant_language": comm.get(
"dominant_language"
),
"description": (
f"Split from {comm_name}"
),
}
result.append(sub_comm)
next_id += 1
logger.info(
"Split oversized community '%s' "
"(%d members) into %d",
comm_name,
len(members),
len(sub_communities),
)
except Exception:
logger.warning(
"Failed to split community '%s', "
"keeping as-is",
comm.get("name", ""),
exc_info=True,
)
result.append(comm)
return result
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def detect_communities(
store: GraphStore, min_size: int = 2
) -> list[dict[str, Any]]:
"""Detect communities in the code graph.
Uses the Leiden algorithm via igraph if available, otherwise falls back to
file-based grouping.
Args:
store: The GraphStore instance.
min_size: Minimum number of nodes for a community to be included.
Returns:
List of community dicts with keys: name, level, size, cohesion,
dominant_language, description, members, member_qns.
"""
# Gather all nodes (exclude File nodes to focus on code entities)
all_edges = store.get_all_edges()
unique_nodes = store.get_all_nodes(exclude_files=True)
# Build adjacency index once for fast cohesion computation
adj = _build_adjacency(all_edges)
logger.info(
"Loaded %d unique nodes, %d edges",
len(unique_nodes), len(all_edges),
)
if IGRAPH_AVAILABLE:
logger.info("Detecting communities with Leiden algorithm (igraph)")
results = _detect_leiden(unique_nodes, all_edges, min_size, adj=adj)
else:
logger.info("igraph not available, using file-based community detection")
results = _detect_file_based(unique_nodes, all_edges, min_size, adj=adj)
# Split oversized communities
results = _split_oversized(
results, unique_nodes, all_edges,
)
# Convert member_qns (internal set) to a list for serialization safety,
# then strip it from the returned dicts to avoid leaking internal state.
for comm in results:
if "member_qns" in comm:
comm["member_qns"] = list(comm["member_qns"])
del comm["member_qns"]
return results
def incremental_detect_communities(
store: GraphStore,
changed_files: list[str],
min_size: int = 2,
) -> int:
"""Re-detect communities only if changed files affect existing communities.
If no existing communities contain nodes from changed files, skips
re-detection entirely (the common case for small changes). Otherwise
re-runs full community detection.
Args:
store: The GraphStore instance.
changed_files: List of file paths that have changed.
min_size: Minimum number of nodes for a community to be included.
Returns:
Number of communities detected, or 0 if skipped.
"""
if not changed_files:
return 0
conn = store._conn
# Check if any communities are affected (batch to stay under SQLite limit)
affected_count = 0
for i in range(0, len(changed_files), _SQL_BATCH):
batch = changed_files[i:i + _SQL_BATCH]
placeholders = ",".join("?" * len(batch))
row = conn.execute(
f"SELECT COUNT(DISTINCT community_id) FROM nodes " # nosec B608
f"WHERE community_id IS NOT NULL AND file_path IN ({placeholders})",
batch,
).fetchone()
if row:
affected_count += row[0]
affected = (affected_count,) if affected_count else None
if not affected or affected[0] == 0:
return 0 # No communities affected, skip
# Re-run full community detection (correct and fast enough)
communities = detect_communities(store, min_size=min_size)
return store_communities(store, communities)
def store_communities(
store: GraphStore, communities: list[dict[str, Any]]
) -> int:
"""Store detected communities in the database.
Clears existing communities and community_id assignments, then inserts
the new communities and updates node community_id references.
Args:
store: The GraphStore instance.
communities: List of community dicts from detect_communities().
Returns:
Number of communities stored.
"""
# NOTE: store_communities uses _conn directly because it performs
# multi-statement batch writes (DELETE + INSERT loop + UPDATE loop)
# that are tightly coupled to the DB transaction lifecycle.
conn = store._conn
if conn.in_transaction:
logger.warning("Rolling back uncommitted transaction before BEGIN IMMEDIATE")
conn.rollback()
# Wrap in explicit transaction so the DELETE + INSERT + UPDATE
# sequence is atomic — no partial community data on crash.
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute("DELETE FROM communities")
conn.execute("UPDATE nodes SET community_id = NULL")
count = 0
for comm in communities:
cursor = conn.execute(
"""INSERT INTO communities
(name, level, cohesion, size, dominant_language, description)
VALUES (?, ?, ?, ?, ?, ?)""",
(
comm["name"],
comm.get("level", 0),
comm.get("cohesion", 0.0),
comm["size"],
comm.get("dominant_language", ""),
comm.get("description", ""),
),
)
community_id = cursor.lastrowid
# Batch update community_id on member nodes
member_qns = comm.get("members", [])
for j in range(0, len(member_qns), _SQL_BATCH):
batch = member_qns[j:j + _SQL_BATCH]
placeholders = ",".join("?" * len(batch))
conn.execute(
f"UPDATE nodes SET community_id = ? WHERE qualified_name IN ({placeholders})", # nosec B608
[community_id] + batch,
)
count += 1
conn.commit()
except BaseException:
conn.rollback()
raise
return count
def get_communities(
store: GraphStore, sort_by: str = "size", min_size: int = 0
) -> list[dict[str, Any]]:
"""Retrieve stored communities from the database.
Args:
store: The GraphStore instance.
sort_by: Column to sort by ("size", "cohesion", "name").
min_size: Minimum community size to include.
Returns:
List of community dicts.
"""
valid_sorts = {"size", "cohesion", "name"}
if sort_by not in valid_sorts:
sort_by = "size"
order = "DESC" if sort_by in ("size", "cohesion") else "ASC"
# NOTE: get_communities reads the communities table which has no
# dedicated GraphStore method (it's a domain-specific table managed
# entirely by the communities module). We use _conn for this query.
rows = store._conn.execute(
f"SELECT * FROM communities WHERE size >= ? ORDER BY {sort_by} {order}", # nosec B608
(min_size,),
).fetchall()
communities: list[dict[str, Any]] = []
for row in rows:
# Fetch member qualified names for this community
member_qns = [
_sanitize_name(qn)
for qn in store.get_community_member_qns(row["id"])
]
communities.append({
"id": row["id"],
"name": _sanitize_name(row["name"]),
"level": row["level"],
"cohesion": row["cohesion"],
"size": row["size"],
"dominant_language": row["dominant_language"] or "",
"description": _sanitize_name(row["description"] or ""),
"members": member_qns,
})
return communities
_TEST_COMMUNITY_RE = re.compile(
r"(^test[-/]|[-/]test([:/]|$)|it:should|describe:|spec[-/]|[-/]spec$)",
re.IGNORECASE,
)
def _is_test_community(name: str) -> bool:
"""Return True if a community name indicates it is test-dominated."""
return bool(_TEST_COMMUNITY_RE.search(name))
def get_architecture_overview(store: GraphStore) -> dict[str, Any]:
"""Generate an architecture overview based on community structure.
Builds a node-to-community mapping, counts cross-community edges,
and generates warnings for high coupling.
Args:
store: The GraphStore instance.
Returns:
Dict with keys: communities, cross_community_edges, warnings.
"""
communities = get_communities(store)
# Build node -> community_id mapping
node_to_community: dict[str, int] = {}
for comm in communities:
comm_id = comm.get("id", 0)
for qn in comm.get("members", []):
node_to_community[qn] = comm_id
# Count cross-community edges
all_edges = store.get_all_edges()
cross_edges: list[dict[str, Any]] = []
cross_counts: Counter[tuple[int, int]] = Counter()
for e in all_edges:
# TESTED_BY edges are expected cross-community coupling (test → code),
# not an architectural smell.
if e.kind == "TESTED_BY":
continue
src_comm = node_to_community.get(e.source_qualified)
tgt_comm = node_to_community.get(e.target_qualified)
if (
src_comm is not None
and tgt_comm is not None
and src_comm != tgt_comm
):
pair = (min(src_comm, tgt_comm), max(src_comm, tgt_comm))
cross_counts[pair] += 1
cross_edges.append({
"source_community": src_comm,
"target_community": tgt_comm,
"edge_kind": e.kind,
"source": _sanitize_name(e.source_qualified),
"target": _sanitize_name(e.target_qualified),
})
# Generate warnings for high coupling, skipping test-dominated pairs.
warnings: list[str] = []
comm_name_map = {c.get("id", 0): c["name"] for c in communities}
for (c1, c2), count in cross_counts.most_common():
if count > 10:
name1 = comm_name_map.get(c1, f"community-{c1}")
name2 = comm_name_map.get(c2, f"community-{c2}")
# Skip pairs where either community is test-dominated — coupling
# between test and production code is expected, not architectural.
if _is_test_community(name1) or _is_test_community(name2):
continue
warnings.append(
f"High coupling ({count} edges) between "
f"'{name1}' and '{name2}'"
)
return {
"communities": communities,
"cross_community_edges": cross_edges,
"warnings": warnings,
}
+23
View File
@@ -0,0 +1,23 @@
"""Shared constants for code-review-graph."""
from __future__ import annotations
import os
SECURITY_KEYWORDS: frozenset[str] = frozenset({
"auth", "login", "password", "token", "session", "crypt", "secret",
"credential", "permission", "sql", "query", "execute", "connect",
"socket", "request", "http", "sanitize", "validate", "encrypt",
"decrypt", "hash", "sign", "verify", "admin", "privilege",
})
# ---------------------------------------------------------------------------
# Configurable limits (override via environment variables)
# ---------------------------------------------------------------------------
MAX_IMPACT_NODES = int(os.environ.get("CRG_MAX_IMPACT_NODES", "500"))
MAX_IMPACT_DEPTH = int(os.environ.get("CRG_MAX_IMPACT_DEPTH", "2"))
MAX_BFS_DEPTH = int(os.environ.get("CRG_MAX_BFS_DEPTH", "15"))
MAX_SEARCH_RESULTS = int(os.environ.get("CRG_MAX_SEARCH_RESULTS", "20"))
# BFS engine: "sql" (SQLite recursive CTE) or "networkx" (Python-side BFS)
BFS_ENGINE = os.environ.get("CRG_BFS_ENGINE", "sql")
+317
View File
@@ -0,0 +1,317 @@
"""Compact estimated context savings helpers.
The project intentionally labels these values as estimates: the helper uses a
conservative character-count approximation instead of model-specific tokenizers.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterable
CHARS_PER_TOKEN = 4
def estimate_tokens(value: Any) -> int:
"""Estimate token count with a conservative 4 chars/token approximation."""
if value is None:
return 0
if isinstance(value, str):
text = value
else:
text = json.dumps(
value,
default=str,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
if not text:
return 0
return max(1, (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN)
def estimate_file_tokens(repo_root: Path, files: Iterable[str]) -> int:
"""Estimate tokens for changed files using file sizes, not file contents."""
total = 0
root = repo_root.resolve()
for file_name in files:
path = Path(file_name)
full_path = path if path.is_absolute() else root / path
try:
if full_path.is_file():
total += max(
1,
(full_path.stat().st_size + CHARS_PER_TOKEN - 1)
// CHARS_PER_TOKEN,
)
except OSError:
continue
return total
def estimate_context_savings(
*,
original_context: Any | None = None,
returned_context: Any | None = None,
original_tokens: int | None = None,
returned_tokens: int | None = None,
) -> dict[str, int | bool] | None:
"""Return tiny savings metadata, or None when no baseline is available."""
baseline = (
original_tokens
if original_tokens is not None
else estimate_tokens(original_context)
)
returned = (
returned_tokens
if returned_tokens is not None
else estimate_tokens(returned_context)
)
if baseline <= 0:
return None
saved = max(0, baseline - returned)
percent = round((saved / baseline) * 100) if baseline else 0
return {
"estimated": True,
"saved_tokens": int(saved),
"saved_percent": int(percent),
}
def attach_context_savings(
result: dict[str, Any],
*,
original_context: Any | None = None,
original_tokens: int | None = None,
returned_context: Any | None = None,
returned_tokens: int | None = None,
) -> dict[str, Any]:
"""Attach compact ``context_savings`` metadata when it can be estimated."""
estimate = estimate_context_savings(
original_context=original_context,
returned_context=result if returned_context is None else returned_context,
original_tokens=original_tokens,
returned_tokens=returned_tokens,
)
if estimate is not None:
result["context_savings"] = estimate
return result
def format_context_savings(estimate: dict[str, Any] | None) -> str | None:
"""Format a one-line human summary for CLI output."""
if not estimate:
return None
saved = int(estimate.get("saved_tokens", 0))
percent = int(estimate.get("saved_percent", 0))
return f"Estimated context saved: ~{saved:,} tokens (~{percent}%)"
def _fmt_compact(n: int) -> str:
"""Compact integer formatting: 1234 -> '1.2k', 9876 -> '9.9k', 500 -> '500'."""
if n >= 10_000:
return f"{n // 1000:,}k"
if n >= 1000:
return f"{n / 1000:.1f}k"
return str(n)
def _breakdown_from_response(response: dict[str, Any]) -> dict[str, int]:
"""Pull a per-category token estimate from a detect-changes / review response.
Only fields that exist and have content are reported, so the breakdown
line stays meaningful instead of padding with zeros.
"""
# Friendly label -> response-dict key
fields = [
("Functions", "changed_functions"),
("Flows", "affected_flows"),
("Tests", "test_gaps"),
("Risk", "review_priorities"),
("Impact", "impacted_nodes"),
("Edges", "edges"),
("Source", "source_snippets"),
("Imports", "imports"),
]
out: dict[str, int] = {}
for label, key in fields:
value = response.get(key)
if not value:
continue
tokens = estimate_tokens(value)
if tokens > 0:
out[label] = tokens
return out
def verify_with_tiktoken(
repo_root: "Path | str",
changed_files: Iterable[str],
response: Any,
encoding_name: str = "cl100k_base",
) -> dict[str, int] | None:
"""Calibrate the chars/4 estimate against a real model tokenizer.
Returns ``{"verified_baseline": int, "verified_returned": int,
"verified_saved": int, "verified_percent": int}`` or ``None`` if
tiktoken is not installed. Reads every changed file's content (unlike
the stat-only ``estimate_file_tokens``) so the numbers reflect what
an agent would actually consume.
"""
try:
import tiktoken # type: ignore[import-untyped]
except ImportError:
return None
enc = tiktoken.get_encoding(encoding_name)
root = Path(repo_root).resolve()
naive_real = 0
for f in changed_files:
p = root / f
try:
if p.is_file():
naive_real += len(enc.encode(p.read_text(errors="replace")))
except OSError:
continue
if isinstance(response, str):
graph_real = len(enc.encode(response))
else:
text = json.dumps(
response, default=str, ensure_ascii=True,
separators=(",", ":"), sort_keys=True,
)
graph_real = len(enc.encode(text))
saved = max(0, naive_real - graph_real)
pct = round(saved * 100 / naive_real) if naive_real > 0 else 0
return {
"verified_baseline": naive_real,
"verified_returned": graph_real,
"verified_saved": saved,
"verified_percent": pct,
}
def format_context_savings_panel(
estimate: dict[str, Any] | None,
*,
original_tokens: int | None = None,
returned_tokens: int | None = None,
response: dict[str, Any] | None = None,
breakdown: dict[str, int] | None = None,
verified: dict[str, int] | None = None,
title: str = "Token Savings",
width: int = 64,
) -> str | None:
"""Format the savings estimate as a boxed multi-line CLI panel.
Example output (width=60)::
┌──────────────── Token Savings ────────────────┐
│ Full context would be: 12,932 tokens │
│ Graph context used: 773 tokens │
│ Saved: 12,159 tokens (~94%) │
│ Breakdown: Functions 580 · Tests 120 · ... │
└───────────────────────────────────────────────┘
All numbers are labelled as estimates upstream (``estimated: true`` in the
metadata dict) because the project uses a 4-chars-per-token approximation,
not model-specific tokenization.
Args:
estimate: The ``context_savings`` dict from a tool response.
original_tokens: Optional override for the naive baseline.
returned_tokens: Optional override for the graph response size.
response: When provided, breakdown is auto-derived from common keys
(``changed_functions``, ``affected_flows``, ``test_gaps``,
``review_priorities``, ``impacted_nodes``, ``edges``,
``source_snippets``, ``imports``).
breakdown: Explicit ``{label: tokens}`` map; takes precedence over
``response``-derived breakdown when both are provided.
title: Title centered in the top border.
width: Total panel width, capped at terminal width if larger.
Returns:
The panel as a single ``\\n``-joined string, or ``None`` when there
is nothing meaningful to display.
"""
if not estimate:
return None
saved = int(estimate.get("saved_tokens", 0))
percent = int(estimate.get("saved_percent", 0))
# Derive baseline + returned from saved+percent if not provided
if original_tokens is None:
if percent > 0:
original_tokens = int(round(saved * 100 / percent))
else:
original_tokens = saved
if returned_tokens is None:
returned_tokens = max(0, (original_tokens or 0) - saved)
if breakdown is None and response is not None:
breakdown = _breakdown_from_response(response)
# Top up the breakdown with an "Other" bucket so the parts sum to
# ``returned_tokens`` exactly. "Other" covers fields the breakdown
# doesn't enumerate (status, summary, risk_score, context_savings
# metadata, JSON envelope chars). Skip when there's no positive
# remainder — the breakdown already accounts for the whole response.
if breakdown and returned_tokens is not None:
labelled_sum = sum(breakdown.values())
remainder = returned_tokens - labelled_sum
if remainder > 0:
breakdown = dict(breakdown) # copy before mutating
breakdown["Other"] = remainder
# Lines that go inside the box (without borders)
inner_lines: list[str] = [
f"Full context would be: {original_tokens:>9,} tokens",
f"Graph context used: {returned_tokens:>9,} tokens",
f"Saved: {saved:>9,} tokens (~{percent}%)",
]
if verified:
vb = verified["verified_baseline"]
vr = verified["verified_returned"]
vs = verified["verified_saved"]
vp = verified["verified_percent"]
inner_lines.append(
f"Verified (tiktoken): {vs:>9,} tokens (~{vp}%) "
f"[{vb:,}{vr:,}]"
)
if breakdown:
parts = [f"{label} {_fmt_compact(tok)}" for label, tok in breakdown.items()]
bd_line = "Breakdown: " + " · ".join(parts)
inner_lines.append(bd_line)
# Compute final width: at least wide enough for the longest inner line + padding
content_width = max(len(s) for s in inner_lines)
inner_w = max(width - 2, content_width + 2) # +2 for one space pad each side
# Title bar
title_str = f" {title} "
dash_total = inner_w - len(title_str)
if dash_total < 4:
dash_total = 4
left_dash = dash_total // 2
right_dash = dash_total - left_dash
top = "" + "" * left_dash + title_str + "" * right_dash + ""
bottom = "" + "" * inner_w + ""
def _box_line(content: str) -> str:
pad = inner_w - 2 - len(content)
if pad < 0:
pad = 0
return f"{content}{' ' * pad}"
lines = [top]
for s in inner_lines:
lines.append(_box_line(s))
lines.append(bottom)
return "\n".join(lines)
+322
View File
@@ -0,0 +1,322 @@
"""Config-driven custom language support ("bring your own language").
Repos can teach the parser new tree-sitter languages without forking by
dropping a ``languages.toml`` file into ``.code-review-graph/``::
[languages.erlang]
extensions = [".erl", ".hrl"]
grammar = "erlang" # tree_sitter_language_pack name
function_node_types = ["function_clause"]
class_node_types = ["record_decl"]
import_node_types = ["import_attribute"]
call_node_types = ["call"]
comment = "Erlang via the bundled tree-sitter-erlang grammar"
The loader is deliberately defensive: a broken config must never crash a
build. Invalid entries are skipped with a ``logger.warning``, and built-in
languages always win — custom entries can neither override built-in file
extensions nor reuse built-in language names. At most
``MAX_CUSTOM_LANGUAGES`` entries are honoured per repo.
See docs/CUSTOM_LANGUAGES.md for the full schema reference (answers #320).
"""
from __future__ import annotations
import logging
import re
import sys
import threading
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import tree_sitter_language_pack as tslp
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError:
tomllib = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
#: Location of the config file, relative to the repo root.
CONFIG_RELATIVE_PATH = Path(".code-review-graph") / "languages.toml"
#: Hard cap on the number of custom languages loaded from a single config.
MAX_CUSTOM_LANGUAGES = 20
#: Custom language names: short lowercase identifiers. The name becomes the
#: ``language`` field on every node parsed from matching files.
_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
#: Extensions: a leading dot followed by 1-15 safe characters (".erl",
#: ".cls", ".4gl"). Uppercase input is normalised to lowercase because the
#: parser lowercases file suffixes before lookup.
_EXTENSION_RE = re.compile(r"^\.[a-z0-9_+-]{1,15}$")
#: The four node-type lists recognised in each ``[languages.<name>]`` table.
_NODE_TYPE_KEYS = (
"function_node_types",
"class_node_types",
"import_node_types",
"call_node_types",
)
@dataclass(frozen=True)
class CustomLanguage:
"""One validated ``[languages.<name>]`` entry from languages.toml."""
name: str
grammar: str
extensions: tuple[str, ...]
function_node_types: tuple[str, ...] = ()
class_node_types: tuple[str, ...] = ()
import_node_types: tuple[str, ...] = ()
call_node_types: tuple[str, ...] = ()
comment: str = ""
@dataclass(frozen=True)
class _CacheEntry:
mtime_ns: int
size: int
languages: dict[str, CustomLanguage] = field(default_factory=dict)
# Config files are re-read only when their mtime/size changes. This matters
# because full builds construct one CodeParser per worker task, and probing
# tree-sitter grammars on every file parse would be wasteful.
_cache_lock = threading.Lock()
_cache: dict[str, _CacheEntry] = {}
def clear_cache() -> None:
"""Drop the loader cache (used by tests)."""
with _cache_lock:
_cache.clear()
def load_custom_languages(
repo_root: Path,
*,
builtin_extensions: Mapping[str, str],
builtin_languages: frozenset[str],
) -> dict[str, CustomLanguage]:
"""Load and validate ``<repo_root>/.code-review-graph/languages.toml``.
Returns a mapping of custom language name -> :class:`CustomLanguage`.
Always returns (possibly empty) — a broken config never raises.
Args:
repo_root: Repository root containing ``.code-review-graph/``.
builtin_extensions: The parser's built-in extension map; custom
entries colliding with these are skipped (built-ins win).
builtin_languages: All built-in language identifiers; custom names
shadowing these are skipped.
"""
config_path = Path(repo_root) / CONFIG_RELATIVE_PATH
try:
stat = config_path.stat()
except OSError:
return {} # No config file — the common case; not worth a log line.
cache_key = str(config_path)
with _cache_lock:
cached = _cache.get(cache_key)
if (
cached is not None
and cached.mtime_ns == stat.st_mtime_ns
and cached.size == stat.st_size
):
return dict(cached.languages)
languages = _load_uncached(config_path, builtin_extensions, builtin_languages)
with _cache_lock:
_cache[cache_key] = _CacheEntry(stat.st_mtime_ns, stat.st_size, dict(languages))
return languages
def _load_uncached(
config_path: Path,
builtin_extensions: Mapping[str, str],
builtin_languages: frozenset[str],
) -> dict[str, CustomLanguage]:
if tomllib is None:
logger.warning(
"%s found but TOML parsing requires the 'tomli' package on "
"Python < 3.11 — no custom languages loaded",
config_path,
)
return {}
try:
raw = config_path.read_bytes()
except (OSError, PermissionError) as exc:
logger.warning("Cannot read %s: %s — no custom languages loaded", config_path, exc)
return {}
try:
data = tomllib.loads(raw.decode("utf-8", errors="replace"))
except tomllib.TOMLDecodeError as exc:
logger.warning("Malformed TOML in %s: %s — no custom languages loaded", config_path, exc)
return {}
tables = data.get("languages")
if tables is None:
return {}
if not isinstance(tables, dict):
logger.warning(
"%s: [languages] must be a table of tables — no custom languages loaded",
config_path,
)
return {}
result: dict[str, CustomLanguage] = {}
claimed_extensions: set[str] = set()
for name, table in tables.items():
if len(result) >= MAX_CUSTOM_LANGUAGES:
logger.warning(
"%s defines more than %d custom languages — ignoring the rest",
config_path, MAX_CUSTOM_LANGUAGES,
)
break
lang = _validate_entry(
name, table, builtin_extensions, builtin_languages,
claimed_extensions, config_path,
)
if lang is None:
continue
result[lang.name] = lang
claimed_extensions.update(lang.extensions)
return result
def _validate_entry(
name: object,
table: object,
builtin_extensions: Mapping[str, str],
builtin_languages: frozenset[str],
claimed_extensions: set[str],
config_path: Path,
) -> Optional[CustomLanguage]:
"""Validate one ``[languages.<name>]`` table; None (after a warning) on
any problem so a bad entry can never break a build."""
label = name if isinstance(name, str) else repr(name)
if not isinstance(table, dict):
logger.warning("%s: [languages.%s] is not a table — skipping", config_path, label)
return None
if not isinstance(name, str) or not _NAME_RE.match(name):
logger.warning(
"%s: invalid custom language name %r (expected lowercase "
"letters/digits/_/-, max 32 chars) — skipping",
config_path, label,
)
return None
if name in builtin_languages:
logger.warning(
"%s: custom language %r shadows a built-in language — skipping "
"(built-ins cannot be overridden)",
config_path, name,
)
return None
grammar = table.get("grammar")
if not isinstance(grammar, str) or not grammar.strip():
logger.warning(
"%s: custom language %r needs a non-empty 'grammar' string — skipping",
config_path, name,
)
return None
grammar = grammar.strip()
raw_extensions = table.get("extensions")
if not isinstance(raw_extensions, list) or not raw_extensions:
logger.warning(
"%s: custom language %r needs a non-empty 'extensions' list — skipping",
config_path, name,
)
return None
extensions: list[str] = []
for ext in raw_extensions:
normalized = ext.strip().lower() if isinstance(ext, str) else ""
if not normalized.startswith("."):
logger.warning(
"%s: custom language %r: extension %r must start with a dot — skipping",
config_path, name, ext,
)
return None
if not _EXTENSION_RE.match(normalized):
logger.warning(
"%s: custom language %r: extension %r is not a valid file "
"extension — skipping",
config_path, name, ext,
)
return None
if normalized in builtin_extensions:
logger.warning(
"%s: custom language %r: extension %r is already handled by "
"the built-in %r parser — skipping (built-ins cannot be overridden)",
config_path, name, normalized, builtin_extensions[normalized],
)
return None
if normalized in claimed_extensions:
logger.warning(
"%s: custom language %r: extension %r is already claimed by "
"an earlier custom language — skipping",
config_path, name, normalized,
)
return None
if normalized not in extensions:
extensions.append(normalized)
node_types: dict[str, tuple[str, ...]] = {}
for key in _NODE_TYPE_KEYS:
value = table.get(key, [])
if not isinstance(value, list) or any(
not isinstance(item, str) or not item.strip() for item in value
):
logger.warning(
"%s: custom language %r: %s must be a list of non-empty "
"strings — skipping",
config_path, name, key,
)
return None
node_types[key] = tuple(item.strip() for item in value)
if not any(node_types.values()):
logger.warning(
"%s: custom language %r defines no node types — nothing to "
"extract, skipping",
config_path, name,
)
return None
comment = table.get("comment", "")
if not isinstance(comment, str):
comment = ""
# Probe the grammar last (it is the expensive check). Parser objects
# themselves are created lazily by CodeParser._get_parser.
try:
tslp.get_language(grammar) # type: ignore[arg-type]
except (LookupError, ValueError, ImportError, OSError) as exc:
logger.warning(
"%s: custom language %r: grammar %r is not available in "
"tree_sitter_language_pack (%s) — skipping",
config_path, name, grammar, exc,
)
return None
return CustomLanguage(
name=name,
grammar=grammar,
extensions=tuple(extensions),
function_node_types=node_types["function_node_types"],
class_node_types=node_types["class_node_types"],
import_node_types=node_types["import_node_types"],
call_node_types=node_types["call_node_types"],
comment=comment,
)
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
"""CLI entry point for the crg-daemon multi-repo watcher.
Usage:
crg-daemon start [--foreground]
crg-daemon stop
crg-daemon restart [--foreground]
crg-daemon status
crg-daemon logs [--repo ALIAS] [--follow] [--lines N]
crg-daemon add <path> [--alias ALIAS]
crg-daemon remove <path_or_alias>
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import subprocess
import sys
import time
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def _handle_start(args: argparse.Namespace) -> None:
"""Start the daemon process."""
from .daemon import WatchDaemon, is_daemon_running, load_config
if is_daemon_running():
print("Error: Daemon is already running.")
sys.exit(1)
config = load_config()
daemon = WatchDaemon(config=config)
daemon.start()
if not args.foreground:
daemon.daemonize()
daemon.run_forever()
def _handle_stop(_args: argparse.Namespace) -> None:
"""Stop the running daemon process."""
from .daemon import clear_pid, is_daemon_running, read_pid
if not is_daemon_running():
print("Daemon is not running.")
sys.exit(1)
pid = read_pid()
if pid is None:
print("Error: Could not read daemon PID.")
sys.exit(1)
print(f"Stopping daemon (PID {pid})...")
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
clear_pid()
print("Daemon stopped (process already gone).")
return
except PermissionError:
print(f"Error: Permission denied sending signal to PID {pid}.")
sys.exit(1)
# Wait up to 5 seconds for process to die
for _ in range(50):
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.1)
else:
# Still alive after 5s — send SIGKILL
print("Daemon did not stop gracefully, sending SIGKILL...")
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
clear_pid()
print("Daemon stopped.")
def _handle_restart(args: argparse.Namespace) -> None:
"""Restart the daemon (stop + start)."""
from .daemon import is_daemon_running
if is_daemon_running():
_handle_stop(args)
else:
print("Daemon is not running, starting fresh.")
_handle_start(args)
def _handle_status(_args: argparse.Namespace) -> None:
"""Show daemon status and configuration."""
from .daemon import is_daemon_running, load_config, load_state, pid_alive, read_pid
config = load_config()
running = is_daemon_running()
if running:
pid = read_pid()
print(f"Daemon: running (PID {pid})")
else:
print("Daemon: not running")
print(f"Name: {config.session_name}")
print(f"Log dir: {config.log_dir}")
print(f"Poll: {config.poll_interval}s")
print()
if not config.repos:
print("No repositories configured.")
print("Use: crg-daemon add <path> [--alias NAME]")
return
# Header
alias_width = max(len(r.alias) for r in config.repos)
alias_width = max(alias_width, 5) # minimum "Alias" header width
if running:
state = load_state()
print(f" {'Alias':<{alias_width}} {'Status':<8} {'PID':<8} Path")
print(f" {'-' * alias_width} {'-' * 8} {'-' * 8} {'-' * 40}")
for repo in config.repos:
entry = state.get(repo.alias, {})
child_pid: int | None = entry.get("pid")
alive = child_pid is not None and pid_alive(child_pid)
status_str = "alive" if alive else "dead"
pid_str = str(child_pid) if child_pid is not None else "-"
print(f" {repo.alias:<{alias_width}} {status_str:<8} {pid_str:<8} {repo.path}")
else:
print(f" {'Alias':<{alias_width}} Path")
print(f" {'-' * alias_width} {'-' * 40}")
for repo in config.repos:
print(f" {repo.alias:<{alias_width}} {repo.path}")
def _handle_logs(args: argparse.Namespace) -> None:
"""Show daemon or per-repo log files."""
from .daemon import load_config
config = load_config()
if args.repo:
log_file = config.log_dir / f"{args.repo}.log"
else:
log_file = config.log_dir / "daemon.log"
if not log_file.exists():
print(f"Log file not found: {log_file}")
sys.exit(1)
if args.follow:
try:
subprocess.run(["tail", "-f", str(log_file)], check=False)
except KeyboardInterrupt:
pass
return
# Read last N lines
lines_count = args.lines
try:
text = log_file.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"Error reading log file: {exc}")
sys.exit(1)
lines = text.splitlines()
tail = lines[-lines_count:] if len(lines) > lines_count else lines
for line in tail:
print(line)
def _handle_add(args: argparse.Namespace) -> None:
"""Add a repository to the daemon config."""
from .daemon import add_repo_to_config, is_daemon_running
try:
add_repo_to_config(args.path, alias=args.alias)
except ValueError as exc:
print(f"Error: {exc}")
sys.exit(1)
# Find the repo we just added to show confirmation
alias = args.alias or os.path.basename(os.path.abspath(args.path))
print(f"Added repository: {args.path} (alias: {alias})")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
def _handle_remove(args: argparse.Namespace) -> None:
"""Remove a repository from the daemon config."""
from .daemon import is_daemon_running, load_config, remove_repo_from_config
config_before = load_config()
count_before = len(config_before.repos)
config_after = remove_repo_from_config(args.path_or_alias)
count_after = len(config_after.repos)
if count_before == count_after:
print(f"No repository matching '{args.path_or_alias}' found in config.")
sys.exit(1)
print(f"Removed repository: {args.path_or_alias}")
if is_daemon_running():
print("Daemon will pick up the change automatically.")
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the crg-daemon CLI."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
ap = argparse.ArgumentParser(
prog="crg-daemon",
description="Multi-repo watch daemon for code-review-graph",
)
sub = ap.add_subparsers(dest="command")
# start
start_cmd = sub.add_parser("start", help="Start the daemon")
start_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# stop
sub.add_parser("stop", help="Stop the daemon")
# restart
restart_cmd = sub.add_parser("restart", help="Restart the daemon")
restart_cmd.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground instead of daemonizing",
)
# status
sub.add_parser("status", help="Show daemon status and configuration")
# logs
logs_cmd = sub.add_parser("logs", help="Show daemon or per-repo logs")
logs_cmd.add_argument(
"--repo",
default=None,
metavar="ALIAS",
help="Show logs for a specific repo (by alias)",
)
logs_cmd.add_argument(
"--follow",
"-f",
action="store_true",
help="Follow log output (tail -f)",
)
logs_cmd.add_argument(
"--lines",
"-n",
type=int,
default=50,
help="Number of lines to show (default: 50)",
)
# add
add_cmd = sub.add_parser("add", help="Add a repository to the daemon config")
add_cmd.add_argument("path", help="Path to the repository")
add_cmd.add_argument(
"--alias",
default=None,
help="Short alias for the repository (default: directory name)",
)
# remove
remove_cmd = sub.add_parser("remove", help="Remove a repository from the daemon config")
remove_cmd.add_argument("path_or_alias", help="Repository path or alias to remove")
args = ap.parse_args()
if not args.command:
ap.print_help()
sys.exit(0)
handlers: dict[str, object] = {
"start": _handle_start,
"stop": _handle_stop,
"restart": _handle_restart,
"status": _handle_status,
"logs": _handle_logs,
"add": _handle_add,
"remove": _handle_remove,
}
handler = handlers.get(args.command)
if handler is None:
ap.print_help()
sys.exit(1)
handler(args) # type: ignore[operator]
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
"""PreToolUse search enrichment for Claude Code hooks.
Intercepts Grep/Glob/Bash/Read tool calls and enriches them with
structural context from the code knowledge graph: callers, callees,
execution flows, community membership, and test coverage.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Flags that consume the next token in grep/rg commands
_RG_FLAGS_WITH_VALUES = frozenset({
"-e", "-f", "-m", "-A", "-B", "-C", "-g", "--glob",
"-t", "--type", "--include", "--exclude", "--max-count",
"--max-depth", "--max-filesize", "--color", "--colors",
"--context-separator", "--field-match-separator",
"--path-separator", "--replace", "--sort", "--sortr",
})
def extract_pattern(tool_name: str, tool_input: dict[str, Any]) -> str | None:
"""Extract a search pattern from a tool call's input.
Returns None if no meaningful pattern can be extracted.
"""
if tool_name == "Grep":
return tool_input.get("pattern")
if tool_name == "Glob":
raw = tool_input.get("pattern", "")
# Extract meaningful name from glob: "**/auth*.ts" -> "auth"
# Skip pure extension globs like "**/*.ts"
match = re.search(r"[*/]([a-zA-Z][a-zA-Z0-9_]{2,})", raw)
return match.group(1) if match else None
if tool_name == "Bash":
cmd = tool_input.get("command", "")
if not re.search(r"\brg\b|\bgrep\b", cmd):
return None
tokens = cmd.split()
found_cmd = False
skip_next = False
for token in tokens:
if skip_next:
skip_next = False
continue
if not found_cmd:
if re.search(r"\brg$|\bgrep$", token):
found_cmd = True
continue
if token.startswith("-"):
if token in _RG_FLAGS_WITH_VALUES:
skip_next = True
continue
cleaned = token.strip("'\"")
return cleaned if len(cleaned) >= 3 else None
return None
return None
def _make_relative(file_path: str, repo_root: str) -> str:
"""Make a file path relative to repo_root for display."""
try:
return str(Path(file_path).relative_to(repo_root))
except ValueError:
return file_path
def _get_community_name(conn: Any, community_id: int) -> str:
"""Fetch a community name by ID."""
row = conn.execute(
"SELECT name FROM communities WHERE id = ?", (community_id,)
).fetchone()
return row["name"] if row else ""
def _get_flow_names_for_node(conn: Any, node_id: int) -> list[str]:
"""Fetch execution flow names that a node participates in (max 3)."""
rows = conn.execute(
"SELECT f.name FROM flow_memberships fm "
"JOIN flows f ON fm.flow_id = f.id "
"WHERE fm.node_id = ? LIMIT 3",
(node_id,),
).fetchall()
return [r["name"] for r in rows]
def _format_node_context(
node: Any,
store: Any,
conn: Any,
repo_root: str,
) -> list[str]:
"""Format a single node's structural context as plain text lines."""
from .graph import GraphNode
assert isinstance(node, GraphNode)
qn = node.qualified_name
loc = _make_relative(node.file_path, repo_root)
if node.line_start:
loc = f"{loc}:{node.line_start}"
header = f"{node.name} ({loc})"
# Community
if node.extra.get("community_id"):
cname = _get_community_name(conn, node.extra["community_id"])
if cname:
header += f" [{cname}]"
else:
# Check via direct query
row = conn.execute(
"SELECT community_id FROM nodes WHERE id = ?", (node.id,)
).fetchone()
if row and row["community_id"]:
cname = _get_community_name(conn, row["community_id"])
if cname:
header += f" [{cname}]"
lines = [header]
# Callers (max 5, deduplicated)
callers: list[str] = []
seen: set[str] = set()
for e in store.get_edges_by_target(qn):
if e.kind == "CALLS" and len(callers) < 5:
c = store.get_node(e.source_qualified)
if c and c.name not in seen:
seen.add(c.name)
callers.append(c.name)
if callers:
lines.append(f" Called by: {', '.join(callers)}")
# Callees (max 5, deduplicated)
callees: list[str] = []
seen.clear()
for e in store.get_edges_by_source(qn):
if e.kind == "CALLS" and len(callees) < 5:
c = store.get_node(e.target_qualified)
if c and c.name not in seen:
seen.add(c.name)
callees.append(c.name)
if callees:
lines.append(f" Calls: {', '.join(callees)}")
# Execution flows
flow_names = _get_flow_names_for_node(conn, node.id)
if flow_names:
lines.append(f" Flows: {', '.join(flow_names)}")
# Tests
tests: list[str] = []
for e in store.get_edges_by_target(qn):
if e.kind == "TESTED_BY" and len(tests) < 3:
t = store.get_node(e.source_qualified)
if t:
tests.append(t.name)
if tests:
lines.append(f" Tests: {', '.join(tests)}")
return lines
def enrich_search(pattern: str, repo_root: str) -> str:
"""Search the graph for pattern and return enriched context."""
from .graph import GraphStore
from .search import _fts_search
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return ""
store = GraphStore(db_path)
try:
conn = store._conn
fts_results = _fts_search(conn, pattern, limit=8)
if not fts_results:
return ""
all_lines: list[str] = []
count = 0
for node_id, _score in fts_results:
if count >= 5:
break
node = store.get_node_by_id(node_id)
if not node or node.is_test:
continue
node_lines = _format_node_context(node, store, conn, repo_root)
all_lines.extend(node_lines)
all_lines.append("")
count += 1
if not all_lines:
return ""
header = f'[code-review-graph] {count} symbol(s) matching "{pattern}":\n'
return header + "\n".join(all_lines)
finally:
store.close()
def enrich_file_read(file_path: str, repo_root: str) -> str:
"""Enrich a file read with structural context for functions in that file."""
from .graph import GraphStore
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return ""
store = GraphStore(db_path)
try:
conn = store._conn
nodes = store.get_nodes_by_file(file_path)
if not nodes:
# Try with resolved path
try:
resolved = str(Path(file_path).resolve())
nodes = store.get_nodes_by_file(resolved)
except (OSError, ValueError):
pass
if not nodes:
return ""
# Filter to functions/classes/types (skip File nodes), limit to 10
interesting = [
n for n in nodes
if n.kind in ("Function", "Class", "Type", "Test")
][:10]
if not interesting:
return ""
all_lines: list[str] = []
for node in interesting:
node_lines = _format_node_context(node, store, conn, repo_root)
all_lines.extend(node_lines)
all_lines.append("")
rel_path = _make_relative(file_path, repo_root)
header = (
f"[code-review-graph] {len(interesting)} symbol(s) in {rel_path}:\n"
)
return header + "\n".join(all_lines)
finally:
store.close()
def run_hook() -> None:
"""Entry point for the enrich CLI subcommand.
Reads Claude Code hook JSON from stdin, extracts the search pattern,
queries the graph, and outputs hookSpecificOutput JSON to stdout.
"""
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
tool_name = hook_input.get("tool_name", "")
tool_input = hook_input.get("tool_input", {})
cwd = hook_input.get("cwd", os.getcwd())
# Find repo root by walking up from cwd
from .incremental import find_project_root
repo_root = str(find_project_root(Path(cwd)))
db_path = Path(repo_root) / ".code-review-graph" / "graph.db"
if not db_path.exists():
return
# Dispatch
context = ""
if tool_name == "Read":
fp = tool_input.get("file_path", "")
if fp:
context = enrich_file_read(fp, repo_root)
else:
pattern = extract_pattern(tool_name, tool_input)
if not pattern or len(pattern) < 3:
return
context = enrich_search(pattern, repo_root)
if not context:
return
response = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": context,
}
}
json.dump(response, sys.stdout)
+33
View File
@@ -0,0 +1,33 @@
"""Evaluation framework for code-review-graph.
Provides scoring metrics (token efficiency, MRR, precision/recall),
benchmark runners, and report generators for benchmarking graph-based code reviews.
"""
from __future__ import annotations
from .reporter import generate_full_report, generate_markdown_report, generate_readme_tables
from .scorer import compute_mrr, compute_precision_recall, compute_token_efficiency
def __getattr__(name: str):
"""Lazy-import runner functions (require pyyaml)."""
_runner_names = {"load_all_configs", "load_config", "run_eval", "write_csv"}
if name in _runner_names:
from . import runner
return getattr(runner, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"compute_mrr",
"compute_precision_recall",
"compute_token_efficiency",
"generate_full_report",
"generate_markdown_report",
"generate_readme_tables",
"load_all_configs",
"load_config",
"run_eval",
"write_csv",
]
@@ -0,0 +1 @@
"""Benchmark modules for the evaluation framework."""
@@ -0,0 +1,193 @@
"""Agent baseline benchmark: grep-and-read-top-k versus a graph query.
The whole-corpus baseline in the standalone token benchmark is an upper
bound no real agent pays: a competent agent greps for identifiers from the
question and reads only the best-matching files. This benchmark measures
that realistic baseline:
1. Derive search terms from the question (identifier-shaped tokens via
``search.extract_query_identifiers`` plus plain keywords).
2. Pure-python grep over the corpus (no external ``rg``/``grep`` binary),
ranking files by total case-insensitive match count.
3. Read the top-k files (k=3) and token-count them with the chars/4 utility
(``token_benchmark.estimate_tokens``) as ``baseline_tokens``.
4. Compare against the graph-query cost for the same question — hybrid
search hits plus one hop of neighbor edges, the same accounting used by
``code_review_graph/token_benchmark.py``.
Questions come from ``agent_questions:`` in the repo config, falling back to
the ``search_queries`` query strings when absent.
Failure semantics match the other benchmarks: a thrown search is recorded
with ``status="error"`` and excluded from aggregates; rows where either side
of the ratio is zero get ``status="no_graph_results"`` /
``status="no_baseline_match"`` and are likewise excluded.
"""
from __future__ import annotations
import logging
import statistics
from collections.abc import Iterator
from pathlib import Path
from code_review_graph.token_benchmark import estimate_tokens
logger = logging.getLogger(__name__)
DEFAULT_TOP_K = 3
_SOURCE_EXTS = (
".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java",
".c", ".cpp", ".h", ".rb", ".php", ".swift", ".kt",
)
_SKIP_DIRS = {
".git", ".hg", ".svn", "node_modules", "__pycache__",
".code-review-graph", ".venv", "venv", "dist", "build",
}
_STOPWORDS = {
"how", "does", "do", "the", "a", "an", "is", "are", "was", "what",
"where", "when", "which", "who", "why", "and", "or", "in", "on", "of",
"to", "for", "with", "via", "into", "from", "this", "that", "it", "its",
}
def derive_search_terms(question: str) -> list[str]:
"""Derive lowercase grep terms: identifiers first, then plain keywords.
Identifier-shaped tokens (``Client.request``, ``get_users``, ``APIRoute``)
are extracted via ``search.extract_query_identifiers``; remaining words of
3+ characters that are not stopwords are appended. Order is deterministic.
"""
from code_review_graph.search import extract_query_identifiers
terms: list[str] = []
seen: set[str] = set()
for ident in extract_query_identifiers(question):
if ident not in seen:
seen.add(ident)
terms.append(ident)
for word in question.split():
w = word.strip(".,;:!?\"'()[]{}`").lower()
if len(w) >= 3 and w not in _STOPWORDS and w not in seen:
seen.add(w)
terms.append(w)
return terms
def iter_source_files(repo_path: Path) -> Iterator[Path]:
"""Yield source files under *repo_path*, skipping vendored/VCS dirs."""
for path in sorted(repo_path.rglob("*")):
if path.suffix not in _SOURCE_EXTS or not path.is_file():
continue
if any(part in _SKIP_DIRS for part in path.parts):
continue
yield path
def grep_rank(
repo_path: Path, terms: list[str], k: int = DEFAULT_TOP_K,
) -> list[tuple[str, int]]:
"""Rank source files by total case-insensitive term matches; take top-k.
Pure python — no external grep/rg dependency. Deterministic: ties break
on the relative path. Files with zero matches are dropped.
"""
lowered = [t.lower() for t in terms if t]
if not lowered:
return []
scores: list[tuple[str, int]] = []
for path in iter_source_files(repo_path):
try:
text = path.read_text(encoding="utf-8", errors="replace").lower()
except OSError:
continue
count = sum(text.count(term) for term in lowered)
if count > 0:
scores.append((str(path.relative_to(repo_path)), count))
scores.sort(key=lambda item: (-item[1], item[0]))
return scores[:k]
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run the agent baseline benchmark for one repo."""
questions = list(config.get("agent_questions") or [])
if not questions:
questions = [sq["query"] for sq in config.get("search_queries", [])]
k = int(config.get("agent_baseline_top_k", DEFAULT_TOP_K))
results: list[dict] = []
for question in questions:
terms = derive_search_terms(question)
top = grep_rank(repo_path, terms, k=k)
baseline_tokens = 0
for rel, _count in top:
try:
baseline_tokens += estimate_tokens(
(repo_path / rel).read_text(encoding="utf-8", errors="replace")
)
except OSError:
continue
row: dict = {
"repo": config["name"],
"question": question,
"terms": " ".join(terms),
"files_matched": len(top),
"top_files": ";".join(rel for rel, _ in top),
"baseline_tokens": baseline_tokens,
"graph_tokens": "",
"baseline_to_graph_ratio": "",
"status": "ok",
"error": "",
}
try:
from code_review_graph.search import hybrid_search
hits = hybrid_search(store, question, limit=5)
except Exception as exc:
logger.warning("hybrid_search failed on %r: %s", question, exc)
row["status"] = "error"
row["error"] = str(exc)[:200]
results.append(row)
continue
# Same accounting as the standalone token benchmark: search hits
# plus up to 5 outgoing edges of neighbor context per hit.
graph_tokens = 0
for hit in hits:
graph_tokens += estimate_tokens(str(hit))
qn = hit.get("qualified_name", "")
for edge in store.get_edges_by_source(qn)[:5]:
graph_tokens += estimate_tokens(str(edge))
row["graph_tokens"] = graph_tokens
if baseline_tokens > 0 and graph_tokens > 0:
row["baseline_to_graph_ratio"] = round(baseline_tokens / graph_tokens, 1)
elif graph_tokens == 0:
row["status"] = "no_graph_results"
else:
row["status"] = "no_baseline_match"
results.append(row)
return results
def aggregate(results: list[dict]) -> dict:
"""Aggregate over rows where both sides of the comparison exist."""
ok = [r for r in results if r.get("status") == "ok"]
ratios = [float(r["baseline_to_graph_ratio"]) for r in ok]
return {
"total_rows": len(results),
"ok_rows": len(ok),
"error_rows": sum(1 for r in results if r.get("status") == "error"),
"median_baseline_to_graph_ratio": (
round(statistics.median(ratios), 1) if ratios else None
),
"mean_baseline_to_graph_ratio": (
round(statistics.mean(ratios), 1) if ratios else None
),
}
@@ -0,0 +1,60 @@
"""Build performance benchmark: measures timing of graph operations."""
from __future__ import annotations
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run build performance benchmark."""
stats = store.get_stats()
# Time flow detection
try:
from code_review_graph.flows import store_flows, trace_flows
t0 = time.perf_counter()
flows = trace_flows(store)
store_flows(store, flows)
flow_time = time.perf_counter() - t0
except Exception as exc:
logger.warning("Flow detection failed: %s", exc)
flow_time = 0.0
# Time community detection
try:
from code_review_graph.communities import detect_communities, store_communities
t0 = time.perf_counter()
comms = detect_communities(store)
store_communities(store, comms)
community_time = time.perf_counter() - t0
except Exception as exc:
logger.warning("Community detection failed: %s", exc)
community_time = 0.0
# Time search (average of queries)
search_times: list[float] = []
for sq in config.get("search_queries", [])[:10]:
t0 = time.perf_counter()
store.search_nodes(sq["query"], limit=20)
search_times.append(time.perf_counter() - t0)
avg_search_ms = round(
sum(search_times) / max(len(search_times), 1) * 1000, 1
)
return [{
"repo": config["name"],
"file_count": stats.files_count,
"node_count": stats.total_nodes,
"edge_count": stats.total_edges,
"flow_detection_seconds": round(flow_time, 3),
"community_detection_seconds": round(community_time, 3),
"search_avg_ms": avg_search_ms,
"nodes_per_second": round(
stats.total_nodes / max(flow_time, 0.001)
),
}]
@@ -0,0 +1,36 @@
"""Flow completeness benchmark: evaluates entry point detection and flow tracing."""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run flow completeness benchmark."""
from code_review_graph.flows import store_flows, trace_flows
flows = trace_flows(store)
count = store_flows(store, flows)
# Get detected entry point names
detected_entries = set()
for flow in flows:
detected_entries.add(flow.get("entry_point") or flow.get("name", ""))
known = set(config.get("entry_points", []))
found = sum(1 for ep in known if any(ep in d for d in detected_entries))
depths = [f.get("depth", 0) for f in flows]
return [{
"repo": config["name"],
"known_entry_points": len(known),
"detected_entry_points": found,
"recall": round(found / max(len(known), 1), 3),
"detected_flows": count,
"avg_flow_depth": round(sum(depths) / max(len(depths), 1), 1),
"max_flow_depth": max(depths, default=0),
}]
@@ -0,0 +1,220 @@
"""Impact accuracy benchmark: measures precision/recall of change impact analysis.
Two ground-truth modes are emitted side by side (``ground_truth_mode`` column):
- **graph-derived (circular — upper bound)** — the historical mode. Ground
truth is the changed files plus files with CALLS/IMPORTS_FROM edges into
them, i.e. derived from the same graph the predictor traverses. Recall in
this mode is an upper bound by construction, not independent evidence.
- **co-change (same commit, seed excluded)** — the honest mode. The predictor
is seeded with a single changed file and graded against the *other* files
the author actually touched in the same commit. The ground truth comes from
git history, not from the graph.
Failure semantics: if ``analyze_changes`` throws, the row is recorded with
``status="error"`` and empty metric fields — it stays in the CSV but is
excluded from aggregates. (Previously a failure silently set
``predicted = set(changed)``, guaranteeing a fake recall of 1.0.)
"""
from __future__ import annotations
import logging
import statistics
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
MODE_GRAPH_DERIVED = "graph-derived (circular — upper bound)"
MODE_CO_CHANGE = "co-change (same commit, seed excluded)"
def _get_changed_files(repo_path: Path, sha: str) -> list[str]:
"""Get list of changed files for a commit."""
result = subprocess.run(
["git", "diff", "--name-only", f"{sha}~1", sha],
cwd=str(repo_path),
capture_output=True,
text=True,
)
if result.returncode != 0:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1", "HEAD"],
cwd=str(repo_path),
capture_output=True,
text=True,
)
return [f.strip() for f in result.stdout.strip().splitlines() if f.strip()]
def _files_from_analysis(analysis: dict) -> set[str]:
"""Extract predicted file paths from an ``analyze_changes`` result."""
predicted: set[str] = set()
for f in analysis.get("changed_functions", []):
if isinstance(f, dict) and "file_path" in f:
predicted.add(f["file_path"])
elif isinstance(f, dict) and "file" in f:
predicted.add(f["file"])
for flow in analysis.get("affected_flows", []):
if isinstance(flow, dict):
for node in flow.get("nodes", []):
if isinstance(node, dict) and "file_path" in node:
predicted.add(node["file_path"])
return predicted
def _graph_neighbor_files(store, files: list[str]) -> set[str]:
"""Files with CALLS/IMPORTS_FROM edges into any node of *files* (one hop)."""
out: set[str] = set()
for f in files:
for node in store.get_nodes_by_file(f):
for edge in store.get_edges_by_target(node.qualified_name):
if edge.kind in ("CALLS", "IMPORTS_FROM"):
src_qual = edge.source_qualified
src_file = src_qual.split("::")[0] if "::" in src_qual else ""
if src_file:
out.add(src_file)
return out
def _base_row(repo: str, sha: str, mode: str, seed: str) -> dict:
return {
"repo": repo,
"commit": sha,
"ground_truth_mode": mode,
"seed_file": seed,
"predicted_files": "",
"actual_files": "",
"true_positives": "",
"precision": "",
"recall": "",
"f1": "",
"status": "ok",
"error": "",
}
def _scored_row(
repo: str, sha: str, mode: str, seed: str,
predicted: set[str], actual: set[str],
) -> dict:
tp = len(predicted & actual)
precision = tp / max(len(predicted), 1)
recall = tp / max(len(actual), 1)
f1 = 2 * precision * recall / max(precision + recall, 0.001)
row = _base_row(repo, sha, mode, seed)
row.update({
"predicted_files": len(predicted),
"actual_files": len(actual),
"true_positives": tp,
"precision": round(precision, 3),
"recall": round(recall, 3),
"f1": round(f1, 3),
})
return row
def _error_row(repo: str, sha: str, mode: str, seed: str, exc: Exception) -> dict:
row = _base_row(repo, sha, mode, seed)
row["status"] = "error"
row["error"] = str(exc)[:200]
return row
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run impact accuracy benchmark (both ground-truth modes)."""
from code_review_graph.changes import analyze_changes
results = []
repo = config["name"]
for tc in config.get("test_commits", []):
sha = tc["sha"]
changed = _get_changed_files(repo_path, sha)
if not changed:
continue
# --- Mode 1: graph-derived ground truth (circular — upper bound) ---
try:
analysis = analyze_changes(
store, changed, repo_root=str(repo_path), base=sha + "~1",
)
except Exception as exc:
# Old behaviour set predicted = set(changed) here, which
# guarantees recall 1.0 on a *failed* run. Mark failed instead.
logger.warning("analyze_changes failed on %s: %s", sha, exc)
results.append(_error_row(repo, sha, MODE_GRAPH_DERIVED, "", exc))
analysis = None
if analysis is not None:
predicted = set(changed) | _files_from_analysis(analysis)
actual = set(changed) | _graph_neighbor_files(store, changed)
results.append(
_scored_row(repo, sha, MODE_GRAPH_DERIVED, "", predicted, actual)
)
# --- Mode 2: co-change ground truth (honest) ---
# Seed the predictor with a single changed file and grade against
# the other files the author touched in the same commit. Note the
# seed analysis deliberately gets no repo_root/diff: it must only
# see the seed file, never the full commit diff.
seed = sorted(changed)[0]
co_actual = set(changed) - {seed}
if not co_actual:
row = _base_row(repo, sha, MODE_CO_CHANGE, seed)
row["status"] = "skipped"
row["error"] = "single-file commit: no co-changed files to grade against"
results.append(row)
continue
try:
seed_analysis = analyze_changes(store, [seed])
except Exception as exc:
logger.warning("analyze_changes (seed=%s) failed on %s: %s", seed, sha, exc)
results.append(_error_row(repo, sha, MODE_CO_CHANGE, seed, exc))
continue
co_predicted = _files_from_analysis(seed_analysis)
co_predicted |= _graph_neighbor_files(store, [seed])
co_predicted.discard(seed)
results.append(
_scored_row(repo, sha, MODE_CO_CHANGE, seed, co_predicted, co_actual)
)
return results
def aggregate(results: list[dict]) -> dict:
"""Per-mode means over successful rows only.
Error/skipped rows stay in the CSV but never contribute to a number.
"""
out: dict = {
"total_rows": len(results),
"error_rows": sum(1 for r in results if r.get("status") == "error"),
"skipped_rows": sum(1 for r in results if r.get("status") == "skipped"),
}
for key, mode in (
("graph_derived", MODE_GRAPH_DERIVED),
("co_change", MODE_CO_CHANGE),
):
rows = [
r for r in results
if r.get("ground_truth_mode") == mode and r.get("status") == "ok"
]
out[key] = {
"ok_rows": len(rows),
"mean_precision": (
round(statistics.mean(float(r["precision"]) for r in rows), 3)
if rows else None
),
"mean_recall": (
round(statistics.mean(float(r["recall"]) for r in rows), 3)
if rows else None
),
"mean_f1": (
round(statistics.mean(float(r["f1"]) for r in rows), 3)
if rows else None
),
}
return out
@@ -0,0 +1,125 @@
"""Multi-hop retrieval benchmark.
Tests a two-step tool chain that mimics how an LLM agent actually uses the
graph for complex tasks:
1. ``hybrid_search(nl_query)`` to find a starting anchor from a natural-
language question.
2. ``query_graph(pattern, target=anchor)`` to traverse one hop along the
requested edge kind (callers_of / callees_of / tests_for / ...).
For each task the benchmark records:
- ``anchor_found`` — did semantic search return a node whose qualified_name
ends with the expected suffix in the top-K?
- ``anchor_rank`` — index in the search result list (lower is better).
- ``neighbor_count`` — number of neighbors returned by the traversal.
- ``neighbor_recall`` — fraction of ``expected_neighbor_names`` that appear
among the neighbor names.
- ``score`` — ``int(anchor_found) * neighbor_recall``. Range 01.
Tasks are defined per-config under ``multi_hop_tasks:`` in
``code_review_graph/eval/configs/*.yaml``. See
``docs/REPRODUCING.md`` for the schema and the curated canonical task set.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _name_set(rows: list[dict[str, Any]]) -> set[str]:
out: set[str] = set()
for r in rows:
name = (r.get("name") or "").lower()
if name:
out.add(name)
return out
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run the multi-hop retrieval benchmark for one repo."""
# Imports are local so an import-time failure in one optional benchmark
# does not poison the whole runner.
from code_review_graph.search import hybrid_search
from code_review_graph.tools.query import query_graph
repo_root = str(repo_path)
results: list[dict] = []
for task in config.get("multi_hop_tasks", []):
task_id = task["id"]
nl_query = task["nl_query"]
suffix = task["anchor_qualified_suffix"].lower()
traversal = task.get("traversal_pattern", "callers_of")
expected = [e.lower() for e in task.get("expected_neighbor_names", [])]
k = int(task.get("k", 10))
# Step 1 — semantic search
try:
hits = hybrid_search(store, nl_query, limit=k)
except Exception as exc: # noqa: BLE001 — benchmark must not abort the runner
logger.warning("hybrid_search failed on %s: %s", task_id, exc)
hits = []
anchor = None
anchor_rank = -1
for i, h in enumerate(hits):
qn = (h.get("qualified_name") or "").lower()
if qn.endswith(suffix):
anchor = h
anchor_rank = i
break
if anchor is None:
results.append({
"repo": config["name"],
"task_id": task_id,
"nl_query": nl_query,
"anchor_found": False,
"anchor_rank": -1,
"neighbor_count": 0,
"expected_count": len(expected),
"matched_count": 0,
"neighbor_recall": 0.0,
"score": 0.0,
})
continue
# Step 2 — single-hop graph traversal from the anchor
try:
trav = query_graph(
pattern=traversal,
target=anchor["qualified_name"],
repo_root=repo_root,
detail_level="standard",
)
except Exception as exc: # noqa: BLE001
logger.warning(
"query_graph(%s) failed on %s: %s", traversal, task_id, exc,
)
trav = {}
rows = trav.get("data") or trav.get("results") or []
names = _name_set(rows)
matched = sum(1 for e in expected if e in names)
recall = matched / len(expected) if expected else 0.0
results.append({
"repo": config["name"],
"task_id": task_id,
"nl_query": nl_query,
"anchor_found": True,
"anchor_rank": anchor_rank,
"neighbor_count": len(rows),
"expected_count": len(expected),
"matched_count": matched,
"neighbor_recall": round(recall, 3),
"score": round(recall, 3),
})
return results
@@ -0,0 +1,59 @@
"""Search quality benchmark: measures search result ranking via MRR."""
from __future__ import annotations
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run search quality benchmark."""
results = []
for sq in config.get("search_queries", []):
query = sq["query"]
expected = sq["expected"]
try:
from code_review_graph.search import hybrid_search
search_results = hybrid_search(store, query, limit=20)
except (ImportError, sqlite3.OperationalError) as exc:
logger.debug("hybrid_search unavailable, using fallback: %s", exc)
# Fallback to basic search
search_results = [
{"qualified_name": n.qualified_name}
for n in store.search_nodes(query, limit=20)
]
rank = 0
for i, r in enumerate(search_results):
if isinstance(r, dict):
qn = r.get("qualified_name", "")
elif hasattr(r, "qualified_name"):
qn = r.qualified_name
else:
qn = ""
qn_lower = qn.lower()
exp_lower = expected.lower()
# Match if expected is substring of qn, qn is substring of expected,
# or the name part after :: matches
exp_name = expected.rsplit("::", 1)[-1] if "::" in expected else expected
qn_name = qn.rsplit("::", 1)[-1] if "::" in qn else qn
if (
exp_lower in qn_lower
or qn_lower in exp_lower
or exp_name.lower() == qn_name.lower()
):
rank = i + 1
break
results.append({
"repo": config["name"],
"query": query,
"expected": expected,
"rank": rank,
"reciprocal_rank": round(1.0 / rank if rank > 0 else 0.0, 3),
})
return results
@@ -0,0 +1,143 @@
"""Token efficiency benchmark: compares naive, standard, and graph-based token counts.
Failure semantics: if ``get_review_context`` throws, the row is recorded with
``status="error"`` and empty metric fields. It stays in the CSV for forensics
but is excluded from every aggregate — a failed tool call is not a
measurement. (Previously a failure silently produced ``graph_tokens=0`` and
``ratio = naive / 1``, inflating the results.)
"""
from __future__ import annotations
import json
import logging
import statistics
import subprocess
from pathlib import Path
logger = logging.getLogger(__name__)
def _count_tokens(text: str) -> int:
"""Approximate token count (1 token ~ 4 chars)."""
return len(text) // 4
def _get_changed_files(repo_path: Path, sha: str) -> list[str]:
"""Get list of changed files for a commit."""
result = subprocess.run(
["git", "diff", "--name-only", f"{sha}~1", sha],
cwd=str(repo_path),
capture_output=True,
text=True,
)
if result.returncode != 0:
# Fallback: diff against parent
result = subprocess.run(
["git", "diff", "--name-only", "HEAD~1", "HEAD"],
cwd=str(repo_path),
capture_output=True,
text=True,
)
return [f.strip() for f in result.stdout.strip().splitlines() if f.strip()]
def _count_file_tokens(repo_path: Path, files: list[str]) -> int:
"""Count tokens from full file contents (naive approach)."""
total = 0
for f in files:
fp = repo_path / f
if fp.is_file():
try:
total += _count_tokens(fp.read_text(encoding="utf-8", errors="replace"))
except OSError:
pass
return total
def _count_diff_tokens(repo_path: Path, sha: str) -> int:
"""Count tokens from git diff output (standard approach)."""
result = subprocess.run(
["git", "diff", f"{sha}~1", sha],
cwd=str(repo_path),
capture_output=True,
text=True,
)
if result.returncode != 0:
result = subprocess.run(
["git", "diff", "HEAD~1", "HEAD"],
cwd=str(repo_path),
capture_output=True,
text=True,
)
return _count_tokens(result.stdout)
def run(repo_path: Path, store, config: dict) -> list[dict]:
"""Run token efficiency benchmark."""
results = []
for tc in config.get("test_commits", []):
changed = _get_changed_files(repo_path, tc["sha"])
if not changed:
continue
naive_tokens = _count_file_tokens(repo_path, changed)
standard_tokens = _count_diff_tokens(repo_path, tc["sha"])
row: dict = {
"repo": config["name"],
"commit": tc["sha"],
"description": tc.get("description", ""),
"changed_files": len(changed),
"naive_tokens": naive_tokens,
"standard_tokens": standard_tokens,
"graph_tokens": "",
"naive_to_graph_ratio": "",
"standard_to_graph_ratio": "",
"status": "ok",
"error": "",
}
# Graph-based: use get_review_context
try:
from code_review_graph.tools import get_review_context
ctx = get_review_context(
changed_files=changed, repo_root=str(repo_path)
)
graph_tokens = _count_tokens(json.dumps(ctx))
except Exception as exc:
# A failed tool call is not a measurement. Recording
# graph_tokens=0 used to turn this into ratio = naive/1 — a
# huge fake win. Mark the row failed; aggregate() excludes it.
logger.warning("get_review_context failed on %s: %s", tc["sha"], exc)
row["status"] = "error"
row["error"] = str(exc)[:200]
results.append(row)
continue
row["graph_tokens"] = graph_tokens
row["naive_to_graph_ratio"] = round(naive_tokens / max(graph_tokens, 1), 1)
row["standard_to_graph_ratio"] = round(standard_tokens / max(graph_tokens, 1), 1)
results.append(row)
return results
def aggregate(results: list[dict]) -> dict:
"""Aggregate token-efficiency rows, excluding failed measurements.
Rows with ``status != "ok"`` stay in the CSV for forensics but must not
contribute to any headline number.
"""
ok = [r for r in results if r.get("status") == "ok"]
ratios = [float(r["naive_to_graph_ratio"]) for r in ok]
return {
"total_rows": len(results),
"ok_rows": len(ok),
"error_rows": sum(1 for r in results if r.get("status") == "error"),
"median_naive_to_graph_ratio": (
round(statistics.median(ratios), 1) if ratios else None
),
"mean_naive_to_graph_ratio": (
round(statistics.mean(ratios), 1) if ratios else None
),
}
@@ -0,0 +1,50 @@
name: code-review-graph
url: https://github.com/tirth8205/code-review-graph
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor. (This config replaces
# the historical "nextjs" entry, which used the same URL but mis-labelled the
# target as a Next.js monorepo.)
commit: 84bde35459c52e1e0c4b25c6c4799743021e0fc7
language: python
size_category: medium
test_commits:
- sha: 528801f841e519567ef54d6e52e9b9831d162e1b
description: "feat: add multi-platform MCP server installation support"
changed_files: 3
- sha: 84bde35459c52e1e0c4b25c6c4799743021e0fc7
description: "feat: add Google Antigravity platform support for MCP install"
changed_files: 2
entry_points:
- "code_review_graph/cli.py::cli"
- "code_review_graph/main.py::main"
search_queries:
- query: "GraphStore nodes"
expected: "code_review_graph/graph.py::GraphStore"
- query: "parse AST"
expected: "code_review_graph/parser.py::CodeParser"
- query: "full build"
expected: "code_review_graph/incremental.py::full_build"
multi_hop_tasks:
- id: crg-parse-file-callers
nl_query: "Who invokes the parser entry point on a single source file"
anchor_qualified_suffix: "code_review_graph/parser.py::codeparser.parse_file"
traversal_pattern: callers_of
expected_neighbor_names: ["setup_method"]
k: 10
- id: crg-upsert-node-callers
nl_query: "Where the graph store inserts or updates a node"
anchor_qualified_suffix: "code_review_graph/graph.py::graphstore.upsert_node"
traversal_pattern: callers_of
expected_neighbor_names: ["store_file_nodes_edges"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does GraphStore upsert_node store a node"
- "Where does full_build parse the repository"
- "How does hybrid_search rank search results"
@@ -0,0 +1,45 @@
name: express
url: https://github.com/expressjs/express
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor.
commit: b4ab7d65d7724d9309b6faaaf82ad492da2a6d35
language: javascript
size_category: small
test_commits:
- sha: 925a1dff1e42f1b393c977b8b77757fcf633e09f
description: "fix: bump qs minimum to ^6.14.2 for CVE-2026-2391"
changed_files: 1
- sha: b4ab7d65d7724d9309b6faaaf82ad492da2a6d35
description: "test: include edge case tests for res.type()"
changed_files: 1
entry_points:
- "lib/application.js::app.handle"
- "lib/express.js::createApplication"
search_queries:
- query: "app handle"
expected: "lib/application.js::app"
- query: "response send"
expected: "lib/response.js::res"
- query: "request"
expected: "lib/request.js::req"
# Express has only one task — JS modules use prototypes + module.exports
# heavily, so most "method" callers are not represented as proper Function
# edges in the graph. createApplication is the cleanest anchor.
multi_hop_tasks:
- id: express-create-application-callees
nl_query: "What express does when constructing an application"
anchor_qualified_suffix: "lib/express.js::createapplication"
traversal_pattern: callees_of
expected_neighbor_names: ["mixin", "create", "init"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does app.handle process the middleware stack"
- "Where does res.send write the response body"
- "How does createApplication initialize an app"
@@ -0,0 +1,48 @@
name: fastapi
url: https://github.com/tiangolo/fastapi
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor.
commit: 0227991a01e61bf5cdd93cc00e9e243f52b47a4a
language: python
size_category: medium
test_commits:
- sha: fa3588c38c7473aca7536b12d686102de4b0f407
description: "Fix typo for client_secret in OAuth2 form docstrings"
changed_files: 1
- sha: 0227991a01e61bf5cdd93cc00e9e243f52b47a4a
description: "Exclude spam comments from statistics in scripts/people.py"
changed_files: 1
entry_points:
- "fastapi/applications.py::FastAPI"
- "fastapi/routing.py::APIRouter"
search_queries:
- query: "FastAPI application"
expected: "fastapi/applications.py::FastAPI"
- query: "APIRoute routing"
expected: "fastapi/routing.py::APIRoute"
- query: "Depends injection"
expected: "fastapi/params.py::Depends"
multi_hop_tasks:
- id: fastapi-route-handler-callers
nl_query: "How fastapi binds a route handler to an APIRoute"
anchor_qualified_suffix: "fastapi/routing.py::apiroute.get_route_handler"
traversal_pattern: callers_of
expected_neighbor_names: ["__init__"]
k: 10
- id: fastapi-get-dependant-callers
nl_query: "Where fastapi resolves dependency declarations into a tree"
anchor_qualified_suffix: "fastapi/dependencies/utils.py::get_dependant"
traversal_pattern: callers_of
expected_neighbor_names: ["get_parameterless_sub_dependant", "solve_dependencies"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does include_router register routes on the application"
- "Where does APIRoute build its route handler"
- "How does solve_dependencies resolve Depends parameters"
+50
View File
@@ -0,0 +1,50 @@
name: flask
url: https://github.com/pallets/flask
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor.
commit: a29f88ce6f2f9843bd6fcbbfce1390a2071965d6
language: python
size_category: small
test_commits:
- sha: fbb6f0bc4c60a0bada0e03c3480d0ccf30a3c1df
description: "all teardown callbacks are called despite errors"
changed_files: 10
- sha: a29f88ce6f2f9843bd6fcbbfce1390a2071965d6
description: "document that headers must be set before streaming"
changed_files: 4
entry_points:
- "src/flask/app.py::Flask.wsgi_app"
- "src/flask/sansio/app.py::App.add_url_rule"
search_queries:
- query: "Flask wsgi"
expected: "src/flask/app.py::Flask"
- query: "AppContext globals"
expected: "src/flask/ctx.py::AppContext"
- query: "create logger"
expected: "src/flask/logging.py::create_logger"
# Multi-hop retrieval tasks (semantic_search → query_graph one-hop)
# See docs/REPRODUCING.md for the schema.
multi_hop_tasks:
- id: flask-dispatch-callers
nl_query: "Where Flask dispatches HTTP requests"
anchor_qualified_suffix: "src/flask/app.py::flask.dispatch_request"
traversal_pattern: callers_of
expected_neighbor_names: ["full_dispatch_request"]
k: 10
- id: flask-exception-callers
nl_query: "Where Flask handles uncaught exceptions"
anchor_qualified_suffix: "src/flask/app.py::flask.handle_exception"
traversal_pattern: callers_of
expected_neighbor_names: ["wsgi_app"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does dispatch_request route an incoming HTTP request"
- "Where is the AppContext pushed and popped"
- "How does create_logger configure application logging"
+51
View File
@@ -0,0 +1,51 @@
name: gin
url: https://github.com/gin-gonic/gin
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor.
commit: 5c00df8afadd06cc5be530dde00fe6d9fa4a2e4a
language: go
size_category: small
test_commits:
- sha: 052d1a79aafe3f04078a2716f8e77d4340308383
description: "feat(render): add PDF renderer and tests"
changed_files: 5
- sha: 472d086af2acd924cb4b9d7be0525f7d790f69bc
description: "fix(tree): panic in findCaseInsensitivePathRec with RedirectFixedPath"
changed_files: 2
- sha: 5c00df8afadd06cc5be530dde00fe6d9fa4a2e4a
description: "fix(render): write content length in Data.Render"
changed_files: 2
entry_points:
- "gin.go::Engine"
- "routergroup.go::RouterGroup"
search_queries:
- query: "Engine ServeHTTP"
expected: "gin.go::Engine"
- query: "Context request"
expected: "context.go::Context"
- query: "node tree"
expected: "tree.go::node"
multi_hop_tasks:
- id: gin-serve-http-callees
nl_query: "What does the gin engine do when serving an HTTP request"
anchor_qualified_suffix: "gin.go::engine.servehttp"
traversal_pattern: callees_of
expected_neighbor_names: ["reset"]
k: 10
- id: gin-context-next-callers
nl_query: "Who advances the gin middleware chain via Context.Next"
anchor_qualified_suffix: "context.go::context.next"
traversal_pattern: callers_of
expected_neighbor_names: ["handleHTTPRequest", "serveError"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does Engine.ServeHTTP route an incoming request"
- "Where does Context.Next advance the middleware chain"
- "How does the node tree match wildcard routes"
+48
View File
@@ -0,0 +1,48 @@
name: httpx
url: https://github.com/encode/httpx
# Pinned to the latest test_commit SHA so the snapshot is deterministic and
# every test_commit below is reachable as an ancestor.
commit: b55d4635701d9dc22928ee647880c76b078ba3f2
language: python
size_category: small
test_commits:
- sha: ae1b9f66238f75ced3ced5e4485408435de10768
description: "Expose FunctionAuth in __all__"
changed_files: 3
- sha: b55d4635701d9dc22928ee647880c76b078ba3f2
description: "Upgrade Python type checker mypy"
changed_files: 4
entry_points:
- "httpx/_client.py::Client"
- "httpx/_client.py::AsyncClient"
search_queries:
- query: "Client request"
expected: "httpx/_client.py::Client"
- query: "Response headers"
expected: "httpx/_models.py::Response"
- query: "BaseClient"
expected: "httpx/_client.py::BaseClient"
multi_hop_tasks:
- id: httpx-client-request-callers
nl_query: "Which HTTP verbs route through the httpx Client.request"
anchor_qualified_suffix: "httpx/_client.py::client.request"
traversal_pattern: callers_of
expected_neighbor_names: ["get", "options", "head", "post", "put", "patch"]
k: 10
- id: httpx-async-request-tests
nl_query: "Tests covering the httpx async client request method"
anchor_qualified_suffix: "httpx/_client.py::asyncclient.request"
traversal_pattern: callers_of
expected_neighbor_names: ["test_raise_for_status"]
k: 10
# Questions for the agent_baseline benchmark (pure-python grep top-k vs graph
# query). See docs/REPRODUCING.md for the methodology.
agent_questions:
- "How does Client.request send an HTTP request"
- "Where are Response headers parsed and decoded"
- "How does BaseClient build request URLs"

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