#!/bin/bash
# code-smell 감지 — oxlint로 표현하기 어려운 "구조적" 신호를 정량 체크한다.
# 출력은 사람이 아니라 Claude(AI 에이전트)가 읽고 행동하는 지시어다 — 각 경고에 FIX를 붙인다.
# 각 룰은 대응 원칙(P-xxx, .claude/skills/frontend-code-convention/references/)을 참조한다.
#
# 이스케이프 해치: 정당한 사유가 있으면 해당 파일에 `code-smell-ok(<rule>): <N>` 주석으로 허용치 명시.
# 위반이 하나라도 있으면 exit 1 (원천 차단). CSR(Vite + React Router) 단일 스택 기준.

set -uo pipefail

YELLOW='\033[0;33m'
NC='\033[0m'
warnings=0

# 앱 스캐폴드 전이면(=src 없음) 조용히 통과
[ -d src ] || { echo "✅ code-smell: src 없음(스캐폴드 전) — skip"; exit 0; }

SRC_GLOB='src'
# vendored(shadcn)·생성물은 우리 소유가 아니므로 제외
EXCLUDE_RE='src/shared/ui/|src/shared/api/_generated/|use-mobile\.ts'

# 줄 단위 예외(code-smell-ok(<rule>))를 지적 지점의 앞·같은·뒤 줄에서 찾는다.
# 같은 줄만 보면 예외를 달 방법이 사실상 없어진다 — oxfmt가 인라인 주석을 다음 줄로
# 밀어내기 때문에, 주석을 붙이면 포맷 게이트가 실패하고 포맷을 맞추면 예외가 무력화되는
# 교착이 생긴다(실측: 파일럿에서 정렬 한 줄 때문에 두 게이트가 서로를 실패시켰다).
has_ok() {
  local file=$1 ln=$2 rule=$3 from
  from=$(( ln > 1 ? ln - 1 : 1 ))
  sed -n "${from},$(( ln + 1 ))p" "$file" 2>/dev/null | grep -q "code-smell-ok(${rule})"
}

# ── 1. 한 컴포넌트에 boolean useState 2개+ (곱적 상태 → union으로) ───────────────
echo "🔍 boolean useState pairs..."
while IFS= read -r line; do
  count=$(echo "$line" | awk '{print $1}'); file=$(echo "$line" | awk '{print $2}')
  ok=$(grep -o 'code-smell-ok(useState): *[0-9]*' "$file" 2>/dev/null | head -1 | grep -o '[0-9]*$' || true)
  [ -n "$ok" ] && [ "$count" -le "$ok" ] && continue
  echo -e "${YELLOW}  ⚠ ${file}: boolean useState ${count}개${NC}"
  echo -e "${YELLOW}    FIX: 상호배타면 union literal('none'|'a'|'b')로 합치기. 모달 open/close는 overlay-kit(P-038). 곱(2^N)을 합(N+1)으로.${NC}"
  warnings=$((warnings+1))
done < <(grep -rn "useState" --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -v "import " | grep -E "\((false|true)\)" | awk -F: '{c[$1]++} END{for(f in c) if(c[f]>=2) print c[f], f}' | sort -rn)

# ── 2. callback props(onX) 8개+ (중개 콜백 → context 직접 소비) ────────────────
echo "🔍 callback props count..."
while IFS= read -r line; do
  count=$(echo "$line" | awk '{print $1}'); file=$(echo "$line" | awk '{print $2}')
  echo -e "${YELLOW}  ⚠ ${file}: callback props ${count}개${NC}"
  echo -e "${YELLOW}    FIX: 중개만 하는 콜백 식별 → 자식이 context에서 직접 소비. 관련 콜백끼리 자식 컴포넌트로 묶기(P-036 결합도).${NC}"
  warnings=$((warnings+1))
done < <(for f in $(find "$SRC_GLOB" -name "*.tsx" 2>/dev/null | grep -vE "$EXCLUDE_RE"); do c=$(grep -cE "on[A-Z][a-zA-Z]*\??:" "$f" 2>/dev/null); [ "$c" -ge 8 ] && echo "$c $f"; done | sort -rn)

