From 41b96e6917370d0342a1cfa1ef64df8b3743905d Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 20 Aug 2026 00:48:07 +0900 Subject: [PATCH] =?UTF-8?q?design:=20QA=204=EB=9D=BC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=ED=9A=8C=EC=8B=A0=201=EC=B0=A8=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=E2=80=94=20=EC=A6=89=EC=8B=9C=20=EC=B2=98=EB=A6=AC=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20=EC=A0=95=EC=A0=95=2020=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 회신(pre-implementation-qa-round4-response.md) 중 판단이 명확한 항목을 base/에 반영. 굵직한 것만: - LP-1: quad의 `Connected`는 "계산된 속성"이 아니라 그냥 RBXScriptConnection의 네이티브 필드다 — rbvm 프록시 사정을 잘못 옮겨온 서술이었음. 실제 판정은 "gcconn이 없음" / "있는데 Connected==false" 두 상태뿐. - D-56: `bindLifetime`의 첫 인자가 Instance가 아닐 수 있다(Slot-in-Slot의 ownerKey) — 백엔드가 반드시 핸들링해야 하는 요구사항으로 신설. gcconn 트릭이 안 통하므로 세 번째 판정 분기가 필요하다는 것까지 명시. - SL-75/D-60: 언마운트 시 `slot.Offset = nil`은 포탈을 깨뜨림(이미 구독 중인 다운스트림이 영구히 끊김) — stale하게 두는 게 맞고, 마운트 전 기본값도 nil이 아니라 0. - SL-74: `SetAndDispose`는 `source:SetAndDispose(value)` 콜론 메서드로 확정. `Apply` 오버라이딩은 Source→State 단방향 때문에 타입이 안 성립. - E-11: leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹는 것으로 Observer와 통일. 옛 "(3) 이후 leaf가 죽어도 중복 호출 안 됨"은 이중 바인딩 게이트상 성립할 수 없는 문장이라 삭제. - AT-20: 생존 이름 최적화는 "부품이 늘어나서 안 하는" 게 아니라 값 비교가 필요해 **원리적으로 불가능**하다(State 계약상 `:Get()` 비교 금지). - UI-8: `mapTweenValue` 로컬 헬퍼를 `Tween:Map(fn)` 공개 메소드로 승격. 부수로 D-3(retract가 깊은 인덱스부터인 이유)/D-10(두 패스를 명시하는 진짜 이유는 순서를 못 믿어서가 아니라 이식성)/LH-8(자기 아래 vs 자기 위)을 코드 예시로 풀어 썼고, TW-16으로 HUMAN_TODO에 initValue 항목을 신설했다. Co-authored-by: qwreey --- .claude/base/attribute-plan.md | 49 +++++++++++++--- .claude/base/blocker-plan.md | 10 ++++ .claude/base/dispatch-core-plan.md | 81 +++++++++++++++++++++++--- .claude/base/effect-plan.md | 14 ++++- .claude/base/lifecycle-hooks-plan.md | 48 ++++++++++++++++ .claude/base/lifecycle-pattern.md | 83 +++++++++++++++++++++++++-- .claude/base/modifier-plan.md | 24 +++++++- .claude/base/module-lifecycle-plan.md | 6 +- .claude/base/ref-plan.md | 20 +++++-- .claude/base/slot-plan.md | 80 ++++++++++++++++++++------ .claude/base/tween-plan.md | 31 ++++++++++ .claude/base/ui-shorthand-plan.md | 55 +++++++++++++----- .claude/question.md | 12 +--- .claude/todos.md | 6 +- HUMAN_TODO.md | 24 ++++++++ 15 files changed, 470 insertions(+), 73 deletions(-) diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 64c891a..c1c92dd 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -239,9 +239,14 @@ end 그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다. - **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹 객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다. - - **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지 - (`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존 - `nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠. + - **[해소, 2026-08-20 구현 전 QA 4라운드 `AT-11`] 이름은 `groupClaimKeys`로 + 확정**(사용자: *"groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길 + 바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을 + 잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가 + "그룹이 claim한 키들"을 가리켜 역할이 드러남. + - **⚠️ 키 설계는 여전히 미정** — 확정된 건 이름뿐이고, `(inst, groupValue) → k` + 인지 `groupKey` 단위인지, 그리고 `nameClaims`와 어떤 순서로 확인하는지는 + M10 구현 전에 정한다. `question.md` 참고. - **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) — 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹 @@ -298,9 +303,16 @@ Store 필드 여러 개를 각각 `[AttributeKey<> "name"] = store.name`으 ``` Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음 +-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] plain 테이블의 값은 raw T뿐 아니라 +-- State/Source도 그대로 됨 — {count = 3, label = someSource, live = state} +-- 새 배선이 아니라, 이 값들이 결국 단일 키 경로로 위임돼 StoreBind가 언랩하기 때문 Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김 attr:NameMap(): {[string]: Source} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것) +-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책: +-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다** +-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의 +-- 정책이고 생성자엔 적용되지 않음 — 겹침을 막고 싶으면 Attribute.Merged를 쓸 것 Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일) ``` @@ -324,6 +336,15 @@ Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자 시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시 error)과 결이 같음. - `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**. +- **[명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 `Attribute(a, b, ...)` + 자신도 "뒤가 이김"이다** — 사용자 확인 요청(*"그리고 뒤에 오는기 이기는 것 + 또한, 잘 명시되어있나 봐야함"*)으로 점검한 결과, 위 API 목록이 `Merged`/ + `Overridden`의 정책만 적어두고 **생성자 자체의 겹침 정책은 어디에도 안 + 적혀 있었다**. `Frame { Tag("a"), Tag("a") }`처럼 배열에 여러 개 나란히 + 놓는 것과 달리 생성자는 인자들을 **하나의 이름 맵으로 평탄화**하므로 겹침 + 판정이 반드시 필요하고, 배열 순서 규칙(`base/modifier-plan.md`의 "Merge + 우선순위" — 나중 것이 우선)과 결을 맞춰 **뒤가 이기는 것**으로 명시한다. + 겹침을 실수로 보고 싶으면 `Attribute.Merged`를 명시적으로 쓰면 된다. - **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지 `Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기, `Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선 @@ -400,12 +421,22 @@ end 그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회 실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미 "값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목) - 결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이 - 그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`만 - 불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴 - 위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또 - 들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는 - 실제로 비용이 문제될 때 재검토. + 결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **[전면 정정, 2026-08-20 구현 전 QA 4라운드 `AT-20`] "체인이 그룹 + 전용이 된 지금은 이론상 최적화도 가능하지만 부품이 늘어나서 안 한다"는 + 옛 서술은 틀렸다 — 애초에 최적화가 성립하지 않는다.** 사용자 판정: + *"생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지 + 비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기 + 때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님."* + - **`Tag`와 결정적으로 다른 지점**: `Tag`는 자원이 **이름 집합**이라 + "이름이 그대로면 할 일 없음"이 성립하지만, `Attribute`는 이름이 그대로여도 + **그 이름에 붙은 값이 바뀌었을 수 있다** — 이름 목록 비교만으로는 아무것도 + 못 건너뛴다. + - **값을 비교하려면 `:Get()`을 해야 하고 그건 금지**(위 "값 비교(`:Get()`으로 + old/new 비교)는 안 함" 항목) — State 계약상 캐시 비교를 하면 안 되고, + 비교하려면 이전 값까지 따로 들고 있어야 한다. 즉 "부품이 늘어난다"가 + 아니라 **설계 원칙 위반이라 애초에 못 한다**. + - 그래서 **균일 철거→재등록이 유일한 경로**이고, 비용(재구독 + 같은 값 + `setAttribute` 1회)은 수용한다. - **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가 자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음. diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index a361415..54a83cc 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -139,6 +139,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의 과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md` "RC-1" 절에 논의 경위 기록). + **[보강, 2026-08-20 구현 전 QA 4라운드 `BK-9`] "영원히 안 만든다"로 못박은 + 건 아니다 — 지금 사용 케이스가 없을 뿐인 백로그다.** 사용자 판정: *"있는게 + 어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임 … 나중에 사용 필요 요구가 + 나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가 + 신설될 가능성이 존재하지 않는다고 못 박기는 이름."* 즉 (a) 구현 난이도가 + 낮고, (b) 신설된다면 이름은 `HasBlocked`가 아니라 **`HasBlockedState`** + 쪽이 될 가능성이 높으며(그 Blocker에 걸린 gated state 중 대기 중인 게 + 있는가 = "블록된 state가 있는가"라 이름이 더 정직함), (c) 실제 요구가 + 나오기 전엔 안 만든다 — `conventions.md`의 "드문 오용이나 가상의 미래 + 요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙 그대로. ## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index fb1959a..dafb5d0 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -83,6 +83,35 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 "Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접 자원**(Observer 구독 등)만 정리하면 됨. + > **[용어 풀이, 2026-08-20 구현 전 QA 4라운드 `D-3`] "깊은 인덱스"가 뭘 + > 뜻하는가 — 그리고 설치와 철거는 실제로 반대 방향이 맞다.** + > "깊다"는 **인덱스 숫자가 크다**는 뜻이다(트리 깊이가 아니라 **같은 + > `(inst,k)` 체인 안에서의 재귀 깊이**). `State>`를 예로 들면: + > + > ``` + > (inst, k) 체인 + > index 1 : StoreBind ← 바깥 State를 구독. "얕음" + > index 2 : StoreBind ← 안쪽 State를 구독(바깥이 재귀로 만든 것) + > index 3 : TagHandler ← 최종 Tag를 실제로 반영. "깊음" + > ``` + > + > - **설치(`Dispatch.process`)는 1 → 2 → 3 순** — 사용자가 짚은 그대로. + > 각 레벨이 값을 한 겹 벗겨 `index + 1`로 재귀하므로 인덱스가 커지는 + > 방향으로 진행한다. + > - **철거(`Dispatch.retractFrom`)는 그 반대인 3 → 2 → 1 순이 맞다** — + > 아래 "Dispatch 체인" 절의 의사코드가 `for i = #list, index, -1`로 + > **꼬리부터 역순**으로 돈다. 즉 사용자가 되물은 *"달라질 때 5, 4, 3, 2 + > … 순이 되는건 아니지?"*의 답은 **"맞다, 그 순서가 된다"** 이다. + > - **왜 반대여야 하는가**: index 2의 `StoreBind`가 index 3의 + > `TagHandler`를 *만들어낸* 주체다. 만든 쪽을 먼저 지우면 만들어진 + > 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것과 같은 + > 이유(LIFO). 그래서 각 핸들러의 retractor는 자기가 재귀로 만든 하위 + > 인덱스를 쫓아갈 필요가 없다: **자기 차례가 왔을 땐 이미 아래가 다 + > 비어 있다.** + > - **주의 — "3 → 2 → 1"은 한 `(inst,k)` 체인 안에서의 이야기다.** + > 서로 다른 키(`(inst,k1)` vs `(inst,k2)`)는 완전히 별개 배열이고 + > 서로의 순서와 무관하다(아래 "인덱스의 의미" 항목). + 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, 첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라 열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치 @@ -152,10 +181,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 `base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고, 다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시 이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다. - `Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게 - 항상 호출 가능한지) 구현 시 정할 것. - 그리고 `Dispatch.listHandlers()`류 - 함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함. + **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`] `Dispatch.listHandlers()`는 + `Quad.debug`와 무관하게 항상 호출 가능하다** — 옛 서술은 "같은 디버그 표면에 + 속하는지 구현 시 정할 것"으로 열어뒀으나, 사용자 판정으로 닫힘: *"listHandlers + 는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고 + 싶다면 출력하는 용도임."* 즉 이 함수는 **아무것도 출력하지 않고 목록을 반환만 + 하는 순수 조회**이고, 찍을지 말지는 호출한 쪽이 정한다 — 게이팅이 필요한 건 + "라이브러리가 스스로 콘솔에 쓰는" 동작(동률 경고 print)뿐이라 조회 함수는 + 애초에 그 대상이 아니다. + 그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를 + **반환**하는 함수로 둔다. 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 `quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의, @@ -353,10 +388,31 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은 **서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이 아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가 - 프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제 - Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 - 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 - 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). + 프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. + **[실측 완료, 2026-08-19 M0]** `luau-test/done/01-two-pass-array-hash-order.luau`가 + 두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저, + array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던 + 항목은 닫혔다. + + **⚠️ [혼동 방지, 2026-08-20 구현 전 QA 4라운드 `D-10`] 이 실측은 "Luau가 + 이 순서를 주는가"를 확인한 게 아니다.** 사용자 지적대로 **Luau의 일반화된 + 반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건 + 의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도 + 하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`). + **그런데도 base 드라이버가 두 패스를 명시적으로 강제하는 이유는 순서를 + 못 믿어서가 아니라 두 가지다**(위 (1)/(2)번 그대로): + 1. **이식성** — 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 + 아닌 자료구조로 표현할 수 있고, 그러면 "Lua 테이블의 순회 동작"이라는 + 근거 자체가 없어진다. 계약을 드라이버가 직접 들고 있어야 한다. + 2. **어차피 구분 비용이 이미 든다** — 숫자 키와 문자열 키를 다른 의미로 + 처리해야 하므로, 순서까지 명시적으로 고정하는 건 거의 공짜다. + + 즉 스파이크 `01`이 검증한 건 **"우리가 짠 두 패스 드라이버가 계약대로 + 도는가"**이지 언어 동작이 아니다 — 그 스파이크 주석도 "사용자가 이미 + REPL로 확인했었지만(우연한 관찰), base는 이 우연한 동작에 기대지 않고 + … 두 패스를 명시적으로 강제하기로 확정함"이라고 같은 구분을 적어두고 있다. + **`nil`-hole로 배열 파트가 통째로 해시 취급이 되는 케이스**(구멍 있는 + 테이블)는 이것과 별개 문제이고 `02`/`06` 스파이크가 담당한다. ### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정) @@ -1588,7 +1644,14 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n **`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11 세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가 `setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를 -`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을 +`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`] +마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** — +사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이 +나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상 +읽을 수 있는 `Source`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막 +값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인 +다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치" +항목. 위 정정대로 이 값을 `LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로, `:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md` 참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 02c2945..9640db0 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -155,7 +155,19 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는 **`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를 부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나, - 최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe + 최소한 그 경로에서 cleanup을 앞당기면 안 된다. + **[강화, 2026-08-20 구현 전 QA 4라운드 `E-11`] "안 되거나/최소한"이 아니라 + Observer와 정확히 같은 규칙으로 통일한다 — leaf 바인딩된 핸들에는 + `:Unsubscribe()`가 아예 안 먹는다.** 사용자 지적: *"옵저버에선 leaf + 바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는 + Unsubscribe 안 먹어야 하는거 아님?"* — 맞다. `Observer`의 + `:Unsubscribe()`가 전역 경로 전용이고 leaf 해제는 `unbindLifetime`이 + 담당한다는 게 이미 확정된 규칙인데(`base/source-state-plan.md`의 "이중 + 바인딩 금지" 절), `Effect`만 애매하게 열어두면 두 프리미티브의 규칙이 + 갈린다. **`State` 재-dispatch와의 상호작용도 이 통일로 같이 + 닫힌다** — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹으므로, + 아래 dedup 시나리오(값이 안 바뀌어 retract가 no-op인데 cleanup만 + 앞당겨져 Effect가 조용히 죽는 것)가 발생할 경로 자체가 없어진다. 사용자 판정: *"subscribe 한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선 그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."* - **왜 위험한가**: leaf 바인딩 + `State`/`State` diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index d8d5e53..898c79f 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -284,6 +284,34 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 것보다는 여전히 먼저** 불림. 이 캐비엇의 정본 서술은 `base/ref-plan.md`의 "`PostRef`" 절 "보장 범위" 항목. +**[풀어쓰기, 2026-08-20 구현 전 QA 4라운드 `LH-8`]** 위 두 문단이 너무 +압축돼 있어 그것만 읽고는 판단이 안 된다는 지적이 있어서, 같은 내용을 +코드로 다시 적는다. `Frame { Frame { TextLabel {} , OnRendered(fn) } }`에서 +안쪽 `Frame`의 `fn`이 불릴 때: + +``` +바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함 + └ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중 + ├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음 + └ OnRendered(fn) ← ③ fn이 여기서 불림 + (프로퍼티/이벤트) ← ② 이미 전부 세팅됨 +``` + +- **①② = "자기 아래"** — 안쪽 `Frame`의 자식(그리고 그 서브트리 전체)과 + 자기 프로퍼티/이벤트. `fn`이 불릴 때 **전부 끝나 있다.** +- **⑤ = "자기 위"** — 안쪽 `Frame`이 바깥 `Frame`의 자식이 되는 일. + Lua 표현식 평가 순서상 **안쪽 `Frame{...}` 호출이 완전히 끝나야** 바깥 + `Frame`의 props 테이블이 완성되므로, `fn`이 불리는 시점엔 바깥 `Frame`은 + 아직 존재하지도 않는다. 조상 체인 전체가 마찬가지다. +- **그래서 "화면에 올라간 뒤"가 아니다** — 화면에 올라가려면 루트까지의 + 조상 체인이 다 이어져야 하는데 그건 ⑤ 이후 일이다. React + `componentDidMount`(DOM 삽입 **후**)를 기대하면 어긋난다. +- **원래 뭐가 헷갈렸나**: 처음엔 경계를 "이 인스턴스의 프로퍼티만이냐(a), + 자식 서브트리까지냐(b)"로 놓고 "(a) 메커니즘으론 (b)를 못 준다"고 + 판단했는데, 배열 파트 루프가 각 자식을 **동기적으로 끝내고** 넘어가므로 + ①이 공짜로 따라온다 — (a)/(b)는 애초에 갈리는 지점이 아니었고, 실제로 + 갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다. + **착수 시점 선택지 — (a) 확정.** 원래 (a)/(b)/(c) 셋을 열어뒀었고 ((b)는 일반 `Ref`로 근사, (c)는 계속 스코프 아웃), 사용자가 **(a)** (위 `PostRef` 스케치대로 두 패스 뒤 `postRefList` 소비)를 선택함. @@ -336,6 +364,26 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 기대하는 것과 미묘하게 다른 시점"**을 가리키므로, 문서화 시 위 ① 절의 ⚠️ 항목을 반드시 같이 노출할 것(이름을 바꾸는 대신 문서로 대응하기로 한 것 — 이름의 친숙함이 주는 이득이 더 크다는 판단). + **[백로그 신설, 2026-08-20 구현 전 QA 4라운드]** 진짜 + `componentDidMount`(= 조상 체인까지 이어져 화면에 실제로 올라간 뒤)에 + 해당하는 훅은 **지금 만들지 않되 백로그로 남긴다.** 사용자 메모: + *"OnRendered 라는 이름이 componentDidMount 같은거 구현 가능하면 좋긴 + 하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가 + 하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을 + 이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고, + 나중에 필요하다는 의견이 나오면 재생각 해볼 예정."* + - **쓸모 있는 시나리오**: "화면에 그려지기 전에 애니메이션이 시작돼버리는" + 것을 막는 것 — 진입 애니메이션(`base/tween-plan.md`의 `initValue`, + 루트 `HUMAN_TODO.md` 10번)과 맞닿는 자리다. + - **왜 지금 안 만드는가**: `PostRef`와 달리 **자기 위**를 알아야 하므로 + `Dispatch.drive` 한 번의 콜스택 안에서 표현할 수가 없고(부모의 drive가 + 자식을 붙인 뒤에야 알 수 있음), 별도 전파 경로가 필요하다 — "공짜"가 + 전혀 아니다. 그런데 구체적 사용 사례가 아직 안 보이므로 + `conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고 + 구조를 복잡하게 만들지 않는다" 원칙대로 보류. + - **요구가 나오면 재검토** — 그때 `OnRendered`와 이름을 어떻게 가를지도 + 같이 정해야 한다(지금 `OnRendered`가 이미 그 이름을 점유 중이라 + 혼동 위험이 있음). ## 패키지 배치 diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index cd6d1f9..cc30f26 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -40,8 +40,32 @@ end ``` 연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를 -바로 지우거나 재구성하지 않음. quad-v2도 이 모양을 그대로 채택: 라이프타임 -홀더는 "내가 아직 살아있게 하는 뒷받침 참조"가 nil인지만 확인하면 됨. +바로 지우거나 재구성하지 않음. + +> **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `LP-1`] quad는 이 rbvm 코드를 +> 채택하지 않는다 — quad에서 `Connected`는 "계산된 속성"이 아니라 그냥 Roblox +> `RBXScriptConnection`의 네이티브 필드다.** 옛 서술("quad-v2도 이 모양을 그대로 +> 채택: 라이프타임 홀더는 '내가 아직 살아있게 하는 뒷받침 참조'가 nil인지만 +> 확인하면 됨")은 rbvm의 프록시 계층 사정을 quad에 잘못 옮긴 것이었다. 사용자 +> 판정: *"Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시 +> 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로 +> 참조를 만들 이유도 없음."* +> +> **quad의 실제 판정은 두 상태뿐**(`isBoundAlive`, 아래 "(1)" 코드 블록): +> 1. **gcconn 자체가 없음** — 아직 바인드 안 됐거나, 이미 GC돼서 weak 릴레이션 +> 항목이 비워진 상태. `BindData:GetWeak(value, "gcconn")`이 `nil`. +> 2. **gcconn은 있는데 `.Connected == false`** — `inst`가 방금 Destroy됐고 +> 아직 GC는 안 된 구간. 엔진이 Destroy 시점에 모든 커넥션을 끊어주므로 +> quad가 아무것도 안 해도 이 값이 저절로 뒤집힌다. +> +> **quad가 `Signal = nil`처럼 직접 참조를 끊는 자리는 없다** — 유일하게 "직접 +> 끊는" 동작인 `unbindLifetime`도 `gchold[value] = nil`과 `BindData` 항목 제거일 +> 뿐 커넥션 자체를 만지지 않는다. rbvm에서 실제로 가져오는 건 **"gcconn 트릭으로 +> Instance 수명에 값을 매단다"는 관용구 하나**이고, `Connected`를 계산 속성으로 +> 만드는 구현은 가져오지 않는다. + +rbvm에서 실제로 재사용하는 부분은 아래 "(0)"/"(1)" 절의 gcconn/gchold +관용구이고, 위 `__index` 계산 속성 코드는 **참고용 원본 인용**으로만 남긴다. ### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측 @@ -50,6 +74,14 @@ rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로 플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀. quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일. +**[구체화, 2026-08-20 구현 전 QA 4라운드 `LP-2`] "예상보다 적을 수 있다"가 아니라 +지금은 정확히 한 곳뿐이다 — `Effect`.** 아래 "2026-08-04 검증 라운드에서 보강된 +내용" 절이 "이 훅을 쓰는 지점이 예상보다 적을 수 있다"고만 열어뒀던 걸 사용자가 +확정해줌(*"당장은 Effect 뿐임"*). `Effect`의 leaf-death cleanup(`base/effect-plan.md`)이 +이 훅을 쓰는 유일한 소비자이고, 그 위의 슈가 `OnDestroyed`(`base/lifecycle-hooks-plan.md`)도 +결국 같은 경로다. 나머지(Observer 게이팅, Tag/Attribute 정리, Tween 취소)는 전부 +gcconn `Connected` 판정이나 엔진 자체 정리로 커버되어 이 훅을 안 씀. + ### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager) rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를 @@ -324,6 +356,40 @@ function canExecute(value) end ``` +#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`) + +**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서 +gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가 +이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 — +`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey, +observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다 +(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기 +자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계). + +**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의 +bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong +으로 gchold 비슷한걸 수행하면 됨."* + +- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로 + 구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고, + Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의 + 필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을 + 되물을 수 있는 근거**를 백엔드가 제공하면 된다. +- **왜 gcconn을 못 쓰는가**: gcconn 트릭은 + `inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌 + 값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이 + reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로 + "Slot이 죽으면 기록도 같이 사라진다"가 성립한다. +- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는 + gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned + 바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로 + 잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이 + 세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함. +- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드) + 이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로 + 둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안 + 된다"는 요구사항 자체**뿐. + **`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부): 1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]` @@ -513,9 +579,16 @@ quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런 Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서 정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서 이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base -관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 `[Event -"Destroying"]`을 직접 바인드해서 처리하면 되는 구조라, 라이브러리가 강제로 -제공할 필요도 없음. +관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 **`Effect`(그리고 그 슈가 +`OnDestroyed`)를 쓰면 되는 구조**라, 라이브러리가 강제로 제공할 필요도 없음. +**[정정, 2026-08-20 구현 전 QA 4라운드 `LP-4`]** 옛 서술은 여기 정상 경로를 +`[Event "Destroying"]`을 직접 바인드하는 것으로 적었는데, 그건 사용자가 엔진 +이벤트를 손으로 다루라는 뜻이 되어 quad가 이미 제공하는 프리미티브를 우회하는 +안내였다 — 사용자 판정: *"Effect 임. 그리고 그 슈거인 OnDestroyed 존재"*. +`Effect(fn)`이 반환하는 cleanup이 leaf 사망 시 정확히 1회 불린다는 계약 +(`base/effect-plan.md`)이 정확히 이 용도이고, `OnDestroyed(fn)`은 그걸 감싼 +순수 팩토리다(`base/lifecycle-hooks-plan.md`). `[Event "Destroying"]`을 직접 +바인드하는 것도 물론 막히진 않지만 권장 경로가 아니다. ## 이름: `cleanup` → `retract` diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 6214449..df878ae 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -84,6 +84,20 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 **디스패치 단계**에서 풀린다: +- **⚠️ [명시 추가, 2026-08-20 구현 전 QA 4라운드 `M-5`] `None`만이 명시적 + unsetter다 — `mod:TextColor3(nil)`은 "지운다"가 아니라 "그 필드가 아예 없는 + 새 Modifier를 만든다"이다.** 사용자 지적: *"mod:TextColor3(nil) mod 그룹에서 + 제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임."* 둘의 관측 가능한 + 차이는 **`Overridden`/flatten에서 갈린다**: + - `mod:TextColor3(nil)` → 그 필드가 **없는** Modifier. 나중에 + `Modifier.Overridden(other, mod)`로 합치면 `other`의 `TextColor3`가 **그대로 + 살아남는다**(덮어쓸 값 자체가 없으므로). + - `mod:TextColor3(None)` → 그 필드가 **`None`이라는 실재값으로 채워진** + Modifier. 같은 `Overridden`에서 `other`의 값을 **이긴 뒤**, 디스패치 단계의 + `NoneHandler`가 실제로 지운다. + - `:Peek(key)`도 이 둘을 구별해서 돌려준다(`nil` vs `None`) — 위 + "`:Peek`의 반환 타입" 항목이 `T | State | None | nil`인 이유가 정확히 + 이것. 구현은 이미 이렇게 되겠지만 **문서화에서 이 구분을 반드시 짚을 것**. - **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.** Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw 값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이 @@ -152,7 +166,15 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴, 이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분. -**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가 +**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** +**[관계 명시, 2026-08-20 구현 전 QA 4라운드 `M-9`] 다만 아래 9번 절의 +`:Peek(key)`가 사실상 getter 역할을 한다** — 사용자 지적(*"사실 Peek 가 +게터라고 봐도 되긴 함"*). 이 절이 "안 만든다"고 한 건 **setter와 짝을 이루는 +필드별 getter**(`:FontSize()` 무인자 호출로 값을 꺼내는 모양, 또는 dot-access +겸용)이고, `:Peek`은 그것과 달리 **키를 인자로 받는 단일 범용 접근자**이며 +설계 동기도 다르다(setter 인체공학이 아니라 `Apply` 팩토리가 현재 상태를 +읽어야 해서). 두 서술이 모순이 아니라는 걸 문서화 시 같이 밝힐 것 — +"getter가 없다"만 읽으면 `:Peek`의 존재와 부딪히는 것처럼 보인다. 애초에 getter가 필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로 다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)` 변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/ diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index dea2ac6..2a60ea1 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -253,8 +253,10 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 게 기본이어야 함. 켜는 건 명시적 opt-in. - **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑" 절과 같이 정할 것. -- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도 - 같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임). +- **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`는 + 이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고 + 스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다. + 상세는 `base/dispatch-core-plan.md`의 "우선순위 동률/매치 실패 처리" 절. ## Quad는 스크립트인가 라이브러리인가 (확정, 참고용) diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index f974a1e..7b1fb39 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -150,10 +150,22 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능 (`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백, - `nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가 - 아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는 - 등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그 - 자리를 되찾아 쓸 수 있음. + `nil` → 빈 슬롯이라 스킵). **[정정, 2026-08-20 구현 전 QA 4라운드 `R-11`] + 새 콜백/대기자 등록은 그냥 `table.insert`를 쓴다** — 옛 서술은 "`table.insert`가 + 아니라 비어있는 첫 슬롯을 선형 탐색해 재사용하는 등록 함수"였는데, 사용자 + 판정으로 뒤집힘: *"table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에 + 넣어주기에 table.insert 가 맞음 … None 으로 바꾸면 무한정 불어나지만, + nil이면 그렇지 않음."* + - 즉 소진으로 생긴 구멍을 `table.insert`가 알아서 되찾아 쓰므로, 별도 + 선형 탐색 등록 함수를 손으로 만들 이유가 없다. 아래 "왜 `None`이 아니라 + `nil`인가" 절이 들던 "`table.insert`의 `#t`가 구멍 있는 테이블에서 + 미정의"라는 회피 근거도 이 배열에 대해서는 실무상 성립하지 않는 것으로 + 정정 — 그 절의 결론(`nil` 소진)은 그대로이고 **우회 방법만 단순해짐**. + - **⚠️ 실측 대상(M0/M8)**: 구멍 있는 테이블에서 `#t`가 반환하는 border는 + Lua 명세상 "어떤 border든" 이므로, "항상 첫 `nil` 자리"가 Luau 구현에서 + 실제로 그렇게 나오는지는 스파이크로 확인할 것 — `luau-test`에 등록 + 함수/소진을 반복하는 케이스를 추가. 결과가 다르면 이 항목만 되돌리면 + 되고(옛 선형 탐색 버전), 나머지 설계엔 영향이 없다. - **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정) — 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안 맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index e2ffcd0..41eaa34 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -91,12 +91,15 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " 붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 — 특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에 - Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })` — **여기서 `Frame`은 - `Ref`라는 named 파라미터를 받는 컴포넌트 함수다.** Instance 리터럴에 - `Ref = ...`를 named 키로 놓는 건 leaf 바인딩이 아니고, 실제로는 - `HANDLER_PRIORITY_FALLBACK` 가드가 잡아 에러를 낸다 — leaf 바인딩은 - 배열(숫자 키) 전용, `base/ref-plan.md`. **[명시 추가, 2026-08-18 구현 전 - QA]**). + Ref를 넘기면 됨(`slot:Add(MyComponent { Ref = myRef })` — **여기서 + `MyComponent`는 `Ref`라는 named 파라미터를 받는 사용자 컴포넌트 함수다.** + **[예시 이름 정정, 2026-08-20 구현 전 QA 4라운드 `SL-4`]** 예전엔 이 자리를 + `Frame {...}`으로 적었는데, `Frame`은 코퍼스 전반에서 **인스턴스 리터럴**을 + 가리키는 이름이라 "리터럴의 named 키에 Ref를 놓아도 된다"로 읽히는 + 정반대 오해를 부른다 — 실제로 인스턴스 리터럴에 `Ref = ...`를 named 키로 + 놓으면 leaf 바인딩이 **아니고** `HANDLER_PRIORITY_FALLBACK` 가드가 잡아 + 에러를 낸다(leaf 바인딩은 배열 숫자 키 전용, `base/ref-plan.md`). + 컴포넌트 함수임이 이름에서 바로 드러나도록 예시 이름을 바꿈). - **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종 마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상 `T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라 @@ -1690,7 +1693,9 @@ local function unmountSlotTree(slot) end end slot._mounted, slot._mountedInst = false, nil - slot.Offset = nil -- 마운트 전 상태로 복원(위 "`Slot.Offset`은 마운트 전엔 nil") + -- [정정, 2026-08-20 `SL-75`] slot.Offset은 건드리지 않는다 — nil로 되돌리면 + -- 그 Source를 이미 구독 중인 다운스트림이 영구히 끊긴다(포탈이 깨짐). + -- stale한 채 남겨두고, 재마운트 시 setOffsetSource의 즉시 계산이 덮어쓴다. -- slot 자신의 unbindLifetime / releaseOwner / owner쪽 setLength·setOffsetSource는 -- 호출부 몫 — destroySlotTree와 동일한 층위 분리. end @@ -2079,8 +2084,16 @@ end Slot처럼 "죽는 순간 `elementOwner`/`lengthList`/`sourceList`가 어긋나는" 트리 부기 자체가 없음. 즉 dispose가 막으려는 문제(부기 붕괴)가 Observer/Effect에는 원천적으로 발생하지 않음 — 아무도 안 들고 - 있으면 그냥 GC, 조기에 끊고 싶으면 `unbindLifetime`으로 충분하고 - `dispose`가 다룰 이유가 없음. + 있으면 그냥 GC. **[정정, 2026-08-20 구현 전 QA 4라운드 `SL-72`] 조기에 끊는 + 사용자 경로는 `unbindLifetime`이 아니다** — 옛 서술은 "조기에 끊고 싶으면 + `unbindLifetime`으로 충분"이라 적어 이걸 사용자 API처럼 안내했는데, 사용자 + 판정: *"우린 조기에 끊는걸 명시적으로 unbindLifetime 로 지원하지 않음. 그건 + 유저에게 드러나는 표면이 아니고, State 를 사용하는게 적절."* + `bindLifetime`/`unbindLifetime`은 **Handler 작성자용 내부 배관**이고 + (`base/lifecycle-pattern.md`), 사용자가 leaf에 붙은 Observer/Effect를 끄고 + 싶으면 그 자리를 `State`로 두고 `nil`을 emit하면 된다 — 그러면 + 기존 하강 diff가 알아서 이전 것을 retract한다(새 경로 불필요). 어느 쪽이든 + `dispose`가 다룰 이유가 없다는 결론은 그대로. **주의 — Modifier 필드/`Slot:Add`·`:List` 원소 금지 규칙("핸들러 계층 값이 들어오면 즉시 error")과 헷갈리지 말 것.** 그건 Modifier 필드나 Slot의 CRUD 원소 자리에 관한 별개 규칙이고, children 배열의 leaf 위치(정적 @@ -2119,9 +2132,24 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 `Source` 전용 변형이 필요한지 같이 정해야 한다. 2. `Source`에 콜론 메서드로 직접 얹기(`source:SetAndDispose(new)`). -**미결**: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지. -`state:Apply`의 시그니처(`(State) -> U`)에 영향이 갈 수 있으므로 **M3 -착수 전에 방향만이라도 정해둘 것**. `question.md`에 올려둠. +**[해소, 2026-08-20 구현 전 QA 4라운드 `SL-74`] 2번(`Source`의 콜론 메서드)으로 +확정 — `state:Apply` 시그니처엔 영향 없음.** 사용자 판정: *"타입 문제 때문에 +Apply 를 오버라이딩 해서 source 타입을 함수에 건내주는건 못함. 그럼 source -> +state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 못함. 따라서 영향이 +안 가고, 그냥 SetAndDispose() 로만 Set() 와 세트로 주는게 나아보이고, 그걸로 +확정지어야할것 같다는 생각임."* + +- **1번(`source:Apply(SetAndDispose(new))`)이 기각된 이유는 타입이다** — + `state:Apply(factory)`는 `factory`에 `State`를 넘기는 것으로 이미 확정돼 + 있는데(`base/source-state-plan.md`), `Source` 전용으로 오버라이딩하면 같은 + 이름이 리시버 타입에 따라 다른 걸 넘기게 된다. `Source`가 `State`를 + **단방향으로만** 만족하므로 그 반대 방향(넘겨받은 게 `Source`임을 보장)은 + 안전하게 성립하지 않는다 — 이름을 그대로 재사용할 수가 없음. +- **확정 형태**: `source:SetAndDispose(value)` — `:Set(value)`와 **한 세트로 + 묶인 `Source` 전용 콜론 메서드**. `Set`(언마운트) → 옛 값 `dispose` 순서를 + 안에서 수행하므로 호출부가 `Get()`으로 옛 값을 미리 잡아둘 필요가 없다. +- **`state:Apply`는 손대지 않는다** — 시그니처 영향이 없으므로 M3 착수 전 + 결론이 필요하던 항목에서 빠진다(`question.md`/`.claude/todos.md`에서 제거). #### 구현상 바뀌어야 하는 것 @@ -2151,7 +2179,8 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`, -`_mounted`/`_mountedInst` 복원, `slot.Offset = nil`). 옛 owner에 등록해둔 +`_mounted`/`_mountedInst` 복원 — **[정정, 2026-08-20 `SL-75`] `slot.Offset`은 +안 건드림**). 옛 owner에 등록해둔 `Dispatch.setLength`/`setOffsetSource` 해제는 호출부 몫(아래). **[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에 @@ -2188,10 +2217,27 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 오류로 이어지진 않지만, 방어적으로 순서를 고정. **추가 방어 조치**: -- **해제 시 `slot.Offset = nil`도 같이** — 이 문서의 "`Slot.Offset`은 - 마운트 시점에 세팅되고 마운트 전엔 `nil`" 규칙과 짝을 맞춤. 안 그러면 - 떼어낸 Slot이 옛 owner 기준의 stale한 `Offset`을 계속 공개해, 그걸 - 읽는 사용자 코드가 조용히 틀린 값을 씀. +- **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `SL-75`/`D-60`] 해제 시 + `slot.Offset`을 `nil`로 되돌리면 안 된다 — stale한 채로 그냥 둔다.** + 옛 서술은 "해제 시 `slot.Offset = nil`도 같이"였는데, 그러면 **포탈(언마운트 + 후 다른 곳에 재마운트)이 깨진다** — 사용자 판정: *"nil 로 만들면 안 되는게, + 포탈로 옮기는게 안 됨. 이미 offset 을 들고 가 바운딩 했다면 큰 문제가 생김. + 그냥 stale하게 있는게 맞고, 나중에 offset이 멀쩡히 다시 설정되는게 옳음. + 언마운트 시 offset stale 은 단순히 맞는 행동이고, 처음 생성 시 0 인것과 유사 + 동작임."* + - **누가 이미 그 `Source`를 구독하고 있을 수 있다** — `updateFn`이 + `layoutOrder:With(offset):Compute(...)`처럼 `offset`을 파생 그래프에 엮어둔 + 상태에서 필드를 `nil`로 갈아치우면, 그 구독은 옛 Source를 계속 보는데 + Slot은 새 Source를 만들어 등록하게 되어 둘이 영영 갈라진다. 재마운트가 + "값이 다시 정상으로 채워지는" 일이 아니라 "연결이 끊긴 채 조용히 멈추는" + 일이 되어버림. + - **stale한 값 자체는 위험하지 않다** — 언마운트 상태의 `Offset`은 그냥 + "마지막으로 알던 위치"이고, 재마운트되면 `setOffsetSource`의 즉시 계산이 + 올바른 값으로 덮어쓴다. 이건 **처음 생성 시 `0`인 것과 같은 성격의 + 잠정값**이지 오염이 아니다. + - **따라서 `Slot.Offset`은 "마운트 전엔 `nil`"이 아니다** — 아래 + "`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드" 관련 서술과 + `unmountSlotTree` 의사코드도 이 정정에 맞춰 갱신됨(같은 라운드). - **`recompute`는 `sourceList[i]`가 `None`이든 `nil`이든 "참여 안 함"으로 똑같이 관대하게 넘어갈 것** — 정상 상태에선 항상 `None`으로 채워지는 게 계약이지만(`nil`은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이 diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index c271d42..149b90c 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -449,6 +449,37 @@ Tween(opts: { }) -> Tween ``` +## `Tween:Map(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설) + +**동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로 +감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이 +`Tween`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다. +그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로 +**`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도 +무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."* + +```lua +tween:Map(fn: (T) -> U): Tween -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환 +``` + +- **타입이 안전하게 성립한다** — `Tween`는 immutable raw 값이고 `Value` 외의 + 필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼 + `Tween`를 만드는 건 타입 레벨에서 깨끗하다. +- **`Tween`가 immutable이라는 기존 확정과 일관** — `:Map`은 원본을 안 건드리고 + `table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의 + clone 체이닝과 같은 계열). +- **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.** + `local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고 + `FAST:Map(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만 + **사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는 + 것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단). +- **이름 — `Map` 또는 `Mapped`.** 코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 + `Added`/`Removed`/`Overridden`처럼 과거분사, `base/source-state-plan.md`의 + "네이밍 — `Compute`가 `-ed`가 아닌 이유" 절)를 그대로 적용하면 **`Mapped`가 + 더 일관적**이다 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로. + 사용자도 둘 다 열어둠(*"혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임"*) + — **⚠️ 최종 이름은 미확정**, 코퍼스 일관성만 보면 `Mapped` 쪽이 맞다. + ## 네임스페이스드 객체 (더 이상 유효한 관심사 아님) 기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 0396a73..15dcc08 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -96,11 +96,21 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든 `UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은 표시·판정용, `Relate`는 조회용**으로 역할이 갈린다. -- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는 - `Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata - 동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`). - 숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지 - 구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다. +- **[해소, 2026-08-20 구현 전 QA 4라운드 `UI-5`] 자식도 반드시 gcconn/gchold + 셋업을 거친다 — 안 거치면 이 숏핸드 자체가 애초에 동작을 못 한다.** 원래 + "구현 시 확인할 것"으로 열어뒀던 항목인데, 사용자 판정으로 닫힘: *"애초에 + 똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터 + 실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든 + gcconn/gchold 가 셋업되는게 맞음."* + - **논거**: 아래 "Tween 지원" 절대로 이 숏핸드는 자식 프로퍼티를 직접 쓰지 + 않고 `Dispatch.process(child, prop, v, 1)`로 위임하는데, 그 값이 + `State`면 `StoreBind`가 `bindLifetime(child, observer)`를 부른다 — 그 + 시점에 `child`에 gcconn/gchold가 없으면 **거기서 곧바로 실패**한다. + 즉 "조용히 미아가 되는" 시나리오가 아니라 **즉시 드러나는 전제 조건**이고, + 숏핸드가 자식을 만드는 경로도 일반 인스턴스 생성과 같은 셋업을 타야 한다. + - **따라서 `ensureManagedChild`는 자식을 만들 때 일반 인스턴스 생성과 + 동일한 경로**(gcconn/gchold 셋업 포함)를 써야 한다 — 별도 확인 항목이 + 아니라 구현이 반드시 만족해야 하는 계약. - **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**: *"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운 dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."* @@ -179,7 +189,14 @@ end State 레이어를 먼저 다 풀어내므로 이 Handler가 실제로 보는 `v`는 `number` 아니면 `Tween` 둘 중 하나. -**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** 숏핸드는 +**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** +**[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`는 +`Tween` 자신의 공개 메소드 `:Map(fn)`(이름 후보 `:Mapped`)으로 올라갔다** — +`base/tween-plan.md`의 "`Tween:Map(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로 +두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는 +게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만 +읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Map(wrap)`, 아니면 +`wrap(v)`): 숏핸드는 "스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`). `v`가 `Tween`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** @@ -215,14 +232,24 @@ PropertyHandler의 "첫 세팅은 애니메이션 없이 즉시"(`prev == nil`) 상황 — 계속 애니메이션되길 원하면 자식이 살아있도록 `nil`로 내리지 말고 값만 바꿀 것. -**자식을 없앨 때의 정리 책임은 이 Handler에 있음** — `v`가 `nil`이 되거나 -retractor가 `nil` 힌트로 불려 자식을 파괴할 때, 실행 중인 엔진 Tween이 -남아있을 수 있으므로 `Dispatch.retractFrom(child, prop, 1)`을 같이 -부르는 게 정석(자식 Instance를 `Destroy`하면 엔진 트윈도 같이 죽고 -`chains`도 weak-keyed라 결국 GC되지만, "즉시" 끊는 건 명시적 호출뿐). -`retractor` 안에서 **다른 키**에 대한 `retractFrom`을 부르는 건 허용된 -경로임(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 -`(inst,k)`에 대한 재진입). +**[정정, 2026-08-20 구현 전 QA 4라운드 `UI-11`] 자식을 파괴할 때 +`Dispatch.retractFrom(child, prop, 1)`을 "정석"으로 요구하지 않는다 — 실익이 +없다.** 옛 서술은 "실행 중인 엔진 Tween이 남아있을 수 있으므로 같이 부르는 게 +정석"이었는데, 사용자 판정: *"자식 파괴 시 사실 Tween 은 엔진에 의해 자동 +멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라 +의미가 없을것이다."* + +- **두 겹으로 무의미하다**: (a) Roblox 엔진이 Destroy 시점에 그 인스턴스에 + 걸린 Tween을 알아서 정리하고(`base/lifecycle-pattern.md`의 "retract는 Destroy 시점에 필요 없는 이유가 + 엔진 레벨에서 한 번 더 보강됨" 절), (b) `PropertyHandler`가 반환하는 retractor는 애초에 + 몸체가 no-op이라(`base/tween-plan.md`의 "왜 `retract`가 더 이상 필요 + 없는가" 절) 불러봐야 하는 일이 없다. +- **그래도 호출이 금지되는 건 아니다** — `retractor` 안에서 **다른 키**에 + 대한 `retractFrom`을 부르는 것 자체는 여전히 허용된 경로다 + (`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 `(inst,k)`에 + 대한 재진입). 다만 이 자리에서 **필요하지 않다**는 것. +- `chains`는 `child`에 대해 weak-keyed라 자식을 버리면 결국 GC된다 — 명시적 + 정리가 필요한 자원이 이 자리엔 없다. ## store-bind — 이 숏핸드도 지원 diff --git a/.claude/question.md b/.claude/question.md index 200b67d..da20231 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -179,16 +179,10 @@ 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 - 하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey` - 단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 — + 하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**. + 남은 건 **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey` + 단위인지)와 기존 `nameClaims`와의 공존 방식 — `base/attribute-plan.md`의 "이름 소유권" 절. -- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** — - `Get()` → `Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게 - 불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))` - (단 이때 `Apply`는 `State`가 아니라 `Source`를 넘겨야 함)와 - `source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번 - 범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것 - (`state:Apply` 시그니처에 영향), `base/slot-plan.md`의 `dispose` 절. - **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 diff --git a/.claude/todos.md b/.claude/todos.md index f7b426f..3e0a206 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -65,8 +65,10 @@ 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — 방향은 확정, 키 설계가 미정. M10 착수 전 필요. - - **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply` - 시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도. + - **[2026-08-20 해소]** `SetAndDispose` 방향 — **`source:SetAndDispose(value)` + 콜론 메서드로 확정**(`:Set`과 한 세트). `state:Apply` 시그니처엔 영향 + 없음(`Apply` 오버라이딩은 `Source`→`State` 단방향 때문에 타입이 안 + 성립해서 애초에 불가). `base/slot-plan.md`의 `dispose` 절. - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` `:Unsubscribe()` 절) — M3 착수 전 확인. - **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면 diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 2711e58..63a9f3f 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -172,6 +172,30 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리 설계/구현 상세는 `.claude/base/quad-types-plan.md`의 "`type-version-check`" 절. +## 10. **[2026-08-20 신설, 안 막음]** Tween 초기 진입 애니메이션(`initValue`) — 에이전트 작업 범위 밖 + +`base/tween-plan.md`가 **"필요해지면 사용자가 직접 코드베이스+문서를 만진다, +에이전트는 임의로 착수하지 말 것"**으로 확정해둔 항목인데, **여기 HUMAN_TODO에는 +그 언급이 없어서 사람 쪽 할 일 목록에서 빠져 있었다**(2026-08-20 구현 전 QA +4라운드 `TW-16`에서 사용자가 지적 — *"틀리진 않았는데, Human todo 에 언급이 +없음"*). 지금 보강. + +- **무엇인가**: 다이얼로그가 아래에서 위로 슬라이드-인하는 것처럼 **첫 마운트에도 + 애니메이션을 원하는 경우**. 지금은 "첫 세팅은 무조건 애니메이션 없이 즉시 + 스냅"(3-상태 릴레이션 슬롯의 `prev == nil` 분기)이 기본이라 이게 안 된다 — + 그 기본값은 "엔진 기본값에서 목표값으로 날아오는 진입 애니메이션 버그"를 + 막으려고 일부러 넣은 것이라, 우회하려면 그 억제 동작과의 상충을 같이 설계해야 + 한다. +- **왜 에이전트가 안 하는가**(그 문서의 근거 그대로): Tween 정보가 부족한 + 에이전트가 다루기엔 `hasBeenSet` 억제 동작과의 상충 판단이 미묘하고, 반대로 + Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부 + `Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데 + 범위상 문제가 없다. +- **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2를 + 막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에 + 사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입 + 애니메이션(`initValue`)" 절. + ## 3. `.claude/question.md`의 **나머지** 항목 검토 (급하지 않음) 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로