#!/usr/bin/env bash
# gate-selftest — 게이트가 "살아있는지" 검증하는 메타 게이트.
#
# 왜: 이 하네스의 게이트 다수는 grep/awk 기반이라, 정규식이 조용히 깨지거나
# 룰이 비활성화돼도 아무도 모른다("죽은 게이트인데 초록불"이 최악).
# 각 게이트마다 위반 픽스처(반드시 잡혀야 함)와 허용 픽스처(오탐 없어야 함)를
# 일부러 만들어 발동/침묵을 assert한다. (colin-web-harness 패턴 이식)
#
# 실행 시점: 게이트 파일(.claude/scripts|hooks, .oxlintrc.json) 변경 커밋의
# pre-commit + housekeeping + CI. 게이트를 새로 만들면 여기 픽스처 추가가 의무다
# (.claude/rules/gate-promotion.md).

set -u
cd "$(dirname "$0")/../.."   # 템플릿 루트

FIXDIR="src/pages/_gate-selftest"
SHARED_FIXDIR="src/shared/lib/_gate-selftest"
pass=0; fail=0

cleanup() { rm -rf "$FIXDIR" "$SHARED_FIXDIR"; }
trap cleanup EXIT
cleanup

RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'

ok()   { pass=$((pass+1)); }
bad()  { echo -e "${RED}  ✗ FAIL: $1${NC}"; fail=$((fail+1)); }

# write <상대경로> <<'EOF' ... — 픽스처 생성 헬퍼
write() { mkdir -p "$FIXDIR/$(dirname "$1")"; cat > "$FIXDIR/$1"; }
wipe()  { rm -rf "$FIXDIR"; }

# expect_block <게이트 이름> <명령...> — 위반 픽스처가 잡혀야 함 (exit != 0)
expect_block() {
  local name="$1"; shift
  if "$@" >/dev/null 2>&1; then bad "$name — 위반을 못 잡음 (죽은 게이트)"; else ok; fi
}
# expect_pass <게이트 이름> <명령...> — 허용 픽스처는 통과해야 함 (exit 0)
expect_pass() {
  local name="$1"; shift
  if "$@" >/dev/null 2>&1; then ok; else bad "$name — 허용 케이스를 오탐"; fi
}

SMELL="bash .claude/scripts/code-smell.sh"
SLOP="bash .claude/scripts/slop-check.sh"

echo "🧪 gate-selftest — 게이트 생존/오탐 검사"

# ── code-smell.sh ────────────────────────────────────────────────────────────
echo "  [code-smell]"

write ui/BoolPair.tsx <<'EOF'
import { useState } from 'react';
export const BoolPair = () => {
  const [a, setA] = useState(false);
  const [b, setB] = useState(false);
  return <button onClick={() => { setA(!a); setB(!b); }}>x</button>;
};
EOF
expect_block "boolean useState 2개" $SMELL
wipe

write ui/OneBool.tsx <<'EOF'
import { useState } from 'react';
export const OneBool = () => {
  const [open, setOpen] = useState(false);
  return <button onClick={() => { setOpen(!open); }}>{String(open)}</button>;
};
EOF
expect_pass "boolean useState 1개 (허용)" $SMELL
wipe

write ui/let-ban.ts <<'EOF'
let counter = 1;
export const next = () => counter++;
EOF
expect_block "let 금지" $SMELL
wipe

write ui/ForwardRef.tsx <<'EOF'
import { forwardRef } from 'react';
export const Input = forwardRef<HTMLInputElement>((props, ref) => <input ref={ref} {...props} />);
EOF
expect_block "forwardRef (React 19 불필요)" $SMELL
wipe

write ui/Provider.tsx <<'EOF'
import { createContext } from 'react';
const Ctx = createContext(0);
export const App = () => <Ctx.Provider value={1}>x</Ctx.Provider>;
EOF
expect_block "Context.Provider (React 19)" $SMELL
wipe

write ui/ProviderOk.tsx <<'EOF'
import { createContext } from 'react';
const Ctx = createContext(0);
export const App = () => <Ctx.Provider value={1}>x</Ctx.Provider>; // code-smell-ok(provider): 서드파티 API 요구
EOF
expect_pass "Context.Provider escape hatch (허용)" $SMELL
wipe

write ui/complex.ts <<'EOF'
export const f = (a: boolean, b: boolean, c: boolean) => {
  if (a && b && c) {
    return 1;
  }
  return 0;
};
EOF
expect_block "이름 없는 복합 조건" $SMELL
wipe

write ui/disable.ts <<'EOF'
// oxlint-disable-next-line no-console
export const log = () => console.log('x');
EOF
expect_block "사유 없는 lint-disable" $SMELL
wipe

# react-table 인스턴스 소비 컴포넌트 'use no memo' (#24 — React Compiler stale 렌더, 실사용 관찰)
write ui/TableNoMemo.tsx <<'EOF'
import type { Table as TableInstance } from '@tanstack/react-table';
export const TableNoMemo = ({ table }: { table: TableInstance<{ id: number }> }) => {
  return <p>{table.getRowModel().rows.length}</p>;
};
EOF
expect_block "table 소비 컴포넌트 use-no-memo 없음" $SMELL
wipe

write ui/TableWithMemo.tsx <<'EOF'
import type { Table as TableInstance } from '@tanstack/react-table';
export const TableWithMemo = ({ table }: { table: TableInstance<{ id: number }> }) => {
  'use no memo';
  return <p>{table.getRowModel().rows.length}</p>;
};
EOF
expect_pass "table 소비 + use no memo (허용)" $SMELL
wipe

# ── slop-check.sh (TARGET 인자 지원 — 픽스처 디렉토리만 스캔) ────────────────
echo "  [slop-check]"

write ui/emoji.tsx <<'EOF'
export const E = () => <span>🚀</span>;
EOF
expect_block "emoji 아이콘" $SLOP "$FIXDIR"
wipe

write ui/grad.tsx <<'EOF'
export const G = () => <span className="bg-clip-text">x</span>;
EOF
expect_block "gradient-text" $SLOP "$FIXDIR"
wipe

