Compare commits

..

11 commits

Author SHA1 Message Date
871c582771
tooling: 핸드오버 준비 — session-summary.md 색인 공백 + ROADMAP/CLAUDE.md stale 정정
session-summary.md에 오늘 세션 04~08 색인 항목이 통째로 빠져 있던 걸 발견해
신설. 더 크게는 CLAUDE.md/project-context.md/ROADMAP.md가 "구현 아직 시작
전"이라는 낡은 전제를 깔고 있었는데, 실측해보니 M0 스파이크 4개와 M1
스캐폴딩 대부분이 이미 완료돼 있어 전부 정정(ROADMAP 체크박스 갱신 포함,
wally.toml→pesde.toml 표기도 같이 정정). quad-roblox-types 백로그는
todos.md/ROADMAP.md M5에 짧은 포인터 보강.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 20:47:31 +09:00
5dfc9b9a43
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>
2026-08-19 17:39:57 +09:00
297c4d459c
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>
2026-08-19 17:09:16 +09:00
1de031e139
design: RunInit vs 백엔드 유일 슬롯 가드 분리 확정 — _initializedBy 유지
사용자 결정: RunInit(함수 identity 추적)은 backend 설치 진입점
(QuadRoblox 등)에 재사용하지 않는다. 대신 bind-system-plan.md 3차
라운드가 이미 정해둔 _initializedBy 문자열 마커(같은 팩토리 재호출=
no-op, 다른 팩토리=에러)를 그대로 별도 메커니즘으로 유지 — "멱등 실행"과
"유일 슬롯 점유"는 의미가 달라 억지로 합치면 RunInit의 단순함만 깨짐.
실제 InitRoblox 구현 예시 의사코드 추가(M5 실착수 시 RobloxFactory.luau
참고용).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 16:26:34 +09:00
9c3bfc890a
design: RunInit 재설계(함수를 릴레이션 키로) + darklua 경계 실측 정밀화
New()의 멱등 Init 가드를 파일마다 Relate+센티널을 두던 방식에서
module:RunInit(initFn) 하나로 통합 — 함수 자체를 릴레이션 키로 써서
"이 함수, 이 모듈에 실행했는가"를 (module, initFn) -> boolean? 하나로
표현(사용자 제안). Debug/init.luau는 가드 없이 순수 뮤테이션만 하도록
단순화. smoke.init.luau로 재호출 무시/인스턴스별 독립/함수별 독립 3개
시나리오 검증(luau/luau-analyze/selene 클린).

darklua process를 실제로 돌려 @self/@game은 안 건드리고 커스텀 .luaurc
alias(@pkg)만 script.Parent류로 치환한다는 걸 확인 — project-setup-plan.md의
darklua 기각 근거를 "지금은 커스텀 alias를 안 쓰니 불필요, 나중에 도입하면
그때 필요해짐"으로 정밀화.

⚠️ 미결: RunInit을 backend 설치 진입점(QuadRoblox(Quad))에도 재사용할지
— 함수 identity 추적으로는 "다른 팩토리 재호출은 에러" 계약을 못 만족.
module-lifecycle-plan.md에 반영, M2/M5 착수 전 확인 필요.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 16:17:08 +09:00
ef3d952dd4
tooling: rokit → mise 전환 + selene 린터 도입 (roblox-project-example 벤치마킹)
Word30210/roblox-project-example(initreq/에 클론)를 참고해 두 가지 채택:
(1) rokit.toml → mise.toml — mise install이 pesde/rojo/luau-lsp/selene을
GitHub attestation+SLSA provenance 검증까지 거쳐 설치하는 걸 이 샌드박스
에서 직접 확인(rokit은 끝내 검증 불가), 이 환경 자체가 이미 mise로
luau를 관리 중이라 더 자연스러움. (2) selene 린터 — 참고 레포의
selene.toml을 패키지별로 채택, 단 CWD 상대 config 탐색 함정을 발견
(루트 단일 설정으로 두면 다른 디렉토리에서 실행 시 조용히 Lua 5.1
std로 폴백해 Luau 타입 문법 전체가 파싱 에러로 잘못 보임) — 참고 레포
그대로 패키지별 selene.toml + 패키지 안에서 실행하는 걸로 확정. 도입
즉시 smoke.mock.luau의 assert 메시지 누락 3건을 잡아 수정.

darklua의 convert_require 변환은 검토 후 기각 — 사용자 판단: Roblox
엔진 자체도 이미 같은 require-by-string 의미론(@self/@game)을 지원해
변환 계층이 불필요.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 15:09:35 +09:00
2de2f99cc4
tooling: 에디터 Luau 솔버 설정 확정 — luau-lsp 설치해 새 솔버 필요성 실측
luau-lsp 1.69.0을 pesde/rojo와 같은 방식으로 직접 설치해
--flag:LuauSolverV2=true/false로 spike 08을 대조(옛 솔버 에러 3건 vs
새 솔버 1건) — HUMAN_TODO 6번이 사람에게 넘겨뒀던 "실제 에디터에서
확인"을 CLI 분석 모드로 대신 검증. quad/.vscode/settings.json에
enableNewSolver:true 반영, rokit.toml에 luau-lsp 핀 추가. 부수로
typing-limits.md 1번의 핵심 주장(0 진단으로 조용히 새는 것)이 Luau
0.734에서도 그대로 재현됨을 별도로 재확인(정정 불필요).

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:37:17 +09:00
0b471535a3
qa: luau-test 스파이크 13 재작성 — 타입/런타임 분리 + PostRef까지 확장
13은 타입(A)/런타임(B) 두 섹션이 한 파일에 있었는데 A의 더미 스텁이
런타임 실행 시 크래시를 내 B가 전혀 검증되지 못하고 있었음(STATUS.md
지적 사항). 13은 타입 전용으로 남기고(PostRef<T>도 Ref<T>를 만족하는지
추가), 런타임 절반은 신규 22로 분리 — isPreRef/isPostRef가 서로 배타적
형제이고 Leaf 핸들러 흉내가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지
확인. 둘 다 done/으로.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:31:56 +09:00
7e4f2a77fe
tooling: Rojo 설치·검증 — pesde workspace symlink는 Studio 배포와 무관함 확인
pesde처럼 rojo도 /code/.local/bin에 직접 설치(7.7.0, rokit.toml 핀과
일치). rojo sourcemap/build가 quad-roblox/roblox_packages의 심볼릭
링크(quad-base로의 workspace 의존성)를 실제 파일까지 투명하게 따라감을
확인 — 이전 세션이 찾은 "luau CLI가 symlink를 안 따라간다"는 문제는
standalone CLI 전용이고 Rojo/Studio 배포 경로엔 영향 없음이 확정됨.
덤으로 luau-lsp가 rojo를 감지해 자동으로 sourcemap을 watch하는 것도
확인 — wally가 안고 있던 에디터 타입 링킹 단절 문제가 이 구성에서
재현 안 됨.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:27:43 +09:00
0c4c4a0537
qa: M0 luau-test 스파이크 05 재작성 통과 + 21 신규(Store 미선언 키) 반영
05는 "emit은 항상 전파, 재계산만 :Get() 캐시로 dedup" 현행 모델로
재작성해 rewrite-required/ -> done/ 이동. 21은 todos.md 00번이 요구하던
"Store 미선언 키 타입 에러" 확인 신규 스파이크 — ProcessStoreType 결과
타입이 미선언 키 접근을 정확히 TypeError로 거부함을 확인, store-plan.md의
"아마" 표시를 해소. STATUS.md/README.md/todos.md 텍스트도 같이 갱신.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:24:19 +09:00
205af32da4
tooling: 프로젝트 셋업 문서화 + wally→pesde 전환, M1 스캐폴딩 골격
pesde 워크스페이스 실제 설치·검증(패키지명 하이픈 금지, workspace 의존성
문법, per-package pesde.lock), init.luau의 @self require 규칙(Luau RFC
확인), 워크스페이스 의존성이 심볼릭 링크라 luau CLI의 require-by-string과
충돌하는 함정을 base/project-setup-plan.md로 정리. architecture.md
패키징 방식 절도 같이 정정. Relate.luau/New()-InitXxx 골격/mock 하네스는
이 구조를 실제로 검증하는 과정에서 나온 최소 스캐폴딩.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-19 14:01:07 +09:00
56 changed files with 3291 additions and 466 deletions

File diff suppressed because one or more lines are too long

View file

