code-atlas-forge 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. code_atlas/__init__.py +6 -0
  2. code_atlas/__main__.py +6 -0
  3. code_atlas/api/__init__.py +5 -0
  4. code_atlas/api/handlers.py +1514 -0
  5. code_atlas/api/server.py +394 -0
  6. code_atlas/cli.py +2084 -0
  7. code_atlas/config.py +149 -0
  8. code_atlas/context/__init__.py +66 -0
  9. code_atlas/context/contracts.py +277 -0
  10. code_atlas/context/domain.py +760 -0
  11. code_atlas/context/engine.py +442 -0
  12. code_atlas/context/expand.py +133 -0
  13. code_atlas/context/renderers.py +71 -0
  14. code_atlas/context/tokenizer.py +37 -0
  15. code_atlas/contracts/__init__.py +38 -0
  16. code_atlas/contracts/enums.py +67 -0
  17. code_atlas/contracts/models.py +175 -0
  18. code_atlas/contracts/ports.py +70 -0
  19. code_atlas/discovery/__init__.py +25 -0
  20. code_atlas/discovery/gitignore.py +119 -0
  21. code_atlas/discovery/identity.py +52 -0
  22. code_atlas/discovery/models.py +204 -0
  23. code_atlas/discovery/scanner.py +495 -0
  24. code_atlas/domains/__init__.py +107 -0
  25. code_atlas/domains/api/__init__.py +28 -0
  26. code_atlas/domains/api/adapter.py +405 -0
  27. code_atlas/domains/api/common.py +308 -0
  28. code_atlas/domains/api/consumers.py +224 -0
  29. code_atlas/domains/api/emit.py +231 -0
  30. code_atlas/domains/api/openapi.py +398 -0
  31. code_atlas/domains/api/routes.py +582 -0
  32. code_atlas/domains/api/yaml_subset.py +683 -0
  33. code_atlas/domains/aws/__init__.py +28 -0
  34. code_atlas/domains/aws/adapter.py +382 -0
  35. code_atlas/domains/aws/common.py +261 -0
  36. code_atlas/domains/aws/configs.py +1164 -0
  37. code_atlas/domains/aws/emit.py +411 -0
  38. code_atlas/domains/aws/pyfiles.py +2220 -0
  39. code_atlas/domains/aws/sqlbridge.py +448 -0
  40. code_atlas/domains/cicd/__init__.py +50 -0
  41. code_atlas/domains/cicd/adapter.py +762 -0
  42. code_atlas/domains/cicd/classify.py +136 -0
  43. code_atlas/domains/cicd/workflow.py +504 -0
  44. code_atlas/domains/cicd/yamlmini.py +946 -0
  45. code_atlas/domains/cli_surface.py +739 -0
  46. code_atlas/domains/contracts.py +274 -0
  47. code_atlas/domains/crossresolve.py +1208 -0
  48. code_atlas/domains/evidence.py +57 -0
  49. code_atlas/domains/identity.py +149 -0
  50. code_atlas/domains/model.py +202 -0
  51. code_atlas/domains/registry.py +250 -0
  52. code_atlas/domains/resolution.py +65 -0
  53. code_atlas/domains/spark/__init__.py +31 -0
  54. code_atlas/domains/spark/adapter.py +146 -0
  55. code_atlas/domains/spark/emit.py +344 -0
  56. code_atlas/domains/spark/extract.py +108 -0
  57. code_atlas/domains/spark/sqllit.py +192 -0
  58. code_atlas/domains/spark/walk.py +1943 -0
  59. code_atlas/domains/sql/__init__.py +30 -0
  60. code_atlas/domains/sql/adapter.py +382 -0
  61. code_atlas/domains/sql/extract.py +1271 -0
  62. code_atlas/domains/sql/nodes.py +264 -0
  63. code_atlas/domains/sql/parser.py +1510 -0
  64. code_atlas/domains/sql/tokens.py +318 -0
  65. code_atlas/domains/summarize.py +126 -0
  66. code_atlas/domains/terraform/__init__.py +20 -0
  67. code_atlas/domains/terraform/adapter.py +127 -0
  68. code_atlas/domains/terraform/catalog.py +43 -0
  69. code_atlas/domains/terraform/extract.py +1277 -0
  70. code_atlas/domains/terraform/hcl.py +538 -0
  71. code_atlas/domains/terraform/refs.py +181 -0
  72. code_atlas/domains/terraform/tfjson.py +163 -0
  73. code_atlas/domains/terraform/tfvars.py +98 -0
  74. code_atlas/domains/validation.py +120 -0
  75. code_atlas/env_override.py +27 -0
  76. code_atlas/eval/__init__.py +26 -0
  77. code_atlas/eval/domain_eval.py +681 -0
  78. code_atlas/eval/ground_truth.py +688 -0
  79. code_atlas/eval/impact_truth.py +758 -0
  80. code_atlas/eval/metrics.py +178 -0
  81. code_atlas/eval/pack_value.py +610 -0
  82. code_atlas/federation/__init__.py +72 -0
  83. code_atlas/federation/federation.py +676 -0
  84. code_atlas/federation/model.py +355 -0
  85. code_atlas/federation/store.py +194 -0
  86. code_atlas/gitbin.py +48 -0
  87. code_atlas/graph/__init__.py +20 -0
  88. code_atlas/graph/builder.py +378 -0
  89. code_atlas/graph/identity.py +54 -0
  90. code_atlas/graph/queries.py +47 -0
  91. code_atlas/graph/validation.py +92 -0
  92. code_atlas/impact/__init__.py +37 -0
  93. code_atlas/impact/blast_radius.py +171 -0
  94. code_atlas/impact/contract_analyzer.py +104 -0
  95. code_atlas/impact/contracts.py +315 -0
  96. code_atlas/impact/diff_reader.py +142 -0
  97. code_atlas/impact/domain_traversal.py +768 -0
  98. code_atlas/impact/engine.py +392 -0
  99. code_atlas/impact/packs.py +128 -0
  100. code_atlas/impact/report.py +136 -0
  101. code_atlas/impact/revision_binding.py +220 -0
  102. code_atlas/impact/risk_classifier.py +83 -0
  103. code_atlas/impact/snapshot_resolver.py +136 -0
  104. code_atlas/impact/symbol_diff.py +210 -0
  105. code_atlas/impact/test_selector.py +166 -0
  106. code_atlas/indexing/__init__.py +5 -0
  107. code_atlas/indexing/cache.py +396 -0
  108. code_atlas/indexing/change_detector.py +278 -0
  109. code_atlas/indexing/coordinator.py +1032 -0
  110. code_atlas/indexing/dependency_planner.py +268 -0
  111. code_atlas/indexing/persist.py +436 -0
  112. code_atlas/indexing/reuse.py +85 -0
  113. code_atlas/install/__init__.py +7 -0
  114. code_atlas/install/cli.py +168 -0
  115. code_atlas/install/lifecycle.py +286 -0
  116. code_atlas/mcp.py +674 -0
  117. code_atlas/observability/__init__.py +19 -0
  118. code_atlas/observability/logging.py +199 -0
  119. code_atlas/parsers/__init__.py +43 -0
  120. code_atlas/parsers/contracts.py +209 -0
  121. code_atlas/parsers/engine.py +125 -0
  122. code_atlas/parsers/languages/__init__.py +1 -0
  123. code_atlas/parsers/languages/python.py +391 -0
  124. code_atlas/parsers/languages/tsx.py +28 -0
  125. code_atlas/parsers/languages/typescript.py +909 -0
  126. code_atlas/parsers/registry.py +51 -0
  127. code_atlas/parsers/tree_sitter_runtime.py +118 -0
  128. code_atlas/resolvers/__init__.py +33 -0
  129. code_atlas/resolvers/bootstrap.py +246 -0
  130. code_atlas/resolvers/capabilities.py +33 -0
  131. code_atlas/resolvers/contracts.py +133 -0
  132. code_atlas/resolvers/coordinator.py +96 -0
  133. code_atlas/resolvers/enrichment.py +318 -0
  134. code_atlas/resolvers/lsp/__init__.py +26 -0
  135. code_atlas/resolvers/lsp/adapter.py +256 -0
  136. code_atlas/resolvers/lsp/lifecycle.py +251 -0
  137. code_atlas/resolvers/lsp/spawn.py +92 -0
  138. code_atlas/resolvers/lsp/transport.py +240 -0
  139. code_atlas/resolvers/registry.py +33 -0
  140. code_atlas/resolvers/scip/__init__.py +49 -0
  141. code_atlas/resolvers/scip/decoder.py +216 -0
  142. code_atlas/resolvers/scip/importer.py +248 -0
  143. code_atlas/resolvers/scip/loader.py +90 -0
  144. code_atlas/resolvers/scip/mapper.py +271 -0
  145. code_atlas/resolvers/scip/provenance.py +211 -0
  146. code_atlas/resolvers/scip/resolver.py +289 -0
  147. code_atlas/resolvers/scip/validation.py +101 -0
  148. code_atlas/resolvers/syntactic/__init__.py +8 -0
  149. code_atlas/resolvers/syntactic/resolver.py +254 -0
  150. code_atlas/search/__init__.py +32 -0
  151. code_atlas/search/contracts.py +176 -0
  152. code_atlas/search/domain.py +561 -0
  153. code_atlas/search/engine.py +530 -0
  154. code_atlas/search/federated.py +276 -0
  155. code_atlas/search/graph.py +282 -0
  156. code_atlas/search/hybrid.py +151 -0
  157. code_atlas/search/intent.py +48 -0
  158. code_atlas/search/lexical.py +176 -0
  159. code_atlas/search/refs.py +359 -0
  160. code_atlas/search/structural.py +505 -0
  161. code_atlas/search/tokenizer.py +43 -0
  162. code_atlas/search/vector/__init__.py +35 -0
  163. code_atlas/search/vector/contracts.py +76 -0
  164. code_atlas/search/vector/embeddings.py +96 -0
  165. code_atlas/search/vector/index.py +169 -0
  166. code_atlas/search/vector/provider.py +50 -0
  167. code_atlas/storage/__init__.py +14 -0
  168. code_atlas/storage/db.py +47 -0
  169. code_atlas/storage/migrations.py +552 -0
  170. code_atlas/storage/store.py +1045 -0
  171. code_atlas/ui/__init__.py +29 -0
  172. code_atlas/ui/i18n.py +143 -0
  173. code_atlas/ui/kit.py +689 -0
  174. code_atlas/ui/wizard.py +317 -0
  175. code_atlas_forge-1.1.0.dist-info/METADATA +246 -0
  176. code_atlas_forge-1.1.0.dist-info/RECORD +181 -0
  177. code_atlas_forge-1.1.0.dist-info/WHEEL +5 -0
  178. code_atlas_forge-1.1.0.dist-info/entry_points.txt +2 -0
  179. code_atlas_forge-1.1.0.dist-info/licenses/LICENSE +202 -0
  180. code_atlas_forge-1.1.0.dist-info/licenses/NOTICE +17 -0
  181. code_atlas_forge-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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"