write ui/glass.tsx <<'EOF'
export const G = () => <div className="backdrop-blur">x</div>;
EOF
expect_block "glassmorphism" $SLOP "$FIXDIR"
wipe

write ui/eyebrow.tsx <<'EOF'
export const E = () => <p className="uppercase text-sm tracking-wide">LABEL</p>;
EOF
expect_block "uppercase eyebrow" $SLOP "$FIXDIR"
wipe

write ui/font.css <<'EOF'
@import url('https://fonts.googleapis.com/css2?family=Inter');
EOF
expect_block "font import" $SLOP "$FIXDIR"
wipe

write ui/hex.tsx <<'EOF'
export const H = () => <div style={{ color: '#ff0000' }}>x</div>;
EOF
expect_block "inline hex style" $SLOP "$FIXDIR"
wipe

write ui/CleanCard.tsx <<'EOF'
export const CleanCard = () => {
  return <div className="rounded-lg border p-4 text-sm text-muted-foreground">clean</div>;
};
EOF
expect_pass "무해한 컴포넌트 (허용)" $SLOP "$FIXDIR"
# (같은 픽스처로 code-smell 오탐도 확인)
expect_pass "무해한 컴포넌트 code-smell (허용)" $SMELL
wipe

# ── post-edit-check.sh (PostToolUse 훅 — stdin JSON) ─────────────────────────
echo "  [post-edit-check]"

hook_out() { # <파일 상대경로> — additionalContext 출력 (없으면 빈 문자열)
  printf '{"tool_input":{"file_path":"%s/%s"}}' "$(pwd)" "$1" \
    | bash .claude/hooks/post-edit-check.sh 2>/dev/null || true
}

write ui/memo.tsx <<'EOF'
import { useCallback } from 'react';
export const M = () => {
  const f = useCallback(() => 1, []);
  return <button onClick={f}>x</button>;
};
EOF
out=$(hook_out "$FIXDIR/ui/memo.tsx")
case "$out" in *"메모이제이션"*) ok ;; *) bad "post-edit-check: 수동 메모 미탐" ;; esac
wipe

write ui/anyfile.ts <<'EOF'
export const f = (x: any) => x;
EOF
out=$(hook_out "$FIXDIR/ui/anyfile.ts")
case "$out" in *any*) ok ;; *) bad "post-edit-check: any 미탐 (oxlint 단일파일 검사 죽음?)" ;; esac
wipe

# Suspense/useSuspenseQuery인데 같은 파일에 ErrorBoundary 없음 (P-041 경계 co-location — 2회+ 재발로 승격)
write ui/suspense-alone.tsx <<'EOF'
import { useSuspenseQuery } from '@tanstack/react-query';
export const S = () => {
  const { data } = useSuspenseQuery({ queryKey: ['x'], queryFn: async () => 1 });
  return <p>{data}</p>;
};
EOF
out=$(hook_out "$FIXDIR/ui/suspense-alone.tsx")
case "$out" in *"ErrorBoundary 없음"*) ok ;; *) bad "post-edit-check: Suspense 경계 누락 미탐" ;; esac
wipe

write ui/suspense-paired.tsx <<'EOF'
import { ErrorBoundary, Suspense } from '@suspensive/react';
export const SP = () => {
  return (
    <ErrorBoundary fallback={() => undefined}>
      <Suspense fallback={undefined}>
        <p>ok</p>
      </Suspense>
    </ErrorBoundary>
  );
};
EOF
out=$(hook_out "$FIXDIR/ui/suspense-paired.tsx")
case "$out" in *"ErrorBoundary 없음"*) bad "post-edit-check: 경계 동반 파일 오탐" ;; *) ok ;; esac
wipe

# 경계가 구조적으로 딴 파일에 있는 경우의 escape (RootLayout — 라우트 에러는 errorElement가 잡음)
write ui/suspense-escaped.tsx <<'EOF'
// code-smell-ok(boundary): 라우트 에러는 router의 errorElement가 잡는다
import { Suspense } from 'react';
export const SE = () => {
  return (
    <Suspense fallback={undefined}>
      <p>ok</p>
    </Suspense>
  );
};
EOF
out=$(hook_out "$FIXDIR/ui/suspense-escaped.tsx")
case "$out" in *"ErrorBoundary 없음"*) bad "post-edit-check: code-smell-ok(boundary) escape 무시" ;; *) ok ;; esac
wipe

write ui/clean-hook.ts <<'EOF'
export const sum = (a: number, b: number) => a + b;
EOF
out=$(hook_out "$FIXDIR/ui/clean-hook.ts")
if [ -z "$out" ]; then ok; else bad "post-edit-check: 클린 파일 오탐 — $out"; fi
wipe

# export-order 미러 (code-smell #23 — helper가 export 컴포넌트보다 위. 재발로 authoring 승격)
write ui/order-bad.tsx <<'EOF'
const Helper = () => {
  return <p>h</p>;
};
export const OrderBadPage = () => {
  return <Helper />;
};
EOF
out=$(hook_out "$FIXDIR/ui/order-bad.tsx")
case "$out" in *"보다 위에 있음"*) ok ;; *) bad "post-edit-check: export-order 미탐" ;; esac
wipe

# table 소비 'use no memo' 미러 (code-smell #24 — React Compiler stale 렌더)
write ui/table-no-memo.tsx <<'EOF'
import type { Table as TableInstance } from '@tanstack/react-table';
export const T = ({ table }: { table: TableInstance<{ id: number }> }) => {
  return <p>{table.getRowModel().rows.length}</p>;
};
EOF
out=$(hook_out "$FIXDIR/ui/table-no-memo.tsx")
case "$out" in *"use no memo"*) ok ;; *) bad "post-edit-check: table 소비 use-no-memo 누락 미탐" ;; esac
wipe

write ui/table-with-memo.tsx <<'EOF'
import type { Table as TableInstance } from '@tanstack/react-table';
export const T = ({ table }: { table: TableInstance<{ id: number }> }) => {
  'use no memo';
  return <p>{table.getRowModel().rows.length}</p>;
};
EOF
out=$(hook_out "$FIXDIR/ui/table-with-memo.tsx")
case "$out" in *"stale 렌더"*) bad "post-edit-check: use no memo 있는 파일 오탐" ;; *) ok ;; esac
wipe

