From 297c4d459c523f1d4bb513a4c82750063d40cff8 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Wed, 19 Aug 2026 17:09:16 +0900 Subject: [PATCH] =?UTF-8?q?design:=20quad-types=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20AddPlugin?= =?UTF-8?q?=20+=20CheckedQuad=20=EC=8B=A4=EC=B8=A1=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quad-roblox가 quad-base를 런타임 주입(QuadRoblox(Quad): QuadRoblox)으로만 받으면 pesde 의존 선언이 필요 없어 보이지만, 타입 참조용 require도 런타임에 실제 실행됨을 실측 확인 — dev-dependency로 두면 게시 후 소비자 환경에서 크래시함. 해법으로 구현 없는 타입 계약 전용 워크스페이스 패키지 quad-types 신설, quad-base/quad-roblox 모두 이것만 의존하도록 전환. AddPlugin(self:Self,fn:(Self)->P):Self&P — 제네릭 self로 둬야 체이닝이 누적됨을 실측 확인(고정하면 이전 확장을 잃음), quad-base에 실제 mutate 기반 구현 반영. CheckedQuad 버전 체크는 배선하며 세 번 깨짐 — error() 대신 print+types.never, 함수 본문 로컬 별칭 대신 리턴 타입 표현식에 직접, 그리고 가장 중요하게 type function을 한 번이라도 거친 값(패스스루 포함)은 이후 AddPlugin 같은 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견 — typing-limits.md §6으로 승격. 최종 설계(검증 결과를 원본과 격리된 가상 필드로)만 AddPlugin과 완전히 호환. 부수로 quad-base 자신도 quad-types workspace 의존 때문에 CLI symlink 함정(지난 세션 발견)에 걸림 — 로컬 테스트용 symlink 실체화로 임시 우회. Co-authored-by: qwreey --- .claude/README.md | 3 +- .claude/base/architecture.md | 24 ++- .claude/base/project-setup-plan.md | 17 ++ .claude/base/quad-types-plan.md | 193 ++++++++++++++++++ .claude/base/typing-limits.md | 76 ++++++- .claude/luau-test/README.md | 1 + .claude/luau-test/STATUS.md | 11 +- ...type-quadtypes-checkversion-addplugin.luau | 83 ++++++++ ...ad-types-package-addplugin-checkversion.md | 125 ++++++++++++ pesde.lock | 3 + pesde.toml | 2 +- quad-base/pesde.lock | 7 + quad-base/pesde.toml | 3 + quad-base/src/init.luau | 29 ++- quad-base/test/smoke.plugin.luau | 55 +++++ quad-roblox/pesde.lock | 8 +- quad-roblox/pesde.toml | 2 +- quad-types/pesde.lock | 6 + quad-types/pesde.toml | 9 + quad-types/selene.toml | 31 +++ quad-types/src/init.luau | 80 ++++++++ 21 files changed, 739 insertions(+), 29 deletions(-) create mode 100644 .claude/base/quad-types-plan.md create mode 100644 .claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau create mode 100644 .claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md create mode 100644 quad-base/test/smoke.plugin.luau create mode 100644 quad-types/pesde.lock create mode 100644 quad-types/pesde.toml create mode 100644 quad-types/selene.toml create mode 100644 quad-types/src/init.luau diff --git a/.claude/README.md b/.claude/README.md index 15e089d..d20c410 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,13 +45,14 @@ |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` | | `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | -| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` | +| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | +| `quad-types-plan.md` | **[2026-08-19 신설]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크). `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리). quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 7b2ff46..a72eb04 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -193,11 +193,18 @@ build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup 소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게 따라감). 실제 구현: 루트 `pesde.toml`(`private = true`, -`workspace_members = ["quad-base", "quad-roblox"]`) + `quad-base/pesde.toml`/ -`quad-roblox/pesde.toml`(각각 `[target] environment = "roblox"`, -`quad-roblox`는 `quad-base`에 workspace 의존), 툴체인은 `mise.toml`로 핀 +`workspace_members = ["quad-base", "quad-roblox", "quad-types"]`) + +`quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml` +(각각 `[target] environment = "roblox"`), 툴체인은 `mise.toml`로 핀 (`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는 판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). +**[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox`는 `quad-base`가 +아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace +의존** — `quad-base`는 `QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임 +주입**받으므로 pesde 의존 선언이 필요 없고, 오히려 무거운 quad-base +전체를 dev-dependency로 두면 게시 후 소비자 환경에서 그 타입 전용 +require가 크래시하는 문제가 있어 별도 패키지로 뽑음 — +`base/quad-types-plan.md`가 소스. pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그 파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`은 이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스 @@ -232,10 +239,15 @@ op" 절. ``` quad/ -├── .luaurc # @quad-base, @quad-roblox alias (편집기 경험용, 런타임 비의존) +├── .luaurc # 편집기 경험용 alias(런타임 비의존, `base/project-setup-plan.md` 참고) +├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀 +├── pesde.toml # 워크스페이스 루트(private, workspace_members) ├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트 +├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad 버전체크(`base/quad-types-plan.md`) +│ ├── pesde.toml +│ └── src/init.luau ├── quad-base/ -│ ├── wally.toml +│ ├── pesde.toml │ └── src/ │ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션) │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속 @@ -265,7 +277,7 @@ quad/ │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 │ └── init.luau └── quad-roblox/ - ├── wally.toml + ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── 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" 절) diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md index 8b856d7..7dda4b0 100644 --- a/.claude/base/project-setup-plan.md +++ b/.claude/base/project-setup-plan.md @@ -214,6 +214,23 @@ Studio도 같은 파일시스템 계층을 쓰는 이상 다르게 동작할 이 Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했듯 문제없이 동작한다. +**[2026-08-19 셋째 후속 세션] 이 함정이 이제 `quad-base` 자기 자신의 +프로덕션 진입점에도 실제로 닥침** — `quad-types` 워크스페이스 패키지가 +신설되며 `quad-base/src/init.luau`가 `require("./roblox_packages/quad_types")`를 +쓰게 됐는데(`quad-types-plan.md` 참고), 이건 M0/M1 스파이크가 아니라 +**실제 출하 소스**라 위 "우회는 스파이크에만"이라는 구분이 더 이상 +깔끔하게 안 맞음. 이 세션이 실제로 쓴 해법: `pesde install`이 만든 +심볼릭 링크를 **로컬 CLI 테스트용으로만** 실제 디렉토리 복사본으로 +치환(`find . -type l ... cp -r`류, 저장소 파일은 안 건드리고 +`roblox_packages/.pesde/`의 링크만 대상) — Rojo/Studio는 이미 symlink를 +투명하게 처리하므로 배포 경로엔 아무 영향 없고, 순수 이 샌드박스의 +`luau`/`luau-analyze` 실행을 가능하게 하는 로컬 조치다. **아직 반복 +가능한 스크립트/mise task로 정식화하진 않음** — 매번 `pesde install` +후 수동으로 치환했음. 다음 세션이 CLI 테스트를 또 돌리려면 같은 수동 +치환이 필요하거나, 이 시점에 정식 스크립트화를 고려할 것(Luau의 +`.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — +그때 다시 볼 것). + ## `.luaurc` — alias는 여전히 편집기 전용 `.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임 diff --git a/.claude/base/quad-types-plan.md b/.claude/base/quad-types-plan.md new file mode 100644 index 0000000..8f9513c --- /dev/null +++ b/.claude/base/quad-types-plan.md @@ -0,0 +1,193 @@ +# `quad-types` — 구현 없는 `Quad` 타입 계약 + 컴파일 타임 버전 체크 + +**상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 +세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한 +사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. + +## 왜 필요한가 — dev-dependency로는 못 푸는 문제 + +`quad-roblox`는 `QuadRoblox(Quad): QuadRoblox`처럼 quad-base 인스턴스를 +**런타임에 함수 인자로 주입**받는다(`base/module-lifecycle-plan.md` +"Bind는 누가, 어떻게 구현하는가" 절이 확정해둔 팩토리 패턴 — quad-roblox +자신은 quad-base를 `require`할 필요가 없어 보인다). + +**그런데 타입 주석 하나 때문에 얘기가 달라진다.** `QuadRoblox`의 시그니처가 +`Quad` 타입을 참조하려면 그 타입이 정의된 모듈을 `require`해야 하고, +**이 require는 "타입만 쓰려는 목적이어도 런타임에 실제로 실행된다"** +(실측 확인, 2026-08-19 — `require`가 반환하는 모듈에서 export 타입만 +꺼내 써도 그 `require` 문 자체는 평범한 런타임 호출이라, 대상이 없으면 +그 자리에서 크래시함). 그래서: + +- `quad_base`를 **일반 의존성**으로 두면: 소비자가 `quad-roblox`를 설치할 + 때마다 무거운 quad-base 전체가 통째로 딸려온다(quad-base를 이미 따로 + 설치해서 `QuadRoblox(Quad)`에 넘기는 상황이면 완전히 중복). +- `quad_base`를 **dev-dependency**로 두면: 로컬 개발 중엔 문제없지만, + `quad-roblox`가 게시된 뒤 **소비자 환경엔 dev-dependency가 전파되지 + 않아** 그 타입-전용 require가 못 찾고 그 자리에서 런타임 크래시난다. + +**해법**: `Quad`의 타입 계약만 담은, 런타임 구현이 사실상 없는 세 번째 +워크스페이스 패키지 `quad-types`를 두고, `quad-roblox`는 이것만 **일반 +의존성**으로 둔다 — 항상 안전하게 실 의존성으로 넣을 수 있을 만큼 +작고, quad-base 전체를 안 끌고 온다. + +## `quad-base` 안에 폴더로 두면 안 되는가 — 안 됨 + +pesde의 워크스페이스 의존성은 **패키지 단위**로만 걸린다 +(`{ workspace = "scope/name" }`) — 서브폴더 단위 의존 문법이 없다. +`quad-base/types/`처럼 폴더로 만들어도 `quad-roblox`가 그걸 가져오려면 +결국 `quad_base` 패키지 전체를 의존성으로 선언해야 하고, 실제로 링크되는 +것도 quad-base 전체 소스 트리다(어느 파일을 실제로 require하는지와 +무관). 그래서 **반드시 별도 pesde 패키지**(`workspace_members`의 새 +멤버)여야 "가벼운 타입만" 효과가 실제로 생긴다. + +## 구조 + +``` +quad-types/ +├── pesde.toml # name = "qwreey/quad_types" +└── src/init.luau # export type Quad, type function CheckVersion, export type CheckedQuad +``` + +- `quad-base`는 `quad_types`에 workspace 의존 — **자기 `Quad` 타입을 + 따로 선언하지 않고 `type Quad = QuadTypes.Quad`로 그대로 가져다 씀** + (한 곳에만 진실이 있게, 구현이 계약과 어긋나면 구조적 타입에러로 + 자연히 드러남). 실제로 `quad-base/src/init.luau`에 반영됨. +- `quad-roblox`도 `quad_types`에 workspace 의존(quad-base 아님). +- **[참고, 2026-08-19 사용자 판단] 모든 백엔드/플러그인 패키지가 이 + 패턴을 따를 필요는 없다** — 예: 가상의 `quad-spring`/`quad-spring-roblox` + 쌍은 타입 분리 없이 `quad-spring-roblox`가 `quad-spring`을 평범하게 + 일반 의존성으로 둬도 된다("주입만 하면 Spring이 같이 따라오도록"). + `quad-types` 분리는 **quad-base처럼 사실상 모든 패키지가 의존하는 + 핵심 계약**일 때만 값어치가 있다. + +## `Quad` 타입 — 확정된 표면 + +```lua +export type Quad = { + Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 + debug: boolean, + New: () -> Quad, + RunInit: (self: Quad, initFn: (Quad) -> any) -> (), + AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P, +} +``` + +`Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`. +이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한 +구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴 +`"0.1.0"`은 `"0.0.0"`과 구조적으로 호환 안 됨) — `CheckVersion`이 주는 +추가 가치는 **감지 자체**가 아니라 사람이 읽을 수 있는 진단 메시지다 +(아래 절). + +## `AddPlugin` — 실측 검증된 플러그인 체이닝 + +```lua +AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P +``` + +`Self`를 고정된 `Quad`가 아니라 **제네릭**으로 둬야 체이닝이 누적된다 +(고정하면 두 번째 `AddPlugin` 호출이 첫 번째 확장을 잃어버림). 실측 +확인(2026-08-19): +- `quad:AddPlugin(springFn)`(→ `Quad & SpringPlugin`)`:AddPlugin(otherFn)` + 체이닝 결과가 정확히 `Quad & SpringPlugin & OtherPlugin`로 누적됨. +- 플러그인 추가 전 그 메소드에 접근하면 정확히 `Key 'X' not found`로 + 거부됨(음성 대조군). + +**런타임 구현**(quad-base, 실제 반영됨): `pluginFn(self)`를 호출해 얻은 +확장 테이블의 필드를 `self`에 **직접 mutate**하고 `self` 그대로 반환 — +새 테이블을 만들지 않는다. 이유: `RunInit`의 멱등 추적이 `module` +identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절), +`AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다. + +## `CheckedQuad` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 + +**왜 필요한가**: `quad-roblox`가 `quad_base`를 pesde `[dependencies]`로 +선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌 +방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥 +함수 인자라서. `CheckedQuad`가 그 빈자리를 메꾸는 컴파일 타임 +대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox +소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 +충분"). + +```lua +export type CheckedQuad = T & { __versionCheck: CheckVersion } +``` + +**사용법**: +```lua +local function CheckQuad(quad: T): QuadTypes.CheckedQuad + return quad :: any +end + +local checked = CheckQuad(injectedQuad) +local _ = checked.__versionCheck -- ⚠️ 필수 — 아래 "함정 2" 참고 +local quad = checked:AddPlugin(installRobloxBackend) -- 이후 정상적으로 체이닝 +``` + +### 실측으로 깨진 시도들 (전부 순서대로 실제로 시도하고 버림) + +**함정 1 — `error()`로 에러 내면 안 됨.** `type function` 안에서 +`error()`를 부르면 "이 type function 자체가 실행에 실패함"으로 판정돼 +버려져서 원하는 메시지가 안 뜬다. **`print("메시지")` + `return +types.never` 조합만 호출부에 정확히 `TypeError: <메시지>`로 뜬다** +(실측 확인 — 3줄짜리 최소 재현으로 검증). + +**함정 2 — 함수 본문 안의 로컬 타입 별칭으론 절대 평가 안 됨.** +```lua +-- ❌ 이렇게 하면 아무 진단도 안 뜬다(제네릭 인스턴스화 시 재평가 안 됨) +local function QuadRoblox(quad: T): T + type _Check = CheckVersion + return quad +end +``` +체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에 +박아 넣어야 한다. 이게 `CheckedQuad`가 함수 파라미터/반환 타입 +표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 +참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ = +checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다. + +**함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 `type +function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다.** +처음엔 `CheckVersion`가 성공 시 `T`를 그대로 패스스루(`return t`)하는 +버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접 +써서 즉시 평가되고, `AddPlugin`도 안 뭉개지는 것처럼 보였다). 그런데 +**`CheckVersion`의 결과를 다른 타입과 `&`로 합친 뒤 `AddPlugin`을 +호출하는 조합**에서 `Expected this to be exactly 'P & Self', but got +'P & Self'`처럼 **앞뒤가 똑같은, 의미 없는 진단**이 뜨며 깨졌다. 더 +좁혀보니, 심지어 **`&`로 안 합쳐도, `CheckVersion`를 거친 값에 +`AddPlugin`을 부르기만 해도 똑같이 깨졌다** — 재구성 비용(값을 +`types.newtable()`로 다시 조립하는 것) 문제가 아니라, **"이 타입이 +`type function`을 거쳤다는 이력 자체"**가 이후 제네릭 self 추론을 +방해하는 것으로 보인다. 그래서 최종 설계는 **`CheckVersion`이 `T`를 +전혀 참조/반환하지 않고**(성공 시 트리비얼한 `types.singleton(true)` +하나만), 검증 결과를 원본과 절대 안 섞이는 **별도 필드** +(`__versionCheck`)로 완전히 격리한다 — `T` 자신은 `type function`을 +한 번도 거치지 않은 "순수한" 타입으로 계속 흘러가므로, 그 뒤 +`AddPlugin` 체이닝이 몇 번이 되든 전혀 안 깨진다(실측 확인). + +**교훈**: `type function`의 부작용은 "재구성이 원본을 뭉갠다"는 상상보다 +넓다 — **패스스루도 이력만으로 오염된다.** 검증/변형용 `type function`을 +설계할 때는 원본 타입을 **절대 반환하지 말고**, 트리비얼한 마커만 +반환해서 완전히 별도 필드로 격리할 것 — `typing-limits.md`의 "새 +타입/API를 설계할 때 체크리스트"에 추가할 후보. + +## 실측 근거 + +`.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` — +실제 `quad-types`/`quad-base`를 `require`해서 위 사용법 그대로 +재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 확장 필드 +유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 +`TypeError: quad-base version mismatch: ...` 하나만. + +`quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate ++ identity 보존 + 체이닝)을 실행 레벨로 검증. + +## 남은 것 + +- `quad-roblox`가 실제로 `CheckedQuad`를 쓰는 진입점(`QuadRoblox` 등) + 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 + 예제가 실제 위치는 아직 없음. +- `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 + `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`는 + **버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개 + 문제로 계속 `_initializedBy`가 담당한다. diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 7492809..c21573b 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -220,12 +220,12 @@ type State = { with different parameters"로 선언 시점에 막힘, 실측: `type-recursive-issue-with-typeof/spikes/ 19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도 - §8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정" + §9의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정" 전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다. **시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**: 콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone` -체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 +체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면 (= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단 두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable` @@ -361,7 +361,68 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 6. 성립이 확인된 것 (안심해도 되는 것) +## 6. `type function`을 거친 값은 이후 제네릭 self 메소드 체이닝이 조용히 깨짐 + +**[2026-08-19 신설, 근거: `quad-types-plan.md`, 실측 `luau-test/23`]** + +### 무엇이 안 되는가 + +값의 **정적 타입**이 한 번이라도 `type function`을 거치면(설령 그 +`type function`이 입력을 그대로 반환하는 순수 패스스루라도), 그 뒤 +그 값에 **제네릭 self 파라미터를 쓰는 메소드**(`(self: Self, +...) -> Self & P`류, `AddPlugin`이 실제 사례)를 부르면 진단이 조용히 +깨집니다: + +```lua +type function CheckVersion(t: type): type + return t -- 순수 패스스루 — 재구성 없음 +end + +local checked: CheckVersion = ... -- 여기까진 정상 +local extended = checked:AddPlugin(somePlugin) -- 여기서 깨짐: +-- TypeError: Expected this to be exactly 'P & Self', but got 'P & Self' +-- (양쪽이 글자 그대로 같은, 의미 없는 진단) +``` + +**핵심은 "재구성"이 아니라 "이력"입니다** — `types.newtable()`로 새로 +조립한 타입만 문제인 게 아니라, `return t`로 원본을 그대로 돌려줘도 +똑같이 깨집니다. 값이 `type function` 호출을 거쳤다는 사실 자체가 +이후 제네릭 self 추론을 방해하는 것으로 보입니다(정확한 내부 메커니즘은 +미상 — 솔버가 type function 출력을 "불투명한" 타입으로 취급해 self +단일화에 필요한 정보를 잃는 것으로 추정됨). + +### 그래서 우리가 하는 것 + +검증/변형용 `type function`은 **원본 타입을 절대 반환하지 않는다** — +성공 시에도 트리비얼한 마커(`types.singleton(true)` 등)만 반환하고, +그 결과를 원본과 **완전히 격리된 별도 필드**로만 노출합니다: + +```lua +-- ✅ 원본 T는 type function을 한 번도 안 거침 +type CheckedQuad = T & { __versionCheck: CheckVersion } + +local checked: CheckedQuad = ... +local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고) +local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함 +``` + +`quad-types-plan.md`의 "`CheckedQuad`" 절에 전체 배선과 실측 과정이 +있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 본문 +로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 +호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로 +격리, 세 가지가 함께 필요합니다. + +### 언제 마주치는가 + +`AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에, +`type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는 +지금 `quad-types`의 `CheckedQuad`가 유일한 실사례지만, 앞으로 비슷한 +"타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 수 +있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가. + +--- + +## 7. 성립이 확인된 것 (안심해도 되는 것) 한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다. 아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것: @@ -381,7 +442,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 7. 새 타입/API를 설계할 때 체크리스트 +## 8. 새 타입/API를 설계할 때 체크리스트 1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo` 안에서 `-> Foo`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), @@ -412,6 +473,11 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC 스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를 확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가 실측에서 뒤집힌" 것들입니다. +7. **`type function`으로 검사/변형한 값에 제네릭 self 메소드 + (`(self:Self,...)`류)를 나중에 부를 계획인가?** → 6번 한계에 + 걸림. 그 `type function`이 원본 타입을 조금이라도 반환하면(패스스루 + 포함) 안 됨 — 검사 결과는 원본과 절대 안 섞이는 별도 필드로 격리하고, + 원본 타입 자체는 `type function`을 아예 거치지 않게 할 것. > **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로 > 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze @@ -421,7 +487,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 8. 미해결 / 추적 중 +## 9. 미해결 / 추적 중 - **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze` CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값** diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index b473abe..5afe4fc 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -89,6 +89,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | | `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 | | `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지 | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 `Brand` 절 | +| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규]** 실제 `quad-types`/`quad-base`를 `require`해서 `CheckedQuad`(버전 체크)가 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견 | `quad-types-plan.md`, `typing-limits.md` §6 | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 480ff96..ef8a521 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,10 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-19 — `13`을 타입 전용/런타임 두 파일로 분리 +> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` +> 버전 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → `done/` +> 직행. 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 +> 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md` +> §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리 > (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임 > 절반은 신규 `22`로 분가 → 둘 다 `done/`. 직전 갱신은 같은 날 — > M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 `05`를 현행 모델("emit은 @@ -32,7 +36,7 @@ | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 18 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -88,7 +92,7 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (18건) +## ✅ `done/` — 통과 or 판정 끝 (19건) **런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는 검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19] @@ -120,6 +124,7 @@ | `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 | | `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType`이 정확히 `{ty: Source, count: Source}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | | `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16`의 `ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 | +| `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규]** ✅ 통과 — 실제 `quad-types`/`quad-base`로 `CheckedQuad`+`AddPlugin` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격) — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | ### 특별히 중요한 통과 3건 diff --git a/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau b/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau new file mode 100644 index 0000000..31bff5d --- /dev/null +++ b/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau @@ -0,0 +1,83 @@ +--!strict +--[[ + 검증 대상(타입 전용): `quad-types`의 `CheckedQuad`(버전 체크 + type function)가 실제 `quad-base`가 구현하는 `Quad` 타입과 맞물려 + 동작하는지 — 그리고 검증 후에도 `AddPlugin`의 제네릭 체이닝이 + 안 깨지는지. + + 배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad): + QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서, + pesde의 semver 충돌 방지가 이 관계엔 안 걸리게 됨(선언된 의존성이 + 아니라 그냥 함수 인자라서) — 그 빈 자리를 메꾸는 컴파일 타임 버전 + 체크. `quad-types/src/init.luau`의 `CheckVersion`/`CheckedQuad` 절 + 참고. + + **이 파일이 재현하는 핵심 함정(처음 시도했다가 깨진 것들)**: + 1. `error()`는 못 씀 — type function 자체가 실패한 걸로 판정돼서 + 버려짐. `print(...)` + `return types.never` 조합만 + "TypeError: <메시지>"로 정확히 뜬다. + 2. 체크를 함수 본문 안의 로컬 타입 별칭으로 두면(`type _Check = + CheckVersion`) 제네릭이 인스턴스화될 때마다 재평가되지 않아 + 진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로 + 해석돼야 하는 자리**에 박아 넣어야 함. + 3. **[가장 중요, 가장 늦게 발견]** `CheckVersion`가 `T`를 단순 + 패스스루(`return t`)해도, 그 결과 타입이 **한 번이라도 type + function을 거쳤다는 이력만으로** 이후 `AddPlugin` 같은 + 제네릭 self 메소드 체이닝이 조용히 깨짐("Expected this to be + exactly 'P & Self', but got 'P & Self'"류의 앞뒤가 같은 의미 없는 + 진단). 그래서 검증 결과는 **원본 타입과 절대 안 섞이는 별도 + 가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를 실제로 + 참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck` 줄이 + 그 필수 스텝을 보여줌. + + 실행: `luau-analyze 23-type-quadtypes-checkversion-addplugin.luau` +]] + +local QuadTypes = require("../../../quad-types/src") + +type RobloxExt = { Frame: (self: any) -> string } + +-- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번 +-- 함정에 걸림) +local function CheckQuad(quad: T): QuadTypes.CheckedQuad + return quad :: any +end + +local function installRobloxBackend(_self: QuadTypes.Quad): RobloxExt + return nil :: any +end + +-- 실제 quad-base가 구현하는 Quad 타입 그대로(버전 일치) +local goodQuad: QuadTypes.Quad = nil :: any + +-- 다른 버전을 흉내 — 구조는 같지만 Version 리터럴만 다름 +local badQuad = { + Version = "9.9.9" :: "9.9.9", + debug = false, + New = (nil :: any) :: () -> any, + RunInit = (nil :: any) :: (self: any, initFn: (any) -> any) -> (), + AddPlugin = (nil :: any) :: (self: Self, pluginFn: (Self) -> P) -> Self & P, +} + +-- 1) 버전이 맞으면: 강제 참조 후 통과 + AddPlugin 체이닝까지 그대로 살아있어야 함 +local checked = CheckQuad(goodQuad) +local _forceCheck = checked.__versionCheck -- ⚠️ 필수 — 없으면 검증이 조용히 스킵됨 + +local withRoblox = checked:AddPlugin(installRobloxBackend) +local okFrame: string = withRoblox:Frame() +local okDebug: boolean = withRoblox.debug +print(okFrame) + +type SpringPlugin = { Spring: (self: any, v: number) -> number } +local function springFn(_self: QuadTypes.Quad): SpringPlugin + return nil :: any +end +local withSpring = withRoblox:AddPlugin(springFn) +local spring: number = withSpring:Spring(1) +local stillFrame: string = withSpring:Frame() -- 먼저 추가한 RobloxExt도 안 사라짐 + +-- 2) 버전이 다르면 강제 참조 시점에 TypeError가 나야 함(음성 대조군) +local checkedBad = CheckQuad(badQuad) +local _forceCheckBad = checkedBad.__versionCheck + +print(okDebug, spring, stillFrame) diff --git a/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md b/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md new file mode 100644 index 0000000..e49e496 --- /dev/null +++ b/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md @@ -0,0 +1,125 @@ +# 2026-08-19, 일곱 번째 세션 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계 + +**요약**: `RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리 +확정한 뒤, 사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면 +dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고 +`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설, +`AddPlugin` 플러그인 체이닝과 `CheckedQuad` 컴파일 타임 +버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 새 Luau 함정을 +하나 발견해 `typing-limits.md`에 승격. + +## 1. `_initializedBy` 결정 반영 + +지난 턴에서 제기된 "`RunInit`을 backend 설치에도 재사용해도 되는가" +질문에 사용자가 짧게 답함: `_initializedBy`를 그대로 쓰자. `RunInit`은 +함수 identity 추적이라 "다른 팩토리 재호출=에러"라는 backend 계약을 +못 만족한다는 진단을 그대로 확정, `module-lifecycle-plan.md`에 반영 +(예시 `InitRoblox` 의사코드 포함, 실제 구현은 M5). + +## 2. dev-dependency 문제 제기 → `quad-types` 신설 + +사용자 문제 제기 요지: `QuadRoblox(Quad): QuadRoblox`처럼 quad-base를 +런타임 주입으로 받으면 quad-roblox가 quad-base를 pesde 의존성으로 +선언할 필요가 없어 보이는데, 실제로는 타입 참조 때문에 `require`가 +필요하다. 이걸 dev-dependency로 두면 게시 후 소비자 환경에서 깨질 것 +같은데, 정확히 왜/어떻게 깨지는지 실측해달라는 요청 + "quad-types +폴더를 quad-base 안에 넣고 그것만 링킹하는 게 되는지"/"버전 필드로 +타입함수 검증하는 게 되는지" 두 구체적 대안 질문. + +**실측 확인**: +- `require(...)`로 타입만 뽑아 쓰는 것도 **런타임에 실제로 실행됨** — + 대상 모듈을 지우고 돌려보니 진짜 크래시(`could not resolve child + component`). dev-dependency 우려가 정확했음. +- pesde 워크스페이스 의존성은 **패키지 단위**만 가능 — `quad-base/types/` + 폴더로는 "가벼운 타입만" 효과를 못 얻음(전체 패키지가 통째로 + 링크됨). **별도 워크스페이스 멤버로 뽑아야만** 실제로 가벼워짐. +- 버전 체크 타입함수 — 최소 재현으로 즉시 성공(`type function +CheckVersion` + `readproperty`/`value()`로 리터럴 비교). + +**결론**: `quad-types` 3번째 워크스페이스 멤버 신설, `pesde.toml` ++ `quad-base`/`quad-roblox` 의존성 전환(quad-roblox는 quad-base 대신 +quad-types만 의존)까지 실제로 실행 — `pesde install`로 링크 확인. +`quad-spring`/`quad-spring-roblox` 같은 가상의 다른 플러그인 쌍은 이 +분리가 필요 없다고 사용자가 별도로 짚음(quad-base처럼 "거의 모든 +패키지가 의존하는 핵심 계약"일 때만 값어치가 있음). + +## 3. `AddPlugin` — 제네릭 self 체이닝 실측 + +`Quad:AddPlugin(pluginFn): T`에서 `T`가 정확히 "플러그인이 누적된 +Quad"가 되는지 확인해달라는 요청("타입의 근간인 부분"). 여러 스파이크로 +검증: +- `(self: Self, pluginFn: (Self) -> P) -> Self & P` — `Self`를 + 제네릭으로 둬야 체이닝이 누적됨(고정하면 두 번째 호출이 첫 확장을 + 잃음). 실제로 `Quad & SpringPlugin & OtherPlugin`까지 정확히 누적 + 확인, 음성 대조군(플러그인 추가 전 접근)도 정확히 거부됨. +- 사용자도 독립적으로 같은 패턴을 직접 테스트해 성공 확인(대화 중 + "성공했어" 보고). +- quad-base에 실제 구현 — `pluginFn(self)`의 결과를 `self`에 mutate + (새 테이블 안 만듦, `RunInit`의 identity 추적을 안 끊기 위해). + `smoke.plugin.luau`로 mutate/identity/체이닝 전부 실행 레벨 검증. + +## 4. `CheckedQuad` — 배선하며 세 번 깨짐, 세 번째가 핵심 발견 + +사용자가 구체적 구현 지침을 줌: `error()` 말고 `print()`+`types.never`, +검증 결과는 `__versionCheck` 같은 가상 필드에, 성공 값은 트리비얼하게. +실제로 배선하며 순서대로: + +1. **`error()` 시도 → 실패**: type function 자체가 실패로 판정됨. + `print`+`types.never`로 교체 → 즉시 성공(호출부에 정확히 + "TypeError: <메시지>"). +2. **함수 본문 로컬 타입 별칭 시도 → 무반응**: `type _Check = + CheckVersion`를 본문에 두면 제네릭 인스턴스화마다 재평가 안 됨. + 리턴 타입 표현식 자체로 옮기니 즉시 해결. +3. **[가장 중요] 패스스루(`return t`) 버전 → 단독으론 통과, `AddPlugin` + 체이닝과 조합하면 조용히 깨짐**: `CheckVersion & RobloxExt` 뒤에 + `:AddPlugin(...)`을 부르면 "Expected this to be exactly 'P & Self', + but got 'P & Self'"처럼 앞뒤가 같은 의미 없는 진단이 남. `&`로 안 + 합쳐도, 패스스루만 거쳐도 동일하게 깨짐 — **재구성이 아니라 "type + function을 거쳤다는 이력 자체"가 문제**. 최종 설계: `CheckVersion`이 + `T`를 절대 반환하지 않고(성공 시 `types.singleton(true)`만), 결과를 + `T & { __versionCheck: CheckVersion }`처럼 원본과 완전히 격리된 + 필드로만 노출 — 이 형태만 `AddPlugin` 체이닝과 완전히 호환됨(실측). + `__versionCheck`는 실제로 참조해야 평가되는 lazy 필드라는 점도 + 다시 확인(함정 2와 같은 결). + +이 세 번째 발견은 quad 코퍼스에 없던 새 Luau 한계라 `typing-limits.md` +§6으로 승격(6번을 신설하며 기존 6/7/8을 7/8/9로 재번호, 내부 상호 +참조 §6/§8도 같이 고침), §8 체크리스트에 항목 7 추가. + +## 5. 부수 발견 — quad-base 자기 자신도 CLI symlink 함정에 걸림 + +`quad-base/src/init.luau`가 `quad-types`를 workspace 의존으로 받게 +되며 `require("./roblox_packages/quad_types")`를 쓰게 됐는데, 이건 +지난 세션에 발견한 심볼릭 링크 문제(Rojo는 괜찮고 standalone `luau` +CLI만 못 따라감)가 **이제 quad-base 자신의 프로덕션 진입점에도** 닥침 — +지난 세션엔 quad-roblox→quad-base(아직 코드 없음)만 영향권이라 여유가 +있었는데, 이번엔 실제로 존재하는 quad-base의 `require`가 막힘. 이 +세션은 `pesde install`이 만든 심볼릭 링크를 로컬 CLI 테스트용으로만 +실제 디렉토리 복사본으로 치환하는 즉석 조치로 우회(`find ... -type l +... cp -r`) — 정식 스크립트화는 아직 안 함, `project-setup-plan.md`에 +다음 세션이 알아야 할 것으로 남김. + +## 6. 산출물 + +- `quad-types/`(신규 패키지: `pesde.toml`, `src/init.luau` — + `Quad`/`CheckVersion`/`CheckedQuad`), `quad-types/selene.toml`. +- `quad-base/pesde.toml` — `quad_types` 의존성 추가. +- `quad-roblox/pesde.toml` — 의존성을 `quad_base`→`quad_types`로 전환. +- `pesde.toml`(루트) — `workspace_members`에 `quad-types` 추가. +- `quad-base/src/init.luau` — `Quad` 타입을 `quad-types`에서 가져오도록 + 전환, `Version`/`AddPlugin` 실제 구현 추가. +- `quad-base/test/smoke.plugin.luau` 신규. +- `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` + 신규 — 실제 quad-types/quad-base 통합 검증. +- `.claude/base/quad-types-plan.md` 신규 — 전체 설계/함정/실측 근거. +- `.claude/base/typing-limits.md` — §6(신규, type function 이력 오염) + 추가 + 6/7/8 재번호(→7/8/9) + 체크리스트 항목 추가. +- `.claude/base/module-lifecycle-plan.md` — `_initializedBy` 결정 반영. +- `.claude/base/architecture.md`/`.claude/base/project-setup-plan.md`/ + `.claude/README.md`/`luau-test/README.md`/`STATUS.md` — 관련 갱신. + +## 7. 다음 + +`quad-roblox`의 `QuadRoblox`/`CheckedQuad` 실사용은 M5. 심볼릭 링크 +로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로 +대화는 계속 한국어로 진행 중. diff --git a/pesde.lock b/pesde.lock index b3d815c..27dcbab 100644 --- a/pesde.lock +++ b/pesde.lock @@ -10,3 +10,6 @@ roblox = "quad-base" [workspace."qwreey/quad_roblox"] roblox = "quad-roblox" + +[workspace."qwreey/quad_types"] +roblox = "quad-types" diff --git a/pesde.toml b/pesde.toml index 259d81d..632c07d 100644 --- a/pesde.toml +++ b/pesde.toml @@ -7,7 +7,7 @@ name = "qwreey/quad" version = "0.0.0" private = true -workspace_members = ["quad-base", "quad-roblox"] +workspace_members = ["quad-base", "quad-roblox", "quad-types"] [target] environment = "roblox" diff --git a/quad-base/pesde.lock b/quad-base/pesde.lock index d1ac3d6..b487a54 100644 --- a/quad-base/pesde.lock +++ b/quad-base/pesde.lock @@ -4,3 +4,10 @@ format = 2 name = "qwreey/quad_base" version = "0.0.0" target = "roblox" + +[graph."qwreey/quad_types@0.0.0 roblox"] +direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"] + +[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref] +ref_ty = "workspace" +path = "quad-types" diff --git a/quad-base/pesde.toml b/quad-base/pesde.toml index ccfce6e..a2bf094 100644 --- a/quad-base/pesde.toml +++ b/quad-base/pesde.toml @@ -7,3 +7,6 @@ includes = ["src/*"] environment = "roblox" build_files = ["src"] lib = "src/init.luau" + +[dependencies] +quad_types = { workspace = "qwreey/quad_types", version = "^" } diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau index 781d7d7..c3d60a9 100644 --- a/quad-base/src/init.luau +++ b/quad-base/src/init.luau @@ -2,27 +2,32 @@ quad-base 최상위 진입점. `.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md` "New()의 내부 구성 — InitXxx 팩토리 체이닝" / "RunInit — 함수 자체를 - 릴레이션 키로 쓰는 멱등 가드" 절 그대로. + 릴레이션 키로 쓰는 멱등 가드" / "AddPlugin" 절 그대로. `require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중 인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출. + + `Quad` 타입 자체는 여기서 선언하지 않고 `quad-types`(워크스페이스 + 패키지, 구현 없이 타입 계약만)에서 그대로 가져와 씀 — quad-roblox 같은 + 백엔드 패키지가 이 무거운 quad-base 전체 대신 `quad-types`만 의존할 수 + 있게 하기 위함(`.claude/base/project-setup-plan.md` "quad-types" 절). ]] local Relate = require("@self/Relate") +local QuadTypes = require("./roblox_packages/quad_types") local InitDebug = require("@self/Debug") +type Quad = QuadTypes.Quad + -- (module, initFn) -> 이미 실행됐는가. New()가 몇 번 불려도 module마다 -- weak-키잉되므로 별도 정리 불필요(module이 GC되면 이 기록도 같이 사라짐). local runInitRelate = Relate() -type Quad = { - New: () -> Quad, - RunInit: (self: Quad, initFn: (Quad) -> any) -> (), - debug: boolean, -} - local function New(): Quad - local module = { New = New } :: Quad + local module = { + New = New, + Version = "0.0.0", + } :: Quad function module.RunInit(self, initFn) if runInitRelate:GetStrong(self, initFn) then @@ -32,6 +37,14 @@ local function New(): Quad initFn(self) end + function module.AddPlugin(self, pluginFn) + local extension = pluginFn(self) + for k, v in extension :: any do + (self :: any)[k] = v + end + return self :: any + end + module:RunInit(InitDebug) -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가 diff --git a/quad-base/test/smoke.plugin.luau b/quad-base/test/smoke.plugin.luau new file mode 100644 index 0000000..aad2e8a --- /dev/null +++ b/quad-base/test/smoke.plugin.luau @@ -0,0 +1,55 @@ +--[[ 임시 스모크 테스트 — Quad.Version/AddPlugin이 실제로 동작하는지 확인 ]] + +local Quad = require("../src") + +print("=== 1. Version 필드가 pesde.toml과 맞음 ===") +assert(Quad.Version == "0.0.0", "quad-base/pesde.toml의 version과 일치해야 함") +print("PASS") + +print() +print("=== 2. AddPlugin이 원본 identity를 유지한 채(mutate) 확장 필드를 추가함 ===") +do + local function springPlugin(_self) + return { + Spring = function(_selfArg, v) + return v * 2 + end, + } + end + + local withSpring = Quad:AddPlugin(springPlugin) + assert(withSpring == Quad, "AddPlugin은 원본 module을 그대로 mutate+반환해야 함(RunInit 추적이 identity에 의존)") + assert(withSpring:Spring(21) == 42, "확장된 메소드가 실제로 동작해야 함") + assert(withSpring.debug == false, "원본 필드는 그대로 남아있어야 함") + print("PASS") +end + +print() +print("=== 3. 체이닝 — 여러 플러그인을 연속으로 추가해도 전부 누적됨 ===") +do + local m = Quad.New() + + local function pluginA(_self) + return { + A = function(_selfArg) + return "a" + end, + } + end + local function pluginB(_self) + return { + B = function(_selfArg) + return "b" + end, + } + end + + local withBoth = m:AddPlugin(pluginA):AddPlugin(pluginB) + assert(withBoth:A() == "a", "pluginA의 확장 메소드가 동작해야 함") + assert(withBoth:B() == "b", "pluginB의 확장 메소드가 동작해야 함") + assert(withBoth.debug == false, "원본 필드는 체이닝 후에도 남아있어야 함") + print("PASS") +end + +print() +print("=== ALL PASS ===") diff --git a/quad-roblox/pesde.lock b/quad-roblox/pesde.lock index 0bb744c..a73858e 100644 --- a/quad-roblox/pesde.lock +++ b/quad-roblox/pesde.lock @@ -5,9 +5,9 @@ name = "qwreey/quad_roblox" version = "0.0.0" target = "roblox" -[graph."qwreey/quad_base@0.0.0 roblox"] -direct = ["quad_base", { workspace = "qwreey/quad_base", version = "^" }, "standard"] +[graph."qwreey/quad_types@0.0.0 roblox"] +direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"] -[graph."qwreey/quad_base@0.0.0 roblox".pkg_ref] +[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref] ref_ty = "workspace" -path = "quad-base" +path = "quad-types" diff --git a/quad-roblox/pesde.toml b/quad-roblox/pesde.toml index e491418..8b53a8a 100644 --- a/quad-roblox/pesde.toml +++ b/quad-roblox/pesde.toml @@ -9,4 +9,4 @@ build_files = ["src"] lib = "src/init.luau" [dependencies] -quad_base = { workspace = "qwreey/quad_base", version = "^" } +quad_types = { workspace = "qwreey/quad_types", version = "^" } diff --git a/quad-types/pesde.lock b/quad-types/pesde.lock new file mode 100644 index 0000000..170397c --- /dev/null +++ b/quad-types/pesde.lock @@ -0,0 +1,6 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "qwreey/quad_types" +version = "0.0.0" +target = "roblox" diff --git a/quad-types/pesde.toml b/quad-types/pesde.toml new file mode 100644 index 0000000..788ede4 --- /dev/null +++ b/quad-types/pesde.toml @@ -0,0 +1,9 @@ +name = "qwreey/quad_types" +version = "0.0.0" +description = "quad — Quad 공개 타입 계약(구현 없음) + 버전 호환성 type function. quad-base가 구현하고, quad-roblox 등 백엔드/플러그인 패키지는 무거운 quad-base 대신 이것만 의존" +includes = ["src/*"] + +[target] +environment = "roblox" +build_files = ["src"] +lib = "src/init.luau" diff --git a/quad-types/selene.toml b/quad-types/selene.toml new file mode 100644 index 0000000..82f16cf --- /dev/null +++ b/quad-types/selene.toml @@ -0,0 +1,31 @@ +std = "luau" +exclude = ["**/.pesde/*", "*_packages/*"] + +[lints] +almost_swapped = "deny" +constant_table_comparison = "deny" +deprecated = "warn" +divide_by_zero = "warn" +empty_if = "deny" +empty_loop = "deny" +high_cyclomatic_complexity = "warn" +if_same_then_else = "deny" +ifs_same_cond = "warn" +incorrect_standard_library_use = "deny" +manual_table_clone = "warn" +mismatched_arg_count = "deny" +mixed_table = "allow" +multiple_statements = "deny" +must_use = "warn" +parenthese_conditions = "deny" +suspicious_reverse_loop = "deny" +type_check_inside_call = "deny" +unbalanced_assignments = "warn" +undefined_variable = "deny" +unscoped_variables = "deny" +unused_variable = "warn" + +[config] +empty_if = { comments_count = true } +empty_loop = { comments_count = true } +multiple_statements = { one_line_if = "break-return-only" } diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau new file mode 100644 index 0000000..6f60241 --- /dev/null +++ b/quad-types/src/init.luau @@ -0,0 +1,80 @@ +--!strict +--[[ + `Quad` 공개 타입 계약 — 구현 없음, 런타임 값은 없다시피 함(빈 테이블). + `quad-base`가 이 타입을 구현하고, `quad-roblox`/향후 백엔드·플러그인 + 패키지는 무거운 `quad-base` 전체 대신 이 패키지만 의존한다. + + **왜 별도 패키지인가**: `QuadRoblox(Quad): QuadRoblox`처럼 quad-base + 인스턴스를 **런타임에 주입받는** 패키지는, quad-base를 pesde + `[dependencies]`로 선언할 필요가 없다 — 실제 값은 호출자가 넘겨준다. + 그런데 `dev_dependencies`로만 선언하면, quad-roblox가 게시된 뒤 다른 + 사람이 그 패키지를 설치할 땐 dev dependency가 전파되지 않아 그 + require(타입만 쓰려는 목적이어도 런타임에 실행됨, 실측 확인됨)가 그 + 자리에서 못 찾고 크래시한다. 그래서 "항상 안전하게 실 의존성으로 둘 수 + 있을 만큼 작은 것"이 따로 필요하고, 그게 이 패키지다. + (`.claude/base/project-setup-plan.md`/`session/2026-08-19-07-*.md` 참고) +]] + +export type Quad = { + Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 + debug: boolean, + New: () -> Quad, + RunInit: (self: Quad, initFn: (Quad) -> any) -> (), + AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P, +} + +--[[ + `CheckVersion` — 주입된 값의 실제 타입 `T`가 이 패키지가 아는 + `Quad.Version`과 일치하는지 컴파일 타임에 확인. + + **⚠️ 반드시 "가상 필드"로만 쓸 것 — `T`를 직접 패스스루하지 말 것.** + 처음엔 `return t`(단순 패스스루)로 짜서 단독으로는 잘 됐는데, + **`AddPlugin`처럼 제네릭 self 파라미터를 쓰는 메소드와 + 체이닝하면 조용히 깨짐**을 실측으로 확인함(2026-08-19) — 값의 정적 + 타입이 한 번이라도 `type function`을 거치면, 그 뒤 제네릭 self 추론이 + "Expected this to be exactly 'P & Self', but got 'P & Self'"류의 + 앞뒤가 같은 의미 없는 진단을 내며 깨진다(재구성이 아니라 **패스스루도 + 똑같이** 깨짐 — 재구성 비용 문제가 아니라 "그 타입이 type function을 + 거쳤다는 이력 자체"가 문제였음). 그래서 검증 결과를 **원본 타입과 + 절대 안 섞이는 별도 필드**로 격리해야 한다 — `CheckVersion` 자체는 + `T`를 절대 참조/반환하지 않고, 성공 시 트리비얼한 `true` 하나만 반환. + + **사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**: + ```lua + type Checked = T & { __versionCheck: CheckVersion } + + local function CheckQuad(quad: T): Checked + return quad :: any + end + + local checked = CheckQuad(injectedQuad) + local _ = checked.__versionCheck -- ⚠️ 이 줄이 없으면 체크가 조용히 스킵됨(lazy 평가) + -- 이후 checked는 injectedQuad와 완전히 같은 타입(원본 그대로) — + -- AddPlugin 체이닝 등 뒤이은 제네릭 연산이 전부 안전하게 동작함 + ``` + + **에러 표시 방식(실측 확인)**: 불일치 시 `error()`가 아니라 + `print("메시지")` + `return types.never`를 쓸 것 — `error()`는 "type + function 자체가 실패함"으로 판정돼 버려서 못 쓰고, `print`+`never` + 조합만 호출부에 정확히 "TypeError: <메시지>"로 뜬다. +]] +-- selene: allow(undefined_variable) — `types`는 `type function` 블록 안에서만 +-- 주입되는 특수 전역이라 selene의 luau std가 아직 이걸 모델링 못 함(실측 +-- 확인, 2026-08-19) — 실제 미정의 변수가 아니라 selene 쪽 오탐. +type function CheckVersion(t: type): type + local versionProp = t:readproperty(types.singleton("Version")) + if versionProp == nil then + print("quad-base injection is missing a Version field") + return types.never + end + local literal = versionProp:value() + if literal ~= "0.0.0" then + print(`quad-base version mismatch: this package expects "0.0.0", got "{tostring(literal)}"`) + return types.never + end + return types.singleton(true) +end + +export type CheckedQuad = T & { __versionCheck: CheckVersion } + +return {}