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:
parent
1de031e139
commit
297c4d459c
21 changed files with 739 additions and 29 deletions
File diff suppressed because one or more lines are too long
|
|
@ -193,11 +193,18 @@ build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup
|
||||||
소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게
|
소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게
|
||||||
따라감).
|
따라감).
|
||||||
실제 구현: 루트 `pesde.toml`(`private = true`,
|
실제 구현: 루트 `pesde.toml`(`private = true`,
|
||||||
`workspace_members = ["quad-base", "quad-roblox"]`) + `quad-base/pesde.toml`/
|
`workspace_members = ["quad-base", "quad-roblox", "quad-types"]`) +
|
||||||
`quad-roblox/pesde.toml`(각각 `[target] environment = "roblox"`,
|
`quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml`
|
||||||
`quad-roblox`는 `quad-base`에 workspace 의존), 툴체인은 `mise.toml`로 핀
|
(각각 `[target] environment = "roblox"`), 툴체인은 `mise.toml`로 핀
|
||||||
(`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는
|
(`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는
|
||||||
판단, `base/project-setup-plan.md`의 "툴체인" 절 참고).
|
판단, `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`을 두지 말 것"이 컨벤션(그
|
pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그
|
||||||
파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`은
|
파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`은
|
||||||
이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스
|
이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스
|
||||||
|
|
@ -232,10 +239,15 @@ op" 절.
|
||||||
|
|
||||||
```
|
```
|
||||||
quad/
|
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 프로젝트
|
├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트
|
||||||
|
├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad 버전체크(`base/quad-types-plan.md`)
|
||||||
|
│ ├── pesde.toml
|
||||||
|
│ └── src/init.luau
|
||||||
├── quad-base/
|
├── quad-base/
|
||||||
│ ├── wally.toml
|
│ ├── pesde.toml
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
||||||
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속
|
│ ├── 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 개념 없음
|
│ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음
|
||||||
│ └── init.luau
|
│ └── init.luau
|
||||||
└── quad-roblox/
|
└── quad-roblox/
|
||||||
├── wally.toml
|
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
||||||
└── src/
|
└── src/
|
||||||
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
|
├── 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" 절)
|
├── 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" 절)
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,23 @@ Studio도 같은 파일시스템 계층을 쓰는 이상 다르게 동작할 이
|
||||||
Rojo/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` — alias는 여전히 편집기 전용
|
||||||
|
|
||||||
`.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임
|
`.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임
|
||||||
|
|
|
||||||
193
.claude/base/quad-types-plan.md
Normal file
193
.claude/base/quad-types-plan.md
Normal 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`가 담당한다.
|
||||||
|
|
@ -220,12 +220,12 @@ type State<T> = {
|
||||||
with different parameters"로 선언 시점에 막힘, 실측:
|
with different parameters"로 선언 시점에 막힘, 실측:
|
||||||
`type-recursive-issue-with-typeof/spikes/
|
`type-recursive-issue-with-typeof/spikes/
|
||||||
19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도
|
19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도
|
||||||
§8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
§9의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
||||||
전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다.
|
전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다.
|
||||||
|
|
||||||
**시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**:
|
**시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**:
|
||||||
콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone`
|
콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone`
|
||||||
체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
||||||
self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면
|
self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면
|
||||||
(= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단
|
(= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단
|
||||||
두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable`
|
두 개가 동시에 남는 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>` 안에서
|
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
|
||||||
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
|
`-> 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건이어도 타입이 제대로
|
> **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로
|
||||||
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
|
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
|
||||||
|
|
@ -421,7 +487,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 미해결 / 추적 중
|
## 9. 미해결 / 추적 중
|
||||||
|
|
||||||
- **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze`
|
- **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze`
|
||||||
CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**
|
CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**
|
||||||
|
|
|
||||||
|
|
@ -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 여섯 번째 세션) |
|
| `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번 |
|
| `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` 절 |
|
| `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 |
|
||||||
|
|
||||||
## 공통 유틸리티
|
## 공통 유틸리티
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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까지 확장, 런타임
|
> (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임
|
||||||
> 절반은 신규 `22`로 분가 → 둘 다 `done/`. 직전 갱신은 같은 날 —
|
> 절반은 신규 `22`로 분가 → 둘 다 `done/`. 직전 갱신은 같은 날 —
|
||||||
> M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 `05`를 현행 모델("emit은
|
> M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 `05`를 현행 모델("emit은
|
||||||
|
|
@ -32,7 +36,7 @@
|
||||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
|
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
|
||||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 18 | — |
|
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
|
||||||
|
|
||||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||||
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
||||||
|
|
@ -88,7 +92,7 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
||||||
|
|
||||||
## ✅ `done/` — 통과 or 판정 끝 (18건)
|
## ✅ `done/` — 통과 or 판정 끝 (19건)
|
||||||
|
|
||||||
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
||||||
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
||||||
|
|
@ -120,6 +124,7 @@
|
||||||
| `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 |
|
| `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절 |
|
| `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에서 실측 확정 |
|
| `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건
|
### 특별히 중요한 통과 3건
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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. 심볼릭 링크
|
||||||
|
로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로
|
||||||
|
대화는 계속 한국어로 진행 중.
|
||||||
|
|
@ -10,3 +10,6 @@ roblox = "quad-base"
|
||||||
|
|
||||||
[workspace."qwreey/quad_roblox"]
|
[workspace."qwreey/quad_roblox"]
|
||||||
roblox = "quad-roblox"
|
roblox = "quad-roblox"
|
||||||
|
|
||||||
|
[workspace."qwreey/quad_types"]
|
||||||
|
roblox = "quad-types"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
name = "qwreey/quad"
|
name = "qwreey/quad"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
private = true
|
private = true
|
||||||
workspace_members = ["quad-base", "quad-roblox"]
|
workspace_members = ["quad-base", "quad-roblox", "quad-types"]
|
||||||
|
|
||||||
[target]
|
[target]
|
||||||
environment = "roblox"
|
environment = "roblox"
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,10 @@ format = 2
|
||||||
name = "qwreey/quad_base"
|
name = "qwreey/quad_base"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
target = "roblox"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,6 @@ includes = ["src/*"]
|
||||||
environment = "roblox"
|
environment = "roblox"
|
||||||
build_files = ["src"]
|
build_files = ["src"]
|
||||||
lib = "src/init.luau"
|
lib = "src/init.luau"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
quad_types = { workspace = "qwreey/quad_types", version = "^" }
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,32 @@
|
||||||
quad-base 최상위 진입점.
|
quad-base 최상위 진입점.
|
||||||
`.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md`
|
`.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md`
|
||||||
"New()의 내부 구성 — InitXxx 팩토리 체이닝" / "RunInit — 함수 자체를
|
"New()의 내부 구성 — InitXxx 팩토리 체이닝" / "RunInit — 함수 자체를
|
||||||
릴레이션 키로 쓰는 멱등 가드" 절 그대로.
|
릴레이션 키로 쓰는 멱등 가드" / "AddPlugin" 절 그대로.
|
||||||
|
|
||||||
`require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중
|
`require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중
|
||||||
인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출.
|
인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출.
|
||||||
|
|
||||||
|
`Quad` 타입 자체는 여기서 선언하지 않고 `quad-types`(워크스페이스
|
||||||
|
패키지, 구현 없이 타입 계약만)에서 그대로 가져와 씀 — quad-roblox 같은
|
||||||
|
백엔드 패키지가 이 무거운 quad-base 전체 대신 `quad-types`만 의존할 수
|
||||||
|
있게 하기 위함(`.claude/base/project-setup-plan.md` "quad-types" 절).
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Relate = require("@self/Relate")
|
local Relate = require("@self/Relate")
|
||||||
|
local QuadTypes = require("./roblox_packages/quad_types")
|
||||||
local InitDebug = require("@self/Debug")
|
local InitDebug = require("@self/Debug")
|
||||||
|
|
||||||
|
type Quad = QuadTypes.Quad
|
||||||
|
|
||||||
-- (module, initFn) -> 이미 실행됐는가. New()가 몇 번 불려도 module마다
|
-- (module, initFn) -> 이미 실행됐는가. New()가 몇 번 불려도 module마다
|
||||||
-- weak-키잉되므로 별도 정리 불필요(module이 GC되면 이 기록도 같이 사라짐).
|
-- weak-키잉되므로 별도 정리 불필요(module이 GC되면 이 기록도 같이 사라짐).
|
||||||
local runInitRelate = Relate()
|
local runInitRelate = Relate()
|
||||||
|
|
||||||
type Quad = {
|
|
||||||
New: () -> Quad,
|
|
||||||
RunInit: (self: Quad, initFn: (Quad) -> any) -> (),
|
|
||||||
debug: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
local function New(): Quad
|
local function New(): Quad
|
||||||
local module = { New = New } :: Quad
|
local module = {
|
||||||
|
New = New,
|
||||||
|
Version = "0.0.0",
|
||||||
|
} :: Quad
|
||||||
|
|
||||||
function module.RunInit(self, initFn)
|
function module.RunInit(self, initFn)
|
||||||
if runInitRelate:GetStrong(self, initFn) then
|
if runInitRelate:GetStrong(self, initFn) then
|
||||||
|
|
@ -32,6 +37,14 @@ local function New(): Quad
|
||||||
initFn(self)
|
initFn(self)
|
||||||
end
|
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(InitDebug)
|
||||||
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
||||||
|
|
||||||
|
|
|
||||||
55
quad-base/test/smoke.plugin.luau
Normal file
55
quad-base/test/smoke.plugin.luau
Normal 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 ===")
|
||||||
|
|
@ -5,9 +5,9 @@ name = "qwreey/quad_roblox"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
target = "roblox"
|
target = "roblox"
|
||||||
|
|
||||||
[graph."qwreey/quad_base@0.0.0 roblox"]
|
[graph."qwreey/quad_types@0.0.0 roblox"]
|
||||||
direct = ["quad_base", { workspace = "qwreey/quad_base", version = "^" }, "standard"]
|
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"
|
ref_ty = "workspace"
|
||||||
path = "quad-base"
|
path = "quad-types"
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ build_files = ["src"]
|
||||||
lib = "src/init.luau"
|
lib = "src/init.luau"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quad_base = { workspace = "qwreey/quad_base", version = "^" }
|
quad_types = { workspace = "qwreey/quad_types", version = "^" }
|
||||||
|
|
|
||||||
6
quad-types/pesde.lock
Normal file
6
quad-types/pesde.lock
Normal 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
9
quad-types/pesde.toml
Normal 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
31
quad-types/selene.toml
Normal 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
80
quad-types/src/init.luau
Normal 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 {}
|
||||||
Loading…
Reference in a new issue