write ui/order-ok.tsx <<'EOF'
export const OrderOkPage = () => {
  return <Small />;
};
const Small = () => {
  return <p>s</p>;
};
EOF
out=$(hook_out "$FIXDIR/ui/order-ok.tsx")
case "$out" in *"보다 위에 있음"*) bad "post-edit-check: export-order 오탐 (export 먼저인 파일)" ;; *) ok ;; esac
wipe

# 소문자 export(openXxx 등)도 최상단 대상 — 대문자 컴포넌트 export만 보던 사각의 확장 (2026-07-30 재발 2회)
write ui/order-lower.tsx <<'EOF'
const HelperCard = () => {
  return <p>h</p>;
};
export const openThing = () => {
  return <HelperCard />;
};
EOF
out=$(hook_out "$FIXDIR/ui/order-lower.tsx")
case "$out" in *"보다 위에 있음"*) ok ;; *) bad "post-edit-check: export-order 소문자 export 미탐" ;; esac
wipe

# F1 회귀: 세션(CLAUDE_PROJECT_DIR)이 엉뚱한 폴더를 가리켜도, 편집 파일에서 프로젝트 루트를
# 스스로 찾아 검사한다. (실사용 관찰: sibling 폴더를 편집하니 rel이 절대경로로 남아
# 매 저장 무음 no-op이었음 — 자동 포맷·lint·smell 층이 통째로 죽어 있었다)
write ui/crossdir.ts <<'EOF'
export const f = (x: any) => x;
EOF
out=$(printf '{"tool_input":{"file_path":"%s/%s/ui/crossdir.ts"}}' "$(pwd)" "$FIXDIR" \
  | CLAUDE_PROJECT_DIR=/tmp bash .claude/hooks/post-edit-check.sh 2>/dev/null || true)
case "$out" in *any*) ok ;; *) bad "post-edit-check: 엉뚱한 CLAUDE_PROJECT_DIR에서 미탐 (F1 회귀)" ;; esac
wipe

# 프로젝트 밖 파일은 검사하지 않는다 (package.json 조상이 없으면 조용히 skip — 다른 레포 오탐 금지)
PEC_OUTDIR=$(mktemp -d)
printf 'let outside = 1;\n' > "$PEC_OUTDIR/outside.ts"
out=$(printf '{"tool_input":{"file_path":"%s/outside.ts"}}' "$PEC_OUTDIR" \
  | bash .claude/hooks/post-edit-check.sh 2>/dev/null || true)
if [ -z "$out" ]; then ok; else bad "post-edit-check: 프로젝트 밖 파일 오탐 — $out"; fi
rm -rf "$PEC_OUTDIR"

# ── oxlint 핵심 룰 생존 (비-type-aware 단일 파일) ────────────────────────────
echo "  [oxlint rules alive]"
OXLINT="./node_modules/.bin/oxlint"
if [ -x "$OXLINT" ]; then
  write ui/ox-any.ts <<'EOF'
export const f = (x: any) => x;
EOF
  expect_block "oxlint no-explicit-any" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-any.ts"
  wipe

  write ui/ox-let.ts <<'EOF'
export const f = () => {
  let x = 1;
  return x;
};
EOF
  expect_block "oxlint functional/no-let" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-let.ts"
  wipe

  write ui/ox-memo.tsx <<'EOF'
import { useMemo } from 'react';
export const C = () => {
  const v = useMemo(() => 1, []);
  return <span>{v}</span>;
};
EOF
  expect_block "oxlint no-restricted-imports(manual memo)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-memo.tsx"
  wipe

  # 챕터 결정: 중첩 삼항 금지 (nested-ternary-1dep, 5/5 keep-going)
  write ui/ox-ternary.ts <<'EOF'
export const f = (a: boolean, b: boolean) => (a ? 1 : b ? 2 : 3);
EOF
  expect_block "oxlint no-nested-ternary(챕터 결정)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-ternary.ts"
  wipe

  # 챕터 결정: 배열 타입은 T[] (array-type-t-brackets)
  write ui/ox-arraytype.ts <<'EOF'
export const xs: Array<string> = [];
EOF
  expect_block "oxlint typescript/array-type(챕터 결정)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-arraytype.ts"
  wipe

  # 사용자 결정(2026-07-30): void 연산자 금지 — 짝으로 no-floating-promises를 껐으므로
  # bare promise 호출이 통과해야 한다 (한쪽만 살아 있으면 void 강제/금지 순환이 생김)
  write ui/ox-void.ts <<'EOF'
export const fire = (run: () => Promise<number>) => {
  void run();
};
EOF
  expect_block "oxlint no-void(void 연산자 금지)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-void.ts"
  wipe

  write ui/ox-bare-promise.ts <<'EOF'
export const fire = (run: () => Promise<number>) => {
  run();
};
EOF
  expect_pass "oxlint bare promise 호출 허용(no-floating-promises off)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-bare-promise.ts"
  wipe

  # 사용자 결정(2026-07-30): default export 금지 — named export만 (설정 파일은 overrides로 예외)
  write ui/ox-default-export.ts <<'EOF'
const value = 1;
export default value;
EOF
  expect_block "oxlint import/no-default-export(named export만)" "$OXLINT" --format=unix --deny-warnings "$FIXDIR/ui/ox-default-export.ts"
  wipe
else
  echo "  (oxlint 바이너리 없음 — 룰 생존 검사 skip. pnpm install 후 재실행)"
fi

# ── PR4 신규 게이트 (룰 캡 · no-deprecated · 복붙/톤/부호 검사) ──────────────
echo "  [expanded gates]"

if [ -x "./node_modules/.bin/oxlint" ]; then
  write ui/deep.ts <<'EOF'
export const f = (a: boolean, b: boolean, c: boolean, d: boolean) => {
  if (a) {
    if (b) {
      if (c) {
        if (d) {
          return 1;
        }
      }
    }
  }
  return 0;
};
EOF
  expect_block "oxlint max-depth 3" "$OXLINT" --format=unix "$FIXDIR/ui/deep.ts"
  wipe

  write ui/deprecated.ts <<'EOF'
