#!/bin/bash # check-numeric-coercion.sh - Find NUMERIC/DECIMAL columns returned from # repositories without Number()/parseFloat() coercion. # # Why: node-postgres returns NUMERIC/DECIMAL (OID 1700) as *strings*. # backend/src/core/config/database.ts overrides only the DATE parser, so # every repository mapper must coerce manually. Missed coercion is this # project's #1 recurring bug class (issues #239, #241, #244). # # Usage: ./check-numeric-coercion.sh (read-only; run from anywhere in repo) # Exit: 0 = no suspects, 1 = suspects found, 2 = setup error set -u ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" [ -z "$ROOT" ] && { echo "ERROR: not inside a git repo" >&2; exit 2; } SRC="$ROOT/backend/src" [ -d "$SRC" ] && cd "$ROOT" || { echo "ERROR: $SRC missing" >&2; exit 2; } # 1. Collect numeric/decimal column names from all migration SQL. # Three shapes: column definition lines, ADD COLUMN, ALTER COLUMN ... TYPE. COLS=$( { grep -rhiE '^[[:space:]]*"?[a-z_]+"?[[:space:]]+(numeric|decimal)[[:space:](]' \ --include='*.sql' "$SRC" | awk '{gsub(/"/,""); print tolower($1)}' grep -rhioE 'add column (if not exists )?"?[a-z_]+"? +(numeric|decimal)' \ --include='*.sql' "$SRC" | awk '{gsub(/"/,""); print tolower($(NF-1))}' grep -rhioE 'alter column "?[a-z_]+"? type +(numeric|decimal)' \ --include='*.sql' "$SRC" | awk '{gsub(/"/,""); print tolower($(NF-2))}' } | sort -u ) [ -z "$COLS" ] && { echo "ERROR: no numeric columns found (wrong dir?)" >&2; exit 2; } echo "Numeric/decimal columns found in migrations:" echo "$COLS" | tr '\n' ' '; echo; echo # 2. In every repository file, flag lines that read row. without # Number( or parseFloat( on the same line. SUSPECTS=0 for col in $COLS; do HITS=$(find "$SRC" -name '*.repository.ts' -exec \ grep -nE "row\.${col}([^a-zA-Z0-9_]|\$)" {} + 2>/dev/null | grep -v 'parseFloat(' | grep -v 'Number(') if [ -n "$HITS" ]; then echo "SUSPECT column '$col' (string from pg, no coercion on line):" echo "$HITS" | sed "s|$ROOT/||; s/^/ /" SUSPECTS=$((SUSPECTS + $(echo "$HITS" | wc -l | tr -d ' '))) fi done # 3. Flag raw-row returns (the actual shape of bug #244: "return res.rows[0]" # bypasses the mapper entirely) in features whose migrations define # numeric columns. Scalar returns (.count/.exists/.length) are filtered. for repo in $(find "$SRC/features" -name '*.repository.ts'); do feat=$(echo "$repo" | sed "s|$SRC/features/||; s|/.*||") featcols=$(grep -rhiE "(numeric|decimal)" --include='*.sql' \ "$SRC/features/$feat/migrations" 2>/dev/null | head -1) [ -z "$featcols" ] && continue RAW=$(grep -nE 'return (res|result)\.rows' "$repo" | grep -vE 'map[A-Za-z]*\(|\.map\(|\?\.|\.length|\.count|\.exists') if [ -n "$RAW" ]; then echo "SUSPECT raw-row return in numeric-bearing feature '$feat':" echo "$RAW" | sed "s|^| ${repo#"$ROOT"/}:|" SUSPECTS=$((SUSPECTS + $(echo "$RAW" | wc -l | tr -d ' '))) fi done echo if [ "$SUSPECTS" -eq 0 ]; then echo "PASS: no uncoerced numeric column reads or raw-row returns found." exit 0 else echo "FAIL: $SUSPECTS suspect line(s). Each must wrap the value in Number()" echo "or parseFloat() (nullable: 'row.x != null ? parseFloat(row.x) : null')." echo "Limitations: same-line heuristic; coercion done on a different line or" echo "in the service layer will still be flagged - verify before fixing." exit 1 fi