3232from pathlib import Path
3333from 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
3643from 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 )
0 commit comments