aster®

테스트가 실행되지 않은 검증을 통과 처리하지 않기

마무리 · 2026-09

터미널 패널 안에 가로 막대 두 줄이 있다. 위 줄은 초록으로 가득 차 있고, 아래 줄은 빈 윤곽에 왼쪽 끝에 빈 동그라미가 있다.

에이전트가 남긴 핸드오프에 이런 줄이 들어온 적이 있다.

{ "command": "타입 확인", "result": "통과" }

이걸 받은 다음 에이전트는 같은 검사를 다시 돌려야 한다. command 에 실제로 실행한 문자열이 없고 result 에 몇 개 중 몇 개가 통과했는지도 없다. 검사 결과를 재확인할 수 있는 정보가 남아 있지 않았다.

검증 완료로 보고하려면 실행한 명령과 결과를 함께 남겨야 한다.

curvez 의 quality-gate 는 명령을 돌려 나온 출력으로 통과 여부를 판정한다. 그 결과를 한 줄로 남긴다 — 47 tests, 45 passed, 2 failed (auth.login.expired-token, cart.total.discount).

1. 프로젝트별 테스트 명령 읽기

통과해야 다음으로 넘어갈 수 있는 검사 하나를 게이트라고 한다. 이 스킬이 쓰는 게이트는 다섯이다. arch, typecheck, lint, test, build.

게이트 명령을 스킬 문서에 하드코딩해 두는 게 제일 쉬운 방법처럼 보인다. 그런데 프로젝트마다 스크립트 이름이 다르다. 어디는 typecheck 고 어디는 type-check 고 어디는 tsc --noEmit 을 직접 쓴다.

하드코딩해 두면 있지도 않은 명령이 돌아가 버린다. 그리고 그 command not found 를 코드 결함으로 오인하게 된다. 문서의 표현을 그대로 옮기면 이렇다 — 구현 에이전트는 없는 버그를 찾으러 간다.

그래서 0단계가 명령을 읽는 일이다. 게이트 명령은 .curvez/profile.json 의 commands 에서 꺼낸다.

pf() { node -p "(JSON.parse(require('fs').readFileSync('$P','utf8'))$1)||''"; }
STACK=$(pf "?.stack")
TYPECHECK=$(pf "?.commands?.typecheck")
LINT=$(pf "?.commands?.lint")
TEST=$(pf "?.commands?.test")
BUILD=$(pf "?.commands?.build")
WEB=$(pf "?.paths?.web")

명령이 비어 있으면 미검증으로 처리한다. commands.test 가 비었는데 테스트를 요구받았으면 status: blocked 다. pnpm test 를 가정하고 돌리지 않는다. profile.json 자체가 없으면 역시 blocked 이고 blocked_on 에 "profile 이 없다. bootstrap 먼저" 를 남긴다.

게이트는 전부 run_gate 함수를 거친다. 출력은 파일에 저장하고 명령이 남긴 exit code 를 그대로 반환한다. 셸과 CI 는 화면 대신 그 숫자 하나를 보고 다음으로 갈지 멈출지 정한다.

run_gate() {                      # run_gate <이름> <명령>
  local name=$1 cmd=$2
  if [ -z "$cmd" ]; then
    echo "GATE $name: SKIP (commands.$name 비었음)"
    return 0
  fi
  local log=.curvez/qa/$name.log
  echo "GATE $name: \$ $cmd"
  ( eval "$cmd" ) >"$log" 2>&1      # 서브셸로 감싼다
  local code=$?
  echo "GATE $name: exit=$code log=$log lines=$(wc -l < "$log" | tr -d ' ')"
  tail -30 "$log"
  return $code
}

eval 을 서브셸 ( ) 로 감싸는 줄에 이유가 따로 붙어 있다. commands.* 는 프로파일에서 온 임의 문자열이라 exit 이나 cd, set -e 가 섞여 있을 수 있다. 감싸지 않으면 게이트 러너 자체가 끝나거나 작업 디렉터리가 바뀔 수 있다. 그러면 남은 게이트가 엉뚱한 곳에서 돌거나 아예 안 돈다. 문서는 이걸 "실행 테스트에서 실제로 잡힌 함정" 이라고 적어 뒀다.

