Files
stack/packages/mosaic/framework/tools/git/repo-decl.sh
T
2026-08-25 12:55:40 +00:00

199 lines
8.6 KiB
Bash
Executable File

#!/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
}