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.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
diffcontext/__init__.py
ADDED
|
@@ -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
|