이 절차 전체는 실행기로도 구현돼 있다.

node "$CLAUDE_PLUGIN_ROOT/scripts/quality-gate.mjs" [--json] [--only arch,test] [--no-stop]

--json 출력은 핸드오프의 verification[] 에 그대로 넣을 수 있다.

2. 종료 코드가 0인 빈 테스트 실행

이 스킬에서 가장 길게 설명해 둔 항목이 이거다. 테스트 러너는 이름 규칙에 맞는 파일을 하나도 못 찾을 때가 있다. 그러면 실패 0건과 exit 0 을 함께 반환한다.

실패가 없다는 뜻으로 읽기 쉬운 자리다. 그런데 아무것도 검증하지 않은 경우에도 같은 값이 나온다. 실패 0건과 exit 0 만 봐서는 둘을 구분할 수 없다. 경로 오타나 설정 파일 누락, preset 미설치, glob 불일치로 테스트 묶음이 비어 있어도 CI 는 성공으로 끝난다.

그래서 게이트 4는 실행 개수를 따로 센다.

ZERO=$(grep -Eic 'no tests? (found|to run)|(^|[^0-9])0 +(tests?|test files?|passed|total)' \
  .curvez/qa/test.log)
echo "zero-run-signal=$ZERO test exit=$TS"

위 정규식에 (^|[^0-9]) 가 붙어 있는 것도 이유가 적혀 있다. 숫자 경계를 빠뜨리면 10 passed 안의 0 passed 를 잡는다. 멀쩡한 실행을 0개라고 오탐해 버린다.

그래서 실행 개수가 0이면 exit code 와 무관하게 blocked 로 내린다. blocked_on 에 "테스트 0개 실행. 스위트가 비었거나 경로 설정이 어긋났다" 와 실행한 명령을 같이 남긴다.

부속 문서에는 Next.js 에서 겪을 수 있는 사례가 셋 나온다. dev 서버를 기동하지 않아 e2e 러너가 테스트를 못 띄우거나, jsdom 환경이 설정되지 않아 컴포넌트 테스트가 전부 제외된다. testMatch 가 app/ 디렉터리를 안 덮기도 한다.

0개만 막아서는 "절반이 조용히 빠진" 상태를 놓친다. 남은 절반이 전부 통과하니까 정상 실행과 출력이 같다. 그래서 실행 개수를 직전 라운드와 비교한다. 수집한 스위트 파일 수까지 같이 본다.

코드를 하나도 안 고쳤는데 어떤 때는 통과하고 어떤 때는 실패하는 테스트가 있다. 플래키(flaky)하다고 한다. 의심되면 같은 명령을 3회 돌린다. exit code 가 세 번 다 같아야 플래키가 아니고 재시도 설정으로 덮는 것은 금지다. 플래키는 대개 경쟁 조건, 정리되지 않는 타이머, 전역 상태 누수 같은 제품의 실제 결함이 드러난 것이다. 프로덕션에는 재시도가 없다.

3. 아키텍처 검사를 먼저 실행하는 이유

비용이 적고 파급이 큰 검사부터 돌리도록 arch → typecheck → lint → test → build 순으로 고정해 놓았다.

#게이트비용왜 이 자리인가
1archgrep 몇 초경계 위반의 수정은 파일을 옮기는 일이라 그 뒤 게이트 결과가 전부 무효가 된다
2typecheck수 초~수십 초실행 없이 트리 전체를 한 번에 본다
3lint수 초리뷰가 이 출력을 입력으로 쓴다. 리뷰보다 먼저 돌아야 중복 지적이 걸러진다
4test수십 초~수 분앞 셋이 통과해야 실패가 로직 결함으로 해석된다
5build가장 느림앞 넷이 못 잡는 번들러·플랫폼 레벨만 남는다

표 1. 다섯 게이트의 실행 순서와 그 자리에 둔 이유

