feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b, closes #1413)
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain repo) sections 4 (consumption contract), 5.1/5.3/5.4, 1.2a. New shared lib repo-decl.sh: one consumption surface for the wrappers. Loads .mosaic/repo.json, classifies absent/invalid/valid via the WP1 validator (5.1: ALL consumers invoke the same script; 5.4 point 1: invalid = ABSENT + loud error naming file/key/reason), extracts the consumed fields, normalizes origin for 5.3 comparisons, validates transitions per 4.2 (a CLI flag is input, not authority), and resolves host:/ paths FAIL-CLOSED while MOSAIC_HOST_ROOT is unset (1.2a — no WP5b consumer resolves a path today; the helper exists so the first that needs one cannot guess). v1 declarations validate but carry no consumable fields: legacy behavior with a note. pr-create.sh: base precedence -B (validated as an allowed transition) -> declared integration_trunk -> legacy WP5a forge-default floor (unmanaged/absent/v1 per 4.3 reversible class, warn + legacy). Remote mismatch vs canonical_remote refuses (write path, 5.3). pr-merge.sh: transition validation per declared flow; the hardcoded main/next target check survives only for undeclared repos during the rollout window (4.3 irreversible class, loud warning). Remote mismatch refuses. ci-queue-wait.sh: ROUTE CONTEXT only (4.1, C4/jarvis F8/DR2 R9) — branch-selection semantics untouched, absence silent, invalid reported per 5.4. mosaic-worktree.sh: staged rule 4.4 — invalid declaration fails branch-creation loud, absent warns and proceeds, valid contributes policy ADVICE only (4.5: placement stays derived; the advisory worktree_root comparison runs only when MOSAIC_HOST_ROOT is set, per 1.2a warn-and-omit). Consuming via a self-located source line and set -u-safe env access. mutate-push-guard.sh: NO change — spec 4.1 names it for push-to-trunk protection, but the tool as shipped is a mutation-coverage meta-tool for push-guard.sh with no trunk-protection logic to consult; the disposition is documented in #1413 rather than force-feeding a fake consumption. All wrappers degrade SILENTLY to legacy behavior when repo-decl.sh is absent from a copied tool subset (a legal deployment shape; a note there broke single-line diagnostic contracts in test-pr-merge-message-field). test-repo-decl-consumption.sh: 67 assertions, green x2, hermetic; runs RED against pre-change tools via WP5B_TOOLS (49 red there — red-first evidence). Covers every 5.4 hostile-input class applicable to consumed fields (missing, malformed, unknown schema_version, v1, unknown key, bad refs, cross-field, userinfo URL, remote mismatch) plus transition validation, base precedence, absence policies, staged worktree rule, route context, and 1.2a fail-closed. Enumerated on the S1 surface (enumeration guard green: population 71, enumerated 57). Neighbor suites green: WP5a fallback suite, all six pr-merge suites, worktree large-repo, help/login/interactive suites. S1 chain failures (fleet-units systemd bus, invariant_r host Pi version, pr-edit credential-helper env) reproduce identically at origin/next — environmental, untouched by this diff. No TS/vitest lane touched (shell tools only).
This commit is contained in:
@@ -503,6 +503,28 @@ if [[ -z "$BRANCH" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# T51 WP5b (spec 4.1, C4/jarvis F8): the declaration adds ROUTE CONTEXT only.
|
||||
# Branch-selection semantics are UNCHANGED — the guard keeps inspecting the
|
||||
# exact head above. No declaration dependency gates the wait (4.3/DR2 R9:
|
||||
# blocking here adds a blocker with no safety gain); absence is silent.
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
&& [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh" ]; then
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh"
|
||||
repo_decl_load
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
route="feature"
|
||||
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
|
||||
route="trunk (integration head)"
|
||||
elif [[ "$BRANCH" == "$DECL_RELEASE" ]]; then
|
||||
route="release branch"
|
||||
fi
|
||||
echo "repo-decl: route context flow=$DECL_FLOW trunk=$DECL_TRUNK release=$DECL_RELEASE; guarded head '$BRANCH' is a $route head (spec 4.1)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
|
||||
@@ -192,6 +192,47 @@ cmd_new() {
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
# T51 WP5b (spec 4.4 staged rule + 4.5 advisory policy): placement stays
|
||||
# DERIVED; the declaration never moves the worktree. An INVALID declaration
|
||||
# fails branch-creation loud (a broken structure file must not ride a new
|
||||
# branch); an absent one warns (rollout window, Q-C); a valid one contributes
|
||||
# policy ADVICE only. The advisory worktree_root comparison runs only when
|
||||
# MOSAIC_HOST_ROOT is set (1.2a: warn-and-omit for advisory display).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
_rd="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/repo-decl.sh"
|
||||
if [ -f ""$_rd"" ]; then
|
||||
source ""$_rd""
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
case "$DECL_STATE" in
|
||||
invalid)
|
||||
repo_decl_report_invalid
|
||||
die "worktree branch-creation refused: the structure declaration is invalid (spec 4.4 — fix it before creating branches)"
|
||||
;;
|
||||
absent)
|
||||
repo_decl_warn_absent_irreversible "worktree branch-creation"
|
||||
;;
|
||||
valid)
|
||||
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
|
||||
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
|
||||
fi
|
||||
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
|
||||
dwt="$(repo_decl_path "$DECL_WT_ROOT" 2>/dev/null || true)"
|
||||
if [ -n "$dwt" ] && [ "${dwt%/}" != "${WT_ROOT%/}" ]; then
|
||||
echo "repo-decl: derived root $WT_ROOT diverges from the declared advisory worktree_root $dwt (advisory per spec 4.1/5.2 — placement stays derived)" >&2
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
|
||||
@@ -78,8 +78,8 @@ gitea_pr_create_api() {
|
||||
# historical "main" literal, which mistargeted every fallback PR on
|
||||
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
|
||||
local api_base=""
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
api_base="$BASE_BRANCH"
|
||||
if [[ -n "$EFFECTIVE_BASE" ]]; then
|
||||
api_base="$EFFECTIVE_BASE"
|
||||
else
|
||||
api_base=$(gitea_default_branch) || {
|
||||
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
|
||||
@@ -198,6 +198,52 @@ if [[ -z "$HEAD_BRANCH" ]]; then
|
||||
HEAD_BRANCH=$(git branch --show-current)
|
||||
fi
|
||||
|
||||
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
|
||||
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
|
||||
# not authority) when a consumable declaration exists
|
||||
# declared trunk (v2 declarations only) -> used directly
|
||||
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
EFFECTIVE_BASE="$BASE_BRANCH"
|
||||
case "$DECL_STATE" in
|
||||
invalid) repo_decl_report_invalid ;;
|
||||
esac
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
|
||||
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
# Write path: a normalized-remote mismatch refuses (spec 5.3).
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
|
||||
exit 1
|
||||
fi
|
||||
# trc 2 cannot happen here (state=valid): 0 = allowed
|
||||
else
|
||||
EFFECTIVE_BASE="$DECL_TRUNK"
|
||||
fi
|
||||
elif [[ -z "$BASE_BRANCH" ]]; then
|
||||
repo_decl_warn_absent_reversible "pr-create"
|
||||
fi
|
||||
|
||||
# Add issue reference to body if provided
|
||||
if [[ -n "$ISSUE" ]]; then
|
||||
if [[ -n "$BODY" ]]; then
|
||||
@@ -215,7 +261,7 @@ case "$PLATFORM" in
|
||||
github)
|
||||
CMD=(gh pr create --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
@@ -240,7 +286,7 @@ case "$PLATFORM" in
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
|
||||
# Handle labels for tea
|
||||
|
||||
@@ -137,9 +137,44 @@ HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
|
||||
# Target branches are validated against the declaration, NEVER hardcoded;
|
||||
# the legacy main/next check survives only for undeclared repos during the
|
||||
# rollout window (4.3 irreversible class, loud warning).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: PR '$HEAD_BRANCH' -> '$BASE_BRANCH' is not a declared transition (flow=$DECL_FLOW, trunk=$DECL_TRUNK, release=$DECL_RELEASE; spec 4.2)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "repo-decl: transition OK under flow=$DECL_FLOW (trunk=$DECL_TRUNK release=$DECL_RELEASE)" >&2
|
||||
else
|
||||
repo_decl_warn_absent_irreversible "pr-merge"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
|
||||
# git wrappers (T51 WP5b).
|
||||
#
|
||||
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
|
||||
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
|
||||
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
|
||||
# plus the per-tool behaviors (base precedence, transition validation, remote
|
||||
# fail-closed, absence policy, route context, staged worktree rule).
|
||||
#
|
||||
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
|
||||
# exits nonzero — the declaration-driven arms fail against tools that predate
|
||||
# the change (evidence captured in the WP5b report).
|
||||
#
|
||||
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
|
||||
# network, no live forge, no writes outside the sandbox.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
|
||||
TROOT="${TMPDIR:-/tmp}"
|
||||
|
||||
PASS=0 FAIL=0 FAILED_CASES=""
|
||||
FIXTURES=()
|
||||
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
|
||||
trap cleanup_all EXIT INT TERM
|
||||
|
||||
ok() { PASS=$((PASS + 1)); }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
|
||||
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
|
||||
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
|
||||
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
|
||||
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }
|
||||
|
||||
new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
|
||||
|
||||
# A fixture repo with the (possibly pre-change) tools + validator installed.
|
||||
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
|
||||
mkrepo() {
|
||||
local d="$1" decl_origin="${2:-}"
|
||||
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
|
||||
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
|
||||
git -C "$d" init -q -b feature/x
|
||||
git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
|
||||
mkdir -p "$d/.mosaic" "$d/sites"
|
||||
printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
|
||||
[ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
|
||||
cat > "$d/.mosaic/repo.json"
|
||||
# whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
|
||||
# not an invalid-file fixture: strip it to no file.
|
||||
grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
|
||||
}
|
||||
|
||||
# The canonical v2 declaration used by default (custom branch names prove the
|
||||
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
|
||||
DECL_TR='{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "dev-trunk",
|
||||
"release_branch": "prod-rel",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.example.test/acme/widgets",
|
||||
"canonical_clone": "host:/src/widgets",
|
||||
"worktree_root": "host:/src/widgets-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated"
|
||||
}'
|
||||
|
||||
decl_direct() { printf '{\n "schema_version": 2,\n "integration_trunk": "mainline",\n "release_branch": "mainline",\n "flow": "direct",\n "canonical_remote": "https://git.example.test/acme/widgets",\n "canonical_clone": "host:/src/widgets"\n}\n'; }
|
||||
|
||||
load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
|
||||
( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
|
||||
&& printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
|
||||
"$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
|
||||
}
|
||||
|
||||
echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
|
||||
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
|
||||
A="$(load_decl_in "$SB/r1")"
|
||||
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
|
||||
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
|
||||
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
|
||||
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
|
||||
assert_contains "A1 schema" "$A" "SCHEMA=2"
|
||||
|
||||
new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
|
||||
A="$(load_decl_in "$SB/r2")"
|
||||
assert_contains "A2 missing file = absent" "$A" "STATE=absent"
|
||||
|
||||
new_sb; mkrepo "$SB/r3" <<< '{ not json'
|
||||
A="$(load_decl_in "$SB/r3")"
|
||||
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
|
||||
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"
|
||||
|
||||
new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r4")"
|
||||
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
|
||||
A="$(load_decl_in "$SB/r5")"
|
||||
assert_contains "A5 v1 validates" "$A" "STATE=valid"
|
||||
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"
|
||||
|
||||
new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
|
||||
A="$(load_decl_in "$SB/r6")"
|
||||
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r7")"
|
||||
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r8")"
|
||||
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
|
||||
A="$(load_decl_in "$SB/r9")"
|
||||
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"
|
||||
|
||||
echo "== A2. transitions + remote + path anchoring =="
|
||||
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
|
||||
T=(); rc=0
|
||||
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
|
||||
repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
|
||||
repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
|
||||
repo_decl_check_transition other other || echo "same:REFUSED"
|
||||
repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
|
||||
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
|
||||
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
|
||||
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
|
||||
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
|
||||
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
|
||||
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
|
||||
repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
|
||||
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
|
||||
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
|
||||
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
|
||||
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
|
||||
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
|
||||
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
|
||||
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"
|
||||
|
||||
new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
|
||||
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
|
||||
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"
|
||||
|
||||
echo "== B. pr-create consumption =="
|
||||
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
|
||||
mkdir -p "$1/stub"
|
||||
cat > "$1/stub/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
url="${*: -1}"
|
||||
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
|
||||
case "$url" in
|
||||
*/api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
|
||||
*/pulls)
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
printf '%s\n' '{"number":42}'; exit 0 ;;
|
||||
*) printf '%s\n' '{}'; exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$1/stub/curl"
|
||||
}
|
||||
run_pr() { # $1 dir, rest args -> pr-create
|
||||
local prdir="$1"; shift
|
||||
PR_RC=0
|
||||
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
|
||||
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
|
||||
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
|
||||
PR_ERR="$(cat "$prdir/err.txt")"
|
||||
PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
|
||||
PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
|
||||
PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
|
||||
: > "$prdir/calls.log" 2>/dev/null || true
|
||||
rm -f "$prdir/payload.json"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
|
||||
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
|
||||
run_pr "$SB/b1" -t "T"
|
||||
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
|
||||
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B prod-rel
|
||||
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
|
||||
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
|
||||
assert_count "B2 no POST issued" 0 "$PR_POSTS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk
|
||||
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
|
||||
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
|
||||
run_pr "$SB/b5" -t "T"
|
||||
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
|
||||
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
|
||||
assert_count "B5 no POST" 0 "$PR_POSTS"
|
||||
|
||||
new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
|
||||
run_pr "$SB/b6" -t "T"
|
||||
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B6 payload base = forge default" "forge-default" "$base"
|
||||
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
|
||||
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"
|
||||
|
||||
new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
|
||||
run_pr "$SB/b7" -t "T"
|
||||
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
|
||||
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B7 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
|
||||
run_pr "$SB/b8" -t "T"
|
||||
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
|
||||
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B8 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
echo "== C. pr-merge transition validation (no hardcoded targets) =="
|
||||
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
|
||||
cat > "$1/tools/git/pr-metadata.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
|
||||
EOF
|
||||
chmod +x "$1/tools/git/pr-metadata.sh"
|
||||
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$1/tools/git/ci-queue-wait.sh"
|
||||
mkdir -p "$1/stub"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
|
||||
M_RC=0
|
||||
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
|
||||
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
|
||||
M_ERR="$(cat "$1/merr.txt")"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c1" dev-trunk feature/x
|
||||
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
|
||||
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel feature/x
|
||||
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
|
||||
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel dev-trunk
|
||||
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c4" main feature/x
|
||||
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
|
||||
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
|
||||
mkmerge "$SB/c4" trunk-x feature/x
|
||||
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
|
||||
mkmerge "$SB/c5" dev-trunk feature/x
|
||||
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
|
||||
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"
|
||||
|
||||
echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
|
||||
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
|
||||
mv "$1/home" "$SB/wthome" 2>/dev/null || true
|
||||
}
|
||||
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
|
||||
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"
|
||||
|
||||
new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
|
||||
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"
|
||||
|
||||
new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
|
||||
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
|
||||
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"
|
||||
|
||||
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
|
||||
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
|
||||
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
|
||||
E_ERR="$(cat "$SB/e1/eerr.txt")"
|
||||
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
|
||||
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
|
||||
|
||||
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
|
||||
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
|
||||
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
|
||||
|
||||
cleanup_all
|
||||
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED CASES:$FAILED_CASES"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL GREEN"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user