diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 561112ac..8132fa8d 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -113,6 +113,40 @@ steps: # stub supplies the scale instead of the host's own checkout. - bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh + # Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2): + # .mosaic/repo.json is the machine-readable structure SSOT consumed by git + # wrappers and the T32 gate seat; this is its repo-side CI enforcement. + # Path-conditional: runs when the declaration, the vendored validator, or this + # pipeline config changes (manual runs always include it). Fails the pipeline + # on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule + # (--require-v2: edited/new declarations may not stay v1). The validator is + # vendored into the framework tree (spec §5.1 final home) — provenance in its + # header; the hostile-input suite (101 arms, hermetic) runs alongside so the + # gate's own instrument ships in the same commit as the gate. + structure-declaration: + image: *node_image + commands: + - apk add --no-cache bash git + # MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed + # for managed validation). CI has no host, so the step provisions an + # EXPLICIT fixture root — honest configuration for the resolution path, + # never a guess about a real host; the per-host containment checks are + # runtime concerns and do not run against a fixture. Grammar, schema, + # refs, flow, remote normalization, and path grammar all prove here. + - mkdir -p /tmp/t51-ci-hostroot + - bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2 + - bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh + environment: + MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot + when: + - event: pull_request + path: + include: + - '.mosaic/repo.json' + - 'packages/mosaic/framework/tools/structure/**' + - '.woodpecker/ci.yml' + - event: manual + # Canonical verify:release stage `upgrade-guard`. # Blocking gate (#791): a framework upgrade must never write or delete an # operator-owned path. The HARD GATE proves an unanticipated operator sentinel diff --git a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md index f2763b75..4e8c427a 100644 --- a/packages/mosaic/framework/guides/TOOLS-REFERENCE.md +++ b/packages/mosaic/framework/guides/TOOLS-REFERENCE.md @@ -219,6 +219,23 @@ Multi-instance support: `-a ` selects a named instance (e.g. `personal ~/.config/mosaic/tools/health/stack-health.sh -f json ``` +### Repo Structure Declaration (T51) + +```bash +# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar, +# ref grammar, cross-field rules, remote normalization; spec §5) +~/.config/mosaic/tools/structure/validate-repo-json.sh /.mosaic/repo.json + +# CI authoring rule: new/edited declarations must be schema_version 2 +~/.config/mosaic/tools/structure/validate-repo-json.sh /.mosaic/repo.json --require-v2 + +# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset) +~/.config/mosaic/tools/structure/validate-repo-json.sh /.mosaic/repo.json --mode display + +# Hermetic hostile-input suite (101 arms) +~/.config/mosaic/tools/structure/test-validate-repo-json.sh +``` + ### Shared Credential Loader ```bash diff --git a/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh b/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh new file mode 100755 index 00000000..c9ef0336 --- /dev/null +++ b/packages/mosaic/framework/tools/structure/test-validate-repo-json.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# test-validate-repo-json.sh — hostile-input suite for the T51 declaration validator. +# (Vendored with validate-repo-json.sh from mosaic-brain @ 515bcbab — see the +# validator header for provenance.) +# +# Hermetic: all fixtures in a tracked mktemp sandbox removed by an EXIT trap +# (pass and fail paths both — zero residue). No network, no real repos, no host +# state mutated. MOSAIC_HOST_ROOT is set/unset per arm via env only. +# +# T51P2RW1: arms extended per review T51P2R1 (F1-F5): root gate for ordinary +# v2 declarations (unset AND explicitly empty; display warns), git-grammar +# branch arms (double slash, dot component, control byte), contract-escape +# arms (list enums, invalid UTF-8, NaN — one VALIDATION_ERROR line, never a +# traceback), remote normalization (.git/ ordering, port preservation), and +# mirror-component fullmatch arms (trailing newline, control bytes). + +set -u + +HERE=$(cd "$(dirname "$0")" && pwd) +V="$HERE/validate-repo-json.sh" + +PASS=0; FAIL=0; FAILED="" +ok() { PASS=$((PASS+1)); } +bad() { FAIL=$((FAIL+1)); FAILED="$FAILED $1"; printf 'FAIL: %s\n' "$1" >&2; } + +SB=$(mktemp -d "${TMPDIR:-/tmp}/vrj-test.XXXXXX") +trap 'rm -rf "$SB"' EXIT + +fx() { printf '%s' "$2" > "$SB/$1"; } + +run() { # [env KV=V ...] -- args... + local envs=() + while [ "$1" != "--" ]; do envs+=("$1"); shift; done; shift + OUT=$(env "${envs[@]:-_=_}" bash "$V" "$@" 2>&1 < /dev/null; echo "__RC__$?") + RC=${OUT##*__RC__}; OUT=${OUT%__RC__*}; OUT=${OUT%$'\n'} +} +expect_ok() { local d="$1"; shift; run "$@"; if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK'; then ok; else bad "$d (rc=$RC out=$(printf '%s' "$OUT" | head -1))"; fi; } +expect_err() { # desc expected-substring [env... -- args...] + local d="$1" sub="$2"; shift 2 + run "$@" + if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub"; then ok + else bad "$d (rc=$RC, wanted error ~$sub, got: ${OUT%%$'\n'*})"; fi +} +expect_err_notrace() { # like expect_err, plus no traceback anywhere in output + local d="$1" sub="$2"; shift 2 + run "$@" + if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub" && ! printf '%s' "$OUT" | grep -q "Traceback"; then ok + else bad "$d (rc=$RC, wanted clean error ~$sub, got: ${OUT%%$'\n'*})"; fi +} + +STACK='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"orchestrator-precreated","notes":"x"}' +BRAIN='{"schema_version":2,"integration_trunk":"main","release_branch":"main","flow":"direct","canonical_remote":"https://git.example.invalid/acme/brain","canonical_clone":"host:/.mosaic","worktree_root":"host:/.mosaic-worktrees","worktree_policy":"orchestrator-precreated"}' +ROOT="$SB/hostroot"; mkdir -p "$ROOT" + +echo "== (0) syntax + version ==" +bash -n "$V" && ok || bad "bash -n" +run -- --version; [ "$RC" = 0 ] && case "$OUT" in validate-repo-json\ *) ok ;; *) bad "version output" ;; esac || bad "version rc" + +echo "== (1) spec examples: stack + brain OK (root set) ==" +fx stack.json "$STACK"; fx brain.json "$BRAIN" +expect_ok a1 MOSAIC_HOST_ROOT=$ROOT -- "$SB/stack.json" +expect_ok a2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/brain.json" + +echo "== (2) malformed JSON (stable contract, no traceback) ==" +fx bad.json '{"schema_version": 2, ' +expect_err_notrace b1 "json:" -- "$SB/bad.json" +fx arr.json '[1,2]' +expect_err_notrace b2 "top level" -- "$SB/arr.json" +printf '\xff\xfe{"schema_version":2}' > "$SB/utf8.json" +expect_err_notrace b3 "UTF-8" MOSAIC_HOST_ROOT=$ROOT -- "$SB/utf8.json" +fx nan.json '{"schema_version":NaN}' +expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json" + +echo "== (3) unknown schema_version = ABSENT-loud ==" +fx v3.json "${STACK/schema_version\":2/schema_version\":3}" +expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json" + +echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) ==" +fx v1.json '{"integration_trunk":"next","release_branch":"main"}' +expect_ok d1 -- "$SB/v1.json" +expect_err d2 "schema_version" -- --require-v2 "$SB/v1.json" +fx v1x.json '{"integration_trunk":"next","release_branch":"main","notes":"no"}' +expect_err d3 "x_extensions" -- "$SB/v1x.json" + +echo "== (5) unknown top-level key rejected; x_extensions home OK ==" +fx unk.json "${STACK%\}*},\"typo_key\":1}" +expect_err e1 "typo_key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/unk.json" +fx ext.json "${STACK%\}*},\"x_extensions\":{\"future\":true}}" +expect_ok e2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/ext.json" + +echo "== (6) flow: required (no defaulting) + cross-field ==" +fx noflow.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["flow"]; print(json.dumps(d))')" +expect_err f1 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noflow.json" +fx xdirect.json "${STACK/\"trunk-release\"/\"direct\"}" +expect_err f2 "direct" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xdirect.json" +fx xtr.json "${BRAIN/\"direct\"/\"trunk-release\"}" +expect_err f3 "trunk-release" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xtr.json" + +echo "== (7) dot-segment / empty-segment / tilde escapes ==" +fx dots.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/../secrets\"}" +expect_err g1 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dots.json" +fx dot1.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/./mosaic-stack\"}" +expect_err g2 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dot1.json" +fx empty.json "${STACK/host:\/src\/mosaic-stack\"/host://src/mosaic-stack\"}" +expect_err g3 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/empty.json" +fx tild.json "${STACK/host:\/src\/mosaic-stack\"/~jw/src/mosaic-stack\"}" +expect_err g4 "tilde" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tild.json" +fx tailslash.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/mosaic-stack/\"}" +expect_err g5 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tailslash.json" +fx noanchor.json "${STACK/host:\/src\/mosaic-stack\"//src/mosaic-stack\"}" +expect_err g6 "host:/" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noanchor.json" + +echo "== (8) branch-name grammar (delegated to git check-ref-format, F2) ==" +fx badbr.json "${STACK/\"next\"/\"bad..name\"}" +expect_err h1 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/badbr.json" +fx sp.json "${STACK/\"next\"/\"fea ture\"}" +expect_err h2 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/sp.json" +fx lock.json "${STACK/\"next\"/\"feature/x.lock\"}" +expect_err h3 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/lock.json" +fx slash.json "${STACK/\"next\"/\"feature/x\"}" +expect_ok h4 MOSAIC_HOST_ROOT=$ROOT -- "$SB/slash.json" +fx dslash.json "${STACK/\"next\"/\"feature//x\"}" +expect_err h5 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dslash.json" +fx hidden.json "${STACK/\"next\"/\"feature/.hidden\"}" +expect_err h6 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/hidden.json" +fx ctrl.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="feature/\x01x"; print(json.dumps(d))')" +expect_err h7 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/ctrl.json" + +echo "== (8b) reflog shorthand rejected independent of ambient checkout history (B1) ==" +# Hermetic repo WITH checkout history: proves '@{-1}' (which git would expand to +# 'main' from THIS repo's reflog) is still refused by the pre-delegation gate. +HISTREPO="$SB/histrepo"; mkdir -p "$HISTREPO" +(cd "$HISTREPO" && git init -q -b main . \ + && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m m \ + && git checkout -q -b feature/x \ + && git checkout -q main \ + && git check-ref-format --branch "@{-1}" >/dev/null 2>&1 && echo "ambient-expandable" || echo "not-expandable") \ + | grep -q ambient-expandable && ok || bad "fixture repo failed to make @{-1} expandable" +fx atminus1.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{-1}"; print(json.dumps(d))')" +# Run the validator from INSIDE the history repo via command substitution so the +# assertion runs in the PARENT shell (T51P2R3 B1: the previous ( subshell ) form +# mutated ok/bad counters only in a dead subshell — FAIL printed, suite rc 0). +OUTX=$(cd "$HISTREPO" && MOSAIC_HOST_ROOT=$ROOT bash "$V" "$SB/atminus1.json" 2>&1 &1 [--mode managed|display] [--require-v2] +# validate-repo-json.sh --mirror-path # §3.1 component check +# validate-repo-json.sh --normalize-remote # §5.3, prints normalized +# validate-repo-json.sh --version +# +# Output: OK (exit 0) | VALIDATION_ERROR : (exit 1) | warnings on stderr. +set -euo pipefail + +VERSION="1.1.0+t51spec-r3+t51p2rw1" + +if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi + +exec python3 - "$@" <<'PYEOF' +import json, os, re, subprocess, sys, urllib.parse + +def err(key, reason): + print(f"VALIDATION_ERROR {key}: {reason}") + sys.exit(1) + +def warn(msg): + print(f"warning: {msg}", file=sys.stderr) + +def main(): + ARGS = sys.argv[1:] + MODE = "managed" + REQUIRE_V2 = False + + # ---- subcommands first (they take no file argument) ---- + def check_mirror_components(host, owner, repo): + # fullmatch: '$' must bind at true end (F5 — a trailing newline must + # NOT pass); the charset excludes control bytes outright. + comp_re = re.compile(r"[a-z0-9][a-z0-9.-]*") + for label, value in (("host", host), ("owner", owner), ("repo", repo)): + if not isinstance(value, str) or not value or not comp_re.fullmatch(value): + err("mirror-component", f"{label} {value!r} fails §3.1 charset ^[a-z0-9][a-z0-9.-]*$ (fullmatch, no '/', no delimiter, no control bytes)") + return f"projects/{host}/{owner}/{repo}/repo.json" + + def normalize_remote(url): + # R5-B1 part 1: reject raw control bytes BEFORE urlsplit — urlsplit + # silently strips TAB/LF/CR, so different input bytes would normalize + # to a different host. The raw bytes ARE the input; nothing may rewrite them. + for ch in url: + if ord(ch) < 0x20 or ord(ch) == 0x7F: + err("canonical_remote", "control byte in URL rejected before parsing (urlsplit would strip it and change the host)") + try: + p = urllib.parse.urlsplit(url) + except ValueError as e: + # py3.12 urlsplit itself validates bracketed hosts (ipaddress) and + # raises for garbage authorities — translate, never traceback. + err("canonical_remote", f"invalid URL authority: {e}") + if not p.scheme or not p.netloc: + err("canonical_remote", f"not a URL with scheme+host: {url!r}") + if p.username or p.password or "@" in (p.netloc or ""): + err("canonical_remote", "userinfo in URL is rejected (§5.3)") + scheme = p.scheme.lower() + # T51P2R4 B1: bracketing is detected from the RAW netloc ('[' prefix), + # not from a ':' in the parsed hostname — IPvFuture literals ([v1.fe80]) + # contain no colon and must not bypass the raw-authority proof. + bracketed = p.netloc.startswith("[") + if bracketed: + # Prove the RAW authority is exactly '[host]' + optional ':port' + # (case-normalized); any text after ']' is hostile/truncated input, + # rejected — never silently rewritten. + import re as _re + import ipaddress as _ip + m = _re.fullmatch(r"\[([^\]]*)\](?::([0-9]+))?", p.netloc) + if not m: + err("canonical_remote", f"malformed bracketed authority {p.netloc!r}: text after ']' is rejected (no silent truncation)") + payload = m.group(1) + # R5-B1 part 2: the bracket payload must be a REAL RFC literal — + # an IPv6 address (ipaddress parse) or an IPvFuture literal + # ("v" + HEXDIG+ + "." + unreserved / sub-delims / ":" only). + # Anything else inside brackets is rejected, closing the payload + # grammar as a class. + if _re.fullmatch(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]*", payload): + pass # IPvFuture (case-normalized below) + elif _re.fullmatch(r"[0-9A-Fa-f:.]+", payload): + # strict IPv6 lexical form (hex/colon/dot only — ipaddress alone + # would also accept scoped zone-ids like fe80::1%eth0, which are + # not valid URI host grammar unless %25-encoded) + try: + _ip.IPv6Address(payload) + except ValueError: + err("canonical_remote", + f"bracket payload {payload!r} is not a valid IPv6 address") + else: + err("canonical_remote", + f"bracket payload {payload!r} is neither a valid IPv6 address nor an IPvFuture literal (v+HEXDIG+.+unreserved/sub-delims/colon)") + host = f"[{payload.lower()}]" # brackets preserved (IPv6 + IPvFuture) + else: + # R6-B1: the non-bracketed branch — urlsplit PARSES but does not + # VALIDATE reg-name, and netloc-nonempty is not host presence. + # Split the raw authority ourselves (host[:port]) and validate the + # raw host against real grammar: unreserved / sub-delims / complete + # %HH octets (reg-name), or IPv4 dotted-quad (reg-name's numeric + # case). Port must be all digits. No branch trusts urlsplit alone. + import re as _re + raw_host, sep, raw_port = p.netloc.rpartition(":") + if sep and _re.fullmatch(r"[0-9]+", raw_port): + pass # host:port split + elif sep: + err("canonical_remote", f"invalid port {raw_port!r} in authority {p.netloc!r} (ASCII digits only)") + else: + raw_host, raw_port = p.netloc, None + if not raw_host: + err("canonical_remote", f"empty host in authority {p.netloc!r}") + # strict reg-name / IPv4 scan: unreserved + sub-delims, with '%' + # only inside complete %HH octets (IPv4 dotted-quad is a subset of + # this charset — digits and dots — so one scan covers both). + import re as _re + i = 0 + ok_host = True + while i < len(raw_host): + c = raw_host[i] + if c == "%": + if i + 2 >= len(raw_host) or not _re.fullmatch(r"[0-9A-Fa-f]{2}", raw_host[i+1:i+3]): + ok_host = False; break + i += 3 + elif c in "!$&'()*+,;=-._~" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9"): + # R7-B1: EXPLICIT ASCII only — str.isalnum() is Unicode-aware + # and admits non-ASCII letters/digits (é, full-width 0). Policy + # is ASCII-only reg-name; punycode xn-- is the sanctioned + # Unicode spelling and remains legal under this charset. + i += 1 + else: + ok_host = False; break + if not ok_host: + err("canonical_remote", f"host {raw_host!r} is not valid reg-name/IPv4 grammar (unreserved/sub-delims/complete %HH only)") + host = raw_host.lower() + port = p.port # None when absent; preserved whenever explicitly present (B2: incl. 0) + authority = host + (f":{port}" if port is not None else "") + path = p.path or "/" + # canonical trailing-slash + .git strip as ONE operation (F4): slash + # first, then .git, then any slash exposed by that strip. + path = path.rstrip("/") + if path.endswith(".git"): + path = path[:-4].rstrip("/") + return f"{scheme}://{authority}{path or ''}" + + if "--mirror-path" in ARGS: + idx = ARGS.index("--mirror-path") + parts = ARGS[idx + 1:] + if len(parts) != 3: + err("usage", "--mirror-path takes ") + print(check_mirror_components(*parts)) + sys.exit(0) + if "--normalize-remote" in ARGS: + idx = ARGS.index("--normalize-remote") + vals = ARGS[idx + 1:] + if len(vals) != 1: + err("usage", "--normalize-remote takes ") + print(normalize_remote(vals[0])) + sys.exit(0) + + # ---- arg parsing ---- + if not ARGS: + err("usage", "a repo.json path is required") + path = None + i = 0 + while i < len(ARGS): + a = ARGS[i] + if a == "--mode": + i += 1 + if i >= len(ARGS) or ARGS[i] not in ("managed", "display"): + err("usage", "--mode takes managed|display") + MODE = ARGS[i] + elif a == "--require-v2": + REQUIRE_V2 = True + elif a.startswith("--"): + err("usage", f"unknown option {a}") + else: + if path is not None: + err("usage", "multiple file arguments") + path = a + i += 1 + if path is None: + err("usage", "a repo.json path is required") + + # ---- load: strict UTF-8, strict RFC JSON (F3) ---- + try: + with open(path, "rb") as fh: + raw_bytes = fh.read() + except OSError as e: + err("file", str(e)) + try: + raw = raw_bytes.decode("utf-8") + except UnicodeDecodeError as e: + err("json", f"invalid UTF-8: {e}") + def _reject_constant(name): + raise ValueError(f"non-RFC JSON constant {name}") + try: + doc = json.loads(raw, parse_constant=_reject_constant) + except (json.JSONDecodeError, ValueError) as e: + err("json", f"malformed JSON: {e}") + if not isinstance(doc, dict): + err("json", "top level must be an object") + + HOST_ROOT = os.environ.get("MOSAIC_HOST_ROOT", "") + + V1_KEYS = {"integration_trunk", "release_branch"} + V2_REQUIRED = ["schema_version", "integration_trunk", "release_branch", "flow", + "canonical_remote", "canonical_clone"] + V2_OPTIONAL = {"worktree_root", "worktree_policy", "notes", "x_extensions"} + ENUM_FLOW = {"direct", "trunk-release"} + ENUM_POLICY = {"tool-managed", "orchestrator-precreated"} + + def check_branch(key, value): + # Delegate the full git branch grammar to git itself (F2). B1: reject + # reflog shorthand BEFORE delegation — `git check-ref-format --branch + # '@{-n}'` expands from the CALLER repo's checkout history, making + # validation cwd-dependent; a persistent declaration must never bind + # to ambient reflog state. + if not isinstance(value, str) or not value: + err(key, "must be a non-empty string") + if "@{" in value: + err(key, f"{value!r} contains '@{{' reflog/namespace shorthand — declarations must be literal branch names (B1)") + if value.startswith("refs/heads/"): # check-ref-format --branch strips this; we do not allow it + err(key, "bare branch name expected, not a full ref") + try: + r = subprocess.run(["git", "check-ref-format", "--branch", value], + capture_output=True) + except OSError as e: + err(key, f"cannot invoke git check-ref-format: {e}") + if r.returncode != 0: + err(key, f"{value!r} is not a valid git branch name (git check-ref-format, §5.2)") + + def check_host_path(key, value): + # §1.2a: host:/-anchored; canonical segment normalization; empty/./.. rejected + # BEFORE resolution; tilde rejected outright. + if not isinstance(value, str) or not value: + err(key, "must be a non-empty string") + if "~" in value: + err(key, "tilde-anchored path rejected (§1.2a: ~ binds to caller HOME)") + if not value.startswith("host:/"): + err(key, "must be host:/-anchored (§1.2a)") + rest = value[len("host:/"):] + if rest == "": + err(key, "no segments after host:/") + segments = rest.split("/") + for seg in segments: + if seg == "": + err(key, f"empty segment in {value!r} (canonical normalization, §1.2a)") + if seg in (".", ".."): + err(key, f"dot segment {seg!r} rejected before resolution (§1.2a)") + return segments + + # ---- version ---- + sv = doc.get("schema_version") + if "schema_version" in doc: + if not isinstance(sv, int) or isinstance(sv, bool): + err("schema_version", "must be an integer") + if sv not in (1, 2): + err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling") + version = sv + else: + version = 1 + warn("schema_version absent → v1 compatibility mode (two keys only)") + + if REQUIRE_V2 and version != 2: + err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2") + + # ---- v1 ---- + if version == 1: + for k in V1_KEYS: + check_branch(k, doc.get(k)) + extra = set(doc) - V1_KEYS + if extra: + err("x_extensions", f"unknown top-level keys in v1: {sorted(extra)}") + print("OK (v1)") + sys.exit(0) + + # ---- v2 required ---- + for k in V2_REQUIRED: + if k not in doc: + err(k, "required for v2 (§1.2)") + + check_branch("integration_trunk", doc["integration_trunk"]) + check_branch("release_branch", doc["release_branch"]) + + # type-check BEFORE membership (F3: list-typed enums must not traceback) + if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW: + err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)") + + unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL + if unknown: + err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions") + + if "worktree_policy" in doc and (not isinstance(doc["worktree_policy"], str) + or doc["worktree_policy"] not in ENUM_POLICY): + err("worktree_policy", f"must be one of {sorted(ENUM_POLICY)}") + if "notes" in doc and not isinstance(doc["notes"], str): + err("notes", "must be a string") + if "x_extensions" in doc and not isinstance(doc["x_extensions"], dict): + err("x_extensions", "must be an object") + for strkey in ("canonical_remote", "canonical_clone", "worktree_root"): + if strkey in doc and not isinstance(doc[strkey], str): + err(strkey, "must be a string") + + # ---- remote (§5.3) ---- + if not isinstance(doc["canonical_remote"], str): + err("canonical_remote", "must be a string") + else: + normalize_remote(doc["canonical_remote"]) + + # ---- paths (§1.2a) ---- + canonical_segments = check_host_path("canonical_clone", doc["canonical_clone"]) + wt_segments = None + if "worktree_root" in doc: + wt_segments = check_host_path("worktree_root", doc["worktree_root"]) + + # ---- cross-field (§5.2) ---- + trunk, rel, flow = doc["integration_trunk"], doc["release_branch"], doc["flow"] + if flow == "direct" and trunk != rel: + err("flow", "direct requires integration_trunk == release_branch (§5.2)") + if flow == "trunk-release" and trunk == rel: + err("flow", "trunk-release requires integration_trunk != release_branch (§5.2)") + + # ---- root gate (§1.2a fail-closed; T51P2R1 F1) ---- + # Every v2 declaration consumes a path (canonical_clone is required), so + # managed mode cannot proceed without a provable host anchor. Display mode + # warns and omits root-dependent resolution only. + if not HOST_ROOT: + if MODE == "managed": + err("MOSAIC_HOST_ROOT", + "unset or empty — managed validation of a v2 declaration consumes paths " + "(canonical_clone required); fail closed (§1.2a, DR3 X2b)") + else: + warn("host root unset; root-dependent resolution omitted (display mode, §1.2a)") + + if doc.get("worktree_policy") == "tool-managed": + if "worktree_root" not in doc: + err("worktree_policy", "tool-managed requires worktree_root (containment provable, §4.5)") + if HOST_ROOT: + root_real = os.path.realpath(HOST_ROOT) + # B3 fix: containment is tested on the LEXICAL normalized path, not + # on realpath of the joined result — a child symlink under the host + # root can no longer fake outside-ness. host:/ segments are always + # lexically under the root, so tool-managed fails universally until + # the anchor scheme grows a real outside-root form (J3 charter). + resolved = os.path.normpath(os.path.join(root_real, *wt_segments)) + if resolved == root_real or resolved.startswith(root_real + os.sep): + err("worktree_policy", + f"tool-managed worktree_root resolves inside MOSAIC_HOST_ROOT (§4.5: outside-root requirement)") + # no-root case: managed mode already failed at the gate above; display warned + + print("OK") + +try: + main() +except SystemExit: + raise +except Exception as e: # F3: no traceback may ever escape the contract + err("internal", f"input rejected (unexpected condition: {type(e).__name__})") +PYEOF