Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77afe6778 | ||
|
|
9014a510a9 |
@@ -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
|
||||
|
||||
@@ -219,6 +219,23 @@ Multi-instance support: `-a <instance>` 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 <repo>/.mosaic/repo.json
|
||||
|
||||
# CI authoring rule: new/edited declarations must be schema_version 2
|
||||
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.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 <repo>/.mosaic/repo.json --mode display
|
||||
|
||||
# Hermetic hostile-input suite (101 arms)
|
||||
~/.config/mosaic/tools/structure/test-validate-repo-json.sh
|
||||
```
|
||||
|
||||
### Shared Credential Loader
|
||||
|
||||
```bash
|
||||
|
||||
@@ -379,54 +379,8 @@ check_fleet_transport() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_structure_anchor_provisioning() {
|
||||
# T51 WP0b (spec §1.2a + PHASE2-MAP F7): audit the two declaration anchors.
|
||||
# Doctor runs from operator shells and CI where the launcher exports do not
|
||||
# exist, so this is an AUDIT ONLY — it never exports, writes, or fabricates
|
||||
# values for consumption. Four states (charter):
|
||||
# both present+nonempty PASS (values reported as paths only)
|
||||
# one missing/empty WARN naming the var + the launcher as authority
|
||||
# neither present INFORMATIONAL launcher-equivalent derivation,
|
||||
# explicitly non-authoritative, + launcher warning;
|
||||
# never an error by design (F7(b))
|
||||
# Severity follows the doctor's existing conventions: pass/note are quiet
|
||||
# (note unless --verbose), warn counts toward --fail-on-warn.
|
||||
local host_root="${MOSAIC_HOST_ROOT:-}" brain_home="${MOSAIC_BRAIN_HOME:-}"
|
||||
# T51P2WP0BRW B1: presence is tracked SEPARATELY from value — `${VAR:-}`
|
||||
# collapses exported-empty into genuinely-unset, which mis-filed both-empty
|
||||
# and the mixed empty/unset states as informational. Only BOTH-genuinely-
|
||||
# absent may be informational (charter state 3); any present-but-empty or
|
||||
# single-present state warns.
|
||||
local host_set=0 brain_set=0
|
||||
[[ -v MOSAIC_HOST_ROOT ]] && host_set=1
|
||||
[[ -v MOSAIC_BRAIN_HOME ]] && brain_set=1
|
||||
if [[ "$host_set" -eq 1 && "$brain_set" -eq 1 && -n "$host_root" && -n "$brain_home" ]]; then
|
||||
pass "Structure anchors provisioned: MOSAIC_HOST_ROOT=$host_root MOSAIC_BRAIN_HOME=$brain_home (paths reported only; not expanded, not consumed)"
|
||||
return
|
||||
fi
|
||||
if [[ "$host_set" -eq 0 && "$brain_set" -eq 0 ]]; then
|
||||
note "Structure anchors not provisioned in this environment. Launcher-equivalent derivation (INFORMATIONAL, NON-AUTHORITATIVE — seats receive the authoritative values from the launchers): MOSAIC_HOST_ROOT would default to the operator home; MOSAIC_BRAIN_HOME would default to the brain tree resolved at launch. Doctor does not guess values for consumption; it audits provisioning."
|
||||
note "Provision both anchors via the seat launchers (launch-seat.sh / launch-seat-claude.sh export them; see T51 spec §1.2a)."
|
||||
return
|
||||
fi
|
||||
# At least one variable is present (possibly empty), or exactly one exists:
|
||||
# every missing/empty anchor gets its own loud WARN naming the launchers.
|
||||
if [[ "$host_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is not set in this environment while MOSAIC_BRAIN_HOME is — declaration consumers fail closed without it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$host_root" ]]; then
|
||||
warn "MOSAIC_HOST_ROOT is present but EMPTY in this environment — declaration consumers fail closed without a usable value (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
if [[ "$brain_set" -eq 0 ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is not set in this environment while MOSAIC_HOST_ROOT is — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
elif [[ -z "$brain_home" ]]; then
|
||||
warn "MOSAIC_BRAIN_HOME is present but EMPTY in this environment — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
|
||||
fi
|
||||
}
|
||||
|
||||
check_fleet_transport
|
||||
|
||||
check_structure_anchor_provisioning
|
||||
|
||||
check_brain_home
|
||||
|
||||
# Legacy migration surfaces should no longer contain symlink trees.
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the structure-anchor provisioning check in `mosaic-doctor` (T51 WP0b).
|
||||
#
|
||||
# Same discipline as test-brain-home-check.sh: functions are extracted from the
|
||||
# shipped script (exact header + closing brace), never copied — a test carrying
|
||||
# its own copy of the logic keeps passing after the shipped copy changes.
|
||||
#
|
||||
# Four contract states (charter T51P2WP0B-20260824):
|
||||
# 1. both present+nonempty -> pass ([OK]), no warns, no notes
|
||||
# 2a. host missing, brain set -> warn naming MOSAIC_HOST_ROOT + launchers
|
||||
# 2b. brain missing, host set -> warn naming MOSAIC_BRAIN_HOME + launchers
|
||||
# 2c. present-but-EMPTY counts as missing (warns; NEVER informational)
|
||||
# 3. neither present -> informational notes, NON-AUTHORITATIVE, never warn
|
||||
# Arms include genuinely-UNSET (env -u) forms, not only empty strings.
|
||||
# Red control: empty-vs-unset distinction removed in a mutated copy -> suite red.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
|
||||
extract_function() {
|
||||
local name="$1"
|
||||
local extracted
|
||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
||||
printf '%s\n' "$extracted"
|
||||
}
|
||||
|
||||
for fn in check_structure_anchor_provisioning; do
|
||||
extract_function "$fn" >/dev/null
|
||||
done
|
||||
|
||||
# run_case LABEL EXPECT(ok|warn|note) [env assignments as args; -u VAR tokens for unset]
|
||||
run_case() {
|
||||
local label="$1" expect="$2"
|
||||
shift 2
|
||||
local envs=() unsets=()
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-u:*) unsets+=("${a#-u:}") ;;
|
||||
*) envs+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
local out warns notes oks
|
||||
# build the env command with proper -u flags (array expansion must not
|
||||
# glue '-u VAR' into one word)
|
||||
local cmd=(env)
|
||||
local e u
|
||||
# env(1) parses options only before the first assignment — -u flags FIRST
|
||||
for u in "${unsets[@]:-}"; do [ -n "$u" ] && cmd+=(-u "$u"); done
|
||||
for e in "${envs[@]:-}"; do [ -n "$e" ] && cmd+=("$e"); done
|
||||
cmd+=(bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function check_structure_anchor_provisioning); check_structure_anchor_provisioning")
|
||||
out=$("${cmd[@]}" 2>&1)
|
||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
oks=$(printf '%s\n' "$out" | grep -c '^\[OK\]' || true)
|
||||
if [[ "$expect" == ok && "$oks" -gt 0 && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -ge 1 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label (warned x$warns)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 && "$warns" -eq 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (oks=$oks warns=$warns notes=$notes)"
|
||||
fi
|
||||
}
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
HOST="$ROOT/host"
|
||||
BRAIN="$ROOT/brain"
|
||||
|
||||
# ── state 1: both present + nonempty → pass ────────────────────────────────
|
||||
run_case "both anchors present passes" ok \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2a: host missing (unset), brain set → exactly one warn ───────────
|
||||
run_case "unset host root warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
|
||||
# ── state 2b: brain missing (unset), host set → exactly one warn ───────────
|
||||
run_case "unset brain home warns" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" -u:MOSAIC_BRAIN_HOME
|
||||
|
||||
# ── state 2c-empty: present-but-empty counts as missing ────────────────────
|
||||
run_case "empty-string host root warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME="$BRAIN"
|
||||
run_case "empty-string brain home warns (empty != set)" warn \
|
||||
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── state 3: neither present (genuinely unset) → notes, never warn ─────────
|
||||
run_case "both unset yields non-authoritative notes" note \
|
||||
-u:MOSAIC_HOST_ROOT -u:MOSAIC_BRAIN_HOME
|
||||
run_case "both empty-string warns (empty is present, not absent)" warn \
|
||||
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME=
|
||||
run_case "host empty + brain unset warns" warn \
|
||||
MOSAIC_HOST_ROOT= -u:MOSAIC_BRAIN_HOME
|
||||
run_case "host unset + brain empty warns" warn \
|
||||
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME=
|
||||
|
||||
# ── red control (mutation): presence tracking removed → red ────────────────
|
||||
# Mutant regresses to the reviewed defect shape: presence derived from
|
||||
# NONEMPTINESS (the `${VAR:-}` collapse) instead of true -v tracking. Both-empty
|
||||
# then looks genuinely-absent and is mis-filed as informational; the both-empty
|
||||
# warn arm above finds no WARN and the suite reds.
|
||||
MUT="$ROOT/mosaic-doctor.mutant"
|
||||
sed 's/\[\[ -v MOSAIC_HOST_ROOT \]\] \&\& host_set=1/[[ -n "${MOSAIC_HOST_ROOT:-}" ]] \&\& host_set=1/; s/\[\[ -v MOSAIC_BRAIN_HOME \]\] \&\& brain_set=1/[[ -n "${MOSAIC_BRAIN_HOME:-}" ]] \&\& brain_set=1/' \
|
||||
"$DOCTOR" > "$MUT"
|
||||
if cmp -s "$DOCTOR" "$MUT"; then
|
||||
echo "SKIP red control (mutation anchor not found — sed pattern drifted)" >&2
|
||||
else
|
||||
mut_fn=$(sed -n "/^check_structure_anchor_provisioning() {/,/^}/p" "$MUT")
|
||||
outm=$(env MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME= bash -c \
|
||||
"warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $mut_fn; check_structure_anchor_provisioning" 2>&1)
|
||||
if printf '%s\n' "$outm" | grep -q '^\[NOTE\]'; then
|
||||
echo "ok - red control bites (mutant collapses empty into informational; shipped does not)"
|
||||
else
|
||||
fail "red control did not reproduce the regression shape (mutant output unexpected)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "structure anchor doctor check: all arms passed"
|
||||
@@ -29,6 +29,10 @@ packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured i
|
||||
# --- tools/tmux: require a live tmux server ---
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||
# The entry below is NOT covered by the #1017 signature block above: it was
|
||||
# signed by jarvis-enhance (dragon-lin, 2026-08-24) at a later base, for the
|
||||
# test added alongside the send-message.sh exact-target fix.
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-target.sh | requires a real tmux server on a throwaway socket, and specifically a MULTI-WINDOW session (the bug it guards is invisible on a single-window fixture); CI image ships no tmux; same burndown condition as its two siblings above
|
||||
|
||||
# --- single-suite directories: unmeasured in CI ---
|
||||
|
||||
|
||||
@@ -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 </dev/null; echo "__RC__$?")
|
||||
RCX=${OUTX##*__RC__}
|
||||
if [ "$RCX" = 1 ] && printf '%s' "$OUTX" | grep -q "VALIDATION_ERROR.*@{"; then ok
|
||||
else bad "@{-1} must be rejected inside a repo with checkout history (got rc=$RCX)"; fi
|
||||
fx atbrace.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{u}"; print(json.dumps(d))')"
|
||||
expect_err h9 "@{" MOSAIC_HOST_ROOT=$ROOT -- "$SB/atbrace.json"
|
||||
|
||||
echo "== (9) canonical_remote: userinfo, list-type, normalization (F3/F4) =="
|
||||
fx user.json "${STACK/https:\/\/git.mosaicstack.dev/https:\/\/bot:s3cret@git.mosaicstack.dev}"
|
||||
expect_err_notrace i1 "userinfo" MOSAIC_HOST_ROOT=$ROOT -- "$SB/user.json"
|
||||
fx listflow.json "${STACK/\"trunk-release\"/[\"trunk-release\"]}"
|
||||
expect_err_notrace i2 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listflow.json"
|
||||
fx listpol.json "${STACK/\"orchestrator-precreated\"/[\"tool-managed\"]}"
|
||||
expect_err_notrace i3 "worktree_policy" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listpol.json"
|
||||
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git/case ($OUT)"
|
||||
run -- --normalize-remote "https://git.mosaicstack.dev/mosaicstack/stack/"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev/mosaicstack/stack" ] && ok || bad "norm trailing slash ($OUT)"
|
||||
run -- --normalize-remote "git.mosaicstack.dev/mosaicstack/stack"
|
||||
[ "$RC" = 1 ] && ok || bad "schemeless must fail"
|
||||
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git/"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git-then-slash ($OUT)"
|
||||
run -- --normalize-remote "https://Git.Example.Invalid:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:8443/o/r" ] && ok || bad "port must be preserved ($OUT)"
|
||||
run -- --normalize-remote "https://[2001:db8::1]:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]:8443/o/r" ] && ok || bad "IPv6 must stay bracketed with port ($OUT)"
|
||||
run -- --normalize-remote "https://[2001:db8::1]/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]/o/r" ] && ok || bad "IPv6 must stay bracketed ($OUT)"
|
||||
run -- --normalize-remote "https://Git.Example.Invalid:0/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:0/o/r" ] && ok || bad "explicit port 0 must be preserved ($OUT)"
|
||||
run -- --normalize-remote "https://[::1].evil.example/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (.evil.example)"
|
||||
run -- --normalize-remote "https://[::1]x:8443/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (x:8443)"
|
||||
run -- --normalize-remote "https://[::1]x/o/r.git"
|
||||
[ "$RC" = 1 ] && ok || bad "suffix after ] must be rejected (x)"
|
||||
echo "== (9b) IPvFuture bracketed authorities (R4-B1: guard keys off raw netloc) =="
|
||||
run -- --normalize-remote "https://[v1.fe80]/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[v1.fe80]/o/r" ] && ok || bad "valid IPvFuture must keep brackets ($OUT)"
|
||||
run -- --normalize-remote "https://[vF.foo]:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://[vf.foo]:8443/o/r" ] && ok || bad "valid IPvFuture+port must keep brackets ($OUT)"
|
||||
run -- --normalize-remote "https://[v1.fe80]evil/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (evil)"
|
||||
run -- --normalize-remote "https://[v1.fe80].evil.example/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (.evil.example)"
|
||||
run -- --normalize-remote "https://[vF.foo]x:8443/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (x:8443)"
|
||||
echo "== (9d) non-bracketed authority grammar (R6-B1) =="
|
||||
for U in "https://:8443/o/r.git" "https://bad host/o/r.git" "https://bad^host/o/r.git" "https://bad\\host/o/r.git" "https://bad%zz/o/r.git" "https://bad%2/o/r.git" "https://bad%/o/r.git"; do
|
||||
run -- --normalize-remote "$U"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "non-bracketed authority must be rejected: $U (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://git.mosaicstack.dev:9000/mosaicstack/stack"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev:9000/mosaicstack/stack" ] && ok || bad "valid host:port unchanged ($OUT)"
|
||||
run -- --normalize-remote "https://192.168.1.10:8443/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://192.168.1.10:8443/o/r" ] && ok || bad "IPv4 reg-name stays valid ($OUT)"
|
||||
run -- --normalize-remote "https://bad%2Fx/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://bad%2fx/o/r" ] && ok || bad "complete %HH must stay legal, case-normalized ($OUT)"
|
||||
echo "== (9e) ASCII-only authority bytes (R7-B1) =="
|
||||
# isolated port arm (R8): VALID ASCII host + full-width-digit port ONLY —
|
||||
# unconfounded, so restoring Unicode-aware isdigit() goes red right here.
|
||||
run -- --normalize-remote "https://git.example.invalid:443/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "ASCII digits only"; then ok
|
||||
else bad "full-width-digit port on a VALID host must be rejected with the port reason (rc=$RC out=$OUT)"; fi
|
||||
for U in "https://éxample.invalid/o/r.git" "https://例え.テスト/o/r.git" "https://full-width.invalid/o/r.git" "https://mosaic-stack.dev:443/o/r.git"; do
|
||||
run -- --normalize-remote "$U"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "non-ASCII authority must be rejected: $U (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://xn--xample-9ua.invalid/o/r.git"
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "https://xn--xample-9ua.invalid/o/r" ] && ok || bad "punycode xn-- host must stay legal ($OUT)"
|
||||
echo "== (9c) bracket-payload grammar + raw control bytes (R5-B1) =="
|
||||
for P in "v1. " "v1.a b" "v1.a^b" "v1.a\\b" "v1.%20" "not-an-ip" "::gg::1"; do
|
||||
run -- --normalize-remote "https://[$P]/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
|
||||
else bad "bracket payload [$P] must be rejected (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
for CB in $'\t' $'\n' $'\r'; do
|
||||
run -- --normalize-remote "https://[v1.a${CB}b]/o/r.git"
|
||||
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "control byte"; then ok
|
||||
else bad "raw control byte must be rejected before urlsplit (rc=$RC out=$OUT)"; fi
|
||||
done
|
||||
run -- --normalize-remote "https://[v1.fe80%zone]/o/r.git"
|
||||
[ "$RC" = 1 ] && ok || bad "percent (not in RFC host grammar) must be rejected ($OUT)"
|
||||
run -- --normalize-remote "https://[fe80::1%eth0]/o/r.git"
|
||||
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPv6 zone-id (not RFC host grammar) must be rejected ($OUT)"
|
||||
|
||||
echo "== (10) root gate: every v2 managed validation fails closed (F1) =="
|
||||
# NOTE (spec §4.5): host:/ paths resolve UNDER MOSAIC_HOST_ROOT by construction,
|
||||
# so tool-managed is not declarable today — the cross-check fails for every
|
||||
# host:/ root until the anchor scheme grows an outside-root form (J3 era).
|
||||
expect_err j1 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
|
||||
expect_err j2 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/brain.json"
|
||||
expect_err j3 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
|
||||
# rider (T51P2R2): genuine ABSENCE, not just explicitly empty — captured via
|
||||
# command substitution, asserted in the parent shell (no subshell-counter shape).
|
||||
OUTU=$(cd "$SB" && env -u MOSAIC_HOST_ROOT bash "$V" "$SB/stack.json" 2>&1 </dev/null; echo "__RC__$?")
|
||||
RCU=${OUTU##*__RC__}
|
||||
if [ "$RCU" = 1 ] && printf '%s' "$OUTU" | grep -q "VALIDATION_ERROR.*MOSAIC_HOST_ROOT"; then ok
|
||||
else bad "unset-by-absence root must fail closed in managed mode (rc=$RCU)"; fi
|
||||
run MOSAIC_HOST_ROOT= -- --mode display "$SB/stack.json"
|
||||
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK' && printf '%s' "$OUT" | grep -q "host root unset"; then ok
|
||||
else bad "display-mode unset must pass with the specified warning (rc=$RC)"; fi
|
||||
run MOSAIC_HOST_ROOT= -- --mode display "$SB/brain.json"
|
||||
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q "host root unset"; then ok
|
||||
else bad "display-mode warn missing for brain fixture"; fi
|
||||
TM='{"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":"tool-managed"}'
|
||||
fx tm.json "$TM"; mkdir -p "$ROOT/src"
|
||||
expect_err j4 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm.json"
|
||||
fx tm_noroot.json "$(printf '%s' "$TM" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["worktree_root"]; print(json.dumps(d))')"
|
||||
expect_err j5 "worktree_root" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm_noroot.json"
|
||||
echo "== (10b) symlink escape cannot fake outside-ness (B3 fix: lexical containment) =="
|
||||
OUTSIDE="$SB/outside-target"; mkdir -p "$OUTSIDE"
|
||||
ln -s "$OUTSIDE" "$ROOT/escape"
|
||||
TM_ESC="${TM/host:\/src\/mosaic-stack-worktrees/host:/escape/worktrees}"
|
||||
fx tmsym.json "$TM_ESC"
|
||||
expect_err j6 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tmsym.json"
|
||||
|
||||
echo "== (11) mirror-path components: collision/delimiter/control fixtures (F5) =="
|
||||
run -- --mirror-path git.mosaicstack.dev mosaicstack stack
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/mosaicstack/stack/repo.json" ] && ok || bad "mirror path ok ($OUT)"
|
||||
expect_err k1 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a__b" "c"
|
||||
expect_err k2 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a" "b__c"
|
||||
expect_err k3 "mirror-component" -- --mirror-path "git.mosaicstack.dev/x" "a" "b"
|
||||
expect_err k4 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "A" "B"
|
||||
expect_err k5 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "" "stack"
|
||||
run -- --mirror-path git.mosaicstack.dev a b.c
|
||||
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/a/b.c/repo.json" ] && ok || bad "distinct path ($OUT)"
|
||||
run -- --mirror-path $'git.example.invalid\n' owner repo
|
||||
[ "$RC" = 1 ] && ok || bad "trailing-newline host must be rejected (fullmatch)"
|
||||
run -- --mirror-path $'git.\texample' owner repo
|
||||
[ "$RC" = 1 ] && ok || bad "control-byte host must be rejected"
|
||||
|
||||
echo "== (12) missing required keys =="
|
||||
for key in release_branch canonical_clone; do
|
||||
fx miss.json "$(printf '%s' "$STACK" | python3 -c "import json,sys; d=json.load(sys.stdin); del d['$key']; print(json.dumps(d))")"
|
||||
expect_err "l-$key" "$key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/miss.json"
|
||||
done
|
||||
|
||||
echo "== (13) absent file =="
|
||||
expect_err m1 "file" -- "$SB/nonexistent.json"
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then echo "FAILED:$FAILED"; exit 1; fi
|
||||
echo "ALL GREEN"
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-repo-json.sh — declaration validator for T51 repo structure declarations.
|
||||
#
|
||||
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md @ 1896adc1
|
||||
# (R3). Implements the spec's validation surface: schema v1/v2 (§1.2), host:/
|
||||
# path grammar with canonical segment normalization — empty/./.. rejected
|
||||
# BEFORE resolution — and tilde rejection (§1.2a), mirror path component
|
||||
# validation (§3.1), cross-field rules (§5.2), remote normalization (§5.3).
|
||||
#
|
||||
# PROVENANCE (T51 WP5c vendoring): ported verbatim from the mosaic-brain tree —
|
||||
# tools/repo-structure-decl/validate-repo-json.sh @ brain main merge 515bcbab
|
||||
# (PR 28, wave-1 R9 PASS, 101-arm suite green). This file is now the framework
|
||||
# home per spec §5.1 ("shipped in the framework package"); the brain copy is the
|
||||
# development origin. Re-sync rule: changes land here via reviewed PR and are
|
||||
# back-ported to the brain tree (or the brain copy retires) — never fork silently.
|
||||
# Validator version at port: 1.1.0+t51spec-r3+t51p2rw1 (R2-R8 rework included).
|
||||
# No operator literal appears in this file; the host root is read from
|
||||
# MOSAIC_HOST_ROOT configuration only.
|
||||
#
|
||||
# Unset-root semantics (spec §1.2a, fail-closed; T51P2R1 F1): v2 declarations
|
||||
# always consume a path (canonical_clone is required), so in --mode managed an
|
||||
# unset OR EMPTY MOSAIC_HOST_ROOT is a VALIDATION_ERROR for every v2 file —
|
||||
# not only tool-managed. In --mode display it warns and omits root-dependent
|
||||
# resolution; grammar checks still run.
|
||||
#
|
||||
# Error contract (T51P2R1 F3): every malformed input — bad UTF-8, non-RFC JSON
|
||||
# constants (NaN/Infinity), wrong-typed enums, anything unexpected — yields
|
||||
# exactly one stable VALIDATION_ERROR line and exit 1. No traceback ever
|
||||
# escapes. Branch names are validated by delegating to `git check-ref-format
|
||||
# --branch` (F2), translated into this contract.
|
||||
#
|
||||
# Usage:
|
||||
# validate-repo-json.sh <repo.json> [--mode managed|display] [--require-v2]
|
||||
# validate-repo-json.sh --mirror-path <host> <owner> <repo> # §3.1 component check
|
||||
# validate-repo-json.sh --normalize-remote <url> # §5.3, prints normalized
|
||||
# validate-repo-json.sh --version
|
||||
#
|
||||
# Output: OK (exit 0) | VALIDATION_ERROR <key>: <reason> (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 <host> <owner> <repo>")
|
||||
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 <url>")
|
||||
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
|
||||
@@ -64,13 +64,31 @@ if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
# Normalise the target to an EXACT session plus a window part, because tmux
|
||||
# resolves the two halves with different and individually dangerous defaults:
|
||||
#
|
||||
# * An unpinned name is a PREFIX match. With a session `foobar` alive and
|
||||
# no session `foo`, `-t foo` resolves to `foobar` at rc=0, so a message is
|
||||
# delivered, verified and reported OK against the wrong agent's pane.
|
||||
# * A bare `=name` is not enough on its own: capture-pane REJECTS it
|
||||
# ("can't find pane") while list-panes silently PREFIX-MATCHES it, so the
|
||||
# validation below would pass on a session the capture cannot read.
|
||||
# * A trailing `:` follows the session's ACTIVE window. Pinning `:0.0`
|
||||
# instead addresses window 0 unconditionally, and since the paste, the
|
||||
# Enter and the verifying capture all use EFFECTIVE_TARGET, a multi-window
|
||||
# agent gets typed into window 0 and confirmed by reading window 0 --
|
||||
# a false "delivered" rather than a loud failure.
|
||||
#
|
||||
# Explicit tmux ids (%pane, @window, $session) are passed through untouched;
|
||||
# prefixing `=` to them would break addressing that is already unambiguous.
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
case "$TARGET" in
|
||||
=*|%*|@*|\$*) ;;
|
||||
*) EFFECTIVE_TARGET="=$TARGET" ;;
|
||||
esac
|
||||
case "$EFFECTIVE_TARGET" in
|
||||
=*) [[ "$EFFECTIVE_TARGET" == *:* ]] || EFFECTIVE_TARGET="${EFFECTIVE_TARGET}:" ;;
|
||||
esac
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Target normalisation: send-message.sh must address an EXACT session and the
|
||||
# session's ACTIVE window. Both halves have caused silent wrong-pane delivery:
|
||||
# * an unpinned name prefix-matches, so a message for an absent session is
|
||||
# delivered to a different agent and reported OK;
|
||||
# * a `:0.0` pin addresses window 0 regardless of where the agent is, and
|
||||
# because the paste, the Enter and the verifying capture share one target,
|
||||
# the wrong window is also the window that confirms the send.
|
||||
# Both rows below FAIL against the pre-fix script, which is the point of them.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
SOCKET="mosaic-test-target-$RANDOM-$$"
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
|
||||
tmux_() { tmux -L "$SOCKET" "$@"; }
|
||||
newsess() { tmux_ new-session -d -s "$1" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'; }
|
||||
hits() { tmux_ capture-pane -p -t "$1" 2>/dev/null | grep -cF "$2" || true; }
|
||||
|
||||
# ── 1. an absent session must not prefix-match a live one ───────────────────
|
||||
newsess sibling-long
|
||||
nonce="absent-target-$RANDOM"
|
||||
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t sibling -m "$nonce" >/dev/null 2>&1 || rc=$?
|
||||
[ "$rc" -ne 0 ] || fail "send to absent session 'sibling' returned rc=0 (prefix-matched)"
|
||||
[ "$(hits sibling-long "$nonce")" -eq 0 ] || fail "message for absent 'sibling' was delivered to 'sibling-long'"
|
||||
|
||||
# positive control: the detector above can see a real delivery
|
||||
nonce_ok="control-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t sibling-long -m "$nonce_ok" >/dev/null 2>&1 \
|
||||
|| fail "send to a live session failed"
|
||||
[ "$(hits sibling-long "$nonce_ok")" -gt 0 ] || fail "control: live delivery not observed — detector is blind"
|
||||
|
||||
# ── 2. delivery follows the ACTIVE window, not window 0 ─────────────────────
|
||||
# A single-window fixture cannot tell `=s:` from `=s:0.0`; the active window
|
||||
# must be non-zero or this test proves nothing.
|
||||
newsess multi
|
||||
tmux_ new-window -t multi -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux_ select-window -t multi:1
|
||||
active=$(tmux_ display-message -p -t multi '#{window_index}')
|
||||
[ "$active" = "1" ] || fail "fixture setup: expected active window 1, got $active"
|
||||
|
||||
for target in multi "=multi"; do
|
||||
nonce="active-win-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "$target" -m "$nonce" >/dev/null 2>&1 \
|
||||
|| fail "send to '$target' failed"
|
||||
[ "$(hits multi:1 "$nonce")" -gt 0 ] || fail "'$target' did not deliver to the active window"
|
||||
[ "$(hits multi:0 "$nonce")" -eq 0 ] || fail "'$target' delivered to window 0 instead of the active window"
|
||||
done
|
||||
|
||||
# ── 3. an explicit window part is preserved ─────────────────────────────────
|
||||
nonce="explicit-win-$RANDOM"
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t multi:0 -m "$nonce" >/dev/null 2>&1 || fail "send to 'multi:0' failed"
|
||||
[ "$(hits multi:0 "$nonce")" -gt 0 ] || fail "explicit 'multi:0' did not deliver to window 0"
|
||||
|
||||
# ── 4. a unique prefix of a live session is still refused ───────────────────
|
||||
nonce="prefix-$RANDOM"
|
||||
rc=0; "$SEND_MESSAGE" -L "$SOCKET" -t mult -m "$nonce" >/dev/null 2>&1 || rc=$?
|
||||
[ "$rc" -ne 0 ] || fail "send to prefix 'mult' returned rc=0"
|
||||
[ "$(hits multi:1 "$nonce")" -eq 0 ] || fail "prefix 'mult' was delivered to 'multi'"
|
||||
|
||||
echo "PASS: send-message.sh target normalisation"
|
||||
@@ -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"
|
||||
"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/fleet/test-agent-session-broker-preflight.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user