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,615 @@
|
|
|
1
|
+
"""HCORTEX parser v2.3.0 — read canonical HCORTEX and reconstruct AST.
|
|
2
|
+
|
|
3
|
+
Implements F-01..F-08 from v2.3.0 spec:
|
|
4
|
+
F-01: parse_hcortex() entry point
|
|
5
|
+
F-02: header reading (internal_encoding, source_artifact, view_schema, etc.)
|
|
6
|
+
F-03: VIEW marker reading (kind, target, reverse, fields, etc.)
|
|
7
|
+
F-04: table parsing (reverse=rows_to_entries)
|
|
8
|
+
F-05: list parsing (numbered/bullet → entries or cuerpo)
|
|
9
|
+
F-06: verbatim blocks (DIAG, code, PUML with preserve:verbatim)
|
|
10
|
+
F-07: HUMAN_BLOCK parsing (preserve_human_block)
|
|
11
|
+
F-08: section normalizer (titles → $N sections per VIEW)
|
|
12
|
+
|
|
13
|
+
Architecture:
|
|
14
|
+
HCORTEX markdown → parse_hcortex() → HCortexDocument → encode_cortex_from_ast() → CORTEX
|
|
15
|
+
|
|
16
|
+
The parser is strict by default: any block without VIEW contract is
|
|
17
|
+
flagged E_VIEW_MISSING (in strict mode) or W_HCORTEX_DISPLAY_ONLY (normal).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from .diagnostics import Diagnostic
|
|
27
|
+
from .view import ViewKind, ReverseStrategy, KIND_REVERSE_COMPAT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Data model
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class HCorTEXHeader:
|
|
36
|
+
"""Parsed HCORTEX header."""
|
|
37
|
+
internal_encoding: str = ""
|
|
38
|
+
source_artifact: str = ""
|
|
39
|
+
source_version: str = ""
|
|
40
|
+
status: str = ""
|
|
41
|
+
derived_from: str = ""
|
|
42
|
+
reversible: bool = False
|
|
43
|
+
view_schema: int = 0
|
|
44
|
+
view_coverage: int = 0
|
|
45
|
+
mode: str = "normal" # normal | display | strict | audit | recovery
|
|
46
|
+
raw: Dict[str, str] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class HCorTEXBlock:
|
|
51
|
+
"""A block in HCORTEX — typically one VIEW directive's worth of content."""
|
|
52
|
+
view_name: str
|
|
53
|
+
kind: str # ViewKind value
|
|
54
|
+
target: str
|
|
55
|
+
reverse: str # ReverseStrategy value
|
|
56
|
+
fields: Optional[List[str]] = None
|
|
57
|
+
order: Optional[str] = None
|
|
58
|
+
title: Optional[str] = None
|
|
59
|
+
status: str = "cur"
|
|
60
|
+
scope: Optional[str] = None
|
|
61
|
+
section: Optional[str] = None
|
|
62
|
+
source_section: Optional[str] = None
|
|
63
|
+
preserve: Optional[str] = None
|
|
64
|
+
hash: Optional[str] = None
|
|
65
|
+
fallback: Optional[str] = None
|
|
66
|
+
content_lines: List[str] = field(default_factory=list) # raw markdown lines between markers
|
|
67
|
+
raw_marker: str = "" # full opening marker text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class HCorTEXDocument:
|
|
72
|
+
"""Parsed HCORTEX document."""
|
|
73
|
+
header: HCorTEXHeader
|
|
74
|
+
blocks: List[HCorTEXBlock] = field(default_factory=list)
|
|
75
|
+
orphan_content: List[str] = field(default_factory=list) # content outside any VIEW
|
|
76
|
+
diags: List[Diagnostic] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"header": self.header.__dict__,
|
|
81
|
+
"blocks": [b.__dict__ for b in self.blocks],
|
|
82
|
+
"orphan_content": self.orphan_content,
|
|
83
|
+
"diags": [d.to_dict() for d in self.diags],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Parser
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
_HEADER_RE = re.compile(r'<!-- CODEC-CORTEX\n(.*?)\n-->', re.DOTALL)
|
|
92
|
+
_VIEW_OPEN_RE = re.compile(
|
|
93
|
+
r'<!-- (VIEW:[\w_-]+)\s+(.*?)\s*-->'
|
|
94
|
+
)
|
|
95
|
+
_VIEW_CLOSE_RE = re.compile(r'<!-- /VIEW:([\w_-]+)\s*-->')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_hcortex(text: str, strict: bool = False) -> HCorTEXDocument:
|
|
99
|
+
"""F-01: Parse canonical HCORTEX into HCorTEXDocument.
|
|
100
|
+
|
|
101
|
+
If strict=True, E_VIEW_MISSING and E_HUMAN_BLOCK_UNDECLARED become errors
|
|
102
|
+
instead of warnings.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
diags: List[Diagnostic] = []
|
|
106
|
+
|
|
107
|
+
# F-02: header
|
|
108
|
+
header, header_diags = _parse_header(text)
|
|
109
|
+
diags.extend(header_diags)
|
|
110
|
+
|
|
111
|
+
# Strip header from body
|
|
112
|
+
body = _HEADER_RE.sub('', text, count=1).lstrip('\n')
|
|
113
|
+
|
|
114
|
+
# F-03/F-08: walk the body, extracting VIEW blocks
|
|
115
|
+
blocks, orphans, block_diags = _parse_blocks(body, strict=strict)
|
|
116
|
+
diags.extend(block_diags)
|
|
117
|
+
|
|
118
|
+
# Validate header consistency
|
|
119
|
+
if header.view_coverage == 100 and not header.reversible:
|
|
120
|
+
diags.append(Diagnostic(
|
|
121
|
+
"E_HCORTEX_NOT_REVERSIBLE",
|
|
122
|
+
"view_coverage=100 but reversible=false — inconsistent header",
|
|
123
|
+
"error",
|
|
124
|
+
))
|
|
125
|
+
|
|
126
|
+
if header.reversible and header.view_coverage < 100:
|
|
127
|
+
diags.append(Diagnostic(
|
|
128
|
+
"E_HCORTEX_NOT_REVERSIBLE",
|
|
129
|
+
f"reversible=true but view_coverage={header.view_coverage} (<100)",
|
|
130
|
+
"error",
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
if orphans and strict:
|
|
134
|
+
diags.append(Diagnostic(
|
|
135
|
+
"E_VIEW_MISSING",
|
|
136
|
+
f"{len(orphans)} content lines outside any VIEW block — strict mode requires all content under VIEW",
|
|
137
|
+
"error",
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
# v2.3.0 T-08: Validate table schemas for blocks with declared fields
|
|
141
|
+
for block in blocks:
|
|
142
|
+
if block.kind == "table" and block.fields:
|
|
143
|
+
_, table_diags = parse_table_block(block)
|
|
144
|
+
diags.extend(table_diags)
|
|
145
|
+
|
|
146
|
+
return HCorTEXDocument(
|
|
147
|
+
header=header,
|
|
148
|
+
blocks=blocks,
|
|
149
|
+
orphan_content=orphans,
|
|
150
|
+
diags=diags,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# F-02: Header parsing
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _parse_header(text: str) -> Tuple[HCorTEXHeader, List[Diagnostic]]:
|
|
159
|
+
"""Parse the <!-- CODEC-CORTEX ... --> header."""
|
|
160
|
+
|
|
161
|
+
diags: List[Diagnostic] = []
|
|
162
|
+
header = HCorTEXHeader()
|
|
163
|
+
|
|
164
|
+
m = _HEADER_RE.search(text)
|
|
165
|
+
if not m:
|
|
166
|
+
diags.append(Diagnostic(
|
|
167
|
+
"E_HCORTEX_HEADER_INVALID",
|
|
168
|
+
"Missing <!-- CODEC-CORTEX ... --> header",
|
|
169
|
+
"error",
|
|
170
|
+
))
|
|
171
|
+
return header, diags
|
|
172
|
+
|
|
173
|
+
body = m.group(1)
|
|
174
|
+
for line in body.split('\n'):
|
|
175
|
+
line = line.strip()
|
|
176
|
+
if not line or line.startswith('<!--') or line.startswith('-->'):
|
|
177
|
+
continue
|
|
178
|
+
if ':' not in line:
|
|
179
|
+
continue
|
|
180
|
+
key, _, value = line.partition(':')
|
|
181
|
+
key = key.strip()
|
|
182
|
+
value = value.strip()
|
|
183
|
+
header.raw[key] = value
|
|
184
|
+
|
|
185
|
+
if key == "internal_encoding":
|
|
186
|
+
header.internal_encoding = value
|
|
187
|
+
elif key == "source_artifact":
|
|
188
|
+
header.source_artifact = value
|
|
189
|
+
elif key == "source_version":
|
|
190
|
+
header.source_version = value
|
|
191
|
+
elif key == "status":
|
|
192
|
+
header.status = value
|
|
193
|
+
elif key == "derived_from":
|
|
194
|
+
header.derived_from = value
|
|
195
|
+
elif key == "reversible":
|
|
196
|
+
header.reversible = (value.lower() == "true")
|
|
197
|
+
elif key == "view_schema":
|
|
198
|
+
try:
|
|
199
|
+
header.view_schema = int(value)
|
|
200
|
+
except ValueError:
|
|
201
|
+
diags.append(Diagnostic(
|
|
202
|
+
"E_HCORTEX_HEADER_INVALID",
|
|
203
|
+
f"view_schema not an integer: {value!r}",
|
|
204
|
+
"error",
|
|
205
|
+
))
|
|
206
|
+
elif key == "view_coverage":
|
|
207
|
+
try:
|
|
208
|
+
header.view_coverage = int(value)
|
|
209
|
+
except ValueError:
|
|
210
|
+
diags.append(Diagnostic(
|
|
211
|
+
"E_HCORTEX_HEADER_INVALID",
|
|
212
|
+
f"view_coverage not an integer: {value!r}",
|
|
213
|
+
"error",
|
|
214
|
+
))
|
|
215
|
+
elif key == "mode":
|
|
216
|
+
header.mode = value
|
|
217
|
+
|
|
218
|
+
# Validate required fields
|
|
219
|
+
if header.internal_encoding != "HCORTEX":
|
|
220
|
+
diags.append(Diagnostic(
|
|
221
|
+
"E_HCORTEX_HEADER_INVALID",
|
|
222
|
+
f"internal_encoding must be 'HCORTEX', got {header.internal_encoding!r}",
|
|
223
|
+
"error",
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
return header, diags
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# F-03/F-04/F-05/F-06/F-07/F-08: Block parsing
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _parse_blocks(body: str, strict: bool = False) -> Tuple[List[HCorTEXBlock], List[str], List[Diagnostic]]:
|
|
234
|
+
"""Walk the body, extract VIEW blocks, collect orphans."""
|
|
235
|
+
|
|
236
|
+
diags: List[Diagnostic] = []
|
|
237
|
+
blocks: List[HCorTEXBlock] = []
|
|
238
|
+
orphans: List[str] = []
|
|
239
|
+
|
|
240
|
+
lines = body.split('\n')
|
|
241
|
+
i = 0
|
|
242
|
+
in_block = False
|
|
243
|
+
current_block: Optional[HCorTEXBlock] = None
|
|
244
|
+
orphan_buffer: List[str] = []
|
|
245
|
+
|
|
246
|
+
while i < len(lines):
|
|
247
|
+
line = lines[i]
|
|
248
|
+
|
|
249
|
+
# Opening VIEW marker
|
|
250
|
+
open_m = _VIEW_OPEN_RE.match(line)
|
|
251
|
+
if open_m and not in_block:
|
|
252
|
+
# Flush orphan buffer
|
|
253
|
+
if orphan_buffer:
|
|
254
|
+
orphans.extend(orphan_buffer)
|
|
255
|
+
orphan_buffer = []
|
|
256
|
+
|
|
257
|
+
open_m.group(0)
|
|
258
|
+
view_tag = open_m.group(1) # "VIEW:name"
|
|
259
|
+
attrs_str = open_m.group(2)
|
|
260
|
+
view_name = view_tag.split(':', 1)[1]
|
|
261
|
+
|
|
262
|
+
block, block_diag = _parse_view_marker(view_name, attrs_str)
|
|
263
|
+
diags.extend(block_diag)
|
|
264
|
+
current_block = block
|
|
265
|
+
in_block = True
|
|
266
|
+
i += 1
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
# Closing VIEW marker
|
|
270
|
+
close_m = _VIEW_CLOSE_RE.match(line)
|
|
271
|
+
if close_m and in_block:
|
|
272
|
+
view_name_close = close_m.group(1)
|
|
273
|
+
if current_block and current_block.view_name == view_name_close:
|
|
274
|
+
blocks.append(current_block)
|
|
275
|
+
else:
|
|
276
|
+
diags.append(Diagnostic(
|
|
277
|
+
"E_VIEW_MISSING",
|
|
278
|
+
f"Closing marker /VIEW:{view_name_close} does not match opening VIEW:{current_block.view_name if current_block else 'None'}",
|
|
279
|
+
"error",
|
|
280
|
+
))
|
|
281
|
+
current_block = None
|
|
282
|
+
in_block = False
|
|
283
|
+
i += 1
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if in_block and current_block is not None:
|
|
287
|
+
current_block.content_lines.append(line)
|
|
288
|
+
else:
|
|
289
|
+
# Orphan content (outside VIEW)
|
|
290
|
+
stripped = line.strip()
|
|
291
|
+
if stripped and not stripped.startswith('**') and stripped not in ('---',):
|
|
292
|
+
orphan_buffer.append(line)
|
|
293
|
+
elif stripped == '---':
|
|
294
|
+
# Section separator — flush orphans
|
|
295
|
+
if orphan_buffer:
|
|
296
|
+
orphans.extend(orphan_buffer)
|
|
297
|
+
orphan_buffer = []
|
|
298
|
+
|
|
299
|
+
i += 1
|
|
300
|
+
|
|
301
|
+
# Flush any remaining orphan buffer
|
|
302
|
+
if orphan_buffer:
|
|
303
|
+
orphans.extend(orphan_buffer)
|
|
304
|
+
|
|
305
|
+
# Filter out trivial orphans (blank lines, "Perfil: ...")
|
|
306
|
+
orphans = [o for o in orphans if o.strip() and not o.strip().startswith('**Perfil')]
|
|
307
|
+
|
|
308
|
+
return blocks, orphans, diags
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _parse_view_marker(view_name: str, attrs_str: str) -> Tuple[HCorTEXBlock, List[Diagnostic]]:
|
|
312
|
+
"""Parse the attributes inside <!-- VIEW:name kind=... target=... -->."""
|
|
313
|
+
|
|
314
|
+
diags: List[Diagnostic] = []
|
|
315
|
+
block = HCorTEXBlock(
|
|
316
|
+
view_name=view_name,
|
|
317
|
+
kind="",
|
|
318
|
+
target="",
|
|
319
|
+
reverse="",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Parse key=value pairs, with quoted values
|
|
323
|
+
# Pattern: key="value with spaces" OR key=bare_value
|
|
324
|
+
pattern = re.compile(r'(\w+)=(?:"([^"]*)"|(\S+))')
|
|
325
|
+
for m in pattern.finditer(attrs_str):
|
|
326
|
+
key = m.group(1)
|
|
327
|
+
value = m.group(2) if m.group(2) is not None else m.group(3)
|
|
328
|
+
|
|
329
|
+
if key == "kind":
|
|
330
|
+
block.kind = value
|
|
331
|
+
# Validate kind
|
|
332
|
+
if value not in {k.value for k in ViewKind}:
|
|
333
|
+
diags.append(Diagnostic(
|
|
334
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
335
|
+
f"VIEW:{view_name} unknown kind {value!r}",
|
|
336
|
+
"error",
|
|
337
|
+
f"VIEW:{view_name}",
|
|
338
|
+
))
|
|
339
|
+
elif key == "target":
|
|
340
|
+
block.target = value
|
|
341
|
+
elif key == "reverse":
|
|
342
|
+
block.reverse = value
|
|
343
|
+
if value not in {r.value for r in ReverseStrategy}:
|
|
344
|
+
diags.append(Diagnostic(
|
|
345
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
346
|
+
f"VIEW:{view_name} unknown reverse {value!r}",
|
|
347
|
+
"error",
|
|
348
|
+
f"VIEW:{view_name}",
|
|
349
|
+
))
|
|
350
|
+
elif key == "fields":
|
|
351
|
+
block.fields = [f.strip() for f in value.split(',')]
|
|
352
|
+
elif key == "order":
|
|
353
|
+
block.order = value
|
|
354
|
+
elif key == "title":
|
|
355
|
+
block.title = value
|
|
356
|
+
elif key == "status":
|
|
357
|
+
block.status = value
|
|
358
|
+
elif key == "scope":
|
|
359
|
+
block.scope = value
|
|
360
|
+
elif key == "section":
|
|
361
|
+
block.section = value
|
|
362
|
+
elif key == "source_section":
|
|
363
|
+
block.source_section = value
|
|
364
|
+
elif key == "preserve":
|
|
365
|
+
block.preserve = value
|
|
366
|
+
elif key == "hash":
|
|
367
|
+
block.hash = value
|
|
368
|
+
elif key == "fallback":
|
|
369
|
+
block.fallback = value
|
|
370
|
+
|
|
371
|
+
# Validate required fields
|
|
372
|
+
if not block.kind:
|
|
373
|
+
diags.append(Diagnostic(
|
|
374
|
+
"E_VIEW_MISSING",
|
|
375
|
+
f"VIEW:{view_name} missing kind",
|
|
376
|
+
"error",
|
|
377
|
+
f"VIEW:{view_name}",
|
|
378
|
+
))
|
|
379
|
+
if not block.target:
|
|
380
|
+
diags.append(Diagnostic(
|
|
381
|
+
"E_VIEW_TARGET_UNRESOLVED",
|
|
382
|
+
f"VIEW:{view_name} missing target",
|
|
383
|
+
"error",
|
|
384
|
+
f"VIEW:{view_name}",
|
|
385
|
+
))
|
|
386
|
+
if not block.reverse:
|
|
387
|
+
diags.append(Diagnostic(
|
|
388
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
389
|
+
f"VIEW:{view_name} missing reverse",
|
|
390
|
+
"error",
|
|
391
|
+
f"VIEW:{view_name}",
|
|
392
|
+
))
|
|
393
|
+
|
|
394
|
+
# Validate kind/reverse compatibility
|
|
395
|
+
if block.kind and block.reverse:
|
|
396
|
+
try:
|
|
397
|
+
kind_enum = ViewKind(block.kind)
|
|
398
|
+
reverse_enum = ReverseStrategy(block.reverse)
|
|
399
|
+
if reverse_enum not in KIND_REVERSE_COMPAT.get(kind_enum, set()):
|
|
400
|
+
diags.append(Diagnostic(
|
|
401
|
+
"E_VIEW_REVERSE_UNSUPPORTED",
|
|
402
|
+
f"VIEW:{view_name} kind={block.kind!r} incompatible with reverse={block.reverse!r}",
|
|
403
|
+
"error",
|
|
404
|
+
f"VIEW:{view_name}",
|
|
405
|
+
))
|
|
406
|
+
except ValueError:
|
|
407
|
+
pass # already reported above
|
|
408
|
+
|
|
409
|
+
return block, diags
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Block content parsers (used by encoder)
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def parse_table_block(block: HCorTEXBlock) -> Tuple[List[Dict[str, str]], List[Diagnostic]]:
|
|
417
|
+
"""F-04: Parse a Markdown table into rows."""
|
|
418
|
+
|
|
419
|
+
diags: List[Diagnostic] = []
|
|
420
|
+
rows: List[Dict[str, str]] = []
|
|
421
|
+
|
|
422
|
+
lines = block.content_lines
|
|
423
|
+
if not lines:
|
|
424
|
+
return rows, diags
|
|
425
|
+
|
|
426
|
+
# Find the header row and separator
|
|
427
|
+
header_idx = None
|
|
428
|
+
sep_idx = None
|
|
429
|
+
for i, line in enumerate(lines):
|
|
430
|
+
stripped = line.strip()
|
|
431
|
+
if stripped.startswith('|') and stripped.endswith('|'):
|
|
432
|
+
if header_idx is None:
|
|
433
|
+
header_idx = i
|
|
434
|
+
elif sep_idx is None and '---' in stripped:
|
|
435
|
+
sep_idx = i
|
|
436
|
+
break
|
|
437
|
+
|
|
438
|
+
if header_idx is None or sep_idx is None:
|
|
439
|
+
diags.append(Diagnostic(
|
|
440
|
+
"E_TABLE_SCHEMA_MISMATCH",
|
|
441
|
+
f"VIEW:{block.view_name} table has no header/separator",
|
|
442
|
+
"error",
|
|
443
|
+
f"VIEW:{block.view_name}",
|
|
444
|
+
))
|
|
445
|
+
return rows, diags
|
|
446
|
+
|
|
447
|
+
# Parse header
|
|
448
|
+
header_cells = _split_table_row(lines[header_idx])
|
|
449
|
+
if not header_cells:
|
|
450
|
+
diags.append(Diagnostic(
|
|
451
|
+
"E_TABLE_SCHEMA_MISMATCH",
|
|
452
|
+
f"VIEW:{block.view_name} empty header row",
|
|
453
|
+
"error",
|
|
454
|
+
f"VIEW:{block.view_name}",
|
|
455
|
+
))
|
|
456
|
+
return rows, diags
|
|
457
|
+
|
|
458
|
+
# Validate against declared fields
|
|
459
|
+
# v2.4.0: Allow Source as an implicit column and extra attrs columns
|
|
460
|
+
# (renderer includes ALL attrs, not just declared fields)
|
|
461
|
+
if block.fields:
|
|
462
|
+
effective_header = [h for h in header_cells if h.lower().strip() != "source"]
|
|
463
|
+
# v2.4.0: Only error if header has FEWER columns than declared fields
|
|
464
|
+
if len(effective_header) < len(block.fields):
|
|
465
|
+
diags.append(Diagnostic(
|
|
466
|
+
"E_TABLE_SCHEMA_MISMATCH",
|
|
467
|
+
f"VIEW:{block.view_name} header has {len(effective_header)} data cols (excl. Source) but fields declares {len(block.fields)}: "
|
|
468
|
+
f"header={header_cells!r}, fields={block.fields!r}",
|
|
469
|
+
"error",
|
|
470
|
+
f"VIEW:{block.view_name}",
|
|
471
|
+
))
|
|
472
|
+
|
|
473
|
+
# Parse data rows
|
|
474
|
+
for line in lines[sep_idx + 1:]:
|
|
475
|
+
stripped = line.strip()
|
|
476
|
+
if not stripped or not stripped.startswith('|'):
|
|
477
|
+
continue
|
|
478
|
+
if '---' in stripped:
|
|
479
|
+
continue
|
|
480
|
+
cells = _split_table_row(stripped)
|
|
481
|
+
if not cells:
|
|
482
|
+
continue
|
|
483
|
+
row = {}
|
|
484
|
+
for i, cell in enumerate(cells):
|
|
485
|
+
key = header_cells[i] if i < len(header_cells) else f"col_{i}"
|
|
486
|
+
row[key.lower().strip()] = cell.strip()
|
|
487
|
+
rows.append(row)
|
|
488
|
+
|
|
489
|
+
return rows, diags
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _split_table_row(line: str) -> List[str]:
|
|
493
|
+
"""Split a Markdown table row into cells, respecting \\| escapes."""
|
|
494
|
+
|
|
495
|
+
if line.startswith('|'):
|
|
496
|
+
line = line[1:]
|
|
497
|
+
if line.endswith('|'):
|
|
498
|
+
line = line[:-1]
|
|
499
|
+
# Split on | but respect \|
|
|
500
|
+
cells = []
|
|
501
|
+
current = ''
|
|
502
|
+
i = 0
|
|
503
|
+
while i < len(line):
|
|
504
|
+
c = line[i]
|
|
505
|
+
if c == '\\' and i + 1 < len(line) and line[i + 1] == '|':
|
|
506
|
+
current += '|'
|
|
507
|
+
i += 2
|
|
508
|
+
elif c == '|':
|
|
509
|
+
cells.append(current)
|
|
510
|
+
current = ''
|
|
511
|
+
i += 1
|
|
512
|
+
else:
|
|
513
|
+
current += c
|
|
514
|
+
i += 1
|
|
515
|
+
cells.append(current)
|
|
516
|
+
return cells
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def parse_list_block(block: HCorTEXBlock) -> Tuple[List[str], List[Diagnostic]]:
|
|
520
|
+
"""F-05: Parse a bullet/numbered list into items.
|
|
521
|
+
|
|
522
|
+
v2.4.0: Skip ## headers and blank lines.
|
|
523
|
+
"""
|
|
524
|
+
|
|
525
|
+
diags: List[Diagnostic] = []
|
|
526
|
+
items: List[str] = []
|
|
527
|
+
|
|
528
|
+
for line in block.content_lines:
|
|
529
|
+
stripped = line.strip()
|
|
530
|
+
if not stripped:
|
|
531
|
+
continue
|
|
532
|
+
# Skip markdown headers
|
|
533
|
+
if stripped.startswith('#'):
|
|
534
|
+
continue
|
|
535
|
+
# Bullet: - item or * item
|
|
536
|
+
if stripped.startswith('- ') or stripped.startswith('* '):
|
|
537
|
+
items.append(stripped[2:].strip())
|
|
538
|
+
# Numbered: 1. item
|
|
539
|
+
elif re.match(r'^\d+\.\s', stripped):
|
|
540
|
+
items.append(re.sub(r'^\d+\.\s+', '', stripped))
|
|
541
|
+
# Checklist: - [x] item or - [ ] item
|
|
542
|
+
elif stripped.startswith('- [x] ') or stripped.startswith('- [ ] '):
|
|
543
|
+
items.append(stripped[6:])
|
|
544
|
+
# Callout header: ### RSK:name
|
|
545
|
+
elif stripped.startswith('### '):
|
|
546
|
+
items.append(stripped[4:])
|
|
547
|
+
else:
|
|
548
|
+
# Could be a continuation or non-list line
|
|
549
|
+
items.append(stripped)
|
|
550
|
+
|
|
551
|
+
return items, diags
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def parse_verbatim_block(block: HCorTEXBlock) -> Tuple[str, List[Diagnostic]]:
|
|
555
|
+
"""F-06: Parse a verbatim code block (``` ... ```)."""
|
|
556
|
+
|
|
557
|
+
diags: List[Diagnostic] = []
|
|
558
|
+
lines = block.content_lines
|
|
559
|
+
|
|
560
|
+
# Find opening ``` and closing ```
|
|
561
|
+
open_idx = None
|
|
562
|
+
close_idx = None
|
|
563
|
+
for i, line in enumerate(lines):
|
|
564
|
+
stripped = line.strip()
|
|
565
|
+
if stripped.startswith('```'):
|
|
566
|
+
if open_idx is None:
|
|
567
|
+
open_idx = i
|
|
568
|
+
else:
|
|
569
|
+
close_idx = i
|
|
570
|
+
break
|
|
571
|
+
|
|
572
|
+
if open_idx is None:
|
|
573
|
+
return '\n'.join(lines), diags
|
|
574
|
+
|
|
575
|
+
if close_idx is None:
|
|
576
|
+
diags.append(Diagnostic(
|
|
577
|
+
"E_BLOCK_NOT_PRESERVED",
|
|
578
|
+
f"VIEW:{block.view_name} verbatim block has no closing ```",
|
|
579
|
+
"error",
|
|
580
|
+
f"VIEW:{block.view_name}",
|
|
581
|
+
))
|
|
582
|
+
return '\n'.join(lines[open_idx + 1:]), diags
|
|
583
|
+
|
|
584
|
+
content = '\n'.join(lines[open_idx + 1:close_idx])
|
|
585
|
+
|
|
586
|
+
# v2.2.3 PRE-04 / F-32: preserve:verbatim means no strip/trim/normalize
|
|
587
|
+
if block.preserve == "verbatim":
|
|
588
|
+
return content, diags
|
|
589
|
+
else:
|
|
590
|
+
return content.strip(), diags
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def parse_prose_block(block: HCorTEXBlock) -> Tuple[str, List[Diagnostic]]:
|
|
594
|
+
"""F-07: Parse a prose block (HUMAN_BLOCK with preserve_human_block).
|
|
595
|
+
|
|
596
|
+
v2.4.0: Preserve content faithfully — strip leading blank lines and
|
|
597
|
+
title header, keep internal structure.
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
diags: List[Diagnostic] = []
|
|
601
|
+
lines = []
|
|
602
|
+
skip_headers = True
|
|
603
|
+
for line in block.content_lines:
|
|
604
|
+
stripped = line.strip()
|
|
605
|
+
if skip_headers:
|
|
606
|
+
if not stripped:
|
|
607
|
+
continue
|
|
608
|
+
if stripped.startswith('#'):
|
|
609
|
+
continue
|
|
610
|
+
skip_headers = False
|
|
611
|
+
lines.append(line)
|
|
612
|
+
# Strip trailing empty lines
|
|
613
|
+
while lines and not lines[-1].strip():
|
|
614
|
+
lines.pop()
|
|
615
|
+
return '\n'.join(lines), diags
|