diff --git a/.claude/README.md b/.claude/README.md index 30dd6d5..0e6da8e 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -39,6 +39,7 @@ | `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`류)만 미확정 | +| `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 | ## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설) diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index a59f63c..c7f0ef3 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -133,8 +133,8 @@ quad/ │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) -│ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) -│ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" +│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 +│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `bind-system-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ └── init.luau @@ -142,8 +142,7 @@ quad/ ├── wally.toml └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) - ├── LifetimeHandle.luau # 실제 구현(Instance 생존 확인) - ├── PerInstanceState.luau # 실제 구현(weak-keyed table, Instance 키) + ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau │ ├── Event.luau # ReflectionService 기반 자동 판별 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1282285..d1cae34 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -45,6 +45,14 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 - `retract(inst, key, value)` — 이전 처리를 무르는/멈추는 함수(아래 절, `base/lifecycle-pattern.md` 참고). 모든 핸들러가 의미 있게 구현할 필요는 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). + **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 + 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 + 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 + 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: Tween↔ + 프로퍼티 교체) 실제로 담당이 바뀌는 순간 `attempt to call a nil value`로 + 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 + 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 + 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 @@ -114,14 +122,18 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 - 멈추거나 끝냄). **base가 이걸 범용 유틸로 제공**(`base.perInstanceState(inst)` - 류, 정확한 이름/모양은 구현 단계에서 확정) — 모든 핸들러가 재사용, 각자 - WeakMap을 새로 만들지 않음. `base/lifecycle-pattern.md`의 "생명 바인드 유틸"과 - 짝을 이루는 유틸. **왜 GC-안전한가(2026-08-07 여섯 번째 세션, 명시화)**: - 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 릴레이션이 - 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 사라지는 - 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — 별도 - cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 + 멈추거나 끝냄). **[정정, 2026-08-08 세션] `base.perInstanceState(inst)`라는 + 이름/모양은 폐기 — `base/relate-plan.md`의 `Relate` 프리미티브로 구체화됨.** + 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 하나 두고 + `relate:SetStrong(inst, k, tween)`/`relate:GetStrong(inst, k)`로 저장/조회 — + "모든 핸들러가 WeakMap을 재발명하지 않고 공유 유틸을 쓴다"는 원래 취지는 + 그대로, `Relate`가 그 공유 유틸의 정식 인터페이스. `base/lifecycle-pattern.md`의 + `bindLifetime`/`canExecute`도 같은 `Relate`를 내부적으로 씀(용도가 다르니 + 별도 `Relate()` 인스턴스). **왜 GC-안전한가(2026-08-07 여섯 번째 세션, + 명시화)**: 구조가 "`inst`로 weak-keyed된 바깥 릴레이션 안에 `k`별 안쪽 + 릴레이션이 중첩된" 모양이라, `inst`가 죽어 weak table 엔트리가 통째로 + 사라지는 순간 그 안에 중첩된 `k`별 Tween 인스턴스 릴레이션도 같이 GC됨 — + 별도 cleanup 로직 불필요(Tween 핸들러가 여기 담아두는 실제 Tween 인스턴스도 자동으로 같이 죽는 것까지 포함, `research/tween-plan.md` 참고). - **다른 값 변경을 추적하는 것도 process 함수의 정상 범위**: 예를 들어 Slot 핸들러는 자기가 감시하는 값(배열/스토어)이 바뀌면 그에 따라 child를 @@ -263,6 +275,42 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구 상관없이 동일한 재귀적 디스패치로 처리 가능 — 아래 "store가 store를 저장 가능한가"와 직결. +**"값이 바뀔 때마다"의 실제 구독 메커니즘 = `state:Observer(fn)` 재사용으로 +확정(2026-08-08 세션).** 이전엔 이 절이 구독 메커니즘 자체를 추상적으로만 +서술했는데(새 프리미티브를 발명하는 것처럼 읽힐 수 있었음), 실제로는 아래 +"`state:Observer(fn)`" 절에서 이미 확정된 것을 그대로 재사용하면 됨 — 새 +구독 primitive를 store-bind 전용으로 따로 만들 이유가 없음: + +```lua +-- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) +local observer = state:Observer(function() + Dispatch.process(inst, k, state:Get()) +end) +observer:Subscribe() +relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 +``` + +- children-array leaf 부착(`Frame { observer }`)이 **아니라** `:Subscribe()`/ + `:Unsubscribe()` 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 + 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer + 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 + `:Subscribe()`가 유일한 선택). +- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐** — 이게 위 "이벤트도 + store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 + 이유(새 디스패치 메커니즘 없이 기존 4종 계약만 구현), 다만 그 절이 + 구체적으로 가리키는 재사용 대상은 "재귀 process+retract 래핑 패턴"이었고 + Observer 자체를 구독 메커니즘으로 쓴다는 것까지는 명시가 안 돼 있었던 + 갭이 이번에 메워짐. +- **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 + 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 + `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 + 특별 취급됨). +- Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 + 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 + 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). +- `relate`는 `base/relate-plan.md`의 `Relate` 인스턴스 — 이 핸들러 모듈 + 톱레벨에 `local relate = Relate()`로 하나 두고 재사용. + Slot이 store 바인드로 넘어오는 경우, pluggable 처리기에 `retract` 핸들러가 필요하다는 점(부모가 slot을 정리하고 다시 process하는 방식)도 이 래핑 방식과 자연스럽게 맞음 — `base/slot-plan.md` 참고. diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 776a987..f4ab998 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -130,35 +130,90 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 도구로 바인드된 옵저버는 `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`(엔진마다 실체가 다를 수 있음). +### `bindLifetime`/`canExecute` — 확정(2026-08-08 세션) -**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 설계에 영향 없음. +**탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ +`Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ +`canExecute`는 `isState`/`isObserver`처럼 핸들러 작성자가 직접 호출하는 +**1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 감싸면 안 됨 — +`LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 평평한 함수: -이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸과 짝을 -이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지"(weak-keyed per-instance -저장소), 다른 하나는 "언제까지 실행되어도 되는지"(생명 바인드 + canExecute)를 -다룸. 둘 다 base가 제공하는 범용 유틸로 확정. +```lua +bindLifetime(inst: any, value: any): () +canExecute(inst: any, value: any): boolean +``` + +base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가 +`BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute` +관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의 +`Relate` 프리미티브 위에 얹힘(2026-08-08 세션, gchold를 `perInstanceState` +직접 조작 대신 `Relate`로 구현): + +```lua +-- quad-roblox 실 구현 스케치 +local relate = Relate() -- 이 모듈 전용 인스턴스, 다른 핸들러와 key 충돌 없음 +local GCCONN = "__gcconn" +local GCHOLD = "__gchold" + +function bindLifetime(inst, value) + local gcconn = relate:GetStrong(inst, GCCONN) + if not gcconn then + -- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음 + -- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠 + local gchold = {} + relate:SetStrong(inst, GCHOLD, gchold) + gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() + local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 + end) + relate:SetStrong(inst, GCCONN, gcconn) + end + local gchold = relate:GetStrong(inst, GCHOLD) + table.insert(gchold, value) -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC +end + +function canExecute(inst, value) + -- Observer/Effect는 자기 바인딩 경로(leaf 부착 또는 :Subscribe())의 + -- 생존 여부를 스스로 알고 있음 — inst가 살아있어도 이 값이 먼저 죽어 + -- 있을 수 있으므로(예: retract가 :Unsubscribe()만 하고 inst는 안 죽음) + -- 반드시 먼저 확인. + if (isObserver(value) or isEffect(value)) and not value.Subscribed then + return false + end + local gcconn = relate:GetStrong(inst, GCCONN) + return gcconn ~= nil and gcconn.Connected +end +``` + +**`canExecute`의 시그니처는 `(inst, value) -> boolean`(2026-08-08 세션, +재정정 — 원래 있던 "`(handle) -> boolean`, zero-arg 아님" 결정을 대체함).** +이전 라운드(2026-08-07 여덟 번째 세션)는 "등록마다 클로저를 새로 만들지 +않기 위해 zero-arg 대신 `handle` 인자를 받는다"까지만 확정했는데, 실제로 +`handle`이 뭘 가리키는지(단일 Connection? Observer 자신?)가 미정으로 +남아있었음 — 이번에 `(inst, value)` 2-인자로 구체화됨. 이유: Observer 자신의 +바인딩 생존(`Subscribed`)과 `inst` 자체 생존(gcconn)은 **독립적인 두 조건**이라 +하나의 opaque `handle`로 뭉치면 "inst는 살아있지만 이 Observer는 이미 +`:Unsubscribe()`됨" 케이스를 못 구별함 — 위 구현처럼 `value`의 타입에 따라 +분기해서 먼저 확인하고, 그 다음 `inst` 공유 gcconn을 봄. "canExecute 하나로 +전역 통일" 원칙(Slot 생존/Observer 게이팅/store-bind retract 전부 재사용)은 +안 바뀜, 시그니처만 구체화된 것. + +**Instance당 gcconn/gchold는 하나로 공유**(꼭 그럴 필요는 없지만 보통 그게 +싸서) — `bindLifetime`을 여러 값에 대해 여러 번 불러도 같은 `inst`면 같은 +`gcconn`/`gchold`를 재사용(첫 호출에서만 생성, 이후는 `relate:GetStrong`으로 +바로 찾음). `Relate`의 lazy 생성 자체가 이 재사용 비용을 이미 다뤄줌 — +자세한 내부 구조는 `base/relate-plan.md`. + +**실측 필요(M0/M2)**: Observer→liveness 역참조를 `value.Subscribed` 필드 +직접 읽기로 확정했으나(위 구현), 실제 Luau 필드 접근 비용/weak table 조회 +비용 비교는 여전히 quad-roblox 구현 단계에서 실측 확인 대상. + +이건 `base/bind-system-plan.md`의 "핸들러 내부 상태 저장" 유틸(`Relate` +직접 사용)과 짝을 이루는 별도 유틸 — 하나는 "상태를 어디에 저장할지" +(`Relate:SetStrong`/`:SetWeak`), 다른 하나는 "언제까지 실행되어도 되는지" +(`bindLifetime` + `canExecute`)를 다룸. 후자는 내부적으로 전자가 제공하는 +같은 `Relate` 프리미티브 위에 얹혀 구현됨(위 절) — 별도 저장 메커니즘을 +새로 만든 게 아니라 `Relate` 하나를 두 용도로 재사용. 둘 다 base가 제공하는 +범용 유틸로 확정. **교차검증(2026-08-04 4차 라운드)**: 사용자가 공유해준 실제 참고 코드 (`.claude/initreq/artworks/EventDrivenProgramming/`, PA님 작성)는 GC-native가 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md new file mode 100644 index 0000000..3f83073 --- /dev/null +++ b/.claude/base/relate-plan.md @@ -0,0 +1,97 @@ +# Relate — inst와 임의의 값을 weak하게 엮는 범용 릴레이션 프리미티브 + +**상태**: base — 2026-08-08 세션에서 신설, 확정. `base/bind-system-plan.md`의 +"핸들러 내부 상태 저장"과 `base/lifecycle-pattern.md`의 `bindLifetime`/ +`canExecute` 양쪽이 필요로 했던 "`inst`를 weak 키로 하는 저장소"가 지금까지 +`base.perInstanceState(inst)`라는 이름만 있고 인터페이스가 미정인 placeholder로 +남아있던 것 — 이번에 독립 프리미티브로 정식 승격, `perInstanceState`라는 +이름/모양은 폐기. + +## 왜 필요한가 + +Store-bind 핸들러(Tween 등)가 "이전에 만든 것"(실행 중인 Tween, gchold +Connection, gchold 배열 등)에 `retract`/`bindLifetime` 시점에 다시 접근하려면 +그 값들을 `inst`에 매달아 저장해야 함. `inst`가 죽으면 이 저장물도 자동으로 +같이 죽어야(GC-native, `base/lifecycle-pattern.md` 원칙) 하므로 바깥 키(`inst`)는 +weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강하게 붙잡아야 +하는 것**(실행 중인 Tween 인스턴스, gcconn — 안 붙잡으면 존재 이유가 없어짐)과 +**약하게만 참조해도 되는 것**(캐시성 값)이 둘 다 있음 — 이 둘을 하나의 테이블 +`__mode`로는 표현 못 함(Luau/Lua 테이블의 weak 모드는 테이블 전체 단위). + +## 왜 자동으로 강하게 들지 않는가 — 엔진이 결정할 일 + +**Relate 자신은 `inst`도 `value`도 자동으로 홀드하지 않는다** — 어느 쪽을 +얼마나 강하게 들지는 호출부(주로 `quad-roblox`)가 명시적으로 결정해야 +함(2026-08-08 세션, 사용자 확정). 자동으로 결정해버리면 weak 키가 참조하는 +값이 그 키를 다시 강하게 참조하는 사이클(예: 프로퍼티 핸들러가 만든 클로저가 +`inst`를 업밸류로 캡쳐한 채로 그 클로저 자신이 `inst`에 매달린 strong 저장소에 +들어가는 경우)이 너무 쉽게 생김 — 엔진 객체의 실제 생명주기를 아는 쪽 +(`quad-roblox`)만이 "이 값은 strong으로 둬도 안전하다"를 판단할 수 있음. +그래서 `Relate`는 판단을 안 하고 **`SetWeak`/`SetStrong`으로 호출부가 매번 +명시**하게 만드는 얇은 표면만 제공. + +## API (확정) + +```lua +Relate() -> relate -- 생성자, 싱글톤 아님 + +relate:SetStrong(inst: any, key: any, value: any) -- value를 강하게 보관 +relate:GetStrong(inst: any, key: any): any? + +relate:SetWeak(inst: any, key: any, value: any) -- value를 약하게만 참조 +relate:GetWeak(inst: any, key: any): any? +``` + +- **`inst`(첫 인자)는 항상 weak** — 이 자유도는 아예 안 열어둠. 지금까지 + 나온 어떤 유스케이스도 "`inst` 쪽을 strong으로 두고 싶다"가 없었고, 열어두면 + "`Relate`가 실수로 엔진 객체를 영구히 붙잡는" 사고 가능성만 늘어남. + `Weak`/`Strong`은 오직 **`value`의 보관 방식**을 가리킴. +- **비싱글톤 — 생성 가능한 값(`Ref`/`Store`/`Modifier`와 같은 프리미티브 + 컨벤션)**. 각 핸들러 모듈이 자기 톱레벨에 `local relate = Relate()`를 + 하나씩 두고 재사용 — 서로 다른 `Relate` 인스턴스라 `key` 네이밍이 모듈 + 간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라 + `Relate()`를 여러 개 만드는 비용은 무시할 만함). + +## 실제 구조 (확정, 2026-08-08 세션) + +``` +{ [inst(weak)]: { StrongMap: {[key]: value}?, WeakMap: {[key]: value(weak)}? }? } +``` + +- **바깥 테이블 하나**: `inst`로 weak-keyed(`__mode = "k"`), 값은 `{ StrongMap?, WeakMap? }` + 형태의 서브테이블. +- **`StrongMap`/`WeakMap`은 각각 lazy 생성** — `Relate()` 호출 시점엔 아무 + 것도 미리 안 만듦. `inst`당 서브테이블도, 그 안의 `StrongMap`/`WeakMap`도 + **`SetWeak`/`SetStrong`이 처음 불릴 때 인덱싱해보고 없으면 그때 생성**. + 이유(사용자 확정, 성능 근거): Luau가 정적 분석으로 포인터 해싱을 캐싱해서 + 같은 자리에서 여러 번 인덱싱하는 건 이미 꽤 싸지지만, **테이블 생성 + 자체(array+hash part 초기화)는 상대적으로 비쌈** — 안 쓸 `inst`/모드 + 조합에 대해 테이블을 미리 만들어두는 건 순수 낭비. +- **`WeakMap`의 메타테이블은 항상 같은 객체를 재사용**(`{__mode = "v"}`류 + 하나를 모듈 로드 시 한 번만 만들어두고, 모든 `WeakMap` 생성에 그 객체를 + 그대로 `setmetatable`) — 메타테이블 내용이 매번 똑같으니 매번 새로 만들 + + 이유가 없음. `StrongMap`은 메타테이블 자체가 필요 없어 그냥 `{}`. +- `GetWeak`/`GetStrong`은 각각 대응하는 서브맵이 아직 안 만들어졌으면(=한 + 번도 `Set`된 적 없음) 그냥 `nil` 반환 — 서브맵을 만들 필요 없음(읽기가 + 쓰기를 유발하면 안 됨). + +**M2 착수 시 실측 확인**: 위 lazy 생성 전략과 `WeakMap` 공유 메타테이블 +재사용이 실제 Luau에서 기대한 만큼 이득인지, `SetStrong`/`SetWeak`을 아주 +자주 왕복 호출하는 핫패스(예: 매 프레임 store-bind 재실행)에서 서브테이블 +존재 체크 자체가 새 비용이 되지는 않는지 — base 설계에는 영향 없는 순수 +구현 최적화 문제. + +## 대체하는 것 + +- `base/bind-system-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` + placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. +- `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 + `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 + strong). + +## 이름 + +`Relate` — 사용자 확정("이름은 Relate 괜찮아요? ... 좋습니다", 2026-08-08 +세션). 다른 프리미티브(`Source`/`Ref`/`Store`/`Modifier`/`Effect`/`Blocker`)와 +같은 "타입 이름이 곧 생성자" 컨벤션 그대로. diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index cc71210..67fd295 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -98,8 +98,8 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했 다시 세팅하는 정도로 충분. 구현 비용도 낮음: 각 Handler가 `process`에서 "이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 -(`base.perInstanceState(inst)`, `base/bind-system-plan.md` "핸들러 내부 -상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween +(`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md` +"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween 객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 (`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. diff --git a/.claude/question.md b/.claude/question.md index 251e44f..ad8780a 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -58,9 +58,10 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 때 이 연쇄까지 같이 고려할 것. -- **`PerInstanceState`(2순위)**: 핵심 프리미티브 `State`와 이름이 겹쳐서 - 실제로는 완전히 무관한 유틸(인스턴스별 weak-keyed 저장소)인데 혼동 - 유발 가능 — `PerInstanceStorage`/`InstanceData` 등 대안. +- **[해소됨, 2026-08-08 세션]** `PerInstanceState` — 이름 문제 자체가 없어짐. + `State`와 이름이 겹쳐 혼동 유발하던 그 유틸은 `Relate`로 대체·정식 + 승격됨(`base/relate-plan.md`) — 이름도 이미 사용자 확정("Relate 괜찮아요"), + `State`와 안 겹침. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -138,23 +139,34 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 확정(`base/component-composition-plan.md` "필수 관용구" 절) — M0에선 이 관용구 자체가 타입/런타임 양쪽에서 문제없이 동작하는지만 검증. -- **`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 릴레이션으로 둘지 평범한 필드로 둘지 포함, 실측은 그대로 필요. +- **~~`canExecute`/`Connected`의 실제 구현 방식이 미확정~~ — 반영 완료 + (2026-08-08 세션)** — 우선순위1-6 해소. `bindLifetime(inst,value)`/ + `canExecute(inst,value)` 탑레벨 함수로 확정(네임스페이스 안 씀, + `LifetimeHandle.luau`는 이 둘의 인터페이스만 갖고 quad-roblox가 구현 + 주입), 시그니처는 `(handle)`이 아니라 `(inst, value)` 2-인자로 재정정 + (Observer 자신의 `Subscribed` 상태를 먼저 보고, 그 다음 `inst`의 공유 + gcconn을 봄 — 두 조건이 독립적이라 하나로 못 뭉침). gchold 저장소는 + 새 프리미티브 `Relate`(`base/relate-plan.md`) 위에 구현 — `base/ + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고. - **~~`LifetimeHandle` 인터페이스가 M8에 배치돼 있지만 M4/M6이 이미 그걸 필요로 함(로드맵 순서 역전)~~ — 반영 완료(2026-08-07 세 번째 세션)**: - `LifetimeHandle`/`PerInstanceState` 인터페이스(타입만)를 `ROADMAP.md` + `LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md` M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. - 그 외(Slot CRUD 의미론 미정의, retract 시 "이전 핸들러" 추적 책임 소재, 우선순위 스캔 동률/매치실패 처리, `:Compute`의 `previous` 인자가 오버엔지니어링일 수 있음, UI shorthand의 기존 UICorner 매칭 기준 등)는 `pre-implementation-audit.md` 본문 참고. +- **[신규, 2026-08-08 세션, 미확인]** `Frame { ref }`/`Frame { observer }`처럼 + children 배열 숫자 슬롯에 직접 놓는 leaf 값을 실제로 매칭·바인드하는 + Handler(`(i:number, v=Ref/Observer/PreRef)`)가 어느 패키지에 사는지 — + `base/bind-system-plan.md:301-303,852-854`는 "`(v=Ref)` 매치 핸들러가 + 처리한다"/"`isObserver`로 판별해 라이프사이클에 묶어준다"까지만 서술하고 + 파일 배치는 안 함(`architecture.md` 소스트리에도 이 Handler가 이름으로 + 안 나와 있음). 제안(미확정): `Ref`/`Observer`/`PreRef` 전부 `inst`를 `any`로 + 취급하는 engine-agnostic 타입이고 process가 `v:Set(inst)`/`LifetimeHandle` + 위임 정도만 하면 되니, `Dispatch/StoreBind.luau`(이미 "범용, 엔진 무관"으로 + 분류)와 같은 층위로 `quad-base`에 두는 게 맞아 보임 — `quad-roblox/Handlers/`가 + 아니라. 사용자 확인 필요, 아직 base에 반영 안 함. ### 3. 낮은 우선순위 @@ -227,6 +239,8 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** | 컴포넌트 이식성(전역 store 참조 시 재사용성 문제) | `base/purity-and-effects-plan.md` | | Blocker(값 기반 emit 지연/합치기) | `base/blocker-plan.md` | | Effect(설치+확정 정리, `state` 있으면 Observer 조합해 재실행도 지원 — 확정) | `base/effect-plan.md` | +| `Relate`(inst-weak 릴레이션 프리미티브, `SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), `bindLifetime`/`canExecute`(inst,value) 탑레벨 함수 | `base/relate-plan.md`, `base/lifecycle-pattern.md` | +| `retract` 필드 생략 불가(no-op 허용, 누락 시 핸들러 교체 순간 크래시), store-bind 재실행은 `state:Observer(fn):Subscribe()` 재사용 | `base/bind-system-plan.md` | | UICorner/UIPadding/UIScale 인라인 편의 키 — 이름·메커니즘·store-bind 가능성까지 확정 | `base/ui-shorthand-plan.md` | | Batch(lexical) 기각, Context(+레이어드 Store) 기각 | `archive/batch-rejected.md`, `archive/context-rejected.md` | | Fusion/Vide 비교 리서치(주의: 일부 서술은 이후 라운드에서 뒤집힘, 문서 내 정정 표시 참고) | `reference/comparison-fusion-vide.md` | diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index f8a82d4..f07a1ad 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -150,6 +150,13 @@ Modifier/Ref를 아예 안 넘기는 케이스를 반드시 포함시킬 것.** ### 1-6. `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨 +**[해소됨, 2026-08-08 세션]** `bindLifetime(inst,value)`/`canExecute(inst,value)` +탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/ +gchold를 얹는 구체 구현까지 나옴 — `base/lifecycle-pattern.md`의 +"`bindLifetime`/`canExecute` — 확정" 절이 최신. 아래는 이 결정이 나오기 +전까지의 문제 서술(정확했던 문제 인식이라 그대로 둠, 남은 실측 항목은 +`lifecycle-pattern.md` 쪽 "M0/M2 실측 필요" 캐비엇으로 이동). + **위치**: `base/lifecycle-pattern.md` "2026-08-04 검증 라운드에서 보강된 내용" 절, 특히 "`Destroying` 훅은 생각보다 덜 중요할 수 있음" 부분. @@ -566,8 +573,8 @@ Handler"라고만 서술해, 사실상 3개의 거의 동일한 형태(리터럴 - M9(컴포넌트 합성)이 M7(Modifier)·M8(Ref) 뒤에 오는 순서 — M9는 "M0 스파이크(named-parameter 전달)를 정식 Modifier/Ref로 검증"하는 단계라고 명시돼 있어 뒤늦은 검증이 아니라 의도된 정식화. -- `PerInstanceState` 실제 구현 시점(M8) — 이걸 필요로 하는 핸들러(Tag/ - Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. +- `Relate`(구 `PerInstanceState`) 실제 구현 시점(M8) — 이걸 필요로 하는 + 핸들러(Tag/Attribute/Tween)가 전부 M10/M11이라 순서상 문제없음. - Slot의 store-bind 의존(M6→M4) 순서. --- diff --git a/CLAUDE.md b/CLAUDE.md index 462cb18..3083ada 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1362,3 +1362,91 @@ nil-hole 위험도 실측이 우선순위 높아짐. 전부 커밋 `98bd46a` 이후 아직 커밋 안 된 이번 대화 전체 변경사항에 포함 — 다음 세션이 새로 알아야 할 건 없음, `ROADMAP.md` M0부터 그대로 시작. + +## 2026-08-08 세션 — `Relate` 신규 프리미티브, `bindLifetime`/`canExecute` +탑레벨 함수로 확정, store-bind 재실행=Observer 재사용 명문화, `retract` +필드 생략 불가 확정 + +사용자가 store-bind/라이프사이클 관련 문서 갭 두 개를 질문하며 시작된 세션 +— 답을 찾는 과정에서 지금까지 이름만 있던 placeholder(`base.perInstanceState`)가 +실제로는 제대로 설계된 적 없는 프리미티브였다는 게 드러나 그 자리에서 +설계까지 확정까지 감. 네 가지로 정리: + +**1. store-bind의 "값이 바뀔 때마다 재귀 process" 구독 메커니즘 = +`state:Observer(fn):Subscribe()` 재사용으로 확정.** 기존 "확정된 디스패치 +모델"/"재실행 래핑" 절이 구독을 추상적으로만 서술해서 마치 새 구독 +프리미티브가 필요한 것처럼 읽혔는데, 실제로는 이미 확정된 Observer(등록 +즉시 1회 실행이라 "최초 적용"과 "이후 갱신"이 공짜로 통일됨, 자기 `Subscribed` +liveness도 이미 있음)를 그대로 쓰면 됨 — `retract`는 `observer:Unsubscribe()` +호출 하나로 끝. 새 구독 메커니즘 발명 없음. `base/bind-system-plan.md` +"Store 바인드는 특수 경우인가" 절 반영. + +**2. `retract` 필드는 no-op이라도 항상 정의해야 함 — 생략 불가로 확정.** +"모든 핸들러가 의미 있게 구현할 필요는 없음(보통 no-op)"이라는 기존 서술이 +"필드 자체를 생략해도 된다"로 오독될 수 있는 갭이었음 — `Dispatch.process`는 +담당 핸들러 타입이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 무조건 +호출하므로, 필드를 생략한 핸들러가 실제로 교체되는 드문 순간(Tween↔프로퍼티 +등)에 `attempt to call a nil value`로 크래시함. `base/bind-system-plan.md` +"핸들러 계약" 절에 명시, M2 체크리스트에 린트 대상으로 추가. + +**3. `Relate` 신규 프리미티브 — `bindLifetime`/`canExecute`(가 의존하는 +per-inst weak 저장소)를 제대로 설계.** 사용자 질문 경위: `Frame { observer }`처럼 +children 배열에 직접 놓는 leaf 케이스와, property store-bind 핸들러가 +**내부에서** 만드는 Observer(배열에 안 들어가므로 그 leaf 부착 경로를 안 탐)를 +처음에 잘못 섞어서 답했다가 사용자가 "state 바인딩은 결국 k,inner v를 +호출하니 i=number,v=observer로 다시 실행 안 된다"고 정정 — 후자는 +`bindLifetime(inst, observer)` 같은 별도 배관이 필요하다는 걸로 이어짐. +이게 `base/lifecycle-pattern.md`가 이미 원 사용자 메모(2026-08-04)로 +갖고 있던 "함수 안에서 만든 옵저버도 GC 대상 되어야 함" 절과 정확히 +같은 문제였음이 드러남 — 그 절이 "범용 유틸이 있어야 한다"까지만 말하고 +실제 인터페이스/이름이 없던 것. + +- **탑레벨 평범한 함수로 확정, 네임스페이스 뒤에 안 숨김** — `bindLifetime(inst,value)`/ + `canExecute(inst,value)`. `Dispatch.process`류는 "시스템 배관"이라 + 네임스페이스가 맞지만 이 둘은 `isState`/`isObserver`처럼 핸들러 작성자가 + 직접 부르는 1급 프리미티브 연산이라 `LifetimeHandle.bind(...)`식으로 + 감싸면 안 된다는 사용자 지적(정확함, 처음 제 제안이 틀렸었음). +- **`canExecute` 시그니처를 `(handle)` 단일 인자에서 `(inst, value)` + 2-인자로 재정정** — Observer 자신의 바인딩 생존(`Subscribed`)과 `inst` + 자체 생존(gcconn)이 독립된 두 조건이라 opaque `handle` 하나로 못 뭉침. + 구현은 `value`가 Observer/Effect면 자기 `Subscribed`부터 확인, 그 다음 + `inst`의 공유 gcconn `.Connected`를 봄. +- **`Relate` — `inst`를 weak 키로 하는 범용 릴레이션, 신규 프리미티브로 + 독립 승격**(`base/relate-plan.md`, 1프리미티브-1파일 컨벤션). `Relate()` + 비싱글톤 생성자 + `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. 핵심 + 결정 세 개, 전부 사용자가 직접 제시: + 1. **자동으로 아무것도 홀드 안 함** — `inst`도 `value`도 Relate 자신은 + 안 붙잡음, weak/strong 여부는 호출부(엔진을 아는 quad-roblox)가 + 매번 명시. 자동으로 정하면 weak 키가 참조하는 값이 그 키로 되돌아 + 강참조하는 사이클이 너무 쉽게 생김. + 2. **`inst`(키) 축은 항상 weak로 고정, 자유도를 안 열어둠** — 강한 키가 + 필요한 유스케이스가 지금까지 하나도 없어서, 그 자유도 자체가 사고 + 가능성만 늘림. `Weak`/`Strong`은 오직 `value` 보관 방식. + 3. **실 구조는 `{ [inst(weak)]: { StrongMap: {[k]:v}?, WeakMap: {[k]:v(weak)}? }? }`, + 둘 다 lazy 생성**(첫 `Set` 호출 시에만 만듦) — Luau가 정적 분석으로 + 포인터 해싱을 캐싱해 반복 인덱싱은 이미 싸지지만 테이블 생성(array+hash + part 초기화) 자체는 비교적 비싸다는 게 이유. `WeakMap`의 메타테이블은 + 매번 새로 안 만들고 공유 객체 하나를 재사용. + - **비싱글톤인 이유**: 각 핸들러 모듈이 자기 톱레벨에 `local relate = + Relate()`를 하나씩 두면 key 네이밍이 모듈 간에 겹칠 걱정이 원천적으로 + 없음(`Ref`/`Store`류와 같은 "생성 가능한 값" 컨벤션). +- **`base.perInstanceState(inst)` 이름/placeholder는 완전히 폐기** — + `Relate`가 그 자리를 정식으로 대체. `bind-system-plan.md`(핸들러 내부 + 상태 저장 절)/`ui-shorthand-plan.md`/`architecture.md`(소스트리, + `Relate.luau`는 quad-base 전체가 순수 Lua라 quad-roblox 재구현 없음)/ + `question.md`(용어 정리 목록에서 `PerInstanceState` 항목 삭제, 이름 + 갈등 자체가 해소됨)/`ROADMAP.md`(M2/M8/병행가능 세 곳) 전부 동기화. + +**4. 아직 안 풀린 것 — `(i:number, v=Ref/Observer/PreRef)` children-array +leaf Handler가 quad-base/quad-roblox 중 어디 사는지.** 3번을 풀다가 +갈라져 나온 별개 질문(`Frame { ref }` 자체를 매칭하는 Handler, store-bind +내부 Observer와는 무관) — 제 제안(엔진 특정 API가 필요 없으니 quad-base, +`Dispatch/StoreBind.luau`와 같은 층위)은 사용자 확인을 못 받은 채 대화가 +3번으로 넘어감. `question.md` 2번에 미확인으로 남김, base에는 반영 안 함 +— 다음에 확인 필요. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0/M2 스파이크 코드가 +검증해야 할 것 목록에 `Relate`의 lazy 서브테이블 생성/공유 메타테이블 +전략, `bindLifetime`/`canExecute`의 실제 gcconn 트릭이 새로 추가됨 — +`base/lifecycle-pattern.md`/`base/relate-plan.md`의 "실측 필요" 캐비엇 +참고. 4번(Ref/Observer leaf Handler 위치)도 M2 착수 전 확인 대상. diff --git a/ROADMAP.md b/ROADMAP.md index 5339a96..ed7e5ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,17 +86,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `isState`처럼 집합 멤버십 아님) 전부의 기반. `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 세 번째 세션에 반영). **`canExecute`는 `(handle: - LifetimeHandle) -> boolean`으로 확정**(바인딩마다 클로저 만드는 - zero-arg가 아니라, quad-roblox가 한 번만 주입하는 공유 함수 — "base - 유틸은 인터페이스, 백엔드가 주입" 패턴과 맞춰야 해서. - `base/lifecycle-pattern.md`의 gchold 스케치 절, 2026-08-07 여덟 번째 - 세션 정정) +- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — + `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. + `inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy + 생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`) + 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 + 대체(2026-08-08 세션 신설). +- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ + `canExecute(inst,value)` 탑레벨 함수 타입 계약, 실 구현 없음 — + quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 + `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 + 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` + 우선순위1-9, `question.md` 2번 — 2026-08-07 세 번째 세션에 반영). + **`canExecute`는 `(inst, value) -> boolean`으로 재확정(2026-08-08 + 세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기 + `Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로 + 저장)의 `.Connected`를 봄. `bindLifetime`/`canExecute` 둘 다 네임스페이스 + 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, + `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고 +- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 + 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 + 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" + 절, 2026-08-08 세션) - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -203,10 +215,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) -- [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스 - 자체는 M2로 이동됨) -- [ ] `PerInstanceState` quad-roblox 실제 구현(weak-keyed table, 인터페이스 - 자체는 M2로 이동됨) +- [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/`canExecute` + 본체(`GetPropertyChangedSignal("ClassName")` 연결 트릭으로 gcconn 확보, + `Relate:SetStrong`으로 gcconn/gchold 저장 — 인터페이스 자체는 M2로 + 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음) ## M9 — 컴포넌트 합성 레이어 @@ -229,8 +241,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## 특정 마일스톤에 안 묶이고 병행 가능 -- [ ] 용어 정리 스윕 — `State`/`DI`/`PerInstanceState`/`Slot` 등 - (`.claude/question.md` 1번), 최종 이름 확정되는 대로 아무 시점에나 +- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`로 + 대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 + 아무 시점에나 - [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록, 필요하면 `CLAUDE.md` "최근 세션 요약"도 갱신