Compare commits
11 commits
14f733dde1
...
871c582771
| Author | SHA1 | Date | |
|---|---|---|---|
| 871c582771 | |||
| 5dfc9b9a43 | |||
| 297c4d459c | |||
| 1de031e139 | |||
| 9c3bfc890a | |||
| ef3d952dd4 | |||
| 2de2f99cc4 | |||
| 0b471535a3 | |||
| 7e4f2a77fe | |||
| 0c4c4a0537 | |||
| 205af32da4 |
56 changed files with 3291 additions and 466 deletions
File diff suppressed because one or more lines are too long
|
|
@ -177,17 +177,58 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
|
|||
아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제
|
||||
폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션.
|
||||
|
||||
**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립
|
||||
wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의
|
||||
타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0`
|
||||
에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/
|
||||
RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체
|
||||
`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc`의
|
||||
`aliases`는 **런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가
|
||||
지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기
|
||||
자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로
|
||||
쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만
|
||||
유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수.
|
||||
**패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지
|
||||
매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의
|
||||
타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립
|
||||
패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정
|
||||
(2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다
|
||||
툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다
|
||||
독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을
|
||||
pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트
|
||||
`workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }`
|
||||
의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제
|
||||
자체도 이 전환으로 같이 해소됨 — **[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 참고)에서도
|
||||
재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라
|
||||
|
|
@ -206,10 +247,18 @@ op" 절.
|
|||
|
||||
```
|
||||
quad/
|
||||
├── .luaurc # @quad-base, @quad-roblox alias (편집기 경험용, 런타임 비의존)
|
||||
├── .luaurc # 편집기 경험용 alias(런타임 비의존, `base/project-setup-plan.md` 참고)
|
||||
├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀
|
||||
├── pesde.toml # 워크스페이스 루트(private, workspace_members)
|
||||
├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트
|
||||
├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad<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/
|
||||
│ ├── wally.toml
|
||||
│ ├── pesde.toml
|
||||
│ └── src/
|
||||
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
||||
│ ├── 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 개념 없음
|
||||
│ └── init.luau
|
||||
└── quad-roblox/
|
||||
├── wally.toml
|
||||
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
||||
└── src/
|
||||
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
|
||||
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
||||
|
|
|
|||
|
|
@ -85,67 +85,105 @@ export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제
|
|||
|
||||
**순서 의존성은 각 `InitXxx`를 `require`처럼 멱등하게 만들어서 해소한다**
|
||||
(2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`가
|
||||
직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저
|
||||
**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`을
|
||||
weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미
|
||||
됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require`
|
||||
캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는
|
||||
한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을
|
||||
여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프
|
||||
캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다.
|
||||
직접 관리할 필요가 없다.
|
||||
|
||||
**[2026-08-19 같은 날 후속 정정] 멱등 가드는 `RunInit` 하나로 통합됨 —
|
||||
파일마다 `Relate()`/`INITED` 센티널을 따로 두지 않는다.** 원래는 각
|
||||
`InitXxx` 파일이 자기 톱레벨에 `local relate = Relate()`를 두고 파일
|
||||
전용 센티널 키(`INITED`)로 "이 `module`에 이미 Init됐는가"를 기록하는
|
||||
보일러플레이트를 파일마다 반복했는데, **사용자 지적**: 그 반복 자체가
|
||||
불필요하다 — **함수 자기 자신을 릴레이션 키로 쓰면** 센티널이 따로
|
||||
필요 없다(`Relate<T, (any)->any, boolean>`이 곧 "이 함수, 이 모듈에
|
||||
실행했는가" 표가 됨). `module` 인스턴스마다 공유하는 `RunInit` 메서드
|
||||
하나가 이 판단을 전담하고, 개별 `InitXxx` 파일은 **가드 없이 그냥
|
||||
뮤테이션만** 하면 된다:
|
||||
|
||||
```lua
|
||||
-- Dispatch/init.luau
|
||||
-- 최상위 init.luau
|
||||
local Relate = require(...)
|
||||
local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스
|
||||
local runInitRelate = Relate() -- (module, initFn) -> 이미 실행됐는가, 파일 스코프 공유
|
||||
|
||||
local function Init(module)
|
||||
if relate:GetStrong(module, INITED) then
|
||||
return -- 이미 이 module 인스턴스엔 Init됨, no-op
|
||||
local function New(): Quad
|
||||
local module = { New = New } :: Quad
|
||||
|
||||
function module.RunInit(self, initFn)
|
||||
if runInitRelate:GetStrong(self, initFn) then
|
||||
return -- 이 module 인스턴스에 이 initFn은 이미 실행됨, no-op
|
||||
end
|
||||
relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고)
|
||||
-- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음
|
||||
-- 예: InitLifetime(module)
|
||||
local ...
|
||||
module.Dispatch = ...
|
||||
runInitRelate:SetStrong(self, initFn, true) -- 실행 전에 먼저 표시(순환 의존 대비)
|
||||
initFn(self)
|
||||
end
|
||||
|
||||
module:RunInit(InitDebug)
|
||||
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 추가
|
||||
return module
|
||||
end
|
||||
```
|
||||
|
||||
```lua
|
||||
-- Debug/init.luau — 가드 없이 그냥 뮤테이션만(RunInit이 이미 1회만 보장)
|
||||
local function Init(module)
|
||||
module.debug = false
|
||||
end
|
||||
return Init
|
||||
```
|
||||
|
||||
이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`require`가
|
||||
의존 그래프를 알아서 풀어주는 것과 같은 감각), 최상위 `New()`는 순서를
|
||||
신경 쓰지 않고 아는 `InitXxx(module)`을 전부 호출해도 된다 — 이미 누가
|
||||
먼저 채웠으면 알아서 스킵된다.
|
||||
|
||||
- **GC와도 자연히 맞물림**: `relate-plan.md`의 "API" 절에 따르면 `Relate`의
|
||||
**첫 인자(`inst`, 여기선 `module`)는 항상 weak**다(선택의 여지가 없는
|
||||
고정 동작 — `Weak`/`Strong` 구분은 오직 `value` 쪽 보관 방식만 가리킴).
|
||||
그래서 `value` 쪽을 `SetWeak`으로 두든 `SetStrong`으로 두든 상관없이,
|
||||
어떤 `Quad` 인스턴스(전체 `module` 테이블)가 더 이상 참조되지 않아
|
||||
수거되면 이 Init-완료 기록도 같이 사라진다 — 별도 정리 로직 불필요.
|
||||
`relate-plan.md`가 이미 확정해둔 "각 모듈이 자기 톱레벨에 `Relate()`
|
||||
하나를 두고 재사용" 관례를 그대로 쓰는 것이라 새 메커니즘 아님.
|
||||
(`relate-plan.md`가 별도로 명시한 "명시적으로 만든 기록은 명시적으로
|
||||
지울 것" 원칙과 충돌하는 게 아니라 — 이 경우는 기록의 키 자체가 죽으면
|
||||
그 기록을 다시 조회할 주체 자체가 사라지므로 지울 대상이 없어지는,
|
||||
원칙이 애초에 상정하지 않은 자리다.)
|
||||
- **`value`는 `SetStrong`으로 통일**: 위 문단대로 GC 결과엔 차이가 없지만
|
||||
(boolean 리터럴은 애초에 GC 대상이 아님), `relate-plan.md`의 일반 규칙
|
||||
"다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 기준으로는 이 `true`
|
||||
플래그를 다른 어디도 붙잡고 있지 않으므로 `SetStrong`이 그 규칙에 맞는
|
||||
선택이다.
|
||||
- **의존성을 갖는 `InitXxx`도 패턴이 그대로 유지된다** — 자기 의존성을
|
||||
`module:RunInit(InitLifetime)`처럼 부르기만 하면 되고, `RunInit` 자체가
|
||||
멱등하므로 여러 `InitXxx`가 같은 의존성을 부르는 순서/중복은 걱정할
|
||||
필요 없다(옛 설계와 결론은 같음, 가드 소유 위치만 파일별→공유로 이동).
|
||||
- **GC와도 자연히 맞물림** — `relate-plan.md`의 "API" 절에 따르면
|
||||
`Relate`의 **첫 인자(`inst`, 여기선 `module`)는 항상 weak**이므로,
|
||||
`Quad` 인스턴스가 더 이상 참조되지 않아 수거되면 이 Init-완료 기록도
|
||||
같이 사라진다(별도 정리 로직 불필요, `value`는 `SetStrong` — boolean
|
||||
리터럴이라 GC 결과엔 무관하지만 "다른 곳에서 안전하게 유지되는 것만
|
||||
`SetWeak`" 일반 규칙에 맞음).
|
||||
- **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제
|
||||
정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend
|
||||
팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개
|
||||
계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base
|
||||
내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현
|
||||
디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지
|
||||
않게 구분해서 쓸 것.
|
||||
정의는 `base/bind-system-plan.md`)와는 여전히 다른 층위** — 그건
|
||||
backend 팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야
|
||||
하는 공개 계약(같은 팩토리 재호출=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`처럼
|
||||
상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저
|
||||
표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시
|
||||
미완성 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는 누가, 어떻게 구현하는가
|
||||
|
||||
|
|
|
|||
373
.claude/base/project-setup-plan.md
Normal file
373
.claude/base/project-setup-plan.md
Normal 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` 커밋 여부 최종 확정(위 절 권고는 잠정)
|
||||
261
.claude/base/quad-types-plan.md
Normal file
261
.claude/base/quad-types-plan.md
Normal 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` 참고.
|
||||
|
|
@ -58,18 +58,19 @@ State를 만족함" 절).
|
|||
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
|
||||
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
|
||||
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
|
||||
- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를
|
||||
무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라
|
||||
타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는
|
||||
타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금
|
||||
설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는
|
||||
이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입
|
||||
에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로
|
||||
확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인
|
||||
상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지.
|
||||
`luau-test/done/16-*`(type function으로 `Store<T>` 레코드 필드 합성)에
|
||||
이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는
|
||||
경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이
|
||||
오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고,
|
||||
방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field:
|
||||
type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입
|
||||
에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉
|
||||
`Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이
|
||||
합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다.
|
||||
**[2026-08-19 실측 완료]** 맞았음 —
|
||||
`luau-test/done/21-type-store-undeclared-key-rejected.luau`가
|
||||
`ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로
|
||||
접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키
|
||||
3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는
|
||||
`:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
|
||||
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
|
||||
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던
|
||||
|
|
|
|||
|
|
@ -220,12 +220,12 @@ type State<T> = {
|
|||
with different parameters"로 선언 시점에 막힘, 실측:
|
||||
`type-recursive-issue-with-typeof/spikes/
|
||||
19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도
|
||||
§8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
||||
§9의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
||||
전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다.
|
||||
|
||||
**시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**:
|
||||
콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone`
|
||||
체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
||||
체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
||||
self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면
|
||||
(= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단
|
||||
두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable`
|
||||
|
|
@ -361,7 +361,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>` 안에서
|
||||
`-> 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건이어도 타입이 제대로
|
||||
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
|
||||
|
|
@ -421,14 +490,16 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
|
|||
|
||||
---
|
||||
|
||||
## 8. 미해결 / 추적 중
|
||||
## 9. 미해결 / 추적 중
|
||||
|
||||
- **에디터(`luau-lsp`)의 솔버 설정** — `luau-analyze` CLI는 새 솔버가
|
||||
기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라
|
||||
같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면
|
||||
`"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터
|
||||
환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상
|
||||
새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요.
|
||||
- **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze`
|
||||
CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**
|
||||
(`LuauSolverV2=false`)이라 같은 코드에 다른 진단이 남. `luau-lsp`
|
||||
바이너리(1.69.0)를 직접 설치해 `luau-lsp analyze
|
||||
--flag:LuauSolverV2=true/false`로 실측 — 새 솔버가 필요하다는 결론
|
||||
재확인, `quad/.vscode/settings.json`에 `"luau-lsp.fflags.enableNewSolver":
|
||||
true`를 반영·커밋 완료(`tbox`도 같은 설정 확인). 실제 VSCode 세션에서
|
||||
이 설정이 반영되는지 육안 확인만 사람 몫으로 남음(`HUMAN_TODO.md` 6번).
|
||||
- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증(③ 포함).
|
||||
- **`state:With(...)`/`state:Apply(factory)`에 1번 ③(`typeof`) 개별
|
||||
실측** — `Compute`에서만 확인됐고, base pseudocode에 실제로 반영할
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| `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 감사 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
|
@ -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` |
|
||||
| `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 열한 번째 세션 신설) |
|
||||
| `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` 생략 캐비엇 |
|
||||
| `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 |
|
||||
|
|
@ -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` 실사례 |
|
||||
| `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 여섯 번째 세션) |
|
||||
| `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 |
|
||||
|
||||
## 공통 유틸리티
|
||||
|
||||
|
|
|
|||
|
|
@ -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=...}` 레코드를 받음) 수정으로 통과,
|
||||
> `rewrite-required/` → `done/` 이동. 근거:
|
||||
> `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
|
||||
|
|
@ -23,9 +35,9 @@
|
|||
| 폴더 | 뜻 | 개수 | 누가 처리 |
|
||||
|---|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
|
||||
|
||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
||||
|
|
@ -50,7 +62,7 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `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` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
||||
| `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`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||
| `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`을 돌릴 때 같이 씀 |
|
||||
|
||||
## ✅ `done/` — 통과 or 판정 끝 (14건)
|
||||
## ✅ `done/` — 통과 or 판정 끝 (19건)
|
||||
|
||||
**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중
|
||||
`04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위
|
||||
`rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시
|
||||
통과였다는 사실 자체는 유효):
|
||||
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
||||
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
||||
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
|
||||
런타임 절반, PostRef까지 확장)가 합류**:
|
||||
|
||||
| 파일 | 확인된 것 |
|
||||
|---|---|
|
||||
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
|
||||
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
|
||||
| `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로 슬롯 소실, 관용구 쓰면 항상 보존 |
|
||||
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
|
||||
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |
|
||||
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
|
||||
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
|
||||
| `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번 |
|
||||
| `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 |
|
||||
| `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`가 아님 |
|
||||
| `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건
|
||||
|
||||
|
|
|
|||
175
.claude/luau-test/done/05-store-state-diamond-propagation.luau
Normal file
175
.claude/luau-test/done/05-store-state-diamond-propagation.luau
Normal 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하게 넘기는
|
||||
부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
79
.claude/luau-test/done/13-type-ref-preref-subtype.luau
Normal file
79
.claude/luau-test/done/13-type-ref-preref-subtype.luau
Normal 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`이라 즉시 크래시함(의도된 것, 타입 전용).
|
||||
]]
|
||||
|
|
@ -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() 호출까지 안 감)
|
||||
113
.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau
Normal file
113
.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau
Normal 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만 다뤘던 원본)보다 새로 넓힌 부분.
|
||||
]]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 온톨로지"
|
||||
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
|
|
@ -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)`를
|
||||
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
|
||||
]]
|
||||
|
|
@ -10,10 +10,13 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
|
|||
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
|
||||
길게 잡음.
|
||||
|
||||
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전**(M0에
|
||||
착수하면 루트 `CLAUDE.md` 머리말도 같이 고칠 것 — 같은 상태를 두 곳이
|
||||
서술하고 있음) — 저장소 루트에 실제 소스
|
||||
코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
|
||||
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치
|
||||
엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
|
||||
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에
|
||||
`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 메커니즘,
|
||||
컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에
|
||||
문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가
|
||||
|
|
@ -73,8 +76,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도
|
||||
여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/
|
||||
`pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새
|
||||
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
|
||||
**[2026-08-18 기준] 폴더 자체가 아직 없음**.
|
||||
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — **실사용**
|
||||
피드백용(M0/M1 스캐폴딩이 아니라 실제로 렌더링해보고 쓰는 단계부터),
|
||||
**[2026-08-19 기준] 폴더 자체가 아직 없음** — 첫 피드백이 생길 때 만들면 됨.
|
||||
`.claude/archive/`는 원래 같은 취급이었으나
|
||||
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
|
||||
이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이
|
||||
|
|
|
|||
|
|
@ -1535,3 +1535,77 @@ ERROR 0.
|
|||
드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술
|
||||
철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.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건 발견·수정 후 커밋.
|
||||
|
|
|
|||
|
|
@ -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개 후속 검증 항목은
|
||||
전부 닫힘.
|
||||
|
|
@ -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/` 하위라 커밋 대상 아님).
|
||||
|
|
@ -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. 이후 진행
|
||||
|
||||
사용자 요청으로 이제부터 대화를 한국어로 진행.
|
||||
|
|
@ -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. 심볼릭 링크
|
||||
로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로
|
||||
대화는 계속 한국어로 진행 중.
|
||||
|
|
@ -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` 독립 분리는 사용자 본인이 나중에 직접 진행.
|
||||
|
|
@ -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`).
|
||||
|
|
@ -43,9 +43,9 @@
|
|||
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
|
||||
3번에도 올라가
|
||||
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
|
||||
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
|
||||
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
|
||||
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
|
||||
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시
|
||||
검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/`
|
||||
문서에도 ⚠️로 표시돼 있다:
|
||||
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
|
||||
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
|
||||
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
|
||||
|
|
@ -71,8 +71,11 @@
|
|||
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
|
||||
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
|
||||
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
|
||||
- **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`)
|
||||
— M0에서 실측 확인.
|
||||
- **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가
|
||||
나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`,
|
||||
`ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키
|
||||
접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store =
|
||||
Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요.
|
||||
|
||||
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
|
||||
`question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy
|
||||
|
|
@ -197,6 +200,10 @@
|
|||
참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의
|
||||
전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계
|
||||
게이트와 무관.
|
||||
**[2026-08-19 추가]** `quad-roblox-types`(가칭, `quad-types`와 같은
|
||||
패턴으로 `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한 패키지)도
|
||||
같은 성격의 백로그로 신설 — 사용자가 지금 만들 필요는 없다고 명시적으로
|
||||
후순위 지정, 상세는 `base/quad-types-plan.md`의 "남은 것" 절.
|
||||
5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중
|
||||
(`HUMAN_TODO.md` 2번 항목).
|
||||
6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -3,3 +3,14 @@
|
|||
# Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# pesde 설치 산출물(각 패키지 로컬에 생김) — pesde.lock 커밋 여부는 아직
|
||||
# 미정(라이브러리 컨벤션 확인 필요, HUMAN_TODO 참고)이라 여기 안 넣음
|
||||
roblox_packages/
|
||||
luau_packages/
|
||||
lune_packages/
|
||||
.pesde/
|
||||
|
||||
# luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함
|
||||
# (에디터 타입 링킹용 산출물, 커밋 대상 아님)
|
||||
sourcemap.json
|
||||
|
|
|
|||
10
.luaurc
Normal file
10
.luaurc
Normal 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
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"luau-lsp.fflags.enableNewSolver": true
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
|
||||
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전** — 같은
|
||||
상태를 `.claude/project-context.md`도 서술하니 M0에 착수하면 두 곳을 같이
|
||||
고칠 것.
|
||||
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치
|
||||
엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`도
|
||||
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
|
||||
항상 루트 `ROADMAP.md`.
|
||||
|
||||
<!-- [2026-08-16 재구조화] 이 파일은 1537줄까지 불어나 (a) 사람이 검토 불가,
|
||||
(b) 공식 권장치(파일당 200줄) 7.7배 초과로 지침 준수도 저하, (c) 에이전트가
|
||||
|
|
|
|||
|
|
@ -124,23 +124,27 @@ B(Attribute의 Instance 참조 타입)/C(CollectionService 태그 왕복) —
|
|||
`architecture.md`의 해당 절을 갱신하고 기존 코드의
|
||||
`const` 전환 범위를 같이 상의할 것.
|
||||
|
||||
## 6. **[2026-08-13 신설, 안 막음]** 에디터의 Luau 솔버 설정 확인
|
||||
## 6. ~~에디터의 Luau 솔버 설정 확인~~ **[2026-08-19 설정 완료 — VSCode 재시작만 확인해주면 됨]**
|
||||
|
||||
`luau-analyze` CLI는 **새 솔버가 기본값**이지만 에디터가 쓰는
|
||||
`luau-lsp`는 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라 **같은 코드에
|
||||
다른 진단이 나옵니다** — 이번 세션에 실제로 겪은 혼선의 원인이었음
|
||||
(CLI는 클린인데 에디터엔 빨간 줄).
|
||||
`luau-analyze` CLI는 새 솔버가 기본값이지만 에디터가 쓰는 `luau-lsp`는
|
||||
**옛 솔버가 기본값**(`LuauSolverV2=false`)이라 같은 코드에 다른 진단이
|
||||
나옴 — 예전엔 "실제 에디터 환경에서 확인 필요"로 사람에게 넘겨뒀던
|
||||
항목.
|
||||
|
||||
옛 솔버는 quad의 `Compute` 시그니처 패턴 자체를 선언 시점에 거부하므로
|
||||
사실상 새 솔버 외에 선택지가 없어 보이지만, **실제 사용하시는 에디터
|
||||
환경에서 확인이 필요**합니다. VSCode의 "Luau Language Server" 확장이라면
|
||||
워크스페이스 `.vscode/settings.json`에:
|
||||
**[2026-08-19] `luau-lsp` 바이너리(1.69.0, `luau-lsp analyze` CLI 모드)를
|
||||
`/code/.local/bin`에 직접 설치해 `--flag:LuauSolverV2=true/false`
|
||||
양쪽으로 실측 — 새 솔버가 필요하다는 결론을 재확인**하고
|
||||
`quad/.vscode/settings.json`을 만들어 `{ "luau-lsp.fflags.enableNewSolver":
|
||||
true }`를 이미 커밋해뒀음(팀/에디터 전체에 공유됨, 사용자가 손댈 것
|
||||
없음). `tbox`(다른 참고 레포)도 동일 설정을 이미 쓰고 있어 교차 확인됨.
|
||||
같이 검토했던 `LuauDoNotExportBrokenTypeFunction` override(tbox가 씀)는
|
||||
quad의 현재 `type function` 스파이크(`16`/`21`)에서 유무 차이가 없어
|
||||
**채택 안 함**(불필요한 설정 추가 지양).
|
||||
|
||||
```json
|
||||
{ "luau-lsp.fflags.enableNewSolver": true }
|
||||
```
|
||||
|
||||
M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은
|
||||
**사람이 확인해줄 것 하나만 남음**: 이건 CLI로 시뮬레이션한 것이지
|
||||
VSCode를 실제로 띄운 게 아님 — 다음에 VSCode를 열면 워크스페이스
|
||||
설정이 잘 먹었는지(같은 `.luau` 파일에서 CLI 결과와 에디터의 빨간 줄이
|
||||
일치하는지) 한 번만 눈으로 확인해주면 이 항목은 완전히 닫힘. 배경은
|
||||
`.claude/base/typing-limits.md` 8번, 실측은
|
||||
`.claude/audit/type-recursion-issue/REPORT.md` 5절.
|
||||
|
||||
|
|
@ -157,6 +161,17 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
|
|||
없는지, (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`의 **나머지** 항목 검토 (급하지 않음)
|
||||
|
||||
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
|
||||
|
|
|
|||
82
ROADMAP.md
82
ROADMAP.md
|
|
@ -5,8 +5,16 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
|
|||
체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서.
|
||||
|
||||
**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 착수를 막던 결정이 전부 해소됐음.**
|
||||
> `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강
|
||||
|
|
@ -29,24 +37,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`architecture.md`/`bind-system-plan.md` 등을 이 시점에 고치는 게 정상 —
|
||||
실패가 아니라 이 단계의 목적.
|
||||
|
||||
- [ ] Store/State push-invalidate → pull-recompute propagation을 실제로
|
||||
- [x] Store/State push-invalidate → pull-recompute propagation을 실제로
|
||||
짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할
|
||||
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
|
||||
**emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은
|
||||
`:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는
|
||||
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
|
||||
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
|
||||
스파이크 `05-store-state-diamond-propagation.luau`는 옛 모델을
|
||||
검증 중이라 `rewrite-required/`에 있음)
|
||||
- [ ] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
|
||||
스파이크 `05-store-state-diamond-propagation.luau`는 **[2026-08-19
|
||||
재작성 완료, `done/`]** 현행 모델("emit은 항상 전파 + `:Get()`
|
||||
시점 캐시로만 dedup")로 재검증 통과)
|
||||
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
|
||||
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
|
||||
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
|
||||
`base/source-state-plan.md` "Source가 State를 만족함" 절 — `State<T>`가
|
||||
`Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는
|
||||
피할 수 있어 보이나 실제 검증 전엔 확정 아님)
|
||||
- [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
|
||||
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함)
|
||||
- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
|
||||
피할 수 있어 보이나 실제 검증 전엔 확정 아님. **[통과]**
|
||||
`luau-test/done/08-type-source-satisfies-state.luau` — 핵심 케이스
|
||||
통과, 잔여 자기재귀 케이스는 Luau 한계로 별도 확정
|
||||
(`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 +
|
||||
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
|
||||
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
|
||||
|
|
@ -62,7 +75,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
|
||||
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
|
||||
체크리스트 항목도 이 값으로 스파이크할 것.
|
||||
- [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성,
|
||||
- [x] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성,
|
||||
`export type Params = {...}`로 타입 체크되는지 확인
|
||||
(`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or
|
||||
None`/`props.Ref or None` 관용구(2026-08-07 열 번째 세션 확정,
|
||||
|
|
@ -71,25 +84,38 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴
|
||||
구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5).
|
||||
M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로
|
||||
타입 체크/런타임 양쪽에서 문제없이 동작하는지**
|
||||
- [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서
|
||||
`.claude/base/` 갱신
|
||||
타입 체크/런타임 양쪽에서 문제없이 동작하는지** —
|
||||
`luau-test/done/06-component-boundary-nil-hole-props.luau` 통과
|
||||
- [x] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서
|
||||
`.claude/base/` 갱신 — 실제로 여러 차례 발생, 그때마다 반영됨(각
|
||||
스파이크 항목의 "정정"/"재작성" 표시가 그 기록)
|
||||
|
||||
**통과 기준**: 세 개 다 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행.
|
||||
안 되면 여기서 관련 `base/` 문서부터 고치고 재시도.
|
||||
**통과 기준**: 위 항목들이 Luau에서 자연스럽게 짜이는 게 확인되면 M1
|
||||
진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`가
|
||||
소스, 여기서 세지 않음).
|
||||
|
||||
## M1 — 실제 스캐폴딩
|
||||
|
||||
- [ ] `quad-base/`, `quad-roblox/` 폴더 + 각 `wally.toml`
|
||||
- [ ] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수:
|
||||
- [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19
|
||||
정정]** 이 체크박스는 원래 `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` 선례, 순수
|
||||
`luau` CLI, `architecture.md` "테스트 전략" 절 참고)
|
||||
- [ ] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
|
||||
- [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
|
||||
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
|
||||
`quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
|
||||
- [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
|
||||
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
|
||||
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
|
||||
그대로, 2026-08-19 확정)
|
||||
- [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작
|
||||
그대로, 2026-08-19 확정) — `quad-base/src/init.luau`의 `New()`/
|
||||
`RunInit`/`AddPlugin`으로 구현·smoke 테스트 검증 완료
|
||||
- [x] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용
|
||||
시작(**[2026-08-19 확인]** 두 폴더 모두 M1 이전인 설계 단계부터
|
||||
이미 쓰이고 있었고 — QA 라운드/역전 결정 기록 — M1 착수 이후에도
|
||||
계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상
|
||||
항상 충족돼 있었음)
|
||||
|
||||
## M2 — 디스패치 엔진
|
||||
|
||||
|
|
@ -361,7 +387,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
|
||||
## 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`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)
|
||||
- [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau`
|
||||
- [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션
|
||||
|
|
|
|||
15
default.project.json
Normal file
15
default.project.json
Normal 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
13
mise.toml
Normal 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
18
pesde.lock
Normal 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
13
pesde.toml
Normal 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
23
quad-base/pesde.lock
Normal 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
12
quad-base/pesde.toml
Normal 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
31
quad-base/selene.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
std = "luau"
|
||||
exclude = ["**/.pesde/*", "*_packages/*"]
|
||||
|
||||
[lints]
|
||||
almost_swapped = "deny"
|
||||
constant_table_comparison = "deny"
|
||||
deprecated = "warn"
|
||||
divide_by_zero = "warn"
|
||||
empty_if = "deny"
|
||||
empty_loop = "deny"
|
||||
high_cyclomatic_complexity = "warn"
|
||||
if_same_then_else = "deny"
|
||||
ifs_same_cond = "warn"
|
||||
incorrect_standard_library_use = "deny"
|
||||
manual_table_clone = "warn"
|
||||
mismatched_arg_count = "deny"
|
||||
mixed_table = "allow"
|
||||
multiple_statements = "deny"
|
||||
must_use = "warn"
|
||||
parenthese_conditions = "deny"
|
||||
suspicious_reverse_loop = "deny"
|
||||
type_check_inside_call = "deny"
|
||||
unbalanced_assignments = "warn"
|
||||
undefined_variable = "deny"
|
||||
unscoped_variables = "deny"
|
||||
unused_variable = "warn"
|
||||
|
||||
[config]
|
||||
empty_if = { comments_count = true }
|
||||
empty_loop = { comments_count = true }
|
||||
multiple_statements = { one_line_if = "break-return-only" }
|
||||
15
quad-base/src/Debug/init.luau
Normal file
15
quad-base/src/Debug/init.luau
Normal 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
92
quad-base/src/Relate.luau
Normal 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
54
quad-base/src/init.luau
Normal 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
227
quad-base/test/mock.luau
Normal 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,
|
||||
}
|
||||
67
quad-base/test/smoke.init.luau
Normal file
67
quad-base/test/smoke.init.luau
Normal 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 ===")
|
||||
59
quad-base/test/smoke.mock.luau
Normal file
59
quad-base/test/smoke.mock.luau
Normal 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 ===")
|
||||
55
quad-base/test/smoke.plugin.luau
Normal file
55
quad-base/test/smoke.plugin.luau
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
--[[ 임시 스모크 테스트 — Quad.Version/AddPlugin이 실제로 동작하는지 확인 ]]
|
||||
|
||||
local Quad = require("../src")
|
||||
|
||||
print("=== 1. Version 필드가 pesde.toml과 맞음 ===")
|
||||
assert(Quad.Version == "0.0.0", "quad-base/pesde.toml의 version과 일치해야 함")
|
||||
print("PASS")
|
||||
|
||||
print()
|
||||
print("=== 2. AddPlugin이 원본 identity를 유지한 채(mutate) 확장 필드를 추가함 ===")
|
||||
do
|
||||
local function springPlugin(_self)
|
||||
return {
|
||||
Spring = function(_selfArg, v)
|
||||
return v * 2
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local withSpring = Quad:AddPlugin(springPlugin)
|
||||
assert(withSpring == Quad, "AddPlugin은 원본 module을 그대로 mutate+반환해야 함(RunInit 추적이 identity에 의존)")
|
||||
assert(withSpring:Spring(21) == 42, "확장된 메소드가 실제로 동작해야 함")
|
||||
assert(withSpring.debug == false, "원본 필드는 그대로 남아있어야 함")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== 3. 체이닝 — 여러 플러그인을 연속으로 추가해도 전부 누적됨 ===")
|
||||
do
|
||||
local m = Quad.New()
|
||||
|
||||
local function pluginA(_self)
|
||||
return {
|
||||
A = function(_selfArg)
|
||||
return "a"
|
||||
end,
|
||||
}
|
||||
end
|
||||
local function pluginB(_self)
|
||||
return {
|
||||
B = function(_selfArg)
|
||||
return "b"
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local withBoth = m:AddPlugin(pluginA):AddPlugin(pluginB)
|
||||
assert(withBoth:A() == "a", "pluginA의 확장 메소드가 동작해야 함")
|
||||
assert(withBoth:B() == "b", "pluginB의 확장 메소드가 동작해야 함")
|
||||
assert(withBoth.debug == false, "원본 필드는 체이닝 후에도 남아있어야 함")
|
||||
print("PASS")
|
||||
end
|
||||
|
||||
print()
|
||||
print("=== ALL PASS ===")
|
||||
23
quad-roblox/pesde.lock
Normal file
23
quad-roblox/pesde.lock
Normal 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
12
quad-roblox/pesde.toml
Normal 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
31
quad-roblox/selene.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
std = "luau"
|
||||
exclude = ["**/.pesde/*", "*_packages/*"]
|
||||
|
||||
[lints]
|
||||
almost_swapped = "deny"
|
||||
constant_table_comparison = "deny"
|
||||
deprecated = "warn"
|
||||
divide_by_zero = "warn"
|
||||
empty_if = "deny"
|
||||
empty_loop = "deny"
|
||||
high_cyclomatic_complexity = "warn"
|
||||
if_same_then_else = "deny"
|
||||
ifs_same_cond = "warn"
|
||||
incorrect_standard_library_use = "deny"
|
||||
manual_table_clone = "warn"
|
||||
mismatched_arg_count = "deny"
|
||||
mixed_table = "allow"
|
||||
multiple_statements = "deny"
|
||||
must_use = "warn"
|
||||
parenthese_conditions = "deny"
|
||||
suspicious_reverse_loop = "deny"
|
||||
type_check_inside_call = "deny"
|
||||
unbalanced_assignments = "warn"
|
||||
undefined_variable = "deny"
|
||||
unscoped_variables = "deny"
|
||||
unused_variable = "warn"
|
||||
|
||||
[config]
|
||||
empty_if = { comments_count = true }
|
||||
empty_loop = { comments_count = true }
|
||||
multiple_statements = { one_line_if = "break-return-only" }
|
||||
13
quad-types/pesde.lock
Normal file
13
quad-types/pesde.lock
Normal 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
12
quad-types/pesde.toml
Normal 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
31
quad-types/selene.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
std = "luau"
|
||||
exclude = ["**/.pesde/*", "*_packages/*"]
|
||||
|
||||
[lints]
|
||||
almost_swapped = "deny"
|
||||
constant_table_comparison = "deny"
|
||||
deprecated = "warn"
|
||||
divide_by_zero = "warn"
|
||||
empty_if = "deny"
|
||||
empty_loop = "deny"
|
||||
high_cyclomatic_complexity = "warn"
|
||||
if_same_then_else = "deny"
|
||||
ifs_same_cond = "warn"
|
||||
incorrect_standard_library_use = "deny"
|
||||
manual_table_clone = "warn"
|
||||
mismatched_arg_count = "deny"
|
||||
mixed_table = "allow"
|
||||
multiple_statements = "deny"
|
||||
must_use = "warn"
|
||||
parenthese_conditions = "deny"
|
||||
suspicious_reverse_loop = "deny"
|
||||
type_check_inside_call = "deny"
|
||||
unbalanced_assignments = "warn"
|
||||
undefined_variable = "deny"
|
||||
unscoped_variables = "deny"
|
||||
unused_variable = "warn"
|
||||
|
||||
[config]
|
||||
empty_if = { comments_count = true }
|
||||
empty_loop = { comments_count = true }
|
||||
multiple_statements = { one_line_if = "break-return-only" }
|
||||
55
quad-types/src/init.luau
Normal file
55
quad-types/src/init.luau
Normal 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 {}
|
||||
6
type-version-check/pesde.lock
Normal file
6
type-version-check/pesde.lock
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# This file is automatically @generated by pesde.
|
||||
# It is not intended for manual editing.
|
||||
format = 2
|
||||
name = "qwreey/type_version_check"
|
||||
version = "0.0.0"
|
||||
target = "luau"
|
||||
8
type-version-check/pesde.toml
Normal file
8
type-version-check/pesde.toml
Normal 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"
|
||||
31
type-version-check/selene.toml
Normal file
31
type-version-check/selene.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
std = "luau"
|
||||
exclude = ["**/.pesde/*", "*_packages/*"]
|
||||
|
||||
[lints]
|
||||
almost_swapped = "deny"
|
||||
constant_table_comparison = "deny"
|
||||
deprecated = "warn"
|
||||
divide_by_zero = "warn"
|
||||
empty_if = "deny"
|
||||
empty_loop = "deny"
|
||||
high_cyclomatic_complexity = "warn"
|
||||
if_same_then_else = "deny"
|
||||
ifs_same_cond = "warn"
|
||||
incorrect_standard_library_use = "deny"
|
||||
manual_table_clone = "warn"
|
||||
mismatched_arg_count = "deny"
|
||||
mixed_table = "allow"
|
||||
multiple_statements = "deny"
|
||||
must_use = "warn"
|
||||
parenthese_conditions = "deny"
|
||||
suspicious_reverse_loop = "deny"
|
||||
type_check_inside_call = "deny"
|
||||
unbalanced_assignments = "warn"
|
||||
undefined_variable = "deny"
|
||||
unscoped_variables = "deny"
|
||||
unused_variable = "warn"
|
||||
|
||||
[config]
|
||||
empty_if = { comments_count = true }
|
||||
empty_loop = { comments_count = true }
|
||||
multiple_statements = { one_line_if = "break-return-only" }
|
||||
130
type-version-check/src/init.luau
Normal file
130
type-version-check/src/init.luau
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue