diff --git a/.claude/README.md b/.claude/README.md index a8622d5..211166f 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -16,7 +16,6 @@ | `todos.md` | 지금 할 일(우선순위순). 가장 자주 바뀜 | | `session-summary.md` | 세션별 2~4줄 요약 색인. **`@import` 안 됨(의도적)** — 이만한 분량을 매 세션 컨텍스트에 올릴 이유가 없어 온디맨드로 둠, 선행 맥락이 필요할 때 grep해서 열 것. 자동생성 전환 예정(`research/doc-include-plan.md`) | | `question.md` | 사용자가 답해야 할 열린 질문(우선순위순) | -| `pre-implementation-qa.md` | **[2026-08-18 신설]** 구현 전 QA — `base/` 확정 문서 전체를 사용자에게 문항으로 재확인받아, **"아니오"가 나온 항목만** 모은 결함 목록. 사용자가 정정 회신을 주기 전까지 해당 부분은 **미해결 결함**으로 보고 착수하지 않는다. 신규 요구사항(`N-n`)과 부수 오탈자도 같이 담음. 진행 현황/커버리지의 소스는 그 문서 맨 아래 "진행 로그" 절 | ## 폴더 기준 @@ -25,9 +24,9 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스). **2라운드 예정** — 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | -| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 빈 폴더로 존재하지만 여긴 그것도 아직 아님 | +| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | | `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함) | | `tools/` | **[2026-08-13 신설, `session/2026-08-13-09-structure-and-guardrails.md`]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **[2026-08-16]** 절 참조는 WARN이 아니라 **ERROR** — 판정 규칙은 `conventions.md`의 "절 인용 규약"이 소스. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | @@ -46,26 +45,26 @@ |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` | -| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유) | -| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로이고 `store "key"` 문자열 커링은 동적 키용 미타입 폴백, 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | -| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요) | -| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화) | -| `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md` | -| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외 | -| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | +| `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | +| `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | +| `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅 | +| `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | +| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가 | +| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | -| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강 | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | +| `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` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | -| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드) | -| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) | +| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`) | -| `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 `false`를 넣으면 disconnect. 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | -| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **분리는 순수 이동** | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정 | +| `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | +| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `Brand.get`이 `x == None`을 먼저 보는 특수 분기 안은 기각(`isNone`은 그냥 `v == None`, `None`을 평범하게 태깅하는 건 무방). **분리는 순수 이동** | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 4bcb23b..e193519 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -20,6 +20,27 @@ > 유효한 규약은 `base/typing-limits.md`. 나머지(0-Z/0-A/0-B 등)는 > 여전히 `question.md`가 소스. +## [해소됨, 2026-08-18 구현 전 QA] `DI` → `D`(Declarative) 리네임 확정 + +2026-08-08 용어 정리 라운드부터 `question.md` **1순위**로 열려 있던 항목. +사용자가 구현 전 QA 라운드 중 직접 확정("이거 하면서 DI => D 확정하자"). + +- **확정 내용 두 갈래**: (1) 네임스페이스/모듈 자체는 `DI` → **`D`** + (`D.Frame` / `D/init.luau` / `D.InstSlot` / `D.FrameModifier`), + (2) "특수 DI 키"라는 **설명용 표현**은 `D`로 바꾸지 않고 **"특수 키"로 + 단순화**(수식어를 빼도 문맥상 통한다는 판단). +- **`D`로 가는 근거**: (1) "Instance" 전용 개념이 아니라 quad-* 전반의 + declare 요소로 확장 가능한 이름, (2) 엔진 종속 없이 다른 백엔드에서도 + 재사용 가능, (3) `D.FrameModifier`류 타입 프리픽스가 짧아야 한다는 실용적 + 제약. 원래 이름 `DI`의 문제는 **"Dependency Injection"과 완전히 겹쳐 실제로 + 오해가 있었던 전례**. +- **2026-08-08에 확정을 미룬 유일한 사유였던 "한 글자 식별자의 검색성/ + 자기설명력"은 표기 규약으로 보완** — 문서에서 `D`가 처음 나오는 자리에서는 + 항상 `D`(Declarative)로 풀어쓴다(`base/architecture.md`의 "코드 스타일 — + 네이밍 케이싱" 절). +- 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 + 네이밍 인체공학" 절이 소스. + --- # 확인/결정 필요 목록 diff --git a/.claude/archive/tag-attribute-load-time-registration-reversed.md b/.claude/archive/tag-attribute-load-time-registration-reversed.md index 2106257..a1941aa 100644 --- a/.claude/archive/tag-attribute-load-time-registration-reversed.md +++ b/.claude/archive/tag-attribute-load-time-registration-reversed.md @@ -1,8 +1,23 @@ -# [역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음 +# [역전됨 → 절반 재역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음 **상태**: archive — 2026-08-14 열두 번째 세션(이 대화)에서 역전. 정본은 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절. +> **⚠️ [재역전, 2026-08-18 구현 전 QA] 이 문서가 폐기했던 두 결론 중 +> "등록 주체" 쪽은 다시 뒤집혀 현행이 됐다.** 사용자 확정 — +> Fallback Handler는 **quad-base 자신이 등록**한다. 이유는 백엔드를 +> 로드하지 않았을 때 "provider가 초기화됐는지 확인하라"는 안내 경로가 +> 아예 안 도는 것("quad-roblox 를 로드하지 않았을 때 로드했는지 물어보는 +> 요소가 처리가 안 된다"), 그리고 `InitNamespace` 거부 원칙은 *남의 상태를 +> 건드리는 top-level 부작용*과 *사용자 수동 init*을 금지한 것이지 모듈이 +> 자기 레지스트리를 채우는 걸 금지한 게 아니라는 것. 근거 전문은 +> `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" +> 절이 소스. +> **이 문서의 "이름" 쪽 결론(등록되는 건 `TagHandler`가 아니라 +> `TagFallbackHandler`)은 그대로 유효**하다 — 아래 2번 항목. +> 즉 이 문서는 이제 "폐기된 설계의 보존"이 아니라 **한 번 뒤집혔다가 +> 절반이 되돌아온 경위 기록**이다. + ## 뒤집힌 주장 (2026-08-14 열한 번째 세션, "네 번째, 최종 정정") `TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 **quad-base diff --git a/.claude/audit/gcconn-trick-verification.md b/.claude/audit/gcconn-trick-verification.md index 1ea97b2..ed7c99c 100644 --- a/.claude/audit/gcconn-trick-verification.md +++ b/.claude/audit/gcconn-trick-verification.md @@ -77,10 +77,12 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아 - **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용** — `bindLifetime`/ `unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection - 메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신]** 게이트는 - `canBound(value)`이고(`if canBound(v) then error(...) end`, `canExecute`는 - emit 게이팅 전용으로 분리 — `lifecycle-pattern.md` "`canBound` vs - `canExecute`" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 + 메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신, 2026-08-18 + 방향 정정]** 게이트는 `canBound(value)`이고(`if not canBound(v) then + error(...) end` — `canBound` 참 = "지금 묶어도 됨", `canExecute`는 + emit 게이팅 전용으로 분리되며 둘은 서로의 부정 — + `lifecycle-pattern.md` "`canBound` vs `canExecute`" 절), + 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는 통과, (c) **`inst`가 Destroy된 뒤에도 통과**(모델이 명시적으로 허용). 전부 미해소이고, 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 78306aa..7d9b744 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -34,12 +34,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. -4. **PA님 스타일 DI 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`, +4. **PA님 스타일 특수 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`, 2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와 이름 충돌 방지로 리네임) 같은 특수 바인드 키, store 컴퓨티드 바인드도 가능해야 함 (`retract`, 구 cleanup, `base/lifecycle-pattern.md` 참고). **[정정, 2026-08-08 세 번째 세션]** `Tag`는 더 이상 `[Tag ""] = true` 해시 파트 - DI 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨, + 특수 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨, `base/tag-plan.md` 참고(`archive/tag-hash-key-model-reversed.md`에 구 모델 보존). **[2026-08-11 아홉 번째 세션]** `Attribute(...)`도 여러 Store를 한 번에 attribute로 묶는 array-part 값 객체로 신설(`Tag`와 @@ -103,11 +103,24 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가 - `BaseModule`을 뮤테이션" 패턴(14번) 그대로, `New()`가 생기면 매번 새 - `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler - 레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy` - 마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는 + `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 `BaseModule` + 테이블을 만들어 팩토리로 채우는 것뿐. 상세 근거는 `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 그렇게만 하면 지금 + module-level state로 사는 모든 것(`_initializedBy` 마커, Dispatch + 레지스트리 등)이 **"자동으로" 테이블별 스코핑된다**고 했는데, 사용자 + 판정은 다르다 — *"모듈이 하나의 인스턴스(dispatch 레지스트리 하나, + canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 … + require 를 감싸지는 않고 단순히 InitModule(module) 등을 받도록 각 + 코드들을 약간 고쳐서 이것을 해결함."* 즉 **코드 변경 없이 자동으로** + 되는 게 아니라, module-level state를 참조하는 코드들이 모듈 인스턴스를 + 인자로 받도록 **손을 대야** 한다. 미래 API 이름도 `New()`가 아니라 + **`Quad()`**(`Quad()`를 부르면 새 quad 인스턴스가 나오는 식)이고, + **지금은 단순 싱글톤이라 `Quad()` 없이 `Quad.Dispatch`로 바로 접근**한다. + - **M0 스캐폴딩에 주는 함의**: 레지스트리를 module-level upvalue로 + 직접 잡아두면 나중에 다중 인스턴스화할 때 전면 수정이 된다. 지금 + 싱글톤으로 가되, **그 참조 형태를 나중에 인자 하나 받는 걸로 바꾸기 + 쉬운 모양으로 둘지**를 M0에서 정할 것. 14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 @@ -167,9 +180,10 @@ quad/ │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) +│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive`의 `None` 스킵 분기 폐기) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) -│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) -│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨 +│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) +│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**) │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈 │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 @@ -184,16 +198,16 @@ quad/ └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) - ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) + ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Event.luau # ReflectionService 기반 자동 판별 - │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) + │ ├── OnChange.luau # `OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) ├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`) - ├── DI/ - │ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances) + ├── D/ + │ └── init.luau # **전량 코드 생성 산출물** — 제네릭 생성자 `New`(커링: `New "Frame" {...}`) + 클래스별 정적 별칭 필드(`D.Frame = New<> "Frame" :: (({...}) -> Frame)`). 생성 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입/`State`/`None`까지 타입으로 찍음(**[2026-08-18 확정]** `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) └── init.luau ``` @@ -221,10 +235,26 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 `:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/ `:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝). 3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 — - `Modifier.Overridden(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는 - 아니지만(여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 - 됨) `Modifier` 타입 고유의 공개 연산이라는 점에서 1/2과 같은 부류 — - `Modifier()` 생성자와 같은 이유로 대문자. + `Modifier.Overridden(mod1, mod2, ...)`, `Attribute.Merged(...)`/ + `Attribute.Overridden(...)`. **그 프리미티브 타입 고유의 공개 연산** + 이라는 점에서 1/2과 같은 부류 — `Modifier()`/`Attribute()` 생성자와 + 같은 이유로 대문자. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 이 분류를 만든 근거로 + *"콜론 메서드는 아니지만 (여러 Modifier를 동등한 인자로 받아야 해서 + self 하나로 안 됨)"* 을 들었는데 **그 근거는 성립하지 않는다** — + `Overridden`은 **콜론 체이닝(`a:Overridden(b)`)으로도 제공**된다 + (사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서 + 직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함"*). 분류 자체는 + "닷 접근으로도 부를 수 있는 정적 결합 함수"라는 표면 차이로 유지하되, + 2번(콜론 메서드)과 배타적이지 않다는 점에 유의. + 4. **`D`(Declarative) 네임스페이스와 그 필드**(`D.Frame`/`D.InstSlot`/ + `D.FrameModifier`) 및 생성자 `New` — **[2026-08-18 신설]** 프리미티브 + 타입은 아니지만 사용자가 직접 쓰는 선언형 표면이라 대문자. + **표기 규약: 문서에서 `D`가 처음 나오는 자리에서는 항상 + `D`(Declarative)로 풀어쓸 것** — 한 글자 식별자라 grep이 어렵고 + 이름만으로 뜻이 안 드러난다는 게 2026-08-08부터 개명을 미뤄온 유일한 + 사유였고, 이 표기 규약이 그 보완책으로 같이 확정됐다 + (`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). - **소문자 시작(camelCase)** — 특정 프리미티브 타입 하나에 안 묶이고 여러 타입을 넘나드는 범용 유틸(`isState`/`isSource`/`isRef`/`isPreRef`/ `isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/ @@ -331,7 +361,7 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 `archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — `:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] -읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만 +읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만 다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. `store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 아님**, [2026-08-04 기준] 남은 건 정확한 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 28945ec..64c891a 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -30,7 +30,7 @@ Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attri 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 `Attribute<>` → `AttributeKey<>`로 리네임(잠정 확정 — 최종 이름은 여전히 `.claude/question.md` 용어정리 대기열). `[AttributeKey "Name"]`(구 -`[Attribute "Name"]`) DI 키의 존재 자체는 `architecture.md` 4번 항목에서 +`[Attribute "Name"]`) 특수 키의 존재 자체는 `architecture.md` 4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute에도 적용해야 한다는 사용자 지적) — @@ -63,15 +63,19 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att `Attribute`가 아니라 이미 타입별로 갈라져 있어 아래 그룹 `Attribute(...)`와 겹치지 않음 — 리네임 대상 아님.** -**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 +**근거**: 이미 확정된 `D`(Declarative) 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 -재사용 — `new(className)` 제네릭 생성자 + 자주 쓰는 ~25개는 -정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히 +재사용 — 제네릭 생성자 하나 + 클래스별 정적 필드를 미리 바인딩하는 +것과 동일한 절충(**[정정, 2026-08-18 구현 전 QA]** 그 생성자의 이름은 +`New`이고 타입은 `new(className): from>` 같은 타입 +레벨 인덱싱이 **아니라 생성기가 찍어내며**, 범위도 "자주 쓰는 ~25개"가 +아니라 "GUI에 쓰이는 모든 인스턴스"다 — 재사용하는 건 절충의 **모양**이지 +그 수치가 아님). **내부 구현은 완전히 동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히 호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 쪽을 쓰든 런타임 동작에 차이 없음. -**[실측 필요, M0/M10]** `[AttributeKey<> "name"] = value`처럼 DI +**[실측 필요, M0/M10]** `[AttributeKey<> "name"] = value`처럼 특수 키 제네릭 파라미터로 `=` 뒤 `value`의 타입까지 실제로 좁혀지는지는 미검증 — Luau 솔버가 이 조합을 못 풀면 `value`가 `any`로 남을 수 있음. 단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 @@ -218,6 +222,30 @@ function AttributeKeyHandler.process(inst, k, v, index) end ``` +- **⚠️ [열린 항목, 2026-08-18 구현 전 QA] 같은 그룹 객체를 두 위치에 놓는 + 경우(`Frame { a, a }`)는 이 claim으로 안 잡힌다 — 위치별 claim이 따로 + 필요하다.** `groupKey(v, name)`이 **그룹 값 객체별·이름별 메모이즈**라, + 같은 객체 `a`가 `k=1`과 `k=2`에 놓이면 양쪽이 **완전히 같은 키 객체**로 + 위임한다. 그러면 위 `nameClaims` 체크는 `cur == k`라 **통과**하고, 두 + 위치가 `(inst, 같은 key)`라는 **하나의 체인을 공유**하게 된다 — 더 + 치명적인 건 철거로, `k=1`이 retract되면 그 클로저가 + `Dispatch.retractFrom(inst, key, 1)`을 불러 **`k=2`가 아직 쓰고 있는 + 바인딩까지 통째로 철거**한다. + - **`Ref`의 "이중 배치 방지"(`base/ref-plan.md`)와 같은 클래스의 + 문제지만 같은 해법을 쓸 수 없다** — 사용자 판정: *"Attribute 는 + bindLifetime 를 못함. Ref 와 다르게 여기저기서 사용 가능하기 때문. 한 + 곳에서 바운딩 했다고 다시 바운딩 못할 순 없음. 따라서 위치별 claim 을 + 하나 두어야한다고 생각함."* `Ref`는 "한 곳에만 배치"가 규칙이지만 + 그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다. + - **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹 + 객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다. + - **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지 + (`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존 + `nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠. + - **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 + **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) + — 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹 + `Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다. - **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는 `slot.retractor(v)` → `h.process(...)` 순서이고, 핸들러가 바뀌는 경우는 `retractFrom` → `process` 순서(`base/dispatch-core-plan.md` "Dispatch @@ -270,7 +298,8 @@ Store 필드 여러 개를 각각 `[AttributeKey<> "name"] = store.name`으 ``` Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음 -Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성) +Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error +Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김 attr:NameMap(): {[string]: Source} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것) Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일) ``` @@ -284,6 +313,26 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 Store 기각과 안 부딪히나" 참고. +**[해소, 2026-08-18 구현 전 QA] 이름 겹침 정책은 `Merged`/`Overridden` 둘 +다 제공하는 것으로 확정** — 열려 있던 "겹치면 error냐 뒤가 이기냐"는 +**둘 중 하나를 고르는 문제가 아니었다**. 사용자 판정: *"차라리 Merged, +Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자는 그냥 조용히 +덮어써주는것. 사용자 의도에 따라 달라질 부분이라 분리해주는것이 +이로워보임."* + +- `Attribute.Merged(a, b, ...)` — 같은 이름이 겹치면 **즉시 error**(합성 + 시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시 + error)과 결이 같음. +- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**. +- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지 + `Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기, + `Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선 + **충돌 시 정책**(error냐 덮어쓰기냐)을 가른다. `base/tag-plan.md`가 + `Tag.Merged` 코드 주석에서 두 이름을 "집합 합치기 vs 이미 계산된 것 + 합치기"로 대조해둔 서술과 같이 읽을 것. +- **`Tag`에는 `Overridden`이 필요 없다** — `Tag`는 이름 집합의 합집합이라 + 애초에 "충돌"이라는 개념이 없다(겹치면 그냥 하나로 합쳐지는 게 정답). + ### 메커니즘 — 그룹 전용 키로 단일 키 경로에 위임 (2026-08-13 열네 번째 세션 확정) **[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler, @@ -445,8 +494,8 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 | 그룹 값 타입+API(`Attribute(...)`/`Merged`/`:NameMap`) | quad-base | | 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시 | quad-base | | 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) | quad-base | -| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨(아래 참고) | -| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) | +| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 등록됨 — **[재역전, 2026-08-18] 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절) | +| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D` 층) | | **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 | - **왜 타입 패밀리만 갈리는가**: Roblox attribute가 받는 타입 집합 @@ -458,9 +507,10 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 claim 알고리즘 구현일 뿐, 스스로 등록되는 주체가 아님(2026-08-14 열두 번째 세션 정정).** `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 `AttributeKeyFallbackHandler`/ - `AttributeGroupFallbackHandler` — 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리(`BaseModule` 뮤테이션 시점, 자기 전용 Handler들과 - 같이 등록). 옛 "quad-base 모듈 로드 시점에 스스로 등록" 모델은 + `AttributeGroupFallbackHandler` — **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 + 상태에서도 안내 에러 경로가 돌아야 하기 때문, `base/dispatch-core-plan.md`의 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스). 경위는 `archive/tag-attribute-load-time-registration-reversed.md`. `setAttribute`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 진짜 @@ -481,16 +531,13 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 하강 diff 재디스패치(0-A)는 확정·반영 완료** — 위 "이름 소유권"/ "메커니즘" 절이 정본, 뒤집힌 옛 모델은 `archive/dispatch-hintvalue-model-reversed.md`. -- **[열림, 사소함, 2026-08-13 열네 번째 세션 신설] `Attribute.Merged`에서 - 두 Store가 같은 이름을 가지면 지금은 조용히 하나가 이김** — - `:NameMap()` 평탄화가 dispatch 이전 단계라 위 이름 claim이 못 잡는 - 자리. 이름 겹침을 error로 잡는 게 이 문서의 다른 결정들과 결이 같고 - 구현도 싸지만(합성 시점 1회 체크), "Merged는 뒤가 이긴다"를 의도된 - override로 볼 여지도 있어서 사용자 확인 대기 — `question.md` 3번. +- **[해소, 2026-08-18 구현 전 QA] `Attribute.Merged`의 이름 겹침 정책** — + `Merged`(error)와 `Overridden`(뒤가 이김)을 **둘 다 제공**하는 것으로 + 확정. 상세는 위 "채택안 — `Tag`와 동형인 array-part 값 객체" 절. - **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은 `Attribute`, 단일 키는 `AttributeKey<>`로 코드/문서 전체 통일해서 - 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/ - `Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 + 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`Slot`/ + `canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 대기열에 있음, 나중에 한꺼번에 재검토. - **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도 `setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index a15bd73..9d80180 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -8,7 +8,7 @@ > | 나간 것 | 어디로 | 단계 | > |---|---|---| > | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | 1단계(9차 세션) | -> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | 1단계 | +> | 이벤트 바인딩(self 미전달, `None`/`nil`로 disconnect) | `base/event-plan.md` | 1단계 | > | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | 1단계 | > | **디스패치 코어**(핸들러 계약 / 디스패치 모델 / `chains`·`retractFrom` / 체크리스트 / Length·Offset) | **`base/dispatch-core-plan.md`** | **2단계(14차 세션)** | > | **반응형 코어**(Source/State 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`/`previous`, `Observer`, 구독·생명주기 게이트) | **`base/source-state-plan.md`** | **3단계(2026-08-14)** | @@ -53,7 +53,7 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 - **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API, `Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**. - **이벤트 바인딩** — 핸들러가 self(Instance)를 안 받는다는 확정, 이벤트도 - store-bind 가능(`false`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 + store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절에 그대로 있음. - **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, @@ -123,19 +123,73 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 문제와 같은 원인). 사용자가 실제 참고 코드를 `.claude/initreq/artworks/DeclarativeProgramming/ DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 -데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라 -"Declarative Instance"(선언형 인스턴스 생성)**. +데모 모듈)에 공유해줘서 직접 확인 — 원래 가칭 `DI`는 Dependency Injection이 +아니라 "Declarative Instance"(선언형 인스턴스 생성)의 약자였음. +**[2026-08-18 확정] 네임스페이스 이름은 `D`(Declarative)** — `DI`는 Dependency +Injection과 완전히 겹쳐 실제로 오해가 있었던 전례가 있고, `D`는 (1) +"Instance" 전용 개념이 아니라 quad-* 전반의 declare 요소로 확장 가능하며, +(2) 엔진 종속 없이 다른 백엔드에서도 재사용 가능하고, (3) `D.FrameModifier`류 +타입 프리픽스가 짧아야 한다는 실용적 제약을 만족한다. 한 글자 식별자라 +grep이 어렵고 이름만으로 뜻이 안 드러나는 게 유일한 단점이었으므로, +**문서에서 `D`가 처음 나오는 자리에서는 항상 `D`(Declarative)로 풀어쓴다** +(표기 규약은 `base/architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절). -**인스턴스 생성 — PA님 코드 그대로 채택**: 처음 제안했던 "필드=1급 타입 -경로, 문자열=폴백"이라는 2트랙(`DI.Frame` vs `DI.New<> "Frame"`) 구상 -보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) — -**제네릭 생성자 함수 하나(`new(className): from>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 UI에서 자주 -쓰는 클래스 ~25개(`Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입 -테이블에 등록된 것들)만 모듈 로드 시점에 **즉시(eager)** `constructor.Frame = -new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지연 생성이 -아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭 -생성자 + 자주 쓰는 것만 정적으로 미리 바인딩. +**인스턴스 생성 — 호출 모양은 PA님 코드 그대로, 타입은 생성기가 만든다 +([2026-08-18 구현 전 QA에서 후자를 정정])**: 처음 제안했던 "필드=1급 타입 +경로, 문자열=폴백"이라는 2트랙 구상보다 실제 호출 모양은 더 단순했음 +(`DeclarativeInstance.luau:104-160`) — **제네릭 생성자 함수 하나 + +자주 쓰는 클래스를 필드로 미리 채운 정적 테이블**(`constructor.Frame = +new("Frame")`, `__index` 지연 생성이 아니라 eager). quad-v2도 이 모양을 +그대로 채택한다. **다만 PA님 코드가 그 필드 타입을 뽑는 방식** +(`new(className): from>` — 타입 +레벨 인덱싱)**은 채택하지 않는다**: + +1. **이벤트 필드가 콜백 타입이 안 나온다.** Roblox 타입 정의에서 + `MouseButton1Click`은 시그널 계열 타입이라, 인덱싱으로 뽑으면 + `RBXScriptSignal`이 그대로 나오고 quad가 원하는 `((...) -> ())?` 콜백 + 시그니처가 안 나옴. +2. **LSP마다 `Frame` 타입을 다루는 방식이 다를 수 있어** 타입 함수/인덱싱에 + 의존하는 게 위험하다. +3. **`T | State`(그리고 `T | Tween`, `None`/`nil` 등)까지 타입 함수로 + 조립해야 하는데**, 그럴 바엔 `D` 파일을 통째로 생성하는 쪽이 단순하다. + +**따라서 `D`는 전량 코드 생성 산출물이다** — 타입뿐 아니라 `New` 호출문까지 +생성기가 찍어낸다(사용자: *"전부 코드 생성이나, New 같은것도 생성기에서 +같이 적어주어야할 부분"*). 손으로 쓰지 않는다. + +**`New`는 커링, `D`는 처리 없는 별칭 테이블 (2026-08-18 확정)**: + +```luau +-- New(name)이 생성자 함수를 반환하고, 그걸 props 테이블로 다시 호출 +New "Frame" { ... } -- == New("Frame")({ ... }) +New<> "Frame" { ... } -- 직접 사용도 같은 모양 + +-- D는 그 결과에 캐스트만 얹은 순수 별칭 테이블 (생성기 산출물) +D.Frame = New<> "Frame" :: (({ ...타입명시 }) -> Frame) +``` + +- **이름은 대문자 `New`로 통일**(사용자 확정: *"2. New입니다."*) — PA님 코드 + 인용의 소문자 `new`와 섞여 있던 것을 정리. +- **뒤집는 게 아니라 명시화**다 — PA님 패턴의 `constructor.Frame = + new("Frame")`이 이미 사실상 커링이었고, 다만 (a) "커링이다", (b) 2단계 호출 + 계약, (c) `New "Name" {...}`라는 직접 호출 형태가 문서에 적힌 적이 없었다. +- **기각된 "2트랙"과 혼동하지 말 것** — 기각된 건 *"필드=1급 타입 경로, + 문자열=폴백"* 이라는 **능력 차이**였지 `New`라는 이름이나 문자열 호출 + 자체가 아니다. 이 확정은 오히려 두 형태가 **완전히 같은 것**(하나가 다른 + 하나의 미리 적용된 결과)임을 못박는다. +- **생성 범위는 "GUI에 쓰이는 모든 인스턴스"**(사용자 확정) — 예전 서술의 + "자주 쓰는 ~25개"도 아니고 Roblox 전체 클래스도 아님. 전량 생성하면 `D` + 파일이 너무 커진다는 게 이유. "GUI에 쓰이는"의 정확한 판정 기준(API + 덤프에서 `GuiObject` 하위 + `UIComponent` 하위 + `LayerCollector`류 등)은 + 생성기 구현 시점에 정한다. +- **범위 밖 클래스는 느슨하게 `any`**(사용자 확정: *"느슨하게 any 로 하고, + 필요하면 이를 직접 구현 가능하게 둡니다. cast 를 하든, 유저의 자유"*) — + `New<> "X" {...}`를 직접 쓰면 props 타입은 `any`이고, 필요하면 사용자가 + `::` 캐스트로 좁힌다. 새 확장 지점을 만드는 게 아니라 `D.Frame` 자신이 + 이미 캐스트 한 줄이므로 **같은 한 줄을 사용자가 직접 쓰면 되는 것**. + 따라서 **"제네릭 생성자 함수 하나가 알려진 타입과 모르는 타입을 전부 + 커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만 + 정확하고 밖은 `any`다. **이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`의 @@ -143,25 +197,51 @@ new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지 `GetEventsOfClass`로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가 `RBXScriptSignal` 타입이면 자동으로 `instance[key]:Connect(value)`로 처리함 — `Frame { MouseButton1Click = fn }`처럼 별도 네임스페이스 없이 그냥 문자열 -키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 -Luau가 검증 못 함 — `apply(instance: T, properties: U): T & U`가 스키마 -검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값 -타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, `On.` 접두어 없이 -문법이 더 간결해짐 — **사용자 확정**("PA 님 방식 괜찮은듯. 타이핑은 인라인이 -되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가" +키로 씀. `On.` 접두어 없이 문법이 더 간결해짐 — **사용자 확정**("PA 님 +방식 괜찮은듯. 타이핑은 인라인이 되긴 하겠지 정도면 괜찮다"). + +**[정정, 2026-08-18 구현 전 QA] "콜백 시그니처까지 Luau가 검증 못 한다"는 +서술은 거짓이었음.** 옛 문장은 이 방식이 *"타입 안전성을 어느 정도 포기하는 +대가"* 이고 *"콜백 시그니처까지 Luau가 검증 못 함"* 이라고 적었는데, 사용자가 +직접 반례를 작성해 보여줬다: + +```luau +function Frame (prop: {MouseButton1Click: ((a: number)->())?}) +end + +Frame{ + MouseButton1Click = function(a) -- a: number 로 추론됨 + end +} +``` + +props 테이블 **타입에 필드로 선언돼 있으면 콜백 파라미터가 그대로 +추론된다.** 런타임 판별을 `ReflectionService`로 하는 것과 **타입을 생성기가 +제공하는 것은 완전히 별개 축**인데 옛 서술이 둘을 묶어버린 것. +따라서 이건 "감수하는 대가"가 아니라 **`D` 생성기가 챙겨야 하는 구현 +체크리스트 항목**이다 — 생성기는 클래스별 props 타입에 **이벤트 필드까지 +정확한 콜백 타입으로** 포함시켜야 하고, 값 타입은 콜백뿐 아니라 +`State<...>`와 disconnect 센티널(`None`/`nil`, `base/event-plan.md`)까지 +포함하는 유니온이어야 한다. 이건 위 "타입은 생성기가 만든다"의 직접적 +근거이기도 하다(인덱싱으로는 시그널 타입이 그대로 나와서 안 됨) — 두 항목은 +같은 문제의 양면이므로 같이 볼 것. quad-v2 구현에서는 이 "키가 이벤트인가" 판별을 `isHandlable`로 감싼 pluggable 핸들러(`quad-roblox`가 `Reflection Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 구조 자체가 불필요해짐. -**Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ -`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 +**Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가 타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 -평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 -유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 -깨진 건 아님. +평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지. +**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로 +같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source`의 +`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상 +필요 없어짐). 동적 키는 명시적 `store:GetDynamic<>(name)`으로 간다 — +`base/store-plan.md`가 소스. +**이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라 +**이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다). **`GetPropertyChangedSignal`은 이 문자열 키 패턴이 안 통함 — 별도 `OnChange` -DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 +특수 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 그대로 `Connect`하면 되지만, `GetPropertyChangedSignal(name)`은 프로퍼티 이름을 인자로 받아야 하고 그 이름이 "값 세팅" 키 네임스페이스와 겹쳐서 평범한 문자열 키로는 세팅과 리스닝을 구분할 수 없음 — 상세는 @@ -196,10 +276,10 @@ DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal `RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 그 라운드들 기준으로 남았던 건 순수 API 표면 이름뿐이었음: -- **`DI`(또는 다른 이름) 등 정확한 모듈 이름** — 방향은 전부 확정, 이름만 - 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 - 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). Source/State - 쪽 이름 문제는 `base/source-state-plan.md`가 소스. +- **[해소, 2026-08-18] 모듈 이름은 `D`로 확정** — 옛 항목("`DI`(또는 다른 + 이름) 등 정확한 모듈 이름")은 닫혔다. 근거는 위 "인스턴스 생성 / 이벤트 + 네이밍 인체공학" 절. Source/State 쪽 이름 문제는 + `base/source-state-plan.md`가 소스. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). diff --git a/.claude/base/brand-plan.md b/.claude/base/brand-plan.md index ca517b7..5cf50d7 100644 --- a/.claude/base/brand-plan.md +++ b/.claude/base/brand-plan.md @@ -125,15 +125,25 @@ wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 들어오면 즉시 error" 절이 필요로 해서 이번에 같이 적음. -**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** -`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 -quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` -레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든 -값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 -"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도 -빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 -특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 -바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음). +**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 — +`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용 +introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이 +내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 +레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다. +사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 +의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == +None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."* + +- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장 + 밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다. +- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이 + 레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단 + 자체는 그대로 유효. +- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가 + 허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉 + "범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한 + 등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는 + 것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다. **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index b67a9ff..d114b27 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -238,9 +238,16 @@ return Frame { props.Modifier or None, props.Ref or None, child } 필요 없고, 기존 메커니즘을 그대로 재사용함 — `flatten` 단계는 애초에 `isModifier(v)`가 거짓인 값은 그냥 건드리지 않고 통과시키므로 (`None`은 Modifier가 아니라서 자동으로 이 경로), `props.Modifier or - None`이 최종적으로 배열 파트에 `None`인 채로 남으면 두 패스 루프 - 자신의 array-part `None`-스킵 규칙(위 "PreRef" 절)이 그대로 적용돼 - 아무 일도 안 일어남 — 새 특수 케이스 코드가 하나도 안 늘어남. + None`이 최종적으로 배열 파트에 `None`인 채로 남으면 **그 자리는 기여 + 0으로 정상 처리된다** — 새 특수 케이스 코드가 하나도 안 늘어남. + **[근거 정정, 2026-08-18 구현 전 QA]** 예전엔 근거를 "두 패스 루프 + 자신의 array-part `None`-스킵 규칙"으로 적었는데, 그 스킵 규칙 자체가 + 폐기됐다(반응형 값이 내놓는 `None`은 어차피 `Dispatch.process`에 + 도착하므로 — `base/dispatch-core-plan.md`의 "`None` 센티널" 절). 지금은 + `NoneHandler`가 매치돼 `nil`로 재귀하고 `NilHandler`가 `setLength(0)`/ + `setOffsetSource(None)`을 등록한다. **결론(`or None`을 쓰는 것)은 안 + 바뀜** — 여전히 "아무것도 안 놓은 것과 같은 효과"이고, 오히려 리터럴 + 경로와 반응형 경로가 같은 핸들러로 수렴해 더 단순해졌다. - 이 관용구는 컴포넌트 저작자가 **직접 챙겨야 하는 규율**(base가 강제로 검증해줄 방법은 없음, Lua는 이런 걸 린트로만 잡을 수 있음) — quad 문서화(초심자 가이드/`props.Modifier`/`props.Ref` 절)에 필수 패턴으로 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index e4a971a..4181d5f 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -134,7 +134,17 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 - **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 - 두 핸들러가 등록되면 콘솔에 경고를 찍고, `Dispatch.listHandlers()`류 + 두 핸들러가 등록되면 콘솔에 경고를 찍되, **[요구 추가, 2026-08-18 구현 전 + QA] 무조건 찍는 게 아니라 모듈 표면의 불리언 플래그 `Quad.debug`(기본 + `false`)가 `true`일 때만 찍는다**(사용자: *"동률 print 는 라이브러리가 + debug 모드일 때만. (Quad.debug: boolean = default false) 식이고, true 로 + 하면 디버깅 가능"*). `Quad.debug`는 **새 공개 API 표면**이라 + `base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고, + 다중 인스턴스화(`Quad()`, `base/architecture.md` "확정된 결정" 13번) 시 + 이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다. + `Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게 + 항상 호출 가능한지) 구현 시 정할 것. + 그리고 `Dispatch.listHandlers()`류 함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함. 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 @@ -312,7 +322,7 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 - **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, - 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 프로퍼티/이벤트/특수 키) 나중(2026-08-07 세 번째 세션).** Luau 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, @@ -323,9 +333,17 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 - 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 - 먼저 처리된다는 게 base 자체의 보장**이 됨 — `ref-plan.md`의 "Ref 일반화" 절 - 뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + 슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨. + **[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서 + 성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장 + 위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도 + pre-pass for 문**에서 먼저 처리되고 `flattened`에는 소진 + 마커(`ProcessedPreRef`/`ProcessedPostRef`)만 남는다(사용자: *"preref 랑 + postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은 + **서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이 + 아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가 + 프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제 Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). @@ -355,34 +373,49 @@ end 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — KV 매치와 무관. - **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 - `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" - 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`(`props.Ref or - None`처럼 애초에 아무것도 놓인 적 없는 자리)은 "빈 슬롯" - 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 - 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 - 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 - 경로가 다르다는 점에 유의. **[정정, 2026-08-14 두 번째 세션] "PreRef - pre-pass가 소진시킨 자리"는 이 규칙의 예가 아님** — 그 자리는 `None`이 - 아니라 별도 센티널 `ProcessedPreRef`로 소진되고, `ProcessedPreRefHandler` - (`base/ref-plan.md`의 "PreRef" 절)를 통해 정상 `Dispatch.process` - 경로를 그대로 탐(아래 "Length/Offset" 절 참고). **[2026-08-14 아홉 번째 - 세션] `PostRef`가 소진시킨 자리(`ProcessedPostRef`)도 완전히 같은 취급** - — 전용 센티널 + 전용 `ProcessedPostRefHandler`(`base/ref-plan.md`의 - "`PostRef`" 절), 즉 "정말 빈 자리인 `None`"만 두 패스 루프가 직접 - 건너뜀 — 예전엔 이 둘(원래부터 - 빈 자리 vs 한때 PreRef였다가 소진된 자리)이 똑같이 `None`으로 뭉뚱그려져 - `setLength`/`setOffsetSource` 등록 책임 소재가 불분명한 갭이 있었음 - (2026-08-14 첫 번째 세션 조사에서 발견), 지금은 서로 다른 센티널로 - 명확히 분리됨. + **[재설계, 2026-08-18 구현 전 QA] `NoneHandler`는 해시 파트 전용이 + 아니고, `Dispatch.drive`는 `None`을 건너뛰지 않는다.** 옛 서술은 + "배열 파트의 `None`은 두 패스 루프가 `Dispatch.process`를 거치지 않고 + 바로 건너뛴다"였는데, 그 전제 자체가 거짓이었음 — 리터럴 + `Frame{None}`만 생각하면 루프가 걸러내면 그만이지만 + **`Frame{ State }`처럼 반응형 값이 `None`을 내놓으면 그 + `None`은 `StoreBind`의 재귀를 타고 `Dispatch.process`에 그대로 + 도착**하기 때문. 사용자 판정: *"drive 는 v == None 인지 확인 안하고 + 그냥 프로세스 태우는게 가장 적절한 처리로 보임"*. 따라서: + - **`Dispatch.drive`에 `None` 특수 분기는 없다** — 배열이든 해시든 + 모든 `(k,v)`가 `Dispatch.process(inst,k,v,1)`을 탄다. + - **`NoneHandler`가 하는 일은 재귀 하나뿐** — `v == None`을 매치해 + `Dispatch.process(inst, k, nil, index+1)`로 내려보내는 것. 배열/해시 + 구분도 하지 않는다. + - **실질 정리(그리고 `setLength(0)`/`setOffsetSource(None)` 등록)는 + 아래 `NilHandler`가 맡는다** — 사용자 선택(2026-08-18): *"NoneHandler는 + 재귀만, NilHandler가 실질 담당"*. 즉 배열 자리가 비는 처리 로직은 + `None` 경로든 진짜 `nil` 경로든 **한 곳에만** 있다. + - **`process` 자체가 이전 것을 걷어낸다** — `Tag` → `None` 전환에서 + 이전 `Tag` 기여가 실제로 사라져야 하는데, 이건 하강 diff가 자동으로 + 해준다(핸들러가 `TagHandler`에서 `NoneHandler`로 바뀌므로 아래 + "Dispatch 체인" 절 (B) 분기가 `retractFrom`을 부름). `NoneHandler`가 + 반환하는 retractor 자체는 no-op이어도 된다. + + **`ProcessedPreRef`/`ProcessedPostRef`는 그대로 별개다** — pre-pass가 + 소진시킨 자리는 `None`이 아니라 전용 센티널로 채워지고 전용 nop + 핸들러(`ProcessedPreRefHandler`/`ProcessedPostRefHandler`, + `base/ref-plan.md`의 "PreRef"/"`PostRef`" 절)가 정상 `Dispatch.process` + 경로에서 캐치한다. 예전엔 "원래부터 빈 자리"와 "한때 PreRef였다가 소진된 + 자리"가 똑같이 `None`으로 뭉뚱그려져 등록 책임 소재가 불분명한 갭이 + 있었고(2026-08-14 첫 번째 세션 조사), 지금은 서로 다른 센티널로 명확히 + 분리돼 있음. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 - `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 - 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `v == nil`이 아님 — 진짜 `nil`은 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커(반응형 값이 + 내놓는 진짜 `nil`은 아래 `NilHandler`가 받는다). `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 - 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 - 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — - `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 - 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 그 + `nil`을 담당하는 핸들러로 흘러감 — 배열 자리(`k`가 숫자)면 `NilHandler`, + 해시 자리면 키 `k`를 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand + 등)로. `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 + 좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. - **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 @@ -404,9 +437,10 @@ end OnChangeHandler/UICornerHandler 등)은 팩토리가 `BaseModule`을 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 같은 패턴, 새 메커니즘 아님). **`Tag`/`Attribute`의 base 소유 - Fallback Handler들(`TagFallbackHandler` 등)도 같은 팩토리 뮤테이션 - 시점에 같이 등록됨** — quad-base 모듈 로드 자체의 부작용이 아님, - 상세는 아래 "base가 소유하는 핸들러와 주입되는 엔진 op" 절. + Fallback Handler들(`TagFallbackHandler` 등)은 이와 달리 quad-base + 자신이 등록함**(**[재역전, 2026-08-18 구현 전 QA]** — 백엔드가 하나도 + 안 붙은 상태에서도 안내 에러 경로가 돌아야 하기 때문), 상세는 아래 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절. - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 @@ -463,6 +497,51 @@ end 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). +### `NilHandler` — 배열 자리의 진짜 `nil`을 받는 짝 핸들러 (2026-08-18 신설, 사용자 요구) + +**왜 필요한가**: 반응형 값이 `None`이 아니라 **진짜 `nil`** 을 내놓는 +경우(`State`)도 정상 동작해야 한다는 사용자 요구. `None`을 +쓰라고 강제하지 않는다 — *"State 일 수도 있지만, +State 이여도 작동은 함"*. + +```lua +NilHandler.priority = <매우 높음> +NilHandler.isHandlable(inst, k, v) = (type(k) == "number" and v == nil) +function NilHandler.process(inst, k, v, index) + -- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다. + -- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가 + -- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 — + -- setLength가 끝에서 recompute를 돌리므로 반대로 하면 죽는 중인 서브트리의 + -- Source에 :Set()이 날아간다). [2026-08-18 감사에서 순서 정정] + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0) + return function() end +end +``` + +- **매치 범위는 `k`가 숫자인 자리로 한정** — 해시 자리의 `nil`은 그 키를 + 원래 담당하던 핸들러(프로퍼티/이벤트)의 몫이다(`None` 재귀가 도착하는 + 기존 경로 그대로, 위 절). 이벤트 키에서 `nil`이 disconnect를 뜻한다는 + 규정은 `base/event-plan.md`가 소스. +- **재귀는 하지 않는다** — 이미 `nil`이라 더 내려보낼 곳이 없다. + `NoneHandler`가 재귀만 담당하고 여기로 흘려보내므로, 배열 자리가 비는 + 처리 로직은 **이 한 곳에만** 있다(사용자 선택, 2026-08-18). +- **호출 순서는 `setOffsetSource` → `setLength`** — 아래 "해제(그 자리가 더 + 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절이 계약으로 + 고정해둔 순서를 그대로 따른다. (`base/ref-plan.md`의 + `ProcessedPreRefHandler`/`ProcessedPostRefHandler` 의사코드는 아직 반대 + 순서로 적혀 있음 — 이 세션 이전부터 있던 것이라 같이 고쳤다.) +- **`setLength(0)` / `setOffsetSource(None)`의 비대칭은 의도된 것** — + 타입이 각각 `number | State`와 `Source | None`이라서 + (`base/ref-plan.md`의 "왜 `None`이 아니라 `nil`인가" 절, 아래 + "Length/Offset" 절). +- **retractor는 no-op이어도 된다** — 이전 것의 철거는 하강 diff가 + `retractFrom`으로 해준다(위 `NoneHandler` 항목과 같은 이유). +- **"중간 노드는 `inst`에 부작용을 가하지 않는다"(아래 "Dispatch 체인" 절)와 + 충돌하지 않는다** — `setLength`/`setOffsetSource`는 `inst`의 프로퍼티를 + 건드리는 게 아니라 Dispatch 자신의 순서 부기이고, 애초에 `NilHandler`는 + 재위임을 하지 않는 **말단** 핸들러다. + ### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) `Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ @@ -511,10 +590,16 @@ end 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 - 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule - 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 - 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 + 스코핑됨, 재설계 불필요"). 다중 인스턴스화가 실제로 생기면 그 시점에 + BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 + 같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. + **[한정, 2026-08-18 구현 전 QA]** 다만 "재설계 불필요"가 **"코드 변경 + 불필요"는 아니다** — 사용자 판정에 따르면 그때는 module-level state를 + 참조하는 코드들이 모듈 인스턴스를 인자로 받도록(`InitModule(module)` 류) + 손을 봐야 하고, 미래 API 이름도 `New()`가 아니라 **`Quad()`**다 + (`base/architecture.md` "확정된 결정" 13번). 지금은 싱글톤이라 + `Quad.Dispatch`로 바로 접근한다. ### base가 소유하는 핸들러와 주입되는 엔진 op (2026-08-13 열네 번째 세션 신설) @@ -565,24 +650,42 @@ setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름 실제로 꽂히는 건 그 알고리즘을 그대로 감싸는 **별도 이름의 엔티티** (`TagFallbackHandler`/`AttributeKeyFallbackHandler`/ `AttributeGroupFallbackHandler`) — "이게 기본 안전망으로 자동 설치되는 -대상"임을 이름 자체로 구분한다. **등록 주체는 quad-base 모듈 자체가 -아니라 필요한 엔진(백엔드 팩토리)** — quad-roblox 같은 백엔드가 -`BaseModule`을 구성할 때 자기 전용 Handler들(Property/Event/OnChange/ -UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다(위 -`Dispatch.addHandler` 절과 같은 경로, `base/module-lifecycle-plan.md`가 -이미 확정해둔 "base는 인터페이스만, 등록/구현은 백엔드 팩토리가 -`BaseModule`을 뮤테이션하는 시점에" 원칙을 그대로 따르는 것뿐 — 새 -예외가 아님). "quad-base가 자기 모듈 로드 시점에 스스로 등록"이라던 -옛 모델은 정확히 `base/lifecycle-pattern.md`가 이미 거부해둔 -`InitNamespace`류 top-level 부작용 패턴과 같은 클래스라 틀렸음 — 원문· -근거는 `archive/tag-attribute-load-time-registration-reversed.md`. +대상"임을 이름 자체로 구분한다. + +**[재역전, 2026-08-18 구현 전 QA — 사용자 확정] 등록 주체는 다시 +quad-base 자신이다(모듈이 자기 레지스트리를 구성하는 시점).** 2026-08-14 +열두 번째 세션은 이걸 "백엔드 팩토리가 자기 Handler들과 같이 등록한다"로 +뒤집었었는데, 그러면 **quad-roblox를 아예 로드하지 않은 상태에서는 이 +Fallback Handler들도 존재하지 않아**, 위 "매치 실패는 즉시 `error`" 절이 +약속한 *"provider가 초기화됐는지 확인하라"* 안내 경로 자체가 동작하지 +않는다(사용자: *"안 그러면 quad-roblox 를 로드하지 않았을 때 로드했는지 +물어보는 요소가 처리가 안 된다"*). Fallback 밴드의 존재 이유가 "아무도 이 +자리를 안 가져갔을 때"인데, 그 등록을 "누군가 자리를 가져가는 시점"에 +의존시키면 밴드가 가장 필요한 상황에서 비어 있게 된다. + +**`InitNamespace` 거부 원칙과 충돌하지 않는 이유**: 그 원칙이 금지한 건 +**라이브러리마다 사용자가 수동으로 init을 호출하게 만드는 것**과 **모듈이 +로드되면서 *남의* 상태를 건드리는 것**이다(`base/lifecycle-pattern.md`의 +"rbvm에서 그대로 가져오면 안 되는 것" 절). base가 **자기 모듈 안의 자기 +레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니다 — 외부에 노출되는 init +표면이 늘지 않고, 순서 의존도 없고(레지스트리와 등록 코드가 같은 모듈), +사용자가 할 일도 없다. 백엔드가 나중에 자기 Handler를 등록해 이기는 구조도 +그대로다(Fallback 밴드는 항상 최하위). A-3의 다중 인스턴스화(`Quad()`)로 +가더라도 자리는 그대로 — 그때는 "모듈 로드 시"가 "인스턴스 생성 시"가 될 +뿐이다. + +옛 역전 원문은 `archive/tag-attribute-load-time-registration-reversed.md` +(그 문서 자체가 이번에 재역전됐다는 배너를 달아뒀음). **그 역전이 같이 +고쳤던 "이름" 쪽 결론은 그대로 유효** — 등록되는 엔티티는 알고리즘 구현체 +(`TagHandler` 등)가 아니라 그걸 감싼 `*FallbackHandler`다. + `HANDLER_PRIORITY_FALLBACK`이라는 밴드 자체가 정확히 이런 용도 — "아무도 이 자리를 안 가져갔을 때의 안전한 기본 동작"을 base가 값싸게 제공하는 것. 엔진 저자 입장에서 "자동/공짜"인 이유는 직접 알고리즘을 -안 짜도 되기 때문이지 quad-base 모듈 자체가 부작용을 내서가 아님 — -모든 백엔드가 (자기 팩토리 뮤테이션 한 번으로) `Tag`/`Attribute` 부기를 -얻고, 특별히 뭔가를 더 하지 않아도 이 값들이 어떤 자리에 놓이든 최소한 -매치는 됨. +안 짜도 되기 때문이고, **백엔드를 아직 안 붙였어도 이 밴드는 이미 채워져 +있다**(위 재역전) — 그래서 모든 백엔드가 `Tag`/`Attribute` 부기를 공짜로 +얻고, 백엔드가 하나도 없을 때조차 "이 값이 어떤 자리에 놓이든 최소한 +매치는 되고, 엔진 op이 없으면 그 자리에서 명확한 에러가 난다"가 성립한다. `addTag`/`removeTag`/`setAttribute`는 base가 시그니처만 소유하고 실제 구현은 팩토리가 뮤테이션으로 주입하는 **타입 계약**(`bindLifetime`/ @@ -625,7 +728,7 @@ UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다( - **타입 패밀리는 백엔드 몫**: `AttributeKey<>` 제네릭 생성자와 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) 까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인 - 패밀리는 그 백엔드(quad-roblox의 `D`/`DI` 층)가 자기 것으로 추가함 — + 패밀리는 그 백엔드(quad-roblox의 `D` 층)가 자기 것으로 추가함 — "이 값이 이 백엔드에서 표현 가능한가"라는 검증도 base가 아니라 주입된 `setAttribute`의 몫(`base/attribute-plan.md` "패키지 배치" 절). @@ -730,6 +833,14 @@ end 정의상 그 핸들러의 `isHandlable`을 만족함. 즉 말단 핸들러는 **`nil` 여부만 구분하면 되고**, 옛 모델이 요구하던 `isX(hintValue)` 방어 가드는 필요 없어짐(옛 규칙은 힌트의 타입 미보장을 메우던 임시방편이었음). + **[한정, 2026-08-18 구현 전 QA] 보장 범위는 "같은 핸들러"까지지 "같은 값 + 모양"까지가 아니다** — `isHandlable`이 **여러 모양의 값**을 받아들이는 + 핸들러라면 그 안에서 어느 모양인지 가르는 `is` 판별은 **여전히 필수**이고, + 그건 그 핸들러 자신의 몫이다(사용자: *"처음부터 한 핸들러가 여러 값을 + 가질 수 있어 is 처리가 필요한건, 그 핸들러의 몫입니다"*). 실제 사례가 + 이미 있음 — `PropertyHandler`는 평범한 값과 `Tween` 래퍼를 **둘 다** + 받아 `isTween(realv)`로 분기한다(`base/tween-plan.md`). 없어진 건 + **타입 미보장을 메우려던 방어 가드**뿐이다. - **깊은 체인에서도 힌트가 안 사라짐** — 힌트를 위에서 아래로 실어 보내는 게 아니라 **각 레벨이 자기 재프로세스에서 자기 힌트를 받기** 때문. `State>`에서 바깥이 새 inner State를 내놓아도 인덱스 2는 @@ -758,6 +869,7 @@ end |---|---|---| | `StoreBind` | 중간 | 없음(구독 + 재위임만) | | `NoneHandler` | 중간 | 없음(재위임만) | + | `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) | | `PropertyHandler` | 말단 | 프로퍼티 세팅 | | `TagHandler` | 말단 | `addTag`/`removeTag` | | `AttributeKeyHandler` | 말단 | `setAttribute` | @@ -908,8 +1020,13 @@ end 깊은 인덱스엔 안 옴 / `nil`이라 가정 금지") 중 앞의 둘은 하강 diff로 구조적으로 사라졌음: - 값이 넘어오는 건 **오직 같은 핸들러로 재프로세스될 때**이므로 그 값은 - 정의상 `isHandlable`을 만족함 → `isTag(...)` 같은 **방어 가드는 이제 - 불필요**(넣어도 무해하지만 죽은 코드). + 정의상 `isHandlable`을 만족함 → **타입 미보장을 메우려던 방어 가드** + (`isTag(...)`를 "혹시 래퍼가 새어 들어왔을까 봐" 부르는 것)는 이제 + 불필요. **[한정, 2026-08-18 구현 전 QA] 다만 한 핸들러가 여러 값 모양을 + 받는다면 그 판별은 여전히 필수이고, 그건 그 핸들러 자신의 책임** + (`PropertyHandler`의 `isTween(realv)` 분기가 실제 사례 — 위 "Dispatch + 체인" 절의 같은 한정 참고). 보장 범위는 "같은 핸들러"까지지 "같은 값 + 모양"까지가 아니다. - 깊이와 무관하게 **각 레벨이 자기 인자를 받음** → 깜빡임 방지 최적화가 깊은 체인에서도 유효. - 다만 **`nil`이라고 가정하는 것은 여전히 금지**(단순 철거일 때만 `nil`). @@ -1005,8 +1122,20 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` - 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ - Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 + 자신의 `:List`/CRUD가 아니라 그 위치의 체인을 실제로 끝내는 말단 + Handler(`Dispatch/Slot.luau`)** — **[정정, 2026-08-18 구현 전 QA]** + 옛 서술은 "그 위치를 **처음** 매치한 Handler"였는데 부정확했다: 배열 + 위치에 `State`이 오면 처음 매치하는 건 `StoreBind`(중간 노드)이고, + 중간 노드는 `inst`에 부작용을 가하지 않는다는 계약(아래 "Dispatch 체인" + 절)과 정면으로 어긋난다. 사용자 판정은 *"최종 말단 요소가 이를 + 처리하는게 더 올바른것으로 보이는데"* — 재귀가 끝나 실제 값을 받은 + 말단 Handler가 등록한다(`State`이면 재귀 끝의 `Dispatch/Slot.luau`, + 빈 자리면 `NilHandler`, `PreRef`/`PostRef` 소진 자리면 각 nop Handler). + 같이 검토 대상이던 *"단순히 모든 핸들러가 `k=number`일 때 처리하도록 + 두는"* 안은 채택 안 함 — 그 안이 메우려던 갭(`State`에서 + `None`이 올 때 아무도 `0`을 안 채우는 것)이 위 `NilHandler` 신설로 이미 + 닫혔고, 말단 규칙 하나로 전부 커버되기 때문. `Slot`은 `inst`/`i`를 + 모르는 독립 값(어디 마운트될지 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 @@ -1045,11 +1174,17 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 첫 번째 세션 조사에서 발견). 지금은 그 슬롯이 전용 센티널 `ProcessedPreRef`로 소진되고, **`ProcessedPreRefHandler`(`base/ ref-plan.md`의 "PreRef" 절)가 정상 매치 과정에서 직접 `setLength(0)`/ - `setOffsetSource(None)`을 등록** — "이 위치를 처음 매치한 Handler가 - 등록 책임을 진다"는 위 원칙을 특수 취급 없이 그대로 만족. + `setOffsetSource(None)`을 등록** — "그 위치의 말단 Handler가 등록 책임을 + 진다"는 위 원칙을 특수 취급 없이 그대로 만족. **[2026-08-14 아홉 번째 세션] `PostRef` 소진 자리도 동일** — `ProcessedPostRefHandler`(`base/ref-plan.md`의 "`PostRef`" 절)가 같은 두 등록을 하는 거울상 Handler라, 새 규칙 없이 그대로 맞물림. + **[정정, 2026-08-18 구현 전 QA] 값 자체가 `None`/`nil`인 자리도 이제 + 같은 원칙으로 덮인다** — `Dispatch.drive`가 `None`을 건너뛰지 않으므로 + 그 자리는 `NoneHandler`(재귀만) → `NilHandler`(말단)를 거치고, + **등록을 실제로 하는 건 `NilHandler`**(위 "`NilHandler`" 절). + `State`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도 + 같은 자리로 수렴한다. **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index ef68701..02c2945 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -77,8 +77,10 @@ source-state-plan.md`의 "동적 경로 가드" 절 참고.) `EffectHandle`도 children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로 흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = function(inst,k,v) return isEffect(v) end, process = -function(inst,k,v) error("EffectHandle은 children 배열 리터럴에만 놓을 -수 있음") end }`. `FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에 +function(inst,k,v) error(`Effect binding should be array index item, but +got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입을 +실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절). +`FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에 named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로 값싸게 override 가능한 자리로 열어둠. @@ -149,8 +151,31 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자 문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그 핸들의 생애주기는 전적으로 수동 관리 대상이 됨). -- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect - 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 +- **⚠️ [축소, 2026-08-18 구현 전 QA] `:Unsubscribe()`는 `:Subscribe()`의 + 짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는 + **`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를 + 부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나, + 최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe + 한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선 + 그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."* + - **왜 위험한가**: leaf 바인딩 + `State`/`State` + 조합에서, 값이 실제로 안 바뀌면 **dedup 최적화 때문에 retract가 아무 + 일도 안 한다**(`base/source-state-plan.md`의 "Observer/Effect Leaf + dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리 + 실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안 + 일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님. + - **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가 + 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이 + 실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것). + `ObserverEffectLeafHandler` 의사코드 기준으론 `process`의 + `if old ~= v then bindLifetime(...) end`와 클로저의 + `if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만, + **`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그 + cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다. + M3 착수 전 확인할 것. +- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 + 위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 + `:Unsubscribe()`는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 diff --git a/.claude/base/event-plan.md b/.claude/base/event-plan.md index f2807e2..49776db 100644 --- a/.claude/base/event-plan.md +++ b/.claude/base/event-plan.md @@ -1,4 +1,4 @@ -# 이벤트 바인딩 — self 미전달, `false`로 disconnect +# 이벤트 바인딩 — self 미전달, `None`/`nil`로 disconnect > **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.** > 사용자가 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접 @@ -52,8 +52,7 @@ SyntheticEvent만 주는 것과 같은 모양). 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 - 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 - disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 + 지원하기로 확정** — 아래 "이벤트도 store-bind 가능" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 메커니즘 불필요). @@ -64,7 +63,7 @@ SyntheticEvent만 주는 것과 같은 모양). 문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 참고할 만한 템플릿 정도로만 취급. -## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) +## 이벤트도 store-bind 가능 — `None`/`nil`로 disconnect (2026-08-06 후속 세션, **센티널은 2026-08-18에 `false`→`None`/`nil`로 정정**) **결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. @@ -80,13 +79,39 @@ SyntheticEvent만 주는 것과 같은 모양). 다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate` 저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐). -**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 -없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 -신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 -타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 -`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. -이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 -boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음. +**[재확정, 2026-08-18 구현 전 QA] 센티널은 `None`(그리고 그 재귀가 만드는 +`nil`)이다 — `false` 아님.** 원래 결정(2026-08-06)은 *"`nil`은 Lua +테이블에서 '키가 아예 없음'과 구별이 안 되니 실재하는 싱글톤 `false`를 +센티널로 쓴다"*였는데, **그건 `None` 센티널이 확정되기 전의 선택**이다. +지금은 `None`이 정확히 그 역할("테이블에 실재하면서 '없음'을 뜻하는 값")로 +도입돼 있으므로(`base/modifier-plan.md` 2-1, `base/dispatch-core-plan.md`의 +"`None` 센티널" 절), 같은 문제를 푸는 센티널이 두 개가 되는 셈이라 이벤트만 +다른 걸 쓸 이유가 없음. 사용자 판정: *"이젠 None 이 있어서 false 을 +사용해야할 이유가 없어졌다고 봄 … 일관적이게 None/nil 을 주는게 맞다는 +생각"*. + +동작: + +- `MouseButton1Click = None`이면 `NoneHandler`가 매우 높은 우선순위로 먼저 + 매치해 `Dispatch.process(inst, k, nil, index+1)`로 재귀한다 — 즉 + **`EventHandler`가 실제로 받는 값은 `nil`**이다. +- 따라서 **`EventHandler.isHandlable`은 `v == nil`인 경우에도 매치돼야 + 한다** — 매치 판정은 값이 아니라 키(리플렉션으로 이벤트 이름인지 판별)로 + 하므로 원래도 값 모양에 의존하지 않았지만, "`nil`이면 매치 안 함" 같은 + 가드를 넣으면 안 된다는 게 이제 명시적 계약이다. +- `(k=이벤트키, v=nil)`을 받으면 `retract`가 하던 일(기존 Connection 해제)만 + 하고 새로 Connect 하지 않는다. +- **배열 자리의 `nil`을 잡는 `NilHandler`와 겹치지 않는다** — + `NilHandler`는 `type(k) == "number"` 전용이고 이벤트 키는 문자열이다 + (`base/dispatch-core-plan.md`의 "`NilHandler`" 절). +- 옛 근거였던 *"이벤트인지 여부는 키로 결정되므로 다른 boolean 프로퍼티 + 핸들러와 `(k, false)` 매칭이 겹칠 위험이 없다"* 는 `false`를 안 쓰는 + 이상 필요 없어져 삭제됨. + +**store-bind될 때의 타입도 같이 바뀐다** — 값 타입이 `((...) -> ())? | +false`가 아니라 `((...) -> ()) | None | nil`(그리고 `State<...>`)이다. +`D` 생성기가 이벤트 필드 타입을 찍을 때 이 유니온을 포함해야 함 +(`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **quad가 미는 기본 패턴은 아님 — 부차적 옵션.** 저빈도 UI 이벤트(클릭류)를 조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index 0298d86..d8d5e53 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -25,7 +25,7 @@ React/Vue류 프레임워크의 `OnCreated`/`OnRendered`/`OnDisposed` 생명주기 훅을 quad에도 두면 좋겠다는 제안. 처음엔 `Frame{[OnCreated] = fn}`처럼 -싱글톤 프리미티브를 해시 파트 DI 키로 쓰는 안을 검토했으나, `:Compute` +싱글톤 프리미티브를 해시 파트 특수 키로 쓰는 안을 검토했으나, `:Compute` 콜백에 `State`이 들어올 때의 처리가 까다로워질 것 같다는 우려로 스스로 기각 — 대신 `OnCreated(fn)`이 이미 있는 `PreRef` 인스턴스를 반환하는 **순수 팩토리 함수**(children 배열에 놓는 슈가)라면 그 우려 자체가 안 @@ -79,8 +79,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 이게 바로 사용자가 처음에 걱정했던 **"`:Compute` 콜백에 `State`이 들어오면 처리가 까다로워지지 않을까"** 문제가 애초에 안 생기는 이유와 -정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 DI 키**(예: -`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — DI 키는 Store/Dispatch +정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 특수 키**(예: +`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — 특수 키는 Store/Dispatch 디스패치 경로를 거쳐야 하고, 그 값이 `State`으로 감싸이는 경우까지 핸들러가 다뤄야 함. 반면 팩토리 함수 호출은 **Store/Dispatch 경로를 아예 안 탐** — 순수 Lua 함수 호출이 즉시 평가되어 끝나고, 그 @@ -103,12 +103,12 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절에 이미 이렇게 확정돼 있음: -> quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다. +> quad v1의 `OnCreated` 특수 키는 이식하지 않는다. > `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 > 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 > 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. -이 문장이 거부한 건 v1식 **"특수 DI 키"** 메커니즘(해시 파트에 매직 +이 문장이 거부한 건 v1식 **"특수 키"** 메커니즘(해시 파트에 매직 키를 두고 Dispatch가 그 키를 특별 취급하는 것)이지, **"팩토리 함수가 기존 `Ref`/`PreRef`를 반환해서 children 배열에 놓는 것"**과는 층위가 다름 — 이 문서의 `OnCreated(fn)`은 정확히 저 문단이 이미 권장한 @@ -183,7 +183,7 @@ Frame { **생성자가 매번 새로 불려 독립된 인스턴스**를 만들어냄 — children 배열의 서로 다른 숫자 슬롯에 놓이므로, 같은 인스턴스에 여러 개를 나란히 등록하는 게 자연히 지원됨. 이건 `Ref():Callback(fn)` 단일 -슈가 관용구나 v1의 단일 DI 키 관례와 달리, **팩토리-함수 접근이 주는 +슈가 관용구나 v1의 단일 특수 키 관례와 달리, **팩토리-함수 접근이 주는 공짜 이점**임(v1처럼 "이 키엔 콜백 하나만" 같은 제약이 아예 성립할 자리가 없음 — 애초에 키가 아니라 매번 새로 만들어지는 값이므로). @@ -291,16 +291,16 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 ## 이름 컨벤션 - **`On` 접두 자체는 이미 선례가 있음** — `base/onchange-plan.md`의 - `OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 DI 키). 단 + `OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 특수 키). 단 **메커니즘은 다름**: `OnChange`는 이름을 인자로 받아 캐시된 키 객체를 - 반환하는 **해시 파트 DI 키 팩토리**(`base/onchange-plan.md` "확정" + 반환하는 **해시 파트 특수 키 팩토리**(`base/onchange-plan.md` "확정" 절)인 반면, 이 문서의 `OnCreated`/`OnRendered`/`OnDestroyed`는 **배열 파트에 놓이는 값(`PreRef`/`PostRef`/`EffectHandle`)을 만드는 팩토리**라 이름 패턴만 - 같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 DI 키와의 대조" + 같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 키와의 대조" 표에 이 둘을 끼워 넣을 필요는 없어 보임(별도 표로 다루는 게 맞음). - `OnCreated`/`OnDestroyed` **이름 확정** — 다만 v1이 이미 - `OnCreated`라는 이름을 다른 메커니즘(특수 DI 키)으로 썼던 전례가 + `OnCreated`라는 이름을 다른 메커니즘(특수 키)으로 썼던 전례가 있어 위 "①" 절의 대조 설명 없이 이름만 보면 헷갈릴 수 있음, 문서화 시 명시할 것. - `OnDestroyed`는 최초 가칭이던 `OnDisposed`보다 사용자가 선호 — diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index a9bfdb5..cd6d1f9 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -135,6 +135,16 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한 번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절) +> **[정정, 2026-08-18 구현 전 QA]** `canBound`의 **판정 방향이 뒤집혀 +> 있었다** — 이름 그대로 "지금 묶을 수 있는가"(참 = 아직 안 묶여 있어서 +> 묶어도 됨)여야 하는데, 문서 전체가 참 = "이미 묶여 있음"으로 쓰고 +> 게이트를 `if canBound(v) then error(...)`로 적어뒀었다. 그대로 구현하면 +> **정상적인 첫 바인드가 전부 에러나고 이중 바인드는 무사통과**한다. +> 아래 (1)~(3) 절은 전부 정정된 방향(`canBound(v) == not isBoundAlive(v)`, +> 게이트는 `if not canBound(v) then error(...)`)으로 다시 쓰여 있다. +> 사용자 판정 원문과 파급 목록은 +> `.claude/qa-request/pre-implementation-qa-round1.md`의 `S-1`. + **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ `canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 @@ -145,7 +155,7 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 ```lua bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐 unbindLifetime(value: any): () -canBound(value: any): boolean -- "이미 유효하게 묶여 있는가" — 구조적 점유 확인 +canBound(value: any): boolean -- "지금 묶어도 되는가" — 참이면 아직 안 묶여 있음(구조적 점유 없음) canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅 ``` @@ -247,8 +257,8 @@ local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐) local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움) -- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정. --- 이 값이 "구조적으로 이미 살아있는 바인딩을 갖고 있는가"는 어느 쪽 --- 진입점에서 물어도 항상 같은 값이라, 판정 로직은 여기 하나만 있음. +-- "이 값이 구조적으로 이미 살아있는 바인딩을 갖고 있는가" 하나만 답한다. +-- 두 공개 진입점은 이걸 서로 반대 방향으로 감싼다(아래 "(3)" 절). local function isBoundAlive(value) -- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음. -- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움 @@ -266,9 +276,9 @@ end function bindLifetime(inst, value) -- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound. - -- "이미 유효한 바인딩을 갖고 있다"를 묻는 자리이지 "지금 발화해도 - -- 되는가"를 묻는 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). - if canBound(value) then + -- "지금 묶어도 되는가"를 묻는 자리이지 "지금 발화해도 되는가"를 묻는 + -- 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). 못 묶는 경우만 에러. + if not canBound(value) then -- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건 -- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한 -- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러). @@ -296,18 +306,19 @@ function unbindLifetime(value) BindData:SetWeak(value, "gcconn", nil) end --- "이미 유효하게 묶여 있는가" — 구조적 점유 확인용. bindLifetime의 이중 --- 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, Ref가 두 자리에 --- 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`)처럼 --- "이 값이 이미 다른 어딘가에 물려 있는가"를 묻는 자리는 전부 이걸 씀. +-- "지금 묶어도 되는가" — 참이면 아직 아무 데도 안 묶여 있다는 뜻. +-- bindLifetime의 이중 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, +-- Ref가 두 자리에 동시에 놓이는 걸 막는 가드(`base/ref-plan.md`)처럼 +-- "이 값을 지금 묶어도 되는가"를 묻는 자리는 전부 이걸 씀. 호출부는 +-- 항상 `if not canBound(v) then error(...) end` 모양이 된다. function canBound(value) - return isBoundAlive(value) + return not isBoundAlive(value) end -- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할 --- 때만 씀(아래 "(4) 실제 호출부" 절). 오늘은 canBound와 판정값이 항상 --- 같지만(같은 isBoundAlive를 공유), 호출부의 질문 자체가 다르므로 --- 이름을 분리해둔다. +-- 때만 씀(아래 "(4) 실제 호출부" 절). 같은 isBoundAlive를 공유하지만 +-- canBound와는 **반대 방향**(canBound(v) == not canExecute(v))이고, +-- 호출부의 질문 자체도 다르므로 이름을 분리해둔다. function canExecute(value) return isBoundAlive(value) end @@ -318,7 +329,8 @@ end 1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]` 강참조가 그것. 2. **`value`는 `inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`에 - 복사된 gcconn 참조가 그것. `canBound`/`canExecute`가 `inst` 없이 성립하는 이유. + 복사된 gcconn 참조가 그것. `isBoundAlive`(따라서 `canBound`/`canExecute`)가 + `inst` 없이 성립하는 이유. **`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로 전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도 @@ -337,7 +349,7 @@ end local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적) function Observer:Subscribe() - if canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) + if not canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) error(if self.Subscribed then "이미 :Subscribe()된 값" else "이미 Instance에 바인딩된 값") @@ -384,28 +396,36 @@ end 전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) — `Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음. -**오늘 두 문맥의 판정값은 우연히 같다**(둘 다 `isBoundAlive` 하나로 -귀결 — gcconn이 살아있는가 OR `.Subscribed`인가). 다섯 번째 세션은 이 -우연한 일치를 "애초에 같은 질문"으로 결론지어 하나로 합쳤지만, 호출부가 -왜 그 질문을 묻는지는 서로 다름 — `Ref`처럼 발화라는 개념 자체가 없는 -값에게 "발화해도 되는가"(`canExecute`)를 묻는 건 개념이 안 맞고, 나중에 -"구조적으로는 묶여 있지만 일시적으로 발화만 멈춘" 상태가 생기면(지금은 -없음) `canBound`는 참인데 `canExecute`는 거짓이어야 하는 경우도 생길 수 -있음 — 판정값이 갈라질 여지 자체가 원래 있었다는 뜻. +**[정정, 2026-08-18 구현 전 QA] 두 판정값은 같은 게 아니라 서로의 +부정이다** — `canBound(v) == not isBoundAlive(v)`, `canExecute(v) == +isBoundAlive(v)`. 열한 번째 세션은 "판정 로직도 같고 값도 항상 같은데 +호출부의 질문만 다르다"를 이름 분리의 근거로 적었는데, 그건 `canBound`를 +"이미 묶여 있는가"로 잘못 읽은 결과였다. 이름 그대로 읽으면 두 질문은 +**반대 방향**이고, 공유하는 건 판정 **로직**(`isBoundAlive`) 하나뿐이다. +**부정 관계라는 사실은 이름 분리의 명분을 오히려 강화한다** — 같은 값을 +두 이름으로 부르는 게 아니라, 서로 다른 방향을 묻는 두 predicate이기 +때문에 호출부가 `not`을 붙이는지 여부로 의도가 드러난다. + +여전히 유효한 것 — **호출부가 왜 묻는지가 서로 다르다**: `Ref`처럼 +발화라는 개념 자체가 없는 값에게 "발화해도 되는가"(`canExecute`)를 묻는 +건 개념이 안 맞고, 나중에 "구조적으로는 묶여 있지만 일시적으로 발화만 +멈춘" 상태가 생기면(지금은 없음) 둘의 관계가 단순 부정에서 더 벌어질 +여지도 있다. **해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/ `.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드 -블록) 하나에만 있고, `canBound`/`canExecute`는 둘 다 그 헬퍼를 그대로 -호출하는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. **바뀐 -호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의 -가드(위 (2))는 이제 `canBound`를 씀 — `canExecute`를 쓰던 옛 코드에서 -이름만 바뀜, 동작은 동일. **안 바뀐 호출부**: State 전파 루프(아래 -"(4)")만 여전히 `canExecute`를 씀. +블록) 하나에만 있고, `canBound`/`canExecute`는 그 헬퍼를 각각 부정해서/ +그대로 감싸는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. +**바뀐 호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의 +가드(위 (2))는 이제 `canBound`를 씀 — 형태는 항상 **`if not canBound(v) +then error(...) end`**(못 묶는 경우에만 에러). **안 바뀐 호출부**: State +전파 루프(아래 "(4)")만 여전히 `canExecute`를 쓰고, 거기선 부정 없이 +그대로 씀. -부수 효과(다섯 번째 세션 결론과 값은 동일, 이름만 갈라짐): **"바인딩이 -죽은 뒤의 재사용은 허용"** — `inst`가 Destroy됐거나 `unbindLifetime`된 -`value`는 `canBound`가 거짓이라 게이트를 통과함(다시 다른 `inst`에 걸 -수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도. +부수 효과: **"바인딩이 죽은 뒤의 재사용은 허용"** — `inst`가 +Destroy됐거나 `unbindLifetime`된 `value`는 `canBound`가 **참**이라 +게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는 +게 이 게이트의 의도. #### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 9e2e6d7..6214449 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -80,7 +80,7 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 **결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는 전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널" -(`false`로 disconnect, `base/event-plan.md` "이벤트도 store-bind +(`None`/`nil`로 disconnect, `base/event-plan.md` "이벤트도 store-bind 가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 **디스패치 단계**에서 풀린다: @@ -308,13 +308,20 @@ Modifier에는 없음). ### 5. 타입 출처는 이미 확정된 dot-access 관습 재사용 "누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 -이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 -전역 관습(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 -됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 -하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. +이미 적용한 **"정적으로 알려진 건 dot-access"** 프로젝트 전역 관습 +(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 됨 — +`mod:UICorner(8)`/`mod:FontSize(...)`처럼 `D`(Declarative) 쪽 "제네릭 생성자 +함수 하나 + 클래스별 정적 필드" 패턴 재사용. +**[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번 +라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백 +(`store "key"` 문자열 커링)은 **기각**됐고 동적 키는 +`store:GetDynamic<>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이 +아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄** +(`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그 +**패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/event-plan.md` -"이벤트 바인딩 — self 미전달, false로 disconnect" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 +"이벤트 바인딩 — self 미전달" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) `mod:UICorner(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 @@ -410,11 +417,17 @@ immutable clone 체이닝(3번)에 얹는 얇은 sugar라 구현/개념 비용 호출해 clone된 새 Modifier를 반환하므로, `Apply` 자체는 clone할 필요조차 없음(`factory(self)`가 이미 새 값을 만들어 줌). -**구현 시 주의**: `Apply`는 제네릭 `__index`가 즉석에서 만들어주는 필드 +**구현 시 주의**: 고정 메소드는 제네릭 `__index`가 즉석에서 만들어주는 필드 setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 메소드 -테이블(현재는 `Apply` 하나)을 먼저 확인하고, 없을 때만 필드 setter를 -합성하도록 구현. 따라서 **`Apply`는 Modifier 필드 이름으로 예약됨**(실제 -스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요). +테이블을 먼저 확인하고, 없을 때만 필드 setter를 합성하도록 구현. +**[정정, 2026-08-18 구현 전 QA] 고정 메소드는 `Apply` 하나가 아니라 +`Apply` / `Peek` / `Overridden` 셋이고, 셋 다 Modifier 필드 이름으로 +예약된다.** 옛 서술("현재는 `Apply` 하나")은 9번 절이 `:Peek(key)`를 +추가하던 시점에 갱신되지 않은 stale이고, `Overridden`도 콜론 호출을 +지원한다(사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함 … +콜론과 닷 둘다 가능함"*). 실제 스타일 프로퍼티 이름과 겹칠 일은 거의 +없어 보이지만 문서화 필요 — 특히 **`FrameModifier`류 타입 생성 스크립트의 +제외 목록에 셋 다 들어가야 함**(M7). **권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를 계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 2abbc9c..9254d02 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -77,7 +77,26 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 ## 모듈 스코핑 (참고, 확정은 `base/architecture.md` 13번) 한 Lua 스레드에서 둘 이상의 모듈 분화체(Roblox+비Roblox 동시)를 쓸 일이 -거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 `New()` 추가. +거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 다중 인스턴스화를 +추가. **[정정, 2026-08-18]** 그때의 API 이름은 `New()`가 아니라 `Quad()`이고, +"코드 변경 없이 자동으로 스코핑"되는 게 아니라 module-level state를 참조하는 +코드들이 모듈 인스턴스를 인자로 받도록 손봐야 한다 — +`base/architecture.md` "확정된 결정" 13번이 소스. + +## 모듈 표면의 디버그 플래그 — `Quad.debug` (2026-08-18 신설, 사용자 요구) + +**`Quad.debug: boolean`(기본 `false`)** — 라이브러리 자체의 디버그 모드 +스위치. 지금 이 플래그가 게이팅하는 것은 **핸들러 우선순위 동률 경고 +print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 +"개발 중에만 켜고 싶은" 진단 출력은 전부 여기 얹는다. 사용자 요구 +원문과 배경은 그 문서에 있음. + +- **기본이 `false`인 이유**: 라이브러리가 사용자 콘솔에 아무것도 안 찍는 + 게 기본이어야 함. 켜는 건 명시적 opt-in. +- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑" + 절과 같이 정할 것. +- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도 + 같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임). ## Quad는 스크립트인가 라이브러리인가 (확정, 참고용) @@ -141,9 +160,13 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 `archive/tag-attribute-load-time-registration-reversed.md`) — `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 `TagFallbackHandler`/`AttributeKeyFallbackHandler`/ - `AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리(바로 위 문단과 같은 `BaseModule` 뮤테이션 경로 — - 새 예외 아님).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 + `AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 + 상태에서도 안내 에러 경로가 돌아야 하기 때문 — `base/dispatch-core-plan.md`의 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반 + 원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인 + 이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라 + 누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서). diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index 6139cac..7cb3ce5 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -17,7 +17,7 @@ ## 확정 -- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키 +- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 특수 키 팩토리, `AttributeKey(name)`/`Tag(...)`와 같은 패턴(`AttributeKey`는 구 `Attribute` — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹 `Attribute(...)` 프리미티브가 신설되며 이름 충돌 방지로 리네임됨, @@ -26,12 +26,17 @@ - **제네릭 타입 파라미터 없음 — `OnChange<>` 같은 타입 파라미터화는 안 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 (`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과 - 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 - Luau가 검증 못 하는 대가를 받아들인다"는 결정(`base/bind-system-plan.md` "이벤트 - 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 - 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 - `AttributeKey<>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 - 엄격한 걸 요구하는 셈이라 일관성이 깨짐. + 일치하는지 검증해주지 않음. + **[근거 교체, 2026-08-18 구현 전 QA — 결론은 그대로]** 옛 근거는 *"이벤트 + 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다는 결정과 + 같은 급"* 이었는데, **그 전제가 거짓**이다(이벤트는 props 타입의 **필드**라 + `D` 생성기가 콜백 타입을 정확히 줄 수 있음 — + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). + 진짜 이유는 **`OnChange(name)`이 이름을 인자로 받는 팩토리라 그 경로가 + 없다는 것** — 필드가 아니므로 생성기가 미리 타입을 찍어둘 자리가 없고, + 프로퍼티별로 전량 생성하는 안은 이미 기각돼 있다(아래 항목). 그래서 + `AttributeKey<>`처럼 제네릭으로 맞추려는 시도만 남는데, 그건 호출부가 + 매번 타입을 두 번 적게 만들 뿐이라 채택 안 함. - **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**: `archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 @@ -58,7 +63,7 @@ 캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`dispatch-core-plan.md` "핸들러 내부 상태 저장" 절). - **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 - store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 + store-bind 가능" 메커니즘(`base/event-plan.md`)이 `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`(와 그 반환 클로저)만 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 @@ -73,14 +78,16 @@ 의도적으로 허용해도 되는 동작 — 문제 없음(사용자 확인). `Handlers/OnChange.luau` 안 `OnChange(name)` 팩토리에 `AttributeKey`와 동일한 캐시 구현. -## 다른 특수 DI 키와의 대조 +## 다른 특수 키와의 대조 | | 소스 | 값 타입 | 패키지 경계 | |---|---|---|---| -| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | +| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백 — **[2026-08-18 정정] 타입 검증됨**(props 타입의 필드라 `D` 생성기가 콜백 시그니처를 찍어줌) | 판별은 quad-roblox(`Handlers/Event.luau`), 타입은 `D` 생성기 | | `AttributeKey(name)` | 주입된 `setAttribute` op | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | **quad-base**(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드 | | `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | -`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 -성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가 -헷갈리지 않도록 표로 명확히 구분해둠. +`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 위 "확정" 절의 정정된 +근거대로 **이름을 인자로 받는 팩토리라 타입을 미리 찍어둘 필드가 없기** +때문 — "콜백을 받는다"는 성질이 이벤트에 가깝다는 분류 자체는 그대로지만, +이벤트 쪽은 필드라서 타입이 나온다는 게 2026-08-18에 확인됐으므로 그 +유사성이 근거가 되지는 못한다. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 09168a6..a998635 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -105,20 +105,24 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 - 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고, + 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 배열 + (`self.Callbacks`) 하나를 재사용(발화 후 해당 인덱스만 **`nil`로 소진** + — 아래 구현 디테일 참고, **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, 바로 아래 캐비엇 참고). - - **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 - `__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** — - Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`를 - `self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 - 같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, - 위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do` - 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 - 경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`로 - 오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면 - 이 충돌 자체가 안 생김. + - **[재설계, 2026-08-18 구현 전 QA] 콜백/대기자는 별도 필드 + `.Callbacks` 테이블에 담고, `.Value`는 그냥 평범한 hash 필드로 둔다.** + 옛 설계(2026-08-09 열한 번째 세션 보강)는 **Ref 객체 자신이 곧 + 콜백/대기자 배열**(숫자 키 색인)이라, `T`가 함수/스레드일 때 + `for i, v in self do` 순회가 hash 파트의 `.Value`까지 훑어 오분류되는 + 걸 막으려고 **`.Value`를 `__index` 메타메소드로** 구현해야 했다. + 사용자 판정: *"단순히 .Callbacks: {fun, thread} 등이 있는게 맞지 + 않나라는 생각임. .Value 는 단순 해시필드로 주는게 더 간단해보임. + 엔지니어링 난이도구 단순 테이블 하나 더 만드는게 쉽고, 크게 비싸지도 + 않다고 생각됨."* — 순회 대상이 `self`가 아니라 `self.Callbacks`가 + 되므로 **hash 파트 충돌 자체가 안 생기고, `__index` 우회 기법을 쓸 + 이유가 사라진다.** 대가는 Ref 하나당 테이블 하나가 더 만들어지는 것뿐 + (`.Callbacks`를 첫 등록 시점에 lazy로 만들지는 구현 재량). - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -133,8 +137,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 - `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 - 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 + `:Set()`될 때, 같은 배열 하나를 `for i, v in self.Callbacks do ... end`로 + 한 번만 순회하면서(**[2026-08-18]** 순회 대상은 Ref 객체 자신이 아니라 + `.Callbacks` 테이블 — 위 재설계) `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 @@ -167,10 +172,18 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `table.insert`의 `#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던 이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이 - 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열 - (PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속 - `None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 - 이유가 없었음. + 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열은 + 실재하는 센티널로 소진** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, + 하나로 통일할 이유가 없었음. + **[정정, 2026-08-18 구현 전 QA] 후자의 예시가 부정확했음** — 옛 + 문장은 순서가 중요한 배열의 예로 "`PreRef` pre-pass 소진 슬롯, + Length/Offset `sourceList`"를 들며 둘 다 `None`으로 채운다고 적었는데, + **pre-pass가 소진시킨 자리는 `None`이 아니라 전용 센티널 + `ProcessedPreRef`/`ProcessedPostRef`** 로 채워지고 전용 nop + 핸들러가 정상 `Dispatch.process` 경로에서 그걸 캐치한다(아래 "PreRef"/ + "`PostRef`" 절, `base/dispatch-core-plan.md`는 2026-08-14에 이미 이렇게 + 정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList`가 `None`인 것은 + 맞음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -254,9 +267,14 @@ skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아 local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — -- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가) -RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) and not isPostRef(v) +RefLeafHandler.isHandlable(inst, k, v) = + type(k) == "number" and isRef(v) and not isPreRef(v) and not isPostRef(v) -- [2026-08-14 열두 번째 세션 정정] PostRef 도입(아홉 번째 세션) 당시 이 자리가 -- 안 갱신돼 있었음 — 아래 "타입/판별" 절의 최종 공식과 일치시킴 + -- [2026-08-18 구현 전 QA] type(k) == "number" 체크가 빠져 있었음 — leaf 바인딩은 + -- 배열 전용이고(사용자 확정: "배열 전용이 맞음"), 짝인 ObserverEffectLeafHandler엔 + -- 이 체크가 필수라고 이미 명시돼 있었음. 빠지면 named 자리로 흘러온 Ref를 잡으려는 + -- HANDLER_PRIORITY_FALLBACK 가드(아래 "동적 경로 가드")가 죽은 코드가 된다. function RefLeafHandler.process(inst, k, v, index) local old = relate:GetStrong(inst, k) @@ -477,10 +495,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스 루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤 Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset - 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). 진짜로 - 원래부터 빈 자리인 `None`(`props.Ref or None` 등)은 여전히 두 패스 - 루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로 - 혼동 금지. "호이스팅"은 PreRef를 배열의 맨 + 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정, + 2026-08-18 구현 전 QA] 원래부터 빈 자리인 `None`도 이제 정상 경로를 + 탄다** — 여기 "여전히 두 패스 루프가 직접 건너뜀"이라고 적혀 있었으나 + 그 스킵 분기 자체가 폐기됐다(아래 "[전면 정정, 2026-08-18 …]" 항목). + 지금은 `NoneHandler`(재귀만) → `NilHandler`(등록 담당)를 거친다. + 두 센티널은 **매치되는 Handler가 다를 뿐** 둘 다 정상 + `Dispatch.process` 경로다. "호이스팅"은 PreRef를 배열의 맨 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 @@ -499,33 +520,44 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급> ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) function ProcessedPreRefHandler.process(inst, i, v) - Dispatch.setLength(inst, i, 0) + -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가 + -- 끝에서 recompute를 돌리므로(`base/dispatch-core-plan.md`의 해제 순서 계약) Dispatch.setOffsetSource(inst, i, None) + Dispatch.setLength(inst, i, 0) return function() end -- no-op retract, 이 자리는 fire가 끝나 -- 되돌릴 상태 자체가 없음 end ``` `isHandlable`이 `v == ProcessedPreRef`만 잡으므로 배열 파트 전용(해시 파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 `base/ - dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 "이 - 위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급 - 없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가 + dispatch-core-plan.md`의 "Length/Offset" 절이 확정해둔 "그 위치의 + 말단 Handler가 등록 책임을 진다"는 계약을 특수 취급 + 없이 그대로 만족시킴(**[정정, 2026-08-18]** 그 계약은 예전엔 "처음 + 매치한 Handler"라고 적혀 있었으나 중간 노드가 매치되는 경우가 있어 + 말단 기준으로 정정됨) — 매치되는 Handler 자신이 곧 등록자라 "누가 등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된 no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과 같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가 dispatch 체인에 실제로 올라가 있어도(**[정정] 예전 서술과 달리 이제는 올라가 있음** — 아래 참고) retract가 할 일이 없는 것뿐. - - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — - `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 - 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, - 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 - `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스 - 루프가 `Dispatch.process` 호출 전에 자기 스스로 - `if v == None then continue end`로 걸러냄). `NoneHandler`는 - `Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 - `None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로 - `NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 - 정확한 설명. + - **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`도 + `Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는 + 전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에 + `Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가 + `if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number` + 조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데, + 리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State }` + 처럼 반응형 값이 `None`을 내놓으면 그 `None`은 `StoreBind`의 재귀를 + 타고 `Dispatch.process`에 그대로 도착**한다(사용자 지적). + 지금 확정된 모델은: + - `Dispatch.drive`에 `None` 스킵 분기가 **없다** — 전부 `process`를 탄다. + - `NoneHandler`는 `k` 타입과 무관하게 매치되고, 하는 일은 + **`nil`로 바꿔 재귀하는 것 하나뿐**. + - `k`가 숫자면 그 재귀가 **`NilHandler`**(`k=number and v==nil` 전용, + `base/dispatch-core-plan.md`의 "`NilHandler`" 절)에 도착해 거기서 + `setLength(0)`/`setOffsetSource(None)`을 등록한다. + 즉 이 자리에서 "매치되는 경우가 없다"가 아니라 **매치되고, 정상 + 경로로 0 기여가 등록된다**가 정확한 설명이다. - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 @@ -631,10 +663,21 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위 "요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는 자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.) -- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게 - 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 +- **일반 `Ref`도 값으로서는 Modifier 필드/Store 값 어디로든 전달될 수 + 있음** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). -- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** + **[한정, 2026-08-18 구현 전 QA] 다만 leaf 바인딩이 일어나는 자리는 + 배열(숫자 키) 전용이다** — 옛 문장("Modifier/Store 어디든 자유롭게 + 들어감")은 "named 해시 키에 놓아도 leaf 바인딩이 된다"로 읽혔는데 그건 + 틀리다. 어디로든 **흘러갈** 수 있다는 뜻이고, 실제로 `Ref:Set(inst)`가 + 일어나는 건 배열 자리뿐(아래 `RefLeafHandler.isHandlable`의 `k` 체크). + **왜 배열 전용인가(사용자 논거, 그동안 어디에도 안 적혀 있던 것)**: + `Ref`끼리는 **배열 index 순서가 통하므로**, "다른 `Ref` 처리를 먼저 해야 + 하는 순서 의존"이 있을 때도 그걸 표현할 수 있게 하려는 것 — + `PreRef`/`PostRef`의 "계열 안 fire 순서는 배열 index 순서" 보장과 같은 + 결의 근거다. 컴포넌트 함수에 `Ref`를 named 파라미터로 넘기는 건 이와 + 무관하게 얼마든지 가능(그건 그 함수의 인자일 뿐 leaf 바인딩이 아님). +- **quad v1의 `OnCreated` 특수 키는 이식하지 않는다.** `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. @@ -747,8 +790,9 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급> ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef) function ProcessedPostRefHandler.process(inst, i, v) - Dispatch.setLength(inst, i, 0) + -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유) Dispatch.setOffsetSource(inst, i, None) + Dispatch.setLength(inst, i, 0) return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음) end ``` @@ -768,8 +812,9 @@ dispatch-core-plan.md` "Length/Offset" 절의 계약을 특수 취급 없이 그 Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는 정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위 레지스트리에 `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = -isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을 -수 있음") }` Handler를 등록(`k` 타입 안 가림 — `PreRef`의 "동적 경로 +isPostRef(v), process = error(`PostRef binding should be array index item, +but got {typeof(k)}`) }` Handler를 등록(**[2026-08-18]** 에러 메시지에 +실제 `k` 타입을 실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절)(`k` 타입 안 가림 — `PreRef`의 "동적 경로 가드" 절과 완전히 같은 이유로 `HANDLER_PRIORITY_FALLBACK`, 2026-08-14 열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입 차단을 우회한 버그라는 뜻. diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 502aaa3..4935fb1 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -214,9 +214,17 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌 - `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. -- `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 - `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 - strong). +- `base/lifecycle-pattern.md`의 `bindLifetime`/`canBound`/`canExecute` — + gcconn/gchold를 `Relate`의 **`SetWeak`**으로 저장. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 `SetStrong`("둘 다 존재 + 이유가 '안 죽는 것'이므로 strong")이었는데 **근거까지 통째로 틀렸다** — + 둘의 생존은 gcconn 클로저의 upvalue와 `gchold[1]`이 이미 보장하므로, + 위 "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 절의 규칙이 + 그대로 적용된다. strong으로 구현하면 `gchold`가 `value`를 강하게 잡고 + `BindData`가 `value`를 키로 `gchold`를 강하게 잡는 모양이 되어, 이 + 문서가 경고하는 **두-`Relate` 상호 강참조 순환**(Luau에 ephemeron이 + 없어 실제 누수)에 정확히 걸린다. 구현 스케치는 이미 전부 `SetWeak`으로 + 적혀 있었고(`base/lifecycle-pattern.md`) 이 요약 줄만 어긋나 있었음. ## 이름 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 99ff3a6..5b45eda 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -91,12 +91,16 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " 붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 — 특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에 - Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`). + 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]**). - **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종 마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상 `T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라 - `DI.InstSlot = Slot<>`(`DI` 네임스페이스 이름 자체는 - `question.md` 1번 용어정리 대기 중, 여기선 잠정 표기)가 사실상 "그" + `D.InstSlot = Slot<>`가 사실상 "그" Slot 타입. `Slot()`가 기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선 `any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것 @@ -1077,6 +1081,10 @@ function activateList(self, inst) local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 + -- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시. + -- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. + local popOnly = (result == PopOnly) + if popOnly then result = nil end if result ~= nil then -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그 @@ -1087,11 +1095,16 @@ function activateList(self, inst) end if result ~= prev then - -- [정정, 2026-08-13 여섯 번째 세션] 파괴가 아니라 **언마운트** — - -- rawUnmount는 rawRemove와 같되 element를 Destroy하지 않고 - -- 소유권만 반납(unmountSlotTree 사용, 아래 "파괴" 절). - -- 명시적 CRUD Remove/Clear/dispose만 여전히 파괴. - if prev ~= nil then rawUnmount(self, prev) end + -- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래 + -- "`nil` 리턴은 파괴가 기본" 절이 소스: + -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만** + -- — state 교체와 동형, 지우라고 한 적이 없음. + -- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드. + -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시. + if prev ~= nil then + if result ~= nil or popOnly then rawUnmount(self, prev) + else rawRemove(self, prev) end + end if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준 mounted[key] = result elseif prev ~= nil and keyIndex[key] ~= pos then @@ -1099,12 +1112,13 @@ function activateList(self, inst) end userdata[key] = ud -- result와 무관, 그대로 기록 + -- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당) newKeyIndex[key] = pos end for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key if not seen[key] then local prev = mounted[key] - if prev ~= nil then rawUnmount(self, prev) end -- 위와 같은 이유로 비파괴 + if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴 mounted[key], userdata[key] = nil, nil end end @@ -1177,19 +1191,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 같이 GC됨(아래 "구독 시점" 절). -- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawMove`뿐** - (**[정정, 2026-08-13 여섯 번째 세션]** 예전엔 `rawRemove`(파괴)였으나 - 언마운트 전환으로 바뀜 — 데이터에서 빠진 아이템도 파괴되지 않고 - 언마운트만 되며, 아무도 안 들고 있으면 GC) — - `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" - 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 - 셋을 직접 호출할 일이 없을 뿐 — **[정정, 2026-08-13 감사] "제거는 - 항상 파괴 확정이라 Extract 아닌 Remove 경로"라는 예전 근거는 언마운트 - 전환으로 이제 틀림**(바로 위에서 정정했듯 reconcile의 제거는 이제 - 비파괴 `rawUnmount`이고, 이 함수 자체가 `rawRemove`의 비파괴 - 짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드 +- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/ + `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 + 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 + **부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고, + 값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 + 소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 + 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 + 자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount`는 `rawRemove`의 + 비파괴 짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드 블록의 "rawRemove의 비파괴 짝 — `:List`의 reconcile과 `Extract` - 계열이 씀" 주석 참고). reconcile이 공개 `Slot:Extract` 대신 + 계열이 씀" 주석 참고. reconcile이 공개 `Slot:Extract` 대신 `rawUnmount`를 직접 부르는 진짜 이유는 파괴 여부가 아니라, reconcile이 이미 자기 `mounted` 맵으로 element를 추적 중이라 `Extract`의 "제거한 element를 호출자에게 반환" 계약이 불필요하고, 공개 CRUD의 가드/에러 @@ -1200,6 +1212,60 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. +### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기) + +**[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로 +지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지 +전부 비파괴로 바꿨는데, **`:List`에는 그 일반화가 안 맞는다**는 게 사용자 +판정: *"List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지, +파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데…"* + +**세 경로로 갈린다**(위 `reconcile` 의사코드): + +| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 | +|---|---|---| +| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 | +| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | +| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 | +| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) | + +**`PopOnly`(가칭) — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** +`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 +파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`PopOnly`와 함께 userdata를 +반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: + +```lua +-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다 +return PopOnly, { old = prev, source = ... } +``` + +- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐 + `userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의 + `old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`이 + 같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내 + 그대로 반환하면 **재마운트**된다(`rawAdd` 경로). +- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기 + 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다. +- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 + 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를 + **둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미 + `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는 + **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 + 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 + 못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다. + **세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번): + (a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴, + (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), + (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다. + **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지. +- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수 + 있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에 + 올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정. +- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 + 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 + 직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서 + 선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다. + ### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime` (2026-08-09 일곱 번째 세션) @@ -1855,6 +1921,25 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 엔진 자체 `:Destroy()` 메소드와 동명이라 사용자가 "그냥 `:Destroy()` 부르는 거 아님?"으로 착각할 위험이 있어 기각 — `dispose` 유지. +**[백로그 후보, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터.** +위 "`Set`(언마운트) → 그 다음 정리" 순서 요구 때문에 호출부가 매번 +**`Get()`으로 이전 값을 미리 잡아두고 → `Set(new)` → 잡아둔 옛 값을 +`dispose`** 하는 3단계를 손으로 써야 해서 편의성이 떨어진다는 사용자 지적: +*"source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 생각해보았음(단 +여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). Get해놓고 Set 이후 +나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 source 자체에 :콜론 +메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음."* 후보 둘: + +1. `source:Apply(SetAndDispose(new))` — 콤비네이터. **단 여기서의 `Apply`는 + `State`가 아니라 `Source`를 넘겨주는 함수여야 함**(사용자 명시) — 지금 + 확정된 `state:Apply(factory)`는 `factory(self)`에 `State`를 넘기므로, + `Source` 전용 변형이 필요한지 같이 정해야 한다. +2. `Source`에 콜론 메서드로 직접 얹기(`source:SetAndDispose(new)`). + +**미결**: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지. +`state:Apply`의 시그니처(`(State) -> U`)에 영향이 갈 수 있으므로 **M3 +착수 전에 방향만이라도 정해둘 것**. `question.md`에 올려둠. + #### 구현상 바뀌어야 하는 것 **[반영 완료, 2026-08-13 감사 후속]** 비파괴 경로를 `unmountSlotTree`로 @@ -1866,14 +1951,20 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + `unbindLifetime` + `releaseOwner`를 부름. -2. **`:List`의 `reconcile`** — 교체/소멸 시 `rawRemove`(파괴) 대신 같은 - 비파괴 경로. 데이터에서 빠진 아이템도 파괴되지 않고 언마운트만 되며, - 아무도 안 들고 있으면 GC(quad 전역의 GC-native 원칙 그대로). +2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 + 비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn`이 `nil`/`None`을 + 반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 + 판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. + 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 + `:List`에는 안 맞는 일반화였음. **여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()` -(CRUD 표가 "제거 **+ 파괴**"로 이미 정의)와 `dispose`. 즉 **"자동 경로는 -언마운트, 명시적으로 지우라고 한 것만 파괴"**로 갈림 — `Ref`/`Attribute`의 -"지울 거면 명시적으로" 철학과 정확히 같은 결. +(CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의 +`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 +지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 +"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" +철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를 +명시한다. `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`, diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index cf01ca1..bf6f60d 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -78,8 +78,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 - 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 - `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — 아래 + 추가로 가짐([정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외되고 + `Get()`으로 통일됨, 그 표기는 Ref의 `.Value` 전용으로 좁혀짐 — 아래 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절 참고). - **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 @@ -98,9 +98,10 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. -- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 - 반환**하는 것으로 자연히 갱신됨(`base/store-plan.md`의 "타입 추론 - 문제" 절과 연동). +- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히 + 갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이 + 아니라 `store:GetDynamic<>(name): Source`다(문자열 커링은 기각, + `base/store-plan.md`의 "타입 추론 문제" 절). **[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로 만족하는 제네릭 메소드 체이닝)은 `08-type-source-satisfies-state.luau`로 @@ -292,6 +293,38 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로 유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더 강해짐. +### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M3 착수 전 결론 필요**) + +**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State -> +State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지 +않음이 명확해야함. 물론 compute 등의 callback 상 가져서 안전할 수 있지만, +With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지고 있어야 +할것임. 이게 되는지는 더 알아봐야할 필요가 있음."* + +확정된 두 서술을 겹치면 체인이 끊길 수 있다: + +- State는 자기 **구독자(하류)를 weak로** 담는다(위 문단, 그리고 + `base/lifecycle-pattern.md`의 "(4) 실제 호출부" 절). +- Observer는 `gchold`(leaf) 또는 전역 레지스트리가 살려준다. +- 그러면 `A → B → C → Observer` 체인에서 **중간 노드 `B`/`C`를 강하게 + 붙잡는 주체가 지금 문서 어디에도 명시돼 있지 않다.** 체이닝을 한 줄로 + 쓰는 흔한 형태에선 아무도 `B`/`C`를 로컬 변수로 안 들고 있고, 하류 weak + 링크만으로는 생존이 보장되지 않아 **중간 State가 수거되고 전파가 조용히 + 끊길** 수 있다. + +**사용자가 지목한 해법 방향 — 구독 엣지는 하류로 weak, 상류로 strong.** +각 노드가 자기 parent(상류)를 강참조로 들고 있으면 leaf에서 root까지가 +한 줄로 살아있게 된다. `:Compute`는 콜백 클로저가 상류를 캡처해 **우연히** +안전할 수 있지만, **`:With`가 만드는 pass-through 노드는 계산 함수가 +없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을 +불변식으로 못박아야 한다. + +**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의 +불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가 +(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에). +**미검증 상태로 M3에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은 +작음"은 이 항목이 닫히기 전까지는 잠정이다. + **결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. @@ -451,20 +484,23 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"( `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 불필요, `Compute` 하나로 일관. -- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** +- **[정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외, `:Get()`만 지원.** 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 - `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, + `.value`/`:Get()` 둘 다 지원하고 프로퍼티 읽기를 관용적 표기로 앞세웠으나, "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 - `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` - 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 + `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. 프로퍼티 + 표기 자체는 폐기하지 않고 **Ref의 `.Value` 전용으로 좁힘**(**[표기 정정, + 2026-08-18]** 이 문단이 소문자 `.value`로 쓰고 있었음 — 실제 필드는 + 대문자 `.Value`. Ref는 lazy가 아니라 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — `base/ref-plan.md`의 `.Value`가 그대로 유일한 존재가 됨, 이름 충돌 자체가 사라져 별도 표기 정리 불필요). -- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return +- 예시 갱신: `store.key1:With(store.key2):Compute(function(key1) return key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 - State. + State. (**[2026-08-18]** 예시가 기각된 `store "key1"` 문자열 커링 문법으로 + 쓰여 있던 걸 dot-access로 고침.) **[2026-08-12 세션 감사에서 확인] `:Compute` 콜백 인자에 `:Get()`을 빠뜨리는 실수가 반복되기 쉬움 — 실제로 `.claude/` 문서 예시 코드 4곳(`tag-plan.md`, @@ -597,9 +633,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그 객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 -재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 -**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 -한다. +재사용해 필드만 patch하고 싶을 때를 위해, **직전에 이 Compute 함수가 +반환했던 값**을 두 번째 인자로 받을 수 있게 한다. +**[표기 정정, 2026-08-18 구현 전 QA]** 이 절만 옛 표기 `fn(value, +previous)`로 남아 있었는데, 최종 시그니처는 **`fn(self, previous?, +...deps)`** 다 — 첫 인자는 raw 값이 아니라 **lazy 핸들**이라 안에서 +`self:Get()`을 불러야 한다(위 "`:With`/`:Compute` — self 인자도 lazy +핸들로 통일" 절이 소스, `:Get()` 누락이 반복되는 실수라 별도 절까지 있음). - **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. @@ -906,8 +946,17 @@ retract/Destroy되면 자동으로 정리됨. 등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 — 전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = function(inst,k,v) return isObserver(v) end, process = -function(inst,k,v) error("Observer는 children 배열 리터럴에만 놓을 수 -있음") end }`. `HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는 +function(inst,k,v) error(`Ref/Observer binding should be array index item, +but got {typeof(k)}`) end }`. +**[요구 추가, 2026-08-18 구현 전 QA] 에러 메시지에 실제 `k`의 타입을 +실을 것.** 사용자 요구: *"Priority Fallback 이 type(k) == "string" 인 +상황에서는 가장 위에 Ref/Observer binding should be array index item, but +got typeof k 처럼 알려줄 필요는 있는듯"*. 근거는 **메시지에 `k` 타입이 +없으면 최종 사용자가 두 원인을 구분할 수 없다는 것** — (a) 핸들러가 +등록이 안 된 것인지, (b) `MyRef = Ref(...)`처럼 named 자리에 잘못 쓴 +것인지. 같은 규칙이 `base/ref-plan.md`의 `PreRef`/`PostRef` 동적 경로 +가드와 `base/effect-plan.md`의 `Effect` 가드에도 그대로 적용된다. +`HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는 하드 블록이 아니라 `Tag`/`Attribute`/`PreRef`와 같은 "base가 소유하되 평범한 우선순위로 등록된 다른 Handler가 있으면 그쪽이 이기는" 자리이기 때문(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 @@ -1087,28 +1136,30 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti ```lua -- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) --- — 둘 다 진입 전 동일하게 확인 -if canBound(self) then +-- — 둘 다 진입 전 동일하게 확인. [정정, 2026-08-18 구현 전 QA] canBound는 +-- "지금 묶어도 되는가"(참 = 아직 안 묶임)라 게이트는 `not`이 붙는다. +if not canBound(self) then error(if self.Subscribed then "이미 :Subscribe()로 전역 바인딩된 값" else "이미 다른 Instance에 바인딩된 값") end ``` -- **"이미 유효하게 묶여 있다"(`canBound`)와 "지금 실행 가능하다" - (`canExecute`)는 판정 로직이 같아서**(둘 다 비공개 헬퍼 - `isBoundAlive`를 그대로 부름, `base/lifecycle-pattern.md`) 값은 항상 - 같지만, 호출부의 질문이 서로 달라 이름은 분리돼 있음 — 이 절(이중 - 바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 - 씀. +- **[정정, 2026-08-18 구현 전 QA] `canBound`와 `canExecute`는 값이 같은 게 + 아니라 서로의 부정이다** — `canBound(v) == not canExecute(v)`. 둘이 + 공유하는 건 판정 **로직**(비공개 헬퍼 `isBoundAlive`, + `base/lifecycle-pattern.md`)이지 판정 **값**이 아니다. 옛 서술("판정 + 로직이 같아서 값도 항상 같지만 호출부의 질문만 다르다")은 `canBound`를 + "이미 묶여 있는가"로 잘못 읽은 것이었음. 이 절(이중 바인딩 금지)은 + `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 씀. - **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, - 거짓인데 `canBound`가 참이면 leaf 경로. -- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한 - 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 - 순서와 무관하게 대칭적으로 막힘. + 거짓인데 `canBound`가 **거짓**이면 leaf 경로. +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "지금 묶어도 되는가"만 + 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 + 대칭적으로 막힘. - **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나 - `unbindLifetime`된 값은 `canBound`가 거짓이라 게이트를 통과함(다른 + `unbindLifetime`된 값은 `canBound`가 **참**이라 게이트를 통과함(다른 `inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐. **[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 @@ -1119,9 +1170,9 @@ end 그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). 옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 -Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 -— `canBound`(와 `canExecute`가 공유하는 `isBoundAlive`)가 gcconn 경로를 -**먼저** 보기 때문. 역전 원문·오염 경로·교훈은 +Observer가 **안 묶인 것으로 오판**됨"은 실제로는 안 일어남 — +`canBound`/`canExecute`가 공유하는 `isBoundAlive`가 gcconn 경로를 +**먼저** 보므로, 그런 Observer는 `canBound`가 제대로 거짓이 된다. 역전 원문·오염 경로·교훈은 `archive/canexecute-inst-arg-reversed.md`(그 문서 하단에 이 재분리 경위도 추가돼 있음). - **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` @@ -1153,8 +1204,8 @@ Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 `Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 -`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고, -통과하면 gchold 등록 + gcconn 참조 복사를 수행. +`canBound` 게이트를 확인** — 진입 전 `canBound(value)`를 확인하고, +참이면(=아직 안 묶여 있으면) gchold 등록 + gcconn 참조 복사를 수행. **children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — `Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" @@ -1166,8 +1217,10 @@ leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만 ```lua function bindLifetime(inst, value) - if canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로 + if not canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로 -- bindLifetime의 게이트는 canBound, canExecute 아님 + -- [정정, 2026-08-18 구현 전 QA] 방향이 뒤집혀 있었음 — + -- canBound 참 = 묶어도 됨이라 에러는 not 쪽 error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고 end ... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md) @@ -1181,11 +1234,11 @@ end - **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** — 옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용 predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데, - `canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는 - 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는 - 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를 - `bindLifetime` 층위에서도 공짜로 잡아줌. -- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그 + 공유 헬퍼 `isBoundAlive`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 + 살아있는 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 + 마운트하는 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 + 실수를 `bindLifetime` 층위에서도 공짜로 잡아줌. +- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 **거짓**이 되므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립. diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 48e9d21..a682d70 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -58,6 +58,18 @@ State를 만족함" 절). 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. +- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를 + 무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라 + 타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는 + 타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 + 설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는 + 이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입 + 에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로 + 확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인 + 상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지. + `luau-test/done/16-*`(type function으로 `Store` 레코드 필드 합성)에 + 이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는 + 경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. - **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 @@ -105,9 +117,15 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 아니라 기존 원칙의 정상적인 적용. -**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 -전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. -`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. +**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지. +**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** — +여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나, +사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된 +바임. 저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 +하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥 +`string`으로 들어가 `Source`의 `T`를 알 수 없고, (b) dot-access + +`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는 +아래 `:GetDynamic` 항목으로 간다. `base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께 읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 @@ -117,7 +135,8 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 ## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드) - `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 - 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** + 추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**, + 위 절), **`store.key`(dot-access)를 1급 경로로 확정** — Store 타입을 `{key: Source, other: Source}`류 평범한 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` @@ -125,8 +144,37 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 해소됨, 위 "Store 값 설정 문법" 절 참고). - `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) - 폴백으로 격하. + **[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** — + `store:GetDynamic<>(name): Source`. 런타임 동작 자체는 원래도 + dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서 + Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다: + 선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입 + 에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 + 정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 + 둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. + 타입 애러가 난다는 점인데, 이는 GetDynamic(name): Source 로 + 제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을 + 포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점. + - **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의 + lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은 + 모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`를 + 만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면 + `store.GetDynamic`이 **`"GetDynamic"`이라는 이름의 새 `Source`를 + 만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서 + **`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy + `Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의 + 예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦). + `Modifier`가 `Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과 + 정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만 + Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일 + 프로퍼티 이름)보다 충돌 확률이 높다는 게 차이. + - **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면 + **탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/ + `bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자 + 탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 — + 네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는 + `GetDynamic(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되, + M3/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 3번). - 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 5989587..f1aa310 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -269,12 +269,13 @@ removeTag(inst: any, names: {string}): () - **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK` — 단 여기 꽂히는 건 `TagHandler` 자신이 아니라 그걸 감싸는 `TagFallbackHandler`(2026-08-14 열두 번째 세션 정정 — `TagHandler`는 참조 카운트 알고리즘 구현일 - 뿐, 스스로 등록되는 주체가 아님).** 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리 — quad-roblox 같은 백엔드가 `BaseModule`을 - 구성할 때 자기 전용 Handler들과 같이 이 `TagFallbackHandler`도 등록해줌 - (`base/module-lifecycle-plan.md`가 이미 확정해둔 "base는 인터페이스만, - 등록은 팩토리 뮤테이션 시점" 원칙 그대로). 옛 "quad-base 모듈 로드 - 시점에 스스로 등록" 모델은 + 뿐, 스스로 등록되는 주체가 아님).** **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(모듈이 자기 + 레지스트리를 구성하는 시점) — 백엔드를 아직 로드하지 않은 상태에서도 + "provider가 초기화됐는지 확인하라"는 안내 에러 경로가 돌아야 하기 + 때문이고, 이건 `InitNamespace` 거부 원칙과 충돌하지 않는다(근거는 + `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 + 엔진 op" 절이 소스). 경위는 `archive/tag-attribute-load-time-registration-reversed.md`. `addTag`/`removeTag`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 062d0cb..0396a73 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -71,7 +71,7 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 `UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 있어야 함 — 순수 런타임 관점(제네릭 `__index`가 처리)에선 문제없지만, -타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(DI +타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(`D` 인스턴스 타입, Modifier 타입 등)이 Roblox API 덤프를 읽어 Luau 타입 파일을 구워내는 스크립트로 생성될 예정이라(구현 단계 결정 사항) — 이 스크립트가 실제 Roblox 프로퍼티뿐 아니라 이 3개 숏핸드 키도 각 @@ -84,6 +84,30 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 피하기 위함. +**[요구 추가, 2026-08-18 구현 전 QA] 다시 찾을 때 `FindFirstChild` 대신 +`Relate`에 저장해둔 참조를 쓸 것.** 사용자 요구: *"FindFirstChild 는 비용이 +ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 릴레이션으로 +저장하는것도 좋은 생각."* + +- **조회 경로**: `(inst, 숏핸드키) → child`를 `Relate`에 저장해두고 그걸로 + 되찾는다. `Relate`는 `inst`를 weak 키로 쓰므로 부모가 죽으면 항목도 + 자연히 빠진다(`base/relate-plan.md`). +- **고정 이름 규약을 없애자는 뜻은 아님** — 이름(`_quad_corner`류)은 + 디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든 + `UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은 + 표시·판정용, `Relate`는 조회용**으로 역할이 갈린다. +- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는 + `Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata + 동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`). + 숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지 + 구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다. +- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**: + *"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운 + dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."* + 즉 숏핸드 Handler가 자식 프로퍼티를 직접 쓰지 말고 + `Dispatch.process(child, k, v, 1)`로 넘기면 `State`/`Tween` 래핑이 + 공짜로 따라온다. + ### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, 반환 클로저는 관여 안 함 (2026-08-07 여덟 번째 세션) `modifier-plan.md`의 `None` 센티널(`base/dispatch-core-plan.md`의 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 297d432..d625c57 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -78,7 +78,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | | `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.md` 2026-08-06 세션, `debug-tooling-plan.md` | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 | -| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | +| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | @@ -114,7 +114,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 계속 `None` — 두 카테고리로 나눠 각각 재현). - `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요 항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute - 제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + + 제네릭 특수 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + `isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을 오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요" 로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정 @@ -239,13 +239,14 @@ Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴 재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`), 재확인 불필요. - - **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 - **`canBound(value)`**다(`if canBound(v) then error(...) end`) — + - **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 + 바인딩 게이트는 **`canBound(value)`**다(`if not canBound(v) then + error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 남고, `canBound`가 - 별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유, - `lifecycle-pattern.md` "`canBound` vs `canExecute`" 절). 판정 - 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 - `bindLifetime`할 수 있어야 하고(게이트가 `canBound` 거짓이라 + 별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유하되 + **서로의 부정**, `lifecycle-pattern.md` "`canBound` vs `canExecute`" + 절). 판정 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 + `bindLifetime`할 수 있어야 하고(게이트가 `canBound` **참**이라 통과), **`inst`가 Destroy된 뒤의 재바인딩도 명시적으로 허용**임 (살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이 재분리 설계 자체를 재검토해야 함 — **아직 미확인.** diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index af7dbbd..22ca682 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -72,7 +72,7 @@ | `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | -| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 `canBound(value)`(`if canBound(v) then error(...) end`) — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | ## ⚪ `not-run/` — 이 환경에서 못 돌림 diff --git a/.claude/project-context.md b/.claude/project-context.md index 0b5bdce..b606462 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -69,9 +69,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 결론은 `base/typing-limits.md`로 승격 — 이후 신설된 폴더들도 같은 구성 관례를 따름(`type-recursive-issue-with-typeof/`, `type-recursive-issue-try-callback/` 등). -- `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, - **[2026-08-16 기준] 아직 비어 있음**(`feedback/`은 폴더 자체가 아직 - 없음). `.claude/archive/`는 원래 같은 취급이었으나 +- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 + 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 + 여기 둠(`pre-implementation-qa-round1.md`, 2라운드 예정 — 라운드마다 새 + 파일). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + **[2026-08-18 기준] 폴더 자체가 아직 없음**. + `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 아님) — `archive/store-source-proxy-reversed.md`가 첫 사례, 나중 diff --git a/.claude/pre-implementation-qa.md b/.claude/qa-request/pre-implementation-qa-round1.md similarity index 97% rename from .claude/pre-implementation-qa.md rename to .claude/qa-request/pre-implementation-qa-round1.md index b0d1d18..d09dec4 100644 --- a/.claude/pre-implementation-qa.md +++ b/.claude/qa-request/pre-implementation-qa-round1.md @@ -1,14 +1,25 @@ -# 구현 전 QA — 사용자 심사에서 "아니오"가 나온 항목 +# 구현 전 QA **1라운드** — 사용자 심사에서 "아니오"가 나온 항목 -**상태**: 진행 중(2026-08-18 세션에 시작). `.claude/base/` 확정 문서를 의존성 +**상태**: **1라운드 완료 + `base/` 반영 완료(2026-08-18)**. 이 파일은 +`.claude/qa-request/`에 라운드별로 쌓인다 — **2라운드는 새 파일** +(`pre-implementation-qa-round2.md`)로 만들 것이고, 이 문서를 이어 쓰지 않는다. +2라운드 대상은 맨 아래 "진행 로그" 절의 "아직 안 본 것" 목록. + +**1라운드 범위**(2026-08-18 세션에 시작). `.claude/base/` 확정 문서를 의존성 순서로 훑으며 **표면 타입계약 / 내부 구현 메커니즘 / 동작 원리·불변식** 세 층위로 "예가 나와야 정상인 주장"을 사용자에게 확인받는 작업의 산출물. **이 문서의 용도**: 사용자가 "아니오" 또는 "부분적으로 틀림"으로 판정한 -항목만 모은다. **여기서 정정하지 않는다** — 사용자가 나중에 "어디가, 어떻게, -왜 틀렸는지, 원래 뭐가 맞는지"를 Markdown으로 한 번에 회신해주면 그때 -`base/` 문서에 일괄 반영한다. 그 전까지 이 항목들은 **미해결 결함으로 간주** -하고, 관련 부분 구현에 착수하지 말 것. +항목만 모은다. **[2026-08-18 갱신] 반영 완료 — 이 문서는 이제 "무엇이 왜 +틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스다.** +같은 날 세션에서 아래 항목 전부를 `base/`(+`ROADMAP.md`/`question.md`/ +`archive/`)에 반영했고, 반영 과정에서 판단이 갈리던 네 건(SL-3의 `PopOnly`, +D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2의 동적 +키 경로)은 그 자리에서 사용자에게 물어 확정했다 — 그 답변도 각 항목에 +반영돼 있다. + +**아직 안 닫힌 것**(결론 전에 해당 마일스톤 착수 금지)은 `question.md` 3번과 +`.claude/todos.md` 00번이 소스 — 여기서 다시 나열하지 않는다. **표기**: `X-N`의 `X`는 문서 코드(A=architecture, S=source-state, …), `N`은 그 문서 안 질문 순번. 사용자 답변 원문은 그대로 인용한다(`conventions.md`의 @@ -49,7 +60,7 @@ 항목). 라이브 문서 19개 파일에 걸쳐 있고, **헤딩 1개와 그걸 절 인용하는 곳 1개가 짝으로 묶여** 있어 한쪽만 고치면 `doc-check.py` ERROR가 난다. 반영 대상 전수 목록이 그 항목에 표로 들어있다. -- `N-9` — **`New` 커링 + `D`는 전량 코드 생성** (열린 항목 전부 확정됨). +- `N-9` — **`New` 커링 + `D`는 전량 코드 생성** (2026-08-18에 열린 항목 전부 확정됨). N-8과 **같은 줄들을 건드리므로 한 번에 처리할 것**(`architecture.md` 소스 트리 주석, `ROADMAP.md` 체크박스). **`BS-2`와는 같은 문제의 양면**이라 (인덱싱으로는 이벤트 콜백 타입이 안 나온다는 것) 반드시 묶어서 볼 것. @@ -477,7 +488,7 @@ 도착**한다. 즉 "배열 파트의 `None`은 `process`를 안 탄다"는 보장이 애초에 성립하지 않는다. - **틀린 위치**: - - `base/ref-plan.md`의 "명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변)" 절 전체 — + - `base/ref-plan.md`의 옛 `명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변)` 항목 전체(2026-08-18에 전면 정정됨) — *"배열 파트의 `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**"*, *"`k=number` 조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*. @@ -529,8 +540,7 @@ > 맞음. 그런데 Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 > 의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == > None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일. -- **문서가 주장하는 것**: `brand-plan.md`의 "**`None`은 이 레지스트리에 안 - 들어감**" 문단 — *"`Brand.get(x)`가 … 범용 introspection 창구 역할까지 +- **문서가 주장하는 것**: `brand-plan.md`의 옛 `None은 이 레지스트리에 안 들어감` 문단(2026-08-18에 정정됨) — *"`Brand.get(x)`가 … 범용 introspection 창구 역할까지 겸하게 하려면 `None`도 빠지면 안 되므로, **`Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고** 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 바로 이 특수 분기의 실제 구현체가 됨"*. @@ -606,8 +616,7 @@ `ObserverEffectLeafHandler`엔 그 체크가 **필수**라고 명시돼 있고 (S-8에서 확인), 빠지면 named 자리로 흘러온 값을 잡으려는 FALLBACK 가드가 죽은 코드가 된다 — 같은 이유가 `Ref`에도 그대로 적용된다. - 2. **`base/ref-plan.md`의 "일반 `Ref`는 계속 Modifier/Store 어디든 - 자유롭게 들어감"** — 이 문장이 "named 해시 키에 놓아도 leaf 바인딩이 + 2. **`base/ref-plan.md`의 옛 `일반 Ref는 계속 Modifier/Store 어디든 자유롭게 들어감` 항목**(2026-08-18에 한정 서술 추가) — 이 문장이 "named 해시 키에 놓아도 leaf 바인딩이 된다"로 읽힌다. 실제 의미는 "Modifier 필드나 Store 값으로 **전달**될 수 있다"(=값으로서 어디든 흘러갈 수 있다)이지 "leaf 바인딩 자리가 아무 데나 된다"가 아니므로, 오해가 없게 다시 써야 한다. @@ -708,8 +717,7 @@ > 다만, 이젠 None 이 있어서 false 을 사용해야할 이유가 없어졌다고 봄. false > 대신 None/nil 을 사용하지 말아야할 이유가 없다면 일관적이게 None/nil 을 > 주는게 맞다는 생각 -- **문서가 주장하는 것**: `event-plan.md`의 "이벤트도 store-bind 가능 — - `false`로 disconnect" 절 — *"**`false`로 disconnect, `nil` 아님.** `nil`은 +- **문서가 주장하는 것**: `event-plan.md`의 "이벤트도 store-bind 가능" 절(제목의 센티널 표기는 2026-08-18에 `None`/`nil`로 갱신됨) — *"**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 '키가 아예 없음'과 구별이 안 됨 … 대신 `false`(Luau에서 실재하는 싱글톤 타입)를 '연결 없음' 센티널로 씀"*. - **왜 낡았나**: 그 결정(2026-08-06)은 **`None` 센티널이 확정되기 전**에 @@ -995,7 +1003,7 @@ | `research/additional-primitives-plan.md` | 21 | 프리미티브 나열 `.../`Slot`/`DI`)` | | `research/debug-tooling-plan.md` | 5, 126, 379, 460, 479 | "Source/DI 생성자", `DI/init.luau`, "DI 제네릭 생성자", "Dispatch/DI", "M5(quad-roblox DI 제네릭 생성자)" | | `research/pre-implementation-audit.md` | 537, 538, 541 | "DI 쪽 패턴 재사용", "DI 타입 생성 계층(M5)", "M5 DI 체크리스트" | -| `pre-implementation-qa.md` | BS-2의 파급 문단 | **이 문서 자신** — `DI/init.luau` 주석과 `D`/`DI` 생성기 언급 | +| 이 문서 자신 | BS-2의 파급 문단 | `DI/init.luau` 주석과 `D`/`DI` 생성기 언급 | **갈래 ② "DI 키" → "특수 키" (설명용 표현)** @@ -1139,7 +1147,7 @@ 이제 정확하지 않다. **런타임은 여전히 전부 커버하지만 타입은 아니다** — `D` 범위 안은 생성된 정확한 타입, 밖은 `any`. 그 문장을 이 구분이 드러나게 다시 쓸 것. -- **이걸로 N-9의 열린 항목은 전부 닫힘** — 남은 건 반영뿐. +- **이걸로 N-9의 열린 항목은 전부 닫힘** — [2026-08-18 기준] 반영도 같은 날 완료. #### 반영할 곳 @@ -1199,7 +1207,7 @@ | (전역 이름·표면) | — | **N-8 `DI`→`D`**, **N-9 `New` 커링** | | `blocker-plan.md` / `tag-plan.md` / `tween-plan.md` / `typing-limits.md` / `component-composition-plan.md` / `module-lifecycle-plan.md` / `onchange-plan.md` / `purity-and-effects-plan.md` / `fallback-plan.md` / `lifecycle-hooks-plan.md` | **전부 통과** | — | -**아직 안 본 것 (2라운드 대상)**: +**아직 안 본 것 (2라운드 대상 — 새 파일 `pre-implementation-qa-round2.md`에 쓸 것)**: - `slot-plan.md`의 `:List` 내부(`reconcile` 구현, `keyFn`, `userdata` 생명주기, 구독 시점, Slot-in-Slot 재귀)와 `dispatch-core-plan.md`의 `recompute`를 **손으로 트레이싱**하는 검증 — 이번 라운드는 "확정된 주장이 diff --git a/.claude/question.md b/.claude/question.md index 84255cc..5d6be9d 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -40,18 +40,19 @@ `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, `archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.) -- **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계 - 표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가 - 있었던 전례(`base/bind-system-plan.md`의 "인스턴스 생성" 절 참고). - **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 - Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 - 때 이 연쇄까지 같이 고려할 것. **(2026-08-08 추가)** 사용자가 `D`(Declarative - 만 남김)로 축약하는 안을 제안 — 근거: (1) "Instance" 전용 개념이 아니라 - quad-* 전반의 declare 요소로 확장해도 되는 이름, (2) 엔진 종속 없이 다른 - 백엔드에서도 재사용 가능, (3) 어차피 `D.FrameModifier`류 타입 프리픽스가 - 길면 못 쓰므로 짧아야 한다는 실용적 제약. 아직 최종 확정 아님 — 다음 - 세션에서 마저 논의(한 글자 식별자의 검색성/자기설명력 트레이드오프를 - 문서에서 어떻게 보완할지도 같이). +- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과 + 완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare + 요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게 + 유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는 + "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 + 보완하기로 같이 확정. 코퍼스 반영 완료 — + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. +- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고 + 자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은 + 파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자: + *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 + 생각해보아야함"*. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -159,6 +160,44 @@ 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는 그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절. +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`을 + 콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의 + lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와 + 부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과 + **`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는 + dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌 + 확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨 + `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 + 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 + 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던 + 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면 + `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가 + `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고 + 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은 + `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의 + `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를 + 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **M8 착수 전 + 필요**, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절. +- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — + 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 + claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 + `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 + 하므로), **키를 무엇으로 할지**(`(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)은 사용자가 + 지목했고, **명문화 여부 결정 + `luau-test` 실측이 M3 착수 전에 필요** — + `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절. - **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패 롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이 이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스 @@ -166,12 +205,10 @@ 문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적 롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정 불필요 — M10 구현 시점에 판단. -- **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** — - 두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서 - 조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리). - 합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이 - 같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어 - 사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절. +- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면 + error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정 + (제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인 + array-part 값 객체" 절. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 @@ -180,7 +217,7 @@ 채택 안 함으로 확정, `base/event-plan.md` "이벤트 핸들러는 self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M2 Dispatch/ - M3 Source/M5 DI 생성자) 시점에 훅 확장 지점만 고려해두면 됨. + M3 Source/M5 `D` 생성자) 시점에 훅 확장 지점만 고려해두면 됨. - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 3a92eaa..5efb586 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -18,7 +18,7 @@ context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된 -독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`DI`)+파생 데이터 +독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`D`)+파생 데이터 (`State`/`Observer`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 재검토해달라는 요청. diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index 3e05cd0..24f07da 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -2,7 +2,7 @@ **상태**: research — 착수 전, 사용자와 계속 논의 필요. 사용자가 "quad 개발이 어느 정도 끝날 때까지는 실제 구현에 못 들어갈 것"이라고 스스로 판단한 후순위 -항목이지만, **base 설계(디스패치 엔진/Source/DI 생성자) 시점에 훅 확장 +항목이지만, **base 설계(디스패치 엔진/Source/`D` 생성자) 시점에 훅 확장 지점만 미리 고려해두면 나중에 훨씬 싸게 먹힌다**는 문제의식으로 지금 미리 정리해둠. `ROADMAP.md` 백로그 항목("범용 렌더 디버깅 도구로서의 quad-mock")과 목적이 다름 — 아래 "quad-mock 백로그와의 관계" 절 참고. @@ -123,7 +123,7 @@ Compute 함수가 어디서 생성됐는지"를 보여주는 **연결 그래프* 등록하는 훅(기본 no-op). 켜졌을 때만 이 레지스트리가 존재하므로 GC-native 원칙(`lifecycle-pattern.md`)과 충돌 없음 — 꺼져 있으면 레지스트리 자체가 안 만들어짐. -- **quad-roblox `DI/init.luau`의 제네릭 생성자(`new(className)`)** — 인스턴스 +- **quad-roblox `D/init.luau`의 제네릭 생성자(`New(className)`)** — 인스턴스 생성 순간 `debug.info(2, "sl")`로 caller의 script+line을 얻어 기록하는 훅(기본 no-op). Instance 생성은 프로퍼티 변경보다 훨씬 드물게 일어나므로 (렌더 타임 1회), 여기서만 비교적 비싼 `debug.info` 호출을 해도 부담 적음. @@ -376,7 +376,7 @@ columnNumber}`를 리터럴로 박아 넣는 컴파일타임 트랜스폼. 런 값으로 존재. **quad-debug 적용 후보**: 위 "계측 지점 3곳"에서 제안한 -`debug.info(2, "sl")` 런타임 캡처(DI 제네릭 생성자, 호출 시점 caller +`debug.info(2, "sl")` 런타임 캡처(`D` 제네릭 생성자, 호출 시점 caller 위치)의 대안/보완으로, **darklua** 같은 빌드타임 Luau 변환기로 quad 생성자 호출부를 순회하며 파일/라인 리터럴을 인자로 미리 주입하는 방식을 검토할 만함. `debug.info`가 "호출자(caller)의 정확한 라인"을 항상 @@ -457,7 +457,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: 나중에 완전 제거하고 싶을 때도 별도 패키지면 그냥 require 자체를 안 하면 끝). - **`quad-debug-roblox`** — 게임(클라이언트) 쪽에서 require하는 provider. - quad-roblox의 Dispatch/DI에 실제 훅을 꽂고, BindableEvent/Function을 + quad-roblox의 Dispatch/`D`에 실제 훅을 꽂고, BindableEvent/Function을 **quad 모듈 자신의 Instance 트리 안에** 만들어 CollectionService 태그로 노출(위 "데이터 채널" 절 — `ReplicatedStorage` 등 게임 트리에 별도 주입 안 함), `IsStudio` 가드 포함. @@ -476,7 +476,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: 만들 필요는 없음). - M3(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기 쉬운 생성자 모양인지만 유의. -- M5(quad-roblox DI 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 +- M5(quad-roblox `D` 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미 `bind-system-plan.md`가 "제네릭 생성자 함수 하나로 통일"이라 확정해둔 것과 자연히 맞아떨어짐, 별도 조치 불필요할 가능성이 큼. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 7368915..92c27db 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -36,7 +36,7 @@ 목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안): 1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) -2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new` + 자주 쓰는 ~25개 클래스 정적 필드(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) +2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) 3. **속성 채우기** — `[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`) 4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜) 5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`) diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 8cb8ae6..04fa967 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -534,11 +534,11 @@ Modifier를 합친다"는 시나리오가 `Overridden`의 가장 그럴듯한 **문제**: Modifier의 런타임 체이닝 엔진은 quad-base 소유가 맞지만, 클래스별 정적 타입 안전성(`mod:UICorner(8)`가 `FrameModifier` 타입으로 추론되는 -것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 -재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 +것)은 "`D` 쪽 '제네릭 생성자 함수 하나 + 정적 별칭 필드' 패턴 +재사용"이라 문서 스스로 밝히듯 quad-roblox의 `D` 타입 생성 계층(M5)에 강하게 결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, `Modifier.Overridden`, `State` 차단)엔 이 클래스별 타입 생성 작업이 -전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. +전혀 없고, M5 `D` 체크리스트에도 Modifier 언급이 없음. **제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier 생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 525fac1..ba1fd37 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1436,3 +1436,50 @@ blob과 바이트 단위로 동일, 메인이 `git rev-parse`로 독립 확인). 도움이 되는 정보에 가깝지 이게 warn을 만들지는 못할듯" — 기계 검사 대상이 아니라 읽는 쪽 판단 재료), (3) (C) 추적은 컨텍스트 보호를 위해 서브에이전트 위임. `conventions.md`에 "문서 표기 규약" 절 신설. + +## 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영 + +원문: `session/2026-08-18-01-pre-implementation-qa-applied.md` + +`.claude/qa-request/pre-implementation-qa-round1.md`(사용자가 `base/` 확정 문서를 문항으로 +재심사해 "아니오"가 나온 것만 모아둔 문서)를 실제 문서에 반영. **그대로 +구현하면 반대로 돌던 두 건**이 닫혔다 — `canBound`가 이름과 반대 방향으로 +쓰이고 있어 정상 첫 바인드가 전부 에러날 뻔한 것(정정 결과 `canBound`와 +`canExecute`는 값이 같은 게 아니라 **서로의 부정**이고, 그게 오히려 이름 +분리의 명분이 됨), 그리고 gcconn/gchold를 `SetStrong`으로 적어 같은 문서가 +경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸리던 것. + +설계가 바뀐 것: `Dispatch.drive`의 `None` 스킵 폐기 → `NoneHandler`는 재귀 +전담 + **`NilHandler` 신설**(깨진 전제는 "배열 파트의 `None`은 `process`를 +안 탄다" — `Frame{State}`이면 탄다), 이벤트 disconnect 센티널 +`false`→`None`/`nil`, `Ref` 내부 구조를 `.Callbacks` 분리로 단순화, +`:List` reconcile의 `nil` 리턴을 **다시 파괴**로 되돌리고 `PopOnly`(가칭) +신설, base Fallback Handler 등록 주체를 **quad-base 로드 시로 재역전** +(백엔드 미로드 상태에서 안내 에러 경로가 안 도는 게 이유 — `InitNamespace` +거부 원칙과의 양립 근거를 새로 씀). **"이벤트 콜백 시그니처는 Luau가 검증 +못 한다"가 거짓**임이 사용자 반례로 확인돼 `onchange-plan.md`가 그걸 근거로 +쓰던 자리까지 같이 무너졌고(결론은 유지, 근거만 교체), 겸해서 `New` 커링과 +"`D`는 전량 코드 생성된 순수 별칭 테이블"이 명문화됨. + +이름 쪽: **`DI` → `D`(Declarative) 확정**(2026-08-08부터 1순위로 열려 있던 +항목) — 코퍼스 전수 반영, 미뤄온 유일한 사유였던 한 글자 식별자의 검색성은 +"처음 나올 때 항상 `D`(Declarative)로 풀어쓴다" 표기 규약으로 보완. +`Attribute.Merged`/`Overridden`을 **둘 다 제공**하는 제3안으로 이름 겹침 +정책도 해소. + +판단이 갈리던 네 건(`PopOnly` 채택, D-7 재역전, `NoneHandler`/`NilHandler` +역할 분담, 동적 키 `GetDynamic`)은 그 자리에서 사용자에게 물어 확정. +남은 착수 금지 게이트(중간 State GC 미검증 등)는 `question.md` 3번과 +`todos.md` 00번이 소스. `doc-check.py` ERROR 0. + +**커밋 전 검증에서 배운 것**: `quad-doc-auditor` 1패스가 "배너는 고쳤는데 +그 배너가 부정하는 본문 bullet은 안 고친" 건을 하나 잡았고, 이어서 사용자가 +직접 돌린 `/code-review high`가 **10건을 더** 잡았다(전부 유효, 전부 반영) — +감사자가 못 본 것들이라 **두 도구가 서로를 대체하지 않는다는 게 실측으로 +드러났다**(감사자는 코퍼스 전체 정합성, code-review는 diff 자체의 결함). +그중엔 ROADMAP이 SL-3 역전을 안 따라와 M8 체크리스트대로 짜면 방금 되돌린 +결함을 다시 만드는 건, 그리고 **설계 갭 2건**(`GetDynamic` 콜론 메소드가 +Store의 lazy `__index`와 충돌 / `PopOnly` 홀드 중 키가 사라지면 파괴도 반환도 +안 됨)이 있어 새 열린 질문으로 올렸다. **감사 비용 메모**: 감사자 한 패스가 +서브에이전트 토큰 21만이라(코퍼스 전체를 다시 읽는 정의라서) 계획했던 4패스를 +중단했음 — 다음엔 diff가 건드린 파일로 범위를 좁혀 프롬프트할 것. diff --git a/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md b/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md new file mode 100644 index 0000000..05d2ca2 --- /dev/null +++ b/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md @@ -0,0 +1,158 @@ +# 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영 + +**요청**: "pre-implementation-qa 의 적용을 수행하자." + +`.claude/qa-request/pre-implementation-qa-round1.md`(같은 날 앞선 세션이 +만든, `base/` 확정 +문서를 사용자에게 문항으로 재심사한 결과)의 항목을 실제 문서에 반영한 세션. +그 문서는 "여기서 정정하지 않는다, 사용자 정정 회신이 오면 반영한다"고 +적혀 있었지만 **각 항목에 이미 사용자 답변 원문과 논거가 붙어 있었고**, +사용자가 적용을 지시했으므로 그 답변을 정정 근거로 삼아 반영했다. + +## 먼저 사용자에게 물은 네 가지 + +QA 문서가 "결론 없음"으로 남겨둔 항목 중, 임의로 정하면 안 되는 것만 +`AskUserQuestion`으로 물었다(나머지는 답변 원문이 이미 방향을 확정함): + +1. **SL-3 — `:List` reconcile의 `nil` 리턴** → *"PopOnly 확정. 다만 이름은 + 변경될 수 있음. 이름에 대해서는 더 생각해보아야함"* → 파괴가 기본으로 + 되돌리고 `PopOnly`(가칭)를 이번 설계에 넣음. +2. **D-7 — base Fallback Handler 등록 주체** → **quad-base 로드 시 등록으로 + 재역전**(2026-08-14의 역전을 다시 뒤집음). +3. **N-4/RF-4 — `None`/`nil` 배열 슬롯 처리 책임** → *"NoneHandler는 + 재귀만, NilHandler가 실질 담당"*. +4. **ST-2 파급 — 동적 키 경로** → *"동적히는 여전히 그냥 Store.Name 하면 + 얻어는 짐. 타입 애러가 난다는 점인데, 이는 GetDynamic(name): + Source 로 제공하는게 최선으로 보임."* + +## 반영한 것 — 성격별 + +**(1) 그대로 구현하면 반대로 도는 것** + +- **S-1 `canBound` 방향 반전**: `canBound(v) == not isBoundAlive(v)`, + 게이트는 전부 `if not canBound(v) then error(...)`. 진원지 + `lifecycle-pattern.md`의 (1)(2)(3) 절을 다시 쓰고, + `source-state-plan.md`/`ROADMAP.md`/`luau-test`(README·STATUS)/ + `audit/gcconn-trick-verification.md`까지 같은 방향으로 정정. + **부수 발견**: 열한 번째 세션이 이름 분리의 근거로 적은 "판정 로직도 + 같고 값도 항상 같다"가 무너짐 — 실제로는 **서로의 부정**이고, 그게 + 오히려 이름 분리의 명분을 강화한다는 쪽으로 절을 다시 씀. +- **RE-1 `SetStrong` → `SetWeak`**: `relate-plan.md`의 "대체하는 것" 절과 + `architecture.md` 소스 트리 주석. 근거 문장("둘 다 존재 이유가 '안 죽는 + 것'이므로 strong")까지 통째로 틀렸던 것이라 근거도 교체 — 그대로 짰으면 + 같은 문서가 경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸렸다. + +**(2) 설계가 바뀐 것** + +- **RF-4+N-4**: `Dispatch.drive`의 `None` 스킵 분기 폐기 → `NoneHandler`는 + 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, + `setLength(0)`/`setOffsetSource(None)` 등록). 깨진 전제는 "배열 파트의 + `None`은 `process`를 절대 안 탄다"였는데 `Frame{ State }`이면 + 탄다는 것. +- **D-6 파생**: Length/Offset 등록 책임이 "그 위치를 **처음** 매치한 + Handler"에서 **말단 Handler**로 정정(중간 노드는 `inst`에 부작용을 안 + 가한다는 D-3 계약과 충돌했음). 같이 검토 대상이던 "모든 핸들러가 + `k=number`일 때 처리" 안은 `NilHandler`가 갭을 닫아 채택 안 함 — + **이건 사용자 답변에서 바로 나온 결론이 아니라 두 답변을 합친 추론이라 + 세션 보고에서 따로 짚었다.** +- **EV-1**: 이벤트 disconnect 센티널 `false` → `None`/`nil`. `EventHandler`가 + `v == nil`에도 매치돼야 한다는 계약이 새로 생김. +- **D-7**: Fallback Handler 등록 주체 재역전. `InitNamespace` 거부 원칙과의 + 양립 근거를 새로 씀 — 그 원칙이 금지한 건 *사용자 수동 init*과 *남의 + 상태를 건드리는 top-level 부작용*이지, 모듈이 자기 레지스트리를 채우는 + 게 아니다. `archive/tag-attribute-load-time-registration-reversed.md`엔 + "절반 재역전" 배너를 달았다(이름 쪽 결론은 그대로 유효). +- **R-1**: `Ref` 내부 구조가 `.Callbacks` 별도 테이블 + 평범한 `.Value` + 필드로 단순화 → `__index` 우회 기법의 존재 이유 자체가 사라짐. +- **SL-3**: `:List` reconcile의 `nil`/키 소멸은 다시 파괴, 값 교체와 + `PopOnly`만 비파괴. `State` 교체가 언마운트인 것은 그대로 유지되게 + 세 경로를 표로 갈랐다(`:Single` sugar가 교체 경로를 타므로 자동으로 안전). +- **BS-2+N-9**: "이벤트 콜백 시그니처는 Luau가 검증 못 한다"가 거짓 — + 사용자가 반례 코드를 직접 작성해 보여줌. `onchange-plan.md`가 이 전제를 + 근거로 쓰던 자리도 근거만 교체(결론은 유지: `OnChange`는 필드가 아니라 + 팩토리라 타입을 미리 찍어둘 자리가 없다). 겸해서 `New` 커링 계약과 + "`D`는 전량 코드 생성된 순수 별칭 테이블"을 명문화. + +**(3) 이름/표면** + +- **N-8 `DI` → `D`(Declarative)** 확정 — 코퍼스 전수 반영(네임스페이스는 + `D`, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화). 2026-08-08부터 + 개명을 미뤄온 유일한 사유(한 글자 식별자의 검색성)는 **"문서에서 처음 + 나올 때 항상 `D`(Declarative)로 풀어쓴다"** 표기 규약으로 보완하고 + `architecture.md`의 네이밍 케이싱 절에 4번 항목으로 넣었다. + `question.md`의 1순위 항목은 `archive/question-resolved.md`로 이전. +- **N-5** `Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 + 이김) **둘 다 제공** — 열려 있던 "error냐 override냐"가 제3안으로 해소. + 덤으로 `Merged`/`Overridden`이라는 이름 쌍의 의미가 코퍼스에서 재정렬됨 + (연산의 종류 → 충돌 시 정책). + +**(4) 나머지** — A-3(`New()` 자동 스코핑이 아니라 `Quad()` + 코드 수정 +필요), D-1(방어 가드 "죽은 코드"에 한정 추가), D-5(`PreRef`는 배열 우선 +보장 위가 아니라 별도 pre-pass), M-3(예약 필드가 `Apply` 하나가 아니라 +셋 + `Overridden`은 콜론도 가능), B-1(`Brand`는 무의존 — `None` 특수 분기 +기각), R-3(`ProcessedPreRef` 센티널), SL-1(`RefLeafHandler`에 `k` 체크 +추가 + 배열 전용 근거 명문화), E-2(`:Unsubscribe()`는 `:Subscribe()`의 +짝으로 축소), N-1(FALLBACK 에러에 `k` 타입), N-2(타입이 방어선), N-3 +(`Quad.debug`), N-6(`SetAndDispose` 후보), N-7(UI 숏핸드는 `Relate`로 +조회), 부수 오탈자 2건(`.value` 케이싱, `fn(value, previous)` 표기). + +## 열어둔 것 (착수 금지 게이트) + +`question.md` 3번과 `todos.md` 00번이 소스 — 중간 State GC 미검증(M3), +그룹 `Attribute` 위치별 claim 키 설계(M10), `SetAndDispose` 방향(M3 전), +dedup 경로의 process/retract 대칭 확인(M3 전), `PopOnly` 이름, +`Store` 미선언 키의 타입 에러 실측(M0). + +## 커밋 전 검증 — 감사자 1패스 + `/code-review high` + +**`quad-doc-auditor` 1패스(base 코퍼스 각도)**: 확실 발견 1건 — +`ref-plan.md`가 "원래부터 빈 자리인 `None`은 **여전히** 두 패스 루프가 직접 +건너뜀"이라고 남겨둔 문장이 같은 파일의 2026-08-18 배너와 정면 모순 +(정확히 "배너는 고쳤는데 그 배너가 부정하는 본문 bullet은 안 고친" 실패 +패턴). 수정 완료. + +**감사 비용 이슈로 나머지 각도는 중단** — 한 패스가 서브에이전트 토큰 +21만/툴 호출 82회였다. 감사자 정의가 "코퍼스 **전체**를 신선한 맥락에서 +다시 읽는다"인 데다(라이브 문서 91개, `base/`만 ~12,000줄) 이번 프롬프트가 +바뀐 결정 16개를 교차 검증하라고 시켜서, 계획대로 4개를 돌렸으면 80만 +토큰대였을 것. **다음에 큰 변경을 감사할 때는 전 코퍼스가 아니라 diff가 +건드린 파일 + 그걸 인용하는 곳으로 범위를 좁혀 프롬프트할 것.** +(부수: `/model`이 opus로 보여 감사자가 opus로 도는지 의심됐는데, 정의 +frontmatter는 `model: sonnet`이고 오버라이드도 안 넘겼다. 다만 **이번 +실행이 실제 sonnet이었는지는 확인 못 함** — 이 세션 트랜스크립트에 +sidechain 레코드가 안 남았다. `todos.md` 7번의 "정의가 언제/얼마나 +반영되는지 모른다"가 여전히 유효.) + +**사용자가 `/code-review high`를 직접 돌림 — 10건 전부 유효**했고 전부 +반영했다. 감사자가 못 잡은 것들이라 **두 도구가 서로를 대체하지 않는다는 +게 실측으로 드러난 라운드**(감사자는 코퍼스 전체 정합성, code-review는 +diff 자체의 결함): + +- **[high] `ROADMAP.md`가 SL-3 역전을 안 따라옴** — `unmountSlotTree`를 + "`:List`의 reconcile"이 쓴다고 그대로 적혀 있었음. M8 체크리스트를 보고 + 구현하면 정확히 이번에 되돌린 결함을 다시 만든다. +- **[medium] `modifier-plan.md` §5 / `attribute-plan.md` 근거 문단**이 + 기각된 "문자열 폴백"과 "자주 쓰는 ~25개"를 근거로 계속 인용. +- **[medium] `GetDynamic` 콜론 메소드가 Store의 lazy `__index`와 충돌** — + 아무 장치 없이 부르면 `"GetDynamic"`이라는 이름의 Source를 만들어 함수로 + 호출하게 됨. 예약 키가 되거나 탑레벨 함수여야 함 → **새 열린 질문**. +- **[medium] ROADMAP에 이번 라운드의 새 표면이 통째로 누락** + (`Attribute.Overridden`/`Quad.debug`/`GetDynamic`/`PopOnly`) → 전부 추가. +- **[medium] `PopOnly` 계약과 의사코드 불일치** — 키가 사라지면 홀드 중이던 + 요소가 파괴도 반환도 안 되고 참조만 끊김 → **새 열린 질문**. +- **[low] `NilHandler`의 `setLength`/`setOffsetSource` 호출 순서가 같은 + 문서의 해제 순서 계약과 반대** → 뒤집음. `ProcessedPreRef`/`PostRef` + 핸들러도 같은 순서 오류가 **이번 세션 이전부터** 있어서 같이 고침. +- **[low]** `architecture.md` 정정 배너가 원문을 "콜론"이 아니라 "콜백" + 메서드로 오인용(정정하려는 문장의 뜻이 뒤집힘), ROADMAP 433행에 리네임 + 전 "대기 중/잠정 표기" 잔여, `documentation-content-map.md`가 `D` 스윕에서 + 누락. + +## 도구/절차 메모 + +- `doc-check.py`: 처음 돌렸을 때 ERROR 6건 — 전부 **내가 절 제목을 바꾸는 + 바람에 다른 문서의 인용이 깨진 것**과, ROADMAP blockquote 안에서 인용을 + 줄바꿈에 걸친 것(`conventions.md`가 이미 경고한 실패 모드를 그대로 밟음). + 고쳐서 **ERROR 0**, WARN 8은 전부 이 세션 이전부터 있던 것. +- 그 QA 문서는 지우지 않고 **근거 기록으로 격하**(상단 + 배너 교체) — 사용자 답변 원문이 그대로 남아 있어야 나중에 되짚을 수 있음. diff --git a/.claude/todos.md b/.claude/todos.md index a4c7ea9..b55caba 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,25 +5,33 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). -00. **⭐⭐ [2026-08-18 신설] M0 착수 전 `.claude/pre-implementation-qa.md`를 - 먼저 읽을 것 — 아래 0번의 "착수를 막는 결정은 없음"보다 이게 우선한다.** - 사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과, **확정으로 - 적혀 있는데 실제로는 틀린 항목**이 여러 건 나왔다. 그 문서가 소스이고 - 여기서 개수도 목록도 세지 않는다 — 다만 성격만 짚으면: - - **그대로 구현하면 반대로 도는 것**이 있다(생명주기 게이트 호출부 반전, - 릴레이션 보관 강/약 반전). 두 건 다 여러 `base/` 문서가 서로를 인용하고 - 있어 한 곳만 고치면 안 된다. - - **설계 자체가 바뀌는 것**이 있다(`Dispatch.drive`의 `None` 처리, - 이벤트 disconnect 센티널, `Ref` 내부 구조, 이벤트 콜백 타이핑 가능 - 여부 등). - - **아직 답이 안 난 것**도 있다(중간 State GC 미검증 등) — 그 항목이 - 걸린 마일스톤은 결론 전에 착수하면 안 된다. +00. **⭐⭐ [2026-08-18 신설, 같은 날 반영 완료] 구현 전 QA 결과는 + `base/`에 전부 반영됐다 — 남은 건 아래 "결론 전에 착수 금지" 항목뿐.** + 사용자가 `base/` 확정 문서 전체를 문항으로 재심사한 결과(원본 문답과 + 사용자 답변 원문은 `.claude/qa-request/pre-implementation-qa-round1.md`가 + 소스), 확정으로 + 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 + 반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면 + 반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다. - **사용자가 정정 회신(어디가·어떻게·왜 틀렸고 원래 뭐가 맞는지)을 Markdown으로 - 주기로 했음** — 그게 오면 `base/`에 일괄 반영하고, 반영 전까지 그 문서의 - 항목들은 **미해결 결함**으로 취급할 것. 회신이 아직 없으면 사용자에게 - 물어볼 것(임의로 정정하지 말 것 — 이 QA의 목적 자체가 에이전트 추정을 - 사용자 판정으로 바꾸는 것이었음). + **M0/M3 착수 전에 결론이 필요한 미해결 항목만 여기 짚는다** — 전부 + `question.md` 3번에 올라가 있고, 각 `base/` 문서에도 ⚠️로 표시돼 있다: + - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / + 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** + - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — + 방향은 확정, 키 설계가 미정. M10 착수 전 필요. + - **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply` + 시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도. + - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` + `:Unsubscribe()` 절) — M3 착수 전 확인. + - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. + - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — + 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. M8 착수 전 필요. + - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** + (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 + 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. + - **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`) + — M0에서 실측 확인. 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy @@ -37,7 +45,10 @@ 재도입**됨(2026-08-14 다섯 번째 세션에 하나로 합쳤던 걸 부분적으로 되짚음 — "이미 묶여 있는가"(bound 문맥)와 "지금 발화해도 되는가" (execute 문맥)는 판정 로직은 공유해도 호출부의 질문이 다르다는 사용자 - 지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). + 지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절. + **[정정, 2026-08-18] 두 predicate는 값이 같은 게 아니라 서로의 부정**이고 + 게이트는 항상 `if not canBound(v) then error(...)` 모양이다 — 그 문서의 + 같은 절이 소스). `question.md`엔 이제 "결정 대기" 절 자체가 없음(비어서 헤딩째로 삭제). **M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라 @@ -92,7 +103,8 @@ stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) - — 아직 진짜로 열려있는 것만 짚으면: `DI`→`D`(1순위), `Slot`(2순위), + — 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 + 완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위), `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). diff --git a/ROADMAP.md b/ROADMAP.md index 2b4c15f..b48f74d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +132,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 좁혀야 함(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로 제외 항 하나 추가, `isPostRef`도 `isRef` 아래 형제로 신설). `isModifier`는 여전히 단순 항등, 상위 개념 없음. **[정정, 2026-08-11 아홉 번째 - 세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 DI 키 + 세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 특수 키 predicate, 해시파트 `k`를 판별)와 `isAttribute`(그룹 값 predicate, array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹 `Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두 @@ -200,7 +200,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider 미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md` 1-3/1-4), 핸들러 등록/정렬 시점 동률 감지 print 경고 + - `Dispatch.listHandlers()` 디버그 유틸 + `Dispatch.listHandlers()` 디버그 유틸. **[2026-08-18]** 동률 경고는 + 무조건 찍지 않고 **모듈 표면의 `Quad.debug`(boolean, 기본 `false`)가 + 참일 때만** — `Quad.debug` 자체가 이번에 신설된 새 공개 표면이다 + (`base/module-lifecycle-plan.md`의 "모듈 표면의 디버그 플래그" 절) - [ ] `Dispatch/Leaf.luau` — `(i:number, v=Ref/Observer/PreRef/PostRef)` children-array leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ @@ -234,6 +237,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M3 — Store/State/Source - [ ] `Source.luau`/`State.luau`/`Store.luau` +- [ ] **[2026-08-18 신설]** `store:GetDynamic<>(name): Source` — 런타임에 + 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 + 기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저 + 확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지 + 아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 3번) - [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) — @@ -295,9 +303,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 호출"로 정정 — 진짜 독립 경로는 둘뿐). **[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`을 폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에 - 다시 갈라짐]** — "이미 유효하게 묶여 있다"(bound 문맥)와 "지금 - 발화해도 되는가"(execute 문맥)는 판정값은 같아도 호출부의 질문이 - 달라, `Ref` 이중 배치 방지(`question.md` 0-W)를 계기로 `canBound`가 + 다시 갈라짐]** — "지금 묶어도 되는가"(bound 문맥)와 "지금 + 발화해도 되는가"(execute 문맥)는 호출부의 질문이 다르고 + **[2026-08-18 구현 전 QA 정정] 판정값도 같은 게 아니라 서로의 + 부정**이라(`canBound(v) == not canExecute(v)`, 게이트는 항상 + `if not canBound(v) then error(...)`), `Ref` 이중 배치 + 방지(`question.md` 0-W)를 계기로 `canBound`가 별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은 공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`** (emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가 @@ -330,7 +341,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M5 — quad-roblox 최소 프로바이더 - [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) -- [ ] `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드) +- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) - [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ @@ -362,8 +373,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 비파괴 경로 `unmountSlotTree`를 `destroySlotTree`와 별도로 구현 — 차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). - **쓰는 자리 둘**: `SlotHandler.process`가 반환하는 클로저, `:List`의 - `reconcile`. **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`. + **쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고 + `:List`의 `reconcile` 중 **값 교체와 `PopOnly`(가칭) 경로만**. + **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고 + **[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이 + `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의 + "reconcile은 전부 비파괴" 일반화가 `:List`엔 안 맞았음 — + `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이 소스). - **해제 시 owner 등록 되돌리는 순서 고정** — `setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**. 반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset @@ -426,9 +442,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 - error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `DI.InstSlot = - Slot<>`(`DI` 네임스페이스 이름 자체는 `question.md` 1번 - 용어정리 대기 중, 여기선 잠정 표기)가 quad-roblox의 사실상 유일한 + error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = + Slot<>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 — + 옛 `question.md` 1번 용어정리 항목은 해소되어 + `archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한 Slot 타입. - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, `keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를 @@ -438,7 +455,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 userdata: UD?): (T|nil, UD?)`가 **매 reconcile 사이클마다 호출** (filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면 - 저비용 재사용 경로. 파라미터 순서는 반환값 순서(`prev`류 먼저, + 저비용 재사용 경로. + **[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`이 + `PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지 + 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한 + userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트). + `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. + **⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드 + 중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요 + (`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, `userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가 `prev`보다 앞이었음). **`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열 @@ -584,7 +609,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`가 쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** — 그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄 - (`base/dispatch-core-plan.md`) + (`base/dispatch-core-plan.md`). + **[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive`의 `None` 스킵 + 분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`에 + 도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고, + 실제 정리는 아래 `NilHandler`가 맡는다 +- [ ] **[2026-08-18 신설]** `NilHandler` — `isHandlable`이 + `type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러. + `Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)` + 등록이 이 핸들러의 일이고 재귀는 안 함(`State`도 정상 + 동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`의 + "`NilHandler`" 절) - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` @@ -699,16 +734,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > `AttributeGroupHandler`는 참조 카운트/이름 claim **알고리즘 구현**일 > 뿐 — `HANDLER_PRIORITY_FALLBACK`에 실제로 등록되는 건 이를 감싸는 > 별도 파일 `TagFallbackHandler`/`AttributeKeyFallbackHandler`/ -> `AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈 -> 자체가 아니라 **백엔드 팩토리**(`RobloxFactory`가 `BaseModule` -> 뮤테이션 시점에 자기 전용 Handler들과 같이 등록). 아래 체크리스트의 +> `AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA] +> 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 +> 상태에서도 안내 에러 경로가 돌아야 하기 때문 — +> `base/dispatch-core-plan.md`의 해당 절). 아래 체크리스트의 > `Handler` 파일 항목은 전부 이 구분을 반영하도록 갱신됨 — 뒤집힌 > 옛 모델은 > `archive/tag-attribute-load-time-registration-reversed.md`. - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) -- [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler, +- [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 @@ -723,7 +759,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 `String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리 - (`Color3Attribute`류)만 quad-roblox의 `D`/`DI` 층에서 각자 추가. + (`Color3Attribute`류)만 quad-roblox의 `D`(Declarative) 층에서 각자 추가. 타입 파라미터화 이름만 착수 전 확인, `base/attribute-plan.md`) - [ ] `quad-base/Dispatch/AttributeKey.luau`(`AttributeKeyHandler` — `setAttribute(inst,name,v)`를 `v`가 뭐든 무조건 호출 + **이름 @@ -735,13 +771,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/ AttributeKeyFallback.luau`(`AttributeKeyFallbackHandler` — 위 `AttributeKeyHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 - 등록되는 별도 이름의 엔티티. 등록 주체는 `RobloxFactory`가 - `BaseModule` 뮤테이션 시점에 자기 전용 Handler들과 같이 — + 등록되는 별도 이름의 엔티티. **[재역전, 2026-08-18] 등록 주체는 + `RobloxFactory`가 아니라 quad-base 자신** — `base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) - [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1, - store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형 array-part 값 객체, - `base/attribute-plan.md`) + store2, ...)`/`Merged`/**`Overridden`**/`:NameMap`, `Tag`와 동형 + array-part 값 객체, `base/attribute-plan.md`. **[2026-08-18]** + `Merged`는 이름이 겹치면 error, `Overridden`은 뒤가 이김 — 둘 다 제공) - [ ] `quad-base/Dispatch/Attribute.luau`(`AttributeGroupHandler` — 이름마다 **그룹 전용 키**(비공개 `GetKey`, 그룹 값 객체별·이름별 메모이즈)로 `Dispatch.process(inst,key,source,1)`만 부르고, 반환 클로저가 자기가 @@ -755,7 +792,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 AttributeGroupFallback.luau`(`AttributeGroupFallbackHandler` — 위 `AttributeGroupHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 등록되는 별도 이름의 엔티티, 등록 주체는 `AttributeKeyFallbackHandler`와 - 동일하게 `RobloxFactory`) + 동일하게 **quad-base 자신** — [재역전, 2026-08-18]) - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`, `base/tag-plan.md` — 2026-08-08 세 번째 세션 array-part 값 객체로 @@ -772,7 +809,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/ TagFallback.luau`(`TagFallbackHandler` — 위 `TagHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 등록되는 별도 이름의 엔티티, - 등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 `RobloxFactory`) + 등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 **quad-base + 자신** — [재역전, 2026-08-18]) - [ ] **[2026-08-14 세션에 누락 발견, 신규]** `quad-roblox/Handlers/ InstanceShorthand.luau` — UI 편의 숏핸드 `UICorner`/`UIPadding` (+`UIPaddingOffset`)/`UIScale`(`base/ui-shorthand-plan.md`). 이 @@ -822,8 +860,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## 특정 마일스톤에 안 묶이고 병행 가능 -- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`로 - 대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 +- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`로 + 대체·해소됨, `DI`→`D`는 2026-08-18 확정·반영 완료) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 아무 시점에나 - [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록, 필요하면 `.claude/session-summary.md` "세션 히스토리"도 갱신(전체 원문은