diff --git a/.claude/README.md b/.claude/README.md index b53ed88..7b2e3c5 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -51,7 +51,7 @@ | `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로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 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 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 Init 가드(순서 의존성 해소) | -| `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`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화 | +| `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`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정 | | `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` 포인터로 압축 | diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 38281ae..72c1616 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -128,6 +128,32 @@ pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`이 절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은 `qa-request/pre-implementation-qa-round3.md`. +## [해소됨, 2026-08-19] `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +`:List` reconcile의 "파괴하지 말고 자리만 비우라" 반환 센티널은 2026-08-18 +가칭 `PopOnly`로 도입되며 사용자가 "이름은 변경될 수 있음, 더 생각해봐야함" +이라 남긴 채 `question.md` 용어 정리 항목에 열려 있었음. 사용자가 후속 +세션에서 이름 아이디어를 요청 → 여러 후보(`Bench`/`Stash`/`Hold`/`Detach` +등)를 검토한 뒤 `Detach`로 좁혔고, 이어서 "이걸 어디에 던져줘야 하나"(공개 +표면 위치)까지 같이 확정됨. 원문은 +`session/2026-08-19-02-detach-naming-and-placement.md`. + +- **이름 — `Detach`.** 근거 둘: (1) 이미 있는 `Extract`(호출자가 직접 + 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모 + 계층)에서만 떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 뜻이라 + `Extract`의 "소유권을 통째로 호출자에게 넘긴다"와 자연스럽게 구분됨. + (2) `nil`(파괴)과의 대비도 더 직접적으로 드러남. +- **공개 표면 위치 — 패키지 최상위 export.** `Slot`이 함수(팩토리)라 + `Slot.Detach`처럼 붙이려면 callable-table+메타테이블이 새로 필요한데, + sentinel 상수 하나 때문에 그 구조를 들이는 건 "실제로 관측된 문제에만 + 구조를 쓴다" 원칙에 안 맞음(사용자 지적). 대신 `None`이 이미 쓰는 패턴을 + 그대로 따름 — `None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서 + 쓰이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고 + 실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음. `Detach`도 같은 + 패턴 — 정의는 Slot 관련 파일 옆에 두고 `init.luau`에서 최상위로 재노출. +- 지금 유효한 설계는 `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이 + 소스. + --- # 확인/결정 필요 목록 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index f90037e..e2ffcd0 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -850,7 +850,7 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it "지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 — "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible = - false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시 + false`도 아니고, 언마운트도 아님 — `rawRemove`. `Detach`를 명시 반환해야 대신 언마운트+재사용됨). 편의상 `nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw @@ -987,15 +987,15 @@ end **⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale 서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로 쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil` -리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소, +리턴은 파괴가 기본 — `Detach`로만 비파괴" 절(SL-3 해소, `question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로 `updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고, "Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면 -`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이 +`nil` 대신 명시적으로 `Detach`를 반환해야 언마운트+재사용이 된다. 이 절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제 자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은 -`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위 -"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. +`Detach`(당시 가칭 `PopOnly`) 신설로 대체됐다 — 이 절을 읽고 filter를 +구현할 땐 반드시 위 "`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. **"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 @@ -1097,10 +1097,10 @@ function activateList(self, inst) local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 - -- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시. - -- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. - local popOnly = (result == PopOnly) - if popOnly then result = nil end + -- [2026-08-18, 이름 2026-08-19 확정] Detach는 "이 자리를 비우되 죽이지는 말라"는 지시. + -- 아래 "Detach" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. + local detach = (result == Detach) + if detach then result = nil end if result ~= nil then -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그 @@ -1115,10 +1115,10 @@ function activateList(self, inst) -- "`nil` 리턴은 파괴가 기본" 절이 소스: -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만** -- — state 교체와 동형, 지우라고 한 적이 없음. - -- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드. + -- (b) Detach: **언마운트만**, 재사용은 ud가 홀드. -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시. if prev ~= nil then - if result ~= nil or popOnly then rawUnmount(self, prev) + if result ~= nil or detach then rawUnmount(self, prev) else rawRemove(self, prev) end end if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준 @@ -1128,7 +1128,7 @@ function activateList(self, inst) end userdata[key] = ud -- result와 무관, 그대로 기록 - -- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당) + -- (Detach 재사용은 여기 담긴 { old = ... }가 담당) newKeyIndex[key] = pos end for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key @@ -1211,7 +1211,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 **부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고, - 값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 + 값 교체와 `Detach`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount`는 `rawRemove`의 @@ -1228,7 +1228,7 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. -### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기) +### `nil` 리턴은 파괴가 기본 — `Detach`로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기; 이름 2026-08-19 확정) **[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지 @@ -1242,17 +1242,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 |---|---|---| | 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 | | `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | -| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 | +| `Detach` | **언마운트만** + 재사용 대기 | 아래 | | 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) | -**`PopOnly`(가칭) — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** +**`Detach` — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** `filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 -파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`PopOnly`와 함께 userdata를 +파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`Detach`와 함께 userdata를 반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: ```lua -- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다 -return PopOnly, { old = prev, source = ... } +return Detach, { old = prev, source = ... } ``` - **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐 @@ -1264,7 +1264,7 @@ return PopOnly, { old = prev, source = ... } 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다. - **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를 - **둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미 + **둘 다** 지우는데, `Detach`로 홀드 중이던 요소는 `mounted[key]`가 이미 `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는 **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 @@ -1274,12 +1274,33 @@ return PopOnly, { old = prev, source = ... } (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다. **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지. -- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수 - 있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에 - 올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정. +- **이름 확정 — `Detach`(2026-08-19).** 처음엔 가칭 `PopOnly`로 도입됐고 + 사용자가 *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 + 생각해보아야함"*이라고 남겨 `question.md` 용어 정리 항목에 올라가 + 있었음 — 이후 세션에서 후보들을 검토하다 `Detach`로 확정. 근거는 둘: + (1) 이미 있는 `Extract`(바로 아래 불릿, 호출자가 직접 부르는 명령형 + 추출)와 동사가 겹치면 헷갈리는데, `Detach`는 "화면(부모 계층)에서만 + 떼어낼 뿐 관리 주체는 여전히 reconcile"이라는 의미라 `Extract`의 + "소유권을 통째로 호출자에게 넘긴다"는 것과 자연스럽게 구분됨. (2) + `nil`(파괴)과의 대비도 더 직접적으로 드러남. 메커니즘(반환 규약 + + userdata 홀드 + 재마운트)은 그대로 — 원문은 + `session/2026-08-19-02-detach-naming-and-placement.md`. +- **공개 표면 위치 확정 — 패키지 최상위 export(2026-08-19).** `Slot`은 + 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 callable-table+메타테이블이 + 새로 필요한데, sentinel 상수 하나 때문에 그 구조를 들이는 건 과함 + (`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 + 구조를 복잡하게 만들지 않는다" 원칙). 대신 `None`과 같은 선례를 따른다 — + `None`도 여러 곳(Slot 요소, Attribute, offsetSource)에서 쓰이는 + sentinel이지만 공개 표면은 패키지 최상위(`quad-base/src/init.luau` + 재노출)이고 실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있다. + `Detach`도 같은 패턴 — **정의는 Slot 관련 파일(`Slot.luau` 또는 + `Dispatch/Slot.luau`) 옆에 두고, `init.luau`에서 최상위로 재노출**한다. + 지금은 `:List` reconcile 한 곳에서만 쓰이지만 `None`도 처음엔 그렇게 + 시작해 이후 재사용됐으므로 최상위에 두는 게 자연스럽다. 정확한 파일 + 배치는 M6 구현 시점에 확정. - **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 - 직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서 + 직접 부르는 명령형 경로**다. `Detach`는 같은 일을 **reconcile 안에서 선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다. ### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime` @@ -2114,7 +2135,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + `unbindLifetime` + `releaseOwner`를 부름. 2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 - 비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn`이 `nil`/`None`을 + 비파괴가 되는 건 **값 교체와 `Detach`뿐**이다. `updateFn`이 `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 @@ -2125,7 +2146,7 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 `:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 "지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" -철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를 +철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `Detach`로 그 의도를 명시한다. `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 diff --git a/.claude/question.md b/.claude/question.md index 5450334..b92797d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -48,11 +48,16 @@ "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 보완하기로 같이 확정. 코퍼스 반영 완료 — `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. -- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고 - 자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은 - 파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자: - *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 - 생각해보아야함"*. +- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: 이미 있는 + `Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데, + `Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히 + reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게 + 구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도 + 같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면 + callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라 + **패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의 + "`nil` 리턴은 파괴가 기본" 절. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -181,7 +186,7 @@ `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. -- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던 +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면 `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가 `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index d53e8db..a814e87 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1504,3 +1504,18 @@ key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도 포함)가 있었는데 그날 session/ 파일은 1개뿐, 2026-08-19는 이 세션 전까지 (QA 3라운드 커밋 1개가 있었음에도) 0개였음. 과거 대화 트랜스크립트에 접근 불가라 그 공백을 사후 재구성하는 건 허위 기록 위험이 있어 보류 — 처리 방침은 사용자 확인 대기. + +## 2026-08-19 — `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +원문: `session/2026-08-19-02-detach-naming-and-placement.md` + +가칭으로 남아있던 `:List` reconcile의 비파괴 반환 sentinel `PopOnly`의 +이름을 사용자 요청으로 브레인스토밍(풀링/보관 은유 계열, "언마운트+보류" +계열, 기존 조어 구조 계열 후보 제시) → 사용자가 이미 있는 `Extract`(명령형 +추출)와 동사가 안 겹치면서 "화면에서만 떼고 관리 주체는 그대로"라는 뜻을 +살릴 수 있다는 이유로 `Detach`를 골라 확정. 이어서 공개 표면 위치("Slot이 +함수라 `Slot.Detach`로 못 붙임")도 논의 — `None` sentinel의 선례(공개 +표면은 패키지 최상위 export, 실제 정의는 관련 로직 옆)를 그대로 따르기로 +확정. `base/slot-plan.md`/`question.md`/`todos.md`/ +`archive/question-resolved.md`/`ROADMAP.md` 전량 반영, `doc-check.py` +ERROR 0. diff --git a/.claude/session/2026-08-19-02-detach-naming-and-placement.md b/.claude/session/2026-08-19-02-detach-naming-and-placement.md new file mode 100644 index 0000000..6084b4a --- /dev/null +++ b/.claude/session/2026-08-19-02-detach-naming-and-placement.md @@ -0,0 +1,72 @@ +# 2026-08-19 — `PopOnly` → `Detach` 리네임 + 공개 표면 위치 확정 + +**요청**: `base/slot-plan.md`에 가칭으로 남아있던 `PopOnly`(`:List` +reconcile에서 "파괴하지 말고 자리만 비우라"는 반환 센티널)의 이름을 사용자가 +직접 못 정해 아이디어를 요청 — "지금은 다른 에이전트들이 있어서 쓰지는 +말아달라"는 조건으로 브레인스토밍만 먼저 진행. + +## 1. 메커니즘 재확인 + +`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절을 다시 읽고 의미를 +정리: `:List` reconcile에서 `updateFn`이 반환하는 sentinel로, 이 자리를 +언마운트(`Parent = nil`)는 하되 **파괴는 하지 말고 userdata를 들고 있다가 +나중에 재사용**하라는 지시 — 목적은 `Instance.new`/`Destroy` 비용을 아끼는 +재사용(오브젝트 풀링에 가까움). `nil` 반환(파괴)과도, 새 값 반환(언마운트만, +즉시 교체)과도 구분되는 세 번째 경로. + +## 2. 이름 후보 제시 + +풀링/보관 은유 계열(`Bench`/`Stash`/`Shelve`/`Park`), "언마운트+보류" 계열 +(`Hold`/`Detach`/`Suspend`), 기존 조어 구조를 살리는 계열(`PopKeep`/ +`KeepOnly`/`SoftPop`)을 제시하고 `Bench`(가장 직관적인 은유) 또는 `Hold` +(문서가 이미 "홀드"라는 용어를 쓰고 있어 도입 마찰이 적음) 둘 중 하나를 +추천. + +## 3. 사용자가 `Detach` 선택 — 근거 검증 + +사용자: *"Detach 많이 괜찮아보이는데, Slot 에서 뽑아내다라는 Extract 를 +지금 쓰고 있으니까, 이미 있는 Extract 를 쓰긴 뭐하니 Detach 를 넣는건 +어때보임?"* + +`Extract`/`ExtractAll`은 호출자가 직접 부르는 **명령형** 추출(소유권을 +통째로 호출자에게 넘김)인 반면, `Detach`(구 `PopOnly`)는 reconcile 내부에서 +여전히 userdata로 추적하며 화면(부모 계층)에서만 떼는 **선언적** sentinel — +"소유권 이관" vs "내부 보류 상태"로 성격이 갈려 `Extract`와 이름이 겹쳐도 +혼동 소지가 적다고 판단, 채택 추천. Roblox 개발자가 `Attachment`/ +`WeldConstraint`류의 물리적 "detach"를 먼저 연상할 여지는 있지만 "부모 +계층에서 떼어낸다"는 실제 동작과 크게 어긋나지 않아 큰 문제 아니라고 정리. +`Detach` 채택 확정. + +## 4. 공개 표면 위치 — 최상위 export + +사용자: *"근데 그러면, Detach 는 어디에서 던져줘야한다 생각함? 그냥 base +모듈 바로 최상위? Slot 이랑 연관인데 Slot 옆에 존재하게 되는게 뭔가 느낌이 +애매한거 같긴 함. 그런데 Slot 은 또 함수라서, 뭔가 넣기 애매해. 넣으려면 +메타테이블이 되어야하기도 하고. 그냥 최상위가 좋음?"* + +`architecture.md`의 패키지 트리를 확인해 `None` sentinel의 선례를 찾음 — +`None`도 Slot 요소/Attribute/offsetSource 등 여러 곳에서 쓰이는 sentinel +이지만, 공개 표면은 패키지 최상위(`quad-base/src/init.luau` 재노출)이고 +실제 정의는 관련 로직 옆(`Dispatch/None.luau`)에 있음(공개 표면과 구현 +위치가 분리돼 있는 기존 패턴). `Detach`도 같은 성격의 sentinel이므로 같은 +자리(최상위)에 두는 게 일관적이라고 판단 — `Slot`이 함수라 `Slot.Detach` +형태로 붙이려면 callable-table+메타테이블이 새로 필요한데, sentinel 상수 +하나 때문에 그 구조를 들이는 건 `conventions.md`의 "드문 오용이나 가상의 +미래 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙에 +안 맞음. 정의 파일 위치(`Slot.luau` 또는 `Dispatch/Slot.luau`)는 `None`과 +같은 패턴(정의는 관련 로직 옆, 재노출만 `init.luau`에서)을 따르되 정확한 +배치는 M6 구현 시점에 확정하기로 함. + +## 5. 반영 + +사용자: *"이거 base/slot-plan.md에 정리해서 반영해줘. 세션 기록도 +남겨주고, 핸드오버 준비해줘. 적절히 정해진것 같아서 커밋해줘도 될듯."* + +`base/slot-plan.md`("이름은 가칭" 불릿을 "이름 확정"/"공개 표면 위치 확정" +두 불릿으로 교체, 본문 전체의 `PopOnly` 표기를 `Detach`로 치환하되 사용자 +발언 직접 인용과 히스토리 서술은 유지), `question.md`(용어 정리 1번 항목을 +`DI`→`D`와 같은 형식의 `[해소됨]` 항목으로 교체, 3번의 남은 열린 항목 이름만 +치환), `todos.md`(00번/2번 항목 갱신), `archive/question-resolved.md`(새 +`[해소됨, 2026-08-19]` 절 추가), `ROADMAP.md`(M6 체크리스트의 `PopOnly` +표기 치환)에 반영. 인덱스 레이어(`README.md`)와 `session-summary.md`도 +같이 갱신, 이 문서가 그 포인터. diff --git a/.claude/todos.md b/.claude/todos.md index 2ca18f1..1a6b275 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -59,12 +59,15 @@ 시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도. - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` `:Unsubscribe()` 절) — M3 착수 전 확인. - - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. - - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — - 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정, - 2026-08-18 `/code-review high` — `ROADMAP.md`의 M6 PopOnly 체크박스와 - 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`) - 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음. + - **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면 + 위치도 `None`과 같은 최상위 export로 같이 확정). 원문은 + `archive/question-resolved.md`. + - **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분** + (`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고 + 참조만 끊김. **[정정, 2026-08-18 `/code-review high` — `ROADMAP.md`의 + M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 + 전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 + 지나칠 위험이 있었음. - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. @@ -142,7 +145,8 @@ 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) — 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 - 완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위), + 완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영 + 완료로 목록에서 빠짐**): `Slot`(2순위), `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). diff --git a/ROADMAP.md b/ROADMAP.md index 822eb25..3f321ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -395,7 +395,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). **쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고 - `:List`의 `reconcile` 중 **값 교체와 `PopOnly`(가칭) 경로만**. + `:List`의 `reconcile` 중 **값 교체와 `Detach` 경로만**. **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고 **[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이 `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의 @@ -479,14 +479,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 (filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. - **[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`이 - `PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지 - 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한 - userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트). - `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. - **⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드 - 중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요 - (`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, + **[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** — + `updateFn`이 `Detach, { old = ..., source = ... }`를 반환하면 그 + 자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, + 보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 + 반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용 + 경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/ + slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절). + **⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를 + 어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론 + 필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, `userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가 `prev`보다 앞이었음). **`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열