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