테스트가 실행되지 않았는데 검증을 통과하는 문제 막기

X Facebook

Written by

in

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

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

이걸 받은 다음 에이전트는 같은 검사를 다시 돌려야 한다. command에 실제로 실행한 문자열이 없고 result에 몇 개 중 몇 개가 통과했는지도 없다. 검사를 한 번 돌렸는데 그 결과가 아무 데도 남지 않았다.

실행한 명령과 결과가 남아 있어야 다음 담당자가 검증 여부를 판단할 수 있다.

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

1. pnpm test라고 적어 두면 왜 안 되나

통과해야 다음으로 넘어갈 수 있는 검사 하나를 게이트라고 한다. 이 스킬이 쓰는 게이트는 다섯이다. 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. exit 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 순으로 고정해 놓았다.

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

표 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
typecheck 0 errors / 3 errors (src/a.ts:12 외 2건) 통과, 문제 없음
lint 0 errors, 5 warnings 깨끗함
arch 규칙 4건 검사, 위반 0건 / ARCH-002 위반 3파일 경계 지킴
test 47 tests, 45 passed, 2 failed (auth.login.expired-token, cart.total.discount) 대부분 통과
build exit 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는 정말 돌아가나

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. 게이트를 수치로 고정해서 얻는 것과 내주는 것

확인하지 못한 것

  • scripts/quality-gate.mjs 실행기의 내부는 읽지 않았다. SKILL.md가 인용한 호출 형식(--json, --only, --no-stop)만 확인했다. 셸 절차와 실행기의 동작이 같은지는 확인하지 못했다.
  • 억제 흔적 수를 “직전 실행 대비”로 비교한다고 되어 있다. 직전 값을 어디에 저장하는지는 SKILL.md에 없다.
  • 실행 개수 급감을 직전 라운드와 비교하라는 지침도 마찬가지다. 비교 대상을 어디서 읽는지가 적혀 있지 않다.

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

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다