#!/usr/bin/env bash # Shared bash reader for framework-manifest.txt (#791). # # This is the bash half of the SSOT ownership resolver; the TypeScript half is # packages/mosaic/src/framework/manifest.ts. BOTH read the same # framework-manifest.txt and MUST resolve identical ownership for any path — the # parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and # compares it against the TS resolver, so the two can never drift (the #631 # two-copies failure class this closes). # # Ownership resolution (deny-wins / fail-safe): # 1. operator glob matches -> operator # 2. else framework glob -> framework # 3. else -> operator (UNKNOWN defaults to operator, #791) # # Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the # hot resolver (manifest_is_framework) forks no subprocesses — the installer # calls it once per file across the whole tree. # # Usage as a library (source it, then): # manifest_load [manifest-file] # populates + compiles the manifest # manifest_is_framework # rc 0 = framework-owned, rc 1 = operator # manifest_resolve # echoes: framework | operator # manifest_subtree_roots # echoes shipped framework `dir/**` roots # # Usage as a CLI (parity harness): # bash manifest.sh resolve # bash manifest.sh subtree-roots # bash manifest.sh classify # reads paths on stdin -> "\t" MANIFEST_FRAMEWORK=() MANIFEST_OPERATOR=() # Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re". _MF_KIND=(); _MF_EXACT=(); _MF_RE=() _MO_KIND=(); _MO_EXACT=(); _MO_RE=() _MF_ROOTS=() _manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; } # Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip # trailing / (mirrors normalizeRel in manifest.ts). _manifest_norm() { local p="$1" p="${p//\\//}" p="${p#./}" while [[ "$p" == /* ]]; do p="${p#/}"; done while [[ "$p" == */ ]]; do p="${p%/}"; done printf '%s' "$p" } # Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody). _manifest_glob_to_ere() { local pattern; pattern="$(_manifest_norm "$1")" local out="" c n i len=${#pattern} trailing for (( i = 0; i < len; i++ )); do c="${pattern:i:1}" if [[ "$c" == "*" ]]; then n="${pattern:i+1:1}" if [[ "$n" == "*" ]]; then i=$((i + 1)) trailing=0 if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi if [[ "$out" == */ ]]; then out="${out%/}(/.*)?" elif [[ "$trailing" -eq 1 ]]; then out="$out(.*/)?" else out="$out.*" fi else out="$out[^/]*" fi else case "$c" in .|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;; *) out="$out$c" ;; esac fi done printf '%s' "$out" } # Compile one raw glob into (kind, exact, re) appended to the given section. # $1 = raw glob, $2 = section letter (F|O). _manifest_compile_one() { local norm; norm="$(_manifest_norm "$1")" [[ -n "$norm" ]] || return 0 if [[ "$norm" == *"*"* ]]; then local re="^$(_manifest_glob_to_ere "$norm")\$" if [[ "$2" == F ]]; then _MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re") else _MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re") fi else if [[ "$2" == F ]]; then _MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("") else _MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("") fi fi [[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}") return 0 } _manifest_compile() { _MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=() _MO_KIND=(); _MO_EXACT=(); _MO_RE=() local g for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done # Explicit success: an empty operator array makes the final `[[ -n "" ]] && …` # short-circuit to rc 1, which would otherwise become this function's (and # manifest_load's) return code — a spurious failure (#791 B2). Never rely on # the last loop's exit status here. return 0 } # Load + compile the manifest. Rejects a malformed file the same way # parseManifest() does (entry before a section header / unknown header). manifest_load() { local file="${1:-}" [[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt" # Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"` # aborts on a raw redirection error with no explanation; downstream that reads # as "no framework paths" and an upgrade could no-op silently (#791 B2/B3). if [[ ! -r "$file" ]]; then echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2 return 1 fi MANIFEST_FRAMEWORK=() MANIFEST_OPERATOR=() local section="" line while IFS= read -r line || [[ -n "$line" ]]; do line="${line#"${line%%[![:space:]]*}"}" # ltrim line="${line%"${line##*[![:space:]]}"}" # rtrim [[ -z "$line" || "${line:0:1}" == "#" ]] && continue case "$line" in "[framework]") section=framework; continue ;; "[operator]") section=operator; continue ;; "["*) echo "manifest: unknown section header: $line" >&2; return 1 ;; esac if [[ -z "$section" ]]; then echo "manifest: entry before any [section] header: $line" >&2 return 1 fi if [[ "$section" == framework ]]; then MANIFEST_FRAMEWORK+=("$line") else MANIFEST_OPERATOR+=("$line") fi done < "$file" # An empty or comment-only manifest defines NO framework-owned paths. Treating # that as valid would make every path resolve operator and an upgrade prune # nothing / write nothing — a silent no-op indistinguishable from success. # Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2). if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2 return 1 fi # An entry like `/` or `./` normalizes to nothing and compiles to a glob that # matches no path — so a manifest whose only [framework] entries are degenerate # passes the count guard above but leaves the framework matcher empty: every # path resolves operator, the exact silent no-op we fail closed against. Require # at least one entry with a real (non-slash, non-dot) character. Mirrors # parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B). local _g _usable=0 for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi done if [[ "$_usable" -eq 0 ]]; then echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2 return 1 fi _manifest_compile return 0 } # Fork-free: does $1 (a mosaic-home-relative path) match an operator glob? _mo_matches() { local path="$1" i n=${#_MO_KIND[@]} re pat for (( i = 0; i < n; i++ )); do if [[ "${_MO_KIND[i]}" == exact ]]; then pat="${_MO_EXACT[i]}" [[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0 else re="${_MO_RE[i]}" [[ "$path" =~ $re ]] && return 0 fi done return 1 } # Fork-free: does $1 match a framework glob? _mf_matches() { local path="$1" i n=${#_MF_KIND[@]} re pat for (( i = 0; i < n; i++ )); do if [[ "${_MF_KIND[i]}" == exact ]]; then pat="${_MF_EXACT[i]}" [[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0 else re="${_MF_RE[i]}" [[ "$path" =~ $re ]] && return 0 fi done return 1 } # The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator # (deny-wins / fail-safe). Assumes an already-clean POSIX relative path. manifest_is_framework() { _mo_matches "$1" && return 1 _mf_matches "$1" && return 0 return 1 } # Echo the ownership of a path: framework | operator. Normalizes first, so it is # safe for CLI / test callers passing unnormalized input. manifest_resolve() { local path; path="$(_manifest_norm "$1")" if manifest_is_framework "$path"; then echo framework; else echo operator; fi } # Echo each shipped framework subtree root (a `dir/**` entry, without the /**). manifest_subtree_roots() { local r for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done } # CLI dispatch — only when executed directly, never when sourced. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then set -o pipefail # Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero # exit instead of continuing to resolve against empty compiled arrays — that is # what lets the parity test assert bash and TS reject the same bad inputs (#791 B2). manifest_load "${MANIFEST_FILE:-}" || exit 1 cmd="${1:-}" case "$cmd" in resolve) manifest_resolve "${2:?path required}" ;; subtree-roots) manifest_subtree_roots ;; classify) while IFS= read -r p; do [[ -z "$p" ]] && continue printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p" done ;; *) echo "usage: manifest.sh {resolve |subtree-roots|classify}" >&2 exit 2 ;; esac fi