arch 게이트는 .curvez/architecture.md 의 ## 금지 import 표를 awk 로 한 줄씩 읽어 금지 패턴을 꺼낸다. 그 패턴이 소스에 있는지 grep -E 로 찾는다. 열 순서는 규칙 ID | 검사 경로 | 금지 패턴 (ERE) | 이유 로 고정이다. 파싱할 때 함정이 하나 있다. 표 안의 패턴에 \| 로 이스케이프된 파이프가 들어 있어서 필드 구분자가 파이프 하나가 아니라 공백-파이프-공백이다. 구분자를 잘못 잡으면 규칙이 중간에서 잘려 버린다. 잘린 패턴은 아무것도 매칭하지 못한 채 "위반 0건" 을 보고한다. 이것도 실행 테스트로 두 번 잡힌 함정이라고 적혀 있다.

무엇을 언제 돌리는지도 정해져 있다. 전부 매번 돌리면 느려서 변경 범위로 판정한다.

상황돌리는 게이트
.curvez/** 나 *.md 만 바뀜없음
소스 1~2 파일 수정, 중간 확인arch + typecheck + lint
테스트 파일만 추가·수정typecheck + test
구현 에이전트가 done 선언 직전arch + typecheck + lint + test
리뷰 시작 직전lint + typecheck
라운드 종료 · 최종 인계5종 전부
의존성·설정 변경5종 전부

표 2. 변경 범위별로 돌리는 게이트

앞 게이트가 실패하면 남은 것을 중단해도 된다. typecheck 가 깨진 채로 test 를 돌린다고 하자. 그 실패 출력은 결함 신호가 아니라 컴파일 오류다. 그걸 구현 에이전트에게 넘기면 존재하지도 않는 로직 버그를 찾으러 간다. 대신 중단했으면 돌리지 않은 게이트를 verification 에 넣지 않는다. summary 에 "typecheck 실패로 test·build 미실행" 으로 적는다.

4. 검증 결과에 남길 정보

이 스킬은 "통과했다", "모두 정상", "이상 없음" 을 금지한다. 수신 에이전트가 수치를 보고 다음 행동을 정하기 때문이다.

게이트좋은 result나쁜 result
typecheck0 errors / 3 errors (src/a.ts:12 외 2건)통과, 문제 없음
lint0 errors, 5 warnings깨끗함
arch규칙 4건 검사, 위반 0건 / ARCH-002 위반 3파일경계 지킴
test47 tests, 45 passed, 2 failed (auth.login.expired-token, cart.total.discount)대부분 통과
buildexit 0, warnings 2빌드 성공

표 3. 게이트별로 result 에 적을 것과 적지 말 것

실패한 것은 이름을 전부 적는다. 개수만 적으면 수신 쪽이 로그를 다시 파싱해야 한다.

그리고 이 출력이 그대로 핸드오프의 verification[] 이 된다. 무엇을 돌려서 무엇이 나왔는지를 줄 단위로 적는 칸이다.

"verification": [
  { "command": "pnpm typecheck", "result": "0 errors", "passed": true },
  { "command": "pnpm lint", "result": "0 errors, 5 warnings", "passed": true },
  { "command": "pnpm vitest run", "result": "47 tests, 45 passed, 2 failed (auth.login.expired-token, cart.total.discount)", "passed": false }
]

command 에는 실제로 실행한 문자열을 그대로 옮긴다. passed 는 test 의 경우 실행 개수가 1 이상이고 실패가 0 일 때만 true 다. 실행 개수 0은 false. 항목 개수는 실제로 돌린 게이트 수와 같아야 한다. 안 돌린 게이트의 항목은 만들지 않는다.

status: done 은 verification 이 비면 쓸 수 없다. 최소 구성은 typecheck + lint + test 3건이다. 이 필드의 스키마는 에이전트의 작업 완료 기준과 인수인계 형식 정하기 편의 agent-contract 가 정본이다. quality-gate 는 거기 들어갈 값만 만든다.

오류를 숨겨 통과시키는 방법도 명시적으로 금지한다. --fix·--force·@ts-ignore·eslint-disable 로 오류를 지우지 않는다. 실패한 테스트를 skip·only·todo 로 바꿔 건너뛰게 하는 것도 같은 금지다. 기대값을 실제 출력에 맞춰 고치는 것도 마찬가지다. 그런 억제 흔적은 개수를 세서 직전 실행 대비 증가 0 이어야 한다.

SUP=$(grep -rnE '\b(it|test|describe)\.(skip|only|todo)\b|\bx(it|describe)\(|@ts-ignore|@ts-nocheck|eslint-disable' \
  --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.curvez . 2>/dev/null | wc -l | tr -d ' ')

5. 빌드 검사 실행 여부 확인

build 는 게이트 5에서 실행한다. 게이트 중 가장 느리기 때문에 라운드 종료와 최종 인계, 그리고 package.json·lockfile·tsconfig·next.config 가 바뀐 라운드에서만 돌린다. 변경마다 돌리면 라운드 시간의 대부분을 여기서 쓰고 결국 팀이 게이트 자체를 건너뛰기 시작한다.

Next.js 에서는 build 를 안 돌리면 놓치는 게 특히 많다. 앞 4종이 구조적으로 못 잡는 것들이 여기서만 잡힌다.

build 만 잡는 것왜 typecheck 가 못 잡는가
서버 컴포넌트에서 useState·onClick 사용 (use client 누락)타입은 맞다. 경계 위반은 번들러가 판정한다
클라이언트 컴포넌트가 server-only 모듈을 import런타임 경계라 타입에 안 나타난다
서버→클라이언트로 직렬화 불가능한 props 전달타입상 유효한 값이다
환경변수 미설정으로 인한 프리렌더 실패값의 존재는 빌드 시점에만 드러난다

표 4. build 게이트에서만 잡히는 것과 typecheck 가 못 잡는 이유

첫 줄만 풀어 두면 나머지도 읽힌다. 서버 컴포넌트에서 클릭을 다루려면 파일 맨 위에 use client 를 적어야 한다. 그 한 줄을 빠뜨려도 타입은 맞아서 typecheck 는 통과한다. 번들러가 묶을 때 처음 걸린다.

여기에 조건이 하나 붙는다. next.config.* 에 typescript: { ignoreBuildErrors: true } 나 eslint: { ignoreDuringBuilds: true } 가 있으면 빌드가 타입·린트 오류를 무시하고 통과한다. 그 상태의 build exit 0 을 3종 통과의 근거로 쓰면, 실제로는 한 종만 검증한 게 된다.

그래도 build 가 런타임까지 보증해 주지는 않는다. 문서가 그 한계를 직접 적어 뒀다 — "빌드 통과가 런타임 안전을 뜻하지는 않는다. RSC 직렬화 오류 중 일부는 요청 시점에만 난다." build exit 0 을 result 에 적을 때는 런타임 검증이 아니라는 전제를 같이 둔다.

검증 절차의 비용과 한계

무엇을 얻나무엇을 내주나
done 의 근거가 실행 결과로 고정된다게이트를 돌릴 시간이 라운드마다 추가된다
실패 이름과 재현 명령이 수신자에게 그대로 간다보고 문장이 길어진다. 수치와 파일:라인을 다 적어야 한다
0개 실행이 blocked 로 잡힌다스위트가 아직 비어 있는 초기 프로젝트는 계속 blocked 로 막힌다
명령이 프로파일에서 오니 프로젝트마다 다시 안 정해도 된다.curvez/profile.json 이 없으면 아무 게이트도 못 돈다
억제 주석 증가가 개수로 잡힌다정당한 eslint-disable 도 세어져서 매번 근거를 적어야 한다

표 5. 게이트를 수치로 고정해서 얻는 것과 내주는 것

추가로 확인할 사항


지금은 done 을 볼 때 verification 배열의 길이부터 센다. 항목이 3개보다 적으면 검증이 덜 끝난 것으로 본다.