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