diffcontext 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. diffcontext/__init__.py +233 -0
  2. diffcontext/_warn_once.py +112 -0
  3. diffcontext/cache.py +216 -0
  4. diffcontext/cli/__init__.py +655 -0
  5. diffcontext/context/__init__.py +1 -0
  6. diffcontext/context/compiler.py +643 -0
  7. diffcontext/context/selector.py +258 -0
  8. diffcontext/diff/__init__.py +1 -0
  9. diffcontext/diff/git_diff.py +298 -0
  10. diffcontext/diff/state_manager.py +75 -0
  11. diffcontext/graph_builder.py +1026 -0
  12. diffcontext/history.py +154 -0
  13. diffcontext/impact/__init__.py +1 -0
  14. diffcontext/impact/blast_radius.py +58 -0
  15. diffcontext/impact/scoring.py +223 -0
  16. diffcontext/impact/traversal.py +58 -0
  17. diffcontext/impact/visualizer.py +338 -0
  18. diffcontext/languages/__init__.py +80 -0
  19. diffcontext/languages/typescript.py +960 -0
  20. diffcontext/lexical.py +108 -0
  21. diffcontext/models.py +180 -0
  22. diffcontext/parser.py +183 -0
  23. diffcontext/pipeline.py +887 -0
  24. diffcontext/py.typed +0 -0
  25. diffcontext/rerank/__init__.py +17 -0
  26. diffcontext/rerank/features.py +356 -0
  27. diffcontext/rerank/model.py +175 -0
  28. diffcontext/resolver.py +288 -0
  29. diffcontext/scanner.py +153 -0
  30. diffcontext/symbols.py +254 -0
  31. diffcontext/verify/__init__.py +68 -0
  32. diffcontext/verify/cases.py +631 -0
  33. diffcontext/verify/history.py +396 -0
  34. diffcontext/verify/sufficiency.py +324 -0
  35. diffcontext-0.5.1.dist-info/METADATA +219 -0
  36. diffcontext-0.5.1.dist-info/RECORD +40 -0
  37. diffcontext-0.5.1.dist-info/WHEEL +5 -0
  38. diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
  39. diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
  40. diffcontext-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,233 @@
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.5.1"
36
+
37
+ # Public, semver-covered API. Everything not listed here (graph_builder,
38
+ # resolver, symbols, scanner, parser internals) is importable but carries no
39
+ # stability guarantee across releases.
40
+ __all__ = [
41
+ "__version__",
42
+ "BlastResult",
43
+ "CoChangeIndex",
44
+ "ContextItem",
45
+ "ScoringConfig",
46
+ "blast_radius",
47
+ "index",
48
+ "diff",
49
+ "compile_context",
50
+ ]
51
+
52
+ from dataclasses import dataclass, field
53
+ from typing import Callable, Dict, List, Optional
54
+
55
+ from .pipeline import index_repository, analyze_impact
56
+ from .pipeline import compile as _compile_pipeline
57
+ from .diff.git_diff import find_changed_symbols
58
+ # Redundant alias: intentional re-export (used by library callers even
59
+ # though nothing in this module calls it).
60
+ from .impact.blast_radius import get_blast_radius as get_blast_radius
61
+ from .history import CoChangeIndex
62
+ from .impact.scoring import ScoringConfig
63
+ from .models import ContextItem
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Public data classes
68
+ # ---------------------------------------------------------------------------
69
+
70
+ @dataclass
71
+ class BlastResult:
72
+ """Result of a blast radius analysis — the public API return type."""
73
+ changed: List[str]
74
+ callers: List[str]
75
+ dependencies: List[str]
76
+ total_affected: int
77
+ scores: Dict[str, float] = field(default_factory=dict)
78
+ graph: Dict[str, List[str]] = field(default_factory=dict)
79
+
80
+ @property
81
+ def affected_files(self) -> List[str]:
82
+ """Unique files in the blast radius."""
83
+ files = set()
84
+ for sym in self.callers:
85
+ parts = sym.split(":", 1)
86
+ if len(parts) == 2:
87
+ files.add(parts[0])
88
+ return sorted(files)
89
+
90
+ def __repr__(self):
91
+ return (
92
+ f"BlastResult(changed={len(self.changed)}, "
93
+ f"callers={len(self.callers)}, "
94
+ f"dependencies={len(self.dependencies)}, "
95
+ f"total_affected={self.total_affected})"
96
+ )
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Public API
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def blast_radius(
104
+ symbol: Optional[str] = None,
105
+ *,
106
+ ref: Optional[str] = None,
107
+ repo: str = ".",
108
+ depth: int = 3,
109
+ ) -> BlastResult:
110
+ """
111
+ Compute the blast radius of a change.
112
+
113
+ Args:
114
+ symbol: Symbol ID like "./auth.py:validate_jwt". If None, uses `ref`.
115
+ ref: Git ref to auto-detect changes (e.g. "HEAD~1").
116
+ repo: Path to the repository root.
117
+ depth: Max traversal depth.
118
+
119
+ Returns:
120
+ BlastResult with callers, dependencies, scores, etc.
121
+
122
+ Examples:
123
+ >>> from diffcontext import blast_radius
124
+ >>> r = blast_radius("./auth.py:validate_jwt", repo="/path/to/project")
125
+ >>> r = blast_radius(ref="HEAD~1", repo="/path/to/project")
126
+ """
127
+ idx = index_repository(repo)
128
+
129
+ # Determine changed symbols
130
+ if symbol:
131
+ changed = [symbol]
132
+ elif ref:
133
+ changed = find_changed_symbols(repo, idx.symbols, ref=ref)
134
+ else:
135
+ changed = find_changed_symbols(repo, idx.symbols, ref="HEAD~1")
136
+
137
+ if not changed:
138
+ return BlastResult(
139
+ changed=[], callers=[], dependencies=[],
140
+ total_affected=0, scores={}, graph=idx.graph,
141
+ )
142
+
143
+ impact = analyze_impact(idx, changed, max_depth=depth)
144
+
145
+ return BlastResult(
146
+ changed=impact.changed,
147
+ callers=impact.blast_radius,
148
+ dependencies=impact.dependencies,
149
+ total_affected=len(impact.all_relevant),
150
+ scores=impact.scores,
151
+ graph=idx.graph,
152
+ )
153
+
154
+
155
+ def index(repo: str = "."):
156
+ """
157
+ Index a repository: parse all Python files and build the call graph.
158
+
159
+ Returns a RepositoryIndex with .symbols and .graph attributes.
160
+
161
+ Example:
162
+ >>> from diffcontext import index
163
+ >>> idx = index("/path/to/project")
164
+ >>> len(idx.symbols)
165
+ 354
166
+ """
167
+ return index_repository(repo)
168
+
169
+
170
+ def diff(repo: str = ".", ref: str = "HEAD~1") -> List[str]:
171
+ """
172
+ Find changed symbol IDs from git diff.
173
+
174
+ Returns list of symbol IDs that were modified.
175
+
176
+ Example:
177
+ >>> from diffcontext import diff
178
+ >>> diff(repo="/path/to/project", ref="HEAD~1")
179
+ ['./auth.py:validate_jwt', './models.py:User.__init__']
180
+ """
181
+ idx = index_repository(repo)
182
+ return find_changed_symbols(repo, idx.symbols, ref=ref)
183
+
184
+
185
+ def compile_context(
186
+ symbol: Optional[str] = None,
187
+ *,
188
+ ref: Optional[str] = None,
189
+ repo: str = ".",
190
+ depth: int = 2,
191
+ max_tokens: int = 10000,
192
+ token_counter: Optional[Callable[[str], int]] = None,
193
+ scoring_config: Optional[ScoringConfig] = None,
194
+ ):
195
+ """
196
+ Full pipeline: detect changes → blast radius → compile LLM context.
197
+
198
+ Returns a ContextPackage with .text (rendered), .items (structured
199
+ ContextItem list a harness can re-budget itself), .token_estimate,
200
+ and .reduction_pct.
201
+
202
+ Args:
203
+ token_counter: text -> token count callable. Pass your model's
204
+ real tokenizer to enforce hard window limits;
205
+ defaults to a ~4-chars/token heuristic.
206
+ scoring_config: Custom impact-scoring weights (ScoringConfig);
207
+ tuned defaults when None.
208
+
209
+ Example:
210
+ >>> from diffcontext import compile_context
211
+ >>> ctx = compile_context(ref="HEAD~1", repo="/path/to/project")
212
+ >>> print(ctx.text) # LLM-ready context
213
+ >>> ctx.items[0].symbol_id # structured form for harnesses
214
+ >>> print(ctx.reduction_pct) # e.g. 99.2
215
+ """
216
+ idx = index_repository(repo)
217
+
218
+ if symbol:
219
+ changed = [symbol]
220
+ elif ref:
221
+ changed = find_changed_symbols(repo, idx.symbols, ref=ref)
222
+ else:
223
+ changed = find_changed_symbols(repo, idx.symbols, ref="HEAD~1")
224
+
225
+ if not changed:
226
+ from .models import ContextPackage
227
+ return ContextPackage(text="", symbol_count=0, token_estimate=0, total_repo_tokens=0)
228
+
229
+ impact = analyze_impact(idx, changed, max_depth=depth, scoring_config=scoring_config)
230
+ return _compile_pipeline(
231
+ idx, impact, max_tokens=max_tokens,
232
+ token_counter=token_counter, scoring_config=scoring_config,
233
+ )
@@ -0,0 +1,112 @@
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 Optional, Set
23
+
24
+
25
+ class WarnState:
26
+ """
27
+ De-dup state for warn-once semantics, scoped to whoever owns it.
28
+
29
+ A long-lived process (an agent harness serving many repos/sessions)
30
+ should create one WarnState per indexing session so one session's
31
+ warnings never suppress another's; the module-level default preserves
32
+ the old process-wide behavior for direct callers.
33
+ """
34
+
35
+ def __init__(self):
36
+ self.syntax_files: Set[str] = set()
37
+ self.encoding_files: Set[str] = set()
38
+ self._lock = threading.Lock()
39
+
40
+ def first_syntax(self, key: str) -> bool:
41
+ with self._lock:
42
+ if key in self.syntax_files:
43
+ return False
44
+ self.syntax_files.add(key)
45
+ return True
46
+
47
+ def first_encoding(self, key: str) -> bool:
48
+ with self._lock:
49
+ if key in self.encoding_files:
50
+ return False
51
+ self.encoding_files.add(key)
52
+ return True
53
+
54
+ def reset(self) -> None:
55
+ with self._lock:
56
+ self.syntax_files.clear()
57
+ self.encoding_files.clear()
58
+
59
+
60
+ _default_state = WarnState()
61
+
62
+
63
+ def warn_syntax_error_once(
64
+ logger: logging.Logger,
65
+ filename: str,
66
+ exc: SyntaxError,
67
+ state: Optional[WarnState] = None,
68
+ ) -> None:
69
+ """Log a SyntaxError warning for `filename`, but only the first time it's
70
+ seen by `state` (the process-wide default when None)."""
71
+ key = os.path.abspath(filename)
72
+ if not (state or _default_state).first_syntax(key):
73
+ return
74
+
75
+ logger.warning(
76
+ "\033[93mSkipping %s due to SyntaxError: %s (line %s)\033[0m",
77
+ os.path.basename(filename), exc.msg, exc.lineno,
78
+ )
79
+
80
+
81
+ def check_and_warn_encoding(
82
+ logger: logging.Logger,
83
+ filename: str,
84
+ raw_bytes: bytes,
85
+ state: Optional[WarnState] = None,
86
+ ) -> None:
87
+ """
88
+ Check whether `raw_bytes` is valid UTF-8. If not, warn once per file --
89
+ the caller will go on to decode with errors="ignore", which silently
90
+ drops the offending bytes (and anything that decoded incorrectly around
91
+ them). This doesn't stop processing; it just makes the data loss
92
+ visible instead of completely silent.
93
+ """
94
+ try:
95
+ raw_bytes.decode("utf-8")
96
+ return
97
+ except UnicodeDecodeError as e:
98
+ key = os.path.abspath(filename)
99
+ if not (state or _default_state).first_encoding(key):
100
+ return
101
+
102
+ logger.warning(
103
+ "\033[93m%s is not valid UTF-8 (%s at byte %d) -- invalid bytes "
104
+ "will be silently dropped, which can corrupt string literals "
105
+ "in the extracted code\033[0m",
106
+ os.path.basename(filename), e.reason, e.start,
107
+ )
108
+
109
+
110
+ def reset_warned_files() -> None:
111
+ """Clear the process-wide de-dup caches. Mainly useful for tests."""
112
+ _default_state.reset()
diffcontext/cache.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ cache.py — SQLite-backed persistent caching for AST parsed symbols and the
3
+ repository call graph.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import sqlite3
9
+ from typing import Dict, Callable, List, Optional, Tuple
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
+ def hash_source(source_bytes: bytes) -> str:
24
+ """SHA-256 of already-read file contents (avoids a second disk read)."""
25
+ return hashlib.sha256(source_bytes).hexdigest()
26
+
27
+
28
+ def repo_state_hash(file_hashes: Dict[str, str]) -> str:
29
+ """
30
+ Single hash summarizing the content state of every Python file in the
31
+ repo. Keyed on (relative_path, content_hash) pairs, order-independent.
32
+ Any file added, removed, or edited changes this hash.
33
+ """
34
+ hasher = hashlib.sha256()
35
+ for path in sorted(file_hashes):
36
+ hasher.update(path.encode("utf-8"))
37
+ hasher.update(b"\0")
38
+ hasher.update(file_hashes[path].encode("ascii"))
39
+ hasher.update(b"\n")
40
+ return hasher.hexdigest()
41
+
42
+
43
+ class SymbolCache:
44
+ """
45
+ Persistent SQLite cache for parsed AST symbols and the call graph.
46
+
47
+ Safe for concurrent use from multiple threads within one process: the
48
+ connection is created with check_same_thread=False and every public
49
+ operation holds an internal lock (SQLite serializes at the file level
50
+ across processes on its own via WAL).
51
+ """
52
+
53
+ def __init__(self, db_path: str = ".diffcontext_cache.db"):
54
+ import threading
55
+ self.db_path = db_path
56
+ self._conn = None
57
+ self._lock = threading.RLock()
58
+ self._connect()
59
+
60
+ def _connect(self):
61
+ if self._conn is None:
62
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
63
+ self._conn.execute("PRAGMA journal_mode=WAL")
64
+ # Cache contents are rebuildable from source; NORMAL skips the
65
+ # per-commit fsync (a measurable cost at one commit per file on
66
+ # a cold index) and risks nothing worse than a stale cache row
67
+ # after power loss, which the content hash then invalidates.
68
+ self._conn.execute("PRAGMA synchronous=NORMAL")
69
+ self._conn.execute("PRAGMA foreign_keys=ON")
70
+ self._init_db()
71
+
72
+ def close(self):
73
+ with self._lock:
74
+ if self._conn:
75
+ self._conn.close()
76
+ self._conn = None
77
+
78
+ def __enter__(self):
79
+ return self
80
+
81
+ def __exit__(self, exc_type, exc_val, exc_tb):
82
+ self.close()
83
+
84
+ def _init_db(self):
85
+ with self._conn:
86
+ self._conn.executescript('''
87
+ CREATE TABLE IF NOT EXISTS files (
88
+ file_path TEXT PRIMARY KEY,
89
+ file_hash TEXT NOT NULL
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS symbols (
93
+ id TEXT PRIMARY KEY,
94
+ file_path TEXT NOT NULL,
95
+ name TEXT NOT NULL,
96
+ code TEXT NOT NULL,
97
+ lineno INTEGER,
98
+ FOREIGN KEY(file_path) REFERENCES files(file_path) ON DELETE CASCADE
99
+ );
100
+
101
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_path);
102
+
103
+ CREATE TABLE IF NOT EXISTS graphs (
104
+ state_hash TEXT PRIMARY KEY,
105
+ graph_json TEXT NOT NULL,
106
+ broken_json TEXT NOT NULL,
107
+ created_at INTEGER
108
+ );
109
+ ''')
110
+
111
+ # ── Graph caching ─────────────────────────────────────────────────────
112
+ # The call graph is repo-global (cross-file edges), so it is cached as a
113
+ # whole, keyed by repo_state_hash(): the combined content hash of every
114
+ # Python file. Same pattern as symbols — content-addressed, no TTL logic.
115
+
116
+ _GRAPH_CACHE_KEEP = 5 # most-recent graph snapshots retained per db
117
+
118
+ def get_graph(self, state_hash: str) -> "Optional[Tuple[Dict[str, List[str]], List[str]]]":
119
+ """Return (graph, broken_files) for this exact repo state, or None."""
120
+ with self._lock:
121
+ row = self._conn.execute(
122
+ "SELECT graph_json, broken_json FROM graphs WHERE state_hash = ?",
123
+ (state_hash,),
124
+ ).fetchone()
125
+ if row is None:
126
+ return None
127
+ return json.loads(row[0]), json.loads(row[1])
128
+
129
+ def put_graph(
130
+ self,
131
+ state_hash: str,
132
+ graph: Dict[str, List[str]],
133
+ broken_files: "List[str]",
134
+ ) -> None:
135
+ """Persist the graph for this repo state; prune old snapshots."""
136
+ import time as _time
137
+ with self._lock, self._conn:
138
+ self._conn.execute(
139
+ "INSERT OR REPLACE INTO graphs VALUES (?, ?, ?, ?)",
140
+ (state_hash, json.dumps(graph), json.dumps(broken_files),
141
+ int(_time.time())),
142
+ )
143
+ self._conn.execute(
144
+ """DELETE FROM graphs WHERE state_hash NOT IN (
145
+ SELECT state_hash FROM graphs
146
+ ORDER BY created_at DESC, rowid DESC LIMIT ?
147
+ )""",
148
+ (self._GRAPH_CACHE_KEEP,),
149
+ )
150
+
151
+ def get_or_parse(
152
+ self,
153
+ filepath: str,
154
+ parse_fn: Callable[[str], Dict[str, Symbol]],
155
+ known_hash: "Optional[str]" = None,
156
+ ) -> Dict[str, Symbol]:
157
+ """
158
+ Return cached symbols if file hash matches, otherwise parse and persist.
159
+
160
+ `known_hash` lets a caller that already read and hashed the file
161
+ (the pipeline hashes every file for the repo state hash) skip a
162
+ second full disk read here. It MUST be the hash of the file's
163
+ current contents.
164
+ """
165
+ file_hash = known_hash if known_hash is not None else get_file_hash(filepath)
166
+
167
+ with self._lock:
168
+ cursor = self._conn.execute("SELECT file_hash FROM files WHERE file_path = ?", (filepath,))
169
+ row = cursor.fetchone()
170
+
171
+ if row and row[0] == file_hash:
172
+ # Cache hit!
173
+ cursor = self._conn.execute(
174
+ "SELECT id, file_path, name, code, lineno FROM symbols WHERE file_path = ?",
175
+ (filepath,)
176
+ )
177
+ symbols = {}
178
+ for row in cursor:
179
+ sym_id, f_path, name, code, lineno = row
180
+ symbols[sym_id] = Symbol(
181
+ id=sym_id,
182
+ file=f_path,
183
+ name=name,
184
+ code=code,
185
+ lineno=lineno
186
+ )
187
+ return symbols
188
+
189
+ # Cache miss or hash mismatch -> parse it (outside the lock; parsing
190
+ # can be slow and must not serialize other threads' cache hits)
191
+ symbols = parse_fn(filepath)
192
+
193
+ # Persist the new state
194
+ with self._lock, self._conn:
195
+ # DELETE CASCADE will drop all existing symbols for this file
196
+ self._conn.execute("DELETE FROM files WHERE file_path = ?", (filepath,))
197
+
198
+ self._conn.execute(
199
+ "INSERT INTO files (file_path, file_hash) VALUES (?, ?)",
200
+ (filepath, file_hash)
201
+ )
202
+
203
+ if symbols:
204
+ # REPLACE, not plain INSERT: rows are keyed by symbols.id but
205
+ # cleared via ON DELETE CASCADE from files.file_path, and those
206
+ # two only line up while Symbol.file is byte-identical to the
207
+ # filepath we just parsed. That holds for the Python parser
208
+ # (both absolute) but is not guaranteed for a language adapter
209
+ # that reports relative paths — stale rows would then survive
210
+ # the DELETE above and collide here on re-index.
211
+ self._conn.executemany(
212
+ "INSERT OR REPLACE INTO symbols (id, file_path, name, code, lineno) VALUES (?, ?, ?, ?, ?)",
213
+ [(s.id, s.file, s.name, s.code, s.lineno) for s in symbols.values()]
214
+ )
215
+
216
+ return symbols