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,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)
|