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,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