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
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Document kind inference and level-policy validation.
|
|
2
|
+
|
|
3
|
+
Implements the cognitive governance rules that close the audit gaps:
|
|
4
|
+
|
|
5
|
+
- B-001 / H-01: `brain.cortex` MUST have `FCS` and `OBJ` active.
|
|
6
|
+
- B-002 / H-02: `SKILL.cortex` (Nivel 1) MUST NOT contain live working
|
|
7
|
+
state (`FCS`/`OBJ`/`WRK`/`STP`/`NXT` as actual state, not contract).
|
|
8
|
+
- B-009: `survive` attribute MUST be in `{min, recovery, work, full}`.
|
|
9
|
+
- Blocking `CNST` MUST have `survive:min` (P0).
|
|
10
|
+
- Nivel 3 packages MUST NOT contain live `WRK` state outside recovery mode.
|
|
11
|
+
|
|
12
|
+
The kind is inferred from:
|
|
13
|
+
1. Explicit `kind` attribute in `IDN:agent`/`IDN:skill`/`IDN:package`
|
|
14
|
+
2. Filename (`brain.cortex`, `SKILL.cortex`, `package.cortex`)
|
|
15
|
+
3. Sigil signature (presence of `FCS`+`OBJ`+`WRK` ⇒ brain;
|
|
16
|
+
presence of `AXM`+`HDL` without `WRK` ⇒ skill;
|
|
17
|
+
presence of `LIM`+`CLAIM` without `WRK` ⇒ package)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
from .ast import CortexDocument, Entry
|
|
26
|
+
from .errors import (
|
|
27
|
+
ALLOWED_SURVIVE,
|
|
28
|
+
E023_LEVEL1_LIVE_STATE,
|
|
29
|
+
E024_LEVEL2_MISSING_FOCUS,
|
|
30
|
+
E025_INVALID_SURVIVE,
|
|
31
|
+
E026_BLOCKING_NOT_P0,
|
|
32
|
+
E027_ATTRS_POS_ARITY,
|
|
33
|
+
E029_LEVEL3_LIVE_STATE,
|
|
34
|
+
E031_SECRET_NOT_BYPASSABLE,
|
|
35
|
+
E032_CRITICAL_SIGIL_INCOMPLETE,
|
|
36
|
+
E033_ZERO_SECTION_MEMORY_ENTRY,
|
|
37
|
+
E034_CRITICAL_REQUIRED_FIELD_EMPTY,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Sigils that constitute "live working state" — forbidden in Nivel 1
|
|
42
|
+
# Per SKILL.md Section 7.2 matrix: FCS/OBJ/WRK/STP/NXT are live working
|
|
43
|
+
# state; SES/LNG are also forbidden as LIVE state in Nivel 1 (only
|
|
44
|
+
# allowed as historical examples with status=specification or
|
|
45
|
+
# nature=example). Re-audit gap H-RA-02: SES/LNG were missing.
|
|
46
|
+
LIVE_WORKING_SIGILS = frozenset({"FCS", "OBJ", "WRK", "STP", "NXT"})
|
|
47
|
+
LIVE_SESSION_SIGILS = frozenset({"SES", "LNG"})
|
|
48
|
+
LIVE_STATE_SIGILS = LIVE_WORKING_SIGILS | LIVE_SESSION_SIGILS
|
|
49
|
+
|
|
50
|
+
# Sigils forbidden as live state specifically in Nivel 1 (SKILL.cortex).
|
|
51
|
+
# Per SKILL.md §7.2: FCS/OBJ/STP only as contract/example; WRK/NXT not
|
|
52
|
+
# allowed; SES/LNG only as historical (must be marked).
|
|
53
|
+
SKILL_FORBIDDEN_LIVE_SIGILS = LIVE_STATE_SIGILS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class DocumentKind:
|
|
58
|
+
"""Inferred kind of a :class:`CortexDocument`."""
|
|
59
|
+
|
|
60
|
+
kind: str # "skill" | "brain" | "package" | "generic"
|
|
61
|
+
source: str # how it was inferred: "idn_attr" | "filename" | "sigils" | "default"
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return f"{self.kind} (inferred via {self.source})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def infer_document_kind(doc: CortexDocument, path: Optional[str] = None) -> DocumentKind:
|
|
68
|
+
"""Infer the document kind (skill / brain / package / generic).
|
|
69
|
+
|
|
70
|
+
The inference checks, in order of authority:
|
|
71
|
+
1. The ``kind`` attribute of ``IDN:agent``/``IDN:skill``/``IDN:package``
|
|
72
|
+
2. The filename (``brain.cortex``, ``SKILL.cortex``, ``package.cortex``)
|
|
73
|
+
3. The sigil signature
|
|
74
|
+
4. Default: ``generic``
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# 1. IDN attribute
|
|
78
|
+
for sec, entry in doc.iter_entries():
|
|
79
|
+
if entry.sigil != "IDN":
|
|
80
|
+
continue
|
|
81
|
+
if isinstance(entry.value, dict):
|
|
82
|
+
kind_val = entry.value.get("kind")
|
|
83
|
+
if isinstance(kind_val, str) and kind_val in ("skill", "brain", "package"):
|
|
84
|
+
return DocumentKind(kind=kind_val, source="idn_attr")
|
|
85
|
+
# Heuristic: IDN:skill → skill, IDN:agent → brain, IDN:package → package
|
|
86
|
+
if entry.name == "skill":
|
|
87
|
+
return DocumentKind(kind="skill", source="idn_attr")
|
|
88
|
+
if entry.name == "package":
|
|
89
|
+
return DocumentKind(kind="package", source="idn_attr")
|
|
90
|
+
if entry.name == "agent":
|
|
91
|
+
return DocumentKind(kind="brain", source="idn_attr")
|
|
92
|
+
|
|
93
|
+
# 2. Filename
|
|
94
|
+
if path:
|
|
95
|
+
import os
|
|
96
|
+
basename = os.path.basename(path).lower()
|
|
97
|
+
if basename == "brain.cortex" or basename.startswith("brain."):
|
|
98
|
+
return DocumentKind(kind="brain", source="filename")
|
|
99
|
+
if basename == "skill.cortex" or basename.startswith("skill."):
|
|
100
|
+
return DocumentKind(kind="skill", source="filename")
|
|
101
|
+
if basename == "package.cortex" or basename.startswith("package."):
|
|
102
|
+
return DocumentKind(kind="package", source="filename")
|
|
103
|
+
|
|
104
|
+
# 3. Sigil signature
|
|
105
|
+
sigils_present = {e.sigil for _, e in doc.iter_entries()}
|
|
106
|
+
if "WRK" in sigils_present and "FCS" in sigils_present:
|
|
107
|
+
return DocumentKind(kind="brain", source="sigils")
|
|
108
|
+
if "AXM" in sigils_present and "WRK" not in sigils_present:
|
|
109
|
+
return DocumentKind(kind="skill", source="sigils")
|
|
110
|
+
if "CLAIM" in sigils_present and "LIM" in sigils_present and "WRK" not in sigils_present:
|
|
111
|
+
return DocumentKind(kind="package", source="sigils")
|
|
112
|
+
|
|
113
|
+
return DocumentKind(kind="generic", source="default")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Level-policy validation
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _is_field_empty(value) -> bool:
|
|
121
|
+
"""Return True if ``value`` is semantically empty.
|
|
122
|
+
|
|
123
|
+
v1.1.6: "", " " (whitespace-only), None are treated as empty.
|
|
124
|
+
v1.1.7: the literal string "null" (case-insensitive) is also treated
|
|
125
|
+
as empty.
|
|
126
|
+
v1.1.8: expanded to cover all common null-like sentinels that a user
|
|
127
|
+
or LLM might write instead of a real value: none, nil, undefined,
|
|
128
|
+
n/a, n.a., na, tbd, todo, tbc, ???, ?, -, --. These are all
|
|
129
|
+
semantically empty for critical required fields per SKILL.md §6.
|
|
130
|
+
"""
|
|
131
|
+
if value is None:
|
|
132
|
+
return True
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
stripped = value.strip().lower()
|
|
135
|
+
if stripped == "":
|
|
136
|
+
return True
|
|
137
|
+
# v1.1.8: comprehensive null-like sentinels
|
|
138
|
+
NULL_LIKE = frozenset({
|
|
139
|
+
"null", "none", "nil", "undefined",
|
|
140
|
+
"n/a", "n.a.", "na",
|
|
141
|
+
"tbd", "todo", "tbc",
|
|
142
|
+
"???", "?",
|
|
143
|
+
"-", "--",
|
|
144
|
+
})
|
|
145
|
+
if stripped in NULL_LIKE:
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_live_entry(entry: Entry) -> bool:
|
|
151
|
+
"""Return True if ``entry`` represents live working state (not contract).
|
|
152
|
+
|
|
153
|
+
An entry is "live" unless it is explicitly marked as
|
|
154
|
+
``example``/``template``/``non_operational``/``contract`` via the
|
|
155
|
+
``nature`` attribute or the entry name.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
if entry.sigil not in LIVE_STATE_SIGILS:
|
|
159
|
+
return False
|
|
160
|
+
if not isinstance(entry.value, dict):
|
|
161
|
+
return True
|
|
162
|
+
nature = entry.value.get("nature", "")
|
|
163
|
+
if isinstance(nature, str) and nature in (
|
|
164
|
+
"example", "template", "non_operational", "contract", "specification",
|
|
165
|
+
):
|
|
166
|
+
return False
|
|
167
|
+
status = entry.value.get("status", "")
|
|
168
|
+
if status in ("specification", "planned", "future", "deprecated"):
|
|
169
|
+
# Specification entries are not "live" — they're contracts/examples
|
|
170
|
+
return False
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_level_policy(
|
|
175
|
+
doc: CortexDocument,
|
|
176
|
+
kind: Optional[DocumentKind] = None,
|
|
177
|
+
) -> List[dict]:
|
|
178
|
+
"""Run level-policy rules and return diagnostic dicts.
|
|
179
|
+
|
|
180
|
+
The rules implemented (per audit H-01, H-02, B-009, B-002):
|
|
181
|
+
|
|
182
|
+
- Nivel 1 (skill): no live ``FCS/OBJ/WRK/STP/NXT`` entries.
|
|
183
|
+
- Nivel 2 (brain): at least one ``FCS`` and one ``OBJ`` entry,
|
|
184
|
+
each with ``status`` in {current, blocked} (v1.1.3: ``done`` is
|
|
185
|
+
a closed state, not active).
|
|
186
|
+
- Nivel 3 (package): no live ``WRK``/``FCS``/``OBJ``/``STP``/``NXT``
|
|
187
|
+
(only historical ``SES``/``LNG`` allowed).
|
|
188
|
+
- ``survive`` attribute must be in {min, recovery, work, full}.
|
|
189
|
+
- ``CNST`` with ``severity:blocking`` MUST have ``survive:min``.
|
|
190
|
+
- ``attrs-pos`` with arity mismatch MUST error.
|
|
191
|
+
- No secrets in clear (API keys, passwords, tokens, etc.).
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
if kind is None:
|
|
195
|
+
kind = infer_document_kind(doc, doc.meta.get("path"))
|
|
196
|
+
|
|
197
|
+
findings: List[dict] = []
|
|
198
|
+
{e.sigil for _, e in doc.iter_entries()}
|
|
199
|
+
|
|
200
|
+
# --- $0 section integrity (v1.1.5 P0-1) ------------------------------
|
|
201
|
+
# $0 is structural metadata, NOT working memory. Only glossary
|
|
202
|
+
# declaration sigils (GSIG/GTYP/GMIC/GCON) are allowed as entries
|
|
203
|
+
# inside $0. Any other sigil is operational memory hidden from
|
|
204
|
+
# HCORTEX (which omits $0 by design) — a critical governance breach.
|
|
205
|
+
GLOSSARY_ENTRY_SIGILS = frozenset({"GSIG", "GTYP", "GMIC", "GCON"})
|
|
206
|
+
for sec in doc.sections:
|
|
207
|
+
if sec.id != "$0":
|
|
208
|
+
continue
|
|
209
|
+
for entry in sec.entries:
|
|
210
|
+
if entry.sigil in GLOSSARY_ENTRY_SIGILS:
|
|
211
|
+
continue
|
|
212
|
+
findings.append({
|
|
213
|
+
"code": E033_ZERO_SECTION_MEMORY_ENTRY,
|
|
214
|
+
"message": (
|
|
215
|
+
f"$0 MUST NOT contain operational entries; found "
|
|
216
|
+
f"{entry.sigil}:{entry.name} in $0. $0 is structural "
|
|
217
|
+
"metadata only (SKILL.md §4.1). Move operational "
|
|
218
|
+
"entries to $1 or later sections."
|
|
219
|
+
),
|
|
220
|
+
"section": "$0",
|
|
221
|
+
"sigil": entry.sigil,
|
|
222
|
+
"entry": entry.name,
|
|
223
|
+
"severity": "error",
|
|
224
|
+
"bypassable": False, # v1.1.5 P0-1: non-bypassable
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
# --- Nivel 1: skill MUST NOT contain live state ----------------------
|
|
228
|
+
if kind.kind == "skill":
|
|
229
|
+
for sec, entry in doc.iter_entries():
|
|
230
|
+
if sec.id == "$0":
|
|
231
|
+
continue # v1.1.5 P0-2: $0 is structural; E033 handles $0 entries
|
|
232
|
+
if not _is_live_entry(entry):
|
|
233
|
+
continue
|
|
234
|
+
if entry.sigil in LIVE_STATE_SIGILS:
|
|
235
|
+
findings.append({
|
|
236
|
+
"code": E023_LEVEL1_LIVE_STATE,
|
|
237
|
+
"message": (
|
|
238
|
+
f"Nivel 1 (skill) MUST NOT contain live state "
|
|
239
|
+
f"sigil {entry.sigil}:{entry.name}; only contracts/examples allowed"
|
|
240
|
+
),
|
|
241
|
+
"line": entry.line_start,
|
|
242
|
+
"section": sec.id,
|
|
243
|
+
"sigil": entry.sigil,
|
|
244
|
+
"entry": entry.name,
|
|
245
|
+
"severity": "error",
|
|
246
|
+
"bypassable": False, # v1.1.4 P0-1: governance invariant
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
# --- Nivel 2: brain MUST have FCS and OBJ active ---------------------
|
|
250
|
+
if kind.kind == "brain":
|
|
251
|
+
# v1.1.3 P0-4: `done` is a closed state, NOT an active one.
|
|
252
|
+
# v1.1.5 P0-2: entries under $0 do NOT count — $0 is structural
|
|
253
|
+
# metadata, not working memory. FCS/OBJ in $0 are invisible to
|
|
254
|
+
# HCORTEX and must not satisfy the operational brain invariant.
|
|
255
|
+
# v1.1.6 P0-3: FCS/OBJ with empty required fields (what:"", goal:"")
|
|
256
|
+
# do NOT count as active — a semantically empty focus/objective
|
|
257
|
+
# is not a real focus/objective.
|
|
258
|
+
active_status = {"current", "blocked"}
|
|
259
|
+
has_fcs = False
|
|
260
|
+
has_obj = False
|
|
261
|
+
for sec, entry in doc.iter_entries():
|
|
262
|
+
if sec.id == "$0":
|
|
263
|
+
continue # v1.1.5 P0-2: $0 entries don't count as operational
|
|
264
|
+
if entry.sigil == "FCS":
|
|
265
|
+
if not isinstance(entry.value, dict):
|
|
266
|
+
continue
|
|
267
|
+
status = entry.value.get("status")
|
|
268
|
+
if status not in active_status:
|
|
269
|
+
continue
|
|
270
|
+
# v1.1.6 P0-3: check that required fields are non-empty
|
|
271
|
+
what = entry.value.get("what")
|
|
272
|
+
if _is_field_empty(what):
|
|
273
|
+
continue # empty FCS doesn't count
|
|
274
|
+
has_fcs = True
|
|
275
|
+
elif entry.sigil == "OBJ":
|
|
276
|
+
if not isinstance(entry.value, dict):
|
|
277
|
+
continue
|
|
278
|
+
status = entry.value.get("status")
|
|
279
|
+
if status not in active_status:
|
|
280
|
+
continue
|
|
281
|
+
# v1.1.6 P0-3: check that required fields are non-empty
|
|
282
|
+
goal = entry.value.get("goal")
|
|
283
|
+
if _is_field_empty(goal):
|
|
284
|
+
continue # empty OBJ doesn't count
|
|
285
|
+
has_obj = True
|
|
286
|
+
if not has_fcs:
|
|
287
|
+
findings.append({
|
|
288
|
+
"code": E024_LEVEL2_MISSING_FOCUS,
|
|
289
|
+
"message": (
|
|
290
|
+
"Nivel 2 (brain) MUST have at least one active FCS entry "
|
|
291
|
+
"(status=current|blocked; 'done' is a closed state, not active)"
|
|
292
|
+
),
|
|
293
|
+
"section": "$2",
|
|
294
|
+
"sigil": "FCS",
|
|
295
|
+
"severity": "error",
|
|
296
|
+
"bypassable": False, # v1.1.4 P0-1: --force cannot remove operational focus
|
|
297
|
+
})
|
|
298
|
+
if not has_obj:
|
|
299
|
+
findings.append({
|
|
300
|
+
"code": E024_LEVEL2_MISSING_FOCUS,
|
|
301
|
+
"message": (
|
|
302
|
+
"Nivel 2 (brain) MUST have at least one active OBJ entry "
|
|
303
|
+
"(status=current|blocked; 'done' is a closed state, not active)"
|
|
304
|
+
),
|
|
305
|
+
"section": "$2",
|
|
306
|
+
"sigil": "OBJ",
|
|
307
|
+
"severity": "error",
|
|
308
|
+
"bypassable": False, # v1.1.4 P0-1: --force cannot remove operational objective
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
# --- Nivel 3: package MUST NOT contain live state --------------------
|
|
312
|
+
if kind.kind == "package":
|
|
313
|
+
for sec, entry in doc.iter_entries():
|
|
314
|
+
if sec.id == "$0":
|
|
315
|
+
continue # v1.1.5 P0-2: $0 is structural; E033 handles $0 entries
|
|
316
|
+
if not _is_live_entry(entry):
|
|
317
|
+
continue
|
|
318
|
+
if entry.sigil in LIVE_STATE_SIGILS:
|
|
319
|
+
findings.append({
|
|
320
|
+
"code": E029_LEVEL3_LIVE_STATE,
|
|
321
|
+
"message": (
|
|
322
|
+
f"Nivel 3 (package) MUST NOT contain live state "
|
|
323
|
+
f"sigil {entry.sigil}:{entry.name}"
|
|
324
|
+
),
|
|
325
|
+
"line": entry.line_start,
|
|
326
|
+
"section": sec.id,
|
|
327
|
+
"sigil": entry.sigil,
|
|
328
|
+
"entry": entry.name,
|
|
329
|
+
"severity": "error",
|
|
330
|
+
"bypassable": False, # v1.1.4 P0-1: governance invariant
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
# --- survive attribute validation (all kinds) ------------------------
|
|
334
|
+
for sec, entry in doc.iter_entries():
|
|
335
|
+
if not isinstance(entry.value, dict):
|
|
336
|
+
continue
|
|
337
|
+
survive = entry.value.get("survive")
|
|
338
|
+
if survive is None:
|
|
339
|
+
continue
|
|
340
|
+
if not isinstance(survive, str) or survive not in ALLOWED_SURVIVE:
|
|
341
|
+
findings.append({
|
|
342
|
+
"code": E025_INVALID_SURVIVE,
|
|
343
|
+
"message": (
|
|
344
|
+
f"{entry.sigil}:{entry.name} survive={survive!r} "
|
|
345
|
+
f"not in {sorted(ALLOWED_SURVIVE)}"
|
|
346
|
+
),
|
|
347
|
+
"line": entry.line_start,
|
|
348
|
+
"section": sec.id,
|
|
349
|
+
"sigil": entry.sigil,
|
|
350
|
+
"entry": entry.name,
|
|
351
|
+
"severity": "error",
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
# --- CNST:blocking must have survive:min -----------------------------
|
|
355
|
+
for sec, entry in doc.iter_entries():
|
|
356
|
+
if entry.sigil != "CNST":
|
|
357
|
+
continue
|
|
358
|
+
if not isinstance(entry.value, dict):
|
|
359
|
+
continue
|
|
360
|
+
severity = entry.value.get("severity")
|
|
361
|
+
survive = entry.value.get("survive")
|
|
362
|
+
if severity == "blocking" and survive != "min":
|
|
363
|
+
findings.append({
|
|
364
|
+
"code": E026_BLOCKING_NOT_P0,
|
|
365
|
+
"message": (
|
|
366
|
+
f"CNST:{entry.name} has severity=blocking but survive={survive!r}; "
|
|
367
|
+
"blocking constraints MUST have survive=min (P0)"
|
|
368
|
+
),
|
|
369
|
+
"line": entry.line_start,
|
|
370
|
+
"section": sec.id,
|
|
371
|
+
"sigil": entry.sigil,
|
|
372
|
+
"entry": entry.name,
|
|
373
|
+
"severity": "error",
|
|
374
|
+
"bypassable": False, # v1.1.4 P0-1: governance invariant
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
# --- attrs-pos arity check -------------------------------------------
|
|
378
|
+
for sec, entry in doc.iter_entries():
|
|
379
|
+
if entry.type != "attrs-pos":
|
|
380
|
+
continue
|
|
381
|
+
contract = doc.glossary.contract_for(entry.sigil)
|
|
382
|
+
if contract is None:
|
|
383
|
+
continue # already flagged by E007
|
|
384
|
+
# The body was parsed to a dict using the contract; check raw body
|
|
385
|
+
# for actual positional count by re-parsing raw
|
|
386
|
+
from .parser import _extract_body # type: ignore
|
|
387
|
+
body = _extract_body(entry.raw)
|
|
388
|
+
parts = [p for p in body.split("|")]
|
|
389
|
+
# Don't count empty trailing
|
|
390
|
+
expected = len(contract.fields)
|
|
391
|
+
if len(parts) > expected:
|
|
392
|
+
findings.append({
|
|
393
|
+
"code": E027_ATTRS_POS_ARITY,
|
|
394
|
+
"message": (
|
|
395
|
+
f"{entry.sigil}:{entry.name} attrs-pos has {len(parts)} positional "
|
|
396
|
+
f"values but contract declares {expected} fields "
|
|
397
|
+
f"({', '.join(contract.fields)}); excess values silently dropped"
|
|
398
|
+
),
|
|
399
|
+
"line": entry.line_start,
|
|
400
|
+
"section": sec.id,
|
|
401
|
+
"sigil": entry.sigil,
|
|
402
|
+
"entry": entry.name,
|
|
403
|
+
"severity": "error",
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
# --- critical sigil completeness (v1.1.3 P0-3) -----------------------
|
|
407
|
+
# Per SKILL.md §6, sigilos críticos tienen campos requeridos obligatorios.
|
|
408
|
+
# v1.1.2 los trataba como warning (W001_MISSING_FIELDS), lo que permitía
|
|
409
|
+
# persistir memoria semánticamente inválida por defecto. Ahora son
|
|
410
|
+
# errores no-bypassables para los sigilos críticos enumerados abajo.
|
|
411
|
+
from .validator import REQUIRED_FIELDS # local import to avoid cycle
|
|
412
|
+
CRITICAL_SIGILS_WITH_REQUIRED_FIELDS = frozenset(REQUIRED_FIELDS.keys())
|
|
413
|
+
for sec, entry in doc.iter_entries():
|
|
414
|
+
if entry.sigil not in CRITICAL_SIGILS_WITH_REQUIRED_FIELDS:
|
|
415
|
+
continue
|
|
416
|
+
if not isinstance(entry.value, dict):
|
|
417
|
+
continue
|
|
418
|
+
required = REQUIRED_FIELDS[entry.sigil]
|
|
419
|
+
missing = [f for f in required if f not in entry.value]
|
|
420
|
+
if missing:
|
|
421
|
+
findings.append({
|
|
422
|
+
"code": E032_CRITICAL_SIGIL_INCOMPLETE,
|
|
423
|
+
"message": (
|
|
424
|
+
f"{entry.sigil}:{entry.name} missing required fields {missing}; "
|
|
425
|
+
"critical sigils MUST be complete to persist (SKILL.md §6)"
|
|
426
|
+
),
|
|
427
|
+
"line": entry.line_start,
|
|
428
|
+
"section": sec.id,
|
|
429
|
+
"sigil": entry.sigil,
|
|
430
|
+
"entry": entry.name,
|
|
431
|
+
"severity": "error",
|
|
432
|
+
"bypassable": False, # v1.1.3 P0-3: not bypassable with --force
|
|
433
|
+
})
|
|
434
|
+
# v1.1.6 P0-1/P0-2: check for semantically empty fields (E034).
|
|
435
|
+
# A field that exists but is "", " ", or null is just as bad
|
|
436
|
+
# as a missing field — it produces a formally valid but
|
|
437
|
+
# semantically empty brain.
|
|
438
|
+
empty_fields = [
|
|
439
|
+
f for f in required
|
|
440
|
+
if f in entry.value and _is_field_empty(entry.value[f])
|
|
441
|
+
]
|
|
442
|
+
if empty_fields:
|
|
443
|
+
findings.append({
|
|
444
|
+
"code": E034_CRITICAL_REQUIRED_FIELD_EMPTY,
|
|
445
|
+
"message": (
|
|
446
|
+
f"{entry.sigil}:{entry.name} has empty required fields {empty_fields}; "
|
|
447
|
+
"critical fields MUST have non-empty values (SKILL.md §6). "
|
|
448
|
+
"A brain with FCS:primary{what:\"\"} is formally valid but "
|
|
449
|
+
"semantically empty."
|
|
450
|
+
),
|
|
451
|
+
"line": entry.line_start,
|
|
452
|
+
"section": sec.id,
|
|
453
|
+
"sigil": entry.sigil,
|
|
454
|
+
"entry": entry.name,
|
|
455
|
+
"severity": "error",
|
|
456
|
+
"bypassable": False, # v1.1.6 P0-1: non-bypassable
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
# --- secret scanning --------------------------------------------------
|
|
460
|
+
findings.extend(_scan_for_secrets(doc))
|
|
461
|
+
|
|
462
|
+
return findings
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# Secret scanner (M-03)
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
# Conservative patterns: catch obvious clear-text secrets without false
|
|
470
|
+
# positives on prose. Each pattern is (regex, label).
|
|
471
|
+
import re as _re
|
|
472
|
+
|
|
473
|
+
_SECRET_PATTERNS: List[tuple] = [
|
|
474
|
+
(_re.compile(r"(?i)\b(api[_-]?key)\b\s*[:=]\s*[\"']?[A-Za-z0-9_\-]{16,}", _re.IGNORECASE), "api_key"),
|
|
475
|
+
(_re.compile(r"(?i)\b(password|passwd|pwd)\b\s*[:=]\s*[\"']?[^\s\"']{4,}", _re.IGNORECASE), "password"),
|
|
476
|
+
(_re.compile(r"(?i)\b(token|secret|bearer)\b\s*[:=]\s*[\"']?[A-Za-z0-9_\-\.]{16,}", _re.IGNORECASE), "token"),
|
|
477
|
+
(_re.compile(r"(?i)\b(aws[_-]?secret[_-]?access[_-]?key)\b\s*[:=]\s*[\"']?[A-Za-z0-9/+=]{40}", _re.IGNORECASE), "aws_secret"),
|
|
478
|
+
(_re.compile(r"(?i)\b(private[_-]?key)\b\s*[:=]\s*[\"']?-----BEGIN"), "private_key"),
|
|
479
|
+
(_re.compile(r"https?://[^/\s]+:[^@/\s]+@"), "url_with_credentials"),
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _scan_for_secrets(doc: CortexDocument) -> List[dict]:
|
|
484
|
+
"""Scan for secrets in clear text.
|
|
485
|
+
|
|
486
|
+
v1.1.3 P0-2: secret findings are tagged ``bypassable=False`` so that
|
|
487
|
+
neither ``--force`` nor ``--no-validate-write`` can override them.
|
|
488
|
+
The SKILL forbids clear-text secrets unconditionally (Section 16.1);
|
|
489
|
+
forensic recovery must use a dedicated explicit flag
|
|
490
|
+
(``--unsafe-allow-secret-forensics``) that lives outside the normal
|
|
491
|
+
mutation path.
|
|
492
|
+
"""
|
|
493
|
+
findings: List[dict] = []
|
|
494
|
+
for sec, entry in doc.iter_entries():
|
|
495
|
+
# Check both the value dict and the raw text
|
|
496
|
+
texts_to_check: List[str] = []
|
|
497
|
+
if isinstance(entry.value, dict):
|
|
498
|
+
for k, v in entry.value.items():
|
|
499
|
+
if isinstance(v, str):
|
|
500
|
+
texts_to_check.append(f"{k}={v}")
|
|
501
|
+
elif isinstance(entry.value, str):
|
|
502
|
+
texts_to_check.append(entry.value)
|
|
503
|
+
texts_to_check.append(entry.raw)
|
|
504
|
+
for text in texts_to_check:
|
|
505
|
+
for pattern, label in _SECRET_PATTERNS:
|
|
506
|
+
if pattern.search(text):
|
|
507
|
+
findings.append({
|
|
508
|
+
"code": E031_SECRET_NOT_BYPASSABLE,
|
|
509
|
+
"message": (
|
|
510
|
+
f"possible {label} in clear text in {entry.sigil}:{entry.name}; "
|
|
511
|
+
"use REF:secret{provider:..., key:...} instead — "
|
|
512
|
+
"this error is NOT bypassable with --force"
|
|
513
|
+
),
|
|
514
|
+
"line": entry.line_start,
|
|
515
|
+
"section": sec.id,
|
|
516
|
+
"sigil": entry.sigil,
|
|
517
|
+
"entry": entry.name,
|
|
518
|
+
"severity": "error",
|
|
519
|
+
"bypassable": False, # v1.1.3 P0-2
|
|
520
|
+
})
|
|
521
|
+
break # one hit per entry is enough
|
|
522
|
+
else:
|
|
523
|
+
continue
|
|
524
|
+
break
|
|
525
|
+
return findings
|