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,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)
@@ -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
+ }