design: quad-types 패키지 신설 — AddPlugin<Self,P> + CheckedQuad<T> 실측 설계

quad-roblox가 quad-base를 런타임 주입(QuadRoblox(Quad): QuadRoblox)으로만
받으면 pesde 의존 선언이 필요 없어 보이지만, 타입 참조용 require도
런타임에 실제 실행됨을 실측 확인 — dev-dependency로 두면 게시 후
소비자 환경에서 크래시함. 해법으로 구현 없는 타입 계약 전용 워크스페이스
패키지 quad-types 신설, quad-base/quad-roblox 모두 이것만 의존하도록
전환.

AddPlugin<Self,P>(self:Self,fn:(Self)->P):Self&P — 제네릭 self로 둬야
체이닝이 누적됨을 실측 확인(고정하면 이전 확장을 잃음), quad-base에
실제 mutate 기반 구현 반영.

CheckedQuad<T> 버전 체크는 배선하며 세 번 깨짐 — 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 <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 17:09:16 +09:00
parent 1de031e139
commit 297c4d459c
No known key found for this signature in database
21 changed files with 739 additions and 29 deletions

File diff suppressed because one or more lines are too long

View file

@ -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" 절)

View file

@ -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`)는 **런타임

View file

@ -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<T>
```
- `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, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
}
```
`Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`.
이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한
구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴
`"0.1.0"``"0.0.0"`과 구조적으로 호환 안 됨) — `CheckVersion`이 주는
추가 가치는 **감지 자체**가 아니라 사람이 읽을 수 있는 진단 메시지다
(아래 절).
## `AddPlugin<Self, P>` — 실측 검증된 플러그인 체이닝
```lua
AddPlugin: <Self, P>(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<T>` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로
**왜 필요한가**: `quad-roblox``quad_base`를 pesde `[dependencies]`
선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌
방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥
함수 인자라서. `CheckedQuad<T>`가 그 빈자리를 메꾸는 컴파일 타임
대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox
소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로
충분").
```lua
export type CheckedQuad<T> = T & { __versionCheck: CheckVersion<T> }
```
**사용법**:
```lua
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T>
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<T>(quad: T): T
type _Check = CheckVersion<T>
return quad
end
```
체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에
박아 넣어야 한다. 이게 `CheckedQuad<T>`가 함수 파라미터/반환 타입
표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로
참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ =
checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다.
**함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 `type
function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다.**
처음엔 `CheckVersion<T>`가 성공 시 `T`를 그대로 패스스루(`return t`)하는
버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접
써서 즉시 평가되고, `AddPlugin`도 안 뭉개지는 것처럼 보였다). 그런데
**`CheckVersion<T>`의 결과를 다른 타입과 `&`로 합친 뒤 `AddPlugin`
호출하는 조합**에서 `Expected this to be exactly 'P & Self', but got
'P & Self'`처럼 **앞뒤가 똑같은, 의미 없는 진단**이 뜨며 깨졌다. 더
좁혀보니, 심지어 **`&`로 안 합쳐도, `CheckVersion<T>`를 거친 값에
`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<T>`를 쓰는 진입점(`QuadRoblox` 등)
구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법
예제가 실제 위치는 아직 없음.
- `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는
`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`
**버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개
문제로 계속 `_initializedBy`가 담당한다.

View file

@ -220,12 +220,12 @@ type State<T> = {
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, P>(self: Self,
...) -> Self & P`류, `AddPlugin`이 실제 사례)를 부르면 진단이 조용히
깨집니다:
```lua
type function CheckVersion(t: type): type
return t -- 순수 패스스루 — 재구성 없음
end
local checked: CheckVersion<Quad> = ... -- 여기까진 정상
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> = T & { __versionCheck: CheckVersion<T> }
local checked: CheckedQuad<Quad> = ...
local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고)
local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함
```
`quad-types-plan.md`의 "`CheckedQuad<T>`" 절에 전체 배선과 실측 과정이
있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 본문
로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야
호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로
격리, 세 가지가 함께 필요합니다.
### 언제 마주치는가
`AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에,
`type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는
지금 `quad-types``CheckedQuad<T>`가 유일한 실사례지만, 앞으로 비슷한
"타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 수
있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가.
---
## 7. 성립이 확인된 것 (안심해도 되는 것)
한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다.
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
@ -381,7 +442,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
---
## 7. 새 타입/API를 설계할 때 체크리스트
## 8. 새 타입/API를 설계할 때 체크리스트
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
@ -412,6 +473,11 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를
확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가
실측에서 뒤집힌" 것들입니다.
7. **`type function`으로 검사/변형한 값에 제네릭 self 메소드
(`<Self,P>(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`는 **옛 솔버가 기본값**

View file

@ -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<T>`(버전 체크)가 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견 | `quad-types-plan.md`, `typing-limits.md` §6 |
## 공통 유틸리티

View file

@ -1,6 +1,10 @@
# 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-19 — `13`을 타입 전용/런타임 두 파일로 분리
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T>`
> 버전 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `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<Input>`이 정확히 `{ty: Source<string>, count: Source<number>}` 구조를 만족, 음성 대조군 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<T>`+`AddPlugin<Self,P>` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격) — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 |
### 특별히 중요한 통과 3건

View file