@ -177,17 +177,58 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제 아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제
폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션. 폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션.
**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립 **패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지
wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의 매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의
타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0` 타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립
에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/ 패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정
RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체 (2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다
`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc` 툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다
`aliases`**런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가 독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을
지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기 pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트
자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로 `workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }`
쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만 의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제
유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수. 자체도 이 전환으로 같이 해소됨 — **[2026-08-19 같은 날 후속 세션]**
pesde/rojo 바이너리를 이 샌드박스에 직접 설치해 `pesde install`/`rojo
build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup-plan.md`가
소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게
따라감).
실제 구현: 루트 `pesde.toml`(`private = true`,
`workspace_members = ["quad-base", "quad-roblox", "quad-types",
"type-version-check"]`) +
`quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml`
(각각 `[target] environment = "roblox"`) + `type-version-check/pesde.toml`
(`[target] environment = "luau"`, 아래 참고), 툴체인은 `mise.toml`로 핀
(`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는
판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). **[2026-08-19 같은
날 후속]** `type-version-check`(`[target] environment = "luau"` — quad에
종속되지 않은 범용 패키지라 다른 멤버와 달리 roblox가 아님)는 워크스페이스
네 번째 멤버로 추가됐고, `quad-types`가 이것에 workspace 의존(자기 target이
roblox라 명시적으로 `target = "luau"` 지정 필요) — `base/quad-types-plan.md`
"`type-version-check`" 절이 소스. 사용자가 나중에 독립 저장소로 분리할
예정(`HUMAN_TODO.md` 9번).
**[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox``quad-base`
아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace
의존** — `quad-base``QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임
주입**받으므로 pesde 의존 선언이 필요 없고, 오히려 무거운 quad-base
전체를 dev-dependency로 두면 게시 후 소비자 환경에서 그 타입 전용
require가 크래시하는 문제가 있어 별도 패키지로 뽑음 —
`base/quad-types-plan.md`가 소스.
pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그
파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`
이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스
루트 자신의 통합 개발/테스트용, 게시되는 패키지 안이 아니므로).
`.luaurc``aliases`는 여전히 **런타임 require에서 엔진이 지원 안 함**
(2026-08-19 이 세션에 커스텀 alias로 직접 재확인 — `require("@alias/x")`
"could not jump to alias"로 실패) — 그래서 alias는 편집기 자동완성/타입체크용으로만
곁들이고, 실제 크로스패키지 require는 상대경로로 쓴다(단, `init.luau`
안에서는 평범한 상대경로가 아니라 **예약 alias `@self`**를 써야 함 —
`init.luau`는 require-by-string 상 "자기 폴더 자체"를 가리키므로 `./`
아니라 `@self/`로 그 폴더 안의 형제 파일에 접근한다, 2026-08-19 사용자 지적
+ Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인 — 실제로 이
세션의 `quad-base/src/init.luau`가 이 착오로 크로스파일 require가 전부
깨졌다가 `@self`로 고치고 나서야 정상화됨). 나중에 실제로 레포를 쪼갤 때는
Rojo `project.json`의 트리 매핑 규칙만 유지하면 되고, require는 그 시점에
한 번 기계적으로 바꾸는 정도로 감수.
**패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도 **패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도
재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라 재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라
@ -206,10 +247,18 @@ 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<T,Pattern> 버전체크(`base/quad-types-plan.md`)
│ ├── pesde.toml # type_version_check workspace 의존(target="luau")
│ └── src/init.luau
├── type-version-check/ # quad에 종속되지 않은 범용 버전 패턴 매칭(`base/quad-types-plan.md` "`type-version-check`" 절) — 사용자가 나중에 독립 저장소로 분리 예정(HUMAN_TODO 9번)
│ ├── pesde.toml # [target] environment = "luau"
│ └── src/init.luau # matchesPattern(런타임) + export type function CheckVersion
├── quad-base/ ├── 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회 실행) 전부 여기 소속
@ -239,7 +288,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" 절)

View file

@ -85,67 +85,105 @@ export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제
**순서 의존성은 각 `InitXxx``require`처럼 멱등하게 만들어서 해소한다** **순서 의존성은 각 `InitXxx``require`처럼 멱등하게 만들어서 해소한다**
(2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()` (2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`
직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저 직접 관리할 필요가 없다.
**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`
weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미 **[2026-08-19 같은 날 후속 정정] 멱등 가드는 `RunInit` 하나로 통합됨 —
됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require` 파일마다 `Relate()`/`INITED` 센티널을 따로 두지 않는다.** 원래는 각
캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는 `InitXxx` 파일이 자기 톱레벨에 `local relate = Relate()`를 두고 파일
한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을 전용 센티널 키(`INITED`)로 "이 `module`에 이미 Init됐는가"를 기록하는
여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프 보일러플레이트를 파일마다 반복했는데, **사용자 지적**: 그 반복 자체가
캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다. 불필요하다 — **함수 자기 자신을 릴레이션 키로 쓰면** 센티널이 따로
필요 없다(`Relate<T, (any)->any, boolean>`이 곧 "이 함수, 이 모듈에
실행했는가" 표가 됨). `module` 인스턴스마다 공유하는 `RunInit` 메서드
하나가 이 판단을 전담하고, 개별 `InitXxx` 파일은 **가드 없이 그냥
뮤테이션만** 하면 된다:
```lua ```lua
-- Dispatch/init.luau -- 최상위 init.luau
local Relate = require(...) local Relate = require(...)
local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스 local runInitRelate = Relate() -- (module, initFn) -> 이미 실행됐는가, 파일 스코프 공유
local function Init(module) local function New(): Quad
if relate:GetStrong(module, INITED) then local module = { New = New } :: Quad
return -- 이미 이 module 인스턴스엔 Init됨, no-op
function module.RunInit(self, initFn)
if runInitRelate:GetStrong(self, initFn) then
return -- 이 module 인스턴스에 이 initFn은 이미 실행됨, no-op
end
runInitRelate:SetStrong(self, initFn, true) -- 실행 전에 먼저 표시(순환 의존 대비)
initFn(self)
end end
relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고)
-- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음 module:RunInit(InitDebug)
-- 예: InitLifetime(module) -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 추가
local ... return module
module.Dispatch = ... end
```
```lua
-- Debug/init.luau — 가드 없이 그냥 뮤테이션만(RunInit이 이미 1회만 보장)
local function Init(module)
module.debug = false
end end
return Init return Init
``` ```
이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`require`가 - **의존성을 갖는 `InitXxx`도 패턴이 그대로 유지된다** — 자기 의존성을
의존 그래프를 알아서 풀어주는 것과 같은 감각), 최상위 `New()`는 순서를 `module:RunInit(InitLifetime)`처럼 부르기만 하면 되고, `RunInit` 자체가
신경 쓰지 않고 아는 `InitXxx(module)`을 전부 호출해도 된다 — 이미 누가 멱등하므로 여러 `InitXxx`가 같은 의존성을 부르는 순서/중복은 걱정할
먼저 채웠으면 알아서 스킵된다. 필요 없다(옛 설계와 결론은 같음, 가드 소유 위치만 파일별→공유로 이동).
- **GC와도 자연히 맞물림**`relate-plan.md`의 "API" 절에 따르면
- **GC와도 자연히 맞물림**: `relate-plan.md`의 "API" 절에 따르면 `Relate` `Relate`의 **첫 인자(`inst`, 여기선 `module`)는 항상 weak**이므로,
**첫 인자(`inst`, 여기선 `module`)는 항상 weak**다(선택의 여지가 없는 `Quad` 인스턴스가 더 이상 참조되지 않아 수거되면 이 Init-완료 기록도
고정 동작 — `Weak`/`Strong` 구분은 오직 `value` 쪽 보관 방식만 가리킴). 같이 사라진다(별도 정리 로직 불필요, `value``SetStrong` — boolean
그래서 `value` 쪽을 `SetWeak`으로 두든 `SetStrong`으로 두든 상관없이, 리터럴이라 GC 결과엔 무관하지만 "다른 곳에서 안전하게 유지되는 것만
어떤 `Quad` 인스턴스(전체 `module` 테이블)가 더 이상 참조되지 않아 `SetWeak`" 일반 규칙에 맞음).
수거되면 이 Init-완료 기록도 같이 사라진다 — 별도 정리 로직 불필요.
`relate-plan.md`가 이미 확정해둔 "각 모듈이 자기 톱레벨에 `Relate()`
하나를 두고 재사용" 관례를 그대로 쓰는 것이라 새 메커니즘 아님.
(`relate-plan.md`가 별도로 명시한 "명시적으로 만든 기록은 명시적으로
지울 것" 원칙과 충돌하는 게 아니라 — 이 경우는 기록의 키 자체가 죽으면
그 기록을 다시 조회할 주체 자체가 사라지므로 지울 대상이 없어지는,
원칙이 애초에 상정하지 않은 자리다.)
- **`value``SetStrong`으로 통일**: 위 문단대로 GC 결과엔 차이가 없지만
(boolean 리터럴은 애초에 GC 대상이 아님), `relate-plan.md`의 일반 규칙
"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 기준으로는 이 `true`
플래그를 다른 어디도 붙잡고 있지 않으므로 `SetStrong`이 그 규칙에 맞는
선택이다.
- **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제 - **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제
정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend 정의는 `base/bind-system-plan.md`)와는 여전히 다른 층위** — 그건
팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개 backend 팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야
계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base 하는 공개 계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, `RunInit`
내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현 quad-base 내부 서브시스템이 **한 번만** 도는지만 보면 되는 사적 구현
디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지 디테일이라 "다른 호출자면 에러" 분기 자체가 없다.
않게 구분해서 쓸 것. **[2026-08-19 해소, 사용자 결정] `RunInit`은 backend 설치에 재사용
안 함 — `_initializedBy` 마커를 그대로 별도로 둔다.** 근거는 위에서
이미 짚은 그대로: `RunInit`은 "이 함수가 이미 돌았는가"만 답하는
함수-identity 추적이라 "이 *슬롯*을 다른 함수가 이미 채웠는가"(다른
팩토리 재호출 = 에러)를 표현 못 함 — 억지로 슬롯 키를 얹어 확장하면
"멱등 실행"과 "유일 슬롯 점유"라는 서로 다른 두 의미가 API 하나에
섞여 `RunInit`의 단순함이 깨짐. `_initializedBy``bind-system-plan.md`
3차 라운드가 이미 확정해둔 그대로 문자열 마커 하나로 남김:
```lua
-- 예시(quad-roblox, M5 실제 구현 시)
local function InitRoblox(module)
if module._initializedBy == "roblox" then
return module -- 같은 팩토리 재호출 = no-op
end
if module._initializedBy ~= nil then
error(`Quad module already initialized by '{module._initializedBy}'`)
end
module._initializedBy = "roblox"
-- ... 실제 백엔드 설치(bindLifetime/canBound/addTag/removeTag/setAttribute 등 주입)
return module
end
```
`RunInit`(quad-base 내부 서브시스템, 함수 identity 추적)과
`_initializedBy`(backend 유일 슬롯, 문자열 마커 + 다른 값이면 에러)는
계속 **서로 다른 메커니즘**으로 남는다 — 이름이 겹치지 않게 쓸 것.
실제 `RobloxFactory`/`InitRoblox` 구현은 M5(`architecture.md` 소스
트리의 `quad-roblox/src/RobloxFactory.luau`)에서.
- **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `InitA`↔`InitB`처럼 - **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `InitA`↔`InitB`처럼
상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저 상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저
표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시 표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시
미완성 exports를 돌려주는 것과 같은 이유로, 실제 작업 시작 전에 먼저 미완성 exports를 돌려주는 것과 같은 이유로, 실제 작업 시작 전에 먼저
"완료"로 표시해둔다. "완료"로 표시해둔다.
- **실측**: `quad-base/src/init.luau`+`Debug/init.luau`가 위 코드
그대로 구현돼 있고, `quad-base/test/smoke.init.luau`가 (a) 같은
`initFn`을 여러 번 `RunInit`해도 1회만 실행, (b) `New()`로 만든
서로 다른 인스턴스는 기록을 공유하지 않음(각자 독립 1회), (c) 서로
다른 `initFn`은 서로의 실행 여부에 영향 안 줌 — 셋 다 `luau`/
`luau-analyze`/`selene` 클린으로 확인.
## Bind는 누가, 어떻게 구현하는가 ## Bind는 누가, 어떻게 구현하는가

View file

@ -0,0 +1,373 @@
# 프로젝트 셋업 — pesde 워크스페이스, `.luaurc`, require 구조
**상태**: base — `architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절이
정한 소스 트리를 **실제로 pesde/luau CLI로 셋업해보고 검증한 결과**. 그
절은 "무엇을 어디에 두는가"까지만 다루고 "그걸 실제로 어떻게 굴리는가"는
비워뒀는데, 이 문서가 그 나머지 — 패키지 매니저 조작, require 문법,
현재 환경에서 확인된 한계까지. **[2026-08-19 신설, 같은 날 pesde 실제
설치·`pesde install` 실행으로 검증]**
**전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지
M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격
`New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음
(`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde
규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만,
"무엇이 구현됐는가"는 이 문서가 아니라 `todos.md`/`ROADMAP.md`를 볼 것.
## 왜 wally가 아니라 pesde인가
**사용자 결정(2026-08-19)**: dev-dependency를 1급으로 지원하는 등 wally보다
툴링이 낫다는 판단. 상세 배경/재검토는 `architecture.md`의 "구현 착수:
소스 트리 구조 확정" 절 "패키징 방식" 문단이 소스 — 여기서 반복하지
않음. 요지만: 모노레포 모양(루트 통합 개발, 서브패키지마다 독립 게시)
자체는 안 바뀌고, pesde의 네이티브 workspace 기능이 그 모양에 그대로
들어맞는다.
## pesde 워크스페이스 구조
**전체 멤버 목록·최신 트리는 `architecture.md`의 "구현 착수: 소스 트리
구조 확정" 절이 소스** — 새 워크스페이스 멤버가 늘 때마다 그쪽만 갱신하면
되게 하기 위해 여기서 다시 나열/개수 세지 않는다(멤버 수가 실제로 2→3→4로
늘어난 이력에서 이 문서의 구식 트리가 갱신을 놓쳤던 게 계기). 아래는 그
구조가 실제로 동작하는 **pesde 메커니즘 자체**(문법/함정)만 다룬다 —
예시엔 최소 2-멤버 형태만 남겨두고 서술 부담을 줄임:
```
quad/
├── pesde.toml # 워크스페이스 루트, private = true, workspace_members
├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀
├── quad-base/
│ ├── pesde.toml # name = "qwreey/quad_base"
│ └── selene.toml
└── quad-roblox/
├── pesde.toml # name = "qwreey/quad_roblox", quad_base에 workspace 의존
└── selene.toml
```
- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members`
(멤버 목록은 `architecture.md`가 소스). `[target] environment = "roblox"`
필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이
세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후
재확인 후보).
- **서브패키지 `pesde.toml`**: `[target] environment = "roblox"` +
`build_files = ["src"]` + `lib = "src/init.luau"`. `quad-roblox`
`[dependencies] quad_base = { workspace = "qwreey/quad_base", version =
"^" }`.
- **⚠️ 패키지 이름은 `a-z`/`0-9`/`_`만 허용 — 하이픈 금지**(`pesde
install` 실측: `qwreey/quad-base`는 파싱 단계에서 바로 거부됨, 에러
메시지가 "did not match any variant of untagged enum
DependencySpecifiers"로 나와서 원인 파악에 혼동을 줌 — 실제 원인은 이름
문자 제약이지 의존성 선언 문법이 아니었음). **`quad_base`/`quad_roblox`로
확정** — 폴더 이름(`quad-base`/`quad-roblox`)은 `architecture.md`
이미 확정해둔 것이라 그대로 두고, `pesde.toml``name` 필드만 언더스코어로
다르게 쓴다. 둘이 다르다는 걸 헷갈리지 말 것.
- **`pesde add`는 워크스페이스 멤버를 자동으로 못 찾는다** — 레지스트리
검색 전용 커맨드라 로컬 워크스페이스 형제를 이름으로 넘기면
"package not found"로 실패한다. `[dependencies]``workspace = "scope/name"`
줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`
받아들이는 걸 확인함).
- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 **모든
워크스페이스 멤버**가 스캔·링크됨(개수는 `architecture.md`
`workspace_members`가 소스 — 새 멤버가 늘어도 이 동작은 안 바뀜).
- **[2026-08-19 후속, `type-version-check` 신설 때 실측] 의존하는
워크스페이스 멤버의 `target`이 자기 자신의 기본 target과 다르면
`workspace = "..."` 의존 선언에 `target = "..."`를 명시해야 한다.**
`quad-types`(기본 target `roblox`)가 `type-version-check`(자기
`[target] environment = "luau"`)에 의존할 때, `target` 없이 `{
workspace = "qwreey/type_version_check", version = "^" }`만 쓰면
`pesde install`이 `no workspace member found with name
qwreey/type_version_check and target roblox`로 실패한다 — `target =
"luau"`를 추가해야 해소됨.
## 툴체인 — `rokit.toml`에서 `mise.toml`로 전환 (2026-08-19)
원래는 `initreq/vide`(참고 레포)의 `rokit.toml` 선례를 따랐음(같은 날
`pesde`/`rojo`/`luau-lsp` 셋 다 `/code/.local/bin`에 직접 다운로드해
설치·핀과 버전 일치까지 검증). **같은 날 후속 세션에 `mise.toml`
재전환** — 사용자 결정("요즘은 rokit 보단 mise로 까는듯 하네. 더
범용적이라 이걸 택하는듯"), 근거는
`Word30210/roblox-project-example`(`initreq/roblox-project-example`로
클론해 확인)의 `mise.toml`. `rokit`은 이 샌드박스에 아예 없어 한 번도
직접 검증 못 했던 반면, **`mise`는 이 샌드박스 자체가 이미 `luau` 설치에
쓰고 있어서 `mise install`을 그 자리에서 실행해 진짜로 검증**함 —
`pesde`/`rojo`/`luau-lsp`/`selene` 넷 다 GitHub artifact attestation +
SLSA provenance 검증까지 거쳐 설치됨(이전 세션이 `curl`로 직접 받던
방식보다 공급망 신뢰도가 높음), `mise exec -- <tool> --version`으로
버전 일치까지 확인. `mise.toml`의 tool 키는 백엔드 접두사가 붙는다
(`github:owner/repo` / `aqua:owner/repo`) — `rokit.toml``owner/repo@ver`
평문 표기와 형태가 다르니 그대로 베끼지 말 것.
**darklua는 검토 후 채택 안 함** — 참고 레포는 `.darklua.json`
`convert_require` 룰로 경로 기반 require를 빌드 시점에 Rojo sourcemap
기준 `script.Parent`류로 변환하는데, **사용자 판단(2026-08-19)**: "Roblox
안에서도 이미 string require가 적용되긴 하고, `@self``@game`
먹는다. 같은 동작을 하지만 `./` 등으로 위치를 어떻게 두냐에 유의가
필요할 뿐" — 즉 실제 Roblox 엔진도 이 세션이 확인한 것과 같은
require-by-string 의미론(`@self` 등)을 그대로 지원하므로, darklua로 다른
형태로 변환할 필요성 자체가 낮다는 판단. quad-base는 엔진 무관이어야
하므로 애초에 Roblox 전용 변환의 적용 대상도 아님.
**[2026-08-19 후속 확인 — 정확한 경계]** `darklua` 바이너리(0.19.0)를
직접 설치해 참고 레포에서 실제로 `darklua process`를 돌려 정확히 어디까지
관여하는지 확인:
- **`require("@self/X")`/`require("@game/...")`는 `convert_require`
아예 손을 안 댐** — `unable to require resource: unknown source name
'@self'`로 경고만 찍고 원문 그대로 통과시킴. 즉 예약 alias는 런타임이
직접 처리한다고 전제하고 있고, 이 세션의 결론과 정합.
- **커스텀 `.luaurc` alias(`@pkg` 등)는 다르다 — `convert_require`
실제로 `@pkg/assets`
`require(game:GetService('ReplicatedStorage'):WaitForChild('roblox_packages'):WaitForChild('assets'))`
로 치환함**(`.luaurc`의 alias 매핑 + Rojo sourcemap을 같이 읽어서
해석 — 대상 파일이 실제로 존재해야 성공, `pesde install` 전엔 실패).
즉 **커스텀 alias 기반 require를 실제로 쓰려면 darklua(또는 동급
빌드 스텝) 없이는 배포 시점에 그 문자열이 그대로 남아 동작을 보장할
근거가 없다** — standalone `luau` CLI가 커스텀 alias를 거부하는 것과
같은 결(`could not jump to alias`), Roblox 엔진이 예약 alias 밖의
커스텀 alias까지 자체적으로 푼다는 근거는 없음.
- **결론**: quad는 커스텀 alias를 전혀 안 쓰고 상대경로+`@self`만
쓰므로 지금은 darklua가 불필요. **나중에 `@pkg/quad_base`류 축약
alias를 도입하고 싶어지면 그때는 darklua(혹은 동급 변환)가 실질적으로
필요해진다** — 그 시점에 이 절을 다시 열 것.
## require 구조 — `@self`가 필수인 이유
**[2026-08-19, 사용자 지적 + Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인]**
`init.luau`라는 파일은 require-by-string 상 **자기가 든 폴더 자체**를
가리키는 특수 취급을 받는다 — 그래서 그 파일 **안에서** 쓰는 상대
경로는 일반 파일과 기준점이 다르다:
- **`init.luau` 안에서 `require("./X")`/`require("../X")`** — 이 폴더
자체가 "자기"이므로, 상대 경로는 **이 폴더의 형제/조상**을 가리킨다.
`quad-base/src/init.luau` 안의 `./Debug``quad-base/src/Debug`
아니라 `quad-base/Debug`를 가리킨다(존재하지 않으면 즉시 에러).
- **`init.luau` 안에서 그 폴더 *안의* 형제 파일에 접근하려면
`@self/X`를 써야 한다** — `@self`는 예약 alias로, 이 모듈(=이 폴더)
자신의 경로로 치환된다. `quad-base/src/init.luau`
`quad-base/src/Debug`에 접근하려면 `require("@self/Debug")`.
- **일반 파일(`init.luau`가 아닌 `*.luau`)에서는 평범한 파일-상대
경로**(`./`/`../`)면 충분 — `@self`가 전혀 필요 없다. 예:
`quad-base/src/Debug/init.luau`(이것도 init.luau라 위 규칙이 적용됨)
안에서 형제 `Relate.luau`(`quad-base/src/Relate.luau`, `Debug`
폴더의 부모에 있음)를 가져오려면 — `Debug/init.luau`의 "자기"는
`Debug` 폴더이므로 그 부모(=`quad-base/src`)에 있는 `Relate.luau`
형제 폴더 취급 → `require("./Relate")`(◯), `require("../Relate")`(✕,
한 단계 더 올라가 `quad-base/Relate`를 찾으려다 실패).
**실측 근거**: `tbox`(`initreq/tbox`, 다른 참고 레포)의 `src/init.luau`
`require("@self/types")`/`require("@self/schema/string")` 패턴을
실제로 쓰고 있어 교차 확인됨. 이 세션에서 `quad-base/src/init.luau`
처음에 `require("./Debug")`로 잘못 짜여 크로스파일 require가 전부
깨졌었고(런타임은 크래시, `luau-analyze`**조용히** `Unifiable<Error>`
새며 0 진단으로 통과 — 이것도 `typing-limits.md`의 "실측 방법 주의"
경고("`luau-analyze`가 진단 0건이어도 타입이 제대로 해소됐다는 뜻이
아닙니다")가 가리키는 것과 같은 종류의 함정, 다만 원인은 재귀 제네릭이
아니라 require 경로 오류라 그 문서 1번 항목과는 별개 사례), `@self`
고치자 즉시 정상화됨(둘 다 clean).
**체크리스트**: 새 `init.luau`를 짤 때마다 "이 파일 안의 `require`
같은 폴더 안의 형제를 가리키는가, 아니면 이 폴더의 형제/조상을
가리키는가"를 먼저 물을 것 — 전자면 `@self/`, 후자면 `./``../`.
## 워크스페이스 의존성은 심볼릭 링크로 연결된다 — CLI 테스트의 함정
**[2026-08-19 실측]** `pesde install`이 워크스페이스 멤버 간 의존성을
해소하는 방식은 **심볼릭 링크**다 — `quad-roblox/roblox_packages/`
안에 실제로 이렇게 생긴다:
```
quad-roblox/roblox_packages/
├── quad_base.luau # 얇은 링커: return require("./.pesde/qwreey+quad_base/0.0.0/quad_base/src")
└── .pesde/qwreey+quad_base/0.0.0/quad_base/
├── src -> ../../../../../quad-base/src (symlink)
├── test -> ../../../../../quad-base/test (symlink)
├── pesde.toml -> ... (symlink)
└── pesde.lock -> ... (symlink)
```
**⚠️ 문제**: Luau의 standalone require-by-string 구현은 **심볼릭 링크를
안 따라간다** — 의도된 설계다(Luau RFC 검색 결과: "보안 상의 이유로
symlink는 일반 파일처럼 취급되고 따라가지 않는다", 추후 `.luaurc`
opt-in 토글이 추가될 수 있다고만 언급됨, 아직 없음). 직접 재현:
```lua
-- entry.luau, ./linked가 실제 폴더로의 symlink일 때
local v = require("./linked")
-- error requiring module "./linked": could not resolve child component "linked"
```
**실무 영향**: `quad-roblox`가 실제로 `quad_base`를 쓰게 되면(M5+),
표준 경로(`require(".../roblox_packages/quad_base")`)는 **`luau` CLI로
직접 못 돌린다** — `could not resolve child component`로 즉시 깨짐.
**[2026-08-19 후속 세션, 확인 완료] Rojo/Studio는 이 문제와 무관함 —
`rojo`를 같은 방식으로 `/code/.local/bin`에 설치해 직접 검증.** `quad-roblox/`
아래 `src`+`roblox_packages`를 매핑하는 임시 project.json으로
`rojo sourcemap`을 돌려보니, symlink를 정확히 따라가 실제 파일까지
해소함을 확인:
```json
{"name":"src","filePaths":["../quad-base/src/init.luau"],
"children":[
{"name":"Debug","filePaths":["../quad-base/src/Debug/init.luau"]},
{"name":"Relate","filePaths":["../quad-base/src/Relate.luau"]}
]}
```
`rojo build`(실제 `.rbxm` 생성)도 같은 트리로 에러 없이 성공. 즉 Rojo는
`fs::canonicalize`류 평범한 파일시스템 API로 트리를 만들어서 symlink를
투명하게 통과하고, 위 함정은 **Luau standalone CLI의 require-by-string
전용 문제**로 확정 — Studio 배포 경로엔 영향 없음. Studio 자체(플러그인
연동)까지는 아직 미검증(`HUMAN_TODO.md` 1번, 계정 분리 대기)이지만,
`rojo build`/`sourcemap` 레벨에서 이미 심볼릭 링크 순회가 확인됐으므로
Studio도 같은 파일시스템 계층을 쓰는 이상 다르게 동작할 이유가 없다.
**우회가 필요한 범위는 M0/M1식 CLI 스파이크/mock 테스트로 좁혀짐**:
`roblox_packages/`를 거치지 말고 실제 형제 패키지 경로를 직접 가리킬
것 — 예: `quad-roblox/src`에서 검증용 스크립트를 짤 때
`require("../../quad-base/src")`처럼. **프로덕션 `quad-roblox` 소스
자체는 그대로 표준 pesde 경로(`roblox_packages/quad_base`)를 쓸 것** —
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 토글이 미래에 생기면 이 절 전체가 불필요해짐 —
그때 다시 볼 것).
**[2026-08-19 같은 날 넷째 후속 세션] 의존 대상의 `target`에 따라 링크
디렉토리 이름이 달라진다** — `quad-types`(target `roblox`)가
`type-version-check`(target `luau`)에 의존하면, `quad-base`/`quad-roblox`가
쓰는 `roblox_packages/`와 달리 `quad-types/src/init.luau`
`require("./luau_packages/type_version_check")`**`luau_packages/`**
아래에서 링크를 찾는다 — pesde가 의존 대상 패키지 자신의 target 이름으로
디렉토리를 분리하기 때문(`.gitignore`에 `luau_packages/`도 이미
포함돼 있어 별도 조치 불필요).
같은 워크어라운드가 2단 의존 체인에도 그대로 재적용됨 — `type-version-check` 신설로
`quad-types`→`type-version-check`가 추가되면서 `quad-base`/`quad-roblox`→
`quad-types`→`type-version-check`처럼 깊이 2인 워크스페이스 의존 그래프가
생겼는데, `pesde install` 후 같은 심볼릭 링크 치환을 반복 적용하는 것만으로
`luau-analyze`/`luau` 양쪽 다 문제없이 동작 확인됨 — 이 우회가 단일
깊이에 국한되지 않고 일반화됨이 실측으로 재확인됨.
## `.luaurc` — alias는 여전히 편집기 전용
`.luaurc``aliases`(`@quad-base`/`@quad-roblox`)는 **런타임
require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇게
서술해뒀던 걸 이 세션에 직접 재확인: `require("@quad-base/Debug")`
실행하면 `could not jump to alias "quad-base/src"`로 실패(별도 에러
메시지라 위 심볼릭 링크 문제와는 다른 원인 — alias 자체가 런타임
미지원이라는 뜻, `@self`는 **예약 alias**라 이 제약과 무관하게 항상
동작하는 것과 구분할 것). 그래서 alias는 편집기 자동완성/타입체크
용도로만 남기고, 실제 require는 위 규칙대로 상대경로 + `@self`.
## `selene` 린터 — 패키지별 설정, CWD 상대 config 탐색 함정
**[2026-08-19 신설]** 사용자 결정으로 `selene`(참고 레포
`initreq/roblox-project-example``scripts/selene.toml` 그대로 채택)을
도입 — `luau-analyze`(타입체크)와 겹치지 않는 별도 축(정의되지 않은
변수, 사용 안 하는 변수, `assert` 메시지 누락 등 스타일/버그 패턴
린트)이라 같이 씀. `quad-base/selene.toml`/`quad-roblox/selene.toml`
각각 독립 배치(참고 레포도 패키지마다 독립 설정) — 아래 실측 이유 때문에
루트 단일 설정은 안 씀.
**⚠️ `selene``--config` 탐색은 파일 트리를 거슬러 올라가며 찾는 게
아니라 CWD 기준 고정 경로(`./selene.toml`)다.** 루트에 `selene.toml`
하나만 두고 `selene quad-base/`를 저장소 루트에서 실행하면, 그 자리엔
`selene.toml`이 없어(패키지 폴더 안에 있으므로) **Luau 문법 자체를 못
읽는 기본(Lua 5.1) std로 조용히 폴백** — `type Quad = {...}` 같은 평범한
타입 선언까지 전부 "unexpected token" 파싱 에러로 쏟아짐(30여 건, 전부
가짜). 처음엔 이게 "이 selene 빌드가 Luau 타입 문법을 아예 지원 못
한다"는 뜻인 줄 알았으나, `std = "luau"`가 든 `selene.toml`을 CWD에
두면 정확히 같은 파일이 0 에러로 통과함 — 즉 **문제는 selene의 Luau
지원이 아니라 config 탐색 방식**이었다.
**규칙**: 항상 **패키지 디렉토리 안에서** 실행할 것 —
`cd quad-base && selene .`(참고 레포의 Justfile도 정확히 이 패턴으로
각 패키지를 순회함, `refresh`/`clean` 레시피). 저장소 루트에서
`--config quad-base/selene.toml quad-base/`처럼 명시적으로 경로를
지정해도 되지만, 그럴 거면 그냥 `cd`가 더 안전(설정 파일 지정을
빠뜨리기 쉬움).
## `pesde.lock` — 커밋 권고 (미확정, 사용자 판단 필요)
**[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱
하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`
**워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트
`pesde.lock` 1개 + 멤버마다 1개씩, 개수는 `architecture.md`
`workspace_members`를 따라간다 — 2026-08-19 이 세션 안에서만 2개
멤버(2+1개 lock)에서 4개 멤버(4+1개 lock)로 늘어난 전례가 있어 여기 숫자를
고정하지 않는다). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만
담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의
lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty =
"workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만).
**이 세션의 잠정 권고는 셋 다 커밋**: Cargo 생태계의 "라이브러리는
lockfile을 커밋하지 않는다" 관행이 여기 그대로 적용 안 되는 이유는, 이
lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `private = true`,
`quad-base`/`quad-roblox`의 `pesde.toml``includes = ["src/*"]`뿐이라
`pesde.lock`은 애초에 게시물에 안 들어감(외부 소비자는 이 파일들을 절대
못 봄 — 자기 프로젝트에서 새로 resolve함). 그래서 "라이브러리 lockfile
딜레마" 자체가 성립하지 않고, 그냥 "이 모노레포를 체크아웃한 개발자/CI가
재현 가능한 빌드를 얻는가" 문제로 좁혀지는데 그건 커밋하는 쪽이 유리 —
**다만 이건 이 세션의 판단이고 최종 확정 아님**, `todos.md`에 확인 필요
항목으로 반영.
## 확인 완료 / 아직 확인 안 된 것
**확인 완료(이 세션, 실제 pesde/luau 실행 근거)**:
- pesde 워크스페이스 설치가 당시 워크스페이스 멤버 전부(그때는
루트+2서브)에 대해 성공(**[2026-08-19 후속]** 이후 `quad-types`/
`type-version-check` 추가로 멤버가 늘어난 뒤에도 같은 절차로 계속 성공 —
아래 후속 문단들 참고)
- 패키지 이름 문자 제약(하이픈 금지)
- `workspace = "scope/name"` 의존성 선언 문법
- `@self``init.luau`의 형제 파일 접근에 필수라는 것(런타임+
`luau-analyze` 양쪽)
- `.luaurc` alias가 런타임에서 여전히 안 먹는다는 것(재확인)
- 워크스페이스 의존성이 symlink로 연결되고, 그게 `luau` CLI의
require-by-string과 충돌한다는 것(직접 재현 + Luau RFC로 원인 확인)
- **[2026-08-19 후속 세션]** Rojo(`/code/.local/bin`에 직접 설치,
`7.7.0`, 툴체인 핀과 일치)는 위 symlink 문제와 무관 — `rojo
sourcemap`/`rojo build` 둘 다 `roblox_packages`의 symlink를 실제
파일까지 투명하게 따라감을 확인. 위 심볼릭 링크 함정은 Luau standalone
CLI의 require-by-string 전용 문제로 범위가 좁혀짐
- **덤 확인**`rojo`가 PATH에 잡히자 `luau-lsp`(에디터)가 자동으로
`rojo sourcemap default.project.json --output sourcemap.json --watch`
백그라운드로 띄움(루트 `default.project.json` 기준). 즉 지금 이
워크스페이스에서 에디터 타입 링킹이 실제로 살아있다는 뜻 — wally가
안고 있던 "설치된 패키지의 타입 정보 단절" 문제가 이 구성에선 재현
안 됨(`architecture.md`가 pesde 전환의 배경으로 들었던 문제 자체가
실제로 해소됐다는 간접 증거). 산출물 `sourcemap.json`은 재생성되는
빌드 산물이라 `.gitignore`에 추가
- **[2026-08-19 셋째 후속 세션]** `mise.toml``pesde`/`rojo`/`luau-lsp`/
`selene` 넷 다 정확히 설치·버전 일치함을 `mise install` + `mise exec`
직접 확인(attestation/provenance 검증까지 포함) — `rokit`은 끝내
한 번도 직접 검증 못 했던 것과 대비됨
- `selene``--config` 탐색이 파일 트리를 안 거슬러 올라가고 CWD
기준 고정 경로라는 것(위 "`selene` 린터" 절)
**아직 확인 안 됨(다음에 도구/환경이 갖춰지면)**:
- 루트 `pesde.toml``[target]` 섹션이 실제로 의미가 있는지(지금은
workspace 가이드 예제를 그대로 따라 둔 것, 검증 안 됨)
- Roblox Studio 자체(플러그인 연동)까지의 실제 동기화 — `rojo
build`/`sourcemap` 레벨은 확인됐지만 `HUMAN_TODO.md` 1번(계정 분리)이
되기 전까진 Studio 실물로는 미확인
- `quad_base` 설치 시 나온 "`roblox_sync_config_generator` 스크립트가
없으면 linking에 문제가 생길 수 있다"는 WARN의 실제 영향 범위 — 지금은
install 자체를 막지 않아서 방치, 실제 Rojo 동기화 단계에서 문제가
드러나면 그때 pesde 문서의 `[target.scripts]` 절을 찾아볼 것
- `pesde.lock` 커밋 여부 최종 확정(위 절 권고는 잠정)

View file

@ -0,0 +1,261 @@
# `quad-types` — 구현 없는 `Quad` 타입 계약 + 컴파일 타임 버전 체크
**상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스
세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한
사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. **[같은 날 후속]**
버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지
`type-version-check`(워크스페이스 네 번째 멤버)로 분리됐고, `CheckedQuad<T>`
`CheckedQuad<T, Pattern>`으로 확장돼 그 위에 얹힌다 — 아래 "`type-version-check`"
절.
## 왜 필요한가 — 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", type_version_check workspace 의존
└── 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` 타입을
따로 선언하지 않고 `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`이 새 테이블을 반환하면 그 추적이 끊긴다.
## `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]`
선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌
방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥
함수 인자라서. `CheckedQuad<T, Pattern>`이 그 빈자리를 메꾸는 컴파일 타임
대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox
소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로
충분"). `Pattern`은 위 `type-version-check`의 글롭/캐럿 패턴 문자열 —
quad-base/quad-roblox처럼 같은 모노레포에서 항상 같이 개발되는 관계는
정확 일치(`"0.0.0"`)를, quad-spring-roblox류 독립 게시 플러그인은
`"0.*.*"` 같은 느슨한 패턴을 직접 골라 쓴다.
```lua
export type CheckedQuad<T, Pattern> = T & { __versionCheck: TypeVersionCheck.CheckVersion<index<T, "Version">, Pattern> }
```
**사용법**:
```lua
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T, "0.0.0">
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, Pattern>`이 함수 파라미터/반환
타입 표현식 안에 직접 나타나야 하는 이유고, `__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`/`type-version-check`를 `require`해서 위
사용법 그대로 재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전
확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서
`TypeError: type-version-check: version "9.9.9" does not match pattern
"0.0.0"` 하나만.
`quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate
+ identity 보존 + 체이닝)을 실행 레벨로 검증.
## 남은 것
- `quad-roblox`가 실제로 `CheckedQuad<T, Pattern>`을 쓰는 진입점
(`QuadRoblox` 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이
문서의 사용법 예제가 실제 위치는 아직 없음.
- `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는
`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`
**버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개
문제로 계속 `_initializedBy`가 담당한다.
- **[백로그, 2026-08-19 신설]** `quad-roblox-types`(가칭) — `quad-types`
같은 패턴으로, `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한
패키지. **사용자가 지금 만들 필요는 없다고 명시적으로 후순위 지정**
다만 이후 쉽게 뽑을 수 있게 `quad-roblox`의 공개 타입은 지금부터 단일
`src/init.luau`(또는 `types.luau`) 형태로 몰아두는 걸 관례로 유지할 것
(quad-types 자신이 이미 이 형태 — `Quad`/`CheckedQuad` 둘 다
`src/init.luau` 하나에 있음).
- **[HUMAN_TODO]** `type-version-check`는 사용자가 나중에 독립 저장소로
직접 분리할 예정 — 루트 `HUMAN_TODO.md` 참고.

View file

@ -58,18 +58,19 @@ State를 만족함" 절).
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를 - **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이
무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라 오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고,
타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는 방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field:
타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입
설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉
이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입 `Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`
에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로 합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다.
확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인 **[2026-08-19 실측 완료]** 맞았음 —
상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지. `luau-test/done/21-type-store-undeclared-key-rejected.luau`
`luau-test/done/16-*`(type function으로 `Store<T>` 레코드 필드 합성)에 `ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로
이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는 접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키
경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. 3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는
`:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 - **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던

View file

@ -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,71 @@ 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, Pattern> = T & { __versionCheck: CheckVersion<T, Pattern> }
local checked: CheckedQuad<Quad, "0.0.0"> = ...
local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고)
local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함
```
`quad-types-plan.md`의 "`CheckedQuad<T, Pattern>`" 절에 전체 배선과 실측
과정이 있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수
본문 로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야
호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로
격리, 세 가지가 함께 필요합니다. **[2026-08-19 후속]** 실제 버전 매칭
로직(`CheckVersion`)은 quad에 종속되지 않은 별도 패키지
`type-version-check`로 분리됐지만, 이 항목이 다루는 "패스스루도 이력만으로
오염된다" 함정과 그 회피(별도 가상 필드 격리)는 그대로 유효합니다.
### 언제 마주치는가
`AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에,
`type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는
지금 `quad-types``CheckedQuad<T, Pattern>`이 유일한 실사례지만, 앞으로
비슷한 "타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할
수 있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가.
---
## 7. 성립이 확인된 것 (안심해도 되는 것)
한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다. 한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다.
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것: 아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
@ -381,7 +445,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
--- ---
## 7. 새 타입/API를 설계할 때 체크리스트 ## 8. 새 타입/API를 설계할 때 체크리스트
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서 1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), `-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
@ -412,6 +476,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,14 +490,16 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
--- ---
## 8. 미해결 / 추적 중 ## 9. 미해결 / 추적 중
- **에디터(`luau-lsp`)의 솔버 설정**`luau-analyze` CLI는 새 솔버가 - **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze`
기본값이지만 `luau-lsp`**옛 솔버가 기본값**(`LuauSolverV2=false`)이라 CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**
같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면 (`LuauSolverV2=false`)이라 같은 코드에 다른 진단이 남. `luau-lsp`
`"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터 바이너리(1.69.0)를 직접 설치해 `luau-lsp analyze
환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상 --flag:LuauSolverV2=true/false`로 실측 — 새 솔버가 필요하다는 결론
새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요. 재확인, `quad/.vscode/settings.json`에 `"luau-lsp.fflags.enableNewSolver":
true`를 반영·커밋 완료(`tbox`도 같은 설정 확인). 실제 VSCode 세션에서
이 설정이 반영되는지 육안 확인만 사람 몫으로 남음(`HUMAN_TODO.md` 6번).
- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증(③ 포함). - **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증(③ 포함).
- **`state:With(...)`/`state:Apply(factory)`에 1번 ③(`typeof`) 개별 - **`state:With(...)`/`state:Apply(factory)`에 1번 ③(`typeof`) 개별
실측** — `Compute`에서만 확인됐고, base pseudocode에 실제로 반영할 실측** — `Compute`에서만 확인됐고, base pseudocode에 실제로 반영할

View file

@ -71,7 +71,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
| `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong``handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong``handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 |
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-14] 전제가 뒤집혀 재작성 대기** — 옛 모델("이미 dirty면 전파 중단")을 검증 중이었으나 그게 폐기됨, 이제 확인할 건 "emit은 항상 전파되고 중복 재계산은 캐시로만 막힌다" | ROADMAP M0-1 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | ROADMAP M0-1 |
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 |
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 |
@ -79,7 +79,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime``value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.md` 2026-08-06 세션, `debug-tooling-plan.md` | | `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime``value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.md` 2026-08-06 세션, `debug-tooling-plan.md` |
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) | | `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef<T>`/`PostRef<T>`가 `Ref<T>`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 |
| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 |
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
@ -87,6 +87,9 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` 실사례 |
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" |
| `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번 |
| `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`/`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,6 +1,18 @@
# 스파이크 상태판 — **폴더가 곧 상태** # 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-15 — `16``types.newfunction` API 버전 드리프트 > 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
> 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md`
> §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리
> (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임
> 절반은 신규 `22`로 분가 → 둘 다 `done/`. 직전 갱신은 같은 날 —
> M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 `05`를 현행 모델("emit은
> 항상 전파, 재계산만 캐시로 dedup")로 재작성해 통과 → `rewrite-required/`
> → `done/` 이동, `todos.md` 00번이 요구하던 "Store 미선언 키 타입 에러"
> 확인도 신규 스파이크 `21`로 완료 → `done/` 직행. 직전 갱신은
> 2026-08-15 — `16``types.newfunction` API 버전 드리프트
> (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과, > (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과,
> `rewrite-required/``done/` 이동. 근거: > `rewrite-required/``done/` 이동. 근거:
> `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. > `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
@ -23,9 +35,9 @@
| 폴더 | 뜻 | 개수 | 누가 처리 | | 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---| |---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | | `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -50,7 +62,7 @@
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨). 아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건) ## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
@ -69,8 +81,6 @@
|---|---|---| |---|---|---|
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
| `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) |
| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 |
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)``bindLifetime``.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
@ -83,24 +93,26 @@
|---|---| |---|---|
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (14건) ## ✅ `done/` — 통과 or 판정 끝 (19건)
**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 **런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
`04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위 검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
`rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시 `05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
통과였다는 사실 자체는 유효): 런타임 절반, PostRef까지 확장)가 합류**:
| 파일 | 확인된 것 | | 파일 | 확인된 것 |
|---|---| |---|---|
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 |
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
| `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 |
| `22-runtime-ref-preref-postref-brand` | **[2026-08-19 신규]** `isPreRef`/`isPostRef`가 서로 배타적 형제(둘 다 `isRef``true`, 서로에겐 `false`)임을 확인, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라냄 |
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:
@ -109,8 +121,11 @@
| `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 | | `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 |
| `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 | | `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 |
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) | | `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) |
| `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef<T>`/`PostRef<T>` 둘 다 `Ref<T>`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) |
| `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에서 실측 확정 |
| `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

