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,676 @@
1
+ """Engine de federação multi-repo (lf-0118, Cycle 7 Phase 10).
2
+
3
+ Superfície de **leitura** sobre repositórios já indexados — nenhuma
4
+ escrita em ``domain_*``/``snapshots``/``relations``, nenhuma execução,
5
+ nenhuma descoberta de diretórios fora dos repos já registrados. As
6
+ únicas escritas do pacote são as tabelas v10 (workspaces/membros) e só
7
+ são alcançadas pela CLI — a API serve conexão ``read_only``.
8
+
9
+ Regras inegociáveis:
10
+
11
+ - **isolamento por snapshot**: cada membro resolve seu próprio
12
+ ``snapshot_id``; todas as leituras de domínio são ancoradas por
13
+ ``deo.snapshot_id = <snapshot do membro>`` — trocar o snapshot de um
14
+ membro nunca toca os dados de outro;
15
+ - **identidade compartilhada honesta**: o join cross-repo é a
16
+ ``identity_key`` lógica (``DomainIdentity.logical_key``) **menos** o
17
+ segmento ``repository`` — ``shared_key``. Mesmo nome em catálogo/
18
+ namespace/env/root distintos NÃO casa (é o motivo da identidade
19
+ qualificada existir);
20
+ - **arestas federadas são computadas**: ``shared_entity`` liga
21
+ entidades de repos distintos com a mesma ``shared_key`` — derivadas
22
+ da view, nunca persistidas; ``computed=True`` + ``resolver=
23
+ federation-view`` declaram a proveniência;
24
+ - **view honesta / cache key completa** (lf-0134): ``view_id`` é o
25
+ hash do contrato inteiro de identidade — pares
26
+ ``(repo_id, identity_key, snapshot_id|None, digest de
27
+ analyzer_versions)`` + ``FEDERATION_RESOLVER_ID``/``VIEW_ID_FORMAT``
28
+ + ``schema_version``. Pin, override, READY novo, mudança de
29
+ membership, re-bind de identidade, upgrade de adapter/resolver ou
30
+ migração de schema mudam a identidade da view — qualquer cache
31
+ chaveado em ``view_id`` invalida sem servir rows de outra identidade.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import sqlite3
39
+ from collections.abc import Mapping
40
+ from pathlib import Path
41
+
42
+ from code_atlas.contracts.enums import SnapshotStatus
43
+ from code_atlas.contracts.models import Snapshot
44
+ from code_atlas.discovery.identity import identity_key_for_root
45
+ from code_atlas.federation.model import (
46
+ FEDERATION_RESOLVER_ID,
47
+ MAX_GRAPH_DEPTH,
48
+ MIN_FEDERATION_MEMBERS,
49
+ EntityNode,
50
+ FederatedEdge,
51
+ FederatedGraph,
52
+ FederatedView,
53
+ FederationMember,
54
+ FederationWorkspace,
55
+ InvalidKeyError,
56
+ InvalidPinError,
57
+ MemberNotFoundError,
58
+ RepositoryNotFoundError,
59
+ SharedOccurrence,
60
+ SnapshotNotFoundError,
61
+ SnapshotNotReadyError,
62
+ ViewMember,
63
+ WorkspaceExistsError,
64
+ WorkspaceNotFoundError,
65
+ WorkspaceSizeError,
66
+ compute_view_id,
67
+ )
68
+ from code_atlas.federation.store import FederationStore
69
+ from code_atlas.storage.migrations import schema_version
70
+ from code_atlas.storage.store import Store
71
+
72
+ MAX_WORKSPACE_NAME = 120
73
+
74
+ # Snapshots que uma view pode ler: READY (publicado) ou STALE (pin
75
+ # legítimo — congelar uma versão antiga é o propósito do pin). BUILDING
76
+ # é transiente; FAILED não tem dados confiáveis.
77
+ _READABLE_STATUSES = frozenset(
78
+ {str(SnapshotStatus.READY), str(SnapshotStatus.STALE)}
79
+ )
80
+
81
+
82
+ def shared_key_of(identity_key: str) -> str | None:
83
+ """``identity_key`` lógica sem o segmento ``repository``.
84
+
85
+ ``dom:<domain>/<repo>/<root>/<catalog>/<ns>/<env>/<qname>[/<snap>]``
86
+ → ``dom:<domain>/<root>/<catalog>/<ns>/<env>/<qname>``. O segmento
87
+ repo é o índice 1 — separadores dentro de componentes são
88
+ percent-encodados, logo split em ``/`` é seguro. ``None`` quando a
89
+ chave não é ``dom:`` bem-formada (nunca fabrica identidade).
90
+ """
91
+ parts = identity_key.split("/")
92
+ if len(parts) < 7 or not parts[0].startswith("dom:"):
93
+ return None
94
+ return "/".join([parts[0], *parts[2:7]])
95
+
96
+
97
+ def normalize_lookup_key(key: str) -> str:
98
+ """Normaliza a chave pedida para a forma compartilhada (6 segmentos).
99
+
100
+ Aceita a ``identity_key`` completa (7 segmentos — repo descartado),
101
+ a ``canonical_key`` (8 — repo e snapshot descartados) ou a forma
102
+ compartilhada já derivada (6). Qualquer outra forma é
103
+ ``InvalidKeyError`` — nunca match por substring/nome nu.
104
+ """
105
+ parts = key.split("/")
106
+ if not parts or not parts[0].startswith("dom:"):
107
+ raise InvalidKeyError(f"not a domain identity key: {key[:80]!r}")
108
+ if len(parts) == 6:
109
+ return key
110
+ if len(parts) in (7, 8):
111
+ return "/".join([parts[0], *parts[2:7]])
112
+ raise InvalidKeyError(
113
+ f"domain identity key expects 6-8 segments, got {len(parts)}"
114
+ )
115
+
116
+
117
+ class Federation:
118
+ """Fachada de federação sobre uma conexão sqlite3.
119
+
120
+ Métodos ``create_workspace``/``add_member``/``pin_member``/
121
+ ``unpin_member`` escrevem — cada um na própria transação curta
122
+ (``with conn:``). Todos os demais são somente leitura.
123
+ """
124
+
125
+ def __init__(self, conn: sqlite3.Connection) -> None:
126
+ self._conn = conn
127
+ self._store = Store(conn)
128
+ self._fstore = FederationStore(conn)
129
+
130
+ # --- workspaces (CRUD — superfície CLI) ---------------------------
131
+
132
+ def create_workspace(self, name: str) -> FederationWorkspace:
133
+ """Cria workspace nomeado; nome duplicado → ``WorkspaceExistsError``."""
134
+ clean = name.strip()
135
+ if not clean or len(clean) > MAX_WORKSPACE_NAME:
136
+ raise InvalidPinError(
137
+ f"workspace name must be 1..{MAX_WORKSPACE_NAME} chars"
138
+ )
139
+ with self._conn:
140
+ try:
141
+ ws_id = self._fstore.create_workspace(clean)
142
+ except sqlite3.IntegrityError as exc:
143
+ raise WorkspaceExistsError(
144
+ f"federation workspace already exists: {clean!r}"
145
+ ) from exc
146
+ return FederationWorkspace(id=ws_id, name=clean, created_at="")
147
+
148
+ def get_workspace(self, ref: str | int) -> FederationWorkspace:
149
+ """Workspace por id (int/str dígito) ou nome — 404 tipado."""
150
+ ws: FederationWorkspace | None = None
151
+ if isinstance(ref, int) or str(ref).isdigit():
152
+ ws = self._fstore.workspace_by_id(int(ref))
153
+ if ws is None and not (isinstance(ref, int) or str(ref).isdigit()):
154
+ ws = self._fstore.workspace_by_name(str(ref))
155
+ if ws is None:
156
+ raise WorkspaceNotFoundError(f"federation workspace: {ref!r}")
157
+ return ws
158
+
159
+ def list_workspaces(self) -> list[FederationWorkspace]:
160
+ return self._fstore.list_workspaces()
161
+
162
+ def members_of(self, workspace_id: int) -> list[FederationMember]:
163
+ return self._fstore.list_members(workspace_id)
164
+
165
+ # --- members ------------------------------------------------------
166
+
167
+ def add_member(
168
+ self, ws: FederationWorkspace, repository_id: int
169
+ ) -> FederationMember:
170
+ """Registra repo no workspace — idempotente (UNIQUE + OR IGNORE).
171
+
172
+ Rejeita repo inexistente: federação só agrupa repositórios **já
173
+ indexados** — nenhuma descoberta de diretório, nenhum registro
174
+ implícito.
175
+ """
176
+ if self._store.repository_by_id(repository_id) is None:
177
+ raise RepositoryNotFoundError(
178
+ f"repository not indexed: {repository_id}"
179
+ )
180
+ with self._conn:
181
+ self._fstore.add_member(ws.id, repository_id)
182
+ member = self._fstore.member_for(ws.id, repository_id)
183
+ assert member is not None
184
+ return member
185
+
186
+ def remove_member(
187
+ self, ws: FederationWorkspace, repository_id: int
188
+ ) -> None:
189
+ with self._conn:
190
+ if not self._fstore.remove_member(ws.id, repository_id):
191
+ raise MemberNotFoundError(
192
+ f"repository {repository_id} is not a member of "
193
+ f"{ws.name!r}"
194
+ )
195
+
196
+ def pin_member(
197
+ self,
198
+ ws: FederationWorkspace,
199
+ repository_id: int,
200
+ snapshot_id: int,
201
+ ) -> FederationMember:
202
+ """Pina o membro num snapshot legível **daquele repo**.
203
+
204
+ READY e STALE são aceitos (pin = congelar versão); BUILDING/
205
+ FAILED são rejeitados — pin nunca aponta para dado incompleto.
206
+ """
207
+ if self._fstore.member_for(ws.id, repository_id) is None:
208
+ raise MemberNotFoundError(
209
+ f"repository {repository_id} is not a member of {ws.name!r}"
210
+ )
211
+ snap = self._store.snapshot_by_id(snapshot_id)
212
+ if snap is None or snap.repository_id != repository_id:
213
+ raise SnapshotNotFoundError(
214
+ f"snapshot {snapshot_id} not found for repository "
215
+ f"{repository_id}"
216
+ )
217
+ if str(snap.status) not in _READABLE_STATUSES:
218
+ raise SnapshotNotReadyError(
219
+ f"snapshot {snapshot_id} is {snap.status}, "
220
+ "cannot pin an unreadable snapshot"
221
+ )
222
+ with self._conn:
223
+ self._fstore.set_pinned_snapshot(ws.id, repository_id, snapshot_id)
224
+ member = self._fstore.member_for(ws.id, repository_id)
225
+ assert member is not None
226
+ return member
227
+
228
+ def unpin_member(
229
+ self, ws: FederationWorkspace, repository_id: int
230
+ ) -> FederationMember:
231
+ """Limpa o pin — membro volta a seguir o READY mais recente."""
232
+ if self._fstore.member_for(ws.id, repository_id) is None:
233
+ raise MemberNotFoundError(
234
+ f"repository {repository_id} is not a member of {ws.name!r}"
235
+ )
236
+ with self._conn:
237
+ self._fstore.set_pinned_snapshot(ws.id, repository_id, None)
238
+ member = self._fstore.member_for(ws.id, repository_id)
239
+ assert member is not None
240
+ return member
241
+
242
+ def resolve_repository(self, ref: str) -> int:
243
+ """``<repo-path-or-id>`` → repository_id já indexado.
244
+
245
+ Ordem: id numérico → ``identity_key``/``root_handle`` literal →
246
+ path on-disk (probe git de ``identity_key_for_root`` + fallback
247
+ ``root_handle`` resolvido). Nada é criado: referência que não
248
+ resolve para repo indexado é ``RepositoryNotFoundError``.
249
+ """
250
+ if ref.isdigit():
251
+ rid = int(ref)
252
+ if self._store.repository_by_id(rid) is not None:
253
+ return rid
254
+ raise RepositoryNotFoundError(f"repository not indexed: {rid}")
255
+ resolved = self._store.repository_id_for(ref)
256
+ if resolved is not None:
257
+ return resolved
258
+ candidate = Path(ref).expanduser()
259
+ if not candidate.is_absolute():
260
+ candidate = Path.cwd() / candidate
261
+ candidate = candidate.resolve()
262
+ resolved = self._store.repository_id_for(
263
+ identity_key_for_root(candidate))
264
+ if resolved is not None:
265
+ return resolved
266
+ resolved = self._store.repository_id_for(str(candidate))
267
+ if resolved is not None:
268
+ return resolved
269
+ raise RepositoryNotFoundError(
270
+ f"no indexed repository for {ref!r} — index it first"
271
+ )
272
+
273
+ # --- view -----------------------------------------------------------
274
+
275
+ def resolve_view(
276
+ self,
277
+ ws: FederationWorkspace,
278
+ pins: Mapping[int, int] | None = None,
279
+ ) -> FederatedView:
280
+ """Materializa ``{repo_id: snapshot_id}`` do workspace.
281
+
282
+ Precedência por membro: ``pins`` (override do request) → pin
283
+ persistido → READY mais recente. Pins de override exigem repo
284
+ membro + snapshot legível do mesmo repo; um pin persistido é
285
+ honrado mesmo que o snapshot tenha virado STALE (congelar é o
286
+ propósito). Membros sem snapshot legível entram na view como
287
+ ``snapshot_id=None`` — ausência declarada, nunca inventada.
288
+ """
289
+ members = self._fstore.list_members(ws.id)
290
+ if len(members) < MIN_FEDERATION_MEMBERS:
291
+ raise WorkspaceSizeError(
292
+ f"federation {ws.name!r} has {len(members)} member(s) — "
293
+ f"requires >= {MIN_FEDERATION_MEMBERS} indexed repositories"
294
+ )
295
+ overrides = dict(pins or {})
296
+ member_repos = {m.repository_id for m in members}
297
+ extra = sorted(set(overrides) - member_repos)
298
+ if extra:
299
+ raise InvalidPinError(
300
+ f"pin override for non-member repositories: {extra}"
301
+ )
302
+ view_members: list[ViewMember] = []
303
+ resolved: dict[int, int | None] = {}
304
+ resolved_snaps: dict[int, Snapshot | None] = {}
305
+ for m in members:
306
+ vm, snap = self._resolve_member(
307
+ m, overrides.get(m.repository_id))
308
+ view_members.append(vm)
309
+ resolved[m.repository_id] = vm.snapshot_id
310
+ resolved_snaps[m.repository_id] = snap
311
+ return FederatedView(
312
+ workspace_id=ws.id,
313
+ workspace_name=ws.name,
314
+ view_id=compute_view_id(
315
+ resolved,
316
+ identities=self._fstore.repo_identities(resolved),
317
+ member_versions={
318
+ rid: _analyzer_digest(resolved_snaps.get(rid))
319
+ for rid in resolved
320
+ },
321
+ schema=schema_version(self._conn),
322
+ ),
323
+ members=tuple(view_members),
324
+ )
325
+
326
+ def _resolve_member(
327
+ self, member: FederationMember, override: int | None
328
+ ) -> tuple[ViewMember, Snapshot | None]:
329
+ """Resolve o membro — ``(ViewMember, snapshot escolhido|None)``.
330
+
331
+ O ``Snapshot`` acompanha o retorno para que a identidade da view
332
+ carregue o digest das ``analyzer_versions`` **sem re-query** —
333
+ a versão dos adapters/resolvers que produziram os dados é parte
334
+ da cache key (lf-0134).
335
+ """
336
+ if override is not None:
337
+ snap = self._store.snapshot_by_id(override)
338
+ if snap is None or snap.repository_id != member.repository_id:
339
+ raise SnapshotNotFoundError(
340
+ f"snapshot {override} not found for repository "
341
+ f"{member.repository_id}"
342
+ )
343
+ if str(snap.status) not in _READABLE_STATUSES:
344
+ raise SnapshotNotReadyError(
345
+ f"snapshot {override} is {snap.status}, "
346
+ "not readable"
347
+ )
348
+ return ViewMember(
349
+ repository_id=member.repository_id,
350
+ snapshot_id=snap.id,
351
+ pinned=True,
352
+ resolution="pin-override",
353
+ snapshot_status=str(snap.status),
354
+ ), snap
355
+ if member.pinned_snapshot_id is not None:
356
+ snap = self._store.snapshot_by_id(member.pinned_snapshot_id)
357
+ if snap is not None and snap.repository_id == member.repository_id:
358
+ return ViewMember(
359
+ repository_id=member.repository_id,
360
+ snapshot_id=snap.id,
361
+ pinned=True,
362
+ resolution="pinned",
363
+ snapshot_status=str(snap.status),
364
+ ), snap
365
+ # pin apontando para snapshot inexistente/alheio só é
366
+ # possível via adulteração manual — reporta, não adivinha.
367
+ return ViewMember(
368
+ repository_id=member.repository_id,
369
+ snapshot_id=None,
370
+ pinned=True,
371
+ resolution="pin-invalid",
372
+ snapshot_status="",
373
+ ), None
374
+ snap = self._store.latest_ready_snapshot(member.repository_id)
375
+ if snap is None:
376
+ return ViewMember(
377
+ repository_id=member.repository_id,
378
+ snapshot_id=None,
379
+ pinned=False,
380
+ resolution="no-ready-snapshot",
381
+ snapshot_status="",
382
+ ), None
383
+ return ViewMember(
384
+ repository_id=member.repository_id,
385
+ snapshot_id=snap.id,
386
+ pinned=False,
387
+ resolution="latest-ready",
388
+ snapshot_status=str(snap.status),
389
+ ), snap
390
+
391
+ # --- leitura federada -------------------------------------------------
392
+
393
+ def _member_occurrences(
394
+ self, view: FederatedView
395
+ ) -> dict[int, list[SharedOccurrence]]:
396
+ """Ocorrências de domínio por membro — ancoradas no snapshot da view."""
397
+ out: dict[int, list[SharedOccurrence]] = {}
398
+ for repo_id, snap_id in view.snapshot_map.items():
399
+ occs: list[SharedOccurrence] = []
400
+ for row in self._fstore.snapshot_entity_occurrences(snap_id):
401
+ skey = shared_key_of(str(row["identity_key"]))
402
+ if skey is None:
403
+ continue # chave corrompida — nunca fabrica identidade
404
+ occs.append(
405
+ SharedOccurrence(
406
+ repository_id=repo_id,
407
+ snapshot_id=snap_id,
408
+ entity_id=int(row["entity_id"]),
409
+ occurrence_id=int(row["occ_id"]),
410
+ domain=str(row["domain"]),
411
+ kind=str(row["kind"]),
412
+ identity_key=str(row["identity_key"]),
413
+ shared_key=skey,
414
+ qualified_name=str(row["qualified_name"]),
415
+ name=str(row["name"]),
416
+ catalog=str(row["catalog"]),
417
+ namespace=str(row["namespace"]),
418
+ environment=str(row["environment"]),
419
+ path=str(row["source_path"] or ""),
420
+ line=(
421
+ None if row["line"] is None else int(row["line"])
422
+ ),
423
+ col=(None if row["col"] is None else int(row["col"])),
424
+ extraction_status=str(row["extraction_status"]),
425
+ )
426
+ )
427
+ out[repo_id] = occs
428
+ return out
429
+
430
+ def entity_occurrences(
431
+ self,
432
+ ws: FederationWorkspace,
433
+ key: str,
434
+ pins: Mapping[int, int] | None = None,
435
+ ) -> dict[str, object]:
436
+ """Quais repos contêm a identidade pedida — ocorrências por repo."""
437
+ skey = normalize_lookup_key(key)
438
+ view = self.resolve_view(ws, pins)
439
+ items: list[SharedOccurrence] = []
440
+ for occs in self._member_occurrences(view).values():
441
+ items.extend(o for o in occs if o.shared_key == skey)
442
+ items.sort(key=lambda o: (o.repository_id, o.entity_id, o.occurrence_id))
443
+ repos = sorted({o.repository_id for o in items})
444
+ payload = view.to_dict()
445
+ payload.update(
446
+ {
447
+ "key": key,
448
+ "shared_key": skey,
449
+ "items": [o.to_dict() for o in items],
450
+ "repositories_with_entity": repos,
451
+ "total": len(items),
452
+ }
453
+ )
454
+ return payload
455
+
456
+ def graph(
457
+ self,
458
+ ws: FederationWorkspace,
459
+ depth: int = 1,
460
+ limit: int = 500,
461
+ pins: Mapping[int, int] | None = None,
462
+ ) -> FederatedGraph:
463
+ """Grafo federado: sementes compartilhadas + BFS limitado.
464
+
465
+ Sementes = entidades cuja ``shared_key`` aparece em >=2 repos
466
+ membros (o único vínculo cross-repo honesto desta fase).
467
+ ``depth`` = hops de BFS sobre arestas intra-snapshot
468
+ (``domain_relations`` entidade↔entidade, ambas as direções) **e**
469
+ links ``shared_entity``; ``limit`` encima nós+arestas —
470
+ ``truncated`` declara o corte.
471
+ """
472
+ depth = max(0, min(int(depth), MAX_GRAPH_DEPTH))
473
+ view = self.resolve_view(ws, pins)
474
+ per_repo = self._member_occurrences(view)
475
+ entities, entity_repo, entity_paths, shared_repos, occ_to_entity = (
476
+ _collect_entities(per_repo))
477
+
478
+ # arestas intra-snapshot (entidade↔entidade apenas — a camada de
479
+ # domínio desta fase não expande endpoints de símbolo)
480
+ adjacency: dict[int, set[int]] = {}
481
+ intra = self._intra_edges(view, occ_to_entity, adjacency)
482
+ shared_edges, seeds = _shared_edges(
483
+ per_repo, shared_repos, adjacency)
484
+
485
+ # BFS a partir das sementes — hops contam intra e shared.
486
+ visited, truncated = _bfs_expand(seeds, adjacency, depth, limit)
487
+ node_ids = sorted(visited)[:limit]
488
+ node_set = set(node_ids)
489
+ edges = [
490
+ e for e in intra + shared_edges
491
+ if e.from_entity_id in node_set and e.to_entity_id in node_set
492
+ ]
493
+ if len(edges) > limit:
494
+ edges = edges[:limit]
495
+ truncated = True
496
+ nodes = tuple(
497
+ EntityNode(
498
+ entity_id=eid,
499
+ repository_id=entity_repo[eid],
500
+ snapshot_id=view.snapshot_map[entity_repo[eid]],
501
+ domain=entities[eid].domain,
502
+ kind=entities[eid].kind,
503
+ identity_key=entities[eid].identity_key,
504
+ shared_key=entities[eid].shared_key,
505
+ qualified_name=entities[eid].qualified_name,
506
+ name=entities[eid].name,
507
+ paths=tuple(sorted(entity_paths.get(eid, ()))),
508
+ )
509
+ for eid in node_ids
510
+ )
511
+ stats = {
512
+ "member_repositories": len(view.snapshot_map),
513
+ "intra_edges": len(intra),
514
+ "shared_edges": len(shared_edges),
515
+ "candidate_entities": len(entities),
516
+ }
517
+ return FederatedGraph(
518
+ view=view,
519
+ nodes=nodes,
520
+ edges=tuple(edges),
521
+ depth=depth,
522
+ seeds=len(seeds),
523
+ truncated=truncated,
524
+ stats=stats,
525
+ )
526
+
527
+ def _intra_edges(
528
+ self,
529
+ view: FederatedView,
530
+ occ_to_entity: Mapping[int, tuple[int, int]],
531
+ adjacency: dict[int, set[int]],
532
+ ) -> list[FederatedEdge]:
533
+ """Arestas reais de ``domain_relations`` dentro de cada snapshot."""
534
+ intra: list[FederatedEdge] = []
535
+ seen: set[tuple[int, int, str]] = set()
536
+ for snap_id in view.snapshot_map.values():
537
+ for r in self._store.domain_relations_snapshot(snap_id):
538
+ fe, te = r["from_entity_occ_id"], r["to_entity_occ_id"]
539
+ if fe is None or te is None:
540
+ continue
541
+ src = occ_to_entity.get(int(fe))
542
+ dst = occ_to_entity.get(int(te))
543
+ if src is None or dst is None:
544
+ continue
545
+ edge_key = (src[1], dst[1], str(r["type"]))
546
+ if edge_key in seen:
547
+ continue
548
+ seen.add(edge_key)
549
+ intra.append(
550
+ FederatedEdge(
551
+ kind="intra",
552
+ relation_id=int(r["id"]),
553
+ from_entity_id=src[1],
554
+ to_entity_id=dst[1],
555
+ from_repo=src[0],
556
+ to_repo=dst[0],
557
+ relation_type=str(r["type"]),
558
+ resolution_status=str(r["resolution_status"]),
559
+ confidence_class=str(r["confidence_class"]),
560
+ resolver=str(r["resolver"]),
561
+ )
562
+ )
563
+ adjacency.setdefault(src[1], set()).add(dst[1])
564
+ adjacency.setdefault(dst[1], set()).add(src[1])
565
+ return intra
566
+
567
+
568
+ def _analyzer_digest(snap: Snapshot | None) -> str:
569
+ """sha256 das ``analyzer_versions`` do snapshot resolvido.
570
+
571
+ Componente "resolver/adapter versions" da cache key da view
572
+ (lf-0134): o digest das versões de parser/extrator/resolver/adapter
573
+ que produziram os rows daquele snapshot. ``None`` (membro sem
574
+ snapshot legível) → ``""`` — ausência declarada, nunca fabricada.
575
+ """
576
+ if snap is None:
577
+ return ""
578
+ return hashlib.sha256(
579
+ json.dumps(snap.analyzer_versions, sort_keys=True).encode("utf-8")
580
+ ).hexdigest()
581
+
582
+
583
+ def _collect_entities(
584
+ per_repo: Mapping[int, list[SharedOccurrence]],
585
+ ) -> tuple[
586
+ dict[int, SharedOccurrence],
587
+ dict[int, int],
588
+ dict[int, set[str]],
589
+ dict[str, set[int]],
590
+ dict[int, tuple[int, int]],
591
+ ]:
592
+ """Índices por entidade a partir das ocorrências por membro."""
593
+ occ_to_entity: dict[int, tuple[int, int]] = {} # occ → (repo, ent)
594
+ entities: dict[int, SharedOccurrence] = {}
595
+ entity_paths: dict[int, set[str]] = {}
596
+ shared_repos: dict[str, set[int]] = {}
597
+ entity_repo: dict[int, int] = {}
598
+ for repo_id, occs in per_repo.items():
599
+ for o in occs:
600
+ occ_to_entity[o.occurrence_id] = (repo_id, o.entity_id)
601
+ entities[o.entity_id] = o
602
+ entity_repo[o.entity_id] = repo_id
603
+ if o.path:
604
+ entity_paths.setdefault(o.entity_id, set()).add(o.path)
605
+ shared_repos.setdefault(o.shared_key, set()).add(repo_id)
606
+ return entities, entity_repo, entity_paths, shared_repos, occ_to_entity
607
+
608
+
609
+ def _shared_edges(
610
+ per_repo: Mapping[int, list[SharedOccurrence]],
611
+ shared_repos: Mapping[str, set[int]],
612
+ adjacency: dict[int, set[int]],
613
+ ) -> tuple[list[FederatedEdge], set[int]]:
614
+ """``shared_entity`` computadas: mesma shared_key em repos distintos."""
615
+ edges: list[FederatedEdge] = []
616
+ seeds: set[int] = set()
617
+ seen: set[frozenset[int]] = set()
618
+ shared_entities = {
619
+ skey for skey, repos in shared_repos.items() if len(repos) > 1
620
+ }
621
+ for skey in sorted(shared_entities):
622
+ members = [
623
+ o for occs in per_repo.values()
624
+ for o in occs if o.shared_key == skey
625
+ ]
626
+ for i, a in enumerate(members):
627
+ seeds.add(a.entity_id)
628
+ for b in members[i + 1:]:
629
+ if a.repository_id == b.repository_id:
630
+ continue
631
+ pair = frozenset((a.entity_id, b.entity_id))
632
+ if pair in seen:
633
+ continue
634
+ seen.add(pair)
635
+ edges.append(
636
+ FederatedEdge(
637
+ kind="shared_entity",
638
+ from_entity_id=a.entity_id,
639
+ to_entity_id=b.entity_id,
640
+ from_repo=a.repository_id,
641
+ to_repo=b.repository_id,
642
+ relation_type="shared_entity",
643
+ resolution_status="resolved",
644
+ confidence_class="resolved",
645
+ resolver=FEDERATION_RESOLVER_ID,
646
+ computed=True,
647
+ )
648
+ )
649
+ adjacency.setdefault(a.entity_id, set()).add(b.entity_id)
650
+ adjacency.setdefault(b.entity_id, set()).add(a.entity_id)
651
+ return edges, seeds
652
+
653
+
654
+ def _bfs_expand(
655
+ seeds: set[int],
656
+ adjacency: Mapping[int, set[int]],
657
+ depth: int,
658
+ limit: int,
659
+ ) -> tuple[set[int], bool]:
660
+ """BFS limitado: ``depth`` hops, ``limit`` nós — retorna visitados."""
661
+ visited: set[int] = set(seeds)
662
+ frontier = set(seeds)
663
+ hops = 0
664
+ while frontier and hops < depth:
665
+ hops += 1
666
+ nxt: set[int] = set()
667
+ for ent in sorted(frontier):
668
+ for nb in adjacency.get(ent, ()):
669
+ if nb not in visited:
670
+ if len(visited) >= limit:
671
+ return visited, True
672
+ visited.add(nb)
673
+ nxt.add(nb)
674
+ frontier = nxt
675
+ return visited, False
676
+