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,1277 @@
1
+ """Núcleo de extração do adapter Terraform (lf-0115, Phase 7).
2
+
3
+ Fluxo: artefatos ``.tf``/``.tf.json``/``.tfvars``(.json) do request →
4
+ parse estrutural → entidades ``TERRAFORM_RESOURCE``/``TERRAFORM_MODULE``/
5
+ ``IAM_ROLE`` + relações ``DEPENDS_ON``/``PROVISIONS``/``USES_RESOURCE``/
6
+ ``ASSUMES_ROLE``/``REFERENCES``.
7
+
8
+ Limites declarados (evidence-first, sem execução):
9
+
10
+ - identidade de recurso = ``type.name`` + module path (``namespace``)
11
+ + ``environment`` quando estaticamente conhecido
12
+ (``request.environment`` ou default literal de variável de ambiente);
13
+ - ``depends_on`` explícito + refs implícitas ``tipo.nome`` →
14
+ ``DEPENDS_ON``; refs dentro de atributos de documento (policy,
15
+ definition, statement…) → ``USES_RESOURCE``; refs ``data.tipo.nome``
16
+ → ``REFERENCES``; leituras ``terraform_remote_state``, ``backend`` e
17
+ ``module`` source remoto → ``REFERENCES`` com resolução ``external``;
18
+ - ``count``/``for_each`` → ``multiplicity=dynamic`` declarado — nenhuma
19
+ expansão fabricada;
20
+ - defaults/atribuições com chave casando ``password|secret|token|key|
21
+ credential`` nunca saem — registrados como ``[REDACTED]``;
22
+ - ``${…}`` em qualquer valor persistido → ``${dynamic}``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import bisect
28
+ import hashlib
29
+ import posixpath
30
+ import re
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+
34
+ from code_atlas.contracts.enums import ConfidenceClass, ResolutionStatus
35
+ from code_atlas.discovery.scanner import (
36
+ artifact_path_confined,
37
+ read_file_verified,
38
+ )
39
+ from code_atlas.domains.contracts import (
40
+ DomainDiagnostic,
41
+ DomainExtractionRequest,
42
+ DomainExtractionResult,
43
+ ExtractedDomainEntity,
44
+ ExtractedDomainRelation,
45
+ )
46
+ from code_atlas.domains.evidence import domain_extractor_id, evidence_record
47
+ from code_atlas.domains.identity import DomainIdentity
48
+ from code_atlas.domains.model import DomainEntityKind, DomainRelationKind
49
+ from code_atlas.domains.resolution import DomainResolution
50
+ from code_atlas.domains.terraform.catalog import provider_of, resource_class
51
+ from code_atlas.domains.terraform.hcl import HclBlock, HclFile, parse_hcl
52
+ from code_atlas.domains.terraform.refs import (
53
+ RefMatch,
54
+ is_sensitive_name,
55
+ looks_like_secret_value,
56
+ sanitize_value,
57
+ scan_refs,
58
+ )
59
+ from code_atlas.domains.terraform.tfjson import parse_tfjson
60
+ from code_atlas.domains.terraform.tfvars import parse_tfvars, parse_tfvars_json
61
+ from code_atlas.parsers.contracts import EvidenceRecord, SourceSpan
62
+
63
+ DOMAIN_ID = "terraform"
64
+
65
+ # Atributos cujo conteúdo é documento/payload — refs dentro deles são
66
+ # uso semântico do recurso (ARN em policy/definition), não wiring de
67
+ # dependência direta.
68
+ _DOC_ATTRS = frozenset(
69
+ {
70
+ "policy", "policy_document", "assume_role_policy", "definition",
71
+ "document", "inline_policy", "statement", "statements", "json",
72
+ "template", "body", "user_data", "container_definitions", "input",
73
+ "resources", "actions", "principals", "identifiers",
74
+ "managed_policy_arns", "policy_arns", "values",
75
+ }
76
+ )
77
+
78
+ # Atributos que carregam uma role — ref a ``aws_iam_role`` nesses attrs
79
+ # é ``ASSUMES_ROLE`` (o recurso opera sob aquela identidade).
80
+ _ROLE_ATTRS = frozenset(
81
+ {
82
+ "role", "role_arn", "service_role", "service_role_arn",
83
+ "task_role", "task_role_arn", "execution_role_arn",
84
+ "job_role_arn", "instance_role", "instance_profile",
85
+ "delivery_stream_role_arn", "crawler_role", "role_name",
86
+ "service_linked_role_arn",
87
+ }
88
+ )
89
+
90
+ # Attachments vinculam policy↔role — não é "assumir" a role.
91
+ _ROLE_BINDING_TYPES = frozenset(
92
+ {"aws_iam_role_policy_attachment", "aws_iam_policy_attachment"}
93
+ )
94
+
95
+ # Variáveis que qualificam ambiente quando o default é literal estático.
96
+ _ENV_VAR_NAMES = frozenset({"environment", "env", "stage", "workspace"})
97
+
98
+ # Princípios de serviço literais em trust policies ("Service": "x").
99
+ _SERVICE_PRINCIPAL_RE = re.compile(
100
+ r'"?Service"?\s*[:=]\s*(?:"([^"\[\]]+?)"|\[\s*"([^"\[\]]+?)")'
101
+ )
102
+
103
+ _MAX_LISTED = 40
104
+ _MAX_DEPTH = 8
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class _Parsed:
109
+ """Artefato parseado — conteúdo lido somente via ``base_path``."""
110
+
111
+ path: str # rel POSIX
112
+ directory: str # dir POSIX rel ("" = raiz do recorte)
113
+ sha256: str
114
+ kind: str # tf | tfjson | tfvars
115
+ parsed: HclFile
116
+ tfvars: tuple[tuple[str, str, bool], ...] = () # (nome, valor, redigido)
117
+ line_starts: tuple[int, ...] = () # byte offsets de "\n"
118
+
119
+
120
+ def _line_starts(text: str) -> tuple[int, ...]:
121
+ """Byte offsets do início de cada linha (para spans de refs)."""
122
+ starts = [0]
123
+ off = 0
124
+ for ch in text:
125
+ off += len(ch.encode("utf-8"))
126
+ if ch == "\n":
127
+ starts.append(off)
128
+ return tuple(starts)
129
+
130
+
131
+ def _byte_span(
132
+ line_starts: tuple[int, ...], start: int, end: int
133
+ ) -> SourceSpan:
134
+ """Byte offsets → ``SourceSpan`` com linha/col calculados."""
135
+ li = bisect.bisect_right(line_starts, start) - 1
136
+ lj = bisect.bisect_right(line_starts, end) - 1
137
+ return SourceSpan(
138
+ start_byte=start, end_byte=end,
139
+ start_line=li + 1, start_col=start - line_starts[li],
140
+ end_line=lj + 1, end_col=end - line_starts[lj],
141
+ )
142
+
143
+
144
+ @dataclass
145
+ class _Builder:
146
+ """Acumulador do resultado — evidência/entidades/relações/diags."""
147
+
148
+ request: DomainExtractionRequest
149
+ adapter_version: str
150
+ evidence: list[EvidenceRecord] = field(default_factory=list)
151
+ _evi_index: dict[tuple[str, tuple[int, ...] | None], int] = field(
152
+ default_factory=dict
153
+ )
154
+ entities: list[ExtractedDomainEntity] = field(default_factory=list)
155
+ relations: list[ExtractedDomainRelation] = field(default_factory=list)
156
+ diagnostics: list[DomainDiagnostic] = field(default_factory=list)
157
+ stats: dict[str, int] = field(default_factory=dict)
158
+ id_by_addr: dict[tuple[str, str], DomainIdentity] = field(
159
+ default_factory=dict
160
+ )
161
+ mod_addr: dict[str, DomainIdentity] = field(default_factory=dict)
162
+
163
+ def evi(self, path: str, sha: str, span: SourceSpan | None) -> int:
164
+ """Índice de evidência dedupado por (path, span)."""
165
+ key = (
166
+ path,
167
+ None
168
+ if span is None
169
+ else (
170
+ span.start_byte, span.end_byte, span.start_line,
171
+ span.start_col, span.end_line, span.end_col,
172
+ ),
173
+ )
174
+ idx = self._evi_index.get(key)
175
+ if idx is not None:
176
+ return idx
177
+ self.evidence.append(
178
+ evidence_record(
179
+ adapter_domain=DOMAIN_ID,
180
+ adapter_version=self.adapter_version,
181
+ path=path, content_hash=sha, span=span,
182
+ )
183
+ )
184
+ idx = len(self.evidence) - 1
185
+ self._evi_index[key] = idx
186
+ return idx
187
+
188
+ def diag(
189
+ self, code: str, severity: str, message: str, path: str = ""
190
+ ) -> None:
191
+ self.diagnostics.append(
192
+ DomainDiagnostic(
193
+ code=code, severity=severity, message=message,
194
+ path=path, adapter_id=DOMAIN_ID,
195
+ )
196
+ )
197
+
198
+ def ident(self, namespace: str, env: str, qname: str) -> DomainIdentity:
199
+ return DomainIdentity(
200
+ domain=DOMAIN_ID,
201
+ repository=self.request.repository,
202
+ root=self.request.root,
203
+ namespace=namespace,
204
+ environment=env,
205
+ qualified_name=qname,
206
+ )
207
+
208
+ def _res(
209
+ self,
210
+ status: ResolutionStatus,
211
+ *,
212
+ target: DomainIdentity | None = None,
213
+ unresolved_text: str = "",
214
+ ) -> DomainResolution:
215
+ return DomainResolution(
216
+ status=status,
217
+ confidence_class=ConfidenceClass.SYNTACTIC,
218
+ resolver=domain_extractor_id(DOMAIN_ID),
219
+ target=target,
220
+ unresolved_text=unresolved_text,
221
+ )
222
+
223
+ def rel_to_entity(
224
+ self,
225
+ rtype: str,
226
+ path: str,
227
+ from_id: DomainIdentity,
228
+ namespace: str,
229
+ qname: str,
230
+ span: SourceSpan | None,
231
+ evi: tuple[int, ...],
232
+ ) -> None:
233
+ """Aresta para alvo interno — resolve contra entidades extraídas."""
234
+ target = self.id_by_addr.get((namespace, qname))
235
+ if target is not None:
236
+ self.relations.append(
237
+ ExtractedDomainRelation(
238
+ relation_type=rtype, path=path,
239
+ from_identity=from_id, to_identity=target,
240
+ resolution=self._res(
241
+ ResolutionStatus.RESOLVED, target=target,
242
+ ),
243
+ span=span, evidence=evi,
244
+ )
245
+ )
246
+ return
247
+ text = f"{namespace}.{qname}" if namespace else qname
248
+ self.relations.append(
249
+ ExtractedDomainRelation(
250
+ relation_type=rtype, path=path,
251
+ from_identity=from_id,
252
+ to_unresolved=text,
253
+ resolution=self._res(
254
+ ResolutionStatus.UNRESOLVED, unresolved_text=text,
255
+ ),
256
+ span=span, evidence=evi,
257
+ )
258
+ )
259
+ self.stats["unresolved_targets"] = (
260
+ self.stats.get("unresolved_targets", 0) + 1
261
+ )
262
+
263
+ def rel_external(
264
+ self,
265
+ rtype: str,
266
+ path: str,
267
+ from_id: DomainIdentity,
268
+ text: str,
269
+ span: SourceSpan | None,
270
+ evi: tuple[int, ...],
271
+ ) -> None:
272
+ """Aresta para alvo fora do snapshot — ``external`` declarado."""
273
+ clean = sanitize_value(text)
274
+ self.relations.append(
275
+ ExtractedDomainRelation(
276
+ relation_type=rtype, path=path,
277
+ from_identity=from_id,
278
+ to_unresolved=clean,
279
+ resolution=self._res(
280
+ ResolutionStatus.EXTERNAL, unresolved_text=clean,
281
+ ),
282
+ span=span, evidence=evi,
283
+ )
284
+ )
285
+ self.stats["external_refs"] = self.stats.get("external_refs", 0) + 1
286
+
287
+
288
+ # ---------------------------------------------------------------------
289
+ # leitura e roteamento de artefatos
290
+ # ---------------------------------------------------------------------
291
+
292
+
293
+ def _artifact_kind(path: str) -> str:
294
+ lower = path.lower()
295
+ if lower.endswith(".tfvars.json"):
296
+ return "tfvars"
297
+ if lower.endswith(".tf.json"):
298
+ return "tfjson"
299
+ if lower.endswith(".tfvars"):
300
+ return "tfvars"
301
+ if lower.endswith(".tf"):
302
+ return "tf"
303
+ return ""
304
+
305
+
306
+ def _read_one(
307
+ b: _Builder, rel_path: str, base_path: Path | None
308
+ ) -> bytes | None:
309
+ """Bytes do artefato — ``None`` + diag em falha/fora-da-raiz.
310
+
311
+ Com ``base_path`` o path do request é hostil: absoluto, ``..`` ou
312
+ symlink trocado nunca sai da raiz (``artifact_path_confined`` +
313
+ ``read_file_verified``, abertura O_NOFOLLOW — lf-c8 F7).
314
+ """
315
+ if base_path is None:
316
+ try:
317
+ return Path(rel_path).read_bytes()
318
+ except OSError as exc:
319
+ b.diag(
320
+ "artifact-unreadable", "error",
321
+ f"cannot read {rel_path}: {exc}", rel_path,
322
+ )
323
+ return None
324
+ base = base_path.resolve()
325
+ fs = artifact_path_confined(rel_path, base)
326
+ if fs is None:
327
+ b.diag(
328
+ "artifact-outside-root", "error",
329
+ f"artifact path escapes repository root: {rel_path}",
330
+ rel_path,
331
+ )
332
+ return None
333
+ raw = read_file_verified(fs, base)
334
+ if raw is None:
335
+ b.diag(
336
+ "artifact-unreadable", "error",
337
+ f"cannot read {rel_path}: unsafe or vanished",
338
+ rel_path,
339
+ )
340
+ return None
341
+ return raw
342
+
343
+
344
+ def _read_artifacts(
345
+ b: _Builder, base_path: Path | None
346
+ ) -> list[_Parsed]:
347
+ out: list[_Parsed] = []
348
+ for art in b.request.artifacts:
349
+ kind = _artifact_kind(art.path)
350
+ if not kind:
351
+ b.diag(
352
+ "unsupported-artifact", "warning",
353
+ f"artifact not handled by terraform adapter: {art.path}",
354
+ art.path,
355
+ )
356
+ continue
357
+ raw = _read_one(b, art.path, base_path)
358
+ if raw is None:
359
+ continue
360
+ sha = hashlib.sha256(raw).hexdigest()
361
+ try:
362
+ text = raw.decode("utf-8")
363
+ except UnicodeDecodeError:
364
+ text = raw.decode("utf-8", errors="replace")
365
+ b.diag(
366
+ "encoding-warning", "warning",
367
+ "non-UTF-8 bytes replaced during decode", art.path,
368
+ )
369
+ tfvars: tuple[tuple[str, str, bool], ...] = ()
370
+ if kind == "tf":
371
+ parsed = parse_hcl(text)
372
+ elif kind == "tfjson":
373
+ jf = parse_tfjson(text)
374
+ parsed = HclFile(blocks=jf.blocks, diagnostics=jf.diagnostics)
375
+ else:
376
+ tv = (
377
+ parse_tfvars_json(text)
378
+ if art.path.lower().endswith(".json")
379
+ else parse_tfvars(text)
380
+ )
381
+ parsed = HclFile(diagnostics=tv.diagnostics)
382
+ tfvars = tuple((a.name, a.value, a.redacted) for a in tv.assignments)
383
+ for d in parsed.diagnostics:
384
+ b.diag(
385
+ d.code,
386
+ "error" if "unterminated" in d.code or "error" in d.code
387
+ else "warning",
388
+ f"{d.message} (line {d.line})" if d.line else d.message,
389
+ art.path,
390
+ )
391
+ out.append(
392
+ _Parsed(
393
+ path=art.path, directory=posixpath.dirname(art.path),
394
+ sha256=sha, kind=kind, parsed=parsed, tfvars=tfvars,
395
+ line_starts=_line_starts(text),
396
+ )
397
+ )
398
+ b.stats[f"{kind}_files"] = b.stats.get(f"{kind}_files", 0) + 1
399
+ return out
400
+
401
+
402
+ # ---------------------------------------------------------------------
403
+ # módulos: caminhos de instanciação resolvidos estaticamente
404
+ # ---------------------------------------------------------------------
405
+
406
+
407
+ def _unquote(value: str) -> str:
408
+ v = value.strip()
409
+ if len(v) >= 2 and v.startswith('"') and v.endswith('"'):
410
+ return v[1:-1]
411
+ return v
412
+
413
+
414
+ def _norm_source(directory: str, source: str) -> str:
415
+ """Source local (``./``/``../``) → dir POSIX normalizado; "" senão."""
416
+ if not source.startswith(("./", "../")):
417
+ return ""
418
+ joined = posixpath.normpath(posixpath.join(directory, source))
419
+ return "" if joined == "." else joined
420
+
421
+
422
+ @dataclass(frozen=True)
423
+ class _ModuleCall:
424
+ name: str
425
+ directory: str # dir do arquivo que contém o bloco
426
+ source: str # valor bruto (unquoted)
427
+ local_dir: str # dir normalizado quando source é local, "" senão
428
+ block: HclBlock
429
+ parsed: _Parsed
430
+
431
+
432
+ def _module_calls(parsed: list[_Parsed]) -> list[_ModuleCall]:
433
+ calls: list[_ModuleCall] = []
434
+ for p in parsed:
435
+ if p.kind not in ("tf", "tfjson"):
436
+ continue
437
+ for blk in p.parsed.blocks:
438
+ if blk.type != "module" or not blk.labels:
439
+ continue
440
+ src_attr = blk.attribute("source")
441
+ source = _unquote(src_attr.value) if src_attr else ""
442
+ calls.append(
443
+ _ModuleCall(
444
+ name=blk.labels[0], directory=p.directory,
445
+ source=source,
446
+ local_dir=_norm_source(p.directory, source),
447
+ block=blk, parsed=p,
448
+ )
449
+ )
450
+ return calls
451
+
452
+
453
+ def _module_addresses(
454
+ parsed: list[_Parsed], calls: list[_ModuleCall]
455
+ ) -> dict[str, tuple[str, ...]]:
456
+ """dir → endereços terraform estáticos ("" = root module).
457
+
458
+ Fixpoint sobre chamadas com source local: ``module x {source="./m"}``
459
+ num diretório com endereço ``a`` dá ao dir ``m`` o endereço
460
+ ``a.module.x``. Dirs não alcançados → ``("unresolved:<dir>",)``.
461
+ Um dir instanciado por N chamadas produz N endereços — nenhuma
462
+ expansão é fabricada, os endereços são literais.
463
+ """
464
+ tf_dirs = sorted(
465
+ {p.directory for p in parsed if p.kind in ("tf", "tfjson")}
466
+ )
467
+ targeted = {c.local_dir for c in calls if c.local_dir}
468
+ candidates = [d for d in tf_dirs if d not in targeted]
469
+ pool = candidates or tf_dirs
470
+ root = min(pool, key=lambda d: (d.count("/"), d), default="")
471
+ addrs: dict[str, set[str]] = {d: set() for d in tf_dirs}
472
+ if root in addrs:
473
+ addrs[root].add("")
474
+ for _ in range(len(tf_dirs) + 2):
475
+ grew = False
476
+ for c in calls:
477
+ if not c.local_dir or c.local_dir not in addrs:
478
+ continue
479
+ for base in sorted(addrs.get(c.directory, ())):
480
+ addr = (
481
+ f"{base}.module.{c.name}" if base else f"module.{c.name}"
482
+ )
483
+ if addr not in addrs[c.local_dir]:
484
+ addrs[c.local_dir].add(addr)
485
+ grew = True
486
+ if not grew:
487
+ break
488
+ return {
489
+ d: tuple(sorted(v)) if v else (f"unresolved:{d}",)
490
+ for d, v in addrs.items()
491
+ }
492
+
493
+
494
+ def _dir_env(
495
+ b: _Builder, parsed: list[_Parsed]
496
+ ) -> dict[str, tuple[str, str]]:
497
+ """dir → ``(environment, source)`` — env estático por diretório.
498
+
499
+ ``request.environment`` vence sempre; depois, default literal de
500
+ variável de ambiente (``environment|env|stage|workspace``).
501
+ """
502
+ env: dict[str, tuple[str, str]] = {}
503
+ if b.request.environment:
504
+ for p in parsed:
505
+ env[p.directory] = (b.request.environment, "request")
506
+ return env
507
+ for p in parsed:
508
+ if p.kind not in ("tf", "tfjson") or p.directory in env:
509
+ continue
510
+ for blk in p.parsed.blocks:
511
+ raw = _env_var_default(blk)
512
+ if raw is not None:
513
+ env[p.directory] = (raw, "variable-default")
514
+ break
515
+ return env
516
+
517
+
518
+ def _env_var_default(blk: HclBlock) -> str | None:
519
+ """Default literal de variável de ambiente — ``None`` se ausente
520
+ ou não-literal (interpolação nunca é avaliada como valor)."""
521
+ if blk.type != "variable" or not blk.labels:
522
+ return None
523
+ if blk.labels[0].lower() not in _ENV_VAR_NAMES:
524
+ return None
525
+ default = blk.attribute("default")
526
+ if default is None:
527
+ return None
528
+ raw = _unquote(default.value)
529
+ if "${" in raw or not raw or len(raw) > 64:
530
+ return None
531
+ if not all(ch.isalnum() or ch in "._-" for ch in raw):
532
+ return None
533
+ # default com formato de credencial (``environment = "AKIA…"``)
534
+ # não é nome de ambiente — recusado, nunca redigido como env (F5).
535
+ if looks_like_secret_value(raw):
536
+ return None
537
+ return raw
538
+
539
+
540
+ # ---------------------------------------------------------------------
541
+ # entidades
542
+ # ---------------------------------------------------------------------
543
+
544
+
545
+ def _multiplicity(block: HclBlock) -> str:
546
+ """count/for_each → multiplicidade declarada, nunca expandida."""
547
+ if (
548
+ block.attribute("count") is not None
549
+ or block.attribute("for_each") is not None
550
+ ):
551
+ return "dynamic"
552
+ return "single"
553
+
554
+
555
+ def _trusted_services(block: HclBlock, _depth: int = 0) -> list[str]:
556
+ """Principals ``Service:`` literais do trust policy — strings curtas."""
557
+ found: list[str] = []
558
+ for attr in block.attributes:
559
+ if attr.name != "assume_role_policy":
560
+ continue
561
+ for m in _SERVICE_PRINCIPAL_RE.finditer(attr.value):
562
+ svc = m.group(1) or m.group(2) or ""
563
+ if svc and svc not in found:
564
+ found.append(svc)
565
+ if _depth < _MAX_DEPTH:
566
+ for child in block.children:
567
+ for svc in _trusted_services(child, _depth + 1):
568
+ if svc not in found:
569
+ found.append(svc)
570
+ return sorted(found)
571
+
572
+
573
+ def _resource_attrs(block: HclBlock, ns: str, env_src: str) -> dict[str, str]:
574
+ rtype, rname = block.labels[0], block.labels[1]
575
+ attrs = {
576
+ "resource_type": rtype,
577
+ "resource_name": rname,
578
+ "resource_class": resource_class(rtype),
579
+ "provider": provider_of(rtype),
580
+ "module_path": ns,
581
+ "multiplicity": _multiplicity(block),
582
+ "mode": "managed",
583
+ }
584
+ if env_src:
585
+ attrs["env_source"] = env_src
586
+ return attrs
587
+
588
+
589
+ def _is_sensitive_var(blk: HclBlock) -> bool:
590
+ """Variável sensível: nome casa o padrão de segredos ou
591
+ ``sensitive = true`` declarado."""
592
+ if is_sensitive_name(blk.labels[0]):
593
+ return True
594
+ sattr = blk.attribute("sensitive")
595
+ return sattr is not None and _unquote(sattr.value) == "true"
596
+
597
+
598
+ def _scan_var_block(
599
+ b: _Builder,
600
+ blk: HclBlock,
601
+ names: list[str],
602
+ sensitive: list[str],
603
+ ) -> str:
604
+ """Um bloco ``variable``: registra nome/flag sensível e devolve
605
+ env-default literal (``""`` quando sensível ou não-literal)."""
606
+ vname = blk.labels[0]
607
+ names.append(vname)
608
+ if _is_sensitive_var(blk):
609
+ sensitive.append(vname)
610
+ b.stats["secrets_redacted"] = (
611
+ b.stats.get("secrets_redacted", 0) + 1
612
+ )
613
+ return ""
614
+ return _env_var_default(blk) or ""
615
+
616
+
617
+ def _attach_var_attrs(
618
+ b: _Builder,
619
+ parsed: list[_Parsed],
620
+ directory: str,
621
+ attrs: dict[str, str],
622
+ env_hints: dict[str, list[str]],
623
+ ) -> None:
624
+ """Variáveis do módulo (dir): nomes + flags — defaults sensíveis
625
+ nunca saem (``[REDACTED]``), interpolações viram ``${dynamic}``."""
626
+ names: list[str] = []
627
+ sensitive: list[str] = []
628
+ env_default = ""
629
+ for p in parsed:
630
+ if p.directory != directory or p.kind not in ("tf", "tfjson"):
631
+ continue
632
+ for blk in p.parsed.blocks:
633
+ if blk.type != "variable" or not blk.labels:
634
+ continue
635
+ default = _scan_var_block(b, blk, names, sensitive)
636
+ if default:
637
+ env_default = default
638
+ _var_summary_attrs(
639
+ attrs, names, sensitive, env_default, env_hints.get(directory)
640
+ )
641
+
642
+
643
+ def _var_summary_attrs(
644
+ attrs: dict[str, str],
645
+ names: list[str],
646
+ sensitive: list[str],
647
+ env_default: str,
648
+ hints: list[str] | None,
649
+ ) -> None:
650
+ """Grava no ``attrs`` do módulo o resumo das variáveis declaradas."""
651
+ if names:
652
+ shown = sorted(set(names))[:_MAX_LISTED]
653
+ attrs["variables"] = ",".join(shown)
654
+ if len(set(names)) > _MAX_LISTED:
655
+ attrs["variables_truncated"] = "true"
656
+ if sensitive:
657
+ attrs["sensitive_variables"] = ",".join(sorted(set(sensitive)))
658
+ if env_default:
659
+ attrs["env_default"] = env_default
660
+ if hints:
661
+ attrs["tfvars_env_hints"] = ",".join(sorted(hints))
662
+
663
+
664
+ def _source_kind(source: str) -> str:
665
+ if source.startswith(("./", "../")):
666
+ return "local"
667
+ if source.startswith(("git::", "github.com/")) or ".git" in source:
668
+ return "git"
669
+ if source.startswith(("s3::", "s3://")):
670
+ return "s3"
671
+ if source.startswith(("gcs::", "gs://")):
672
+ return "gcs"
673
+ if source.startswith(("http://", "https://")):
674
+ return "http"
675
+ return "registry"
676
+
677
+
678
+ def _module_attrs(
679
+ block: HclBlock, ns: str, env_src: str, source: str
680
+ ) -> dict[str, str]:
681
+ attrs: dict[str, str] = {
682
+ "module_path": ns,
683
+ "multiplicity": _multiplicity(block),
684
+ }
685
+ if env_src:
686
+ attrs["env_source"] = env_src
687
+ if source:
688
+ attrs["source"] = sanitize_value(source)
689
+ attrs["source_kind"] = _source_kind(source)
690
+ return attrs
691
+
692
+
693
+ def _emit_entities(
694
+ b: _Builder,
695
+ parsed: list[_Parsed],
696
+ addresses: dict[str, tuple[str, ...]],
697
+ env: dict[str, tuple[str, str]],
698
+ env_hints: dict[str, list[str]],
699
+ ) -> None:
700
+ """Entidades de ``resource``/``data``/``module`` + módulo root."""
701
+ for p in parsed:
702
+ if p.kind not in ("tf", "tfjson"):
703
+ continue
704
+ p_env, p_src = env.get(p.directory, ("", ""))
705
+ for ns in addresses.get(p.directory, (f"unresolved:{p.directory}",)):
706
+ for blk in p.parsed.blocks:
707
+ evi = b.evi(p.path, p.sha256, blk.header_span or blk.span)
708
+ if blk.type == "resource" and len(blk.labels) >= 2:
709
+ _emit_resource(b, p, blk, ns, p_env, p_src, evi)
710
+ elif blk.type == "data" and len(blk.labels) >= 2:
711
+ _emit_data(b, p, blk, ns, p_env, p_src, evi)
712
+ elif blk.type == "module" and blk.labels:
713
+ _emit_module(
714
+ b, p, blk, ns, parsed, env, env_hints, evi
715
+ )
716
+ elif blk.type in ("resource", "data", "module"):
717
+ b.diag(
718
+ "malformed-block", "error",
719
+ f"{blk.type} block missing labels", p.path,
720
+ )
721
+ _emit_root_module(b, parsed, addresses, env, env_hints)
722
+
723
+
724
+ def _emit_resource(
725
+ b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
726
+ env_val: str, env_src: str, evi: int,
727
+ ) -> None:
728
+ rtype, rname = blk.labels[0], blk.labels[1]
729
+ kind = (
730
+ DomainEntityKind.IAM_ROLE
731
+ if rtype == "aws_iam_role"
732
+ else DomainEntityKind.TERRAFORM_RESOURCE
733
+ )
734
+ qname = f"{rtype}.{rname}"
735
+ ident = b.ident(ns, env_val, qname)
736
+ attrs = _resource_attrs(blk, ns, env_src)
737
+ if kind is DomainEntityKind.IAM_ROLE:
738
+ svc = _trusted_services(blk)
739
+ if svc:
740
+ attrs["trusted_services"] = ",".join(svc)
741
+ b.entities.append(
742
+ ExtractedDomainEntity(
743
+ identity=ident, kind=kind, name=qname,
744
+ path=p.path, span=blk.span, content_hash=p.sha256,
745
+ attributes=attrs, evidence=(evi,),
746
+ )
747
+ )
748
+ b.id_by_addr[(ns, qname)] = ident
749
+ key = "iam_roles" if kind is DomainEntityKind.IAM_ROLE else "resources"
750
+ b.stats[key] = b.stats.get(key, 0) + 1
751
+
752
+
753
+ def _emit_data(
754
+ b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
755
+ env_val: str, env_src: str, evi: int,
756
+ ) -> None:
757
+ rtype, rname = blk.labels[0], blk.labels[1]
758
+ qname = f"data.{rtype}.{rname}"
759
+ ident = b.ident(ns, env_val, qname)
760
+ attrs = _resource_attrs(blk, ns, env_src)
761
+ attrs["mode"] = "data"
762
+ if rtype == "terraform_remote_state":
763
+ attrs["remote_state"] = "true"
764
+ backend = blk.attribute("backend")
765
+ if backend is not None:
766
+ attrs["backend"] = sanitize_value(_unquote(backend.value))
767
+ b.entities.append(
768
+ ExtractedDomainEntity(
769
+ identity=ident, kind=DomainEntityKind.TERRAFORM_RESOURCE,
770
+ name=qname, path=p.path, span=blk.span,
771
+ content_hash=p.sha256, attributes=attrs, evidence=(evi,),
772
+ )
773
+ )
774
+ b.id_by_addr[(ns, qname)] = ident
775
+ b.stats["data_sources"] = b.stats.get("data_sources", 0) + 1
776
+
777
+
778
+ def _emit_module(
779
+ b: _Builder, p: _Parsed, blk: HclBlock, ns: str,
780
+ parsed: list[_Parsed],
781
+ env: dict[str, tuple[str, str]],
782
+ env_hints: dict[str, list[str]],
783
+ evi: int,
784
+ ) -> None:
785
+ name = blk.labels[0]
786
+ qname = f"{ns}.module.{name}" if ns else f"module.{name}"
787
+ src_attr = blk.attribute("source")
788
+ source = _unquote(src_attr.value) if src_attr else ""
789
+ local_dir = _norm_source(p.directory, source)
790
+ # env da entidade-módulo = env do dir alvo quando local
791
+ env_key = local_dir if local_dir else p.directory
792
+ env_val, env_src = env.get(env_key, env.get(p.directory, ("", "")))
793
+ ident = b.ident(ns, env_val, qname)
794
+ attrs = _module_attrs(blk, ns, env_src, source)
795
+ if local_dir:
796
+ _attach_var_attrs(b, parsed, local_dir, attrs, env_hints)
797
+ b.entities.append(
798
+ ExtractedDomainEntity(
799
+ identity=ident, kind=DomainEntityKind.TERRAFORM_MODULE,
800
+ name=qname, path=p.path, span=blk.span,
801
+ content_hash=p.sha256, attributes=attrs, evidence=(evi,),
802
+ )
803
+ )
804
+ b.id_by_addr[(ns, qname)] = ident
805
+ b.mod_addr[qname] = ident
806
+ b.stats["modules"] = b.stats.get("modules", 0) + 1
807
+
808
+
809
+ def _emit_root_module(
810
+ b: _Builder,
811
+ parsed: list[_Parsed],
812
+ addresses: dict[str, tuple[str, ...]],
813
+ env: dict[str, tuple[str, str]],
814
+ env_hints: dict[str, list[str]],
815
+ ) -> None:
816
+ """Módulo root sintetizado — derivação declarada (``path=""``)."""
817
+ root_dir = ""
818
+ for d, addrs in addresses.items():
819
+ if "" in addrs:
820
+ root_dir = d
821
+ break
822
+ if not any(p.kind in ("tf", "tfjson") for p in parsed):
823
+ return
824
+ env_val, env_src = env.get(root_dir, ("", ""))
825
+ ident = b.ident("", env_val, "module.root")
826
+ attrs: dict[str, str] = {
827
+ "module_role": "root", "module_path": "",
828
+ "declared": "synthesized",
829
+ }
830
+ if env_src:
831
+ attrs["env_source"] = env_src
832
+ _attach_var_attrs(b, parsed, root_dir, attrs, env_hints)
833
+ b.entities.append(
834
+ ExtractedDomainEntity(
835
+ identity=ident, kind=DomainEntityKind.TERRAFORM_MODULE,
836
+ name="module.root", path="", attributes=attrs,
837
+ )
838
+ )
839
+ b.id_by_addr[("", "module.root")] = ident
840
+ b.mod_addr[""] = ident
841
+ b.stats["modules"] = b.stats.get("modules", 0) + 1
842
+
843
+
844
+ # ---------------------------------------------------------------------
845
+ # relações
846
+ # ---------------------------------------------------------------------
847
+
848
+
849
+ def _attr_index(block: HclBlock) -> list[tuple[int, int, str]]:
850
+ """``(start_byte, end_byte, attr_name)`` dos valores do bloco."""
851
+ out: list[tuple[int, int, str]] = []
852
+
853
+ def walk(blk: HclBlock, depth: int) -> None:
854
+ if depth > _MAX_DEPTH:
855
+ return
856
+ for a in blk.attributes:
857
+ out.append(
858
+ (a.value_span.start_byte, a.value_span.end_byte, a.name)
859
+ )
860
+ for c in blk.children:
861
+ walk(c, depth + 1)
862
+
863
+ walk(block, 0)
864
+ return out
865
+
866
+
867
+ def _enclosing_attr(spans: list[tuple[int, int, str]], offset: int) -> str:
868
+ for s, e, name in spans:
869
+ if s <= offset <= e:
870
+ return name
871
+ return ""
872
+
873
+
874
+ def _block_from_ids(
875
+ b: _Builder,
876
+ blk: HclBlock,
877
+ ns_list: tuple[str, ...],
878
+ ) -> list[DomainIdentity]:
879
+ """Identidades do bloco em cada endereço do seu diretório."""
880
+ out: list[DomainIdentity] = []
881
+ for ns in ns_list:
882
+ if blk.type == "module":
883
+ qn = f"{ns}.module.{blk.labels[0]}" if ns else f"module.{blk.labels[0]}"
884
+ else:
885
+ prefix = "data." if blk.type == "data" else ""
886
+ qn = f"{prefix}{blk.labels[0]}.{blk.labels[1]}"
887
+ ident = b.id_by_addr.get((ns, qn))
888
+ if ident is not None:
889
+ out.append(ident)
890
+ return out
891
+
892
+
893
+ def _role_chain_edge(
894
+ b: _Builder,
895
+ p: _Parsed,
896
+ block: HclBlock,
897
+ m: RefMatch,
898
+ enclosing: str,
899
+ from_id: DomainIdentity,
900
+ span: SourceSpan | None,
901
+ evi: tuple[int, ...],
902
+ seen: set[tuple[DomainIdentity, str, str]],
903
+ ) -> bool:
904
+ """Trust chain: trust policy de R lista principal ``aws_iam_role.P``
905
+ → aresta ``P ASSUMES_ROLE R``. ``True`` quando aplicado."""
906
+ if not (
907
+ block.type == "resource"
908
+ and block.labels
909
+ and block.labels[0] == "aws_iam_role"
910
+ and enclosing == "assume_role_policy"
911
+ and m.type_name == "aws_iam_role"
912
+ ):
913
+ return False
914
+ ns = from_id.namespace
915
+ target_qname = f"{m.type_name}.{m.name}"
916
+ prin = b.id_by_addr.get((ns, target_qname))
917
+ if prin is None:
918
+ b.diag(
919
+ "role-principal-unresolved", "info",
920
+ f"trust policy principal {target_qname} not extracted "
921
+ "in this snapshot", p.path,
922
+ )
923
+ return True
924
+ key = (prin, str(from_id), "ASSUMES_ROLE")
925
+ if key not in seen:
926
+ seen.add(key)
927
+ b.relations.append(
928
+ ExtractedDomainRelation(
929
+ relation_type=str(DomainRelationKind.ASSUMES_ROLE),
930
+ path=p.path,
931
+ from_identity=prin,
932
+ to_identity=from_id,
933
+ resolution=b._res(
934
+ ResolutionStatus.RESOLVED, target=from_id,
935
+ ),
936
+ span=span, evidence=evi,
937
+ )
938
+ )
939
+ return True
940
+
941
+
942
+ def _managed_ref_kinds(
943
+ block: HclBlock, m: RefMatch, explicit: bool, enclosing: str
944
+ ) -> tuple[str, ...]:
945
+ """Classifica ref a recurso managed: atributo de role →
946
+ ``ASSUMES_ROLE`` + ``DEPENDS_ON``; doc/payload → ``USES_RESOURCE``;
947
+ demais → ``DEPENDS_ON``."""
948
+ if (
949
+ not explicit
950
+ and enclosing in _ROLE_ATTRS
951
+ and m.type_name == "aws_iam_role"
952
+ and block.labels
953
+ and block.labels[0] not in _ROLE_BINDING_TYPES
954
+ ):
955
+ return (
956
+ str(DomainRelationKind.ASSUMES_ROLE),
957
+ str(DomainRelationKind.DEPENDS_ON),
958
+ )
959
+ if not explicit and enclosing in _DOC_ATTRS:
960
+ return (str(DomainRelationKind.USES_RESOURCE),)
961
+ return (str(DomainRelationKind.DEPENDS_ON),)
962
+
963
+
964
+ def _emit_one_ref(
965
+ b: _Builder,
966
+ p: _Parsed,
967
+ block: HclBlock,
968
+ m: RefMatch,
969
+ explicit: bool,
970
+ enclosing: str,
971
+ from_id: DomainIdentity,
972
+ seen: set[tuple[DomainIdentity, str, str]],
973
+ ) -> None:
974
+ """Uma referência → aresta(s) honestas a partir de ``from_id``."""
975
+ ns = from_id.namespace
976
+ # tf: match em offsets de arquivo reais; tfjson: offsets no body
977
+ # serializado → evidência cai para o span do bloco.
978
+ span = (
979
+ _byte_span(p.line_starts, m.start, m.end)
980
+ if p.kind == "tf"
981
+ else block.span
982
+ )
983
+ evi = (b.evi(p.path, p.sha256, span or block.header_span),)
984
+ if m.kind == "arn":
985
+ key = (from_id, f"arn:{m.name}", "REFERENCES")
986
+ if key not in seen:
987
+ seen.add(key)
988
+ b.rel_external(
989
+ str(DomainRelationKind.REFERENCES), p.path, from_id,
990
+ m.name, span, evi,
991
+ )
992
+ return
993
+ if m.kind == "data":
994
+ tqn = f"data.{m.type_name}.{m.name}"
995
+ dtype = (
996
+ "DEPENDS_ON" if explicit else str(DomainRelationKind.REFERENCES)
997
+ )
998
+ key = (from_id, f"{ns}.{tqn}", dtype)
999
+ if key not in seen:
1000
+ seen.add(key)
1001
+ b.rel_to_entity(dtype, p.path, from_id, ns, tqn, span, evi)
1002
+ return
1003
+ if m.kind == "module":
1004
+ mqname = f"{ns}.module.{m.name}" if ns else f"module.{m.name}"
1005
+ key = (from_id, mqname, "DEPENDS_ON")
1006
+ if key not in seen:
1007
+ seen.add(key)
1008
+ b.rel_to_entity(
1009
+ str(DomainRelationKind.DEPENDS_ON), p.path, from_id,
1010
+ ns, mqname, span, evi,
1011
+ )
1012
+ return
1013
+ # ref a recurso managed
1014
+ if not explicit and _role_chain_edge(
1015
+ b, p, block, m, enclosing, from_id, span, evi, seen
1016
+ ):
1017
+ return
1018
+ target_qname = f"{m.type_name}.{m.name}"
1019
+ for rtype in _managed_ref_kinds(block, m, explicit, enclosing):
1020
+ key = (from_id, f"{ns}.{target_qname}", rtype)
1021
+ if key in seen:
1022
+ continue
1023
+ seen.add(key)
1024
+ b.rel_to_entity(rtype, p.path, from_id, ns, target_qname, span, evi)
1025
+
1026
+
1027
+ def _emit_block_relations(
1028
+ b: _Builder,
1029
+ p: _Parsed,
1030
+ block: HclBlock,
1031
+ from_ids: list[DomainIdentity],
1032
+ ) -> None:
1033
+ """Refs do corpo do bloco → arestas por instância de identidade."""
1034
+ attr_spans = _attr_index(block)
1035
+ dep_attr = block.attribute("depends_on")
1036
+ dep_span = (
1037
+ (dep_attr.value_span.start_byte, dep_attr.value_span.end_byte)
1038
+ if dep_attr is not None
1039
+ else None
1040
+ )
1041
+ refs = scan_refs(block.body, base_byte=block.body_start)
1042
+ seen: set[tuple[DomainIdentity, str, str]] = set()
1043
+ for m in refs:
1044
+ explicit = (
1045
+ dep_span is not None and dep_span[0] <= m.start <= dep_span[1]
1046
+ )
1047
+ enclosing = "" if explicit else _enclosing_attr(attr_spans, m.start)
1048
+ for from_id in from_ids:
1049
+ _emit_one_ref(
1050
+ b, p, block, m, explicit, enclosing, from_id, seen
1051
+ )
1052
+
1053
+
1054
+ def _emit_terraform_block(
1055
+ b: _Builder,
1056
+ p: _Parsed,
1057
+ block: HclBlock,
1058
+ from_ids: list[DomainIdentity],
1059
+ ) -> None:
1060
+ """``terraform { backend "x" {} / cloud {} }`` → REFERENCES external."""
1061
+ for child in block.children:
1062
+ if child.type not in ("backend", "cloud"):
1063
+ continue
1064
+ label = child.labels[0] if child.labels else ""
1065
+ target = f"{child.type}:{label}" if label else child.type
1066
+ for from_id in from_ids:
1067
+ evi = (b.evi(p.path, p.sha256, child.header_span or child.span),)
1068
+ b.rel_external(
1069
+ str(DomainRelationKind.REFERENCES), p.path, from_id,
1070
+ target, child.header_span, evi,
1071
+ )
1072
+
1073
+
1074
+ def _emit_relations_for_block(
1075
+ b: _Builder,
1076
+ p: _Parsed,
1077
+ blk: HclBlock,
1078
+ ns_list: tuple[str, ...],
1079
+ ) -> None:
1080
+ """Despacha relações de um bloco — entidade própria ou módulo dono."""
1081
+ if blk.type in ("resource", "data") and len(blk.labels) < 2:
1082
+ return
1083
+ if blk.type in ("resource", "data", "module") and blk.labels:
1084
+ from_ids = _block_from_ids(b, blk, ns_list)
1085
+ if not from_ids:
1086
+ return
1087
+ if blk.type == "module":
1088
+ _emit_module_relations(b, p, blk, from_ids)
1089
+ elif blk.type == "data" and blk.labels[0] == "terraform_remote_state":
1090
+ _emit_remote_state_relations(b, p, blk, from_ids)
1091
+ _emit_block_relations(b, p, blk, from_ids)
1092
+ return
1093
+ if blk.type in ("output", "provider", "terraform"):
1094
+ owners = [b.mod_addr[ns] for ns in ns_list if ns in b.mod_addr]
1095
+ if not owners:
1096
+ return
1097
+ if blk.type == "terraform":
1098
+ _emit_terraform_block(b, p, blk, owners)
1099
+ _emit_block_relations(b, p, blk, owners)
1100
+
1101
+
1102
+ def _emit_relations(
1103
+ b: _Builder,
1104
+ parsed: list[_Parsed],
1105
+ calls: list[_ModuleCall],
1106
+ addresses: dict[str, tuple[str, ...]],
1107
+ ) -> None:
1108
+ for p in parsed:
1109
+ if p.kind not in ("tf", "tfjson"):
1110
+ continue
1111
+ ns_list = addresses.get(
1112
+ p.directory, (f"unresolved:{p.directory}",)
1113
+ )
1114
+ for blk in p.parsed.blocks:
1115
+ _emit_relations_for_block(b, p, blk, ns_list)
1116
+ _emit_provisions(b, calls, addresses)
1117
+
1118
+
1119
+ def _emit_module_relations(
1120
+ b: _Builder,
1121
+ p: _Parsed,
1122
+ blk: HclBlock,
1123
+ from_ids: list[DomainIdentity],
1124
+ ) -> None:
1125
+ """``module`` com source remoto → REFERENCES external (declarado)."""
1126
+ src_attr = blk.attribute("source")
1127
+ if src_attr is None:
1128
+ return
1129
+ source = _unquote(src_attr.value)
1130
+ if _norm_source(p.directory, source):
1131
+ return # local — resolvido via PROVISIONS, nada externo
1132
+ for from_id in from_ids:
1133
+ evi = (b.evi(p.path, p.sha256, src_attr.value_span),)
1134
+ b.rel_external(
1135
+ str(DomainRelationKind.REFERENCES), p.path, from_id,
1136
+ source, src_attr.value_span, evi,
1137
+ )
1138
+
1139
+
1140
+ def _emit_remote_state_relations(
1141
+ b: _Builder,
1142
+ p: _Parsed,
1143
+ blk: HclBlock,
1144
+ from_ids: list[DomainIdentity],
1145
+ ) -> None:
1146
+ """``data "terraform_remote_state"`` → leitura externa declarada."""
1147
+ backend = blk.attribute("backend")
1148
+ bname = _unquote(backend.value) if backend is not None else ""
1149
+ target = f"remote-state:{bname}" if bname else "remote-state"
1150
+ for from_id in from_ids:
1151
+ evi = (b.evi(p.path, p.sha256, blk.header_span or blk.span),)
1152
+ b.rel_external(
1153
+ str(DomainRelationKind.REFERENCES), p.path, from_id,
1154
+ target, blk.header_span, evi,
1155
+ )
1156
+
1157
+
1158
+ def _provision_call(
1159
+ b: _Builder,
1160
+ call: _ModuleCall,
1161
+ addresses: dict[str, tuple[str, ...]],
1162
+ res_by_ns: dict[str, list[DomainIdentity]],
1163
+ prov_seen: set[tuple[DomainIdentity, DomainIdentity]],
1164
+ ) -> None:
1165
+ """``module x { source = "./dir" }`` → ``PROVISIONS`` por recurso."""
1166
+ for parent_addr in addresses.get(call.directory, ()):
1167
+ qname = (
1168
+ f"{parent_addr}.module.{call.name}"
1169
+ if parent_addr
1170
+ else f"module.{call.name}"
1171
+ )
1172
+ mod_id = b.mod_addr.get(qname)
1173
+ if mod_id is None:
1174
+ continue
1175
+ evi = (
1176
+ b.evi(call.parsed.path, call.parsed.sha256,
1177
+ call.block.header_span or call.block.span),
1178
+ )
1179
+ for target in res_by_ns.get(qname, []):
1180
+ if (mod_id, target) in prov_seen:
1181
+ continue
1182
+ prov_seen.add((mod_id, target))
1183
+ b.relations.append(
1184
+ ExtractedDomainRelation(
1185
+ relation_type=str(DomainRelationKind.PROVISIONS),
1186
+ path=call.parsed.path, from_identity=mod_id,
1187
+ to_identity=target,
1188
+ resolution=b._res(
1189
+ ResolutionStatus.RESOLVED, target=target,
1190
+ ),
1191
+ span=call.block.header_span, evidence=evi,
1192
+ )
1193
+ )
1194
+
1195
+
1196
+ def _emit_provisions(
1197
+ b: _Builder,
1198
+ calls: list[_ModuleCall],
1199
+ addresses: dict[str, tuple[str, ...]],
1200
+ ) -> None:
1201
+ """``module`` call ``PROVISIONS`` recursos do source local; root idem."""
1202
+ res_by_ns: dict[str, list[DomainIdentity]] = {}
1203
+ for ent in b.entities:
1204
+ if ent.kind in (
1205
+ DomainEntityKind.TERRAFORM_RESOURCE, DomainEntityKind.IAM_ROLE,
1206
+ ):
1207
+ res_by_ns.setdefault(ent.identity.namespace, []).append(
1208
+ ent.identity
1209
+ )
1210
+ prov_seen: set[tuple[DomainIdentity, DomainIdentity]] = set()
1211
+ for call in calls:
1212
+ if call.local_dir:
1213
+ _provision_call(b, call, addresses, res_by_ns, prov_seen)
1214
+ root = b.mod_addr.get("")
1215
+ if root is not None:
1216
+ for target in res_by_ns.get("", []):
1217
+ b.relations.append(
1218
+ ExtractedDomainRelation(
1219
+ relation_type=str(DomainRelationKind.PROVISIONS),
1220
+ path="", from_identity=root, to_identity=target,
1221
+ resolution=b._res(ResolutionStatus.RESOLVED, target=target),
1222
+ )
1223
+ )
1224
+
1225
+
1226
+ def _emit_tfvars(
1227
+ b: _Builder, parsed: list[_Parsed]
1228
+ ) -> dict[str, list[str]]:
1229
+ """Hints de ambiente vindos de tfvars — valores sensíveis nunca saem."""
1230
+ env_hints: dict[str, list[str]] = {}
1231
+ for p in parsed:
1232
+ if p.kind != "tfvars":
1233
+ continue
1234
+ for name, value, redacted in p.tfvars:
1235
+ if redacted:
1236
+ b.stats["secrets_redacted"] = (
1237
+ b.stats.get("secrets_redacted", 0) + 1
1238
+ )
1239
+ continue
1240
+ if name.lower() in _ENV_VAR_NAMES and value:
1241
+ vals = env_hints.setdefault(p.directory, [])
1242
+ if value not in vals:
1243
+ vals.append(value)
1244
+ return env_hints
1245
+
1246
+
1247
+ # ---------------------------------------------------------------------
1248
+ # pipeline
1249
+ # ---------------------------------------------------------------------
1250
+
1251
+
1252
+ def extract(
1253
+ request: DomainExtractionRequest,
1254
+ adapter_version: str,
1255
+ base_path: Path | None,
1256
+ ) -> DomainExtractionResult:
1257
+ """Orquestra leitura→parse→entidades→relações; nunca levanta."""
1258
+ b = _Builder(request=request, adapter_version=adapter_version)
1259
+ parsed = _read_artifacts(b, base_path)
1260
+ env_hints = _emit_tfvars(b, parsed)
1261
+ calls = _module_calls(parsed)
1262
+ addresses = _module_addresses(parsed, calls)
1263
+ env = _dir_env(b, parsed)
1264
+ _emit_entities(b, parsed, addresses, env, env_hints)
1265
+ _emit_relations(b, parsed, calls, addresses)
1266
+ b.stats["entities"] = len(b.entities)
1267
+ b.stats["relations"] = len(b.relations)
1268
+ b.stats["files"] = len(parsed)
1269
+ return DomainExtractionResult(
1270
+ domain_id=DOMAIN_ID,
1271
+ adapter_version=adapter_version,
1272
+ entities=tuple(b.entities),
1273
+ relations=tuple(b.relations),
1274
+ evidence=tuple(b.evidence),
1275
+ diagnostics=tuple(b.diagnostics),
1276
+ stats=b.stats,
1277
+ )