@ -0,0 +1,175 @@
--[[
검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get()
시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지.
배경: ROADMAP.md M0 1번째 항목. **[재작성, 2026-08-19]** 옛 버전은
"이미 dirty면 더 아래로 전파하지 않는다"를 검증했는데, 그 규칙은
확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 모순돼 역전됨
(`archive/invalidate-dedup-propagation-reversed.md`). 이 버전은
현행 모델을 검증:
1. emit(invalidate 신호)은 자기 invalid 상태와 무관하게 *항상*
전파된다 — 이미 dirty인 노드도 다시 전파를 계속함(early-stop 없음)
2. 중복 **재계산**은 오직 :Get() 시점 캐시로만 막힌다(dirty 플래그가
"내 캐시가 낡았다" 표시일 뿐, 전파 게이트가 아님)
3. :Get()을 안 부르는 Observer는 다이아몬드에서 한 번의 source 변경마다
경로 수만큼(여기선 2번) 계속 운다 — 옛 모델처럼 두 번째부터
침묵하면 안 됨(핵심 음성 대조군)
다이아몬드 구조:
source
/ \
stateA stateB
\ /
stateC (:With(stateA, stateB):Compute(...))
실행: `luau 05-store-state-diamond-propagation.luau`
]]
local function makeSource(initial)
local self = { value = initial, listeners = {} }
function self:Get()
return self.value
end
function self:Set(v)
self.value = v
self:Invalidate()
end
function self:Invalidate()
-- source 자신은 dirty 개념이 없음(항상 최신) — 리스너에게 신호만 쏨
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
return self
end
local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local function makeState(name, deps, computeFn)
local self = {
name = name,
dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty
cached = nil,
listeners = {},
}
function self:Invalidate()
invalidateCallCount[name] += 1
-- 핵심: invalid는 "내 캐시가 낡았다"는 표시일 뿐 전파 게이트가 아님
-- — 이미 dirty였어도 무조건 다시 세팅하고 무조건 아래로 전파한다.
self.dirty = true
print(string.format(" [%s] invalidate #%d — 항상 전파", name, invalidateCallCount[name]))
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
function self:Get()
if self.dirty then
computeCallCount[name] += 1
print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name]))
local args = {}
for i, d in deps do
args[i] = d:Get()
end
self.cached = computeFn(table.unpack(args))
self.dirty = false
else
print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name))
end
return self.cached
end
for _, d in deps do
d:OnInvalidate(function()
self:Invalidate()
end)
end
return self
end
local source = makeSource(1)
local stateA = makeState("stateA", { source }, function(v)
return v + 10
end)
local stateB = makeState("stateB", { source }, function(v)
return v + 100
end)
local stateC = makeState("stateC", { stateA, stateB }, function(a, b)
return a + b
end)
print("=== 1. 최초 Get() — 전부 계산돼야 함 ===")
print("stateC:Get() =", stateC:Get())
print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC)
assert(
computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1,
"최초 계산 횟수가 예상과 다름"
)
print()
print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===")
print("stateC:Get() =", stateC:Get())
assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)")
print()
print("=== 3. source:Set() -> 다이아몬드 invalidate 전파(항상 전파, early-stop 없음) ===")
source:Set(2)
print(
"invalidate 호출 횟수(stateC):",
invalidateCallCount.stateC,
"(stateA 경로 1번 + stateB 경로 1번 = 2번이어야 함 — 두 번 다 끝까지 실행돼야 함, 조기 종료 없음)"
)
assert(invalidateCallCount.stateC == 2, "다이아몬드 두 경로 모두 stateC까지 끝까지 전파돼야 함(early-stop 없음)")
print()
print("=== 4. invalidate 이후 Get() — 재계산은 딱 1번(중복 재계산 방지는 캐시 몫) ===")
print("stateC:Get() =", stateC:Get())
print(
"compute 호출 횟수(stateC):",
computeCallCount.stateC,
"(2여야 함 — 1차 계산 + 이번 재계산 1번. invalidate가 2번 왔다고 재계산도 2번이면 버그)"
)
assert(computeCallCount.stateC == 2, "invalidate가 2번 와도 재계산은 캐시로 1번만 막혀야 함")
print()
print("=== 5. :Get()을 안 부르는 Observer — 매 source 변경마다 계속 울어야 함(옛 모델 음성 대조군) ===")
do
local observerFireCount = 0
-- state:Observer(fn)을 흉내: :Get()을 절대 호출하지 않는 순수 리스너
stateC:OnInvalidate(function()
observerFireCount += 1
end)
for i = 1, 3 do
source:Set(2 + i)
end
print("3번의 source:Set() 이후 Observer 발화 횟수:", observerFireCount, "(다이아몬드라 회당 2번씩, 6이어야 함)")
assert(
observerFireCount == 6,
"Get()을 안 부르는 Observer가 계속 안 울면(예: 2 근처에서 멈추면) 옛 '이미 dirty면 전파 중단' 모델로 회귀한 것"
)
end
print()
print("모든 assert 통과 — '항상 전파 + Get() 시점 캐시 dedup' 모델이 예상대로 동작함")
--[[
확인 포인트:
1. 위 assert들이 전부 통과하는가.
2. 3번 절에서 stateC의 invalidate 로그가 "이미 dirty" 같은 조기 종료
문구 없이 매번 "항상 전파"로 끝까지 도는지 눈으로 확인.
3. 5번 절이 이 파일에서 가장 중요한 회귀 방지 장치 — 옛(역전된) 모델을
그대로 되돌려 넣으면(Invalidate 안에 `if self.dirty then return end`를
부활시키면) observerFireCount가 6이 아니라 2에서 멈추고 이 assert가
실패해야 정상.
4. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만
흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는
부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]

View file

@ -0,0 +1,79 @@
--!strict
--[[
검증 대상(타입 전용, 런타임 대상 아님 — 절대 `luau`로 실행하지 말 것,
`luau-analyze`/`luau-lsp`로만 확인): `PreRef<T>`/`PostRef<T>`가
구조적으로 `Ref<T>`를 만족하는지(08번 파일이 Source/State에 대해
검증한 것과 정확히 같은 질문).
**[재작성, 2026-08-19]** 원래 이 파일은 타입(A)/런타임(B) 두 섹션이
한 파일에 있었는데, A의 더미 스텁(`fakePreRef(0)`이 `nil`을 반환)이
런타임에선 "attempt to index nil"로 죽어 B 섹션이 전혀 실행되지
못했음(`luau-test/STATUS.md` 🟠 항목). 지금은 순수 타입 전용 파일로
분리 — 런타임 검증은 `22-runtime-ref-preref-postref-brand.luau`가
담당(PostRef까지 같이 커버, ROADMAP `luau-test/13` 재작성 지침).
배경: `.claude/base/ref-plan.md`의 "`PostRef`" 절 —
"`isPostRef`는 `isPreRef`와 같은 층위의 가장 구체적인 항등 체크이고,
`isRef`가 그 위에 얹히는 상위 개념".
실행: `luau-analyze 13-type-ref-preref-subtype.luau`
]]
export type Ref<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
Callback: (self: Ref<T>, fn: (T) -> ()) -> Ref<T>,
Wait: (self: Ref<T>, thread: thread?) -> Ref<T>,
}
-- PreRef/PostRef는 둘 다 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고
-- 문서가 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드
-- 차이는 런타임 전용이라 정적 타입엔 안 드러남)
export type PreRef<T> = {
Value: T,
Set: (self: PreRef<T>, value: T) -> PreRef<T>,
Callback: (self: PreRef<T>, fn: (T) -> ()) -> PreRef<T>,
Wait: (self: PreRef<T>, thread: thread?) -> PreRef<T>,
}
export type PostRef<T> = {
Value: T,
Set: (self: PostRef<T>, value: T) -> PostRef<T>,
Callback: (self: PostRef<T>, fn: (T) -> ()) -> PostRef<T>,
Wait: (self: PostRef<T>, thread: thread?) -> PostRef<T>,
}
local function fakePreRef<T>(default: T): PreRef<T>
return (nil :: any) :: PreRef<T>
end
local function fakePostRef<T>(default: T): PostRef<T>
return (nil :: any) :: PostRef<T>
end
-- 시도: PreRef<T>/PostRef<T> 값을 Ref<T>가 필요한 자리에 그대로 넘길 수 있는가
local function useAsRef<T>(r: Ref<T>): T
return r.Value
end
local myPreRef: PreRef<number> = fakePreRef(0)
local viaPreRefSubtype: number = useAsRef(myPreRef) -- <- luau-analyze 확인 포인트 1
print(viaPreRefSubtype)
local myPostRef: PostRef<number> = fakePostRef(0)
local viaPostRefSubtype: number = useAsRef(myPostRef) -- <- luau-analyze 확인 포인트 2
print(viaPostRefSubtype)
-- 음성 대조군 — 타입이 실제로 강제되는지(0 진단이 곧 안전은 아니므로)
local _wrongPreRef: string = useAsRef(myPreRef) -- 반드시 에러: number를 string으로
local _wrongPostRef: string = useAsRef(myPostRef) -- 반드시 에러: number를 string으로
--[[
확인 포인트:
1. `viaPreRefSubtype`/`viaPostRefSubtype` 줄이 에러 없이 통과하는가 —
구조적 서브타이핑이 PreRef/PostRef 둘 다에 성립하는지.
2. 음성 대조군 두 줄이 정확히 TypeError로 걸리는가(안 걸리면 이
스파이크 자체가 아무것도 검증 못 하고 있다는 뜻).
3. 이 파일은 절대 `luau`로 실행하지 말 것 — `fakePreRef`/`fakePostRef`가
런타임엔 `nil`이라 즉시 크래시함(의도된 것, 타입 전용).
]]

