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
@@ -0,0 +1,489 @@
1
+ """HCORTEX-EDIT parser — turns reversible Markdown back into a :class:`CortexDocument`.
2
+
3
+ The parser scans for:
4
+ 1. The first-line ``cortex-render: hcortex-edit`` header
5
+ 2. ``<!-- cortex-section -->`` markers
6
+ 3. ``<!-- cortex-entry -->`` markers (with section, sigil, name, type, hash)
7
+ 4. The ```` ```cortex-glossary ```` fenced block (rebuilds $0)
8
+ 5. Markdown tables (key|value) → attrs
9
+ 6. Fenced code blocks → cuerpo or bloque (depending on declared type)
10
+
11
+ The parser refuses HCORTEX-READ input (raises
12
+ :class:`HCortexReadNotCompilableError`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import List, Optional, Tuple
19
+
20
+ from ..core.ast import (
21
+ AttrsPosContract,
22
+ CortexDocument,
23
+ Entry,
24
+ Glossary,
25
+ MicroDef,
26
+ Section,
27
+ SigilDef,
28
+ TypeDef,
29
+ compute_document_hash,
30
+ compute_entry_hash,
31
+ )
32
+ from ..core.errors import (
33
+ HCortexEditMetadataMissingError,
34
+ HCortexReadNotCompilableError,
35
+ )
36
+ from .markdown_model import (
37
+ EditHeader,
38
+ ENTRY_MARKER_RE,
39
+ SECTION_MARKER_RE,
40
+ is_hcortex_edit,
41
+ is_hcortex_read_in_text,
42
+ )
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Glossary block parser
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _GLOSSARY_DECL_RE = re.compile(
50
+ r"^\s*(?:\#\s*)?"
51
+ r"(?P<sigil>[A-Z][A-Z0-9_]*|!)"
52
+ r"\s*\|\s*"
53
+ r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)"
54
+ r"\s*\|\s*"
55
+ r"(?P<type>[A-Za-z\-]+)"
56
+ r"\s*\|\s*"
57
+ r"(?P<risk>[A-Z])"
58
+ r"\s*\|\s*"
59
+ r"(?P<layer>[A-Za-z/]+)"
60
+ r"\s*\|\s*"
61
+ r"(?P<desc>.+?)\s*$"
62
+ )
63
+
64
+ _TYPE_DECL_RE = re.compile(r"^\s*\#\s*(?P<name>[\w\-]+)\s*=\s*(?P<desc>.+?)\s*$")
65
+ _MICRO_PAIR_RE = re.compile(r"(?P<tok>[a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(?P<val>[^\s,]+)")
66
+ _CONTRACT_RE = re.compile(
67
+ r"^\s*\#\s*contract\s*:\s*"
68
+ r"(?P<sigil>[A-Z][A-Z0-9_]*|!)\s*\|\s*(?P<fields>.+?)\s*$"
69
+ )
70
+
71
+
72
+ def parse_glossary_block(text: str) -> Glossary:
73
+ """Parse a ```` ```cortex-glossary ```` block into a :class:`Glossary`."""
74
+
75
+ g = Glossary()
76
+ # Seed canonical types + micro so the file is operable even if $0 omits them
77
+ from ..core.errors import CANONICAL_MICRO, CANONICAL_TYPES
78
+ for t in CANONICAL_TYPES:
79
+ g.add_type(TypeDef(name=t, description="canonical type"))
80
+ for tok, val in CANONICAL_MICRO.items():
81
+ g.add_micro(MicroDef(token=tok, value=val))
82
+
83
+ for line in text.splitlines():
84
+ line = line.rstrip()
85
+ if not line.strip():
86
+ continue
87
+ m = _GLOSSARY_DECL_RE.match(line)
88
+ if m:
89
+ g.add_sigil(SigilDef(
90
+ sigil=m.group("sigil"),
91
+ name=m.group("name"),
92
+ type=m.group("type"),
93
+ risk=m.group("risk"),
94
+ layer=m.group("layer"),
95
+ description=m.group("desc").strip(),
96
+ ))
97
+ continue
98
+ m = _CONTRACT_RE.match(line)
99
+ if m:
100
+ fields = [f.strip() for f in m.group("fields").split("|")]
101
+ g.add_contract(AttrsPosContract(sigil=m.group("sigil"), fields=fields))
102
+ continue
103
+ # Type declaration: ONLY if line has exactly one '='
104
+ eq_count = line.count("=")
105
+ if eq_count == 1:
106
+ m = _TYPE_DECL_RE.match(line)
107
+ if m:
108
+ name = m.group("name")
109
+ canonical_type_names = {"attrs", "cuerpo", "bloque", "attrs-pos", "relación"}
110
+ if name in canonical_type_names or len(name) > 4:
111
+ g.add_type(TypeDef(name=name, description=m.group("desc")))
112
+ continue
113
+ # Micro-token pairs
114
+ for tok, val in _MICRO_PAIR_RE.findall(line):
115
+ if tok in {"attrs", "cuerpo", "bloque", "attrs-pos", "relación"}:
116
+ continue
117
+ g.add_micro(MicroDef(token=tok, value=val))
118
+ return g
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Markdown table parser
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def _split_markdown_row(line: str) -> List[str]:
126
+ """Split a Markdown table row on ``|`` respecting ``\\|`` escapes.
127
+
128
+ Per CommonMark, a literal ``|`` inside a table cell MUST be escaped
129
+ as ``\\|``. This function honours that convention so values
130
+ containing pipes roundtrip correctly (audit gap H-04 / B-007).
131
+ """
132
+
133
+ s = line.strip()
134
+ # Strip leading/trailing pipe (CommonMark tables)
135
+ if s.startswith("|"):
136
+ s = s[1:]
137
+ if s.endswith("|") and not s.endswith("\\|"):
138
+ s = s[:-1]
139
+ cells: List[str] = []
140
+ buf: List[str] = []
141
+ i = 0
142
+ n = len(s)
143
+ while i < n:
144
+ ch = s[i]
145
+ if ch == "\\" and i + 1 < n and s[i + 1] == "|":
146
+ buf.append("|")
147
+ i += 2
148
+ continue
149
+ if ch == "|":
150
+ cells.append("".join(buf).strip())
151
+ buf = []
152
+ i += 1
153
+ continue
154
+ buf.append(ch)
155
+ i += 1
156
+ cells.append("".join(buf).strip())
157
+ return cells
158
+
159
+
160
+ def _parse_markdown_table(lines: List[str], start: int) -> Tuple[dict, int]:
161
+ """Parse a Markdown table starting at ``lines[start]``.
162
+
163
+ Returns ``(attrs_dict, end_index)``.
164
+ """
165
+
166
+ if start >= len(lines):
167
+ return {}, start
168
+ # Skip leading blank lines
169
+ i = start
170
+ while i < len(lines) and not lines[i].strip():
171
+ i += 1
172
+ if i >= len(lines):
173
+ return {}, i
174
+ # Header row
175
+ if "|" not in lines[i]:
176
+ return {}, i
177
+ header = _split_markdown_row(lines[i])
178
+ i += 1
179
+ # Separator row (---|---|...)
180
+ if i < len(lines) and re.match(r"^\s*\|?\s*[-:]+", lines[i]):
181
+ i += 1
182
+ # Data rows
183
+ out: dict = {}
184
+ while i < len(lines):
185
+ line = lines[i]
186
+ if not line.strip():
187
+ break
188
+ if "|" not in line:
189
+ break
190
+ cells = _split_markdown_row(line)
191
+ key = None
192
+ value = None
193
+ for idx, cell in enumerate(cells):
194
+ if idx < len(header) and header[idx].lower() == "key":
195
+ key = cell
196
+ elif idx < len(header) and header[idx].lower() == "value":
197
+ value = cell
198
+ if key is not None and value is not None:
199
+ out[key] = value
200
+ i += 1
201
+ return out, i
202
+
203
+
204
+ def _parse_attrs_table(lines: List[str], start: int) -> Tuple[dict, int]:
205
+ """Parse a 2-column key|value Markdown table into a dict.
206
+
207
+ Uses :func:`_split_markdown_row` so escaped pipes ``\\|`` in values
208
+ are correctly preserved (audit gap H-04).
209
+ """
210
+
211
+ i = start
212
+ # Skip blanks
213
+ while i < len(lines) and not lines[i].strip():
214
+ i += 1
215
+ if i >= len(lines) or "|" not in lines[i]:
216
+ return {}, i
217
+ # header
218
+ _split_markdown_row(lines[i])
219
+ i += 1
220
+ # separator
221
+ if i < len(lines) and re.match(r"^\s*\|?\s*[-:]+", lines[i]):
222
+ i += 1
223
+ out: dict = {}
224
+ while i < len(lines):
225
+ line = lines[i]
226
+ if not line.strip() or "|" not in line:
227
+ break
228
+ cells = _split_markdown_row(line)
229
+ if len(cells) >= 2:
230
+ key = cells[0]
231
+ value = cells[1]
232
+ # Try to interpret booleans / numbers
233
+ if value == "true":
234
+ value = True
235
+ elif value == "false":
236
+ value = False
237
+ elif re.fullmatch(r"-?\d+", value):
238
+ value = int(value)
239
+ elif re.fullmatch(r"-?\d+\.\d+", value):
240
+ value = float(value)
241
+ out[key] = value
242
+ i += 1
243
+ return out, i
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Fenced block parser
248
+ # ---------------------------------------------------------------------------
249
+
250
+ def _parse_fenced_block(lines: List[str], start: int) -> Tuple[str, str, int]:
251
+ """Parse a ```lang ... ``` block starting at ``lines[start]``.
252
+
253
+ Returns ``(language, body, end_index)`` where ``end_index`` is the
254
+ index *after* the closing fence.
255
+ """
256
+
257
+ line = lines[start].strip()
258
+ # Match opening fence: ```lang or ``` or ~~~~lang
259
+ m = re.match(r"^(?P<fence>```+|~~~~+)(?P<lang>\S*)$", line)
260
+ if not m:
261
+ return "", "", start
262
+ fence = m.group("fence")
263
+ lang = m.group("lang") or ""
264
+ i = start + 1
265
+ body_parts: List[str] = []
266
+ while i < len(lines):
267
+ if lines[i].strip().startswith(fence[0] * len(fence)):
268
+ # closing fence
269
+ return lang, "\n".join(body_parts), i + 1
270
+ body_parts.append(lines[i])
271
+ i += 1
272
+ # unterminated fence — return what we have
273
+ return lang, "\n".join(body_parts), i
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # Top-level HCORTEX-EDIT parser
278
+ # ---------------------------------------------------------------------------
279
+
280
+ def parse_hcortex_edit(text: str, source: str = "<hcortex-edit>") -> CortexDocument:
281
+ """Parse HCORTEX-EDIT Markdown back into a :class:`CortexDocument`.
282
+
283
+ Raises :class:`HCortexReadNotCompilableError` if the input is an
284
+ HCORTEX-READ file, and :class:`HCortexEditMetadataMissingError` if
285
+ required markers are absent.
286
+ """
287
+
288
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
289
+ lines = text.split("\n")
290
+
291
+ if not lines:
292
+ raise HCortexEditMetadataMissingError("empty input")
293
+
294
+ # v1.1.3 P1-5: HCORTEX-READ now starts with "Perfil: CORTEX-<LEVEL>"
295
+ # as the first real line, with the cortex-render marker on line 2.
296
+ # We scan the first few lines to detect the format robustly.
297
+ first_few = "\n".join(lines[:5])
298
+
299
+ # Reject HCORTEX-READ (not compilable)
300
+ if is_hcortex_read_in_text(first_few):
301
+ raise HCortexReadNotCompilableError()
302
+
303
+ # Find the cortex-edit header line
304
+ edit_header_line = None
305
+ for idx, line in enumerate(lines[:5]):
306
+ if is_hcortex_edit(line):
307
+ edit_header_line = line
308
+ break
309
+ if edit_header_line is None:
310
+ raise HCortexEditMetadataMissingError(
311
+ "first lines must declare cortex-render: hcortex-edit; "
312
+ f"got first 3 lines: {lines[:3]!r}"
313
+ )
314
+
315
+ header = EditHeader.parse(edit_header_line)
316
+ if header is None:
317
+ raise HCortexEditMetadataMissingError("malformed hcortex-edit header")
318
+
319
+ doc = CortexDocument()
320
+ doc.meta = {
321
+ "path": source,
322
+ "format": ".hcortex.edit.md",
323
+ "version": None,
324
+ "hash": header.hash,
325
+ "source": header.source,
326
+ }
327
+
328
+ # 1. Find the glossary block (```cortex-glossary ... ```)
329
+ glossary_text = None
330
+ for i, line in enumerate(lines):
331
+ if line.strip().startswith("```cortex-glossary"):
332
+ # find the closing fence
333
+ j = i + 1
334
+ buf: List[str] = []
335
+ while j < len(lines):
336
+ if lines[j].strip().startswith("```"):
337
+ break
338
+ buf.append(lines[j])
339
+ j += 1
340
+ glossary_text = "\n".join(buf)
341
+ break
342
+ if glossary_text is None:
343
+ raise HCortexEditMetadataMissingError("no ```cortex-glossary block found")
344
+ doc.glossary = parse_glossary_block(glossary_text)
345
+
346
+ # Create the $0 section (glossary carrier)
347
+ sec0 = Section(id="$0", title="GLOSSARY")
348
+ doc.sections.append(sec0)
349
+
350
+ # 2. Walk the lines, collecting section markers and entry markers
351
+ current_section: Optional[Section] = None
352
+ i = 0
353
+ while i < len(lines):
354
+ line = lines[i]
355
+ # Section marker
356
+ m = SECTION_MARKER_RE.search(line)
357
+ if m:
358
+ sec_id = m.group("id")
359
+ sec_title = m.group("title") or ""
360
+ if sec_id == "$0":
361
+ current_section = sec0
362
+ current_section.title = sec_title or "GLOSSARY"
363
+ else:
364
+ # Don't create duplicate sections
365
+ existing = doc.get_section(sec_id)
366
+ if existing is None:
367
+ current_section = Section(id=sec_id, title=sec_title)
368
+ doc.sections.append(current_section)
369
+ else:
370
+ current_section = existing
371
+ if sec_title:
372
+ current_section.title = sec_title
373
+ i += 1
374
+ continue
375
+ # Entry marker
376
+ m = ENTRY_MARKER_RE.search(line)
377
+ if m and current_section is not None:
378
+ entry = _parse_entry_block(lines, i, m, current_section.id, doc.glossary)
379
+ current_section.entries.append(entry)
380
+ # Skip past the entry body (we already consumed it)
381
+ # Find the next entry marker or section marker
382
+ j = i + 1
383
+ while j < len(lines):
384
+ if SECTION_MARKER_RE.search(lines[j]):
385
+ break
386
+ if ENTRY_MARKER_RE.search(lines[j]):
387
+ break
388
+ j += 1
389
+ i = j
390
+ continue
391
+ i += 1
392
+
393
+ # Recompute entry hashes based on parsed values
394
+ for sec, entry in doc.iter_entries():
395
+ entry.hash = compute_entry_hash(entry)
396
+ entry.entry_id = entry.hash
397
+
398
+ # Recompute document hash
399
+ doc.meta["hash"] = compute_document_hash(text)
400
+ return doc
401
+
402
+
403
+ def _parse_entry_block(
404
+ lines: List[str],
405
+ marker_idx: int,
406
+ marker_match: re.Match,
407
+ section_id: str,
408
+ glossary: Glossary,
409
+ ) -> Entry:
410
+ """Parse a single entry starting at ``marker_idx``.
411
+
412
+ ``marker_match`` is the regex match for the ``<!-- cortex-entry -->``
413
+ line. The body follows the marker.
414
+ """
415
+
416
+ sigil = marker_match.group("sigil")
417
+ name = marker_match.group("name")
418
+ type_ = marker_match.group("type")
419
+ marker_match.group("hash") or ""
420
+
421
+ # Body starts on the line after the marker
422
+ i = marker_idx + 1
423
+ # Skip blank lines
424
+ while i < len(lines) and not lines[i].strip():
425
+ i += 1
426
+
427
+ value: object = None
428
+
429
+ if type_ in ("attrs", "attrs-pos"):
430
+ attrs, i = _parse_attrs_table(lines, i)
431
+ value = attrs
432
+ ", ".join(f"{k}:{v!r}" for k, v in attrs.items())
433
+ elif type_ == "cuerpo":
434
+ # expect a fenced block ```text ... ```
435
+ if i < len(lines) and lines[i].strip().startswith("```"):
436
+ lang, body, i = _parse_fenced_block(lines, i)
437
+ value = body
438
+ else:
439
+ # fallback: take text until blank line
440
+ buf = []
441
+ while i < len(lines) and lines[i].strip():
442
+ buf.append(lines[i])
443
+ i += 1
444
+ value = "\n".join(buf)
445
+ elif type_ == "bloque":
446
+ # expect a fenced block
447
+ if i < len(lines) and (lines[i].strip().startswith("```") or lines[i].strip().startswith("~~~~")):
448
+ lang, body, i = _parse_fenced_block(lines, i)
449
+ value = body
450
+ else:
451
+ buf = []
452
+ while i < len(lines) and lines[i].strip():
453
+ buf.append(lines[i])
454
+ i += 1
455
+ value = "\n".join(buf)
456
+ elif type_ == "relación":
457
+ if i < len(lines) and lines[i].strip().startswith("```"):
458
+ lang, body, i = _parse_fenced_block(lines, i)
459
+ value = body
460
+ else:
461
+ buf = []
462
+ while i < len(lines) and lines[i].strip():
463
+ buf.append(lines[i])
464
+ i += 1
465
+ value = "\n".join(buf)
466
+ else:
467
+ # Unknown type — fallback to attrs
468
+ attrs, i = _parse_attrs_table(lines, i)
469
+ value = attrs
470
+ ", ".join(f"{k}:{v!r}" for k, v in attrs.items())
471
+
472
+ # Build canonical raw text (for AST compatibility)
473
+ from ..core.writer import serialize_entry_value
474
+ raw = f"{sigil}:{name}{{{serialize_entry_value(value, type_)}}}"
475
+
476
+ entry = Entry(
477
+ section=section_id,
478
+ sigil=sigil,
479
+ name=name,
480
+ type=type_,
481
+ value=value,
482
+ raw=raw,
483
+ line_start=marker_idx + 1,
484
+ line_end=i,
485
+ hash="", # filled below
486
+ )
487
+ entry.hash = compute_entry_hash(entry)
488
+ entry.entry_id = entry.hash
489
+ return entry
@@ -0,0 +1,158 @@
1
+ """HCORTEX-EDIT renderer — human-editable, structurally reversible Markdown.
2
+
3
+ Per Section 8.2 of the spec:
4
+ - Preserves $0 glossary (as a fenced ```` ```cortex-glossary ```` block)
5
+ - Every section has a ``<!-- cortex-section -->`` marker
6
+ - Every entry has a ``<!-- cortex-entry -->`` marker with section,
7
+ sigil, name, type and hash
8
+ - ``bloque`` entries are preserved verbatim inside fenced code blocks
9
+ - The first line declares the file is roundtrip-compilable
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import List
15
+
16
+ from ..core.ast import CortexDocument, Entry, Glossary, SigilDef
17
+ from ..core.writer import serialize_value
18
+ from .markdown_model import HCORTEX_EDIT_HEADER
19
+ from .read_renderer import SECTION_TITLES
20
+
21
+
22
+ def _render_glossary_block(glossary: Glossary) -> List[str]:
23
+ """Render the $0 glossary as a fenced pipe-separated table."""
24
+
25
+ lines: List[str] = []
26
+ lines.append("```cortex-glossary")
27
+ lines.append("# Sigil | Name | Type | Risk | Layer | Description")
28
+ # Order sigils deterministically
29
+ canonical_order = [
30
+ "IDN", "DOM", "KNW", "REF", "TAG", "AXM", "CNST", "!",
31
+ "CLAIM", "LIM", "AUD", "RSK", "FCS", "OBJ", "WRK", "STP",
32
+ "NXT", "SES", "LNG", "DIAG", "HDL", "PFL", "DEP", "DESC", "ERR",
33
+ ]
34
+ sigils = list(glossary.sigils.values())
35
+ def _sort(sd: SigilDef):
36
+ if sd.sigil in canonical_order:
37
+ return (0, canonical_order.index(sd.sigil))
38
+ return (1, sd.sigil)
39
+ sigils.sort(key=_sort)
40
+ for sd in sigils:
41
+ lines.append(
42
+ f"{sd.sigil} | {sd.name} | {sd.type} | {sd.risk} | {sd.layer} | {sd.description}"
43
+ )
44
+ lines.append("# Types:")
45
+ for td in glossary.types.values():
46
+ lines.append(f"# {td.name} = {td.description}")
47
+ lines.append("# Micro-glossary:")
48
+ parts = [f"{m.token}={m.value}" for m in glossary.micro.values()]
49
+ for i in range(0, len(parts), 4):
50
+ lines.append("# " + " ".join(parts[i : i + 4]))
51
+ if glossary.contracts:
52
+ lines.append("# Positional contracts:")
53
+ for c in glossary.contracts.values():
54
+ lines.append(f"# contract: {c.sigil} | " + " | ".join(c.fields))
55
+ lines.append("```")
56
+ return lines
57
+
58
+
59
+ def _render_attrs_table(entry: Entry) -> List[str]:
60
+ lines = [
61
+ "| key | value |",
62
+ "| --- | --- |",
63
+ ]
64
+ if isinstance(entry.value, dict):
65
+ for k, v in entry.value.items():
66
+ if isinstance(v, str):
67
+ # escape pipe in value
68
+ val = v.replace("|", "\\|")
69
+ else:
70
+ val = serialize_value(v)
71
+ lines.append(f"| {k} | {val} |")
72
+ return lines
73
+
74
+
75
+ def _render_cuerpo(entry: Entry) -> List[str]:
76
+ """Render cuerpo as a fenced text block."""
77
+
78
+ text = str(entry.value or "").rstrip()
79
+ return ["```text", text, "```"]
80
+
81
+
82
+ def _render_bloque(entry: Entry) -> List[str]:
83
+ """Render bloque verbatim in a fenced block."""
84
+
85
+ text = str(entry.value or "").rstrip()
86
+ # Try to detect language from content (puml, plantuml, code)
87
+ lang = "text"
88
+ if "@startuml" in text or "@enduml" in text:
89
+ lang = "puml"
90
+ elif "```" in text:
91
+ # Nested fences — wrap in a 4-tilde fence to avoid collision
92
+ return ["~~~~", text, "~~~~"]
93
+ return [f"```{lang}", text, "```"]
94
+
95
+
96
+ def _render_entry(entry: Entry) -> List[str]:
97
+ out: List[str] = []
98
+ # Heading
99
+ out.append(f"### {entry.sigil}:{entry.name}")
100
+ out.append("")
101
+ # Marker (after heading, before body)
102
+ out.append(
103
+ f'<!-- cortex-entry: section="{entry.section}" sigil="{entry.sigil}" '
104
+ f'name="{entry.name}" type="{entry.type}" hash="{entry.hash}" -->'
105
+ )
106
+ out.append("")
107
+ if entry.type in ("attrs", "attrs-pos"):
108
+ out.extend(_render_attrs_table(entry))
109
+ elif entry.type == "cuerpo":
110
+ out.extend(_render_cuerpo(entry))
111
+ elif entry.type == "bloque":
112
+ out.extend(_render_bloque(entry))
113
+ elif entry.type == "relación":
114
+ out.append(f"```\n{entry.value}\n```")
115
+ else:
116
+ out.append(str(entry.value))
117
+ out.append("")
118
+ return out
119
+
120
+
121
+ def render_hcortex_edit(doc: CortexDocument, source: str = "<ast>") -> str:
122
+ """Render ``doc`` as HCORTEX-EDIT Markdown (reversible)."""
123
+
124
+ header = HCORTEX_EDIT_HEADER.format(
125
+ source=source,
126
+ hash=doc.meta.get("hash", "sha256:unknown"),
127
+ )
128
+ out: List[str] = [header, ""]
129
+ out.append("# HCORTEX-EDIT")
130
+ out.append("")
131
+ out.append("> Structurally reversible. Edit entries below; preserve markers.")
132
+ out.append("")
133
+
134
+ # $0 glossary
135
+ out.append("## $0 · GLOSSARY")
136
+ out.append("")
137
+ out.append('<!-- cortex-section: id="$0" title="GLOSSARY" -->')
138
+ out.append("")
139
+ out.extend(_render_glossary_block(doc.glossary))
140
+ out.append("")
141
+
142
+ # Other sections
143
+ for sec in doc.sections:
144
+ if sec.id == "$0":
145
+ continue
146
+ title = sec.title or SECTION_TITLES.get(sec.id, "") # type: ignore[attr-defined]
147
+ out.append(f"## {sec.id} · {title}" if title else f"## {sec.id}")
148
+ out.append("")
149
+ out.append(f'<!-- cortex-section: id="{sec.id}" title="{title}" -->')
150
+ out.append("")
151
+ if not sec.entries:
152
+ out.append("_(no entries)_")
153
+ out.append("")
154
+ continue
155
+ for entry in sec.entries:
156
+ out.extend(_render_entry(entry))
157
+
158
+ return "\n".join(out).rstrip() + "\n"