sourcecode 2.3.0__py3-none-any.whl → 2.5.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.
@@ -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()