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,1164 @@
1
+ """Artefatos de configuração AWS-data — EMR/Glue em JSON/YAML (lf-0113).
2
+
3
+ Shapes reconhecidos (somente leitura — nenhum step/roda/comando é
4
+ executado, nenhum ``aws``/``boto3`` é contactado):
5
+
6
+ - **Glue job JSON**: ``{"Name","Role": "arn:…","Command":{"Name",
7
+ "ScriptLocation"},"DefaultArguments"}`` → ``glue_job`` +
8
+ ``ASSUMES_ROLE`` (external) + ``REFERENCES`` ao script S3;
9
+ - **EMR step configs**: ``{"Steps":[…]}``, ``{"JobFlowId","Steps":[…]}``
10
+ ou lista top-level de ``{"Name","HadoopJarStep":{"Jar","Args"}}`` →
11
+ entidade por step (``spark_job`` quando ``spark-submit`` está nos
12
+ args — incl. via ``command-runner.jar``; ``data_pipeline`` senão) +
13
+ ``DEPENDS_ON`` sequencial (ordem de submissão é fato estático);
14
+ - **cluster config**: dict com ``JobFlowRole``/``ServiceRole``/
15
+ ``Applications`` → ``data_pipeline`` ``emr-cluster`` +
16
+ ``ASSUMES_ROLE`` por ``*Role*``/``*RoleArn`` com ARN IAM literal;
17
+ - **glue triggers/workflows**: ``{"Type":"SCHEDULED"|"ON_DEMAND"|
18
+ "CONDITIONAL","Actions":[{"JobName":…}]}`` ou ``{"Workflow",
19
+ "Triggers":[…]}`` → ``data_pipeline`` + ``TRIGGERS``→job (resolvido
20
+ quando o job foi extraído no request; ``external`` senão) +
21
+ ``DEPENDS_ON`` para ``Predicate.Conditions``;
22
+ - **spark-submit em .sh**: linhas ``spark-submit --conf k=v script``
23
+ → ``spark_job`` + ``REFERENCES`` a script s3/local;
24
+ - **spark confs** (``.conf``/``.properties``): ``spark.sql.catalog.<n>``
25
+ → ``data_catalog``; ``*.warehouse=s3://`` → ``REFERENCES`` ao path.
26
+
27
+ JSON fora de shape AWS → ``not-aws-artifact`` info (nunca fato
28
+ fabricado); JSON malformado → ``malformed-config`` error. Chaves
29
+ ``password|secret|token|key|credential`` têm valores ``[REDACTED]``
30
+ (política lf-0115).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import posixpath
37
+ import re
38
+ import shlex
39
+ from collections.abc import Sequence
40
+ from pathlib import PurePosixPath
41
+ from typing import Any
42
+
43
+ from code_atlas.contracts.enums import ResolutionStatus
44
+ from code_atlas.domains.aws.common import (
45
+ ARN_RE,
46
+ aws_catalog,
47
+ byte_span,
48
+ is_iam_role_arn,
49
+ is_iceberg_impl,
50
+ line_starts,
51
+ parse_arn,
52
+ parse_s3_uri,
53
+ redact_if_sensitive,
54
+ )
55
+ from code_atlas.domains.aws.emit import Sink
56
+ from code_atlas.domains.aws.sqlbridge import s3_entity
57
+ from code_atlas.domains.cicd import yamlmini
58
+ from code_atlas.domains.identity import DomainIdentity
59
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
60
+ from code_atlas.parsers.contracts import SourceSpan
61
+
62
+ _READS = str(DomainRelationKind.READS_FROM)
63
+ _WRITES = str(DomainRelationKind.WRITES_TO)
64
+ _REFS = str(DomainRelationKind.REFERENCES)
65
+ _ASSUMES = str(DomainRelationKind.ASSUMES_ROLE)
66
+ _TRIGGERS = str(DomainRelationKind.TRIGGERS)
67
+ _DEPENDS = str(DomainRelationKind.DEPENDS_ON)
68
+ _CONTAINS = "CONTAINS"
69
+
70
+ _ROLE_KEYS = re.compile(r"role|rolearn|role_arn", re.IGNORECASE)
71
+ _STEP_TYPES = frozenset({"SCHEDULED", "ON_DEMAND", "CONDITIONAL", "EVENT"})
72
+
73
+
74
+ # -- helpers genéricos ------------------------------------------------------
75
+
76
+
77
+ def _file_key(path: str) -> str:
78
+ return PurePosixPath(path).stem
79
+
80
+
81
+ def _json_span(text: str, starts: tuple[int, ...], needle: str) -> SourceSpan | None:
82
+ """Span aproximado: primeira ocorrência literal de ``needle``."""
83
+ idx = text.find(needle)
84
+ if idx < 0:
85
+ return None
86
+ return byte_span(starts, idx, idx + len(needle))
87
+
88
+
89
+ def _role_entity_from_arn(
90
+ sink: Sink,
91
+ arn_text: str,
92
+ *,
93
+ path: str,
94
+ sha: str,
95
+ span: SourceSpan | None,
96
+ evi: int,
97
+ ) -> DomainIdentity | None:
98
+ parts = parse_arn(arn_text)
99
+ if parts is None or not is_iam_role_arn(parts):
100
+ return None
101
+ return sink.entity(
102
+ kind=DomainEntityKind.IAM_ROLE,
103
+ name=parts.resource.rsplit("/", 1)[-1],
104
+ qualified_name=parts.resource,
105
+ catalog=aws_catalog("iam", parts.account, parts.region),
106
+ path=path,
107
+ span=span,
108
+ content_hash=sha,
109
+ attributes={
110
+ "arn": parts.arn,
111
+ "account": parts.account,
112
+ "partition": parts.partition,
113
+ },
114
+ evidence=(evi,),
115
+ )
116
+
117
+
118
+ def _emit_s3_refs(
119
+ sink: Sink,
120
+ values: Sequence[object],
121
+ subject: DomainIdentity,
122
+ *,
123
+ path: str,
124
+ sha: str,
125
+ span: SourceSpan | None,
126
+ evi: int,
127
+ ) -> None:
128
+ """Valores que são URIs ``s3://`` → entidades + ``REFERENCES``."""
129
+ for v in values:
130
+ if not isinstance(v, str):
131
+ continue
132
+ for m in re.finditer(r"s3://[^\s'\",}\]]+", v):
133
+ uri = m.group(0)
134
+ ident = s3_entity(sink, uri, path=path, sha=sha, span=span, evi=evi)
135
+ if ident is not None:
136
+ sink.rel(
137
+ _REFS,
138
+ path,
139
+ subject,
140
+ to_identity=ident,
141
+ span=span,
142
+ evidence=(evi,),
143
+ )
144
+ sink.bump("s3_paths")
145
+
146
+
147
+ def _emit_role_edges(
148
+ sink: Sink,
149
+ doc: dict[str, Any],
150
+ subject: DomainIdentity,
151
+ *,
152
+ path: str,
153
+ sha: str,
154
+ text: str,
155
+ starts: tuple[int, ...],
156
+ ) -> int:
157
+ """Chaves ``*Role*`` com ARN IAM → ``iam_role`` + ``ASSUMES_ROLE``."""
158
+ n = 0
159
+ for k, v in doc.items():
160
+ if not isinstance(v, str) or not _ROLE_KEYS.search(k):
161
+ continue
162
+ for m in ARN_RE.finditer(v):
163
+ span = _json_span(text, starts, m.group(0))
164
+ evi = sink.evi(path, sha, span, notes=f"role:{k}")
165
+ role = _role_entity_from_arn(
166
+ sink,
167
+ m.group(0),
168
+ path=path,
169
+ sha=sha,
170
+ span=span,
171
+ evi=evi,
172
+ )
173
+ if role is None:
174
+ continue
175
+ sink.rel(
176
+ _ASSUMES,
177
+ path,
178
+ subject,
179
+ to_identity=role,
180
+ status=ResolutionStatus.EXTERNAL,
181
+ span=span,
182
+ evidence=(evi,),
183
+ )
184
+ n += 1
185
+ return n
186
+
187
+
188
+ # -- spark-submit -----------------------------------------------------------
189
+
190
+
191
+ _SUBMIT_VALUE_FLAGS = frozenset(
192
+ {
193
+ "--master",
194
+ "--deploy-mode",
195
+ "--class",
196
+ "--name",
197
+ "--packages",
198
+ "--jars",
199
+ "--py-files",
200
+ "--files",
201
+ "--archives",
202
+ "--queue",
203
+ "--driver-memory",
204
+ "--executor-memory",
205
+ "--num-executors",
206
+ "--executor-cores",
207
+ "--driver-cores",
208
+ "--repositories",
209
+ "--exclude-packages",
210
+ "--properties-file",
211
+ "--keytab",
212
+ "--principal",
213
+ "--proxy-user",
214
+ "-e",
215
+ }
216
+ )
217
+ _NAMED_FLAGS = frozenset({"--class", "--master", "--deploy-mode", "--name"})
218
+
219
+
220
+ def _consume_conf(tokens: list[str], i: int, conf: dict[str, str]) -> int:
221
+ """``--conf k=v`` | ``--conf`` ``k=v`` → último índice consumido."""
222
+ tail = tokens[i][len("--conf") :]
223
+ if tail:
224
+ kv = tail.lstrip("=")
225
+ if "=" in kv:
226
+ k, _, v = kv.partition("=")
227
+ conf[k] = v
228
+ return i
229
+ if i + 1 < len(tokens):
230
+ kv = tokens[i + 1]
231
+ if "=" in kv:
232
+ k, _, v = kv.partition("=")
233
+ conf[k] = v
234
+ return i + 1
235
+ return i
236
+
237
+
238
+ def _consume_value_flag(tokens: list[str], i: int, conf: dict[str, str]) -> int:
239
+ """Flag ``--x valor`` — só flags nomeadas viram ``arg.*``."""
240
+ if i + 1 >= len(tokens):
241
+ return i
242
+ if tokens[i] in _NAMED_FLAGS:
243
+ conf[f"arg.{tokens[i].lstrip('-')}"] = tokens[i + 1]
244
+ return i + 1
245
+
246
+
247
+ def _parse_spark_submit(
248
+ tokens: list[str],
249
+ ) -> tuple[dict[str, str], list[str]]:
250
+ """``--conf k=v``/flags → (conf, positionals).
251
+
252
+ Tokens com ``$``/``${`` viram ``<dynamic>`` — nunca resolvidos.
253
+ """
254
+ conf: dict[str, str] = {}
255
+ positional: list[str] = []
256
+ i = 0
257
+ while i < len(tokens):
258
+ tok = tokens[i]
259
+ if tok.startswith("--conf"):
260
+ i = _consume_conf(tokens, i, conf)
261
+ elif tok in _SUBMIT_VALUE_FLAGS:
262
+ i = _consume_value_flag(tokens, i, conf)
263
+ elif not tok.startswith("--") and tok != "spark-submit":
264
+ positional.append(tok)
265
+ i += 1
266
+ return conf, positional
267
+
268
+
269
+ def _submit_catalogs(
270
+ sink: Sink,
271
+ conf: dict[str, str],
272
+ attrs: dict[str, str],
273
+ *,
274
+ path: str,
275
+ sha: str,
276
+ span: SourceSpan | None,
277
+ evi: int,
278
+ ) -> None:
279
+ """``spark.sql.catalog.<n>[.prop]=v`` → ``data_catalog`` + refs.
280
+
281
+ Duas passadas: declarações primeiro (uma ``.warehouse`` pode citar
282
+ catálogo declarado depois no mesmo comando); demais props viram
283
+ ``conf.*`` no job — nunca promovidas a fato sem evidência.
284
+ """
285
+ catalogs: list[str] = []
286
+ props: list[tuple[str, str, str]] = []
287
+ for k, v in conf.items():
288
+ if "$" in v or "${" in v:
289
+ continue
290
+ if not k.startswith("spark.sql.catalog."):
291
+ attrs[f"conf.{k}"] = redact_if_sensitive(k, v)
292
+ continue
293
+ cat, _, prop = k[len("spark.sql.catalog.") :].partition(".")
294
+ if prop:
295
+ props.append((cat, prop, v))
296
+ continue
297
+ catalogs.append(cat)
298
+ cat_ident = sink.entity(
299
+ kind=DomainEntityKind.DATA_CATALOG,
300
+ name=cat,
301
+ qualified_name=cat,
302
+ path=path,
303
+ span=span,
304
+ content_hash=sha,
305
+ attributes={
306
+ "declared_by": path,
307
+ "catalog_impl": v,
308
+ **({"engine": "iceberg"} if is_iceberg_impl(v) else {}),
309
+ },
310
+ evidence=(evi,),
311
+ )
312
+ sink.register_catalog(cat, cat_ident)
313
+ for cat, prop, v in props:
314
+ cat_ent = sink.catalogs.get(cat)
315
+ if prop == "warehouse" and parse_s3_uri(v) and cat_ent is not None:
316
+ s3 = s3_entity(sink, v, path=path, sha=sha, span=span, evi=evi)
317
+ if s3 is not None:
318
+ sink.rel(
319
+ _REFS,
320
+ path,
321
+ cat_ent,
322
+ to_identity=s3,
323
+ span=span,
324
+ evidence=(evi,),
325
+ )
326
+ continue
327
+ key = f"spark.sql.catalog.{cat}.{prop}"
328
+ attrs[f"conf.{key}"] = redact_if_sensitive(key, v)
329
+ if catalogs:
330
+ attrs["catalogs"] = ",".join(sorted(catalogs))
331
+
332
+
333
+ def _submit_script_refs(
334
+ sink: Sink,
335
+ script: str,
336
+ positional: list[str],
337
+ job: DomainIdentity,
338
+ *,
339
+ path: str,
340
+ sha: str,
341
+ span: SourceSpan | None,
342
+ evi: int,
343
+ ) -> None:
344
+ """Script + posicionais ``s3://`` → ``REFERENCES``/unresolved."""
345
+ if script:
346
+ s3 = s3_entity(sink, script, path=path, sha=sha, span=span, evi=evi)
347
+ if s3 is not None:
348
+ sink.rel(
349
+ _REFS,
350
+ path,
351
+ job,
352
+ to_identity=s3,
353
+ span=span,
354
+ evidence=(evi,),
355
+ )
356
+ sink.bump("s3_paths")
357
+ elif script.startswith("s3") or "{" in script:
358
+ sink.diag(
359
+ "dynamic-s3-uri",
360
+ "warning",
361
+ "spark-submit script arg is dynamic",
362
+ path,
363
+ )
364
+ else:
365
+ sink.rel(
366
+ _REFS,
367
+ path,
368
+ job,
369
+ to_unresolved=script,
370
+ status=ResolutionStatus.UNRESOLVED,
371
+ span=span,
372
+ evidence=(evi,),
373
+ )
374
+ for p in positional[1:] if script else positional:
375
+ if p.startswith("-"):
376
+ continue
377
+ s3 = s3_entity(sink, p, path=path, sha=sha, span=span, evi=evi)
378
+ if s3 is not None:
379
+ sink.rel(
380
+ _REFS,
381
+ path,
382
+ job,
383
+ to_identity=s3,
384
+ span=span,
385
+ evidence=(evi,),
386
+ )
387
+
388
+
389
+ def _emit_spark_submit(
390
+ sink: Sink,
391
+ tokens: list[str],
392
+ subject_hint: str,
393
+ *,
394
+ path: str,
395
+ sha: str,
396
+ span: SourceSpan | None,
397
+ step: DomainIdentity | None = None,
398
+ ) -> DomainIdentity:
399
+ """``spark-submit …`` → ``spark_job`` + refs a script/conf."""
400
+ conf, positional = _parse_spark_submit(tokens)
401
+ script = next((p for p in positional if not p.startswith("-")), "")
402
+ evi = sink.evi(path, sha, span, notes="spark-submit")
403
+ qname = subject_hint or _file_key(path)
404
+ attrs: dict[str, str] = {"artifact_kind": "spark-submit"}
405
+ for key in ("arg.class", "arg.master", "arg.deploy-mode", "arg.name"):
406
+ if key in conf:
407
+ attrs[key[4:]] = conf[key]
408
+ _submit_catalogs(sink, conf, attrs, path=path, sha=sha, span=span, evi=evi)
409
+ job = sink.entity(
410
+ kind=DomainEntityKind.SPARK_JOB,
411
+ name=qname,
412
+ qualified_name=qname,
413
+ namespace=posixpath.dirname(path),
414
+ path=path,
415
+ span=span,
416
+ content_hash=sha,
417
+ attributes=attrs,
418
+ evidence=(evi,),
419
+ )
420
+ if step is not None and step.logical_key() != job.logical_key():
421
+ sink.rel(
422
+ _CONTAINS,
423
+ path,
424
+ step,
425
+ to_identity=job,
426
+ span=span,
427
+ evidence=(evi,),
428
+ )
429
+ _submit_script_refs(
430
+ sink,
431
+ script,
432
+ positional,
433
+ job,
434
+ path=path,
435
+ sha=sha,
436
+ span=span,
437
+ evi=evi,
438
+ )
439
+ return job
440
+
441
+
442
+ # -- EMR steps -------------------------------------------------------------
443
+
444
+
445
+ def _step_entity(
446
+ sink: Sink,
447
+ name: str,
448
+ *,
449
+ spark: bool,
450
+ file_stem: str,
451
+ path: str,
452
+ sha: str,
453
+ span: SourceSpan | None,
454
+ attrs: dict[str, str],
455
+ ) -> DomainIdentity:
456
+ kind = DomainEntityKind.SPARK_JOB if spark else DomainEntityKind.DATA_PIPELINE
457
+ qname = f"{file_stem}#{name}"
458
+ merged = {"artifact_kind": "emr-step", "emr_step": "true", **attrs}
459
+ return sink.entity(
460
+ kind=kind,
461
+ name=name,
462
+ qualified_name=qname,
463
+ namespace=posixpath.dirname(path),
464
+ path=path,
465
+ span=span,
466
+ content_hash=sha,
467
+ attributes=merged,
468
+ evidence=(sink.evi(path, sha, span, notes="emr-step"),),
469
+ )
470
+
471
+
472
+ def _emit_step(
473
+ sink: Sink,
474
+ step: dict[str, Any],
475
+ idx: int,
476
+ prev: DomainIdentity | None,
477
+ *,
478
+ path: str,
479
+ sha: str,
480
+ text: str,
481
+ starts: tuple[int, ...],
482
+ ) -> DomainIdentity | None:
483
+ """Um step EMR → ``spark_job`` (spark-submit) ou ``data_pipeline``."""
484
+ name = str(step.get("Name") or f"step-{idx}")
485
+ jarstep = step.get("HadoopJarStep")
486
+ jar = args = None
487
+ if isinstance(jarstep, dict):
488
+ jar = jarstep.get("Jar")
489
+ raw_args = jarstep.get("Args")
490
+ args = [str(a) for a in raw_args] if isinstance(raw_args, (list, tuple)) else None
491
+ span = _json_span(text, starts, name) or None
492
+ attrs: dict[str, str] = {}
493
+ if isinstance(step.get("ActionOnFailure"), str):
494
+ attrs["action_on_failure"] = str(step["ActionOnFailure"])
495
+ if isinstance(jar, str) and jar:
496
+ attrs["jar"] = jar[:200]
497
+ spark = False
498
+ if args:
499
+ joined = " ".join(args)
500
+ if "spark-submit" in joined or "spark_submit" in joined:
501
+ spark = True
502
+ ident = _step_entity(
503
+ sink,
504
+ name,
505
+ spark=spark,
506
+ file_stem=_file_key(path),
507
+ path=path,
508
+ sha=sha,
509
+ span=span,
510
+ attrs=attrs,
511
+ )
512
+ if prev is not None:
513
+ sink.rel(
514
+ _DEPENDS,
515
+ path,
516
+ ident,
517
+ to_identity=prev,
518
+ span=span,
519
+ evidence=(sink.evi(path, sha, span, notes="step-order"),),
520
+ )
521
+ if args:
522
+ tokens = list(args)
523
+ # command-runner.jar ou Jar direto — args[0] é o comando
524
+ if tokens and tokens[0] in ("spark-submit", "spark_submit"):
525
+ _emit_spark_submit(
526
+ sink,
527
+ tokens,
528
+ name,
529
+ path=path,
530
+ sha=sha,
531
+ span=span,
532
+ step=ident,
533
+ )
534
+ else:
535
+ _emit_s3_refs(
536
+ sink,
537
+ args,
538
+ ident,
539
+ path=path,
540
+ sha=sha,
541
+ span=span,
542
+ evi=sink.evi(path, sha, span, notes="step-args"),
543
+ )
544
+ if tokens:
545
+ attrs_cmd = " ".join(tokens[:4])[:120]
546
+ sink.entity_for(
547
+ ident,
548
+ kind=ident_kind(ident, sink),
549
+ name=name,
550
+ path=path,
551
+ span=span,
552
+ content_hash=sha,
553
+ attributes={
554
+ "artifact_kind": "emr-step",
555
+ "emr_step": "true",
556
+ "command": attrs_cmd,
557
+ },
558
+ evidence=(),
559
+ )
560
+ return ident
561
+
562
+
563
+ def ident_kind(ident: DomainIdentity, sink: Sink) -> DomainEntityKind:
564
+ """Recupera o kind já emitido para ``ident`` (merge de attrs)."""
565
+ ent = sink.entities.get(ident.logical_key())
566
+ return ent.kind if ent is not None else DomainEntityKind.DATA_PIPELINE
567
+
568
+
569
+ def _emit_steps(
570
+ sink: Sink,
571
+ steps: list[Any],
572
+ *,
573
+ path: str,
574
+ sha: str,
575
+ text: str,
576
+ starts: tuple[int, ...],
577
+ cluster: DomainIdentity | None = None,
578
+ ) -> int:
579
+ prev: DomainIdentity | None = None
580
+ n = 0
581
+ for i, st in enumerate(steps):
582
+ if not isinstance(st, dict):
583
+ sink.diag(
584
+ "malformed-step",
585
+ "warning",
586
+ f"step #{i} is not an object — skipped",
587
+ path,
588
+ )
589
+ continue
590
+ ident = _emit_step(
591
+ sink,
592
+ st,
593
+ i,
594
+ prev,
595
+ path=path,
596
+ sha=sha,
597
+ text=text,
598
+ starts=starts,
599
+ )
600
+ if ident is not None:
601
+ if cluster is not None:
602
+ sink.rel(
603
+ _CONTAINS,
604
+ path,
605
+ cluster,
606
+ to_identity=ident,
607
+ span=None,
608
+ evidence=(sink.evi(path, sha, None, "cluster-step"),),
609
+ )
610
+ prev = ident
611
+ n += 1
612
+ return n
613
+
614
+
615
+ # -- documentos -------------------------------------------------------------
616
+
617
+
618
+ def _glue_job_entity(
619
+ sink: Sink,
620
+ doc: dict[str, Any],
621
+ *,
622
+ path: str,
623
+ sha: str,
624
+ text: str,
625
+ starts: tuple[int, ...],
626
+ ) -> DomainIdentity | None:
627
+ """``{"Name","Role","Command":{…},"DefaultArguments"}`` → glue_job."""
628
+ name = doc.get("Name")
629
+ if not isinstance(name, str) or not name:
630
+ sink.diag(
631
+ "missing-name",
632
+ "warning",
633
+ "glue job definition without literal Name",
634
+ path,
635
+ )
636
+ return None
637
+ span = _json_span(text, starts, name)
638
+ evi = sink.evi(path, sha, span, notes="glue-job-def")
639
+ attrs: dict[str, str] = {"artifact_kind": "glue-job-definition"}
640
+ cmd = doc.get("Command")
641
+ if isinstance(cmd, dict):
642
+ cname = cmd.get("Name")
643
+ if isinstance(cname, str):
644
+ attrs["command"] = cname
645
+ pver = cmd.get("PythonVersion")
646
+ if isinstance(pver, str):
647
+ attrs["python_version"] = pver
648
+ ident = sink.entity(
649
+ kind=DomainEntityKind.GLUE_JOB,
650
+ name=name,
651
+ qualified_name=name,
652
+ namespace=posixpath.dirname(path),
653
+ path=path,
654
+ span=span,
655
+ content_hash=sha,
656
+ attributes=attrs,
657
+ evidence=(evi,),
658
+ )
659
+ _emit_role_edges(sink, doc, ident, path=path, sha=sha, text=text, starts=starts)
660
+ if isinstance(cmd, dict):
661
+ _job_command_refs(sink, cmd, ident, path=path, sha=sha, span=span, evi=evi)
662
+ _job_default_args(sink, doc, ident, name, path=path, sha=sha, span=span, evi=evi)
663
+ sink.bump("glue_jobs")
664
+ return ident
665
+
666
+
667
+ def _job_command_refs(
668
+ sink: Sink,
669
+ cmd: dict[str, Any],
670
+ ident: DomainIdentity,
671
+ *,
672
+ path: str,
673
+ sha: str,
674
+ span: SourceSpan | None,
675
+ evi: int,
676
+ ) -> None:
677
+ """``Command.ScriptLocation`` → ``REFERENCES`` ao script s3."""
678
+ loc = cmd.get("ScriptLocation")
679
+ if not isinstance(loc, str):
680
+ return
681
+ s3 = s3_entity(sink, loc, path=path, sha=sha, span=span, evi=evi)
682
+ if s3 is not None:
683
+ sink.rel(_REFS, path, ident, to_identity=s3, span=span, evidence=(evi,))
684
+ sink.bump("s3_paths")
685
+
686
+
687
+ def _job_default_args(
688
+ sink: Sink,
689
+ doc: dict[str, Any],
690
+ ident: DomainIdentity,
691
+ name: str,
692
+ *,
693
+ path: str,
694
+ sha: str,
695
+ span: SourceSpan | None,
696
+ evi: int,
697
+ ) -> None:
698
+ """``DefaultArguments`` → attrs ``arg.*`` (redacted) + refs s3."""
699
+ defaults = doc.get("DefaultArguments")
700
+ if not isinstance(defaults, dict):
701
+ return
702
+ extra = {f"arg.{k}": redact_if_sensitive(k, str(v)) for k, v in defaults.items()}
703
+ if extra:
704
+ sink.entity_for(
705
+ ident,
706
+ kind=DomainEntityKind.GLUE_JOB,
707
+ name=name,
708
+ path=path,
709
+ span=span,
710
+ content_hash=sha,
711
+ attributes={"artifact_kind": "glue-job-definition", **extra},
712
+ evidence=(evi,),
713
+ )
714
+ _emit_s3_refs(
715
+ sink,
716
+ list(defaults.values()),
717
+ ident,
718
+ path=path,
719
+ sha=sha,
720
+ span=span,
721
+ evi=evi,
722
+ )
723
+
724
+
725
+ def _emit_trigger(
726
+ sink: Sink,
727
+ doc: dict[str, Any],
728
+ *,
729
+ path: str,
730
+ sha: str,
731
+ text: str,
732
+ starts: tuple[int, ...],
733
+ ) -> int:
734
+ """Glue trigger ``{"Type","Actions":[{JobName}],"Predicate"}``."""
735
+ name = str(doc.get("Name") or _file_key(path))
736
+ span = _json_span(text, starts, name)
737
+ evi = sink.evi(path, sha, span, notes="glue-trigger")
738
+ ttype = str(doc.get("Type") or "")
739
+ attrs = {
740
+ "artifact_kind": "glue-trigger",
741
+ "trigger_type": ttype,
742
+ }
743
+ sched = doc.get("Schedule")
744
+ if isinstance(sched, str):
745
+ attrs["schedule"] = sched[:120]
746
+ trig = sink.entity(
747
+ kind=DomainEntityKind.DATA_PIPELINE,
748
+ name=name,
749
+ qualified_name=f"trigger:{name}",
750
+ namespace=posixpath.dirname(path),
751
+ path=path,
752
+ span=span,
753
+ content_hash=sha,
754
+ attributes=attrs,
755
+ evidence=(evi,),
756
+ )
757
+ n = 0
758
+ actions = doc.get("Actions")
759
+ if isinstance(actions, list):
760
+ n = _trigger_actions(sink, actions, trig, path=path, span=span, evi=evi)
761
+ pred = doc.get("Predicate")
762
+ if isinstance(pred, dict) and isinstance(pred.get("Conditions"), list):
763
+ _trigger_conditions(sink, pred["Conditions"], trig, path=path, span=span, evi=evi)
764
+ return n
765
+
766
+
767
+ def _trigger_actions(
768
+ sink: Sink,
769
+ actions: list[Any],
770
+ trig: DomainIdentity,
771
+ *,
772
+ path: str,
773
+ span: SourceSpan | None,
774
+ evi: int,
775
+ ) -> int:
776
+ """``Actions[].JobName`` → ``TRIGGERS`` (resolvido ou external)."""
777
+ n = 0
778
+ for a in actions:
779
+ if not isinstance(a, dict):
780
+ continue
781
+ jobname = a.get("JobName") or a.get("Job")
782
+ crawler = a.get("CrawlerName")
783
+ target_txt = crawler or jobname
784
+ if not isinstance(target_txt, str) or not target_txt:
785
+ continue
786
+ tgt = sink.by_name.get((DomainEntityKind.GLUE_JOB.value, str(jobname))) if jobname else None
787
+ if tgt is not None:
788
+ sink.rel(
789
+ _TRIGGERS,
790
+ path,
791
+ trig,
792
+ to_identity=tgt,
793
+ span=span,
794
+ evidence=(evi,),
795
+ )
796
+ else:
797
+ label = "glue-crawler" if crawler else "glue-job"
798
+ sink.rel(
799
+ _TRIGGERS,
800
+ path,
801
+ trig,
802
+ to_unresolved=f"{label}:{target_txt}",
803
+ status=ResolutionStatus.EXTERNAL,
804
+ span=span,
805
+ evidence=(evi,),
806
+ )
807
+ n += 1
808
+ return n
809
+
810
+
811
+ def _trigger_conditions(
812
+ sink: Sink,
813
+ conds: list[Any],
814
+ trig: DomainIdentity,
815
+ *,
816
+ path: str,
817
+ span: SourceSpan | None,
818
+ evi: int,
819
+ ) -> None:
820
+ """``Predicate.Conditions[].JobName`` → ``DEPENDS_ON``."""
821
+ for c in conds:
822
+ if not isinstance(c, dict):
823
+ continue
824
+ jn = c.get("JobName") or c.get("CrawlerName")
825
+ if not isinstance(jn, str) or not jn:
826
+ continue
827
+ tgt = sink.by_name.get((DomainEntityKind.GLUE_JOB.value, jn))
828
+ if tgt is not None:
829
+ sink.rel(
830
+ _DEPENDS,
831
+ path,
832
+ trig,
833
+ to_identity=tgt,
834
+ span=span,
835
+ evidence=(evi,),
836
+ )
837
+ else:
838
+ sink.rel(
839
+ _DEPENDS,
840
+ path,
841
+ trig,
842
+ to_unresolved=f"glue-job:{jn}",
843
+ status=ResolutionStatus.EXTERNAL,
844
+ span=span,
845
+ evidence=(evi,),
846
+ )
847
+
848
+
849
+ def _walk_json(
850
+ sink: Sink,
851
+ doc: Any,
852
+ *,
853
+ path: str,
854
+ sha: str,
855
+ text: str,
856
+ starts: tuple[int, ...],
857
+ ) -> None:
858
+ """Despacha um documento JSON para o shape AWS reconhecido."""
859
+ if isinstance(doc, list):
860
+ if doc and all(isinstance(s, dict) and "HadoopJarStep" in s for s in doc):
861
+ _emit_steps(
862
+ sink,
863
+ doc,
864
+ path=path,
865
+ sha=sha,
866
+ text=text,
867
+ starts=starts,
868
+ )
869
+ return
870
+ sink.diag(
871
+ "not-aws-artifact",
872
+ "info",
873
+ "JSON list is not an EMR step config",
874
+ path,
875
+ )
876
+ return
877
+ if not isinstance(doc, dict):
878
+ sink.diag(
879
+ "not-aws-artifact",
880
+ "info",
881
+ "JSON scalar/array without AWS shape",
882
+ path,
883
+ )
884
+ return
885
+
886
+ handled = False
887
+ if (
888
+ isinstance(doc.get("Command"), dict)
889
+ and ("ScriptLocation" in doc["Command"] or "Role" in doc)
890
+ ) or ("ScriptLocation" in doc and ("Role" in doc or "Command" in doc)):
891
+ _glue_job_entity(sink, doc, path=path, sha=sha, text=text, starts=starts)
892
+ handled = True
893
+
894
+ ttype = str(doc.get("Type") or "").upper()
895
+ if ttype in _STEP_TYPES and ("Actions" in doc or "Predicate" in doc):
896
+ _emit_trigger(sink, doc, path=path, sha=sha, text=text, starts=starts)
897
+ handled = True
898
+
899
+ cluster_keys = {
900
+ "JobFlowRole",
901
+ "ServiceRole",
902
+ "AutoScalingRole",
903
+ "Applications",
904
+ "Instances",
905
+ "LogUri",
906
+ "Ec2KeyName",
907
+ }
908
+ cluster = None
909
+ if cluster_keys & set(doc):
910
+ name = str(doc.get("Name") or _file_key(path))
911
+ span = _json_span(text, starts, name)
912
+ evi = sink.evi(path, sha, span, notes="emr-cluster")
913
+ cluster = sink.entity(
914
+ kind=DomainEntityKind.DATA_PIPELINE,
915
+ name=name,
916
+ qualified_name=f"emr-cluster:{name}",
917
+ namespace=posixpath.dirname(path),
918
+ path=path,
919
+ span=span,
920
+ content_hash=sha,
921
+ attributes={"artifact_kind": "emr-cluster-config"},
922
+ evidence=(evi,),
923
+ )
924
+ _emit_role_edges(
925
+ sink,
926
+ doc,
927
+ cluster,
928
+ path=path,
929
+ sha=sha,
930
+ text=text,
931
+ starts=starts,
932
+ )
933
+ handled = True
934
+
935
+ steps = doc.get("Steps")
936
+ if isinstance(steps, list):
937
+ _emit_steps(
938
+ sink,
939
+ steps,
940
+ path=path,
941
+ sha=sha,
942
+ text=text,
943
+ starts=starts,
944
+ cluster=cluster,
945
+ )
946
+ handled = True
947
+ elif "Steps" in doc and not isinstance(steps, list):
948
+ sink.diag(
949
+ "malformed-config",
950
+ "warning",
951
+ "'Steps' is not a list — skipped",
952
+ path,
953
+ )
954
+
955
+ if not handled:
956
+ sink.diag(
957
+ "not-aws-artifact",
958
+ "info",
959
+ "JSON does not match any AWS-data shape — skipped",
960
+ path,
961
+ )
962
+
963
+
964
+ def extract_json(sink: Sink, *, path: str, text: str, sha: str) -> None:
965
+ """Artefato ``.json`` → shapes AWS-data."""
966
+ starts = line_starts(text)
967
+ try:
968
+ doc = json.loads(text)
969
+ except json.JSONDecodeError as exc:
970
+ sink.diag(
971
+ "malformed-config",
972
+ "error",
973
+ f"JSON does not parse: {exc}",
974
+ path,
975
+ )
976
+ return
977
+ _walk_json(sink, doc, path=path, sha=sha, text=text, starts=starts)
978
+ sink.bump("json_files")
979
+
980
+
981
+ def _ynode_py(node: yamlmini.YNode) -> Any:
982
+ """``YNode`` → python dict/list/str (subconjunto honesto)."""
983
+ if node.kind == "map":
984
+ out: dict[str, Any] = {}
985
+ for k, v in node.pairs:
986
+ if k.kind == "scalar":
987
+ out[k.value] = _ynode_py(v)
988
+ return out
989
+ if node.kind == "seq":
990
+ return [_ynode_py(it) for it in node.items]
991
+ if node.kind == "scalar":
992
+ return node.value
993
+ return None
994
+
995
+
996
+ def extract_yaml(sink: Sink, *, path: str, text: str, sha: str) -> None:
997
+ """Artefato ``.yaml``/``.yml`` via ``yamlmini`` (subconjunto)."""
998
+ result = yamlmini.parse(text)
999
+ for yd in result.diagnostics:
1000
+ sink.diag(
1001
+ f"yaml-{yd.code}",
1002
+ "error" if yd.severity == "error" else "warning",
1003
+ f"line {yd.line}: {yd.message}" if yd.line else yd.message,
1004
+ path,
1005
+ )
1006
+ if result.root is None:
1007
+ if not result.diagnostics:
1008
+ sink.diag("not-aws-artifact", "info", "empty YAML document", path)
1009
+ return
1010
+ doc = _ynode_py(result.root)
1011
+ starts = line_starts(text)
1012
+ _walk_json(sink, doc, path=path, sha=sha, text=text, starts=starts)
1013
+ sink.bump("yaml_files")
1014
+
1015
+
1016
+ def _logical_lines(text: str) -> list[tuple[int, str]]:
1017
+ """Junta continuações ``\\`` — pares ``(lineno, linha lógica)``."""
1018
+ logical: list[tuple[int, str]] = []
1019
+ buf = ""
1020
+ buf_line = 0
1021
+ for i, raw in enumerate(text.splitlines(), start=1):
1022
+ stripped = raw.strip()
1023
+ cont = stripped.endswith("\\")
1024
+ piece = stripped[:-1].rstrip() if cont else stripped
1025
+ if buf:
1026
+ buf = f"{buf} {piece}" if piece else buf
1027
+ else:
1028
+ buf = piece
1029
+ buf_line = i
1030
+ if not cont:
1031
+ if buf:
1032
+ logical.append((buf_line, buf))
1033
+ buf = ""
1034
+ if buf:
1035
+ logical.append((buf_line, buf))
1036
+ return logical
1037
+
1038
+
1039
+ def _shell_submit_line(
1040
+ sink: Sink,
1041
+ lineno: int,
1042
+ line: str,
1043
+ *,
1044
+ path: str,
1045
+ sha: str,
1046
+ starts: tuple[int, ...],
1047
+ ) -> bool:
1048
+ """Uma linha lógica com ``spark-submit`` → ``spark_job``."""
1049
+ code = line.split("#", 1)[0]
1050
+ try:
1051
+ tokens = shlex.split(code, posix=True)
1052
+ except ValueError:
1053
+ sink.diag(
1054
+ "malformed-config",
1055
+ "warning",
1056
+ f"line {lineno}: spark-submit line does not lex",
1057
+ path,
1058
+ )
1059
+ return False
1060
+ if "spark-submit" not in tokens:
1061
+ return False
1062
+ tokens = tokens[tokens.index("spark-submit") :]
1063
+ if "$" in " ".join(tokens):
1064
+ tokens = [t if "$" not in t else "<dynamic>" for t in tokens]
1065
+ line_b = len(line.encode("utf-8"))
1066
+ span = byte_span(starts, starts[lineno - 1], starts[lineno - 1] + line_b)
1067
+ _emit_spark_submit(sink, tokens, "", path=path, sha=sha, span=span)
1068
+ return True
1069
+
1070
+
1071
+ def extract_shell(sink: Sink, *, path: str, text: str, sha: str) -> None:
1072
+ """``.sh`` com linhas ``spark-submit`` → ``spark_job``."""
1073
+ starts = line_starts(text)
1074
+ found = 0
1075
+ for lineno, line in _logical_lines(text):
1076
+ if "spark-submit" not in line:
1077
+ continue
1078
+ found += _shell_submit_line(sink, lineno, line, path=path, sha=sha, starts=starts)
1079
+ if not found:
1080
+ sink.diag(
1081
+ "not-aws-artifact",
1082
+ "info",
1083
+ "shell script without spark-submit lines",
1084
+ path,
1085
+ )
1086
+ sink.bump("shell_files")
1087
+
1088
+
1089
+ def _conf_catalog_key(
1090
+ sink: Sink,
1091
+ key: str,
1092
+ val: str,
1093
+ *,
1094
+ path: str,
1095
+ sha: str,
1096
+ span: SourceSpan | None,
1097
+ ) -> int:
1098
+ """``spark.sql.catalog.<n>[.prop]=v`` — declaração ou warehouse."""
1099
+ cat, _, prop = key[len("spark.sql.catalog.") :].partition(".")
1100
+ if prop:
1101
+ cat_ident = sink.catalogs.get(cat)
1102
+ if prop == "warehouse" and parse_s3_uri(val) and cat_ident is not None:
1103
+ evi = sink.evi(path, sha, span, notes="catalog-warehouse")
1104
+ s3 = s3_entity(sink, val, path=path, sha=sha, span=span, evi=evi)
1105
+ if s3 is not None:
1106
+ sink.rel(
1107
+ _REFS,
1108
+ path,
1109
+ cat_ident,
1110
+ to_identity=s3,
1111
+ span=span,
1112
+ evidence=(evi,),
1113
+ )
1114
+ return 0
1115
+ evi = sink.evi(path, sha, span, notes="spark-catalog-conf")
1116
+ ident = sink.entity(
1117
+ kind=DomainEntityKind.DATA_CATALOG,
1118
+ name=cat,
1119
+ qualified_name=cat,
1120
+ path=path,
1121
+ span=span,
1122
+ content_hash=sha,
1123
+ attributes={
1124
+ "declared_by": path,
1125
+ "catalog_impl": redact_if_sensitive(key, val),
1126
+ **({"engine": "iceberg"} if is_iceberg_impl(val) else {}),
1127
+ },
1128
+ evidence=(evi,),
1129
+ )
1130
+ sink.register_catalog(cat, ident)
1131
+ return 1
1132
+
1133
+
1134
+ def extract_spark_conf(sink: Sink, *, path: str, text: str, sha: str) -> None:
1135
+ """``.conf``/``.properties`` com chaves ``spark.*`` → catálogos."""
1136
+ starts = line_starts(text)
1137
+ seen = False
1138
+ catalogs = 0
1139
+ for i, raw in enumerate(text.splitlines(), start=1):
1140
+ line = raw.strip()
1141
+ if not line or line.startswith("#"):
1142
+ continue
1143
+ m = re.match(r"^([A-Za-z0-9._-]+)\s*[= ]\s*(.*)$", line)
1144
+ if m is None:
1145
+ continue
1146
+ key, val = m.group(1), m.group(2).strip()
1147
+ if not key.startswith("spark."):
1148
+ continue
1149
+ seen = True
1150
+ if not key.startswith("spark.sql.catalog."):
1151
+ continue
1152
+ raw_b = len(raw.encode("utf-8"))
1153
+ span = byte_span(starts, starts[i - 1], starts[i - 1] + raw_b)
1154
+ catalogs += _conf_catalog_key(sink, key, val, path=path, sha=sha, span=span)
1155
+ if not seen:
1156
+ sink.diag(
1157
+ "not-aws-artifact",
1158
+ "info",
1159
+ "conf/properties without spark.* keys",
1160
+ path,
1161
+ )
1162
+ else:
1163
+ sink.bump("spark_confs")
1164
+ sink.bump("catalogs", catalogs)