syntaxai/tdd.md · main · tools / sama-cli / src / b32_checks.sh
# b32 — logic: the seven SAMA v2 §4 conformance checks, implemented
# as pure shell functions over an in-memory file list. Mirrors
# src/b32_sama_v2_verify.ts. Each check populates the globals
# CHECK_EXAMINED + CHECK_VIOLATIONS so the d21 dispatcher can render
# verdicts uniformly. No I/O beyond reading the explicitly-listed
# files; the file walker (list_repo_files) lives in b32_utils.
# sama-import: a31_constants.sh
# sama-import: b32_utils.sh
# Output globals — reset by every run_check_* function.
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
# Cached SAMA_FILES + TEST_FILES — populated by collect_input.
SAMA_FILES=()
TEST_FILES=()
ALL_FILES=()
ALL_FILES_FULLPATHS=()
REPO_ROOT=""
SRC_DIR=""
# — collect_input -------------------------------------------------
# Walks <src_dir>, populates SAMA_FILES / TEST_FILES / ALL_FILES.
# Also records the full filesystem path in ALL_FILES_FULLPATHS so
# checks can stat / cat files without re-resolving paths.
collect_input() {
REPO_ROOT="$1"
SRC_DIR="$2"
SAMA_FILES=()
TEST_FILES=()
ALL_FILES=()
ALL_FILES_FULLPATHS=()
local rel
while IFS= read -r rel; do
[ -z "$rel" ] && continue
ALL_FILES+=("$rel")
ALL_FILES_FULLPATHS+=("$REPO_ROOT/$rel")
if is_sama_file "$rel"; then
SAMA_FILES+=("$rel")
elif is_test_file "$rel"; then
TEST_FILES+=("$rel")
fi
done < <(list_repo_files "$REPO_ROOT" "$SRC_DIR")
}
# Resolve a relative ".ts" import like "./c14_git.ts" from a file's
# directory to the repo-relative path (e.g. "src/c14_git.ts").
# Implemented in shell so the Law/Consistency checks share it.
_resolve_import() {
local from_path="$1"
local imp="$2"
local dir="${from_path%/*}"
local rel="${imp#./}"
echo "${dir}/${rel}"
}
# Returns 0 if <rel_path> is in ALL_FILES.
_in_files() {
local target="$1"
local f
for f in "${ALL_FILES[@]}"; do
[ "$f" = "$target" ] && return 0
done
return 1
}
# — Check 1: Sorted -----------------------------------------------
# Every file carries a profile-recognised prefix; lex prefix order
# equals layer order.
run_check_sorted() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local n=${#PROFILE_PREFIXES[@]}
local i j
for i in $(seq 0 $((n - 1))); do
for j in $(seq 0 $((n - 1))); do
[ "$i" = "$j" ] && continue
local la="${PROFILE_LAYERS[$i]}"
local pa="${PROFILE_PREFIXES[$i]}"
local lb="${PROFILE_LAYERS[$j]}"
local pb="${PROFILE_PREFIXES[$j]}"
if [ "$la" -lt "$lb" ] && [ "$pa" \> "$pb" ]; then
CHECK_VIOLATIONS+=("$pa::prefix \`$pa\` (layer $la) sorts after \`$pb\` (layer $lb) — lex order must equal layer order")
fi
done
done
local f
for f in "${SAMA_FILES[@]}"; do
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
if ! declared_layer "$f" > /dev/null; then
CHECK_VIOLATIONS+=("$f::no profile-recognised prefix")
fi
done
}
# — Check 2: Architecture -----------------------------------------
run_check_architecture() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local f
for f in "${ALL_FILES[@]}"; do
if is_sama_file "$f" || is_test_file "$f"; then
:
else
continue
fi
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
local matches
matches="$(all_prefix_matches "$f")"
if [ -z "$matches" ]; then
CHECK_VIOLATIONS+=("$f::unprefixed — does not match any profile prefix")
continue
fi
local distinct_layers
distinct_layers="$(echo "$matches" | awk '{print $1}' | sort -u | wc -l)"
if [ "$distinct_layers" -gt 1 ]; then
local detail
detail="$(echo "$matches" | awk '{printf "%s→L%s, ", $2, $1}' | sed 's/, $//')"
CHECK_VIOLATIONS+=("$f::ambiguous — matches multiple layers: $detail")
fi
done
}
# — Check 3: Modeled (tests) --------------------------------------
run_check_modeled_tests() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local ext="$PROFILE_EXTENSION"
local f
for f in "${SAMA_FILES[@]}"; do
local info
info="$(declared_layer "$f")" || continue
local layer
layer="$(echo "$info" | awk '{print $1}')"
if [ "$layer" != "1" ] && [ "$layer" != "2" ]; then
continue
fi
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
# Sibling = same path with extension swapped to .test<ext>.
local sibling="${f%${ext}}.test${ext}"
if ! _in_files "$sibling"; then
CHECK_VIOLATIONS+=("$f::no sibling test at \`$sibling\` — Layer $layer requires one")
fi
done
}
# — Check 4: Modeled (boundary) -----------------------------------
run_check_modeled_boundary() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local f
for f in "${SAMA_FILES[@]}"; do
local info
info="$(declared_layer "$f")" || continue
local layer
layer="$(echo "$info" | awk '{print $1}')"
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
[ "$layer" = "2" ] && continue
local matches
matches="$(parse_boundary_matches "$REPO_ROOT/$f")"
[ -z "$matches" ] && continue
local pat
while IFS= read -r pat; do
[ -z "$pat" ] && continue
CHECK_VIOLATIONS+=("$f::boundary pattern \`$pat\` found in Layer $layer — parsing belongs in Layer 2")
done <<< "$matches"
done
}
# — Check 5: Atomic -----------------------------------------------
run_check_atomic() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local f
for f in "${ALL_FILES[@]}"; do
if is_sama_file "$f" || is_test_file "$f"; then
:
else
continue
fi
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
local fullpath="$REPO_ROOT/$f"
local lc
lc="$(count_lines "$fullpath")"
if [ "$lc" -gt "$MAX_LINES" ]; then
CHECK_VIOLATIONS+=("$f::$lc lines (over the ${MAX_LINES}-line cap — split per UI/data domain)")
fi
if is_barrel "$fullpath"; then
CHECK_VIOLATIONS+=("$f::barrel re-export file (all lines are \`export … from\`)")
fi
done
}
# — Check 6: The Law (§1.2) ---------------------------------------
# Build adjacency, then for every edge A→B require layer(B) <
# layer(A), or same layer with sublayer.index(B) <= sublayer.index(A).
# Plus DFS cycle detection.
run_check_law() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
# Adjacency: ADJ_KEYS holds file paths in encounter order; the
# adjacency for ADJ_KEYS[i] is ADJ_VALUES[i] (space-separated).
ADJ_KEYS=()
ADJ_VALUES=()
local f
for f in "${ALL_FILES[@]}"; do
if is_sama_file "$f" || is_test_file "$f"; then
:
else
continue
fi
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
local out=""
local imp
while IFS= read -r imp; do
[ -z "$imp" ] && continue
# Only follow edges into files we actually loaded.
if _in_files "$imp"; then
out="$out $imp"
fi
done < <(collect_imports "$f" "$REPO_ROOT")
ADJ_KEYS+=("$f")
ADJ_VALUES+=("${out# }")
done
# Edge-by-edge check.
local i
for i in "${!ADJ_KEYS[@]}"; do
local from="${ADJ_KEYS[$i]}"
local outs="${ADJ_VALUES[$i]}"
[ -z "$outs" ] && continue
local a_info a_layer a_index
a_info="$(declared_layer "$from")" || continue
a_layer="$(echo "$a_info" | awk '{print $1}')"
a_index="$(echo "$a_info" | awk '{print $3}')"
local to
for to in $outs; do
local b_info b_layer b_index b_name
b_info="$(declared_layer "$to")" || continue
b_layer="$(echo "$b_info" | awk '{print $1}')"
b_index="$(echo "$b_info" | awk '{print $3}')"
b_name="$(echo "$b_info" | awk '{print $2}')"
if [ "$b_layer" -lt "$a_layer" ]; then
continue
fi
if [ "$b_layer" -gt "$a_layer" ]; then
CHECK_VIOLATIONS+=("$from::imports \`$to\` — Layer $a_layer → Layer $b_layer (upward, breaks §1.2)")
continue
fi
# Same layer: sublayer ordering — target must be earlier-or-equal.
if [ "$b_index" -gt "$a_index" ]; then
local a_name
a_name="$(echo "$a_info" | awk '{print $2}')"
CHECK_VIOLATIONS+=("$from::imports \`$to\` — same layer $a_layer but sublayer order reversed ($a_name index $a_index → $b_name index $b_index)")
fi
done
done
# DFS cycle detection on the same graph.
_detect_cycles_law
}
# Cycle detector. Uses parallel arrays + a recursive bash function.
# Emits violations directly into CHECK_VIOLATIONS.
_detect_cycles_law() {
CYCLE_COLOR_KEYS=()
CYCLE_COLOR_VALS=()
CYCLE_STACK=()
local k
for k in "${ADJ_KEYS[@]}"; do
CYCLE_COLOR_KEYS+=("$k")
CYCLE_COLOR_VALS+=("0") # 0=white, 1=gray, 2=black
done
local i
for i in "${!CYCLE_COLOR_KEYS[@]}"; do
if [ "${CYCLE_COLOR_VALS[$i]}" = "0" ]; then
_dfs "${CYCLE_COLOR_KEYS[$i]}" || true
fi
done
}
_color_get() {
local node="$1"
local i
for i in "${!CYCLE_COLOR_KEYS[@]}"; do
if [ "${CYCLE_COLOR_KEYS[$i]}" = "$node" ]; then
echo "${CYCLE_COLOR_VALS[$i]}"
return 0
fi
done
echo "0"
}
_color_set() {
local node="$1"
local val="$2"
local i
for i in "${!CYCLE_COLOR_KEYS[@]}"; do
if [ "${CYCLE_COLOR_KEYS[$i]}" = "$node" ]; then
CYCLE_COLOR_VALS[$i]="$val"
return 0
fi
done
CYCLE_COLOR_KEYS+=("$node")
CYCLE_COLOR_VALS+=("$val")
}
_adj_for() {
local node="$1"
local i
for i in "${!ADJ_KEYS[@]}"; do
if [ "${ADJ_KEYS[$i]}" = "$node" ]; then
echo "${ADJ_VALUES[$i]}"
return 0
fi
done
}
_dfs() {
local node="$1"
_color_set "$node" "1"
CYCLE_STACK+=("$node")
local outs
outs="$(_adj_for "$node")"
local next
for next in $outs; do
local c
c="$(_color_get "$next")"
if [ "$c" = "1" ]; then
# Found a back-edge — extract cycle from stack.
local found_idx=-1
local i
for i in "${!CYCLE_STACK[@]}"; do
if [ "${CYCLE_STACK[$i]}" = "$next" ]; then
found_idx="$i"
break
fi
done
if [ "$found_idx" -ge 0 ]; then
local cycle_str=""
for i in $(seq "$found_idx" $((${#CYCLE_STACK[@]} - 1))); do
if [ -z "$cycle_str" ]; then
cycle_str="${CYCLE_STACK[$i]}"
else
cycle_str="$cycle_str → ${CYCLE_STACK[$i]}"
fi
done
cycle_str="$cycle_str → $next"
CHECK_VIOLATIONS+=("${CYCLE_STACK[$found_idx]}::import cycle: $cycle_str")
fi
_color_set "$node" "2"
unset 'CYCLE_STACK[${#CYCLE_STACK[@]}-1]'
return 1
fi
if [ "$c" = "0" ]; then
_dfs "$next" || true
fi
done
_color_set "$node" "2"
unset 'CYCLE_STACK[${#CYCLE_STACK[@]}-1]'
return 0
}
# — Check 7: Consistency (§3) -------------------------------------
# For each file: find the max layer of any in-tree import. If that
# max > declared layer, the prefix lies. (Same-layer with bad
# sublayer order is the Law's concern; Consistency only fires when
# the layer ceiling itself is breached.)
run_check_consistency() {
CHECK_EXAMINED=0
CHECK_VIOLATIONS=()
local f
for f in "${SAMA_FILES[@]}"; do
local a_info a_layer a_prefix
a_info="$(declared_layer "$f")" || continue
a_layer="$(echo "$a_info" | awk '{print $1}')"
a_prefix="$(echo "$a_info" | awk '{print $4}')"
CHECK_EXAMINED=$((CHECK_EXAMINED + 1))
local ceiling=-1
local ceiling_file=""
local imp
while IFS= read -r imp; do
[ -z "$imp" ] && continue
local b_info b_layer
b_info="$(declared_layer "$imp")" || continue
b_layer="$(echo "$b_info" | awk '{print $1}')"
if [ "$b_layer" -gt "$ceiling" ]; then
ceiling="$b_layer"
ceiling_file="$imp"
fi
done < <(collect_imports "$f" "$REPO_ROOT")
if [ "$ceiling" -gt "$a_layer" ]; then
CHECK_VIOLATIONS+=("$f::declared Layer $a_layer (prefix \`$a_prefix\`) but imports reach Layer $ceiling via \`$ceiling_file\` — the prefix claims something the imports contradict")
fi
done
}