58 KiB
직접 편집(시각 편집기) 1.8.0 — TDD RED→GREEN 기록
- 작업 트리:
/home/master/work/WebHoting_AI_Agent-visual-editor-v180, 브랜치feature/visual-editor-v1.8.0, 기준origin/main 3b10859884ef2fd0f2af11934ab6c16370fd64b8 - 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·배포 없음(Neo 검토 전 구현 인도)
- 아래 출력은 실제 실행 결과에서 그대로 옮겼다(요약을 위해 스택 줄만 줄임). 실행 때
CLAUDE_BIN은 바로 실패하는 가짜 실행 파일로 두어 개발 PC 의 진짜claude가 사이트 생성 작업으로 실행되지 않게 했다(첫 RED 실행에서는 그 설정이 없어 진짜 claude 프로세스가 하나 시작되었고 즉시 종료시켰다 — 이 때문에 두 테스트 파일 모두 가짜 실행 파일을 쓰도록 고쳤다).
조각 1·2 — 서버 순수 모듈(lib/visual-editor.js)·공용 핵심(public/visual-editor-core.js)
RED — node --test test/visual-editor.test.js (모듈이 없음)
Error: Cannot find module '/home/master/work/WebHoting_AI_Agent-visual-editor-v180/lib/visual-editor'
Require stack:
- /home/master/work/WebHoting_AI_Agent-visual-editor-v180/test/visual-editor.test.js
ℹ pass 0
ℹ fail 1
중간(모듈 구현 뒤 첫 실행) — 12/15 통과, 3 실패: ① 테스트의 정규식이 보존 속성 data-wm-ve-href="javascript:…" 까지 잡아 /\shref="javascript:/ 로 고침, ② restoreFromCanvas 가 덧씌운 UI 요소 안의 글자("삭제")를 남겨 범위 제거(stripUiElements)로 구현 수정, ③ 런타임 파일(public/wm-ve-canvas.js·public/visual-editor-ui.js) 미존재(조각 4 에서 해결).
GREEN — node --test test/visual-editor.test.js
✔ splitDocument: 머리(doctype·head·body 여는 태그)·본문·꼬리로 나눈다
✔ prepareCanvas: 페이지 스크립트·핸들러·javascript: 링크·iframe 을 모두 비활성화하고 CSP nonce·base·런타임을 넣는다
✔ restoreFromCanvas: 편집기 표식·덧씌운 요소를 지우고 보존 속성을 되돌린다
✔ validateVisualSave: 글만 고친 본문은 머리·doctype·스타일·스크립트를 그대로 둔 채 합쳐진다
✔ validateVisualSave: 새 스크립트·이벤트 속성·위험 주소·금지 요소·스타일 표현식·구조 태그·크기·형식 오류를 거절한다
✔ validateVisualSave: 원래 있던 스크립트·iframe 을 지우는 것은 허용(구성 요소 삭제)되고 숫자가 늘면 거절
✔ validateVisualSave: body 가 없는 문서는 거절하고 머리 없는 조각은 본문만 저장한다
✔ contentHash·isHtmlPath
✔ core: 브라우저 전역·Node 모듈 양쪽에서 로드되고 런타임 파일은 문법 검사를 통과한다
✔ core.createHistory: 실행 취소·다시 실행·새 변경 뒤 다시 실행 지움·한도
✔ core.createSaveState: 저장 안 됨 → 저장 중 → 저장됨/오류 전이와 한국어 표시
✔ core.validateMessage: 보낸 창·출처·세션·형식·종류를 모두 확인해야 통과한다
✔ core.COMPONENTS: 필수 구성 요소가 있고 각 HTML 은 스크립트·핸들러·위험 주소가 없다
✔ core.moveIndex·dropIndexFor: 재배치 계산
✔ core.safeUrlInput·propsFor·labelFor: 속성 편집 규칙
ℹ tests 15
ℹ pass 15
ℹ fail 0
조각 3·4 — 서버 경로(server.js)·화면(index.html·style.css·app.js·visual-editor-ui.js·wm-ve-canvas.js)
RED — node --test test/visual-editor-server.test.js (경로가 없어 404, 화면에 버튼 없음)
✖ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절
✖ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절
✖ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다
✖ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존
✖ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400
✖ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버)
✖ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다
ℹ pass 0
ℹ fail 7
actual: 404,
expected: 200,
중간 1 — 4/7 통과: 저장·오래된 저장 테스트가 "AI 작업 실행 중" 409 를 받음 → 사이트 생성이 띄운 AI 작업이 진짜 claude 로 실행되고 있었다(개발 PC PATH). 테스트를 가짜 실행 파일(CLAUDE_BIN, 즉시 exit 1)로 바꾸고 작업 종료를 기다리게 수정. 화면 테스트의 반응형 정규식([^}]* → [^@]*) 수정.
중간 2 — 6/7: 오래된 저장 테스트가 baseHash:'deadbeef'(형식 오류 400)를 보냄 → 64자리 16진으로 수정. 그 뒤 JSON 파싱 실패가 500 으로 나와 서버 수정(직접 편집 PUT 은 본문 읽기 실패를 400 한국어 안내로).
GREEN — node --test test/visual-editor-server.test.js
✔ 권한·출처: 소유자만, 다른 사용자는 404, 로그인 없이는 401, 출처(Referer)·CSRF 없는 fetch 는 거절 (1266ms)
✔ 허용 페이지: HTML 만, 경로 탈출·링크·없는 파일 거절 (24ms)
✔ 캔버스 문서 계약: 페이지 스크립트·핸들러는 실행되지 않는 형태, CSP nonce·편집 런타임·base·세션이 있고 GET /files 는 hash 를 준다 (11ms)
✔ 저장: 파일 갱신·버전 기록(visual-edit)·미리보기 목록·캐시 미차감·AI 수정 횟수·명령 기록 미증가·표식 제거·머리 보존 (138ms)
✔ 오래된 저장(다른 창이 먼저 저장) 은 409 로 거절하고 최신 hash 를 알려 준다 · 잘못된 본문은 400 (147ms)
✔ AI 작업 실행 중에는 저장이 409 로 거절된다 (가짜 claude 실행 파일로 작업을 멈춰 둔 서버) (510ms)
✔ 화면: 직접 편집 전환·저장 상태·저장/실행 취소/다시 실행/버리기·팔레트·속성 창이 접근성 속성과 함께 있고 런타임 파일이 서빙된다 (22ms)
ℹ pass 7
ℹ fail 0
ℹ duration_ms 2255.904911
전체 묶음 — CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 74
ℹ pass 74
ℹ fail 0
ℹ duration_ms 90188.973409
(원본 1.7.3 의 52개 + 새 22개. 기존 테스트 변경 없음.)
요구 ↔ 테스트 대응
| 요구 | 테스트 |
|---|---|
| 권한(소유자만)·다른 사용자 404·로그인 401 | server: 권한·출처 |
| CSRF/출처(Referer 없는 fetch·생성 사이트 페이지 출처 거절) | server: 권한·출처 |
| 허용 페이지(HTML 만·경로 탈출·링크·없는 파일) | server: 허용 페이지 · unit: isHtmlPath |
| 낙관적 동시성/오래된 저장 409 + currentHash | server: 오래된 저장 |
| 안전한 정화·규약(스크립트·핸들러·위험 주소·금지 요소·스타일 표현식·구조 태그·표식 제거·머리 보존) | unit: prepareCanvas·restoreFromCanvas·validateVisualSave ×4 · server: 캔버스 계약·저장 |
| 스크립트 비실행 계약(disabled type·CSP nonce·보존 속성·세션·부모 출처) | unit: prepareCanvas · server: 캔버스 계약 |
| 저장이 버전 이력(visual-edit)을 만들고 캐시·AI 명령에 세지 않음 | server: 저장 |
| 실행 취소/다시 실행 핵심 | unit: core.createHistory |
| 끌어 옮기기/구성 요소 삽입 핵심(재배치 계산·구성 요소 안전) | unit: core.moveIndex·dropIndexFor·COMPONENTS |
| 저장 상태/이동 보호 | unit: core.createSaveState · server: 화면(상태 aria-live·버튼) · app.js 연결(정적) |
| 필수 접근성 조작 | server: 화면 |
| AI 작업 중 거절 | server: AI 작업 실행 중 |
| 잘못된 payload·크기 | server: 오래된 저장(400/413) · unit: validateVisualSave |
검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화)
지적 ①(차단): 직접 편집 PUT 이 await readBody(req) 앞에서 파일을 읽어 hash 를 잡아 두었다. 두 요청이 본문을 읽는 동안 나란히 들어오면 둘 다 같은 hash 를 통과해 둘째가 첫째를 조용히 덮어쓴다. 지적 ②(강화): fs.writeFileSync 로 운영 HTML 을 직접 덮어써 쓰기 실패·중단 때 마지막 정상 페이지가 잘릴 수 있다.
RED — 새 테스트 3건 추가 뒤 실행
node --test test/visual-editor.test.js(helper 없음)
✖ writeTextAtomic: 같은 폴더의 임시 파일(0644)에 쓴 뒤 rename 하고, 실패하면 임시 파일을 지우고 원본을 건드리지 않는다
TypeError [Error]: ve.writeTextAtomic is not a function
ℹ pass 15
ℹ fail 1
node --test test/visual-editor-server.test.js— 경쟁 테스트는 chunked PUT 두 개의 머리+본문 앞 12바이트만 보내 둔 채 400ms 기다려(두 처리기가 본문 읽기에서 멈춤) A 를 끝내고 200 을 받은 뒤 B 를 끝낸다. 원자성 테스트는 사이트 폴더를 0555 로 바꿔 임시 파일을 만들 수 없게 한다(기존 파일 직접 덮어쓰기는 이 조건에서도 "성공" 한다).
✖ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다
AssertionError [ERR_ASSERTION]: 둘째는 오래된 저장: {"path":"index.html", … "change":{"no":2,"kind":"visual-edit", …}}
actual: 200,
expected: 409,
✖ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다
actual: 200,
expected: 500,
ℹ pass 7
ℹ fail 2
(둘째 요청이 200 을 받고 파일에 "둘째-B" 가 남아 첫 저장이 사라졐음을 확인 — 재현 성공.)
수정
lib/visual-editor.jswriteTextAtomic(absPath, content, {fs, mode}): 같은 폴더의.<이름>.<pid>.<무작위>.tmp에 쓰고(0644, umask 와 무관하게 chmod) rename. 실패하면 임시 파일을 지우고(정리 실패는 무시) 오류를 그대로 던진다. 파일 시스템 함수를 주입할 수 있어 rename 실패·쓰기 실패 경로를 결정적으로 검증한다.server.js직접 편집 PUT: 본문을 다 읽은 뒤store.load로 사이트 기록을,fs.readFileSync로 파일을 다시 읽어 hash 를 비교하고, 검증·원자적 쓰기까지 await 없이 이어 간다. 쓰기 실패는 500 + "페이지를 저장하지 못했습니다 (…). … 기존 페이지는 그대로 있습니다". GET 은 바뀌지 않았다. 소스 편집기(PUT /files)의 기존 쓰기는 손대지 않았다.- 중간 실행: helper 가 0644 를 돌려주지 않아(테스트 프로세스 umask 077 → 0600) chmod 를 추가.
GREEN
node --test test/visual-editor.test.js
ℹ tests 16
ℹ pass 16
ℹ fail 0
node --test test/visual-editor-server.test.js
✔ 동시 저장 경쟁: 두 PUT 이 본문을 다 보내기 전에 함께 들어와도 둘째는 409 — 첫 저장이 조용히 덮어써지지 않는다 (655ms)
✔ 원자적 저장: 임시 파일을 만들 수 없는 폴더(읽기 전용)에서는 500 과 한국어 안내, 기존 페이지는 그대로이고 임시 파일도 남지 않는다 (243ms)
ℹ tests 9
ℹ pass 9
ℹ fail 0
전체 묶음(수정 뒤): 아래 "전체 묶음 — 검토 수정 뒤" 절.
전체 묶음 — 검토 수정 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 77
ℹ pass 77
ℹ fail 0
ℹ duration_ms 90193.018088
(원본 52 + 직접 편집 25. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 84 · pass 84 · fail 0 — 0.1.1 의 ariweb.test.js 7건 포함.)
실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단)
발견(실제 브라우저, 독립 Webmaker 코드): 1280×633 에서 직접 편집을 켜도 .app 이 레일 232px + 작업 칸 .left 460px + 캔버스 세 칸 그대로였고 .ve-palette·.ve-inspector 가 각 240px 이라 iframe 실제 폭이 114.625px, 한국어 제목이 한 글자씩 줄바꿈되었다. 390×844 에서는 .left 가 그대로 보여 캔버스가 y≈2228 로 밀려 옛 작업 폼을 다 지나야 편집기가 나왔다.
원인: visual-editor-ui.js 는 켤 때 body.ve-on 을 넣고 끌 때 빼지만, style.css 에 그 class 로 작업 칸을 숨기거나 .app 격자를 다시 짜는 규칙이 없었다(.ve-on 은 전환 버튼 색에만 쓰였다).
RED — node --test test/visual-editor.test.js (새 계약 테스트 3건 추가 뒤; CSS 파서가 선택자·속성값을 읽는다 — 주석 문자열은 보지 않는다)
✖ style.css 계약: body.ve-on 이면 옛 작업 칸(.left)을 숨기고 .app 을 레일 + 전체 폭 캔버스로 바꾼다 — 1280px 에서 캔버스 폭 ≥ 500px
AssertionError [ERR_ASSERTION]: 직접 편집 중 옛 작업 칸 숨김
✖ style.css 계약: 900px 이하에서 body.ve-on 은 레일 바로 아래에 캔버스를 두고(옛 작업 칸 행 없음) 팔레트·속성 창은 캔버스 아래에 쌓인다
AssertionError [ERR_ASSERTION]: 좁은 화면 body.ve-on .app 규칙
✔ visual-editor-ui.js 동작: open() 이 body.ve-on 을 켜고 패널·도구 줄을 보이며, close() 가 모두 되돌리고 미리보기를 다시 읽는다
ℹ tests 19
ℹ pass 17
ℹ fail 2
세 번째 테스트(가짜 DOM 위에서 visual-editor-ui.js 를 실행해 open()/close() 가 body.ve-on·패널·aria-pressed·srcdoc 을 켜고 되돌리는지)는 RED 단계에서 이미 통과 — JS 전환은 맞고 CSS 규칙만 빠졌다는 진단이 테스트로 확인되었다.
수정(public/style.css, 두 저장소 동일)
body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0, 1fr); }
body.ve-on .left { display: none; }
@media (max-width: 1180px) { body.ve-on .ve-side { flex-basis: 190px; } }
@media (max-width: 900px) { … body.ve-on .app { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } body.ve-on .right { min-height: 0; } }
계산: 1280px → 캔버스 1280 − 232 − 240×2 = 568px(양쪽 패널 펼침). 1100px(레일 204px) → 1100 − 204 − 190×2 = 516px. 390px → 레일(가로 띠) 다음 행이 바로 캔버스(frame-wrap order 1, min-height 40vh), 팔레트·속성 창은 아래에 쌓이고 기본 접힘. 끝내면 body.ve-on 이 빠져 원래 var(--rail-w) var(--left-w, 460px) 1fr 세 칸이 그대로 돌아온다(작업 칸 DOM 은 건드리지 않는다).
GREEN — node --test test/visual-editor.test.js (독립 저장소 / Suite modules/webmaker 모두)
ℹ tests 19
ℹ pass 19
ℹ fail 0
전체 묶음(수정 뒤): 아래 "전체 묶음 — 배치 수정 뒤".
전체 묶음 — 배치 수정 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 80
ℹ pass 80
ℹ fail 0
(원본 52 + 직접 편집 28. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 87 · pass 87 · fail 0. Suite 전체 node test/run.mjs 결과는 Suite 의 docs/verification/webmaker-visual-editor-0.2.0.md.)
실제 브라우저 시각 검증 수정 2(Neo 의 배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단)
발견(실제 브라우저): 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 .wm-ve-tools 가 페이지 위에 이미 그려져 있었다. 속성 창은 "선택한 요소 없음", DOM 은 tools.hidden = true 였다.
원인: 캔버스 런타임(wm-ve-canvas.js)이 끼워 넣는 작성자 규칙 .wm-ve-tools{…display:flex…} 가 사용자 에이전트 규칙 [hidden]{display:none} 을 덮어쓴다(작성자 origin 은 명시도와 무관하게 UA 를 이긴다). 그래서 hidden 속성이 있어도 flex 로 보였다. renderOverlay() 가 hidden=false 로 바꾸기 전까지 보이지 않아야 하고, 선택 해제·미리보기·applyHtml 뒤 다시 숨겨져야 한다.
RED — node --test test/visual-editor.test.js (새 계약 테스트 1건 추가 뒤). 테스트는 런타임 소스의 CSS 블록(st.textContent)을 꺼내 파싱하고, hidden 인 .wm-ve-tools·.wm-ve-drop 요소의 최종 display 를 cascade 모형(!important > [hidden] 명시도 > 나중 선언, 작성자 규칙이 없으면 UA 의 none)으로 계산한다 — 주석 문자열은 보지 않는다.
✖ wm-ve-canvas.js CSS 계약: hidden 인 선택 도구 줄(.wm-ve-tools)·놓기 표시(.wm-ve-drop)는 작성자 CSS 가 다시 보이게 만들 수 없다
AssertionError [ERR_ASSERTION]: hidden 인 .wm-ve-tools 의 최종 display
actual: 'flex',
expected: 'none',
ℹ tests 20
ℹ pass 19
ℹ fail 1
수정(public/wm-ve-canvas.js 런타임 CSS, 두 저장소 byte-identical) — 한 줄 추가, 다른 동작 변경 없음
.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}
hidden 을 켜고 끄는 JS(renderOverlay·끌기 시작·showDrop·dragleave)는 그대로이며, 보일 때의 display:flex 도 그대로다(테스트가 함께 확인).
GREEN — node --test test/visual-editor.test.js (독립 저장소 / Suite modules/webmaker 모두)
ℹ tests 20
ℹ pass 20
ℹ fail 0
전체 묶음 — 도구 줄 수정 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 81
ℹ pass 81
ℹ fail 0
(원본 52 + 직접 편집 29. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 88 · pass 88 · fail 0. Suite 전체 node test/run.mjs 결과는 Suite 의 docs/verification/webmaker-visual-editor-0.2.0.md.)
실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단)
발견(실제 Chrome, Playwright frameLocator 로 캔버스 iframe 을 직접 조작): 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시까지 19/19 확인 통과. 그러나 부모 팔레트의 "문단" 을 캔버스에 실제 HTML5 끌어다 놓기 하면 아무것도 들어가지 않았다(시간 안에 되지 않음: 끌어다 놓기 뒤 요소 수 증가).
원인: 부모 dragstart 는 전용 MIME text/wm-ve-component 만 넣었고, 캔버스는 sandbox="allow-scripts"(allow-same-origin 없음) 의 출처 'null' 문서다. Chromium 은 다른 출처가 넣은 전용 MIME 끌기 자료를 이 경계 너머로 넘겨주지 않는다(종류 목록에서도 사라진다) → 캔버스의 dragover/drop 이 끌기를 인식하지 못했다. 하네스 한계가 아니라 제품 결함.
설계(요구대로): 전용 MIME 은 유지하고, 부모가 표준 text/plain 에 이름 공간 값 wm-ve-component:<검증된 id> 를 함께 넣는다. 캔버스는 안전한 추출을 한곳(core.dragComponentId)에 모아 전용 MIME 을 우선 읽고, 없으면 text/plain 이 그 형식에 정확히 맞고 core.COMPONENTS 의 알려진 id 일 때만 받는다. dragover 때는 브라우저가 getData 를 막으므로 종류에 전용 MIME 또는 text/plain 이 있으면 끌기로 인식해 놓기 표시를 보인다(core.isComponentDrag). drop 때 id 가 틀리거나 임의 외부 글이면 아무것도 넣지 않고 표식도 남기지 않는다. sandbox(allow-same-origin 추가 없음)·CSP·메시지 검증·저장 검증·"추가" 버튼 대안은 그대로.
RED — node --test test/visual-editor.test.js (새 테스트 3건 추가 뒤)
✖ core.dragComponentId·isComponentDrag·componentDragText: 전용 MIME 이 없어도 이름 공간이 붙은 text/plain 만 정확히 받고, 임의 글·모르는 id 는 거절한다
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: actual: undefined, expected: 'text/wm-ve-component'
✖ visual-editor-ui.js 팔레트 dragstart: 전용 MIME 과 함께 이름 공간이 붙은 text/plain 을 넣는다(sandbox iframe 으로 넘어가는 표준 종류)
TypeError [Error]: core.componentDragText is not a function
✖ wm-ve-canvas.js 동작: 전용 MIME 이 sandbox 경계에서 사라져도 이름 공간이 붙은 text/plain 으로 구성 요소 하나를 넣고, 임의 text/plain·모르는 id 는 아무것도 넣지 않는다
AssertionError [ERR_ASSERTION]: dragover preventDefault(놓기 허용) actual: false, expected: true
ℹ tests 23
ℹ pass 20
ℹ fail 3
세 번째는 동작 테스트다: 작은 가짜 DOM(요소 트리·속성·classList·innerHTML 파서/직렬화·querySelectorAll/closest·getBoundingClientRect·template.content·elementFromPoint·parent.postMessage)을 만들어 실제 wm-ve-canvas.js 를 vm 에서 부트하고, 전용 MIME 이 없는 DataTransfer(types: ['text/plain'], drop 때 text/plain = 'wm-ve-component:paragraph')로 dragover/drop 이벤트를 쏜다. 확인: dragover 가 기본 동작을 막고 놓기 표시를 보임 → drop 뒤 본문에 문단 하나가 h1 앞에 들어가고 선택되며 부모에 changed 본문(표식 없음)이 한 번 감 → 임의 글(https://…, <img … onerror>)·모르는 id(wm-ve-component:evil)·정확히 맞지 않는 값('…paragraph ', 'paragraph')은 본문 불변·표식 없음·changed/error 없음(브라우저 기본 drop 은 막음) → 전용 MIME 이 남아 있으면 그 값 우선(heading → h2) → 파일/HTML 만 있는 끌기는 dragover/drop 모두 건드리지 않음. (가짜 DOM 의 innerHTML 파서가 자식 목록 참조를 그대로 돌려줘 둘째 요소가 빠지던 테스트 자체 결함을 먼저 고쳤다 — RED 가 "진짜 이유" 로 나도록.)
수정(두 저장소 byte-identical)
public/visual-editor-core.js:COMPONENT_MIME,componentDragText(id),isComponentDrag(types),dragComponentId(dt)추가·export(한국어 주석에 이유).public/visual-editor-ui.js팔레트 dragstart:setData(core.COMPONENT_MIME, id)+setData('text/plain', core.componentDragText(id)).public/wm-ve-canvas.jsdragover/drop:core.isComponentDrag(e.dataTransfer.types)로 인식, drop 에서core.dragComponentId가 null 이면 놓기 표시만 숨기고 반환(기본 drop 차단, 삽입 없음). drop 도mode === 'edit'에서만.
GREEN — node --test test/visual-editor.test.js (독립 저장소 / Suite modules/webmaker 모두)
ℹ tests 23
ℹ pass 23
ℹ fail 0
전체 묶음 — 끌어다 놓기 수정 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 84
ℹ pass 84
ℹ fail 0
(원본 52 + 직접 편집 32. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 91 · pass 91 · fail 0. Suite 전체 node test/run.mjs 결과는 Suite 의 docs/verification/webmaker-visual-editor-0.2.0.md.)
실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단)
발견: MIME 보완(게이트 3 수정) 뒤 같은 게이트를 다시 돌렸는데 여전히 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 window 에 살균 리스너(종류·types·defaultPrevented·text/plain·전용 MIME 존재 여부·이름 공간 일치 여부만, 실제 자료는 기록하지 않음)를 붙여 확인한 결과: 부모 dragstart 2건(text/plain·전용 MIME 모두 존재·형식 유효), 캔버스 dragenter 0·dragover 0·drop 0. 즉 Chromium 은 HTML5 끌기 자체를 부모 문서에서 출처 'null' sandbox iframe 으로 넘기지 않는다 — dataTransfer 종류 보완으로는 해결할 수 없는 제품 결함.
설계(요구대로, sandbox 는 그대로 — allow-same-origin 추가 없음): 부모(visual-editor-ui.js)가 팔레트 항목의 포인터(마우스·펜)를 직접 추적한다. pointerdown("추가" 버튼 위는 제외, 터치·보조 버튼 제외) → 포인터 캡처 → core.POINTER_DRAG_THRESHOLD(4px) 넘게 움직이면 끌기 시작(표시 class) → iframe 위에서도 캡처로 pointermove 를 받아 #frame.getBoundingClientRect()(테두리·스크롤바 보정 clientLeft/Top/Width/Height)와 비교 → 안쪽이면 검증된 명령 paletteDragAt { component, x, y }(iframe 상대 좌표), 밖으로 나가면·Escape·pointercancel·끝내기면 paletteDragCancel → 안에서 pointerup 이면 paletteDropAt { component, x, y }. 캔버스(wm-ve-canvas.js)는 메시지 규약(보낸 창·출처·세션·종류)을 통과한 뒤 core.isComponentId·core.canvasPoint(유한 숫자·자기 뷰포트 안)·편집 모드를 다시 검사하고, 스스로 dropTargetAt → showDrop / insertComponent 를 한다(부모는 DOM 위치를 지정하지 않는다). 잘못된 id·좌표·모드는 아무것도 넣지 않고 놓기 표시를 숨긴다. HTML5 DnD 는 포인터 이벤트가 없는 환경의 대체 경로로 남기되, 포인터 끌기 중에는 네이티브 dragstart 를 막아 pointercancel 로 끊기지 않게 한다. CSS 는 .ve-comp { user-select:none; touch-action:pan-y }·끌기 중 class 만 추가(터치 스크롤·"추가" 버튼·키보드·메시지·CSP·저장 검증 불변).
RED — node --test test/visual-editor.test.js (새 테스트 3건 추가 뒤)
✖ core 포인터 끌기 규약: 메시지 종류·문턱·iframe 상대 좌표 계산(테두리 보정)·캔버스 좌표 검증
AssertionError [ERR_ASSERTION]: paletteDragAt 는 부모→캔버스 종류
✖ visual-editor-ui.js 포인터 끌기 다리: 팔레트 항목 pointerdown → 문턱 뒤 이동 추적 → iframe 안에서만 상대 좌표 paletteDragAt, 밖으로 나가면 paletteDragCancel, 안에서 pointerup 이면 paletteDropAt; "추가" 버튼·Escape·pointercancel 처리
AssertionError [ERR_ASSERTION]: pointerdown/move/up 처리기
✖ wm-ve-canvas.js 동작: paletteDragAt 이 놓기 표시를 보이고, paletteDropAt 이 계산한 자리에 구성 요소를 넣어 표식 없는 changed 를 한 번 보내며, 잘못된 id·좌표·미리보기 모드는 아무것도 하지 않고 표시를 숨긴다
AssertionError [ERR_ASSERTION]: 놓기 표시 보임
ℹ tests 26
ℹ pass 23
ℹ fail 3
동작 테스트 내용 — 부모(가짜 DOM 위 실제 visual-editor-ui.js): 바깥 pointerdown → 문턱 아래 이동(메시지 없음) → 문턱 넘어 바깥(표시 class 만) → iframe 안(400,200 → paletteDragAt {x:100,y:100}, 봉투 wmve·세션 그대로) → 밖(paletteDragCancel 한 번, 계속 밖이어도 한 번) → 안 → pointerup(paletteDropAt {x:160,y:160}, 캡처·class 해제, 이후 이벤트 무시) · "추가" 버튼 위 pointerdown 은 끌기 아님 · 짧은 클릭 메시지 없음 · 터치·보조 버튼 무시 · Escape → 취소 뒤 up 은 놓기 아님 · pointercancel → 취소 정리 · 포인터 끌기 중 네이티브 dragstart 차단, 평소엔 HTML5 자료 유지 · 편집 끝낸 뒤 메시지 없음. 캔버스(가짜 DOM 위 실제 wm-ve-canvas.js, 부모 창에서 온 것처럼 message 이벤트): paletteDragAt → 놓기 표시(h1 위쪽 절반 → h1 앞 선, p 아래쪽 절반 → p 아래 선) · 취소 → 숨김 · 모르는 id·뷰포트 밖·문자열 좌표 → 숨김 · paletteDropAt → h1 앞에 문단 하나, 표식 없는 changed 한 번, 새 요소 선택, 표시 숨김 · 빈 곳 → 본문 끝 · 모르는 id·좌표 누락/범위 밖/문자열·빈 payload·미리보기 모드 → 본문 불변·changed/error 없음 · 다른 세션·다른 보낸 창 → 무시.
GREEN — node --test test/visual-editor.test.js (독립 저장소 / Suite modules/webmaker 모두)
ℹ tests 26
ℹ pass 26
ℹ fail 0
전체 묶음 — 포인터 끌기 다리 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 87
ℹ pass 87
ℹ fail 0
(원본 52 + 직접 편집 35. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 94 · pass 94 · fail 0. Suite 전체 node test/run.mjs 결과는 Suite 의 docs/verification/webmaker-visual-editor-0.2.0.md.)
독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — 보안 7 · 규격/회귀 5
검토는 참조 저장소·Suite 양쪽 작업 트리를 읽고 아래 항목을 출시 차단으로 보고했다. 항목마다 행동·적대 테스트를 먼저 써 RED 를 잡고 최소 수정으로 GREEN 을 만들었다. 공유 파일(lib/visual-editor.js·lib/gitstore.js·public/visual-editor-core.js·visual-editor-ui.js·wm-ve-canvas.js·test/visual-editor*.test.js)은 두 저장소에서 byte-identical(cmp). server.js·public/app.js·lib/store.js 는 참조 저장소 변경을 Suite 블록을 보존한 채 패치했다(Suite 전용 항목 7·11 은 Suite 쪽 기록 docs/verification/webmaker-visual-editor-0.2.0.md).
| # | 결함 | 수정 | 테스트(RED 증거) |
|---|---|---|---|
| 1 | iframe 안 문서가 바뀌면(a/area href 등) 새 문서가 같은 contentWindow·출처 null 로 부모의 * 명령에서 세션을 배워 changed/hotkey 를 꾸밀 수 있음 |
캔버스가 MessageChannel 포트를 첫 ready 에 동봉, 이후 포트 전용(validatePortMessage); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음; a/area/제출/input[type=image] 클릭·submit 차단(두 모드), <base target="_blank">, CSP form-action none, meta refresh 비활성화 |
visual-editor-ui.js 연결 규약(RED: ready 와 함께 포트가 넘어온다 실패 — 포트 없음) · wm-ve-canvas.js 연결 규약 · 캔버스 안의 이동·제출 활성화 … 막힌다(RED: area 클릭 defaultPrevented false) · prepareCanvas: 이동 활성화 방어(RED: base 에 target 없음) |
| 2 | javascript:… 등 이름 엔티티로 감춘 스킴 통과 |
core.urlScheme/isDangerousUrl(서버 lib 가 core 를 require — 한 구현): 이름·숫자 엔티티 두 겹, 제어·폭 없는 문자 제거, 모르는 엔티티가 스킴 자리에 남으면 거절 |
isDangerousUrl·neutralizeTag·validateVisualSave: 이름 있는 엔티티…(RED: 거절: "javascript:alert(1)" 등) · applyProps·core.safeUrlInput(RED: javascript:alert(1) 통과) |
| 3 | /<head[^>]*>/ 가 주석·<header>·원문의 <head> 에 걸려 CSP·런타임이 엉뚱한 곳에 들어감 |
scanTags(title·textarea 도 원문)로 진짜 head/html/body 를 찾아 넣고 없으면 진짜 <head> 생성 |
prepareCanvas: 주석·원문… 진짜 <head> 뒤에만(RED: 주석은 그대로 실패 — CSP 가 주석 안에 들어감) |
| 4 | 원본 전체 ↔ 본문만 비교 → 머리의 스크립트·핸들러·위험 주소·iframe/object 를 본문에 복사해도 통과 | 완성 문서 전체 ↔ 원본 전체 비교(꼬리 스크립트 중복 제거 뒤) | validateVisualSave: 머리(head)·body 여는 태그·꼬리에만 있던 … 거절(RED: <p>본문</p><script src="/head.js"></script> 통과) |
| 5 | 사전 검사만 지난 느린 요청이 다른 변경 도중에 씀; 소스 편집기 저장은 hash 검사 없음 | withSiteLock: 소스 PUT·직접 편집 PUT·버전 복원은 잠금 안에서 재검사→쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 claimJob; 소스 PUT 선택적 baseHash(app.js 가 보냄) + 응답 hash |
사이트 변경 잠금: 소스 편집기 PUT(baseHash) 과 직접 편집 PUT …(RED: 소스 저장 200 — 직접 편집 저장이 덮어써짐) · … AI 작업이 시작되면 … 409(RED: 200, AI 작업 중 파일 변경) |
| 6 | 파일 쓴 뒤 기록/Git 실패를 삼키고 200 | 스냅샷(기록·HEAD) → 파일/기록 되돌리기(store.save(prev,{restore:true}))·gitstore.rollbackTo → 500; gitRecord(...,{strict:true}) |
직접 편집 저장 트랜잭션: 사이트 기록(store) 저장이 실패하면 …(RED: 500 이지만 파일은 바뀐 채) · … Git 버전 기록이 실패하면 …(RED: 200, change null) |
| 8 | 홈 탭·openSite·HTML 없는 갱신이 묻지 않고 편집기를 끝냄 | openSite 전환 전 close(false)·취소 시 현재 사이트 반환, showTab('home') 질문, onSiteUpdated 가 다른 사이트/사라진 페이지에서 묻고 취소 시 유지, renderPreview 가 캔버스를 숨기지 않음 |
visual-editor-ui.js onSiteUpdated(RED: 다른 사이트 갱신에 즉시 종료) · test/visual-editor-app.test.js(실제 app.js — RED: VE.isActive() actual false expected true, 취소했는데 끝남) |
| 9 | 복제가 뿌리 id 만 지움 | 자손 [id] 모두 제거 |
duplicate: 복제본은 … 자손의 id 도 지우고(RED: 복제본 자손 id 1개 남음) |
| 10 | 블록 끌기에 캡처·pointercancel/blur/Escape 정리 없음 | endBlockDrag: 리스너·캡처·클래스·표시 정리; 손잡이 setPointerCapture |
기존 블록 끌기: … pointercancel·창 blur·Escape …(RED: setPointerCapture 미호출) |
RED — node --test test/visual-editor.test.js(수정 전 코드, 새 테스트 포함)
ℹ tests 37
ℹ pass 24
ℹ fail 13
실패 13건: 주소 엔티티 / prepareCanvas 구조 / 완성 문서 비교 / 부모 연결 규약 / onSiteUpdated / 캔버스 연결 규약 / 이동 활성화 차단 / base target·meta refresh / 복제 id / 끌기 정리 / applyProps 엔티티 / (포트 규약으로 옮긴) 포인터 다리 부모 / 캔버스 paletteDragAt — 대표 메시지 Error: 포트가 연결되지 않음, AssertionError: 거절: "javascript:alert(1)", AssertionError: 주석은 그대로, AssertionError: 거절되어야 함: <p>본문</p><script src="/head.js"></script>.
RED — CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js
ℹ tests 13
ℹ pass 9
ℹ fail 4
actual: 200, expected: 409(소스↔직접 편집 잠금) · actual: "EACCES …sites/<id>.json….tmp", expected: /저장하지 못했습니다|되돌렸습니다/(store 실패가 그대로 500 으로 새고 파일은 바뀜) · actual: 200, expected: 500(Git 실패를 삼킴) · actual: 200, expected: 409(AI 작업 시작과 겹친 저장).
RED — node --test test/visual-editor-app.test.js(수정 전 public/app.js 스냅샷으로 실행)
ℹ pass 0
ℹ fail 1
AssertionError: Expected values to be strictly equal: actual: false, expected: true ← openSite(다른 사이트) 취소 뒤 VE.isActive()
GREEN — 수정 뒤
node --test test/visual-editor.test.js ℹ tests 37 · pass 37 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 13 · pass 13 · fail 0
node --test test/visual-editor-app.test.js ℹ tests 1 · pass 1 · fail 0
전체 묶음 — 독립 검토(1차) 차단 항목 수정 뒤
CLAUDE_BIN=<가짜 exit 1> npm test (node --test test/*.test.js)
ℹ tests 103
ℹ pass 103
ℹ fail 0
(원본 52 + 직접 편집 51 = 단위 37 + 서비스 13 + 화면 전환 1. Suite 체크아웃의 modules/webmaker 에서 같은 명령: tests 110 · pass 110 · fail 0. Suite 전체 node test/run.mjs: 테스트 파일 24개 중 실패 0 — Suite 쪽 기록 docs/verification/webmaker-visual-editor-0.2.0.md.) 최종 수치는 아래 "종결 검토 뒤" 절.
문법: node --check — lib/visual-editor.js·lib/gitstore.js·lib/store.js·server.js·public/visual-editor-core.js·visual-editor-ui.js·wm-ve-canvas.js·public/app.js 두 저장소 모두 통과. 공유 파일 두 저장소 byte-identical(cmp). git diff --check 깨끗함.
수정 뒤 브라우저 게이트 재실행 실패의 원인 조사(2026-10-28) — 게이트 멱등성 결함 + auxclick 틈
- 재실행 출력:
step: "edit-text", 11/11 통과 뒤시간 안에 되지 않음: 저장 상태 dirty. 선택·속성 창이 동작했으므로 MessagePort 연결은 정상. - 가설 (a) 같은 저장 폴더 재사용 — 이전 37/37 실행이 이미 같은 고정 제목을 저장했다. 로컬 재현(참조 저장소 서버 + Chrome for Testing 156 + playwright-core 1.63, 고정 페이지의 h1 을 미리 같은 제목으로 둠): 게이트 진단
editDiag.afterTyping = { events:{ input:16, beforeinput:16, keydown:5, focusout:0 }, canvas:{ tag:"h1", editable:true, hasFocus:true }, h1EqualsTyped:true }, Escape 뒤veState:"ve-state clean", isDirtyApi:false, undoEnabled:false. 즉 입력은 캔버스 h1 에 정확히 들어갔고changed도 갔지만 본문이 저장본과 같아history.push가 false → clean 유지. 제품의 dirty 규칙은 옳고(변경 없음 = clean) 게이트가 멱등하지 않았다. 게이트는 실행마다브라우저에서 직접 수정한 제목 <실행 표식>을 쓰고 편집 전에 현재 제목과 다른지 단언한다(editTargetDiffersFromCurrent). 제품 변경 없음. - 가설 (b) 가운데 클릭 — 같은 로컬 실행에서 게이트의 새 단언
canvasNoNavigate_linkMiddleClick이popups=1(새 페이지가 앱 출처로 열림)로 실패. 가운데 버튼은click이 아니라auxclick으로 오고 런타임은click만 막고 있었다 → 실제 틈. RED(단위, 가짜 DOMfire('auxclick'):AssertionError: edit: AREA 가운데 클릭(auxclick) 차단) →wm-ve-canvas.js에auxclick캡처 리스너(이동 활성화 요소만 preventDefault) → GREEN. 실제 Chrome:popups=0. - 결과(로컬 게이트, 같은 저장 폴더로 연속 2회):
status: pass, 61/61, 두 번째도 61/61.handshake:{ windowMessagesFromFrame:1, readyWithPort:1, otherTypesViaWindow:[], origins:["null"] },forged:{ putSeen:false, elemsBefore:28, elemsAfter:28 }, 이동 활성화 6종 모두alive:true, popups:0.
독립 검토 2차(종결 검토) 차단 항목 3건 수정(2026-10-28) — SVG SMIL 저장형 XSS · Git 저장 트랜잭션 재설계 · (Suite) 릴리스 승인 fail-open
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | validateVisualSave 가 <svg><a href="#x"><animate attributeName="href" values="javascript:…">·<set … to="javascript:…"> 를 받았다 — 주소 목록은 정적 주소 속성만 보고 위험 요소 목록에 SMIL 변이 요소가 없었다(Chromium 에서 저장 페이지를 열면 href 가 javascript: 로 바뀌어 실행) |
lib/visual-editor.js: SMIL 묶음 animate·set·animateMotion·animateTransform·animateColor·discard·mpath(HTML 파서가 SVG 로 읽는 SMIL 요소 전부; 대소문자·svg: 접두어 형태도 같은 묶음, fail-closed)을 위험 요소로 센다(원본 수까지만 — 삭제 허용, 추가·속성 변경 거절, 전용 안내문). prepareCanvas 는 SMIL 요소를 wm-ve-x-<이름>(여는·닫는 태그, 속성 보존)으로 바꾸고 restoreFromCanvas 가 표준 이름으로 되돌린다(브라우저가 소문자로 직렬화해도 animateMotion 등으로). core.isSafeComponentHtml 도 같은 묶음 거절 |
단위 +1(validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL …) |
| 2 | recordChange 가 커밋·브랜치·원격 푸시 뒤 사이트 기록을 저장; rollbackTo 는 soft reset(커밋 실패 뒤 인덱스 그대로); 경로가 되돌리기 boolean 무시; 성공한 푸시 뒤 기록 저장 실패가 원격만 바꾼 채 남을 수 있음 |
lib/gitstore.js 새 recordChangeStrict(큐 한 항목): 스냅샷(add 전) → add·commit·변경 브랜치 → onCommitted(기록 저장) → git push --atomic 으로 main + 변경 브랜치 함께(마지막 단계, 그 뒤 상태 변경 없음) / 실패 → rollbackLocal: reset --mixed <이전 HEAD>(커밋 되감기 + 인덱스 비움), 첫 커밋 전이면 브랜치 삭제 + read-tree --empty, 변경 브랜치 삭제/복원, onRollback(페이지 원본·store.save(prev,{restore:true})), 검증(HEAD·인덱스·브랜치·작업 트리 경로 ⊆ 저장 전·페이지 바이트·기록) → {ok, problems, notes}. server.js PUT /visual 은 rolledBack 을 검증 결과로만 보내고 실패 때 rolledBack:false+rollbackProblems+"상태를 확신할 수 없습니다". 강제 푸시 되돌리기 없음. 푸시 전에 저장되는 기록은 pushed:false, remote:'pending'(원격 반영 미주장) 이고 reconcilePushes(저장 뒤·기동 때·다음 기록 때)가 ls-remote 로 확인한 뒤에만 verified. gitRecord strict 옵션·headCommit·changeBranchName 제거(다른 호출자는 recordChange 그대로) |
서비스 +2(진짜 bare 원격 + pre-receive 거부 훅/pre-commit 훅/기록 폴더 읽기 전용; gitstore 직접 호출 — 첫 커밋 전 되감기·문제 보고 때 ok:false) + 기존 store/Git 실패 테스트에 rolledBack:true·인덱스·브랜치 단언 |
| 3 | (Suite) hal/core.approveRelease 가 명시적 false 만 거절 — checks 없음·testsPassed null(--skip-tests)·dirty 미기록도 승인 |
Suite hal/core.releaseApprovalProblem: testsPassed === true·dirty === false·provenanceClean === true 모두 필수(approveRelease·registerRelease approve:true·운영자 API·release.mjs register --approve 가 같은 규칙) |
Suite test/release-provenance.test.mjs +3 |
RED — node --test test/visual-editor.test.js(수정 전 코드, 새 테스트 포함)
✖ validateVisualSave·prepareCanvas·restoreFromCanvas·core.isSafeComponentHtml: SVG SMIL 변이 요소(…)는 … 거절하고 …
AssertionError [ERR_ASSERTION]: 거절되어야 함: …<svg><a href="#x"><text>hi</text></a><animate attributeName="href" values="javascript:alert(1)" begin="0s" xlink:href="#a"/></svg>
RED — CLAUDE_BIN=<가짜 exit 1> node --test test/visual-editor-server.test.js(수정 전 코드)
ℹ tests 14 · pass 11 · fail 3
AssertionError: 검증된 되돌리기: {"error":"버전 기록(Git)·사이트 기록을 남기지 못해 저장을 취소했습니다 (EACCES). … 일부 되돌리기에 실패했으니 관리자에게 알리세요 …","rolledBack":false} ← Git 실패 테스트: 옛 rollbackTo 가 false 를 돌려주는데 상태는 멀쩡(판정 근거 없음)
AssertionError: {"refs/heads/sites/<id>/changes/0001-create":…,"…/0002-visual-edit":…,"…/main":…} ← 원격 트랜잭션 테스트(첫 단언에서 멈춤; 옛 코드는 pre-receive 거부를 200 + rec.error 로 삼켰다)
RED — Suite node test/release-provenance.test.mjs(수정 전 hal/core·release.mjs)
✘ hal/core.approveRelease·registerRelease(approve:true): … → Error: checks 없음 (approveRelease) (승인됨)
✘ HAL 운영자 API POST /ops/api/releases/:version/approve … → Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}
✘ release.mjs register --approve: testsPassed … → Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0
✘ 통과 4 · 실패 3
GREEN — 수정 뒤
node --test test/visual-editor.test.js ℹ tests 38 · pass 38 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 16 · pass 16 · fail 0 (원격 트랜잭션: 성공 때 main·변경 브랜치 함께 갱신·기록은 pending → ref 확인 뒤 verified, 거부/커밋 실패/기록 실패 세 경로 모두 원격 ref 불변·rolledBack:true·재시도 200, 기동 조정)
Suite: node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0
원격 반영 기록의 보수성(종결 검토 후속) — RED → GREEN
- 검사: 푸시가 원격에 닿는 순간(pre-receive 훅)에 사이트 기록 파일을 복사해 보니, 1차 구현은 푸시 전 에 저장한 기록이 이미
pushed:true였다(RED:AssertionError: 푸시 전 기록은 원격 반영을 주장하지 않는다: {"no":2,"kind":"visual-edit",…,"pushed":true,"error":null,…}). 프로세스가 그 사이에 죽으면 "원격에 있다" 는 거짓 기록이 남는다. - 수정: 기록은
pushed:false, remote:'pending'으로 저장·응답하고(푸시 뒤 어떤 저장도 하지 않는다), 트랜잭션 밖의reconcilePushes가 잠금 안에서 기록을 다시 읽어ls-remote로 원격 ref 에서 확인 한 뒤에만pushed:true, remote:'verified', verifiedAt로 바꾼다. 원격에 없으면(크래시 창) 로컬 변경 브랜치가 기록의 커밋을 가리키는지 확인하고 변경 브랜치들 + main 을 한 번의 원자적 푸시로 올린 뒤 다시 확인한다. 호출: 저장 응답 뒤(setImmediate)·서버 기동 때(pending 이 있는 사이트)·다음 기록 때(옛 retryPush 자리). 조정의 저장은{restore:true}라 Suite rev 를 올리지 않는다. - GREEN(서비스 테스트): 훅이 복사한 푸시 순간의 기록
pushed:false·remote:'pending', 응답도 pending, 조정 뒤verified(푸시 1회 — 확인만 하고 다시 푸시하지 않음); 기동 조정: 기록을 pending 으로 되돌리고 원격 변경 브랜치 삭제·main 을 한 커밋 전으로 되돌린 뒤 같은 데이터로 재기동 → 원자적 푸시 한 번으로 브랜치·main 복구, ref 확인 뒤verified, 기록 저장되고 rev 불변; gitstore 직접 호출: 거부(REJECT)면 pending + 사유, 해제 뒤 푸시·확인, 이미 올라간 기록은 확인만(푸시 로그 2건 = 거부 1 + 성공 1).
설계 메모: 되돌리기에 reset --hard 를 쓰지 않은 이유 — 작업 트리는 사이트 폴더 전체이고 저장과 무관한 커밋되지 않은 파일(이전 비엄격 기록 실패 등)이 있을 수 있어 --hard 는 그것을 지운다. 저장이 바꾼 것은 페이지 한 파일(원본 바이트로 복원)과 커밋·인덱스·브랜치뿐이므로 --mixed + 페이지 복원 + "작업 트리 변경 경로 ⊆ 저장 전 경로" 검증이 같은 보장을 준다. 판정은 명령 성공 여부가 아니라 실제 상태로 한다(읽기 전용 저장소에서 add 가 실패하면 reset 도 실패하지만 상태는 저장 전과 같아 ok — notes 에 명령 오류). 성공한 푸시 뒤에는 트랜잭션 안에서 어떤 저장도 하지 않는다 — 기록은 보수적(pending)으로 남고, 별도 조정 단계가 원격 ref 로 확인한 뒤에만 verified 로 바꾼다. 크래시 창(기록 저장과 푸시 사이에 프로세스 종료)에는 기록이 pending·로컬 커밋과 변경 브랜치가 남아 기동 조정이 원자적으로 올린다(거짓 "원격 반영" 기록 없음).
독립 검토 3차(최종 검토) 차단 항목 4건 수정(2026-10-28) — (Suite) 승인 불변 · 사이트 변경 잠금 전면 감사 · 트랜잭션 완결성·모호한 푸시 판정 · 문서 수치
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | (Suite) registerRelease upsert 가 승인된 버전의 checks·artifact 를 바꾸면서 approved 유지 |
승인된 버전은 같은 내용 재등록만 멱등, 다르면 release_immutable(변경 없음); 미승인 버전의 변경 재등록은 registered·승인 미승계; CLI 거부 종료 1 |
Suite provenance +2(core 8가지 × approve true/false readback 불변, CLI) |
| 2 | 라우터가 요청 시작 때 읽은 기록으로 본문을 기다린 뒤 처리·저장(claimJob·다국어 설정·언어 폴더·복원이 기다리기 전 기록 사용) | 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체 잠금, 긴 경로는 재적재한 기록으로 처리(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·detached(); changeLanguages 는 claimJob 먼저 |
서비스 +1(설정 저장·복원·작업 시작 vs 직접 편집 저장의 결정적 경쟁) |
| 3 | recordChangeStrict 의 initSite·스냅샷·원격 설정이 try 밖(실패해도 onRollback 없음); 푸시 클라이언트 오류를 무조건 실패로 보고 되돌림(원격이 받았을 수 있음) |
모두 트랜잭션 안(prepare), 스냅샷에 repoExisted·indexClean, 새로 만든 저장소는 되돌릴 때 삭제, 스냅샷 전 실패는 ok:false; 푸시 전 원격 ref 를 읽고 오류 뒤 다시 읽어 accepted(성공)/unchanged(되돌림 + 원격 재검증)/ambiguous(pending 유지, 200 + remoteState) |
서비스 +1(git 래퍼: FAIL_AFTER·MIXED·FLAKY·DOWN) + 직접 호출 준비 단계 실패 |
| 4 | 문서 합계 모순(106/113·107/114) | 현재 증거로 통일 | — |
| 5 | 배경 AI 작업이 자기 사이트 객체를 나중에 저장해 작업 중 허용되는 짧은 변경(기본 사항 PATCH·모델 등급·도메인·첨부·검수·모듈 — 실행 중 검사 없음)을 지움 | lib/store.js 저장소 불변식: 읽은 객체마다 __base(항목별 JSON + saveId)를 기억, 저장 때 디스크 saveId 가 다르면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합침(restore 제외, 조정은 keepRev); 취소·상태 경로 불변, 새 잠금 없음 |
서비스 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 종료 저장 뒤 유지) |
| 6 | 같은 최상위 항목을 둘 다 바꾸면 마지막 저장자가 이겨 먼저 쓴 값이 사라짐(작업 중 다시 푸시의 git.changes 반영 상태 vs 작업 종료의 새 버전 추가) | lib/store.js 잎 단위 3자 병합 + 같은 잎을 다르게 바꾼 경우 SaveConflict(site_save_conflict, 409, fields)로 거절·디스크 보존; store.update·store.adopt; 서버 saveJob(site, reapply)(배경 작업의 모든 저장, 실패 기록 포함)·reapplyCommand/reapplyFields; gitRecord 충돌 때 버전만 추가; 다시 푸시·조정은 applyPushState 를 store.update 로 적용 |
서비스 +2(저장소 규칙 6가지; 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시) |
RED(수정 전 코드)
Suite: ✘ hal/core.registerRelease 불변 승인 … Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…} ← 재등록이 승인된 행을 바꿈
Suite: ✘ HAL CLI register-release … 기대 approved_by "test" 실제 "test2"
W: ✖ 사이트 변경 잠금(전체 감사) … AssertionError: 설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"] ← 설정 저장이 본문을 기다리는 사이 끝난 직접 편집 버전을 지움
W: ✖ 원자적 푸시의 모호한 클라이언트 오류 … AssertionError: 원격이 받았으면 성공: {"error":"원격 소스 보관소(Git)에 올리지 못해 저장을 취소했습니다 (fatal: the remote end hung up unexpectedly (test)) …","rolledBack":true,"stage":"push"} ← 원격이 받았는데 되돌림
W: ✖ gitstore.recordChangeStrict(직접 호출) … AssertionError: 준비 단계 실패도 트랜잭션 안: {"message":"…/git/s1: Permission denied","gitDirExists":false,"page":"<p>b</p>"} ← stage·rollback 없음, 페이지 미복원
W: ✖ 배경 AI 작업이 나중에 자기 사이트 객체를 저장해도 … AssertionError: 작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"} ← 작업 중 바꾼 제목이 종료 저장에 지워짐
W: ✖ 배경 AI 작업과 작업 중 허용되는 경로가 같은 잎을 바꾸는 진짜 동시 변경 … AssertionError: 다시 푸시가 쓴 확인 시각이 그대로다(…): {"kept":"…46.004Z","retry":"…45.975Z"} ← 작업 종료가 덮어쓰고 배경 재확인이 다른 시각을 씀(최상위 항목 합치기만 있던 코드)
W: ✖ store.save 합치기 규칙 … TypeError: store.update is not a function / 같은 잎을 둘 다 다르게 바꿔도 거절되지 않음
중간(저장소를 fail-closed 로 바꾼 직후): ✖ … AssertionError: {"error":"사이트 기록 저장 충돌: … (git.changes) …"} ← 충돌이 "다시 푸시" 경로에 드러남(덮어쓰기 없음) → 다시 푸시·조정을 store.update 경로로 바꿈
GREEN — 수정 뒤
node --test test/visual-editor-server.test.js ℹ tests 21 · pass 21 · fail 0 (저장소 합치기 뒤: 되돌리기 검증은 저장 표식 saveId 도 제외하고 비교; 같은 잎 동시 변경은 거절·디스크 보존·작업은 최신 기록 위에 재적용)
Suite: node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0
독립 검토 4차 차단 항목 4건 수정(2026-10-28) — (Suite) artifact 설치 불변 · 읽기 요청 부작용 · 폐기 잠금·표식 · svg/math 안 title 파서 차이
| # | 결함(검토자 발견) | 수정 | 테스트 |
|---|---|---|---|
| 1 | (Suite) 두 CLI 가 DB 불변 검사 전에 버전 폴더에 복사해 승인된 artifact 바이트가 덮임; HAL CLI 는 해시 미검증 | hal/core.installArtifacts: 바이트 해시·크기 검증 → 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음) / 다르면 release_immutable(파일·DB 불변) → 새 설치는 임시 파일 → 해시 재확인 → rename; 두 CLI 모두 설치 → 등록 |
Suite provenance +2(HAL CLI·release.mjs) |
| 2 | GET/HEAD/OPTIONS 가 x-ui-lang 으로 잠금 밖 store.save | 읽기 메서드는 저장소를 건드리지 않음; 언어는 변경 요청의 잠금 안 재적재에서만 저장 | 서비스 +1 |
| 3 | 폐기가 잠금·표식 없이 긴 정리를 기다림; 실패 기록을 오래된 객체로 저장 | 잠금 안 재검사 + 표식(token) 원자적 claim → 잠금 밖 정리 → 다시 잠금·같은 표식 확인 뒤 임시 이름으로 옮김(되돌릴 수 있음) → beforeIrreversible 훅(Suite 포털 연결 해제) → 기록 unlink(되돌릴 수 없는 한 단계, 실패면 되돌리기 + compensate) → 최선의 뒷정리(실패는 warnings·감사); 표식 중 변경 요청·claimJob 409; 실패는 표식 해제 + store.update 로 deleteFailed{recoveryRequired} |
서비스 +1(가짜 ssh 2초: 5종 409, 성공·실패·다른 사이트); Suite 모듈 test/ariweb.test.js +3(정리 실패 → 연결 유지, 연결 해제 실패 → 옮긴 것 되돌림, 뒷정리 실패 → 경고·남은 폴더) |
| 4 | 스캐너가 svg/math 안 <title> 을 RCDATA 로 읽어 <svg><title><script> 통과(브라우저는 실행) |
scanTags 외래 콘텐츠 깊이 — svg/math 안에서는 원문 규칙 없음(통합 지점도 요소로, fail-closed) |
단위 +1(15 변형·닫히지 않은 title·정상 SVG·캔버스 출력) |
RED(수정 전 코드)
W 단위: ✖ scanTags 파서 차이(저장형 XSS) … AssertionError: 거절되어야 함: …<svg><title><script>alert(1)</script></title></svg>
W 서비스: ✖ 읽기 요청은 부작용이 없다 … AssertionError: 읽기 요청은 기록 파일을 바꾸지 않는다(uiLang·saveId·rev 포함)
W 서비스: ✖ 사이트 폐기는 잠금 안에서 … AssertionError: #0 폐기 안내 ← 폐기 중 직접 편집 저장이 "폐기" 로 거절되지 않음
Suite: ✘ artifact 설치 불변·fail-closed(HAL CLI register-release) … 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different" ← 승인된 artifact 가 덮임
Suite: ✘ artifact 설치 불변·fail-closed(scripts/release.mjs register) … 기대 "rel-A" 실제 "rel-B-other"
GREEN — 수정 뒤
node --test test/visual-editor.test.js ℹ tests 39 · pass 39 · fail 0
node --test test/visual-editor-server.test.js ℹ tests 23 · pass 23 · fail 0
Suite: node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0
전체 묶음 — 독립 검토 4차 뒤(현재 증거)
CLAUDE_BIN=<가짜 exit 1> npm test
ℹ tests 115
ℹ pass 115
ℹ fail 0
(원본 52 + 직접 편집 63 = 단위 39 + 서비스 23 + 화면 전환 1. Suite modules/webmaker 같은 명령: tests 125 · pass 125 · fail 0(Suite 전용 test/ariweb.test.js 10 포함). Suite 전체 24개 파일 실패 0 — release-provenance 11.) 실제 Chrome 게이트는 61/61 그대로(이번 4건은 게이트가 보는 화면 동작을 바꾸지 않는다).
전체 묶음 — 최종 검토(3차) + 잎 단위 저장소 합치기 뒤
CLAUDE_BIN=<가짜 exit 1> npm test
ℹ tests 112
ℹ pass 112
ℹ fail 0
(원본 52 + 직접 편집 60 = 단위 38 + 서비스 21 + 화면 전환 1. Suite modules/webmaker 같은 명령: tests 119 · pass 119 · fail 0. Suite 전체 CLAUDE_BIN=<가짜 exit 1> node test/run.mjs: 테스트 파일 24개 중 실패 0 — release-provenance 7·webmaker-visual-editor 5 포함, 생성된 local-e2e-2026-10-28.{md,json} 은 삭제.) 문법 node --check(server.js·lib/gitstore.js·lib/visual-editor.js·core·테스트) 두 저장소 통과, 공유 파일 byte-identical(cmp), Suite server.js 차이 115줄(Suite 블록만), git diff --check 깨끗함. Suite 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도 — Neo 가 참조 저장소를 커밋한 뒤 다시 가져온다).
브라우저 검증 상태
- 실제 브라우저 검증은 수행되었다(Neo 의 게이트 — 1·2차 시각 게이트 1280×633·390×844, 3·4차 Playwright 자동 게이트, 5차 네이티브 포인터 순서 게이트 37/37 통과: 직접 편집 열림·선택·글 편집·실행 취소/다시 실행·Alt+↑/↓·팔레트 포인터 끌어다 놓기(놓기 표시 → 문단 삽입)·저장 PUT 2xx·저장 파일(편집 제목·추가 문단·원본 스크립트 보존·표식 없음)·390×844 배치). 이 37/37 은 독립 검토(1차) 차단 항목 수정 전 의 결과다.
- 수정 뒤 브라우저 재실행 — 완료: Neo 의 첫 재실행은 게이트 멱등성 결함으로
edit-text에서 멈췄다(위 절). 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 이 PC 의 Chrome 156 에서 61/61(연속 2회), Neo 호스트의 실제 Chrome 에서도 61/61 통과(2026-10-28): 직접 편집 열림(ready + 포트 → 본문 수신)·선택·글 편집·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·파일·390×844 + 연결 규약·이동 활성화 6종·위조 창 메시지. 종결 검토 3건(서버 검증·Git·HAL)은 브라우저 동작을 바꾸지 않아 추가 브라우저 재실행은 필요 없다. - 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면, 운영 서버 배포, Chrome 외 브라우저. Node 에서는 런타임 파일의 문법 검사·
eval/new Function부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행(연결·전환·끌기·놓기)을 확인한다.