@@ -158,3 +158,213 @@ def build_verdict(
158158 negative_evidence ["related_existing" ] = did_you_mean
159159
160160 return {"verdict" : verdict , "negative_evidence" : negative_evidence }
161+
162+
163+ def suggest_paths (
164+ requested_path : Optional [str ],
165+ source_files : Optional [Sequence [str ]],
166+ cap : int = 5 ,
167+ ) -> list :
168+ """Indexed paths that plausibly match a missing ``requested_path``.
169+
170+ Exact-basename matches in a different directory come first (the agent had
171+ the filename right, the directory wrong), then stem substring matches. The
172+ requested path itself is never suggested.
173+ """
174+ if not requested_path or not source_files :
175+ return []
176+ req = str (requested_path ).replace ("\\ " , "/" )
177+ req_base = req .rsplit ("/" , 1 )[- 1 ].lower ()
178+ req_stem = req_base .rsplit ("." , 1 )[0 ] if "." in req_base else req_base
179+ exact : list = []
180+ partial : list = []
181+ seen : set = set ()
182+ for f in source_files :
183+ norm = str (f ).replace ("\\ " , "/" )
184+ if norm == req or f in seen :
185+ continue
186+ base = norm .rsplit ("/" , 1 )[- 1 ].lower ()
187+ stem = base .rsplit ("." , 1 )[0 ] if "." in base else base
188+ if base == req_base :
189+ exact .append (f )
190+ seen .add (f )
191+ elif req_stem and len (req_stem ) >= 3 and (req_stem in stem or stem in req_stem ):
192+ partial .append (f )
193+ seen .add (f )
194+ return (exact + partial )[:cap ]
195+
196+
197+ def _symbol_name_of (symbol_id : Optional [str ]) -> str :
198+ """Bare name from a symbol id like ``path::Name#kind`` (or a plain name)."""
199+ if not symbol_id :
200+ return ""
201+ s = str (symbol_id )
202+ if "::" in s :
203+ s = s .rsplit ("::" , 1 )[- 1 ]
204+ if "#" in s :
205+ s = s .split ("#" , 1 )[0 ]
206+ return s .lower ()
207+
208+
209+ def suggest_symbol_ids (
210+ requested_id : Optional [str ],
211+ symbols : Optional [Sequence [dict ]],
212+ cap : int = 5 ,
213+ ) -> list :
214+ """Indexed symbol ids whose name matches a missing ``requested_id``.
215+
216+ Same-name symbols (right name, wrong file/kind) rank ahead of substring
217+ matches. Operates on the index's raw symbol dicts.
218+ """
219+ name = _symbol_name_of (requested_id )
220+ if not name or not symbols :
221+ return []
222+ exact : list = []
223+ partial : list = []
224+ seen : set = set ()
225+ for s in symbols :
226+ sid = s .get ("id" )
227+ if not sid or sid == requested_id or sid in seen :
228+ continue
229+ sname = str (s .get ("name" , "" )).lower ()
230+ if not sname :
231+ continue
232+ if sname == name :
233+ exact .append (sid )
234+ seen .add (sid )
235+ elif len (name ) >= 3 and (name in sname or sname in name ):
236+ partial .append (sid )
237+ seen .add (sid )
238+ if len (exact ) >= cap :
239+ break
240+ return (exact + partial )[:cap ]
241+
242+
243+ def build_file_verdict (
244+ * ,
245+ present : bool ,
246+ requested_path : Optional [str ] = None ,
247+ source_files : Optional [Sequence [str ]] = None ,
248+ index_stale : bool = False ,
249+ empty_symbols : bool = False ,
250+ ) -> dict :
251+ """`_meta.verdict` for the file-read tools.
252+
253+ * ``present=False`` — the path is not in the index: ``absent`` plus a
254+ ``did_you_mean`` list of near-miss paths.
255+ * ``present=True, empty_symbols=True`` — the file is indexed but yields no
256+ symbols (data/config file, or constructs the parser does not surface):
257+ ``absent`` with no suggestions, so the agent does not retry the outline.
258+ * otherwise — ``ok``.
259+ """
260+ if not present :
261+ state = STATE_ABSENT
262+ note = "Path is not in the index. " + _NOTES [STATE_ABSENT ]
263+ suggestions = suggest_paths (requested_path , source_files )
264+ elif empty_symbols :
265+ state = STATE_ABSENT
266+ note = (
267+ "File is indexed but exposes no extractable symbols (a data/config "
268+ "file, or constructs the parser does not surface). Re-requesting the "
269+ "outline will not change this."
270+ )
271+ suggestions = []
272+ else :
273+ state = STATE_OK
274+ note = _NOTES [STATE_OK ]
275+ suggestions = []
276+ verdict = {
277+ "state" : state ,
278+ "channels" : {"index" : "stale" if index_stale else "fresh" },
279+ "note" : note ,
280+ }
281+ if suggestions :
282+ verdict ["did_you_mean" ] = suggestions
283+ return verdict
284+
285+
286+ def symbol_verdict_for_index (
287+ index ,
288+ * ,
289+ found_count : int ,
290+ requested_id : Optional [str ] = None ,
291+ ) -> dict :
292+ """Index-aware wrapper over :func:`build_symbol_verdict`."""
293+ return build_symbol_verdict (
294+ found_count = found_count ,
295+ requested_id = requested_id ,
296+ symbols = getattr (index , "symbols" , None ) if found_count == 0 else None ,
297+ index_stale = _index_is_stale (index ),
298+ )
299+
300+
301+ def _index_source_files (index ) -> list :
302+ """Best-effort list of indexed source paths (keys of ``file_languages``)."""
303+ langs = getattr (index , "file_languages" , None )
304+ if isinstance (langs , dict ):
305+ return list (langs .keys ())
306+ return []
307+
308+
309+ def _index_is_stale (index ) -> bool :
310+ """Whether the index SHA lags the live git HEAD (never raises)."""
311+ try :
312+ from .freshness import FreshnessProbe
313+
314+ probe = FreshnessProbe (
315+ source_root = getattr (index , "source_root" , "" ) or None ,
316+ indexed_at = getattr (index , "indexed_at" , "" ),
317+ index_sha = getattr (index , "git_head" , None ),
318+ file_mtimes = getattr (index , "file_mtimes" , None ),
319+ )
320+ return probe .repo_is_stale
321+ except Exception :
322+ return False
323+
324+
325+ def file_verdict_for_index (
326+ index ,
327+ * ,
328+ present : bool ,
329+ requested_path : Optional [str ] = None ,
330+ empty_symbols : bool = False ,
331+ ) -> dict :
332+ """Index-aware wrapper over :func:`build_file_verdict` for the file tools."""
333+ return build_file_verdict (
334+ present = present ,
335+ requested_path = requested_path ,
336+ source_files = _index_source_files (index ) if not present else None ,
337+ index_stale = _index_is_stale (index ),
338+ empty_symbols = empty_symbols ,
339+ )
340+
341+
342+ def build_symbol_verdict (
343+ * ,
344+ found_count : int ,
345+ requested_id : Optional [str ] = None ,
346+ symbols : Optional [Sequence [dict ]] = None ,
347+ index_stale : bool = False ,
348+ ) -> dict :
349+ """`_meta.verdict` for ``get_symbol_source``.
350+
351+ ``found_count == 0`` yields ``absent`` plus ``did_you_mean`` symbol ids that
352+ share the requested name; any resolved symbol yields ``ok`` (a partial batch
353+ is still a hit).
354+ """
355+ if found_count == 0 :
356+ state = STATE_ABSENT
357+ note = "Symbol id is not in the index. " + _NOTES [STATE_ABSENT ]
358+ suggestions = suggest_symbol_ids (requested_id , symbols )
359+ else :
360+ state = STATE_OK
361+ note = _NOTES [STATE_OK ]
362+ suggestions = []
363+ verdict = {
364+ "state" : state ,
365+ "channels" : {"index" : "stale" if index_stale else "fresh" },
366+ "note" : note ,
367+ }
368+ if suggestions :
369+ verdict ["did_you_mean" ] = suggestions
370+ return verdict
0 commit comments