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,44 @@
1
+ """``cortex list`` — list entries in a .cortex file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ...crud.selectors import select
8
+ from ..commands import load_doc
9
+
10
+
11
+ def run(args) -> int:
12
+ doc = load_doc(args.input)
13
+ # Build selector from filters
14
+ if args.section and args.sigil:
15
+ sel = f"{args.section}/*"
16
+ # narrow by sigil after the fact
17
+ entries = [e for e in select(doc, sel) if e.sigil == args.sigil]
18
+ elif args.section:
19
+ entries = select(doc, f"{args.section}/*")
20
+ elif args.sigil:
21
+ entries = select(doc, f"{args.sigil}:*")
22
+ else:
23
+ entries = select(doc, "*:*")
24
+
25
+ if args.format == "json":
26
+ print(json.dumps({
27
+ "ok": True,
28
+ "count": len(entries),
29
+ "entries": [e.to_dict() for e in entries],
30
+ }, indent=2, ensure_ascii=False, default=str))
31
+ else:
32
+ if not entries:
33
+ print("(no entries match)")
34
+ return 0
35
+ lines = []
36
+ lines.append(f"{'SECTION':<6} {'SIGIL':<6} {'NAME':<24} {'TYPE':<10} {'HASH':<16}")
37
+ lines.append("-" * 70)
38
+ for e in entries:
39
+ lines.append(
40
+ f"{e.section:<6} {e.sigil:<6} {e.name:<24} {e.type:<10} "
41
+ f"{e.hash[:16] if e.hash else '':<16}"
42
+ )
43
+ print("\n".join(lines))
44
+ return 0
@@ -0,0 +1,79 @@
1
+ """``cortex micro`` — micro-token CRUD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ...core.errors import CortexError
8
+ from ...crud.mutations import (
9
+ add_micro_to_glossary,
10
+ delete_micro_from_glossary,
11
+ update_micro_in_glossary,
12
+ )
13
+ from ...crud.transactions import atomic_write_cortex
14
+ from ..commands import load_doc
15
+
16
+
17
+ def run_list(args) -> int:
18
+ doc = load_doc(args.input)
19
+ micro = list(doc.glossary.micro.values())
20
+ if args.format == "json":
21
+ print(json.dumps({
22
+ "ok": True,
23
+ "count": len(micro),
24
+ "micro": [m.to_dict() for m in micro],
25
+ }, indent=2, default=str))
26
+ else:
27
+ if not micro:
28
+ print("(no micro-tokens declared)")
29
+ return 0
30
+ lines = [f"{'TOKEN':<10} VALUE", "-" * 40]
31
+ for m in micro:
32
+ lines.append(f"{m.token:<10} {m.value}")
33
+ print("\n".join(lines))
34
+ return 0
35
+
36
+
37
+ def run_add(args) -> int:
38
+ doc = load_doc(args.input)
39
+ try:
40
+ add_micro_to_glossary(doc, token=args.token, value=args.value)
41
+ except CortexError as e:
42
+ print(f"error: {e}")
43
+ return 1
44
+ if args.dry_run:
45
+ print(json.dumps({"ok": True, "dry_run": True, "token": args.token}, indent=2))
46
+ return 0
47
+ result = atomic_write_cortex(doc, args.input, force=False)
48
+ print(json.dumps({"ok": True, "token": args.token, "written": result.to_dict()}, indent=2, default=str))
49
+ return 0
50
+
51
+
52
+ def run_update(args) -> int:
53
+ doc = load_doc(args.input)
54
+ try:
55
+ update_micro_in_glossary(doc, token=args.token, value=args.value)
56
+ except CortexError as e:
57
+ print(f"error: {e}")
58
+ return 1
59
+ if args.dry_run:
60
+ print(json.dumps({"ok": True, "dry_run": True, "token": args.token}, indent=2))
61
+ return 0
62
+ result = atomic_write_cortex(doc, args.input, force=False)
63
+ print(json.dumps({"ok": True, "token": args.token, "written": result.to_dict()}, indent=2, default=str))
64
+ return 0
65
+
66
+
67
+ def run_delete(args) -> int:
68
+ doc = load_doc(args.input)
69
+ try:
70
+ delete_micro_from_glossary(doc, token=args.token, force=args.force)
71
+ except CortexError as e:
72
+ print(f"error: {e}")
73
+ return 1
74
+ if args.dry_run:
75
+ print(json.dumps({"ok": True, "dry_run": True, "token": args.token}, indent=2))
76
+ return 0
77
+ result = atomic_write_cortex(doc, args.input, force=False)
78
+ print(json.dumps({"ok": True, "token": args.token, "written": result.to_dict()}, indent=2, default=str))
79
+ return 0
@@ -0,0 +1,45 @@
1
+ """``cortex move`` — move an entry to another section.
2
+
3
+ v1.1.2: post-mutation validation via :func:`~cortex.cli.commands.post_mutation_gate`
4
+ (so moving an entry to a section that violates level policy is rejected
5
+ unless ``--force``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from ...core.errors import CortexError
13
+ from ...crud.mutations import move_entry
14
+ from ...crud.transactions import atomic_write_cortex
15
+ from ..commands import load_doc, post_mutation_gate
16
+
17
+
18
+ def run(args) -> int:
19
+ doc = load_doc(args.input)
20
+ try:
21
+ entry = move_entry(doc, args.selector, args.to_section)
22
+ except CortexError as e:
23
+ print(f"error: {e}")
24
+ return 1
25
+
26
+ if args.dry_run:
27
+ print(json.dumps({
28
+ "ok": True, "dry_run": True,
29
+ "moved": entry.to_dict(),
30
+ }, indent=2, default=str))
31
+ return 0
32
+
33
+ # v1.1.2: post-mutation validation gate (same as add).
34
+ err = post_mutation_gate(doc, args)
35
+ if err is not None:
36
+ print(json.dumps(err, indent=2, default=str))
37
+ return 1
38
+
39
+ result = atomic_write_cortex(doc, args.input, force=args.force)
40
+ print(json.dumps({
41
+ "ok": True,
42
+ "moved": entry.to_dict(),
43
+ "written": result.to_dict(),
44
+ }, indent=2, default=str))
45
+ return 0
@@ -0,0 +1,80 @@
1
+ """``cortex new`` — create a new .cortex file from a template.
2
+
3
+ v1.1.2: ``--json`` now produces real JSON output (was text-only before).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+
11
+ from ...core.errors import CortexError, TemplateUnknownError
12
+ from ...crud.transactions import atomic_write_cortex
13
+ from ...templates import build_brain, build_package, build_skill
14
+
15
+
16
+ def run(args) -> int:
17
+ kind = args.kind
18
+ if kind == "brain":
19
+ doc = build_brain(
20
+ name=args.name or "project-brain",
21
+ domain=args.domain or "active work",
22
+ owner=args.owner or "agent",
23
+ language=args.language,
24
+ template=args.template,
25
+ with_diagrams=args.with_diagrams,
26
+ )
27
+ elif kind == "skill":
28
+ doc = build_skill(
29
+ name=args.name or "codec-cortex",
30
+ version=args.doc_version or "1.0.0",
31
+ domain=args.domain or "LLM/SLM contextual memory",
32
+ owner=args.owner or "agent",
33
+ language=args.language,
34
+ template=args.template,
35
+ with_diagrams=args.with_diagrams,
36
+ )
37
+ elif kind == "package":
38
+ doc = build_package(
39
+ name=args.name or "context_package",
40
+ version=args.doc_version or "0.1.0",
41
+ domain=args.domain or "specific domain",
42
+ owner=args.owner or "source",
43
+ language=args.language,
44
+ template=args.template,
45
+ with_diagrams=args.with_diagrams,
46
+ )
47
+ elif kind == "generic":
48
+ doc = build_brain(
49
+ name=args.name or "generic-cortex",
50
+ domain=args.domain or "generic",
51
+ owner=args.owner or "agent",
52
+ language=args.language,
53
+ template=args.template,
54
+ with_diagrams=args.with_diagrams,
55
+ )
56
+ else:
57
+ raise TemplateUnknownError(kind)
58
+
59
+ out_path = args.out
60
+ if os.path.exists(out_path) and not args.force:
61
+ raise CortexError(
62
+ "E015_ATOMIC_WRITE_FAILED",
63
+ f"output file exists: {out_path} (use --force to overwrite)",
64
+ )
65
+
66
+ result = atomic_write_cortex(doc, out_path, force=True, keep_backup=False)
67
+ payload = {
68
+ "ok": True,
69
+ "text": f"created {out_path} ({result.bytes_written} bytes)",
70
+ "path": out_path,
71
+ "bytes": result.bytes_written,
72
+ "kind": kind,
73
+ }
74
+ # v1.1.2: honour --json properly
75
+ json_mode = getattr(args, "_json_mode", False)
76
+ if json_mode:
77
+ print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
78
+ else:
79
+ print(payload["text"])
80
+ return 0
@@ -0,0 +1,90 @@
1
+ """``cortex recover`` — recover a legacy or non-conforming .cortex file.
2
+
3
+ Closes audit gap H-06: tolerates preambles, legacy column names, missing
4
+ ``$0``, and reconstructs a conforming document with RSK diagnostics for
5
+ every reconstructed sigil.
6
+
7
+ v1.1.6 P1-5: recover now validates the result and returns non-zero if
8
+ the recovered artefact is not conformant (e.g. still has E033/E034 errors
9
+ that recovery cannot fix).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ from ...core.errors import CortexError
19
+ from ...core.parser import parse_cortex
20
+ from ...core.writer import write_cortex
21
+ from ...core.validator import validate
22
+ from ...crud.transactions import atomic_write_text
23
+ from ...hcortex import recover_cortex
24
+ from ..commands import load_doc # noqa: F401 (re-exported helper)
25
+
26
+
27
+ def run(args) -> int:
28
+ if not os.path.exists(args.input):
29
+ raise CortexError("E013_NOT_FOUND", f"file not found: {args.input}")
30
+ with open(args.input, "r", encoding="utf-8") as f:
31
+ text = f.read()
32
+
33
+ result = recover_cortex(
34
+ text, path=args.input, strict=args.strict,
35
+ embed_aud_rsk=getattr(args, "embed_aud_rsk", False),
36
+ )
37
+ cortex_text = write_cortex(result.doc)
38
+
39
+ # v1.1.6 P1-5: validate the recovered artefact. If it still has
40
+ # non-bypassable errors, return non-zero so CI and automation can
41
+ # detect that recovery did not produce a conformant file.
42
+ reparsed = parse_cortex(cortex_text, path=args.input)
43
+ post_diags = validate(reparsed, strict=False)
44
+ post_errors = [d for d in post_diags if d.get("severity") == "error"]
45
+ conformant = len(post_errors) == 0
46
+
47
+ if args.out:
48
+ # Write even if non-conformant — the user may need to inspect
49
+ # the partial recovery. But return non-zero to signal the issue.
50
+ atomic_write_text(cortex_text, args.out, keep_backup=False)
51
+ if args.format == "json":
52
+ print(json.dumps({
53
+ "ok": conformant,
54
+ "input": args.input,
55
+ "out": args.out,
56
+ "reconstructed_glossary": result.reconstructed_glossary,
57
+ "preamble_lines": len(result.preamble),
58
+ "diagnostics": result.diagnostics,
59
+ "post_validation_errors": post_errors if not conformant else [],
60
+ "conformant": conformant,
61
+ }, indent=2, default=str))
62
+ else:
63
+ print(f"recovered {args.input} → {args.out}")
64
+ print(f" preamble stripped: {len(result.preamble)} line(s)")
65
+ print(f" reconstructed $0: {result.reconstructed_glossary}")
66
+ print(f" diagnostics: {len(result.diagnostics)}")
67
+ for d in result.diagnostics:
68
+ sev = d.get("severity", "info")
69
+ print(f" [{sev}] {d.get('code')}: {d.get('message')}")
70
+ if not conformant:
71
+ print(f" ⚠ WARNING: recovered artefact has {len(post_errors)} "
72
+ "validation error(s) — NOT conformant")
73
+ for e in post_errors[:5]:
74
+ print(f" [{e.get('code')}] {e.get('message', '')[:100]}")
75
+ else:
76
+ if args.format == "json":
77
+ print(json.dumps({
78
+ "ok": conformant,
79
+ "input": args.input,
80
+ "reconstructed_glossary": result.reconstructed_glossary,
81
+ "preamble_lines": len(result.preamble),
82
+ "diagnostics": result.diagnostics,
83
+ "conformant": conformant,
84
+ "cortex": cortex_text,
85
+ }, indent=2, default=str))
86
+ else:
87
+ sys.stdout.write(cortex_text)
88
+
89
+ # v1.1.6 P1-5: non-zero if not conformant
90
+ return 0 if conformant else 1
@@ -0,0 +1,93 @@
1
+ """``cortex render`` (alias: ``cortex decode``) — render .cortex to HCORTEX markdown.
2
+
3
+ v1.1.2: ``--json`` now produces real JSON output (was text-only before).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ from ...core.errors import CortexError
13
+ from ...crud.transactions import atomic_write_text
14
+ from ...hcortex import render_hcortex_edit, render_hcortex_read
15
+ from ..commands import load_doc
16
+
17
+
18
+ def run(args) -> int:
19
+ doc = load_doc(args.input)
20
+ # v1.1.3 P2-8: default mode is 'readable' so `decode <file>` works
21
+ # without --mode (matches the SKILL §22.2 planned UX).
22
+ mode = (args.mode or "readable").lower()
23
+
24
+ # Mode normalisation: legacy 'read'/'edit' → canonical
25
+ if mode == "read":
26
+ mode = "readable"
27
+ # Map profile arg to uppercase
28
+ profile = args.profile.upper() if args.profile else None
29
+
30
+ if mode == "edit":
31
+ md = render_hcortex_edit(doc, source=args.input)
32
+ elif mode in ("readable", "audit", "recovery", "full"):
33
+ # Map mode aliases to profile + with_source
34
+ if mode == "recovery":
35
+ profile = profile or "RECOVERY"
36
+ elif mode == "full":
37
+ profile = profile or "FULL"
38
+ args.with_source = True
39
+ elif mode == "audit":
40
+ args.with_source = True
41
+ md = render_hcortex_read(
42
+ doc,
43
+ with_source=getattr(args, "with_source", False),
44
+ profile=profile,
45
+ mode=mode.upper() if mode in ("recovery", "full") else "READABLE",
46
+ layout=getattr(args, "layout", None),
47
+ )
48
+ else:
49
+ raise CortexError(
50
+ "E021_INVALID_VALUE",
51
+ f"unknown render mode {args.mode!r}",
52
+ )
53
+
54
+ out = args.out
55
+ json_mode = getattr(args, "_json_mode", False)
56
+ if out:
57
+ # If --out exists and no --force, error (audit gap L-03).
58
+ if os.path.exists(out) and not getattr(args, "force", False):
59
+ raise CortexError(
60
+ "E015_ATOMIC_WRITE_FAILED",
61
+ f"output file exists: {out} (use --force to overwrite)",
62
+ )
63
+ result = atomic_write_text(md, out, keep_backup=False)
64
+ payload = {
65
+ "ok": True,
66
+ "text": (
67
+ f"rendered {args.input} → {out} "
68
+ f"({result.bytes_written} bytes, mode={mode}"
69
+ f"{f', profile={profile}' if profile else ''})"
70
+ ),
71
+ "input": args.input,
72
+ "out": out,
73
+ "mode": mode,
74
+ "profile": profile,
75
+ "bytes": result.bytes_written,
76
+ }
77
+ if json_mode:
78
+ print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
79
+ else:
80
+ print(payload["text"])
81
+ else:
82
+ # No --out: write markdown to stdout. --json wraps it in a payload.
83
+ if json_mode:
84
+ print(json.dumps({
85
+ "ok": True,
86
+ "input": args.input,
87
+ "mode": mode,
88
+ "profile": profile,
89
+ "markdown": md,
90
+ }, indent=2, ensure_ascii=False, default=str))
91
+ else:
92
+ sys.stdout.write(md)
93
+ return 0
@@ -0,0 +1,81 @@
1
+ """``cortex update`` (alias: ``cortex patch_update``) — update an existing entry.
2
+
3
+ v1.1.2: post-mutation validation via :func:`~cortex.cli.commands.post_mutation_gate`
4
+ (so an update that breaks a contract is rejected unless ``--force``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+
12
+ from ...core.errors import CortexError
13
+ from ...crud.mutations import update_entry
14
+ from ...crud.transactions import atomic_write_cortex
15
+ from ..commands import load_doc, post_mutation_gate
16
+
17
+
18
+ _SET_PAIR_RE = re.compile(r'^(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<val>.*)$')
19
+
20
+
21
+ def _parse_set_pairs(pairs):
22
+ out = {}
23
+ for p in pairs:
24
+ m = _SET_PAIR_RE.match(p)
25
+ if not m:
26
+ raise CortexError("E021_INVALID_VALUE", f"invalid --set pair: {p!r} (expected key=value)")
27
+ key = m.group("key")
28
+ val = m.group("val")
29
+ # Strip surrounding quotes
30
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
31
+ val = val[1:-1]
32
+ elif val == "true":
33
+ val = True
34
+ elif val == "false":
35
+ val = False
36
+ elif val.lower() in ("null", "none", "nil", "undefined"):
37
+ # v1.1.7 P0-3 + v1.1.8: convert null-like literals to None
38
+ val = None
39
+ elif re.fullmatch(r"-?\d+", val):
40
+ val = int(val)
41
+ elif re.fullmatch(r"-?\d+\.\d+", val):
42
+ val = float(val)
43
+ out[key] = val
44
+ return out
45
+
46
+
47
+ def run(args) -> int:
48
+ doc = load_doc(args.input)
49
+ set_ = _parse_set_pairs(args.set_pairs) if args.set_pairs else None
50
+ try:
51
+ entry = update_entry(
52
+ doc,
53
+ args.selector,
54
+ set_=set_,
55
+ replace_body=args.body,
56
+ append=args.append,
57
+ )
58
+ except CortexError as e:
59
+ print(f"error: {e}")
60
+ return 1
61
+
62
+ if args.dry_run:
63
+ print(json.dumps({
64
+ "ok": True, "dry_run": True,
65
+ "entry": entry.to_dict(),
66
+ }, indent=2, default=str))
67
+ return 0
68
+
69
+ # v1.1.2: post-mutation validation gate (same as add).
70
+ err = post_mutation_gate(doc, args)
71
+ if err is not None:
72
+ print(json.dumps(err, indent=2, default=str))
73
+ return 1
74
+
75
+ result = atomic_write_cortex(doc, args.input, force=args.force)
76
+ print(json.dumps({
77
+ "ok": True,
78
+ "entry": entry.to_dict(),
79
+ "written": result.to_dict(),
80
+ }, indent=2, default=str))
81
+ return 0
@@ -0,0 +1,162 @@
1
+ """``cortex canonicalize`` — normalize artefacts without changing semantics.
2
+
3
+ Canonical name: ``canonicalize`` (since v0.3.2).
4
+ Deprecated alias: ``v2-canonicalize`` (still accepted).
5
+
6
+ v0.3.2 — VIEW-aware behavior (B-01/B-05 fix):
7
+
8
+ The previous implementation always rewrote the .cortex using
9
+ :func:`write_cortex_v2`, which produced a canonically-formatted v2
10
+ artefact. That broke v1-render compatibility for artefacts that did
11
+ not declare VIEW directives (notably the benchmark corpus), causing
12
+ BCFNR=1.0 and HCORTEX empty output (issues B-01, B-03, B-05).
13
+
14
+ The new behavior is:
15
+
16
+ 1. Parse the artefact and check whether it has any operational VIEW
17
+ directives (``view.parse_view_entries_from_doc`` semantics).
18
+ 2. If it has VIEW directives AND ``--preserve`` was NOT passed:
19
+ - Apply full canonicalization (the original behavior, via
20
+ :func:`write_cortex_v2`). This is safe because the VIEW
21
+ directives guarantee reversibility.
22
+ 3. If it does NOT have VIEW directives, OR ``--preserve`` was passed:
23
+ - Emit a warning to stderr explaining that the artefact lacks
24
+ VIEW directives and that only whitespace/section ordering will
25
+ be normalized (structure preserved).
26
+ - Use :func:`write_cortex_v2_preserve` to produce a
27
+ structure-preserving serialization. This keeps v1-render
28
+ compatibility.
29
+ 4. In all cases, return 0 on success.
30
+
31
+ The ``--preserve`` flag is an explicit escape hatch for users who want
32
+ to force the structure-preserving path even when VIEW directives are
33
+ present (e.g. when preparing an artefact for a v1-only consumer).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ import sys
40
+
41
+ from ...core.errors import CortexError
42
+ from ...v2.parser import parse_cortex_v2
43
+ from ...v2.writer import (
44
+ write_cortex_v2,
45
+ write_cortex_v2_preserve,
46
+ has_view_directives,
47
+ )
48
+ from ...v2.view_renderer import render_hcortex
49
+ from ...v2.hcortex_parser import parse_hcortex
50
+ from ...v2.encoder import encode_cortex_from_ast
51
+
52
+
53
+ def _emit_warning(msg: str) -> None:
54
+ """Emit a warning to stderr (visible even when stdout is redirected)."""
55
+ print(f"WARNING: {msg}", file=sys.stderr)
56
+
57
+
58
+ def _canonicalize_cortex(text: str, preserve: bool) -> tuple[str, list[str]]:
59
+ """Canonicalize a CORTEX (non-HCORTEX) artefact.
60
+
61
+ Returns ``(output_text, warnings)``.
62
+ """
63
+ warnings: list[str] = []
64
+ doc = parse_cortex_v2(text)
65
+
66
+ has_views = has_view_directives(doc)
67
+
68
+ if preserve:
69
+ warnings.append(
70
+ "--preserve requested: structure-preserving canonicalization "
71
+ "(whitespace + section ordering only). VIEW directives, if any, "
72
+ "are NOT used for canonical rendering."
73
+ )
74
+ out = write_cortex_v2_preserve(doc)
75
+ return out, warnings
76
+
77
+ if not has_views:
78
+ warnings.append(
79
+ "artefact has no VIEW directives: applying structure-preserving "
80
+ "canonicalization (whitespace + section ordering only). The "
81
+ "output remains v1-render compatible. Add VIEW directives to "
82
+ "enable full v2 canonicalization, or pass --preserve to silence "
83
+ "this warning."
84
+ )
85
+ out = write_cortex_v2_preserve(doc)
86
+ return out, warnings
87
+
88
+ # Default path: VIEW directives present, no --preserve → full canonicalize.
89
+ out = write_cortex_v2(doc)
90
+ return out, warnings
91
+
92
+
93
+ def _canonicalize_hcortex(text: str, preserve: bool) -> tuple[str, list[str]]:
94
+ """Canonicalize an HCORTEX artefact.
95
+
96
+ HCORTEX → CORTEX → HCORTEX (canonicalize both legs).
97
+
98
+ The same VIEW-aware logic applies on the intermediate CORTEX: if it
99
+ has no VIEW directives, we fall back to the structure-preserving
100
+ serializer for the CORTEX leg, then render HCORTEX from it.
101
+ """
102
+ warnings: list[str] = []
103
+ hdoc = parse_hcortex(text, strict=False)
104
+ doc, _ = encode_cortex_from_ast(hdoc)
105
+
106
+ has_views = has_view_directives(doc)
107
+
108
+ if preserve or not has_views:
109
+ if preserve:
110
+ warnings.append(
111
+ "--preserve requested: HCORTEX canonicalization will use "
112
+ "the structure-preserving CORTEX leg."
113
+ )
114
+ else:
115
+ warnings.append(
116
+ "HCORTEX artefact has no VIEW directives (after decoding to "
117
+ "CORTEX): applying structure-preserving canonicalization on "
118
+ "the CORTEX leg. HCORTEX output will be re-rendered from "
119
+ "the preserved CORTEX."
120
+ )
121
+ # Use preserved CORTEX, then re-render HCORTEX
122
+ from ...v2.writer import write_cortex_v2_preserve
123
+ _ = write_cortex_v2_preserve(doc) # no-op effect, kept for symmetry
124
+ hcortex_md, _ = render_hcortex(doc)
125
+ return hcortex_md, warnings
126
+
127
+ # Default path: full canonicalize
128
+ cortex_text = write_cortex_v2(doc)
129
+ hcortex_md, _ = render_hcortex(doc)
130
+ _ = cortex_text # generated for symmetry; HCORTEX is the output
131
+ return hcortex_md, warnings
132
+
133
+
134
+ def run(args) -> int:
135
+ if not os.path.exists(args.input):
136
+ raise CortexError("E013_NOT_FOUND", f"file not found: {args.input}")
137
+
138
+ with open(args.input, "r", encoding="utf-8") as f:
139
+ text = f.read()
140
+
141
+ preserve = bool(getattr(args, "preserve", False))
142
+
143
+ is_hcortex = "internal_encoding: HCORTEX" in text
144
+
145
+ if is_hcortex:
146
+ out_text, warnings = _canonicalize_hcortex(text, preserve)
147
+ kind = "HCORTEX"
148
+ else:
149
+ out_text, warnings = _canonicalize_cortex(text, preserve)
150
+ kind = "CORTEX"
151
+
152
+ for w in warnings:
153
+ _emit_warning(w)
154
+
155
+ if args.out:
156
+ with open(args.out, "w", encoding="utf-8") as f:
157
+ f.write(out_text)
158
+ print(f"canonicalized {kind}: {args.input} → {args.out}")
159
+ else:
160
+ sys.stdout.write(out_text)
161
+
162
+ return 0