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,151 @@
1
+ """Fusão híbrida de candidatos — Reciprocal Rank Fusion (lf-0038 /
2
+ Cycle 4 Ph5).
3
+
4
+ Pipeline: lexical + structural + graph (vetorial quando existir) →
5
+ dedup por identidade canônica do símbolo (``qualified_name``)
6
+ preservando a participação e as ocorrências de cada estratégia →
7
+ score RRF → página.
8
+
9
+ - ``RRF_K = 60``: constante padrão da literatura (Cormack et al. 2009)
10
+ — documentada; variantes só com evidência de ganho.
11
+ - Scores crus de backends distintos NUNCA são comparados — RRF opera
12
+ sobre POSIÇÕES (rank), não sobre score.
13
+ - Sem provider vetorial, ``hybrid`` funciona sobre as 3 estratégias
14
+ reais — degradação honesta, declarada no hit.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, replace
20
+
21
+ from code_atlas.search.contracts import SearchHit
22
+ from code_atlas.search.intent import QueryIntent
23
+
24
+ RRF_K = 60
25
+ RANKING_VERSION_HYBRID = "rrf-k60-v2"
26
+
27
+ # Fatores do rerank task-aware (lf-0050) — constantes documentadas,
28
+ # NUNCA arbitrárias: qualquer alteração exige benchmark em
29
+ # eval-report (comparação v1-vs-v2 no mesmo dataset).
30
+ BOOST_EXACT_QNAME = 1.5 # último segmento do qname == query (locate)
31
+ BOOST_DIRECTION = 1.25 # evidência de passo na direção do intent
32
+ DEMOTE_NO_EVIDENCE = 0.9 # hit sem nenhuma evidência explicável
33
+
34
+
35
+ def canonical_key(h: SearchHit) -> str:
36
+ """Identidade canônica do símbolo (lf-0050).
37
+
38
+ Não basta ``qualified_name``: identidades diferentes com o mesmo
39
+ nome (linguagem/kind distintos) NÃO podem fundir. ``qname`` já
40
+ carrega escopo (path/módulo); repo+snapshot são fixos por
41
+ consulta (isolamento garantido a montante)."""
42
+ return f"{h.language}:{h.kind}:{h.qualified_name}"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class FusedCandidate:
47
+ """Símbolo deduplicado com participação por estratégia."""
48
+
49
+ symbol_key: str # canonical_key composta (lang:kind:qname)
50
+ qualified_name: str
51
+ hits: tuple[SearchHit, ...] # ocorrências por estratégia
52
+ ranks: dict[str, int] # strategy → melhor posição (1-based)
53
+ rrf_score: float
54
+ rerank_score: float = 0.0 # score final pós-rerank (0 = =rrf)
55
+ rerank_reasons: tuple[str, ...] = ()
56
+
57
+
58
+ def fuse(
59
+ rankings: dict[str, list[SearchHit]],
60
+ *,
61
+ k: int = RRF_K,
62
+ weights: dict[str, float] | None = None,
63
+ intent: QueryIntent | None = None,
64
+ query: str = "",
65
+ ) -> list[FusedCandidate]:
66
+ """RRF sobre rankings de várias estratégias.
67
+
68
+ ``rankings``: strategy → hits ordenados (posição = rank).
69
+ Dedup por ``canonical_key`` preserva evidências: hits de todas as
70
+ estratégias ficam em ``hits`` (nunca descartados); o primário é o
71
+ de melhor rank (menor occurrence_id em empate — determinístico).
72
+ ``intent``/``query`` ativam o rerank task-aware — sem eles o
73
+ ranking é RRF puro (v1-compatível)."""
74
+ w = weights or {}
75
+ by_sym: dict[str, dict[str, int]] = {}
76
+ hits_by_sym: dict[str, list[SearchHit]] = {}
77
+ for strategy, hits in rankings.items():
78
+ for pos, h in enumerate(hits, start=1):
79
+ key = canonical_key(h)
80
+ ranks = by_sym.setdefault(key, {})
81
+ if pos < ranks.get(strategy, 1 << 30):
82
+ ranks[strategy] = pos
83
+ hits_by_sym.setdefault(key, []).append(h)
84
+ out: list[FusedCandidate] = []
85
+ for sym, ranks in by_sym.items():
86
+ score = sum(
87
+ w.get(s, 1.0) / (k + rank) for s, rank in ranks.items()
88
+ )
89
+ primaries = sorted(
90
+ hits_by_sym[sym],
91
+ key=lambda h: (min(ranks.values()), h.occurrence_id),
92
+ )
93
+ out.append(FusedCandidate(
94
+ symbol_key=sym,
95
+ qualified_name=primaries[0].qualified_name,
96
+ hits=tuple(primaries),
97
+ ranks=ranks,
98
+ rrf_score=score,
99
+ ))
100
+ ranked = [_rerank(c, intent, query) for c in out]
101
+ ranked.sort(key=lambda c: (
102
+ -(c.rerank_score or c.rrf_score), c.symbol_key))
103
+ return ranked
104
+
105
+
106
+ def _rerank(
107
+ c: FusedCandidate, intent: QueryIntent | None, query: str,
108
+ ) -> FusedCandidate:
109
+ """Rerank task-aware — multiplicadores documentados, explicados
110
+ em ``rerank_reasons``. Sem intent → RRF puro inalterado."""
111
+ score = c.rrf_score
112
+ reasons: list[str] = []
113
+ primary = c.hits[0]
114
+ if query and primary.qualified_name.split(".")[-1] == query:
115
+ score *= BOOST_EXACT_QNAME
116
+ reasons.append(f"exact-qname x{BOOST_EXACT_QNAME}")
117
+ if intent is not None and intent.direction != "both":
118
+ for h in c.hits:
119
+ if any(f"dir={intent.direction}" in e for e in h.evidence):
120
+ score *= BOOST_DIRECTION
121
+ reasons.append(f"direction={intent.direction} "
122
+ f"x{BOOST_DIRECTION}")
123
+ break
124
+ if not any(h.evidence for h in c.hits):
125
+ score *= DEMOTE_NO_EVIDENCE
126
+ reasons.append(f"no-evidence x{DEMOTE_NO_EVIDENCE}")
127
+ return replace(c, rerank_score=round(score, 9),
128
+ rerank_reasons=tuple(reasons))
129
+
130
+
131
+ def to_search_hit(c: FusedCandidate) -> SearchHit:
132
+ """Projeta o candidato fundido para ``SearchHit`` explicável."""
133
+ primary = c.hits[0]
134
+ strategies = tuple(sorted(c.ranks))
135
+ evidence = (
136
+ *(f"{s}:rank={c.ranks[s]}" for s in strategies),
137
+ f"rrf={c.rrf_score:.6f}",
138
+ *(f"rerank={r}" for r in c.rerank_reasons),
139
+ )
140
+ return SearchHit(
141
+ occurrence_id=primary.occurrence_id,
142
+ qualified_name=c.qualified_name,
143
+ kind=primary.kind,
144
+ language=primary.language,
145
+ path=primary.path,
146
+ span=primary.span,
147
+ score=round(c.rrf_score, 6),
148
+ strategy="hybrid",
149
+ matched_fields=strategies,
150
+ evidence=evidence,
151
+ )
@@ -0,0 +1,48 @@
1
+ """Classificação determinística de intenção de consulta (lf-0049).
2
+
3
+ Sem LLM — heurística por palavras-chave configurável. Produz o tipo
4
+ de tarefa e a direção de travessia preferida no grafo:
5
+ - "find-consumers" → incoming (quem chama/importa o símbolo)
6
+ - "explain-dependencies" → outgoing (o que o símbolo usa)
7
+ - demais → both (exploração simétrica)
8
+
9
+ A classificação é deliberadamente conservadora: na dúvida, ``both``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ # padrões → (task_type, direction). Ordem importa: primeiro match vence.
18
+ _RULES: tuple[tuple[str, str, str], ...] = (
19
+ (r"\b(who calls|callers?|consumers?|referenc|usado por|"
20
+ r"quem chama|incoming)\b", "find-consumers", "incoming"),
21
+ (r"\b(depend\w*|import\w*|callee\w*|outgoing|delega\w*|"
22
+ r"o que .{0,20}usa)\b", "explain-dependencies", "outgoing"),
23
+ (r"\b(flow|fluxo|architect|arquitet|component|pipeline|"
24
+ r"camadas?)\b", "understand-component", "both"),
25
+ (r"\b(test|tests?|spec|coverage)\b", "find-tests", "incoming"),
26
+ (r"\b(bug|falha|fail|erro|crash|debug|defeito)\b",
27
+ "investigate-failure", "incoming"),
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class QueryIntent:
33
+ """Intenção classificada — determinística, explicável."""
34
+
35
+ task_type: str # find-consumers|explain-dependencies|...
36
+ direction: str # incoming|outgoing|both
37
+ matched_rule: str # padrão que casou (ou "" = default)
38
+
39
+
40
+ def classify(query: str) -> QueryIntent:
41
+ """Classifica a consulta. Sem match → locate/both (default seguro)."""
42
+ q = query.lower()
43
+ for pattern, task, direction in _RULES:
44
+ if re.search(pattern, q):
45
+ return QueryIntent(task_type=task, direction=direction,
46
+ matched_rule=pattern)
47
+ return QueryIntent(task_type="locate", direction="both",
48
+ matched_rule="")
@@ -0,0 +1,176 @@
1
+ """Busca lexical snapshot-scoped via FTS5 ``fts_occurrences`` (lf-0036).
2
+
3
+ - Rowid do FTS = id da ``symbol_occurrences`` → join direto para
4
+ span/path/símbolo lógico.
5
+ - ``snapshot_id`` UNINDEXED garante isolamento: só o snapshot READY
6
+ corrente é consultado.
7
+ - Coluna ``terms`` carrega identificadores pré-tokenizados
8
+ (``search/tokenizer.py``) — camelCase/snake_case recuperáveis por
9
+ fragmento.
10
+ - Ranking: ``bm25`` nativo do FTS5 com pesos por coluna
11
+ (name > terms > signature > docstring > path).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sqlite3
17
+
18
+ from code_atlas.search.contracts import (
19
+ SearchDiagnostic,
20
+ SearchHit,
21
+ SearchRequest,
22
+ )
23
+ from code_atlas.search.tokenizer import fts_escape, split_identifier
24
+
25
+ # Pesos bm25 por coluna indexada (ordem do CREATE VIRTUAL TABLE):
26
+ # snapshot_id(unindexed), name, terms, signature, docstring, path,
27
+ # language(unindexed), kind(unindexed) → 8 posições.
28
+ _BM25_WEIGHTS = "0.0, 10.0, 6.0, 2.0, 1.0, 1.0, 0.0, 0.0"
29
+
30
+ _MAX_LIMIT = 200
31
+
32
+
33
+ def fts_query(query: str) -> str | None:
34
+ """Monta a expressão MATCH: fragmentos tokenizados OU exato cru.
35
+
36
+ - ``foo*`` → prefixo em ``terms``
37
+ - ``pkg.util.helper`` → tokens por segmento (FTS já separa pontos)
38
+ - ``processCustomerOrders`` → exato em ``name`` + fragmentos em
39
+ ``terms``
40
+ """
41
+ q = query.strip()
42
+ if not q:
43
+ return None
44
+ if q.endswith("*") and len(q) > 1:
45
+ stem = q[:-1]
46
+ toks = split_identifier(stem)
47
+ if toks:
48
+ return f"terms : {fts_escape(toks[-1])}*"
49
+ return f"terms : {fts_escape(stem)}*"
50
+ toks = split_identifier(q)
51
+ if not toks:
52
+ return None
53
+ or_terms = " OR ".join(fts_escape(t) for t in toks)
54
+ return (
55
+ f"{{name terms signature docstring}} : ({or_terms})"
56
+ f" OR name : {fts_escape(q)}"
57
+ )
58
+
59
+
60
+ class LexicalSearch:
61
+ """Engine lexical — só lê; erros viram diagnósticos, nunca raise."""
62
+
63
+ def __init__(self, conn: sqlite3.Connection) -> None:
64
+ self._conn = conn
65
+
66
+ def search(
67
+ self,
68
+ req: SearchRequest,
69
+ snapshot_id: int,
70
+ offset: int = 0,
71
+ ) -> tuple[tuple[SearchHit, ...], int, tuple[SearchDiagnostic, ...]]:
72
+ """Retorna (hits, total_estimado, diagnostics)."""
73
+ match = fts_query(req.query)
74
+ if match is None:
75
+ return (), 0, (
76
+ SearchDiagnostic(
77
+ code="empty-query",
78
+ severity="warning",
79
+ message="query vazia após tokenização",
80
+ ),
81
+ )
82
+ limit = max(1, min(req.limit or 20, _MAX_LIMIT))
83
+ where = [
84
+ "fts_occurrences MATCH ?",
85
+ "f.snapshot_id = ?",
86
+ ]
87
+ params: list[object] = [match, snapshot_id]
88
+ if req.language:
89
+ where.append("f.language = ?")
90
+ params.append(req.language)
91
+ if req.kind:
92
+ where.append("f.kind = ?")
93
+ params.append(req.kind)
94
+ if req.path:
95
+ where.append("sf.relative_path LIKE ? ESCAPE '\\'")
96
+ params.append(f"%{_esc_like(req.path)}%")
97
+ base = (
98
+ "FROM fts_occurrences f"
99
+ " JOIN symbol_occurrences o ON o.id = f.rowid"
100
+ " JOIN source_files sf ON sf.id = o.source_file_id"
101
+ " JOIN symbols s ON s.id = o.logical_symbol_id"
102
+ )
103
+ where_sql = " AND ".join(where)
104
+ # SQL composto em variável nomeada — auditoria AST exige que o
105
+ # primeiro arg de execute() seja nome/literal, nunca expressão.
106
+ count_sql = "SELECT COUNT(*) " + base + " WHERE " + where_sql
107
+ select_sql = (
108
+ "SELECT f.rowid AS occ_id, s.qualified_name, f.kind,"
109
+ " f.language, sf.relative_path,"
110
+ " o.span_start, o.span_end, o.line, o.col,"
111
+ f" bm25(fts_occurrences, {_BM25_WEIGHTS}) AS score "
112
+ + base
113
+ + " WHERE "
114
+ + where_sql
115
+ + " ORDER BY score LIMIT ? OFFSET ?"
116
+ )
117
+ try:
118
+ total = int(
119
+ self._conn.execute(count_sql, params).fetchone()[0]
120
+ )
121
+ rows = self._conn.execute(
122
+ select_sql, [*params, limit, offset]
123
+ ).fetchall()
124
+ except sqlite3.OperationalError as exc:
125
+ return (), 0, (
126
+ SearchDiagnostic(
127
+ code="fts-query-error",
128
+ severity="error",
129
+ message=f"MATCH inválida: {exc}",
130
+ ),
131
+ )
132
+ hits = tuple(
133
+ SearchHit(
134
+ occurrence_id=int(r["occ_id"]),
135
+ qualified_name=str(r["qualified_name"]),
136
+ kind=str(r["kind"]),
137
+ language=str(r["language"]),
138
+ path=str(r["relative_path"]),
139
+ span=(
140
+ {
141
+ "start_byte": r["span_start"],
142
+ "end_byte": r["span_end"],
143
+ "line": r["line"],
144
+ "col": r["col"],
145
+ }
146
+ if r["span_start"] is not None
147
+ else None
148
+ ),
149
+ score=round(-float(r["score"]), 6), # bm25 menor = melhor
150
+ strategy="lexical",
151
+ matched_fields=_matched_fields(req.query, r),
152
+ evidence=(f"occ:{int(r['occ_id'])}",),
153
+ )
154
+ for r in rows
155
+ )
156
+ return hits, total, ()
157
+
158
+
159
+ def _matched_fields(query: str, row: sqlite3.Row) -> tuple[str, ...]:
160
+ """Explicação aproximada: quais campos contêm tokens da query.
161
+
162
+ Honesta: é um pós-checagem em Python, não o highlight do FTS —
163
+ serve para explicar o hit sem custo de ``highlight()`` por linha.
164
+ """
165
+ toks = set(split_identifier(query))
166
+ if not toks:
167
+ return ()
168
+ fields: list[str] = []
169
+ qname_toks = set(split_identifier(str(row["qualified_name"])))
170
+ if toks & qname_toks:
171
+ fields.append("qualified_name")
172
+ return tuple(fields)
173
+
174
+
175
+ def _esc_like(term: str) -> str:
176
+ return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")