DESIGN.md가 **무엇을(디자인 시스템 스펙)**이라면, 이 문서는 **왜(제품 원칙·의사결정 근거)**를 담는다. 이 문서엔 제품에 남길 가치가 있는 원칙·의사결정만 통합했다. 토큰의 현재 확정값은DESIGN.md와src/app/studio/web/src/styles/가 항상 우선한다.
시트(구글 시트·CSV·XLSX)에 적힌 테스트 케이스를 AI가 브라우저 액션으로 해석해 실행하고, 결정적 엔진이 pass/fail을 판정하는 웹 테스트 자동화 스튜디오. 터미널 없이 쓴다.
한국어를 쓰는 QA 담당자·테스터. 데스크톱 브라우저에서 스테이징 환경 회귀 스위트를 하루 수 회 돌리고 결과를 조치한다. 코드나 터미널 없이 시트로 케이스를 관리한다.
- 판정은 엔진이, 해석은 AI가 — UI는 그 경계를 흐리지 않는다. AI 산출물(스텝 해석, 규칙 제안, self-heal)은 항상 신뢰도·사유와 함께 "보류 가능한 것"으로 표시한다. 판정(verdict)은 장식 없이 단정적으로 표시한다.
- 증거 없이 승인 없음. baseline 승인·삭제 등 상태를 바꾸는 행동은 증거(스크린샷·텍스트·사유)를 먼저 보여주고, 되돌릴 수 없는 것은 2단계 확인을 거친다.
- 실패가 먼저다. 정렬·색·위계 전부 조치가 필요한 것(실패 → 오류 → 리뷰 필요) 우선. 전부 통과한 화면은 조용해야 정상이다.
- 라임은 "여기를 봐", 초록은 "건강함" — 두 신호를 절대 섞지 않는다. 액센트(
#9ee600)는 행동 유도(CTA·활성 상태·히어로)에만, pass 초록(#4cc06d)은 상태 표시에만. 한 뷰포트에 액센트 배치는 5곳 이하.
- 한국어. 반말 아님·과공대 아님(해요체 대신 명사형·평서형 위주), 실무자 대 실무자 톤.
- 에러는 원인 + 다음 단계를 한 문장씩.
- 마케팅 수사 금지.
- 도메인 용어(baseline, self-heal, assert)는 번역하지 않고 그대로 쓴다.
대시보드에 착지한 사용자가 60초 안에 "지금 조치가 필요한 케이스"를 식별하고 해당 화면(실행 상세 또는 리뷰 큐)에 도달한다.
- 라이트 모드 없음 — 다크 전용 도구.
- 마케팅 표면 없음 — 내부 도구; 제품 언어만.
- 테스트 코드 편집기가 아님 — 케이스의 소스는 시트.
- CI 대시보드가 아님 — 로컬 최근 20회 실행 히스토리만.
- 다국어 UI: KO/EN 지원 — 상단 바 전역 토글로 UI 전체를 한국어/영어 전환(공유
useLang스토어 +localStorage: to_lang, 기본 한국어). 프로젝트/시트 이름·케이스 내용은 시트(사용자 데이터) 언어를 따른다. (2026-07-21 이전의 '한국어 전용, i18n 보류' 결정에서 변경.)
| 날짜 | 제약 | 근거 |
|---|---|---|
| 2026-07-20 | pass 색을 액센트 라임과 분리 (--pass #4cc06d) |
액센트와 성공색이 같으면 건강한 화면이 온통 라임이 되어 히어로가 초점을 잃는다. |
| 2026-07-20 | 판정 표시는 항상 6px dot + 한국어 라벨 (VerdictMark), 배지/필 금지 |
배지는 시각 소음이자 AI 슬롭 패턴. dot이 상태, 텍스트가 라벨. |
| 2026-07-20 | 테이블 헤더는 sentence case, uppercase 금지. 넓은 테이블은 .tscroll 안에서 스크롤 |
— |
| 2026-07-21 | 이전 레이아웃 보존 제약 폐기. 다크 차콜 + 라임 아이덴티티와 판정 의미색만 유지하고, 정보 구조·셸·컴포넌트 문법은 전면 재구성 가능 | 부분 개선을 반복해도 기존 사이드바·균일 카드 문법이 계속 어색함을 남겼고, 색상 외 요소는 브랜드 자산으로 보지 않기로 결정. |
| 2026-07-23 | AI 스텝 해석은 기본 ON, 진짜 어려운 케이스는 리뷰로 남긴다 | 자연어 스텝엔 해석이 필수. 목표는 pass rate를 부풀리는 게 아니라 엔진이 올바르게 동작하게 하는 것 — self-heal 실패는 앱을 탓하지 않고 needs_review로 사람에게 넘긴다. |
참고: 2026-07-20의 "반응형 셸 = 가로 wrap 바" 제약은 2026-07-21 재구성 결정으로 대체됨(§9 참조). 현재 셸은 상단 헤더 + 좌측 뷰 레일 + 하단 도크(모바일) 구조다.
정확한 값은 DESIGN.md·styles/tokens.css가 기준. 여기서는 "왜 그렇게 정했는가"만 남긴다.
- 색: 단일 액센트(라임) 예산 ≤5/뷰포트. pass 초록은 액센트보다 한 단계 조용하게(“act here” vs “healthy”). 판정은 색 단독 신호 금지 — 항상 dot + 텍스트.
- 타이포: 시스템 폰트 스택, 본문 15px. 모든 수치는
font-variant-numeric: tabular-nums. uppercase는 tracked 마이크로 라벨(kicker/section label)에만. 테이블 헤더는 sentence case. 한국어 산문은word-break: keep-all로 한 글자 고아 줄 방지. - 간격: 4px 그리드. 셸은
100dvh그리드이며.workspace-main이 세로 스크롤 주인. 콘텐츠 최대폭 1440px, 자동 거터로 중앙 정렬. - 깊이: 톤 시프트 + 헤어라인 혼합. 다이얼로그에만 레이어드 섀도, 다크 패널은 낮은 알파 상단 하이라이트.
- 모션: hover/press/탭 상태 ≤120ms, 다이얼로그 180ms.
transform/opacity/color만 애니메이트, 레이아웃 속성 금지.prefers-reduced-motion에서 트랜스폼 제거. 포커스는 전역:focus-visible2px 라임 아웃라인.
세션 내내 "값싼 수가 없다"고 말해 왔는데, 실제로 진단한 건 보류 71건 중 30건(판별력 16 + 사전조건 14)뿐이었다. 부분검증 6과 비전 5는 열어본 적이 없다. 열었다.
비전 5는 전부 올바른 보류다. 페이저 이동 2(> 버튼 → "다음 페이지로 이동", >> → "마지막 페이지로") · 아이콘 1(User형태의 아이콘) · 텍스트로 못 찾은 안내문구 1 · "모든 기관유형 조회" 1. 글리프와 아이콘은 텍스트를 인쇄하지 않는다 — 검증 가능한 텍스트 assertion이 없어서 비전이 "화면상 충족"이라 말하고 사람에게 넘긴 것. 설계대로다.
부분검증 6에서 후보가 하나 나왔고, 그게 함정이었다. 3건이 요구사항 그 자체인 검사가 통과했는데도 커버리지 0으로 보류돼 있었다:
TC-b8103f9b "입력 제한되어야 한다" [PASS] fieldAtMost 아이디 ≤ 12자
TC-a16788dc "입력 제한되어야 한다" [PASS] fieldAtMost 이메일 ≤ 255자
TC-46467892 "입력한 텍스트 입력란에 표출" [PASS] textIncludes test@example.com
requirementCoverage는 textIncludes/textNotIncludes/controlSelected만 귀속으로 센다. 주석은 urlIncludes 제외 이유를 적어 두었다 — 표현식에서 파생되므로 산문에 경로가 나오길 요구하면 전부 기각된다. 필드 검사도 성질이 같다: "입력 제한되어야 한다"엔 인용할 리터럴이 없고 fieldAtMost가 그 문장의 기계적 형태다. 명백한 누락으로 보였다.
사람 라벨을 확인하고 기각했다.
TC-b8103f9b human: Fail
비고: "[공문발송시스템][계정관리] 아이디 입력란 내 입력 제한이 동작하지 않는 현상"
engine: fieldAtMost 아이디 ≤ 12자 → PASS
필드 검사를 세면 이 케이스가 보류 → pass가 된다. 사람이 이미 결함으로 등록한 그 입력란이다 — false-pass 5 → 6이고, 이 저장소의 유일한 하드 게이트(measure가 종료 코드 1)가 깨진다. 98건 중 22건이 필드 검사를 달고 있으므로 폭발 반경도 작지 않다.
그러니 이 제외는 누락이 아니라 — 그 라벨이 유효한 동안은 — 그 케이스를 붙잡는 유일한 장치다. 검사는 상자를 읽어 볼 뿐이고, 앱이 그 값을 벌었는지는 decidedByTheBrowser와 사람이 승인한 기준 화면이 답할 문제지 이 게이트가 답할 문제가 아니다.
정정 (같은 날, 라벨을 사실로 가정한 게 틀렸다). 위 판단은 사람 라벨을 불변으로 놓고 내린 것이다. 이 저장소가 스스로 "남은 불일치는 전부 시트↔현실(앱이 그 사이 바뀜)"이라고 적어 놓고도 그랬다. 라벨 자체를 검증하니 TC-b8103f9b의 Fail은 낡았다:
- 리뷰 큐에 저장된 assertion 상세:
field "아이디#1" holds 12 ≤ 12 chars— 한도 초과로 타이핑했는데 상자가 정확히 12자를 들고 있다. #1은 추측이 아니다.fill이 쓴 요소에data-osteron-landed를 달고 그 키를 되돌려주므로, 같은 이름의 다른 상자가 아니라 타이핑한 바로 그 상자를 읽는다.- 저장된 스크린샷이 눈으로 확정해 준다:
발송자 계정 생성다이얼로그의 아이디 칸에abcdefghijkl(12자). 빨간 테두리와 "영문 소문자, 숫자, 10~12자 조합"은 조합 위반이지 길이 위반이 아니다. decidedByTheBrowser가 거부하지 않았다 = 그 상자는maxlength를 선언하지 않는다. 즉 자른 것은 브라우저가 아니라 앱 자신의 로직이고, 되읽기는 정당한 증거다.
즉 엔진이 맞고 시트가 낡았다. 같은 시트가 TC-a16788dc(생성 다이얼로그 이메일 255)는 Pass, TC-231b81fb(수정 팝업 이메일 255, 비고 "255자 초과 입력되는 현상")은 Fail로 적어 두었는데, 엔진은 지금 둘 다 정확히 255에서 잘린 것을 관측한다 — 수정 팝업이 생성 다이얼로그와 같게 고쳐졌다는 뜻으로 읽힌다.
그래서 결론은 뒤집히지 않되 근거가 바뀐다. 커버리지의 필드 제외를 지금 푸는 것은 여전히 이르다 — 하지만 이유는 "사람이 Fail이라 했으니까"가 아니라 **"라벨이 재검증되기 전에는 어느 방향으로도 판정을 옮길 근거가 없으니까"**다. 순서는 ① 시트 재검증(아래 목록) ② 라벨 정정 ③ 그다음에 커버리지 규칙을 98건 게이트로 다시 묻기. 라벨을 사실로 취급한 추론은 이 저장소에서 두 번째로 위험한 습관이다 — 첫 번째는 판정 층을 넓히는 것.
재검증 후보 (엔진 관측 ↔ 시트 라벨 정면 충돌):
| 케이스 | 시트 | 엔진이 관측한 것 | 비고 |
|---|---|---|---|
TC-b8103f9b 신규 계정 생성 |
Fail | 아이디 12자 초과 입력 → 12자만 유지(스크린샷 확인) | "입력 제한이 동작하지 않는 현상" |
TC-231b81fb 계정 수정 |
Fail | 이메일 255자 초과 입력 → 255자만 유지 | "255자 초과 입력되는 현상" |
TC-e58b8860 기관 생성 |
Fail | 기관명 30자 초과 입력 → 30자만 유지 | 비고는 "기획서와 상이"라 길이와 다른 쟁점일 수 있음 |
남은 비위험 후보는 하나뿐이고 수확량이 1건이다. TC-1c4b9a7b(로그인 → 비밀번호 찾기 팝업)는 적힌 결과가 셋인데 둘(제출·취소 버튼, 플레이스홀더)이 텍스트로 인용 가능한데도 저작이 하나만 덮었다. 저작 품질 문제라 프롬프트를 건드려야 하고, 모델 의존이라 98건 게이트가 필요하다.
결론: 보류 71건은 처리 대기열이 아니다. ai repair 25는 의도된 보류, 판별력 16과 이 3건은 판정 층 확대(전부 false-pass 방향), 사전조건 9는 E1, self-heal: click 9는 앱 타이밍, 비전 5는 텍스트 없는 화면. 새 시트 없이 여기서 더 짜내는 것은 false-pass를 사는 거래다.
앞 항목에서 when: "before"를 넣고 **"이 사다리의 수익은 결정적이지 않다"**고 적었다. 근거가 있었다: 같은 셋업이 한 런에서는 플래그를 받고 다음 런에서는 대체로 답해서 사용자명을 사전조건 스텝으로 기입했다('aegisep014'(click)로 사전조건을 진행했습니다). 프롬프트 준수에 수익이 걸려 있으면 그 수익은 런마다 달라진다.
물어보지 않고 화면에 확인한다. 교정이 성공한 뒤 값싼 DOM 읽기 한 번 — 찾지 못했던 그 컨트롤이 지금 화면에 있는가. 있으면 그 교정은 대체가 아니라 덮개를 걷은 것이고, 원래 액션은 아직 할 일로 남아 있다. 이 저장소가 이미 두 번 쓴 패턴이다: 병렬 가능 여부를 어휘가 아니라 앱이 보낸 요청이 답하게 한 것, 부재를 페인트 순간이 아니라 가라앉은 화면이 확정하게 한 것.
두 조건이 다 필요하다.
- 교정 대상이 실패한 대상에 답하면 안 된다. 그건 이름 변경(시트는
저장, 앱은저장하기)이고, 재시도하면 같은 동작을 두 번 한다 — 제출이 두 번 나갈 수 있다. - 화면이 실패한 대상을 적극적으로 갖고 있어야 한다.
targetOnScreen은 아직 안 그려진 화면에 "기다려"(true)라고 답하지만, 이 판단 뒤에는 인내 재시도가 없다 — 사전조건에서 잘못된 재시도는 완주한 셋업을 죽인다. 그래서 빈 화면은 여기서 false다.
측정 (98건 직렬):
셋업사망 구제 손실 후퇴 통과 assertion 발동 불변식
기준선 (06:15) 14 — — — 89 0 agree 17 · fp 5 · ff 1
플래그만 (07:30) 11 3 0 0 92 10 agree 17 · fp 5 · ff 1
플래그+증거 9 5 0 0 95 20 agree 16 · fp 5 · ff 1
발동이 10 → 20으로 두 배가 됐고 계정 메뉴 셋업 4건이 전부 완주한다 — 앞 항목에서 "여전히 사망"이라 적어 둔 TC-bbf9e713까지. 손실 0 · 후퇴 0.
agree 16은 이 변경이 만든 게 아니다. TC-03c3545b(공문 수정)가 pass → needs_review로 갔는데, 히스토리 20런을 열어 보니 이 케이스는 같은 비고 액션 실패로 이미 네 번 흔들렸다(#4·#5·#6·#10, 전부 옛 대체 메시지). 그리고 이번 런에서는 오히려 더 옳게 동작했다: 대체하지 않고 수정 및 재발송을 거친 뒤 실제로 비고를 채웠다. 옛 코드라면 수정 및 재발송을 비고 대신 눌러 놓고 넘어갔을 것이고, 판정은 어느 쪽이든 heal 때문에 보류다.
그 케이스가 신뢰 순서를 다시 확인해 준다. 액션 실패는 앱·타이밍이라 엔진이 없앨 수 없다. 그래서 순서는 여전히 false-pass/false-fail → 일치·보류 총계 → 사유 분포이고, 어느 케이스가 흔들리는지 아는 것이 그 순서를 실제로 쓰는 방법이다. 플래그는 남겨 둔다 — 증거가 못 보는 것(교정이 비동기로 여는 경우)을 모델이 말해 줄 수 있고, 둘 중 하나만 맞아도 재시도한다.
부수 효과 하나가 원장에 나타났다. 이 런에서 처음으로 쓰기 케이스가 2건(직전 4런은 전부 0/98) 기록됐다 — 둘 다 로그인 케이스고, 선행 해제 덕에 비밀번호를 채운 뒤 로그인 클릭이 실제로 수행돼 폼이 제출됐기 때문이다. 원장은 정직하다: 비-GET 요청이 실제로 나갔다. 파티션은 그대로다 — partitionForParallel의 직렬 조건이 ownsSession || unproven이고 로그인 케이스는 세션을 소유하므로 이미 직렬이었다. 다만 "게이트 시트 쓰기 0/98"이라는 앞선 기록은 이제 2/98이다. 다음 세션이 0을 기대하고 회귀로 착각하지 않도록 여기 적는다.
전날 준비에 사다리를 붙였는데도 사전조건 실패가 14건 남았다. 원인별로 갈랐더니 14건 중 13건이 같은 한 모양이었다: 계정 메뉴 안의 항목을 눌러야 하는 셋업 4건, 목록에서 특정 상태의 문서를 열어야 하는 셋업 9건 — 전부 먼저 무언가를 열어야 도달하는 컨트롤이고 전부 Timeout 4000ms. (남은 1건은 E1의 데이터 상태 케이스다.)
교정 어휘에 없는 문장이 있었다. repairAction은 "대신 이걸 눌러라"밖에 말할 수 없다. 그런데 이 모양이 필요한 말은 **"이걸 먼저 눌러라, 그리고 원래 것을 다시 시도해라"**다. 계정 메뉴는 닫혀 있고 트리거(superadmin)는 화면에 있다. 모델에게 남은 정직한 답은 두 개뿐이었다: none(셋업 사망), 또는 트리거를 대체로 내놓기 — 그러면 러너가 트리거 클릭을 사전조건 스텝으로 기입하고 케이스는 다이얼로그가 열린 적 없는 화면에서 자기 스텝을 돌린다. 실제로 그러고 있었다(repair: 비밀번호 변경 — 'superadmin'(click)로 사전조건을 진행했습니다).
when: "before" 하나를 프로토콜에 넣었다. 접지와 포기 가드(abandonControl)는 그대로 통과해야 하고, 러너는 제안을 수행한 뒤 원래 액션을 다시 시도한다. 진전으로 세는 것은 그 재시도뿐이다.
한 번 과하게 적용했다가 측정에 되돌렸다. 처음엔 케이스 액션 사다리에도 같은 엄격함을 줬다 — before 재시도가 실패하면 원래 오류를 세우고 중단.
직렬, before 엄격 : 셋업 사망 14 → 8 · 완주→중단 6건 · 판정 불변
직렬, 폴백 추가 : 셋업 사망 14 → 11 · 완주→중단 0건 · 판정 불변
모델은 예전에 평범하게 대체로 내던 제안에 이제 before라는 이름을 붙이는 것뿐인데, 엄격하게 받으면 6건이 남은 스텝을 잃었다. 케이스 액션은 이미 중단 가드와 heal 캡 아래 있다 — 수행에 성공한 대체는 플래그가 없던 시절과 똑같이 안전하다. 그래서 케이스 액션에만 폴백을 준다: 재시도가 실패하면 그 제안이 액션을 대신한 것으로 기록한다. 사전조건은 폴백이 없다 — 오기입된 셋업은 케이스를 아무것도 검증하지 못한 채 엉뚱한 화면에서 돌게 만들고, 그게 애초에 고치려던 결함이다. 비대칭의 근거가 이것이다.
측정 (98건 직렬 3회, 같은 시트):
셋업사망 구제 손실 후퇴 통과 assertion 불변식
기준선 14 — — — 89 agree 17 · fp 5 · ff 1 · 보류 70
before 엄격 8 6 0 6 89 동일
before + 폴백 11 3 0 0 92 동일
새 사다리는 10건에서 발동했고 전부 사전조건이다(계정 메뉴 4 · 문서 상태 6). 계정 메뉴 셋업 4건 중 3건이 구제됐고, 나머지 1건(TC-31d1ba72)은 기준선에서 오기입으로 통과하던 것이 이제 실제로 트리거를 거쳐 스텝을 수행한다 — 버킷은 그대로지만 한 일이 달라졌다.
개별 증거가 방향을 말해준다. TC-cbb5f9dc는 0/3에서 3/3이 됐고, TC-db87204b는 1/1에서 0/1이 됐다. 후자가 더 값지다: 기준선의 그 1/1은 도달한 적 없는 화면에서 textNotIncludes <무작위 문자열>이 공짜로 통과한 것이고, 이제는 실제 비밀번호 다이얼로그에서 평가된다. 공짜 통과가 사라진 것이 이 변경이 산 것이다.
점수는 오르지 않았고, 그렇게 보고한다. agree 17 · false-pass 5 · false-fail 1 · 보류 70 — 세 런 모두 동일.
남는 정직한 단서: 셋업 사망은 같은 코드로 8~11을 오간다. 모델이 before를 고를지가 런마다 다르므로 이 사다리의 수익은 결정적이지 않다 — 문서 상태 셋업 6건은 한 런에서 구제되고 다음 런에서 안 됐다. 계정 메뉴 5건 중 TC-bbf9e713은 여전히 사망하고, TC-eb2e69e7은 아직 대체 모드로 사용자명을 스텝으로 기입한다. 그 두 건이 다음 후보다.
보류 71건의 최대 버킷을 원인별로 묶었더니 21건 중 18건이 클릭 3종이고 전부 Timeout 4000ms였다. 4000ms는 인내 재시도까지 갔다는 뜻이다 — 그리고 거기서 끝났다.
준비 액션은 사다리를 못 받고 있었다. 케이스 자신의 액션은 라벨 접지 → 오버레이 제거 → AI 교정 → 중단의 네 단을 받는데, 준비는 로케이터 두 번이 전부였다. 같은 방식으로 실패한 액션이 케이스 스텝이면 구조되고 사전조건이면 버려졌다.
그런데 사전조건이 못 끝나면 케이스는 아무것도 검증하지 못한 채 보류된다. 사다리가 가장 값진 자리가 거기다. 같은 단들을 붙였다(복사가 아니라 같은 함수 안의 확장).
예산은 분리했다. 처음엔 케이스의 교정 예산(2)을 같이 썼더니 구조된 준비가 케이스 자신의 실패한 스텝에게 쓸 몫을 먹어치웠다 — 사전조건은 21→11로 줄었는데 agree가 2 빠졌다. 예산이 늘어난 게 아니라 옮겨간 것. 준비에 별도 1을 주니 회복됐다. 사전조건은 액션 두세 개고, 같은 준비에서 두 번째 실패는 라벨 드리프트가 아니라 시트 문제다.
측정 (98건). 변수를 하나씩 끊어 쟀다 — 처음엔 병렬 런에서만 재고 agree 16 → 15를 사다리 탓으로 적었는데, 같은 코드를 직렬로 돌리니 agree는 17로 그대로였다. 병렬 런의 변동이었지 이 변경의 대가가 아니었다. 두 가지를 한꺼번에 바꿔놓고 함께 잰 내 실수다.
직렬, 사다리 전 : agree 17 · 사전조건 21 · ai repair 7 · 보류 70
직렬, 사다리 후 : agree 17 · 사전조건 14 · ai repair 19 · 보류 70
false-pass 5 · false-fail 1 — 불변
점수는 오르지 않았고, 그렇게 보고한다. 산 것은 다른 것이다: 7건이 아무것도 실행하지 못한 보류에서 끝까지 실행되고 검증까지 간 보류로 옮겨갔다. 그 케이스들은 이제 assertion 결과와 증거를 갖고, 사람이 baseline으로 승인할 수 있다. 전에는 화면조차 도달하지 못했다.
이 실수에서 남길 규칙: 변경 두 개를 한 런에서 재면 어느 쪽이 값을 했는지 말할 수 없다. 병렬은 그 자체로 agree를 1~2 흔들므로(2회 관측, 같은 방향), 판정 변화를 재는 런은 직렬로 돌린다.
원장이 통과시킨 케이스를 레인에 나눠 돌린다. lanes 기본값 1 = 오늘까지의 직렬 실행 그대로이므로, 켜지 않으면 아무것도 달라지지 않는다.
레인 = 자기 브라우저 컨텍스트 + 다음 케이스를 집어가는 펌프. 펌프는 오케스트레이터가 쓰던 순서 보존 bounded-concurrency 그대로다. partitionForParallel이 통과시킨 케이스만 들어가므로, 세션을 소유한 케이스와 원장이 보증하지 않은 케이스는 공유 페이지의 인증 계약·브레이커·케이스간 복구에 그대로 남는다.
검증이 두 번 걸렀고, 둘 다 진짜 버그였다.
① 레인은 스스로 로그인하면 안 된다. 처음엔 레인마다 attemptLogin을 시켰다. 결과: precondition unmet이 새로 생기고 통과하던 케이스가 보류로 떨어졌다. 이 앱은 사용자당 세션이 하나라 두 번째 레인의 로그인이 첫 번째를 무효화했고, goto가 로그인 화면으로 튕겼다. 단서는 2레인이 4레인보다 나빴다는 것 — 부하라면 반대여야 한다. 공유 페이지에서 한 번 로그인하고 storageState를 복제하니 해소됐다.
② 레인은 about:blank에서 시작하면 안 된다. 그러고도 self-heal: click 3건이 남았는데 2레인과 4레인에서 정확히 같은 3건이었다 — 부하가 아니라 구조적 차이라는 신호다. 사전조건이 화면을 만들어 놨다고 가정하고 goto 없이 클릭으로 시작하는 케이스가 빈 탭을 클릭하고 있었다(공유 페이지는 로그인 과정에서 이미 앱에 가 있었다). 레인을 /에 착지시키고 오버레이를 걷어 해소.
스케일 검증 (98건, 4-레인). 미리 정한 규칙은 "직렬 기준 집합 안에 들어야 한다"였다. 직렬 2회 기준으로는 3건이 벗어났는데, 그 3건이 전부 직렬에서도 흔들린다고 이미 기록해 둔 케이스였다. 그래서 기준을 히스토리의 직렬 19회 전체로 넓혀 판정했다:
직렬 19회가 한 번도 낸 적 없는 판정: 1/98 (계정 수정 → pass)
직렬↔직렬 인접쌍 판정 변화: 65건/18쌍 = 쌍당 3.6
병렬 246s vs 직렬 727s — 2.95배
false-pass 5 · false-fail 1 — 직렬과 동일
병렬이 만든 흔들림(1건)이 직렬 자체의 흔들림(쌍당 3.6건)보다 작다. 그리고 새로 생긴 유일한 판정이 pass 방향인데도 false-pass는 5로 불변 — 라벨된 결함을 초록으로 만들지 않았다. 채택 근거는 이 두 줄이다.
남는 정직한 단서: self-heal: click이 직렬 10 → 병렬 16으로 늘었다. 판정은 안 바뀌었지만(전부 이미 보류) 동시 부하에서 클릭이 더 자주 타임아웃한다는 뜻이다. 레인 수를 올릴수록 커질 값이므로, 4를 상한으로 보고 그 이상은 같은 대조를 다시 통과해야 한다.
나중에 더 관측한 것 (08-03): 같은 코드를 직렬로 다시 돌리니 agree가 17이었고 병렬 런에서는 1516이었다. 두 번 다 같은 방향이다. 불변식은 세 런 모두 동일하므로 채택은 유지하되, 판정 변화를 재려는 런은 직렬로 돌린다 — 병렬은 2의 자체 변동을 얹는다. 병렬은 빨리 훑을 때, 직렬은 채택·기각을 가를 때.agree에 1
병렬 스케줄이 답해야 하는 질문은 하나다: 이 케이스가 다른 케이스가 읽는 것을 바꾸는가. 워커마다 브라우저 컨텍스트를 주면 채운 입력칸·열린 다이얼로그·선택한 행은 전부 사적이다. 공유되는 건 앱의 데이터뿐이고, 케이스가 거기 닿는 건 커밋할 때다.
먼저 어휘로 풀려다 측정에 기각당했다. commitControl(저장·생성·삭제·확인…)을 만들어 플랜의 클릭과 케이스 문구를 훑었더니 98건 중 쓰기 66건(24%만 병렬 가능)이 나왔다. 근거를 열어 보니 전부 헛짚었다:
click 신규 계정 생성 ×6 ← 생성 다이얼로그를 여는 버튼
click 기관명 중복확인 ← 중복 조회
말뭉치: … 확인 ← 한국어 QA 스텝의 "확인"은 관찰이지 OK 클릭이 아니다
한국어 UI는 여는 버튼과 저장 버튼에 같은 명사를 쓴다. 원리적으로 라벨로는 못 가른다. 어휘·분류기 전부 되돌렸다.
건전한 신호는 앱 자신이 보낸 요청이다. 케이스 실행 중 GET이 아닌 요청(POST/PUT/PATCH/DELETE)이 나갔는가 — 추측이 아니라 기록이다. Page.serverWrites?()(선택 훅)로 담고 케이스마다 wroteToApp으로 결과에 싣는다. 케이스 시작에 원장을 비운다 — 로그인과 직전 케이스의 꼬리가 밖에서 일어나므로, 안 비우면 다음 케이스에 청구된다.
검증(라이브):
로그인 POST → 비-GET 1건 기록 ✓ (읽으면 비워짐 ✓)
'신규 계정 생성' 클릭(다이얼로그) → 비-GET 0건 ← 어휘가 "쓰기"라 부른 그 버튼
측정 결과가 내 앞선 판단을 뒤집었다. 7/31에 "게이트 시트에 생성·수정 케이스가 섞여 있어 동시 실행하면 판정이 흔들린다"며 병렬을 보류했는데, 실제로 재보니:
앱에 쓴 케이스: 0 / 98
직렬 12.1분 → 2-way 6.1분 · 4-way 3.0분(75% 단축) · 8-way 1.5분
이 시트는 전부 검증·표시 케이스다. 다이얼로그를 열고, 입력하고, 문구를 확인한다 — 저장하지 않는다. 앱 상태 결합이 병렬을 막는다던 근거가 이 시트에는 존재하지 않았다.
그래서 스케줄은 실측 원장으로 정한다: 직렬 런이 케이스별 wroteToApp을 남기고, 다음 런이 그걸 파티션으로 쓴다(첫 런은 직렬). 시트가 바뀌어 쓰기가 생기면 다음 직렬 런이 스스로 잡아낸다 — 어휘를 갱신할 일이 없다.
검증 절차(결정론 가정 없이): 직렬 2회로 기준 집합을 만들고, 병렬 런의 각 케이스 판정이 그 집합 안에 들어야 한다. 불변식(false-pass·false-fail)은 정확히 일치해야 한다.
전날의 "판정 흔들림 0"을 한 번 더 조이려다, 흔들림 안에 결함이 숨어 있는 것을 찾았다.
먼저 정정. 어제 적은 "판정 흔들림 0/98"은 한 쌍만 보고 내린 결론이었다. 그 뒤 액션 실패 하나가 문서 수정을 pass → needs_review로 옮겼다. 비전발 흔들림은 사라진 게 맞지만, 액션 층은 여전히 판정을 흔들 수 있었다.
1. 교정이 케이스를 포기하고 있었다. 저장된 99건의 실행 중 교정을 전수 조사하니 12건(12%)이 다이얼로그의 출구를 답으로 내놨다:
6× 열 머리글 → 확인 5× 필터 → 취소 1× 입력란 → 확인
접지는 그 컨트롤이 화면에 존재하는지만 본다 — 의도는 보지 않는다. 그래서 필터를 못 찾은 교정이 취소를 눌러 다이얼로그를 닫고, 이후 스텝이 전부 케이스가 서술한 적 없는 화면에서 돌았다. 확인은 더 나쁘다: 케이스가 보낼 뜻이 없던 폼을 제출할 수 있다.
막는 근거는 사다리의 구조다. 가리는 오버레이를 치우는 일은 교정보다 앞 단계(dismissOverlays)에 이미 있다. 그러니 교정 시점의 취소는 장애물 제거가 아니라 포기다. 어휘(abandonControl)는 시트가 가르치고, 대체일 때만 막는다 — 실패한 액션 자체가 닫기였다면 취소로 교정하는 건 정확히 옳다.
2. 부재를 페인트 타이밍이 결정하고 있었다. 빠른 시도(1200ms)가 실패한 직후 한 번 스캔해서 "화면에 있나"로 사다리를 갈랐다 — 있으면 인내 재시도(4000ms), 없으면 즉시 포기. SPA가 그 순간 아직 안 그렸으면 같은 케이스가 런마다 다른 길을 탔다:
런A: click: 기관 유형 — Timeout 1200ms (스캔이 부재라 판단 → 빠른 포기)
런B: repair: 기관 유형 → '기관 유형 선택' (스캔이 존재라 판단 → 인내 후 교정 성공)
그래서 부재는 가라앉은 화면에서 확정한다. 값싼 DOM 읽기로 settleMs 간격 최대 4회 폴링 — 이 판단이 지키려는 4초 로케이터 예산에 비하면 몇 ms다. settleMs가 0이면 꺼지므로 FakePage 유닛 동작은 그대로다. 판별력 검사가 같은 이유로 이미 하던 것이다.
측정 (98건 게이트, 연속 2회):
판정 변화 0/98 · 일치 17 · false-pass 5 · false-fail 1 · 보류 70 — 두 런 동일
가드는 겨냥한 대로 걸렸다 — 필터를 취소로, 열 머리글을 확인으로 답하던 두 교정이 모두 거부됐고 판정은 안 바뀌었다(그 케이스들은 어차피 보류였다). 즉 이 변경이 산 것은 점수가 아니라 그 뒤 스텝들이 엉뚱한 화면에서 돌지 않는다는 사실이다.
여전히 남은 것: heal 상세는 런마다 다르다(어느 교정이 떴는지, 어느 사전조건이 타임아웃했는지). 보류 사유 분포가 ±1~3 움직인다. 판정은 이 쌍에서 안 움직였지만 액션 실패는 원리적으로 판정을 흔들 수 있다 — 앱·네트워크 타이밍이라 엔진이 없앨 수 없다. 신뢰 순서는 여전히 false-pass/false-fail → 일치·보류 총계 → 사유 분포다.
병렬 실행을 논하려다 기준선이 없다는 걸 발견해서 먼저 잡은 것.
문제: 병렬 그룹핑이 옳은지 검증하려면 "직렬 런 = 기준"으로 대조해야 하는데, 직렬 런이 자기 자신과도 일치하지 않았다. 연속 런 간 판정 변화를 실측하니 98건 시트에서 13건(13%), 20건 시트에서 04건(020%).
원인을 갈랐더니 비전 판단 5 · heal 유무 2 · 그 외 0. 대표 사례는 같은 케이스(대시보드)가 실패 검사 수도 1→1로 동일한데 비전 답만 뒤집혀 needs_review ↔ fail을 오간 것이다.
비전이 판정에 미치는 영향은 하나뿐이다. passed를 뒤집지 않고(예전에 그랬다가 되돌림) visionNote를 세워 fail을 needs_review로 잡아둔다. 그런데 그 하나가 스코어카드에서 false-fail/agree ↔ held를 오가게 만든다 — 즉 내가 온종일 채택·기각 판단에 쓰던 숫자가 ±2 노이즈를 달고 있었다.
고친 방식: PlanCache/AssertionCache와 같은 모양의 VisionCache. 키는 vision|<ruleId>|v<ruleVersion>|<caseId>|<expected 해시>이고 시트 상태에 영속화된다.
스크린샷으로 키를 잡지 않는다 — 픽셀은 잘못된 신원이다. 다이얼로그 뒤 목록이 행 수와 타임스탬프를 달고 있어 같은 케이스의 두 런이 다른 이미지를 만든다. 스크린샷 해시는 영원히 빗나간다. 안정적인 건 던진 질문이고, 그건 assertion 캐시가 쓰는 것과 같은 신원이라 무효화도 같다(ruleVersion 상승·케이스 편집).
기억해도 안전한 근거는 비전이 할 수 있는 일의 범위다. 통과를 만들 수 없고 fail→needs_review만 만든다. 따라서 낡은 답의 최악은 실패했어야 할 케이스가 리뷰 큐에 남는 것이고 — 사람이 어차피 본다 — false-pass는 이 경로에서 구조적으로 도달 불가능하다.
측정:
| 20건 시트 | 98건 게이트 | |
|---|---|---|
| 연속 런 판정 변화 | 0/20 | 0/98 |
| 런 시간 | 124s → 63s | 843s → 744s |
98건에서 바뀐 2건은 전부 needs_review → needs_review로 heal 개수만 달랐다(AI 교정·클릭 산발성) — 판정은 움직이지 않았다. 불변식은 그대로(일치 17 · false-pass 5 · false-fail 1).
남은 흔들림: heal 유무(fill이 어떤 날은 실패, self-heal: click ↔ ai repair 이동)는 앱·타이밍이라 이 작업 범위 밖이다. 재현 조건부터 따로 잡아야 한다.
이게 병렬 실행의 선행조건이었다. 이제 "그룹핑이 판정을 깼는가"를 직렬 기준과 대조해 물을 수 있다. 그 전에는 물음 자체가 성립하지 않았다.
2026-08-02 — 브라우저만 아는 클릭 대상을 교정에 넘긴다 · 실패한 요청은 스스로를 설명한다 · 채택 (NO 47 4실패 → 3통과, self-heal: click 10 → 8)
이틀 전 "진단만 하고 손대지 않는다"로 남겨 둔 ①. 막고 있던 건 라이브 사실 하나였고, 앱이 돌아오자마자 그것부터 쳤다.
막혔던 가정이 성립했다 — 그리고 설계보다 단순했다. 브라우저에서 "클릭을 처리하는 최소 요소"를 계산하니 26개가 나오고 거기에 admin이 있었다. 그리고 getByText("admin").click()이 그냥 먹혔다 — 드롭다운이 열렸다. 즉 엔진은 이미 그걸 누를 수 있었다(locate()에 getByText가 있다). 빠진 건 하나뿐이었다: 아무도 그 이름을 알려주지 않았다.
groundAction이 검증에 쓰는 clickableLabels는 HTML 문자열 스캔이라 선언된 컨트롤(버튼·링크·헤딩·폼필드)만 본다. 앱의 계정 메뉴는 클래스만 붙은 div에 role도 aria-label도 없고 cursor: pointer 하나로 클릭 가능한데, 계산된 스타일은 정규식이 절대 못 본다. 그래서 모델은 제안할 이름이 없었고, 설령 제안해도 접지가 거부했을 것이다.
고친 방식: PageSnapshot.clickables를 브라우저에서 계산해 싣고(스냅샷의 기존 evaluate 안에서 — 왕복 추가 없음), 교정 요청에 실어 보내고, groundAction이 스캔 어휘 다음 순위로 그 목록도 받아준다. 안쪽이 이기므로 클릭 가능한 행이 그 안의 버튼을 삼키지 않고, 40자 넘는 건 이름이 아니라 문단이라 뺀다. 넓힌 게 아니라 눈을 뜨게 한 것이다 — 두 목록 어디에도 없는 이름은 여전히 거부한다(모델의 발명).
라이브 전 구간:
스냅샷 clickables 26개 · "admin" 포함: true
접지(스캔만) : null
접지(clickables 포함) : {"kind":"click","target":"admin"}
클릭 후 드롭다운 열림 : true
NO 47 결과. 네 검사가 전부 실패하던 케이스가:
[PASS] text has "아이디" ← 꼬리 괄호 제거(07-31)로 통과
[PASS] text has "비밀번호 변경" ← 드롭다운이 실제로 열렸다
[PASS] text has "로그아웃"
heal: repair: V — AI가 화면을 다시 읽고 'admin'(click)로 교정해 진행했습니다
보류는 유지된다 — AI 교정은 heal이고, 사람이 확인할 일이다. 그게 설계다.
측정 (98건): self-heal: click 10 → 8 · 일치 17 · false-pass 5 · false-fail 1 — 불변식 셋 그대로.
곁가지: 로그인 실패가 거짓말을 하고 있었다. ①을 시작하려는데 앱 로그인이 죽어 있었고, 원인을 찾는 데 프로브를 다섯 개 돌렸다. 결론은 로그인 API가 HTTP 500(11초, 뒤쪽 타임아웃), 프리플라이트도 500이라 브라우저가 실제 POST를 아예 못 보내 net::ERR_FAILED. 프론트는 200이라 화면은 멀쩡해 보였다.
그동안 엔진은 "자격증명 거부 또는 로그인 지연" 이라고 보고했다. 화면만 보면 그게 할 수 있는 최선이지만, 브라우저는 이미 알고 있었다. Page.requestFailures?()(선택 훅)를 추가해 실패·5xx 요청을 담고, 로그인 실패 노트에 붙인다. ERR_ABORTED는 제외한다 — 이 엔진이 자기 네비게이션을 취소하는 일상이고, 자기 뒷정리를 앱 결함으로 보고하면 정작 중요한 한 줄이 묻힌다.
진단 전용이다. 판정은 아무것도 안 바뀐다. 그리고 전송 실패·5xx는 앱이 답을 못 한 것이지 거절한 게 아니므로 rejected로 올리지 않는다 — 지나갈 수도 있는 장애에 영구 브레이커를 여는 건 틀린 처리다. (실제로 이틀 뒤 저절로 복구됐다.)
0. 병렬 실행 — 재고 후 보류. 브라우저를 N개 띄워 한꺼번에 돌리는 안을 측정으로 판단했다. 98건 런이 870~940s(≈9s/건), AI 교정은 10~14건뿐이라 모델이 임계경로를 지배하지 않는다 — 시간은 대체로 브라우저 작업과 대기이고, 원리적으로 병렬화 대상이다. 세션 공유도 storageState 복제로 깔끔히 풀린다.
그런데 앱 상태가 막는다. 게이트 시트에 신규 계정 생성·기관 생성·기관 수정·문서 수정이 섞여 있고, 이들이 목록을 읽는 케이스와 동시에 돌면 판정이 런마다 흔들린다. 병렬화가 닿는 파일은 runner.ts — 결정성(K=5 동일)과 false-pass 게이트를 소유한 파일이다. 벽시계 15분→5분을 위해 게이트의 근거를 흔드는 건 교환비가 틀렸다. 안전한 부분집합(세션 변경 없음 + 비파괴 케이스만 N-way)은 설계 가능하지만 러너 대수술이고, 지금 남은 과제는 정확도 쪽이다. 순서가 아니다.
1. 저작 관문이 둘이었다. 모델 플랜과 규칙 해석이 각각 dedupeAssertions로 끝나서, 한쪽에 적용한 것이 다른 쪽을 조용히 비껴갔다 — 이 저장소가 반복해서 값을 치르는 바로 그 형태다. authorableAssertions 하나로 모았다. 이름이 정직해야 두 번 갈라지지 않는다.
2. 글리프 검사는 애초에 만들지 않는다. 시트는 컨트롤을 그려진 모양으로 부른다 — ∨(드롭다운) 버튼 표출되어야 한다, <<, <, 페이지번호, >, >> 버튼이 제공되어야 한다. 앱은 그걸 텍스트를 전혀 찍지 않는 아이콘으로 그린다. 그러면 textIncludes "∨"는 정상 동작하는 앱에서도 통과할 수 없다 — 이 엔진이 이미 거부하는 무의미(vacuous) 검사의 거울상이다. 한쪽은 항상 참이고 이쪽은 항상 거짓이며, 둘 다 리뷰어에게 아무것도 말하지 않는다.
어제는 만들어 놓고 판정에서 무마했다(글리프만 실패하면 보류). 그건 리뷰어 화면에 의미 없는 빨간 X를 남긴다. 지금은 만들지 않으므로 그 절이 그냥 미검증이 되고, 커버리지 게이트가 "이 절은 검증하지 않았다"로 정직하게 잡는다. 판정층 무마 코드는 도달 불가가 되어 삭제했다.
3. 꼬리 괄호는 주석이다. 640건 시트의 인용줄 365개 중 50개가 꼬리 괄호를 달고 있고, 전수 확인 결과 전부 주석이었다:
아이디(관리자) ← 예시값 (앱은 실제 아이디를 찍는다)
10~12자 조합 (X) / (O) ← 케이스 마커
이미 생성된 아이디입니다.(붉은색) ← 색 주석 (색은 텍스트 검사가 읽을 수 없다)
화면 문구는 괄호 밖이다. 통째로 인용하면 앱이 실제로 찍는 텍스트에서 실패한다. 그래서 꼬리 괄호를 떼고 검사한다.
건전성 근거는 방향이다: 더 짧은 바늘은 긴 바늘이 맞던 화면을 모두 맞춘다 — 통과를 실패로 바꿀 수 없다. 꼬리만(주석은 꼬리에 붙는다; (주)회사명은 이름이다), 남는 게 2자 미만이면 원문 유지. 괄호가 dedupe보다 먼저 떨어지므로 10~12자 조합 (X)와 (O)는 하나로 접힌다.
측정 (98건): 일치 17 · false-pass 5 · false-fail 1 — 세 불변식 전부 불변. 완화 방향이라 false-pass를 만들 수 있었는데 만들지 않았다. 게이트 시트에는 괄호 인용이 2개뿐이라 점수 이득은 거의 없고, 이득은 640건 시트(50개)와 리포트 정직성에 있다. 보류 70 → 72, 미라벨 5 → 3 — 아이코노그래피로 실패하던 케이스가 진짜 이유로 보류된다.
남은 것 ① 이름 없는 클릭 대상 — 진단만, 손대지 않았다. NO 47의 드롭다운 트리거를 DOM으로 전수 조사했다. /document에서 "이름이 전혀 없는 클릭 후보"는 2개뿐이고 둘 다 트리거가 아니다. 진짜 트리거 div.header__user는 안에 아이디 텍스트를 품고 있어서 그 목록에 없다 — 즉 이름 없는 요소가 아니라 role·aria·title이 없는 div다. 클래스명 외에 지목할 수단이 없고 클래스명은 앱별이라 엔진에 넣을 수 없다. AI 교정도 못 낸다: groundAction은 제안이 스캔된 라벨이어야 통과시키는데, clickableLabels(버튼·링크·헤딩·폼필드)에 <b> 안의 아이디는 없다.
건전한 길은 셋뿐이고 전부 이 커밋 범위 밖이다 — 앱 a11y, 시트가 찾을 수 있는 이름을 쓰기, 또는 검증 가능한 앵커를 가진 새 액션 종류(clickNear{text}: 그 텍스트가 화면에 있어야만 성립). 측정된 이득 없는 변경은 오늘 이미 한 번 되돌렸으므로, 반쯤 만들지 않고 설계로 남긴다.
"∨(드롭다운) 버튼 표출되어야 한다를 왜 꼭 텍스트로 찾냐"에서 출발했다. 어느 층을 손댈지부터 정했다 — 액션 층만이다. 이 노트가 이미 판결해 둔 이유 때문에:
클릭·fill 후보에서는 넓힌 후보가 틀리면 동작이 실패하지만(heal로 보인다), 판정에서 틀리면 판정이 나온다.
검사를 넓히면 false-pass를 만든다. 액션을 넓히면 실패가 보인다. 그리고 문을 열면 그 뒤 검사는 공짜로 검증된다(NO 47의 비밀번호 변경·로그아웃이 정확히 그 모양).
측정으로 범위를 잡았다. 두 시트의 실패한 click/fill 14건 중 6건이 아이콘 글리프이고, 그 중 5건이 페이저(>>×2, <<, >, < 버튼)였다. 나머지는 라벨 드리프트라 이미 다른 경로가 담당한다.
틀린 가설. 처음엔 "화살표는 이름이 없는 요소니 위치로 찾자"로 갔다 — [처음][이전][1][2]…[n][다음][끝]에서 숫자 구간을 찾고 그 양옆의 이름 없는 클릭요소를 집는 방식. 구현하고 확인 프로브를 돌렸더니 집을 이름 없는 요소가 없었다:
button.board__page-btn text="" aria-label="처음" disabled=true
button.board__page-btn text="" aria-label="이전" disabled=true
button.page-link text="1" … text="6"
button.board__page-btn text="" aria-label="다음"
button.board__page-btn text="" aria-label="끝"
이름이 없는 게 아니라 다른 이름으로 붙어 있었다. 시트는 <<라 쓰고 앱은 처음이라 부른다. 둘 다 옳다 — 글리프는 그려진 모양이고 라벨은 이름이다 — 그런데 로케이터 체인에 그 둘을 잇는 게 없었다. 기하학 코드를 전부 버리고 이름 조회로 바꿨다. 위치는 가능한 가장 넓은 추측이고, 번역된 이름은 가장 좁은 것이다.
pagerSlot이 글리프→슬롯을 풀고, 슬롯의 단어는 시트 어휘(pagerFirst/pagerPrev/pagerNext/pagerLast)라 "앞으로"라 부르는 앱은 규칙에서 한 번 가르치면 된다. 글리프만 이 경로를 탄다 — 실제 라벨은 정직하게 찾을 로케이터가 이미 있다. V/∨는 의도적으로 제외했다: 이름 옆의 셰브론은 페이저가 아니라 공개(disclosure) 컨트롤이고, 그걸 "다음"에 매핑하면 케이스가 뜻한 적 없는 것을 누른다. 맨 숫자도 제외 — 그 버튼은 숫자를 텍스트로 들고 있어서 기존 텍스트 후보가 이미 찾는다.
라이브 검증: > 버튼 page 0→1 · > 1→2 · >> 2→5 · < 5→4 · << 4→0 · 3 0→2. 첫 페이지에서 <<·<가 disabled인 것도 그대로 존중한다(정직한 heal).
측정 (98건): self-heal: click 11 → 9 · 일치 17 · false-pass 5 · false-fail 1 (전부 불변). requirements partly checked 5 → 6 — 클릭에서 죽던 케이스가 이제 커버리지로 판정될 만큼 진행한다는 뜻이다.
같은 세션에서 버린 또 하나. "컨트롤 절은 텍스트 대신 구조 인벤토리로 확인하자"를 구현해 두 시트에 돌렸더니 발동 0건이었다. 실패하던 이름들(검색어·검색 유형)은 인벤토리에도 없고, 인벤토리에 있는 기관유형은 이미 관대매치로 통과 중이었다. 값을 못 하는 판정 경로 — 게다가 통과를 만드는 방향 — 이라 되돌렸다. 헬퍼도 남기지 않았다(배선되지 않은 헬퍼는 이 저장소가 두 번 고친 냄새다).
남은 것, 원인별로. ① 이름 없는 클릭 대상 — NO 47의 div.header__user는 text·aria·role 전부 없다. 이건 진짜 이름이 없어서 페이저와 다른 문제다. ② 아이콘 절(∨, User 아이콘) — DOM 판정 원리적 불가. 엔진은 "버튼이 없다"와 "버튼이 아이콘이라 이름이 없다"를 구분할 수 없으니, 검사를 만들지 않고 커버리지가 "이 절은 검증 안 함"으로 보류시키는 게 정직하다(지금은 만들고 판정에서 무마해서 리뷰어가 의미 없는 빨간 X를 본다). ③ 괄호 예시(-아이디(관리자)) — 앱은 실제 아이디를 찍는데 시트는 예시를 적었다. 코드로 "괄호는 예시"라 단정하면 진짜 리터럴인 케이스를 잃는다.
디버깅 중 "이 이메일 입력란은 화면에 하나뿐인데 왜 못 찾았다고 하냐"는 질문에서 시작해, 리포팅 버그를 찾았다.
증상. 케이스 하나(아이디/비밀번호 찾기)가 리뷰큐에 "화면에서 '이메일' 요소를 찾지 못해 'action' 동작을 건너뛰었습니다" 로 떠 있었다. 실제 실행 기록은 정반대다 — 접지가 이메일 → 이메일을 입력해 주세요.로 맞췄고, fill이 성공했고, 타이핑한 값 검증도 통과했다(field value has "invalid-email"). 즉 AI는 이미 보정에 성공했는데 화면은 실패했다고 말하고 있었다.
원인 ①: ground:에 사유가 없었다. summarizeHeal이 아는 종류는 precondition/goto/click/fill/repair/skip뿐이라, 접지 이벤트가 마지막 줄의 쓰레기통 self-heal: action으로 떨어졌다. 그 코드의 패널 카피가 "요소를 찾지 못해 건너뛰었다"이고, OP_LABEL.ko에 action 키가 없어 한국어 문장에 영어가 그대로 샜다. 게다가 이 가짜 사유가 진짜 사유(비전 불일치)를 밀어냈다 — reason은 heal이 있으면 무조건 heal이 이긴다.
→ label snapped 사유 신설 + 정직한 카피(동작은 수행됐고, 다만 그 이름으로 불릴 수 있는 게 둘 이상이라 확인이 필요하다) + action: "동작" 매핑 추가.
원인 ②: 후보가 하나뿐인 접지까지 사람을 부르고 있었다. normalizedOnly(공백만 다름)일 때만 heal을 생략했는데, 그 주석이 밝힌 생략의 근거는 "어느 요소를 뜻했는지에 대한 추측이기 때문" 이다. 후보가 하나면 추측이 아니다. unambiguous를 추가했다 — 화면에서 그 이름에 답할 수 있는 것이 정확히 하나이고, 그것이 타겟으로 시작할 때.
두 조건 다 필요하다. 둘이면 다시 선택이고(이메일 vs 이메일 인증번호), 이건 전에 엉뚱한 상자가 대답했던 바로 그 모양이다. 그리고 타겟이 라벨 안쪽에 박힌 건 그 이름이 아니라 다른 이름의 조각이다 — 생성은 신규 계정 생성 한 곳만 맞지만 케이스가 뜻한 생성 버튼은 이 화면에 없을 수 있다. 한국어 UI는 이름이 앞, 안내가 뒤(이메일 → 이메일을 입력해 주세요.)라서 접두 조건이 정확히 그 둘을 가른다.
측정. 20건 스모크: self-heal: action 2 → 0, vision disagrees 4 → 6 — 가짜 사유에 가려져 있던 진짜 이유가 드러난 것이다. 문제의 케이스는 heal: [] · 사유 vision disagrees로 바뀌었다(엔진 텍스트 검사는 못 찾았고 비전은 충족으로 봤다 = 사람이 볼 일). 98건 게이트: 일치 17 · false-pass 5 · false-fail 1 전부 불변.
앞 항목이 남긴 "준비 충실도" 과제. 남은 false-fail 2건을 라이브로 갈랐더니 하나는 엔진 결함, 하나는 시트 드리프트였다 — 같은 증상에 다른 원인이라 고치는 곳도 다르다.
① NO 206 — 데이터 사전조건이 UI 동작으로 번역되지 않는다. 사전조건이 "중복된 이름이 없는 경우"다. 이건 화면 상태가 아니라 데이터 상태라서, 작성된 준비는 "다이얼로그를 연다"까지만 하고 끝났다. 케이스는 빈 칸을 두고 기관명 중복확인을 눌렀고, 앱은 확인할 이름이 없으니 아무 말도 하지 않았고, 엔진은 그걸 앱의 결함으로 적었다. 라이브 프로브: 이름을 채우고 누르면 시트가 적은 문구가 그대로 나온다. 앱은 멀쩡했고 상태가 도달된 적이 없었다.
규칙: 필드 이름 + 동사로 된 컨트롤을 그 필드가 빈 채로 눌렀고 거기에 착지한 값이 하나도 없으면, 판정이 아니라 사전조건 미충족(untypedFieldInTarget). 좁게 잠갔다 — 타겟이 필드 이름보다 길어야 하고(칸 자체를 클릭하는 건 평범한 일이다), 필드가 비어 있어야 하고, 착지 기록이 없어야 한다(앱이 지운 칸은 타이핑이 닿은 것이고 그건 발견이지 준비 누락이 아니다). 가장 긴 필드 이름이 이긴다.
값을 대신 채워 넣지 않는다. 그게 더 쉽고 206을 통과로 만들지만, 시트가 준 적 없는 데이터를 검사가 대답하기 시작하는 문이다 — 이 저장소가 두 번 되돌린 방향이다. 엔진이 말할 수 있는 건 "그 상태에 도달한 적 없다"까지고, 고칠 사람은 리뷰어다. 리뷰 패널의 "precondition unmet" 카피가 이미 정확히 그 말을 한다.
② NO 216 — 엔진이 맞다. 시트가 낡았다. 앱이 그 칸에 실제로 그리는 안내는 영문 대문자, 숫자 4자 입력가능인데 시트는 영문 대문자 최대 2자 입력가능을 요구한다. 입력 시·blur·제출 어느 시점에도 시트의 문구는 없다. false-pass 4건의 "기획서와 상이"와 같은 부류, 방향만 반대다 — 사람은 pass, 엔진은 시트 리터럴을 정직하게 못 찾는다. 엔진 수정 없음.
대신 measure가 false-fail 행에 실패한 검사를 그대로 찍는다. 사람이 pass로 적은 행에는 비고(결함 사유)가 없으니, false-pass를 비고로 판결하던 것의 나머지 반쪽이 없었다. 이제 한 줄로 끝난다:
216 pass fail 0/1 false-fail · text lacks "영문 대문자 최대 2자 입력가능"
측정 (98건): false-fail 2 → 1, 보류 69 → 71, 일치 17 유지, false-pass 5 유지(전부 판결 부류). 새 규칙은 정확히 1건에서만 발동했다 — 겨냥한 그 케이스다.
범용화 2차. 1878ad4가 고객 이름·계정·호스트명을 걷어냈지만 업종 명사가 주석과 픽스처에 남아 있었다. 한 업종의 명사가 픽스처에 있으면 엔진이 한 제품용으로 읽히는데, 그 픽스처가 실증하는 규칙은 목록·편집기·토글에 대한 것이고 그 단어 없이 더 분명하다. rowNoun 근거 주석도 자기가 배제하는 두 단어를 더 이상 호명하지 않는다 — "한 업종만 쓰는 단어는 그 시트의 규칙으로"가 규칙인데, 그걸 호명하는 것 자체가 같은 종속이었다. 한국어는 남는다(설계 목표), 한 업종의 명사는 아니다.
2026-07-31 — 사전조건 스톰의 진범은 beforeunload · 죽은 클릭과 글리프 검증의 사후 처리 · 채택 (사전조건 미충족 66 → 22, false-fail 5 → 2)
1. 66건 스톰 — 로그인이 아니라 다이얼로그였다. (어제 "준비 재작성 주사위가 유력 용의자"로 적었던 것의 정정.)
이틀 연속 정확히 66건, 두 밤 다 같은 시트 위치(index 32~33)에서 시작 — 플레이크가 아니라 결정적이다. 첫 배선은 로그인이었다: 2연속 실패 → 영구 브레이커 개방 → 남은 전 케이스가 로그아웃 상태로 실행 → 모든 goto가 ERR_ABORTED. 그래서 브레이커를 half-open으로 바꿨는데(§2), 재측정에서 재시도 창이 여는 로그인의 goto마저 ERR_ABORTED였다. 로그인 실패는 결과지 원인이 아니었다.
진범: 어댑터의 다이얼로그 핸들러가 모든 네이티브 팝업을 dismiss했다. beforeunload를 dismiss하는 것은 "페이지에 머무르기"다 — 그 확인창을 띄운 네비게이션 자체가 취소되고 page.goto는 net::ERR_ABORTED를 던지며, 다음 goto에서 확인창이 다시 무장된다. 이 앱의 문서 편집기(/document/write)는 타이핑이 닿는 순간 beforeunload를 무장한다 — 라이브 재현: dismiss 핸들러로는 더러운 편집기 이후 모든 goto가 abort, accept로 바꾸면 즉시 해소. 케이스 하나가 더러운 편집기를 남기면 그 뒤 전부 — 사전조건 goto, 로그인 재시도, resetSession의 goto — 가 같은 확인창에 막혀 죽는다. 시트 실행 순서가 같으니 매일 밤 같은 자리였고, 새로 띄운 프로브는 beforeunload가 무장된 적이 없으니 재현이 안 됐던 것이다.
수정: beforeunload는 accept(사용자가 하는 일 — 페이지를 떠나고 초안을 잃는다), 나머지는 dismiss(confirm의 dismiss는 파괴 동작의 거부라 안전한 답이다). 결정은 순수 함수 dialogAnswer로 빼서 회귀 테스트가 지킨다.
2. 로그인 브레이커는 pause, 거부만 영구. 스톰의 원인은 아니었지만 독립적으로 옳아서 유지한다 — 일시 장애 2회가 배치의 남은 전부를 로그아웃으로 굴리는 구조는 그대로 참이었다. failedSignIns(일시·무증상 실패)는 예산 소진 후 몇 케이스 쉬었다가 half-open으로 한 번 다시 시도하고, 성공이 카운터를 닫는다. credentialRefused(앱이 화면으로 거절)만 배치 영구다 — 거절된 자격증명 재제출은 계정 잠금만 산다. settle도 6s → 15s: 오늘 밤 느린 로그인은 앱에 대한 발견이 아니다.
3. 스톰이 걷히자 false-fail 5건이 드러났다 — 두 부류는 코드로, 한 부류는 다음 작업으로.
- 비활성 컨트롤 강제 클릭 (NO 15·21).
clickByText폴백이 disabled 버튼에 DOM 클릭을 보내고 성공을 보고했다 — 앱은 본 적 없는 성공. 로그인 폼은 두 칸이 다 차야 버튼이 활성인데 준비 플랜이 아이디만 채웠고, 강제 클릭이 "성공"한 뒤 아무 일도 없는 화면에 팝업 문구를 물었다. disabled/aria-disabled스킵 → 액션이 원래의 heal로 실패하고 케이스는 리뷰로 간다. - 글리프 검증 (NO 161). "<<, <, 페이지번호, >, >> 버튼이 제공되어야 한다"의 화살표들을 앱은 텍스트 없는 아이콘(
<i class="icon">)으로 그린다 — 사람은 pass, 엔진은 글리프 4건으로 fail. 단어 문자(글자·숫자·한글)가 하나도 없는 textIncludes의 실패는 앱에 대한 증거가 아니다: 글리프 실패만으로 실린 fail은 needs_review로(isIconographic). 한 방향만 — 통과하는 글리프 검증은 그대로 세고, 실단어 검증이 하나라도 실패하면 fail은 선다. - 준비 충실도 (NO 206·216, 그리고 15·21의 근본) — 다음 작업. "중복된 기관명 미존재하는 경우"가
goto /agency + click 신규 기관 생성으로만 계획되어 기관명이 빈 채 중복확인을 눌렀다(라이브: 기관명을 채우면 "사용할 수 있는 기관명입니다."가 그대로 나온다). "~입력된 상태"류 사전조건이 이름 그대로의 필드 하나만 채우는 플랜으로 작성된다 — 서술된 상태를 동작 가능하게 만드는 보완 입력(비밀번호, 기관명)이 빠진다.
측정 (98건, 같은 캐시, 새-런 가드 통과):
| 스톰 런 | beforeunload 수정 후 | + 비활성·글리프 수정 후 | |
|---|---|---|---|
| 사람 라벨 일치 | 8 | 17 | 17 |
| false-pass | 4 | 5 | 5 — 전부 판결 부류 |
| false-fail | 1 | 5 | 2 (206·216만) |
| 사전조건 미충족 | 66 | 19 | 22 |
false-pass 5는 늘어난 게 아니라 드러난 것이다: 4건은 비고 "기획서와 상이"(문서화된 부류), 새 1건(NO 6)은 스톰에 가려 있던 케이스가 돌기 시작한 것 — 라이브 확인 결과 로그인 아이디에 maxlength=12가 실제로 있고 13자를 12자로 자른다. 앱이 그 사이 고쳐졌고 시트가 낡았다. 엔진 수정 없음, measure의 비고 출력으로 한 줄 판결.
어제 남긴 두 판단의 처리.
판단 ① 인용줄 도출 유지 (NO 73·169). 노트가 이미 두 번 판결한 부류다 — 2026-07-28 "NO 169: 시트의 기대 목록과 화면이 일치, 사람 Fail은 기획서 대비 판정", 2026-07-29 "남은 false-pass 3건 중 3건이 기획서와 상이 — 시트 기준 false-pass는 0". 이번 4건(72·168·73·169)의 비고가 전부 "기획서랑/기획과 상이"로, 정확히 같은 부류다. 스코어카드 수치는 손대지 않는다 — 자동 재분류는 세탁의 방향이다. 대신 measure가 불일치 행에 시트의 비고(사람이 적은 결함 사유)를 그대로 출력한다. false-pass 줄을 보는 순간 "엔진이 시트를 판정했고 사람은 기획서를 판정했다"가 한 줄로 판결된다. 게이트(exit 1)는 유지 — 모든 false-pass는 매번 사람 눈을 요구하는 게 맞다.
판단 ② NO 114 근본 수정 — 착지 증거. 필드 검사의 전제는 "내가 타이핑한 값이 이 상자에 남았거나 거부됐다"인데, 이름 조회는 그 전제를 운반하지 못한다. 이제:
Page.fill이 실제로 쓴 요소의 스냅샷 키를 돌려준다(string | void— 보고 못 하는 구현은 기존 그대로).BrowserPage는 채운 요소를 태깅하고readForm이 스냅샷과 같은 규칙으로 이름 붙인다. 세 fill 경로 모두.readForm의writable.key도 dedupe된 최종 키로 통일 — 쓰는 쪽과 읽는 쪽이 같은 라벨 두 상자를 두고 다른 말을 할 수 없게.- 러너가 fill마다
landings[target] = 착지 키를 기록하고 모든 판정 호출에 넘긴다. fieldAtMost/fieldExcludes/fieldHolds는 landings가 있으면 착지한 상자만 읽는다. 착지 기록이 없으면no typed value ever landed in field로 실패 — 같은 이름의 빈 상자가 대신 대답하는 경로가 원천 차단된다.
NO 149(앱이 걸러서 빈칸 → 정당한 통과)와 NO 114(타이핑이 딴 데 감 → 거짓 통과)를 가르는 유일한 사실이 착지고, 이제 판정이 그 사실을 안다. 회귀 테스트 둘: 착지가 다른 키를 보고하면 빈 동명 상자 대신 그 키의 값으로 판정(114 모양 → fail), fill이 실패하면 검사도 닫힌 채 실패.
측정.
- 18건: agree 7 · false-pass 0 · false-fail 0 — 착지 변경이 149·151·223(필드 검증 통과)을 하나도 깨지 않았다. 145의 플레이키 클릭도 이번엔 안 났다.
- 98건: false-pass 5 → 4 (114가 agree로 이동), false-fail 0. 남은 4건은 전부 비고 "기획서 상이" — 시트 기준 false-pass 0.
남은 것 (다음 작업, 이번 범위 아님): 98건 중 66건 사전조건 미충족이 이틀 연속 같은 숫자다 — 앱 플레이크가 아니라 체계적일 가능성이 높다. 시점상 준비 플랜이 a6로 재작성된 직후부터라, 준비 재작성 주사위(파생-읽기 전환이 assertion만 덮고 준비 액션은 여전히 캐시에 굽는다)가 유력 용의자다. 스케일 신뢰성의 선행 조건.
18건 시트에서만 재던 것을 100건으로 올려 보고 두 가지가 드러났다. 하나는 측정 도구의 결함, 하나는 판정의 잠복 구멍이다.
1. measure가 죽은 런 대신 어제 런을 채점했다.
98건 배치가 첫 로그인 타임아웃으로 중단되면 아무것도 기록되지 않는다. measure-sheet.ts는 실행 후 history[0]을 채점하는데, 그게 21시간 전 런이었다. 숫자가 그럴듯해서(그 시트의 기록된 기준선과 정확히 일치) 나는 그걸 "이번 세션 코드의 스케일 검증"으로 보고했다 — 틀린 보고였다. 옛 서버가 포트를 물고 있어 옛 코드로 측정했던 사고의 형제 격이다.
실행 전 시각을 잡아 두고 채점 대상이 그보다 새 런인지 확인한다. 아니면 런 에러와 함께 크게 실패한다(--score-only는 의도적으로 옛 런을 볼 때만). 이 가드가 붙자마자 진짜 숫자가 나왔다.
2. 진짜 숫자: false-pass 3 → 5.
| 런 | false-pass |
|---|---|
| 어제 코드 (4런, 안정) | 3 — 72·168·56, 전부 textIncludes |
| 이번 세션 코드 | 6 → 판정층 노운 제거 되돌린 뒤 5 |
늘어난 것의 성격이 갈린다:
- NO 260 (
fieldExcludes 기관명) — 판정층lookupLabelled에 넣었던 노운 제거가 원인. 되돌렸다. 클릭·fill 후보에서는 넓힌 후보가 틀리면 동작이 실패하지만(heal로 보인다), 판정에서 틀리면 판정이 나온다. 같은 헬퍼라도 층에 따라 값이 다르다. NO 222의 false-fail은 그 대가로 받는다 — 더 싼 쪽이다. - NO 114 (
fieldExcludes 아이디) — 되돌린 뒤에도 남는다. 라이브로 확인: 이 앱은한글ABC!@#를 그대로 받는다(fields["아이디"] = "한글ABC!@#"). 즉 사람의 "입력 제한이 동작하지 않는 현상"이 맞고 엔진의 통과가 틀렸다. - NO 73·169 (
textIncludes) — 오늘 넣은 인용줄 도출이 만들었다. 사람 Fail 사유는 둘 다 "필터명이 기획서와 상이" — 엔진이 본 적 없는 문서 대비 판정이고, 시트가 적어 둔 여섯 항목은 화면에 실제로 다 있다. 시트 기준으로는 엔진이 맞는 쪽(2026-07-29의 NO 169와 같은 분류)이지만, 스코어카드는 이것도 false-pass로 센다.
3. 미해결: 빈 필드는 타이핑이 닿았을 때만 증거다.
NO 114의 메커니즘은 필드 검사 전체에 걸린 구멍이다. fieldExcludes/fieldAtMost는 빈 필드를 통과시키고, 그 근거는 "앱이 지웠다"였다. 그건 그 상자에 실제로 타이핑이 들어갔을 때만 참이다. 이번 세션이 필드 이름 해석을 고치면서 조회 가능한 필드가 늘었고, 그만큼 "같은 이름의 다른 빈 상자가 대신 대답하는" 경로가 실제로 밟혔다.
fill이 어느 요소에 썼는지는 이제 런타임에 알 수 있다(bestFieldMatch가 인덱스로 태깅한다). 그 사실을 판정까지 들고 가야 한다 — 검사는 "이 필드가 비었다"가 아니라 "내가 타이핑한 그 필드가 비었다"를 물어야 한다. NO 149(연락처에 abc → 앱이 걸러 빈칸 → 정당한 통과)와 NO 114를 가르는 건 그 한 가지뿐이라, "빈 필드는 무조건 실패"로 바꾸는 건 답이 아니다.
4. 이 시트는 오늘 밤 측정 품질이 나쁘다. 98건 중 66건이 사전조건 미충족으로 보류였다(어제 런에는 없던 규모다). 로그인·세션 처리가 흔들리는 상태라 세밀한 판단에는 못 쓴다. 스케일 측정을 신뢰하려면 배치가 온전히 도는 것이 선행 조건이고, 그게 다음 작업이다.
파생을 읽는 시점으로 옮긴 직후의 첫 판정 변경이고, AUTHOR_VERSION 범프 없이 같은 플랜에 적용됐다 — 즉 이 표는 순수 A/B다.
검증 0건으로 남은 6건을 뜯으니 두 부류였다. - 이메일 형식, 최대 255자처럼 화면에 나올 문구가 기대문 바로 아래 적혀 있는데 모델이 안 쓴 것(141·143), 그리고 원리적으로 다른 것(139·140의 "팝업 종료", 112·221의 행 선택 반영). 앞의 것은 코드가 읽으면 된다.
이 시트 652건 중 278건(43%) 이 그런 인용 줄을 갖고 있다. 모델이 그 줄을 집을지는 주사위다 — 141·143은 a5에서 textIncludes "이메일 형식, 최대 255자"로 통과했고 a6에서 검증 0건이 됐다.
좁게 잡았다. 오인용은 곧 false-fail이므로:
- 모델이 텍스트 검증을 하나도 안 썼을 때만. 빈칸을 메울 뿐, 모델이 실제로 한 선택과 경쟁하지 않는다(검사를 하나 더 붙이면 통과율만 내려간다).
- 인용 표식으로 열린 줄만.
*는 표식이 아니다 — 이 시트에서 그건 요구사항 에 대한 주석("* 기본값 : 전체", "* 기관 생성 시 작성한 유형값 반영")이고 화면에 없다. 표식은 어휘(quotedLine)라 시트가 가르칠 수 있다. 라벨 : 값주석과 산문은 제외. 둘 다 앱을 인용하는 게 아니라 요구사항을 설명한다. 요구사항 문장을 그대로 검증으로 만드는 건 2026-07-27부터 거부해 온 false-pass다.- 열거는 모델 작성분과 같은 규칙으로 쪼갠다. 한 줄의 이름 넷은 화면이 보여야 할 것 넷이고, 콤마로 이어붙인 문자열은 어느 페이지에도 없다.
| 앞 | 이번 | |
|---|---|---|
| 사람 라벨 일치 | 5 | 7 (141·143 복귀) |
| false-pass | 0 | 0 |
| false-fail | 1 | 1 (145, E1) |
| 보류 | 12 | 10 |
두 게이트는 면제하지 않는다 — 인용한 문구가 동작 전에도 화면에 있었으면 무의미 판정으로 잡히고(141·143은 검증 메시지라 실제로 바뀐다), 인용값이 곧 요구사항에서 온 리터럴이라 귀속은 자연히 충족된다.
한계 하나는 그대로 남는다: 영어 픽스처 EN-1은 기대문이 1. Welcome text must show. / - Accounts인데 모델이 산문 쪽("Welcome")을 인용했다. 모델이 이미 골랐으므로 이 도출은 개입하지 않고, 잘못 고른 인용을 코드가 덮어쓰는 건 다른 문제다(덮어쓰면 모델이 옳았던 경우를 잃는다).
"지금 이건 이 앱 테스트를 짜는 건가, 엔진을 고치는 건가"에 코드로 답한 감사. 이 세션에서 넣은 것 전부를 놓고 앱 결합을 찾았다.
- 앱 고유 식별자(
form-item·modal-dim·agencyGroupSearch·소속그룹등)가 코드에 0건. 전부 주석 안에 근거로만 있다 — 이 저장소가 원래 쓰는 방식이고, 셀렉터로는 한 글자도 안 들어갔다. - 규칙 밖에 살던 어휘 2개 발견.
withoutUiNoun이 시트 규칙이 아니라DEFAULT_PHRASES를 직접 읽고 있었고,dismissOverlays는 닫기 라벨 6개를 코드에 배열 리터럴로 들고 있었다. 판정 어휘를 규칙으로 옮긴 결정(같은 날 아래 항목)이 어댑터까지는 안 왔던 것이다.
BrowserPageOptions.phrases를 추가해 시트의 규칙이 어댑터까지 따라간다. overlayCloser가 새 PhraseKind가 되고, withoutUiNoun(label, {phrases})가 규칙 어휘를 받는다. 기본값은 예전 리터럴과 정확히 같아서 행동은 안 바뀐다(측정 확인: 일치 5 · false-pass 0 · 보류 12, 직전과 동일).
남은 한 곳은 알고 남긴다: evaluateAssertion은 규칙을 받지 않으므로 필드 조회의 노운 제거는 기본 어휘를 쓴다. 판정 함수 시그니처에 규칙을 흘리는 건 별개 작업이고, 지금은 기본값과 시트 어휘가 다를 때만 어긋난다.
바로 위 항목이 남긴 부채. 지금까지 authorPlanAI가 모델 출력에 파생 검증을 붙여서 캐시에 저장했다. 파생 검증은 (케이스, 규칙, 액션)의 순수 함수인데도 작성 시점에 굳어 버리니, 이미 돌린 시트에 새 검증을 주는 유일한 길이 AUTHOR_VERSION 범프 — 즉 모든 플랜을 모델로 다시 굴리는 것이었다.
이번 세션에 세 번 올렸고 세 번 다 이렇게 됐다: NO 141·143은 a5에서 textIncludes "이메일 형식"을 갖고 통과했고 a6에서는 assertion 0건이 되어 보류로 내려갔다. 같은 케이스, 같은 프롬프트, 같은 핀된 모델. 변경의 효과와 주사위가 한 런에 섞여 도착하니 지표를 읽을 수가 없다.
- 캐시는 모델이 쓴 것만 담는다.
authorPlanAI는 이제 sanitize된 모델 출력만 돌려준다. withDerivedAssertions(tc, rule, plan)가 읽는 쪽에서 네 가지를 얹는다(경로·입력 제한·선택 상태·반영).getOrAuthorPlan이 캐시 히트든 새 작성이든 똑같이 통과시킨다.- dedupe로 멱등하게. 이 변경 전에 캐시된 플랜은 이미 파생 검증을 품고 있으므로, 다시 얹을 때 두 배가 되면 안 된다. 구조화된 종류는 자기 형태로 키잉하니 정확히 하나로 접힌다.
- 회귀 방지 테스트 둘: 캐시된 플랜이 작성된 적 없는 파생 검증을 갖고 나오는지(모델을 부르면 throw하는 클라이언트로 캐시 히트를 고정), 그리고 이미 파생 검증을 품은 플랜을 다시 읽어도 중복되지 않는지.
측정: 일치 5 · false-pass 0 · false-fail 1 · 보류 12 — 직전 런과 완전히 동일. 그게 맞는 결과다. 같은 캐시에 같은 도출을 적용하면 같은 검증 집합이 나와야 하고, 실제로 그랬다(151·223의 fieldHolds, 152의 controlSelected, 222의 fieldAtMost 모두 그대로). 얻은 것은 지표가 아니라 다음 판정 변경을 재작성 없이 잴 수 있다는 것이다.
앞 항목에서 다섯 건이 실행되기 시작하자, 이제 검증 쪽이 병목으로 드러났다. NO 223은 값을 타이핑했고 필드가 그 값을 들고 있었고 검사도 통과했는데 보류였다 — 기대문이 "1. 해당란에 반영되어야 한다."라서 인용할 리터럴이 기대문에 없다. 귀속 게이트는 정확히 그런 검사를 잡으라고 있는 것이고, 이 요구사항은 원리적으로 그 게이트를 통과할 수 없다.
1. fieldHolds{field, value}. 기대문이 "반영"을 주장하고 플랜이 실제로 무언가를 타이핑했을 때만 도출한다. 값은 산문 추측이 아니라 플랜 자신의 fill 에서 온다. fieldAtMost·fieldExcludes·controlSelected와 같은 계열이고, 같은 건전성 규칙을 진다 — 필드를 못 찾으면 실패.
- 비교는 구두점 무시로 한다. 앱이
01012345678을010-1234-5678로 되돌려주면 그건 반영한 것이지 거부한 것이 아니다. 빈 칸·잘린 값·다른 값은 그대로 실패한다(실측: 이 앱은 포맷팅을 안 하고 13자에서 자른다). - 귀속 게이트만 면제한다. 무의미 판정 게이트는 그대로 받는다 — 타이핑 전엔 빈 칸이었으므로 정상 동작하면 화면이 실제로 바뀌고, 게이트를 자연스럽게 통과한다. 면제 근거는 요구사항이 대용어라는 것이다: "해당란"은 스텝이 지목한 상자이고, 그래서 인용할 것이 기대문에 없다.
ValueAssertion을 형태({value: string})가 아니라 이름으로 정의하도록 바꿨다.fieldHolds가value를 갖는다는 이유만으로 모델이 작성할 수 있는 종류가 되면 안 된다. 같은 이유로 dedupe 키도 종류별로 분리 —value만으로 키잉하면 서로 다른 상자에 대한 두 검사가 하나로 뭉개진다(테스트로 고정).
2. 필드 이름을 푸는 곳을 하나로. 도입 직후 NO 222가 false-fail로 떴다. 원인은 새 것이 아니라 같은 불일치의 세 번째 발현이었다: 재작성된 플랜이 fill "소속 그룹 입력란"이었고, fill은 노운을 떼어 붙었는데 파생된 검사는 떼지 않은 문자열로 조회해 not on screen → 앱이 멀쩡히 처리한 케이스가 실패.
withoutUiNoun을 어댑터에서 규칙 모듈로 옮겨(자기가 읽는 어휘 옆) 세 층이 같은 답을 쓰게 했다: 클릭 후보, fill 후보, 그리고 검사의 필드 조회. 조회는 여전히 정확히 푼다 — 이름 그대로 → 공백/구두점 무시 → 노운 제거 후 다시 정확히. 스템 매칭이 아니다. 되돌렸던 false-pass 벡터(플레이스홀더 이름만 있는 빈 상자가 대답하는 것)는 그대로 닫혀 있고, 그걸 테스트로 못 박았다 — 그 상자만 있는 화면에서 아이디 입력란은 여전히 not on screen이다.
측정 (sh_anyrow 18건, luna high)
| 앞 항목 | fieldHolds | +이름 통일 | |
|---|---|---|---|
| 사람 라벨 일치 | 5 | 5 | 5 |
| false-pass | 0 | 0 | 0 |
| false-fail | 1 | 2 ← 222 | 1 |
| 보류 | 12 | 11 | 12 |
| 런 시간 | 147s | 214s | 144s |
일치 숫자 뒤에서 실제로 일어난 일: 151·223이 fieldHolds로 통과(둘 다 필드 값을 읽어서 얻은 통과)했고, 141·143이 통과에서 보류로 내려갔다 — 후자는 코드가 아니라 재작성 주사위다. a5 플랜은 textIncludes "이메일 형식"을 갖고 있었고 a6 플랜은 assertion이 0건이다. 같은 케이스, 같은 프롬프트, 같은 핀된 모델.
그래서 이 세션의 방법론 부채 하나가 드러났고, 바로 갚았다: 새 검증 종류를 넣으려면 AUTHOR_VERSION을 올려야 했고, 그러면 모든 캐시된 플랜이 다시 굴러간다. 이번 세션에 세 번 올렸고 매번 지표가 ±2건 흔들렸다(영어 픽스처 EN-1도 같은 이유로 리터럴 대신 산문을 인용하게 됐다). 즉 변경과 주사위가 늘 함께 도착해서 측정이 둘을 구분할 수 없었다. 아래 항목에서 파생 검증을 읽는 시점으로 옮겼다.
남은 보류 12건을 다시 원인별로 묶었다. 소속 그룹 필터 클러스터 5건이 전부 첫 동작에서 중단되고 있었고, 파 보니 서로 다른 결함 넷이 겹쳐 있었다.
1. withoutUiNoun이 아무 데서도 호출되지 않았다.
주석은 "여섯 개의 fill이 바로 거기 있는 필드에서 실패했다 … 정확한 후보들보다 나중 후보로만 쓴다"고 자세히 선언하고, 단위 테스트도 있고, src/ 어디에서도 호출되지 않는다. routes를 파싱에서 흘렸을 때와 같은 구조다 — 헬퍼는 있고 테스트는 통과하고 실행은 안 된다. 클릭(전체 후보가 0건일 때)과 fill(bestFieldMatch가 빈손일 때)에 선언대로 후순위로 배선했다. 필터/filter를 uiNoun에 추가 — 시트는 영역을 컨트롤처럼 부른다("소속 그룹 필터 선택")고, 앱은 <label>소속그룹</label>과 그 아래 목록만 그린다. 실측: click "소속 그룹 필터" 4초 타임아웃 → 56ms 성공.
2. 오버레이 정리가 자기가 재시도할 화면을 부수고 있었다.
dismissOverlays()의 스윕 규칙("크고 fixed이고 z가 높고 화면 중앙을 덮는 레이어")은 다이얼로그의 백드롭 div.modal-dim을 정확히 기술한다. 실측: 스윕 전 "계정 수정" 보임 → 스윕 후 사라짐. 즉 팝업 안에서 실패한 모든 클릭이 회복 사다리 ①단계에서 팝업째 날아갔고, 그 뒤의 재시도는 구조적으로 실패할 수밖에 없었다.
이제 스윕은 무엇을 찾고 있었는지를 받는다. 타겟이 페이지 어디에도 없으면 걷어낼 게 없으니 스윕하지 않고, 타겟이 그 레이어 안에 있으면 그건 앞을 가로막은 게 아니라 케이스가 일하고 있는 화면이므로 건드리지 않는다.
3. clickRow가 모달 뒤 사이드바를 집었다.
기관 생성 팝업의 그룹 목록은 ul > li인데, 뒤에 깔린 사이드바 나브도 ul > li이고 문서 순서상 먼저다. "임의 항목 선택"은 아무도 누를 수 없는 메뉴 항목에 4초 예산을 다 쓰고 죽었다. 다이얼로그가 떠 있으면 맨 앞 레이어 안에서 먼저 찾는다(오버레이 스윕과 같은 구조적 신호로 레이어를 표시). 스코프가 빈손이면 전체 문서로 폴백하므로 다이얼로그 없는 페이지의 동작은 한 글자도 안 바뀐다. 실측: 4010ms 타임아웃 → 39ms 성공.
4. 행 라벨은 직계 자식만, 그리고 컨트롤보다 앞에 있는 것만.
앞 항목에서 넣은 행 라벨이 소속그룹 검색창을 "전체" 라고 불렀다. DOM을 보면 답이 있다:
div.form-item > label "소속그룹" ← 직계 자식
div.form-input-wrap
(nested) label "전체" ← 중첩된 다른 컴포넌트의 것
input[name=agencyGroupSearch]
ul > li × 30 (체크박스 라벨)
그래서 세 조건 전부를 건다: 직계 자식(중첩된 라벨은 그 컴포넌트의 것), bare(for도 없고 컨트롤을 품지도 않은 것 — 남의 이름), 컨트롤보다 앞(아래에 있는 건 뒤따르는 것의 제목이지 앞의 것 이름이 아니다). 실측: fill "소속 그룹" 타임아웃 → 성공, 스냅샷 키가 소속그룹으로 잡혀 검증이 값을 읽는다.
측정 (sh_anyrow 18건, luna high)
| 앞 항목 결과 | 이번 | |
|---|---|---|
| 사람 라벨 일치 | 5 | 5 |
| false-pass | 0 | 0 |
| 보류 | 12 | 12 |
| 첫 동작에서 중단된 케이스 | 5 | 0 |
| 케이스 간 화면 리셋 | 6회 | 1회 |
| AI 교정 호출 | 5 | 3 |
| 런 시간 | 195s | 147s |
일치 숫자는 안 움직였고, 그게 정확한 그림이다. 다섯 건은 이제 실행된다 — 예전엔 첫 클릭에서 죽어 나머지 3~4개 동작이 통째로 버려졌다. 지금은 끝까지 돌고 검증에서 보류된다(222는 2/2 통과 후 무의미 판정, 223은 1/1 통과 후 귀속 커버리지). 중단된 케이스는 무엇이 부족한지조차 말해 주지 않지만, 실행된 케이스는 말해 준다.
다음 후보(측정된 것만). 223은 기대가 "해당란에 반영되어야 한다"이고 검증은 통과하는데, 인용할 리터럴이 기대문이 아니라 스텝에 있어 귀속 게이트에 걸린다 — fieldAtMost·fieldExcludes·controlSelected가 면제를 얻은 것과 정확히 같은 모양이라 fieldHolds{field, value}(플랜이 타이핑한 값을 필드가 실제로 들고 있는가)가 같은 근거로 성립한다. 139는 click "X" — 한 글자 타겟은 flexTextRe·clickByText가 둘 다 2자 미만을 거부해 구조적으로 도달 불가다(닫기 컨트롤을 구조로 찾는 문제는 scripts/probe-modal-close.mjs에 이미 열려 있다).
앞 항목 뒤에 남은 보류 14건을 기계적 원인으로 묶으니 6건이 하나였다. 계정 수정 팝업의 DOM:
input[name=contact]
div.form-input-wrap labels=[] ← fillByProximity가 보던 곳(el.closest("div"))
div.form-item labels=[연락처] ← 라벨은 한 층 위
div.form-group-modal labels=[소속 기관|기관 유형|아이디|이메일|비밀번호|연락처|상태|…] ← 더 올라가면 엉뚱한 게 대답한다
그래서 두 가지가 동시에 깨져 있었다. 연락처는 라벨·플레이스홀더('-' 를 제외한 번호를 입력해 주세요.)·name=contact 어디와도 한 글자도 안 겹쳐서 fill이 4초 타임아웃(149·150·151), 이메일은 플레이스홀더에 우연히 포함돼 fill은 됐지만 스냅샷 키가 플레이스홀더라 검증이 field "이메일" not on screen(142·222).
- fill 타겟과 필드 검증이 같은 읽기를 쓴다.
readForm하나가fields·controls·limits·writable(각 필드가 응답하는 모든 별칭)을 한 번의 evaluate로 낸다.fillByProximity는 그걸 받아 순수 함수bestFieldMatch로 고르고data-osteron-field인덱스로 채운다 — 즉 쓴 필드와 나중에 조회하는 필드가 구성상 동일하다. 예전엔 두 규칙이 따로 있었고 이 앱에서 서로 어긋났다. - 행 라벨은 "이 컨트롤 하나만 가진 조상"까지만 올라간다. 한 층 얕으면 라벨이 없고, 한 층 더 가면 다이얼로그의 라벨 9개가 전부 후보가 된다. 두 번 되돌렸던 그 오답이다. 두 번째 입력 가능 컨트롤이 보이는 순간 멈추는 게 이름을 유일하게 만든다.
- 매칭 규칙 자체는 브라우저 안에서 밖으로 꺼내기만 했다(포함 관계 + 가장 짧은 이름 승리). 느슨해진 건 없고, 대신 이제 단위 테스트가 있다 — 엉뚱한 상자가 대답하는 지점이 정확히 여기다.
그리고 그게 숨어 있던 false-pass를 드러냈다.
필드가 조회되기 시작하자 NO 142가 field "이메일" holds 255 ≤ 255 chars → pass. 사람이 남긴 결함은 "계정 수정 팝업 내 이메일 입력란 255자 초과 입력되는 현상". 실측하니 input[name=email]에 maxlength="255" 가 걸려 있다(연락처는 maxlength="13"). 즉 플랜이 260자를 쳐도 박스가 255에서 자르므로 이 검사는 실행 전에 답이 정해져 있다.
fieldAtMost/fieldExcludes의 두 게이트 면제는 근거가 "값을 읽는 것이 곧 증거"였다. 값이 달라질 수 없었으면 그 전제가 깨진다. 컨트롤이 선언한maxlength가 주장된 한도 이하일 때 면제를 거둔다 → 무의미 판정 게이트가 정상 작동해 보류.fail이 아니라 보류인 이유: 선언된 maxlength는 앱이 실제로 거는 제한이기도 하다. 앱을 탓할 근거는 없고, 우리가 증명하지 못했을 뿐이다. NO 150(연락처 13자, 사람 Pass)도 같은 이유로 보류 — 못 얻은 통과를 주지 않는다.- 검사 결과 자체(
passed: true)는 그대로 둔다. 결정적 결과를 뒤집는 게 아니라 판정만 보류한다 — 비전이 판정을 못 뒤집게 한 것과 같은 원칙. fieldExcludes는 면제가 유지된다: NO 149의 연락처는 앱이 JS로 글자를 걸러내므로 브라우저가 보장해 준 게 없다. 실제로 이번에 통과(일치) 했다.
측정 (sh_anyrow 18건, luna high)
| 앞 항목 결과 | 필드 이름 통일 | +브라우저 보장 판별 | |
|---|---|---|---|
| 사람 라벨 일치 | 4 | 4 | 5 |
| false-pass | 0 | 1 ← 142 | 0 |
| false-fail | 0 | 0 | 1 (145, 아래) |
| 보류 | 14 | 13 | 12 |
중간 열이 정확히 "지표는 좋아졌는데 false-pass가 올랐다"의 실례다. 되돌리는 대신 드러난 불건전성을 고쳤다 — 되돌렸다면 142는 field not on screen이라는 틀린 이유로 맞는 답을 내는 상태로 남았다.
남은 false-fail 1건은 NO 145 — 세션 내내 흔들리던 그 케이스다. 중복확인 클릭이 붙는 런에서만 드러나고, 드러나면 사전조건 "중복값 미존재하는 경우" 를 엔진이 만들지 못한다는 사실이 보인다(기존 이메일 그대로 중복확인을 누르니 앱은 정확히 "이미 생성된 이메일입니다"라고 답한다). 클릭 실패가 그걸 가려 왔을 뿐이고, 다음 작업은 이것이다 — 사전조건이 서술하는 데이터 상태를 준비 단계가 만들 수 있어야 한다.
sh_anyrow의 NO 152·153("상태 항목 내 활성/비활성 라디오 버튼 선택")을 붙잡고 세 층을 팠다. 세 층 다 각각만으로는 지표가 1도 안 움직인다 — 그게 이 항목의 핵심이다.
1. 롤 후보에 radio를 더하는 것만으로는 아무것도 안 고쳐진다.
시트가 "라디오 버튼"이라 부르는 것의 실제 DOM은 이랬다:
<input id="radio-1" type="radio" name="status" value="false" class="radio-box_btn"> ← opacity:0, 13×13
<label class="radio-box_name" for="radio-1"><span class="r_circle"></span> 비활성</label> ← 60×36, 보이는 것
접근성 이름은 input이 갖는다. 그래서 getByLabel('비활성')은 이미 맞히고 있었고(실측 로그: locator resolved to <input id="radio-1" …>), Playwright가 보이지 않는 요소를 못 눌러서 4초 뒤 타임아웃했다. getByRole("radio")를 더해도 같은 input이 나온다 — 라이브로 확인: getByRole(radio) count=1, click() 타임아웃, check()도 타임아웃, label[for="radio-1"] 클릭만 상태를 뒤집었다.
- 이름이 정확히 일치하는 컨트롤의
<label>만 누른다(clickControlLabel). 텍스트 검색도, 근처 요소도 아니다 — 이름을 못 대는 라디오는 추측해야 하는 라디오고, 추측한 클릭은 엉뚱한 대상이 대답하는 그 방향이다. 라벨이 없으면 그냥 실패한다. - 오버레이 정리보다 먼저 시도한다.
dismissOverlays()는 "닫기"를 누르고 큰 고정 레이어를 숨기는데, 이 앱에서 그건 라디오가 들어 있는 그 다이얼로그다. 순서를 반대로 놓으면 구제 경로가 자기 대상을 없앤다. - 롤 후보의
radio는 남긴다:aria-label만 있거나role="radio"인(그래서 실제로 눌리는) 컨트롤은 이게 유일한 후보다.
라이브 증명(scripts/probe-radio-control.ts, 출고된 어댑터를 그대로 구동): 각 대상을 반대 상태에서 눌러 snapshot().controls가 뒤집히는지 본다 — 활성이 앱의 기본값이라 "누른 뒤 선택돼 있다"는 안 눌러도 참이기 때문이다. 양방향 모두 OK.
2. 그런데 클릭이 붙어도 판정은 그대로 보류였다. 검증이 0건이니까.
"1. 활성 라디오 버튼 선택되어야 한다."는 산문이고, 라디오의 상태는 페이지 텍스트에 없고 value에도 없다(value="false"는 선택 여부와 무관한 고정 속성이다). 즉 이 클래스는 구조적으로 무판정이었다. fieldAtMost/fieldExcludes 때와 같은 처방을 같은 이유로 한다 — 컨트롤 자신에게 묻는다.
PageSnapshot.controls(라벨 → 체크 여부).fields와 분리한다: 토글의value를 값으로 읽는 건 아무것도 선택되지 않은 화면에서도 만족되는 검사를 만드는 길이다. 스냅샷 한 번에 두 맵을 같이 읽는다(왕복 1회, 같은 순간).controlSelected { control }. 못 찾으면 실패 — 클릭이 컨트롤에 닿지 못한 화면에서 통과를 주는 게 정확히 되돌렸던 그 구멍이다.- 도출은 좁다(
deriveSelectionAssertions): 기대문이 라디오/체크박스를 지목하고(그냥 "선택되어야 한다"는 이 시트에 널렸고 대부분 텍스트로 이미 보인다), 컨트롤은 **플랜 자신의click**에서 오고, 기대문이 그 이름을 단어 경계로 언급해야 한다 —활성은비활성의 부분문자열이라 부분일치를 허용하면 반대 결과에 초록불을 준다. 어휘(toggleNoun·selected)는 정규식 리터럴이 아니라 규칙에 얹었다. - 무의미 판정 게이트는 면제하지 않는다. 필드 검사와 달리 정상 동작하는 선택은 화면을 바꾼다. 그래서 NO 152(활성이 이미 켜진 채 열리는 화면)는
1/1을 통과하고도 "검증이 동작을 구분하지 못함"으로 보류된다 — 클릭이 닿았든 아니든 참이니까 그게 정직한 답이다. NO 153은 꺼짐→켜짐이라 구분한다. - 귀속 커버리지에는 포함시킨다. 컨트롤 이름은 기대문에서 인용한 리터럴이고(도출 조건이 그것이다), 그게 바로 이 게이트가 요구하는 것이다.
3. 플랜이 준비를 되풀이하면서 준비를 부숴 왔다.
AUTHOR_VERSION 범프로 18건이 재작성되자 NO 153의 플랜이 [goto /account, click 비활성]로 바뀌었다. 사전조건 "임의 계정 선택된 상태"는 행을 클릭해 수정 팝업을 연다 — 그리고 플랜의 첫 동작이 그 페이지를 다시 로드해서 팝업을 닫는다. 같은 런에서 같은 모양이 5건이었다(팝업 안 필드에 fill이 걸려 보류된 건들).
준비와 플랜은 같은 케이스에서 서로 독립된 두 번의 작성으로 나온다. 그래서 플랜은 준비를 자주 다시 쓴다. withoutRestatedSetup: 둘 다 같은 goto로 시작할 때만(플랜이 다른 데로 가는 건 그 케이스가 실제로 원하는 것이다) 동일한 선두 구간을 잘라낸다. 팝업을 다시 여는 재클릭도 같이 잘린다 — 이미 열린 걸 또 여는 것도 리로드만큼 확실히 실패한다. 전부 잘라내지는 않는다: 자기 동작이 0개인 케이스는 비교할 이전 화면이 없고, 그건 아무것도 실행하지 않고 통과할 수 있는 유일한 모양이다.
측정 (sh_anyrow 18건, luna high, 사람 검증 결과 대조)
| 기준선 | 라디오 라벨 클릭 | +선택 검증(재작성) | +준비 되풀이 제거 | |
|---|---|---|---|---|
| 사람 라벨 일치 | 1 | 1 | 2 | 4 |
| false-pass | 0 | 0 | 0 | 0 |
| false-fail | 0 | 1 | 1 | 0 |
| 보류 | 17 | 16 | 15 | 14 |
NO 153 = control "비활성" is selected → pass(일치). NO 152 = 통과하지만 구분 못 해서 보류(설계대로). 나머지 상승분(142·143·144)은 재작성 몫이다.
정직하게 남길 것 셋.
- NO 145는 내 변경 이전부터 흔들린다. 히스토리 5런에서
중복확인클릭이 1200ms 첫 시도에 붙기도 하고(144·145 통과) 안 붙기도 했다. 붙는 런에서는 145가fail로 드러나는데 그건 진짜 결함이 아니라 사전조건 "중복값 미존재"를 엔진이 만들지 못해서다(기존 이메일 그대로 중복확인을 누른다). 클릭 실패가 그걸 가려 왔을 뿐이다. 다음 작업 후보. AUTHOR_VERSION범프는 캐시된 플랜을 전부 다시 굴린다. 영어 픽스처 EN-1이 그 비용을 그대로 보여줬다 — 기대문1. Welcome text must show. / - Accounts에서 a4는 리터럴Accounts를 인용했고 a5 재작성은 산문의Welcome을 인용했다. 픽스처에 없는 단어라 false-fail. 코드 경로가 아니라 작성 주사위다(영어 시트 일치 2 → 1, false-pass 0 유지).- 이 세 변경은 순서대로만 의미가 있다. 클릭만 고치면 검증이 없어 보류, 검증만 더하면 클릭이 안 닿아 실패, 둘 다 있어도 플랜이 팝업을 닫으면 컨트롤이 화면에 없다.
1. 감사: 어디까지가 이 시트 전용이었나.
프로젝트/앱 식별자는 코드에 0건이고 판정 코어(baseline.ts, page.ts, 캐시 키, assertion 평가)에는 이 시트의 언어가 없다. 컬럼 이름은 이미 별칭 테이블에 있었다. 문제는 판정 어휘가 정규식 리터럴로 흩어져 있었고 동작 어휘(intents)만 규칙에 있었다는 것 — 시트가 "클릭"의 새 표현은 가르칠 수 있고 "제한되어야 한다"는 못 가르쳤다. 그중 둘은 영어가 아예 없었다((\d+)\s*자\s*(?:초과|이상), 문자 클래스).
InterpretationRule.phrases(prose·navigation·restriction·uiNoun·lengthUnit·exceed·anyRow·rowNoun)와 .charClasses로 옮겼다. 기본값은 대체한 리터럴과 정확히 동일해 기존 시트가 안 바뀐다. 설계 결정 셋:
- 길이 제한을 정규식으로 저장하지 않는다 — "단위 단어 옆의 숫자 + 초과 표현"으로 읽어 어순이 다른 언어도 되고, 디스크의 정규식은 ReDoS 표면이자 편집 불가능한 문자열이다
- 구절은 리터럴로만 매치(대소문자 무시). 디스크·모델에서 온 어휘가 정규식으로 동작하면 안 된다
- 빈 목록은 기본값으로 폴백 — 빈 어휘는 조용히 아무것도 매치하지 않고, 그건 판정에 절대 드러나지 않는다
같은 트랩이 또 놓여 있었다: 왕복 테스트가 기본값 규칙을 쓰니 새 필드를 흘려도 폴백으로 통과한다 — routes를 파싱에서 흘렸을 때 통과했던 것과 같은 구조. 기본값이 아닌 값으로 채워 탐지 가능하게 했다.
2. 행 선택 액션 — 라벨 없는 지시를 표현할 수 없었다.
임의 계정 선택이 652건 중 126건으로 압도적 1위이고, 순서 지시(첫 번째·마지막 합계 4건)의 30배다. 액션 어휘가 라벨 기반뿐이라 모델이 click "임의 계정"을 만들었다 — 어느 페이지에도 존재한 적 없는 타겟이라 검증에 도달하기 전에 실패했다.
clickRow { nth }를 추가했다. BrowserPage는 tbody tr → ARIA row → 리스트 항목 순으로 시도하고 헤더·빈 상태 줄을 제외하며, 아무것도 안 맞으면 실패한다(클릭 가능한 다른 것으로 폴백하지 않는다 — 필터 칩을 조용히 누르는 건 놓치는 것보다 나쁘다). "임의"는 항상 첫 행이다: 매번 다른 행을 고르는 실행은 회귀 게이트가 아니다.
라이브 프로브로 증명: /account 첫 행 클릭 → 수정 팝업 열림(텍스트 776 → 898자, 계정 수정 … 중복 확인 … 취소 완료 등장).
그런데 18건 전용 슬라이스는 여전히 전부 보류였다. 행 클릭이 케이스 스텝에만 닿고 준비에는 닿지 않았고, 임의 X 선택된 상태는 스텝보다 사전조건으로 훨씬 자주 나온다. 게다가 모델의 준비는 click "1"(페이지네이션 숫자)이거나 아무것도 없었다 — 팝업이 닫힌 채 본문이 실행되어 팝업 안 필드에서 실패했다. derivePreparationActions가 사전조건에서 행 선택을 도출하고 숫자 클릭을 버리게 했다(숫자는 행을 고르지 않고 2페이지로 넘긴다). 슬라이스 일치 0 → 1, false-pass 0 유지.
3. 영어 end-to-end 대상이 없었다.
영어 경로가 오래 안 드러난 이유가 이것이다 — 번들 픽스처는 한국어 SPA와 로그인 페이지 하나뿐이었다. scripts/english-fixture.mjs가 도출이 읽는 형태만 담는다: 이동할 경로, 행이 편집기를 여는 테이블, maxlength 입력란, 숫자만 받는 입력란.
첫 영어 런이 픽스처 문제가 아니라 실제 엔진 한계를 드러냈다. 로그인 감지가 "파싱된 HTML에 비밀번호 필드가 남아 있나"를 묻는데, 뷰를 교체하는 SPA는 로그인 입력란을 마크업에 그대로 남긴다 — url이 /#/accounts고 계정 테이블이 화면에 있는데도 "머무름"으로 판정해 케이스 1번 전에 배치가 중단됐다.
제출 버튼의 보이는 라벨이 신뢰할 수 있는 신호다(snapshot().text는 innerText라 사라지거나 숨은 폼은 기여하지 않고, 자격증명이 거부되면 버튼이 그대로 있다). 비밀번호 라벨로 판단하는 안은 시도했다가 버렸다 — 플레이스홀더만 있는 폼은 화면을 독차지하고 있어도 그 텍스트가 없고, 그걸 성공으로 읽는 건 느린 로그인을 실패로 읽는 것보다 훨씬 나쁘다.
영어 시트 6건 라이브: 일치 2 · false-pass 0 · false-fail 0. 길이 제한과 숫자 전용 필드가 영어에서 필드 검증을 실제로 작성했다 — 단위 테스트로는 보일 수 없는 것이다.
한국어 시트 불변: 98건에서 일치 13 · false-pass 2 유지.
되돌린 문자열 도출을 건전한 방식으로 다시 만들었다. 되돌린 이유는 질문이 틀렸다는 것이었다 — "타이핑한 문자열이 없으면 제한됨"은 앱이 13자를 12자로 자르는 순간 무너진다. 필드 값 자체에 묻는다:
fieldAtMost { field, max } "12자 초과 입력 → 입력 제한" 28건
fieldExcludes { field, classes } "한글/대문자/특수문자/숫자 외" 11건
건전성의 핵심 세 가지:
- 필드를 못 찾으면 실패. 타이핑한 값이 검증이 보는 곳에 안 닿았다면 제한된 게 아니라 아무것도 못 배운 것이다. 되돌린 버전이 진짜 결함 2건을 초록으로 만든 구멍이 정확히 이것이었다
- 스냅샷이 빈 값도 담는다. 맵에 있느냐가 "앱이 지웠다"와 "그런 필드가 없다"를 구분하는 유일한 근거다
- 수치는 스텝에서, 필드는 플랜의 fill 타겟에서. 둘 다 있어야 도출하니 산문만으로는 아무것도 만들지 않는다
두 판정 게이트(무의미 판정·귀속 커버리지)는 필드 검증에 면제하되 근거가 원칙적이다 — 정상 동작하는 제한은 아무것도 바꾸지 않고, 수치는 기대문이 아니라 스텝에 있다. 필드 값을 읽는 것이 바로 그 게이트들이 요구하는 증거다.
측정 (98건, luna high, 캐시 비움):
| 기준선 | 채택 후 | |
|---|---|---|
| 사람 라벨 일치 | 12 | 13 |
| false-pass | 3 | 2 |
| false-fail | 5 | 6 |
| 검증을 하나도 못 만든 케이스 | 32 | 20 |
| 케이스당 평균 검증 | 1.03 | 1.26 |
필드 검증을 가진 21건 → pass 0 / fail 6 / 보류 15. 통과를 하나도 만들지 않았다 — 6건은 실제 제한 미동작을 잡고 15건은 닫히는 쪽으로 실패했다. 잡은 것 하나: field "기관명을 입력해주세요." accepted symbol: "!@#$%^&*()".
과정에서 내 결함 두 개를 만들고 잡았다.
Promise.race가 패자를 취소하지 않아 플랜 예산 타이머가 항상 발동했다. 98건 중 87건이 "150초 초과"를 보고했는데 실제 플랜은 5초에 도착해 있었다 — 타임아웃이 하나도 없는 런이 모델 장애처럼 보였고, 실제 작성 호출을 직접 재보기 전까지 두 번 오진했다(5.1초).
그리고 필드 해석을 시트 표현(아이디 입력란)과 DOM 스캔의 플레이스홀더(아이디를 입력해 주세요.)를 잇도록 느슨하게 했다가 너무 멀리 이었다. 같은 명사를 공유하는 상자가 둘 있었고, stem 매칭이 케이스가 타이핑한 적 없는 쪽으로 답해서 그 비어 있음이 "제한이 동작함"으로 읽혔다. false-pass 3 → 5, 새로 늘어난 2건은 문자열 도출을 되돌리게 만든 것과 같은 실패였다. 정확 일치와 공백 무시까지만 허용하고 되돌렸다.
교훈이 반복된다: 필드/타겟 해석을 느슨하게 하면 엉뚱한 대상이 대답할 수 있고, 그건 항상 false-pass 방향이다. 닫히는 쪽으로 실패하는 게 정답이다.
1. 작성 모델이 아무도 선택하지 않은 채 바뀌고 있었다.
자동 복원이 connect({ mode: "codex" })를 아무것도 고정하지 않고 불러서, 모델은 ~/.codex/config.toml이 그때 말하는 값이었고 reasoning은 모델 기본값이었다. 세션 중간에 gpt-5.6-sol → gpt-5.6-luna로 스스로 바뀌었고, 캐시된 플랜이 다른 모델로 재작성됐고, 비교 가능해 보였던 스코어카드 둘이 실은 다른 실험이었다. 선택을 영속화하고 복원에 적용한다(model-pin.ts). 로그에 [pinned]/[from codex config]를 찍는다 — 어느 쪽인지 모르는 상태가 문제의 절반이었다.
2. luna high vs sol medium: 해석 지표가 완전히 동일하다.
핀이 생겨서 처음으로 유효한 A/B가 됐다. 같은 98건, 캐시 둘 다 비움:
| luna high | sol medium | |
|---|---|---|
| 검증을 하나도 못 만든 케이스 | 32/98 | 32/98 |
| 케이스당 평균 검증 | 1.03 | 1.03 |
| 타겟 실패/교정 | 25/98 | 25/98 |
| 사전조건 미충족 | 19/98 | 19/98 |
| 사람 라벨 일치 | 12 | 13 |
| false-pass / false-fail | 3 / 5 | 3 / 5 |
모델과 effort를 둘 다 바꿨는데 네 지표가 소수점까지 같다. 남은 병목에 모델은 관여하지 않는다.
3. "high가 2.2배 느리다"는 내 결론은 틀렸다 — 이상치 하나였다.
luna high 2002s vs sol medium 908s를 보고 체계적 차이로 읽었다. 순서를 통제해 luna high를 3번째로 다시 돌리니 885s, 60초 넘는 정체 0회. 2002s의 대부분은 31/98에서 9분 정체 한 번이었다. 두 조합은 같은 속도다.
원인은 요청에 경계가 없었다는 것이다. 두 모델 클라이언트 모두 타임아웃이 없어 응답하지 않는 호출이 undici 기본값(300s+300s)에 맡겨져 있었다 — 관측된 9분과 정확히 맞는다. 두 층으로 고쳤다: 요청마다 abort 시그널(기본 120s), 그리고 케이스 루프가 150s 후 그 케이스만 규칙 해석으로 떨어뜨리되 작성은 배경에서 계속되어 캐시에 남는다. 스트림에는 20초 heartbeat를 넣었다(9분 침묵이 fetch의 유휴 body 타임아웃으로 클라이언트를 죽였고, 33분짜리 측정을 폴링으로 회수해야 했다).
4. 입력 제한 도출 — 채택했다가 되돌렸다. 불건전했다.
검증을 하나도 못 만든 32건을 분해하니 27건이 "산문 기대 — 인용할 리터럴 없음" 이고 다수가 "1. 입력 제한되어야 한다." 였다(전체 시트 652건 중 39건). 무엇을 타이핑했는지는 fill 액션이 알고, 제한이란 그 값이 필드에 남지 않는 것 — 그래서 textNotIncludes: <타이핑한 값>을 코드로 도출했다.
지표는 좋아졌다: 검증 0건 32 → 15, 케이스당 평균 검증 1.03 → 1.27, 일치 12 → 14. 도출된 검증이 두 게이트(무의미 판정·귀속 커버리지)에 걸려 22건 중 18건이 보류되던 것도, 근거를 대어 면제했다 — 정상 동작하는 제한은 아무것도 바꾸지 않으므로 "동작이 무엇을 바꿨나" 판정에 걸릴 수밖에 없고, 도출한 값은 리터럴이 없는 요구사항에서 뽑았으니 인용 검사에 걸릴 수밖에 없다.
그런데 false-pass가 3 → 8로 올랐고, 새로 늘어난 것 중 2건이 진짜였다:
NO 6 기대 "입력제한되어야 한다" 검증 textNotIncludes("abcdefghijklm") → pass
사람 결함: "아이디 입력란 내 입력 제한이 동작하지 않는 현상"
NO 115 동일 형태 (abcdefghijklmn) → pass
13자를 넣고 앱이 12자로 잘라 저장하면 13자 문자열 전체는 없으므로 검증이 통과한다. 부분 수용을 제한으로 읽는다 — 길이 제한 케이스에 원리적으로 맞지 않는 검증이고, 하필 이 도출이 겨냥한 클래스의 다수가 길이 제한이다. 결함이 있는데 통과하는 검증은 보류보다 나쁘다는 게 이 프로젝트의 전제이므로 전부 되돌렸다(false-pass 3 복귀 확인).
건전한 설계는 텍스트 부재가 아니라 필드 값 자체의 의미를 봐야 한다 — 길이 제한이면 필드 길이 ≤ N, 문자 제한이면 허용되지 않은 문자 각각의 부재. 둘 다 새 assertion 종류가 필요하고(현재 세 종류는 모두 문자열 포함 검사다), 제한 수치는 스텝 문장에서 파싱해야 한다. 다음 작업으로 남긴다.
남은 false-pass 3건 중 3건이 기획서와 상이 — 시트의 기대는 화면과 일치하고 사람 Fail은 엔진이 본 적 없는 문서 기준이다. 시트 기준 false-pass는 0이다.
앞선 루프에서 프롬프트로 시도했다가 0/10으로 실패한 것을 결정적 방식으로 다시 했다. 실패 이유는 명확했다 — 모델에게 "경로를 써라"라고 말하는 것과 경로 표를 주는 것은 다르다.
1. recon이 이미 스캔한 것을 버리고 있었다.
ReconResult.pages[]에는 links: {label, href}[]가 구조화된 형태로 들어 있다. 그런데 영속화되는 건 모델이 쓴 산문 브리프뿐이었다. 그래서 downstream에서 "대시보드가 어느 경로냐"에 답할 방법이 없었다 — 이동 기대 56건 중 52건이 미검증으로 남은 근본 원인.
routeTable(pages): 링크에서 label→path 표를 뽑는다. 내부 경로만, 중복 제거, 라벨 긴 것 우선(구체적인 매치가 일반적인 것을 이긴다). 쿼리·프래그먼트는 버린다 — 뷰의 상태이지 뷰가 아니다.InterpretationRule.routes+setRuleRoutes()(버전 범프 → 캐시된 플랜 재작성).appContext와 같은 패턴이되 산문이 아니라 자료구조다./api/app/analyze가 브리프와 표를 함께 저장한다.
2. 이동 검증은 프롬프트가 아니라 코드로 도출한다.
deriveRouteAssertion(expected, routes) — 기대문이 이동을 주장하고(이동|전환|redirect|navigat|moves to) 그 안에 경로 라벨이 등장하면 urlIncludes: <path>를 붙인다. 모델이 이미 url 검증을 썼으면 절대 덮지 않는다.
오검출이 곧 false-fail이므로 좁게 잡았다:
- 이동을 주장하지 않으면 무동작(필터 목록이 뜨는 건 경로 변경이 아니다).
- 길이가 같은 라벨이 서로 다른 경로를 가리키면 무동작 — 어느 쪽인지 모르면 추측하지 않는다.
path === "/"인 항목은 제외한다. 모든 url이/를 포함하므로urlIncludes: "/"는 실패할 수 없는 검증이다. 실측으로 걸렸다 — 라이브 대시보드의일간/월간토글이 href 없는 앵커라서 표에→ /로 들어온다. 어제 없앤 무의미 검증과 같은 클래스를 스스로 만들 뻔했다.
실측 (이동 기대 10건 슬라이스, 앞선 루프에서 0/10이었던 것)
routes: 문서 현황→/document 계정 관리→/account 기관 관리→/agency
대시보드→/dashboard 월간→/ 일간→/ ← 뒤 두 개는 도출에서 제외
NO 41·42 계정 관리로 이동 → ★ /account ✅
NO 50·51 대시보드로 이동 → ★ /dashboard ✅
NO 27 비밀번호 변경 페이지로 이동 → 없음 ✅ 앱이 링크한 적 없는 경로
NO 70 첫 로그인 화면으로 이동 → 없음 ✅
NO 162·163·165 첫/이전/다음 페이지로 이동 → 없음 ✅ 페이지네이션은 경로가 없다
NO 164 해당 페이지로 이동 → ★ /account 모델이 쓴 것 — 타임라인 판정이
`assertion not discriminating`으로 잡음
★ urlIncludes: 0/10 → 5/10, 잘못 도출 0건
10건 중 4건은 정확히 도출, 5건은 정확히 기권, 1건은 모델이 쓴 무의미 검증을 다른 게이트가 잡았다. 기권이 도출만큼 중요하다 — 페이지네이션에 경로를 발명하면 매 실행 false-fail이 된다.
스모크 20 슬라이스는 불변 조건만 확인했다 (AUTHOR_VERSION 범프로 재작성됨): false-pass 2(둘 다 기존 라벨 한계), false-fail 0, 일치 56 · 보류 1112 — 하루 종일 관찰한 재작성 변동 폭 안이다. 이 슬라이스에는 이동 기대가 0건이므로 이번 변경의 효과가 나타날 수 없다(그 사실 자체를 앞선 루프에서 측정 무효로 한 번 걸렀다).
남은 것: 이동 슬라이스 10건은 여전히 전부 보류다(self-heal: click 8건). 사전조건이 깊어(팝업이 열린 상태, 특정 페이지에 있는 상태) 플랜이 그 화면에 도달하지 못한다. 검증은 이제 올바르게 작성되지만 실행이 도달하지 못하는 것이고, 그건 authoring이 아니라 사전조건 처리의 문제다.
self-heal: click 보류의 원인을 추적했다. 실패한 타겟은 기관명, 기관 선택, 이메일 — 전부 라벨 드리프트로 보였다. 그런데 라이브로 groundAction을 돌려보니 셋 다 null이었다.
스캔을 직접 찍어보고 원인이 나왔다:
/document buttons: 신규 문서 등록 | 검색 | 초기화 | 1..8 ← 필터가 없다
/document formFields: … | 문서 상태 | 전체 | 문서 유형 | … ← 필터는 여기 있었다
clickableLabels(버튼+링크+헤딩)에 폼 컨트롤이 빠져 있었다. 그런데 같은 파일의 targetOnScreen은 formFields를 포함한다 — 즉 러너는 "화면에 있다"고 판단해 전체 로케이터 예산을 다 기다리고, 접지는 같은 라벨을 스냅하기를 거부했다. 한 파일 안에서 두 함수의 어휘가 달랐다.
필터·드롭다운은 클릭해서 여는 컨트롤이고, 이 엔진이 읽는 시트는 "기관유형 필터 선택"으로 가득하다. formFields를 clickable 어휘 끝에 추가했다(순서상 진짜 버튼이 이긴다 — "검색" 버튼과 "검색" 필드가 같이 있으면 버튼을 누른다).
실측 (샘플관리 스모크 20, 동일 모델)
| 루프 8~9 | 루프 10 | |
|---|---|---|
self-heal: click(하드 실패) |
2 | 0 |
| NO 312 검증 통과 | 1/4 | 3/4 |
| NO 360 검증 통과 | 1/4 | 3/4 |
| 케이스 중단 → 화면 초기화 | 2회 | 0회 |
| 실행 시간 | 122s | 110s |
| 사람 라벨 일치 | 6 | 6 |
| false-pass / false-fail | 2* / 0 | 2* / 0 |
| 보류 | 11 | 12 |
* NO 73·169 — 시트 기대와 화면이 일치하고 사람 Fail은 기획서 기준. 시트 기준 false-pass 0.
메커니즘은 AI 교정 경로였다. 모델은 전에도 가온기관(필터에 현재 표시된 값)를 제안했는데 groundAction이 폼 컨트롤을 어휘에서 빼놨기 때문에 거부해서 교정이 실패하고 케이스가 중단됐다. 이제 채택되어 클릭이 실제로 꽂히고, 뒤 스텝들이 실행되고, 검증이 1/4에서 3/4로 늘었다. 중단이 없어지니 다음 케이스를 오염시키는 화면 초기화도 사라졌다.
판정 수치는 개선되지 않았다 — 보류가 self-heal: click에서 ai repair로 옮겨갔을 뿐이다(설계상 AI 교정은 사람 확인 대상이므로 맞는 동작). 나아진 것은 엔진이 앱을 얼마나 깊이 조작하는가이고, 그건 판정 이전의 전제조건이다.
선제 접지(pregroundAction)는 이 슬라이스에서 0회 발동했다 — 정직하게 기록한다.
드리프트를 실패·타임아웃·모델 호출 전에 결정적으로 잡으려고 넣었다(공백만 다른 경우는 정규화로 취급해 heal 이벤트를 남기지 않으므로, 스트레이 스페이스 하나 때문에 영구 보류되던 케이스가 통과할 수 있다). 부분 일치는 "어느 컨트롤을 의도했는지"에 대한 추측이라 heal 이벤트로 남겨 사람에게 보인다.
한 번도 발동하지 않은 이유가 오히려 좋은 소식이다: recon의 앱 컨텍스트가 모델에게 올바른 표기를 이미 알려주므로 공백 드리프트가 원천에서 줄었다(기관 유형을 처음부터 정확히 쓴다). 안전망은 recon을 돌리지 않은 프로젝트 — 즉 기본 상태 — 를 위한 것이므로 유지한다. 다만 이 슬라이스로는 효과를 입증하지 못했다.
1. 판정이 단 한 순간만 보고 있었다 — 그리고 재시도가 찾은 것을 스스로 버렸다.
runScenario는 최종 스냅샷으로 판정한다. 그래서 나타났다 사라지는 것은 검증이 불가능했다 — 이 시트 652건 중 93건이 "팝업/스낵바가 표출되어야 한다" 다. 더 나쁜 건 assertRetryMs 재시도 루프가 토스트를 찾아 통과시킨 직후, 더 늦은 스냅샷으로 처음부터 다시 평가해 그 결과를 전부 버렸다(391행). 즉 이 기능은 "나타나서 머무는" 것에만 작동했다.
거울상도 같은 뿌리다: 93건은 "종료되어야 한다" 인데, 팝업은 케이스 시작 전에도 똑같이 없다. 최종 화면만 보면 이 검증은 영원히 아무것도 구분하지 못한다.
- 동작 경계마다 화면 텍스트를 기록(
observed, 텍스트만 — 영속화하지 않는다). textIncludes는 거쳐온 어느 화면에서든 충족되면 통과(detail에 "실행 중 화면에서 확인됨").textNotIncludes·urlIncludes는 그대로 최종 상태만 본다 — "종료되어야 한다"를 "어느 순간이든"으로 보면 팝업이 뜬 순간 통과해버린다.- 무의미 판정도 타임라인으로: 양 끝(동작 전·최종)의 답이 같아도, 양 끝에서 없고 중간에만 있으면 실제 UI 이벤트다.
두 번 자가 교정했다. 처음엔 "타임라인 어디서든 값이 변하면 유효"로 썼는데, 그건 판정을 더 약하게 만든다 — 중간 스냅샷의 리렌더 깜빡임까지 변화로 세서, 항상 보이는 필터 라벨이 유효 판정을 받고 통과했다(실측: false-pass 1 → 2). 방향을 한쪽으로 못 박아 고쳤다(present, gone, present는 깜빡임이지 의도된 결과가 아니다). 두 방향 모두 테스트로 고정.
2. recon이 로그인에 실패해서 앱 전체가 모델에게 보이지 않았다.
appContext 길이 0. 즉 계획 모델은 이 앱의 경로를 하나도 몰랐다. 원인은 두 개의 로그인 구현이 아니라 재시도였다 — 이 앱은 첫 제출이 삼켜지고 두 번째에 성공한다. 실행기는 재시도를 갖고 있었고(signIn) recon은 없었다.
재시도를 attemptLogin 안으로 옮겼다(거부된 자격증명은 절대 재제출하지 않는 규칙까지 함께 — 계정 잠김을 부른다). 호출자는 onRetry로 통지만 받는다. 결과: loggedIn: false → true, 스캔 페이지 1 → 5. 모델이 이제 /account, /agency, /dashboard, /document와 페이지별 실제 필드·버튼 라벨을 갖는다.
플랜이 즉시 나아졌다 — 취약한 메뉴 클릭이 실제 경로로 바뀌었다:
전: click(기관 관리) → click(기관유형)
후: goto(/agency) → click(기관 유형)
3. authoring 계약이 캐시 키에 없었다.
키는 입력(케이스·룰·룰버전)만 담고 플랜을 만들어낸 코드는 담지 않았다. 그래서 authoring을 개선해도 이미 돌린 시트에는 영원히 닿지 않는다. 어제 산문 필터가 통한 건 캐시를 읽을 때 재-sanitize하기 때문이고, 프롬프트 변경에는 그런 탈출구가 없다. AUTHOR_VERSION을 키에 넣었다.
실측 (샘플관리 스모크 20, 동일 모델)
| 루프 1~7 이후 | 루프 8~9 이후 | |
|---|---|---|
| 사람 라벨 일치 | 6 | 6 |
| false-pass | 1 | 2* |
| false-fail | 0 | 0 |
| 보류 | 12 | 11 |
vision disagrees |
4 | 3 |
| 케이스당 평균 assertion | 2.15 | 3.10 |
* NO 73·169 두 건 모두 라벨의 한계다 — 시트의 기대 목록과 화면이 정확히 일치하고, 사람의 Fail은 엔진이 본 적 없는 기획서 기준이다. 시트 기준으로는 false-pass 0. NO 169가 이제 통과하는 것은 goto(/agency)로 기준 화면이 제대로 잡혀 클릭이 실제로 필터를 열었음이 확인되기 때문이다.
4. 이동 검증(urlIncludes)은 실패했다 — 정직하게 기록한다.
목표는 "이동되어야 한다"(652건 중 56건)를 urlIncludes로 검증하는 것이었다. 경로가 이제 컨텍스트에 있으므로 프롬프트에 지시를 넣고 AUTHOR_VERSION으로 재작성을 강제했다. 결과: 0/10.
측정 자체를 두 번 검증해야 했다. 스모크 20 슬라이스에는 이동 기대가 0건이었다(읽기 전용 관측 케이스만 골랐으니 당연히 빠졌다) — 즉 그 슬라이스의 urlIncludes: 0은 아무 의미가 없었다. 이동 기대 전용 10건 슬라이스를 새로 만들어 다시 재고 나서야 답이 나왔다.
케이스별로 갈린다:
- NO 162~165(
첫/이전/다음 페이지로 이동): 페이지네이션은 별도 경로가 없다(쿼리 파라미터). "목록에 없으면 쓰지 마라"는 지시대로 모델이 올바르게 거절했다. - NO 41·42·50·51(
계정 관리/대시보드로 이동):/account·/dashboard가 목록에 있는데도 쓰지 않았다. 지시가 무시됐다.
프롬프트 문구를 더 만지는 것은 이 프로젝트가 불신하는 비결정적 수단이다. 결정적 경로는 있다: recon은 이미 pages[]에 url과 navLabels를 구조화된 형태로 만드는데, 영속화되는 건 산문 브리프(context)뿐이다. 라벨→경로 표를 그대로 저장하면 "기대에 '대시보드'가 나오고 recon이 대시보드 → /dashboard를 봤다"에서 urlIncludes: /dashboard를 코드로 도출할 수 있다 — groundAction이 동작에 하는 일과 같은 원리. 다음 작업으로 남긴다.
5. 루프 10(self-heal: click)은 착수하지 않았다 — 다만 이동 슬라이스가 큰 표본을 줬다. 10건 중 9건이 타겟을 못 찾았고, 실패한 타겟이 V, <<, <, >, 페이지 였다. 시트가 UI 기호를 그대로 쓰는데 그건 접근성 이름으로 존재하지 않는다. 해결은 authoring 시점에 타겟을 recon이 관찰한 실제 라벨에 스냅시키는 것 — 즉 4번과 같은 선행 작업(구조화된 recon 데이터 영속화)이 필요하다.
1. 열거형을 한 문자열로 묶어 절대 매치될 수 없는 검증을 만들고 있었다.
시트는 필터 내용을 인라인으로 적는다("필터 리스트 표출되어야 한다 - 가온, 지자체, 공공기관, 위탁관리, 일반업체"). 모델은 그걸 그대로 textIncludes: "가온, 지자체, 공공기관, 위탁관리, 일반업체" 하나로 옮기고, 페이지에는 그 문자열이 존재하지 않는다 — 다섯 라벨이 개별로 있다. 실측 화면 텍스트: 기관 유형 전체 전체 공공기관 가온 지자체 위탁관리 일반업체. 즉 이 케이스는 영원히 miss만 가능했다.
splitEnumeratedValue()로 항목당 assertion 1개로 쪼갠다. 가드: 3개 이상 · 각 24자 이하 · 산문 아님 → "Welcome, admin"은 인접성 검사를 유지한다(2개짜리는 열거가 아니라 구절). textIncludes만 분리한다(textNotIncludes 값은 실무에서 타이핑한 단일 문자열). 실측: vision disagrees 6 → 3.
2. 동작 전 기준 화면이 두 가지 이유로 잘못 잡히고 있었다.
- 페인트 타이밍. 기준 화면을 클릭 직전에 그냥 찍으면 "그 순간까지 그려진 것"이 기준이 된다. SPA면 판정이 페인트 속도에 좌우된다 — 이 엔진이 절대 가질 수 없는 성질.
settledSnapshot()으로 연속 두 읽기가 같아질 때까지(최대 3회) 기다린다. 러너 기본값은 0(결정적FakePage용), 라이브는 서버가settleMs: 250을 넘긴다 —assertRetryMs와 같은 패턴. - 클릭으로 하는 이동.
goto는 준비 과정으로 제외했는데 SPA 메뉴 클릭은 제외하지 않았다. 실측:click "기관 관리"→click "기관유형"플랜에서 기준이 계정 관리 화면(같은 6개 필터 라벨을 가진 다른 페이지)에 남아, 진짜 필터 클릭이 아무것도 드러내지 않은 것처럼 보였다. 이제 상호작용이 URL을 바꾸고 뒤에 상호작용이 더 남아 있으면 기준을 다시 잡는다. 마지막 상호작용이 이동한 경우는 그 이동이 곧 기대 결과이므로 기준을 유지한다.
부수 확인: 이 두 수정 후에도 NO 73(pass)과 NO 169(보류)는 갈렸는데, 그게 맞았다 — /account는 필터 항목을 클릭 시 렌더하고 /agency는 항상 렌더한다. 엔진은 "기관관리에서는 이 검증이 아무것도 증명하지 못한다"를 정확히 말하고 있었다. 앱 구현 차이지 엔진 비결정성이 아니다.
3. 사람 라벨 스코어카드 (report/label-scorecard.ts + scripts/measure-sheet.ts).
하루 종일 임시 스크립트로 재던 측정을 레포에 넣었다. report/benchmark.ts는 픽스처 모델로 결정성을 증명하지만 모델도 브라우저도 타지 않으므로 "사람과 의견이 일치하는가"는 답할 수 없다. 그리고 이 도구의 사용 가능 여부를 결정하는 수치는 false-pass — QA가 결함으로 기록한 케이스를 엔진이 초록으로 부른 것 — 이며 사람 라벨 없이는 측정 자체가 불가능하다.
parseHumanVerdict():NT(미시험)·NA(해당없음)·공백은 판정이 아니므로unlabeled로 분리한다. 어느 쪽으로든 세면 아무도 표명하지 않은 일치/불일치를 발명한다.scoreAgainstLabels():held(needs_review/error)를 오답으로 세지 않는다 — 판정 보류는 설계대로 동작한 것이고, 오답으로 세면 정직한 보류가 전부 회귀처럼 보인다. false-pass / false-fail을 분리해 센다.bun run measure -- <projectId> <sheetId> [labelColumn]: 라이브 실행 후 스코어카드 출력, false-pass가 1건이라도 있으면 exit 1. CONTRIBUTING에 게이트로 추가.
최종 스코어카드 (샘플관리 스모크 20, ai/gpt-5.6-sol, 166s)
total 20 · agree 6 · false-pass 1 · false-fail 0 · held 12 · unlabeled 1
holds: 4 vision disagrees · 3 ai repair · 2 assertion not discriminating
2 self-heal: click · 1 requirements partly checked
남은 false-pass 1건(NO 73)은 엔진 결함이 아니라 라벨의 한계다. 시트의 기대 목록(전체, 가온, 지자체, 공공기관, 위탁관리, 일반업체)과 화면이 정확히 일치한다. 사람의 Fail 사유는 "필터명이 기획서와 상이" — 엔진이 본 적 없는 별도 문서 기준이다. 시트의 예상결과를 기준으로 하면 false-pass = 0.
이 루프가 찾아낸 실제 앱 결함: 로그인 화면 푸터의 지원 메일 주소에 도메인 오타가 있었다 — 시트가 요구한 철자와 두 글자가 뒤바뀐 형태. 사람 시트에도 기록되지 않은 결함이고, 아침까지의 비전 폴백은 이걸 pass로 덮고 있었다.
1. 요구사항 일부만 검증하고 통과하던 것.
예상결과가 여러 항목을 나열하는데 assertion은 일부만 만들어진다. NO 137: 4개 항목(컬럼 구성·정렬·이메일 마스킹·연락처 마스킹)에 assertion 2건 — 사람이 실제로 신고한 결함은 정렬이었고 아무것도 그걸 검사하지 않았는데 통과했다. 실측: 이 시트 652건 중 235건(36%) 이 요구사항 2건 이상.
expectedRequirements(): 최상위 번호 항목 단위로 분해.-/*/들여쓴 줄은 위 항목의 상세이므로 붙여 둔다(개수 부풀리기 방지).requirementCoverage(): 각 요구사항을 어떤 assertion이 가리키는지 결정적으로 귀속(assertion 값이 그 요구사항 텍스트 안에 나타나면 커버). 모델이 자기 커버리지를 정직하게 보고하길 기대하지 않고, 프롬프트 변경도 없으므로 이전에 캐시된 플랜에도 그대로 적용된다.covered < total이면pass→needs_review+ 검증하지 않은 항목 목록. 요구사항이 1건인 경우는 측정하지 않는다 — 그건 "assertion이 있나"의 재진술이고, 문장과 다르게 표현된 정당한 assertion마다 오탐이 난다.
2. baseline이 올린 pass를 구분할 수 없었다.
assertion이 통과해서 초록인 것과, 사람이 언젠가 승인해서 초록인 것이 보고서에서 동일하게 보였다. 후자는 그때 그 빌드를 보고 한 번 내린 판단이고, 화면 텍스트가 유지되는 한 계속 pass를 만든다 — 그 텍스트를 건드리지 않는 회귀 뒤에도, 승인한 화면이 이미 깨져 있었어도. StructuredResult.baselineLifted / CaseView.baselineLifted로 표시한다. "Clear runs"는 의도적으로 승인을 남기므로(history·리뷰만 삭제) 이게 승인의 작동을 볼 수 있는 유일한 수단이다.
3. 전체 승인(/api/review/approve-all) 제거.
측정 중에 내가 직접 밟았다. 깨끗한 상태에서 1회차는 20건 중 15건을 올바르게 보류했다(false-pass 1). 거기서 전체 승인을 한 번 누르니 false-pass가 7로 뛰었다 — 사람이 이미 결함으로 신고한 화면 6건이 그 클릭으로 영구히 초록이 됐다. 승인은 "Clear runs"보다 오래 살기 때문에 되돌리기도 어렵다.
리뷰 큐에 오는 모든 사유가 이제 "엔진이 이 화면을 확인할 수 없다 — 사람이 보라"는 뜻이다(텍스트 없음 / 검증이 구분 못 함 / 항목 일부만 검증 / AI 교정). 이걸 한꺼번에 축복하는 버튼에 정직한 버전은 없다 — "15개 스크린샷 전부 확인했다"는 아무도 진실하게 하지 않는 주장이다. 엔드포인트·API 클라이언트·버튼·관련 문자열·상태 전부 제거. 케이스별 승인(/api/review/approve) 또는 실패 처리(/api/review/reject)만 남긴다.
최종 실측 (처녀 시트 상태, 사람 판정 검증 결과 대조)
| 세션 시작 | 비전 수정 | 루프 1+2 | 루프 3 + 전체승인 제거 | |
|---|---|---|---|---|
| false-pass | 7 | 4 | 1 | 0 |
| 사람 라벨 일치 | 6 | 5 | 6 | 6 |
| 보류 | 5 | 10 | 12 | 13 |
| pass | 10 | 5 | 1 | 1 |
| baseline이 올린 pass | (측정 불가) | (측정 불가) | (측정 불가) | 0 |
대가는 정직하게 적어둔다: 산문 시트 1회차 pass는 20건 중 1건이다. 초록으로 가는 길은 두 가지뿐이고 둘 다 사람이 한다 — 케이스별로 기준 화면을 승인하거나, 예상결과를 항목별로 검증 가능하게 다듬는 것. 일괄 처리 경로는 그게 false-pass 6건을 만든다는 걸 측정했기 때문에 없앴다.
다음 후보: 보류 13건 중 vision disagrees 6건이 최대 덩어리다. 이건 "텍스트로는 못 찾았는데 화면상으론 맞아 보임" — 즉 assertion이 화면의 실제 문구와 어긋난 경우가 많다. author 단계에서 recon으로 얻은 실제 화면 문구에 assertion을 스냅시키면(이미 groundAction이 동작에 하는 일) 상당수가 진짜 pass/fail로 갈릴 것이다.
비전 수정 후에도 남아 있던 false-pass 4건을 메커니즘별로 하나씩. 측정 대상은 같은 시트(샘플관리 스모크 20)와 같은 모델.
1. 입력값이 스냅샷에 없어서 "입력 제한" 케이스가 판정 불가였다.
<input>에 타이핑한 값은 DOM 속성이라 innerText에도 content()에도 없다. 실측으로 확인했다 — 타이핑 후 text.includes(타이핑한 값) = false, html.includes(...) = false. 그래서 "12자 초과 입력 → 입력 제한되어야 한다"류는 textNotIncludes: <타이핑한 문자열>이 앱이 제한했든 안 했든 무조건 통과했다. 이 시트 652건 중 57건(8.7%) 이 정확히 이 형태다.
PageSnapshot.fields(사람이 읽는 라벨 → 현재 값) 추가.BrowserPage가 라이브 페이지에서 읽는다(label[for] → 감싼 label → aria-label → placeholder → name 순으로 라벨 해석).FakePage는 이미 추적하던 타이핑 값을 그대로 노출.evaluateAssertion의 텍스트 검사 대상 = 페이지 텍스트 + 필드 값. 필드에서 맞으면 detail에field value has "…"로 남긴다(리뷰어가 화면에서 텍스트를 헛되게 찾지 않도록).- 전이 상태(transient)로 유지. 증거·baseline·리뷰 카드는 전부
text를 쓰므로 타이핑한 비밀번호가 디스크나 리뷰 카드에 남지 않는다. hidden 필드도 제외(실측 확인: CSRF 값 미유출). - 실측:
maxlength=5필드에1234567890입력 → 값이12345로 잘림 →textNotIncludes("1234567890")통과(제한 동작함). 제한 없는 필드에한글ABC!@#입력 →field value unexpectedly has "한글ABC!@#"실패(= 시트가 기술한 결함).
2. 동어반복 검증 — 결과가 아니라 트리거를 검사.
NO 3: "개인정보처리방침 선택 → 팝업 표출되어야 한다"의 assertion이 textIncludes: 개인정보처리방침. 클릭하는 링크 자체의 텍스트다. 팝업이 안 떠도(사람이 기록한 결함이 정확히 "팝업 미노출") 통과한다.
- 첫 클릭/입력 직전 화면을 한 번 캡처(텍스트만). 선행
goto는 제외한다 — 내비게이션은 준비 과정이고, 질문은 "상호작용이 검증 가능한 무언가를 바꿨는가"다. - 판정이
pass로 나왔을 때, 통과한 assertion 전부가 동작 전 화면에서도 통과하면 그 검증은 아무것도 구분하지 못한다 →needs_review+ 사유.fail이 아닌 이유: 앱이 정상일 수도 있고 약한 건 검증 쪽이다. - 상호작용이 없는 관측 케이스("로그인 페이지 확인")는 대상이 아니다 — 이미 렌더된 것을 검사하는 게 정당하다.
실측 (동일 시트·모델, 사람 판정 검증 결과 컬럼 대조)
| 세션 시작 | 비전 수정 후 | 루프 1+2 후 | |
|---|---|---|---|
| false-pass(사람 Fail→엔진 pass) | 7 | 4 | 1 |
| pass | 10 | 5 | 1 |
| fail | 5 | 5 | 7 |
| needs_review | 5 | 10 | 12 |
남은 1건은 NO 169 — 시트의 기대 목록과 화면이 일치하고 사람의 Fail은 기획서 대비 판정이므로, 시트 기준으로는 엔진이 맞다.
"pass 1/20"은 고장이 아니다 — 수렴 과정의 1회차다. 승인 흐름으로 검증했다:
review queue 12 → 승인 가능 7 / 승인 불가 5(전부 self-heal = 실제 동작 실패)
approve-all → approved=7 skipped=5 ← 실행되지 않은 케이스는 일괄 승인에서도 제외
2회차 → pass 9 · fail 6 · needs_review 5
즉 1회차는 "내가 검증할 수 있는 것/없는 것"을 정직하게 말하고, 사람이 기준 화면을 한 번 승인하면 이후는 결정적으로 통과한다. 큐에 남는 5건은 전부 플랜이 요소를 못 찾은 실제 실패 — 서명이 아니라 플랜·앱 수정이 필요한 것들이라 남는 게 맞다.
실제 시트(샘플관리, 20케이스 추림)를 라이브로 돌려 사람 판정(검증 결과 컬럼)과 대조해서 찾았다. 불일치 7건이 전부 같은 방향 — 사람 Fail → 엔진 pass. 범인은 runner.ts의 비전 폴백이었다.
증거(실측 detail 문자열):
로그인 PASS value="support@acme.example" detail="text lacks "support@acme.example" · 비전 확인"
메인 PASS value="관리자" detail="text lacks "관리자" · 비전 확인"
계정관리 PASS value="가온, 지자체, 공공기관…" detail="text lacks "가온, 지자체…" · 비전 확인"
로그인 PASS value="1. 팝업이 종료되어야 한다." detail="비전 확인: 1. 팝업이 종료되어야 한다."
즉 결정적으로 실패한 textIncludes를 모델에게 스크린샷 보여주고 물어본 뒤 passed: true로 뒤집고 있었다. text lacks "X" → pass. 이건 F12("LLM은 작성·자가복구에만, 매 실행 판정은 안 함")의 정면 위반이고, false-pass=0 하드 게이트도 함께 깨진다. 두 번째 형태는 assertion이 0건일 때 예상결과 산문을 그대로 textIncludes 값으로 만들어 passed: true로 넣었다 — "팝업이 종료되어야 한다"는 팝업을 한 번도 열지 않아도 참으로 보인다(사람이 남긴 결함이 정확히 "팝업 미노출"이었다).
- 비전은 판정에서 빠진다. assertion의 결정적 결과는 그대로 두고(
passed: false유지), 불일치를visionNote로 기록한다. 그리고fail을needs_review로만 완화한다 — 엔진은 아니라 하고 화면은 맞아 보이면 그건 판정이 아니라 사람 몫이다. 반대 방향(무엇도pass로 올리는)은 없다. - 합성 assertion 제거. assertion 0건인 순수 시각 기대는 이미
needs_review로 간다. 비전은 "왜 사람이 봐야 하는지"만 남긴다. - 초록으로 가는 길은 남아 있다. 비전 분쟁 케이스는
executedAsWritten=true(실행됨)이므로 사람이 기준 화면을 승인하면 이후 실행은 결정적으로 통과한다. 시각 기대의 정당한 경로는 원래 baseline이었고, 비전은 그 판단을 사람에게서 빼앗고 있었을 뿐이다. - 리뷰 사유 추가.
vision disagrees가 일반 폴백보다 우선한다(스크린샷에서 볼 지점을 유일하게 지목하므로). 패널에 한/영 설명 추가.
실측 변화(동일 시트·동일 모델):
| 전 | 후 | |
|---|---|---|
| pass | 10 | 5 |
| fail | 5 | 5 |
| needs_review | 5 | 10 |
| false-pass(사람 Fail→엔진 pass) | 7 | 4 |
pass가 줄어든 게 퇴보가 아니다 — 그 5건은 실패한 검사를 모델 의견으로 덮은 것이었다.
남은 false-pass 4건(이 변경 범위 밖, 각각 다른 메커니즘):
- NO 3 동어반복: 기대 "개인정보처리방침 팝업 표출" → assertion
textIncludes: 개인정보처리방침. 트리거 링크 자체가 그 텍스트라 팝업이 안 떠도 통과한다. 결과가 아니라 원인을 검사하고 있다. - NO 5 관측 불가 상태: "입력 제한되어야 한다" →
textNotIncludes: 한글ABC!@#. input의 value는 애초에snapshot.text에 없어서 무조건 통과한다. - NO 137 커버리지 부족: 요구사항 4건(컬럼 구성·정렬·이메일 마스킹·연락처 마스킹)에 assertion 2건. 사람이 잡은 결함은 정렬이라 검사 대상에 없었다.
- NO 169 엔진이 맞는 쪽: 시트의 기대 목록과 화면이 일치한다. 사람의 Fail은 기획서 대비 판정이라 시트 기준으로는 통과가 옳다.
"종료해도 커서/입력 모드가 안 돌아온다"를 코드에서 역추적한 결과, 복원 로직 자체보다 복원을 건너뛰는 종료 경로가 문제였다.
- SIGINT/SIGTERM 핸들러가 두 벌 등록돼 있었다.
main()위쪽에서shutdown(복원 포함)을, 아래쪽에서process.once(signal, …)로 브라우저만 닫고process.exit(0)하는 핸들러를 각각 달고 있었다. 결과:browser.close()이중 호출, 두 개의process.exit(0)경합, 그리고 두 번째 Ctrl+C 가드(shuttingDown)가 한쪽만once라 비대칭. 하나로 합쳤다. (검증: 리스너 수 2 → 1) - SIGBREAK·SIGHUP은 핸들러가 아예 없었다. Windows의 Ctrl+Break, 콘솔 창 닫기가 여기 해당한다. 리스너가 없는 시그널은 Node가
exit리스너를 실행하지 않고 프로세스를 죽인다 — 즉 이 경로들은 복원도, 브라우저 종료도 한 적이 없다. 넷 다 같은shutdown으로 등록. (검증: 0 → 1) --selftest는 복원을 등록조차 안 했다. 셀프테스트도 배치를 돌리므로 Chromium을 띄운다.process.on("exit", …)등록을--selftest분기 위로 올렸다.- 시작 실패가 unhandled rejection으로 새어나갔다.
main().then(...)에.catch가 없어서, 브라우저 미설치 같은 실패가 스택 트레이스를 터미널에 쏟고 우리 종료 경로를 통과하지 않은 채 프로세스를 내렸다. 실측(브라우저 삭제된 상태--selftest): 8줄 스택 → 이제 복원 후Chromium 브라우저가 설치되어 있지 않습니다…한 줄 + exit 1. - stdout만 복원하고 있었다.
studio > run.log처럼 stdout이 파일이면 커서가 살아 있는 콘솔(stderr)에는 아무것도 안 썼다. stdout → stderr 순으로 TTY인 쪽에 쓴다. - 쓰기를
writeSync로. Node의 TTY 쓰기는 Windows에서 비동기라, 시그널/exit핸들러에서 낸process.stdout.write는 큐에 들어간 채 프로세스 종료와 함께 버려질 수 있다. 다만 이건 이 머신의 ConPTY에서는 재현되지 않았다(양쪽 다 출력이 도달) — 레거시 conhost를 상정한 방어적 변경이며, exit 핸들러에서의 정석이기도 하다. setRawMode(false)는 남긴다(우리가 raw 모드를 켠 적이 없더라도). Windows에서 libuv가 이 지점에 콘솔 핸들의ENABLE_ECHO_INPUT/ENABLE_LINE_INPUT을 되돌리므로, 자식 프로세스가 꺼놓은 에코를 되살리는 실제 처방이 맞다.- 복원 로직은
run-helpers.ts의restoreTerminal(io)로 분리(주입 가능한 stdin/stdout/stderr/writeSync) — 서버를 import하지 않고 테스트하기 위해서다. TTY 조합별 대상 fd, 파이프일 때 무동작, 반쯤 닫힌 핸들에서 throw 안 함까지 테스트로 고정.
runScenario는 heal 이벤트가 있으면 needs_review로 캡한 다음, 승인된 baseline이 화면과 일치하면 그걸 pass(confidence 0.9)로 올렸다. 문제는 왜 리뷰로 갔는지를 보지 않는다는 것이다.
재현(FakePage, 규칙 해석): 한국어 산문 시트의 케이스 — 스텝 2개 전부 unknown(→ 실행된 동작 0개), 예상결과는 산문(→ assertion 0개). 브라우저는 랜딩 화면에 그대로 앉아 있다. 그 랜딩 화면으로 baseline이 한 번이라도 승인돼 있으면 판정은 pass. 아무것도 하지 않고 통과한다 — 프로젝트가 하드 게이트로 못 박은 false-pass = 0 위반이고, runner.ts 헤더가 스스로 선언한 "any heal event caps the verdict at needs_review (never a silent pass)"와도 모순이었다. 특히 위험한 건 "권한 없는 사용자에게 X가 안 보인다" 류의 음성 케이스다 — 기대 화면이 곧 아무것도 안 한 화면이라 우연이 아니라 구조적으로 일치한다.
- 구분 기준: baseline은 작성하지 못한 assertion의 대체물이지 실행의 대체물이 아니다.
executedAsWritten(StructuredResult에 노출)을 도입해 스텝 미해석(skip)·동작 실패·중단(abort)·예외는false로 내리고, AI 교정(repair)은true로 유지한다 — 교정된 케이스는 라이브 DOM에 근거해 동작을 다시 수행하고 나머지를 이어갔으므로 실제로 실행된 것이 맞고, 2026-07-23/27의 "승인된 baseline이 있을 때만 pass" 의도는 이쪽에만 적용된다. - 제안 자체를 안 한다: 실행되지 않은 케이스에는 pending baseline을 제안하지 않는다. 리뷰어에게 승인할 가치가 없는 화면을 들이밀지 않기 위해서다.
- 리뷰 UX 정합: 그래서 승인 버튼이 500을 뱉지 않도록
ReviewItem.baselineEligible을 실어 보내고, 개별 승인은 400 + 사유, 전체 승인은 해당 항목을 건너뛰고(skipped) 큐에 남기며, 리뷰 패널은 그 항목에 "실패로 처리"만 노출한다. - 기존 테스트 수정:
Click "Nonexistent Button"(동작 실패)으로 승격을 검증하던 테스트는 의미가 바뀌었으므로, 정상 실행 + assertion 부재 케이스로 교체하고 실패·미해석 케이스가 승격되지 않음을 별도 테스트 2개로 고정했다.
- Playwright를 필요할 때만 붙인다.
execute/browser-page.ts가playwright를 최상단에서 정적 import 하고,index.ts가 그 파일을 재수출하고 있었다 — 즉 시트 인제스트·규칙 해석·판정·CLI·Studio 부팅처럼 브라우저를 한 번도 열지 않는 경로가 전부 모듈 로드(실측 ~0.5초)와 "playwright가 설치돼 있어야 함"이라는 조건을 함께 지고 있었다. 이 파일에서 런타임 값으로 쓰는 건chromium.launch하나뿐이라 나머지는 전부 타입 import로 내리고chromium만 동적 import로 바꿨다(성공만 캐시 — 세션 중에 브라우저를 설치한 사용자가 Studio를 재시작하지 않아도 되도록 실패는 캐시하지 않는다). 실측: Studio 부팅 후 playwright 모듈 로드 0건(이전 8건),bun test19.4s → 13.6s. 회귀 방지로 "어댑터를 import 해도 playwright는 로드되지 않는다"를 서브프로세스 테스트로 고정했다. - 케이스가 0건이면 브라우저를 열지 않는다.
runBatch가 케이스 수와 무관하게 Chromium을 띄우고 트레이스 디렉터리를 만들고 있었다. 빈 시트에서는 순수 낭비이고, 브라우저 미설치 머신에서는 "실행할 게 없다"가 브라우저 설치 에러로 바뀐다. - 실행 기록에 모델·추론 수준·소요 시간을 남긴다. 히스토리 항목이
{at, source, baseUrl, interpreter, counts, results}뿐이라 같은 시트의 두 실행이 사후에 구분되지 않았다 — 어떤 모델·어떤 추론 수준이 어떤 판정을 만들었는지 기록 자체가 없었고, 따라서 모델/추론 수준 비교는 애초에 불가능했다.runModelMeta(규칙 실행에는 모델을 각인하지 않는다 — 모델이 관여하지 않은 실행을 모델 결과로 오염시키지 않기 위해)로model/reasoning을 남기고durationMs를 함께 기록한다. 대시보드의 최근 실행 카드와 실행 선택 드롭다운에모델 · 추론수준 · 소요시간으로 표시. - 아직 아닌 것: 모델×추론수준 스윕 하네스.
report/benchmark.ts는 결정적 픽스처 모델(makePage)만 돌리므로 모델을 전혀 타지 않는다 — 즉 지금의 벤치마크 게이트는 판정 결정성용이지 모델 비교용이 아니다. 비교를 하려면 같은 시트를 연결만 바꿔 반복 실행하고 위 각인으로 대조하는 것이 현재 유일한 경로.
사용자가 등록한 실제 프로젝트(654행 xlsx, 실계정)를 CDP 계측과 함께 라이브로 돌려서 찾은 것들. 전부 "엔진은 도는데 아무것도 검증하지 않는" 부류라 로그만 봐서는 정상처럼 보였다.
- xlsx 탭 병합이 여러 줄 셀을 파괴. 병합을 물리적 줄 단위로 하면서 따옴표 안쪽 줄에도 탭 이름을 붙였다 → 예상결과·시험절차 셀 안에
TestCase_…,가 주입(실측: 두 프로젝트에서 각각 1021·1549개 셀). CSV 레코드 단위 병합으로 교체하고, 파서를intake/csv.ts로 분리해 서버와 웹 번들이 같은 파서를 쓰도록 했다. 이미 저장된 시트는 주입 문자열이 행의 1열 값과 정확히 일치하므로 결정적으로 복원 가능 — 백업 후 복구했다. - 열 매핑이 열 순서에 지배당함.
사전조건이시험절차보다 앞에 있으면 "정확히 일치 우선" 규칙 때문에 스텝이 사전조건으로 매핑됐다. 즉 실행된 것은 절차가 아니라 사전 상태 문장. 별칭 우선순위가 열 순서를 이기도록 바꾸고 사전조건은 최후 폴백으로 내렸다. - 규칙 해석 어휘가 영어 전용. 이 도구가 존재하는 이유인 한국어 시트의 스텝("1. 개인정보처리방침 선택")이 전부
unknown이었다 — 즉 모델 미연결 시 폴백 경로에서 한 스텝도 실행되지 않았다. 기본 intent에 한국어를 1급으로 넣고, 스텝→라벨 정리(stepTarget: 목록 번호·조사·활용형 동사·"버튼/링크" 같은 UI 명사 제거)를 추가했다. - 산문 기대결과를 문자열 assertion으로 조작. "…표출되어야 한다."를 그대로
textIncludes로 걸면 절대 매치되지 않아 앱을 탓하는 가짜 fail이 된다. 규칙 해석은 이제 산문 기대결과에 대해 assertion을 만들지 않고 리뷰로 넘긴다(가짜 통과보다 무판정이 안전하다는 기존 원칙과 동일). - 사람 라벨이 CSS 파서로 들어가 체인 전체가 폭발.
locate()의 마지막 폴백p.locator(target)에 "아이디/비밀번호 찾기" 같은 라벨이 들어가면 셀렉터 파싱 에러가 나면서 앞의 role/text 후보까지 통째로 무효화됐다(매치될 수 있었는데도). 이제 진짜 셀렉터(#id/.class/[attr]/태그명)일 때만 CSS 폴백을 붙인다. - 인증 케이스 분류가 카테고리만 봄. 스프레드시트 탭 이름이 카테고리로 들어오는 실제 시트에서는(
TestCase_샘플관리 시스템_관리자) 기능이 제목 열에 있다 → 로그인 케이스 전체가 일반 케이스로 분류되어 로그인된 세션에서 돌았다. 카테고리+제목을 함께 보되 정규식을 좁혀(인증(?!서),\bauth\b) 오탐을 막고, 아이디/비밀번호 찾기도 로그아웃 상태 케이스로 포함했다.
실제 앱(Nuxt CSR)으로 돌려보니 두 구멍이 드러났다: (a) 스텝이 실패해도 나머지를 그대로 밀어붙였고, (b) 모델은 author-time에만 관여하고 실행 중에는 화면을 한 번도 보지 않았다. "계획은 앱을 본 적 없는 상태로 작성되는데, 재생은 앱이 바뀌어도 그대로"가 근본 원인.
- 회복 사다리(
runner.ts). 실패한 동작마다 ① 오버레이 해제 후 1회 재시도 → ② 모델 개입(아래) → ③ 그래도 실패면 케이스 중단. 중단이 정답인 이유: 선행 동작이 실패한 시점에서 이후 스텝은 계획이 가정한 화면이 아닌 곳에서 실행되며, 최악의 경우 파괴적 클릭(삭제·승인)이 엉뚱한 대상에 꽂힌다. 판정 의미는 그대로다 — heal 이벤트가 이미needs_review로 캡하므로 중단이 판정을 바꾸지 않는다. - 런타임 AI 개입(
repair.ts). 실패 시점의 라이브 DOM 구조 스캔 + 스크린샷을 모델에 주고 동작 1개를 교정받는다. 신뢰 규칙: 반환 라벨/경로가 그 화면에 실제로 존재해야만 채택(groundAction)하고, 화면의 정확한 문구로 스냅해서 실행하며, 케이스당 예산 2회, 그리고 교정은 heal 이벤트로 남아 자동 pass가 되지 않는다. 승인된 baseline이 있을 때만 pass로 올라간다 — 즉 "AI가 고쳤다"가 곧 "통과"가 되지 않는다. - 케이스 간 회복(
server.ts). 모든 케이스가 브라우저 세션 하나를 공유하므로 중단된 케이스는 뒤의 모든 케이스를 오염시킨다.aborted/error뒤에는 홈으로 리셋하고, 로그인 화면으로 튕겼으면 재로그인(2회 한도) 후 계속. - 해석 못 한 스텝도 기록.
parseStep의unknown을 조용히 버리던 경로가 규칙 모드의 false-pass 벡터였다. 이제 heal 이벤트로 남겨 캡한다(실행은 계속 — 시도조차 안 한 스텝이라 화면 상태는 여전히 신뢰 가능). - 리뷰 사유 분리. heal[0]이 우연히 대표 사유가 되던 것을 심각도 순(동작 실패 > AI 교정 > 미해석)으로 요약(
summarizeHeal). 리뷰 패널에 "AI가 교정함/해석 못 함" 설명을 추가. - 경로 검증(
landingProblem).page.goto는 404에도 리다이렉트에도 예외를 던지지 않는다 — 즉 "요청한 경로에 갔다"는 검증된 적이 없었고, 미인증 상태에서/official-docs를 요청하면 로그인 화면을 상대로 케이스 전체가 돌았다. 이제 최종 URL + 문서 상태를 대조하되 인증 튕김과 4xx만 실패로 본다./orders→/orders/list같은 정상 리다이렉트까지 잡으면 노이즈로 진짜 실패가 묻힌다. - 경로 어휘를 계획 모델에 공급.
renderPageForPrompt가 nav '라벨'만 넘기고 href는 버리고 있었다. 앱을 본 적 없는 계획 모델이/login을 지어내는 게 당연했다. 이제 내부 경로를routes:줄로 넘기고 RECON_SYSTEM도 "경로를 그대로 인용하라"로 강화. - 세션 소유권을 실행기로. 최악의 구멍은 역할 라우팅이 세션을 바꾸지 않는 것이었다 —
role로 계정을 찾아 놓고 그 자격증명을 계획 작성 모델에만 넘겼다. 즉 admin 케이스가 viewer 세션으로 돌았고, "권한 없는 사용자는 X를 못 본다" 류는 엉뚱한 이유로 통과했다(전형적 false-pass). 이제 케이스마다 세션 계약을 강제한다: 인증 케이스는 매번resetSession()(쿠키+웹스토리지) 후 시작, 나머지는 자기role계정으로 실제 로그인(사용자가 다르면 로그아웃 후 전환), 로그아웃 케이스는 로그인 상태로 시작하고 끝나면 세션 무효 표시. 결정 로직은authStepFor로 분리해 시퀀스 자체를 단위 테스트(로그인→역할 전환→로그아웃→복구)로 못 박았다 — 이 계약은 케이스 하나만 보면 늘 멀쩡해 보여서 조용히 썩는다. - 로그인 재시도는 조건부. 실패 원인이 앱의 명시적 거부 메시지면 재시도하지 않는다(
LoginResult.rejected) — 거부된 비밀번호 재제출은 얻는 것 없이 계정 잠김만 부른다. 폼 미렌더·제출 삼킴 같은 무증상 실패만 1회 재시도. - 로그인은 지연 실행(2026-07-23 "로그인 사전조건" 대체). 사전조건 방식은 "로그인 페이지 검증에 로그인이 선행되면 안 된다"는 자기 원칙을 시트 단위로만 지켰다 — 혼합 시트(로그인 케이스 + 일반 케이스)는 무조건 먼저 로그인한 뒤 첫 로그인 케이스에서 그 세션을 즉시 버렸다. 왕복 낭비이자, 로그인 폼을 실제로 만나지 않는 구조. 이제 사전 로그인을 없애고 세션이 필요한 케이스에서 로그인한다. 그 결과 시트 단위 휴리스틱(
isLoginOnlyCases)은 케이스 단위 계약에 흡수되어 삭제. 첫 필수 로그인이 실패하면 예전 사전조건처럼 배치를 중단한다(케이스를 하나씩 리뷰 큐로 흘리지 않기 위해).
한 세션 동안 실제 앱/복잡 fixture로 스트레스 테스트하며 발견·수정한 엔진 결정들. UI보다 엔진 비중이 크지만 "왜"를 여기 남긴다.
- 로그인 사전조건(#3). 로그인 시트가 아닌 케이스는 실행 전 프로젝트 계정으로 한 번 자동 로그인하고 세션을 공유(
recon.ts의attemptLogin, 폼이 사라질 때까지 폴링). 로그인 카테고리는 자체 로그인 흐름을 구동하도록 건너뜀. → "명백한 실패가 잘못 리뷰됨"은 판정 버그가 아니라 로그인 부재의 증상이었다. 성급했던 #4 판정 변경은 되돌리고, heal→review는 의도된 동작으로 유지(엔진 한계를 앱 탓으로 돌리지 않음). - 파일 1개 = 시트 1개(#1).
.xlsx임포트는 파일당 시트 1개로 매핑하고 탭들을 병합, 탭 이름을분류카테고리로 캡처. 카테고리/[말머리]는 미리보기·결과·대시보드·리뷰에 배지로. 리뷰 네비 배지는 프로젝트 전역이 아니라 선택된 시트 기준으로 정정. - 비전 폴백. DOM 텍스트 매치가 없거나 기대가 순수 시각(색·아이콘)일 때 스크린샷을 멀티모달로 판정(
vision.ts). 결정성 유지를 위해 보류 판단에만 사용. - 관대 매치 + 비동기 재시도. 프로젝트 옵션
lenientMatch(공백·문장부호 무시),assertRetryMs(실패 assertion을 짧게 재확인). 둘 다 opt-in이라 기본 엄격성은 유지. - 실행 수명주기. 서버 측 run 레지스트리로 새로고침·재접속에도 실행 생존, 중지 버튼 +
AbortSignal취소,/api/run/active재접속. - 모델 자동 복원. 서버 시작 시 Codex 로그인을 자동 복원(전체 실패의 근본 원인이었음). 모델 없으면 규칙 해석으로 소프트 폴백 + 안내.
- CodeGraph는 선택적 "재료". explore 전 인덱싱을 먼저 수행(과거엔 인덱스 없이 explore가 돌던 버그). 없어도 일반 스캔으로 우아하게 폴백 — 하드 의존성 아님.
- 복잡 fixture 5/7는 건강한 결과. 남은 2건(새 주문 비동기 다단계 폼, 계정 삭제 네이티브 confirm)은 정당하게 어려워 리뷰로 남기는 게 옳다. 7/7을 쫓지 않는다.
차콜/라임/판정 의미색은 유지. 좌측 사이드바 + 가로 wrap 셸 결정을 상단 내비 + 가로 프로젝트/시트 컨텍스트 + 중앙 run rail 구조로 대체. 이전 셸 결정은 역사적 맥락으로만 남고 현재 UI를 더는 제약하지 않는다.
2026-07-20 — 패널은 마운트 유지(hidden), 탭 전환 시 언마운트 안 함 · 채택
App이 4개 패널을 모두 렌더하고 hidden만 토글. 이유: 실행 중 스트리밍 뷰가 탭 전환에도 살아남고, 편집 중이던 폼이 파괴되지 않음. 패널 간 데이터 신선도는 refreshKey(실행 종료 시 증가)로 처리. 비용: 모든 패널이 시작 시 1회 fetch — 로컬 도구에선 허용.
"전체 승인 (N)"은 "N건의 증거를 모두 확인했습니까?" 2단계 확인을 거친다. 큐에 2건 이상일 때만 노출. 다건 보류 운영자의 편의를 제공하되 "증거 없이 승인 없음"을 조용히 깨지 않는다.
--pass를 #9ee600(라임과 동일)에서 #4cc06d로 이동. 액센트와 성공색이 같으면 대부분 통과한 대시보드가 액센트 예산을 초과하고 히어로 지표가 초점을 잃는다. 앱 전역(배지·dot·chip·✓) 적용.
960px 이하에서 사이드바를 아이콘 레일/오프캔버스 드로어 대신 가로 wrap 바로. 나브가 한국어 텍스트 라벨 5개라 아이콘 발명이 비용. → 2026-07-21 재구성으로 현재는 좌측 뷰 레일 + 모바일 하단 도크로 대체됨.
Studio UI 재구성 최종 리뷰(2026-07-21)에서 **비차단(non-blocking)**으로 기록된 후속 과제. 승인 기준을 실패시키지 않았으나 다음 손댈 때 정리 권장.
- M1 — 대시보드 부분 실패의 조용한 집계 누락 — 2026-08-03 해소 확인(코드가 사라짐). 그 사이
DashboardPanel이 선택된 한 시트의 히스토리만 읽도록 재작성됐다(api.history(selId, selSheetId), 호출자 한 곳).Promise.allSettled는 이제src/전체에 존재하지 않으므로, "일부 시트만 성공한 값을 프로젝트 집계로 제시"하는 경로 자체가 없다. 부채가 코드보다 오래 살아남은 사례 — 다음 세션이 유령을 쫓지 않도록 지운다. - M2 — Studio UI 자동 회귀 테스트 부재 — 2026-08-03 착수(첫 커버리지 있음). DOM 셰임(
@happy-dom/global-registrator) +bunfig.toml프리로드로 컴포넌트 테스트 하네스를 세우고 두 곳을 덮었다:ModalShell4건(열림·포커스 진입·닫기 요청 1회 계약·포커스 복원)과 대시보드의 stale 응답 가드 1건(시트를 바꾼 뒤 이전 시트의 느린 응답이 도착해도 새 시트의 숫자를 덮지 않는다 — 가드를 끄면 시트 B 아래에 A의 100%가 뜬다). 브라우저는 쓰지 않는다 — 이 스위트는 결정성이 자산이고, "어댑터를 import해도 playwright가 로드되지 않는다"를 검증하는 테스트가 이미 그 태도를 못 박아 두었다. 셰임의 한계를 명시해 둔다(test/setup-dom.ts): happy-dom은 DOM API를 구현하지만 UA 동작은 만들지 않으므로,showModal()상태에서 Escape가cancel을 올리는 것 같은 브라우저가 생성하는 사건은 여기서 검증할 수 없다 — 컴포넌트가 이벤트를 받아 무엇을 하는지만 물을 수 있다. 프리로드가 엔진을 오염시킬 수 있다는 것도 실측했다: 등록이 전역fetch를 갈아치워 오케스트레이션 계약 테스트(실제 HTTP 워커)가NetworkError: Parse Error로 깨졌고, Bun의 HTTP 표면을 등록 후 되돌려 해소했다. 남은 것: 스트림 중 컨텍스트 전환(RunPanel),StudioChrome의 컨텍스트 레일. - M3 — 에러 처리의 무검증 단언·삼킴 — 2026-08-03 해소. 세 갈래 중 경계가 실제로 물고 있었다. ① 스트림이
res.ok를 안 봤다: 서버는 동시 실행을 **409 +{"error":"이미 실행 중인 런이 있습니다. 먼저 중지하세요."}**로 거절하는데, 그것도 본문이라 리더가 읽고JSON.parse(line) as RunEvent가 **type이 없는 "이벤트"**를 만들었다. 패널의 이벤트 처리는typeif/else 체인이고 else가 없어서 그냥 버려졌고 — 스트림이 끝나면 런은 화면에 아무것도 남기지 않고 종료됐다. 서버가 보낸 그 한국어 문장이 정작 그걸 봐야 할 사람에게 도달하지 못했다. ② 2xx인데 JSON이 아니면null을as T로 반환했다: 호출자는 몇 프레임 뒤 자기가 가졌다고 믿은 것의 필드에서 터진다. 둘 다responseFailure()하나로 모으고 경계에서 이름 붙여 던진다(메시지 우선순위: 서버error→statusText→HTTP <status>— HTTP/2엔 reason phrase가 없어 빈 배너가 되던 자리). ③ 조용한 삼킴 2곳:api.status실패는 이제 연결 주장만 철회한다(withoutConnectionClaim) — 낡은 상태를 그대로 두면 서버가 답을 멈춘 뒤에도 상단 바가 모델 이름을 계속 대고, 사람이 그걸 아는 첫 순간이 "시작되지 않는 런"이다. 반대로 상태 전체를 버리면 규칙 뷰의 어휘·refine 대화가 날아가므로 주장만 뗀다(두 속성 모두 테스트로 고정).api.reviewQueue실패는 의도적으로 삼킨다 — 배지 숫자는 판단이 아니고 리뷰 패널이 자기 로드 오류를 따로 보고하므로, 사유를 코드에 적어 두는 것이 옳은 처리다.(e as Error).message61곳은 손대지 않았다: 측정해 보니 이 앱의 거부값은 전부Error고(fetch·JSON.parse·playwright·new Error), 경계가 이제 그걸 보장한다 — 61곳을 헬퍼로 바꾸는 건 발동 0건짜리 변경이다. - M4 — Studio web이 린터 범위 밖 — 2026-08-03 해소(린트) / 포맷은 의도적으로 제외.
biome.json이 이제src/app/studio/web를 포함하며bun run lint가 64 → 98 파일을 검사한다(빌드 산출물dist만 제외 —vcs.enabled: false라 biome이.gitignore를 읽지 않아 번들이 2713건의 소음을 냈다). 첫 수확이 바로 이 부채가 말한 부류였다:DashboardPanel의 죽은 타입 import(CaseView). 나머지 20건은 전부 이 코드베이스가 의도적으로 위반하는 관용구였고(트리거로서의 추가 훅 의존성 7 · append-only 목록의 인덱스 키 6 · ANSI 스트리퍼의 제어문자 1 · hidden 패널 계약과 reduced-motion의!important5 ·role="group"1) 각 지점에 사유를 적어 억제했다 — 규칙을 끄지 않았으므로 새 위반은 여전히 잡힌다. 포매터는 web에서 끈다: 이 저장소는 CSS를 규칙당 한 줄로 쓰는데 biome CSS 포매터가 블록을 펼쳐panels.css하나에서 +1050줄이 났고, UI 자동 테스트가 없는 상태(M2)에서 3.4천 줄 재배치는 검증할 수 없는 변경이다. CSS 계통noDescendingSpecificity9건도 캐스케이드 재정렬이 필요해 같은 이유로styles/**에서 껐다 — M2가 생기면 다시 열 것. - L1 — 콘텐츠 기반 React key 비유일 — 2026-08-03 해소. chat은 그 사이 인덱스 키로 바뀌어 있었고(append-only라 안정), 남아 있던 절반은
RulesPanel의 warning을 자기 텍스트로 키잉한 것 — 엔진이 같은 문구를 두 번 올리면 형제 key가 충돌한다. biome은 이걸 잡지 못한다(인덱스 키가 아니므로).${index}:${warning}으로 고쳤다. - L2 — 모달 닫기 요청이 여러 번 나간다 — 2026-08-03 해소. 노트는 경로가 둘이라 했지만 셋이었다: 전역
keydown, 네이티브cancel(showModal()에서 Escape가 올린다), 그리고 정리 단계의dialog.close()가 부르는 네이티브close— 마지막 것은 언마운트 중에 부모에게 닫으라고 다시 말한다. 새 컴포넌트 테스트로 실측했다: Escape 한 번 + 배경 클릭 한 번에onClose가 3회. 경로를 지우는 대신(어느 경로든 어떤 브라우저에서는 옳은 경로다) 한 마운트당 한 번만 통과하는 단일 게이트(requestClose)를 두고, 자기 정리에서만 올 수 있는 요소onClose핸들러는 뺐다. 마운트된 모달은 한 번만 닫히면 되고, 제거는 부모가 한다. - L3 — Studio chrome 전체 리마운트가 프로젝트 검색을 초기화 — 2026-08-03 해소 확인(코드가 사라짐).
App은 더 이상StudioChrome에key를 주지 않으므로projectQuery가 프로젝트 전환에도 살아남고, 시트 검색 리셋은useEffect(..., [selectedProjectId])가 담당한다 — 부채가 권고한 "시트 검색 상태만 스코프"가 이미 그 모양이다. M1과 같은 재작성에 함께 실려 갔다. - E1 — 사전조건이 서술하는 데이터 상태를 준비가 만들지 못한다 (2026-07-30 관측) — 2026-07-31 일부 해소. 엔진은 이제 이 구멍을 조용히 통과시키지 않는다: 필드 이름을 딴 컨트롤(
<필드> 중복확인)을 그 칸이 빈 채 눌렀고 착지한 값도 없으면 사전조건 미충족으로 보류하고 이유를 한 줄로 적는다("눌렀지만 이 칸이 비어 있습니다"). false-fail이 하나 줄었고, 리뷰어는 앱이 아니라 시트의 준비를 봐야 한다는 걸 즉시 안다. 남은 부분은 그대로다 — 엔진이 그 값을 만들어 주지는 않는다. 값을 지어내는 건 dev 환경에 데이터를 심는 것과 종이 한 장 차이고, "무엇이 유일한 값인가"는 앱마다 다르며, 판정 층을 넓히는 건 이 저장소가 두 번 되돌린 방향이다. 반대 상태를 요구하는 쌍(중복 존재/미존재)은 결국 시트가 값을 주거나, 준비가 데이터를 만들 권한을 명시적으로 받아야 풀린다.
2026-07-21 이후 별도 폴리시 패스에서 컨트롤 높이(40/32/28), 버튼 타이포(15/12px·weight 650), 헤더·컨텍스트 스트립·본문의 콘텐츠 컬럼 정렬(스크롤바 폭 보정 포함)을 토큰화해 통일했다. 세부는
DESIGN.md와styles/tokens.css·shell.css참조.
브랜딩/에셋: 브랜드 마크·파비콘은
assets/logo.png(파비콘) 및 투명 배경logo-mark.png(topbar)를 쓰고, 환영 화면 좌측 히어로는logo-forged.png(라임 글로우)를 쓴다. 세 에셋은 VitepublicDir을 프로젝트 루트assets/로 지정해 단일 소스로 유지하며 빌드 시dist/루트로 복사된다.logo-mark.png는logo.png의 검정 배경을 알파 키잉(max-channel 임계 + 안티앨리어싱)으로 제거한 버전이다.
i18n(전역): UI 크롬 전체를 KO/EN 토글로 전환한다 — 상단 바
header-actions의LangToggle, 공유useLang()/setLang()스토어(src/i18n.ts,localStorage: to_lang, 기본 한국어). 번역은 컴포넌트 인접(co-located) 딕셔너리(const S = {ko,en}; const t = S[useLang()])로 두어 중앙 카탈로그 없이 유지한다. 엔진 생성 문구는 구조화 데이터로 로컬라이즈: assertion detail은kind+value로 KO 재구성(EN은 엔진 canonical), self-heal 접두어·verdict 라벨(vLabel)도 언어별. 비대상(시트 언어를 따름): 프로젝트/시트 이름, 케이스 제목·스텝·기대결과·따옴표 값, 서버 생성 리뷰 사유. 구(舊) 실행 데이터는 assertionkind/value가 없어 EN detail로 폴백.
라우팅: SPA에 경로 기반 history 라우팅(
/p/{projectId}/{sheetId}/{tab}, dash는 생략). state↔URL을pushState/popstate로 동기해 새로고침·딥링크·뒤로/앞으로를 지원한다. Vitebase: "/"(절대 경로)로 어떤 딥경로에서도 에셋/파비콘이 사이트 루트에서 로드되고, Studio 서버의 기존 정적 SPA fallback(파일 없으면 index.html)이 딥링크를 받는다. 라이브러리 없이App.tsx의readRoute()/routePath()로 구현.
AI 어시스트 컨텍스트(T1+T2, 2026-07-22): 사람이 in-system으로 준 힌트가 AI 플랜 작성에 도달하도록 연결. T1 —
getOrAuthorPlan이 rule을authorPlanAI까지 forward해, 사람이 refine으로 가르친 의도 어휘(intents)·destructiveKeywords를 플랜 프롬프트의 "step vocabulary" 힌트로 주입(이전엔 AI 켜면 규칙이 무시됐음). T2 — 시트별 자유서술appContext(규칙에 저장,setRuleContext가 ruleVersion을 bump → 플랜/assertion 캐시 무효화 → 재작성)를 규칙·해석 뷰에서 편집(POST /api/rule/context)하고authorPlanAI프롬프트에 도메인 컨텍스트로 주입. 목적: 애매한 "천차만별" TC도 외부 AI 없이 in-system 어시스트로 이해도를 높임.T3(추후 고민): 케이스 단위 AI 플랜 교정 루프 — 특정 TC의 플랜이 틀렸을 때 규칙처럼 대화형으로 "이 케이스는 '로그인'을 눌러"라고 고치거나, 시트에 per-case 힌트 열을 두는 것. 데이터모델·UI(케이스별 플랜 표시/편집) 확장이 필요해 규모가 큼. 지금은 T1+T2로 프로젝트/시트 레벨 어시스트까지만 커버. 맥락 자동화(2026-07-22): T2의
appContext/신규codeContext를 사람이 직접 타이핑하지 않고 자동 초안으로 채우는 두 액션을 규칙 뷰에 추가. 라이브 정찰(reconApp,POST /api/app/analyze) — 시트 계정으로 로그인 시도 + 페이지 구조(nav·폼·버튼·표) 결정적 스캔 → 모델이 한국어 도메인 브리프로 축약 →appContext초안. 레포 코드 맥락(repo-recon,POST /api/repo/analyze) — 참고 repo를 로컬/캐시/shallow clone(+토큰·refresh)로 확보 → 파일 스캔(AGENTS.md·README·라우트·컴포넌트) + 옵션 CodeGraph →codeContext초안. 둘 다 author-time이라 결정적 실행경로 불변, 사람이 검토 후 저장. 원칙: AI 산출물은 "보류 가능"(초안)으로 두고 사람이 확정한다.트레이스 증거(2026-07-22): 리뷰 큐 증거를 스크린샷 1장에서 Playwright 트레이스로 확장. 실행 시 케이스별 chunk로 캡처하되 needs_review/error만 보존(깨끗한 pass는 discard — "증거는 조치 필요한 것만"). 뷰어는 playwright 번들 트레이스 뷰어(
node_modules/playwright-core/lib/vite/traceViewer)를 Studio 서버가 동일 오리진으로 서빙(/trace-viewer+/api/trace)해 리뷰 뷰에 iframe 임베드 — 공개trace.playwright.dev가 localhost를 fetch하다 막히는 Private Network Access 문제를 회피.
이 절은 왜 단일 PC 실행에 머물지 않는가와, 아직 씨앗 단계인 분산 실행의 목표 그림을 남긴다. "지금 구현된 것"과 "다음 단계(roadmap)"를 명확히 구분한다 — 여기 적힌 fleet은 목표지 완성이 아니다.
"큰 과제만 두둥실 띄워 놓고 아무도 팔로업하지 않는" 상태를 피하고 싶었다. 사람이 목적(purpose) 만 넘기면 시스템이 조용히 목표(goal) 를 수행하고 결과만 취합해 오는 것 — 그게 이 도구의 존재 이유다. 판정을 결정적으로 묶어 둔 것도 같은 맥락이다: 결과가 기계마다·실행마다 흔들리지 않아야 여러 대에서 나온 결과를 믿고 합칠 수 있다. 결정성은 오케스트레이션의 전제 조건이다.
- 회사·부서마다 이름이 다른 시트(테스트시트·QA시트·회귀표 …)를 그대로 읽어 자동 실행한다. 포맷·명칭에 종속되지 않는 인테이크가 전제.
- 웨이포인트(호스트 에이전트) 한 곳이 잡을 쥐고, 여러 PC·환경이 peer로 붙어 병렬 실행하고 결과를 되돌려 취합한다. 한 대에 갇힌 실행이 아니라, 가진 만큼의 머신을 fleet으로 쓴다.
- 어디서든 동작한다. 회사 폐쇄망이든 개인 개발 환경이든, 외부 클라우드 없이 로컬 네트워크만으로 host↔peer가 붙는다. "모델 없으면 규칙 해석으로 soft-fallback"과 같은 없으면 없는 대로 도는 원칙을 인프라에도 적용 — 인터넷·중앙 서버가 없어도 성립해야 한다.
- 같은 아티팩트가 곧 peer다.
worker.ts는 러너를POST /run(+GET /health)으로 노출한다 — 한 노드가 독립 실행도, host 구동 워커도 된다(serveWorker). 별도의 클라이언트/서버 바이너리가 없고, 잡은 순수 직렬화 JSON(WorkerJob = { tc, rule, env, executionId? })이다. - 경계는 하나의 계약.
runScenario(...) → StructuredResult.host.ts의Dispatch는 in-process(inProcessDispatch)든 HTTP(httpDispatch)든 동일한 StructuredResult를 돌려주고, 오케스트레이션 계약 테스트(test/orchestration.test.ts)가 프로세스 경계를 넘어 이를 검증한다. - 취합.
runScenarios(jobs, dispatch, {concurrency})가 bounded 병렬로 디스패치하고, 입력 순서를 보존한 채 verdict별로 집계(Aggregate)한다.
- peer 풀 팬아웃. 현재
runScenarios는 dispatch 하나(=워커 1대 또는 in-process)에 대해 병렬이다. 여러 워커 URL을 풀로 묶어 잡을 머신들에 분산하는 pool dispatch(헬스체크·재시도·느린/죽은 노드 회피)가 fleet의 실제 진입점이다. - 디스커버리/등록. peer가 host에 자기 환경(
env)과 여력을 알리고 붙는 등록 흐름. 폐쇄망 우선(로컬 네트워크 발견 또는 명시적 host URL). - 환경 라우팅. 잡의
env(브라우저·해상도·계정·베이스 URL)에 맞는 peer로 라우팅해, 이기종 환경 매트릭스를 한 번에 돌린다.
아직 확보한 실제 테스트 시트가 많지 않아 파일럿을 넉넉히 돌려보지 못했다. 검증된 지점은 host/worker 계약 + 단일 dispatch 병렬 실행까지고, peer fleet은 목표 그림이다. 조급히 규모를 쫓기보다, 시트를 늘려 가며 키운다. 누군가에겐 아직 불완전한 "밤티" 같은 에이전트로 보일 수 있지만, 목표는 화려함이 아니라 폐쇄망이든 개인 환경이든 어디서나 조용히 목표를 수행하고 결과를 돌려주는 것이다.