# PreToolUse(Edit|Write) 훅 — 스텝 파일에 raw 로케이터가 들어오면 exit 2 로 차단
if grep -qE '\bpage\.locator\(' <<< "$NEW_TEXT"; then
echo "🚫 로케이터는 pages/*Page.ts 에만" >&2
exit 2
fi
이 훅이 오늘도 막고 있을 거라 믿으면, 함정에 걸린 거다
훅은 세운 날 동작한다, 그리고 그 뒤로 아무도 다시 확인하지 않는다
훅으로 Playwright 규율을 강제하고 있었다
Claude Code 로 E2E 시나리오를 짜게 하면서 규율을 CLAUDE.md 에 적어 두는 것으로는 부족했다
로케이터는 Page Object 에만 두고 스텝은 POM 메서드만 호출한다,
이유 없는 test.skip 은 금지다, 커밋 제목은 영어다 —
이런 규칙은 에이전트가 급해지면 가장 먼저 무너진다
그래서 규칙을 훅으로 옮겼다
PreToolUse(Edit|Write) → 스텝에 page.locator / getBy* / XPath 가 들어오면 차단
PreToolUse(Edit|Write) → test.only, 이유 없는 test.skip 차단
PreToolUse(Bash) → git commit 인데 커밋 메시지 규칙 위반이면 차단
PreToolUse(Bash) → 로직 파일 커밋인데 적대적 코드리뷰 2회가 없으면 차단
몇 주 동안 잘 돌았다, 정확히는 잘 도는 것처럼 보였다
전수 감사를 하면서 훅을 하나씩 뜯어봤더니, 죽어 있는 규칙과 열려 있는 구멍이 한 훅에 서너 개씩 나왔다
죽는 방식은 셋으로 갈렸다
규칙 하나를 고쳤는데 옆 규칙이 죽었다
스텝 훅에 .page.* 직접 접근 차단 규칙을 새로 넣었다
appPage.page.waitForEvent(...) 처럼 POM 캡슐을 우회하는 코드가
기존 locator/getBy 열거만으로는 다 열려 있었기 때문이다
그런데 이 규칙은 주석에서 오탐이 났다 — // appPage.page.route 를 쓰지 말 것 같은 산문 주석이 편집을 막았다
해결로 주석을 지우고 검사하는 전처리를 넣었다
# ❌ 전처리를 전 규칙에 공통으로 걸었다
nocomment = re.sub(r"(?m)//.*$", "", text)
for pat, name in rules:
if re.search(pat, nocomment): violations.append(name)
그 순간 XPath 규칙이 통째로 죽었다
XPath 리터럴은 정의상 //div[@id='x'] 로 시작한다,
주석 제거가 // 뒤를 먼저 지워 버려 XPath 규칙은 빈 문자열을 검사하게 됐다
한 규칙의 오탐을 막으려 넣은 전처리가 다른 규칙의 미탐이 된 것이다
이 회귀는 셀프체크가 없던 시점이라 26개 입력을 손으로 HEAD 판본과 대조해서야 드러났다
고친 방향은 두 가지다
# ✅ 주석 제거는 줄머리 앵커로만, 그리고 그것을 필요로 하는 규칙에만 적용한다
nocomment = re.sub(r"(?m)^\s*(?://|\*|/\*).*$", "", text)
rules = [
(r"\bpage\.locator\(", "page.locator 직접 호출", False),
(r"\.page\.[a-zA-Z]", ".page.* 직접 접근", True), # 이 규칙만 주석 제외
(r"xpath=|[\"'`]//[\w*@\[]", "XPath 사용", False),
]
for pat, name, strip_comments in rules:
if re.search(pat, nocomment if strip_comments else text):
violations.append(name)
줄머리 앵커가 아니면 문자열 안의 https://... 나 '/*.cif' 도 주석으로 오인돼 뒤 코드가 통째로 삭제된다
전처리 라우팅은 규칙 튜플 안에 박는다 — 패턴 문자열 동등비교로 분기하면 패턴 한 글자만 고쳐도 조용히 원문 매칭으로 돌아간다
게이트가 "못 돌면 통과" 로 만들어져 있었다
두 번째 방식은 규칙이 죽은 게 아니라 규칙이 한 번도 실행되지 않은 경우다
리뷰 게이트는 Claude Code 의 transcript JSONL 을 읽어 "마지막 편집 이후 적대적 리뷰가 2회 있었나" 를 센다
그 코드의 첫 줄이 이랬다
# ❌ transcript 를 못 읽으면 조용히 통과
if not transcript_path or not os.path.isfile(transcript_path):
sys.exit(0)
같은 형태가 셋 더 있었다
- 임시 트랜스파일이 실패하면
|| exit 0 - 커버리지 근거 검증기가 제품 레포 경로를 잘못 조립해 빈 목록을 받으면
return true git diff --cached로 커밋 대상을 잡아서,git add -A && git commit한 줄이면 훅이 add 전에 돌아 대상이 비고exit 0
전부 CI 처럼 전제가 없는 환경을 배려한 기본값이다
그런데 경로 오타로 못 찾은 것과 환경상 없는 것을 구분하지 않으면 그 배려는 상시 스킵이 된다
게이트의 초록불은 "검증했고 문제없다" 와 "검증을 못 했다" 두 뜻을 같은 exit 0 으로 낸다,
읽는 쪽은 앞의 뜻으로만 읽는다
# ✅ 검증 불가 ≠ 검증 통과 — 못 읽으면 차단하거나, 최소한 stderr 로 크게 알린다
try:
lines = open(path, encoding="utf-8", errors="replace").readlines()
except Exception as exc:
print(f"🚫 차단: 리뷰 이력을 읽지 못했다({exc.__class__.__name__})", file=sys.stderr)
sys.exit(2)
커밋 대상은 git status --porcelain 으로 본다
staged·unstaged·untracked 를 한 번에 잡는 유일한 소스다
errors="replace" 도 같은 이유다
transcript 는 하니스가 실시간으로 append 하는 파일이라 한글 3바이트 문자가 잘린 창이 상시 열려 있고,
그 한 글자의 UnicodeDecodeError 가 게이트를 통째로 무성 통과시켰다
게이트가 내 작업을 막으면 게이트를 넓히고 싶어진다
세 번째 방식이 가장 지독하다, 게이트를 고치는 손이 게이트를 연다
리뷰 게이트는 transcript 의 Agent 호출 프롬프트에서 리뷰 마커를 substring 으로 찾는다
정상적으로 리뷰를 2회 돌렸는데 프롬프트가 한국어여서 0/2 로 집계돼 커밋이 막혔다
자연스러운 수정은 마커에 적대적 리뷰·코드 리뷰 를 추가하는 것이다
# ❌ 마커를 넓혀 내 정상 케이스를 통과시켰다
return ('adversarial code reviewer' in blob
or '적대적 리뷰' in blob or '코드 리뷰' in blob)
이 변경본을 적대적 리뷰에 넘기자 바로 뚫렸다
"적대적 리뷰에서 나온 지적 3건을 반영해줘" 는 리뷰가 아니라 수정 위임인데 마커에 걸린다
리뷰 → 수정 위임 → 수정 위임, 이 흐름은 실제 리뷰 1회인데 게이트는 3회로 센다
더 나쁜 건 이 훅의 차단 메시지 자체가 "유효 코드리뷰가 2회 미만" 이라는 점이다
막힌 뒤 그 문구를 붙여 넣어 복구를 위임하는 게 가장 자연스러운 동선이고, 두 번 하면 실제 리뷰 0회로 게이트가 열린다
이 훅이 존재하는 이유가 바로 "리뷰 → 수정 → 재리뷰 생략 → 커밋" 고무도장을 막는 것이다
마커를 넓히자 막으려던 구멍이 그대로 다시 열렸다
# ✅ 정규 토큰 하나만 인정한다 — 좁혀서 생기는 실패는 재리뷰(fail-closed), 넓혀서 생기는 실패는 게이트 무력화
return 'adversarial code reviewer' in blob
해결은 훅이 아니라 호출 규약이었다 — 리뷰를 위임할 때 description 에 항상 그 토큰을 넣는다
게이트가 정상 작업을 막을 때 게이트를 느슨하게 고치면, 그 느슨함은 정확히 게이트가 막던 자리에 생긴다
왜 훅은 조용히 죽나 — 훅에는 검증이 없다
세 방식의 공통점은 하나다
훅은 exit code 하나를 내는 프로그램이고, 그 프로그램을 알려진 나쁜 입력으로 돌려보는 사람이 없다
E2E 스위트는 매 실행이 곧 검증이다, 오라클이 깨지면 RED 가 뜬다
훅은 반대다 — 규칙이 죽어도 편집이 막히지 않으니 아무도 아프지 않고, 그래서 아무도 모른다
여기에 한 축이 더 있다, 훅의 상대는 사람이 아니라 에이전트다
에이전트는 막히면 우회한다, 그것도 아주 합리적으로
// 훅이 .page. 를 막으니 지역변수로 받는다 — 규칙 위반은 그대로인데 정규식은 안 걸린다
const p = appPage.page
await p.route('**/x', (r) => r.abort())
즉 게이트는 적대적 사용자를 상시로 두고 있는 코드다
적대적 사용자를 둔 코드에 회귀 검증이 없으면 결과는 정해져 있다
게이트는 세운 시점이 아니라 깨보고 못 깬 시점에 게이트다
훅에 (입력, 기대 판정) 표를 먹인다
그래서 훅에도 검증을 붙였다
구조는 단순하다 — 훅별로 (이름, PreToolUse 페이로드, 기대 exit) 표를 두고,
각 훅을 spawnSync 로 실제 실행해 exit code 를 대조한다
const BLOCK = 2, PASS = 0
const write = (file, content) => ({ tool_input: { file_path: join(ROOT, file), content } })
const stepPom = [
// 2026-09-05 에 실제로 죽었던 규칙 — 지우지 말 것
['XPath 리터럴', write('steps/t.steps.ts', `await appPage.q("//div[@id='x']");`), BLOCK],
['.page.waitForEvent', write('steps/t.steps.ts', `const d = appPage.page.waitForEvent('download');`), BLOCK],
// 같은 줄 형태가 핵심 — 두 줄로 나누면 줄머리 앵커를 제거해도 안 잡힌다
['같은 줄 URL + 위반', write('steps/t.steps.ts', `const u = 'https://a.b/c'; await appPage.page.route(u, r => r.abort());`), BLOCK],
// 통과해야 하는 것 — 오탐은 편집을 막아 마찰이 된다
['줄머리 주석 인용', write('steps/t.steps.ts', `// appPage.page.route 를 쓰지 말 것\nawait appPage.arm();`), PASS],
['멤버명 접두', write('steps/t.steps.ts', `await appPage.pageFileRow('a').click();`), PASS],
]
for (const [name, payload, want] of stepPom) {
const r = spawnSync('bash', [join(HOOKS, 'check-step-pom.sh')], {
input: JSON.stringify(payload), encoding: 'utf8', timeout: 15000,
env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT },
})
const got = r.status === 0 ? PASS : r.status
console.log(`${got === want ? '✓' : '✗'} ${name} — 기대 ${want === BLOCK ? '차단' : '통과'}, 실제 exit=${got}`)
}
표를 짤 때 지킨 원칙이 넷이다
- 차단 케이스와 통과 케이스를 짝으로 둔다 — 차단만 있으면 훅을
exit 2한 줄로 바꿔도 초록불이다 - 죽었던 규칙은 케이스로 박제한다 — XPath 케이스 옆에 "왜 여기 있는지" 를 적어 지워지지 않게 한다
- 페이로드 형태를 전부 넣는다 — settings.json 이
Edit|Write|MultiEdit에 훅을 물리는데 표에 Write 만 있으면 Edit 파싱을 지워도 초록불이다 - fail-closed 가드 자체를 케이스로 넣는다 —
git commit -m "$MSG"처럼 메시지를 판독할 수 없는 입력은 차단이어야 한다, 이 케이스가 없으면-m경로로 위 규칙 전부를 우회할 수 있다
그리고 이 표를 게이트에 다시 물렸다
.claude/hooks/ 나 셀프체크 파일을 건드리는 커밋이면 커밋 훅이 셀프체크를 먼저 돌리고, 하나라도 깨지면 커밋을 막는다
의도한 변경이면 scripts/hook-selftest.mjs 의 기대표를 함께 갱신하세요
오프라인·결정론이라 52케이스에 2초다
죽어 있던 XPath 규칙을 이 표에 태우니 즉시 5건 RED 였다, 손으로 26입력을 대조하던 일이 2초로 줄었다
표도 공격받아야 한다
표를 만든 뒤 그 표를 적대적 리뷰에 넘겼다
훅에 26개 변이를 넣었는데 13개가 초록불로 통과했다
describe.only 를 정규식에서 빼도 통과, feature 파일 분기를 통째로 삭제해도 통과, MultiEdit 파싱을 지워도 통과였다
표에 없는 규칙은 보호받지 못한다, 그 자리를 케이스로 메웠다
그래도 표가 덮는 훅은 셋이고 나머지는 무커버다
그래서 셀프체크가 매 실행 끝에 커버·미커버 목록을 같이 찍게 했다
통과 — 52/52
커버: check-step-pom · block-skip-without-reason · pre-commit-guidelines-check
미커버(훅 단위): remind-code-review-before-commit · check-locator-quality · ...
⚠️ "이 표가 초록불 = 게이트가 온전" 이 아니다
초록불 옆에 미커버 목록이 없으면 그 초록불이 또 하나의 fail-open 이 된다
핵심 정리
| 죽는 방식 | 겉모습 | 원리 | 막는 법 |
|---|---|---|---|
| 옆 규칙의 오탐 수정이 이 규칙을 죽임 | 편집이 잘 통과됨 | 공통 전처리가 다른 규칙의 입력을 지운다 | 전처리를 규칙 튜플에 묶고, 죽었던 규칙을 케이스로 박제 |
| 전제가 없으면 조용히 통과 | 게이트 초록불 | exit 0 이 "검증 OK" 와 "검증 못 함" 을 같은 값으로 낸다 | fail-closed, 불가피하면 stderr 로 크게 알림 |
| 내 정상 케이스를 통과시키려 마커를 넓힘 | 정상 작업이 다시 됨 | 넓힌 자리가 정확히 게이트가 막던 자리 | 정규 토큰 하나 + 호출 규약, 훅을 느슨하게 고치지 않음 |
- 훅은 exit code 하나를 내는 프로그램이고 적대적 사용자(에이전트)를 상시로 둔다 — 회귀 검증이 없으면 조용히 죽는다
- 훅마다 (입력, 기대 exit) 표를 두고 실제 실행해 대조한다, 차단·통과를 짝으로, 페이로드 형태를 전부, fail-closed 가드 자체를 케이스로
- 표는 훅을 건드리는 커밋에서 강제한다 — 규칙을 바꾼 사람이 그 자리에서 깨진 케이스를 본다
- 표도 공격받는다 — 변이 26개 중 13개가 통과했다, 표에 없는 규칙은 보호받지 못한다
- 초록불엔 미커버 목록을 같이 찍는다 — "검증됨" 으로 읽히는 초록불은 그 자체가 fail-open 이다
- 게이트는 세운 시점이 아니라 깨보고 못 깬 시점에 게이트다
'TIL > Playwright' 카테고리의 다른 글
| [TIL][Playwright] 비동기 작업의 완료를 동기적으로 기다리지 않기 (0) | 2026.07.14 |
|---|---|
| [TIL][Playwright] AI 챗봇 응답 감지 #2 — follow-up 함정과 nth 인덱스 (0) | 2026.06.21 |
| [TIL][Playwright] AI 챗봇 응답 완료를 E2E 로 감지하는 방법 (0) | 2026.06.14 |
| [TIL][Playwright] expect vs wait_for — disabled 버튼 때문에 30초 날린 이야기 (0) | 2026.06.07 |
| [TIL][Claude] GitHub Actions 에서 Playwright E2E 매일 자동 실행하기 (0) | 2026.05.17 |