sourcecode 2.3.0__py3-none-any.whl → 2.4.0__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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.3.0"
7
+ __version__ = "2.4.0"
sourcecode/cli.py CHANGED
@@ -5341,6 +5341,7 @@ def explain_cmd(
5341
5341
  from sourcecode.context_graph import ContextGraph
5342
5342
  from sourcecode.spring_model import SpringSemanticModel
5343
5343
  from sourcecode.explain import explain_class
5344
+ from sourcecode import context_cache as _ctxcache
5344
5345
 
5345
5346
  if not class_name.strip():
5346
5347
  _emit_error_json(
@@ -5383,14 +5384,27 @@ def explain_cmd(
5383
5384
  )
5384
5385
  raise typer.Exit(code=1)
5385
5386
 
5387
+ # AI Context Cache — operates at the *knowledge* level: it caches the
5388
+ # reusable Canonical IR (the expensive Java parse), keyed only by knowledge
5389
+ # state, not by command. explain then derives its answer cheaply from that
5390
+ # shared CIR. The same cached CIR is reused for free by any other command.
5391
+ # Provider-agnostic and best-effort: any fault degrades to a fresh build.
5386
5392
  _prog = Progress()
5387
5393
  _prog.start(f"explaining {class_name} ({len(file_list)} files)")
5394
+ _cc_look = None
5388
5395
  try:
5389
- cir = ContextGraph.build(file_list, target).cir
5396
+ try:
5397
+ cir, _cc_look = _ctxcache.get_or_build_cir(
5398
+ _resolve_repo_root(target), target, file_list
5399
+ )
5400
+ except Exception:
5401
+ cir = ContextGraph.build(file_list, target).cir # fallback: never break explain
5390
5402
  model = SpringSemanticModel.build(cir)
5391
5403
  explanation = explain_class(class_name, cir, model)
5392
5404
  finally:
5393
5405
  _prog.finish()
5406
+ if _cc_look is not None:
5407
+ typer.echo(_cc_look.render(), err=True)
5394
5408
 
5395
5409
  if format == "json":
5396
5410
  output = _json.dumps(explanation.to_dict(), indent=2, ensure_ascii=False)
@@ -7199,6 +7213,53 @@ def cache_freshness_cmd(
7199
7213
  typer.echo(f"RIS updated: {result.get('ris_last_updated_at') or 'never'}")
7200
7214
 
7201
7215
 
7216
+ @cache_app.command("context-stats")
7217
+ def cache_context_stats_cmd(
7218
+ path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
7219
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
7220
+ ) -> None:
7221
+ """Show AI Context Cache statistics (structured-knowledge reuse metrics).
7222
+
7223
+ This is the provider-agnostic context cache — distinct from the snapshot
7224
+ cache reported by `ask cache status`. It measures reuse of ASK's structured
7225
+ context (entities/evidence/observations/summaries), never prompts or LLM output.
7226
+ """
7227
+ from sourcecode import context_cache as _ctxcache
7228
+ target = _resolve_repo_root(Path(path))
7229
+ stats = _ctxcache.stats_for_repo(target)
7230
+ if json_output:
7231
+ import json as _j
7232
+ typer.echo(_j.dumps(stats, indent=2, ensure_ascii=False))
7233
+ else:
7234
+ typer.echo(f"Cache dir: {stats['cache_dir']}")
7235
+ typer.echo(f"Enabled: {stats['enabled']}")
7236
+ typer.echo(f"Contexts: {stats['contexts']}")
7237
+ typer.echo(f"Hits: {stats['hits']}")
7238
+ typer.echo(f"Misses: {stats['misses']}")
7239
+ typer.echo(f"Invalidations: {stats['invalidations']}")
7240
+ typer.echo(f"Stores: {stats['stores']}")
7241
+ typer.echo(f"Hit ratio: {stats['hit_ratio']}")
7242
+ typer.echo(f"Avg lookup: {stats['avg_lookup_ms']} ms")
7243
+ typer.echo(f"Avg build: {stats['avg_build_ms']} ms")
7244
+ typer.echo(f"Bytes stored: {stats['bytes_stored']}")
7245
+ typer.echo(f"KL version: {stats['kl_schema_version']} producer={stats['producer_version']}")
7246
+
7247
+
7248
+ @cache_app.command("context-clear")
7249
+ def cache_context_clear_cmd(
7250
+ path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
7251
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
7252
+ ) -> None:
7253
+ """Delete the AI Context Cache for a repository (structured context + stats)."""
7254
+ from sourcecode import context_cache as _ctxcache
7255
+ target = _resolve_repo_root(Path(path))
7256
+ if not yes and sys.stdin.isatty():
7257
+ import click as _click
7258
+ _click.confirm(f"Delete AI context cache for {target}?", abort=True, err=True)
7259
+ removed = _ctxcache.clear_for_repo(target)
7260
+ typer.echo(f"Removed {removed} context file(s).", err=True)
7261
+
7262
+
7202
7263
  # ── Entry point ───────────────────────────────────────────────────────────────
7203
7264
 
7204
7265
  def _stderr_is_interactive() -> bool:
@@ -0,0 +1,637 @@
1
+ """
2
+ AI Context Cache — structured-knowledge cache for ASK.
3
+
4
+ This is **not** a prompt cache. It never stores prompts, generated text, or LLM
5
+ responses. It stores the *structured context* that ASK's Knowledge Layer
6
+ produces — entities, evidence, observations, summaries and graph metrics — so
7
+ that expensive commands (``explain``, ``impact``, ``onboard``, ``prepare-context``,
8
+ ``archetype`` …) can reuse the same derived knowledge across runs.
9
+
10
+ The component is completely independent of any AI provider. The cached objects
11
+ are consumed identically by GPT, Claude, Gemini, a local model, or any future
12
+ tool. The LLM never participates in producing or reading this cache.
13
+
14
+ Design
15
+ ------
16
+ Context Request → Cache Key → Lookup → Hit / Miss → Store → Return Context
17
+
18
+ Cache key
19
+ ---------
20
+ The key depends **only on the state of the knowledge**, never on any prompt text::
21
+
22
+ repo_id + worktree_signature + KL_SCHEMA_VERSION + PRODUCER_VERSION
23
+ + command + target + relevant_options
24
+
25
+ ``worktree_signature`` is the committed HEAD SHA, folded with a hash of the
26
+ working-tree diff when the tree is dirty — so an uncommitted edit deterministically
27
+ invalidates the entry instead of serving stale context. If *any* component
28
+ changes, the key changes and the old context is no longer served.
29
+
30
+ Invalidation
31
+ ------------
32
+ Deterministic and state-based, never TTL-based for correctness:
33
+ * new commit → different ``worktree_signature`` → miss
34
+ * working-tree edit → different ``worktree_signature`` → miss
35
+ * Knowledge Layer bump → different ``KL_SCHEMA_VERSION`` → miss
36
+ * producer bump → different ``PRODUCER_VERSION`` → miss
37
+ * relevant option flip → different ``relevant_options`` → miss
38
+ Stored entries also carry their own ``kl_version`` / ``signature`` and are
39
+ re-validated on read as a defensive second check. TTL and a max-entry cap exist
40
+ only as *cleanup*, never as a consistency guarantee.
41
+
42
+ Persistence
43
+ -----------
44
+ <base>/context-cache/<repo_id>/
45
+ ctx-<key16>.json.gz ← one gzip'd CachedContext envelope per key
46
+ stats.json ← advisory counters (hits/misses/…)
47
+
48
+ Env vars
49
+ --------
50
+ SOURCECODE_CONTEXT_CACHE_DIR Override base dir (default: ~/.sourcecode)
51
+ SOURCECODE_CONTEXT_CACHE_DISABLE "1" to disable (always miss, never store)
52
+ SOURCECODE_CONTEXT_CACHE_TTL_DAYS Cleanup age (default 30; 0 = never)
53
+ SOURCECODE_CONTEXT_CACHE_MAX_ENTRIES Max entries per repo (default 256; 0 = unlimited)
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import gzip
58
+ import hashlib
59
+ import json
60
+ import os
61
+ import subprocess
62
+ import time
63
+ import uuid
64
+ from dataclasses import dataclass, field
65
+ from datetime import datetime, timezone
66
+ from pathlib import Path
67
+ from typing import Any, Optional
68
+
69
+ from sourcecode import __version__ as _PKG_VERSION
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Versioning — the stable half of the cache key
73
+ # ---------------------------------------------------------------------------
74
+
75
+ #: Bump when the *shape or meaning* of the Knowledge Layer / context objects
76
+ #: changes in a way that makes older cached context invalid. Independent of the
77
+ #: package version — a patch release must NOT bump this unless the context
78
+ #: format actually changed.
79
+ KL_SCHEMA_VERSION: str = "1"
80
+
81
+ #: Envelope schema version. Bump to invalidate *every* stored context file.
82
+ ENVELOPE_VERSION: str = "1"
83
+
84
+ #: Producer identity — the exact code that built the context. Package version
85
+ #: bumps naturally participate in the key so a new release never reads context
86
+ #: built by an older one.
87
+ PRODUCER_VERSION: str = _PKG_VERSION
88
+
89
+ _DEFAULT_TTL_DAYS: int = 30
90
+ _DEFAULT_MAX_ENTRIES: int = 256
91
+
92
+ _UNIT_SEP = "\x1f" # key-material field separator (never appears in inputs)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Cached object
97
+ # ---------------------------------------------------------------------------
98
+
99
+ @dataclass
100
+ class CachedContext:
101
+ """One unit of structured, inspectable context produced by ASK.
102
+
103
+ Deliberately *not* an opaque blob: every field is a plain JSON-serialisable
104
+ structure so the cache can be inspected, diffed and reasoned about. No
105
+ prompts, no generated text, no LLM output ever lands here.
106
+ """
107
+
108
+ metadata: dict[str, Any] = field(default_factory=dict)
109
+ entities: list[Any] = field(default_factory=list)
110
+ evidence: list[Any] = field(default_factory=list)
111
+ observations: list[Any] = field(default_factory=list)
112
+ summaries: dict[str, Any] = field(default_factory=dict)
113
+ graph_metrics: dict[str, Any] = field(default_factory=dict)
114
+ #: The reusable, command-agnostic knowledge artifact itself (e.g. the raw IR
115
+ #: dict from which a full CIR is rebuilt cheaply). This is the expensive thing
116
+ #: worth caching; the fields above are inspectable metadata over it.
117
+ payload: dict[str, Any] = field(default_factory=dict)
118
+ timestamps: dict[str, Any] = field(default_factory=dict)
119
+ producer_version: str = PRODUCER_VERSION
120
+
121
+ def to_dict(self) -> dict[str, Any]:
122
+ return {
123
+ "metadata": self.metadata,
124
+ "entities": self.entities,
125
+ "evidence": self.evidence,
126
+ "observations": self.observations,
127
+ "summaries": self.summaries,
128
+ "graph_metrics": self.graph_metrics,
129
+ "payload": self.payload,
130
+ "timestamps": self.timestamps,
131
+ "producer_version": self.producer_version,
132
+ }
133
+
134
+ @classmethod
135
+ def from_dict(cls, d: dict[str, Any]) -> "CachedContext":
136
+ return cls(
137
+ metadata=d.get("metadata", {}) or {},
138
+ entities=d.get("entities", []) or [],
139
+ evidence=d.get("evidence", []) or [],
140
+ observations=d.get("observations", []) or [],
141
+ summaries=d.get("summaries", {}) or {},
142
+ graph_metrics=d.get("graph_metrics", {}) or {},
143
+ payload=d.get("payload", {}) or {},
144
+ timestamps=d.get("timestamps", {}) or {},
145
+ producer_version=d.get("producer_version", "") or "",
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Location / identity helpers
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def _now_iso() -> str:
154
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
155
+
156
+
157
+ def _repo_id(repo_root: Path) -> str:
158
+ """Stable 16-char id from the canonical repo path (mirrors cache.repo_id)."""
159
+ return hashlib.sha256(str(repo_root.resolve()).encode()).hexdigest()[:16]
160
+
161
+
162
+ def _base_dir() -> Path:
163
+ env = os.environ.get("SOURCECODE_CONTEXT_CACHE_DIR", "")
164
+ return Path(env) if env else Path.home() / ".sourcecode"
165
+
166
+
167
+ def _cache_dir(repo_root: Path) -> Path:
168
+ return _base_dir() / "context-cache" / _repo_id(repo_root)
169
+
170
+
171
+ def _git_head(repo_root: Path) -> str:
172
+ try:
173
+ r = subprocess.run(
174
+ ["git", "-C", str(repo_root), "rev-parse", "HEAD"],
175
+ capture_output=True, text=True, timeout=3,
176
+ )
177
+ if r.returncode == 0:
178
+ return r.stdout.strip()
179
+ except Exception:
180
+ pass
181
+ return ""
182
+
183
+
184
+ def _worktree_signature(repo_root: Path) -> str:
185
+ """Deterministic fingerprint of the *exact* tree state.
186
+
187
+ committed HEAD, plus — only when the tree is dirty — a hash of the porcelain
188
+ status and the diff against HEAD. Two identical tree states yield the same
189
+ signature (correct reuse); any tracked edit changes it (correct invalidation).
190
+ Returns ``""`` when the path is not a git repo (caller disables caching).
191
+ """
192
+ head = _git_head(repo_root)
193
+ if not head:
194
+ return ""
195
+ try:
196
+ status = subprocess.run(
197
+ ["git", "-C", str(repo_root), "status", "--porcelain"],
198
+ capture_output=True, text=True, timeout=5,
199
+ )
200
+ porcelain = status.stdout if status.returncode == 0 else ""
201
+ except Exception:
202
+ porcelain = ""
203
+ if not porcelain.strip():
204
+ return head # clean tree — HEAD fully describes it
205
+ try:
206
+ diff = subprocess.run(
207
+ ["git", "-C", str(repo_root), "diff", "HEAD"],
208
+ capture_output=True, text=True, timeout=10,
209
+ )
210
+ diff_txt = diff.stdout if diff.returncode == 0 else ""
211
+ except Exception:
212
+ diff_txt = ""
213
+ dirty = hashlib.sha256((porcelain + "\x00" + diff_txt).encode("utf-8", "replace")).hexdigest()[:16]
214
+ return f"{head}+dirty:{dirty}"
215
+
216
+
217
+ def _options_fingerprint(options: Optional[dict[str, Any]]) -> str:
218
+ """Stable hash of the knowledge-relevant options (order-independent)."""
219
+ if not options:
220
+ return "-"
221
+ try:
222
+ canon = json.dumps(options, sort_keys=True, ensure_ascii=False, default=str)
223
+ except Exception:
224
+ canon = repr(sorted((str(k), str(v)) for k, v in options.items()))
225
+ return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:12]
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Cache
230
+ # ---------------------------------------------------------------------------
231
+
232
+ class ContextCache:
233
+ """Provider-agnostic structured-context cache for one repository.
234
+
235
+ Construct via :meth:`for_repo`. All operations are best-effort: a broken or
236
+ unwritable cache degrades to a permanent miss, never to an error that would
237
+ break the command. Knows nothing about any AI model.
238
+ """
239
+
240
+ def __init__(
241
+ self,
242
+ cache_dir: Path,
243
+ signature: str,
244
+ *,
245
+ enabled: bool = True,
246
+ ) -> None:
247
+ self._dir = cache_dir
248
+ self._signature = signature
249
+ # No signature (non-git tree) means we cannot invalidate deterministically,
250
+ # so caching is disabled rather than risk serving stale context.
251
+ self.enabled = bool(enabled and signature)
252
+
253
+ @classmethod
254
+ def for_repo(cls, repo_root: Path, *, enabled: Optional[bool] = None) -> "ContextCache":
255
+ if enabled is None:
256
+ enabled = os.environ.get("SOURCECODE_CONTEXT_CACHE_DISABLE", "") not in ("1", "true", "yes")
257
+ root = repo_root.resolve()
258
+ return cls(_cache_dir(root), _worktree_signature(root), enabled=enabled)
259
+
260
+ # ── key ────────────────────────────────────────────────────────────────
261
+
262
+ def knowledge_key(
263
+ self,
264
+ scope: str,
265
+ *,
266
+ options: Optional[dict[str, Any]] = None,
267
+ ) -> str:
268
+ """Key for a **reusable, command-agnostic** knowledge artifact.
269
+
270
+ Depends only on the knowledge state and the artifact *scope* (e.g.
271
+ ``"java-cir"``) — deliberately NOT on any command or target, so every
272
+ command that needs the same knowledge shares one cache entry. This is
273
+ the level the cache operates at: cache the knowledge once, let commands
274
+ derive their answers from it for free.
275
+ """
276
+ return self.make_key(scope, options=options)
277
+
278
+ def make_key(
279
+ self,
280
+ scope: str,
281
+ *,
282
+ target: Optional[str] = None,
283
+ options: Optional[dict[str, Any]] = None,
284
+ ) -> str:
285
+ """Deterministic cache key. Depends only on knowledge state, never on prompt text.
286
+
287
+ ``scope`` names what is being cached. ``target`` is an optional finer
288
+ discriminator; for command-agnostic knowledge (the normal case) leave it
289
+ unset and prefer :meth:`knowledge_key`.
290
+ """
291
+ material = _UNIT_SEP.join([
292
+ self._signature,
293
+ KL_SCHEMA_VERSION,
294
+ PRODUCER_VERSION,
295
+ ENVELOPE_VERSION,
296
+ scope,
297
+ target or "",
298
+ _options_fingerprint(options),
299
+ ])
300
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
301
+
302
+ def _path(self, key: str) -> Path:
303
+ return self._dir / f"ctx-{key}.json.gz"
304
+
305
+ # ── read ─────────────────────────────────────────────────────────────
306
+
307
+ def get(self, key: str) -> Optional[CachedContext]:
308
+ """Return the cached context for *key*, or ``None`` on miss.
309
+
310
+ Records hit/miss and lookup latency. A stored entry whose embedded
311
+ signature/version no longer matches the current knowledge state is
312
+ treated as an invalidation (removed, counted, reported as a miss).
313
+ """
314
+ if not self.enabled:
315
+ return None
316
+ start = time.perf_counter()
317
+ path = self._path(key)
318
+ try:
319
+ if not path.exists():
320
+ self._record(miss=True, lookup_ms=(time.perf_counter() - start) * 1000)
321
+ return None
322
+ envelope = json.loads(gzip.decompress(path.read_bytes()).decode("utf-8"))
323
+ except Exception:
324
+ # Corrupted file — evict and count as a miss.
325
+ _safe_unlink(path)
326
+ self._record(miss=True, lookup_ms=(time.perf_counter() - start) * 1000)
327
+ return None
328
+
329
+ lookup_ms = (time.perf_counter() - start) * 1000
330
+ if not isinstance(envelope, dict) or envelope.get("ev") != ENVELOPE_VERSION:
331
+ _safe_unlink(path)
332
+ self._record(invalidation=True, lookup_ms=lookup_ms)
333
+ return None
334
+ # Defensive re-validation against live knowledge state.
335
+ if envelope.get("sig") != self._signature or envelope.get("kl") != KL_SCHEMA_VERSION:
336
+ _safe_unlink(path)
337
+ self._record(invalidation=True, lookup_ms=lookup_ms)
338
+ return None
339
+
340
+ data = envelope.get("context")
341
+ if not isinstance(data, dict):
342
+ _safe_unlink(path)
343
+ self._record(invalidation=True, lookup_ms=lookup_ms)
344
+ return None
345
+
346
+ self._record(hit=True, lookup_ms=lookup_ms)
347
+ return CachedContext.from_dict(data)
348
+
349
+ # ── write ─────────────────────────────────────────────────────────────
350
+
351
+ def put(
352
+ self,
353
+ key: str,
354
+ context: CachedContext,
355
+ *,
356
+ build_time_ms: Optional[float] = None,
357
+ ) -> None:
358
+ """Store *context* under *key*. Best-effort; failures are swallowed."""
359
+ if not self.enabled:
360
+ return
361
+ ts = dict(context.timestamps)
362
+ ts.setdefault("created_at", _now_iso())
363
+ if build_time_ms is not None:
364
+ ts["build_time_ms"] = round(build_time_ms, 3)
365
+ context.timestamps = ts
366
+
367
+ envelope = {
368
+ "ev": ENVELOPE_VERSION,
369
+ "kl": KL_SCHEMA_VERSION,
370
+ "sig": self._signature,
371
+ "key": key,
372
+ "ts": _now_iso(),
373
+ "context": context.to_dict(),
374
+ }
375
+ try:
376
+ self._dir.mkdir(parents=True, exist_ok=True)
377
+ payload = gzip.compress(json.dumps(envelope, ensure_ascii=False).encode("utf-8"), 6)
378
+ _atomic_write(self._path(key), payload)
379
+ except Exception:
380
+ return
381
+ self._record(store=True, build_ms=build_time_ms)
382
+ self._gc()
383
+
384
+ # ── stats ─────────────────────────────────────────────────────────────
385
+
386
+ def stats(self) -> dict[str, Any]:
387
+ """Return advisory metrics for this repo's context cache."""
388
+ s = _read_stats(self._dir)
389
+ files = list(self._dir.glob("ctx-*.json.gz")) if self._dir.exists() else []
390
+ bytes_stored = sum(f.stat().st_size for f in files if f.exists())
391
+ hits = s.get("hits", 0)
392
+ misses = s.get("misses", 0)
393
+ invalidations = s.get("invalidations", 0)
394
+ lookups = hits + misses + invalidations
395
+ lookup_total = s.get("lookup_time_total_ms", 0.0)
396
+ build_count = s.get("build_count", 0)
397
+ build_total = s.get("build_time_total_ms", 0.0)
398
+ return {
399
+ "cache_dir": str(self._dir),
400
+ "enabled": self.enabled,
401
+ "contexts": len(files),
402
+ "hits": hits,
403
+ "misses": misses,
404
+ "invalidations": invalidations,
405
+ "stores": s.get("stores", 0),
406
+ "hit_ratio": round(hits / lookups, 4) if lookups else 0.0,
407
+ "avg_lookup_ms": round(lookup_total / lookups, 4) if lookups else 0.0,
408
+ "avg_build_ms": round(build_total / build_count, 4) if build_count else 0.0,
409
+ "bytes_stored": bytes_stored,
410
+ "kl_schema_version": KL_SCHEMA_VERSION,
411
+ "producer_version": PRODUCER_VERSION,
412
+ }
413
+
414
+ def clear(self) -> int:
415
+ """Delete all context files and reset stats. Returns files removed."""
416
+ removed = 0
417
+ if self._dir.exists():
418
+ for f in self._dir.glob("ctx-*.json.gz"):
419
+ _safe_unlink(f)
420
+ removed += 1
421
+ _safe_unlink(self._dir / "stats.json")
422
+ return removed
423
+
424
+ # ── internals ─────────────────────────────────────────────────────────
425
+
426
+ def _record(
427
+ self,
428
+ *,
429
+ hit: bool = False,
430
+ miss: bool = False,
431
+ invalidation: bool = False,
432
+ store: bool = False,
433
+ lookup_ms: Optional[float] = None,
434
+ build_ms: Optional[float] = None,
435
+ ) -> None:
436
+ try:
437
+ s = _read_stats(self._dir)
438
+ if hit:
439
+ s["hits"] = s.get("hits", 0) + 1
440
+ if miss:
441
+ s["misses"] = s.get("misses", 0) + 1
442
+ if invalidation:
443
+ s["invalidations"] = s.get("invalidations", 0) + 1
444
+ if store:
445
+ s["stores"] = s.get("stores", 0) + 1
446
+ if lookup_ms is not None:
447
+ s["lookup_time_total_ms"] = s.get("lookup_time_total_ms", 0.0) + lookup_ms
448
+ if build_ms is not None:
449
+ s["build_count"] = s.get("build_count", 0) + 1
450
+ s["build_time_total_ms"] = s.get("build_time_total_ms", 0.0) + build_ms
451
+ _write_stats(self._dir, s)
452
+ except Exception:
453
+ pass # stats are advisory — never fatal
454
+
455
+ def _gc(self) -> None:
456
+ """Cleanup pass: drop entries past TTL, then cap total entry count.
457
+
458
+ Cleanup only — correctness never depends on it (stale-key entries are
459
+ already unreachable because the key embeds the knowledge signature).
460
+ """
461
+ try:
462
+ ttl_days = int(os.environ.get("SOURCECODE_CONTEXT_CACHE_TTL_DAYS", _DEFAULT_TTL_DAYS))
463
+ max_entries = int(os.environ.get("SOURCECODE_CONTEXT_CACHE_MAX_ENTRIES", _DEFAULT_MAX_ENTRIES))
464
+ files = list(self._dir.glob("ctx-*.json.gz"))
465
+
466
+ if ttl_days > 0:
467
+ cutoff = time.time() - ttl_days * 86400
468
+ for f in list(files):
469
+ try:
470
+ if f.stat().st_mtime < cutoff:
471
+ _safe_unlink(f)
472
+ files.remove(f)
473
+ except Exception:
474
+ pass
475
+
476
+ if max_entries > 0 and len(files) > max_entries:
477
+ files.sort(key=lambda p: p.stat().st_mtime, reverse=True) # newest first
478
+ for f in files[max_entries:]:
479
+ _safe_unlink(f)
480
+ except Exception:
481
+ pass
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Stats file (advisory, plain JSON)
486
+ # ---------------------------------------------------------------------------
487
+
488
+ def _stats_path(cache_dir: Path) -> Path:
489
+ return cache_dir / "stats.json"
490
+
491
+
492
+ def _read_stats(cache_dir: Path) -> dict[str, Any]:
493
+ p = _stats_path(cache_dir)
494
+ if not p.exists():
495
+ return {}
496
+ try:
497
+ d = json.loads(p.read_text(encoding="utf-8"))
498
+ return d if isinstance(d, dict) else {}
499
+ except Exception:
500
+ _safe_unlink(p) # corrupted — reset
501
+ return {}
502
+
503
+
504
+ def _write_stats(cache_dir: Path, stats: dict[str, Any]) -> None:
505
+ cache_dir.mkdir(parents=True, exist_ok=True)
506
+ _atomic_write(_stats_path(cache_dir), json.dumps(stats, ensure_ascii=False).encode("utf-8"))
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # I/O utilities
511
+ # ---------------------------------------------------------------------------
512
+
513
+ def _atomic_write(dest: Path, data: bytes) -> None:
514
+ """Write *data* to *dest* atomically via a unique temp file + os.replace."""
515
+ tmp = dest.parent / f"{dest.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
516
+ try:
517
+ tmp.write_bytes(data)
518
+ os.replace(tmp, dest)
519
+ finally:
520
+ _safe_unlink(tmp)
521
+
522
+
523
+ def _safe_unlink(path: Path) -> None:
524
+ try:
525
+ path.unlink(missing_ok=True)
526
+ except Exception:
527
+ pass
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Knowledge-level API — the reusable-context surface commands build on
532
+ # ---------------------------------------------------------------------------
533
+
534
+ #: Scope name for the Java/Spring Canonical IR — the expensive parse artifact
535
+ #: shared by explain / impact / onboard / review-pr / modernize / archetype …
536
+ SCOPE_JAVA_CIR: str = "java-cir"
537
+
538
+
539
+ @dataclass
540
+ class KnowledgeLookup:
541
+ """Outcome of a knowledge fetch, for command-side observability."""
542
+ hit: bool
543
+ enabled: bool
544
+ lookup_ms: float = 0.0
545
+ build_ms: float = 0.0
546
+
547
+ def render(self) -> str:
548
+ if not self.enabled:
549
+ return "Context cache: disabled"
550
+ if self.hit:
551
+ return f"Context cache: HIT (lookup {self.lookup_ms:.1f}ms)"
552
+ return f"Context cache: MISS (build {self.build_ms:.0f}ms)"
553
+
554
+
555
+ def get_or_build_cir(
556
+ repo_root: Path,
557
+ root: Path,
558
+ file_list: list[str],
559
+ *,
560
+ since: Optional[str] = None,
561
+ ) -> tuple[Any, KnowledgeLookup]:
562
+ """Return ``(CanonicalRepositoryIR, KnowledgeLookup)``, caching the CIR.
563
+
564
+ This is the knowledge-level entry point. It caches the **reusable** raw IR
565
+ (the expensive Java parse) keyed only by knowledge state — not by command —
566
+ so every command that needs a CIR shares one cache entry. On a hit the full
567
+ CIR is rebuilt cheaply from the cached raw IR via ``ir_dict_to_canonical``,
568
+ skipping the parse entirely. Best-effort: any cache fault falls back to a
569
+ fresh build.
570
+ """
571
+ from sourcecode.canonical_ir import (
572
+ build_canonical_ir,
573
+ ir_dict_to_canonical,
574
+ validate_canonical_ir,
575
+ )
576
+
577
+ cache = ContextCache.for_repo(repo_root)
578
+ if not cache.enabled:
579
+ cir = build_canonical_ir(file_list, root, since=since)
580
+ return cir, KnowledgeLookup(hit=False, enabled=False)
581
+
582
+ key = cache.knowledge_key(SCOPE_JAVA_CIR, options={"since": since} if since else None)
583
+
584
+ t0 = time.perf_counter()
585
+ cached = cache.get(key)
586
+ lookup_ms = (time.perf_counter() - t0) * 1000
587
+ if cached is not None:
588
+ raw = cached.payload.get("raw_ir")
589
+ if isinstance(raw, dict):
590
+ try:
591
+ cir = ir_dict_to_canonical(raw, file_paths=file_list)
592
+ if not validate_canonical_ir(cir):
593
+ return cir, KnowledgeLookup(hit=True, enabled=True, lookup_ms=lookup_ms)
594
+ except Exception:
595
+ pass # corrupt/incompatible payload — fall through to rebuild
596
+
597
+ t1 = time.perf_counter()
598
+ cir = build_canonical_ir(file_list, root, since=since)
599
+ build_ms = (time.perf_counter() - t1) * 1000
600
+ try:
601
+ ctx = CachedContext(
602
+ metadata={
603
+ "scope": SCOPE_JAVA_CIR,
604
+ "cir_hash": cir.cir_hash,
605
+ "schema_version": cir.schema_version,
606
+ "file_count": len(cir.files),
607
+ "symbol_count": len(cir.symbols),
608
+ },
609
+ entities=list(cir.symbols[:200]), # inspectable sample, not exhaustive
610
+ graph_metrics={
611
+ "symbol_count": len(cir.symbols),
612
+ "edge_count": len(cir.call_graph),
613
+ "endpoint_count": len(cir.endpoints),
614
+ },
615
+ payload={"raw_ir": cir._raw_ir},
616
+ )
617
+ cache.put(key, ctx, build_time_ms=build_ms)
618
+ except Exception:
619
+ pass
620
+ return cir, KnowledgeLookup(hit=False, enabled=True, build_ms=build_ms)
621
+
622
+
623
+ # ---------------------------------------------------------------------------
624
+ # Module-level observability helper
625
+ # ---------------------------------------------------------------------------
626
+
627
+ def stats_for_repo(repo_root: Path) -> dict[str, Any]:
628
+ """Convenience: stats for a repo without a live signature (read-only view)."""
629
+ root = repo_root.resolve()
630
+ cache = ContextCache(_cache_dir(root), _worktree_signature(root), enabled=True)
631
+ return cache.stats()
632
+
633
+
634
+ def clear_for_repo(repo_root: Path) -> int:
635
+ root = repo_root.resolve()
636
+ cache = ContextCache(_cache_dir(root), "read-only", enabled=True)
637
+ return cache.clear()
sourcecode/explain.py CHANGED
@@ -133,6 +133,30 @@ class ClassExplanation:
133
133
  "found": self.found,
134
134
  }
135
135
 
136
+ @classmethod
137
+ def from_dict(cls, d: dict) -> "ClassExplanation":
138
+ """Reconstruct from :meth:`to_dict` output (lossless round-trip).
139
+
140
+ The informational ``incoming_callers_note`` key is derived, not stored
141
+ on the instance, so it is dropped on the way back in.
142
+ """
143
+ return cls(
144
+ class_name=d.get("class_name", ""),
145
+ class_fqn=d.get("class_fqn", ""),
146
+ stereotype=d.get("stereotype", ""),
147
+ purpose=d.get("purpose", ""),
148
+ public_methods=list(d.get("public_methods", [])),
149
+ incoming_callers=list(d.get("incoming_callers", [])),
150
+ outgoing_deps=list(d.get("outgoing_deps", [])),
151
+ events_published=list(d.get("events_published", [])),
152
+ events_consumed=list(d.get("events_consumed", [])),
153
+ transactions=list(d.get("transactions", [])),
154
+ security_constraints=list(d.get("security_constraints", [])),
155
+ rest_endpoints=list(d.get("rest_endpoints", [])),
156
+ warnings=list(d.get("warnings", [])),
157
+ found=bool(d.get("found", True)),
158
+ )
159
+
136
160
 
137
161
  # ---------------------------------------------------------------------------
138
162
  # FQN resolution
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=_9_i7ziVcM1wLhG9oULnIMnZhX9H1T1qmnmsiNwXBmg,308
1
+ sourcecode/__init__.py,sha256=1iN-frOfJr05mYoED0IYnoxSY-4xnA-sHP9MRe_9Qn4,308
2
2
  sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
3
3
  sourcecode/archetype.py,sha256=ezeyCR4E0VeZUUBPwmsWUUPAhIlqwwkc8qDieUx2JWA,31087
4
4
  sourcecode/architecture_analyzer.py,sha256=6_wC4TYuBYxaqckZS0I1vBSMRPeH2vzlDB48gXwOOd4,46627
@@ -10,9 +10,10 @@ sourcecode/caller_metrics.py,sha256=HG8RCOkpzzsEGdnMRob6byrwjoj5__t0TEsBj4yR7ks,
10
10
  sourcecode/canonical_ir.py,sha256=U69m8Vkr0p407xF1q5y1KguHXWEAubYw8NFHR9hDNJk,29178
11
11
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
12
12
  sourcecode/classifier.py,sha256=RhLMDR031FAiUcT3bsf2EL3KrFwl7KsWh_wXXR_Bumo,18729
13
- sourcecode/cli.py,sha256=rK4umQmgajbQwuwmhhm6q898X-xaniHuRpXbUnW1WUk,296904
13
+ sourcecode/cli.py,sha256=xYrKelvgspWuus8LM4DcRroXGydbU47CAr-KV-8oysU,299977
14
14
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
15
15
  sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
16
+ sourcecode/context_cache.py,sha256=maws79rLKDwyZ7c9Q2LwlIxqZHRFlaKzErtO3mglWoA,25036
16
17
  sourcecode/context_graph.py,sha256=8MVDu06bPhvRDTgUqWEvRME2fOw34bFLf4OUILZd13I,43527
17
18
  sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
18
19
  sourcecode/context_summarizer.py,sha256=cI2TZMvEhl0BEma12VtPaX6z03ZVBAetVzuK5GaCOvg,6852
@@ -28,7 +29,7 @@ sourcecode/entrypoint_classifier.py,sha256=jhTYlyqDJH2AtdEcLVaRU3lYRTJuF8DkxVzl4
28
29
  sourcecode/env_analyzer.py,sha256=aNTyYgQk5noJDfJU6FmasmESOHfiomyJw5EvZqjy6qc,22213
29
30
  sourcecode/error_schema.py,sha256=uwosfNaSujtYm11_732Hu92z5ITV040fQDaIyefSvR4,1683
30
31
  sourcecode/evidence_provider.py,sha256=GSSL44JEaouO5AHks2sB3d1YvC9xIKIld1yBYxZpXxo,4277
31
- sourcecode/explain.py,sha256=bo4sy8qkEstIF3sIefECKRYJBK97C6cMjyzRa8USCdI,21189
32
+ sourcecode/explain.py,sha256=HnHWVTNNf9fzeR3FP-A-eXKeKZvBcUEuODgIO3rJQkQ,22312
32
33
  sourcecode/file_chunker.py,sha256=3vkM3mDQ5eE_yTPvUgjyjpGFBIjkW6_mrBmIbrylnA8,16444
33
34
  sourcecode/file_classifier.py,sha256=A0fEABqtfVu1MfoaxnPAvGpZgneGgVXlJDhT74NYXxE,15314
34
35
  sourcecode/format_contract.py,sha256=1cTNqwP8geA2hbQoBHUPgX3_vSh3l8guJT_jmgEnFF8,3466
@@ -117,8 +118,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
117
118
  sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
118
119
  sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
119
120
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
120
- sourcecode-2.3.0.dist-info/METADATA,sha256=sOmWl4PHteHOjBgUxeKpVh6H6Hc2Yo-Zyrk3c62z1CE,10837
121
- sourcecode-2.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
122
- sourcecode-2.3.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
123
- sourcecode-2.3.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
124
- sourcecode-2.3.0.dist-info/RECORD,,
121
+ sourcecode-2.4.0.dist-info/METADATA,sha256=fairNRCqgRAaSicMGWcWQcPral9_3NeVBIDTrv2rHaI,10837
122
+ sourcecode-2.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
123
+ sourcecode-2.4.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
124
+ sourcecode-2.4.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
125
+ sourcecode-2.4.0.dist-info/RECORD,,