design: type-version-check 패키지 추출 — CheckedQuad<T, Pattern> 글롭/캐럿 확장

quad-spring-roblox류 독립 게시 플러그인엔 정확 버전 일치가 과하다는 지적에
따라, 버전 패턴 매칭(글롭 "*"/캐럿 "N^")을 quad에 종속되지 않은 범용
워크스페이스 멤버 type-version-check로 분리하고 quad-types의 CheckedQuad를
CheckedQuad<T, Pattern>으로 확장. 새 Luau 함정 2건(type function의 outer
local 참조 불가, cross-package엔 export type function + 이중 꺾쇠 제네릭
인스턴스화 필요) 발견·문서화. 독립 저장소 분리는 HUMAN_TODO로 위임.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 17:39:57 +09:00
parent 297c4d459c
commit 5dfc9b9a43
No known key found for this signature in database
21 changed files with 542 additions and 106 deletions

File diff suppressed because one or more lines are too long

View file

@ -193,11 +193,19 @@ build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup
소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게 소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게
따라감). 따라감).
실제 구현: 루트 `pesde.toml`(`private = true`, 실제 구현: 루트 `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` `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 사용자 결정 — 더 범용적인 도구라는 (`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` **[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox``quad-base`
아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace 아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace
의존** — `quad-base``QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임 의존** — `quad-base``QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임
@ -243,9 +251,12 @@ quad/
├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀 ├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀
├── pesde.toml # 워크스페이스 루트(private, workspace_members) ├── pesde.toml # 워크스페이스 루트(private, workspace_members)
├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트 ├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트
├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad 버전체크(`base/quad-types-plan.md`) ├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad<T,Pattern> 버전체크(`base/quad-types-plan.md`)
│ ├── pesde.toml │ ├── pesde.toml # type_version_check workspace 의존(target="luau")
│ └── src/init.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/ ├── quad-base/
│ ├── pesde.toml │ ├── pesde.toml
│ └── src/ │ └── src/

View file

@ -25,6 +25,13 @@ M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/
## pesde 워크스페이스 구조 ## pesde 워크스페이스 구조
**전체 멤버 목록·최신 트리는 `architecture.md`의 "구현 착수: 소스 트리
구조 확정" 절이 소스** — 새 워크스페이스 멤버가 늘 때마다 그쪽만 갱신하면
되게 하기 위해 여기서 다시 나열/개수 세지 않는다(멤버 수가 실제로 2→3→4로
늘어난 이력에서 이 문서의 구식 트리가 갱신을 놓쳤던 게 계기). 아래는 그
구조가 실제로 동작하는 **pesde 메커니즘 자체**(문법/함정)만 다룬다 —
예시엔 최소 2-멤버 형태만 남겨두고 서술 부담을 줄임:
``` ```
quad/ quad/
├── pesde.toml # 워크스페이스 루트, private = true, workspace_members ├── pesde.toml # 워크스페이스 루트, private = true, workspace_members
@ -37,8 +44,8 @@ quad/
└── selene.toml └── selene.toml
``` ```
- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members = - **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members`
["quad-base", "quad-roblox"]`. `[target] environment = "roblox"` (멤버 목록은 `architecture.md`가 소스). `[target] environment = "roblox"`
필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이 필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이
세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후 세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후
재확인 후보). 재확인 후보).
@ -59,9 +66,18 @@ quad/
"package not found"로 실패한다. `[dependencies]``workspace = "scope/name"` "package not found"로 실패한다. `[dependencies]``workspace = "scope/name"`
줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install` 줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`
받아들이는 걸 확인함). 받아들이는 걸 확인함).
- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 전체가 - **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 **모든
갱신됨(`qwreey/quad`/`qwreey/quad_base`/`qwreey/quad_roblox` 셋 다 워크스페이스 멤버**가 스캔·링크됨(개수는 `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) ## 툴체인 — `rokit.toml`에서 `mise.toml`로 전환 (2026-08-19)
@ -231,6 +247,22 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했
`.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — `.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` — alias는 여전히 편집기 전용
`.luaurc``aliases`(`@quad-base`/`@quad-roblox`)는 **런타임 `.luaurc``aliases`(`@quad-base`/`@quad-roblox`)는 **런타임
@ -275,8 +307,10 @@ require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇
**[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱 **[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱
하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install` 하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`
**워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트 **워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트
`pesde.lock` 1개 + `quad-base/pesde.lock` + `quad-roblox/pesde.lock`, `pesde.lock` 1개 + 멤버마다 1개씩, 개수는 `architecture.md`
총 3개). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만 `workspace_members`를 따라간다 — 2026-08-19 이 세션 안에서만 2개
멤버(2+1개 lock)에서 4개 멤버(4+1개 lock)로 늘어난 전례가 있어 여기 숫자를
고정하지 않는다). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만
담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의 담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의
lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty = lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty =
"workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만). "workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만).
@ -295,7 +329,10 @@ lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `privat
## 확인 완료 / 아직 확인 안 된 것 ## 확인 완료 / 아직 확인 안 된 것
**확인 완료(이 세션, 실제 pesde/luau 실행 근거)**: **확인 완료(이 세션, 실제 pesde/luau 실행 근거)**:
- pesde 워크스페이스 설치가 3개 패키지(루트+2서브) 전부에 대해 성공 - pesde 워크스페이스 설치가 당시 워크스페이스 멤버 전부(그때는
루트+2서브)에 대해 성공(**[2026-08-19 후속]** 이후 `quad-types`/
`type-version-check` 추가로 멤버가 늘어난 뒤에도 같은 절차로 계속 성공 —
아래 후속 문단들 참고)
- 패키지 이름 문자 제약(하이픈 금지) - 패키지 이름 문자 제약(하이픈 금지)
- `workspace = "scope/name"` 의존성 선언 문법 - `workspace = "scope/name"` 의존성 선언 문법
- `@self``init.luau`의 형제 파일 접근에 필수라는 것(런타임+ - `@self``init.luau`의 형제 파일 접근에 필수라는 것(런타임+

View file

@ -2,7 +2,11 @@
**상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 **상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스
세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한 세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한
사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. 사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. **[같은 날 후속]**
버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지
`type-version-check`(워크스페이스 네 번째 멤버)로 분리됐고, `CheckedQuad<T>`
`CheckedQuad<T, Pattern>`으로 확장돼 그 위에 얹힌다 — 아래 "`type-version-check`"
절.
## 왜 필요한가 — dev-dependency로는 못 푸는 문제 ## 왜 필요한가 — dev-dependency로는 못 푸는 문제
@ -44,8 +48,12 @@ pesde의 워크스페이스 의존성은 **패키지 단위**로만 걸린다
``` ```
quad-types/ quad-types/
├── pesde.toml # name = "qwreey/quad_types" ├── pesde.toml # name = "qwreey/quad_types", type_version_check workspace 의존
└── src/init.luau # export type Quad, type function CheckVersion, export type CheckedQuad<T> └── src/init.luau # export type Quad, export type CheckedQuad<T, Pattern>
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` 타입을 - `quad-base``quad_types`에 workspace 의존 — **자기 `Quad` 타입을
@ -99,23 +107,73 @@ AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P
identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절), identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절),
`AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다. `AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다.
## `CheckedQuad<T>` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 ## `type-version-check` — 범용 버전 패턴 매칭 패키지
**[2026-08-19 신설]** 처음엔 `CheckVersion<T>`가 정확 일치(`"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<A, B>`)가 비교 연산자로 오파싱되니
반드시 이중 꺾쇠(`Foo<<A, B>>`)를 써야 한다(코퍼스에 이미 있던
`AttributeKey<<T>>` 관례와 같은 이유).
`Version` 필드는 Luau 내장 `index<T, "Version">` type function으로 뽑는다
(수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인
완료).
## `CheckedQuad<T, Pattern>` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로
**왜 필요한가**: `quad-roblox``quad_base`를 pesde `[dependencies]` **왜 필요한가**: `quad-roblox``quad_base`를 pesde `[dependencies]`
선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌 선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌
방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥 방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥
함수 인자라서. `CheckedQuad<T>`가 그 빈자리를 메꾸는 컴파일 타임 함수 인자라서. `CheckedQuad<T, Pattern>`이 그 빈자리를 메꾸는 컴파일 타임
대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox 대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox
소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로
충분"). 충분"). `Pattern`은 위 `type-version-check`의 글롭/캐럿 패턴 문자열 —
quad-base/quad-roblox처럼 같은 모노레포에서 항상 같이 개발되는 관계는
정확 일치(`"0.0.0"`)를, quad-spring-roblox류 독립 게시 플러그인은
`"0.*.*"` 같은 느슨한 패턴을 직접 골라 쓴다.
```lua ```lua
export type CheckedQuad<T> = T & { __versionCheck: CheckVersion<T> } export type CheckedQuad<T, Pattern> = T & { __versionCheck: TypeVersionCheck.CheckVersion<index<T, "Version">, Pattern> }
``` ```
**사용법**: **사용법**:
```lua ```lua
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T> local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T, "0.0.0">
return quad :: any return quad :: any
end end
@ -141,8 +199,8 @@ local function QuadRoblox<T>(quad: T): T
end end
``` ```
체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에 체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에
박아 넣어야 한다. 이게 `CheckedQuad<T>`가 함수 파라미터/반환 타입 박아 넣어야 한다. 이게 `CheckedQuad<T, Pattern>`이 함수 파라미터/반환
표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 타입 표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로
참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ = 참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ =
checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다. checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다.
@ -174,20 +232,30 @@ function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히
## 실측 근거 ## 실측 근거
`.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`
실제 `quad-types`/`quad-base`를 `require`해서 위 사용법 그대로 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 위
재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 확장 필드 사용법 그대로 재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전
유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서
`TypeError: quad-base version mismatch: ...` 하나만. `TypeError: type-version-check: version "9.9.9" does not match pattern
"0.0.0"` 하나만.
`quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate `quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate
+ identity 보존 + 체이닝)을 실행 레벨로 검증. + identity 보존 + 체이닝)을 실행 레벨로 검증.
## 남은 것 ## 남은 것
- `quad-roblox`가 실제로 `CheckedQuad<T>`를 쓰는 진입점(`QuadRoblox` 등) - `quad-roblox`가 실제로 `CheckedQuad<T, Pattern>`을 쓰는 진입점
구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 문서의 사용법 (`QuadRoblox` 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이
예제가 실제 위치는 아직 없음. 문서의 사용법 예제가 실제 위치는 아직 없음.
- `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 - `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는
`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad` `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`
**버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개 **버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개
문제로 계속 `_initializedBy`가 담당한다. 문제로 계속 `_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` 참고.

View file

@ -399,26 +399,29 @@ local extended = checked:AddPlugin(somePlugin) -- 여기서 깨짐:
```lua ```lua
-- ✅ 원본 T는 type function을 한 번도 안 거침 -- ✅ 원본 T는 type function을 한 번도 안 거침
type CheckedQuad<T> = T & { __versionCheck: CheckVersion<T> } type CheckedQuad<T, Pattern> = T & { __versionCheck: CheckVersion<T, Pattern> }
local checked: CheckedQuad<Quad> = ... local checked: CheckedQuad<Quad, "0.0.0"> = ...
local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고) local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고)
local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함 local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함
``` ```
`quad-types-plan.md`의 "`CheckedQuad<T>`" 절에 전체 배선과 실측 과정이 `quad-types-plan.md`의 "`CheckedQuad<T, Pattern>`" 절에 전체 배선과 실측
있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 본문 과정이 있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수
로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 본문 로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야
호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로 호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로
격리, 세 가지가 함께 필요합니다. 격리, 세 가지가 함께 필요합니다. **[2026-08-19 후속]** 실제 버전 매칭
로직(`CheckVersion`)은 quad에 종속되지 않은 별도 패키지
`type-version-check`로 분리됐지만, 이 항목이 다루는 "패스스루도 이력만으로
오염된다" 함정과 그 회피(별도 가상 필드 격리)는 그대로 유효합니다.
### 언제 마주치는가 ### 언제 마주치는가
`AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에, `AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에,
`type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는 `type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는
지금 `quad-types``CheckedQuad<T>`가 유일한 실사례지만, 앞으로 비슷한 지금 `quad-types``CheckedQuad<T, Pattern>`이 유일한 실사례지만, 앞으로
"타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 비슷한 "타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할
있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가. 있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가.
--- ---

