diff --git a/.claude/README.md b/.claude/README.md index 16fcb87..d652861 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,7 +24,7 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스). **2라운드 예정** — 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | @@ -48,14 +48,14 @@ | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | -| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅 | +| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈 | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요) | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | -| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | @@ -122,7 +122,7 @@ | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | | `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | -| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — `base/lifecycle-pattern.md`가 이미 거부해둔 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라 틀렸음. 정정: 저 이름들은 참조 카운트/이름 claim **알고리즘 구현**일 뿐이고, `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 별도 이름의 `...FallbackHandler`이며, 등록 주체는 quad-base 모듈이 아니라 백엔드 팩토리(`BaseModule` 뮤테이션 시점, 자기 전용 Handler들과 같이). 현행은 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절 | +| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 | ## 참고 diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index e193519..981f5e2 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -41,6 +41,45 @@ - 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 소스. +## [해소됨, 2026-08-18 구현 전 QA 2라운드 후속] `RC-1` — `recompute` 트리거 모델 재설계 + +`base/dispatch-core-plan.md`의 `recompute`가 배열 위치를 순차 등록하는 +동안(`Frame{A,B}`처럼 정적 자식 2개짜리도) 아직 등록 안 된 자리를 `nil`로 +읽어 산술 에러를 내던 크래시(`qa-request/pre-implementation-qa-round2.md`의 +"RC-1" 절에서 손 트레이싱으로 발견) — 같은 날 후속 세션에서 사용자가 +Blocker 재사용 설계를 직접 제시해 해결됨. + +- **핵심**: owner(물리 `inst` 또는 Slot)마다 `Relate`로 들고 있는 전용 + `Blocker`를 배치(`Dispatch.drive`의 배열 파트 순회, `attachSlot`의 자기 + `_elements` flush) 시작 시 `On`, `setLength`의 Observer 콜백(등록 즉시 + 1회 실행 포함)이 `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 + 끝나면 `blocker:OffWithoutEmit()` + 명시적 `recompute` 딱 1회. + `setOffsetSource`는 등록되는 그 자리에서 앞선 형제들의 길이 합을 직접 + 계산해 즉시 `:Set`해서(recompute를 안 기다림) 배치 도중 `:List`가 + 실체화되며 옛 offset을 읽는 문제도 같이 없앰. +- **Blocker에 신설된 API**: `IsOn()`(`IsBlocked` 조회 얇은 래퍼), + `OffWithoutEmit()`(끄되 gated state의 대기 emit은 흘려보내지 않음, + `HasBlockedEmit`은 그대로 리셋) — `state:Block()`을 거치지 않고 직접 + 쓰는 첫 사례. 처음 요청됐던 `HasBlocked`(Blocker 자신의 새 최상위 + 플래그)는 논의 중 불필요함이 확인돼 신설 안 함. +- **중첩 Slot마다 별도 Blocker**(부모와 공유 금지, `blocker-plan.md`의 + 재진입 미지원 규칙 그대로), **런타임 단건 `slot:Add()`는 게이팅 불필요** + (이미 안정된 앞선 position만 참조하므로 무관) — 둘 다 사용자가 직접 + 확인. +- **후속 정정 — `attachSlot` 호출 순서**: 기존 의사코드는 `setLength`를 + 먼저 불렀는데, "호출 순서는 `setOffsetSource` → `setLength`" 일반 + 규칙과 어긋나 있었음이 드러남(RC-1로 `setOffsetSource`가 즉시 계산을 + 하게 되며 순서가 겉으로 드러났기 때문) — Slot의 진짜 `.Length`는 + `activateList` 실체화 뒤에야 확정되므로 `setOffsetSource` → 실체화 → + `setLength` → 물리 마운트 순으로 바로잡음. 같은 논의에서 **코루틴 yield + 금지 불변식**도 확정(`Dispatch.process`/`attachSlot` 호출 체인 도중 + yield는 UB). +- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 + 만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, + `base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" + 절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은 + `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절. + --- # 확인/결정 필요 목록 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 7d9b744..54480dd 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -98,9 +98,11 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은 `quad-roblox`가 담당. -13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox - 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` - 추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 +13. **모듈은 기본 싱글톤, `Quad()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox + 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `Quad()` + 추가(**[정정, 2026-08-18 `/code-review high`] 이름은 아래 배너대로 + `New()`가 아니라 `Quad()`가 확정 — 이 문장도 그 이름으로 통일**). + **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가 `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 `BaseModule` diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 9d80180..aa7cb5d 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -112,9 +112,13 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가 초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만 -두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은 -열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도 -테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요. +두면 됨. 모듈 스코핑(`Quad()`, `base/architecture.md` 13번)과의 관계도 +실은 열려있던 게 아니라 자연히 풀림 — `Quad()`가 생기면 각 인스턴스가 +별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨(**[한정, +2026-08-18 `/code-review high` — `architecture.md` 13번의 정정과 맞춤]** +단 "자동으로"는 아님 — module-level state를 참조하는 코드들이 모듈 +인스턴스를 인자로 받도록 손을 봐야 하는 건 architecture.md 13번의 정정 +그대로, 여기서 반복 안 함). ## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 184841f..4e45289 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -38,7 +38,15 @@ debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 Blocker() -> blocker -- 생성자 blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함 blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된 - -- onunblock 핸들 전부 실행(순서 무관, idempotent) + -- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent) +blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음 + -- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이 + -- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀. + -- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고), + -- 차이는 넘기는 emit 플래그 하나뿐. +blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는 + -- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래 + -- "이름 확정" 참고), 호출부 가독성만을 위한 추가. state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 -- 처음 블록될 때가 아니라) onunblock 핸들을 @@ -49,9 +57,14 @@ gated state의 동작: - 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도. - `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅. - `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과). -- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해 - true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면 - 아무 것도 안 함(idempotent). +- **onunblock 핸들은 이제 `emit: boolean` 인자를 받는다**(`blocker:Off()`/ + `:OffWithoutEmit()`가 공유하는 내부 실행 경로, 2026-08-18 신설) — + `HasBlockedEmit`을 확인해 true면 `emit`이 참일 때만 그제서야 정확히 + 1회 전파(emit)하고, `emit`이 거짓이면 전파 없이 플래그만 리셋. 이미 + `HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함 + (idempotent). 즉 `Off()`는 "밀린 전파를 흘려보내며 끈다", + `OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기 + 상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨. **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 @@ -77,6 +90,28 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. +## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설) + +지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를 +경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`의 +"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 +순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 +고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다 +— 콜백 안에서 `blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 +방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` +형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 +않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — +`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두 +메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기 +위해 `OffWithoutEmit()`을 쓴다**("이 배치가 끝나면 무엇이든 자동으로 +흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도 +표현). 상세 메커니즘·`Dispatch.setLength`/`setOffsetSource`가 이 Blocker를 +어떻게 만들고 어디에 저장하는지는 `base/dispatch-core-plan.md`의 "배치 +등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음. +**재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용** — 중첩된 owner +(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를 +새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절). + ## 이름 확정 - 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자 @@ -89,6 +124,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 - 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). - 메소드: `state:Block(blocker) -> state`. +- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는 + 얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들" + 참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함. + HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 즉 `IsBlocked`/ + `HasBlockedEmit` 필드는 그대로 유지하고, 별도 `HasBlocked`(Blocker + 자신의 새 최상위 플래그)는 **신설하지 않는다** — `OffWithoutEmit()`이 + 각 gated state의 기존 `HasBlockedEmit`을 그대로 리셋해주는 것으로 + 충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의 + 과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md` + "RC-1" 절에 논의 경위 기록). ## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 @@ -104,7 +149,22 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. -## 상태: 핵심 메커니즘+이름 확정. [2026-08-07 기준] 남은 건 문서화뿐 +**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — +위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset +배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을 +재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** — +부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도 +Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의 +`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려 +부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이 +경고하는 실패 모드의 구체 사례. + +## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐 + +**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과 +`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API +확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과 +메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신. `quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용). diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 4181d5f..79571cb 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -127,10 +127,20 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 **이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부 해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가 호출되면?" 케이스(`pre-implementation-audit.md` - 1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는 + 1-4)도 별도 분기 없이 자동으로 해소됨** — **backend가 직접 소유하는 + 핸들러(`Property`/`Event`/`Slot`류)에 한해** provider 미주입 상태는 결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로 - "매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider - 미주입을 서로 다른 에러 종류로 구분할 필요가 없음. + "매치 실패"와 정확히 같은 경로로 수렴함. **[한정, 2026-08-18 `/code-review + high` — `D-7` 재역전과의 정합성]** `Tag`/`Attribute`처럼 **base가 + Fallback Handler를 자기 로드 시점에 스스로 등록하는 것**(위 문단, + "base가 소유하는 핸들러와 주입되는 엔진 op" 절)은 이 일반화의 예외다 — + 백엔드가 하나도 없어도 그 Fallback Handler는 이미 레지스트리에 있으므로 + **매치는 되고**, 실패는 "매치 실패" 에러가 아니라 그 자리에서 실행되는 + 주입 op 스텁의 명시적 에러(`addTag가 구현되지 않음...` 류)로 남 + — "provider 미주입"과 "매치 실패"가 **에러 경로 자체는 다르지만 둘 + 다 명확한 에러로 수렴한다"**는 결론은 안 바뀜, 다만 오타 키/미지원 + 조합과 provider 미주입을 구분할 필요가 없다는 문장은 backend 소유 + 핸들러에만 해당한다. - **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 @@ -459,6 +469,13 @@ end `PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass 하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는 `base/ref-plan.md`의 "`PostRef`" 절. + **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회 + 전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에 + `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가 + (pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤 + `recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/ + `setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을 + 안전하게 만드는 Blocker 게이팅" 절이 소스. **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 @@ -511,8 +528,9 @@ function NilHandler.process(inst, k, v, index) -- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다. -- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가 -- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 — - -- setLength가 끝에서 recompute를 돌리므로 반대로 하면 죽는 중인 서브트리의 - -- Source에 :Set()이 날아간다). [2026-08-18 감사에서 순서 정정] + -- setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- 반대로 하면 죽는 중인 서브트리의 Source에 :Set()이 날아간다). + -- [2026-08-18 감사에서 순서 정정] Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0) return function() end @@ -581,16 +599,20 @@ end 전부 하나의 우선순위 스캔을 공유. **[정정, 2026-08-10 세션]** Tween은 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`). -- **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 - 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 +- **모듈 재생성(`Quad()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 + 풀림.**(**[정정, 2026-08-18 `/code-review high`] 헤딩이 옛 이름 `New()`를 + 쓰고 있었음 — `architecture.md` 13번 항목과 같은 이유로 `Quad()`로 + 통일, 아래 "[한정]" 문단이 이미 정확한 이름을 씀**) v1처럼 `require`를 + 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 - 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 + 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`Quad()`가 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 - 스코핑됨, 재설계 불필요"). 다중 인스턴스화가 실제로 생기면 그 시점에 + 스코핑됨" — 단 아래 "[한정]" 문단대로 코드 손질은 필요, 재설계까지는 + 불필요). 다중 인스턴스화가 실제로 생기면 그 시점에 BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. @@ -1084,6 +1106,16 @@ end 중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐. 조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서 `Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것. + +**9. `process` 안에서(또는 `process`가 부르는 컴포넌트 함수/`updateFn` +안에서) 코루틴 yield 금지(2026-08-18 신설, `/code-review high`로 이 +불변식이 "Length/Offset" 절에만 묻혀 있던 걸 발견해 여기로도 끌어올림).** +아래 "Length/Offset" 절의 배치 게이팅(`Blocker`)이 "position이 항상 +순서대로, 다른 코드가 끼어들 틈 없이 동기로 처리된다"는 전제 위에 +서 있음 — 이 체인 도중 yield가 끼면 같은 owner의 `Blocker`를 다른 +코드가 그 사이에 건드릴 수 있어 게이팅 순서 보장이 깨짐. 상세 근거는 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절. + ### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) **문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, @@ -1142,8 +1174,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. - **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 - **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 - 하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우 + **스스로 만들어서** 등록. **[2026-08-18 구현 전 QA 2라운드 후속 — + `RC-1` 해결]** 예전엔 "Dispatch는 그냥 레지스트리에 넣어두기만 하고 + `recompute`가 그 자리에 값을 `:Set()`한다"였는데, 이제 **등록되는 그 + 자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 즉시 + `:Set()`한다**(아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의 + "`setOffsetSource`의 즉시 계산" 참고) — `recompute`는 이후 값이 바뀔 때 + 전체를 다시 계산하는 역할로 남는다. Slot이 매치되는 경우 이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래 참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이 등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정, @@ -1189,8 +1226,9 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 -반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 -부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 +반대면 위험함**: `setLength`가 끝에서 `gatedRecompute`를 경유해(배치 +게이팅 중이 아니면) `recompute`를 돌리므로, 먼저 부르면 그 `recompute`가 +아직 남아있는 옛 `Source`(지금 막 떼어내는 서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의 `offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 @@ -1229,6 +1267,14 @@ lazy 생성. **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: +**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래 의사코드를 배치 +등록 중 안전하지 않게 만들던 크래시(`RC-1`)는 해결됨 — 해법은 "배치 +등록을 안전하게 만드는 Blocker 게이팅" 절(바로 아래)이 소스, 여기 +`recompute` 자체의 코드는 안 바뀜(off-by-one 수정 버전 그대로). 바뀐 +건 **언제 호출되는가**뿐 — `setLength`/`setOffsetSource`가 새로 개입한다. +트레이싱 경위·논의 원문은 `qa-request/pre-implementation-qa-round2.md`의 +"RC-1" 절. + **[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한 뒤 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 @@ -1308,11 +1354,22 @@ vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. 일어나게 막음. **`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), -`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션).** **[재작성, 2026-08-18 +구현 전 QA 2라운드 후속 — `RC-1` 해결]** `setLength`는 더 이상 `recompute`를 +직접 부르지 않는다 — State든 상수든 항상 아래 `gatedRecompute` 하나를 +경유하고, 그 함수가 `blocker:IsOn()`을 확인해 배치 등록 중이면 건너뛴다 +(Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이 +게이트를 통과한다 — 사용자: *"setLength 는 recompute 를 직접 수행하진 +않고, Observer 에서 recompute 를 수행해. 맨 처음 emit 에서도 blocker 가 +on 이면 무시하는식"*). `blocker`가 무엇이고 어디서 오는지는 바로 아래 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절 참고 — 이 함수는 그 +Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 않음, +그건 호출하는 배치 쪽 책임): ```lua -function Dispatch.setLength(inst, i, len) - local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 +function Dispatch.setLength(ownerKey, i, len) + local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성 + local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고) local oldObserver = bk.observers[i] if oldObserver then @@ -1322,23 +1379,122 @@ function Dispatch.setLength(inst, i, len) bk.lengthList[i] = len - if isState(len) then - local observer = len:Observer(function() - recompute(inst, bk) - end) - bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 - bk.observers[i] = observer + local function gatedRecompute() + if not blocker:IsOn() then + recompute(ownerKey, bk) + end end - recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) + if isState(len) then + local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨 + bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + else + gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름 + end end ``` `:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 -본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 -같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 -끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, -`inst` 생명주기에 자동 귀속)를 충족. +본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst +또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적 +`:Unsubscribe()`가 없으면 안 끊기므로 안 맞음. `bindLifetime`/ +`unbindLifetime`이 이미 이 요구(GC-native, `ownerKey` 생명주기에 자동 +귀속)를 충족. + +### 배치 등록을 안전하게 만드는 Blocker 게이팅 (2026-08-18, `RC-1` 해결) + +**문제 재확인**: `bk.N`(그 owner의 array part 크기)은 배치가 시작되는 +시점에 이미 정해져 있는데, `bk.lengthList[1..N]`은 각 position이 처리될 +때마다 하나씩 채워진다 — 순차 처리 도중에 `recompute`가 돌면 아직 안 +채워진 뒤쪽 position을 `nil`로 읽어 산술 에러가 난다(`Frame{A,B}`처럼 +정적 자식 2개짜리도 재현됨, 트레이싱 상세는 +`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절). + +**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 +자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: + +1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기 + 자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그 + owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작 + 전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 + **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 + 직접 쓰는 두 번째 용례" 절 참고. +2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 + `gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉 + **배치 도중엔 `recompute`가 단 한 번도 안 돈다**, 그래서 + `bk.lengthList`의 빈 자리를 읽을 일 자체가 없다. +3. **`setOffsetSource`는 그동안 손 놓고 있지 않는다 — 등록되는 그 + 자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 `:Set`한다** + (아래 "`setOffsetSource`의 즉시 계산" 참고). 배치가 항상 position을 + 순서대로(1,2,...,N) 처리하므로, position `i`를 등록하는 시점엔 `1..i-1`이 + 이미 전부 끝나 있어 이 합산이 항상 정확하다. **이게 "recompute를 + 미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`가 + 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 + 배치 중이라도 항상 최신값을 보게 됨. +4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 + `attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을 + 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 + 호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, + `ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위 + 재귀 케이스)도 같이 확정시킨다. + +**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서 +`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면 +스킵 — 참여 안 하는 자리는 계산할 게 없음): + +```lua +function Dispatch.setOffsetSource(ownerKey, i, source) + local bk = getBookkeeping(ownerKey) + bk.sourceList[i] = source + if source ~= None then + local sum = 0 + for j = 1, i - 1 do + local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음 + sum += (if isState(v) then v:Get() else v) + end + if source:Get() ~= sum then + source:Set(sum) + end + end +end +``` + +이건 `recompute`의 로직을 대체하는 게 아니라 **보완**한다 — 이 즉시 +계산은 "지금 막 등록되는 이 position의 초기값"만 맞춰줄 뿐이고, 이후 어느 +position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보다 뒤에 +있는 모든 position의 offset을 다시 계산해야 하므로 여전히 `recompute`의 +전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로). + +**적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로 +별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히 +둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위 +`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의** +`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 — +중첩된 Slot마다 그 Slot 자신의 owner 키로 **별도** Blocker를 새로 만듦, +부모 Blocker 재사용 금지는 `base/blocker-plan.md`의 "재진입" 절 그대로). +**런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 흔한 패턴은 +이 게이팅이 필요 없다** — 사용자 확인: *"그건 이미 마운트가 된 +이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위 +요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서, +setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함"* — 이미 +마운트된 배치 밖에서 하나씩 추가되는 position은 그 앞의 모든 position이 +이미 안정적으로 등록돼 있어 `nil` 자리를 만들 여지가 없고, 그 owner의 +Blocker는 이미 `OffWithoutEmit()`으로 꺼진 채라 `gatedRecompute`가 +평소처럼 즉시 돈다. + +**⚠️ 불변식 — `Dispatch.process`/`attachSlot` 호출 체인 도중에는 코루틴 +yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체가 +"position이 항상 1,2,...,N 순서대로, 다른 코드가 끼어들 틈 없이 동기로 +처리된다"는 전제 위에 서 있다 — 이 체인 도중 어딘가(컴포넌트 함수, +`updateFn`, Handler 등) yield가 끼면, 아직 배치가 안 끝난 owner의 같은 +`Blocker`를 다른 코드가 그 사이에 건드릴 수 있어(예: 다른 이벤트 콜백이 +같은 owner에 `setLength`를 부르는 것) 배치 도중/직후의 게이팅 순서 보장이 +깨진다. 사용자: *"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 +할듯. 안 그럼 꼬이는 문제가 발생하지 않나 생각함"* — 웹 백엔드처럼 +`setLength`가 뒤섞이면 특히 골치 아파짐. 새 방어 로직을 넣는다는 뜻이 +아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은 +톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것. **동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 9254d02..7f650e8 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -174,10 +174,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록할 수 있음 — 상세는 `base/dispatch-core-plan.md`의 같은 절. **중복 호출 - 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 + 가드/`Quad()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — - 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 + 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `Quad()`가 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 - 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 + 스코핑됨(**[한정, 2026-08-18 `/code-review high`]** 위 "모듈 스코핑" 절의 + 정정과 맞춰 — "자동으로"는 아니고 module-level state를 참조하는 코드는 + 손을 봐야 함, 그 손질까지 하고 나면 이 가드 자체는 재설계 불필요라는 + 뜻). **이 결론이 Dispatch의 handler 레지스트리에도 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index a998635..f974a1e 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -521,7 +521,8 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) function ProcessedPreRefHandler.process(inst, i, v) -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가 - -- 끝에서 recompute를 돌리므로(`base/dispatch-core-plan.md`의 해제 순서 계약) + -- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- (`base/dispatch-core-plan.md`의 해제 순서 계약) Dispatch.setOffsetSource(inst, i, None) Dispatch.setLength(inst, i, 0) return function() end -- no-op retract, 이 자리는 fire가 끝나 @@ -606,9 +607,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. 전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = function(inst,k,v) return isPreRef(v) end, process = - function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수 - 있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)`만 - 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler" + function(inst,k,v) error(`PreRef binding should be array index item, + but got {typeof(k)}`) end }`(**[2026-08-18, `/code-review high`로 + 누락 발견 — `PostRef`의 "동적 경로 가드 Handler도 거울상으로 하나 더" 절/ + `effect-plan.md`의 "동적 경로 가드" 절과 짝을 맞춤]** 에러 메시지에 + 실제 `k` 타입을 실을 것) — `k` 타입은 안 가림(숫자든 문자열이든 + `isPreRef(v)`만 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 + 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는 `HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라 `Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 5b45eda..102d201 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -359,8 +359,9 @@ function SlotHandler.process(inst, k, slotValue, index) -- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음. unmountSlotTree(slotValue) -- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정** - -- (setLength가 끝에서 recompute를 돌리므로 offsetSource를 먼저 비워야 - -- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고) + -- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감, + -- 아래 ⚠️ 절 참고) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0) unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝 @@ -847,9 +848,10 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it 되는 식): - **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환. "지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면 - **[정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트됨**(단순 - `Visible = false`도 아님 — 위 "구현" 절 `rawUnmount` 참고, 이 - 문단 작성 당시엔 아직 destroy 모델이었음). 편의상 + **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 + — "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible = + false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시 + 반환해야 대신 언마운트+재사용됨). 편의상 `nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw `nil`/`None` 금지와 안 부딪힘). @@ -974,12 +976,26 @@ end **해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면 그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil` -반환으로 **[정정, 2026-08-13 3차 감사] 언마운트**되게 함(위 캐비엇대로 -파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 -통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 -존재하지 않음(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 -GC되기 전까지 `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 -있음 — 위 캐비엇 참고). +반환으로 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 +재역전 — 아래 참고] 언마운트**되게 함(위 캐비엇대로 파괴 아님) — Visible +토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면 +20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음 +(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지 +`nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇 +참고). + +**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale +서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로 +쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil` +리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소, +`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로 +`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고, +"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면 +`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이 +절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제 +자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은 +`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위 +"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. **"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 @@ -1457,11 +1473,32 @@ nil/None 금지)는 그대로. ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 +**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 +`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 +수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그 +해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래 +코드의 `blocker` 관련 줄). + `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출**이 됩니다. +**[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음 +— `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.** +`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출 +순서는 `setOffsetSource` → `setLength`"** 일반 규칙(해제 시점 계약에서 +나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속 +어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게 +되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각 +요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset +전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length`는 `activateList`가 +자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그 +전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는 +**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야 +확정된 값으로 등록) → 물리 마운트**: + ```lua -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 local function attachSlot(slot, physicalTarget, ownerKey, position) @@ -1475,24 +1512,38 @@ local function attachSlot(slot, physicalTarget, ownerKey, position) slot._mounted = true slot._mountedInst = physicalTarget - Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State, 기존 로직 그대로 local offsetSource = Source(0) - Dispatch.setOffsetSource(ownerKey, position, offsetSource) + Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산 slot.Offset = offsetSource if slot._listed then - activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜 + activateList(slot, physicalTarget) -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨 end - -- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에 - -- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님) + Dispatch.setLength(ownerKey, position, slot.Length) -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음 + + -- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush — + -- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의 + -- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은 + -- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로 + -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의 + -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용. + local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 + blocker:On() for i, element in ipairs(slot._elements) do if isSlot(element) then attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신 else + -- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도 + -- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1. + Dispatch.setOffsetSource(slot, i, None) + Dispatch.setLength(slot, i, 1) element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행 end end + blocker:OffWithoutEmit() + local bk = getBookkeeping(slot) + if bk then recompute(slot, bk) end end ``` @@ -1513,6 +1564,15 @@ end -- attachSlot될 때 위 flush 루프가 처리 ``` +**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인, +2026-08-18)** — *"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운 +개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 +요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 +받고 수행 가능함"* — 이 시점엔 `self`의 Blocker가 이미 flush 배치를 +끝내고 `OffWithoutEmit()`으로 꺼져 있고, 새로 등록되는 position보다 +앞선 모든 position은 이미 안정적으로 채워져 있어 `nil` 자리가 생길 +여지 자체가 없다. + `recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록 확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더 이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested @@ -1983,9 +2043,12 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 (2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할 때는 이 순서를 반드시 지켜야 함**: -- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는 - `sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 - (`base/dispatch-core-plan.md` "Length/Offset" 절). +- `Dispatch.setLength`는 끝에서 `gatedRecompute`를 경유해(배치 게이팅 + 중이 아니면) `recompute`를 돌리고, `recompute`는 `sourceList`를 + 순회하며 각 자리의 `offset:Set(sum)`을 호출함(`base/dispatch-core-plan.md` + "Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히 + 일어나므로 이 경로에서는 `gatedRecompute`가 거의 항상 즉시 `recompute`로 + 이어짐). - 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 diff --git a/.claude/project-context.md b/.claude/project-context.md index b606462..a74f1d6 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -71,8 +71,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 `type-recursive-issue-try-callback/` 등). - `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 - 여기 둠(`pre-implementation-qa-round1.md`, 2라운드 예정 — 라운드마다 새 - 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md` + 둘 다 **완료** — 라운드마다 새 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, **[2026-08-18 기준] 폴더 자체가 아직 없음**. `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 diff --git a/.claude/qa-request/pre-implementation-qa-round1.md b/.claude/qa-request/pre-implementation-qa-round1.md index d09dec4..484a74b 100644 --- a/.claude/qa-request/pre-implementation-qa-round1.md +++ b/.claude/qa-request/pre-implementation-qa-round1.md @@ -336,7 +336,7 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2 어긋난다**(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이 필요한 항목. -### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ⚠️ 재확인 필요 +### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ✅ 해소(같은 세션 내 재역전) - **판정**: 아니오(잠정) — 사용자는 **quad-base 로드 시 등록이 맞다고 했었다**고 기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함. @@ -359,6 +359,18 @@ D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2 `archive/tag-attribute-load-time-registration-reversed.md`를 되살릴지 아니면 새로 쓸지, (c) `InitNamespace` 거부 원칙과 어떻게 양립시킬지를 같이 적어주시면 좋겠음. +- **해소(같은 세션 내, 2026-08-18) — (a)/(b)/(c) 전부 답변됨**: + (a) **최종은 로드 시 등록** — "등록 주체는 다시 quad-base 자신이다 + (모듈이 자기 레지스트리를 구성하는 시점)"으로 재역전 확정. (b) 새로 + 안 쓰고 **기존 `archive/tag-attribute-load-time-registration-reversed.md`에 + 재역전 배너만 추가**(그 문서 자체가 "이번에 재역전됐다"는 걸 스스로 + 알림 — 새 archive 문서 생성 없음). (c) `InitNamespace` 거부 원칙과의 + 양립: 그 원칙이 금지한 건 "사용자가 수동으로 init을 호출하게 만드는 + 것"과 "모듈 로드 시 *남의* 상태를 건드리는 것" 둘인데, base가 **자기 + 모듈 안의 자기 레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니므로 + 충돌 없음. 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "base가 + 소유하는 핸들러와 주입되는 엔진 op" 절의 "[재역전, 2026-08-18 구현 전 + QA — 사용자 확정]" 배너가 소스. --- diff --git a/.claude/qa-request/pre-implementation-qa-round2.md b/.claude/qa-request/pre-implementation-qa-round2.md new file mode 100644 index 0000000..d81499e --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round2.md @@ -0,0 +1,340 @@ +# 구현 전 QA **2라운드** — 의사코드 손 트레이싱 + +**상태**: **완료 — 핵심 발견(`RC-1`)까지 같은 날 후속 세션에서 해결· +`base/` 반영 완료.** 1라운드는 "확정된 주장이 맞는가"를 물었을 뿐 의사코드를 +실제로 실행해보진 않았음(`pre-implementation-qa-round1.md` 맨 아래 "진행 +로그" 절). 2라운드는 그 갭을 메우는 작업 — `base/slot-plan.md`의 `:List` +`reconcile`과 `base/dispatch-core-plan.md`의 `recompute`를 구체 시나리오로 +직접 손으로 실행해보고, 부수로 `reference/` 인용 정확성과 `ROADMAP.md` +마일스톤 정합성도 훑었다. + +**이 문서의 용도**: 1라운드와 달리 "예/아니오 판정" 문항이 아니라 트레이싱 +결과 자체가 산출물 — 버그를 찾으면 그 자리에서 기록하고, 방향이 갈리는 +것만 사용자에게 물었다. 새로 발견된 결함은 `RC-1` 하나뿐이고, 나머지는 +"트레이싱했지만 문제 없음 확인"으로 아래 각 절에 남긴다(재작업 방지용 +기록 — `conventions.md`의 "작업이 끝나면(또는 방향이 바뀌면) 항상 자기 +문서화" 원칙). + +--- + +## RC-1 — `recompute`의 트리거 모델 자체가 재검토 대상 ✅ 해결(2026-08-18 후속 세션) + +**판정**: 트레이싱으로 실제 크래시 경로를 확인 — 사용자도 진단은 맞다고 +동의했고, 같은 날 후속 대화에서 **Blocker를 재사용하는 배치 게이팅 +설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제 +반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 +Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, +`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" +절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다. + +### 발견한 크래시 경로 + +`base/dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 +순서 보장" 절의 `recompute` 의사코드: + +```lua +local function recompute(ownerKey, bk) + local sum = 0 + for i = 1, bk.N do + local offset = bk.sourceList[i] + if offset ~= nil and offset ~= None and offset:Get() ~= sum then + offset:Set(sum) + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) -- ← v가 nil이면 여기서 산술 에러 + end + ... +end +``` + +**구체 시나리오 — `Frame{A, B}`(정적 자식 2개, 반응형 없음, `N=2`).** + +1. `Dispatch.drive(inst, flattened)`가 배열 파트를 순서대로 순회 — + `bk.N`은 "그 `inst`의 array part 크기" 이므로 순회를 **시작하는 시점에 + 이미 `2`로 정해져 있음**(같은 절의 "저장 위치" 문단). +2. position 1(`A`)을 담당하는 말단 Handler가 `Dispatch.setLength(inst,1,1)`을 + 부름. `setLength`의 의사코드는 **끝에서 무조건 `recompute(inst,bk)`를 + 호출**(같은 절 "`setLength` 구현" 문단 — "등록 즉시 1회(Observer 자체의 + '등록 즉시 1회 실행'과 겹쳐도 무해)"). +3. 이 시점의 `recompute` 루프가 `i=1..bk.N(=2)`을 돎 — `i=1`은 + `bk.lengthList[1]=1`이라 정상. `i=2`는 **position 2(`B`)가 아직 처리되기 + 전이라 `bk.lengthList[2]`가 `nil`** — `sum += (if isState(v) then + v:Get() else v)`에서 `sum += nil`이 되어 산술 에러로 크래시. + +`sourceList`(offset) 쪽은 같은 절에 이미 nil 가드가 있음(*"[방어, +2026-08-13 여섯 번째 세션] `nil`도 같이 배제"*) — `lengthList` 쪽엔 그 +대칭 가드가 없다. 정적 자식 2개짜리 `Frame`처럼 **가장 흔한 경우**에서 +바로 재현되는 경로라, 별도 `Slot`/반응형 값 없이도 걸림. + +### 왜 지금까지 안 잡혔나 + +기존 `luau-test/` 스파이크 중 이 함수를 다루는 건 +`done/20-slot-splice-index-arithmetic.luau` 하나뿐인데, 이건 `Splice`의 +순수 배열 인덱스 산술(제거/삽입 시 뒤 요소가 몇 칸 밀리는지)만 검증하고 +**`Dispatch.drive`가 여러 position을 순차 처리하며 `bk.lengthList`를 점진적으로 +채우는 과정 자체는 다루지 않음** — 이 경로를 실행해본 스파이크가 없었다. +같은 함수의 다른 버그(offset이 자기 자신을 포함해 누적되던 off-by-one)는 +2026-08-11 세션에 이미 한 번 발견·수정됐지만(같은 문서 "recompute — 매번 +전체 순회" 문단), 이번 것은 그와 별개의 문제. + +### 사용자 답변 원문 — 진단은 맞다고 확인, 그러나 더 큰 재설계 필요 + +> setLength/setOffset 자체는 리레이아웃을 트리거하진 않아야한다고 생각함. +> 명시 리트리거를 해야하는게, 이러면 첫 실행에서 계속 recompute 비용이 +> 쌓임. 옵져버 생성 시 클로저 위쪽에 init = true 두고 처리할 필요가 +> 있는듯 하고, 각각의 process 가 setLength 를 나중에 수행했을 때는 +> recompute 를 어떻게 할지 생각해봐야할듯. 등록을 배치로 미룸은 맞는데, +> State 이 오는건 어쩌냐를 잘 모르겠음. 각각 처음에 바운딩 +> 할 땐 그럼 offset/length 어떻게 계산할지는 이것도 아직 모르겠음.. +> 생각을 더 해 + +**애초에 열려 있던 세 갈래**(사용자가 처음엔 하나로 안 좁힘) — (1) +`setLength`/`setOffsetSource` 자체는 `recompute`를 트리거하지 않아야 +한다(명시적 리트리거 필요), (2) 등록을 배치로 미루는 방향은 맞지만 +`State`처럼 값이 나중에 도착하는 경우를 어떻게 커버할지 불명, +(3) 최초 바인딩 시점의 offset/length 계산 방식 자체가 안 잡힘 — 아래가 +같은 날 후속 대화에서 이 세 갈래를 하나로 합친 결과다. + +### 해결 — Blocker 게이팅 (2026-08-18, 같은 날 후속 세션) + +**사용자가 직접 제시한 설계 원문**: + +> 각각의 length 들을 그냥 받아서 바인딩 하는게 아니야. 각 Frame 에 대한 +> Blocker 를 릴레이션으로 가지고 있고 이건 드라이빙 함수가 실행될 때 +> 생성돼. Offset/Length 소스가 설정 될 때, 특히 Length 에 있어서는 이 +> Frame->Blocker 로 있는걸 얻어와서 한번 적용하고 넣어둬. 그리고 Observer +> callback 에서도 Blocker 가 IsOn 상태면 무시해줘. 드라이브 함수가 +> 실행되어 Blocker 가 생성될 때 기본으로 On 을 해줘. 이러면 length 가 +> 변경되며 계속 recompute 되지 않아. 그런 다음 OffWithoutEmit 을 해. +> 맨 마지막으로 recompute 를 한번 하면 되는식이야. +> +> 다만, 이러면 초기에 레이아웃이 이상해져. 주의할 점은 setOffsetSource +> 를 하게 되면 이건 자기 자신 위쪽으로 있는 요소들의 length 를 합해서 +> 설정해줘야할거야. 이건 첫 for 루프에서도 작동한다고 봄. 즉, 사실 첫 +> 루프 상 recompute 자체는 필요 없어. 그리고 setOffsetSource 는 여전히 +> slot 의 실체화로 List 가 수행되기 전에 설정되어야하고, length 가 확정 +> 된 다음에 다음 요소로 넘어가야해. + +**요지 두 갈래로 나뉜다**: +1. **`recompute`를 배치가 끝날 때까지 아예 안 돈다** — owner(inst 또는 + Slot)마다 `Relate`로 들고 있는 전용 `Blocker`를 배치 시작 시 `On`, + `setLength`의 Observer 콜백(등록 즉시 1회 실행 포함)이 + `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 끝나면 + `OffWithoutEmit()` + 명시적 `recompute` 딱 1회. +2. **`setOffsetSource`는 그 즉시 앞선 형제들의 길이 합을 직접 계산해 + `:Set`한다** — recompute를 미루는 것만으로는 "초기 레이아웃이 + 이상해지는" 문제(배치 도중 `:List`가 실체화되며 옛/기본값 offset을 + 읽어버림)가 남기 때문에, offset 자체는 즉시 계산으로 옮겨 recompute를 + 기다리지 않게 함. + +**뒤이은 확인 질문 3개와 답변**(`AskUserQuestion`, 같은 세션): + +- **중첩된 Slot이 `attachSlot`을 재귀할 때도 각자 별도 Blocker가 + 필요한가?** → *"예 — 중첩마다 별도 Blocker (권장)"* — + `base/blocker-plan.md`의 재진입(네스팅) 미지원 규칙 그대로 적용. +- **런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 경우도 + 같은 위험이 있는가?** → *"그건 이미 마운트가 된 이후라서 별 상관 + 없음. 가장 큰 문제는 마운트 중간에 후행 nil 이 있는데 recompute 가 + 난다는게 문제... 정확한 내 의견은 이래: setLength 는 recompute 를 + 직접 수행하진 않고, Observer 에서 recompute 를 수행해. 맨 처음 emit + 에서도 blocker 가 on 이면 무시하는식. 그럼에도 새로운 개체가 뒤에 + 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를 + 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고 + 수행 가능함"* — 크래시는 오직 "N이 미리 정해진 채 배치로 등록"되는 + 두 자리(`Dispatch.drive`, `attachSlot`의 flush)에서만 나고, 런타임 + 단건 append는 이미 안정된 앞선 position만 참조하므로 무관함이 확정. + `setLength`가 `recompute`를 직접 안 부르고 Observer 콜백(첫 실행 + 포함)만을 경유한다는 것도 이 답변에서 확정됨. +- **`IsOn`/`HasBlocked`를 기존 `IsBlocked`/`HasBlockedEmit`과 어떻게 + 관계지을까?** → *"IsBlocked가 있다면 그냥 두어도 될듯 함. + HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 기존 필드는 그대로 두고, + `IsOn()`은 `IsBlocked`를 읽는 얇은 조회 메소드로만 추가. 처음 요청했던 + `HasBlocked`(Blocker 자신의 새 최상위 플래그)는 **신설하지 않음** — + `OffWithoutEmit()`이 각 gated state의 기존 `HasBlockedEmit`을 그대로 + 리셋해주는 것으로 충분하다고 판단. + +### 후속 정정 — `attachSlot`의 호출 순서가 뒤집혀 있었음 (같은 세션, RC-1 반영 직후) + +위 설계를 `attachSlot`에 실제로 반영하는 과정에서 사용자가 직접 짚은 +추가 결함: `attachSlot`의 기존 의사코드는 `setLength`를 먼저, `setOffsetSource`를 +나중에 불렀는데, 이건 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이 +이미 확정해둔 **"호출 순서는 `setOffsetSource` → `setLength`"** 일반 +규칙과 어긋나 있었다(RC-1로 `setOffsetSource`가 즉시 계산을 하게 되면서 +이 불일치가 드러남 — 그 전엔 둘 다 `recompute`에 얹혀 있어서 순서가 +겉으로 안 드러났었다). 사용자 확정 원문: + +> length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset +> 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨. 따라서 위가 +> 맞음 + +즉 Slot의 진짜 `.Length`는 `activateList`가 자기 `:List`를 최초 +reconcile한 **뒤에야** 확정되므로, 순서는 **`setOffsetSource`(즉시 계산) +→ (Slot이면) `activateList` 실체화 → `setLength`(그제서야 확정된 값으로 +등록) → 물리 마운트**여야 한다 — `setLength`를 실체화 전에 부르면 등록 +직후 값이 또 바뀌어 전파가 한 번 낭비된다. 평범한 Instance 요소도 같은 +순서(`setOffsetSource(None)` → `setLength(1)` → `Parent` 대입)를 따르며, +이 경로는 기존 의사코드에 아예 안 보이던 것도 이번에 같이 채워짐. + +**부수 확정 — 코루틴 yield 금지 불변식.** 사용자가 이 논의 말미에 지적: + +> 모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 할듯. 안 그럼 +> 꼬이는 문제가 발생하지 않나 생각함 + +이 배치 게이팅 전체가 "position이 항상 순서대로, 끼어드는 코드 없이 +동기로 처리된다"는 전제 위에 있어서, `Dispatch.process`/`attachSlot` +호출 체인 도중 코루틴 yield가 끼면 같은 owner의 Blocker를 다른 코드가 +그 사이에 건드릴 수 있다 — 명시적 불변식(UB 선언)으로 문서화하기로 확정. + +**반영된 곳**: +- `base/blocker-plan.md` — `IsOn()`/`OffWithoutEmit()` 신설, onunblock + 핸들이 `emit: boolean`을 받도록 변경, `state:Block()` 없이 직접 쓰는 + 용례 신설, 재진입 규칙에 이 용례의 실제 사례 추가. +- `base/dispatch-core-plan.md` — "Length/Offset" 절에 "배치 등록을 + 안전하게 만드는 Blocker 게이팅" 절 신설(`setLength`/`setOffsetSource` + 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈), 코루틴 + yield 금지 불변식 추가. +- `base/slot-plan.md` — "재귀 메커니즘" 절의 `attachSlot`이 자기 flush + 루프를 자기 자신의 Blocker로 감싸도록, 그리고 `setOffsetSource`→`setLength` + 순서로 재작성(평범한 Instance 요소의 등록도 명시적으로 채움), 런타임 + 단건 `Add` 경로는 게이팅 불필요함을 명시. + +--- + +## SL — `base/slot-plan.md`의 `:List` reconcile 트레이싱 — 새 결함 없음, 기존 미해결 갭만 재확인 + +`base/slot-plan.md`의 "구현" 절(`activateList`/`reconcile` 의사코드)을 아래 +시나리오로 손으로 실행: + +- **재정렬**(`[A,B,C]` → `[C,A,B]`, 값 동일) — `pos`/`keyIndex` 비교가 + `rawMove`를 정확한 절대 위치로 호출, 정상. +- **키 제거**(`[A,B,C]` → `[A,C]`) — 소멸 루프가 `keyIndex`(직전 사이클 전체 + key 집합)를 순회해 `B`를 `rawRemove`, 나머지 `pos` 압축도 정상. +- **필터 토글**(`updateFn`이 특정 key에 `nil` 반환) — `rawRemove`(파괴) + 경로를 타고, `pos`가 그 키만큼 증가 안 해 뒤 요소가 정상 압축됨. +- **`PopOnly` 반환 후 재등장** — `mounted[key]=nil`이지만 `userdata[key]`가 + `{old=...}`를 강하게 붙잡아 GC를 막고, 다음 사이클에 `prev=nil`로 + 받은 `updateFn`이 `ud.old`를 그대로 반환하면 `rawAdd`로 재마운트됨 — 문서 + 서술대로 정상 동작. +- **nested Slot 반환**(`isSlot(result)`) — `pos = candidateIndex - 1 + + result.Length:Get()`으로 다음 형제의 위치가 정확히 밀림, "`:List`의 + `index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함" 절의 결론과 + 일치. +- **중복 key** — `seen[key]` 체크가 `updateFn` 호출 *전에* 있어 즉시 + `error`, 상태 오염 없음. + +**PopOnly 홀드 중 키가 데이터에서 완전히 사라지는 경우**만 트레이싱으로도 +재현됨 — 소멸 루프가 `mounted[key]`(이미 `nil`)만 보고 `rawRemove`를 +건너뛰어, 그 요소가 파괴도 반환도 안 되고 참조만 끊겨 GC된다. 이건 **이미 +`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절과 `question.md` +3번(`PopOnly`로 홀드 중이던 요소의 키가 사라지면 어떻게 처분하는가)이 +알고 있는 미해결 갭** — 이번 트레이싱은 그 갭이 실제로 재현됨을 손으로 +다시 확인했을 뿐, 새 발견이 아니다. 결론은 그대로 `question.md` 3번의 +(a)/(b)/(c) 선택지에 맡긴다. + +--- + +## D — `base/dispatch-core-plan.md`의 하강 diff 재디스패치 트레이싱 — 문제 없음 + +"Dispatch 체인" 절의 `Dispatch.process`/`Dispatch.retractFrom`을 아래 +시나리오로 트레이싱: + +- **최초 마운트**(`store.key = a`, `a: State`) — `StoreBind`가 `index=1`을 + 잡고 `a:Get()`을 들고 `Dispatch.process(inst,k,realv,2)`를 재귀, `chains`에 + `[1]=StoreBind`, `[2]=TagHandler`가 순서대로 쌓임. 정상. +- **같은 핸들러로 값 교체**(`a`가 새 `Tag`를 내놓음) — `Dispatch.process`의 + (A) 분기가 인덱스 2 슬롯의 기존 `retractor`에 새 값을 넘기고 클로저를 + 교체, 인덱스 1은 안 건드림. `chains` 구조·재귀 깊이 전부 문서 서술과 + 일치. +- **`State>`** — 안쪽 재귀가 `index+1`이라는 별개 슬롯을 쓰므로 + StoreBind 싱글톤이 같은 `(inst,k)`에 두 번 매치돼도 슬롯이 안 겹침 — + "`State>`는 정상 지원 대상" 절의 결론과 일치. + +새로 발견된 문제 없음. + +--- + +## `reference/` 인용 정확성 — 표본 점검, 불일치 없음 + +`base/` 문서가 `reference/quad-v1-architecture.md`/`reference/ +comparison-fusion-vide.md`를 인용하는 자리 중 표본 3곳을 원문과 대조: + +- `base/lifecycle-pattern.md`가 v1의 GC 방지 핫팩을 인용한 자리 — 원문 + (`PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 관용구)과 + 일치. +- `base/slot-plan.md`가 v1 `mount.lua`를 분석한 자리("부모/자식 부기까지 + 했지만 다중 마운트 방지는 없었음") — 원문(`mount.lua`는 실제로 부모/자식 + 부기(bookkeeping) + 라이프사이클 파괴까지 담당하는 무거운 모듈)과 + 정합(다중 마운트 방지 부재는 문서 전체 맥락상 타당한 요약). +- `base/tween-plan.md`가 Fusion의 Tween/Spring을 반면교사로 인용한 자리 — + `reference/comparison-fusion-vide.md`의 해당 문구와 일치. + +전수 대조는 아니고(`reference/`를 인용하는 자리 전체는 `base/*.md`에서 +`reference/`로 grep한 결과 10여 곳 — 정확한 개수는 grep 결과 자체가 +소스), 표본에서 불일치가 안 나와 전수 대조로 확장하지 않음. + +## `ROADMAP.md` 마일스톤 정합성 — 1라운드 반영분 확인, 불일치 없음 + +1라운드에서 뒤집힌 것 중 `ROADMAP.md`가 언급하는 것들이 갱신됐는지 확인: +`NilHandler`/`NoneHandler` 분리(**[정정, 2026-08-18 `/code-review high`] +M2가 아니라 M7 — Modifier 마일스톤에 체크박스가 있음, M2엔 서술 문단 +안에서만 이름이 언급될 뿐 별도 체크리스트 항목이 없음**), `PopOnly` 반환 +경로(M6), `store:GetDynamic` 탑레벨/콜론 미정 표시(M3), `DI`→`D` +리네임(전역) — 전부 반영 확인됨. +`D-6`(`setLength`/`setOffsetSource` 호출 책임자를 "처음 매치한 Handler"로 +서술하던 옛 오류)의 흔적도 `ROADMAP.md`엔 없음(애초에 그 정도 구현 +디테일은 `base/`만 갖고 있고 `ROADMAP.md`는 체크박스 수준이라 옮겨붙을 +자리가 없었음). + +--- + +## [중간 상태 기록, Blocker 해법 확정 이전] 반영 + +**⚠️ 이 절은 낡았다 — RC-1이 아직 "미해결"이던 시점(사용자가 "생각을 더 +해"라며 방향을 안 정했을 때)에 세워둔 임시 조치 목록이다.** 그 뒤 같은 +세션 후속 대화에서 Blocker 게이팅 설계가 확정되며 아래 내용 대부분이 +다시 뒤집히거나 대체됐다 — **지금 유효한 반영 목록은 위 "해결 — Blocker +게이팅" 절의 "반영된 곳" 문단이 소스**, 여기서 다시 정리하지 않는다. +`question.md`는 이 중간 단계에서 `RC-1`을 추가했다가 해소 단계에서 +다시 뺀 것이라 최종 diff엔 흔적이 안 남는다(2026-08-18 감사에서 확인) — +다음 세션이 "question.md가 바뀌었어야 하는데 안 바뀌었다"고 오해하지 +않도록 남겨둠. 아래는 그 시점의 원문 그대로 보존(역사 기록): + +- `question.md` 3번에 `RC-1`을 M2 착수 전 결론 필요 항목으로 추가(→ 이후 + 해소되며 제거, `archive/question-resolved.md`로). +- `.claude/todos.md` 00번(구현 전 QA 결과 요약)의 미해결 목록에 `RC-1` + 추가, 머리말을 "M2/M3 착수 전 필요"로 갱신(→ 이후 해소 반영으로 다시 + 갱신). +- `base/dispatch-core-plan.md`의 "Length/Offset" 절 `recompute`/`setLength` + **의사코드 자체엔 손대지 않음**(nil 가드 같은 국소 수선도 넣지 않기로 + 함) — 대신 그 절 바로 위에 미해결 배너를 추가(→ 이후 의사코드 자체가 + Blocker 게이팅으로 재작성됨). +- `base/slot-plan.md`의 "재귀 메커니즘" 절 서두에 `RC-1` 포인터 추가(→ + 이후 `attachSlot` 의사코드 자체가 재작성됨). +- `ROADMAP.md` M2/M6 체크박스 3곳에 `RC-1` 경고/각주 추가(→ 이후 해결 + 표시로 갱신). +- `.claude/README.md`/`project-context.md`의 `qa-request/` 서술을 round2 + 진행 중 상태로 갱신(→ 이후 완료 상태로 갱신). + +--- + +## 진행 로그 + +**2라운드(2026-08-18) — `:List` reconcile + `recompute` 손 트레이싱, `reference/` +표본 대조, `ROADMAP.md` 정합성 확인, 그리고 같은 날 후속 세션에서 `RC-1` +해결까지 완료.** 1라운드가 "아직 안 본 것"으로 남겨둔 항목 중: + +- `slot-plan.md`의 `:List` 내부 — **트레이싱 완료**(위 "SL" 절). +- `dispatch-core-plan.md`의 `recompute` — **트레이싱 완료, `RC-1` 발견 → + 같은 날 Blocker 게이팅 설계로 해결**(위 "해결 — Blocker 게이팅" 절). +- `reference/` — **표본 점검 완료**(전수는 아님, 위 참고). +- `research/` 13개(1라운드 기록 당시 11개였으나 같은 날 세션 중 + `fastscroll-plan.md`/`spring-plan.md`가 추가돼 지금은 13개 — + 정확한 개수는 `research/` 폴더 자체가 소스) — **이번 라운드에서도 제외**, + 확정 전 문서라 우선순위 낮음(`.claude/README.md`의 `research/` 표 참고). +- 루트 `ROADMAP.md` 마일스톤 분할 — **확인 완료**(위 "ROADMAP.md 마일스톤 + 정합성" 절). + +**남은 일**: 없음 — `RC-1`까지 닫혀 2라운드 자체가 완료됐다. 3라운드가 +필요해지면(예: 이번에 새로 들어간 `attachSlot`/`recompute`/`Blocker` +의사코드를 다시 손으로 트레이싱하는 검증) 새 파일 +`pre-implementation-qa-round3.md`를 만들 것. diff --git a/.claude/question.md b/.claude/question.md index 5d6be9d..99b80f1 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -177,8 +177,10 @@ 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은 `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의 `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를 - 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **M8 착수 전 - 필요**, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절. + 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정, + 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전 + 필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 + 기본" 절. - **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 diff --git a/.claude/todos.md b/.claude/todos.md index b55caba..e486d70 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,17 +5,27 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설, 같은 날 반영 완료] 구현 전 QA 결과는 - `base/`에 전부 반영됐다 — 남은 건 아래 "결론 전에 착수 금지" 항목뿐.** - 사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과(원본 문답과 - 사용자 답변 원문은 `.claude/qa-request/pre-implementation-qa-round1.md`가 - 소스), 확정으로 +00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2라운드 전부 + `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를 + 문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은 + `.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면 반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다. + 2라운드는 `:List`의 `reconcile`/`recompute` 같은 확정 의사코드를 실제로 + 손으로 실행해보는 작업(원본과 진행 로그는 + `.claude/qa-request/pre-implementation-qa-round2.md`가 소스) — `recompute` + 트레이싱에서 `Frame{A,B}`처럼 정적 자식 2개짜리도 첫 마운트에 크래시하는 + 경로(`RC-1`)를 찾았고, 같은 날 후속 대화에서 사용자가 직접 제시한 + Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다 + (`archive/question-resolved.md`의 `RC-1` 절). - **M0/M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다** — 전부 - `question.md` 3번에 올라가 있고, 각 `base/` 문서에도 ⚠️로 표시돼 있다: + **M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다**(M0/M2는 여전히 + 막혀 있지 않음, 0번 항목 참고) — 대부분 `question.md` 3번에도 올라가 + 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 + 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 + 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, + 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — @@ -26,7 +36,10 @@ `:Unsubscribe()` 절) — M3 착수 전 확인. - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — - 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. M8 착수 전 필요. + 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정, + 2026-08-18 `/code-review high` — `ROADMAP.md`의 M6 PopOnly 체크박스와 + 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`) + 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. diff --git a/ROADMAP.md b/ROADMAP.md index b48f74d..973962a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -184,7 +184,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 (2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset" - 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) + 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소). + **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음 + 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로 + 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 + `recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는 + `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker + 게이팅" 절. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 @@ -416,6 +422,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State`도 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. + **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 + 해결됨**, 위 M2 항목 참고. - [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ `Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12 @@ -528,6 +536,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/ `removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절). + **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 + 해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기 + Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).** - [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로 확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음). `initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상