#!/usr/bin/env bash # repo-decl.sh — shared .mosaic/repo.json consumption for the git wrappers (T51 WP5b). # # Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain # repo) sections 4 (consumption contract), 5.3 (normalization), 5.4 # (enforcement points), 1.2a (root anchoring). ALL consumers invoke the SAME # WP1 validator (spec 5.1 — no in-process-only parsing of the declaration). # # Source this file, then call repo_decl_load once. It sets: # DECL_STATE absent | invalid | valid # DECL_FILE the declaration path that was inspected # DECL_ERROR the validator's error line when DECL_STATE=invalid # DECL_TRUNK / DECL_RELEASE / DECL_FLOW / DECL_REMOTE / DECL_POLICY / # DECL_WT_ROOT / DECL_CLONE (populated only when DECL_STATE=valid) # DECL_ORIGIN_N the normalized origin URL (when resolvable) # # Enforcement point 5.4(1): an invalid or unknown-version file counts as # ABSENT for behavior, PLUS a loud error naming the file and the validator's # key/reason — callers print DECL_ERROR (repo_decl_report_invalid) whenever # they loaded something that failed validation; they do not silently ignore a # broken file. # # Absence behavior (4.3) is the CALLER's policy (reversible vs irreversible; # managed vs unmanaged — the adoption register is WP6, so during rollout every # repo is unmanaged: warn + legacy). Helpers below provide the shared wordings. # # Root-dependent fields: consumers here read branch/flow/remote/policy only — # NO path resolution happens in this library. The one helper that would # resolve a host:/ path (repo_decl_path) fails closed while MOSAIC_HOST_ROOT # is unset (1.2a: never guess a root), for any future caller that needs it. # # No output on success; diagnostics go to stderr. REPO_DECL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DECL_VALIDATOR="$REPO_DECL_SCRIPT_DIR/../structure/validate-repo-json.sh" repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; } # Load and classify the declaration for the repo containing the current # directory. Never fatal — classification is the product. repo_decl_load() { DECL_STATE=absent DECL_FILE="" DECL_ERROR="" DECL_SCHEMA="" DECL_TRUNK=""; DECL_RELEASE=""; DECL_FLOW=""; DECL_REMOTE="" DECL_POLICY=""; DECL_WT_ROOT=""; DECL_CLONE="" DECL_ORIGIN_N="" local root root="$(git rev-parse --show-toplevel 2>/dev/null)" || { repo_decl_warn "no git repository — declaration consumption skipped" return 0 } DECL_FILE="$root/.mosaic/repo.json" [ -f "$DECL_FILE" ] || return 0 if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then # 5.1 mandates the shared validator; a missing validator is an # infrastructure failure, not an absent declaration. repo_decl_warn "validator not found at $REPO_DECL_VALIDATOR — treating declaration as invalid" DECL_STATE=invalid DECL_ERROR="VALIDATION_ERROR validator: the shared validator is missing" return 0 fi local vout if ! vout="$("$REPO_DECL_VALIDATOR" "$DECL_FILE" --mode display 2>&1)"; then DECL_STATE=invalid DECL_ERROR="$(printf '%s\n' "$vout" | grep -m1 'VALIDATION_ERROR' || printf '%s\n' "$vout" | head -1)" return 0 fi # Valid: extract the consumed fields via the same python stdlib the # ecosystem already uses. Field-level grammar was the validator's job. eval "$(python3 - "$DECL_FILE" <<'PY' import json, sys d = json.load(open(sys.argv[1])) def q(k): v = d.get(k, "") return v if isinstance(v, str) else "" sv = d.get("schema_version", 1) print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}") print(f"DECL_TRUNK={q('integration_trunk')!r}") print(f"DECL_RELEASE={q('release_branch')!r}") print(f"DECL_FLOW={q('flow')!r}") print(f"DECL_REMOTE={q('canonical_remote')!r}") print(f"DECL_POLICY={q('worktree_policy')!r}") print(f"DECL_WT_ROOT={q('worktree_root')!r}") print(f"DECL_CLONE={q('canonical_clone')!r}") PY )" || { DECL_STATE=invalid DECL_ERROR="VALIDATION_ERROR internal: field extraction failed" return 0 } DECL_STATE=valid # 5.3: normalize origin once for remote comparisons (read callers warn, # write callers refuse). An unresolvable origin is left empty — callers # treat empty as "cannot compare" and act per their read/write policy. local ourl if ourl="$(git remote get-url origin 2>/dev/null)" && [ -n "$ourl" ]; then DECL_ORIGIN_N="$("$REPO_DECL_VALIDATOR" --normalize-remote "$ourl" 2>/dev/null || true)" fi return 0 } # The mandatory loud error for an invalid file (5.4 point 1). Callers invoke # this whenever DECL_STATE=invalid, regardless of their proceed/refuse policy. repo_decl_report_invalid() { repo_decl_warn "declaration INVALID at $DECL_FILE — $DECL_ERROR" repo_decl_warn "treating the declaration as ABSENT (spec 5.4); legacy behavior follows" } # Shared absence wordings (4.3, rollout window: no adoption register yet, so # every repo is unmanaged; warn + legacy per the Q-C ruling). repo_decl_warn_absent_reversible() { # $1 = operation name repo_decl_warn "no .mosaic/repo.json — $1 is unmanaged during rollout: legacy behavior, no declaration guarantees (spec 4.3)" } repo_decl_warn_absent_irreversible() { # $1 = operation name repo_decl_warn "no .mosaic/repo.json — $1 proceeds on LEGACY assumptions during the rollout window; declaration-validated transitions unavailable (spec 4.3)" } # Remote comparison (5.3). rc 0 match/unknown, rc 1 mismatch. repo_decl_remote_matches() { [ "$DECL_STATE" = valid ] || return 0 [ -n "$DECL_ORIGIN_N" ] && [ -n "$DECL_REMOTE" ] || return 0 local want want="$("$REPO_DECL_VALIDATOR" --normalize-remote "$DECL_REMOTE" 2>/dev/null || true)" [ -n "$want" ] || return 0 [ "$DECL_ORIGIN_N" = "$want" ] } # Resolve a host:/-anchored declaration path (1.2a). Fails CLOSED while # MOSAIC_HOST_ROOT is unset or empty — never guesses a root. No WP5b consumer # calls this today; it exists so the first one that needs a path cannot # silently guess. repo_decl_path() { # $1 = host:/... value; prints the resolved absolute path local v="${1:-}" root="${MOSAIC_HOST_ROOT:-}" case "$v" in host:/*) ;; *) return 1 ;; esac if [ -z "$root" ]; then repo_decl_warn "MOSAIC_HOST_ROOT is unset — refusing to resolve '$v' (spec 1.2a fail-closed; never guess a root)" return 1 fi printf '%s/%s\n' "${root%/}" "${v#host:/}" } # Transition validation (4.2: a CLI flag is input, not authority). # rc 0 = allowed; rc 1 = forbidden (message on stderr); rc 2 = no valid # declaration (caller applies its absence policy). # flow=direct: base must be the trunk (trunk == release); head must # differ from it. # flow=trunk-release: feature->trunk allowed; trunk->release allowed (release # promotion: head IS the trunk); anything else refused — # feature->release explicitly REJECTED. repo_decl_check_transition() { # $1 head, $2 base [ "$DECL_STATE" = valid ] || return 2 local head="$1" base="$2" if [ -z "$head" ] || [ -z "$base" ]; then repo_decl_warn "transition check needs a head and a base (got head='$head' base='$base')" return 1 fi if [ "$head" = "$base" ]; then repo_decl_warn "forbidden transition: head '$head' equals base '$base'" return 1 fi case "$DECL_FLOW" in direct) if [ "$base" = "$DECL_TRUNK" ]; then return 0 fi repo_decl_warn "forbidden transition (flow=direct): base must be the trunk '$DECL_TRUNK', got '$base'" return 1 ;; trunk-release) if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then return 0 # feature -> trunk fi if [ "$head" = "$DECL_TRUNK" ] && [ "$base" = "$DECL_RELEASE" ]; then return 0 # release promotion: trunk -> release fi if [ "$base" = "$DECL_RELEASE" ] && [ "$head" != "$DECL_TRUNK" ]; then repo_decl_warn "forbidden transition (flow=trunk-release): feature->release is REJECTED (head '$head' -> release '$DECL_RELEASE'); promote via $DECL_TRUNK" return 1 fi repo_decl_warn "forbidden transition (flow=trunk-release): '$head' -> '$base' is not a declared transition (feature->$DECL_TRUNK or $DECL_TRUNK->$DECL_RELEASE)" return 1 ;; *) repo_decl_warn "unknown declared flow '$DECL_FLOW'" return 1 ;; esac }