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/v2/parser.py
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
r"""CORTEX v2 parser — handles the exact format of ``skill/cortex/SKILL.md``.
|
|
2
|
+
|
|
3
|
+
The v2 format has these key characteristics:
|
|
4
|
+
1. File wrapped in ``\`\`\`markdown ... \`\`\``
|
|
5
|
+
2. ``<!-- CODEC-CORTEX -->`` header with internal_encoding
|
|
6
|
+
3. ``$0`` glossary declared via entries: ``IDN:identity{type:attrs,risk:B,cortex:Semantic,desc:"..."}``
|
|
7
|
+
4. ``$0`` metadata: ``$0:type_attrs{rule:"..."}``, ``$0:contract_hdl{pos:"..."}``, etc.
|
|
8
|
+
5. HDL uses bare ``attrs-pos``: ``HDL:agent_init|specification|SKILL.md|notes...``
|
|
9
|
+
6. Micro-tokens as bare values: ``status:cur``, ``survive:min``
|
|
10
|
+
7. ``DESC``/``AXM`` (cuerpo): ``SIGIL:name{long text...}``
|
|
11
|
+
8. ``DIAG`` (bloque): ``SIGIL:name{\nmultiline\n}``
|
|
12
|
+
9. ``!`` rules: ``!:name{rule:"...",survive:min}``
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Data model
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class V2Entry:
|
|
28
|
+
"""A single entry in a CORTEX v2 document."""
|
|
29
|
+
sigil: str
|
|
30
|
+
name: str
|
|
31
|
+
entry_type: str # attrs | attrs-pos | cuerpo | bloque
|
|
32
|
+
value: Any # dict for attrs, list for attrs-pos, str for cuerpo/bloque
|
|
33
|
+
raw: str = "" # original text for verbatim reproduction
|
|
34
|
+
section: str = ""
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"sigil": self.sigil,
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"type": self.entry_type,
|
|
41
|
+
"value": self.value,
|
|
42
|
+
"section": self.section,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class V2Section:
|
|
48
|
+
"""A ``$N`` section in a CORTEX v2 document."""
|
|
49
|
+
id: str # "$0", "$1", etc.
|
|
50
|
+
entries: List[V2Entry] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return {"id": self.id, "entries": [e.to_dict() for e in self.entries]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CortexV2Document:
|
|
58
|
+
"""Parsed CORTEX v2 document."""
|
|
59
|
+
header: Dict[str, str] = field(default_factory=dict) # CODEC-CORTEX header fields
|
|
60
|
+
sections: List[V2Section] = field(default_factory=list)
|
|
61
|
+
raw_text: str = "" # original text (for roundtrip verification)
|
|
62
|
+
|
|
63
|
+
def get_section(self, section_id: str) -> Optional[V2Section]:
|
|
64
|
+
for s in self.sections:
|
|
65
|
+
if s.id == section_id:
|
|
66
|
+
return s
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def get_entries(self, section_id: str = None, sigil: str = None) -> List[V2Entry]:
|
|
70
|
+
result = []
|
|
71
|
+
for sec in self.sections:
|
|
72
|
+
if section_id and sec.id != section_id:
|
|
73
|
+
continue
|
|
74
|
+
for e in sec.entries:
|
|
75
|
+
if sigil and e.sigil != sigil:
|
|
76
|
+
continue
|
|
77
|
+
result.append(e)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"header": dict(self.header),
|
|
83
|
+
"sections": [s.to_dict() for s in self.sections],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Parser
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
# Regex for the markdown wrapper
|
|
92
|
+
_WRAPPER_OPEN = re.compile(r'^```markdown\n', re.MULTILINE)
|
|
93
|
+
_WRAPPER_CLOSE = re.compile(r'\n```$', re.MULTILINE)
|
|
94
|
+
|
|
95
|
+
# Regex for the CODEC-CORTEX header
|
|
96
|
+
_HEADER_RE = re.compile(
|
|
97
|
+
r'<!-- CODEC-CORTEX\n(.*?)\n-->',
|
|
98
|
+
re.DOTALL
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Regex for section headers: bare $N on its own line.
|
|
102
|
+
# v0.3.2: also accept v1-style "$N: DESCRIPTION" headers (used by the
|
|
103
|
+
# benchmark corpus). The description must start with a space after the
|
|
104
|
+
# colon to distinguish from $0 metadata entries like $0:type_attrs{...}.
|
|
105
|
+
# The description must not contain braces (those are entry bodies, not
|
|
106
|
+
# section descriptions). The captured section id is just $N.
|
|
107
|
+
_SECTION_RE = re.compile(r'^\$(\d+)(?::\s+[^\n{}]*)?$', re.MULTILINE)
|
|
108
|
+
|
|
109
|
+
# Regex for entry starts: SIGIL:name{ or $0:name{ or HDL:name|
|
|
110
|
+
# Special case: ! entries can be !:name{ or !name{ (without colon)
|
|
111
|
+
_ENTRY_START_RE = re.compile(
|
|
112
|
+
r'^(?P<sigil>[A-Z][A-Z0-9_]*|\$0):(?P<name>[A-Za-z_][A-Za-z0-9_]*)\{',
|
|
113
|
+
re.MULTILINE
|
|
114
|
+
)
|
|
115
|
+
# Separate regex for ! entries: !name{ or !:name{
|
|
116
|
+
_BANG_ENTRY_RE = re.compile(
|
|
117
|
+
r'^!(?::(?P<name1>[A-Za-z_][A-Za-z0-9_]*)|(?P<name2>[A-Za-z_][A-Za-z0-9_]*))\{',
|
|
118
|
+
re.MULTILINE
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Regex for HDL bare attrs-pos: HDL:name|val1|val2|...
|
|
122
|
+
_HDL_RE = re.compile(
|
|
123
|
+
r'^HDL:(?P<name>[A-Za-z_][A-Za-z0-9_]*)\|',
|
|
124
|
+
re.MULTILINE
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Regex for $0 metadata entries: $0:name{...}
|
|
128
|
+
_META_RE = re.compile(
|
|
129
|
+
r'^\$0:(?P<name>\w+)\{',
|
|
130
|
+
re.MULTILINE
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Keys that are always bare (enum/micro values)
|
|
134
|
+
_BARE_KEYS = frozenset({
|
|
135
|
+
'type', 'risk', 'cortex', 'status', 'survive', 'severity',
|
|
136
|
+
'encoding', 'license', 'priority', 'expand', 'pos',
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
# Keys that are always quoted
|
|
140
|
+
_QUOTED_KEYS = frozenset({
|
|
141
|
+
'desc', 'rule', 'content', 'topic', 'effect', 'prevention',
|
|
142
|
+
'role', 'author', 'version', 'spec', 'project', 'name',
|
|
143
|
+
'domain', 'lang_struct', 'lang_semantic', 'output_human',
|
|
144
|
+
'category', 'nature', 'target', 'path', 'limit', 'scope',
|
|
145
|
+
'risk_desc', 'impact', 'mitigation', 'pattern', 'values',
|
|
146
|
+
'statement', 'evidence', 'event', 'result', 'date',
|
|
147
|
+
'action', 'reason', 'owner', 'what', 'goal', 'success',
|
|
148
|
+
'phase', 'current', 'notes',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_cortex_v2(text: str) -> CortexV2Document:
|
|
153
|
+
"""Parse a CORTEX v2 file into a :class:`CortexV2Document`.
|
|
154
|
+
|
|
155
|
+
Handles the exact format used in ``skill/cortex/SKILL.md``:
|
|
156
|
+
- Markdown code fence wrapper
|
|
157
|
+
- ``<!-- CODEC-CORTEX -->`` header
|
|
158
|
+
- ``$0`` entry-based glossary
|
|
159
|
+
- HDL bare ``attrs-pos``
|
|
160
|
+
- ``DIAG`` multiline ``bloque`` entries
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
doc = CortexV2Document()
|
|
164
|
+
doc.raw_text = text
|
|
165
|
+
|
|
166
|
+
# 1. Strip markdown wrapper
|
|
167
|
+
inner = text
|
|
168
|
+
m = _WRAPPER_OPEN.match(inner)
|
|
169
|
+
if m:
|
|
170
|
+
inner = inner[m.end():]
|
|
171
|
+
# Remove closing ```
|
|
172
|
+
idx = inner.rfind('\n```')
|
|
173
|
+
if idx != -1:
|
|
174
|
+
inner = inner[:idx]
|
|
175
|
+
|
|
176
|
+
# 2. Parse CODEC-CORTEX header
|
|
177
|
+
m = _HEADER_RE.search(inner)
|
|
178
|
+
if m:
|
|
179
|
+
header_text = m.group(1)
|
|
180
|
+
for line in header_text.split('\n'):
|
|
181
|
+
if ':' in line:
|
|
182
|
+
k, v = line.split(':', 1)
|
|
183
|
+
k = k.strip()
|
|
184
|
+
v = v.strip()
|
|
185
|
+
# v2.0.1 M-01: if value is already quoted, strip the quotes
|
|
186
|
+
if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
|
|
187
|
+
v = v[1:-1]
|
|
188
|
+
doc.header[k] = v
|
|
189
|
+
# Remove header from inner text for section parsing
|
|
190
|
+
inner = inner[:m.start()] + inner[m.end():]
|
|
191
|
+
|
|
192
|
+
# 3. Parse sections
|
|
193
|
+
# Find all section boundaries
|
|
194
|
+
section_matches = list(_SECTION_RE.finditer(inner))
|
|
195
|
+
if not section_matches:
|
|
196
|
+
# No sections found — treat entire content as $0
|
|
197
|
+
sections_text = [("$0", inner.strip())]
|
|
198
|
+
else:
|
|
199
|
+
sections_text = []
|
|
200
|
+
for i, m in enumerate(section_matches):
|
|
201
|
+
sec_num = m.group(1)
|
|
202
|
+
start = m.end()
|
|
203
|
+
if i + 1 < len(section_matches):
|
|
204
|
+
end = section_matches[i + 1].start()
|
|
205
|
+
else:
|
|
206
|
+
end = len(inner)
|
|
207
|
+
sec_text = inner[start:end].strip()
|
|
208
|
+
sections_text.append((f"${sec_num}", sec_text))
|
|
209
|
+
|
|
210
|
+
# 4. Parse entries in each section
|
|
211
|
+
for sec_id, sec_text in sections_text:
|
|
212
|
+
section = V2Section(id=sec_id)
|
|
213
|
+
section.entries = _parse_entries(sec_text, sec_id)
|
|
214
|
+
doc.sections.append(section)
|
|
215
|
+
|
|
216
|
+
return doc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_entries(text: str, section_id: str) -> List[V2Entry]:
|
|
220
|
+
"""Parse all entries in a section's text."""
|
|
221
|
+
|
|
222
|
+
entries: List[V2Entry] = []
|
|
223
|
+
lines = text.split('\n')
|
|
224
|
+
i = 0
|
|
225
|
+
while i < len(lines):
|
|
226
|
+
line = lines[i]
|
|
227
|
+
stripped = line.strip()
|
|
228
|
+
if not stripped:
|
|
229
|
+
i += 1
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
# Check for HDL bare attrs-pos: HDL:name|...
|
|
233
|
+
if stripped.startswith('HDL:') and '|' in stripped:
|
|
234
|
+
entry = _parse_hdl_entry(stripped, section_id)
|
|
235
|
+
if entry:
|
|
236
|
+
entries.append(entry)
|
|
237
|
+
i += 1
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Check for $0 metadata: $0:name{...}
|
|
241
|
+
if stripped.startswith('$0:'):
|
|
242
|
+
entry = _parse_meta_entry(stripped, section_id)
|
|
243
|
+
if entry:
|
|
244
|
+
entries.append(entry)
|
|
245
|
+
i += 1
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Check for ! entries: !name{ or !:name{
|
|
249
|
+
if stripped.startswith('!'):
|
|
250
|
+
m_bang = _BANG_ENTRY_RE.match(stripped)
|
|
251
|
+
if m_bang:
|
|
252
|
+
name = m_bang.group('name1') or m_bang.group('name2')
|
|
253
|
+
full_raw, end_line = _collect_entry_raw(lines, i)
|
|
254
|
+
entry = _parse_bang_entry(full_raw, name, section_id)
|
|
255
|
+
if entry:
|
|
256
|
+
entries.append(entry)
|
|
257
|
+
i = end_line + 1
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Check for regular entry: SIGIL:name{...}
|
|
261
|
+
m = _ENTRY_START_RE.match(stripped)
|
|
262
|
+
if m:
|
|
263
|
+
sigil = m.group('sigil')
|
|
264
|
+
name = m.group('name')
|
|
265
|
+
# Check if this is a single-line or multi-line entry
|
|
266
|
+
# Count braces in the remaining text from this line
|
|
267
|
+
full_raw, end_line = _collect_entry_raw(lines, i)
|
|
268
|
+
entry = _parse_single_entry(full_raw, sigil, name, section_id)
|
|
269
|
+
if entry:
|
|
270
|
+
entries.append(entry)
|
|
271
|
+
i = end_line + 1
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# Unrecognized line — skip
|
|
275
|
+
i += 1
|
|
276
|
+
|
|
277
|
+
return entries
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _collect_entry_raw(lines: List[str], start: int) -> Tuple[str, int]:
|
|
281
|
+
"""Collect the full raw text of an entry starting at ``lines[start]``.
|
|
282
|
+
|
|
283
|
+
Handles multi-line bloque entries by counting braces.
|
|
284
|
+
Returns ``(raw_text, last_line_index)``.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
line = lines[start]
|
|
288
|
+
# Check if entry is complete on this line (braces balanced)
|
|
289
|
+
depth = 0
|
|
290
|
+
in_string = False
|
|
291
|
+
escape = False
|
|
292
|
+
for ch in line:
|
|
293
|
+
if escape:
|
|
294
|
+
escape = False
|
|
295
|
+
continue
|
|
296
|
+
if ch == '\\':
|
|
297
|
+
escape = True
|
|
298
|
+
continue
|
|
299
|
+
if in_string:
|
|
300
|
+
if ch == '"':
|
|
301
|
+
in_string = False
|
|
302
|
+
continue
|
|
303
|
+
if ch == '"':
|
|
304
|
+
in_string = True
|
|
305
|
+
continue
|
|
306
|
+
if ch == '{':
|
|
307
|
+
depth += 1
|
|
308
|
+
elif ch == '}':
|
|
309
|
+
depth -= 1
|
|
310
|
+
|
|
311
|
+
if depth <= 0:
|
|
312
|
+
# Single-line entry
|
|
313
|
+
return line, start
|
|
314
|
+
|
|
315
|
+
# Multi-line entry: collect until braces balanced
|
|
316
|
+
parts = [line]
|
|
317
|
+
j = start + 1
|
|
318
|
+
while j < len(lines):
|
|
319
|
+
parts.append(lines[j])
|
|
320
|
+
for ch in lines[j]:
|
|
321
|
+
if escape:
|
|
322
|
+
escape = False
|
|
323
|
+
continue
|
|
324
|
+
if ch == '\\':
|
|
325
|
+
escape = True
|
|
326
|
+
continue
|
|
327
|
+
if in_string:
|
|
328
|
+
if ch == '"':
|
|
329
|
+
in_string = False
|
|
330
|
+
continue
|
|
331
|
+
if ch == '"':
|
|
332
|
+
in_string = True
|
|
333
|
+
continue
|
|
334
|
+
if ch == '{':
|
|
335
|
+
depth += 1
|
|
336
|
+
elif ch == '}':
|
|
337
|
+
depth -= 1
|
|
338
|
+
if depth <= 0:
|
|
339
|
+
break
|
|
340
|
+
j += 1
|
|
341
|
+
|
|
342
|
+
return '\n'.join(parts), j
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_single_entry(raw: str, sigil: str, name: str, section_id: str) -> Optional[V2Entry]:
|
|
346
|
+
"""Parse a single entry from its raw text."""
|
|
347
|
+
|
|
348
|
+
# Extract body between { and }
|
|
349
|
+
start = raw.find('{')
|
|
350
|
+
if start == -1:
|
|
351
|
+
return None
|
|
352
|
+
# Find matching close brace
|
|
353
|
+
depth = 0
|
|
354
|
+
in_string = False
|
|
355
|
+
escape = False
|
|
356
|
+
end = -1
|
|
357
|
+
for i in range(start, len(raw)):
|
|
358
|
+
ch = raw[i]
|
|
359
|
+
if escape:
|
|
360
|
+
escape = False
|
|
361
|
+
continue
|
|
362
|
+
if ch == '\\':
|
|
363
|
+
escape = True
|
|
364
|
+
continue
|
|
365
|
+
if in_string:
|
|
366
|
+
if ch == '"':
|
|
367
|
+
in_string = False
|
|
368
|
+
continue
|
|
369
|
+
if ch == '"':
|
|
370
|
+
in_string = True
|
|
371
|
+
continue
|
|
372
|
+
if ch == '{':
|
|
373
|
+
depth += 1
|
|
374
|
+
elif ch == '}':
|
|
375
|
+
depth -= 1
|
|
376
|
+
if depth == 0:
|
|
377
|
+
end = i
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
if end == -1:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
body = raw[start + 1:end]
|
|
384
|
+
|
|
385
|
+
# Determine entry type based on sigil
|
|
386
|
+
# For $0 sigil declarations: IDN:identity{type:attrs,risk:B,...}
|
|
387
|
+
# For $0 metadata: $0:type_attrs{rule:"..."}
|
|
388
|
+
if sigil == '$0':
|
|
389
|
+
# This is a $0 metadata entry
|
|
390
|
+
return _parse_meta_body(raw, name, body, section_id)
|
|
391
|
+
|
|
392
|
+
# For regular sigils, check if it's a cuerpo/bloque (DESC, AXM, DIAG)
|
|
393
|
+
if sigil in ('DESC', 'AXM') :
|
|
394
|
+
# cuerpo type: literal text
|
|
395
|
+
return V2Entry(
|
|
396
|
+
sigil=sigil, name=name, entry_type='cuerpo',
|
|
397
|
+
value=body, raw=raw, section=section_id,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if sigil == 'DIAG':
|
|
401
|
+
# bloque type: verbatim multiline
|
|
402
|
+
return V2Entry(
|
|
403
|
+
sigil=sigil, name=name, entry_type='bloque',
|
|
404
|
+
value=body, raw=raw, section=section_id,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# For ! sigil: attrs with rule/survive
|
|
408
|
+
if sigil == '!':
|
|
409
|
+
attrs = _parse_attrs(body)
|
|
410
|
+
return V2Entry(
|
|
411
|
+
sigil=sigil, name=name, entry_type='attrs',
|
|
412
|
+
value=attrs, raw=raw, section=section_id,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# For $0 sigil declarations (IDN:identity{type:attrs,...})
|
|
416
|
+
# These are glossary declaration entries
|
|
417
|
+
if section_id == '$0' and sigil not in ('$',):
|
|
418
|
+
attrs = _parse_attrs(body)
|
|
419
|
+
# Check if this is a sigil declaration (has type/risk/cortex/desc)
|
|
420
|
+
if 'type' in attrs or 'risk' in attrs or 'cortex' in attrs:
|
|
421
|
+
return V2Entry(
|
|
422
|
+
sigil=sigil, name=name, entry_type='sigil_decl',
|
|
423
|
+
value=attrs, raw=raw, section=section_id,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Default: attrs
|
|
427
|
+
attrs = _parse_attrs(body)
|
|
428
|
+
return V2Entry(
|
|
429
|
+
sigil=sigil, name=name, entry_type='attrs',
|
|
430
|
+
value=attrs, raw=raw, section=section_id,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_meta_entry(line: str, section_id: str) -> Optional[V2Entry]:
|
|
435
|
+
"""Parse a $0:name{...} metadata entry."""
|
|
436
|
+
|
|
437
|
+
m = _META_RE.match(line)
|
|
438
|
+
if not m:
|
|
439
|
+
return None
|
|
440
|
+
name = m.group('name')
|
|
441
|
+
# Extract body
|
|
442
|
+
start = line.find('{')
|
|
443
|
+
if start == -1:
|
|
444
|
+
return None
|
|
445
|
+
# Find matching close brace
|
|
446
|
+
depth = 0
|
|
447
|
+
in_string = False
|
|
448
|
+
escape = False
|
|
449
|
+
end = -1
|
|
450
|
+
for i in range(start, len(line)):
|
|
451
|
+
ch = line[i]
|
|
452
|
+
if escape:
|
|
453
|
+
escape = False
|
|
454
|
+
continue
|
|
455
|
+
if ch == '\\':
|
|
456
|
+
escape = True
|
|
457
|
+
continue
|
|
458
|
+
if in_string:
|
|
459
|
+
if ch == '"':
|
|
460
|
+
in_string = False
|
|
461
|
+
continue
|
|
462
|
+
if ch == '"':
|
|
463
|
+
in_string = True
|
|
464
|
+
continue
|
|
465
|
+
if ch == '{':
|
|
466
|
+
depth += 1
|
|
467
|
+
elif ch == '}':
|
|
468
|
+
depth -= 1
|
|
469
|
+
if depth == 0:
|
|
470
|
+
end = i
|
|
471
|
+
break
|
|
472
|
+
if end == -1:
|
|
473
|
+
return None
|
|
474
|
+
body = line[start + 1:end]
|
|
475
|
+
attrs = _parse_attrs(body)
|
|
476
|
+
return V2Entry(
|
|
477
|
+
sigil='$0', name=name, entry_type='meta',
|
|
478
|
+
value=attrs, raw=line, section=section_id,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _parse_meta_body(raw: str, name: str, body: str, section_id: str) -> Optional[V2Entry]:
|
|
483
|
+
"""Parse a $0 metadata entry from its body text."""
|
|
484
|
+
|
|
485
|
+
attrs = _parse_attrs(body)
|
|
486
|
+
return V2Entry(
|
|
487
|
+
sigil='$0', name=name, entry_type='meta',
|
|
488
|
+
value=attrs, raw=raw, section=section_id,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _parse_bang_entry(raw: str, name: str, section_id: str) -> Optional[V2Entry]:
|
|
493
|
+
"""Parse a ! rule entry: ``!name{rule:"...",survive:min}`` or ``!:name{...}``."""
|
|
494
|
+
|
|
495
|
+
# Extract body between { and }
|
|
496
|
+
start = raw.find('{')
|
|
497
|
+
if start == -1:
|
|
498
|
+
return None
|
|
499
|
+
depth = 0
|
|
500
|
+
in_string = False
|
|
501
|
+
escape = False
|
|
502
|
+
end = -1
|
|
503
|
+
for i in range(start, len(raw)):
|
|
504
|
+
ch = raw[i]
|
|
505
|
+
if escape:
|
|
506
|
+
escape = False
|
|
507
|
+
continue
|
|
508
|
+
if ch == '\\':
|
|
509
|
+
escape = True
|
|
510
|
+
continue
|
|
511
|
+
if in_string:
|
|
512
|
+
if ch == '"':
|
|
513
|
+
in_string = False
|
|
514
|
+
continue
|
|
515
|
+
if ch == '"':
|
|
516
|
+
in_string = True
|
|
517
|
+
continue
|
|
518
|
+
if ch == '{':
|
|
519
|
+
depth += 1
|
|
520
|
+
elif ch == '}':
|
|
521
|
+
depth -= 1
|
|
522
|
+
if depth == 0:
|
|
523
|
+
end = i
|
|
524
|
+
break
|
|
525
|
+
if end == -1:
|
|
526
|
+
return None
|
|
527
|
+
body = raw[start + 1:end]
|
|
528
|
+
attrs = _parse_attrs(body)
|
|
529
|
+
|
|
530
|
+
# In $0, if the entry has type/risk/cortex/desc keys, it's a sigil declaration
|
|
531
|
+
if section_id == '$0' and ('type' in attrs or 'risk' in attrs or 'cortex' in attrs):
|
|
532
|
+
return V2Entry(
|
|
533
|
+
sigil='!', name=name, entry_type='sigil_decl',
|
|
534
|
+
value=attrs, raw=raw, section=section_id,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
return V2Entry(
|
|
538
|
+
sigil='!', name=name, entry_type='attrs',
|
|
539
|
+
value=attrs, raw=raw, section=section_id,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _parse_hdl_entry(line: str, section_id: str) -> Optional[V2Entry]:
|
|
544
|
+
"""Parse an HDL bare attrs-pos entry: ``HDL:name|v1|v2|v3|v4``."""
|
|
545
|
+
|
|
546
|
+
# HDL:name|status|requires|notes
|
|
547
|
+
m = re.match(r'^HDL:(?P<name>[A-Za-z_][A-Za-z0-9_]*)\|(?P<rest>.*)$', line)
|
|
548
|
+
if not m:
|
|
549
|
+
return None
|
|
550
|
+
name = m.group('name')
|
|
551
|
+
rest = m.group('rest')
|
|
552
|
+
# Split by | — but notes field may contain | characters
|
|
553
|
+
# Contract: operation|status|requires|notes
|
|
554
|
+
# We split on the first 3 | characters
|
|
555
|
+
parts = rest.split('|', 3)
|
|
556
|
+
fields = ['operation', 'status', 'requires', 'notes']
|
|
557
|
+
value = {}
|
|
558
|
+
for i, field_name in enumerate(fields):
|
|
559
|
+
if i < len(parts):
|
|
560
|
+
value[field_name] = parts[i]
|
|
561
|
+
else:
|
|
562
|
+
value[field_name] = ''
|
|
563
|
+
|
|
564
|
+
return V2Entry(
|
|
565
|
+
sigil='HDL', name=name, entry_type='attrs-pos',
|
|
566
|
+
value=value, raw=line, section=section_id,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _parse_attrs(body: str) -> Dict[str, Any]:
|
|
571
|
+
"""Parse an attrs body into a dict.
|
|
572
|
+
|
|
573
|
+
Handles mixed quoting:
|
|
574
|
+
- Bare values: ``status:cur``, ``risk:B``, ``survive:min``
|
|
575
|
+
- Quoted values: ``desc:"..."``, ``rule:"..."``
|
|
576
|
+
- Escaped quotes inside strings: ``rule:"... clave:\"valor\" ..."``
|
|
577
|
+
"""
|
|
578
|
+
|
|
579
|
+
out: Dict[str, Any] = {}
|
|
580
|
+
s = body.strip()
|
|
581
|
+
if not s:
|
|
582
|
+
return out
|
|
583
|
+
i = 0
|
|
584
|
+
n = len(s)
|
|
585
|
+
while i < n:
|
|
586
|
+
# Skip whitespace and commas
|
|
587
|
+
while i < n and (s[i].isspace() or s[i] == ','):
|
|
588
|
+
i += 1
|
|
589
|
+
if i >= n:
|
|
590
|
+
break
|
|
591
|
+
# Read key
|
|
592
|
+
m = re.match(r'([A-Za-z_][A-Za-z0-9_]*)', s[i:])
|
|
593
|
+
if not m:
|
|
594
|
+
break
|
|
595
|
+
key = m.group(1)
|
|
596
|
+
i += len(key)
|
|
597
|
+
# Skip whitespace
|
|
598
|
+
while i < n and s[i].isspace():
|
|
599
|
+
i += 1
|
|
600
|
+
if i >= n or s[i] != ':':
|
|
601
|
+
break
|
|
602
|
+
i += 1 # skip ':'
|
|
603
|
+
while i < n and s[i].isspace():
|
|
604
|
+
i += 1
|
|
605
|
+
if i >= n:
|
|
606
|
+
break
|
|
607
|
+
# Read value
|
|
608
|
+
if s[i] == '"':
|
|
609
|
+
# Quoted string — handle escaped quotes
|
|
610
|
+
j = i + 1
|
|
611
|
+
buf: List[str] = []
|
|
612
|
+
escape = False
|
|
613
|
+
while j < n:
|
|
614
|
+
ch = s[j]
|
|
615
|
+
if escape:
|
|
616
|
+
if ch == 'n':
|
|
617
|
+
buf.append('\n')
|
|
618
|
+
elif ch == 't':
|
|
619
|
+
buf.append('\t')
|
|
620
|
+
elif ch == '"':
|
|
621
|
+
buf.append('"')
|
|
622
|
+
elif ch == '\\':
|
|
623
|
+
buf.append('\\')
|
|
624
|
+
else:
|
|
625
|
+
buf.append(ch)
|
|
626
|
+
escape = False
|
|
627
|
+
elif ch == '\\':
|
|
628
|
+
escape = True
|
|
629
|
+
elif ch == '"':
|
|
630
|
+
break
|
|
631
|
+
else:
|
|
632
|
+
buf.append(ch)
|
|
633
|
+
j += 1
|
|
634
|
+
value: Any = ''.join(buf)
|
|
635
|
+
i = j + 1 # skip closing "
|
|
636
|
+
else:
|
|
637
|
+
# Bare value — read until , or end
|
|
638
|
+
j = i
|
|
639
|
+
while j < n and s[j] != ',':
|
|
640
|
+
j += 1
|
|
641
|
+
raw_val = s[i:j].strip()
|
|
642
|
+
i = j
|
|
643
|
+
# Interpret bare values
|
|
644
|
+
if raw_val in ('true', 'false'):
|
|
645
|
+
value = (raw_val == 'true')
|
|
646
|
+
elif raw_val == 'null':
|
|
647
|
+
value = None
|
|
648
|
+
elif re.fullmatch(r'-?\d+', raw_val):
|
|
649
|
+
value = int(raw_val)
|
|
650
|
+
elif re.fullmatch(r'-?\d+\.\d+', raw_val):
|
|
651
|
+
value = float(raw_val)
|
|
652
|
+
else:
|
|
653
|
+
value = raw_val
|
|
654
|
+
out[key] = value
|
|
655
|
+
return out
|