import { z } from 'zod';
export const emailSchema = z.string().email();
EOF
  expect_block "oxlint no-deprecated (type-aware)" "$OXLINT" --type-aware --format=unix "$FIXDIR/ui/deprecated.ts"
  wipe
fi

write ui/DupA.tsx <<'EOF'
export const DupWidget = () => <span>a</span>;
EOF
write ui/DupB.tsx <<'EOF'
export const DupWidget = () => <span>b</span>;
EOF
expect_block "중복 컴포넌트명 (복붙 발산)" $SMELL
wipe

write ui/render-helper.tsx <<'EOF'
export const renderBadge = (label: string) => <span>{label}</span>;
EOF
expect_block "render 헬퍼" $SMELL
wipe

write ui/return-type.tsx <<'EOF'
import type { ReactNode } from 'react';
export const Card = (): ReactNode => <div>x</div>;
EOF
expect_block "JSX 반환타입 명시" $SMELL
wipe

write ui/native-dialog.tsx <<'EOF'
export const Modal = () => <dialog open>x</dialog>;
EOF
expect_block "native dialog" $SMELL
wipe

mkdir -p "$SHARED_FIXDIR"
printf "export const addItem = (xs: number[], x: number) => {\n  xs.push(x);\n  return xs;\n};\n" > "$SHARED_FIXDIR/mutate.ts"
expect_block "순수영역 배열 mutation" $SMELL
# 예외 주석은 지적 지점의 앞·같은·뒤 줄에서 인정된다. 셋을 모두 고정하는 이유: 같은 줄만
# 보던 시절 oxfmt가 인라인 주석을 다음 줄로 밀어내 예외를 달 방법이 사라졌다(포맷 게이트와
# code-smell이 서로를 실패시키는 교착). 한 자리라도 퇴행하면 여기서 잡힌다.
printf "export const sorted = (xs: number[]) => [...xs].sort((a, b) => a - b); // code-smell-ok(mutation)\n" > "$SHARED_FIXDIR/mutate.ts"
expect_pass "배열 mutation 예외 — 같은 줄 (허용)" $SMELL
printf "// code-smell-ok(mutation)\nexport const sorted = (xs: number[]) => [...xs].sort((a, b) => a - b);\n" > "$SHARED_FIXDIR/mutate.ts"
expect_pass "배열 mutation 예외 — 앞 줄 (허용)" $SMELL
printf "export const sorted = (xs: number[]) =>\n  [...xs].sort((a, b) => {\n    // code-smell-ok(mutation)\n    return a - b;\n  });\n" > "$SHARED_FIXDIR/mutate.ts"
expect_pass "배열 mutation 예외 — 뒤 줄, 포매터가 밀어낸 자리 (허용)" $SMELL
rm -rf "$SHARED_FIXDIR"

mkdir -p "$SHARED_FIXDIR"
printf "const K = 'k';\nexport const read = () => JSON.parse(localStorage.getItem(K) ?? '[]');\n" > "$SHARED_FIXDIR/storage.ts"
expect_block "웹 스토리지 미감쌈" $SMELL
printf "const K = 'k';\nexport const read = () => {\n  try {\n    return JSON.parse(localStorage.getItem(K) ?? '[]');\n  } catch {\n    return [];\n  }\n};\n" > "$SHARED_FIXDIR/storage.ts"
expect_pass "웹 스토리지 try/catch (허용)" $SMELL
printf "// code-smell-ok(storage): 초기화 시점 1회 읽기라 실패해도 무해\nconst K = 'k';\nexport const read = () => localStorage.getItem(K);\n" > "$SHARED_FIXDIR/storage.ts"
expect_pass "웹 스토리지 사유 예외 (허용)" $SMELL
rm -f "$SHARED_FIXDIR/storage.ts"

printf "import { queryOptions } from '@tanstack/react-query';\nexport const jobQueries = {\n  list: () => queryOptions({ queryKey: ['jobs'], queryFn: async () => [] }),\n};\n" > "$SHARED_FIXDIR/queries.ts"
expect_block "도메인 쿼리 팩토리가 shared에" $SMELL
printf "// code-smell-ok(query-location): BFF 단위 호출이라 도메인에 안 붙는다\nimport { queryOptions } from '@tanstack/react-query';\nexport const bffQueries = {\n  me: () => queryOptions({ queryKey: ['bff', 'me'], queryFn: async () => null }),\n};\n" > "$SHARED_FIXDIR/queries.ts"
expect_pass "shared 쿼리 팩토리 사유 예외 (허용)" $SMELL
rm -rf "$SHARED_FIXDIR"

write ui/ConfirmDialog.tsx <<'EOF'
export const ConfirmDialog = () => <div />;
EOF
expect_block "확인 다이얼로그 재발명(이름)" $SMELL
wipe

write ui/Risky.tsx <<'EOF'
import { AlertDialog } from '@/shared/ui/alert-dialog';
export const Risky = () => <AlertDialog />;
EOF
expect_block "확인 다이얼로그 재발명(alert-dialog 직접)" $SMELL
wipe

write ui/ConfirmDialog.tsx <<'EOF'
// code-smell-ok(confirm-dialog): 입력을 받아야 하는 확인 창이라 공용 부품으로 안 된다
export const ConfirmDialog = () => <div />;
EOF
expect_pass "확인 다이얼로그 사유 예외 (허용)" $SMELL
wipe

write model/score.ts <<'EOF'
export const scoreOf = (n: number) => n * 2;
EOF
expect_block "model 순수 로직에 spec 없음" $SMELL
write model/score.spec.ts <<'EOF'
import { scoreOf } from './score';
describe('점수', () => {
  it('두 배로 만든다', () => {
    expect(scoreOf(2)).toBe(4);
  });
});
EOF
expect_pass "model + spec 쌍 (허용)" $SMELL
wipe

write model/types.ts <<'EOF'
export type Only = { a: string };
EOF
expect_pass "model 타입 전용 파일 (허용)" $SMELL
wipe

write ui/tone.tsx <<'EOF'
export const Notice = () => <p>저장이 완료되었습니다.</p>;
EOF
expect_block "UX 카피 니다체 혼용" $SMELL
wipe

