diff --git a/.claude/README.md b/.claude/README.md index 0e6da8e..76632e2 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -37,7 +37,7 @@ | `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` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | -| `tag-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** `[Tag "Name"] = boolean` — `CollectionService` 얇은 래퍼, `process`가 add/remove 전부 처리, `retract` 불필요 | +| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` | | `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`가 그 위에 얹힘 | diff --git a/.claude/archive/tag-hash-key-model-reversed.md b/.claude/archive/tag-hash-key-model-reversed.md new file mode 100644 index 0000000..8807bc0 --- /dev/null +++ b/.claude/archive/tag-hash-key-model-reversed.md @@ -0,0 +1,45 @@ +# [역전됨] Tag = 해시 파트 boolean DI 키(`[Tag "Name"] = true`) — array-part 값 객체로 대체됨 + +**역전 일시**: 2026-08-08 (세 번째 세션). **원 확정 일시**: 2026-08-07 +여덟 번째 세션(`base/tag-plan.md` 최초 작성, "상태: base — 전부 확정"). +**현재 유효한 설계**: `base/tag-plan.md`(전면 재작성됨)가 최종 소스. 이 +파일은 능동적으로 참고할 필요 없음(구현에 안 씀) — 왜 "태그 하나당 키 +하나"에서 "여러 태그를 조합하는 값 객체"로 넘어갔는지가 `quadnomicon` +소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**값 모양**: `[Tag "Name"] = boolean | State` — 태그 이름 하나당 +해시 파트 키 하나, 값은 store-bind 가능한 boolean. + +**메커니즘**: `isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 +`TagHandler` 하나로 충분. `process(inst,k,v)`가 `v`가 참이면 `AddTag`, +거짓/`nil`이면 `RemoveTag`. **`retract` 불필요**로 결론 — "값이 뭐든 +(`true`/`false`/`nil`) 항상 같은 `TagHandler`가 이 키를 계속 담당하니 +핸들러 *타입*이 안 바뀐다"는 게 근거였음. + +## 왜 역전됐나 + +사용자가 실사용 시나리오를 제시하며 기각: 상호배타적인 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 실제로는 20개까지도 가능)를 표현하려면 이 모델은 +**태그 이름 개수만큼 boolean 키를 각각 만들어야** 함 — 상태 전환마다 +여러 키를 동시에 갱신해야 하고, 스타일 조합(여러 태그를 합쳐 쓰는 것)도 +자연스럽게 표현이 안 됨. "하나의 값을 통째로 바꿔서 태그 집합을 바꾼다"는 +요구를 이 모델은 구조적으로 못 담음. + +## 대체 모델과의 비교 + +| | 구 모델(해시 파트) | 신 모델(array-part 값 객체) | +|---|---|---| +| 값 모양 | `[Tag "이름"] = boolean` | `Tag(...)`/`Tag.Merged(...)` 값 객체, array 슬롯에 놓임 | +| 상태 전환 | 태그 개수만큼 키 갱신 | 값 하나를 store-bind로 교체 | +| 조합 | 안 됨(키가 독립적) | `:Added`/`:Removed`/`Merged`로 조립 | +| retract | 불필요(핸들러 타입 안 바뀜) | 필요(값이 `nil`이 되면 핸들러 자체가 안 바뀜, 전체 삭제) — `Dispatch` 체인 메커니즘(`bind-system-plan.md` "Dispatch 체인" 절)과 맞물려 재설계됨 | + +부수적으로, 이 역전이 `Dispatch.process`/`retract`의 "이전 매치 핸들러 +추적" 문제(`pre-implementation-audit.md` 1-2번)를 실제로 파고드는 계기가 +됐음 — Tag가 재귀 재-dispatch(`Source`가 store-bind를 거쳐 +TagHandler로 위임)에 진입하는 첫 구체 사례가 되면서, "핸들러 타입이 안 +바뀌니 retract 불필요"라는 구 모델의 전제 자체가 신 모델에서 깨졌고, 그 +자리를 메우려다 `Dispatch.retractUnder`(체인 기반 retract 전파) 설계로 +이어짐. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5b8662c..05ff75f 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -26,9 +26,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. -4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]`, `[Tag ""] = true` 같은 - 특수 바인드 키. Tag는 `retract`(구 cleanup, `base/lifecycle-pattern.md` 참고)가 - 내장되어 store 컴퓨티드 바인드도 가능해야 함. +4. **PA님 스타일 DI 키 계속 지원**: `[Attribute "Name"]` 같은 특수 바인드 키, + store 컴퓨티드 바인드도 가능해야 함(`retract`, 구 cleanup, + `base/lifecycle-pattern.md` 참고). **[정정, 2026-08-08 세 번째 세션]** + `Tag`는 더 이상 `[Tag ""] = true` 해시 파트 DI 키가 아님 — array-part + 값 객체(`Tag(...)`)로 재설계됨, `base/tag-plan.md` 참고 + (`archive/tag-hash-key-model-reversed.md`에 구 모델 보존). 5. **id 기반 전역 조회 폐지, Tag 시스템으로 대체.** v1의 `Store.GetObject(id)`/ `Frame "id" {}`류는 더 이상 없음 — "id 매핑이 비현실적"이라는 게 이유. 네임스페이싱 문제는 있지만 별도 네임스페이스 개념을 추가하면 라이브러리 @@ -134,9 +137,10 @@ quad/ │ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환 │ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치 │ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Override`(`base/modifier-plan.md`) +│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), CollectionService 글루는 quad-roblox Handlers/Tag.luau(`base/tag-plan.md`, 2026-08-08 세 번째 세션) │ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`) │ ├── Dispatch/ -│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔 +│ │ ├── init.luau # process/retract 엔진, isHandlable 우선순위 스캔, `chains`(inst,k별 핸들러 체인)+`retractUnder`(`bind-system-plan.md` "Dispatch 체인" 절, 2026-08-08 세 번째 세션) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process/retract) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef) children-array leaf 매칭 Handler, StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) @@ -155,7 +159,7 @@ quad/ │ ├── Property.luau │ ├── Event.luau # ReflectionService 기반 자동 판별 │ ├── Attribute.luau - │ ├── Tag.luau # CollectionService + │ ├── Tag.luau # CollectionService 글루만(process/retract) — 값 타입/API는 quad-base Tag.luau(`base/tag-plan.md`) │ ├── Tween.luau # 높은 우선순위 store-bind 핸들러 │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 06cd5c5..d36ec0c 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -92,13 +92,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 결국 정리하긴 하지만, GC 되기 전에도 store 값이 업데이트될 수 있으므로 그 시점엔 그냥 `Connected`를 보고 무시(no-op). 2. 처리해도 되면, 사용자가 넘긴 함수들을 거쳐 실제 값(`realv`)을 계산. - 3. **`realv`를 들고 다시 `Dispatch.process(inst, k, realv)`를 재귀 호출** - (오케스트레이터 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 - 여덟 번째 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 - 재실행하는 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 - store가 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 - 통과 못 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 - — 무한 재귀 걱정 없음. + 3. **재귀 호출 전에 먼저 `Dispatch.retractUnder(inst, k, self, realv)`를 + 불러 자기 밑에 위임돼 있던 걸 정리한 뒤, `realv`를 들고 + `Dispatch.process(inst, k, realv)`를 재귀 호출**(정확한 메커니즘은 + 아래 "Dispatch 체인" 절, 2026-08-08 세 번째 세션 — 오케스트레이터 + 이름 공식화는 아래 `None` 센티널 절 참고, 2026-08-07 여덟 번째 + 세션) — 이게 바로 "store 바인드는 pluggable 바인드를 재실행하는 + 래핑"이라는 이 문서 이전 초안의 결론과 일치. `realv`가 store가 + 아니라면 자연히 Tween의 store-bind 핸들러 `isHandlable`을 통과 못 + 하고 우선순위상 다음 핸들러(일반 프로퍼티 세터 등)로 흘러감 — 무한 + 재귀 걱정 없음. - **`retract(inst, k, v)`** (이전 초안의 "cleanup", 이름 변경 근거는 `base/lifecycle-pattern.md` 참고) — 이전 처리를 무르는/멈추는 함수. **오직 "같은 key에 새 값이 들어와서 이전 처리를 갈아치우는" 시나리오에만 존재** — @@ -111,14 +114,19 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 세션, 정정) — 예: 이전엔 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`의 일. **Attribute는 여기 해당 안 함** — UICorner 숏핸드와 + 같은 패턴(값의 참/거짓/nil 여부와 무관하게 항상 같은 핸들러가 계속 + 담당, 추가/제거를 전부 `process` 자신이 처리)이라 핸들러 교체 자체가 + 안 일어남 — `base/attribute-plan.md`. **[정정, 2026-08-08 세 번째 + 세션] Tag는 더 이상 여기 해당하지 않음** — array-part 값 객체로 + 재설계되며(`base/tag-plan.md`, 구 모델은 `archive/ + tag-hash-key-model-reversed.md`) `Tag(...)`↔`nil` 사이에서 핸들러 + 타입 자체가 바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 + 바뀌는 diff는 `process`가 담당. + - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 + 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 + 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 + 2026-08-08 세 번째 세션에 구체화됨, 여기 한 줄 설명은 그 요약). - **핸들러 내부 상태 저장**: `retract`가 "이전에 생성한 것"(예: 실행 중이던 Tween 객체)에 접근하려면 상태를 어딘가에 저장해야 함 — **`inst`를 키로 하는 weak-keyed 테이블에 `k`별로 저장**(예: 생성된 Tween을 담아뒀다가 나중에 @@ -252,15 +260,12 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 "`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` 내부에 위임) 자연히 해소됨. +- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가" + bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로 + 매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch + 체인" 절의 `chains`/`Dispatch.retractUnder`로 구체화됨 — `NoneHandler`의 + 재귀 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 + 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). ### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) @@ -313,6 +318,92 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. +### Dispatch 체인 — 재귀 재-dispatch의 retract 전파, `Dispatch.retractUnder` (2026-08-08 세 번째 세션) + +**문제**: Tween/`NoneHandler`/`StoreBind`처럼 자기 `process` 안에서 +`Dispatch.process(inst,k,realv)`를 다시 부르는 래핑 핸들러가 있으면, 같은 +`(inst,k)`에 대해 "지금 누가 담당 중인가"를 슬롯 하나로 추적하는 순간 +깨짐 — 래핑 핸들러 A 자신의 생명주기(예: StoreBind의 Observer 구독)와, +A가 재귀로 위임한 핸들러 B의 생명주기가 **같은 슬롯을 두고 서로 +덮어씀**. 구체적으로: A의 재귀 진입 시점에 슬롯을 A→B로 갱신해두면, A가 +스스로 다시 값을 재계산해 재-dispatch할 때(예: store 값이 또 바뀜) 그 +슬롯엔 이미 B가 적혀있어 "A로 바뀌었다"고 오판해 A 자신을 엉뚱하게 +retract하거나, 반대로 A가 자길 스스로 retract하는 오작동이 남 — 처음 +검토했던 "Dispatch 전역 소유자맵 슬롯 하나" 안은 이 이유로 기각됨(당시 +대화에서 직접 반례로 확인). + +**해법 — Dispatch가 `(inst,k)`별 핸들러 체인(순서 있는 배열)을 소유**: + +```lua +-- Dispatch/init.luau +local chains = Relate() -- {[inst(weak)] = {[k] = {handler, handler, ...}(strong, 순서 있는 배열)}} + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + local list = chains:GetStrong(inst, k) or {} + table.insert(list, h) -- 항상 꼬리에 추가 + chains:SetStrong(inst, k, list) + h.process(inst, k, v) + end +end + +function Dispatch.retractUnder(inst, k, keep, v) + local list = chains:GetStrong(inst, k) + if not list then return end + local cutoff = 0 + if keep then + for i, h in list do if h == keep then cutoff = i break end end + end + for i = #list, cutoff + 1, -1 do + list[i].retract(inst, k, i == cutoff + 1 and v or nil) + list[i] = nil + end +end +``` + +- **재귀/래핑 핸들러는 재-dispatch 전에 반드시 `Dispatch.retractUnder(inst, + k, self, newV)`를 먼저 부른 뒤 `Dispatch.process(inst, k, newV)`를 + 부름** — "나 밑에 있던 걸 전부 정리하고 새로 위임". `keep`(자기 자신) + 바로 다음 항목만 실제 `newV`를 받고, 그보다 더 안쪽(다단 체인이 있을 + 경우)은 `nil`을 받음 — 더 안쪽 항목엔 "구체적으로 뭐로 대체됐는지" + 정보가 없고 "완전히 사라진다"는 것만 사실이라서. +- **개별 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 + 쫓아가도 됨** — `retractUnder`가 꼬리부터 `keep` 앞까지 한 번의 + 루프로 체인 전체를 순서대로 정리해주므로, A→B→C처럼 몇 단계든 각 + 핸들러는 **자기 자신의 자원만** 정리하면 자동으로 전파됨(질문 + 제기됐던 "다단 체인에서 안쪽까지 retract가 안 간다" 문제가 이걸로 + 해소 — `retractUnder`의 루프 자체가 체인 전체를 훑으므로 각 핸들러가 + 수동으로 cascade할 필요가 원천적으로 없음). +- **구멍 걱정 없음** — 이 배열은 항상 꼬리에서만 추가/삭제되는 스택 + 모양이라(`retractUnder`가 항상 꼬리부터 연속으로 지움), "촘촘하지 + 않은 정수 키는 순회 순서가 깨진다"는 문제(위 "PreRef" 절의 `None` + 소진 이슈)가 애초에 발생할 구조가 아님. +- **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화 + 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 + 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 + 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" + 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건), Tween 같은 + 경우는 자기 `Relate` 저장분만 보고 `Cancel`하면 되니 역시 `v`를 꼭 + 안 봐도 됨 — `v`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 + 됨" 정도로 이해할 것. +- **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 + B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 + 나므로 애초에 일어날 수 없는 구조(각 핸들러는 최대 한 번씩만 그 + 키에서 호출됨을 전제) — 값에 별도 플래그를 심어 의도적으로 순환을 + 만드는 것도 이론상 가능하지만 use case가 없어 문서화 대상 밖, + 2026-08-04 세션에 이미 확정된 "일반적 무한루프 방어 안 함" 원칙과 + 같은 결로 UB 취급. +- **부수 효과 — 미래 재바인드/quad-debug에 유리**: 이 체인이 Dispatch에 + 중앙화돼 있으므로, `research/existing-instance-bind-plan.md`가 다룰 + 미래의 재바인드는 `Dispatch.retractUnder(inst, k, nil, newV); + Dispatch.process(inst, k, newV)` 두 줄로 "이 키의 체인을 통째로 갈아 + 끼우기"가 자연스럽게 됨(각 래핑 핸들러가 자기 전용 `Relate`에 위임 + 대상을 비공개로 숨겨두는 대안 설계는 이게 안 됨 — 대화 중 검토 후 + 기각). `research/debug-tooling-plan.md`의 "무엇이 무엇에 연결됐는가" + 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 + 새로 설계할 필요 없음. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -335,7 +426,8 @@ value)로 `Dispatch.process(inst,k,realv)`를 재귀 호출"하는 식으로 구 ```lua -- 예: 일반 프로퍼티 store-bind 핸들러의 process(inst, k, state) local observer = state:Observer(function() - Dispatch.process(inst, k, state:Get()) + Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 + Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) end) observer:Subscribe() relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 @@ -346,12 +438,12 @@ relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 `:Subscribe()`가 유일한 선택). -- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐** — 이게 위 "이벤트도 +- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐 — 위임 대상까지 + 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 밑에 위임된 + 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 `retract`는 + 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 - 이유(새 디스패치 메커니즘 없이 기존 4종 계약만 구현), 다만 그 절이 - 구체적으로 가리키는 재사용 대상은 "재귀 process+retract 래핑 패턴"이었고 - Observer 자체를 구독 메커니즘으로 쓴다는 것까지는 명시가 안 돼 있었던 - 갭이 이번에 메워짐. + 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index e116ce9..345777f 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -1,46 +1,111 @@ -# Tag 특수 키 — `CollectionService` 얇은 래퍼 +# Tag — array-part 값 객체, `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` 동작을 반영. +**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 +모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). +새 결정만 반영, 열린 질문 없음. -## 값 모양 +## 왜 재설계됐나 -`[Tag "Name"] = boolean | State` — store-bind 가능(일반 프로퍼티와 -동일하게 취급). PA님의 `EventDrivenProgramming/Observer.luau` -`subscribeTaggedInstance`도 얇은 `CollectionService` 래퍼일 뿐이라 -(`bind-system-plan.md` "PA님 코드와 대조" 절) **Instance 태그는 -`CollectionService` 직접 사용 그대로 유지** — 별도 자체 태그 시스템(v1이 -검토했던 것 같은) 안 만듦. +구 모델(`[Tag "Name"] = boolean`, 태그 하나당 해시 파트 키 하나)은 상호 +배타적인 스타일 상태(`btn1`/`btn2`/`btn3`류, 실사용에서 20개까지도 가능)를 +표현하려면 태그 이름 개수만큼 키를 각각 갱신해야 해서 끔찍함, 스타일 +조합(여러 태그를 합쳐 쓰는 것)도 구조적으로 안 됨 — 상세 경위는 +`archive/tag-hash-key-model-reversed.md`. -## 메커니즘 — 새 아키텍처 개념 불필요 +## 값 모양 — `Modifier`와 같은 immutable clone 체이닝 -`isHandlable`이 `[Tag "Name"]` 모양의 키를 매칭하는 `TagHandler` 하나로 -충분: +``` +Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 값도 유효 +tag:Added(name): Tag -- clone 후 이름 추가, 원본 안 건드림 +tag:Removed(name): Tag -- clone 후 이름 제거 +tag:Contains(name): boolean -- 멤버십 확인 +tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) +Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 + Override(필드 단위 덮어쓰기, 손실 있음)와 + 다른 연산이라 이름도 다름 — Override는 + "이미 계산된 걸 합침", Merged는 "집합을 + 합침" +``` -- `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 필요 예시로 잘못 들었던 걸 여기서 바로잡음.** +`Added`/`Removed`가 `-ed` 어미인 이유는 **`Add`/`Remove`로 쓰면 뮤테이션 +API처럼 보이기 때문** — 실제로는 항상 `table.clone` 후 반환(Modifier +3번 절과 동일한 immutable 확정 이유: 형제 서브트리 오염 방지). `Tag(a,b)` +자체가 `Tag():Added(a):Added(b)`의 sugar라고 생각하면 됨 — 별도 런타임 +경로 아님. -## 패키지 배치 +**children 배열 슬롯(array-part)에 직접 놓임** — `Frame { Tag("selected") }`. +정적으로 여러 개 놓아도(`Frame { Tag("a"), Tag("b") }`) 각자 독립적으로 +자기 태그만 추가하면 되므로 `Merged` 없이도 됨(`Merged`/`Added`/`Removed`는 +"하나의 Tag 값을 프로그래밍적으로 조립"하는 용도). -UICorner 숏핸드/Tween과 같은 판단 재사용 — 작고 항상 켜져 있어도 비용이 -무시할 만한 기능은 `quad-roblox` 코어에 직접 포함(`base/ui-shorthand-plan.md` -"패키지 배치" 절 참고, 별도 opt-out 패키지로 안 쪼갬). +**동적 토글은 `Source`/`State`로, `None` 불필요** — 상호배타 상태 전환은 +`store.activeTag:Compute(function(name) return name == "btn1" and +Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센티널은 +"정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" 문제의 +해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 리턴값이 +동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 넘기는 건 +아무 문제 없음. (단, `Frame { cond and Tag("a") or nil, sibling }`처럼 +**정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 싶은 경우엔 다른 array-part +값들과 마찬가지로 `cond and Tag("a") or None` 관용구가 여전히 유효 — +이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/ +`props.Ref`와 같은 일반 array-part 관용구.) + +## 메커니즘 — `TagHandler`, retract가 이제 의미 있어짐 + +구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔ +`nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안 +맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된 +디스패치 모델" 절의 일반 원칙 그대로). + +```lua +local relate = Relate() -- TagHandler 전용, 이전에 반영한 Tag 값 저장 + +TagHandler.priority = <일반> +TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 + +function TagHandler.process(inst, k, v) + local old = relate:GetStrong(inst, k) + -- diff: old에 있고 v에 없는 이름만 RemoveTag, v에 있고 old에 없는 이름만 AddTag + -- (모두 지웠다 다시 붙이지 않음 — 랙/스타일 깜빡임 방지가 이 diff의 존재 이유) + relate:SetStrong(inst, k, v) +end + +function TagHandler.retract(inst, k, v) + local old = relate:GetStrong(inst, k) + if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end + relate:SetStrong(inst, k, nil) +end +``` + +- **`Tag(A) → Tag(B)`(같은 핸들러, 타입 안 바뀜)**: `retract`는 아예 안 + 불림 — `Dispatch`의 "핸들러가 안 바뀌면 retract 없이 process만 다시" + 원칙 그대로(`bind-system-plan.md` "Dispatch 체인" 절). **diff는 여기, + `process` 안에서만** 일어남 — 전체 삭제 후 재생성하면 스타일이 순간 + 전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시 + 이전 값과 diff. +- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가 + 불림 — **`v`를 굳이 안 봐도 됨**: retract는 구조상 "더 이상 Tag가 + 아니게 됐을 때만" 불리므로, 뭐가 새로 들어왔든 전체 삭제가 항상 맞는 + 동작. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를 받는 건 계약 + 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가 그걸 필수로 + 요구해서가 아님. +- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** — + `Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는 + 자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은 + `bind-system-plan.md` "Dispatch 체인" 절. + +## 패키지 배치 — base는 값+API, roblox는 process/retract 글루 + +**Tag의 "값 타입과 clone 체이닝 API"(`Tag(...)`/`:Added`/`:Removed`/ +`:Contains`/`:Apply`/`Merged`)는 quad-base 소속** — `Modifier`와 정확히 +같은 층위(엔진 무관, 순수 데이터+연산). `CollectionService` 실제 호출 +(`TagHandler.process`/`retract`)만 quad-roblox 소속 — 이미 확정된 "base는 +인터페이스/값, backend는 process·retract 글루" 패턴(`LifetimeHandle`, +`Dispatch.addHandler` 자체가 이 패턴)을 값 타입 수준까지 그대로 확장한 +것뿐, 새 아키텍처 개념 아님. ## 열린 질문 -없음 — 값 모양/메커니즘/retract 여부 전부 확정. 이름 자체(`Tag`)는 이미 -쓰기 시작한 v1/PA님 관례와 일치해 특별히 재검토 대상 아님. +없음 — 값 모양/메커니즘/retract/패키지 배치 전부 확정. 이름 자체 +(`Tag`/`Added`/`Removed`/`Merged`)는 다른 가칭들과 같이 용어 정리 대상 +(`.claude/question.md`). diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index f07a1ad..54f2b1e 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -66,7 +66,17 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 `Tween.luau`는 그 위에 얹히는 "값에 tween 설정이 붙어있으면 가로채는" 더 높은 우선순위의 특수 케이스로 재정리하는 게 자연스러워 보임. -### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 +### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션] + +**해소**: `Dispatch`가 `(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을 +직접 소유하고, `Dispatch.retractUnder(inst,k,keep,v)`가 꼬리부터 `keep` +앞까지 정리해주는 걸로 확정 — 아래 원래 제안(`Dispatch/StoreBind.luau`가 +"마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가 +여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 +슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 +전체 체인을 배열로 들고 있는 쪽으로 정리됨. 상세는 `base/ +bind-system-plan.md` "Dispatch 체인" 절, `ROADMAP.md` M2/M4. 아래는 +원래 발견 당시 기록. **위치**: `base/bind-system-plan.md` "확정된 디스패치 모델" 절 90-91행 — "store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract(inst, k, v)`를 diff --git a/CLAUDE.md b/CLAUDE.md index 713ea12..f62e722 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1539,3 +1539,92 @@ architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절 신설. **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계/문서 정리라 M0 착수 우선순위 자체는 그대로. 위 2026-08-08 첫 세션이 남긴 "M0/M2 스파이크 검증 목록"에 새로 추가되는 항목 없음. + +## 2026-08-08 세 번째 세션 — Tag를 array-part 값 객체로 재설계, Dispatch +체인+`retractUnder`로 재귀 재-dispatch의 retract 전파 문제 해결 + +같은 날 이어진 세션. 사용자가 "Tag를 해시 파트 boolean 키 대신 array-part +값 객체로 바꾸는 게 낫지 않냐"는 질문으로 시작 — 상호배타 스타일 상태 +(`btn1`/`btn2`/`btn3`류, 20개까지도 가능)를 표현하려면 구 모델은 태그 +개수만큼 키를 갱신해야 해서 끔찍하다는 실사용 근거. 이 논의가 "retract가 +새 값의 타입에 따라 이전 핸들러를 정확히 찾아 부를 수 있는가"라는 훨씬 +근본적인 구멍(`pre-implementation-audit.md` 1-2번이 이미 지적해뒀던 것)을 +직접 건드리게 됐고, 몇 차례 시행착오 끝에 사용자가 제시한 "체인+ +`retractUnder`" 설계로 수렴. 세 갈래로 정리: + +**1. Tag 재설계 — array-part 값 객체, `Modifier`와 같은 immutable clone +체이닝.** `Tag(name1, name2, ...)`(가변인자 생성자, 빈 `Tag()`도 유효), +`:Added`/`:Removed`(뮤테이션처럼 안 보이게 `-ed` 어미 — 실제로는 항상 +clone 후 반환), `:Contains(name):boolean`, `:Apply(factory)`(Modifier와 +동일한 순수 체이닝 설탕), `Tag.Merged(tag1,tag2,...)`(집합 합집합, 무손실 +— Modifier의 `Override`는 필드 단위 덮어쓰기라 손실 있음, 그래서 이름도 +다름). `None` 센티널은 불필요로 확인 — 동적 토글은 `Source`/`State`가 +계산 결과로 `nil`을 리턴하면 되는 함수 인자 전달이라 테이블 리터럴의 +nil-hole 문제 자체가 없음(정적 리터럴에서 조건부로 Tag를 넣고 뺄 때는 +다른 array-part 값과 마찬가지로 기존 `None` 관용구가 그대로 유효, Tag +전용 규칙 아님). 구 모델(해시 파트 boolean, "핸들러 타입이 안 바뀌니 +retract 불필요"가 결론이었음)은 `archive/tag-hash-key-model-reversed.md`로 +역전 보존, `base/tag-plan.md` 전면 재작성. 값 타입+API(`Tag.luau`)는 +quad-base, `CollectionService` 글루(`Handlers/Tag.luau`)만 quad-roblox — +이미 확정된 "base는 인터페이스/값, backend는 process·retract 글루" +패턴(`LifetimeHandle`)을 값 타입 수준까지 그대로 확장한 것으로 확인, +새 아키텍처 개념 아님. + +**2. Tag 재설계가 "retract가 실제로 필요해지는" 첫 array-part store-bind +사례가 되며, 기존 "이전 핸들러 추적" 설계 공백이 정면으로 드러남.** +`pre-implementation-audit.md` 1-2번이 이미 "store-bind 재실행 모델에서 +realv 타입이 매 갱신마다 바뀔 수 있는데 '이전 핸들러'를 누가 추적하는지 +불명"이라고 짚어뒀던 것 — Tag가 `Tag(...)`↔`nil` 사이를 오가며 실제로 +핸들러 타입이 바뀌는 구체 사례가 되어 더 이상 미룰 수 없어짐. 시행착오 +과정: +- **1차 제안(제가 냄, 기각됨)**: Dispatch가 `(inst,k)`별로 "지금 누가 + 담당 중인가"를 슬롯 하나로 추적. **재귀/래핑 핸들러(StoreBind 등) + 에서 깨짐** — 사용자가 직접 "A→B 구조에서 A가 바뀌면 B의 retract가 + 실행되고, 재귀로 B로 다시 내려오면 retract가 없는 거 아니냐"고 반례를 + 제시 — A 자신의 생명주기(예: Observer 구독)와 A가 재귀로 위임한 B의 + 생명주기가 슬롯 하나를 두고 서로 덮어써서, A가 스스로 재-dispatch할 + 때 자길 엉뚱하게 retract하거나 반대로 안 해야 할 때 안 하는 오작동이 + 생김이 실제 트레이스로 확인됨. +- **2차 제안(제가 냄, 부분 기각)**: 각 래핑 핸들러가 자기 전용 `Relate`에 + 위임 대상을 비공개로 저장(A→B→C면 A.retract가 수동으로 B.retract를 + 부르고 B.retract가 수동으로 C.retract를 부르는 linked 구조). 동작은 + 하지만 사용자가 두 가지 지적: (a) 나중에 재바인드(`existing-instance- + bind-plan.md`) 지원을 생각하면 위임 정보가 핸들러별로 비공개 분산돼 + 있어 외부에서 못 들여다봄, (b) 각 핸들러 작성자가 "내 retract에서 + 위임 대상도 cascade해야 한다"는 걸 매번 기억해야 하는 규율 의존적 + 설계. +- **최종 채택(사용자 제안) — Dispatch가 `(inst,k)`별 핸들러 체인(순서 + 있는 배열)을 직접 소유, `Dispatch.retractUnder(inst,k,keep,v)`가 + 꼬리부터 `keep` 앞까지 훑으며 정리.** `Dispatch.process`가 매치될 + 때마다 체인에 push, 재귀/래핑 핸들러는 재-dispatch 전에 + `retractUnder(inst,k,self,newV)`를 먼저 불러 자기 밑을 정리 — 이 + 한 번의 루프가 다단 체인(A→B→C) 전체를 순서대로 정리해주므로 개별 + 핸들러의 `retract`는 더 이상 자기 위임 대상을 수동으로 안 쫓아가도 + 됨(2차 제안의 (b) 해소), 체인이 Dispatch에 중앙화돼 있어 미래 + 재바인드도 `retractUnder(inst,k,nil,newV);process(inst,k,newV)` + 두 줄로 자연스럽게 됨((a) 해소) — quad-debug의 "무엇이 무엇에 + 연결됐는가" 그래프도 이 구조를 그대로 읽으면 됨. 배열이 항상 꼬리에서만 + 추가/삭제되는 스택 모양이라 `None` 소진 이슈(구멍 있는 정수 키 순회 + 문제)도 애초에 안 생김. **`retract`는 여전히 `(inst,k,v)` 3-인자 + 유지** — 한 차례 제가 "v 제거"를 제안했다가 틀렸음(사용자가 Tag의 + 전체삭제 vs diff 분기를 근거로 정정) — 다만 최종 설계에서 diff는 + `process`(같은 핸들러 유지 시)의 몫이고 `retract`는 항상 "더 이상 + 매치 안 될 때만" 불리므로 Tag 한정으로는 `v`를 안 봐도 항상 전체 + 삭제가 맞다는 것도 확인. 순환은 기존 "일반적 무한루프 방어 안 함" + 원칙(2026-08-04) 그대로 UB. + +**3. 전부 `base/bind-system-plan.md`(신규 "Dispatch 체인" 절 + "확정된 +디스패치 모델"/"None 센티널"/"Store 바인드는 특수 경우인가" 절 갱신)/ +`base/tag-plan.md`(전면 재작성)/`archive/tag-hash-key-model-reversed.md` +(신규)/`base/architecture.md`(소스트리 `Tag.luau` 추가, 4번 항목 정정)/ +`ROADMAP.md`(M2/M4/M10)/`research/pre-implementation-audit.md`(1-2번 +해소 표시)에 반영 완료.** + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M2/M4 스파이크 +검증 목록에 `chains`/`retractUnder`가 다단 체인에서 실제로 정확히 +동작하는지가 새로 추가됨(추론만으로 확정된 것, `pre-implementation-audit.md` +류 "실제 Luau로 부딪혀본 적 없는 것" 범주). `pre-implementation-audit.md` +1-1번(Tween이 유일한 store-bind 예시라 "일반 store-bind와 Tween이 같은 +핸들러인지"가 불명확한 문제)은 Tag가 두 번째 구체 사례가 되면서 정황상 +"별개 핸들러, 둘 다 `Dispatch/StoreBind.luau` 재사용"쪽에 힘이 실리지만 +**아직 명시적으로 확정된 건 아님** — M2/M4 착수 전 마저 확인할 것. diff --git a/ROADMAP.md b/ROADMAP.md index 1449589..456833e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,6 +113,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) +- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...} + (strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` — + 재귀 재-dispatch(StoreBind/Tween/NoneHandler)의 retract를 다단 + 체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/ + bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md` + 1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될 + 때마다 체인에 push하는 것도 이 항목에 포함 - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -143,8 +150,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M4 — 첫 end-to-end 반응형 업데이트 -- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관) -- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" 확인 +- [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch + 전 `Dispatch.retractUnder(inst,k,self,realv)` 호출 필수, `base/ + bind-system-plan.md` "Dispatch 체인" 절) +- [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + + "이전 값이 다른 타입이면 이전 핸들러의 `retract`가 정확히 불린다" + 확인 ## M5 — quad-roblox 최소 프로바이더 @@ -235,7 +246,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) - [ ] `Handlers/Attribute.luau`(`base/attribute-plan.md` — 메커니즘/`None`/ `retract` 불필요 확정, 타입 파라미터화 이름만 착수 전 확인) -- [ ] `Handlers/Tag.luau`(`CollectionService`, `base/tag-plan.md` — 전부 확정) +- [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ + `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` + — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 + 모델은 `archive/tag-hash-key-model-reversed.md`) +- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` process/retract + 글루만, `isHandlable`은 `isTag(v)`. `retract`는 이제 의미 있음(값이 + Tag가 아니게 되면 전체 삭제), 같은 Tag끼리 바뀌는 diff는 `process`가 + 자기 `Relate` 저장분과 비교해서 처리 — 전체 삭제 후 재생성 금지(랙 + 유발), `base/tag-plan.md` 참고) ## M11 — Tween