diffcontext 0.5.3__tar.gz → 0.5.5__tar.gz
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-0.5.3 → diffcontext-0.5.5}/PKG-INFO +1 -1
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/__init__.py +1 -1
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/cache.py +90 -16
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/cli/__init__.py +22 -2
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/mcp_server.py +4 -2
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/parser.py +2 -2
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/pipeline.py +11 -3
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/PKG-INFO +1 -1
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/SOURCES.txt +1 -0
- diffcontext-0.5.5/tests/test_cache_fallback.py +121 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_core.py +59 -34
- {diffcontext-0.5.3 → diffcontext-0.5.5}/LICENSE +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/README.md +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/_warn_once.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/compiler.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/selector.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/git_diff.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/state_manager.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/graph_builder.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/history.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/blast_radius.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/scoring.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/traversal.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/visualizer.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/languages/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/languages/typescript.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/lexical.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/models.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/py.typed +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/features.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/model.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/resolver.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/scanner.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/symbols.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/__init__.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/cases.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/history.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/sufficiency.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/dependency_links.txt +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/entry_points.txt +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/requires.txt +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/top_level.txt +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/pyproject.toml +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/setup.cfg +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_cache.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_compiler_meta.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_cutoff.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_determinism.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_downstream_eval.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_git_diff.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_harness_api.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_history.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_incremental.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_index_scoping.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_lexical.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_mcp_server.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_rerank.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_ablation.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_audit.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_gap.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_metrics.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_mine.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_service_clone.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_src_layout.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_stats.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_token_budget.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_typescript_adapter.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_verify.py +0 -0
- {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_version.py +0 -0
|
@@ -32,7 +32,7 @@ Usage as a library:
|
|
|
32
32
|
print(ctx.reduction_pct) # how much code was filtered out
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
__version__ = "0.5.
|
|
35
|
+
__version__ = "0.5.5"
|
|
36
36
|
|
|
37
37
|
# Public, semver-covered API. Everything not listed here (graph_builder,
|
|
38
38
|
# resolver, symbols, scanner, parser internals) is importable but carries no
|
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
"""
|
|
2
2
|
cache.py — SQLite-backed persistent caching for AST parsed symbols and the
|
|
3
3
|
repository call graph.
|
|
4
|
+
|
|
5
|
+
Cache path resolution (cascade, in order):
|
|
6
|
+
1. DIFFCONTEXT_CACHE_DIR env var (if set)
|
|
7
|
+
2. repo_path/.diffcontext_cache.db (current behaviour, keeps cache locality)
|
|
8
|
+
3. $XDG_CACHE_HOME/diffcontext/<sha256 of abs repo_path>.db
|
|
9
|
+
or ~/.cache/diffcontext/<sha256>.db
|
|
10
|
+
4. sqlite ':memory:' (degraded — no persistence across calls, but working)
|
|
11
|
+
|
|
12
|
+
The cascade ensures DiffContext works on read-only repos (Glama sandbox,
|
|
13
|
+
CI checkouts, containers with read-only rootfs, repos the user doesn't own).
|
|
14
|
+
Do NOT fail the call because of cache — a stale or missing cache only costs
|
|
15
|
+
a re-parse, never correctness.
|
|
4
16
|
"""
|
|
5
17
|
|
|
6
18
|
import hashlib
|
|
7
19
|
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
8
22
|
import sqlite3
|
|
9
23
|
from typing import Dict, Callable, List, Optional, Tuple
|
|
10
24
|
|
|
11
25
|
from .models import Symbol
|
|
12
26
|
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
13
29
|
|
|
14
30
|
def get_file_hash(filepath: str) -> str:
|
|
15
|
-
"""Compute SHA-256
|
|
31
|
+
"""Compute SHA-256 of a file."""
|
|
16
32
|
hasher = hashlib.sha256()
|
|
17
33
|
with open(filepath, "rb") as f:
|
|
18
|
-
# Python files are small enough to read into memory safely
|
|
19
34
|
hasher.update(f.read())
|
|
20
35
|
return hasher.hexdigest()
|
|
21
36
|
|
|
@@ -26,11 +41,7 @@ def hash_source(source_bytes: bytes) -> str:
|
|
|
26
41
|
|
|
27
42
|
|
|
28
43
|
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
|
-
"""
|
|
44
|
+
"""Single hash summarizing the content state of every Python file."""
|
|
34
45
|
hasher = hashlib.sha256()
|
|
35
46
|
for path in sorted(file_hashes):
|
|
36
47
|
hasher.update(path.encode("utf-8"))
|
|
@@ -40,17 +51,84 @@ def repo_state_hash(file_hashes: Dict[str, str]) -> str:
|
|
|
40
51
|
return hasher.hexdigest()
|
|
41
52
|
|
|
42
53
|
|
|
54
|
+
def _resolve_cache_path(
|
|
55
|
+
repo_path: Optional[str] = None,
|
|
56
|
+
cache_dir: Optional[str] = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Resolve the SQLite cache database path via a cascade.
|
|
60
|
+
|
|
61
|
+
1. If `cache_dir` is explicitly passed (from --cache-dir CLI flag), use it.
|
|
62
|
+
2. If DIFFCONTEXT_CACHE_DIR env var is set, use it.
|
|
63
|
+
3. Try repo_path/.diffcontext_cache.db (keeps cache locality with the repo).
|
|
64
|
+
4. Fall back to $XDG_CACHE_HOME/diffcontext/<hash>.db or ~/.cache/diffcontext/<hash>.db
|
|
65
|
+
5. Fall back to ':memory:' (degraded — no persistence, but working).
|
|
66
|
+
|
|
67
|
+
Steps 3 and 4 try to create the file/dir; if they fail (read-only
|
|
68
|
+
filesystem, permission denied), the cascade continues. Step 5 always
|
|
69
|
+
works.
|
|
70
|
+
|
|
71
|
+
Returns a path string (or ':memory:').
|
|
72
|
+
"""
|
|
73
|
+
# 1. Explicit override (highest priority)
|
|
74
|
+
if cache_dir:
|
|
75
|
+
try:
|
|
76
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
return os.path.join(cache_dir, ".diffcontext_cache.db")
|
|
80
|
+
|
|
81
|
+
# 2. Env var override
|
|
82
|
+
env_cache_dir = os.environ.get("DIFFCONTEXT_CACHE_DIR")
|
|
83
|
+
if env_cache_dir:
|
|
84
|
+
try:
|
|
85
|
+
os.makedirs(env_cache_dir, exist_ok=True)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
return os.path.join(env_cache_dir, ".diffcontext_cache.db")
|
|
89
|
+
|
|
90
|
+
# 3. In-repo path (current behaviour, keeps cache locality)
|
|
91
|
+
if repo_path:
|
|
92
|
+
in_repo = os.path.join(repo_path, ".diffcontext_cache.db")
|
|
93
|
+
# Try to create/test writability. If it works, use it.
|
|
94
|
+
try:
|
|
95
|
+
# Touch to test writability (file may already exist)
|
|
96
|
+
fd = os.open(in_repo, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
|
|
97
|
+
os.close(fd)
|
|
98
|
+
return in_repo
|
|
99
|
+
except OSError:
|
|
100
|
+
logger.debug("cache: repo_path not writable (%s), falling back", in_repo)
|
|
101
|
+
|
|
102
|
+
# 4. XDG cache dir
|
|
103
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
|
104
|
+
# Hash the abs repo path so different repos don't collide
|
|
105
|
+
repo_hash = hashlib.sha256(os.path.abspath(repo_path or "").encode()).hexdigest()[:16]
|
|
106
|
+
cache_subdir = os.path.join(xdg_cache, "diffcontext")
|
|
107
|
+
xdg_path = os.path.join(cache_subdir, f"{repo_hash}.db")
|
|
108
|
+
try:
|
|
109
|
+
os.makedirs(cache_subdir, exist_ok=True)
|
|
110
|
+
fd = os.open(xdg_path, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o644)
|
|
111
|
+
os.close(fd)
|
|
112
|
+
logger.debug("cache: using XDG cache at %s", xdg_path)
|
|
113
|
+
return xdg_path
|
|
114
|
+
except OSError:
|
|
115
|
+
logger.debug("cache: XDG cache dir not writable (%s), falling back to :memory:", xdg_path)
|
|
116
|
+
|
|
117
|
+
# 5. In-memory (always works, but no persistence)
|
|
118
|
+
logger.debug("cache: all disk locations failed, using :memory:")
|
|
119
|
+
return ":memory:"
|
|
120
|
+
|
|
121
|
+
|
|
43
122
|
class SymbolCache:
|
|
44
123
|
"""
|
|
45
124
|
Persistent SQLite cache for parsed AST symbols and the call graph.
|
|
46
125
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
across processes on its own via WAL).
|
|
126
|
+
The cache path is resolved via _resolve_cache_path(). On read-only
|
|
127
|
+
repos, the cascade falls back to XDG cache or :memory: — the call
|
|
128
|
+
never fails because of cache.
|
|
51
129
|
"""
|
|
52
130
|
|
|
53
|
-
def __init__(self, db_path: str = "
|
|
131
|
+
def __init__(self, db_path: str = ":memory:"):
|
|
54
132
|
import threading
|
|
55
133
|
self.db_path = db_path
|
|
56
134
|
self._conn = None
|
|
@@ -61,10 +139,6 @@ class SymbolCache:
|
|
|
61
139
|
if self._conn is None:
|
|
62
140
|
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
63
141
|
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
142
|
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
69
143
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
70
144
|
self._init_db()
|
|
@@ -50,6 +50,12 @@ def main():
|
|
|
50
50
|
"(tests/, benchmarks/, docs/, ...). Useful when a change "
|
|
51
51
|
"spans an excluded dir; .gitignore still applies.",
|
|
52
52
|
)
|
|
53
|
+
p_index.add_argument(
|
|
54
|
+
"--cache-dir", default=None, metavar="DIR",
|
|
55
|
+
help="Cache database location (default: repo/.diffcontext_cache.db, "
|
|
56
|
+
"falls back to XDG cache or :memory: on read-only repos). "
|
|
57
|
+
"Also settable via DIFFCONTEXT_CACHE_DIR env var.",
|
|
58
|
+
)
|
|
53
59
|
|
|
54
60
|
# --- impact ---
|
|
55
61
|
p_impact = sub.add_parser("impact", help="Analyze impact of a symbol change")
|
|
@@ -83,6 +89,12 @@ def main():
|
|
|
83
89
|
p_compile.add_argument("--changed", nargs="+", help="Changed symbol IDs")
|
|
84
90
|
p_compile.add_argument("--ref", default=None, help="Git ref (auto-detect changes)")
|
|
85
91
|
p_compile.add_argument("--repo", default=".", help="Repository path")
|
|
92
|
+
p_compile.add_argument(
|
|
93
|
+
"--cache-dir", default=None, metavar="DIR",
|
|
94
|
+
help="Cache database location (default: repo/.diffcontext_cache.db, "
|
|
95
|
+
"falls back to XDG cache or :memory: on read-only repos). "
|
|
96
|
+
"Also settable via DIFFCONTEXT_CACHE_DIR env var.",
|
|
97
|
+
)
|
|
86
98
|
p_compile.add_argument(
|
|
87
99
|
"--include", nargs="*", default=[], metavar="DIR",
|
|
88
100
|
help="Directory names to index despite default exclusions (tests/, "
|
|
@@ -270,7 +282,7 @@ def _cmd_index(args):
|
|
|
270
282
|
"""Index repository: show stats."""
|
|
271
283
|
t0 = time.perf_counter()
|
|
272
284
|
include = set(args.include or [])
|
|
273
|
-
idx = index_repository(args.repo, include=include)
|
|
285
|
+
idx = index_repository(args.repo, include=include, cache_dir=args.cache_dir)
|
|
274
286
|
elapsed = (time.perf_counter() - t0) * 1000
|
|
275
287
|
|
|
276
288
|
print(f"Symbols : {len(idx.symbols)}")
|
|
@@ -368,7 +380,10 @@ def _cmd_diff(args):
|
|
|
368
380
|
|
|
369
381
|
def _cmd_compile(args):
|
|
370
382
|
"""Build full context package."""
|
|
371
|
-
idx = index_repository(
|
|
383
|
+
idx = index_repository(
|
|
384
|
+
args.repo, include=set(args.include or []),
|
|
385
|
+
cache_dir=getattr(args, "cache_dir", None),
|
|
386
|
+
)
|
|
372
387
|
|
|
373
388
|
# Determine changed symbols
|
|
374
389
|
if args.changed:
|
|
@@ -392,6 +407,11 @@ def _cmd_compile(args):
|
|
|
392
407
|
from ..history import CoChangeIndex
|
|
393
408
|
history = CoChangeIndex(args.repo)
|
|
394
409
|
|
|
410
|
+
# Untuned default; not derived from any sweep. The query_weight controls
|
|
411
|
+
# how much the problem-description signal moves candidates relative to
|
|
412
|
+
# the graph + BM25 + same-file blend. 0.3 is a guess that "matters but
|
|
413
|
+
# doesn't dominate" — it has NOT been benchmarked or optimized. Do not
|
|
414
|
+
# cite this as a measured value; tune it with evidence when data exists.
|
|
395
415
|
query_weight = 0.3 if args.query_text else 0.0
|
|
396
416
|
impact = analyze_impact(
|
|
397
417
|
idx, changed, max_depth=args.depth, hybrid=not args.graph_only,
|
|
@@ -131,8 +131,10 @@ def main():
|
|
|
131
131
|
if not changed and not task_description:
|
|
132
132
|
return "No changed symbols found. Pass changed_symbols, git_ref, or task_description."
|
|
133
133
|
|
|
134
|
-
#
|
|
135
|
-
#
|
|
134
|
+
# Untuned default; not derived from any sweep. The query_weight controls
|
|
135
|
+
# how much the problem-description signal moves candidates relative to
|
|
136
|
+
# the graph + BM25 + same-file blend. 0.3 is a guess that "matters but
|
|
137
|
+
# doesn't dominate" — it has NOT been benchmarked or optimized.
|
|
136
138
|
query_weight = 0.3 if task_description else 0.0
|
|
137
139
|
impact = analyze_impact(
|
|
138
140
|
idx, changed, query_text=task_description, query_weight=query_weight,
|
|
@@ -166,12 +166,12 @@ def extract_all_symbols(
|
|
|
166
166
|
that failed to parse (SyntaxError) are appended to it.
|
|
167
167
|
"""
|
|
168
168
|
from .scanner import find_python_files
|
|
169
|
-
from .cache import SymbolCache
|
|
169
|
+
from .cache import SymbolCache, _resolve_cache_path
|
|
170
170
|
|
|
171
171
|
repo_path = os.path.abspath(repo_path)
|
|
172
172
|
all_symbols: Dict[str, Symbol] = {}
|
|
173
173
|
|
|
174
|
-
db_path =
|
|
174
|
+
db_path = _resolve_cache_path(repo_path)
|
|
175
175
|
|
|
176
176
|
with SymbolCache(db_path) as cache:
|
|
177
177
|
for filepath in find_python_files(repo_path):
|
|
@@ -24,7 +24,7 @@ from .models import (
|
|
|
24
24
|
from .languages import available_adapters, discover_files
|
|
25
25
|
from .parser import extract_symbols
|
|
26
26
|
from .scanner import find_python_files, first_excluded_dir
|
|
27
|
-
from .cache import SymbolCache, get_file_hash, hash_source, repo_state_hash
|
|
27
|
+
from .cache import SymbolCache, get_file_hash, hash_source, repo_state_hash, _resolve_cache_path
|
|
28
28
|
from .resolver import build_import_map
|
|
29
29
|
from .graph_builder import build_repository_graph
|
|
30
30
|
from ._warn_once import warn_syntax_error_once, check_and_warn_encoding, WarnState
|
|
@@ -104,6 +104,7 @@ def _read_and_parse(
|
|
|
104
104
|
|
|
105
105
|
def index_repository(
|
|
106
106
|
repo_path: str, include: Optional[Set[str]] = None,
|
|
107
|
+
cache_dir: Optional[str] = None,
|
|
107
108
|
) -> RepositoryIndex:
|
|
108
109
|
"""
|
|
109
110
|
Phase 1: Parse repository and build dependency graph.
|
|
@@ -123,9 +124,14 @@ def index_repository(
|
|
|
123
124
|
list of files (if any) that failed to parse due to a SyntaxError. The
|
|
124
125
|
returned index supports in-process incremental updates via
|
|
125
126
|
`index.update([...])`.
|
|
127
|
+
|
|
128
|
+
`cache_dir` overrides the cache location. If None, the cascade in
|
|
129
|
+
`_resolve_cache_path` is used: repo_path/.diffcontext_cache.db →
|
|
130
|
+
XDG cache → :memory:. Pass a path to force a specific location (also
|
|
131
|
+
settable via DIFFCONTEXT_CACHE_DIR env var).
|
|
126
132
|
"""
|
|
127
133
|
repo_path = os.path.abspath(repo_path)
|
|
128
|
-
db_path =
|
|
134
|
+
db_path = _resolve_cache_path(repo_path, cache_dir)
|
|
129
135
|
|
|
130
136
|
files = find_python_files(repo_path, include)
|
|
131
137
|
|
|
@@ -258,6 +264,7 @@ def index_repository(
|
|
|
258
264
|
index._lang_graphs = lang_graphs # None on graph-cache hit (lazy)
|
|
259
265
|
index._warn_state = warn_state
|
|
260
266
|
index._include = include # scanner dirs to keep despite defaults
|
|
267
|
+
index._cache_dir = cache_dir # cache location override for update_index
|
|
261
268
|
return index
|
|
262
269
|
|
|
263
270
|
|
|
@@ -442,7 +449,8 @@ def update_index(index: RepositoryIndex, changed_files: List[str]) -> Repository
|
|
|
442
449
|
"index_repository()."
|
|
443
450
|
)
|
|
444
451
|
repo_path = index._repo_path
|
|
445
|
-
|
|
452
|
+
cache_dir = getattr(index, "_cache_dir", None)
|
|
453
|
+
db_path = _resolve_cache_path(repo_path, cache_dir)
|
|
446
454
|
|
|
447
455
|
_ensure_trees(index)
|
|
448
456
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
tests/test_cache_fallback.py — Tests for the cache path cascade.
|
|
4
|
+
|
|
5
|
+
The cascade: repo_path/.diffcontext_cache.db → XDG cache → :memory:.
|
|
6
|
+
Ensures DiffContext works on read-only repos (Glama sandbox, CI checkouts,
|
|
7
|
+
containers with read-only rootfs, repos the user doesn't own).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
15
|
+
|
|
16
|
+
from diffcontext.cache import _resolve_cache_path
|
|
17
|
+
from diffcontext.pipeline import index_repository
|
|
18
|
+
|
|
19
|
+
FIXTURES = os.path.join(os.path.dirname(os.path.dirname(__file__)), "tests", "fixtures")
|
|
20
|
+
SIMPLE = os.path.join(FIXTURES, "simple_repo")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestResolveCachePath:
|
|
24
|
+
def test_explicit_cache_dir_takes_priority(self, tmp_path):
|
|
25
|
+
"""--cache-dir / env var overrides everything."""
|
|
26
|
+
path = _resolve_cache_path("/some/repo", cache_dir=str(tmp_path))
|
|
27
|
+
assert path == os.path.join(str(tmp_path), ".diffcontext_cache.db")
|
|
28
|
+
|
|
29
|
+
def test_env_var_takes_priority(self, tmp_path, monkeypatch):
|
|
30
|
+
"""DIFFCONTEXT_CACHE_DIR env var is used when no explicit override."""
|
|
31
|
+
monkeypatch.setenv("DIFFCONTEXT_CACHE_DIR", str(tmp_path))
|
|
32
|
+
path = _resolve_cache_path("/some/repo")
|
|
33
|
+
assert path == os.path.join(str(tmp_path), ".diffcontext_cache.db")
|
|
34
|
+
|
|
35
|
+
def test_readonly_repo_falls_back_to_xdg(self, tmp_path, monkeypatch):
|
|
36
|
+
"""Read-only repo dir falls back to XDG cache."""
|
|
37
|
+
monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
|
|
38
|
+
xdg = tmp_path / "xdg"
|
|
39
|
+
monkeypatch.setenv("XDG_CACHE_HOME", str(xdg))
|
|
40
|
+
|
|
41
|
+
# Make repo dir read-only
|
|
42
|
+
ro_repo = tmp_path / "ro_repo"
|
|
43
|
+
ro_repo.mkdir()
|
|
44
|
+
(ro_repo / "main.py").write_text("def f():\n return 1\n")
|
|
45
|
+
os.chmod(str(ro_repo), 0o555) # read-only
|
|
46
|
+
|
|
47
|
+
path = _resolve_cache_path(str(ro_repo))
|
|
48
|
+
assert ".cache" in path or "xdg" in path
|
|
49
|
+
assert path != os.path.join(str(ro_repo), ".diffcontext_cache.db")
|
|
50
|
+
|
|
51
|
+
os.chmod(str(ro_repo), 0o755) # cleanup
|
|
52
|
+
|
|
53
|
+
def test_memory_fallback_when_all_disk_fails(self, monkeypatch):
|
|
54
|
+
"""When repo is read-only AND XDG cache is not writable, fall to :memory:."""
|
|
55
|
+
monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
|
|
56
|
+
monkeypatch.setenv("XDG_CACHE_HOME", "/nonexistent/path/that/does/not/exist")
|
|
57
|
+
|
|
58
|
+
path = _resolve_cache_path("/also/nonexistent")
|
|
59
|
+
assert path == ":memory:", f"expected :memory:, got {path}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestReadOnlyRepoIndexing:
|
|
63
|
+
def test_readonly_repo_still_indexes(self, tmp_path):
|
|
64
|
+
"""The Glama sandbox / CI checkout use case: repo is read-only."""
|
|
65
|
+
# Copy the fixture to a temp dir, make it read-only
|
|
66
|
+
ro = tmp_path / "ro_repo"
|
|
67
|
+
shutil.copytree(SIMPLE, str(ro))
|
|
68
|
+
# Remove any existing cache
|
|
69
|
+
cache = ro / ".diffcontext_cache.db"
|
|
70
|
+
if cache.exists():
|
|
71
|
+
cache.unlink()
|
|
72
|
+
|
|
73
|
+
# Make the entire repo read-only
|
|
74
|
+
for root, dirs, files in os.walk(str(ro)):
|
|
75
|
+
os.chmod(root, 0o555)
|
|
76
|
+
for f in files:
|
|
77
|
+
os.chmod(os.path.join(root, f), 0o444)
|
|
78
|
+
|
|
79
|
+
# This must not raise — the cascade falls back to XDG or :memory:
|
|
80
|
+
idx = index_repository(str(ro))
|
|
81
|
+
assert len(idx.symbols) > 0, "indexing must succeed on read-only repo"
|
|
82
|
+
|
|
83
|
+
# Restore permissions for cleanup
|
|
84
|
+
for root, dirs, files in os.walk(str(ro)):
|
|
85
|
+
os.chmod(root, 0o755)
|
|
86
|
+
for f in files:
|
|
87
|
+
os.chmod(os.path.join(root, f), 0o644)
|
|
88
|
+
|
|
89
|
+
def test_env_var_override_honored(self, tmp_path, monkeypatch):
|
|
90
|
+
"""DIFFCONTEXT_CACHE_DIR forces the cache location."""
|
|
91
|
+
cache_dir = tmp_path / "custom_cache"
|
|
92
|
+
monkeypatch.setenv("DIFFCONTEXT_CACHE_DIR", str(cache_dir))
|
|
93
|
+
|
|
94
|
+
idx = index_repository(SIMPLE)
|
|
95
|
+
assert len(idx.symbols) > 0
|
|
96
|
+
assert cache_dir.exists(), "custom cache dir must be created"
|
|
97
|
+
assert (cache_dir / ".diffcontext_cache.db").exists(), "cache db must be in the custom dir"
|
|
98
|
+
|
|
99
|
+
def test_memory_cache_works_when_both_fail(self, tmp_path, monkeypatch):
|
|
100
|
+
"""When both repo and XDG are unwritable, :memory: still indexes."""
|
|
101
|
+
ro = tmp_path / "ro_repo"
|
|
102
|
+
shutil.copytree(SIMPLE, str(ro))
|
|
103
|
+
cache = ro / ".diffcontext_cache.db"
|
|
104
|
+
if cache.exists():
|
|
105
|
+
cache.unlink()
|
|
106
|
+
|
|
107
|
+
for root, dirs, files in os.walk(str(ro)):
|
|
108
|
+
os.chmod(root, 0o555)
|
|
109
|
+
for f in files:
|
|
110
|
+
os.chmod(os.path.join(root, f), 0o444)
|
|
111
|
+
|
|
112
|
+
monkeypatch.delenv("DIFFCONTEXT_CACHE_DIR", raising=False)
|
|
113
|
+
monkeypatch.setenv("XDG_CACHE_HOME", "/nonexistent")
|
|
114
|
+
|
|
115
|
+
idx = index_repository(str(ro))
|
|
116
|
+
assert len(idx.symbols) > 0, "indexing must succeed even with :memory: cache"
|
|
117
|
+
|
|
118
|
+
for root, dirs, files in os.walk(str(ro)):
|
|
119
|
+
os.chmod(root, 0o755)
|
|
120
|
+
for f in files:
|
|
121
|
+
os.chmod(os.path.join(root, f), 0o644)
|
|
@@ -308,55 +308,80 @@ class TestPipeline:
|
|
|
308
308
|
class TestQueryText:
|
|
309
309
|
"""The query_text signal: BM25 scores the bug report / issue text
|
|
310
310
|
against every symbol's source code, adding a relevance-to-the-problem
|
|
311
|
-
signal the graph alone can't provide.
|
|
311
|
+
signal the graph alone can't provide.
|
|
312
312
|
|
|
313
|
-
|
|
313
|
+
Two requirements the user asked for:
|
|
314
|
+
1. query_text=None reproduces current ranking byte-identically
|
|
315
|
+
(regression guard — no behavior change when the feature is off).
|
|
316
|
+
2. A non-None value measurably reorders results (the signal actually
|
|
317
|
+
changes the ranking, not just the scores).
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def test_none_reproduces_baseline_byte_identically(self):
|
|
321
|
+
"""The regression guard: query_text=None must not change ANY score,
|
|
322
|
+
not even by a floating-point epsilon. This is what keeps the feature
|
|
323
|
+
truly opt-in — existing users see zero behavior change."""
|
|
314
324
|
_require_fixture(MEDIUM, "medium_repo")
|
|
315
325
|
idx = index_repository(MEDIUM)
|
|
316
|
-
|
|
317
|
-
assert sym_id in idx.graph
|
|
318
|
-
|
|
319
|
-
# Without query_text: baseline scores
|
|
320
|
-
impact_base = analyze_impact(idx, [sym_id])
|
|
321
|
-
base_sids = set(impact_base.scores)
|
|
326
|
+
seed = "./service.py:onboard_user"
|
|
322
327
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
impact_q = analyze_impact(
|
|
327
|
-
idx, [sym_id], query_text="create user order onboard", query_weight=0.3,
|
|
328
|
+
baseline = analyze_impact(idx, [seed])
|
|
329
|
+
with_query_kwarg = analyze_impact(
|
|
330
|
+
idx, [seed], query_text=None, query_weight=0.0,
|
|
328
331
|
)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
"query_text must not remove baseline candidates, only add them"
|
|
332
|
+
# Every score must be identical — not just the set of candidates,
|
|
333
|
+
# but the actual float values. query_text=None is a true no-op.
|
|
334
|
+
assert baseline.scores == with_query_kwarg.scores, (
|
|
335
|
+
"query_text=None must reproduce baseline scores exactly; "
|
|
336
|
+
"the feature must be a true no-op when disabled."
|
|
335
337
|
)
|
|
336
338
|
|
|
337
|
-
def
|
|
339
|
+
def test_query_text_reorders_results(self):
|
|
340
|
+
"""A non-None query_text must measurably change the ranking — not
|
|
341
|
+
just add candidates, but move at least one existing symbol to a
|
|
342
|
+
different rank position. This proves the signal is doing work."""
|
|
338
343
|
_require_fixture(MEDIUM, "medium_repo")
|
|
339
344
|
idx = index_repository(MEDIUM)
|
|
340
|
-
|
|
345
|
+
seed = "./service.py:onboard_user"
|
|
341
346
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
347
|
+
baseline = analyze_impact(idx, [seed])
|
|
348
|
+
base_ranked = sorted(baseline.scores.items(), key=lambda x: -x[1])
|
|
349
|
+
base_order = [sid for sid, _ in base_ranked]
|
|
350
|
+
|
|
351
|
+
# "email validation check" should boost is_valid_email (whose code
|
|
352
|
+
# contains "email" and "valid") above User.__init__ (which doesn't).
|
|
353
|
+
impacted = analyze_impact(
|
|
354
|
+
idx, [seed], query_text="email validation check", query_weight=0.3,
|
|
355
|
+
)
|
|
356
|
+
q_ranked = sorted(impacted.scores.items(), key=lambda x: -x[1])
|
|
357
|
+
q_order = [sid for sid, _ in q_ranked]
|
|
358
|
+
|
|
359
|
+
assert base_order != q_order, (
|
|
360
|
+
"query_text must reorder at least one symbol; if the ranking "
|
|
361
|
+
"is identical the signal has no effect."
|
|
362
|
+
)
|
|
363
|
+
# Specifically: is_valid_email should rank higher with the query
|
|
364
|
+
# "email validation check" than without it.
|
|
365
|
+
base_email_rank = base_order.index("./validators.py:is_valid_email")
|
|
366
|
+
q_email_rank = q_order.index("./validators.py:is_valid_email")
|
|
367
|
+
assert q_email_rank < base_email_rank, (
|
|
368
|
+
f"is_valid_email should rank HIGHER with query_text='email "
|
|
369
|
+
f"validation check' (baseline rank {base_email_rank} -> query "
|
|
370
|
+
f"rank {q_email_rank}), not lower or unchanged."
|
|
348
371
|
)
|
|
349
372
|
|
|
350
|
-
def
|
|
373
|
+
def test_query_weight_zero_is_noop(self):
|
|
374
|
+
"""query_weight=0.0 must be a no-op even with non-None query_text."""
|
|
351
375
|
_require_fixture(MEDIUM, "medium_repo")
|
|
352
376
|
idx = index_repository(MEDIUM)
|
|
353
|
-
|
|
377
|
+
seed = "./service.py:onboard_user"
|
|
354
378
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
379
|
+
baseline = analyze_impact(idx, [seed])
|
|
380
|
+
with_weight_zero = analyze_impact(
|
|
381
|
+
idx, [seed], query_text="email validation", query_weight=0.0,
|
|
382
|
+
)
|
|
383
|
+
assert baseline.scores == with_weight_zero.scores, (
|
|
384
|
+
"query_weight=0.0 must be a no-op even with non-None query_text."
|
|
360
385
|
)
|
|
361
386
|
|
|
362
387
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|