write ui/tone-ok.tsx <<'EOF'
export const Notice = () => <p>저장이 끝났어요.</p>;
EOF
expect_pass "UX 카피 해요체 (허용)" $SMELL
wipe

write ui/export-order.tsx <<'EOF'
const Helper = () => <span>x</span>;
export const Main = () => <Helper />;
EOF
expect_block "helper 컴포넌트가 export보다 위" $SMELL
wipe

write ui/export-order-ok.tsx <<'EOF'
import { createContext } from 'react';
const Ctx = createContext(0);
export const Main = () => (
  <Ctx value={1}>
    <Helper />
  </Ctx>
);
const Helper = () => <span>y</span>;
EOF
expect_pass "export 최상단 + Context 상수 위 (허용)" $SMELL
wipe

# 한 파일에 컴포넌트 몰림 (code-smell #26). 임계값 5는 골든샘플 실측(최대 4개) 기준 —
# 허용 픽스처를 4개로 두어 골든샘플이 자기 규칙에 걸리지 않는 것도 함께 지킨다.
write ui/many.tsx <<'EOF'
export const Main = () => <div><A /><B /><C /><D /></div>;
const A = () => <p>a</p>;
const B = () => <p>b</p>;
const C = () => <p>c</p>;
const D = () => <p>d</p>;
EOF
expect_block "code-smell: 한 파일 컴포넌트 5개+" $SMELL
wipe

write ui/few.tsx <<'EOF'
export const Main = () => <div><A /><B /><C /></div>;
const A = () => <p>a</p>;
const B = () => <p>b</p>;
const C = () => <p>c</p>;
EOF
expect_pass "한 파일 컴포넌트 4개 (허용)" $SMELL
wipe

# gitignore가 소스를 삼키는지 (code-smell #25). 앵커 없는 무시 패턴이 같은 이름의
# 도메인 슬라이스를 먹으면 로컬은 멀쩡한데 커밋에서만 빠진다 — 받는 쪽에서 빌드가 깨진다.
if git rev-parse --git-dir >/dev/null 2>&1; then
  write ui/swallowed/index.ts <<'EOF'
export const x = 1;
EOF
  # 무시 규칙은 픽스처 안에 둔다 — .gitignore 패턴은 그 파일이 있는 디렉토리 기준이라
  # 레포 루트를 건드릴 필요가 없고, wipe가 픽스처째 지워 뒷정리도 따라온다.
  write ui/swallowed/.gitignore <<'EOF'
index.ts
EOF
  expect_block "code-smell: gitignore가 소스를 삼킴" $SMELL
  rm -f "$FIXDIR/ui/swallowed/.gitignore"
  expect_pass "gitignore 무시 없음 (허용)" $SMELL
  wipe
fi

write ui/svg.tsx <<'EOF'
export const Icon = () => <svg viewBox="0 0 8 8" />;
EOF
expect_block "인라인 svg" $SLOP "$FIXDIR"
wipe

write ui/emdash.tsx <<'EOF'
export const T = () => <p>저장 — 완료</p>;
EOF
expect_block "em dash" $SLOP "$FIXDIR"
wipe

# ── FSD 경계 게이트 생존 (oxlint fsd 플러그인 · depcruise · steiger) ─────────
echo "  [fsd boundary gates]"
# 검사 범위는 픽스처 폴더로 한정한다. src 전체를 스캔하면 사용자 코드의 실제 위반이
# expect_pass(허용 케이스)를 실패시키면서 "게이트가 허용 케이스를 오탐"이라는 엉뚱한 진단을
# 내놓는다 — 사용자는 자기 코드가 원인인 줄 모르고 게이트를 뜯게 된다(실사용 관찰).
# 픽스처 경로도 src/pages/... 로 시작하므로 ^src 기반 룰은 그대로 매칭된다.
DEPCRUISE="./node_modules/.bin/depcruise $FIXDIR --config .dependency-cruiser.cjs"
STEIGER="./node_modules/.bin/steiger src"

if [ -x "./node_modules/.bin/oxlint" ]; then
  # 상향 import (shared → pages) — fsd/forbidden-imports
  mkdir -p "$SHARED_FIXDIR"
  printf "import { HomePage } from '@/pages/home';\nexport const bad = HomePage;\n" > "$SHARED_FIXDIR/upward.ts"
  expect_block "fsd 상향 import (shared→pages)" "$OXLINT" --format=unix "$SHARED_FIXDIR/upward.ts"
  rm -rf "$SHARED_FIXDIR"

  # 같은 레이어 cross-slice — fsd/no-cross-slice-dependency
  write ui/cross.ts <<'EOF'
import { HomePage } from '@/pages/home';
export const bad = HomePage;
EOF
  expect_block "fsd cross-slice (pages↔pages)" "$OXLINT" --format=unix "$FIXDIR/ui/cross.ts"
  wipe
fi

if [ -x "./node_modules/.bin/depcruise" ]; then
  # 헤드리스 원자재 직수입 (shared/ui 밖) — 봉인 규칙. 원자재가 세 갈래라 셋 다 본다.
  write ui/radix.ts <<'EOF'
import { Dialog } from 'radix-ui';
export const bad = Dialog;
EOF
  expect_block "depcruise radix 봉인" $DEPCRUISE
  wipe

  write ui/baseui.ts <<'EOF'
import { Combobox } from '@base-ui/react';
export const bad = Combobox;
EOF
  expect_block "depcruise base-ui 봉인" $DEPCRUISE
  wipe

  write ui/shadcnreact.ts <<'EOF'
import { MessageScroller } from '@shadcn/react/message-scroller';
export const bad = MessageScroller;
EOF
  expect_block "depcruise @shadcn/react 봉인" $DEPCRUISE
  wipe

  # orval 생성물 값 직수입 — 봉인 규칙
  write ui/gen.ts <<'EOF'
import { getPosts } from '@/shared/api/_generated/api';
export const bad = getPosts;
EOF
  expect_block "depcruise 생성물 봉인" $DEPCRUISE
  wipe

  # 생성물 타입 import는 허용 (type-only 예외)
  write ui/gen-type.ts <<'EOF'
