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,2220 @@
1
+ """Extração estática de scripts ``.py`` AWS-data (lf-0113, Phase 5).
2
+
3
+ Análise por ``ast`` (stdlib) — nenhuma execução do script, nenhum SDK:
4
+
5
+ - **Glue**: ``GlueContext``/``awsglue`` marcam o script;
6
+ ``create_dynamic_frame.from_catalog``/``getDataFrame`` →
7
+ ``READS_FROM`` de ``data_table`` de catálogo; ``from_options``/
8
+ ``getSource`` → ``READS_FROM`` de path ``s3://`` (ou referência
9
+ externa a conexão nomeada); ``write_dynamic_frame.*``/
10
+ ``write_from_options``/``getSink`` → ``WRITES_TO``;
11
+ ``job.init``/``job.commit``/``getResolvedOptions`` viram atributos
12
+ (bookmarks/args observados — nomes de opções, nunca valores
13
+ resolvidos em runtime);
14
+ - **Spark/Iceberg**: ``spark.sql(<literal>)``/``session.sql``/``execute``
15
+ com literal SQL → linhagem via ``sqlbridge`` (transformações
16
+ ``path@L<linha>#<stmt>``); ``df.writeTo("c.n.t")`` seguido de
17
+ ``.using("iceberg")`` ou catálogo ``spark.sql.catalog.<c>`` com impl
18
+ iceberg → ``iceberg_table``; ``insertInto``/``saveAsTable``/
19
+ ``spark.table``/``spark.read.<fmt>(s3|nome)`` → arestas diretas;
20
+ ``MERGE``/``INSERT``/``UPDATE``/``DELETE`` dentro de literais vêm do
21
+ parser sql;
22
+ - **Athena SDK**: ``start_query_execution(QueryString=<literal>)`` →
23
+ mesmo pipeline SQL com ``USE`` sintético só quando
24
+ ``QueryExecutionContext`` declara Database/Catalog; ``OutputLocation``
25
+ → ``WRITES_TO`` s3;
26
+ - **IAM**: ``create_job``/literal ``arn:aws:iam::…:role/x`` →
27
+ ``iam_role`` entity + ``ASSUMES_ROLE`` (external — o role vive na
28
+ conta, fora do snapshot); ARN solto sem contexto de papel →
29
+ ``REFERENCES`` external;
30
+ - **S3**: literal ``s3://`` fora de call reconhecida → ``REFERENCES``
31
+ (direção desconhecida — nunca inventar leitura/escrita);
32
+ - identidade conta/região: ``catalog_id`` literal vira
33
+ ``aws:glue:<acct>``; segmentos ausentes ficam vazios — identidades
34
+ sem conta NÃO colidem com as que a têm.
35
+
36
+ Scripts sem marcadores AWS-data → diagnóstico ``not-aws-data-artifact``
37
+ e zero fatos. ``SyntaxError`` → ``py-parse-error`` + varredura
38
+ degradada por ``tokenize`` apenas de literais ``s3://``/ARN (modo
39
+ degradado declarado, nunca estrutura inventada).
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import ast
45
+ import io
46
+ import posixpath
47
+ import re
48
+ import tokenize
49
+ from dataclasses import dataclass, field
50
+ from pathlib import PurePosixPath
51
+
52
+ from code_atlas.contracts.enums import ResolutionStatus
53
+ from code_atlas.domains.aws.common import (
54
+ ARN_RE,
55
+ ICEBERG_META,
56
+ aws_catalog,
57
+ is_iam_role_arn,
58
+ is_iceberg_impl,
59
+ line_starts,
60
+ node_span,
61
+ parse_arn,
62
+ parse_s3_uri,
63
+ redact_if_sensitive,
64
+ split_table_name,
65
+ )
66
+ from code_atlas.domains.aws.emit import Sink
67
+ from code_atlas.domains.aws.sqlbridge import (
68
+ extract_sql_fragment,
69
+ literal_span_remap,
70
+ s3_entity,
71
+ )
72
+ from code_atlas.domains.identity import DomainIdentity
73
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
74
+ from code_atlas.domains.sql.adapter import looks_like_sql
75
+ from code_atlas.parsers.contracts import SourceSpan
76
+
77
+ _READS = str(DomainRelationKind.READS_FROM)
78
+ _WRITES = str(DomainRelationKind.WRITES_TO)
79
+ _REFS = str(DomainRelationKind.REFERENCES)
80
+ _ASSUMES = str(DomainRelationKind.ASSUMES_ROLE)
81
+
82
+ # Cadeias de atributos que denotam leitura/escrita no catálogo Glue.
83
+ _READ_CATALOG_SUFFIXES = (
84
+ "create_dynamic_frame.from_catalog",
85
+ "create_dynamic_frame_from_catalog",
86
+ "getDataFrame",
87
+ "get_data_frame",
88
+ )
89
+ _READ_OPTIONS_SUFFIXES = (
90
+ "create_dynamic_frame.from_options",
91
+ "create_dynamic_frame_from_options",
92
+ "getSource",
93
+ "get_source",
94
+ )
95
+ _WRITE_CATALOG_SUFFIXES = (
96
+ "write_dynamic_frame.from_catalog",
97
+ "write_dynamic_frame_from_catalog",
98
+ )
99
+ _WRITE_OPTIONS_SUFFIXES = (
100
+ "write_dynamic_frame.from_options",
101
+ "write_dynamic_frame_from_options",
102
+ "write_from_options",
103
+ "write.from_options",
104
+ "getSink",
105
+ "get_sink",
106
+ )
107
+
108
+ # receivers aceitos para ``X.sql(...)``/``X.table(...)`` etc. — reduz
109
+ # falsos positivos de ``session.sql`` fora do contexto Spark.
110
+ _SPARK_ROOTS = re.compile(
111
+ r"^(spark|sparkSession|spark_session|sqlContext|sql_context"
112
+ r"|sparkSessionBuilder|glueContext|glue_context|glue_context"
113
+ r"|sparkContext|sc|sess|session|emr|sparky)$",
114
+ re.IGNORECASE,
115
+ )
116
+
117
+ _ICEBERG_FORMATS = frozenset({"iceberg"})
118
+ _SPARK_READ_FMTS = frozenset({"load", "parquet", "json", "csv", "orc", "avro", "text"})
119
+ _WRITE_TERMINALS = frozenset(
120
+ {
121
+ "append",
122
+ "overwritePartitions",
123
+ "overwrite",
124
+ "replace",
125
+ "create",
126
+ "createOrReplace",
127
+ "insertInto",
128
+ }
129
+ )
130
+
131
+
132
+ @dataclass
133
+ class _PyFacts:
134
+ """Observações de primeira passada de um script ``.py``."""
135
+
136
+ tree: ast.AST | None = None
137
+ parents: dict[int, ast.AST] = field(default_factory=dict)
138
+ calls: list[ast.Call] = field(default_factory=list)
139
+ assigns: dict[str, str] = field(default_factory=dict)
140
+ str_nodes: list[ast.Constant] = field(default_factory=list)
141
+ markers: set[str] = field(default_factory=set)
142
+ spark_vars: set[str] = field(default_factory=set)
143
+ declared_catalogs: dict[str, str] = field(default_factory=dict)
144
+ job_inits: list[ast.Call] = field(default_factory=list)
145
+ job_commits: list[ast.Call] = field(default_factory=list)
146
+ resolved_options: list[tuple[str, ...]] = field(default_factory=list)
147
+
148
+
149
+ def _dotted(node: ast.AST) -> str:
150
+ """Caminho pontilhado do callee/receiver (``a.b.c`` → ``"a.b.c"``).
151
+
152
+ Desce por ``Call.func`` — ``spark.read.format("x").load`` forma a
153
+ cadeia ``spark.read.format.load``.
154
+ """
155
+ parts: list[str] = []
156
+ cur: ast.AST = node
157
+ seen = 0
158
+ while seen < 32:
159
+ seen += 1
160
+ if isinstance(cur, ast.Attribute):
161
+ parts.append(cur.attr)
162
+ cur = cur.value
163
+ continue
164
+ if isinstance(cur, ast.Call):
165
+ cur = cur.func
166
+ continue
167
+ if isinstance(cur, ast.Name):
168
+ parts.append(cur.id)
169
+ break
170
+ parts.reverse()
171
+ return ".".join(parts)
172
+
173
+
174
+ def _call_root(call: ast.Call) -> str:
175
+ """Nome raiz do receiver — ``spark.read.format("x").load`` → spark."""
176
+ func = call.func
177
+ if isinstance(func, ast.Attribute):
178
+ return _dotted(func.value).split(".")[0]
179
+ return ""
180
+
181
+
182
+ def _str_lit(node: ast.AST | None) -> str | None:
183
+ """Valor de ``ast.Constant`` str; ``None`` para JoinedStr/dinâmico."""
184
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
185
+ return node.value
186
+ return None
187
+
188
+
189
+ def _kw(call: ast.Call, name: str) -> ast.AST | None:
190
+ for kw in call.keywords:
191
+ if kw.arg == name:
192
+ return kw.value
193
+ return None
194
+
195
+
196
+ def _kw_lit(call: ast.Call, name: str) -> str | None:
197
+ return _str_lit(_kw(call, name))
198
+
199
+
200
+ def _pos_lit(call: ast.Call, i: int) -> str | None:
201
+ if i < len(call.args):
202
+ return _str_lit(call.args[i])
203
+ return None
204
+
205
+
206
+ def _pos_or_kw(call: ast.Call, i: int, name: str) -> str | None:
207
+ """kwarg ``name`` ou posicional ``i`` — assinaturas Glue/Spark."""
208
+ v = _kw_lit(call, name)
209
+ return v if v is not None else _pos_lit(call, i)
210
+
211
+
212
+ def _literal(node: ast.AST | None) -> object | None:
213
+ """``ast.literal_eval`` seguro — ``None`` para não-literais."""
214
+ if node is None:
215
+ return None
216
+ try:
217
+ result: object = ast.literal_eval(node)
218
+ return result
219
+ except (ValueError, TypeError, SyntaxError, MemoryError):
220
+ return None
221
+
222
+
223
+ def _dict_lit(node: ast.AST | None) -> dict[str, object] | None:
224
+ val = _literal(node)
225
+ if isinstance(val, dict):
226
+ return {str(k): v for k, v in val.items()}
227
+ return None
228
+
229
+
230
+ class _Scanner(ast.NodeVisitor):
231
+ """Primeira passada: coleta calls/strings/marcadores."""
232
+
233
+ def __init__(self) -> None:
234
+ self.fx = _PyFacts()
235
+
236
+ def visit(self, node: ast.AST) -> None:
237
+ for child in ast.iter_child_nodes(node):
238
+ self.fx.parents[id(child)] = node
239
+ super().visit(node)
240
+
241
+ def visit_Import(self, node: ast.Import) -> None:
242
+ for a in node.names:
243
+ if a.name.split(".")[0] in ("awsglue", "pyspark", "boto3"):
244
+ self.fx.markers.add(
245
+ {"awsglue": "glue", "pyspark": "spark", "boto3": "aws-sdk"}[
246
+ a.name.split(".")[0]
247
+ ]
248
+ )
249
+ self.generic_visit(node)
250
+
251
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
252
+ mod = node.module or ""
253
+ if mod.startswith("awsglue"):
254
+ self.fx.markers.add("glue")
255
+ if mod.startswith("pyspark"):
256
+ self.fx.markers.add("spark")
257
+ if mod.startswith("boto3"):
258
+ self.fx.markers.add("aws-sdk")
259
+ self.generic_visit(node)
260
+
261
+ def visit_Assign(self, node: ast.Assign) -> None:
262
+ if isinstance(node.value, ast.Call):
263
+ dotted = _dotted(node.value.func)
264
+ for tgt in node.targets:
265
+ if isinstance(tgt, ast.Name):
266
+ self.fx.assigns[tgt.id] = dotted
267
+ if dotted.endswith("GlueContext") or ("awsglue" in dotted):
268
+ self.fx.markers.add("glue")
269
+ self.fx.spark_vars.add(tgt.id)
270
+ if "SparkSession" in dotted:
271
+ self.fx.markers.add("spark")
272
+ self.fx.spark_vars.add(tgt.id)
273
+ elif isinstance(node.value, ast.Attribute):
274
+ dotted = _dotted(node.value)
275
+ for tgt in node.targets:
276
+ if isinstance(tgt, ast.Name):
277
+ self.fx.assigns[tgt.id] = dotted
278
+ if dotted.endswith("spark_session"):
279
+ self.fx.spark_vars.add(tgt.id)
280
+ self.fx.markers.add("spark")
281
+ self.generic_visit(node)
282
+
283
+ def visit_Call(self, node: ast.Call) -> None:
284
+ self.fx.calls.append(node)
285
+ dotted = _dotted(node.func)
286
+ last = dotted.rsplit(".", 1)[-1]
287
+ if last in ("GlueContext", "Job"):
288
+ self.fx.markers.add("glue")
289
+ if last in ("SparkSession", "getOrCreate"):
290
+ self.fx.markers.add("spark")
291
+ if last == "getResolvedOptions":
292
+ self.fx.markers.add("glue")
293
+ self._options(node)
294
+ if last in ("sql", "writeTo") or (
295
+ last in ("read", "table", "write")
296
+ and (
297
+ _call_root(node) in self.fx.spark_vars or bool(_SPARK_ROOTS.match(_call_root(node)))
298
+ )
299
+ ):
300
+ self.fx.markers.add("spark")
301
+ if last in (
302
+ "start_query_execution",
303
+ "create_job",
304
+ "update_job",
305
+ "add_steps",
306
+ "run_job_flow",
307
+ ):
308
+ self.fx.markers.add("aws-sdk")
309
+ if last == "init" and self._is_job_call(node):
310
+ self.fx.job_inits.append(node)
311
+ self.fx.markers.add("glue")
312
+ if last in ("commit", "run") and self._is_job_call(node):
313
+ self.fx.job_commits.append(node)
314
+ self.fx.markers.add("glue")
315
+ self.generic_visit(node)
316
+
317
+ def _is_job_call(self, node: ast.Call) -> bool:
318
+ """``job.init/commit/run`` — receiver ``job``/``Job``-bound."""
319
+ if not isinstance(node.func, ast.Attribute):
320
+ return False
321
+ root = _dotted(node.func.value).split(".")[0]
322
+ return (
323
+ root in self.fx.spark_vars
324
+ or self.fx.assigns.get(root, "").endswith("Job")
325
+ or root == "job"
326
+ )
327
+
328
+ def _options(self, node: ast.Call) -> None:
329
+ """``getResolvedOptions(sys.argv, [...])`` → nomes observados."""
330
+ if len(node.args) >= 2:
331
+ lst = _literal(node.args[1])
332
+ if isinstance(lst, (list, tuple)):
333
+ self.fx.resolved_options.append(tuple(str(v) for v in lst))
334
+
335
+ def visit_Constant(self, node: ast.Constant) -> None:
336
+ if isinstance(node.value, str):
337
+ self.fx.str_nodes.append(node)
338
+ self.generic_visit(node)
339
+
340
+
341
+ def extract_py(
342
+ sink: Sink,
343
+ *,
344
+ path: str,
345
+ text: str,
346
+ sha: str,
347
+ ) -> None:
348
+ """Extrai um script ``.py`` — fatos AWS-data evidence-first."""
349
+ starts = line_starts(text)
350
+ try:
351
+ tree = ast.parse(text)
352
+ except (SyntaxError, ValueError) as exc:
353
+ sink.diag(
354
+ "py-parse-error",
355
+ "error",
356
+ f"Python does not parse: {exc}",
357
+ path,
358
+ )
359
+ _degraded_scan(sink, path=path, text=text, sha=sha, starts=starts)
360
+ return
361
+ sc = _Scanner()
362
+ sc.visit(tree)
363
+ facts = sc.fx
364
+ facts.tree = tree
365
+
366
+ if not facts.markers and not _has_aws_observations(facts):
367
+ sink.diag(
368
+ "not-aws-data-artifact",
369
+ "info",
370
+ "no Glue/Spark/Athena markers — skipped",
371
+ path,
372
+ )
373
+ return
374
+
375
+ # catálogos declarados precisam preceder as arestas que os usam
376
+ _collect_catalogs(sink, facts, path, sha, starts)
377
+ job = _script_entity(sink, facts, path=path, sha=sha, starts=starts)
378
+ used_strings: set[int] = set()
379
+
380
+ for call in facts.calls:
381
+ _handle_call(
382
+ sink,
383
+ facts,
384
+ call,
385
+ job,
386
+ path=path,
387
+ text=text,
388
+ sha=sha,
389
+ starts=starts,
390
+ used=used_strings,
391
+ )
392
+
393
+ _job_call_args(
394
+ sink,
395
+ facts,
396
+ job,
397
+ path=path,
398
+ sha=sha,
399
+ starts=starts,
400
+ used=used_strings,
401
+ )
402
+ _sweep_literals(
403
+ sink,
404
+ facts,
405
+ job,
406
+ path=path,
407
+ sha=sha,
408
+ starts=starts,
409
+ used=used_strings,
410
+ )
411
+ sink.bump("py_files")
412
+
413
+
414
+ def _has_aws_observations(facts: _PyFacts) -> bool:
415
+ """Sem marcadores: ainda observável se há literal s3/arn ou call SDK."""
416
+ for node in facts.str_nodes:
417
+ v = node.value
418
+ if isinstance(v, str) and (parse_s3_uri(v) is not None or v.startswith("arn:aws:")):
419
+ return True
420
+ return any(
421
+ _dotted(c.func).rsplit(".", 1)[-1] in ("start_query_execution", "create_job")
422
+ for c in facts.calls
423
+ )
424
+
425
+
426
+ def _script_entity(
427
+ sink: Sink,
428
+ facts: _PyFacts,
429
+ *,
430
+ path: str,
431
+ sha: str,
432
+ starts: tuple[int, ...],
433
+ ) -> DomainIdentity:
434
+ """Entidade-âncora do script — kind honesto pelo marcador dominante."""
435
+ namespace = posixpath.dirname(path)
436
+ stem = PurePosixPath(path).stem or PurePosixPath(path).name
437
+ if "glue" in facts.markers:
438
+ kind = DomainEntityKind.GLUE_JOB
439
+ elif "spark" in facts.markers:
440
+ kind = DomainEntityKind.SPARK_JOB
441
+ else:
442
+ kind = DomainEntityKind.DATA_PIPELINE
443
+ attrs: dict[str, str] = {
444
+ "artifact_kind": "py-script",
445
+ "script": PurePosixPath(path).name,
446
+ }
447
+ if facts.job_inits:
448
+ attrs["job_init"] = "true"
449
+ if facts.job_commits:
450
+ attrs["job_commit_or_run"] = "true"
451
+ if facts.resolved_options:
452
+ names: list[str] = []
453
+ for tup in facts.resolved_options:
454
+ names.extend(tup)
455
+ attrs["job_option_names"] = ",".join(sorted(set(names)))
456
+ if "JOB_BOOKMARK_OPTION" in {n.lstrip("-") for n in names}:
457
+ attrs["job_bookmark_option_listed"] = "true"
458
+ return sink.entity(
459
+ kind=kind,
460
+ name=stem,
461
+ qualified_name=stem,
462
+ namespace=namespace,
463
+ path=path,
464
+ span=None,
465
+ content_hash=sha,
466
+ attributes=attrs,
467
+ evidence=(sink.evi(path, sha, None, notes="script"),),
468
+ )
469
+
470
+
471
+ # -- catálogos declarados ---------------------------------------------------
472
+
473
+
474
+ def _register_catalog(
475
+ sink: Sink,
476
+ name: str,
477
+ impl: str,
478
+ *,
479
+ path: str,
480
+ sha: str,
481
+ span: SourceSpan | None,
482
+ ) -> None:
483
+ """``spark.sql.catalog.<name>`` → ``data_catalog`` + registro."""
484
+ attrs = {"declared_by": path}
485
+ if impl:
486
+ attrs["catalog_impl"] = impl
487
+ if is_iceberg_impl(impl):
488
+ attrs["engine"] = "iceberg"
489
+ ident = sink.entity(
490
+ kind=DomainEntityKind.DATA_CATALOG,
491
+ name=name,
492
+ qualified_name=name,
493
+ path=path,
494
+ span=span,
495
+ content_hash=sha,
496
+ attributes=attrs,
497
+ evidence=(sink.evi(path, sha, span, notes="catalog-decl"),),
498
+ )
499
+ sink.register_catalog(name, ident)
500
+ sink.bump("catalogs")
501
+
502
+
503
+ def _collect_catalogs(
504
+ sink: Sink,
505
+ facts: _PyFacts,
506
+ path: str,
507
+ sha: str,
508
+ starts: tuple[int, ...],
509
+ ) -> None:
510
+ """``conf.set("spark.sql.catalog.<n>", "<impl>")`` → catálogos."""
511
+ for call in facts.calls:
512
+ dotted = _dotted(call.func)
513
+ if not dotted.endswith("conf.set") and not dotted.endswith("setConf"):
514
+ continue
515
+ key = _pos_lit(call, 0) or _kw_lit(call, "key")
516
+ val = _pos_lit(call, 1) or _kw_lit(call, "value")
517
+ if not key or not key.startswith("spark.sql.catalog."):
518
+ continue
519
+ rest = key[len("spark.sql.catalog.") :]
520
+ cat, _, prop = rest.partition(".")
521
+ span = node_span(starts, call)
522
+ if not prop:
523
+ _register_catalog(
524
+ sink,
525
+ cat,
526
+ val or "",
527
+ path=path,
528
+ sha=sha,
529
+ span=span,
530
+ )
531
+ facts.declared_catalogs[cat] = val or ""
532
+ if val and is_iceberg_impl(val):
533
+ facts.markers.add("iceberg")
534
+ continue
535
+ # propriedade de catálogo já declarado (ex.: ``.warehouse``)
536
+ cat_ident = sink.catalogs.get(cat)
537
+ if cat_ident is not None and val and prop == "warehouse" and parse_s3_uri(val) is not None:
538
+ evi = sink.evi(path, sha, span, notes="catalog-warehouse")
539
+ s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
540
+ if s3 is not None:
541
+ sink.rel(
542
+ _REFS,
543
+ path,
544
+ cat_ident,
545
+ to_identity=s3,
546
+ span=span,
547
+ evidence=(evi,),
548
+ )
549
+
550
+
551
+ def _is_spark_receiver(facts: _PyFacts, call: ast.Call) -> bool:
552
+ root = _call_root(call)
553
+ return (
554
+ root in facts.spark_vars
555
+ or bool(_SPARK_ROOTS.match(root))
556
+ or facts.assigns.get(root, "").endswith(("GlueContext",))
557
+ )
558
+
559
+
560
+ def _table_kind(catalog: str, iceberg: bool) -> DomainEntityKind:
561
+ return DomainEntityKind.ICEBERG_TABLE if iceberg else DomainEntityKind.DATA_TABLE
562
+
563
+
564
+ def _table_entity(
565
+ sink: Sink,
566
+ written: str,
567
+ *,
568
+ iceberg: bool,
569
+ path: str,
570
+ sha: str,
571
+ span: SourceSpan | None,
572
+ evi: int,
573
+ ) -> DomainIdentity | None:
574
+ """``cat.db.t`` → ``data_table``/``iceberg_table``; meta-suffix ok."""
575
+ cat, ns, name = split_table_name(written)
576
+ if not name:
577
+ return None
578
+ attrs = {"written_name": written}
579
+ if name.lower() in ICEBERG_META and ns:
580
+ attrs["iceberg_meta_table"] = name.lower()
581
+ iceberg = True
582
+ return sink.entity(
583
+ kind=_table_kind(cat, iceberg),
584
+ name=name,
585
+ qualified_name=name,
586
+ catalog=cat,
587
+ namespace=ns,
588
+ path=path,
589
+ span=span,
590
+ content_hash=sha,
591
+ attributes=attrs,
592
+ evidence=(evi,),
593
+ )
594
+
595
+
596
+ def _catalog_ref_for(
597
+ sink: Sink,
598
+ written: str,
599
+ subject: DomainIdentity,
600
+ path: str,
601
+ span: SourceSpan | None,
602
+ evi: int,
603
+ ) -> None:
604
+ """Catálogo nomeado de um ``cat.db.t`` → ``REFERENCES`` adiada."""
605
+ cat = split_table_name(written)[0]
606
+ if cat:
607
+ sink.catalog_ref(subject, cat, path, span, evi)
608
+
609
+
610
+ # -- handlers por call ------------------------------------------------------
611
+
612
+
613
+ def _catalog_read(
614
+ sink: Sink,
615
+ call: ast.Call,
616
+ subject: DomainIdentity,
617
+ *,
618
+ path: str,
619
+ sha: str,
620
+ starts: tuple[int, ...],
621
+ ) -> None:
622
+ """``from_catalog``/``getDataFrame`` — leitura de tabela Glue."""
623
+ database = _pos_or_kw(call, 0, "database")
624
+ table = _pos_or_kw(call, 1, "table_name") or _kw_lit(call, "table")
625
+ catalog_id = _kw_lit(call, "catalog_id") or _kw_lit(call, "CatalogId")
626
+ span = node_span(starts, call)
627
+ evi = sink.evi(path, sha, span, notes="glue-catalog-read")
628
+ catalog = aws_catalog("glue", catalog_id or "")
629
+ if catalog_id:
630
+ _declare_glue_catalog(sink, catalog, subject, path=path, sha=sha, span=span)
631
+ if not database or not table:
632
+ sink.diag(
633
+ "dynamic-catalog-ref",
634
+ "warning",
635
+ "catalog read with non-literal database/table — relation kept unresolved",
636
+ path,
637
+ )
638
+ sink.rel(
639
+ _READS,
640
+ path,
641
+ subject,
642
+ to_unresolved="<dynamic>",
643
+ status=ResolutionStatus.UNRESOLVED,
644
+ span=span,
645
+ evidence=(evi,),
646
+ )
647
+ return
648
+ ident = sink.entity(
649
+ kind=DomainEntityKind.DATA_TABLE,
650
+ name=table,
651
+ qualified_name=table,
652
+ catalog=catalog,
653
+ namespace=database,
654
+ path=path,
655
+ span=span,
656
+ content_hash=sha,
657
+ attributes={
658
+ "written_name": f"{database}.{table}",
659
+ "source": "glue-catalog",
660
+ },
661
+ evidence=(evi,),
662
+ )
663
+ sink.rel(
664
+ _READS,
665
+ path,
666
+ subject,
667
+ to_identity=ident,
668
+ span=span,
669
+ evidence=(evi,),
670
+ )
671
+ sink.bump("glue_reads")
672
+
673
+
674
+ def _declare_glue_catalog(
675
+ sink: Sink,
676
+ catalog: str,
677
+ subject: DomainIdentity,
678
+ *,
679
+ path: str,
680
+ sha: str,
681
+ span: SourceSpan | None,
682
+ ) -> None:
683
+ """``catalog_id`` explícito → ``data_catalog`` + REFERENCES."""
684
+ ident = sink.entity(
685
+ kind=DomainEntityKind.DATA_CATALOG,
686
+ name=catalog,
687
+ qualified_name=catalog,
688
+ path=path,
689
+ span=span,
690
+ content_hash=sha,
691
+ attributes={"source": "glue-catalog-id"},
692
+ evidence=(sink.evi(path, sha, span, notes="glue-catalog-id"),),
693
+ )
694
+ sink.register_catalog(catalog, ident)
695
+ sink.rel(
696
+ _REFS,
697
+ path,
698
+ subject,
699
+ to_identity=ident,
700
+ span=span,
701
+ evidence=(sink.evi(path, sha, span, notes="catalog-ref"),),
702
+ )
703
+
704
+
705
+ def _catalog_write(
706
+ sink: Sink,
707
+ call: ast.Call,
708
+ subject: DomainIdentity,
709
+ *,
710
+ path: str,
711
+ sha: str,
712
+ starts: tuple[int, ...],
713
+ ) -> None:
714
+ """``write_dynamic_frame.from_catalog`` — escrita em tabela Glue."""
715
+ database = _kw_lit(call, "database")
716
+ table = _kw_lit(call, "table_name") or _kw_lit(call, "table")
717
+ if database is None:
718
+ database = _pos_lit(call, 1) or _pos_lit(call, 0)
719
+ if table is None:
720
+ table = _pos_lit(call, 2)
721
+ catalog_id = _kw_lit(call, "catalog_id") or _kw_lit(call, "CatalogId")
722
+ span = node_span(starts, call)
723
+ evi = sink.evi(path, sha, span, notes="glue-catalog-write")
724
+ catalog = aws_catalog("glue", catalog_id or "")
725
+ if catalog_id:
726
+ _declare_glue_catalog(sink, catalog, subject, path=path, sha=sha, span=span)
727
+ if not database or not table:
728
+ sink.diag(
729
+ "dynamic-catalog-ref",
730
+ "warning",
731
+ "catalog write with non-literal database/table",
732
+ path,
733
+ )
734
+ sink.rel(
735
+ _WRITES,
736
+ path,
737
+ subject,
738
+ to_unresolved="<dynamic>",
739
+ status=ResolutionStatus.UNRESOLVED,
740
+ span=span,
741
+ evidence=(evi,),
742
+ )
743
+ return
744
+ ident = sink.entity(
745
+ kind=DomainEntityKind.DATA_TABLE,
746
+ name=table,
747
+ qualified_name=table,
748
+ catalog=catalog,
749
+ namespace=database,
750
+ path=path,
751
+ span=span,
752
+ content_hash=sha,
753
+ attributes={
754
+ "written_name": f"{database}.{table}",
755
+ "source": "glue-catalog",
756
+ },
757
+ evidence=(evi,),
758
+ )
759
+ sink.rel(
760
+ _WRITES,
761
+ path,
762
+ subject,
763
+ to_identity=ident,
764
+ span=span,
765
+ evidence=(evi,),
766
+ )
767
+ sink.bump("glue_writes")
768
+
769
+
770
+ def _conn_type_of(call: ast.Call) -> str | None:
771
+ """``connection_type=`` kw ou posicional ``write_from_options``."""
772
+ conn_type = _kw_lit(call, "connection_type")
773
+ if conn_type is not None:
774
+ return conn_type
775
+ for i in range(len(call.args)):
776
+ v = _pos_lit(call, i)
777
+ if isinstance(v, str) and v.lower() in _CONN_TYPES:
778
+ return v
779
+ return None
780
+
781
+
782
+ def _conn_opts_dict(call: ast.Call) -> dict[str, object] | None:
783
+ """``connection_options=`` literal dict — kw ou 1º dict posicional."""
784
+ opts = _dict_lit(_kw(call, "connection_options"))
785
+ if opts is None:
786
+ opts = _dict_lit(_kw(call, "connectionOptions"))
787
+ if opts is None:
788
+ for a in call.args:
789
+ d = _dict_lit(a)
790
+ if d is not None:
791
+ opts = d
792
+ break
793
+ return opts
794
+
795
+
796
+ def _connection_fallback(
797
+ sink: Sink,
798
+ call: ast.Call,
799
+ subject: DomainIdentity,
800
+ *,
801
+ direction: str,
802
+ conn_type: str | None,
803
+ opts: dict[str, object] | None,
804
+ path: str,
805
+ span: SourceSpan | None,
806
+ evi: int,
807
+ ) -> None:
808
+ """Alvos não-literais → diagnóstico/aresta honesta."""
809
+ if conn_type is None or opts is None:
810
+ sink.diag(
811
+ "dynamic-connection",
812
+ "warning",
813
+ "connection options not a literal dict — targets unknown",
814
+ path,
815
+ )
816
+ return
817
+ if conn_type:
818
+ sink.rel(
819
+ _REFS,
820
+ path,
821
+ subject,
822
+ to_unresolved=f"glue-connection:{conn_type}",
823
+ status=ResolutionStatus.EXTERNAL,
824
+ span=span,
825
+ evidence=(evi,),
826
+ )
827
+ return
828
+ sink.rel(
829
+ direction,
830
+ path,
831
+ subject,
832
+ to_unresolved="<dynamic>",
833
+ status=ResolutionStatus.UNRESOLVED,
834
+ span=span,
835
+ evidence=(evi,),
836
+ )
837
+
838
+
839
+ def _connection_options(
840
+ sink: Sink,
841
+ call: ast.Call,
842
+ subject: DomainIdentity,
843
+ *,
844
+ direction: str,
845
+ path: str,
846
+ sha: str,
847
+ starts: tuple[int, ...],
848
+ ) -> None:
849
+ """``from_options``/``write_from_options`` — s3/jdbc/connection."""
850
+ span = node_span(starts, call)
851
+ evi = sink.evi(path, sha, span, notes="connection-options")
852
+ conn_type = _conn_type_of(call)
853
+ opts = _conn_opts_dict(call)
854
+ conn_name = _kw_lit(call, "connection_name") or _kw_lit(call, "connectionName")
855
+ handled = False
856
+ if opts:
857
+ handled = _emit_option_targets(
858
+ sink,
859
+ conn_type,
860
+ opts,
861
+ subject,
862
+ direction=direction,
863
+ path=path,
864
+ sha=sha,
865
+ span=span,
866
+ evi=evi,
867
+ )
868
+ if conn_name:
869
+ sink.rel(
870
+ _REFS,
871
+ path,
872
+ subject,
873
+ to_unresolved=f"glue-connection:{conn_name}",
874
+ status=ResolutionStatus.EXTERNAL,
875
+ span=span,
876
+ evidence=(evi,),
877
+ )
878
+ handled = True
879
+ if not handled:
880
+ _connection_fallback(
881
+ sink,
882
+ call,
883
+ subject,
884
+ direction=direction,
885
+ conn_type=conn_type,
886
+ opts=opts,
887
+ path=path,
888
+ span=span,
889
+ evi=evi,
890
+ )
891
+
892
+
893
+ _CONN_TYPES = frozenset(
894
+ {
895
+ "s3",
896
+ "dynamodb",
897
+ "jdbc",
898
+ "mysql",
899
+ "postgresql",
900
+ "oracle",
901
+ "sqlserver",
902
+ "redshift",
903
+ "mongodb",
904
+ "documentdb",
905
+ "kinesis",
906
+ "kafka",
907
+ "marketplace",
908
+ "custom.jdbc",
909
+ "snowflake",
910
+ "bigquery",
911
+ "opensearch",
912
+ "saphana",
913
+ "teradata",
914
+ "vertica",
915
+ "netezza",
916
+ "azure",
917
+ "cosmos",
918
+ "db2",
919
+ "informix",
920
+ "sybase",
921
+ "excel",
922
+ "athena",
923
+ "salesforce",
924
+ "servicenow",
925
+ }
926
+ )
927
+
928
+
929
+ def _emit_option_targets(
930
+ sink: Sink,
931
+ conn_type: str | None,
932
+ opts: dict[str, object],
933
+ subject: DomainIdentity,
934
+ *,
935
+ direction: str,
936
+ path: str,
937
+ sha: str,
938
+ span: SourceSpan | None,
939
+ evi: int,
940
+ ) -> bool:
941
+ """Emite arestas ``direction`` para alvos de ``connection_options``."""
942
+ handled = False
943
+ raw_paths = opts.get("paths", opts.get("path"))
944
+ paths: list[str] = []
945
+ if isinstance(raw_paths, str):
946
+ paths = [raw_paths]
947
+ elif isinstance(raw_paths, (list, tuple)):
948
+ paths = [str(p) for p in raw_paths]
949
+ for p in paths:
950
+ ident = s3_entity(sink, p, path=path, sha=sha, span=span, evi=evi)
951
+ if ident is None:
952
+ sink.diag(
953
+ "dynamic-s3-uri",
954
+ "warning",
955
+ "s3 path in connection_options is not a literal URI",
956
+ path,
957
+ )
958
+ continue
959
+ sink.rel(
960
+ direction,
961
+ path,
962
+ subject,
963
+ to_identity=ident,
964
+ span=span,
965
+ evidence=(evi,),
966
+ )
967
+ sink.bump("s3_paths")
968
+ handled = True
969
+ if conn_type == "dynamodb":
970
+ for key in ("dynamodb.input.tableName", "dynamodb.output.tableName"):
971
+ val = opts.get(key)
972
+ if isinstance(val, str) and val:
973
+ sink.rel(
974
+ _REFS,
975
+ path,
976
+ subject,
977
+ to_unresolved=f"dynamodb:{val}",
978
+ status=ResolutionStatus.EXTERNAL,
979
+ span=span,
980
+ evidence=(evi,),
981
+ )
982
+ handled = True
983
+ return handled
984
+
985
+
986
+ def _sql_literal_call(
987
+ sink: Sink,
988
+ call: ast.Call,
989
+ subject: DomainIdentity,
990
+ *,
991
+ arg_node: ast.AST | None,
992
+ path: str,
993
+ text: str,
994
+ sha: str,
995
+ starts: tuple[int, ...],
996
+ use_catalog: str = "",
997
+ use_schema: str = "",
998
+ notes: str = "sql-literal",
999
+ ) -> None:
1000
+ """Literal SQL → ``sqlbridge``; dinâmico → diagnóstico honesto."""
1001
+ span = node_span(starts, call)
1002
+ evi = sink.evi(path, sha, span, notes=notes)
1003
+ if arg_node is None:
1004
+ return
1005
+ if isinstance(arg_node, ast.JoinedStr) or (not isinstance(arg_node, ast.Constant)):
1006
+ sink.diag(
1007
+ "dynamic-sql-literal",
1008
+ "warning",
1009
+ "SQL argument is not a string literal — never parsed",
1010
+ path,
1011
+ )
1012
+ sink.rel(
1013
+ "REFERENCES",
1014
+ path,
1015
+ subject,
1016
+ to_unresolved="<dynamic-sql>",
1017
+ status=ResolutionStatus.UNRESOLVED,
1018
+ span=span,
1019
+ evidence=(evi,),
1020
+ )
1021
+ return
1022
+ sql = arg_node.value
1023
+ if not isinstance(sql, str) or not sql.strip():
1024
+ return
1025
+ # remap: corpo do literal contíguo no fonte → offsets reais
1026
+ body_start, contiguous = _literal_body_start(text, arg_node, sql)
1027
+ remap = literal_span_remap(
1028
+ file_text=text,
1029
+ line_starts=starts,
1030
+ body_char_start=body_start if body_start is not None else 0,
1031
+ contiguous=contiguous and body_start is not None,
1032
+ fallback=node_span(starts, arg_node) or span,
1033
+ )
1034
+ frag = extract_sql_fragment(
1035
+ sink,
1036
+ sql_text=sql,
1037
+ path=path,
1038
+ sha=sha,
1039
+ transform_tag=f"L{arg_node.lineno}",
1040
+ span_remap=remap,
1041
+ use_catalog=use_catalog,
1042
+ use_schema=use_schema,
1043
+ fallback_anchor=subject,
1044
+ )
1045
+ # a script contém literalmente os statements — CONTAINS honesto
1046
+ for tident in frag.transform_idents:
1047
+ sink.rel(
1048
+ "CONTAINS",
1049
+ path,
1050
+ subject,
1051
+ to_identity=tident,
1052
+ span=span,
1053
+ evidence=(evi,),
1054
+ )
1055
+
1056
+
1057
+ def _literal_body_start(text: str, node: ast.AST, decoded: str) -> tuple[int | None, bool]:
1058
+ """Início do corpo decodificado do literal no fonte + contiguidade.
1059
+
1060
+ Retorna ``(char_start, contiguous)``; ``contiguous=False`` quando
1061
+ escapes/concatenação impedem mapear offsets internos — o caller cai
1062
+ para o span do literal inteiro (nunca offset errado).
1063
+ """
1064
+ lineno = getattr(node, "lineno", None)
1065
+ col = getattr(node, "col_offset", None)
1066
+ if lineno is None or col is None:
1067
+ return None, False
1068
+ lines = text.splitlines(keepends=True)
1069
+ if lineno < 1 or lineno > len(lines):
1070
+ return None, False
1071
+ line_start_char = sum(len(ln) for ln in lines[: lineno - 1])
1072
+ # col_offset é byte offset na linha → converter para char
1073
+ line = lines[lineno - 1]
1074
+ char_col = len(line.encode("utf-8")[:col].decode("utf-8", "replace"))
1075
+ start = line_start_char + char_col
1076
+ raw = ast.get_source_segment(text, node)
1077
+ if raw is None:
1078
+ return None, False
1079
+ m = re.match(r"(?i)([rubf]*)('''|\"\"\"|'|\")", raw)
1080
+ if m is None:
1081
+ return None, False
1082
+ body_start = start + m.end()
1083
+ contiguous = text[body_start : body_start + len(decoded)] == decoded
1084
+ return body_start, contiguous
1085
+
1086
+
1087
+ def _role_entity(
1088
+ sink: Sink,
1089
+ arn_text: str,
1090
+ *,
1091
+ path: str,
1092
+ sha: str,
1093
+ span: SourceSpan | None,
1094
+ evi: int,
1095
+ ) -> DomainIdentity | None:
1096
+ """ARN ``iam::acct:role/x`` → ``iam_role`` com conta explícita."""
1097
+ parts = parse_arn(arn_text)
1098
+ if parts is None or not is_iam_role_arn(parts):
1099
+ return None
1100
+ role_name = parts.resource
1101
+ ident = sink.entity(
1102
+ kind=DomainEntityKind.IAM_ROLE,
1103
+ name=role_name.rsplit("/", 1)[-1],
1104
+ qualified_name=role_name,
1105
+ catalog=aws_catalog("iam", parts.account, parts.region),
1106
+ namespace="",
1107
+ path=path,
1108
+ span=span,
1109
+ content_hash=sha,
1110
+ attributes={
1111
+ "arn": parts.arn,
1112
+ "account": parts.account,
1113
+ "partition": parts.partition,
1114
+ },
1115
+ evidence=(evi,),
1116
+ )
1117
+ sink.bump("iam_roles")
1118
+ return ident
1119
+
1120
+
1121
+ def _mark_used(call: ast.Call, used: set[int]) -> None:
1122
+ """Marca ``Constant`` str dentro dos args do call como consumidos."""
1123
+ for node in ast.walk(call):
1124
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
1125
+ used.add(id(node))
1126
+
1127
+
1128
+ def _merge_attrs(
1129
+ sink: Sink,
1130
+ ident: DomainIdentity,
1131
+ attrs: dict[str, str],
1132
+ *,
1133
+ path: str,
1134
+ sha: str,
1135
+ span: SourceSpan | None,
1136
+ evi: int,
1137
+ ) -> None:
1138
+ """Reemite a entidade já registrada acrescentando ``attrs``."""
1139
+ prev = sink.entities.get(ident.logical_key())
1140
+ if prev is None:
1141
+ return
1142
+ sink.entity_for(
1143
+ ident,
1144
+ kind=prev.kind,
1145
+ name=prev.name,
1146
+ path=path or prev.path,
1147
+ span=span or prev.span,
1148
+ content_hash=sha,
1149
+ attributes=attrs,
1150
+ evidence=(evi,),
1151
+ )
1152
+
1153
+
1154
+ def _job_call_args(
1155
+ sink: Sink,
1156
+ facts: _PyFacts,
1157
+ job: DomainIdentity,
1158
+ *,
1159
+ path: str,
1160
+ sha: str,
1161
+ starts: tuple[int, ...],
1162
+ used: set[int],
1163
+ ) -> None:
1164
+ """``job.init(name, ctx, args={…})`` / ``job.commit()`` literais.
1165
+
1166
+ Extrai nomes de opção + ``JOB_BOOKMARK_OPTION`` e refs ``s3://``
1167
+ declaradas nos args — valores sensíveis ficam ``[REDACTED]``.
1168
+ """
1169
+ for call in [*facts.job_inits, *facts.job_commits]:
1170
+ _job_call_one(sink, call, job, path=path, sha=sha, starts=starts, used=used)
1171
+
1172
+
1173
+ def _job_call_one(
1174
+ sink: Sink,
1175
+ call: ast.Call,
1176
+ job: DomainIdentity,
1177
+ *,
1178
+ path: str,
1179
+ sha: str,
1180
+ starts: tuple[int, ...],
1181
+ used: set[int],
1182
+ ) -> None:
1183
+ """Um ``job.init``/``job.commit`` — args literais → attrs/refs."""
1184
+ span = node_span(starts, call)
1185
+ evi = sink.evi(path, sha, span, notes="glue-job-args")
1186
+ args_node = _kw(call, "args")
1187
+ if args_node is None:
1188
+ for a in call.args:
1189
+ if isinstance(a, ast.Dict):
1190
+ args_node = a
1191
+ break
1192
+ if args_node is None:
1193
+ return
1194
+ opts = _dict_lit(args_node)
1195
+ if opts is None:
1196
+ sink.diag(
1197
+ "dynamic-job-args",
1198
+ "warning",
1199
+ "job.init/run args is not a literal dict — option values unknown",
1200
+ path,
1201
+ )
1202
+ return
1203
+ attrs: dict[str, str] = {}
1204
+ for k, v in opts.items():
1205
+ _job_arg_entry(sink, job, k, v, attrs, path=path, sha=sha, span=span, evi=evi)
1206
+ if attrs:
1207
+ _merge_attrs(sink, job, attrs, path=path, sha=sha, span=span, evi=evi)
1208
+ _mark_used(call, used)
1209
+
1210
+
1211
+ def _job_arg_entry(
1212
+ sink: Sink,
1213
+ job: DomainIdentity,
1214
+ k: object,
1215
+ v: object,
1216
+ attrs: dict[str, str],
1217
+ *,
1218
+ path: str,
1219
+ sha: str,
1220
+ span: SourceSpan | None,
1221
+ evi: int,
1222
+ ) -> None:
1223
+ """Uma entrada de ``args={…}``: attr redacted + bookmark + s3 ref."""
1224
+ kk = str(k).lstrip("-")
1225
+ if not kk:
1226
+ return
1227
+ attrs[f"job_arg.{kk}"] = redact_if_sensitive(kk, str(v))
1228
+ if kk == "JOB_BOOKMARK_OPTION" and isinstance(v, str):
1229
+ attrs["job_bookmarks"] = v
1230
+ if not isinstance(v, str) or parse_s3_uri(v) is None:
1231
+ return
1232
+ s3 = s3_entity(sink, v, path=path, sha=sha, span=span, evi=evi)
1233
+ if s3 is not None:
1234
+ sink.rel(_REFS, path, job, to_identity=s3, span=span, evidence=(evi,))
1235
+ sink.bump("s3_paths")
1236
+
1237
+
1238
+ def _handle_call(
1239
+ sink: Sink,
1240
+ facts: _PyFacts,
1241
+ call: ast.Call,
1242
+ subject: DomainIdentity,
1243
+ *,
1244
+ path: str,
1245
+ text: str,
1246
+ sha: str,
1247
+ starts: tuple[int, ...],
1248
+ used: set[int],
1249
+ ) -> None:
1250
+ """Despacha um ``ast.Call`` para o reconhecedor adequado."""
1251
+ for disp in (
1252
+ _disp_glue_io,
1253
+ _disp_sqlish,
1254
+ _disp_sdk_calls,
1255
+ _disp_writes,
1256
+ _disp_reads,
1257
+ ):
1258
+ if disp(
1259
+ sink,
1260
+ facts,
1261
+ call,
1262
+ subject,
1263
+ path=path,
1264
+ text=text,
1265
+ sha=sha,
1266
+ starts=starts,
1267
+ used=used,
1268
+ ):
1269
+ return
1270
+
1271
+
1272
+ def _disp_glue_io(
1273
+ sink: Sink,
1274
+ facts: _PyFacts,
1275
+ call: ast.Call,
1276
+ subject: DomainIdentity,
1277
+ *,
1278
+ path: str,
1279
+ sha: str,
1280
+ starts: tuple[int, ...],
1281
+ used: set[int],
1282
+ **_unused: object,
1283
+ ) -> bool:
1284
+ """``from_catalog``/``from_options``/``write_*`` do GlueContext."""
1285
+ dotted = _dotted(call.func)
1286
+ if dotted.endswith(_READ_CATALOG_SUFFIXES):
1287
+ _catalog_read(sink, call, subject, path=path, sha=sha, starts=starts)
1288
+ elif dotted.endswith(_WRITE_CATALOG_SUFFIXES):
1289
+ _catalog_write(sink, call, subject, path=path, sha=sha, starts=starts)
1290
+ elif dotted.endswith(_READ_OPTIONS_SUFFIXES):
1291
+ _connection_options(
1292
+ sink,
1293
+ call,
1294
+ subject,
1295
+ direction=_READS,
1296
+ path=path,
1297
+ sha=sha,
1298
+ starts=starts,
1299
+ )
1300
+ elif dotted.endswith(_WRITE_OPTIONS_SUFFIXES):
1301
+ _connection_options(
1302
+ sink,
1303
+ call,
1304
+ subject,
1305
+ direction=_WRITES,
1306
+ path=path,
1307
+ sha=sha,
1308
+ starts=starts,
1309
+ )
1310
+ else:
1311
+ return False
1312
+ _mark_used(call, used)
1313
+ return True
1314
+
1315
+
1316
+ def _disp_sqlish(
1317
+ sink: Sink,
1318
+ facts: _PyFacts,
1319
+ call: ast.Call,
1320
+ subject: DomainIdentity,
1321
+ *,
1322
+ path: str,
1323
+ text: str,
1324
+ sha: str,
1325
+ starts: tuple[int, ...],
1326
+ used: set[int],
1327
+ **_unused: object,
1328
+ ) -> bool:
1329
+ """``X.sql(lit)``/``execute(lit)`` → SQL via ``sqlbridge``."""
1330
+ last = _dotted(call.func).rsplit(".", 1)[-1]
1331
+ arg: ast.AST | None = None
1332
+ notes = ""
1333
+ if last == "sql":
1334
+ arg = call.args[0] if call.args else _kw(call, "sqlQuery")
1335
+ # receiver spark OU literal que prova ser SQL — ambos honestos
1336
+ if not _is_spark_receiver(facts, call) and not (
1337
+ isinstance(arg, ast.Constant)
1338
+ and isinstance(arg.value, str)
1339
+ and looks_like_sql(arg.value)
1340
+ ):
1341
+ return True # não-SQL — consumido e encerrado
1342
+ notes = "spark-sql-literal"
1343
+ elif last in ("execute", "executemany", "execute_query"):
1344
+ arg = call.args[0] if call.args else _kw(call, "query")
1345
+ if not (
1346
+ isinstance(arg, ast.Constant)
1347
+ and isinstance(arg.value, str)
1348
+ and looks_like_sql(arg.value)
1349
+ ):
1350
+ return True
1351
+ notes = "execute-sql"
1352
+ else:
1353
+ return False
1354
+ _sql_literal_call(
1355
+ sink,
1356
+ call,
1357
+ subject,
1358
+ arg_node=arg,
1359
+ path=path,
1360
+ text=text,
1361
+ sha=sha,
1362
+ starts=starts,
1363
+ notes=notes,
1364
+ )
1365
+ _mark_used(call, used)
1366
+ return True
1367
+
1368
+
1369
+ def _disp_sdk_calls(
1370
+ sink: Sink,
1371
+ facts: _PyFacts,
1372
+ call: ast.Call,
1373
+ subject: DomainIdentity,
1374
+ *,
1375
+ path: str,
1376
+ text: str,
1377
+ sha: str,
1378
+ starts: tuple[int, ...],
1379
+ used: set[int],
1380
+ **_unused: object,
1381
+ ) -> bool:
1382
+ """``start_query_execution``/``create_job``/``update_job``."""
1383
+ last = _dotted(call.func).rsplit(".", 1)[-1]
1384
+ if last == "start_query_execution":
1385
+ _athena_call(
1386
+ sink,
1387
+ call,
1388
+ subject,
1389
+ path=path,
1390
+ text=text,
1391
+ sha=sha,
1392
+ starts=starts,
1393
+ )
1394
+ elif last in ("create_job", "update_job"):
1395
+ _glue_job_call(
1396
+ sink,
1397
+ call,
1398
+ subject,
1399
+ path=path,
1400
+ sha=sha,
1401
+ starts=starts,
1402
+ used=used,
1403
+ )
1404
+ else:
1405
+ return False
1406
+ _mark_used(call, used)
1407
+ return True
1408
+
1409
+
1410
+ def _disp_writes(
1411
+ sink: Sink,
1412
+ facts: _PyFacts,
1413
+ call: ast.Call,
1414
+ subject: DomainIdentity,
1415
+ *,
1416
+ path: str,
1417
+ sha: str,
1418
+ starts: tuple[int, ...],
1419
+ used: set[int],
1420
+ **_unused: object,
1421
+ ) -> bool:
1422
+ """``writeTo``/``insertInto``/``saveAsTable`` → ``WRITES_TO``."""
1423
+ last = _dotted(call.func).rsplit(".", 1)[-1]
1424
+ if last == "writeTo":
1425
+ _write_to(sink, facts, call, subject, path=path, sha=sha, starts=starts)
1426
+ elif last in ("insertInto", "saveAsTable") and _is_spark_receiver(facts, call):
1427
+ _named_write(sink, call, subject, facts, path=path, sha=sha, starts=starts)
1428
+ else:
1429
+ return False
1430
+ _mark_used(call, used)
1431
+ return True
1432
+
1433
+
1434
+ def _disp_reads(
1435
+ sink: Sink,
1436
+ facts: _PyFacts,
1437
+ call: ast.Call,
1438
+ subject: DomainIdentity,
1439
+ *,
1440
+ path: str,
1441
+ sha: str,
1442
+ starts: tuple[int, ...],
1443
+ used: set[int],
1444
+ **_unused: object,
1445
+ ) -> bool:
1446
+ """``spark.table``/``spark.read.<fmt>`` → ``READS_FROM``."""
1447
+ last = _dotted(call.func).rsplit(".", 1)[-1]
1448
+ if last == "table" and _is_spark_receiver(facts, call):
1449
+ _named_read(sink, call, subject, facts, path=path, sha=sha, starts=starts)
1450
+ elif last in _SPARK_READ_FMTS and _is_read_chain(facts, call):
1451
+ _read_path(sink, facts, call, subject, path=path, sha=sha, starts=starts)
1452
+ else:
1453
+ return False
1454
+ _mark_used(call, used)
1455
+ return True
1456
+
1457
+
1458
+ def _chain_attrs(facts: _PyFacts, call: ast.Call) -> list[str]:
1459
+ """Atributos da cadeia englobante: ``w.writeTo(t).using(x).append()``
1460
+ → ``["writeTo", "using", "append"]`` (interno→externo)."""
1461
+ attrs: list[str] = []
1462
+ node: ast.AST = call
1463
+ seen = 0
1464
+ while seen < 16:
1465
+ parent = facts.parents.get(id(node))
1466
+ if parent is None:
1467
+ break
1468
+ if isinstance(parent, ast.Attribute):
1469
+ attrs.append(parent.attr)
1470
+ gp = facts.parents.get(id(parent))
1471
+ node = gp if gp is not None else parent
1472
+ if gp is None:
1473
+ break
1474
+ elif isinstance(parent, ast.Call):
1475
+ node = parent
1476
+ else:
1477
+ break
1478
+ seen += 1
1479
+ return attrs
1480
+
1481
+
1482
+ def _chain_inner_calls(call: ast.Call) -> list[tuple[str, ast.Call]]:
1483
+ """Chamadas no receiver: ``a.read.format('x').load(t)`` →
1484
+ [(load,call),(format,…),(read,…)] externo→interno."""
1485
+ chain: list[tuple[str, ast.Call]] = []
1486
+ cur = call
1487
+ depth = 0
1488
+ while depth < 16:
1489
+ if not isinstance(cur.func, ast.Attribute):
1490
+ break
1491
+ chain.append((cur.func.attr, cur))
1492
+ recv = cur.func.value
1493
+ if not isinstance(recv, ast.Call):
1494
+ break
1495
+ cur = recv
1496
+ depth += 1
1497
+ return chain
1498
+
1499
+
1500
+ def _is_read_chain(facts: _PyFacts, call: ast.Call) -> bool:
1501
+ """Terminal de ``<spark>.read.<fmt>(...)`` — receiver ``*.read``
1502
+ ou cadeia com ``.format(...)`` sobre receiver spark."""
1503
+ if not isinstance(call.func, ast.Attribute):
1504
+ return False
1505
+ recv = _dotted(call.func.value)
1506
+ if recv.endswith(".read") or recv == "read":
1507
+ return True
1508
+ for attr, _c in _chain_inner_calls(call):
1509
+ if attr in ("read", "format"):
1510
+ return _is_spark_receiver(facts, call)
1511
+ return False
1512
+
1513
+
1514
+ def _chain_format(facts: _PyFacts, call: ast.Call) -> str:
1515
+ """Valor literal de ``.format("x")`` na cadeia receiver do call."""
1516
+ for attr, c in _chain_inner_calls(call):
1517
+ if attr == "format":
1518
+ v = _pos_lit(c, 0)
1519
+ if v:
1520
+ return v.lower()
1521
+ return ""
1522
+
1523
+
1524
+ def _write_to(
1525
+ sink: Sink,
1526
+ facts: _PyFacts,
1527
+ call: ast.Call,
1528
+ subject: DomainIdentity,
1529
+ *,
1530
+ path: str,
1531
+ sha: str,
1532
+ starts: tuple[int, ...],
1533
+ ) -> None:
1534
+ """``df.writeTo("c.n.t")`` + ``.using("iceberg")`` + terminal."""
1535
+ outer = _chain_attrs(facts, call)
1536
+ terminal = [a for a in outer if a in _WRITE_TERMINALS]
1537
+ if not terminal:
1538
+ return # builder sem operação terminal — nada a afirmar
1539
+ using_iceberg = False
1540
+ node: ast.AST = call
1541
+ for _ in range(16):
1542
+ parent = facts.parents.get(id(node))
1543
+ if isinstance(parent, ast.Attribute) and parent.attr == "using":
1544
+ gp = facts.parents.get(id(parent))
1545
+ if isinstance(gp, ast.Call):
1546
+ v = _pos_lit(gp, 0)
1547
+ if v and v.lower() in _ICEBERG_FORMATS:
1548
+ using_iceberg = True
1549
+ break
1550
+ if isinstance(parent, ast.Call):
1551
+ node = parent
1552
+ continue
1553
+ node = parent if parent is not None else node
1554
+ if parent is None:
1555
+ break
1556
+ written = _pos_lit(call, 0)
1557
+ span = node_span(starts, call)
1558
+ evi = sink.evi(path, sha, span, notes="writeTo")
1559
+ if not written:
1560
+ sink.diag(
1561
+ "dynamic-table-ref",
1562
+ "warning",
1563
+ "writeTo with non-literal target",
1564
+ path,
1565
+ )
1566
+ sink.rel(
1567
+ _WRITES,
1568
+ path,
1569
+ subject,
1570
+ to_unresolved="<dynamic>",
1571
+ status=ResolutionStatus.UNRESOLVED,
1572
+ span=span,
1573
+ evidence=(evi,),
1574
+ )
1575
+ return
1576
+ cat, _, _name = split_table_name(written)
1577
+ iceberg = using_iceberg or (
1578
+ cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
1579
+ )
1580
+ ident = _table_entity(
1581
+ sink,
1582
+ written,
1583
+ iceberg=iceberg,
1584
+ path=path,
1585
+ sha=sha,
1586
+ span=span,
1587
+ evi=evi,
1588
+ )
1589
+ if ident is None:
1590
+ return
1591
+ sink.rel(
1592
+ _WRITES,
1593
+ path,
1594
+ subject,
1595
+ to_identity=ident,
1596
+ span=span,
1597
+ evidence=(evi,),
1598
+ )
1599
+ _catalog_ref_for(sink, written, subject, path, span, evi)
1600
+ sink.bump("writes")
1601
+
1602
+
1603
+ def _named_write(
1604
+ sink: Sink,
1605
+ call: ast.Call,
1606
+ subject: DomainIdentity,
1607
+ facts: _PyFacts,
1608
+ *,
1609
+ path: str,
1610
+ sha: str,
1611
+ starts: tuple[int, ...],
1612
+ ) -> None:
1613
+ """``insertInto``/``saveAsTable("n.t")`` → ``WRITES_TO``."""
1614
+ written = _pos_or_kw(call, 0, "tableName") or _kw_lit(call, "name")
1615
+ span = node_span(starts, call)
1616
+ evi = sink.evi(path, sha, span, notes="named-write")
1617
+ if not written:
1618
+ sink.rel(
1619
+ _WRITES,
1620
+ path,
1621
+ subject,
1622
+ to_unresolved="<dynamic>",
1623
+ status=ResolutionStatus.UNRESOLVED,
1624
+ span=span,
1625
+ evidence=(evi,),
1626
+ )
1627
+ sink.diag(
1628
+ "dynamic-table-ref",
1629
+ "warning",
1630
+ "non-literal write target",
1631
+ path,
1632
+ )
1633
+ return
1634
+ cat = split_table_name(written)[0]
1635
+ iceberg = cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
1636
+ ident = _table_entity(
1637
+ sink,
1638
+ written,
1639
+ iceberg=iceberg,
1640
+ path=path,
1641
+ sha=sha,
1642
+ span=span,
1643
+ evi=evi,
1644
+ )
1645
+ if ident is None:
1646
+ return
1647
+ sink.rel(
1648
+ _WRITES,
1649
+ path,
1650
+ subject,
1651
+ to_identity=ident,
1652
+ span=span,
1653
+ evidence=(evi,),
1654
+ )
1655
+ _catalog_ref_for(sink, written, subject, path, span, evi)
1656
+ sink.bump("writes")
1657
+
1658
+
1659
+ def _named_read(
1660
+ sink: Sink,
1661
+ call: ast.Call,
1662
+ subject: DomainIdentity,
1663
+ facts: _PyFacts,
1664
+ *,
1665
+ path: str,
1666
+ sha: str,
1667
+ starts: tuple[int, ...],
1668
+ ) -> None:
1669
+ """``spark.table("c.n.t")`` → ``READS_FROM``."""
1670
+ written = _pos_lit(call, 0) or _kw_lit(call, "tableName")
1671
+ span = node_span(starts, call)
1672
+ evi = sink.evi(path, sha, span, notes="named-read")
1673
+ if not written:
1674
+ sink.rel(
1675
+ _READS,
1676
+ path,
1677
+ subject,
1678
+ to_unresolved="<dynamic>",
1679
+ status=ResolutionStatus.UNRESOLVED,
1680
+ span=span,
1681
+ evidence=(evi,),
1682
+ )
1683
+ return
1684
+ cat = split_table_name(written)[0]
1685
+ iceberg = cat in facts.declared_catalogs and is_iceberg_impl(facts.declared_catalogs[cat])
1686
+ ident = _table_entity(
1687
+ sink,
1688
+ written,
1689
+ iceberg=iceberg,
1690
+ path=path,
1691
+ sha=sha,
1692
+ span=span,
1693
+ evi=evi,
1694
+ )
1695
+ if ident is None:
1696
+ return
1697
+ sink.rel(
1698
+ _READS,
1699
+ path,
1700
+ subject,
1701
+ to_identity=ident,
1702
+ span=span,
1703
+ evidence=(evi,),
1704
+ )
1705
+ _catalog_ref_for(sink, written, subject, path, span, evi)
1706
+ sink.bump("reads")
1707
+
1708
+
1709
+ def _read_path(
1710
+ sink: Sink,
1711
+ facts: _PyFacts,
1712
+ call: ast.Call,
1713
+ subject: DomainIdentity,
1714
+ *,
1715
+ path: str,
1716
+ sha: str,
1717
+ starts: tuple[int, ...],
1718
+ ) -> None:
1719
+ """``spark.read.<fmt>(path|table)`` — s3 → entidade; nome → tabela."""
1720
+ fmt = _dotted(call.func).rsplit(".", 1)[-1]
1721
+ val = _pos_lit(call, 0)
1722
+ span = node_span(starts, call)
1723
+ evi = sink.evi(path, sha, span, notes=f"read-{fmt}")
1724
+ fmt_is_iceberg = _chain_format(facts, call) in _ICEBERG_FORMATS
1725
+ if val is None:
1726
+ # lista literal de paths?
1727
+ arg0 = call.args[0] if call.args else None
1728
+ lst = _literal(arg0)
1729
+ if isinstance(lst, (list, tuple)):
1730
+ for v in lst:
1731
+ _read_path_value(
1732
+ sink,
1733
+ str(v),
1734
+ facts,
1735
+ subject,
1736
+ fmt_is_iceberg,
1737
+ path=path,
1738
+ sha=sha,
1739
+ span=span,
1740
+ evi=evi,
1741
+ )
1742
+ return
1743
+ sink.rel(
1744
+ _READS,
1745
+ path,
1746
+ subject,
1747
+ to_unresolved="<dynamic>",
1748
+ status=ResolutionStatus.UNRESOLVED,
1749
+ span=span,
1750
+ evidence=(evi,),
1751
+ )
1752
+ sink.diag(
1753
+ "dynamic-path",
1754
+ "warning",
1755
+ f"spark.read.{fmt} with non-literal path",
1756
+ path,
1757
+ )
1758
+ return
1759
+ _read_path_value(
1760
+ sink,
1761
+ val,
1762
+ facts,
1763
+ subject,
1764
+ fmt_is_iceberg,
1765
+ path=path,
1766
+ sha=sha,
1767
+ span=span,
1768
+ evi=evi,
1769
+ )
1770
+
1771
+
1772
+ def _read_path_value(
1773
+ sink: Sink,
1774
+ val: str,
1775
+ facts: _PyFacts,
1776
+ subject: DomainIdentity,
1777
+ iceberg: bool,
1778
+ *,
1779
+ path: str,
1780
+ sha: str,
1781
+ span: SourceSpan | None,
1782
+ evi: int,
1783
+ ) -> None:
1784
+ s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
1785
+ if s3 is not None:
1786
+ sink.rel(
1787
+ _READS,
1788
+ path,
1789
+ subject,
1790
+ to_identity=s3,
1791
+ span=span,
1792
+ evidence=(evi,),
1793
+ )
1794
+ sink.bump("s3_paths")
1795
+ return
1796
+ if val.lower().startswith(("s3://", "s3a://", "s3n://")):
1797
+ sink.diag(
1798
+ "dynamic-s3-uri",
1799
+ "warning",
1800
+ f"s3 URI not statically decodable: {val[:80]}",
1801
+ path,
1802
+ )
1803
+ return
1804
+ if re.match(r"^[A-Za-z_][\w]*(?:\.[\w`\"]+)+$", val):
1805
+ ident = _table_entity(
1806
+ sink,
1807
+ val,
1808
+ iceberg=iceberg,
1809
+ path=path,
1810
+ sha=sha,
1811
+ span=span,
1812
+ evi=evi,
1813
+ )
1814
+ if ident is not None:
1815
+ sink.rel(
1816
+ _READS,
1817
+ path,
1818
+ subject,
1819
+ to_identity=ident,
1820
+ span=span,
1821
+ evidence=(evi,),
1822
+ )
1823
+ _catalog_ref_for(sink, val, subject, path, span, evi)
1824
+ sink.bump("reads")
1825
+ return
1826
+ sink.rel(
1827
+ _REFS,
1828
+ path,
1829
+ subject,
1830
+ to_unresolved=val[:200],
1831
+ status=ResolutionStatus.EXTERNAL,
1832
+ span=span,
1833
+ evidence=(evi,),
1834
+ )
1835
+
1836
+
1837
+ def _athena_call(
1838
+ sink: Sink,
1839
+ call: ast.Call,
1840
+ subject: DomainIdentity,
1841
+ *,
1842
+ path: str,
1843
+ text: str,
1844
+ sha: str,
1845
+ starts: tuple[int, ...],
1846
+ ) -> None:
1847
+ """``start_query_execution(QueryString=..., QueryExecutionContext=…)``."""
1848
+ qctx = _dict_lit(_kw(call, "QueryExecutionContext"))
1849
+ catalog = schema = ""
1850
+ if qctx:
1851
+ catalog = str(qctx.get("Catalog") or "")
1852
+ schema = str(qctx.get("Database") or "")
1853
+ out = _dict_lit(_kw(call, "ResultConfiguration"))
1854
+ span = node_span(starts, call)
1855
+ if out:
1856
+ loc = out.get("OutputLocation")
1857
+ if isinstance(loc, str):
1858
+ evi = sink.evi(path, sha, span, notes="athena-output")
1859
+ s3 = s3_entity(sink, loc, path=path, sha=sha, span=span, evi=evi)
1860
+ if s3 is not None:
1861
+ sink.rel(
1862
+ _WRITES,
1863
+ path,
1864
+ subject,
1865
+ to_identity=s3,
1866
+ span=span,
1867
+ evidence=(evi,),
1868
+ )
1869
+ _sql_literal_call(
1870
+ sink,
1871
+ call,
1872
+ subject,
1873
+ arg_node=_kw(call, "QueryString"),
1874
+ path=path,
1875
+ text=text,
1876
+ sha=sha,
1877
+ starts=starts,
1878
+ use_catalog=catalog,
1879
+ use_schema=schema,
1880
+ notes="athena-querystring",
1881
+ )
1882
+
1883
+
1884
+ def _glue_job_call(
1885
+ sink: Sink,
1886
+ call: ast.Call,
1887
+ subject: DomainIdentity,
1888
+ *,
1889
+ path: str,
1890
+ sha: str,
1891
+ starts: tuple[int, ...],
1892
+ used: set[int],
1893
+ ) -> None:
1894
+ """boto3 ``create_job``/``update_job`` com kwargs literais."""
1895
+ name = _kw_lit(call, "Name")
1896
+ span = node_span(starts, call)
1897
+ evi = sink.evi(path, sha, span, notes="glue-job-def-call")
1898
+ if not name:
1899
+ sink.diag(
1900
+ "dynamic-job-name",
1901
+ "info",
1902
+ "create_job/update_job without literal Name — no job entity minted",
1903
+ path,
1904
+ )
1905
+ return
1906
+ job = sink.entity(
1907
+ kind=DomainEntityKind.GLUE_JOB,
1908
+ name=name,
1909
+ qualified_name=name,
1910
+ namespace=posixpath.dirname(path),
1911
+ path=path,
1912
+ span=span,
1913
+ content_hash=sha,
1914
+ attributes={"artifact_kind": "boto3-create-job"},
1915
+ evidence=(evi,),
1916
+ )
1917
+ if subject.logical_key() != job.logical_key():
1918
+ sink.rel(
1919
+ "CONTAINS",
1920
+ path,
1921
+ subject,
1922
+ to_identity=job,
1923
+ span=span,
1924
+ evidence=(evi,),
1925
+ )
1926
+ _glue_call_role(sink, call, job, path=path, sha=sha, span=span, evi=evi, used=used)
1927
+ _glue_call_command(sink, call, job, name, path=path, sha=sha, span=span, evi=evi)
1928
+ _glue_call_defaults(sink, call, job, name, path=path, sha=sha, span=span, evi=evi)
1929
+
1930
+
1931
+ def _glue_call_role(
1932
+ sink: Sink,
1933
+ call: ast.Call,
1934
+ job: DomainIdentity,
1935
+ *,
1936
+ path: str,
1937
+ sha: str,
1938
+ span: SourceSpan | None,
1939
+ evi: int,
1940
+ used: set[int],
1941
+ ) -> None:
1942
+ """``Role="arn:…:role/x"`` → ``iam_role`` + ``ASSUMES_ROLE`` external."""
1943
+ role = _kw(call, "Role")
1944
+ role_lit = _str_lit(role)
1945
+ if not role_lit or parse_arn(role_lit) is None:
1946
+ return
1947
+ role_ident = _role_entity(sink, role_lit, path=path, sha=sha, span=span, evi=evi)
1948
+ if role_ident is None:
1949
+ return
1950
+ sink.rel(
1951
+ _ASSUMES,
1952
+ path,
1953
+ job,
1954
+ to_identity=role_ident,
1955
+ status=ResolutionStatus.EXTERNAL,
1956
+ span=span,
1957
+ evidence=(evi,),
1958
+ )
1959
+ if isinstance(role, ast.Constant):
1960
+ used.add(id(role))
1961
+
1962
+
1963
+ def _glue_call_command(
1964
+ sink: Sink,
1965
+ call: ast.Call,
1966
+ job: DomainIdentity,
1967
+ name: str,
1968
+ *,
1969
+ path: str,
1970
+ sha: str,
1971
+ span: SourceSpan | None,
1972
+ evi: int,
1973
+ ) -> None:
1974
+ """``Command={"Name","ScriptLocation"}`` → attr + ``REFERENCES`` s3."""
1975
+ cmd = _dict_lit(_kw(call, "Command"))
1976
+ if not cmd:
1977
+ return
1978
+ loc = cmd.get("ScriptLocation")
1979
+ if isinstance(loc, str):
1980
+ s3 = s3_entity(sink, loc, path=path, sha=sha, span=span, evi=evi)
1981
+ if s3 is not None:
1982
+ sink.rel(
1983
+ _REFS,
1984
+ path,
1985
+ job,
1986
+ to_identity=s3,
1987
+ span=span,
1988
+ evidence=(evi,),
1989
+ )
1990
+ cname = cmd.get("Name")
1991
+ if isinstance(cname, str):
1992
+ # atributo pós-criação: reemite entidade com merge honesto
1993
+ sink.entity_for(
1994
+ job,
1995
+ kind=DomainEntityKind.GLUE_JOB,
1996
+ name=name,
1997
+ path=path,
1998
+ span=span,
1999
+ content_hash=sha,
2000
+ attributes={
2001
+ "artifact_kind": "boto3-create-job",
2002
+ "command": cname,
2003
+ },
2004
+ evidence=(evi,),
2005
+ )
2006
+
2007
+
2008
+ def _glue_call_defaults(
2009
+ sink: Sink,
2010
+ call: ast.Call,
2011
+ job: DomainIdentity,
2012
+ name: str,
2013
+ *,
2014
+ path: str,
2015
+ sha: str,
2016
+ span: SourceSpan | None,
2017
+ evi: int,
2018
+ ) -> None:
2019
+ """``DefaultArguments`` → attrs ``arg.*`` redacted + refs ``s3://``."""
2020
+ defaults = _dict_lit(_kw(call, "DefaultArguments"))
2021
+ if not defaults:
2022
+ return
2023
+ extra: dict[str, str] = {}
2024
+ for k, v in defaults.items():
2025
+ extra[f"arg.{k}"] = redact_if_sensitive(k, str(v))
2026
+ if isinstance(v, str) and parse_s3_uri(v) is not None:
2027
+ s3 = s3_entity(sink, v, path=path, sha=sha, span=span, evi=evi)
2028
+ if s3 is not None:
2029
+ sink.rel(
2030
+ _REFS,
2031
+ path,
2032
+ job,
2033
+ to_identity=s3,
2034
+ span=span,
2035
+ evidence=(evi,),
2036
+ )
2037
+ if extra:
2038
+ sink.entity_for(
2039
+ job,
2040
+ kind=DomainEntityKind.GLUE_JOB,
2041
+ name=name,
2042
+ path=path,
2043
+ span=span,
2044
+ content_hash=sha,
2045
+ attributes={"artifact_kind": "boto3-create-job", **extra},
2046
+ evidence=(evi,),
2047
+ )
2048
+
2049
+
2050
+ def _sweep_literals(
2051
+ sink: Sink,
2052
+ facts: _PyFacts,
2053
+ subject: DomainIdentity,
2054
+ *,
2055
+ path: str,
2056
+ sha: str,
2057
+ starts: tuple[int, ...],
2058
+ used: set[int],
2059
+ ) -> None:
2060
+ """Varredura final: literais ``s3://``/ARN não consumidos."""
2061
+ for node in facts.str_nodes:
2062
+ if id(node) in used:
2063
+ continue
2064
+ val = node.value
2065
+ if not isinstance(val, str):
2066
+ continue
2067
+ span = node_span(starts, node)
2068
+ if parse_s3_uri(val) is not None:
2069
+ evi = sink.evi(path, sha, span, notes="s3-literal")
2070
+ s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
2071
+ if s3 is not None:
2072
+ sink.rel(
2073
+ _REFS,
2074
+ path,
2075
+ subject,
2076
+ to_identity=s3,
2077
+ span=span,
2078
+ evidence=(evi,),
2079
+ )
2080
+ sink.bump("s3_paths")
2081
+ continue
2082
+ for m in ARN_RE.finditer(val):
2083
+ parts = parse_arn(m.group(0))
2084
+ if parts is None or not is_iam_role_arn(parts):
2085
+ continue
2086
+ evi = sink.evi(path, sha, span, notes="iam-arn-literal")
2087
+ role = _role_entity(
2088
+ sink,
2089
+ m.group(0),
2090
+ path=path,
2091
+ sha=sha,
2092
+ span=span,
2093
+ evi=evi,
2094
+ )
2095
+ if role is not None:
2096
+ sink.rel(
2097
+ _REFS,
2098
+ path,
2099
+ subject,
2100
+ to_identity=role,
2101
+ span=span,
2102
+ evidence=(evi,),
2103
+ )
2104
+
2105
+
2106
+ def _degraded_lits(text: str, starts: tuple[int, ...]) -> list[tuple[str, SourceSpan | None]]:
2107
+ """Literais str coletados por ``tokenize`` até a quebra."""
2108
+ lits: list[tuple[str, SourceSpan | None]] = []
2109
+ try:
2110
+ tokens = tokenize.generate_tokens(io.StringIO(text).readline)
2111
+ for tok in tokens:
2112
+ if tok.type != tokenize.STRING:
2113
+ continue
2114
+ try:
2115
+ val = ast.literal_eval(tok.string)
2116
+ except (ValueError, SyntaxError):
2117
+ continue
2118
+ if isinstance(val, str):
2119
+ lits.append((val, _token_span(starts, tok.start, tok.end)))
2120
+ except tokenize.TokenError:
2121
+ pass # degradado — o que foi coletado até a quebra vale
2122
+ return lits
2123
+
2124
+
2125
+ def _emit_degraded_lit(
2126
+ sink: Sink,
2127
+ val: str,
2128
+ span: SourceSpan | None,
2129
+ job: DomainIdentity,
2130
+ *,
2131
+ path: str,
2132
+ sha: str,
2133
+ ) -> None:
2134
+ """Um literal degradado → entidade s3 ou ``iam_role`` + REFERENCES."""
2135
+ if parse_s3_uri(val) is not None:
2136
+ evi = sink.evi(path, sha, span, notes="s3-literal")
2137
+ s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
2138
+ if s3 is not None:
2139
+ sink.rel(
2140
+ _REFS,
2141
+ path,
2142
+ job,
2143
+ to_identity=s3,
2144
+ span=span,
2145
+ evidence=(evi,),
2146
+ )
2147
+ return
2148
+ for m in ARN_RE.finditer(val):
2149
+ parts = parse_arn(m.group(0))
2150
+ if parts is None or not is_iam_role_arn(parts):
2151
+ continue
2152
+ evi = sink.evi(path, sha, span, notes="iam-arn")
2153
+ role = _role_entity(sink, m.group(0), path=path, sha=sha, span=span, evi=evi)
2154
+ if role is not None:
2155
+ sink.rel(
2156
+ _REFS,
2157
+ path,
2158
+ job,
2159
+ to_identity=role,
2160
+ span=span,
2161
+ evidence=(evi,),
2162
+ )
2163
+
2164
+
2165
+ def _degraded_scan(
2166
+ sink: Sink,
2167
+ *,
2168
+ path: str,
2169
+ text: str,
2170
+ sha: str,
2171
+ starts: tuple[int, ...],
2172
+ ) -> None:
2173
+ """``SyntaxError`` → varredura tokenize de literais (degradado)."""
2174
+ lits = _degraded_lits(text, starts)
2175
+ s3_count = sum(1 for v, _ in lits if parse_s3_uri(v) is not None)
2176
+ arn_count = sum(1 for v, _ in lits if ARN_RE.search(v))
2177
+ if not s3_count and not arn_count:
2178
+ return
2179
+ stem = PurePosixPath(path).stem
2180
+ kind = (
2181
+ DomainEntityKind.GLUE_JOB
2182
+ if "awsglue" in text or "GlueContext" in text
2183
+ else DomainEntityKind.DATA_PIPELINE
2184
+ )
2185
+ job = sink.entity(
2186
+ kind=kind,
2187
+ name=stem,
2188
+ qualified_name=stem,
2189
+ namespace=posixpath.dirname(path),
2190
+ path=path,
2191
+ span=None,
2192
+ content_hash=sha,
2193
+ attributes={
2194
+ "artifact_kind": "py-script",
2195
+ "degraded_scan": "tokenize-fallback",
2196
+ },
2197
+ evidence=(sink.evi(path, sha, None, notes="degraded"),),
2198
+ )
2199
+ for val, span in lits:
2200
+ _emit_degraded_lit(sink, val, span, job, path=path, sha=sha)
2201
+
2202
+
2203
+ def _token_span(
2204
+ starts: tuple[int, ...],
2205
+ start: tuple[int, int],
2206
+ end: tuple[int, int],
2207
+ ) -> SourceSpan | None:
2208
+ """(lineno, col-char) de tokenize → SourceSpan bytes."""
2209
+ from code_atlas.domains.aws.common import byte_span
2210
+
2211
+ def _abs(pos: tuple[int, int]) -> int | None:
2212
+ line, col = pos
2213
+ if line < 1 or line > len(starts):
2214
+ return None
2215
+ return starts[line - 1] + col
2216
+
2217
+ b0, b1 = _abs(start), _abs(end)
2218
+ if b0 is None or b1 is None:
2219
+ return None
2220
+ return byte_span(starts, b0, b1)