qa: 구현 전 QA 1라운드 결과를 base/에 전량 반영
`.claude/pre-implementation-qa.md`(사용자가 base/ 확정 문서를 문항으로 재심사한 결과)를 실제 문서에 반영하고, 그 문서를 qa-request/로 옮기며 1라운드임을 파일명·제목에 명시(2라운드는 새 파일). 그대로 구현하면 반대로 돌던 것 2건: - canBound의 판정 방향이 이름과 반대였음 → canBound(v) == not isBoundAlive(v), 게이트는 전부 `if not canBound(v) then error(...)`. canExecute와는 값이 같은 게 아니라 서로의 부정이고, 그게 오히려 이름 분리의 명분이 됨(옛 근거 "값이 항상 같다"는 폐기). - gcconn/gchold 보관이 SetStrong으로 적혀 있었음 → SetWeak. 근거 문장까지 틀렸던 것이라 같이 교체(그대로 짰으면 두-Relate 상호 강참조 누수). 설계가 바뀐 것: - Dispatch.drive의 None 스킵 분기 폐기 → NoneHandler는 재귀 전담, NilHandler 신설(k=number and v==nil 말단이 setLength/setOffsetSource 등록). 깨진 전제는 "배열 파트의 None은 process를 안 탄다". - Length/Offset 등록 책임이 "처음 매치한 Handler" → 말단 Handler. - 이벤트 disconnect 센티널 false → None/nil. - Ref 내부 구조를 .Callbacks 분리 + 평범한 .Value 필드로 단순화, RefLeafHandler에 빠져 있던 type(k)=="number" 추가(leaf는 배열 전용). - :List reconcile의 nil 리턴은 다시 파괴가 기본, 값 교체와 PopOnly(가칭)만 비파괴. - base 소유 Fallback Handler 등록 주체를 백엔드 팩토리 → quad-base 자신으로 재역전(백엔드 미로드 시 안내 에러 경로가 안 돌았음). - "이벤트 콜백 시그니처는 Luau가 검증 못 한다"가 거짓임이 사용자 반례로 확인 → onchange-plan.md의 파생 근거까지 교체(결론은 유지). 이름/표면: DI → D(Declarative) 확정 및 전수 반영, New 커링 + D는 전량 코드 생성, Attribute.Merged/Overridden 둘 다 제공, Quad.debug 신설, store "key" 문자열 커링 기각(→ store:GetDynamic). 판단이 갈리던 4건(PopOnly 채택 / D-7 재역전 / NoneHandler·NilHandler 역할 분담 / 동적 키 경로)은 사용자에게 물어 확정. 커밋 전 검증: quad-doc-auditor 1패스가 1건, 사용자가 돌린 `/code-review high`가 10건을 더 잡아 전부 반영(ROADMAP이 SL-3 역전을 안 따라오던 것, 설계 갭 2건은 새 열린 질문으로 등록). doc-check.py ERROR 0. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
d499a68044
commit
8b57cfbb3c
37 changed files with 1510 additions and 477 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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`의 "인스턴스 생성 / 이벤트
|
||||
네이밍 인체공학" 절이 소스.
|
||||
|
||||
---
|
||||
|
||||
# 확인/결정 필요 목록
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 파일은 **재작성 후에야** 이걸 확인할 수
|
||||
|
|
|
|||
|
|
@ -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" :: (({...}) -> Frame)`). 생성 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입/`State<T>`/`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 기준] 남은 건 정확한
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attri
|
|||
프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를
|
||||
`Attribute<<T>>` → `AttributeKey<<T>>`로 리네임(잠정 확정 — 최종 이름은
|
||||
여전히 `.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>(className)` 제네릭 생성자 + 자주 쓰는 ~25개는
|
||||
정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히
|
||||
재사용 — 제네릭 생성자 하나 + 클래스별 정적 필드를 미리 바인딩하는
|
||||
것과 동일한 절충(**[정정, 2026-08-18 구현 전 QA]** 그 생성자의 이름은
|
||||
`New`이고 타입은 `new<ClassName>(className): from<index<...>>` 같은 타입
|
||||
레벨 인덱싱이 **아니라 생성기가 찍어내며**, 범위도 "자주 쓰는 ~25개"가
|
||||
아니라 "GUI에 쓰이는 모든 인스턴스"다 — 재사용하는 건 절충의 **모양**이지
|
||||
그 수치가 아님). **내부 구현은 완전히
|
||||
동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히
|
||||
호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느
|
||||
쪽을 쓰든 런타임 동작에 차이 없음.
|
||||
|
||||
**[실측 필요, M0/M10]** `[AttributeKey<<boolean>> "name"] = value`처럼 DI
|
||||
**[실측 필요, M0/M10]** `[AttributeKey<<boolean>> "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<<T>> "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<any>} -- 평탄화된 이름→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<<T>>(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<<T>>`로 코드/문서 전체 통일해서
|
||||
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/
|
||||
`Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
|
||||
당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`Slot`/
|
||||
`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리
|
||||
대기열에 있음, 나중에 한꺼번에 재검토.
|
||||
- **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도
|
||||
`setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`"
|
||||
|
|
|
|||
|
|
@ -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>> "Frame"`) 구상
|
||||
보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) —
|
||||
**제네릭 생성자 함수 하나(`new<ClassName>(className): from<index<UIInstances,
|
||||
ClassName>>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 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>(className): from<index<UIInstances, ClassName>>` — 타입
|
||||
레벨 인덱싱)**은 채택하지 않는다**:
|
||||
|
||||
1. **이벤트 필드가 콜백 타입이 안 나온다.** Roblox 타입 정의에서
|
||||
`MouseButton1Click`은 시그널 계열 타입이라, 인덱싱으로 뽑으면
|
||||
`RBXScriptSignal`이 그대로 나오고 quad가 원하는 `((...) -> ())?` 콜백
|
||||
시그니처가 안 나옴.
|
||||
2. **LSP마다 `Frame` 타입을 다루는 방식이 다를 수 있어** 타입 함수/인덱싱에
|
||||
의존하는 게 위험하다.
|
||||
3. **`T | State<T>`(그리고 `T | Tween<T>`, `None`/`nil` 등)까지 타입 함수로
|
||||
조립해야 하는데**, 그럴 바엔 `D` 파일을 통째로 생성하는 쪽이 단순하다.
|
||||
|
||||
**따라서 `D`는 전량 코드 생성 산출물이다** — 타입뿐 아니라 `New` 호출문까지
|
||||
생성기가 찍어낸다(사용자: *"전부 코드 생성이나, New 같은것도 생성기에서
|
||||
같이 적어주어야할 부분"*). 손으로 쓰지 않는다.
|
||||
|
||||
**`New`는 커링, `D`는 처리 없는 별칭 테이블 (2026-08-18 확정)**:
|
||||
|
||||
```luau
|
||||
-- New(name)이 생성자 함수를 반환하고, 그걸 props 테이블로 다시 호출
|
||||
New "Frame" { ... } -- == New("Frame")({ ... })
|
||||
New<<Frame>> "Frame" { ... } -- 직접 사용도 같은 모양
|
||||
|
||||
-- D는 그 결과에 캐스트만 얹은 순수 별칭 테이블 (생성기 산출물)
|
||||
D.Frame = New<<Frame>> "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>> "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<T,U>(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<number>, ...}`류
|
||||
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로
|
||||
유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가
|
||||
깨진 건 아님.
|
||||
평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지.
|
||||
**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로
|
||||
같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source<T>`의
|
||||
`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상
|
||||
필요 없어짐). 동적 키는 명시적 `store:GetDynamic<<T>>(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()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서
|
||||
확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현
|
||||
검증 대상).
|
||||
|
|
|
|||
|
|
@ -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 필드에 들어갈 수 있는 임의의
|
||||
|
|
|
|||
|
|
@ -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` 절)에 필수 패턴으로
|
||||
|
|
|
|||
|
|
@ -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<Slot|None> }`처럼 반응형 값이 `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<Slot|nil>`)도 정상 동작해야 한다는 사용자 요구. `None`을
|
||||
쓰라고 강제하지 않는다 — *"State<Slot|None> 일 수도 있지만,
|
||||
State<Slot|nil> 이여도 작동은 함"*.
|
||||
|
||||
```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<number>`와 `Source<number> | 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<<T>>` 제네릭 생성자와
|
||||
스칼라 편의 패밀리(`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<T>` 래퍼를 **둘 다**
|
||||
받아 `isTween(realv)`로 분기한다(`base/tween-plan.md`). 없어진 건
|
||||
**타입 미보장을 메우려던 방어 가드**뿐이다.
|
||||
- **깊은 체인에서도 힌트가 안 사라짐** — 힌트를 위에서 아래로 실어
|
||||
보내는 게 아니라 **각 레벨이 자기 재프로세스에서 자기 힌트를 받기**
|
||||
때문. `State<State<Tag>>`에서 바깥이 새 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<number> | None)
|
|||
`1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State<number>`,
|
||||
아래 참고), `state<Frame>`처럼 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<Slot>`이 오면 처음 매치하는 건 `StoreBind`(중간 노드)이고,
|
||||
중간 노드는 `inst`에 부작용을 가하지 않는다는 계약(아래 "Dispatch 체인"
|
||||
절)과 정면으로 어긋난다. 사용자 판정은 *"최종 말단 요소가 이를
|
||||
처리하는게 더 올바른것으로 보이는데"* — 재귀가 끝나 실제 값을 받은
|
||||
말단 Handler가 등록한다(`State<Slot>`이면 재귀 끝의 `Dispatch/Slot.luau`,
|
||||
빈 자리면 `NilHandler`, `PreRef`/`PostRef` 소진 자리면 각 nop Handler).
|
||||
같이 검토 대상이던 *"단순히 모든 핸들러가 `k=number`일 때 처리하도록
|
||||
두는"* 안은 채택 안 함 — 그 안이 메우려던 갭(`State<Slot|None>`에서
|
||||
`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<number> | 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<Slot|None>`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도
|
||||
같은 자리로 수렴한다.
|
||||
|
||||
**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`
|
||||
→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).**
|
||||
|
|
|
|||
|
|
@ -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<Effect>`/`State<Observer>`
|
||||
조합에서, 값이 실제로 안 바뀌면 **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()`도
|
||||
|
|
|
|||
|
|
@ -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 이벤트(클릭류)를
|
||||
조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
React/Vue류 프레임워크의 `OnCreated`/`OnRendered`/`OnDisposed` 생명주기
|
||||
훅을 quad에도 두면 좋겠다는 제안. 처음엔 `Frame{[OnCreated] = fn}`처럼
|
||||
싱글톤 프리미티브를 해시 파트 DI 키로 쓰는 안을 검토했으나, `:Compute`
|
||||
싱글톤 프리미티브를 해시 파트 특수 키로 쓰는 안을 검토했으나, `:Compute`
|
||||
콜백에 `State<function>`이 들어올 때의 처리가 까다로워질 것 같다는 우려로
|
||||
스스로 기각 — 대신 `OnCreated(fn)`이 이미 있는 `PreRef` 인스턴스를 반환하는
|
||||
**순수 팩토리 함수**(children 배열에 놓는 슈가)라면 그 우려 자체가 안
|
||||
|
|
@ -79,8 +79,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
|
|||
|
||||
이게 바로 사용자가 처음에 걱정했던 **"`:Compute` 콜백에 `State<function>`이
|
||||
들어오면 처리가 까다로워지지 않을까"** 문제가 애초에 안 생기는 이유와
|
||||
정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 DI 키**(예:
|
||||
`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — DI 키는 Store/Dispatch
|
||||
정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 특수 키**(예:
|
||||
`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — 특수 키는 Store/Dispatch
|
||||
디스패치 경로를 거쳐야 하고, 그 값이 `State<function>`으로 감싸이는
|
||||
경우까지 핸들러가 다뤄야 함. 반면 팩토리 함수 호출은 **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`보다 사용자가 선호 —
|
||||
|
|
|
|||
|
|
@ -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`로 게이팅한다
|
||||
|
||||
|
|
|
|||
|
|
@ -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<<T>>(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를
|
||||
계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상
|
||||
|
|
|
|||
|
|
@ -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가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
|
||||
|
|
|
|||
|
|
@ -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<<T>>` 같은 타입 파라미터화는 안
|
||||
함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시
|
||||
(`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과
|
||||
일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를
|
||||
Luau가 검증 못 하는 대가를 받아들인다"는 결정(`base/bind-system-plan.md` "이벤트
|
||||
바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도
|
||||
포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려
|
||||
`AttributeKey<<T>>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더
|
||||
엄격한 걸 요구하는 셈이라 일관성이 깨짐.
|
||||
일치하는지 검증해주지 않음.
|
||||
**[근거 교체, 2026-08-18 구현 전 QA — 결론은 그대로]** 옛 근거는 *"이벤트
|
||||
바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다는 결정과
|
||||
같은 급"* 이었는데, **그 전제가 거짓**이다(이벤트는 props 타입의 **필드**라
|
||||
`D` 생성기가 콜백 타입을 정확히 줄 수 있음 —
|
||||
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절).
|
||||
진짜 이유는 **`OnChange(name)`이 이름을 인자로 받는 팩토리라 그 경로가
|
||||
없다는 것** — 필드가 아니므로 생성기가 미리 타입을 찍어둘 자리가 없고,
|
||||
프로퍼티별로 전량 생성하는 안은 이미 기각돼 있다(아래 항목). 그래서
|
||||
`AttributeKey<<T>>`처럼 제네릭으로 맞추려는 시도만 남는데, 그건 호출부가
|
||||
매번 타입을 두 번 적게 만들 뿐이라 채택 안 함.
|
||||
- **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**:
|
||||
`archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의
|
||||
"제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만
|
||||
|
|
@ -58,7 +63,7 @@
|
|||
캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`dispatch-core-plan.md`
|
||||
"핸들러 내부 상태 저장" 절).
|
||||
- **`State<function>` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도
|
||||
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에 확인됐으므로 그
|
||||
유사성이 근거가 되지는 못한다.
|
||||
|
|
|
|||
|
|
@ -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<Slot|None> }`
|
||||
처럼 반응형 값이 `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가 이미 소진시키므로 이게 매치되면 곧 타입
|
||||
차단을 우회한 버그라는 뜻.
|
||||
|
|
|
|||
|
|
@ -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`) 이 요약 줄만 어긋나 있었음.
|
||||
|
||||
## 이름
|
||||
|
||||
|
|
|
|||
|
|
@ -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<<Instance>>`(`DI` 네임스페이스 이름 자체는
|
||||
`question.md` 1번 용어정리 대기 중, 여기선 잠정 표기)가 사실상 "그"
|
||||
`D.InstSlot = Slot<<Instance>>`가 사실상 "그"
|
||||
Slot 타입. `Slot<T>()`가
|
||||
기본값(`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<Frame> 교체와 동형, 지우라고 한 적이 없음.
|
||||
-- (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<Frame>` 교체와 동형이고, `Slot { State<Slot> }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State<Slot>` 교체" 절의 확정도 그대로 유지됨 |
|
||||
| `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<T>) -> 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`,
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
|
|||
자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable<T> extends
|
||||
Readable<T>`와 같은 모양). 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<U>`를 반환** — Source
|
||||
자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는
|
||||
|
|
@ -98,9 +98,10 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
|
|||
다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류
|
||||
매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임
|
||||
구현 델리게이션 포함)이라 그 금지와 충돌하지 않음.
|
||||
- **동적 키 폴백(`store "key"`)은 이제 `State<any>`가 아니라 `Source<any>`를
|
||||
반환**하는 것으로 자연히 갱신됨(`base/store-plan.md`의 "타입 추론
|
||||
문제" 절과 연동).
|
||||
- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히
|
||||
갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이
|
||||
아니라 `store:GetDynamic<<T>>(name): Source<T>`다(문자열 커링은 기각,
|
||||
`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()`하면 기존 두 진입점의 기존
|
||||
체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>` 레코드 필드 합성)에
|
||||
이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는
|
||||
경우는 `: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<T> 의 타입을 모르기도
|
||||
하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥
|
||||
`string`으로 들어가 `Source<T>`의 `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<T>`를 오버로드 함수 타입으로 정확히
|
||||
추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정**
|
||||
추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**,
|
||||
위 절), **`store.key`(dot-access)를 1급 경로로 확정**
|
||||
— Store 타입을 `{key: Source<number>, other: Source<string>}`류 평범한
|
||||
레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열
|
||||
리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State<T>`
|
||||
|
|
@ -125,8 +144,37 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐
|
|||
갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가
|
||||
더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이
|
||||
해소됨, 위 "Store 값 설정 문법" 절 참고).
|
||||
`store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source<any>`)
|
||||
폴백으로 격하.
|
||||
**[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** —
|
||||
`store:GetDynamic<<T>>(name): Source<T>`. 런타임 동작 자체는 원래도
|
||||
dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서
|
||||
Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다:
|
||||
선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입
|
||||
에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이
|
||||
정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를
|
||||
둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐.
|
||||
타입 애러가 난다는 점인데, 이는 GetDynamic<T>(name): Source<T> 로
|
||||
제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을
|
||||
포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점.
|
||||
- **⚠️ [구현 주의, 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<T>(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되,
|
||||
M3/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 3번).
|
||||
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
|
||||
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
|
||||
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
|
||||
|
|
|
|||
|
|
@ -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 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나
|
||||
|
|
|
|||
|
|
@ -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`의
|
||||
|
|
|
|||
|
|
@ -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<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
|
||||
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
|
||||
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>`가 `Ref<T>`를 구조적으로 만족하는지, (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된 뒤의 재바인딩도 명시적으로 허용**임
|
||||
(살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이
|
||||
재분리 설계 자체를 재검토해야 함 — **아직 미확인.**
|
||||
|
|
|
|||
|
|
@ -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/` — 이 환경에서 못 돌림
|
||||
|
||||
|
|
|
|||
|
|
@ -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`가 첫 사례, 나중
|
||||
|
|
|
|||
|
|
@ -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`를 **손으로 트레이싱**하는 검증 — 이번 라운드는 "확정된 주장이
|
||||
|
|
@ -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`(뼈대만). 정식 백로그 항목으로
|
||||
올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요.
|
||||
|
|
|
|||
|
|
@ -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 개발 관점에서 솔직하게
|
||||
재검토해달라는 요청.
|
||||
|
||||
|
|
|
|||
|
|
@ -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`가 "제네릭 생성자 함수 하나로 통일"이라 확정해둔
|
||||
것과 자연히 맞아떨어짐, 별도 조치 불필요할 가능성이 큼.
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안):
|
||||
|
||||
1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`)
|
||||
2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new<Class>` + 자주 쓰는 ~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`)
|
||||
|
|
|
|||
|
|
@ -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<Modifier>` 차단)엔 이 클래스별 타입 생성 작업이
|
||||
전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음.
|
||||
전혀 없고, M5 `D` 체크리스트에도 Modifier 언급이 없음.
|
||||
|
||||
**제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier
|
||||
생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것.
|
||||
|
|
|
|||
|
|
@ -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<Slot|None>}`이면 탄다), 이벤트 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가 건드린 파일로 범위를 좁혀 프롬프트할 것.
|
||||
|
|
|
|||
158
.claude/session/2026-08-18-01-pre-implementation-qa-applied.md
Normal file
158
.claude/session/2026-08-18-01-pre-implementation-qa-applied.md
Normal file
|
|
@ -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<T>(name):
|
||||
Source<T> 로 제공하는게 최선으로 보임."*
|
||||
|
||||
## 반영한 것 — 성격별
|
||||
|
||||
**(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<Slot|None> }`이면
|
||||
탄다는 것.
|
||||
- **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<Slot>` 교체가 언마운트인 것은 그대로 유지되게
|
||||
세 경로를 표로 갈랐다(`: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 문서는 지우지 않고 **근거 기록으로 격하**(상단
|
||||
배너 교체) — 사용자 답변 원문이 그대로 남아 있어야 나중에 되짚을 수 있음.
|
||||
|
|
@ -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순위).
|
||||
|
|
|
|||
90
ROADMAP.md
90
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<<T>>(name): Source<T>` — 런타임에
|
||||
이름이 정해지는 동적 키의 정식 창구(옛 `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<<Instance>>`(`DI` 네임스페이스 이름 자체는 `question.md` 1번
|
||||
용어정리 대기 중, 여기선 잠정 표기)가 quad-roblox의 사실상 유일한
|
||||
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
|
||||
Slot<<Instance>>`(**[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<Slot|nil>`도 정상
|
||||
동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`의
|
||||
"`NilHandler`" 절)
|
||||
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
|
||||
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
|
||||
끝, 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<<T>>(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` "세션 히스토리"도 갱신(전체 원문은
|
||||
|
|
|
|||
Loading…
Reference in a new issue