View file

@ -0,0 +1,58 @@
--!strict
--[[
검증 대상: `Store<{field: T}>`로 선언되지 않은 이름에 dot-access하면
타입 시간에 실제로 거부되는지 — `.claude/base/store-plan.md` "Store =
Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]"
항목, 사용자 원문: *"Store<{ field: type }> 상 없는 네임에는 타입
시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금
설계가 그럴것이예요"* — "아마"라 M0에서 실측 확인하라고 명시됨.
`todos.md` 00번 항목 / `ROADMAP.md` M0 목록.
`16-type-store-key-typefunction.luau`가 이미 검증한 `ProcessStoreType`
(type function 기반 `Store<T>` 합성)을 그대로 재사용 — 새로 검증하는
건 그 결과 타입이 **인덱서 없는 순수 레코드**라 미선언 프로퍼티
접근을 실제로 거부하는지 하나뿐.
실행: `luau-analyze 21-type-store-undeclared-key-rejected.luau` — 양성
경로(선언된 키 3개)는 클린, 미선언 키 접근 2건(읽기/메소드 호출)만
TypeError로 걸려야 함.
]]
export type Source<T> = {
Get: (self: Source<T>) -> T,
Set: (self: Source<T>, value: T) -> (),
}
type function WrapStore(ty: type): type
local result = types.newtable()
result:setproperty(types.singleton("Get"), types.newfunction({ head = { result } }, { head = { ty } }))
result:setproperty(types.singleton("Set"), types.newfunction({ head = { result, ty } }, { head = {} }))
return result
end
type function ProcessStoreType(ty: type): type
local props = ty:properties() :: { [type]: { read: type?, write: type? } }
local result = types.newtable()
for i, v in props do
local fieldType = v.read or v.write
if fieldType then
result:setproperty(i, WrapStore(fieldType))
end
end
return result
end
type Input = { ty: string, count: number }
type Processed = ProcessStoreType<Input>
local processed: Processed = nil :: any
-- 양성 경로 — 선언된 키는 정상 접근
local okTy: string = processed.ty:Get()
local okCount: number = processed.count:Get()
processed.ty:Set("hi")
print(okTy, okCount)
-- 검증 포인트 — 선언 안 된 키에 접근하면 타입 에러가 나야 함
print(processed.doesNotExist) -- 반드시 에러: 'doesNotExist'는 Processed에 없는 프로퍼티
processed.alsoMissing:Get() -- 반드시 에러: 'alsoMissing'도 없는 프로퍼티(체이닝된 :Get() 호출까지 안 감)

View file

@ -0,0 +1,113 @@
--[[
검증 대상(런타임): `isRef`/`isPreRef`/`isPostRef` predicate 합성이
`base/ref-plan.md`/`base/brand-plan.md`에 적힌 대로 동작하는지 —
`isPreRef`/`isPostRef`는 같은 층위의 가장 구체적인 항등(서로
배타적 형제)이고 `isRef`가 그 위에 얹히는 상위 개념(OR로 합성).
그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가
`isRef(v) and not isPreRef(v) and not isPostRef(v)`로 **명시적으로
좁혀야만** PreRef/PostRef를 잘못 삼키지 않는다는 것.
**[2026-08-19 신설]** `13-type-ref-preref-subtype.luau`(타입 전용)의
런타임 절반을 분리 + PostRef까지 확장한 것 — 원본은 PreRef만 다뤘고
타입/런타임이 한 파일에 있어 A의 더미 스텁이 B의 실행을 막았음
(`luau-test/STATUS.md` 🟠 항목). PostRef 확장은 ROADMAP의 재작성
지침("같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다
그대로 확장").
배경: `.claude/base/ref-plan.md`의 "`PostRef`" 절
("`isPostRef`는 `isPreRef`와 같은 층위의 가장 구체적인 항등 체크이고,
`isRef`가 그 위에 얹히는 상위 개념 — `Dispatch/Leaf.luau`의 일반 Ref
매치는 이제 `isRef(v) and not isPreRef(v) and not isPostRef(v)`").
실행: `luau 22-runtime-ref-preref-postref-brand.luau`
]]
local Brand = {}
local registry = setmetatable({}, { __mode = "k" })
function Brand.set(x, tag)
registry[x] = tag
end
function Brand.get(x)
return registry[x]
end
local RefTag, PreRefTag, PostRefTag = {}, {}, {}
local function isPreRef(x)
return Brand.get(x) == PreRefTag
end
local function isPostRef(x)
return Brand.get(x) == PostRefTag
end
local function isRef(x)
-- PreRef/PostRef는 Ref의 하위 개념(둘 다 OR로 얹음), 서로는 배타적 형제
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
end
local function makeRef()
local self = {}
Brand.set(self, RefTag)
return self
end
local function makePreRef()
local self = {}
Brand.set(self, PreRefTag)
return self
end
local function makePostRef()
local self = {}
Brand.set(self, PostRefTag)
return self
end
local ref1 = makeRef()
local preref1 = makePreRef()
local postref1 = makePostRef()
print("=== 1. isRef/isPreRef/isPostRef 기본 동작 ===")
print("isRef(ref1) =", isRef(ref1), "(true여야 함)")
print("isPreRef(ref1) =", isPreRef(ref1), "(false — Ref는 PreRef가 아님)")
print("isPostRef(ref1) =", isPostRef(ref1), "(false — Ref는 PostRef가 아님)")
print("isRef(preref1) =", isRef(preref1), "(true — PreRef는 Ref의 하위 개념)")
print("isPreRef(preref1) =", isPreRef(preref1), "(true)")
print("isPostRef(preref1) =", isPostRef(preref1), "(false — PreRef/PostRef는 서로 배타적 형제)")
print("isRef(postref1) =", isRef(postref1), "(true — PostRef도 Ref의 하위 개념)")
print("isPreRef(postref1) =", isPreRef(postref1), "(false — 서로 배타적 형제)")
print("isPostRef(postref1) =", isPostRef(postref1), "(true)")
assert(isRef(ref1) == true)
assert(isPreRef(ref1) == false)
assert(isPostRef(ref1) == false)
assert(isRef(preref1) == true, "PreRef는 isRef를 통과해야 함 (2026-08-09 재정정의 핵심)")
assert(isPreRef(preref1) == true)
assert(isPostRef(preref1) == false, "PreRef/PostRef는 서로 배타적이어야 함")
assert(isRef(postref1) == true, "PostRef도 isRef를 통과해야 함")
assert(isPreRef(postref1) == false, "PreRef/PostRef는 서로 배타적이어야 함")
assert(isPostRef(postref1) == true)
-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef/PostRef를
-- 잘못 삼키면 안 됨(둘 다 자기 전담 핸들러가 따로 처리)
local function leafRefHandlerIsHandlable(v)
return isRef(v) and not isPreRef(v) and not isPostRef(v)
end
print()
print("=== 2. Leaf의 (v=Ref) 핸들러가 PreRef/PostRef를 잘못 삼키지 않는가 ===")
print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)")
print("leafRefHandlerIsHandlable(preref1) =", leafRefHandlerIsHandlable(preref1), "(false — PreRef 전담 핸들러 몫)")
print("leafRefHandlerIsHandlable(postref1) =", leafRefHandlerIsHandlable(postref1), "(false — PostRef 전담 핸들러 몫)")
assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)")
assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그)")
assert(leafRefHandlerIsHandlable(postref1) == false, "PostRef가 Leaf 핸들러에 잘못 잡힘 (버그)")
print()
print("모든 assert 통과 — isRef(v) and not isPreRef(v) and not isPostRef(v) 조합이 기대대로 동작함")
--[[
확인 포인트:
1. 위 assert가 전부 통과하는가.
2. `isPreRef(postref1)`/`isPostRef(preref1)`이 둘 다 `false` —
PreRef/PostRef가 서로를 오인하지 않는 진짜 배타적 형제인지가 이
파일이 13번(PreRef만 다뤘던 원본)보다 새로 넓힌 부분.
]]

View file

@ -0,0 +1,89 @@
--!strict
--[[
검증 대상(타입 전용): `quad-types`의 `CheckedQuad<T, Pattern>`(버전
패턴 체크, `type-version-check` 패키지 위에 얹힘)가 실제 `quad-base`가
구현하는 `Quad` 타입과 맞물려 동작하는지 — 그리고 검증 후에도
`AddPlugin<Self,P>`의 제네릭 체이닝이 안 깨지는지.
배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad):
QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서,
pesde의 semver 충돌 방지가 이 관계엔 안 걸리게 됨(선언된 의존성이
아니라 그냥 함수 인자라서) — 그 빈 자리를 메꾸는 컴파일 타임 버전
체크. `quad-types/src/init.luau`의 `CheckedQuad` 절,
`type-version-check/src/init.luau`의 `CheckVersion` 절 참고.
**이 파일이 재현하는 핵심 함정(처음 시도했다가 깨진 것들)**:
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 메소드
체이닝이 조용히 깨짐. 검증 결과는 **원본 타입과 절대 안 섞이는
별도 가상 필드**(`__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`
]]
local QuadTypes = require("../../../quad-types/src")
type RobloxExt = { Frame: (self: any) -> string }
-- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번
-- 함정에 걸림). 여기선 quad-base와 정확히 같은 버전만 허용("0.0.0" 그대로) —
-- quad-spring-roblox류 느슨한 소비자는 "0.*.*" 같은 패턴을 대신 씀.
local function CheckQuad<T>(quad: T): QuadTypes.CheckedQuad<T, "0.0.0">
return quad :: any
end
local function installRobloxBackend(_self: QuadTypes.Quad): RobloxExt
return nil :: any
end
-- 실제 quad-base가 구현하는 Quad 타입 그대로(버전 일치)
local goodQuad: QuadTypes.Quad = nil :: any
-- 다른 버전을 흉내 — 구조는 같지만 Version 리터럴만 다름
local badQuad = {
Version = "9.9.9" :: "9.9.9",
debug = false,
New = (nil :: any) :: () -> any,
RunInit = (nil :: any) :: (self: any, initFn: (any) -> any) -> (),
AddPlugin = (nil :: any) :: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
}
-- 1) 버전이 맞으면: 강제 참조 후 통과 + AddPlugin 체이닝까지 그대로 살아있어야 함
local checked = CheckQuad(goodQuad)
local _forceCheck = checked.__versionCheck -- ⚠️ 필수 — 없으면 검증이 조용히 스킵됨
local withRoblox = checked:AddPlugin(installRobloxBackend)
local okFrame: string = withRoblox:Frame()
local okDebug: boolean = withRoblox.debug
print(okFrame)
type SpringPlugin = { Spring: (self: any, v: number) -> number }
local function springFn(_self: QuadTypes.Quad): SpringPlugin
return nil :: any
end
local withSpring = withRoblox:AddPlugin(springFn)
local spring: number = withSpring:Spring(1)
local stillFrame: string = withSpring:Frame() -- 먼저 추가한 RobloxExt도 안 사라짐
-- 2) 버전이 다르면 강제 참조 시점에 TypeError가 나야 함(음성 대조군)
local checkedBad = CheckQuad(badQuad)
local _forceCheckBad = checkedBad.__versionCheck
print(okDebug, spring, stillFrame)

View file

