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,538 @@
1
+ """Parser HCL minimalista e seguro (lf-0115, Cycle 7 Phase 7).
2
+
3
+ Hand-rolled, stdlib only — **nunca** executa ``terraform``, nunca lê
4
+ state, nunca faz plan/apply. Cobre o subconjunto estrutural de HCL
5
+ necessário ao adapter:
6
+
7
+ - blocos ``resource``/``module``/``data``/``variable``/``locals``/
8
+ ``output``/``provider``/``terraform`` com labels quoted;
9
+ - atributos ``name = expr`` com strings, heredocs (``<<EOF`` /
10
+ ``<<-EOF``), listas/mapas multi-linha, chamadas de função e
11
+ interpolações ``${...}``;
12
+ - comentários ``#``, ``//`` e ``/* */``;
13
+ - blocos aninhados arbitrários (``lifecycle {}``, ``statement {}``…).
14
+
15
+ Semântica de falha: **diagnóstico, nunca exceção** — conteúdo malformado
16
+ produz o que foi parseado (parcial) + ``HclDiagnostic`` com posição.
17
+ Spans seguem a convenção de ``SourceSpan``: byte offsets UTF-8, linha
18
+ 1-based, coluna em bytes 0-based.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import bisect
24
+ from dataclasses import dataclass
25
+
26
+ from code_atlas.parsers.contracts import SourceSpan
27
+
28
+ _IDENT_START = frozenset(
29
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"
30
+ )
31
+ _IDENT = frozenset(
32
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789-"
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HclDiagnostic:
38
+ """Problema estrutural do parser HCL — honesto, com posição."""
39
+
40
+ code: str # parse-error | unterminated-block | unterminated-string…
41
+ message: str
42
+ line: int = 0 # 1-based; 0 = desconhecida
43
+ col: int = 0 # byte col 0-based
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class HclAttribute:
48
+ """Atributo ``name = expr`` num corpo de bloco (ou top-level tfvars)."""
49
+
50
+ name: str
51
+ value: str # texto bruto da expressão (não avaliado)
52
+ span: SourceSpan # span de ``name = expr``
53
+ value_span: SourceSpan
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class HclBlock:
58
+ """Bloco HCL ``type "label"… { body }`` com estrutura aninhada."""
59
+
60
+ type: str
61
+ labels: tuple[str, ...] = ()
62
+ attributes: tuple[HclAttribute, ...] = ()
63
+ children: tuple[HclBlock, ...] = ()
64
+ body: str = "" # texto bruto entre ``{`` e ``}`` (sem as chaves)
65
+ body_start: int = 0 # byte offset do início de ``body``
66
+ span: SourceSpan | None = None # bloco inteiro
67
+ header_span: SourceSpan | None = None # ``type "l1" "l2"`` sem ``{``
68
+
69
+ def attribute(self, name: str) -> HclAttribute | None:
70
+ """Primeiro atributo top-level com ``name`` (ordem declarada)."""
71
+ for a in self.attributes:
72
+ if a.name == name:
73
+ return a
74
+ return None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class HclFile:
79
+ """Saída do parser: itens top-level + diagnósticos parciais."""
80
+
81
+ blocks: tuple[HclBlock, ...] = ()
82
+ attributes: tuple[HclAttribute, ...] = () # top-level (tfvars)
83
+ diagnostics: tuple[HclDiagnostic, ...] = ()
84
+
85
+
86
+ class _ParseError(Exception):
87
+ """Erro interno de parse — o caller resincroniza, nunca propaga."""
88
+
89
+
90
+ class _Parser:
91
+ """Cursor sobre ``text`` com posição→(byte,line,col) pré-computada."""
92
+
93
+ def __init__(self, text: str) -> None:
94
+ self.text = text
95
+ self.n = len(text)
96
+ self.pos = 0
97
+ self.diags: list[HclDiagnostic] = []
98
+ # offset de byte UTF-8 por índice de char (posições são chars;
99
+ # SourceSpan exige bytes — converte uma vez, O(n)).
100
+ self._byte: list[int] = [0] * (self.n + 1)
101
+ off = 0
102
+ for i, ch in enumerate(text):
103
+ self._byte[i] = off
104
+ off += len(ch.encode("utf-8"))
105
+ self._byte[self.n] = off
106
+ self._lines: list[int] = [0]
107
+ for i, ch in enumerate(text):
108
+ if ch == "\n":
109
+ self._lines.append(i + 1)
110
+
111
+ # --- posição ------------------------------------------------------
112
+
113
+ def _lc(self, char_pos: int) -> tuple[int, int, int]:
114
+ """``(byte_offset, line 1-based, col bytes 0-based)``."""
115
+ cp = min(max(char_pos, 0), self.n)
116
+ byte = self._byte[cp]
117
+ li = bisect.bisect_right(self._lines, cp) - 1
118
+ line_start = self._lines[li]
119
+ return byte, li + 1, byte - self._byte[line_start]
120
+
121
+ def span(self, start: int, end: int) -> SourceSpan:
122
+ sb, sl, sc = self._lc(start)
123
+ eb, el, ec = self._lc(end)
124
+ return SourceSpan(
125
+ start_byte=sb, end_byte=eb,
126
+ start_line=sl, start_col=sc, end_line=el, end_col=ec,
127
+ )
128
+
129
+ def diag(self, code: str, message: str, pos: int) -> None:
130
+ _, line, col = self._lc(pos)
131
+ self.diags.append(
132
+ HclDiagnostic(code=code, message=message, line=line, col=col)
133
+ )
134
+
135
+ # --- trivia -------------------------------------------------------
136
+
137
+ def skip_trivia(self) -> None:
138
+ """Consome whitespace e comentários ``#``/``//``/``/* */``."""
139
+ while self.pos < self.n:
140
+ c = self.text[self.pos]
141
+ if c in " \t\r\n":
142
+ self.pos += 1
143
+ continue
144
+ if c == "#":
145
+ self._skip_line_comment()
146
+ continue
147
+ if c == "/" and self.pos + 1 < self.n:
148
+ nxt = self.text[self.pos + 1]
149
+ if nxt == "/":
150
+ self._skip_line_comment()
151
+ continue
152
+ if nxt == "*":
153
+ end = self.text.find("*/", self.pos + 2)
154
+ if end < 0:
155
+ self.diag(
156
+ "unterminated-comment",
157
+ "block comment never closed", self.pos,
158
+ )
159
+ self.pos = self.n
160
+ return
161
+ self.pos = end + 2
162
+ continue
163
+ return
164
+
165
+ def _skip_line_comment(self) -> None:
166
+ nl = self.text.find("\n", self.pos)
167
+ self.pos = self.n if nl < 0 else nl + 1
168
+
169
+ # --- strings / heredocs -------------------------------------------
170
+
171
+ def _skip_string(self) -> None:
172
+ """Consome uma string ``"…"`` a partir de ``pos`` (na aspa).
173
+
174
+ Lida com escapes ``\\"`` e interpolações ``${…}`` balanceadas —
175
+ strings dentro da interpolação são reconhecidas recursivamente.
176
+ """
177
+ start = self.pos
178
+ self.pos += 1 # opening quote
179
+ while self.pos < self.n:
180
+ c = self.text[self.pos]
181
+ if c == "\\":
182
+ self.pos += 2
183
+ continue
184
+ if c == '"':
185
+ self.pos += 1
186
+ return
187
+ if c == "$" and self.pos + 1 < self.n and self.text[self.pos + 1] == "{":
188
+ self._skip_interpolation()
189
+ continue
190
+ self.pos += 1
191
+ self.diag("unterminated-string", "string literal never closed", start)
192
+
193
+ def _skip_interpolation(self) -> None:
194
+ """Consome ``${…}`` — braces balanceados, strings internas ok."""
195
+ start = self.pos
196
+ self.pos += 2 # consume '${'
197
+ depth = 1
198
+ while self.pos < self.n and depth:
199
+ c = self.text[self.pos]
200
+ if c == "{":
201
+ depth += 1
202
+ elif c == "}":
203
+ depth -= 1
204
+ elif c == '"':
205
+ self._skip_string()
206
+ continue
207
+ elif c == "#":
208
+ self._skip_line_comment()
209
+ continue
210
+ self.pos += 1
211
+ if depth:
212
+ self.diag(
213
+ "unterminated-interpolation",
214
+ "${…} never closed", start,
215
+ )
216
+
217
+ def _heredoc_tag(self) -> tuple[str, int] | None:
218
+ """Tag de ``<<TAG``/``<<-TAG``/``<<"TAG"`` → ``(tag, end)``."""
219
+ p = self.pos
220
+ if self.text.startswith("<<-", p):
221
+ p += 3
222
+ elif self.text.startswith("<<", p):
223
+ p += 2
224
+ else:
225
+ return None
226
+ q = p
227
+ quoted = q < self.n and self.text[q] == '"'
228
+ if quoted:
229
+ q += 1
230
+ tstart = q
231
+ while q < self.n and self.text[q] in _IDENT:
232
+ q += 1
233
+ if q == tstart:
234
+ return None # '<<' sem tag — não é heredoc
235
+ if quoted:
236
+ if q < self.n and self.text[q] == '"':
237
+ q += 1
238
+ else:
239
+ return None
240
+ tag = self.text[tstart:q - (1 if quoted else 0)].strip()
241
+ return (tag, q) if tag else None
242
+
243
+ def _consume_heredoc_body(self, tag: str, line_start: int) -> None:
244
+ """Avança linha a linha até o terminador ``tag``."""
245
+ while line_start <= self.n:
246
+ line_end = self.text.find("\n", line_start)
247
+ if line_end < 0:
248
+ line_end = self.n
249
+ if self.text[line_start:line_end].strip() == tag:
250
+ self.pos = line_end + 1 if line_end < self.n else self.n
251
+ return
252
+ if line_end >= self.n:
253
+ break
254
+ line_start = line_end + 1
255
+ self.diag(
256
+ "unterminated-heredoc",
257
+ f"heredoc terminator {tag!r} not found", self.pos,
258
+ )
259
+ self.pos = self.n
260
+
261
+ def _try_heredoc(self) -> bool:
262
+ """Se ``pos`` inicia ``<<TAG``/``<<-TAG``, consome o heredoc."""
263
+ res = self._heredoc_tag()
264
+ if res is None:
265
+ return False
266
+ tag, q = res
267
+ # consome até o fim da linha de abertura
268
+ nl = self.text.find("\n", q)
269
+ if nl < 0:
270
+ self.diag(
271
+ "unterminated-heredoc",
272
+ f"heredoc {tag!r} opens at EOF", self.pos,
273
+ )
274
+ self.pos = self.n
275
+ return True
276
+ self._consume_heredoc_body(tag, nl + 1)
277
+ return True
278
+
279
+ # --- expressões -----------------------------------------------------
280
+
281
+ def read_expression(self) -> tuple[str, int, int]:
282
+ """Lê uma expressão até newline/``}`` em profundidade zero.
283
+
284
+ Retorna ``(texto, start, end)`` em posições de char. Erros
285
+ viram diagnósticos — a expressão termina no melhor ponto
286
+ conhecido (parcial, nunca exceção).
287
+ """
288
+ start = self.pos
289
+ depth = 0
290
+ while self.pos < self.n:
291
+ c = self.text[self.pos]
292
+ if depth == 0 and c in ("\n", "}"):
293
+ break # newline ou '}' em depth 0 encerram a expressão
294
+ if c == '"':
295
+ self._skip_string()
296
+ continue
297
+ if c == "<" and self._try_heredoc():
298
+ continue
299
+ if self._is_comment_start():
300
+ if depth == 0:
301
+ break # comentário é trivia — encerra a expressão
302
+ self._skip_comment()
303
+ continue
304
+ depth = self._expr_delimiter(c, depth)
305
+ self.pos += 1
306
+ if depth != 0:
307
+ self.diag(
308
+ "unbalanced-delimiter",
309
+ f"expression ended with depth {depth}", start,
310
+ )
311
+ # whitespace final é trivia (ex.: antes de comentário) — não faz
312
+ # parte do valor bruto.
313
+ raw = self.text[start:self.pos].rstrip()
314
+ return raw, start, start + len(raw)
315
+
316
+ def _is_comment_start(self) -> bool:
317
+ c = self.text[self.pos]
318
+ if c == "#":
319
+ return True
320
+ return (
321
+ c == "/"
322
+ and self.pos + 1 < self.n
323
+ and self.text[self.pos + 1] in "/*"
324
+ )
325
+
326
+ def _skip_comment(self) -> None:
327
+ """``pos`` em início de comentário ``#``/``//``/``/* */``."""
328
+ if (
329
+ self.text[self.pos] == "/"
330
+ and self.pos + 1 < self.n
331
+ and self.text[self.pos + 1] == "*"
332
+ ):
333
+ end = self.text.find("*/", self.pos + 2)
334
+ if end < 0:
335
+ self.diag(
336
+ "unterminated-comment",
337
+ "block comment never closed", self.pos,
338
+ )
339
+ self.pos = self.n
340
+ return
341
+ self.pos = end + 2
342
+ return
343
+ self._skip_line_comment()
344
+
345
+ def _expr_delimiter(self, c: str, depth: int) -> int:
346
+ """Bracket ``([{``/``)]}`` → novo depth (diag em desequilíbrio)."""
347
+ if c in "([{":
348
+ return depth + 1
349
+ if c in ")]}":
350
+ if depth == 0:
351
+ self.diag(
352
+ "unbalanced-delimiter",
353
+ f"unexpected {c!r} at depth 0", self.pos,
354
+ )
355
+ return depth
356
+ return depth - 1
357
+ return depth
358
+
359
+ # --- tokens --------------------------------------------------------
360
+
361
+ def _read_ident(self) -> tuple[str, int, int] | None:
362
+ """Identificador ``[A-Za-z_][\\w-]*``; ``None`` sem consumir."""
363
+ if self.pos >= self.n or self.text[self.pos] not in _IDENT_START:
364
+ return None
365
+ start = self.pos
366
+ self.pos += 1
367
+ while self.pos < self.n and self.text[self.pos] in _IDENT:
368
+ self.pos += 1
369
+ return self.text[start:self.pos], start, self.pos
370
+
371
+ def _read_label(self) -> tuple[str, int, int] | None:
372
+ """Label de bloco: string quoted ou identificador bare."""
373
+ self.skip_trivia()
374
+ if self.pos >= self.n:
375
+ return None
376
+ start = self.pos
377
+ if self.text[self.pos] == '"':
378
+ self._skip_string()
379
+ raw = self.text[start:self.pos]
380
+ # remove aspas externas sem interpretar escapes — labels
381
+ # interpoladas são inválidas em HCL de qualquer forma
382
+ return raw[1:-1], start, self.pos
383
+ tok = self._read_ident()
384
+ if tok is not None:
385
+ return tok
386
+ # label numérico (HCL antigo permite ``module 0 {…}``)
387
+ dstart = self.pos
388
+ while self.pos < self.n and self.text[self.pos].isdigit():
389
+ self.pos += 1
390
+ if self.pos > dstart:
391
+ return self.text[dstart:self.pos], dstart, self.pos
392
+ return None
393
+
394
+ # --- corpo / bloco ---------------------------------------------------
395
+
396
+ def _parse_body(self, body_char_start: int) -> tuple[
397
+ tuple[HclAttribute, ...], tuple[HclBlock, ...], int
398
+ ]:
399
+ """Parse até o ``}`` de fechamento; retorna attrs, filhos, end."""
400
+ attrs: list[HclAttribute] = []
401
+ children: list[HclBlock] = []
402
+ while True:
403
+ self.skip_trivia()
404
+ if self.pos >= self.n:
405
+ self.diag(
406
+ "unterminated-block",
407
+ "block body reached EOF without '}'", self.pos,
408
+ )
409
+ return tuple(attrs), tuple(children), self.pos
410
+ if self.text[self.pos] == "}":
411
+ self.pos += 1
412
+ return tuple(attrs), tuple(children), self.pos
413
+ item = self._parse_item()
414
+ if isinstance(item, HclAttribute):
415
+ attrs.append(item)
416
+ elif isinstance(item, HclBlock):
417
+ children.append(item)
418
+ # item None = resync já aplicado por _parse_item
419
+
420
+ def _read_block_labels(
421
+ self, name: str, item_start: int
422
+ ) -> tuple[str, ...] | None:
423
+ """Labels até ``{`` — ``None`` quando o header não fecha."""
424
+ labels: list[str] = []
425
+ while True:
426
+ self.skip_trivia()
427
+ if self.pos >= self.n:
428
+ self.diag(
429
+ "unterminated-block",
430
+ f"block {name!r} header reached EOF", item_start,
431
+ )
432
+ return None
433
+ if self.text[self.pos] == "{":
434
+ return tuple(labels)
435
+ lab = self._read_label()
436
+ if lab is not None:
437
+ labels.append(lab[0])
438
+ continue
439
+ self.diag(
440
+ "parse-error",
441
+ f"expected label or '{{' after {name!r}", self.pos,
442
+ )
443
+ # resync: pula até próximo '{' ou newline
444
+ while self.pos < self.n and self.text[self.pos] not in "{\n":
445
+ self.pos += 1
446
+ if self.pos >= self.n or self.text[self.pos] == "\n":
447
+ return None
448
+
449
+ def _parse_item(self) -> HclAttribute | HclBlock | None:
450
+ """Um item de corpo: atributo ``name = expr`` ou bloco aninhado."""
451
+ item_start = self.pos
452
+ tok = self._read_ident()
453
+ if tok is None:
454
+ self.diag(
455
+ "parse-error",
456
+ f"unexpected character {self.text[self.pos]!r}", self.pos,
457
+ )
458
+ # resync: avança até newline — progresso garantido
459
+ self._skip_line_comment()
460
+ if self.pos <= item_start:
461
+ self.pos = item_start + 1
462
+ return None
463
+ name, name_start, _name_end = tok
464
+ self.skip_trivia()
465
+ if (
466
+ self.pos < self.n
467
+ and self.text[self.pos] == "="
468
+ and (self.pos + 1 >= self.n or self.text[self.pos + 1] != "=")
469
+ ):
470
+ self.pos += 1
471
+ self.skip_trivia()
472
+ value, vstart, vend = self.read_expression()
473
+ return HclAttribute(
474
+ name=name, value=value,
475
+ span=self.span(name_start, vend),
476
+ value_span=self.span(vstart, vend),
477
+ )
478
+ labels = self._read_block_labels(name, item_start)
479
+ if labels is None:
480
+ return None
481
+ header_end = self.pos
482
+ self.pos += 1 # consume '{'
483
+ body_char_start = self.pos
484
+ attrs, children, body_end = self._parse_body(body_char_start)
485
+ # ``body_end`` inclui o ``}`` de fechamento quando presente
486
+ inner_end = (
487
+ body_end - 1
488
+ if body_end > body_char_start and self.text[body_end - 1] == "}"
489
+ else body_end
490
+ )
491
+ return HclBlock(
492
+ type=name, labels=labels,
493
+ attributes=attrs, children=children,
494
+ body=self.text[body_char_start:inner_end],
495
+ body_start=self._byte[body_char_start],
496
+ span=self.span(item_start, body_end),
497
+ header_span=self.span(item_start, header_end),
498
+ )
499
+
500
+ def parse(self) -> HclFile:
501
+ """Top-level: mistura de blocos e atributos (tfvars)."""
502
+ blocks: list[HclBlock] = []
503
+ attrs: list[HclAttribute] = []
504
+ while True:
505
+ self.skip_trivia()
506
+ if self.pos >= self.n:
507
+ break
508
+ if self.text[self.pos] == "}":
509
+ self.diag("stray-brace", "'}' without open block", self.pos)
510
+ self.pos += 1
511
+ continue
512
+ item_start = self.pos
513
+ item = self._parse_item()
514
+ if isinstance(item, HclBlock):
515
+ blocks.append(item)
516
+ elif isinstance(item, HclAttribute):
517
+ attrs.append(item)
518
+ if self.pos <= item_start: # guard anti-loop infinito
519
+ self.pos = item_start + 1
520
+ return HclFile(
521
+ blocks=tuple(blocks), attributes=tuple(attrs),
522
+ diagnostics=tuple(self.diags),
523
+ )
524
+
525
+
526
+ def parse_hcl(text: str) -> HclFile:
527
+ """Parse de texto HCL → ``HclFile``; nunca levanta exceção."""
528
+ try:
529
+ return _Parser(text).parse()
530
+ except Exception as exc: # última linha de defesa — diagnóstico honesto
531
+ return HclFile(
532
+ diagnostics=(
533
+ HclDiagnostic(
534
+ code="parse-error",
535
+ message=f"internal parser failure: {exc!r}",
536
+ ),
537
+ )
538
+ )