syntaxai/tdd.md · main · tools / sama-cli / src / b32_checks.sh

b32_checks.sh 398 lines · 11751 bytes raw
# 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
}