@ -0,0 +1,83 @@
--!strict
--[[
검증 대상(타입 전용): `quad-types`의 `CheckedQuad<T>`(버전 체크
type function)가 실제 `quad-base`가 구현하는 `Quad` 타입과 맞물려
동작하는지 — 그리고 검증 후에도 `AddPlugin<Self,P>`의 제네릭 체이닝이
안 깨지는지.
배경: 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<T>`) 제네릭이 인스턴스화될 때마다 재평가되지 않아
진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로
해석돼야 하는 자리**에 박아 넣어야 함.
3. **[가장 중요, 가장 늦게 발견]** `CheckVersion<T>`가 `T`를 단순
패스스루(`return t`)해도, 그 결과 타입이 **한 번이라도 type
function을 거쳤다는 이력만으로** 이후 `AddPlugin<Self,P>` 같은
제네릭 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<T>(quad: T): QuadTypes.CheckedQuad<T>
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, P>(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)

View file

@ -0,0 +1,125 @@
# 2026-08-19, 일곱 번째 세션 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계
**요약**: `RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리
확정한 뒤, 사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면
dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고
`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설,
`AddPlugin<Self,P>` 플러그인 체이닝과 `CheckedQuad<T>` 컴파일 타임
버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 새 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,P>` — 제네릭 self 체이닝 실측
`Quad:AddPlugin(pluginFn): T`에서 `T`가 정확히 "플러그인이 누적된
Quad"가 되는지 확인해달라는 요청("타입의 근간인 부분"). 여러 스파이크로
검증:
- `<Self, P>(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<T>` — 배선하며 세 번 깨짐, 세 번째가 핵심 발견
사용자가 구체적 구현 지침을 줌: `error()` 말고 `print()`+`types.never`,
검증 결과는 `__versionCheck` 같은 가상 필드에, 성공 값은 트리비얼하게.
실제로 배선하며 순서대로:
1. **`error()` 시도 → 실패**: type function 자체가 실패로 판정됨.
`print`+`types.never`로 교체 → 즉시 성공(호출부에 정확히
"TypeError: <메시지>").
2. **함수 본문 로컬 타입 별칭 시도 → 무반응**: `type _Check =
CheckVersion<T>`를 본문에 두면 제네릭 인스턴스화마다 재평가 안 됨.
리턴 타입 표현식 자체로 옮기니 즉시 해결.
3. **[가장 중요] 패스스루(`return t`) 버전 → 단독으론 통과, `AddPlugin`
체이닝과 조합하면 조용히 깨짐**: `CheckVersion<T> & RobloxExt` 뒤에
`:AddPlugin(...)`을 부르면 "Expected this to be exactly 'P & Self',
but got 'P & Self'"처럼 앞뒤가 같은 의미 없는 진단이 남. `&`로 안
합쳐도, 패스스루만 거쳐도 동일하게 깨짐 — **재구성이 아니라 "type
function을 거쳤다는 이력 자체"가 문제**. 최종 설계: `CheckVersion`
`T`를 절대 반환하지 않고(성공 시 `types.singleton(true)`만), 결과를
`T & { __versionCheck: CheckVersion<T> }`처럼 원본과 완전히 격리된
필드로만 노출 — 이 형태만 `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<T>` 실사용은 M5. 심볼릭 링크
로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로
대화는 계속 한국어로 진행 중.

View file

@ -10,3 +10,6 @@ roblox = "quad-base"
[workspace."qwreey/quad_roblox"]
roblox = "quad-roblox"
[workspace."qwreey/quad_types"]
roblox = "quad-types"

View file

@ -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"

View file

@ -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"

View file

@ -7,3 +7,6 @@ includes = ["src/*"]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"
[dependencies]
quad_types = { workspace = "qwreey/quad_types", version = "^" }

View file

@ -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)를 순서 무관하게 추가

View file

@ -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 ===")

View file

@ -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"

View file

@ -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 = "^" }

6
quad-types/pesde.lock Normal file
View file

@ -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"

9
quad-types/pesde.toml Normal file
View file

@ -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"

31
quad-types/selene.toml Normal file
View file

@ -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" }

80
quad-types/src/init.luau Normal file
View file

@ -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, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
}
--[[
`CheckVersion<T>` — 주입된 값의 실제 타입 `T`가 이 패키지가 아는
`Quad.Version`과 일치하는지 컴파일 타임에 확인.
**⚠️ 반드시 "가상 필드"로만 쓸 것 — `T`를 직접 패스스루하지 말 것.**
처음엔 `return t`(단순 패스스루)로 짜서 단독으로는 잘 됐는데,
**`AddPlugin<Self,P>`처럼 제네릭 self 파라미터를 쓰는 메소드와
체이닝하면 조용히 깨짐**을 실측으로 확인함(2026-08-19) — 값의 정적
타입이 한 번이라도 `type function`을 거치면, 그 뒤 제네릭 self 추론이
"Expected this to be exactly 'P & Self', but got 'P & Self'"류의
앞뒤가 같은 의미 없는 진단을 내며 깨진다(재구성이 아니라 **패스스루도
똑같이** 깨짐 — 재구성 비용 문제가 아니라 "그 타입이 type function을
거쳤다는 이력 자체"가 문제였음). 그래서 검증 결과를 **원본 타입과
절대 안 섞이는 별도 필드**로 격리해야 한다 — `CheckVersion<T>` 자체는
`T`를 절대 참조/반환하지 않고, 성공 시 트리비얼한 `true` 하나만 반환.
**사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**:
```lua
type Checked<T> = T & { __versionCheck: CheckVersion<T> }
local function CheckQuad<T>(quad: T): Checked<T>
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> = T & { __versionCheck: CheckVersion<T> }
return {}