codec-cortex 0.3.2__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 (81) hide show
  1. codec_cortex-0.3.2.dist-info/METADATA +183 -0
  2. codec_cortex-0.3.2.dist-info/RECORD +81 -0
  3. codec_cortex-0.3.2.dist-info/WHEEL +5 -0
  4. codec_cortex-0.3.2.dist-info/entry_points.txt +2 -0
  5. codec_cortex-0.3.2.dist-info/licenses/LICENSE +21 -0
  6. codec_cortex-0.3.2.dist-info/scm_file_list.json +124 -0
  7. codec_cortex-0.3.2.dist-info/scm_version.json +8 -0
  8. codec_cortex-0.3.2.dist-info/top_level.txt +1 -0
  9. cortex/__init__.py +22 -0
  10. cortex/__main__.py +7 -0
  11. cortex/_version.py +24 -0
  12. cortex/cli/__init__.py +5 -0
  13. cortex/cli/commands/__init__.py +150 -0
  14. cortex/cli/commands/add.py +89 -0
  15. cortex/cli/commands/compile.py +31 -0
  16. cortex/cli/commands/delete.py +44 -0
  17. cortex/cli/commands/diagram.py +130 -0
  18. cortex/cli/commands/diff.py +135 -0
  19. cortex/cli/commands/doctor.py +74 -0
  20. cortex/cli/commands/format.py +29 -0
  21. cortex/cli/commands/get.py +45 -0
  22. cortex/cli/commands/glossary.py +98 -0
  23. cortex/cli/commands/list.py +44 -0
  24. cortex/cli/commands/micro.py +79 -0
  25. cortex/cli/commands/move.py +45 -0
  26. cortex/cli/commands/new.py +80 -0
  27. cortex/cli/commands/recover.py +90 -0
  28. cortex/cli/commands/render.py +93 -0
  29. cortex/cli/commands/update.py +81 -0
  30. cortex/cli/commands/v2_canonicalize.py +162 -0
  31. cortex/cli/commands/v2_compare.py +74 -0
  32. cortex/cli/commands/v2_convert.py +183 -0
  33. cortex/cli/commands/v2_explain_loss.py +97 -0
  34. cortex/cli/commands/v2_inspect.py +113 -0
  35. cortex/cli/commands/v2_roundtrip.py +104 -0
  36. cortex/cli/commands/v2_roundtrip_bidir.py +128 -0
  37. cortex/cli/commands/v2_verify_view.py +65 -0
  38. cortex/cli/commands/verify.py +119 -0
  39. cortex/cli/main.py +636 -0
  40. cortex/core/__init__.py +85 -0
  41. cortex/core/ast.py +313 -0
  42. cortex/core/compare.py +180 -0
  43. cortex/core/document_kind.py +525 -0
  44. cortex/core/errors.py +399 -0
  45. cortex/core/lexer.py +267 -0
  46. cortex/core/parser.py +671 -0
  47. cortex/core/validator.py +268 -0
  48. cortex/core/writer.py +249 -0
  49. cortex/crud/__init__.py +17 -0
  50. cortex/crud/mutations.py +342 -0
  51. cortex/crud/selectors.py +95 -0
  52. cortex/crud/transactions.py +213 -0
  53. cortex/glossary/__init__.py +21 -0
  54. cortex/glossary/contracts.py +37 -0
  55. cortex/glossary/minimal.py +94 -0
  56. cortex/glossary/model.py +77 -0
  57. cortex/glossary/resolver.py +96 -0
  58. cortex/hcortex/__init__.py +35 -0
  59. cortex/hcortex/edit_parser.py +489 -0
  60. cortex/hcortex/edit_renderer.py +158 -0
  61. cortex/hcortex/markdown_model.py +81 -0
  62. cortex/hcortex/profiles.py +166 -0
  63. cortex/hcortex/read_renderer.py +342 -0
  64. cortex/hcortex/recovery.py +782 -0
  65. cortex/py.typed +0 -0
  66. cortex/templates/__init__.py +8 -0
  67. cortex/templates/brain.py +118 -0
  68. cortex/templates/minimal_glossary.py +42 -0
  69. cortex/templates/package.py +91 -0
  70. cortex/templates/skill.py +91 -0
  71. cortex/v2/__init__.py +30 -0
  72. cortex/v2/diagnostics.py +57 -0
  73. cortex/v2/encoder.py +1106 -0
  74. cortex/v2/equivalence.py +323 -0
  75. cortex/v2/hcortex_parser.py +615 -0
  76. cortex/v2/hcortex_renderer.py +450 -0
  77. cortex/v2/ir.py +223 -0
  78. cortex/v2/parser.py +655 -0
  79. cortex/v2/view.py +425 -0
  80. cortex/v2/view_renderer.py +474 -0
  81. cortex/v2/writer.py +301 -0