@ -1,174 +0,0 @@
--[[
!!! [2026-08-14] 재작성 대기 — 아래 코드는 폐기된 모델을 검증 중 !!!
이 파일은 "이미 dirty로 표시된 노드는 더 아래로 전파하지 않는다"를
assert하는데, 그 규칙이 확정된 Observer 계약(fn이 :Get()을 안 불러도
됨)과 모순돼 역전됐음. 통과했다고 해서 현행 설계를 검증한 게 아님.
- 역전 근거/영향 범위: archive/invalidate-dedup-propagation-reversed.md
- 현행 모델: base/bind-system-plan.md "전파 모델 확정" /
"다이아몬드 의존성은 무엇이 푸는가"
재작성 방향(STATUS.md와 동일):
1. emit은 자기 invalid 상태와 무관하게 *항상* 전파되는가
2. 중복 재계산은 :Get() 시점 캐시로만 막히는가 (재계산 1회는 그대로 유효)
3. :Get()을 안 부르는 Observer가 매 변경마다 계속 울리는가
— 옛 모델에선 두 번째부터 침묵했으므로 이게 딱 맞는 음성 대조군
--- 이하 원문(옛 모델) ---
검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get()
시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지.
배경: ROADMAP.md M0 1번째 항목 "Store/State push-invalidate ->
pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스
포함 — 이미 invalid면 전파 중단되는지)".
다이아몬드 구조:
source
/ \
stateA stateB
\ /
stateC (:With(stateA, stateB):Compute(...))
검증할 것 두 가지:
1. source가 바뀌면 invalidate 신호가 stateA/stateB를 거쳐 stateC까지
전파되는데, "이미 dirty로 표시된 노드는 더 이상 아래로 전파하지
않는다"는 방어가 있어야 다이아몬드에서 stateC가 두 경로로 두 번
invalidate 신호를 받아도 문제없이 처리됨(도달 자체는 두 번 일어나되,
두 번째는 즉시 조기 종료돼야 함).
2. stateC:Get()을 실제로 호출했을 때, compute 함수가 정확히 1번만
실행되는가(다이아몬드 때문에 stateA 경로/stateB 경로 각각 한 번씩
총 2번 이상 실행되면 버그).
실행: `luau 05-store-state-diamond-propagation.luau`
]]
local function makeSource(initial)
local self = { value = initial, listeners = {} }
function self:Get()
return self.value
end
function self:Set(v)
self.value = v
self:Invalidate()
end
function self:Invalidate()
-- source 자신은 dirty 개념이 없음(항상 최신) — 그냥 리스너에게 신호만 쏨
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
return self
end
local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 }
local function makeState(name, deps, computeFn)
local self = {
name = name,
dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty
cached = nil,
listeners = {},
}
function self:Invalidate()
invalidateCallCount[name] += 1
if self.dirty then
-- 핵심: 이미 dirty면 더 아래로 전파하지 않음(다이아몬드 방어)
print(string.format(" [%s] 이미 dirty -> 전파 중단", name))
return
end
print(string.format(" [%s] dirty로 표시, 아래로 전파", name))
self.dirty = true
for _, fn in self.listeners do
fn()
end
end
function self:OnInvalidate(fn)
table.insert(self.listeners, fn)
end
function self:Get()
if self.dirty then
computeCallCount[name] += 1
print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name]))
local args = {}
for i, d in deps do
args[i] = d:Get()
end
self.cached = computeFn(table.unpack(args))
self.dirty = false
else
print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name))
end
return self.cached
end
for _, d in deps do
d:OnInvalidate(function()
self:Invalidate()
end)
end
return self
end
local source = makeSource(1)
local stateA = makeState("stateA", { source }, function(v)
return v + 10
end)
local stateB = makeState("stateB", { source }, function(v)
return v + 100
end)
local stateC = makeState("stateC", { stateA, stateB }, function(a, b)
return a + b
end)
print("=== 1. 최초 Get() — 전부 계산돼야 함 ===")
print("stateC:Get() =", stateC:Get())
print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC)
assert(
computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1,
"최초 계산 횟수가 예상과 다름"
)
print()
print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===")
print("stateC:Get() =", stateC:Get())
assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)")
print()
print("=== 3. source:Set() -> 다이아몬드 invalidate 전파 ===")
source:Set(2)
print(
"invalidate 호출 횟수(stateC):",
invalidateCallCount.stateC,
"(stateA 경로 1번 + stateB 경로 1번 = 2번 호출은 정상, 단 2번째는 즉시 'already dirty'로 중단돼야 함)"
)
print()
print("=== 4. invalidate 이후 Get() — 정확히 1번만 재계산되는가 ===")
print("stateC:Get() =", stateC:Get())
print(
"compute 호출 횟수(stateC):",
computeCallCount.stateC,
"(2여야 함 — 1차 계산 + 이번 재계산, 3 이상이면 다이아몬드 중복 재계산 버그)"
)
assert(computeCallCount.stateC == 2, "다이아몬드 의존성 때문에 stateC가 여러 번 재계산됨 (버그)")
print()
print("모든 assert 통과 — 다이아몬드 전파/재계산 모델이 예상대로 동작함")
--[[
확인 포인트:
1. 위 assert들이 전부 통과하는가(하나라도 실패하면 error로 죽고 스택
트레이스가 찍힘 — 그대로 알려줄 것).
2. invalidateCallCount.stateC가 정확히 2(stateA 경로, stateB 경로 각각
1번씩 도달)이지만, 그 중 두 번째 호출은 "이미 dirty" 로그로 조기
종료되는지 눈으로 확인.
3. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만
흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는
부분(.claude/base/bind-system-plan.md "Store/State/Source 온톨로지"
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]

View file

@ -1,140 +0,0 @@
--!strict
--[[
검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 —
`isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를
만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨.
이전엔 `isRef(preRefInstance) == false`였는데, 지금은
`isRef(preRefInstance) == true`로 바뀜.
이 파일은 두 부분으로 나뉨:
A) 타입 체크 대상 — `PreRef<T>`가 구조적으로 `Ref<T>`를 만족하는지
(08번 파일이 Source/State에 대해 검증한 것과 정확히 같은 질문을
Ref/PreRef에 대해 재검증).
B) 런타임 대상 — `isRef`/`isPreRef` predicate 합성이 문서에 적힌 대로
동작하는지, 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가
이제 `isHandlable = isRef(v) and not isPreRef(v)`로 **명시적으로
좁혀야만** PreRef를 잘못 삼키지 않는다는 것.
배경: .claude/base/brand-plan.md의 `Brand` 절
("isRef(x)는 그 위에 Brand.get(x)==RefTag를 OR로 얹은 상위 개념")와
"`(v=Ref)` children 배열 leaf 매치 핸들러... isRef(v) and not
isPreRef(v)로 명시적으로 좁혀야 함" 부분.
실행:
A) `luau-analyze 13-type-ref-preref-subtype.luau` (또는 luau-lsp)
B) `luau 13-type-ref-preref-subtype.luau`
[정정, 2026-08-13 첫 실측 라운드] 위 "런타임 부분은 그냥 통과함" 예상은
틀렸음 — 실제로 돌려보니 A섹션의 `fakePreRef(0)`가 런타임에 `nil`을
반환하는 더미 스텁이라, B섹션이 시작하기 전에 `useAsRef(myPreRef)`의
`r.Value` 접근에서 "attempt to index nil"로 죽어 B섹션 런타임 검증까지
도달하지 못함. 타입 A섹션(luau-analyze)은 별개로 통과. 상세는
`luau-test/STATUS.md` 🟠 항목 — A/B를 별도 파일로 분리해야 함(재작성
대기).
]]
-- ===== A) 타입 체크 대상 =====
export type Ref<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
Callback: (self: Ref<T>, fn: (T) -> ()) -> Ref<T>,
Wait: (self: Ref<T>, thread: thread?) -> Ref<T>,
}
-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가
-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는
-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분)
export type PreRef<T> = {
Value: T,
Set: (self: PreRef<T>, value: T) -> PreRef<T>,
Callback: (self: PreRef<T>, fn: (T) -> ()) -> PreRef<T>,
Wait: (self: PreRef<T>, thread: thread?) -> PreRef<T>,
}
local function fakePreRef<T>(default: T): PreRef<T>
return (nil :: any) :: PreRef<T>
end
-- 시도: PreRef<T> 값을 Ref<T>가 필요한 자리에 그대로 넘길 수 있는가
local function useAsRef<T>(r: Ref<T>): T
return r.Value
end
local myPreRef: PreRef<number> = fakePreRef(0)
local viaSubtype: number = useAsRef(myPreRef) -- <- 여기가 luau-analyze 확인 포인트
print("A) 타입 체크는 luau-analyze/luau-lsp로 확인 — 런타임은 그냥 통과")
print(viaSubtype)
-- ===== B) 런타임 대상 — Brand/isRef/isPreRef predicate 합성 =====
local Brand = {}
local registry = setmetatable({}, { __mode = "k" })
function Brand.set(x, tag)
registry[x] = tag
end
function Brand.get(x)
return registry[x]
end
local RefTag, PreRefTag = {}, {}
local function isPreRef(x)
return Brand.get(x) == PreRefTag
end
local function isRef(x)
-- 재정정된 합성 — PreRef가 Ref의 하위 개념(OR로 얹음)
return isPreRef(x) or Brand.get(x) == RefTag
end
local function makeRef()
local self = {}
Brand.set(self, RefTag)
return self
end
local function makePreRef()
local self = {}
Brand.set(self, PreRefTag)
return self
end
local ref1 = makeRef()
local preref1 = makePreRef()
print()
print("=== B-1. isRef/isPreRef 기본 동작 ===")
print("isRef(ref1) =", isRef(ref1), "(true여야 함)")
print("isPreRef(ref1) =", isPreRef(ref1), "(false여야 함 — Ref는 PreRef가 아님)")
print("isRef(preref1) =", isRef(preref1), "(true여야 함 — 2026-08-09 재정정의 핵심)")
print("isPreRef(preref1) =", isPreRef(preref1), "(true여야 함)")
-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef를 잘못 삼키면 안 됨
local function leafRefHandlerIsHandlable(v)
return isRef(v) and not isPreRef(v)
end
print()
print("=== B-2. Leaf의 (v=Ref) 핸들러가 PreRef를 잘못 삼키지 않는가 ===")
print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)")
print(
"leafRefHandlerIsHandlable(preref1) =",
leafRefHandlerIsHandlable(preref1),
"(false여야 함 — PreRef는 pre-pass가 이미 처리했어야 하고, 이 핸들러가 또 삼키면 안 됨)"
)
assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)")
assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그 — 2026-08-09 재정정이 요구하는 명시적 좁히기 실패)")
print()
print("assert 전부 통과 — isRef(v) and not isPreRef(v) 조합이 기대대로 동작함")
--[[
확인 포인트:
A) luau-analyze/luau-lsp에서 `viaSubtype` 줄이 에러 없이 통과하는가 —
08번 파일이 Source/State에 대해 확인했던 것과 같은 결론(구조적
서브타이핑 성립)이 Ref/PreRef에도 그대로 적용되는지.
B) 런타임 assert가 전부 통과하는가 — 특히 `isRef(preref1) == true`
(뒤집힌 결정 자체)와 `leafRefHandlerIsHandlable(preref1) == false`
(그 뒤집힘 때문에 Leaf 핸들러가 이제 반드시 `not isPreRef(v)`를
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
]]

View file

@ -10,10 +10,13 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
길게 잡음. 길게 잡음.
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전**(M0에 **[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치
착수하면 루트 `CLAUDE.md` 머리말도 같이 고칠 것 — 같은 상태를 두 곳이 엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
서술하고 있음) — 저장소 루트에 실제 소스 같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에
코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract` `quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`
소스. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, 디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘,
컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/` 컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`
문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가 문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가
@ -73,8 +76,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/ 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/
`pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새 `pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — **실사용**
**[2026-08-18 기준] 폴더 자체가 아직 없음**. 피드백용(M0/M1 스캐폴딩이 아니라 실제로 렌더링해보고 쓰는 단계부터),
**[2026-08-19 기준] 폴더 자체가 아직 없음** — 첫 피드백이 생길 때 만들면 됨.
`.claude/archive/`는 원래 같은 취급이었으나 `.claude/archive/`는 원래 같은 취급이었으나
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이

View file

@ -1535,3 +1535,77 @@ ERROR 0.
드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술 드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술
철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/ 철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/
`source-state-plan.md`/`blocker-plan.md` 전량 반영. `source-state-plan.md`/`blocker-plan.md` 전량 반영.
## 2026-08-19 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정
원문: `session/2026-08-19-04-pesde-migration-and-project-setup.md`
`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 revert 가능한 상태로 실제로
짜보는 시도 — `quad-base/`/`quad-roblox/` 폴더, `Relate.luau` 전량 구현,
`Debug/init.luau` + 최상위 `init.luau`, mock+스모크 테스트까지 작성. 그
과정에서 크로스파일 require가 전부 깨지는 진짜 버그를 찾았으나 원인
진단은 처음에 틀렸음(CWD 기준설로 오판) — 사용자가 Luau RFC를 근거로
`init.luau``@self/X`가 필요한 특수 케이스라고 직접 정정. 이어서 사용자
결정으로 패키지 매니저를 wally에서 pesde로 전환, tbox 참고 후 실제 설치해
워크스페이스 전체를 검증. 산출물은 `base/project-setup-plan.md`(신설)와
`architecture.md`의 "패키징 방식" 절 정정.
**커밋 후 후속(같은 세션, §5)**: 사용자가 "전부 차근차근"이라고 답해 이어서
4가지 진행 — Rojo를 직접 설치해 `rojo sourcemap`/`rojo build`가 워크스페이스
symlink를 투명하게 따라감을 확인(Luau standalone CLI 전용 문제였음을
재확인, Studio 실물 검증만 계정 분리 대기로 남음), 스파이크 `13`
타입 전용/런타임(`22` 신규) 두 파일로 분리해 `PreRef`/`PostRef` 배타성까지
검증, `luau-lsp`를 직접 설치해 새 Luau 솔버 필요성을 재귀 제네릭 스파이크로
재확인하고 `.vscode/settings.json`에 반영, 스파이크 `05`/`21` STATUS.md
텍스트 갱신.
## 2026-08-19 — rokit→mise 전환, selene 린터 도입, darklua 검토 후 기각
원문: `session/2026-08-19-05-mise-migration-and-selene-linter.md`
사용자가 참고 GitHub 레포 `Word30210/roblox-project-example`
`mise.toml`을 제시("요즘은 rokit보단 mise로 까는듯") → 클론해 구조 전체를
훑고 mise 전환/selene 린터/darklua/Justfile 네 후보를 멀티셀렉트로 제시,
사용자는 mise 전환과 selene만 채택. darklua는 사용자가 직접 반박해 보류
— "Roblox 엔진 자체가 이미 `@self`/`@game` string require를 지원하므로
변환 계층이 불필요"라는 논거. mise 전환은 GitHub artifact attestation +
SLSA provenance 검증까지 거쳐 실제 설치·검증 완료.
## 2026-08-19 — `RunInit` 재설계, darklua 경계 정밀화, 한국어 진행 합의
원문: `session/2026-08-19-06-runinit-redesign-and-darklua-precision.md`
사용자 요청 두 가지: (1) darklua 기각 근거를 실측으로 정밀화 — 직접
설치해 돌려보니 `@self`/`@game`은 안 건드리고 커스텀 `.luaurc` alias만
변환한다는 정확한 경계 확인(지금은 불필요하지만 나중에 축약 alias를 쓰면
필요해질 수 있음을 `project-setup-plan.md`에 반영). (2) `New()`의 멱등
Init 가드를 파일마다 `Relate`+센티널을 두는 대신 **함수 자체를 릴레이션
키로 쓰는 공유 `module:RunInit(initFn)`**로 재설계 — 실제 구현+3개
시나리오 스모크 테스트까지 완료. 이후 대화를 한국어로 진행하기로 합의.
## 2026-08-19 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계
원문: `session/2026-08-19-07-quad-types-package-addplugin-checkversion.md`
`RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리 확정한 뒤,
사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면
dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고
`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설,
`AddPlugin<Self,P>` 플러그인 체이닝과 `CheckedQuad<T>` 컴파일 타임
버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 "값이 한 번이라도
`type function`을 거치면 이후 제네릭 self 체이닝이 조용히 깨진다"는 새
Luau 함정을 발견해 `typing-limits.md` §6으로 승격.
## 2026-08-19 — `type-version-check` 패키지 추출, `CheckedQuad<T, Pattern>` 확장
원문: `session/2026-08-19-08-type-version-check-package-extraction.md`
직전 세션의 `CheckedQuad<T>`(정확 버전 일치만)가 `quad-spring`/
`quad-spring-roblox`류 독립 게시 플러그인엔 너무 빡빡하다는 사용자 지적
→ 글롭(`"*"`)/캐럿(`"N^"`) 패턴을 지원하는 `CheckedQuad<T, Pattern>`으로
확장. 버전 매칭 로직 자체는 quad에 종속되지 않은 범용 워크스페이스 멤버
`type-version-check`로 분리(사용자 지시: 지금은 모노레포 안에 두고 독립
저장소 분리는 나중에 직접 — `HUMAN_TODO.md` 9번). 새 Luau 함정 2건 발견
(`type function`은 outer local 참조 불가, cross-package엔 `export type
function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로
구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋.

View file

@ -0,0 +1,146 @@
# 2026-08-19, 네 번째 세션 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정
**요약**: 사용자 요청으로 M0(스파이크)/M1(스캐폴딩)을 revert 가능한
상태로 실제로 짜보며 문제를 찾는 시도. 그 과정에서 진짜 문제(require
경로 버그)를 하나 찾았는데 원인 진단이 틀렸었고, 사용자가 직접 정답
(`@self`)을 지목해 정정. 이어서 사용자가 패키지 매니저를 wally에서
pesde로 바꾸자고 결정, tbox 참고 후 실제 pesde를 설치해 워크스페이스
전체를 검증. 산출물은 `.claude/base/project-setup-plan.md`(신설)와
`architecture.md`의 "패키징 방식" 절 정정.
## 1. M0/M1 첫 실제 시도
`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 실제로 Luau로 짜봄:
- M0 항목 3(재귀 재-dispatch)은 기존 스파이크 `03`을 재실행해 여전히
유효함만 확인(재작성 불필요).
- M0 항목 1(다이아몬드 전파)은 `05-store-state-diamond-propagation.luau`
"emit은 항상 전파 + `:Get()` 시점 캐시로만 dedup" 현행 모델로 재작성,
통과 후 `done/`으로 이동.
- `todos.md`가 M0 항목으로 요구하던 "Store 미선언 키가 타입 에러 나는가"도
새 스파이크 `21`로 확인 — `luau-analyze`가 정확히 2건의 `TypeError`
거부함을 확인(사용자의 "아마 그럴 것" 추측이 맞았음).
- M1: `quad-base/`, `quad-roblox/` 폴더, `Relate.luau`(전량 구현),
`Debug/init.luau` + 최상위 `init.luau`(`New()`/`InitXxx` 팩토리
체이닝 + `Relate` 기반 멱등 가드), `quad-base/test/mock.luau`(최소
mock) + 스모크 테스트까지 작성.
## 2. require 버그 — 원인을 잘못 짚었다가 사용자가 정정
`quad-base/src/init.luau`(`require("./Debug")`)가 크로스파일 require
전부 실패. 여러 각도로 재현하다 "standalone `luau` CLI가 relative
require를 **process CWD** 기준으로 푼다"는 결론을 냈고, 이 결론으로
첫 보고를 마쳤음(**틀린 진단**).
사용자가 바로잡음: *"init.luau 는 상위 폴더를 자신으로 만들어낸다는
의미라, @self 로 주변 요소를 접근해야해"* + Luau 공식 문서 링크 제공.
`rfcs.luau.org/abstract-module-paths-and-init-dot-luau`를 확인한 결과:
`init.luau`는 require-by-string 상 **자기가 든 폴더 자체**를 가리키는
특수 케이스라, 그 안의 `./X`는 그 폴더의 형제를 가리키고, 폴더
**안의** 형제 파일을 가리키려면 예약 alias `@self/X`가 필요함 — CWD와는
무관한 문제였음. `quad-base/src/init.luau``require("./Debug")`
`require("@self/Debug")`, `Debug/init.luau``require("../Relate")`
`require("./Relate")`로 고치자 즉시 정상화(런타임 clean, `luau-analyze`
0 진단). 부수로 `Relate.luau`의 진짜 타입 내로잉 버그 2건도 이때 처음
드러나 같이 고침(전에는 require가 안 뚫려 그 부분이 타입체크 자체를 안
받고 있었음).
**교훈**: CWD 기반이라는 첫 결론은 "여러 재현 케이스가 다 맞아떨어졌다"는
확신 때문에 유지했는데, 실제로는 초기 가설(구조적 require 특수 케이스)을
검증 안 하고 다른 잘못된 가설로 건너뛴 것 — 사용자가 정확한 1차 소스
(공식 문서)를 제시해줘서 빠르게 정정됨.
## 3. tbox 확인 → pesde 결정 → 실제 설치·검증
사용자 요청: `tbox`(`initreq/tbox`) 확인 후 "pesde로 가야 할 것 같다"
(dev-dependency 등 더 나은 툴링). `tbox`엔 pesde/wally 설정 자체가 없었지만
(독립 스키마 라이브러리, 패키지 매니저 미사용), `src/init.luau`
`require("@self/...")` 패턴을 실제로 쓰고 있어 위 2번 정정을 교차
확인해줬고, `.vscode/settings.json``enableNewSolver: true`를 이미
켜둔 것도 `HUMAN_TODO.md` 6번(에디터 솔버 확인)에 참고 근거로 남음.
pesde 실물 설정은 `initreq/vide`(`pesde.toml`+`rokit.toml` 보유)를
템플릿으로 씀. 이어서 사용자가 직접 pesde 공식 설치 문서 링크를 주고
`/code/.local/bin`(이미 PATH)에 설치해보라고 요청 — GitHub 릴리스에서
`pesde-0.7.3-linux-x86_64.zip`을 받아 압축 해제 후 그 경로에 배치,
`pesde 0.7.3` 확인(`rokit.toml`의 핀과 정확히 일치).
**실제 `pesde install`을 워크스페이스 루트에서 돌려서 나온 것들**(전부
`.claude/base/project-setup-plan.md`에 정리):
1. 패키지 이름에 하이픈 불가(`a-z`/`0-9`/`_`만) — `qwreey/quad-base`
파싱 단계에서 거부됨(에러 메시지가 원인을 안 알려줘서 처음엔 의존성
선언 문법이 잘못된 줄 알았음). `quad_base`/`quad_roblox`로 고침.
2. `workspace = "scope/name"` 의존성 문법은 원래 손으로 쓴 그대로
맞았음(이름만 고치니 바로 통과) — `pesde add`는 워크스페이스 멤버를
못 찾는다는 것도 같이 확인(레지스트리 전용 커맨드).
3. `pesde.lock`은 워크스페이스 루트에 딱 하나만 생김.
4. **가장 중요한 발견** — 워크스페이스 의존성은 **심볼릭 링크**로
연결됨(`roblox_packages/.pesde/scope+pkg/version/pkg/src` →
실제 형제 패키지 경로). 실제로 `quad_base``quad-roblox`에서
`require`하는 스모크 테스트를 짜보니 "could not resolve child
component 'src'"로 깨짐 — 직접 격리 재현(`/tmp`에 symlink 하나만
만들어 `require`) 후 원인이 **Luau의 require-by-string이 symlink를
의도적으로 안 따라간다**(보안상의 이유, RFC 검색으로 확인, 향후
`.luaurc` opt-in 토글 가능성만 언급되고 아직 없음)로 확정.
`quad-roblox`가 실제로 `quad_base`를 쓰게 될 M5부터 이 문제가
현실화됨 — Rojo/Studio는 아마 무관(파일시스템 워크라 symlink를 그냥
따라갈 가능성이 높음)이지만 이 세션엔 Rojo가 없어 미검증.
## 4. 산출물
- `.claude/base/project-setup-plan.md` 신설 — 위 내용 전부 정리,
"확인 완료/아직 확인 안 된 것" 절로 후속 검증 항목 명시.
- `.claude/base/architecture.md` "패키징 방식" 절 — wally→pesde 전환 반영.
- `.gitignore``roblox_packages/`/`luau_packages/`/`.pesde/` 추가.
- `pesde.toml`(루트+quad-base+quad-roblox), `rokit.toml`, `.luaurc`,
`default.project.json` 신설.
- `quad-base/src/{Relate.luau,init.luau,Debug/init.luau}`,
`quad-base/test/{mock.luau,smoke.mock.luau}` 신설.
- `luau-test/05`(다이아몬드 전파, 재작성 후 `done/`), `luau-test/21`(Store
미선언 키, 신규) — 둘 다 `STATUS.md` 표 텍스트는 아직 안 고침(스스로
발견한 것 — 다음에 손댈 것).
## 5. 커밋 후 후속 — 05/21 STATUS.md 반영, Rojo 설치·symlink 검증
사용자가 산출물(문서화+셋업 파일)만 먼저 커밋하길 원해 그렇게 진행(`tooling:`
커밋). 이어서 4가지를 전부 순서대로 진행하기로 함(사용자: "전부 차근차근
진행해보면 될듯 함") — (1) `05`/`21` STATUS.md 텍스트 반영 후 별도 커밋
(`qa:`), (2) Rojo 설치 후 symlink 처리 검증, (3) 스파이크 `13` 재작성,
(4) `HUMAN_TODO` 6번 에디터 솔버 설정.
**(2) 완료** — pesde와 같은 방식으로 `rojo``/code/.local/bin`에 직접
설치(`7.7.0`, `rokit.toml` 핀과 일치). `quad-roblox/``src`+
`roblox_packages`를 매핑하는 임시 project.json으로 `rojo
sourcemap`/`rojo build`를 돌려본 결과, **symlink를 투명하게 따라가
실제 `quad-base/src/init.luau` 등까지 정확히 해소함을 확인** —
`project-setup-plan.md`가 가장 크게 남겨뒀던 미해결 항목이 이걸로
닫힘. 결론: 이전 세션이 발견한 "workspace 의존성 symlink가 require를
깨뜨린다"는 문제는 **Luau standalone CLI 전용**이고 Rojo/Studio 배포
경로엔 영향 없음(Studio 실물 확인은 여전히 계정 분리 대기,
`HUMAN_TODO.md` 1번). 임시 검증 파일(`test-symlink-check.project.json`)은
확인 후 삭제, 결과만 `project-setup-plan.md`에 반영.
**(3) 완료** — `13-type-ref-preref-subtype.luau`를 타입 전용으로 남기고
(`PostRef<T>`도 `Ref<T>`를 만족하는지 추가), 런타임 절반은 신규
`22-runtime-ref-preref-postref-brand.luau`로 분리(A의 더미 스텁이 B
실행을 막던 문제 해결). `isPreRef`/`isPostRef`가 서로 배타적 형제이고
Leaf 핸들러 흉내가 셋을 정확히 갈라냄을 확인. 둘 다 `done/`.
**(4) 완료** — `luau-lsp` 바이너리(1.69.0)도 같은 방식으로
`/code/.local/bin`에 직접 설치. `luau-lsp analyze
--flag:LuauSolverV2=true/false`로 spike `08`(재귀 제네릭 패턴)을
비교한 결과 새 솔버 필요성 재확인(옛 솔버는 같은 패턴에 에러 3건,
새 솔버는 1건). `quad/.vscode/settings.json`
`enableNewSolver: true` 반영·커밋. 부수로 typing-limits.md §1의 핵심
주장("`local s = n:Compute(fn); local wrong: number = s:Get()`가 0
진단으로 통과") 자체도 Luau 0.734에서 여전히 재현됨을 별도 최소
repro로 재확인(`s`가 `Unifiable<Error>`로 새는 것, `wrong` 줄은 진단
0건 — base 문서 정정 불필요, 그대로 유효함만 재확인). tbox가 쓰던
`LuauDoNotExportBrokenTypeFunction` override는 quad의 현재 type
function 스파이크(`16`/`21`)에서 유무 차이가 없어 채택 안 함.
`HUMAN_TODO.md` 6번/`typing-limits.md` §8 갱신, `rokit.toml`
`luau-lsp` 핀 추가.
**남은 사람 몫**: VSCode를 실제로 열어 `.vscode/settings.json` 설정이
반영됐는지 육안 확인(HUMAN_TODO 6번), Studio 실물 동기화(HUMAN_TODO
1번, 계정 분리 대기). 이번 라운드로 이번 대화의 4개 후속 검증 항목은
전부 닫힘.

View file

@ -0,0 +1,73 @@
# 2026-08-19, 다섯 번째 세션 — rokit→mise 전환, selene 린터 도입, darklua 검토 후 기각
**요약**: 사용자가 `Word30210/roblox-project-example`(참고용 GitHub 레포)의
`mise.toml`을 보여주며 "요즘은 rokit보단 mise로 까는듯 하네" 언급.
`initreq/roblox-project-example`로 클론해 구조 전체를 훑고 흡수할 요소를
찾음 — mise 전환, selene 린터, darklua 세 후보 중 사용자가 앞의 둘만
채택.
## 1. 참고 레포 훑기
`packages/`(독립 게시 패키지)+`places/`(멀티 플레이스 게임 프로젝트)+
`scripts/`(빌드 도구) 구조, 각 서브패키지가 독립 `pesde.toml`/
`selene.toml`/`stylua.toml`/`.luaurc`/`.vscode`를 가짐. `Justfile`
`refresh`/`clean`/`dev` 태스크 러너(각 패키지를 순회하며 `pesde
install`+`darklua process` 반복). `.darklua.json``convert_require`
룰이 경로 기반 require를 Rojo sourcemap 기준 `script.Parent`류로 빌드
시점에 변환. `places/main``default.project.json`(로컬 dev, `src`
직결)과 `build.project.json`(`dist`, darklua 처리 결과) 분리. `optional`
path 문법(`{"$path": {"optional": "roblox_packages"}}`)으로 설치 전에도
Rojo 에러 안 나게 함.
`packages/assets/src/init.luau``require("@self/assets")`를 실제로
씀 — 지난 세션에 발견한 `@self` 규칙의 세 번째 교차 확인(tbox, 이번
세션 자체 실측에 이어).
## 2. 사용자 선택 — mise 전환 + selene 채택, darklua는 보류
멀티셀렉트로 네 후보(mise 전환/selene/darklua/Justfile) 제시,
사용자 답: mise 전환(추천)과 selene은 채택. darklua에는 직접 반박 —
*"roblox 안에서도 이미 string require가 적용되긴 하고, @self와 @game이
먹는다. 같은 동작을 하지만, ./ 등으로 위치를 어떻게 두냐에 유의가
필요할 뿐임. 따라서 darklua의 필요성은 잘 모르겠다"* — 즉 실제 Roblox
엔진도 이 세션들이 확인해온 require-by-string 의미론을 그대로 지원하므로
변환 계층이 불필요하다는 판단(Justfile은 언급 안 돼 도입 안 함).
## 3. mise 전환 — 실제 검증까지 완료
`/tmp`에서 먼저 `mise install`로 pesde/rojo를 테스트 — **GitHub artifact
attestation + SLSA provenance 검증까지 거쳐 설치됨**(이전 세션이 `curl`
직접 받던 것보다 공급망 신뢰도가 높음). `rokit`은 이 샌드박스에 없어
한 번도 못 써봤던 것과 대비되게, `mise`는 이 샌드박스 자체가 이미
`luau` 설치에 쓰고 있어 실제 검증이 가능했음. `rokit.toml` 삭제,
`mise.toml` 신설(`github:`/`aqua:` 백엔드 접두사 문법 — `rokit.toml`
평문 `owner/repo@ver`와 형태가 다름) — `pesde`/`rojo`/`luau-lsp`/
`selene` 넷 다 `mise install`+`mise exec`로 버전 일치까지 재확인.
## 4. selene 도입 — CWD 상대 config 탐색 함정 발견
참고 레포의 `scripts/selene.toml`을 그대로 채택. 처음 루트에 단일
`selene.toml`을 두고 `selene quad-base/`를 저장소 루트에서 돌렸더니
`type Quad = {...}` 같은 평범한 타입 선언까지 전부 파싱 에러(30여 건) —
"이 selene 빌드가 Luau 타입 문법 자체를 지원 안 하나?"로 오인했다가,
최소 재현으로 `std = "luau"`가 CWD에 없으면 조용히 Lua 5.1 std로
폴백한다는 걸 확인(`--config`가 파일 트리를 안 거슬러 올라감, CWD 기준
고정 경로). 참고 레포처럼 **패키지별 독립 `selene.toml`**로 전환하고
`cd quad-base && selene .`처럼 패키지 안에서 실행하는 걸로 확정.
부수로 `quad-base/test/smoke.mock.luau``assert(cond)` 3건(메시지
없음)이 selene의 `incorrect_standard_library_use`(deny)에 걸려 실제
수정(메시지 추가) — 도입하자마자 실제 코드 품질 개선.
## 5. 산출물
- `mise.toml`(루트, `rokit.toml` 대체), `quad-base/selene.toml`,
`quad-roblox/selene.toml` 신설.
- `.claude/base/project-setup-plan.md` — "툴체인" 절 전면 갱신(mise 전환
경위, darklua 기각 근거), "`selene` 린터" 절 신설.
- `.claude/base/architecture.md`/`.claude/README.md`의 `rokit.toml`
잔여 참조 정정.
- `quad-base/test/smoke.mock.luau` — selene이 잡은 `assert` 메시지
누락 3건 수정.
- `initreq/roblox-project-example` 클론 보존(읽기 전용 참고 레포,
`.gitignore`로 이미 제외되는 `initreq/` 하위라 커밋 대상 아님).

View file

@ -0,0 +1,69 @@
# 2026-08-19, 여섯 번째 세션 — `RunInit` 재설계, darklua 경계 정밀화
**요약**: 사용자가 두 가지를 요청. (1) 지난 darklua 기각 근거를 실측으로
정밀화, (2) `New()`의 멱등 Init 가드를 파일마다 `Relate`+센티널을 두는
대신 **함수 자체를 릴레이션 키로 쓰는 공유 `module:RunInit(initFn)`**로
재설계. 둘 다 실제로 구현·검증까지 완료. 이후 대화는 한국어로 진행하기로
합의.
## 1. darklua 경계 실측 — `@self`/`@game`은 안 건드리고, 커스텀 alias만 변환
`darklua` 0.19.0을 직접 설치해 참고 레포(`initreq/roblox-project-example`)에서
`pesde install`+`rojo sourcemap`+`darklua process`를 실제로 돌려봄:
- `require("@self/X")`/`require("@game/...")`는 `convert_require`
손을 안 대고 경고만 찍은 채 원문 그대로 통과(`unknown source name`).
- 커스텀 `.luaurc` alias(`@pkg`)는 실제로
`require(game:GetService('ReplicatedStorage'):WaitForChild('roblox_packages'):WaitForChild('assets'))`
치환됨 — `.luaurc` 매핑 + Rojo sourcemap을 같이 읽어 해석.
결론: quad는 상대경로+`@self`만 쓰므로 지금 darklua는 정말 불필요하지만,
**나중에 `@pkg/quad_base`류 축약 alias를 쓰고 싶어지면 그때는 darklua가
실질적으로 필요해진다** — `project-setup-plan.md`에 이 경계를 정확히 반영.
## 2. `RunInit` 재설계
사용자 제안: 모듈 설정 완료 여부 릴레이션을 파일마다(`INITED` 센티널 +
`Relate()`) 따로 두지 말고, **함수 자체를 릴레이션 키로** 쓰고
`module.RunInit(initfun: (module)->any)`를 구현해 "실행한 적 없으면
실행"을 전담시키자는 것. 근거: `(any)->any : boolean?` 형태의 릴레이션이
간단하고, 파일마다 보일러플레이트를 반복할 이유가 없음.
실제로 반영:
- `quad-base/src/init.luau` — 최상위에 `runInitRelate = Relate()` 하나만
두고, `module.RunInit(self, initFn)``(module, initFn) -> boolean?`
릴레이션으로 실행 여부를 판정. `Quad` 타입에 `RunInit` 필드 추가.
- `quad-base/src/Debug/init.luau` — 가드 보일러플레이트 전부 삭제, 그냥
무조건 `module.debug = false`만(멱등은 호출부 `RunInit`이 보장).
- `quad-base/test/smoke.init.luau` 신설 — 같은 `initFn` 재호출 시
1회만 실행, 서로 다른 `New()` 인스턴스는 기록 비공유, 서로 다른
`initFn`은 서로 무간섭 — 3개 시나리오 전부 검증. `selene``assert`
메시지 누락/미사용 매개변수 몇 건을 잡아 같이 수정.
- `luau`/`luau-analyze`/`selene` 셋 다 클린.
- `module-lifecycle-plan.md` "New()의 내부 구성" 절 — 옛 파일별
센티널 의사코드를 새 `RunInit` 의사코드로 교체, 근거·GC 특성·
`_initializedBy`와의 층위 차이 재서술.
## 3. 미결 — `RunInit`을 backend 설치 진입점에도 재사용할지
사용자 질문: `RunInit``QuadRoblox(Quad) -> QuadRoblox`(backend 주입
진입점) 내부에서도 그대로 써도 되는지. **답은 아직 안 냄**`RunInit`
함수 identity로만 추적하는데, backend 가드(`base/bind-system-plan.md`의
"Bind는 누가, 어떻게 구현하는가" 절)는 "같은 팩토리 재호출=no-op, **다른
팩토리=에러**"라는 계약이 있어서, `InitRoblox`/`InitGtk`처럼 서로 다른
함수가 같은 "백엔드 슬롯"을 다투는 상황을 `RunInit`은 구분 못함(둘 다
"아직 안 돈 함수"라 각자 조용히 실행되어 버림 — 에러가 나야 하는데 안
남). 슬롯 키를 별도로 감싸는 방안을 떠올렸으나 결론은 안 냄 —
`module-lifecycle-plan.md`에 ⚠️ 미결로 반영, M2/M5 착수 전 확인 필요.
## 4. 타입 관련 질문 — 답변 보류
사용자가 "pesde가 타입만 뽑아주는 게 있다고 들었다"며 quad-roblox가
quad-base 타입을 그걸로 갖게 되는지 물음. 워크스페이스 링크(심볼릭
링크로 연결된 실제 소스)를 통한 타입 추론은 이미 지난 세션에 실측
확인됐고 이 메커니즘과는 무관해 보이지만, `HUMAN_TODO.md` 8번이 말하는
"pesde의 타입 추출"(d.ts류, const 지원과 엮인) 기능 자체를 이 세션이
따로 조사하지 않아 정확한 답은 다음 턴에서 이어감.
## 5. 이후 진행
사용자 요청으로 이제부터 대화를 한국어로 진행.

View file

@ -0,0 +1,125 @@
# 2026-08-19, 일곱 번째 세션 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계
**요약**: `RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리
확정한 뒤, 사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면
dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고
`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설,
`AddPlugin<Self,P>` 플러그인 체이닝과 `CheckedQuad<T>` 컴파일 타임
버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 새 Luau 함정을
하나 발견해 `typing-limits.md`에 승격.
## 1. `_initializedBy` 결정 반영
지난 턴에서 제기된 "`RunInit`을 backend 설치에도 재사용해도 되는가"
질문에 사용자가 짧게 답함: `_initializedBy`를 그대로 쓰자. `RunInit`
함수 identity 추적이라 "다른 팩토리 재호출=에러"라는 backend 계약을
못 만족한다는 진단을 그대로 확정, `module-lifecycle-plan.md`에 반영
(예시 `InitRoblox` 의사코드 포함, 실제 구현은 M5).
## 2. dev-dependency 문제 제기 → `quad-types` 신설
사용자 문제 제기 요지: `QuadRoblox(Quad): QuadRoblox`처럼 quad-base를
런타임 주입으로 받으면 quad-roblox가 quad-base를 pesde 의존성으로
선언할 필요가 없어 보이는데, 실제로는 타입 참조 때문에 `require`
필요하다. 이걸 dev-dependency로 두면 게시 후 소비자 환경에서 깨질 것
같은데, 정확히 왜/어떻게 깨지는지 실측해달라는 요청 + "quad-types
폴더를 quad-base 안에 넣고 그것만 링킹하는 게 되는지"/"버전 필드로
타입함수 검증하는 게 되는지" 두 구체적 대안 질문.
**실측 확인**:
- `require(...)`로 타입만 뽑아 쓰는 것도 **런타임에 실제로 실행됨**
대상 모듈을 지우고 돌려보니 진짜 크래시(`could not resolve child
component`). dev-dependency 우려가 정확했음.
- pesde 워크스페이스 의존성은 **패키지 단위**만 가능 — `quad-base/types/`
폴더로는 "가벼운 타입만" 효과를 못 얻음(전체 패키지가 통째로
링크됨). **별도 워크스페이스 멤버로 뽑아야만** 실제로 가벼워짐.
- 버전 체크 타입함수 — 최소 재현으로 즉시 성공(`type function
CheckVersion` + `readproperty`/`value()`로 리터럴 비교).
**결론**: `quad-types` 3번째 워크스페이스 멤버 신설, `pesde.toml`
+ `quad-base`/`quad-roblox` 의존성 전환(quad-roblox는 quad-base 대신
quad-types만 의존)까지 실제로 실행 — `pesde install`로 링크 확인.
`quad-spring`/`quad-spring-roblox` 같은 가상의 다른 플러그인 쌍은 이
분리가 필요 없다고 사용자가 별도로 짚음(quad-base처럼 "거의 모든
패키지가 의존하는 핵심 계약"일 때만 값어치가 있음).
## 3. `AddPlugin<Self,P>` — 제네릭 self 체이닝 실측
`Quad:AddPlugin(pluginFn): T`에서 `T`가 정확히 "플러그인이 누적된
Quad"가 되는지 확인해달라는 요청("타입의 근간인 부분"). 여러 스파이크로
검증:
- `<Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P``Self`
제네릭으로 둬야 체이닝이 누적됨(고정하면 두 번째 호출이 첫 확장을
잃음). 실제로 `Quad & SpringPlugin & OtherPlugin`까지 정확히 누적
확인, 음성 대조군(플러그인 추가 전 접근)도 정확히 거부됨.
- 사용자도 독립적으로 같은 패턴을 직접 테스트해 성공 확인(대화 중
"성공했어" 보고).
- quad-base에 실제 구현 — `pluginFn(self)`의 결과를 `self`에 mutate
(새 테이블 안 만듦, `RunInit`의 identity 추적을 안 끊기 위해).
`smoke.plugin.luau`로 mutate/identity/체이닝 전부 실행 레벨 검증.
## 4. `CheckedQuad<T>` — 배선하며 세 번 깨짐, 세 번째가 핵심 발견
사용자가 구체적 구현 지침을 줌: `error()` 말고 `print()`+`types.never`,
검증 결과는 `__versionCheck` 같은 가상 필드에, 성공 값은 트리비얼하게.
실제로 배선하며 순서대로:
1. **`error()` 시도 → 실패**: type function 자체가 실패로 판정됨.
`print`+`types.never`로 교체 → 즉시 성공(호출부에 정확히
"TypeError: <메시지>").
2. **함수 본문 로컬 타입 별칭 시도 → 무반응**: `type _Check =
CheckVersion<T>`를 본문에 두면 제네릭 인스턴스화마다 재평가 안 됨.
리턴 타입 표현식 자체로 옮기니 즉시 해결.
3. **[가장 중요] 패스스루(`return t`) 버전 → 단독으론 통과, `AddPlugin`
체이닝과 조합하면 조용히 깨짐**: `CheckVersion<T> & RobloxExt` 뒤에
`:AddPlugin(...)`을 부르면 "Expected this to be exactly 'P & Self',
but got 'P & Self'"처럼 앞뒤가 같은 의미 없는 진단이 남. `&`로 안
합쳐도, 패스스루만 거쳐도 동일하게 깨짐 — **재구성이 아니라 "type
function을 거쳤다는 이력 자체"가 문제**. 최종 설계: `CheckVersion`
`T`를 절대 반환하지 않고(성공 시 `types.singleton(true)`만), 결과를
`T & { __versionCheck: CheckVersion<T> }`처럼 원본과 완전히 격리된
필드로만 노출 — 이 형태만 `AddPlugin` 체이닝과 완전히 호환됨(실측).
`__versionCheck`는 실제로 참조해야 평가되는 lazy 필드라는 점도
다시 확인(함정 2와 같은 결).
이 세 번째 발견은 quad 코퍼스에 없던 새 Luau 한계라 `typing-limits.md`
§6으로 승격(6번을 신설하며 기존 6/7/8을 7/8/9로 재번호, 내부 상호
참조 §6/§8도 같이 고침), §8 체크리스트에 항목 7 추가.
## 5. 부수 발견 — quad-base 자기 자신도 CLI symlink 함정에 걸림
`quad-base/src/init.luau``quad-types`를 workspace 의존으로 받게
되며 `require("./roblox_packages/quad_types")`를 쓰게 됐는데, 이건
지난 세션에 발견한 심볼릭 링크 문제(Rojo는 괜찮고 standalone `luau`
CLI만 못 따라감)가 **이제 quad-base 자신의 프로덕션 진입점에도** 닥침 —
지난 세션엔 quad-roblox→quad-base(아직 코드 없음)만 영향권이라 여유가
있었는데, 이번엔 실제로 존재하는 quad-base의 `require`가 막힘. 이
세션은 `pesde install`이 만든 심볼릭 링크를 로컬 CLI 테스트용으로만
실제 디렉토리 복사본으로 치환하는 즉석 조치로 우회(`find ... -type l
... cp -r`) — 정식 스크립트화는 아직 안 함, `project-setup-plan.md`
다음 세션이 알아야 할 것으로 남김.
## 6. 산출물
- `quad-types/`(신규 패키지: `pesde.toml`, `src/init.luau`
`Quad`/`CheckVersion`/`CheckedQuad`), `quad-types/selene.toml`.
- `quad-base/pesde.toml``quad_types` 의존성 추가.
- `quad-roblox/pesde.toml` — 의존성을 `quad_base`→`quad_types`로 전환.
- `pesde.toml`(루트) — `workspace_members``quad-types` 추가.
- `quad-base/src/init.luau``Quad` 타입을 `quad-types`에서 가져오도록
전환, `Version`/`AddPlugin` 실제 구현 추가.
- `quad-base/test/smoke.plugin.luau` 신규.
- `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau`
신규 — 실제 quad-types/quad-base 통합 검증.
- `.claude/base/quad-types-plan.md` 신규 — 전체 설계/함정/실측 근거.
- `.claude/base/typing-limits.md` — §6(신규, type function 이력 오염)
추가 + 6/7/8 재번호(→7/8/9) + 체크리스트 항목 추가.
- `.claude/base/module-lifecycle-plan.md``_initializedBy` 결정 반영.
- `.claude/base/architecture.md`/`.claude/base/project-setup-plan.md`/
`.claude/README.md`/`luau-test/README.md`/`STATUS.md` — 관련 갱신.
## 7. 다음
`quad-roblox``QuadRoblox`/`CheckedQuad<T>` 실사용은 M5. 심볼릭 링크
로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로
대화는 계속 한국어로 진행 중.

View file

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

@ -0,0 +1,78 @@
# 2026-08-19, 아홉 번째 세션 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소
**요약**: 사용자가 "quad-roblox-types도 base 문서에 짧게 언급해둘까,
핸드오버 준비해줘, 빠진 게 있으면 적절히 적어달라"고 요청. 확인해보니
`quad-roblox-types`는 직전 세션에 이미 `quad-types-plan.md`에 반영돼
있었으나, 감사 과정에서 훨씬 큰 두 가지 실제 공백을 발견 — (1)
`session-summary.md`에 오늘 세션 5개(04~08)의 색인 항목이 통째로 빠져
있었고, (2) `CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직
시작 전"이라는 낡은 전제를 그대로 깔고 있었는데 실제로는 오늘 M0(스파이크
4개)/M1(스캐폴딩) 전부가 이미 완료·커밋된 상태였음. 둘 다 발견 즉시
반영, 감사 라운드로 재검증까지 마침.
## 1. `quad-roblox-types` 확인
`base/quad-types-plan.md`의 "남은 것" 절에 이미 백로그로 적혀 있음을
확인(직전 세션 산출물). 추가로 두 곳에 짧은 포인터만 보강 —
`todos.md` 4번 백로그(다른 미래 패키지 아이디어들과 나란히), `ROADMAP.md`
M5 섹션 상단(구현 관례 각주: quad-roblox 공개 타입은 지금부터 단일 파일에
몰아둘 것). 개수/설명 자체는 `quad-types-plan.md`가 계속 유일한 소스.
## 2. `session-summary.md` 색인 공백 발견·수정
`.claude/session/` 폴더엔 오늘 파일이 01~08까지 있는데
`session-summary.md`엔 01~03만 있었음(04~08 다섯 개 누락). 각 세션 파일
상단 "요약" 단락을 압축해 5개 항목 신설. 그 과정에서 session-04 항목이
그 세션 §5(커밋 후 후속 — Rojo/symlink 검증, 스파이크 13→13/22 분리,
에디터 새 솔버 설정 확정)를 놓치고 있는 것도 감사가 잡아내 보강.
## 3. `ROADMAP.md`/`CLAUDE.md`/`project-context.md` 대규모 stale 발견
감사 라운드가 지적: `CLAUDE.md`/`project-context.md`가 "[2026-08-16
기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전"이라고 서술 중인데,
오늘 커밋 로그(`205af32`~`5dfc9b9`, 총 10개)를 보면 M0 스파이크 4개
전부와 M1 스캐폴딩 대부분이 이미 끝나 있었음 — `quad-base/src/init.luau`
동작하는 `New()`/`RunInit`/`AddPlugin`이 실존, smoke 테스트도 전부 PASS.
`Explore` 서브에이전트로 각 M0/M1 체크박스를 하나하나 실측 대조(과대평가
방지):
- **M0 4개 전부 통과**`luau-test/done/05`(다이아몬드 전파, 현행
모델로 재작성됨)/`08`(Source⊇State 제네릭)/`03`(재귀 재-process
디스패치)/`01`+`02`+`13`+`22`(props 두 패스+PreRef/PostRef)/`06`
(컴포넌트 경계 `or None` 관용구).
- **M1 5개 중 4개 확실히 완료, 1개는 항상 충족돼 있던 조건으로 판명**
폴더+`pesde.toml`(단 체크박스 텍스트가 `wally.toml`로 stale — pesde
전환 반영해 정정), `default.project.json`/`.luaurc`, mock 테스트
하네스, `New()`/`RunInit`. `qa-request/`/`archive/` "실사용 시작"
항목은 실제로 M1 이전부터 이미 계속 쓰이고 있어 조건이 항상 참이었음
(모호했던 문구를 명시적으로 정리).
**반영**: `ROADMAP.md` 상단 배너 갱신(M0/M1 완료, M2 착수 예정), M0/M1
체크박스 전부 `[x]` + 근거 스파이크 파일명 추가, "통과 기준"의 하드코딩된
개수("세 개 다") 제거(개수는 `luau-test/STATUS.md`가 소스), M5 섹션에
`quad-roblox-types` 관례 각주. `CLAUDE.md`/`project-context.md` 머리말을
"M0/M1 완료, M2 착수 예정"으로 갱신. `README.md`/`project-context.md`의
`feedback/` 폴더 부재 설명도 "구현 시작 전이라서"에서 "M0/M1 스캐폴딩만
으론 안 생기고 실사용 단계부터"로 정정(폴더가 없다는 결론 자체는 그대로,
근거만 정확하게).
## 4. 감사 루프
핸드오버 체크리스트대로 라운드를 나눠 진행(전부 `quad-doc-auditor` 단독
호출, 병렬 없음):
1. `type-version-check`/`CheckedQuad<T, Pattern>` 관련 변경 감사 3라운드
(직전 턴에서 이미 완료 — 1라운드 5건, 2라운드 3건 발견·수정, 3라운드
무발견으로 수렴).
2. `session-summary.md`/`todos.md` 신규 변경 감사 1라운드 — 위 3번 항목의
대규모 stale(CLAUDE.md/project-context.md/ROADMAP.md)을 여기서 발견.
3. ROADMAP/CLAUDE.md/project-context.md 수정분 재감사 1라운드 — 무발견,
여기서 종료.
`python3 .claude/tools/doc-check.py`는 전 과정에서 ERROR 0 유지(기존
WARN 8건은 이 세션과 무관한 사전 부채).
## 다음에 확인할 것
없음 — M2(디스패치 엔진) 착수가 다음 마일스톤. M2 진입 전 필독 문서는
`ROADMAP.md` 상단 배너와 `todos.md` 0번이 이미 안내하고 있음(`typing-limits.md`/
`dispatch-core-plan.md`).

View file

@ -43,9 +43,9 @@
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
3번에도 올라가 3번에도 올라가
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, 검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/`
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: 문서에도 ⚠️로 표시돼 있다:
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
@ -71,8 +71,11 @@
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
- **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`) - **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가
— M0에서 실측 확인. 나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`,
`ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키
접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store =
Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요.
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
`question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy `question.md`의 최우선 항목이 **전부 비었음**`0-Y`(`:Compute` lazy
@ -197,6 +200,10 @@
참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의 참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의
전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계 전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계
게이트와 무관. 게이트와 무관.
**[2026-08-19 추가]** `quad-roblox-types`(가칭, `quad-types`와 같은
패턴으로 `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한 패키지)도
같은 성격의 백로그로 신설 — 사용자가 지금 만들 필요는 없다고 명시적으로
후순위 지정, 상세는 `base/quad-types-plan.md`의 "남은 것" 절.
5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중
(`HUMAN_TODO.md` 2번 항목). (`HUMAN_TODO.md` 2번 항목).
6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include 6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include

