diff --git a/.claude/README.md b/.claude/README.md index 172e211..a4cb785 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -36,7 +36,9 @@ | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | -| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고 | +| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | +| `tag-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Attribute "Name"]` — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. 타입 파라미터화 이름(`Attribute` vs `BooleanAttribute`류)만 미확정 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md new file mode 100644 index 0000000..d147134 --- /dev/null +++ b/.claude/base/attribute-plan.md @@ -0,0 +1,72 @@ +# Attribute 특수 키 — 타입 파라미터화, `SetAttribute(name, nil)` 네이티브 지우기 + +**상태**: base(메커니즘/`None`/`retract` 동작은 확정) — 타입 파라미터화 +이름만 미확정. `[Attribute "Name"]` DI 키의 존재 자체는 `architecture.md` +4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 +2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute +에도 적용해야 한다는 사용자 지적) — `bind-system-plan.md`의 "Attribute +특수 키 — 타입 파라미터화" 절(2026-08-06 신설) 내용을 그대로 옮기고, 오늘 +논의한 `None`/`process`/`retract` 동작을 추가. + +## 문제 — 타입 있는 값이라 Luau가 좁혀줄 방법이 필요 + +Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 값** +(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/Instance +참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 함)이라, 그냥 +`[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가 좁혀줄 방법이 +없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 지원을 안 하므로 +Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 프리미티브 전용으로 +남기면 된다는 게 사용자 판단 — Value 오브젝트가 역사적으로 Attribute의 +대안(테이블/참조를 담는 용도)으로 나온 배경이지만, 지금은 Roblox Attribute가 +Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Attribute를 +그대로 쓸 수 있다는 점을 사용자가 짚음(`research/debug-tooling-plan.md`의 +"Value 오브젝트 기각, Attribute로 확정" 결정과 같은 방향 — Instance 타입 +지원까지 감안하면 그 결정의 근거가 한층 더 탄탄해짐). + +**후보 두 가지 (미확정)**: +- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — + 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. +- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 + 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ + `InstanceAttribute` 등). + +**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 +패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 +구조적으로 똑같은 문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 +필드로 나눌지" 고민이 있었고, 결론은 **둘 다**(`new(className)` +제네릭 생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 +같은 모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 +기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ +`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ +`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 사용자 확인 전 +소견일 뿐 — `.claude/question.md`에 반영, 사용자 판단 필요. + +## 메커니즘, `None`, `retract` — 전부 확정 (2026-08-07 여덟 번째 세션) + +타입 파라미터화 이름과 무관하게 런타임 동작은 확정: + +- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부. + **Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가 + `SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로 + 네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의 + `None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이** + `inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 + "만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음. +- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 + 같은 `AttributeHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). + `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", + Tween↔일반 프로퍼티가 실사례)에 해당 안 함 — `bind-system-plan.md` + "확정된 디스패치 모델" 절이 한때 Attribute도 retract 필요 예시로 들었던 + 걸 여기서 바로잡음. +- store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` + 값도 받음). + +## 패키지 배치 + +UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어에 직접 +포함, 별도 opt-out 패키지로 안 쪼갬. + +## 열린 질문 (`.claude/question.md`에도 취합) + +- 타입 파라미터화 이름(`Attribute` 제네릭 vs `BooleanAttribute`류 정적 + 패밀리 vs 절충) — 위 "문제" 절 참고, 다음 세션 사용자 판단 필요. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7d4718d..a3594b4 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -24,12 +24,20 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 핸들러는 다음 4개를 제공하는 등록 가능한 객체: -- `isHandlable(key, value): boolean` — 이 핸들러가 이 key/value 쌍을 처리할 - 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — tbox의 type-check/ - constraint-check 분리 원칙(`.claude/initreq/tbox/CLAUDE.md`의 "타입 체크는 - 분기 선택에 쓰이므로 순수해야 함")을 그대로 적용: `isHandlable`은 오직 - "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 유효성 검사는 핸들러가 - 선택된 *이후* 별도 단계에서. +- `isHandlable(inst, key, value): boolean` — 이 핸들러가 이 inst/key/value + 조합을 처리할 수 있는지 판별하는 predicate. **부작용 없이, 빠르게** — + tbox의 type-check/constraint-check 분리 원칙(`.claude/initreq/tbox/ + CLAUDE.md`의 "타입 체크는 분기 선택에 쓰이므로 순수해야 함")을 그대로 + 적용: `isHandlable`은 오직 "이 핸들러가 맞는가" 판별에만 쓰이고, 실제 + 유효성 검사는 핸들러가 선택된 *이후* 별도 단계에서. **`inst`도 받음 + (2026-08-07 여덟 번째 세션 정정, 원래 `(key,value)`뿐이었음)** — + `process`/`retract`는 처음부터 항상 `inst`를 받았는데("모든 핸들러는 + 대상 Instance를 직접, 항상 받는다", 아래 "확정된 디스패치 모델" 절) + `isHandlable`만 예외였던 게 애초에 약간의 불일치. 지금 당장 `inst`에 + 따라 매치 여부가 갈리는 케이스는 없지만, 나중에 필요해지면(다른 + 백엔드에서 인스턴스 종류별로 매치가 달라져야 하는 경우 등) 핸들러 + 계약 자체를 깨는 breaking change가 되므로 지금 넣어두는 게 훨씬 쌈 — + 사용자 판단으로 확정. - `priority: number` — 우선순위. 등록 순서(Fusion의 4단계 고정 stage, Vide의 action() 우선순위)보다 일반화된 **열린 숫자 공간**으로. - `process(inst, key, value)` — 실제 처리 수행(아래 "확정된 디스패치 모델" @@ -62,8 +70,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 엔드포인트 백엔드(`quad-roblox`/`quad-web` 등)가 알아서 결정할 문제 — base 인터페이스는 "무언가를 inst로 받아 process/retract한다"는 계약만 지키면 됨, 그 inst의 실체가 뭔지는 백엔드 재량. -- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, `isHandlable(k,v)`를 - 만족하는 최상위 핸들러가 실제 처리를 담당. +- `process(inst, k, v)` — 우선순위 순으로 등록된 핸들러를 스캔, + `isHandlable(inst,k,v)`를 만족하는 최상위 핸들러가 실제 처리를 담당. + **이 "스캔+실행" 오케스트레이터는 `Dispatch.process`로, 순수 스캔 + 부분은 `Dispatch.getHandler`로 이름이 공식화됨**(아래 `None` 센티널 + 절, 2026-08-07 여덟 번째 세션) — 이 절에서는 개념 설명이라 편의상 + 그냥 `process`로 계속 씀. - 예시: Tween의 store-bind 핸들러는 **`k`는 무엇이든 받고 `v`가 Store인 경우를 잡아내는, 우선순위가 매우 높은 핸들러** — `v`가 store이면 그 값을 처리(구독)함. 이 핸들러 안에서: @@ -72,11 +84,13 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 `Connected`를 보고 무시(no-op). 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. - 3. **`realv`를 들고 다시 `process(inst, k, realv)`를 재귀 호출** — 이게 바로 - "store 바인드는 pluggable 바인드를 재실행하는 래핑"이라는 이 문서 이전 - 초안의 결론과 일치. `realv`가 store가 아니라면 자연히 Tween의 store-bind - 핸들러 `isHandlable`을 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 - 세터 등)로 흘러감 — 무한 재귀 걱정 없음. + 3. **`realv`를 들고 다시 `Dispatch.process(inst, k, realv)`를 재귀 호출** + (오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 + 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 + 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 + store가 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 + 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 + — 무한 재귀 걱정 없음. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -84,9 +98,17 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고). - 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋 동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음. - - `retract`가 실제로 의미 있는 곳: **Tag를 지운다, Attribute 엔트리 자체를 - 지운다, 실행 중인 Tween을 멈춘다** 같은, "값을 새로 셋하는 것"과 - "이전 상태를 명시적으로 되돌리는 것"이 다른 케이스. + - **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는 + 핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째 + 세션, 정정) — 예: 이전엔 Tween 핸들러가 매치돼 애니메이션이 실행 + 중이었는데, 다음 값이 더 이상 Tween 대상이 아니게 되어 일반 + PropertyHandler로 매치가 넘어가는 경우, 이전 Tween을 멈추는 게 + `retract`의 일. **Tag/Attribute는 여기 해당 안 함** — 처음엔 이 + 둘도 예시로 들었으나, 실제로는 UICorner 숏핸드와 같은 패턴(값의 + 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 담당하고, + 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 안 + 일어나서 `retract`가 발화할 조건이 생기지 않음 — 구체 설계는 + `base/tag-plan.md`/`base/attribute-plan.md`. - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 한 번 호출해주면 됨. - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 @@ -135,6 +157,92 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). +### `None` 센티널 — Tween store-bind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션) + +`modifier-plan.md` "2-1"절의 "인라인 키로 modifier 필드를 명시적으로 +지우기" 문제 — raw 저장 계층(Modifier 필드/인라인 props/`Peek`)에서 쓰는 +`None` 센티널이 실제로 인스턴스에 반영될 때 base가 뭘 하는지가 이 문서의 +층위. 결론: **새 메커니즘이 아니라 위 "확정된 디스패치 모델"의 +Tween store-bind 핸들러(65-79행)와 완전히 같은 모양의 핸들러 하나 추가.** + +``` +NoneHandler.priority = <매우 높음> +NoneHandler.isHandlable(inst, k, v) = (v == None) +NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 +``` + +- **매치 predicate는 `isHandlable`** — `canExecute`가 아님. 둘은 완전히 + 다른 개념이라 혼동하지 말 것: `isHandlable(k,v)`는 KV 매치 predicate(핸들러 + 계약 4종 중 하나, 이 절에서 다루는 것), `canExecute`는 인자로 받은 특정 + 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 + 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — + KV 매치와 무관. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 + `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 + 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — Tween의 + store-bind 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 + 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. +- **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 + 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 + "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 + 로직" 둘 다 그냥 `process`라고 불러서 이름이 겹쳤음 — 이제 두 계층을 + 명시적으로 분리: + - `Dispatch.getHandler(inst,k,v): Handler?` — 순수 스캔(`handler.isHandlable(inst,k,v)`+ + `priority`), 부작용 없음. + - `Dispatch.process(inst,k,v)` — 오케스트레이터: `getHandler` 호출 → + 이전에 이 키를 담당하던 핸들러와 다르면 이전 핸들러의 `retract` 호출 → + 새로 매치된 핸들러의 `.process` 호출. **재귀 재디스패치(Tween/일반 + store-bind/`NoneHandler`)는 전부 이 `Dispatch.process`를 다시 부르는 + 것** — 원래 있던 재귀 관례 그대로, 새로 바뀐 것 없음. + - `Dispatch.addHandler(handler: Handler)` — 핸들러를 우선순위 레지스트리에 + 등록. `Dispatch.process`/`getHandler`와 마찬가지로 base엔 인터페이스만 + 있고, quad-roblox의 concrete Handler들(PropertyHandler/EventHandler/ + UICornerHandler/TagHandler/AttributeHandler 등)은 팩토리가 `BaseModule`을 + 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 + 같은 패턴, 새 메커니즘 아님). + - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — + 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 + 소유자를 명시(`Dispatch.process` vs `handler.process`)하는 것으로 해소. + - **base 드라이버 루프 자신의 이름은 `Dispatch.drive(inst, flattened)`로 + 확정** — 이미 위 "props 순회 순서" 절이 이걸 비공식적으로 "base + 디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 + flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 + 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 + `Dispatch.process`를 호출하는 게 이 함수의 본체. +- **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 + "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 + 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 + 자식 Instance를 실제로 지우는 것까지 포함 — 구체 예시는 + `base/ui-shorthand-plan.md`/`base/tag-plan.md`/`base/attribute-plan.md`. + `None`은 **"이 조합 단계에서 나는 이 필드를 세팅 안 한다"**는 뜻이고, + 그걸 받은 실제 핸들러가 무엇을 할지는 각자 몫. 개별 프로퍼티/이벤트/UI + shorthand 핸들러의 `process` 시그니처는 안 바뀜 — 이들은 원래도 `v`가 + State 계산 결과로 `nil`이 되는 경우를 처리할 수 있어야 했으므로(일반 + 반응형 케이스), `None`은 그 기존 경로에 도달하는 방법 하나가 늘어난 것뿐. + **구현 디테일 캐비엇**: `None→nil`이 Roblox의 nil을 허용 안 하는 타입 + 프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 — + PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야 + 함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸). +- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이 + 넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지 + "`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상 + `Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가 + 딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로). +- **M2 착수 시 확인할 것 (`pre-implementation-audit.md` 우선순위1 + "이전에 실제로 매치됐던 핸들러 추적" 항목에 추가)**: "이 키를 지금 누가 + 담당 중인가" bookkeeping은 바깥 순회 루프(`Dispatch.drive`)가 아니라 + `Dispatch.process` 호출 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 + `None`으로 유지되는 매 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 + 실제 담당은 다른 핸들러"로 바깥 루프가 오판해 불필요한 `retract`를 반복 + 호출할 위험이 있음 — `Dispatch.process`가 재귀 호출 시에도 자기 자신을 + 통해 담당자 기록을 갱신하게만 해두면(`Dispatch.drive`가 별도로 기록 안 + 하고 `Dispatch.process` 내부에 위임) 자연히 해소됨. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -142,10 +250,11 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 **확정**: 래핑 쪽. 위 "확정된 디스패치 모델" 절 참고 — store 바인드 핸들러도 다른 핸들러와 동일한 `isHandlable`/`priority`/`process`/`retract` 계약을 -따르되, `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 value)로 -`process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. 이러면 store 값 -자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) 상관없이 동일한 -재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결. +따르되, 자신의 `process`가 내부적으로 "실제 값이 바뀔 때마다 (원래 key, 새 +value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구현됨. +이러면 store 값 자체가 어떤 타입이든(원시값, 인스턴스, 심지어 다른 store) +상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 +가능한가"와 직결. Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 @@ -454,9 +563,9 @@ SyntheticEvent만 주는 것과 같은 모양). 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. **엔지니어링 비용이 낮은 이유**: 이미 확정된 "Store 바인드는 pluggable -바인드를 재실행하는 래핑"(위 절, `process`가 값이 바뀔 때마다 -`process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이 -호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. +바인드를 재실행하는 래핑"(위 절, 핸들러의 `process`가 값이 바뀔 때마다 +`Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 +같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. 이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/ @@ -1155,8 +1264,8 @@ copy-on-write 절충안은 2026-08-04 검증 라운드에서 사실상 폐기 ## 확정된 것 (더 이상 열린 질문 아님) -- **핸들러 계약**: `isHandlable(k,v)` + `priority` + `process`(구 `bind`) + - `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 +- **핸들러 계약**: `isHandlable(inst,k,v)` + `priority` + `process`(구 + `bind`) + `retract`(구 `cleanup`) 4종 조합으로 확정 — tbox식 6-hook 세분화는 지금은 안 함. 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장). - **Signal 클래스**: 안 만듦, 콜백 + `Connected` 계산 속성만(`base/ lifecycle-pattern.md`). @@ -1245,71 +1354,86 @@ Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 - **Store/State 전파 모델, 라이프사이클 — 둘 다 재검토 후 기존 확정 유지** (위 "Store/State/Source 온톨로지" 절의 "PA님 코드와의 교차검증" 참고). -## Attribute 특수 키 — 타입 파라미터화 (2026-08-06, 신규 논의) +## Tag/Attribute 특수 키 — 전용 문서로 분리됨 (2026-08-07 여덟 번째 세션) -**상태**: 미확정, 사용자가 이번에 새로 제기 — 이전에 기록된 적 없음 -(`architecture.md` 4번 항목의 `[Attribute "Name"]`은 특수 DI 키의 존재만 -확정했을 뿐, 타입을 어떻게 표현할지는 다룬 적 없었음). +`base/tag-plan.md`/`base/attribute-plan.md`로 이동 — 이 절이 다루던 타입 +파라미터화 문제(`[Attribute<> "name"]` vs `[BooleanAttribute +"name"]`)뿐 아니라 `None`/`process`/`retract` 동작까지 확정 반영됨. +UICorner 숏핸드/Tween처럼 "1 프리미티브 1 파일" 관례를 따라야 한다는 +지적으로 분리. -**문제**: Roblox Attribute는 Instance/Tag와 달리 실제로 **타입이 있는 -값**(string/boolean/number/Color3/UDim/UDim2/Vector2/Vector3/CFrame/ -Instance 참조 등 제한된 프리미티브 집합, 테이블 등 복합 타입은 지원 안 -함)이라, 그냥 `[Attribute "name"] = value`로 두면 `value`의 타입을 Luau가 -좁혀줄 방법이 없음. 커스텀/복합 데이터(테이블 등)는 애초에 Attribute가 -지원을 안 하므로 Ref(직접 참조 획득) 쪽으로 빠지는 게 맞고, Attribute는 -프리미티브 전용으로 남기면 된다는 게 사용자 판단 — Value 오브젝트가 -역사적으로 Attribute의 대안(테이블/참조를 담는 용도)으로 나온 배경이지만, -지금은 Roblox Attribute가 Instance 참조 타입도 지원해서 `ObjectValue` -없이도 Ref 용도로 Attribute를 그대로 쓸 수 있다는 점을 사용자가 짚음 -(`research/debug-tooling-plan.md`의 "Value 오브젝트 기각, Attribute로 -확정" 결정과 같은 방향 — Instance 타입 지원까지 감안하면 그 결정의 근거가 -한층 더 탄탄해짐). +## `Brand` — 런타임 nominal 타입 판별 통합 메커니즘, `isState`를 일반화 (2026-08-07 여덟 번째 세션) -**후보 두 가지**: -- `[Attribute<> "name"] = true` (리터럴 또는 store-bind 값) — - 제네릭 파라미터로 타입을 명시하는 제네릭 생성자 스타일. -- `[BooleanAttribute "name"] = true` — 타입별로 이름이 다른 정적 생성자 - 패밀리(`StringAttribute`/`NumberAttribute`/`Color3Attribute`/ - `InstanceAttribute` 등). +**배경**: `isState`(2026-08-07 다섯 번째 세션 확정, `:Peek<>(key): +T|State|nil`가 돌려주는 raw union을 사용자 코드가 분기하려면 판별 +수단이 필요했음)와 똑같은 필요가 quad의 다른 branded 타입에도 전부 +적용됨 — `Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ +`Source`/`Slot`/`None`까지, Handler 구현(`isHandlable`에서 "이 값이 +Tween인가/Store인가" 판별)과 사용자 코드 양쪽에서 반복적으로 필요해질 +수단이라 `isState` 하나만 만들고 끝내지 않고 전체를 일관된 메커니즘으로 +통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로 +(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐). -**소견(확정 아님, 검토 필요)**: 이 선택은 이미 확정된 DI 인스턴스 생성 -패턴(위 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 -문제 — 그때도 "제네릭 하나로 다 커버할지 vs 타입별 정적 필드로 나눌지" -고민이 있었고, 결론은 **둘 다**(`new(className)` 제네릭 -생성자 + 자주 쓰는 ~25개는 정적 필드로 미리 바인딩)였음. Attribute도 같은 -모양을 재사용하면 자연스러울 가능성 — `Attribute("name")` 제네릭을 -기본으로 두고, 실사용 빈도가 압도적으로 높을 `Boolean`/`Number`/`String`/ -`Instance` 정도만 `BooleanAttribute`/`NumberAttribute`/`StringAttribute`/ -`InstanceAttribute` 같은 지름길로 정적 바인딩하는 절충. 단 이건 사용자 -확인 전 소견일 뿐 — `.claude/question.md`에 반영, 다음 세션에서 사용자 -판단 필요. - -## `isState(x): boolean` — State/Source 판별 predicate (2026-08-07 다섯 번째 세션) - -**배경**: `base/modifier-plan.md` 9번 절의 `:Peek<>(key): T|State|nil` -(Modifier 필드를 확정하지 않고 raw 그대로 읽는 접근자)이 나오면서, 사용자 -코드가 그 결과를 State/plain으로 분기하려면 판별 수단이 필요해짐 — 같은 -필요가 실은 새로 생긴 게 아니라 Modifier의 함수형 setter(`modifier-plan.md` -4-1번, "현재 필드가 State냐 plain이냐"로 동작이 갈림)가 지금까지도 내부적으로 -풀어야 했던 문제인데 그 판별 방법 자체가 문서에 명시된 적이 없었음 — 이번에 -`isState`로 명문화하며 그 구멍도 같이 메움. - -**Source도 같이 잡힘, 별도 `isSource` 불필요** — Source가 State를 구조적으로 -만족(위 "Source가 State를 만족함" 관련 내용은 `base/store-semantics.md` -참고)하므로, `isState(source) == true`가 자연스러운 동작이고 그걸로 충분함. - -**구현: weak-key 레지스트리, duck-typing 아님.** +**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로 +사용(문자열 아님).** ``` +local Brand = {} local registry = setmetatable({}, {__mode = "k"}) --- State/Source를 만드는 모든 생성 지점(Source(...), :With(...), :Compute(fn) 등)에서: -registry[newHandle] = true --- predicate: + +function Brand.set(x, tag) registry[x] = tag end +function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 + +-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 +local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, + StateTag, SourceTag, StoreTag, SlotTag = {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + +-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: +Brand.set(newHandle, ObserverTag) +``` + +**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** — +Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는 +무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열 +리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블 +레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이 +되어 헷갈릴 여지가 없음. + +**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — +단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위 +관계(subtype)가 있어 집합 멤버십이 필요한 경우(`isState`)로 갈림: + +``` local function isState(x) - return registry[x] == true + local t = Brand.get(x) + return t == StateTag or t == SourceTag -- Source가 State를 구조적으로 만족 +end +local function isSource(x) + return Brand.get(x) == SourceTag end ``` +**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 +뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만 +염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을 +가진 진짜 서브타입이라 "이 값이 (읽기 전용이 아니라) 쓰기도 되는 +원천인가"를 알아야 하는 코드는 `isState`만으론 부족함 — `isSource`를 +별도로 제공, `isState`는 여전히 `{State, Source}` 둘 다 통과시킴(상위 +개념이니까 당연히). `component-composition-plan.md` 4번 절이 이미 +`isSource`가 존재한다고 가정하고 있었던 것과도 이걸로 정합됨(그동안 두 +문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` +불필요" 서술도 같이 정정 대상. + +**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** +`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 +quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` +레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든 +값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 +"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도 +빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 +특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 +바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음). + **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를 @@ -1317,10 +1441,21 @@ end 인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한 엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음. weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과 -같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 State/ -Source가 GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 +같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이 +GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격). +**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자 +확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가 +`v`의 정적 타입을 알아서 좁혀주진 않음(TypeScript의 `x is T`류 사용자 +정의 타입 가드를 Luau가 지원 안 함) — `if isState(v) then local s = v :: +State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 게 실제 +패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만 +"자동 narrowing"을 기대하면 안 됨. + +**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상, +`.claude/question.md`에 반영.** + ## 남은 열린 질문 (`.claude/question.md`에도 취합) 이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 020495b..776a987 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -127,9 +127,33 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 **base가 범용 유틸로 제공할 것: 임의의 클로저/구독을 실제 Roblox 객체의 생명주기에 바인드하는 도구** — 내부적으로 v1/rbvm이 쓰던 "connect 트릭"(어떤 신호에든 연결해서 참조를 죽을 때까지만 붙잡아두는 것)을 그대로 재사용. 이 -도구로 바인드된 옵저버는 `canExecute: () -> boolean` 같은 predicate 람다를 -가질 수 있어서, `Connected`가 false면 실행 자체를 건너뛸 수 있음(죽은 대상에 -대한 처리 시도 방지, 위 원칙과 직결). +도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 +실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). + +**`canExecute`의 시그니처는 `(handle) -> boolean`이지 `() -> boolean`이 +아님(2026-08-07 여덟 번째 세션, 정정)** — 처음엔 "바인딩마다 클로즈오버된 +zero-arg 람다"로 적었으나, 그러면 등록마다 클로저를 새로 만들어야 해서 +아래 "base 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절의 패턴 +(base는 타입만 갖고, quad-roblox가 `BaseModule`을 뮤테이션해서 실 구현체를 +채워넣음)과 잘 안 맞음 — 그 패턴이 성립하려면 `canExecute`는 **quad-roblox가 +한 번만 주입하는 공유 함수**여야 하고, 그러려면 "어떤 등록을 확인할지"를 +가리키는 인자(`handle`, 아래 gchold 스케치의 Connection이 이 역할)가 있어야 +함. base 입장에선 `handle`은 `any`(엔진마다 실체가 다를 수 있음). + +**quad-roblox 구현 스케치(rbvm 패턴 재사용, base 결정 아님 — 참고용)**: +Instance당(꼭 하나일 필요는 없지만 보통 그게 싸서 하나로 감) weak-keyed +per-instance 저장소(`base.perInstanceState(inst)`)에 "gchold" 배열을 둠. +그 배열엔 절대 발화하지 않도록 골라 만든 신호에 연결한 Connection을 +넣는데, 이 Connection의 콜백 클로저 안에 실제로 살려두고 싶은 옵저버를 +업밸류로 캡쳐해둠(콜백은 안 불려도 클로저 자체가 살아있는 한 업밸류는 +안 죽음) — `inst`가 GC되면 gchold 배열째로 같이 죽으므로 옵저버도 자연히 +GC됨(`base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 weak-keyed +중첩 구조와 같은 원리). `canExecute(handle)`은 이 Connection(또는 이를 +감싼 핸들)을 받아 `.Connected`를 확인하는 정도로 구현될 것. +**미확인 세부사항**: 옵저버 → Connection 역참조를 별도 weak 릴레이션으로 +둘지, 아니면 그냥 Observer 테이블 안 평범한 필드로 넣을지(정적 해싱된 +필드 접근이 weak 테이블 조회보다 싸서 후자가 나을 수 있음) — quad-roblox +구현 단계에서 실측 확인 필요, base 설계에 영향 없음. 이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index ae9eb76..03ddfba 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -46,22 +46,46 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 규칙으로 쪼개야 함. -### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — `None` 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정) +### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기" — `None` 센티널로 확정 (2026-08-07 여덟 번째 세션) -**문제**: `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 +**문제**: `{ TextColor3 = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키 자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서 위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 -존재한다는 사실 자체)가 사라지고, `mod`가 주는 `Override` 값이 그대로 -새어나옴. +존재한다는 사실 자체)가 사라지고, `mod`가 주는 값이 그대로 새어나옴. -**후보(미확정)**: 이벤트 store-bind에서 이미 쓴 "`nil` 대신 실재하는 -센티널 값" 패턴(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 -store-bind 가능" 절) 재사용 — `None`(가칭) 프리미티브를 만들어 -`{ Override = None, mod }`로 쓰면 flatten 로직이 `None`을 만난 인라인 키를 -"명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입, -flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음 -세션에서 이어감 — 지금은 문제와 방향성만 기록. +**결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는 +전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널" +(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 store-bind +가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 +**디스패치 단계**에서 풀린다: + +- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.** + Modifier setter/Override/인라인 props 테이블은 `None`을 그냥 평범한 raw + 값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이 + 테이블에 존재하는 값으로 표현이 안 된다"는 것뿐이라, 표현 가능한 실재 + 센티널만 있으면 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 구현 비용이 사실상 0이라 인라인 키/setter 둘 다 여는 데 주저할 이유가 + 없음(2026-08-07 여덟 번째 세션 확정) — setter로 받으면 "특정 필드만 지우는 + 재사용 가능한 modifier 조각"(9-1번의 스타일 프리셋 opt-out 시나리오)도 + 공짜로 됨. +- **`:Peek<>(key)`의 반환 타입이 `T | State | None | nil`로 확장됨** — + `Peek`은 raw 저장값을 그대로 읽으므로(9번 절 "현재 저장된 그대로 넘김" + 원칙) `None`을 다른 값처럼 있는 그대로 돌려줌. "필드가 아예 안 채워짐" + (`nil`)과 "명시적으로 지워짐"(`None`)은 raw 계층에서 계속 구별됨. +- **실제 "지우기" 동작은 디스패치 쪽 `NoneHandler`가 담당** — merge가 끝난 + 뒤 최종 flatten 결과에 `None`이 남아있으면, base 드라이버가 그 키를 어떻게 + 처리하는지는 새 개념이 아니라 이미 확정된 디스패치 모델 그대로다. + 상세는 `base/bind-system-plan.md`의 "`None` 센티널 — Tween store-bind와 + 같은 재귀 재디스패치 패턴 재사용" 절 참고 — 핵심만 요약하면 `NoneHandler`도 + Tween의 store-bind 핸들러와 완전히 같은 모양(`isHandlable`이 `v == None`을 + 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 `process(inst, k, nil)`을 재귀 + 호출)이라 base 드라이버 자체엔 `None`을 아는 코드가 한 줄도 안 들어감 — + 개별 프로퍼티/이벤트/UI shorthand 핸들러도 자기 시그니처에 `None`이 안 + 나옴(원래도 있어야 했던 "`v`가 `nil`일 때" 처리를 재사용할 뿐). 구체 예시는 + `base/ui-shorthand-plan.md`의 UICorner 숏핸드 절(nil 받으면 만들어둔 자식 + 제거, 단 `retract`가 아니라 `process` 쪽 로직). ### 3. Immutable 값 + clone 기반 체이닝 @@ -445,9 +469,12 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방 **`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**. `Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면 판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가 -Source도 같이 잡아줌 — 별도 `isSource` 불필요). 상세 근거/구현 방식은 -`bind-system-plan.md`의 `isState` 절 참고 — 요지만: duck-typing 대신 -weak-key 레지스트리 기반, 그리고 이 판별 로직 자체는 새로 만드는 게 +Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도 +별도로 존재함**, `:Set`/`:Emit` 같은 Source 전용 능력이 있는지 알아야 +하는 코드를 위해 필요하다고 판단 정정됨). 상세 근거/구현 방식은 +`bind-system-plan.md`의 `Brand`/`isState` 절 참고 — 요지만: duck-typing +대신 weak-key 레지스트리 기반(quad의 다른 branded 타입 전부와 공유하는 +통합 메커니즘으로 일반화됨), 그리고 이 판별 로직 자체는 새로 만드는 게 아니라 4-1번 setter 분기가 이미 내부적으로 해야 하는 걸 public 유틸로 승격하는 것뿐. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 4ed563e..7a40c72 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -45,9 +45,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 **사용자 확인 완료**: base가 `LifetimeHandle` 추상화(생명주기/`Connected` 계산 속성)를 소유하는 게 맞다고 확정. 추가로 명확해진 것 — **store 바인드가 -수행하는 "처리된 값을 다시 `process(inst,k,realv)`로 넘기는" 재실행 로직 -자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 재구현하면 안 -됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → `base/bind-system-plan.md`의 "확정된 디스패치 모델" 절이 바로 이 base 제공 로직. +수행하는 "처리된 값을 다시 `Dispatch.process(inst,k,realv)`로 넘기는" 재실행 +로직 자체도 base가 한 번만 구현**해야 함(모든 백엔드/핸들러가 각자 +재구현하면 안 됨). 근거: "모든 곳에서 다시 구현하는 건 나쁘니까." → +`base/bind-system-plan.md`의 "확정된 디스패치 모델"/`Dispatch` 네이밍 절이 +바로 이 base 제공 로직. 부수적으로 확인된 것: - **Store 자체의 연산은 더 단순해져도 됨** — v1의 `:Add`/`:With`/`:Tween` 같은 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md new file mode 100644 index 0000000..e116ce9 --- /dev/null +++ b/.claude/base/tag-plan.md @@ -0,0 +1,46 @@ +# Tag 특수 키 — `CollectionService` 얇은 래퍼 + +**상태**: base — `[Tag "Name"] = true` DI 키의 존재 자체는 `architecture.md` +4번 항목에서 이미 확정. 이 문서는 UICorner 숏핸드(`base/ui-shorthand-plan.md`)/ +Tween(`research/tween-plan.md`)처럼 별도 전용 문서가 없던 걸 2026-08-07 +여덟 번째 세션에 메꾼 것 — Tag/Attribute도 "1 프리미티브 1 파일" 관례 +(Blocker/Effect/Ref/PreRef 분리 선례)를 따라야 한다는 사용자 지적으로 신설. +새 설계 내용은 없음 — 이미 여기저기 흩어져 있던 결정을 한 곳에 모으고, +오늘 논의한 `None`/`process`/`retract` 동작을 반영. + +## 값 모양 + +`[Tag "Name"] = boolean | State` — store-bind 가능(일반 프로퍼티와 +동일하게 취급). PA님의 `EventDrivenProgramming/Observer.luau` +`subscribeTaggedInstance`도 얇은 `CollectionService` 래퍼일 뿐이라 +(`bind-system-plan.md` "PA님 코드와 대조" 절) **Instance 태그는 +`CollectionService` 직접 사용 그대로 유지** — 별도 자체 태그 시스템(v1이 +검토했던 것 같은) 안 만듦. + +## 메커니즘 — 새 아키텍처 개념 불필요 + +`isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 `TagHandler` 하나로 +충분: + +- `process(inst, k, v)` — `v`가 참이면(`true`) `CollectionService:AddTag(inst, + name)`, 거짓/`nil`이면 `RemoveTag(inst, name)`. `None → nil` 재디스패치 + (`base/bind-system-plan.md`의 `None` 센티널 절)가 그대로 이 경로를 탐 — + `nil`을 "태그 없음"으로 자연스럽게 해석하면 되므로 특별 처리 불필요. +- **`retract` 불필요** — 값이 `true`/`false`/`nil` 무엇이든 항상 같은 + `TagHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜), 추가/제거를 + 전부 `process` 자신이 처리. `retract`는 "매치되는 핸들러 타입 자체가 + 바뀌는" 경우에만 의미 있다는 게 확정된 원칙(`bind-system-plan.md` + "확정된 디스패치 모델" 절, Tween↔일반 프로퍼티가 그 유일한 실사례) — + Tag는 여기 해당 안 됨. **처음엔 이 문서 없이 "확정된 디스패치 모델" + 절이 Tag를 retract 필요 예시로 잘못 들었던 걸 여기서 바로잡음.** + +## 패키지 배치 + +UICorner 숏핸드/Tween과 같은 판단 재사용 — 작고 항상 켜져 있어도 비용이 +무시할 만한 기능은 `quad-roblox` 코어에 직접 포함(`base/ui-shorthand-plan.md` +"패키지 배치" 절 참고, 별도 opt-out 패키지로 안 쪼갬). + +## 열린 질문 + +없음 — 값 모양/메커니즘/retract 여부 전부 확정. 이름 자체(`Tag`)는 이미 +쓰기 시작한 v1/PA님 관례와 일치해 특별히 재검토 대상 아님. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 390821b..b394394 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -69,6 +69,26 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 피하기 위함. +### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, `retract`는 관여 안 함 (2026-08-07 여덟 번째 세션) + +`modifier-plan.md`의 `None` 센티널(`base/bind-system-plan.md`의 +`NoneHandler` 재귀 재디스패치 절 참고)이 최종적으로 이 Handler의 +`process(inst, k, nil)`을 호출하는 구체 사례 — 이 Handler에서 "`v`가 +`nil`"은 만들어둔 `_quad_corner`류 자식이 있으면 그냥 지우는 것으로 확정. +일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는 +쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨. + +- **이건 `retract`가 아니라 `process` 자신의 로직** — `retract`는 "이 + 키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된 + 디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을 + 정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) + 바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 + 구현할 게 없음. +- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를 + 자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운 + API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에 + 직접 물리는 건 문서화 시점에 캐비엇으로 명시할 것(지금은 메모만). + ## store-bind — 이 숏핸드도 지원, Tween만큼 무겁게 안 가도 됨 v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했음 diff --git a/.claude/question.md b/.claude/question.md index fd416d9..267a593 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -91,6 +91,18 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** Effect 핸들이 leaf 부착과 `:Subscribe()` 중 이미 어느 한쪽으로 바인딩됐는지 표시하는 내부 플래그 이름(`base/bind-system-plan.md` "이중 바인딩 금지" 절) — 동작은 확정, 이름만 가칭. +- **`None`/`NoneHandler`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: + 인라인 키/Modifier setter로 필드를 명시적으로 지우는 센티널과, 그걸 + `nil`로 바꿔 재디스패치하는 base 내장 핸들러 이름 — + `modifier-plan.md` "2-1"절/`bind-system-plan.md`의 `None` 센티널 + 절에서 동작은 확정, 이름만 다른 가칭들과 같이 재검토 대상. +- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임 + nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를 + 10종 branded 타입 전부로 일반화) — `bind-system-plan.md`의 `Brand` + 절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을 + 전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는 + 이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로 + "type namespace"류를 사용자가 검토했으나 미확정. - **"프로바이더"(3순위, 사소함)**: `base/module-lifecycle-plan.md`가 "provider"라고 불러온, `isHandlable`로 참여 여부를 결정하고 우선순위대로 스캔되는 pluggable 참가자 개념 — 정확한 이름을 "provider"/"processor"/ @@ -126,6 +138,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. + **(2026-08-07 여덟 번째 세션 보강)** 시그니처는 `(handle) -> boolean`으로 + 확정(zero-arg 클로저 아님, `base/lifecycle-pattern.md` 참고)됐고 + rbvm식 gchold 스케치(weak per-instance 배열에 절대 안 발화하는 + Connection을 넣어 그 클로저 업밸류로 Observer를 살려두는 방식)도 + 후보로 적어뒀지만, 여전히 스케치 단계 — Observer→Connection 역참조를 + weak 릴레이션으로 둘지 평범한 필드로 둘지 포함, 실측은 그대로 필요. - **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: `LifetimeHandle`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md` @@ -137,15 +155,6 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** ### 3. 낮은 우선순위 -- **`None`(가칭) 센티널 프리미티브 — 미확정, 2026-08-07 세 번째 세션 - 신설.** `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 - 명시적으로 지우고 싶어도 Lua 테이블 리터럴의 `키 = nil`은 키가 아예 - 없는 것과 구별이 안 돼서 "인라인이 modifier보다 무조건 우선"이라는 - 기존 merge 규칙(`modifier-plan.md` 2번)이 이 케이스에선 실제로 작동을 - 안 함. `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 실재하는 값 - `None`을 도입하는 방향만 나왔고 상세(타입, flatten 내부 표현, State - 필드에도 같은 문제가 적용되는지)는 미정 — `modifier-plan.md` "2-1"절 - 참고, M7(Modifier) 착수 전 확인. - `research/existing-instance-bind-plan.md` — 스코프 논의만 필요, 구현 착수를 막지 않음. - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — diff --git a/CLAUDE.md b/CLAUDE.md index 2ea1303..7224758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -991,3 +991,174 @@ setter를 단발로 직접 호출하는 흔한 경로는 여전히 mutable이라 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 여덟 번째 세션 — `None` 센티널 확정(인라인 필드 지우기), `NoneHandler`가 Tween store-bind와 같은 재귀 재디스패치임을 확인 + +세 번째 세션에서 "미확정"으로 메모만 남겨뒀던 `None` 센티널 +(`{ Override = nil, mod }`처럼 인라인 키로 modifier 값을 명시적으로 +지우고 싶어도 Lua 테이블의 `키 = nil`이 "키 없음"과 구별 안 되는 문제)을 +사용자가 "이거 결정할 게 진짜 있냐"고 다시 제기해 라이브로 짧게 논의, +확정까지 감. 전부 `base/modifier-plan.md`(2-1번)/`base/bind-system-plan.md` +(신규 절)/`base/ui-shorthand-plan.md`(신규 절)에 반영 완료: + +- **merge/setter 쪽은 아무것도 안 바뀜** — `None`은 raw 저장 계층의 그냥 + 평범한 실재값이라, 기존 merge 규칙("인라인 키 존재 시 무조건 우선", + `Override`의 "뒤 인자가 필드 단위로 이김")이 손댈 것 없이 그대로 작동함. + 처음엔 "merge 시점에 키를 지운다"는 새 분기가 필요하다고 잘못 생각했다가, + "값을 표현만 할 수 있으면 기존 규칙이 이미 다 해줌"이라는 걸로 정정. + 인라인 props 테이블 키(`{ TextColor3 = None, mod }`)와 Modifier setter + 인자(`mod:TextColor3(None)`) 둘 다 지원 — 메커니즘이 완전히 같아 구현 + 비용 거의 0("어차피 무료로 얻어지는거 아님?" — 사용자), 후자 덕에 + "특정 필드만 지우는 재사용 가능한 modifier 조각" 패턴도 공짜로 됨. + `:Peek()` 반환 타입도 `T | State | None | nil`로 확장(raw 계층에서 + `None`을 있는 그대로 돌려줌 — Peek은 확정 안 하고 그대로 넘긴다는 기존 + 9번 절 원칙 그대로). +- **실제 "지우기"는 디스패치 단계에서, 새 메커니즘 없이 풀림 — 핵심 + 발견.** 처음엔 "우선순위 최상단에서 값을 그냥 nop 처리"로 생각했다가, + 사용자가 "그럼 process에 특수 로직이 들어간다"고 지적하며 더 나은 안을 + 직접 제시: `NoneHandler`라는 평범한 pluggable 핸들러 하나를 추가 — + `isHandlable`이 `v == None`을 잡고, `process`가 `v`를 진짜 `nil`로 바꿔 + **`process(inst, k, nil)`을 재귀 호출**. 이게 바로 이미 확정돼 있던 Tween + store-bind 핸들러(`bind-system-plan.md` "확정된 디스패치 모델" 절, + `v`가 Store면 `realv`를 계산해 `process(inst,k,realv)`로 재귀)와 + **완전히 같은 패턴**이라는 걸 확인 — 새 아키텍처 개념이 하나도 안 늘어남. + base 드라이버 자체(`process(inst,k,v) -> getHandler(inst,k,v).process(...)`)는 + `None`을 전혀 모르는 순수 제네릭 그대로 유지, 개별 프로퍼티/이벤트/UI + shorthand 핸들러 시그니처도 `None`이 안 나옴(원래 있어야 했던 "`v`가 + `nil`인 경우" 처리를 재사용할 뿐). +- **`None`의 의미는 "리셋"이 아니라 "이 조합 단계에서 이 필드를 세팅 + 안 함"** — 실제로 `v=nil`을 받은 핸들러가 뭘 할지는 핸들러마다 다름(일반 + 프로퍼티는 사실상 그대로 두는 것과 다름없고, UICorner 숏핸드처럼 실제 + Instance를 만들어 붙이는 핸들러는 그 자식을 지움). 구체 사례로 + `ui-shorthand-plan.md`에 UICorner 절 신설 — `process(inst,k,nil)`이 + 만들어둔 `_quad_corner`류 자식을 직접 지움(이건 `retract`가 아니라 + `process` 자신의 로직 — `retract`는 "다른 핸들러가 키를 넘겨받는" 별개 + 시나리오 전용, 이미 확정돼 있던 원칙 재확인), 값이 자주 `nil`↔숫자로 + 토글되면 생성/제거 비용이 매번 든다는 캐비엇도 명시. +- **M2(디스패치 엔진) 착수 시 확인할 것 하나 새로 생김** — "이 키를 지금 + 누가 담당 중인가" bookkeeping이 바깥 순회 루프가 아니라 `process` 호출 + 자체 내부에서 갱신돼야 함. 안 그러면 값이 계속 `None`으로 유지되는 매 + 사이클마다 "1차 매치는 `NoneHandler`, 재귀 호출 뒤 실제 담당은 다른 + 핸들러"로 바깥 루프가 오판해 불필요한 `retract`가 반복 호출될 위험 — + `ROADMAP.md` M2에 반영, `pre-implementation-audit.md`의 "이전 매치 + 핸들러 추적" 항목과 같은 부류라 새 우선순위 등급 없이 거기 흡수. + +**부수 정리**: `question.md`에서 "미확정"이던 `None` 항목을 해소로 +제거하고, 이름 자체(`None`/`NoneHandler`)만 다른 가칭들과 같이 용어 +정리 대상(3순위)으로 새로 추가. `ROADMAP.md` M7 체크박스를 "확정 완료"로 +갱신. + +**같은 세션 후속 — `Dispatch` 함수 네이밍 정리, `canExecute` 시그니처 정정, +Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 엔진 자체의 +용어가 여러 군데서 흔들리고 있다는 게 드러나 바로 이어서 정리함. 전부 +`base/bind-system-plan.md`/`base/lifecycle-pattern.md`/`base/tag-plan.md` +(신규)/`base/attribute-plan.md`(신규)/`ROADMAP.md`에 반영 완료: + +- **제 실수 정정 — `canExecute`와 `isHandlable`은 다른 개념.** + `isHandlable(k,v)`는 KV 매치 predicate(핸들러 계약 4종 중 하나), + `canExecute`는 특정 바인딩 하나가 "지금 살아있어 실행돼도 되는가"만 + 보는 별개의 라이프타임 게이트(`lifecycle-pattern.md`) — `NoneHandler`가 + 구현해야 하는 건 `isHandlable`이지 `canExecute`가 아님, 앞서 잘못 쓴 + 문장을 고침. +- **`Dispatch.getHandler`/`Dispatch.process`/`Dispatch.addHandler`/ + `Dispatch.drive`로 이름 공식화.** 원래 "확정된 디스패치 모델" 절은 + "스캔+실행"과 "매치된 핸들러 자신의 처리"를 둘 다 그냥 `process`라고 + 불러 이름이 겹쳤던 게 혼동의 원인이었음 — `Dispatch.getHandler(inst,k,v): + Handler?`(순수 스캔)와 `Dispatch.process(inst,k,v)`(오케스트레이터: + getHandler → 이전 담당자 다르면 그 `retract` → 새 핸들러의 `.process`)로 + 분리, Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, + 재검토 대상 아님) — 겹침은 소유자 표기(`Dispatch.process` vs + `handler.process`)로 해소, 새 이름 발명 안 함. **`Dispatch.addHandler(handler)`** + 도 신설 — concrete Handler를 우선순위 레지스트리에 등록하는 것도 + 결국 quad-roblox가 `BaseModule`을 뮤테이션하는 시점에 해줘야 하는 + 일이라(기존 "base 유틸은 인터페이스, 백엔드가 주입" 패턴과 같은 모양). + **배열→해시 두 패스 순회 드라이버 자신은 `Dispatch.drive(inst, + flattened)`로 확정** — 문서가 이미 이걸 비공식적으로 "base 디스패치 + 드라이버"라 불러왔던 걸 그대로 동사화(`apply`는 기각 — "Dispatch를 + 뮤테이션해서 결과를 낸다"는 어감이라 안 맞는다는 사용자 판단). +- **`canExecute` 시그니처 정정: `(handle) -> boolean`, zero-arg 아님.** + `lifecycle-pattern.md`가 원래 `canExecute: () -> boolean`(바인딩마다 + 클로즈오버된 람다)으로 적어뒀던 걸 정정 — 그러면 등록마다 클로저를 + 새로 만들어야 해서 "base는 인터페이스만, quad-roblox가 `BaseModule` + 뮤테이션으로 실 구현 주입"이라는 이미 확정된 패턴과 안 맞음. 공유 + 함수 하나가 되려면 "어떤 등록을 볼지" 가리키는 인자가 필요 — + `canExecute(handle: LifetimeHandle): boolean`으로 확정. **quad-roblox + 구현 스케치(참고용, base 결정 아님)**: rbvm 패턴 재사용 — Instance당 + weak-keyed per-instance 저장소에 "gchold" 배열을 두고, 절대 발화 안 + 하는 신호에 연결한 Connection의 콜백 클로저 안에 살려두고 싶은 + Observer를 업밸류로 캡쳐(콜백은 안 불려도 클로저의 업밸류는 안 죽음, + `inst`가 GC되면 gchold 배열째로 같이 죽음). `canExecute(handle)`은 이 + Connection(류)의 `.Connected`를 확인. **미확인 세부사항으로 남긴 것**: + Observer→Connection 역참조를 별도 weak 릴레이션으로 둘지 그냥 Observer + 테이블 안 평범한 필드로 넣을지(정적 해싱 필드 접근이 더 쌀 수 있음) — + quad-roblox 구현 단계에서 실측 필요. +- **`Tag`/`Attribute`도 UICorner/Tween처럼 전용 문서가 있어야 한다는 + 지적 — 맞아서 `base/tag-plan.md`/`base/attribute-plan.md` 신설.** + 둘 다 이미 `architecture.md`/`ROADMAP.md` M10에 파일로는 계획돼 + 있었지만 "1 프리미티브 1 파일" 관례(Blocker/Effect/Ref/PreRef 분리 + 선례)를 따르는 전용 설계 문서가 없었음 — 흩어져 있던 내용(Attribute의 + 타입 파라미터화 논의 등)을 모으고, 오늘 확정된 `None`/`process`/ + `retract` 동작을 반영. **핵심 발견**: Tag/Attribute 둘 다 UICorner + 숏핸드와 같은 패턴(값이 뭐든 항상 같은 핸들러가 계속 담당, 추가/제거를 + `process` 자신이 처리)이라 **retract가 필요 없음** — "확정된 디스패치 + 모델" 절이 원래 Tag/Attribute를 retract 필요 예시로 들었던 게 잘못이었음, + 바로잡고 "retract가 의미 있는 유일한 패턴은 매치되는 핸들러 *타입* + 자체가 사이클마다 바뀌는 경우(Tween↔일반 프로퍼티가 실사례)"로 좁힘. + Attribute는 특히 깔끔한 사례 — Roblox `SetAttribute(name, nil)` 자체가 + 네이티브하게 "지움"이라 `None→nil` 재디스패치가 특별 처리 없이 그대로 + 맞아떨어짐. +- `.claude/README.md`에 두 신규 문서 반영, `ROADMAP.md` M2/M10 체크박스 + 갱신(`Dispatch` 4개 함수, `canExecute` 시그니처, Tag/Attribute 문서 + 참조). + +**같은 세션 세 번째 후속 — `canExecute` 옵션 하나 더 검토 후 확정 유지, +`Brand` 통합 판별 메커니즘 신설(`isState`를 10종으로 일반화), `isHandlable`도 +`inst`를 받도록 정정.** 전부 `base/bind-system-plan.md`(`Brand` 절, 핸들러 +계약 절)/`base/modifier-plan.md`/`ROADMAP.md`/`question.md`에 반영 완료: + +- **`canExecute`를 "각 핸들 타입이 직접 구현"(`Observer.canExecute`)할지 + "공유 함수"(`canExecute(any)->boolean`)로 할지 재확인 — 공유 함수 유지, + 솔직한 이유까지 명시.** `Observer` 자체는 quad-base 레벨(엔진 무관) + 타입인데 liveness 체크(Connection 기반)는 본질적으로 엔진 종속적이라, + `Observer.canExecute`가 직접 구현하면 base/roblox 분리 원칙이 깨지거나 + 결국 내부적으로 공유 함수를 다시 호출하는 얇은 래퍼가 될 뿐 — 어느 + 쪽이든 공유 함수 쪽이 낫다는 결론 재확인(추가 논의 없이 유지). +- **`Brand` 신설 — `isState`(다섯 번째 세션)를 quad의 다른 branded 타입 + 전부(`Observer`/`Effect`/`Tag`/`Attribute`/`Tween`/`Blocker`/`Store`/ + `Source`/`Slot`)로 일반화.** 공유 weak-key 레지스트리 하나(`Brand.set`/ + `Brand.get`) + **문자열이 아니라 테이블 아이덴티티를 태그로 사용** + (사용자 제안 — Luau 인터닝 문자열도 이미 O(1) 포인터 비교라 성능 차는 + 없지만, 오타 안전성이 실질 이득: 잘못된 변수 참조는 즉시 드러나지만 + 오타난 문자열 리터럴은 조용히 어긋남). `isX`는 `Brand`를 감싼 얇은 + wrapper — 단순 항등(`isObserver`)과 집합 멤버십이 필요한 경우(`isState` + = `{State,Source}`)로 갈림. **`None`만 예외 — 싱글턴이라 레지스트리 + 없이 `x == None` 항등 비교가 더 싸고 정확**, 대신 `Brand.get`이 범용 + introspection 창구(quad-debug 용도) 역할까지 겸하도록 `None`을 특수 + 분기로 앞단에서 걸러줌 — `isNone`이 그 분기의 실제 구현체. +- **정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을 + 뒤집음.** 그땐 "State면 충분한 용도"만 봤지만 `Source`는 State보다 + 진짜 더 많은 능력(`:Set`/`:Emit`)을 가진 서브타입이라 "쓰기도 되는 + 원천인가"를 알아야 하는 코드엔 `isState`만으론 부족 — `isSource` 별도 + 제공, `isState`는 여전히 `{State,Source}` 둘 다 통과. `component- + composition-plan.md` 4번 절이 애초에 `isSource`가 존재한다고 가정해둔 + 것과도 이걸로 정합됨(그동안 두 문서가 서로 모순돼 있었음, 이번에 발견). +- **Luau 타입 narrowing은 자동으로 안 됨 — 사용자가 직접 확인, 명시적 + `::` 캐스팅 필요.** `isX(v)`가 참이어도 Luau가 TypeScript의 `x is T` + 같은 사용자 정의 타입 가드를 지원 안 해서 `v`의 정적 타입을 자동으로 + 안 좁혀줌 — `if isState(v) then local s = v :: State ... end`처럼 + 런타임 검증 뒤 명시적 캐스팅이 실제 패턴. 여전히 duck-typing보다 훨씬 + 안전하니 가치는 있지만 자동 narrowing을 기대하면 안 됨. +- **`isHandlable`도 `inst`를 받도록 확정 — `(inst,key,value): boolean`, + 원래 `(key,value)`였던 걸 정정.** `process`/`retract`는 처음부터 + `inst`를 항상 받았는데(핸들러 계약 원 원칙) `isHandlable`만 예외였던 + 게 애초에 약간의 불일치 — 지금 당장 `inst`로 매치가 갈리는 케이스는 + 없지만, 나중에 필요해지면 핸들러 계약 자체를 깨는 breaking change가 + 되므로 지금 넣어두는 게 훨씬 쌈. `Dispatch.getHandler`가 스캔 중 + `handler.isHandlable(inst,k,v)`로 호출하도록 갱신. +- `ROADMAP.md` M2에 `Brand.luau` 체크박스 신설, `Handler.luau`/M7의 + `isState` 항목 갱신. `question.md`에 `Brand` 이름(용어 정리 대상, + "OOP 클래스명을 얻는 느낌"에 맞는 더 나은 이름 필요 — `Tag`는 이미 + quad-roblox에서 다른 뜻으로 쓰여서 충돌) 반영. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 216543f..5921179 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,14 +50,38 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M2 — 디스패치 엔진 -- [ ] `Dispatch/init.luau`(`process`/`retract` 엔진, `isHandlable` 우선순위 스캔) -- [ ] `Handler.luau`(핸들러 계약 타입) +- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수 + 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v)`(오케 + 스트레이터: getHandler → 이전 담당자와 다르면 그 `retract` → 새 + 핸들러의 `.process`) / `Dispatch.addHandler(handler)`(레지스트리 + 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, + flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 `process` 호출 — + `bind-system-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에 + 네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은 + `Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서 + 갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀 + 재디스패치를 쓰는 케이스(Tween store-bind, `NoneHandler`)에서 매 + 사이클 불필요한 `retract`가 반복 호출될 위험) +- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ + `process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07 + 여덟 번째 세션 정정) +- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ + `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ + `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot` + 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 + 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 + 세션 신설) - [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` 2번 - — 2026-08-07 세 번째 세션에 반영) + — 2026-08-07 세 번째 세션에 반영). **`canExecute`는 `(handle: + LifetimeHandle) -> boolean`으로 확정**(바인딩마다 클로저 만드는 + zero-arg가 아니라, quad-roblox가 한 번만 주입하는 공유 함수 — "base + 유틸은 인터페이스, 백엔드가 주입" 패턴과 맞춰야 해서. + `base/lifecycle-pattern.md`의 gchold 스케치 절, 2026-08-07 여덟 번째 + 세션 정정) - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -119,12 +143,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키 `Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인) - [ ] `:Peek<>(key): T|State|nil` 필드 읽기 접근자 + - `isState(x): boolean`(weak-key 레지스트리 기반, quad-base 공용 - 유틸 — `modifier-plan.md` 9번, `bind-system-plan.md`의 `isState` 절) -- [ ] 인라인 키로 modifier 필드를 명시적으로 지우는 문제 확인 — `None` - (가칭) 센티널 프리미티브 도입 여부(`modifier-plan.md` 2-1번, 아직 - 미정 — 착수 전 사용자 확인 필요, 확정 안 되면 이번 마일스톤은 - 스킵하고 다음으로 미뤄도 됨) + `isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 — + `modifier-plan.md` 9번, `bind-system-plan.md`의 `Brand` 절, M2의 + `Brand.luau`에 이미 구현돼 있어야 함) +- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None`(가칭) + 센티널(`modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) + + 이를 `nil`로 재디스패치하는 base 내장 `NoneHandler` + (`bind-system-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의 + "이전 매치 핸들러 추적" 항목과 함께 구현 — Tween store-bind 핸들러와 + 동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — 확정 완료 ## M8 — Ref @@ -153,8 +180,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M10 — Event / Attribute / Tag - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) -- [ ] `Handlers/Attribute.luau` -- [ ] `Handlers/Tag.luau`(`CollectionService`) +- [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ + `retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) +- [ ] `Handlers/Tag.luau`(`CollectionService`, `base/tag-plan.md` — 전부 확정) ## M11 — Tween