From 32e9db07730c6181a1751876899a5618be15e6d5 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 14 Aug 2026 00:29:25 +0900 Subject: [PATCH] =?UTF-8?q?fix(docs):=20=EB=A6=AC=EB=B7=B0=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=20=E2=80=94=20=EC=9D=98=EC=82=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B2=B0=ED=95=A8=203=EA=B1=B4=20+=20doc-check=20?= =?UTF-8?q?=EC=82=AC=EA=B0=81=EC=A7=80=EB=8C=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 에이전트 감사와 사용자 트레이싱으로 69466ab에서 나온 지적 반영. 핵심 알고리즘(하강 diff/인덱스 체인)엔 버그 없음이 확인됐고, 의사코드와 문서 정합성에서 나온 것들: 1. `nameClaims`가 Relate의 3-인자 계약 위반(`GetStrong(inst)` / `SetStrong(inst, claims)`) → `(inst, name[, key])`로 정정. 같은 커밋의 `tagNameMap`은 이미 3-인자였어서 대조로 드러남. 2. TagHandler가 생존 이름의 홀더를 비웠다가 곧이은 process에서 `addTag`를 헛되이 재호출 — 문서 서술("addTag 자체가 안 불림")과 정반대였음. 생존 이름은 홀더를 유지하도록 정정하고, `addTag`도 `removeTag`처럼 배치 호출로 통일(`{string}` 시그니처 도입 근거와 맞춤). 3. 그룹 process의 부분 실패 경로(순회 중 충돌 error) 문서화 — 피해가 인스턴스 수명으로 한정되고 재현이 시끄럽게 반복됨을 트레이싱으로 확인, 롤백 장치는 안 넣고 question.md 3번에 열어둠. doc-check.py: REF 정규식이 줄 단위라 파일명과 절 제목이 줄바꿈에 걸친 인용을 통째로 놓치고 있었음(이번 2단계 분할뿐 아니라 9차 세션 1단계 분할 stale까지 숨어 있던 원인). 파일 전체 스캔 + 개행 허용으로 고치고, 새로 드러난 stale 참조 30여 곳을 dispatch-core/event-plan/ref-plan/ typing-limits로 정정. 오탐 없음(참조–인용 스팬 3줄 미만 전수 확인). 부수: 세션 로그에 리뷰 라운드 절 추가(커밋 메시지 줄수 오기 2291→1213 정정 포함), CLAUDE.md의 스파이크 이동 서술에 `19` 누락 보강, luau-test/README 상단 요약표를 STATUS.md와 동기화. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ --- .claude/base/attribute-plan.md | 29 +++++--- .claude/base/lifecycle-pattern.md | 2 +- .claude/base/modifier-plan.md | 4 +- .claude/base/module-lifecycle-plan.md | 4 +- .claude/base/onchange-plan.md | 4 +- .claude/base/relate-plan.md | 2 +- .claude/base/slot-plan.md | 8 +-- .claude/base/store-semantics.md | 2 +- .claude/base/tag-plan.md | 33 ++++++---- .claude/base/ui-shorthand-plan.md | 2 +- .claude/luau-test/README.md | 8 +-- .claude/question.md | 7 ++ .claude/research/documentation-content-map.md | 4 +- .claude/research/operator-sugar-plan.md | 2 +- .claude/research/pre-implementation-audit.md | 6 +- .claude/research/v1-compat-plan.md | 2 +- ...ttribute-getkey-dispatch-diff-reflected.md | 62 +++++++++++++++++- .../__pycache__/doc-check.cpython-314.pyc | Bin 0 -> 15704 bytes .claude/tools/doc-check.py | 57 +++++++++------- CLAUDE.md | 19 ++++-- ROADMAP.md | 14 ++-- 21 files changed, 191 insertions(+), 80 deletions(-) create mode 100644 .claude/tools/__pycache__/doc-check.cpython-314.pyc diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 266a806..8ed5627 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -195,27 +195,24 @@ identity 하나로** 판정할 수 있고, 그러면 claim 로직이 그룹을 ```lua -- AttributeKeyHandler(quad-base) — 이름 claim만 자기 상태로 가짐 +-- Relate는 항상 (inst, key) 2단 — `base/relate-plan.md` API 참고. +-- 여기선 key = attribute 이름, value = 그 이름을 지금 잡고 있는 키 객체. local nameClaims = Relate() -- {[inst(weak)] = {[name] = key(strong)}} function AttributeKeyHandler.process(inst, k, v, index) - local claims = nameClaims:GetStrong(inst) - if not claims then - claims = {} - nameClaims:SetStrong(inst, claims) - end - local cur = claims[k.Name] + local cur = nameClaims:GetStrong(inst, k.Name) if cur ~= nil and cur ~= k then error(`attribute "{k.Name}" is already bound by another owner`) end - claims[k.Name] = k + nameClaims:SetStrong(inst, k.Name, k) setAttribute(inst, k.Name, v) -- v가 nil이든 아니든 무조건(위 "메커니즘" 절) return function() -- 엔진 부작용 없음 — claim 반납만. "내가 실제로 물러날 때만" 지움 -- (`dispatch-core-plan.md` "Handler 작성 체크리스트" 4번). - if claims[k.Name] == k then - claims[k.Name] = nil + if nameClaims:GetStrong(inst, k.Name) == k then + nameClaims:SetStrong(inst, k.Name, nil) end end end @@ -330,6 +327,20 @@ function AttributeGroupHandler.process(inst, k, v, index) end ``` +- **[부분 실패 경로, 2026-08-14 리뷰에서 명시화] 순회 도중 소유권 충돌 + error가 나면, 그 전에 이미 등록된 이름들은 이 사이클의 클로저가 만들어지지 + 못해 즉시 회수되지 않는다.** `error`가 `process` 밖으로 전파되므로 `return + function() ... end`에 도달하지 못하고, `Dispatch`는 성공한 반환값만 + 저장하기 때문. 다만 **피해 범위는 그 인스턴스의 수명으로 한정됨**: + (1) `nameClaims`/`chains`는 `Relate`라 `inst`에 대해 weak — 그 인스턴스가 + GC되면 잔여 claim도 같이 사라짐, (2) 같은 자리가 다시 프로세스되면 + `Dispatch`가 남겨둔 마커 슬롯 덕분에 (A) 분기를 타고, 이미 claim된 이름은 + `cur == k`라 **에러 없이 그대로 통과**해 같은 자리에서 같은 error로 다시 + 멈춤 — 조용한 오작동이 아니라 **반복 재현되는 시끄러운 실패**. 그래서 + 별도 롤백 장치를 넣지 않음(코퍼스의 "에러=패닉 상태, 그 이후 정합성은 + 관리 대상 아님" 원칙과 같은 결). 원자적 롤백이 필요하다고 판단되면 + 그때 그룹 `process`에만 국소적으로 넣을 수 있음 — `question.md` 3번에 + 열어둠. - **`groupKey(v, name)`는 그룹 값 객체별·이름별 메모이즈** — 같은 그룹 값이 재프로세스될 때 같은 키가 나와야 claim이 자기 자신과 안 부딪힘. 구현은 `Relate<그룹 값 → {[name] = key}>` 하나면 충분하고, **공개 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 292bdee..fdc251d 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -146,7 +146,7 @@ unbindLifetime(inst: any, value: any): () canExecute(inst: any, value: any): boolean ``` -**`unbindLifetime` 추가 이유(2026-08-09 세션, `bind-system-plan.md`의 +**`unbindLifetime` 추가 이유(2026-08-09 세션, `dispatch-core-plan.md`의 "Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새 `State`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함, `State` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저, diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 5005fe8..746e133 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -100,7 +100,7 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 - **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. - 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — StoreBind와 + 상세는 `base/dispatch-core-plan.md`의 "`None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 `StoreBind` 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀 @@ -312,7 +312,7 @@ Modifier에는 없음). 됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 -PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/bind-system-plan.md` +PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/event-plan.md` "이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 39f7d71..07f8a1a 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -111,7 +111,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ **[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름. `Dispatch`(그 계약을 스캔/실행하는 엔진, 프리미티브 아닌 탑레벨 싱글톤)와 - 구분해서 쓸 것 — `base/bind-system-plan.md` "Dispatch는 프리미티브가 + 구분해서 쓸 것 — `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절. **왜 다른 후보들을 기각했는지(2026-08-08 세션, 재확인)**: `Processor`는 계약 메소드 자체가 `process`라 이름 안에 같은 단어가 겹쳐 눈에 거슬림, `Provider`는 `canProvide`처럼 "뭔가를 공급한다"는 @@ -125,7 +125,7 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 번도 실행 안 된 상태에서 dispatch가 호출되면 어떻게 되는지 (`pre-implementation-audit.md` 1-4) — 별도 케이스로 처리하지 않음. provider 미주입 상태는 결국 그 클래스의 핸들러가 레지스트리에 하나도 - 없는 상태이므로, `base/bind-system-plan.md` "우선순위 동률/매치 실패 + 없는 상태이므로, `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절의 일반 "매치 실패 시 즉시 error" 규칙 하나로 자연히 커버됨. - base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고 실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로 diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index 649877e..a88ea49 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -27,7 +27,7 @@ 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 (`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 - Luau가 검증 못 하는 대가를 받아들인다"는 결정(`bind-system-plan.md` "이벤트 + Luau가 검증 못 하는 대가를 받아들인다"는 결정(`event-plan.md` "이벤트 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 `AttributeKey<>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 @@ -55,7 +55,7 @@ 별도 `retract(inst,k,v)` 필드가 Disconnect를 담당한다고 적혀 있었으나, Handler 계약이 `process` 1-메소드로 합쳐지며 그 로직이 반환 클로저로 이동 — `connection`은 `process`의 로컬 변수를 클로저가 upvalue로 그대로 - 캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`bind-system-plan.md` + 캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`dispatch-core-plan.md` "핸들러 내부 상태 저장" 절). - **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 7c645c3..5b7fb72 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -1,6 +1,6 @@ # Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브 -**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/bind-system-plan.md`의 +**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/dispatch-core-plan.md`의 "핸들러 내부 상태 저장"과 `base/lifecycle-pattern.md`의 `bindLifetime`/ `canExecute` 양쪽이 필요로 했던 "`inst`를 weak 키로 하는 저장소"가 지금까지 `base.perInstanceState(inst)`라는 이름만 있고 인터페이스가 미정인 placeholder로 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 92749e9..72efb45 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -202,7 +202,7 @@ Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서( 있었던 질문 — **메커니즘 확정으로 해소됨**: `Dispatch.setLength`/ `Dispatch.setOffsetSource` + 형제별 개수 누적합(`offset`)을 리액티브 프로퍼티(Roblox `LayoutOrder`)에 바인딩하는 방식 — 상세는 `base/ -bind-system-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서 +dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장" 절 참고. **DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로 재사용됨**(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요 없음 — `insertBefore`가 뒤 형제를 자연히 밀어주므로, backend Handler의 @@ -1314,7 +1314,7 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫. **동적 자식은 반드시 `Slot` 또는 `state`류 store-bind를 통해서만 -추가/제거 — 그 외 경로는 UB(2026-08-10 세션, `base/bind-system-plan.md`의 +추가/제거 — 그 외 경로는 UB(2026-08-10 세션, `base/dispatch-core-plan.md`의 "Length/Offset" 절 반영).** 둘 다 `Dispatch.setLength`/`setOffsetSource`를 정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 마운트해둔 부모 Instance에 직접 `.Parent = parentInst`로 자식을 끼워 @@ -1589,7 +1589,7 @@ Roblox뿐 아니라 web에도 그대로 필요. `offset`/`sum`은 0-based 개수(카디널 수)고, `_elements`/`updateFn`의 `index`는 1-based Lua 배열 관례 — `index + offset` 공식이 이 둘을 -의도적으로 섞는 것. 상세는 `base/bind-system-plan.md`의 "0-based +의도적으로 섞는 것. 상세는 `base/dispatch-core-plan.md`의 "0-based 개수" 절 참고. ## 반응형 raw 요소 — `State`/`Source`도 Slot 요소로 허용 (2026-08-11 일곱 번째 세션) @@ -1834,7 +1834,7 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 **[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에 대해 그냥 **`Dispatch.setOffsetSource(ownerKey, position, None)` + `Dispatch.setLength(ownerKey, position, 0)`을 다시 부르면 끝**. -이건 이미 확정된 관용구 그대로임(`base/bind-system-plan.md`의 "실제 +이건 이미 확정된 관용구 그대로임(`base/dispatch-core-plan.md`의 "실제 마운트를 하지 않는 위치는 `None`을 등록 — `setLength`도 짝을 맞춰 `0`"). 즉 **해제 = 0/`None`으로 재등록**이고 별도 unregister 함수가 없어도 됨 — 앞서 "이게 실제 작업량"이라고 적었던 판단은 과했음. diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index ff8257f..01ad4db 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -113,7 +113,7 @@ Handler는 quad 사용자가 아니라 **백엔드/핸들러 구현자가 채우 지점**이라는 완전히 다른 축의 개념이라, 여기 분류를 "왜 Handler가 빠졌는지" 궁금해할 필요 없음 — 프리미티브 분류가 불완전한 게 아니라 Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispatch`) 자체가 -왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/bind-system-plan.md`의 +왜 프리미티브가 아니라 탑레벨 싱글톤인지는 `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절 참고. 과거 "미해결로 남은 것"으로 적었던 두 항목도 모두 해소됨: `:Compute`의 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index e9349d1..68e5e4c 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -98,7 +98,7 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라 구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔ `nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안 -맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된 +맞음) — 그래서 `retract`가 실제로 필요해짐(`dispatch-core-plan.md` "확정된 디스패치 모델" 절의 일반 원칙 그대로). **[전면 정정, 2026-08-12 열한 번째 세션] 아래는 이전 버전(단일 `relate`, @@ -156,6 +156,7 @@ TagHandler.priority = <일반> TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 function TagHandler.process(inst, k, v, index) + local added = {} for name in v:Names() do local holders = tagNameMap:GetStrong(inst, name) if not holders then @@ -163,10 +164,13 @@ function TagHandler.process(inst, k, v, index) tagNameMap:SetStrong(inst, name, holders) end if next(holders) == nil then - addTag(inst, { name }) -- 주입된 엔진 op(아래 "패키지 배치" 절) + table.insert(added, name) -- 이 이름을 처음 거는 위치일 때만 실제 호출 대상 end holders[k] = true -- 이 "위치"가 이 이름을 걺(Tag 객체가 다른 위치와 같아도 무관) end + if #added > 0 then + addTag(inst, added) -- 한 번에 — 웹 className 일괄 갱신을 위한 배치 계약 + end return function(nextValue) -- nextValue는 nil(단순 철거)이거나 같은 핸들러가 곧 처리할 새 Tag — -- 타입이 계약으로 보장되므로 isTag 가드가 필요 없음(2026-08-13 열네 번째 세션). @@ -174,26 +178,33 @@ function TagHandler.process(inst, k, v, index) -- 이름 집합도 절대 안 바뀜 — holders 순회 자체가 불필요한 순수 최적화 local removed = {} for name in v:Names() do + -- [정정, 2026-08-14 리뷰] 생존 이름은 **홀더 등록 자체를 유지**한다. + -- 예전엔 홀더를 일단 빼고 removeTag 호출만 skip했는데, 그러면 곧 + -- 이어지는 process가 빈 holders를 보고 `addTag`를 **다시** 부름 + -- (엔진이 멱등이라 안 보였을 뿐, "진짜 바뀐 이름에만 호출"이라는 + -- 이 절의 설계 목표와 어긋났고 아래 서술과도 모순이었음). + if nextValue ~= nil and nextValue:Contains(name) then continue end local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음 holders[k] = nil -- 이 "위치"가 이 이름을 놓음(같은 Tag 객체를 다른 위치도 쓰고 있어도 무관) - if next(holders) == nil and not (nextValue ~= nil and nextValue:Contains(name)) then - table.insert(removed, name) -- 곧 새 process가 재확정할 이름이면 skip(깜빡임 방지) + if next(holders) == nil then + table.insert(removed, name) end end if #removed > 0 then - removeTag(inst, removed) -- 한 번에 — 웹 className 일괄 갱신을 위한 배치 계약 + removeTag(inst, removed) -- 한 번에 end end end ``` - **`addTag`는 온전히 `process`, `removeTag`는 온전히 반환하는 클로저** - — 서로 겹치는 diff 계산이 없음. 클로저가 이전 `Tag`(`v`, 자기 자신이 - 캡처)가 걸었던 이름 전부를 소유 목록에서 빼되(항상 실행), 그 결과 - 목록이 비었을 때 **실제 `removeTag` 호출만** "새로 들어올 값이 - 그 이름을 여전히 Contains하는가"로 판단해 skip — 소유 목록 자체는 - 항상 최신 객체로 갱신되므로(정확히 `v`를 빼고 새 `process`가 새 값을 - 넣는 두 단계), 이름이 살아남는 경우에도 stale 레퍼런스가 안 남음. + — 서로 겹치는 diff 계산이 없음. 클로저는 이전 `Tag`(`v`, 자기 자신이 + 캡처)가 걸었던 이름 중 **새 값이 더 이상 안 거는 것만** 소유 목록에서 + 빼고, 그 결과 목록이 빈 이름들을 모아 `removeTag`를 **한 번** 호출함. + 생존 이름은 소유 목록을 그대로 두므로(**[정정, 2026-08-14 리뷰]** 예전엔 + 일단 뺐다가 호출만 skip했는데 그러면 곧이은 `process`가 빈 목록을 보고 + `addTag`를 다시 불렀음) 뒤이은 `process`에서 `addTag` 자체가 안 불림 — + "실제 엔진 호출은 진짜 바뀐 이름에만"이 코드 수준에서도 성립. `process`는 `v`가 새로 거는 이름 전부를 무조건 등록(소유 목록이 비어있던 경우에만 실제 `addTag`) — 자기 나름의 old-vs-new diff가 전혀 필요 없음(그 일을 클로저가 매번 정확히 해줌). diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 3ea3981..6f95dd8 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -114,7 +114,7 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했 다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 "이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 -(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md` +(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인 Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 8b552a7..a8ac03d 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -11,9 +11,9 @@ | 폴더 | 뜻 | 누가 처리 | |---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 | -| `rewrite-required/` | 스파이크 코드가 깨짐(설계 문제 아님, `13`/`15`/`16`) | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff) | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용 `10` + GC 헬퍼) | 사용자 or MCP 연결 후 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음(15개) | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음(14개) | — | **스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 STATUS.md의 줄도 같이 옮길 것** — 그게 곧 상태 갱신이다. 아래 파일 목록의 경로는 @@ -68,7 +68,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | 파일 | 검증 대상 | 근거 문서 | |---|---|---| | `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `dispatch-core-plan.md` "props 순회 순서", ROADMAP M0-4 | -| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | +| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | @@ -82,7 +82,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | -| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | +| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | diff --git a/.claude/question.md b/.claude/question.md index b6ea97a..4fa82df 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -192,6 +192,13 @@ 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는 그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절. +- **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패 + 롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이 + 이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스 + 수명으로 한정되고 재현도 시끄럽게 반복돼서 **지금은 별도 장치 없이 + 문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적 + 롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정 + 불필요 — M10 구현 시점에 판단. - **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** — 두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서 조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리). diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index fba0553..3001886 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -196,7 +196,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: 인가`)을 더 깊게 확장, `Blocker` 같은 파생 프리미티브가 이 목표 위에서 왜 자연스럽게 나왔는지까지 포함하는 설계 철학 에세이 6. **왜 배열/해시 두 패스 순서를 안 뒤집는가, `PreRef`는 왜 그 예외로 - 따로 필요한가** (2026-08-07 세 번째 세션 원자료, `bind-system-plan.md` + 따로 필요한가** (2026-08-07 세 번째 세션 원자료, `ref-plan.md` "`phase` 옵션 폐기" 절 마지막 항목이 이 자리를 지목해뒀던 것 — 지금까지 여기 안 옮겨져 있었음) — "프로퍼티/이벤트가 항상 children/Ref보다 나중"이라는 순서를 고치는 대신 `PreRef`라는 별도 타입으로 예외를 @@ -213,7 +213,7 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: **[해소됨, 2026-08-12 여섯 번째 세션]** 취소 가능 여부 — PreRef는 구조적으로 `retract` 체인에 아예 안 올라가므로 취소 개념 자체가 없고, 대신 이미 fire된 PreRef를 재사용하면(두 번째 construction에 다시 - 놓으면) 즉시 `error` — "1회용, 재할당 불가"로 확정. `bind-system-plan.md` + 놓으면) 즉시 `error` — "1회용, 재할당 불가"로 확정. `ref-plan.md` "동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러" 절 바로 아래 "PreRef는 '취소'라는 개념이 없다" 항목 참고. diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 9614303..2ca9876 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -302,7 +302,7 @@ Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 **배경**: 2026-08-13 다섯 번째 세션의 인덱스 기반 `Dispatch` 재설계로 `State>`가 UB에서 **정상 지원 대상**이 됐음(각 재귀 단계가 -다른 인덱스를 써서 슬롯 충돌이 없어짐, `base/bind-system-plan.md` +다른 인덱스를 써서 슬롯 충돌이 없어짐, `base/dispatch-core-plan.md` "Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님** (사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다"). diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 55bfd4a..55c026d 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -88,7 +88,7 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 전체 체인을 배열로 들고 있는 쪽으로 정리됨. 상세는 `base/ -bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 +dispatch-core-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 원래 발견 당시 기록. **위치**: `base/dispatch-core-plan.md` "확정된 디스패치 모델" 절 90-91행 — @@ -137,7 +137,7 @@ bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 **해소**: 별도 케이스로 처리하지 않음 — provider 미주입 상태는 결국 그 클래스의 핸들러가 레지스트리에 하나도 없는 상태이므로, 위 1-3에서 확정된 일반 "매치 실패 시 즉시 error" 규칙 하나로 자연히 커버됨. 상세는 -`base/module-lifecycle-plan.md` 해당 항목, `base/bind-system-plan.md` +`base/module-lifecycle-plan.md` 해당 항목, `base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절. 아래는 원래 발견 당시 기록. **위치**: `base/module-lifecycle-plan.md` "Bind는 누가, 어떻게 구현하는가" @@ -299,7 +299,7 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. 이미 쓰이는 패턴). 결과가 구조적으로 `Source`를 만족하기만 하면 Luau가 이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이 없어졌으니 M0/M3 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로 -안 옮겨도 됨. 상세는 `base/bind-system-plan.md` "`store.key` 레코드 필드 +안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드 타이핑" 절, 실제 문법 실측은 `luau-test`의 `16-type-store-key-typefunction.luau`(신규). 아래는 원래 발견 당시 기록. diff --git a/.claude/research/v1-compat-plan.md b/.claude/research/v1-compat-plan.md index 74d21db..89290c1 100644 --- a/.claude/research/v1-compat-plan.md +++ b/.claude/research/v1-compat-plan.md @@ -53,7 +53,7 @@ v1(`.claude/initreq/quad/src`) 조사 결과, API는 성격이 다른 두 계층 ### 3-1. (a)는 얇게 재현 가능 — opt-in 서브패키지로 격리하면 근거 문제도 해소됨 -- **이벤트 self 관습**: 클로저 한 겹으로 재현 가능. `base/bind-system-plan.md` +- **이벤트 self 관습**: 클로저 한 겹으로 재현 가능. `base/event-plan.md` "이벤트 핸들러는 self를 받지 않는다" 절이 든 반대 근거 4가지(Ref 중복 채널, Modifier 정적 flatten과 경쟁, quad-debug 추적 밖 mutate 경로, 클로저 비용)는 **코어에 넣을 때** 문제가 되는 것들 — 별도 opt-in 패키지 diff --git a/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md b/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md index 77a4961..554f6d3 100644 --- a/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md +++ b/.claude/session/2026-08-13-14-attribute-getkey-dispatch-diff-reflected.md @@ -119,7 +119,9 @@ clear 하게 준비해두고, 커밋하세요."* `확정된 디스패치 모델`/`None 센티널`/`Dispatch는 프리미티브가 아니다`/ `Dispatch 체인`/`Handler 작성 체크리스트`/`Length·Offset`/`store 바인드는 래핑` 블록(1074줄)을 **`base/dispatch-core-plan.md`**로 옮기고 새 모델로 -재작성. `bind-system-plan.md`는 2263 → 1219줄(반응형 코어 + 인체공학). +재작성. `bind-system-plan.md`는 **2291 → 1213줄**(반응형 코어 + 인체공학). +(커밋 `69466ab` 메시지엔 이 숫자가 "2263→1219"로 잘못 적혀 있음 — +분할 직전/직후가 아니라 어림한 값이었음. 히스토리 재작성 대신 여기 정정.) 인바운드 참조는 `doc-check.py` 출력을 그대로 입력으로 삼아 기계적으로 고침(파일별 라인 지정 치환) — 15개 파일 30여 곳. @@ -196,3 +198,61 @@ end "국소 레지스트리 + 즉시 error" 모양이 자연스러운 선택지라는 점을 질문 문서에 덧붙임. - `doc-check.py` **ERROR 0** 유지 확인. + +--- + +## 6. 후속 리뷰 라운드 (2026-08-14, 다른 에이전트 감사 + 사용자 트레이싱) + +커밋 `69466ab`를 다른 에이전트 셋이 감사하고 사용자가 직접 트레이싱한 +결과를 받음. **핵심 알고리즘(하강 diff, 인덱스 체인)에는 버그 없음**으로 +확인됐고, 의사코드/문서 정합성에서 **실제 결함 3건 + stale 참조 다수**가 +나와 전부 수정: + +**1. `nameClaims`가 `Relate`의 3-인자 계약을 위반** (실제 버그) +`GetStrong(inst)`/`SetStrong(inst, claims)`로 1·2-인자를 쓰고 있었음 — +`relate-plan.md`가 확정한 API는 항상 `(inst, key[, value])`. 같은 커밋의 +`tagNameMap`은 정확히 3-인자를 쓰고 있어 대조로 바로 드러남. +→ `GetStrong(inst, k.Name)`/`SetStrong(inst, k.Name, k)`로 정정(중간 +`claims` 테이블 자체가 없어져 코드도 짧아짐). + +**2. `TagHandler`의 서술과 의사코드 불일치** (실제 버그) +`Tag(A)→Tag(B)`에서 생존 이름("selected")을 손 트레이싱하면: 클로저가 +`holders[k]=nil`로 홀더를 **비운 뒤** `removeTag` 호출만 skip → 곧이은 +`process`가 빈 홀더를 보고 **`addTag`를 다시 호출**함. 문서는 정반대로 +"이미 걸려있던 이름은 소유 목록이 안 비어 있어 `addTag` 자체가 안 불림" +이라고 적고 있었음. 엔진이 멱등이라 눈에 안 보였을 뿐, "실제 호출은 진짜 +바뀐 이름에만"이라는 이 절의 설계 목표와 어긋남. +→ **생존 이름은 홀더 등록 자체를 유지**하도록 정정(`nextValue`가 그 +이름을 `Contains`하면 `continue`). 덤으로 `addTag`도 `removeTag`처럼 +**배치 호출**로 바꿈 — `{string}` 시그니처를 도입한 근거(웹 `className` +일괄 갱신)와 코드가 안 맞던 것도 같이 해소. + +**3. 그룹 `process`의 부분 실패 경로가 문서에 없었음** (문서 갭) +이름 순회 도중 소유권 충돌 `error`가 나면 클로저가 안 만들어져 앞서 +등록된 이름들이 그 사이클에 회수되지 않음. **다만 감사가 말한 "영구 +누수"보다는 좁음** — `nameClaims`/`chains`가 `inst`에 weak라 인스턴스 +GC와 함께 사라지고, 재프로세스 시 이미 claim된 이름은 `cur == k`라 +에러 없이 통과해 **같은 자리에서 같은 error로 반복 재현**됨(조용한 +오작동이 아님). 그래서 롤백 장치는 안 넣고 그 경로를 명시적으로 +문서화, 원자적 롤백 여부는 `question.md` 3번에 열어둠. + +**4. 문서 분할 후 stale 절 참조 20여 곳** — `doc-check.py`의 사각지대였음 +`REF` 정규식이 **줄 단위**로 돌아서 파일명과 "절 제목"이 줄바꿈에 걸친 +자연스러운 인용(`` `base/\nbind-system-plan.md`의 "Length/Offset" ``)을 +통째로 놓치고 있었음. 이번 분할뿐 아니라 **9차 세션의 1단계 분할 stale도 +같은 이유로 계속 숨어 있었음**(`event-plan`/`ref-plan`/`typing-limits`로 +간 절들). +→ 검사기를 **파일 전체 스캔 + 개행 허용**으로 고치고(줄 번호는 매치 +오프셋에서 역산), 새로 드러난 WARN을 기준으로 30여 곳을 기계적으로 +정정. WARN 56 → 120건으로 늘었다가 정정 후 101건(남은 건 의역 인용 등 +판단이 필요한 것들). + +**5. 사소한 것**: 커밋 메시지의 줄수(2263→1219)가 실제(2291→1213)와 다름 +(히스토리 재작성 대신 위 4-1에 정정 기록), `CLAUDE.md`가 `04`만 언급하고 +`19` 이동을 빠뜨린 것, `luau-test/README.md` 상단 요약표가 STATUS.md와 +어긋난 것 — 전부 수정. + +**교훈**: 문서 분할 시 stale 참조는 "검사기가 ERROR 0이면 됐다"로 끝나지 +않음 — **검사기 자신의 사각지대**를 먼저 의심할 것. 이번엔 감사가 그 +사각지대를 짚어줬고, 고치자마자 1단계 분할의 오래된 stale까지 같이 +드러났다. diff --git a/.claude/tools/__pycache__/doc-check.cpython-314.pyc b/.claude/tools/__pycache__/doc-check.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de90682039d0cd18ea708432a9d36ba31c22a4c6 GIT binary patch literal 15704 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 literal 0 HcmV?d00001 diff --git a/.claude/tools/doc-check.py b/.claude/tools/doc-check.py index d50cfb9..dbe6925 100644 --- a/.claude/tools/doc-check.py +++ b/.claude/tools/doc-check.py @@ -61,7 +61,11 @@ def rel(p): # ---------- 1 & 2: 참조 무결성 ---------- # `(경로/)파일.md` 또는 `파일.luau` (+ 바로 뒤에 "절 제목"이 오면 절 검사도) -REF = re.compile(r'`\.?/?((?:[\w.-]+/)*[\w.@-]+\.(?:md|luau))`(\s*(?:의\s*)?"([^"\n]{2,60})")?') +# [2026-08-14] 파일명과 "절 제목"이 **줄바꿈에 걸쳐 있어도** 잡도록 개행 허용. +# 예전엔 줄 단위로 돌려서 `base/\nbind-system-plan.md`의 "Length/Offset" 같은 +# 자연스러운 줄바꿈 인용을 통째로 놓쳤고, 실제로 문서 분할 후 stale 참조 +# 20여 곳이 이 사각지대로 빠져나갔음(14차 세션 리뷰에서 발견). +REF = re.compile(r'`\.?/?((?:[\w.-]+/\s*)*[\w.@-]+\.(?:md|luau))`(\s*(?:의\s*)?"([^"]{2,60})")?') def resolve(target, src): """참조 문자열을 실제 경로로 해석 — 상대/부분 경로를 관대하게 매칭.""" @@ -122,28 +126,35 @@ def check_refs(docs): for d in docs: # archive는 히스토리라 절 참조까지 강제하지 않음(파일 존재만) is_archive = '/archive/' in rel(d) - for ln, line in enumerate(open(d, encoding='utf-8'), 1): - for m in REF.finditer(line): - target, _, section = m.group(1), m.group(2), m.group(3) - if not interesting(target): - continue - p = resolve(target, d) - if p is None: - name = os.path.basename(target) - msg = f"{rel(d)}:{ln} 깨진 파일 참조 → `{target}`" - (errors if OURS.search(name) else warns).append( - msg if OURS.search(name) else msg + " (외부 문서명일 수 있음)") - continue - if section and not is_archive and p.endswith('.md'): - if p not in hcache: - hcache[p] = headings(p) or [] - core = re.sub(r'[`*]', '', section).strip() - if not any(core in h for h in hcache[p]): - # 절 제목은 의역해서 인용하는 관례가 있어 WARN — 다만 - # 문서를 쪼개거나 헤딩을 고칠 때 여기가 제일 먼저 걸린다. - warns.append( - f"{rel(d)}:{ln} 절 참조 불일치 → {os.path.basename(p)}에 " - f'"{section}" 절 없음') + # 줄 단위가 아니라 **파일 전체**를 한 번에 스캔 — 파일명/절 제목이 + # 줄바꿈에 걸친 인용도 잡기 위함(위 REF 주석 참고). 줄 번호는 매치 + # 시작 오프셋으로 역산. + text = open(d, encoding='utf-8').read() + for m in REF.finditer(text): + ln = text.count('\n', 0, m.start()) + 1 + target, _, section = m.group(1), m.group(2), m.group(3) + target = re.sub(r'\s+', '', target) + if section is not None: + section = re.sub(r'\s+', ' ', section).strip() + if not interesting(target): + continue + p = resolve(target, d) + if p is None: + name = os.path.basename(target) + msg = f"{rel(d)}:{ln} 깨진 파일 참조 → `{target}`" + (errors if OURS.search(name) else warns).append( + msg if OURS.search(name) else msg + " (외부 문서명일 수 있음)") + continue + if section and not is_archive and p.endswith('.md'): + if p not in hcache: + hcache[p] = headings(p) or [] + core = re.sub(r'[`*]', '', section).strip() + if not any(core in h for h in hcache[p]): + # 절 제목은 의역해서 인용하는 관례가 있어 WARN — 다만 + # 문서를 쪼개거나 헤딩을 고칠 때 여기가 제일 먼저 걸린다. + warns.append( + f"{rel(d)}:{ln} 절 참조 불일치 → {os.path.basename(p)}에 " + f'"{section}" 절 없음') # ---------- 3: 색인 누락 ---------- diff --git a/CLAUDE.md b/CLAUDE.md index c2e488a..ff4ce6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,7 +213,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 실제 코드 작성에 재사용. - **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에 `08`이 `done/`으로 가며 `review-required/`가 비었고, **14차 세션에 - `04`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`로 갔음**. + `04`와 `19`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`로 + 갔음**(`19`는 B 섹션만 낡음, A/C는 유효). **개수의 소스는 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 설계 결정은 없고, 0-Y/0-A가 남긴 규약(`base/typing-limits.md`/ `base/dispatch-core-plan.md`)은 착수 전 필독. @@ -1097,6 +1098,16 @@ claim**으로 확정. 이걸로 마지막 게이트가 열려 **0-A(하강 diff 알고리즘이 백엔드마다 복제됨), 그 실패 모드를 위해 **`HANDLER_PRIORITY_FALLBACK`** 신설(사용자 제안 — base 핸들러는 최하위 밴드, 백엔드가 덮어쓰면 언제나 이김). 옛 모델은 `archive/dispatch-hintvalue-model-reversed.md`로 이전, -스파이크 `04`/`19`는 옛 모델을 검증 중이라 `rewrite-required/`로 이동. -**M0 착수를 막는 결정이 이제 없음** — 새로 연 것은 사소한 둘뿐 -(`Attribute.Merged` 이름 중복, `hintValue` 이름 재검토). +스파이크 `04`(체인/`retractFrom`)와 `19`(B 섹션 = Attribute 소유권)는 옛 +모델을 검증 중이라 `rewrite-required/`로 이동 — `19`의 A/C 섹션은 그대로 +유효. +**M0 착수를 막는 결정이 이제 없음** — 새로 연 것은 사소한 셋뿐 +(`Attribute.Merged` 이름 중복, `hintValue` 이름 재검토, 그룹 `process` +부분 실패 롤백). **[2026-08-14 후속 리뷰 라운드]** 다른 에이전트 감사 + +사용자 트레이싱으로 의사코드 결함 3건 수정 — `nameClaims`가 `Relate`의 +3-인자 계약을 위반, `TagHandler`가 생존 이름의 홀더를 비웠다 되돌려 +`addTag`를 헛되이 재호출(+`addTag` 배치 누락), 그룹 `process` 부분 실패 +경로 미문서화. 더 큰 수확은 **`doc-check.py` 자신의 사각지대 발견** — +`REF` 정규식이 줄 단위라 파일명과 절 제목이 줄바꿈에 걸친 인용을 통째로 +놓치고 있었고, 고치자 이번 분할뿐 아니라 **9차 세션 1단계 분할의 stale +참조까지** 무더기로 드러나 30여 곳 정정. diff --git a/ROADMAP.md b/ROADMAP.md index 6a884ee..e4c3116 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 - (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 + (2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 `nil`이 아니라 `None`으로(2026-08-07 열 번째 세션 정정, 사용자가 Luau REPL로 반례 제시 — 키가 듬성듬성해지면 순회가 index 순서를 @@ -180,7 +180,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Dispatch/Leaf.luau` — `(i:number, v=Ref/Observer/PreRef)` children-array leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ - bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) + dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절) - [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]={handler, retractor}}}}` — **재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)**) + **3-인자** `Dispatch.retractFrom(inst,k,index)` — 재귀 재-dispatch @@ -213,7 +213,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `store.key` dot-access 타입 추론 확인 — Luau `type function` (`WrapStore`/`ProcessStoreType`)으로 `Store`가 `T`의 각 필드를 `Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 - 번째 세션, `base/bind-system-plan.md` "`store.key` 레코드 필드 + 번째 세션, `base/typing-limits.md` "`store.key` 레코드 필드 타이핑" 절) — 실제 문법이 통과하는지는 `luau-test`의 `16-type-store-key-typefunction.luau`로 실측 필요 - [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar @@ -328,7 +328,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ - bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 + dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State`도 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. - [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ @@ -377,7 +377,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 위치"(`candidateIndex`, filter로 압축됨), `key`와도 무관(순서/레이아웃 전용, 식별 목적 아님) — 문서화 시 셋(원본 raw index/`key`/`updateFn`의 `index`)을 혼동하지 않게 주의. **`offset`은 `Slot.Offset`을 그대로 - 전달**(형제 Slot/정적 자식 누적합, `base/bind-system-plan.md`의 + 전달**(형제 Slot/정적 자식 누적합, `base/dispatch-core-plan.md`의 "Length/Offset" 절) — `index`/`offset` 둘 다 **raw 값으로만 전달, `Slot`/Handler가 `LayoutOrder` 등을 자동으로 세팅해주지 않음** (2026-08-11 세션 확정 — 자동 바인딩은 컴포넌트가 이미 지정한 값을 @@ -443,7 +443,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/ slot-plan.md` "CRUD API 확정" 절). - [x] **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/ - bind-system-plan.md` "Length/Offset" 절) — `sum` 누적과 + dispatch-core-plan.md` "Length/Offset" 절) — `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던 버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입 방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로 @@ -526,7 +526,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Ref.luau`(`.Value` 읽기 전용 필드 + `:Set(value)`/`:Callback(fn)`/ `:Wait(thread?)`, 전부 self 반환) + `PreRef.luau`(별도 파일, Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, - 위치 무관 호이스팅 pre-pass — `base/bind-system-plan.md` "`phase` + 위치 무관 호이스팅 pre-pass — `base/ref-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) - [ ] `(v=Ref)` 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 `Ref(default)` 인스턴스를 인식해 바인드(별도 `CreatedRef` 래퍼