Skip to content

Commit 06c403b

Browse files
authored
Merge pull request #15 from closedloop-ai/campaign-prd-739-20260915-c1
feat(code-intel): no completeness claim without data (PLN-2027 PR 1)
2 parents 00e1b39 + 18773ad commit 06c403b

15 files changed

Lines changed: 833 additions & 30 deletions

File tree

src/lemoncrow/gateway/adapters/mcp_server.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,18 @@
162162
tool_web_fetch,
163163
)
164164
from lemoncrow.gateway.adapters.mcp_branding import icon_metadata
165-
from lemoncrow.infra.code_intel.completeness import CODE_OP_MATCH_KINDS, CODE_OP_OBJECTIVES, OBJECTIVE_RANKED
165+
from lemoncrow.infra.code_intel.completeness import (
166+
CODE_OP_MATCH_KINDS,
167+
CODE_OP_OBJECTIVES,
168+
DATA_UNAVAILABLE,
169+
OBJECTIVE_PARTIAL,
170+
OBJECTIVE_RANKED,
171+
)
166172
from lemoncrow.infra.code_intel.freshness import ( # noqa: F401 (IndexRebuilding re-exported for handlers/tests)
167173
FRESHNESS_REBUILT,
168174
IndexRebuilding,
169175
VersionedEngineCache,
176+
reset_readiness_probes,
170177
)
171178
from lemoncrow.infra.runtime.run_ledger import (
172179
RunLedger,
@@ -1131,6 +1138,7 @@ def _reset_runtime_cache_for_testing() -> None:
11311138
_COMPACT_ADVISE_CACHE.clear()
11321139
_last_blocked_plan_hash_by_session.clear()
11331140
_code_engine_cache.clear()
1141+
reset_readiness_probes()
11341142
_scoped_context_cache.clear()
11351143

11361144

@@ -8994,7 +9002,12 @@ def _maybe_attach_code_rendered(op: str, payload: dict[str, Any], *, render_comp
89949002
# top-N read as a complete set is how a confident wrong finding gets filed.
89959003
objective = CODE_OP_OBJECTIVES.get(op)
89969004
if objective is not None:
8997-
result.setdefault("objective", objective)
9005+
# Edge data that was never there to look up is not "no edges found":
9006+
# `empty` keeps the op's claim, `unavailable` cannot make it.
9007+
if result.get("data_status") == DATA_UNAVAILABLE:
9008+
result["objective"] = OBJECTIVE_PARTIAL
9009+
else:
9010+
result.setdefault("objective", objective)
89989011

89999012
# ...and say how the edges were matched. Exhaustive is necessary, not
90009013
# sufficient: both edge stores are name-keyed, so a complete enumeration of

src/lemoncrow/infra/code_intel/change_impact.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@
3232
from pathlib import Path
3333
from typing import Any
3434

35-
from lemoncrow.infra.code_intel.completeness import MATCH_NAME, MATCH_RESOLVED, OBJECTIVE_EXHAUSTIVE
35+
from lemoncrow.infra.code_intel.completeness import (
36+
DATA_AVAILABLE,
37+
DATA_UNAVAILABLE,
38+
MATCH_NAME,
39+
MATCH_RESOLVED,
40+
objective_for_data,
41+
)
42+
from lemoncrow.infra.code_intel.freshness import require_ready
3643
from lemoncrow.infra.code_intel.store import CodeIntelStore, SymbolRow
3744

3845
__all__ = [
@@ -190,13 +197,20 @@ class ChangeImpactReport:
190197
impacted_total: int
191198
truncated: bool
192199
unindexed_paths: tuple[str, ...]
200+
#: ``unavailable`` when the call graph the caller lookups read was never
201+
#: built: every ``callers`` count is then a zero nobody measured, and
202+
#: ``reason`` says what was missing.
203+
data_status: str = DATA_AVAILABLE
204+
reason: str | None = None
193205

194206
def to_dict(self) -> dict[str, Any]:
195-
return {
207+
payload: dict[str, Any] = {
196208
# Name-keyed matching over-reports and never misses, so the caller
197209
# list is a superset of the truth -- exhaustive in the sense that
198-
# matters for impact analysis.
199-
"objective": OBJECTIVE_EXHAUSTIVE,
210+
# matters for impact analysis. Only over a call graph that exists,
211+
# though: with no edges to reverse, every changed symbol reads as
212+
# having no callers, and that is missing data rather than an answer.
213+
"objective": objective_for_data(self.data_status == DATA_AVAILABLE),
200214
"repo_root": self.repo_root,
201215
"base_ref": self.base_ref,
202216
"diff_ref": self.diff_ref,
@@ -209,7 +223,11 @@ def to_dict(self) -> dict[str, Any]:
209223
"impacted_total": self.impacted_total,
210224
"truncated": self.truncated,
211225
"unindexed_paths": list(self.unindexed_paths),
226+
"data_status": self.data_status,
212227
}
228+
if self.reason is not None:
229+
payload["reason"] = self.reason
230+
return payload
213231

214232

215233
# --------------------------------------------------------------------------- #
@@ -465,14 +483,27 @@ def analyze_changes(
465483
A site that reaches two changed symbols is reported once per symbol. That is
466484
not double counting: "who calls what" has two answers there, and collapsing
467485
them would silently drop one.
486+
487+
Raises :class:`~lemoncrow.infra.code_intel.freshness.IndexRebuilding` while
488+
the index is mid-write and
489+
:class:`~lemoncrow.infra.code_intel.store.CodeIntelUnavailable` when it is
490+
absent. With no call graph to reverse, the report still maps the diff onto
491+
changed symbols but states ``data_status: "unavailable"`` and a ``partial``
492+
objective, because the zero callers it would otherwise report were never
493+
measured.
468494
"""
469495
root = Path(repo_root).resolve()
470496
depth = max(1, int(depth))
471497
limit = max(1, int(limit))
472498
diff_ref, changes = collect_changes(root, base_ref=base_ref, paths=paths)
499+
# After the diff, which reports a directory that is not a git worktree as the
500+
# caller's error to fix first; before the first store read, so a torn or
501+
# empty index raises rather than mapping the diff onto nothing.
502+
require_ready(root)
473503

474504
with CodeIntelStore(root) as store:
475505
index_version = store.engine_state("index_version")
506+
call_graph_gap = store.call_graph_gap()
476507
indexed_paths = {row.file_path for row in store.files()}
477508

478509
pending: list[tuple[SymbolRow, str]] = []
@@ -576,4 +607,6 @@ def analyze_changes(
576607
impacted_total=len(impacted),
577608
truncated=len(impacted) > limit,
578609
unindexed_paths=tuple(unindexed),
610+
data_status=DATA_AVAILABLE if call_graph_gap is None else DATA_UNAVAILABLE,
611+
reason=call_graph_gap,
579612
)

src/lemoncrow/infra/code_intel/completeness.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,15 @@
7272
__all__ = [
7373
"CODE_OP_MATCH_KINDS",
7474
"CODE_OP_OBJECTIVES",
75+
"DATA_AVAILABLE",
76+
"DATA_UNAVAILABLE",
7577
"MATCH_NAME",
7678
"MATCH_RESOLVED",
7779
"OBJECTIVE_EXHAUSTIVE",
7880
"OBJECTIVE_PARTIAL",
7981
"OBJECTIVE_RANKED",
8082
"objective_for_coverage",
83+
"objective_for_data",
8184
"with_match_kind",
8285
"with_objective",
8386
]
@@ -90,7 +93,7 @@
9093

9194
#: Exhaustive over what was examined -- but what was examined is not everything.
9295
#:
93-
#: Only derived surfaces can be in this state. They answer from stored results,
96+
#: Derived surfaces reach this state through coverage. They answer from stored results,
9497
#: so the question "was the whole subject looked at" is separate from "was the
9598
#: whole answer returned", and ``truncated`` only ever spoke to the second.
9699
#:
@@ -103,6 +106,13 @@
103106
#: consumer honouring the documented predicate got a false negative. So the
104107
#: field the contract *does* make authoritative carries it: below full
105108
#: coverage the objective is no longer exhaustive.
109+
#:
110+
#: The second way to examine less than everything is for the data the answer
111+
#: depends on to be unavailable. A reverse lookup over a call graph that was
112+
#: never built returns ``[]`` -- the value a symbol with no callers returns --
113+
#: and ``code_changes`` reported that as zero callers for every changed symbol,
114+
#: exhaustive and untruncated. :func:`objective_for_data` makes that partial
115+
#: before coverage is consulted.
106116
OBJECTIVE_PARTIAL = "partial"
107117

108118
#: Engine-backed ``code`` ops we have evidence for. ``pattern`` and ``node`` are
@@ -139,6 +149,16 @@
139149
}
140150

141151

152+
#: The data an enumeration depends on was there to be read. Mirrors the engine's
153+
#: call-graph ``data_status``, whose third value, ``"empty"``, means "looked up
154+
#: and none found" -- a real answer, which stays exhaustive.
155+
DATA_AVAILABLE = "available"
156+
157+
#: The data an enumeration depends on was never built, so an empty result is not
158+
#: evidence of absence. See :func:`objective_for_data`.
159+
DATA_UNAVAILABLE = "unavailable"
160+
161+
142162
def objective_for_coverage(coverage: float | None, superseded: int | None = None) -> str:
143163
"""The objective a stored-result surface may claim about this answer.
144164
@@ -161,6 +181,24 @@ def objective_for_coverage(coverage: float | None, superseded: int | None = None
161181
return OBJECTIVE_EXHAUSTIVE
162182

163183

184+
def objective_for_data(available: bool, coverage: float | None = None, superseded: int | None = None) -> str:
185+
"""The objective an enumerative answer may claim, given whether its data existed.
186+
187+
*available* false means the lookup had nothing to read -- the call graph or
188+
import table the answer depends on was never built -- so an empty result is
189+
not evidence of absence, and the answer is :data:`OBJECTIVE_PARTIAL`
190+
whatever else is true. Otherwise this defers to
191+
:func:`objective_for_coverage`.
192+
193+
Unavailable is not empty. Edges that were looked up with none found are a
194+
real answer and stay exhaustive; only data that was never there to look up
195+
downgrades the claim.
196+
"""
197+
if not available:
198+
return OBJECTIVE_PARTIAL
199+
return objective_for_coverage(coverage, superseded)
200+
201+
164202
def with_objective(payload: dict[str, Any], objective: str) -> dict[str, Any]:
165203
"""Stamp *payload* in place and return it."""
166204
payload["objective"] = objective

src/lemoncrow/infra/code_intel/coverage.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from typing import Any
3131

3232
from lemoncrow.infra.code_intel.completeness import OBJECTIVE_EXHAUSTIVE
33+
from lemoncrow.infra.code_intel.freshness import require_ready
3334
from lemoncrow.infra.code_intel.languages import language_for_path
3435
from lemoncrow.infra.code_intel.store import CodeIntelStore, FileRow
3536

@@ -179,8 +180,15 @@ def check_coverage(paths: list[str] | None = None, repo_root: Path | str = ".")
179180
With no *paths*, the candidate set is every git-tracked file plus everything
180181
already in the index -- not a filesystem walk, which would drag in build
181182
output and virtualenvs the indexer never looked at.
183+
184+
Raises :class:`~lemoncrow.infra.code_intel.freshness.IndexRebuilding` while
185+
the index is mid-write, and
186+
:class:`~lemoncrow.infra.code_intel.store.CodeIntelUnavailable` when it is
187+
absent: verdicts judged against a torn index report real files as missing,
188+
and an absent one has nothing to judge against.
182189
"""
183190
root = Path(repo_root).expanduser().resolve()
191+
require_ready(root)
184192

185193
with CodeIntelStore(root) as store:
186194
snapshot = store.snapshot()

src/lemoncrow/infra/code_intel/file_graph.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
from types import TracebackType
3131
from typing import Any
3232

33-
from lemoncrow.infra.code_intel.completeness import OBJECTIVE_EXHAUSTIVE
33+
from lemoncrow.infra.code_intel.completeness import DATA_AVAILABLE, DATA_UNAVAILABLE, objective_for_data
34+
from lemoncrow.infra.code_intel.freshness import require_ready
3435
from lemoncrow.infra.code_intel.store import CodeIntelStore, IndexSnapshot
3536

3637
__all__ = ["FileGraph", "open_file_graph"]
@@ -141,6 +142,7 @@ def __init__(self, store: CodeIntelStore, repo_root: Path) -> None:
141142
self.repo_root: Path = repo_root
142143
self._store: CodeIntelStore = store
143144
self._snapshot: IndexSnapshot = store.snapshot()
145+
self._import_gap: str | None = store.import_gap()
144146
self._files: dict[str, str] = {row.file_path: row.language for row in store.files()}
145147
self._edges: _Edges = self._build_edges()
146148
self._import_languages: frozenset[str] = frozenset(
@@ -241,15 +243,20 @@ def _envelope(self) -> dict[str, Any]:
241243
242244
Every operation here enumerates rather than ranks -- the import graph is
243245
walked exhaustively and only the *returned list* is ever cut -- so the
244-
objective is stamped once, for all five.
246+
objective is stamped once, for all five: exhaustive over an import table
247+
that has rows, partial over one that has none.
245248
"""
246-
return {
247-
"objective": OBJECTIVE_EXHAUSTIVE,
249+
envelope: dict[str, Any] = {
250+
"objective": objective_for_data(self._import_gap is None),
248251
"analyzed_files": len(self._files),
249252
"resolved_edges": self._edges.resolved,
250253
"unresolved_edges": self._edges.unresolved,
251254
"engine_index_version": self._snapshot.index_version,
255+
"data_status": DATA_AVAILABLE if self._import_gap is None else DATA_UNAVAILABLE,
252256
}
257+
if self._import_gap is not None:
258+
envelope["reason"] = self._import_gap
259+
return envelope
253260

254261
def _scope(self, paths: list[str] | None) -> frozenset[str] | None:
255262
"""Normalise a caller's path filter to repo-relative prefixes."""
@@ -476,6 +483,13 @@ def _normalise(self, path: str) -> str:
476483

477484

478485
def open_file_graph(repo_root: Path | str = ".") -> FileGraph:
479-
"""Open a :class:`FileGraph` over *repo_root*'s code index."""
486+
"""Open a :class:`FileGraph` over *repo_root*'s code index.
487+
488+
Raises :class:`~lemoncrow.infra.code_intel.freshness.IndexRebuilding` while
489+
the index is mid-write and
490+
:class:`~lemoncrow.infra.code_intel.store.CodeIntelUnavailable` when it is
491+
absent or empty, rather than analysing nothing.
492+
"""
480493
root = Path(repo_root).expanduser().resolve()
494+
require_ready(root)
481495
return FileGraph(CodeIntelStore(root), root)

src/lemoncrow/infra/code_intel/freshness.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
error.
1717
* :class:`VersionedEngineCache` -- a process cache whose entries are stamped
1818
with the generation they were built against, and rebuilt on mismatch.
19+
* :func:`require_ready` -- the same probe, throttled the same way, as a gate
20+
for the tools that read the databases directly instead of through a cached
21+
engine.
1922
2023
The rule both serve is **fail loud, never empty**. An empty result set from an
2124
index that is mid-rebuild is indistinguishable from a true negative, which
@@ -40,7 +43,7 @@
4043
from pathlib import Path
4144
from typing import Any
4245

43-
from lemoncrow.infra.code_intel.store import CODE_CONTEXT_DB, workspace_dir
46+
from lemoncrow.infra.code_intel.store import CODE_CONTEXT_DB, CodeIntelUnavailable, workspace_dir
4447

4548
__all__ = [
4649
"DEFAULT_RECHECK_SECONDS",
@@ -57,6 +60,8 @@
5760
"IndexState",
5861
"VersionedEngineCache",
5962
"index_state",
63+
"require_ready",
64+
"reset_readiness_probes",
6065
]
6166

6267
logger = logging.getLogger(__name__)
@@ -354,3 +359,46 @@ def __len__(self) -> int:
354359

355360
def __contains__(self, key: object) -> bool:
356361
return key in self._entries
362+
363+
364+
#: Readiness probes for the store-backed tools, throttled exactly like the engine
365+
#: cache's. One entry per distinct repo root this process has queried -- the bound
366+
#: ``_code_engine_cache`` already has -- cleared by :func:`reset_readiness_probes`.
367+
_readiness = VersionedEngineCache("store_readiness")
368+
369+
370+
def require_ready(repo_root: Path | str = ".") -> IndexState:
371+
"""Raise unless the index under *repo_root* can answer; return its state.
372+
373+
``code_changes``, ``code_query``, ``code_coverage_check`` and the file-graph
374+
analytics open the engine's databases directly, so the engine cache's
375+
rebuild check never ran for them: mid-reindex they read a torn index and
376+
returned what was left, an empty answer delivered as a complete one. This
377+
is that check, applied where they start.
378+
379+
``rebuilding`` raises :class:`IndexRebuilding`. ``absent`` raises
380+
:class:`~lemoncrow.infra.code_intel.store.CodeIntelUnavailable`: the probe
381+
cannot tell a workspace that was never indexed from one whose index was
382+
emptied for an engine migration and has not repopulated yet, and neither
383+
has anything to enumerate. Both fail loud; neither returns empty.
384+
385+
The probe is re-read at most once per :data:`DEFAULT_RECHECK_SECONDS`, the
386+
staleness bound the engine cache already accepts, so a hot query path does
387+
not open SQLite an extra time per call.
388+
"""
389+
root = Path(repo_root).expanduser().resolve()
390+
state = _readiness.state_for(root)
391+
if state.status == STATUS_REBUILDING:
392+
raise IndexRebuilding(root, state.detail)
393+
if state.status == STATUS_ABSENT:
394+
raise CodeIntelUnavailable(
395+
f"code index for {root} is unavailable ({state.detail}): the workspace has not been "
396+
"indexed, or its index is being migrated and has not repopulated yet -- run "
397+
"`lc code index`, or retry shortly"
398+
)
399+
return state
400+
401+
402+
def reset_readiness_probes() -> None:
403+
"""Forget every throttled readiness probe, so the next gate re-reads disk."""
404+
_readiness.clear()

0 commit comments

Comments
 (0)