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,1943 @@
1
+ """Avaliador estático de DataFrames PySpark sobre ``ast`` (lf-0112, Ph.4).
2
+
3
+ Nenhum parser regex, nenhuma execução: o walker percorre o ``ast`` do
4
+ ``.py`` em ordem sequencial mantendo um **ambiente de bindings** —
5
+ ``nome → sessão | dataframe | writer | str-literal | não-df |
6
+ desconhecido`` — com escopo por função e merge de branches em
7
+ ``if``/``match`` (loops e ``try`` usam aproximação sequencial
8
+ declarada: corpo desenrolado uma vez).
9
+
10
+ Modelo de linhagem (espelha a semântica do adapter ``sql``):
11
+
12
+ - **transformação materializada** (``data_transformation``): criada
13
+ quando um valor-DataFrame ancora um nome (``df = …``) ou uma
14
+ expressão terminal executa (``spark.sql``, ``df.write…``, dfv
15
+ "complexo" achado em argumento de função desconhecida);
16
+ - ``T READS_FROM tabela|path``: ``spark.read.*``, ``spark.table``,
17
+ ``spark.sql(SELECT …)``;
18
+ - ``T DERIVES_FROM T'``: encadeamento var→var (``enriched = df.join…``);
19
+ - ``T DERIVES_FROM to_unresolved``: receiver/arg não-resolvível —
20
+ linhagem **parcial**, honesta, diagnosticada;
21
+ - ``T WRITES_TO tabela|path|iceberg``: ``df.write.*``,
22
+ ``df.writeTo(t).<terminal>``, ``spark.sql(INSERT/CREATE …)``;
23
+ - ``T TRANSFORMS tabela``: mutação in-place vinda de literal
24
+ ``spark.sql`` (UPDATE/DELETE/MERGE — remap do adapter sql);
25
+ - ``sink DERIVES_FROM fonte``: colapso tabela→tabela do write
26
+ (mesmo desenho do CTAS no adapter sql).
27
+
28
+ Regras de honestidade (hard):
29
+
30
+ - variável-DataFrame NÃO é tabela persistida — ``data_transformation``
31
+ âncora ``<arquivo>#<var>@<linha>`` nunca colide com identidade de
32
+ ``data_table`` (``catalog.namespace.name``);
33
+ - paths (``s3://…``) viram ``data_table`` com ``storage="path"`` e
34
+ ``qualified_name`` = literal verbatim — nunca conflitam com tabelas
35
+ de catálogo;
36
+ - ``spark.sql(não-literal)`` → ``dynamic-sql`` + ``to_unresolved`` —
37
+ nenhuma dependência fabricada;
38
+ - receiver desconhecido → ``unresolved-dataframe`` (info) + aresta
39
+ ``to_unresolved`` — parcial, nunca fabricado;
40
+ - literais de string propagam apenas via constantes ``str`` do próprio
41
+ arquivo (``TABLE = "db.t"``); f-strings com ``{}`` são dinâmicas;
42
+ - ``.py`` sem uso de Spark → ignorado em silêncio (stats somente).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import ast
48
+ from collections.abc import Callable
49
+ from dataclasses import dataclass, field
50
+ from typing import Any
51
+
52
+ from code_atlas.contracts.enums import EdgeKind
53
+ from code_atlas.domains.identity import DomainIdentity
54
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
55
+ from code_atlas.domains.spark.emit import (
56
+ DOMAIN_ID,
57
+ Sink,
58
+ split_table_name,
59
+ uri_scheme,
60
+ )
61
+ from code_atlas.domains.spark.sqllit import delegate_sql_literal
62
+ from code_atlas.parsers.contracts import SourceSpan
63
+
64
+ # -- vocabulário PySpark reconhecido (estático, declarado) -------------------
65
+
66
+ _SESSION_NAMES = frozenset({"spark", "spark_session", "sparkSession"})
67
+ _SESSION_ATTRS = frozenset({"spark", "spark_session"}) # self.spark
68
+ _SESSION_READ_ATTRS = frozenset({"read", "readStream"})
69
+ _SESSION_METHODS = frozenset(
70
+ {
71
+ "read", "readStream", "sql", "table", "createDataFrame", "range",
72
+ "newSession", "getActiveSession", "sparkContext", "conf",
73
+ "catalog", "udf", "streams", "version", "stop",
74
+ }
75
+ )
76
+ # Métodos de sessão que definitivamente NÃO devolvem DataFrame.
77
+ _SESSION_NON_DF = frozenset(
78
+ {
79
+ "sparkContext", "conf", "catalog", "udf", "streams", "version",
80
+ "stop", "getActiveSession", "newSession",
81
+ }
82
+ )
83
+
84
+ # Operações DataFrame→DataFrame (spec: select/filter/join/groupBy/agg/
85
+ # union/withColumn/dropDuplicates/withColumnRenamed + equivalentes da
86
+ # API pública documentada).
87
+ _DF_TRANSFORMS = frozenset(
88
+ {
89
+ "select", "selectExpr", "filter", "where", "join", "crossJoin",
90
+ "joinWith", "union", "unionByName", "unionAll",
91
+ "intersect", "intersectAll", "exceptAll", "subtract",
92
+ "groupBy", "agg", "rollup", "cube",
93
+ "withColumn", "withColumns", "withColumnRenamed",
94
+ "withColumnsRenamed", "withMetadata", "withField",
95
+ "drop", "dropDuplicates", "drop_duplicates",
96
+ "dropDuplicatesWithinWatermark", "distinct",
97
+ "orderBy", "sort", "sortWithinPartitions", "limit", "alias",
98
+ "repartition", "repartitionByRange", "coalesce",
99
+ "cache", "persist", "unpersist", "checkpoint", "localCheckpoint",
100
+ "dropna", "fillna", "replace", "sample", "sampleBy",
101
+ "transform", "toDF", "toJSON", "describe", "summary",
102
+ "hint", "observe", "withWatermark", "mapInPandas",
103
+ "applyInPandas",
104
+ }
105
+ )
106
+ # Transformações cujo primeiro argumento também é um DataFrame.
107
+ _DF_ARG_OPS = frozenset(
108
+ {
109
+ "join", "crossJoin", "joinWith", "union", "unionByName",
110
+ "unionAll", "exceptAll", "subtract", "intersect", "intersectAll",
111
+ }
112
+ )
113
+ # Chamadas que definitivamente NÃO devolvem DataFrame (ações/terminais).
114
+ _DF_NON_DF_CALLS = frozenset(
115
+ {
116
+ "collect", "count", "show", "take", "head", "first", "tail",
117
+ "foreach", "foreachPartition", "reduce", "explain",
118
+ "printSchema", "toPandas", "toLocalIterator", "inputFiles",
119
+ "isLocal", "isEmpty", "countApprox", "approxQuantile",
120
+ "randomSplit", "countByValue",
121
+ }
122
+ )
123
+ _DF_NON_DF_ATTRS = frozenset(
124
+ {"columns", "schema", "dtypes", "rdd", "isStreaming"}
125
+ )
126
+
127
+ # Terminais de ``df.write.*`` que escrevem em PATH.
128
+ _WRITER_PATH_TERMINALS = frozenset(
129
+ {"parquet", "csv", "json", "orc", "text", "avro", "save"}
130
+ )
131
+ # Terminais de ``df.write.*`` que escrevem em TABELA de catálogo.
132
+ _WRITER_TABLE_TERMINALS = frozenset({"saveAsTable", "insertInto", "jdbc"})
133
+ _WRITER_OPTS = frozenset(
134
+ {"mode", "format", "option", "options", "partitionBy",
135
+ "bucketBy", "sortBy"}
136
+ )
137
+ # Terminais de ``df.writeTo(t)`` (DataFrameWriterV2 — Iceberg-style).
138
+ _V2_TERMINALS = frozenset(
139
+ {"append", "overwrite", "overwritePartitions", "replace",
140
+ "createOrReplace", "create"}
141
+ )
142
+ _V2_OPTS = frozenset(
143
+ {"using", "option", "options", "tableProperty", "partitionedBy",
144
+ "clusteredBy"}
145
+ )
146
+ # Temp views — ``df.createOrReplaceTempView("v")`` etc.
147
+ _TEMP_VIEW_METHODS = frozenset(
148
+ {
149
+ "createOrReplaceTempView", "createTempView",
150
+ "createOrReplaceGlobalTempView", "createGlobalTempView",
151
+ "registerTempTable",
152
+ }
153
+ )
154
+ _NA_METHODS = frozenset({"drop", "fill", "replace"})
155
+ _TRANSPARENT_FNS = frozenset({"broadcast"})
156
+ # Formatos de path do DataFrameReader (``spark.read.<fmt>(path)``).
157
+ _READER_PATH_FORMATS = frozenset(
158
+ {"parquet", "csv", "json", "orc", "text", "avro"}
159
+ )
160
+
161
+ # Gate fraco do arquivo: ops camelCase distintivas da API DataFrame
162
+ # (``.filter``/``.select`` minúsculos ficam de fora — pandas colide).
163
+ _CAMEL_DF_OPS = frozenset(
164
+ {
165
+ "groupBy", "agg", "rollup", "cube", "withColumn", "withColumns",
166
+ "withColumnRenamed", "withColumnsRenamed", "dropDuplicates",
167
+ "drop_duplicates", "dropDuplicatesWithinWatermark",
168
+ "unionByName", "unionAll", "selectExpr", "repartition",
169
+ "repartitionByRange", "coalesce", "withWatermark", "joinWith",
170
+ "writeTo", "saveAsTable", "insertInto",
171
+ "createOrReplaceTempView", "createGlobalTempView",
172
+ "createTempView", "registerTempTable", "exceptAll",
173
+ "intersectAll", "localCheckpoint", "foreachBatch",
174
+ }
175
+ )
176
+
177
+
178
+ # -- valores avaliados --------------------------------------------------------
179
+
180
+
181
+ class _E:
182
+ """União de resultados da avaliação estática de expressão."""
183
+
184
+
185
+ class _Session(_E):
186
+ """SparkSession (ou objeto que produz uma)."""
187
+
188
+
189
+ class _SClass(_E):
190
+ """Referência à classe ``SparkSession`` (ou alias importado)."""
191
+
192
+
193
+ class _SBuilder(_E):
194
+ """``SparkSession.builder[.appName…]`` — vira sessão no getOrCreate."""
195
+
196
+
197
+ @dataclass
198
+ class _Reader(_E):
199
+ """``spark.read[.format(f)][.option…]`` antes do terminal."""
200
+
201
+ fmt: str = ""
202
+
203
+
204
+ @dataclass
205
+ class _Str(_E):
206
+ """Constante ``str`` — propagação honesta de literal por variável."""
207
+
208
+ value: str
209
+
210
+
211
+ @dataclass
212
+ class _Read:
213
+ """Alvo de leitura observado (identidade emitida na materialização)."""
214
+
215
+ ident_key: str # logical_key da entidade (tabela|path|view)
216
+ fmt: str # parquet|csv|json|table|jdbc|iceberg|…
217
+ span: SourceSpan | None = None
218
+
219
+
220
+ @dataclass
221
+ class _PKey:
222
+ """Pai materializado: variável já ligada a uma transformação."""
223
+
224
+ ident_key: str
225
+
226
+
227
+ @dataclass
228
+ class _PAnon:
229
+ """Pai anônimo: dfv intermediário dentro da mesma expressão."""
230
+
231
+ dfv: _Dfv
232
+
233
+
234
+ @dataclass
235
+ class _PUnres:
236
+ """Pai não-resolvível — ``to_unresolved`` honesto."""
237
+
238
+ text: str
239
+ span: SourceSpan | None = None
240
+
241
+
242
+ _Parent = _PKey | _PAnon | _PUnres
243
+
244
+
245
+ @dataclass
246
+ class _SqlLit:
247
+ """Literal ``spark.sql(…)`` — delegado ao adapter sql na materialização."""
248
+
249
+ literal: str
250
+ span: SourceSpan | None
251
+ line: int
252
+
253
+
254
+ @dataclass
255
+ class _Dfv(_E):
256
+ """Resumo estático de um valor-DataFrame (ainda não materializado)."""
257
+
258
+ reads: list[_Read] = field(default_factory=list)
259
+ parents: list[_Parent] = field(default_factory=list)
260
+ sql: list[_SqlLit] = field(default_factory=list)
261
+
262
+ def is_trivial(self) -> bool:
263
+ """Alias puro ``x = df`` — rebind sem nova transformação."""
264
+ return (
265
+ not self.reads
266
+ and not self.sql
267
+ and len(self.parents) == 1
268
+ and isinstance(self.parents[0], _PKey)
269
+ )
270
+
271
+ def is_interesting(self) -> bool:
272
+ """Merece materialização própria quando achado solto."""
273
+ if self.reads or self.sql:
274
+ return True
275
+ return any(not isinstance(p, _PKey) for p in self.parents)
276
+
277
+
278
+ @dataclass
279
+ class _Writer(_E):
280
+ """``<df>.write[.mode][.format…]`` — aguarda terminal."""
281
+
282
+ dfv: _Dfv | None
283
+ unknown_recv: str = ""
284
+ mode: str = ""
285
+ fmt: str = ""
286
+ span: SourceSpan | None = None
287
+
288
+
289
+ @dataclass
290
+ class _WriterV2(_E):
291
+ """``<df>.writeTo(t)[.using…]`` — aguarda terminal V2."""
292
+
293
+ dfv: _Dfv | None
294
+ unknown_recv: str = ""
295
+ target_lit: str | None = None
296
+ target_span: SourceSpan | None = None
297
+ via: str = ""
298
+ span: SourceSpan | None = None
299
+
300
+
301
+ @dataclass
302
+ class _NaOps(_E):
303
+ """``df.na`` — ``.drop()/.fill()/.replace()`` devolvem DataFrame."""
304
+
305
+ dfv: _Dfv
306
+
307
+
308
+ class _NotDf(_E):
309
+ """Expressão conhecida não-DataFrame (ação, collection, writer…)."""
310
+
311
+
312
+ @dataclass
313
+ class _Unknown(_E):
314
+ """Expressão não-classificada — ops DataFrame nela viram parcial."""
315
+
316
+ text: str
317
+ span: SourceSpan | None = None
318
+
319
+
320
+ @dataclass
321
+ class _SeqE(_E):
322
+ """Tupla/lista literal avaliada — unpack de assignment."""
323
+
324
+ items: list[_E] = field(default_factory=list)
325
+
326
+
327
+ # -- bindings do ambiente -----------------------------------------------------
328
+
329
+
330
+ class _Bind:
331
+ """União de bindings nomeados em um escopo."""
332
+
333
+
334
+ class _BSession(_Bind):
335
+ pass
336
+
337
+
338
+ @dataclass
339
+ class _BDf(_Bind):
340
+ ident_key: str
341
+
342
+
343
+ @dataclass
344
+ class _BWriter(_Bind):
345
+ writer: _E # _Writer | _WriterV2
346
+
347
+
348
+ class _BNotDf(_Bind):
349
+ pass
350
+
351
+
352
+ class _BUnknown(_Bind):
353
+ pass
354
+
355
+
356
+ @dataclass
357
+ class _BStr(_Bind):
358
+ """Binding de constante ``str`` — propagada como literal."""
359
+
360
+ value: str
361
+
362
+
363
+ @dataclass
364
+ class _BParamDf(_Bind):
365
+ """Parâmetro anotado ``DataFrame`` — fonte opaca, honesta."""
366
+
367
+ name: str
368
+
369
+
370
+ class _Env:
371
+ """Pilha de escopos ``nome → _Bind`` (o mais interno escreve)."""
372
+
373
+ def __init__(self) -> None:
374
+ self.scopes: list[dict[str, _Bind]] = [{}]
375
+
376
+ def push(self) -> None:
377
+ self.scopes.append({})
378
+
379
+ def pop(self) -> dict[str, _Bind]:
380
+ return self.scopes.pop()
381
+
382
+ def lookup(self, name: str) -> _Bind | None:
383
+ for scope in reversed(self.scopes):
384
+ if name in scope:
385
+ return scope[name]
386
+ return None
387
+
388
+ def bind(self, name: str, b: _Bind) -> None:
389
+ self.scopes[-1][name] = b
390
+
391
+ def unbind(self, name: str) -> None:
392
+ self.scopes[-1][name] = _BUnknown()
393
+
394
+
395
+ # -- helpers de AST -----------------------------------------------------------
396
+
397
+
398
+ def _attr_chain(node: ast.expr) -> tuple[str | None, list[str]]:
399
+ """Desenrola ``a.b.c.d`` → (``a`` como Name|None, [b,c,d])."""
400
+ attrs: list[str] = []
401
+ cur = node
402
+ while isinstance(cur, ast.Attribute):
403
+ attrs.append(cur.attr)
404
+ cur = cur.value
405
+ attrs.reverse()
406
+ return (cur.id if isinstance(cur, ast.Name) else None), attrs
407
+
408
+
409
+ def _literal_str(node: ast.expr, *, depth: int = 0) -> str | None:
410
+ """Literal de string estático; ``None`` quando dinâmico.
411
+
412
+ Cobre ``"x"``, concatenação ``"a" + "b"`` e f-string **sem**
413
+ placeholders. ``JoinedStr`` com ``FormattedValue`` → ``None``
414
+ (dinâmico — nunca fabricado).
415
+ """
416
+ if depth > 8:
417
+ return None
418
+ if isinstance(node, ast.Constant):
419
+ return node.value if isinstance(node.value, str) else None
420
+ if isinstance(node, ast.JoinedStr):
421
+ parts: list[str] = []
422
+ for v in node.values:
423
+ if isinstance(v, ast.Constant) and isinstance(v.value, str):
424
+ parts.append(v.value)
425
+ else:
426
+ return None
427
+ return "".join(parts)
428
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
429
+ left = _literal_str(node.left, depth=depth + 1)
430
+ right = _literal_str(node.right, depth=depth + 1)
431
+ if left is not None and right is not None:
432
+ return left + right
433
+ return None
434
+
435
+
436
+ def _ann_name(node: ast.expr | None) -> str:
437
+ """Nome terminal de uma anotação (``pyspark.sql.DataFrame`` → DataFrame)."""
438
+ if node is None:
439
+ return ""
440
+ if isinstance(node, ast.Name):
441
+ return node.id
442
+ if isinstance(node, ast.Attribute):
443
+ return node.attr
444
+ if isinstance(node, ast.Constant):
445
+ return str(node.value)
446
+ if isinstance(node, ast.Subscript):
447
+ return _ann_name(node.value)
448
+ return ""
449
+
450
+
451
+ @dataclass
452
+ class _Flat:
453
+ """Resultado do flatten de um ``_Dfv`` (pais anônimos expandidos)."""
454
+
455
+ reads: list[_Read] = field(default_factory=list)
456
+ pkeys: list[str] = field(default_factory=list)
457
+ unres: list[tuple[str, SourceSpan | None]] = field(default_factory=list)
458
+ sql: list[_SqlLit] = field(default_factory=list)
459
+
460
+
461
+ # -- walker --------------------------------------------------------------------
462
+
463
+
464
+ class _Walker:
465
+ """Percorre o ``ast`` de um ``.py`` emitindo fatos no ``Sink``."""
466
+
467
+ def __init__(
468
+ self, sink: Sink, path: str, sha: str, text: str
469
+ ) -> None:
470
+ self.sink = sink
471
+ self.path = path
472
+ self.sha = sha
473
+ self.text = text
474
+ self.temp_views: dict[str, str] = {} # view name → ident_key
475
+ self.transform_idents: list[DomainIdentity] = []
476
+ self.app_name = ""
477
+ self.sparky = False
478
+ self.implicit_sessions: set[str] = set()
479
+ self.spark_classes: set[str] = set()
480
+ self._offs = self._line_offs(text)
481
+
482
+ # -- spans ----------------------------------------------------------------
483
+
484
+ @staticmethod
485
+ def _line_offs(text: str) -> list[int]:
486
+ offs = [0]
487
+ total = 0
488
+ for raw in text.splitlines(keepends=True):
489
+ total += len(raw.encode("utf-8"))
490
+ offs.append(total)
491
+ return offs
492
+
493
+ def _span(self, node: ast.AST) -> SourceSpan | None:
494
+ lineno = getattr(node, "lineno", 0)
495
+ col = getattr(node, "col_offset", 0)
496
+ if not lineno or lineno > len(self._offs):
497
+ return None
498
+ end_line = getattr(node, "end_lineno", None) or lineno
499
+ end_col = getattr(node, "end_col_offset", None) or col
500
+ end_line = min(end_line, len(self._offs))
501
+ return SourceSpan(
502
+ start_byte=self._offs[lineno - 1] + col,
503
+ end_byte=self._offs[end_line - 1] + end_col,
504
+ start_line=lineno, start_col=col,
505
+ end_line=end_line, end_col=end_col,
506
+ )
507
+
508
+ def _txt(self, node: ast.AST) -> str:
509
+ try:
510
+ txt = ast.unparse(node)
511
+ except Exception:
512
+ return "<expr>"
513
+ txt = " ".join(txt.split())
514
+ return txt[:120]
515
+
516
+ def _literal_arg(self, node: ast.expr, env: _Env) -> str | None:
517
+ """Literal estático do argumento — direto ou via binding ``str``."""
518
+ lit = _literal_str(node)
519
+ if lit is not None:
520
+ return lit
521
+ if isinstance(node, ast.Name):
522
+ b = env.lookup(node.id)
523
+ if isinstance(b, _BStr):
524
+ return b.value
525
+ return None
526
+
527
+ # -- ponto de entrada -------------------------------------------------------
528
+
529
+ def run(self) -> None:
530
+ try:
531
+ tree = ast.parse(self.text)
532
+ except (SyntaxError, ValueError, RecursionError) as exc:
533
+ self.sink.diag(
534
+ "parse-error", "error",
535
+ f"python ast.parse failed: {exc}", self.path,
536
+ )
537
+ self.sink.bump("parse_errors")
538
+ return
539
+ self._prescan(tree)
540
+ if not self.sparky:
541
+ self.sink.bump("non_spark_files")
542
+ return
543
+ env = _Env()
544
+ for name in sorted(self.implicit_sessions):
545
+ env.bind(name, _BSession())
546
+ self.sink.bump("spark_files")
547
+ self._stmts(tree.body, env)
548
+ self._emit_job()
549
+
550
+ def _emit_job(self) -> None:
551
+ """``spark_job`` do artefato — âncora do pipeline + CONTAINS.
552
+
553
+ A entidade-job é o alvo semântico para vínculos de provisiona-
554
+ mento cross-domain (``aws_glue_job.script_location`` ↔ arquivo,
555
+ lf-0117) e o container honesto das transformações do arquivo.
556
+
557
+ ``import pyspark`` sozinho NÃO basta — sem transformação/view o
558
+ arquivo declara dependência, não um job.
559
+ """
560
+ if not self.transform_idents and not self.temp_views:
561
+ return
562
+ stem = self.path[:-3] if self.path.endswith(".py") else self.path
563
+ req = self.sink.request
564
+ ident = DomainIdentity(
565
+ domain=DOMAIN_ID, repository=req.repository, root=req.root,
566
+ environment=req.environment, qualified_name=stem,
567
+ )
568
+ evi = self.sink.evi(self.path, self.sha, None, notes="spark-job")
569
+ attrs = {"entry_point": self.path, "language": "python"}
570
+ if self.app_name:
571
+ attrs["app_name"] = self.app_name
572
+ self.sink.add_entity(
573
+ ident, DomainEntityKind.SPARK_JOB, stem,
574
+ path=self.path, content_hash=self.sha,
575
+ attributes=attrs, roles=("job",), evidence=(evi,),
576
+ )
577
+ for t in self.transform_idents:
578
+ self.sink.add_rel(
579
+ str(EdgeKind.CONTAINS), ident,
580
+ path=self.path, to_i=t, evidence=(evi,),
581
+ )
582
+ self.sink.bump("spark_jobs")
583
+
584
+ # -- gate de arquivo --------------------------------------------------------
585
+
586
+ def _prescan(self, tree: ast.AST) -> None:
587
+ """Decide se o arquivo usa Spark — sem custo de emitir nada.
588
+
589
+ Marcadores: imports ``pyspark*``, nome ``SparkSession``, padrões
590
+ ``<nome>.read.<fmt>`` (sessão implícita de notebook) e ops
591
+ camelCase distintivas da API DataFrame (``.writeTo``,
592
+ ``.write.parquet``, ``groupBy`` …).
593
+ """
594
+ for node in ast.walk(tree):
595
+ if isinstance(node, ast.ImportFrom):
596
+ self._prescan_importfrom(node)
597
+ elif isinstance(node, ast.Import):
598
+ for a in node.names:
599
+ if a.name == "pyspark" or a.name.startswith("pyspark."):
600
+ self.sparky = True
601
+ elif isinstance(node, ast.Name) and node.id == "SparkSession":
602
+ self.sparky = True
603
+ elif isinstance(node, ast.Call) and isinstance(
604
+ node.func, ast.Attribute
605
+ ):
606
+ self._prescan_call(node)
607
+
608
+ def _prescan_importfrom(self, node: ast.ImportFrom) -> None:
609
+ mod = node.module or ""
610
+ if not (mod == "pyspark" or mod.startswith("pyspark.")):
611
+ return
612
+ self.sparky = True
613
+ for a in node.names:
614
+ if a.name == "SparkSession":
615
+ self.spark_classes.add(a.asname or a.name)
616
+
617
+ def _prescan_call(self, node: ast.Call) -> None:
618
+ """Marcadores de ``<recv>.<cadeia>(…)`` no gate do arquivo."""
619
+ base, attrs = _attr_chain(node.func)
620
+ if not attrs:
621
+ return
622
+ if base in _SESSION_NAMES and attrs[0] in _SESSION_METHODS:
623
+ self.sparky = True
624
+ if base is not None:
625
+ self.implicit_sessions.add(base)
626
+ elif attrs[0] in _SESSION_READ_ATTRS and len(attrs) >= 2:
627
+ # ``x.read.table(…)`` — leitor só existe em sessão
628
+ self.sparky = True
629
+ if base is not None:
630
+ self.implicit_sessions.add(base)
631
+ if (
632
+ attrs[0] in ("write", "writeStream")
633
+ and attrs[-1]
634
+ in (_WRITER_PATH_TERMINALS | _WRITER_TABLE_TERMINALS)
635
+ ):
636
+ self.sparky = True
637
+ if attrs[-1] in _CAMEL_DF_OPS:
638
+ self.sparky = True
639
+
640
+ # -- statements -------------------------------------------------------------
641
+
642
+ def _stmts(self, body: list[ast.stmt], env: _Env) -> None:
643
+ for st in body:
644
+ self._stmt(st, env)
645
+
646
+ def _stmt(self, st: ast.stmt, env: _Env) -> None:
647
+ if isinstance(st, ast.Assign):
648
+ value = self._eval(st.value, env)
649
+ bound = self._bind_targets(st.targets, value, env)
650
+ if not bound:
651
+ self._after_eval(value, st)
652
+ elif isinstance(st, ast.AnnAssign):
653
+ if st.value is not None:
654
+ value = self._eval(st.value, env)
655
+ bound = self._bind_targets([st.target], value, env)
656
+ if not bound:
657
+ self._after_eval(value, st)
658
+ elif isinstance(st, ast.AugAssign):
659
+ self._eval(st.value, env)
660
+ if isinstance(st.target, ast.Name):
661
+ env.unbind(st.target.id)
662
+ elif isinstance(st, ast.Expr):
663
+ self._after_eval(self._eval(st.value, env), st)
664
+ else:
665
+ handler = _STMT_DISPATCH.get(type(st))
666
+ if handler is not None:
667
+ handler(self, st, env)
668
+ # Import/ImportFrom/Global/Nonlocal/Pass/Break/Continue — nada
669
+
670
+ # -- handlers de statement (``_STMT_DISPATCH``) ---------------------------
671
+
672
+ def _st_class(self, st: ast.ClassDef, env: _Env) -> None:
673
+ for d in st.decorator_list:
674
+ self._after_eval(self._eval(d, env), d)
675
+ for b in st.bases:
676
+ self._after_eval(self._eval(b, env), b)
677
+ env.push()
678
+ self._stmts(st.body, env)
679
+ env.pop()
680
+
681
+ def _st_if(self, st: ast.If, env: _Env) -> None:
682
+ self._after_eval(self._eval(st.test, env), st.test)
683
+ self._branches([st.body, st.orelse], env, st.lineno)
684
+
685
+ def _st_for(self, st: ast.For | ast.AsyncFor, env: _Env) -> None:
686
+ self._after_eval(self._eval(st.iter, env), st.iter)
687
+ self._bind_loop_target(st.target, env)
688
+ # aproximação sequencial declarada: corpo desenrolado 1x
689
+ self._stmts(st.body, env)
690
+ self._stmts(st.orelse, env)
691
+
692
+ def _st_while(self, st: ast.While, env: _Env) -> None:
693
+ self._after_eval(self._eval(st.test, env), st.test)
694
+ self._stmts(st.body, env)
695
+ self._stmts(st.orelse, env)
696
+
697
+ def _st_with(self, st: ast.With | ast.AsyncWith, env: _Env) -> None:
698
+ for item in st.items:
699
+ v = self._eval(item.context_expr, env)
700
+ if item.optional_vars is not None:
701
+ self._bind_targets([item.optional_vars], v, env)
702
+ self._stmts(st.body, env)
703
+
704
+ def _st_try(self, st: ast.Try | ast.TryStar, env: _Env) -> None:
705
+ self._stmts(st.body, env)
706
+ for h in st.handlers:
707
+ self._stmts(h.body, env)
708
+ self._stmts(st.orelse, env)
709
+ self._stmts(st.finalbody, env)
710
+
711
+ def _st_return(self, st: ast.Return, env: _Env) -> None:
712
+ if st.value is not None:
713
+ self._after_eval(self._eval(st.value, env), st.value)
714
+
715
+ def _st_raise(self, st: ast.Raise, env: _Env) -> None:
716
+ if st.exc is not None:
717
+ self._after_eval(self._eval(st.exc, env), st.exc)
718
+
719
+ def _st_delete(self, st: ast.Delete, env: _Env) -> None:
720
+ for t in st.targets:
721
+ if isinstance(t, ast.Name):
722
+ env.unbind(t.id)
723
+
724
+ def _st_match(self, st: ast.Match, env: _Env) -> None:
725
+ self._after_eval(self._eval(st.subject, env), st.subject)
726
+ # match pode ser não-exaustivo — trilha vazia conta
727
+ self._branches([c.body for c in st.cases] + [[]], env, st.lineno)
728
+
729
+ def _st_assert(self, st: ast.Assert, env: _Env) -> None:
730
+ self._after_eval(self._eval(st.test, env), st.test)
731
+
732
+ def _branches(
733
+ self, bodies: list[list[ast.stmt]], env: _Env, lineno: int
734
+ ) -> None:
735
+ """Processa branches em escopos-filho e funde bindings.
736
+
737
+ Cada branch pode vincular o mesmo nome a transformações
738
+ diferentes — o merge cria uma transformação
739
+ ``<nome>@merge@<linha>`` que DERIVES_FROM todas as alternativas,
740
+ nunca descartando uma leitura. Branch que não vincula o nome
741
+ mantém o binding do escopo pai como candidato.
742
+ """
743
+ childs: list[dict[str, _Bind]] = []
744
+ for body in bodies:
745
+ env.push()
746
+ self._stmts(body, env)
747
+ childs.append(env.pop())
748
+ parent = env.scopes[-1]
749
+ names: list[str] = []
750
+ for ch in childs:
751
+ for n in ch:
752
+ if n not in names:
753
+ names.append(n)
754
+ for name in names:
755
+ candidates: list[_Bind | None] = [
756
+ ch[name] for ch in childs if name in ch
757
+ ]
758
+ covered = sum(1 for ch in childs if name in ch)
759
+ if covered < len(childs) and name in parent:
760
+ candidates.append(parent[name])
761
+ merged = self._merge_binding(name, candidates, lineno)
762
+ if merged is not None:
763
+ parent[name] = merged
764
+ elif name in parent:
765
+ del parent[name]
766
+
767
+ def _merge_binding(
768
+ self, name: str, candidates: list[_Bind | None], lineno: int
769
+ ) -> _Bind | None:
770
+ """Fusão de bindings de branches — honesta, nunca resolved-na-sorte."""
771
+ bound = [b for b in candidates if b is not None]
772
+ if not bound:
773
+ return None
774
+ dfkeys: list[str] = []
775
+ for b in bound:
776
+ if isinstance(b, _BDf) and b.ident_key not in dfkeys:
777
+ dfkeys.append(b.ident_key)
778
+ if len(dfkeys) == 1:
779
+ return _BDf(dfkeys[0])
780
+ if len(dfkeys) > 1:
781
+ dfv = _Dfv(parents=[_PKey(k) for k in dfkeys])
782
+ ident = self._materialize(
783
+ dfv, f"{name}@merge@{lineno}", None
784
+ )
785
+ return _BDf(ident.logical_key())
786
+ for cls in (_BSession, _BWriter, _BParamDf):
787
+ for b in bound:
788
+ if isinstance(b, cls):
789
+ return b
790
+ if any(isinstance(b, _BUnknown) for b in bound):
791
+ return _BUnknown()
792
+ return bound[-1]
793
+
794
+ # -- assignments -------------------------------------------------------------
795
+
796
+ def _bind_targets(
797
+ self, targets: list[ast.expr], value: _E, env: _Env
798
+ ) -> bool:
799
+ """Liga RHS avaliado aos targets. True quando algo vinculou."""
800
+ bound_any = False
801
+ for t in targets:
802
+ if isinstance(t, ast.Name):
803
+ self._bind_name(t.id, value, env, t)
804
+ bound_any = True
805
+ elif isinstance(t, ast.Starred) and isinstance(
806
+ t.value, ast.Name
807
+ ):
808
+ env.bind(t.value.id, _BUnknown())
809
+ bound_any = True
810
+ elif isinstance(t, (ast.Tuple, ast.List)):
811
+ if isinstance(value, _SeqE) and len(t.elts) == len(
812
+ value.items
813
+ ):
814
+ for el, item in zip(
815
+ t.elts, value.items, strict=True
816
+ ):
817
+ if isinstance(el, ast.Name):
818
+ self._bind_name(el.id, item, env, el)
819
+ bound_any = True
820
+ else:
821
+ for el in t.elts:
822
+ if isinstance(el, ast.Name):
823
+ env.bind(el.id, _BUnknown())
824
+ bound_any = True
825
+ # Attribute/Subscript targets — fora do env de nomes; o
826
+ # caller materializa o valor via ``_after_eval``.
827
+ return bound_any
828
+
829
+ def _bind_loop_target(self, target: ast.expr, env: _Env) -> None:
830
+ for t in ast.walk(target):
831
+ if isinstance(t, ast.Name):
832
+ env.bind(t.id, _BUnknown())
833
+
834
+ def _bind_name(
835
+ self, name: str, value: _E, env: _Env, node: ast.expr
836
+ ) -> None:
837
+ if isinstance(value, _Dfv):
838
+ if value.is_trivial():
839
+ key_parent = value.parents[0]
840
+ assert isinstance(key_parent, _PKey)
841
+ env.bind(name, _BDf(key_parent.ident_key))
842
+ return
843
+ ident = self._materialize(
844
+ value, f"{name}@{node.lineno}", self._span(node)
845
+ )
846
+ env.bind(name, _BDf(ident.logical_key()))
847
+ elif isinstance(value, _Session):
848
+ env.bind(name, _BSession())
849
+ elif isinstance(value, (_Writer, _WriterV2)):
850
+ env.bind(name, _BWriter(value))
851
+ elif isinstance(value, _Str):
852
+ env.bind(name, _BStr(value.value))
853
+ elif isinstance(value, _NotDf):
854
+ env.bind(name, _BNotDf())
855
+ else:
856
+ env.bind(name, _BUnknown())
857
+
858
+ # -- functions -------------------------------------------------------------
859
+
860
+ def _function(
861
+ self, st: ast.FunctionDef | ast.AsyncFunctionDef, env: _Env
862
+ ) -> None:
863
+ for d in st.decorator_list:
864
+ self._after_eval(self._eval(d, env), d)
865
+ args = st.args
866
+ for d in args.defaults:
867
+ self._after_eval(self._eval(d, env), d)
868
+ for kd in args.kw_defaults:
869
+ if kd is not None:
870
+ self._after_eval(self._eval(kd, env), kd)
871
+ env.push()
872
+ params: list[ast.arg] = [
873
+ *args.posonlyargs, *args.args, *args.kwonlyargs,
874
+ ]
875
+ if args.vararg is not None:
876
+ params.append(args.vararg)
877
+ if args.kwarg is not None:
878
+ params.append(args.kwarg)
879
+ for p in params:
880
+ ann = _ann_name(p.annotation)
881
+ if p.arg in _SESSION_NAMES or ann == "SparkSession":
882
+ env.bind(p.arg, _BSession())
883
+ elif ann == "DataFrame":
884
+ env.bind(p.arg, _BParamDf(p.arg))
885
+ self._stmts(st.body, env)
886
+ env.pop()
887
+
888
+ # -- materialização / emissão --------------------------------------------
889
+
890
+ def _flatten(self, dfv: _Dfv, out: _Flat) -> None:
891
+ out.reads.extend(dfv.reads)
892
+ out.sql.extend(dfv.sql)
893
+ for p in dfv.parents:
894
+ if isinstance(p, _PKey):
895
+ if p.ident_key not in out.pkeys:
896
+ out.pkeys.append(p.ident_key)
897
+ elif isinstance(p, _PAnon):
898
+ self._flatten(p.dfv, out)
899
+ else:
900
+ out.unres.append((p.text, p.span))
901
+
902
+ def _ident_by_key(self, key: str) -> DomainIdentity:
903
+ for acc in self.sink.entities.values():
904
+ if acc.entity.identity.logical_key() == key:
905
+ return acc.entity.identity
906
+ raise KeyError(key)
907
+
908
+ def _expand_view_sources(self, keys: set[str]) -> set[str]:
909
+ """Expande temp views registradas para as fontes terminais."""
910
+ out: set[str] = set()
911
+ for k in keys:
912
+ vs = self.sink.view_sources.get(k)
913
+ out.update(vs if vs else {k})
914
+ return out
915
+
916
+ def _materialize(
917
+ self, dfv: _Dfv, anchor: str, span: SourceSpan | None
918
+ ) -> DomainIdentity:
919
+ """Cria o ``data_transformation`` + arestas; devolve a identidade."""
920
+ if dfv.is_trivial():
921
+ pk = dfv.parents[0]
922
+ assert isinstance(pk, _PKey)
923
+ return self._ident_by_key(pk.ident_key)
924
+ flat = _Flat()
925
+ self._flatten(dfv, flat)
926
+ ident = self.sink.transform_ident(self.path, anchor)
927
+ key = ident.logical_key()
928
+ evi = self.sink.evi(
929
+ self.path, self.sha, span, notes=f"df:{anchor}"
930
+ )
931
+ operation = (
932
+ "sql" if flat.sql
933
+ else "read" if flat.reads and not flat.pkeys
934
+ else "transform"
935
+ )
936
+ variable = "" if anchor.startswith("expr@") else anchor.split(
937
+ "@", 1
938
+ )[0]
939
+ attrs = {
940
+ "artifact": self.path,
941
+ "variable": variable,
942
+ "operation": operation,
943
+ "line": str(anchor.rsplit("@", 1)[-1]),
944
+ }
945
+ self.sink.add_entity(
946
+ ident, DomainEntityKind.DATA_TRANSFORMATION, anchor,
947
+ path=self.path, span=span, content_hash=self.sha,
948
+ attributes=attrs, evidence=(evi,),
949
+ )
950
+ src_keys: set[str] = set()
951
+ for r in flat.reads:
952
+ src_keys.add(r.ident_key)
953
+ self.sink.add_rel(
954
+ DomainRelationKind.READS_FROM.value, ident,
955
+ path=self.path, to_i=self._ident_by_key(r.ident_key),
956
+ span=r.span, evidence=(evi,),
957
+ )
958
+ for pkey in flat.pkeys:
959
+ if pkey == key:
960
+ continue # auto-aresta não é linhagem
961
+ self.sink.add_rel(
962
+ DomainRelationKind.DERIVES_FROM.value, ident,
963
+ path=self.path,
964
+ to_i=self._ident_by_key(pkey),
965
+ evidence=(evi,),
966
+ )
967
+ src_keys.update(self.sink.sources.get(pkey, set()))
968
+ partial = False
969
+ for text, pspan in flat.unres:
970
+ partial = True
971
+ self.sink.add_rel(
972
+ DomainRelationKind.DERIVES_FROM.value, ident,
973
+ path=self.path, to_text=text,
974
+ span=pspan or span, evidence=(evi,),
975
+ )
976
+ for lit in flat.sql:
977
+ sql_src, sql_unres = delegate_sql_literal(
978
+ self.sink, lit, self.path, self.sha,
979
+ call_evi=evi, call_span=span, t_ident=ident,
980
+ )
981
+ src_keys.update(sql_src)
982
+ partial = partial or sql_unres
983
+ if partial:
984
+ self.sink.set_entity_attr(key, "lineage", "partial")
985
+ self.sink.sources[key] = self._expand_view_sources(src_keys)
986
+ self.transform_idents.append(ident)
987
+ self.sink.bump("transformations")
988
+ return ident
989
+
990
+ # -- avaliação de expressões ----------------------------------------------
991
+
992
+ def _eval(self, node: ast.expr, env: _Env) -> _E:
993
+ handler = _EVAL_DISPATCH.get(type(node))
994
+ if handler is not None:
995
+ return handler(self, node, env)
996
+ # ``"a" + "b"`` e afins foldam em literal — propagação honesta
997
+ lit = _literal_str(node)
998
+ if lit is not None:
999
+ return _Str(lit)
1000
+ # BinOp/Compare/BoolOp/UnaryOp/Lambda/Slice/FormattedValue e
1001
+ # demais — avalia filhos para efeitos colaterais.
1002
+ for child in ast.iter_child_nodes(node):
1003
+ if isinstance(child, ast.expr):
1004
+ self._after_eval(self._eval(child, env), child)
1005
+ return _Unknown(self._txt(node), self._span(node))
1006
+
1007
+ # -- handlers de expressão (``_EVAL_DISPATCH``) ----------------------------
1008
+
1009
+ def _e_name(self, node: ast.Name, env: _Env) -> _E:
1010
+ b = env.lookup(node.id)
1011
+ if isinstance(b, _BSession):
1012
+ return _Session()
1013
+ if isinstance(b, _BDf):
1014
+ return _Dfv(parents=[_PKey(b.ident_key)])
1015
+ if isinstance(b, _BWriter):
1016
+ return b.writer
1017
+ if isinstance(b, _BParamDf):
1018
+ return _Dfv(
1019
+ parents=[_PUnres(f"param:{node.id}", self._span(node))]
1020
+ )
1021
+ if isinstance(b, _BStr):
1022
+ return _Str(b.value)
1023
+ if isinstance(b, _BNotDf):
1024
+ return _NotDf()
1025
+ if node.id in self.spark_classes:
1026
+ return _SClass()
1027
+ return _Unknown(node.id, self._span(node))
1028
+
1029
+ def _e_walrus(self, node: ast.NamedExpr, env: _Env) -> _E:
1030
+ v = self._eval(node.value, env)
1031
+ if isinstance(node.target, ast.Name):
1032
+ self._bind_name(node.target.id, v, env, node.target)
1033
+ return v
1034
+
1035
+ def _e_ifexp(self, node: ast.IfExp, env: _Env) -> _E:
1036
+ self._eval(node.test, env)
1037
+ a = self._eval(node.body, env)
1038
+ b = self._eval(node.orelse, env)
1039
+ return self._merge_ifexp(a, b, node)
1040
+
1041
+ def _e_seq(
1042
+ self, node: ast.Tuple | ast.List | ast.Set, env: _Env
1043
+ ) -> _E:
1044
+ return _SeqE([self._eval(el, env) for el in node.elts])
1045
+
1046
+ def _e_dict(self, node: ast.Dict, env: _Env) -> _E:
1047
+ for v in node.values:
1048
+ self._after_eval(self._eval(v, env), v)
1049
+ for k in node.keys:
1050
+ if k is not None:
1051
+ self._after_eval(self._eval(k, env), k)
1052
+ return _NotDf()
1053
+
1054
+ def _e_const(self, node: ast.Constant, env: _Env) -> _E:
1055
+ del env
1056
+ if isinstance(node.value, str):
1057
+ return _Str(node.value)
1058
+ return _NotDf()
1059
+
1060
+ def _e_fstr(self, node: ast.JoinedStr, env: _Env) -> _E:
1061
+ for v in node.values:
1062
+ if isinstance(v, ast.FormattedValue):
1063
+ self._after_eval(self._eval(v.value, env), v.value)
1064
+ # f-string sem placeholders é literal estático
1065
+ lit = _literal_str(node)
1066
+ return _Str(lit) if lit is not None else _NotDf()
1067
+
1068
+ def _e_sub(self, node: ast.Subscript, env: _Env) -> _E:
1069
+ self._after_eval(self._eval(node.value, env), node.value)
1070
+ self._after_eval(self._eval(node.slice, env), node.slice)
1071
+ return _Unknown(self._txt(node), self._span(node))
1072
+
1073
+ def _e_unwrap(
1074
+ self, node: ast.Await | ast.Starred | ast.YieldFrom, env: _Env
1075
+ ) -> _E:
1076
+ # o valor interno é quem conta
1077
+ return self._eval(node.value, env)
1078
+
1079
+ def _e_yield(self, node: ast.Yield, env: _Env) -> _E:
1080
+ if node.value is not None:
1081
+ return self._eval(node.value, env)
1082
+ return _NotDf()
1083
+
1084
+ def _e_comp(
1085
+ self,
1086
+ node: ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp,
1087
+ env: _Env,
1088
+ ) -> _E:
1089
+ self._comprehension(node, env)
1090
+ return _NotDf()
1091
+
1092
+ def _merge_ifexp(self, a: _E, b: _E, node: ast.IfExp) -> _E:
1093
+ da, db = isinstance(a, _Dfv), isinstance(b, _Dfv)
1094
+ if da and db:
1095
+ assert isinstance(a, _Dfv) and isinstance(b, _Dfv)
1096
+ return _Dfv(parents=[_PAnon(a), _PAnon(b)])
1097
+ if da:
1098
+ assert isinstance(a, _Dfv)
1099
+ return _Dfv(
1100
+ parents=[
1101
+ _PAnon(a), _PUnres(self._txt(node.orelse), None)
1102
+ ]
1103
+ )
1104
+ if db:
1105
+ assert isinstance(b, _Dfv)
1106
+ return _Dfv(
1107
+ parents=[
1108
+ _PAnon(b), _PUnres(self._txt(node.body), None)
1109
+ ]
1110
+ )
1111
+ return _Unknown(self._txt(node), self._span(node))
1112
+
1113
+ def _comprehension(self, node: ast.expr, env: _Env) -> None:
1114
+ for g in node.generators: # type: ignore[attr-defined]
1115
+ self._after_eval(self._eval(g.iter, env), g.iter)
1116
+ for cond in g.ifs:
1117
+ self._after_eval(self._eval(cond, env), cond)
1118
+ if isinstance(node, ast.DictComp):
1119
+ self._after_eval(self._eval(node.key, env), node.key)
1120
+ self._after_eval(self._eval(node.value, env), node.value)
1121
+ else:
1122
+ elt = getattr(node, "elt", None)
1123
+ if elt is not None:
1124
+ self._after_eval(self._eval(elt, env), elt)
1125
+
1126
+ def _attribute(self, node: ast.Attribute, env: _Env) -> _E:
1127
+ # self.spark / cls.spark — sessão por convenção de atributo
1128
+ if (
1129
+ node.attr in _SESSION_ATTRS
1130
+ and isinstance(node.value, ast.Name)
1131
+ and node.value.id in ("self", "cls")
1132
+ ):
1133
+ return _Session()
1134
+ base = self._eval(node.value, env)
1135
+ if isinstance(base, _Session):
1136
+ if node.attr in _SESSION_READ_ATTRS:
1137
+ return _Reader()
1138
+ return _Unknown(self._txt(node), self._span(node))
1139
+ if isinstance(base, _SClass):
1140
+ return (
1141
+ _SBuilder()
1142
+ if node.attr == "builder"
1143
+ else _Unknown(self._txt(node), self._span(node))
1144
+ )
1145
+ if isinstance(base, _SBuilder):
1146
+ return base
1147
+ if isinstance(base, _Dfv):
1148
+ return self._dfv_attr(base, node)
1149
+ if isinstance(base, _Unknown):
1150
+ return self._unknown_attr(base, node)
1151
+ return _Unknown(self._txt(node), self._span(node))
1152
+
1153
+ def _dfv_attr(self, base: _Dfv, node: ast.Attribute) -> _E:
1154
+ if node.attr in ("write", "writeStream"):
1155
+ return _Writer(dfv=base, span=self._span(node))
1156
+ if node.attr == "na":
1157
+ return _NaOps(base)
1158
+ if node.attr in _DF_NON_DF_ATTRS:
1159
+ return _NotDf()
1160
+ return _Unknown(self._txt(node), self._span(node))
1161
+
1162
+ def _unknown_attr(self, base: _Unknown, node: ast.Attribute) -> _E:
1163
+ if node.attr in ("write", "writeStream"):
1164
+ return _Writer(
1165
+ dfv=None, unknown_recv=base.text,
1166
+ span=self._span(node),
1167
+ )
1168
+ return _Unknown(
1169
+ f"{base.text}.{node.attr}", self._span(node)
1170
+ )
1171
+
1172
+ # -- calls ---------------------------------------------------------------
1173
+
1174
+ def _call(self, node: ast.Call, env: _Env) -> _E:
1175
+ fn = node.func
1176
+ if isinstance(fn, ast.Attribute):
1177
+ recv = self._eval(fn.value, env)
1178
+ return self._method(recv, fn.attr, node, env)
1179
+ if isinstance(fn, ast.Name):
1180
+ if fn.id in self.spark_classes:
1181
+ return _SClass()
1182
+ # função transparente: broadcast(df) → df
1183
+ if fn.id in _TRANSPARENT_FNS and node.args:
1184
+ return self._eval(node.args[0], env)
1185
+ self._sweep_args(node, env)
1186
+ return _Unknown(self._txt(fn), self._span(fn))
1187
+ # callee exótico — ainda assim varre os argumentos
1188
+ self._sweep_args(node, env)
1189
+ return _Unknown(self._txt(node), self._span(node))
1190
+
1191
+ def _sweep_args(self, node: ast.Call, env: _Env) -> None:
1192
+ """Avalia args de call desconhecido — efeitos colaterais contam."""
1193
+ for a in node.args:
1194
+ self._after_eval(self._eval(a, env), a)
1195
+ for kw in node.keywords:
1196
+ self._after_eval(self._eval(kw.value, env), kw.value)
1197
+
1198
+ def _method(
1199
+ self, recv: _E, method: str, node: ast.Call, env: _Env
1200
+ ) -> _E:
1201
+ handler = _METHOD_RECV.get(type(recv))
1202
+ if handler is not None:
1203
+ return handler(self, recv, method, node, env)
1204
+ # _NotDf/_Str/_SeqE — chamada sobre não-DF: nada a propagar
1205
+ self._sweep_args(node, env)
1206
+ return _Unknown(self._txt(node.func), self._span(node))
1207
+
1208
+ def _m_sclass(
1209
+ self, recv: _SClass, method: str, node: ast.Call, env: _Env
1210
+ ) -> _E:
1211
+ del recv
1212
+ if method == "builder":
1213
+ return _SBuilder()
1214
+ if method in ("getActiveSession", "active", "newSession"):
1215
+ return _Session()
1216
+ self._sweep_args(node, env)
1217
+ return _Unknown(self._txt(node.func), self._span(node))
1218
+
1219
+ def _m_sbuilder(
1220
+ self, recv: _SBuilder, method: str, node: ast.Call, env: _Env
1221
+ ) -> _E:
1222
+ if method == "getOrCreate":
1223
+ return _Session()
1224
+ if method == "appName" and node.args:
1225
+ lit = self._literal_arg(node.args[0], env)
1226
+ if lit:
1227
+ self.app_name = lit.strip()
1228
+ self._sweep_args(node, env)
1229
+ return recv
1230
+
1231
+ def _m_naops(
1232
+ self, recv: _NaOps, method: str, node: ast.Call, env: _Env
1233
+ ) -> _E:
1234
+ self._sweep_args(node, env)
1235
+ if method in _NA_METHODS:
1236
+ return recv.dfv
1237
+ return _Unknown(self._txt(node), self._span(node))
1238
+
1239
+ # -- métodos de sessão ------------------------------------------------------
1240
+
1241
+ def _session_method(
1242
+ self, recv: _Session, method: str, node: ast.Call, env: _Env
1243
+ ) -> _E:
1244
+ del recv
1245
+ if method in _SESSION_READ_ATTRS:
1246
+ self._sweep_args(node, env)
1247
+ return _Reader()
1248
+ if method == "sql":
1249
+ return self._spark_sql(node, env)
1250
+ if method == "table":
1251
+ return self._read_table(node, env, via="spark.table")
1252
+ if method in ("createDataFrame", "range"):
1253
+ self._sweep_args(node, env)
1254
+ return _Dfv() # dados literais/locais — sem upstream
1255
+ if method in _SESSION_NON_DF:
1256
+ self._sweep_args(node, env)
1257
+ return _NotDf()
1258
+ self._sweep_args(node, env)
1259
+ return _Unknown(self._txt(node.func), self._span(node))
1260
+
1261
+ def _spark_sql(self, node: ast.Call, env: _Env) -> _E:
1262
+ if not node.args:
1263
+ self.sink.diag(
1264
+ "empty-sql", "info",
1265
+ "spark.sql() without positional query", self.path,
1266
+ )
1267
+ self._sweep_args(node, env)
1268
+ return _Dfv()
1269
+ lit = self._literal_arg(node.args[0], env)
1270
+ span = self._span(node)
1271
+ if lit is None:
1272
+ self.sink.diag(
1273
+ "dynamic-sql", "warning",
1274
+ "spark.sql() argument is not a static string literal "
1275
+ "— no table deps fabricated", self.path,
1276
+ )
1277
+ self.sink.bump("sql_dynamic")
1278
+ self._sweep_args(node, env)
1279
+ return _Dfv(
1280
+ parents=[
1281
+ _PUnres(f"sql:{self._txt(node.args[0])}", span)
1282
+ ]
1283
+ )
1284
+ self.sink.bump("sql_literals")
1285
+ self._sweep_args(node, env)
1286
+ return _Dfv(
1287
+ sql=[_SqlLit(literal=lit, span=span, line=node.lineno)]
1288
+ )
1289
+
1290
+ def _read_table(self, node: ast.Call, env: _Env, *, via: str) -> _E:
1291
+ """``spark.table(t)`` / ``spark.read.table(t)`` — catálogo."""
1292
+ self._sweep_args(node, env)
1293
+ if not node.args:
1294
+ self.sink.diag(
1295
+ "missing-source", "info",
1296
+ f"{via}() without table argument", self.path,
1297
+ )
1298
+ return _Dfv()
1299
+ lit = self._literal_arg(node.args[0], env)
1300
+ span = self._span(node.args[0]) or self._span(node)
1301
+ if lit is None:
1302
+ self.sink.diag(
1303
+ "dynamic-source", "warning",
1304
+ f"{via}() table is not a static literal — "
1305
+ "unresolved read recorded", self.path,
1306
+ )
1307
+ return _Dfv(
1308
+ parents=[_PUnres(self._txt(node.args[0]), span)]
1309
+ )
1310
+ parts = split_table_name(lit)
1311
+ if parts is None:
1312
+ self.sink.diag(
1313
+ "dynamic-source", "warning",
1314
+ f"{via}() table name is dynamic: {lit!r}", self.path,
1315
+ )
1316
+ return _Dfv(
1317
+ parents=[_PUnres(f"table:{lit}", span)]
1318
+ )
1319
+ # temp view registrada neste arquivo sombreia o catálogo —
1320
+ # mantém a aresta de leitura na view (que deriva das fontes)
1321
+ tv_key = self.temp_views.get(".".join(parts))
1322
+ if tv_key is not None:
1323
+ return _Dfv(
1324
+ reads=[_Read(ident_key=tv_key, fmt="tempview", span=span)]
1325
+ )
1326
+ ident = self.sink.table_ident(parts)
1327
+ self._emit_table(
1328
+ ident, parts[-1], via=via, roles=("read",),
1329
+ written=lit, span=span,
1330
+ )
1331
+ return _Dfv(
1332
+ reads=[_Read(ident_key=ident.logical_key(), fmt="table",
1333
+ span=span)]
1334
+ )
1335
+
1336
+ # -- métodos de DataFrameReader ------------------------------------------------
1337
+
1338
+ def _reader_method(
1339
+ self, recv: _Reader, method: str, node: ast.Call, env: _Env
1340
+ ) -> _E:
1341
+ if method == "format":
1342
+ fmt = self._literal_arg(node.args[0], env) if node.args else None
1343
+ self._sweep_args(node, env)
1344
+ return _Reader(fmt=fmt or recv.fmt)
1345
+ if method in ("schema", "option", "options"):
1346
+ self._sweep_args(node, env)
1347
+ return recv
1348
+ if method == "table":
1349
+ return self._read_table(node, env, via="spark.read.table")
1350
+ if method in _READER_PATH_FORMATS or method == "load":
1351
+ return self._read_path(node, env, recv, method)
1352
+ if method == "jdbc":
1353
+ return self._read_jdbc(node, env)
1354
+ self._sweep_args(node, env)
1355
+ return _Unknown(self._txt(node.func), self._span(node))
1356
+
1357
+ def _read_path(
1358
+ self, node: ast.Call, env: _Env, recv: _Reader, method: str
1359
+ ) -> _E:
1360
+ """``spark.read.parquet|csv|json|…(path)`` e ``.format(f).load(path)``."""
1361
+ self._sweep_args(node, env)
1362
+ fmt = recv.fmt if method == "load" else method
1363
+ if not node.args:
1364
+ self.sink.diag(
1365
+ "missing-source", "info",
1366
+ f"spark.read.{method}() without path — source "
1367
+ "recorded as unresolved", self.path,
1368
+ )
1369
+ return _Dfv(
1370
+ parents=[_PUnres(f"read:{method}()", self._span(node))]
1371
+ )
1372
+ lit = self._literal_arg(node.args[0], env)
1373
+ span = self._span(node.args[0]) or self._span(node)
1374
+ if lit is None:
1375
+ self.sink.diag(
1376
+ "dynamic-source", "warning",
1377
+ f"spark.read.{method}() path is not a static "
1378
+ "literal — unresolved read recorded", self.path,
1379
+ )
1380
+ return _Dfv(
1381
+ parents=[_PUnres(self._txt(node.args[0]), span)]
1382
+ )
1383
+ ident = self.sink.path_ident(lit)
1384
+ self.sink.add_entity(
1385
+ ident, DomainEntityKind.DATA_TABLE, lit.strip(),
1386
+ path=self.path, span=span, content_hash=self.sha,
1387
+ attributes={
1388
+ "storage": "path", "format": fmt,
1389
+ "uri_scheme": uri_scheme(lit),
1390
+ "written_name": lit.strip(), "via": f"spark.read.{method}",
1391
+ },
1392
+ roles=("read",),
1393
+ evidence=(self.sink.evi(
1394
+ self.path, self.sha, span, notes=f"read:{method}"
1395
+ ),),
1396
+ )
1397
+ return _Dfv(
1398
+ reads=[_Read(ident_key=ident.logical_key(), fmt=fmt, span=span)]
1399
+ )
1400
+
1401
+ def _read_jdbc(self, node: ast.Call, env: _Env) -> _E:
1402
+ """``spark.read.jdbc(url, table)`` — o catálogo é o 2º argumento."""
1403
+ self._sweep_args(node, env)
1404
+ if len(node.args) < 2:
1405
+ self.sink.diag(
1406
+ "missing-source", "info",
1407
+ "spark.read.jdbc() without table argument", self.path,
1408
+ )
1409
+ return _Dfv()
1410
+ lit = self._literal_arg(node.args[1], env)
1411
+ span = self._span(node.args[1]) or self._span(node)
1412
+ if lit is None:
1413
+ self.sink.diag(
1414
+ "dynamic-source", "warning",
1415
+ "spark.read.jdbc() table is not a static literal",
1416
+ self.path,
1417
+ )
1418
+ return _Dfv(
1419
+ parents=[_PUnres(self._txt(node.args[1]), span)]
1420
+ )
1421
+ parts = split_table_name(lit)
1422
+ if parts is None:
1423
+ return _Dfv(parents=[_PUnres(f"table:{lit}", span)])
1424
+ ident = self.sink.table_ident(parts)
1425
+ self._emit_table(
1426
+ ident, parts[-1], via="spark.read.jdbc", roles=("read",),
1427
+ written=lit, span=span,
1428
+ )
1429
+ return _Dfv(
1430
+ reads=[_Read(ident_key=ident.logical_key(), fmt="jdbc",
1431
+ span=span)]
1432
+ )
1433
+
1434
+ # -- métodos de DataFrame ---------------------------------------------------
1435
+
1436
+ def _df_method(
1437
+ self, recv: _Dfv, method: str, node: ast.Call, env: _Env
1438
+ ) -> _E:
1439
+ if method == "writeTo":
1440
+ return self._write_to(recv, node, env)
1441
+ if method in _TEMP_VIEW_METHODS:
1442
+ return self._temp_view(recv, method, node, env)
1443
+ if method in _DF_TRANSFORMS:
1444
+ parents: list[_Parent] = [_PAnon(recv)]
1445
+ for i, a in enumerate(node.args):
1446
+ e = self._eval(a, env)
1447
+ if i == 0 and method in _DF_ARG_OPS:
1448
+ if isinstance(e, _Dfv):
1449
+ parents.append(_PAnon(e))
1450
+ elif isinstance(e, _Unknown):
1451
+ parents.append(_PUnres(e.text, e.span))
1452
+ else:
1453
+ self._after_eval(e, a)
1454
+ else:
1455
+ self._after_eval(e, a)
1456
+ for kw in node.keywords:
1457
+ self._after_eval(self._eval(kw.value, env), kw.value)
1458
+ return _Dfv(parents=parents)
1459
+ if method in _DF_NON_DF_CALLS:
1460
+ self._sweep_args(node, env)
1461
+ return _NotDf()
1462
+ # método desconhecido sobre DF conhecido — honesto: não sabemos
1463
+ self._sweep_args(node, env)
1464
+ return _Unknown(self._txt(node.func), self._span(node))
1465
+
1466
+ def _temp_view(
1467
+ self, recv: _Dfv, method: str, node: ast.Call, env: _Env
1468
+ ) -> _E:
1469
+ self._sweep_args(node, env)
1470
+ if not node.args:
1471
+ return _NotDf()
1472
+ lit = self._literal_arg(node.args[0], env)
1473
+ if lit is None:
1474
+ self.sink.diag(
1475
+ "dynamic-source", "warning",
1476
+ f"df.{method}() view name is not a static literal",
1477
+ self.path,
1478
+ )
1479
+ return _NotDf()
1480
+ name = lit.strip()
1481
+ if not name:
1482
+ return _NotDf()
1483
+ global_view = "Global" in method
1484
+ parts = ("global_temp", name) if global_view else (name,)
1485
+ flat = _Flat()
1486
+ self._flatten(recv, flat)
1487
+ ident = self.sink.table_ident(parts)
1488
+ evi = self.sink.evi(
1489
+ self.path, self.sha, self._span(node), notes=f"tempview:{name}"
1490
+ )
1491
+ self.sink.add_entity(
1492
+ ident, DomainEntityKind.DATA_VIEW, name,
1493
+ path=self.path, span=self._span(node),
1494
+ content_hash=self.sha,
1495
+ attributes={
1496
+ "temporary": "true", "via": f"df.{method}",
1497
+ "written_name": name,
1498
+ },
1499
+ roles=("declare",), evidence=(evi,),
1500
+ )
1501
+ self.temp_views[".".join(parts)] = ident.logical_key()
1502
+ vsrc: set[str] = set()
1503
+ for r in flat.reads:
1504
+ vsrc.add(r.ident_key)
1505
+ self.sink.add_rel(
1506
+ DomainRelationKind.DERIVES_FROM.value, ident,
1507
+ path=self.path, to_i=self._ident_by_key(r.ident_key),
1508
+ evidence=(evi,),
1509
+ )
1510
+ for pk in flat.pkeys:
1511
+ if pk == ident.logical_key():
1512
+ continue
1513
+ self.sink.add_rel(
1514
+ DomainRelationKind.DERIVES_FROM.value, ident,
1515
+ path=self.path, to_i=self._ident_by_key(pk),
1516
+ evidence=(evi,),
1517
+ )
1518
+ vsrc.update(self.sink.sources.get(pk, set()))
1519
+ self.sink.view_sources[ident.logical_key()] = vsrc
1520
+ self.sink.bump("temp_views")
1521
+ return _NotDf()
1522
+
1523
+ # -- writes -----------------------------------------------------------------
1524
+
1525
+ def _writer_method(
1526
+ self, recv: _Writer, method: str, node: ast.Call, env: _Env
1527
+ ) -> _E:
1528
+ if method == "mode":
1529
+ lit = (
1530
+ self._literal_arg(node.args[0], env)
1531
+ if node.args else None
1532
+ )
1533
+ self._sweep_args(node, env)
1534
+ return _Writer(
1535
+ dfv=recv.dfv, unknown_recv=recv.unknown_recv,
1536
+ mode=lit or recv.mode, fmt=recv.fmt, span=recv.span,
1537
+ )
1538
+ if method == "format":
1539
+ lit = (
1540
+ self._literal_arg(node.args[0], env)
1541
+ if node.args else None
1542
+ )
1543
+ self._sweep_args(node, env)
1544
+ return _Writer(
1545
+ dfv=recv.dfv, unknown_recv=recv.unknown_recv,
1546
+ mode=recv.mode, fmt=lit or recv.fmt, span=recv.span,
1547
+ )
1548
+ if method in _WRITER_OPTS:
1549
+ self._sweep_args(node, env)
1550
+ return recv
1551
+ if method in _WRITER_PATH_TERMINALS:
1552
+ self._emit_path_write(recv, node, env, method)
1553
+ return _NotDf()
1554
+ if method in _WRITER_TABLE_TERMINALS:
1555
+ self._emit_table_write(recv, node, env, method)
1556
+ return _NotDf()
1557
+ self._sweep_args(node, env)
1558
+ return _Unknown(self._txt(node.func), self._span(node))
1559
+
1560
+ def _v2_method(
1561
+ self, recv: _WriterV2, method: str, node: ast.Call, env: _Env
1562
+ ) -> _E:
1563
+ if method in _V2_OPTS:
1564
+ via = recv.via
1565
+ if method == "using" and node.args:
1566
+ lit = self._literal_arg(node.args[0], env)
1567
+ if lit:
1568
+ via = lit
1569
+ self._sweep_args(node, env)
1570
+ return _WriterV2(
1571
+ dfv=recv.dfv, unknown_recv=recv.unknown_recv,
1572
+ target_lit=recv.target_lit,
1573
+ target_span=recv.target_span, via=via, span=recv.span,
1574
+ )
1575
+ if method in _V2_TERMINALS:
1576
+ self._emit_v2_write(recv, node, env, method)
1577
+ return _NotDf()
1578
+ self._sweep_args(node, env)
1579
+ return _Unknown(self._txt(node.func), self._span(node))
1580
+
1581
+ def _write_to(self, recv: _Dfv, node: ast.Call, env: _Env) -> _E:
1582
+ """``df.writeTo("cat.t")`` — alvo é o 1º argumento."""
1583
+ target_lit: str | None = None
1584
+ target_span: SourceSpan | None = None
1585
+ if node.args:
1586
+ target_lit = self._literal_arg(node.args[0], env)
1587
+ target_span = self._span(node.args[0])
1588
+ for a in node.args[1:]:
1589
+ self._after_eval(self._eval(a, env), a)
1590
+ for kw in node.keywords:
1591
+ self._after_eval(self._eval(kw.value, env), kw.value)
1592
+ return _WriterV2(
1593
+ dfv=recv, target_lit=target_lit, target_span=target_span,
1594
+ span=self._span(node),
1595
+ )
1596
+
1597
+ def _emit_write_base(
1598
+ self, dfv: _Dfv | None, unknown_recv: str, node: ast.Call
1599
+ ) -> tuple[DomainIdentity, set[str]]:
1600
+ """Materializa o receiver do write → (identidade T, fontes)."""
1601
+ span = self._span(node)
1602
+ if dfv is not None:
1603
+ ident = self._materialize(dfv, f"expr@{node.lineno}", span)
1604
+ return ident, self.sink.sources.get(ident.logical_key(), set())
1605
+ self.sink.diag(
1606
+ "unresolved-dataframe", "info",
1607
+ f"write on unresolved receiver {unknown_recv!r} — "
1608
+ "partial lineage", self.path,
1609
+ )
1610
+ ident = self.sink.transform_ident(
1611
+ self.path, f"expr@{node.lineno}"
1612
+ )
1613
+ evi = self.sink.evi(
1614
+ self.path, self.sha, span, notes="df:write"
1615
+ )
1616
+ self.sink.add_entity(
1617
+ ident, DomainEntityKind.DATA_TRANSFORMATION,
1618
+ f"expr@{node.lineno}", path=self.path, span=span,
1619
+ content_hash=self.sha,
1620
+ attributes={
1621
+ "artifact": self.path, "operation": "write",
1622
+ "line": str(node.lineno), "lineage": "partial",
1623
+ },
1624
+ evidence=(evi,),
1625
+ )
1626
+ self.sink.add_rel(
1627
+ DomainRelationKind.DERIVES_FROM.value, ident,
1628
+ path=self.path, to_text=unknown_recv,
1629
+ span=span, evidence=(evi,),
1630
+ )
1631
+ self.transform_idents.append(ident)
1632
+ return ident, set()
1633
+
1634
+ def _emit_path_write(
1635
+ self, recv: _Writer, node: ast.Call, env: _Env, method: str
1636
+ ) -> None:
1637
+ for a in node.args[1:]:
1638
+ self._after_eval(self._eval(a, env), a)
1639
+ for kw in node.keywords:
1640
+ self._after_eval(self._eval(kw.value, env), kw.value)
1641
+ tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
1642
+ span = self._span(node)
1643
+ evi = self.sink.evi(self.path, self.sha, span, notes="df:write")
1644
+ if not node.args:
1645
+ self.sink.diag(
1646
+ "missing-target", "info",
1647
+ f"df.write.{method}() without target", self.path,
1648
+ )
1649
+ return
1650
+ lit = self._literal_arg(node.args[0], env)
1651
+ if lit is None:
1652
+ self.sink.diag(
1653
+ "dynamic-write-target", "warning",
1654
+ f"df.write.{method}() target is not a static literal",
1655
+ self.path,
1656
+ )
1657
+ self.sink.add_rel(
1658
+ DomainRelationKind.WRITES_TO.value, tid,
1659
+ path=self.path, to_text=self._txt(node.args[0]),
1660
+ span=span, evidence=(evi,),
1661
+ )
1662
+ return
1663
+ fmt = method if method != "save" else (recv.fmt or "save")
1664
+ ident = self.sink.path_ident(lit)
1665
+ arg_span = self._span(node.args[0]) or span
1666
+ self.sink.add_entity(
1667
+ ident, DomainEntityKind.DATA_TABLE, lit.strip(),
1668
+ path=self.path, span=arg_span, content_hash=self.sha,
1669
+ attributes={
1670
+ "storage": "path", "format": fmt,
1671
+ "uri_scheme": uri_scheme(lit),
1672
+ "written_name": lit.strip(), "via": f"df.write.{method}",
1673
+ },
1674
+ roles=("write",),
1675
+ evidence=(self.sink.evi(
1676
+ self.path, self.sha, arg_span, notes="df:write.target"
1677
+ ),),
1678
+ )
1679
+ self._write_edges(tid, ident, srcs, span, evi)
1680
+
1681
+ def _emit_table_write(
1682
+ self, recv: _Writer, node: ast.Call, env: _Env, method: str
1683
+ ) -> None:
1684
+ # jdbc(url, table) usa o 2º argumento; demais usam o 1º
1685
+ arg_idx = 1 if method == "jdbc" else 0
1686
+ for i, a in enumerate(node.args):
1687
+ if i != arg_idx:
1688
+ self._after_eval(self._eval(a, env), a)
1689
+ for kw in node.keywords:
1690
+ self._after_eval(self._eval(kw.value, env), kw.value)
1691
+ tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
1692
+ span = self._span(node)
1693
+ evi = self.sink.evi(self.path, self.sha, span, notes="df:write")
1694
+ if len(node.args) <= arg_idx:
1695
+ self.sink.diag(
1696
+ "missing-target", "info",
1697
+ f"df.write.{method}() without target", self.path,
1698
+ )
1699
+ return
1700
+ lit = self._literal_arg(node.args[arg_idx], env)
1701
+ if lit is None:
1702
+ self.sink.diag(
1703
+ "dynamic-write-target", "warning",
1704
+ f"df.write.{method}() target is not a static literal",
1705
+ self.path,
1706
+ )
1707
+ self.sink.add_rel(
1708
+ DomainRelationKind.WRITES_TO.value, tid,
1709
+ path=self.path,
1710
+ to_text=self._txt(node.args[arg_idx]),
1711
+ span=span, evidence=(evi,),
1712
+ )
1713
+ return
1714
+ parts = split_table_name(lit)
1715
+ if parts is None:
1716
+ self.sink.add_rel(
1717
+ DomainRelationKind.WRITES_TO.value, tid,
1718
+ path=self.path, to_text=f"table:{lit}",
1719
+ span=span, evidence=(evi,),
1720
+ )
1721
+ return
1722
+ ident = self.sink.table_ident(parts)
1723
+ arg_span = self._span(node.args[arg_idx]) or span
1724
+ self._emit_table(
1725
+ ident, parts[-1], via=f"df.write.{method}", roles=("write",),
1726
+ mode=recv.mode, written=lit, span=arg_span,
1727
+ )
1728
+ self._write_edges(tid, ident, srcs, span, evi)
1729
+
1730
+ def _emit_v2_write(
1731
+ self, recv: _WriterV2, node: ast.Call, env: _Env, method: str
1732
+ ) -> None:
1733
+ self._sweep_args(node, env)
1734
+ tid, srcs = self._emit_write_base(recv.dfv, recv.unknown_recv, node)
1735
+ span = self._span(node)
1736
+ evi = self.sink.evi(self.path, self.sha, span, notes="df:writeTo")
1737
+ lit = recv.target_lit
1738
+ if lit is None:
1739
+ self.sink.diag(
1740
+ "dynamic-write-target", "warning",
1741
+ f"df.writeTo().{method}() target is not a static "
1742
+ "literal", self.path,
1743
+ )
1744
+ self.sink.add_rel(
1745
+ DomainRelationKind.WRITES_TO.value, tid,
1746
+ path=self.path, to_text="writeTo(<dynamic>)",
1747
+ span=span, evidence=(evi,),
1748
+ )
1749
+ return
1750
+ parts = split_table_name(lit)
1751
+ if parts is None:
1752
+ self.sink.add_rel(
1753
+ DomainRelationKind.WRITES_TO.value, tid,
1754
+ path=self.path, to_text=f"table:{lit}",
1755
+ span=span, evidence=(evi,),
1756
+ )
1757
+ return
1758
+ ident = self.sink.table_ident(parts)
1759
+ tgt_span = recv.target_span or span
1760
+ self.sink.add_entity(
1761
+ ident, DomainEntityKind.ICEBERG_TABLE, parts[-1],
1762
+ path=self.path, span=tgt_span,
1763
+ content_hash=self.sha,
1764
+ attributes={
1765
+ "api": "df.writeTo", "written_name": lit.strip(),
1766
+ "using": recv.via, "op": method,
1767
+ },
1768
+ roles=("write",),
1769
+ evidence=(self.sink.evi(
1770
+ self.path, self.sha, tgt_span, notes="df:writeTo.target"
1771
+ ),),
1772
+ )
1773
+ self._write_edges(tid, ident, srcs, span, evi)
1774
+
1775
+ def _write_edges(
1776
+ self,
1777
+ tid: DomainIdentity,
1778
+ sink_ident: DomainIdentity,
1779
+ srcs: set[str],
1780
+ span: SourceSpan | None,
1781
+ evi: int,
1782
+ ) -> None:
1783
+ self.sink.add_rel(
1784
+ DomainRelationKind.WRITES_TO.value, tid,
1785
+ path=self.path, to_i=sink_ident,
1786
+ span=span, evidence=(evi,),
1787
+ )
1788
+ skey = sink_ident.logical_key()
1789
+ for sk in sorted(srcs):
1790
+ if sk == skey:
1791
+ continue # auto-derivação não é aresta
1792
+ self.sink.add_rel(
1793
+ DomainRelationKind.DERIVES_FROM.value, sink_ident,
1794
+ path=self.path, to_i=self._ident_by_key(sk),
1795
+ span=span, evidence=(evi,),
1796
+ )
1797
+ self.sink.bump("writes")
1798
+
1799
+ # -- desconhecido ------------------------------------------------------------
1800
+
1801
+ def _unknown_method(
1802
+ self, recv: _Unknown, method: str, node: ast.Call, env: _Env
1803
+ ) -> _E:
1804
+ if method == "writeTo":
1805
+ target_lit = (
1806
+ self._literal_arg(node.args[0], env)
1807
+ if node.args else None
1808
+ )
1809
+ for a in node.args[1:]:
1810
+ self._after_eval(self._eval(a, env), a)
1811
+ for kw in node.keywords:
1812
+ self._after_eval(self._eval(kw.value, env), kw.value)
1813
+ return _WriterV2(
1814
+ dfv=None, unknown_recv=recv.text,
1815
+ target_lit=target_lit,
1816
+ target_span=(
1817
+ self._span(node.args[0]) if node.args else None
1818
+ ),
1819
+ span=self._span(node),
1820
+ )
1821
+ if method in _DF_TRANSFORMS:
1822
+ self.sink.diag(
1823
+ "unresolved-dataframe", "info",
1824
+ f"DataFrame op {method!r} on unresolved receiver "
1825
+ f"{recv.text!r} — partial lineage", self.path,
1826
+ )
1827
+ parents: list[_Parent] = [_PUnres(recv.text, recv.span)]
1828
+ for i, a in enumerate(node.args):
1829
+ e = self._eval(a, env)
1830
+ if i == 0 and method in _DF_ARG_OPS:
1831
+ if isinstance(e, _Dfv):
1832
+ parents.append(_PAnon(e))
1833
+ elif isinstance(e, _Unknown):
1834
+ parents.append(_PUnres(e.text, e.span))
1835
+ else:
1836
+ self._after_eval(e, a)
1837
+ else:
1838
+ self._after_eval(e, a)
1839
+ for kw in node.keywords:
1840
+ self._after_eval(self._eval(kw.value, env), kw.value)
1841
+ return _Dfv(parents=parents)
1842
+ self._sweep_args(node, env)
1843
+ return _Unknown(
1844
+ f"{recv.text}.{method}", self._span(node)
1845
+ )
1846
+
1847
+ # -- pós-avaliação --------------------------------------------------------------
1848
+
1849
+ def _after_eval(self, value: _E, node: ast.AST | None) -> None:
1850
+ """Materializa valores-DF "interessantes" achados soltos."""
1851
+ if isinstance(value, _Dfv):
1852
+ if value.is_interesting():
1853
+ line = getattr(node, "lineno", 0) if node is not None else 0
1854
+ self._materialize(
1855
+ value, f"expr@{line}",
1856
+ self._span(node) if node is not None else None,
1857
+ )
1858
+ elif isinstance(value, (_Writer, _WriterV2)):
1859
+ self.sink.diag(
1860
+ "write-never-executed", "info",
1861
+ "DataFrameWriter built but no terminal op observed "
1862
+ "— no write emitted", self.path,
1863
+ )
1864
+
1865
+ # -- emissão compartilhada -------------------------------------------------
1866
+
1867
+ def _emit_table(
1868
+ self, ident: DomainIdentity, name: str, *, via: str,
1869
+ roles: tuple[str, ...], written: str,
1870
+ span: SourceSpan | None,
1871
+ mode: str = "",
1872
+ ) -> None:
1873
+ attrs = {
1874
+ "storage": "catalog", "via": via, "written_name": written,
1875
+ }
1876
+ if mode:
1877
+ attrs["write_mode"] = mode
1878
+ self.sink.add_entity(
1879
+ ident, DomainEntityKind.DATA_TABLE, name,
1880
+ path=self.path, span=span, content_hash=self.sha,
1881
+ attributes=attrs, roles=roles,
1882
+ evidence=(self.sink.evi(self.path, self.sha, span,
1883
+ notes=f"table:{name}"),),
1884
+ )
1885
+
1886
+
1887
+ # -- tabelas de dispatch -----------------------------------------------------
1888
+ # ``Any`` no parâmetro do nó (mesmo desenho de ``sql.extract._STMT_HANDLERS``):
1889
+ # a assinatura uniforme permite narrowings diferentes por handler.
1890
+
1891
+ _STMT_DISPATCH: dict[type[ast.stmt], Callable[[_Walker, Any, _Env], None]] = {
1892
+ ast.FunctionDef: _Walker._function,
1893
+ ast.AsyncFunctionDef: _Walker._function,
1894
+ ast.ClassDef: _Walker._st_class,
1895
+ ast.If: _Walker._st_if,
1896
+ ast.For: _Walker._st_for,
1897
+ ast.AsyncFor: _Walker._st_for,
1898
+ ast.While: _Walker._st_while,
1899
+ ast.With: _Walker._st_with,
1900
+ ast.AsyncWith: _Walker._st_with,
1901
+ ast.Try: _Walker._st_try,
1902
+ ast.TryStar: _Walker._st_try,
1903
+ ast.Return: _Walker._st_return,
1904
+ ast.Raise: _Walker._st_raise,
1905
+ ast.Delete: _Walker._st_delete,
1906
+ ast.Match: _Walker._st_match,
1907
+ ast.Assert: _Walker._st_assert,
1908
+ }
1909
+
1910
+ _EVAL_DISPATCH: dict[type[ast.expr], Callable[[_Walker, Any, _Env], _E]] = {
1911
+ ast.Call: _Walker._call,
1912
+ ast.Name: _Walker._e_name,
1913
+ ast.Attribute: _Walker._attribute,
1914
+ ast.NamedExpr: _Walker._e_walrus,
1915
+ ast.IfExp: _Walker._e_ifexp,
1916
+ ast.Tuple: _Walker._e_seq,
1917
+ ast.List: _Walker._e_seq,
1918
+ ast.Set: _Walker._e_seq,
1919
+ ast.Dict: _Walker._e_dict,
1920
+ ast.Constant: _Walker._e_const,
1921
+ ast.JoinedStr: _Walker._e_fstr,
1922
+ ast.Subscript: _Walker._e_sub,
1923
+ ast.Await: _Walker._e_unwrap,
1924
+ ast.Starred: _Walker._e_unwrap,
1925
+ ast.YieldFrom: _Walker._e_unwrap,
1926
+ ast.Yield: _Walker._e_yield,
1927
+ ast.ListComp: _Walker._e_comp,
1928
+ ast.SetComp: _Walker._e_comp,
1929
+ ast.DictComp: _Walker._e_comp,
1930
+ ast.GeneratorExp: _Walker._e_comp,
1931
+ }
1932
+
1933
+ _METHOD_RECV: dict[type[_E], Callable[[_Walker, Any, str, ast.Call, _Env], _E]] = {
1934
+ _Session: _Walker._session_method,
1935
+ _SClass: _Walker._m_sclass,
1936
+ _SBuilder: _Walker._m_sbuilder,
1937
+ _Reader: _Walker._reader_method,
1938
+ _Dfv: _Walker._df_method,
1939
+ _NaOps: _Walker._m_naops,
1940
+ _Writer: _Walker._writer_method,
1941
+ _WriterV2: _Walker._v2_method,
1942
+ _Unknown: _Walker._unknown_method,
1943
+ }