import type { Post } from '@/shared/api/_generated/api.schemas';
export type P = Post;
EOF
  expect_pass "depcruise 생성물 type-only (허용)" $DEPCRUISE
  wipe

  # 순환 의존
  mkdir -p "$SHARED_FIXDIR"
  printf "import { b } from './circ-b';\nexport const a = () => b;\n" > "$SHARED_FIXDIR/circ-a.ts"
  printf "import { a } from './circ-a';\nexport const b = () => a;\n" > "$SHARED_FIXDIR/circ-b.ts"
  expect_block "depcruise 순환 의존" $DEPCRUISE
  rm -rf "$SHARED_FIXDIR"
fi

if [ -x "./node_modules/.bin/steiger" ]; then
  # 슬라이스 공개 API 우회 (깊은 경로 import — @/entities/post 가 아니라 내부 파일 직접)
  write index.ts <<'EOF'
export { Sidestep } from './ui/Sidestep';
EOF
  write ui/Sidestep.tsx <<'EOF'
import { postQueries } from '@/entities/post/api/queries';
export const Sidestep = () => <span>{String(postQueries)}</span>;
EOF
  expect_block "steiger 공개 API 우회" $STEIGER
  wipe

  # 규정 준수 슬라이스는 통과
  write index.ts <<'EOF'
export { Fine } from './ui/Fine';
EOF
  write ui/Fine.tsx <<'EOF'
import { postQueries } from '@/shared/api';
export const Fine = () => <span>{String(postQueries)}</span>;
EOF
  expect_pass "steiger 규정 준수 슬라이스 (허용)" $STEIGER
  wipe
fi

# ── knip (죽은 파일/export 검출) 생존 ────────────────────────────────────────
echo "  [knip]"
if [ -x "./node_modules/.bin/knip" ]; then
  # knip.jsonc가 _gate-selftest를 ignore하므로 픽스처는 shared/lib 직하에 둔다
  printf "export const orphanNeverImported = 1;\n" > src/shared/lib/knip-orphan-fixture.ts
  expect_block "knip 죽은 파일" ./node_modules/.bin/knip --no-config-hints
  rm -f src/shared/lib/knip-orphan-fixture.ts
fi

# ── PreToolUse 가드 훅 (deny/allow 12케이스) ─────────────────────────────────
echo "  [guard hooks]"
guard() { printf '{"tool_input":{"command":"%s"}}' "$1" | bash ".claude/hooks/$2" 2>/dev/null || true; }
guard_file() { printf '{"tool_input":{"file_path":"%s"}}' "$1" | bash ".claude/hooks/$2" 2>/dev/null || true; }

# no-verify-guard — 차단 3
case "$(guard 'git commit --no-verify -m x' no-verify-guard.sh)" in *deny*) ok ;; *) bad "no-verify: commit --no-verify 통과됨" ;; esac
case "$(guard 'git commit -n -m x' no-verify-guard.sh)" in *deny*) ok ;; *) bad "no-verify: commit -n 통과됨" ;; esac
case "$(guard 'git push --no-verify origin main' no-verify-guard.sh)" in *deny*) ok ;; *) bad "no-verify: push --no-verify 통과됨" ;; esac
# no-verify-guard — 허용(오탐 방지) 5
case "$(guard 'git log -n 5' no-verify-guard.sh)" in '') ok ;; *) bad "no-verify: git log -n 오탐" ;; esac
case "$(guard 'git grep -n foo src' no-verify-guard.sh)" in '') ok ;; *) bad "no-verify: git grep -n 오탐" ;; esac
case "$(guard 'git push -n origin main' no-verify-guard.sh)" in '') ok ;; *) bad "no-verify: push -n(dry-run) 오탐" ;; esac
case "$(guard 'git commit -m \\"docs: mention --no-verify flag\\"' no-verify-guard.sh)" in '') ok ;; *) bad "no-verify: 따옴표 안 문자열 오탐" ;; esac
case "$(guard 'git commit -m x' no-verify-guard.sh)" in '') ok ;; *) bad "no-verify: 정상 커밋 오탐" ;; esac
# admin-merge-guard
case "$(guard 'gh pr merge 5 --admin' admin-merge-guard.sh)" in *deny*) ok ;; *) bad "admin-merge: --admin 통과됨" ;; esac
case "$(guard 'gh pr merge 5 --squash' admin-merge-guard.sh)" in '') ok ;; *) bad "admin-merge: --squash 오탐" ;; esac

# new-component-guard — 슬라이스 단위 deny-once (첫 파일 1회 거부 → 재시도·같은 슬라이스 후속 파일 통과)
NCG_MARKER="$(git rev-parse --git-dir 2>/dev/null)/harness-guards/ncg-$(printf '%s' 'pages/_ncg-probe' | cksum | cut -d' ' -f1)"
rm -f "$NCG_MARKER" 2>/dev/null
out1=$(guard_file "$(pwd)/src/pages/_ncg-probe/ui/Probe.tsx" new-component-guard.sh)
out2=$(guard_file "$(pwd)/src/pages/_ncg-probe/ui/Probe.tsx" new-component-guard.sh)
out3=$(guard_file "$(pwd)/src/pages/_ncg-probe/ui/Probe2.tsx" new-component-guard.sh)
case "$out1" in *deny*) ok ;; *) bad "new-component: 첫 생성이 deny되지 않음" ;; esac
if [ -z "$out2" ]; then ok; else bad "new-component: 재시도가 통과되지 않음"; fi
if [ -z "$out3" ]; then ok; else bad "new-component: 같은 슬라이스 후속 파일이 재차단됨"; fi
case "$(guard_file "$(pwd)/src/pages/home/ui/Probe.spec.tsx" new-component-guard.sh)" in '') ok ;; *) bad "new-component: spec 파일 오탐" ;; esac

# ds-primitive-guard — deny-once
DSG_PATH="$(pwd)/src/shared/ui/button.tsx"
out1=$(guard_file "$DSG_PATH" ds-primitive-guard.sh)
out2=$(guard_file "$DSG_PATH" ds-primitive-guard.sh)
case "$out1" in *deny*) ok ;; *) bad "ds-primitive: shared/ui 편집이 deny되지 않음" ;; esac
if [ -z "$out2" ]; then ok; else bad "ds-primitive: 재시도가 통과되지 않음"; fi