View file

@ -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 여섯 번째 세션) | | `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 | | `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
## 공통 유틸리티 ## 공통 유틸리티

View file

@ -1,8 +1,9 @@
# 스파이크 상태판 — **폴더가 곧 상태** # 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T>` > 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 버전 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 → `done/` > 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
> 직행. 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 > `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
> 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md` > 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md`
> §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리 > §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리
> (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임 > (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임
@ -124,7 +125,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으로 승격) — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | | `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check``CheckedQuad<T, Pattern>`+`AddPlugin<Self,P>` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 |
### 특별히 중요한 통과 3건 ### 특별히 중요한 통과 3건

View file

@ -1,16 +1,16 @@
--!strict --!strict
--[[ --[[
검증 대상(타입 전용): `quad-types`의 `CheckedQuad<T>`(버전 체크 검증 대상(타입 전용): `quad-types`의 `CheckedQuad<T, Pattern>`(버전
type function)가 실제 `quad-base`가 구현하는 `Quad` 타입과 맞물려 패턴 체크, `type-version-check` 패키지 위에 얹힘)가 실제 `quad-base`가
동작하는지 — 그리고 검증 후에도 `AddPlugin<Self,P>`의 제네릭 체이닝이 구현하는 `Quad` 타입과 맞물려 동작하는지 — 그리고 검증 후에도
안 깨지는지. `AddPlugin<Self,P>`의 제네릭 체이닝이 안 깨지는지.
배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad): 배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad):
QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서, QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서,
pesde의 semver 충돌 방지가 이 관계엔 안 걸리게 됨(선언된 의존성이 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 자체가 실패한 걸로 판정돼서 1. `error()`는 못 씀 — type function 자체가 실패한 걸로 판정돼서
@ -20,15 +20,20 @@
CheckVersion<T>`) 제네릭이 인스턴스화될 때마다 재평가되지 않아 CheckVersion<T>`) 제네릭이 인스턴스화될 때마다 재평가되지 않아
진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로 진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로
해석돼야 하는 자리**에 박아 넣어야 함. 해석돼야 하는 자리**에 박아 넣어야 함.
3. **[가장 중요, 가장 늦게 발견]** `CheckVersion<T>`가 `T`를 단순 3. **[가장 중요]** `CheckVersion<T>`가 `T`를 단순 패스스루(`return
패스스루(`return t`)해도, 그 결과 타입이 **한 번이라도 type t`)해도, 그 결과 타입이 **한 번이라도 type function을 거쳤다는
function을 거쳤다는 이력만으로** 이후 `AddPlugin<Self,P>` 같은 이력만으로** 이후 `AddPlugin<Self,P>` 같은 제네릭 self 메소드
제네릭 self 메소드 체이닝이 조용히 깨짐("Expected this to be 체이닝이 조용히 깨짐. 검증 결과는 **원본 타입과 절대 안 섞이는
exactly 'P & Self', but got 'P & Self'"류의 앞뒤가 같은 의미 없는 별도 가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를
진단). 그래서 검증 결과는 **원본 타입과 절대 안 섞이는 별도 실제로 참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck`
가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를 실제로 줄이 그 필수 스텝을 보여줌.
참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck` 줄이 4. **[신규]** cross-package 사용에는 `type function` 선언에 `export`가
그 필수 스텝을 보여줌. 필요하다(`type function`이 아니라 `export type function`) — 안
그러면 다른 파일에서 `Unknown type 'Module.CheckVersion'`으로
막힘. 그리고 명시적 제네릭 인스턴스화가 **2개 이상**이면 단일
꺾쇠(`Foo<A, B>`)가 비교 연산자로 오파싱돼 반드시 이중 꺾쇠
(`Foo<<A, B>>`)를 써야 한다(코퍼스에 이미 있던 `AttributeKey<<T>>`
관례와 같은 이유).
실행: `luau-analyze 23-type-quadtypes-checkversion-addplugin.luau` 실행: `luau-analyze 23-type-quadtypes-checkversion-addplugin.luau`
]] ]]
@ -38,8 +43,9 @@ local QuadTypes = require("../../../quad-types/src")
type RobloxExt = { Frame: (self: any) -> string } type RobloxExt = { Frame: (self: any) -> string }
-- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번 -- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번
-- 함정에 걸림) -- 함정에 걸림). 여기선 quad-base와 정확히 같은 버전만 허용("0.0.0" 그대로) —
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T> -- quad-spring-roblox류 느슨한 소비자는 "0.*.*" 같은 패턴을 대신 씀.
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T, "0.0.0">
return quad :: any return quad :: any
end end

View file

@ -0,0 +1,116 @@
# 2026-08-19, 여덟 번째 세션 — `type-version-check` 패키지 추출, `CheckedQuad<T, Pattern>` 확장
**요약**: 직전(일곱 번째) 세션이 만든 `CheckedQuad<T>`(정확 버전 일치만
지원)가 `quad-spring`/`quad-spring-roblox`류 독립 게시 플러그인엔 너무
빡빡하다는 사용자 지적으로 시작. 글롭(`"*"`)/캐럿(`"N^"`) 패턴 매칭을
지원하도록 `CheckedQuad<T, Pattern>`으로 확장하고, 그 매칭 로직 자체는
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<<A, B>>`)가 필요(코퍼스의 기존
`AttributeKey<<T>>` 관례와 동일 이유).
`index<T, "Version">` 내장 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> = T & { __versionCheck: CheckVersion<T> }`
`CheckedQuad<T, Pattern> = T & { __versionCheck:
TypeVersionCheck.CheckVersion<index<T, "Version">, 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<T,
Pattern>` 섹션 갱신 + 남은 것에 `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<T, Pattern>`으로 갱신 + 절 인용
수정), `.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<T>`(콤마 없는 단일 파라미터) 잔존 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` 독립 분리는 사용자 본인이 나중에 직접 진행.

View file

@ -161,6 +161,17 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
없는지, (3) `git worktree list`에 다른 에이전트 워크트리가 없는지를 없는지, (3) `git worktree list`에 다른 에이전트 워크트리가 없는지를
확인했습니다. 이 항목은 기록용으로만 남겨둡니다. 확인했습니다. 이 항목은 기록용으로만 남겨둡니다.
## 9. **[2026-08-19 신설, 안 막음]** `type-version-check` 독립 저장소로 분리
`type-version-check/`(컴파일 타임 버전 패턴 매칭 — 글롭/캐럿, `quad-types`
`CheckedQuad<T, Pattern>`이 이 위에 얹힘)는 quad에 종속되지 않은 범용
유틸이라 **사용자가 직접 독립 저장소로 분리할 예정**("우선 이 프로젝트
안에 넣어둬줘. 나중에 내가 다른 프로젝트로 분리해줄게.", 2026-08-19).
지금은 quad 워크스페이스의 네 번째 멤버(`workspace_members`)로만 있음 —
에이전트가 먼저 나서서 분리하지 말고 사용자가 하라고 할 때까지 대기.
설계/구현 상세는 `.claude/base/quad-types-plan.md`
"`type-version-check`" 절.
## 3. `.claude/question.md`**나머지** 항목 검토 (급하지 않음) ## 3. `.claude/question.md`**나머지** 항목 검토 (급하지 않음)
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로

View file

@ -13,3 +13,6 @@ roblox = "quad-roblox"
[workspace."qwreey/quad_types"] [workspace."qwreey/quad_types"]
roblox = "quad-types" roblox = "quad-types"
[workspace."qwreey/type_version_check"]
luau = "type-version-check"

View file

@ -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", "quad-types"] workspace_members = ["quad-base", "quad-roblox", "quad-types", "type-version-check"]
[target] [target]
environment = "roblox" environment = "roblox"

View file

@ -8,6 +8,16 @@ target = "roblox"
[graph."qwreey/quad_types@0.0.0 roblox"] [graph."qwreey/quad_types@0.0.0 roblox"]
direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"] 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] [graph."qwreey/quad_types@0.0.0 roblox".pkg_ref]
ref_ty = "workspace" ref_ty = "workspace"
path = "quad-types" 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"

View file

@ -8,6 +8,16 @@ target = "roblox"
[graph."qwreey/quad_types@0.0.0 roblox"] [graph."qwreey/quad_types@0.0.0 roblox"]
direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"] 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] [graph."qwreey/quad_types@0.0.0 roblox".pkg_ref]
ref_ty = "workspace" ref_ty = "workspace"
path = "quad-types" 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"

View file

@ -4,3 +4,10 @@ format = 2
name = "qwreey/quad_types" name = "qwreey/quad_types"
version = "0.0.0" version = "0.0.0"
target = "roblox" 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"

View file

@ -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]
type_version_check = { workspace = "qwreey/type_version_check", version = "^", target = "luau" }

View file

@ -15,6 +15,8 @@
(`.claude/base/project-setup-plan.md`/`session/2026-08-19-07-*.md` 참고) (`.claude/base/project-setup-plan.md`/`session/2026-08-19-07-*.md` 참고)
]] ]]
local TypeVersionCheck = require("./luau_packages/type_version_check")
export type Quad = { export type Quad = {
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
debug: boolean, debug: boolean,
@ -24,57 +26,30 @@ export type Quad = {
} }
--[[ --[[
`CheckVersion<T>` — 주입된 값의 실제 타입 `T`가 이 패키지가 아는 `CheckedQuad<T, Pattern>` — 주입된 값의 실제 타입 `T`가 `Pattern`
`Quad.Version`과 일치하는지 컴파일 타임에 확인. (글롭/캐럿 문자열, `type-version-check` 패키지 참고 — `"*"`/`"N^"`/정확값)에
맞는 `Version`을 갖는지 컴파일 타임에 확인. quad-base/quad-roblox
자신은 항상 같은 모노레포에서 같이 개발되므로 지금은 정확히 일치하는
`"0.0.0"` 패턴으로만 쓰지만(아래 사용법), quad-spring-roblox류
**독립적으로 게시되는 백엔드 플러그인**은 `"0.*.*"`처럼 느슨한 패턴을
직접 골라 쓸 수 있다 — 그래서 패턴을 이 타입의 파라미터로 열어둠.
**⚠️ 반드시 "가상 필드"로만 쓸 것 — `T`를 직접 패스스루하지 말 것.** **⚠️ 반드시 "가상 필드"로만 쓸 것 — `T`를 직접 패스스루하지 말 것.**
처음엔 `return t`(단순 패스스루)로 짜서 단독으로는 잘 됐는데, `TypeVersionCheck.CheckVersion<Actual, Pattern>` 자체가 이미 이 규칙을
**`AddPlugin<Self,P>`처럼 제네릭 self 파라미터를 쓰는 메소드와 지키게 설계돼 있다(트리비얼한 `true`만 반환, `T`를 절대 참조/재구성
체이닝하면 조용히 깨짐**을 실측으로 확인함(2026-08-19) — 값의 정적 안 함) — `type function`을 거친 값은 패스스루라도 이후 `AddPlugin`
타입이 한 번이라도 `type function`을 거치면, 그 뒤 제네릭 self 추론이 같은 제네릭 self 메소드 체이닝이 조용히 깨진다는 게 실측 확인됐기
"Expected this to be exactly 'P & Self', but got 'P & Self'"류의 때문(`typing-limits.md` §6, `type-version-check/src/init.luau`
앞뒤가 같은 의미 없는 진단을 내며 깨진다(재구성이 아니라 **패스스루도 참고).
똑같이** 깨짐 — 재구성 비용 문제가 아니라 "그 타입이 type function을
거쳤다는 이력 자체"가 문제였음). 그래서 검증 결과를 **원본 타입과
절대 안 섞이는 별도 필드**로 격리해야 한다 — `CheckVersion<T>` 자체는
`T`를 절대 참조/반환하지 않고, 성공 시 트리비얼한 `true` 하나만 반환.
**사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**: **사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**:
```lua ```lua
type Checked<T> = T & { __versionCheck: CheckVersion<T> } local checked: QuadTypes.CheckedQuad<typeof(injectedQuad), "0.0.0"> = injectedQuad :: any
local function CheckQuad<T>(quad: T): Checked<T>
return quad :: any
end
local checked = CheckQuad(injectedQuad)
local _ = checked.__versionCheck -- ⚠️ 이 줄이 없으면 체크가 조용히 스킵됨(lazy 평가) local _ = checked.__versionCheck -- ⚠️ 이 줄이 없으면 체크가 조용히 스킵됨(lazy 평가)
-- 이후 checked는 injectedQuad와 완전히 같은 타입(원본 그대로) — -- 이후 checked는 injectedQuad와 완전히 같은 타입(원본 그대로) —
-- AddPlugin 체이닝 등 뒤이은 제네릭 연산이 전부 안전하게 동작함 -- AddPlugin 체이닝 등 뒤이은 제네릭 연산이 전부 안전하게 동작함
``` ```
**에러 표시 방식(실측 확인)**: 불일치 시 `error()`가 아니라
`print("메시지")` + `return types.never`를 쓸 것 — `error()`는 "type
function 자체가 실패함"으로 판정돼 버려서 못 쓰고, `print`+`never`
조합만 호출부에 정확히 "TypeError: <메시지>"로 뜬다.
]] ]]
-- selene: allow(undefined_variable) — `types`는 `type function` 블록 안에서만 export type CheckedQuad<T, Pattern> = T & { __versionCheck: TypeVersionCheck.CheckVersion<index<T, "Version">, Pattern> }
-- 주입되는 특수 전역이라 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 {} return {}

View file

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

View file

@ -0,0 +1,8 @@
name = "qwreey/type_version_check"
version = "0.0.0"
description = "컴파일 타임 버전 패턴 매칭 — type function 하나로 semver 비슷한 문자열 리터럴 타입을 글롭(*)/캐럿(N^) 패턴과 대조. quad에 종속되지 않은 범용 유틸(quad-types의 CheckedQuad<T, Pattern>이 이 위에 얹힘). 현재는 quad 모노레포 안 워크스페이스 멤버로 두지만, 다른 프로젝트에서도 쓸 수 있게 독립 패키지로 분리 예정(HUMAN_TODO.md 참고) — 지금부터 quad 전용 타입/이름을 안 섞을 것."
includes = ["src/*"]
[target]
environment = "luau"
lib = "src/init.luau"

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

View file

@ -0,0 +1,130 @@
--!strict
--[[
컴파일 타임 버전 패턴 매칭 — `quad`에 종속되지 않은 범용 유틸.
**[2026-08-19 신설, quad 저장소 안에 임시로 둠]** 지금은 quad
워크스페이스의 네 번째 멤버로 두지만, 사용자가 나중에 독립 저장소로
직접 분리할 예정(`HUMAN_TODO.md` 참고) — 그래서 이 파일 안에 `Quad`류
quad 전용 이름/타입을 절대 안 섞는다. `quad-types`의
`CheckedQuad<T, Pattern>`이 이 위에 얹히는 소비자 중 하나일 뿐.
**패턴 문법**: `.`로 나뉜 각 자리가
- `"*"` — 와일드카드(뭐든 통과)
- `"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>` — `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<Self,P>`류)를 나중에 부르면 조용히 깨지는 게 실측
확인됐기 때문(`typing-limits.md` §6). 예시는 `quad-types`의
`CheckedQuad<T, Pattern>` 구현 참고.
]]
-- 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,
}