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/crud/mutations.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Mutations — add / update / delete / move entries in a :class:`CortexDocument`.
|
|
2
|
+
|
|
3
|
+
All mutations operate on the AST in memory; persistence (atomic write)
|
|
4
|
+
is handled by :mod:`cortex.crud.transactions`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional, Union
|
|
10
|
+
|
|
11
|
+
from ..core.ast import CortexDocument, Entry, normalize_section_id
|
|
12
|
+
from ..core.errors import (
|
|
13
|
+
DuplicateEntryError,
|
|
14
|
+
InvalidValueError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
ProtectedEntryError,
|
|
17
|
+
)
|
|
18
|
+
from ..core.parser import build_entry_from_value, parse_attrs_body
|
|
19
|
+
from ..core.validator import is_protected_entry
|
|
20
|
+
from .selectors import select_one
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Add
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def add_entry(
|
|
28
|
+
doc: CortexDocument,
|
|
29
|
+
section: str,
|
|
30
|
+
sigil: str,
|
|
31
|
+
name: str,
|
|
32
|
+
value: Union[str, Dict[str, Any]],
|
|
33
|
+
*,
|
|
34
|
+
create_section: bool = False,
|
|
35
|
+
allow_duplicate: bool = False,
|
|
36
|
+
allow_unknown_sigil: bool = False,
|
|
37
|
+
) -> Entry:
|
|
38
|
+
"""Add a new entry to ``doc``.
|
|
39
|
+
|
|
40
|
+
``value`` may be:
|
|
41
|
+
- a dict (used as-is for attrs)
|
|
42
|
+
- a string (parsed as attrs body for attrs, or stored verbatim for
|
|
43
|
+
cuerpo / bloque)
|
|
44
|
+
|
|
45
|
+
Re-audit H-RA-06: by default the sigil MUST exist in ``$0``. If it
|
|
46
|
+
doesn't, :class:`~cortex.core.errors.UnknownSigilError` is raised.
|
|
47
|
+
Pass ``allow_unknown_sigil=True`` (or CLI ``--allow-unknown-sigil``)
|
|
48
|
+
to permit undeclared sigils (recovery/debug scenarios).
|
|
49
|
+
|
|
50
|
+
v1.1.5 P0-4: adding operational entries to ``$0`` is rejected.
|
|
51
|
+
``$0`` is structural metadata only (SKILL.md §4.1); only glossary
|
|
52
|
+
declaration sigils (GSIG/GTYP/GMIC/GCON) are allowed there.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Resolve section
|
|
56
|
+
sec_id = normalize_section_id(section)
|
|
57
|
+
|
|
58
|
+
# v1.1.5 P0-4: $0 is reserved for glossary declarations, not memory.
|
|
59
|
+
GLOSSARY_ENTRY_SIGILS = frozenset({"GSIG", "GTYP", "GMIC", "GCON"})
|
|
60
|
+
if sec_id == "$0" and sigil not in GLOSSARY_ENTRY_SIGILS:
|
|
61
|
+
from ..core.errors import CortexError
|
|
62
|
+
raise CortexError(
|
|
63
|
+
"E033_ZERO_SECTION_MEMORY_ENTRY",
|
|
64
|
+
f"$0 MUST NOT contain operational entries; refusing to add "
|
|
65
|
+
f"{sigil}:{name} to $0. $0 is structural metadata only "
|
|
66
|
+
"(SKILL.md §4.1). Use $1 or later sections for operational memory.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
target = doc.get_section(sec_id)
|
|
70
|
+
if target is None:
|
|
71
|
+
if not create_section:
|
|
72
|
+
raise NotFoundError(f"section {sec_id}")
|
|
73
|
+
target = doc.get_or_create_section(sec_id)
|
|
74
|
+
|
|
75
|
+
# Check for duplicates
|
|
76
|
+
existing = doc.find_entries(sigil=sigil, name=name, section=sec_id)
|
|
77
|
+
if existing and not allow_duplicate:
|
|
78
|
+
raise DuplicateEntryError(sigil, name, section=sec_id)
|
|
79
|
+
|
|
80
|
+
# Determine type from glossary
|
|
81
|
+
sigil_def = doc.glossary.sigils.get(sigil)
|
|
82
|
+
if sigil_def is None and not allow_unknown_sigil:
|
|
83
|
+
from ..core.errors import UnknownSigilError
|
|
84
|
+
raise UnknownSigilError(sigil)
|
|
85
|
+
type_ = sigil_def.type if sigil_def else "attrs"
|
|
86
|
+
|
|
87
|
+
# Build value
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
v = value
|
|
90
|
+
elif isinstance(value, str):
|
|
91
|
+
if type_ == "attrs":
|
|
92
|
+
try:
|
|
93
|
+
v = parse_attrs_body(value)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
raise InvalidValueError(f"cannot parse attrs body: {e}")
|
|
96
|
+
elif type_ in ("cuerpo", "bloque", "relación"):
|
|
97
|
+
v = value
|
|
98
|
+
else:
|
|
99
|
+
v = value
|
|
100
|
+
else:
|
|
101
|
+
raise InvalidValueError(f"unsupported value type: {type(value).__name__}")
|
|
102
|
+
|
|
103
|
+
entry = build_entry_from_value(sec_id, sigil, name, type_, v)
|
|
104
|
+
target.entries.append(entry)
|
|
105
|
+
return entry
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Update
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def update_entry(
|
|
113
|
+
doc: CortexDocument,
|
|
114
|
+
selector: str,
|
|
115
|
+
*,
|
|
116
|
+
set_: Optional[Dict[str, Any]] = None,
|
|
117
|
+
replace_body: Optional[str] = None,
|
|
118
|
+
append: bool = False,
|
|
119
|
+
) -> Entry:
|
|
120
|
+
"""Update an entry selected by ``selector``.
|
|
121
|
+
|
|
122
|
+
For ``attrs`` / ``attrs-pos`` entries, ``set_`` merges key/value
|
|
123
|
+
pairs into the existing dict. For ``cuerpo`` / ``bloque`` entries,
|
|
124
|
+
``replace_body`` replaces the body (or appends if ``append=True``).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
entry = select_one(doc, selector)
|
|
128
|
+
if entry.type in ("attrs", "attrs-pos"):
|
|
129
|
+
if set_ is None:
|
|
130
|
+
return entry
|
|
131
|
+
if not isinstance(entry.value, dict):
|
|
132
|
+
entry.value = {}
|
|
133
|
+
for k, v in set_.items():
|
|
134
|
+
entry.value[k] = v
|
|
135
|
+
# Recompute raw + hash
|
|
136
|
+
from ..core.writer import serialize_entry_value
|
|
137
|
+
body = serialize_entry_value(entry.value, entry.type)
|
|
138
|
+
entry.raw = f"{entry.sigil}:{entry.name}{{{body}}}"
|
|
139
|
+
from ..core.ast import compute_entry_hash
|
|
140
|
+
entry.hash = compute_entry_hash(entry)
|
|
141
|
+
entry.entry_id = entry.hash
|
|
142
|
+
return entry
|
|
143
|
+
elif entry.type in ("cuerpo", "bloque", "relación"):
|
|
144
|
+
if replace_body is None:
|
|
145
|
+
return entry
|
|
146
|
+
if append:
|
|
147
|
+
entry.value = str(entry.value or "") + "\n" + str(replace_body)
|
|
148
|
+
else:
|
|
149
|
+
entry.value = str(replace_body)
|
|
150
|
+
from ..core.writer import serialize_entry_value
|
|
151
|
+
body = serialize_entry_value(entry.value, entry.type)
|
|
152
|
+
entry.raw = f"{entry.sigil}:{entry.name}{{{body}}}"
|
|
153
|
+
from ..core.ast import compute_entry_hash
|
|
154
|
+
entry.hash = compute_entry_hash(entry)
|
|
155
|
+
entry.entry_id = entry.hash
|
|
156
|
+
return entry
|
|
157
|
+
else:
|
|
158
|
+
raise InvalidValueError(f"cannot update entry of type {entry.type!r}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Delete
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def delete_entry(
|
|
166
|
+
doc: CortexDocument,
|
|
167
|
+
selector: str,
|
|
168
|
+
*,
|
|
169
|
+
force: bool = False,
|
|
170
|
+
) -> Entry:
|
|
171
|
+
"""Delete the entry matching ``selector`` from ``doc``.
|
|
172
|
+
|
|
173
|
+
Protected entries (P0 / ``severity:blocking`` / ``survive:min`` on
|
|
174
|
+
FCS, OBJ, CNST) require ``force=True``.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
entry = select_one(doc, selector)
|
|
178
|
+
if is_protected_entry(entry) and not force:
|
|
179
|
+
raise ProtectedEntryError(entry.sigil, entry.name)
|
|
180
|
+
|
|
181
|
+
sec = doc.get_section(entry.section)
|
|
182
|
+
if sec is None:
|
|
183
|
+
raise NotFoundError(f"section {entry.section}")
|
|
184
|
+
sec.entries = [e for e in sec.entries if not (
|
|
185
|
+
e.sigil == entry.sigil and e.name == entry.name and e.section == entry.section
|
|
186
|
+
)]
|
|
187
|
+
return entry
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Move
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def move_entry(
|
|
195
|
+
doc: CortexDocument,
|
|
196
|
+
selector: str,
|
|
197
|
+
to_section: str,
|
|
198
|
+
) -> Entry:
|
|
199
|
+
"""Move an entry from its current section to ``to_section``."""
|
|
200
|
+
|
|
201
|
+
entry = select_one(doc, selector)
|
|
202
|
+
old_sec = doc.get_section(entry.section)
|
|
203
|
+
if old_sec is None:
|
|
204
|
+
raise NotFoundError(f"section {entry.section}")
|
|
205
|
+
new_sec_id = normalize_section_id(to_section)
|
|
206
|
+
new_sec = doc.get_section(new_sec_id)
|
|
207
|
+
if new_sec is None:
|
|
208
|
+
new_sec = doc.get_or_create_section(new_sec_id)
|
|
209
|
+
# Remove from old
|
|
210
|
+
old_sec.entries = [e for e in old_sec.entries if not (
|
|
211
|
+
e.sigil == entry.sigil and e.name == entry.name and e.section == entry.section
|
|
212
|
+
)]
|
|
213
|
+
# Add to new
|
|
214
|
+
entry.section = new_sec_id
|
|
215
|
+
new_sec.entries.append(entry)
|
|
216
|
+
return entry
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Glossary mutations
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def add_sigil_to_glossary(
|
|
224
|
+
doc: CortexDocument,
|
|
225
|
+
sigil: str,
|
|
226
|
+
name: str,
|
|
227
|
+
type_: str,
|
|
228
|
+
risk: str = "M",
|
|
229
|
+
layer: str = "Semantic",
|
|
230
|
+
description: str = "",
|
|
231
|
+
*,
|
|
232
|
+
force_governance: bool = False,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Add a new sigil to ``$0``.
|
|
235
|
+
|
|
236
|
+
Refuses to silently REDEFINE a sigil that's already declared in $0.
|
|
237
|
+
Canonical sigils that are not yet declared MAY be added (the user is
|
|
238
|
+
extending a minimal glossary to include canonical sigils they need).
|
|
239
|
+
``force_governance`` is required only to redefine an existing sigil
|
|
240
|
+
with a different type/contract.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
from ..core.errors import ProtectedSigilError
|
|
244
|
+
existing = doc.glossary.sigils.get(sigil)
|
|
245
|
+
if existing is not None:
|
|
246
|
+
# Refuse to redefine without explicit governance override
|
|
247
|
+
if not force_governance:
|
|
248
|
+
raise ProtectedSigilError(sigil)
|
|
249
|
+
# With --force-governance, allow replacement if type is unchanged
|
|
250
|
+
if existing.type != type_:
|
|
251
|
+
raise ProtectedSigilError(sigil)
|
|
252
|
+
from ..core.ast import SigilDef
|
|
253
|
+
doc.glossary.add_sigil(SigilDef(
|
|
254
|
+
sigil=sigil, name=name, type=type_, risk=risk,
|
|
255
|
+
layer=layer, description=description,
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def update_sigil_in_glossary(
|
|
260
|
+
doc: CortexDocument,
|
|
261
|
+
sigil: str,
|
|
262
|
+
*,
|
|
263
|
+
description: Optional[str] = None,
|
|
264
|
+
risk: Optional[str] = None,
|
|
265
|
+
layer: Optional[str] = None,
|
|
266
|
+
force_governance: bool = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Update a sigil's metadata (NOT its type if used)."""
|
|
269
|
+
|
|
270
|
+
sd = doc.glossary.sigils.get(sigil)
|
|
271
|
+
if sd is None:
|
|
272
|
+
raise NotFoundError(f"sigil {sigil}")
|
|
273
|
+
if description is not None:
|
|
274
|
+
sd.description = description
|
|
275
|
+
if risk is not None:
|
|
276
|
+
sd.risk = risk
|
|
277
|
+
if layer is not None:
|
|
278
|
+
sd.layer = layer
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def delete_sigil_from_glossary(
|
|
282
|
+
doc: CortexDocument,
|
|
283
|
+
sigil: str,
|
|
284
|
+
*,
|
|
285
|
+
force: bool = False,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Remove a sigil from ``$0``.
|
|
288
|
+
|
|
289
|
+
Refuses to remove sigils that are used by entries (unless ``force``).
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
from ..core.errors import SigilInUseError
|
|
293
|
+
usage = sum(1 for _, e in doc.iter_entries() if e.sigil == sigil)
|
|
294
|
+
if usage > 0 and not force:
|
|
295
|
+
raise SigilInUseError(sigil, usage)
|
|
296
|
+
doc.glossary.sigils.pop(sigil, None)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Micro-token mutations
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def add_micro_to_glossary(
|
|
304
|
+
doc: CortexDocument,
|
|
305
|
+
token: str,
|
|
306
|
+
value: str,
|
|
307
|
+
) -> None:
|
|
308
|
+
from ..core.ast import MicroDef
|
|
309
|
+
existing = doc.glossary.micro.get(token)
|
|
310
|
+
if existing is not None and existing.value != value:
|
|
311
|
+
from ..core.errors import CortexError
|
|
312
|
+
raise CortexError(
|
|
313
|
+
"E021_INVALID_VALUE",
|
|
314
|
+
f"micro-token {token!r} already declared with value {existing.value!r}",
|
|
315
|
+
)
|
|
316
|
+
doc.glossary.add_micro(MicroDef(token=token, value=value))
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def update_micro_in_glossary(
|
|
320
|
+
doc: CortexDocument,
|
|
321
|
+
token: str,
|
|
322
|
+
value: str,
|
|
323
|
+
) -> None:
|
|
324
|
+
existing = doc.glossary.micro.get(token)
|
|
325
|
+
if existing is None:
|
|
326
|
+
from ..core.errors import NotFoundError
|
|
327
|
+
raise NotFoundError(f"micro-token {token}")
|
|
328
|
+
existing.value = value
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def delete_micro_from_glossary(
|
|
332
|
+
doc: CortexDocument,
|
|
333
|
+
token: str,
|
|
334
|
+
*,
|
|
335
|
+
force: bool = False,
|
|
336
|
+
) -> None:
|
|
337
|
+
from ..glossary.resolver import is_micro_used
|
|
338
|
+
from ..core.errors import MicroInUseError
|
|
339
|
+
all_entries = [e for _, e in doc.iter_entries()]
|
|
340
|
+
if is_micro_used(all_entries, token) and not force:
|
|
341
|
+
raise MicroInUseError(token)
|
|
342
|
+
doc.glossary.micro.pop(token, None)
|
cortex/crud/selectors.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Selectors — locate entries in a :class:`CortexDocument`.
|
|
2
|
+
|
|
3
|
+
Selector grammar (Section 12.1 of the spec):
|
|
4
|
+
|
|
5
|
+
$SECTION/SIGIL:NAME exact match
|
|
6
|
+
SIGIL:NAME match any section
|
|
7
|
+
SIGIL:* all entries with that sigil
|
|
8
|
+
$SECTION/* all entries in a section
|
|
9
|
+
|
|
10
|
+
Wildcards ``*`` are supported on name and section.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from ..core.ast import CortexDocument, Entry, normalize_section_id
|
|
19
|
+
from ..core.errors import AmbiguousSelectorError, NotFoundError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Selector:
|
|
24
|
+
section: Optional[str] # "$2" or "*" or None
|
|
25
|
+
sigil: str # "FCS" or "*"
|
|
26
|
+
name: str # "primary" or "*"
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
s = ""
|
|
30
|
+
if self.section:
|
|
31
|
+
s += f"{self.section}/"
|
|
32
|
+
s += f"{self.sigil}:{self.name}"
|
|
33
|
+
return s
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_selector(s: str) -> Selector:
|
|
37
|
+
"""Parse a selector string into a :class:`Selector`."""
|
|
38
|
+
|
|
39
|
+
s = s.strip()
|
|
40
|
+
if not s:
|
|
41
|
+
raise ValueError("empty selector")
|
|
42
|
+
# Split on /
|
|
43
|
+
if "/" in s:
|
|
44
|
+
section_part, rest = s.split("/", 1)
|
|
45
|
+
else:
|
|
46
|
+
section_part, rest = None, s
|
|
47
|
+
# section_part may be "$2" or "*"
|
|
48
|
+
if section_part == "*":
|
|
49
|
+
section = "*"
|
|
50
|
+
elif section_part:
|
|
51
|
+
section = normalize_section_id(section_part)
|
|
52
|
+
else:
|
|
53
|
+
section = None
|
|
54
|
+
# rest is "SIGIL:NAME" or "SIGIL:*" or "*"
|
|
55
|
+
if ":" in rest:
|
|
56
|
+
sigil, name = rest.split(":", 1)
|
|
57
|
+
else:
|
|
58
|
+
sigil, name = rest, "*"
|
|
59
|
+
if sigil == "":
|
|
60
|
+
sigil = "*"
|
|
61
|
+
if name == "":
|
|
62
|
+
name = "*"
|
|
63
|
+
return Selector(section=section, sigil=sigil, name=name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def select(doc: CortexDocument, selector_str: str) -> List[Entry]:
|
|
67
|
+
"""Return all entries that match ``selector_str``."""
|
|
68
|
+
|
|
69
|
+
sel = parse_selector(selector_str)
|
|
70
|
+
out: List[Entry] = []
|
|
71
|
+
for sec, entry in doc.iter_entries():
|
|
72
|
+
if sel.section and sel.section != "*":
|
|
73
|
+
if sec.id != normalize_section_id(sel.section):
|
|
74
|
+
continue
|
|
75
|
+
if sel.sigil != "*" and entry.sigil != sel.sigil:
|
|
76
|
+
continue
|
|
77
|
+
if sel.name != "*" and entry.name != sel.name:
|
|
78
|
+
continue
|
|
79
|
+
out.append(entry)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def select_one(doc: CortexDocument, selector_str: str) -> Entry:
|
|
84
|
+
"""Return exactly one matching entry or raise.
|
|
85
|
+
|
|
86
|
+
Raises :class:`NotFoundError` if 0 matches and
|
|
87
|
+
:class:`AmbiguousSelectorError` if >1 matches.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
matches = select(doc, selector_str)
|
|
91
|
+
if not matches:
|
|
92
|
+
raise NotFoundError(selector_str)
|
|
93
|
+
if len(matches) > 1:
|
|
94
|
+
raise AmbiguousSelectorError(selector_str, len(matches))
|
|
95
|
+
return matches[0]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Transactions — atomic write with backup, dry-run and diff support.
|
|
2
|
+
|
|
3
|
+
Per Section 15 of the spec:
|
|
4
|
+
|
|
5
|
+
atomic_write(path, content):
|
|
6
|
+
tmp = path + ".tmp"
|
|
7
|
+
bak = path + ".bak"
|
|
8
|
+
write tmp
|
|
9
|
+
parse tmp
|
|
10
|
+
validate tmp
|
|
11
|
+
copy path to bak
|
|
12
|
+
replace path with tmp
|
|
13
|
+
|
|
14
|
+
The writer NEVER overwrites the target if validation fails.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..core.ast import CortexDocument
|
|
25
|
+
from ..core.errors import AtomicWriteError
|
|
26
|
+
from ..core.parser import parse_cortex
|
|
27
|
+
from ..core.writer import write_cortex
|
|
28
|
+
from ..core.validator import validate
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class WriteResult:
|
|
33
|
+
path: str
|
|
34
|
+
backup: Optional[str]
|
|
35
|
+
bytes_written: int
|
|
36
|
+
diagnostics: list
|
|
37
|
+
dry_run: bool
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"path": self.path,
|
|
42
|
+
"backup": self.backup,
|
|
43
|
+
"bytes_written": self.bytes_written,
|
|
44
|
+
"diagnostics": self.diagnostics,
|
|
45
|
+
"dry_run": self.dry_run,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def atomic_write_cortex(
|
|
50
|
+
doc: CortexDocument,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
force: bool = False,
|
|
54
|
+
dry_run: bool = False,
|
|
55
|
+
keep_backup: bool = True,
|
|
56
|
+
unsafe_allow_secret_forensics: bool = False,
|
|
57
|
+
) -> WriteResult:
|
|
58
|
+
"""Serialise ``doc`` and atomically write it to ``path``.
|
|
59
|
+
|
|
60
|
+
The workflow:
|
|
61
|
+
1. Serialise to canonical text.
|
|
62
|
+
2. Re-parse the text and validate.
|
|
63
|
+
3. If validation produces error-severity diagnostics and ``force``
|
|
64
|
+
is False, abort.
|
|
65
|
+
4. Write to ``path + ".tmp"``.
|
|
66
|
+
5. Copy the original file to ``path + ".bak"`` (if it existed).
|
|
67
|
+
6. Rename ``.tmp`` → ``path``.
|
|
68
|
+
7. If ``dry_run`` is True, do not touch the filesystem; just report.
|
|
69
|
+
|
|
70
|
+
v1.1.3 P0-2: ``unsafe_allow_secret_forensics=True`` allows bypassing
|
|
71
|
+
``E031_SECRET_NOT_BYPASSABLE`` for forensic recovery ONLY. It does
|
|
72
|
+
NOT bypass ``E032_CRITICAL_SIGIL_INCOMPLETE``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
text = write_cortex(doc)
|
|
76
|
+
# Re-parse to verify roundtrip-ability
|
|
77
|
+
reparsed = parse_cortex(text, path=path)
|
|
78
|
+
diagnostics = validate(reparsed)
|
|
79
|
+
errors = [d for d in diagnostics if d.get("severity") == "error"]
|
|
80
|
+
# v1.1.3 P0-2/P0-3: non-bypassable errors (secrets, critical sigil
|
|
81
|
+
# incompleteness) CANNOT be overridden by --force.
|
|
82
|
+
non_bypassable = [d for d in errors if d.get("bypassable") is False]
|
|
83
|
+
bypassable = [d for d in errors if d.get("bypassable") is not False]
|
|
84
|
+
# v1.1.3 P0-2: --unsafe-allow-secret-forensics can bypass secret errors ONLY
|
|
85
|
+
if unsafe_allow_secret_forensics:
|
|
86
|
+
non_bypassable = [
|
|
87
|
+
d for d in non_bypassable
|
|
88
|
+
if d.get("code") != "E031_SECRET_NOT_BYPASSABLE"
|
|
89
|
+
]
|
|
90
|
+
if non_bypassable:
|
|
91
|
+
raise AtomicWriteError(
|
|
92
|
+
f"{len(non_bypassable)} non-bypassable error(s) prevent write; "
|
|
93
|
+
"--force cannot override security/governance rules: "
|
|
94
|
+
+ "; ".join(d.get("code", "") + ": " + d.get("message", "")[:80] for d in non_bypassable[:3]),
|
|
95
|
+
)
|
|
96
|
+
if bypassable and not force:
|
|
97
|
+
raise AtomicWriteError(
|
|
98
|
+
f"validation failed with {len(bypassable)} bypassable error(s); "
|
|
99
|
+
"use --force to override",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if dry_run:
|
|
103
|
+
return WriteResult(
|
|
104
|
+
path=path,
|
|
105
|
+
backup=None,
|
|
106
|
+
bytes_written=len(text.encode("utf-8")),
|
|
107
|
+
diagnostics=diagnostics,
|
|
108
|
+
dry_run=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Ensure parent directory exists
|
|
112
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
113
|
+
if parent and not os.path.isdir(parent):
|
|
114
|
+
os.makedirs(parent, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
tmp_path = path + ".tmp"
|
|
117
|
+
bak_path = path + ".bak"
|
|
118
|
+
|
|
119
|
+
# Write tmp
|
|
120
|
+
try:
|
|
121
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
122
|
+
f.write(text)
|
|
123
|
+
except OSError as e:
|
|
124
|
+
raise AtomicWriteError(f"cannot write tmp file: {e}")
|
|
125
|
+
|
|
126
|
+
# Backup original
|
|
127
|
+
backup_created = None
|
|
128
|
+
if keep_backup and os.path.exists(path):
|
|
129
|
+
try:
|
|
130
|
+
shutil.copy2(path, bak_path)
|
|
131
|
+
backup_created = bak_path
|
|
132
|
+
except OSError as e:
|
|
133
|
+
# Clean up tmp
|
|
134
|
+
try:
|
|
135
|
+
os.remove(tmp_path)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
raise AtomicWriteError(f"cannot create backup: {e}")
|
|
139
|
+
|
|
140
|
+
# Rename tmp → path
|
|
141
|
+
try:
|
|
142
|
+
os.replace(tmp_path, path)
|
|
143
|
+
except OSError as e:
|
|
144
|
+
try:
|
|
145
|
+
os.remove(tmp_path)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
raise AtomicWriteError(f"cannot replace target file: {e}")
|
|
149
|
+
|
|
150
|
+
return WriteResult(
|
|
151
|
+
path=path,
|
|
152
|
+
backup=backup_created,
|
|
153
|
+
bytes_written=len(text.encode("utf-8")),
|
|
154
|
+
diagnostics=diagnostics,
|
|
155
|
+
dry_run=False,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def atomic_write_text(
|
|
160
|
+
text: str,
|
|
161
|
+
path: str,
|
|
162
|
+
*,
|
|
163
|
+
dry_run: bool = False,
|
|
164
|
+
keep_backup: bool = True,
|
|
165
|
+
) -> WriteResult:
|
|
166
|
+
"""Atomic write for arbitrary text (used by render/compile)."""
|
|
167
|
+
|
|
168
|
+
if dry_run:
|
|
169
|
+
return WriteResult(
|
|
170
|
+
path=path, backup=None,
|
|
171
|
+
bytes_written=len(text.encode("utf-8")),
|
|
172
|
+
diagnostics=[], dry_run=True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
176
|
+
if parent and not os.path.isdir(parent):
|
|
177
|
+
os.makedirs(parent, exist_ok=True)
|
|
178
|
+
|
|
179
|
+
tmp_path = path + ".tmp"
|
|
180
|
+
bak_path = path + ".bak"
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
184
|
+
f.write(text)
|
|
185
|
+
except OSError as e:
|
|
186
|
+
raise AtomicWriteError(f"cannot write tmp file: {e}")
|
|
187
|
+
|
|
188
|
+
backup_created = None
|
|
189
|
+
if keep_backup and os.path.exists(path):
|
|
190
|
+
try:
|
|
191
|
+
shutil.copy2(path, bak_path)
|
|
192
|
+
backup_created = bak_path
|
|
193
|
+
except OSError as e:
|
|
194
|
+
try:
|
|
195
|
+
os.remove(tmp_path)
|
|
196
|
+
except OSError:
|
|
197
|
+
pass
|
|
198
|
+
raise AtomicWriteError(f"cannot create backup: {e}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
os.replace(tmp_path, path)
|
|
202
|
+
except OSError as e:
|
|
203
|
+
try:
|
|
204
|
+
os.remove(tmp_path)
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
raise AtomicWriteError(f"cannot replace target file: {e}")
|
|
208
|
+
|
|
209
|
+
return WriteResult(
|
|
210
|
+
path=path, backup=backup_created,
|
|
211
|
+
bytes_written=len(text.encode("utf-8")),
|
|
212
|
+
diagnostics=[], dry_run=False,
|
|
213
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Glossary package — minimal sigil sets, resolver, contracts."""
|
|
2
|
+
|
|
3
|
+
from .model import minimal_glossary, add_sigil, add_micro, add_contract, sigils_in_use
|
|
4
|
+
from .minimal import (
|
|
5
|
+
brain_sigils, skill_sigils, package_sigils, generic_sigils, hdL_contract,
|
|
6
|
+
)
|
|
7
|
+
from .resolver import (
|
|
8
|
+
is_canonical, is_declared, type_of, contract_of,
|
|
9
|
+
expand_micro, sigil_usage_count, is_micro_used,
|
|
10
|
+
)
|
|
11
|
+
from .contracts import (
|
|
12
|
+
get_contract, require_contract, declare_contract, DEFAULT_CONTRACTS,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"minimal_glossary", "add_sigil", "add_micro", "add_contract", "sigils_in_use",
|
|
17
|
+
"brain_sigils", "skill_sigils", "package_sigils", "generic_sigils", "hdL_contract",
|
|
18
|
+
"is_canonical", "is_declared", "type_of", "contract_of",
|
|
19
|
+
"expand_micro", "sigil_usage_count", "is_micro_used",
|
|
20
|
+
"get_contract", "require_contract", "declare_contract", "DEFAULT_CONTRACTS",
|
|
21
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Positional contracts for ``attrs-pos`` sigils.
|
|
2
|
+
|
|
3
|
+
A contract is the ordered list of field names that the parser must use
|
|
4
|
+
when splitting a positional body. Contracts live in ``$0`` and are
|
|
5
|
+
declared via ``# contract: SIGIL | field1 | field2 | ...`` or via
|
|
6
|
+
explicit ``GCON:SIGIL{sigil:"SIGIL", fields:"f1|f2"}`` entries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from ..core.ast import AttrsPosContract, Glossary
|
|
14
|
+
from ..core.errors import AttrsPosContractMissingError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_contract(glossary: Glossary, sigil: str) -> Optional[AttrsPosContract]:
|
|
18
|
+
return glossary.contracts.get(sigil)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_contract(glossary: Glossary, sigil: str) -> AttrsPosContract:
|
|
22
|
+
c = get_contract(glossary, sigil)
|
|
23
|
+
if c is None:
|
|
24
|
+
raise AttrsPosContractMissingError(sigil)
|
|
25
|
+
return c
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def declare_contract(glossary: Glossary, sigil: str, fields: List[str]) -> AttrsPosContract:
|
|
29
|
+
c = AttrsPosContract(sigil=sigil, fields=list(fields))
|
|
30
|
+
glossary.add_contract(c)
|
|
31
|
+
return c
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Default contracts for canonical attrs-pos sigils (Section 6)
|
|
35
|
+
DEFAULT_CONTRACTS = {
|
|
36
|
+
"HDL": ["operation", "status", "requires"],
|
|
37
|
+
}
|