Spaces:
Runtime error
Runtime error
trakshan-mishra commited on
Commit ·
036a2db
1
Parent(s): 8356354
Deploy FastAPI & MCP server over SSE
Browse files- .gitignore +24 -0
- Dockerfile +30 -0
- README.md +263 -12
- USAGE.md +122 -0
- diffcontext-service/README.md +177 -0
- diffcontext-service/backend/main.py +435 -0
- diffcontext-service/frontend/index.html +1097 -0
- diffcontext-service/mcp/mcp_server.py +19 -0
- diffcontext/__init__.py +199 -0
- diffcontext/_warn_once.py +73 -0
- diffcontext/cache.py +115 -0
- diffcontext/cli/__init__.py +486 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +438 -0
- diffcontext/context/selector.py +102 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +301 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +737 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +160 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +341 -0
- diffcontext/mcp_server.py +401 -0
- diffcontext/models.py +122 -0
- diffcontext/parser.py +121 -0
- diffcontext/pipeline.py +168 -0
- diffcontext/resolver.py +157 -0
- diffcontext/scanner.py +46 -0
- diffcontext/symbols.py +254 -0
- pyproject.toml +38 -0
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
*.egg-info/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
*.egg
|
| 9 |
+
state.json
|
| 10 |
+
diffcontext_state.json
|
| 11 |
+
benchmark_results.json
|
| 12 |
+
.venv/
|
| 13 |
+
venv/
|
| 14 |
+
yt-dlp/
|
| 15 |
+
benchmark_repos/
|
| 16 |
+
a.txt
|
| 17 |
+
ctxsync/
|
| 18 |
+
demo-visual.html
|
| 19 |
+
node_modules/
|
| 20 |
+
sync-context.sh
|
| 21 |
+
test_fixtures.tar.gz
|
| 22 |
+
test_graph_resolution.py
|
| 23 |
+
.diffcontext_cache.db
|
| 24 |
+
benchmarks/results/
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use python:3.11-slim as a lightweight base image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Install system dependencies (git is needed for diffcontext)
|
| 5 |
+
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
| 6 |
+
|
| 7 |
+
# Set up user with UID 1000 (Hugging Face Spaces requirement)
|
| 8 |
+
RUN useradd -m -u 1000 user
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Copy configuration and project files
|
| 12 |
+
COPY pyproject.toml /app/
|
| 13 |
+
COPY README.md /app/
|
| 14 |
+
COPY diffcontext /app/diffcontext
|
| 15 |
+
COPY diffcontext-service /app/diffcontext-service
|
| 16 |
+
|
| 17 |
+
# Install package dependencies and extra tools
|
| 18 |
+
RUN pip install --no-cache-dir fastapi uvicorn python-multipart mcp sse-starlette .
|
| 19 |
+
|
| 20 |
+
# Grant write permissions for cached SQLite database and temp directories
|
| 21 |
+
RUN chown -R user:user /app
|
| 22 |
+
|
| 23 |
+
# Switch to the non-root user
|
| 24 |
+
USER user
|
| 25 |
+
|
| 26 |
+
# Expose port 7860 (Hugging Face Spaces requirement)
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
# Start the FastAPI server using Uvicorn
|
| 30 |
+
CMD ["uvicorn", "diffcontext-service.backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,12 +1,263 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DiffContext
|
| 2 |
+
|
| 3 |
+
Static-analysis-powered repository context compiler for LLMs.
|
| 4 |
+
|
| 5 |
+
**Git diff + AST parsing + dependency graph + blast radius + impact scoring → optimized context package**
|
| 6 |
+
|
| 7 |
+
Instead of dumping an entire codebase (or doing keyword/vector search), DiffContext:
|
| 8 |
+
|
| 9 |
+
1. **Parses** Python files via AST → extracts every function, method, class
|
| 10 |
+
2. **Builds a dependency graph** → calls, imports, inheritance, attribute ownership, decorators
|
| 11 |
+
3. **Detects changes** → via git diff (including uncommitted edits) or snapshot comparison
|
| 12 |
+
4. **Computes blast radius** → everything transitively affected by the change
|
| 13 |
+
5. **Scores impact** → prioritizes symbols by structural importance
|
| 14 |
+
6. **Selects relevant code** → respects a token budget
|
| 15 |
+
7. **Compiles context** → structured output ready to paste into an LLM
|
| 16 |
+
|
| 17 |
+
On a real ~1,100-symbol production repo, this reliably produces **95–99% token
|
| 18 |
+
reduction** versus pasting the whole codebase, while keeping the functions that
|
| 19 |
+
are actually call-graph-connected to your change.
|
| 20 |
+
|
| 21 |
+
## Quick Start
|
| 22 |
+
|
| 23 |
+
### Step 1: Install
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
git clone https://github.com/trakshan-mishra/Diffcontext.git
|
| 27 |
+
cd Diffcontext
|
| 28 |
+
pip install -e .
|
| 29 |
+
diffcontext --help
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Step 2: Index a repository
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
diffcontext index /path/to/any/python/project
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
If any file fails to parse (a real `SyntaxError`), it's reported explicitly —
|
| 39 |
+
not silently skipped:
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
Skipping broken_file.py due to SyntaxError: unmatched ')' (line 237)
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Step 3: Check impact of a function you're working on
|
| 46 |
+
|
| 47 |
+
This is the recommended default mode while actively editing — it doesn't
|
| 48 |
+
depend on git at all, so it works on uncommitted or untracked files too:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
diffcontext blast --changed ./src/auth.py:validate_jwt
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Shows who calls it, what it calls, and the full transitive blast radius.
|
| 55 |
+
|
| 56 |
+
### Step 4: Auto-detect changes from git (optional)
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
diffcontext diff
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Compares your working tree (including uncommitted edits to **tracked** files)
|
| 63 |
+
against `HEAD~1` by default. Note: this only sees changes to files git
|
| 64 |
+
already knows about — a brand-new untracked file is invisible to any
|
| 65 |
+
git-diff-based tool until you `git add -N <file>` or commit it. Use
|
| 66 |
+
`--committed-only` to compare two commits and ignore working-tree changes.
|
| 67 |
+
|
| 68 |
+
### Step 5: Build LLM-ready context
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
# From a specific function:
|
| 72 |
+
diffcontext compile --changed ./src/auth.py:validate_jwt
|
| 73 |
+
|
| 74 |
+
# From git diff:
|
| 75 |
+
diffcontext compile --ref HEAD~1
|
| 76 |
+
|
| 77 |
+
# With a token budget:
|
| 78 |
+
diffcontext compile --changed ./src/auth.py:validate_jwt --max-tokens 8000
|
| 79 |
+
|
| 80 |
+
# JSON output (for piping into another tool):
|
| 81 |
+
diffcontext compile --changed ./src/auth.py:validate_jwt --json
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Then paste the output into Claude / ChatGPT / your LLM of choice, **with a
|
| 85 |
+
specific question** — not just the raw context. E.g.:
|
| 86 |
+
|
| 87 |
+
> "Is the dynamic SQL construction in `update_run` safe, given how `kwargs`
|
| 88 |
+
> is validated against `_UPDATABLE_RUN_COLUMNS`?"
|
| 89 |
+
|
| 90 |
+
### Step 6: Use as a library
|
| 91 |
+
|
| 92 |
+
```python
|
| 93 |
+
from diffcontext.pipeline import index_repository, analyze_impact, compile
|
| 94 |
+
|
| 95 |
+
idx = index_repository("/path/to/repo")
|
| 96 |
+
impact = analyze_impact(idx, ["./src/auth.py:validate_jwt"])
|
| 97 |
+
ctx = compile(idx, impact, max_tokens=10000)
|
| 98 |
+
|
| 99 |
+
print(ctx.text) # the context to send to the LLM
|
| 100 |
+
print(f"{ctx.token_estimate:,} / {ctx.total_repo_tokens:,} tokens")
|
| 101 |
+
print(f"{ctx.reduction_pct:.1f}% reduction")
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
See `USAGE.md` for the full day-to-day workflow, including shell aliases.
|
| 105 |
+
|
| 106 |
+
### Step 7: Cloud sync (CtxSync) (yet to be impemented)
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
diffcontext sync
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
One command. Compiles blast radius and pushes to your CtxSync cloud endpoint.
|
| 113 |
+
Credentials are read from `~/.ctxsync`, env vars, or `--url`/`--key` flags.
|
| 114 |
+
|
| 115 |
+
### Step 8: Use as an MCP Server (Claude Desktop / Cursor)
|
| 116 |
+
|
| 117 |
+
DiffContext includes a built-in **Model Context Protocol (MCP)** server, allowing AI assistants to natively query your codebase's blast radius without manual copy-pasting.
|
| 118 |
+
|
| 119 |
+
**1. Install with MCP support:**
|
| 120 |
+
```bash
|
| 121 |
+
pip install -e .[mcp]
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
**2. Configure your AI client:**
|
| 125 |
+
For Claude Desktop (`claude_desktop_config.json`) or Cursor:
|
| 126 |
+
```json
|
| 127 |
+
{
|
| 128 |
+
"mcpServers": {
|
| 129 |
+
"diffcontext": {
|
| 130 |
+
"command": "diffcontext-mcp"
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
Now you can just ask your AI: *"What is the blast radius of validate_jwt in the diffcontext repo?"* and it will autonomously use DiffContext to find the precise context!
|
| 137 |
+
|
| 138 |
+
## What the resolver actually handles
|
| 139 |
+
|
| 140 |
+
Confirmed via an automated test suite (`tests/`) that builds small repos on
|
| 141 |
+
the fly and asserts on real resolved call-graph edges — not just "it ran
|
| 142 |
+
without crashing":
|
| 143 |
+
|
| 144 |
+
- Function and method calls, including multi-hop attribute chains (`self.a.b.method()`)
|
| 145 |
+
- Multiple inheritance / MRO, including cross-file base classes
|
| 146 |
+
- Circular imports
|
| 147 |
+
- Local variables instantiating a class inside a **free function** (not just
|
| 148 |
+
`self.x = ...` inside a method) — e.g. `h = Handler(); h.process()`
|
| 149 |
+
- Annotated parameters as call receivers (`def run(h: Handler): h.process()`)
|
| 150 |
+
- Import aliasing (`from .user import Handler as UserHandler`), including
|
| 151 |
+
disambiguating two same-named classes in different files
|
| 152 |
+
- Bare `import x` where `x` lives in a sibling directory rather than the
|
| 153 |
+
repo root (common in script-style codebases)
|
| 154 |
+
- **Decorators**: a decorated function's graph entry now correctly includes
|
| 155 |
+
calls made by its decorator's wrapper — e.g. `@require_auth` wrapping
|
| 156 |
+
`get_profile` correctly shows `get_profile` depending on whatever
|
| 157 |
+
`require_auth`'s wrapper calls (like a session check), not falsely
|
| 158 |
+
attributed to `require_auth` itself
|
| 159 |
+
- Higher-order stdlib functions: `map(fn, items)`, `sorted(x, key=fn)`,
|
| 160 |
+
`filter(fn, items)` — a function passed *by reference* to these is
|
| 161 |
+
tracked as an implicit call
|
| 162 |
+
|
| 163 |
+
## Known limitations (genuinely unfixable by static analysis, not bugs)
|
| 164 |
+
|
| 165 |
+
- **Dynamic dispatch / `getattr()`-based routing**: `getattr(obj, name)()`
|
| 166 |
+
can't be resolved statically when `name` is computed at runtime (from
|
| 167 |
+
config, user input, etc.) — no static analysis tool can do this in
|
| 168 |
+
general, including IDEs.
|
| 169 |
+
- **Cross-file changes related by theme, not by function calls**: e.g. "remove
|
| 170 |
+
a dependency," touching 3 files for one conceptual reason with no direct
|
| 171 |
+
call-graph edges between them. Blast radius is a call-graph tool; it
|
| 172 |
+
cannot detect relatedness that isn't expressed as a function call.
|
| 173 |
+
- **User-defined higher-order functions**: only the common stdlib cases
|
| 174 |
+
(`map`, `filter`, `sorted`/`max`/`min` with `key=`) are recognized.
|
| 175 |
+
A custom function like `def apply_twice(fn, value): return fn(fn(value))`
|
| 176 |
+
is not — this would need cross-function signature analysis to know which
|
| 177 |
+
parameter is expected to be callable.
|
| 178 |
+
|
| 179 |
+
Run `grep -rn "function_name(" --include="*.py" .` to spot-check anything
|
| 180 |
+
important before fully trusting "no callers found."
|
| 181 |
+
|
| 182 |
+
## Architecture
|
| 183 |
+
|
| 184 |
+
```
|
| 185 |
+
diffcontext/
|
| 186 |
+
├── __init__.py # Package entry, high-level API
|
| 187 |
+
├── models.py # Data classes (Symbol, RepositoryIndex, etc.)
|
| 188 |
+
├── scanner.py # File discovery with exclusion list
|
| 189 |
+
├── parser.py # AST symbol extraction
|
| 190 |
+
├── resolver.py # Import -> filesystem path resolution
|
| 191 |
+
├── symbols.py # Attribute / local-var type tracking
|
| 192 |
+
├── graph_builder.py # Core: dependency graph construction
|
| 193 |
+
├── pipeline.py # Pipeline orchestrator
|
| 194 |
+
├── _warn_once.py # De-duplicated warnings (broken files, encoding, unknown symbols)
|
| 195 |
+
├── diff/
|
| 196 |
+
│ ├── git_diff.py # Git diff -> changed symbols
|
| 197 |
+
│ └── state_manager.py # Snapshot-based change detection
|
| 198 |
+
├── impact/
|
| 199 |
+
│ ├── blast_radius.py # Reverse graph traversal
|
| 200 |
+
│ ├── scoring.py # Impact scoring
|
| 201 |
+
│ ├── traversal.py # Forward dependency expansion
|
| 202 |
+
│ └── visualizer.py # Terminal tree rendering
|
| 203 |
+
├── context/
|
| 204 |
+
│ ├── selector.py # Token-budget-aware selection
|
| 205 |
+
│ └── compiler.py # Structured output formatting
|
| 206 |
+
└── cli/
|
| 207 |
+
└── __init__.py # CLI: index, impact, diff, compile, blast, sync
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
## Try it (30 seconds)
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
bash demos.sh # interactive — pick from 5 famous repos or use your own
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
## How symbol IDs work
|
| 217 |
+
|
| 218 |
+
Every function gets a unique ID: `./relative/path.py:ClassName.method_name`
|
| 219 |
+
|
| 220 |
+
```
|
| 221 |
+
./src/auth.py:validate_jwt
|
| 222 |
+
./src/flask/app.py:Flask.route
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
**No parentheses, no arguments** — `validate_jwt`, never `validate_jwt(token)`.
|
| 226 |
+
|
| 227 |
+
## Testing
|
| 228 |
+
|
| 229 |
+
```bash
|
| 230 |
+
python3 -m pytest tests/ -v
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
17 tests, all self-contained (no external clone needed), covering both
|
| 234 |
+
correct resolution and the documented limitations above — including tests
|
| 235 |
+
that were written to fail loudly if a future change silently regresses
|
| 236 |
+
something that's currently working.
|
| 237 |
+
|
| 238 |
+
## Status
|
| 239 |
+
|
| 240 |
+
This is a personal project, built and iteratively debugged against real
|
| 241 |
+
production codebases (openai/whisper, pallets/click, pallets/flask). Several
|
| 242 |
+
real resolver bugs were found and fixed through dogfooding — decorators,
|
| 243 |
+
higher-order functions, and sibling-directory imports all required fixes
|
| 244 |
+
that were only visible on real code, not toy examples. Treat blast-radius
|
| 245 |
+
output as a strong starting point, not a guarantee, and spot-check with
|
| 246 |
+
`grep` on anything load-bearing.
|
| 247 |
+
|
| 248 |
+
## License
|
| 249 |
+
|
| 250 |
+
MIT
|
| 251 |
+
|
| 252 |
+
## Benchmarks & Performance (Baseline)
|
| 253 |
+
|
| 254 |
+
We adhere strictly to **Measure Before Optimizing**. Our baseline metrics demonstrate that traversal and compilation are nearly instantaneous, while parsing and graph construction are the primary bottlenecks. This data drives our roadmap for v0.4 (Incremental Caching).
|
| 255 |
+
|
| 256 |
+
| Repo | Files | Symbols | Parse (ms) | Graph Build (ms) | Traversal (ms) | Compile (ms) | Token Reduction |
|
| 257 |
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| 258 |
+
| **Flask** | 20 | 354 | 488 | 930 | 0.1 | 0.0 | 98.15% |
|
| 259 |
+
| **Click** | 19 | 506 | 1273 | 1824 | 0.2 | 0.0 | 96.51% |
|
| 260 |
+
| **HTTPX** | 21 | 434 | 665 | 1147 | 0.1 | 0.0 | 96.70% |
|
| 261 |
+
| **Pydantic** | 90 | 1826 | 4519 | 7914 | 0.6 | 0.0 | 98.43% |
|
| 262 |
+
|
| 263 |
+
*Note: Peak memory for Pydantic (the largest repo) was only 66.4 MB, validating that memory is not currently a bottleneck.*
|
USAGE.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DiffContext — Day-to-Day Usage
|
| 2 |
+
|
| 3 |
+
This captures the actual workflow that works, based on real use against a
|
| 4 |
+
1000+ symbol production repo (not just toy examples).
|
| 5 |
+
|
| 6 |
+
## Setup (once per shell session, or add to ~/.bashrc / ~/.zshrc)
|
| 7 |
+
|
| 8 |
+
```bash
|
| 9 |
+
alias dcb='diffcontext blast --changed'
|
| 10 |
+
alias dcc='diffcontext compile --changed'
|
| 11 |
+
alias dci='diffcontext index'
|
| 12 |
+
alias dcs='diffcontext sync'
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
To make these permanent, append the three lines above to `~/.bashrc` (or
|
| 16 |
+
`~/.zshrc` if you use zsh), then `source ~/.bashrc`.
|
| 17 |
+
|
| 18 |
+
## The core workflow
|
| 19 |
+
|
| 20 |
+
### 1. While actively editing — don't rely on git diff detection
|
| 21 |
+
|
| 22 |
+
Git-diff-based commands (`diffcontext diff`, `diffcontext blast` with no
|
| 23 |
+
`--changed`) only see **tracked** changes that are committed or staged.
|
| 24 |
+
An edit to an **untracked** (brand new) file is invisible to them — not a
|
| 25 |
+
bug, that's just what `git diff` means.
|
| 26 |
+
|
| 27 |
+
For active editing, skip git entirely and name the symbol directly:
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
dcb ./path/to/file.py:function_name
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
This works immediately, no commit, no `git add`, no staging.
|
| 34 |
+
|
| 35 |
+
### 2. Symbol IDs — exact format
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
./relative/path.py:function_name
|
| 39 |
+
./relative/path.py:ClassName.method_name
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Rules:
|
| 43 |
+
- Path is relative to the repo root you indexed, always starts with `./`
|
| 44 |
+
- **No parentheses, no arguments, no type hints** — `update_run`, never
|
| 45 |
+
`update_run(run_id: int, **kwargs)`. Bash will choke on unquoted `()`
|
| 46 |
+
with a `syntax error near unexpected token` — that's bash, not
|
| 47 |
+
diffcontext, complaining.
|
| 48 |
+
- Find real names fast:
|
| 49 |
+
```bash
|
| 50 |
+
grep -n "^def \|^ def " path/to/file.py
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### 3. Before trusting "no callers found" — spot-check with grep
|
| 54 |
+
|
| 55 |
+
This caught 3 real bugs during testing. Make it a habit, not a one-off:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
grep -rn "function_name(" --include="*.py" .
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
If grep finds callers diffcontext's blast radius missed, that's a real
|
| 62 |
+
gap worth knowing about (and worth reporting) — don't assume the blast
|
| 63 |
+
radius is complete just because it ran without error.
|
| 64 |
+
|
| 65 |
+
### 4. Getting LLM-ready context
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
dcc ./path/to/file.py:function_name --max-tokens 4000
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Paste the output into Claude/ChatGPT **with a specific question**, not
|
| 72 |
+
just the raw context:
|
| 73 |
+
|
| 74 |
+
- Bad: "review this"
|
| 75 |
+
- Good: "I'm about to add a new field to `update_run` — given these 5
|
| 76 |
+
callers, what do I need to check?"
|
| 77 |
+
- Good: "Is the dynamic SQL construction in `update_run` safe given how
|
| 78 |
+
`kwargs` is validated against `_UPDATABLE_RUN_COLUMNS`?"
|
| 79 |
+
|
| 80 |
+
### 5. Tuning context size
|
| 81 |
+
|
| 82 |
+
- `--depth N` (default 2-3): how many hops of callers/callees to pull in.
|
| 83 |
+
Use `--depth 1` for a tight, single-function check. Use `--depth 4+`
|
| 84 |
+
for "how does this fit into the bigger picture."
|
| 85 |
+
- `--max-tokens N`: hard cap. Lower it to force tighter selection (only
|
| 86 |
+
the highest-scored symbols survive); raise it if you have a
|
| 87 |
+
large-context model and want more surrounding code.
|
| 88 |
+
|
| 89 |
+
### 6. Checking what changed (only works for committed/staged files)
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
diffcontext diff # working tree vs HEAD~1, tracked files only
|
| 93 |
+
diffcontext diff --committed-only # two commits only, ignores uncommitted edits
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
If a file shows as broken (`Skipping X due to SyntaxError`), diffcontext
|
| 97 |
+
will still report a best-effort diff using the prior committed version —
|
| 98 |
+
look for the `⚠ N file(s) failed to parse` block in the output.
|
| 99 |
+
|
| 100 |
+
## Known limitations (don't trust blast radius blindly here)
|
| 101 |
+
|
| 102 |
+
- **Dynamic dispatch / `getattr()`-based routing**: common in CLI
|
| 103 |
+
argument dispatch and plugin systems — invisible to static analysis.
|
| 104 |
+
- **Cross-file changes related by theme, not by function calls**: e.g.
|
| 105 |
+
"remove a dependency" touching 3 unrelated-by-call-graph files for one
|
| 106 |
+
conceptual reason. Blast radius won't connect these.
|
| 107 |
+
- **User-defined higher-order functions**: only the common stdlib cases
|
| 108 |
+
(`map`, `filter`, `sorted`/`max`/`min` with `key=`) are recognized.
|
| 109 |
+
A custom `def apply_twice(fn, value)` is not tracked.
|
| 110 |
+
|
| 111 |
+
When in doubt: grep first, trust second.
|
| 112 |
+
|
| 113 |
+
## Cloud sync (CtxSync)
|
| 114 |
+
|
| 115 |
+
Push your blast radius to the cloud in one command:
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
diffcontext sync
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Reads credentials from `~/.ctxsync` (or `--url`/`--key` flags, or env vars).
|
| 122 |
+
The system prompt URL can be pasted into any AI tool for live context awareness.
|
diffcontext-service/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DiffContext Web Service — Complete Setup Guide
|
| 2 |
+
|
| 3 |
+
## What you just built
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
diffcontext-service/
|
| 7 |
+
├── backend/
|
| 8 |
+
│ └── main.py ← FastAPI REST API server
|
| 9 |
+
├── frontend/
|
| 10 |
+
│ └── index.html ← Web UI (open in browser, no server needed)
|
| 11 |
+
├── mcp/
|
| 12 |
+
│ └── mcp_server.py ← MCP server for Claude Desktop / Cursor
|
| 13 |
+
└── README.md ← This file
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## Option A — Web UI (easiest, for beginners)
|
| 19 |
+
|
| 20 |
+
### Step 1: Install dependencies
|
| 21 |
+
```bash
|
| 22 |
+
pip install fastapi uvicorn python-multipart aiofiles
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
### Step 2: Start the API server
|
| 26 |
+
```bash
|
| 27 |
+
cd /path/to/Diffcontext
|
| 28 |
+
uvicorn diffcontext-service.backend.main:app --reload --port 8000
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### Step 3: Open the web UI
|
| 32 |
+
Just open `diffcontext-service/frontend/index.html` in your browser.
|
| 33 |
+
(Double-click it, or drag it into Chrome/Firefox.)
|
| 34 |
+
|
| 35 |
+
### Step 4: Use it
|
| 36 |
+
1. Zip your Python project folder
|
| 37 |
+
2. Upload the zip on the "Upload Project" tab
|
| 38 |
+
3. Pick a function from the list
|
| 39 |
+
4. Click "Analyse Blast Radius"
|
| 40 |
+
5. See what breaks!
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Option B — REST API (for developers)
|
| 45 |
+
|
| 46 |
+
Once the server is running at `http://localhost:8000`:
|
| 47 |
+
|
| 48 |
+
```python
|
| 49 |
+
import requests
|
| 50 |
+
|
| 51 |
+
# 1. Upload your project
|
| 52 |
+
with open("myproject.zip", "rb") as f:
|
| 53 |
+
r = requests.post("http://localhost:8000/upload", files={"file": f})
|
| 54 |
+
repo_id = r.json()["repo_id"]
|
| 55 |
+
|
| 56 |
+
# 2. List all functions
|
| 57 |
+
r = requests.get(f"http://localhost:8000/symbols?repo_id={repo_id}")
|
| 58 |
+
print(r.json()["symbols"][:5])
|
| 59 |
+
|
| 60 |
+
# 3. Get blast radius
|
| 61 |
+
r = requests.post("http://localhost:8000/blast", json={
|
| 62 |
+
"repo_id": repo_id,
|
| 63 |
+
"symbol": "./src/auth.py:validate_jwt"
|
| 64 |
+
})
|
| 65 |
+
print(r.json())
|
| 66 |
+
|
| 67 |
+
# 4. Get LLM context to paste into Claude
|
| 68 |
+
r = requests.post("http://localhost:8000/compile", json={
|
| 69 |
+
"repo_id": repo_id,
|
| 70 |
+
"symbol": "./src/auth.py:validate_jwt",
|
| 71 |
+
"max_tokens": 8000
|
| 72 |
+
})
|
| 73 |
+
print(r.json()["context_text"])
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
### Or use curl:
|
| 77 |
+
```bash
|
| 78 |
+
# Upload
|
| 79 |
+
curl -X POST http://localhost:8000/upload -F "file=@myproject.zip"
|
| 80 |
+
|
| 81 |
+
# Blast radius
|
| 82 |
+
curl -X POST http://localhost:8000/blast \
|
| 83 |
+
-H "Content-Type: application/json" \
|
| 84 |
+
-d '{"repo_id": "abc123", "symbol": "./main.py:my_function"}'
|
| 85 |
+
|
| 86 |
+
# Paste code directly (no upload)
|
| 87 |
+
curl -X POST http://localhost:8000/inline \
|
| 88 |
+
-H "Content-Type: application/json" \
|
| 89 |
+
-d '{
|
| 90 |
+
"files": {"main.py": "def greet(name):\n msg = build_message(name)\n print(msg)\n\ndef build_message(name):\n return f\"Hello {name}\""},
|
| 91 |
+
"symbol": "./main.py:greet"
|
| 92 |
+
}'
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Interactive docs: http://localhost:8000/docs
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Option C — MCP Server (Claude Desktop / Cursor)
|
| 100 |
+
|
| 101 |
+
This lets Claude Desktop call DiffContext automatically — no copy-paste needed.
|
| 102 |
+
|
| 103 |
+
### Step 1: Install MCP
|
| 104 |
+
```bash
|
| 105 |
+
pip install mcp
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Step 2: Find the full path to mcp_server.py
|
| 109 |
+
```bash
|
| 110 |
+
# Mac/Linux
|
| 111 |
+
realpath diffcontext-service/mcp/mcp_server.py
|
| 112 |
+
|
| 113 |
+
# Windows
|
| 114 |
+
cd diffcontext-service\mcp && echo %CD%\mcp_server.py
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
### Step 3: Add to Claude Desktop config
|
| 118 |
+
|
| 119 |
+
**Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
| 120 |
+
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
| 121 |
+
|
| 122 |
+
```json
|
| 123 |
+
{
|
| 124 |
+
"mcpServers": {
|
| 125 |
+
"diffcontext": {
|
| 126 |
+
"command": "python",
|
| 127 |
+
"args": ["/FULL/PATH/TO/diffcontext-service/mcp/mcp_server.py"]
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### Step 4: Restart Claude Desktop
|
| 134 |
+
|
| 135 |
+
### Step 5: Use it in Claude Desktop
|
| 136 |
+
```
|
| 137 |
+
"List all functions in /Users/me/myproject"
|
| 138 |
+
|
| 139 |
+
"What is the blast radius of ./src/auth.py:validate_jwt
|
| 140 |
+
in /Users/me/myproject?"
|
| 141 |
+
|
| 142 |
+
"I'm about to change create_user in /Users/me/myproject.
|
| 143 |
+
Compile context so I can understand the impact."
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
Claude will call DiffContext automatically and show you the results.
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
## Troubleshooting
|
| 151 |
+
|
| 152 |
+
| Problem | Fix |
|
| 153 |
+
|---------|-----|
|
| 154 |
+
| `Connection refused` on localhost:8000 | Run the uvicorn command first |
|
| 155 |
+
| `No Python files found` | Make sure your zip contains .py files |
|
| 156 |
+
| Symbol not found | Use format `./filename.py:function_name` (no parentheses) |
|
| 157 |
+
| MCP not working | Check the full path in claude_desktop_config.json |
|
| 158 |
+
| `ModuleNotFoundError: mcp` | Run `pip install mcp` |
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## API response format
|
| 163 |
+
|
| 164 |
+
```json
|
| 165 |
+
{
|
| 166 |
+
"symbol": "./service.py:onboard_user",
|
| 167 |
+
"direct_callers": ["./api.py:handle_signup"],
|
| 168 |
+
"direct_callees": ["./service.py:create_user", "./service.py:create_order"],
|
| 169 |
+
"blast_radius_count": 5,
|
| 170 |
+
"blast_radius_by_file": {
|
| 171 |
+
"./api.py": ["handle_signup", "process_request"],
|
| 172 |
+
"./tests/test_service.py": ["test_onboard"]
|
| 173 |
+
},
|
| 174 |
+
"all_affected": ["./api.py:handle_signup", ...],
|
| 175 |
+
"total_symbols_in_repo": 47
|
| 176 |
+
}
|
| 177 |
+
```
|
diffcontext-service/backend/main.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DiffContext Web Service — FastAPI Backend
|
| 3 |
+
Exposes DiffContext as a REST API so anyone can use it online.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import ast
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
import zipfile
|
| 11 |
+
import shutil
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Dict, List, Optional
|
| 14 |
+
|
| 15 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from fastapi.responses import JSONResponse
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="DiffContext API",
|
| 23 |
+
description="Analyse your Python code's blast radius — which functions break when you change one?",
|
| 24 |
+
version="1.0.0",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
app.add_middleware(
|
| 28 |
+
CORSMiddleware,
|
| 29 |
+
allow_origins=["*"],
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# ── In-memory session store (repo_id -> temp dir path) ──────────────────────
|
| 35 |
+
SESSIONS: Dict[str, str] = {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ── Pydantic models ──────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
class BlastRequest(BaseModel):
|
| 41 |
+
repo_id: str
|
| 42 |
+
symbol: str # e.g. "./service.py:onboard_user"
|
| 43 |
+
|
| 44 |
+
class CompileRequest(BaseModel):
|
| 45 |
+
repo_id: str
|
| 46 |
+
symbol: str
|
| 47 |
+
max_tokens: int = 8000
|
| 48 |
+
|
| 49 |
+
class InlineRequest(BaseModel):
|
| 50 |
+
"""Lets beginners paste code directly without uploading a zip."""
|
| 51 |
+
files: Dict[str, str] # filename -> source code
|
| 52 |
+
symbol: str # which function to analyze
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ── Helpers ─────────────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
def _run_diffcontext(repo_path: str, symbol: str):
|
| 58 |
+
"""
|
| 59 |
+
Run the DiffContext pipeline on repo_path for one symbol.
|
| 60 |
+
Returns a plain dict so it's JSON-serialisable.
|
| 61 |
+
Falls back to a lightweight AST-only analysis if DiffContext
|
| 62 |
+
isn't installed (so the service still works standalone).
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
# Try real DiffContext first
|
| 66 |
+
import sys
|
| 67 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 68 |
+
from diffcontext.pipeline import index_repository, analyze_impact
|
| 69 |
+
|
| 70 |
+
idx = index_repository(repo_path)
|
| 71 |
+
if symbol not in idx.symbols and symbol not in idx.graph:
|
| 72 |
+
# Suggest similar ones
|
| 73 |
+
import difflib
|
| 74 |
+
suggestions = difflib.get_close_matches(symbol, list(idx.symbols.keys()), n=3, cutoff=0.4)
|
| 75 |
+
return {
|
| 76 |
+
"error": f"Symbol '{symbol}' not found.",
|
| 77 |
+
"suggestions": suggestions,
|
| 78 |
+
"all_symbols": list(idx.symbols.keys()),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
impact = analyze_impact(idx, [symbol])
|
| 82 |
+
|
| 83 |
+
# Build callers / callees for response
|
| 84 |
+
reverse: Dict[str, List[str]] = {}
|
| 85 |
+
for caller, callees in idx.graph.items():
|
| 86 |
+
for callee in callees:
|
| 87 |
+
reverse.setdefault(callee, []).append(caller)
|
| 88 |
+
|
| 89 |
+
direct_callers = reverse.get(symbol, [])
|
| 90 |
+
direct_callees = idx.graph.get(symbol, [])
|
| 91 |
+
|
| 92 |
+
# Group blast radius by file
|
| 93 |
+
by_file: Dict[str, List[str]] = {}
|
| 94 |
+
for sym in impact.blast_radius:
|
| 95 |
+
file_part = sym.split(":")[0]
|
| 96 |
+
by_file.setdefault(file_part, []).append(sym.split(":")[-1])
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"symbol": symbol,
|
| 100 |
+
"direct_callers": direct_callers,
|
| 101 |
+
"direct_callees": direct_callees,
|
| 102 |
+
"blast_radius_count": len(impact.blast_radius),
|
| 103 |
+
"blast_radius_by_file": by_file,
|
| 104 |
+
"all_affected": impact.blast_radius[:50],
|
| 105 |
+
"scores": {k: round(v, 1) for k, v in list(impact.scores.items())[:30]},
|
| 106 |
+
"total_symbols_in_repo": len(idx.symbols),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
except ImportError:
|
| 110 |
+
# Fallback: pure AST analysis without DiffContext
|
| 111 |
+
return _ast_fallback(repo_path, symbol)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _ast_fallback(repo_path: str, symbol: str) -> dict:
|
| 115 |
+
"""
|
| 116 |
+
Lightweight fallback: parse all .py files with stdlib ast,
|
| 117 |
+
build a simple call graph, return blast radius.
|
| 118 |
+
Works even without DiffContext installed.
|
| 119 |
+
"""
|
| 120 |
+
file_part, func_name = (symbol.split(":", 1) + [""])[:2]
|
| 121 |
+
if not func_name:
|
| 122 |
+
return {"error": "Invalid symbol format. Use ./file.py:function_name"}
|
| 123 |
+
|
| 124 |
+
# Collect all functions from all .py files
|
| 125 |
+
all_functions: Dict[str, str] = {} # id -> source snippet
|
| 126 |
+
call_graph: Dict[str, List[str]] = {}
|
| 127 |
+
|
| 128 |
+
for py_file in Path(repo_path).rglob("*.py"):
|
| 129 |
+
rel = "./" + str(py_file.relative_to(repo_path))
|
| 130 |
+
try:
|
| 131 |
+
source = py_file.read_text(errors="ignore")
|
| 132 |
+
tree = ast.parse(source)
|
| 133 |
+
except SyntaxError:
|
| 134 |
+
continue
|
| 135 |
+
|
| 136 |
+
for node in ast.walk(tree):
|
| 137 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 138 |
+
fid = f"{rel}:{node.name}"
|
| 139 |
+
all_functions[fid] = ast.get_source_segment(source, node) or ""
|
| 140 |
+
# Collect calls made inside this function
|
| 141 |
+
calls = []
|
| 142 |
+
for child in ast.walk(node):
|
| 143 |
+
if isinstance(child, ast.Call):
|
| 144 |
+
if isinstance(child.func, ast.Name):
|
| 145 |
+
calls.append(child.func.id)
|
| 146 |
+
elif isinstance(child.func, ast.Attribute):
|
| 147 |
+
calls.append(child.func.attr)
|
| 148 |
+
call_graph[fid] = calls
|
| 149 |
+
|
| 150 |
+
# Resolve calls to full IDs
|
| 151 |
+
name_to_ids: Dict[str, List[str]] = {}
|
| 152 |
+
for fid in all_functions:
|
| 153 |
+
name = fid.split(":")[-1]
|
| 154 |
+
name_to_ids.setdefault(name, []).append(fid)
|
| 155 |
+
|
| 156 |
+
resolved_graph: Dict[str, List[str]] = {}
|
| 157 |
+
for fid, raw_calls in call_graph.items():
|
| 158 |
+
resolved = []
|
| 159 |
+
for call in raw_calls:
|
| 160 |
+
for candidate in name_to_ids.get(call, []):
|
| 161 |
+
if candidate != fid:
|
| 162 |
+
resolved.append(candidate)
|
| 163 |
+
resolved_graph[fid] = resolved
|
| 164 |
+
|
| 165 |
+
# Build reverse graph
|
| 166 |
+
reverse: Dict[str, List[str]] = {}
|
| 167 |
+
for caller, callees in resolved_graph.items():
|
| 168 |
+
for callee in callees:
|
| 169 |
+
reverse.setdefault(callee, []).append(caller)
|
| 170 |
+
|
| 171 |
+
if symbol not in all_functions:
|
| 172 |
+
import difflib
|
| 173 |
+
suggestions = difflib.get_close_matches(symbol, list(all_functions.keys()), n=3, cutoff=0.4)
|
| 174 |
+
return {
|
| 175 |
+
"error": f"Symbol '{symbol}' not found.",
|
| 176 |
+
"suggestions": suggestions,
|
| 177 |
+
"all_symbols": list(all_functions.keys()),
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# BFS blast radius
|
| 181 |
+
visited = {symbol}
|
| 182 |
+
blast = []
|
| 183 |
+
frontier = [symbol]
|
| 184 |
+
while frontier:
|
| 185 |
+
next_f = []
|
| 186 |
+
for node in frontier:
|
| 187 |
+
for caller in reverse.get(node, []):
|
| 188 |
+
if caller not in visited:
|
| 189 |
+
visited.add(caller)
|
| 190 |
+
blast.append(caller)
|
| 191 |
+
next_f.append(caller)
|
| 192 |
+
frontier = next_f
|
| 193 |
+
|
| 194 |
+
by_file: Dict[str, List[str]] = {}
|
| 195 |
+
for sym in blast:
|
| 196 |
+
fp = sym.split(":")[0]
|
| 197 |
+
by_file.setdefault(fp, []).append(sym.split(":")[-1])
|
| 198 |
+
|
| 199 |
+
return {
|
| 200 |
+
"symbol": symbol,
|
| 201 |
+
"direct_callers": reverse.get(symbol, []),
|
| 202 |
+
"direct_callees": resolved_graph.get(symbol, []),
|
| 203 |
+
"blast_radius_count": len(blast),
|
| 204 |
+
"blast_radius_by_file": by_file,
|
| 205 |
+
"all_affected": blast[:50],
|
| 206 |
+
"scores": {},
|
| 207 |
+
"total_symbols_in_repo": len(all_functions),
|
| 208 |
+
"note": "Lightweight AST mode (install DiffContext for full analysis)",
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _list_symbols(repo_path: str) -> List[str]:
|
| 213 |
+
"""Return all function symbol IDs in the repo."""
|
| 214 |
+
symbols = []
|
| 215 |
+
for py_file in Path(repo_path).rglob("*.py"):
|
| 216 |
+
rel = "./" + str(py_file.relative_to(repo_path))
|
| 217 |
+
try:
|
| 218 |
+
source = py_file.read_text(errors="ignore")
|
| 219 |
+
tree = ast.parse(source)
|
| 220 |
+
except SyntaxError:
|
| 221 |
+
continue
|
| 222 |
+
for node in ast.walk(tree):
|
| 223 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 224 |
+
symbols.append(f"{rel}:{node.name}")
|
| 225 |
+
return sorted(symbols)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ── Routes ───────────────────────────────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
@app.get("/")
|
| 231 |
+
def root():
|
| 232 |
+
return {
|
| 233 |
+
"service": "DiffContext API",
|
| 234 |
+
"docs": "/docs",
|
| 235 |
+
"endpoints": [
|
| 236 |
+
"POST /upload — upload a .zip of your project",
|
| 237 |
+
"POST /inline — paste code directly (no upload needed)",
|
| 238 |
+
"POST /blast — get blast radius for a function",
|
| 239 |
+
"GET /symbols — list all functions in uploaded repo",
|
| 240 |
+
"POST /compile — get LLM-ready context string",
|
| 241 |
+
"GET /health — service health check",
|
| 242 |
+
],
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@app.get("/health")
|
| 247 |
+
def health():
|
| 248 |
+
return {"status": "ok"}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@app.post("/upload")
|
| 252 |
+
async def upload_repo(file: UploadFile = File(...)):
|
| 253 |
+
"""
|
| 254 |
+
Upload a .zip of your Python project.
|
| 255 |
+
Returns a repo_id you use in all other calls.
|
| 256 |
+
"""
|
| 257 |
+
if not file.filename.endswith(".zip"):
|
| 258 |
+
raise HTTPException(400, "Please upload a .zip file of your project folder.")
|
| 259 |
+
|
| 260 |
+
# Save zip to temp dir
|
| 261 |
+
tmp_dir = tempfile.mkdtemp(prefix="diffctx_")
|
| 262 |
+
zip_path = os.path.join(tmp_dir, "repo.zip")
|
| 263 |
+
extract_path = os.path.join(tmp_dir, "repo")
|
| 264 |
+
|
| 265 |
+
content = await file.read()
|
| 266 |
+
with open(zip_path, "wb") as f:
|
| 267 |
+
f.write(content)
|
| 268 |
+
|
| 269 |
+
# Extract
|
| 270 |
+
try:
|
| 271 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 272 |
+
zf.extractall(extract_path)
|
| 273 |
+
except zipfile.BadZipFile:
|
| 274 |
+
shutil.rmtree(tmp_dir)
|
| 275 |
+
raise HTTPException(400, "Could not open zip file. Make sure it's a valid .zip.")
|
| 276 |
+
|
| 277 |
+
# Find the actual Python root (handle nested folder inside zip)
|
| 278 |
+
py_files = list(Path(extract_path).rglob("*.py"))
|
| 279 |
+
if not py_files:
|
| 280 |
+
shutil.rmtree(tmp_dir)
|
| 281 |
+
raise HTTPException(400, "No Python (.py) files found in your zip.")
|
| 282 |
+
|
| 283 |
+
# Use shallowest common directory
|
| 284 |
+
repo_root = str(min(py_files, key=lambda p: len(p.parts)).parent)
|
| 285 |
+
|
| 286 |
+
# Store session
|
| 287 |
+
import uuid
|
| 288 |
+
repo_id = str(uuid.uuid4())[:8]
|
| 289 |
+
SESSIONS[repo_id] = repo_root
|
| 290 |
+
|
| 291 |
+
symbols = _list_symbols(repo_root)
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
"repo_id": repo_id,
|
| 295 |
+
"message": f"Uploaded successfully! Found {len(symbols)} functions.",
|
| 296 |
+
"symbol_count": len(symbols),
|
| 297 |
+
"sample_symbols": symbols[:10],
|
| 298 |
+
"next_step": f'Now call POST /blast with {{"repo_id": "{repo_id}", "symbol": "<pick from symbols above>"}}',
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@app.post("/inline")
|
| 303 |
+
def analyze_inline(req: InlineRequest):
|
| 304 |
+
"""
|
| 305 |
+
Paste code directly — no upload needed.
|
| 306 |
+
Great for quick testing.
|
| 307 |
+
|
| 308 |
+
Example body:
|
| 309 |
+
{
|
| 310 |
+
"files": {
|
| 311 |
+
"service.py": "def create_user(name): ...\\ndef onboard(name): create_user(name)"
|
| 312 |
+
},
|
| 313 |
+
"symbol": "./service.py:onboard"
|
| 314 |
+
}
|
| 315 |
+
"""
|
| 316 |
+
tmp_dir = tempfile.mkdtemp(prefix="diffctx_inline_")
|
| 317 |
+
try:
|
| 318 |
+
for filename, code in req.files.items():
|
| 319 |
+
# Safety: strip any path traversal
|
| 320 |
+
safe_name = Path(filename).name
|
| 321 |
+
filepath = os.path.join(tmp_dir, safe_name)
|
| 322 |
+
with open(filepath, "w") as f:
|
| 323 |
+
f.write(code)
|
| 324 |
+
|
| 325 |
+
result = _run_diffcontext(tmp_dir, req.symbol)
|
| 326 |
+
return result
|
| 327 |
+
finally:
|
| 328 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@app.get("/symbols")
|
| 332 |
+
def list_symbols(repo_id: str):
|
| 333 |
+
"""List all functions in your uploaded repo."""
|
| 334 |
+
if repo_id not in SESSIONS:
|
| 335 |
+
raise HTTPException(404, f"repo_id '{repo_id}' not found. Upload your project first via POST /upload.")
|
| 336 |
+
repo_path = SESSIONS[repo_id]
|
| 337 |
+
symbols = _list_symbols(repo_path)
|
| 338 |
+
return {
|
| 339 |
+
"repo_id": repo_id,
|
| 340 |
+
"count": len(symbols),
|
| 341 |
+
"symbols": symbols,
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@app.post("/blast")
|
| 346 |
+
def blast_radius(req: BlastRequest):
|
| 347 |
+
"""
|
| 348 |
+
Get the blast radius for a function.
|
| 349 |
+
Tells you: who calls it, what it calls, what breaks if you change it.
|
| 350 |
+
"""
|
| 351 |
+
if req.repo_id not in SESSIONS:
|
| 352 |
+
raise HTTPException(404, f"repo_id '{req.repo_id}' not found. Upload your project first.")
|
| 353 |
+
repo_path = SESSIONS[req.repo_id]
|
| 354 |
+
result = _run_diffcontext(repo_path, req.symbol)
|
| 355 |
+
return result
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@app.post("/compile")
|
| 359 |
+
def compile_context(req: CompileRequest):
|
| 360 |
+
"""
|
| 361 |
+
Get an LLM-ready context string you can paste into Claude or ChatGPT.
|
| 362 |
+
"""
|
| 363 |
+
if req.repo_id not in SESSIONS:
|
| 364 |
+
raise HTTPException(404, f"repo_id '{req.repo_id}' not found. Upload your project first.")
|
| 365 |
+
|
| 366 |
+
repo_path = SESSIONS[req.repo_id]
|
| 367 |
+
|
| 368 |
+
try:
|
| 369 |
+
import sys
|
| 370 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 371 |
+
from diffcontext.pipeline import index_repository, analyze_impact, compile as dc_compile
|
| 372 |
+
|
| 373 |
+
idx = index_repository(repo_path)
|
| 374 |
+
impact = analyze_impact(idx, [req.symbol])
|
| 375 |
+
ctx = dc_compile(idx, impact, max_tokens=req.max_tokens)
|
| 376 |
+
|
| 377 |
+
return {
|
| 378 |
+
"context_text": ctx.text,
|
| 379 |
+
"symbol_count": ctx.symbol_count,
|
| 380 |
+
"token_estimate": ctx.token_estimate,
|
| 381 |
+
"reduction_pct": round(ctx.reduction_pct, 1),
|
| 382 |
+
"usage_tip": "Paste context_text into Claude/ChatGPT with a specific question about your change.",
|
| 383 |
+
}
|
| 384 |
+
except ImportError:
|
| 385 |
+
# Fallback: return basic info
|
| 386 |
+
result = _run_diffcontext(repo_path, req.symbol)
|
| 387 |
+
context_text = f"""
|
| 388 |
+
=== DIFFCONTEXT ANALYSIS ===
|
| 389 |
+
Symbol: {req.symbol}
|
| 390 |
+
|
| 391 |
+
Direct callers (will break if signature changes):
|
| 392 |
+
{chr(10).join(f' - {c}' for c in result.get('direct_callers', [])) or ' (none found)'}
|
| 393 |
+
|
| 394 |
+
Direct callees (dependencies):
|
| 395 |
+
{chr(10).join(f' - {c}' for c in result.get('direct_callees', [])) or ' (none found)'}
|
| 396 |
+
|
| 397 |
+
Full blast radius ({result.get('blast_radius_count', 0)} functions affected):
|
| 398 |
+
{chr(10).join(f' - {s}' for s in result.get('all_affected', [])[:20]) or ' (none found)'}
|
| 399 |
+
"""
|
| 400 |
+
return {
|
| 401 |
+
"context_text": context_text,
|
| 402 |
+
"symbol_count": result.get("blast_radius_count", 0),
|
| 403 |
+
"token_estimate": len(context_text) // 4,
|
| 404 |
+
"reduction_pct": 0,
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ── Optional MCP SSE integration ─────────────────────────────────────────────
|
| 409 |
+
try:
|
| 410 |
+
from fastapi import Request
|
| 411 |
+
from starlette.routing import Mount
|
| 412 |
+
from mcp.server.sse import SseServerTransport
|
| 413 |
+
from diffcontext.mcp_server import server as mcp_server, HAS_MCP
|
| 414 |
+
|
| 415 |
+
if HAS_MCP and mcp_server is not None:
|
| 416 |
+
sse = SseServerTransport("/messages/")
|
| 417 |
+
|
| 418 |
+
# Mount the /messages endpoint
|
| 419 |
+
app.router.routes.append(Mount("/messages", app=sse.handle_post_message))
|
| 420 |
+
|
| 421 |
+
@app.get("/sse")
|
| 422 |
+
async def handle_sse(request: Request):
|
| 423 |
+
async with sse.connect_sse(
|
| 424 |
+
request.scope,
|
| 425 |
+
request.receive,
|
| 426 |
+
request._send
|
| 427 |
+
) as (read_stream, write_stream):
|
| 428 |
+
await mcp_server.run(
|
| 429 |
+
read_stream,
|
| 430 |
+
write_stream,
|
| 431 |
+
mcp_server.create_initialization_options()
|
| 432 |
+
)
|
| 433 |
+
except ImportError:
|
| 434 |
+
pass
|
| 435 |
+
|
diffcontext-service/frontend/index.html
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>DiffContext — Code Blast Radius Analyser</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0d0f14;
|
| 10 |
+
--surface: #13161e;
|
| 11 |
+
--surface2: #1a1e2a;
|
| 12 |
+
--border: #252a38;
|
| 13 |
+
--accent: #7c6fff;
|
| 14 |
+
--accent2: #ff6b6b;
|
| 15 |
+
--accent3: #43e97b;
|
| 16 |
+
--text: #e2e8f0;
|
| 17 |
+
--muted: #64748b;
|
| 18 |
+
--warn: #f59e0b;
|
| 19 |
+
--mono: 'JetBrains Mono', 'Fira Code', monospace;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
background: var(--bg);
|
| 26 |
+
color: var(--text);
|
| 27 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/* ── Header ── */
|
| 34 |
+
header {
|
| 35 |
+
background: var(--surface);
|
| 36 |
+
border-bottom: 1px solid var(--border);
|
| 37 |
+
padding: 18px 32px;
|
| 38 |
+
display: flex;
|
| 39 |
+
align-items: center;
|
| 40 |
+
gap: 16px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.logo {
|
| 44 |
+
font-size: 22px;
|
| 45 |
+
font-weight: 700;
|
| 46 |
+
color: var(--accent);
|
| 47 |
+
letter-spacing: -0.5px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.logo span { color: var(--text); }
|
| 51 |
+
|
| 52 |
+
.tagline {
|
| 53 |
+
color: var(--muted);
|
| 54 |
+
font-size: 13px;
|
| 55 |
+
margin-left: auto;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* ── Tabs ── */
|
| 59 |
+
.tabs {
|
| 60 |
+
display: flex;
|
| 61 |
+
gap: 0;
|
| 62 |
+
border-bottom: 1px solid var(--border);
|
| 63 |
+
background: var(--surface);
|
| 64 |
+
padding: 0 32px;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.tab {
|
| 68 |
+
padding: 12px 20px;
|
| 69 |
+
cursor: pointer;
|
| 70 |
+
font-size: 13px;
|
| 71 |
+
font-weight: 500;
|
| 72 |
+
color: var(--muted);
|
| 73 |
+
border-bottom: 2px solid transparent;
|
| 74 |
+
transition: all 0.15s;
|
| 75 |
+
user-select: none;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.tab:hover { color: var(--text); }
|
| 79 |
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
| 80 |
+
|
| 81 |
+
/* ── Main layout ── */
|
| 82 |
+
main {
|
| 83 |
+
flex: 1;
|
| 84 |
+
display: flex;
|
| 85 |
+
gap: 0;
|
| 86 |
+
overflow: hidden;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.panel {
|
| 90 |
+
padding: 28px 32px;
|
| 91 |
+
overflow-y: auto;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.left { width: 380px; min-width: 320px; border-right: 1px solid var(--border); }
|
| 95 |
+
.right { flex: 1; }
|
| 96 |
+
|
| 97 |
+
/* ── Section headers ── */
|
| 98 |
+
.section-title {
|
| 99 |
+
font-size: 11px;
|
| 100 |
+
font-weight: 600;
|
| 101 |
+
text-transform: uppercase;
|
| 102 |
+
letter-spacing: 1px;
|
| 103 |
+
color: var(--muted);
|
| 104 |
+
margin-bottom: 12px;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* ── Input groups ── */
|
| 108 |
+
.input-group {
|
| 109 |
+
margin-bottom: 20px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
label {
|
| 113 |
+
display: block;
|
| 114 |
+
font-size: 12px;
|
| 115 |
+
font-weight: 500;
|
| 116 |
+
color: var(--muted);
|
| 117 |
+
margin-bottom: 6px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
input, select, textarea {
|
| 121 |
+
width: 100%;
|
| 122 |
+
background: var(--surface2);
|
| 123 |
+
border: 1px solid var(--border);
|
| 124 |
+
color: var(--text);
|
| 125 |
+
padding: 10px 14px;
|
| 126 |
+
border-radius: 8px;
|
| 127 |
+
font-size: 13px;
|
| 128 |
+
font-family: var(--mono);
|
| 129 |
+
outline: none;
|
| 130 |
+
transition: border-color 0.15s;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
input:focus, select:focus, textarea:focus {
|
| 134 |
+
border-color: var(--accent);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
textarea { resize: vertical; min-height: 120px; }
|
| 138 |
+
|
| 139 |
+
/* ── Buttons ── */
|
| 140 |
+
.btn {
|
| 141 |
+
padding: 10px 20px;
|
| 142 |
+
border-radius: 8px;
|
| 143 |
+
border: none;
|
| 144 |
+
font-size: 13px;
|
| 145 |
+
font-weight: 600;
|
| 146 |
+
cursor: pointer;
|
| 147 |
+
transition: all 0.15s;
|
| 148 |
+
display: inline-flex;
|
| 149 |
+
align-items: center;
|
| 150 |
+
gap: 8px;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.btn-primary {
|
| 154 |
+
background: var(--accent);
|
| 155 |
+
color: white;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.btn-primary:hover { background: #6b5fee; }
|
| 159 |
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 160 |
+
|
| 161 |
+
.btn-secondary {
|
| 162 |
+
background: var(--surface2);
|
| 163 |
+
color: var(--text);
|
| 164 |
+
border: 1px solid var(--border);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
|
| 168 |
+
|
| 169 |
+
.btn-full { width: 100%; justify-content: center; }
|
| 170 |
+
|
| 171 |
+
/* ── Upload zone ── */
|
| 172 |
+
.upload-zone {
|
| 173 |
+
border: 2px dashed var(--border);
|
| 174 |
+
border-radius: 12px;
|
| 175 |
+
padding: 32px;
|
| 176 |
+
text-align: center;
|
| 177 |
+
cursor: pointer;
|
| 178 |
+
transition: all 0.15s;
|
| 179 |
+
margin-bottom: 20px;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.upload-zone:hover, .upload-zone.drag-over {
|
| 183 |
+
border-color: var(--accent);
|
| 184 |
+
background: rgba(124, 111, 255, 0.04);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.upload-icon { font-size: 36px; margin-bottom: 10px; }
|
| 188 |
+
|
| 189 |
+
.upload-zone p { font-size: 13px; color: var(--muted); }
|
| 190 |
+
.upload-zone strong { color: var(--text); }
|
| 191 |
+
|
| 192 |
+
/* ── Result cards ── */
|
| 193 |
+
.result-area {
|
| 194 |
+
display: none;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.result-area.visible { display: block; }
|
| 198 |
+
|
| 199 |
+
.stat-row {
|
| 200 |
+
display: grid;
|
| 201 |
+
grid-template-columns: repeat(3, 1fr);
|
| 202 |
+
gap: 12px;
|
| 203 |
+
margin-bottom: 24px;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.stat-card {
|
| 207 |
+
background: var(--surface2);
|
| 208 |
+
border: 1px solid var(--border);
|
| 209 |
+
border-radius: 10px;
|
| 210 |
+
padding: 16px;
|
| 211 |
+
text-align: center;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.stat-value {
|
| 215 |
+
font-size: 28px;
|
| 216 |
+
font-weight: 700;
|
| 217 |
+
color: var(--accent);
|
| 218 |
+
font-family: var(--mono);
|
| 219 |
+
line-height: 1;
|
| 220 |
+
margin-bottom: 4px;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.stat-label {
|
| 224 |
+
font-size: 11px;
|
| 225 |
+
color: var(--muted);
|
| 226 |
+
text-transform: uppercase;
|
| 227 |
+
letter-spacing: 0.5px;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* ── Symbol lists ── */
|
| 231 |
+
.sym-section {
|
| 232 |
+
margin-bottom: 20px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.sym-header {
|
| 236 |
+
display: flex;
|
| 237 |
+
align-items: center;
|
| 238 |
+
gap: 8px;
|
| 239 |
+
margin-bottom: 10px;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.sym-badge {
|
| 243 |
+
font-size: 11px;
|
| 244 |
+
font-weight: 600;
|
| 245 |
+
padding: 2px 8px;
|
| 246 |
+
border-radius: 20px;
|
| 247 |
+
font-family: var(--mono);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.badge-caller { background: rgba(255, 107, 107, 0.15); color: var(--accent2); }
|
| 251 |
+
.badge-callee { background: rgba(67, 233, 123, 0.15); color: var(--accent3); }
|
| 252 |
+
.badge-blast { background: rgba(124, 111, 255, 0.15); color: var(--accent); }
|
| 253 |
+
|
| 254 |
+
.sym-list {
|
| 255 |
+
display: flex;
|
| 256 |
+
flex-direction: column;
|
| 257 |
+
gap: 6px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.sym-item {
|
| 261 |
+
background: var(--surface2);
|
| 262 |
+
border: 1px solid var(--border);
|
| 263 |
+
border-radius: 6px;
|
| 264 |
+
padding: 8px 12px;
|
| 265 |
+
font-family: var(--mono);
|
| 266 |
+
font-size: 12px;
|
| 267 |
+
color: var(--text);
|
| 268 |
+
display: flex;
|
| 269 |
+
align-items: center;
|
| 270 |
+
gap: 8px;
|
| 271 |
+
cursor: pointer;
|
| 272 |
+
transition: border-color 0.1s;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.sym-item:hover { border-color: var(--accent); }
|
| 276 |
+
|
| 277 |
+
.sym-file { color: var(--muted); }
|
| 278 |
+
.sym-name { color: var(--text); font-weight: 500; }
|
| 279 |
+
.sym-dot { color: var(--border); }
|
| 280 |
+
|
| 281 |
+
/* ── File groups in blast radius ── */
|
| 282 |
+
.file-group {
|
| 283 |
+
margin-bottom: 14px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.file-label {
|
| 287 |
+
font-size: 11px;
|
| 288 |
+
color: var(--accent);
|
| 289 |
+
font-family: var(--mono);
|
| 290 |
+
margin-bottom: 6px;
|
| 291 |
+
display: flex;
|
| 292 |
+
align-items: center;
|
| 293 |
+
gap: 6px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.file-fns {
|
| 297 |
+
display: flex;
|
| 298 |
+
flex-wrap: wrap;
|
| 299 |
+
gap: 6px;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.fn-chip {
|
| 303 |
+
background: var(--surface2);
|
| 304 |
+
border: 1px solid var(--border);
|
| 305 |
+
padding: 3px 10px;
|
| 306 |
+
border-radius: 20px;
|
| 307 |
+
font-size: 11px;
|
| 308 |
+
font-family: var(--mono);
|
| 309 |
+
color: var(--text);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/* ── Code paste tab ── */
|
| 313 |
+
.code-file-row {
|
| 314 |
+
display: flex;
|
| 315 |
+
gap: 8px;
|
| 316 |
+
margin-bottom: 10px;
|
| 317 |
+
align-items: flex-start;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.code-file-name {
|
| 321 |
+
width: 160px;
|
| 322 |
+
flex-shrink: 0;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.code-remove {
|
| 326 |
+
background: none;
|
| 327 |
+
border: none;
|
| 328 |
+
color: var(--accent2);
|
| 329 |
+
cursor: pointer;
|
| 330 |
+
font-size: 16px;
|
| 331 |
+
padding: 8px;
|
| 332 |
+
opacity: 0.6;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.code-remove:hover { opacity: 1; }
|
| 336 |
+
|
| 337 |
+
/* ── Context output ── */
|
| 338 |
+
.context-out {
|
| 339 |
+
background: var(--surface2);
|
| 340 |
+
border: 1px solid var(--border);
|
| 341 |
+
border-radius: 8px;
|
| 342 |
+
padding: 16px;
|
| 343 |
+
font-family: var(--mono);
|
| 344 |
+
font-size: 12px;
|
| 345 |
+
white-space: pre-wrap;
|
| 346 |
+
max-height: 500px;
|
| 347 |
+
overflow-y: auto;
|
| 348 |
+
color: var(--text);
|
| 349 |
+
line-height: 1.6;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/* ── Status / error ── */
|
| 353 |
+
.status {
|
| 354 |
+
padding: 10px 14px;
|
| 355 |
+
border-radius: 8px;
|
| 356 |
+
font-size: 13px;
|
| 357 |
+
margin-bottom: 16px;
|
| 358 |
+
display: none;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
.status.visible { display: block; }
|
| 362 |
+
.status.error { background: rgba(255,107,107,0.1); border: 1px solid rgba(255,107,107,0.3); color: var(--accent2); }
|
| 363 |
+
.status.success { background: rgba(67,233,123,0.1); border: 1px solid rgba(67,233,123,0.3); color: var(--accent3); }
|
| 364 |
+
.status.loading { background: rgba(124,111,255,0.1); border: 1px solid rgba(124,111,255,0.3); color: var(--accent); }
|
| 365 |
+
|
| 366 |
+
/* ── Tip box ── */
|
| 367 |
+
.tip {
|
| 368 |
+
background: var(--surface2);
|
| 369 |
+
border-left: 3px solid var(--accent);
|
| 370 |
+
padding: 12px 16px;
|
| 371 |
+
border-radius: 0 8px 8px 0;
|
| 372 |
+
font-size: 12px;
|
| 373 |
+
color: var(--muted);
|
| 374 |
+
margin-bottom: 20px;
|
| 375 |
+
line-height: 1.6;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.tip strong { color: var(--text); }
|
| 379 |
+
|
| 380 |
+
/* ── Symbol picker ── */
|
| 381 |
+
.sym-picker {
|
| 382 |
+
max-height: 240px;
|
| 383 |
+
overflow-y: auto;
|
| 384 |
+
border: 1px solid var(--border);
|
| 385 |
+
border-radius: 8px;
|
| 386 |
+
background: var(--surface2);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.sym-picker-item {
|
| 390 |
+
padding: 8px 12px;
|
| 391 |
+
font-family: var(--mono);
|
| 392 |
+
font-size: 12px;
|
| 393 |
+
cursor: pointer;
|
| 394 |
+
border-bottom: 1px solid var(--border);
|
| 395 |
+
transition: background 0.1s;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.sym-picker-item:hover { background: rgba(124,111,255,0.1); }
|
| 399 |
+
.sym-picker-item:last-child { border-bottom: none; }
|
| 400 |
+
|
| 401 |
+
/* ── API docs callout ── */
|
| 402 |
+
.api-box {
|
| 403 |
+
background: var(--surface2);
|
| 404 |
+
border: 1px solid var(--border);
|
| 405 |
+
border-radius: 10px;
|
| 406 |
+
padding: 20px;
|
| 407 |
+
margin-top: 20px;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.api-endpoint {
|
| 411 |
+
display: flex;
|
| 412 |
+
align-items: center;
|
| 413 |
+
gap: 10px;
|
| 414 |
+
margin-bottom: 10px;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.method {
|
| 418 |
+
font-family: var(--mono);
|
| 419 |
+
font-size: 11px;
|
| 420 |
+
font-weight: 700;
|
| 421 |
+
padding: 2px 8px;
|
| 422 |
+
border-radius: 4px;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.method-post { background: rgba(124,111,255,0.2); color: var(--accent); }
|
| 426 |
+
.method-get { background: rgba(67,233,123,0.2); color: var(--accent3); }
|
| 427 |
+
|
| 428 |
+
.endpoint-path {
|
| 429 |
+
font-family: var(--mono);
|
| 430 |
+
font-size: 12px;
|
| 431 |
+
color: var(--text);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.endpoint-desc { font-size: 12px; color: var(--muted); }
|
| 435 |
+
|
| 436 |
+
/* ── Divider ── */
|
| 437 |
+
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
| 438 |
+
|
| 439 |
+
/* ── Spinner ── */
|
| 440 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 441 |
+
.spinner {
|
| 442 |
+
display: inline-block;
|
| 443 |
+
width: 14px; height: 14px;
|
| 444 |
+
border: 2px solid rgba(255,255,255,0.2);
|
| 445 |
+
border-top-color: white;
|
| 446 |
+
border-radius: 50%;
|
| 447 |
+
animation: spin 0.6s linear infinite;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/* ── Pane visibility ── */
|
| 451 |
+
.pane { display: none; }
|
| 452 |
+
.pane.active { display: flex; flex: 1; overflow: hidden; }
|
| 453 |
+
|
| 454 |
+
/* ── Scrollbar ── */
|
| 455 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 456 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 457 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 458 |
+
</style>
|
| 459 |
+
</head>
|
| 460 |
+
<body>
|
| 461 |
+
|
| 462 |
+
<header>
|
| 463 |
+
<div class="logo">Diff<span>Context</span></div>
|
| 464 |
+
<div class="tagline">Which functions break when you change one?</div>
|
| 465 |
+
</header>
|
| 466 |
+
|
| 467 |
+
<div class="tabs">
|
| 468 |
+
<div class="tab active" onclick="switchTab('upload')">📁 Upload Project</div>
|
| 469 |
+
<div class="tab" onclick="switchTab('paste')">📋 Paste Code</div>
|
| 470 |
+
<div class="tab" onclick="switchTab('api')">⚡ API & MCP</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
<!-- ══════════════════ TAB: UPLOAD ══════════════════ -->
|
| 474 |
+
<div class="pane active" id="pane-upload">
|
| 475 |
+
<div class="panel left">
|
| 476 |
+
<p class="section-title">Step 1 — Upload your project</p>
|
| 477 |
+
|
| 478 |
+
<div class="tip">
|
| 479 |
+
<strong>What to do:</strong> Zip your Python project folder, upload it here,
|
| 480 |
+
then pick a function to analyse. DiffContext will tell you what breaks if you change it.
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
|
| 484 |
+
<input type="file" id="fileInput" accept=".zip" style="display:none" onchange="handleUpload(this)">
|
| 485 |
+
<div class="upload-icon">📦</div>
|
| 486 |
+
<p><strong>Click to upload a .zip</strong></p>
|
| 487 |
+
<p>of your Python project</p>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<div id="uploadStatus" class="status"></div>
|
| 491 |
+
|
| 492 |
+
<div id="symbolSection" style="display:none">
|
| 493 |
+
<hr class="divider">
|
| 494 |
+
<p class="section-title">Step 2 — Pick a function</p>
|
| 495 |
+
<div class="input-group">
|
| 496 |
+
<label>Search functions</label>
|
| 497 |
+
<input type="text" id="symSearch" placeholder="type to filter..." oninput="filterSymbols()">
|
| 498 |
+
</div>
|
| 499 |
+
<div class="sym-picker" id="symPicker"></div>
|
| 500 |
+
|
| 501 |
+
<div class="input-group" style="margin-top:14px">
|
| 502 |
+
<label>Selected function</label>
|
| 503 |
+
<input type="text" id="selectedSym" placeholder="./file.py:function_name" readonly>
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
<button class="btn btn-primary btn-full" onclick="runBlast()" id="blastBtn">
|
| 507 |
+
⚡ Analyse Blast Radius
|
| 508 |
+
</button>
|
| 509 |
+
</div>
|
| 510 |
+
</div>
|
| 511 |
+
|
| 512 |
+
<div class="panel right">
|
| 513 |
+
<div id="uploadResultArea" class="result-area">
|
| 514 |
+
<p class="section-title">Blast Radius Results</p>
|
| 515 |
+
|
| 516 |
+
<div class="stat-row">
|
| 517 |
+
<div class="stat-card">
|
| 518 |
+
<div class="stat-value" id="statCallers">—</div>
|
| 519 |
+
<div class="stat-label">Direct Callers</div>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="stat-card">
|
| 522 |
+
<div class="stat-value" id="statCallees">—</div>
|
| 523 |
+
<div class="stat-label">Direct Callees</div>
|
| 524 |
+
</div>
|
| 525 |
+
<div class="stat-card">
|
| 526 |
+
<div class="stat-value" id="statBlast">—</div>
|
| 527 |
+
<div class="stat-label">Total Affected</div>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
|
| 531 |
+
<div class="sym-section">
|
| 532 |
+
<div class="sym-header">
|
| 533 |
+
<span class="sym-badge badge-caller">▲ CALLERS</span>
|
| 534 |
+
<span style="font-size:12px;color:var(--muted)">These will break if you change the function</span>
|
| 535 |
+
</div>
|
| 536 |
+
<div class="sym-list" id="callerList"></div>
|
| 537 |
+
</div>
|
| 538 |
+
|
| 539 |
+
<div class="sym-section">
|
| 540 |
+
<div class="sym-header">
|
| 541 |
+
<span class="sym-badge badge-callee">▼ CALLEES</span>
|
| 542 |
+
<span style="font-size:12px;color:var(--muted)">Functions this one depends on</span>
|
| 543 |
+
</div>
|
| 544 |
+
<div class="sym-list" id="calleeList"></div>
|
| 545 |
+
</div>
|
| 546 |
+
|
| 547 |
+
<div class="sym-section">
|
| 548 |
+
<div class="sym-header">
|
| 549 |
+
<span class="sym-badge badge-blast">◉ BLAST RADIUS</span>
|
| 550 |
+
<span style="font-size:12px;color:var(--muted)">All transitively affected, by file</span>
|
| 551 |
+
</div>
|
| 552 |
+
<div id="blastByFile"></div>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
<hr class="divider">
|
| 556 |
+
<button class="btn btn-secondary" onclick="getContext()" style="width:100%">
|
| 557 |
+
📋 Get LLM Context (paste into Claude)
|
| 558 |
+
</button>
|
| 559 |
+
<div id="contextOut" style="display:none;margin-top:14px">
|
| 560 |
+
<div class="sym-header" style="justify-content:space-between">
|
| 561 |
+
<span class="section-title" style="margin:0">Context ready to paste</span>
|
| 562 |
+
<button class="btn btn-secondary" onclick="copyContext()" style="padding:6px 12px;font-size:12px">Copy</button>
|
| 563 |
+
</div>
|
| 564 |
+
<div class="context-out" id="contextText"></div>
|
| 565 |
+
</div>
|
| 566 |
+
</div>
|
| 567 |
+
|
| 568 |
+
<div id="uploadEmpty" style="display:flex;align-items:center;justify-content:center;height:100%;flex-direction:column;gap:12px;color:var(--muted)">
|
| 569 |
+
<div style="font-size:48px">🎯</div>
|
| 570 |
+
<p>Upload a project and pick a function to see results here</p>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
</div>
|
| 574 |
+
|
| 575 |
+
<!-- ══════════════════ TAB: PASTE ══════════��═══════ -->
|
| 576 |
+
<div class="pane" id="pane-paste">
|
| 577 |
+
<div class="panel left">
|
| 578 |
+
<p class="section-title">Paste your Python code</p>
|
| 579 |
+
|
| 580 |
+
<div class="tip">
|
| 581 |
+
<strong>No upload needed.</strong> Paste one or more Python files below
|
| 582 |
+
and pick a function to analyse instantly.
|
| 583 |
+
</div>
|
| 584 |
+
|
| 585 |
+
<div id="codeFiles">
|
| 586 |
+
<div class="code-file-row" id="codeRow0">
|
| 587 |
+
<input class="code-file-name" type="text" value="main.py" placeholder="filename.py">
|
| 588 |
+
<textarea style="flex:1;height:140px;font-size:12px" placeholder="def greet(name): msg = build_message(name) print(msg) def build_message(name): return f'Hello, {name}!'"></textarea>
|
| 589 |
+
<button class="code-remove" onclick="removeCodeFile(0)">✕</button>
|
| 590 |
+
</div>
|
| 591 |
+
</div>
|
| 592 |
+
|
| 593 |
+
<button class="btn btn-secondary" onclick="addCodeFile()" style="width:100%;margin-bottom:16px">
|
| 594 |
+
+ Add another file
|
| 595 |
+
</button>
|
| 596 |
+
|
| 597 |
+
<div class="input-group">
|
| 598 |
+
<label>Function to analyse</label>
|
| 599 |
+
<input type="text" id="pasteSymbol" placeholder="./main.py:greet">
|
| 600 |
+
</div>
|
| 601 |
+
|
| 602 |
+
<div id="pasteStatus" class="status"></div>
|
| 603 |
+
|
| 604 |
+
<button class="btn btn-primary btn-full" onclick="runPasteBlast()">
|
| 605 |
+
⚡ Analyse
|
| 606 |
+
</button>
|
| 607 |
+
</div>
|
| 608 |
+
|
| 609 |
+
<div class="panel right">
|
| 610 |
+
<div id="pasteResultArea" class="result-area">
|
| 611 |
+
<p class="section-title">Blast Radius Results</p>
|
| 612 |
+
|
| 613 |
+
<div class="stat-row">
|
| 614 |
+
<div class="stat-card">
|
| 615 |
+
<div class="stat-value" id="pStatCallers">—</div>
|
| 616 |
+
<div class="stat-label">Direct Callers</div>
|
| 617 |
+
</div>
|
| 618 |
+
<div class="stat-card">
|
| 619 |
+
<div class="stat-value" id="pStatCallees">—</div>
|
| 620 |
+
<div class="stat-label">Direct Callees</div>
|
| 621 |
+
</div>
|
| 622 |
+
<div class="stat-card">
|
| 623 |
+
<div class="stat-value" id="pStatBlast">—</div>
|
| 624 |
+
<div class="stat-label">Total Affected</div>
|
| 625 |
+
</div>
|
| 626 |
+
</div>
|
| 627 |
+
|
| 628 |
+
<div class="sym-section">
|
| 629 |
+
<div class="sym-header">
|
| 630 |
+
<span class="sym-badge badge-caller">▲ CALLERS</span>
|
| 631 |
+
</div>
|
| 632 |
+
<div class="sym-list" id="pCallerList"></div>
|
| 633 |
+
</div>
|
| 634 |
+
|
| 635 |
+
<div class="sym-section">
|
| 636 |
+
<div class="sym-header">
|
| 637 |
+
<span class="sym-badge badge-callee">▼ CALLEES</span>
|
| 638 |
+
</div>
|
| 639 |
+
<div class="sym-list" id="pCalleeList"></div>
|
| 640 |
+
</div>
|
| 641 |
+
|
| 642 |
+
<div class="sym-section">
|
| 643 |
+
<div class="sym-header">
|
| 644 |
+
<span class="sym-badge badge-blast">◉ BLAST RADIUS</span>
|
| 645 |
+
</div>
|
| 646 |
+
<div id="pBlastByFile"></div>
|
| 647 |
+
</div>
|
| 648 |
+
</div>
|
| 649 |
+
|
| 650 |
+
<div id="pasteEmpty" style="display:flex;align-items:center;justify-content:center;height:100%;flex-direction:column;gap:12px;color:var(--muted)">
|
| 651 |
+
<div style="font-size:48px">✏️</div>
|
| 652 |
+
<p>Paste some code and click Analyse</p>
|
| 653 |
+
</div>
|
| 654 |
+
</div>
|
| 655 |
+
</div>
|
| 656 |
+
|
| 657 |
+
<!-- ══════════════════ TAB: API ══════════════════ -->
|
| 658 |
+
<div class="pane" id="pane-api">
|
| 659 |
+
<div class="panel" style="width:100%;max-width:820px;margin:0 auto">
|
| 660 |
+
<p class="section-title">API & MCP Integration</p>
|
| 661 |
+
|
| 662 |
+
<div class="tip" style="margin-bottom:24px">
|
| 663 |
+
Run DiffContext as a local server and call it from your own tools,
|
| 664 |
+
scripts, or connect it to Claude Desktop as an MCP server.
|
| 665 |
+
</div>
|
| 666 |
+
|
| 667 |
+
<div class="api-box">
|
| 668 |
+
<p style="font-size:14px;font-weight:600;margin-bottom:16px">🚀 Start the web server</p>
|
| 669 |
+
<div class="context-out" style="max-height:none">pip install fastapi uvicorn
|
| 670 |
+
cd /path/to/Diffcontext
|
| 671 |
+
uvicorn diffcontext-service.backend.main:app --reload --port 8000
|
| 672 |
+
|
| 673 |
+
# Now open: http://localhost:8000/docs ← interactive API docs</div>
|
| 674 |
+
</div>
|
| 675 |
+
|
| 676 |
+
<div class="api-box" style="margin-top:16px">
|
| 677 |
+
<p style="font-size:14px;font-weight:600;margin-bottom:16px">📡 REST API Endpoints</p>
|
| 678 |
+
|
| 679 |
+
<div class="api-endpoint">
|
| 680 |
+
<span class="method method-post">POST</span>
|
| 681 |
+
<span class="endpoint-path">/upload</span>
|
| 682 |
+
<span class="endpoint-desc">Upload a .zip of your project → get a repo_id</span>
|
| 683 |
+
</div>
|
| 684 |
+
|
| 685 |
+
<div class="api-endpoint">
|
| 686 |
+
<span class="method method-get">GET</span>
|
| 687 |
+
<span class="endpoint-path">/symbols?repo_id=abc123</span>
|
| 688 |
+
<span class="endpoint-desc">List all functions in your project</span>
|
| 689 |
+
</div>
|
| 690 |
+
|
| 691 |
+
<div class="api-endpoint">
|
| 692 |
+
<span class="method method-post">POST</span>
|
| 693 |
+
<span class="endpoint-path">/blast</span>
|
| 694 |
+
<span class="endpoint-desc">Get blast radius for a function</span>
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
<div class="api-endpoint">
|
| 698 |
+
<span class="method method-post">POST</span>
|
| 699 |
+
<span class="endpoint-path">/inline</span>
|
| 700 |
+
<span class="endpoint-desc">Analyse pasted code (no upload needed)</span>
|
| 701 |
+
</div>
|
| 702 |
+
|
| 703 |
+
<div class="api-endpoint">
|
| 704 |
+
<span class="method method-post">POST</span>
|
| 705 |
+
<span class="endpoint-path">/compile</span>
|
| 706 |
+
<span class="endpoint-desc">Get LLM-ready context string</span>
|
| 707 |
+
</div>
|
| 708 |
+
|
| 709 |
+
<hr class="divider">
|
| 710 |
+
|
| 711 |
+
<p style="font-size:12px;color:var(--muted);margin-bottom:10px">Example — call from Python:</p>
|
| 712 |
+
<div class="context-out" style="max-height:none">import requests
|
| 713 |
+
|
| 714 |
+
# 1. Upload your project
|
| 715 |
+
with open("myproject.zip", "rb") as f:
|
| 716 |
+
r = requests.post("http://localhost:8000/upload", files={"file": f})
|
| 717 |
+
repo_id = r.json()["repo_id"]
|
| 718 |
+
|
| 719 |
+
# 2. Get blast radius
|
| 720 |
+
r = requests.post("http://localhost:8000/blast", json={
|
| 721 |
+
"repo_id": repo_id,
|
| 722 |
+
"symbol": "./src/auth.py:validate_jwt"
|
| 723 |
+
})
|
| 724 |
+
print(r.json())
|
| 725 |
+
|
| 726 |
+
# 3. Get LLM context
|
| 727 |
+
r = requests.post("http://localhost:8000/compile", json={
|
| 728 |
+
"repo_id": repo_id,
|
| 729 |
+
"symbol": "./src/auth.py:validate_jwt",
|
| 730 |
+
"max_tokens": 8000
|
| 731 |
+
})
|
| 732 |
+
print(r.json()["context_text"]) # paste this into Claude</div>
|
| 733 |
+
</div>
|
| 734 |
+
|
| 735 |
+
<div class="api-box" style="margin-top:16px">
|
| 736 |
+
<p style="font-size:14px;font-weight:600;margin-bottom:16px">🤖 Connect to Claude Desktop (MCP)</p>
|
| 737 |
+
|
| 738 |
+
<p style="font-size:12px;color:var(--muted);margin-bottom:10px">1. Install MCP package:</p>
|
| 739 |
+
<div class="context-out" style="max-height:none">pip install mcp</div>
|
| 740 |
+
|
| 741 |
+
<p style="font-size:12px;color:var(--muted);margin:12px 0 6px">2. Add to Claude Desktop config:</p>
|
| 742 |
+
<p style="font-size:11px;color:var(--muted);margin-bottom:8px">
|
| 743 |
+
Mac: <code style="color:var(--accent)">~/Library/Application Support/Claude/claude_desktop_config.json</code><br>
|
| 744 |
+
Windows: <code style="color:var(--accent)">%APPDATA%\Claude\claude_desktop_config.json</code>
|
| 745 |
+
</p>
|
| 746 |
+
<div class="context-out" style="max-height:none">{
|
| 747 |
+
"mcpServers": {
|
| 748 |
+
"diffcontext": {
|
| 749 |
+
"command": "python",
|
| 750 |
+
"args": ["/path/to/Diffcontext/diffcontext-service/mcp/mcp_server.py"]
|
| 751 |
+
}
|
| 752 |
+
}
|
| 753 |
+
}</div>
|
| 754 |
+
|
| 755 |
+
<p style="font-size:12px;color:var(--muted);margin:12px 0 6px">3. Restart Claude Desktop, then ask:</p>
|
| 756 |
+
<div class="context-out" style="max-height:none">"List all functions in /Users/me/myproject"
|
| 757 |
+
"What is the blast radius of ./src/auth.py:validate_jwt in /Users/me/myproject?"
|
| 758 |
+
"Compile context for ./service.py:onboard_user in /Users/me/myproject"</div>
|
| 759 |
+
</div>
|
| 760 |
+
</div>
|
| 761 |
+
</div>
|
| 762 |
+
|
| 763 |
+
<script>
|
| 764 |
+
// ── State ────────────────────────────────────────────────────────────────────
|
| 765 |
+
const API = 'http://localhost:8000';
|
| 766 |
+
let repoId = null;
|
| 767 |
+
let allSymbols = [];
|
| 768 |
+
let codeFileCount = 1;
|
| 769 |
+
let lastBlastResult = null;
|
| 770 |
+
|
| 771 |
+
// ── Tab switching ─────────────────────────────────────────────────────────────
|
| 772 |
+
function switchTab(tab) {
|
| 773 |
+
document.querySelectorAll('.tab').forEach((t, i) => {
|
| 774 |
+
t.classList.toggle('active', ['upload','paste','api'][i] === tab);
|
| 775 |
+
});
|
| 776 |
+
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
|
| 777 |
+
document.getElementById(`pane-${tab}`).classList.add('active');
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
// ── Status helpers ─────────────────────────────────────────────────────────────
|
| 781 |
+
function setStatus(id, msg, type) {
|
| 782 |
+
const el = document.getElementById(id);
|
| 783 |
+
el.className = `status visible ${type}`;
|
| 784 |
+
el.textContent = msg;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
function clearStatus(id) {
|
| 788 |
+
const el = document.getElementById(id);
|
| 789 |
+
el.className = 'status';
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
// ── Drag & drop ───────────────────────────────────────────────────────────────
|
| 793 |
+
const uploadZone = document.getElementById('uploadZone');
|
| 794 |
+
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
|
| 795 |
+
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
|
| 796 |
+
uploadZone.addEventListener('drop', e => {
|
| 797 |
+
e.preventDefault();
|
| 798 |
+
uploadZone.classList.remove('drag-over');
|
| 799 |
+
const file = e.dataTransfer.files[0];
|
| 800 |
+
if (file) doUpload(file);
|
| 801 |
+
});
|
| 802 |
+
|
| 803 |
+
// ── Upload ────────────────────────────────────────────────────────────────────
|
| 804 |
+
function handleUpload(input) {
|
| 805 |
+
if (input.files[0]) doUpload(input.files[0]);
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
async function doUpload(file) {
|
| 809 |
+
setStatus('uploadStatus', '⏳ Uploading and analysing...', 'loading');
|
| 810 |
+
|
| 811 |
+
const form = new FormData();
|
| 812 |
+
form.append('file', file);
|
| 813 |
+
|
| 814 |
+
try {
|
| 815 |
+
const r = await fetch(`${API}/upload`, { method: 'POST', body: form });
|
| 816 |
+
const data = await r.json();
|
| 817 |
+
|
| 818 |
+
if (!r.ok) {
|
| 819 |
+
setStatus('uploadStatus', data.detail || 'Upload failed.', 'error');
|
| 820 |
+
return;
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
repoId = data.repo_id;
|
| 824 |
+
allSymbols = data.sample_symbols || [];
|
| 825 |
+
|
| 826 |
+
// Fetch full symbol list
|
| 827 |
+
const symR = await fetch(`${API}/symbols?repo_id=${repoId}`);
|
| 828 |
+
if (symR.ok) {
|
| 829 |
+
const symData = await symR.json();
|
| 830 |
+
allSymbols = symData.symbols || [];
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
setStatus('uploadStatus', `✓ ${data.message}`, 'success');
|
| 834 |
+
document.getElementById('symbolSection').style.display = 'block';
|
| 835 |
+
renderSymbolPicker(allSymbols);
|
| 836 |
+
|
| 837 |
+
} catch (e) {
|
| 838 |
+
setStatus('uploadStatus',
|
| 839 |
+
'Could not connect to DiffContext server. Make sure it\'s running on localhost:8000',
|
| 840 |
+
'error'
|
| 841 |
+
);
|
| 842 |
+
}
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
// ── Symbol picker ──────────────────────────────────────────────────────────────
|
| 846 |
+
function renderSymbolPicker(syms) {
|
| 847 |
+
const picker = document.getElementById('symPicker');
|
| 848 |
+
picker.innerHTML = syms.slice(0, 100).map(s => {
|
| 849 |
+
const [file, fn] = s.split(':');
|
| 850 |
+
return `<div class="sym-picker-item" onclick="selectSymbol('${s}')">
|
| 851 |
+
<span style="color:var(--muted);font-size:11px">${file}</span>
|
| 852 |
+
<span style="color:var(--text);font-weight:500">:${fn}</span>
|
| 853 |
+
</div>`;
|
| 854 |
+
}).join('');
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
function filterSymbols() {
|
| 858 |
+
const q = document.getElementById('symSearch').value.toLowerCase();
|
| 859 |
+
const filtered = allSymbols.filter(s => s.toLowerCase().includes(q));
|
| 860 |
+
renderSymbolPicker(filtered);
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
function selectSymbol(sym) {
|
| 864 |
+
document.getElementById('selectedSym').value = sym;
|
| 865 |
+
document.querySelectorAll('.sym-picker-item').forEach(el => {
|
| 866 |
+
el.style.background = el.textContent.includes(sym.split(':')[1]) ?
|
| 867 |
+
'rgba(124,111,255,0.15)' : '';
|
| 868 |
+
});
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
// ── Blast radius ───────────────────────────────────────────────────────────────
|
| 872 |
+
async function runBlast() {
|
| 873 |
+
const symbol = document.getElementById('selectedSym').value.trim();
|
| 874 |
+
if (!symbol) { alert('Pick a function first.'); return; }
|
| 875 |
+
if (!repoId) { alert('Upload a project first.'); return; }
|
| 876 |
+
|
| 877 |
+
const btn = document.getElementById('blastBtn');
|
| 878 |
+
btn.innerHTML = '<span class="spinner"></span> Analysing...';
|
| 879 |
+
btn.disabled = true;
|
| 880 |
+
|
| 881 |
+
try {
|
| 882 |
+
const r = await fetch(`${API}/blast`, {
|
| 883 |
+
method: 'POST',
|
| 884 |
+
headers: { 'Content-Type': 'application/json' },
|
| 885 |
+
body: JSON.stringify({ repo_id: repoId, symbol }),
|
| 886 |
+
});
|
| 887 |
+
const data = await r.json();
|
| 888 |
+
|
| 889 |
+
if (data.error) {
|
| 890 |
+
setStatus('uploadStatus', data.error + (data.suggestions ? ' Did you mean: ' + data.suggestions.join(', ') : ''), 'error');
|
| 891 |
+
return;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
lastBlastResult = data;
|
| 895 |
+
renderBlastResults(data, 'upload');
|
| 896 |
+
document.getElementById('uploadEmpty').style.display = 'none';
|
| 897 |
+
document.getElementById('uploadResultArea').classList.add('visible');
|
| 898 |
+
document.getElementById('contextOut').style.display = 'none';
|
| 899 |
+
|
| 900 |
+
} catch (e) {
|
| 901 |
+
setStatus('uploadStatus', 'Server not reachable. Is it running?', 'error');
|
| 902 |
+
} finally {
|
| 903 |
+
btn.innerHTML = '⚡ Analyse Blast Radius';
|
| 904 |
+
btn.disabled = false;
|
| 905 |
+
}
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
function renderBlastResults(data, prefix) {
|
| 909 |
+
const p = prefix === 'upload' ? '' : 'p';
|
| 910 |
+
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
| 911 |
+
|
| 912 |
+
document.getElementById(`${p}stat${p ? cap('Callers') : 'Callers'}`).textContent = data.direct_callers?.length ?? 0;
|
| 913 |
+
document.getElementById(`${p}stat${p ? cap('Callees') : 'Callees'}`).textContent = data.direct_callees?.length ?? 0;
|
| 914 |
+
document.getElementById(`${p}stat${p ? cap('Blast') : 'Blast'}`).textContent = data.blast_radius_count ?? 0;
|
| 915 |
+
|
| 916 |
+
// Helpers
|
| 917 |
+
const symItem = sym => {
|
| 918 |
+
const parts = sym.split(':');
|
| 919 |
+
return `<div class="sym-item">
|
| 920 |
+
<span class="sym-file">${parts[0]}</span>
|
| 921 |
+
<span class="sym-dot">:</span>
|
| 922 |
+
<span class="sym-name">${parts[1] || sym}</span>
|
| 923 |
+
</div>`;
|
| 924 |
+
};
|
| 925 |
+
|
| 926 |
+
const listEl = (id, items) => {
|
| 927 |
+
const el = document.getElementById(id);
|
| 928 |
+
el.innerHTML = items?.length
|
| 929 |
+
? items.map(symItem).join('')
|
| 930 |
+
: '<div style="color:var(--muted);font-size:12px;padding:8px">(none found)</div>';
|
| 931 |
+
};
|
| 932 |
+
|
| 933 |
+
listEl(`${p === 'p' ? 'p' : ''}CallerList`, data.direct_callers);
|
| 934 |
+
listEl(`${p === 'p' ? 'p' : ''}CalleeList`, data.direct_callees);
|
| 935 |
+
|
| 936 |
+
const blastEl = document.getElementById(`${p === 'p' ? 'p' : ''}BlastByFile`);
|
| 937 |
+
const byFile = data.blast_radius_by_file || {};
|
| 938 |
+
if (Object.keys(byFile).length === 0) {
|
| 939 |
+
blastEl.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:8px">(no transitive impact found)</div>';
|
| 940 |
+
} else {
|
| 941 |
+
blastEl.innerHTML = Object.entries(byFile).map(([file, fns]) => `
|
| 942 |
+
<div class="file-group">
|
| 943 |
+
<div class="file-label">📄 ${file} (${fns.length})</div>
|
| 944 |
+
<div class="file-fns">${fns.map(f => `<span class="fn-chip">${f}</span>`).join('')}</div>
|
| 945 |
+
</div>
|
| 946 |
+
`).join('');
|
| 947 |
+
}
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
// Fix the IDs for paste tab
|
| 951 |
+
function renderBlastResults(data, tab) {
|
| 952 |
+
if (tab === 'upload') {
|
| 953 |
+
document.getElementById('statCallers').textContent = data.direct_callers?.length ?? 0;
|
| 954 |
+
document.getElementById('statCallees').textContent = data.direct_callees?.length ?? 0;
|
| 955 |
+
document.getElementById('statBlast').textContent = data.blast_radius_count ?? 0;
|
| 956 |
+
_renderList('callerList', data.direct_callers);
|
| 957 |
+
_renderList('calleeList', data.direct_callees);
|
| 958 |
+
_renderByFile('blastByFile', data.blast_radius_by_file);
|
| 959 |
+
} else {
|
| 960 |
+
document.getElementById('pStatCallers').textContent = data.direct_callers?.length ?? 0;
|
| 961 |
+
document.getElementById('pStatCallees').textContent = data.direct_callees?.length ?? 0;
|
| 962 |
+
document.getElementById('pStatBlast').textContent = data.blast_radius_count ?? 0;
|
| 963 |
+
_renderList('pCallerList', data.direct_callers);
|
| 964 |
+
_renderList('pCalleeList', data.direct_callees);
|
| 965 |
+
_renderByFile('pBlastByFile', data.blast_radius_by_file);
|
| 966 |
+
}
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
function _renderList(id, items) {
|
| 970 |
+
const el = document.getElementById(id);
|
| 971 |
+
el.innerHTML = items?.length
|
| 972 |
+
? items.map(sym => {
|
| 973 |
+
const parts = sym.split(':');
|
| 974 |
+
return `<div class="sym-item">
|
| 975 |
+
<span class="sym-file">${parts[0]}</span>
|
| 976 |
+
<span class="sym-dot">:</span>
|
| 977 |
+
<span class="sym-name">${parts[1] || sym}</span>
|
| 978 |
+
</div>`;
|
| 979 |
+
}).join('')
|
| 980 |
+
: '<div style="color:var(--muted);font-size:12px;padding:8px">(none found)</div>';
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
function _renderByFile(id, byFile) {
|
| 984 |
+
const el = document.getElementById(id);
|
| 985 |
+
const obj = byFile || {};
|
| 986 |
+
if (!Object.keys(obj).length) {
|
| 987 |
+
el.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:8px">(no transitive impact)</div>';
|
| 988 |
+
return;
|
| 989 |
+
}
|
| 990 |
+
el.innerHTML = Object.entries(obj).map(([file, fns]) => `
|
| 991 |
+
<div class="file-group">
|
| 992 |
+
<div class="file-label">📄 ${file} (${fns.length})</div>
|
| 993 |
+
<div class="file-fns">${fns.map(f => `<span class="fn-chip">${f}</span>`).join('')}</div>
|
| 994 |
+
</div>
|
| 995 |
+
`).join('');
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
// ── Get LLM context ────────────────────────────────────────────────────────────
|
| 999 |
+
async function getContext() {
|
| 1000 |
+
const symbol = document.getElementById('selectedSym').value.trim();
|
| 1001 |
+
if (!symbol || !repoId) return;
|
| 1002 |
+
|
| 1003 |
+
const btn = event.target;
|
| 1004 |
+
btn.textContent = '⏳ Compiling...';
|
| 1005 |
+
btn.disabled = true;
|
| 1006 |
+
|
| 1007 |
+
try {
|
| 1008 |
+
const r = await fetch(`${API}/compile`, {
|
| 1009 |
+
method: 'POST',
|
| 1010 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1011 |
+
body: JSON.stringify({ repo_id: repoId, symbol, max_tokens: 8000 }),
|
| 1012 |
+
});
|
| 1013 |
+
const data = await r.json();
|
| 1014 |
+
|
| 1015 |
+
document.getElementById('contextText').textContent = data.context_text || JSON.stringify(data, null, 2);
|
| 1016 |
+
document.getElementById('contextOut').style.display = 'block';
|
| 1017 |
+
} catch (e) {
|
| 1018 |
+
alert('Could not compile context. Is the server running?');
|
| 1019 |
+
} finally {
|
| 1020 |
+
btn.textContent = '📋 Get LLM Context (paste into Claude)';
|
| 1021 |
+
btn.disabled = false;
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
function copyContext() {
|
| 1026 |
+
const text = document.getElementById('contextText').textContent;
|
| 1027 |
+
navigator.clipboard.writeText(text).then(() => {
|
| 1028 |
+
const btn = event.target;
|
| 1029 |
+
btn.textContent = '✓ Copied!';
|
| 1030 |
+
setTimeout(() => btn.textContent = 'Copy', 1500);
|
| 1031 |
+
});
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
// ── Paste tab ─────────────────────────────────────────────────────────────────
|
| 1035 |
+
function addCodeFile() {
|
| 1036 |
+
const container = document.getElementById('codeFiles');
|
| 1037 |
+
const idx = codeFileCount++;
|
| 1038 |
+
const row = document.createElement('div');
|
| 1039 |
+
row.className = 'code-file-row';
|
| 1040 |
+
row.id = `codeRow${idx}`;
|
| 1041 |
+
row.innerHTML = `
|
| 1042 |
+
<input class="code-file-name" type="text" value="file${idx}.py" placeholder="filename.py">
|
| 1043 |
+
<textarea style="flex:1;height:140px;font-size:12px" placeholder="def my_function(): ..."></textarea>
|
| 1044 |
+
<button class="code-remove" onclick="removeCodeFile(${idx})">✕</button>
|
| 1045 |
+
`;
|
| 1046 |
+
container.appendChild(row);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
function removeCodeFile(idx) {
|
| 1050 |
+
const row = document.getElementById(`codeRow${idx}`);
|
| 1051 |
+
if (row) row.remove();
|
| 1052 |
+
}
|
| 1053 |
+
|
| 1054 |
+
async function runPasteBlast() {
|
| 1055 |
+
const symbol = document.getElementById('pasteSymbol').value.trim();
|
| 1056 |
+
if (!symbol) { setStatus('pasteStatus', 'Enter a function name like ./main.py:greet', 'error'); return; }
|
| 1057 |
+
|
| 1058 |
+
const files = {};
|
| 1059 |
+
document.querySelectorAll('.code-file-row').forEach(row => {
|
| 1060 |
+
const name = row.querySelector('input').value.trim();
|
| 1061 |
+
const code = row.querySelector('textarea').value.trim();
|
| 1062 |
+
if (name && code) files[name] = code;
|
| 1063 |
+
});
|
| 1064 |
+
|
| 1065 |
+
if (!Object.keys(files).length) {
|
| 1066 |
+
setStatus('pasteStatus', 'Paste some Python code first.', 'error');
|
| 1067 |
+
return;
|
| 1068 |
+
}
|
| 1069 |
+
|
| 1070 |
+
setStatus('pasteStatus', '⏳ Analysing...', 'loading');
|
| 1071 |
+
|
| 1072 |
+
try {
|
| 1073 |
+
const r = await fetch(`${API}/inline`, {
|
| 1074 |
+
method: 'POST',
|
| 1075 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1076 |
+
body: JSON.stringify({ files, symbol }),
|
| 1077 |
+
});
|
| 1078 |
+
const data = await r.json();
|
| 1079 |
+
|
| 1080 |
+
clearStatus('pasteStatus');
|
| 1081 |
+
|
| 1082 |
+
if (data.error) {
|
| 1083 |
+
setStatus('pasteStatus', data.error + (data.suggestions ? ' Try: ' + data.suggestions.join(', ') : ''), 'error');
|
| 1084 |
+
return;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
renderBlastResults(data, 'paste');
|
| 1088 |
+
document.getElementById('pasteEmpty').style.display = 'none';
|
| 1089 |
+
document.getElementById('pasteResultArea').classList.add('visible');
|
| 1090 |
+
|
| 1091 |
+
} catch (e) {
|
| 1092 |
+
setStatus('pasteStatus', 'Could not reach server. Is it running on localhost:8000?', 'error');
|
| 1093 |
+
}
|
| 1094 |
+
}
|
| 1095 |
+
</script>
|
| 1096 |
+
</body>
|
| 1097 |
+
</html>
|
diffcontext-service/mcp/mcp_server.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DiffContext MCP Server Wrapper
|
| 3 |
+
Forwards execution to the package-level mcp_server module.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Add the project root to sys.path so we can import diffcontext
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
from diffcontext.mcp_server import main
|
| 14 |
+
except ImportError as e:
|
| 15 |
+
print(f"Error: Could not import diffcontext.mcp_server: {e}", file=sys.stderr)
|
| 16 |
+
sys.exit(1)
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
main()
|
diffcontext/__init__.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DiffContext — static-analysis-powered repository context compiler for LLMs.
|
| 3 |
+
|
| 4 |
+
Converts code changes into dependency-aware, blast-radius-aware context
|
| 5 |
+
packages, enabling far more accurate code understanding than keyword search
|
| 6 |
+
or traditional RAG.
|
| 7 |
+
|
| 8 |
+
Usage as a library:
|
| 9 |
+
|
| 10 |
+
from diffcontext import blast_radius, index, diff, compile_context
|
| 11 |
+
|
| 12 |
+
# Get blast radius for a symbol
|
| 13 |
+
result = blast_radius("./auth.py:validate_jwt", repo="/path/to/project")
|
| 14 |
+
print(result.callers) # who calls this?
|
| 15 |
+
print(result.dependencies) # what does this call?
|
| 16 |
+
print(result.total_affected) # total transitive impact
|
| 17 |
+
|
| 18 |
+
# Auto-detect changes and get blast radius
|
| 19 |
+
result = blast_radius(ref="HEAD~1", repo="/path/to/project")
|
| 20 |
+
|
| 21 |
+
# Index a repository
|
| 22 |
+
idx = index("/path/to/project")
|
| 23 |
+
print(idx.symbols) # all parsed symbols
|
| 24 |
+
print(idx.graph) # call graph
|
| 25 |
+
|
| 26 |
+
# Find changed symbols from git diff
|
| 27 |
+
changed = diff(repo="/path/to/project", ref="HEAD~1")
|
| 28 |
+
|
| 29 |
+
# Full context compilation for LLMs
|
| 30 |
+
ctx = compile_context(ref="HEAD~1", repo="/path/to/project")
|
| 31 |
+
print(ctx.text) # LLM-ready context
|
| 32 |
+
print(ctx.reduction_pct) # how much code was filtered out
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
__version__ = "0.2.0"
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
from dataclasses import dataclass, field
|
| 39 |
+
from typing import Dict, List, Optional
|
| 40 |
+
|
| 41 |
+
from .pipeline import index_repository, analyze_impact
|
| 42 |
+
from .pipeline import compile as _compile_pipeline
|
| 43 |
+
from .diff.git_diff import find_changed_symbols
|
| 44 |
+
from .impact.blast_radius import get_blast_radius
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Public data classes
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class BlastResult:
|
| 53 |
+
"""Result of a blast radius analysis — the public API return type."""
|
| 54 |
+
changed: List[str]
|
| 55 |
+
callers: List[str]
|
| 56 |
+
dependencies: List[str]
|
| 57 |
+
total_affected: int
|
| 58 |
+
scores: Dict[str, float] = field(default_factory=dict)
|
| 59 |
+
graph: Dict[str, List[str]] = field(default_factory=dict)
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def affected_files(self) -> List[str]:
|
| 63 |
+
"""Unique files in the blast radius."""
|
| 64 |
+
files = set()
|
| 65 |
+
for sym in self.callers:
|
| 66 |
+
parts = sym.split(":", 1)
|
| 67 |
+
if len(parts) == 2:
|
| 68 |
+
files.add(parts[0])
|
| 69 |
+
return sorted(files)
|
| 70 |
+
|
| 71 |
+
def __repr__(self):
|
| 72 |
+
return (
|
| 73 |
+
f"BlastResult(changed={len(self.changed)}, "
|
| 74 |
+
f"callers={len(self.callers)}, "
|
| 75 |
+
f"dependencies={len(self.dependencies)}, "
|
| 76 |
+
f"total_affected={self.total_affected})"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
# Public API
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
|
| 84 |
+
def blast_radius(
|
| 85 |
+
symbol: Optional[str] = None,
|
| 86 |
+
*,
|
| 87 |
+
ref: Optional[str] = None,
|
| 88 |
+
repo: str = ".",
|
| 89 |
+
depth: int = 3,
|
| 90 |
+
) -> BlastResult:
|
| 91 |
+
"""
|
| 92 |
+
Compute the blast radius of a change.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
symbol: Symbol ID like "./auth.py:validate_jwt". If None, uses `ref`.
|
| 96 |
+
ref: Git ref to auto-detect changes (e.g. "HEAD~1").
|
| 97 |
+
repo: Path to the repository root.
|
| 98 |
+
depth: Max traversal depth.
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
BlastResult with callers, dependencies, scores, etc.
|
| 102 |
+
|
| 103 |
+
Examples:
|
| 104 |
+
>>> from diffcontext import blast_radius
|
| 105 |
+
>>> r = blast_radius("./auth.py:validate_jwt", repo="/path/to/project")
|
| 106 |
+
>>> r = blast_radius(ref="HEAD~1", repo="/path/to/project")
|
| 107 |
+
"""
|
| 108 |
+
idx = index_repository(repo)
|
| 109 |
+
|
| 110 |
+
# Determine changed symbols
|
| 111 |
+
if symbol:
|
| 112 |
+
changed = [symbol]
|
| 113 |
+
elif ref:
|
| 114 |
+
changed = find_changed_symbols(repo, idx.symbols, ref=ref)
|
| 115 |
+
else:
|
| 116 |
+
changed = find_changed_symbols(repo, idx.symbols, ref="HEAD~1")
|
| 117 |
+
|
| 118 |
+
if not changed:
|
| 119 |
+
return BlastResult(
|
| 120 |
+
changed=[], callers=[], dependencies=[],
|
| 121 |
+
total_affected=0, scores={}, graph=idx.graph,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
impact = analyze_impact(idx, changed, max_depth=depth)
|
| 125 |
+
|
| 126 |
+
return BlastResult(
|
| 127 |
+
changed=impact.changed,
|
| 128 |
+
callers=impact.blast_radius,
|
| 129 |
+
dependencies=impact.dependencies,
|
| 130 |
+
total_affected=len(impact.all_relevant),
|
| 131 |
+
scores=impact.scores,
|
| 132 |
+
graph=idx.graph,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def index(repo: str = "."):
|
| 137 |
+
"""
|
| 138 |
+
Index a repository: parse all Python files and build the call graph.
|
| 139 |
+
|
| 140 |
+
Returns a RepositoryIndex with .symbols and .graph attributes.
|
| 141 |
+
|
| 142 |
+
Example:
|
| 143 |
+
>>> from diffcontext import index
|
| 144 |
+
>>> idx = index("/path/to/project")
|
| 145 |
+
>>> len(idx.symbols)
|
| 146 |
+
354
|
| 147 |
+
"""
|
| 148 |
+
return index_repository(repo)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def diff(repo: str = ".", ref: str = "HEAD~1") -> List[str]:
|
| 152 |
+
"""
|
| 153 |
+
Find changed symbol IDs from git diff.
|
| 154 |
+
|
| 155 |
+
Returns list of symbol IDs that were modified.
|
| 156 |
+
|
| 157 |
+
Example:
|
| 158 |
+
>>> from diffcontext import diff
|
| 159 |
+
>>> diff(repo="/path/to/project", ref="HEAD~1")
|
| 160 |
+
['./auth.py:validate_jwt', './models.py:User.__init__']
|
| 161 |
+
"""
|
| 162 |
+
idx = index_repository(repo)
|
| 163 |
+
return find_changed_symbols(repo, idx.symbols, ref=ref)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def compile_context(
|
| 167 |
+
symbol: Optional[str] = None,
|
| 168 |
+
*,
|
| 169 |
+
ref: Optional[str] = None,
|
| 170 |
+
repo: str = ".",
|
| 171 |
+
depth: int = 2,
|
| 172 |
+
max_tokens: int = 10000,
|
| 173 |
+
):
|
| 174 |
+
"""
|
| 175 |
+
Full pipeline: detect changes → blast radius → compile LLM context.
|
| 176 |
+
|
| 177 |
+
Returns a ContextPackage with .text, .token_estimate, .reduction_pct.
|
| 178 |
+
|
| 179 |
+
Example:
|
| 180 |
+
>>> from diffcontext import compile_context
|
| 181 |
+
>>> ctx = compile_context(ref="HEAD~1", repo="/path/to/project")
|
| 182 |
+
>>> print(ctx.text) # LLM-ready context
|
| 183 |
+
>>> print(ctx.reduction_pct) # e.g. 99.2
|
| 184 |
+
"""
|
| 185 |
+
idx = index_repository(repo)
|
| 186 |
+
|
| 187 |
+
if symbol:
|
| 188 |
+
changed = [symbol]
|
| 189 |
+
elif ref:
|
| 190 |
+
changed = find_changed_symbols(repo, idx.symbols, ref=ref)
|
| 191 |
+
else:
|
| 192 |
+
changed = find_changed_symbols(repo, idx.symbols, ref="HEAD~1")
|
| 193 |
+
|
| 194 |
+
if not changed:
|
| 195 |
+
from .models import ContextPackage
|
| 196 |
+
return ContextPackage(text="", symbol_count=0, token_estimate=0, total_repo_tokens=0)
|
| 197 |
+
|
| 198 |
+
impact = analyze_impact(idx, changed, max_depth=depth)
|
| 199 |
+
return _compile_pipeline(idx, impact, max_tokens=max_tokens)
|
diffcontext/_warn_once.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
_warn_once.py — De-duplicate repeated warnings for the same file.
|
| 3 |
+
|
| 4 |
+
parser.py, graph_builder.py, and resolver.py each independently call
|
| 5 |
+
ast.parse() on every file. When a file has a syntax error, all three would
|
| 6 |
+
otherwise log their own identical warning. This module tracks which files
|
| 7 |
+
have already been warned about (per process run) so only the first one
|
| 8 |
+
actually prints.
|
| 9 |
+
|
| 10 |
+
Also covers invalid-UTF-8 source files: these are read with
|
| 11 |
+
errors="ignore", which silently DROPS any byte that isn't valid UTF-8 with
|
| 12 |
+
no warning, anywhere. A dropped byte inside a string literal corrupts that
|
| 13 |
+
literal's contents (e.g. "Café Menu" -> "Caf Menu") without raising any
|
| 14 |
+
error or appearing in any log -- the file still parses fine, the symbol
|
| 15 |
+
still extracts fine, the code text is just silently wrong. warn_encoding_
|
| 16 |
+
issue_once exists so this corruption is at least visible once per file.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import os
|
| 21 |
+
import threading
|
| 22 |
+
from typing import Set
|
| 23 |
+
|
| 24 |
+
_warned_files: Set[str] = set()
|
| 25 |
+
_warned_encoding_files: Set[str] = set()
|
| 26 |
+
_lock = threading.Lock()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def warn_syntax_error_once(logger: logging.Logger, filename: str, exc: SyntaxError) -> None:
|
| 30 |
+
"""Log a SyntaxError warning for `filename`, but only the first time it's seen."""
|
| 31 |
+
key = os.path.abspath(filename)
|
| 32 |
+
with _lock:
|
| 33 |
+
if key in _warned_files:
|
| 34 |
+
return
|
| 35 |
+
_warned_files.add(key)
|
| 36 |
+
|
| 37 |
+
logger.warning(
|
| 38 |
+
"\033[93mSkipping %s due to SyntaxError: %s (line %s)\033[0m",
|
| 39 |
+
os.path.basename(filename), exc.msg, exc.lineno,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def check_and_warn_encoding(logger: logging.Logger, filename: str, raw_bytes: bytes) -> None:
|
| 44 |
+
"""
|
| 45 |
+
Check whether `raw_bytes` is valid UTF-8. If not, warn once per file --
|
| 46 |
+
the caller will go on to decode with errors="ignore", which silently
|
| 47 |
+
drops the offending bytes (and anything that decoded incorrectly around
|
| 48 |
+
them). This doesn't stop processing; it just makes the data loss
|
| 49 |
+
visible instead of completely silent.
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
raw_bytes.decode("utf-8")
|
| 53 |
+
return
|
| 54 |
+
except UnicodeDecodeError as e:
|
| 55 |
+
key = os.path.abspath(filename)
|
| 56 |
+
with _lock:
|
| 57 |
+
if key in _warned_encoding_files:
|
| 58 |
+
return
|
| 59 |
+
_warned_encoding_files.add(key)
|
| 60 |
+
|
| 61 |
+
logger.warning(
|
| 62 |
+
"\033[93m%s is not valid UTF-8 (%s at byte %d) -- invalid bytes "
|
| 63 |
+
"will be silently dropped, which can corrupt string literals "
|
| 64 |
+
"in the extracted code\033[0m",
|
| 65 |
+
os.path.basename(filename), e.reason, e.start,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def reset_warned_files() -> None:
|
| 70 |
+
"""Clear the de-dup caches. Mainly useful for tests."""
|
| 71 |
+
with _lock:
|
| 72 |
+
_warned_files.clear()
|
| 73 |
+
_warned_encoding_files.clear()
|
diffcontext/cache.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache.py — SQLite-backed persistent caching for AST parsed symbols.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import sqlite3
|
| 7 |
+
import os
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
from typing import Dict, Callable
|
| 10 |
+
|
| 11 |
+
from .models import Symbol
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_file_hash(filepath: str) -> str:
|
| 15 |
+
"""Compute SHA-256 hash of a file."""
|
| 16 |
+
hasher = hashlib.sha256()
|
| 17 |
+
with open(filepath, "rb") as f:
|
| 18 |
+
# Python files are small enough to read into memory safely
|
| 19 |
+
hasher.update(f.read())
|
| 20 |
+
return hasher.hexdigest()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class SymbolCache:
|
| 24 |
+
"""Persistent SQLite cache for parsed AST symbols."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, db_path: str = ".diffcontext_cache.db"):
|
| 27 |
+
self.db_path = db_path
|
| 28 |
+
self._conn = None
|
| 29 |
+
self._connect()
|
| 30 |
+
|
| 31 |
+
def _connect(self):
|
| 32 |
+
if self._conn is None:
|
| 33 |
+
self._conn = sqlite3.connect(self.db_path)
|
| 34 |
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
| 35 |
+
self._conn.execute("PRAGMA foreign_keys=ON")
|
| 36 |
+
self._init_db()
|
| 37 |
+
|
| 38 |
+
def close(self):
|
| 39 |
+
if self._conn:
|
| 40 |
+
self._conn.close()
|
| 41 |
+
self._conn = None
|
| 42 |
+
|
| 43 |
+
def __enter__(self):
|
| 44 |
+
return self
|
| 45 |
+
|
| 46 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 47 |
+
self.close()
|
| 48 |
+
|
| 49 |
+
def _init_db(self):
|
| 50 |
+
with self._conn:
|
| 51 |
+
self._conn.executescript('''
|
| 52 |
+
CREATE TABLE IF NOT EXISTS files (
|
| 53 |
+
file_path TEXT PRIMARY KEY,
|
| 54 |
+
file_hash TEXT NOT NULL
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
CREATE TABLE IF NOT EXISTS symbols (
|
| 58 |
+
id TEXT PRIMARY KEY,
|
| 59 |
+
file_path TEXT NOT NULL,
|
| 60 |
+
name TEXT NOT NULL,
|
| 61 |
+
code TEXT NOT NULL,
|
| 62 |
+
lineno INTEGER,
|
| 63 |
+
FOREIGN KEY(file_path) REFERENCES files(file_path) ON DELETE CASCADE
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
|
| 67 |
+
''')
|
| 68 |
+
|
| 69 |
+
def get_or_parse(self, filepath: str, parse_fn: Callable[[str], Dict[str, Symbol]]) -> Dict[str, Symbol]:
|
| 70 |
+
"""
|
| 71 |
+
Return cached symbols if file hash matches, otherwise parse and persist.
|
| 72 |
+
"""
|
| 73 |
+
file_hash = get_file_hash(filepath)
|
| 74 |
+
|
| 75 |
+
cursor = self._conn.execute("SELECT file_hash FROM files WHERE file_path = ?", (filepath,))
|
| 76 |
+
row = cursor.fetchone()
|
| 77 |
+
|
| 78 |
+
if row and row[0] == file_hash:
|
| 79 |
+
# Cache hit!
|
| 80 |
+
cursor = self._conn.execute(
|
| 81 |
+
"SELECT id, file_path, name, code, lineno FROM symbols WHERE file_path = ?",
|
| 82 |
+
(filepath,)
|
| 83 |
+
)
|
| 84 |
+
symbols = {}
|
| 85 |
+
for row in cursor:
|
| 86 |
+
sym_id, f_path, name, code, lineno = row
|
| 87 |
+
symbols[sym_id] = Symbol(
|
| 88 |
+
id=sym_id,
|
| 89 |
+
file=f_path,
|
| 90 |
+
name=name,
|
| 91 |
+
code=code,
|
| 92 |
+
lineno=lineno
|
| 93 |
+
)
|
| 94 |
+
return symbols
|
| 95 |
+
|
| 96 |
+
# Cache miss or hash mismatch -> parse it
|
| 97 |
+
symbols = parse_fn(filepath)
|
| 98 |
+
|
| 99 |
+
# Persist the new state
|
| 100 |
+
with self._conn:
|
| 101 |
+
# DELETE CASCADE will drop all existing symbols for this file
|
| 102 |
+
self._conn.execute("DELETE FROM files WHERE file_path = ?", (filepath,))
|
| 103 |
+
|
| 104 |
+
self._conn.execute(
|
| 105 |
+
"INSERT INTO files (file_path, file_hash) VALUES (?, ?)",
|
| 106 |
+
(filepath, file_hash)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
if symbols:
|
| 110 |
+
self._conn.executemany(
|
| 111 |
+
"INSERT INTO symbols (id, file_path, name, code, lineno) VALUES (?, ?, ?, ?, ?)",
|
| 112 |
+
[(s.id, s.file, s.name, s.code, s.lineno) for s in symbols.values()]
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
return symbols
|
diffcontext/cli/__init__.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cli/main.py — DiffContext command-line interface.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
diffcontext index .
|
| 6 |
+
diffcontext impact auth.py:validate_jwt
|
| 7 |
+
diffcontext diff HEAD~1
|
| 8 |
+
diffcontext compile --changed ./api.py:get_user
|
| 9 |
+
diffcontext blast --ref HEAD~1 # visual blast radius
|
| 10 |
+
diffcontext blast --ref HEAD~1 --verify # with proof chains
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
from ..pipeline import index_repository, analyze_impact, compile, warn_unknown_symbols
|
| 21 |
+
from ..diff.git_diff import find_changed_symbols
|
| 22 |
+
from ..impact.visualizer import render_blast_radius, render_verification
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
# Make sure warnings from anywhere in the pipeline (broken files,
|
| 27 |
+
# invalid encoding, unknown --changed symbols) are actually visible.
|
| 28 |
+
# Without this, they depend on logging's lastResort fallback, which is
|
| 29 |
+
# unreliable -- some warnings showed up by accident, others silently
|
| 30 |
+
# didn't, depending on subtle propagation/level details.
|
| 31 |
+
logging.basicConfig(level=logging.WARNING, format="%(message)s", stream=sys.stderr)
|
| 32 |
+
|
| 33 |
+
parser = argparse.ArgumentParser(
|
| 34 |
+
prog="diffcontext",
|
| 35 |
+
description="Static-analysis-powered repository context compiler for LLMs",
|
| 36 |
+
)
|
| 37 |
+
sub = parser.add_subparsers(dest="command", help="Available commands")
|
| 38 |
+
|
| 39 |
+
# --- index ---
|
| 40 |
+
p_index = sub.add_parser("index", help="Index a repository")
|
| 41 |
+
p_index.add_argument("repo", default=".", nargs="?", help="Path to repository")
|
| 42 |
+
|
| 43 |
+
# --- impact ---
|
| 44 |
+
p_impact = sub.add_parser("impact", help="Analyze impact of a symbol change")
|
| 45 |
+
p_impact.add_argument("symbols", nargs="+", help="Changed symbol IDs (e.g. ./auth.py:validate_jwt)")
|
| 46 |
+
p_impact.add_argument("--repo", default=".", help="Repository path")
|
| 47 |
+
p_impact.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
| 48 |
+
p_impact.add_argument("--tree", action="store_true", help="Show visual blast radius tree")
|
| 49 |
+
p_impact.add_argument("--verify", action="store_true", help="Show proof chains for each edge")
|
| 50 |
+
|
| 51 |
+
# --- diff ---
|
| 52 |
+
p_diff = sub.add_parser("diff", help="Find changed symbols from git diff")
|
| 53 |
+
p_diff.add_argument("ref", default="HEAD~1", nargs="?", help="Git ref to compare against")
|
| 54 |
+
p_diff.add_argument("--repo", default=".", help="Repository path")
|
| 55 |
+
p_diff.add_argument(
|
| 56 |
+
"--committed-only", action="store_true",
|
| 57 |
+
help="Compare two commits only (ref vs HEAD); ignores uncommitted working-tree changes",
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# --- compile ---
|
| 61 |
+
p_compile = sub.add_parser("compile", help="Build LLM context for changes")
|
| 62 |
+
p_compile.add_argument("--changed", nargs="+", help="Changed symbol IDs")
|
| 63 |
+
p_compile.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
| 64 |
+
p_compile.add_argument("--repo", default=".", help="Repository path")
|
| 65 |
+
p_compile.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
| 66 |
+
p_compile.add_argument("--max-tokens", type=int, default=10000, help="Token budget")
|
| 67 |
+
p_compile.add_argument("--notes", type=str, default=None, help="Developer notes to prepend to the context output")
|
| 68 |
+
p_compile.add_argument("--json", action="store_true", help="Output as JSON")
|
| 69 |
+
p_compile.add_argument("--sync", action="store_true", help="Sync output to CtxSync cloud")
|
| 70 |
+
|
| 71 |
+
# --- blast (NEW: visual blast radius) ---
|
| 72 |
+
p_blast = sub.add_parser("blast", help="Visual blast radius analysis")
|
| 73 |
+
p_blast.add_argument("--changed", nargs="+", help="Changed symbol IDs (manual)")
|
| 74 |
+
p_blast.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
| 75 |
+
p_blast.add_argument("--repo", default=".", help="Repository path")
|
| 76 |
+
p_blast.add_argument("--depth", type=int, default=3, help="Max traversal depth for tree")
|
| 77 |
+
p_blast.add_argument("--verify", action="store_true", help="Show proof chains for each edge")
|
| 78 |
+
p_blast.add_argument("--no-color", action="store_true", help="Disable ANSI colors")
|
| 79 |
+
p_blast.add_argument(
|
| 80 |
+
"--committed-only", action="store_true",
|
| 81 |
+
help="Compare two commits only (ref vs HEAD); ignores uncommitted working-tree changes",
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# --- sync (push blast radius to CtxSync cloud) ---
|
| 85 |
+
p_sync = sub.add_parser("sync", help="Compile and push context to CtxSync cloud")
|
| 86 |
+
p_sync.add_argument("--ref", default="HEAD~1", help="Git ref to diff against (default: HEAD~1)")
|
| 87 |
+
p_sync.add_argument("--repo", default=".", help="Repository path")
|
| 88 |
+
p_sync.add_argument("--depth", type=int, default=2, help="Max dependency depth")
|
| 89 |
+
p_sync.add_argument("--max-tokens", type=int, default=10000, help="Token budget")
|
| 90 |
+
p_sync.add_argument("--project", default=None, help="Project name (default: repo directory name)")
|
| 91 |
+
p_sync.add_argument("--url", default=None, help="CtxSync URL (or set CTXSYNC_URL env var)")
|
| 92 |
+
p_sync.add_argument("--key", default=None, help="CtxSync API key (or set CTXSYNC_KEY env var)")
|
| 93 |
+
|
| 94 |
+
args = parser.parse_args()
|
| 95 |
+
|
| 96 |
+
if args.command is None:
|
| 97 |
+
parser.print_help()
|
| 98 |
+
sys.exit(1)
|
| 99 |
+
|
| 100 |
+
if args.command == "index":
|
| 101 |
+
_cmd_index(args)
|
| 102 |
+
elif args.command == "impact":
|
| 103 |
+
_cmd_impact(args)
|
| 104 |
+
elif args.command == "diff":
|
| 105 |
+
_cmd_diff(args)
|
| 106 |
+
elif args.command == "compile":
|
| 107 |
+
_cmd_compile(args)
|
| 108 |
+
elif args.command == "blast":
|
| 109 |
+
_cmd_blast(args)
|
| 110 |
+
elif args.command == "sync":
|
| 111 |
+
_cmd_sync(args)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _cmd_index(args):
|
| 115 |
+
"""Index repository: show stats."""
|
| 116 |
+
t0 = time.perf_counter()
|
| 117 |
+
idx = index_repository(args.repo)
|
| 118 |
+
elapsed = (time.perf_counter() - t0) * 1000
|
| 119 |
+
|
| 120 |
+
print(f"Symbols : {len(idx.symbols)}")
|
| 121 |
+
print(f"Edges : {idx.total_edges}")
|
| 122 |
+
print(f"Time : {elapsed:.0f}ms")
|
| 123 |
+
|
| 124 |
+
# Show top-level breakdown
|
| 125 |
+
files = set()
|
| 126 |
+
for sym in idx.symbols.values():
|
| 127 |
+
files.add(sym.file)
|
| 128 |
+
print(f"Files : {len(files)}")
|
| 129 |
+
|
| 130 |
+
if idx.broken_files:
|
| 131 |
+
print(f"Broken : {len(idx.broken_files)} file(s) failed to parse (see warnings above)")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _cmd_impact(args):
|
| 135 |
+
"""Analyze impact of specific symbol changes."""
|
| 136 |
+
idx = index_repository(args.repo)
|
| 137 |
+
impact = analyze_impact(idx, args.symbols, max_depth=args.depth)
|
| 138 |
+
|
| 139 |
+
if getattr(args, 'tree', False) or getattr(args, 'verify', False):
|
| 140 |
+
# Visual tree mode
|
| 141 |
+
output = render_blast_radius(
|
| 142 |
+
idx.graph, args.symbols, idx.symbols,
|
| 143 |
+
max_depth=args.depth,
|
| 144 |
+
show_proof=getattr(args, 'verify', False),
|
| 145 |
+
repo_path=os.path.abspath(args.repo),
|
| 146 |
+
)
|
| 147 |
+
print(output)
|
| 148 |
+
|
| 149 |
+
if getattr(args, 'verify', False):
|
| 150 |
+
verification = render_verification(
|
| 151 |
+
idx.graph, args.symbols, idx.symbols,
|
| 152 |
+
)
|
| 153 |
+
print(verification)
|
| 154 |
+
else:
|
| 155 |
+
# Original text mode
|
| 156 |
+
print(f"\nChanged: {impact.changed}")
|
| 157 |
+
print(f"\nBlast radius ({len(impact.blast_radius)}):")
|
| 158 |
+
for sym in impact.blast_radius[:20]:
|
| 159 |
+
score = impact.scores.get(sym, 0)
|
| 160 |
+
print(f" {sym} (score: {score:.0f})")
|
| 161 |
+
|
| 162 |
+
print(f"\nTotal impacted: {len(impact.all_relevant)}")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _print_broken_files(idx, broken_patches):
|
| 166 |
+
"""Shared helper: print patch text for any files that failed to parse."""
|
| 167 |
+
if not idx.broken_files:
|
| 168 |
+
return
|
| 169 |
+
|
| 170 |
+
print(f"\n⚠ {len(idx.broken_files)} file(s) failed to parse and could not be fully analyzed:")
|
| 171 |
+
for f in idx.broken_files:
|
| 172 |
+
print(f"\n--- {f} ---")
|
| 173 |
+
patch = broken_patches.get(f)
|
| 174 |
+
if patch:
|
| 175 |
+
print(patch.rstrip("\n"))
|
| 176 |
+
else:
|
| 177 |
+
print(" (no patch text available -- file may be new/untracked)")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _cmd_diff(args):
|
| 181 |
+
"""Find changed symbols from git diff."""
|
| 182 |
+
idx = index_repository(args.repo)
|
| 183 |
+
|
| 184 |
+
against = "HEAD" if args.committed_only else None
|
| 185 |
+
broken_patches = {}
|
| 186 |
+
changed = find_changed_symbols(
|
| 187 |
+
args.repo, idx.symbols, ref=args.ref, against=against,
|
| 188 |
+
broken_files=idx.broken_files,
|
| 189 |
+
broken_file_patches=broken_patches,
|
| 190 |
+
known_broken_files=idx.broken_files,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
if not changed:
|
| 194 |
+
print("No changed symbols found.")
|
| 195 |
+
_print_broken_files(idx, broken_patches)
|
| 196 |
+
return
|
| 197 |
+
|
| 198 |
+
print(f"Changed symbols ({len(changed)}):")
|
| 199 |
+
for sym_id in changed:
|
| 200 |
+
print(f" {sym_id}")
|
| 201 |
+
|
| 202 |
+
_print_broken_files(idx, broken_patches)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _cmd_compile(args):
|
| 206 |
+
"""Build full context package."""
|
| 207 |
+
idx = index_repository(args.repo)
|
| 208 |
+
|
| 209 |
+
# Determine changed symbols
|
| 210 |
+
if args.changed:
|
| 211 |
+
changed = args.changed
|
| 212 |
+
elif args.ref:
|
| 213 |
+
changed = find_changed_symbols(
|
| 214 |
+
args.repo, idx.symbols, ref=args.ref,
|
| 215 |
+
broken_files=idx.broken_files,
|
| 216 |
+
known_broken_files=idx.broken_files,
|
| 217 |
+
)
|
| 218 |
+
else:
|
| 219 |
+
print("Error: provide --changed or --ref", file=sys.stderr)
|
| 220 |
+
sys.exit(1)
|
| 221 |
+
|
| 222 |
+
if not changed:
|
| 223 |
+
print("No changes detected.")
|
| 224 |
+
return
|
| 225 |
+
|
| 226 |
+
impact = analyze_impact(idx, changed, max_depth=args.depth)
|
| 227 |
+
max_tokens = args.max_tokens if args.max_tokens > 0 else None
|
| 228 |
+
ctx = compile(idx, impact, max_tokens=max_tokens, notes=args.notes)
|
| 229 |
+
|
| 230 |
+
if args.json:
|
| 231 |
+
result = {
|
| 232 |
+
"symbol_count": ctx.symbol_count,
|
| 233 |
+
"token_estimate": ctx.token_estimate,
|
| 234 |
+
"total_repo_tokens": ctx.total_repo_tokens,
|
| 235 |
+
"reduction_pct": round(ctx.reduction_pct, 2),
|
| 236 |
+
"context": ctx.text,
|
| 237 |
+
}
|
| 238 |
+
print(json.dumps(result, indent=2))
|
| 239 |
+
elif getattr(args, "sync", False):
|
| 240 |
+
import urllib.request
|
| 241 |
+
import urllib.error
|
| 242 |
+
url = os.environ.get("CTXSYNC_URL")
|
| 243 |
+
key = os.environ.get("CTXSYNC_KEY")
|
| 244 |
+
if not url or not key:
|
| 245 |
+
print("Error: CTXSYNC_URL and CTXSYNC_KEY environment variables must be set.", file=sys.stderr)
|
| 246 |
+
sys.exit(1)
|
| 247 |
+
|
| 248 |
+
url_event = url.rstrip("/") + "/event"
|
| 249 |
+
url_switch = url.rstrip("/") + "/project/switch"
|
| 250 |
+
project_name = os.path.basename(os.path.abspath(args.repo))
|
| 251 |
+
|
| 252 |
+
# Switch active project first
|
| 253 |
+
try:
|
| 254 |
+
req_switch = urllib.request.Request(url_switch, data=json.dumps({"name": project_name}).encode("utf-8"), headers={
|
| 255 |
+
"Authorization": f"Bearer {key}",
|
| 256 |
+
"Content-Type": "application/json",
|
| 257 |
+
"User-Agent": "CtxSync-DiffContext/1.0"
|
| 258 |
+
})
|
| 259 |
+
urllib.request.urlopen(req_switch)
|
| 260 |
+
except Exception:
|
| 261 |
+
pass
|
| 262 |
+
|
| 263 |
+
data = json.dumps({
|
| 264 |
+
"type": "diffcontext_update",
|
| 265 |
+
"text": ctx.text,
|
| 266 |
+
"project": project_name
|
| 267 |
+
}).encode("utf-8")
|
| 268 |
+
req = urllib.request.Request(url_event, data=data, headers={
|
| 269 |
+
"Authorization": f"Bearer {key}",
|
| 270 |
+
"Content-Type": "application/json",
|
| 271 |
+
"User-Agent": "CtxSync-DiffContext/1.0"
|
| 272 |
+
})
|
| 273 |
+
try:
|
| 274 |
+
with urllib.request.urlopen(req) as res:
|
| 275 |
+
response_body = res.read().decode("utf-8")
|
| 276 |
+
print(f"☁️ Automatically synced blast radius to CtxSync Cloud!\nResponse: {response_body}")
|
| 277 |
+
except urllib.error.URLError as e:
|
| 278 |
+
print(f"Error syncing to CtxSync: {e}", file=sys.stderr)
|
| 279 |
+
sys.exit(1)
|
| 280 |
+
else:
|
| 281 |
+
print(ctx.text)
|
| 282 |
+
print(f"\n--- Stats ---")
|
| 283 |
+
print(f"Symbols : {ctx.symbol_count}")
|
| 284 |
+
print(f"Tokens : {ctx.token_estimate:,} / {ctx.total_repo_tokens:,}")
|
| 285 |
+
print(f"Reduction: {ctx.reduction_pct:.1f}%")
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _cmd_blast(args):
|
| 289 |
+
"""Visual blast radius analysis."""
|
| 290 |
+
t0 = time.perf_counter()
|
| 291 |
+
idx = index_repository(args.repo)
|
| 292 |
+
index_ms = (time.perf_counter() - t0) * 1000
|
| 293 |
+
|
| 294 |
+
against = "HEAD" if getattr(args, "committed_only", False) else None
|
| 295 |
+
broken_patches = {}
|
| 296 |
+
|
| 297 |
+
# Determine changed symbols
|
| 298 |
+
if args.changed:
|
| 299 |
+
changed = args.changed
|
| 300 |
+
elif args.ref:
|
| 301 |
+
changed = find_changed_symbols(
|
| 302 |
+
args.repo, idx.symbols, ref=args.ref, against=against,
|
| 303 |
+
broken_files=idx.broken_files, broken_file_patches=broken_patches,
|
| 304 |
+
known_broken_files=idx.broken_files,
|
| 305 |
+
)
|
| 306 |
+
else:
|
| 307 |
+
# Default: compare against HEAD~1
|
| 308 |
+
changed = find_changed_symbols(
|
| 309 |
+
args.repo, idx.symbols, ref="HEAD~1", against=against,
|
| 310 |
+
broken_files=idx.broken_files, broken_file_patches=broken_patches,
|
| 311 |
+
known_broken_files=idx.broken_files,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
if not changed:
|
| 315 |
+
print("No changed symbols detected.")
|
| 316 |
+
print(" Tip: make a Python change and commit it, or use --changed <symbol_id>")
|
| 317 |
+
print(f" Available symbols: {len(idx.symbols)} (use 'diffcontext index' to see stats)")
|
| 318 |
+
_print_broken_files(idx, broken_patches)
|
| 319 |
+
return
|
| 320 |
+
|
| 321 |
+
# blast renders directly from idx.graph and never calls analyze_impact,
|
| 322 |
+
# so it needs its own unknown-symbol check (typo'd --changed, renamed/
|
| 323 |
+
# deleted symbol) -- otherwise a typo silently renders as "0 impact"
|
| 324 |
+
# indistinguishable from a real, genuinely-isolated symbol.
|
| 325 |
+
warn_unknown_symbols(idx, changed)
|
| 326 |
+
|
| 327 |
+
# Strip ANSI if --no-color
|
| 328 |
+
if args.no_color:
|
| 329 |
+
from ..impact import visualizer
|
| 330 |
+
visualizer._C.RED = ""
|
| 331 |
+
visualizer._C.YELLOW = ""
|
| 332 |
+
visualizer._C.GREEN = ""
|
| 333 |
+
visualizer._C.CYAN = ""
|
| 334 |
+
visualizer._C.MAGENTA = ""
|
| 335 |
+
visualizer._C.BLUE = ""
|
| 336 |
+
visualizer._C.DIM = ""
|
| 337 |
+
visualizer._C.BOLD = ""
|
| 338 |
+
visualizer._C.RESET = ""
|
| 339 |
+
visualizer._C.WHITE = ""
|
| 340 |
+
|
| 341 |
+
# Render visual blast radius
|
| 342 |
+
output = render_blast_radius(
|
| 343 |
+
idx.graph, changed, idx.symbols,
|
| 344 |
+
max_depth=args.depth,
|
| 345 |
+
show_proof=args.verify,
|
| 346 |
+
repo_path=os.path.abspath(args.repo),
|
| 347 |
+
)
|
| 348 |
+
print(output)
|
| 349 |
+
|
| 350 |
+
# If --verify, also show detailed proof chains
|
| 351 |
+
if args.verify:
|
| 352 |
+
verification = render_verification(
|
| 353 |
+
idx.graph, changed, idx.symbols,
|
| 354 |
+
)
|
| 355 |
+
print(verification)
|
| 356 |
+
|
| 357 |
+
_print_broken_files(idx, broken_patches)
|
| 358 |
+
|
| 359 |
+
# Timing footer
|
| 360 |
+
total_ms = (time.perf_counter() - t0) * 1000
|
| 361 |
+
print(f" Indexed {len(idx.symbols)} symbols in {index_ms:.0f}ms")
|
| 362 |
+
print(f" Total analysis time: {total_ms:.0f}ms")
|
| 363 |
+
print()
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _cmd_sync(args):
|
| 367 |
+
"""Compile blast radius and push to CtxSync cloud in one step."""
|
| 368 |
+
import urllib.request
|
| 369 |
+
import urllib.error
|
| 370 |
+
|
| 371 |
+
# --- Resolve credentials: CLI flags > env vars > ~/.ctxsync config ---
|
| 372 |
+
url = args.url or os.environ.get("CTXSYNC_URL")
|
| 373 |
+
key = args.key or os.environ.get("CTXSYNC_KEY")
|
| 374 |
+
|
| 375 |
+
if not url or not key:
|
| 376 |
+
config_path = os.path.expanduser("~/.ctxsync")
|
| 377 |
+
if os.path.exists(config_path):
|
| 378 |
+
with open(config_path) as f:
|
| 379 |
+
for line in f:
|
| 380 |
+
line = line.strip()
|
| 381 |
+
if line.startswith("#") or "=" not in line:
|
| 382 |
+
continue
|
| 383 |
+
k, v = line.split("=", 1)
|
| 384 |
+
k, v = k.strip(), v.strip()
|
| 385 |
+
if k == "CTXSYNC_URL" and not url:
|
| 386 |
+
url = v
|
| 387 |
+
elif k == "CTXSYNC_KEY" and not key:
|
| 388 |
+
key = v
|
| 389 |
+
|
| 390 |
+
if not url or not key:
|
| 391 |
+
print("Error: CtxSync credentials not found.", file=sys.stderr)
|
| 392 |
+
print("", file=sys.stderr)
|
| 393 |
+
print("Set them via any of:", file=sys.stderr)
|
| 394 |
+
print(" 1. CLI flags: diffcontext sync --url <URL> --key <KEY>", file=sys.stderr)
|
| 395 |
+
print(" 2. Env vars: export CTXSYNC_URL=... CTXSYNC_KEY=...", file=sys.stderr)
|
| 396 |
+
print(" 3. Config file: ~/.ctxsync with CTXSYNC_URL=... and CTXSYNC_KEY=...", file=sys.stderr)
|
| 397 |
+
sys.exit(1)
|
| 398 |
+
|
| 399 |
+
# --- Compile blast radius ---
|
| 400 |
+
idx = index_repository(args.repo)
|
| 401 |
+
changed = find_changed_symbols(
|
| 402 |
+
args.repo, idx.symbols, ref=args.ref,
|
| 403 |
+
broken_files=idx.broken_files,
|
| 404 |
+
known_broken_files=idx.broken_files,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
if not changed:
|
| 408 |
+
print("No changes detected — nothing to sync.")
|
| 409 |
+
return
|
| 410 |
+
|
| 411 |
+
impact = analyze_impact(idx, changed, max_depth=args.depth)
|
| 412 |
+
max_tokens = args.max_tokens if args.max_tokens > 0 else None
|
| 413 |
+
ctx = compile(idx, impact, max_tokens=max_tokens)
|
| 414 |
+
|
| 415 |
+
project_name = args.project or os.path.basename(os.path.abspath(args.repo))
|
| 416 |
+
|
| 417 |
+
# --- Push to CtxSync ---
|
| 418 |
+
url_base = url.rstrip("/")
|
| 419 |
+
|
| 420 |
+
# Switch active project
|
| 421 |
+
try:
|
| 422 |
+
req_switch = urllib.request.Request(
|
| 423 |
+
url_base + "/project/switch",
|
| 424 |
+
data=json.dumps({"name": project_name}).encode("utf-8"),
|
| 425 |
+
headers={
|
| 426 |
+
"Authorization": f"Bearer {key}",
|
| 427 |
+
"Content-Type": "application/json",
|
| 428 |
+
"User-Agent": "DiffContext-Sync/1.0",
|
| 429 |
+
},
|
| 430 |
+
)
|
| 431 |
+
urllib.request.urlopen(req_switch)
|
| 432 |
+
except Exception:
|
| 433 |
+
pass
|
| 434 |
+
|
| 435 |
+
# Push context
|
| 436 |
+
data = json.dumps({
|
| 437 |
+
"type": "diffcontext_update",
|
| 438 |
+
"text": ctx.text,
|
| 439 |
+
"project": project_name,
|
| 440 |
+
}).encode("utf-8")
|
| 441 |
+
req = urllib.request.Request(
|
| 442 |
+
url_base + "/event",
|
| 443 |
+
data=data,
|
| 444 |
+
headers={
|
| 445 |
+
"Authorization": f"Bearer {key}",
|
| 446 |
+
"Content-Type": "application/json",
|
| 447 |
+
"User-Agent": "DiffContext-Sync/1.0",
|
| 448 |
+
},
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
try:
|
| 452 |
+
with urllib.request.urlopen(req) as res:
|
| 453 |
+
body = json.loads(res.read().decode("utf-8"))
|
| 454 |
+
print(f"☁️ Synced to CtxSync!")
|
| 455 |
+
print(f" Project : {project_name}")
|
| 456 |
+
print(f" Symbols : {ctx.symbol_count} compiled ({ctx.reduction_pct:.1f}% reduction)")
|
| 457 |
+
print(f" Tokens : {ctx.token_estimate:,}")
|
| 458 |
+
print(f" Nodes : {body.get('nodes', '?')}")
|
| 459 |
+
print(f" Errors : {body.get('activeErrors', '?')}")
|
| 460 |
+
except urllib.error.URLError as e:
|
| 461 |
+
print(f"Error syncing to CtxSync: {e}", file=sys.stderr)
|
| 462 |
+
sys.exit(1)
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
if __name__ == "__main__":
|
| 466 |
+
main()
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def cli_main():
|
| 470 |
+
"""
|
| 471 |
+
Entry point for the `diffcontext` console script.
|
| 472 |
+
|
| 473 |
+
Wraps main() to handle BrokenPipeError gracefully -- this happens
|
| 474 |
+
whenever stdout is piped into something that closes early (a missing
|
| 475 |
+
command, `head`, a reader that exits before reading everything). Without
|
| 476 |
+
this, piping `diffcontext compile | some-missing-tool` prints a full
|
| 477 |
+
Python traceback even though nothing is actually wrong.
|
| 478 |
+
"""
|
| 479 |
+
try:
|
| 480 |
+
sys.exit(main())
|
| 481 |
+
except BrokenPipeError:
|
| 482 |
+
# Redirect remaining stdout to devnull so the interpreter's own
|
| 483 |
+
# shutdown-time flush doesn't also raise BrokenPipeError.
|
| 484 |
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
| 485 |
+
os.dup2(devnull, sys.stdout.fileno())
|
| 486 |
+
sys.exit(1)
|
diffcontext/context/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""context subpackage — selection, expansion, compilation."""
|
diffcontext/context/compiler.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
compiler.py — Compile selected symbols into an LLM-ready context package.
|
| 3 |
+
|
| 4 |
+
Key additions over the naive "dump code blocks" approach:
|
| 5 |
+
- META header: LLM knows repo size, what was dropped, graph confidence,
|
| 6 |
+
broken files, and scoring basis BEFORE reading any code.
|
| 7 |
+
- Per-symbol relationship annotations: callers / callees, and explicit
|
| 8 |
+
"NOT IN CONTEXT" tags so the LLM doesn't hallucinate missing deps.
|
| 9 |
+
- Dropped-symbol manifest: explicit list of symbols cut by token budget.
|
| 10 |
+
- Graph confidence score: fraction of edges that resolved to known symbols.
|
| 11 |
+
- Auto-generated suggestions: rule-based hints derived from graph data.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import ast
|
| 15 |
+
import re
|
| 16 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 17 |
+
|
| 18 |
+
from ..models import Symbol, ContextPackage
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_module_docstring(abs_file_path: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Read the first line of the module-level docstring from an ABSOLUTE path.
|
| 24 |
+
Returns "" if file is unreadable or has no docstring.
|
| 25 |
+
Must receive sym.file (absolute), NOT the relative path from sym_id.
|
| 26 |
+
"""
|
| 27 |
+
try:
|
| 28 |
+
with open(abs_file_path, "r", encoding="utf-8") as f:
|
| 29 |
+
source = f.read()
|
| 30 |
+
tree = ast.parse(source)
|
| 31 |
+
doc = ast.get_docstring(tree)
|
| 32 |
+
if doc:
|
| 33 |
+
first_line = doc.strip().split("\n")[0]
|
| 34 |
+
first_line = re.sub(r'^[\w/\-\.]+\.py\s*[—\-]+\s*', '', first_line).strip()
|
| 35 |
+
if len(first_line) > 60:
|
| 36 |
+
return first_line[:57] + "..."
|
| 37 |
+
return first_line
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
return ""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Public entry point
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
def compile_context(
|
| 48 |
+
symbols: Dict[str, Symbol],
|
| 49 |
+
selected_ids: List[str],
|
| 50 |
+
changed_ids: List[str],
|
| 51 |
+
scores: Dict[str, float],
|
| 52 |
+
graph: Optional[Dict[str, List[str]]] = None,
|
| 53 |
+
dropped_ids: Optional[List[str]] = None,
|
| 54 |
+
skipped_files: Optional[List[str]] = None,
|
| 55 |
+
notes: Optional[str] = None,
|
| 56 |
+
) -> ContextPackage:
|
| 57 |
+
"""
|
| 58 |
+
Build the final context text from selected symbols.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
symbols: Full symbol table for the repo.
|
| 62 |
+
selected_ids: Symbols chosen for this context (respects token budget).
|
| 63 |
+
changed_ids: The symbols that actually changed (always selected).
|
| 64 |
+
scores: Impact scores for all scored symbols.
|
| 65 |
+
graph: Full call graph (id -> [dep ids]). Enables relationship
|
| 66 |
+
annotations and confidence calculation.
|
| 67 |
+
dropped_ids: Symbols that were scored but cut by token budget.
|
| 68 |
+
skipped_files: Files that raised SyntaxError (graph has holes here).
|
| 69 |
+
notes: Optional user notes injected into meta header.
|
| 70 |
+
"""
|
| 71 |
+
dropped_ids = dropped_ids or []
|
| 72 |
+
skipped_files = skipped_files or []
|
| 73 |
+
|
| 74 |
+
changed_set = set(changed_ids)
|
| 75 |
+
selected_set = set(selected_ids)
|
| 76 |
+
|
| 77 |
+
# Build reverse graph for caller annotation
|
| 78 |
+
reverse: Dict[str, Set[str]] = {}
|
| 79 |
+
if graph:
|
| 80 |
+
for caller, callees in graph.items():
|
| 81 |
+
for callee in callees:
|
| 82 |
+
reverse.setdefault(callee, set()).add(caller)
|
| 83 |
+
|
| 84 |
+
# Graph confidence: fraction of edges that point to a known symbol
|
| 85 |
+
graph_confidence = _compute_confidence(graph, symbols)
|
| 86 |
+
|
| 87 |
+
# Token bookkeeping
|
| 88 |
+
total_repo_code = "\n\n".join(s.code for s in symbols.values())
|
| 89 |
+
total_repo_tokens = max(1, len(total_repo_code) // 4)
|
| 90 |
+
|
| 91 |
+
# --- Bucket symbols into sections ---
|
| 92 |
+
sections: Dict[str, List[str]] = {"CHANGED": [], "IMPACTED": [], "DEPENDENCIES": []}
|
| 93 |
+
|
| 94 |
+
for sym_id in selected_ids:
|
| 95 |
+
if sym_id not in symbols:
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
sym = symbols[sym_id]
|
| 99 |
+
score = scores.get(sym_id, 0)
|
| 100 |
+
file_name, func_name = sym_id.split(":", 1)
|
| 101 |
+
|
| 102 |
+
# Relationship annotation block
|
| 103 |
+
rel_block = _build_relationship_block(
|
| 104 |
+
sym_id, graph, reverse, selected_set, symbols
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
entry = (
|
| 108 |
+
f"FILE: {file_name}\n"
|
| 109 |
+
f"FUNCTION: {func_name} (score: {score:.0f})\n"
|
| 110 |
+
+ rel_block +
|
| 111 |
+
f"\n{sym.code}"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if sym_id in changed_set:
|
| 115 |
+
sections["CHANGED"].append(entry)
|
| 116 |
+
elif score >= 70:
|
| 117 |
+
sections["IMPACTED"].append(entry)
|
| 118 |
+
else:
|
| 119 |
+
sections["DEPENDENCIES"].append(entry)
|
| 120 |
+
|
| 121 |
+
# --- Assemble code sections ---
|
| 122 |
+
parts = []
|
| 123 |
+
for label, entries in sections.items():
|
| 124 |
+
if entries:
|
| 125 |
+
parts.append(f"=== {label} SYMBOLS ===\n")
|
| 126 |
+
parts.append("\n\n---\n\n".join(entries))
|
| 127 |
+
|
| 128 |
+
code_text = "\n\n".join(parts)
|
| 129 |
+
context_tokens = max(1, len(code_text) // 4)
|
| 130 |
+
|
| 131 |
+
# --- Build meta-header (prepended so LLM reads it first) ---
|
| 132 |
+
meta = _build_meta_header(
|
| 133 |
+
symbols = symbols,
|
| 134 |
+
selected_ids = selected_ids,
|
| 135 |
+
dropped_ids = dropped_ids,
|
| 136 |
+
skipped_files = skipped_files,
|
| 137 |
+
changed_ids = changed_ids,
|
| 138 |
+
graph = graph,
|
| 139 |
+
reverse = reverse,
|
| 140 |
+
graph_confidence = graph_confidence,
|
| 141 |
+
token_budget = total_repo_tokens, # not the budget cap; just total repo
|
| 142 |
+
context_tokens = context_tokens,
|
| 143 |
+
scores = scores,
|
| 144 |
+
notes = notes,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# --- Build suggestions block (appended at bottom) ---
|
| 148 |
+
suggestions = _build_suggestions(
|
| 149 |
+
changed_ids = changed_ids,
|
| 150 |
+
dropped_ids = dropped_ids,
|
| 151 |
+
skipped_files = skipped_files,
|
| 152 |
+
graph = graph,
|
| 153 |
+
reverse = reverse,
|
| 154 |
+
graph_confidence = graph_confidence,
|
| 155 |
+
scores = scores,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
full_text = meta + "\n\n" + code_text
|
| 159 |
+
if suggestions:
|
| 160 |
+
full_text += "\n\n" + suggestions
|
| 161 |
+
|
| 162 |
+
return ContextPackage(
|
| 163 |
+
text = full_text,
|
| 164 |
+
symbol_count = len(selected_ids),
|
| 165 |
+
token_estimate = context_tokens,
|
| 166 |
+
total_repo_tokens = total_repo_tokens,
|
| 167 |
+
dropped_symbols = dropped_ids,
|
| 168 |
+
skipped_files = skipped_files,
|
| 169 |
+
graph_confidence = graph_confidence,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
# Meta-header
|
| 175 |
+
# ---------------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
def _build_meta_header(
|
| 178 |
+
symbols: Dict[str, Symbol],
|
| 179 |
+
selected_ids: List[str],
|
| 180 |
+
dropped_ids: List[str],
|
| 181 |
+
skipped_files: List[str],
|
| 182 |
+
changed_ids: List[str],
|
| 183 |
+
graph: Optional[Dict[str, List[str]]],
|
| 184 |
+
reverse: Dict[str, Set[str]],
|
| 185 |
+
graph_confidence: float,
|
| 186 |
+
token_budget: int,
|
| 187 |
+
context_tokens: int,
|
| 188 |
+
scores: Dict[str, float],
|
| 189 |
+
notes: Optional[str] = None,
|
| 190 |
+
) -> str:
|
| 191 |
+
total_syms = len(symbols)
|
| 192 |
+
selected_cnt = len(selected_ids)
|
| 193 |
+
dropped_cnt = len(dropped_ids)
|
| 194 |
+
scored_cnt = len(scores)
|
| 195 |
+
total_edges = sum(len(v) for v in graph.values()) if graph else 0
|
| 196 |
+
|
| 197 |
+
direct_callers = sum(
|
| 198 |
+
1 for s in changed_ids
|
| 199 |
+
for c in reverse.get(s, set())
|
| 200 |
+
)
|
| 201 |
+
direct_callees = sum(
|
| 202 |
+
len(graph.get(s, []))
|
| 203 |
+
for s in changed_ids
|
| 204 |
+
) if graph else 0
|
| 205 |
+
|
| 206 |
+
lines = [
|
| 207 |
+
"=== DIFFCONTEXT META ===",
|
| 208 |
+
f"Repo symbols total : {total_syms}",
|
| 209 |
+
f"Symbols scored : {scored_cnt}",
|
| 210 |
+
f"Symbols IN context : {selected_cnt}",
|
| 211 |
+
f"Symbols DROPPED : {dropped_cnt} ← you cannot see these",
|
| 212 |
+
f"Graph edges total : {total_edges}",
|
| 213 |
+
f"Graph confidence : {graph_confidence * 100:.0f}%"
|
| 214 |
+
+ (" ✓" if graph_confidence >= 0.9 else " ⚠ incomplete"),
|
| 215 |
+
f"Changed symbols : {len(changed_ids)}",
|
| 216 |
+
f"Direct callers found : {direct_callers}",
|
| 217 |
+
f"Direct callees found : {direct_callees}",
|
| 218 |
+
f"Context tokens (est.) : {context_tokens:,}",
|
| 219 |
+
f"Scoring basis : changed=100 | direct_callee=90 | direct_caller=80 "
|
| 220 |
+
f"| 2hop_callee=60 | 2hop_caller=50 | indegree*2+outdegree bonus",
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
# --- Repository Architecture Snapshot ---
|
| 224 |
+
# Build rel_file -> absolute_path mapping from symbol table.
|
| 225 |
+
# sym_id gives us relative path; sym.file gives us the absolute path we
|
| 226 |
+
# need to actually open the file for its docstring.
|
| 227 |
+
rel_to_abs: Dict[str, str] = {}
|
| 228 |
+
for sym_id, sym in symbols.items():
|
| 229 |
+
rel_file = sym_id.split(":", 1)[0]
|
| 230 |
+
if rel_file not in rel_to_abs:
|
| 231 |
+
rel_to_abs[rel_file] = sym.file # sym.file is always absolute
|
| 232 |
+
|
| 233 |
+
modules_total = {}
|
| 234 |
+
modules_selected = {}
|
| 235 |
+
for sym_id in symbols:
|
| 236 |
+
file_name = sym_id.split(":", 1)[0]
|
| 237 |
+
modules_total[file_name] = modules_total.get(file_name, 0) + 1
|
| 238 |
+
|
| 239 |
+
for sym_id in selected_ids:
|
| 240 |
+
if sym_id in symbols:
|
| 241 |
+
file_name = sym_id.split(":", 1)[0]
|
| 242 |
+
modules_selected[file_name] = modules_selected.get(file_name, 0) + 1
|
| 243 |
+
|
| 244 |
+
lines.append("")
|
| 245 |
+
lines.append("=== REPOSITORY ARCHITECTURE SNAPSHOT ===")
|
| 246 |
+
|
| 247 |
+
loaded_files = []
|
| 248 |
+
blind_files = []
|
| 249 |
+
|
| 250 |
+
for file_name, total in sorted(modules_total.items()):
|
| 251 |
+
selected = modules_selected.get(file_name, 0)
|
| 252 |
+
|
| 253 |
+
doc_snippet = ""
|
| 254 |
+
if file_name.endswith(".py"):
|
| 255 |
+
# FIX: use absolute path, not the relative file_name
|
| 256 |
+
abs_path = rel_to_abs.get(file_name, "")
|
| 257 |
+
doc_str = _get_module_docstring(abs_path) if abs_path else ""
|
| 258 |
+
if doc_str:
|
| 259 |
+
doc_snippet = f" — {doc_str}"
|
| 260 |
+
|
| 261 |
+
if selected > 0:
|
| 262 |
+
loaded_files.append(f" - {file_name} ({selected}/{total} symbols loaded){doc_snippet}")
|
| 263 |
+
else:
|
| 264 |
+
blind_files.append(f" - {file_name} ({total} symbols){doc_snippet}")
|
| 265 |
+
|
| 266 |
+
lines.append("MODULES IN CONTEXT:")
|
| 267 |
+
if loaded_files:
|
| 268 |
+
lines.extend(loaded_files)
|
| 269 |
+
else:
|
| 270 |
+
lines.append(" (none)")
|
| 271 |
+
|
| 272 |
+
lines.append("")
|
| 273 |
+
lines.append("KNOWN MODULES (NOT IN CONTEXT - BLIND SPOTS):")
|
| 274 |
+
if blind_files:
|
| 275 |
+
lines.extend(blind_files)
|
| 276 |
+
else:
|
| 277 |
+
lines.append(" (none)")
|
| 278 |
+
|
| 279 |
+
if skipped_files:
|
| 280 |
+
lines.append("")
|
| 281 |
+
lines.append(f"FILES WITH SYNTAXERROR ({len(skipped_files)}) — graph has holes here:")
|
| 282 |
+
for f in skipped_files:
|
| 283 |
+
lines.append(f" ✗ {f}")
|
| 284 |
+
|
| 285 |
+
if dropped_cnt > 0:
|
| 286 |
+
lines.append("")
|
| 287 |
+
lines.append(f"DROPPED SYMBOLS ({dropped_cnt}) — scored but cut by token budget:")
|
| 288 |
+
for d in dropped_ids[:15]:
|
| 289 |
+
lines.append(f" - {d} (score: {scores.get(d, 0):.0f})")
|
| 290 |
+
if dropped_cnt > 15:
|
| 291 |
+
lines.append(f" ... and {dropped_cnt - 15} more")
|
| 292 |
+
lines.append(" → If any of these are critical, re-run with a higher --max-tokens.")
|
| 293 |
+
|
| 294 |
+
warnings = []
|
| 295 |
+
if skipped_files:
|
| 296 |
+
warnings.append(
|
| 297 |
+
f"⚠ {len(skipped_files)} file(s) had SyntaxErrors. "
|
| 298 |
+
"Call graph may be incomplete for those files."
|
| 299 |
+
)
|
| 300 |
+
if dropped_cnt > 0:
|
| 301 |
+
warnings.append(
|
| 302 |
+
f"⚠ {dropped_cnt} symbol(s) were dropped. "
|
| 303 |
+
"References to them in the code below are NOT backed by visible implementations."
|
| 304 |
+
)
|
| 305 |
+
if graph_confidence < 0.8:
|
| 306 |
+
warnings.append(
|
| 307 |
+
f"⚠ Graph confidence is {graph_confidence * 100:.0f}%. "
|
| 308 |
+
"Many calls could not be resolved — likely external/stdlib deps or dynamic dispatch."
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
if warnings:
|
| 312 |
+
lines.append("")
|
| 313 |
+
for w in warnings:
|
| 314 |
+
lines.append(w)
|
| 315 |
+
|
| 316 |
+
if notes:
|
| 317 |
+
lines.append(f"\n=== DEVELOPER NOTES ===\n{notes}")
|
| 318 |
+
|
| 319 |
+
lines.append("=== END META ===")
|
| 320 |
+
return "\n".join(lines)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ---------------------------------------------------------------------------
|
| 324 |
+
# Per-symbol relationship block
|
| 325 |
+
# ---------------------------------------------------------------------------
|
| 326 |
+
|
| 327 |
+
def _build_relationship_block(
|
| 328 |
+
sym_id: str,
|
| 329 |
+
graph: Optional[Dict[str, List[str]]],
|
| 330 |
+
reverse: Dict[str, Set[str]],
|
| 331 |
+
selected_set: Set[str],
|
| 332 |
+
symbols: Dict[str, Symbol],
|
| 333 |
+
) -> str:
|
| 334 |
+
if not graph:
|
| 335 |
+
return ""
|
| 336 |
+
|
| 337 |
+
lines = []
|
| 338 |
+
|
| 339 |
+
callers = sorted(reverse.get(sym_id, set()))
|
| 340 |
+
callees = graph.get(sym_id, [])
|
| 341 |
+
|
| 342 |
+
if callers:
|
| 343 |
+
caller_parts = []
|
| 344 |
+
for c in callers[:6]:
|
| 345 |
+
tag = "" if c in selected_set else " [NOT IN CONTEXT]"
|
| 346 |
+
caller_parts.append(c + tag)
|
| 347 |
+
if len(callers) > 6:
|
| 348 |
+
caller_parts.append(f"... +{len(callers) - 6} more")
|
| 349 |
+
lines.append(f"CALLERS: {', '.join(caller_parts)}")
|
| 350 |
+
|
| 351 |
+
if callees:
|
| 352 |
+
callee_parts = []
|
| 353 |
+
for c in callees[:6]:
|
| 354 |
+
tag = "" if c in selected_set else " [NOT IN CONTEXT]"
|
| 355 |
+
callee_parts.append(c + tag)
|
| 356 |
+
if len(callees) > 6:
|
| 357 |
+
callee_parts.append(f"... +{len(callees) - 6} more")
|
| 358 |
+
lines.append(f"CALLEES: {', '.join(callee_parts)}")
|
| 359 |
+
|
| 360 |
+
if not callers and not callees:
|
| 361 |
+
lines.append("CALLERS: (none found in repo)")
|
| 362 |
+
lines.append("CALLEES: (none found in repo)")
|
| 363 |
+
|
| 364 |
+
return "\n".join(lines) + "\n" if lines else ""
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# ---------------------------------------------------------------------------
|
| 368 |
+
# Suggestions block
|
| 369 |
+
# ---------------------------------------------------------------------------
|
| 370 |
+
|
| 371 |
+
def _build_suggestions(
|
| 372 |
+
changed_ids, dropped_ids, skipped_files,
|
| 373 |
+
graph, reverse, graph_confidence, scores,
|
| 374 |
+
) -> str:
|
| 375 |
+
tips = []
|
| 376 |
+
|
| 377 |
+
if skipped_files:
|
| 378 |
+
tips.append(
|
| 379 |
+
f"Fix SyntaxErrors in {len(skipped_files)} file(s) to improve graph accuracy."
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
if graph_confidence < 0.7:
|
| 383 |
+
tips.append(
|
| 384 |
+
f"Graph confidence is {graph_confidence * 100:.0f}%. "
|
| 385 |
+
"Consider running diffcontext on installed site-packages too, "
|
| 386 |
+
"or add the dependency source to your repo path."
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
for sym_id in changed_ids:
|
| 390 |
+
callers = list(reverse.get(sym_id, set()))
|
| 391 |
+
if len(callers) > 20:
|
| 392 |
+
tips.append(
|
| 393 |
+
f"'{sym_id.split(':')[-1]}' has {len(callers)} callers — "
|
| 394 |
+
"unusually high blast radius. Review changes carefully."
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
if dropped_ids:
|
| 398 |
+
tips.append(
|
| 399 |
+
f"{len(dropped_ids)} symbol(s) were dropped by token budget. "
|
| 400 |
+
"Run with --max-tokens=0 (unlimited) to see full context."
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
if not tips:
|
| 404 |
+
return ""
|
| 405 |
+
|
| 406 |
+
lines = ["=== DIFFCONTEXT SUGGESTIONS ==="]
|
| 407 |
+
for i, tip in enumerate(tips, 1):
|
| 408 |
+
lines.append(f" {i}. {tip}")
|
| 409 |
+
lines.append("=== END SUGGESTIONS ===")
|
| 410 |
+
return "\n".join(lines)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
# ---------------------------------------------------------------------------
|
| 414 |
+
# Graph confidence
|
| 415 |
+
# ---------------------------------------------------------------------------
|
| 416 |
+
|
| 417 |
+
def _compute_confidence(
|
| 418 |
+
graph: Optional[Dict[str, List[str]]],
|
| 419 |
+
symbols: Dict[str, Symbol],
|
| 420 |
+
) -> float:
|
| 421 |
+
"""
|
| 422 |
+
Fraction of graph edges that resolve to a known symbol.
|
| 423 |
+
Edges to external/stdlib deps count as unresolved.
|
| 424 |
+
Returns 1.0 if graph is empty or None (no data = no known holes).
|
| 425 |
+
"""
|
| 426 |
+
if not graph:
|
| 427 |
+
return 1.0
|
| 428 |
+
|
| 429 |
+
total = 0
|
| 430 |
+
resolved = 0
|
| 431 |
+
|
| 432 |
+
for deps in graph.values():
|
| 433 |
+
for d in deps:
|
| 434 |
+
total += 1
|
| 435 |
+
if d in symbols:
|
| 436 |
+
resolved += 1
|
| 437 |
+
|
| 438 |
+
return resolved / total if total > 0 else 1.0
|
diffcontext/context/selector.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
selector.py — Select which symbols to include in context, respecting token budget.
|
| 3 |
+
|
| 4 |
+
Key fix over original:
|
| 5 |
+
- Changed symbols are the ONLY unconditional include.
|
| 6 |
+
- Score >= 80 threshold no longer bypasses the token budget.
|
| 7 |
+
That rule caused direct callees (score ~90 after structural bonus)
|
| 8 |
+
to consume the entire budget before any co-change sibling candidates
|
| 9 |
+
(score ~38-50) were even evaluated. Precision suffered badly.
|
| 10 |
+
- Instead: rank strictly by score, apply budget universally, with one
|
| 11 |
+
exception: changed symbols always fit (they're the reason we're here).
|
| 12 |
+
- Added a per-symbol token cap so one giant function can't crowd out
|
| 13 |
+
ten relevant small ones.
|
| 14 |
+
|
| 15 |
+
Fix vs previous version:
|
| 16 |
+
The token cap logic was wrong: it counted a capped amount toward the
|
| 17 |
+
budget (250 tokens) but included the full symbol in the result, causing
|
| 18 |
+
silent overruns. The correct behavior is: if a symbol exceeds the cap,
|
| 19 |
+
SKIP IT ENTIRELY rather than including it at a lie. This means the
|
| 20 |
+
selector tries smaller candidates next instead of filling context with
|
| 21 |
+
one huge function and then claiming there's room for more.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from typing import Dict, List, Optional, Tuple
|
| 25 |
+
|
| 26 |
+
from ..models import Symbol
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# A single symbol can burn at most this fraction of the total budget.
|
| 30 |
+
# Prevents one huge function from crowding out ten relevant small ones.
|
| 31 |
+
MAX_SINGLE_SYMBOL_FRACTION = 0.25
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def select_context(
|
| 35 |
+
symbols: Dict[str, Symbol],
|
| 36 |
+
scores: Dict[str, float],
|
| 37 |
+
changed: List[str],
|
| 38 |
+
max_tokens: Optional[int] = None,
|
| 39 |
+
) -> Tuple[List[str], List[str]]:
|
| 40 |
+
"""
|
| 41 |
+
Select symbols for context based on scores and token budget.
|
| 42 |
+
|
| 43 |
+
Priority:
|
| 44 |
+
1. Changed symbols always included (no budget bypass for anything else)
|
| 45 |
+
2. All remaining symbols ranked by score, included until budget exhausted
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
(selected_ids, dropped_ids)
|
| 49 |
+
dropped_ids: scored symbols that exist in `symbols` but were cut by
|
| 50 |
+
the token budget. The LLM is told about these explicitly.
|
| 51 |
+
"""
|
| 52 |
+
if not scores:
|
| 53 |
+
return list(changed), []
|
| 54 |
+
|
| 55 |
+
per_sym_cap = int(max_tokens * MAX_SINGLE_SYMBOL_FRACTION) if max_tokens else None
|
| 56 |
+
|
| 57 |
+
changed_set = set(changed)
|
| 58 |
+
result: List[str] = []
|
| 59 |
+
dropped: List[str] = []
|
| 60 |
+
current_tokens = 0
|
| 61 |
+
|
| 62 |
+
# ── Pass 1: changed symbols always in, no budget check ───────────────
|
| 63 |
+
for sym_id in changed:
|
| 64 |
+
if sym_id in symbols:
|
| 65 |
+
result.append(sym_id)
|
| 66 |
+
current_tokens += _estimate_tokens(symbols[sym_id].code)
|
| 67 |
+
|
| 68 |
+
# ── Pass 2: everything else ranked by score, budget-gated ────────────
|
| 69 |
+
scored = sorted(
|
| 70 |
+
((sid, sc) for sid, sc in scores.items() if sid not in changed_set),
|
| 71 |
+
key=lambda x: x[1],
|
| 72 |
+
reverse=True,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
for sym_id, score in scored:
|
| 76 |
+
if sym_id not in symbols:
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
sym_tokens = _estimate_tokens(symbols[sym_id].code)
|
| 80 |
+
|
| 81 |
+
# FIX: if the symbol exceeds the per-symbol cap, skip it entirely.
|
| 82 |
+
# Previous code counted the capped amount toward the budget but
|
| 83 |
+
# still included the full symbol, silently overrunning the budget
|
| 84 |
+
# and then continuing to include more symbols as if there were room.
|
| 85 |
+
# Skipping is correct: the budget should gate what's actually included.
|
| 86 |
+
if per_sym_cap is not None and sym_tokens > per_sym_cap:
|
| 87 |
+
dropped.append(sym_id)
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
if max_tokens is not None and current_tokens + sym_tokens > max_tokens:
|
| 91 |
+
dropped.append(sym_id)
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
result.append(sym_id)
|
| 95 |
+
current_tokens += sym_tokens
|
| 96 |
+
|
| 97 |
+
return result, dropped
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _estimate_tokens(text: str) -> int:
|
| 101 |
+
"""~4 chars per token (GPT approximation). Add 20% buffer for safety."""
|
| 102 |
+
return max(1, int(len(text) / 4 * 1.2))
|
diffcontext/diff/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""diff subpackage — git diff + state-based change detection."""
|
diffcontext/diff/git_diff.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
git_diff.py — Extract changed files/symbols from git diff output.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
from typing import List, Optional, Set
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_changed_files(
|
| 11 |
+
repo_path: str,
|
| 12 |
+
ref: str = "HEAD~1",
|
| 13 |
+
against: "Optional[str]" = None,
|
| 14 |
+
) -> List[str]:
|
| 15 |
+
"""
|
| 16 |
+
Get list of Python files changed between `ref` and `against`.
|
| 17 |
+
|
| 18 |
+
against=None (default): compare against the WORKING TREE, i.e. include
|
| 19 |
+
uncommitted changes (staged or not). This is `git diff <ref>` with no
|
| 20 |
+
second ref -- the same thing `git status`-style tools show you before
|
| 21 |
+
you commit.
|
| 22 |
+
against="HEAD" (or any other ref): compare two committed snapshots only;
|
| 23 |
+
uncommitted edits are invisible to this mode.
|
| 24 |
+
|
| 25 |
+
Returns list of relative paths like ["./src/auth.py", "./api/login.py"]
|
| 26 |
+
"""
|
| 27 |
+
repo_path = os.path.abspath(repo_path)
|
| 28 |
+
try:
|
| 29 |
+
cmd = ["git", "diff", "--name-only", "--diff-filter=ACMR", ref]
|
| 30 |
+
if against is not None:
|
| 31 |
+
cmd.append(against)
|
| 32 |
+
|
| 33 |
+
result = subprocess.run(
|
| 34 |
+
cmd,
|
| 35 |
+
cwd=repo_path,
|
| 36 |
+
capture_output=True,
|
| 37 |
+
text=True,
|
| 38 |
+
timeout=30,
|
| 39 |
+
)
|
| 40 |
+
if result.returncode != 0:
|
| 41 |
+
return []
|
| 42 |
+
|
| 43 |
+
files = []
|
| 44 |
+
for line in result.stdout.strip().split("\n"):
|
| 45 |
+
line = line.strip()
|
| 46 |
+
if line.endswith(".py"):
|
| 47 |
+
files.append("./" + line)
|
| 48 |
+
return files
|
| 49 |
+
|
| 50 |
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
| 51 |
+
return []
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_changed_lines(
|
| 55 |
+
repo_path: str,
|
| 56 |
+
filepath: str,
|
| 57 |
+
ref: str = "HEAD~1",
|
| 58 |
+
against: "Optional[str]" = None,
|
| 59 |
+
) -> Set[int]:
|
| 60 |
+
"""
|
| 61 |
+
Get set of changed line numbers for a specific file.
|
| 62 |
+
|
| 63 |
+
against=None (default): compare `ref` against the working tree,
|
| 64 |
+
including uncommitted edits. See get_changed_files for details.
|
| 65 |
+
|
| 66 |
+
Returns set of 1-indexed line numbers that were added or modified.
|
| 67 |
+
"""
|
| 68 |
+
repo_path = os.path.abspath(repo_path)
|
| 69 |
+
try:
|
| 70 |
+
cmd = ["git", "diff", "-U0", ref]
|
| 71 |
+
if against is not None:
|
| 72 |
+
cmd.append(against)
|
| 73 |
+
cmd += ["--", filepath.lstrip("./")]
|
| 74 |
+
|
| 75 |
+
result = subprocess.run(
|
| 76 |
+
cmd,
|
| 77 |
+
cwd=repo_path,
|
| 78 |
+
capture_output=True,
|
| 79 |
+
text=True,
|
| 80 |
+
timeout=30,
|
| 81 |
+
)
|
| 82 |
+
if result.returncode != 0:
|
| 83 |
+
return set()
|
| 84 |
+
|
| 85 |
+
changed_lines: Set[int] = set()
|
| 86 |
+
for line in result.stdout.split("\n"):
|
| 87 |
+
if line.startswith("@@"):
|
| 88 |
+
# Parse @@ -old,count +new,count @@
|
| 89 |
+
parts = line.split()
|
| 90 |
+
for part in parts:
|
| 91 |
+
if part.startswith("+") and "," in part:
|
| 92 |
+
start_str, count_str = part[1:].split(",", 1)
|
| 93 |
+
start = int(start_str)
|
| 94 |
+
count = int(count_str)
|
| 95 |
+
for i in range(start, start + max(count, 1)):
|
| 96 |
+
changed_lines.add(i)
|
| 97 |
+
elif part.startswith("+") and part[1:].isdigit():
|
| 98 |
+
changed_lines.add(int(part[1:]))
|
| 99 |
+
|
| 100 |
+
return changed_lines
|
| 101 |
+
|
| 102 |
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
| 103 |
+
return set()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_patch_text(
|
| 107 |
+
repo_path: str,
|
| 108 |
+
filepath: str,
|
| 109 |
+
ref: str = "HEAD~1",
|
| 110 |
+
against: "Optional[str]" = None,
|
| 111 |
+
context_lines: int = 3,
|
| 112 |
+
) -> str:
|
| 113 |
+
"""
|
| 114 |
+
Return the raw unified-diff patch text for a single file between
|
| 115 |
+
`ref` and `against` (working tree if against=None).
|
| 116 |
+
|
| 117 |
+
Useful for files that no longer parse: you can't get a line-level
|
| 118 |
+
symbol diff out of AST comparison, but the raw patch still shows
|
| 119 |
+
exactly what text changed (e.g. a commented-out `class` line).
|
| 120 |
+
|
| 121 |
+
Returns "" if there's no diff, the file/ref doesn't exist, or git fails.
|
| 122 |
+
"""
|
| 123 |
+
repo_path = os.path.abspath(repo_path)
|
| 124 |
+
try:
|
| 125 |
+
cmd = ["git", "diff", f"-U{context_lines}", ref]
|
| 126 |
+
if against is not None:
|
| 127 |
+
cmd.append(against)
|
| 128 |
+
cmd += ["--", filepath.lstrip("./")]
|
| 129 |
+
|
| 130 |
+
result = subprocess.run(
|
| 131 |
+
cmd,
|
| 132 |
+
cwd=repo_path,
|
| 133 |
+
capture_output=True,
|
| 134 |
+
text=True,
|
| 135 |
+
timeout=30,
|
| 136 |
+
)
|
| 137 |
+
if result.returncode != 0:
|
| 138 |
+
return ""
|
| 139 |
+
return result.stdout
|
| 140 |
+
|
| 141 |
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
| 142 |
+
return ""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def find_changed_symbols(
|
| 146 |
+
repo_path: str,
|
| 147 |
+
symbols: dict,
|
| 148 |
+
ref: str = "HEAD~1",
|
| 149 |
+
against: "Optional[str]" = None,
|
| 150 |
+
broken_files: Optional[List[str]] = None,
|
| 151 |
+
broken_file_patches: Optional[dict] = None,
|
| 152 |
+
known_broken_files: Optional[List[str]] = None,
|
| 153 |
+
) -> List[str]:
|
| 154 |
+
"""
|
| 155 |
+
Find which symbol IDs are affected by a git diff.
|
| 156 |
+
|
| 157 |
+
against=None (default): compares `ref` against the WORKING TREE, so
|
| 158 |
+
uncommitted edits are picked up. Pass against="HEAD" explicitly if you
|
| 159 |
+
only want committed changes.
|
| 160 |
+
|
| 161 |
+
Cross-references changed lines with symbol line ranges to find
|
| 162 |
+
exactly which functions/methods were modified.
|
| 163 |
+
|
| 164 |
+
`symbols` is the CURRENT index (built from the working tree / `against`
|
| 165 |
+
ref). A changed file that contributes zero entries to `symbols` is
|
| 166 |
+
ambiguous on its own -- it might have failed to parse (SyntaxError), or
|
| 167 |
+
it might just be a file with no function/method definitions (setup.py,
|
| 168 |
+
a constants module, an __init__.py with only imports, etc). To tell
|
| 169 |
+
these apart correctly, pass `known_broken_files` -- the ground-truth
|
| 170 |
+
list from extract_all_symbols()/RepositoryIndex.broken_files, which
|
| 171 |
+
only contains files that actually raised SyntaxError. Only files in
|
| 172 |
+
that list get the broken-file fallback treatment below; any other
|
| 173 |
+
zero-symbol file is treated as a normal (legitimately function-less)
|
| 174 |
+
file and simply contributes no changed symbols.
|
| 175 |
+
|
| 176 |
+
For files confirmed broken via `known_broken_files`:
|
| 177 |
+
- the file's relative path is appended to `broken_files` (if provided)
|
| 178 |
+
- we fall back to the symbol IDs that existed in the file at `ref`
|
| 179 |
+
(the prior, presumably-working revision) via `git show`, so the
|
| 180 |
+
change is still reported instead of silently disappearing.
|
| 181 |
+
- if `broken_file_patches` (a dict) is provided, it's populated with
|
| 182 |
+
{relative_file: raw_patch_text} -- the actual unified diff, since a
|
| 183 |
+
broken file can't be symbol-diffed and the patch is the only real
|
| 184 |
+
signal of what changed.
|
| 185 |
+
"""
|
| 186 |
+
known_broken = set(known_broken_files or ())
|
| 187 |
+
changed_files = get_changed_files(repo_path, ref, against)
|
| 188 |
+
if not changed_files:
|
| 189 |
+
return []
|
| 190 |
+
|
| 191 |
+
# Which changed files actually have symbols in the current index?
|
| 192 |
+
files_with_current_symbols = {
|
| 193 |
+
"./" + os.path.relpath(sym.file, os.path.abspath(repo_path))
|
| 194 |
+
for sym in symbols.values()
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
changed_symbols = []
|
| 198 |
+
|
| 199 |
+
for sym_id, sym in symbols.items():
|
| 200 |
+
sym_file = "./" + os.path.relpath(sym.file, os.path.abspath(repo_path))
|
| 201 |
+
if sym_file not in changed_files:
|
| 202 |
+
continue
|
| 203 |
+
|
| 204 |
+
changed_lines = get_changed_lines(repo_path, sym_file, ref, against)
|
| 205 |
+
if not changed_lines:
|
| 206 |
+
continue
|
| 207 |
+
|
| 208 |
+
code_lines = sym.code.count("\n") + 1
|
| 209 |
+
sym_lines = set(range(sym.lineno, sym.lineno + code_lines))
|
| 210 |
+
|
| 211 |
+
if sym_lines & changed_lines:
|
| 212 |
+
changed_symbols.append(sym_id)
|
| 213 |
+
|
| 214 |
+
# Check for deleted symbols or broken files
|
| 215 |
+
for changed_file in changed_files:
|
| 216 |
+
# Handle broken files (SyntaxError)
|
| 217 |
+
if changed_file in known_broken:
|
| 218 |
+
if broken_files is not None and changed_file not in broken_files:
|
| 219 |
+
broken_files.append(changed_file)
|
| 220 |
+
|
| 221 |
+
if broken_file_patches is not None:
|
| 222 |
+
broken_file_patches[changed_file] = get_patch_text(
|
| 223 |
+
repo_path, changed_file, ref, against
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
prior_ids = _symbol_ids_at_ref(repo_path, changed_file, ref)
|
| 227 |
+
for p_id in prior_ids:
|
| 228 |
+
if p_id not in changed_symbols:
|
| 229 |
+
changed_symbols.append(p_id)
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
# Handle valid files: find symbols that existed before but are gone now
|
| 233 |
+
prior_ids = _symbol_ids_at_ref(repo_path, changed_file, ref)
|
| 234 |
+
current_ids = {
|
| 235 |
+
sym_id for sym_id, sym in symbols.items()
|
| 236 |
+
if "./" + os.path.relpath(sym.file, os.path.abspath(repo_path)) == changed_file
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
deleted_ids = set(prior_ids) - current_ids
|
| 240 |
+
for d_id in deleted_ids:
|
| 241 |
+
if d_id not in changed_symbols:
|
| 242 |
+
changed_symbols.append(d_id)
|
| 243 |
+
|
| 244 |
+
return changed_symbols
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def _symbol_ids_at_ref(repo_path: str, filepath: str, ref: str) -> List[str]:
|
| 248 |
+
"""
|
| 249 |
+
Best-effort: list symbol IDs ("./file.py:Name") that existed in
|
| 250 |
+
`filepath` at git ref `ref`, by parsing the file's contents at that
|
| 251 |
+
revision. Returns [] if the file didn't exist, wasn't valid Python
|
| 252 |
+
at that ref either, or git/parse fails for any reason.
|
| 253 |
+
"""
|
| 254 |
+
repo_path = os.path.abspath(repo_path)
|
| 255 |
+
try:
|
| 256 |
+
result = subprocess.run(
|
| 257 |
+
["git", "show", f"{ref}:{filepath.lstrip('./')}"],
|
| 258 |
+
cwd=repo_path,
|
| 259 |
+
capture_output=True,
|
| 260 |
+
text=True,
|
| 261 |
+
timeout=30,
|
| 262 |
+
)
|
| 263 |
+
if result.returncode != 0:
|
| 264 |
+
return []
|
| 265 |
+
|
| 266 |
+
import ast as _ast
|
| 267 |
+
try:
|
| 268 |
+
tree = _ast.parse(result.stdout)
|
| 269 |
+
except SyntaxError:
|
| 270 |
+
return []
|
| 271 |
+
|
| 272 |
+
class _FuncVisitor(_ast.NodeVisitor):
|
| 273 |
+
def __init__(self):
|
| 274 |
+
self.class_stack = []
|
| 275 |
+
self.names = []
|
| 276 |
+
|
| 277 |
+
def visit_ClassDef(self, node):
|
| 278 |
+
self.class_stack.append(node.name)
|
| 279 |
+
self.generic_visit(node)
|
| 280 |
+
self.class_stack.pop()
|
| 281 |
+
|
| 282 |
+
def visit_FunctionDef(self, node):
|
| 283 |
+
self._add(node)
|
| 284 |
+
self.generic_visit(node)
|
| 285 |
+
|
| 286 |
+
def visit_AsyncFunctionDef(self, node):
|
| 287 |
+
self._add(node)
|
| 288 |
+
self.generic_visit(node)
|
| 289 |
+
|
| 290 |
+
def _add(self, node):
|
| 291 |
+
if self.class_stack:
|
| 292 |
+
self.names.append(f"{self.class_stack[-1]}.{node.name}")
|
| 293 |
+
else:
|
| 294 |
+
self.names.append(node.name)
|
| 295 |
+
|
| 296 |
+
visitor = _FuncVisitor()
|
| 297 |
+
visitor.visit(tree)
|
| 298 |
+
return [f"{filepath}:{name}" for name in visitor.names]
|
| 299 |
+
|
| 300 |
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
| 301 |
+
return []
|
diffcontext/diff/state_manager.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
state_manager.py — Snapshot-based change detection (no git required).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from typing import Dict, Iterable, List, Optional
|
| 8 |
+
|
| 9 |
+
from ..models import DiffResult
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _symbol_file(fn_id: str) -> str:
|
| 13 |
+
"""Extract the file portion of a 'file.py:Class.method' symbol id."""
|
| 14 |
+
return fn_id.split(":", 1)[0] if ":" in fn_id else fn_id
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def compare_states(
|
| 18 |
+
previous: Dict[str, dict],
|
| 19 |
+
current: Dict[str, dict],
|
| 20 |
+
broken_files: Optional[Iterable[str]] = None,
|
| 21 |
+
) -> DiffResult:
|
| 22 |
+
"""
|
| 23 |
+
Compare two snapshots of repository functions.
|
| 24 |
+
|
| 25 |
+
Each snapshot is: {function_id: {"code": "..."}}
|
| 26 |
+
|
| 27 |
+
broken_files: relative paths (e.g. "./objects.py") of files that failed
|
| 28 |
+
to parse (SyntaxError) when building `current`. Symbols that previously
|
| 29 |
+
existed in one of these files are reported as `modified` rather than
|
| 30 |
+
`deleted` -- the function wasn't removed, the file just can't be parsed
|
| 31 |
+
right now. The file itself is also recorded in `broken_files` so callers
|
| 32 |
+
can flag it distinctly from a normal diff.
|
| 33 |
+
"""
|
| 34 |
+
broken_files = set(broken_files or ())
|
| 35 |
+
|
| 36 |
+
modified = []
|
| 37 |
+
added = []
|
| 38 |
+
deleted = []
|
| 39 |
+
|
| 40 |
+
for fn_id in previous:
|
| 41 |
+
if fn_id not in current:
|
| 42 |
+
if _symbol_file(fn_id) in broken_files:
|
| 43 |
+
# File failed to parse this run -- treat every symbol that
|
| 44 |
+
# used to live there as modified (not deleted), since the
|
| 45 |
+
# change (the syntax break) is exactly what needs review.
|
| 46 |
+
modified.append(fn_id)
|
| 47 |
+
else:
|
| 48 |
+
deleted.append(fn_id)
|
| 49 |
+
elif previous[fn_id] != current[fn_id]:
|
| 50 |
+
modified.append(fn_id)
|
| 51 |
+
|
| 52 |
+
for fn_id in current:
|
| 53 |
+
if fn_id not in previous:
|
| 54 |
+
added.append(fn_id)
|
| 55 |
+
|
| 56 |
+
return DiffResult(
|
| 57 |
+
modified=modified,
|
| 58 |
+
added=added,
|
| 59 |
+
deleted=deleted,
|
| 60 |
+
broken_files=sorted(broken_files),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def save_state(state: Dict, path: str = "diffcontext_state.json"):
|
| 65 |
+
"""Save function snapshot to disk."""
|
| 66 |
+
with open(path, "w") as f:
|
| 67 |
+
json.dump(state, f, indent=2)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_state(path: str = "diffcontext_state.json") -> Dict:
|
| 71 |
+
"""Load previous function snapshot from disk."""
|
| 72 |
+
if not os.path.exists(path):
|
| 73 |
+
return {}
|
| 74 |
+
with open(path, "r") as f:
|
| 75 |
+
return json.load(f)
|
diffcontext/graph_builder.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graph_builder.py — Build the full dependency graph for a Python repository.
|
| 3 |
+
|
| 4 |
+
Edge types (v2):
|
| 5 |
+
1. Direct call edges — f() calls g() → f→g
|
| 6 |
+
2. Inheritance override edges — Child.method → Parent.method (child only)
|
| 7 |
+
3. Shared-import consumer edges— files co-importing same module (capped ≤10)
|
| 8 |
+
4. Decorator edges — @decorator applied to a function → fn→decorator
|
| 9 |
+
5. Annotated return-type edges — def f() -> MyClass: ... → f→MyClass.__init__
|
| 10 |
+
6. Same-directory sibling edges— one representative per file, light connectivity
|
| 11 |
+
|
| 12 |
+
Performance:
|
| 13 |
+
- Import maps built ONCE in pre-pass.
|
| 14 |
+
- _resolve_owner_type results memoized (manual dict cache).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import ast
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 21 |
+
|
| 22 |
+
from .scanner import find_python_files
|
| 23 |
+
from .parser import extract_all_symbols
|
| 24 |
+
from .resolver import build_import_map
|
| 25 |
+
from .symbols import (
|
| 26 |
+
extract_attribute_ownerships,
|
| 27 |
+
extract_local_var_types,
|
| 28 |
+
extract_param_types,
|
| 29 |
+
_iter_statements,
|
| 30 |
+
)
|
| 31 |
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# Fan-out caps — keep shared-import and sibling edges from becoming mega-hubs
|
| 36 |
+
_SHARED_IMPORT_MAX_CONSUMERS = 10 # skip if >10 files share the same import
|
| 37 |
+
_SAME_DIR_MAX_FILES = 20 # skip same-dir bonus if directory is huge
|
| 38 |
+
_DECORATOR_EDGE_MAX = 6 # max decorator edges per function (guards chains)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_repository_graph(repo_path: str) -> Dict[str, List[str]]:
|
| 42 |
+
"""
|
| 43 |
+
Build the complete call graph for a repository.
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
dict mapping function_id -> [list of called function_ids]
|
| 47 |
+
"""
|
| 48 |
+
repo_path = os.path.abspath(repo_path)
|
| 49 |
+
|
| 50 |
+
functions = extract_all_symbols(repo_path)
|
| 51 |
+
function_ids = set(functions)
|
| 52 |
+
|
| 53 |
+
# ── pre-pass: per-file ASTs, import maps, class registry ─────────────
|
| 54 |
+
file_trees: Dict[str, ast.Module] = {}
|
| 55 |
+
import_maps: Dict[str, Dict[str, str]] = {}
|
| 56 |
+
class_registry: Dict[str, List[str]] = {} # class_name -> [rel_file, ...]
|
| 57 |
+
classes_by_file: Dict[str, List[str]] = {} # rel_file -> [class_name, ...]
|
| 58 |
+
inheritance: Dict[str, List[Tuple]] = {} # "rel_file:ClassName" -> bases
|
| 59 |
+
factory_returns: Dict[str, Tuple] = {} # "rel_file:func" -> (q, type)
|
| 60 |
+
# Module-level var types: rel_file -> {var_name: (qualifier, bare_name)}
|
| 61 |
+
# e.g. `app = Flask(__name__)` at module scope -> {"app": (None, "Flask")}
|
| 62 |
+
module_var_types: Dict[str, Dict[str, Tuple]] = {}
|
| 63 |
+
|
| 64 |
+
for filename in find_python_files(repo_path):
|
| 65 |
+
with open(filename, "rb") as f:
|
| 66 |
+
raw = f.read()
|
| 67 |
+
check_and_warn_encoding(logger, filename, raw)
|
| 68 |
+
source = raw.decode("utf-8", errors="ignore")
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
tree = ast.parse(source)
|
| 72 |
+
except SyntaxError as e:
|
| 73 |
+
warn_syntax_error_once(logger, filename, e)
|
| 74 |
+
continue
|
| 75 |
+
|
| 76 |
+
relative_file = "./" + os.path.relpath(filename, repo_path)
|
| 77 |
+
file_trees[relative_file] = tree
|
| 78 |
+
import_maps[relative_file] = build_import_map(filename, repo_path)
|
| 79 |
+
|
| 80 |
+
mvt: Dict[str, Tuple] = {}
|
| 81 |
+
for node in tree.body:
|
| 82 |
+
if isinstance(node, ast.ClassDef):
|
| 83 |
+
class_registry.setdefault(node.name, []).append(relative_file)
|
| 84 |
+
classes_by_file.setdefault(relative_file, []).append(node.name)
|
| 85 |
+
|
| 86 |
+
bases = []
|
| 87 |
+
for b in node.bases:
|
| 88 |
+
if isinstance(b, ast.Name):
|
| 89 |
+
bases.append((None, b.id))
|
| 90 |
+
elif isinstance(b, ast.Attribute) and isinstance(b.value, ast.Name):
|
| 91 |
+
bases.append((b.value.id, b.attr))
|
| 92 |
+
elif isinstance(b, ast.Attribute):
|
| 93 |
+
bases.append((None, b.attr))
|
| 94 |
+
inheritance[f"{relative_file}:{node.name}"] = bases
|
| 95 |
+
|
| 96 |
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 97 |
+
ref = _find_return_type(node)
|
| 98 |
+
if ref:
|
| 99 |
+
factory_returns[f"{relative_file}:{node.name}"] = ref
|
| 100 |
+
ann_ref = _find_annotated_return(node)
|
| 101 |
+
if ann_ref and not ref:
|
| 102 |
+
factory_returns[f"{relative_file}:{node.name}"] = ann_ref
|
| 103 |
+
|
| 104 |
+
elif isinstance(node, ast.Assign):
|
| 105 |
+
# Track module-level: `app = Flask(__name__)`
|
| 106 |
+
if isinstance(node.value, ast.Call):
|
| 107 |
+
func = node.value.func
|
| 108 |
+
if isinstance(func, ast.Name):
|
| 109 |
+
type_ref = (None, func.id)
|
| 110 |
+
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
| 111 |
+
type_ref = (func.value.id, func.attr)
|
| 112 |
+
else:
|
| 113 |
+
type_ref = None
|
| 114 |
+
if type_ref:
|
| 115 |
+
for tgt in node.targets:
|
| 116 |
+
if isinstance(tgt, ast.Name):
|
| 117 |
+
mvt[tgt.id] = type_ref
|
| 118 |
+
|
| 119 |
+
elif isinstance(node, ast.AnnAssign):
|
| 120 |
+
# Track: `db: SQLAlchemy = SQLAlchemy(app)`
|
| 121 |
+
if node.value and isinstance(node.value, ast.Call):
|
| 122 |
+
func = node.value.func
|
| 123 |
+
if isinstance(func, ast.Name):
|
| 124 |
+
type_ref = (None, func.id)
|
| 125 |
+
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
| 126 |
+
type_ref = (func.value.id, func.attr)
|
| 127 |
+
else:
|
| 128 |
+
type_ref = None
|
| 129 |
+
if type_ref and isinstance(node.target, ast.Name):
|
| 130 |
+
mvt[node.target.id] = type_ref
|
| 131 |
+
|
| 132 |
+
if mvt:
|
| 133 |
+
module_var_types[relative_file] = mvt
|
| 134 |
+
|
| 135 |
+
# ── Resolution cache ──────────────────────────────────────────────────
|
| 136 |
+
# _resolve_owner_type is called O(symbols * calls_per_function) times.
|
| 137 |
+
# The same (qualifier, bare_name, rel_file) triple recurs constantly on
|
| 138 |
+
# large repos. Cache the result to avoid redundant work.
|
| 139 |
+
_resolve_cache: Dict[Tuple, Optional[str]] = {}
|
| 140 |
+
|
| 141 |
+
def _cached_resolve_owner_type(qualifier, bare_name, relative_file, import_map, _seen=None):
|
| 142 |
+
key = (qualifier, bare_name, relative_file)
|
| 143 |
+
if key in _resolve_cache:
|
| 144 |
+
return _resolve_cache[key]
|
| 145 |
+
result = _resolve_owner_type(
|
| 146 |
+
qualifier, bare_name, relative_file, import_map,
|
| 147 |
+
class_registry, factory_returns, import_maps, repo_path,
|
| 148 |
+
_seen=_seen,
|
| 149 |
+
classes_by_file=classes_by_file,
|
| 150 |
+
)
|
| 151 |
+
_resolve_cache[key] = result
|
| 152 |
+
return result
|
| 153 |
+
|
| 154 |
+
# ── Attribute owners ──────────────────────────────────────────────────
|
| 155 |
+
attribute_owners: Dict[str, str] = {}
|
| 156 |
+
|
| 157 |
+
# Also register module-level vars as attribute owners so that
|
| 158 |
+
# `app.run()` in a function body resolves to Flask.run.
|
| 159 |
+
for rel_file, mvt in module_var_types.items():
|
| 160 |
+
import_map = import_maps[rel_file]
|
| 161 |
+
for var_name, type_ref in mvt.items():
|
| 162 |
+
qualifier, bare_name = type_ref
|
| 163 |
+
resolved = _resolve_owner_type(
|
| 164 |
+
qualifier, bare_name, rel_file, import_map,
|
| 165 |
+
class_registry, factory_returns, import_maps, repo_path,
|
| 166 |
+
classes_by_file=classes_by_file,
|
| 167 |
+
)
|
| 168 |
+
if resolved:
|
| 169 |
+
# Key format matches attribute_owners: "ClassName.attr"
|
| 170 |
+
# but here var_name is the module-level variable.
|
| 171 |
+
attribute_owners[f"{rel_file}:{var_name}"] = resolved
|
| 172 |
+
|
| 173 |
+
for relative_file, tree in file_trees.items():
|
| 174 |
+
import_map = import_maps[relative_file]
|
| 175 |
+
raw_own = extract_attribute_ownerships(tree)
|
| 176 |
+
|
| 177 |
+
for key, type_ref in raw_own.items():
|
| 178 |
+
if type_ref is None:
|
| 179 |
+
continue
|
| 180 |
+
qualifier, bare_name = type_ref
|
| 181 |
+
resolved = _cached_resolve_owner_type(qualifier, bare_name, relative_file, import_map)
|
| 182 |
+
if resolved:
|
| 183 |
+
attribute_owners[f"{relative_file}:{key}"] = resolved
|
| 184 |
+
|
| 185 |
+
# ── Build the call graph ──────────────────────────────────────────────
|
| 186 |
+
graph: Dict[str, List[str]] = {}
|
| 187 |
+
|
| 188 |
+
for relative_file, tree in file_trees.items():
|
| 189 |
+
import_map = import_maps[relative_file]
|
| 190 |
+
|
| 191 |
+
local_name_to_id = {
|
| 192 |
+
fid.split(":", 1)[1]: fid
|
| 193 |
+
for fid in functions
|
| 194 |
+
if fid.startswith(relative_file + ":")
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function_nodes = _collect_function_nodes(tree)
|
| 198 |
+
|
| 199 |
+
for fn_node, is_method, class_name in function_nodes:
|
| 200 |
+
function_name = f"{class_name}.{fn_node.name}" if class_name else fn_node.name
|
| 201 |
+
function_id = f"{relative_file}:{function_name}"
|
| 202 |
+
|
| 203 |
+
graph.setdefault(function_id, [])
|
| 204 |
+
|
| 205 |
+
param_types = extract_param_types(fn_node)
|
| 206 |
+
local_var_types = {**param_types, **extract_local_var_types(fn_node, param_types)}
|
| 207 |
+
|
| 208 |
+
for child in ast.walk(fn_node):
|
| 209 |
+
if not isinstance(child, ast.Call):
|
| 210 |
+
continue
|
| 211 |
+
|
| 212 |
+
dep = _resolve_call(
|
| 213 |
+
child,
|
| 214 |
+
is_method,
|
| 215 |
+
class_name,
|
| 216 |
+
relative_file,
|
| 217 |
+
local_name_to_id,
|
| 218 |
+
import_map,
|
| 219 |
+
functions,
|
| 220 |
+
function_ids,
|
| 221 |
+
repo_path,
|
| 222 |
+
attribute_owners,
|
| 223 |
+
inheritance,
|
| 224 |
+
class_registry,
|
| 225 |
+
factory_returns,
|
| 226 |
+
import_maps,
|
| 227 |
+
local_var_types,
|
| 228 |
+
classes_by_file,
|
| 229 |
+
_cached_resolve_owner_type,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
if dep and dep != function_id and dep not in graph[function_id]:
|
| 233 |
+
graph[function_id].append(dep)
|
| 234 |
+
|
| 235 |
+
# ── Phase 1A: Inheritance override edges ─────────────────────────────
|
| 236 |
+
# When ChildClass overrides ParentClass.method, add child → parent edge.
|
| 237 |
+
# Only child → parent direction: "if I changed the child, show me the
|
| 238 |
+
# parent contract I might be violating." The reverse (parent → all 400
|
| 239 |
+
# children) would create mega-hubs that destroy ranking.
|
| 240 |
+
for child_key, bases in inheritance.items():
|
| 241 |
+
child_file, child_class = child_key.split(":", 1)
|
| 242 |
+
for qualifier, base_name in bases:
|
| 243 |
+
base_owner = _resolve_owner_type(
|
| 244 |
+
qualifier, base_name, child_file,
|
| 245 |
+
import_maps.get(child_file, {}),
|
| 246 |
+
class_registry, factory_returns, import_maps, repo_path,
|
| 247 |
+
classes_by_file=classes_by_file,
|
| 248 |
+
)
|
| 249 |
+
if not base_owner:
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
# Find all methods in the child class and look for same-named parent methods
|
| 253 |
+
child_prefix = f"{child_key}."
|
| 254 |
+
for fid in function_ids:
|
| 255 |
+
if not fid.startswith(child_prefix):
|
| 256 |
+
continue
|
| 257 |
+
method_name = fid[len(child_prefix):]
|
| 258 |
+
parent_method = f"{base_owner}.{method_name}"
|
| 259 |
+
if parent_method in function_ids and parent_method != fid:
|
| 260 |
+
# Child → parent only (avoids mega-hub on parent side)
|
| 261 |
+
if parent_method not in graph.get(fid, []):
|
| 262 |
+
graph.setdefault(fid, []).append(parent_method)
|
| 263 |
+
|
| 264 |
+
# ── Phase 1B: Decorator edges ─────────────────────────────────────────
|
| 265 |
+
# A function decorated with @my_decorator has an implicit dependency on
|
| 266 |
+
# my_decorator. This is one of the strongest co-change signals:
|
| 267 |
+
# if the decorator changes, all its callsites likely change too.
|
| 268 |
+
for relative_file, tree in file_trees.items():
|
| 269 |
+
import_map = import_maps[relative_file]
|
| 270 |
+
for fn_node, _is_method, _class_name in _collect_function_nodes(tree):
|
| 271 |
+
added = 0
|
| 272 |
+
for deco in fn_node.decorator_list:
|
| 273 |
+
if added >= _DECORATOR_EDGE_MAX:
|
| 274 |
+
break
|
| 275 |
+
# @plain_name or @module.name or @name(args)
|
| 276 |
+
deco_func = deco.func if isinstance(deco, ast.Call) else deco
|
| 277 |
+
dep = None
|
| 278 |
+
if isinstance(deco_func, ast.Name):
|
| 279 |
+
dep = _lookup(
|
| 280 |
+
deco_func.id,
|
| 281 |
+
{fid.split(":", 1)[1]: fid
|
| 282 |
+
for fid in functions
|
| 283 |
+
if fid.startswith(relative_file + ":")},
|
| 284 |
+
import_map, functions, repo_path,
|
| 285 |
+
)
|
| 286 |
+
elif isinstance(deco_func, ast.Attribute) and isinstance(deco_func.value, ast.Name):
|
| 287 |
+
if deco_func.value.id in import_map:
|
| 288 |
+
src = import_map[deco_func.value.id]
|
| 289 |
+
rel_src = "./" + os.path.relpath(src, repo_path)
|
| 290 |
+
dep = f"{rel_src}:{deco_func.attr}"
|
| 291 |
+
if dep not in function_ids:
|
| 292 |
+
dep = None
|
| 293 |
+
if dep:
|
| 294 |
+
fn_name_local = (
|
| 295 |
+
f"{_class_name}.{fn_node.name}"
|
| 296 |
+
if _class_name else fn_node.name
|
| 297 |
+
)
|
| 298 |
+
fid = f"{relative_file}:{fn_name_local}"
|
| 299 |
+
if fid in function_ids and dep != fid and dep not in graph.get(fid, []):
|
| 300 |
+
graph.setdefault(fid, []).append(dep)
|
| 301 |
+
added += 1
|
| 302 |
+
|
| 303 |
+
# ── Build file_groups for use by shared-import edges ────────────────────
|
| 304 |
+
file_groups: Dict[str, List[str]] = {}
|
| 305 |
+
for fid in function_ids:
|
| 306 |
+
ffile = fid.split(":")[0]
|
| 307 |
+
file_groups.setdefault(ffile, []).append(fid)
|
| 308 |
+
|
| 309 |
+
# ── Phase 1C: Shared-import consumer edges ───────────────────────────
|
| 310 |
+
# If file_a and file_b both import from the same internal module,
|
| 311 |
+
# functions in file_a and file_b are likely to co-change when that
|
| 312 |
+
# module changes. Create edges between functions across those files.
|
| 313 |
+
import_consumers: Dict[str, List[str]] = {}
|
| 314 |
+
for rel_file, imap in import_maps.items():
|
| 315 |
+
for _local_name, abs_path in imap.items():
|
| 316 |
+
# Only track internal (in-repo) imports
|
| 317 |
+
rel_imported = "./" + os.path.relpath(abs_path, repo_path)
|
| 318 |
+
if not rel_imported.startswith("./"):
|
| 319 |
+
continue
|
| 320 |
+
import_consumers.setdefault(rel_imported, []).append(rel_file)
|
| 321 |
+
|
| 322 |
+
# Deduplicate consumer file lists, then connect representatives
|
| 323 |
+
for _imported_mod, consumer_files in import_consumers.items():
|
| 324 |
+
consumer_files = list(dict.fromkeys(consumer_files)) # deduplicate, preserve order
|
| 325 |
+
if len(consumer_files) < 2 or len(consumer_files) > _SHARED_IMPORT_MAX_CONSUMERS:
|
| 326 |
+
continue
|
| 327 |
+
# Pick a representative symbol from each consumer file
|
| 328 |
+
representatives = []
|
| 329 |
+
for cfile in consumer_files:
|
| 330 |
+
rep_syms = file_groups.get(cfile, [])
|
| 331 |
+
if rep_syms:
|
| 332 |
+
representatives.append(rep_syms[0])
|
| 333 |
+
# Connect representatives pairwise
|
| 334 |
+
for i, a in enumerate(representatives):
|
| 335 |
+
for b in representatives[i + 1:]:
|
| 336 |
+
if b not in graph.get(a, []):
|
| 337 |
+
graph.setdefault(a, []).append(b)
|
| 338 |
+
if a not in graph.get(b, []):
|
| 339 |
+
graph.setdefault(b, []).append(a)
|
| 340 |
+
|
| 341 |
+
# ── Phase 1D: Same-directory sibling edges ────────────────────────────
|
| 342 |
+
# Files in the same package directory tend to co-change (tests ↔ impl,
|
| 343 |
+
# models ↔ serializers, etc.). Connect one representative per file to
|
| 344 |
+
# one representative from every other file in the same directory.
|
| 345 |
+
# Cap: skip directories with >_SAME_DIR_MAX_FILES Python files to avoid
|
| 346 |
+
# linking unrelated utility grab-bags.
|
| 347 |
+
dir_files: Dict[str, List[str]] = {}
|
| 348 |
+
for rel_file in file_groups:
|
| 349 |
+
dir_part = os.path.dirname(rel_file)
|
| 350 |
+
dir_files.setdefault(dir_part, []).append(rel_file)
|
| 351 |
+
|
| 352 |
+
for _dir, dir_file_list in dir_files.items():
|
| 353 |
+
if len(dir_file_list) < 2 or len(dir_file_list) > _SAME_DIR_MAX_FILES:
|
| 354 |
+
continue
|
| 355 |
+
reps = []
|
| 356 |
+
for df in dir_file_list:
|
| 357 |
+
syms = file_groups.get(df, [])
|
| 358 |
+
if syms:
|
| 359 |
+
reps.append(syms[0]) # one rep per file
|
| 360 |
+
for i, a in enumerate(reps):
|
| 361 |
+
for b in reps[i + 1:]:
|
| 362 |
+
if b not in graph.get(a, []):
|
| 363 |
+
graph.setdefault(a, []).append(b)
|
| 364 |
+
if a not in graph.get(b, []):
|
| 365 |
+
graph.setdefault(b, []).append(a)
|
| 366 |
+
|
| 367 |
+
# ── Phase 1E: Sliding-window within-file edges ────────────────────────
|
| 368 |
+
# Functions defined near each other in the same file are empirically the
|
| 369 |
+
# strongest co-change signal after direct calls. A sliding window of
|
| 370 |
+
# WINDOW_SIZE links each function to its closest neighbours in definition
|
| 371 |
+
# order. Cap: skip files with >FILE_WINDOW_MAX_SYMS symbols (e.g. a
|
| 372 |
+
# 600-function god-file would create O(n*w) noise edges).
|
| 373 |
+
WINDOW_SIZE = 3 # each function linked to ±3 neighbours
|
| 374 |
+
FILE_WINDOW_MAX_SYMS = 60 # skip window edges in very large files
|
| 375 |
+
|
| 376 |
+
for rel_file, syms_in_file in file_groups.items():
|
| 377 |
+
if len(syms_in_file) < 2 or len(syms_in_file) > FILE_WINDOW_MAX_SYMS:
|
| 378 |
+
continue
|
| 379 |
+
for i, a in enumerate(syms_in_file):
|
| 380 |
+
for j in range(i + 1, min(i + 1 + WINDOW_SIZE, len(syms_in_file))):
|
| 381 |
+
b = syms_in_file[j]
|
| 382 |
+
if b not in graph.get(a, []):
|
| 383 |
+
graph.setdefault(a, []).append(b)
|
| 384 |
+
if a not in graph.get(b, []):
|
| 385 |
+
graph.setdefault(b, []).append(a)
|
| 386 |
+
|
| 387 |
+
# ── Phase 1F: Light parent→child inheritance edges ────────────────────
|
| 388 |
+
# Child→parent already exists (Phase 1A). For parents with FEW children
|
| 389 |
+
# (≤PARENT_CHILD_MAX_CHILDREN), also add parent→child so that a change
|
| 390 |
+
# to the parent method surfaces its direct overriders. We skip parents
|
| 391 |
+
# with many children to avoid creating mega-hubs (e.g. BaseModel in
|
| 392 |
+
# pydantic has 400+ subclasses — those would destroy ranking).
|
| 393 |
+
PARENT_CHILD_MAX_CHILDREN = 8
|
| 394 |
+
|
| 395 |
+
# Build a map: parent_method_id -> [child_method_ids]
|
| 396 |
+
parent_to_children: Dict[str, List[str]] = {}
|
| 397 |
+
for child_key, bases in inheritance.items():
|
| 398 |
+
child_file, child_class = child_key.split(":", 1)
|
| 399 |
+
for qualifier, base_name in bases:
|
| 400 |
+
base_owner = _resolve_owner_type(
|
| 401 |
+
qualifier, base_name, child_file,
|
| 402 |
+
import_maps.get(child_file, {}),
|
| 403 |
+
class_registry, factory_returns, import_maps, repo_path,
|
| 404 |
+
classes_by_file=classes_by_file,
|
| 405 |
+
)
|
| 406 |
+
if not base_owner:
|
| 407 |
+
continue
|
| 408 |
+
child_prefix = f"{child_key}."
|
| 409 |
+
for fid in function_ids:
|
| 410 |
+
if not fid.startswith(child_prefix):
|
| 411 |
+
continue
|
| 412 |
+
method_name = fid[len(child_prefix):]
|
| 413 |
+
parent_method = f"{base_owner}.{method_name}"
|
| 414 |
+
if parent_method in function_ids and parent_method != fid:
|
| 415 |
+
parent_to_children.setdefault(parent_method, []).append(fid)
|
| 416 |
+
|
| 417 |
+
for parent_method, children in parent_to_children.items():
|
| 418 |
+
if len(children) > PARENT_CHILD_MAX_CHILDREN:
|
| 419 |
+
continue # too many — would create a mega-hub
|
| 420 |
+
for child_fid in children:
|
| 421 |
+
if child_fid not in graph.get(parent_method, []):
|
| 422 |
+
graph.setdefault(parent_method, []).append(child_fid)
|
| 423 |
+
|
| 424 |
+
return graph
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
| 428 |
+
|
| 429 |
+
def _collect_function_nodes(tree):
|
| 430 |
+
"""
|
| 431 |
+
Return list of (function_node, is_method, class_name) for EVERY function
|
| 432 |
+
in the file, including nested functions and closures.
|
| 433 |
+
|
| 434 |
+
Mirrors what parser.py's _FunctionCollector does so that the graph covers
|
| 435 |
+
every symbol the parser emits (previously missed ~30-40% of nodes).
|
| 436 |
+
"""
|
| 437 |
+
result = []
|
| 438 |
+
_collect_recursive(tree.body, class_stack=[], result=result)
|
| 439 |
+
return result
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def _collect_recursive(stmts, class_stack, result):
|
| 443 |
+
"""Recursively collect function nodes from a list of statements."""
|
| 444 |
+
for node in stmts:
|
| 445 |
+
if isinstance(node, ast.ClassDef):
|
| 446 |
+
class_stack.append(node.name)
|
| 447 |
+
_collect_recursive(node.body, class_stack, result)
|
| 448 |
+
class_stack.pop()
|
| 449 |
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 450 |
+
if class_stack:
|
| 451 |
+
class_name = ".".join(class_stack)
|
| 452 |
+
is_method = True
|
| 453 |
+
else:
|
| 454 |
+
class_name = None
|
| 455 |
+
is_method = False
|
| 456 |
+
result.append((node, is_method, class_name))
|
| 457 |
+
# Also recurse into the function body to catch closures/nested funcs
|
| 458 |
+
_collect_recursive(node.body, class_stack, result)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _find_return_type(node):
|
| 462 |
+
found = None
|
| 463 |
+
for stmt in _iter_statements(node.body):
|
| 464 |
+
if isinstance(stmt, ast.Return) and stmt.value is not None:
|
| 465 |
+
if not isinstance(stmt.value, ast.Call):
|
| 466 |
+
return None
|
| 467 |
+
func = stmt.value.func
|
| 468 |
+
if isinstance(func, ast.Name):
|
| 469 |
+
ref = (None, func.id)
|
| 470 |
+
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
| 471 |
+
ref = (func.value.id, func.attr)
|
| 472 |
+
elif isinstance(func, ast.Attribute):
|
| 473 |
+
ref = (None, func.attr)
|
| 474 |
+
else:
|
| 475 |
+
return None
|
| 476 |
+
if found is None:
|
| 477 |
+
found = ref
|
| 478 |
+
elif found != ref:
|
| 479 |
+
return None
|
| 480 |
+
return found
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _find_annotated_return(node):
|
| 484 |
+
"""
|
| 485 |
+
Extract (qualifier, bare_name) from a PEP-3107 return annotation.
|
| 486 |
+
|
| 487 |
+
def f() -> MyClass: ... -> (None, "MyClass")
|
| 488 |
+
def f() -> module.MyClass: ... -> ("module", "MyClass")
|
| 489 |
+
|
| 490 |
+
Skips primitive annotations (str, int, bool, None, etc.) and generics
|
| 491 |
+
like List[X] where the outer type is not a class we own.
|
| 492 |
+
"""
|
| 493 |
+
PRIMITIVES = frozenset({
|
| 494 |
+
"str", "int", "float", "bool", "bytes", "None",
|
| 495 |
+
"list", "dict", "set", "tuple", "Any", "Optional",
|
| 496 |
+
"List", "Dict", "Set", "Tuple", "Iterator", "Generator",
|
| 497 |
+
"Iterable", "Sequence", "Mapping", "Type", "Union",
|
| 498 |
+
})
|
| 499 |
+
ann = node.returns
|
| 500 |
+
if ann is None:
|
| 501 |
+
return None
|
| 502 |
+
if isinstance(ann, ast.Constant):
|
| 503 |
+
return None
|
| 504 |
+
if isinstance(ann, ast.Name):
|
| 505 |
+
if ann.id in PRIMITIVES:
|
| 506 |
+
return None
|
| 507 |
+
return (None, ann.id)
|
| 508 |
+
if isinstance(ann, ast.Attribute) and isinstance(ann.value, ast.Name):
|
| 509 |
+
if ann.attr in PRIMITIVES:
|
| 510 |
+
return None
|
| 511 |
+
return (ann.value.id, ann.attr)
|
| 512 |
+
# Subscript like Optional[Router] — unwrap one level
|
| 513 |
+
if isinstance(ann, ast.Subscript):
|
| 514 |
+
return _find_annotated_return(
|
| 515 |
+
type("_Stub", (), {"returns": ann.slice})() # type: ignore[arg-type]
|
| 516 |
+
)
|
| 517 |
+
return None
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _resolve_owner_type(
|
| 521 |
+
qualifier, bare_name, relative_file, import_map,
|
| 522 |
+
class_registry, factory_returns, import_maps, repo_path, _seen=None,
|
| 523 |
+
classes_by_file=None,
|
| 524 |
+
):
|
| 525 |
+
if _seen is None:
|
| 526 |
+
_seen = set()
|
| 527 |
+
|
| 528 |
+
cache_key = (qualifier, bare_name, relative_file)
|
| 529 |
+
if cache_key in _seen:
|
| 530 |
+
return None
|
| 531 |
+
_seen.add(cache_key)
|
| 532 |
+
|
| 533 |
+
if qualifier:
|
| 534 |
+
if qualifier not in import_map:
|
| 535 |
+
return None
|
| 536 |
+
target_file = "./" + os.path.relpath(import_map[qualifier], repo_path)
|
| 537 |
+
|
| 538 |
+
if bare_name in class_registry:
|
| 539 |
+
if target_file in class_registry[bare_name]:
|
| 540 |
+
return f"{target_file}:{bare_name}"
|
| 541 |
+
if target_file.endswith("__init__.py"):
|
| 542 |
+
target_dir = target_file[:-12]
|
| 543 |
+
for cand_file in class_registry[bare_name]:
|
| 544 |
+
if cand_file.startswith(target_dir + "/"):
|
| 545 |
+
return f"{cand_file}:{bare_name}"
|
| 546 |
+
|
| 547 |
+
factory_key = f"{target_file}:{bare_name}"
|
| 548 |
+
if factory_key in factory_returns:
|
| 549 |
+
return _resolve_owner_type(
|
| 550 |
+
*factory_returns[factory_key], target_file,
|
| 551 |
+
import_maps.get(target_file, {}), class_registry,
|
| 552 |
+
factory_returns, import_maps, repo_path, _seen,
|
| 553 |
+
classes_by_file,
|
| 554 |
+
)
|
| 555 |
+
return None
|
| 556 |
+
|
| 557 |
+
# bare name
|
| 558 |
+
if bare_name in class_registry and relative_file in class_registry[bare_name]:
|
| 559 |
+
return f"{relative_file}:{bare_name}"
|
| 560 |
+
|
| 561 |
+
if bare_name in import_map:
|
| 562 |
+
target_file = "./" + os.path.relpath(import_map[bare_name], repo_path)
|
| 563 |
+
|
| 564 |
+
if bare_name in class_registry:
|
| 565 |
+
if target_file in class_registry[bare_name]:
|
| 566 |
+
return f"{target_file}:{bare_name}"
|
| 567 |
+
if target_file.endswith("__init__.py"):
|
| 568 |
+
target_dir = target_file[:-12]
|
| 569 |
+
for cand_file in class_registry[bare_name]:
|
| 570 |
+
if cand_file.startswith(target_dir + "/"):
|
| 571 |
+
return f"{cand_file}:{bare_name}"
|
| 572 |
+
elif classes_by_file:
|
| 573 |
+
file_classes = classes_by_file.get(target_file, [])
|
| 574 |
+
if len(file_classes) == 1:
|
| 575 |
+
return f"{target_file}:{file_classes[0]}"
|
| 576 |
+
|
| 577 |
+
factory_key = f"{target_file}:{bare_name}"
|
| 578 |
+
if factory_key in factory_returns:
|
| 579 |
+
return _resolve_owner_type(
|
| 580 |
+
*factory_returns[factory_key], target_file,
|
| 581 |
+
import_maps.get(target_file, {}), class_registry,
|
| 582 |
+
factory_returns, import_maps, repo_path, _seen,
|
| 583 |
+
classes_by_file,
|
| 584 |
+
)
|
| 585 |
+
return None
|
| 586 |
+
|
| 587 |
+
factory_key = f"{relative_file}:{bare_name}"
|
| 588 |
+
if factory_key in factory_returns:
|
| 589 |
+
return _resolve_owner_type(
|
| 590 |
+
*factory_returns[factory_key], relative_file,
|
| 591 |
+
import_map, class_registry, factory_returns,
|
| 592 |
+
import_maps, repo_path, _seen,
|
| 593 |
+
classes_by_file=classes_by_file,
|
| 594 |
+
)
|
| 595 |
+
return None
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def _resolve_owner_of_expr(
|
| 599 |
+
node, class_name, relative_file, is_method, attribute_owners,
|
| 600 |
+
local_var_types=None, import_map=None, class_registry=None,
|
| 601 |
+
factory_returns=None, import_maps=None, repo_path=None,
|
| 602 |
+
classes_by_file=None, _cached_resolve=None,
|
| 603 |
+
):
|
| 604 |
+
if isinstance(node, ast.Name) and node.id == "self" and is_method and class_name:
|
| 605 |
+
return f"{relative_file}:{class_name}"
|
| 606 |
+
|
| 607 |
+
if (
|
| 608 |
+
isinstance(node, ast.Name)
|
| 609 |
+
and local_var_types
|
| 610 |
+
and node.id in local_var_types
|
| 611 |
+
and import_map is not None
|
| 612 |
+
):
|
| 613 |
+
qualifier, bare_name = local_var_types[node.id]
|
| 614 |
+
if _cached_resolve:
|
| 615 |
+
return _cached_resolve(qualifier, bare_name, relative_file, import_map)
|
| 616 |
+
return _resolve_owner_type(
|
| 617 |
+
qualifier, bare_name, relative_file, import_map,
|
| 618 |
+
class_registry or {}, factory_returns or {},
|
| 619 |
+
import_maps or {}, repo_path or "",
|
| 620 |
+
classes_by_file=classes_by_file,
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
# Module-level variable fallback: `app = Flask()` at module scope.
|
| 624 |
+
# attribute_owners stores these as "{rel_file}:{var_name}".
|
| 625 |
+
if isinstance(node, ast.Name) and attribute_owners:
|
| 626 |
+
module_key = f"{relative_file}:{node.id}"
|
| 627 |
+
if module_key in attribute_owners:
|
| 628 |
+
return attribute_owners[module_key]
|
| 629 |
+
|
| 630 |
+
if isinstance(node, ast.Attribute):
|
| 631 |
+
base_owner = _resolve_owner_of_expr(
|
| 632 |
+
node.value, class_name, relative_file, is_method, attribute_owners,
|
| 633 |
+
local_var_types, import_map, class_registry,
|
| 634 |
+
factory_returns, import_maps, repo_path,
|
| 635 |
+
classes_by_file, _cached_resolve,
|
| 636 |
+
)
|
| 637 |
+
if base_owner is None:
|
| 638 |
+
return None
|
| 639 |
+
return attribute_owners.get(f"{base_owner}.{node.attr}")
|
| 640 |
+
|
| 641 |
+
return None
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
def _resolve_via_inheritance(
|
| 645 |
+
owner_type, method_name, inheritance, class_registry,
|
| 646 |
+
import_maps, function_ids, repo_path, _seen=None,
|
| 647 |
+
classes_by_file=None,
|
| 648 |
+
):
|
| 649 |
+
if _seen is None:
|
| 650 |
+
_seen = set()
|
| 651 |
+
if owner_type in _seen:
|
| 652 |
+
return None
|
| 653 |
+
_seen.add(owner_type)
|
| 654 |
+
|
| 655 |
+
rel_file, class_name = owner_type.split(":", 1)
|
| 656 |
+
import_map = import_maps.get(rel_file, {})
|
| 657 |
+
|
| 658 |
+
for qualifier, base_name in inheritance.get(owner_type, []):
|
| 659 |
+
base_owner = _resolve_owner_type(
|
| 660 |
+
qualifier, base_name, rel_file, import_map,
|
| 661 |
+
class_registry, {}, import_maps, repo_path,
|
| 662 |
+
classes_by_file=classes_by_file,
|
| 663 |
+
)
|
| 664 |
+
if not base_owner:
|
| 665 |
+
continue
|
| 666 |
+
|
| 667 |
+
candidate = f"{base_owner}.{method_name}"
|
| 668 |
+
if candidate in function_ids:
|
| 669 |
+
return candidate
|
| 670 |
+
|
| 671 |
+
deeper = _resolve_via_inheritance(
|
| 672 |
+
base_owner, method_name, inheritance, class_registry,
|
| 673 |
+
import_maps, function_ids, repo_path, _seen,
|
| 674 |
+
classes_by_file,
|
| 675 |
+
)
|
| 676 |
+
if deeper:
|
| 677 |
+
return deeper
|
| 678 |
+
|
| 679 |
+
return None
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def _resolve_call(
|
| 683 |
+
call_node, is_method, class_name, relative_file,
|
| 684 |
+
local_name_to_id, import_map, all_functions, function_ids,
|
| 685 |
+
repo_path, attribute_owners, inheritance, class_registry,
|
| 686 |
+
factory_returns, import_maps, local_var_types=None,
|
| 687 |
+
classes_by_file=None, _cached_resolve=None,
|
| 688 |
+
):
|
| 689 |
+
func = call_node.func
|
| 690 |
+
|
| 691 |
+
if isinstance(func, ast.Name):
|
| 692 |
+
return _lookup(func.id, local_name_to_id, import_map, all_functions, repo_path)
|
| 693 |
+
|
| 694 |
+
if isinstance(func, ast.Attribute):
|
| 695 |
+
method_name = func.attr
|
| 696 |
+
|
| 697 |
+
owner_type = _resolve_owner_of_expr(
|
| 698 |
+
func.value, class_name, relative_file, is_method, attribute_owners,
|
| 699 |
+
local_var_types, import_map, class_registry,
|
| 700 |
+
factory_returns, import_maps, repo_path,
|
| 701 |
+
classes_by_file, _cached_resolve,
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
if owner_type:
|
| 705 |
+
candidate = f"{owner_type}.{method_name}"
|
| 706 |
+
if candidate in function_ids:
|
| 707 |
+
return candidate
|
| 708 |
+
|
| 709 |
+
resolved = _resolve_via_inheritance(
|
| 710 |
+
owner_type, method_name, inheritance, class_registry,
|
| 711 |
+
import_maps, function_ids, repo_path,
|
| 712 |
+
classes_by_file=classes_by_file,
|
| 713 |
+
)
|
| 714 |
+
if resolved:
|
| 715 |
+
return resolved
|
| 716 |
+
return None
|
| 717 |
+
|
| 718 |
+
if isinstance(func.value, ast.Name) and func.value.id in import_map:
|
| 719 |
+
source_file = import_map[func.value.id]
|
| 720 |
+
relative_source = "./" + os.path.relpath(source_file, repo_path)
|
| 721 |
+
candidate = f"{relative_source}:{method_name}"
|
| 722 |
+
if candidate in all_functions:
|
| 723 |
+
return candidate
|
| 724 |
+
|
| 725 |
+
return None
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def _lookup(called_name, local_name_to_id, import_map, all_functions, repo_path):
|
| 729 |
+
if called_name in local_name_to_id:
|
| 730 |
+
return local_name_to_id[called_name]
|
| 731 |
+
if called_name in import_map:
|
| 732 |
+
source_file = import_map[called_name]
|
| 733 |
+
relative_source = "./" + os.path.relpath(source_file, repo_path)
|
| 734 |
+
candidate = f"{relative_source}:{called_name}"
|
| 735 |
+
if candidate in all_functions:
|
| 736 |
+
return candidate
|
| 737 |
+
return None
|
diffcontext/impact/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""impact subpackage — blast radius, scoring, traversal."""
|
diffcontext/impact/blast_radius.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
blast_radius.py — Find all symbols transitively affected by a change.
|
| 3 |
+
|
| 4 |
+
Given a changed symbol, walks the REVERSE graph (callers) to find
|
| 5 |
+
everything that depends on it.
|
| 6 |
+
|
| 7 |
+
Performance fix: accept an optional pre-built reverse graph so the caller
|
| 8 |
+
can build it once and reuse it across multiple symbols instead of
|
| 9 |
+
rebuilding it O(N) times.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from typing import Dict, List, Optional, Set
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_reverse_graph(graph: Dict[str, List[str]]) -> Dict[str, Set[str]]:
|
| 16 |
+
"""Build the reverse (caller) graph once; reuse for many queries."""
|
| 17 |
+
reverse: Dict[str, Set[str]] = {}
|
| 18 |
+
for caller, callees in graph.items():
|
| 19 |
+
for callee in callees:
|
| 20 |
+
reverse.setdefault(callee, set()).add(caller)
|
| 21 |
+
return reverse
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_blast_radius(
|
| 25 |
+
graph: Dict[str, List[str]],
|
| 26 |
+
changed_symbol: str,
|
| 27 |
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
| 28 |
+
) -> List[str]:
|
| 29 |
+
"""
|
| 30 |
+
All functions that (transitively) call changed_symbol.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
graph: Forward call graph (caller -> [callees]).
|
| 34 |
+
changed_symbol: The symbol whose blast radius we want.
|
| 35 |
+
reverse: Pre-built reverse graph. If None it is built here
|
| 36 |
+
(correct but wasteful when called in a loop).
|
| 37 |
+
|
| 38 |
+
Uses iterative DFS with cycle detection.
|
| 39 |
+
"""
|
| 40 |
+
if reverse is None:
|
| 41 |
+
reverse = build_reverse_graph(graph)
|
| 42 |
+
|
| 43 |
+
affected: List[str] = []
|
| 44 |
+
visited: Set[str] = set()
|
| 45 |
+
|
| 46 |
+
# Iterative DFS on reverse graph
|
| 47 |
+
stack = [changed_symbol]
|
| 48 |
+
visited.add(changed_symbol)
|
| 49 |
+
|
| 50 |
+
while stack:
|
| 51 |
+
current = stack.pop()
|
| 52 |
+
for caller in reverse.get(current, ()):
|
| 53 |
+
if caller not in visited:
|
| 54 |
+
visited.add(caller)
|
| 55 |
+
affected.append(caller)
|
| 56 |
+
stack.append(caller)
|
| 57 |
+
|
| 58 |
+
return affected
|
diffcontext/impact/scoring.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
scoring.py — Impact scoring for prioritizing symbols in context.
|
| 3 |
+
|
| 4 |
+
Algorithm: Bidirectional decay propagation from changed symbols.
|
| 5 |
+
|
| 6 |
+
Forward (callees): score decays by CALLEE_DECAY per hop
|
| 7 |
+
Backward (callers): score decays by CALLER_DECAY per hop
|
| 8 |
+
|
| 9 |
+
Structural bonuses:
|
| 10 |
+
- Sibling bonus: shares a caller with a changed symbol (co-change signal),
|
| 11 |
+
weighted by 1/caller_outdegree so hub callers don’t flood the pool,
|
| 12 |
+
and log-scaled before capping to dampen compounding across many hubs.
|
| 13 |
+
- Structural bonus: log2(1 + indegree)*3 + log2(1 + outdegree),
|
| 14 |
+
hard-capped at STRUCT_MAX so mega-hub nodes (outdegree=400) don’t
|
| 15 |
+
accumulate +800 and crowd out genuinely co-changed code.
|
| 16 |
+
|
| 17 |
+
Changes vs v1:
|
| 18 |
+
1. Structural bonus capped (STRUCT_MAX=15). Uncapped bonus turned hubs
|
| 19 |
+
into permanent top-scorers regardless of actual co-change signal.
|
| 20 |
+
2. BFS propagation cutoff raised 5.0 → 8.0. Large repos (transformers)
|
| 21 |
+
have long paths; 5.0 prematurely cut valid propagation chains.
|
| 22 |
+
3. Sibling bonus log-scaled before accumulation to dampen compounding.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import math
|
| 26 |
+
from collections import deque
|
| 27 |
+
from typing import Dict, List, Optional, Set
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Tunable constants
|
| 31 |
+
CHANGED_SCORE = 100.0
|
| 32 |
+
CALLEE_DECAY = 0.65 # lowered: callees are less likely to co-change
|
| 33 |
+
CALLER_DECAY = 0.85 # raised: callers co-change more than callees
|
| 34 |
+
CALLEE_BASE = 90.0 # direct callee of changed symbol
|
| 35 |
+
CALLER_BASE = 85.0 # direct caller
|
| 36 |
+
SIBLING_BASE = 60.0 # base for sibling contribution (divided by caller_outdegree)
|
| 37 |
+
SIBLING_MAX = 80.0 # cap: a symbol can’t accumulate more than this from siblings
|
| 38 |
+
BLAST_BASE = 30.0 # in blast_radii but not reached by BFS
|
| 39 |
+
MAX_HOPS = 8 # propagation depth limit
|
| 40 |
+
BFS_CUTOFF = 8.0 # stop propagating a path when score drops below this
|
| 41 |
+
STRUCT_MAX = 15.0 # hard cap on structural bonus (log-scaled)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def compute_impact_scores(
|
| 45 |
+
graph: Dict[str, List[str]],
|
| 46 |
+
changed_symbols: List[str],
|
| 47 |
+
blast_radii: Dict[str, List[str]],
|
| 48 |
+
expanded_deps: List[str] = None,
|
| 49 |
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
| 50 |
+
) -> Dict[str, float]:
|
| 51 |
+
"""
|
| 52 |
+
Score every symbol's relevance to understanding the change.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
graph: Forward call graph.
|
| 56 |
+
changed_symbols: Symbols that were modified.
|
| 57 |
+
blast_radii: Pre-computed blast radii per changed symbol.
|
| 58 |
+
expanded_deps: Symbols reachable by forward dependency expansion.
|
| 59 |
+
reverse: Pre-built reverse graph (built internally if None).
|
| 60 |
+
|
| 61 |
+
Returns dict of symbol_id -> score (higher = more important).
|
| 62 |
+
"""
|
| 63 |
+
# Build reverse graph once (or reuse caller's)
|
| 64 |
+
if reverse is None:
|
| 65 |
+
reverse = {}
|
| 66 |
+
for caller, callees in graph.items():
|
| 67 |
+
for callee in callees:
|
| 68 |
+
reverse.setdefault(callee, set()).add(caller)
|
| 69 |
+
|
| 70 |
+
changed_set = set(changed_symbols)
|
| 71 |
+
scores: Dict[str, float] = {}
|
| 72 |
+
|
| 73 |
+
# ── 1. Changed symbols = 100 ──────────────────────────────────────────
|
| 74 |
+
for sym in changed_symbols:
|
| 75 |
+
scores[sym] = CHANGED_SCORE
|
| 76 |
+
|
| 77 |
+
# ── 2. Forward BFS (callees) with decay ──────────────────────────────
|
| 78 |
+
queue: deque = deque()
|
| 79 |
+
for sym in changed_symbols:
|
| 80 |
+
for callee in graph.get(sym, []):
|
| 81 |
+
if callee not in changed_set:
|
| 82 |
+
queue.append((callee, CALLEE_BASE, 1))
|
| 83 |
+
|
| 84 |
+
visited_fwd: Set[str] = set(changed_symbols)
|
| 85 |
+
while queue:
|
| 86 |
+
node, score, hop = queue.popleft()
|
| 87 |
+
if node in visited_fwd or hop > MAX_HOPS:
|
| 88 |
+
continue
|
| 89 |
+
visited_fwd.add(node)
|
| 90 |
+
scores[node] = max(scores.get(node, 0.0), score)
|
| 91 |
+
next_score = score * CALLEE_DECAY
|
| 92 |
+
if next_score >= BFS_CUTOFF:
|
| 93 |
+
for callee in graph.get(node, []):
|
| 94 |
+
if callee not in visited_fwd:
|
| 95 |
+
queue.append((callee, next_score, hop + 1))
|
| 96 |
+
|
| 97 |
+
# ── 3. Backward BFS (callers) with decay ─────────────────────────────
|
| 98 |
+
queue2: deque = deque()
|
| 99 |
+
for sym in changed_symbols:
|
| 100 |
+
for caller in reverse.get(sym, set()):
|
| 101 |
+
if caller not in changed_set:
|
| 102 |
+
queue2.append((caller, CALLER_BASE, 1))
|
| 103 |
+
|
| 104 |
+
visited_bwd: Set[str] = set(changed_symbols)
|
| 105 |
+
while queue2:
|
| 106 |
+
node, score, hop = queue2.popleft()
|
| 107 |
+
if node in visited_bwd or hop > MAX_HOPS:
|
| 108 |
+
continue
|
| 109 |
+
visited_bwd.add(node)
|
| 110 |
+
scores[node] = max(scores.get(node, 0.0), score)
|
| 111 |
+
next_score = score * CALLER_DECAY
|
| 112 |
+
if next_score >= BFS_CUTOFF:
|
| 113 |
+
for caller in reverse.get(node, set()):
|
| 114 |
+
if caller not in visited_bwd:
|
| 115 |
+
queue2.append((caller, next_score, hop + 1))
|
| 116 |
+
|
| 117 |
+
# ── 4. Specificity-weighted sibling bonus ────────────────────────────
|
| 118 |
+
sibling_accumulator: Dict[str, float] = {}
|
| 119 |
+
|
| 120 |
+
for sym in changed_symbols:
|
| 121 |
+
for caller in reverse.get(sym, set()):
|
| 122 |
+
caller_callees = graph.get(caller, [])
|
| 123 |
+
caller_outdegree = len(caller_callees)
|
| 124 |
+
if caller_outdegree <= 1:
|
| 125 |
+
continue
|
| 126 |
+
raw_contribution = SIBLING_BASE / caller_outdegree
|
| 127 |
+
contribution = math.log2(1.0 + raw_contribution)
|
| 128 |
+
for sibling in caller_callees:
|
| 129 |
+
if sibling not in changed_set and sibling != sym:
|
| 130 |
+
sibling_accumulator[sibling] = (
|
| 131 |
+
sibling_accumulator.get(sibling, 0.0) + contribution
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
for sibling, bonus in sibling_accumulator.items():
|
| 135 |
+
capped_bonus = min(bonus, SIBLING_MAX)
|
| 136 |
+
scores[sibling] = scores.get(sibling, 0.0) + capped_bonus
|
| 137 |
+
|
| 138 |
+
# ── 5. Expanded deps: give them a meaningful score ────────────────────
|
| 139 |
+
if expanded_deps:
|
| 140 |
+
for sym in expanded_deps:
|
| 141 |
+
if sym not in scores:
|
| 142 |
+
scores[sym] = BLAST_BASE
|
| 143 |
+
|
| 144 |
+
# ── 6. Remaining blast radius symbols not yet scored ─────────────────
|
| 145 |
+
for sym, radius in blast_radii.items():
|
| 146 |
+
for affected in radius:
|
| 147 |
+
if affected not in scores:
|
| 148 |
+
scores[affected] = BLAST_BASE
|
| 149 |
+
|
| 150 |
+
# ── 7. Structural bonus: log-scaled, hard-capped ──────────────────────────
|
| 151 |
+
# Previous formula (indegree*2 + outdegree) was unbounded: a hub with
|
| 152 |
+
# indegree=50 got +100 structural bonus, drowning all co-change signal.
|
| 153 |
+
# log2(1+degree) grows slowly and the STRUCT_MAX cap prevents run-away.
|
| 154 |
+
for sym in scores:
|
| 155 |
+
indegree = len(reverse.get(sym, set()))
|
| 156 |
+
outdegree = len(graph.get(sym, []))
|
| 157 |
+
struct_bonus = math.log2(1 + indegree) * 3 + math.log2(1 + outdegree)
|
| 158 |
+
scores[sym] += min(struct_bonus, STRUCT_MAX)
|
| 159 |
+
|
| 160 |
+
return scores
|
diffcontext/impact/traversal.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
traversal.py — Dependency expansion (forward graph walk).
|
| 3 |
+
|
| 4 |
+
Given selected symbols, walk forward edges to include their callees.
|
| 5 |
+
Supports both unbounded DFS and bounded BFS.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Dict, List, Optional, Set
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def expand_dependencies(
|
| 12 |
+
graph: Dict[str, List[str]],
|
| 13 |
+
selected_symbols: List[str],
|
| 14 |
+
max_depth: Optional[int] = None,
|
| 15 |
+
) -> List[str]:
|
| 16 |
+
"""
|
| 17 |
+
Walk forward edges from selected_symbols.
|
| 18 |
+
|
| 19 |
+
max_depth=None -> full transitive closure (iterative DFS)
|
| 20 |
+
max_depth=N -> only nodes reachable within N hops (BFS)
|
| 21 |
+
"""
|
| 22 |
+
visited: Set[str] = set()
|
| 23 |
+
result: List[str] = []
|
| 24 |
+
|
| 25 |
+
if max_depth is None:
|
| 26 |
+
# Iterative DFS — safe on large repos (no recursion limit)
|
| 27 |
+
stack = list(selected_symbols)
|
| 28 |
+
while stack:
|
| 29 |
+
func = stack.pop()
|
| 30 |
+
if func in visited:
|
| 31 |
+
continue
|
| 32 |
+
visited.add(func)
|
| 33 |
+
result.append(func)
|
| 34 |
+
for dep in reversed(graph.get(func, [])):
|
| 35 |
+
if dep not in visited:
|
| 36 |
+
stack.append(dep)
|
| 37 |
+
return result
|
| 38 |
+
|
| 39 |
+
# Bounded BFS
|
| 40 |
+
frontier = list(selected_symbols)
|
| 41 |
+
for func in frontier:
|
| 42 |
+
if func not in visited:
|
| 43 |
+
visited.add(func)
|
| 44 |
+
result.append(func)
|
| 45 |
+
|
| 46 |
+
depth = 0
|
| 47 |
+
while frontier and depth < max_depth:
|
| 48 |
+
next_frontier = []
|
| 49 |
+
for func in frontier:
|
| 50 |
+
for dep in graph.get(func, []):
|
| 51 |
+
if dep not in visited:
|
| 52 |
+
visited.add(dep)
|
| 53 |
+
result.append(dep)
|
| 54 |
+
next_frontier.append(dep)
|
| 55 |
+
frontier = next_frontier
|
| 56 |
+
depth += 1
|
| 57 |
+
|
| 58 |
+
return result
|
diffcontext/impact/visualizer.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
visualizer.py — Visual blast radius renderer.
|
| 3 |
+
|
| 4 |
+
Renders the blast radius as a colored, indented tree showing:
|
| 5 |
+
- The changed symbol at the root
|
| 6 |
+
- Direct callers (who calls this?)
|
| 7 |
+
- Direct callees (what does this call?)
|
| 8 |
+
- Transitive impact propagation
|
| 9 |
+
- Proof chains: the actual code line creating each edge
|
| 10 |
+
|
| 11 |
+
Designed for terminal output with ANSI colors.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import ast
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ANSI color codes
|
| 21 |
+
class _C:
|
| 22 |
+
RED = "\033[91m"
|
| 23 |
+
YELLOW = "\033[93m"
|
| 24 |
+
GREEN = "\033[92m"
|
| 25 |
+
CYAN = "\033[96m"
|
| 26 |
+
MAGENTA = "\033[95m"
|
| 27 |
+
BLUE = "\033[94m"
|
| 28 |
+
DIM = "\033[2m"
|
| 29 |
+
BOLD = "\033[1m"
|
| 30 |
+
RESET = "\033[0m"
|
| 31 |
+
WHITE = "\033[97m"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def render_blast_radius(
|
| 35 |
+
graph: Dict[str, List[str]],
|
| 36 |
+
changed_symbols: List[str],
|
| 37 |
+
symbols: dict,
|
| 38 |
+
max_depth: int = 3,
|
| 39 |
+
show_proof: bool = False,
|
| 40 |
+
repo_path: str = "",
|
| 41 |
+
) -> str:
|
| 42 |
+
"""
|
| 43 |
+
Render a visual tree of the blast radius for changed symbols.
|
| 44 |
+
|
| 45 |
+
Returns a formatted string ready for terminal output.
|
| 46 |
+
"""
|
| 47 |
+
# Build reverse graph (callers)
|
| 48 |
+
reverse: Dict[str, Set[str]] = {}
|
| 49 |
+
for caller, callees in graph.items():
|
| 50 |
+
for callee in callees:
|
| 51 |
+
reverse.setdefault(callee, set()).add(caller)
|
| 52 |
+
|
| 53 |
+
lines: List[str] = []
|
| 54 |
+
|
| 55 |
+
lines.append("")
|
| 56 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 57 |
+
lines.append(f"{_C.BOLD}{_C.WHITE} BLAST RADIUS ANALYSIS{_C.RESET}")
|
| 58 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 59 |
+
|
| 60 |
+
for sym_id in changed_symbols:
|
| 61 |
+
lines.append("")
|
| 62 |
+
lines.append(f" {_C.BOLD}{_C.RED}⚡ CHANGED:{_C.RESET} {_C.BOLD}{sym_id}{_C.RESET}")
|
| 63 |
+
|
| 64 |
+
# Show symbol location info
|
| 65 |
+
if sym_id in symbols:
|
| 66 |
+
sym = symbols[sym_id]
|
| 67 |
+
lines.append(f" {_C.DIM} File: {sym.file}{_C.RESET}")
|
| 68 |
+
lines.append(f" {_C.DIM} Line: {sym.lineno}{_C.RESET}")
|
| 69 |
+
|
| 70 |
+
lines.append("")
|
| 71 |
+
|
| 72 |
+
# ---- CALLERS (who is affected by this change?) ----
|
| 73 |
+
direct_callers = sorted(reverse.get(sym_id, set()))
|
| 74 |
+
lines.append(f" {_C.BOLD}{_C.YELLOW}▲ WHO CALLS THIS? (directly affected){_C.RESET}")
|
| 75 |
+
|
| 76 |
+
if not direct_callers:
|
| 77 |
+
lines.append(f" {_C.DIM} (no direct callers found){_C.RESET}")
|
| 78 |
+
else:
|
| 79 |
+
for i, caller in enumerate(direct_callers):
|
| 80 |
+
is_last = i == len(direct_callers) - 1
|
| 81 |
+
prefix = "└──" if is_last else "├──"
|
| 82 |
+
lines.append(f" {_C.YELLOW} {prefix} {caller}{_C.RESET}")
|
| 83 |
+
|
| 84 |
+
if show_proof:
|
| 85 |
+
proof = _find_proof(caller, sym_id, symbols, graph)
|
| 86 |
+
if proof:
|
| 87 |
+
pad = " " if is_last else "│ "
|
| 88 |
+
lines.append(f" {_C.DIM} {pad} proof: {proof}{_C.RESET}")
|
| 89 |
+
|
| 90 |
+
# 2nd-level callers (transitive)
|
| 91 |
+
if max_depth >= 2:
|
| 92 |
+
indirect_callers = sorted(reverse.get(caller, set()))
|
| 93 |
+
for j, caller2 in enumerate(indirect_callers[:5]):
|
| 94 |
+
is_last2 = j == len(indirect_callers[:5]) - 1
|
| 95 |
+
indent = " " if is_last else "│ "
|
| 96 |
+
prefix2 = "└──" if is_last2 else "├──"
|
| 97 |
+
lines.append(
|
| 98 |
+
f" {_C.DIM} {indent}{prefix2} {caller2}{_C.RESET}"
|
| 99 |
+
)
|
| 100 |
+
if len(indirect_callers) > 5:
|
| 101 |
+
indent = " " if is_last else "│ "
|
| 102 |
+
lines.append(
|
| 103 |
+
f" {_C.DIM} {indent}... +{len(indirect_callers) - 5} more{_C.RESET}"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
lines.append("")
|
| 107 |
+
|
| 108 |
+
# ---- CALLEES (what does this function depend on?) ----
|
| 109 |
+
direct_callees = sorted(graph.get(sym_id, []))
|
| 110 |
+
lines.append(f" {_C.BOLD}{_C.GREEN}▼ WHAT DOES THIS CALL? (dependencies){_C.RESET}")
|
| 111 |
+
|
| 112 |
+
if not direct_callees:
|
| 113 |
+
lines.append(f" {_C.DIM} (no outgoing calls resolved){_C.RESET}")
|
| 114 |
+
else:
|
| 115 |
+
for i, callee in enumerate(direct_callees):
|
| 116 |
+
is_last = i == len(direct_callees) - 1
|
| 117 |
+
prefix = "└──" if is_last else "├──"
|
| 118 |
+
lines.append(f" {_C.GREEN} {prefix} {callee}{_C.RESET}")
|
| 119 |
+
|
| 120 |
+
if show_proof:
|
| 121 |
+
proof = _find_proof(sym_id, callee, symbols, graph)
|
| 122 |
+
if proof:
|
| 123 |
+
pad = " " if is_last else "│ "
|
| 124 |
+
lines.append(f" {_C.DIM} {pad} proof: {proof}{_C.RESET}")
|
| 125 |
+
|
| 126 |
+
# 2nd-level callees
|
| 127 |
+
if max_depth >= 2:
|
| 128 |
+
indirect_callees = sorted(graph.get(callee, []))
|
| 129 |
+
for j, callee2 in enumerate(indirect_callees[:5]):
|
| 130 |
+
is_last2 = j == len(indirect_callees[:5]) - 1
|
| 131 |
+
indent = " " if is_last else "│ "
|
| 132 |
+
prefix2 = "└──" if is_last2 else "├──"
|
| 133 |
+
lines.append(
|
| 134 |
+
f" {_C.DIM} {indent}{prefix2} {callee2}{_C.RESET}"
|
| 135 |
+
)
|
| 136 |
+
if len(indirect_callees) > 5:
|
| 137 |
+
indent = " " if is_last else "│ "
|
| 138 |
+
lines.append(
|
| 139 |
+
f" {_C.DIM} {indent}... +{len(indirect_callees) - 5} more{_C.RESET}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
lines.append("")
|
| 143 |
+
|
| 144 |
+
# ---- FULL TRANSITIVE BLAST RADIUS ----
|
| 145 |
+
all_affected = _get_transitive_callers(reverse, sym_id, max_depth)
|
| 146 |
+
lines.append(f" {_C.BOLD}{_C.CYAN}◉ FULL BLAST RADIUS{_C.RESET}")
|
| 147 |
+
lines.append(
|
| 148 |
+
f" {_C.CYAN} {len(all_affected)} symbols transitively affected{_C.RESET}"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
# Group by file
|
| 152 |
+
by_file: Dict[str, List[str]] = {}
|
| 153 |
+
for affected_sym in all_affected:
|
| 154 |
+
parts = affected_sym.split(":", 1)
|
| 155 |
+
filepath = parts[0] if len(parts) == 2 else "unknown"
|
| 156 |
+
by_file.setdefault(filepath, []).append(affected_sym)
|
| 157 |
+
|
| 158 |
+
for filepath, syms in sorted(by_file.items()):
|
| 159 |
+
lines.append(f" {_C.BLUE} 📄 {filepath} ({len(syms)} symbols){_C.RESET}")
|
| 160 |
+
for sym in sorted(syms)[:8]:
|
| 161 |
+
name = sym.split(":", 1)[1] if ":" in sym else sym
|
| 162 |
+
lines.append(f" {_C.DIM} · {name}{_C.RESET}")
|
| 163 |
+
if len(syms) > 8:
|
| 164 |
+
lines.append(f" {_C.DIM} ... +{len(syms) - 8} more{_C.RESET}")
|
| 165 |
+
|
| 166 |
+
# ---- SUMMARY ----
|
| 167 |
+
lines.append("")
|
| 168 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'─' * 70}{_C.RESET}")
|
| 169 |
+
|
| 170 |
+
total_blast = set()
|
| 171 |
+
for sym_id in changed_symbols:
|
| 172 |
+
total_blast.update(_get_transitive_callers(reverse, sym_id, max_depth))
|
| 173 |
+
|
| 174 |
+
total_deps = set()
|
| 175 |
+
for sym_id in changed_symbols:
|
| 176 |
+
total_deps.update(_get_transitive_callees(graph, sym_id, max_depth))
|
| 177 |
+
|
| 178 |
+
blast_files = set()
|
| 179 |
+
for sym in total_blast:
|
| 180 |
+
parts = sym.split(":", 1)
|
| 181 |
+
if len(parts) == 2:
|
| 182 |
+
blast_files.add(parts[0])
|
| 183 |
+
|
| 184 |
+
lines.append(f" {_C.BOLD}Summary:{_C.RESET}")
|
| 185 |
+
lines.append(f" Changed symbols : {_C.RED}{len(changed_symbols)}{_C.RESET}")
|
| 186 |
+
lines.append(f" Direct callers : {_C.YELLOW}{sum(len(reverse.get(s, set())) for s in changed_symbols)}{_C.RESET}")
|
| 187 |
+
lines.append(f" Direct dependencies: {_C.GREEN}{sum(len(graph.get(s, [])) for s in changed_symbols)}{_C.RESET}")
|
| 188 |
+
lines.append(f" Total blast radius : {_C.CYAN}{len(total_blast)} symbols across {len(blast_files)} files{_C.RESET}")
|
| 189 |
+
lines.append(f" Total dependencies : {_C.GREEN}{len(total_deps)} symbols{_C.RESET}")
|
| 190 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 191 |
+
lines.append("")
|
| 192 |
+
|
| 193 |
+
return "\n".join(lines)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def render_verification(
|
| 197 |
+
graph: Dict[str, List[str]],
|
| 198 |
+
changed_symbols: List[str],
|
| 199 |
+
symbols: dict,
|
| 200 |
+
) -> str:
|
| 201 |
+
"""
|
| 202 |
+
Render verification proof: for each edge in the blast radius,
|
| 203 |
+
show the actual code line that creates the dependency.
|
| 204 |
+
"""
|
| 205 |
+
reverse: Dict[str, Set[str]] = {}
|
| 206 |
+
for caller, callees in graph.items():
|
| 207 |
+
for callee in callees:
|
| 208 |
+
reverse.setdefault(callee, set()).add(caller)
|
| 209 |
+
|
| 210 |
+
lines: List[str] = []
|
| 211 |
+
lines.append("")
|
| 212 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 213 |
+
lines.append(f"{_C.BOLD}{_C.WHITE} VERIFICATION: Proof of each connection{_C.RESET}")
|
| 214 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 215 |
+
|
| 216 |
+
for sym_id in changed_symbols:
|
| 217 |
+
lines.append("")
|
| 218 |
+
lines.append(f" {_C.BOLD}{_C.RED}⚡ {sym_id}{_C.RESET}")
|
| 219 |
+
lines.append("")
|
| 220 |
+
|
| 221 |
+
# Verify each caller
|
| 222 |
+
callers = sorted(reverse.get(sym_id, set()))
|
| 223 |
+
if callers:
|
| 224 |
+
lines.append(f" {_C.BOLD}{_C.YELLOW} Callers (these functions call the changed code):{_C.RESET}")
|
| 225 |
+
for caller in callers:
|
| 226 |
+
lines.append(f" {_C.YELLOW} → {caller}{_C.RESET}")
|
| 227 |
+
proof = _find_proof(caller, sym_id, symbols, graph)
|
| 228 |
+
if proof:
|
| 229 |
+
lines.append(f" {_C.DIM} evidence: {proof}{_C.RESET}")
|
| 230 |
+
else:
|
| 231 |
+
lines.append(f" {_C.DIM} evidence: (edge exists in call graph){_C.RESET}")
|
| 232 |
+
lines.append("")
|
| 233 |
+
|
| 234 |
+
# Verify each callee
|
| 235 |
+
callees = sorted(graph.get(sym_id, []))
|
| 236 |
+
if callees:
|
| 237 |
+
lines.append(f" {_C.BOLD}{_C.GREEN} Callees (the changed code calls these):{_C.RESET}")
|
| 238 |
+
for callee in callees:
|
| 239 |
+
lines.append(f" {_C.GREEN} → {callee}{_C.RESET}")
|
| 240 |
+
proof = _find_proof(sym_id, callee, symbols, graph)
|
| 241 |
+
if proof:
|
| 242 |
+
lines.append(f" {_C.DIM} evidence: {proof}{_C.RESET}")
|
| 243 |
+
else:
|
| 244 |
+
lines.append(f" {_C.DIM} evidence: (edge exists in call graph){_C.RESET}")
|
| 245 |
+
|
| 246 |
+
lines.append("")
|
| 247 |
+
lines.append(f"{_C.BOLD}{_C.WHITE}{'═' * 70}{_C.RESET}")
|
| 248 |
+
lines.append("")
|
| 249 |
+
return "\n".join(lines)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
# Internal helpers
|
| 254 |
+
# ---------------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _get_transitive_callers(
|
| 258 |
+
reverse: Dict[str, Set[str]],
|
| 259 |
+
start: str,
|
| 260 |
+
max_depth: int,
|
| 261 |
+
) -> List[str]:
|
| 262 |
+
"""BFS up the reverse graph to find all transitively affected symbols."""
|
| 263 |
+
visited: Set[str] = {start}
|
| 264 |
+
result: List[str] = []
|
| 265 |
+
frontier = [start]
|
| 266 |
+
depth = 0
|
| 267 |
+
|
| 268 |
+
while frontier and depth < max_depth:
|
| 269 |
+
next_frontier = []
|
| 270 |
+
for node in frontier:
|
| 271 |
+
for caller in reverse.get(node, set()):
|
| 272 |
+
if caller not in visited:
|
| 273 |
+
visited.add(caller)
|
| 274 |
+
result.append(caller)
|
| 275 |
+
next_frontier.append(caller)
|
| 276 |
+
frontier = next_frontier
|
| 277 |
+
depth += 1
|
| 278 |
+
|
| 279 |
+
return result
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _get_transitive_callees(
|
| 283 |
+
graph: Dict[str, List[str]],
|
| 284 |
+
start: str,
|
| 285 |
+
max_depth: int,
|
| 286 |
+
) -> List[str]:
|
| 287 |
+
"""BFS down the forward graph to find all dependencies."""
|
| 288 |
+
visited: Set[str] = {start}
|
| 289 |
+
result: List[str] = []
|
| 290 |
+
frontier = [start]
|
| 291 |
+
depth = 0
|
| 292 |
+
|
| 293 |
+
while frontier and depth < max_depth:
|
| 294 |
+
next_frontier = []
|
| 295 |
+
for node in frontier:
|
| 296 |
+
for callee in graph.get(node, []):
|
| 297 |
+
if callee not in visited:
|
| 298 |
+
visited.add(callee)
|
| 299 |
+
result.append(callee)
|
| 300 |
+
next_frontier.append(callee)
|
| 301 |
+
frontier = next_frontier
|
| 302 |
+
depth += 1
|
| 303 |
+
|
| 304 |
+
return result
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _find_proof(
|
| 308 |
+
caller_id: str,
|
| 309 |
+
callee_id: str,
|
| 310 |
+
symbols: dict,
|
| 311 |
+
graph: Dict[str, List[str]],
|
| 312 |
+
) -> Optional[str]:
|
| 313 |
+
"""
|
| 314 |
+
Find the actual code line in `caller` that references `callee`.
|
| 315 |
+
|
| 316 |
+
This is the "proof" that the edge is real — a grep through the caller's
|
| 317 |
+
source code for the callee's function name.
|
| 318 |
+
"""
|
| 319 |
+
if caller_id not in symbols:
|
| 320 |
+
return None
|
| 321 |
+
|
| 322 |
+
caller_sym = symbols[caller_id]
|
| 323 |
+
callee_name = callee_id.split(":")[-1] if ":" in callee_id else callee_id
|
| 324 |
+
|
| 325 |
+
# Strip class prefix for method calls: "ClassName.method" -> "method"
|
| 326 |
+
if "." in callee_name:
|
| 327 |
+
bare_name = callee_name.split(".")[-1]
|
| 328 |
+
else:
|
| 329 |
+
bare_name = callee_name
|
| 330 |
+
|
| 331 |
+
# Search caller's code for the call
|
| 332 |
+
for i, line in enumerate(caller_sym.code.splitlines(), start=1):
|
| 333 |
+
stripped = line.strip()
|
| 334 |
+
# Look for function/method call pattern
|
| 335 |
+
if bare_name + "(" in stripped or f".{bare_name}(" in stripped:
|
| 336 |
+
# Truncate long lines
|
| 337 |
+
if len(stripped) > 80:
|
| 338 |
+
stripped = stripped[:77] + "..."
|
| 339 |
+
return f"line {caller_sym.lineno + i - 1}: {stripped}"
|
| 340 |
+
|
| 341 |
+
return None
|
diffcontext/mcp_server.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DiffContext MCP Server
|
| 3 |
+
Lets Claude Desktop / Cursor call DiffContext natively — no copy-paste needed.
|
| 4 |
+
|
| 5 |
+
How to use:
|
| 6 |
+
1. pip install mcp
|
| 7 |
+
2. Run: python -m diffcontext.mcp_server
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import ast
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import tempfile
|
| 15 |
+
import shutil
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any, Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
# ── Try to import MCP; give a clear error if not installed ──────────────────
|
| 20 |
+
try:
|
| 21 |
+
from mcp.server import Server
|
| 22 |
+
from mcp.server.stdio import stdio_server
|
| 23 |
+
from mcp.types import Tool, TextContent
|
| 24 |
+
HAS_MCP = True
|
| 25 |
+
except ImportError:
|
| 26 |
+
HAS_MCP = False
|
| 27 |
+
|
| 28 |
+
# ── Try to import DiffContext ────────────────────────────────────────────────
|
| 29 |
+
try:
|
| 30 |
+
from .pipeline import index_repository, analyze_impact
|
| 31 |
+
from .pipeline import compile as dc_compile
|
| 32 |
+
HAS_DIFFCONTEXT = True
|
| 33 |
+
except ImportError:
|
| 34 |
+
try:
|
| 35 |
+
from diffcontext.pipeline import index_repository, analyze_impact
|
| 36 |
+
from diffcontext.pipeline import compile as dc_compile
|
| 37 |
+
HAS_DIFFCONTEXT = True
|
| 38 |
+
except ImportError:
|
| 39 |
+
HAS_DIFFCONTEXT = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── Sessions helper for remote hosted servers ────────────────────────────────
|
| 43 |
+
|
| 44 |
+
def _get_sessions() -> Dict[str, str]:
|
| 45 |
+
try:
|
| 46 |
+
import importlib
|
| 47 |
+
backend_main = importlib.import_module("diffcontext-service.backend.main")
|
| 48 |
+
return backend_main.SESSIONS
|
| 49 |
+
except Exception:
|
| 50 |
+
try:
|
| 51 |
+
import importlib
|
| 52 |
+
backend_main = importlib.import_module("backend.main")
|
| 53 |
+
return backend_main.SESSIONS
|
| 54 |
+
except Exception:
|
| 55 |
+
return {}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── Shared analysis logic (same as web service) ──────────────────────────────
|
| 59 |
+
|
| 60 |
+
def _list_symbols_in_dir(repo_path: str) -> List[str]:
|
| 61 |
+
symbols = []
|
| 62 |
+
for py_file in Path(repo_path).rglob("*.py"):
|
| 63 |
+
rel = "./" + str(py_file.relative_to(repo_path))
|
| 64 |
+
try:
|
| 65 |
+
source = py_file.read_text(errors="ignore")
|
| 66 |
+
tree = ast.parse(source)
|
| 67 |
+
except SyntaxError:
|
| 68 |
+
continue
|
| 69 |
+
for node in ast.walk(tree):
|
| 70 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 71 |
+
symbols.append(f"{rel}:{node.name}")
|
| 72 |
+
return sorted(symbols)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _blast_radius(repo_path: str, symbol: str) -> dict:
|
| 76 |
+
if HAS_DIFFCONTEXT:
|
| 77 |
+
idx = index_repository(repo_path)
|
| 78 |
+
if symbol not in idx.symbols and symbol not in idx.graph:
|
| 79 |
+
import difflib
|
| 80 |
+
suggestions = difflib.get_close_matches(symbol, list(idx.symbols.keys()), n=3, cutoff=0.4)
|
| 81 |
+
return {"error": f"'{symbol}' not found.", "suggestions": suggestions}
|
| 82 |
+
|
| 83 |
+
impact = analyze_impact(idx, [symbol])
|
| 84 |
+
|
| 85 |
+
reverse: Dict[str, List[str]] = {}
|
| 86 |
+
for caller, callees in idx.graph.items():
|
| 87 |
+
for callee in callees:
|
| 88 |
+
reverse.setdefault(callee, []).append(caller)
|
| 89 |
+
|
| 90 |
+
by_file: Dict[str, List[str]] = {}
|
| 91 |
+
for sym in impact.blast_radius:
|
| 92 |
+
fp = sym.split(":")[0]
|
| 93 |
+
by_file.setdefault(fp, []).append(sym.split(":")[-1])
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
"symbol": symbol,
|
| 97 |
+
"direct_callers": reverse.get(symbol, []),
|
| 98 |
+
"direct_callees": idx.graph.get(symbol, []),
|
| 99 |
+
"blast_radius_count": len(impact.blast_radius),
|
| 100 |
+
"blast_radius_by_file": by_file,
|
| 101 |
+
"all_affected": impact.blast_radius[:50],
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# AST fallback
|
| 105 |
+
all_functions: Dict[str, str] = {}
|
| 106 |
+
call_graph: Dict[str, List[str]] = {}
|
| 107 |
+
|
| 108 |
+
for py_file in Path(repo_path).rglob("*.py"):
|
| 109 |
+
rel = "./" + str(py_file.relative_to(repo_path))
|
| 110 |
+
try:
|
| 111 |
+
source = py_file.read_text(errors="ignore")
|
| 112 |
+
tree = ast.parse(source)
|
| 113 |
+
except SyntaxError:
|
| 114 |
+
continue
|
| 115 |
+
for node in ast.walk(tree):
|
| 116 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 117 |
+
fid = f"{rel}:{node.name}"
|
| 118 |
+
all_functions[fid] = ""
|
| 119 |
+
calls = []
|
| 120 |
+
for child in ast.walk(node):
|
| 121 |
+
if isinstance(child, ast.Call):
|
| 122 |
+
if isinstance(child.func, ast.Name):
|
| 123 |
+
calls.append(child.func.id)
|
| 124 |
+
elif isinstance(child.func, ast.Attribute):
|
| 125 |
+
calls.append(child.func.attr)
|
| 126 |
+
call_graph[fid] = calls
|
| 127 |
+
|
| 128 |
+
name_to_ids: Dict[str, List[str]] = {}
|
| 129 |
+
for fid in all_functions:
|
| 130 |
+
name_to_ids.setdefault(fid.split(":")[-1], []).append(fid)
|
| 131 |
+
|
| 132 |
+
resolved_graph: Dict[str, List[str]] = {}
|
| 133 |
+
for fid, raw_calls in call_graph.items():
|
| 134 |
+
resolved = []
|
| 135 |
+
for call in raw_calls:
|
| 136 |
+
for candidate in name_to_ids.get(call, []):
|
| 137 |
+
if candidate != fid:
|
| 138 |
+
resolved.append(candidate)
|
| 139 |
+
resolved_graph[fid] = resolved
|
| 140 |
+
|
| 141 |
+
reverse: Dict[str, List[str]] = {}
|
| 142 |
+
for caller, callees in resolved_graph.items():
|
| 143 |
+
for callee in callees:
|
| 144 |
+
reverse.setdefault(callee, []).append(caller)
|
| 145 |
+
|
| 146 |
+
if symbol not in all_functions:
|
| 147 |
+
import difflib
|
| 148 |
+
suggestions = difflib.get_close_matches(symbol, list(all_functions.keys()), n=3, cutoff=0.4)
|
| 149 |
+
return {"error": f"'{symbol}' not found.", "suggestions": suggestions}
|
| 150 |
+
|
| 151 |
+
visited = {symbol}
|
| 152 |
+
blast = []
|
| 153 |
+
frontier = [symbol]
|
| 154 |
+
while frontier:
|
| 155 |
+
next_f = []
|
| 156 |
+
for node in frontier:
|
| 157 |
+
for caller in reverse.get(node, []):
|
| 158 |
+
if caller not in visited:
|
| 159 |
+
visited.add(caller)
|
| 160 |
+
blast.append(caller)
|
| 161 |
+
next_f.append(caller)
|
| 162 |
+
frontier = next_f
|
| 163 |
+
|
| 164 |
+
by_file: Dict[str, List[str]] = {}
|
| 165 |
+
for sym in blast:
|
| 166 |
+
fp = sym.split(":")[0]
|
| 167 |
+
by_file.setdefault(fp, []).append(sym.split(":")[-1])
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
"symbol": symbol,
|
| 171 |
+
"direct_callers": reverse.get(symbol, []),
|
| 172 |
+
"direct_callees": resolved_graph.get(symbol, []),
|
| 173 |
+
"blast_radius_count": len(blast),
|
| 174 |
+
"blast_radius_by_file": by_file,
|
| 175 |
+
"all_affected": blast[:50],
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── MCP Tool definitions ──────────────────────────────────────────────────────
|
| 180 |
+
|
| 181 |
+
if HAS_MCP:
|
| 182 |
+
TOOLS = [
|
| 183 |
+
Tool(
|
| 184 |
+
name="list_symbols",
|
| 185 |
+
description=(
|
| 186 |
+
"List all Python functions/methods in a repository. "
|
| 187 |
+
"Returns symbol IDs in the format './file.py:function_name'. "
|
| 188 |
+
"Provide either repo_path (local absolute path) or repo_id (from /upload on remote hosted servers)."
|
| 189 |
+
),
|
| 190 |
+
inputSchema={
|
| 191 |
+
"type": "object",
|
| 192 |
+
"properties": {
|
| 193 |
+
"repo_path": {
|
| 194 |
+
"type": "string",
|
| 195 |
+
"description": "Absolute path to the Python project directory on the local machine.",
|
| 196 |
+
},
|
| 197 |
+
"repo_id": {
|
| 198 |
+
"type": "string",
|
| 199 |
+
"description": "The session ID of an uploaded repository (for remote MCP servers).",
|
| 200 |
+
}
|
| 201 |
+
},
|
| 202 |
+
"required": [],
|
| 203 |
+
},
|
| 204 |
+
),
|
| 205 |
+
Tool(
|
| 206 |
+
name="blast_radius",
|
| 207 |
+
description=(
|
| 208 |
+
"Find the blast radius of a Python function — which other functions "
|
| 209 |
+
"will break if you change it. Returns direct callers, direct callees, "
|
| 210 |
+
"and the full transitive set of affected functions grouped by file."
|
| 211 |
+
),
|
| 212 |
+
inputSchema={
|
| 213 |
+
"type": "object",
|
| 214 |
+
"properties": {
|
| 215 |
+
"repo_path": {
|
| 216 |
+
"type": "string",
|
| 217 |
+
"description": "Absolute path to the Python project directory.",
|
| 218 |
+
},
|
| 219 |
+
"repo_id": {
|
| 220 |
+
"type": "string",
|
| 221 |
+
"description": "The session ID of an uploaded repository (for remote MCP servers).",
|
| 222 |
+
},
|
| 223 |
+
"symbol": {
|
| 224 |
+
"type": "string",
|
| 225 |
+
"description": "Symbol ID in format './relative/path.py:function_name' or './path.py:ClassName.method'",
|
| 226 |
+
},
|
| 227 |
+
},
|
| 228 |
+
"required": ["symbol"],
|
| 229 |
+
},
|
| 230 |
+
),
|
| 231 |
+
Tool(
|
| 232 |
+
name="compile_context",
|
| 233 |
+
description=(
|
| 234 |
+
"Compile a token-efficient LLM context package for a code change. "
|
| 235 |
+
"Returns the relevant functions and their relationships, trimmed to a token budget."
|
| 236 |
+
),
|
| 237 |
+
inputSchema={
|
| 238 |
+
"type": "object",
|
| 239 |
+
"properties": {
|
| 240 |
+
"repo_path": {
|
| 241 |
+
"type": "string",
|
| 242 |
+
"description": "Absolute path to the Python project directory.",
|
| 243 |
+
},
|
| 244 |
+
"repo_id": {
|
| 245 |
+
"type": "string",
|
| 246 |
+
"description": "The session ID of an uploaded repository (for remote MCP servers).",
|
| 247 |
+
},
|
| 248 |
+
"symbol": {
|
| 249 |
+
"type": "string",
|
| 250 |
+
"description": "Symbol ID to analyze, e.g. './src/auth.py:validate_jwt'",
|
| 251 |
+
},
|
| 252 |
+
"max_tokens": {
|
| 253 |
+
"type": "integer",
|
| 254 |
+
"description": "Token budget for context (default 8000). Lower = tighter selection.",
|
| 255 |
+
"default": 8000,
|
| 256 |
+
},
|
| 257 |
+
},
|
| 258 |
+
"required": ["symbol"],
|
| 259 |
+
},
|
| 260 |
+
),
|
| 261 |
+
Tool(
|
| 262 |
+
name="analyze_inline",
|
| 263 |
+
description=(
|
| 264 |
+
"Analyze Python code you paste directly — no file upload or local repo needed. "
|
| 265 |
+
"Useful for quick analysis of code snippets. "
|
| 266 |
+
"Returns blast radius and caller/callee relationships."
|
| 267 |
+
),
|
| 268 |
+
inputSchema={
|
| 269 |
+
"type": "object",
|
| 270 |
+
"properties": {
|
| 271 |
+
"files": {
|
| 272 |
+
"type": "object",
|
| 273 |
+
"description": "Dict of filename -> Python source code. E.g. {'service.py': 'def foo(): ...'}",
|
| 274 |
+
},
|
| 275 |
+
"symbol": {
|
| 276 |
+
"type": "string",
|
| 277 |
+
"description": "Symbol to analyze, e.g. './service.py:foo'",
|
| 278 |
+
},
|
| 279 |
+
},
|
| 280 |
+
"required": ["files", "symbol"],
|
| 281 |
+
},
|
| 282 |
+
),
|
| 283 |
+
]
|
| 284 |
+
else:
|
| 285 |
+
TOOLS = []
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ── MCP Server ────────────────────────────────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
if HAS_MCP:
|
| 291 |
+
server = Server("diffcontext")
|
| 292 |
+
|
| 293 |
+
@server.list_tools()
|
| 294 |
+
async def list_tools():
|
| 295 |
+
return TOOLS
|
| 296 |
+
|
| 297 |
+
@server.call_tool()
|
| 298 |
+
async def call_tool(name: str, arguments: Dict[str, Any]):
|
| 299 |
+
try:
|
| 300 |
+
repo_path = arguments.get("repo_path")
|
| 301 |
+
repo_id = arguments.get("repo_id")
|
| 302 |
+
|
| 303 |
+
if name != "analyze_inline" and not repo_path and not repo_id:
|
| 304 |
+
result = {"error": "Must provide either 'repo_path' or 'repo_id'."}
|
| 305 |
+
else:
|
| 306 |
+
if repo_id:
|
| 307 |
+
sessions = _get_sessions()
|
| 308 |
+
if repo_id not in sessions:
|
| 309 |
+
result = {"error": f"repo_id '{repo_id}' not found. Please upload the repository zip first."}
|
| 310 |
+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
| 311 |
+
repo_path = sessions[repo_id]
|
| 312 |
+
|
| 313 |
+
if name == "list_symbols":
|
| 314 |
+
if not os.path.isdir(repo_path):
|
| 315 |
+
result = {"error": f"Directory not found: {repo_path}"}
|
| 316 |
+
else:
|
| 317 |
+
symbols = _list_symbols_in_dir(repo_path)
|
| 318 |
+
result = {"count": len(symbols), "symbols": symbols}
|
| 319 |
+
|
| 320 |
+
elif name == "blast_radius":
|
| 321 |
+
symbol = arguments["symbol"]
|
| 322 |
+
if not os.path.isdir(repo_path):
|
| 323 |
+
result = {"error": f"Directory not found: {repo_path}"}
|
| 324 |
+
else:
|
| 325 |
+
result = _blast_radius(repo_path, symbol)
|
| 326 |
+
|
| 327 |
+
elif name == "compile_context":
|
| 328 |
+
symbol = arguments["symbol"]
|
| 329 |
+
max_tokens = arguments.get("max_tokens", 8000)
|
| 330 |
+
|
| 331 |
+
if not os.path.isdir(repo_path):
|
| 332 |
+
result = {"error": f"Directory not found: {repo_path}"}
|
| 333 |
+
elif not HAS_DIFFCONTEXT:
|
| 334 |
+
br = _blast_radius(repo_path, symbol)
|
| 335 |
+
result = {
|
| 336 |
+
"context": json.dumps(br, indent=2),
|
| 337 |
+
"note": "Install DiffContext for full compiled context.",
|
| 338 |
+
}
|
| 339 |
+
else:
|
| 340 |
+
idx = index_repository(repo_path)
|
| 341 |
+
impact = analyze_impact(idx, [symbol])
|
| 342 |
+
ctx = dc_compile(idx, impact, max_tokens=max_tokens)
|
| 343 |
+
result = {
|
| 344 |
+
"context": ctx.text,
|
| 345 |
+
"symbol_count": ctx.symbol_count,
|
| 346 |
+
"token_estimate": ctx.token_estimate,
|
| 347 |
+
"reduction_pct": round(ctx.reduction_pct, 1),
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
elif name == "analyze_inline":
|
| 351 |
+
files = arguments["files"]
|
| 352 |
+
symbol = arguments["symbol"]
|
| 353 |
+
tmp_dir = tempfile.mkdtemp(prefix="diffctx_mcp_")
|
| 354 |
+
try:
|
| 355 |
+
for filename, code in files.items():
|
| 356 |
+
safe_name = Path(filename).name
|
| 357 |
+
with open(os.path.join(tmp_dir, safe_name), "w") as f:
|
| 358 |
+
f.write(code)
|
| 359 |
+
result = _blast_radius(tmp_dir, symbol)
|
| 360 |
+
finally:
|
| 361 |
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
| 362 |
+
|
| 363 |
+
else:
|
| 364 |
+
result = {"error": f"Unknown tool: {name}"}
|
| 365 |
+
|
| 366 |
+
except Exception as e:
|
| 367 |
+
result = {"error": str(e)}
|
| 368 |
+
|
| 369 |
+
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
| 370 |
+
else:
|
| 371 |
+
server = None
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
async def run_mcp_server():
|
| 375 |
+
if not HAS_MCP:
|
| 376 |
+
print(
|
| 377 |
+
"ERROR: 'mcp' package not installed.\n"
|
| 378 |
+
"Fix: pip install mcp\n",
|
| 379 |
+
file=sys.stderr,
|
| 380 |
+
)
|
| 381 |
+
sys.exit(1)
|
| 382 |
+
|
| 383 |
+
async with stdio_server() as (read_stream, write_stream):
|
| 384 |
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def main():
|
| 388 |
+
import asyncio
|
| 389 |
+
print("Starting DiffContext MCP Server...", file=sys.stderr)
|
| 390 |
+
print(f"DiffContext installed: {HAS_DIFFCONTEXT}", file=sys.stderr)
|
| 391 |
+
print(f"MCP installed: {HAS_MCP}", file=sys.stderr)
|
| 392 |
+
|
| 393 |
+
if not HAS_MCP:
|
| 394 |
+
print("\nTo install: pip install mcp", file=sys.stderr)
|
| 395 |
+
sys.exit(1)
|
| 396 |
+
|
| 397 |
+
asyncio.run(run_mcp_server())
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
if __name__ == "__main__":
|
| 401 |
+
main()
|
diffcontext/models.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
models.py — Data classes used across the pipeline.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class Symbol:
|
| 11 |
+
"""A single extracted symbol (function/method)."""
|
| 12 |
+
id: str # "./path.py:ClassName.method" or "./path.py:func"
|
| 13 |
+
file: str # absolute path to source file
|
| 14 |
+
name: str # bare name like "ClassName.method" or "func"
|
| 15 |
+
code: str # source code text
|
| 16 |
+
lineno: int = 0 # start line number
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class RepositoryIndex:
|
| 21 |
+
"""Complete index of a repository."""
|
| 22 |
+
symbols: Dict[str, Symbol] = field(default_factory=dict) # id -> Symbol
|
| 23 |
+
graph: Dict[str, List[str]] = field(default_factory=dict) # id -> [dependency ids]
|
| 24 |
+
broken_files: List[str] = field(default_factory=list)
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def reverse_graph(self) -> Dict[str, Set[str]]:
|
| 28 |
+
"""Build reverse graph (callers of each symbol)."""
|
| 29 |
+
rev: Dict[str, Set[str]] = {}
|
| 30 |
+
for caller, callees in self.graph.items():
|
| 31 |
+
for callee in callees:
|
| 32 |
+
rev.setdefault(callee, set()).add(caller)
|
| 33 |
+
return rev
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def total_edges(self) -> int:
|
| 37 |
+
return sum(len(deps) for deps in self.graph.values())
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class DiffResult:
|
| 42 |
+
"""Result of comparing two states."""
|
| 43 |
+
modified: List[str] = field(default_factory=list)
|
| 44 |
+
added: List[str] = field(default_factory=list)
|
| 45 |
+
deleted: List[str] = field(default_factory=list)
|
| 46 |
+
broken_files: List[str] = field(default_factory=list)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def all_changed(self) -> List[str]:
|
| 50 |
+
return self.modified + self.added
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class ImpactResult:
|
| 55 |
+
"""Result of impact analysis."""
|
| 56 |
+
changed: List[str] = field(default_factory=list)
|
| 57 |
+
blast_radius: List[str] = field(default_factory=list)
|
| 58 |
+
dependencies: List[str] = field(default_factory=list)
|
| 59 |
+
scores: Dict[str, float] = field(default_factory=dict)
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def all_relevant(self) -> List[str]:
|
| 63 |
+
"""All symbols that should be in context, deduplicated, ordered by score."""
|
| 64 |
+
seen = set()
|
| 65 |
+
result = []
|
| 66 |
+
# Score-ordered
|
| 67 |
+
scored = sorted(self.scores.items(), key=lambda x: x[1], reverse=True)
|
| 68 |
+
for sym_id, _ in scored:
|
| 69 |
+
if sym_id not in seen:
|
| 70 |
+
seen.add(sym_id)
|
| 71 |
+
result.append(sym_id)
|
| 72 |
+
# Any remaining that weren't scored
|
| 73 |
+
for sym_id in self.changed + self.blast_radius + self.dependencies:
|
| 74 |
+
if sym_id not in seen:
|
| 75 |
+
seen.add(sym_id)
|
| 76 |
+
result.append(sym_id)
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class ContextPackage:
|
| 82 |
+
"""Final compiled context for an LLM."""
|
| 83 |
+
text: str
|
| 84 |
+
symbol_count: int
|
| 85 |
+
token_estimate: int
|
| 86 |
+
total_repo_tokens: int
|
| 87 |
+
# LLM self-awareness fields
|
| 88 |
+
dropped_symbols: List[str] = field(default_factory=list) # scored but cut by budget
|
| 89 |
+
skipped_files: List[str] = field(default_factory=list) # SyntaxError'd files
|
| 90 |
+
graph_confidence: float = 1.0 # fraction of edges that resolved
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def reduction_pct(self) -> float:
|
| 94 |
+
if self.total_repo_tokens == 0:
|
| 95 |
+
return 0.0
|
| 96 |
+
return (1 - self.token_estimate / self.total_repo_tokens) * 100
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class BenchmarkResult:
|
| 101 |
+
"""Result of a single benchmark run."""
|
| 102 |
+
name: str
|
| 103 |
+
repo_path: str
|
| 104 |
+
changed_functions: List[str]
|
| 105 |
+
# Graph stats
|
| 106 |
+
total_symbols: int = 0
|
| 107 |
+
total_edges: int = 0
|
| 108 |
+
graph_build_ms: float = 0.0
|
| 109 |
+
# Retrieval stats
|
| 110 |
+
retrieved_count: int = 0
|
| 111 |
+
retrieved_ids: List[str] = field(default_factory=list)
|
| 112 |
+
# Token stats
|
| 113 |
+
total_tokens: int = 0
|
| 114 |
+
context_tokens: int = 0
|
| 115 |
+
token_reduction_pct: float = 0.0
|
| 116 |
+
function_reduction_pct: float = 0.0
|
| 117 |
+
# Precision/Recall (when ground truth available)
|
| 118 |
+
precision: Optional[float] = None
|
| 119 |
+
recall: Optional[float] = None
|
| 120 |
+
f1: Optional[float] = None
|
| 121 |
+
# Timing
|
| 122 |
+
pipeline_ms: float = 0.0
|
diffcontext/parser.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
parser.py — AST-based symbol extraction from Python source files.
|
| 3 |
+
|
| 4 |
+
Extracts functions, methods (including async), with class-aware naming.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import ast
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
from typing import Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
from .models import Symbol
|
| 13 |
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _FunctionCollector(ast.NodeVisitor):
|
| 19 |
+
"""AST visitor that collects all function/method definitions."""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.class_stack: list = []
|
| 23 |
+
self.collected: list = []
|
| 24 |
+
|
| 25 |
+
def visit_ClassDef(self, node):
|
| 26 |
+
self.class_stack.append(node.name)
|
| 27 |
+
for child in node.body:
|
| 28 |
+
self.visit(child)
|
| 29 |
+
self.class_stack.pop()
|
| 30 |
+
|
| 31 |
+
def visit_FunctionDef(self, node):
|
| 32 |
+
self._collect(node)
|
| 33 |
+
self.generic_visit(node)
|
| 34 |
+
|
| 35 |
+
def visit_AsyncFunctionDef(self, node):
|
| 36 |
+
self._collect(node)
|
| 37 |
+
self.generic_visit(node)
|
| 38 |
+
|
| 39 |
+
def _collect(self, node):
|
| 40 |
+
if self.class_stack:
|
| 41 |
+
name = ".".join(self.class_stack) + "." + node.name
|
| 42 |
+
else:
|
| 43 |
+
name = node.name
|
| 44 |
+
self.collected.append((name, node))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def extract_symbols(
|
| 48 |
+
filename: str,
|
| 49 |
+
repo_path: str,
|
| 50 |
+
broken_files: "Optional[List[str]]" = None,
|
| 51 |
+
) -> Dict[str, Symbol]:
|
| 52 |
+
"""
|
| 53 |
+
Parse a single Python file, return dict of symbol_id -> Symbol.
|
| 54 |
+
|
| 55 |
+
Symbol IDs look like: "./relative/path.py:ClassName.method_name"
|
| 56 |
+
|
| 57 |
+
If parsing fails and `broken_files` is provided (a list), the file's
|
| 58 |
+
relative path is appended to it so callers can distinguish "file failed
|
| 59 |
+
to parse" from "file legitimately has no functions."
|
| 60 |
+
"""
|
| 61 |
+
with open(filename, "rb") as f:
|
| 62 |
+
raw = f.read()
|
| 63 |
+
check_and_warn_encoding(logger, filename, raw)
|
| 64 |
+
source = raw.decode("utf-8", errors="ignore")
|
| 65 |
+
|
| 66 |
+
relative_file = "./" + os.path.relpath(filename, repo_path)
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
tree = ast.parse(source)
|
| 70 |
+
except SyntaxError as e:
|
| 71 |
+
warn_syntax_error_once(logger, filename, e)
|
| 72 |
+
if broken_files is not None:
|
| 73 |
+
broken_files.append(relative_file)
|
| 74 |
+
return {}
|
| 75 |
+
|
| 76 |
+
collector = _FunctionCollector()
|
| 77 |
+
collector.visit(tree)
|
| 78 |
+
|
| 79 |
+
symbols = {}
|
| 80 |
+
for name, node in collector.collected:
|
| 81 |
+
symbol_id = f"{relative_file}:{name}"
|
| 82 |
+
code = ast.get_source_segment(source, node)
|
| 83 |
+
if code is None:
|
| 84 |
+
continue
|
| 85 |
+
symbols[symbol_id] = Symbol(
|
| 86 |
+
id=symbol_id,
|
| 87 |
+
file=filename,
|
| 88 |
+
name=name,
|
| 89 |
+
code=code,
|
| 90 |
+
lineno=node.lineno,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
return symbols
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def extract_all_symbols(
|
| 97 |
+
repo_path: str,
|
| 98 |
+
broken_files: "Optional[List[str]]" = None,
|
| 99 |
+
) -> Dict[str, Symbol]:
|
| 100 |
+
"""
|
| 101 |
+
Extract symbols from all Python files in a repository.
|
| 102 |
+
|
| 103 |
+
If `broken_files` is provided (a list), relative paths of any files
|
| 104 |
+
that failed to parse (SyntaxError) are appended to it.
|
| 105 |
+
"""
|
| 106 |
+
from .scanner import find_python_files
|
| 107 |
+
from .cache import SymbolCache
|
| 108 |
+
|
| 109 |
+
repo_path = os.path.abspath(repo_path)
|
| 110 |
+
all_symbols: Dict[str, Symbol] = {}
|
| 111 |
+
|
| 112 |
+
db_path = os.path.join(repo_path, ".diffcontext_cache.db")
|
| 113 |
+
|
| 114 |
+
with SymbolCache(db_path) as cache:
|
| 115 |
+
for filepath in find_python_files(repo_path):
|
| 116 |
+
def _parse(path: str) -> Dict[str, Symbol]:
|
| 117 |
+
return extract_symbols(path, repo_path, broken_files=broken_files)
|
| 118 |
+
|
| 119 |
+
all_symbols.update(cache.get_or_parse(filepath, _parse))
|
| 120 |
+
|
| 121 |
+
return all_symbols
|
diffcontext/pipeline.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pipeline.py — The main DiffContext pipeline.
|
| 3 |
+
|
| 4 |
+
Connects all stages: parse -> graph -> diff -> blast radius -> score -> select -> compile
|
| 5 |
+
|
| 6 |
+
Key fixes vs original:
|
| 7 |
+
- expanded_deps is now passed into compute_impact_scores so those symbols
|
| 8 |
+
are actually scored (previously they were collected but never used).
|
| 9 |
+
- Graph is built with a single pass and cached import maps (see graph_builder).
|
| 10 |
+
- warn_unknown_symbols is called before any scoring to surface typos early.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import difflib
|
| 14 |
+
import logging
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
from typing import Dict, List, Optional
|
| 18 |
+
|
| 19 |
+
from .models import (
|
| 20 |
+
RepositoryIndex, ImpactResult, ContextPackage, Symbol,
|
| 21 |
+
)
|
| 22 |
+
from .parser import extract_all_symbols
|
| 23 |
+
from .graph_builder import build_repository_graph
|
| 24 |
+
from .impact.blast_radius import get_blast_radius
|
| 25 |
+
from .impact.scoring import compute_impact_scores
|
| 26 |
+
from .impact.traversal import expand_dependencies
|
| 27 |
+
from .context.selector import select_context
|
| 28 |
+
from .context.compiler import compile_context
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _suggest_similar_symbol(unknown_id: str, known_ids) -> Optional[str]:
|
| 34 |
+
"""Fuzzy-match an unknown symbol ID against known ones (typo correction)."""
|
| 35 |
+
matches = difflib.get_close_matches(unknown_id, known_ids, n=1, cutoff=0.6)
|
| 36 |
+
return matches[0] if matches else None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def index_repository(repo_path: str) -> RepositoryIndex:
|
| 40 |
+
"""
|
| 41 |
+
Phase 1: Parse repository and build dependency graph.
|
| 42 |
+
|
| 43 |
+
Returns a RepositoryIndex with all symbols, the call graph, and the
|
| 44 |
+
list of files (if any) that failed to parse due to a SyntaxError.
|
| 45 |
+
"""
|
| 46 |
+
repo_path = os.path.abspath(repo_path)
|
| 47 |
+
|
| 48 |
+
broken_files: List[str] = []
|
| 49 |
+
symbols = extract_all_symbols(repo_path, broken_files=broken_files)
|
| 50 |
+
graph = build_repository_graph(repo_path)
|
| 51 |
+
|
| 52 |
+
return RepositoryIndex(symbols=symbols, graph=graph, broken_files=broken_files)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def warn_unknown_symbols(index: RepositoryIndex, changed_symbols: List[str]) -> List[str]:
|
| 56 |
+
"""
|
| 57 |
+
Check `changed_symbols` against the index and warn about any that don't
|
| 58 |
+
actually exist. Returns the list of unknown symbol IDs.
|
| 59 |
+
"""
|
| 60 |
+
unknown = [s for s in changed_symbols if s not in index.graph and s not in index.symbols]
|
| 61 |
+
for sym_id in unknown:
|
| 62 |
+
suggestion = _suggest_similar_symbol(sym_id, index.symbols.keys())
|
| 63 |
+
if suggestion:
|
| 64 |
+
logger.warning(
|
| 65 |
+
"\033[93m'%s' was not found in the index -- did you mean '%s'? "
|
| 66 |
+
"Its blast radius will show as empty, which does NOT mean "
|
| 67 |
+
"the real symbol has no impact.\033[0m",
|
| 68 |
+
sym_id, suggestion,
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
logger.warning(
|
| 72 |
+
"\033[93m'%s' was not found in the index (typo, renamed, or "
|
| 73 |
+
"deleted symbol?). Its blast radius will show as empty, "
|
| 74 |
+
"which does NOT mean the real symbol has no impact.\033[0m",
|
| 75 |
+
sym_id,
|
| 76 |
+
)
|
| 77 |
+
return unknown
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def analyze_impact(
|
| 81 |
+
index: RepositoryIndex,
|
| 82 |
+
changed_symbols: List[str],
|
| 83 |
+
max_depth: Optional[int] = 2,
|
| 84 |
+
) -> ImpactResult:
|
| 85 |
+
"""
|
| 86 |
+
Phase 2: Given changed symbols, compute blast radius and impact scores.
|
| 87 |
+
|
| 88 |
+
Fix: expanded_deps is now passed into compute_impact_scores so those
|
| 89 |
+
nodes are actually scored. Previously they were computed and discarded.
|
| 90 |
+
"""
|
| 91 |
+
warn_unknown_symbols(index, changed_symbols)
|
| 92 |
+
|
| 93 |
+
# ── Blast radius (reverse graph / callers) ────────────────────────────
|
| 94 |
+
blast_radii: Dict[str, List[str]] = {}
|
| 95 |
+
all_blast: List[str] = []
|
| 96 |
+
|
| 97 |
+
for sym_id in changed_symbols:
|
| 98 |
+
if sym_id in index.graph:
|
| 99 |
+
radius = get_blast_radius(index.graph, sym_id)
|
| 100 |
+
blast_radii[sym_id] = radius
|
| 101 |
+
all_blast.extend(radius)
|
| 102 |
+
|
| 103 |
+
# ── Forward dependency expansion ──────────────────────────────────────
|
| 104 |
+
# Seed with changed + blast so we also pull in what callers depend on.
|
| 105 |
+
deps = expand_dependencies(
|
| 106 |
+
index.graph,
|
| 107 |
+
changed_symbols + all_blast,
|
| 108 |
+
max_depth=max_depth,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# ── Impact scoring ────────────────────────────────────────────────────
|
| 112 |
+
# FIX: pass expanded deps so they get scored (previously ignored).
|
| 113 |
+
scores = compute_impact_scores(
|
| 114 |
+
index.graph,
|
| 115 |
+
changed_symbols,
|
| 116 |
+
blast_radii,
|
| 117 |
+
expanded_deps=deps,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return ImpactResult(
|
| 121 |
+
changed=changed_symbols,
|
| 122 |
+
blast_radius=list(set(all_blast)),
|
| 123 |
+
dependencies=deps,
|
| 124 |
+
scores=scores,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def compile(
|
| 129 |
+
index: RepositoryIndex,
|
| 130 |
+
impact: ImpactResult,
|
| 131 |
+
max_tokens: Optional[int] = 10000,
|
| 132 |
+
notes: Optional[str] = None,
|
| 133 |
+
) -> ContextPackage:
|
| 134 |
+
"""
|
| 135 |
+
Phase 3: Select symbols and compile into LLM context.
|
| 136 |
+
"""
|
| 137 |
+
selected, dropped = select_context(
|
| 138 |
+
index.symbols,
|
| 139 |
+
impact.scores,
|
| 140 |
+
impact.changed,
|
| 141 |
+
max_tokens=max_tokens,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return compile_context(
|
| 145 |
+
index.symbols,
|
| 146 |
+
selected,
|
| 147 |
+
impact.changed,
|
| 148 |
+
impact.scores,
|
| 149 |
+
graph=index.graph,
|
| 150 |
+
dropped_ids=dropped,
|
| 151 |
+
skipped_files=index.broken_files,
|
| 152 |
+
notes=notes,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def run_pipeline(
|
| 157 |
+
repo_path: str,
|
| 158 |
+
changed_symbols: List[str],
|
| 159 |
+
max_depth: Optional[int] = 2,
|
| 160 |
+
max_tokens: Optional[int] = 10000,
|
| 161 |
+
) -> ContextPackage:
|
| 162 |
+
"""
|
| 163 |
+
Full pipeline in one call:
|
| 164 |
+
repo_path + changed_symbols -> ContextPackage
|
| 165 |
+
"""
|
| 166 |
+
index = index_repository(repo_path)
|
| 167 |
+
impact = analyze_impact(index, changed_symbols, max_depth=max_depth)
|
| 168 |
+
return compile(index, impact, max_tokens=max_tokens)
|
diffcontext/resolver.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
resolver.py — Resolve Python import statements to filesystem paths.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import ast
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from typing import Dict, Optional
|
| 9 |
+
|
| 10 |
+
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_import_map(filename: str, repo_path: str) -> Dict[str, str]:
|
| 16 |
+
"""
|
| 17 |
+
Parse imports in a file and resolve them to absolute paths.
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
dict: local_name -> absolute_path_of_source_file
|
| 21 |
+
e.g. {"helper": "/repo/utils.py", "Session": "/repo/sessions.py"}
|
| 22 |
+
|
| 23 |
+
__init__.py transparency:
|
| 24 |
+
When an import resolves to a package __init__.py, we scan that
|
| 25 |
+
__init__.py for re-export statements (`from .sub import Name`) and
|
| 26 |
+
follow them to the actual definition file. This means:
|
| 27 |
+
|
| 28 |
+
from flask import Flask
|
| 29 |
+
# resolves to src/flask/__init__.py
|
| 30 |
+
# __init__.py has: from .app import Flask
|
| 31 |
+
# final result: src/flask/app.py ← correct
|
| 32 |
+
"""
|
| 33 |
+
with open(filename, "rb") as f:
|
| 34 |
+
raw = f.read()
|
| 35 |
+
check_and_warn_encoding(logger, filename, raw)
|
| 36 |
+
source = raw.decode("utf-8", errors="ignore")
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
tree = ast.parse(source)
|
| 40 |
+
except SyntaxError as e:
|
| 41 |
+
warn_syntax_error_once(logger, filename, e)
|
| 42 |
+
return {}
|
| 43 |
+
|
| 44 |
+
imports: Dict[str, str] = {}
|
| 45 |
+
file_dir = os.path.dirname(os.path.abspath(filename))
|
| 46 |
+
repo_abs = os.path.abspath(repo_path)
|
| 47 |
+
|
| 48 |
+
for node in ast.walk(tree):
|
| 49 |
+
|
| 50 |
+
if isinstance(node, ast.ImportFrom):
|
| 51 |
+
module = node.module or ""
|
| 52 |
+
level = node.level
|
| 53 |
+
|
| 54 |
+
if level > 0:
|
| 55 |
+
# Relative import: from .utils import helper
|
| 56 |
+
base = file_dir
|
| 57 |
+
for _ in range(level - 1):
|
| 58 |
+
base = os.path.dirname(base)
|
| 59 |
+
module_path = os.path.join(base, module.replace(".", os.sep))
|
| 60 |
+
else:
|
| 61 |
+
# Absolute import: from requests.utils import helper
|
| 62 |
+
module_path = os.path.join(repo_abs, module.replace(".", os.sep))
|
| 63 |
+
|
| 64 |
+
resolved = _resolve_module_path(module_path)
|
| 65 |
+
|
| 66 |
+
for alias in node.names:
|
| 67 |
+
local_name = alias.asname or alias.name
|
| 68 |
+
|
| 69 |
+
# Check if this is a submodule import (from package import module)
|
| 70 |
+
submodule_path = os.path.join(module_path, alias.name)
|
| 71 |
+
submodule_resolved = _resolve_module_path(submodule_path)
|
| 72 |
+
|
| 73 |
+
if submodule_resolved:
|
| 74 |
+
imports[local_name] = submodule_resolved
|
| 75 |
+
elif resolved:
|
| 76 |
+
# __init__.py transparency: follow re-exports one level
|
| 77 |
+
if resolved.endswith("__init__.py"):
|
| 78 |
+
real = _follow_init_reexport(resolved, alias.name, repo_abs)
|
| 79 |
+
imports[local_name] = real if real else resolved
|
| 80 |
+
else:
|
| 81 |
+
imports[local_name] = resolved
|
| 82 |
+
|
| 83 |
+
elif isinstance(node, ast.Import):
|
| 84 |
+
for alias in node.names:
|
| 85 |
+
top_module = alias.name.split(".")[0]
|
| 86 |
+
local_name = alias.asname or top_module
|
| 87 |
+
module_path = os.path.join(repo_abs, alias.name.replace(".", os.sep))
|
| 88 |
+
resolved = _resolve_module_path(module_path)
|
| 89 |
+
|
| 90 |
+
if not resolved:
|
| 91 |
+
# Bare `import x` — try the importing file's own directory
|
| 92 |
+
sibling_path = os.path.join(file_dir, alias.name.replace(".", os.sep))
|
| 93 |
+
resolved = _resolve_module_path(sibling_path)
|
| 94 |
+
|
| 95 |
+
if resolved:
|
| 96 |
+
imports[local_name] = resolved
|
| 97 |
+
|
| 98 |
+
return imports
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _follow_init_reexport(init_path: str, name: str, repo_abs: str) -> Optional[str]:
|
| 102 |
+
"""
|
| 103 |
+
Given a package __init__.py and a name imported from it, check whether
|
| 104 |
+
__init__.py re-exports that name from a submodule.
|
| 105 |
+
|
| 106 |
+
Example:
|
| 107 |
+
__init__.py contains: from .app import Flask
|
| 108 |
+
name = "Flask"
|
| 109 |
+
→ returns /abs/path/to/app.py
|
| 110 |
+
|
| 111 |
+
Only follows one level (no recursive re-export chasing) to stay fast.
|
| 112 |
+
Returns None if the name is not re-exported or the submodule can't be found.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
with open(init_path, "rb") as f:
|
| 116 |
+
raw = f.read()
|
| 117 |
+
source = raw.decode("utf-8", errors="ignore")
|
| 118 |
+
tree = ast.parse(source)
|
| 119 |
+
except (OSError, SyntaxError):
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
init_dir = os.path.dirname(init_path)
|
| 123 |
+
|
| 124 |
+
for node in ast.walk(tree):
|
| 125 |
+
if not isinstance(node, ast.ImportFrom):
|
| 126 |
+
continue
|
| 127 |
+
if node.level == 0:
|
| 128 |
+
continue # only relative re-exports (from .sub import X)
|
| 129 |
+
for alias in node.names:
|
| 130 |
+
exported_name = alias.asname or alias.name
|
| 131 |
+
if exported_name != name:
|
| 132 |
+
continue
|
| 133 |
+
# Found the re-export — resolve the submodule
|
| 134 |
+
module = node.module or ""
|
| 135 |
+
base = init_dir
|
| 136 |
+
for _ in range(node.level - 1):
|
| 137 |
+
base = os.path.dirname(base)
|
| 138 |
+
sub_path = os.path.join(base, module.replace(".", os.sep))
|
| 139 |
+
# Check if alias.name itself is a submodule
|
| 140 |
+
submodule_path = os.path.join(sub_path, alias.name)
|
| 141 |
+
resolved = _resolve_module_path(submodule_path) or _resolve_module_path(sub_path)
|
| 142 |
+
if resolved:
|
| 143 |
+
return resolved
|
| 144 |
+
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _resolve_module_path(module_path: str) -> Optional[str]:
|
| 149 |
+
"""Try to find a .py file or package __init__.py for a given path."""
|
| 150 |
+
candidates = [
|
| 151 |
+
module_path + ".py",
|
| 152 |
+
os.path.join(module_path, "__init__.py"),
|
| 153 |
+
]
|
| 154 |
+
for candidate in candidates:
|
| 155 |
+
if os.path.isfile(candidate):
|
| 156 |
+
return os.path.normpath(candidate)
|
| 157 |
+
return None
|
diffcontext/scanner.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
scanner.py — Discover Python files in a repository.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from typing import List, Set
|
| 7 |
+
|
| 8 |
+
EXCLUDED_DIRS: Set[str] = {
|
| 9 |
+
"__pycache__",
|
| 10 |
+
".git",
|
| 11 |
+
".tox",
|
| 12 |
+
".mypy_cache",
|
| 13 |
+
".pytest_cache",
|
| 14 |
+
"venv",
|
| 15 |
+
".venv",
|
| 16 |
+
"env",
|
| 17 |
+
"node_modules",
|
| 18 |
+
"experimental",
|
| 19 |
+
"examples",
|
| 20 |
+
"docs",
|
| 21 |
+
"tests",
|
| 22 |
+
"test",
|
| 23 |
+
"benchmarks",
|
| 24 |
+
"datasets",
|
| 25 |
+
"dist",
|
| 26 |
+
"build",
|
| 27 |
+
"egg-info",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def find_python_files(root_dir: str) -> List[str]:
|
| 32 |
+
"""Walk repo tree, return list of .py file paths, skipping excluded dirs."""
|
| 33 |
+
python_files = []
|
| 34 |
+
|
| 35 |
+
for root, dirs, files in os.walk(root_dir):
|
| 36 |
+
dirs[:] = [
|
| 37 |
+
d for d in dirs
|
| 38 |
+
if d not in EXCLUDED_DIRS
|
| 39 |
+
and not d.endswith(".egg-info")
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
for f in files:
|
| 43 |
+
if f.endswith(".py"):
|
| 44 |
+
python_files.append(os.path.join(root, f))
|
| 45 |
+
|
| 46 |
+
return python_files
|
diffcontext/symbols.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
symbols.py — Attribute ownership extraction for resolving self.attr.method() chains.
|
| 3 |
+
|
| 4 |
+
Given a class with `self.router = APIRouter()`, this module figures out that
|
| 5 |
+
`self.router` has type `APIRouter`, enabling cross-file method resolution.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import ast
|
| 9 |
+
from typing import Dict, Optional, Tuple, List
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _iter_statements(body):
|
| 13 |
+
"""Yield statements in source order, recursing into if/for/while/with/try."""
|
| 14 |
+
for stmt in body:
|
| 15 |
+
yield stmt
|
| 16 |
+
|
| 17 |
+
if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While)):
|
| 18 |
+
yield from _iter_statements(stmt.body)
|
| 19 |
+
yield from _iter_statements(stmt.orelse)
|
| 20 |
+
|
| 21 |
+
elif isinstance(stmt, (ast.With, ast.AsyncWith)):
|
| 22 |
+
yield from _iter_statements(stmt.body)
|
| 23 |
+
|
| 24 |
+
elif isinstance(stmt, ast.Try):
|
| 25 |
+
yield from _iter_statements(stmt.body)
|
| 26 |
+
for handler in stmt.handlers:
|
| 27 |
+
yield from _iter_statements(handler.body)
|
| 28 |
+
yield from _iter_statements(stmt.orelse)
|
| 29 |
+
yield from _iter_statements(stmt.finalbody)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _extract_annotation_name(annotation) -> Optional[Tuple[Optional[str], str]]:
|
| 33 |
+
"""
|
| 34 |
+
Returns (qualifier, bare_name) or None.
|
| 35 |
+
|
| 36 |
+
Router -> (None, "Router")
|
| 37 |
+
Optional[Router]-> (None, "Router") <- unwrap subscript
|
| 38 |
+
routing.Router -> ("routing", "Router")
|
| 39 |
+
"""
|
| 40 |
+
if isinstance(annotation, ast.Name):
|
| 41 |
+
return (None, annotation.id)
|
| 42 |
+
|
| 43 |
+
if isinstance(annotation, ast.Attribute) and isinstance(annotation.value, ast.Name):
|
| 44 |
+
return (annotation.value.id, annotation.attr)
|
| 45 |
+
|
| 46 |
+
# Optional[Router], List[Router], etc. — unwrap one level
|
| 47 |
+
if isinstance(annotation, ast.Subscript):
|
| 48 |
+
return _extract_annotation_name(annotation.slice)
|
| 49 |
+
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _extract_call_owner(value) -> Optional[Tuple[Optional[str], str]]:
|
| 54 |
+
"""
|
| 55 |
+
Returns (qualifier, bare_name) or None.
|
| 56 |
+
|
| 57 |
+
APIRouter() -> (None, "APIRouter")
|
| 58 |
+
routing.APIRouter() -> ("routing", "APIRouter")
|
| 59 |
+
make_helper() -> (None, "make_helper")
|
| 60 |
+
"""
|
| 61 |
+
if not isinstance(value, ast.Call):
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
func = value.func
|
| 65 |
+
|
| 66 |
+
if isinstance(func, ast.Name):
|
| 67 |
+
return (None, func.id)
|
| 68 |
+
|
| 69 |
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
| 70 |
+
return (func.value.id, func.attr)
|
| 71 |
+
|
| 72 |
+
if isinstance(func, ast.Attribute):
|
| 73 |
+
return (None, func.attr)
|
| 74 |
+
|
| 75 |
+
return None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _extract_assign_owner(value, local_var_types=None, param_types=None):
|
| 79 |
+
"""
|
| 80 |
+
Handles non-Call RHS cases that still indicate a type:
|
| 81 |
+
self.x = router or Router() -> BoolOp -> try each operand
|
| 82 |
+
self.x = router -> bare Name -> no type info (return None)
|
| 83 |
+
"""
|
| 84 |
+
if local_var_types is None:
|
| 85 |
+
local_var_types = {}
|
| 86 |
+
if param_types is None:
|
| 87 |
+
param_types = {}
|
| 88 |
+
|
| 89 |
+
if isinstance(value, ast.Call):
|
| 90 |
+
return _extract_call_owner(value)
|
| 91 |
+
|
| 92 |
+
if isinstance(value, ast.Name):
|
| 93 |
+
if value.id in param_types:
|
| 94 |
+
return param_types[value.id]
|
| 95 |
+
if value.id in local_var_types:
|
| 96 |
+
return local_var_types[value.id]
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
if isinstance(value, ast.BoolOp):
|
| 100 |
+
call_result = None
|
| 101 |
+
name_result = None
|
| 102 |
+
for operand in value.values:
|
| 103 |
+
ref = _extract_assign_owner(operand, local_var_types, param_types)
|
| 104 |
+
if ref is None:
|
| 105 |
+
continue
|
| 106 |
+
if isinstance(operand, ast.Call):
|
| 107 |
+
call_result = call_result or ref
|
| 108 |
+
else:
|
| 109 |
+
name_result = name_result or ref
|
| 110 |
+
return call_result or name_result
|
| 111 |
+
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def extract_local_var_types(
|
| 116 |
+
fn_node,
|
| 117 |
+
param_types: Optional[Dict[str, Tuple[Optional[str], str]]] = None,
|
| 118 |
+
) -> Dict[str, Tuple[Optional[str], str]]:
|
| 119 |
+
"""
|
| 120 |
+
Track local variable -> type assignments within a SINGLE function body,
|
| 121 |
+
free function or method alike:
|
| 122 |
+
|
| 123 |
+
h = Helper() -> {"h": (None, "Helper")}
|
| 124 |
+
r: routing.Router = ... -> {"r": ("routing", "Router")}
|
| 125 |
+
x = make_helper() -> chases factory functions the same way
|
| 126 |
+
attribute ownership tracking does
|
| 127 |
+
(resolution of the factory return type
|
| 128 |
+
itself happens later, in graph_builder)
|
| 129 |
+
|
| 130 |
+
This is the free-function counterpart to the self.attr tracking that
|
| 131 |
+
extract_attribute_ownerships does for class bodies. It exists because
|
| 132 |
+
a huge fraction of real code instantiates a class in a local variable
|
| 133 |
+
inside a plain function (not as a self.attr) and immediately calls a
|
| 134 |
+
method on it -- e.g.:
|
| 135 |
+
|
| 136 |
+
def run():
|
| 137 |
+
h = Handler()
|
| 138 |
+
return h.process()
|
| 139 |
+
|
| 140 |
+
Without this, `h.process()` can never resolve: there's nowhere that
|
| 141 |
+
records "h" has type "Handler". extract_attribute_ownerships alone
|
| 142 |
+
can't help, because it only ever looks inside ast.ClassDef bodies.
|
| 143 |
+
|
| 144 |
+
Returns: {local_var_name: (qualifier, bare_type_name)}
|
| 145 |
+
"""
|
| 146 |
+
if param_types is None:
|
| 147 |
+
param_types = {}
|
| 148 |
+
|
| 149 |
+
local_var_types: Dict[str, Tuple[Optional[str], str]] = {}
|
| 150 |
+
|
| 151 |
+
for stmt in _iter_statements(fn_node.body):
|
| 152 |
+
|
| 153 |
+
if isinstance(stmt, ast.AnnAssign):
|
| 154 |
+
target = stmt.target
|
| 155 |
+
if isinstance(target, ast.Name):
|
| 156 |
+
ref = _extract_annotation_name(stmt.annotation)
|
| 157 |
+
if ref:
|
| 158 |
+
local_var_types[target.id] = ref
|
| 159 |
+
continue
|
| 160 |
+
|
| 161 |
+
if isinstance(stmt, ast.Assign):
|
| 162 |
+
for target in stmt.targets:
|
| 163 |
+
if isinstance(target, ast.Name):
|
| 164 |
+
ref = _extract_assign_owner(
|
| 165 |
+
stmt.value, local_var_types, param_types
|
| 166 |
+
)
|
| 167 |
+
if ref:
|
| 168 |
+
local_var_types[target.id] = ref
|
| 169 |
+
continue
|
| 170 |
+
|
| 171 |
+
return local_var_types
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def extract_param_types(fn_node) -> Dict[str, Tuple[Optional[str], str]]:
|
| 175 |
+
"""
|
| 176 |
+
Map annotated parameter names to their (qualifier, bare_name) type, e.g.
|
| 177 |
+
|
| 178 |
+
def f(router: routing.Router, name: str): ...
|
| 179 |
+
|
| 180 |
+
-> {"router": ("routing", "Router")} (unannotated/non-type params skipped)
|
| 181 |
+
"""
|
| 182 |
+
param_types: Dict[str, Tuple[Optional[str], str]] = {}
|
| 183 |
+
for arg in fn_node.args.args:
|
| 184 |
+
if arg.annotation is not None:
|
| 185 |
+
ref = _extract_annotation_name(arg.annotation)
|
| 186 |
+
if ref:
|
| 187 |
+
param_types[arg.arg] = ref
|
| 188 |
+
return param_types
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def extract_attribute_ownerships(tree) -> Dict[str, Tuple[Optional[str], str]]:
|
| 192 |
+
"""
|
| 193 |
+
Extracts mappings like:
|
| 194 |
+
self.router: routing.Router = routing.Router()
|
| 195 |
+
self.router = Router()
|
| 196 |
+
self.router2 = router # typed constructor param
|
| 197 |
+
h = Helper(); self.helper = h # local var tracking
|
| 198 |
+
self.router3 = router or Router()
|
| 199 |
+
self.opt_router: Optional[Router] = None
|
| 200 |
+
|
| 201 |
+
Returns: {"FastAPI.router": (qualifier, type_name), ...}
|
| 202 |
+
"""
|
| 203 |
+
ownerships: Dict[str, Tuple[Optional[str], str]] = {}
|
| 204 |
+
|
| 205 |
+
for node in tree.body:
|
| 206 |
+
if not isinstance(node, ast.ClassDef):
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
class_name = node.name
|
| 210 |
+
|
| 211 |
+
for item in node.body:
|
| 212 |
+
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
param_types = extract_param_types(item)
|
| 216 |
+
|
| 217 |
+
local_var_types = {}
|
| 218 |
+
|
| 219 |
+
for stmt in _iter_statements(item.body):
|
| 220 |
+
|
| 221 |
+
if isinstance(stmt, ast.AnnAssign):
|
| 222 |
+
target = stmt.target
|
| 223 |
+
if (
|
| 224 |
+
isinstance(target, ast.Attribute)
|
| 225 |
+
and isinstance(target.value, ast.Name)
|
| 226 |
+
and target.value.id == "self"
|
| 227 |
+
):
|
| 228 |
+
ref = _extract_annotation_name(stmt.annotation)
|
| 229 |
+
if ref:
|
| 230 |
+
ownerships[f"{class_name}.{target.attr}"] = ref
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
if isinstance(stmt, ast.Assign):
|
| 234 |
+
for target in stmt.targets:
|
| 235 |
+
if (
|
| 236 |
+
isinstance(target, ast.Attribute)
|
| 237 |
+
and isinstance(target.value, ast.Name)
|
| 238 |
+
and target.value.id == "self"
|
| 239 |
+
):
|
| 240 |
+
ref = _extract_assign_owner(
|
| 241 |
+
stmt.value, local_var_types, param_types
|
| 242 |
+
)
|
| 243 |
+
if ref:
|
| 244 |
+
ownerships[f"{class_name}.{target.attr}"] = ref
|
| 245 |
+
|
| 246 |
+
elif isinstance(target, ast.Name):
|
| 247 |
+
ref = _extract_assign_owner(
|
| 248 |
+
stmt.value, local_var_types, param_types
|
| 249 |
+
)
|
| 250 |
+
if ref:
|
| 251 |
+
local_var_types[target.id] = ref
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
+
return ownerships
|
pyproject.toml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "diffcontext"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Static-analysis-powered repository context compiler for LLMs"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.8"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
authors = [
|
| 13 |
+
{ name = "Your Name" },
|
| 14 |
+
]
|
| 15 |
+
classifiers = [
|
| 16 |
+
"Programming Language :: Python :: 3",
|
| 17 |
+
"License :: OSI Approved :: MIT License",
|
| 18 |
+
"Operating System :: OS Independent",
|
| 19 |
+
]
|
| 20 |
+
dependencies = []
|
| 21 |
+
|
| 22 |
+
[project.optional-dependencies]
|
| 23 |
+
dev = [
|
| 24 |
+
"pytest>=7.0",
|
| 25 |
+
]
|
| 26 |
+
mcp = [
|
| 27 |
+
"mcp>=1.0.0",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
[project.scripts]
|
| 31 |
+
diffcontext = "diffcontext.cli:cli_main"
|
| 32 |
+
diffcontext-mcp = "diffcontext.mcp_server:main"
|
| 33 |
+
|
| 34 |
+
[tool.setuptools.packages.find]
|
| 35 |
+
include = ["diffcontext*"]
|
| 36 |
+
|
| 37 |
+
[tool.setuptools.package-data]
|
| 38 |
+
diffcontext = ["py.typed"]
|