code-atlas-forge 1.1.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.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,530 @@
1
+ """Fachada de busca — resolve snapshot READY e despacha estratégias
2
+ (lf-0036). ``structural`` delega ao ``StructuralSearch`` existente;
3
+ ``graph``/``hybrid``/``vector`` ainda não implementados → diagnóstico
4
+ tipado honesto (nunca resultado vazio silencioso)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ import json
10
+ import sqlite3
11
+
12
+ from code_atlas.observability.logging import timed
13
+ from code_atlas.search.contracts import (
14
+ RANKING_VERSION,
15
+ SEARCH_SCHEMA_VERSION,
16
+ SearchCapability,
17
+ SearchDiagnostic,
18
+ SearchHit,
19
+ SearchRequest,
20
+ SearchResult,
21
+ decode_cursor,
22
+ encode_cursor,
23
+ query_fingerprint,
24
+ )
25
+ from code_atlas.search.domain import DomainEntitySearch
26
+ from code_atlas.search.graph import GraphRetrieval
27
+ from code_atlas.search.hybrid import RANKING_VERSION_HYBRID, fuse, to_search_hit
28
+ from code_atlas.search.lexical import LexicalSearch
29
+ from code_atlas.search.structural import StructuralSearch
30
+ from code_atlas.search.vector.contracts import (
31
+ EmbeddingProvider,
32
+ )
33
+ from code_atlas.search.vector.embeddings import SqliteEmbeddingCache
34
+ from code_atlas.search.vector.index import (
35
+ BruteForceIndex,
36
+ sqlite_vec_available,
37
+ )
38
+
39
+
40
+ class SearchEngine:
41
+ """Ponto único de entrada de busca sobre a conexão."""
42
+
43
+ def __init__(
44
+ self, conn: sqlite3.Connection, *, enable_vector: bool = True,
45
+ enable_rerank: bool = True,
46
+ ) -> None:
47
+ self._conn = conn
48
+ self._structural = StructuralSearch(conn)
49
+ self._lexical = LexicalSearch(conn)
50
+ self._graph = GraphRetrieval(conn)
51
+ self._domain = DomainEntitySearch(conn)
52
+ self._vec = enable_vector
53
+ self._rerank = enable_rerank
54
+
55
+ def capabilities(self) -> SearchCapability:
56
+ fts5 = self._conn.execute(
57
+ "SELECT 1 FROM sqlite_master WHERE name='fts_occurrences'"
58
+ ).fetchone() is not None
59
+ base: tuple[str, ...] = ("lexical", "structural", "graph", "hybrid")
60
+ if self._vec:
61
+ base += ("vector",)
62
+ backend = (
63
+ "sqlite-vec" if sqlite_vec_available() else "bruteforce")
64
+ return SearchCapability(
65
+ strategies=base if fts5 else tuple(
66
+ s for s in base if s != "lexical"),
67
+ fts5=fts5,
68
+ vector=self._vec,
69
+ detail=(
70
+ f"vector: provider=naive-hash backend={backend}"
71
+ if self._vec else "vector: disabled"),
72
+ )
73
+
74
+ def search(self, req: SearchRequest) -> SearchResult:
75
+ diags: list[SearchDiagnostic] = []
76
+ repo_id = self._structural.repo_for_root(req.repository_root)
77
+ if repo_id is None:
78
+ diags.append(SearchDiagnostic(
79
+ code="repository-not-found", severity="error",
80
+ message=f"repositório não indexado: {req.repository_root}"))
81
+ return self._empty(req, diags)
82
+ snap, snap_err = self._structural.resolve_snapshot(
83
+ repo_id, req.snapshot_id)
84
+ if snap is None:
85
+ diags.append(SearchDiagnostic(
86
+ code="no-ready-snapshot", severity="error",
87
+ message=snap_err or "nenhum snapshot READY"))
88
+ return self._empty(req, diags)
89
+ return self.search_scoped(req, repo_id, int(snap.id or 0))
90
+
91
+ # lf-0141: slow-query guard — cada busca (incl. membro federado, que
92
+ # chama este método por repo) mede e vira linha DEBUG acima de
93
+ # ``CODE_ATLAS_SLOW_MS``. Fora do env, custo = um monotonic extra.
94
+ @timed("search")
95
+ def search_scoped(
96
+ self, req: SearchRequest, repository_id: int, snapshot_id: int
97
+ ) -> SearchResult:
98
+ """Busca num escopo (repositório, snapshot) já resolvido.
99
+
100
+ Diferente de ``search``: NÃO revalida o status do snapshot —
101
+ quem chama garante a legibilidade (a busca federada admite pins
102
+ STALE, cuja leitura é legítima). ``search()`` delega aqui após
103
+ resolver repo + READY.
104
+ """
105
+ repo_id, sid = repository_id, snapshot_id
106
+ diags: list[SearchDiagnostic] = []
107
+
108
+ # lf-0058: snapshot no fingerprint — cursor de outra revisão é
109
+ # rejeitado (proteção cross-revision exigida pela Phase 0.2).
110
+ filters = {"language": req.language, "kind": req.kind,
111
+ "path": req.path, "domain": req.domain,
112
+ "snapshot": str(sid)}
113
+ fp = query_fingerprint(req.query, filters)
114
+ offset = 0
115
+ if req.cursor:
116
+ decoded = decode_cursor(req.cursor, fp)
117
+ if decoded is None:
118
+ diags.append(SearchDiagnostic(
119
+ code="invalid-cursor", severity="error",
120
+ message="cursor não corresponde a esta query/filtros"))
121
+ return self._empty(req, diags, snapshot_id=sid)
122
+ offset = decoded
123
+
124
+ if req.strategy == "hybrid":
125
+ fused, h_diags = self._hybrid_hits(req, repo_id, sid)
126
+ diags.extend(h_diags)
127
+ # lf-0058 Phase 0.2: limit/offset APLICADOS após fusão —
128
+ # antes retornava todos os candidatos (P1).
129
+ lim = max(1, min(req.limit or 20, 200))
130
+ page = fused[offset:offset + lim]
131
+ truncated = offset + len(page) < len(fused)
132
+ res = SearchResult(
133
+ schema_version=SEARCH_SCHEMA_VERSION,
134
+ repository=req.repository_root,
135
+ snapshot_id=sid, query=req.query, strategy=req.strategy,
136
+ ranking_version=RANKING_VERSION_HYBRID, items=page,
137
+ limit=lim,
138
+ truncated=truncated,
139
+ next_cursor=(encode_cursor(offset + len(page), fp)
140
+ if truncated else None),
141
+ diagnostics=tuple(diags),
142
+ )
143
+ return self._attach_domain(res, req, repo_id, sid)
144
+ if req.strategy == "graph":
145
+ hits, _total, g_diags = self._graph_hits(req, repo_id, sid)
146
+ diags.extend(g_diags)
147
+ res = SearchResult(
148
+ schema_version=SEARCH_SCHEMA_VERSION,
149
+ repository=req.repository_root,
150
+ snapshot_id=sid, query=req.query, strategy=req.strategy,
151
+ ranking_version="graph-structural-v1", items=hits,
152
+ limit=req.limit,
153
+ truncated=len(hits) >= max(1, req.limit or 20),
154
+ diagnostics=tuple(diags),
155
+ )
156
+ return self._attach_domain(res, req, repo_id, sid)
157
+ if req.strategy == "structural":
158
+ hits, total = self._structural_hits(req, repo_id, sid, offset)
159
+ ranking = "structural-sql-v1"
160
+ elif req.strategy == "lexical":
161
+ hits, total, lex_diags = self._lexical.search(req, sid, offset)
162
+ diags.extend(lex_diags)
163
+ ranking = RANKING_VERSION
164
+ elif req.strategy == "vector":
165
+ hits, total, v_diags = self._vector_hits(req, sid, offset)
166
+ diags.extend(v_diags)
167
+ ranking = "vector-cosine-v1"
168
+ else:
169
+ diags.append(SearchDiagnostic(
170
+ code="strategy-unavailable", severity="error",
171
+ message=f"estrategia '{req.strategy}' não implementada "
172
+ f"(capabilities: lexical, structural)"))
173
+ return self._empty(req, diags, snapshot_id=sid)
174
+
175
+ truncated = offset + len(hits) < total
176
+ res = SearchResult(
177
+ schema_version=SEARCH_SCHEMA_VERSION,
178
+ repository=req.repository_root,
179
+ snapshot_id=sid,
180
+ query=req.query,
181
+ strategy=req.strategy,
182
+ ranking_version=ranking,
183
+ items=hits,
184
+ limit=req.limit,
185
+ truncated=truncated,
186
+ next_cursor=(
187
+ encode_cursor(offset + len(hits), fp) if truncated else None
188
+ ),
189
+ diagnostics=tuple(diags),
190
+ )
191
+ return self._attach_domain(res, req, repo_id, sid)
192
+
193
+ def _attach_domain(
194
+ self, res: SearchResult, req: SearchRequest,
195
+ repository_id: int, snapshot_id: int,
196
+ ) -> SearchResult:
197
+ """Pós-dispatch: tag de escopo em cada hit + faceta de domínio.
198
+
199
+ Todo hit carrega ``repository_id``/``snapshot_id`` do escopo —
200
+ a busca federada depende disso para nunca colapsar homônimos
201
+ cross-repo (lf-0119). A faceta ``domain_items`` é separada por
202
+ desenho — nunca fundida com símbolos.
203
+ """
204
+ tagged = tuple(
205
+ dataclasses.replace(
206
+ h, repository_id=repository_id, snapshot_id=snapshot_id)
207
+ for h in res.items
208
+ )
209
+ dom_items, dom_total, d_diags = self._domain.search(
210
+ req, repository_id, snapshot_id)
211
+ return dataclasses.replace(
212
+ res, items=tagged, domain_items=dom_items,
213
+ domain_total=dom_total,
214
+ diagnostics=(*res.diagnostics, *d_diags),
215
+ )
216
+
217
+ def _structural_hits(
218
+ self, req: SearchRequest, repo_id: int, snapshot_id: int,
219
+ offset: int,
220
+ ) -> tuple[tuple[SearchHit, ...], int]:
221
+ """Adapta ``search_symbols`` do StructuralSearch para SearchHit."""
222
+ from code_atlas.search.structural import SearchFilters
223
+
224
+ page = self._structural.search_symbols(
225
+ repo_id, snapshot_id, req.query,
226
+ SearchFilters(
227
+ language=req.language, kind=req.kind, path=req.path,
228
+ ),
229
+ max(1, min(req.limit or 20, 200)) + offset,
230
+ 0,
231
+ )
232
+ items = page.items[offset:]
233
+ hits = tuple(
234
+ SearchHit(
235
+ occurrence_id=int(it.get("occurrence_id", 0) or 0),
236
+ qualified_name=str(it.get("qualified_name", "")),
237
+ kind=str(it.get("kind", "")),
238
+ language=str(it.get("language", "")),
239
+ path=str(it.get("path", "")),
240
+ span=it.get("span"),
241
+ score=0.0,
242
+ strategy="structural",
243
+ matched_fields=("qualified_name",),
244
+ evidence=(
245
+ (f"occ:{int(it.get('occurrence_id', 0) or 0)}",)
246
+ if int(it.get("occurrence_id", 0) or 0) else ()),
247
+ )
248
+ for it in items
249
+ )
250
+ return hits, int(page.total)
251
+
252
+ def _hybrid_hits(
253
+ self, req: SearchRequest, repo_id: int, snapshot_id: int
254
+ ) -> tuple[tuple[SearchHit, ...], tuple[SearchDiagnostic, ...]]:
255
+ """Pipeline híbrido: lexical+structural+graph → RRF → página."""
256
+ diags: list[SearchDiagnostic] = []
257
+ fetch = max(20, min((req.limit or 20) * 3, 200))
258
+ rankings: dict[str, list[SearchHit]] = {}
259
+ # lf-0058: cursor NÃO pode vazar para os sub-canais — a lista
260
+ # fundida deve ser idêntica entre páginas (paginação estável).
261
+ lex_req = dataclasses.replace(req, limit=fetch, cursor=None)
262
+ lex_hits, _total, lex_diags = self._lexical.search(lex_req, snapshot_id)
263
+ diags.extend(lex_diags)
264
+ rankings["lexical"] = list(lex_hits)
265
+ st_hits, _ = self._structural_hits(
266
+ lex_req, repo_id, snapshot_id, 0)
267
+ rankings["structural"] = list(st_hits)
268
+ g_hits, _, g_diags = self._graph_hits(
269
+ lex_req, repo_id, snapshot_id)
270
+ diags.extend(d for d in g_diags if d.code != "seed-not-found")
271
+ rankings["graph"] = list(g_hits)
272
+ if self._vec:
273
+ # lf-0086: superfícies read-only (API Explorer) abrem o banco
274
+ # com query_only — o canal vetorial materializa embeddings e
275
+ # cachearia a query, o que falharia com OperationalError.
276
+ # Degradação declarada, nunca silenciosa.
277
+ ro = self._conn.execute("PRAGMA query_only").fetchone()
278
+ if ro is not None and bool(ro[0]):
279
+ diags.append(SearchDiagnostic(
280
+ code="vector-unavailable-readonly", severity="info",
281
+ message="canal vetorial desativado em conexão "
282
+ "read-only (embeddings exigem escrita)"))
283
+ else:
284
+ v_hits, _vt, v_diags = self._vector_hits(
285
+ lex_req, snapshot_id, 0)
286
+ diags.extend(
287
+ d for d in v_diags if d.code != "vector-empty")
288
+ rankings["vector"] = list(v_hits)
289
+ from code_atlas.search.intent import classify
290
+
291
+ fused = fuse(
292
+ rankings,
293
+ intent=classify(req.query) if self._rerank else None,
294
+ query=req.query if self._rerank else "",
295
+ )
296
+ return tuple(to_search_hit(c) for c in fused), tuple(diags)
297
+
298
+ def _vector_hits(
299
+ self, req: SearchRequest, snapshot_id: int, offset: int
300
+ ) -> tuple[tuple[SearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
301
+ """Busca vetorial: ensure embeddings → embed query → KNN → hits.
302
+
303
+ Backend: sqlite-vec se o extra estiver instalado, senão scan
304
+ bruteforce stdlib — capability declarada, nunca silenciosa.
305
+ """
306
+ from code_atlas.search.vector import (
307
+ BruteForceIndex,
308
+ NaiveEmbeddingProvider,
309
+ SqliteEmbeddingCache,
310
+ embed_texts_cached,
311
+ sqlite_vec_available,
312
+ )
313
+
314
+ provider = NaiveEmbeddingProvider()
315
+ model = provider.info()
316
+ if sqlite_vec_available():
317
+ from code_atlas.search.vector.index import SqliteVecIndex
318
+
319
+ index: BruteForceIndex = SqliteVecIndex(
320
+ self._conn, model.model_id, model.version, model.dim)
321
+ else:
322
+ index = BruteForceIndex(self._conn, model.model_id, model.version)
323
+ cache = SqliteEmbeddingCache(self._conn)
324
+ built = self._ensure_embeddings(
325
+ snapshot_id, provider, cache, index)
326
+ qvec, _qh, _qm = embed_texts_cached(provider, cache, [req.query])
327
+ results = index.search(
328
+ snapshot_id, qvec[0], offset + max(1, req.limit or 20))
329
+ results = results[offset:]
330
+ occ_ids = [r.occurrence_id for r in results]
331
+ info: dict[int, dict[str, object]] = {}
332
+ if occ_ids:
333
+ rows = self._conn.execute(
334
+ "SELECT o.id, s.qualified_name, s.kind, s.language,"
335
+ " sf.relative_path, o.span_start, o.span_end,"
336
+ " o.line, o.col"
337
+ " FROM symbol_occurrences o"
338
+ " JOIN source_files sf ON sf.id = o.source_file_id"
339
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
340
+ " WHERE o.id IN (SELECT value FROM json_each(?))",
341
+ (json.dumps(occ_ids),),
342
+ ).fetchall()
343
+ for r in rows:
344
+ info[int(r["id"])] = dict(r)
345
+ hits = tuple(
346
+ SearchHit(
347
+ occurrence_id=r.occurrence_id,
348
+ qualified_name=str(
349
+ info.get(r.occurrence_id, {}).get(
350
+ "qualified_name", r.symbol_key)),
351
+ kind=str(info.get(r.occurrence_id, {}).get("kind", "")),
352
+ language=str(
353
+ info.get(r.occurrence_id, {}).get("language", "")),
354
+ path=str(
355
+ info.get(r.occurrence_id, {}).get(
356
+ "relative_path", "")),
357
+ span=None,
358
+ score=round(r.score, 6),
359
+ strategy="vector",
360
+ matched_fields=("embedding",),
361
+ evidence=(
362
+ f"cosine={r.score:.4f}",
363
+ f"model={r.model_id} dim={model.dim}",
364
+ f"embeddings_built={built}",
365
+ ),
366
+ )
367
+ for r in results
368
+ if r.occurrence_id in info and r.score > 0.0
369
+ )
370
+ diags: tuple[SearchDiagnostic, ...] = ()
371
+ if not hits:
372
+ diags = (SearchDiagnostic(
373
+ code="vector-empty", severity="info",
374
+ message="sem hits vetoriais para esta query"),)
375
+ return hits, len(hits) + offset, diags
376
+
377
+ def _ensure_embeddings(
378
+ self,
379
+ snapshot_id: int,
380
+ provider: EmbeddingProvider,
381
+ cache: SqliteEmbeddingCache,
382
+ index: BruteForceIndex,
383
+ ) -> int:
384
+ """Materializa embeddings do snapshot — só os que faltam na
385
+ versão atual do modelo (rebuild por mudança de conteúdo/modelo).
386
+ Retorna quantos embeddings novos foram gerados."""
387
+ from code_atlas.search.vector.embeddings import (
388
+ content_hash,
389
+ embed_texts_cached,
390
+ index_text_for,
391
+ )
392
+
393
+ model = provider.info()
394
+ rows = self._conn.execute(
395
+ "SELECT f.rowid AS occ_id, s.qualified_name, f.signature,"
396
+ " f.docstring FROM fts_occurrences f"
397
+ " JOIN symbol_occurrences o ON o.id = f.rowid"
398
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
399
+ " WHERE f.snapshot_id = ?",
400
+ (snapshot_id,),
401
+ ).fetchall()
402
+ pending: list[tuple[int, str, str, str]] = []
403
+ for r in rows:
404
+ text = index_text_for(
405
+ str(r["qualified_name"]),
406
+ str(r["signature"] or ""), str(r["docstring"] or ""))
407
+ pending.append(
408
+ (int(r["occ_id"]), str(r["qualified_name"]),
409
+ text, content_hash(text)))
410
+ existing = {
411
+ (int(x["occurrence_id"]), str(x["content_hash"]))
412
+ for x in self._conn.execute(
413
+ "SELECT occurrence_id, content_hash "
414
+ "FROM vector_embeddings "
415
+ "WHERE snapshot_id = ? AND model_id = ?"
416
+ " AND model_version = ?",
417
+ (snapshot_id, model.model_id, model.version),
418
+ ).fetchall()
419
+ }
420
+ todo = [p for p in pending if (p[0], p[3]) not in existing]
421
+ if not todo:
422
+ return 0
423
+ vecs, _h, _m = embed_texts_cached(
424
+ provider, cache, [t[2] for t in todo])
425
+ for (occ_id, sym, _text, chash), vec in zip(
426
+ todo, vecs, strict=True):
427
+ index.upsert(
428
+ snapshot_id, occ_id, sym, vec, content_hash=chash)
429
+ return len(todo)
430
+
431
+ def _graph_hits(
432
+ self, req: SearchRequest, repo_id: int, snapshot_id: int
433
+ ) -> tuple[tuple[SearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
434
+ """Multi-seed → BFS limitado com direção por intent (lf-0049).
435
+
436
+ Seeds: qualified_name exato + top lexical + top structural —
437
+ dedup por occurrence_id. Seeds entram como hits rotulados
438
+ ``graph-seed`` (casaram com a query por nome — honesto).
439
+ Direção vem de ``classify(query)`` — nunca inventa aresta.
440
+
441
+ lf-0119: seeds são coletados direto nos canais snapshot-scoped
442
+ (sem re-resolver repo+status via ``search()``) — funciona em
443
+ pins STALE da federação e não vaza o cursor da página externa
444
+ para a janela de seeds."""
445
+ from code_atlas.search.graph import TraversalLimits
446
+ from code_atlas.search.intent import classify
447
+
448
+ intent = classify(req.query)
449
+ seed_ids = set(self._graph.seed_occurrences(
450
+ snapshot_id, req.query))
451
+ seed_req = dataclasses.replace(req, limit=3, cursor=None)
452
+ lex_hits, _lt, _ld = self._lexical.search(seed_req, snapshot_id)
453
+ st_hits, _st = self._structural_hits(
454
+ seed_req, repo_id, snapshot_id, 0)
455
+ seed_hits: list[SearchHit] = []
456
+ for h in (*lex_hits, *st_hits):
457
+ if h.occurrence_id not in seed_ids:
458
+ seed_ids.add(h.occurrence_id)
459
+ seed_hits.append(h)
460
+ if not seed_ids:
461
+ return (), 0, (SearchDiagnostic(
462
+ code="seed-not-found", severity="warning",
463
+ message=f"nenhuma ocorrência casa '{req.query}' no "
464
+ f"snapshot"),)
465
+ lim = TraversalLimits(
466
+ depth=3, max_nodes=500, max_edges=2000, max_ms=500,
467
+ max_results=max(1, min(req.limit or 20, 200)),
468
+ )
469
+ hits, stats = self._graph.traverse(
470
+ snapshot_id, list(seed_ids), direction=intent.direction,
471
+ limits=lim)
472
+ diags: list[SearchDiagnostic] = [
473
+ SearchDiagnostic(
474
+ code="graph-intent", severity="info",
475
+ message=f"intent={intent.task_type} "
476
+ f"direction={intent.direction} "
477
+ f"seeds={len(seed_ids)}"),
478
+ ]
479
+ if stats.truncated_by:
480
+ diags.append(SearchDiagnostic(
481
+ code="traversal-truncated", severity="info",
482
+ message=f"travessia truncada por limite: "
483
+ f"{stats.truncated_by}"))
484
+ seed_out = [
485
+ SearchHit(
486
+ occurrence_id=h.occurrence_id,
487
+ qualified_name=h.qualified_name, kind=h.kind,
488
+ language=h.language, path=h.path, span=h.span,
489
+ score=h.score, strategy="graph",
490
+ matched_fields=("graph-seed", *h.matched_fields),
491
+ evidence=h.evidence,
492
+ )
493
+ for h in seed_hits
494
+ ]
495
+ out = tuple(seed_out) + tuple(
496
+ SearchHit(
497
+ occurrence_id=h.occurrence_id,
498
+ qualified_name=h.qualified_name, kind=h.kind,
499
+ language=h.language, path=h.path, span=None,
500
+ score=h.score, strategy="graph",
501
+ matched_fields=("graph",),
502
+ evidence=tuple(
503
+ f"dir={intent.direction}:"
504
+ f"{s.relation}:{s.from_id}->{s.to_id}"
505
+ f"({s.resolution_status})"
506
+ for s in h.steps
507
+ ),
508
+ )
509
+ for h in hits
510
+ )
511
+ return out, len(out), tuple(diags)
512
+
513
+ def _empty(
514
+ self,
515
+ req: SearchRequest,
516
+ diags: list[SearchDiagnostic],
517
+ snapshot_id: int = 0,
518
+ ) -> SearchResult:
519
+ return SearchResult(
520
+ schema_version=SEARCH_SCHEMA_VERSION,
521
+ repository=req.repository_root,
522
+ snapshot_id=snapshot_id,
523
+ query=req.query,
524
+ strategy=req.strategy,
525
+ ranking_version=RANKING_VERSION,
526
+ items=(),
527
+ limit=req.limit,
528
+ truncated=False,
529
+ diagnostics=tuple(diags),
530
+ )