From d3f8c4d2d2d6f7b6b0c4ac44f0f4066c79afcca1 Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 13 Aug 2026 17:44:26 +0900 Subject: [PATCH] =?UTF-8?q?docs(audit):=207=EC=B0=A8=20=EA=B0=90=EC=82=AC(?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EA=B2=80=EC=A6=9D)=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=20=EB=B0=98=EC=98=81=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?3=EA=B1=B4=20+=20Slot=20=EC=96=B8=EB=A7=88=EC=9A=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=AF=B8=EB=B0=98=EC=98=81=206=EA=B1=B4=20=EC=A0=95=EC=A0=95,?= =?UTF-8?q?=20=EC=97=AD=EC=A0=84=20=EC=84=9C=EC=82=AC=20archive=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직전 6라운드 감사(에이전트 병렬)가 "수렴"으로 종료했으나, 손으로 재검증하니 가장 영향 큰 층위에서 stale이 남아 있었음: 1. CLAUDE.md "지금 할 일" 1번 본문 — 항상 로드되는 진입점인데 "스파이크 20개 중 19개를 여전히 안 돌려봄"이라고 서술(사실은 여섯 번째 세션에 런타임 12개 전원 통과), "19는 재작성 대기"(사실은 재작성 완료·통과), "설계는 더 이상 안 막힘"(실측이 0-Y를 새로 염). 4라운드에서 헤더에 정정 배너만 붙이고 본문 bullet은 안 고친 게 원인. 2. question.md 2번 — 같은 "아직 사용자가 안 돌려봄"이 잔존, 자기 문서 최상단 0-Y/0-Z와도 모순. 3. pre-implementation-audit.md — 16/17 "결과 미확인" 잔존(17 통과, 16 실패). Slot 파괴→언마운트 전환(여섯 번째 세션)의 미반영도 추가로 6곳: - slot-plan.md 최상단 "상태" 줄이 여전히 "retract=폐기"(앞에서부터 읽는 구현자가 구 모델로 짤 위험 — 4라운드가 같은 사유로 다른 곳을 고쳤던 것) - reconcile이 rawRemove(파괴)를 부른다는 서술 3곳(391/450/1708 부근). rawUnmount도 releaseOwner를 부르므로 소유권 논증 자체는 성립 — 함수명만 stale이라 논증 유지 근거를 함께 명시 - question.md 확정 요약표 Slot 행, README.md slot-plan 행(여섯 번째 세션 전환 전체가 색인에 누락 — 언마운트/rawUnmount/portal/dispose 전부) 역전 서사 archive 이전(사용자 지시): - archive/slot-discard-no-portal-reversed.md 신설 — base/slot-plan.md에 히스토리로만 남아 있던 세 덩어리(2026-08-04 "폐기, 옮기지 않음" 확정, State 왕복 분석, 포탈 검토+숙제 셋)를 원문 보존해 이전하고 본문엔 요약 포인터만 남김. slot-plan.md 1982 → 1919줄 검증했으나 정확했던 것: 4개 base 문서의 0-Z 배너, architecture.md 0-Z 포인터, ROADMAP 게이트 서술, luau-test/STATUS.md 분류, luau-test/README.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6hzeUi5QdLPEk69B6cXFa --- .claude/README.md | 3 +- .../slot-discard-no-portal-reversed.md | 170 ++++++++++++++++++ .claude/base/slot-plan.md | 158 +++++----------- .claude/question.md | 11 +- .claude/research/pre-implementation-audit.md | 12 +- CLAUDE.md | 47 ++--- 6 files changed, 262 insertions(+), 139 deletions(-) create mode 100644 .claude/archive/slot-discard-no-portal-reversed.md diff --git a/.claude/README.md b/.claude/README.md index 848c47a..f0e0ae3 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -33,7 +33,7 @@ | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `bind-system-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`를 이미 캡처)임을 명문화 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `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/bind-system-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/bind-system-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이 `rawRemove`→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록 | +| `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/bind-system-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/bind-system-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**(시그니처/범위는 `question.md` 0-B로 열림) | | `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 우선순위를 쓰는 이유) | | `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` 포인터로 압축 | @@ -88,6 +88,7 @@ | `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) | | `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 | | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | +| `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | ## 참고 diff --git a/.claude/archive/slot-discard-no-portal-reversed.md b/.claude/archive/slot-discard-no-portal-reversed.md new file mode 100644 index 0000000..2536f09 --- /dev/null +++ b/.claude/archive/slot-discard-no-portal-reversed.md @@ -0,0 +1,170 @@ +# [역전됨] Slot의 "retract = 폐기, 옮기지 않음" + "포탈은 오버엔지니어링" 결정 + +**역전 시점**: 2026-08-13 여섯 번째 세션(사용자 결정). +**원 결정 시점**: 2026-08-04 검증 라운드(폐기/no-portal), 2026-08-09 세 번째 +세션(범위 명확화), 2026-08-13 여섯 번째 세션 전반부(파생 분석 두 절). +**현재 유효한 설계**: `base/slot-plan.md`의 +"`State` 교체는 파괴가 아니라 언마운트" 절 + `unmountSlotTree`/ +`rawUnmount`. 시그니처가 열려 있는 `dispose(value)`는 `question.md` 0-B. + +이 문서는 CLAUDE.md가 2026-08-06 세 번째 세션부터 정한 archive 용법 +("완전히 뒤집힌 설계 결정을 원문 + 역전 이유와 함께 보존", 나중 +`quadnomicon` 콘텐츠 소재)에 따라, `base/slot-plan.md` 본문에서 히스토리로만 +남아 있던 세 덩어리를 원문 그대로 옮겨온 것이다. base 문서가 1982줄까지 +불어나 **구현자가 앞에서부터 읽다가 뒤집힌 "확정"을 그대로 믿는 위험**이 +같은 세션 감사에서 반복 지적됐던 게 이전 사유. + +--- + +## 1. 무엇이 뒤집혔나 (요약) + +| | 옛 결정 | 현재 | +|---|---|---| +| `State` 교체 시 이전 Slot | **파괴**(`rawRemove`→`destroySlotTree`) | **언마운트만**(`rawUnmount`→`unmountSlotTree`), 안 죽음 | +| 포탈(살아있는 Slot을 다른 곳으로) | "오버엔지니어링, 이번 마일스톤에서 안 함" | **별도 기능이 아니라 위 결정의 자연스러운 귀결** — 기본 동작 | +| 명시적 파괴 | reconcile이 알아서 해줌 | 탑레벨 `dispose(value)` — 트리가 아직 그 값을 요구하면 **거부하고 error** | +| `reconcile`이 부르는 것 | `rawAdd`/`rawRemove`/`rawMove` | `rawAdd`/**`rawUnmount`**/`rawMove` | + +**역전 근거(사용자)**: `state`가 이미 그렇게 동작함 — +`store.child:Set(otherFrame)`을 해도 quad가 이전 `Frame`을 `Destroy()`해주지 +않고 그냥 트리에서 내려올 뿐인데, `State`만 다르게(파괴로) 동작할 +이유가 없음. **"이전 값을 지울지는 그 값을 만든 쪽이 정한다"**는 +`Ref`("Destroy와 무관")/`Attribute`("명시적 `None`으로만 지움")에서 이미 +확정돼 있던 quad 전역 철학과도 같은 결. + +주목할 점: 아래 3번 원문이 스스로 **"막는 게 소유권 규칙이 아니라 '제거 = +파괴'라는 reconcile의 선택 하나뿐"**이라고 짚어냈고, 그 한 줄이 곧바로 +역전으로 이어졌다. 필요한 부품(`Extract`/`ExtractAll`/`Splice`의 비파괴 +제거, `claimOwner`/`releaseOwner`의 소유권 이양, `attachSlot`의 재마운트 +지원)은 이미 전부 확정돼 있었고, 포탈은 그것들 위에 아무것도 더 얹지 않아도 +나왔다. + +--- + +## 2. 원문 — "폐기, 옮기지 않음" 확정 (`slot-plan.md` "Slot과 Store 바인드의 관계" 절) + +> **확정(2026-08-04 검증 라운드, 메커니즘은 2026-08-12 아홉 번째 세션에 +> 정정): retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다.** +> Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot +> 상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥 +> 버림. React의 portal(`<>`)류로 나중에 옮길 수 있게 하는 것도 +> 검토됐으나 **이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — +> 필요성이 명확해지면 그때 별도로 다시 논의. + +> **범위 명확화(2026-08-09 세 번째 세션)**: 위 "폐기, 옮기지 않음"은 +> **프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울 +> 때**(retract)만의 얘기 — **사용자가 직접 `Slot:Extract(element)`를 +> 부르는 CRUD 경로는 이것과 다른 시나리오**다. Extract로 뺀 element는 +> 파괴되지 않고 호출부가 소유권을 되찾으며, **임의의 다른 Slot으로 +> 자유롭게 다시 `Add`할 수 있다** — retract가 "옮기지 않는다"고 확정한 건 +> 프레임워크가 알아서 옮겨주는 자동 portal을 안 만든다는 뜻이지, 사용자가 +> 명시적으로 두 번 호출(`Extract` 후 `Add`)해서 옮기는 것 자체를 막는 게 +> 아니다. + +**이 "범위 명확화"가 사실상 역전의 예고편이었다** — 비파괴 이동 경로가 +공개 API에 이미 있다는 걸 명시해뒀으므로, 남은 건 "프레임워크 자동 경로도 +같은 시맨틱을 쓸 것인가" 하나였다. + +--- + +## 3. 원문 — `State`가 `nil`이 됐다 돌아오는 경우 (2026-08-13 여섯 번째 세션, 사용자 질문) + +원 제목: "소유권은 정상, 단 **Slot은 파괴된다**". + +> **질문**: +> ```lua +> local stateSlot: State = Source() +> Frame { Slot { stateSlot } } +> ``` +> 에서 `stateSlot`이 `nil`로 지워졌다 다시 나타나도 문제 없는가. +> +> **소유권 bookkeeping은 정상** — `:Single`이 감싼 `:List`의 `reconcile`이 +> `data`가 빈 배열이 되면 직전 사이클 key(`true`)가 `seen`에 없으므로 +> `rawRemove(sub, prev)`를 부르고(→ `releaseOwner`), 다시 값이 오면 +> `rawAdd`(→ `claimOwner`)를 부름. 감사 수정(=`rawRemove`가 실제로 +> `releaseOwner`를 부르고, `destroySlotTree`가 `_mounted`/`_mountedInst`도 +> 되돌림) 이후로는 재클레임이 깨끗하게 됨. +> +> **하지만 그 사이에 Slot 자체가 파괴됨 — 이게 실질적인 답이다.** +> `reconcile`이 쓰는 건 `rawRemove`(= 제거 **+ 파괴**)이지 `rawExtract`(= +> 비파괴)가 아님("제거는 항상 파괴 확정이라 `Extract` 아닌 `Remove` 경로"). +> 그래서 `nil`이 된 순간 `destroySlotTree(slotA)`가 돌아 **slotA의 자식 +> Instance들이 `:Destroy()`됨**. 다시 나타날 때 같은 `slotA` 객체를 넣으면 +> `_elements`엔 이미 죽은 Instance 참조가 남아있는 상태로 재마운트되는 것 — +> 껍데기만 살아있다. 이건 이미 확정된 정책("retract/재바인드되는 slot은 +> 옮겨지지 않고 그냥 폐기된다")의 직접적인 귀결이고 이번 감사로 바뀐 게 아님. +> +> **따라서 실용적 관용구는 "Slot을 껐다 켜는" 게 아니라, Slot은 계속 두고 +> 그 *내용*을 비우는 것**(`Slot:Clear()`, 또는 `:List`의 `data`를 빈 +> 배열로) — 또는 매번 새로 만든 Slot을 `Set`하는 것. 같은 Slot 객체를 +> `nil`↔`slotA`로 왕복시키는 코드는 두 번째 등장부터 조용히 빈/깨진 +> 서브트리를 냄. + +**지금은**: 언마운트 전환으로 이 문제 자체가 사라짐 — `nil`↔`slotA` 왕복이 +정상 동작한다. 위 "실용적 관용구" 권고(같은 Slot 왕복 금지)도 함께 폐기. +단 **`Set`으로 덮어쓰기 *전에* 이전 값을 직접 `Destroy()`하는 건 여전히 +UB**(`state`에서 먼저 `frame:Destroy()`하고 `Set`하는 것과 같은 +문제) — 순서는 항상 `Set`(언마운트) → 그 다음 정리. + +--- + +## 4. 원문 — 포탈 검토 (2026-08-13 여섯 번째 세션, 사용자 질문) + +> **질문**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른 Slot을 +> 넣고 → 뽑아둔 Slot을 다른 곳에 넣을 수 있는가. 가능하다면 "포탈"이 +> 이걸로 해결됨. +> +> **현재 설계로는 불가** — 위 절과 같은 이유 하나 때문임: `reconcile`이 +> 교체 시 `rawRemove`(파괴)를 부르므로, `Get()`으로 뽑아둔 레퍼런스는 +> `Set()` 직후 **이미 파괴된 Slot**이 됨. 막는 게 소유권 규칙이 아니라 +> "제거 = 파괴"라는 reconcile의 선택 하나라는 점이 중요함. +> +> **그런데 나머지 부품은 이미 다 있음** — 그래서 사용자 지적대로 이 +> 방향은 실현 가능성이 높음: +> - `Extract`/`ExtractAll`/`Splice`가 이미 **비파괴 제거**로 확정돼 있음 — +> 필요한 시맨틱이 이미 공개 API에 존재. +> - `claimOwner`/`releaseOwner`가 이미 소유권을 정확히 이양함 — 뽑힌 +> Slot은 owner 없는 상태가 되고, 다른 곳에서 `Add`하면 깨끗이 클레임됨. +> - `attachSlot`은 **재마운트를 구조적으로 이미 지원** — `_mounted`/ +> `_mountedInst`를 새 target으로 세팅하고 `_elements`를 새 물리 부모로 +> flush하는 순수 구조 로직이라, 자식이 파괴만 안 됐다면 그대로 옮겨감. +> +> **남는 숙제(그래서 지금 확정 안 하고 열어둠)**: +> 1. **어느 경로가 비파괴가 되어야 하는가** — `reconcile` 전체를 +> `rawExtract`로 바꾸면 "안 쓰는 서브트리가 조용히 안 죽는" 누수가 되기 +> 쉬움. 사용자가 명시적으로 고르는 opt-in이 맞아 보임. +> 2. **`destroySlotTree`가 하는 나머지 일들의 짝** — 자식 observer +> `unbindLifetime`, `Dispatch.setLength`/`setOffsetSource`로 옛 owner에 +> 등록해둔 항목 정리. 비파괴 경로는 이것들을 "해제 후 새 owner에 재등록" +> 해야 하는데, 지금 `attachSlot`은 등록만 하고 해제하는 짝이 없음. +> 3. **`Extract` 후 재마운트 전까지의 중간 상태** — 소유자 없이 물리 +> 트리에서도 떨어진 Slot이 얼마나 오래 떠 있어도 되는지. + +**세 숙제의 결말**(전부 같은 세션에 해소): +1. **사라짐** — opt-in이 아니라 기본 동작이 됐으므로 "어디에 opt-in할지"라는 + 질문 자체가 없어짐. +2. **해소** — 새 API 필요 없음(사용자 지적). 옛 owner에 대해 + `setOffsetSource(ownerKey, position, None)` **다음에** + `setLength(ownerKey, position, 0)`을 부르면 됨(이미 확정된 "마운트 안 + 하는 위치는 `0`/`None` 등록" 관용구 그대로). **순서가 중요** — + `setLength`가 끝에서 `recompute`를 돌리므로 먼저 부르면 아직 남아있는 + 옛 `Source`에 헛된 `:Set()`이 날아감(`base/slot-plan.md`의 ⚠️ 절). +3. **해소** — "아무도 안 들고 있으면 GC, 지금 죽이려면 `dispose`". + +`state>`류로 offset이 밀리는 문제는 `state>`와 같은 +범주로 **"그냥 확인된 것"으로 수용**(평탄화 도구가 처리, 케이스 드묾). + +--- + +## 5. 파급 — 이 역전이 건드린 곳 + +- `base/slot-plan.md` — `rawUnmount`/`unmountSlotTree` 신설, + `reconcile`이 부르는 함수 교체, 문서 앞부분 "상태" 줄의 "retract=폐기" + 표기 정정(2026-08-13 감사에서 뒤늦게 발견된 잔존). +- `.claude/question.md` — 0-B(`dispose`)/0-C(포탈) 신설, 확정 요약표의 + Slot 행 정정. +- `.claude/README.md` — `slot-plan.md` 행에 이 역전 반영. +- `ROADMAP.md` — 비파괴 경로 `unmountSlotTree`를 별도 구현 항목으로 추가. +- `research/documentation-content-map.md` — "retract=폐기 확정 히스토리 + (portal 검토 후 기각)"를 심화 문서 소재에서 **"왜 한때 destroy+no-portal로 + 결정했었는가"라는 히스토리 소재**로 격하. diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index de3bad7..a4e4e7f 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -10,7 +10,9 @@ > 구현하면 옛 모델로 짜게 됨** — 반드시 > `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. -**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, retract=폐기)과 +**상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, **[2026-08-13 +여섯 번째 세션 역전] retract=언마운트** — 옛 "retract=폐기"는 뒤집혔음, +아래 "`State` 교체는 파괴가 아니라 언마운트" 절이 정본)과 소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/ architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본: `.claude/initreq/raw-userinput.md` "slot을 구현하도록 하기로 했음" 절. Fusion의 @@ -389,8 +391,11 @@ end 두 번째 호출이 덮어써 첫 번째 `Source`가 고아가 됨. **해법의 근거 — nested엔 "재클레임"이라는 개념이 애초에 없다.** `:List`의 -`reconcile`은 요소를 바꿀 때 항상 `rawRemove(prev)`(→`releaseOwner`) 다음에 -`rawAdd(result)`(→`claimOwner`) 순서로 부르고(아래 "구현" 절 의사코드), +`reconcile`은 요소를 바꿀 때 항상 `rawUnmount(prev)`(→`releaseOwner`) 다음에 +`rawAdd(result)`(→`claimOwner`) 순서로 부르고(아래 "구현" 절 의사코드 — +**[정정, 2026-08-13 여섯 번째 세션]** 예전엔 `rawRemove`(파괴)였으나 +언마운트 전환으로 바뀜, `rawUnmount`도 `releaseOwner`를 똑같이 부르므로 +이 소유권 논증 자체는 그대로 성립), `rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가 갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건 error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로 @@ -447,8 +452,10 @@ releaseOwner(element, slot) 예전 버전은 "이미 같은 owner면 `false` 반환"이었고 `rawAdd` 호출부는 그 반환값을 아예 안 봐서, `local a = Slot{}; Slot { a, a }`가 조용히 통과했음 (위 "Slot과 Store 바인드의 관계" 절의 전면 정정 참고). nested 경로엔 -재클레임이 정당한 경우가 하나도 없으므로(reconcile은 항상 `rawRemove`→ -`rawAdd` 순서, `rawMove`/`rawSwap`은 클레임 미접촉) 무조건 error가 맞음 — +재클레임이 정당한 경우가 하나도 없으므로(reconcile은 항상 `rawUnmount`→ +`rawAdd` 순서 — **[정정, 2026-08-13 여섯 번째 세션]** 옛 `rawRemove`(파괴)에서 +바뀌었으나 둘 다 `releaseOwner`를 부르므로 논증 동일, `rawMove`/`rawSwap`은 +클레임 미접촉) 무조건 error가 맞음 — top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. **[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨.** `elementOwner`는 @@ -487,22 +494,16 @@ nested `Slot`을 파괴 후 재사용하는 경로가 정확히 이 케이스). Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 동일하게 적용. -> **🔄 [역전됨, 2026-08-13 여섯 번째 세션] 아래 "폐기, 옮기지 않음" 결정은 -> 뒤집혔습니다 — 지금은 `State` 교체가 **파괴가 아니라 언마운트**이고, -> **portal은 별도 기능이 아니라 그 결정의 자연스러운 귀결**입니다. -> 최신 설계는 이 문서 아래쪽 "`State` 교체는 파괴가 아니라 언마운트" -> 절(+ `unmountSlotTree`)이 정본. 아래 문단은 그 결론에 이르기까지의 -> 히스토리로만 남겨둡니다 — **여기 적힌 "확정"을 그대로 믿고 구현하지 말 것.** +> **🔄 [역전됨, 2026-08-13 여섯 번째 세션 — 원문은 archive로 이동]** +> 여기 있던 **"retract/재바인드되는 slot은 그냥 폐기된다"+"portal은 +> 오버엔지니어링이라 안 함"** 확정은 뒤집혔습니다 — 지금은 `State` +> 교체가 **파괴가 아니라 언마운트**이고, **portal은 별도 기능이 아니라 그 +> 결정의 자연스러운 귀결**입니다. 정본은 이 문서 아래쪽 +> "`State` 교체는 파괴가 아니라 언마운트" 절(+ `unmountSlotTree`). +> 원문·역전 근거·파급 범위는 `archive/slot-discard-no-portal-reversed.md`. -**확정(2026-08-04 검증 라운드, 메커니즘은 위처럼 2026-08-12 아홉 번째 -세션에 정정): retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다.** -Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot -상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥 -버림. React의 portal(`<>`)류로 나중에 옮길 수 있게 하는 것도 검토됐으나 -**이번 마일스톤에서는 오버엔지니어링으로 판단, 하지 않음** — 필요성이 명확해지면 -그때 별도로 다시 논의. - -> **범위 명확화(2026-08-09 세 번째 세션)**: 위 "폐기, 옮기지 않음"은 +> **범위 명확화(2026-08-09 세 번째 세션, 이 조항은 지금도 유효)**: 위 +> (지금은 역전된) "폐기, 옮기지 않음"은 > **프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울 > 때**(retract)만의 얘기 — **사용자가 직접 `Slot:Extract(element)`를 > 부르는 CRUD 경로는 이것과 다른 시나리오**다. Extract로 뺀 element는 @@ -1705,14 +1706,17 @@ return Slot { **결론: 후자가 맞고, 실제로 성립함.** 위 sugar 때문에 구조는 항상 `outer._elements[i] = sub`(래퍼 Slot, 이 위치에 **영구 고정**) → `sub:Single(state)` → 그 `:List`의 `reconcile`이 안쪽만 교체 — 이고, -`reconcile`은 `result ~= prev`일 때 **`rawRemove(self, prev)` 다음에 -`rawAdd(self, result, pos)`** 순서로 부름(위 "구현" 절). 즉 소유권이 +`reconcile`은 `result ~= prev`일 때 **`rawUnmount(self, prev)` 다음에 +`rawAdd(self, result, pos)`** 순서로 부름(위 "구현" 절 — **[정정, +2026-08-13 여섯 번째 세션]** 옛 `rawRemove`(파괴)에서 비파괴 +`rawUnmount`로 바뀜, 다만 `rawUnmount`도 `releaseOwner`를 부르므로 +아래 소유권 표는 그대로 성립). 즉 소유권이 **반납 → 재클레임** 순서로 정확히 갈림: | 시점 | `elementOwner[innerA]` | `elementOwner[innerB]` | |---|---|---| | 최초 reconcile | `sub` | — | -| state가 B로 재설정, `rawRemove(sub, innerA)` | `nil`(releaseOwner) | — | +| state가 B로 재설정, `rawUnmount(sub, innerA)` | `nil`(releaseOwner) | — | | 이어서 `rawAdd(sub, innerB, pos)` | `nil` | `sub`(claimOwner) | 바깥(`outer`) 입장에선 `_elements[i]`가 계속 `sub`라 아무 일도 안 @@ -1724,17 +1728,18 @@ return Slot { **단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 셋을 전제함** — (1) nested `claimOwner`가 엄격(같은 owner 재클레임도 error)이라 -`Slot { a, a }`가 실제로 막히고, (2) `rawRemove`가 `releaseOwner`를 -실제로 부르고(의사코드에서 빠져 있었음), (3) `destroySlotTree`가 자식 +`Slot { a, a }`가 실제로 막히고, (2) `rawRemove`/`rawUnmount`가 +`releaseOwner`를 실제로 부르고(의사코드에서 빠져 있었음), (3) `destroySlotTree`가 자식 소유권을 GC에 안 맡기고 명시적으로 반납. 셋 중 하나라도 빠지면 이 표의 중간 단계가 어긋남. ### [전면 정정, 2026-08-13 여섯 번째 세션 후속, 사용자 결정] `State` 교체는 **파괴가 아니라 언마운트** — `state`와 완전히 동일 -아래 두 절("`State`가 `nil`이 됐다 돌아오는 경우", "포탈")은 당시 -설계(reconcile이 `rawRemove`=파괴를 씀)를 정확히 서술한 것이지만, 그 -설계 자체가 이 정정으로 **뒤집힘**. 두 절은 문제 제기 과정으로만 남겨두고, -현재 유효한 규칙은 이 절이다. +이 정정 **전에** 쓰인 두 절("`State`가 `nil`이 됐다 돌아오는 경우", +"포탈")은 당시 설계(reconcile이 `rawRemove`=파괴를 씀)를 정확히 서술한 +것이지만, 그 설계 자체가 이 정정으로 **뒤집힘** — 두 절의 원문은 +`archive/slot-discard-no-portal-reversed.md`로 옮겼고(아래쪽에 요약 +포인터만 남김), 현재 유효한 규칙은 이 절이다. **결정(사용자)**: `State`이 다른 값으로 교체될 때 이전 Slot은 **파괴되지 않고 언마운트만 된다.** 근거: @@ -1871,90 +1876,23 @@ Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 평탄화 도구(`research/operator-sugar-plan.md`)가 처리할 요소이고 실사용 케이스가 드묾. Dispatch/Slot에 별도 배관을 넣지 않음. -### `State`가 `nil`이 됐다 돌아오는 경우 — 소유권은 정상, 단 **Slot은 파괴된다** (2026-08-13 여섯 번째 세션, 사용자 질문 — **위 정정으로 뒤집힘, 문제 제기 기록으로만 보존**) +### [역전됨 — 원문은 archive로 이동] `State` 왕복 / 포탈 검토 두 절 -**질문**: -```lua -local stateSlot: State = Source() -Frame { Slot { stateSlot } } -``` -에서 `stateSlot`이 `nil`로 지워졌다 다시 나타나도 문제 없는가. +위 "언마운트" 정정 **이전에** 쓰인 두 절(`State`가 `nil`이 됐다 +돌아오면 Slot이 파괴된다는 분석, 그리고 포탈이 가능한지 검토하며 숙제 셋을 +열어둔 절)은 결론이 전부 뒤집혀 이 문서에서 뺐다 — **원문·역전 근거·숙제 +셋의 결말은 `archive/slot-discard-no-portal-reversed.md` 3~4절.** -**소유권 bookkeeping은 정상** — `:Single`이 감싼 `:List`의 `reconcile`이 -`data`가 빈 배열이 되면 직전 사이클 key(`true`)가 `seen`에 없으므로 -`rawRemove(sub, prev)`를 부르고(→ `releaseOwner`), 다시 값이 오면 -`rawAdd`(→ `claimOwner`)를 부름. 위 감사 수정(=`rawRemove`가 실제로 -`releaseOwner`를 부르고, `destroySlotTree`가 `_mounted`/`_mountedInst`도 -되돌림) 이후로는 재클레임이 깨끗하게 됨. +지금 유효한 답만 옮겨두면: -**하지만 그 사이에 Slot 자체가 파괴됨 — 이게 실질적인 답이다.** -`reconcile`이 쓰는 건 `rawRemove`(= 제거 **+ 파괴**)이지 `rawExtract`(= -비파괴)가 아님(위 "구현" 절, "제거는 항상 파괴 확정이라 `Extract` 아닌 -`Remove` 경로"). 그래서 `nil`이 된 순간 `destroySlotTree(slotA)`가 돌아 -**slotA의 자식 Instance들이 `:Destroy()`됨**. 다시 나타날 때 같은 -`slotA` 객체를 넣으면 `_elements`엔 이미 죽은 Instance 참조가 남아있는 -상태로 재마운트되는 것 — 껍데기만 살아있다. 이건 이미 확정된 정책 -("retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다", 위 "확정" -절)의 직접적인 귀결이고 이번 감사로 바뀐 게 아님. - -**따라서 실용적 관용구는 "Slot을 껐다 켜는" 게 아니라, Slot은 계속 두고 -그 *내용*을 비우는 것**(`Slot:Clear()`, 또는 `:List`의 `data`를 빈 -배열로) — 또는 매번 새로 만든 Slot을 `Set`하는 것. 같은 Slot 객체를 -`nil`↔`slotA`로 왕복시키는 코드는 두 번째 등장부터 조용히 빈/깨진 -서브트리를 냄. - -### 포탈(`Extract`로 살아있는 Slot을 다른 곳으로 옮기기) — **해결됨**(위 "언마운트" 정정으로), 아래는 그 결론에 이른 과정 - -**질문**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른 Slot을 -넣고 → 뽑아둔 Slot을 다른 곳에 넣을 수 있는가. 가능하다면 "포탈" -(위 "확정" 절에서 오버엔지니어링으로 미뤄둔 React portal류)이 이걸로 -해결됨. - -**현재 설계로는 불가** — 위 절과 같은 이유 하나 때문임: `reconcile`이 -교체 시 `rawRemove`(파괴)를 부르므로, `Get()`으로 뽑아둔 레퍼런스는 -`Set()` 직후 **이미 파괴된 Slot**이 됨. 막는 게 소유권 규칙이 아니라 -"제거 = 파괴"라는 reconcile의 선택 하나라는 점이 중요함. - -**그런데 나머지 부품은 이미 다 있음** — 그래서 사용자 지적대로 이 -방향은 실현 가능성이 높음: -- `Extract`/`ExtractAll`/`Splice`가 이미 **비파괴 제거**로 확정돼 있음 - (위 "CRUD API 확정" 절) — 필요한 시맨틱이 이미 공개 API에 존재. -- `claimOwner`/`releaseOwner`가 이미 소유권을 정확히 이양함 — 뽑힌 - Slot은 owner 없는 상태가 되고, 다른 곳에서 `Add`하면 깨끗이 클레임됨. -- `attachSlot`은 **재마운트를 구조적으로 이미 지원** — `_mounted`/ - `_mountedInst`를 새 target으로 세팅하고 `_elements`를 새 물리 부모로 - flush하는 순수 구조 로직이라, 자식이 파괴만 안 됐다면 그대로 옮겨감. - (`destroySlotTree`가 `_mounted`를 되돌리도록 한 이번 감사 수정이 - 마침 이 경로의 전제 조건이기도 함.) - -**남는 숙제(그래서 지금 확정 안 하고 열어둠)**: -1. **어느 경로가 비파괴가 되어야 하는가** — `reconcile` 전체를 - `rawExtract`로 바꾸면 "안 쓰는 서브트리가 조용히 안 죽는" 누수가 되기 - 쉬움. 사용자가 명시적으로 고르는 opt-in(예: `:Single`/`:List`의 - 옵션, 또는 "이 Slot은 portal 대상"이라는 표식)이 맞아 보임 — Attribute - 자동 unset을 opt-in 유틸로 미뤄둔 것과 같은 결. -2. **`destroySlotTree`가 하는 나머지 일들의 짝** — 자식 observer - `unbindLifetime`, `Dispatch.setLength`/`setOffsetSource`로 옛 owner에 - 등록해둔 항목 정리. 비파괴 경로는 이것들을 "해제 후 새 owner에 재등록" - 해야 하는데, 지금 `attachSlot`은 등록만 하고 해제하는 짝이 없음. -3. **`Extract` 후 재마운트 전까지의 중간 상태** — 소유자 없이 물리 - 트리에서도 떨어진 Slot이 얼마나 오래 떠 있어도 되는지(GC 앵커가 - `bindLifetime` 하나뿐인데 top-level에서 뽑히면 그 앵커도 풀림 → - 사용자가 레퍼런스를 안 들고 있으면 그냥 GC됨. 이건 오히려 자연스러운 - 동작일 수 있음). - -**결론**: "포탈은 별도 메커니즘이 필요하다"는 기존 전제는 **틀렸음** — -`Extract` 비파괴 경로 하나로 나옴. **[사용자 결정, 위 "언마운트" 정정]** -게다가 opt-in이 아니라 **기본 동작**이 됨: `State` 교체는 원래부터 -`state`와 같이 언마운트여야 했다는 판단이라, "포탈"은 별도 -기능이 아니라 그 결정의 자연스러운 귀결일 뿐. 위 숙제 1번(어디에 -opt-in할지)은 그래서 사라졌고, **2번(등록의 해제 짝)만 실제 작업으로 -남음** — 3번(중간 상태)은 "아무도 안 들고 있으면 GC, 명시적으로 지우려면 -`dispose`"로 답이 나옴. - -**실측 필요 — 새 항목 아님, 기존 실측 목록에 흡수**: `State`/`Slot` -자기 참조 제네릭 타입체크는 이미 M0/M6 실측 목록에 있던 것과 같은 급이라 -별도 항목 추가 안 함. +- **`nil`↔`slotA` 왕복은 정상 동작한다** — 언마운트 전환으로 "두 번째 + 등장부터 깨진 서브트리" 문제 자체가 사라짐. 단 **`Set`으로 덮어쓰기 + *전에* 이전 값을 직접 `Destroy()`하는 건 UB**(`state`에서 먼저 + `frame:Destroy()`하고 `Set`하는 것과 같은 문제) — 순서는 항상 + `Set`(언마운트) → 그 다음 정리. +- **포탈은 기본 동작이다** — opt-in 표식도, 새 API도 없음. 옛 owner의 + 등록 해제는 `setOffsetSource(ownerKey, position, None)` **다음에** + `setLength(ownerKey, position, 0)`(순서 중요 — 위 ⚠️ 절). ### `:List`의 `index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함 (같은 세션 후속, 사용자 발견) diff --git a/.claude/question.md b/.claude/question.md index 7949bb9..eef1779 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -396,8 +396,13 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 가능함 확인), Modifier `__index`+`table.clone` 트릭 검증(1-11, 메타테이블 참조 공유 방식 확인) — 상세는 `pre-implementation-audit.md` 해당 항목, `base/bind-system-plan.md`/`base/modifier-plan.md` 참고. **우선순위1 - 11개 전부 해소됨** — M0 착수 전 남은 건 `.claude/luau-test/` 스파이크 - 실측 결과 확인뿐(아직 사용자가 안 돌려봄). + 11개 전부 해소됨.** **[2026-08-13 갱신]** 그 다음 게이트였던 + `.claude/luau-test/` 스파이크는 **여섯 번째 세션에 첫 실측이 돌아 + 런타임 12개 전원 통과**했고(`audit/luau-test-first-run-2026-08-13.md`, + 상태판은 `luau-test/STATUS.md`), 남은 건 Studio 전용 `10`과 코드가 + 깨져 재작성이 필요한 `13`/`15`/`16`뿐. **대신 그 실측이 위 0-Y를 새로 + 열었으므로 M0 착수 전 게이트는 이제 0-Y/0-Z** — "실측 확인만 남았다"는 + 옛 서술이니 주의. - **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼 children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler (`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로 @@ -496,7 +501,7 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** | Store/State/Source 온톨로지, 인스턴스 생성/이벤트 인체공학, Ref, 남은 API 이름 | `base/bind-system-plan.md` | | Store 부작용 허용, `:With`+`:Compute`, dot-access 문법 | `base/store-semantics.md` | | 프로바이더 패턴, bind/store 구현 책임 분리 | `base/module-lifecycle-plan.md` | -| Slot 재조정, 재마운트 시 throw, retract=폐기 | `base/slot-plan.md` | +| Slot 재조정, 재마운트 시 throw, **[2026-08-13 6번째 세션 역전] retract=언마운트**(파괴 아님, portal이 그 귀결 — 옛 "retract=폐기"는 뒤집힘) | `base/slot-plan.md` | | `Connected`+GC 라이프사이클 패턴 | `base/lifecycle-pattern.md` | | Modifier(정적 merge, immutable 체이닝, State 필드 지원, `Apply`/`Overridden`/`Peek`/`isState`) | `base/modifier-plan.md` | | 컴포넌트화(플레인 함수, State/Source 경계, 컴포넌트 경계 modifier/Ref는 named parameter로 전달, multi-root 개념 폐기, `Modifier.Overridden`) | `base/component-composition-plan.md` | diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index d62b8a8..6143261 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -718,7 +718,15 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 돌려보는 것 자체는 여전히 스파이크 대상 — `.claude/luau-test/16-type- store-key-typefunction.luau`/`17-modifier-index-tableclone-chaining.luau`로 신규 추가됨(2026-08-12 열일곱 번째 세션 핸드오버 점검 중 발견된 갭, - 이전까진 이 두 항목을 커버하는 스파이크 파일 자체가 없었음). 아직 - 사용자가 안 돌려봄, 결과 미확인. + 이전까진 이 두 항목을 커버하는 스파이크 파일 자체가 없었음). + **[2026-08-13 첫 실측 라운드 결과]** `17`은 **통과**(제네릭 `__index` + + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염) — 다만 + 최초 실행은 크래시했고 그 원인이 실은 **문서 결함**이었음(`modifier-plan.md`의 + "데이터를 테이블에 직접 두고"가 self 최상위 리터럴 키로 읽히면 `__index`가 + `rawget` 성공 시 안 불려 두 번째 호출에서 죽음) — 문서 수정 후 통과. + `16`은 **실패** — `types.newfunction` 시그니처가 설치된 Luau 버전의 실제 + API와 안 맞음(문서가 스스로 예비해둔 케이스), 실제 API 재확인 후 재시도 + 필요. 상세는 `audit/luau-test-first-run-2026-08-13.md`, 상태판은 + `luau-test/STATUS.md`. - **나머지**: 해당 마일스톤 착수 시점에 이 문서를 다시 열어 관련 항목만 확인하면 됨 — 지금 전부 결정할 필요는 없음. diff --git a/CLAUDE.md b/CLAUDE.md index a8f442c..556007c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,29 +155,30 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 이후 같은 날 발견돼 실제로는 게이트가 하나 더 있음 — "유일한"은 그 발견 전 서술이 안 갱신된 stale, 0-Y/0-Z가 최우선으로 먼저 해소돼야 함**): - - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개 — - 2026-08-12 열일곱 번째 세션에 16/17 추가, 2026-08-13에 세션 8~21이 - 만든 커버리지 갭을 메우는 18/19/20 추가) 스파이크 결과** — - **[2026-08-13] `10`의 A 섹션 일부(ClassName 신호 미발화, Destroy 시 - Connection.Connected 즉시 전환)가 처음으로 부분 확인됨**, 결과는 - `.claude/audit/gcconn-trick-verification.md`. 나머지 19개 전체(신규 - `18`/`19`/`20` 포함) + `10`의 남은 부분(A-1/A-2/B/C)은 여전히 사용자가 - `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 안 돌려봄 — 결과가 - 나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선 - 확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고, - 없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌 - 게 없음 — `.claude/question.md` 2번이 최신 상태. **[2026-08-13 다섯 - 번째 세션 추가 주의]** `Dispatch`가 인덱스 기반으로 전면 재설계됨 - (`base/bind-system-plan.md` "Dispatch 체인" 절, `chains`/`retractUnder` - 대신 `retractFrom`+`process`가 반환하는 클로저) — 기존 `luau-test/04` - (다단 체인 스트레스 테스트)는 옛 모델(핸들러 identity 기반 `chains`) - 전제로 쓰여 있어서, 돌려보기 전에 새 모델에 맞춰 먼저 다시 써야 함 - — **[2026-08-13 여섯 번째 세션에 완료]** `04-dispatch-chain- - retractFrom.luau`로 전면 재작성됨(인덱스 기반 3단 체인 + 같은 세션 - 감사가 발견한 `chains:SetStrong` 순서 버그를 재현하는 음성 대조군 - 포함). 대신 `luau-test/19`의 B/C 섹션이 폐기된 설계(`rawNew`+ - `owners`, 3분기 `claimOwner`)를 검증 중인 게 새로 드러나 재작성 - 대기 — 무엇을 어떻게 고쳐야 하는지는 그 파일 헤더 배너에 적어둠. + - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크 + 결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].** + **상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 / + 스파이크 깨짐 / 미실행 4분류), 실행 결과 상세는 + `.claude/audit/luau-test-first-run-2026-08-13.md`. 요지만: + - **런타임 12개 전원 통과**(01~07/11/17/18/19/20, crash 0 / FAIL 0) — + 특히 `07`이 연쇄 GC를, `18`이 두-`Relate` 상호 순환 미해제를 실측 + 확정해 GC-native 아키텍처의 핵심 전제가 검증됨. `04`는 같은 세션 + 감사가 찾은 `chains:SetStrong` 순서 버그를 음성 대조군으로 재현. + - **타입 쪽에서 하나가 걸림** → 그게 위 **0-Y**. 나머지 타입 스파이크는 + 판정 완료(`09` 통과, `12`는 실패지만 문서가 이미 fallback으로 + 예비해둔 결과라 설계 영향 없음, `14`는 부분). + - **남은 것은 셋뿐**: (1) `10`은 **Studio 전용이라 `luau` CLI로 못 + 돌림** — A 섹션 앞부분만 사용자 자작 스크립트로 부분 확인 + (`audit/gcconn-trick-verification.md`), A-1/A-2/B/C 미확인. + (2) `13`/`15`/`16`은 **스파이크 코드 자체가 깨져 재작성 필요** + (설계 문제 아님 — 각각 더미 스텁 도달불가 / 음성 대조군이 + `SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0 + 실제 코드 작성에 재사용. + - **[주의] "설계가 더 이상 안 막혔다"는 이 실측 *이전* 서술이었음** — + 실측이 0-Y를 새로 열었으므로 지금은 위 0번 항목이 먼저다. + - 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된 + `rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯 + 번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님. 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는