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,276 @@
|
|
|
1
|
+
"""Busca federada multi-repo (lf-0119, Cycle 7 Phase 11).
|
|
2
|
+
|
|
3
|
+
Resolve a view do workspace via ``Federation.resolve_view`` (override
|
|
4
|
+
de pin do request → pin persistido → READY mais recente) e executa a
|
|
5
|
+
MESMA busca em cada escopo ``(repo, snapshot)`` através de
|
|
6
|
+
``SearchEngine.search_scoped`` — nenhum snapshot é misturado: cada
|
|
7
|
+
leitura fica ancorada no ``snapshot_id`` resolvido daquele membro, e
|
|
8
|
+
pins STALE são honrados (o contrato de ``search_scoped`` não revalida
|
|
9
|
+
status; a view declara ``resolution``/``snapshot_status`` por membro).
|
|
10
|
+
|
|
11
|
+
Merge determinístico e NUNCA colapsante:
|
|
12
|
+
|
|
13
|
+
- a ordenação é posicional — ``(rank dentro do membro, repository_id,
|
|
14
|
+
occurrence_id)`` — porque scores de backends distintos não são
|
|
15
|
+
comparáveis entre repositórios (bm25 é por corpus); rank dentro do
|
|
16
|
+
próprio escopo é a única ordem honesta;
|
|
17
|
+
- hits homônimos em repos distintos NUNCA colapsam — nenhum dedup é
|
|
18
|
+
aplicado além do já feito por membro, e todo item sai tagueado com
|
|
19
|
+
``repository_id`` + ``snapshot_id`` do escopo que o produziu;
|
|
20
|
+
- a faceta de domínio (``domain_items``) é fundida pela mesma regra e
|
|
21
|
+
permanece separada dos hits de símbolo.
|
|
22
|
+
|
|
23
|
+
``view_id`` entra no fingerprint do cursor: reindexar ou repinar um
|
|
24
|
+
membro muda a identidade da view e cursores antigos são rejeitados —
|
|
25
|
+
a mesma invariância cross-revision do engine single-repo (lf-0058).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import dataclasses
|
|
31
|
+
import sqlite3
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
|
|
35
|
+
from code_atlas.federation import FederatedView, Federation
|
|
36
|
+
from code_atlas.search.contracts import (
|
|
37
|
+
SEARCH_SCHEMA_VERSION,
|
|
38
|
+
DomainSearchHit,
|
|
39
|
+
SearchDiagnostic,
|
|
40
|
+
SearchHit,
|
|
41
|
+
SearchRequest,
|
|
42
|
+
decode_cursor,
|
|
43
|
+
encode_cursor,
|
|
44
|
+
query_fingerprint,
|
|
45
|
+
)
|
|
46
|
+
from code_atlas.search.engine import SearchEngine
|
|
47
|
+
from code_atlas.storage.store import Store
|
|
48
|
+
|
|
49
|
+
_MAX_LIMIT = 200
|
|
50
|
+
# Teto do merge por membro — janela de candidatos por escopo, nunca
|
|
51
|
+
# ilimitada (análogo ao fetch interno do hybrid).
|
|
52
|
+
_MEMBER_FETCH_CAP = 200
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class FederatedSearchResult:
|
|
57
|
+
"""Envelope da busca federada — view + items tagueados por escopo."""
|
|
58
|
+
|
|
59
|
+
view: FederatedView
|
|
60
|
+
query: str
|
|
61
|
+
strategy: str
|
|
62
|
+
ranking_version: str
|
|
63
|
+
items: tuple[SearchHit, ...]
|
|
64
|
+
domain_items: tuple[DomainSearchHit, ...]
|
|
65
|
+
domain_total: int
|
|
66
|
+
members_searched: int
|
|
67
|
+
repository_filter: int | None
|
|
68
|
+
limit: int
|
|
69
|
+
truncated: bool
|
|
70
|
+
next_cursor: str | None
|
|
71
|
+
diagnostics: tuple[SearchDiagnostic, ...]
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, object]:
|
|
74
|
+
out = self.view.to_dict() # workspace_id, workspace, view_id, members
|
|
75
|
+
out.update({
|
|
76
|
+
"schema_version": SEARCH_SCHEMA_VERSION,
|
|
77
|
+
"query": self.query,
|
|
78
|
+
"strategy": self.strategy,
|
|
79
|
+
"ranking_version": self.ranking_version,
|
|
80
|
+
"items": [dataclasses.asdict(h) for h in self.items],
|
|
81
|
+
"domain_items": [dataclasses.asdict(h)
|
|
82
|
+
for h in self.domain_items],
|
|
83
|
+
"domain_total": self.domain_total,
|
|
84
|
+
"members_searched": self.members_searched,
|
|
85
|
+
"repository_filter": self.repository_filter,
|
|
86
|
+
"repositories_with_hits": sorted({
|
|
87
|
+
h.repository_id for h in self.items
|
|
88
|
+
if h.repository_id is not None}),
|
|
89
|
+
"limit": self.limit,
|
|
90
|
+
"truncated": self.truncated,
|
|
91
|
+
"next_cursor": self.next_cursor,
|
|
92
|
+
"diagnostics": [dataclasses.asdict(d)
|
|
93
|
+
for d in self.diagnostics],
|
|
94
|
+
})
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FederatedSearchEngine:
|
|
99
|
+
"""Busca cross-repo sobre a view de um workspace — somente leitura."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
102
|
+
self._conn = conn
|
|
103
|
+
self._fed = Federation(conn)
|
|
104
|
+
self._engine = SearchEngine(conn)
|
|
105
|
+
self._store = Store(conn)
|
|
106
|
+
|
|
107
|
+
def search(
|
|
108
|
+
self,
|
|
109
|
+
req: SearchRequest,
|
|
110
|
+
workspace: str | int,
|
|
111
|
+
pins: Mapping[int, int] | None = None,
|
|
112
|
+
*,
|
|
113
|
+
repository_id: int | None = None,
|
|
114
|
+
) -> FederatedSearchResult:
|
|
115
|
+
"""Busca ``req`` em cada membro da view do workspace.
|
|
116
|
+
|
|
117
|
+
``workspace``: id (int/dígito) ou nome — ``get_workspace``.
|
|
118
|
+
``pins``: overrides ``{repository_id: snapshot_id}`` da view.
|
|
119
|
+
``req.snapshot_id`` é açúcar para pin do repo dono do snapshot
|
|
120
|
+
(o override explícito prevalece sobre pins persistidos; repo
|
|
121
|
+
fora do workspace → ``InvalidPinError`` tipado).
|
|
122
|
+
``repository_id``: narrowing honesto — só aquele membro é
|
|
123
|
+
pesquisado; a view (e sua identidade) cobre o workspace inteiro.
|
|
124
|
+
"""
|
|
125
|
+
eff_pins = dict(pins or {})
|
|
126
|
+
if req.snapshot_id is not None:
|
|
127
|
+
from code_atlas.federation.model import (
|
|
128
|
+
SnapshotNotFoundError,
|
|
129
|
+
)
|
|
130
|
+
snap = self._store.snapshot_by_id(req.snapshot_id)
|
|
131
|
+
if snap is None:
|
|
132
|
+
raise SnapshotNotFoundError(
|
|
133
|
+
f"snapshot {req.snapshot_id} not found")
|
|
134
|
+
eff_pins[snap.repository_id] = snap.id or 0
|
|
135
|
+
ws = self._fed.get_workspace(workspace)
|
|
136
|
+
view = self._fed.resolve_view(ws, eff_pins)
|
|
137
|
+
|
|
138
|
+
lim = max(1, min(req.limit or 20, _MAX_LIMIT))
|
|
139
|
+
fp = query_fingerprint(req.query, {
|
|
140
|
+
"language": req.language, "kind": req.kind,
|
|
141
|
+
"path": req.path, "domain": req.domain,
|
|
142
|
+
"repository": ("" if repository_id is None
|
|
143
|
+
else str(repository_id)),
|
|
144
|
+
"view": view.view_id,
|
|
145
|
+
})
|
|
146
|
+
offset = 0
|
|
147
|
+
diags: list[SearchDiagnostic] = []
|
|
148
|
+
if req.cursor:
|
|
149
|
+
decoded = decode_cursor(req.cursor, fp)
|
|
150
|
+
if decoded is None:
|
|
151
|
+
diags.append(SearchDiagnostic(
|
|
152
|
+
code="invalid-cursor", severity="error",
|
|
153
|
+
message="cursor não corresponde a esta query/view"))
|
|
154
|
+
return self._result(req, view, (), 0, repository_id,
|
|
155
|
+
lim, False, None, diags)
|
|
156
|
+
offset = decoded
|
|
157
|
+
|
|
158
|
+
fetch = min(offset + lim, _MEMBER_FETCH_CAP)
|
|
159
|
+
(member_hits, member_domain, domain_total, searched,
|
|
160
|
+
truncated, member_ranking) = self._member_results(
|
|
161
|
+
req, view, fetch, repository_id, diags)
|
|
162
|
+
|
|
163
|
+
member_hits.sort(key=lambda t: (t[0], t[1], t[2].occurrence_id))
|
|
164
|
+
member_domain.sort(key=lambda t: (t[0], t[1], t[2].entity_id))
|
|
165
|
+
merged = [h for _pos, _rid, h in member_hits]
|
|
166
|
+
page = tuple(merged[offset:offset + lim])
|
|
167
|
+
domain_page = tuple(
|
|
168
|
+
h for _pos, _rid, h in member_domain)[:lim]
|
|
169
|
+
truncated = truncated or (offset + len(page) < len(merged))
|
|
170
|
+
cursor = (encode_cursor(offset + len(page), fp)
|
|
171
|
+
if truncated else None)
|
|
172
|
+
if repository_id is not None and searched == 0:
|
|
173
|
+
diags.append(SearchDiagnostic(
|
|
174
|
+
code="member-filter-empty", severity="warning",
|
|
175
|
+
message=f"repository_id {repository_id} não é membro "
|
|
176
|
+
f"pesquisável desta view"))
|
|
177
|
+
return self._result(req, view, page, domain_total, repository_id,
|
|
178
|
+
lim, truncated, cursor, diags,
|
|
179
|
+
domain_items=domain_page,
|
|
180
|
+
members_searched=searched,
|
|
181
|
+
member_ranking=member_ranking)
|
|
182
|
+
|
|
183
|
+
def _member_results(
|
|
184
|
+
self,
|
|
185
|
+
req: SearchRequest,
|
|
186
|
+
view: FederatedView,
|
|
187
|
+
fetch: int,
|
|
188
|
+
repository_id: int | None,
|
|
189
|
+
diags: list[SearchDiagnostic],
|
|
190
|
+
) -> tuple[
|
|
191
|
+
list[tuple[int, int, SearchHit]],
|
|
192
|
+
list[tuple[int, int, DomainSearchHit]],
|
|
193
|
+
int, int, bool, str,
|
|
194
|
+
]:
|
|
195
|
+
"""Executa a busca em cada membro resolvido da view.
|
|
196
|
+
|
|
197
|
+
Retorna ``(hits, domain_hits, domain_total, searched,
|
|
198
|
+
truncated, member_ranking)`` — hits como ``(rank_no_membro,
|
|
199
|
+
repository_id, hit)`` para o merge posicional. Diagnósticos de
|
|
200
|
+
membros são propagados com prefixo de escopo na mensagem.
|
|
201
|
+
"""
|
|
202
|
+
roots = self._repo_roots()
|
|
203
|
+
member_hits: list[tuple[int, int, SearchHit]] = []
|
|
204
|
+
member_domain: list[tuple[int, int, DomainSearchHit]] = []
|
|
205
|
+
domain_total = 0
|
|
206
|
+
searched = 0
|
|
207
|
+
truncated = False
|
|
208
|
+
member_ranking = ""
|
|
209
|
+
for m in view.members:
|
|
210
|
+
if m.snapshot_id is None:
|
|
211
|
+
if repository_id in (None, m.repository_id):
|
|
212
|
+
diags.append(SearchDiagnostic(
|
|
213
|
+
code="member-no-snapshot", severity="warning",
|
|
214
|
+
message=f"repo:{m.repository_id} sem snapshot "
|
|
215
|
+
f"legível ({m.resolution})"))
|
|
216
|
+
continue
|
|
217
|
+
if repository_id is not None and m.repository_id != repository_id:
|
|
218
|
+
continue
|
|
219
|
+
searched += 1
|
|
220
|
+
mreq = dataclasses.replace(
|
|
221
|
+
req, repository_root=roots.get(m.repository_id, ""),
|
|
222
|
+
snapshot_id=m.snapshot_id, cursor=None, limit=fetch)
|
|
223
|
+
res = self._engine.search_scoped(
|
|
224
|
+
mreq, m.repository_id, m.snapshot_id)
|
|
225
|
+
member_ranking = member_ranking or res.ranking_version
|
|
226
|
+
diags.extend(
|
|
227
|
+
SearchDiagnostic(
|
|
228
|
+
code=d.code, severity=d.severity,
|
|
229
|
+
message=f"repo:{m.repository_id} "
|
|
230
|
+
f"snap:{m.snapshot_id} — {d.message}")
|
|
231
|
+
for d in res.diagnostics)
|
|
232
|
+
for pos, h in enumerate(res.items):
|
|
233
|
+
member_hits.append((pos, m.repository_id, h))
|
|
234
|
+
for pos, dh in enumerate(res.domain_items):
|
|
235
|
+
member_domain.append((pos, m.repository_id, dh))
|
|
236
|
+
domain_total += res.domain_total
|
|
237
|
+
truncated = truncated or res.truncated
|
|
238
|
+
return (member_hits, member_domain, domain_total, searched,
|
|
239
|
+
truncated, member_ranking)
|
|
240
|
+
|
|
241
|
+
def _repo_roots(self) -> dict[int, str]:
|
|
242
|
+
rows = self._conn.execute(
|
|
243
|
+
"SELECT id, root_handle FROM repositories").fetchall()
|
|
244
|
+
return {int(r["id"]): str(r["root_handle"]) for r in rows}
|
|
245
|
+
|
|
246
|
+
def _result(
|
|
247
|
+
self,
|
|
248
|
+
req: SearchRequest,
|
|
249
|
+
view: FederatedView,
|
|
250
|
+
items: tuple[SearchHit, ...],
|
|
251
|
+
domain_total: int,
|
|
252
|
+
repository_filter: int | None,
|
|
253
|
+
limit: int,
|
|
254
|
+
truncated: bool,
|
|
255
|
+
cursor: str | None,
|
|
256
|
+
diags: list[SearchDiagnostic],
|
|
257
|
+
*,
|
|
258
|
+
domain_items: tuple[DomainSearchHit, ...] = (),
|
|
259
|
+
members_searched: int = 0,
|
|
260
|
+
member_ranking: str = "",
|
|
261
|
+
) -> FederatedSearchResult:
|
|
262
|
+
# versão do merge declarada + versão do ranking intra-membro —
|
|
263
|
+
# scores nunca são comparados cross-repo, só ranks locais.
|
|
264
|
+
ranking = "federated-rank-v1"
|
|
265
|
+
if member_ranking:
|
|
266
|
+
ranking = f"{ranking}+{member_ranking}"
|
|
267
|
+
return FederatedSearchResult(
|
|
268
|
+
view=view, query=req.query, strategy=req.strategy,
|
|
269
|
+
ranking_version=ranking,
|
|
270
|
+
items=items, domain_items=domain_items,
|
|
271
|
+
domain_total=domain_total,
|
|
272
|
+
members_searched=members_searched,
|
|
273
|
+
repository_filter=repository_filter,
|
|
274
|
+
limit=limit, truncated=truncated, next_cursor=cursor,
|
|
275
|
+
diagnostics=tuple(diags),
|
|
276
|
+
)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Retrieval estrutural sobre o grafo persistido (lf-0037 / Cycle 4 Ph3).
|
|
2
|
+
|
|
3
|
+
Travessia BFS limitada sobre ``relations`` do snapshot READY:
|
|
4
|
+
depth/max_nodes/max_edges/max_ms/max_results — todos honrados, ciclos
|
|
5
|
+
fechados por conjunto de visitados explícito. Uma query por nível de
|
|
6
|
+
fronteira via ``json_each`` (SQL constante — sem N+1, sem grafo em
|
|
7
|
+
memória).
|
|
8
|
+
|
|
9
|
+
Ranking estrutural documentado (nunca "grau alto por grau alto"):
|
|
10
|
+
1. distância da semente (menor primeiro)
|
|
11
|
+
2. status da evidência do caminho (resolved > ambiguous > unresolved)
|
|
12
|
+
3. peso da relação usada (CALLS/IMPORTS > EXTENDS/IMPLEMENTS >
|
|
13
|
+
REFERENCES/CONTAINS/…)
|
|
14
|
+
4. confidence do último salto (resolved > syntactic > heuristic >
|
|
15
|
+
inferred)
|
|
16
|
+
5. id da ocorrência (desempate determinístico)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sqlite3
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
# Pesos por tipo de relação (menor = mais relevante para contexto).
|
|
27
|
+
_REL_WEIGHT = {
|
|
28
|
+
"CALLS": 0, "IMPORTS": 1, "EXTENDS": 2, "IMPLEMENTS": 2,
|
|
29
|
+
"REFERENCES": 3, "CONTAINS": 4, "DEFINES": 4, "TESTS": 4,
|
|
30
|
+
}
|
|
31
|
+
_STATUS_RANK = {"resolved": 0, "ambiguous": 1, "unresolved": 2,
|
|
32
|
+
"external": 2}
|
|
33
|
+
_CONF_RANK = {"resolved": 0, "syntactic": 1, "heuristic": 2,
|
|
34
|
+
"inferred": 3}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TraversalLimits:
|
|
39
|
+
"""Limites obrigatórios da travessia — sempre aplicados."""
|
|
40
|
+
|
|
41
|
+
depth: int = 3
|
|
42
|
+
max_nodes: int = 500
|
|
43
|
+
max_edges: int = 2000
|
|
44
|
+
max_ms: int = 500
|
|
45
|
+
max_results: int = 100
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class TraversalStep:
|
|
50
|
+
"""Um salto do caminho — explicação por hit."""
|
|
51
|
+
|
|
52
|
+
from_id: int
|
|
53
|
+
to_id: int
|
|
54
|
+
relation: str
|
|
55
|
+
resolution_status: str
|
|
56
|
+
confidence_class: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TraversalHit:
|
|
61
|
+
"""Ocorrência alcançada + caminho + ranking estrutural."""
|
|
62
|
+
|
|
63
|
+
occurrence_id: int
|
|
64
|
+
qualified_name: str
|
|
65
|
+
kind: str
|
|
66
|
+
language: str
|
|
67
|
+
path: str
|
|
68
|
+
depth: int
|
|
69
|
+
steps: tuple[TraversalStep, ...]
|
|
70
|
+
score: float
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class TraversalStats:
|
|
75
|
+
nodes_visited: int = 0
|
|
76
|
+
edges_examined: int = 0
|
|
77
|
+
truncated_by: str = "" # qual limite cortou ("depth|nodes|edges|time|results")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_EDGES_SQL = (
|
|
81
|
+
"SELECT from_id, to_id, type, resolution_status, confidence_class"
|
|
82
|
+
" FROM relations WHERE snapshot_id = ? AND ("
|
|
83
|
+
" from_id IN (SELECT value FROM json_each(?))"
|
|
84
|
+
" OR to_id IN (SELECT value FROM json_each(?)))"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_OCC_INFO_SQL = (
|
|
88
|
+
"SELECT o.id, s.qualified_name, s.kind, s.language,"
|
|
89
|
+
" sf.relative_path, o.span_start, o.span_end, o.line, o.col"
|
|
90
|
+
" FROM symbol_occurrences o"
|
|
91
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
92
|
+
" JOIN source_files sf ON sf.id = o.source_file_id"
|
|
93
|
+
" WHERE o.id IN (SELECT value FROM json_each(?))"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GraphRetrieval:
|
|
98
|
+
"""Travessia limitada e explicável sobre relations do snapshot."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
101
|
+
self._conn = conn
|
|
102
|
+
|
|
103
|
+
def seed_occurrences(
|
|
104
|
+
self, snapshot_id: int, qualified_name: str
|
|
105
|
+
) -> list[int]:
|
|
106
|
+
"""Seeds por qualified_name exato (case-insensitive)."""
|
|
107
|
+
rows = self._conn.execute(
|
|
108
|
+
"SELECT o.id FROM symbol_occurrences o"
|
|
109
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
110
|
+
" WHERE o.snapshot_id = ? AND ("
|
|
111
|
+
" s.qualified_name = ? OR s.qualified_name LIKE ?)",
|
|
112
|
+
(snapshot_id, qualified_name, f"%.{qualified_name}"),
|
|
113
|
+
).fetchall()
|
|
114
|
+
return [int(r["id"]) for r in rows]
|
|
115
|
+
|
|
116
|
+
def traverse(
|
|
117
|
+
self,
|
|
118
|
+
snapshot_id: int,
|
|
119
|
+
seed_ids: list[int],
|
|
120
|
+
*,
|
|
121
|
+
direction: str = "outgoing", # outgoing|incoming|both
|
|
122
|
+
relation_types: frozenset[str] | None = None,
|
|
123
|
+
limits: TraversalLimits | None = None,
|
|
124
|
+
) -> tuple[list[TraversalHit], TraversalStats]:
|
|
125
|
+
"""BFS limitado — devolve hits rankeados + stats honestas."""
|
|
126
|
+
lim = limits or TraversalLimits()
|
|
127
|
+
stats = TraversalStats()
|
|
128
|
+
t0 = time.monotonic()
|
|
129
|
+
visited: set[int] = set(seed_ids)
|
|
130
|
+
paths: dict[int, list[TraversalStep]] = {s: [] for s in seed_ids}
|
|
131
|
+
frontier = list(seed_ids)
|
|
132
|
+
hit_ids: list[int] = []
|
|
133
|
+
for _depth in range(1, lim.depth + 1):
|
|
134
|
+
if not frontier:
|
|
135
|
+
break
|
|
136
|
+
if (time.monotonic() - t0) * 1000 > lim.max_ms:
|
|
137
|
+
stats.truncated_by = "time"
|
|
138
|
+
break
|
|
139
|
+
next_frontier = self._walk_level(
|
|
140
|
+
snapshot_id, frontier, visited, paths, direction,
|
|
141
|
+
relation_types, lim, stats, hit_ids,
|
|
142
|
+
)
|
|
143
|
+
if stats.truncated_by:
|
|
144
|
+
break
|
|
145
|
+
if stats.edges_examined >= lim.max_edges:
|
|
146
|
+
stats.truncated_by = "edges"
|
|
147
|
+
break
|
|
148
|
+
frontier = next_frontier
|
|
149
|
+
else:
|
|
150
|
+
pass
|
|
151
|
+
if not hit_ids:
|
|
152
|
+
return [], stats
|
|
153
|
+
info = {
|
|
154
|
+
int(r["id"]): r
|
|
155
|
+
for r in self._conn.execute(
|
|
156
|
+
_OCC_INFO_SQL, (json.dumps(hit_ids),)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
hits = [
|
|
160
|
+
TraversalHit(
|
|
161
|
+
occurrence_id=oid,
|
|
162
|
+
qualified_name=str(info[oid]["qualified_name"]),
|
|
163
|
+
kind=str(info[oid]["kind"]),
|
|
164
|
+
language=str(info[oid]["language"]),
|
|
165
|
+
path=str(info[oid]["relative_path"]),
|
|
166
|
+
depth=len(paths[oid]),
|
|
167
|
+
steps=tuple(paths[oid]),
|
|
168
|
+
score=_structural_score(paths[oid]),
|
|
169
|
+
)
|
|
170
|
+
for oid in hit_ids
|
|
171
|
+
if oid in info
|
|
172
|
+
]
|
|
173
|
+
if len(hit_ids) >= lim.max_results and not stats.truncated_by:
|
|
174
|
+
stats.truncated_by = "results"
|
|
175
|
+
hits.sort(
|
|
176
|
+
key=lambda h: (
|
|
177
|
+
h.depth,
|
|
178
|
+
min((_STATUS_RANK.get(s.resolution_status, 3)
|
|
179
|
+
for s in h.steps), default=3),
|
|
180
|
+
min((_REL_WEIGHT.get(s.relation, 5)
|
|
181
|
+
for s in h.steps), default=5),
|
|
182
|
+
min((_CONF_RANK.get(s.confidence_class, 4)
|
|
183
|
+
for s in h.steps), default=4),
|
|
184
|
+
h.occurrence_id,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return hits, stats
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _walk_level(
|
|
191
|
+
self,
|
|
192
|
+
snapshot_id: int,
|
|
193
|
+
frontier: list[int],
|
|
194
|
+
visited: set[int],
|
|
195
|
+
paths: dict[int, list[TraversalStep]],
|
|
196
|
+
direction: str,
|
|
197
|
+
relation_types: frozenset[str] | None,
|
|
198
|
+
lim: TraversalLimits,
|
|
199
|
+
stats: TraversalStats,
|
|
200
|
+
hit_ids: list[int],
|
|
201
|
+
) -> list[int]:
|
|
202
|
+
"""Um nível de BFS: edges da fronteira → próximos visitados."""
|
|
203
|
+
rows = self._conn.execute(
|
|
204
|
+
_EDGES_SQL,
|
|
205
|
+
(snapshot_id, json.dumps(frontier), json.dumps(frontier)),
|
|
206
|
+
).fetchall()
|
|
207
|
+
stats.edges_examined += len(rows)
|
|
208
|
+
next_frontier: list[int] = []
|
|
209
|
+
for r in rows:
|
|
210
|
+
frm, to = int(r["from_id"]), (
|
|
211
|
+
int(r["to_id"]) if r["to_id"] is not None else None)
|
|
212
|
+
if to is None:
|
|
213
|
+
continue
|
|
214
|
+
rel = str(r["type"])
|
|
215
|
+
if relation_types and rel not in relation_types:
|
|
216
|
+
continue
|
|
217
|
+
# direção: outgoing = seed→out, incoming = out→seed
|
|
218
|
+
for a, b, direc in (
|
|
219
|
+
(frm, to, "outgoing"), (to, frm, "incoming"),
|
|
220
|
+
):
|
|
221
|
+
if direc != direction and direction != "both":
|
|
222
|
+
continue
|
|
223
|
+
if a not in visited or b in visited:
|
|
224
|
+
continue
|
|
225
|
+
visited.add(b)
|
|
226
|
+
stats.nodes_visited += 1
|
|
227
|
+
step = TraversalStep(
|
|
228
|
+
from_id=frm, to_id=to, relation=rel,
|
|
229
|
+
resolution_status=str(r["resolution_status"]),
|
|
230
|
+
confidence_class=str(r["confidence_class"]),
|
|
231
|
+
)
|
|
232
|
+
paths[b] = [*paths[a], step]
|
|
233
|
+
next_frontier.append(b)
|
|
234
|
+
if len(hit_ids) < lim.max_results:
|
|
235
|
+
hit_ids.append(b)
|
|
236
|
+
if stats.nodes_visited >= lim.max_nodes:
|
|
237
|
+
stats.truncated_by = "nodes"
|
|
238
|
+
return next_frontier
|
|
239
|
+
return next_frontier
|
|
240
|
+
|
|
241
|
+
# -- atalhos de capacidade -------------------------------------------
|
|
242
|
+
|
|
243
|
+
def callers(
|
|
244
|
+
self, snapshot_id: int, seed: int, **kw: object
|
|
245
|
+
) -> tuple[list[TraversalHit], TraversalStats]:
|
|
246
|
+
"""Consumidores: arestas incoming (quem chama/importa a seed)."""
|
|
247
|
+
return self.traverse(
|
|
248
|
+
snapshot_id, [seed], direction="incoming", **kw) # type: ignore[arg-type]
|
|
249
|
+
|
|
250
|
+
def callees(
|
|
251
|
+
self, snapshot_id: int, seed: int, **kw: object
|
|
252
|
+
) -> tuple[list[TraversalHit], TraversalStats]:
|
|
253
|
+
return self.traverse(
|
|
254
|
+
snapshot_id, [seed], direction="outgoing", **kw) # type: ignore[arg-type]
|
|
255
|
+
|
|
256
|
+
def dependencies(
|
|
257
|
+
self, snapshot_id: int, seed: int, **kw: object
|
|
258
|
+
) -> tuple[list[TraversalHit], TraversalStats]:
|
|
259
|
+
kw.setdefault(
|
|
260
|
+
"relation_types",
|
|
261
|
+
frozenset({"IMPORTS", "EXTENDS", "IMPLEMENTS", "REFERENCES"}),
|
|
262
|
+
)
|
|
263
|
+
return self.traverse(
|
|
264
|
+
snapshot_id, [seed], direction="outgoing", **kw) # type: ignore[arg-type]
|
|
265
|
+
|
|
266
|
+
def components(
|
|
267
|
+
self, snapshot_id: int, seed: int, **kw: object
|
|
268
|
+
) -> tuple[list[TraversalHit], TraversalStats]:
|
|
269
|
+
"""Componente conectado da seed — travessia bidirecional."""
|
|
270
|
+
return self.traverse(
|
|
271
|
+
snapshot_id, [seed], direction="both", **kw) # type: ignore[arg-type]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _structural_score(steps: list[TraversalStep]) -> float:
|
|
275
|
+
"""Score contínuo para ordenação estável (espelha a chave de sort)."""
|
|
276
|
+
depth_penalty = len(steps)
|
|
277
|
+
rel = min((_REL_WEIGHT.get(s.relation, 5) for s in steps), default=5)
|
|
278
|
+
status = min(
|
|
279
|
+
(_STATUS_RANK.get(s.resolution_status, 3) for s in steps),
|
|
280
|
+
default=3,
|
|
281
|
+
)
|
|
282
|
+
return round(depth_penalty + rel * 0.1 + status * 0.05, 4)
|