From f198fd9c6bbd08b7ef2adcf3c207e66ba905a852 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 23:37:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(base):=20=EC=A4=91=EA=B0=84=EA=B2=80?= =?UTF-8?q?=ED=86=A0(=EC=A7=88=EB=AC=B8=20=EB=AA=A8=EB=93=9C)=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9C=EA=B2=AC=EB=90=9C=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A8=20=EB=8B=A4=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/base/ 전체를 배치별로 리스팅해 사용자 확인을 받는 중간검토 세션 — Ref 콜백/대기자 배열의 None 소진이 무한 성장 버그였던 것을 nil로 되돌리고, isRef/isPreRef를 isState/isSource와 같은 상위-하위 합성으로 재정정, Slot CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 전환(ExtractAll/Get/IndexOf 신설), 컴포넌트 리프 바인딩에서 Source 직접 사용이 정상 경로라는 정정, Dispatch 직접 호출 UB 명시, Tag retract 전제 명시, Attribute 타입 파라미터화 확정, EffectHandle 내부 Observer cascade/Subscribe GC 예외 경고 등을 반영. CLAUDE.md에 세션 요약, stale해진 research/documentation-content-map.md 일부 항목도 동기화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/README.md | 3 +- .claude/base/attribute-plan.md | 36 +-- .claude/base/bind-system-plan.md | 211 +++++++++++++----- .claude/base/component-composition-plan.md | 43 +++- .claude/base/effect-plan.md | 36 +++ .claude/base/modifier-plan.md | 11 + .claude/base/slot-plan.md | 84 ++++--- .claude/base/tag-plan.md | 16 +- .claude/base/ui-shorthand-plan.md | 11 + .claude/question.md | 12 +- .claude/reference/comparison-charm.md | 135 +++++++++++ .claude/research/documentation-content-map.md | 28 ++- CLAUDE.md | 120 ++++++++++ ROADMAP.md | 54 +++-- 14 files changed, 645 insertions(+), 155 deletions(-) create mode 100644 .claude/reference/comparison-charm.md diff --git a/.claude/README.md b/.claude/README.md index 4d81a04..d2e26be 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -30,7 +30,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차 라운드) | | `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?)`, `keyFn` 생략 시 index 기본값, `updateFn(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가 | +| `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?)`, `keyFn` 생략 시 index 기본값, `updateFn(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | | `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` 포인터로 압축 | @@ -47,6 +47,7 @@ |---|---| | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | | `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것). **[2026-08-07 `base/`→`reference/` 이동]**, `quadnomicon` 소재 후보 | +| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, Blocker의 "previous 값 비교" 미결 문제엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 | ## `research/` — 아직 착수 전, 상의 필요 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index d147134..f00c1c7 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -1,7 +1,8 @@ # Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 -**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화 -이름만 미확정. `[Attribute "Name"]` DI 키의 존재 자체는 `architecture.md` +**상태**: base — 메커니즘/`None`/`retract` 동작뿐 아니라 타입 파라미터화도 +**둘 다 채택으로 확정**(2026-08-09 열한 번째 세션, 아래 참고). `[Attribute +"Name"]` DI 키의 존재 자체는 `architecture.md` 4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute 에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute @@ -23,23 +24,28 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att "Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입 지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). -**후보 두 가지 (미확정)**: +**확정(2026-08-09 열한 번째 세션) — 둘 다 채택**: - `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — - 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. 기본/범용 경로. - `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ - `InstanceAttribute` 등). + `InstanceAttribute` 등). 실사용 빈도가 높은 몇 개만 지름길로. -**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 -패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 -구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 -필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new(className)` -제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 -같은 모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 -기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ -`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ -`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전 -소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요. +**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 +생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 +재사용 — `new(className)` 제네릭 생성자 + 자주 쓰는 ~25개는 +정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히 +동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히 +호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 +쪽을 쓰든 런타임 동작에 차이 없음. + +**[실측 필요, M0/M10]** `[Attribute<> "name"] = value`처럼 DI +키 제네릭 파라미터로 `=` 뒤 `value`의 타입까지 실제로 좁혀지는지는 +미검증 — Luau 솔버가 이 조합을 못 풀면 `value`가 `any`로 남을 수 있음. +단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 +타입체크 실패일 뿐, `SetAttribute` 호출 자체는 항상 정상 작동) — 안 +되면 `BooleanAttribute` 같은 정적 타입 패밀리 쪽이 사실상 유일하게 +믿을 수 있는 정적 체크 경로가 됨. ## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 0c463f2..a18869f 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -365,6 +365,15 @@ function Dispatch.retractUnder(inst, k, keep, v) end ``` +- **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접 + 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** + 이유: `chains` 배열에 push하는 bookkeeping이 `Dispatch.process` 내부에만 + 있어서, `handler.process`를 직접 부르면 그 핸들러가 실제로 활성화됐는데도 + 체인에 안 올라가 — 나중에 다른 값으로 바뀌어도 `retractUnder`가 이 + 핸들러의 존재를 몰라 `retract`가 영영 안 불리거나(리소스 누수), 반대로 + 체인 순서 자체가 실제 활성 상태와 어긋나는 정합성 붕괴로 이어짐. 재귀/ + 래핑 핸들러가 위임할 때도 항상 `Dispatch.process(inst,k,newV)`를 + 불러야지 매치된 핸들러의 `.process`를 스스로 찾아 직접 호출하면 안 됨. - **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) @@ -450,8 +459,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind 재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). - **실제 마운트를 하지 않는 위치(Ref/PreRef 등)는 `None`을 등록** — 순서 - 계산에 참여할 게 없다는 명시적 선언. + **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에 + 참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열 + 위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로 + 캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도 + 같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의 + "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 + 길이 합계와 실제 순서 계산이 어긋남). **둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB (2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — @@ -467,12 +481,15 @@ lazy 생성. 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) `nil`을 넣으면 (1) 그 자리가 "안 채워짐"과 구별이 안 되고 (2) 배열이 구멍 나면서 순수 array 취급이 깨져 접근 비용이 올라감(해시 파트로 밀림) -— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, `Ref` -콜백/대기자 배열·PreRef pre-pass에 이미 적용된 것과 같은 원칙(위 "왜 -`nil`이 아니라 `None`인가" 절 참고). 다만 `recompute`가 `1..N` 고정 -범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 자체는 안 -생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 여부 -구별과 접근 비용" 쪽. +— `None`은 실재하는 값이라 자리를 "채워짐"으로 유지시켜줌, PreRef +pre-pass 소진 슬롯에 이미 적용된 것과 같은 원칙(위 "PreRef" 절의 +"왜 `None`이 아니라 `nil`인가" 참고 — **단, 그 절에서 최종적으로 `nil`로 +되돌아간 건 Ref 콜백/대기자 배열 한정**이고 `sourceList`/PreRef +pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀히 구별해야 +하는 배열은 여전히 `None`이 맞음, 헷갈리지 말 것). 다만 `recompute`가 +`1..N` 고정 범위를 도는 인덱스 `for`라 애초에 성긴 정수 키 순회 문제 +자체는 안 생김 — `None`이 필요한 이유는 순회 순서 보존이 아니라 "채워짐 +여부 구별과 접근 비용" 쪽. **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: @@ -722,8 +739,19 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 **`None`으로 소진** — 아래 구현 디테일의 - 2026-08-07 열 번째 세션 정정 참고, 단순 `nil` 처리는 아님). + 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고, + **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, + 바로 아래 캐비엇 참고). + - **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 + `__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** — + Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`를 + `self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 + 같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, + 위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do` + 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 + 경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`로 + 오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면 + 이 충돌 자체가 안 생김. - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -737,38 +765,45 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume - payload 정정, 열 번째 세션에서 소진 방식 정정)**: 값이 새로 `:Set()`될 - 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 + payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 + `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`를 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 - 이번 세션에 정정) 후 **`[i] = None`**(**`nil`이 아님** — 아래 - "왜 `nil`이 아니라 `None`인가" 참고), 아니면 일반 콜백 함수로 보고 - 그냥 `v(value)`(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 - 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 - 값 타입 하나로 분기 가능(`type(v) == "thread"` → 대기자, - `type(v) == "function"` → 콜백, 그 외/`None` → 빈 슬롯이라 스킵). - 새 콜백/대기자 등록은 `table.insert`로 끝. - - **왜 `nil`이 아니라 `None`인가(2026-08-07 열 번째 세션, 사용자가 실제 - Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 - `for`가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음.** 대기자/콜백 - 자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 - 하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 - 저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(`nil`로 지운 슬롯도 - 포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 - 버킷 순서가 되어버림(사용자가 `{[1]=1,[2222]=2222,[211]=211,...}`류 - REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 - 안 되지만, 순서가 실제로 중요한 다른 배열(`PreRef` pre-pass 등)엔 - 치명적). (2) `table.insert`가 내부적으로 쓰는 `#t`(length 연산자)는 - Lua 명세상 구멍이 있는 테이블에서 **정의되지 않은 동작**이라, 다음 - 콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 - 있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. - `None`은 `nil`이 아닌 **실재하는 값**이라 그 슬롯이 "차 있다"는 사실 - 자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil - 값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음. + 정정) 후 **`[i] = nil`**로 소진(아래 "왜 `None`이 아니라 `nil`인가" + 참고), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 여전히 + 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 + 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능 + (`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백, + `nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가 + 아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는 + 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그 + 자리를 되찾아 쓸 수 있음. + - **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정) + — 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안 + 맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는 + 정수 키가 해시 파트로 튀어 순회 순서가 깨짐, `table.insert`의 `#t`가 + 구멍 있는 테이블에서 미정의 동작)는 **순서가 실제로 중요한 배열** + (`PreRef` pre-pass, Length/Offset의 `sourceList` — `1..N` 고정 + 범위로 도는 `for` 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만, + Ref의 콜백/대기자 배열은 애초에 **순서가 중요하지 않다**(어느 게 + 먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 `for i,v in tbl do`는 + 구멍이 있어도 순서가 뒤섞여도 **모든 엔트리를 빠짐없이 방문**하므로 + "순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 `None`을 + 쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 **매 `:Wait()` + 호출마다 배열이 끝없이 길어지는** 새 문제가 생김(등록이 항상 끝에만 + 추가되고 예전 슬롯을 재사용 못 함) — `nil`로 지우면 다음 등록이 그 + 빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨. + `table.insert`의 `#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈 + 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던 + 이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이 + 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열 + (PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속 + `None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 + 이유가 없었음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -894,11 +929,14 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] - = None`**으로 소진(`nil`이 아님 — 위 "왜 `nil`이 아니라 `None`인가" - 절과 같은 이유, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 - 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 - 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 - 보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + = None`**으로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`로 + 지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 + 순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 + 바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref + 자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로 + 되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서 + 보장이 실제로 필요한 별개 케이스라 `None` 소진이 계속 맞음, 두 + 사례를 혼동하지 말 것**). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 @@ -918,6 +956,17 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 이 오탐을 막기 위해 반드시 필요. + - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — + `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 + 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, + 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 + `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스 + 루프가 `Dispatch.process` 호출 전에 자기 스스로 + `if v == None then continue end`로 걸러냄). `NoneHandler`는 + `Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 + `None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로 + `NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 + 정확한 설명. - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 @@ -1375,6 +1424,18 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 포인트). +- **⚠️ 이건 quad 전역의 "정리는 기본적으로 GC에 위임" 원칙의 의도적 + 예외 — 문서에 명시적으로 경고할 것(2026-08-09 열한 번째 세션).** + `:Subscribe()`로 등록한 뒤 로컬 변수 참조를 전부 놓아도(스코프 이탈, + 변수 재할당 등) **GC되지 않고 영원히 계속 실행됨** — 강참조 + 레지스트리가 그 자체로 생존을 보장하기 때문. `bindLifetime`(leaf + 부착 포함) 경로는 `inst`가 죽으면 자동으로 정리되는 GC-native 그대로지만, + `:Subscribe()` 경로는 오직 명시적 `:Unsubscribe()` 호출로만 끊김 — 이 + 차이를 모르고 "quad는 다 GC-native니까 참조만 버리면 되겠지"라고 + 가정하면 조용한 누수(메모리뿐 아니라 계속 재실행되는 콜백까지)로 + 이어짐. 용도도 "완전히 top-level(어떤 Instance 생명주기에도 안 묶인) + 사이드 이펙트"로 좁게 문서화할 것 — 특정 `inst`에 묶인 경우는 + `:Subscribe()`가 아니라 leaf 부착(`bindLifetime`)이 정상 경로. - **`:Subscribe()`/`:Unsubscribe()` 둘 다 `self`를 리턴(대칭)** — `local obs = state:Observer(fn):Subscribe()`처럼 "구독 시작 + 나중에 끊을 핸들 확보"가 한 줄로 되고, `table.insert(subs, state:Observer(fn) @@ -1724,6 +1785,20 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사 구현 지양, 팩토리 함수로 대체" 원칙과도 정확히 일치. `Store({defaults})`도 같은 스타일로 지원(`defaults`는 선택 — 안 주고 `Store()`만 호출해도 됨, 순수 편의용 초기값 템플릿). +- **[보강, 2026-08-09 열한 번째 세션] `Source(default)`/`Ref(default)`의 + `default` 인자가 "선택"이라는 서술은 정확히는 `T`가 `nil`을 포함할 때만 + 성립함 — 생략하면 실제로 `nil`이 그 자리를 채우기 때문.** `Source()` + (무인자)는 `Source(nil)`과 동치라고 이미 명시돼 있으나, 이게 타입 + 레벨에서 뭘 뜻하는지(`T`가 nilable이 아니면 타입과 실제 저장값이 + 어긋난다는 것)는 지금까지 명시적으로 안 적혀 있었음. `Ref`도 마찬가지 + 캐비엇이 있고 오히려 더 눈에 띄게 드러남 — `:Callback(fn)`은 등록 + 즉시 그 시점 값으로 무조건 1회 호출되므로(미설정 상태여도 그 상태 + 그대로 호출, 아래 `Ref` "바인드 방법" 절 참고), `default`를 생략한 + `Ref()`에 콜백을 걸면 그 콜백이 즉시 `nil`로 한 번 불림 — `T`가 + non-nilable이면 이 시점에 이미 타입 위반. 따라서 `default`를 생략해도 + 되는 건 오직 `T`가 nilable(`T?`)로 선언된 경우뿐이라는 걸 문서 차원에서 + 명시할 것(non-nilable `T`에 `default` 없이 생성하는 건 사용자 실수, + 타입으로 막을 수 있으면 막고 안 되면 UB로 문서 경고). **[정정, 2026-08-07]** 아래 두 문장은 이후 라운드에서 정정된 옛 서술 — 실제 메커니즘·mutate 취급은 `base/store-semantics.md` "Source가 State를 만족함" 절이 최종 소스: (a) "`__newindex`/`__index` 프록시로 감싸면 @@ -1961,16 +2036,27 @@ Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교 **`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — 단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 -관계(subtype)가 있어 집합 멤버십이 필요한 경우(`isState`)로 갈림: +관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우 +(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 +"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을 +"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로 +재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지 +(포함 관계의 방향)가 코드 모양 자체에 드러나게 함: ``` -local function isState(x) - local t = Brand.get(x) - return t == StateTag or t == SourceTag -- Source가 State를 구조적으로 만족 -end local function isSource(x) return Brand.get(x) == SourceTag end +local function isState(x) + return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족 +end + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + return isPreRef(x) or Brand.get(x) == RefTag -- PreRef가 Ref 런타임을 재사용 = Ref의 한 종류 +end ``` **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 @@ -1985,20 +2071,25 @@ end 불필요" 서술도 같이 정정 대상. **갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 -추가(2026-08-07 열 번째 세션).** 위 코드 예시가 원래 `RefTag`/ -`PreRefTag`/`ModifierTag`를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 -`isPreRef(v)`, `component-composition-plan.md`의 `isModifier(v)` 등)이 -이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 -게 맞아서 태그 목록에 추가. **`isRef`/`isPreRef`는 `isObserver`와 같은 -단순 항등**(`isRef(x) = Brand.get(x) == RefTag`, `isPreRef(x) = -Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 -**`isRef(preRefInstance)`는 `false`.** 이게 중요한 이유: `PreRef`가 -"Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 `isRef`가 -`PreRef`도 통과시키면, 일반 `(v=Ref)` 매치 핸들러가 `PreRef` 인스턴스도 -집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 -됨"이 깨짐 — `Ref`/`PreRef`는 State/Source 같은 상하위 관계가 아니라 -서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 -(`Brand.get(x) == ModifierTag`). +추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가 +재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를 +`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로 +취급(`isRef(preRefInstance)`가 `false`)했으나, 이건 `isState`/`isSource` +쌍과 비일관적이었음 — `Source`가 State를 구조적으로 만족하듯, +**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함 +방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘. + +- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) == + PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로 + 얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)`는 `true`.** +- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는 + 이제 `isHandlable`을 `isRef(v) and not isPreRef(v)`로 명시적으로 + 좁혀야 함** — 예전처럼 `isRef` 자체가 배타적이라 저절로 걸러지는 게 + 아니라, "Ref이긴 한데 그 중 PreRef는 아니다"를 호출부가 명시적으로 + 말해야 하는 모양으로 바뀜(PreRef pre-pass가 이미 소진시켜 정상 경로에선 + 거의 안 걸리지만, 위 "PreRef" 절의 동적 경로 가드 Handler와 이 조합이 + 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장). `isModifier`도 같은 + 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념 없음). **같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — `Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 8cbc275..3457d0e 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -90,13 +90,29 @@ Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store 생기는거지. 따라서 실제 사용은 제한적일듯. callback을 쓰는게 일반적이여 보이긴 해. 타입으로도 편하기도 하고 디버깅도 편함"). -### 5. 리프(Roblox 프로퍼티) 바인딩엔 원칙적으로 State만 +### 5. 리프(Roblox 프로퍼티) 바인딩 — Source 직접 바인딩도 정상 경로, +"좁은 예외"라는 표현이 오해를 유발해 정정함(2026-08-09 열한 번째 세션) -계산된 최종값만 실제로 인스턴스에 반영되어야 하므로, 리프 바인딩은 State가 -일반 경로. Source는 리프 바인딩용 프리미티브가 아니라, 아주 단순한 구조에서 -콜백 보일러플레이트를 줄이기 위한 좁은 용도의 예외 — **사용자 확정** -("리프 바인딩엔 state만 쓰이지 않을까... source는 그냥 아주 단순한 -구조에서 콜백을 넣고 하는 복잡함을 줄이기 위함일 뿐임"). +**[정정] 이전 서술("Source는 리프 바인딩용 프리미티브가 아니라 좁은 +용도의 예외")은 부정확했음 — 사용자가 직접 반례를 제시: +`local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼 +Source를 리프 프로퍼티에 곧바로 물리는 건 **막힐 이유가 전혀 없고 +흔한 정상 패턴**(단순 토글/가시성 같은 값은 오히려 이 모양이 자연스러움) +— 4번 절이 이미 확정해둔 "Source가 State를 구조적으로 만족해서 +핸들러가 서브타입 호환으로 자동 통과시킨다"가 정확히 이 케이스를 +커버함, 별도 제약이 있었던 적이 없음.** + +바로잡은 원칙: **"State가 일반 경로"라는 말은 Source를 못 쓴다는 뜻이 +아니라, 리프에 물리는 값이 "여러 소스에서 파생된 계산 결과"인 경우 +(`:With`/`:Compute`로 조합된 값)엔 그 결과가 State이지 Source가 아니기 +때문에 자연히 State가 더 자주 보인다는, **결과의 통계적 경향에 대한 +서술**일 뿐이다.** 원본 값 하나를 그대로(가공 없이) 리프에 물리는 +경우(`Visible`/`Enabled`류 단순 불리언 토글이 가장 흔한 예)엔 Source +직접 바인딩이 오히려 첫 번째로 권할 만한 관용구 — `isEnabled`처럼 +여러 조건에 영향받는(파생된) 값만 원천적으로 Source가 될 수 없는 +경우(그런 값은 애초에 `:Compute`로 만들어진 State일 수밖에 없어서), +그 경우에 한해 "State/콜백 패턴이 기본"이라는 4번 절 서술은 그대로 +유효. ## 프레임워크 사례 조사 (2026-08-04, modifier/Ref 경계 통과 문제 관련) @@ -206,8 +222,12 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 `nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 -있음(`base/bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절 — Luau -REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향). +있음(`base/bind-system-plan.md`의 PreRef pre-pass 절이 다루는 것과 같은 +부류의 문제 — Luau REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 +테이블 전체에 영향. **[주의]** 순서가 안 중요한 Ref 자신의 콜백/대기자 +배열은 반대로 `nil` 소진이 맞다는 정정이 따로 있음 — 여기서 다루는 건 +컴포넌트가 넘기는 **리터럴 children 배열**(순서가 중요한 배열)이라 +그 정정과 무관하고 `None` 관용구가 계속 맞음). 그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**: ```luau return Frame { props.Modifier or None, props.Ref or None, child } @@ -254,7 +274,12 @@ Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이 파라미터를 선언하지 않으면 그만 — 타입 시그니처 자체가 "나는 단일 대상에게 적용할 modifier/Ref가 없다"를 표현. 별도 조율 메커니즘 불필요 — **사용자 확정**("불가능하진 않고 기술적으로도 충분히 되는 일... 엄청 집중해야할 - 일은 아니지 않을까"). + 일은 아니지 않을까"). **[재확인, 2026-08-09 열한 번째 세션]** 새 배선 + 없이 그대로 작동함을 재확인 — `Frame { Comp{} }`에서 `Comp`가 `Slot`을 + 반환하면, 그 반환값이 그냥 children 배열의 한 항목(값)이 되고 + `Dispatch/Slot.luau`의 기존 Slot 매치 핸들러가 평소처럼 처리(값이 + 컴포넌트 호출로 왔든 리터럴로 직접 놓였든 디스패치 입장에선 구분이 + 없음) — 이 경로 전용 특수 취급이 전혀 필요 없다는 뜻. 이 정리로 원래의 "모호해지는 케이스"는 사라짐: 컴포넌트가 단일 root를 갖는 한 named parameter로 명확히 전달되고, 단일 root가 없는 컴포넌트(Slot 반환)는 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 8d74af6..28e3a98 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -58,6 +58,31 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. +**보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 +세션, 재확인 후 명시화)**: + +- **`EffectHandle`은 내부 Observer를 필드로 강참조** — `handle._observer = + observer`(`state`가 주어진 경우만 존재). 이건 GC 방지가 목적이 아니라 + (그건 아래 `bindLifetime`/`gchold`가 담당) `:Unsubscribe()`/`bindLifetime` + cascade가 이 필드를 통해 내부 Observer에 접근하기 위한 것. +- **`bindLifetime(inst, handle)`은 `state`가 있는 경우 내부 Observer도 + 같은 `inst`로 `bindLifetime(inst, handle._observer)`를 cascade해야 + 함** — `Dispatch/Leaf.luau`가 children 배열의 `EffectHandle`을 매치해 + `bindLifetime(inst, handle)`을 부르는 시점(leaf 부착)과, `:Subscribe()`가 + `handle`을 전역 레지스트리에 등록하는 시점(아래) 둘 다 해당. 이유: + 내부 Observer 자신의 재실행 게이팅(`canExecute`)이 "`Subscribed` 필드 + + `inst`의 gcconn"을 함께 보는데, 후자는 그 Observer가 **직접** + `bindLifetime(inst, observer)`된 적이 있어야만 올바른 `inst`를 참조함 + — `EffectHandle`만 바인드하고 내부 Observer는 안 하면, 그 Observer의 + `canExecute`가 `inst` 생존을 못 보고 엉뚱하게(또는 전혀) 게이팅됨. + 같은 이유로 `unbindLifetime(inst, handle)`도 내부 Observer까지 같이 + 풀어야 대칭이 맞음. +- **`:Subscribe()`도 마찬가지로 `state`가 있으면 내부 Observer를 같은 + 전역 강참조 레지스트리에 같이 등록**(`handle` 자신 + `handle._observer` + 둘 다, 또는 `handle._observer`만으로 충분한지는 구현 세부 — 어느 쪽이든 + "`EffectHandle`은 등록됐는데 내부 Observer는 등록 안 됨" 상태가 생기면 + 안 됨). + **Observer 자체에 cleanup 반환 계약을 추가하는 안은 여전히 기각** — React `useEffect`식으로 `fn`의 반환값을 자동으로 배선해주는 안을 검토했으나, 클로저 업밸류로 이미 충분해 채택 안 함. 이 기각은 위 Effect 설계와 @@ -85,6 +110,17 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 자신(또는 `state` 있는 경우 내부 Observer)을 등록 — 새 메커니즘 아님, 기존 레지스트리 재사용. 이후 로컬 변수로 참조를 안 들고 있어도 계속 살아있음(Observer와 동일 관용구). + - **⚠️ 용도는 완전히 top-level(모듈/스크립트 레벨, 어떤 Instance + 생명주기에도 안 묶인) 사이드 이펙트로 한정할 것 — 특정 `inst`에 + 묶인 경우엔 leaf 부착(`bindLifetime`)을 쓰지 `:Subscribe()`를 쓰지 + 않는 게 정상 경로.** `:Subscribe()`를 쓰기로 했다면(top-level이든 + 의도적으로 다른 경우든) **반드시 `:Unsubscribe()`로 짝을 맞춰야 + 함** — 강참조 레지스트리는 quad 전역의 "정리는 기본적으로 GC에 + 위임" 원칙의 **의도적 예외**라, 로컬 변수 참조를 다 놓아도(스코프를 + 벗어나도) **GC되지 않고 계속 실행됨**. 이건 quad의 다른 프리미티브 + 대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자 + 문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그 + 핸들의 생애주기는 전적으로 수동 관리 대상이 됨). - **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index f78320f..ed8dfc7 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -206,6 +206,17 @@ UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에 - **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음, 4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인 값은 이 체크를 그냥 통과함). +- **한계, 명시적 UB로 남김(2026-08-09 열한 번째 세션) — `State`류 + "State/Source가 담고 있는 값"이 핸들러 계층 값인 경우는 이 체크로 + 못 잡음.** `isRef(v)` 등은 setter가 확정하는 바로 그 값(State 자체 + 또는 plain 값)만 보므로, 값이 State/Source면 그 껍데기가 `isState`를 + 통과해 검사를 그냥 지나가고, 그 State가 나중에 `:Get()`됐을 때 실제로 + 내놓는 내용물(예: 그 State가 Ref/PreRef/Observer/Effect/Slot을 값으로 + 들고 있는 경우)까지는 검사하지 않음 — 검사 시점엔 아직 실체화 안 된 + 값이라 정적으로 알 수 없고, 값이 바뀔 때마다 매번 `:Get()`해서 + 검사하는 건 관측 시점을 앞당기는 부작용까지 생기는 오버엔지니어링. + **이 안쪽 케이스는 방어 로직 없는 순수 UB로 문서화만 하고 넘어감** — + 의도치 않게 자주 발생할 이유가 없는 조합이라 실사용 위험은 낮음. - **7번 절(`State` UB)과의 비대칭이 이걸로 줄어듦** — `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index d274eea..2b7bab2 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -205,53 +205,73 @@ Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데 ## CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소) -`get`/`set`은 드롭 — 최종 표면(`Move`/`Swap`은 같은 세션 후속 논의에서 -추가, 아래 "원시 최소화 원칙 정정" 참고): +**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 +인덱스 기준으로 전환.** 원래 "인덱스는 add/remove 반복 시 곧 stale +해진다"는 이유로 레퍼런스 기준을 택했으나, 실사용에서는 반대 문제가 더 +흔함(사용자 지적) — `slot:Add(Frame{...})`처럼 호출부가 리턴값을 변수에 +안 담고 바로 흘려보내는 경우가 많아서, 나중에 그 element를 다시 골라 +Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `Add`만 +새로 넣는 대상이라 자연히 element를 직접 받고, 나머지 CRUD는 전부 +**인덱스 기준**으로 재확정 — 레퍼런스만 갖고 있으면 `IndexOf`로 먼저 +인덱스를 구하면 됨(아래): | 연산 | 시그니처 | 복잡도 | 의미 | |---|---|---|---| | `Add` | `Slot:Add(element, index?)` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 | -| `Remove` | `Slot:Remove(element)` | O(n) | 제거 **+ 파괴**(retract/Destroy) | -| `Extract` | `Slot:Extract(element)` | O(n) | 제거, **파괴 안 함** — 호출부가 소유권 회수, 임의의 다른 Slot에 재삽입 가능 | +| `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 | +| `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 | +| `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 | | `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op | -| `Move` | `Slot:Move(element, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | -| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 **인덱스**의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | +| `Move` | `Slot:Move(oldIndex, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | +| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 인덱스의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | +| `Get` | `Slot:Get(index): T?` | O(1) | 그 인덱스의 element 조회(범위 밖이면 `nil`) | +| `IndexOf` | `Slot:IndexOf(element): number?` | O(n) | element의 현재 인덱스 역조회(멤버 아니면 `nil`) — 레퍼런스만 있고 인덱스가 없을 때 다른 CRUD와 연결하는 다리 | -- **식별은 기본적으로 element 레퍼런스 기준**(`Add`/`Remove`/`Extract`/ - `Move`) — element는 컴포넌트 호출/Instance 생성이 만들어낸 구체 값이라 - 항상 아이덴티티로 구분 가능. 인덱스 기준으로 하면 add/remove가 반복될 - 때 호출부가 들고 있던 인덱스가 곧바로 stale해짐. -- **`Swap`만 예외 — 인덱스 기준(`indexA`/`indexB`), element 레퍼런스 - 아님(2026-08-09 세 번째 세션 정정).** element 레퍼런스로 받으면 Slot이 - element→index 역방향 맵을 안 갖고 있는 이상 두 element 각각의 현재 - 위치를 찾는 데 O(n)씩(총 2n) 들어서 **`Swap`이 약속하는 O(1)이 깨짐** - — `Move`는 어차피 시프트 자체가 O(n)이라 element 조회 비용이 묻히지만, - `Swap`은 조회 비용이 곧 전체 비용이라 이 차이가 그대로 드러남. 호출부는 - 보통 "지금 몇 번째 항목과 몇 번째 항목을 바꿀지"를 이미 알고 있는 - 상황(예: 드래그 리오더 UI)이라 인덱스로 받는 게 자연스럽기도 함. -- **열거(iteration)/`Get` API는 지금 안 만듦** — `Slot:List(...)`(아래)는 - 자기 자신의 key→element 맵을 따로 들고 있어 Slot의 내부 상태를 조회할 - 필요가 없음. 다른 실사용이 나오면 그때 추가(YAGNI). +- **`Extract(index, newElement?)`가 존재하는 이유** — 인덱스 기준 모델에서 + "요소 하나를 다른 걸로 교체"하려면 `Extract(index)`(O(n) 시프트) 후 + `Add(newElement, index)`(O(n) 시프트 재발생)를 따로 불러야 해서 이중으로 + 무거움. `newElement`를 같이 넘기면 그 자리 값을 시프트 없이 바로 + 갈아끼우기만 하면 되므로 훨씬 쌈 — 별도 `Set`이라는 이름 대신 `Extract`의 + 확장으로 둔 이유는 반환값이 "이전 element"라는 의미가 `Extract`와 + 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일 + 뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이 + 똑같이 적용됨. +- **`Get`/`IndexOf` 신설, 원래 "YAGNI"로 뺐던 것을 재추가.** 처음엔 + "`:List`가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가 + 불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시 + 필요해짐 — element 레퍼런스만 갖고 있는 호출부가 인덱스 기반 CRUD를 + 쓰려면 `IndexOf`가 유일한 다리. `Get`은 대칭성/일반적인 컬렉션 API + 완결성을 위해 같이 열어둠(필수까진 아니지만 비용이 거의 없어 열어둠). +- **`raw*` 내부 호출 규약은 공개 API와 다를 수 있음(구현 세부, M6에서 + 확정)** — `:List`의 reconcile은 이미 자기 `key→element` 맵을 들고 + 있어서 `rawRemove`/`rawMove` 등을 element 기준으로 계속 부를 수도 + 있음. 공개 CRUD가 인덱스를 받아 내부적으로 element를 찾아 `raw*`에 + 넘기는 얇은 변환 계층이 될지, `raw*` 자체를 인덱스 기준으로 통일할지는 + base 설계가 못박을 필요 없는 구현 디테일. - **에러 조건 — 전부 즉시 `error()`, no-op 없음**(기존 "재마운트 시 throw"와 같은 fail-fast 톤): - `Add`: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 CRUD 경로에도 동일 적용. `element`가 `nil`/`None`이거나 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절. - - `Remove`/`Extract`: 그 element가 지금 이 Slot의 멤버가 아니면 에러. - - `Move`: `element`가 이 Slot의 멤버가 아니면 에러. - - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖(1..현재 개수)이면 에러 — - 단 `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. + - `Remove`/`Extract`/`Move`: `index`(들)가 범위 밖(1..현재 개수)이면 + 에러. + - `Extract(index, newElement)`: `newElement`도 `Add`와 동일한 검증 + (이미 마운트/타입 제약) 적용. + - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖이면 에러 — 단 + `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. - **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십 weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/ `Remove`/`Extract`보다 저렴함. -- **모든 공개 CRUD는 "가드 확인 + `raw*` 위임"의 얇은 wrapper** — `Add`/ - `Remove`/`Extract`/`Clear`/`Move`/`Swap` 전부 `self._listed`(`:List`가 - 설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 `rawAdd`/ - `rawRemove`/`rawExtract`/`rawClear`/`rawMove`/`rawSwap`에 있음 — 이 - `raw*` 함수들이 `:List`의 reconcile이 가드 없이 직접 호출하는 바로 - 그 함수(아래 "`Slot:List`" 절의 "구현" 참고). 공개 메소드에 로직이 - 따로 있는 게 아니라 전부 이 한 세트를 공유. +- **공개 CRUD 중 실제로 mutate하는 것(`Add`/`Remove`/`Extract`/ + `ExtractAll`/`Clear`/`Move`/`Swap`)은 "가드 확인 + `raw*` 위임"의 얇은 + wrapper** — `self._listed`(`:List`가 설치돼 있으면 수동 CRUD 금지)만 + 확인하고 실제 로직은 `rawAdd`/`rawRemove`/`rawExtract`/`rawClear`/ + `rawMove`/`rawSwap`에 있음 — 이 `raw*` 함수들이 `:List`의 reconcile이 + 가드 없이 직접 호출하는 바로 그 함수(아래 "`Slot:List`" 절의 "구현" + 참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를 + 공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `:List`가 + 설치돼 있어도 자유롭게 호출 가능. - **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를 다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 + Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index eb01544..5694f63 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -71,6 +71,7 @@ function TagHandler.process(inst, k, v) end function TagHandler.retract(inst, k, v) + assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함") local old = relate:GetStrong(inst, k) if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end relate:SetStrong(inst, k, nil) @@ -84,11 +85,16 @@ end 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 이전 값과 diff. - **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가 - 불림 — **`v`를 굳이 안 봐도 됨**: retract는 구조상 "더 이상 Tag가 - 아니게 됐을 때만" 불리므로, 뭐가 새로 들어왔든 전체 삭제가 항상 맞는 - 동작. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 받는 건 계약 - 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 그걸 필수로 - 요구해서가 아님. + 불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히 + `v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상 + nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한 + 표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한 + 경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이 + 전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상 + 옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를 + 코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 + 받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 + 그걸 필수로 요구해서가 아님. - **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — `Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index cee3d1c..511b121 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -63,6 +63,17 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 자동 생성된 자식은 기존 관례대로 `_`/`QUAD_` 접두어 네이밍 (`research/debug-tooling-plan.md` 9번, v1의 `_quad_round`류 그대로 재사용). +**[보강, 2026-08-09 열한 번째 세션] `mod:UICorner(8)`류 체이닝이 실제로 +타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 +`UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 +있어야 함 — 순수 런타임 관점(제네릭 `__index`가 처리)에선 문제없지만, +타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(DI +인스턴스 타입, Modifier 타입 등)이 Roblox API 덤프를 읽어 Luau 타입 +파일을 구워내는 스크립트로 생성될 예정이라(구현 단계 결정 사항) — 이 +스크립트가 실제 Roblox 프로퍼티뿐 아니라 이 3개 숏핸드 키도 각 +Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 설계할 +게 없는 구현 체크리스트 항목. + **기존 자식과의 매칭 기준(2026-08-06 감사에서 지적된 항목, 여기서 같이 확정)**: 재사용 대상은 quad가 이전에 만든 고정 이름(`_quad_corner`류) 자식으로 한정 — 타입만 보고(`UICorner`이기만 하면 아무거나) 재사용하지 diff --git a/.claude/question.md b/.claude/question.md index 26307ea..64c09df 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -216,12 +216,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. -- **Attribute 특수 키 타입 파라미터화** — `base/bind-system-plan.md` - "Attribute 특수 키" 절(2026-08-06 신규). `[Attribute<> "name"]` - 제네릭 스타일 vs `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 - 중 뭘로 갈지 — 소견은 DI 인스턴스 생성 패턴처럼 "제네릭 하나 + 자주 - 쓰는 타입만 정적 지름길" 절충이지만 확정 아님, M10(Handlers/Attribute) - 착수 전 아무 때나 확인해도 됨. +- **[해소됨, 2026-08-09 열한 번째 세션]** Attribute 특수 키 타입 + 파라미터화 — `[Attribute<> "name"]` 제네릭 스타일과 + `[BooleanAttribute "name"]` 타입별 정적 생성자 패밀리 **둘 다 채택으로 + 확정**(내부 구현 동일, 호출부 표기만 다름). `base/attribute-plan.md` + 참고 — 제네릭 파라미터가 `=` 뒤 값 타입까지 좁혀주는지는 M0/M10에서 + 실측 필요(안 돼도 런타임엔 영향 없음). - **v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat`** — `research/v1-compat-plan.md`(신규, 2026-08-06, 두 차례 후속 논의로 수렴). 방향 확정: v1을 그대로 병행 실행 + 경계에서만 `state:Observer()`(lazy diff --git a/.claude/reference/comparison-charm.md b/.claude/reference/comparison-charm.md new file mode 100644 index 0000000..1a690d8 --- /dev/null +++ b/.claude/reference/comparison-charm.md @@ -0,0 +1,135 @@ +# charm(littensy/charm) 비교 — quad-v2 설계 근거 + +**상태**: reference — 온디맨드 참고 자료, "완료" 개념 없음. quad에 관한 결정 +자체가 아니라 charm 리서치 스냅샷(2026-08-09, `.claude/initreq/charm`에 +새로 클론)이라 항상 읽어야 하는 base 컨텍스트는 아님 — Fusion/Vide 비교와 +같은 성격, `quadnomicon` 소재 후보이기도 함. quad-v2의 Blocker/Effect/ +Slot:List/(미래) 네트워크 동기화 설계에 근거로 인용될 때만 열어볼 것, +실제 확정 사항은 인용하는 쪽 `base/` 문서가 소스. + +**charm이 뭔지**: Roblox용 Zustand류 상태관리 라이브러리 — +`atom`/`computed`/`subscribe`/`effect`/`batch` 핵심(`packages/charm/src/ +init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) + +`react-charm`/`vide-charm`(얇은 어댑터). 코어는 실제로 절반쯤이 alien-signals +포크(`system.luau`, dirty/pending 비트플래그 전파 엔진, 237줄 — 가장 큰 +테스트 파일이 이걸 검증하는 `topology.test.luau` 484줄)라 순수 서핏보다 +알고리즘 실체가 있지만, quad는 노드/의존성 재사용 모델 자체를 안 쓰기로 +이미 갈라섰으므로 이 부분은 이식 대상이 아님. + +## 반면교사 — quad가 이미 기각/확정한 것과 충돌하는 부분 + +- **`batch(fn, ...)`가 quad가 이미 기각한 `Batch` 렉시컬 블록과 구조적으로 + 동일.** `init.luau:768-778`이 `startBatch`/`endBatch`(`init.luau:285-296`)로 + 콜백을 감싸 effect flush를 지연시키는 모듈 전역 `batchDepth` 카운터 + 방식(`init.luau:66`) — `archive/batch-rejected.md`가 "코루틴 yield에 + 안전하지 않다"는 이유로 기각한 것과 정확히 같은 모양. **charm 자신도 이 + 위험을 인정하는 증거를 갖고 있음**: `wrapUserSpace()`(`init.luau:100-129`)가 + signal/effect/batch 콜백을 `coroutine.create`/`resume`으로 감싸서 콜백 도중 + yield를 시도하면 에러내는 가드(`flags.strict`, Studio 기본 on, + `init.luau:71-81`)를 따로 둠 — 위험을 런타임 가드로 땜질한 것이지 없앤 게 + 아님. quad는 원시 자체를 제거하는 쪽을 택했으니(`Blocker`가 그 자리를 + 대신함, `base/blocker-plan.md:25-44`) 이 모양을 참고할 이유 없음. +- **`atom()`의 getter/setter 겸용 콜러블이 quad가 `Store`에서 이미 기각한 + 대입 문법과 같은 트레이드오프.** `atom(initialValue, equals?)` + (`init.luau:519-527`)가 인자 개수로 read/write를 분기하는 방식 — + `store.key = value`를 버리고 `store.key:Set(value)`로 간 이유 + (`base/store-semantics.md:208-233`, 읽기/쓰기 타입 비대칭)와 같은 문제. + charm 스스로도 README(185-196행)에서 `atom()`을 `signal()`(진짜 get/set + 쌍) 위에 얹은 편의 sugar로 취급 — charm 안에서도 "진짜 1급 형태는 아니다"로 + 다뤄지는 걸 참고. +- **Effect가 전혀 GC-native가 아님 — 전부 수동 dispose 필요.** `effect`/ + `effectScope`/`listen`/`subscribe` 전부 호출자가 직접 불러야 하는 + `Cleanup` 함수를 반환(`init.luau:607-641`, `652-676`, `800-835`) — Roblox + Instance 라이프타임에 자동으로 묶이는 경로가 코어에 아예 없음. `base/ + lifecycle-pattern.md`의 GC-native 원칙과 정반대 축. 오히려 `gc.test. + luau:19-33`의 코멘트가 "스코프 밖에서 `computed()`를 그냥 부르면 의존성에 + 대한 영구 강참조가 생겨서 `effectScope`로 감싸 명시적으로 풀어줘야 + 한다"는 걸 테스트 자체가 우회 헬퍼(`unlink()`, 29-33행)로 증명함 — + 이건 quad의 GC-native 가정을 **뒷받침하는** 증거가 아니라, "레퍼런스/ + 클로저 기반 반응 그래프가 자동으로 안 치워질 수 있다"는 **반례**로 + 인용할 것(rbvm이 "실물 검증된 근거"로 인용되는 것과 반대 방향 — 나중에 + quad의 GC-native 가정을 스트레스테스트할 때 이 케이스를 참고). +- **`computed()`의 값-동등성 억제가 기본값이자 암묵적, opt-in이 아님.** + `updateComputed`가 `oldValue ~= newValue`(`init.luau:302-321`, 특히 + 317행)를 리턴하고 signal setter도 `equals`가 없으면 `node.pendingValue ~= + value`로 기본 비교(`init.luau:489`) — charm의 모든 atom/computed가 기본으로 + 값 비교 억제를 함. quad가 나중에 Blocker에 인접한 "값 안 바뀌면 자동 + 스킵" 기본값을 도입하고 싶어질 때, charm처럼 **모든 노드에 암묵적으로** + 거는 방식은 `Blocker`가 이미 명시한 "특정 게이트 지점에서만 opt-in" + 원칙(`base/blocker-plan.md:65-68`)과 "Source는 스스로를 자동 변형하지 + 않는다"는 `store-semantics.md` 기조에 둘 다 어긋남 — 반면교사로 남겨둘 것. + +## 참고할만한 부분 + +- **charm의 `None` 센티널이 quad 자신의 것을 독립적으로 재확인해줌.** + `patch.luau:10,19-30`이 diff 페이로드에서 "안 바뀜"과 "명시적으로 + 지움"을 `nil`로는 구분 못 해서 `None = {__none="__none"}`을 따로 + 둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/ + bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례. + 새 아이디어는 아니고 인용 근거로만 가치 있음. +- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.** + (1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals` 타입은 + 23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이 + 아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 — + `research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이 + 확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`가 + getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`, + `(previousValue: T?) -> T`, README 276-287행, `computed.test. + luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의 + `store-semantics.md:280-284`가 이미 띄워둔 "`:Compute(fn)`에 선택적 + 두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가 + 아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용 + 가치 있음. +- **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라 + 가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적 + diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable` + (`patch.luau:91-131`)이 immutable 재구축(레벨마다 `table.clone`, 순수 + signal용)과 in-place mutate+`:Emit()`류 변형(반응형 프록시용) 둘 다 + 제공 — quad가 이미 다른 이유로 갖고 있는 clone-vs-mutate+`Emit` 분리 + (`base/store-semantics.md:240-284`)와 우연히 같은 모양. `patch. + luau:32-57`(`stringifySparseArray`)는 실전에서 놓치기 쉬운 페이로드 + 함정을 문서화함 — RemoteEvent/JSON 직렬화가 성긴 배열의 trailing hole을 + 조용히 드롭해서, 보낼 땐 문자열 키로 재인코딩하고 받을 땐 숫자 키로 + 복원해야 함(`patch.luau:101-107`). `server.luau`는 클라이언트별 관심사 + 필터링을 하나의 전역 diff 위에 구현(`clients` 테이블의 + `PENDING_INITIAL_STATE`/`LISTENING_FOR_CHANGES` 상태, 27-32행, + `selectFromGlobalPatch` 209-250행) + 모든 중간 변경을 보존하는 opt-in + 모드(`config.preserveHistory`, `diffGlobalUpdateBuffer`, 124-133행) vs + 기본값인 flush당 diff 하나로 합치는 모드(`diffGlobalState`, + 192-207행) — `Blocker`가 일반화하는 coalescing 트레이드오프의 손으로 짠 + sync 전용 구현체. 지금 스코프 밖이지만 나중에 quad가 네트워크 복제 + 설계를 시작하면 첫 참고 지점으로 쓸 것. +- **`observe()`의 엣지케이스 테스트 스위트가 `Slot:List` 테스트 체크리스트로 + 재사용할 만함.** `observe.test.luau`가 마운트 콜백 도중의 재귀적 + add/remove(92-113행), 자기 마운트 도중 자기 자신 제거(115-132행), add/remove + 도중 dispose(134-168행), 재귀적 업데이트 중 에러가 reconciler를 안 멈추게 + 하는지(170-196행)를 검증 — `observe()` 자신의 메커니즘(키별 + `effectScope`, `init.luau:851-898`)은 quad가 채택한 방식이 아니지만, + 테스트 항목 목록 자체는 `base/slot-plan.md`의 키 기반 재조정을 실제 + 구현할 때 대조 체크리스트로 쓸 가치가 있음. + +## 종합 + +코어(atom/computed/effect/subscribe/batch, `init.luau`의 절반쯤)는 평범한 +시그널 라이브러리라 quad가 이미 확정한 것을 대체로 재진술할 뿐이고, 세 +군데(`batch()`, `atom()`, 수동 dispose Effect)는 오히려 quad가 이미 능동 +기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은 +라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고 +가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만 +새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에 +대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`의 +previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수 +참고자료로 등록. + +**인용 위치**: `packages/charm/src/init.luau:66,71-93,100-129,285-296, +302-321,432,489,519-527,538,607-641,652-676,768-778,800-835,851-898` · +`packages/charm/src/system.luau`(전체, alien-signals 포크) · +`packages/charm/test/gc.test.luau:9-33` · `packages/charm/test/ +computed.test.luau:84-104` · `packages/charm/test/observe.test.luau:92-196` · +`packages/charm-sync/src/patch.luau:10,19-30,32-57,59-89,91-131` · +`packages/charm-sync/src/server.luau:27-32,124-133,192-207,209-250` · +`README.md:185-196,262-287` · `base/store-semantics.md:208-233,240-284` · +`base/blocker-plan.md:25-44,65-68` · `base/lifecycle-pattern.md`(GC-native +원칙) · `archive/batch-rejected.md` · `base/bind-system-plan.md:180-266` +(None 센티널) · `research/additional-primitives-plan.md`(Blocker/키 기반 +컬렉션 미결 상태). diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index aa6467c..e75ee54 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -73,7 +73,7 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 - skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 ### component-composition-plan.md / module-lifecycle-plan.md -- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티 바인딩(**[정정, 2026-08-09 열한 번째 세션] "State만"이 아님 — 단순 원본 토글(`Frame{Visible=source}`)은 Source 직접 바인딩이 정상 경로, 여러 값에서 파생된 계산 결과일 때만 자연히 State가 됨, `component-composition-plan.md` 5번 절 참고**) / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 - api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Overridden(mod1, mod2, ...)` 유틸(구 `Merge`, `props.Modifier` 단일 슬롯용 특수 상황으로 한정 소개 — 아래 modifier-plan.md 절 참고) / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 - 심화: v1 `Extend` 자동 store 소유 폐지 이유(React 벤치마킹) / Source가 State를 구조적으로 만족하는 서브타입 설계(2026-08-06 후속 세션 — `StoreSource` 프록시 중간안은 폐기되고 이걸로 대체됨, `store-semantics.md` 참고) / named-parameter 경계 방식 채택 이유(Compose/Fusion/Vide/v1 선례 수렴) / 다중 루트 반환 개념 제거 근거 / 팩토리 초기화 패턴 채택 이유(RBVM `InitNamespace` 반례) / Store 책임 분리(base가 `LifetimeHandle` 소유) / v1 named 체이닝 연산 폐기 - skip: Compose/Fusion/Vide/v1 프레임워크 비교 원자료 / provider/processor 네이밍 미정 등 열린 질문 메모 @@ -220,14 +220,26 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: ## 5. 문서화 아직 보류(미확정 설계라 쓰면 안 됨) -- Slot 형제 순서 보장 (`slot-plan.md`) +**[정정, 2026-08-09 열한 번째 세션] 아래 목록 중 상당수가 이미 해소돼 +있었음 — 이 절이 오래 안 갱신되며 stale해진 것, 실제 열린 것만 남기고 +해소된 건 표시만 남김(중복 조사 방지 목적, 지웠다가 나중에 또 조사하게 +되는 걸 막기 위해 흔적만 유지).** + +- **[해소됨]** Slot 형제 순서 보장 — `Dispatch.setLength`/ + `setOffsetSource`(Length/Offset)로 2026-08-09 여섯 번째 세션에 확정, + `bind-system-plan.md` "Length/Offset" 절 참고. - Tween 오버라이드/삭제후재시작/끝점이동 세부 옵션 키 이름, 트윈 옵션 값 - 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) -- `Attribute` 제네릭 vs 타입별 정적 생성자 (`bind-system-plan.md`) -- provider/processor 네이밍 (`module-lifecycle-plan.md`) -- 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 - 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ - additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) + 모양(TweenInfo vs 편의 필드) (`research/tween-plan.md`) — 아직 열림. +- **[해소됨]** `Attribute` 제네릭 vs 타입별 정적 생성자 — 2026-08-09 + 열한 번째 세션에 "둘 다 채택"으로 확정, `base/attribute-plan.md` 참고. +- provider/processor 네이밍 — **[해소됨]** `Handler`로 이미 오래전 확정 + (`base/module-lifecycle-plan.md`), 이 줄이 그 갱신을 놓치고 있었음. +- **[해소됨]** 키 기반 동적 컬렉션 재조정 최종 이름/시그니처, `Slot:Extract` + 세부 시맨틱 — `Slot:List(data, updateFn, keyFn?)`로 2026-08-09 세 번째 + 세션에 전부 확정·통합(`base/slot-plan.md`), `Extract`도 CRUD 표에서 + 완전히 확정(2026-08-09 열한 번째 세션엔 `Extract(index, newElement?)`로 + 더 확장). `research/additional-primitives-plan.md`는 더 이상 열린 + 항목 없음, 배경 자료로만 유지. - **"hook"/"pre-hook" 용어 채택 여부 + `PreRef`의 취소 가능성** (2026-08-07, 위 심화 후보 6번 참고) — `bind-system-plan.md`는 `PreRef`가 위치 무관 호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는 diff --git a/CLAUDE.md b/CLAUDE.md index 564957b..e6b6184 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2306,3 +2306,123 @@ lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 여덟 번째 세션 — `.claude/base/` 전체 중간검토(질문 모드), +실제 설계 결함 다수 발견·수정 + +사용자가 "이 프로젝트의 계획을 중간검토합니다. 각 요소들에 대해서 함수나 +클래스 등의 동작을 제가 확인 가능하게 리스팅해요... 질문 모드를 쓰면 +좋겠습니다"라고 요청 — 2026-08-04 6차 라운드 때 예고해뒀던 "다음 세션 +검증 패스"를 실제로 실행한 세션. 서브에이전트 6개를 병렬로 띄워 +`.claude/base/` 전체(15개 파일, 5296줄)를 클러스터별로 정독시켜 확정된 +API/동작을 file:line 인용과 함께 그라운딩된 리스팅으로 뽑아낸 뒤, 6개 +배치(Store/State/Source+Dispatch, Ref/PreRef+Brand+Length-Offset+생명주기, +Modifier, Slot, Tag/Attribute/UI shorthand+Blocker/Effect, 컴포넌트 +경계+아키텍처)로 나눠 각 배치를 텍스트로 보여주고 바로 `AskUserQuestion` +(문제없음/문제있음)으로 확인받는 방식으로 진행 — 문제 제기된 건 그 +자리에서 바로 문서에 반영(끝까지 미루지 않음). 총 24개 확인 질문 중 +약 1/3에서 실제 설계 결함이 나옴 — 전부 사용자가 구체적인 반례/Luau +시맨틱스를 근거로 지적한 것이라 전부 그대로 수용, 방어하지 않고 수정. + +**발견·수정된 것 (파일별)**: + +- **`base/bind-system-plan.md`** (가장 많이 고침): + - `Source(default)`/`Ref(default)`의 `default` 생략이 "선택"이라는 + 서술에 "`T`가 nilable일 때만 안전하다"는 캐비엇 누락 — 추가. + `Ref`는 `:Callback`이 등록 즉시 발화해서 이 문제가 더 잘 드러남. + - Dispatch 체인 절에 "`handler.process`를 `Dispatch.process` 없이 + 직접 호출하면 UB(체인 bookkeeping이 깨져 `retract`가 영영 안 + 불리거나 정합성이 무너짐)"라는 불변식이 안 적혀 있었음 — 추가. + - **Ref 콜백/대기자 배열의 소진 슬롯을 `None`에서 `nil`로 되돌림** — + 2026-08-07 열 번째 세션에 "구멍 있는 정수 키는 순회 순서가 깨진다"는 + 이유로 `None`으로 바꿨던 게 이 배열엔 안 맞는 처방이었음(사용자 + 지적): 이 배열은 순서가 안 중요해서 일반화 `for`가 구멍이 있어도 + 전부 방문하고, 오히려 `None`을 쓰면 슬롯이 영원히 안 비어서 + `:Wait()`마다 배열이 끝없이 길어지는 새 문제가 생김 — `nil`로 + 지우고 빈 슬롯을 재사용하는 등록 함수로 바꿈. PreRef pre-pass/ + Length-Offset의 `sourceList`는 순서가 실제로 중요해서 계속 `None`이 + 맞음 — 두 사례를 헷갈리지 않게 교차 참조로 명확히 구분. + - `.Value`가 평범한 hash 필드가 아니라 `__index`로 구현돼야 하는 + 이유(콜백 배열과 같은 테이블에 있으면 `T`가 함수/스레드일 때 콜백 + 처리 루프에 오분류될 위험) 추가. + - **`isRef`/`isPreRef`를 `isState`/`isSource`와 같은 상위-하위 합성 + 패턴으로 재정정** — 원래 "서로 배타적인 형제 브랜드"였는데, `Source`가 + `State`를 만족하듯 `PreRef`도 `Ref` 런타임을 재사용하는 관계라 + 같은 방향(하위=PreRef가 상위=Ref에 포함)으로 다뤄야 일관적이라는 + 지적 — `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹힌 + 상위 개념. `(v=Ref)` children leaf 매치 핸들러는 이제 + `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. + - `NoneHandler`가 `k` 타입을 안 가리는데 왜 배열 파트 `None`(숫자 + 키)에 실제로 안 걸리는지 명확화(배열 파트 `None`은 애초에 + `Dispatch.process`를 안 타서 `NoneHandler`가 볼 기회 자체가 없음). + - `setLength`/`setOffsetSource`의 `None` 페어링 대상을 "Ref/PreRef + 등" 예시 목록에서 "그 배열 위치의 값 자체가 `None`인 모든 경우"로 + 명시적으로 확장, 둘이 항상 짝을 맞춰야 한다는 점도 재강조. + - `:Subscribe()`가 quad 전역 GC-native 원칙의 의도적 예외(참조를 + 다 놓아도 GC 안 되고 계속 실행됨)라는 경고가 없었음 — 추가, 용도도 + "완전히 top-level" 케이스로 좁혀 문서화. +- **`base/modifier-plan.md`**: 핸들러 계층 값 → error 체크가 `State`류 + "State/Source가 감싼 내부 값"까지는 못 잡는다는 한계 — 명시적 UB로 + 문서화(오버엔지니어링 방지, 실사용 위험 낮음). +- **`base/slot-plan.md`** (가장 큰 변경): **CRUD 식별 기준을 element + 레퍼런스에서 인덱스 기준으로 전환** — `Remove(index)`/ + `Extract(index, newElement?)`/`Move(oldIndex, newIndex)`. 원래 + "인덱스는 stale해진다"는 이유로 레퍼런스 기준을 택했는데, 실제로는 + 반대(호출부가 `Add` 리턴값을 안 담고 흘려버리는 경우가 흔함)가 더 + 큰 문제였음. **`ExtractAll()`/`Get(index)`/`IndexOf(element)` 신설** + (`Get`은 "YAGNI"로 드롭했던 걸 재추가). **`Extract(index, newElement?)` + 신설** — 교체가 필요하면 기존엔 Extract+Add 이중 O(n) 시프트가 + 필요했는데, 이제 O(1) 제자리 교체 가능(이전 element 반환). +- **`base/tag-plan.md`**: `TagHandler.retract`의 전체 삭제 동작이 + 정확히 `v == nil`일 때만 맞다는 전제를 `assert`로 명시(기존엔 "v를 + 안 봐도 됨"이라고만 서술돼 있어 조건이 암묵적이었음). +- **`base/attribute-plan.md`**, **`.claude/question.md`**: 타입 + 파라미터화(`Attribute<>` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리) — "미확정"에서 **"둘 다 채택"으로 확정**(내부 구현 동일, + 호출부 표기만 다름). `=` 뒤 값 타입까지 narrowing되는지는 M0/M10 + 실측 필요(안 돼도 런타임 무관)로 명시. +- **`base/ui-shorthand-plan.md`**: `UICorner`/`UIPadding`/`UIScale`이 + 타입 생성 스크립트가 만드는 `FrameModifier`류 타입의 메소드 목록에도 + 포함돼야 한다는 체크리스트 항목 추가(런타임과 무관한 순수 타입 + 생성 디테일). +- **`base/effect-plan.md`**: `EffectHandle`이 내부 Observer를 필드로 + 강참조한다는 것, `bindLifetime`/`:Subscribe()` 둘 다 `state`가 있으면 + 내부 Observer까지 cascade해야 한다는 것(안 그러면 내부 Observer의 + `canExecute` 게이팅이 올바른 `inst`를 못 봄) — 재확인 후 명시화. +- **`base/component-composition-plan.md`** (Length/Offset 다음으로 많이 + 고침): + - **"리프 바인딩엔 Source가 좁은 예외"라는 서술이 틀림 — 정정.** + `local a = Source(true); Frame { Visible = a }; a:Set(false)`처럼 + Source를 리프에 직접 물리는 건 이미 확정된 "Source가 State를 + 구조적으로 만족" 원칙이 그대로 커버하는 정상 경로였음 — "State가 + 일반적"이라는 서술은 Source를 못 쓴다는 뜻이 아니라 "여러 값에서 + 파생된 계산 결과는 State일 수밖에 없다"는 통계적 경향 서술일 + 뿐이라고 재정정. + - `props.Modifier or None` 관용구의 `None` 근거 포인터가 Ref 콜백 + 배열 정정으로 깨질 뻔한 걸 교차 참조로 바로잡음(그 배열은 순서가 + 중요한 별개 케이스라 `None`이 계속 맞음). + - `Frame { Comp{} }`에서 `Comp`가 `Slot`을 반환하는 다중 루트 우회 + 경로가 새 배선 없이 그대로 작동함을 재확인(값이 컴포넌트 호출로 + 왔든 리터럴이든 디스패치 입장에선 구분 없음). +- **`ROADMAP.md`**: 위 `Ref` `None`→`nil`/`isRef`·`isPreRef` 변경사항 + 체크박스 동기화. + +**변경 없이 확인만 된 것**: `:With`/`:Compute` 체이닝, `None` 센티널 +기본 메커니즘, Length/Offset 전체, 이중 바인딩 금지/`Relate`/생명주기, +Modifier setter/Apply/Overridden 판단 기준, `Peek`/`isState`/`None` +setter 인자, Slot 요소 타입 제약/Extract portal/`Length`, `Slot:List` +시그니처(단, 캐스케이드 성능 이슈는 `keyFn` 명시 유도로 이미 문서화돼 +있어 추가 조치 불필요), List 구독 lazy 시점, Tag 값 모양/패키지 배치, +Blocker 전체, 소스트리/네이밍 컨벤션/Handler 3분류/테스트 전략/이식성 +원칙. + +**부수 기록**: `.claude/memory`(세션 간 영속 기억)의 협업 스타일 메모에 +이번 리뷰 진행 방식(에이전트 병렬 추출 → 배치별 텍스트+AskUserQuestion +즉시 확인 → 그 자리에서 바로 문서 반영)을 다음에 재사용할 패턴으로 +기록 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션은 설계 +확정이 아니라 기존 확정 사항의 결함 수정이었지만, 결과적으로 M0 착수 +전 상태가 더 탄탄해졌을 뿐 우선순위 자체는 그대로. 이 중간검토가 +마지막 배치(6단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는 +다음 세션 시작 시 확인. diff --git a/ROADMAP.md b/ROADMAP.md index b850aeb..db42293 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,8 +82,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 - 태그 목록에서 빠져있었음, `isRef`/`isPreRef`는 단순 항등이지 - `isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 + 태그 목록에서 빠져있었음. **[정정, 2026-08-09 열한 번째 세션]** + `isRef`/`isPreRef`는 `isState`처럼 상위-하위 관계로 재정정됨 — + `isPreRef`가 가장 구체적인 항등, `isRef`는 그 위에 얹혀 + `isPreRef`도 `true`로 통과시킴(PreRef가 Ref 런타임을 재사용하는 + 것과 정합). `(v=Ref)` children leaf 매치 핸들러는 이제 + `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 함. `isModifier`는 + 여전히 단순 항등, 상위 개념 없음) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 세션 신설) - [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — @@ -195,21 +200,31 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. -- [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 - 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 - 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 - throw"도 `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 - 기준이 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). - `Move(element, newIndex)`(O(n))/`Swap(indexA, indexB)`(O(1), element - 아닌 인덱스 — element면 위치 조회에 2n 들어 O(1) 약속이 깨짐)은 - 리오더 전용, Parent를 안 건드림. 공개 6개 메소드 전부 "가드 확인 + - `raw*` 위임" 얇은 wrapper. base/roblox 경계에 mount/unmount 외 - reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 타입 제약 확정** - — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 - `T`만), 핸들러 계층 값(Ref/PreRef/Observer/Effect/Modifier)은 - self-ref 컨텍스트가 없어 의미 불성립이라 즉시 error(`Modifier` - 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = Slot<>`가 - quad-roblox의 사실상 유일한 Slot 타입. +- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ + `Get`/`IndexOf` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09 + 열한 번째 세션에 식별 기준 재정정) — 에러 조건까지 전부 확정 + (`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도 + `isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이 + 명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절). + **[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 + 인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)` + (O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA, + indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접 + 받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을 + 안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안 + 맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를 + 구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?` + 신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)` — + `newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔 + 교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개 + mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/ + `IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에 + mount/unmount 외 reposition 훅 추가됨. **`Slot()` 제네릭화, 요소 + 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 + 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ + Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 + error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = + Slot<>`가 quad-roblox의 사실상 유일한 Slot 타입. - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, `keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프). @@ -294,8 +309,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 우회한 버그라는 뜻 — 같은 절 참고 - [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 - `coroutine.resume(v, self)`+`None`으로 소진(`nil` 아님 — - 2026-08-07 열 번째 세션 정정, `#t`/`table.insert` 안전성), 함수면 + `coroutine.resume(v, self)`+`nil`로 소진(2026-08-09 열한 번째 + 세션 최종 정정 — 순서 안 중요 + 슬롯 재사용 위해 `None`이 아닌 + `nil`, `table.insert` 대신 빈 슬롯 선형 탐색 등록), 함수면 `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self`