# ux-contract-guard — 설계 단계 UX 계약 (차단 3 / 허용 3)
UXG=$(mktemp -d)
mkdir -p "$UXG/src/pages/_uxg/ui" "$UXG/docs/features"
uxg() { printf '{"tool_input":{"file_path":"%s"}}' "$1" | bash .claude/hooks/ux-contract-guard.sh 2>/dev/null || true; }
UXG_FULL='- **register**: product\n- **되돌릴 수 없는 동작**: 없음\n- **빈 상태**: a\n- **로딩·실패**: b\n- **최소 폭**: c\n'
case "$(uxg "$UXG/src/pages/_uxg/ui/Page.tsx")" in *deny*) ok ;; *) bad "ux-contract: 설계 문서 없이 통과됨" ;; esac
printf '# f\npages/_uxg\n- **register**: product\n' > "$UXG/docs/features/f.md"
case "$(uxg "$UXG/src/pages/_uxg/ui/Page.tsx")" in *deny*) ok ;; *) bad "ux-contract: 계약 4줄 누락이 통과됨" ;; esac
printf "# f\npages/_uxg\n- **register**: ?\n${UXG_FULL}" > "$UXG/docs/features/f.md"
case "$(uxg "$UXG/src/pages/_uxg/ui/Page.tsx")" in *deny*) ok ;; *) bad "ux-contract: 미정 값이 통과됨" ;; esac
printf "# f\npages/_uxg\n${UXG_FULL}" > "$UXG/docs/features/f.md"
case "$(uxg "$UXG/src/pages/_uxg/ui/Page.tsx")" in '') ok ;; *) bad "ux-contract: 채워진 계약을 오탐" ;; esac
case "$(uxg "$UXG/src/pages/sample-table/ui/New.tsx")" in '') ok ;; *) bad "ux-contract: 골든샘플 오탐" ;; esac
touch "$UXG/src/pages/_uxg/ui/First.tsx"; rm -rf "$UXG/docs"
case "$(uxg "$UXG/src/pages/_uxg/ui/Second.tsx")" in '') ok ;; *) bad "ux-contract: 같은 슬라이스 후속 파일 재차단" ;; esac
rm -rf "$UXG"

rm -rf "$(git rev-parse --git-dir 2>/dev/null)/harness-guards" 2>/dev/null

# ── ensure-hooks (postinstall 커밋게이트 배선 — 조용히 죽지 않기) ────────────
echo "  [ensure-hooks]"
EH_DIR=$(mktemp -d)
cp scripts/ensure-hooks.sh "$EH_DIR/"
# .git 없는 폴더(중첩·미초기화) → 반드시 크게 경고하고 exit 0 (설치는 안 막음)
eh_out=$(cd "$EH_DIR" && bash ensure-hooks.sh 2>&1); eh_rc=$?
case "$eh_out" in *"연결되지 않았어요"*) ok ;; *) bad "ensure-hooks: .git 부재 경고가 안 뜸" ;; esac
if [ "$eh_rc" -eq 0 ]; then ok; else bad "ensure-hooks: 경고 경로가 설치를 막음 (exit $eh_rc)"; fi
rm -rf "$EH_DIR"

# ── wiki 훅 생존 ─────────────────────────────────────────────────────────────
echo "  [wiki hooks]"
case "$(bash .claude/hooks/wiki-session-start.sh 2>/dev/null)" in *"wiki 색인"*) ok ;; *) bad "wiki-session-start: index 주입 죽음" ;; esac
w1=$(printf '{"session_id":"selftest-run"}' | bash .claude/hooks/wiki-stop.sh 2>/dev/null || true)
w2=$(printf '{"session_id":"selftest-run"}' | bash .claude/hooks/wiki-stop.sh 2>/dev/null || true)
case "$w1" in *block*) ok ;; *) bad "wiki-stop: 첫 호출이 기록 지시를 안 함" ;; esac
if [ -z "$w2" ]; then ok; else bad "wiki-stop: 세션당 1회 제한 깨짐"; fi
rm -rf "$(git rev-parse --git-dir 2>/dev/null)/harness-guards" 2>/dev/null

# ── skill-injector.sh 라우팅 생존 ────────────────────────────────────────────
echo "  [prune-tests 분류기]"
if node .claude/scripts/prune-tests.mjs >/dev/null 2>&1; then
  ok
else
  bad "prune-tests: 깨끗한 저장소에서 정리 대상을 보고함(오탐) — 배럴 재수출 추적 확인"
fi

PT_DIR="src/__selftest_prune__"
mkdir -p "$PT_DIR"
printf "import { gone } from './nowhere';\ndescribe('x', () => {\n  it('y', () => {\n    expect(gone).toBeDefined();\n  });\n});\n" > "$PT_DIR/probe.spec.ts"
if node .claude/scripts/prune-tests.mjs 2>/dev/null | grep -q '대상 파일이 없음'; then ok; else bad "prune-tests: 대상 파일이 사라진 spec 미탐"; fi
rm -rf "$PT_DIR"

echo "  [contrast-check]"
CONTRAST="node .claude/scripts/contrast-check.mjs"
CFIX="$FIXDIR/contrast"
mkdir -p "$CFIX"

# 위반: 본문이 흰 배경에서 1.61:1 — 읽을 수 없다
printf ':root {\n  --background: #ffffff;\n  --foreground: #cccccc;\n}\n' > "$CFIX/bad.css"
expect_block "본문 대비 미달" $CONTRAST "$CFIX/bad.css"

# 허용: 같은 조합이 기준을 넘으면 통과해야 한다 (오탐 방지)
printf ':root {\n  --background: #ffffff;\n  --foreground: #000000;\n}\n' > "$CFIX/good.css"
expect_pass "본문 대비 충분 (허용)" $CONTRAST "$CFIX/good.css"

# 다크 블록도 검사해야 한다 — 라이트만 보면 다크에서 안 읽히는 화면이 그대로 나간다
printf ':root {\n  --card: #ffffff;\n  --card-foreground: #000000;\n}\n.dark {\n  --card: #1a1a1a;\n  --card-foreground: #333333;\n}\n' > "$CFIX/dark.css"
expect_block "다크 모드 대비 미달" $CONTRAST "$CFIX/dark.css"