cortex/core/parser.py ADDED
@@ -0,0 +1,671 @@
1
+ """``.cortex`` parser — turns raw text into a :class:`CortexDocument` AST.
2
+
3
+ The parser follows the algorithm in Section 6 of the spec:
4
+
5
+ 1. Lex the text into tokens.
6
+ 2. Walk tokens, building sections and entries.
7
+ 3. Validate that ``$0`` is the first section.
8
+ 4. Resolve every entry's expansion type from ``$0``.
9
+ 5. Parse each entry's body according to its type.
10
+ 6. Return a fully populated :class:`CortexDocument`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ from . import errors
19
+ from .ast import (
20
+ AttrsPosContract,
21
+ CortexDocument,
22
+ Entry,
23
+ Glossary,
24
+ MicroDef,
25
+ Section,
26
+ SigilDef,
27
+ TypeDef,
28
+ compute_entry_hash,
29
+ compute_document_hash,
30
+ )
31
+ from .errors import (
32
+ E017_UNPARSED_LINE,
33
+ E003_UNKNOWN_SIGIL,
34
+ E004_UNKNOWN_TYPE,
35
+ E006_INVALID_ATTRS,
36
+ E007_ATTRS_POS_CONTRACT_MISSING,
37
+ CANONICAL_TYPES,
38
+ CANONICAL_MICRO,
39
+ GLOSSARY_RESERVED_SIGILS,
40
+ )
41
+ from .lexer import lex
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # $0 glossary parser
46
+ # ---------------------------------------------------------------------------
47
+
48
+ # Pipe-separated declaration in a comment line:
49
+ # IDN | identity | attrs | B | Semantic | Identity descriptor
50
+ _GLOSSARY_DECL_RE = re.compile(
51
+ r"""^\s*\#?\s*
52
+ (?P<sigil>[A-Z][A-Z0-9_]*|!)
53
+ \s*\|\s*
54
+ (?P<name>[A-Za-z_][A-Za-z0-9_]*)
55
+ \s*\|\s*
56
+ (?P<type>[A-Za-z\-]+)
57
+ \s*\|\s*
58
+ (?P<risk>[A-Z]) # single uppercase letter (B/H/M canonical, others allowed)
59
+ \s*\|\s*
60
+ (?P<layer>[A-Za-z/]+)
61
+ \s*\|\s*
62
+ (?P<desc>.+?)
63
+ \s*$
64
+ """,
65
+ re.VERBOSE,
66
+ )
67
+
68
+ # Type declaration: "# attrs = key:value pairs"
69
+ # Uses \w (Unicode-aware in Python 3) to accept type names like "relación"
70
+ _TYPE_DECL_RE = re.compile(
71
+ r"""^\s*\#\s*
72
+ (?P<name>[\w\-]+)
73
+ \s*=\s*
74
+ (?P<desc>.+?)
75
+ \s*$
76
+ """,
77
+ re.VERBOSE,
78
+ )
79
+
80
+ # Micro-token declaration: "# cur=current pln=planned ..."
81
+ _MICRO_PAIR_RE = re.compile(r"\b(?P<tok>[a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?P<val>[^\s,]+)")
82
+
83
+ # attrs-pos contract declaration inside $0:
84
+ # # contract: HDL | operation | status | requires
85
+ _CONTRACT_RE = re.compile(
86
+ r"""^\s*\#\s*contract\s*:\s*
87
+ (?P<sigil>[A-Z][A-Z0-9_]*|!)
88
+ \s*\|\s*
89
+ (?P<fields>.+?)
90
+ \s*$
91
+ """,
92
+ re.VERBOSE,
93
+ )
94
+
95
+ # Entry-form declarations inside $0 (alternative explicit form):
96
+ # GSIG:IDN{name:"identity", type:"attrs", ...}
97
+ # These are emitted by the writer and re-parsed transparently.
98
+ _GSIG_RE = re.compile(r"^GSIG:(?P<sigil>[A-Z][A-Z0-9_]*|!)\s*\{(?P<body>.*)\}\s*$")
99
+ _GTYP_RE = re.compile(r"^GTYP:(?P<name>[a-zA-Z\-]+)\s*\{(?P<body>.*)\}\s*$")
100
+ _GMIC_RE = re.compile(r"^GMIC:(?P<token>[a-zA-Z_][a-zA-Z0-9_]*)\s*\{(?P<body>.*)\}\s*$")
101
+ _GCON_RE = re.compile(r"^GCON:(?P<sigil>[A-Z][A-Z0-9_]*|!)\s*\{(?P<body>.*)\}\s*$")
102
+
103
+
104
+ def parse_attrs_body(body: str) -> Dict[str, Any]:
105
+ """Parse an ``attrs`` body like ``key:"value", key2:42`` into a dict.
106
+
107
+ Values may be:
108
+ - double-quoted strings (escapes preserved)
109
+ - bare numbers (int/float)
110
+ - booleans (true/false)
111
+ - bare words (treated as strings)
112
+
113
+ Raises :class:`InvalidAttrsError` on malformed input.
114
+ """
115
+
116
+ out: Dict[str, Any] = {}
117
+ s = body.strip()
118
+ if not s:
119
+ return out
120
+ # Tokenize: key : value (, key : value)*
121
+ i = 0
122
+ n = len(s)
123
+ while i < n:
124
+ # skip whitespace and leading commas
125
+ while i < n and (s[i].isspace() or s[i] == ","):
126
+ i += 1
127
+ if i >= n:
128
+ break
129
+ # read key (identifier)
130
+ m = re.match(r"([A-Za-z_][A-Za-z0-9_]*)", s[i:])
131
+ if not m:
132
+ raise errors.InvalidAttrsError(f"expected key at position {i}: {s[i:i+20]!r}")
133
+ key = m.group(1)
134
+ i += len(key)
135
+ # skip whitespace
136
+ while i < n and s[i].isspace():
137
+ i += 1
138
+ if i >= n or s[i] != ":":
139
+ raise errors.InvalidAttrsError(f"expected ':' after key {key!r}")
140
+ i += 1
141
+ # skip whitespace
142
+ while i < n and s[i].isspace():
143
+ i += 1
144
+ if i >= n:
145
+ raise errors.InvalidAttrsError(f"missing value for key {key!r}")
146
+ # read value
147
+ if s[i] == '"':
148
+ # quoted string
149
+ j = i + 1
150
+ buf: List[str] = []
151
+ escape = False
152
+ while j < n:
153
+ ch = s[j]
154
+ if escape:
155
+ if ch == "n":
156
+ buf.append("\n")
157
+ elif ch == "t":
158
+ buf.append("\t")
159
+ elif ch == "r":
160
+ buf.append("\r")
161
+ else:
162
+ buf.append(ch)
163
+ escape = False
164
+ elif ch == "\\":
165
+ escape = True
166
+ elif ch == '"':
167
+ break
168
+ else:
169
+ buf.append(ch)
170
+ j += 1
171
+ if j >= n:
172
+ raise errors.InvalidAttrsError(f"unterminated string for key {key!r}")
173
+ value: Any = "".join(buf)
174
+ i = j + 1
175
+ else:
176
+ # bare value: read until , or end
177
+ j = i
178
+ while j < n and s[j] != ",":
179
+ j += 1
180
+ raw = s[i:j].strip()
181
+ i = j
182
+ # interpret
183
+ if raw in ("true", "false"):
184
+ value = (raw == "true")
185
+ elif raw.lower() in ("null", "none", "nil", "undefined"):
186
+ # v1.1.7 P0-3 + v1.1.8: convert null-like literals to None
187
+ value = None
188
+ elif re.fullmatch(r"-?\d+", raw):
189
+ value = int(raw)
190
+ elif re.fullmatch(r"-?\d+\.\d+", raw):
191
+ value = float(raw)
192
+ else:
193
+ value = raw
194
+ out[key] = value
195
+ return out
196
+
197
+
198
+ def parse_attrs_pos_body(body: str, contract: AttrsPosContract) -> Dict[str, Any]:
199
+ """Parse an ``attrs-pos`` body using the sigil's positional contract.
200
+
201
+ The body is a ``|``-separated list of values. The number of values
202
+ must match the contract length (trailing missing fields are filled
203
+ with ``None``).
204
+
205
+ Re-audit H-RA-05: ``|`` is the delimiter and CANNOT appear inside
206
+ values, even between quotes. This is the canonical contract per
207
+ SKILL.md §4.3 (``attrs-pos`` = "máxima compresión, solo cuando el
208
+ contrato es estable"). If a value needs ``|``, use ``attrs`` instead.
209
+ Quote-aware splitting is intentionally NOT implemented to keep the
210
+ contract deterministic.
211
+ """
212
+
213
+ parts = [p.strip() for p in body.split("|")]
214
+ # strip surrounding quotes from each part
215
+ cleaned: List[str] = []
216
+ for p in parts:
217
+ if len(p) >= 2 and p[0] == '"' and p[-1] == '"':
218
+ cleaned.append(p[1:-1])
219
+ else:
220
+ cleaned.append(p)
221
+ out: Dict[str, Any] = {}
222
+ for idx, field_name in enumerate(contract.fields):
223
+ if idx < len(cleaned):
224
+ out[field_name] = cleaned[idx]
225
+ else:
226
+ out[field_name] = None
227
+ return out
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Entry body extraction
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def _extract_body(raw: str) -> str:
235
+ """Return the text between the outer ``{`` and the matching ``}``."""
236
+
237
+ # raw is the full entry text including SIGIL:name{...}
238
+ start = raw.find("{")
239
+ if start == -1:
240
+ return ""
241
+ # find matching close brace (depth-aware)
242
+ depth = 0
243
+ in_string = False
244
+ escape = False
245
+ for i in range(start, len(raw)):
246
+ ch = raw[i]
247
+ if escape:
248
+ escape = False
249
+ continue
250
+ if ch == "\\":
251
+ escape = True
252
+ continue
253
+ if in_string:
254
+ if ch == '"':
255
+ in_string = False
256
+ continue
257
+ if ch == '"':
258
+ in_string = True
259
+ continue
260
+ if ch == "{":
261
+ depth += 1
262
+ elif ch == "}":
263
+ depth -= 1
264
+ if depth == 0:
265
+ return raw[start + 1 : i]
266
+ return raw[start + 1 :]
267
+
268
+
269
+ def _entry_body_lines(raw: str) -> Tuple[str, int, int]:
270
+ """Return ``(body, start_line, end_line)`` for a raw entry text.
271
+
272
+ The body is the verbatim content between the outer ``{`` and ``}``,
273
+ including any inner newlines.
274
+ """
275
+
276
+ body = _extract_body(raw)
277
+ return body
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Glossary construction
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _build_glossary_from_section(section: Section) -> Glossary:
285
+ """Parse a ``$0`` section into a :class:`Glossary`.
286
+
287
+ The section may contain either:
288
+ (a) Pipe-separated declaration comments:
289
+ # IDN | identity | attrs | B | Semantic | Identity
290
+ # attrs = key:value pairs
291
+ # cur=current pln=planned
292
+ # contract: HDL | operation | status | requires
293
+ (b) Explicit GSIG/GTYP/GMIC/GCON entries.
294
+
295
+ Both forms are accepted and may be mixed.
296
+ """
297
+
298
+ g = Glossary()
299
+ # Seed with canonical types so any file is operable even if $0 omits them
300
+ for t in CANONICAL_TYPES:
301
+ g.add_type(TypeDef(name=t, description="canonical type"))
302
+
303
+ # Seed canonical micro-tokens as defaults
304
+ for tok, val in CANONICAL_MICRO.items():
305
+ g.add_micro(MicroDef(token=tok, value=val))
306
+
307
+ # Process explicit entries first (GSIG/GTYP/GMIC/GCON)
308
+ for entry in section.entries:
309
+ if entry.sigil == "GSIG":
310
+ attrs = parse_attrs_body(entry.raw[entry.raw.find("{") + 1 : entry.raw.rfind("}")])
311
+ sigil = attrs.get("sigil") or entry.name
312
+ sd = SigilDef(
313
+ sigil=sigil,
314
+ name=attrs.get("name", sigil.lower()),
315
+ type=attrs.get("type", "attrs"),
316
+ risk=attrs.get("risk", "M"),
317
+ layer=attrs.get("layer", "Semantic"),
318
+ description=attrs.get("description", ""),
319
+ )
320
+ g.add_sigil(sd)
321
+ elif entry.sigil == "GTYP":
322
+ attrs = parse_attrs_body(entry.raw[entry.raw.find("{") + 1 : entry.raw.rfind("}")])
323
+ g.add_type(TypeDef(name=attrs.get("name", entry.name),
324
+ description=attrs.get("description", "")))
325
+ elif entry.sigil == "GMIC":
326
+ attrs = parse_attrs_body(entry.raw[entry.raw.find("{") + 1 : entry.raw.rfind("}")])
327
+ tok = attrs.get("token") or entry.name
328
+ g.add_micro(MicroDef(token=tok, value=attrs.get("value", "")))
329
+ elif entry.sigil == "GCON":
330
+ attrs = parse_attrs_body(entry.raw[entry.raw.find("{") + 1 : entry.raw.rfind("}")])
331
+ sigil = attrs.get("sigil") or entry.name
332
+ fields_str = attrs.get("fields", "")
333
+ if isinstance(fields_str, str) and fields_str:
334
+ fields = [f.strip() for f in fields_str.split("|")]
335
+ else:
336
+ fields = []
337
+ g.add_contract(AttrsPosContract(sigil=sigil, fields=fields))
338
+
339
+ # Process comment-line declarations preserved in section.comments
340
+ for line in section.comments:
341
+ m = _GLOSSARY_DECL_RE.match(line)
342
+ if m:
343
+ g.add_sigil(SigilDef(
344
+ sigil=m.group("sigil"),
345
+ name=m.group("name"),
346
+ type=m.group("type"),
347
+ risk=m.group("risk"),
348
+ layer=m.group("layer"),
349
+ description=m.group("desc").strip(),
350
+ ))
351
+ continue
352
+ m = _CONTRACT_RE.match(line)
353
+ if m:
354
+ fields = [f.strip() for f in m.group("fields").split("|")]
355
+ g.add_contract(AttrsPosContract(sigil=m.group("sigil"), fields=fields))
356
+ continue
357
+ # Type declaration: ONLY if line has exactly one '=' and the name
358
+ # looks like a type word (alphabetic, possibly with '-' or '_').
359
+ # Micro-token lines have MULTIPLE '=' pairs (e.g. `cur=cur pln=pln`).
360
+ eq_count = line.count("=")
361
+ if eq_count == 1:
362
+ m = _TYPE_DECL_RE.match(line)
363
+ if m:
364
+ name = m.group("name")
365
+ # Heuristic: type names are full words (attrs, cuerpo, bloque,
366
+ # attrs-pos, relación). Reject if the name is ≤4 chars and
367
+ # not in the canonical type set.
368
+ canonical_type_names = {"attrs", "cuerpo", "bloque", "attrs-pos", "relación"}
369
+ if name in canonical_type_names or len(name) > 4:
370
+ g.add_type(TypeDef(name=name, description=m.group("desc")))
371
+ continue
372
+ # Fall through: try micro-token pairs (handles multi-pair lines)
373
+ for tok, val in _MICRO_PAIR_RE.findall(line):
374
+ # Only add as micro if it's NOT a type-like word
375
+ if tok in {"attrs", "cuerpo", "bloque", "attrs-pos", "relación"}:
376
+ continue
377
+ g.add_micro(MicroDef(token=tok, value=val))
378
+
379
+ # Also scan entries' raw for comment-style declarations (legacy)
380
+ for entry in section.entries:
381
+ if entry.sigil in GLOSSARY_RESERVED_SIGILS:
382
+ continue
383
+ for line in entry.raw.split("\n"):
384
+ m = _GLOSSARY_DECL_RE.match(line)
385
+ if m:
386
+ g.add_sigil(SigilDef(
387
+ sigil=m.group("sigil"),
388
+ name=m.group("name"),
389
+ type=m.group("type"),
390
+ risk=m.group("risk"),
391
+ layer=m.group("layer"),
392
+ description=m.group("desc").strip(),
393
+ ))
394
+ continue
395
+ m = _CONTRACT_RE.match(line)
396
+ if m:
397
+ fields = [f.strip() for f in m.group("fields").split("|")]
398
+ g.add_contract(AttrsPosContract(sigil=m.group("sigil"), fields=fields))
399
+ continue
400
+ m = _TYPE_DECL_RE.match(line)
401
+ if m:
402
+ g.add_type(TypeDef(name=m.group("name"), description=m.group("desc")))
403
+ continue
404
+ for tok, val in _MICRO_PAIR_RE.findall(line):
405
+ g.add_micro(MicroDef(token=tok, value=val))
406
+
407
+ return g
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Top-level parser
412
+ # ---------------------------------------------------------------------------
413
+
414
+ def parse_cortex(text: str, path: str = "<string>") -> CortexDocument:
415
+ """Parse ``.cortex`` source text into a :class:`CortexDocument`.
416
+
417
+ The parser is deterministic and never invokes an LLM. Validation
418
+ errors are raised as :class:`~cortex.core.errors.CortexError`;
419
+ non-fatal findings are recorded in ``doc.diagnostics``.
420
+ """
421
+
422
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
423
+ if not text.endswith("\n"):
424
+ text = text + "\n"
425
+
426
+ doc = CortexDocument()
427
+ doc.meta = {
428
+ "path": path,
429
+ "format": ".cortex",
430
+ "version": None,
431
+ "hash": compute_document_hash(text),
432
+ }
433
+
434
+ tokens = lex(text)
435
+ current_section: Optional[Section] = None
436
+
437
+ for tok in tokens:
438
+ if tok.kind.value == "blank":
439
+ continue
440
+ if tok.kind.value == "comment":
441
+ # Preserve comment lines inside the current section (esp. $0)
442
+ if current_section is not None:
443
+ current_section.comments.append(tok.text)
444
+ continue
445
+ if tok.kind.value == "section_header":
446
+ sec = Section(
447
+ id=tok.section_id or "$0",
448
+ title=tok.section_title or "",
449
+ line_start=tok.line,
450
+ )
451
+ doc.sections.append(sec)
452
+ current_section = sec
453
+ continue
454
+ if tok.kind.value == "entry_start":
455
+ if current_section is None:
456
+ # Implicit $0 if entries appear before any section header
457
+ current_section = Section(id="$0", title="GLOSSARY")
458
+ doc.sections.append(current_section)
459
+ entry = _build_entry(tok, current_section.id)
460
+ current_section.entries.append(entry)
461
+ continue
462
+ if tok.kind.value == "text":
463
+ # Preserve unparsed lines too (rare) — record in current section
464
+ if current_section is not None:
465
+ current_section.raw_lines.append(tok.text)
466
+ # If the line looks like an entry start (SIGIL:name{...) it's
467
+ # almost certainly an unbalanced-brace error → emit E005
468
+ from .lexer import looks_like_entry_start
469
+ line_text = tok.text.strip()
470
+ if looks_like_entry_start(line_text):
471
+ doc.diagnostics.append({
472
+ "code": "E005_UNBALANCED_BRACES",
473
+ "message": f"line {tok.line} looks like an entry start but braces are unbalanced: {line_text!r}",
474
+ "line": tok.line,
475
+ "severity": "error",
476
+ })
477
+ else:
478
+ doc.diagnostics.append({
479
+ "code": E017_UNPARSED_LINE,
480
+ "message": f"line {tok.line} could not be parsed: {tok.text!r}",
481
+ "line": tok.line,
482
+ "severity": "warning",
483
+ })
484
+ continue
485
+
486
+ # ---- validation: $0 first ------------------------------------------------
487
+ if not doc.sections:
488
+ raise errors.MissingGlossaryError(
489
+ "document has no sections and no $0 glossary",
490
+ )
491
+ if doc.sections[0].id != "$0":
492
+ # Try to locate $0 anywhere and reorder if it's at top
493
+ raise errors.GlossaryNotFirstError(
494
+ f"first section is {doc.sections[0].id!r}; $0 must come first",
495
+ )
496
+
497
+ # ---- build glossary ------------------------------------------------------
498
+ doc.glossary = _build_glossary_from_section(doc.sections[0])
499
+
500
+ # ---- resolve types + parse entry values ---------------------------------
501
+ _resolve_entry_types(doc)
502
+ _parse_entry_values(doc)
503
+
504
+ return doc
505
+
506
+
507
+ def _build_entry(tok, section_id: str) -> Entry:
508
+ """Build an :class:`Entry` from a lexer ENTRY_START token.
509
+
510
+ The entry's body is preserved verbatim in ``raw``; value parsing is
511
+ deferred until the glossary is available.
512
+ """
513
+
514
+ sigil = tok.sigil or ""
515
+ name = tok.name or ""
516
+ raw = tok.text
517
+ _extract_body(raw)
518
+ # Compute line range
519
+ line_start = tok.line
520
+ line_end = line_start + raw.count("\n")
521
+ entry = Entry(
522
+ section=section_id,
523
+ sigil=sigil,
524
+ name=name,
525
+ type="", # filled by _resolve_entry_types
526
+ value=None,
527
+ raw=raw,
528
+ line_start=line_start,
529
+ line_end=line_end,
530
+ )
531
+ return entry
532
+
533
+
534
+ def _resolve_entry_types(doc: CortexDocument) -> None:
535
+ """Look up each entry's expansion type from ``$0``.
536
+
537
+ Unknown sigils produce diagnostics; the entry's type falls back to
538
+ ``attrs`` so the AST remains usable for inspection.
539
+ """
540
+
541
+ for sec, entry in doc.iter_entries():
542
+ if entry.sigil in GLOSSARY_RESERVED_SIGILS:
543
+ # Glossary declaration entry — type is "attrs" by definition
544
+ entry.type = "attrs"
545
+ continue
546
+ sigil_def = doc.glossary.sigils.get(entry.sigil)
547
+ if sigil_def is None:
548
+ doc.diagnostics.append({
549
+ "code": E003_UNKNOWN_SIGIL,
550
+ "message": f"sigil {entry.sigil!r} (entry {entry.name!r}) is not declared in $0",
551
+ "line": entry.line_start,
552
+ "section": sec.id,
553
+ "sigil": entry.sigil,
554
+ "entry": entry.name,
555
+ "severity": "error",
556
+ })
557
+ entry.type = "attrs" # fallback
558
+ continue
559
+ entry.type = sigil_def.type
560
+ # Validate type is known
561
+ if entry.type not in doc.glossary.types and entry.type not in CANONICAL_TYPES:
562
+ doc.diagnostics.append({
563
+ "code": E004_UNKNOWN_TYPE,
564
+ "message": f"type {entry.type!r} (sigil {entry.sigil!r}) is not declared in $0",
565
+ "line": entry.line_start,
566
+ "section": sec.id,
567
+ "sigil": entry.sigil,
568
+ "entry": entry.name,
569
+ "severity": "error",
570
+ })
571
+
572
+
573
+ def _parse_entry_values(doc: CortexDocument) -> None:
574
+ """Parse each entry's body according to its declared expansion type."""
575
+
576
+ for sec, entry in doc.iter_entries():
577
+ if entry.sigil in GLOSSARY_RESERVED_SIGILS:
578
+ # Glossary entries: parse as attrs for the resolver to consume
579
+ try:
580
+ entry.value = parse_attrs_body(_extract_body(entry.raw))
581
+ except errors.InvalidAttrsError as e:
582
+ entry.value = {}
583
+ doc.diagnostics.append({
584
+ "code": E006_INVALID_ATTRS,
585
+ "message": str(e),
586
+ "line": entry.line_start,
587
+ "section": sec.id,
588
+ "sigil": entry.sigil,
589
+ "entry": entry.name,
590
+ "severity": "error",
591
+ })
592
+ continue
593
+
594
+ body = _extract_body(entry.raw)
595
+ t = entry.type
596
+ try:
597
+ if t == "attrs":
598
+ entry.value = parse_attrs_body(body)
599
+ elif t == "attrs-pos":
600
+ contract = doc.glossary.contract_for(entry.sigil)
601
+ if contract is None:
602
+ doc.diagnostics.append({
603
+ "code": E007_ATTRS_POS_CONTRACT_MISSING,
604
+ "message": f"attrs-pos sigil {entry.sigil!r} has no contract",
605
+ "line": entry.line_start,
606
+ "section": sec.id,
607
+ "sigil": entry.sigil,
608
+ "entry": entry.name,
609
+ "severity": "error",
610
+ })
611
+ entry.value = {}
612
+ else:
613
+ entry.value = parse_attrs_pos_body(body, contract)
614
+ elif t == "cuerpo":
615
+ entry.value = body.strip("\n")
616
+ elif t == "bloque":
617
+ # Preserved verbatim — leading/trailing newline stripped
618
+ entry.value = body.strip("\n")
619
+ elif t == "relación":
620
+ entry.value = body.strip()
621
+ else:
622
+ # Unknown type — fallback to attrs
623
+ entry.value = parse_attrs_body(body)
624
+ except errors.InvalidAttrsError as e:
625
+ entry.value = {}
626
+ doc.diagnostics.append({
627
+ "code": E006_INVALID_ATTRS,
628
+ "message": str(e),
629
+ "line": entry.line_start,
630
+ "section": sec.id,
631
+ "sigil": entry.sigil,
632
+ "entry": entry.name,
633
+ "severity": "error",
634
+ })
635
+ # Recompute hash with the final type + value
636
+ entry.hash = compute_entry_hash(entry)
637
+ entry.entry_id = entry.hash
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Convenience entry constructor (used by CRUD mutations)
642
+ # ---------------------------------------------------------------------------
643
+
644
+ def build_entry_from_value(
645
+ section_id: str,
646
+ sigil: str,
647
+ name: str,
648
+ type_: str,
649
+ value: Any,
650
+ ) -> Entry:
651
+ """Construct an :class:`Entry` from a parsed value.
652
+
653
+ Used by the CRUD layer and templates — produces an entry whose
654
+ ``raw`` text is the canonical serialisation of the value.
655
+ """
656
+
657
+ from .writer import serialize_entry_value
658
+ raw = f"{sigil}:{name}{{{serialize_entry_value(value, type_)}}}"
659
+ entry = Entry(
660
+ section=section_id,
661
+ sigil=sigil,
662
+ name=name,
663
+ type=type_,
664
+ value=value,
665
+ raw=raw,
666
+ line_start=0,
667
+ line_end=0,
668
+ )
669
+ entry.hash = compute_entry_hash(entry)
670
+ entry.entry_id = entry.hash
671
+ return entry