codecortex 0.15.2__tar.gz → 0.15.4__tar.gz

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.
Files changed (106) hide show
  1. {codecortex-0.15.2/src/codecortex.egg-info → codecortex-0.15.4}/PKG-INFO +1 -1
  2. {codecortex-0.15.2 → codecortex-0.15.4/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. {codecortex-0.15.2 → codecortex-0.15.4}/src/codecortex.egg-info/SOURCES.txt +8 -0
  4. codecortex-0.15.4/src/codeintel/__init__.py +1 -0
  5. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/query.py +35 -1
  6. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/gateway.py +70 -11
  7. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/indexer.py +5 -6
  8. codecortex-0.15.4/src/codeintel/loc.py +73 -0
  9. codecortex-0.15.4/src/codeintel/outcome.py +77 -0
  10. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/provider.py +40 -0
  11. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/providers/graph.py +376 -21
  12. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/providers/lsp.py +241 -37
  13. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/providers/semantic.py +50 -5
  14. codecortex-0.15.4/src/codeintel/redact.py +113 -0
  15. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/reset.py +63 -2
  16. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/server.py +34 -10
  17. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/source_kind.py +69 -0
  18. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_cli_commands.py +9 -9
  19. codecortex-0.15.4/tests/test_cold_process.py +532 -0
  20. codecortex-0.15.4/tests/test_corpus.py +335 -0
  21. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_gateway.py +3 -2
  22. codecortex-0.15.4/tests/test_graph_failure_population.py +206 -0
  23. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_graph_provider.py +1 -1
  24. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_graph_real.py +67 -1
  25. codecortex-0.15.4/tests/test_incompleteness.py +580 -0
  26. codecortex-0.15.4/tests/test_loc_census.py +212 -0
  27. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_lsp_provider.py +126 -3
  28. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_lsp_real.py +38 -19
  29. codecortex-0.15.2/src/codeintel/__init__.py +0 -1
  30. {codecortex-0.15.2 → codecortex-0.15.4}/LICENSE +0 -0
  31. {codecortex-0.15.2 → codecortex-0.15.4}/README.md +0 -0
  32. {codecortex-0.15.2 → codecortex-0.15.4}/pyproject.toml +0 -0
  33. {codecortex-0.15.2 → codecortex-0.15.4}/setup.cfg +0 -0
  34. {codecortex-0.15.2 → codecortex-0.15.4}/src/codecortex.egg-info/dependency_links.txt +0 -0
  35. {codecortex-0.15.2 → codecortex-0.15.4}/src/codecortex.egg-info/entry_points.txt +0 -0
  36. {codecortex-0.15.2 → codecortex-0.15.4}/src/codecortex.egg-info/requires.txt +0 -0
  37. {codecortex-0.15.2 → codecortex-0.15.4}/src/codecortex.egg-info/top_level.txt +0 -0
  38. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/__main__.py +0 -0
  39. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/auth.py +0 -0
  40. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/cache.py +0 -0
  41. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/__init__.py +0 -0
  42. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/_common.py +0 -0
  43. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/doctor.py +0 -0
  44. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/gen_token.py +0 -0
  45. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/graph.py +0 -0
  46. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/index.py +0 -0
  47. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/install.py +0 -0
  48. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/map.py +0 -0
  49. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/reset.py +0 -0
  50. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/serve.py +0 -0
  51. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/serve_http.py +0 -0
  52. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/setup.py +0 -0
  53. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/commands/status.py +0 -0
  54. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/config.py +0 -0
  55. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/containment.py +0 -0
  56. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/doctor.py +0 -0
  57. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/grapher.py +0 -0
  58. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/http_server.py +0 -0
  59. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/injector.py +0 -0
  60. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/installer.py +0 -0
  61. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/logconfig.py +0 -0
  62. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/mapper.py +0 -0
  63. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/metrics.py +0 -0
  64. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/onboarding.py +0 -0
  65. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/policy.py +0 -0
  66. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/providers/__init__.py +0 -0
  67. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/providers/none.py +0 -0
  68. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/py.typed +0 -0
  69. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/reindexer.py +0 -0
  70. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/searcher.py +0 -0
  71. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/semantic_db.py +0 -0
  72. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/term.py +0 -0
  73. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/verify.py +0 -0
  74. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/viewer/__init__.py +0 -0
  75. {codecortex-0.15.2 → codecortex-0.15.4}/src/codeintel/viewer/graph_template.html +0 -0
  76. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_cache.py +0 -0
  77. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_chunking.py +0 -0
  78. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_cli_help.py +0 -0
  79. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_config.py +0 -0
  80. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_doctor.py +0 -0
  81. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_e2e.py +0 -0
  82. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_engine_adoption.py +0 -0
  83. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_enterprise.py +0 -0
  84. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_graph_stdin.py +0 -0
  85. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_grapher.py +0 -0
  86. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_hardening.py +0 -0
  87. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_http_auth.py +0 -0
  88. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_http_server.py +0 -0
  89. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_installer.py +0 -0
  90. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_integration.py +0 -0
  91. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_mapper.py +0 -0
  92. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_mcp_handshake.py +0 -0
  93. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_mcp_server.py +0 -0
  94. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_model_dimension.py +0 -0
  95. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_never_raise.py +0 -0
  96. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_onboarding.py +0 -0
  97. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_rbac.py +0 -0
  98. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_reindexer.py +0 -0
  99. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_rerank.py +0 -0
  100. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_reset.py +0 -0
  101. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_semantic_provider.py +0 -0
  102. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_source_kind.py +0 -0
  103. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_term.py +0 -0
  104. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_treesitter.py +0 -0
  105. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_verify.py +0 -0
  106. {codecortex-0.15.2 → codecortex-0.15.4}/tests/test_verify_call.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.15.2
3
+ Version: 0.15.4
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.15.2
3
+ Version: 0.15.4
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -20,13 +20,16 @@ src/codeintel/http_server.py
20
20
  src/codeintel/indexer.py
21
21
  src/codeintel/injector.py
22
22
  src/codeintel/installer.py
23
+ src/codeintel/loc.py
23
24
  src/codeintel/logconfig.py
24
25
  src/codeintel/mapper.py
25
26
  src/codeintel/metrics.py
26
27
  src/codeintel/onboarding.py
28
+ src/codeintel/outcome.py
27
29
  src/codeintel/policy.py
28
30
  src/codeintel/provider.py
29
31
  src/codeintel/py.typed
32
+ src/codeintel/redact.py
30
33
  src/codeintel/reindexer.py
31
34
  src/codeintel/reset.py
32
35
  src/codeintel/searcher.py
@@ -60,12 +63,15 @@ tests/test_cache.py
60
63
  tests/test_chunking.py
61
64
  tests/test_cli_commands.py
62
65
  tests/test_cli_help.py
66
+ tests/test_cold_process.py
63
67
  tests/test_config.py
68
+ tests/test_corpus.py
64
69
  tests/test_doctor.py
65
70
  tests/test_e2e.py
66
71
  tests/test_engine_adoption.py
67
72
  tests/test_enterprise.py
68
73
  tests/test_gateway.py
74
+ tests/test_graph_failure_population.py
69
75
  tests/test_graph_provider.py
70
76
  tests/test_graph_real.py
71
77
  tests/test_graph_stdin.py
@@ -73,8 +79,10 @@ tests/test_grapher.py
73
79
  tests/test_hardening.py
74
80
  tests/test_http_auth.py
75
81
  tests/test_http_server.py
82
+ tests/test_incompleteness.py
76
83
  tests/test_installer.py
77
84
  tests/test_integration.py
85
+ tests/test_loc_census.py
78
86
  tests/test_lsp_provider.py
79
87
  tests/test_lsp_real.py
80
88
  tests/test_mapper.py
@@ -0,0 +1 @@
1
+ __version__ = "0.15.4"
@@ -1,6 +1,7 @@
1
1
  """`codeintel query` — one question against the gateway: search, callers, callees, impact, chain."""
2
2
 
3
3
  import json
4
+ import os
4
5
  import sys
5
6
  import time
6
7
  from typing import Any
@@ -12,6 +13,36 @@ from codeintel.provider import Result, safe_null_result
12
13
  # and reporting whatever the gateway last said.
13
14
  _WARMING_TIMEOUT_S = 45.0
14
15
 
16
+ # Per-query time budget, in milliseconds, handed to whichever engine answers.
17
+ #
18
+ # This used to be omitted entirely, so every engine fell back to its own default — 5s for the LSP
19
+ # provider — against a cold first `symbol` query measured at 11.65s on a real 841-file TypeScript
20
+ # repo. The call timed out, the reference lookup came back empty, and the empty list was rendered
21
+ # as "(none)": a confident false answer produced by a missing argument. A CLI invocation is a
22
+ # human or an agent waiting on one question; it can afford to wait properly.
23
+ _CLI_BUDGET_MS = 30_000
24
+
25
+
26
+ def _budget_ms() -> int:
27
+ """The per-query budget, overridable via ``CODEINTEL_BUDGET_MS``.
28
+
29
+ Two reasons this is an env var rather than a constant. Operators on slow machines or huge
30
+ repositories need to raise it. And tests need to LOWER it: the cold-process tier exists to catch
31
+ the defect where a timed-out backend call is rendered as a confident "(none)", but on a fast
32
+ machine the cold call simply succeeds, so the tier passed with that exact regression planted
33
+ back in. A budget it can drive to near-zero lets it reproduce the starvation condition
34
+ deterministically instead of waiting for a slow day.
35
+ """
36
+ raw = os.environ.get("CODEINTEL_BUDGET_MS", "").strip()
37
+ if raw:
38
+ try:
39
+ n = int(raw)
40
+ if n > 0:
41
+ return n
42
+ except ValueError:
43
+ pass
44
+ return _CLI_BUDGET_MS
45
+
15
46
 
16
47
  def run(args: Any) -> int:
17
48
  """`--json` promises parseable stdout, so its failures must be JSON too.
@@ -55,7 +86,9 @@ def _query(args: Any) -> Result:
55
86
 
56
87
  project_root = resolve_root(args)
57
88
  engine = args.engine if args.engine != "auto" else None
58
- gw = server._build_gateway()
89
+ # oneshot: this process exits when the query returns, so it must not start a background
90
+ # reindex it cannot finish (and must not then report that reindex as staleness).
91
+ gw = server._build_gateway(oneshot=True)
59
92
 
60
93
  def _run_query() -> Result:
61
94
  return gw.query(
@@ -63,6 +96,7 @@ def _query(args: Any) -> Result:
63
96
  target=args.target,
64
97
  engine=engine,
65
98
  role="",
99
+ budget=_budget_ms(),
66
100
  project_root=project_root,
67
101
  )
68
102
 
@@ -6,8 +6,9 @@ from typing import Any
6
6
 
7
7
  from codeintel.cache import ContentHashCache
8
8
  from codeintel.policy import TieringPolicy
9
- from codeintel.provider import Result, log_swallowed, safe_null_result
9
+ from codeintel.provider import Result, attach_confidence, log_swallowed, safe_null_result
10
10
  from codeintel.providers.none import NoneProvider
11
+ from codeintel.redact import redact
11
12
  from codeintel.reindexer import Reindexer
12
13
 
13
14
  _KNOWN_ENGINES: frozenset[str] = frozenset({"graph", "lsp", "semantic", "auto", "both", "all"})
@@ -51,7 +52,17 @@ def _mark_reindexing(result: Result, reindexing: bool) -> Result:
51
52
 
52
53
  class Gateway:
53
54
  def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None,
54
- reindexer: Reindexer | None = None):
55
+ reindexer: Reindexer | None = None, oneshot: bool = False):
56
+ # A one-shot process (the `codeintel` CLI) must not run the long-lived server's background
57
+ # machinery. It used to: every query called `maybe_reindex`, which in a fresh process always
58
+ # passed the debounce (`_last_fired` starts empty) and submitted a pass to a DAEMON pool —
59
+ # then the same query asked `reindex_pending` ten lines later and was told "yes", by itself.
60
+ # That is why `reindexing: true` accompanied literally every answer this tool has ever
61
+ # produced, and why the tree was re-walked on every query. Worse, a daemon thread is killed
62
+ # wherever it happens to be when the process exits, so those passes never completed and wrote
63
+ # torn state on the way out — the most plausible source of the `.corrupt` index files found
64
+ # in the cache. A process that cannot finish a reindex must not start one.
65
+ self._oneshot = bool(oneshot)
55
66
  # Backward-compat: old tests pass a list as the first positional arg.
56
67
  if isinstance(graph, list):
57
68
  self._legacy_providers: list | None = graph
@@ -179,14 +190,32 @@ class Gateway:
179
190
  if all_unreachable else "")),
180
191
  )
181
192
 
182
- return {
193
+ # A fan-out answer is only as whole as its parts. This used to hand-build a six-key envelope
194
+ # and drop both `confidence` and `gaps` on the floor — so a `context` request (the DEFAULT
195
+ # fan-out op) whose graph half timed out returned the lsp half alone, unqualified, and a
196
+ # `partial` a provider had explicitly produced was destroyed on the way out. Worse, an engine
197
+ # that answered NOTHING is silently absent from `parts`: the body simply does not mention it,
198
+ # which reads as "that engine had nothing to add" rather than "that engine could not be asked".
199
+ merged_gaps: list[dict] = []
200
+ for eng, r in results.items():
201
+ merged_gaps.extend({**g, "engine": eng}
202
+ for g in (r.get("gaps") or []) if isinstance(g, dict))
203
+ if r.get("result") is None:
204
+ merged_gaps.append({
205
+ "section": eng,
206
+ "kind": str(r.get("reason") or "no-result"),
207
+ "detail": f"the {eng} engine contributed nothing to this answer "
208
+ f"({r.get('reason') or 'no-result'})",
209
+ "engine": eng,
210
+ })
211
+ return attach_confidence({
183
212
  "ok": True,
184
213
  "op": op_str,
185
214
  "target": target_str,
186
215
  "result": "\n\n".join(parts),
187
216
  "engine": engine_str,
188
217
  "cached": False,
189
- }
218
+ }, merged_gaps)
190
219
 
191
220
  def _dispatch_single(
192
221
  self,
@@ -235,6 +264,28 @@ class Gateway:
235
264
  role: str = "",
236
265
  budget=None,
237
266
  project_root=None,
267
+ ) -> Result:
268
+ """Answer one question. Never raises.
269
+
270
+ A thin wrapper over `_query`, existing so that redaction has exactly ONE seam to cover.
271
+ Every leak found in the evaluation was in a field some renderer built and no one swept —
272
+ the scope note inside `result`, the "index it standalone with:" command inside `hint`. Both
273
+ are downstream of here, and so is anything added later."""
274
+ result = self._query(op, target, engine, role, budget, project_root)
275
+ try:
276
+ return redact(result) # type: ignore[return-value]
277
+ except Exception as exc:
278
+ log_swallowed("Gateway.query.redact", exc)
279
+ return result
280
+
281
+ def _query(
282
+ self,
283
+ op=None,
284
+ target=None,
285
+ engine=None,
286
+ role: str = "",
287
+ budget=None,
288
+ project_root=None,
238
289
  ) -> Result:
239
290
  try:
240
291
  op_str = str(op or "")
@@ -258,20 +309,28 @@ class Gateway:
258
309
  hint="this token's role is not scoped to that "
259
310
  "project_root (see the [roots] table in auth.toml)")
260
311
 
261
- try:
262
- self._reindexer.maybe_reindex(str(project_root or ""))
263
- except Exception:
264
- pass
312
+ if not self._oneshot:
313
+ try:
314
+ self._reindexer.maybe_reindex(str(project_root or ""))
315
+ except Exception:
316
+ pass
265
317
 
266
318
  # If a reindex is running, this answer comes from the PREVIOUS index. Structural
267
319
  # answers (callers/impact/hotspots) hash a symbol name, not file bytes, so nothing
268
320
  # else in the envelope can reveal that — and an agent that just edited and asked
269
321
  # "what did I break?" lands precisely here. Busting the cache would not help: the
270
322
  # index itself is behind, so re-asking refetches the same stale data.
271
- try:
272
- reindexing = self._reindexer.reindex_pending(str(project_root or ""))
273
- except Exception:
323
+ # In one-shot mode this is always False and the flag is simply never emitted: no
324
+ # reindex was started, so there is nothing for the answer to be behind. In server mode
325
+ # the flag now means what it always claimed to — a pass this process did not start is
326
+ # genuinely still running — which is why it is fixed at the cause rather than deleted.
327
+ if self._oneshot:
274
328
  reindexing = False
329
+ else:
330
+ try:
331
+ reindexing = self._reindexer.reindex_pending(str(project_root or ""))
332
+ except Exception:
333
+ reindexing = False
275
334
 
276
335
  # Legacy list-based path (backward compat with pre-Phase-2 tests)
277
336
  if self._legacy_providers is not None:
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
11
11
 
12
12
  from codeintel.containment import contained_path, real_root
13
13
  from codeintel.source_kind import (
14
+ CODE_EXTS,
14
15
  load_gitattributes_globs,
15
16
  looks_generated_file,
16
17
  looks_generated_path,
@@ -31,12 +32,10 @@ _MAX_CHUNK_CHARS = 200_000
31
32
  # signal and is what `git` itself uses.
32
33
  _BINARY_SNIFF_BYTES = 8192
33
34
 
34
- _INDEXED_EXTS = frozenset({
35
- ".py", ".md",
36
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", # TS/JS variants
37
- ".go", ".rs", ".java",
38
- ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", # C/C++ variants
39
- })
35
+ # Derived from the shared policy rather than repeated: the corpus is "code, plus markdown". Two
36
+ # hand-typed copies of one population is how the sign-out bug in the review notes happened — one
37
+ # copy drifts and nothing says so.
38
+ _INDEXED_EXTS = CODE_EXTS | {".md"}
40
39
  _SKIP_DIRS = frozenset({"__pycache__", ".git", "node_modules"})
41
40
  # Directory names that mean "generated" only at the REPO ROOT. Matching them at any depth hid real
42
41
  # source: `coverage/` is the coverage.py package, `src/build/` is pypa/build's entire codebase
@@ -0,0 +1,73 @@
1
+ """Line-number conversion for the backends whose numbering differs from ours.
2
+
3
+ **This module does not apply to every backend, and the line base is data, not a rule.** An earlier
4
+ version of this docstring asserted "the only place in codeintel permitted to format a path:line" and
5
+ "every backend we speak to counts lines from zero". The second claim is false, and an investigation
6
+ found that following the first would have introduced a NEW off-by-one: the graph backend
7
+ (codebase-memory-mcp) reports 1-based line numbers, so ``graph.py`` formats its own ``path:line``
8
+ directly and must keep doing so. Centralising a guarantee only removes a defect class when the
9
+ implementations agree on the semantics being centralised — here they do not.
10
+
11
+ LINE BASES, verified by probing each backend — see LINE_BASES below:
12
+ serena / LSP -> use loc()/span()
13
+ semantic chunk_start -> use loc() (chunk_start = max(0, start - 1), indexer.py)
14
+ codebase-memory-mcp -> see LINE_BASES below; do NOT use loc(); emit as-is
15
+
16
+ Use ``loc()``/``span()`` for the 0-based backends. Every human, editor and terminal that consumes
17
+ our output counts from one, and agent hosts turn ``path:line`` into a clickable link. Each
18
+ renderer used to do its own conversion, which meant each renderer got to forget it independently
19
+ — and two of them did:
20
+
21
+ - ``lsp.py`` emitted serena's ``body_location`` and reference markers verbatim;
22
+ - ``semantic.py`` emitted ``chunk_start``, which is 0-based by construction in ``indexer.py``.
23
+
24
+ Both shipped answers pointing one line above the truth, and both produced ``path:0`` for anything
25
+ at the top of a file — a line number that does not exist, which is the tell that survived into the
26
+ evaluation transcript before anyone noticed it.
27
+
28
+ Centralising the conversion is what stops the next renderer from re-deciding this. If you need a
29
+ ``path:line`` string, call ``loc()``; if you need the number alone, call ``line1()``.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ # The measured line base of each backend. The prose table above is the explanation; THIS is the
35
+ # enforced form (tests/test_loc_census.py drives every provider and checks the number it emits
36
+ # against this map). A new provider file with no entry here fails the census.
37
+ LINE_BASES: dict[str, int] = {"lsp": 0, "semantic": 0, "graph": 1}
38
+
39
+
40
+ def line1(line0: object) -> int | None:
41
+ """Convert a backend's 0-based line number to a 1-based one.
42
+
43
+ Returns None for anything that is not a usable line number, so callers render a bare path
44
+ rather than inventing a position. Accepts str because some backends report line numbers as
45
+ text inside a larger payload.
46
+ """
47
+ if line0 is None or isinstance(line0, bool):
48
+ return None
49
+ try:
50
+ n = int(line0) # type: ignore[call-overload]
51
+ except (TypeError, ValueError):
52
+ return None
53
+ # A negative line is meaningless; clamp rather than emit `path:-1` or `path:0`.
54
+ return n + 1 if n >= 0 else None
55
+
56
+
57
+ def loc(path: object, line0: object = None) -> str:
58
+ """Render ``path:line`` with the line converted to 1-based, or a bare path when there is no
59
+ usable line. ``line0`` is the backend's own 0-based number — never pre-increment it."""
60
+ p = str(path or "").strip() or "?"
61
+ n = line1(line0)
62
+ return f"{p}:{n}" if n is not None else p
63
+
64
+
65
+ def span(path: object, start0: object, end0: object = None) -> str:
66
+ """Render ``path:start-end`` (1-based) for a symbol body, or ``path:start``/``path`` when the
67
+ end is absent or unusable."""
68
+ p = str(path or "").strip() or "?"
69
+ s = line1(start0)
70
+ if s is None:
71
+ return p
72
+ e = line1(end0)
73
+ return f"{p}:{s}-{e}" if e is not None else f"{p}:{s}"
@@ -0,0 +1,77 @@
1
+ """A typed result for internal seams, so a failure stays a failure all the way up.
2
+
3
+ Every provider-internal helper used to return ``X | None``, and ``None`` there meant five
4
+ different things: never asked · timed out · the backend errored · the payload did not parse ·
5
+ genuinely empty. Callers could not tell them apart, so they picked the cheapest reading — and the
6
+ cheapest reading of "no references came back" is "this symbol has no references", which is the
7
+ permissive answer and the wrong one.
8
+
9
+ That collapse is what produced the worst bug in the 2026-08-17 evaluation: a cold language server
10
+ timed out, ``_call_tool`` returned ``None``, and four conversions later the caller rendered
11
+ ``## References\\n(none)`` — a confident false statement, with no ``reason`` and no ``hint``,
12
+ byte-identical to a true one.
13
+
14
+ ``graph.py`` had already worked this out and re-invented a fix for it three separate times in one
15
+ module (``_FAIL``/``_UNPARSABLE``, ``ProjectLookup``, and ``_search_symbols`` returning ``None``
16
+ vs ``[]`` on purpose). ``lsp.py`` had none of them, which is exactly where the critical bug lived.
17
+ This is that abstraction, written once.
18
+
19
+ Rule: a helper returns ``Missing`` when it could not answer, and ``Ok`` when it did — including
20
+ ``Ok`` of an empty collection, which is a real and useful answer meaning "asked, and there is
21
+ nothing". Never conflate the two.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Generic, Literal, TypeAlias, TypeVar
28
+
29
+ T = TypeVar("T")
30
+
31
+ # Why a call produced no value. These strings reach the caller inside a gap note, so they are
32
+ # written to be read by a person or an agent, not only matched by code.
33
+ MissingKind = Literal[
34
+ "not-asked", # a precondition was absent, so the call was never made
35
+ "timeout", # the backend did not answer inside the budget
36
+ "backend-error", # the backend answered, and the answer was an error
37
+ "unparsable", # the backend answered, and the payload could not be read
38
+ "unsupported", # this backend cannot answer this question at all
39
+ ]
40
+
41
+ _DETAIL: dict[str, str] = {
42
+ "not-asked": "a precondition for this lookup was missing, so it was never requested",
43
+ "timeout": "the backend did not respond within the time budget",
44
+ "backend-error": "the backend returned an error instead of an answer",
45
+ "unparsable": "the backend's response could not be parsed",
46
+ "unsupported": "this engine cannot answer that",
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Ok(Generic[T]):
52
+ """The call answered. ``value`` may legitimately be empty."""
53
+
54
+ value: T
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Missing:
59
+ """The call did not answer, and this is why. Never rendered as a value."""
60
+
61
+ kind: MissingKind
62
+ detail: str = ""
63
+ retry_after_s: float | None = None
64
+
65
+ def describe(self) -> str:
66
+ """One caller-facing sentence. Falls back to a default phrasing per kind so that every
67
+ Missing carries something readable even when the call site passed no detail."""
68
+ return self.detail or _DETAIL.get(self.kind, "this lookup did not complete")
69
+
70
+
71
+ Outcome: TypeAlias = "Ok[T] | Missing"
72
+
73
+
74
+ def is_missing(outcome: object) -> bool:
75
+ """True when an outcome represents a failure to answer. Kept as a helper so call sites read
76
+ as intent rather than as an isinstance check against an implementation detail."""
77
+ return isinstance(outcome, Missing)
@@ -37,6 +37,17 @@ class Result(TypedDict):
37
37
  # reflects the last COMPLETED index rather than the current source. Optional, and the MCP
38
38
  # tools deliberately return plain `dict` so this never becomes a required schema field.
39
39
  reindexing: NotRequired[bool]
40
+ # How much of the answer the engine vouches for. A non-null `result` used to be an implicit
41
+ # promise of completeness, which is how a timed-out reference lookup shipped as "(none)".
42
+ # "complete" — ran; the engine stands behind the whole body
43
+ # "partial" — ran; produced a body; a NAMED part of it is known to be missing (see `gaps`)
44
+ # Absent on null results, where `reason` already carries the whole story.
45
+ confidence: NotRequired[str]
46
+ # One entry per part of the answer that could not be retrieved:
47
+ # {"section": str, "kind": str, "detail": str, "retry_after_s": float?}. Emitted only when
48
+ # non-empty. The same fact is always rendered into `result` too, because that is the field an
49
+ # agent actually reads.
50
+ gaps: NotRequired[list[dict[str, Any]]]
40
51
 
41
52
 
42
53
  @runtime_checkable
@@ -74,3 +85,32 @@ def safe_null_result(
74
85
  if hint is not None:
75
86
  r["hint"] = hint
76
87
  return r
88
+
89
+
90
+ def attach_confidence(result: Result, gaps: Any = ()) -> Result:
91
+ """Stamp an answered envelope with how much of it the engine vouches for.
92
+
93
+ Every engine must call this, and that is the point. `confidence` was introduced on the LSP
94
+ provider alone, which reproduced in miniature the defect it was added to fix: the instructions
95
+ told callers to check a field that two of three engines never set, and an ABSENT field is
96
+ ambiguous — "complete" and "this engine does not report" look identical. A contract that only
97
+ one implementation honours is not a contract.
98
+
99
+ `gaps` is a sequence of {"section", "kind", "detail", "retry_after_s"?} dicts. Empty ⇒
100
+ `complete`; non-empty ⇒ `partial`, and the caller is expected to have said the same thing in
101
+ the body text, because that is the field an agent actually reads.
102
+
103
+ Null results are left alone: `reason` already carries the whole story there, and stamping them
104
+ would imply a body exists to be partial about.
105
+ """
106
+ try:
107
+ if result.get("result") is None:
108
+ return result
109
+ items = [g for g in (gaps or ()) if isinstance(g, dict)]
110
+ out: Result = {**result, "confidence": "partial" if items else "complete"}
111
+ if items:
112
+ out["gaps"] = items
113
+ return out
114
+ except Exception as exc:
115
+ log_swallowed("attach_confidence", exc)
116
+ return result