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,1208 @@
1
+ """Resolução cross-domain (lf-0117, Cycle 7 Phase 9).
2
+
3
+ A camada que conecta entidades observadas por **adapters diferentes**
4
+ dentro do mesmo snapshot: a tabela que o SQL declara e o PySpark lê, o
5
+ ``aws_glue_job`` que o Terraform provisiona e o script que o job executa,
6
+ o literal ``fetch("/v1/pets")`` e o ``GET /v1/pets`` do contrato OpenAPI.
7
+
8
+ Regra inegociável: **nunca casar por nome nu**. Um vínculo exige acordo
9
+ de identidade qualificada — ``catalog``/``namespace``/``environment``/
10
+ ``root`` + ``qualified_name``, ou conta+região+recurso para ARNs AWS,
11
+ ou igualdade literal de ``method+path`` para consumidor↔endpoint. Nome
12
+ igual com qualificador divergente NÃO é resolvido — é exatamente o caso
13
+ que ``DomainIdentity`` existe para separar.
14
+
15
+ Desenho:
16
+
17
+ - opera sobre estado **persistido** do snapshot (``domain_entities`` +
18
+ ``domain_entity_occurrences`` + ``domain_relations`` + símbolos) —
19
+ nada é re-extraído de arquivo;
20
+ - ``plan`` é puro: devolve ``CrossResolutionPlan`` com *updates* (rows
21
+ ``unresolved`` re-resolvidas in place) e *edges* novas — a gravação é
22
+ ``Store.persist_cross_resolutions`` dentro da transação de publicação;
23
+ - isolamento por snapshot: todo candidato é ocorrência do **mesmo**
24
+ snapshot; a auditoria ``cross_snapshot_domain_relations`` cobre as
25
+ arestas novas como qualquer outra;
26
+ - ``inferred`` nunca vira ``resolved`` (invariante de ``DomainRelation``).
27
+
28
+ Estados honestos (spec):
29
+
30
+ - identidade qualificada plena → ``resolved``;
31
+ - acordo parcial (nome+ns, mas catálogo/env diverge) → ``ambiguous``
32
+ com todos os candidatos preservados no ``to_unresolved``;
33
+ - lado exclusivamente externo (URL absoluta, ARN sem entidade local)
34
+ → ``external``;
35
+ - nome nu sem qualificador → continua ``unresolved`` e vira
36
+ ``CrossDiagnostic`` — nunca aresta.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import re
43
+ import sqlite3
44
+ from collections.abc import Mapping
45
+ from dataclasses import dataclass, field
46
+ from enum import StrEnum
47
+ from typing import TYPE_CHECKING
48
+
49
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
50
+ from code_atlas.domains.api.common import is_absolute_url, url_path
51
+ from code_atlas.domains.identity import DomainIdentity
52
+ from code_atlas.domains.model import (
53
+ DomainEntityKind,
54
+ DomainRelation,
55
+ DomainRelationKind,
56
+ )
57
+
58
+ if TYPE_CHECKING:
59
+ # ``storage.store`` importa ``domains.model`` — manter o resolver
60
+ # dentro do pacote domains exige quebrar o ciclo: Store só aparece
61
+ # como anotação, nunca em runtime.
62
+ from code_atlas.storage.store import Store
63
+
64
+ CROSS_RESOLVER_ID = "cross-domain"
65
+ CROSS_RESOLVER_VERSION = "0.1.0"
66
+
67
+ # Status que o resolver pode re-resolver: somente ``unresolved``.
68
+ # ``resolved`` nunca é tocado; ``ambiguous``/``external`` são decisões
69
+ # já declaradas pelo adapter — re-derivá-las encolheria a honestidade.
70
+ _RERESOLVABLE = frozenset({str(ResolutionStatus.UNRESOLVED)})
71
+
72
+ # Relações cujo alvo é nome qualificado de dados (tabela/visão/iceberg).
73
+ _DATA_RELATION_TYPES = frozenset(
74
+ {
75
+ str(DomainRelationKind.READS_FROM),
76
+ str(DomainRelationKind.WRITES_TO),
77
+ str(DomainRelationKind.TRANSFORMS),
78
+ str(DomainRelationKind.DERIVES_FROM),
79
+ }
80
+ )
81
+
82
+ # Atributos de entidade cujos valores podem ancorar um artefato do repo.
83
+ # Adapters registram observáveis como strings; um valor que é path
84
+ # relativo do repositório é evidência literal (path qualificado).
85
+ _PATH_ATTR_KEYS = frozenset(
86
+ {
87
+ "script_location", "source", "path", "file", "filename",
88
+ "entry_point", "artifact", "artifacts_out", "artifacts_in",
89
+ "definition_path", "target",
90
+ }
91
+ )
92
+
93
+ _ABS_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
94
+ _ARN_RE = re.compile(
95
+ r"^arn:(?P<partition>[a-z0-9-]+):(?P<service>[a-z0-9-]+):"
96
+ r"(?P<region>[a-z0-9-]*):(?P<account>[0-9]*):(?P<resource>.+)$"
97
+ )
98
+ _METHOD_NOTE_RE = re.compile(r"^method=([A-Z]+)$")
99
+ _METHOD_QNAME_RE = re.compile(r"^([A-Z]+)\s+(\S.*)$")
100
+ # Marcadores que adapters usam para "não é candidato estático" — nunca
101
+ # viram resolução nem diagnóstico (são honestidade, não pendência).
102
+ _SKIP_PREFIXES = ("dynamic:", "${dynamic}", "environment:")
103
+
104
+
105
+ class Agreement(StrEnum):
106
+ """Grau de acordo entre uma referência e uma identidade de entidade."""
107
+
108
+ FULL = "full" # todos os componentes qualificados iguais
109
+ PARTIAL = "partial" # nome + algum qualificador, com divergência
110
+ NAME_ONLY = "name" # só o nome casa — nunca resolve
111
+ NONE = "none"
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class _RefParts:
116
+ """Parse posicional de um ``to_unresolved`` textual."""
117
+
118
+ catalog: str = ""
119
+ namespace: str = ""
120
+ environment: str = ""
121
+ root: str = ""
122
+ name: str = ""
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class _Occ:
127
+ """Ocorrência de entidade de domínio num snapshot (visão do resolver)."""
128
+
129
+ occ_id: int
130
+ entity_id: int
131
+ identity: DomainIdentity
132
+ kind: str
133
+ attributes: Mapping[str, str]
134
+ source_file_id: int | None
135
+ source_path: str
136
+ span_start: int | None
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class _RelRow:
141
+ """Row de ``domain_relations`` do snapshot."""
142
+
143
+ relation_id: int
144
+ relation_type: str
145
+ from_entity_occ_id: int | None
146
+ from_symbol_occ_id: int | None
147
+ to_entity_occ_id: int | None
148
+ to_symbol_occ_id: int | None
149
+ to_unresolved: str | None
150
+ resolution_status: str
151
+ confidence_class: str
152
+ resolver: str
153
+ evidence_ids: tuple[int, ...]
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class CrossUpdate:
158
+ """Re-resolução in place de uma row ``domain_relations`` existente."""
159
+
160
+ relation_id: int
161
+ resolution_status: ResolutionStatus
162
+ confidence_class: ConfidenceClass
163
+ to_entity_occ_id: int | None = None
164
+ to_symbol_occ_id: int | None = None
165
+ to_unresolved: str | None = None
166
+ evidence_ids: tuple[int, ...] = ()
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class CrossEdge:
171
+ """Nova aresta cross-domain entre ocorrências do mesmo snapshot."""
172
+
173
+ relation_type: str
174
+ resolution_status: ResolutionStatus
175
+ confidence_class: ConfidenceClass
176
+ from_entity_occ_id: int | None = None
177
+ from_symbol_occ_id: int | None = None
178
+ to_entity_occ_id: int | None = None
179
+ to_symbol_occ_id: int | None = None
180
+ to_unresolved: str | None = None
181
+ evidence_ids: tuple[int, ...] = ()
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class CrossDiagnostic:
186
+ """Diagnóstico do resolver — candidato não-resolvido, nunca aresta."""
187
+
188
+ code: str
189
+ severity: str # info | warning
190
+ message: str
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class CrossResolutionPlan:
195
+ """Saída determinística de ``CrossDomainResolver.plan``.
196
+
197
+ ``update_rows()``/``insert_rows()`` produzem as tuplas primitivas que
198
+ ``Store.persist_cross_resolutions`` grava — o store não conhece os
199
+ dataclasses daqui (fronteira limpa, mesmo desenho de ``persist``).
200
+ """
201
+
202
+ updates: tuple[CrossUpdate, ...] = ()
203
+ edges: tuple[CrossEdge, ...] = ()
204
+ diagnostics: tuple[CrossDiagnostic, ...] = ()
205
+ stats: Mapping[str, int] = field(default_factory=dict)
206
+
207
+ def update_rows(self) -> list[tuple[object, ...]]:
208
+ """(to_ent, to_sym, to_text, status, confidence, resolver, evi, rid)."""
209
+ return [
210
+ (
211
+ u.to_entity_occ_id,
212
+ u.to_symbol_occ_id,
213
+ u.to_unresolved,
214
+ str(u.resolution_status),
215
+ str(u.confidence_class),
216
+ CROSS_RESOLVER_ID,
217
+ json.dumps(sorted(u.evidence_ids)),
218
+ u.relation_id,
219
+ )
220
+ for u in self.updates
221
+ ]
222
+
223
+ def insert_rows(self, snapshot_id: int) -> list[tuple[object, ...]]:
224
+ """Mesmo shape de ``Store.insert_domain_relations``."""
225
+ return [
226
+ (
227
+ snapshot_id,
228
+ e.from_entity_occ_id,
229
+ e.from_symbol_occ_id,
230
+ e.to_entity_occ_id,
231
+ e.to_symbol_occ_id,
232
+ e.to_unresolved,
233
+ e.relation_type,
234
+ str(e.resolution_status),
235
+ str(e.confidence_class),
236
+ CROSS_RESOLVER_ID,
237
+ json.dumps(sorted(e.evidence_ids)),
238
+ )
239
+ for e in self.edges
240
+ ]
241
+
242
+
243
+ # ---------------------------------------------------------------------
244
+ # parsing de texto não-resolvido
245
+ # ---------------------------------------------------------------------
246
+
247
+
248
+ def _name_parses(text: str) -> list[_RefParts]:
249
+ """Todos os parses posicionais plausíveis de um nome pontuado.
250
+
251
+ ``a.b.c`` rende parses por ponto de corte — ``(ns=a.b, name=c)``,
252
+ ``(catalog=a, ns=b, name=c)``, ``(ns=a, name=b.c)`` etc. O conjunto
253
+ é o que permite comparar texto heterogêneo (``db.t`` ANSI,
254
+ ``module.vpc.aws_glue_job.etl`` Terraform) com identidades reais
255
+ sem chutar a estrutura: cada parse é testado e o desfecho agrega
256
+ os candidatos de todos eles — parses que só rendem nomes nus são
257
+ descartados aqui, no nascimento.
258
+ """
259
+ t = text.strip()
260
+ if not t or any(ch in t for ch in " /:\\\"'()[]{}<>*?"):
261
+ return []
262
+ parts = [p for p in t.split(".") if p != ""]
263
+ if ".".join(parts) != t or not parts:
264
+ return []
265
+ if len(parts) == 1:
266
+ return [_RefParts(name=parts[0])]
267
+ out: list[_RefParts] = []
268
+ seen: set[tuple[str, str, str]] = set()
269
+ n = len(parts)
270
+ for k in range(1, n):
271
+ cand = (
272
+ _RefParts(namespace=".".join(parts[:k]), name=".".join(parts[k:])),
273
+ _RefParts(
274
+ catalog=parts[0],
275
+ namespace=".".join(parts[1:k]),
276
+ name=".".join(parts[k:]),
277
+ ),
278
+ )
279
+ for c in cand:
280
+ key = (c.catalog, c.namespace, c.name)
281
+ if key not in seen:
282
+ seen.add(key)
283
+ out.append(c)
284
+ return out
285
+
286
+
287
+ def _repo_path(text: str) -> str | None:
288
+ """``to_unresolved`` como path relativo do repo; ``None`` se não for."""
289
+ t = text.strip()
290
+ if t.startswith("local:"):
291
+ t = t[len("local:"):]
292
+ if not t or _ABS_SCHEME_RE.match(t) or t.startswith(
293
+ ("arn:", "dynamic:", "module:", "data.", "/", "environment:")
294
+ ):
295
+ return None
296
+ if t.startswith("./"):
297
+ t = t[2:]
298
+ if not t or t.startswith(".") or any(
299
+ ch in t for ch in " \\'\"()[]{}<>*?"
300
+ ):
301
+ return None
302
+ return t
303
+
304
+
305
+ def _arn_parts(text: str) -> tuple[str, str, str] | None:
306
+ """ARN AWS → ``(account, region, resource_name)``; ``None`` se não ARN."""
307
+ m = _ARN_RE.match(text.strip())
308
+ if m is None:
309
+ return None
310
+ resource = m.group("resource")
311
+ leaf = re.split(r"[/:]", resource)[-1]
312
+ if not leaf:
313
+ return None
314
+ return m.group("account"), m.group("region"), leaf
315
+
316
+
317
+ def _agreement(ref: _RefParts, ident: DomainIdentity) -> Agreement:
318
+ """Grau de acordo ref↔identidade — o coração da regra "nunca nome nu".
319
+
320
+ ``FULL`` exige igualdade em catalog+namespace+environment+root **e**
321
+ ao menos um qualificador não-vazio (acordo só de nome nu = NAME_ONLY,
322
+ mesmo quando ambos os lados são igualmente não-qualificados).
323
+ ``PARTIAL`` = nome igual, ≥1 qualificador não-vazio igual, ≥1
324
+ qualificador divergente (catálogo/env/root distintos — o caso que
325
+ identidade qualificada existe para separar).
326
+ """
327
+ if ref.name != ident.qualified_name:
328
+ return Agreement.NONE
329
+ diverged = (
330
+ ref.catalog != ident.catalog
331
+ or ref.namespace != ident.namespace
332
+ or ref.environment != ident.environment
333
+ or ref.root != ident.root
334
+ )
335
+ if not diverged:
336
+ if ref.catalog or ref.namespace or ref.environment or ref.root:
337
+ return Agreement.FULL
338
+ return Agreement.NAME_ONLY
339
+ partial = (
340
+ (ref.catalog != "" and ref.catalog == ident.catalog)
341
+ or (ref.namespace != "" and ref.namespace == ident.namespace)
342
+ )
343
+ return Agreement.PARTIAL if partial else Agreement.NAME_ONLY
344
+
345
+
346
+ def _pair_agreement(a: DomainIdentity, b: DomainIdentity) -> Agreement:
347
+ """Acordo entidade↔entidade (mesma regra de ``_agreement``)."""
348
+ ref = _RefParts(
349
+ catalog=a.catalog,
350
+ namespace=a.namespace,
351
+ environment=a.environment,
352
+ root=a.root,
353
+ name=a.qualified_name,
354
+ )
355
+ return _agreement(ref, b)
356
+
357
+
358
+ def _ident_from_row(row: sqlite3.Row) -> DomainIdentity | None:
359
+ """Reconstrói a identidade completa a partir da ``identity_key``.
360
+
361
+ As colunas ``catalog``/``namespace``/``environment``/``qualified_name``
362
+ cobrem a maior parte; ``root`` só existe dentro da chave canônica —
363
+ por isso o parse da chave é a fonte única (coerente com a UNIQUE).
364
+ """
365
+ segs = DomainIdentity.parse_key(str(row["identity_key"]))
366
+ if segs is None or len(segs) < 7:
367
+ return None
368
+ domain, repository, root, catalog, namespace, env, qname = segs[:7]
369
+ return DomainIdentity(
370
+ domain=domain, repository=repository, root=root, catalog=catalog,
371
+ namespace=namespace, environment=env, qualified_name=qname,
372
+ )
373
+
374
+
375
+ def _endpoint_path(occ: _Occ) -> str:
376
+ """Path literal de um ``APIEndpoint`` — atributo ou ``"METHOD /p"``."""
377
+ fp = occ.attributes.get("full_path", "")
378
+ if fp:
379
+ return fp
380
+ m = _METHOD_QNAME_RE.match(occ.identity.qualified_name)
381
+ return m.group(2).strip() if m else occ.identity.qualified_name
382
+
383
+
384
+ def _endpoint_method(occ: _Occ) -> str:
385
+ m = _METHOD_QNAME_RE.match(occ.identity.qualified_name)
386
+ declared = occ.attributes.get("method", "")
387
+ if declared:
388
+ return declared.upper()
389
+ return m.group(1).upper() if m else ""
390
+
391
+
392
+ class CrossDomainResolver:
393
+ """Resolver opt-in de vínculos entre adapters distintos (Phase 9).
394
+
395
+ Stateless: ``plan(store, snapshot_id)`` lê o estado persistido do
396
+ snapshot (incluindo rows não-commitadas da transação corrente —
397
+ é assim que roda dentro do BEGIN de publicação) e devolve o plano
398
+ determinístico. Nenhuma escrita acontece aqui.
399
+ """
400
+
401
+ resolver_id = CROSS_RESOLVER_ID
402
+ resolver_version = CROSS_RESOLVER_VERSION
403
+
404
+ # -- API pública ---------------------------------------------------
405
+
406
+ def plan(self, store: Store, snapshot_id: int) -> CrossResolutionPlan:
407
+ """Propõe updates/edges para ``snapshot_id`` — somente leitura.
408
+
409
+ Determinístico: ordenação por ids/identity_keys; re-executável —
410
+ rows já marcadas ``resolver='cross-domain'`` não são tocadas e
411
+ arestas já existentes entram no dedup, então um segundo ``plan``
412
+ sobre o mesmo estado converge para vazio.
413
+ """
414
+ occs, by_path = self._load_occurrences(store, snapshot_id)
415
+ rels = self._load_relations(store, snapshot_id)
416
+ evidence = self._load_evidence(store, snapshot_id)
417
+ file_occs = self._load_file_occs(store, snapshot_id)
418
+ ctx = _Ctx(
419
+ occs=occs, by_path=by_path, evidence=evidence,
420
+ file_occs=file_occs, linked=self._linked_pairs(rels),
421
+ )
422
+ updates = self._plan_updates(rels, ctx)
423
+ # pares já ligados por updates entram no dedup — uma row
424
+ # re-resolvida para B não pode também virar aresta A→B nova.
425
+ for u in updates:
426
+ rel = next(
427
+ r for r in rels if r.relation_id == u.relation_id
428
+ )
429
+ if rel.from_entity_occ_id and u.to_entity_occ_id:
430
+ ctx.linked.add(
431
+ frozenset(
432
+ (int(rel.from_entity_occ_id), u.to_entity_occ_id)
433
+ )
434
+ )
435
+ edges = self._plan_edges(ctx)
436
+ stats = {
437
+ "cross_considered": ctx.considered,
438
+ "cross_resolved": sum(
439
+ 1 for u in updates
440
+ if u.resolution_status is ResolutionStatus.RESOLVED
441
+ ) + sum(
442
+ 1 for e in edges
443
+ if e.resolution_status is ResolutionStatus.RESOLVED
444
+ ),
445
+ "cross_ambiguous": sum(
446
+ 1 for u in updates
447
+ if u.resolution_status is ResolutionStatus.AMBIGUOUS
448
+ ) + sum(
449
+ 1 for e in edges
450
+ if e.resolution_status is ResolutionStatus.AMBIGUOUS
451
+ ),
452
+ "cross_external": sum(
453
+ 1 for u in updates
454
+ if u.resolution_status is ResolutionStatus.EXTERNAL
455
+ ),
456
+ "cross_edges": len(edges),
457
+ "cross_diagnostics": len(ctx.diagnostics),
458
+ "cross_missing_evidence": ctx.missing_evidence,
459
+ }
460
+ return CrossResolutionPlan(
461
+ updates=tuple(updates), edges=tuple(edges),
462
+ diagnostics=tuple(ctx.diagnostics), stats=stats,
463
+ )
464
+
465
+ # -- carga do snapshot ----------------------------------------------
466
+
467
+ def _load_occurrences(
468
+ self, store: Store, snapshot_id: int
469
+ ) -> tuple[dict[int, _Occ], dict[str, list[_Occ]]]:
470
+ occs: dict[int, _Occ] = {}
471
+ by_path: dict[str, list[_Occ]] = {}
472
+ for row in store.domain_occurrences_snapshot(snapshot_id):
473
+ ident = _ident_from_row(row)
474
+ if ident is None:
475
+ continue # chave corrompida — nunca fabrica identidade
476
+ try:
477
+ attrs = json.loads(str(row["attributes"] or "{}"))
478
+ except json.JSONDecodeError:
479
+ attrs = {}
480
+ occ = _Occ(
481
+ occ_id=int(row["occ_id"]),
482
+ entity_id=int(row["entity_id"]),
483
+ identity=ident,
484
+ kind=str(row["kind"]),
485
+ attributes={str(k): str(v) for k, v in attrs.items()},
486
+ source_file_id=(
487
+ None
488
+ if row["source_file_id"] is None
489
+ else int(row["source_file_id"])
490
+ ),
491
+ source_path=str(row["source_path"] or ""),
492
+ span_start=(
493
+ None
494
+ if row["span_start"] is None
495
+ else int(row["span_start"])
496
+ ),
497
+ )
498
+ occs[occ.occ_id] = occ
499
+ if occ.source_path:
500
+ by_path.setdefault(occ.source_path, []).append(occ)
501
+ return occs, by_path
502
+
503
+ def _load_relations(
504
+ self, store: Store, snapshot_id: int
505
+ ) -> list[_RelRow]:
506
+ out: list[_RelRow] = []
507
+ for row in store.domain_relations_snapshot(snapshot_id):
508
+ try:
509
+ ev = tuple(
510
+ int(x) for x in json.loads(str(row["evidence_ids"] or "[]"))
511
+ )
512
+ except json.JSONDecodeError:
513
+ ev = ()
514
+ out.append(
515
+ _RelRow(
516
+ relation_id=int(row["id"]),
517
+ relation_type=str(row["type"]),
518
+ from_entity_occ_id=row["from_entity_occ_id"],
519
+ from_symbol_occ_id=row["from_symbol_occ_id"],
520
+ to_entity_occ_id=row["to_entity_occ_id"],
521
+ to_symbol_occ_id=row["to_symbol_occ_id"],
522
+ to_unresolved=row["to_unresolved"],
523
+ resolution_status=str(row["resolution_status"]),
524
+ confidence_class=str(row["confidence_class"]),
525
+ resolver=str(row["resolver"]),
526
+ evidence_ids=ev,
527
+ )
528
+ )
529
+ return out
530
+
531
+ def _load_evidence(
532
+ self, store: Store, snapshot_id: int
533
+ ) -> dict[int, sqlite3.Row]:
534
+ return {
535
+ int(r["id"]): r
536
+ for r in store.evidence_snapshot(snapshot_id)
537
+ }
538
+
539
+ def _load_file_occs(
540
+ self, store: Store, snapshot_id: int
541
+ ) -> dict[str, list[int]]:
542
+ """path relativo → ids de ocorrência do símbolo FILE."""
543
+ out: dict[str, list[int]] = {}
544
+ for path, occ_id in store.file_symbol_occurrences(snapshot_id):
545
+ out.setdefault(path, []).append(occ_id)
546
+ return out
547
+
548
+ @staticmethod
549
+ def _linked_pairs(rels: list[_RelRow]) -> set[frozenset[int]]:
550
+ """Pares de ocorrências de entidade já ligados — dedup de arestas."""
551
+ out: set[frozenset[int]] = set()
552
+ for r in rels:
553
+ if r.from_entity_occ_id and r.to_entity_occ_id:
554
+ out.add(
555
+ frozenset(
556
+ (int(r.from_entity_occ_id), int(r.to_entity_occ_id))
557
+ )
558
+ )
559
+ return out
560
+
561
+ # -- evidência ------------------------------------------------------
562
+
563
+ def _occ_evidence(
564
+ self, occ: _Occ, ctx: _Ctx
565
+ ) -> tuple[int, ...]:
566
+ """Ids de evidência do adapter que observou ``occ`` no snapshot.
567
+
568
+ Filtro: mesmo ``snapshot_id`` + mesmo ``source_file_id`` +
569
+ ``extractor='domain:<domínio>'``; quando a ocorrência tem span,
570
+ evidências cujo span casa o início são preferidas (precisão sem
571
+ inventar posição). Nada encontrado → ``()`` e o caller decide:
572
+ aresta SEM evidência dos dois lados nunca é emitida.
573
+ """
574
+ if occ.source_file_id is None:
575
+ return ()
576
+ extractor = f"domain:{occ.identity.domain}"
577
+ cands = [
578
+ e for e in ctx.evidence.values()
579
+ if e["source_file_id"] == occ.source_file_id
580
+ and str(e["extractor"]) == extractor
581
+ ]
582
+ if not cands:
583
+ return ()
584
+ if occ.span_start is not None:
585
+ precise = [
586
+ e for e in cands
587
+ if self._evidence_span_start(e) == occ.span_start
588
+ ]
589
+ if precise:
590
+ return tuple(sorted(int(e["id"]) for e in precise))
591
+ return tuple(sorted(int(e["id"]) for e in cands))
592
+
593
+ @staticmethod
594
+ def _evidence_span_start(row: sqlite3.Row) -> int | None:
595
+ raw = row["span"]
596
+ if raw is None:
597
+ return None
598
+ try:
599
+ span = json.loads(str(raw))
600
+ except json.JSONDecodeError:
601
+ return None
602
+ if not isinstance(span, list) or not span:
603
+ return None
604
+ return int(span[0])
605
+
606
+ # -- re-resolução de rows unresolved (M1) ----------------------------
607
+
608
+ def _plan_updates(
609
+ self, rels: list[_RelRow], ctx: _Ctx
610
+ ) -> list[CrossUpdate]:
611
+ updates: list[CrossUpdate] = []
612
+ for rel in sorted(rels, key=lambda r: r.relation_id):
613
+ if rel.resolution_status not in _RERESOLVABLE:
614
+ continue
615
+ if rel.resolver == CROSS_RESOLVER_ID:
616
+ continue # idempotência
617
+ if rel.to_entity_occ_id or rel.to_symbol_occ_id:
618
+ continue
619
+ text = (rel.to_unresolved or "").strip()
620
+ if not text or text.startswith(_SKIP_PREFIXES):
621
+ continue
622
+ ctx.considered += 1
623
+ upd = self._resolve_row(rel, text, ctx)
624
+ if upd is not None:
625
+ updates.append(upd)
626
+ return updates
627
+
628
+ def _resolve_row(
629
+ self, rel: _RelRow, text: str, ctx: _Ctx
630
+ ) -> CrossUpdate | None:
631
+ """Despacha o texto não-resolvido para a regra adequada."""
632
+ producer = self._producer_domain(rel.resolver)
633
+ if is_absolute_url(text):
634
+ # lado só-externo: URL absoluta nunca vira endpoint local.
635
+ return self._mk_update(
636
+ rel, ResolutionStatus.EXTERNAL,
637
+ ConfidenceClass(rel.confidence_class),
638
+ to_unresolved=text, ctx=ctx,
639
+ )
640
+ arn = _arn_parts(text)
641
+ if arn is not None:
642
+ return self._resolve_arn(rel, arn, producer, ctx)
643
+ if rel.relation_type == str(DomainRelationKind.CALLS_ENDPOINT):
644
+ return self._resolve_endpoint(rel, text, ctx)
645
+ path = _repo_path(text)
646
+ if path is not None and (
647
+ "/" in path or path in ctx.file_occs or ctx.by_path.get(path)
648
+ ):
649
+ # path "definitivo": com separador, ou literalmente presente
650
+ # no inventário. Nome nu sem "/" e fora do inventário cai
651
+ # para o parse de nome qualificado → diagnóstico honesto.
652
+ return self._resolve_repo_path(rel, path, producer, ctx)
653
+ parses = _name_parses(text)
654
+ if parses:
655
+ return self._resolve_qualified(rel, text, parses, producer, ctx)
656
+ ctx.diagnostic(
657
+ "unqualified-target", "info",
658
+ f"relation {rel.relation_id} target {text!r} carries no "
659
+ "qualifier — never resolved by name alone",
660
+ )
661
+ return None
662
+
663
+ @staticmethod
664
+ def _producer_domain(resolver: str) -> str:
665
+ """Domínio produtor da row — ``resolver='domain:<id>'`` padrão."""
666
+ return resolver[len("domain:"):] if resolver.startswith("domain:") else ""
667
+
668
+ def _mk_update(
669
+ self,
670
+ rel: _RelRow,
671
+ status: ResolutionStatus,
672
+ confidence: ConfidenceClass,
673
+ ctx: _Ctx,
674
+ *,
675
+ to_entity_occ_id: int | None = None,
676
+ to_symbol_occ_id: int | None = None,
677
+ to_unresolved: str | None = None,
678
+ extra_evidence: tuple[int, ...] = (),
679
+ ) -> CrossUpdate:
680
+ ev = sorted(set(rel.evidence_ids) | set(extra_evidence))
681
+ return CrossUpdate(
682
+ relation_id=rel.relation_id,
683
+ resolution_status=status,
684
+ confidence_class=confidence,
685
+ to_entity_occ_id=to_entity_occ_id,
686
+ to_symbol_occ_id=to_symbol_occ_id,
687
+ to_unresolved=to_unresolved,
688
+ evidence_ids=tuple(ev),
689
+ )
690
+
691
+ # -- regra: nome qualificado de dados ---------------------------------
692
+
693
+ def _resolve_qualified(
694
+ self,
695
+ rel: _RelRow,
696
+ text: str,
697
+ parses: list[_RefParts],
698
+ producer: str,
699
+ ctx: _Ctx,
700
+ ) -> CrossUpdate | None:
701
+ """Casa parses com entidades de **outros** domínios do snapshot.
702
+
703
+ FULL único → resolved. FULL múltiplo ou PARTIAL ≥1 → ambiguous
704
+ (candidatos preservados no texto). NAME_ONLY → diagnóstico.
705
+ """
706
+ full, partial, bare = self._qualified_candidates(
707
+ parses, producer, ctx
708
+ )
709
+ if len(full) == 1:
710
+ occ = next(iter(full.values()))
711
+ evi = self._occ_evidence(occ, ctx)
712
+ if not evi:
713
+ return self._missing_evidence(rel, occ, ctx)
714
+ return self._mk_update(
715
+ rel, ResolutionStatus.RESOLVED, ConfidenceClass.RESOLVED,
716
+ to_entity_occ_id=occ.occ_id, extra_evidence=evi, ctx=ctx,
717
+ )
718
+ if len(full) > 1:
719
+ return self._ambiguous(rel, text, sorted(full.values(),
720
+ key=lambda o: o.occ_id), ctx)
721
+ if partial:
722
+ return self._ambiguous(
723
+ rel, text,
724
+ sorted(partial.values(), key=lambda o: o.occ_id), ctx,
725
+ )
726
+ if bare:
727
+ ctx.diagnostic(
728
+ "unqualified-target", "info",
729
+ f"relation {rel.relation_id} target {text!r} is a bare "
730
+ "name — no qualifier, never resolved",
731
+ )
732
+ else:
733
+ ctx.diagnostic(
734
+ "no-cross-target", "info",
735
+ f"relation {rel.relation_id} target {text!r} has no "
736
+ "cross-domain entity candidate in this snapshot",
737
+ )
738
+ return None
739
+
740
+ @staticmethod
741
+ def _qualified_candidates(
742
+ parses: list[_RefParts], producer: str, ctx: _Ctx
743
+ ) -> tuple[dict[int, _Occ], dict[int, _Occ], bool]:
744
+ """Scan de candidatos: ``(full, partial, bare)`` por agreement."""
745
+ full: dict[int, _Occ] = {}
746
+ partial: dict[int, _Occ] = {}
747
+ bare = all(not (p.catalog or p.namespace) for p in parses)
748
+ for p in parses:
749
+ for occ in ctx.occs.values():
750
+ if producer and occ.identity.domain == producer:
751
+ continue # cross-domain: alvo precisa ser outro adapter
752
+ ag = _agreement(p, occ.identity)
753
+ if ag is Agreement.FULL:
754
+ full.setdefault(occ.occ_id, occ)
755
+ elif ag is Agreement.PARTIAL:
756
+ partial.setdefault(occ.occ_id, occ)
757
+ return full, partial, bare
758
+
759
+ def _ambiguous(
760
+ self, rel: _RelRow, text: str, cands: list[_Occ], ctx: _Ctx
761
+ ) -> CrossUpdate:
762
+ """Ambiguous honesto: todos os candidatos ficam no ``to_unresolved``."""
763
+ disp = ", ".join(sorted(o.identity.display() for o in cands))
764
+ return self._mk_update(
765
+ rel, ResolutionStatus.AMBIGUOUS, ConfidenceClass.SYNTACTIC,
766
+ to_unresolved=f"{text} [candidates: {disp}]", ctx=ctx,
767
+ )
768
+
769
+ def _missing_evidence(
770
+ self, rel: _RelRow, occ: _Occ, ctx: _Ctx
771
+ ) -> CrossUpdate | None:
772
+ """Candidato sem evidência localizável → não resolve (evidence-first)."""
773
+ ctx.missing_evidence += 1
774
+ ctx.diagnostic(
775
+ "missing-evidence", "warning",
776
+ f"relation {rel.relation_id} candidate "
777
+ f"{occ.identity.display()} has no locatable source evidence "
778
+ "— not resolved",
779
+ )
780
+ return None
781
+
782
+ # -- regra: consumidor ↔ endpoint (method+path literal) ---------------
783
+
784
+ def _resolve_endpoint(
785
+ self, rel: _RelRow, text: str, ctx: _Ctx
786
+ ) -> CrossUpdate | None:
787
+ """``CALLS_ENDPOINT`` — igualdade literal de method+path.
788
+
789
+ Regra interna do domínio ``api`` (o adapter declara os
790
+ candidatos mas não resolve — handoff explícito da Phase 9):
791
+ vale para endpoints de qualquer domínio produtor, exige
792
+ ``kind=api_endpoint``. URL absoluta já saiu como ``external``
793
+ antes daqui; path é extraído de ``url_path`` (sem urllib).
794
+ """
795
+ path = url_path(text)
796
+ declared = self._declared_method(rel, ctx)
797
+ cands: dict[int, _Occ] = {}
798
+ for occ in ctx.occs.values():
799
+ if occ.kind != str(DomainEntityKind.API_ENDPOINT):
800
+ continue
801
+ if _endpoint_path(occ) != path:
802
+ continue
803
+ if declared and _endpoint_method(occ) not in (declared, "ANY"):
804
+ continue
805
+ cands[occ.occ_id] = occ
806
+ if len(cands) == 1:
807
+ occ = next(iter(cands.values()))
808
+ evi = self._occ_evidence(occ, ctx)
809
+ if not evi:
810
+ return self._missing_evidence(rel, occ, ctx)
811
+ return self._mk_update(
812
+ rel, ResolutionStatus.RESOLVED, ConfidenceClass.RESOLVED,
813
+ to_entity_occ_id=occ.occ_id, extra_evidence=evi, ctx=ctx,
814
+ )
815
+ if len(cands) > 1:
816
+ return self._ambiguous(
817
+ rel, text,
818
+ sorted(cands.values(), key=lambda o: o.occ_id), ctx,
819
+ )
820
+ ctx.diagnostic(
821
+ "endpoint-unmatched", "info",
822
+ f"relation {rel.relation_id} call literal {text!r} matched "
823
+ "no api_endpoint entity (method+path)",
824
+ )
825
+ return None
826
+
827
+ @staticmethod
828
+ def _declared_method(rel: _RelRow, ctx: _Ctx) -> str:
829
+ """``method=X`` declarado em qualquer evidência da relação."""
830
+ for eid in rel.evidence_ids:
831
+ row = ctx.evidence.get(eid)
832
+ if row is None:
833
+ continue
834
+ m = _METHOD_NOTE_RE.match(str(row["notes"] or ""))
835
+ if m:
836
+ return m.group(1)
837
+ return ""
838
+
839
+ # -- regra: ARN AWS (conta+região+recurso) ----------------------------
840
+
841
+ def _resolve_arn(
842
+ self,
843
+ rel: _RelRow,
844
+ arn: tuple[str, str, str],
845
+ producer: str,
846
+ ctx: _Ctx,
847
+ ) -> CrossUpdate | None:
848
+ """ARN → entidade com catálogo ``*:<conta>:<região>`` + nome.
849
+
850
+ Sem entidade local que prove conta+região+recurso, o ARN é
851
+ externo por definição — status sobe para ``external`` honesto.
852
+ """
853
+ account, region, name = arn
854
+ suffix = f":{account}:{region}" if (account or region) else ""
855
+ cands: dict[int, _Occ] = {}
856
+ partial: dict[int, _Occ] = {}
857
+ for occ in ctx.occs.values():
858
+ if producer and occ.identity.domain == producer:
859
+ continue
860
+ cat = occ.identity.catalog
861
+ if occ.identity.qualified_name != name:
862
+ continue
863
+ if suffix and cat.endswith(suffix):
864
+ cands[occ.occ_id] = occ
865
+ elif cat:
866
+ partial[occ.occ_id] = occ
867
+ if len(cands) == 1:
868
+ occ = next(iter(cands.values()))
869
+ evi = self._occ_evidence(occ, ctx)
870
+ if not evi:
871
+ return self._missing_evidence(rel, occ, ctx)
872
+ return self._mk_update(
873
+ rel, ResolutionStatus.RESOLVED, ConfidenceClass.RESOLVED,
874
+ to_entity_occ_id=occ.occ_id, extra_evidence=evi, ctx=ctx,
875
+ )
876
+ if len(cands) > 1:
877
+ return self._ambiguous(
878
+ rel, rel.to_unresolved or "",
879
+ sorted(cands.values(), key=lambda o: o.occ_id), ctx,
880
+ )
881
+ if partial:
882
+ return self._ambiguous(
883
+ rel, rel.to_unresolved or "",
884
+ sorted(partial.values(), key=lambda o: o.occ_id), ctx,
885
+ )
886
+ return self._mk_update(
887
+ rel, ResolutionStatus.EXTERNAL,
888
+ ConfidenceClass(rel.confidence_class),
889
+ to_unresolved=rel.to_unresolved or "", ctx=ctx,
890
+ )
891
+
892
+ # -- regra: path de artefato do repo ----------------------------------
893
+
894
+ def _resolve_repo_path(
895
+ self, rel: _RelRow, path: str, producer: str, ctx: _Ctx
896
+ ) -> CrossUpdate | None:
897
+ """Path literal inventariado → entidade naquele path ou símbolo FILE.
898
+
899
+ Preferência: entidade de **outro domínio** observada naquele
900
+ arquivo (alvo semântico) > ocorrência do símbolo ``file`` do
901
+ próprio path (ponte domínio→código, lf-0117/0120).
902
+ """
903
+ ents = [
904
+ o for o in ctx.by_path.get(path, ())
905
+ if not producer or o.identity.domain != producer
906
+ ]
907
+ if len(ents) == 1:
908
+ occ = ents[0]
909
+ evi = self._occ_evidence(occ, ctx)
910
+ if not evi:
911
+ return self._missing_evidence(rel, occ, ctx)
912
+ return self._mk_update(
913
+ rel, ResolutionStatus.RESOLVED, ConfidenceClass.RESOLVED,
914
+ to_entity_occ_id=occ.occ_id, extra_evidence=evi, ctx=ctx,
915
+ )
916
+ if len(ents) > 1:
917
+ return self._ambiguous(rel, path, sorted(ents, key=lambda o: o.occ_id), ctx)
918
+ occ_ids = ctx.file_occs.get(path, ())
919
+ if len(occ_ids) == 1:
920
+ return self._mk_update(
921
+ rel, ResolutionStatus.RESOLVED, ConfidenceClass.RESOLVED,
922
+ to_symbol_occ_id=occ_ids[0], ctx=ctx,
923
+ )
924
+ if len(occ_ids) > 1:
925
+ return self._mk_update(
926
+ rel, ResolutionStatus.AMBIGUOUS, ConfidenceClass.SYNTACTIC,
927
+ to_unresolved=f"{path} [candidates: {len(occ_ids)} file "
928
+ "occurrences]", ctx=ctx,
929
+ )
930
+ ctx.diagnostic(
931
+ "no-cross-target", "info",
932
+ f"relation {rel.relation_id} path target {path!r} not in "
933
+ "this snapshot inventory",
934
+ )
935
+ return None
936
+
937
+ # -- arestas novas entidade↔entidade (M2) ------------------------------
938
+
939
+ def _plan_edges(self, ctx: _Ctx) -> list[CrossEdge]:
940
+ """Pares de entidades de domínios distintos com identidade ligada.
941
+
942
+ Dois vínculos reconhecidos:
943
+
944
+ - **identidade qualificada plena** (mesmo ``qualified_name`` +
945
+ qualificadores iguais) → ``REFERENCES`` resolved — mesma coisa
946
+ lógica observada por dois adapters;
947
+ - **path-anchor**: atributo-path de A == arquivo-fonte de B →
948
+ aresta resolved (``PROVISIONS`` quando A é Terraform — o caso
949
+ canônico ``aws_glue_job`` ↔ script; ``REFERENCES`` demais).
950
+
951
+ Acordo parcial → aresta ``REFERENCES`` ambiguous com ambos os
952
+ candidatos no texto. Nome nu → diagnóstico, nunca aresta.
953
+ """
954
+ proposed: set[frozenset[int]] = set(ctx.linked)
955
+ edges = self._qualified_pair_edges(ctx, proposed)
956
+ edges.extend(self._path_anchor_edges(ctx, proposed))
957
+ edges.sort(
958
+ key=lambda e: (
959
+ e.from_entity_occ_id or 0,
960
+ e.to_entity_occ_id or 0,
961
+ e.relation_type,
962
+ )
963
+ )
964
+ return edges
965
+
966
+ def _qualified_pair_edges(
967
+ self, ctx: _Ctx, proposed: set[frozenset[int]]
968
+ ) -> list[CrossEdge]:
969
+ """Mesmo ``qualified_name`` em domínios distintos → identidade."""
970
+ by_qname: dict[str, list[_Occ]] = {}
971
+ for occ in ctx.occs.values():
972
+ by_qname.setdefault(occ.identity.qualified_name, []).append(occ)
973
+ edges: list[CrossEdge] = []
974
+ for qname in sorted(by_qname):
975
+ group = sorted(by_qname[qname], key=lambda o: o.occ_id)
976
+ for i, a in enumerate(group):
977
+ for b in group[i + 1:]:
978
+ if a.identity.domain == b.identity.domain:
979
+ continue
980
+ pair = frozenset((a.occ_id, b.occ_id))
981
+ if pair in proposed:
982
+ continue
983
+ edge = self._pair_edge(a, b, qname, ctx)
984
+ if edge is not None:
985
+ edges.append(edge)
986
+ proposed.add(pair)
987
+ return edges
988
+
989
+ def _pair_edge(
990
+ self, a: _Occ, b: _Occ, qname: str, ctx: _Ctx
991
+ ) -> CrossEdge | None:
992
+ """Um par cross-domain: FULL → aresta, PARTIAL → ambiguous,
993
+ NAME_ONLY → diagnóstico (nunca aresta)."""
994
+ ag = _pair_agreement(a.identity, b.identity)
995
+ if ag is Agreement.FULL:
996
+ return self._mk_edge(
997
+ a, b, str(DomainRelationKind.REFERENCES),
998
+ ResolutionStatus.RESOLVED, ctx,
999
+ )
1000
+ if ag is Agreement.PARTIAL:
1001
+ return self._mk_ambiguous_edge(a, b, ctx)
1002
+ if ag is Agreement.NAME_ONLY:
1003
+ ctx.diagnostic(
1004
+ "name-only-collision", "info",
1005
+ f"{a.identity.display()} vs {b.identity.display()} "
1006
+ f"share bare name {qname!r} — no qualified agreement, "
1007
+ "no edge",
1008
+ )
1009
+ return None
1010
+
1011
+ def _path_anchor_edges(
1012
+ self, ctx: _Ctx, proposed: set[frozenset[int]]
1013
+ ) -> list[CrossEdge]:
1014
+ """Atributo-path de A ancorado no repo — entidade↔entidade ou
1015
+ entidade→símbolo FILE.
1016
+
1017
+ Preferência: entidade de outro domínio observada no arquivo do
1018
+ anchor (alvo semântico — ``aws_glue_job`` ↔ job que executa o
1019
+ script). Sem entidade, cai para o símbolo FILE do próprio path
1020
+ ("CI publica artefato presente no repo") — mas só se o arquivo
1021
+ tiver evidência-fonte localizável; sem ela, diagnóstico em vez
1022
+ de aresta.
1023
+ """
1024
+ out: list[CrossEdge] = []
1025
+ for a in sorted(ctx.occs.values(), key=lambda o: o.occ_id):
1026
+ for anchor in sorted(self._path_anchors(a)):
1027
+ if anchor == a.source_path:
1028
+ continue # auto-referência ao próprio artefato
1029
+ targets = [
1030
+ o for o in ctx.by_path.get(anchor, ())
1031
+ if o.identity.domain != a.identity.domain
1032
+ ]
1033
+ for b in sorted(targets, key=lambda o: o.occ_id):
1034
+ pair = frozenset((a.occ_id, b.occ_id))
1035
+ if pair in proposed:
1036
+ continue
1037
+ rtype = (
1038
+ str(DomainRelationKind.PROVISIONS)
1039
+ if a.identity.domain == "terraform"
1040
+ else str(DomainRelationKind.REFERENCES)
1041
+ )
1042
+ edge = self._mk_edge(
1043
+ a, b, rtype, ResolutionStatus.RESOLVED, ctx,
1044
+ )
1045
+ if edge is not None:
1046
+ out.append(edge)
1047
+ proposed.add(pair)
1048
+ if targets:
1049
+ continue
1050
+ edge = self._file_anchor_edge(a, anchor, ctx)
1051
+ if edge is not None:
1052
+ out.append(edge)
1053
+ return out
1054
+
1055
+ def _file_anchor_edge(
1056
+ self, a: _Occ, anchor: str, ctx: _Ctx
1057
+ ) -> CrossEdge | None:
1058
+ """Anchor sem entidade de domínio → aresta para o símbolo FILE."""
1059
+ occ_ids = ctx.file_occs.get(anchor, ())
1060
+ if len(occ_ids) != 1:
1061
+ return None
1062
+ evi_a = self._occ_evidence(a, ctx)
1063
+ if not evi_a:
1064
+ ctx.missing_evidence += 1
1065
+ return None
1066
+ # evidência do lado arquivo: qualquer extractor — o fato-fonte é
1067
+ # a existência do arquivo no inventário do snapshot.
1068
+ evi_b = [
1069
+ int(e["id"]) for e in ctx.evidence.values()
1070
+ if str(e["path"]) == anchor
1071
+ ]
1072
+ if not evi_b:
1073
+ ctx.missing_evidence += 1
1074
+ ctx.diagnostic(
1075
+ "missing-evidence", "info",
1076
+ f"edge {a.identity.display()} → file {anchor!r} dropped: "
1077
+ "no evidence rows for the artifact file",
1078
+ )
1079
+ return None
1080
+ rel = DomainRelation(
1081
+ snapshot_id=0,
1082
+ relation_type=str(DomainRelationKind.REFERENCES),
1083
+ resolver=CROSS_RESOLVER_ID,
1084
+ confidence_class=ConfidenceClass.RESOLVED,
1085
+ resolution_status=ResolutionStatus.RESOLVED,
1086
+ from_entity_occ_id=a.occ_id, to_symbol_occ_id=occ_ids[0],
1087
+ )
1088
+ return self._edge_of(
1089
+ rel, tuple(sorted(set(evi_a) | set(evi_b)))
1090
+ )
1091
+
1092
+ @staticmethod
1093
+ def _path_anchors(occ: _Occ) -> set[str]:
1094
+ """Valores-atributo que são paths relativos do repo."""
1095
+ out: set[str] = set()
1096
+ for key, value in occ.attributes.items():
1097
+ if key not in _PATH_ATTR_KEYS:
1098
+ continue
1099
+ for piece in value.split(","):
1100
+ p = piece.strip()
1101
+ if p.startswith("./"):
1102
+ p = p[2:]
1103
+ if p and "/" in p and "://" not in p:
1104
+ out.add(p)
1105
+ return out
1106
+
1107
+ def _mk_edge(
1108
+ self,
1109
+ a: _Occ,
1110
+ b: _Occ,
1111
+ rtype: str,
1112
+ status: ResolutionStatus,
1113
+ ctx: _Ctx,
1114
+ *,
1115
+ to_unresolved: str | None = None,
1116
+ ) -> CrossEdge | None:
1117
+ """Monta aresta com evidência dos DOIS lados — ou nada.
1118
+
1119
+ Direção determinística para ``REFERENCES`` de identidade
1120
+ qualificada: o ``from`` é o domínio lexicograficamente maior
1121
+ (o vínculo é o que importa; a direção não carrega semântica).
1122
+ Sem evidência localizável de um dos lados → diagnóstico e
1123
+ nenhuma aresta (evidence-first é inegociável).
1124
+ """
1125
+ if (
1126
+ rtype == str(DomainRelationKind.REFERENCES)
1127
+ and a.identity.domain < b.identity.domain
1128
+ ):
1129
+ a, b = b, a
1130
+ evi_a = self._occ_evidence(a, ctx)
1131
+ evi_b = self._occ_evidence(b, ctx)
1132
+ if not evi_a or not evi_b:
1133
+ ctx.missing_evidence += 1
1134
+ ctx.diagnostic(
1135
+ "missing-evidence", "warning",
1136
+ f"edge {a.identity.display()} → {b.identity.display()} "
1137
+ "dropped: source evidence missing on one side",
1138
+ )
1139
+ return None
1140
+ to_occ = b.occ_id if to_unresolved is None else None
1141
+ # DomainRelation é a fonte única: construir primeiro valida as
1142
+ # invariantes do modelo (endpoint obrigatório, inferred≠resolved)
1143
+ # e o CrossEdge deriva dos campos coercionados — zero drift.
1144
+ rel = DomainRelation(
1145
+ snapshot_id=0, relation_type=rtype, resolver=CROSS_RESOLVER_ID,
1146
+ confidence_class=(
1147
+ ConfidenceClass.RESOLVED
1148
+ if status is ResolutionStatus.RESOLVED
1149
+ else ConfidenceClass.SYNTACTIC
1150
+ ),
1151
+ resolution_status=status,
1152
+ from_entity_occ_id=a.occ_id, to_entity_occ_id=to_occ,
1153
+ to_unresolved=to_unresolved,
1154
+ )
1155
+ return self._edge_of(rel, tuple(sorted(set(evi_a) | set(evi_b))))
1156
+
1157
+ def _mk_ambiguous_edge(self, a: _Occ, b: _Occ, ctx: _Ctx) -> CrossEdge:
1158
+ """Aresta ``ambiguous``: ambos os candidatos ficam no texto."""
1159
+ evi = sorted(
1160
+ set(self._occ_evidence(a, ctx)) | set(self._occ_evidence(b, ctx))
1161
+ )
1162
+ disp = " | ".join(
1163
+ sorted({a.identity.display(), b.identity.display()})
1164
+ )
1165
+ rel = DomainRelation(
1166
+ snapshot_id=0,
1167
+ relation_type=str(DomainRelationKind.REFERENCES),
1168
+ resolver=CROSS_RESOLVER_ID,
1169
+ confidence_class=ConfidenceClass.SYNTACTIC,
1170
+ resolution_status=ResolutionStatus.AMBIGUOUS,
1171
+ from_entity_occ_id=a.occ_id,
1172
+ to_unresolved=f"[ambiguous candidates: {disp}]",
1173
+ )
1174
+ return self._edge_of(rel, tuple(evi))
1175
+
1176
+ @staticmethod
1177
+ def _edge_of(rel: DomainRelation, evi: tuple[int, ...]) -> CrossEdge:
1178
+ """``DomainRelation`` validada → ``CrossEdge`` (campos coercionados)."""
1179
+ return CrossEdge(
1180
+ relation_type=rel.relation_type,
1181
+ resolution_status=rel.resolution_status,
1182
+ confidence_class=rel.confidence_class,
1183
+ from_entity_occ_id=rel.from_entity_occ_id,
1184
+ from_symbol_occ_id=rel.from_symbol_occ_id,
1185
+ to_entity_occ_id=rel.to_entity_occ_id,
1186
+ to_symbol_occ_id=rel.to_symbol_occ_id,
1187
+ to_unresolved=rel.to_unresolved,
1188
+ evidence_ids=evi,
1189
+ )
1190
+
1191
+
1192
+ @dataclass
1193
+ class _Ctx:
1194
+ """Contexto mutável interno de um ``plan`` (não é API pública)."""
1195
+
1196
+ occs: dict[int, _Occ]
1197
+ by_path: dict[str, list[_Occ]]
1198
+ evidence: dict[int, sqlite3.Row]
1199
+ file_occs: dict[str, list[int]]
1200
+ linked: set[frozenset[int]]
1201
+ diagnostics: list[CrossDiagnostic] = field(default_factory=list)
1202
+ considered: int = 0
1203
+ missing_evidence: int = 0
1204
+
1205
+ def diagnostic(self, code: str, severity: str, message: str) -> None:
1206
+ self.diagnostics.append(
1207
+ CrossDiagnostic(code=code, severity=severity, message=message)
1208
+ )