11
.gitignore vendored
View file

@ -3,3 +3,14 @@
# Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음) # Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음)
__pycache__/ __pycache__/
*.pyc *.pyc
# pesde 설치 산출물(각 패키지 로컬에 생김) — pesde.lock 커밋 여부는 아직
# 미정(라이브러리 컨벤션 확인 필요, HUMAN_TODO 참고)이라 여기 안 넣음
roblox_packages/
luau_packages/
lune_packages/
.pesde/
# luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함
# (에디터 타입 링킹용 산출물, 커밋 대상 아님)
sourcemap.json

10
.luaurc Normal file
View file

@ -0,0 +1,10 @@
{
"languageMode": "strict",
"lint": {
"*": true
},
"aliases": {
"quad-base": "quad-base/src",
"quad-roblox": "quad-roblox/src"
}
}

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"luau-lsp.fflags.enableNewSolver": true
}

View file

@ -1,9 +1,10 @@
# CLAUDE.md # CLAUDE.md
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트. Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전** — 같은 **[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치
상태를 `.claude/project-context.md`도 서술하니 M0에 착수하면 두 곳을 같이 엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`
고칠 것. 서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
항상 루트 `ROADMAP.md`.
<!-- [2026-08-16 재구조화] 이 파일은 1537줄까지 불어나 (a) 사람이 검토 불가, <!-- [2026-08-16 재구조화] 이 파일은 1537줄까지 불어나 (a) 사람이 검토 불가,
(b) 공식 권장치(파일당 200줄) 7.7배 초과로 지침 준수도 저하, (c) 에이전트가 (b) 공식 권장치(파일당 200줄) 7.7배 초과로 지침 준수도 저하, (c) 에이전트가

View file

@ -124,23 +124,27 @@ B(Attribute의 Instance 참조 타입)/C(CollectionService 태그 왕복) —
`architecture.md`의 해당 절을 갱신하고 기존 코드의 `architecture.md`의 해당 절을 갱신하고 기존 코드의
`const` 전환 범위를 같이 상의할 것. `const` 전환 범위를 같이 상의할 것.
## 6. **[2026-08-13 신설, 안 막음]** 에디터의 Luau 솔버 설정 확인 ## 6. ~~에디터의 Luau 솔버 설정 확인~~ **[2026-08-19 설정 완료 — VSCode 재시작만 확인해주면 됨]**
`luau-analyze` CLI는 **새 솔버가 기본값**이지만 에디터가 쓰는 `luau-analyze` CLI는 새 솔버가 기본값이지만 에디터가 쓰는 `luau-lsp`
`luau-lsp`**옛 솔버가 기본값**(`LuauSolverV2=false`)이라 **같은 코드에 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라 같은 코드에 다른 진단이
다른 진단이 나옵니다** — 이번 세션에 실제로 겪은 혼선의 원인이었음 나옴 — 예전엔 "실제 에디터 환경에서 확인 필요"로 사람에게 넘겨뒀던
(CLI는 클린인데 에디터엔 빨간 줄). 항목.
옛 솔버는 quad의 `Compute` 시그니처 패턴 자체를 선언 시점에 거부하므로 **[2026-08-19] `luau-lsp` 바이너리(1.69.0, `luau-lsp analyze` CLI 모드)를
사실상 새 솔버 외에 선택지가 없어 보이지만, **실제 사용하시는 에디터 `/code/.local/bin`에 직접 설치해 `--flag:LuauSolverV2=true/false`
환경에서 확인이 필요**합니다. VSCode의 "Luau Language Server" 확장이라면 양쪽으로 실측 — 새 솔버가 필요하다는 결론을 재확인**하고
워크스페이스 `.vscode/settings.json`에: `quad/.vscode/settings.json`을 만들어 `{ "luau-lsp.fflags.enableNewSolver":
true }`를 이미 커밋해뒀음(팀/에디터 전체에 공유됨, 사용자가 손댈 것
없음). `tbox`(다른 참고 레포)도 동일 설정을 이미 쓰고 있어 교차 확인됨.
같이 검토했던 `LuauDoNotExportBrokenTypeFunction` override(tbox가 씀)는
quad의 현재 `type function` 스파이크(`16`/`21`)에서 유무 차이가 없어
**채택 안 함**(불필요한 설정 추가 지양).
```json **사람이 확인해줄 것 하나만 남음**: 이건 CLI로 시뮬레이션한 것이지
{ "luau-lsp.fflags.enableNewSolver": true } VSCode를 실제로 띄운 게 아님 — 다음에 VSCode를 열면 워크스페이스
``` 설정이 잘 먹었는지(같은 `.luau` 파일에서 CLI 결과와 에디터의 빨간 줄이
일치하는지) 한 번만 눈으로 확인해주면 이 항목은 완전히 닫힘. 배경은
M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은
`.claude/base/typing-limits.md` 8번, 실측은 `.claude/base/typing-limits.md` 8번, 실측은
`.claude/audit/type-recursion-issue/REPORT.md` 5절. `.claude/audit/type-recursion-issue/REPORT.md` 5절.
@ -157,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

@ -5,8 +5,16 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서. 체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서.
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가 **2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0 확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
자체는 시작 안 함.** 다음 세션은 바로 M0부터.
> **✅ [2026-08-19 기준] M0 스파이크 4개 전부 통과, M1 스캐폴딩도 대부분
> 완료(quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/
> .luaurc, mock 테스트 하네스, `New()`/`RunInit`/`AddPlugin` 골격 — 아래
> M0/M1 체크박스 참고). 다음은 M2(디스패치 엔진) 착수.** M1 착수 도중
> wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의
> `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는
> `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서
> 먼저 신설됨(`base/quad-types-plan.md`).
> **✅ [2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음.** > **✅ [2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음.**
> `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강 > `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강
@ -29,24 +37,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`architecture.md`/`bind-system-plan.md` 등을 이 시점에 고치는 게 정상 — `architecture.md`/`bind-system-plan.md` 등을 이 시점에 고치는 게 정상 —
실패가 아니라 이 단계의 목적. 실패가 아니라 이 단계의 목적.
- [ ] Store/State push-invalidate → pull-recompute propagation을 실제로 - [x] Store/State push-invalidate → pull-recompute propagation을 실제로
짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할 짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대: 것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
**emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은 **emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은
`:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는 `:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터 `Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`). 침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
스파이크 `05-store-state-diamond-propagation.luau`는 옛 모델을 스파이크 `05-store-state-diamond-propagation.luau`**[2026-08-19
검증 중이라 `rewrite-required/`에 있음) 재작성 완료, `done/`]** 현행 모델("emit은 항상 전파 + `:Get()`
- [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self: 시점 캐시로만 dedup")로 재검증 통과)
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이 Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션, Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
`base/source-state-plan.md` "Source가 State를 만족함" 절 — `State<T>` `base/source-state-plan.md` "Source가 State를 만족함" 절 — `State<T>`
`Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는
피할 수 있어 보이나 실제 검증 전엔 확정 아님) 피할 수 있어 보이나 실제 검증 전엔 확정 아님. **[통과]**
- [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 `luau-test/done/08-type-source-satisfies-state.luau` — 핵심 케이스
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) 통과, 잔여 자기재귀 케이스는 Luau 한계로 별도 확정
- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 (`base/typing-limits.md`), 설계 영향 없음)
- [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 —
`luau-test/done/03-recursive-store-bind-dispatch.luau` 통과)
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션 (2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
@ -62,7 +75,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md` (`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드 "Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
체크리스트 항목도 이 값으로 스파이크할 것. 체크리스트 항목도 이 값으로 스파이크할 것.
- [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, - [x] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성,
`export type Params = {...}`로 타입 체크되는지 확인 `export type Params = {...}`로 타입 체크되는지 확인
(`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or (`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or
None`/`props.Ref or None` 관용구(2026-08-07 열 번째 세션 확정, None`/`props.Ref or None` 관용구(2026-08-07 열 번째 세션 확정,
@ -71,25 +84,38 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
`or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴 `or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴
구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5). 구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5).
M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로 M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로
타입 체크/런타임 양쪽에서 문제없이 동작하는지** 타입 체크/런타임 양쪽에서 문제없이 동작하는지** —
- [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 `luau-test/done/06-component-boundary-nil-hole-props.luau` 통과
`.claude/base/` 갱신 - [x] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서
`.claude/base/` 갱신 — 실제로 여러 차례 발생, 그때마다 반영됨(각
스파이크 항목의 "정정"/"재작성" 표시가 그 기록)
**통과 기준**: 세 개 다 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행. **통과 기준**: 위 항목들이 Luau에서 자연스럽게 짜이는 게 확인되면 M1
안 되면 여기서 관련 `base/` 문서부터 고치고 재시도. 진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`
소스, 여기서 세지 않음).
## M1 — 실제 스캐폴딩 ## M1 — 실제 스캐폴딩
- [ ] `quad-base/`, `quad-roblox/` 폴더 + 각 `wally.toml` - [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19
- [ ] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수: 정정]** 이 체크박스는 원래 `wally.toml`이라 적혀 있었으나 같은 날
wally→pesde 전환이 확정돼 `pesde.toml`로 정정 —
`base/project-setup-plan.md` 참고. `quad-roblox/src`는 아직 빈
폴더 — 실제 소스는 M5)
- [x] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수:
소스 트리 구조 확정" 절 그대로) 소스 트리 구조 확정" 절 그대로)
- [ ] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수 - [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) `luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
- [ ] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템 `quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
- [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별 Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
그대로, 2026-08-19 확정) 그대로, 2026-08-19 확정) — `quad-base/src/init.luau``New()`/
- [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작 `RunInit`/`AddPlugin`으로 구현·smoke 테스트 검증 완료
- [x] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용
시작(**[2026-08-19 확인]** 두 폴더 모두 M1 이전인 설계 단계부터
이미 쓰이고 있었고 — QA 라운드/역전 결정 기록 — M1 착수 이후에도
계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상
항상 충족돼 있었음)
## M2 — 디스패치 엔진 ## M2 — 디스패치 엔진
@ -361,7 +387,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M5 — quad-roblox 최소 프로바이더 ## M5 — quad-roblox 최소 프로바이더
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) > **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
> 백로그 `quad-roblox-types`(가칭, `quad-types`와 같은 패턴)로 쉽게
> 분리할 수 있게 하기 위함. `base/quad-types-plan.md`의 "남은 것" 절이
> 소스.
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) — 진입점
`QuadRoblox(Quad): QuadRoblox``QuadTypes.CheckedQuad<T, Pattern>`으로
주입받은 quad-base 버전을 확인(`base/quad-types-plan.md` 참고)
- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) - [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)
- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` - [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau`
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션

15
default.project.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "quad",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"quad-base": {
"$path": "quad-base/src"
},
"quad-roblox": {
"$path": "quad-roblox/src"
}
}
}
}

13
mise.toml Normal file
View file

@ -0,0 +1,13 @@
# mise-관리 툴체인 — `rokit.toml`에서 전환(2026-08-19 사용자 결정, "요즘은
# rokit 보단 mise로 까는듯 하네. 더 범용적이라 이걸 택하는듯"). 이 판단은
# `Word30210/roblox-project-example`(`initreq/roblox-project-example`,
# 참고용으로 클론)의 `mise.toml`을 실제로 이 세션에서 `mise install`로
# 검증하며 확인됨 — pesde/rojo를 GitHub artifact attestation + SLSA
# provenance 검증까지 거쳐 설치함(이 세션이 직접 curl로 받았던 방식보다
# 안전). 이 샌드박스 자체가 이미 mise로 `luau`를 관리 중이라 rokit보다
# 자연스럽게 맞물림.
[tools]
"github:pesde-pkg/pesde" = "0.7.3+registry.0.2.3"
"github:rojo-rbx/rojo" = "7.7.0"
"github:JohnnyMorganz/luau-lsp" = "1.69.0"
"aqua:Kampfkarren/selene" = "0.31.0"

18
pesde.lock Normal file
View file

@ -0,0 +1,18 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad"
version = "0.0.0"
target = "roblox"
[workspace."qwreey/quad_base"]
roblox = "quad-base"
[workspace."qwreey/quad_roblox"]
roblox = "quad-roblox"
[workspace."qwreey/quad_types"]
roblox = "quad-types"
[workspace."qwreey/type_version_check"]
luau = "type-version-check"

13
pesde.toml Normal file
View file

@ -0,0 +1,13 @@
# 워크스페이스 루트 — 그 자체로는 게시되지 않음(`private = true`).
# `quad-base`/`quad-roblox`가 실제 게시 대상 워크스페이스 멤버.
# 구조는 `.claude/base/architecture.md` "구현 착수: 소스 트리 구조 확정" 절의
# RbxUtil식 모노레포(루트 통합 개발, 서브패키지마다 독립 게시)를 그대로 따르되
# 패키지 매니저만 wally -> pesde로 전환(사용자 결정, 2026-08-19 — dev-dependency
# 지원이 더 낫다는 판단).
name = "qwreey/quad"
version = "0.0.0"
private = true
workspace_members = ["quad-base", "quad-roblox", "quad-types", "type-version-check"]
[target]
environment = "roblox"

23
quad-base/pesde.lock Normal file
View file

@ -0,0 +1,23 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad_base"
version = "0.0.0"
target = "roblox"
[graph."qwreey/quad_types@0.0.0 roblox"]
direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"]
[graph."qwreey/quad_types@0.0.0 roblox".dependencies]
type_version_check = ["qwreey/type_version_check@0.0.0 luau", "standard"]
[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref]
ref_ty = "workspace"
path = "quad-types"
[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref.dependencies]
type_version_check = [{ workspace = "qwreey/type_version_check", version = "^", target = "luau" }, "standard"]
[graph."qwreey/type_version_check@0.0.0 luau".pkg_ref]
ref_ty = "workspace"
path = "type-version-check"

12
quad-base/pesde.toml Normal file
View file

@ -0,0 +1,12 @@
name = "qwreey/quad_base"
version = "0.0.0"
description = "quad — 엔진 무관 코어: Store/State/Source 온톨로지, pluggable 디스패치 엔진"
includes = ["src/*"]
[target]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"
[dependencies]
quad_types = { workspace = "qwreey/quad_types", version = "^" }

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

@ -0,0 +1,31 @@
std = "luau"
exclude = ["**/.pesde/*", "*_packages/*"]
[lints]
almost_swapped = "deny"
constant_table_comparison = "deny"
deprecated = "warn"
divide_by_zero = "warn"
empty_if = "deny"
empty_loop = "deny"
high_cyclomatic_complexity = "warn"
if_same_then_else = "deny"
ifs_same_cond = "warn"
incorrect_standard_library_use = "deny"
manual_table_clone = "warn"
mismatched_arg_count = "deny"
mixed_table = "allow"
multiple_statements = "deny"
must_use = "warn"
parenthese_conditions = "deny"
suspicious_reverse_loop = "deny"
type_check_inside_call = "deny"
unbalanced_assignments = "warn"
undefined_variable = "deny"
unscoped_variables = "deny"
unused_variable = "warn"
[config]
empty_if = { comments_count = true }
empty_loop = { comments_count = true }
multiple_statements = { one_line_if = "break-return-only" }

View file

@ -0,0 +1,15 @@
--[[
InitDebug(module) — `module.debug` 표면 설치.
`.claude/base/module-lifecycle-plan.md` "모듈 표면의 디버그 플래그" /
"RunInit — 함수 자체를 릴레이션 키로 쓰는 멱등 가드" 절 그대로.
멱등 가드는 `module:RunInit(InitDebug)` 호출부(`init.luau`)가 이미
보장하므로, 이 파일은 조건 없이 딱 한 번만 실행된다는 전제로 그냥
뮤테이션만 하면 됨 — 자기 자신의 가드를 따로 안 둠.
]]
local function Init(module: any)
module.debug = false
end
return Init

92
quad-base/src/Relate.luau Normal file
View file

@ -0,0 +1,92 @@
--[[
Relate — inst를 weak 키로 하는 범용 릴레이션 프리미티브.
`.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현.
]]
export type Relate = {
SetStrong: (self: Relate, inst: any, key: any, value: any) -> (),
GetStrong: (self: Relate, inst: any, key: any) -> any?,
SetWeak: (self: Relate, inst: any, key: any, value: any) -> (),
GetWeak: (self: Relate, inst: any, key: any) -> any?,
}
type Bucket = {
StrongMap: { [any]: any }?,
WeakMap: { [any]: any }?,
}
-- 모든 WeakMap이 공유하는 단일 메타테이블 — 매번 새로 만들 이유가 없음
local WEAK_VALUE_MT = { __mode = "v" }
local RelateImpl = {}
RelateImpl.__index = RelateImpl
local function getBucket(self, inst: any): Bucket?
return self.buckets[inst]
end
local function getOrCreateBucket(self, inst: any): Bucket
local bucket = self.buckets[inst]
if bucket == nil then
bucket = {} :: Bucket
self.buckets[inst] = bucket
end
return bucket
end
function RelateImpl:SetStrong(inst: any, key: any, value: any)
local bucket = getOrCreateBucket(self, inst)
local strongMap = bucket.StrongMap
if strongMap == nil then
local newMap = {}
bucket.StrongMap = newMap
newMap[key] = value
return
end
strongMap[key] = value
end
function RelateImpl:GetStrong(inst: any, key: any): any?
local bucket = getBucket(self, inst)
if bucket == nil then
return nil
end
local strongMap = bucket.StrongMap
if strongMap == nil then
return nil
end
return strongMap[key]
end
function RelateImpl:SetWeak(inst: any, key: any, value: any)
local bucket = getOrCreateBucket(self, inst)
local weakMap = bucket.WeakMap
if weakMap == nil then
local newMap = setmetatable({}, WEAK_VALUE_MT)
bucket.WeakMap = newMap
newMap[key] = value
return
end
weakMap[key] = value
end
function RelateImpl:GetWeak(inst: any, key: any): any?
local bucket = getBucket(self, inst)
if bucket == nil then
return nil
end
local weakMap = bucket.WeakMap
if weakMap == nil then
return nil
end
return weakMap[key]
end
local function Relate(): Relate
local self = setmetatable({
buckets = setmetatable({}, { __mode = "k" }) :: { [any]: Bucket },
}, RelateImpl)
return (self :: any) :: Relate
end
return Relate

54
quad-base/src/init.luau Normal file
View file

@ -0,0 +1,54 @@
--[[
quad-base 최상위 진입점.
`.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md`
"New()의 내부 구성 — InitXxx 팩토리 체이닝" / "RunInit — 함수 자체를
릴레이션 키로 쓰는 멱등 가드" / "AddPlugin" 절 그대로.
`require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중
인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출.
`Quad` 타입 자체는 여기서 선언하지 않고 `quad-types`(워크스페이스
패키지, 구현 없이 타입 계약만)에서 그대로 가져와 씀 — quad-roblox 같은
백엔드 패키지가 이 무거운 quad-base 전체 대신 `quad-types`만 의존할 수
있게 하기 위함(`.claude/base/project-setup-plan.md` "quad-types" 절).
]]
local Relate = require("@self/Relate")
local QuadTypes = require("./roblox_packages/quad_types")
local InitDebug = require("@self/Debug")
type Quad = QuadTypes.Quad
-- (module, initFn) -> 이미 실행됐는가. New()가 몇 번 불려도 module마다
-- weak-키잉되므로 별도 정리 불필요(module이 GC되면 이 기록도 같이 사라짐).
local runInitRelate = Relate()
local function New(): Quad
local module = {
New = New,
Version = "0.0.0",
} :: Quad
function module.RunInit(self, initFn)
if runInitRelate:GetStrong(self, initFn) then
return -- 이 module 인스턴스에 이 initFn은 이미 실행됨, no-op
end
runInitRelate:SetStrong(self, initFn, true) -- 실제 실행 전에 먼저 표시(순환 의존 대비)
initFn(self)
end
function module.AddPlugin(self, pluginFn)
local extension = pluginFn(self)
for k, v in extension :: any do
(self :: any)[k] = v
end
return self :: any
end
module:RunInit(InitDebug)
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
return module
end
return New()

227
quad-base/test/mock.luau Normal file
View file

@ -0,0 +1,227 @@
--[[
quad-base용 최소 mock — Vide `test/mock.luau` 선례를 따르되 범위를 더
좁힘(`.claude/base/architecture.md` "테스트 전략" 절): parent/children
트리 + 타입 검증 없는 property bag + property별 변경 시그널만. `IsA()`/
클래스별 프로퍼티 스키마/`WaitForChild`/`DataModel`은 만들지 않는다 —
quad-base 코어는 `inst`를 `any`로 취급하고 Instance 특정 동작을 참조하지
않으므로 mock이 실제 Roblox 충실도를 가질 이유가 없다(같은 절).
Vide의 mock과 달리 GC-독립 userdata 프록시 트릭을 안 씀 — 그건 Roblox
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base
정적 스냅샷 테스트 범위 밖.
]]
export type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (),
}
export type Connection = {
Connected: boolean,
Disconnect: (self: Connection) -> (),
}
local Signal = {}
Signal.__index = Signal
local function newSignal(): Signal
return (setmetatable({ connections = {} }, Signal) :: any) :: Signal
end
function Signal:Connect(fn: (...any) -> ()): Connection
local connections = (self :: any).connections
local conn = { Connected = true, fn = fn }
function conn.Disconnect(self2)
if not self2.Connected then
return
end
self2.Connected = false
local i = table.find(connections, conn)
if i then
table.remove(connections, i)
end
end
table.insert(connections, conn)
return (conn :: any) :: Connection
end
function Signal:Fire(...: any)
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
local snapshot = table.clone((self :: any).connections)
for i = #snapshot, 1, -1 do
local conn = snapshot[i]
if conn.Connected then
conn.fn(...)
end
end
end
export type MockInstance = {
ClassName: string,
Name: string,
Parent: MockInstance?,
Destroying: Signal,
GetPropertyChangedSignal: (self: MockInstance, property: string) -> Signal,
GetChildren: (self: MockInstance) -> { MockInstance },
FindFirstChild: (self: MockInstance, name: string) -> MockInstance?,
Destroy: (self: MockInstance) -> (),
[string]: any, -- 타입 검증 없는 property bag
}
type Data = {
className: string,
name: string,
parent: Data?,
children: { Data },
changed: { [any]: Signal },
properties: { [any]: any },
destroying: Signal,
}
local dataOf = setmetatable({}, { __mode = "k" }) :: { [any]: Data }
local proxyOf = setmetatable({}, { __mode = "v" }) :: { [Data]: any }
local methods = {}
local function fireChanged(data: Data, key: any)
local sig = data.changed[key]
if sig then
sig:Fire()
end
end
local function getData(inst: any): Data
local data = dataOf[inst]
if data == nil then
error("not a mock instance", 2)
end
return data
end
local function getProxy(data: Data): any
local existing = proxyOf[data]
if existing then
return existing
end
local proxy = setmetatable({}, {
__index = function(_, key)
if methods[key] then
return methods[key]
elseif key == "Name" then
return data.name
elseif key == "ClassName" then
return data.className
elseif key == "Parent" then
if data.parent == nil then
return nil
end
return getProxy(data.parent)
elseif key == "Destroying" then
return data.destroying
else
return data.properties[key]
end
end,
__newindex = function(_, key, value)
if key == "Name" then
if type(value) ~= "string" then
error("Name must be a string", 2)
end
data.name = value
elseif key == "Parent" then
assert(value == nil or dataOf[value] ~= nil, "attempt to set non-instance as Parent")
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
end
if value == nil then
data.parent = nil
else
local newParentData = getData(value)
data.parent = newParentData
table.insert(newParentData.children, data)
end
else
data.properties[key] = value
end
fireChanged(data, key)
end,
})
dataOf[proxy] = data
proxyOf[data] = proxy
return proxy
end
local Instance = {}
function Instance.new(className: string): MockInstance
local data: Data = {
className = className,
name = className,
parent = nil,
children = {},
changed = {},
properties = {},
destroying = newSignal(),
}
return (getProxy(data) :: any) :: MockInstance
end
function methods.GetPropertyChangedSignal(inst: any, property: string): Signal
local data = getData(inst)
local sig = data.changed[property]
if sig == nil then
sig = newSignal()
data.changed[property] = sig
end
return sig
end
function methods.GetChildren(inst: any): { any }
local data = getData(inst)
local out = table.create(#data.children)
for i, childData in data.children do
out[i] = getProxy(childData)
end
return out
end
function methods.FindFirstChild(inst: any, name: string): any?
local data = getData(inst)
for _, childData in data.children do
if childData.name == name then
return getProxy(childData)
end
end
return nil
end
function methods.Destroy(inst: any)
local data = getData(inst)
data.destroying:Fire()
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
data.parent = nil
fireChanged(data, "Parent")
end
end
local function isMockInstance(value: any): boolean
return dataOf[value] ~= nil
end
return {
Instance = Instance,
newSignal = newSignal,
isMockInstance = isMockInstance,
}

View file

@ -0,0 +1,67 @@
--[[ 임시 스모크 테스트 — New()/RunInit의 멱등 가드가 기대대로 동작하는지 확인 ]]
local Quad = require("../src")
print("=== 1. New()가 만든 module엔 InitDebug가 이미 반영돼 있음 ===")
assert(Quad.debug == false, "top-level require(quad-base)도 New()를 거쳐야 함")
print("PASS")
print()
print("=== 2. RunInit은 같은 함수를 다시 넘겨도 재실행하지 않음 ===")
do
local runCount = 0
local function initFn(module)
runCount += 1
module.marker = true
end
Quad:RunInit(initFn)
Quad:RunInit(initFn)
Quad:RunInit(initFn)
assert(runCount == 1, "같은 initFn을 여러 번 RunInit해도 실제 실행은 1회여야 함")
assert(Quad.marker == true, "실행됐다면 마커가 세팅돼 있어야 함")
print("PASS: " .. runCount .. "회 호출 중 실제 실행 " .. runCount .. "회")
end
print()
print("=== 3. New()로 만든 서로 다른 module은 RunInit 기록을 공유하지 않음 ===")
do
local runCount = 0
local function initFn(_module)
runCount += 1
end
local a = Quad.New()
local b = Quad.New()
assert(a ~= b, "New()는 매번 독립된 module을 만들어야 함")
a:RunInit(initFn)
b:RunInit(initFn)
assert(runCount == 2, "서로 다른 module 인스턴스는 각자 한 번씩 실행돼야 함(총 2회)")
print("PASS: 독립 인스턴스 2개 각각 1회씩, 총 " .. runCount .. "회")
end
print()
print("=== 4. 서로 다른 initFn은 서로의 실행 여부에 영향을 안 줌 ===")
do
local m = Quad.New()
local aRan, bRan = false, false
local function initA(_module)
aRan = true
end
local function initB(_module)
bRan = true
end
m:RunInit(initA)
assert(aRan == true and bRan == false, "initA만 돌았어야 함")
m:RunInit(initB)
assert(aRan == true and bRan == true, "이제 둘 다 돌았어야 함")
m:RunInit(initA) -- no-op이어야 함, 에러 없이 통과하면 충분
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,59 @@
--[[ 임시 스모크 테스트 — mock.luau 자체가 기대대로 동작하는지 확인 ]]
local mock = require("./mock")
local Instance = mock.Instance
print("=== 1. property bag + changed signal ===")
do
local frame = Instance.new("Frame")
local fired = {}
frame:GetPropertyChangedSignal("Visible"):Connect(function()
table.insert(fired, frame.Visible)
end)
frame.Visible = false
frame.Visible = true
assert(#fired == 2 and fired[1] == false and fired[2] == true, "changed signal must fire per write with the new value visible")
print("PASS")
end
print()
print("=== 2. parent/children tree ===")
do
local root = Instance.new("Frame")
local child1 = Instance.new("Frame")
local child2 = Instance.new("Frame")
child1.Name = "A"
child2.Name = "B"
child1.Parent = root
child2.Parent = root
local children = root:GetChildren()
assert(#children == 2, "expected 2 children")
assert(root:FindFirstChild("A") == child1, "FindFirstChild must locate child by Name")
assert(root:FindFirstChild("B") == child2, "FindFirstChild must locate child by Name")
assert(child1.Parent == root, "Parent must read back what was assigned")
child1.Parent = nil
assert(#root:GetChildren() == 1, "removing Parent must detach from old parent's children")
print("PASS")
end
print()
print("=== 3. Destroy fires Destroying + detaches ===")
do
local root = Instance.new("Frame")
local child = Instance.new("Frame")
child.Parent = root
local destroyed = false
child.Destroying:Connect(function()
destroyed = true
end)
child:Destroy()
assert(destroyed, "Destroying must fire on Destroy")
assert(#root:GetChildren() == 0, "Destroy must detach from parent")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,55 @@
--[[ 임시 스모크 테스트 — Quad.Version/AddPlugin이 실제로 동작하는지 확인 ]]
local Quad = require("../src")
print("=== 1. Version 필드가 pesde.toml과 맞음 ===")
assert(Quad.Version == "0.0.0", "quad-base/pesde.toml의 version과 일치해야 함")
print("PASS")
print()
print("=== 2. AddPlugin이 원본 identity를 유지한 채(mutate) 확장 필드를 추가함 ===")
do
local function springPlugin(_self)
return {
Spring = function(_selfArg, v)
return v * 2
end,
}
end
local withSpring = Quad:AddPlugin(springPlugin)
assert(withSpring == Quad, "AddPlugin은 원본 module을 그대로 mutate+반환해야 함(RunInit 추적이 identity에 의존)")
assert(withSpring:Spring(21) == 42, "확장된 메소드가 실제로 동작해야 함")
assert(withSpring.debug == false, "원본 필드는 그대로 남아있어야 함")
print("PASS")
end
print()
print("=== 3. 체이닝 — 여러 플러그인을 연속으로 추가해도 전부 누적됨 ===")
do
local m = Quad.New()
local function pluginA(_self)
return {
A = function(_selfArg)
return "a"
end,
}
end
local function pluginB(_self)
return {
B = function(_selfArg)
return "b"
end,
}
end
local withBoth = m:AddPlugin(pluginA):AddPlugin(pluginB)
assert(withBoth:A() == "a", "pluginA의 확장 메소드가 동작해야 함")
assert(withBoth:B() == "b", "pluginB의 확장 메소드가 동작해야 함")
assert(withBoth.debug == false, "원본 필드는 체이닝 후에도 남아있어야 함")
print("PASS")
end
print()
print("=== ALL PASS ===")

23
quad-roblox/pesde.lock Normal file
View file

@ -0,0 +1,23 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad_roblox"
version = "0.0.0"
target = "roblox"
[graph."qwreey/quad_types@0.0.0 roblox"]
direct = ["quad_types", { workspace = "qwreey/quad_types", version = "^" }, "standard"]
[graph."qwreey/quad_types@0.0.0 roblox".dependencies]
type_version_check = ["qwreey/type_version_check@0.0.0 luau", "standard"]
[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref]
ref_ty = "workspace"
path = "quad-types"
[graph."qwreey/quad_types@0.0.0 roblox".pkg_ref.dependencies]
type_version_check = [{ workspace = "qwreey/type_version_check", version = "^", target = "luau" }, "standard"]
[graph."qwreey/type_version_check@0.0.0 luau".pkg_ref]
ref_ty = "workspace"
path = "type-version-check"

12
quad-roblox/pesde.toml Normal file
View file

@ -0,0 +1,12 @@
name = "qwreey/quad_roblox"
version = "0.0.0"
description = "quad — Roblox 백엔드: quad-base의 pluggable 인터페이스를 실제로 구현"
includes = ["src/*"]
[target]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"
[dependencies]
quad_types = { workspace = "qwreey/quad_types", version = "^" }

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

@ -0,0 +1,31 @@
std = "luau"
exclude = ["**/.pesde/*", "*_packages/*"]
[lints]
almost_swapped = "deny"
constant_table_comparison = "deny"
deprecated = "warn"
divide_by_zero = "warn"
empty_if = "deny"
empty_loop = "deny"
high_cyclomatic_complexity = "warn"
if_same_then_else = "deny"
ifs_same_cond = "warn"
incorrect_standard_library_use = "deny"
manual_table_clone = "warn"
mismatched_arg_count = "deny"
mixed_table = "allow"
multiple_statements = "deny"
must_use = "warn"
parenthese_conditions = "deny"
suspicious_reverse_loop = "deny"
type_check_inside_call = "deny"
unbalanced_assignments = "warn"
undefined_variable = "deny"
unscoped_variables = "deny"
unused_variable = "warn"
[config]
empty_if = { comments_count = true }
empty_loop = { comments_count = true }
multiple_statements = { one_line_if = "break-return-only" }

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

@ -0,0 +1,13 @@
# 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"
[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"

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

@ -0,0 +1,12 @@
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"
[dependencies]
type_version_check = { workspace = "qwreey/type_version_check", version = "^", target = "luau" }

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

@ -0,0 +1,31 @@
std = "luau"
exclude = ["**/.pesde/*", "*_packages/*"]
[lints]
almost_swapped = "deny"
constant_table_comparison = "deny"
deprecated = "warn"
divide_by_zero = "warn"
empty_if = "deny"
empty_loop = "deny"
high_cyclomatic_complexity = "warn"
if_same_then_else = "deny"
ifs_same_cond = "warn"
incorrect_standard_library_use = "deny"
manual_table_clone = "warn"
mismatched_arg_count = "deny"
mixed_table = "allow"
multiple_statements = "deny"
must_use = "warn"
parenthese_conditions = "deny"
suspicious_reverse_loop = "deny"
type_check_inside_call = "deny"
unbalanced_assignments = "warn"
undefined_variable = "deny"
unscoped_variables = "deny"
unused_variable = "warn"
[config]
empty_if = { comments_count = true }
empty_loop = { comments_count = true }
multiple_statements = { one_line_if = "break-return-only" }

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

@ -0,0 +1,55 @@
--!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` 참고)
]]
local TypeVersionCheck = require("./luau_packages/type_version_check")
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,
}
--[[
`CheckedQuad<T, Pattern>` — 주입된 값의 실제 타입 `T`가 `Pattern`
(글롭/캐럿 문자열, `type-version-check` 패키지 참고 — `"*"`/`"N^"`/정확값)에
맞는 `Version`을 갖는지 컴파일 타임에 확인. quad-base/quad-roblox
자신은 항상 같은 모노레포에서 같이 개발되므로 지금은 정확히 일치하는
`"0.0.0"` 패턴으로만 쓰지만(아래 사용법), quad-spring-roblox류
**독립적으로 게시되는 백엔드 플러그인**은 `"0.*.*"`처럼 느슨한 패턴을
직접 골라 쓸 수 있다 — 그래서 패턴을 이 타입의 파라미터로 열어둠.
**⚠️ 반드시 "가상 필드"로만 쓸 것 — `T`를 직접 패스스루하지 말 것.**
`TypeVersionCheck.CheckVersion<Actual, Pattern>` 자체가 이미 이 규칙을
지키게 설계돼 있다(트리비얼한 `true`만 반환, `T`를 절대 참조/재구성
안 함) — `type function`을 거친 값은 패스스루라도 이후 `AddPlugin`
같은 제네릭 self 메소드 체이닝이 조용히 깨진다는 게 실측 확인됐기
때문(`typing-limits.md` §6, `type-version-check/src/init.luau`
참고).
**사용법(필수 — 이 필드를 실제로 참조해야 체크가 평가된다)**:
```lua
local checked: QuadTypes.CheckedQuad<typeof(injectedQuad), "0.0.0"> = injectedQuad :: any
local _ = checked.__versionCheck -- ⚠️ 이 줄이 없으면 체크가 조용히 스킵됨(lazy 평가)
-- 이후 checked는 injectedQuad와 완전히 같은 타입(원본 그대로) —
-- AddPlugin 체이닝 등 뒤이은 제네릭 연산이 전부 안전하게 동작함
```
]]
export type CheckedQuad<T, Pattern> = T & { __versionCheck: TypeVersionCheck.CheckVersion<index<T, "Version">, Pattern> }
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,
}