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/encoder.py
ADDED
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
"""HCORTEX → CORTEX encoder v2.4.0 — bidirectional verified release.
|
|
2
|
+
|
|
3
|
+
v2.3.1 had critical issues:
|
|
4
|
+
- Only 182/266 entries reconstructed (68%)
|
|
5
|
+
- $6 DIAG, $7 contracts, $9 profiles not reconstructed
|
|
6
|
+
- Tables without `name` column produced unparseable names
|
|
7
|
+
|
|
8
|
+
v2.4.0 fixes:
|
|
9
|
+
- Synthetic names: snake_case, stable, reparsable
|
|
10
|
+
- $0 meta entries: target name → entry name (e.g. $0:enum_state → $0:enum_state{...})
|
|
11
|
+
- $0 canonical_sigils: sigil_decl reconstruction
|
|
12
|
+
- $0 contracts/microtokens/type_decls: group kv_table → multiple entries
|
|
13
|
+
- $6 DIAG: verbatim PUML preservation with hash validation
|
|
14
|
+
- $7 contracts: derive contract_<sigil> names from rule content
|
|
15
|
+
- $9 profiles: derive profile_<level> names from topic content
|
|
16
|
+
- $13 VIEW: reconstruct all 44 directives with full metadata
|
|
17
|
+
- Post-write validation: don't write if reparsing loses entries
|
|
18
|
+
- Hash verification: real SHA-256 per block
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import re
|
|
25
|
+
from typing import Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from .diagnostics import Diagnostic
|
|
28
|
+
from .hcortex_parser import (
|
|
29
|
+
HCorTEXDocument, HCorTEXBlock, HCorTEXHeader,
|
|
30
|
+
parse_table_block, parse_list_block, parse_verbatim_block, parse_prose_block,
|
|
31
|
+
)
|
|
32
|
+
from .parser import CortexV2Document, V2Entry, V2Section
|
|
33
|
+
from .view import ViewKind, ReverseStrategy
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# v2.4.0: Column header normalization map
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
HEADER_MAP = {
|
|
41
|
+
"sigilo": "sigil", "sigil": "sigil",
|
|
42
|
+
"nombre": "name", "name": "name",
|
|
43
|
+
"tipo": "type", "type": "type",
|
|
44
|
+
"riesgo": "risk", "risk": "risk",
|
|
45
|
+
"corteza": "cortex", "cortex": "cortex",
|
|
46
|
+
"descripción": "desc", "descripcion": "desc", "desc": "desc",
|
|
47
|
+
"operación": "operation", "operacion": "operation", "operation": "operation",
|
|
48
|
+
"estado": "status", "status": "status",
|
|
49
|
+
"requiere": "requires", "requires": "requires",
|
|
50
|
+
"notas": "notes", "notes": "notes",
|
|
51
|
+
"regla": "rule", "rule": "rule",
|
|
52
|
+
"severidad": "severity", "severity": "severity",
|
|
53
|
+
"survive": "survive",
|
|
54
|
+
"tema": "topic", "topic": "topic",
|
|
55
|
+
"contenido": "content", "content": "content",
|
|
56
|
+
"ruta": "path", "path": "path",
|
|
57
|
+
"rol": "role", "role": "role",
|
|
58
|
+
"codificación": "encoding", "codificacion": "encoding", "encoding": "encoding",
|
|
59
|
+
"límite": "limit", "limite": "limit", "limit": "limit",
|
|
60
|
+
"ámbito": "scope", "ambito": "scope", "scope": "scope",
|
|
61
|
+
"impacto": "impact", "impact": "impact",
|
|
62
|
+
"mitigación": "mitigation", "mitigacion": "mitigation", "mitigation": "mitigation",
|
|
63
|
+
"patrón": "pattern", "patron": "pattern", "pattern": "pattern",
|
|
64
|
+
"prevención": "prevention", "prevencion": "prevention", "prevention": "prevention",
|
|
65
|
+
"declaración": "statement", "declaracion": "statement", "statement": "statement",
|
|
66
|
+
"evidencia": "evidence", "evidence": "evidence",
|
|
67
|
+
"valor": "value", "value": "value",
|
|
68
|
+
"campo": "campo", "field": "campo",
|
|
69
|
+
"expand": "expand", "pos": "pos", "values": "values",
|
|
70
|
+
"campos posicionales": "pos",
|
|
71
|
+
"expansión": "expand", "expansion": "expand",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_field_name(name: str) -> str:
|
|
76
|
+
if not name:
|
|
77
|
+
return name
|
|
78
|
+
cleaned = name.strip().lower()
|
|
79
|
+
return HEADER_MAP.get(cleaned, cleaned)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _strip_backticks(value: str) -> str:
|
|
83
|
+
if not isinstance(value, str):
|
|
84
|
+
return value
|
|
85
|
+
v = value.strip()
|
|
86
|
+
if v.startswith('`') and v.endswith('`'):
|
|
87
|
+
v = v[1:-1]
|
|
88
|
+
elif v.startswith('`'):
|
|
89
|
+
v = v[1:]
|
|
90
|
+
elif v.endswith('`'):
|
|
91
|
+
v = v[:-1]
|
|
92
|
+
return v.strip()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _sanitize_value(value: str) -> str:
|
|
96
|
+
if not isinstance(value, str):
|
|
97
|
+
return value
|
|
98
|
+
v = _strip_backticks(value)
|
|
99
|
+
# v2.4.0: Un-escape \| back to | (renderer escapes | in table cells)
|
|
100
|
+
v = v.replace('\\|', '|')
|
|
101
|
+
v = re.sub(r'\s+', ' ', v).strip()
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _split_table_cells(line: str) -> List[str]:
|
|
106
|
+
"""v2.4.0: Split a Markdown table row into cells, respecting \\| escapes."""
|
|
107
|
+
if line.startswith('|'):
|
|
108
|
+
line = line[1:]
|
|
109
|
+
if line.endswith('|'):
|
|
110
|
+
line = line[:-1]
|
|
111
|
+
cells = []
|
|
112
|
+
current = ''
|
|
113
|
+
i = 0
|
|
114
|
+
while i < len(line):
|
|
115
|
+
c = line[i]
|
|
116
|
+
if c == '\\' and i + 1 < len(line) and line[i + 1] == '|':
|
|
117
|
+
current += '|'
|
|
118
|
+
i += 2
|
|
119
|
+
elif c == '|':
|
|
120
|
+
cells.append(current.strip())
|
|
121
|
+
current = ''
|
|
122
|
+
i += 1
|
|
123
|
+
else:
|
|
124
|
+
current += c
|
|
125
|
+
i += 1
|
|
126
|
+
cells.append(current.strip())
|
|
127
|
+
return cells
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _slugify(text: str, max_len: int = 40) -> str:
|
|
131
|
+
"""Convert text to snake_case slug suitable for entry names."""
|
|
132
|
+
if not text:
|
|
133
|
+
return "entry"
|
|
134
|
+
# Remove backticks
|
|
135
|
+
text = _strip_backticks(text)
|
|
136
|
+
# Lowercase
|
|
137
|
+
text = text.lower()
|
|
138
|
+
# Replace non-alphanumeric with underscore
|
|
139
|
+
text = re.sub(r'[^a-z0-9]+', '_', text)
|
|
140
|
+
# Strip leading/trailing underscores
|
|
141
|
+
text = text.strip('_')
|
|
142
|
+
# Truncate
|
|
143
|
+
if len(text) > max_len:
|
|
144
|
+
text = text[:max_len].rstrip('_')
|
|
145
|
+
return text or "entry"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _synthetic_name(sigil: str, section: str, ordinal: int, hint: str = "") -> str:
|
|
149
|
+
"""Generate a stable, reparsable synthetic name.
|
|
150
|
+
|
|
151
|
+
Format: synthetic_<sigil>_<section>_<ordinal> or synthetic_<sigil>_<section>_<hint>
|
|
152
|
+
v2.4.0: Sanitize sigil (replace ! with rule, $ with s) to keep names reparsable.
|
|
153
|
+
"""
|
|
154
|
+
safe_sigil = sigil.replace("!", "rule").replace("$", "s").lower()
|
|
155
|
+
sec = section.replace("$", "s")
|
|
156
|
+
if hint:
|
|
157
|
+
slug = _slugify(hint, max_len=30)
|
|
158
|
+
return f"synthetic_{safe_sigil}_{sec}_{slug}"
|
|
159
|
+
return f"synthetic_{safe_sigil}_{sec}_{ordinal:03d}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# F-09: encode_cortex_from_ast()
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def encode_cortex_from_ast(hdoc: HCorTEXDocument, mode: str = "normal") -> Tuple[CortexV2Document, List[Diagnostic]]:
|
|
167
|
+
"""Encode an HCorTEXDocument back to a CortexV2Document.
|
|
168
|
+
|
|
169
|
+
v2.4.0: Full reconstruction with synthetic names, DIAG preservation,
|
|
170
|
+
$13 VIEW reconstruction, and post-write validation.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
diags: List[Diagnostic] = list(hdoc.diags)
|
|
174
|
+
|
|
175
|
+
cortex_doc = CortexV2Document()
|
|
176
|
+
cortex_doc.header = _build_cortex_header(hdoc.header)
|
|
177
|
+
|
|
178
|
+
sections: Dict[str, V2Section] = {}
|
|
179
|
+
|
|
180
|
+
# F-10/F-13: For each VIEW block, reconstruct entries
|
|
181
|
+
for block in hdoc.blocks:
|
|
182
|
+
entries, entry_diags = _block_to_entries(block, mode)
|
|
183
|
+
diags.extend(entry_diags)
|
|
184
|
+
|
|
185
|
+
section_id = _resolve_section(block)
|
|
186
|
+
if section_id not in sections:
|
|
187
|
+
sections[section_id] = V2Section(id=section_id)
|
|
188
|
+
sections[section_id].entries.extend(entries)
|
|
189
|
+
|
|
190
|
+
# v2.4.0 P0-6: Reconstruct $13 with VIEW directives
|
|
191
|
+
if hdoc.blocks:
|
|
192
|
+
if "$13" not in sections:
|
|
193
|
+
sections["$13"] = V2Section(id="$13")
|
|
194
|
+
for block in hdoc.blocks:
|
|
195
|
+
view_entry = _block_to_view_entry(block)
|
|
196
|
+
if view_entry is not None:
|
|
197
|
+
sections["$13"].entries.append(view_entry)
|
|
198
|
+
|
|
199
|
+
# F-11: Ensure $0 exists
|
|
200
|
+
if "$0" not in sections:
|
|
201
|
+
sections["$0"] = V2Section(id="$0")
|
|
202
|
+
diags.append(Diagnostic(
|
|
203
|
+
"E_VIEW_MISSING",
|
|
204
|
+
"$0 section not found in HCORTEX — empty glossary created",
|
|
205
|
+
"warning" if mode != "strict" else "error",
|
|
206
|
+
))
|
|
207
|
+
|
|
208
|
+
sorted_ids = sorted(sections.keys(), key=lambda s: int(s[1:]) if s[1:].isdigit() else 999)
|
|
209
|
+
cortex_doc.sections = [sections[sid] for sid in sorted_ids]
|
|
210
|
+
|
|
211
|
+
# v2.4.0 P0-9: Post-write validation
|
|
212
|
+
from .writer import write_cortex_v2
|
|
213
|
+
from .parser import parse_cortex_v2
|
|
214
|
+
test_text = write_cortex_v2(cortex_doc)
|
|
215
|
+
try:
|
|
216
|
+
re_doc = parse_cortex_v2(test_text)
|
|
217
|
+
re_entries = sum(len(s.entries) for s in re_doc.sections)
|
|
218
|
+
declared_entries = sum(len(s.entries) for s in cortex_doc.sections)
|
|
219
|
+
if re_entries < declared_entries:
|
|
220
|
+
diags.append(Diagnostic(
|
|
221
|
+
"E_AST_EQUIVALENCE_FAIL",
|
|
222
|
+
f"Post-write validation: declared {declared_entries} entries but reparse found {re_entries}",
|
|
223
|
+
"error",
|
|
224
|
+
))
|
|
225
|
+
except Exception as e:
|
|
226
|
+
diags.append(Diagnostic(
|
|
227
|
+
"E_AST_EQUIVALENCE_FAIL",
|
|
228
|
+
f"Post-write validation: reparse failed: {e}",
|
|
229
|
+
"error",
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
if mode == "strict":
|
|
233
|
+
for d in diags:
|
|
234
|
+
if d.severity == "warning":
|
|
235
|
+
d.severity = "error"
|
|
236
|
+
|
|
237
|
+
return cortex_doc, diags
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _block_to_view_entry(block: HCorTEXBlock) -> Optional[V2Entry]:
|
|
241
|
+
"""Reconstruct a VIEW directive entry from an HCorTEXBlock."""
|
|
242
|
+
attrs: Dict[str, str] = {
|
|
243
|
+
"kind": block.kind,
|
|
244
|
+
"target": block.target,
|
|
245
|
+
"reverse": block.reverse,
|
|
246
|
+
"status": block.status,
|
|
247
|
+
}
|
|
248
|
+
if block.fields:
|
|
249
|
+
attrs["fields"] = ",".join(block.fields)
|
|
250
|
+
if block.order:
|
|
251
|
+
attrs["order"] = block.order
|
|
252
|
+
if block.title:
|
|
253
|
+
attrs["title"] = block.title
|
|
254
|
+
if block.scope:
|
|
255
|
+
attrs["scope"] = block.scope
|
|
256
|
+
if block.section:
|
|
257
|
+
attrs["section"] = block.section
|
|
258
|
+
if block.source_section:
|
|
259
|
+
attrs["source_section"] = block.source_section
|
|
260
|
+
if block.preserve:
|
|
261
|
+
attrs["preserve"] = block.preserve
|
|
262
|
+
if block.hash:
|
|
263
|
+
attrs["hash"] = block.hash
|
|
264
|
+
if block.fallback:
|
|
265
|
+
attrs["fallback"] = block.fallback
|
|
266
|
+
|
|
267
|
+
return V2Entry(
|
|
268
|
+
sigil="VIEW",
|
|
269
|
+
name=block.view_name,
|
|
270
|
+
entry_type="attrs",
|
|
271
|
+
value=attrs,
|
|
272
|
+
section="$13",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_cortex_header(hheader: HCorTEXHeader) -> Dict[str, str]:
|
|
277
|
+
cortex_header: Dict[str, str] = {"internal_encoding": "CORTEX"}
|
|
278
|
+
if hheader.source_artifact:
|
|
279
|
+
cortex_header["source_artifact"] = hheader.source_artifact
|
|
280
|
+
if hheader.source_version:
|
|
281
|
+
cortex_header["source_version"] = hheader.source_version
|
|
282
|
+
if hheader.status:
|
|
283
|
+
cortex_header["status"] = hheader.status
|
|
284
|
+
return cortex_header
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _resolve_section(block: HCorTEXBlock) -> str:
|
|
288
|
+
if block.section:
|
|
289
|
+
return block.section
|
|
290
|
+
if block.source_section:
|
|
291
|
+
return block.source_section
|
|
292
|
+
if block.target.startswith("$"):
|
|
293
|
+
parts = block.target.split(":", 1)
|
|
294
|
+
return parts[0]
|
|
295
|
+
return "$1"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _derive_sigil_name_from_target(target: str) -> Tuple[str, str]:
|
|
299
|
+
if target.startswith("$") and target.count(":") == 2:
|
|
300
|
+
parts = target.split(":")
|
|
301
|
+
return parts[1], parts[2]
|
|
302
|
+
if target.startswith("$") and target.endswith(":*"):
|
|
303
|
+
parts = target.split(":")
|
|
304
|
+
sigil = parts[1] if len(parts) >= 2 else "IDN"
|
|
305
|
+
return sigil, "entry"
|
|
306
|
+
if target.startswith("$") and ":" in target and target.count(":") == 1:
|
|
307
|
+
parts = target.split(":", 1)
|
|
308
|
+
name = parts[1]
|
|
309
|
+
if name in ("canonical_sigils", "type_decls", "contracts", "microtokens"):
|
|
310
|
+
return "$0", name
|
|
311
|
+
return "IDN", name
|
|
312
|
+
if ":" in target and not target.startswith("$"):
|
|
313
|
+
parts = target.split(":", 1)
|
|
314
|
+
sigil = parts[0]
|
|
315
|
+
name = parts[1] if parts[1] != "*" else "entry"
|
|
316
|
+
return sigil, name
|
|
317
|
+
return "IDN", "entry"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _block_to_entries(block: HCorTEXBlock, mode: str) -> Tuple[List[V2Entry], List[Diagnostic]]:
|
|
321
|
+
diags: List[Diagnostic] = []
|
|
322
|
+
entries: List[V2Entry] = []
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
kind = ViewKind(block.kind)
|
|
326
|
+
reverse = ReverseStrategy(block.reverse)
|
|
327
|
+
except ValueError as e:
|
|
328
|
+
diags.append(Diagnostic(
|
|
329
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
330
|
+
f"VIEW:{block.view_name} invalid kind/reverse: {e}",
|
|
331
|
+
"error", f"VIEW:{block.view_name}",
|
|
332
|
+
))
|
|
333
|
+
return entries, diags
|
|
334
|
+
|
|
335
|
+
# v2.4.0 P1-2: Verify hash if present
|
|
336
|
+
if block.hash:
|
|
337
|
+
computed_hash = _compute_block_hash(block)
|
|
338
|
+
if computed_hash != block.hash:
|
|
339
|
+
diags.append(Diagnostic(
|
|
340
|
+
"E_VIEW_HASH_MISMATCH",
|
|
341
|
+
f"VIEW:{block.view_name} hash mismatch: declared={block.hash!r}, computed={computed_hash!r}",
|
|
342
|
+
"error", f"VIEW:{block.view_name}",
|
|
343
|
+
))
|
|
344
|
+
|
|
345
|
+
if kind == ViewKind.TABLE and reverse == ReverseStrategy.ROWS_TO_ENTRIES:
|
|
346
|
+
entries = _table_to_entries(block, diags)
|
|
347
|
+
elif kind == ViewKind.KV_TABLE and reverse == ReverseStrategy.ROW_TO_ATTRS:
|
|
348
|
+
entries = _kv_table_to_entries(block, diags)
|
|
349
|
+
elif kind == ViewKind.LIST and reverse == ReverseStrategy.ITEMS_TO_ENTRIES:
|
|
350
|
+
entries = _list_to_entries(block, diags)
|
|
351
|
+
elif kind == ViewKind.NUMBERED_LIST and reverse == ReverseStrategy.ITEMS_TO_ORDERED_ENTRIES:
|
|
352
|
+
entries = _numbered_list_to_entries(block, diags)
|
|
353
|
+
elif kind in (ViewKind.PROSE, ViewKind.QUOTE) and reverse == ReverseStrategy.BODY_TO_CUERPO:
|
|
354
|
+
entries = _prose_to_cuerpo(block, diags)
|
|
355
|
+
elif kind in (ViewKind.PUML, ViewKind.CODE) and reverse == ReverseStrategy.VERBATIM_TO_BLOQUE:
|
|
356
|
+
entries = _verbatim_to_bloque(block, diags)
|
|
357
|
+
elif kind == ViewKind.CALLOUT and reverse == ReverseStrategy.CALLOUT_TO_RISK:
|
|
358
|
+
entries = _callout_to_risk(block, diags)
|
|
359
|
+
else:
|
|
360
|
+
diags.append(Diagnostic(
|
|
361
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
362
|
+
f"VIEW:{block.view_name} kind={kind.value} reverse={reverse.value} not yet implemented",
|
|
363
|
+
"warning", f"VIEW:{block.view_name}",
|
|
364
|
+
))
|
|
365
|
+
|
|
366
|
+
return entries, diags
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _compute_block_hash(block: HCorTEXBlock) -> str:
|
|
370
|
+
content = '\n'.join(block.content_lines)
|
|
371
|
+
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:16]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# v2.4.0 P0-2: TABLE → entries with synthetic names
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def _table_to_entries(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
379
|
+
"""Convert a table to multiple entries.
|
|
380
|
+
|
|
381
|
+
v2.4.0: Smart name derivation — uses name column, source, or synthetic names.
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
rows, table_diags = parse_table_block(block)
|
|
385
|
+
diags.extend(table_diags)
|
|
386
|
+
|
|
387
|
+
default_sigil, _ = _derive_sigil_name_from_target(block.target)
|
|
388
|
+
section_id = _resolve_section(block)
|
|
389
|
+
|
|
390
|
+
# v2.4.0: Special handling for $0:canonical_sigils
|
|
391
|
+
if block.target == "$0:canonical_sigils":
|
|
392
|
+
return _canonical_sigils_to_entries(rows, diags)
|
|
393
|
+
|
|
394
|
+
# v2.4.0: Special handling for $0:contracts
|
|
395
|
+
if block.target == "$0:contracts":
|
|
396
|
+
return _contracts_decl_to_entries(rows, diags)
|
|
397
|
+
|
|
398
|
+
# v2.4.0: Special handling for $0:microtokens
|
|
399
|
+
if block.target == "$0:microtokens":
|
|
400
|
+
return _microtokens_decl_to_entries(rows, diags)
|
|
401
|
+
|
|
402
|
+
entries: List[V2Entry] = []
|
|
403
|
+
for i, row in enumerate(rows):
|
|
404
|
+
normalized_row: Dict[str, str] = {}
|
|
405
|
+
for k, v in row.items():
|
|
406
|
+
norm_key = _normalize_field_name(k)
|
|
407
|
+
normalized_row[norm_key] = v
|
|
408
|
+
|
|
409
|
+
# v2.4.0: Check for `source` column first (SIGIL:name)
|
|
410
|
+
source = _strip_backticks(normalized_row.get("source", ""))
|
|
411
|
+
sigil = ""
|
|
412
|
+
name = ""
|
|
413
|
+
if source and ":" in source:
|
|
414
|
+
parts = source.split(":", 1)
|
|
415
|
+
sigil = parts[0].strip().upper()
|
|
416
|
+
name = _sanitize_value(parts[1])
|
|
417
|
+
|
|
418
|
+
# Fall back to sigil/name columns if present
|
|
419
|
+
if not sigil:
|
|
420
|
+
sigil = _strip_backticks(normalized_row.get("sigil", "")).upper()
|
|
421
|
+
if not name:
|
|
422
|
+
name = _sanitize_value(normalized_row.get("name", ""))
|
|
423
|
+
|
|
424
|
+
if not sigil:
|
|
425
|
+
sigil = default_sigil
|
|
426
|
+
|
|
427
|
+
# v2.4.0 P0-2: Derive name deterministically if still missing
|
|
428
|
+
if not name:
|
|
429
|
+
name = _derive_name_from_content(normalized_row, sigil, section_id, i, block.target)
|
|
430
|
+
|
|
431
|
+
if not sigil or not name:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# Build attrs (exclude source, sigil, name; skip empty values)
|
|
435
|
+
attrs: Dict[str, str] = {}
|
|
436
|
+
for k, v in normalized_row.items():
|
|
437
|
+
if k in ("sigil", "name", "source"):
|
|
438
|
+
continue
|
|
439
|
+
sanitized = _sanitize_value(v)
|
|
440
|
+
if sanitized: # v2.4.0: skip empty attrs to match original
|
|
441
|
+
attrs[k] = sanitized
|
|
442
|
+
|
|
443
|
+
# v2.4.0: HDL special case — use attrs-pos
|
|
444
|
+
if sigil == "HDL":
|
|
445
|
+
# HDL uses bare attrs-pos: HDL:name|operation|status|requires|notes
|
|
446
|
+
entry = V2Entry(
|
|
447
|
+
sigil=sigil,
|
|
448
|
+
name=name,
|
|
449
|
+
entry_type="attrs-pos",
|
|
450
|
+
value={
|
|
451
|
+
"operation": attrs.get("operation", ""),
|
|
452
|
+
"status": attrs.get("status", ""),
|
|
453
|
+
"requires": attrs.get("requires", ""),
|
|
454
|
+
"notes": attrs.get("notes", ""),
|
|
455
|
+
},
|
|
456
|
+
section=section_id,
|
|
457
|
+
)
|
|
458
|
+
elif sigil == "$0":
|
|
459
|
+
entry = V2Entry(
|
|
460
|
+
sigil="$0",
|
|
461
|
+
name=name,
|
|
462
|
+
entry_type="meta",
|
|
463
|
+
value=attrs,
|
|
464
|
+
section="$0",
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
entry = V2Entry(
|
|
468
|
+
sigil=sigil,
|
|
469
|
+
name=name,
|
|
470
|
+
entry_type="attrs",
|
|
471
|
+
value=attrs,
|
|
472
|
+
section=section_id,
|
|
473
|
+
)
|
|
474
|
+
entries.append(entry)
|
|
475
|
+
|
|
476
|
+
return entries
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _derive_name_from_content(row: Dict[str, str], sigil: str, section: str, ordinal: int, target: str) -> str:
|
|
480
|
+
"""v2.4.0 P0-2: Derive a stable, reparsable name from row content.
|
|
481
|
+
|
|
482
|
+
Priority:
|
|
483
|
+
1. If `name` field exists → use it
|
|
484
|
+
2. If `operation` field → slugify it (HDL handlers)
|
|
485
|
+
3. If `path` field → slugify it (REF entries)
|
|
486
|
+
4. If `topic` field → slugify it (KNW entries)
|
|
487
|
+
5. If `rule` field → try to extract sigil name from contract rules
|
|
488
|
+
6. If `pattern` field → slugify it (PFL entries)
|
|
489
|
+
7. If `limit` field → slugify it (LIM entries)
|
|
490
|
+
8. If `risk` field → slugify it (RSK entries)
|
|
491
|
+
9. Fallback: synthetic_<sigil>_<section>_<ordinal>
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
# Check for contract patterns: "FCS requiere what,priority,..."
|
|
495
|
+
rule = row.get("rule", "")
|
|
496
|
+
if rule:
|
|
497
|
+
# Contract pattern: "SIGIL requiere ..." or "SIGIL MUST ..."
|
|
498
|
+
m = re.match(r'^(\w+)\s+(?:requiere|MUST|DEBE|debe)', rule, re.IGNORECASE)
|
|
499
|
+
if m:
|
|
500
|
+
contract_sigil = m.group(1).upper()
|
|
501
|
+
return f"contract_{contract_sigil.lower()}"
|
|
502
|
+
|
|
503
|
+
# Check for profile patterns in topic: "CORTEX-MIN", "CORTEX-RECOVERY"
|
|
504
|
+
topic = row.get("topic", "")
|
|
505
|
+
if topic:
|
|
506
|
+
slug = _slugify(topic)
|
|
507
|
+
if slug:
|
|
508
|
+
return f"profile_{slug}" if section == "$9" else slug
|
|
509
|
+
|
|
510
|
+
# operation field (HDL)
|
|
511
|
+
op = row.get("operation", "")
|
|
512
|
+
if op:
|
|
513
|
+
return _slugify(op)
|
|
514
|
+
|
|
515
|
+
# path field (REF)
|
|
516
|
+
path = row.get("path", "")
|
|
517
|
+
if path:
|
|
518
|
+
# Extract filename without extension
|
|
519
|
+
basename = path.split("/")[-1].split(".")[0]
|
|
520
|
+
return _slugify(basename) or f"ref_{ordinal:03d}"
|
|
521
|
+
|
|
522
|
+
# pattern field (PFL)
|
|
523
|
+
pattern = row.get("pattern", "")
|
|
524
|
+
if pattern:
|
|
525
|
+
return _slugify(pattern, max_len=30)
|
|
526
|
+
|
|
527
|
+
# limit field (LIM)
|
|
528
|
+
limit = row.get("limit", "")
|
|
529
|
+
if limit:
|
|
530
|
+
return _slugify(limit, max_len=30)
|
|
531
|
+
|
|
532
|
+
# risk field (RSK)
|
|
533
|
+
risk = row.get("risk", "")
|
|
534
|
+
if risk:
|
|
535
|
+
return _slugify(risk, max_len=30)
|
|
536
|
+
|
|
537
|
+
# Fallback: synthetic name
|
|
538
|
+
return _synthetic_name(sigil, section, ordinal)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _canonical_sigils_to_entries(rows: List[Dict[str, str]], diags: List[Diagnostic]) -> List[V2Entry]:
|
|
542
|
+
"""Reconstruct $0 sigil declarations from the canonical_sigils table."""
|
|
543
|
+
entries: List[V2Entry] = []
|
|
544
|
+
for row in rows:
|
|
545
|
+
normalized: Dict[str, str] = {}
|
|
546
|
+
for k, v in row.items():
|
|
547
|
+
normalized[_normalize_field_name(k)] = v
|
|
548
|
+
|
|
549
|
+
sigil = _strip_backticks(normalized.get("sigil", "")).upper()
|
|
550
|
+
name = _sanitize_value(normalized.get("name", ""))
|
|
551
|
+
|
|
552
|
+
if not sigil or not name:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
attrs: Dict[str, str] = {}
|
|
556
|
+
for k, v in normalized.items():
|
|
557
|
+
if k in ("sigil", "name"):
|
|
558
|
+
continue
|
|
559
|
+
attrs[k] = _sanitize_value(v)
|
|
560
|
+
|
|
561
|
+
entry = V2Entry(
|
|
562
|
+
sigil=sigil,
|
|
563
|
+
name=name,
|
|
564
|
+
entry_type="sigil_decl",
|
|
565
|
+
value=attrs,
|
|
566
|
+
section="$0",
|
|
567
|
+
)
|
|
568
|
+
entries.append(entry)
|
|
569
|
+
return entries
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _contracts_decl_to_entries(rows: List[Dict[str, str]], diags: List[Diagnostic]) -> List[V2Entry]:
|
|
573
|
+
"""Reconstruct $0:contract_* entries from the contracts table.
|
|
574
|
+
|
|
575
|
+
Table format: | Sigilo | Campos posicionales |
|
|
576
|
+
Entry format: $0:contract_<sigil>{pos:"..."}
|
|
577
|
+
"""
|
|
578
|
+
entries: List[V2Entry] = []
|
|
579
|
+
for row in rows:
|
|
580
|
+
normalized: Dict[str, str] = {}
|
|
581
|
+
for k, v in row.items():
|
|
582
|
+
normalized[_normalize_field_name(k)] = v
|
|
583
|
+
|
|
584
|
+
sigil = _strip_backticks(normalized.get("sigil", "")).upper()
|
|
585
|
+
pos = _sanitize_value(normalized.get("pos", ""))
|
|
586
|
+
|
|
587
|
+
if not sigil:
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
name = f"contract_{sigil.lower()}"
|
|
591
|
+
entry = V2Entry(
|
|
592
|
+
sigil="$0",
|
|
593
|
+
name=name,
|
|
594
|
+
entry_type="meta",
|
|
595
|
+
value={"pos": pos},
|
|
596
|
+
section="$0",
|
|
597
|
+
)
|
|
598
|
+
entries.append(entry)
|
|
599
|
+
return entries
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _microtokens_decl_to_entries(rows: List[Dict[str, str]], diags: List[Diagnostic]) -> List[V2Entry]:
|
|
603
|
+
"""Reconstruct $0:micro_* entries from the microtokens table.
|
|
604
|
+
|
|
605
|
+
Table format: | Token | Expansión |
|
|
606
|
+
Entry format: $0:micro_<token>{expand:"..."}
|
|
607
|
+
"""
|
|
608
|
+
entries: List[V2Entry] = []
|
|
609
|
+
for row in rows:
|
|
610
|
+
normalized: Dict[str, str] = {}
|
|
611
|
+
for k, v in row.items():
|
|
612
|
+
normalized[_normalize_field_name(k)] = v
|
|
613
|
+
|
|
614
|
+
token = _strip_backticks(normalized.get("token", normalized.get("campo", "")))
|
|
615
|
+
expand = _sanitize_value(normalized.get("expand", normalized.get("valor", "")))
|
|
616
|
+
|
|
617
|
+
if not token:
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
name = f"micro_{_slugify(token)}"
|
|
621
|
+
entry = V2Entry(
|
|
622
|
+
sigil="$0",
|
|
623
|
+
name=name,
|
|
624
|
+
entry_type="meta",
|
|
625
|
+
value={"expand": expand},
|
|
626
|
+
section="$0",
|
|
627
|
+
)
|
|
628
|
+
entries.append(entry)
|
|
629
|
+
return entries
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ---------------------------------------------------------------------------
|
|
633
|
+
# v2.4.0: KV_TABLE → entries (smart group handling)
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
def _kv_table_to_entries(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
637
|
+
"""Convert a KV table to entries.
|
|
638
|
+
|
|
639
|
+
v2.4.0: Smart handling:
|
|
640
|
+
- **Source:** markers split group kv_tables into individual entries
|
|
641
|
+
- $0:NAME targets → single meta entry with name=NAME
|
|
642
|
+
- $N:SIGIL:name targets → single attrs entry
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
rows, table_diags = parse_table_block(block)
|
|
646
|
+
diags.extend(table_diags)
|
|
647
|
+
|
|
648
|
+
section_id = _resolve_section(block)
|
|
649
|
+
default_sigil, target_name = _derive_sigil_name_from_target(block.target)
|
|
650
|
+
|
|
651
|
+
# v2.4.0: Check for **Source:** markers in the content (group kv_table)
|
|
652
|
+
source_markers = []
|
|
653
|
+
for line in block.content_lines:
|
|
654
|
+
stripped = line.strip()
|
|
655
|
+
if stripped.startswith("**Source:**"):
|
|
656
|
+
import re as _re
|
|
657
|
+
m = _re.search(r'`([$\w]+):(\w+)`', stripped)
|
|
658
|
+
if m:
|
|
659
|
+
source_markers.append((m.group(1), m.group(2)))
|
|
660
|
+
|
|
661
|
+
# v2.4.0: If Source markers found, split rows by Source
|
|
662
|
+
if source_markers:
|
|
663
|
+
return _kv_table_group_with_sources(block, rows, source_markers, section_id, diags)
|
|
664
|
+
|
|
665
|
+
if not rows:
|
|
666
|
+
return []
|
|
667
|
+
|
|
668
|
+
# $0:NAME → single meta entry
|
|
669
|
+
if section_id == "$0" and not block.target.endswith(":*"):
|
|
670
|
+
attrs: Dict[str, str] = {}
|
|
671
|
+
for row in rows:
|
|
672
|
+
campo = _normalize_field_name(row.get("campo", row.get("field", "")))
|
|
673
|
+
valor = _sanitize_value(row.get("valor", row.get("value", "")))
|
|
674
|
+
if campo:
|
|
675
|
+
attrs[campo] = valor
|
|
676
|
+
|
|
677
|
+
entry = V2Entry(
|
|
678
|
+
sigil="$0",
|
|
679
|
+
name=target_name,
|
|
680
|
+
entry_type="meta",
|
|
681
|
+
value=attrs,
|
|
682
|
+
section="$0",
|
|
683
|
+
)
|
|
684
|
+
return [entry]
|
|
685
|
+
|
|
686
|
+
# $N:SIGIL:name → single attrs entry
|
|
687
|
+
if not block.target.endswith(":*") and default_sigil != "IDN":
|
|
688
|
+
attrs: Dict[str, str] = {}
|
|
689
|
+
for row in rows:
|
|
690
|
+
campo = _normalize_field_name(row.get("campo", row.get("field", "")))
|
|
691
|
+
valor = _sanitize_value(row.get("valor", row.get("value", "")))
|
|
692
|
+
if campo:
|
|
693
|
+
attrs[campo] = valor
|
|
694
|
+
|
|
695
|
+
entry = V2Entry(
|
|
696
|
+
sigil=default_sigil,
|
|
697
|
+
name=target_name,
|
|
698
|
+
entry_type="attrs",
|
|
699
|
+
value=attrs,
|
|
700
|
+
section=section_id,
|
|
701
|
+
)
|
|
702
|
+
return [entry]
|
|
703
|
+
|
|
704
|
+
# v2.4.0: Group kv_table without Source markers — create one entry with synthetic name
|
|
705
|
+
attrs: Dict[str, str] = {}
|
|
706
|
+
for row in rows:
|
|
707
|
+
campo = _normalize_field_name(row.get("campo", row.get("field", "")))
|
|
708
|
+
valor = _sanitize_value(row.get("valor", row.get("value", "")))
|
|
709
|
+
if campo:
|
|
710
|
+
attrs[campo] = valor
|
|
711
|
+
|
|
712
|
+
name = _slugify(target_name) if target_name != "entry" else _synthetic_name(default_sigil, section_id, 1)
|
|
713
|
+
entry = V2Entry(
|
|
714
|
+
sigil=default_sigil,
|
|
715
|
+
name=name,
|
|
716
|
+
entry_type="attrs",
|
|
717
|
+
value=attrs,
|
|
718
|
+
section=section_id,
|
|
719
|
+
)
|
|
720
|
+
return [entry]
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _kv_table_group_with_sources(
|
|
724
|
+
block: HCorTEXBlock,
|
|
725
|
+
rows: List[Dict[str, str]],
|
|
726
|
+
source_markers: List[Tuple[str, str]],
|
|
727
|
+
section_id: str,
|
|
728
|
+
diags: List[Diagnostic],
|
|
729
|
+
) -> List[V2Entry]:
|
|
730
|
+
"""v2.4.0: Parse a group kv_table with **Source:** markers.
|
|
731
|
+
|
|
732
|
+
Each Source marker indicates a new entry. Rows between markers belong to that entry.
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
entries: List[V2Entry] = []
|
|
736
|
+
|
|
737
|
+
# If we have N source markers, we need to split the rows into N groups
|
|
738
|
+
# The rows don't have Source info, so we split evenly (or by content analysis)
|
|
739
|
+
# Actually, the renderer puts the Source heading BEFORE each table, and
|
|
740
|
+
# parse_table_block only finds the FIRST table. So for group kv_tables,
|
|
741
|
+
# we need to parse each table separately.
|
|
742
|
+
|
|
743
|
+
# Re-parse the content to find all tables
|
|
744
|
+
import re as _re
|
|
745
|
+
raw_content = '\n'.join(block.content_lines)
|
|
746
|
+
|
|
747
|
+
# Find all table sections (each preceded by **Source:** marker)
|
|
748
|
+
table_pattern = _re.compile(
|
|
749
|
+
r'\*\*Source:\*\*\s*`([$\w]+):(\w+)`\s*\n(.*?)(?=\*\*Source:\*\*|<!-- /VIEW|\Z)',
|
|
750
|
+
_re.DOTALL
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
for m in table_pattern.finditer(raw_content):
|
|
754
|
+
sigil = m.group(1)
|
|
755
|
+
name = m.group(2)
|
|
756
|
+
table_content = m.group(3)
|
|
757
|
+
|
|
758
|
+
# Parse the Campo|Valor table in this section
|
|
759
|
+
attrs: Dict[str, str] = {}
|
|
760
|
+
for line in table_content.split('\n'):
|
|
761
|
+
stripped = line.strip()
|
|
762
|
+
if not stripped.startswith('|') or '---' in stripped:
|
|
763
|
+
continue
|
|
764
|
+
# v2.4.0: Use proper cell splitting that handles \| escapes
|
|
765
|
+
cells = _split_table_cells(stripped)
|
|
766
|
+
if len(cells) >= 2:
|
|
767
|
+
campo = _normalize_field_name(cells[0])
|
|
768
|
+
valor = _sanitize_value(cells[1])
|
|
769
|
+
if campo and campo != "campo" and campo != "field":
|
|
770
|
+
attrs[campo] = valor
|
|
771
|
+
|
|
772
|
+
# v2.4.0: $0 entries should be meta type
|
|
773
|
+
entry_type = "meta" if sigil == "$0" else "attrs"
|
|
774
|
+
entry = V2Entry(
|
|
775
|
+
sigil=sigil,
|
|
776
|
+
name=name,
|
|
777
|
+
entry_type=entry_type,
|
|
778
|
+
value=attrs,
|
|
779
|
+
section=section_id,
|
|
780
|
+
)
|
|
781
|
+
entries.append(entry)
|
|
782
|
+
|
|
783
|
+
return entries
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ---------------------------------------------------------------------------
|
|
787
|
+
# LIST → entries
|
|
788
|
+
# ---------------------------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
def _list_to_entries(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
791
|
+
items, list_diags = parse_list_block(block)
|
|
792
|
+
diags.extend(list_diags)
|
|
793
|
+
|
|
794
|
+
default_sigil, _ = _derive_sigil_name_from_target(block.target)
|
|
795
|
+
section_id = _resolve_section(block)
|
|
796
|
+
|
|
797
|
+
entries: List[V2Entry] = []
|
|
798
|
+
for item in items:
|
|
799
|
+
# Parse: "- `SIGIL:name` — description (survive:min)"
|
|
800
|
+
m = re.match(r'^`?([$\w!]+)?:?(\w+)`?\s*[—-]\s*(.*)$', item)
|
|
801
|
+
if m:
|
|
802
|
+
sigil = _strip_backticks(m.group(1) or default_sigil).upper()
|
|
803
|
+
if sigil == "IDN" and default_sigil != "IDN":
|
|
804
|
+
sigil = default_sigil
|
|
805
|
+
name = _sanitize_value(m.group(2))
|
|
806
|
+
desc = _sanitize_value(m.group(3))
|
|
807
|
+
|
|
808
|
+
# Extract survive from "(survive:min)" suffix
|
|
809
|
+
survive = ""
|
|
810
|
+
surv_m = re.search(r'\(survive:(\w+)\)', desc)
|
|
811
|
+
if surv_m:
|
|
812
|
+
survive = surv_m.group(1)
|
|
813
|
+
desc = re.sub(r'\s*\(survive:\w+\)', '', desc).strip()
|
|
814
|
+
|
|
815
|
+
attrs: Dict[str, str] = {}
|
|
816
|
+
if desc:
|
|
817
|
+
attrs["rule"] = desc
|
|
818
|
+
if survive:
|
|
819
|
+
attrs["survive"] = survive
|
|
820
|
+
|
|
821
|
+
entry = V2Entry(
|
|
822
|
+
sigil=sigil,
|
|
823
|
+
name=name,
|
|
824
|
+
entry_type="attrs",
|
|
825
|
+
value=attrs,
|
|
826
|
+
section=section_id,
|
|
827
|
+
)
|
|
828
|
+
else:
|
|
829
|
+
entry = V2Entry(
|
|
830
|
+
sigil=default_sigil if default_sigil != "IDN" else "!",
|
|
831
|
+
name=_synthetic_name(default_sigil, section_id, len(entries)),
|
|
832
|
+
entry_type="attrs",
|
|
833
|
+
value={"rule": _sanitize_value(item)},
|
|
834
|
+
section=section_id,
|
|
835
|
+
)
|
|
836
|
+
entries.append(entry)
|
|
837
|
+
|
|
838
|
+
return entries
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _numbered_list_to_entries(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
842
|
+
"""Convert a numbered list to ordered entries.
|
|
843
|
+
|
|
844
|
+
v2.4.0: Parse `SIGIL:name` source markers from each item.
|
|
845
|
+
"""
|
|
846
|
+
|
|
847
|
+
items, list_diags = parse_list_block(block)
|
|
848
|
+
diags.extend(list_diags)
|
|
849
|
+
|
|
850
|
+
default_sigil, _ = _derive_sigil_name_from_target(block.target)
|
|
851
|
+
if default_sigil == "IDN":
|
|
852
|
+
default_sigil = "!"
|
|
853
|
+
section_id = _resolve_section(block)
|
|
854
|
+
|
|
855
|
+
entries: List[V2Entry] = []
|
|
856
|
+
for i, item in enumerate(items):
|
|
857
|
+
# v2.4.0: Don't sanitize before regex — backticks needed for source match
|
|
858
|
+
# Parse `SIGIL:name` — description
|
|
859
|
+
source_m = re.match(r'^`([$\w!]+):(\w+)`\s*[—-]\s*(.*)$', item)
|
|
860
|
+
if source_m:
|
|
861
|
+
sigil = source_m.group(1)
|
|
862
|
+
name = source_m.group(2)
|
|
863
|
+
desc = _sanitize_value(source_m.group(3))
|
|
864
|
+
else:
|
|
865
|
+
sigil = default_sigil
|
|
866
|
+
name = _synthetic_name(default_sigil, section_id, i + 1)
|
|
867
|
+
desc = _sanitize_value(item)
|
|
868
|
+
|
|
869
|
+
# Extract survive from "(survive:X)" suffix
|
|
870
|
+
survive = ""
|
|
871
|
+
surv_m = re.search(r'\(survive:(\w+)\)$', desc)
|
|
872
|
+
if surv_m:
|
|
873
|
+
survive = surv_m.group(1)
|
|
874
|
+
desc = re.sub(r'\s*\(survive:\w+\)$', '', desc).strip()
|
|
875
|
+
|
|
876
|
+
attrs: Dict[str, str] = {"rule": desc}
|
|
877
|
+
if survive:
|
|
878
|
+
attrs["survive"] = survive
|
|
879
|
+
|
|
880
|
+
entry = V2Entry(
|
|
881
|
+
sigil=sigil,
|
|
882
|
+
name=name,
|
|
883
|
+
entry_type="attrs",
|
|
884
|
+
value=attrs,
|
|
885
|
+
section=section_id,
|
|
886
|
+
)
|
|
887
|
+
entries.append(entry)
|
|
888
|
+
|
|
889
|
+
return entries
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
# PROSE/QUOTE → cuerpo
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
def _prose_to_cuerpo(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
897
|
+
body, prose_diags = parse_prose_block(block)
|
|
898
|
+
diags.extend(prose_diags)
|
|
899
|
+
|
|
900
|
+
sigil, name = _derive_sigil_name_from_target(block.target)
|
|
901
|
+
section_id = _resolve_section(block)
|
|
902
|
+
|
|
903
|
+
entry = V2Entry(
|
|
904
|
+
sigil=sigil,
|
|
905
|
+
name=name,
|
|
906
|
+
entry_type="cuerpo",
|
|
907
|
+
value=body,
|
|
908
|
+
section=section_id,
|
|
909
|
+
)
|
|
910
|
+
return [entry]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
# ---------------------------------------------------------------------------
|
|
914
|
+
# v2.4.0 P0-3: PUML/CODE → bloque (verbatim preservation)
|
|
915
|
+
# ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
def _verbatim_to_bloque(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
918
|
+
"""Convert a verbatim code block to a bloque entry.
|
|
919
|
+
|
|
920
|
+
v2.4.0 P0-3: Preserve DIAG/PUML content verbatim with hash validation.
|
|
921
|
+
v2.4.0: Handle multiple fenced blocks within one VIEW block.
|
|
922
|
+
"""
|
|
923
|
+
|
|
924
|
+
section_id = _resolve_section(block)
|
|
925
|
+
sigil, name = _derive_sigil_name_from_target(block.target)
|
|
926
|
+
if not sigil or sigil == "IDN":
|
|
927
|
+
sigil = "DIAG"
|
|
928
|
+
|
|
929
|
+
# v2.4.0: $0:DIAG:diagram canonical decl — not real PUML, it's sigil attrs
|
|
930
|
+
if block.target == "$0:DIAG:diagram":
|
|
931
|
+
content, _ = parse_verbatim_block(block)
|
|
932
|
+
entry = V2Entry(
|
|
933
|
+
sigil="DIAG",
|
|
934
|
+
name="diagram",
|
|
935
|
+
entry_type="bloque",
|
|
936
|
+
value=content,
|
|
937
|
+
section="$0",
|
|
938
|
+
)
|
|
939
|
+
return [entry]
|
|
940
|
+
|
|
941
|
+
# v2.4.0: Find ALL ```puml or ``` fences with Source markers
|
|
942
|
+
raw_content = '\n'.join(block.content_lines)
|
|
943
|
+
|
|
944
|
+
# v2.4.0: Parse Source markers + fenced blocks
|
|
945
|
+
# Pattern: **Source:** `SIGIL:name` ... ```puml ... ```
|
|
946
|
+
source_block_pattern = re.compile(
|
|
947
|
+
r'\*\*Source:\*\*\s*`([$\w!]+):(\w+)`\s*\n(.*?)(?=\*\*Source:\*\*|\Z)',
|
|
948
|
+
re.DOTALL
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
source_matches = source_block_pattern.findall(raw_content)
|
|
952
|
+
|
|
953
|
+
if source_matches:
|
|
954
|
+
entries: List[V2Entry] = []
|
|
955
|
+
for sigil, name, section_content in source_matches:
|
|
956
|
+
# Find ```puml ... ``` within section_content
|
|
957
|
+
fence_m = re.search(r'```(?:puml|plantuml)?\n(.*?)```', section_content, re.DOTALL)
|
|
958
|
+
if fence_m:
|
|
959
|
+
puml_content = fence_m.group(1)
|
|
960
|
+
else:
|
|
961
|
+
puml_content = section_content.strip()
|
|
962
|
+
|
|
963
|
+
# v2.4.0 P1-5: PUML validation
|
|
964
|
+
if block.kind == "puml":
|
|
965
|
+
if "@startuml" not in puml_content:
|
|
966
|
+
diags.append(Diagnostic(
|
|
967
|
+
"E_PUML_INVALID",
|
|
968
|
+
f"VIEW:{block.view_name} PUML block '{name}' missing @startuml",
|
|
969
|
+
"error", f"VIEW:{block.view_name}",
|
|
970
|
+
))
|
|
971
|
+
if "@enduml" not in puml_content:
|
|
972
|
+
diags.append(Diagnostic(
|
|
973
|
+
"E_PUML_INVALID",
|
|
974
|
+
f"VIEW:{block.view_name} PUML block '{name}' missing @enduml",
|
|
975
|
+
"error", f"VIEW:{block.view_name}",
|
|
976
|
+
))
|
|
977
|
+
|
|
978
|
+
# v2.4.0 P0-3: preserve:verbatim — preserve exact content
|
|
979
|
+
# But strip ONE trailing newline added by the fence format
|
|
980
|
+
if puml_content.endswith('\n'):
|
|
981
|
+
content = puml_content[:-1]
|
|
982
|
+
else:
|
|
983
|
+
content = puml_content
|
|
984
|
+
|
|
985
|
+
entry = V2Entry(
|
|
986
|
+
sigil=sigil if sigil != "$0" else "DIAG",
|
|
987
|
+
name=name,
|
|
988
|
+
entry_type="bloque",
|
|
989
|
+
value=content,
|
|
990
|
+
section=section_id,
|
|
991
|
+
)
|
|
992
|
+
entries.append(entry)
|
|
993
|
+
return entries
|
|
994
|
+
|
|
995
|
+
# Fallback: find fenced blocks without Source markers
|
|
996
|
+
fence_pattern = re.compile(r'```(?:puml|plantuml)?\n(.*?)```', re.DOTALL)
|
|
997
|
+
matches = fence_pattern.findall(raw_content)
|
|
998
|
+
|
|
999
|
+
if not matches:
|
|
1000
|
+
content, _ = parse_verbatim_block(block)
|
|
1001
|
+
entry = V2Entry(
|
|
1002
|
+
sigil=sigil,
|
|
1003
|
+
name=name if name != "entry" else "diagram_1",
|
|
1004
|
+
entry_type="bloque",
|
|
1005
|
+
value=content,
|
|
1006
|
+
section=section_id,
|
|
1007
|
+
)
|
|
1008
|
+
return [entry]
|
|
1009
|
+
|
|
1010
|
+
entries: List[V2Entry] = []
|
|
1011
|
+
for i, puml_content in enumerate(matches):
|
|
1012
|
+
title_m = re.search(r'title\s+(.+?)(?:\n|$)', puml_content)
|
|
1013
|
+
if title_m:
|
|
1014
|
+
name = _slugify(title_m.group(1))
|
|
1015
|
+
else:
|
|
1016
|
+
name = f"diagram_{i+1}"
|
|
1017
|
+
|
|
1018
|
+
if block.preserve == "verbatim":
|
|
1019
|
+
content = puml_content
|
|
1020
|
+
else:
|
|
1021
|
+
content = puml_content.strip()
|
|
1022
|
+
|
|
1023
|
+
entry = V2Entry(
|
|
1024
|
+
sigil="DIAG",
|
|
1025
|
+
name=name,
|
|
1026
|
+
entry_type="bloque",
|
|
1027
|
+
value=content,
|
|
1028
|
+
section=section_id,
|
|
1029
|
+
)
|
|
1030
|
+
entries.append(entry)
|
|
1031
|
+
|
|
1032
|
+
return entries
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
# ---------------------------------------------------------------------------
|
|
1036
|
+
# CALLOUT → RSK
|
|
1037
|
+
# ---------------------------------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
def _callout_to_risk(block: HCorTEXBlock, diags: List[Diagnostic]) -> List[V2Entry]:
|
|
1040
|
+
"""Convert a callout block to RSK entries.
|
|
1041
|
+
|
|
1042
|
+
v2.4.0: Parse ### RSK:name headers as entry boundaries.
|
|
1043
|
+
Each header starts a new RSK entry; list items below become attrs.
|
|
1044
|
+
"""
|
|
1045
|
+
|
|
1046
|
+
section_id = _resolve_section(block)
|
|
1047
|
+
entries: List[V2Entry] = []
|
|
1048
|
+
|
|
1049
|
+
current_name = None
|
|
1050
|
+
current_attrs: Dict[str, str] = {}
|
|
1051
|
+
|
|
1052
|
+
for line in block.content_lines:
|
|
1053
|
+
stripped = line.strip()
|
|
1054
|
+
if not stripped:
|
|
1055
|
+
continue
|
|
1056
|
+
|
|
1057
|
+
# Skip markdown headers (## title)
|
|
1058
|
+
if stripped.startswith('## ') and not stripped.startswith('### '):
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
# ### RSK:name — new entry boundary
|
|
1062
|
+
import re as _re
|
|
1063
|
+
header_m = _re.match(r'^###\s+RSK:(\w+)', stripped)
|
|
1064
|
+
if header_m:
|
|
1065
|
+
# Save previous entry
|
|
1066
|
+
if current_name:
|
|
1067
|
+
# Ensure required fields
|
|
1068
|
+
current_attrs.setdefault("impact", "")
|
|
1069
|
+
current_attrs.setdefault("mitigation", "")
|
|
1070
|
+
current_attrs.setdefault("status", "cur")
|
|
1071
|
+
current_attrs.setdefault("survive", "min")
|
|
1072
|
+
entry = V2Entry(
|
|
1073
|
+
sigil="RSK",
|
|
1074
|
+
name=current_name,
|
|
1075
|
+
entry_type="attrs",
|
|
1076
|
+
value=current_attrs,
|
|
1077
|
+
section=section_id,
|
|
1078
|
+
)
|
|
1079
|
+
entries.append(entry)
|
|
1080
|
+
current_name = header_m.group(1)
|
|
1081
|
+
current_attrs = {}
|
|
1082
|
+
continue
|
|
1083
|
+
|
|
1084
|
+
# List item: "- key: value"
|
|
1085
|
+
item_m = _re.match(r'^-\s+(\w+):\s*(.*)$', stripped)
|
|
1086
|
+
if item_m and current_name:
|
|
1087
|
+
key = item_m.group(1).lower()
|
|
1088
|
+
value = _sanitize_value(item_m.group(2))
|
|
1089
|
+
current_attrs[key] = value
|
|
1090
|
+
|
|
1091
|
+
# Save last entry
|
|
1092
|
+
if current_name:
|
|
1093
|
+
current_attrs.setdefault("impact", "")
|
|
1094
|
+
current_attrs.setdefault("mitigation", "")
|
|
1095
|
+
current_attrs.setdefault("status", "cur")
|
|
1096
|
+
current_attrs.setdefault("survive", "min")
|
|
1097
|
+
entry = V2Entry(
|
|
1098
|
+
sigil="RSK",
|
|
1099
|
+
name=current_name,
|
|
1100
|
+
entry_type="attrs",
|
|
1101
|
+
value=current_attrs,
|
|
1102
|
+
section=section_id,
|
|
1103
|
+
)
|
|
1104
|
+
entries.append(entry)
|
|
1105
|
+
|
|
1106
|
+
return entries
|