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,85 @@
1
+ """Core module exports."""
2
+
3
+ from .ast import (
4
+ AttrsPosContract,
5
+ CortexDocument,
6
+ Entry,
7
+ Glossary,
8
+ MicroDef,
9
+ Section,
10
+ SigilDef,
11
+ TypeDef,
12
+ compute_document_hash,
13
+ compute_entry_hash,
14
+ normalize_section_id,
15
+ )
16
+ from .compare import Diff, DiffResult, compare_ast
17
+ from .errors import (
18
+ CANONICAL_MICRO,
19
+ CANONICAL_SIGILS,
20
+ CANONICAL_TYPES,
21
+ CortexError,
22
+ Diagnostic,
23
+ DiagnosticBag,
24
+ GLOSSARY_RESERVED_SIGILS,
25
+ MissingGlossaryError,
26
+ GlossaryNotFirstError,
27
+ UnknownSigilError,
28
+ UnknownTypeError,
29
+ BraceError,
30
+ InvalidAttrsError,
31
+ AttrsPosContractMissingError,
32
+ DuplicateEntryError,
33
+ ProtectedEntryError,
34
+ HCortexReadNotCompilableError,
35
+ HCortexEditMetadataMissingError,
36
+ RoundtripFailedError,
37
+ NotFoundError,
38
+ AmbiguousSelectorError,
39
+ AtomicWriteError,
40
+ InvalidSectionHeaderError,
41
+ UnparsedLineError,
42
+ ProtectedSigilError,
43
+ SigilInUseError,
44
+ MicroInUseError,
45
+ InvalidValueError,
46
+ TemplateUnknownError,
47
+ )
48
+ from .lexer import lex
49
+ from .parser import parse_cortex, parse_attrs_body, parse_attrs_pos_body, build_entry_from_value
50
+ from .validator import validate, is_valid, is_protected_entry
51
+ from .document_kind import (
52
+ DocumentKind, infer_document_kind, validate_level_policy,
53
+ LIVE_STATE_SIGILS,
54
+ )
55
+ from .writer import write_cortex, serialize_entry, serialize_entry_value, serialize_glossary
56
+
57
+ __all__ = [
58
+ # AST
59
+ "AttrsPosContract", "CortexDocument", "Entry", "Glossary", "MicroDef",
60
+ "Section", "SigilDef", "TypeDef", "compute_document_hash",
61
+ "compute_entry_hash", "normalize_section_id",
62
+ # Compare
63
+ "Diff", "DiffResult", "compare_ast",
64
+ # Errors
65
+ "CANONICAL_MICRO", "CANONICAL_SIGILS", "CANONICAL_TYPES",
66
+ "CortexError", "Diagnostic", "DiagnosticBag",
67
+ "GLOSSARY_RESERVED_SIGILS",
68
+ "MissingGlossaryError", "GlossaryNotFirstError", "UnknownSigilError",
69
+ "UnknownTypeError", "BraceError", "InvalidAttrsError",
70
+ "AttrsPosContractMissingError", "DuplicateEntryError",
71
+ "ProtectedEntryError", "HCortexReadNotCompilableError",
72
+ "HCortexEditMetadataMissingError", "RoundtripFailedError",
73
+ "NotFoundError", "AmbiguousSelectorError", "AtomicWriteError",
74
+ "InvalidSectionHeaderError", "UnparsedLineError", "ProtectedSigilError",
75
+ "SigilInUseError", "MicroInUseError", "InvalidValueError",
76
+ "TemplateUnknownError",
77
+ # Parser / writer / validator
78
+ "lex", "parse_cortex", "parse_attrs_body", "parse_attrs_pos_body",
79
+ "build_entry_from_value",
80
+ "validate", "is_valid", "is_protected_entry",
81
+ "DocumentKind", "infer_document_kind", "validate_level_policy",
82
+ "LIVE_STATE_SIGILS",
83
+ "write_cortex", "serialize_entry", "serialize_entry_value",
84
+ "serialize_glossary",
85
+ ]
cortex/core/ast.py ADDED
@@ -0,0 +1,313 @@
1
+ """Canonical AST for CODEC-CORTEX.
2
+
3
+ The AST is the *only* internal representation of a ``.cortex`` document.
4
+ Every parser, renderer, compiler and CRUD operation goes through the AST.
5
+
6
+ The dataclasses below mirror Section 5.1 of the specification.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Glossary model
19
+ # ---------------------------------------------------------------------------
20
+
21
+ @dataclass
22
+ class SigilDef:
23
+ """A sigil declaration from ``$0``.
24
+
25
+ Fields mirror the canonical pipe-separated table:
26
+ ``SIGIL | NAME | TYPE | RISK | LAYER | DESCRIPTION``.
27
+ """
28
+
29
+ sigil: str
30
+ name: str
31
+ type: str
32
+ risk: str = "M"
33
+ layer: str = "Semantic"
34
+ description: str = ""
35
+
36
+ def to_dict(self) -> Dict[str, Any]:
37
+ return {
38
+ "sigil": self.sigil,
39
+ "name": self.name,
40
+ "type": self.type,
41
+ "risk": self.risk,
42
+ "layer": self.layer,
43
+ "description": self.description,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class TypeDef:
49
+ """An expansion-type declaration from ``$0``."""
50
+
51
+ name: str
52
+ description: str = ""
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ return {"name": self.name, "description": self.description}
56
+
57
+
58
+ @dataclass
59
+ class MicroDef:
60
+ """A micro-token declaration from ``$0``."""
61
+
62
+ token: str
63
+ value: str
64
+
65
+ def to_dict(self) -> Dict[str, Any]:
66
+ return {"token": self.token, "value": self.value}
67
+
68
+
69
+ @dataclass
70
+ class AttrsPosContract:
71
+ """Positional contract for an ``attrs-pos`` sigil.
72
+
73
+ ``fields`` is the ordered list of field names declared in ``$0``.
74
+ """
75
+
76
+ sigil: str
77
+ fields: List[str] = field(default_factory=list)
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ return {"sigil": self.sigil, "fields": list(self.fields)}
81
+
82
+
83
+ @dataclass
84
+ class Glossary:
85
+ """Local ``$0`` glossary attached to a :class:`CortexDocument`."""
86
+
87
+ sigils: Dict[str, SigilDef] = field(default_factory=dict)
88
+ types: Dict[str, TypeDef] = field(default_factory=dict)
89
+ micro: Dict[str, MicroDef] = field(default_factory=dict)
90
+ contracts: Dict[str, AttrsPosContract] = field(default_factory=dict)
91
+
92
+ def add_sigil(self, sigil: SigilDef) -> None:
93
+ self.sigils[sigil.sigil] = sigil
94
+
95
+ def add_type(self, t: TypeDef) -> None:
96
+ self.types[t.name] = t
97
+
98
+ def add_micro(self, m: MicroDef) -> None:
99
+ self.micro[m.token] = m
100
+
101
+ def add_contract(self, c: AttrsPosContract) -> None:
102
+ self.contracts[c.sigil] = c
103
+
104
+ def sigil_type(self, sigil: str) -> Optional[str]:
105
+ s = self.sigils.get(sigil)
106
+ return s.type if s else None
107
+
108
+ def contract_for(self, sigil: str) -> Optional[AttrsPosContract]:
109
+ return self.contracts.get(sigil)
110
+
111
+ def to_dict(self) -> Dict[str, Any]:
112
+ return {
113
+ "sigils": {k: v.to_dict() for k, v in self.sigils.items()},
114
+ "types": {k: v.to_dict() for k, v in self.types.items()},
115
+ "micro": {k: v.to_dict() for k, v in self.micro.items()},
116
+ "contracts": {k: v.to_dict() for k, v in self.contracts.items()},
117
+ }
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Entry / Section / Document
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @dataclass
125
+ class Entry:
126
+ """A single ``SIGIL:name{...}`` entry inside a section."""
127
+
128
+ section: str
129
+ sigil: str
130
+ name: str
131
+ type: str # attrs | attrs-pos | cuerpo | bloque | relación
132
+ value: Any # dict | str | list (depends on type)
133
+ raw: str = "" # original text (for bloque verbatim)
134
+ line_start: int = 0
135
+ line_end: int = 0
136
+ hash: str = ""
137
+ entry_id: str = ""
138
+
139
+ def __post_init__(self) -> None:
140
+ if not self.hash:
141
+ self.hash = compute_entry_hash(self)
142
+ if not self.entry_id:
143
+ self.entry_id = self.hash
144
+
145
+ def to_dict(self) -> Dict[str, Any]:
146
+ return {
147
+ "entry_id": self.entry_id,
148
+ "section": self.section,
149
+ "sigil": self.sigil,
150
+ "name": self.name,
151
+ "type": self.type,
152
+ "value": self.value,
153
+ "raw": self.raw,
154
+ "line_start": self.line_start,
155
+ "line_end": self.line_end,
156
+ "hash": self.hash,
157
+ }
158
+
159
+
160
+ @dataclass
161
+ class Section:
162
+ """A numbered ``$N`` section inside a :class:`CortexDocument`."""
163
+
164
+ id: str # e.g. "$0", "$1"
165
+ title: str = ""
166
+ entries: List[Entry] = field(default_factory=list)
167
+ line_start: int = 0
168
+ # Raw comment lines preserved verbatim — used by $0 glossary parser
169
+ # and by the writer when re-serialising a section.
170
+ comments: List[str] = field(default_factory=list)
171
+ # Raw non-comment, non-entry lines (rare; recorded for round-trip safety)
172
+ raw_lines: List[str] = field(default_factory=list)
173
+
174
+ def to_dict(self) -> Dict[str, Any]:
175
+ return {
176
+ "id": self.id,
177
+ "title": self.title,
178
+ "entries": [e.to_dict() for e in self.entries],
179
+ "line_start": self.line_start,
180
+ "comments": list(self.comments),
181
+ }
182
+
183
+
184
+ @dataclass
185
+ class CortexDocument:
186
+ """Canonical AST root for a ``.cortex`` document.
187
+
188
+ Mirrors Section 5.1 of the spec.
189
+ """
190
+
191
+ meta: Dict[str, Any] = field(default_factory=dict)
192
+ glossary: Glossary = field(default_factory=Glossary)
193
+ sections: List[Section] = field(default_factory=list)
194
+ diagnostics: List[Dict[str, Any]] = field(default_factory=list)
195
+
196
+ # --- convenience helpers -------------------------------------------------
197
+
198
+ @property
199
+ def section_ids(self) -> List[str]:
200
+ return [s.id for s in self.sections]
201
+
202
+ def get_section(self, section_id: str) -> Optional[Section]:
203
+ # Normalize "$2", "2", "$2: x" forms
204
+ norm = normalize_section_id(section_id)
205
+ for s in self.sections:
206
+ if s.id == norm:
207
+ return s
208
+ return None
209
+
210
+ def get_or_create_section(self, section_id: str, title: str = "") -> Section:
211
+ sec = self.get_section(section_id)
212
+ if sec is not None:
213
+ return sec
214
+ norm = normalize_section_id(section_id)
215
+ sec = Section(id=norm, title=title)
216
+ self.sections.append(sec)
217
+ return sec
218
+
219
+ def iter_entries(self):
220
+ for sec in self.sections:
221
+ for e in sec.entries:
222
+ yield sec, e
223
+
224
+ def find_entries(self, sigil: Optional[str] = None, name: Optional[str] = None,
225
+ section: Optional[str] = None) -> List[Entry]:
226
+ out: List[Entry] = []
227
+ for sec, e in self.iter_entries():
228
+ if section is not None and sec.id != normalize_section_id(section):
229
+ continue
230
+ if sigil is not None and e.sigil != sigil:
231
+ continue
232
+ if name is not None and e.name != name:
233
+ continue
234
+ out.append(e)
235
+ return out
236
+
237
+ def to_dict(self) -> Dict[str, Any]:
238
+ return {
239
+ "meta": dict(self.meta),
240
+ "glossary": self.glossary.to_dict(),
241
+ "sections": [s.to_dict() for s in self.sections],
242
+ "diagnostics": list(self.diagnostics),
243
+ }
244
+
245
+ def to_json(self, indent: int = 2) -> str:
246
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False, sort_keys=False)
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Hashing helpers
251
+ # ---------------------------------------------------------------------------
252
+
253
+ def compute_entry_hash(entry: Entry) -> str:
254
+ """Stable structural hash of an entry.
255
+
256
+ The hash is computed over the *canonical* representation
257
+ (sigil, name, type, value) — not the raw text — so equivalent
258
+ entries that differ only in formatting still hash identically.
259
+ ``bloque`` entries include the verbatim raw text in the hash.
260
+ """
261
+
262
+ if entry.type == "bloque":
263
+ payload = {
264
+ "sigil": entry.sigil,
265
+ "name": entry.name,
266
+ "type": entry.type,
267
+ "raw": entry.raw,
268
+ }
269
+ else:
270
+ payload = {
271
+ "sigil": entry.sigil,
272
+ "name": entry.name,
273
+ "type": entry.type,
274
+ "value": entry.value,
275
+ }
276
+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
277
+ return "sha256:" + hashlib.sha256(blob).hexdigest()
278
+
279
+
280
+ def compute_document_hash(text: str) -> str:
281
+ blob = text.encode("utf-8")
282
+ return "sha256:" + hashlib.sha256(blob).hexdigest()
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Section id normalization
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def normalize_section_id(section_id: str) -> str:
290
+ """Normalize section identifiers.
291
+
292
+ Accepts:
293
+ - ``"$2"`` → ``"$2"``
294
+ - ``"2"`` → ``"$2"``
295
+ - ``"$2: TITLE"`` → ``"$2"``
296
+ - ``"$2 · TITLE"``→ ``"$2"``
297
+ """
298
+
299
+ if section_id is None:
300
+ return "$0"
301
+ s = section_id.strip()
302
+ if not s:
303
+ return "$0"
304
+ if not s.startswith("$"):
305
+ # accept "2" or "2: title"
306
+ if s.split(":", 1)[0].strip().isdigit():
307
+ return "$" + s.split(":", 1)[0].strip()
308
+ return "$" + s
309
+ # already starts with $
310
+ head = s[1:].split(":", 1)[0].split("·", 1)[0].strip()
311
+ if head.isdigit():
312
+ return "$" + head
313
+ return s
cortex/core/compare.py ADDED
@@ -0,0 +1,180 @@
1
+ """Structural comparison of two :class:`CortexDocument` ASTs.
2
+
3
+ Used by ``cortex verify --roundtrip hcortex-edit`` to verify that
4
+ ``.cortex → HCORTEX-EDIT → .cortex`` preserves structural identity.
5
+
6
+ Comparison rules (Section 10.2 of the spec):
7
+
8
+ - Compare: section id, section order, sigil, entry name, expansion type,
9
+ parsed value, raw bloque exact, glossary definitions.
10
+ - Ignore: non-structural comments, formatting whitespace, line numbers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Dict, List
17
+
18
+ from .ast import CortexDocument, Entry, Glossary
19
+
20
+
21
+ @dataclass
22
+ class Diff:
23
+ kind: str # section_added | section_removed | section_reordered
24
+ # | entry_added | entry_removed | entry_value_changed
25
+ # | entry_type_changed | bloque_changed | glossary_changed
26
+ path: str
27
+ left: Any = None
28
+ right: Any = None
29
+ message: str = ""
30
+
31
+ def to_dict(self) -> Dict[str, Any]:
32
+ return {
33
+ "kind": self.kind,
34
+ "path": self.path,
35
+ "left": self.left,
36
+ "right": self.right,
37
+ "message": self.message,
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class DiffResult:
43
+ equal: bool
44
+ diffs: List[Diff] = field(default_factory=list)
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ return {
48
+ "equal": self.equal,
49
+ "diffs": [d.to_dict() for d in self.diffs],
50
+ }
51
+
52
+
53
+ def compare_glossary(left: Glossary, right: Glossary) -> List[Diff]:
54
+ diffs: List[Diff] = []
55
+ # sigils
56
+ left_sigils = set(left.sigils)
57
+ right_sigils = set(right.sigils)
58
+ for s in sorted(left_sigils - right_sigils):
59
+ diffs.append(Diff("glossary_changed", f"sigils.{s}",
60
+ left=left.sigils[s].to_dict(), right=None,
61
+ message=f"sigil {s} removed from glossary"))
62
+ for s in sorted(right_sigils - left_sigils):
63
+ diffs.append(Diff("glossary_changed", f"sigils.{s}",
64
+ left=None, right=right.sigils[s].to_dict(),
65
+ message=f"sigil {s} added to glossary"))
66
+ for s in sorted(left_sigils & right_sigils):
67
+ ld = left.sigils[s].to_dict()
68
+ rd = right.sigils[s].to_dict()
69
+ if ld != rd:
70
+ diffs.append(Diff("glossary_changed", f"sigils.{s}",
71
+ left=ld, right=rd,
72
+ message=f"sigil {s} definition changed"))
73
+ # types
74
+ lt = set(left.types)
75
+ rt = set(right.types)
76
+ for t in sorted(lt - rt):
77
+ diffs.append(Diff("glossary_changed", f"types.{t}",
78
+ left=left.types[t].to_dict(), right=None,
79
+ message=f"type {t} removed"))
80
+ for t in sorted(rt - lt):
81
+ diffs.append(Diff("glossary_changed", f"types.{t}",
82
+ left=None, right=right.types[t].to_dict(),
83
+ message=f"type {t} added"))
84
+ # micro
85
+ lm = set(left.micro)
86
+ rm = set(right.micro)
87
+ for tok in sorted(lm - rm):
88
+ diffs.append(Diff("glossary_changed", f"micro.{tok}",
89
+ left=left.micro[tok].to_dict(), right=None,
90
+ message=f"micro-token {tok} removed"))
91
+ for tok in sorted(rm - lm):
92
+ diffs.append(Diff("glossary_changed", f"micro.{tok}",
93
+ left=None, right=right.micro[tok].to_dict(),
94
+ message=f"micro-token {tok} added"))
95
+ for tok in sorted(lm & rm):
96
+ if left.micro[tok].value != right.micro[tok].value:
97
+ diffs.append(Diff("glossary_changed", f"micro.{tok}",
98
+ left=left.micro[tok].value,
99
+ right=right.micro[tok].value,
100
+ message=f"micro-token {tok} value changed"))
101
+ return diffs
102
+
103
+
104
+ def compare_entry(left: Entry, right: Entry) -> List[Diff]:
105
+ diffs: List[Diff] = []
106
+ path = f"{left.section}/{left.sigil}:{left.name}"
107
+ if left.sigil != right.sigil:
108
+ diffs.append(Diff("entry_type_changed", path,
109
+ left=left.sigil, right=right.sigil,
110
+ message="sigil changed"))
111
+ if left.name != right.name:
112
+ diffs.append(Diff("entry_type_changed", path,
113
+ left=left.name, right=right.name,
114
+ message="name changed"))
115
+ if left.type != right.type:
116
+ diffs.append(Diff("entry_type_changed", path,
117
+ left=left.type, right=right.type,
118
+ message="expansion type changed"))
119
+ # Value comparison
120
+ if left.type == "bloque":
121
+ # Compare verbatim raw
122
+ if left.value != right.value:
123
+ diffs.append(Diff("bloque_changed", path,
124
+ left=left.value, right=right.value,
125
+ message="bloque content changed (verbatim mismatch)"))
126
+ else:
127
+ if left.value != right.value:
128
+ diffs.append(Diff("entry_value_changed", path,
129
+ left=left.value, right=right.value,
130
+ message="parsed value changed"))
131
+ return diffs
132
+
133
+
134
+ def compare_ast(left: CortexDocument, right: CortexDocument) -> DiffResult:
135
+ """Compare two ASTs structurally; return :class:`DiffResult`."""
136
+
137
+ diffs: List[Diff] = []
138
+ diffs.extend(compare_glossary(left.glossary, right.glossary))
139
+
140
+ # Sections
141
+ left_secs = {s.id: s for s in left.sections if s.id != "$0"}
142
+ right_secs = {s.id: s for s in right.sections if s.id != "$0"}
143
+ left_order = [s.id for s in left.sections if s.id != "$0"]
144
+ right_order = [s.id for s in right.sections if s.id != "$0"]
145
+ # added/removed
146
+ for sid in left_order:
147
+ if sid not in right_secs:
148
+ diffs.append(Diff("section_removed", sid,
149
+ message=f"section {sid} removed"))
150
+ for sid in right_order:
151
+ if sid not in left_secs:
152
+ diffs.append(Diff("section_added", sid,
153
+ message=f"section {sid} added"))
154
+ # order (only among common sections)
155
+ common_left = [s for s in left_order if s in right_secs]
156
+ common_right = [s for s in right_order if s in left_secs]
157
+ if common_left != common_right:
158
+ diffs.append(Diff("section_reordered", "sections",
159
+ left=common_left, right=common_right,
160
+ message="section order changed"))
161
+
162
+ # Entries per common section
163
+ for sid in common_left:
164
+ ls = left_secs[sid]
165
+ rs = right_secs[sid]
166
+ le = {f"{e.sigil}:{e.name}": e for e in ls.entries}
167
+ re_ = {f"{e.sigil}:{e.name}": e for e in rs.entries}
168
+ for key in le:
169
+ if key not in re_:
170
+ diffs.append(Diff("entry_removed", f"{sid}/{key}",
171
+ message=f"entry {key} removed from {sid}"))
172
+ for key in re_:
173
+ if key not in le:
174
+ diffs.append(Diff("entry_added", f"{sid}/{key}",
175
+ message=f"entry {key} added to {sid}"))
176
+ for key in le:
177
+ if key in re_:
178
+ diffs.extend(compare_entry(le[key], re_[key]))
179
+
180
+ return DiffResult(equal=(len(diffs) == 0), diffs=diffs)