From 5dfc9b9a43dc1a379528bd13a1e7f916a720dfa3 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Wed, 19 Aug 2026 17:39:57 +0900 Subject: [PATCH] =?UTF-8?q?design:=20type-version-check=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EC=B6=94=EC=B6=9C=20=E2=80=94=20CheckedQu?= =?UTF-8?q?ad=20=EA=B8=80=EB=A1=AD/=EC=BA=90=EB=9F=BF=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quad-spring-roblox류 독립 게시 플러그인엔 정확 버전 일치가 과하다는 지적에 따라, 버전 패턴 매칭(글롭 "*"/캐럿 "N^")을 quad에 종속되지 않은 범용 워크스페이스 멤버 type-version-check로 분리하고 quad-types의 CheckedQuad를 CheckedQuad으로 확장. 새 Luau 함정 2건(type function의 outer local 참조 불가, cross-package엔 export type function + 이중 꺾쇠 제네릭 인스턴스화 필요) 발견·문서화. 독립 저장소 분리는 HUMAN_TODO로 위임. Co-authored-by: qwreey --- .claude/README.md | 4 +- .claude/base/architecture.md | 21 ++- .claude/base/project-setup-plan.md | 53 +++++-- .claude/base/quad-types-plan.md | 102 +++++++++++--- .claude/base/typing-limits.md | 21 +-- .claude/luau-test/README.md | 2 +- .claude/luau-test/STATUS.md | 9 +- ...type-quadtypes-checkversion-addplugin.luau | 40 +++--- ...8-type-version-check-package-extraction.md | 116 ++++++++++++++++ HUMAN_TODO.md | 11 ++ pesde.lock | 3 + pesde.toml | 2 +- quad-base/pesde.lock | 10 ++ quad-roblox/pesde.lock | 10 ++ quad-types/pesde.lock | 7 + quad-types/pesde.toml | 3 + quad-types/src/init.luau | 59 +++----- type-version-check/pesde.lock | 6 + type-version-check/pesde.toml | 8 ++ type-version-check/selene.toml | 31 +++++ type-version-check/src/init.luau | 130 ++++++++++++++++++ 21 files changed, 542 insertions(+), 106 deletions(-) create mode 100644 .claude/session/2026-08-19-08-type-version-check-package-extraction.md create mode 100644 type-version-check/pesde.lock create mode 100644 type-version-check/pesde.toml create mode 100644 type-version-check/selene.toml create mode 100644 type-version-check/src/init.luau diff --git a/.claude/README.md b/.claude/README.md index d20c410..c3b24ef 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -45,14 +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), §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` | +| `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가 런타임 크래시하는 문제를 원천 회피 | +| `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. 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 a72eb04..ae48a33 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -193,11 +193,19 @@ build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup 소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게 따라감). 실제 구현: 루트 `pesde.toml`(`private = true`, -`workspace_members = ["quad-base", "quad-roblox", "quad-types"]`) + +`workspace_members = ["quad-base", "quad-roblox", "quad-types", +"type-version-check"]`) + `quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml` -(각각 `[target] environment = "roblox"`), 툴체인은 `mise.toml`로 핀 +(각각 `[target] environment = "roblox"`) + `type-version-check/pesde.toml` +(`[target] environment = "luau"`, 아래 참고), 툴체인은 `mise.toml`로 핀 (`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는 -판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). +판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). **[2026-08-19 같은 +날 후속]** `type-version-check`(`[target] environment = "luau"` — quad에 +종속되지 않은 범용 패키지라 다른 멤버와 달리 roblox가 아님)는 워크스페이스 +네 번째 멤버로 추가됐고, `quad-types`가 이것에 workspace 의존(자기 target이 +roblox라 명시적으로 `target = "luau"` 지정 필요) — `base/quad-types-plan.md`의 +"`type-version-check`" 절이 소스. 사용자가 나중에 독립 저장소로 분리할 +예정(`HUMAN_TODO.md` 9번). **[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox`는 `quad-base`가 아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace 의존** — `quad-base`는 `QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임 @@ -243,9 +251,12 @@ quad/ ├── 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 +├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad 버전체크(`base/quad-types-plan.md`) +│ ├── pesde.toml # type_version_check workspace 의존(target="luau") │ └── src/init.luau +├── type-version-check/ # quad에 종속되지 않은 범용 버전 패턴 매칭(`base/quad-types-plan.md` "`type-version-check`" 절) — 사용자가 나중에 독립 저장소로 분리 예정(HUMAN_TODO 9번) +│ ├── pesde.toml # [target] environment = "luau" +│ └── src/init.luau # matchesPattern(런타임) + export type function CheckVersion ├── quad-base/ │ ├── pesde.toml │ └── src/ diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md index 7dda4b0..5c733ec 100644 --- a/.claude/base/project-setup-plan.md +++ b/.claude/base/project-setup-plan.md @@ -25,6 +25,13 @@ M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/ ## pesde 워크스페이스 구조 +**전체 멤버 목록·최신 트리는 `architecture.md`의 "구현 착수: 소스 트리 +구조 확정" 절이 소스** — 새 워크스페이스 멤버가 늘 때마다 그쪽만 갱신하면 +되게 하기 위해 여기서 다시 나열/개수 세지 않는다(멤버 수가 실제로 2→3→4로 +늘어난 이력에서 이 문서의 구식 트리가 갱신을 놓쳤던 게 계기). 아래는 그 +구조가 실제로 동작하는 **pesde 메커니즘 자체**(문법/함정)만 다룬다 — +예시엔 최소 2-멤버 형태만 남겨두고 서술 부담을 줄임: + ``` quad/ ├── pesde.toml # 워크스페이스 루트, private = true, workspace_members @@ -37,8 +44,8 @@ quad/ └── selene.toml ``` -- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members = - ["quad-base", "quad-roblox"]`. `[target] environment = "roblox"`도 +- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members` + (멤버 목록은 `architecture.md`가 소스). `[target] environment = "roblox"`도 필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이 세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후 재확인 후보). @@ -59,9 +66,18 @@ quad/ "package not found"로 실패한다. `[dependencies]`의 `workspace = "scope/name"` 줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`이 받아들이는 걸 확인함). -- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 전체가 - 갱신됨(`qwreey/quad`/`qwreey/quad_base`/`qwreey/quad_roblox` 셋 다 - 스캔·링크). +- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 **모든 + 워크스페이스 멤버**가 스캔·링크됨(개수는 `architecture.md`의 + `workspace_members`가 소스 — 새 멤버가 늘어도 이 동작은 안 바뀜). +- **[2026-08-19 후속, `type-version-check` 신설 때 실측] 의존하는 + 워크스페이스 멤버의 `target`이 자기 자신의 기본 target과 다르면 + `workspace = "..."` 의존 선언에 `target = "..."`를 명시해야 한다.** + `quad-types`(기본 target `roblox`)가 `type-version-check`(자기 + `[target] environment = "luau"`)에 의존할 때, `target` 없이 `{ + workspace = "qwreey/type_version_check", version = "^" }`만 쓰면 + `pesde install`이 `no workspace member found with name + qwreey/type_version_check and target roblox`로 실패한다 — `target = + "luau"`를 추가해야 해소됨. ## 툴체인 — `rokit.toml`에서 `mise.toml`로 전환 (2026-08-19) @@ -231,6 +247,22 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했 `.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — 그때 다시 볼 것). +**[2026-08-19 같은 날 넷째 후속 세션] 의존 대상의 `target`에 따라 링크 +디렉토리 이름이 달라진다** — `quad-types`(target `roblox`)가 +`type-version-check`(target `luau`)에 의존하면, `quad-base`/`quad-roblox`가 +쓰는 `roblox_packages/`와 달리 `quad-types/src/init.luau`는 +`require("./luau_packages/type_version_check")`로 **`luau_packages/`** +아래에서 링크를 찾는다 — pesde가 의존 대상 패키지 자신의 target 이름으로 +디렉토리를 분리하기 때문(`.gitignore`에 `luau_packages/`도 이미 +포함돼 있어 별도 조치 불필요). + +같은 워크어라운드가 2단 의존 체인에도 그대로 재적용됨 — `type-version-check` 신설로 +`quad-types`→`type-version-check`가 추가되면서 `quad-base`/`quad-roblox`→ +`quad-types`→`type-version-check`처럼 깊이 2인 워크스페이스 의존 그래프가 +생겼는데, `pesde install` 후 같은 심볼릭 링크 치환을 반복 적용하는 것만으로 +`luau-analyze`/`luau` 양쪽 다 문제없이 동작 확인됨 — 이 우회가 단일 +깊이에 국한되지 않고 일반화됨이 실측으로 재확인됨. + ## `.luaurc` — alias는 여전히 편집기 전용 `.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임 @@ -275,8 +307,10 @@ require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇 **[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱 하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`이 **워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트 -`pesde.lock` 1개 + `quad-base/pesde.lock` + `quad-roblox/pesde.lock`, -총 3개). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만 +`pesde.lock` 1개 + 멤버마다 1개씩, 개수는 `architecture.md`의 +`workspace_members`를 따라간다 — 2026-08-19 이 세션 안에서만 2개 +멤버(2+1개 lock)에서 4개 멤버(4+1개 lock)로 늘어난 전례가 있어 여기 숫자를 +고정하지 않는다). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만 담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의 lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty = "workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만). @@ -295,7 +329,10 @@ lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `privat ## 확인 완료 / 아직 확인 안 된 것 **확인 완료(이 세션, 실제 pesde/luau 실행 근거)**: -- pesde 워크스페이스 설치가 3개 패키지(루트+2서브) 전부에 대해 성공 +- pesde 워크스페이스 설치가 당시 워크스페이스 멤버 전부(그때는 + 루트+2서브)에 대해 성공(**[2026-08-19 후속]** 이후 `quad-types`/ + `type-version-check` 추가로 멤버가 늘어난 뒤에도 같은 절차로 계속 성공 — + 아래 후속 문단들 참고) - 패키지 이름 문자 제약(하이픈 금지) - `workspace = "scope/name"` 의존성 선언 문법 - `@self`가 `init.luau`의 형제 파일 접근에 필수라는 것(런타임+ diff --git a/.claude/base/quad-types-plan.md b/.claude/base/quad-types-plan.md index 8f9513c..12c2bc1 100644 --- a/.claude/base/quad-types-plan.md +++ b/.claude/base/quad-types-plan.md @@ -2,7 +2,11 @@ **상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한 -사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. +사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. **[같은 날 후속]** +버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 +`type-version-check`(워크스페이스 네 번째 멤버)로 분리됐고, `CheckedQuad`는 +`CheckedQuad`으로 확장돼 그 위에 얹힌다 — 아래 "`type-version-check`" +절. ## 왜 필요한가 — dev-dependency로는 못 푸는 문제 @@ -44,8 +48,12 @@ pesde의 워크스페이스 의존성은 **패키지 단위**로만 걸린다 ``` quad-types/ -├── pesde.toml # name = "qwreey/quad_types" -└── src/init.luau # export type Quad, type function CheckVersion, export type CheckedQuad +├── pesde.toml # name = "qwreey/quad_types", type_version_check workspace 의존 +└── src/init.luau # export type Quad, export type CheckedQuad + +type-version-check/ # 워크스페이스 네 번째 멤버, quad에 종속되지 않음 +├── pesde.toml # name = "qwreey/type_version_check", environment = "luau" +└── src/init.luau # matchesPattern(런타임), export type function CheckVersion ``` - `quad-base`는 `quad_types`에 workspace 의존 — **자기 `Quad` 타입을 @@ -99,23 +107,73 @@ AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절), `AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다. -## `CheckedQuad` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 +## `type-version-check` — 범용 버전 패턴 매칭 패키지 + +**[2026-08-19 신설]** 처음엔 `CheckVersion`가 정확 일치(`"0.0.0"`)만 +보는 quad-types 내부 함수였다. 그런데 정확 일치는 `quad-spring`/ +`quad-spring-roblox`처럼 **독립적으로 게시되는 백엔드 플러그인** 쌍엔 너무 +빡빡하다 — 최신 `quad-spring-roblox`가 예전 `quad-spring`도 잘 다루는 +경우가 흔할 텐데, 정확 일치를 강제하면 그때마다 재게시가 필요해진다 +(**사용자 판단**: "구현해주는것 정말 쉽고... 있으면 좋다고 생각함"). +그래서 글롭/캐럿 패턴을 지원하는 별도 패키지로 뺐다 — quad 전용 이름을 +안 섞어서 quad-spring류가 quad-base 전체를 끌고 올 필요 없이 이것만 +가볍게 의존하게 하기 위함이기도 하다. + +**[2026-08-19] 지금은 quad 모노레포 워크스페이스의 네 번째 멤버로 두지만, +사용자가 나중에 독립 저장소로 직접 분리할 예정** — `HUMAN_TODO.md` 참고. + +**패턴 문법**(`.`로 나뉜 각 자리): `"*"` = 와일드카드, `"N^"` = 그 자리 +숫자값이 N **이상**이면 통과(caret), 그 외 = 정확히 같은 문자열이어야 +통과. 예: `"3.*.*"`(메이저만 고정), `"3.3^.4^"`(마이너 3 이상 + 패치 +4 이상), `"0.0.0"`(정확 일치 — quad-types가 지금 쓰는 패턴). + +```lua +export type function CheckVersion(actual: type, pattern: type): type +``` + +`actual`/`pattern` 둘 다 문자열 리터럴(singleton) 타입이어야 하고, 일치하면 +트리비얼한 `true`(`types.singleton(true)`) 하나만 반환 — `quad-types` +"함정 3"과 같은 이유로 원본 타입을 절대 반환하지 않는다. + +**Luau 신규 실측 함정 2건**(이 세션에 처음 발견, `typing-limits.md`가 +다루는 "타입 시스템 해석 한계"와는 결이 달라 여기 기록): +- **`type function`은 같은 파일의 바깥 스코프 로컬 함수를 아예 참조 못 + 한다** — `Type function cannot reference outer local 'X'`로 컴파일 + 자체가 실패. 그래서 런타임용 `matchesPattern`과 `CheckVersion` 내부의 + 매칭 로직은 **물리적으로 별개 함수로 중복**돼 있다 + (`type-version-check/src/init.luau`) — 하나를 고치면 반드시 다른 + 하나도 같이 고칠 것. +- **cross-package 사용엔 `export type function`이 필요**하다(`type + function`만으론 안 됨) — 안 그러면 다른 파일에서 `Unknown type + 'Module.CheckVersion'`으로 막힌다. 그리고 명시적 제네릭 인스턴스화가 + **2개 이상**이면 단일 꺾쇠(`Foo`)가 비교 연산자로 오파싱되니 + 반드시 이중 꺾쇠(`Foo<>`)를 써야 한다(코퍼스에 이미 있던 + `AttributeKey<>` 관례와 같은 이유). + +`Version` 필드는 Luau 내장 `index` type function으로 뽑는다 +(수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인 +완료). + +## `CheckedQuad` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 **왜 필요한가**: `quad-roblox`가 `quad_base`를 pesde `[dependencies]`로 선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌 방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥 -함수 인자라서. `CheckedQuad`가 그 빈자리를 메꾸는 컴파일 타임 +함수 인자라서. `CheckedQuad`이 그 빈자리를 메꾸는 컴파일 타임 대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox 소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 -충분"). +충분"). `Pattern`은 위 `type-version-check`의 글롭/캐럿 패턴 문자열 — +quad-base/quad-roblox처럼 같은 모노레포에서 항상 같이 개발되는 관계는 +정확 일치(`"0.0.0"`)를, quad-spring-roblox류 독립 게시 플러그인은 +`"0.*.*"` 같은 느슨한 패턴을 직접 골라 쓴다. ```lua -export type CheckedQuad = T & { __versionCheck: CheckVersion } +export type CheckedQuad = T & { __versionCheck: TypeVersionCheck.CheckVersion, Pattern> } ``` **사용법**: ```lua -local function CheckQuad(quad: T): QuadTypes.CheckedQuad +local function CheckQuad(quad: T): QuadTypes.CheckedQuad return quad :: any end @@ -141,8 +199,8 @@ local function QuadRoblox(quad: T): T end ``` 체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에 -박아 넣어야 한다. 이게 `CheckedQuad`가 함수 파라미터/반환 타입 -표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 +박아 넣어야 한다. 이게 `CheckedQuad`이 함수 파라미터/반환 +타입 표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ = checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다. @@ -174,20 +232,30 @@ function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 ## 실측 근거 `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` — -실제 `quad-types`/`quad-base`를 `require`해서 위 사용법 그대로 -재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 확장 필드 -유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 -`TypeError: quad-base version mismatch: ...` 하나만. +실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 위 +사용법 그대로 재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 +확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 +`TypeError: type-version-check: version "9.9.9" does not match pattern +"0.0.0"` 하나만. `quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate + identity 보존 + 체이닝)을 실행 레벨로 검증. ## 남은 것 -- `quad-roblox`가 실제로 `CheckedQuad`를 쓰는 진입점(`QuadRoblox` 등) - 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 - 예제가 실제 위치는 아직 없음. +- `quad-roblox`가 실제로 `CheckedQuad`을 쓰는 진입점 + (`QuadRoblox` 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 + 문서의 사용법 예제가 실제 위치는 아직 없음. - `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`는 **버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개 문제로 계속 `_initializedBy`가 담당한다. +- **[백로그, 2026-08-19 신설]** `quad-roblox-types`(가칭) — `quad-types`와 + 같은 패턴으로, `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한 + 패키지. **사용자가 지금 만들 필요는 없다고 명시적으로 후순위 지정** — + 다만 이후 쉽게 뽑을 수 있게 `quad-roblox`의 공개 타입은 지금부터 단일 + `src/init.luau`(또는 `types.luau`) 형태로 몰아두는 걸 관례로 유지할 것 + (quad-types 자신이 이미 이 형태 — `Quad`/`CheckedQuad` 둘 다 + `src/init.luau` 하나에 있음). +- **[HUMAN_TODO]** `type-version-check`는 사용자가 나중에 독립 저장소로 + 직접 분리할 예정 — 루트 `HUMAN_TODO.md` 참고. diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index c21573b..f087cfd 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -399,26 +399,29 @@ local extended = checked:AddPlugin(somePlugin) -- 여기서 깨짐: ```lua -- ✅ 원본 T는 type function을 한 번도 안 거침 -type CheckedQuad = T & { __versionCheck: CheckVersion } +type CheckedQuad = T & { __versionCheck: CheckVersion } -local checked: CheckedQuad = ... +local checked: CheckedQuad = ... local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고) local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함 ``` -`quad-types-plan.md`의 "`CheckedQuad`" 절에 전체 배선과 실측 과정이 -있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 본문 -로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 +`quad-types-plan.md`의 "`CheckedQuad`" 절에 전체 배선과 실측 +과정이 있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 +본문 로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로 -격리, 세 가지가 함께 필요합니다. +격리, 세 가지가 함께 필요합니다. **[2026-08-19 후속]** 실제 버전 매칭 +로직(`CheckVersion`)은 quad에 종속되지 않은 별도 패키지 +`type-version-check`로 분리됐지만, 이 항목이 다루는 "패스스루도 이력만으로 +오염된다" 함정과 그 회피(별도 가상 필드 격리)는 그대로 유효합니다. ### 언제 마주치는가 `AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에, `type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는 -지금 `quad-types`의 `CheckedQuad`가 유일한 실사례지만, 앞으로 비슷한 -"타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 수 -있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가. +지금 `quad-types`의 `CheckedQuad`이 유일한 실사례지만, 앞으로 +비슷한 "타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 +수 있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가. --- diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 5afe4fc..66b6141 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -89,7 +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 | +| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index ef8a521..36502cc 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,8 +1,9 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` -> 버전 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → `done/` -> 직행. 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 +> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` +> 버전 패턴 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → +> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성. +> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 > 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md` > §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리 > (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임 @@ -124,7 +125,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으로 승격) — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | +| `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check`로 `CheckedQuad`+`AddPlugin` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | ### 특별히 중요한 통과 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 index 31bff5d..9677551 100644 --- a/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau +++ b/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau @@ -1,16 +1,16 @@ --!strict --[[ - 검증 대상(타입 전용): `quad-types`의 `CheckedQuad`(버전 체크 - type function)가 실제 `quad-base`가 구현하는 `Quad` 타입과 맞물려 - 동작하는지 — 그리고 검증 후에도 `AddPlugin`의 제네릭 체이닝이 - 안 깨지는지. + 검증 대상(타입 전용): `quad-types`의 `CheckedQuad`(버전 + 패턴 체크, `type-version-check` 패키지 위에 얹힘)가 실제 `quad-base`가 + 구현하는 `Quad` 타입과 맞물려 동작하는지 — 그리고 검증 후에도 + `AddPlugin`의 제네릭 체이닝이 안 깨지는지. 배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad): QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서, pesde의 semver 충돌 방지가 이 관계엔 안 걸리게 됨(선언된 의존성이 아니라 그냥 함수 인자라서) — 그 빈 자리를 메꾸는 컴파일 타임 버전 - 체크. `quad-types/src/init.luau`의 `CheckVersion`/`CheckedQuad` 절 - 참고. + 체크. `quad-types/src/init.luau`의 `CheckedQuad` 절, + `type-version-check/src/init.luau`의 `CheckVersion` 절 참고. **이 파일이 재현하는 핵심 함정(처음 시도했다가 깨진 것들)**: 1. `error()`는 못 씀 — type function 자체가 실패한 걸로 판정돼서 @@ -20,15 +20,20 @@ CheckVersion`) 제네릭이 인스턴스화될 때마다 재평가되지 않아 진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로 해석돼야 하는 자리**에 박아 넣어야 함. - 3. **[가장 중요, 가장 늦게 발견]** `CheckVersion`가 `T`를 단순 - 패스스루(`return t`)해도, 그 결과 타입이 **한 번이라도 type - function을 거쳤다는 이력만으로** 이후 `AddPlugin` 같은 - 제네릭 self 메소드 체이닝이 조용히 깨짐("Expected this to be - exactly 'P & Self', but got 'P & Self'"류의 앞뒤가 같은 의미 없는 - 진단). 그래서 검증 결과는 **원본 타입과 절대 안 섞이는 별도 - 가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를 실제로 - 참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck` 줄이 - 그 필수 스텝을 보여줌. + 3. **[가장 중요]** `CheckVersion`가 `T`를 단순 패스스루(`return + t`)해도, 그 결과 타입이 **한 번이라도 type function을 거쳤다는 + 이력만으로** 이후 `AddPlugin` 같은 제네릭 self 메소드 + 체이닝이 조용히 깨짐. 검증 결과는 **원본 타입과 절대 안 섞이는 + 별도 가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를 + 실제로 참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck` + 줄이 그 필수 스텝을 보여줌. + 4. **[신규]** cross-package 사용에는 `type function` 선언에 `export`가 + 필요하다(`type function`이 아니라 `export type function`) — 안 + 그러면 다른 파일에서 `Unknown type 'Module.CheckVersion'`으로 + 막힘. 그리고 명시적 제네릭 인스턴스화가 **2개 이상**이면 단일 + 꺾쇠(`Foo`)가 비교 연산자로 오파싱돼 반드시 이중 꺾쇠 + (`Foo<>`)를 써야 한다(코퍼스에 이미 있던 `AttributeKey<>` + 관례와 같은 이유). 실행: `luau-analyze 23-type-quadtypes-checkversion-addplugin.luau` ]] @@ -38,8 +43,9 @@ local QuadTypes = require("../../../quad-types/src") type RobloxExt = { Frame: (self: any) -> string } -- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번 --- 함정에 걸림) -local function CheckQuad(quad: T): QuadTypes.CheckedQuad +-- 함정에 걸림). 여기선 quad-base와 정확히 같은 버전만 허용("0.0.0" 그대로) — +-- quad-spring-roblox류 느슨한 소비자는 "0.*.*" 같은 패턴을 대신 씀. +local function CheckQuad(quad: T): QuadTypes.CheckedQuad return quad :: any end diff --git a/.claude/session/2026-08-19-08-type-version-check-package-extraction.md b/.claude/session/2026-08-19-08-type-version-check-package-extraction.md new file mode 100644 index 0000000..88838ef --- /dev/null +++ b/.claude/session/2026-08-19-08-type-version-check-package-extraction.md @@ -0,0 +1,116 @@ +# 2026-08-19, 여덟 번째 세션 — `type-version-check` 패키지 추출, `CheckedQuad` 확장 + +**요약**: 직전(일곱 번째) 세션이 만든 `CheckedQuad`(정확 버전 일치만 +지원)가 `quad-spring`/`quad-spring-roblox`류 독립 게시 플러그인엔 너무 +빡빡하다는 사용자 지적으로 시작. 글롭(`"*"`)/캐럿(`"N^"`) 패턴 매칭을 +지원하도록 `CheckedQuad`으로 확장하고, 그 매칭 로직 자체는 +quad에 종속되지 않은 범용 워크스페이스 패키지 `type-version-check`로 +분리·구현·검증까지 완료. 사용자 지시대로 지금은 quad 모노레포 안에 +두고, 독립 저장소 분리는 `HUMAN_TODO.md`에 남김. + +## 1. 문제 제기 — 정확 일치는 독립 게시 플러그인엔 과함 + +사용자 발언 요지: "quad-spring/quad-spring-roblox 같은건 버전 확인이 +exactly 할 필요는 없다고 봄" — 최신 `quad-spring-roblox`가 과거 +`quad-spring` 버전도 잘 다룰 가능성이 높으므로, 글롭(`"3.*.*"`)이나 +캐럿(`"3.3^.4^"`, 마이너 3 이상 + 패치 4 이상) 패턴을 지원하는 게 +"구현하기 정말 쉽고... 있으면 좋다"는 판단. 같이 제안된 것: (1) 미래 +`quad-roblox-types` 패키지(지금 만들 필요는 없음, 다만 `quad-roblox`의 +공개 타입을 지금부터 단일 파일에 몰아둬서 나중에 쉽게 분리되게만 +준비), (2) 버전 체크 패턴 자체를 `qwreey/type-version-check`로 독립 +추출(다른 프로젝트에도 쓸 수 있고, quad-spring-roblox류가 이것 때문에 +quad-base 전체를 끌고 올 필요가 없어짐), (3) Luau 내장 `index<>` type +function으로 `Version` 필드를 뽑는 게 수동 `readproperty`보다 나아 +보인다는 제안. + +**사용자 명시적 지시**: "우선 이 프로젝트 안에 넣어둬줘. 나중에 내가 +다른 프로젝트로 분리해줄게. Human todo 로 남기면 될듯 함." + +## 2. `type-version-check` 패키지 구현 + +새 워크스페이스 멤버(`[target] environment = "luau"` — quad에 종속되지 +않아 다른 멤버와 달리 roblox가 아님). 핵심: + +- 런타임 유틸 `matchesPattern(actual, pattern)` — `.`로 나눈 각 자리를 + `"*"`(와일드카드) / `"N^"`(그 자리 숫자값이 N 이상이면 통과) / 그 외 + 정확 일치로 비교. +- `export type function CheckVersion(actual: type, pattern: type): type` — + `actual`/`pattern`을 문자열 리터럴로 검증 후 같은 매칭 로직을 타입 + 레벨에서 재현, 성공 시 `types.singleton(true)`만 반환(원본 타입 + 패스스루 금지 — 직전 세션이 확정한 함정 회피 원칙 그대로 유지). + +**새로 발견한 Luau 함정 2건** (이전 세션들의 `typing-limits.md` §6과는 +다른 결의 순수 문법 제약): +1. `type function`은 같은 파일의 바깥 스코프 로컬 함수를 못 + 참조한다(`Type function cannot reference outer local 'X'`) — + `matchesPattern`과 `CheckVersion` 내부의 매칭 로직은 물리적으로 + 중복된 별개 함수로 유지해야 함. +2. cross-package 사용엔 `type function`이 아니라 `export type + function`이 필요(안 그러면 `Unknown type 'Module.X'`), 그리고 명시적 + 제네릭 인스턴스화가 2개 이상이면 단일 꺾쇠가 비교 연산자로 + 오파싱되므로 이중 꺾쇠(`Foo<>`)가 필요(코퍼스의 기존 + `AttributeKey<>` 관례와 동일 이유). + +`index` 내장 type function으로 `Version` 필드 추출 — +사용자 제안대로 채택, 단독 스파이크와 `CheckVersion`에 실제로 물려서 +둘 다 실측 확인. + +selene `shadowing` 경고(중첩 스코프의 `actualValue` 재선언, 두 곳)를 +안쪽 변수를 `actualNumber`로 리네임해 해소. + +## 3. `quad-types` 쪽 배선 + +`quad-types/pesde.toml`에 `type_version_check = { workspace = +"qwreey/type_version_check", version = "^", target = "luau" }` 추가 — +`target` 없이는 `pesde install`이 "no workspace member found with name +qwreey/type_version_check and target roblox"로 실패(quad-types 자신의 +기본 target이 roblox라 명시적 target 지정이 필요, `quad-types`↔`type- +version-check`가 서로 다른 target을 가진 첫 워크스페이스 의존 관계라서 +이번에 처음 실측됨). + +`CheckedQuad = T & { __versionCheck: CheckVersion }` → +`CheckedQuad = T & { __versionCheck: +TypeVersionCheck.CheckVersion, Pattern> }`로 확장. + +심볼릭 링크 로컬 CLI 우회(`project-setup-plan.md`가 이미 문서화한 +워크어라운드)를 2단 깊이 의존 그래프(`quad-base`/`quad-roblox` → +`quad-types` → `type-version-check`)에 재적용 — 문제없이 일반화됨을 +확인. + +## 4. 검증 + +전 패키지(`quad-base`/`quad-roblox`/`quad-types`/`type-version-check`) +`luau-analyze`/`luau`/`selene` 전부 클린. 스파이크 +`23-type-quadtypes-checkversion-addplugin.luau`를 새 시그니처로 +재작성해 재검증 — 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝) 클린, +음성 경로(버전 불일치)는 정확히 `TypeError: type-version-check: version +"9.9.9" does not match pattern "0.0.0"` 하나만 발생. + +## 5. 문서 반영 + +`base/quad-types-plan.md`(`type-version-check` 절 신설 + `CheckedQuad` 섹션 갱신 + 남은 것에 `quad-roblox-types` 백로그/HUMAN_TODO +포인터 추가), `base/architecture.md`(소스 트리에 `type-version-check/` +추가, 패키징 방식 문단 갱신), `base/project-setup-plan.md`(cross-target +워크스페이스 의존 함정 + 2단 심볼릭 링크 우회 재확인), `base/typing- +limits.md`(§6 예시 코드를 `CheckedQuad`으로 갱신 + 절 인용 +수정), `.claude/README.md`/`luau-test/README.md`/`luau-test/STATUS.md` +(스파이크 23 설명 갱신), 루트 `HUMAN_TODO.md`(9번 — `type-version-check` +독립 저장소 분리는 사용자 몫). + +## 감사 루프 (2라운드, 핸드오버 체크리스트대로) + +1라운드는 `quad-types-plan.md`/`type-version-check` 자신의 파일/주석에 +남아있던 구 `CheckedQuad`(콤마 없는 단일 파라미터) 잔존 4건과 +`architecture.md`의 `pesde.toml` 나열 누락, `luau-test/STATUS.md` 배너 +stale 1건을 찾아 전부 수정. 2라운드(각도를 인덱스 레이어/교차 참조로 +전환)는 `project-setup-plan.md`의 옛 2-멤버 트리 다이어그램과 "3개 +패키지"/"총 3개" 개수 하드코딩(멤버가 2→4로 늘어난 걸 못 따라간 자리 +2곳)을 찾아 `architecture.md`를 소스로 가리키게 일반화. 이후 3라운드는 +새 발견 0건 — 여기서 감사 루프 종료. + +## 다음에 확인할 것 + +없음 — 이 턴의 설계/구현/검증/문서화/2라운드 감사까지 전부 마무리. +`quad-roblox-types`는 사용자가 명시적으로 후순위 지정(지금 안 만듦), +`type-version-check` 독립 분리는 사용자 본인이 나중에 직접 진행. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 9943359..2711e58 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -161,6 +161,17 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리 없는지, (3) `git worktree list`에 다른 에이전트 워크트리가 없는지를 확인했습니다. 이 항목은 기록용으로만 남겨둡니다. +## 9. **[2026-08-19 신설, 안 막음]** `type-version-check` 독립 저장소로 분리 + +`type-version-check/`(컴파일 타임 버전 패턴 매칭 — 글롭/캐럿, `quad-types`의 +`CheckedQuad`이 이 위에 얹힘)는 quad에 종속되지 않은 범용 +유틸이라 **사용자가 직접 독립 저장소로 분리할 예정**("우선 이 프로젝트 +안에 넣어둬줘. 나중에 내가 다른 프로젝트로 분리해줄게.", 2026-08-19). +지금은 quad 워크스페이스의 네 번째 멤버(`workspace_members`)로만 있음 — +에이전트가 먼저 나서서 분리하지 말고 사용자가 하라고 할 때까지 대기. +설계/구현 상세는 `.claude/base/quad-types-plan.md`의 +"`type-version-check`" 절. + ## 3. `.claude/question.md`의 **나머지** 항목 검토 (급하지 않음) 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 diff --git a/pesde.lock b/pesde.lock index 27dcbab..9fd1356 100644 --- a/pesde.lock +++ b/pesde.lock @@ -13,3 +13,6 @@ roblox = "quad-roblox" [workspace."qwreey/quad_types"] roblox = "quad-types" + +[workspace."qwreey/type_version_check"] +luau = "type-version-check" diff --git a/pesde.toml b/pesde.toml index 632c07d..9881af9 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", "quad-types"] +workspace_members = ["quad-base", "quad-roblox", "quad-types", "type-version-check"] [target] environment = "roblox" diff --git a/quad-base/pesde.lock b/quad-base/pesde.lock index b487a54..002465a 100644 --- a/quad-base/pesde.lock +++ b/quad-base/pesde.lock @@ -8,6 +8,16 @@ 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".dependencies] +type_version_check = ["qwreey/type_version_check@0.0.0 luau", "standard"] + [graph."qwreey/quad_types@0.0.0 roblox".pkg_ref] ref_ty = "workspace" path = "quad-types" + +[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref.dependencies] +type_version_check = [{ workspace = "qwreey/type_version_check", version = "^", target = "luau" }, "standard"] + +[graph."qwreey/type_version_check@0.0.0 luau".pkg_ref] +ref_ty = "workspace" +path = "type-version-check" diff --git a/quad-roblox/pesde.lock b/quad-roblox/pesde.lock index a73858e..be910ac 100644 --- a/quad-roblox/pesde.lock +++ b/quad-roblox/pesde.lock @@ -8,6 +8,16 @@ 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".dependencies] +type_version_check = ["qwreey/type_version_check@0.0.0 luau", "standard"] + [graph."qwreey/quad_types@0.0.0 roblox".pkg_ref] ref_ty = "workspace" path = "quad-types" + +[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref.dependencies] +type_version_check = [{ workspace = "qwreey/type_version_check", version = "^", target = "luau" }, "standard"] + +[graph."qwreey/type_version_check@0.0.0 luau".pkg_ref] +ref_ty = "workspace" +path = "type-version-check" diff --git a/quad-types/pesde.lock b/quad-types/pesde.lock index 170397c..6d5e374 100644 --- a/quad-types/pesde.lock +++ b/quad-types/pesde.lock @@ -4,3 +4,10 @@ format = 2 name = "qwreey/quad_types" version = "0.0.0" target = "roblox" + +[graph."qwreey/type_version_check@0.0.0 luau"] +direct = ["type_version_check", { workspace = "qwreey/type_version_check", version = "^", target = "luau" }, "standard"] + +[graph."qwreey/type_version_check@0.0.0 luau".pkg_ref] +ref_ty = "workspace" +path = "type-version-check" diff --git a/quad-types/pesde.toml b/quad-types/pesde.toml index 788ede4..a29988d 100644 --- a/quad-types/pesde.toml +++ b/quad-types/pesde.toml @@ -7,3 +7,6 @@ includes = ["src/*"] environment = "roblox" build_files = ["src"] lib = "src/init.luau" + +[dependencies] +type_version_check = { workspace = "qwreey/type_version_check", version = "^", target = "luau" } diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 6f60241..706424a 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -15,6 +15,8 @@ (`.claude/base/project-setup-plan.md`/`session/2026-08-19-07-*.md` 참고) ]] +local TypeVersionCheck = require("./luau_packages/type_version_check") + export type Quad = { Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 debug: boolean, @@ -24,57 +26,30 @@ export type Quad = { } --[[ - `CheckVersion` — 주입된 값의 실제 타입 `T`가 이 패키지가 아는 - `Quad.Version`과 일치하는지 컴파일 타임에 확인. + `CheckedQuad` — 주입된 값의 실제 타입 `T`가 `Pattern` + (글롭/캐럿 문자열, `type-version-check` 패키지 참고 — `"*"`/`"N^"`/정확값)에 + 맞는 `Version`을 갖는지 컴파일 타임에 확인. quad-base/quad-roblox + 자신은 항상 같은 모노레포에서 같이 개발되므로 지금은 정확히 일치하는 + `"0.0.0"` 패턴으로만 쓰지만(아래 사용법), quad-spring-roblox류 + **독립적으로 게시되는 백엔드 플러그인**은 `"0.*.*"`처럼 느슨한 패턴을 + 직접 골라 쓸 수 있다 — 그래서 패턴을 이 타입의 파라미터로 열어둠. **⚠️ 반드시 "가상 필드"로만 쓸 것 — `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` 하나만 반환. + `TypeVersionCheck.CheckVersion` 자체가 이미 이 규칙을 + 지키게 설계돼 있다(트리비얼한 `true`만 반환, `T`를 절대 참조/재구성 + 안 함) — `type function`을 거친 값은 패스스루라도 이후 `AddPlugin` + 같은 제네릭 self 메소드 체이닝이 조용히 깨진다는 게 실측 확인됐기 + 때문(`typing-limits.md` §6, `type-version-check/src/init.luau` + 참고). **사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**: ```lua - type Checked = T & { __versionCheck: CheckVersion } - - local function CheckQuad(quad: T): Checked - return quad :: any - end - - local checked = CheckQuad(injectedQuad) + local checked: QuadTypes.CheckedQuad = injectedQuad :: any 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 } +export type CheckedQuad = T & { __versionCheck: TypeVersionCheck.CheckVersion, Pattern> } return {} diff --git a/type-version-check/pesde.lock b/type-version-check/pesde.lock new file mode 100644 index 0000000..bf157fe --- /dev/null +++ b/type-version-check/pesde.lock @@ -0,0 +1,6 @@ +# This file is automatically @generated by pesde. +# It is not intended for manual editing. +format = 2 +name = "qwreey/type_version_check" +version = "0.0.0" +target = "luau" diff --git a/type-version-check/pesde.toml b/type-version-check/pesde.toml new file mode 100644 index 0000000..06e543a --- /dev/null +++ b/type-version-check/pesde.toml @@ -0,0 +1,8 @@ +name = "qwreey/type_version_check" +version = "0.0.0" +description = "컴파일 타임 버전 패턴 매칭 — type function 하나로 semver 비슷한 문자열 리터럴 타입을 글롭(*)/캐럿(N^) 패턴과 대조. quad에 종속되지 않은 범용 유틸(quad-types의 CheckedQuad이 이 위에 얹힘). 현재는 quad 모노레포 안 워크스페이스 멤버로 두지만, 다른 프로젝트에서도 쓸 수 있게 독립 패키지로 분리 예정(HUMAN_TODO.md 참고) — 지금부터 quad 전용 타입/이름을 안 섞을 것." +includes = ["src/*"] + +[target] +environment = "luau" +lib = "src/init.luau" diff --git a/type-version-check/selene.toml b/type-version-check/selene.toml new file mode 100644 index 0000000..82f16cf --- /dev/null +++ b/type-version-check/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/type-version-check/src/init.luau b/type-version-check/src/init.luau new file mode 100644 index 0000000..b17c43a --- /dev/null +++ b/type-version-check/src/init.luau @@ -0,0 +1,130 @@ +--!strict +--[[ + 컴파일 타임 버전 패턴 매칭 — `quad`에 종속되지 않은 범용 유틸. + + **[2026-08-19 신설, quad 저장소 안에 임시로 둠]** 지금은 quad + 워크스페이스의 네 번째 멤버로 두지만, 사용자가 나중에 독립 저장소로 + 직접 분리할 예정(`HUMAN_TODO.md` 참고) — 그래서 이 파일 안에 `Quad`류 + quad 전용 이름/타입을 절대 안 섞는다. `quad-types`의 + `CheckedQuad`이 이 위에 얹히는 소비자 중 하나일 뿐. + + **패턴 문법**: `.`로 나뉜 각 자리가 + - `"*"` — 와일드카드(뭐든 통과) + - `"N^"` — 그 자리 값이 숫자로 봤을 때 N **이상**이면 통과(caret) + - 그 외 — 정확히 같은 문자열이어야 통과 + + 예: `"3.*.*"`(메이저만 고정), `"3.3^.4^"`(마이너 3 이상 + 패치 + 4 이상), `"0.0.0"`(정확히 일치, quad-types의 `CheckedQuad`가 지금 + 쓰는 방식). + + **⚠️ `matchesPattern`은 아래 `type function` 안에 통째로 다시 + 적혀 있다(중복) — 실측 확인된 제약**: `type function`은 같은 파일의 + 바깥 스코프 로컬 함수를 아예 참조 못 한다(`Type function cannot + reference outer local 'X'`로 컴파일 자체가 실패함). 그래서 순수 + 런타임 유틸(아래, 테스트/문서화/일반 사용 목적)과 `type function` + 내부 구현은 로직이 같아도 물리적으로 별개 함수여야 한다 — 하나를 + 고치면 반드시 다른 하나도 같이 고칠 것. +]] + +local function matchesPattern(actual: string, pattern: string): boolean + local actualParts = string.split(actual, ".") + local patternParts = string.split(pattern, ".") + if #actualParts ~= #patternParts then + return false + end + + for i, patternPart in patternParts do + local actualPart = actualParts[i] + if patternPart == "*" then + continue + end + + if string.sub(patternPart, -1) == "^" then + local minValue = tonumber(string.sub(patternPart, 1, -2)) + local actualNumber = tonumber(actualPart) + if minValue == nil or actualNumber == nil or actualNumber < minValue then + return false + end + else + if actualPart ~= patternPart then + return false + end + end + end + + return true +end + +--[[ + `CheckVersion` — `Actual`/`Pattern` 둘 다 문자열 + 리터럴(singleton) 타입이어야 함. 일치하면 트리비얼한 `true` 하나만 + 반환하고, 불일치하면 `print`+`types.never`로 사람이 읽을 메시지를 + 낸다(`error()`는 쓰지 않음 — `typing-limits.md` §6/`quad-types-plan.md` + 참고, type function 안에서 `error()`를 쓰면 "type function 자체가 + 실패함"으로 판정돼 버려짐). + + **⚠️ 이 type function이 반환하는 값은 절대 원본 타입을 담지 않는다** — + `Actual`/`Pattern`을 그대로도, 재구성해서도 반환하지 않고 트리비얼한 + `true`만 반환한다. 소비자는 이 결과를 원본과 절대 안 섞이는 별도 + 필드로만 노출할 것 — `type function`을 거친 값에 제네릭 self + 메소드(`AddPlugin`류)를 나중에 부르면 조용히 깨지는 게 실측 + 확인됐기 때문(`typing-limits.md` §6). 예시는 `quad-types`의 + `CheckedQuad` 구현 참고. +]] +-- selene: allow(undefined_variable) — `types`는 `type function` 블록 안에서만 +-- 주입되는 특수 전역(quad-types/src/init.luau와 같은 이유의 오탐). +export type function CheckVersion(actual: type, pattern: type): type + local actualOk, actualValue = pcall(function() + return actual:value() + end) + if not actualOk or type(actualValue) ~= "string" then + print("type-version-check: expected a string literal type for the actual version") + return types.never + end + + local patternOk, patternValue = pcall(function() + return pattern:value() + end) + if not patternOk or type(patternValue) ~= "string" then + print("type-version-check: expected a string literal type for the version pattern") + return types.never + end + + -- matchesPattern과 로직 동일 — 위 경고문 참고, 바깥 함수를 못 불러서 복제함 + local function matches(actualStr: string, patternStr: string): boolean + local actualParts = string.split(actualStr, ".") + local patternParts = string.split(patternStr, ".") + if #actualParts ~= #patternParts then + return false + end + for i, patternPart in patternParts do + local actualPart = actualParts[i] + if patternPart == "*" then + continue + end + if string.sub(patternPart, -1) == "^" then + local minValue = tonumber(string.sub(patternPart, 1, -2)) + local actualNumber = tonumber(actualPart) + if minValue == nil or actualNumber == nil or actualNumber < minValue then + return false + end + else + if actualPart ~= patternPart then + return false + end + end + end + return true + end + + if not matches(actualValue :: string, patternValue :: string) then + print(`type-version-check: version "{actualValue}" does not match pattern "{patternValue}"`) + return types.never + end + + return types.singleton(true) +end + +return { + matchesPattern = matchesPattern, +}