← Files APODICTIC Development EditorARCHIVED FILE
scripts/crosslink.py
26.1 KB · Nov 3, 2026 · 18:57 UTC
#!/usr/bin/env python3
"""crosslink — letter <-> margin bidirectional cross-links (Annotated-Manuscript Increment 3).
`validate.sh crosslink <run_folder> [--strict]` shells out here. Increments 1-2 made the margin->letter
direction real (each margin comment ends "(See letter §F-...)"). This adds letter->margin: a crosslink
render injects a CriticMarkup back-link span immediately after each editorial-letter `<!-- finding: F-... -->`
marker whose finding has a manifest annotation, pointing at that finding's manuscript anchor:
{>>→ marked-up copy: <finding_id> @ <anchor.kind>:<anchor.value><<}
The anchor (kind:value) and the id are copied VERBATIM from the gated annotation manifest; the render
authors nothing. The letter is treated as a "second snapshot": the SAME reverse transform (delete every
{>> ... <<} span) proves no letter mutation, behind the SAME two-sided sigil precondition (the letter
must not already contain a CriticMarkup sigil). The crosslinked letter is a derived companion; the letter
of record is never touched.
Validator (`crosslink`):
X1 forward link each manifest annotation's comment carries "(See letter §<id>)" (margin->letter).
X2 reverse consist for each `finding:` marker of an annotated finding, a back-link carries that id and
an anchor string == "<kind>:<value>" from the manifest (no drift).
X3 no dangling every back-link id resolves to a manifest annotation (no phantom), and every
marker-of-an-annotated-finding has its back-link (no missing reverse link), by count.
W1 uncited a finding annotated but not cited by any letter `finding:` marker (advisory; ERROR
under --strict; override `<!-- override: crosslink-uncited F-... -->`).
X4 no mutation letter has no {>>/<<} sigil (precondition) AND reverse_transform(crosslinked)==letter.
Reuses annotation_manifest (reverse_transform + sigil constants + parse_manifest + comment_for) and
finding_trace (editorial-letter globs). The `<!-- finding: F-... -->` marker parser and the back-link
parser are NEW. See docs/annotated-manuscript.md (§Increment 3).
crosslink.py crosslink <run_folder|files...> [--strict]
crosslink.py render <run_folder> | render <letter> <manifest> [-o out.md]
crosslink.py build <run_folder> # alias for render that writes the crosslinked letter
crosslink.py --self-test
Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import glob
import json
import os
import re
import sys
from override_marker import override_targets # SSoT: code-span-stripped, boundary-matched override scan
try:
import annotation_manifest as am
except ImportError:
am = None
try:
import finding_trace as ft
except ImportError:
ft = None
try:
import apodictic_artifacts as art
except ImportError:
art = None
def _has_block(text, btype):
"""True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).
Classifying on parsed blocks — not a raw substring — keeps a file that merely *names* the marker
in prose from being misrouted (the 2026-06-20 resolver-hardening sweep). Gated by
validate.sh validator-conventions (M2)."""
if art is None:
return ("apodictic:%s" % btype) in (text or "")
return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))
_MANIFEST_GLOB = "*_Annotation_Manifest_*.md"
_CROSSLINKED_GLOB = "*_Crosslinked_Letter_*.md"
# Editorial-letter globs (mirror finding_trace's letter set; redeclared so a missing ft still resolves).
_LETTER_GLOBS = ("*_Core_DE_Synthesis_*.md", "*_Full_DE_*.md", "*_Editorial_Letter_*.md")
# The letter's per-finding citation marker (the injection target). NB this is the `finding:` marker
# specifically — NOT every F-id in an HTML comment (finding_trace.letter_cited_ids also returns
# severity_calibration ids); crosslink keys on the finding: marker set.
_FINDING_MARKER_RE = re.compile(r"<!--\s*finding:\s*(F-[A-Za-z0-9]+-[0-9]{2,})\s*-->")
# The back-link span: {>>→ marked-up copy: <fid> @ <kind>:<value><<}. The id makes it self-describing
# (matched by id, not position); `@` separates it from the verbatim kind:value (which may hold ':' '-'
# spaces '§' or be empty for `document`). Non-greedy stops at the first <<} (anchor values are sigil-safe).
_BACKLINK_RE = re.compile(r"\{>>→ marked-up copy: (F-[A-Za-z0-9]+-[0-9]{2,}) @ (.*?)<<\}", re.DOTALL)
# W1 override ids ("<!-- override: crosslink-uncited F-… -->") route through the shared override_marker
# SSoT — code spans stripped, slug boundary-matched. The finding-id payload pattern:
_OVERRIDE_FID = r"(F-[A-Za-z0-9]+-[0-9]{2,})"
# Any CriticMarkup span (the shared grammar; mirrors annotation_manifest._CM_SPAN_RE). Used to enumerate
# EVERY span so an authored/non-back-link span can't ride through X4's reverse transform. Fallback only
# when annotation_manifest isn't importable; otherwise am._CM_SPAN_RE is used.
_CM_SPAN_RE_FALLBACK = re.compile(r"\{>>.*?<<\}", re.DOTALL)
def _read(path):
try:
with open(path, encoding="utf-8") as fh:
return fh.read()
except (OSError, UnicodeDecodeError):
return None
def _sigils():
return (am._CM_OPEN, am._CM_CLOSE) if am is not None else ("{>>", "<<}")
def _anchor_str(anchor):
"""The verbatim '<kind>:<value>' embedded in a back-link (value may be empty for `document`)."""
a = anchor if isinstance(anchor, dict) else {}
return "%s:%s" % (a.get("kind", ""), a.get("value", ""))
def backlink_span(fid, anchor):
o, c = _sigils()
return "%s→ marked-up copy: %s @ %s%s" % (o, fid, _anchor_str(anchor), c)
def manifest_anchors(manifest_text):
"""{finding_id: annotation-dict} from the annotation manifest (Increment 1-2)."""
out = {}
if am is None or not manifest_text:
return out
obj, _errs = am.parse_manifest(manifest_text)
if not isinstance(obj, dict):
return out
for an in obj.get("annotations") or []:
if isinstance(an, dict) and an.get("finding_id"):
out[an["finding_id"]] = an
return out
# ---------------------------------------------------------------- render
def render(letter, anchors):
"""Inject a back-link span after each `<!-- finding: F-id -->` marker whose finding is annotated.
`anchors`: {finding_id: annotation-dict}. Raises ValueError on the two-sided sigil precondition
(the letter must not already contain a CriticMarkup sigil — else the reverse transform would not be
reversible and would silently delete the author's own span). Spans are spliced in DESCENDING offset
order (the Increment-2 renderer's rule) so insertions never perturb later offsets, and always land
AFTER the marker's `-->`, never inside the comment."""
o, c = _sigils()
if o in letter or c in letter:
raise ValueError("letter already contains a CriticMarkup sigil ({>> or <<}) — the reverse "
"transform would not be reversible; escape the letter first")
inserts = [] # (end_offset, marker_index, span)
for i, m in enumerate(_FINDING_MARKER_RE.finditer(letter)):
an = anchors.get(m.group(1))
if an is None:
continue
inserts.append((m.end(), i, backlink_span(m.group(1), an.get("anchor") or {})))
out = letter
for off, _i, span in sorted(inserts, key=lambda t: (t[0], t[1]), reverse=True):
out = out[:off] + span + out[off:]
return out
# ---------------------------------------------------------------- validator
def check(letter_text, crosslinked_text, manifest_text, strict=False):
"""Run X1-X4 + W1. Returns (code, lines)."""
lines, errs, warns = [], [], []
anchors = manifest_anchors(manifest_text)
if not anchors:
return 0, ["crosslink: no annotation manifest / annotations resolved — nothing to cross-link"]
if letter_text is None or crosslinked_text is None:
miss = " + ".join(n for n, t in (("editorial letter", letter_text),
("crosslinked letter", crosslinked_text)) if t is None)
return 2, ["crosslink: need an editorial letter AND a crosslinked letter (missing %s)" % miss]
o, c = _sigils()
# X1 — forward link: each annotation's comment carries the (See letter §<id>) margin->letter link
for fid, an in sorted(anchors.items()):
tail = "(See letter §%s.)" % fid
if tail not in str(an.get("comment", "")):
errs.append("X1 forward link: annotation %s comment lacks the margin->letter link '%s'"
% (fid, tail))
marker_fids = _FINDING_MARKER_RE.findall(letter_text)
marker_count = {}
for f in marker_fids:
marker_count[f] = marker_count.get(f, 0) + 1
# X3 marker integrity — the injected spans must not have CORRUPTED any finding marker (e.g. a span
# buried inside `<!-- finding: F-… {>>…<<}-->` reverses cleanly under X4 but breaks navigation). The
# crosslinked letter's `finding:` markers, in order, must equal the letter's.
crl_markers = [(m.group(1), m.end()) for m in _FINDING_MARKER_RE.finditer(crosslinked_text)]
if [f for f, _e in crl_markers] != marker_fids:
errs.append("X3 marker integrity: the crosslinked letter's `finding:` markers differ from the "
"letter's — a back-link span was injected inside (not after) a <!-- finding: --> marker")
# X2/X3 POSITIONAL pairing — enumerate EVERY CriticMarkup span (not just back-link-shaped ones):
# each must be a well-formed back-link sitting in the bytes IMMEDIATELY AFTER a marker's `-->`
# (start == that marker's end), carrying that marker's id, resolving to a manifest annotation, and
# matching its anchor. Iterating the SHARED span regex (not `_BACKLINK_RE`) is the firewall fix: an
# authored / malformed `{>> ... <<}` span would otherwise ride through X4 (which deletes ALL spans
# before the reverse-transform compare) un-noticed (Codex #103 review). This also catches swapped /
# mis-attributed back-links, orphan/in-prose/doubled spans, phantoms, and drift.
cm_span = am._CM_SPAN_RE if am is not None else _CM_SPAN_RE_FALLBACK
marker_end_fid = {e: f for f, e in crl_markers}
paired = {} # fid -> count of correctly-placed back-links
for sm in cm_span.finditer(crosslinked_text):
bm = _BACKLINK_RE.fullmatch(sm.group())
bstart = sm.start()
if bm is None:
errs.append("X3 un-manifested span: a CriticMarkup span in the crosslinked letter is not a "
"valid manifest back-link (authored or malformed) — %r" % (sm.group()[:60]))
continue
bl_fid, bl_anchor = bm.group(1), bm.group(2)
if bstart not in marker_end_fid:
errs.append("X3 misplaced back-link: a %s back-link is not immediately after a `finding:` "
"marker (orphan / in-prose / doubled)" % bl_fid)
continue
mk_fid = marker_end_fid[bstart]
if bl_fid != mk_fid:
errs.append("X2 mis-attributed back-link: the back-link after the %s marker carries %s "
"(swapped / wrong finding)" % (mk_fid, bl_fid))
continue
if bl_fid not in anchors:
errs.append("X3 phantom back-link: %s has a back-link but no manifest annotation" % bl_fid)
continue
want = _anchor_str(anchors[bl_fid].get("anchor"))
if bl_anchor != want:
errs.append("X2 anchor drift: back-link %s points at %r but the manifest anchor is %r"
% (bl_fid, bl_anchor, want))
continue
paired[bl_fid] = paired.get(bl_fid, 0) + 1
# X3 missing — each annotated finding cited by N markers needs N correctly-paired back-links
for fid in sorted(anchors):
if marker_count.get(fid, 0) > paired.get(fid, 0):
errs.append("X3 missing reverse link: %s is cited by %d letter marker(s) but has %d correctly-"
"placed back-link(s)" % (fid, marker_count[fid], paired.get(fid, 0)))
# W1 — annotated but not cited by any letter finding: marker (advisory; override-able)
overrides = ({t[0] for t in override_targets(crosslinked_text, "crosslink-uncited", _OVERRIDE_FID)}
| {t[0] for t in override_targets(letter_text, "crosslink-uncited", _OVERRIDE_FID)})
for fid in sorted(anchors):
if marker_count.get(fid, 0) == 0 and fid not in overrides:
warns.append("W1 uncited: %s is annotated but not cited by any letter `finding:` marker" % fid)
# X4 — no letter mutation: two-sided sigil precondition on the LETTER + reverse-transform identity
if o in letter_text or c in letter_text:
errs.append("X4 no letter mutation: the LETTER already contains a CriticMarkup sigil ({>> or <<}) "
"— the reverse transform is not reversible; escape the letter first")
elif am is not None and am.reverse_transform(crosslinked_text) != letter_text:
errs.append("X4 no letter mutation: deleting every {>> ... <<} span from the crosslinked letter "
"does NOT reproduce the letter byte-for-byte (prose altered, or a span is malformed)")
lines.append("crosslink: %d annotation(s), %d letter `finding:` marker(s), %d back-link(s)"
% (len(anchors), len(marker_fids), len(_BACKLINK_RE.findall(crosslinked_text))))
for e in errs:
lines.append(" ERROR: %s" % e)
for w in warns:
lines.append(" %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))
if errs or (strict and warns):
lines.append("crosslink: FAIL (%d error(s)%s)"
% (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
return 1, lines
if warns:
lines.append("WARN: crosslink: %d advisory flag(s) — see W1 above" % len(warns))
else:
lines.append("crosslink: PASS (bidirectional integrity + no letter mutation)")
return 0, lines
# ---------------------------------------------------------------- resolution + CLIs
def _newest(paths):
return max(paths, key=os.path.getmtime) if paths else None
def resolve_run_folder(folder):
man = _newest(glob.glob(os.path.join(folder, _MANIFEST_GLOB)))
crl = _newest(glob.glob(os.path.join(folder, _CROSSLINKED_GLOB)))
letter = None
for g in _LETTER_GLOBS:
letter = _newest(glob.glob(os.path.join(folder, g)))
if letter:
break
return letter, crl, man
def classify_files(paths):
letter = crl = man = None
for p in paths:
base = os.path.basename(p)
body = _read(p) or ""
if "_Annotation_Manifest_" in base or _has_block(body, "annotation"):
man = p
elif "_Crosslinked_Letter_" in base or "→ marked-up copy:" in body:
crl = p
else:
letter = p
return letter, crl, man
def run(paths, strict=False):
if len(paths) == 1 and os.path.isdir(paths[0]):
letter, crl, man = resolve_run_folder(paths[0])
else:
letter, crl, man = classify_files(paths)
if not man:
return 2, ["crosslink: no annotation manifest found (need a *_Annotation_Manifest_*.md or a "
"file with an apodictic:annotation block)"]
return check(_read(letter) if letter else None, _read(crl) if crl else None, _read(man), strict=strict)
def _crosslinked_name(letter_path):
"""`<Project>_Crosslinked_Letter_<runlabel>.md` derived from the letter filename."""
base = os.path.basename(letter_path)
stem = os.path.splitext(base)[0]
parts = stem.split("_")
project = parts[0] if parts else "Letter"
runlabel = parts[-1] if len(parts) > 1 else "run"
return "%s_Crosslinked_Letter_%s.md" % (project, runlabel)
def build(folder):
"""Render the crosslinked letter from the run folder's editorial letter + annotation manifest."""
letter, _crl, man = resolve_run_folder(folder)
if not letter or not man:
print("crosslink: need both an editorial letter and an annotation manifest in %s" % folder,
file=sys.stderr)
return 2
anchors = manifest_anchors(_read(man))
try:
crosslinked = render(_read(letter), anchors)
except ValueError as exc:
print("crosslink: %s" % exc, file=sys.stderr)
return 1
out = os.path.join(folder, _crosslinked_name(letter))
with open(out, "w", encoding="utf-8", newline="") as fh:
fh.write(crosslinked)
print("crosslink: wrote %s" % os.path.basename(out))
return 0
# ---------------------------------------------------------------- self-test
def run_self_test():
import tempfile
import shutil
rc = {"v": 0}
made = []
def chk(name, cond):
print(" %s: %s" % (name, "OK" if cond else "FAIL"))
if not cond:
rc["v"] = 1
# non-UTF8 artifact: _read must degrade to the unreadable path (None), never
# a traceback (the disposition_check adjacent-exception class, swept repo-wide)
import tempfile as _tf
_fd, _nu = _tf.mkstemp(suffix=".md")
with os.fdopen(_fd, "wb") as _fh:
_fh.write(b"\xff\xfenot utf-8\xff")
chk("non_utf8_read_returns_none", _read(_nu) is None)
os.unlink(_nu)
def comment(fid):
return "[Must-Fix · %s] m — fix class: f. (See letter §%s.)" % (fid, fid)
def manifest_md(annos):
obj = {"schema": "apodictic.annotation.v1", "project": "T", "runlabel": "r",
"snapshot_path": "s.md", "snapshot_sha256": "0" * 64, "snapshot_line_count": 1,
"annotations": annos}
return "# Manifest\n<!-- apodictic:annotation\n%s\n-->\n" % json.dumps(obj)
annos = [
{"finding_id": "F-A-01", "anchor": {"kind": "chapter", "value": "Ch 9"}, "comment": comment("F-A-01")},
{"finding_id": "F-B-01", "anchor": {"kind": "quote", "value": "10-20"}, "comment": comment("F-B-01")},
{"finding_id": "F-D-01", "anchor": {"kind": "document", "value": ""}, "comment": comment("F-D-01")},
]
manifest = manifest_md(annos)
anchors = manifest_anchors(manifest)
chk("manifest_parsed", set(anchors) == {"F-A-01", "F-B-01", "F-D-01"})
# A letter citing F-A-01 (twice), F-B-01, F-D-01 via finding: markers
letter = ("# Editorial Letter\n## What Needs Work\n"
"Pacing collapses at Chapter 9. <!-- finding: F-A-01 -->\n"
"The dialogue goes flat. <!-- finding: F-B-01 -->\n"
"(Again, the pacing.) <!-- finding: F-A-01 -->\n"
"A soft genre signal. <!-- finding: F-D-01 -->\n")
crosslinked = render(letter, anchors)
chk("render_reverses", am.reverse_transform(crosslinked) == letter)
chk("render_backlink_count", len(_BACKLINK_RE.findall(crosslinked)) == 4) # F-A-01 x2, F-B-01, F-D-01
chk("render_doc_empty_value", "@ document:<<}".replace("<<}", am._CM_CLOSE) in crosslinked)
chk("render_after_marker", "<!-- finding: F-A-01 -->{>>→ marked-up copy: F-A-01 @ chapter:Ch 9<<}"
.replace("{>>", am._CM_OPEN).replace("<<}", am._CM_CLOSE) in crosslinked)
# clean validate
code, ls = check(letter, crosslinked, manifest)
chk("clean_validate", code == 0)
# X4 — mutate one prose char in the crosslinked letter
code, ls = check(letter, crosslinked.replace("flat.", "flat!!"), manifest)
chk("x4_mutation", code == 1 and any("X4 no letter mutation" in x for x in ls))
# X4 / precondition — a letter that already contains a sigil
sigil_letter = "# L\nAn aside {>>not ours<<} here. <!-- finding: F-A-01 -->\n".replace("{>>", am._CM_OPEN).replace("<<}", am._CM_CLOSE)
chk("render_refuses_sigil_letter", _render_raises(sigil_letter, anchors))
code, ls = check(sigil_letter, sigil_letter, manifest)
chk("x4_letter_sigil_precondition", code == 1 and any("already contains a CriticMarkup sigil" in x for x in ls))
# X2 — anchor drift: a back-link whose anchor != manifest
drift = crosslinked.replace("F-A-01 @ chapter:Ch 9", "F-A-01 @ chapter:Ch 3", 1)
code, ls = check(letter, drift, manifest)
chk("x2_anchor_drift", code == 1 and any("X2 anchor drift" in x and "F-A-01" in x for x in ls))
# X3 — missing reverse link (a cited annotated finding with no back-link)
missing = _BACKLINK_RE.sub("", crosslinked, count=1) # drop the first back-link (an F-A-01 one)
code, ls = check(letter, missing, manifest)
chk("x3_missing", code == 1 and any("X3 missing reverse link" in x for x in ls))
bl_b = "%s→ marked-up copy: F-B-01 @ quote:10-20%s" % (am._CM_OPEN, am._CM_CLOSE)
bl_d = "%s→ marked-up copy: F-D-01 @ document:%s" % (am._CM_OPEN, am._CM_CLOSE)
# X2 — SWAPPED back-links: the F-B-01 marker followed by F-D-01's back-link and vice-versa. Each
# back-link is globally self-consistent (right id+anchor), so only the POSITIONAL pairing catches it.
swapped = crosslinked.replace("F-B-01 -->" + bl_b, "F-B-01 -->" + bl_d).replace("F-D-01 -->" + bl_d, "F-D-01 -->" + bl_b)
code, ls = check(letter, swapped, manifest)
chk("x2_swapped", code == 1 and any("X2 mis-attributed" in x for x in ls))
# X3 — a back-link injected INSIDE the marker reverses cleanly under X4 but breaks the marker
in_comment = crosslinked.replace("<!-- finding: F-B-01 -->" + bl_b, "<!-- finding: F-B-01 " + bl_b + "-->")
code, ls = check(letter, in_comment, manifest)
chk("x3_in_comment", code == 1 and any("marker integrity" in x or "misplaced" in x for x in ls))
chk("x3_in_comment_x4_blind", am.reverse_transform(in_comment) == letter) # X4 alone would not catch it
# X3 — misplaced back-link (appended in prose, not after a marker)
misplaced = crosslinked + bl_b
code, ls = check(letter, misplaced, manifest)
chk("x3_misplaced", code == 1 and any("X3 misplaced" in x for x in ls))
# X3 — an AUTHORED (non-back-link) CriticMarkup span rides through X4 (deletes all spans) unless we
# enumerate EVERY span and reject non-back-links (Codex #103 firewall finding).
authored = crosslinked + ("%sAUTHORED EXTRA NOTE%s" % (am._CM_OPEN, am._CM_CLOSE))
code, ls = check(letter, authored, manifest)
chk("x3_authored_span", code == 1 and any("X3 un-manifested span" in x for x in ls))
chk("x3_authored_span_x4_blind", am.reverse_transform(authored) == letter) # X4 alone misses it
# X3 — a PHANTOM back-link: after a marker for a finding with no manifest annotation
letter_ph = letter + "An unannotated finding. <!-- finding: F-Z-99 -->\n"
crl_ph = letter_ph.replace("<!-- finding: F-Z-99 -->",
"<!-- finding: F-Z-99 -->%s→ marked-up copy: F-Z-99 @ chapter:Ch 1%s"
% (am._CM_OPEN, am._CM_CLOSE))
code, ls = check(letter_ph, crl_ph, manifest)
chk("x3_phantom", code == 1 and any("X3 phantom" in x and "F-Z-99" in x for x in ls))
# W1 — annotated but uncited (F-D-01 not cited)
letter_no_d = letter.replace("A soft genre signal. <!-- finding: F-D-01 -->\n", "")
crl_no_d = render(letter_no_d, anchors)
code, ls = check(letter_no_d, crl_no_d, manifest)
chk("w1_uncited_advisory", code == 0 and any("W1 uncited" in x and "F-D-01" in x for x in ls))
chk("w1_uncited_strict_fails", check(letter_no_d, crl_no_d, manifest, strict=True)[0] == 1)
# ...silenced by an override marker carried in the letter (and so in the crosslinked letter)
letter_ovr = letter_no_d + "<!-- override: crosslink-uncited F-D-01 -->\n"
crl_ovr = render(letter_ovr, anchors)
code, ls = check(letter_ovr, crl_ovr, manifest)
chk("w1_override", code == 0 and not any("W1 uncited" in x for x in ls))
# X1 — a manifest comment missing the (See letter §id) link
annos_bad = json.loads(json.dumps(annos))
annos_bad[0]["comment"] = "[Must-Fix · F-A-01] m — fix class: f." # dropped the link tail
code, ls = check(letter, crosslinked, manifest_md(annos_bad))
chk("x1_missing_forward_link", code == 1 and any("X1 forward link" in x and "F-A-01" in x for x in ls))
# resolution — a run folder
d = tempfile.mkdtemp()
made.append(d)
with open(os.path.join(d, "Example_Annotation_Manifest_r.md"), "w", encoding="utf-8", newline="") as fh:
fh.write(manifest)
with open(os.path.join(d, "Example_Editorial_Letter_r.md"), "w", encoding="utf-8", newline="") as fh:
fh.write(letter)
chk("build_writes_crosslinked", build(d) == 0)
chk("run_folder_validates", run([d])[0] == 0)
chk("missing_manifest_usage", run([d + "/nope"])[0] == 2)
for d in made:
shutil.rmtree(d, ignore_errors=True)
# regression: a non-dict anchor must not crash _anchor_str (2026-06-20 sweep)
chk("crash_nondict_anchor", _anchor_str([1, 2, 3]) == ":" and _anchor_str(None) == ":")
print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
return rc["v"]
def _render_raises(letter, anchors):
try:
render(letter, anchors)
return False
except ValueError:
return True
def main(argv):
if "--self-test" in argv:
return run_self_test()
if len(argv) > 1 and argv[1] in ("render", "build"):
rest = argv[2:]
out = None
if "-o" in rest:
i = rest.index("-o")
out = rest[i + 1] if i + 1 < len(rest) else None
rest = rest[:i] + rest[i + 2:]
rest = [a for a in rest if not a.startswith("-")]
if len(rest) == 1 and os.path.isdir(rest[0]):
return build(rest[0])
if len(rest) < 2:
print("Usage: crosslink.py render <run_folder> | render <letter> <manifest> [-o out.md]")
return 2
anchors = manifest_anchors(_read(rest[1]))
try:
h = render(_read(rest[0]), anchors)
except ValueError as exc:
print("crosslink: %s" % exc, file=sys.stderr)
return 1
if out:
with open(out, "w", encoding="utf-8", newline="") as fh:
fh.write(h)
print("crosslink: rendered %s" % out)
else:
sys.stdout.write(h)
return 0
args = [a for a in argv[1:] if a != "crosslink"]
strict = "--strict" in args
paths = [a for a in args if not a.startswith("--")]
if not paths:
print("Usage: crosslink.py crosslink <run_folder|files...> [--strict] | render ... | --self-test")
return 2
code, lines = run(paths, strict=strict)
for ln in lines:
print(ln)
return code
if __name__ == "__main__":
sys.exit(main(sys.argv))
SHA-256: 48152c04478a56829f1c2c7cea29a1b890e5a21ca9913dae456deee66b8eefbf