feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b) (#1416)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-02 <[email protected]>
This commit was merged in pull request #1416.
This commit is contained in:
+198
@@ -0,0 +1,198 @@
|
||||
#!/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
|
||||
}
|
||||
Reference in New Issue
Block a user