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/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
|