# ── 3. Suspense 있는데 ErrorBoundary 없음 (로딩/에러 쌍) ───────────────────────
echo "🔍 Suspense without ErrorBoundary..."
while IFS= read -r file; do
  s=$(grep -c "<Suspense\|SuspenseQuery" "$file" 2>/dev/null || true)
  b=$(grep -c "ErrorBoundary\|errorElement" "$file" 2>/dev/null || true)
  [ "$s" -gt 0 ] && [ "$b" -eq 0 ] || continue
  grep -q 'code-smell-ok(boundary)' "$file" 2>/dev/null && continue
  echo -e "${YELLOW}  ⚠ ${file}: Suspense/SuspenseQuery인데 ErrorBoundary 없음${NC}"
  echo -e "${YELLOW}    FIX: @suspensive/react의 <ErrorBoundary>로 감싸거나 라우트 errorElement 지정. 로딩(Suspense)+에러(ErrorBoundary) 쌍(P-041). 경계가 구조적으로 딴 곳에 있으면 code-smell-ok(boundary): 사유.${NC}"
  warnings=$((warnings+1))
done < <(find "$SRC_GLOB" -name "*.tsx" 2>/dev/null | grep -vE "$EXCLUDE_RE")

# ── 4. FSD 레이어 경계 — oxlint fsd 플러그인(forbidden-imports·no-cross-slice-dependency)이
#        resolver 기반으로 대체 (grep보다 정확, .oxlintrc.json 참조) ────────────

# ── 5. 이름없는 복합 조건 (if에 같은 boolean 연산자 2개+ = 피연산자 3개+) ──────────
echo "🔍 unnamed complex conditions..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: if에 boolean 연산자 2개+(피연산자 3개+) — 이름 없는 복합조건${NC}"
  echo -e "${YELLOW}    FIX: (1)union type으로 경우의 수 줄이기 (2)불가하면 const isX = 조건 으로 이름 부여(P-029 가독성).${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE 'if \(.*&&.*&&|if \(.*\|\|.*\|\|' --include="*.tsx" --include="*.ts" "$SRC_GLOB" 2>/dev/null | grep -v '\.test\.' | grep -vE "$EXCLUDE_RE")

# ── 6. lint-disable에 사유 강제 ───────────────────────────────────────────────
echo "🔍 lint-disable needs reason..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: 사유 없는 lint-disable${NC}"
  echo -e "${YELLOW}    FIX: 인라인 \`-- 왜\` 또는 바로 윗줄 사유 주석. 룰에 걸렸으면 설계 재검토가 먼저(lint=신호).${NC}"
  warnings=$((warnings+1))
done < <(
  for f in $(grep -rlE '(oxlint|eslint)-disable' --include="*.ts" --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -v '\.test\.' | grep -vE "$EXCLUDE_RE"); do
    awk '/(oxlint|eslint)-disable/{ if(($0 !~ /--/) && (prev !~ /^[[:space:]]*(\/\/|\/\*|\*)/)) print FILENAME":"NR } {prev=$0}' "$f"
  done
)

# ── 7. React 19 deprecated: forwardRef ────────────────────────────────────────
echo "🔍 React 19 deprecated: forwardRef..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: forwardRef (React 19에서 불필요)${NC}"
  echo -e "${YELLOW}    FIX: forwardRef 제거, ref를 일반 prop으로 직접 받기.${NC}"
  warnings=$((warnings+1))
done < <(grep -rn 'forwardRef' --include="*.tsx" --include="*.ts" "$SRC_GLOB" 2>/dev/null | grep -v '\.test\.' | grep -vE "$EXCLUDE_RE")

# ── 8. React 19 deprecated: Context.Provider ──────────────────────────────────
echo "🔍 React 19 deprecated: Context.Provider..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  has_ok "$file" "$ln" provider && continue
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: <Context.Provider> (React 19에서 <Context>로)${NC}"
  echo -e "${YELLOW}    FIX: <Ctx.Provider value={}> → <Ctx value={}>. 서드파티가 .Provider API를 요구하면 그 줄에 // code-smell-ok(provider) 주석과 사유.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '\.Provider[ >]' --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE")

