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,768 @@
|
|
|
1
|
+
"""Travessia de domínio no Impact Engine (lf-0120, Cycle 7 Phase 12).
|
|
2
|
+
|
|
3
|
+
Responde a "se esta tabela/job/recurso mudar, quem é afetado?": BFS de
|
|
4
|
+
blast sobre o grafo **misto** — arestas de código (``relations``,
|
|
5
|
+
dependente em ``from``) mais arestas de domínio
|
|
6
|
+
(``domain_relations``, endpoints entidade-ou-símbolo) — tudo no
|
|
7
|
+
**mesmo snapshot** (isolamento por repositório+snapshot é
|
|
8
|
+
intransponível: endpoints de outro snapshot nunca são atravessados).
|
|
9
|
+
|
|
10
|
+
Semântica de direção — uma aresta só propaga mudança na direção do
|
|
11
|
+
dependente:
|
|
12
|
+
|
|
13
|
+
- **alvo dependido** (``from`` depende de ``to``): ``READS_FROM``,
|
|
14
|
+
``DERIVES_FROM``, ``DEPENDS_ON``, ``CALLS_ENDPOINT``,
|
|
15
|
+
``IMPLEMENTS_CONTRACT``, ``USES_RESOURCE``, ``ASSUMES_ROLE``,
|
|
16
|
+
``TESTS``, ``REFERENCES`` (não-identidade), ``IMPORTS``, ``CALLS``,
|
|
17
|
+
``IMPLEMENTS``, ``EXTENDS``, ``CONFIGURES``, ``CONTAINS`` — atravessa
|
|
18
|
+
``to → from`` (mudança no alvo impacta o consumidor/agregado);
|
|
19
|
+
- **acoplados** (``from`` produz/muta/declara ``to``): ``WRITES_TO``,
|
|
20
|
+
``TRANSFORMS``, ``PROVISIONS``, ``TRIGGERS``, ``DEPLOYS``,
|
|
21
|
+
``DEFINES`` — atravessa nos dois sentidos: o produto é afetado pelo
|
|
22
|
+
produtor (``from → to``) e o produtor depende do estado do produto
|
|
23
|
+
(``to → from`` — job que só *escreve* numa tabela quebra quando o
|
|
24
|
+
schema dela muda);
|
|
25
|
+
- **identidade simétrica**: ``REFERENCES`` entidade↔entidade com mesmo
|
|
26
|
+
``root``/``catalog``/``namespace``/``environment``/``qualified_name``
|
|
27
|
+
(o par cross-domain de ``crossresolve`` — "a direção não carrega
|
|
28
|
+
semântica") atravessa nos dois sentidos: é a *mesma* coisa lógica
|
|
29
|
+
observada por dois adapters.
|
|
30
|
+
|
|
31
|
+
Estados honestos (spec): ``resolution_status`` de cada hop vai para
|
|
32
|
+
``ImpactPath.resolution_statuses``; um hop não-``resolved`` rebaixa a
|
|
33
|
+
cadeia para ``possible`` — nunca promove. Alvos ``to_unresolved``
|
|
34
|
+
(``unresolved``/``ambiguous``/``external``) não têm endpoint: contam
|
|
35
|
+
como **incerteza** do nó (``uncertain_deps`` + ``unknown_coverage``
|
|
36
|
+
agregada), nunca como blast confirmado.
|
|
37
|
+
|
|
38
|
+
Seeds: ``resolve_seeds`` aceita ``identity_key`` ``dom:…``, row id de
|
|
39
|
+
``domain_entities`` (``entity:N``/``id:N``/``N``), ``display()``
|
|
40
|
+
(``pyspark:staging.orders``) ou nome qualificado (``staging.orders``,
|
|
41
|
+
``orders``). ``ImpactRequest.entity`` é o canal explícito;
|
|
42
|
+
``ImpactRequest.symbol`` casa as mesmas formas **sem diagnóstico de
|
|
43
|
+
ausência** — é a fiação mínima que permite a handlers existentes
|
|
44
|
+
(API ``_impact``, CLI ``impact --ident``, MCP ``atlas_impact``) aceitar
|
|
45
|
+
seeds de entidade sem mudança de assinatura: passem o campo ``symbol``
|
|
46
|
+
(ou ``ident``) com a ref de entidade. Para expor ``entity`` nas
|
|
47
|
+
superfícies basta repassar o parâmetro homônimo — o engine já o lê.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import re
|
|
53
|
+
import sqlite3
|
|
54
|
+
from collections import deque
|
|
55
|
+
from collections.abc import Mapping
|
|
56
|
+
from dataclasses import dataclass, field, replace
|
|
57
|
+
|
|
58
|
+
from code_atlas.domains.identity import DomainIdentity
|
|
59
|
+
from code_atlas.impact.contracts import (
|
|
60
|
+
EvidenceStrength,
|
|
61
|
+
ImpactCandidate,
|
|
62
|
+
ImpactDiagnostic,
|
|
63
|
+
ImpactKind,
|
|
64
|
+
ImpactLimits,
|
|
65
|
+
ImpactPath,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ``from`` depende de ``to`` → mudança em ``to`` impacta ``from``.
|
|
69
|
+
_DEPENDS_ON_TO: frozenset[str] = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"READS_FROM", "DERIVES_FROM", "DEPENDS_ON", "CALLS_ENDPOINT",
|
|
72
|
+
"IMPLEMENTS_CONTRACT", "USES_RESOURCE", "ASSUMES_ROLE",
|
|
73
|
+
"REFERENCES", "TESTS", "IMPORTS", "CALLS", "IMPLEMENTS",
|
|
74
|
+
"EXTENDS", "CONFIGURES", "CONTAINS",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# ``from`` produz/muta/declara ``to`` — acoplamento nos dois sentidos:
|
|
79
|
+
# o produto é afetado por mudança no produtor (writer que muda altera o
|
|
80
|
+
# conteúdo da tabela) E o produtor depende do estado do produto (escrever
|
|
81
|
+
# numa tabela cujo schema mudou quebra; recurso drifted exige mexer no
|
|
82
|
+
# terraform que o declara). TRIGGERS só atravessa quando o alvo resolve
|
|
83
|
+
# (o cicd `workflow TRIGGERS evento` é sempre ``to_unresolved``).
|
|
84
|
+
_COUPLED: frozenset[str] = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"WRITES_TO", "TRANSFORMS", "PROVISIONS", "TRIGGERS", "DEPLOYS",
|
|
87
|
+
"DEFINES",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_REFERENCES = "REFERENCES"
|
|
92
|
+
|
|
93
|
+
_IN_SQL = (
|
|
94
|
+
"SELECT id, from_id, to_id, type, resolution_status"
|
|
95
|
+
" FROM relations WHERE snapshot_id = ? AND to_id = ?"
|
|
96
|
+
" ORDER BY id LIMIT ?"
|
|
97
|
+
)
|
|
98
|
+
_SYM_SQL = (
|
|
99
|
+
"SELECT s.qualified_name, s.language, s.kind, o.id AS occ_id,"
|
|
100
|
+
" o.snapshot_id AS snap, f.relative_path FROM symbol_occurrences o"
|
|
101
|
+
" JOIN symbols s ON s.id = o.logical_symbol_id"
|
|
102
|
+
" JOIN source_files f ON f.id = o.source_file_id"
|
|
103
|
+
" WHERE o.id = ?"
|
|
104
|
+
)
|
|
105
|
+
# Todas as rows de domain_relations que tocam o nó (qualquer endpoint).
|
|
106
|
+
# Os JOINs trazem o snapshot de cada endpoint — prova local de que o
|
|
107
|
+
# vizinho vive no mesmo snapshot (guarda anti-federal do spec).
|
|
108
|
+
_DOM_SQL = (
|
|
109
|
+
"SELECT dr.id, dr.type, dr.resolution_status,"
|
|
110
|
+
" dr.from_entity_occ_id, dr.from_symbol_occ_id,"
|
|
111
|
+
" dr.to_entity_occ_id, dr.to_symbol_occ_id, dr.to_unresolved,"
|
|
112
|
+
" fe.snapshot_id AS fe_snap, fe_de.identity_key AS fe_key,"
|
|
113
|
+
" fs.snapshot_id AS fs_snap,"
|
|
114
|
+
" te.snapshot_id AS te_snap, te_de.identity_key AS te_key,"
|
|
115
|
+
" ts.snapshot_id AS ts_snap"
|
|
116
|
+
" FROM domain_relations dr"
|
|
117
|
+
" LEFT JOIN domain_entity_occurrences fe"
|
|
118
|
+
" ON fe.id = dr.from_entity_occ_id"
|
|
119
|
+
" LEFT JOIN domain_entities fe_de ON fe_de.id = fe.entity_id"
|
|
120
|
+
" LEFT JOIN symbol_occurrences fs ON fs.id = dr.from_symbol_occ_id"
|
|
121
|
+
" LEFT JOIN domain_entity_occurrences te"
|
|
122
|
+
" ON te.id = dr.to_entity_occ_id"
|
|
123
|
+
" LEFT JOIN domain_entities te_de ON te_de.id = te.entity_id"
|
|
124
|
+
" LEFT JOIN symbol_occurrences ts ON ts.id = dr.to_symbol_occ_id"
|
|
125
|
+
" WHERE dr.snapshot_id = ? AND ("
|
|
126
|
+
" dr.from_entity_occ_id = ? OR dr.from_symbol_occ_id = ? OR"
|
|
127
|
+
" dr.to_entity_occ_id = ? OR dr.to_symbol_occ_id = ?)"
|
|
128
|
+
" ORDER BY dr.id LIMIT ?"
|
|
129
|
+
)
|
|
130
|
+
_ENT_SQL = (
|
|
131
|
+
"SELECT deo.id AS occ_id, deo.snapshot_id AS snap,"
|
|
132
|
+
" de.identity_key, de.domain, de.kind, de.qualified_name,"
|
|
133
|
+
" de.catalog, de.namespace, de.environment,"
|
|
134
|
+
" sf.relative_path"
|
|
135
|
+
" FROM domain_entity_occurrences deo"
|
|
136
|
+
" JOIN domain_entities de ON de.id = deo.entity_id"
|
|
137
|
+
" LEFT JOIN source_files sf ON sf.id = deo.source_file_id"
|
|
138
|
+
" WHERE deo.id = ?"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
_ID_REF_RE = re.compile(r"(?:entity|id):?(\d+)")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Um nó do grafo misto: ("s", occ) = ocorrência de símbolo,
|
|
145
|
+
# ("e", occ) = ocorrência de entidade de domínio.
|
|
146
|
+
_Node = tuple[str, int]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class _Hit:
|
|
151
|
+
"""Nó atingido — forma unificada símbolo/entidade."""
|
|
152
|
+
|
|
153
|
+
node: _Node
|
|
154
|
+
key: str # symbol_key (lang:kind:qname) ou identity_key
|
|
155
|
+
qname: str # qualified_name ou display() da entidade
|
|
156
|
+
language: str # linguagem ou domínio produtor
|
|
157
|
+
kind: str # kind de símbolo ou DomainEntityKind
|
|
158
|
+
path: str # path do artefato ("" quando sem arquivo)
|
|
159
|
+
is_domain: bool
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class SeedResolution:
|
|
164
|
+
"""Seeds de entidade resolvidos contra um par de snapshots.
|
|
165
|
+
|
|
166
|
+
``*_origins`` mapeiam ``identity_key`` → id de ocorrência no
|
|
167
|
+
respectivo snapshot — mesmo desenho dos ``origins`` de símbolo do
|
|
168
|
+
blast de código.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
target_origins: Mapping[str, int]
|
|
172
|
+
base_origins: Mapping[str, int]
|
|
173
|
+
diagnostics: tuple[ImpactDiagnostic, ...]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class BlastOutcome:
|
|
178
|
+
"""Saída do blast misto — candidatos + métricas de incerteza.
|
|
179
|
+
|
|
180
|
+
``truncated`` é a união de ``reasons`` (cycle-8.1/lf-0151):
|
|
181
|
+
``max-paths`` (emissão no cap), ``max-nodes-edge-scan`` (varredura
|
|
182
|
+
de vizinhos de um nó encheu o cap — prefixo ``ORDER BY id``
|
|
183
|
+
determinístico, rows além dele nunca vistas) e
|
|
184
|
+
``max-depth-frontier`` (nó emitido em ``hops == max_depth`` ainda
|
|
185
|
+
tinha trabalho atravessável e não foi expandido).
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
candidates: tuple[ImpactCandidate, ...]
|
|
189
|
+
truncated: bool
|
|
190
|
+
# status → nº de alvos não-traversáveis nos nós expandidos
|
|
191
|
+
uncertain: Mapping[str, int] = field(default_factory=dict)
|
|
192
|
+
# nº de vizinhos pulados por viverem fora do snapshot analisado
|
|
193
|
+
cross_snapshot: int = 0
|
|
194
|
+
# bounds que cortaram trabalho — vocabulário de
|
|
195
|
+
# ``ImpactResult.truncation_reasons``
|
|
196
|
+
reasons: tuple[str, ...] = ()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _sym_hit(conn: sqlite3.Connection, occ_id: int) -> _Hit | None:
|
|
200
|
+
row = conn.execute(_SYM_SQL, (occ_id,)).fetchone()
|
|
201
|
+
if row is None:
|
|
202
|
+
return None
|
|
203
|
+
key = f"{row['language']}:{row['kind']}:{row['qualified_name']}"
|
|
204
|
+
return _Hit(
|
|
205
|
+
("s", int(row["occ_id"])), key, str(row["qualified_name"]),
|
|
206
|
+
str(row["language"]), str(row["kind"]),
|
|
207
|
+
str(row["relative_path"]), False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _display_of(row: sqlite3.Row) -> str:
|
|
211
|
+
head = ".".join(
|
|
212
|
+
p for p in (str(row["catalog"] or ""),
|
|
213
|
+
str(row["namespace"] or "")) if p)
|
|
214
|
+
qn = (f"{head}.{row['qualified_name']}" if head
|
|
215
|
+
else str(row["qualified_name"]))
|
|
216
|
+
env = f"@{row['environment']}" if row["environment"] else ""
|
|
217
|
+
return f"{row['domain']}:{qn}{env}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _ent_hit(conn: sqlite3.Connection, occ_id: int) -> _Hit | None:
|
|
221
|
+
row = conn.execute(_ENT_SQL, (occ_id,)).fetchone()
|
|
222
|
+
if row is None:
|
|
223
|
+
return None
|
|
224
|
+
return _Hit(
|
|
225
|
+
("e", int(row["occ_id"])), str(row["identity_key"]),
|
|
226
|
+
_display_of(row), str(row["domain"]), str(row["kind"]),
|
|
227
|
+
str(row["relative_path"] or ""), True)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _hit_of(
|
|
231
|
+
conn: sqlite3.Connection, node: _Node,
|
|
232
|
+
) -> _Hit | None:
|
|
233
|
+
return (_ent_hit(conn, node[1]) if node[0] == "e"
|
|
234
|
+
else _sym_hit(conn, node[1]))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _identity_pair(fe_key: str | None, te_key: str | None) -> bool:
|
|
238
|
+
"""``REFERENCES`` simétrico: mesma coisa lógica em dois adapters.
|
|
239
|
+
|
|
240
|
+
Compara os segmentos posicionais da ``identity_key`` a partir de
|
|
241
|
+
``root`` (``root/catalog/namespace/environment/qualified_name``) —
|
|
242
|
+
o ``domain`` difere por construção (mesma chave completa = mesma
|
|
243
|
+
entidade, impossível como par).
|
|
244
|
+
"""
|
|
245
|
+
if not fe_key or not te_key or fe_key == te_key:
|
|
246
|
+
return False
|
|
247
|
+
a = DomainIdentity.parse_key(fe_key)
|
|
248
|
+
b = DomainIdentity.parse_key(te_key)
|
|
249
|
+
if a is None or b is None or len(a) != len(b):
|
|
250
|
+
return False
|
|
251
|
+
return a[1:] == b[1:]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _endpoints(row: sqlite3.Row) -> list[tuple[_Node, int | None]]:
|
|
255
|
+
"""Todos os endpoints concretos da row: ``(nó, occ_snapshot)``."""
|
|
256
|
+
out: list[tuple[_Node, int | None]] = []
|
|
257
|
+
if row["from_entity_occ_id"] is not None:
|
|
258
|
+
out.append(
|
|
259
|
+
(("e", int(row["from_entity_occ_id"])), row["fe_snap"]))
|
|
260
|
+
if row["from_symbol_occ_id"] is not None:
|
|
261
|
+
out.append(
|
|
262
|
+
(("s", int(row["from_symbol_occ_id"])), row["fs_snap"]))
|
|
263
|
+
if row["to_entity_occ_id"] is not None:
|
|
264
|
+
out.append(
|
|
265
|
+
(("e", int(row["to_entity_occ_id"])), row["te_snap"]))
|
|
266
|
+
if row["to_symbol_occ_id"] is not None:
|
|
267
|
+
out.append(
|
|
268
|
+
(("s", int(row["to_symbol_occ_id"])), row["ts_snap"]))
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _from_side(row: sqlite3.Row, node: _Node) -> bool:
|
|
273
|
+
kind, occ = node
|
|
274
|
+
return (
|
|
275
|
+
kind == "e" and row["from_entity_occ_id"] == occ
|
|
276
|
+
) or (
|
|
277
|
+
kind == "s" and row["from_symbol_occ_id"] == occ
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _to_side(row: sqlite3.Row, node: _Node) -> bool:
|
|
282
|
+
kind, occ = node
|
|
283
|
+
return (
|
|
284
|
+
kind == "e" and row["to_entity_occ_id"] == occ
|
|
285
|
+
) or (
|
|
286
|
+
kind == "s" and row["to_symbol_occ_id"] == occ
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _dom_params(node: _Node, snapshot_id: int, cap: int) -> tuple[object, ...]:
|
|
291
|
+
ent = node[1] if node[0] == "e" else None
|
|
292
|
+
sym = node[1] if node[0] == "s" else None
|
|
293
|
+
return (snapshot_id, ent, sym, ent, sym, cap)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Um item de trabalho do BFS: nó, origem do caminho e acumuladores
|
|
297
|
+
# (relation_ids, intermediate_keys, statuses, edge_refs) + profundidade.
|
|
298
|
+
_Work = tuple[
|
|
299
|
+
_Node, str, tuple[int, ...], tuple[str, ...],
|
|
300
|
+
tuple[str, ...], tuple[str, ...], int,
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class _Ctx:
|
|
306
|
+
"""Estado mutável de um blast — passado aos expansores."""
|
|
307
|
+
|
|
308
|
+
snapshot_id: int
|
|
309
|
+
limits: ImpactLimits
|
|
310
|
+
reason_suffix: str
|
|
311
|
+
origin_keys: set[str]
|
|
312
|
+
queue: deque[_Work] = field(default_factory=deque)
|
|
313
|
+
visited: set[tuple[_Node, str]] = field(default_factory=set)
|
|
314
|
+
out: list[ImpactCandidate] = field(default_factory=list)
|
|
315
|
+
node_deps: dict[_Node, set[str]] = field(default_factory=dict)
|
|
316
|
+
uncertain: dict[str, int] = field(default_factory=dict)
|
|
317
|
+
cross_snap: int = 0
|
|
318
|
+
# lf-0151: alguma varredura de vizinhos excedeu ``max_nodes``
|
|
319
|
+
# (sonda +1) — rows além do prefixo ``ORDER BY id`` nunca vistas.
|
|
320
|
+
edge_scan_capped: bool = False
|
|
321
|
+
# nós emitidos em ``hops >= max_depth`` e não enfileirados —
|
|
322
|
+
# sondados no ``_finish`` (trabalho restante = corte de depth).
|
|
323
|
+
frontier: list[tuple[_Node, str]] = field(default_factory=list)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _emit(
|
|
327
|
+
ctx: _Ctx, hit: _Hit, origin_key: str,
|
|
328
|
+
rel_ids: tuple[int, ...], mids: tuple[str, ...],
|
|
329
|
+
statuses: tuple[str, ...], refs: tuple[str, ...],
|
|
330
|
+
depth: int, *, rel_id: int | None, edge_ref: str,
|
|
331
|
+
rtype: str, status: str, via: str,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Registra candidato + enfileira continuação do caminho."""
|
|
334
|
+
new_rels = (*rel_ids, rel_id) if rel_id is not None else rel_ids
|
|
335
|
+
new_mid = (*mids, hit.key)
|
|
336
|
+
new_st = (*statuses, status)
|
|
337
|
+
new_rf = (*refs, edge_ref)
|
|
338
|
+
verified = all(s == "resolved" for s in new_st)
|
|
339
|
+
hops = len(new_rf)
|
|
340
|
+
kind = (ImpactKind.DIRECT if hops == 1 and verified
|
|
341
|
+
else ImpactKind.TRANSITIVE if verified
|
|
342
|
+
else ImpactKind.POSSIBLE)
|
|
343
|
+
strength = (EvidenceStrength.VERIFIED if verified
|
|
344
|
+
else EvidenceStrength.POSSIBLE)
|
|
345
|
+
path = ImpactPath(
|
|
346
|
+
origin_symbol_key=origin_key,
|
|
347
|
+
affected_symbol_key=hit.key, hops=hops,
|
|
348
|
+
relation_ids=new_rels,
|
|
349
|
+
intermediate_keys=new_mid,
|
|
350
|
+
resolution_statuses=new_st,
|
|
351
|
+
fully_verified=verified,
|
|
352
|
+
reason=f"{rtype} via {via}{ctx.reason_suffix}",
|
|
353
|
+
edge_refs=new_rf)
|
|
354
|
+
ctx.out.append(ImpactCandidate(
|
|
355
|
+
symbol_key=hit.key, qualified_name=hit.qname,
|
|
356
|
+
language=hit.language, kind=hit.kind, impact=kind,
|
|
357
|
+
strength=strength, path=path,
|
|
358
|
+
source_path=hit.path or None,
|
|
359
|
+
occurrence_id=None if hit.is_domain else hit.node[1],
|
|
360
|
+
node_type="domain" if hit.is_domain else "symbol",
|
|
361
|
+
domain_occurrence_id=hit.node[1] if hit.is_domain else None))
|
|
362
|
+
if hops < ctx.limits.max_depth:
|
|
363
|
+
ctx.queue.append((hit.node, origin_key, new_rels,
|
|
364
|
+
new_mid, new_st, new_rf, depth + 1))
|
|
365
|
+
else:
|
|
366
|
+
# fronteira de depth: emitido mas nunca expandido — a sonda
|
|
367
|
+
# pós-BFS decide se havia trabalho atravessável (lf-0151).
|
|
368
|
+
ctx.frontier.append((hit.node, origin_key))
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _expand_code(
|
|
372
|
+
conn: sqlite3.Connection, ctx: _Ctx, node: _Node,
|
|
373
|
+
origin_key: str, rel_ids: tuple[int, ...], mids: tuple[str, ...],
|
|
374
|
+
statuses: tuple[str, ...], refs: tuple[str, ...], depth: int,
|
|
375
|
+
) -> bool:
|
|
376
|
+
"""Arestas ``relations`` — consumidores (to→from). ``True`` = corte."""
|
|
377
|
+
# +1 linha de sonda: distingue fit exato (nada cortado) de overflow
|
|
378
|
+
# real — só o overflow declara ``max-nodes-edge-scan`` (lf-0151).
|
|
379
|
+
rows = conn.execute(
|
|
380
|
+
_IN_SQL, (ctx.snapshot_id, node[1],
|
|
381
|
+
ctx.limits.max_nodes + 1)).fetchall()
|
|
382
|
+
if len(rows) > ctx.limits.max_nodes:
|
|
383
|
+
ctx.edge_scan_capped = True
|
|
384
|
+
rows = rows[:ctx.limits.max_nodes]
|
|
385
|
+
for r in rows:
|
|
386
|
+
consumer = int(r["from_id"])
|
|
387
|
+
if (("s", consumer), origin_key) in ctx.visited:
|
|
388
|
+
continue
|
|
389
|
+
hit = _sym_hit(conn, consumer)
|
|
390
|
+
if hit is None or hit.key in ctx.origin_keys:
|
|
391
|
+
continue
|
|
392
|
+
_emit(ctx, hit, origin_key, rel_ids, mids, statuses, refs,
|
|
393
|
+
depth, rel_id=int(r["id"]), edge_ref=f"rel:{r['id']}",
|
|
394
|
+
rtype=str(r["type"]),
|
|
395
|
+
status=str(r["resolution_status"]),
|
|
396
|
+
via=f"relation {r['id']}")
|
|
397
|
+
if len(ctx.out) >= ctx.limits.max_paths:
|
|
398
|
+
return True
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _dom_neighbors(
|
|
403
|
+
ctx: _Ctx, r: sqlite3.Row, rtype: str, node: _Node,
|
|
404
|
+
from_hit: bool, to_hit: bool,
|
|
405
|
+
) -> list[tuple[_Node, int | None]]:
|
|
406
|
+
"""Vizinhos de uma aresta de domínio conforme a direção do tipo."""
|
|
407
|
+
if rtype == _REFERENCES and _identity_pair(
|
|
408
|
+
str(r["fe_key"] or ""), str(r["te_key"] or "")):
|
|
409
|
+
# identidade simétrica — atravessa para o outro lado.
|
|
410
|
+
return [(n, snap) for n, snap in _endpoints(r) if n != node]
|
|
411
|
+
if rtype in _DEPENDS_ON_TO and to_hit:
|
|
412
|
+
return [(n, snap) for n, snap in _endpoints(r)
|
|
413
|
+
if _from_side(r, n)]
|
|
414
|
+
if rtype in _COUPLED:
|
|
415
|
+
return [(n, snap) for n, snap in _endpoints(r) if n != node]
|
|
416
|
+
return []
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _expand_domain(
|
|
420
|
+
conn: sqlite3.Connection, ctx: _Ctx, node: _Node,
|
|
421
|
+
origin_key: str, rel_ids: tuple[int, ...], mids: tuple[str, ...],
|
|
422
|
+
statuses: tuple[str, ...], refs: tuple[str, ...], depth: int,
|
|
423
|
+
) -> bool:
|
|
424
|
+
"""Arestas ``domain_relations`` — ``True`` = corte por max_paths."""
|
|
425
|
+
node_unc = ctx.node_deps.setdefault(node, set())
|
|
426
|
+
rows = conn.execute(
|
|
427
|
+
_DOM_SQL, _dom_params(node, ctx.snapshot_id,
|
|
428
|
+
ctx.limits.max_nodes + 1)).fetchall()
|
|
429
|
+
if len(rows) > ctx.limits.max_nodes:
|
|
430
|
+
ctx.edge_scan_capped = True
|
|
431
|
+
rows = rows[:ctx.limits.max_nodes]
|
|
432
|
+
for r in rows:
|
|
433
|
+
rtype = str(r["type"])
|
|
434
|
+
status = str(r["resolution_status"])
|
|
435
|
+
from_hit = _from_side(r, node)
|
|
436
|
+
to_hit = _to_side(r, node)
|
|
437
|
+
|
|
438
|
+
# Alvo não-traversável: row sem endpoint concreto em `to`. É
|
|
439
|
+
# dependência/produto não localizado — incerteza do nó, nunca hop.
|
|
440
|
+
if (from_hit and r["to_entity_occ_id"] is None
|
|
441
|
+
and r["to_symbol_occ_id"] is None
|
|
442
|
+
and r["to_unresolved"]):
|
|
443
|
+
dep = f"{rtype}→{str(r['to_unresolved'])[:80]} ({status})"
|
|
444
|
+
if dep not in node_unc:
|
|
445
|
+
node_unc.add(dep)
|
|
446
|
+
ctx.uncertain[status] = ctx.uncertain.get(status, 0) + 1
|
|
447
|
+
|
|
448
|
+
for nnode, nsnap in _dom_neighbors(
|
|
449
|
+
ctx, r, rtype, node, from_hit, to_hit):
|
|
450
|
+
if nnode == node or (nnode, origin_key) in ctx.visited:
|
|
451
|
+
continue
|
|
452
|
+
if nsnap is not None and nsnap != ctx.snapshot_id:
|
|
453
|
+
# guarda de federação: alvo em outro snapshot nunca é
|
|
454
|
+
# atravessado — conta como cobertura desconhecida.
|
|
455
|
+
ctx.cross_snap += 1
|
|
456
|
+
continue
|
|
457
|
+
hit = _hit_of(conn, nnode)
|
|
458
|
+
if hit is None or hit.key in ctx.origin_keys:
|
|
459
|
+
continue
|
|
460
|
+
_emit(ctx, hit, origin_key, rel_ids, mids, statuses, refs,
|
|
461
|
+
depth, rel_id=None, edge_ref=f"dom:{r['id']}",
|
|
462
|
+
rtype=rtype, status=status,
|
|
463
|
+
via=f"domain_relation {r['id']}")
|
|
464
|
+
if len(ctx.out) >= ctx.limits.max_paths:
|
|
465
|
+
return True
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _dom_frontier_pending(
|
|
470
|
+
conn: sqlite3.Connection, ctx: _Ctx, node: _Node, origin_key: str,
|
|
471
|
+
) -> bool:
|
|
472
|
+
"""Sonda de fronteira sobre ``domain_relations`` — usada por nós
|
|
473
|
+
símbolo E entidade (``_expand_domain`` expande os dois)."""
|
|
474
|
+
rows = conn.execute(
|
|
475
|
+
_DOM_SQL, _dom_params(node, ctx.snapshot_id,
|
|
476
|
+
ctx.limits.max_nodes + 1)).fetchall()
|
|
477
|
+
if len(rows) > ctx.limits.max_nodes:
|
|
478
|
+
ctx.edge_scan_capped = True
|
|
479
|
+
return True
|
|
480
|
+
for r in rows:
|
|
481
|
+
rtype = str(r["type"])
|
|
482
|
+
from_hit = _from_side(r, node)
|
|
483
|
+
to_hit = _to_side(r, node)
|
|
484
|
+
if (from_hit and r["to_entity_occ_id"] is None
|
|
485
|
+
and r["to_symbol_occ_id"] is None
|
|
486
|
+
and r["to_unresolved"]):
|
|
487
|
+
# dependência não-traversável nunca contada — informação
|
|
488
|
+
# de incerteza descartada pelo corte de depth.
|
|
489
|
+
return True
|
|
490
|
+
for nnode, nsnap in _dom_neighbors(
|
|
491
|
+
ctx, r, rtype, node, from_hit, to_hit):
|
|
492
|
+
if nnode == node or (nnode, origin_key) in ctx.visited:
|
|
493
|
+
continue
|
|
494
|
+
if nsnap is not None and nsnap != ctx.snapshot_id:
|
|
495
|
+
# alvo fora do snapshot nunca atravessado, mas a conta
|
|
496
|
+
# de cross-snapshot também seria descartada — cobertura
|
|
497
|
+
# desconhecida subestimada.
|
|
498
|
+
return True
|
|
499
|
+
hit = _hit_of(conn, nnode)
|
|
500
|
+
if hit is None or hit.key in ctx.origin_keys:
|
|
501
|
+
continue
|
|
502
|
+
return True
|
|
503
|
+
return False
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _frontier_pending(
|
|
507
|
+
conn: sqlite3.Connection, ctx: _Ctx, node: _Node, origin_key: str,
|
|
508
|
+
) -> bool:
|
|
509
|
+
"""``True`` se o nó de fronteira (``hops >= max_depth``) ainda tem
|
|
510
|
+
trabalho atravessável para a origem dada: vizinho novo e
|
|
511
|
+
atravessável (mesmo snapshot, não-origem), dependência
|
|
512
|
+
``to_unresolved`` de domínio nunca contada — o nó nunca foi
|
|
513
|
+
expandido, logo nada disso entrou em ``node_deps``/``uncertain``
|
|
514
|
+
— ou alvo cross-snapshot nunca contado.
|
|
515
|
+
|
|
516
|
+
A sonda repete o bound ``LIMIT max_nodes + 1`` de cada scan:
|
|
517
|
+
overflow também declara ``edge_scan_capped`` e conta como
|
|
518
|
+
trabalho pendente (a cauda não-vista pode esconder vizinhos).
|
|
519
|
+
Nós símbolo sondam as DUAS famílias — ``_expand_code`` e
|
|
520
|
+
``_expand_domain`` rodam para todo nó no BFS.
|
|
521
|
+
"""
|
|
522
|
+
if node[0] == "s":
|
|
523
|
+
rows = conn.execute(
|
|
524
|
+
_IN_SQL, (ctx.snapshot_id, node[1],
|
|
525
|
+
ctx.limits.max_nodes + 1)).fetchall()
|
|
526
|
+
if len(rows) > ctx.limits.max_nodes:
|
|
527
|
+
ctx.edge_scan_capped = True
|
|
528
|
+
return True
|
|
529
|
+
for r in rows:
|
|
530
|
+
consumer = int(r["from_id"])
|
|
531
|
+
if (("s", consumer), origin_key) in ctx.visited:
|
|
532
|
+
continue
|
|
533
|
+
hit = _sym_hit(conn, consumer)
|
|
534
|
+
if hit is None or hit.key in ctx.origin_keys:
|
|
535
|
+
continue
|
|
536
|
+
return True
|
|
537
|
+
# arestas de domínio do nó símbolo — mesmo scan de
|
|
538
|
+
# ``_expand_domain`` (from_symbol_occ_id / to_symbol_occ_id).
|
|
539
|
+
return _dom_frontier_pending(conn, ctx, node, origin_key)
|
|
540
|
+
return _dom_frontier_pending(conn, ctx, node, origin_key)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _finish(
|
|
544
|
+
ctx: _Ctx, conn: sqlite3.Connection, paths_cut: bool,
|
|
545
|
+
) -> BlastOutcome:
|
|
546
|
+
"""Anexa ``uncertain_deps`` por candidato e sonda a fronteira —
|
|
547
|
+
pós-BFS.
|
|
548
|
+
|
|
549
|
+
As dependências não-traversáveis de um nó só são vistas quando ele
|
|
550
|
+
é expandido, o que pode ocorrer depois de o candidato já ter sido
|
|
551
|
+
emitido: o anexo é sempre um pós-passo, nunca no emit.
|
|
552
|
+
|
|
553
|
+
A sonda (lf-0151) fecha o contrato de truncamento: nós emitidos
|
|
554
|
+
em ``hops >= max_depth`` nunca foram enfileirados — se algum ainda
|
|
555
|
+
tinha trabalho atravessável, a fronteira cortou análise real e o
|
|
556
|
+
corte é declarado ``max-depth-frontier``.
|
|
557
|
+
"""
|
|
558
|
+
depth_cut = any(
|
|
559
|
+
_frontier_pending(conn, ctx, node, origin_key)
|
|
560
|
+
for node, origin_key in dict.fromkeys(ctx.frontier))
|
|
561
|
+
reasons = tuple(sorted(
|
|
562
|
+
({"max-paths"} if paths_cut else set())
|
|
563
|
+
| ({"max-nodes-edge-scan"} if ctx.edge_scan_capped else set())
|
|
564
|
+
| ({"max-depth-frontier"} if depth_cut else set())))
|
|
565
|
+
deps = {n: tuple(sorted(s)) for n, s in ctx.node_deps.items() if s}
|
|
566
|
+
done: list[ImpactCandidate] = []
|
|
567
|
+
for c in ctx.out:
|
|
568
|
+
node: _Node | None = None
|
|
569
|
+
if c.node_type == "domain":
|
|
570
|
+
if c.domain_occurrence_id is not None:
|
|
571
|
+
node = ("e", c.domain_occurrence_id)
|
|
572
|
+
elif c.occurrence_id is not None:
|
|
573
|
+
node = ("s", c.occurrence_id)
|
|
574
|
+
done.append(
|
|
575
|
+
replace(c, uncertain_deps=deps.get(node, ())
|
|
576
|
+
if node is not None else ()))
|
|
577
|
+
return BlastOutcome(tuple(done), bool(reasons), ctx.uncertain,
|
|
578
|
+
ctx.cross_snap, reasons)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def blast(
|
|
582
|
+
conn: sqlite3.Connection, snapshot_id: int,
|
|
583
|
+
*,
|
|
584
|
+
sym_origins: Mapping[str, int],
|
|
585
|
+
ent_origins: Mapping[str, int],
|
|
586
|
+
limits: ImpactLimits,
|
|
587
|
+
reason_suffix: str = "",
|
|
588
|
+
) -> BlastOutcome:
|
|
589
|
+
"""BFS de blast sobre grafo misto; retorna ``BlastOutcome``.
|
|
590
|
+
|
|
591
|
+
Mesma disciplina de ``blast_radius.blast``: visita por
|
|
592
|
+
``(nó, origem)`` preservando caminhos alternativos, origens nunca
|
|
593
|
+
entram como candidatos, ``fully_verified`` exige todos os hops
|
|
594
|
+
``resolved``. Adições: nós entidade (``("e", occ)``), direção por
|
|
595
|
+
tipo de relação de domínio, alvos ``to_unresolved`` contados como
|
|
596
|
+
incerteza (``uncertain`` + ``uncertain_deps`` por candidato) e
|
|
597
|
+
endpoints fora do snapshot pulados (``cross_snapshot``).
|
|
598
|
+
|
|
599
|
+
Truncamento (lf-0151): ``truncated``/``reasons`` declaram todo
|
|
600
|
+
bound que descartou trabalho — ``max-paths`` (emissão no cap),
|
|
601
|
+
``max-nodes-edge-scan`` (varredura de vizinhos no cap, prefixo
|
|
602
|
+
``ORDER BY id`` determinístico) e ``max-depth-frontier`` (nó na
|
|
603
|
+
fronteira com trabalho atravessável restante, sondado no fim).
|
|
604
|
+
"""
|
|
605
|
+
conn.row_factory = sqlite3.Row
|
|
606
|
+
ctx = _Ctx(snapshot_id, limits, reason_suffix,
|
|
607
|
+
set(sym_origins) | set(ent_origins))
|
|
608
|
+
for key, occ in sym_origins.items():
|
|
609
|
+
ctx.queue.append((("s", occ), key, (), (), (), (), 0))
|
|
610
|
+
for key, occ in ent_origins.items():
|
|
611
|
+
ctx.queue.append((("e", occ), key, (), (), (), (), 0))
|
|
612
|
+
|
|
613
|
+
while ctx.queue:
|
|
614
|
+
node, origin_key, rel_ids, mids, statuses, refs, depth = \
|
|
615
|
+
ctx.queue.popleft()
|
|
616
|
+
if (node, origin_key) in ctx.visited \
|
|
617
|
+
or depth > ctx.limits.max_depth:
|
|
618
|
+
continue
|
|
619
|
+
ctx.visited.add((node, origin_key))
|
|
620
|
+
if node[0] == "s" and _expand_code(
|
|
621
|
+
conn, ctx, node, origin_key, rel_ids, mids,
|
|
622
|
+
statuses, refs, depth):
|
|
623
|
+
return _finish(ctx, conn, True)
|
|
624
|
+
if _expand_domain(conn, ctx, node, origin_key, rel_ids,
|
|
625
|
+
mids, statuses, refs, depth):
|
|
626
|
+
return _finish(ctx, conn, True)
|
|
627
|
+
|
|
628
|
+
return _finish(ctx, conn, False)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ---------------------------------------------------------------------
|
|
632
|
+
# resolução de seeds de entidade
|
|
633
|
+
# ---------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _display_forms(row: sqlite3.Row) -> set[str]:
|
|
637
|
+
"""Formas casáveis de uma row ``domain_entities``."""
|
|
638
|
+
qn = str(row["qualified_name"])
|
|
639
|
+
ns = str(row["namespace"] or "")
|
|
640
|
+
cat = str(row["catalog"] or "")
|
|
641
|
+
env = str(row["environment"] or "")
|
|
642
|
+
forms = {qn, str(row["identity_key"]), _display_of(row)}
|
|
643
|
+
if ns:
|
|
644
|
+
forms.add(f"{ns}.{qn}")
|
|
645
|
+
if cat or ns:
|
|
646
|
+
forms.add(".".join(p for p in (cat, ns, qn) if p))
|
|
647
|
+
if env:
|
|
648
|
+
forms.update(f + f"@{env}" for f in tuple(forms))
|
|
649
|
+
return forms
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def resolve_entity_refs(
|
|
653
|
+
conn: sqlite3.Connection, repository_id: int, ref: str,
|
|
654
|
+
) -> tuple[list[sqlite3.Row], str]:
|
|
655
|
+
"""Resolve uma ref de entidade → ``(rows de domain_entities, como)``.
|
|
656
|
+
|
|
657
|
+
Formas: ``dom:…`` (identity_key), ``entity:N``/``id:N``/``N`` (row
|
|
658
|
+
id), ``display()``, ``catalog.ns.qname``/``ns.qname``/``qname``.
|
|
659
|
+
Múltiplos matches são retornados todos — o caller decide seedar
|
|
660
|
+
todos (multiplicidade honesta) e diagnosticar.
|
|
661
|
+
"""
|
|
662
|
+
conn.row_factory = sqlite3.Row
|
|
663
|
+
ref = ref.strip()
|
|
664
|
+
if not ref:
|
|
665
|
+
return [], "empty"
|
|
666
|
+
if ref.startswith("dom:"):
|
|
667
|
+
row = conn.execute(
|
|
668
|
+
"SELECT * FROM domain_entities"
|
|
669
|
+
" WHERE repository_id = ? AND identity_key = ?",
|
|
670
|
+
(repository_id, ref)).fetchone()
|
|
671
|
+
return ([row] if row else []), "identity-key"
|
|
672
|
+
m = _ID_REF_RE.fullmatch(ref)
|
|
673
|
+
if m:
|
|
674
|
+
row = conn.execute(
|
|
675
|
+
"SELECT * FROM domain_entities"
|
|
676
|
+
" WHERE id = ? AND repository_id = ?",
|
|
677
|
+
(int(m.group(1)), repository_id)).fetchone()
|
|
678
|
+
return ([row] if row else []), "row-id"
|
|
679
|
+
guesses = sorted(
|
|
680
|
+
g for g in {ref,
|
|
681
|
+
ref.split(":")[-1].split("@")[0].split(".")[-1]}
|
|
682
|
+
if g)
|
|
683
|
+
hits: list[sqlite3.Row] = []
|
|
684
|
+
seen: set[int] = set()
|
|
685
|
+
for row in conn.execute(
|
|
686
|
+
"SELECT * FROM domain_entities WHERE repository_id = ?"
|
|
687
|
+
" AND (qualified_name = ? OR qualified_name = ?)"
|
|
688
|
+
" ORDER BY id",
|
|
689
|
+
(repository_id,
|
|
690
|
+
guesses[0] if guesses else "",
|
|
691
|
+
guesses[-1] if guesses else "")).fetchall():
|
|
692
|
+
if ref in _display_forms(row) and int(row["id"]) not in seen:
|
|
693
|
+
seen.add(int(row["id"]))
|
|
694
|
+
hits.append(row)
|
|
695
|
+
return (hits, "display") if hits else ([], "none")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def resolve_seeds(
|
|
699
|
+
conn: sqlite3.Connection, repository_id: int,
|
|
700
|
+
refs: tuple[tuple[str, bool], ...],
|
|
701
|
+
base_sid: int, target_sid: int,
|
|
702
|
+
) -> SeedResolution:
|
|
703
|
+
"""Resolve refs de entidade → ocorrências-semente nos dois snapshots.
|
|
704
|
+
|
|
705
|
+
``refs``: ``(ref, explícita)`` — refs explícitas (``req.entity``)
|
|
706
|
+
geram diagnóstico quando não casam; refs de ``req.symbol`` falham
|
|
707
|
+
em silêncio (o campo tem outro papel primário). Uma entidade só vira
|
|
708
|
+
seed no snapshot onde tem ocorrência — sem ocorrência no par
|
|
709
|
+
analisado, ``entity-seed-no-occurrence`` honesto.
|
|
710
|
+
"""
|
|
711
|
+
target: dict[str, int] = {}
|
|
712
|
+
base: dict[str, int] = {}
|
|
713
|
+
diags: list[ImpactDiagnostic] = []
|
|
714
|
+
seen_refs: set[str] = set()
|
|
715
|
+
for ref, explicit in refs:
|
|
716
|
+
if ref in seen_refs:
|
|
717
|
+
continue
|
|
718
|
+
seen_refs.add(ref)
|
|
719
|
+
rows, how = resolve_entity_refs(conn, repository_id, ref)
|
|
720
|
+
if not rows:
|
|
721
|
+
if explicit:
|
|
722
|
+
diags.append(ImpactDiagnostic(
|
|
723
|
+
"entity-seed-not-found",
|
|
724
|
+
f"entidade '{ref}' não encontrada no repositório"))
|
|
725
|
+
continue
|
|
726
|
+
if len(rows) > 1:
|
|
727
|
+
diags.append(ImpactDiagnostic(
|
|
728
|
+
"entity-seed-multi",
|
|
729
|
+
f"'{ref}' casa {len(rows)} entidades ({how}) — todas "
|
|
730
|
+
"viram seed (multiplicidade honesta)"))
|
|
731
|
+
for row in rows:
|
|
732
|
+
eid = int(row["id"])
|
|
733
|
+
ikey = str(row["identity_key"])
|
|
734
|
+
for sid, sink in ((target_sid, target), (base_sid, base)):
|
|
735
|
+
occ = conn.execute(
|
|
736
|
+
"SELECT id FROM domain_entity_occurrences"
|
|
737
|
+
" WHERE snapshot_id = ? AND entity_id = ?"
|
|
738
|
+
" ORDER BY id LIMIT 1",
|
|
739
|
+
(sid, eid)).fetchone()
|
|
740
|
+
if occ is not None and ikey not in sink:
|
|
741
|
+
sink[ikey] = int(occ["id"])
|
|
742
|
+
if ikey not in target and ikey not in base and explicit:
|
|
743
|
+
diags.append(ImpactDiagnostic(
|
|
744
|
+
"entity-seed-no-occurrence",
|
|
745
|
+
f"entidade '{ref}' ({ikey}) não tem ocorrência nos "
|
|
746
|
+
"snapshots analisados"))
|
|
747
|
+
return SeedResolution(target, base, tuple(diags))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def domain_state(
|
|
751
|
+
conn: sqlite3.Connection, snapshot_id: int,
|
|
752
|
+
analyzer_versions: Mapping[str, str] | None,
|
|
753
|
+
) -> str:
|
|
754
|
+
"""Estado dos dados de domínio no snapshot.
|
|
755
|
+
|
|
756
|
+
``present``: há ocorrências de domínio. ``empty``: adapters rodaram
|
|
757
|
+
(``domain:*`` em ``analyzer_versions``) mas nada foi extraído.
|
|
758
|
+
``adapters-disabled``: nenhum adapter habilitado no index — a
|
|
759
|
+
ausência de dados de domínio é configuração, não falha.
|
|
760
|
+
"""
|
|
761
|
+
row = conn.execute(
|
|
762
|
+
"SELECT COUNT(*) AS c FROM domain_entity_occurrences"
|
|
763
|
+
" WHERE snapshot_id = ?", (snapshot_id,)).fetchone()
|
|
764
|
+
if int(row["c"]):
|
|
765
|
+
return "present"
|
|
766
|
+
enabled = any(
|
|
767
|
+
str(k).startswith("domain:") for k in (analyzer_versions or {}))
|
|
768
|
+
return "empty" if enabled else "adapters-disabled"
|