# 배경이 원인인 조합도 잡아야 한다 (삭제 버튼: 흰 라벨 on 옅은 빨강)
printf ':root {\n  --primary-foreground: #ffffff;\n  --destructive: #ff9999;\n}\n' > "$CFIX/onbg.css"
expect_block "배경색이 원인인 조합" $CONTRAST "$CFIX/onbg.css"

# 색 형식이 바뀌어도 검사가 살아 있어야 한다. 6자리 hex만 읽던 시절, 3자리 hex와
# oklch(shadcn 최신 기본형)를 만나면 "0개 조합 통과"로 조용히 죽었다 — 발행 후에 발견했다.
printf ':root {\n  --background: #fff;\n  --foreground: #ccc;\n}\n' > "$CFIX/short-hex.css"
expect_block "3자리 hex 저대비" $CONTRAST "$CFIX/short-hex.css"
printf ':root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.85 0 0);\n}\n' > "$CFIX/oklch-bad.css"
expect_block "oklch 저대비" $CONTRAST "$CFIX/oklch-bad.css"
printf ':root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n}\n' > "$CFIX/oklch-ok.css"
expect_pass "oklch 정상 대비 (허용)" $CONTRAST "$CFIX/oklch-ok.css"

# 못 읽는 형식과 "검사 0건"은 통과가 아니라 실패여야 한다 — 게이트가 죽는 경로다
printf ':root {\n  --background: hsl(0 0%% 100%%);\n  --foreground: hsl(0 0%% 20%%);\n}\n' > "$CFIX/unknown.css"
expect_block "읽을 수 없는 색 형식" $CONTRAST "$CFIX/unknown.css"
printf ':root {\n  --radius: 0.5rem;\n}\n' > "$CFIX/nocolor.css"
expect_block "검사할 조합 0건" $CONTRAST "$CFIX/nocolor.css"

# 알파를 버리면 거의 안 보이는 글씨가 통과한다(실측: rgba(0,0,0,0.05)가 검정 21:1로 계산됐다)
printf ':root {\n  --background: #ffffff;\n  --foreground: rgba(0, 0, 0, 0.05);\n}\n' > "$CFIX/alpha-fg.css"
expect_block "반투명 전경 (배경과 합성해야 실제 색)" $CONTRAST "$CFIX/alpha-fg.css"
printf ':root {\n  --background: rgba(255, 255, 255, 0.5);\n  --foreground: #000000;\n}\n' > "$CFIX/alpha-bg.css"
expect_block "반투명 배경 (아래 색을 알 수 없음)" $CONTRAST "$CFIX/alpha-bg.css"

# 일부 토큰만 계산 불가여도 나머지로 통과하면 안 된다 — 필터에서 떨어뜨리면
# "못 읽으면 실패" 방어를 한 층 위에서 우회하게 된다
printf ':root {\n  --background: #ffffff;\n  --foreground: var(--x);\n  --card: #ffffff;\n  --card-foreground: #000000;\n}\n' > "$CFIX/partial-var.css"
expect_block "일부만 계산 불가(var)" $CONTRAST "$CFIX/partial-var.css"

# 실제 테마가 자기 게이트를 통과하는지 — 스캐폴드 직후 verify가 막히면 안 된다
expect_pass "템플릿 기본 테마" $CONTRAST src/app/index.css
rm -rf "$CFIX"

echo "  [skill-injector routes]"
route() { printf '{"prompt":"%s"}' "$1" | bash .claude/hooks/skill-injector.sh 2>/dev/null || true; }

case "$(route '배포해줘')" in *deploy*) ok ;; *) bad "injector: 배포→deploy 라우트 죽음" ;; esac
case "$(route '아까 상태로 되돌려줘')" in *checkpoint*) ok ;; *) bad "injector: 되돌리기→checkpoint 라우트 죽음" ;; esac
case "$(route '새로고침해도 남아있게 해줘')" in *data-storage*) ok ;; *) bad "injector: 저장→data-storage 라우트 죽음" ;; esac
case "$(route '실제 데이터 연결해줘')" in *connect-api*) ok ;; *) bad "injector: 실서버 연결→connect-api 라우트 죽음" ;; esac

# 골든샘플이 knip 심사에서 빠져 있어야 한다 — 첫 실제 화면이 루트를 가져가면 샘플은
# 참조를 잃는데, 그때 죽은 파일로 잡히면 하드 관문이 막힌다. 라우트를 남겨 참조를
# 유지하는 길은 배포 안전망("샘플이 외부에 보인다")과 충돌해 막다른 길이다(실사용 재현).
case "$(grep -c 'src/pages/sample-table/\*\*' knip.jsonc)" in 0) bad "knip ignore에서 샘플 빠짐 — 첫 화면 만들면 하드 관문이 막힌다" ;; *) ok ;; esac
case "$(grep -c 'deleteFails' src/pages/sample-table/ui/SampleTablePage.spec.tsx)" in 0) bad "골든샘플 spec에서 실패 케이스가 사라짐 (회귀 1순위 미검증)" ;; *) ok ;; esac
case "$(route 'API 붙여줘')" in *connect-api*) ok ;; *) bad "injector: API 연결→connect-api 라우트 죽음" ;; esac
case "$(route '새로고침해도 남아있게 해줘')" in *connect-api*) bad "injector: 저장 요청이 connect-api로 샘" ;; *) ok ;; esac
case "$(route '다 됐어? 보여줘')" in *verify*) ok ;; *) bad "injector: 완료→verify 게이트 라우트 죽음" ;; esac

# ── 결과 ─────────────────────────────────────────────────────────────────────
echo ""
if [ "$fail" -gt 0 ]; then
  echo -e "${RED}✗ gate-selftest: ${fail}개 게이트가 죽었거나 오탐 (${pass} PASS)${NC}"
  echo "  FIX: 해당 게이트의 정규식/룰이 최근 변경으로 깨졌는지 확인하라. 게이트를 고치기 전에는 커밋 금지."
  exit 1
fi
echo -e "${GREEN}✅ gate-selftest: ${pass}개 전부 살아있음 (오탐 0)${NC}"