# ── 9. optional prop 곱폭발 (unique optional prop 4개+) ───────────────────────
echo "🔍 optional prop multiplication..."
for file in $(grep -rlE '[a-zA-Z_]+\?:' --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE"); do
  ok=$(grep -o 'code-smell-ok(optional): *[0-9]*' "$file" 2>/dev/null | head -1 | grep -o '[0-9]*$' || true)
  count=$(grep -oE '[a-zA-Z_]+\?:' "$file" | sort -u | wc -l | tr -d ' ')
  [ -n "$ok" ] && [ "$count" -le "$ok" ] && continue
  [ "$count" -ge 4 ] || continue
  echo -e "${YELLOW}  ⚠ ${file}: optional prop(?) ${count}개 = 최대 2^${count}가지 조합${NC}"
  echo -e "${YELLOW}    FIX: 실제 유효한 조합만 discriminated union variant로 선언. 불가능한 조합을 타입에서 제거(P-031 예측가능성).${NC}"
  warnings=$((warnings+1))
done

# ── 10. let/var 금지 (const만, 복잡 로직은 es-toolkit) — P-006.1 ───────────────
echo "🔍 let/var (const only)..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: let/var 사용 (const만 허용)${NC}"
  echo -e "${YELLOW}    FIX: const로. 재할당·누적이 필요하면 es-toolkit(map/reduce/groupBy/pipe 등)으로 불변하게 다시 쓰기(P-006.1).${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '(^|[^a-zA-Z_.])(let|var)[[:space:]]+[a-zA-Z_$]' --include="*.ts" --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -v '\.test\.')

# ── 11. useImperativeHandle — 명령형 탈출구 ───────────────────────────────────
echo "🔍 useImperativeHandle..."
while IFS= read -r file; do
  echo -e "${YELLOW}  ⚠ ${file}: useImperativeHandle${NC}"
  echo -e "${YELLOW}    FIX: props/state로 대체 가능한지 확인. 불가하면 사유를 인라인 주석으로 명시.${NC}"
  warnings=$((warnings+1))
done < <(grep -rln "useImperativeHandle" --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE")

# ── 12. React 19: useFormState → useActionState ───────────────────────────────
echo "🔍 useFormState (removed in React 19)..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: useFormState (제거됨)${NC}"
  echo -e "${YELLOW}    FIX: useFormState → useActionState 로 교체.${NC}"
  warnings=$((warnings+1))
done < <(grep -rn 'useFormState' --include="*.tsx" --include="*.ts" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE")

# ── 13. 네이티브 Date 포맷/조작 → dayjs ───────────────────────────────────────
echo "🔍 native Date methods (use dayjs)..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: native Date 메서드${NC}"
  echo -e "${YELLOW}    FIX: dayjs 사용(이미 의존성). 날짜 포맷·파싱·연산은 dayjs로 일관되게.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '\.getFullYear|\.getMonth\b|\.getDate\b|\.getHours|\.getMinutes|\.toLocaleDateString|\.toLocaleTimeString|Intl\.DateTimeFormat' --include="*.ts" --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -v '\.test\.')

# ── 14. 커스텀 훅 반환 필드 6개+ (소비자별 분리 검토) ─────────────────────────
echo "🔍 large hook return types..."
while IFS= read -r line; do
  count=$(echo "$line" | awk '{print $1}'); file=$(echo "$line" | awk '{print $2}')
  echo -e "${YELLOW}  ⚠ ${file}: hook 반환 타입 필드 ${count}개${NC}"
  echo -e "${YELLOW}    FIX: 소비자가 일부만 쓰면 discriminated union으로 phase별 노출 제한, 또는 소비자별 전용 훅 분리(P-013 God Hook).${NC}"
  warnings=$((warnings+1))
done < <(find "$SRC_GLOB" \( -name "use*.ts" -o -name "use*.tsx" \) 2>/dev/null | grep -vE "$EXCLUDE_RE" | while IFS= read -r f; do
  awk '
  /code-smell-ok\(/ { if (match($0,/code-smell-ok\([0-9]+\)/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9]/,"",s); approved=int(s) } next }
  /^export type .*= \{/ { in_type=1; c=0; depth=1; next }
  in_type {
    if (depth==1 && /readonly |: /) c++
    tmp=$0; depth+=gsub(/\{/,"",tmp); tmp=$0; depth-=gsub(/\}/,"",tmp)
    if (depth<=0) { th=(approved>0)?approved:6; if(c>th) print c, FILENAME; in_type=0; approved=0 }
  }' "$f" 2>/dev/null
done | sort -rn)

# ── 15. union 타입 variant 8개+ (phase 분리 검토) ─────────────────────────────
echo "🔍 large union types..."
while IFS= read -r line; do
  count=$(echo "$line" | awk '{print $1}'); file=$(echo "$line" | awk '{print $2}'); name=$(echo "$line" | awk '{print $3}')
  echo -e "${YELLOW}  ⚠ ${file}: type ${name} = ${count} variants${NC}"
  echo -e "${YELLOW}    FIX: A→B 후 안 돌아가는 구간=별도 phase로 분리. 소비자가 일부 variant만 쓰면 인터페이스 분리(P-031 예측가능성).${NC}"
  warnings=$((warnings+1))
done < <(find "$SRC_GLOB" \( -name "*.ts" -o -name "*.tsx" \) 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -v '\.test\.' | while IFS= read -r f; do
  awk '
  /code-smell-ok\(/ { if (match($0,/code-smell-ok\([0-9]+\)/)) { s=substr($0,RSTART,RLENGTH); gsub(/[^0-9]/,"",s); pend=int(s) } next }
  /^(export )?type [A-Z]/ { if (c>th) print c, FILENAME, nm; nm=($1=="export")?$3:$2; c=0; it=1; th=(pend>0)?pend:8; pend=0; next }
  it && /^[ ]*\|/ { c++ }
  END { if (c>th) print c, FILENAME, nm }' "$f" 2>/dev/null
done | sort -rn)

# ── 16. OR 체인 3회+ (=== || === ) → tuple.includes ───────────────────────────
echo "🔍 OR chain 3+ (→ includes)..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: === || === 3회 이상${NC}"
  echo -e "${YELLOW}    FIX: 같은 변수 반복 비교면 const XS = [...] as const + XS.includes(x)로. 이름으로 의도 표현.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '=== .*\|\|.*=== .*\|\|.*===' --include="*.tsx" --include="*.ts" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -v '\.test\.')

# ── 결과 ──────────────────────────────────────────────────────────────────────
# ── 17. 중복 컴포넌트명 (2개+ 파일에 같은 이름 선언 = 복붙 발산 신호) ─────────
echo "🔍 duplicate component names across files..."
while IFS= read -r line; do
  name=$(echo "$line" | awk '{print $1}'); files=$(echo "$line" | cut -d' ' -f2-)
  echo -e "${YELLOW}  ⚠ 컴포넌트 '${name}' 이(가) 여러 파일에 선언됨: ${files}${NC}"
  echo -e "${YELLOW}    FIX: 두 번째 사용처가 생긴 것 — 복붙하지 말고 상위 레이어(widgets/shared)로 승격해 한 곳에서 import하라 (FSD 5-1).${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '^(export )?(const|function) [A-Z][a-zA-Z0-9]+ *[=(]' --include="*.tsx" "$SRC_GLOB" 2>/dev/null \
  | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.' \
  | sed -E 's/^([^:]+):[0-9]+:(export )?(const|function) ([A-Za-z0-9]+).*/\4 \1/' \
  | grep -E '^[A-Z]' | grep -vE '^[A-Z_0-9]+ ' \
  | sort | awk '{files[$1]=files[$1]" "$2; count[$1]++} END {for (n in count) if (count[n]>1) print n files[n]}')

# ── 18. render 헬퍼 금지 (renderX 함수 → 모듈 레벨 컴포넌트로) ────────────────
echo "🔍 render helper functions..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: render 헬퍼 함수 (renderX)${NC}"
  echo -e "${YELLOW}    FIX: JSX를 반환하는 헬퍼는 모듈 레벨 컴포넌트로 추출하라 — 호출형 헬퍼는 React 최적화·lint(no-unstable-nested-components)의 사각지대다.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '(const|function) render[A-Z]' --include="*.tsx" --include="*.ts" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.')

# ── 19. 컴포넌트 JSX 반환타입 명시 금지 (추론에 맡김 — render 헬퍼 신호) ──────
echo "🔍 explicit JSX return types..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  has_ok "$file" "$ln" return-type && continue
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: JSX 반환타입 명시 (ReactNode/ReactElement/JSX.Element)${NC}"
  echo -e "${YELLOW}    FIX: 컴포넌트 반환타입은 추론에 맡겨라. 명시가 필요해 보이면 대개 render 헬퍼를 컴포넌트로 추출해야 한다는 신호다.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '\): *(Promise<)?(ReactNode|ReactElement|JSX\.Element)' --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.')

# ── 20. native <dialog> 금지 (shared/ui 래퍼 = radix Dialog 사용) ─────────────
echo "🔍 native dialog outside shared/ui..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: native <dialog> 사용${NC}"
  echo -e "${YELLOW}    FIX: shared/ui의 Dialog(radix)를 써라 — native dialog는 top-layer가 z-index를 무시해 포탈 UI가 조용히 가려진다.${NC}"
  warnings=$((warnings+1))
done < <(grep -rn '<dialog' --include="*.tsx" "$SRC_GLOB" 2>/dev/null | grep -v 'src/shared/ui/' | grep -vE "$EXCLUDE_RE")

# ── 21. 순수영역(entities·shared/lib) 배열 mutation 금지 ──────────────────────
echo "🔍 array mutation in pure layers..."
for dir in src/entities src/shared/lib; do
  [ -d "$dir" ] || continue
  while IFS= read -r m; do
    file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
    has_ok "$file" "$ln" mutation && continue
    echo -e "${YELLOW}  ⚠ ${file}:${ln}: 배열 mutation (.push/.sort/.splice 등)${NC}"
    echo -e "${YELLOW}    FIX: 불변으로 — toSorted/toSpliced/toReversed 또는 es-toolkit. 원본을 지켜야 할 뿐이면 [...arr].sort(cmp)로 복사본을 정렬하고 code-smell-ok(mutation)을 붙여도 된다. 타입 기반 lint(immutable-data)가 못 잡는 사각이라 여기서 막는다 (P-006.1).${NC}"
    warnings=$((warnings+1))
  done < <(grep -rnE '\.(push|pop|shift|unshift|splice|sort|reverse)\(' --include="*.ts" --include="*.tsx" "$dir" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.')
done

# ── 22. UX 카피 톤 혼용 금지 (이 템플릿의 기본 톤 = 해요체 — 니다체 차단) ─────
#        프로젝트가 니다체를 원하면 이 검사와 카피를 함께 반전시킬 것.
echo "🔍 UX copy tone (해요체 SSOT)..."
while IFS= read -r m; do
  file=$(echo "$m" | cut -d: -f1); ln=$(echo "$m" | cut -d: -f2)
  has_ok "$file" "$ln" tone && continue
  echo -e "${YELLOW}  ⚠ ${file}:${ln}: UX 카피에 니다체 (기본 톤은 해요체)${NC}"
  echo -e "${YELLOW}    FIX: '~습니다/~됩니다' → '~어요/~돼요'. 화면마다 톤이 다르면 다른 사람이 만든 티가 난다. 예외는 code-smell-ok(tone)+사유.${NC}"
  warnings=$((warnings+1))
done < <(grep -rnE '(습니다|합니다|입니다|됩니다)' --include="*.tsx" "$SRC_GLOB" 2>/dev/null \
  | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.' \
  | grep -vE ':[0-9]+: *(//|\*|/\*|\{/\*)')

# ── 23. export 컴포넌트 최상단 배치 (helper 컴포넌트는 export 아래로) ──────────
#        파일을 열면 주인공(공개 컴포넌트)이 먼저 보이게 한다. 비-export PascalCase
#        컴포넌트(화살표·function)가 공개 컴포넌트보다 위에 선언되면 위반.
#        상수·타입·스키마·Context는 위에 둬도 됨(컴포넌트가 아니므로 감지 안 함).
echo "🔍 exported component precedes helpers..."
while IFS= read -r file; do
  grep -q 'code-smell-ok(export-order)' "$file" 2>/dev/null && continue
  exp_ln=$(grep -nE '^export const [A-Za-z][A-Za-z0-9]* = \(|^export function [A-Za-z][A-Za-z0-9]*\(' "$file" 2>/dev/null | head -1 | cut -d: -f1)
  [ -n "$exp_ln" ] || continue
  bad=$(grep -nE '^const [A-Z][A-Za-z0-9]* = \(|^function [A-Z][A-Za-z0-9]*\(' "$file" 2>/dev/null \
    | awk -F: -v e="$exp_ln" '$1 < e {print $1; exit}')
  if [ -n "$bad" ]; then
    echo -e "${YELLOW}  ⚠ ${file}:${bad}: 비-export 컴포넌트가 공개 컴포넌트(라인 ${exp_ln})보다 위에 선언됨${NC}"
    echo -e "${YELLOW}    FIX: 파일의 공개(export) 컴포넌트를 최상단(상수·타입 아래)에 두고 helper 컴포넌트는 그 아래로. 예외는 code-smell-ok(export-order): 사유.${NC}"
    warnings=$((warnings+1))
  fi
done < <(find "$SRC_GLOB" -name '*.tsx' 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.')

# ── 24. react-table 인스턴스 소비 컴포넌트에 'use no memo' 강제 ───────────────
#        useReactTable의 table은 매 렌더 같은 참조를 유지하며 내부만 변이한다.
#        React Compiler가 이런 컴포넌트를 메모하면 데이터가 바뀌어도 리렌더를 건너뛴다
#        (stale 렌더 — 콘솔 에러 0, 다른 게이트 전부 통과. 실사용 관찰).
echo "🔍 table-consuming component without 'use no memo'..."
while IFS= read -r file; do
  echo -e "${YELLOW}  ⚠ ${file}: react-table 인스턴스(Table 타입)를 prop으로 받는데 'use no memo' 없음${NC}"
  echo -e "${YELLOW}    FIX: 컴포넌트 본문 첫 줄에 'use no memo'; — table은 같은 참조로 내부만 변이해 React Compiler 메모 시 데이터가 바뀌어도 표가 안 갱신된다(stale). 예외는 code-smell-ok(table-memo): 사유.${NC}"
  warnings=$((warnings+1))
done < <(for f in $(grep -rl "@tanstack/react-table" --include='*.tsx' "$SRC_GLOB" 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.'); do
  grep -qE 'type Table\b|TableInstance' "$f" 2>/dev/null || continue
  grep -q 'useReactTable' "$f" 2>/dev/null && continue
  grep -q 'use no memo' "$f" 2>/dev/null && continue
  grep -q 'code-smell-ok(table-memo)' "$f" 2>/dev/null && continue
  echo "$f"
done)

# ── 26. 한 파일에 모듈 레벨 컴포넌트가 몰림 ──────────────────────────────────
#        함수 120줄 게이트는 있는데 "한 파일에 컴포넌트 몇 개까지"라는 기준이 없어서,
#        페이지 하나에 5~6개를 몰아넣고 사람이 "파일 분리해줘"를 직접 요청해야 했다
#        (실사용 관찰). 임계값 5는 골든샘플 실측(최대 4개)에 맞춰 잡았다 —
#        3~4개는 페이지+하위 조각의 정상 범위라 오탐이 된다.
echo "🔍 too many module-level components in one file..."
while IFS= read -r line; do
  file="${line%%:*}"; n="${line##*:}"
  echo -e "${YELLOW}  ⚠ ${file}: 모듈 레벨 컴포넌트 ${n}개 — 한 파일에 너무 몰렸다${NC}"
  echo -e "${YELLOW}    FIX: 화면을 조각으로 나눠 같은 슬라이스의 ui/ 아래 파일로 분리하라(파일 하나 = 컴포넌트 하나가 기본). 예외는 code-smell-ok(component-count): 사유.${NC}"
  warnings=$((warnings+1))
done < <(for f in $(find "$SRC_GLOB" -name '*.tsx' 2>/dev/null | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.'); do
  grep -q 'code-smell-ok(component-count)' "$f" 2>/dev/null && continue
  n=$(grep -cE '^(export )?const [A-Z][A-Za-z0-9]* = \(' "$f")
  [ "$n" -ge 5 ] && echo "$f:$n"
done)

# ── 25. src 아래 소스가 gitignore에 삼켜지는지 ────────────────────────────────
#        앵커 없는 무시 패턴(coverage/·dist/)은 어느 깊이에서든 매칭돼 같은 이름의
#        도메인 슬라이스를 통째로 먹는다. 로컬에선 멀쩡히 돌아가고 커밋에서만 조용히
#        빠져서, 받는 사람 쪽에서 "모듈을 찾을 수 없음"으로 터진다
#        (실사용 관찰: src/shared/api/coverage 유실로 타입검사·빌드 실패).
echo "🔍 source files swallowed by gitignore..."
if git rev-parse --git-dir >/dev/null 2>&1; then
  while IFS= read -r file; do
    [ -n "$file" ] || continue
    echo -e "${YELLOW}  ⚠ ${file}: 소스인데 gitignore가 무시한다 — 커밋에 안 들어간다${NC}"
    echo -e "${YELLOW}    FIX: .gitignore의 해당 패턴을 루트로 앵커하라(coverage/ → /coverage/). 빌드 산출물을 막으려던 패턴이 같은 이름의 소스 폴더를 삼킨 것이다.${NC}"
    warnings=$((warnings+1))
  done < <(find "$SRC_GLOB" -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) 2>/dev/null \
             | git check-ignore --stdin 2>/dev/null | head -20)
fi

# ── 27. 도메인 쿼리 팩토리가 shared에 있는지 ────────────────────────────────
#        도메인 이름을 아는 코드(postQueries·지원자 mock)가 shared에 있으면 그건 shared가
#        아니다. shared는 다른 프로젝트에 그대로 복사해도 동작해야 하는 층이라, 도메인이
#        섞이는 순간 그 전제가 깨진다. 자리는 entities/<domain>/api/ (query-factory 스킬).
#        실사용 관찰에서 사내 FE 리뷰어 두 명이 서로 모르는 채 같은 지적을 냈고,
#        규모가 큰 사내 프로덕션 앱은 공용 레이어의 쿼리 모음을 0개까지 비우고 entities/*/api로
#        전부 옮겨 뒀다(2026-08-03 실측). 예외를 쓸 일은 거의 없다 — 쓰면 사유를 남긴다.
echo "🔍 domain query factory in shared..."
while IFS= read -r hit; do
  [ -n "$hit" ] || continue
  file=${hit%%:*}
  echo -e "${YELLOW}  ⚠ ${file}: 쿼리/뮤테이션 팩토리가 shared에 있다${NC}"
  echo -e "${YELLOW}    FIX: entities/<domain>/api/queries.ts(mutations.ts)로 옮기고 슬라이스 루트 index.ts로만 공개하라. 목 데이터·스키마도 같이 간다. 도메인이 아니라 BFF·외부 서비스 단위 호출이면 code-smell-ok(query-location): 사유.${NC}"
  warnings=$((warnings+1))
done < <(grep -rlE '\b(queryOptions|mutationOptions|infiniteQueryOptions)\(' \
           --include='*.ts' --include='*.tsx' "$SRC_GLOB/shared" 2>/dev/null \
           | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.' \
           | grep -v '/queryCache/' \
           | while IFS= read -r f; do
               grep -q 'code-smell-ok(query-location)' "$f" 2>/dev/null || echo "$f:"
             done)

# ── 28. 확인 다이얼로그 재발명 ───────────────────────────────────────────────
#        확인 창은 화면마다 새로 만들면 반드시 갈라진다 — 버튼 정렬이 달라지고 문구 톤이
#        달라진다. 골든샘플이 화면 전용이라 복제 대상이 없던 게 원인이었다.
#        실사용 관찰에서 두 갈래로 드러났다: 확인 창 버튼이 좌우로 벌어지고 문구가 어색해진
#        경우, 그리고 되돌릴 수 없는 동작에 확인 단계 자체가 없던 경우.
#        자리는 shared/ui의 openConfirmDialog 하나다.
echo "🔍 re-invented confirm dialog..."
while IFS= read -r f; do
  [ -n "$f" ] || continue
  echo -e "${YELLOW}  ⚠ ${f}: 확인 다이얼로그를 새로 만들었다${NC}"
  echo -e "${YELLOW}    FIX: shared/ui의 openConfirmDialog({ title, description, confirmLabel, tone })를 쓰라 — 화면마다 확인 창 모양·문구가 갈라지는 걸 막는다. 입력을 받는 등 정말 다른 창이면 code-smell-ok(confirm-dialog): 사유.${NC}"
  warnings=$((warnings+1))
done < <({
  find "$SRC_GLOB" -name '*.tsx' 2>/dev/null \
    | grep -iE '/[a-z0-9-]*confirm[a-z0-9-]*dialog\.tsx$|/[a-z0-9-]*dialog[a-z0-9-]*confirm\.tsx$|/alert-?dialog\.tsx$'
  grep -rl 'shared/ui/alert-dialog' --include='*.tsx' "$SRC_GLOB" 2>/dev/null
} | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.' | sort -u \
  | while IFS= read -r f; do
      grep -q 'code-smell-ok(confirm-dialog)' "$f" 2>/dev/null || echo "$f"
    done)

# ── 29. model/ 순수 로직에 테스트 없음 ──────────────────────────────────────
#        CLAUDE.md는 "도메인/로직은 테스트 의무"라고 선언만 하고 아무도 안 지켰다.
#        이게 중요한 이유는 커버리지가 아니라 인수인계다 — 비개발자가 만든 걸 개발자가
#        받을 때, 무엇을 의도했는지가 코드베이스에 남는 유일한 자리가 테스트다.
#        실사용 관찰에서 나온 지적이다: 다른 직군이 만든 것이 개발자에게 넘어오는 구조라면
#        테스트가 요구사항 문서 역할을 할 수 있다. 대상은 함수를 export하는 model/ 파일뿐 —
#        타입만 있는 파일은 검사하지 않는다.
echo "🔍 model logic without spec..."
while IFS= read -r f; do
  [ -n "$f" ] || continue
  echo -e "${YELLOW}  ⚠ ${f}: 순수 로직인데 테스트가 없다${NC}"
  echo -e "${YELLOW}    FIX: 같은 폴더에 ${f%.ts}.spec.ts를 만들고, 설계 문서(docs/features/)의 G-W-T 시나리오 문장을 describe/it 이름으로 그대로 옮겨라 — 나중에 이어받는 사람이 요구사항을 코드에서 읽는다. 테스트 가치가 없는 파일이면 code-smell-ok(model-spec): 사유.${NC}"
  warnings=$((warnings+1))
done < <(find "$SRC_GLOB" -path '*/model/*' -name '*.ts' ! -name '*.spec.ts' ! -name '*.test.ts' 2>/dev/null \
  | grep -vE "$EXCLUDE_RE" \
  | while IFS= read -r f; do
      grep -qE '^export (const [a-zA-Z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]+)?\(|function )' "$f" 2>/dev/null || continue
      grep -q 'code-smell-ok(model-spec)' "$f" 2>/dev/null && continue
      [ -f "${f%.ts}.spec.ts" ] || echo "$f"
    done)

# ── 30. 웹 스토리지를 감싸지 않고 쓰기 ──────────────────────────────────────
#        localStorage·sessionStorage는 던진다 — 프라이빗 모드·용량 초과·쿠키 차단.
#        감싸지 않으면 화면이 통째로 죽는데 타입검사도 린트도 이걸 못 잡는다.
#        실사용 리뷰에서 9게이트를 전부 통과한 코드에 이 부류 결함이 남아 있었다
#        (예외 미처리·JSON.parse 무검증 단언). 저장 코드는 복제되는 자리라 파급이 크다.
echo "🔍 unguarded web storage..."
while IFS= read -r f; do
  [ -n "$f" ] || continue
  echo -e "${YELLOW}  ⚠ ${f}: 웹 스토리지를 try/catch 없이 쓴다${NC}"
  echo -e "${YELLOW}    FIX: getItem/setItem을 try/catch로 감싸라 — 읽기는 기본값으로 계속 가고, 쓰기 실패는 Result로 돌려 사용자에게 알린다. JSON.parse 결과는 단언이 아니라 스키마로 검증한다(data-storage 스킬). 정말 예외가 필요하면 code-smell-ok(storage): 사유.${NC}"
  warnings=$((warnings+1))
done < <(grep -rlE '\b(localStorage|sessionStorage)\.(getItem|setItem|removeItem)\(' \
           --include='*.ts' --include='*.tsx' "$SRC_GLOB" 2>/dev/null \
           | grep -vE "$EXCLUDE_RE" | grep -vE '\.(test|spec)\.' \
           | while IFS= read -r f; do
               grep -q 'code-smell-ok(storage)' "$f" 2>/dev/null && continue
               grep -q 'catch' "$f" 2>/dev/null || echo "$f"
             done)

echo ""
if [ "$warnings" -gt 0 ]; then
  echo -e "${YELLOW}code-smell: ${warnings} warnings${NC}"
  exit 1
fi
echo "✅ code-smell: clean"
exit 0
