codec-cortex 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. codec_cortex-0.3.2.dist-info/METADATA +183 -0
  2. codec_cortex-0.3.2.dist-info/RECORD +81 -0
  3. codec_cortex-0.3.2.dist-info/WHEEL +5 -0
  4. codec_cortex-0.3.2.dist-info/entry_points.txt +2 -0
  5. codec_cortex-0.3.2.dist-info/licenses/LICENSE +21 -0
  6. codec_cortex-0.3.2.dist-info/scm_file_list.json +124 -0
  7. codec_cortex-0.3.2.dist-info/scm_version.json +8 -0
  8. codec_cortex-0.3.2.dist-info/top_level.txt +1 -0
  9. cortex/__init__.py +22 -0
  10. cortex/__main__.py +7 -0
  11. cortex/_version.py +24 -0
  12. cortex/cli/__init__.py +5 -0
  13. cortex/cli/commands/__init__.py +150 -0
  14. cortex/cli/commands/add.py +89 -0
  15. cortex/cli/commands/compile.py +31 -0
  16. cortex/cli/commands/delete.py +44 -0
  17. cortex/cli/commands/diagram.py +130 -0
  18. cortex/cli/commands/diff.py +135 -0
  19. cortex/cli/commands/doctor.py +74 -0
  20. cortex/cli/commands/format.py +29 -0
  21. cortex/cli/commands/get.py +45 -0
  22. cortex/cli/commands/glossary.py +98 -0
  23. cortex/cli/commands/list.py +44 -0
  24. cortex/cli/commands/micro.py +79 -0
  25. cortex/cli/commands/move.py +45 -0
  26. cortex/cli/commands/new.py +80 -0
  27. cortex/cli/commands/recover.py +90 -0
  28. cortex/cli/commands/render.py +93 -0
  29. cortex/cli/commands/update.py +81 -0
  30. cortex/cli/commands/v2_canonicalize.py +162 -0
  31. cortex/cli/commands/v2_compare.py +74 -0
  32. cortex/cli/commands/v2_convert.py +183 -0
  33. cortex/cli/commands/v2_explain_loss.py +97 -0
  34. cortex/cli/commands/v2_inspect.py +113 -0
  35. cortex/cli/commands/v2_roundtrip.py +104 -0
  36. cortex/cli/commands/v2_roundtrip_bidir.py +128 -0
  37. cortex/cli/commands/v2_verify_view.py +65 -0
  38. cortex/cli/commands/verify.py +119 -0
  39. cortex/cli/main.py +636 -0
  40. cortex/core/__init__.py +85 -0
  41. cortex/core/ast.py +313 -0
  42. cortex/core/compare.py +180 -0
  43. cortex/core/document_kind.py +525 -0
  44. cortex/core/errors.py +399 -0
  45. cortex/core/lexer.py +267 -0
  46. cortex/core/parser.py +671 -0
  47. cortex/core/validator.py +268 -0
  48. cortex/core/writer.py +249 -0
  49. cortex/crud/__init__.py +17 -0
  50. cortex/crud/mutations.py +342 -0
  51. cortex/crud/selectors.py +95 -0
  52. cortex/crud/transactions.py +213 -0
  53. cortex/glossary/__init__.py +21 -0
  54. cortex/glossary/contracts.py +37 -0
  55. cortex/glossary/minimal.py +94 -0
  56. cortex/glossary/model.py +77 -0
  57. cortex/glossary/resolver.py +96 -0
  58. cortex/hcortex/__init__.py +35 -0
  59. cortex/hcortex/edit_parser.py +489 -0
  60. cortex/hcortex/edit_renderer.py +158 -0
  61. cortex/hcortex/markdown_model.py +81 -0
  62. cortex/hcortex/profiles.py +166 -0
  63. cortex/hcortex/read_renderer.py +342 -0
  64. cortex/hcortex/recovery.py +782 -0
  65. cortex/py.typed +0 -0
  66. cortex/templates/__init__.py +8 -0
  67. cortex/templates/brain.py +118 -0
  68. cortex/templates/minimal_glossary.py +42 -0
  69. cortex/templates/package.py +91 -0
  70. cortex/templates/skill.py +91 -0
  71. cortex/v2/__init__.py +30 -0
  72. cortex/v2/diagnostics.py +57 -0
  73. cortex/v2/encoder.py +1106 -0
  74. cortex/v2/equivalence.py +323 -0
  75. cortex/v2/hcortex_parser.py +615 -0
  76. cortex/v2/hcortex_renderer.py +450 -0
  77. cortex/v2/ir.py +223 -0
  78. cortex/v2/parser.py +655 -0
  79. cortex/v2/view.py +425 -0
  80. cortex/v2/view_renderer.py +474 -0
  81. cortex/v2/writer.py +301 -0
@@ -0,0 +1,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