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,946 @@
1
+ """Subconjunto mínimo e honesto de YAML (stdlib-only) — lf-0116.
2
+
3
+ Workflows GitHub Actions usam um subconjunto limitado de YAML: mapas de
4
+ bloco, sequências, escalares plain/single/double, comentários, escalares
5
+ de bloco ``|``/``>`` e coleções flow de linha única. Este parser cobre
6
+ **apenas** esse subconjunto — nunca executa, nunca importa ``yaml``
7
+ (dependência externa proibida: adapters são stdlib por contrato).
8
+
9
+ Construções fora do subconjunto NÃO são fabricadas nem ignoradas em
10
+ silêncio: cada uma vira ``YamlDiagnostic`` posicional — anchors ``&``,
11
+ aliases ``*``, merge keys ``<<:``, tags ``!``, múltiplos documentos,
12
+ coleções flow multiline, tabs em indentação, chaves duplicadas.
13
+
14
+ Sem resolução implícita YAML 1.1: ``on:`` continua a *string* ``"on"``
15
+ (o bug clássico de ``yaml.safe_load`` que lê ``on`` como ``True`` não
16
+ existe aqui — chaves e escalares nunca são re-interpretados como bool).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from typing import Literal
24
+
25
+ from code_atlas.parsers.contracts import SourceSpan
26
+
27
+ YNodeKind = Literal["map", "seq", "scalar", "null"]
28
+
29
+ _DOC_MARKERS = frozenset({"---", "..."})
30
+ # cabeçalho de escalar de bloco: "|" ou ">" + chomping opcional + indicador
31
+ _BLOCK_HEADER = re.compile(r"^([|>])([+-]?)(\d*)\s*(#.*)?$")
32
+ # expressões GitHub ${{ ... }} — sanitizadas em atributos, nunca avaliadas
33
+ _ANCHOR_TOKEN = re.compile(r"^&[^\s,[\]{}]+")
34
+ _ALIAS_TOKEN = re.compile(r"^\*[^\s,[\]{}]+")
35
+ _TAG_TOKEN = re.compile(r"^!(?:[^\s!]+!)?[^\s,[\]{}]*")
36
+
37
+ _ESCAPES = {
38
+ "0": "\0", "a": "\a", "b": "\b", "t": "\t", "n": "\n",
39
+ "v": "\v", "f": "\f", "r": "\r", "e": "\x1b", '"': '"',
40
+ "\\": "\\", "/": "/", "_": "\xa0", "N": "\x85",
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class YamlDiagnostic:
46
+ """Problema estrutural do subconjunto YAML — posicional e honesto."""
47
+
48
+ code: str # ex.: "anchor-unsupported", "expected-key", "tab-indent"
49
+ severity: str # "error" | "warning" | "info"
50
+ message: str
51
+ line: int = 0 # 1-based; 0 = arquivo inteiro
52
+ col: int = 0 # 0-based (char)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class YNode:
57
+ """Nó da árvore YAML do subconjunto.
58
+
59
+ ``value`` só vale para ``kind == "scalar"`` (texto já decodificado
60
+ de quotes; plain scalars ficam como texto — sem coerção de tipo).
61
+ """
62
+
63
+ kind: YNodeKind
64
+ span: SourceSpan
65
+ value: str = ""
66
+ style: str = "" # plain|single|double|literal|folded|flow — auditoria
67
+ pairs: tuple[tuple[YNode, YNode], ...] = ()
68
+ items: tuple[YNode, ...] = ()
69
+
70
+ # --- navegação -----------------------------------------------------
71
+
72
+ def get(self, key: str) -> YNode | None:
73
+ """Último ``key`` do mapa (semântica de override do YAML).
74
+
75
+ Chaves duplicadas são reportadas como diagnóstico pelo parser;
76
+ aqui o override tardio vence, como ``yaml.safe_load``.
77
+ """
78
+ found: YNode | None = None
79
+ for k, v in self.pairs:
80
+ if k.kind == "scalar" and k.value == key:
81
+ found = v
82
+ return found
83
+
84
+ def keys(self) -> tuple[str, ...]:
85
+ """Chaves escalares do mapa, na ordem declarada."""
86
+ return tuple(
87
+ k.value for k, _ in self.pairs if k.kind == "scalar"
88
+ )
89
+
90
+ def scalar_text(self) -> str | None:
91
+ """Texto do escalar; ``None`` quando o nó não é escalar."""
92
+ return self.value if self.kind == "scalar" else None
93
+
94
+ def scalar_list(self) -> tuple[str, ...]:
95
+ """Lista de textos: seq de escalares, escalar único ou vazio.
96
+
97
+ Uniformiza ``needs: build`` e ``needs: [a, b]`` sem inventar
98
+ valores — itens não-escalares são omitidos.
99
+ """
100
+ if self.kind == "scalar":
101
+ return (self.value,) if self.value else ()
102
+ if self.kind == "seq":
103
+ return tuple(
104
+ it.value for it in self.items if it.kind == "scalar"
105
+ )
106
+ return ()
107
+
108
+
109
+ @dataclass
110
+ class _Line:
111
+ """Linha física com posição — base de todos os spans."""
112
+
113
+ num: int # 1-based
114
+ byte0: int # byte offset do início da linha no documento
115
+ raw: str # conteúdo sem newline
116
+ indent: int # colunas de espaço à esquerda (tab conta 1)
117
+ body: str # raw[indent:]
118
+
119
+
120
+ def _is_blank_or_comment(line: _Line) -> bool:
121
+ body = line.body.strip()
122
+ return not body or body.startswith("#")
123
+
124
+
125
+ def _cut_comment(text: str) -> str:
126
+ """Remove trailing comment de escalar plain: `` #`` inicia comentário."""
127
+ for i, ch in enumerate(text):
128
+ if ch == "#" and (i == 0 or text[i - 1] in " \t"):
129
+ return text[:i]
130
+ return text
131
+
132
+
133
+ class _Parser:
134
+ """Descida recursiva por indentação sobre a tabela de linhas."""
135
+
136
+ def __init__(self, text: str) -> None:
137
+ self.lines: list[_Line] = []
138
+ self.diags: list[YamlDiagnostic] = []
139
+ self._doc_markers_seen = 0
140
+ pos = 0
141
+ for n, rl in enumerate(text.split("\n"), 1):
142
+ body = rl[:-1] if rl.endswith("\r") else rl
143
+ ws_len = len(body) - len(body.lstrip(" \t"))
144
+ ws = body[:ws_len]
145
+ if "\t" in ws:
146
+ self._diag(
147
+ "tab-indent", "error",
148
+ "tab in indentation is unsupported YAML subset", n, 0,
149
+ )
150
+ self.lines.append(
151
+ _Line(n, pos, body, ws_len, body[ws_len:])
152
+ )
153
+ pos += len(rl.encode("utf-8")) + 1
154
+ self.n = len(self.lines)
155
+
156
+ # --- diagnósticos / spans ------------------------------------------
157
+
158
+ def _diag(
159
+ self, code: str, severity: str, message: str,
160
+ line: int = 0, col: int = 0,
161
+ ) -> None:
162
+ self.diags.append(
163
+ YamlDiagnostic(code, severity, message, line, col)
164
+ )
165
+
166
+ def _span(
167
+ self, l1: int, c1: int, l2: int, c2: int
168
+ ) -> SourceSpan:
169
+ """Span em bytes: ``l*`` são índices 0-based de linha."""
170
+ a = self.lines[l1]
171
+ b = self.lines[l2]
172
+ return SourceSpan(
173
+ start_byte=a.byte0 + len(a.raw[:c1].encode("utf-8")),
174
+ end_byte=b.byte0 + len(b.raw[:c2].encode("utf-8")),
175
+ start_line=l1 + 1,
176
+ start_col=len(a.raw[:c1].encode("utf-8")),
177
+ end_line=l2 + 1,
178
+ end_col=len(b.raw[:c2].encode("utf-8")),
179
+ )
180
+
181
+ def _eol_span(self, line_idx: int) -> SourceSpan:
182
+ ln = self.lines[line_idx]
183
+ col = len(ln.raw)
184
+ return self._span(line_idx, col, line_idx, col)
185
+
186
+ def _sig(self, i: int) -> int | None:
187
+ """Próxima linha significativa (não-branca, não-comentário)."""
188
+ while i < self.n:
189
+ if not _is_blank_or_comment(self.lines[i]):
190
+ return i
191
+ i += 1
192
+ return None
193
+
194
+ # --- entrada ----------------------------------------------------------
195
+
196
+ def parse_document(self) -> YNode | None:
197
+ """Documento único; marcadores ``---``/``...`` tolerados."""
198
+ i = self._sig(0)
199
+ while i is not None and self.lines[i].body.strip() == "---":
200
+ self._doc_markers_seen += 1
201
+ i = self._sig(i + 1)
202
+ if i is not None and self.lines[i].body.lstrip().startswith("%"):
203
+ while i is not None and self.lines[i].body.lstrip().startswith("%"):
204
+ self._diag(
205
+ "directive-unsupported", "warning",
206
+ "YAML directive ignored", self.lines[i].num,
207
+ )
208
+ i = self._sig(i + 1)
209
+ while i is not None and self.lines[i].body.strip() == "---":
210
+ i = self._sig(i + 1)
211
+ if i is None:
212
+ return None
213
+ node, i2 = self.parse_block(i, self.lines[i].indent)
214
+ # conteúdo remanescente: docs extras ou lixo estrutural
215
+ j = self._sig(i2)
216
+ in_extra_doc = False
217
+ while j is not None:
218
+ stripped = self.lines[j].body.strip()
219
+ if stripped in _DOC_MARKERS:
220
+ if stripped == "---":
221
+ self._diag(
222
+ "multi-document-unsupported", "warning",
223
+ "additional YAML document ignored",
224
+ self.lines[j].num,
225
+ )
226
+ in_extra_doc = True
227
+ else:
228
+ in_extra_doc = False
229
+ elif not in_extra_doc:
230
+ self._diag(
231
+ "unparsed-trailing-content", "error",
232
+ "line could not be attached to the document tree",
233
+ self.lines[j].num, self.lines[j].indent,
234
+ )
235
+ j = self._sig(j + 1)
236
+ return node
237
+
238
+ # --- núcleo -----------------------------------------------------------
239
+
240
+ def parse_block(self, i: int, min_indent: int) -> tuple[YNode | None, int]:
241
+ """Nó de bloco começando em ``i``; ``None`` se nada se aplica."""
242
+ j = self._sig(i)
243
+ if j is None:
244
+ return None, i
245
+ ln = self.lines[j]
246
+ if ln.indent < min_indent:
247
+ return None, i
248
+ if ln.body.strip() in _DOC_MARKERS:
249
+ return None, i
250
+ if self._is_seq_item(ln.body):
251
+ return self._parse_seq(j, ln.indent)
252
+ if self._split_key(ln.body) is not None:
253
+ return self._parse_map(j, ln.indent)
254
+ return self._parse_scalar(j, ln.indent)
255
+
256
+ @staticmethod
257
+ def _is_seq_item(body: str) -> bool:
258
+ return body == "-" or body.startswith("- ")
259
+
260
+ def _split_key(self, body: str) -> int | None:
261
+ """Posição do ``:`` separador de ``key:``; ``None`` se não-mapa.
262
+
263
+ Separador YAML: ``:`` seguido de espaço ou fim-de-linha, fora de
264
+ quotes e de coleções flow — ``http://x`` nunca é chave.
265
+ """
266
+ if not body:
267
+ return None
268
+ if body[0] in "'\"":
269
+ return self._quoted_key_pos(body)
270
+ return self._plain_key_pos(body)
271
+
272
+ def _quoted_key_pos(self, body: str) -> int | None:
273
+ end = self._quoted_end(body, 0)
274
+ if end is None:
275
+ return None
276
+ rest = body[end:]
277
+ if rest.startswith(":") and (len(rest) == 1 or rest[1] == " "):
278
+ return end
279
+ return None
280
+
281
+ @staticmethod
282
+ def _is_key_colon(body: str, k: int) -> bool:
283
+ return (
284
+ body[k] == ":"
285
+ and (k + 1 == len(body) or body[k + 1] == " ")
286
+ )
287
+
288
+ def _plain_key_pos(self, body: str) -> int | None:
289
+ depth = 0
290
+ for k, ch in enumerate(body):
291
+ if ch in "[{":
292
+ depth += 1
293
+ elif ch in "]}":
294
+ depth -= 1
295
+ elif ch == "#" and k > 0 and body[k - 1] == " ":
296
+ return None # comentário antes de qualquer ':' → escalar
297
+ elif depth == 0 and self._is_key_colon(body, k):
298
+ return k
299
+ return None
300
+
301
+ def _parse_map(self, i: int, indent: int) -> tuple[YNode, int]:
302
+ pairs: list[tuple[YNode, YNode]] = []
303
+ first = i
304
+ seen: dict[str, int] = {}
305
+ while True:
306
+ j = self._sig(i)
307
+ if j is None:
308
+ i = self.n
309
+ break
310
+ ln = self.lines[j]
311
+ if ln.indent < indent or ln.body.strip() in _DOC_MARKERS:
312
+ i = j
313
+ break
314
+ if ln.indent > indent:
315
+ self._diag(
316
+ "unexpected-indent", "error",
317
+ "line indented deeper than enclosing map",
318
+ ln.num, ln.indent,
319
+ )
320
+ i = j + 1
321
+ continue
322
+ if self._is_seq_item(ln.body):
323
+ i = j
324
+ break
325
+ pair, i = self._parse_map_pair(j, indent, seen)
326
+ if pair is not None:
327
+ pairs.append(pair)
328
+ return (
329
+ YNode(kind="map", pairs=tuple(pairs),
330
+ span=self._join_span(first, pairs)),
331
+ i,
332
+ )
333
+
334
+ def _parse_map_pair(
335
+ self, j: int, indent: int, seen: dict[str, int]
336
+ ) -> tuple[tuple[YNode, YNode] | None, int]:
337
+ """Uma entrada ``key: value`` (inline ou bloco) na linha ``j``."""
338
+ ln = self.lines[j]
339
+ kpos = self._split_key(ln.body)
340
+ if kpos is None:
341
+ self._diag(
342
+ "expected-key", "error",
343
+ "line inside mapping has no 'key:' separator",
344
+ ln.num, ln.indent,
345
+ )
346
+ return None, j + 1
347
+ raw_key = ln.body[:kpos].strip()
348
+ key = self._decode_key(raw_key, j)
349
+ if key in seen:
350
+ self._diag(
351
+ "duplicate-key", "warning",
352
+ f"duplicate mapping key {key!r}: later entry wins",
353
+ ln.num, ln.indent,
354
+ )
355
+ seen[key] = j
356
+ if key == "<<":
357
+ self._diag(
358
+ "merge-key-unsupported", "warning",
359
+ "merge key '<<' is outside the supported subset",
360
+ ln.num, ln.indent,
361
+ )
362
+ key_node = YNode(
363
+ kind="scalar", value=key, style="key",
364
+ span=self._span(j, ln.indent, j, ln.indent + len(raw_key)),
365
+ )
366
+ rest = ln.body[kpos + 1:]
367
+ stripped = rest.strip()
368
+ if not stripped:
369
+ child, i = self._map_value_block(j, indent)
370
+ return (key_node, child), i
371
+ child, i = self._parse_inline(
372
+ stripped, j,
373
+ ln.indent + kpos + 1 + len(rest) - len(rest.lstrip()),
374
+ indent, j + 1,
375
+ )
376
+ return (key_node, child), i
377
+
378
+ def _map_value_block(
379
+ self, j: int, indent: int
380
+ ) -> tuple[YNode, int]:
381
+ """Valor de ``key:`` vazio: bloco mais fundo ou seq mesmo indent."""
382
+ j2 = self._sig(j + 1)
383
+ if (
384
+ j2 is not None
385
+ and self.lines[j2].indent == indent
386
+ and self._is_seq_item(self.lines[j2].body)
387
+ ):
388
+ return self._parse_seq(j2, indent)
389
+ child, i = self.parse_block(j + 1, indent + 1)
390
+ return (
391
+ child if child is not None else self._null_at(j)
392
+ ), i
393
+
394
+ def _join_span(
395
+ self, first_line: int, pairs: list[tuple[YNode, YNode]]
396
+ ) -> SourceSpan:
397
+ first = self.lines[first_line]
398
+ if pairs:
399
+ last = pairs[-1][1].span
400
+ return SourceSpan(
401
+ start_byte=first.byte0 + len(
402
+ first.raw[: first.indent].encode("utf-8")
403
+ ),
404
+ end_byte=last.end_byte,
405
+ start_line=first_line + 1,
406
+ start_col=first.indent,
407
+ end_line=last.end_line,
408
+ end_col=last.end_col,
409
+ )
410
+ return self._eol_span(first_line)
411
+
412
+ def _null_at(self, line_idx: int) -> YNode:
413
+ return YNode(kind="null", span=self._eol_span(line_idx))
414
+
415
+ def _parse_seq(self, i: int, indent: int) -> tuple[YNode, int]:
416
+ items: list[YNode] = []
417
+ first = i
418
+ while True:
419
+ j = self._sig(i)
420
+ if j is None:
421
+ i = self.n
422
+ break
423
+ ln = self.lines[j]
424
+ if (
425
+ ln.indent < indent
426
+ or ln.body.strip() in _DOC_MARKERS
427
+ or not self._is_seq_item(ln.body)
428
+ ):
429
+ i = j
430
+ break
431
+ if ln.indent > indent:
432
+ self._diag(
433
+ "unexpected-indent", "error",
434
+ "sequence item indented deeper than sequence",
435
+ ln.num, ln.indent,
436
+ )
437
+ i = j + 1
438
+ continue
439
+ body = ln.body
440
+ after = body[1:].lstrip(" ")
441
+ if not after:
442
+ child, i = self.parse_block(j + 1, indent + 1)
443
+ items.append(child if child is not None else self._null_at(j))
444
+ continue
445
+ # linha virtual: conteúdo após '- ' funciona como bloco
446
+ # próprio cuja indentação é a coluna real do conteúdo.
447
+ vcol = ln.indent + (len(body) - len(after))
448
+ virt = _Line(ln.num, ln.byte0, ln.raw, vcol, ln.raw[vcol:])
449
+ self.lines[j] = virt
450
+ try:
451
+ child, i = self.parse_block(j, vcol)
452
+ finally:
453
+ self.lines[j] = ln
454
+ if i == j:
455
+ # parse_block recusou a linha virtual (ex.: marcador de
456
+ # documento como item) — avançar para nunca ciclar.
457
+ i = j + 1
458
+ items.append(
459
+ child if child is not None else self._null_at(j)
460
+ )
461
+ end = items[-1].span if items else self._eol_span(first)
462
+ start = self.lines[first]
463
+ return (
464
+ YNode(
465
+ kind="seq", items=tuple(items),
466
+ span=SourceSpan(
467
+ start_byte=start.byte0 + start.indent,
468
+ end_byte=end.end_byte,
469
+ start_line=first + 1,
470
+ start_col=start.indent,
471
+ end_line=end.end_line,
472
+ end_col=end.end_col,
473
+ ),
474
+ ),
475
+ i,
476
+ )
477
+
478
+ def _parse_scalar(self, i: int, indent: int) -> tuple[YNode, int]:
479
+ """Escalar de linha única; continuações mais fundas viram fold."""
480
+ ln = self.lines[i]
481
+ body = ln.body
482
+ node, _ = self._inline_scalar(body, i, ln.indent)
483
+ # continuações plain: linhas mais indentadas dobram com ' '
484
+ consumed = i + 1
485
+ extra: list[str] = []
486
+ while True:
487
+ j = self._sig(consumed)
488
+ if j is None:
489
+ consumed = self.n
490
+ break
491
+ cont = self.lines[j]
492
+ if cont.indent <= indent or cont.body.strip() in _DOC_MARKERS:
493
+ consumed = j
494
+ break
495
+ if node.style in ("single", "double"):
496
+ break # quoted multi-line fora do subset
497
+ if self._is_seq_item(cont.body) or self._split_key(cont.body):
498
+ break # estrutura — não é continuação
499
+ extra.append(_cut_comment(cont.body).strip())
500
+ consumed = j + 1
501
+ if extra:
502
+ node = YNode(
503
+ kind="scalar",
504
+ value=" ".join([node.value, *extra]),
505
+ style="plain-folded",
506
+ span=SourceSpan(
507
+ start_byte=node.span.start_byte,
508
+ end_byte=self._eol_span(consumed - 1).end_byte,
509
+ start_line=node.span.start_line,
510
+ start_col=node.span.start_col,
511
+ end_line=consumed,
512
+ end_col=len(self.lines[consumed - 1].raw),
513
+ ),
514
+ )
515
+ return node, consumed
516
+
517
+ # --- valores inline ------------------------------------------------
518
+
519
+ def _parse_inline(
520
+ self, text: str, line_idx: int, col: int,
521
+ parent_indent: int, next_i: int,
522
+ ) -> tuple[YNode, int]:
523
+ """Valor na mesma linha do ``key:``/``- `` — escalar, flow ou bloco."""
524
+ head = text[0]
525
+ if head in "|>":
526
+ m = _BLOCK_HEADER.match(text)
527
+ if m is None:
528
+ self._diag(
529
+ "block-scalar-header", "error",
530
+ f"unsupported block scalar header {text!r}",
531
+ self.lines[line_idx].num, col,
532
+ )
533
+ return self._plain_node(text, line_idx, col), next_i
534
+ style = "literal" if m.group(1) == "|" else "folded"
535
+ return self._block_scalar(
536
+ parent_indent, next_i, style, m.group(2), line_idx, col,
537
+ )
538
+ if head == "&":
539
+ self._diag(
540
+ "anchor-unsupported", "warning",
541
+ f"anchor {text.split()[0]!r} outside subset; value kept",
542
+ self.lines[line_idx].num, col,
543
+ )
544
+ m = _ANCHOR_TOKEN.match(text)
545
+ rest = text[m.end():].lstrip() if m else ""
546
+ if not rest:
547
+ child, i2 = self.parse_block(next_i, parent_indent + 1)
548
+ return (
549
+ child if child is not None else self._null_at(line_idx)
550
+ ), i2
551
+ return self._plain_node(rest, line_idx, col), next_i
552
+ if head == "*":
553
+ self._diag(
554
+ "alias-unsupported", "error",
555
+ f"alias {text.split()[0]!r} cannot be resolved — no "
556
+ "anchor table; value reported as null",
557
+ self.lines[line_idx].num, col,
558
+ )
559
+ return self._null_at(line_idx), next_i
560
+ if head == "!":
561
+ self._diag(
562
+ "tag-unsupported", "warning",
563
+ f"tag {text.split()[0]!r} outside subset; value kept",
564
+ self.lines[line_idx].num, col,
565
+ )
566
+ m = _TAG_TOKEN.match(text)
567
+ rest = text[m.end():].lstrip() if m else ""
568
+ if not rest:
569
+ child, i2 = self.parse_block(next_i, parent_indent + 1)
570
+ return (
571
+ child if child is not None else self._null_at(line_idx)
572
+ ), i2
573
+ return self._plain_node(rest, line_idx, col), next_i
574
+ if head in "[{":
575
+ return self._parse_flow(text, line_idx, col), next_i
576
+ node, _ = self._inline_scalar(text, line_idx, col)
577
+ return node, next_i
578
+
579
+ def _plain_node(self, text: str, line_idx: int, col: int) -> YNode:
580
+ value = _cut_comment(text).strip()
581
+ return YNode(
582
+ kind="scalar", value=value, style="plain",
583
+ span=self._span(line_idx, col, line_idx, col + len(text)),
584
+ )
585
+
586
+ def _inline_scalar(
587
+ self, text: str, line_idx: int, col: int
588
+ ) -> tuple[YNode, int]:
589
+ """Escalar inline: quoted ou plain com comment-cut."""
590
+ if text and text[0] in "'\"":
591
+ value, end = self._decode_quoted(text, 0, line_idx, col)
592
+ trailing = text[end:].strip()
593
+ if trailing and not trailing.startswith("#"):
594
+ self._diag(
595
+ "scalar-trailing-content", "warning",
596
+ "content after closing quote ignored",
597
+ self.lines[line_idx].num, col + end,
598
+ )
599
+ return (
600
+ YNode(
601
+ kind="scalar", value=value,
602
+ style="single" if text[0] == "'" else "double",
603
+ span=self._span(line_idx, col, line_idx, col + end),
604
+ ),
605
+ end,
606
+ )
607
+ value = _cut_comment(text).strip()
608
+ return (
609
+ YNode(
610
+ kind="scalar", value=value, style="plain",
611
+ span=self._span(
612
+ line_idx, col, line_idx, col + len(text)
613
+ ),
614
+ ),
615
+ len(text),
616
+ )
617
+
618
+ # --- coleções flow --------------------------------------------------
619
+
620
+ def _parse_flow(self, text: str, line_idx: int, col: int) -> YNode:
621
+ node, pos = self._flow_node(text, 0, line_idx, col)
622
+ if node is None:
623
+ return self._null_at(line_idx)
624
+ trailing = text[pos:].strip()
625
+ if trailing and not trailing.startswith("#"):
626
+ self._diag(
627
+ "flow-trailing-content", "error",
628
+ "content after flow collection ignored",
629
+ self.lines[line_idx].num, col + pos,
630
+ )
631
+ return YNode(
632
+ kind=node.kind, value=node.value, style="flow",
633
+ pairs=node.pairs, items=node.items,
634
+ span=self._span(line_idx, col, line_idx, col + pos),
635
+ )
636
+
637
+ def _flow_node(
638
+ self, s: str, pos: int, line_idx: int, col: int
639
+ ) -> tuple[YNode | None, int]:
640
+ pos = self._flow_ws(s, pos)
641
+ if pos >= len(s):
642
+ self._diag(
643
+ "flow-unexpected-end", "error",
644
+ "flow collection ended unexpectedly",
645
+ self.lines[line_idx].num, col + pos,
646
+ )
647
+ return None, pos
648
+ ch = s[pos]
649
+ if ch == "[":
650
+ return self._flow_seq(s, pos, line_idx, col)
651
+ if ch == "{":
652
+ return self._flow_map(s, pos, line_idx, col)
653
+ if ch in "'\"":
654
+ value, end = self._decode_quoted(s, pos, line_idx, col)
655
+ return (
656
+ YNode(kind="scalar", value=value,
657
+ style="single" if s[pos] == "'" else "double",
658
+ span=self._span(
659
+ line_idx, col + pos, line_idx, col + end)),
660
+ end,
661
+ )
662
+ # escalar plain dentro de flow: até , ] } — ou ':' seguido de
663
+ # separador/espaço (é o separador de chave do flow map)
664
+ start = pos
665
+ while pos < len(s):
666
+ ch = s[pos]
667
+ if ch in ",]}":
668
+ break
669
+ if (
670
+ ch == ":"
671
+ and (pos + 1 == len(s) or s[pos + 1] in " ,]}")
672
+ ):
673
+ break
674
+ pos += 1
675
+ raw = s[start:pos].strip()
676
+ return (
677
+ YNode(kind="scalar", value=raw, style="flow",
678
+ span=self._span(
679
+ line_idx, col + start, line_idx, col + pos)),
680
+ pos,
681
+ )
682
+
683
+ def _flow_ws(self, s: str, pos: int) -> int:
684
+ while pos < len(s) and s[pos] in " \t":
685
+ pos += 1
686
+ return pos
687
+
688
+ def _flow_seq(
689
+ self, s: str, pos: int, line_idx: int, col: int
690
+ ) -> tuple[YNode | None, int]:
691
+ items: list[YNode] = []
692
+ start = pos
693
+ pos += 1
694
+ while True:
695
+ pos = self._flow_ws(s, pos)
696
+ if pos >= len(s):
697
+ self._diag(
698
+ "flow-unclosed", "error",
699
+ "flow sequence missing ']'", self.lines[line_idx].num,
700
+ col + start,
701
+ )
702
+ return (
703
+ YNode(kind="seq", items=tuple(items),
704
+ span=self._span(
705
+ line_idx, col + start, line_idx, col + pos)),
706
+ pos,
707
+ )
708
+ if s[pos] == "]":
709
+ return (
710
+ YNode(kind="seq", items=tuple(items),
711
+ span=self._span(
712
+ line_idx, col + start, line_idx, col + pos + 1)),
713
+ pos + 1,
714
+ )
715
+ if s[pos] == ",":
716
+ pos += 1
717
+ continue
718
+ item, pos = self._flow_node(s, pos, line_idx, col)
719
+ if item is None:
720
+ return (
721
+ YNode(kind="seq", items=tuple(items),
722
+ span=self._span(
723
+ line_idx, col + start, line_idx, col + pos)),
724
+ pos,
725
+ )
726
+ items.append(item)
727
+
728
+ def _flow_map(
729
+ self, s: str, pos: int, line_idx: int, col: int
730
+ ) -> tuple[YNode | None, int]:
731
+ pairs: list[tuple[YNode, YNode]] = []
732
+ start = pos
733
+ pos += 1
734
+ while True:
735
+ pos = self._flow_ws(s, pos)
736
+ if pos >= len(s):
737
+ self._diag(
738
+ "flow-unclosed", "error",
739
+ "flow mapping missing '}'", self.lines[line_idx].num,
740
+ col + start,
741
+ )
742
+ return (
743
+ YNode(kind="map", pairs=tuple(pairs),
744
+ span=self._span(
745
+ line_idx, col + start, line_idx, col + pos)),
746
+ pos,
747
+ )
748
+ if s[pos] == "}":
749
+ return (
750
+ YNode(kind="map", pairs=tuple(pairs),
751
+ span=self._span(
752
+ line_idx, col + start, line_idx, col + pos + 1)),
753
+ pos + 1,
754
+ )
755
+ if s[pos] == ",":
756
+ pos += 1
757
+ continue
758
+ # chave flow: escalar simples até ':'
759
+ key_node, pos = self._flow_node(s, pos, line_idx, col)
760
+ if key_node is None:
761
+ break
762
+ pos = self._flow_ws(s, pos)
763
+ if pos < len(s) and s[pos] == ":":
764
+ pos += 1
765
+ pos = self._flow_ws(s, pos)
766
+ if pos < len(s) and s[pos] in ",}":
767
+ val = self._null_at(line_idx)
768
+ else:
769
+ child, pos = self._flow_node(s, pos, line_idx, col)
770
+ val = child if child is not None else self._null_at(line_idx)
771
+ else:
772
+ val = self._null_at(line_idx)
773
+ pairs.append((key_node, val))
774
+ return (
775
+ YNode(kind="map", pairs=tuple(pairs),
776
+ span=self._span(
777
+ line_idx, col + start, line_idx, col + pos)),
778
+ pos,
779
+ )
780
+
781
+ # --- escalares de bloco ---------------------------------------------
782
+
783
+ def _block_scalar(
784
+ self, parent_indent: int, i: int, style: str,
785
+ chomp: str, header_line: int, header_col: int,
786
+ ) -> tuple[YNode, int]:
787
+ """``|``/``>`` com chomping; consome linhas mais indentadas.
788
+
789
+ Linhas em branco pertencem ao escalar; comentários dentro dele
790
+ são CONTEÚDO (não são stripados) — ex.: ``run: | # comenta``.
791
+ """
792
+ vals, j = self._block_scalar_lines(parent_indent, i)
793
+ value = self._block_scalar_text(vals, style, chomp)
794
+ end_line = vals[-1][0] if vals else header_line
795
+ return (
796
+ YNode(
797
+ kind="scalar", value=value, style=style,
798
+ span=self._span(
799
+ header_line, header_col,
800
+ end_line, len(self.lines[end_line].raw),
801
+ ),
802
+ ),
803
+ j,
804
+ )
805
+
806
+ def _block_scalar_lines(
807
+ self, parent_indent: int, i: int
808
+ ) -> tuple[list[tuple[int, str, bool]], int]:
809
+ """Coleta linhas do escalar: (índice, texto, more-indented)."""
810
+ vals: list[tuple[int, str, bool]] = []
811
+ content_indent = -1
812
+ j = i
813
+ while j < self.n:
814
+ ln = self.lines[j]
815
+ if not ln.body.strip():
816
+ vals.append((j, "", False))
817
+ j += 1
818
+ continue
819
+ if ln.indent <= parent_indent:
820
+ break
821
+ if content_indent < 0:
822
+ content_indent = ln.indent
823
+ if ln.indent < content_indent:
824
+ self._diag(
825
+ "block-scalar-indent", "warning",
826
+ "line less indented than block scalar content",
827
+ ln.num, ln.indent,
828
+ )
829
+ keep = min(ln.indent, content_indent)
830
+ vals.append((j, ln.raw[keep:], ln.indent > content_indent))
831
+ j += 1
832
+ # trailing blanks além do último conteúdo não geram linhas extras
833
+ while vals and vals[-1][1] == "":
834
+ vals.pop()
835
+ return vals, j
836
+
837
+ @staticmethod
838
+ def _block_scalar_text(
839
+ vals: list[tuple[int, str, bool]], style: str, chomp: str
840
+ ) -> str:
841
+ if style == "literal":
842
+ text = "\n".join(v for _, v, _ in vals)
843
+ else:
844
+ parts: list[str] = []
845
+ for k, (_, v, more) in enumerate(vals):
846
+ if k > 0:
847
+ prev_blank = vals[k - 1][1] == ""
848
+ prev_more = vals[k - 1][2]
849
+ if v == "" or prev_blank or more or prev_more:
850
+ parts.append("\n")
851
+ else:
852
+ parts.append(" ")
853
+ parts.append(v)
854
+ text = "".join(parts)
855
+ core = text.rstrip("\n")
856
+ if chomp == "-":
857
+ return core
858
+ if chomp == "+":
859
+ return text if text.endswith("\n") or not text else text + "\n"
860
+ return core + ("\n" if core else "")
861
+
862
+ # --- quotes -----------------------------------------------------------
863
+
864
+ def _quoted_end(self, s: str, pos: int) -> int | None:
865
+ """Posição após a aspa de fechamento; ``None`` se não fecha."""
866
+ q = s[pos]
867
+ pos += 1
868
+ while pos < len(s):
869
+ ch = s[pos]
870
+ if ch == q:
871
+ if q == "'" and pos + 1 < len(s) and s[pos + 1] == "'":
872
+ pos += 2
873
+ continue
874
+ return pos + 1
875
+ if q == '"' and ch == "\\":
876
+ pos += 2
877
+ continue
878
+ pos += 1
879
+ return None
880
+
881
+ def _decode_quoted(
882
+ self, s: str, pos: int, line_idx: int, col: int
883
+ ) -> tuple[str, int]:
884
+ q = s[pos]
885
+ out: list[str] = []
886
+ pos += 1
887
+ while pos < len(s):
888
+ ch = s[pos]
889
+ if ch == q:
890
+ if q == "'" and pos + 1 < len(s) and s[pos + 1] == "'":
891
+ out.append("'")
892
+ pos += 2
893
+ continue
894
+ return "".join(out), pos + 1
895
+ if q == '"' and ch == "\\" and pos + 1 < len(s):
896
+ esc = s[pos + 1]
897
+ if esc in _ESCAPES:
898
+ out.append(_ESCAPES[esc])
899
+ else:
900
+ self._diag(
901
+ "unknown-escape", "warning",
902
+ f"unknown escape \\{esc} kept literally",
903
+ self.lines[line_idx].num, col + pos,
904
+ )
905
+ out.append(ch + esc)
906
+ pos += 2
907
+ continue
908
+ out.append(ch)
909
+ pos += 1
910
+ self._diag(
911
+ "unterminated-quote", "error",
912
+ "quoted scalar never closes", self.lines[line_idx].num, col,
913
+ )
914
+ return "".join(out), pos
915
+
916
+ def _decode_key(self, raw: str, line_idx: int) -> str:
917
+ if raw and raw[0] in "'\"":
918
+ value, _ = self._decode_quoted(
919
+ raw, 0, line_idx, self.lines[line_idx].indent
920
+ )
921
+ return value
922
+ return raw
923
+
924
+
925
+ @dataclass(frozen=True)
926
+ class MiniYamlResult:
927
+ """Resultado honesto: árvore + diagnósticos, nunca exceção."""
928
+
929
+ root: YNode | None
930
+ diagnostics: tuple[YamlDiagnostic, ...] = field(
931
+ default_factory=tuple
932
+ )
933
+
934
+
935
+ def parse(text: str) -> MiniYamlResult:
936
+ """Parse do subconjunto YAML; falhas viram diagnósticos."""
937
+ parser = _Parser(text)
938
+ try:
939
+ root = parser.parse_document()
940
+ except Exception as exc: # robustez: bug vira diagnóstico, nunca crash
941
+ parser._diag(
942
+ "parser-internal-error", "error",
943
+ f"internal parser failure: {exc}",
944
+ )
945
+ return MiniYamlResult(None, tuple(parser.diags))
946
+ return MiniYamlResult(root, tuple(parser.diags))