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
|
@@ -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"
|