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
diffcontext/lexical.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ lexical.py — BM25 lexical retrieval signal.
3
+
4
+ Pure-stdlib BM25Okapi over symbol source code, used as the lexical leg of
5
+ hybrid retrieval. The eval_v2 benchmark showed the call graph alone loses
6
+ to full-code BM25 on most measures, while the graph+BM25+same-file blend
7
+ beats every individual signal on 4/5 repos (see benchmarks/EVAL_V2_REPORT.md);
8
+ this module is that lexical leg, with no third-party dependency.
9
+
10
+ The math replicates rank_bm25's BM25Okapi (k1=1.5, b=0.75, and negative-idf
11
+ flooring at epsilon * average_idf) so product scores match the benchmarked
12
+ implementation. Scoring uses an inverted index, so a query only touches
13
+ documents that share at least one term with it — indexing a ~9k-symbol repo
14
+ takes ~1s and a query a few milliseconds.
15
+ """
16
+
17
+ import math
18
+ import re
19
+ from collections import Counter, defaultdict
20
+ from typing import Dict, List
21
+
22
+ from .models import Symbol
23
+
24
+ K1 = 1.5
25
+ B = 0.75
26
+ EPSILON = 0.25 # floor for negative idf, as a fraction of average idf
27
+
28
+ _TOKEN_RE = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
29
+
30
+
31
+ def tokenize(code: str) -> List[str]:
32
+ """Identifier tokens, lowercased, single-char tokens dropped."""
33
+ return [t.lower() for t in _TOKEN_RE.findall(code) if len(t) > 1]
34
+
35
+
36
+ class LexicalIndex:
37
+ """BM25 index over every symbol's source code."""
38
+
39
+ def __init__(self, symbols: Dict[str, Symbol]):
40
+ self.ids: List[str] = list(symbols.keys())
41
+ n = len(self.ids)
42
+
43
+ doc_len: List[int] = []
44
+ term_doc_freq: Counter = Counter() # term -> #docs containing it
45
+ postings: Dict[str, List] = defaultdict(list) # term -> [(doc_idx, tf)]
46
+
47
+ for i, sid in enumerate(self.ids):
48
+ tf = Counter(tokenize(symbols[sid].code))
49
+ doc_len.append(sum(tf.values()))
50
+ for term, count in tf.items():
51
+ term_doc_freq[term] += 1
52
+ postings[term].append((i, count))
53
+
54
+ self.doc_len = doc_len
55
+ self.avgdl = sum(doc_len) / n if n else 0.0
56
+ self.postings = postings
57
+
58
+ # idf with rank_bm25's negative-idf flooring
59
+ idf: Dict[str, float] = {}
60
+ idf_sum, negatives = 0.0, []
61
+ for term, df in term_doc_freq.items():
62
+ v = math.log(n - df + 0.5) - math.log(df + 0.5)
63
+ idf[term] = v
64
+ idf_sum += v
65
+ if v < 0:
66
+ negatives.append(term)
67
+ if idf:
68
+ # rank_bm25 floors negative idf at EPSILON * average_idf, but on
69
+ # tiny corpora the average itself can be negative, which would
70
+ # make the floor negative and silently zero out every match.
71
+ # Clamp the floor to a small positive value instead.
72
+ floor = EPSILON * (idf_sum / len(idf))
73
+ if floor <= 0:
74
+ floor = EPSILON
75
+ for term in negatives:
76
+ idf[term] = floor
77
+ self.idf = idf
78
+
79
+ def scores_for(self, query_code: str) -> Dict[str, float]:
80
+ """
81
+ BM25 scores of every symbol against `query_code`.
82
+
83
+ Returns only symbols with a positive score. Duplicate query terms
84
+ contribute once per occurrence (same as BM25Okapi.get_scores).
85
+ """
86
+ if not self.ids or self.avgdl == 0:
87
+ return {}
88
+ query_tf = Counter(tokenize(query_code))
89
+ scores: Dict[int, float] = defaultdict(float)
90
+ for term, q_count in query_tf.items():
91
+ idf = self.idf.get(term)
92
+ if idf is None:
93
+ continue
94
+ for doc_idx, f in self.postings[term]:
95
+ denom = f + K1 * (1 - B + B * self.doc_len[doc_idx] / self.avgdl)
96
+ scores[doc_idx] += q_count * idf * f * (K1 + 1) / denom
97
+ return {self.ids[i]: s for i, s in scores.items() if s > 0}
98
+
99
+
100
+ def get_lexical_index(index) -> LexicalIndex:
101
+ """
102
+ Return the RepositoryIndex's lexical index, building and caching it on
103
+ first use. pipeline.update_index() invalidates the cache when symbols
104
+ change, so a stale index is never served.
105
+ """
106
+ if index._lexical is None:
107
+ index._lexical = LexicalIndex(index.symbols)
108
+ return index._lexical
diffcontext/models.py ADDED
@@ -0,0 +1,180 @@
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
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
+ # Incremental-update state, populated by pipeline.index_repository().
27
+ # Private: not part of the public API, excluded from repr/comparison.
28
+ _repo_path: Optional[str] = field(default=None, repr=False, compare=False)
29
+ _file_trees: Optional[Dict] = field(default=None, repr=False, compare=False)
30
+ _import_maps: Optional[Dict] = field(default=None, repr=False, compare=False)
31
+ _warn_state: Optional[object] = field(default=None, repr=False, compare=False)
32
+ # Lazily built BM25 index (see lexical.get_lexical_index); invalidated
33
+ # by update_index() whenever symbols change.
34
+ _lexical: Optional[object] = field(default=None, repr=False, compare=False)
35
+ # Lazily built reverse call graph; invalidated by update_index()
36
+ # whenever the forward graph is rebuilt.
37
+ _reverse_graph: Optional[Dict[str, Set[str]]] = field(default=None, repr=False, compare=False)
38
+ # Per-language-adapter edge sets (adapter name -> {id: [dep ids]}),
39
+ # kept separately so update_index() can rebuild the Python part
40
+ # without re-running unchanged language adapters. None on a
41
+ # graph-cache warm start (recomputed on first update that needs it).
42
+ _lang_graphs: Optional[Dict[str, Dict[str, List[str]]]] = field(default=None, repr=False, compare=False)
43
+
44
+ def update(self, changed_files: List[str]) -> "RepositoryIndex":
45
+ """
46
+ Incrementally re-index after `changed_files` were edited, created,
47
+ or deleted. Only those files are re-read and re-parsed; the graph
48
+ is rebuilt from in-memory ASTs. Mutates and returns this index.
49
+
50
+ Only available on indexes created by pipeline.index_repository().
51
+ """
52
+ from .pipeline import update_index
53
+ return update_index(self, changed_files)
54
+
55
+ @property
56
+ def reverse_graph(self) -> Dict[str, Set[str]]:
57
+ """
58
+ Reverse graph (callers of each symbol), computed once per index
59
+ state and cached. Treat the returned dict as read-only: it is
60
+ shared across callers and only invalidated by update().
61
+ """
62
+ if self._reverse_graph is None:
63
+ rev: Dict[str, Set[str]] = {}
64
+ for caller, callees in self.graph.items():
65
+ for callee in callees:
66
+ rev.setdefault(callee, set()).add(caller)
67
+ self._reverse_graph = rev
68
+ return self._reverse_graph
69
+
70
+ @property
71
+ def total_edges(self) -> int:
72
+ return sum(len(deps) for deps in self.graph.values())
73
+
74
+
75
+ @dataclass
76
+ class DiffResult:
77
+ """Result of comparing two states."""
78
+ modified: List[str] = field(default_factory=list)
79
+ added: List[str] = field(default_factory=list)
80
+ deleted: List[str] = field(default_factory=list)
81
+ broken_files: List[str] = field(default_factory=list)
82
+
83
+ @property
84
+ def all_changed(self) -> List[str]:
85
+ return self.modified + self.added
86
+
87
+
88
+ @dataclass
89
+ class ImpactResult:
90
+ """Result of impact analysis."""
91
+ changed: List[str] = field(default_factory=list)
92
+ blast_radius: List[str] = field(default_factory=list)
93
+ dependencies: List[str] = field(default_factory=list)
94
+ scores: Dict[str, float] = field(default_factory=dict)
95
+
96
+ @property
97
+ def all_relevant(self) -> List[str]:
98
+ """All symbols that should be in context, deduplicated, ordered by score."""
99
+ seen = set()
100
+ result = []
101
+ # Score-ordered
102
+ scored = sorted(self.scores.items(), key=lambda x: x[1], reverse=True)
103
+ for sym_id, _ in scored:
104
+ if sym_id not in seen:
105
+ seen.add(sym_id)
106
+ result.append(sym_id)
107
+ # Any remaining that weren't scored
108
+ for sym_id in self.changed + self.blast_radius + self.dependencies:
109
+ if sym_id not in seen:
110
+ seen.add(sym_id)
111
+ result.append(sym_id)
112
+ return result
113
+
114
+
115
+ @dataclass
116
+ class ContextItem:
117
+ """
118
+ One selected symbol, in structured form. The base representation of a
119
+ compiled context: a harness can filter, reorder, and re-budget these
120
+ itself instead of consuming the pre-rendered text.
121
+ """
122
+ symbol_id: str # "./path.py:ClassName.method"
123
+ code: str # full source of the symbol
124
+ score: float # impact score (higher = more relevant)
125
+ role: str # "changed" | "impacted" | "dependency"
126
+ callers: List[str] = field(default_factory=list) # full list, untruncated
127
+ callees: List[str] = field(default_factory=list) # full list, untruncated
128
+ token_estimate: int = 0
129
+
130
+
131
+ @dataclass
132
+ class ContextPackage:
133
+ """
134
+ Final compiled context for an LLM.
135
+
136
+ `items` is the structured base representation; `text` is one renderer
137
+ over it (meta-header + sections + suggestions) for direct LLM pasting.
138
+ """
139
+ text: str
140
+ symbol_count: int
141
+ token_estimate: int
142
+ total_repo_tokens: int
143
+ # Structured selection — the machine-consumable form of `text`'s body.
144
+ items: List[ContextItem] = field(default_factory=list)
145
+ # LLM self-awareness fields
146
+ dropped_symbols: List[str] = field(default_factory=list) # scored but cut by budget
147
+ skipped_files: List[str] = field(default_factory=list) # SyntaxError'd files
148
+ graph_confidence: float = 1.0 # fraction of edges that resolved
149
+
150
+ @property
151
+ def reduction_pct(self) -> float:
152
+ if self.total_repo_tokens == 0:
153
+ return 0.0
154
+ return (1 - self.token_estimate / self.total_repo_tokens) * 100
155
+
156
+
157
+ @dataclass
158
+ class BenchmarkResult:
159
+ """Result of a single benchmark run."""
160
+ name: str
161
+ repo_path: str
162
+ changed_functions: List[str]
163
+ # Graph stats
164
+ total_symbols: int = 0
165
+ total_edges: int = 0
166
+ graph_build_ms: float = 0.0
167
+ # Retrieval stats
168
+ retrieved_count: int = 0
169
+ retrieved_ids: List[str] = field(default_factory=list)
170
+ # Token stats
171
+ total_tokens: int = 0
172
+ context_tokens: int = 0
173
+ token_reduction_pct: float = 0.0
174
+ function_reduction_pct: float = 0.0
175
+ # Precision/Recall (when ground truth available)
176
+ precision: Optional[float] = None
177
+ recall: Optional[float] = None
178
+ f1: Optional[float] = None
179
+ # Timing
180
+ pipeline_ms: float = 0.0
diffcontext/parser.py ADDED
@@ -0,0 +1,183 @@
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
+ # `def`/`class` are statements, so collection only needs to descend
19
+ # through statement blocks — expression subtrees (the majority of AST
20
+ # nodes) can never contain a definition. Field order mirrors the AST's
21
+ # own field order so collection order matches a full NodeVisitor walk.
22
+ _STMT_BLOCK_FIELDS = ("body", "handlers", "orelse", "finalbody", "cases")
23
+
24
+
25
+ def _collect_functions(tree: "ast.Module") -> "List[tuple]":
26
+ """
27
+ Collect (qualified_name, node) for every function/method definition,
28
+ including nested functions, methods of classes defined inside
29
+ functions, and definitions under conditional blocks (`if
30
+ TYPE_CHECKING:`, `try/except ImportError`, `match`).
31
+ """
32
+ collected: "List[tuple]" = []
33
+ class_stack: "List[str]" = []
34
+
35
+ def _walk(stmts):
36
+ for node in stmts:
37
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
38
+ if class_stack:
39
+ name = ".".join(class_stack) + "." + node.name
40
+ else:
41
+ name = node.name
42
+ collected.append((name, node))
43
+ _walk(node.body)
44
+ elif isinstance(node, ast.ClassDef):
45
+ class_stack.append(node.name)
46
+ _walk(node.body)
47
+ class_stack.pop()
48
+ else:
49
+ for field in _STMT_BLOCK_FIELDS:
50
+ block = getattr(node, field, None)
51
+ if block:
52
+ _walk(block)
53
+
54
+ _walk(tree.body)
55
+ return collected
56
+
57
+
58
+ def _segment_lines(source: str) -> "Optional[List[str]]":
59
+ """
60
+ Pre-split source for fast per-symbol segment slicing.
61
+
62
+ `ast.get_source_segment` re-splits the ENTIRE file for every symbol —
63
+ on a large repo that is the single biggest cold-index cost. Splitting
64
+ once per file and slicing per symbol is equivalent, but only when the
65
+ file has no `\\r` or `\\f` characters (the parser's line accounting
66
+ treats those specially); return None then, and the caller falls back
67
+ to `ast.get_source_segment` for that file.
68
+ """
69
+ if "\r" in source or "\f" in source:
70
+ return None
71
+ return source.split("\n")
72
+
73
+
74
+ def _fast_segment(lines: "List[str]", node) -> "Optional[str]":
75
+ """Slice a node's source from pre-split lines. AST column offsets are
76
+ UTF-8 byte offsets, so non-ASCII boundary lines go through bytes."""
77
+ end_lineno = getattr(node, "end_lineno", None)
78
+ end_col = getattr(node, "end_col_offset", None)
79
+ if end_lineno is None or end_col is None:
80
+ return None
81
+ lineno = node.lineno - 1
82
+ end_lineno -= 1
83
+ col = node.col_offset
84
+
85
+ def _cols(line: str, start: "Optional[int]", end: "Optional[int]") -> str:
86
+ if line.isascii():
87
+ return line[start:end]
88
+ return line.encode("utf-8")[start:end].decode("utf-8")
89
+
90
+ if end_lineno == lineno:
91
+ return _cols(lines[lineno], col, end_col)
92
+ first = _cols(lines[lineno], col, None)
93
+ last = _cols(lines[end_lineno], None, end_col)
94
+ return "\n".join([first, *lines[lineno + 1 : end_lineno], last])
95
+
96
+
97
+ def extract_symbols(
98
+ filename: str,
99
+ repo_path: str,
100
+ broken_files: "Optional[List[str]]" = None,
101
+ source: "Optional[str]" = None,
102
+ tree: "Optional[ast.Module]" = None,
103
+ ) -> Dict[str, Symbol]:
104
+ """
105
+ Parse a single Python file, return dict of symbol_id -> Symbol.
106
+
107
+ Symbol IDs look like: "./relative/path.py:ClassName.method_name"
108
+
109
+ If parsing fails and `broken_files` is provided (a list), the file's
110
+ relative path is appended to it so callers can distinguish "file failed
111
+ to parse" from "file legitimately has no functions."
112
+
113
+ `source` and `tree` may be supplied together to reuse an already-read,
114
+ already-parsed file (the pipeline parses each file exactly once and
115
+ shares the result); both must correspond to the same file contents.
116
+ """
117
+ relative_file = "./" + os.path.relpath(filename, repo_path)
118
+
119
+ if source is None or tree is None:
120
+ with open(filename, "rb") as f:
121
+ raw = f.read()
122
+ check_and_warn_encoding(logger, filename, raw)
123
+ source = raw.decode("utf-8", errors="ignore")
124
+
125
+ try:
126
+ tree = ast.parse(source)
127
+ except SyntaxError as e:
128
+ warn_syntax_error_once(logger, filename, e)
129
+ if broken_files is not None:
130
+ broken_files.append(relative_file)
131
+ return {}
132
+
133
+ seg_lines = _segment_lines(source)
134
+
135
+ symbols = {}
136
+ for name, node in _collect_functions(tree):
137
+ symbol_id = f"{relative_file}:{name}"
138
+ if seg_lines is not None:
139
+ try:
140
+ code = _fast_segment(seg_lines, node)
141
+ except (IndexError, UnicodeError):
142
+ code = ast.get_source_segment(source, node)
143
+ else:
144
+ code = ast.get_source_segment(source, node)
145
+ if code is None:
146
+ continue
147
+ symbols[symbol_id] = Symbol(
148
+ id=symbol_id,
149
+ file=filename,
150
+ name=name,
151
+ code=code,
152
+ lineno=node.lineno,
153
+ )
154
+
155
+ return symbols
156
+
157
+
158
+ def extract_all_symbols(
159
+ repo_path: str,
160
+ broken_files: "Optional[List[str]]" = None,
161
+ ) -> Dict[str, Symbol]:
162
+ """
163
+ Extract symbols from all Python files in a repository.
164
+
165
+ If `broken_files` is provided (a list), relative paths of any files
166
+ that failed to parse (SyntaxError) are appended to it.
167
+ """
168
+ from .scanner import find_python_files
169
+ from .cache import SymbolCache
170
+
171
+ repo_path = os.path.abspath(repo_path)
172
+ all_symbols: Dict[str, Symbol] = {}
173
+
174
+ db_path = os.path.join(repo_path, ".diffcontext_cache.db")
175
+
176
+ with SymbolCache(db_path) as cache:
177
+ for filepath in find_python_files(repo_path):
178
+ def _parse(path: str) -> Dict[str, Symbol]:
179
+ return extract_symbols(path, repo_path, broken_files=broken_files)
180
+
181
+ all_symbols.update(cache.get_or_parse(filepath, _parse))
182
+
183
+ return all_symbols