code-atlas-forge 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_atlas/__init__.py +6 -0
- code_atlas/__main__.py +6 -0
- code_atlas/api/__init__.py +5 -0
- code_atlas/api/handlers.py +1514 -0
- code_atlas/api/server.py +394 -0
- code_atlas/cli.py +2084 -0
- code_atlas/config.py +149 -0
- code_atlas/context/__init__.py +66 -0
- code_atlas/context/contracts.py +277 -0
- code_atlas/context/domain.py +760 -0
- code_atlas/context/engine.py +442 -0
- code_atlas/context/expand.py +133 -0
- code_atlas/context/renderers.py +71 -0
- code_atlas/context/tokenizer.py +37 -0
- code_atlas/contracts/__init__.py +38 -0
- code_atlas/contracts/enums.py +67 -0
- code_atlas/contracts/models.py +175 -0
- code_atlas/contracts/ports.py +70 -0
- code_atlas/discovery/__init__.py +25 -0
- code_atlas/discovery/gitignore.py +119 -0
- code_atlas/discovery/identity.py +52 -0
- code_atlas/discovery/models.py +204 -0
- code_atlas/discovery/scanner.py +495 -0
- code_atlas/domains/__init__.py +107 -0
- code_atlas/domains/api/__init__.py +28 -0
- code_atlas/domains/api/adapter.py +405 -0
- code_atlas/domains/api/common.py +308 -0
- code_atlas/domains/api/consumers.py +224 -0
- code_atlas/domains/api/emit.py +231 -0
- code_atlas/domains/api/openapi.py +398 -0
- code_atlas/domains/api/routes.py +582 -0
- code_atlas/domains/api/yaml_subset.py +683 -0
- code_atlas/domains/aws/__init__.py +28 -0
- code_atlas/domains/aws/adapter.py +382 -0
- code_atlas/domains/aws/common.py +261 -0
- code_atlas/domains/aws/configs.py +1164 -0
- code_atlas/domains/aws/emit.py +411 -0
- code_atlas/domains/aws/pyfiles.py +2220 -0
- code_atlas/domains/aws/sqlbridge.py +448 -0
- code_atlas/domains/cicd/__init__.py +50 -0
- code_atlas/domains/cicd/adapter.py +762 -0
- code_atlas/domains/cicd/classify.py +136 -0
- code_atlas/domains/cicd/workflow.py +504 -0
- code_atlas/domains/cicd/yamlmini.py +946 -0
- code_atlas/domains/cli_surface.py +739 -0
- code_atlas/domains/contracts.py +274 -0
- code_atlas/domains/crossresolve.py +1208 -0
- code_atlas/domains/evidence.py +57 -0
- code_atlas/domains/identity.py +149 -0
- code_atlas/domains/model.py +202 -0
- code_atlas/domains/registry.py +250 -0
- code_atlas/domains/resolution.py +65 -0
- code_atlas/domains/spark/__init__.py +31 -0
- code_atlas/domains/spark/adapter.py +146 -0
- code_atlas/domains/spark/emit.py +344 -0
- code_atlas/domains/spark/extract.py +108 -0
- code_atlas/domains/spark/sqllit.py +192 -0
- code_atlas/domains/spark/walk.py +1943 -0
- code_atlas/domains/sql/__init__.py +30 -0
- code_atlas/domains/sql/adapter.py +382 -0
- code_atlas/domains/sql/extract.py +1271 -0
- code_atlas/domains/sql/nodes.py +264 -0
- code_atlas/domains/sql/parser.py +1510 -0
- code_atlas/domains/sql/tokens.py +318 -0
- code_atlas/domains/summarize.py +126 -0
- code_atlas/domains/terraform/__init__.py +20 -0
- code_atlas/domains/terraform/adapter.py +127 -0
- code_atlas/domains/terraform/catalog.py +43 -0
- code_atlas/domains/terraform/extract.py +1277 -0
- code_atlas/domains/terraform/hcl.py +538 -0
- code_atlas/domains/terraform/refs.py +181 -0
- code_atlas/domains/terraform/tfjson.py +163 -0
- code_atlas/domains/terraform/tfvars.py +98 -0
- code_atlas/domains/validation.py +120 -0
- code_atlas/env_override.py +27 -0
- code_atlas/eval/__init__.py +26 -0
- code_atlas/eval/domain_eval.py +681 -0
- code_atlas/eval/ground_truth.py +688 -0
- code_atlas/eval/impact_truth.py +758 -0
- code_atlas/eval/metrics.py +178 -0
- code_atlas/eval/pack_value.py +610 -0
- code_atlas/federation/__init__.py +72 -0
- code_atlas/federation/federation.py +676 -0
- code_atlas/federation/model.py +355 -0
- code_atlas/federation/store.py +194 -0
- code_atlas/gitbin.py +48 -0
- code_atlas/graph/__init__.py +20 -0
- code_atlas/graph/builder.py +378 -0
- code_atlas/graph/identity.py +54 -0
- code_atlas/graph/queries.py +47 -0
- code_atlas/graph/validation.py +92 -0
- code_atlas/impact/__init__.py +37 -0
- code_atlas/impact/blast_radius.py +171 -0
- code_atlas/impact/contract_analyzer.py +104 -0
- code_atlas/impact/contracts.py +315 -0
- code_atlas/impact/diff_reader.py +142 -0
- code_atlas/impact/domain_traversal.py +768 -0
- code_atlas/impact/engine.py +392 -0
- code_atlas/impact/packs.py +128 -0
- code_atlas/impact/report.py +136 -0
- code_atlas/impact/revision_binding.py +220 -0
- code_atlas/impact/risk_classifier.py +83 -0
- code_atlas/impact/snapshot_resolver.py +136 -0
- code_atlas/impact/symbol_diff.py +210 -0
- code_atlas/impact/test_selector.py +166 -0
- code_atlas/indexing/__init__.py +5 -0
- code_atlas/indexing/cache.py +396 -0
- code_atlas/indexing/change_detector.py +278 -0
- code_atlas/indexing/coordinator.py +1032 -0
- code_atlas/indexing/dependency_planner.py +268 -0
- code_atlas/indexing/persist.py +436 -0
- code_atlas/indexing/reuse.py +85 -0
- code_atlas/install/__init__.py +7 -0
- code_atlas/install/cli.py +168 -0
- code_atlas/install/lifecycle.py +286 -0
- code_atlas/mcp.py +674 -0
- code_atlas/observability/__init__.py +19 -0
- code_atlas/observability/logging.py +199 -0
- code_atlas/parsers/__init__.py +43 -0
- code_atlas/parsers/contracts.py +209 -0
- code_atlas/parsers/engine.py +125 -0
- code_atlas/parsers/languages/__init__.py +1 -0
- code_atlas/parsers/languages/python.py +391 -0
- code_atlas/parsers/languages/tsx.py +28 -0
- code_atlas/parsers/languages/typescript.py +909 -0
- code_atlas/parsers/registry.py +51 -0
- code_atlas/parsers/tree_sitter_runtime.py +118 -0
- code_atlas/resolvers/__init__.py +33 -0
- code_atlas/resolvers/bootstrap.py +246 -0
- code_atlas/resolvers/capabilities.py +33 -0
- code_atlas/resolvers/contracts.py +133 -0
- code_atlas/resolvers/coordinator.py +96 -0
- code_atlas/resolvers/enrichment.py +318 -0
- code_atlas/resolvers/lsp/__init__.py +26 -0
- code_atlas/resolvers/lsp/adapter.py +256 -0
- code_atlas/resolvers/lsp/lifecycle.py +251 -0
- code_atlas/resolvers/lsp/spawn.py +92 -0
- code_atlas/resolvers/lsp/transport.py +240 -0
- code_atlas/resolvers/registry.py +33 -0
- code_atlas/resolvers/scip/__init__.py +49 -0
- code_atlas/resolvers/scip/decoder.py +216 -0
- code_atlas/resolvers/scip/importer.py +248 -0
- code_atlas/resolvers/scip/loader.py +90 -0
- code_atlas/resolvers/scip/mapper.py +271 -0
- code_atlas/resolvers/scip/provenance.py +211 -0
- code_atlas/resolvers/scip/resolver.py +289 -0
- code_atlas/resolvers/scip/validation.py +101 -0
- code_atlas/resolvers/syntactic/__init__.py +8 -0
- code_atlas/resolvers/syntactic/resolver.py +254 -0
- code_atlas/search/__init__.py +32 -0
- code_atlas/search/contracts.py +176 -0
- code_atlas/search/domain.py +561 -0
- code_atlas/search/engine.py +530 -0
- code_atlas/search/federated.py +276 -0
- code_atlas/search/graph.py +282 -0
- code_atlas/search/hybrid.py +151 -0
- code_atlas/search/intent.py +48 -0
- code_atlas/search/lexical.py +176 -0
- code_atlas/search/refs.py +359 -0
- code_atlas/search/structural.py +505 -0
- code_atlas/search/tokenizer.py +43 -0
- code_atlas/search/vector/__init__.py +35 -0
- code_atlas/search/vector/contracts.py +76 -0
- code_atlas/search/vector/embeddings.py +96 -0
- code_atlas/search/vector/index.py +169 -0
- code_atlas/search/vector/provider.py +50 -0
- code_atlas/storage/__init__.py +14 -0
- code_atlas/storage/db.py +47 -0
- code_atlas/storage/migrations.py +552 -0
- code_atlas/storage/store.py +1045 -0
- code_atlas/ui/__init__.py +29 -0
- code_atlas/ui/i18n.py +143 -0
- code_atlas/ui/kit.py +689 -0
- code_atlas/ui/wizard.py +317 -0
- code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
- code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
- code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
- code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
- code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
- code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
- code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|