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,30 @@
1
+ """Adapter de domínio SQL (lf-0111, Cycle 7 Phase 3).
2
+
3
+ Linhagem estática ANSI/Athena(Trino)/Spark em stdlib puro — tokenizer
4
+ (``tokens``) + parser recursivo-descendente (``parser``) + extração para
5
+ o grafo unificado de domínio (``extract``). Nenhuma execução de SQL,
6
+ nenhuma conexão externa, nenhuma dependência nova.
7
+
8
+ Estados honestos: ``SELECT *`` limita linhagem de coluna declaradamente;
9
+ aliases/expressões não-resolvíveis viram ``ambiguous``/``unresolved`` —
10
+ nunca ``resolved``; ``${}``/``{{}}``/``EXEC`` são ``dynamic`` —
11
+ diagnosticados, nunca fabricados.
12
+ """
13
+
14
+ from code_atlas.domains.sql.adapter import (
15
+ ADAPTER_VERSION,
16
+ DOMAIN_ID,
17
+ SqlDomainAdapter,
18
+ looks_like_sql,
19
+ )
20
+ from code_atlas.domains.sql.parser import ParsedFile, detect_dialect, parse_file
21
+
22
+ __all__ = [
23
+ "ADAPTER_VERSION",
24
+ "DOMAIN_ID",
25
+ "ParsedFile",
26
+ "SqlDomainAdapter",
27
+ "detect_dialect",
28
+ "looks_like_sql",
29
+ "parse_file",
30
+ ]
@@ -0,0 +1,382 @@
1
+ """``SqlDomainAdapter`` — adapter de domínio SQL (lf-0111, Cycle 7 Ph.3).
2
+
3
+ Linhagem estática ANSI/Athena(Trino)/Spark sobre artefatos ``.sql``:
4
+
5
+ - tokenizer + parser stdlib puros (``tokens.py``/``parser.py``) —
6
+ **zero dependências**, nenhum ``sqlparse``;
7
+ - extração por statement em ``extract.py``: ``DataTable``/``DataView``
8
+ com identidade catalog/schema/name-qualificada, ``data_column`` para
9
+ linhagem de coluna e ``data_transformation`` (1 por statement) como
10
+ âncora honesta de ``READS_FROM``/``WRITES_TO``/``TRANSFORMS``;
11
+ - ``DERIVES_FROM`` tabela→tabela colapsa CTEs/subqueries/views;
12
+ ``DERIVES_FROM`` coluna→coluna só quando a expressão resolve
13
+ honestamente (alias único, sem star, sem fonte opaca);
14
+ - estados honestos: ``SELECT *``, aliases não-resolvíveis, SQL
15
+ dinâmico (``${}``/``{{}}``/``EXEC``) e fontes opacas viram
16
+ ``DomainDiagnostic`` ou ``to_unresolved`` — nunca ``resolved``;
17
+ - dialeto é **rótulo** de discovery (``detect_dialect``), declarado em
18
+ ``discovery_rules`` — não é gate de extração.
19
+
20
+ Leitura de artefatos (mesmo desenho de ``ApiContractAdapter``/
21
+ ``TerraformAdapter``): ``base_path`` resolve os paths relativos de
22
+ ``DiscoveredArtifact``; ``None`` trata os paths como estão. O adapter
23
+ nunca lê fora do request, nunca executa SQL/jobs e nunca abre rede.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import re
30
+ from pathlib import Path
31
+
32
+ from code_atlas.contracts.enums import ConfidenceClass
33
+ from code_atlas.discovery.scanner import (
34
+ artifact_path_confined,
35
+ read_file_verified,
36
+ )
37
+ from code_atlas.domains.contracts import (
38
+ ArtifactSupport,
39
+ ConfidenceModel,
40
+ DiscoveryRule,
41
+ DomainDiagnostic,
42
+ DomainExtractionRequest,
43
+ DomainExtractionResult,
44
+ ExtractedDomainEntity,
45
+ ExtractedDomainRelation,
46
+ )
47
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
48
+ from code_atlas.domains.sql.extract import FileExtraction, extract_parsed
49
+ from code_atlas.domains.sql.parser import parse_file
50
+ from code_atlas.parsers.contracts import EvidenceRecord
51
+
52
+ DOMAIN_ID = "sql"
53
+ ADAPTER_VERSION = "0.1.0"
54
+
55
+ _MAX_ARTIFACT = 4 << 20 # 4 MiB — mesmo guarda-rail do adapter api
56
+
57
+ # Content-sniffing honesto: um artefato listado no request cuja extensão
58
+ # não é ``.sql`` só é extraído quando o conteúdo realmente parece SQL.
59
+ # Extensão ``.sql`` sem conteúdo SQL vira diagnóstico — nunca claim falso.
60
+ _SNIFF_RE = re.compile(
61
+ r"^\s*(?:--[^\n]*\n|/\*.*?\*/\s*)*"
62
+ r"(?:WITH|SELECT|INSERT|UPDATE|DELETE|MERGE|CREATE|USE|TRUNCATE|"
63
+ r"DROP|ALTER|PREPARE|EXECUTE|EXPLAIN|GRANT)\b",
64
+ re.IGNORECASE | re.DOTALL,
65
+ )
66
+
67
+
68
+ def looks_like_sql(text: str) -> bool:
69
+ """Heurística de sniffing — keyword inicial após comentários."""
70
+ return bool(_SNIFF_RE.match(text[:8192]))
71
+
72
+
73
+ class SqlDomainAdapter:
74
+ """Adapter ``sql`` — linhagem estática ANSI/Athena/Spark.
75
+
76
+ ``base_path``: raiz on-disk que resolve os paths relativos de
77
+ ``DiscoveredArtifact``; ``None`` trata os paths como estão
78
+ (absolutos ou relativos ao cwd do caller).
79
+ """
80
+
81
+ def __init__(self, base_path: str | Path | None = None) -> None:
82
+ self._base = Path(base_path) if base_path is not None else None
83
+
84
+ # -- contrato ------------------------------------------------------
85
+
86
+ @property
87
+ def domain_id(self) -> str:
88
+ return DOMAIN_ID
89
+
90
+ @property
91
+ def adapter_version(self) -> str:
92
+ return ADAPTER_VERSION
93
+
94
+ def supported_artifacts(self) -> ArtifactSupport:
95
+ return ArtifactSupport(
96
+ extensions=frozenset({".sql"}),
97
+ globs=("*.sql.j2",),
98
+ )
99
+
100
+ def entity_kinds(self) -> frozenset[DomainEntityKind]:
101
+ return frozenset(
102
+ {
103
+ DomainEntityKind.DATA_TABLE,
104
+ DomainEntityKind.DATA_VIEW,
105
+ DomainEntityKind.DATA_COLUMN,
106
+ DomainEntityKind.DATA_TRANSFORMATION,
107
+ }
108
+ )
109
+
110
+ def relation_kinds(self) -> frozenset[str]:
111
+ return frozenset(
112
+ {
113
+ str(DomainRelationKind.READS_FROM),
114
+ str(DomainRelationKind.WRITES_TO),
115
+ str(DomainRelationKind.TRANSFORMS),
116
+ str(DomainRelationKind.DERIVES_FROM),
117
+ }
118
+ )
119
+
120
+ def confidence_model(self) -> ConfidenceModel:
121
+ # Extração estritamente evidence-first: o que o texto SQL não
122
+ # estabelece sai como diagnóstico/unresolved — nunca promovido.
123
+ return ConfidenceModel(
124
+ ceiling=ConfidenceClass.SYNTACTIC,
125
+ default=ConfidenceClass.SYNTACTIC,
126
+ allows_inferred=False,
127
+ notes="static SQL lineage; star/ambiguous/dynamic are "
128
+ "diagnosed, never resolved",
129
+ )
130
+
131
+ def discovery_rules(self) -> tuple[DiscoveryRule, ...]:
132
+ return (
133
+ DiscoveryRule(
134
+ glob="*.sql",
135
+ artifact_kind="sql-script",
136
+ notes=(
137
+ "ANSI/Athena(Trino)/Spark SQL scripts — dialect is "
138
+ "content-sniffed (spark|athena|ansi label), not a gate"
139
+ ),
140
+ ),
141
+ DiscoveryRule(
142
+ glob="*.sql.j2",
143
+ artifact_kind="sql-template",
144
+ notes=(
145
+ "templated SQL — parsed best-effort; dynamic markers "
146
+ "produce diagnostics, never fabricated relations"
147
+ ),
148
+ ),
149
+ )
150
+
151
+ # -- extract ---------------------------------------------------------
152
+
153
+ def extract(
154
+ self, request: DomainExtractionRequest
155
+ ) -> DomainExtractionResult:
156
+ try:
157
+ return _extract(request, self.adapter_version, self._base)
158
+ except Exception as exc: # última linha de defesa — nunca levanta
159
+ return DomainExtractionResult(
160
+ domain_id=self.domain_id,
161
+ adapter_version=self.adapter_version,
162
+ diagnostics=(
163
+ DomainDiagnostic(
164
+ code="adapter-failure",
165
+ severity="error",
166
+ message=f"sql adapter failure: {exc!r}",
167
+ adapter_id=self.domain_id,
168
+ ),
169
+ ),
170
+ )
171
+
172
+ # -- por artefato ----------------------------------------------------
173
+
174
+ def _read(
175
+ self, path: str, sha256: str, diags: list[DomainDiagnostic]
176
+ ) -> tuple[str, str] | None:
177
+ """Lê ``base_path/path``; devolve (texto, sha256) ou None."""
178
+ if self._base is not None:
179
+ base = self._base.resolve()
180
+ fs = artifact_path_confined(path, base)
181
+ if fs is None:
182
+ diags.append(
183
+ DomainDiagnostic(
184
+ code="artifact-outside-root", severity="error",
185
+ message=f"artifact path escapes repository root: {path}",
186
+ path=path, adapter_id=DOMAIN_ID,
187
+ )
188
+ )
189
+ return None
190
+ raw = read_file_verified(fs, base)
191
+ if raw is None:
192
+ diags.append(
193
+ DomainDiagnostic(
194
+ code="unreadable-artifact", severity="error",
195
+ message="cannot read artifact: unsafe or vanished",
196
+ path=path, adapter_id=DOMAIN_ID,
197
+ )
198
+ )
199
+ return None
200
+ else:
201
+ fs = Path(path)
202
+ try:
203
+ raw = fs.read_bytes()
204
+ except OSError as exc:
205
+ diags.append(
206
+ DomainDiagnostic(
207
+ code="unreadable-artifact", severity="error",
208
+ message=f"cannot read artifact: {exc}",
209
+ path=path, adapter_id=DOMAIN_ID,
210
+ )
211
+ )
212
+ return None
213
+ if len(raw) > _MAX_ARTIFACT:
214
+ diags.append(
215
+ DomainDiagnostic(
216
+ code="artifact-too-large", severity="error",
217
+ message=f"{len(raw)} bytes exceeds {_MAX_ARTIFACT}",
218
+ path=path, adapter_id=DOMAIN_ID,
219
+ )
220
+ )
221
+ return None
222
+ real = hashlib.sha256(raw).hexdigest()
223
+ if sha256 and sha256 != real:
224
+ diags.append(
225
+ DomainDiagnostic(
226
+ code="artifact-hash-mismatch", severity="warning",
227
+ message=(
228
+ f"inventory sha256 {sha256[:12]}… != "
229
+ f"computed {real[:12]}…"
230
+ ),
231
+ path=path, adapter_id=DOMAIN_ID,
232
+ )
233
+ )
234
+ return raw.decode("utf-8", "replace"), real
235
+
236
+
237
+ def _extract(
238
+ request: DomainExtractionRequest,
239
+ adapter_version: str,
240
+ base: Path | None,
241
+ ) -> DomainExtractionResult:
242
+ """Extrai o request inteiro — merge determinístico por artefato."""
243
+ entities: dict[str, ExtractedDomainEntity] = {}
244
+ relations: list[ExtractedDomainRelation] = []
245
+ evidence: list[EvidenceRecord] = []
246
+ diagnostics: list[DomainDiagnostic] = []
247
+ stats: dict[str, int] = {}
248
+
249
+ adapter = SqlDomainAdapter(base)
250
+ for art in sorted(request.artifacts, key=lambda a: a.path):
251
+ got = adapter._read(art.path, art.sha256, diagnostics)
252
+ if got is None:
253
+ continue
254
+ text, content_hash = got
255
+ ext = Path(art.path).suffix.lower()
256
+ if ext != ".sql" and not looks_like_sql(text):
257
+ diagnostics.append(
258
+ DomainDiagnostic(
259
+ code="not-sql-artifact", severity="info",
260
+ message="content does not look like SQL — skipped",
261
+ path=art.path, adapter_id=DOMAIN_ID,
262
+ )
263
+ )
264
+ continue
265
+ try:
266
+ parsed = parse_file(text)
267
+ fx = extract_parsed(
268
+ parsed,
269
+ repository=request.repository,
270
+ root=request.root,
271
+ environment=request.environment,
272
+ path=art.path,
273
+ sha=content_hash,
274
+ adapter_version=adapter_version,
275
+ )
276
+ except Exception as exc: # isolamento por arquivo — nunca crash
277
+ diagnostics.append(
278
+ DomainDiagnostic(
279
+ code="adapter-crash", severity="error",
280
+ message=f"{type(exc).__name__}: {exc}",
281
+ path=art.path, adapter_id=DOMAIN_ID,
282
+ )
283
+ )
284
+ continue
285
+ if (
286
+ not fx.entities and not fx.relations
287
+ and not parsed.statements and not looks_like_sql(text)
288
+ ):
289
+ diagnostics.append(
290
+ DomainDiagnostic(
291
+ code="not-sql-artifact", severity="info",
292
+ message=".sql artifact with no SQL statements",
293
+ path=art.path, adapter_id=DOMAIN_ID,
294
+ )
295
+ )
296
+ continue
297
+ _merge_file(
298
+ fx, entities=entities, relations=relations,
299
+ evidence=evidence, diagnostics=diagnostics, stats=stats,
300
+ )
301
+
302
+ return DomainExtractionResult(
303
+ domain_id=DOMAIN_ID,
304
+ adapter_version=adapter_version,
305
+ entities=tuple(entities.values()),
306
+ relations=tuple(relations),
307
+ evidence=tuple(evidence),
308
+ diagnostics=tuple(diagnostics),
309
+ stats=stats,
310
+ )
311
+
312
+
313
+ def _merge_file(
314
+ fx: FileExtraction,
315
+ *,
316
+ entities: dict[str, ExtractedDomainEntity],
317
+ relations: list[ExtractedDomainRelation],
318
+ evidence: list[EvidenceRecord],
319
+ diagnostics: list[DomainDiagnostic],
320
+ stats: dict[str, int],
321
+ ) -> None:
322
+ """Funde a extração de um artefato no resultado do request.
323
+
324
+ Índices de evidência são relativos ao arquivo — remapeados para o
325
+ envelope global. Entidades dedupam por ``logical_key``; declaração
326
+ (``declared=create``) vence referência no merge de atributos e a
327
+ evidência acumula — mesma política do dedup intra-arquivo.
328
+ """
329
+ base = len(evidence)
330
+ evidence.extend(fx.evidence)
331
+
332
+ def remap(evi: tuple[int, ...]) -> tuple[int, ...]:
333
+ return tuple(i + base for i in evi)
334
+
335
+ for e in fx.entities:
336
+ key = e.identity.logical_key()
337
+ prev = entities.get(key)
338
+ if prev is None:
339
+ entities[key] = ExtractedDomainEntity(
340
+ identity=e.identity, kind=e.kind, name=e.name,
341
+ path=e.path, span=e.span, content_hash=e.content_hash,
342
+ attributes=e.attributes, evidence=remap(e.evidence),
343
+ )
344
+ continue
345
+ merged_ev = tuple(sorted({*prev.evidence, *remap(e.evidence)}))
346
+ declared_wins = (
347
+ e.attributes.get("declared") == "create"
348
+ and prev.attributes.get("declared") != "create"
349
+ )
350
+ if declared_wins:
351
+ entities[key] = ExtractedDomainEntity(
352
+ identity=e.identity, kind=e.kind, name=e.name,
353
+ path=e.path, span=e.span, content_hash=e.content_hash,
354
+ attributes=e.attributes, evidence=merged_ev,
355
+ )
356
+ else:
357
+ entities[key] = ExtractedDomainEntity(
358
+ identity=prev.identity, kind=prev.kind, name=prev.name,
359
+ path=prev.path, span=prev.span,
360
+ content_hash=prev.content_hash,
361
+ attributes=prev.attributes, evidence=merged_ev,
362
+ )
363
+
364
+ relations.extend(
365
+ ExtractedDomainRelation(
366
+ relation_type=r.relation_type, path=r.path,
367
+ from_identity=r.from_identity, from_symbol=r.from_symbol,
368
+ to_identity=r.to_identity, to_symbol=r.to_symbol,
369
+ to_unresolved=r.to_unresolved, resolution=r.resolution,
370
+ span=r.span, evidence=remap(r.evidence),
371
+ )
372
+ for r in fx.relations
373
+ )
374
+ diagnostics.extend(fx.diagnostics)
375
+ for k, v in fx.stats.items():
376
+ stats[k] = stats.get(k, 0) + v
377
+
378
+
379
+ def satisfies_protocol() -> SqlDomainAdapter:
380
+ """Smoke check para tests: a instância satisfaz ``DomainAdapter``."""
381
+ adapter: SqlDomainAdapter = SqlDomainAdapter()
382
+ return adapter