From 8169b90a0ea1cf483e724f4b969a57e412c8a7b5 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 16:00:19 +0900 Subject: [PATCH] =?UTF-8?q?decide(base):=20canBound=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=ED=99=95=EC=A0=95,=20Compute=20previous=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4/=EC=8A=A4=EC=BD=94=ED=95=91=20=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=99=94,=20Modifier=20=ED=95=B8=EB=93=A4=EB=9F=AC=EA=B0=92?= =?UTF-8?q?=C2=B7State=20UB=E2=86=92error=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound 플래그 → canBound(handle) 탑레벨 함수로 확정(canExecute와 같은 결) - :Compute의 previous 인자 오버엔지니어링 의심 기각, 결과 노드 귀속으로 스코핑 명확화(self.Cache 안 씀 — 팬아웃 충돌 회피) - Modifier 필드의 Ref/PreRef/Observer/Effect/Slot/Modifier 값: UB → isX predicate 기반 즉시 error - State/Source가 Modifier를 값으로 담는 것(State)도 동일하게 error로 통일, Slot 등은 계속 허용 - Tween initValue/useTween 논의 신설(미확정) - pre-implementation-audit.md 3-1/2-2/문서모순 절 해소 반영, question.md/ROADMAP.md 동기화 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/base/bind-system-plan.md | 96 ++++++++++++++++---- .claude/base/effect-plan.md | 3 +- .claude/base/modifier-plan.md | 87 ++++++++++++++---- .claude/base/store-semantics.md | 15 +-- .claude/question.md | 18 ++-- .claude/research/pre-implementation-audit.md | 28 ++++++ .claude/research/tween-plan.md | 34 +++++++ CLAUDE.md | 86 ++++++++++++++++++ ROADMAP.md | 4 +- 9 files changed, 320 insertions(+), 51 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 46eaf18..f67ecae 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -697,8 +697,9 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 - 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 - 같은 원칙의 재적용. + 없음 — `State`를 막기로 한 것(`modifier-plan.md` 7번, + 2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은 + 원칙의 재적용. - **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 @@ -989,6 +990,45 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. "Ref로 한 번 얻어서 수동으로 Parent만 하고 끝"처럼 능동적 관측 경로가 안 남아있으면, 이 최적화는 그냥 조용히 작동을 멈춘다. +**[2026-08-09 세션] 오버엔지니어링 의심 재검토 — 기각, 현재 설계 +유지.** `research/pre-implementation-audit.md` 3-1이 "클로저 업밸류로 +이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 것에 대한 사용자 +반박: 클로저 업밸류 대안은 실제로 다음처럼 즉시실행함수(IIFE)로 감싸 +업밸류를 준비해야 함 — + +```lua +local computeFn = (function() + local prev + return function(self) + -- prev를 읽고 새 값을 계산, prev 갱신 + prev = ... + return prev + end +end)() +someSource:Compute(computeFn) +``` + +이 준비 코드 자체가 이미 별도 `previous` 인자 하나보다 무겁고 번거로움 +— "재사용하고 싶으면 그냥 캐시된 값을 바로 넘겨주면 되는" 게 더 +단순하다는 게 사용자 논거. 반대로 `previous`가 없으면 `fn`은 매 호출마다 +새 인스턴스를 만들어야 해서(예: `LocalizationTable.new()`) lazy든 +아니든 재계산이 일어날 때마다 항상 비싼 재생성이 발생 — `previous`가 +막으려는 문제는 실재함. **`pre-implementation-audit.md` 3-1 해소 — +현재 `fn(self, previous)` 설계 그대로 유지, API 표면을 줄이지 않음.** + +**스코핑 명확화(이번 세션에 확인, 새 결정 아님) — `previous`는 `self` +(입력)가 아니라 "이 `:Compute` 호출 하나가 만들어낸 결과 State 노드" +자신에 귀속된다.** State가 `:With`/`:Compute` 호출마다 새 노드를 +만든다는 건 이미 확정된 온톨로지(아래 "왜 State 체인을 Modifier처럼 +플래튼하지 않는가" 절)라, `previous`도 그 새 노드의 내부 캐시 슬롯일 +뿐 `self`에 얹히는 게 아님 — 같은 `self`에서 여러 `:Compute`가 갈라지는 +팬아웃(`c1 = w:Compute(g1)`, `c2 = w:Compute(g2)`)이 있어도 `g1`/`g2` +각자의 `previous`는 각자의 결과 노드에 독립적으로 저장되므로 서로 안 +섞임 — 새로 결정할 것 없이 기존 "노드별 캐시" 원칙의 당연한 귀결. +(참고: `self.Cache`처럼 `self` — 즉 입력 — 에 캐시를 얹는 모양은 이 +스코핑과 안 맞아 채택하지 않음 — 팬아웃 시 여러 소비자가 같은 +`self.Cache` 슬롯을 공유해 덮어쓰는 충돌이 생기기 때문.) + ### `state:Observer(fn)` — 값을 안 실어주는 구독, children 배열에 직접 놓는 leaf 값 **결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` @@ -1169,7 +1209,7 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. -### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `Bound` 플래그로 즉시 에러 (2026-08-07 일곱 번째 세션) +### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정) **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 @@ -1181,29 +1221,44 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 **UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 -바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈: +바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. + +**이름 확정 — `canBound(handle): boolean`, `canExecute`와 같은 결의 +탑레벨 함수(2026-08-09 세션, 가칭 `Bound` 필드를 직접 노출하는 대신).** +`canExecute(inst, value)`가 "지금 살아있어서 실행돼도 되는가"를 묻는 +탑레벨 predicate인 것과 똑같이, "아직 어느 경로로도 안 묶였는가"도 +raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑레벨 +함수로 감싼다 — Observer/Effect 둘 다 쓰는 범용 predicate라 특정 +프리미티브 하나의 전용 소유물이 아니므로(`store-semantics.md`의 +네이밍 케이싱 기준: "이 이름이 특정 프리미티브 타입 하나의 전용 +소유물인가?"에 아니오라 소문자 탑레벨이 맞음, `architecture.md` +"코드 스타일 — 네이밍 케이싱" 절과 같은 기준): ```lua -- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 -if self.Bound then +if not canBound(self) then error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") end -self.Bound = true +-- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) ``` -- **`Bound`는 가칭** — 용어 정리 라운드에서 최종 이름 재검토 대상 - (`.claude/question.md`에 반영). -- 이 플래그는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 - 표시 — 두 진입점이 똑같이 확인/설정하므로 순서와 무관하게 대칭적으로 - 막힘. +- `canBound(handle)`은 "이 핸들이 아직 어느 경로로도 안 묶였으면 + `true`, 이미 한 번 묶였으면 `false`"를 답하는 순수 predicate — 내부 + 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, + 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 + 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 + 안 바뀜. +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 + 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 + 대칭적으로 막힘. - **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 - 뜻으로 통일** — `Bound`가 어느 경로로 세워졌는지와 무관하게, - `:Unsubscribe()` 한 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 - 강참조 등록이든)을 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 - 자동(리프) 케이스에도 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 - 별개로, "단일 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. + 뜻으로 통일** — 바인딩이 어느 경로로 세워졌든, `:Unsubscribe()` 한 + 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 + 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 자동(리프) 케이스에도 + 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 + 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. - **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 - `state` 없는 경우든 같은 `Bound` 게이트를 그대로 재사용 + `state` 없는 경우든 같은 `canBound` 게이트를 그대로 재사용 (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 @@ -1761,6 +1816,13 @@ Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 (`Brand.get(x) == ModifierTag`). +**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** — +`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등 +predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX` +wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 +"핸들러 계층 값이 필드로 들어오면 즉시 error" 절이 필요로 해서 이번에 +같이 적음. + **`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** `Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 4ccecfc..7427cbc 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -109,7 +109,8 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 - 규칙과 `Bound` 플래그 기반 즉시-에러 메커니즘은 + 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` + 플래그, 2026-08-09 세션에서 이름 확정)은 `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 7093350..f78320f 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -184,14 +184,34 @@ Roblox API에 전혀 의존 안 하는 순수 Lua 테이블 조작이라, "base **Modifier의 체이닝 엔진 자체는 quad-base에 완결된 구현으로 그대로 존재해도 됨** — 주입할 엔진별 구현이 애초에 없음. -**Modifier는 핸들러 계층을 모름 — 순수 데이터 merge 레이어.** Modifier는 -`process`/`retract`/`isHandlable` 핸들러 레지스트리보다 아래층(위 "1. -런타임 pluggable 핸들러 아님" 참고)이라, Ref/Slot 같은 핸들러 계층 -개념을 아예 몰라도 됨 — 필드에 Ref나 Slot 같은 값이 들어가도 Modifier -입장에선 그냥 flatten해서 최종 props 테이블에 얹을 뿐. 권장하는 사용법은 -아니지만(Modifier는 스타일링 데이터 지향이니), 막을 이유도 없음 — 방어 -로직 없는 UB로 남겨둠(오늘 세션 내내 반복된 "드문 오용까지 방어하려고 -구조 복잡하게 안 만든다" 원칙과 동일). +**Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ +Modifier)이 들어오면 즉시 error — UB 아님(2026-08-09 세션, 정정).** +이전 버전("권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 +UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에 자식/Ref +같은 걸 다루는 목적이 아니고, 이런 값이 실제로 쓸모 있는 use case가 +없다고 확인된 이상 조용한 UB보다 그 자리에서 막는 쪽이 낫다 — 판별 +비용도 이미 있는 `Brand` 기반 predicate(`isRef`/`isPreRef`/ +`isObserver`/`isEffect`/`isSlot`/`isModifier`, `bind-system-plan.md`의 +`Brand` 절)를 그대로 재사용하면 되므로 거의 공짜. + +- **체크 지점 — 제네릭 `__index` setter가 최종 저장 직전에 검사.** 위 + 4번 절의 제네릭 setter(`clone[key] = value`, 또는 함수 인자면 + `clone[key] = fn(old)`, 4-1번 표의 State 분기 결과도 포함)가 실제로 + 필드에 쓰려는 값을 확정한 직후, 그 값이 `isRef(v) or isPreRef(v) or + isObserver(v) or isEffect(v) or isSlot(v) or isModifier(v)`를 + 만족하면 `error`. 리터럴로 직접 넣은 경우든(`mod:SomeField(someRef)`류 + 오용) 변환 함수가 반환한 경우든(`mod:X(function(old) return someRef + end)`) 동일하게 걸림 — "콜백이냐 직접 실행이냐"를 구분하지 않고 최종 + 저장값 하나만 보면 충분(사용자 제안). +- **State/Source는 여전히 허용** — 이 체크는 핸들러 계층 값만 잡음, + 4-1번 절의 "필드가 State일 수도 있음"과 안 부딪힘(`isState`가 참인 + 값은 이 체크를 그냥 통과함). +- **7번 절(`State` UB)과의 비대칭이 이걸로 줄어듦** — + `pre-implementation-audit.md`가 지적했던 "같은 문서 안에서 한쪽은 + 방어(타입 차단 시도), 한쪽은 무방비 UB"라는 비일관성이, 이제 둘 다 + "적극적으로 막는다"는 같은 방향으로 정리됨(메커니즘은 여전히 다름 — + 하나는 타입 레벨 차단 시도+실패 시 UB 폴백, 하나는 런타임 `error` — + 이 차이 자체는 남지만 "막을 가치가 있는가"라는 판단은 통일됨). ### 4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기 @@ -248,17 +268,50 @@ Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 것 없음. -### 7. State가 Modifier를 값으로 담는 것은 UB — 타입으로 막을 것 (2026-08-04, 로드맵 인수인계 라운드) +### 7. State/Source가 Modifier를 값으로 담는 것 — 명시적 error로 확정 (2026-08-04 신설, 2026-08-09 세션 정정) -Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, **State 자체의 -value가 Modifier인 경우**(예: `someState:With(fn)`이 Modifier를 반환)는 -지원 대상이 아님 — Modifier는 "flatten해서 한 번 적용"이 전제인 정적 값인데, -State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 -필요해지고, 이는 "정적 merge" 확정(1번)과 정면으로 충돌함 — **사용자 +Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, **State/Source +자체의 value가 Modifier인 경우**(예: `someState:With(fn)`이 Modifier를 +반환하거나 `someSource:Set(someModifier)`)는 지원 대상이 아님 — Modifier는 +"flatten해서 한 번 적용"이 전제인 정적 값인데, State/Source에 담기면 그 +값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 필요해지고, +이는 "정적 merge" 확정(1번, "Modifier는 런타임 pluggable 핸들러가 아니라 +dispatch 밖에서만 처리되는 유일한 존재")과 정면으로 충돌함 — **사용자 확정**("state 안에 modifier가 있으면 그건 끔찍히 힘들꺼야... 타입 상 받지 -못하게 만들어야 할 수도 있고"). **UB로 확정, 가능하면 타입 시스템으로 -아예 못 넣게 막을 것**(`State` 같은 조합을 타입 정의 단계에서 -거부) — 런타임 가드가 아니라 타입 차단을 우선 검토. +못하게 만들어야 할 수도 있고"). + +**[정정, 2026-08-09 세션] "UB, 가능하면 타입 차단"에서 "명시적 +`error`로 확정"으로 전환** — 위 "핸들러 계층 값이 필드로 들어오면 +즉시 error" 절(Ref/PreRef/Observer/Effect/Slot/Modifier가 Modifier +*필드*로 들어오는 걸 막은 것)과 같은 방향으로 통일: `isModifier` +predicate(`Brand` 절)를 State/Source 쪽에도 적용해 **런타임에 직접 +막는다.** 타입 차단(`State` 같은 조합을 타입 정의 단계에서 +거부)은 여전히 되면 좋은 보너스로 계속 시도해볼 수 있지만 +(`research/pre-implementation-audit.md` 2-2 — Luau에서 실제로 가능한지 +미검증), **더 이상 유일한 방어선이 아님** — 타입이 뚫려도 런타임 +`error`가 항상 잡아준다. + +- **적용 지점**: "어떤 값이 Source/State의 현재 값으로 확정되는 모든 + 지점" — `Source:Set(value)` 호출 시, `Store({defaults})` 생성 시 + 각 `defaults` 키를 `Source(v)`로 만드는 시점, 그리고 State의 + `:Compute(fn)` 결과를 캐시로 저장하기 직전(`fn`이 반환한 값이 + `isModifier`면 캐싱 전에 `error`). 새 체크 지점을 여러 곳에 흩는 게 + 아니라, "값이 State/Source의 값으로 확정되는" 이미 존재하는 몇 안 + 되는 지점에 `isModifier` 검사 한 줄씩 얹는 것뿐. +- **Slot/Tag/Attribute/Tween 등 다른 핸들러 계층 값은 여전히 아무 + 문제 없이 State/Source에 담길 수 있음 — Modifier만의 예외임을 + 명확히.** (사용자 확인: "slot은 당연히 가능함, retract도 되는 애고 + 런타임 값이라") 이 값들은 전부 정상적으로 `process`/`retract` + 재귀 경로(store-bind 재실행 모델, "확정된 디스패치 모델" 절)를 타는 + 진짜 런타임 dispatch 참가자라, State/Source 값으로 담겨 바뀌어도 + 기존 재귀 재-dispatch 메커니즘이 그대로 처리해줌 — 새로 막을 이유가 + 없음. Modifier만 유독 문제인 건 Modifier가 애초에 dispatch 경로를 + 아예 안 타는 유일한 존재(1번 절)라서, State/Source에 담기는 순간 + "재귀 재-dispatch로 처리"할 대상 자체가 없어지기 때문 — 이 구분이 + 왜 Modifier만 막고 나머지는 다 허용하는지의 핵심 근거. +- **`Store`의 `T`는 Modifier가 될 수 없음(`base/store-semantics.md` + "따름정리" 절)도 이 결정을 그대로 물려받음** — Source가 State를 + 구조적으로 만족하므로 별도로 다시 논증할 필요 없이 동일하게 적용됨. ### 8. `:Apply(factory)` — 팩토리 함수 체이닝 지원 (2026-08-07) diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 15d4cb1..66d3407 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -285,16 +285,19 @@ mutation에 정식 신호를 붙여주는 것뿐. **따름정리 — `Store`의 `T`는 Modifier가 될 수 없음.** Modifier는 정적 flatten으로 dispatch와 완전히 별개인 단계에서 처리되고 -(`base/modifier-plan.md`), `State`가 UB로 확정된 것도 같은 -이유(`modifier-plan.md` 7번) — Store/State/dispatch 경로엔 애초에 -Modifier용 processor가 없음. **[2026-08-06 후속 세션 추가]** Source가 -State를 구조적으로 만족하게 되면서 이 UB는 `Source`(Store를 +(`base/modifier-plan.md`) — Store/State/dispatch 경로엔 애초에 +Modifier용 processor가 없음. **[정정, 2026-08-09 세션]** `State` +조합은 "UB, 가능하면 타입 차단"이 아니라 **명시적 `error`로 확정** +(`modifier-plan.md` 7번) — `isModifier` predicate를 `Source:Set()`/ +Store 생성 시 eager `Source(default)`/State의 `:Compute` 결과 캐싱 +지점에서 확인해 런타임에 직접 막음, 타입 차단은 되면 좋은 보너스일 +뿐 유일한 방어선이 아님. **[2026-08-06 후속 세션 추가]** Source가 +State를 구조적으로 만족하게 되면서 이 제약은 `Source`(Store를 거치지 않는 독립 `Source(someModifier)`)에도 동일하게 적용됨을 명시 — Source가 State 계약을 만족하는 이상 같은 이유(Modifier용 processor 부재)가 그대로 적용되고, 별도로 다시 논증할 필요 없음. 위 "하드 경계"와 같은 이유로, `Emit`이 Modifier의 정적 flatten과 충돌할 걱정 자체가 -성립하지 않음(둘이 만날 -지점이 없음). +성립하지 않음(둘이 만날 지점이 없음). ## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정 diff --git a/.claude/question.md b/.claude/question.md index b5d0563..f6a4a37 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -87,10 +87,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 즉시모드 GUI 뉘앙스), `List`(중립적이나 메커니즘을 안 알려줌) — 아직 미정, `research/additional-primitives-plan.md`의 "키 기반 동적 컬렉션 재조정" 절 참고. -- **`Bound`(3순위, 사소함, 2026-08-07 일곱 번째 세션 추가)**: Observer/ - Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 - 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` - "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. +- **[해소됨, 2026-08-09 세션]** `Bound` — **`canBound(handle): boolean` + 탑레벨 함수로 확정**, `canExecute`와 같은 결(raw 필드를 직접 노출하는 + 대신 predicate 함수로 감쌈). `base/bind-system-plan.md` "이중 바인딩 + 금지" 절 참고. - **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 10종 branded 타입 전부로 일반화) — `bind-system-plan.md`의 `Brand` @@ -138,11 +138,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 일반 반응형 프로퍼티 바인딩(`BackgroundColor3 = store.color`, 애니메이션 없음)이 결국 이름은 "Tween"인 파일을 거쳐가는 건지, 아니면 별도 범용 `Handlers/StoreBind.luau`가 있어야 하는 건지 확정 필요 — 우선순위1-1. -- **`State` 타입 차단(엔지니어링 비용 감수)과 Ref/Slot이 Modifier - 필드에 들어가는 건 UB 방치 — 같은 문서 안에서 정반대 원칙이 근거 설명 - 없이 나란히 적용됨.** 왜 이 경우만 예외로 방어하는지 명문화 필요, 또는 - Luau에서 실제 타입 차단이 가능한지부터 확인(안 되면 그냥 UB로 격하) — - 문서모순 절 + 우선순위2-2. +- **[해소됨, 2026-08-09 세션]** `State`와 Ref/Slot이 Modifier + 필드에 들어가는 것 — 이제 둘 다 `isX` predicate 기반 명시적 `error`로 + 통일(`base/modifier-plan.md` 4번/7번 절, `base/store-semantics.md` + "따름정리" 절). Luau 타입 차단은 "되면 좋은 보너스"로 격하되어 더 + 이상 필수 검증 항목 아님 — 문서모순 절 + 우선순위2-2도 갱신 완료. - ~~`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 nil-hole 함정에 그대로 노출됨~~ — **반영 완료(2026-08-07 열 번째 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index c2558af..f56af34 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -313,6 +313,16 @@ Luau 솔버가 막히면 되돌릴 범위가 `store-semantics.md`의 절반 이 ### 2-2. `State` 타입 차단이 Luau에서 실제로 가능한지 검증 계획이 없음 +**[대부분 해소, 2026-08-09 세션]** "가능하면 타입 차단, 안 되면 UB로 +후퇴"라는 원래 걱정 자체가 무의미해짐 — `State`를 이제 +`isModifier` predicate 기반 명시적 `error`로 막기로 확정 +(`base/modifier-plan.md` 7번 정정, `base/store-semantics.md` "따름정리" +절)했으므로, 타입 차단은 성공하든 실패하든 런타임 에러라는 안전망이 +항상 있음 — 아래 "제안"이 우려했던 "조용히 UB로 후퇴" 시나리오 자체가 +발생하지 않음. 타입 차단이 Luau에서 실제로 가능한지는 여전히 미검증이지만, +이제 "되면 좋은 보너스"로 우선순위가 낮아짐 — M0/M7 필수 검증 항목에서 +제외해도 됨. 아래는 원래 발견 당시 기록. + **위치**: `base/modifier-plan.md` 7번. **문제**: "가능하면 타입 시스템으로 아예 못 넣게 막을 것"이라 확정했지만, @@ -501,6 +511,14 @@ M11 착수 시. ### 3-1. `:Compute(fn)`의 `previous` 인자 — 클로저 업밸류로 이미 되는 걸 별도 API로 만든 것일 수 있음 +**[해소됨, 2026-08-09 세션]** 오버엔지니어링 아님으로 확정, 현재 +`fn(self, previous)` 설계 그대로 유지 — 클로저 업밸류 대안은 IIFE로 +감싸야 하는 준비 비용이 오히려 더 크다는 게 사용자 반박 논거. +`previous`는 `self`(입력)가 아니라 이 `:Compute` 호출 하나가 만든 +결과 State 노드 자신에 귀속되므로 팬아웃 시에도 충돌 없음 — 상세는 +`base/bind-system-plan.md`의 "previous" 절 참고. 아래는 원래 발견 +당시 기록. + **위치**: `base/bind-system-plan.md` "`:Compute(fn)`의 선택적 두 번째 인자 — `previous`" 절. @@ -565,6 +583,16 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 보이지만, 문서 어디에도 "왜 이 경우엔 원칙에서 예외로 처리하는가"를 명시적으로 인정/정당화하지 않고 그냥 나란히 적혀 있다. 위 2-2 항목 (Luau에서 실제 차단 가능한지)과 묶어서 같이 정리할 문제. + + **[완전 해소, 2026-08-09 세션]** 양쪽 다 이제 같은 메커니즘 — + Ref/Slot(+Observer/Effect/Modifier 자기 자신)이 Modifier *필드*로 + 들어오는 것도, `State`처럼 Modifier가 State/Source *값*으로 + 담기는 것도 전부 `Brand` 기반 `isX` predicate로 런타임에 즉시 + `error`(`base/modifier-plan.md` 4번/7번 절 정정, `base/ + store-semantics.md` "따름정리" 절). 더 이상 "한쪽만 방어" 비대칭이 + 아님 — 남은 차이는 `State` 쪽에 "되면 좋은 보너스"로 + 타입 차단을 추가 시도해볼 여지가 있다는 것뿐(위 2-2번, 미검증이지만 + 더 이상 필수 방어선이 아니라 우선순위 낮음). - **Destroying 훅 신뢰도에 대한 서술이 `lifecycle-pattern.md` 내부에서도, `framework-comparison-findings.md`와의 사이에서도 어긋남** — 위 1-6 항목에 상세, 여기서는 "아직 아무도 하나의 확정 문장으로 정리 안 함"이라는 diff --git a/.claude/research/tween-plan.md b/.claude/research/tween-plan.md index 9db5085..f8439b3 100644 --- a/.claude/research/tween-plan.md +++ b/.claude/research/tween-plan.md @@ -126,6 +126,37 @@ delayTime)`는 순수 포지셔널 생성자인데, Luau엔 named call 문법이 구체적인 필드 이름/기본값/`TweenInfo` 재사용 경로의 정확한 문법은 아직 확정 아님 — 나중 논의 대상으로 남김. +## 초기 진입 애니메이션(`initValue`)과 트윈 우회(`useTween`) — 둘 다 미확정 (2026-08-09 세션) + +**아직 논의 시작 단계 — 나중에 더 다룰 주제로만 남겨둠.** 사용자가 두 +실사용 시나리오에서 출발한 제안: + +1. **`Tween{initValue=...}`(검토 후 필요성 낮은 쪽으로 기움)** — 동기: + 다이얼로그가 아래에서 위로 올라오며 열리는 것처럼, 처음 마운트될 때 + 시작 위치에서 목표값으로 자연스럽게 움직이는 "enter" 애니메이션을 + 내고 싶은데, 지금 구조로는 첫 마운트 시 이미 목표값이 프로퍼티에 + 세팅된 채로 시작해 트윈이 걸리지 않음. `initValue`로 먼저 그 값을 + 세팅해두고 그 다음 실제 목표값으로 트윈을 트리거하면 될 것 같다는 + 아이디어. **사용자가 스스로 재검토하며 "필요 없는 것 같다"는 쪽으로 + 기움** — 정확한 반례(재process/재마운트 시 `initValue`를 매번 다시 + 적용하면 안 되는데 "최초 1회"를 무엇으로 판별할지가 또 다른 설계 + 문제가 될 수 있음)는 다음 세션에서 확인 필요, 완전 폐기는 아직 아님. +2. **`Tween{useTween = state}`(필요성은 확인, 모양은 미정)** — + 동기: `Visible`로 여닫는 다이얼로그가 항상 아래에서 위로 슬라이드 + 올라와야 하는 게 기본이지만, 특정 상황(접근성 설정으로 애니메이션을 + 완전히 끄고 싶은 경우 등)엔 트윈을 건너뛰고 즉시 목표값으로 스냅해야 + 함 — 트윈을 무시하고 값을 그냥 세팅할 방법이 필요하다는 문제의식. + 제안 동작: 트윈이 실행되려는 시점에 먼저 `useTween:Get()`을 읽어서 + `false`면 진행 중이던 트윈을 전부 죽이고 목표값으로 즉시 `Set`, + `true`(또는 생략 시 기본값)면 평소대로 트윈. + +**결론(미확정)**: `initValue`는 필요 없어 보이지만(사용자 소견) 완전 +폐기는 아직 안 함. `useTween`류 트윈 우회 필요성은 실사용 근거가 +뚜렷해 유지될 가능성이 높지만, **정확한 옵션 키 모양과 문서화 방식이 +아직 전혀 안 정해짐**(사용자 강조: "어떤 모양을 주냐가, 문서화 어떤 +식으로 하냐가 엄청 중요한듯") — 위 "트윈 옵션 값 모양" 절의 편의 필드 +논의와 함께 M11 착수 전 나중 세션에서 마저 정리. + ## 네임스페이스드 객체 (성능상 이유로 보류) 트윈 대상을 이름으로 찾는 별도 네임스페이스는 성능상 별로라고 판단 — @@ -143,3 +174,6 @@ CollectionService를 쓰는 게 나아 보이지만, 트윈 전용 네임스페 받을지 편의 필드+기본값으로 받을지, 소견은 후자 쪽이지만 확정 아님. - 자연 완료(Completed) 시 per-instance 북키핑 정리 여부 — `research/pre-implementation-audit.md` 2-10번 참고, M11 착수 시 확정. +- `initValue`(진입 애니메이션)/`useTween`(트윈 우회) — 위 절 참고, + 후자는 필요성은 확인됐으나 모양/문서화 미정, 전자는 필요성 자체가 + 낮은 쪽으로 기움. diff --git a/CLAUDE.md b/CLAUDE.md index 655746a..ae1859a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1699,3 +1699,89 @@ realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `DI`/`D`와 `canExecute`/`isAlive` 두 개만 용어 정리 라운드에 계속 남음 — `question.md` 1순위/3순위 목록 참고. + +## 2026-08-09 세션 — `canBound` 이름 확정, `:Compute`의 `previous` 방어/스코핑 +명확화, Modifier 핸들러 계층 값 UB→error 전환, Tween `initValue`/`useTween` +논의 신설 + +사용자가 `.claude/question.md`를 훑다 나온 여러 짧은 질문/제안을 한 번에 +처리. 전부 `base/`/`research/`에 반영 완료: + +1. **`Bound` → `canBound(handle): boolean` 탑레벨 함수로 확정** — 사용자 + 제안("canExecute 같은 게 있으니 canBound로 넣어도 되지 않나"), raw + 불리언 필드를 직접 노출하는 대신 `canExecute`와 같은 결의 predicate + 함수로 감쌈. 동작 자체(leaf 부착과 `:Subscribe()`는 상호 배타, 위반 + 시 즉시 에러)는 안 바뀜 — `base/bind-system-plan.md` "이중 바인딩 + 금지" 절, `base/effect-plan.md`, `.claude/question.md` 반영. +2. **`:Compute(fn)`의 `previous` 인자 — 오버엔지니어링 의심 기각, 현재 + 설계 유지.** `pre-implementation-audit.md` 3-1이 "클로저 업밸류로 + 이미 되는 걸 별도 API로 만든 것 아니냐"고 의심했던 데 대해 사용자가 + 직접 반박 — 클로저 업밸류 대안은 IIFE로 감싸는 준비 비용이 오히려 + `previous`라는 인자 하나보다 무겁고 번거로움. **부수적으로 스코핑도 + 명확화**: 처음엔 `self.Cache`처럼 `previous`를 `self`(입력) 쪽에 + 얹는 모양이 제안됐으나, `self`는 `:Compute`의 입력(receiver)이라 + 같은 `self`에서 여러 `:Compute`가 갈라지는 팬아웃(`w:Compute(g1)`, + `w:Compute(g2)`)이 있으면 `self.Cache` 슬롯이 충돌한다는 문제를 + 검토 중 발견 — `previous`는 그 대신 "이 `:Compute` 호출 하나가 만든 + 결과 State 노드" 자신에 귀속되는 것으로 정리(State가 호출마다 새 + 노드를 만든다는 기존 온톨로지의 당연한 귀결이라 새 결정은 아님). + `base/bind-system-plan.md`의 "previous" 절, `pre-implementation-audit.md` + 3-1 반영. +3. **Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 UB 대신 즉시 `error`로 확정.** 기존 + "권장 사용법은 아니지만 막을 이유도 없음 — 방어 로직 없는 UB"였던 + 것을, 이런 값의 실사용 case가 없다는 게 확인된 이상 조용한 UB보다 + 그 자리에서 막는 쪽이 낫다는 사용자 판단으로 전환 — 이미 있는 + `Brand` 기반 predicate(`isRef`/`isPreRef`/`isObserver`/`isEffect`/ + `isSlot`/`isModifier`)를 제네릭 `__index` setter가 최종 저장 직전에 + 확인하기만 하면 되므로 구현 비용 거의 0. `isSlot`/`isEffect` + predicate가 `Brand` 절에 명시적으로 없던 갭도 같이 보강. + `pre-implementation-audit.md`가 지적했던 "`State`는 방어, + Ref/Slot은 무방비"라는 비일관성이 이걸로 절반 해소(메커니즘 차이는 + 남지만 "막을 가치가 있다"는 판단은 통일) — `base/modifier-plan.md` + "핸들러 계층을 모름" 절, `base/bind-system-plan.md`의 `Brand` 절, + `pre-implementation-audit.md` 문서모순 절 반영. +4. **UI shorthand(UICorner/UIPadding/UIScale)가 Modifier 체이닝에서도 + 되는지 — 이미 확정돼 있던 것 재확인, 새 결정 없음.** `mod:UICorner(8)`은 + 그냥 제네릭 `__index` setter가 `UICorner` 필드를 채우는 것뿐이고, + 그 필드가 Modifier flatten을 거쳐 최종 props 테이블에 얹히든 + `Frame { UICorner = 8 }`처럼 순수 인라인으로 들어가든 UICorner + Handler 입장에선 구분이 없음 — `base/ui-shorthand-plan.md`에 이미 + 명시돼 있던 내용이라 문서 변경 없음. +5. **Tween `initValue`/`useTween` — 새 열린 논의 신설, 확정 아님.** + 사용자가 두 실사용 시나리오(다이얼로그 진입 애니메이션, 트윈 우회)를 + 제기 — `initValue`(첫 마운트 시 시작값을 세팅 후 목표값으로 트윈)는 + 재검토 끝에 필요성이 낮은 쪽으로 기움(재process 시 "최초 1회" 판별 + 문제가 있어 보임), `useTween = state`(트윈을 끄고 즉시 + 스냅)은 필요성은 확인됐으나 정확한 모양/문서화 방식이 전혀 안 + 정해짐 — `research/tween-plan.md`에 신규 절로 반영, M11 착수 전 + 나중 세션에서 마저 정리하기로 함. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정/보강이라 M0 착수 우선순위 자체는 그대로. + +**같은 세션 후속 — `State`도 UB 대신 명시적 `error`로 확정, +"핸들러 계층 값 → error" 원칙을 State/Source 쪽까지 완전히 통일.** +사용자 질문: "Modifier 필드"뿐 아니라 "State/Source 자체의 값이 +Modifier인 경우"(`State`, `modifier-plan.md` 7번)도 같은 +방식으로 막아도 되는지 — 확정. `isModifier` predicate를 +`Source:Set()`/Store 생성 시 eager `Source(default)`/State의 +`:Compute` 결과 캐싱 지점에서 확인해 런타임 `error`, 타입 차단(Luau +가능 여부 미검증)은 필수 방어선이 아니라 되면 좋은 보너스로 격하. +**Slot은 대조적으로 계속 허용** — 사용자 확인("slot은 당연히 가능함, +retract도 되는 애고 런타임 값이라"): Slot/Tag/Attribute/Tween 등은 +정상적으로 process/retract 재귀 경로를 타는 진짜 dispatch 참가자라 +State/Source 값으로 담겨도 기존 재귀 재-dispatch가 그대로 처리해줌 — +Modifier만 예외인 건 Modifier가 애초에 dispatch 경로 자체를 안 타는 +유일한 존재라서. `base/modifier-plan.md` 7번, `base/store-semantics.md` +"따름정리" 절, `research/pre-implementation-audit.md` 2-2/문서모순 절 +(완전 해소로 갱신), `.claude/question.md`, `ROADMAP.md` M7 반영 완료 — +이걸로 `pre-implementation-audit.md`가 지적했던 "State는 +방어, Ref/Slot은 무방비"라는 비일관성이 완전히 해소됨. + +**핸드오버 준비 완료** — 이번 대화(2026-08-08~09에 걸친 세션)에서 나온 +결정은 전부 `base/`/`research/`/`question.md`/`ROADMAP.md`에 반영, +문서 간 참조도 동기화 완료. **다음 세션 예고(사용자 지정)**: Slot과 +"State에서 Slot을 뽑아내는" 키 기반 동적 컬렉션 재조정(가칭 `Keyed`는 +탈락, 최종 이름 미정) — `.claude/question.md` 0번 "키 기반 동적 +컬렉션 재조정"이 이미 최우선 항목으로 잡혀있으니 그걸 이어서 보면 됨. diff --git a/ROADMAP.md b/ROADMAP.md index 3aca8d7..de9c013 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -187,7 +187,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `GuiObjectModifier`)을 섞을 때의 타입 시그니처 실 Luau 테스트 (`modifier-plan.md` 9-2번, 미검증 — 안 되면 일단 `Overridden(...: any): any`로 느슨하게 열어두고 이 항목으로 되돌아올 것) -- [ ] `State` 조합 타입 차단 확인(`modifier-plan.md` 7번, UB 확정) +- [ ] `State` 조합에 `isModifier` 기반 명시적 error 적용 + (`modifier-plan.md` 7번, 2026-08-09 세션 확정) — 타입 차단은 + 되면 좋은 보너스로 선택 검증(필수 아님) - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 +