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.
Files changed (73) hide show
  1. {diffcontext-0.5.3 → diffcontext-0.5.5}/PKG-INFO +1 -1
  2. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/__init__.py +1 -1
  3. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/cache.py +90 -16
  4. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/cli/__init__.py +22 -2
  5. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/mcp_server.py +4 -2
  6. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/parser.py +2 -2
  7. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/pipeline.py +11 -3
  8. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/PKG-INFO +1 -1
  9. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/SOURCES.txt +1 -0
  10. diffcontext-0.5.5/tests/test_cache_fallback.py +121 -0
  11. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_core.py +59 -34
  12. {diffcontext-0.5.3 → diffcontext-0.5.5}/LICENSE +0 -0
  13. {diffcontext-0.5.3 → diffcontext-0.5.5}/README.md +0 -0
  14. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/_warn_once.py +0 -0
  15. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/__init__.py +0 -0
  16. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/compiler.py +0 -0
  17. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/context/selector.py +0 -0
  18. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/__init__.py +0 -0
  19. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/git_diff.py +0 -0
  20. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/diff/state_manager.py +0 -0
  21. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/graph_builder.py +0 -0
  22. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/history.py +0 -0
  23. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/__init__.py +0 -0
  24. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/blast_radius.py +0 -0
  25. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/scoring.py +0 -0
  26. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/traversal.py +0 -0
  27. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/impact/visualizer.py +0 -0
  28. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/languages/__init__.py +0 -0
  29. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/languages/typescript.py +0 -0
  30. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/lexical.py +0 -0
  31. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/models.py +0 -0
  32. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/py.typed +0 -0
  33. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/__init__.py +0 -0
  34. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/features.py +0 -0
  35. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/rerank/model.py +0 -0
  36. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/resolver.py +0 -0
  37. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/scanner.py +0 -0
  38. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/symbols.py +0 -0
  39. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/__init__.py +0 -0
  40. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/cases.py +0 -0
  41. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/history.py +0 -0
  42. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext/verify/sufficiency.py +0 -0
  43. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/dependency_links.txt +0 -0
  44. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/entry_points.txt +0 -0
  45. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/requires.txt +0 -0
  46. {diffcontext-0.5.3 → diffcontext-0.5.5}/diffcontext.egg-info/top_level.txt +0 -0
  47. {diffcontext-0.5.3 → diffcontext-0.5.5}/pyproject.toml +0 -0
  48. {diffcontext-0.5.3 → diffcontext-0.5.5}/setup.cfg +0 -0
  49. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_cache.py +0 -0
  50. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_compiler_meta.py +0 -0
  51. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_cutoff.py +0 -0
  52. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_determinism.py +0 -0
  53. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_downstream_eval.py +0 -0
  54. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_git_diff.py +0 -0
  55. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_harness_api.py +0 -0
  56. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_history.py +0 -0
  57. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_incremental.py +0 -0
  58. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_index_scoping.py +0 -0
  59. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_lexical.py +0 -0
  60. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_mcp_server.py +0 -0
  61. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_rerank.py +0 -0
  62. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_ablation.py +0 -0
  63. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_audit.py +0 -0
  64. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_gap.py +0 -0
  65. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_metrics.py +0 -0
  66. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_semantic_mine.py +0 -0
  67. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_service_clone.py +0 -0
  68. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_src_layout.py +0 -0
  69. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_stats.py +0 -0
  70. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_token_budget.py +0 -0
  71. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_typescript_adapter.py +0 -0
  72. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_verify.py +0 -0
  73. {diffcontext-0.5.3 → diffcontext-0.5.5}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffcontext
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Static-analysis-powered repository context compiler for LLMs
5
5
  Author-email: Trakshan Mishra <trakshanmishra477@gmail.com>
6
6
  License: MIT
@@ -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.3"
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 hash of a file."""
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
- 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).
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 = ".diffcontext_cache.db"):
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(args.repo, include=set(args.include or []))
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
- # query_weight > 0 only when task_description is provided the
135
- # signal is opt-in and has no effect when absent (backwards compat).
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 = os.path.join(repo_path, ".diffcontext_cache.db")
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 = os.path.join(repo_path, ".diffcontext_cache.db")
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
- db_path = os.path.join(repo_path, ".diffcontext_cache.db")
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffcontext
3
- Version: 0.5.3
3
+ Version: 0.5.5
4
4
  Summary: Static-analysis-powered repository context compiler for LLMs
5
5
  Author-email: Trakshan Mishra <trakshanmishra477@gmail.com>
6
6
  License: MIT
@@ -43,6 +43,7 @@ diffcontext/verify/cases.py
43
43
  diffcontext/verify/history.py
44
44
  diffcontext/verify/sufficiency.py
45
45
  tests/test_cache.py
46
+ tests/test_cache_fallback.py
46
47
  tests/test_compiler_meta.py
47
48
  tests/test_core.py
48
49
  tests/test_cutoff.py
@@ -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
- def test_query_text_adds_candidates(self):
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
- sym_id = "./service.py:onboard_user"
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
- # With query_text: should surface additional candidates whose code
324
- # matches the described problem but may have no call-graph connection.
325
- # Use a query that matches symbols in the medium repo.
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
- query_sids = set(impact_q.scores)
330
-
331
- # Query-text candidates can add symbols the graph didn't reach.
332
- # At minimum, the query should not REMOVE any baseline candidates.
333
- assert base_sids.issubset(query_sids), (
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 test_query_weight_zero_is_noop(self):
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
- sym_id = "./service.py:onboard_user"
345
+ seed = "./service.py:onboard_user"
341
346
 
342
- # query_weight=0.0 = no effect (backwards compatible)
343
- impact_off = analyze_impact(idx, [sym_id], query_text="anything", query_weight=0.0)
344
- impact_none = analyze_impact(idx, [sym_id])
345
- # Same scores (query_text with weight 0 is a no-op)
346
- assert impact_off.scores == impact_none.scores, (
347
- "query_weight=0.0 must be a no-op"
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 test_no_query_text_is_noop(self):
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
- sym_id = "./service.py:onboard_user"
377
+ seed = "./service.py:onboard_user"
354
378
 
355
- # No query_text = no effect even if query_weight > 0
356
- impact_off = analyze_impact(idx, [sym_id], query_weight=0.3)
357
- impact_none = analyze_impact(idx, [sym_id])
358
- assert impact_off.scores == impact_none.scores, (
359
- "no query_text with query_weight > 0 must be a no-op"
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