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.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- 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("_", "\\_")
|