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/core/__init__.py
ADDED
|
@@ -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)
|