trakshan-mishra commited on
Commit
036a2db
·
1 Parent(s): 8356354

Deploy FastAPI & MCP server over SSE

Browse files
.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
- title: Diffcontext
3
- emoji: 📚
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: 'DiffContext, a static-analysis-based context compiler '
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):&#10; msg = build_message(name)&#10; print(msg)&#10;&#10;def build_message(name):&#10; 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"]