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/errors.py ADDED
@@ -0,0 +1,399 @@
1
+ """CODEC-CORTEX error model.
2
+
3
+ Every error has a stable machine-readable code (``E0xx_*``) and a human
4
+ message. Errors are raised as :class:`CortexError` (or subclasses) and
5
+ collected as diagnostics in the AST during parsing/validation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Optional
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Error code registry — matches Section 17 of the specification
16
+ # ---------------------------------------------------------------------------
17
+
18
+ E001_MISSING_GLOSSARY = "E001_MISSING_GLOSSARY"
19
+ E002_GLOSSARY_NOT_FIRST = "E002_GLOSSARY_NOT_FIRST"
20
+ E003_UNKNOWN_SIGIL = "E003_UNKNOWN_SIGIL"
21
+ E004_UNKNOWN_TYPE = "E004_UNKNOWN_TYPE"
22
+ E005_UNBALANCED_BRACES = "E005_UNBALANCED_BRACES"
23
+ E006_INVALID_ATTRS = "E006_INVALID_ATTRS"
24
+ E007_ATTRS_POS_CONTRACT_MISSING = "E007_ATTRS_POS_CONTRACT_MISSING"
25
+ E008_DUPLICATE_ENTRY = "E008_DUPLICATE_ENTRY"
26
+ E009_PROTECTED_ENTRY = "E009_PROTECTED_ENTRY"
27
+ E010_HCORTEX_READ_NOT_COMPILABLE = "E010_HCORTEX_READ_NOT_COMPILABLE"
28
+ E011_HCORTEX_EDIT_METADATA_MISSING = "E011_HCORTEX_EDIT_METADATA_MISSING"
29
+ E012_ROUNDTRIP_FAILED = "E012_ROUNDTRIP_FAILED"
30
+
31
+ # Extended codes (operational, not in the spec's compact list)
32
+ E013_NOT_FOUND = "E013_NOT_FOUND"
33
+ E014_AMBIGUOUS_SELECTOR = "E014_AMBIGUOUS_SELECTOR"
34
+ E015_ATOMIC_WRITE_FAILED = "E015_ATOMIC_WRITE_FAILED"
35
+ E016_INVALID_SECTION_HEADER = "E016_INVALID_SECTION_HEADER"
36
+ E017_UNPARSED_LINE = "E017_UNPARSED_LINE"
37
+ E018_PROTECTED_SIGIL = "E018_PROTECTED_SIGIL"
38
+ E019_SIGIL_IN_USE = "E019_SIGIL_IN_USE"
39
+ E020_MICRO_IN_USE = "E020_MICRO_IN_USE"
40
+ E021_INVALID_VALUE = "E021_INVALID_VALUE"
41
+ E022_TEMPLATE_UNKNOWN = "E022_TEMPLATE_UNKNOWN"
42
+
43
+ # Codes introduced in 1.1.0 for cognitive governance (audit gaps)
44
+ E023_LEVEL1_LIVE_STATE = "E023_LEVEL1_LIVE_STATE"
45
+ E024_LEVEL2_MISSING_FOCUS = "E024_LEVEL2_MISSING_FOCUS"
46
+ E025_INVALID_SURVIVE = "E025_INVALID_SURVIVE"
47
+ E026_BLOCKING_NOT_P0 = "E026_BLOCKING_NOT_P0"
48
+ E027_ATTRS_POS_ARITY = "E027_ATTRS_POS_ARITY"
49
+ E028_SECRET_IN_CLEAR = "E028_SECRET_IN_CLEAR"
50
+ E029_LEVEL3_LIVE_STATE = "E029_LEVEL3_LIVE_STATE"
51
+ E030_RECOVERY_INCOMPLETE = "E030_RECOVERY_INCOMPLETE"
52
+
53
+ # Codes introduced in 1.1.3 (verification gaps)
54
+ E031_SECRET_NOT_BYPASSABLE = "E031_SECRET_NOT_BYPASSABLE"
55
+ E032_CRITICAL_SIGIL_INCOMPLETE = "E032_CRITICAL_SIGIL_INCOMPLETE"
56
+
57
+ # Codes introduced in 1.1.5 ($0 section integrity)
58
+ E033_ZERO_SECTION_MEMORY_ENTRY = "E033_ZERO_SECTION_MEMORY_ENTRY"
59
+
60
+ # Codes introduced in 1.1.6 (semantic emptiness)
61
+ E034_CRITICAL_REQUIRED_FIELD_EMPTY = "E034_CRITICAL_REQUIRED_FIELD_EMPTY"
62
+
63
+
64
+ @dataclass
65
+ class CortexError(Exception):
66
+ """Base error for the CODEC-CORTEX CLI.
67
+
68
+ Carries a stable ``code`` (E0xx_*) and optional structural context
69
+ (line number, section id, sigil, entry name) so the CLI can render
70
+ actionable diagnostics.
71
+ """
72
+
73
+ code: str
74
+ message: str
75
+ line: Optional[int] = None
76
+ section: Optional[str] = None
77
+ sigil: Optional[str] = None
78
+ entry: Optional[str] = None
79
+
80
+ def __post_init__(self) -> None:
81
+ # ``dataclass`` + ``Exception`` requires us to forward the message
82
+ # to ``Exception`` so ``str(err)`` keeps working.
83
+ super().__init__(self.message)
84
+
85
+ def __str__(self) -> str: # pragma: no cover - trivial
86
+ loc = []
87
+ if self.line is not None:
88
+ loc.append(f"line {self.line}")
89
+ if self.section is not None:
90
+ loc.append(f"section {self.section}")
91
+ if self.sigil is not None:
92
+ loc.append(f"sigil {self.sigil}")
93
+ if self.entry is not None:
94
+ loc.append(f"entry {self.entry}")
95
+ prefix = f"[{self.code}]"
96
+ if loc:
97
+ prefix += " (" + ", ".join(loc) + ")"
98
+ return f"{prefix} {self.message}"
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Concrete subclasses — convenient ``except`` targets
103
+ # ---------------------------------------------------------------------------
104
+
105
+ class MissingGlossaryError(CortexError):
106
+ def __init__(self, message: str = "$0 local glossary is missing", **kw):
107
+ super().__init__(E001_MISSING_GLOSSARY, message, **kw)
108
+
109
+
110
+ class GlossaryNotFirstError(CortexError):
111
+ def __init__(self, message: str = "$0 must be the first section", **kw):
112
+ super().__init__(E002_GLOSSARY_NOT_FIRST, message, **kw)
113
+
114
+
115
+ class UnknownSigilError(CortexError):
116
+ def __init__(self, sigil: str, **kw):
117
+ kw.setdefault("sigil", sigil)
118
+ super().__init__(E003_UNKNOWN_SIGIL, f"sigil '{sigil}' not declared in $0", **kw)
119
+
120
+
121
+ class UnknownTypeError(CortexError):
122
+ def __init__(self, type_name: str, **kw):
123
+ super().__init__(E004_UNKNOWN_TYPE, f"expansion type '{type_name}' not declared in $0", **kw)
124
+
125
+
126
+ class BraceError(CortexError):
127
+ def __init__(self, message: str = "unbalanced braces", **kw):
128
+ super().__init__(E005_UNBALANCED_BRACES, message, **kw)
129
+
130
+
131
+ class InvalidAttrsError(CortexError):
132
+ def __init__(self, message: str = "invalid attrs body", **kw):
133
+ super().__init__(E006_INVALID_ATTRS, message, **kw)
134
+
135
+
136
+ class AttrsPosContractMissingError(CortexError):
137
+ def __init__(self, sigil: str, **kw):
138
+ kw.setdefault("sigil", sigil)
139
+ super().__init__(
140
+ E007_ATTRS_POS_CONTRACT_MISSING,
141
+ f"attrs-pos sigil '{sigil}' has no positional contract in $0",
142
+ **kw,
143
+ )
144
+
145
+
146
+ class DuplicateEntryError(CortexError):
147
+ def __init__(self, sigil: str, name: str, **kw):
148
+ kw.setdefault("sigil", sigil)
149
+ kw.setdefault("entry", name)
150
+ super().__init__(
151
+ E008_DUPLICATE_ENTRY,
152
+ f"duplicate entry {sigil}:{name} not allowed without --allow-duplicate",
153
+ **kw,
154
+ )
155
+
156
+
157
+ class ProtectedEntryError(CortexError):
158
+ def __init__(self, sigil: str, name: str, **kw):
159
+ kw.setdefault("sigil", sigil)
160
+ kw.setdefault("entry", name)
161
+ super().__init__(
162
+ E009_PROTECTED_ENTRY,
163
+ f"entry {sigil}:{name} is protected; use --force to override",
164
+ **kw,
165
+ )
166
+
167
+
168
+ class HCortexReadNotCompilableError(CortexError):
169
+ def __init__(self, **kw):
170
+ super().__init__(
171
+ E010_HCORTEX_READ_NOT_COMPILABLE,
172
+ "HCORTEX-READ is not roundtrip-compilable; use HCORTEX-EDIT instead",
173
+ **kw,
174
+ )
175
+
176
+
177
+ class HCortexEditMetadataMissingError(CortexError):
178
+ def __init__(self, message: str = "HCORTEX-EDIT metadata missing", **kw):
179
+ super().__init__(E011_HCORTEX_EDIT_METADATA_MISSING, message, **kw)
180
+
181
+
182
+ class RoundtripFailedError(CortexError):
183
+ def __init__(self, message: str = "structural roundtrip comparison failed", **kw):
184
+ super().__init__(E012_ROUNDTRIP_FAILED, message, **kw)
185
+
186
+
187
+ class NotFoundError(CortexError):
188
+ def __init__(self, selector: str, **kw):
189
+ super().__init__(E013_NOT_FOUND, f"no entry matches selector '{selector}'", **kw)
190
+
191
+
192
+ class AmbiguousSelectorError(CortexError):
193
+ def __init__(self, selector: str, count: int, **kw):
194
+ super().__init__(
195
+ E014_AMBIGUOUS_SELECTOR,
196
+ f"selector '{selector}' matched {count} entries; refine to a single match",
197
+ **kw,
198
+ )
199
+
200
+
201
+ class AtomicWriteError(CortexError):
202
+ def __init__(self, message: str = "atomic write failed", **kw):
203
+ super().__init__(E015_ATOMIC_WRITE_FAILED, message, **kw)
204
+
205
+
206
+ class InvalidSectionHeaderError(CortexError):
207
+ def __init__(self, message: str = "invalid section header", **kw):
208
+ super().__init__(E016_INVALID_SECTION_HEADER, message, **kw)
209
+
210
+
211
+ class UnparsedLineError(CortexError):
212
+ def __init__(self, line: int, **kw):
213
+ kw.setdefault("line", line)
214
+ super().__init__(E017_UNPARSED_LINE, f"line {line} could not be parsed", **kw)
215
+
216
+
217
+ class ProtectedSigilError(CortexError):
218
+ def __init__(self, sigil: str, **kw):
219
+ kw.setdefault("sigil", sigil)
220
+ super().__init__(
221
+ E018_PROTECTED_SIGIL,
222
+ f"sigil '{sigil}' is canonical and cannot be redefined without --force-governance",
223
+ **kw,
224
+ )
225
+
226
+
227
+ class SigilInUseError(CortexError):
228
+ def __init__(self, sigil: str, count: int, **kw):
229
+ kw.setdefault("sigil", sigil)
230
+ super().__init__(
231
+ E019_SIGIL_IN_USE,
232
+ f"sigil '{sigil}' is used by {count} entries; cannot change type or remove",
233
+ **kw,
234
+ )
235
+
236
+
237
+ class MicroInUseError(CortexError):
238
+ def __init__(self, token: str, **kw):
239
+ super().__init__(
240
+ E020_MICRO_IN_USE,
241
+ f"micro-token '{token}' is used by at least one entry; cannot remove without --force",
242
+ **kw,
243
+ )
244
+
245
+
246
+ class InvalidValueError(CortexError):
247
+ def __init__(self, message: str = "invalid value for entry", **kw):
248
+ super().__init__(E021_INVALID_VALUE, message, **kw)
249
+
250
+
251
+ class TemplateUnknownError(CortexError):
252
+ def __init__(self, kind: str, **kw):
253
+ super().__init__(E022_TEMPLATE_UNKNOWN, f"unknown template kind '{kind}'", **kw)
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Diagnostic record (non-fatal parser findings)
258
+ # ---------------------------------------------------------------------------
259
+
260
+ @dataclass
261
+ class Diagnostic:
262
+ """Non-fatal diagnostic collected during parsing/validation.
263
+
264
+ Diagnostics are preserved in ``CortexDocument.diagnostics`` so callers
265
+ can decide whether to surface them as warnings or hard errors.
266
+ """
267
+
268
+ code: str
269
+ message: str
270
+ line: Optional[int] = None
271
+ section: Optional[str] = None
272
+ sigil: Optional[str] = None
273
+ entry: Optional[str] = None
274
+ severity: str = "warning" # warning | info
275
+
276
+ def __str__(self) -> str: # pragma: no cover - trivial
277
+ return f"[{self.code}] {self.message}"
278
+
279
+
280
+ @dataclass
281
+ class DiagnosticBag:
282
+ """Mutable collection of :class:`Diagnostic` with helper queries."""
283
+
284
+ items: List[Diagnostic] = field(default_factory=list)
285
+
286
+ def add(self, diag: Diagnostic) -> None:
287
+ self.items.append(diag)
288
+
289
+ def extend(self, others: List[Diagnostic]) -> None:
290
+ self.items.extend(others)
291
+
292
+ def errors(self) -> List[Diagnostic]:
293
+ return [d for d in self.items if d.severity == "error"]
294
+
295
+ def warnings(self) -> List[Diagnostic]:
296
+ return [d for d in self.items if d.severity == "warning"]
297
+
298
+ def infos(self) -> List[Diagnostic]:
299
+ return [d for d in self.items if d.severity == "info"]
300
+
301
+ def has_errors(self) -> bool:
302
+ return any(d.severity == "error" for d in self.items)
303
+
304
+ def to_list(self) -> List[dict]:
305
+ return [
306
+ {
307
+ "code": d.code,
308
+ "message": d.message,
309
+ "line": d.line,
310
+ "section": d.section,
311
+ "sigil": d.sigil,
312
+ "entry": d.entry,
313
+ "severity": d.severity,
314
+ }
315
+ for d in self.items
316
+ ]
317
+
318
+
319
+ # Canonical sigils — protected from silent redefinition (Section 4.2)
320
+ CANONICAL_SIGILS = frozenset({
321
+ "IDN", "DOM", "KNW", "REF", "TAG", "AXM", "CNST", "!",
322
+ "CLAIM", "LIM", "AUD", "RSK", "FCS", "OBJ", "WRK", "STP",
323
+ "NXT", "SES", "LNG", "DIAG", "HDL", "PFL", "DEP", "DESC", "ERR",
324
+ })
325
+
326
+ # Reserved sigils used inside $0 to declare the glossary itself
327
+ GLOSSARY_RESERVED_SIGILS = frozenset({"GSIG", "GTYP", "GMIC"})
328
+
329
+ # Canonical expansion types (Section 4.3)
330
+ CANONICAL_TYPES = frozenset({"attrs", "attrs-pos", "cuerpo", "bloque", "relación"})
331
+
332
+ # Allowed status / severity / priority values (Section 6)
333
+ ALLOWED_STATUS = frozenset({
334
+ "current", "specification", "planned", "future",
335
+ "experimental", "deprecated", "blocked", "done",
336
+ })
337
+ ALLOWED_SEVERITY = frozenset({"blocking", "warning", "info"})
338
+ ALLOWED_PRIORITY = frozenset({"high", "medium", "low"})
339
+
340
+ # Allowed values for the `survive` attribute (Section 11.3 of SKILL.md).
341
+ # `survive` maps to P-level: min→P0, recovery→P1, work→P2, full→P5.
342
+ ALLOWED_SURVIVE = frozenset({"min", "recovery", "work", "full"})
343
+
344
+ # Mapping from `survive` value to canonical P-level (priority pack).
345
+ SURVIVE_TO_PLEVEL = {
346
+ "min": "P0",
347
+ "recovery": "P1",
348
+ "work": "P2",
349
+ "full": "P5",
350
+ }
351
+
352
+ # Mapping from sigil + attributes to P-level (priority classifier).
353
+ # These are the canonical rules per Section 11.2 of SKILL.md.
354
+ SIGIL_DEFAULT_PLEVEL = {
355
+ "FCS": "P0",
356
+ "OBJ": "P0",
357
+ "STP": "P0",
358
+ "CNST": "P0", # may be overridden to P1+ if not blocking
359
+ "WRK": "P1",
360
+ "AUD": "P1",
361
+ "RSK": "P1",
362
+ "NXT": "P1",
363
+ "CLAIM": "P2",
364
+ "LIM": "P2",
365
+ "KNW": "P2",
366
+ "LNG": "P2",
367
+ "SES": "P3",
368
+ "STAT": "P3",
369
+ "REF": "P4",
370
+ "DIAG": "P5",
371
+ "TAG": "P5",
372
+ "IDN": "P2",
373
+ "DOM": "P2",
374
+ "AXM": "P0",
375
+ "!": "P0",
376
+ "HDL": "P3",
377
+ "PFL": "P2",
378
+ "DEP": "P3",
379
+ "DESC": "P4",
380
+ "ERR": "P1",
381
+ }
382
+
383
+ # Canonical P-level ordering (lowest P0 = highest priority)
384
+ PLEVEL_ORDER = ["P0", "P1", "P2", "P3", "P4", "P5"]
385
+
386
+ # Canonical micro-tokens (Section 4.1.1)
387
+ CANONICAL_MICRO = {
388
+ "cur": "current",
389
+ "pln": "planned",
390
+ "fut": "future",
391
+ "blk": "blocked",
392
+ "min": "minimum",
393
+ "rec": "recovery",
394
+ "wrk": "work",
395
+ "full": "full",
396
+ "ok": "success",
397
+ "fail": "failure",
398
+ "part": "partial",
399
+ }
cortex/core/lexer.py ADDED
@@ -0,0 +1,267 @@
1
+ """Character-stream lexer for ``.cortex``.
2
+
3
+ The lexer is a small deterministic automaton (Section 6.2 of the spec)
4
+ that emits low-level tokens consumed by :mod:`cortex.core.parser`.
5
+
6
+ States:
7
+ - TEXT
8
+ - SECTION_HEADER
9
+ - SIGIL
10
+ - ENTRY_NAME
11
+ - ENTRY_BODY
12
+ - ESCAPE
13
+ - COMMENT
14
+
15
+ The lexer is intentionally minimal: it identifies *boundaries* (section
16
+ headers, entry starts, brace blocks, comments) but does **not** interpret
17
+ values — that is the parser's job. The lexer preserves raw text so the
18
+ parser can extract verbatim ``bloque`` content.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from enum import Enum
25
+ from typing import List, Optional, Tuple
26
+
27
+ from .errors import BraceError
28
+
29
+
30
+ class TokenKind(str, Enum):
31
+ SECTION_HEADER = "section_header"
32
+ COMMENT = "comment"
33
+ BLANK = "blank"
34
+ ENTRY_START = "entry_start" # SIGIL:name{ ...
35
+ ENTRY_CONTINUATION = "entry_continuation"
36
+ TEXT = "text" # unparsed line (recorded as diagnostic)
37
+
38
+
39
+ @dataclass
40
+ class Token:
41
+ kind: TokenKind
42
+ text: str
43
+ line: int
44
+ # for ENTRY_START only:
45
+ sigil: Optional[str] = None
46
+ name: Optional[str] = None
47
+ # for SECTION_HEADER:
48
+ section_id: Optional[str] = None
49
+ section_title: Optional[str] = None
50
+
51
+
52
+ # Characters that introduce a comment line
53
+ _COMMENT_PREFIXES = ("#", "//")
54
+
55
+
56
+ def looks_like_comment(line: str) -> bool:
57
+ stripped = line.lstrip()
58
+ return any(stripped.startswith(p) for p in _COMMENT_PREFIXES)
59
+
60
+
61
+ def looks_like_section_header(line: str) -> bool:
62
+ """Return True if ``line`` declares a numbered section.
63
+
64
+ Recognised forms (per Section 5.2 of the spec):
65
+ - ``$2``
66
+ - ``$2: TITLE``
67
+ - ``$2 · TITLE``
68
+ - ``# -- $2: TITLE --``
69
+ - ``## $2 · TITLE`` (Markdown-friendly)
70
+ - ``2`` (bare number, less common)
71
+ """
72
+
73
+ s = line.strip()
74
+ if not s:
75
+ return False
76
+ # Markdown-style: ## $2 · TITLE
77
+ if s.startswith("##"):
78
+ rest = s.lstrip("#").strip()
79
+ if rest.startswith("$"):
80
+ head = rest[1:].split(":", 1)[0].split("·", 1)[0].strip()
81
+ return head.isdigit()
82
+ return False
83
+ # Comment-style: # -- $2: TITLE --
84
+ if s.startswith("#"):
85
+ inner = s.lstrip("#").strip()
86
+ # remove leading dashes
87
+ inner = inner.lstrip("-").strip()
88
+ if inner.startswith("$"):
89
+ head = inner[1:].split(":", 1)[0].split("·", 1)[0].strip()
90
+ return head.isdigit()
91
+ return False
92
+ # Plain: $2 or $2: TITLE
93
+ if s.startswith("$"):
94
+ head = s[1:].split(":", 1)[0].split("·", 1)[0].strip()
95
+ return head.isdigit()
96
+ # Bare number "2" — accept only if the entire token is a digit
97
+ head = s.split(":", 1)[0].split("·", 1)[0].strip()
98
+ return head.isdigit()
99
+
100
+
101
+ def parse_section_header(line: str) -> Tuple[str, str]:
102
+ """Extract ``(section_id, title)`` from a section header line."""
103
+
104
+ s = line.strip()
105
+ # Strip markdown hashes
106
+ if s.startswith("##"):
107
+ s = s.lstrip("#").strip()
108
+ # Strip comment marker (for ``# -- $2: TITLE --``)
109
+ if s.startswith("#"):
110
+ s = s.lstrip("#").strip().lstrip("-").strip()
111
+ # Now s should start with $ or be a bare number
112
+ if s.startswith("$"):
113
+ s = s[1:]
114
+ # Split on first ':' or '·'
115
+ if ":" in s:
116
+ num, title = s.split(":", 1)
117
+ elif "·" in s:
118
+ num, title = s.split("·", 1)
119
+ else:
120
+ num, title = s, ""
121
+ num = num.strip()
122
+ title = title.strip().rstrip("-").strip()
123
+ if not num.isdigit():
124
+ # Not a real section header; return as-is and let caller decide.
125
+ return ("$" + num if num else "$0", title)
126
+ return ("$" + num, title)
127
+
128
+
129
+ # Pattern for entry starts: SIGIL:name{
130
+ # Sigils are uppercase letters/digits/!; names are snake_case.
131
+ import re as _re
132
+
133
+ _ENTRY_START_RE = _re.compile(
134
+ r"""^
135
+ (?P<sigil>[A-Z][A-Z0-9_]*|!) # sigil (uppercase, or single '!')
136
+ :
137
+ (?P<name>[A-Za-z_][A-Za-z0-9_]*) # snake_case name
138
+ \s*
139
+ \{ # opening brace (rest of line = body start)
140
+ (?P<rest>.*)
141
+ $""",
142
+ _re.VERBOSE,
143
+ )
144
+
145
+
146
+ def looks_like_entry_start(line: str) -> bool:
147
+ s = line.strip()
148
+ if not s:
149
+ return False
150
+ # Reject comment lines
151
+ if looks_like_comment(line):
152
+ return False
153
+ return _ENTRY_START_RE.match(s) is not None
154
+
155
+
156
+ def parse_entry_start(line: str) -> Tuple[str, str, str]:
157
+ """Return ``(sigil, name, rest_after_open_brace)``."""
158
+
159
+ s = line.strip()
160
+ m = _ENTRY_START_RE.match(s)
161
+ if not m:
162
+ raise ValueError(f"not an entry start: {line!r}")
163
+ return m.group("sigil"), m.group("name"), m.group("rest")
164
+
165
+
166
+ def collect_balanced_entry(lines: List[str], start: int) -> Tuple[str, int, int]:
167
+ """Collect a multi-line entry starting at ``lines[start]``.
168
+
169
+ Returns ``(raw_text, start_line, end_line)`` where ``end_line`` is the
170
+ index (inclusive) of the line that closed the entry.
171
+
172
+ Raises :class:`BraceError` if braces are unbalanced.
173
+ """
174
+
175
+ depth = 0
176
+ in_escape = False
177
+ in_string = False
178
+ buffer_parts: List[str] = []
179
+ end = start
180
+ opened = False
181
+ for i in range(start, len(lines)):
182
+ line = lines[i]
183
+ for ch in line:
184
+ if in_escape:
185
+ in_escape = False
186
+ continue
187
+ if ch == "\\":
188
+ in_escape = True
189
+ continue
190
+ if in_string:
191
+ if ch == '"':
192
+ in_string = False
193
+ continue
194
+ if ch == '"':
195
+ in_string = True
196
+ continue
197
+ if ch == "{":
198
+ depth += 1
199
+ opened = True
200
+ elif ch == "}":
201
+ depth -= 1
202
+ if depth < 0:
203
+ raise BraceError(
204
+ f"unbalanced '}}' on line {i + 1}",
205
+ line=i + 1,
206
+ )
207
+ buffer_parts.append(line)
208
+ end = i
209
+ if opened and depth == 0:
210
+ return "\n".join(buffer_parts), start, end
211
+ raise BraceError(f"unclosed entry starting on line {start + 1}", line=start + 1)
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Top-level lexer
216
+ # ---------------------------------------------------------------------------
217
+
218
+ def lex(text: str) -> List[Token]:
219
+ """Split ``text`` into a flat list of :class:`Token` records.
220
+
221
+ The lexer normalises newlines and emits one token per logical line
222
+ (or one token per multi-line entry, when braces span several lines).
223
+ """
224
+
225
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
226
+ lines = text.split("\n")
227
+ tokens: List[Token] = []
228
+ i = 0
229
+ while i < len(lines):
230
+ line = lines[i]
231
+ line_no = i + 1
232
+ stripped = line.strip()
233
+ if not stripped:
234
+ tokens.append(Token(TokenKind.BLANK, line, line_no))
235
+ i += 1
236
+ continue
237
+ if looks_like_comment(line):
238
+ tokens.append(Token(TokenKind.COMMENT, line, line_no))
239
+ i += 1
240
+ continue
241
+ if looks_like_section_header(line):
242
+ sec_id, title = parse_section_header(line)
243
+ tokens.append(Token(
244
+ TokenKind.SECTION_HEADER, line, line_no,
245
+ section_id=sec_id, section_title=title,
246
+ ))
247
+ i += 1
248
+ continue
249
+ if looks_like_entry_start(line):
250
+ try:
251
+ raw, _, end = collect_balanced_entry(lines, i)
252
+ except BraceError:
253
+ # emit a TEXT token and a diagnostic upstream
254
+ tokens.append(Token(TokenKind.TEXT, line, line_no))
255
+ i += 1
256
+ continue
257
+ sigil, name, _ = parse_entry_start(line)
258
+ tokens.append(Token(
259
+ TokenKind.ENTRY_START, raw, line_no,
260
+ sigil=sigil, name=name,
261
+ ))
262
+ i = end + 1
263
+ continue
264
+ # Unparsed line — record so the parser can emit a diagnostic
265
+ tokens.append(Token(TokenKind.TEXT, line, line_no))
266
+ i += 1
267
+ return tokens