diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 76a8c44..bfe9aea 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -178,8 +178,8 @@ quad/ **남은 것**: Slot 코어 로직의 정확한 API(`research`→`base` 승격된 `slot-plan.md` 참고)와 각 파일의 정확한 함수/타입 이름은 구현 단계에서. -Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 -막지 않음(`purity-and-effects-plan.md`는 이미 `base/`로 승격 완료). +existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확정을 +막지 않음(`purity-and-effects-plan.md`/`tween-plan.md`는 이미 `base/`로 승격 완료). ## 코드 스타일 — 네이밍 케이싱 (2026-08-08 두 번째 세션 신설) @@ -227,9 +227,17 @@ Tween/existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 표현식 문법(공식 릴리스 노트: ) — `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이 falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의 -`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **이 프로젝트의 -기본 삼항 표현 방식은 `if-then-else`** — `cond and x or y`는 `x`가 -테이블/숫자처럼 항상-truthy임이 보장될 때만 예외적으로 허용. +`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **[강화, 2026-08-12 +세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`만 +쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그 +경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`가 +항상 더 낫다는 게 재확인됨 — `and`/`or`는 진짜 short-circuit이라 각 +단계 truthiness를 테스트하는 분기가 최대 2번 들어가는데(`cond` 테스트 + +`and`의 결과 테스트), `if-then-else`는 `cond` 하나만 테스트하고 단일 +분기로 끝남 — 방어적이면서 바이트코드상으로도 더 적은 분기. 단순 +2항 fallback(`x or y`, "and" 없이 값 하나를 기본값으로 대체하는 +`props.Modifier or None`류)은 애초에 이 문제가 없어 그대로 유지 — +금지 대상은 어디까지나 `A and B or C` 3항 조합. **`const` 바인딩도 Luau 공식 문법**() 이지만 **지금은 채택하지 않음** — 타입 추출/narrowing 등 주변 툴링이 @@ -296,8 +304,8 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 ## 아직 미정 (research/로 분리됨) -Tween 플러깅, 이미 생성된 인스턴스에 대한 바인드 — `.claude/research/` 각 -문서 참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 +이미 생성된 인스턴스에 대한 바인드 — `.claude/research/existing-instance-bind-plan.md` +참고, 전체 색인은 `.claude/README.md`. 바인드 디스패치/Slot/모듈 라이프사이클/Modifier/컴포넌트화(컴포넌트 경계 modifier/Ref 전달 포함)는 위 "구현 착수" 섹션대로 확정되어 `.claude/base/`로 승격됨 (`bind-system-plan.md`/`module-lifecycle-plan.md`/`slot-plan.md`/ diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 50a1c9b..3565e6d 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -1,7 +1,9 @@ # Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브 **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는 -전부 확정(2026-08-09 열한 번째 세션). **[2026-08-11 아홉 번째 세션 추가]** +전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서 +`retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/ +"그룹 `Attribute(...)`" 절이 최신**). **[2026-08-11 아홉 번째 세션 추가]** Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attribute(...)` 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 `Attribute<>` → `AttributeKey<>`로 리네임(잠정 확정 — 최종 이름은 @@ -103,24 +105,30 @@ end 타입 파라미터화 이름과 무관하게 런타임 동작은 확정: -- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부. - **Attribute는 `None`의 가장 깔끔한 사례** — Roblox API 자체가 - `SetAttribute(name, nil)`을 "그 Attribute 엔트리를 지운다"는 뜻으로 - 네이티브 지원하므로, `None → nil` 재디스패치(`base/bind-system-plan.md`의 - `None` 센티널 절)가 도착했을 때 handler가 **아무 특별 처리도 없이** - `inst:SetAttribute(name, nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 - "만들어둔 자식을 수동으로 찾아 지우는" 로직조차 필요 없음. -- **`retract` 불필요** — Tag와 같은 이유: 값이 뭐든(실제 값/`nil`) 항상 - 같은 `AttributeKeyHandler`가 이 키를 계속 담당(핸들러 *타입*이 안 바뀜). - `retract`가 의미 있는 유일한 패턴("매치되는 핸들러 타입 자체가 바뀜", - `Tag(...)`↔`nil`이 실사례 — 2026-08-10 세션부터 Tween은 더 이상 이 - 패턴의 예시가 아님, `base/tween-plan.md`)에 해당 안 함 — - `bind-system-plan.md` "확정된 디스패치 모델" 절이 한때 Attribute도 - retract 필요 예시로 들었던 걸 여기서 바로잡음. **[정정, 2026-08-12 - 열 번째 세션] 그룹 위임 경로가 생기면서 `retract`가 다시 의미 있어짐 — - 아래 "이름 소유권" 절 참고, 이 문단이 말하는 "값이 뭐든 같은 핸들러"는 - 여전히 맞지만 그룹이 이름을 놓을 때는 그 이름 전용 키 객체 자체가 - 통째로 폐기되므로 그 키 슬롯 기준으로는 `retract`가 실행됨.** +- `process(inst, k, v)` — `inst:SetAttribute(name, v)`가 사실상 전부, + **`v`가 뭐든(실제 값이든 `nil`이든) 무조건 그대로 호출** — 일반 + 프로퍼티 핸들러와 완전히 동일한 무조건 set. **Attribute는 `None`의 + 가장 깔끔한 사례** — Roblox API 자체가 `SetAttribute(name, nil)`을 + "그 Attribute 엔트리를 지운다"는 뜻으로 네이티브 지원하므로, `None → + nil` 재디스패치(`base/bind-system-plan.md`의 `None` 센티널 절)가 + 도착했을 때 handler가 **아무 특별 처리도 없이** `inst:SetAttribute(name, + nil)`을 그대로 호출하면 끝 — UICorner 숏핸드처럼 "만들어둔 자식을 + 수동으로 찾아 지우는" 로직조차 필요 없음. +- **`retract`는 완전 no-op — [재정정, 2026-08-12 세션 후속] "매번 + 불리지만 대부분 no-op"이라던 직전 서술도 틀렸음, "대부분"이 아니라 + "항상"** — 일반 프로퍼티 핸들러(`retract`가 완전 무조건 no-op, + `bind-system-plan.md` "일반 프로퍼티는 애초에 'unset' 개념이 없음")와 + 완전히 같은 성격으로 재정정. **`AttributeKeyHandler.retract`는 + `SetAttribute`를 절대 호출하지 않음** — attribute를 지우는 유일한 + 경로는 `process(inst,k,nil)`(`None`이든, State가 스스로 `nil`로 + 바뀌든) 뿐. 이전 버전("이름이 사라질 때(`v==nil`)만 retract가 + `SetAttribute(name,nil)`을 호출")은 두 가지 문제가 있었음 — (1) + `retract` 안에 관측 가능한 부작용이 생겨 `bind-system-plan.md`의 + "retract는 구조적 팝만, process 트리거 금지" 일반 규칙과 어긋나는 + 성격의 코드가 됨, (2) 그룹이 survivor 이름에 `retractUnder(...,source)`를 + 부를 때 그 시점에 `SetAttribute`가 잘못 끼어들 수 있는 경로가 생겨 + `a→nil→b` 깜빡임 위험(사용자 지적) — `retract`가 완전 no-op이면 이 + 경로 자체가 물리적으로 없어짐. - store-bind 가능(일반 프로퍼티와 동일하게 취급, `Store`/`State` 값도 받음). @@ -154,28 +162,17 @@ function AttributeKeyHandler.process(inst, k, v) if current ~= nil and current ~= k then error(("attribute \"%s\"는 이미 다른 AttributeKey가 관리 중"):format(name)) end - inst:SetAttribute(name, v) - if v == nil then - map[name] = nil -- nil로 귀결되면 소유권도 같이 반납 — 다른 claimant가 다시 쓸 수 있게 - else - map[name] = k - end + inst:SetAttribute(name, v) -- v가 nil이든 아니든 무조건 — 일반 프로퍼티와 완전히 동일 + map[name] = if v == nil then nil else k -- nil로 귀결되면 소유권도 같이 반납 owners:SetStrong(inst, map) end function AttributeKeyHandler.retract(inst, k, v) - -- [정정, 2026-08-12 열한 번째 세션] retract는 이 k가 store 재발행으로 - -- 다시 process될 때도 매번 불림(핸들러 타입이 안 바뀌어도) — - -- bind-system-plan.md 일반 retract 계약 절 참고. v가 nil이 아니면 - -- 곧바로 process가 재확정하므로(같은 k가 계속 살아있는 한 항상 - -- 자기 자신이 다시 매치됨) 여기서 SetAttribute/소유권 반납을 할 - -- 이유가 없음 — v가 nil일 때만(진짜 이 이름이 사라지는 경우) 실행. - local name = k.Name - local map = owners:GetStrong(inst) - if map and map[name] == k and v == nil then - map[name] = nil - inst:SetAttribute(name, nil) - end + -- 완전 no-op. 일반 프로퍼티와 동일 — "unset" 개념 자체가 없음. + -- retract 안에서 process를 부르는 건 Dispatch.retractUnder의 체인 + -- 추적을 꼬는 UB(base/bind-system-plan.md 일반 규칙)라 여기서도 + -- SetAttribute를 직접이든 간접이든 절대 안 부름 — 지우는 건 오직 + -- process(inst,k,nil). end ``` @@ -188,22 +185,10 @@ end 새 릴레이션 불필요, 이미 있던 걸 재사용. 이름을 처음 보면 `rawNew(name)`로 만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에 이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로 - 재사용**해서 위임. -- **[정정, 2026-08-12 열한 번째 세션] "남아있는 이름은 retract 자체가 안 - 불린다"는 예전 서술은 틀렸음** — `AttributeKeyHandler.retract`는 이제 - `v==nil`일 때만 실제로 뭔가 하므로(위 "메커니즘, `None`, `retract`" 절 - 정정분), 남아있는 이름에 대해서도 `retractUnder`를 불러도 안전하고 — - 오히려 **불러야 함**: `Dispatch.process`가 매번 체인 꼬리에 새 항목을 - 쌓기만 하지 스스로 옛 항목을 안 지우므로(popping은 `retractUnder` - 자신의 일), 남아있는 이름을 `retractUnder` 없이 `Dispatch.process`만 - 반복 호출하면 그룹 값이 교체될 때마다 같은 `(inst,key)` 체인에 옛 - `AttributeKeyHandler` 항목이 계속 쌓이는 누수가 생김. **그래서 그룹의 - diff는 사라진/남아있는 이름 모두 자기 캐싱된 키로 먼저 - `Dispatch.retractUnder(inst, key, nil, newSourceOrNil)`를 부른 뒤에만 - `Dispatch.process`를 부름** — 새로 들어온 이름만 예외(옛 체인이 없으니 - retractUnder 없이 바로 process). `AttributeKeyHandler.retract`가 `v`가 - non-nil이면 즉시 return하는 얇은 함수라 이 추가 호출의 비용은 무시할 - 수준. + 재사용**해서 위임. 이 diff/재위임 로직의 정확한 코드는 아래 "그룹 + `Attribute(...)`" 절의 `AttributeGroupHandler.process`/`.retract` 참고 + — `AttributeKeyHandler` 자신은 diff를 전혀 모름(위 "이름이 살아있는 + 동안 항상 같은 키 재사용" 전제만 지켜지면 그만). - **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와 달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제 위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 — @@ -257,59 +242,95 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구 위 "이름 소유권" 절 참고, 이 절은 그 위에서 diff 로직이 어떻게 도는지만 설명: -- **그룹 값이 (재)할당될 때**(마운트 시점, 또는 `:Compute`가 그룹 값 - 자체를 교체하는 드문 경우 — 흔한 필드 하나 변경과는 다른 경로, 아래 - 참고): `(inst, index)`(array-part 위치, `Tag`의 `relate:GetStrong(inst,k)`와 - 동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이전에 쓴 - attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" — - 이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위 - "이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff: - - **[정정, 2026-08-12 열한 번째 세션] 사라진 이름뿐 아니라 남아있는 - 이름도 먼저 `Dispatch.retractUnder(inst, key, nil, source)`를 부른 - 뒤에야 `Dispatch.process`를 부름** — 그 이름이 맵에 들고 있던 **그 - 전용 키 객체**로, 그 이름이 살아있는 동안 만들어졌던 원래 체인과 - 정확히 같은 슬롯을 가리켜 정리(팝)됨. `retractUnder` 없이 - `Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서 - (팝은 `retractUnder`의 일) 같은 키 자리에 옛 `AttributeKeyHandler` - 항목이 계속 누적되는 진짜 누수가 생기므로, "값이 안 바뀌었으니 - retract 생략"은 성립 안 함 — `AttributeKeyHandler.retract`가 `v` - non-nil이면 즉시 return하는 얇은 함수라(위 "이름 소유권" 절) 이 - 호출 자체는 사실상 공짜. - - 사라진 이름: `Dispatch.retractUnder(inst, key, nil, nil)` — 뒤이어 - `process` 안 부름, 맵에서도 그 이름 제거. - - 남아있는 이름: `Dispatch.retractUnder(inst, key, nil, source)` → - 바로 `Dispatch.process(inst, key, source)` — **맵에 이미 캐싱된 같은 - 키 객체를 그대로 재사용**. - - 새로 들어온 이름: 옛 체인이 없으니 `retractUnder` 없이 바로 - `rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱하고 - `Dispatch.process(inst, key, source)`. - - **값 비교 없이 전부 재위임** — 작성자가 직접 - `[AttributeKey<> name] = source`를 쓴 것과 거의 같은 경로를 - 타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가 - `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 - 해줌(그룹 Handler가 따로 구독 관리 안 함). - - **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은 - 항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md` - "하드 경계" 절)과 어긋나고, 릴레이션 키를 문자열 이름 집합(존재 여부)만 - 들고 있으면 충분해서 굳이 값까지 비교할 이유가 없음. - - **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다 - 남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners` - 레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이 - 남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 - 영속되는 건 이미 확정돼 있던 설계라 새로 챙길 것 없음, 저장 값 - 형태만 위처럼 바뀜. +**그룹의 `process`/`retract` — [전면 재정정, 2026-08-12 세션 후속]** +아래는 `(inst, index)`(array-part 위치, `Tag`의 `relate:GetStrong(inst,k)`와 +동일 키잉 — `k`는 배열 인덱스)로 찾은 릴레이션에 저장된 **"이름 → +그 이름 전용 키 객체 맵"**(이름 존재 여부뿐 아니라 그때 쓴 키 객체 +자체까지 같이 들고 있어야 위 "이름 소유권" 절의 동일 객체 재사용이 +성립)을 씀: + +```lua +local groupState = Relate() -- {[inst(weak)] = {[index]: {[name]: keyObject}}} + +function AttributeGroupHandler.process(inst, index, v) + if v == nil then return end + local map = groupState:GetStrong(inst, index) or {} + for name, source in pairs(v:NameMap()) do + local key = map[name] or rawNew(name) -- 남아있던 이름은 캐싱된 같은 객체 재사용 + map[name] = key + Dispatch.retractUnder(inst, key, nil, source) -- chain-append-leak 방지, 매번(신규는 빈 체인이라 no-op) + Dispatch.process(inst, key, source) + end + groupState:SetStrong(inst, index, map) +end + +function AttributeGroupHandler.retract(inst, index, v) + local map = groupState:GetStrong(inst, index) + if not map then return end + local newNames = if isAttribute(v) then v:NameMap() else {} + for name, key in pairs(map) do + if newNames[name] == nil then -- 새 v에 이제 없는 이름만 + Dispatch.retractUnder(inst, key, nil, nil) -- 구독만 끊음 — SetAttribute는 안 일어남(아래 원칙) + map[name] = nil + end + end +end +``` + +- **`process`는 매번 살아있는 이름 전부를 `retractUnder`+`process` + 페어로 재위임** — 신규/생존 구분 없이 균일 처리. `Dispatch.process`가 + 매번 체인 꼬리에 새 항목을 쌓기만 하지 스스로 옛 항목을 안 지우므로 + (팝은 `retractUnder`의 일), `retractUnder` 없이 `Dispatch.process`만 + 반복 호출하면 같은 키 자리에 옛 `AttributeKeyHandler` 항목이 계속 + 쌓이는 누수가 생김 — 신규 이름은 아직 체인이 없어 `retractUnder`가 + 그냥 no-op이라 이 페어링을 신규/생존 가리지 않고 통일해도 비용 없음. + **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상 + 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md` + "하드 경계" 절)과 어긋나고, `source`가 `State`/`Source`면 + `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 해줌(그룹 Handler가 + 따로 구독 관리 안 함)이라 굳이 비교할 이유가 없음. +- **[확정, 2026-08-12 세션 후속, 사용자 결정] `retract`는 `SetAttribute`를 + 절대 안 부름 — Attribute는 오직 명시적 `None`/`nil`로만 지워진다.** + 그룹에서 이름이 조용히 빠지든(diff로 사라짐), 그룹 바인딩 자체가 + 통째로 사라지든(컴포넌트 언마운트 등, `v`가 더 이상 Attribute가 아님) + 프레임워크가 자동으로 `SetAttribute(name,nil)`을 대신 불러주지 + 않음 — 값이 이전 것 그대로 남는 게 정상 동작. `Ref`가 Destroy와 + 무관하게 동작하는 것과 같은 철학("지울 거면 명시적으로 지우라", + `bind-system-plan.md`의 "`Ref`의 retract" 절)으로 통일. **이전 초안은 + "Tag와 동일하게 확실히 청소"였으나 뒤집힘** — 이유: (1) diff로 + 조용히 빠지는 이름은 안 지워주면서 통째 소멸일 땐 지워주면, 두 경우가 + 서로 다른 규칙이 되어 오히려 모호해짐(사용자 지적: "diff 쌓인 거랑 + 전부 지운 거랑 완전 달라짐"). (2) Attribute 이름은 이미 "겹치면 + error"로 소유 코드가 명확히 갈리는 설계(위 "이름 소유권" 절)라, 그 + 이름을 만든 코드가 알아서 지우는 게 맞지 프레임워크가 대신 판단할 + 이유가 불투명함. (3) 정말 자동 청소가 필요하면 `Animate`와 같은 모양 + (`State -> State`를 만드는 `:Apply` 팩토리, 이전 + 그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을 + 나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라 + 모호하지 않음, 지금은 범위 밖(백로그). +- **다만 사라진 이름의 *구독*은 끊음 — 값은 안 지워도 자원은 새면 + 안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지 + 않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던 + `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 + `Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가 + 됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 + 문제). 그래서 `retract`는 사라진 이름에 한해 `Dispatch.retractUnder(inst, + key, nil, nil)`만 부름 — **`Dispatch.process`는 절대 안 부르므로** + (retract 안에서 process 호출은 `retractUnder`의 체인 추적을 꼬는 UB, + `bind-system-plan.md` 일반 규칙) `AttributeKeyHandler.retract`(완전 + no-op)만 타고 끝나 `SetAttribute`는 여기서도 절대 안 일어남 — 위 + "명시적 None으로만 지운다" 원칙과 안 부딪힘. - **필드 하나만 바뀌는 흔한 경우**(`storeA.foo:Set(v)`, 그룹 자체는 안 바뀜)는 위 그룹 재처리를 아예 거치지 않음 — 마운트 시 이미 걸린 단일 키 `AttributeKeyHandler`의 store-bind 구독이 바로 `SetAttribute("foo", v)`를 호출(그룹 재진입 없이 그 경로 스스로). **`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** — 키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎. -- **`retract`**: 자기가 쓴 키(전용 키 객체) 전부 `Dispatch.retractUnder`로 - 정리 — 단일 키의 `SetAttribute(name, nil)`이 그대로 실행되므로 결과적으로 - `Tag`와 동일하게 확실히 청소됨. Roblox는 Instance 풀링/재사용이 흔해서, - 이전 컴포넌트가 남긴 attribute가 재사용된 Instance에 방치되면 - selector/스타일시트 로직에 실제 버그가 됨. ("소진돼도 부작용 없으니 - 방치해도 된다"는 초안은 기각 — Tag의 확실한 청소 원칙과 통일.) +- **캐시(맵)는 그룹 값 교체를 넘어 계속 유지돼야 함** — 매 교체마다 + 남아있는 이름의 키까지 새로 만들면, "이름 소유권" 절의 `owners` + 레지스트리엔 옛 키가 남아있어 새로 만든 키와 비교 시 오탐 충돌이 + 남(자기 자신과 충돌하는 꼴). 이 릴레이션이 `(inst, index)`로 영속되는 + 건 이미 확정돼 있던 설계. **그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제 `SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키 @@ -353,3 +374,10 @@ UICorner 숏핸드/Tween/Tag와 같은 판단 재사용 — `quad-roblox` 코어 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`State`/ `DI`→`D`/`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 대기열에 있음, 나중에 한꺼번에 재검토. +- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도 + `SetAttribute(name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" + 절의 결정 — 그래도 명시적 자동 unset이 갖고 싶으면 `Animate`와 같은 + 모양의 `:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 + `None`으로 채워주는 콤비네이터)을 나중에 추가할 수 있음, 착수 안 함 — + `research/operator-sugar-plan.md` "Attribute 그룹 명시적 unset 유틸" + 절 참고. diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 8d2b4c9..971fff5 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -47,12 +47,13 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는 없음(예: 일반 프로퍼티 핸들러는 보통 no-op). **`retract` 필드 자체는 생략 불가, no-op이라도 항상 정의할 것(2026-08-08 세션, 확정)** — `Dispatch.process`(아래 "확정된 디스패치 모델" 절)는 - 담당 핸들러 *타입*이 바뀔 때 이전 핸들러의 `retract`를 nil 체크 없이 - 무조건 호출함. 필드를 생략한 핸들러가 나중에(드물더라도, 예: `Tag(...)`↔ - `nil` 교체 — `base/tag-plan.md`) 실제로 담당이 바뀌는 순간 `attempt to - call a nil value`로 - 바로 크래시 — "의미 있게 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 - 뜻이지 "필드를 안 둬도 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 + store 재발행마다(핸들러 *타입*이 안 바뀌어도) 이전 핸들러의 `retract`를 + nil 체크 없이 무조건 호출함(`[전면 정정, 2026-08-12 열한 번째 세션]`, + 아래 "retract-always-fires" 정정 절 참고 — 담당 타입이 바뀔 때만 + 불린다는 건 예전의 틀린 가정이었음). 필드를 생략한 핸들러는 이 반복 + 호출에서 바로 `attempt to call a nil value`로 크래시 — "의미 있게 + 구현할 필요 없음"은 "구현이 사소해도 됨"이라는 뜻이지 "필드를 안 둬도 + 안전하다"는 뜻이 아님. 새 핸들러를 짤 때 이 필드가 없으면 리뷰/린트에서 걸러내야 함(M2 착수 시 확인 목록에 추가). 디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출, @@ -155,6 +156,22 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md` "이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은 `slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고. + - **[일반 규칙, 2026-08-12 세션 후속] `retract`의 `v`는 타입을 보장 안 + 함 — 내용(메소드/필드)을 보려면 반드시 `isX(v)` 가드부터.** `retract`가 + `old ~= v`/`v == nil`처럼 identity/nil만 비교하면 가드가 필요 없지만 + (`Ref`/`Slot`/`AttributeKey`가 이 경우), `v`의 내용을 실제로 들여다봐야 + 하면(`TagHandler.retract`의 `newv:Contains(name)`처럼) 그 전에 반드시 + `isTag(newv)` 같은 타입 가드를 거칠 것 — 안 그러면 그 자리 핸들러 + *타입*이 바뀌는 드문 경우에 엉뚱한 값의 메소드를 호출해 크래시함. + (`AttributeGroupHandler.retract`가 이 가드를 처음엔 빠뜨렸던 실수, + `base/attribute-plan.md` 참고.) + - **[일반 규칙, 2026-08-12 세션 후속] `retract` 안에서 `Dispatch.process`를 + 부르는 것은 UB — `Dispatch.retractUnder`가 체인을 걷는 도중의 트래킹이 + 꼬임.** `retract`는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 + 새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹 + 로직이 `retract` 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는 + 순서로만 일어나야 함. `Dispatch.retractUnder`를 (다른 키에 대해) + `retract` 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`. - Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가 소비하는 값-레벨 래퍼(`Tween`)라 매치되는 핸들러가 항상 PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은 @@ -411,15 +428,20 @@ function Dispatch.retractUnder(inst, k, keep, v) end ``` -**[2026-08-12 세션에서 정정]** `list[i].retract(...)`의 세 번째 인자가 -원래 `i == cutoff + 1 and v or nil`(and/or 삼항 관용구)이었으나, `v`가 -`false`일 때(정당한 boolean 프로퍼티 값) `and`의 결과가 falsy가 되어 -`i == cutoff + 1`이 참이어도 `or nil`로 새는 조용한 버그였음 — Luau의 -`if-then-else` 표현식(2021년 도입)으로 교체. **일반 규칙**: `cond and -truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy 값일 -때만 안전(예: `Tag(...)`/`Length:Get()`처럼 절대 `nil`/`false`가 될 수 -없는 값) — 임의 `T`(boolean 포함) 값이 가운데 올 수 있으면 반드시 -`if cond then a else b`를 쓸 것. +**[2026-08-12 세션에서 정정, 같은 세션 후속으로 예외 조항까지 폐기]** +`list[i].retract(...)`의 세 번째 인자가 원래 `i == cutoff + 1 and v or +nil`(and/or 삼항 관용구)이었으나, `v`가 `false`일 때(정당한 boolean +프로퍼티 값) `and`의 결과가 falsy가 되어 `i == cutoff + 1`이 참이어도 +`or nil`로 새는 조용한 버그였음 — Luau의 `if-then-else` 표현식(2021년 +도입)으로 교체. **일반 규칙(강화): `cond and x or y` 삼항 관용구는 +전면 금지, `if cond then x else y`만 쓸 것** — 처음엔 "가운데 값이 +테이블/항상-truthy일 때만 예외적으로 and/or 허용"이었으나, 안전 여부와 +무관하게 `if-then-else`가 항상 우월하다는 게 재확인돼 예외 자체를 +없앰: `and`/`or`는 진짜 short-circuit이라 각 단계마다 truthiness를 +테스트하는 명령이 들어가는데(최대 2회 분기), `if-then-else`는 `cond` +하나만 테스트하고 단일 분기로 끝남 — 안전한 경우에도 `if-then-else` +쪽이 바이트코드상 더 적은 분기. `base/architecture.md`의 "코드 스타일 +— Luau 문법 관례" 절도 같이 갱신. - **`handler.process(inst,k,v)`를 `Dispatch.process`를 거치지 않고 직접 호출하는 것은 UB — 반드시 `Dispatch.process`를 통해서만 진입할 것.** @@ -450,13 +472,18 @@ truthyOnly or fallback` 관용구는 가운데 값이 테이블/항상-truthy - **`retract`는 여전히 `(inst,k,v)` 3-인자** — 드롭하자는 제안이 대화 중 한 번 나왔으나 기각(전체 삭제 vs 부분 diff를 갈라야 하는 핸들러가 있어서, `base/tag-plan.md` 참고). 다만 `v`가 실제로 필요한지는 - 핸들러마다 다름 — Tag는 구조상 retract가 "더 이상 매치 안 될 때만" - 불리므로 `v`를 안 봐도 항상 전체 삭제가 맞음(무조건) — `v`는 "계약상 - 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. + 핸들러마다 다름 — **[정정, 2026-08-12 열한 번째 세션]** Tag는 오히려 + 반대로 `v`를 반드시 봐야 하는 대표 사례다: `retract`는 store + 재발행마다(핸들러 타입이 안 바뀌어도) 항상 불리므로, `TagHandler.retract`는 + `v`가 여전히 그 이름을 `Contains`하는지 힌트로 확인해 실제 엔진 + `RemoveTag` 호출만 skip한다 — "더 이상 매치 안 될 때만 불리므로 무조건 + 전체 삭제가 맞다"는 원 서술은 이 정정 전 잘못된 가정이었음, 상세는 + `base/tag-plan.md` "메커니즘" 절 참고. `v`는 "계약상 항상 주어지지만 + 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. **[정정, 2026-08-10 세션]** 원래 두 번째 예시로 들었던 Tween(자기 `Relate` 저장분만 보고 `Cancel`하면 되니 `v`를 꼭 안 봐도 됨)은 더 이상 유효한 예시가 아님 — PropertyHandler가 항상 매치되는 유일한 - 핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`research/ + 핸들러가 되어 이 `retract` 경로 자체가 사실상 안 쓰임(`base/ tween-plan.md`). - **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 B가 다시 A로 돌아오는 것)는 재귀 호출이 안 끝나 바로 스택오버플로가 @@ -624,7 +651,7 @@ local function recompute(ownerKey, bk) offset:Set(sum) end local v = bk.lengthList[i] - sum += (isState(v) and v:Get() or v) + sum += (if isState(v) then v:Get() else v) end if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 11ec772..9800c5b 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -33,8 +33,13 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강 ## 위험한 패턴 — 서로 다른 두 `Relate`의 상호 강참조 순환 (2026-08-12 열세/열네 번째 세션, `Slot` 설계 중 발견) 위 26-28행이 말하는 "값이 자기 키를 다시 참조하는" 자기참조(예: -`Dispatch.setLength`의 `observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`)는 -**단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가 +`Dispatch.setLength`의 `observer` 클로저가 `inst`를 캡처, `Ref.Value=inst`, +**[확인, 2026-08-12 세션 후속] `slot._mountedInst = physicalTarget`도 +같은 패턴** — `bindLifetime(physicalTarget, slot)`이 내부적으로 +`physicalTarget`을 키로 `slot`을 강하게 붙잡는 단일 `relate`고, +`slot._mountedInst`는 그 값(`slot`)이 자기 키(`physicalTarget`)를 다시 +참조하는 필드일 뿐이라 GC 안전 — `base/slot-plan.md` "재귀 메커니즘" +절)는 **단일 `Relate` 안에서** 일어나는 한 안전함 — 그 `Relate`의 키(`inst`)가 테이블 *바깥에서* 독립적으로 reachable한지만 판별하면 되기 때문. 하지만 **서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 제공하는 상호 순환**(예: `RelateA[inst]=value`(강)와 `RelateB[value]=inst`(강)가 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 3edd242..73b1738 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -138,10 +138,14 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의 "구독 시점" 절 참고). - **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등) - 마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임 - ("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). - `Add`가 이 weak-set을 확인(이미 참이면 error)/설정, `Remove`/`Extract` - 둘 다 여기서 제거(둘의 차이는 파괴 여부일 뿐, "마운트 해제"라는 점은 같음). + 마다 전역 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임("한 인스턴스가 + 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). **[구체화, + 2026-08-12 열여섯 번째 세션]** 이 전역 멤버십의 실제 구현은 아래 + "요소 소유권 — `elementOwner`" 절의 `claimOwner`/`releaseOwner` — + `Add`가 `claimOwner`(이미 다른 owner면 error)/`Remove`·`Extract`가 + `releaseOwner`. top-level Dispatch 마운트(`SlotHandler`)도 **같은** + 레지스트리를 써서 두 경로가 서로의 소유권을 볼 수 있음(전에는 별도 + 레지스트리라 안 보였던 gap, 해당 절 참고). ## 여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨 @@ -152,12 +156,18 @@ Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 ## 마운트된 Slot의 재마운트는 즉시 throw (확정) -**사용자 확인 완료**: 이미 사용된(마운트된) slot을 재마운트하려 하면 **즉시 -`error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게 -실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서 +**사용자 확인 완료**: 이미 사용된(마운트된) slot을 **다른 위치로** 재마운트하려 +하면 **즉시 `error()`로 중단** — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 +강하게 실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서 **글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음**(재사용/재마운트가 막히므로). +> **[명확화, 2026-08-12 열두 번째 세션 메커니즘과 대조]** 위 throw는 **다른** +> `inst`로 마운트하려 할 때만 해당 — **같은** `inst`로 재-emit되는 경우(store +> 재발행 등으로 `process`가 같은 slot을 다시 받는 경우)는 예외적으로 no-op이지 +> throw가 아니다. 상세 메커니즘은 아래 "Slot과 Store 바인드의 관계" 절의 +> `SlotHandler.process`(`owner == inst` 분기) 참고. + ## 클래스가 슬롯을 받는 방법 "네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을 @@ -241,28 +251,54 @@ tables" 항목).** 즉 이 회피는 "혹시 몰라서"가 아니라 **Luau에 하나로만 두고, `kSlotMap`/`slotOwner`는 전부 `SetWeak`(순수 조회용, 아무것도 안 붙잡음)로 낮춤**: +**[일반화, 2026-08-12 열여섯 번째 세션] `slotOwner`는 top-level Dispatch +마운트만 보고 있어서, Slot-in-Slot으로 nested `Add`되는 경로(아래 "요소 +소유권 — `elementOwner`" 절)와 서로 다른 레지스트리라 어느 한쪽이 이미 +소유 중인 걸 다른 쪽이 못 보고 이중 마운트를 허용하는 gap이 있었음 +(사용자 발견) — `slotOwner`를 element(범용, Slot이든 plain Instance든) +전체를 커버하는 `elementOwner`로 승격해 top-level Dispatch 경로와 +nested CRUD 경로가 **같은** 레지스트리를 쓰도록 통합: + ```lua -local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용 -local slotOwner = Relate() -- Slot 자신이 weak 키 — {[slot] = 지금 바인딩된 inst} — weak, 조회 전용 +local kSlotMap = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot — weak, 조회 전용 +local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용 + -- {[element] = ownerKey} -- ownerKey: inst | Slot, 전부 weak +local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후 + -- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 — + -- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고) + +local function claimOwner(element, ownerKey) + local current = elementOwner:GetWeak(element, OWNER) + if current == ownerKey then + return false -- 이미 같은 owner — no-op, 재확인만 + end + if current ~= nil then + error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지") + end + elementOwner:SetWeak(element, OWNER, ownerKey) + return true +end + +local function releaseOwner(element, ownerKey) + if elementOwner:GetWeak(element, OWNER) == ownerKey then + elementOwner:SetWeak(element, OWNER, nil) + end +end function SlotHandler.process(inst, k, slotValue) - local owner = slotOwner:GetWeak(slotValue) - if owner == inst then + if not claimOwner(slotValue, inst) then return -- 이미 이 inst에 바인딩된 채 — 단순 emit 전파, no-op end - if owner ~= nil then - error("이 Slot은 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지") - end - attachSlot(slotValue, inst, inst, k) -- 내부에서 bindLifetime(inst, slotValue) 호출(아래) - slotOwner:SetWeak(slotValue, inst) + attachSlot(slotValue, inst, inst, k) -- top-level이라 내부에서 bindLifetime(inst, slotValue) 호출(위 "재귀 메커니즘" 절) kSlotMap:SetWeak(inst, k, slotValue) end function SlotHandler.retract(inst, k, v) local old = kSlotMap:GetWeak(inst, k) if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음 - destroySlotTree(old) -- 내부에서 unbindLifetime(inst, old) 호출(아래), 폐기·옮기지 않음 - slotOwner:SetWeak(old, nil) + destroySlotTree(old) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용) + unbindLifetime(inst, old) -- top-level 자신의 GC 앵커 해제 — attachSlot의 top-level bindLifetime과 짝 + releaseOwner(old, inst) kSlotMap:SetWeak(inst, k, nil) end -- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 owner==inst로 no-op @@ -270,10 +306,44 @@ end ``` `attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 — -`slotOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간 +`elementOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간 표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이 누구냐"만 구분하면 됨. +### 요소 소유권 — `elementOwner`, nested `Add`/top-level Dispatch 공용 (2026-08-12 열여섯 번째 세션) + +**문제(사용자 발견)**: 위 `elementOwner`가 승격되기 전엔 top-level +Dispatch 마운트(`SlotHandler.process`)만 `slotOwner`를 봤고, `Add`가 +확인한다는 "개별 element 전역 weak-set"(구 "isMounted 이중 추적 분리" +절)은 완전히 별개 레지스트리로 서술만 있고 코드가 없었음 — 그래서 +`slot1`을 top-level store-bind하면 `slotOwner`만 찍히고, 그 다음 +`otherSlot:Add(slot1)`을 하면 `Add`가 보는 레지스트리엔 아무것도 없어 +그대로 통과 → 같은 `slot1`이 두 군데 물리적으로 마운트되는데 에러가 +안 남(반대 순서도 동일하게 뚫림) — "핵심 제약: 소유권 귀속과 단일 +마운트" 절의 라이브러리 전역 불변식이 실제로는 안 지켜지던 gap. + +**해법**: 위에서 승격한 `elementOwner`/`claimOwner`/`releaseOwner`를 +Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의 +소유권 판정에 공용으로 씀 — top-level(`SlotHandler.process`/`.retract`)과 +nested(`rawAdd`/`rawRemove`/`rawExtract`)가 정확히 같은 함수, 같은 +`Relate`를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시 +봄: + +```lua +-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리 +claimOwner(element, self) -- self = 담는 Slot. 이미 다른 곳 소유면 여기서 error + +-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리 +releaseOwner(element, self) +``` + +`ownerKey`가 `inst`(top-level)든 `Slot`(nested)든 `elementOwner`는 +타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로 +들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부 +`SetWeak`), 실제 강한 참조는 `outerSlot._elements`(plain array, `Relate` +아님)가 이미 쥐고 있으므로 `relate-plan.md`가 경고하는 "두 Relate +상호 강참조" 패턴과 다른 모양 — GC 문제 없음. + - **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라 정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal 없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재 @@ -859,7 +929,7 @@ function activateList(self, inst) -- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이 -- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도 -- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고) - pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1) + pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1) end if result ~= prev then @@ -1090,9 +1160,9 @@ local function identityUpdateFn(item) return item end function Slot:Single(state, updateFn) updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가 - local data = isState(state) - and state:Compute(function(v) return v:Get() == nil and {} or { v:Get() } end) - or (state == nil and {} or { state }) + local data = if isState(state) + then state:Compute(function(v) return (if v:Get() == nil then {} else { v:Get() }) end) + else (if state == nil then {} else { state }) return self:List(data, function(item, index, offset, prev, ud) return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김 @@ -1151,10 +1221,19 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 local function attachSlot(slot, physicalTarget, ownerKey, position) slot._mounted = true slot._mountedInst = physicalTarget - bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] Slot 자신의 - -- GC 앵커 — 이게 빠져있으면 아무도 slot을 강하게 - -- 안 붙잡아 조기 GC될 수 있음(위 "Slot과 Store - -- 바인드의 관계" 절 GC 주의 참고) + if ownerKey == physicalTarget then + -- [2026-08-12 열여섯 번째 세션, 스코프 정정] bindLifetime은 최상위(물리 + -- inst에 직접 연동하는 말단)에서만 — 중첩 Slot은 자신을 담는 outer의 + -- `_elements`(plain strong array)로 이미 transitively 살아있어서 + -- (elementOwner는 전부 weak라 별도 anchor 아님, 위 "요소 소유권 — + -- `elementOwner`" 절 참고) 여기서 또 anchor할 이유가 없음 — State의 노드 연결처럼 + -- 중간 노드는 구조로만 연결되고, 말단만 실제 엔진 생명주기에 건다는 + -- 원칙과 같은 결. 매 레벨 anchor하면 매 레벨 파괴 시 짝을 맞춰 + -- unbindLifetime해야 하는 부담만 늘어남(destroySlotTree 참고). + bindLifetime(physicalTarget, slot) -- [2026-08-12 열세 번째 세션 추가] 최상위 + -- Slot의 GC 앵커 — 이게 빠져있으면 아무도 + -- 강하게 안 붙잡아 조기 GC될 수 있음 + end Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State, 기존 로직 그대로 local offsetSource = Source(0) @@ -1222,10 +1301,16 @@ local function destroySlotTree(slot) unbindLifetime(slot._mountedInst, observer) end end - unbindLifetime(slot._mountedInst, slot) -- [2026-08-12 열세 번째 세션 추가] - -- attachSlot의 bindLifetime과 짝 + -- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은 + -- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도 + -- 최상위 파괴 지점(SlotHandler.retract, 아래)에서만 한 번. destroySlotTree는 + -- 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당. end +-- [명확화] 아래 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이 +-- 이미 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로 +-- rawRemove(self, prev)를 부름. 둘 중 하나로 통일할지 얇은 변환 계층을 둘지는 +-- 아직 M6 구현 세부로 열려 있음 — 이 블록은 그 결정 전 illustrative 예시. function rawRemove(self, index) local element = self._elements[index] local bk = getBookkeeping(self) @@ -1349,7 +1434,7 @@ sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함 (`:List`의 reconcile은 `key` 기준으로 독립 동작, `sub`가 바깥에서 어느 인덱스에 있든 상관 안 함). - **`State`(nilable)도 특별 취급 없이 그냥 됨** — `:Single`이 이미 - `v:Get() == nil and {} or {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접 + `if v:Get() == nil then {} else {v:Get()}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접 전달 요소(`Add(element)`, State로 안 감싼 경우)에만 여전히 non-nil이 요구되고, `State`/`Source`로 감싼 값은 내부적으로 nilable이어도 아무 문제 없음 — 위 "요소 타입 제약" 절의 nil/None 금지는 **State/Source로 @@ -1403,7 +1488,7 @@ Slot-in-Slot으로 `T = Instance | Slot`가 허용되면서, `:List` Slot(Length=3)을 반환할 때 다음 아이템의 `index`가 3만큼 안 건너뛰고 1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도된 동작으로 확정, 위 "구현" 절의 `reconcile` 의사코드에 이미 반영됨**(`pos = candidateIndex -- 1 + (isSlot(result) and result.Length:Get() or 1)`). +- 1 + (if isSlot(result) then result.Length:Get() else 1)`). **남는 캐비엇 — `index`는 여전히 raw 스냅샷이라, nested Slot의 Length가 outer `:List`의 reconcile 없이 나중에 바뀌면 그 이후 형제들의 `index`는 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 2d56252..fa5fab0 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -67,16 +67,20 @@ plain string이라(핸들러 계층 값처럼 identity/생명주기가 얽힌 "하나의 Tag 값을 프로그래밍적으로 조립"하는 용도). **동적 토글은 `Source`/`State`로, `None` 불필요** — 상호배타 상태 전환은 -`store.activeTag:Compute(function(name) return name:Get() == "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 관용구.) +`store.activeTag:Compute(function(name) return (if name:Get() == "btn1" +then Tag("selected") else nil) end)`처럼 그냥 `nil`을 리턴하면 됨. `None` +센티널은 "정적 테이블 리터럴에서 `키 = nil`이 키 없음과 구별 안 되는" +문제의 해법이지(`bind-system-plan.md` "`None` 센티널" 절), 이건 함수 +리턴값이 동적으로 흘러가는 경우라 그 문제 자체가 없음 — `nil`을 인자로 +넘기는 건 아무 문제 없음. (단, `Frame { (if cond then Tag("a") else +nil), sibling }`처럼 **정적 리터럴**에서 조건부로 Tag를 넣거나 빼고 +싶은 경우엔 다른 array-part 값들과 마찬가지로 `(if cond then Tag("a") +else None)` 관용구가 여전히 유효 — 이건 nil-hole 문제라 Tag만의 특수 +규칙이 아니라 `props.Modifier`/`props.Ref`와 같은 일반 array-part +관용구. **[2026-08-12 세션 후속]** 예전엔 `cond and Tag("a") or +nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라 +당장은 안전해도 `if-then-else` 전면 금지 규칙(`base/architecture.md` +"코드 스타일" 절)에 예외를 안 두기로 하며 여기도 통일.) ## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐 diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index fcc59e9..613cd8d 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -1,6 +1,6 @@ # Tween / 애니메이션 플러깅 (전부 확정 — `base/`로 승격) -**상태**: research — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존 +**상태**: base — **2026-08-10 세션에서 구조 전체가 재설계됨.** 기존 "`v`가 Store인 아무 `k`나 잡는 우선순위 최상위 Dispatch 핸들러" 모델은 `research/pre-implementation-audit.md` 1-1이 지적한 구조적 모호함("애니메이션 없는 일반 반응형 프로퍼티 바인딩도 결국 이름이 Tween인 파일을 거쳐가는가")을 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 65ccf2b..44bc647 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -112,8 +112,8 @@ v1에서도 `Corner`/`PaddingAll`/`Scale`은 store 값으로 바인드 가능했 "이전에 자기가 찾거나 만든 자식 Instance"를 얻어야 하는데, 이건 이미 base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저장소 (`Relate:SetStrong(inst,k,...)`, `base/relate-plan.md`/`base/bind-system-plan.md` -"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — Tween 핸들러가 실행 중인 Tween -객체를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 +"핸들러 내부 상태 저장" 절)를 그대로 재사용하면 됨 — PropertyHandler가 실행 중인 +Tween 상태를 기억해두는 것과 정확히 같은 패턴. 새 메커니즘 발명 불필요, 이미 있는 "store 바인드는 pluggable 바인드를 재실행하는 래핑" 원칙 (`base/bind-system-plan.md` "확정된 디스패치 모델" 절)이 그대로 적용됨. @@ -124,7 +124,7 @@ base가 범용 유틸로 제공하기로 확정한 per-instance weak-keyed 저 넣어도 충분하다, opt-out할 이유가 별로 없다"는 게 사용자 판단 — **작고 항상 켜져 있어도 비용이 무시할 만한 편의 기능은 별도 opt-out 패키지로 쪼개지 말고 `quad-roblox` 코어에 직접 포함한다**는 원칙으로 확정(이미 -계획된 Tween 핸들러가 같은 모양이라는 게 근거). 이 원칙은 일반화해서 +계획된 Tween 처리 로직이 같은 모양이라는 게 근거). 이 원칙은 일반화해서 재사용 가능 — 앞으로 비슷한 "작은 인스턴스 편의 기능"이 제안되면 `quad-roblox-util` 같은 걸 새로 만들지 않고 이 선례를 따르면 됨. diff --git a/.claude/question.md b/.claude/question.md index 1113a52..7f45937 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -195,8 +195,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 있던 것을 이번에 `pre-implementation-audit.md` 2-11에도 해소 표시로 동기화. **[해소됨, 2026-08-09 세 번째 세션]** Slot CRUD 의미론 (`add`/`remove`/`clear`) 미정의(1-7)/`isMounted` 이중 추적 혼용(1-8) — - `base/slot-plan.md` 참고. **아직 실제로 열려있는 건 하나** — 우선순위 - 스캔 동률/매치실패 처리(1-3) — `pre-implementation-audit.md` 본문 참고. + `base/slot-plan.md` 참고. **[2026-08-12 감사 세션 정정] 아직 실제로 + 열려있는 건 하나가 아니라 넷** — `pre-implementation-audit.md` 원문에 + `[해소됨]` 마커가 없는 항목이 1-3 외에도 셋 더 있음: 우선순위 스캔 + 동률/매치실패 처리(1-3), provider 미주입 상태 dispatch 처리(1-4), + `store.key`의 레코드 필드 타이핑을 M0로 앞당길지(1-10), Modifier + `__index`+`table.clone` 트릭 검증(1-11) — 이전 요약이 이 셋을 누락한 + 채로 "1-3 하나뿐"이라 잘못 축약해뒀던 것, `pre-implementation-audit.md` + 본문 및 "다음 액션 제안" 절 참고. - **[해소됨, 2026-08-08 두 번째 세션]** `Frame { ref }`/`Frame { observer }`처럼 children 배열 숫자 슬롯에 직접 놓는 leaf 값을 매칭·바인드하는 Handler (`(i:number, v=Ref/Observer/PreRef)`)의 패키지 배치 — 원래 제안대로 diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 3df8ce3..659d1f1 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -209,6 +209,30 @@ introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어 큼. Slot을 거치지 않는 순수 `State -> State
` 값 변환 용도라면 문제없이 들어갈 수 있음 — 실사용 사례가 나오면 재검토. +## 열린 질문 — Attribute 그룹 명시적 unset 유틸 (2026-08-12 세션 후속, 신설) + +**배경**: `base/attribute-plan.md`가 "그룹에서 이름이 사라져도(diff든 +통째 언마운트든) 프레임워크가 자동으로 `SetAttribute(name,nil)`을 +안 해준다 — 지울 거면 명시적으로 `None`을 써라"로 확정됨(사용자 결정, +Ref의 "Destroy 무관, 정리 필요하면 Effect" 철학과 통일). 이유는 diff로 +조용히 빠지는 것과 통째 소멸을 다르게 취급하면 오히려 모호해지고, +Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라 +프레임워크가 대신 판단할 근거가 불투명하기 때문(상세는 해당 문서 +"그룹 `Attribute(...)`" 절). + +**아이디어(사용자 제시, 착수 안 함)**: 그래도 자동 unset이 갖고 싶으면, +`Animate`와 같은 모양의 `:Apply` 팩토리로 **명시적 opt-in 유틸**을 나중에 +추가하면 됨 — `State -> State`를 만들면서 내부적으로 +이전 이름 집합과 비교해 사라진 이름을 자동으로 `None`으로 채워 넣어주는 +콤비네이터. 이 카탈로그의 다른 항목들(`Sum`/`Concat`/`Sorted`)과 같은 +성격 — 순수 슈가, 없어도 기능 격차 없음, 사용자가 명시적으로 골라야만 +동작(자동/암묵적이 아님이 핵심 — 그래야 "프레임워크가 대신 판단"이라는 +모호함 문제 자체가 안 생김). + +**우선순위**: 이 문서 전체와 동급으로 맨 마지막, 실사용 사례가 나오면 +재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의 +`Animate`/`Sum` 패턴을 그대로 참고. + ## 우선순위 **맨 마지막.** 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에 diff --git a/.claude/session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md b/.claude/session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md new file mode 100644 index 0000000..19ebcfe --- /dev/null +++ b/.claude/session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md @@ -0,0 +1,122 @@ +# 2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면 재설계, Slot 소유권 일반화 + +## 배경 + +사용자가 "문서 전체에서 가정이 틀렸는데 다른 문서에서는 이미 정정된 +사실이 있음에도 잘못된 추론이 그대로 남아있는 곳이 있는지" 감사를 요청. +이전 세션들(특히 2026-08-12 열한~열다섯 번째, retract-always-fires +정정과 그 파생 GC 이슈 시리즈)에서 큰 변경이 여러 차례 있었기 때문 — +"정정됨" 표시만 붙고 원 문장이 그대로 방치되는 패턴이 반복된다는 CLAUDE.md +자기 경고가 이번에도 실제로 맞아떨어짐. + +## 1부 — 코퍼스 전체 감사 (7개 에이전트 병렬) + +`base/` 14개 파일 + `research/`/`question.md`/`README.md`를 7개 +서브에이전트로 나눠 정독·대조. 발견해서 그 자리에서 고친 것: + +- **`bind-system-plan.md`**: "Tag는 retract가 무조건 전체 삭제"라던 + 옛 서술 2곳이 세션11 정정(참조 카운트 기반) 이후에도 안 고쳐진 채 + 남아있었음 — 수정. +- **`attribute-plan.md`**: "retract 불필요" 헤더가 바로 아래 정정된 코드 + 주석과 모순 — 수정(이후 2부에서 이 절 전체가 다시 크게 재작성됨). +- **`slot-plan.md`**: `slotOwner:GetWeak/SetWeak` 호출이 `Relate` 공식 + API(항상 3-인자)와 인자 개수가 안 맞는 실제 버그 — sentinel key 추가로 + 수정. "재마운트는 즉시 throw" 절이 나중에 확정된 "같은 위치 재-emit은 + no-op" 예외를 반영 안 함 — 교차참조 추가. `rawRemove`가 element + 기준/index 기준 두 가지로 같은 파일에 리터럴로 존재 — M6 미확정임을 + 명시하는 주석 추가(결정 자체는 안 내림). +- **Tween 승격(research→base) 반영 누락**: `tween-plan.md` 상태 필드가 + 여전히 "research", `architecture.md` 2곳("Tween은 여전히 research/에 + 있음"), `ui-shorthand-plan.md`의 폐기된 "Tween 핸들러" 용어 — 전부 수정. +- **`question.md`/`CLAUDE.md`의 "지금 할 일" 1번**: `pre-implementation-audit.md` + 우선순위1 중 열려있는 게 "1개(1-3)뿐"이라던 요약이 실제로는 4개(1-3/ + 1-4/1-10/1-11, 원문에 `[해소됨]` 마커 없음)였음 — 개수/목록만 정정, + 실제 해소는 사용자가 "나중으로 연기" 선택. + +## 2부 — Attribute retract 전면 재설계 (사용자 주도 다단계 정정) + +diff를 직접 보던 사용자가 "AttributeHandler가 retract 없는 게 말이 되나" +질문에서 시작해 여러 라운드에 걸쳐 최종 설계에 도달: + +1. **1차**: 그룹 자신의 `(inst,index)` 슬롯도 process/retract 코드가 + 없다는 gap 발견 — `AttributeGroupHandler.process`/`.retract` 스케치. +2. **2차 (사용자 catch)**: `retract`의 `v` 타입을 안 보고 `v:NameMap()`을 + 호출하는 게 버그 — `TagHandler.retract`의 `isTag(newv)` 가드 선례와 + 대조해 확인. `Ref`/`Slot`/`AttributeKey`의 기존 retract는 전부 + identity/nil 비교뿐이라 애초에 이 문제가 없었음(내용을 봐야 하는 건 + Tag/Attribute뿐). +3. **3차 (사용자 catch)**: `AttributeKeyHandler.retract`가 `v==nil`이면 + `SetAttribute(name,nil)`을 직접 부르는 게 깜빡임(`a→nil→b`) 위험 — + "일반 프로퍼티와 동일하게 취급"이라는 기존 원칙대로 retract를 완전 + no-op으로, 지우는 건 오직 `process(inst,k,nil)`(None/nil 명시)로만 + 통일. +4. **4차 (사용자 결정, 가장 큰 정정)**: 그룹이 "사라진 이름"을 자동으로 + `SetAttribute(nil)` 해주는 것 자체를 기각 — **Attribute는 오직 + 명시적 `None`/`nil`로만 지워진다.** 그룹 바인딩이 통째로 사라져도 + (컴포넌트 언마운트 등) 자동 청소 없음. 이유: diff로 조용히 빠지는 + 이름과 통째 소멸을 다르게 취급하면 오히려 모호해지고, Attribute는 + 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라 프레임워크가 + 대신 판단할 근거가 불투명함. `Ref`의 "Destroy 무관, 정리는 Effect로" + 철학과 통일. 이 결정으로 기존 "Tag와 동일하게 확실히 청소" 초안(line + 308-313)이 뒤집힘. +5. **5차**: (4차와 별개 문제) 값을 안 지워도 사라진 이름의 *구독*은 + 끊어야 함 — 안 그러면 그룹이 더 이상 안 쓰는 Source를 향한 StoreBind + 구독이 인스턴스가 살아있는 동안 계속 살아남아 리소스 누수. `retract`가 + `Dispatch.retractUnder`만 부르고(`process`는 안 부름 — retract 안에서 + process 호출은 UB, 아래 참고) 구독만 끊는 걸로 확정 — `SetAttribute`는 + 여기서도 절대 안 일어남. + +**부수적으로 확정된 일반 규칙 2개**(`bind-system-plan.md`에 추가): +- retract의 `v`는 타입 보장 안 됨 — 내용을 보려면 `isX(v)` 가드 필수. +- retract 안에서 `Dispatch.process` 호출은 UB(`retractUnder`의 체인 + 추적이 꼬임) — retract는 구조적 팝/자원 해제만, 새 등록 트리거 금지. + `Dispatch.retractUnder`를 (다른 키에 대해) retract 안에서 부르는 건 + 문제없음. + +**백로그**: 그래도 자동 unset이 필요해지면 `Animate`와 같은 모양의 +`:Apply` opt-in 유틸(이전 이름 집합과 비교해 사라진 이름을 `None`으로 +채워주는 콤비네이터)을 나중에 추가 가능 — `research/operator-sugar-plan.md`에 +"Attribute 그룹 명시적 unset 유틸" 절로 신설, 착수 안 함. + +## 3부 — Slot 소유권 일반화, bindLifetime 스코프 정정 + +같은 흐름에서 Slot도 감사: + +- **소유권 레지스트리 이중화 gap(사용자 발견)**: top-level Dispatch + 마운트(`slotOwner`)와 nested `Add`(문서에만 있고 코드 없던 "전역 + weak-set")가 서로 다른 레지스트리라, 한쪽으로 먼저 마운트한 걸 다른 + 쪽이 못 보고 이중 마운트를 허용하는 실제 gap이 있었음. `slotOwner`를 + `elementOwner`(Slot이든 plain Instance든 공용)로 승격, `claimOwner`/ + `releaseOwner`를 top-level(`SlotHandler`)과 nested(`rawAdd`/`rawRemove`/ + `rawExtract`)가 동일하게 호출하도록 통합. +- **bindLifetime 스코프 정정(사용자 판단)**: `attachSlot`이 재귀 모든 + 레벨에서 `bindLifetime`을 부르던 것을 **top-level에서만**으로 축소 — + nested Slot은 담는 outer의 `_elements`(plain strong array)로 이미 + transitively 살아있어서 별도 anchor가 불필요, State의 노드 연결처럼 + 말단만 실제 엔진 생명주기에 건다는 원칙과 일치. `destroySlotTree`의 + 자기-unbind도 대칭으로 제거, top-level 파괴 지점(`SlotHandler.retract`) + 에서만 짝 맞춤. +- **GC 안전성 확인**: `slot._mountedInst = physicalTarget`은 + `relate-plan.md`가 이미 안전하다고 확정한 "값이 자기 키를 다시 + 참조하는" 단일-Relate 자기참조 패턴(`Ref.Value=inst`와 동일 부류)이라 + 안전 — `relate-plan.md`에 확인 사례로 추가. + +## 4부 — `and`/`or` 삼항 관용구 전면 금지 + +사용자가 성능(진짜 short-circuit이라 매 단계 truthiness 테스트, `if-then-else`는 +단일 분기)과 안전성(가운데 값 falsy 시 새는 구조적 결함) 둘 다 근거로 +"쓸 수 있는 곳은 전부 `if-then-else`"를 요청 — 기존에 "가운데 값이 +항상-truthy면 예외적으로 and/or 허용"이라던 예외 조항을 폐기하고, 코드로 +실존하던 6곳(`bind-system-plan.md`/`slot-plan.md`/`tag-plan.md`)을 전부 +`if-then-else`로 교체. `architecture.md`/`bind-system-plan.md`의 규칙 +설명도 같이 강화. 단순 2항 `A or B` fallback(`props.Modifier or None`류)은 +애초에 이 문제가 없어 그대로 유지. + +## 반영 완료 확인 + +`base/attribute-plan.md`(221줄 변경), `base/slot-plan.md`(147줄), +`base/bind-system-plan.md`(67줄), `base/architecture.md`(22줄), +`base/tag-plan.md`(24줄), `base/relate-plan.md`, `base/tween-plan.md`, +`base/ui-shorthand-plan.md`, `research/operator-sugar-plan.md`, +`.claude/question.md`, 루트 `CLAUDE.md` 전부 이 세션 안에서 갱신 완료. +열린 설계 질문 없음 — Attribute/Slot 소유권 메커니즘 둘 다 최종 확정. diff --git a/CLAUDE.md b/CLAUDE.md index a269704..897036c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,8 +119,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 없으면 그대로 M0 실제 코드 작성에 재사용. - **`research/pre-implementation-audit.md` 우선순위1** — 신설 당시 11개였으나 대부분 이후 세션에서 해소됨(Tween/Tag 재설계, Slot CRUD, - `canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). 실제로 아직 - 열려있는 건 "우선순위 스캔 동률/매치실패 처리"(1-3) 하나뿐 — + `canExecute` 시그니처, `LifetimeHandle` 로드맵 순서 등). **[2026-08-12 + 감사 세션 정정]** 실제로 아직 열려있는 건 넷(원문에 `[해소됨]` 마커가 + 없는 항목 기준) — 우선순위 스캔 동률/매치실패 처리(1-3), provider + 미주입 상태 dispatch 처리(1-4), `store.key` 레코드 필드 타이핑을 M0로 + 앞당길지(1-10), Modifier `__index`+`table.clone` 트릭 검증(1-11). + 이전엔 "1-3 하나뿐"이라 잘못 축약돼 있었음(1-4/1-10/1-11 누락) — `.claude/question.md` 2번이 최신 상태. 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 @@ -645,3 +649,22 @@ vararg 유지(요소 개수가 대개 소수로 고정, 동적이면 Slot-in-Slo 가능, `T|{T}`는 `Slot`가 base 레벨에선 `T`가 뭔지 모르는 제네릭이라 바깥 `{}`가 단일 T인지 배열인지 원천적으로 판별 불가능해서 오히려 모호해짐 — `Slot`의 T에 우연히 Slot이 섞여서가 아님, `slot-plan.md`). + +**2026-08-12 열여섯 번째 세션 — 코퍼스 전체 감사, Attribute retract 전면 +재설계, Slot 소유권 일반화** (`session/2026-08-12-16-corpus-audit-attribute-retract-slot-owner.md`) +7개 에이전트로 `.claude/` 코퍼스 전체를 감사해 stale 서술 다수 정정 +(retract-always-fires 정정 전파 누락, Tween research→base 승격 반영 +누락, `Relate` API 인자 개수 버그, `pre-implementation-audit.md` 열린 +항목 개수 오류 등). 이어서 사용자가 diff를 직접 검토하며 Attribute +`retract`를 다단계로 재설계 — **최종: retract는 완전 no-op(SetAttribute는 +오직 `process(inst,k,nil)`), Attribute는 오직 명시적 `None`/`nil`로만 +지워짐(그룹이 사라진 이름을 자동으로 안 지워줌 — Ref의 "Destroy 무관" +철학과 통일), 단 사라진 이름의 *구독*은 끊음(값은 안 지워도 리소스는 +안 새게)**. 일반 규칙 2개 신설(retract의 `v` 타입 미보장 → `isX(v)` +가드 필수, retract 안에서 `process` 호출은 UB). Slot도 `slotOwner`를 +`elementOwner`로 일반화해 top-level/nested 이중 마운트 gap 폐쇄, +`bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 +transitively 생존). `and`/`or` 삼항 관용구 전면 금지(기존 "항상-truthy면 +예외" 조항 폐기), 코퍼스 전체 실제 코드 6곳을 `if-then-else`로 교체. +Attribute 자동 unset이 필요해지면 쓸 `:Apply` opt-in 유틸을 +`research/operator-sugar-plan.md`에 백로그로 추가(착수 안 함).