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.
- codec_cortex-0.3.2.dist-info/METADATA +183 -0
- codec_cortex-0.3.2.dist-info/RECORD +81 -0
- codec_cortex-0.3.2.dist-info/WHEEL +5 -0
- codec_cortex-0.3.2.dist-info/entry_points.txt +2 -0
- codec_cortex-0.3.2.dist-info/licenses/LICENSE +21 -0
- codec_cortex-0.3.2.dist-info/scm_file_list.json +124 -0
- codec_cortex-0.3.2.dist-info/scm_version.json +8 -0
- codec_cortex-0.3.2.dist-info/top_level.txt +1 -0
- cortex/__init__.py +22 -0
- cortex/__main__.py +7 -0
- cortex/_version.py +24 -0
- cortex/cli/__init__.py +5 -0
- cortex/cli/commands/__init__.py +150 -0
- cortex/cli/commands/add.py +89 -0
- cortex/cli/commands/compile.py +31 -0
- cortex/cli/commands/delete.py +44 -0
- cortex/cli/commands/diagram.py +130 -0
- cortex/cli/commands/diff.py +135 -0
- cortex/cli/commands/doctor.py +74 -0
- cortex/cli/commands/format.py +29 -0
- cortex/cli/commands/get.py +45 -0
- cortex/cli/commands/glossary.py +98 -0
- cortex/cli/commands/list.py +44 -0
- cortex/cli/commands/micro.py +79 -0
- cortex/cli/commands/move.py +45 -0
- cortex/cli/commands/new.py +80 -0
- cortex/cli/commands/recover.py +90 -0
- cortex/cli/commands/render.py +93 -0
- cortex/cli/commands/update.py +81 -0
- cortex/cli/commands/v2_canonicalize.py +162 -0
- cortex/cli/commands/v2_compare.py +74 -0
- cortex/cli/commands/v2_convert.py +183 -0
- cortex/cli/commands/v2_explain_loss.py +97 -0
- cortex/cli/commands/v2_inspect.py +113 -0
- cortex/cli/commands/v2_roundtrip.py +104 -0
- cortex/cli/commands/v2_roundtrip_bidir.py +128 -0
- cortex/cli/commands/v2_verify_view.py +65 -0
- cortex/cli/commands/verify.py +119 -0
- cortex/cli/main.py +636 -0
- cortex/core/__init__.py +85 -0
- cortex/core/ast.py +313 -0
- cortex/core/compare.py +180 -0
- cortex/core/document_kind.py +525 -0
- cortex/core/errors.py +399 -0
- cortex/core/lexer.py +267 -0
- cortex/core/parser.py +671 -0
- cortex/core/validator.py +268 -0
- cortex/core/writer.py +249 -0
- cortex/crud/__init__.py +17 -0
- cortex/crud/mutations.py +342 -0
- cortex/crud/selectors.py +95 -0
- cortex/crud/transactions.py +213 -0
- cortex/glossary/__init__.py +21 -0
- cortex/glossary/contracts.py +37 -0
- cortex/glossary/minimal.py +94 -0
- cortex/glossary/model.py +77 -0
- cortex/glossary/resolver.py +96 -0
- cortex/hcortex/__init__.py +35 -0
- cortex/hcortex/edit_parser.py +489 -0
- cortex/hcortex/edit_renderer.py +158 -0
- cortex/hcortex/markdown_model.py +81 -0
- cortex/hcortex/profiles.py +166 -0
- cortex/hcortex/read_renderer.py +342 -0
- cortex/hcortex/recovery.py +782 -0
- cortex/py.typed +0 -0
- cortex/templates/__init__.py +8 -0
- cortex/templates/brain.py +118 -0
- cortex/templates/minimal_glossary.py +42 -0
- cortex/templates/package.py +91 -0
- cortex/templates/skill.py +91 -0
- cortex/v2/__init__.py +30 -0
- cortex/v2/diagnostics.py +57 -0
- cortex/v2/encoder.py +1106 -0
- cortex/v2/equivalence.py +323 -0
- cortex/v2/hcortex_parser.py +615 -0
- cortex/v2/hcortex_renderer.py +450 -0
- cortex/v2/ir.py +223 -0
- cortex/v2/parser.py +655 -0
- cortex/v2/view.py +425 -0
- cortex/v2/view_renderer.py +474 -0
- 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
|