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,561 @@
1
+ """Faceta de busca sobre entidades de domínio (lf-0119, Cycle 7 Ph11;
2
+ FTS dedicado em lf-0133, Cycle 8 Phase 7).
3
+
4
+ A correspondência direta usa ``domain_fts`` (schema v12) quando a
5
+ tabela existe **e** cobre o snapshot consultado — BM25 ordena os
6
+ candidatos dentro do teto de scan. Sem cobertura (banco antigo,
7
+ snapshot pré-v12, build sem FTS5, query não tokenizável) cai no LIKE
8
+ escapado **bounded** de sempre sobre ``domain_entity_occurrences``
9
+ ancoradas no snapshot — nunca resultado vazio silencioso: a queda é
10
+ declarada via diagnóstico. Nunca se funde com hits de símbolo: sai
11
+ como faceta separada ``domain_items`` no envelope, um item por
12
+ entidade lógica, com a contagem de evidência (ocorrências no snapshot).
13
+
14
+ Semântica honesta de cada caminho (diferenças declaradas, não
15
+ escondidas): LIKE é match de substring case-insensitive ASCII; FTS5
16
+ ``unicode61`` casa por token com case-folding e remoção de diacríticos
17
+ (``sao`` casa ``São`` no FTS, não no LIKE) e a coluna ``terms`` cobre
18
+ fragmentos de identificador (``orders`` casa ``v_orders`` por token).
19
+
20
+ Estados honestos declarados via ``SearchDiagnostic``:
21
+
22
+ - tabela de domínio ausente (schema <v9) → faceta omitida +
23
+ ``domain-facet-unavailable``;
24
+ - filtro ``language`` ativo → entidades de domínio não carregam
25
+ linguagem: faceta omitida + ``domain-facet-skipped`` (nunca um
26
+ narrowing silencioso/falso);
27
+ - ``domain_fts`` existe mas não cobre o snapshot → LIKE bounded +
28
+ ``domain-fts-fallback``;
29
+ - MATCH FTS inválida → ``domain-fts-error`` + queda para LIKE;
30
+ - mais candidatos que o teto de scan → ``domain-facet-capped`` com o
31
+ total real;
32
+ - expansão ``via_relation`` cortada pelo teto de arestas →
33
+ ``domain-related-capped``.
34
+
35
+ ``via_relation`` (relationship search mínimo): entidades que são
36
+ endpoint entity↔entity **resolvido** de ``domain_relations`` adjacente
37
+ a uma entidade que casou com ``q`` — a aresta real é citada em
38
+ ``evidence`` como ``via=TYPE:rel:<id>``. Nenhuma aresta é inventada:
39
+ só rows persistidos do snapshot. Sem match direto → nenhuma expansão.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import sqlite3
46
+
47
+ from code_atlas.search.contracts import (
48
+ DomainSearchHit,
49
+ SearchDiagnostic,
50
+ SearchRequest,
51
+ )
52
+ from code_atlas.search.tokenizer import fts_escape, split_identifier
53
+
54
+ # Tetos honestos da faceta — declarados em diagnóstico quando batidos.
55
+ _CANDIDATE_CAP = 200 # entidades candidatas escaneadas (match direto)
56
+ _RELATED_EDGE_CAP = 500 # arestas entity↔entity lidas na expansão
57
+ _MAX_LIMIT = 200
58
+ _RELATED_SCORE = 0.5
59
+ _DIRECT_SCORE = 1.0
60
+ _EXACT_SCORE = 2.0
61
+
62
+ # Query agrupada por entidade: flags de match como agregados MAX(CASE)
63
+ # (honestas por construção); ``de.*`` são chave do GROUP BY (PK →
64
+ # constantes por grupo). path/line/extraction_status da ocorrência
65
+ # representativa (MIN deo.id) são hidratados por query separada —
66
+ # SQLite só garante bare-column↔MIN com UM único agregado min/max.
67
+ _DIRECT_SELECT = """
68
+ SELECT de.id AS entity_id, de.domain, de.kind, de.identity_key,
69
+ de.qualified_name, de.name, de.catalog, de.namespace,
70
+ de.environment,
71
+ COUNT(*) AS occurrences,
72
+ MIN(deo.id) AS first_occurrence_id,
73
+ MAX(CASE WHEN sf.relative_path LIKE ? ESCAPE '\\'
74
+ THEN 1 ELSE 0 END) AS m_path,
75
+ MAX(de.name = ? OR de.qualified_name = ?) AS m_exact
76
+ FROM domain_entities de
77
+ JOIN domain_entity_occurrences deo
78
+ ON deo.entity_id = de.id AND deo.snapshot_id = ?
79
+ LEFT JOIN source_files sf ON sf.id = deo.source_file_id
80
+ """
81
+
82
+ # Pesos bm25 por coluna de ``domain_fts`` (ordem do CREATE v12):
83
+ # snapshot_id, entity_id, name, qualified_name, terms, path, domain,
84
+ # kind → 8 posições. ``name``/``qualified_name`` guardam a forma exata
85
+ # (peso maior); ``terms`` cobre fragmentos tokenizados; ``path`` é
86
+ # âncora fraca; escopos UNINDEXED não pontuam.
87
+ _FTS_BM25_WEIGHTS = "0.0, 0.0, 10.0, 8.0, 6.0, 1.0, 0.0, 0.0"
88
+
89
+ # Mesma projeção agregada de _DIRECT_SELECT, dirigida por MATCH: as
90
+ # rows candidatas vêm do índice FTS (rowid = domain_entity_occurrences.id)
91
+ # e ``de`` hidrata a entidade — o LIKE de path opera sobre a coluna
92
+ # armazenada ``f.path`` (mesma semântica de ``sf.relative_path``).
93
+ # ``bm25`` é função auxiliar do FTS5 e não pode viver dentro de
94
+ # agregado — a subquery com ``LIMIT -1`` (impede flattening) projeta o
95
+ # rank por row e o agregado externo toma ``MIN(f.bm)``.
96
+ _FTS_SELECT = (
97
+ "SELECT de.id AS entity_id, de.domain, de.kind, de.identity_key,"
98
+ " de.qualified_name, de.name, de.catalog, de.namespace,"
99
+ " de.environment,"
100
+ " COUNT(*) AS occurrences,"
101
+ " MIN(f.rowid) AS first_occurrence_id,"
102
+ " MAX(CASE WHEN f.path LIKE ? ESCAPE '\\'"
103
+ " THEN 1 ELSE 0 END) AS m_path,"
104
+ " MAX(de.name = ? OR de.qualified_name = ?) AS m_exact,"
105
+ " MIN(f.bm) AS m_rank"
106
+ " FROM (SELECT rowid, entity_id, path,"
107
+ f" bm25(domain_fts, {_FTS_BM25_WEIGHTS}) AS bm"
108
+ " FROM domain_fts"
109
+ " WHERE domain_fts MATCH ? AND snapshot_id = ? LIMIT -1) f"
110
+ " JOIN domain_entities de ON de.id = f.entity_id "
111
+ )
112
+
113
+
114
+ def _esc_like(term: str) -> str:
115
+ """Escapa curingas LIKE — input do usuário nunca vira padrão."""
116
+ return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
117
+
118
+
119
+ def _fts_match(query: str) -> str | None:
120
+ """Expressão MATCH para ``domain_fts`` — ``None`` quando não tokenizável.
121
+
122
+ Mesmo desenho de ``lexical.fts_query``: fragmentos por
123
+ ``split_identifier`` em OR nas colunas indexadas + formas exatas em
124
+ ``name``/``qualified_name``; sufixo ``*`` vira prefixo em ``terms``.
125
+ """
126
+ q = query.strip()
127
+ if not q:
128
+ return None
129
+ if q.endswith("*") and len(q) > 1:
130
+ stem = q[:-1]
131
+ toks = split_identifier(stem)
132
+ return f"terms : {fts_escape(toks[-1] if toks else stem)}*"
133
+ toks = split_identifier(q)
134
+ if not toks:
135
+ return None
136
+ or_terms = " OR ".join(fts_escape(t) for t in toks)
137
+ return (
138
+ f"{{name qualified_name terms path}} : ({or_terms})"
139
+ f" OR name : {fts_escape(q)}"
140
+ f" OR qualified_name : {fts_escape(q)}"
141
+ )
142
+
143
+
144
+ class DomainEntitySearch:
145
+ """Faceta de entidades de domínio — FTS preferido, LIKE bounded."""
146
+
147
+ def __init__(self, conn: sqlite3.Connection) -> None:
148
+ self._conn = conn
149
+ self._available: bool | None = None
150
+ self._fts_present: bool | None = None
151
+ self._fts_covered: dict[int, bool] = {}
152
+
153
+ def available(self) -> bool:
154
+ """Schema >=v9 presente — cacheada por instância."""
155
+ if self._available is None:
156
+ self._available = self._conn.execute(
157
+ "SELECT 1 FROM sqlite_master"
158
+ " WHERE name='domain_entity_occurrences'"
159
+ ).fetchone() is not None
160
+ return self._available
161
+
162
+ def _fts_usable_for(self, snapshot_id: int) -> bool:
163
+ """``domain_fts`` presente E cobrindo ``snapshot_id``.
164
+
165
+ Cobertura é por snapshot: só o persist pós-v12 grava rows —
166
+ snapshots antigas seguem no LIKE honesto. Cacheado por
167
+ instância (uma SearchEngine por request nas superfícies).
168
+ """
169
+ if self._fts_present is None:
170
+ self._fts_present = self._conn.execute(
171
+ "SELECT 1 FROM sqlite_master WHERE name='domain_fts'"
172
+ ).fetchone() is not None
173
+ if not self._fts_present:
174
+ return False
175
+ if snapshot_id not in self._fts_covered:
176
+ self._fts_covered[snapshot_id] = self._conn.execute(
177
+ "SELECT 1 FROM domain_fts WHERE snapshot_id = ? LIMIT 1",
178
+ (snapshot_id,),
179
+ ).fetchone() is not None
180
+ return self._fts_covered[snapshot_id]
181
+
182
+ def search(
183
+ self,
184
+ req: SearchRequest,
185
+ repository_id: int,
186
+ snapshot_id: int,
187
+ *,
188
+ include_related: bool = True,
189
+ ) -> tuple[tuple[DomainSearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
190
+ """``(items, total, diagnostics)`` — faceta top-``limit`` honesta.
191
+
192
+ ``total`` = matches diretos (COUNT real, não o teto de scan) +
193
+ entidades adjacentes ``via_relation`` encontradas.
194
+ """
195
+ diags: list[SearchDiagnostic] = []
196
+ if not self.available():
197
+ return (), 0, (SearchDiagnostic(
198
+ code="domain-facet-unavailable", severity="info",
199
+ message="tabelas de domínio ausentes (schema <v9)"),)
200
+ if req.language:
201
+ return (), 0, (SearchDiagnostic(
202
+ code="domain-facet-skipped", severity="info",
203
+ message="filtro language não se aplica a entidades de "
204
+ "domínio — faceta omitida"),)
205
+ q = req.query.strip()
206
+ if not q:
207
+ return (), 0, ()
208
+ limit = max(1, min(req.limit or 20, _MAX_LIMIT))
209
+
210
+ rows, total, capped, fb_diag, used_fts = self._direct_rows(
211
+ req, repository_id, snapshot_id, q)
212
+ if fb_diag is not None:
213
+ diags.append(fb_diag)
214
+ if capped:
215
+ diags.append(SearchDiagnostic(
216
+ code="domain-facet-capped", severity="info",
217
+ message=f"faceta de domínio escaneou os primeiros "
218
+ f"{_CANDIDATE_CAP} de {total} matches diretos"))
219
+ direct_ids = [int(r["entity_id"]) for r in rows]
220
+ occ_paths = self._representative_occurrences(
221
+ snapshot_id, [int(r["first_occurrence_id"]) for r in rows])
222
+
223
+ hits = [
224
+ self._direct_hit(
225
+ r, q, occ_paths.get(int(r["first_occurrence_id"]), {}),
226
+ repository_id, snapshot_id, used_fts)
227
+ for r in rows
228
+ ]
229
+
230
+ related_total = 0
231
+ if include_related and hits:
232
+ related, rel_diags = self._related_hits(
233
+ req, repository_id, snapshot_id, set(direct_ids))
234
+ diags.extend(rel_diags)
235
+ related_total = len(related)
236
+ hits.extend(related)
237
+
238
+ # Ordem determinística: exato > direto > via_relation, depois
239
+ # identidade da entidade — nunca depende de ordem de scan.
240
+ hits.sort(key=lambda h: (
241
+ -h.score, h.domain, h.kind, h.qualified_name, h.entity_id))
242
+ return tuple(hits[:limit]), total + related_total, tuple(diags)
243
+
244
+ @staticmethod
245
+ def _direct_hit(
246
+ r: sqlite3.Row,
247
+ q: str,
248
+ occ: dict[str, object],
249
+ repository_id: int,
250
+ snapshot_id: int,
251
+ used_fts: bool,
252
+ ) -> DomainSearchHit:
253
+ """Hit direto — ``matched_fields`` honesto sobre COMO casou."""
254
+ occ_line = occ.get("line")
255
+ fields: list[str] = []
256
+ if q.lower() in str(r["name"]).lower():
257
+ fields.append("name")
258
+ if q.lower() in str(r["qualified_name"]).lower():
259
+ fields.append("qualified_name")
260
+ if int(r["m_path"]):
261
+ fields.append("path")
262
+ if used_fts and not fields:
263
+ # Match por token no índice (coluna ``terms`` ou forma
264
+ # normalizada do nome) que o teste de substring não
265
+ # reproduz — declarado, nunca vazio/ambíguo.
266
+ fields.append("terms")
267
+ return DomainSearchHit(
268
+ entity_id=int(r["entity_id"]),
269
+ occurrence_id=int(r["first_occurrence_id"]),
270
+ domain=str(r["domain"]), kind=str(r["kind"]),
271
+ qualified_name=str(r["qualified_name"]),
272
+ name=str(r["name"]),
273
+ identity_key=str(r["identity_key"]),
274
+ catalog=str(r["catalog"]),
275
+ namespace=str(r["namespace"]),
276
+ environment=str(r["environment"]),
277
+ path=str(occ.get("path") or ""),
278
+ line=(occ_line if isinstance(occ_line, int) else None),
279
+ occurrences=int(r["occurrences"]),
280
+ extraction_status=str(occ.get("status") or ""),
281
+ repository_id=repository_id,
282
+ snapshot_id=snapshot_id,
283
+ score=_EXACT_SCORE if r["m_exact"] else _DIRECT_SCORE,
284
+ matched_fields=tuple(fields),
285
+ evidence=(f"de:{int(r['entity_id'])}",),
286
+ )
287
+
288
+ # --- match direto -------------------------------------------------
289
+
290
+ def _direct_rows(
291
+ self,
292
+ req: SearchRequest,
293
+ repository_id: int,
294
+ snapshot_id: int,
295
+ q: str,
296
+ ) -> tuple[
297
+ list[sqlite3.Row], int, bool, SearchDiagnostic | None, bool
298
+ ]:
299
+ """Despacha FTS→LIKE — ``(rows, total, capped, diag, used_fts)``."""
300
+ if self._fts_usable_for(snapshot_id):
301
+ try:
302
+ out = self._direct_rows_fts(
303
+ req, repository_id, snapshot_id, q)
304
+ except sqlite3.OperationalError as exc:
305
+ return (*self._direct_rows_like(
306
+ req, repository_id, snapshot_id, q),
307
+ SearchDiagnostic(
308
+ code="domain-fts-error", severity="warning",
309
+ message=f"MATCH de domínio falhou ({exc}) — "
310
+ "LIKE bounded aplicado"), False)
311
+ if out is not None:
312
+ return *out, None, True
313
+ # query não expressível em MATCH (sem tokens): LIKE cobre.
314
+ return (*self._direct_rows_like(
315
+ req, repository_id, snapshot_id, q), None, False)
316
+ if self._fts_present:
317
+ return (*self._direct_rows_like(
318
+ req, repository_id, snapshot_id, q),
319
+ SearchDiagnostic(
320
+ code="domain-fts-fallback", severity="info",
321
+ message="snapshot sem cobertura domain_fts (pré-v12) "
322
+ "— LIKE bounded aplicado"), False)
323
+ return (*self._direct_rows_like(
324
+ req, repository_id, snapshot_id, q), None, False)
325
+
326
+ def _direct_rows_fts(
327
+ self,
328
+ req: SearchRequest,
329
+ repository_id: int,
330
+ snapshot_id: int,
331
+ q: str,
332
+ ) -> tuple[list[sqlite3.Row], int, bool] | None:
333
+ """Candidatos via ``domain_fts`` — ``None`` = MATCH não expressível.
334
+
335
+ BM25 (``m_rank``) ordena os candidatos dentro do teto de scan —
336
+ exato primeiro, depois relevância, desempate por identidade. A
337
+ ordem pública final continua a chave determinística do
338
+ docstring de ``search`` (score > identidade): o rank decide só
339
+ quais entidades entram no scan, nunca o contrato do envelope.
340
+ """
341
+ match = _fts_match(q)
342
+ if match is None:
343
+ return None
344
+ like = f"%{_esc_like(q)}%"
345
+ conds: list[str] = []
346
+ params: list[object] = []
347
+ if req.domain:
348
+ conds.append("de.domain = ?")
349
+ params.append(req.domain)
350
+ if req.kind:
351
+ conds.append("de.kind = ?")
352
+ params.append(req.kind)
353
+ if req.path:
354
+ conds.append("f.path LIKE ? ESCAPE '\\'")
355
+ params.append(f"%{_esc_like(req.path)}%")
356
+ extra = (" AND " + " AND ".join(conds)) if conds else ""
357
+ outer = "WHERE de.repository_id = ?" + extra
358
+ # SELECT params (m_path like, m_exact q,q) precedem a subquery
359
+ # (match, snapshot) — ordem textual dos placeholders.
360
+ sel_params = [like, q, q, match, snapshot_id, repository_id, *params]
361
+ list_sql = (
362
+ _FTS_SELECT + outer
363
+ + " GROUP BY de.id"
364
+ " ORDER BY m_exact DESC, m_rank,"
365
+ " de.domain, de.kind, de.qualified_name, de.id LIMIT ?"
366
+ )
367
+ count_sql = (
368
+ "SELECT COUNT(DISTINCT f.entity_id) AS n"
369
+ " FROM (SELECT entity_id FROM domain_fts"
370
+ " WHERE domain_fts MATCH ? AND snapshot_id = ? LIMIT -1) f"
371
+ " JOIN domain_entities de ON de.id = f.entity_id " + outer
372
+ )
373
+ rows = self._conn.execute(
374
+ list_sql, [*sel_params, _CANDIDATE_CAP]).fetchall()
375
+ total = int(self._conn.execute(
376
+ count_sql, [match, snapshot_id, repository_id, *params]
377
+ ).fetchone()["n"])
378
+ return rows, total, total > _CANDIDATE_CAP
379
+
380
+ def _direct_rows_like(
381
+ self,
382
+ req: SearchRequest,
383
+ repository_id: int,
384
+ snapshot_id: int,
385
+ q: str,
386
+ ) -> tuple[list[sqlite3.Row], int, bool]:
387
+ """Candidatos LIKE bounded — ``(rows, total_real, capped)``."""
388
+ like = f"%{_esc_like(q)}%"
389
+ conds = [
390
+ "(de.name LIKE ? ESCAPE '\\'"
391
+ " OR de.qualified_name LIKE ? ESCAPE '\\'"
392
+ " OR sf.relative_path LIKE ? ESCAPE '\\')",
393
+ ]
394
+ params: list[object] = [like, like, like]
395
+ if req.domain:
396
+ conds.append("de.domain = ?")
397
+ params.append(req.domain)
398
+ if req.kind:
399
+ conds.append("de.kind = ?")
400
+ params.append(req.kind)
401
+ if req.path:
402
+ conds.append("sf.relative_path LIKE ? ESCAPE '\\'")
403
+ params.append(f"%{_esc_like(req.path)}%")
404
+ where = " AND ".join(conds)
405
+ base = _DIRECT_SELECT + f" WHERE de.repository_id = ? AND {where}"
406
+ # SELECT params (m_path like, m_exact q) precedem os do WHERE.
407
+ sel_params = [like, q, q, snapshot_id, repository_id, *params]
408
+ # exact-match primeiro — o teto de scan nunca esconde o melhor
409
+ # candidato; desempate pela identidade da entidade.
410
+ list_sql = (
411
+ base + " GROUP BY de.id"
412
+ " ORDER BY m_exact DESC, de.domain, de.kind,"
413
+ " de.qualified_name, de.id LIMIT ?"
414
+ )
415
+ count_sql = (
416
+ "SELECT COUNT(DISTINCT de.id) AS n "
417
+ "FROM domain_entities de "
418
+ "JOIN domain_entity_occurrences deo"
419
+ " ON deo.entity_id = de.id AND deo.snapshot_id = ? "
420
+ "LEFT JOIN source_files sf ON sf.id = deo.source_file_id "
421
+ f"WHERE de.repository_id = ? AND {where}"
422
+ )
423
+ rows = self._conn.execute(
424
+ list_sql, [*sel_params, _CANDIDATE_CAP]).fetchall()
425
+ total = int(self._conn.execute(
426
+ count_sql, [snapshot_id, repository_id, *params]
427
+ ).fetchone()["n"])
428
+ return rows, total, total > _CANDIDATE_CAP
429
+
430
+ def _representative_occurrences(
431
+ self, snapshot_id: int, occ_ids: list[int]
432
+ ) -> dict[int, dict[str, object]]:
433
+ """path/line/status da ocorrência representativa (MIN deo.id)."""
434
+ if not occ_ids:
435
+ return {}
436
+ rows = self._conn.execute(
437
+ "SELECT deo.id, sf.relative_path AS path, deo.line,"
438
+ " deo.extraction_status AS status"
439
+ " FROM domain_entity_occurrences deo"
440
+ " LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
441
+ " WHERE deo.id IN (SELECT value FROM json_each(?))",
442
+ (json.dumps(occ_ids),),
443
+ ).fetchall()
444
+ return {int(r["id"]): dict(r) for r in rows}
445
+
446
+ # --- expansão via_relation (resolved targets de domain_relations) --
447
+
448
+ def _related_hits(
449
+ self,
450
+ req: SearchRequest,
451
+ repository_id: int,
452
+ snapshot_id: int,
453
+ direct_ids: set[int],
454
+ ) -> tuple[list[DomainSearchHit], tuple[SearchDiagnostic, ...]]:
455
+ """Entidades adjacentes a matches diretos por arestas REAIS.
456
+
457
+ Lê ``domain_relations`` do snapshot com ambos os endpoints
458
+ entity-occurrence resolvidos; o "outro lado" de uma aresta que
459
+ toca um match direto entra como hit ``via_relation`` —
460
+ deduplicado contra os matches diretos e contra si mesmo
461
+ (primeira aresta por ordem de ``r.id`` declara a proveniência).
462
+ A expansão é por aresta, nunca por nome — ``q`` já casou.
463
+ """
464
+ diags: list[SearchDiagnostic] = []
465
+ ids_json = json.dumps(sorted(direct_ids))
466
+ # UNION de dois probes indexados em vez de OR entre aliases —
467
+ # o OR cruzava duas tabelas (fe/te) e impedia multi-index OR,
468
+ # forçando scan de domain_relations. Cada braço usa os índices
469
+ # v11 (snapshot_id, from/to_entity_occ_id); UNION já deduplica
470
+ # arestas com ambos os endpoints no conjunto direto.
471
+ edge_rows = self._conn.execute(
472
+ "SELECT r.id AS rel_id, r.type, fe.entity_id AS from_entity,"
473
+ " te.entity_id AS to_entity"
474
+ " FROM domain_relations r"
475
+ " JOIN domain_entity_occurrences fe"
476
+ " ON fe.id = r.from_entity_occ_id"
477
+ " JOIN domain_entity_occurrences te"
478
+ " ON te.id = r.to_entity_occ_id"
479
+ " WHERE r.snapshot_id = ?"
480
+ " AND fe.entity_id IN (SELECT value FROM json_each(?))"
481
+ " UNION "
482
+ "SELECT r.id AS rel_id, r.type, fe.entity_id AS from_entity,"
483
+ " te.entity_id AS to_entity"
484
+ " FROM domain_relations r"
485
+ " JOIN domain_entity_occurrences fe"
486
+ " ON fe.id = r.from_entity_occ_id"
487
+ " JOIN domain_entity_occurrences te"
488
+ " ON te.id = r.to_entity_occ_id"
489
+ " WHERE r.snapshot_id = ?"
490
+ " AND te.entity_id IN (SELECT value FROM json_each(?))"
491
+ " ORDER BY rel_id LIMIT ?",
492
+ (snapshot_id, ids_json, snapshot_id, ids_json,
493
+ _RELATED_EDGE_CAP),
494
+ ).fetchall()
495
+ if len(edge_rows) >= _RELATED_EDGE_CAP:
496
+ diags.append(SearchDiagnostic(
497
+ code="domain-related-capped", severity="info",
498
+ message=f"expansão via_relation leu o teto de "
499
+ f"{_RELATED_EDGE_CAP} arestas"))
500
+ via: dict[int, str] = {}
501
+ for r in edge_rows:
502
+ fe, te = int(r["from_entity"]), int(r["to_entity"])
503
+ other = te if fe in direct_ids else fe
504
+ if other in direct_ids or other in via:
505
+ continue
506
+ via[other] = f"via={r['type']}:rel:{int(r['rel_id'])}"
507
+ if not via:
508
+ return [], tuple(diags)
509
+ extra_conds = ""
510
+ params: list[object] = [snapshot_id, repository_id,
511
+ json.dumps(sorted(via))]
512
+ if req.domain:
513
+ extra_conds += " AND de.domain = ?"
514
+ params.append(req.domain)
515
+ if req.kind:
516
+ extra_conds += " AND de.kind = ?"
517
+ params.append(req.kind)
518
+ related_sql = (
519
+ "SELECT de.id AS entity_id, de.domain, de.kind,"
520
+ " de.identity_key, de.qualified_name, de.name, de.catalog,"
521
+ " de.namespace, de.environment,"
522
+ " COUNT(*) AS occurrences,"
523
+ " MIN(deo.id) AS first_occurrence_id,"
524
+ " sf.relative_path AS path, deo.line AS line,"
525
+ " deo.extraction_status AS status"
526
+ " FROM domain_entities de"
527
+ " JOIN domain_entity_occurrences deo"
528
+ " ON deo.entity_id = de.id AND deo.snapshot_id = ?"
529
+ " LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
530
+ " WHERE de.repository_id = ?"
531
+ " AND de.id IN (SELECT value FROM json_each(?))"
532
+ + extra_conds
533
+ + " GROUP BY de.id"
534
+ " ORDER BY de.domain, de.kind, de.qualified_name, de.id"
535
+ )
536
+ rows = self._conn.execute(related_sql, params).fetchall()
537
+ hits = [
538
+ DomainSearchHit(
539
+ entity_id=int(r["entity_id"]),
540
+ occurrence_id=int(r["first_occurrence_id"]),
541
+ domain=str(r["domain"]), kind=str(r["kind"]),
542
+ qualified_name=str(r["qualified_name"]),
543
+ name=str(r["name"]),
544
+ identity_key=str(r["identity_key"]),
545
+ catalog=str(r["catalog"]),
546
+ namespace=str(r["namespace"]),
547
+ environment=str(r["environment"]),
548
+ path=str(r["path"] or ""),
549
+ line=(None if r["line"] is None else int(r["line"])),
550
+ occurrences=int(r["occurrences"]),
551
+ extraction_status=str(r["status"] or ""),
552
+ repository_id=repository_id,
553
+ snapshot_id=snapshot_id,
554
+ score=_RELATED_SCORE,
555
+ matched_fields=("via_relation",),
556
+ evidence=(f"de:{int(r['entity_id'])}",
557
+ via[int(r["entity_id"])]),
558
+ )
559
+ for r in rows
560
+ ]
561
+ return hits, tuple(diags)