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,1271 @@
1
+ """Extração AST → entidades/relações de domínio SQL (lf-0111, Phase 3).
2
+
3
+ Semântica das relações emitidas:
4
+
5
+ - ``transformation`` (1 por statement DML/SELECT com fontes)
6
+ ``READS_FROM`` cada tabela terminal lida (CTEs/subqueries são
7
+ transparentes — a leitura liga à tabela real);
8
+ - ``transformation`` ``WRITES_TO`` alvo de INSERT/CTAS/VIEW;
9
+ - ``transformation`` ``TRANSFORMS`` alvo de UPDATE/DELETE/MERGE/TRUNCATE
10
+ (mutação in-place, não carga);
11
+ - ``target`` ``DERIVES_FROM`` fonte — linhagem tabela→tabela colapsada;
12
+ - ``coluna_out`` ``DERIVES_FROM`` ``coluna_src`` — linhagem de coluna
13
+ **somente quando a expressão resolve honestamente** (alias/tabela
14
+ únicos, sem ``SELECT *``, sem subquery de saída desconhecida).
15
+
16
+ Regras de honestidade (hard):
17
+
18
+ - ``SELECT *``/``t.*`` em statement produtor → diagnóstico
19
+ ``select-star`` (linhagem de coluna limitada; a de tabela mantém-se);
20
+ - colref não-qualificado com ≥2 fontes potenciais → ``ambiguous-column``,
21
+ nunca promovido; qualificador que não casa nenhuma fonte →
22
+ ``unresolved-column``;
23
+ - ``${}``/``{{}}``/``EXEC``/fonte dinâmica → ``dynamic-sql`` + relação
24
+ ``to_unresolved`` com o texto preservado — nada fabricado;
25
+ - fonte não-tabela (UNNEST, LATERAL VIEW, função-de-tabela) →
26
+ ``unsupported-source`` info — o statement continua extraído;
27
+ - qualquer falha interna por statement → ``extract-error``, o resto do
28
+ arquivo continua (parcial honesto, nunca crash).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Callable
34
+ from dataclasses import dataclass, field
35
+ from typing import Any
36
+
37
+ from code_atlas.domains.contracts import (
38
+ DomainDiagnostic,
39
+ ExtractedDomainEntity,
40
+ ExtractedDomainRelation,
41
+ )
42
+ from code_atlas.domains.evidence import evidence_record
43
+ from code_atlas.domains.identity import DomainIdentity
44
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
45
+ from code_atlas.domains.sql.nodes import (
46
+ ColRef,
47
+ Cte,
48
+ ExprRefs,
49
+ NameRef,
50
+ SCreateTable,
51
+ SCreateView,
52
+ SDelete,
53
+ SelectStmt,
54
+ SInsert,
55
+ SMerge,
56
+ Source,
57
+ SrcGroup,
58
+ SrcJoin,
59
+ SrcSub,
60
+ SrcTable,
61
+ SrcUnsupported,
62
+ SrcValues,
63
+ SSelect,
64
+ Statement,
65
+ STruncate,
66
+ SUnsupported,
67
+ SUpdate,
68
+ SUse,
69
+ )
70
+ from code_atlas.domains.sql.parser import ParsedFile
71
+ from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
72
+
73
+ SQL_DOMAIN = "sql"
74
+ _DIAG_CAP = 60 # diagnósticos por artefato — depois vira resumo
75
+ _SCOPE_DEPTH = 16 # recursão máxima CTE/subquery — patológico além disso
76
+
77
+
78
+ @dataclass
79
+ class _EntAcc:
80
+ identity: DomainIdentity
81
+ kind: DomainEntityKind
82
+ name: str
83
+ span: SourceSpan | None
84
+ attrs: dict[str, str]
85
+ prio: int
86
+ ev: set[int] = field(default_factory=set)
87
+
88
+
89
+ @dataclass
90
+ class _RelAcc:
91
+ rtype: str
92
+ from_i: DomainIdentity
93
+ to_i: DomainIdentity | None
94
+ to_text: str
95
+ span: SourceSpan | None
96
+ ev: set[int] = field(default_factory=set)
97
+
98
+
99
+ # --- escopo de resolução de colunas --------------------------------------
100
+
101
+
102
+ @dataclass
103
+ class _Out:
104
+ """Coluna de saída nomeada de uma subquery/CTE."""
105
+
106
+ name: str | None
107
+ refs: ExprRefs
108
+
109
+
110
+ @dataclass
111
+ class _TblSrc:
112
+ ident: DomainIdentity
113
+ parts: tuple[str, ...]
114
+ alias: str | None
115
+ span: SourceSpan
116
+
117
+
118
+ @dataclass
119
+ class _SubSrc:
120
+ alias: str | None
121
+ outputs: list[_Out]
122
+ inner: _Scope
123
+ star: bool # saída parcialmente desconhecida (SELECT *)
124
+ literal: bool = False # VALUES — nomes conhecidos, sem upstream
125
+
126
+
127
+ @dataclass
128
+ class _DeadSrc:
129
+ """Fonte não-resolvível (UNNEST, função, dinâmica) — nunca casa."""
130
+
131
+ alias: str | None = None
132
+
133
+
134
+ _SrcInfo = _TblSrc | _SubSrc | _DeadSrc
135
+
136
+
137
+ @dataclass
138
+ class _Scope:
139
+ sources: list[_SrcInfo]
140
+ parent: _Scope | None = None
141
+
142
+
143
+ @dataclass
144
+ class _RCol:
145
+ terminals: list[tuple[DomainIdentity, str]] = field(default_factory=list)
146
+ problems: list[tuple[str, str]] = field(default_factory=list)
147
+
148
+
149
+ class _FileCtx:
150
+ """Acumulador por artefato: entidades/relações/evidência/diagnósticos."""
151
+
152
+ def __init__(
153
+ self, *, repository: str, root: str, environment: str,
154
+ path: str, sha: str, dialect: str, adapter_version: str,
155
+ ) -> None:
156
+ self.repo = repository
157
+ self.root = root
158
+ self.environment = environment
159
+ self.path = path
160
+ self.sha = sha
161
+ self.dialect = dialect
162
+ self.version = adapter_version
163
+ self.entities: dict[str, _EntAcc] = {}
164
+ self.relations: dict[tuple[str, str, str], _RelAcc] = {}
165
+ self.evidence: list[EvidenceRecord] = []
166
+ self._evidx: dict[int, int] = {}
167
+ self.diagnostics: list[DomainDiagnostic] = []
168
+ self.stats: dict[str, int] = {}
169
+ # contexto USE — sequencial dentro do arquivo
170
+ self.use_catalog = ""
171
+ self.use_schema = ""
172
+ self._truncated_diags = 0
173
+
174
+ # --- evidência / diagnóstico ---------------------------------------
175
+
176
+ def evi(self, stmt_i: int, span: SourceSpan | None) -> int:
177
+ if stmt_i in self._evidx:
178
+ return self._evidx[stmt_i]
179
+ self.evidence.append(
180
+ evidence_record(
181
+ adapter_domain=SQL_DOMAIN,
182
+ adapter_version=self.version,
183
+ path=self.path,
184
+ content_hash=self.sha,
185
+ span=span,
186
+ notes=f"stmt:{stmt_i}",
187
+ )
188
+ )
189
+ idx = len(self.evidence) - 1
190
+ self._evidx[stmt_i] = idx
191
+ return idx
192
+
193
+ def diag(self, code: str, severity: str, message: str) -> None:
194
+ if len(self.diagnostics) >= _DIAG_CAP:
195
+ self._truncated_diags += 1
196
+ return
197
+ self.diagnostics.append(
198
+ DomainDiagnostic(
199
+ code=code, severity=severity,
200
+ message=message, path=self.path,
201
+ adapter_id=SQL_DOMAIN,
202
+ )
203
+ )
204
+
205
+ def finish_diags(self) -> None:
206
+ if self._truncated_diags:
207
+ self.diagnostics.append(
208
+ DomainDiagnostic(
209
+ code="diagnostics-truncated", severity="info",
210
+ message=(
211
+ f"{self._truncated_diags} further diagnostics "
212
+ f"suppressed (cap {_DIAG_CAP})"
213
+ ),
214
+ path=self.path, adapter_id=SQL_DOMAIN,
215
+ )
216
+ )
217
+
218
+ # --- identidades ------------------------------------------------------
219
+
220
+ def table_ident(self, parts: tuple[str, ...]) -> DomainIdentity:
221
+ """catalog/namespace/qualified_name a partir das partes do nome."""
222
+ if len(parts) >= 3:
223
+ catalog, namespace, name = (
224
+ parts[0], ".".join(parts[1:-1]), parts[-1]
225
+ )
226
+ elif len(parts) == 2:
227
+ catalog, namespace, name = self.use_catalog, parts[0], parts[1]
228
+ else:
229
+ catalog, namespace, name = (
230
+ self.use_catalog, self.use_schema, parts[0]
231
+ )
232
+ return DomainIdentity(
233
+ domain=SQL_DOMAIN, repository=self.repo, root=self.root,
234
+ catalog=catalog, namespace=namespace, environment=self.environment,
235
+ qualified_name=name,
236
+ )
237
+
238
+ def col_ident(self, table: DomainIdentity, col: str) -> DomainIdentity:
239
+ return DomainIdentity(
240
+ domain=SQL_DOMAIN, repository=self.repo, root=self.root,
241
+ catalog=table.catalog, namespace=table.namespace,
242
+ environment=table.environment,
243
+ qualified_name=f"{table.qualified_name}.{col}",
244
+ )
245
+
246
+ # --- entidades / relações --------------------------------------------
247
+
248
+ def add_entity(
249
+ self,
250
+ ident: DomainIdentity,
251
+ kind: DomainEntityKind,
252
+ name: str,
253
+ span: SourceSpan | None,
254
+ attrs: dict[str, str],
255
+ prio: int,
256
+ evi: int,
257
+ ) -> None:
258
+ key = ident.logical_key()
259
+ acc = self.entities.get(key)
260
+ if acc is None:
261
+ self.entities[key] = _EntAcc(
262
+ identity=ident, kind=kind, name=name, span=span,
263
+ attrs=dict(attrs), prio=prio, ev={evi},
264
+ )
265
+ return
266
+ acc.ev.add(evi)
267
+ if prio > acc.prio:
268
+ acc.kind = kind
269
+ acc.name = name
270
+ acc.span = span
271
+ acc.attrs = dict(attrs)
272
+ acc.prio = prio
273
+ elif prio == acc.prio and acc.kind is not kind:
274
+ self.diag(
275
+ "conflicting-declarations", "warning",
276
+ f"{ident.display()} declared as both "
277
+ f"{acc.kind.value} and {kind.value}",
278
+ )
279
+
280
+ def add_rel(
281
+ self,
282
+ rtype: str,
283
+ from_i: DomainIdentity,
284
+ *,
285
+ to_i: DomainIdentity | None = None,
286
+ to_text: str = "",
287
+ span: SourceSpan | None = None,
288
+ evi: int,
289
+ ) -> None:
290
+ to_key = to_i.logical_key() if to_i is not None else f"text:{to_text}"
291
+ key = (rtype, from_i.logical_key(), to_key)
292
+ acc = self.relations.get(key)
293
+ if acc is None:
294
+ self.relations[key] = _RelAcc(
295
+ rtype=rtype, from_i=from_i, to_i=to_i,
296
+ to_text=to_text, span=span, ev={evi},
297
+ )
298
+ return
299
+ acc.ev.add(evi)
300
+
301
+ def table_entity(
302
+ self, name: NameRef, evi: int, *, declared: bool, kind: DomainEntityKind
303
+ ) -> DomainIdentity:
304
+ ident = self.table_ident(name.parts)
305
+ attrs = {
306
+ "written_name": name.written,
307
+ "dialect": self.dialect,
308
+ "declared": "create" if declared else "reference",
309
+ }
310
+ self.add_entity(
311
+ ident, kind, name.parts[-1], name.span, attrs,
312
+ prio=3 if declared else 1, evi=evi,
313
+ )
314
+ return ident
315
+
316
+ def col_entity(
317
+ self, table: DomainIdentity, col: str, evi: int, *, declared: bool
318
+ ) -> DomainIdentity:
319
+ ident = self.col_ident(table, col)
320
+ self.add_entity(
321
+ ident, DomainEntityKind.DATA_COLUMN, col, None,
322
+ {
323
+ "table": table.display(),
324
+ "dialect": self.dialect,
325
+ "declared": "create" if declared else "reference",
326
+ },
327
+ prio=3 if declared else 1, evi=evi,
328
+ )
329
+ return ident
330
+
331
+ def transform_entity(
332
+ self, stmt_i: int, st: Statement, evi: int
333
+ ) -> DomainIdentity:
334
+ qname = f"{self.path}#{stmt_i}"
335
+ ident = DomainIdentity(
336
+ domain=SQL_DOMAIN, repository=self.repo, root=self.root,
337
+ environment=self.environment, qualified_name=qname,
338
+ )
339
+ self.add_entity(
340
+ ident, DomainEntityKind.DATA_TRANSFORMATION,
341
+ f"{st.kind}#{stmt_i}", st.span,
342
+ {
343
+ "artifact": self.path,
344
+ "statement": st.kind,
345
+ "dialect": self.dialect,
346
+ },
347
+ prio=1, evi=evi,
348
+ )
349
+ return ident
350
+
351
+
352
+ # --- coleta de tabelas terminais ------------------------------------------
353
+
354
+
355
+ def _cte_env(ctes: list[Cte], base: dict[str, Cte] | None = None) -> dict[str, Cte]:
356
+ env = dict(base or {})
357
+ for c in ctes:
358
+ env[c.name] = c
359
+ return env
360
+
361
+
362
+ def _srcs_tables(
363
+ srcs: list[Source],
364
+ env: dict[str, Cte],
365
+ out: list[SrcTable],
366
+ dyn: list[SrcUnsupported],
367
+ ctx: _FileCtx,
368
+ depth: int,
369
+ shadow: frozenset[str] = frozenset(),
370
+ ) -> None:
371
+ for s in srcs:
372
+ if isinstance(s, SrcJoin):
373
+ _srcs_tables([s.left, s.right], env, out, dyn, ctx, depth, shadow)
374
+ elif isinstance(s, SrcGroup):
375
+ _srcs_tables([s.inner], env, out, dyn, ctx, depth, shadow)
376
+ elif isinstance(s, SrcTable):
377
+ _table_read(s, env, out, dyn, ctx, depth, shadow)
378
+ elif isinstance(s, SrcSub):
379
+ if depth < _SCOPE_DEPTH:
380
+ env2 = _cte_env(s.query.ctes, env)
381
+ _query_tables(
382
+ s.query, env2, out, dyn, ctx, depth + 1, shadow
383
+ )
384
+ elif isinstance(s, SrcUnsupported):
385
+ if s.what == "dynamic":
386
+ dyn.append(s)
387
+ else:
388
+ ctx.diag(
389
+ "unsupported-source", "info",
390
+ f"table source not expanded: {s.written} ({s.what})",
391
+ )
392
+ # SrcValues: dados literais — sem upstream
393
+
394
+
395
+ def _table_read(
396
+ s: SrcTable,
397
+ env: dict[str, Cte],
398
+ out: list[SrcTable],
399
+ dyn: list[SrcUnsupported],
400
+ ctx: _FileCtx,
401
+ depth: int,
402
+ shadow: frozenset[str],
403
+ ) -> None:
404
+ """SrcTable: expande CTE, diagnostica auto-recursão, ou tabela base."""
405
+ bare = len(s.name.parts) == 1
406
+ if bare and s.name.parts[0] in env:
407
+ cte = env[s.name.parts[0]]
408
+ if depth < _SCOPE_DEPTH:
409
+ env2 = _cte_env(cte.query.ctes, env)
410
+ env2.pop(cte.name, None) # corta auto-recursão
411
+ _query_tables(
412
+ cte.query, env2, out, dyn, ctx, depth + 1,
413
+ shadow | {cte.name},
414
+ )
415
+ return
416
+ if bare and s.name.parts[0] in shadow:
417
+ # auto-referência de CTE recursiva — nunca vira tabela base
418
+ ctx.diag(
419
+ "recursive-cte", "info",
420
+ f"recursive CTE self-reference not expanded: "
421
+ f"{s.name.written}",
422
+ )
423
+ return
424
+ out.append(s)
425
+
426
+
427
+ def _query_tables(
428
+ q: SelectStmt,
429
+ env: dict[str, Cte],
430
+ out: list[SrcTable],
431
+ dyn: list[SrcUnsupported],
432
+ ctx: _FileCtx,
433
+ depth: int,
434
+ shadow: frozenset[str] = frozenset(),
435
+ ) -> None:
436
+ _srcs_tables(q.sources, env, out, dyn, ctx, depth, shadow)
437
+ for br in q.set_branches:
438
+ env2 = _cte_env(br.ctes, env)
439
+ _query_tables(br, env2, out, dyn, ctx, depth + 1, shadow)
440
+ for sq in q.tail_subqueries:
441
+ env2 = _cte_env(sq.ctes, env)
442
+ _query_tables(sq, env2, out, dyn, ctx, depth + 1, shadow)
443
+
444
+
445
+ # --- escopo de resolução de colunas ---------------------------------------
446
+
447
+
448
+ def _outputs_of(select: SelectStmt) -> tuple[list[_Out], bool]:
449
+ """Saídas nomeadas do select principal; ``star`` = saída desconhecida."""
450
+ outs: list[_Out] = []
451
+ star = False
452
+ for item in select.items:
453
+ if item.star is not None:
454
+ star = True
455
+ continue
456
+ name = item.alias
457
+ if name is None and len(item.refs.cols) == 1 and not item.refs.stars:
458
+ name = item.refs.cols[0].parts[-1]
459
+ outs.append(_Out(name=name, refs=item.refs))
460
+ return outs, star
461
+
462
+
463
+ def _build_scope(
464
+ srcs: list[Source],
465
+ env: dict[str, Cte],
466
+ ctx: _FileCtx,
467
+ parent: _Scope | None,
468
+ depth: int,
469
+ seen: frozenset[int],
470
+ ) -> _Scope:
471
+ """Constrói o escopo de resolução de uma cláusula FROM."""
472
+ scope = _Scope(sources=[], parent=parent)
473
+ for s in srcs:
474
+ info = _src_info(s, env, ctx, scope, depth, seen)
475
+ if isinstance(info, list):
476
+ scope.sources.extend(info)
477
+ elif info is not None:
478
+ scope.sources.append(info)
479
+ return scope
480
+
481
+
482
+ def _src_info(
483
+ s: Source,
484
+ env: dict[str, Cte],
485
+ ctx: _FileCtx,
486
+ scope: _Scope,
487
+ depth: int,
488
+ seen: frozenset[int],
489
+ ) -> _SrcInfo | list[_SrcInfo] | None:
490
+ if isinstance(s, SrcJoin):
491
+ left = _src_info(s.left, env, ctx, scope, depth, seen)
492
+ right = _src_info(s.right, env, ctx, scope, depth, seen)
493
+ out: list[_SrcInfo] = []
494
+ for v in (left, right):
495
+ if isinstance(v, list):
496
+ out.extend(v)
497
+ elif v is not None:
498
+ out.append(v)
499
+ return out
500
+ if isinstance(s, SrcGroup):
501
+ inner = _src_info(s.inner, env, ctx, scope, depth, seen)
502
+ inner_list = (
503
+ inner if isinstance(inner, list)
504
+ else [inner] if inner is not None else []
505
+ )
506
+ group_scope = _Scope(sources=inner_list, parent=scope)
507
+ return _SubSrc(
508
+ alias=s.alias, outputs=[], inner=group_scope, star=True,
509
+ )
510
+ if isinstance(s, SrcTable):
511
+ return _src_table_info(s, env, ctx, scope, depth, seen)
512
+ if isinstance(s, SrcSub):
513
+ return _src_sub_info(s, env, ctx, scope, depth, seen)
514
+ if isinstance(s, SrcValues):
515
+ outs = [_Out(name=c, refs=ExprRefs()) for c in s.col_aliases]
516
+ val_scope = _Scope(sources=[], parent=scope)
517
+ return _SubSrc(
518
+ alias=s.alias, outputs=outs, inner=val_scope,
519
+ star=not s.col_aliases, literal=True,
520
+ )
521
+ if isinstance(s, SrcUnsupported):
522
+ return _DeadSrc()
523
+ return None # type: ignore[unreachable] # subclasses futuras de Source
524
+
525
+
526
+ def _src_table_info(
527
+ s: SrcTable,
528
+ env: dict[str, Cte],
529
+ ctx: _FileCtx,
530
+ scope: _Scope,
531
+ depth: int,
532
+ seen: frozenset[int],
533
+ ) -> _SrcInfo:
534
+ """Nome em ``env`` = CTE (vira ``_SubSrc``); senão, tabela real."""
535
+ if len(s.name.parts) == 1 and s.name.parts[0] in env:
536
+ cte = env[s.name.parts[0]]
537
+ if depth >= _SCOPE_DEPTH or id(cte.query) in seen:
538
+ # auto-recursão / profundidade — fonte real, não-resolvível
539
+ return _DeadSrc(alias=s.alias or cte.name)
540
+ env2 = _cte_env(cte.query.ctes, env)
541
+ # o próprio nome fica em env2: auto-referência recai no
542
+ # ``seen`` acima → _DeadSrc, nunca _TblSrc fabricado
543
+ inner = _build_scope(
544
+ cte.query.sources, env2, ctx, scope, depth + 1,
545
+ seen | {id(cte.query)},
546
+ )
547
+ outs, star = _outputs_of(cte.query)
548
+ outputs = _apply_col_aliases(outs, cte.columns)
549
+ return _SubSrc(
550
+ alias=s.alias or cte.name, outputs=outputs,
551
+ inner=inner, star=star,
552
+ )
553
+ return _TblSrc(
554
+ ident=ctx.table_ident(s.name.parts),
555
+ parts=s.name.parts,
556
+ alias=s.alias or s.name.parts[-1],
557
+ span=s.name.span,
558
+ )
559
+
560
+
561
+ def _src_sub_info(
562
+ s: SrcSub,
563
+ env: dict[str, Cte],
564
+ ctx: _FileCtx,
565
+ scope: _Scope,
566
+ depth: int,
567
+ seen: frozenset[int],
568
+ ) -> _SrcInfo:
569
+ """Subquery parentizada — escopo interno + outputs nomeados."""
570
+ if depth >= _SCOPE_DEPTH or id(s.query) in seen:
571
+ return _DeadSrc(alias=s.alias)
572
+ env2 = _cte_env(s.query.ctes, env)
573
+ inner = _build_scope(
574
+ s.query.sources, env2, ctx, scope, depth + 1,
575
+ seen | {id(s.query)},
576
+ )
577
+ outs, star = _outputs_of(s.query)
578
+ outputs = _apply_col_aliases(outs, s.col_aliases)
579
+ return _SubSrc(
580
+ alias=s.alias, outputs=outputs, inner=inner, star=star,
581
+ )
582
+
583
+
584
+ def _apply_col_aliases(outs: list[_Out], aliases: tuple[str, ...]) -> list[_Out]:
585
+ if not aliases:
586
+ return outs
587
+ out = list(outs)
588
+ for i, a in enumerate(aliases):
589
+ if i < len(out):
590
+ out[i] = _Out(name=a, refs=out[i].refs)
591
+ else:
592
+ out.append(_Out(name=a, refs=ExprRefs()))
593
+ return out
594
+
595
+
596
+ def _may_expose(s: _SrcInfo, name: str) -> bool:
597
+ if isinstance(s, _TblSrc):
598
+ return True
599
+ if isinstance(s, _SubSrc):
600
+ if s.star:
601
+ return True
602
+ return any(o.name == name for o in s.outputs)
603
+ return False
604
+
605
+
606
+ def _match_qual(s: _SrcInfo, qual: tuple[str, ...]) -> bool:
607
+ if isinstance(s, _TblSrc):
608
+ if s.alias is not None:
609
+ return len(qual) == 1 and s.alias == qual[0]
610
+ return len(qual) <= len(s.parts) and s.parts[-len(qual):] == qual
611
+ if isinstance(s, _SubSrc):
612
+ return s.alias is not None and len(qual) == 1 and s.alias == qual[0]
613
+ return False
614
+
615
+
616
+ def _into(
617
+ s: _SrcInfo,
618
+ colname: str,
619
+ ctx: _FileCtx,
620
+ depth: int,
621
+ seen: frozenset[int],
622
+ ) -> _RCol:
623
+ """Resolve ``s.<colname>`` dentro da fonte — terminais + problemas."""
624
+ res = _RCol()
625
+ if isinstance(s, _TblSrc):
626
+ res.terminals.append((s.ident, colname))
627
+ return res
628
+ if isinstance(s, _SubSrc):
629
+ if s.literal:
630
+ return res # VALUES — resolvido, sem upstream
631
+ matched = [o for o in s.outputs if o.name == colname]
632
+ if not matched:
633
+ if s.star:
634
+ res.problems.append(
635
+ ("unresolved-column", f"{colname} in star-expanding "
636
+ f"source {s.alias!r} — terminal unknown")
637
+ )
638
+ else:
639
+ res.problems.append(
640
+ ("unresolved-column", f"{colname} not in outputs of "
641
+ f"{s.alias!r}")
642
+ )
643
+ return res
644
+ for o in matched:
645
+ res = _merge_rcol(
646
+ res, _resolve_refs(o.refs, s.inner, ctx, depth + 1, seen)
647
+ )
648
+ return res
649
+ res.problems.append(
650
+ ("unresolved-column", f"{colname} on opaque source")
651
+ )
652
+ return res
653
+
654
+
655
+ def _merge_rcol(a: _RCol, b: _RCol) -> _RCol:
656
+ a.terminals.extend(b.terminals)
657
+ a.problems.extend(b.problems)
658
+ return a
659
+
660
+
661
+ def _resolve_refs(
662
+ refs: ExprRefs,
663
+ scope: _Scope,
664
+ ctx: _FileCtx,
665
+ depth: int,
666
+ seen: frozenset[int],
667
+ ) -> _RCol:
668
+ """Terminais de coluna para todos os colrefs de uma expressão.
669
+
670
+ Subqueries na expressão expandem com escopo próprio encadeado ao
671
+ pai (correlação honesta).
672
+ """
673
+ res = _RCol()
674
+ if depth >= _SCOPE_DEPTH:
675
+ res.problems.append(("unresolved-column", "resolution depth exceeded"))
676
+ return res
677
+ for col in refs.cols:
678
+ res = _merge_rcol(res, _resolve_col(col, scope, ctx, depth, seen))
679
+ for sq in refs.subqueries:
680
+ if id(sq) in seen:
681
+ continue
682
+ sub_env = _cte_env(sq.ctes, {})
683
+ inner = _build_scope(
684
+ sq.sources, sub_env, ctx, scope, depth + 1, seen | {id(sq)}
685
+ )
686
+ for item in sq.items:
687
+ res = _merge_rcol(
688
+ res,
689
+ _resolve_refs(item.refs, inner, ctx, depth + 1, seen | {id(sq)}),
690
+ )
691
+ for br in sq.set_branches:
692
+ for item in br.items:
693
+ res = _merge_rcol(
694
+ res,
695
+ _resolve_refs(
696
+ item.refs, inner, ctx, depth + 1, seen | {id(sq)}
697
+ ),
698
+ )
699
+ return res
700
+
701
+
702
+ def _resolve_col(
703
+ col: ColRef,
704
+ scope: _Scope,
705
+ ctx: _FileCtx,
706
+ depth: int,
707
+ seen: frozenset[int],
708
+ ) -> _RCol:
709
+ res = _RCol()
710
+ parts = col.parts
711
+ if len(parts) == 1:
712
+ name = parts[0]
713
+ cands = [s for s in scope.sources if _may_expose(s, name)]
714
+ if len(cands) == 1:
715
+ return _into(cands[0], name, ctx, depth, seen)
716
+ if len(cands) > 1:
717
+ res.problems.append(
718
+ ("ambiguous-column",
719
+ f"unqualified {name!r} matches {len(cands)} sources")
720
+ )
721
+ return res
722
+ if scope.parent is not None:
723
+ return _resolve_col(col, scope.parent, ctx, depth + 1, seen)
724
+ res.problems.append(
725
+ ("unresolved-column", f"unqualified {name!r} matches no source")
726
+ )
727
+ return res
728
+ # qualificado: maior prefixo primeiro
729
+ for k in range(len(parts) - 1, 0, -1):
730
+ qual = tuple(parts[:k])
731
+ colname = parts[k]
732
+ matches = [s for s in scope.sources if _match_qual(s, qual)]
733
+ if len(matches) > 1:
734
+ res.problems.append(
735
+ ("ambiguous-column",
736
+ f"{'.'.join(parts)!r}: qualifier "
737
+ f"{'.'.join(qual)!r} matches {len(matches)} sources")
738
+ )
739
+ return res
740
+ if len(matches) == 1:
741
+ return _into(matches[0], colname, ctx, depth, seen)
742
+ if scope.parent is not None:
743
+ return _resolve_col(col, scope.parent, ctx, depth + 1, seen)
744
+ res.problems.append(
745
+ ("unresolved-column",
746
+ f"{'.'.join(parts)!r}: qualifier matches no source")
747
+ )
748
+ return res
749
+
750
+
751
+ # --- extração por statement -------------------------------------------------
752
+
753
+
754
+ def _star_diag(ctx: _FileCtx, st: Statement, q: SelectStmt | None) -> None:
755
+ if q is None:
756
+ return
757
+ for item in q.items:
758
+ if item.star is not None:
759
+ qual = item.star.qualifier
760
+ what = f"{'.'.join(qual)}.*" if qual else "*"
761
+ ctx.diag(
762
+ "select-star", "warning",
763
+ f"{st.kind} expands {what} — column lineage is "
764
+ "limited to non-star items; table lineage kept",
765
+ )
766
+
767
+
768
+ def _col_lineage(
769
+ ctx: _FileCtx,
770
+ scope: _Scope,
771
+ pairs: list[tuple[str, ExprRefs]],
772
+ target_i: DomainIdentity,
773
+ evi: int,
774
+ seen: frozenset[int] = frozenset(),
775
+ ) -> None:
776
+ """Emite DERIVES_FROM col→col para pares (alvo, expr-fonte)."""
777
+ for tcol, refs in pairs:
778
+ res = _resolve_refs(refs, scope, ctx, 0, seen)
779
+ for code, msg in res.problems:
780
+ ctx.diag(code, "warning", msg)
781
+ out_i = ctx.col_entity(target_i, tcol, evi, declared=True)
782
+ for tbl_i, cname in res.terminals:
783
+ src_i = ctx.col_entity(tbl_i, cname, evi, declared=False)
784
+ ctx.add_rel(
785
+ DomainRelationKind.DERIVES_FROM.value, out_i,
786
+ to_i=src_i, evi=evi,
787
+ )
788
+
789
+
790
+ def _extract_file(ctx: _FileCtx, parsed: ParsedFile) -> None:
791
+ for i, st in enumerate(parsed.statements):
792
+ try:
793
+ _stmt(ctx, i, st)
794
+ except Exception as exc: # nunca crash — parcial honesto
795
+ ctx.diag(
796
+ "extract-error", "error",
797
+ f"{st.kind}#{i}: {type(exc).__name__}: {exc}",
798
+ )
799
+ # problemas do parser viram diagnósticos de domínio
800
+ for d in parsed.diagnostics:
801
+ sev = "error" if d.severity == "error" else "warning"
802
+ ctx.diag(
803
+ d.code if d.code != "error-node" else "parse-error",
804
+ sev, d.message,
805
+ )
806
+ ctx.stats["statements"] = len(parsed.statements)
807
+ ctx.stats["parse_diagnostics"] = len(parsed.diagnostics)
808
+
809
+
810
+ def _stmt(ctx: _FileCtx, i: int, st: Statement) -> None:
811
+ evi = ctx.evi(i, st.span)
812
+ if st.dynamic:
813
+ ctx.diag(
814
+ "dynamic-sql", "warning",
815
+ f"{st.kind}#{i} contains dynamic/template content — "
816
+ "only static parts were extracted",
817
+ )
818
+ if isinstance(st, SUnsupported):
819
+ code = (
820
+ "dynamic-sql" if st.why == "dynamic-exec"
821
+ else "unsupported-statement"
822
+ )
823
+ sev = "warning" if st.why == "dynamic-exec" else "info"
824
+ ctx.diag(code, sev, f"statement skipped: {st.head or st.why}")
825
+ return
826
+ if isinstance(st, SUse):
827
+ _use(ctx, st)
828
+ return
829
+ handler = _STMT_HANDLERS.get(type(st))
830
+ if handler is not None:
831
+ handler(ctx, i, st, evi)
832
+
833
+
834
+ def _use(ctx: _FileCtx, st: SUse) -> None:
835
+ if len(st.parts) >= 2:
836
+ ctx.use_catalog = st.parts[0]
837
+ ctx.use_schema = ".".join(st.parts[1:])
838
+ elif st.parts:
839
+ ctx.use_schema = st.parts[0]
840
+
841
+
842
+ def _read_edges(
843
+ ctx: _FileCtx,
844
+ tid: DomainIdentity,
845
+ sources: list[SrcTable],
846
+ dyn: list[SrcUnsupported],
847
+ evi: int,
848
+ ) -> None:
849
+ for s in sources:
850
+ ti = ctx.table_entity(s.name, evi, declared=False,
851
+ kind=DomainEntityKind.DATA_TABLE)
852
+ ctx.add_rel(
853
+ DomainRelationKind.READS_FROM.value, tid,
854
+ to_i=ti, span=s.name.span, evi=evi,
855
+ )
856
+ for d in dyn:
857
+ ctx.add_rel(
858
+ DomainRelationKind.READS_FROM.value, tid,
859
+ to_text=d.written, span=d.span, evi=evi,
860
+ )
861
+
862
+
863
+ def _collect(
864
+ ctx: _FileCtx, env: dict[str, Cte], queries: list[SelectStmt]
865
+ ) -> tuple[list[SrcTable], list[SrcUnsupported]]:
866
+ tables: list[SrcTable] = []
867
+ dyn: list[SrcUnsupported] = []
868
+ for q in queries:
869
+ env2 = _cte_env(q.ctes, env)
870
+ _query_tables(q, env2, tables, dyn, ctx, 0)
871
+ return tables, dyn
872
+
873
+
874
+ def _derive_edges(
875
+ ctx: _FileCtx,
876
+ target_i: DomainIdentity,
877
+ sources: list[SrcTable],
878
+ evi: int,
879
+ ) -> None:
880
+ tkey = target_i.logical_key()
881
+ for s in sources:
882
+ ti = ctx.table_entity(s.name, evi, declared=False,
883
+ kind=DomainEntityKind.DATA_TABLE)
884
+ if ti.logical_key() == tkey:
885
+ continue # auto-derivação não é aresta
886
+ ctx.add_rel(
887
+ DomainRelationKind.DERIVES_FROM.value, target_i,
888
+ to_i=ti, span=s.name.span, evi=evi,
889
+ )
890
+
891
+
892
+ def _select(ctx: _FileCtx, i: int, st: SSelect, evi: int) -> None:
893
+ q = st.query
894
+ if q is None:
895
+ return
896
+ env = _cte_env(q.ctes)
897
+ tables, dyn = _collect(ctx, env, [q])
898
+ if not tables and not dyn:
899
+ return
900
+ tid = ctx.transform_entity(i, st, evi)
901
+ _read_edges(ctx, tid, tables, dyn, evi)
902
+ _star_diag(ctx, st, q)
903
+
904
+
905
+ def _insert(ctx: _FileCtx, i: int, st: SInsert, evi: int) -> None:
906
+ assert st.target is not None
907
+ tid = ctx.transform_entity(i, st, evi)
908
+ tgt = ctx.table_entity(st.target, evi, declared=False,
909
+ kind=DomainEntityKind.DATA_TABLE)
910
+ env = _cte_env(st.query.ctes if st.query else [])
911
+ tables, dyn = _collect(ctx, env, [st.query] if st.query else [])
912
+ _read_edges(ctx, tid, tables, dyn, evi)
913
+ ctx.add_rel(
914
+ DomainRelationKind.WRITES_TO.value, tid,
915
+ to_i=tgt, span=st.target.span, evi=evi,
916
+ )
917
+ _derive_edges(ctx, tgt, tables, evi)
918
+ if st.query is not None:
919
+ _star_diag(ctx, st, st.query)
920
+ if st.columns is None:
921
+ ctx.diag(
922
+ "implicit-column-list", "info",
923
+ "INSERT without column list — target column names "
924
+ "unknown; column lineage skipped",
925
+ )
926
+ else:
927
+ pairs = _positional_pairs(st.columns, st.query)
928
+ if pairs is None:
929
+ ctx.diag(
930
+ "column-count-mismatch", "warning",
931
+ f"INSERT column list ({len(st.columns)}) does not "
932
+ f"match select items — mapped common prefix",
933
+ )
934
+ pairs = _positional_pairs(st.columns, st.query, strict=False)
935
+ scope = _build_scope(
936
+ st.query.sources, env, ctx, None, 0, frozenset()
937
+ )
938
+ _col_lineage(ctx, scope, pairs or [], tgt, evi)
939
+ elif st.value_rows:
940
+ # VALUES literal — sem upstream; subqueries nas linhas são lidas
941
+ sub_tables: list[SrcTable] = []
942
+ sub_dyn: list[SrcUnsupported] = []
943
+ for row in st.value_rows:
944
+ for refs in row:
945
+ for sq in refs.subqueries:
946
+ _query_tables(sq, dict(env), sub_tables, sub_dyn, ctx, 0)
947
+ _read_edges(ctx, tid, sub_tables, sub_dyn, evi)
948
+ if st.columns is None:
949
+ ctx.diag(
950
+ "implicit-column-list", "info",
951
+ "INSERT … VALUES without column list — "
952
+ "column lineage skipped",
953
+ )
954
+
955
+
956
+ def _positional_pairs(
957
+ cols: tuple[str, ...], q: SelectStmt, *, strict: bool = True
958
+ ) -> list[tuple[str, ExprRefs]] | None:
959
+ items = q.items
960
+ if strict and len(cols) != len(items):
961
+ return None
962
+ return [
963
+ (c, items[k].refs)
964
+ for k, c in enumerate(cols)
965
+ if k < len(items)
966
+ ]
967
+
968
+
969
+ def _update(ctx: _FileCtx, i: int, st: SUpdate, evi: int) -> None:
970
+ assert st.target is not None
971
+ tid = ctx.transform_entity(i, st, evi)
972
+ tgt = ctx.table_entity(st.target, evi, declared=False,
973
+ kind=DomainEntityKind.DATA_TABLE)
974
+ env = _cte_env(st.ctes)
975
+ tables: list[SrcTable] = []
976
+ dyn: list[SrcUnsupported] = []
977
+ _srcs_tables(st.sources, env, tables, dyn, ctx, 0)
978
+ for q in st.tail_subqueries:
979
+ _query_tables(q, dict(env), tables, dyn, ctx, 0)
980
+ for _, refs in st.sets:
981
+ for sq in refs.subqueries:
982
+ _query_tables(sq, dict(env), tables, dyn, ctx, 0)
983
+ ctx.add_rel(
984
+ DomainRelationKind.READS_FROM.value, tid,
985
+ to_i=tgt, span=st.target.span, evi=evi,
986
+ )
987
+ _read_edges(ctx, tid, tables, dyn, evi)
988
+ ctx.add_rel(
989
+ DomainRelationKind.TRANSFORMS.value, tid,
990
+ to_i=tgt, span=st.target.span, evi=evi,
991
+ )
992
+ _derive_edges(ctx, tgt, tables, evi)
993
+ # escopo: o próprio alvo + fontes da cláusula FROM
994
+ tsrc = _TblSrc(
995
+ ident=tgt, parts=st.target.parts,
996
+ alias=st.target_alias or st.target.parts[-1],
997
+ span=st.target.span,
998
+ )
999
+ scope = _Scope(sources=[tsrc])
1000
+ for s in st.sources:
1001
+ info = _src_info(s, env, ctx, scope, 0, frozenset())
1002
+ if isinstance(info, list):
1003
+ scope.sources.extend(info)
1004
+ elif info is not None:
1005
+ scope.sources.append(info)
1006
+ _col_lineage(ctx, scope, list(st.sets), tgt, evi)
1007
+
1008
+
1009
+ def _delete(ctx: _FileCtx, i: int, st: SDelete, evi: int) -> None:
1010
+ assert st.target is not None
1011
+ tid = ctx.transform_entity(i, st, evi)
1012
+ tgt = ctx.table_entity(st.target, evi, declared=False,
1013
+ kind=DomainEntityKind.DATA_TABLE)
1014
+ env = _cte_env(st.ctes)
1015
+ tables: list[SrcTable] = []
1016
+ dyn: list[SrcUnsupported] = []
1017
+ _srcs_tables(st.sources, env, tables, dyn, ctx, 0)
1018
+ for q in st.tail_subqueries:
1019
+ _query_tables(q, dict(env), tables, dyn, ctx, 0)
1020
+ ctx.add_rel(
1021
+ DomainRelationKind.READS_FROM.value, tid,
1022
+ to_i=tgt, span=st.target.span, evi=evi,
1023
+ )
1024
+ _read_edges(ctx, tid, tables, dyn, evi)
1025
+ ctx.add_rel(
1026
+ DomainRelationKind.TRANSFORMS.value, tid,
1027
+ to_i=tgt, span=st.target.span, evi=evi,
1028
+ )
1029
+ _derive_edges(ctx, tgt, tables, evi)
1030
+
1031
+
1032
+ def _merge(ctx: _FileCtx, i: int, st: SMerge, evi: int) -> None:
1033
+ assert st.target is not None and st.source is not None
1034
+ tid = ctx.transform_entity(i, st, evi)
1035
+ tgt = ctx.table_entity(st.target, evi, declared=False,
1036
+ kind=DomainEntityKind.DATA_TABLE)
1037
+ env = _cte_env(st.ctes)
1038
+ tables: list[SrcTable] = []
1039
+ dyn: list[SrcUnsupported] = []
1040
+ _srcs_tables([st.source], env, tables, dyn, ctx, 0)
1041
+ for q in st.tail_subqueries:
1042
+ _query_tables(q, dict(env), tables, dyn, ctx, 0)
1043
+ for _, refs in st.sets:
1044
+ for sq in refs.subqueries:
1045
+ _query_tables(sq, dict(env), tables, dyn, ctx, 0)
1046
+ for refs in st.insert_exprs:
1047
+ for sq in refs.subqueries:
1048
+ _query_tables(sq, dict(env), tables, dyn, ctx, 0)
1049
+ ctx.add_rel(
1050
+ DomainRelationKind.READS_FROM.value, tid,
1051
+ to_i=tgt, span=st.target.span, evi=evi,
1052
+ )
1053
+ _read_edges(ctx, tid, tables, dyn, evi)
1054
+ ctx.add_rel(
1055
+ DomainRelationKind.TRANSFORMS.value, tid,
1056
+ to_i=tgt, span=st.target.span, evi=evi,
1057
+ )
1058
+ _derive_edges(ctx, tgt, tables, evi)
1059
+ # escopo: alvo + fonte do USING
1060
+ scope = _Scope(sources=[])
1061
+ scope.sources.append(
1062
+ _TblSrc(
1063
+ ident=tgt, parts=st.target.parts,
1064
+ alias=st.target_alias or st.target.parts[-1],
1065
+ span=st.target.span,
1066
+ )
1067
+ )
1068
+ info = _src_info(st.source, env, ctx, scope, 0, frozenset())
1069
+ if isinstance(info, list):
1070
+ scope.sources.extend(info)
1071
+ elif info is not None:
1072
+ scope.sources.append(info)
1073
+ _col_lineage(ctx, scope, list(st.sets), tgt, evi)
1074
+ if st.insert_cols is not None and st.insert_exprs:
1075
+ pairs = [
1076
+ (c, st.insert_exprs[k])
1077
+ for k, c in enumerate(st.insert_cols)
1078
+ if k < len(st.insert_exprs)
1079
+ ]
1080
+ if len(st.insert_cols) != len(st.insert_exprs):
1081
+ ctx.diag(
1082
+ "column-count-mismatch", "warning",
1083
+ "MERGE INSERT column list does not match VALUES arity",
1084
+ )
1085
+ _col_lineage(ctx, scope, pairs, tgt, evi)
1086
+
1087
+
1088
+ def _create_table(ctx: _FileCtx, i: int, st: SCreateTable, evi: int) -> None:
1089
+ assert st.name is not None
1090
+ tgt = ctx.table_entity(st.name, evi, declared=True,
1091
+ kind=DomainEntityKind.DATA_TABLE)
1092
+ for cname, ctype in st.columns:
1093
+ ident = ctx.col_ident(tgt, cname)
1094
+ ctx.add_entity(
1095
+ ident, DomainEntityKind.DATA_COLUMN, cname, None,
1096
+ {
1097
+ "table": tgt.display(),
1098
+ "declared_type": ctype,
1099
+ "dialect": ctx.dialect,
1100
+ "declared": "create",
1101
+ },
1102
+ prio=3, evi=evi,
1103
+ )
1104
+ if st.like is not None:
1105
+ tid = ctx.transform_entity(i, st, evi)
1106
+ li = ctx.table_entity(st.like, evi, declared=False,
1107
+ kind=DomainEntityKind.DATA_TABLE)
1108
+ ctx.add_rel(
1109
+ DomainRelationKind.READS_FROM.value, tid,
1110
+ to_i=li, span=st.like.span, evi=evi,
1111
+ )
1112
+ ctx.add_rel(
1113
+ DomainRelationKind.WRITES_TO.value, tid,
1114
+ to_i=tgt, span=st.name.span, evi=evi,
1115
+ )
1116
+ ctx.add_rel(
1117
+ DomainRelationKind.DERIVES_FROM.value, tgt,
1118
+ to_i=li, span=st.like.span, evi=evi,
1119
+ )
1120
+ if st.query is not None:
1121
+ tid = ctx.transform_entity(i, st, evi)
1122
+ env = _cte_env(st.query.ctes)
1123
+ tables, dyn = _collect(ctx, env, [st.query])
1124
+ _read_edges(ctx, tid, tables, dyn, evi)
1125
+ ctx.add_rel(
1126
+ DomainRelationKind.WRITES_TO.value, tid,
1127
+ to_i=tgt, span=st.name.span, evi=evi,
1128
+ )
1129
+ _derive_edges(ctx, tgt, tables, evi)
1130
+ _star_diag(ctx, st, st.query)
1131
+ pairs = _named_pairs(ctx, st.query)
1132
+ scope = _build_scope(
1133
+ st.query.sources, env, ctx, None, 0, frozenset()
1134
+ )
1135
+ _col_lineage(ctx, scope, pairs, tgt, evi)
1136
+
1137
+
1138
+ def _create_view(ctx: _FileCtx, i: int, st: SCreateView, evi: int) -> None:
1139
+ assert st.name is not None and st.query is not None
1140
+ tgt = ctx.table_entity(st.name, evi, declared=True,
1141
+ kind=DomainEntityKind.DATA_VIEW)
1142
+ tid = ctx.transform_entity(i, st, evi)
1143
+ env = _cte_env(st.query.ctes)
1144
+ tables, dyn = _collect(ctx, env, [st.query])
1145
+ _read_edges(ctx, tid, tables, dyn, evi)
1146
+ ctx.add_rel(
1147
+ DomainRelationKind.WRITES_TO.value, tid,
1148
+ to_i=tgt, span=st.name.span, evi=evi,
1149
+ )
1150
+ _derive_edges(ctx, tgt, tables, evi)
1151
+ _star_diag(ctx, st, st.query)
1152
+ pairs = _named_pairs(ctx, st.query, col_aliases=st.col_aliases)
1153
+ scope = _build_scope(
1154
+ st.query.sources, env, ctx, None, 0, frozenset()
1155
+ )
1156
+ _col_lineage(ctx, scope, pairs, tgt, evi)
1157
+
1158
+
1159
+ def _named_pairs(
1160
+ ctx: _FileCtx,
1161
+ q: SelectStmt,
1162
+ col_aliases: tuple[str, ...] = (),
1163
+ ) -> list[tuple[str, ExprRefs]]:
1164
+ """Pares (coluna-saída, refs) por nome — VIEW/CTAS.
1165
+
1166
+ Nome vem de col_aliases posicionais, alias do item ou colref simples.
1167
+ Itens sem nome → diagnóstico ``unnamed-output`` e skip.
1168
+ """
1169
+ pairs: list[tuple[str, ExprRefs]] = []
1170
+ for k, item in enumerate(q.items):
1171
+ name: str | None = None
1172
+ if k < len(col_aliases):
1173
+ name = col_aliases[k]
1174
+ if name is None:
1175
+ name = item.alias
1176
+ if name is None and len(item.refs.cols) == 1 and not item.refs.stars:
1177
+ name = item.refs.cols[0].parts[-1]
1178
+ if name is None:
1179
+ if item.star is None:
1180
+ ctx.diag(
1181
+ "unnamed-output", "info",
1182
+ "output column has no resolvable name — "
1183
+ "column lineage skipped for it",
1184
+ )
1185
+ continue
1186
+ pairs.append((name, item.refs))
1187
+ return pairs
1188
+
1189
+
1190
+ def _truncate(ctx: _FileCtx, i: int, st: STruncate, evi: int) -> None:
1191
+ assert st.name is not None
1192
+ tid = ctx.transform_entity(i, st, evi)
1193
+ tgt = ctx.table_entity(st.name, evi, declared=False,
1194
+ kind=DomainEntityKind.DATA_TABLE)
1195
+ ctx.add_rel(
1196
+ DomainRelationKind.TRANSFORMS.value, tid,
1197
+ to_i=tgt, span=st.name.span, evi=evi,
1198
+ )
1199
+
1200
+
1201
+ # Dispatch por tipo de statement — ``_use``/``SUnsupported`` ficam fora
1202
+ # porque têm assinatura e semântica próprias (sem evidência de statement).
1203
+ _STMT_HANDLERS: dict[
1204
+ type[Statement], Callable[[_FileCtx, int, Any, int], None]
1205
+ ] = {
1206
+ SSelect: _select,
1207
+ SInsert: _insert,
1208
+ SUpdate: _update,
1209
+ SDelete: _delete,
1210
+ SMerge: _merge,
1211
+ SCreateTable: _create_table,
1212
+ SCreateView: _create_view,
1213
+ STruncate: _truncate,
1214
+ }
1215
+
1216
+
1217
+ # --- ponto de entrada --------------------------------------------------------
1218
+
1219
+
1220
+ @dataclass
1221
+ class FileExtraction:
1222
+ entities: list[ExtractedDomainEntity]
1223
+ relations: list[ExtractedDomainRelation]
1224
+ evidence: list[EvidenceRecord]
1225
+ diagnostics: list[DomainDiagnostic]
1226
+ stats: dict[str, int]
1227
+
1228
+
1229
+ def extract_parsed(
1230
+ parsed: ParsedFile,
1231
+ *,
1232
+ repository: str,
1233
+ root: str,
1234
+ environment: str,
1235
+ path: str,
1236
+ sha: str,
1237
+ adapter_version: str,
1238
+ ) -> FileExtraction:
1239
+ """Converte ``ParsedFile`` em fatos de domínio de um artefato."""
1240
+ ctx = _FileCtx(
1241
+ repository=repository, root=root, environment=environment,
1242
+ path=path, sha=sha, dialect=parsed.dialect,
1243
+ adapter_version=adapter_version,
1244
+ )
1245
+ _extract_file(ctx, parsed)
1246
+ ctx.finish_diags()
1247
+ entities = [
1248
+ ExtractedDomainEntity(
1249
+ identity=acc.identity, kind=acc.kind, name=acc.name,
1250
+ path=path, span=acc.span, content_hash=sha,
1251
+ attributes=acc.attrs, evidence=tuple(sorted(acc.ev)),
1252
+ )
1253
+ for acc in ctx.entities.values()
1254
+ ]
1255
+ relations = [
1256
+ ExtractedDomainRelation(
1257
+ relation_type=acc.rtype, path=path,
1258
+ from_identity=acc.from_i, to_identity=acc.to_i,
1259
+ to_unresolved=acc.to_text, span=acc.span,
1260
+ evidence=tuple(sorted(acc.ev)),
1261
+ )
1262
+ for acc in ctx.relations.values()
1263
+ ]
1264
+ stats = dict(ctx.stats)
1265
+ stats["entities"] = len(entities)
1266
+ stats["relations"] = len(relations)
1267
+ stats["diagnostics"] = len(ctx.diagnostics)
1268
+ return FileExtraction(
1269
+ entities=entities, relations=relations,
1270
+ evidence=ctx.evidence, diagnostics=ctx.diagnostics, stats=stats,
1271
+ )