Compare commits
No commits in common. "871c582771ca106420d735ccde5670cbbfd845f8" and "14f733dde143e78fdb7f0bb417866db56cad114c" have entirely different histories.
871c582771
...
14f733dde1
56 changed files with 466 additions and 3291 deletions
File diff suppressed because one or more lines are too long
|
|
@ -177,58 +177,17 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
|
|||
아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제
|
||||
폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션.
|
||||
|
||||
**패키징 방식(모노레포, 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는 그 시점에
|
||||
한 번 기계적으로 바꾸는 정도로 감수.
|
||||
**패키징 방식(모노레포, 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는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수.
|
||||
|
||||
**패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도
|
||||
재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라
|
||||
|
|
@ -247,18 +206,10 @@ op" 절.
|
|||
|
||||
```
|
||||
quad/
|
||||
├── .luaurc # 편집기 경험용 alias(런타임 비의존, `base/project-setup-plan.md` 참고)
|
||||
├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀
|
||||
├── pesde.toml # 워크스페이스 루트(private, workspace_members)
|
||||
├── .luaurc # @quad-base, @quad-roblox alias (편집기 경험용, 런타임 비의존)
|
||||
├── 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/
|
||||
│ ├── pesde.toml
|
||||
│ ├── wally.toml
|
||||
│ └── src/
|
||||
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
||||
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속
|
||||
|
|
@ -288,7 +239,7 @@ quad/
|
|||
│ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음
|
||||
│ └── init.luau
|
||||
└── quad-roblox/
|
||||
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
||||
├── wally.toml
|
||||
└── 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,105 +85,67 @@ export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제
|
|||
|
||||
**순서 의존성은 각 `InitXxx`를 `require`처럼 멱등하게 만들어서 해소한다**
|
||||
(2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`가
|
||||
직접 관리할 필요가 없다.
|
||||
|
||||
**[2026-08-19 같은 날 후속 정정] 멱등 가드는 `RunInit` 하나로 통합됨 —
|
||||
파일마다 `Relate()`/`INITED` 센티널을 따로 두지 않는다.** 원래는 각
|
||||
`InitXxx` 파일이 자기 톱레벨에 `local relate = Relate()`를 두고 파일
|
||||
전용 센티널 키(`INITED`)로 "이 `module`에 이미 Init됐는가"를 기록하는
|
||||
보일러플레이트를 파일마다 반복했는데, **사용자 지적**: 그 반복 자체가
|
||||
불필요하다 — **함수 자기 자신을 릴레이션 키로 쓰면** 센티널이 따로
|
||||
필요 없다(`Relate<T, (any)->any, boolean>`이 곧 "이 함수, 이 모듈에
|
||||
실행했는가" 표가 됨). `module` 인스턴스마다 공유하는 `RunInit` 메서드
|
||||
하나가 이 판단을 전담하고, 개별 `InitXxx` 파일은 **가드 없이 그냥
|
||||
뮤테이션만** 하면 된다:
|
||||
직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저
|
||||
**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`을
|
||||
weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미
|
||||
됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require`
|
||||
캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는
|
||||
한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을
|
||||
여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프
|
||||
캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다.
|
||||
|
||||
```lua
|
||||
-- 최상위 init.luau
|
||||
-- Dispatch/init.luau
|
||||
local Relate = require(...)
|
||||
local runInitRelate = Relate() -- (module, initFn) -> 이미 실행됐는가, 파일 스코프 공유
|
||||
local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스
|
||||
|
||||
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
|
||||
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
|
||||
if relate:GetStrong(module, INITED) then
|
||||
return -- 이미 이 module 인스턴스엔 Init됨, no-op
|
||||
end
|
||||
relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고)
|
||||
-- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음
|
||||
-- 예: InitLifetime(module)
|
||||
local ...
|
||||
module.Dispatch = ...
|
||||
end
|
||||
return Init
|
||||
```
|
||||
|
||||
- **의존성을 갖는 `InitXxx`도 패턴이 그대로 유지된다** — 자기 의존성을
|
||||
`module:RunInit(InitLifetime)`처럼 부르기만 하면 되고, `RunInit` 자체가
|
||||
멱등하므로 여러 `InitXxx`가 같은 의존성을 부르는 순서/중복은 걱정할
|
||||
필요 없다(옛 설계와 결론은 같음, 가드 소유 위치만 파일별→공유로 이동).
|
||||
- **GC와도 자연히 맞물림** — `relate-plan.md`의 "API" 절에 따르면
|
||||
`Relate`의 **첫 인자(`inst`, 여기선 `module`)는 항상 weak**이므로,
|
||||
`Quad` 인스턴스가 더 이상 참조되지 않아 수거되면 이 Init-완료 기록도
|
||||
같이 사라진다(별도 정리 로직 불필요, `value`는 `SetStrong` — boolean
|
||||
리터럴이라 GC 결과엔 무관하지만 "다른 곳에서 안전하게 유지되는 것만
|
||||
`SetWeak`" 일반 규칙에 맞음).
|
||||
이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`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`이 그 규칙에 맞는
|
||||
선택이다.
|
||||
- **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제
|
||||
정의는 `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`)에서.
|
||||
정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend
|
||||
팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개
|
||||
계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base
|
||||
내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현
|
||||
디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지
|
||||
않게 구분해서 쓸 것.
|
||||
- **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `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는 누가, 어떻게 구현하는가
|
||||
|
||||
|
|
|
|||
|
|
@ -1,373 +0,0 @@
|
|||
# 프로젝트 셋업 — 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` 커밋 여부 최종 확정(위 절 권고는 잠정)
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
# `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,19 +58,18 @@ State를 만족함" 절).
|
|||
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
|
||||
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
|
||||
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
|
||||
- **[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`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **[확인 요구, 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`(아래 "타입 추론 문제" 절)이 정식 창구.
|
||||
- **`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`). ③을 관례로 채택해도
|
||||
§9의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
||||
§8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정"
|
||||
전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다.
|
||||
|
||||
**시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**:
|
||||
콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone`
|
||||
체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
||||
체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이
|
||||
self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면
|
||||
(= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단
|
||||
두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable`
|
||||
|
|
@ -361,71 +361,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC
|
|||
|
||||
---
|
||||
|
||||
## 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. 성립이 확인된 것 (안심해도 되는 것)
|
||||
## 6. 성립이 확인된 것 (안심해도 되는 것)
|
||||
|
||||
한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다.
|
||||
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
|
||||
|
|
@ -445,7 +381,7 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T
|
|||
|
||||
---
|
||||
|
||||
## 8. 새 타입/API를 설계할 때 체크리스트
|
||||
## 7. 새 타입/API를 설계할 때 체크리스트
|
||||
|
||||
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
|
||||
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
|
||||
|
|
@ -476,11 +412,6 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T
|
|||
스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를
|
||||
확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가
|
||||
실측에서 뒤집힌" 것들입니다.
|
||||
7. **`type function`으로 검사/변형한 값에 제네릭 self 메소드
|
||||
(`<Self,P>(self:Self,...)`류)를 나중에 부를 계획인가?** → 6번 한계에
|
||||
걸림. 그 `type function`이 원본 타입을 조금이라도 반환하면(패스스루
|
||||
포함) 안 됨 — 검사 결과는 원본과 절대 안 섞이는 별도 필드로 격리하고,
|
||||
원본 타입 자체는 `type function`을 아예 거치지 않게 할 것.
|
||||
|
||||
> **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로
|
||||
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
|
||||
|
|
@ -490,16 +421,14 @@ local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T
|
|||
|
||||
---
|
||||
|
||||
## 9. 미해결 / 추적 중
|
||||
## 8. 미해결 / 추적 중
|
||||
|
||||
- **[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-lsp`)의 솔버 설정** — `luau-analyze` CLI는 새 솔버가
|
||||
기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라
|
||||
같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면
|
||||
`"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터
|
||||
환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상
|
||||
새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요.
|
||||
- **`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-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | ROADMAP M0-1 |
|
||||
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-14] 전제가 뒤집혀 재작성 대기** — 옛 모델("이미 dirty면 전파 중단")을 검증 중이었으나 그게 폐기됨, 이제 확인할 건 "emit은 항상 전파되고 중복 재계산은 캐시로만 막힌다" | 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` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef<T>`/`PostRef<T>`가 `Ref<T>`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 |
|
||||
| `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 열한 번째 세션 재정정) |
|
||||
| `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,9 +87,6 @@ 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,18 +1,6 @@
|
|||
# 스파이크 상태판 — **폴더가 곧 상태**
|
||||
|
||||
> 마지막 갱신: 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 버전 드리프트
|
||||
> 마지막 갱신: 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트
|
||||
> (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과,
|
||||
> `rewrite-required/` → `done/` 이동. 근거:
|
||||
> `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
|
||||
|
|
@ -35,9 +23,9 @@
|
|||
| 폴더 | 뜻 | 개수 | 누가 처리 |
|
||||
|---|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — |
|
||||
|
||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
||||
|
|
@ -62,7 +50,7 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
|
||||
|
|
@ -81,6 +69,8 @@
|
|||
|---|---|---|
|
||||
| `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 섹션은 손댈 것 없음 |
|
||||
|
||||
|
|
@ -93,26 +83,24 @@
|
|||
|---|---|
|
||||
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
||||
|
||||
## ✅ `done/` — 통과 or 판정 끝 (19건)
|
||||
## ✅ `done/` — 통과 or 판정 끝 (14건)
|
||||
|
||||
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
||||
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
||||
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
|
||||
런타임 절반, PostRef까지 확장)가 합류**:
|
||||
**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중
|
||||
`04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위
|
||||
`rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시
|
||||
통과였다는 사실 자체는 유효):
|
||||
|
||||
| 파일 | 확인된 것 |
|
||||
|---|---|
|
||||
| `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 셋을 정확히 갈라냄 |
|
||||
|
||||
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:
|
||||
|
||||
|
|
@ -121,11 +109,8 @@
|
|||
| `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건
|
||||
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
--[[
|
||||
검증 대상: 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하게 넘기는
|
||||
부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
--!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`이라 즉시 크래시함(의도된 것, 타입 전용).
|
||||
]]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
--!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() 호출까지 안 감)
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
--[[
|
||||
검증 대상(런타임): `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만 다뤘던 원본)보다 새로 넓힌 부분.
|
||||
]]
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
--!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)
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
--[[
|
||||
!!! [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 온톨로지"
|
||||
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
--!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,13 +10,10 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
|
|||
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
|
||||
길게 잡음.
|
||||
|
||||
**[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`
|
||||
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전**(M0에
|
||||
착수하면 루트 `CLAUDE.md` 머리말도 같이 고칠 것 — 같은 상태를 두 곳이
|
||||
서술하고 있음) — 저장소 루트에 실제 소스
|
||||
코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract`
|
||||
디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘,
|
||||
컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에
|
||||
문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가
|
||||
|
|
@ -76,9 +73,8 @@ 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/` — **실사용**
|
||||
피드백용(M0/M1 스캐폴딩이 아니라 실제로 렌더링해보고 쓰는 단계부터),
|
||||
**[2026-08-19 기준] 폴더 자체가 아직 없음** — 첫 피드백이 생길 때 만들면 됨.
|
||||
파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
|
||||
**[2026-08-18 기준] 폴더 자체가 아직 없음**.
|
||||
`.claude/archive/`는 원래 같은 취급이었으나
|
||||
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
|
||||
이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이
|
||||
|
|
|
|||
|
|
@ -1535,77 +1535,3 @@ 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건 발견·수정 후 커밋.
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
# 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개 후속 검증 항목은
|
||||
전부 닫힘.
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# 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/` 하위라 커밋 대상 아님).
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# 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. 이후 진행
|
||||
|
||||
사용자 요청으로 이제부터 대화를 한국어로 진행.
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
# 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. 심볼릭 링크
|
||||
로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로
|
||||
대화는 계속 한국어로 진행 중.
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
# 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` 독립 분리는 사용자 본인이 나중에 직접 진행.
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
# 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 경로" 대칭 확인은 판단이 아니라 구현 시
|
||||
검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/`
|
||||
문서에도 ⚠️로 표시돼 있다:
|
||||
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측
|
||||
확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음,
|
||||
여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다:
|
||||
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
|
||||
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
|
||||
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
|
||||
|
|
@ -71,11 +71,8 @@
|
|||
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
|
||||
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
|
||||
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
|
||||
- **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가
|
||||
나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`,
|
||||
`ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키
|
||||
접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store =
|
||||
Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요.
|
||||
- **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`)
|
||||
— M0에서 실측 확인.
|
||||
|
||||
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
|
||||
`question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy
|
||||
|
|
@ -200,10 +197,6 @@
|
|||
참고 구현 `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,14 +3,3 @@
|
|||
# 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
10
.luaurc
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"languageMode": "strict",
|
||||
"lint": {
|
||||
"*": true
|
||||
},
|
||||
"aliases": {
|
||||
"quad-base": "quad-base/src",
|
||||
"quad-roblox": "quad-roblox/src"
|
||||
}
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"luau-lsp.fflags.enableNewSolver": true
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
|
||||
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치
|
||||
엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`도
|
||||
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
|
||||
항상 루트 `ROADMAP.md`.
|
||||
**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전** — 같은
|
||||
상태를 `.claude/project-context.md`도 서술하니 M0에 착수하면 두 곳을 같이
|
||||
고칠 것.
|
||||
|
||||
<!-- [2026-08-16 재구조화] 이 파일은 1537줄까지 불어나 (a) 사람이 검토 불가,
|
||||
(b) 공식 권장치(파일당 200줄) 7.7배 초과로 지침 준수도 저하, (c) 에이전트가
|
||||
|
|
|
|||
|
|
@ -124,27 +124,23 @@ B(Attribute의 Instance 참조 타입)/C(CollectionService 태그 왕복) —
|
|||
`architecture.md`의 해당 절을 갱신하고 기존 코드의
|
||||
`const` 전환 범위를 같이 상의할 것.
|
||||
|
||||
## 6. ~~에디터의 Luau 솔버 설정 확인~~ **[2026-08-19 설정 완료 — VSCode 재시작만 확인해주면 됨]**
|
||||
## 6. **[2026-08-13 신설, 안 막음]** 에디터의 Luau 솔버 설정 확인
|
||||
|
||||
`luau-analyze` CLI는 새 솔버가 기본값이지만 에디터가 쓰는 `luau-lsp`는
|
||||
**옛 솔버가 기본값**(`LuauSolverV2=false`)이라 같은 코드에 다른 진단이
|
||||
나옴 — 예전엔 "실제 에디터 환경에서 확인 필요"로 사람에게 넘겨뒀던
|
||||
항목.
|
||||
`luau-analyze` CLI는 **새 솔버가 기본값**이지만 에디터가 쓰는
|
||||
`luau-lsp`는 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라 **같은 코드에
|
||||
다른 진단이 나옵니다** — 이번 세션에 실제로 겪은 혼선의 원인이었음
|
||||
(CLI는 클린인데 에디터엔 빨간 줄).
|
||||
|
||||
**[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`)에서 유무 차이가 없어
|
||||
**채택 안 함**(불필요한 설정 추가 지양).
|
||||
옛 솔버는 quad의 `Compute` 시그니처 패턴 자체를 선언 시점에 거부하므로
|
||||
사실상 새 솔버 외에 선택지가 없어 보이지만, **실제 사용하시는 에디터
|
||||
환경에서 확인이 필요**합니다. VSCode의 "Luau Language Server" 확장이라면
|
||||
워크스페이스 `.vscode/settings.json`에:
|
||||
|
||||
**사람이 확인해줄 것 하나만 남음**: 이건 CLI로 시뮬레이션한 것이지
|
||||
VSCode를 실제로 띄운 게 아님 — 다음에 VSCode를 열면 워크스페이스
|
||||
설정이 잘 먹었는지(같은 `.luau` 파일에서 CLI 결과와 에디터의 빨간 줄이
|
||||
일치하는지) 한 번만 눈으로 확인해주면 이 항목은 완전히 닫힘. 배경은
|
||||
```json
|
||||
{ "luau-lsp.fflags.enableNewSolver": true }
|
||||
```
|
||||
|
||||
M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은
|
||||
`.claude/base/typing-limits.md` 8번, 실측은
|
||||
`.claude/audit/type-recursion-issue/REPORT.md` 5절.
|
||||
|
||||
|
|
@ -161,17 +157,6 @@ 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,16 +5,8 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
|
|||
체크박스를 세분화해서 늘려도 되고, 끝나면 체크만 하면 됨 — 살아있는 문서.
|
||||
|
||||
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
|
||||
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
|
||||
|
||||
> **✅ [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`).
|
||||
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0
|
||||
자체는 시작 안 함.** 다음 세션은 바로 M0부터.
|
||||
|
||||
> **✅ [2026-08-13 열네 번째 세션] M0 착수를 막던 결정이 전부 해소됐음.**
|
||||
> `0-Y`(13차 세션), `0-Z`(Attribute 이름 소유권)/`0-A`(재디스패치 하강
|
||||
|
|
@ -37,29 +29,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`architecture.md`/`bind-system-plan.md` 등을 이 시점에 고치는 게 정상 —
|
||||
실패가 아니라 이 단계의 목적.
|
||||
|
||||
- [x] Store/State push-invalidate → pull-recompute propagation을 실제로
|
||||
- [ ] 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`는 **[2026-08-19
|
||||
재작성 완료, `done/`]** 현행 모델("emit은 항상 전파 + `:Get()`
|
||||
시점 캐시로만 dedup")로 재검증 통과)
|
||||
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
|
||||
스파이크 `05-store-state-diamond-propagation.luau`는 옛 모델을
|
||||
검증 중이라 `rewrite-required/`에 있음)
|
||||
- [ ] 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`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는
|
||||
피할 수 있어 보이나 실제 검증 전엔 확정 아님. **[통과]**
|
||||
`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 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
|
||||
피할 수 있어 보이나 실제 검증 전엔 확정 아님)
|
||||
- [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
|
||||
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함)
|
||||
- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
|
||||
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
|
||||
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
|
||||
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
|
||||
|
|
@ -75,7 +62,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
|
||||
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
|
||||
체크리스트 항목도 이 값으로 스파이크할 것.
|
||||
- [x] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성,
|
||||
- [ ] `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 열 번째 세션 확정,
|
||||
|
|
@ -84,38 +71,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴
|
||||
구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5).
|
||||
M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로
|
||||
타입 체크/런타임 양쪽에서 문제없이 동작하는지** —
|
||||
`luau-test/done/06-component-boundary-nil-hole-props.luau` 통과
|
||||
- [x] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서
|
||||
`.claude/base/` 갱신 — 실제로 여러 차례 발생, 그때마다 반영됨(각
|
||||
스파이크 항목의 "정정"/"재작성" 표시가 그 기록)
|
||||
타입 체크/런타임 양쪽에서 문제없이 동작하는지**
|
||||
- [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서
|
||||
`.claude/base/` 갱신
|
||||
|
||||
**통과 기준**: 위 항목들이 Luau에서 자연스럽게 짜이는 게 확인되면 M1
|
||||
진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`가
|
||||
소스, 여기서 세지 않음).
|
||||
**통과 기준**: 세 개 다 Luau에서 자연스럽게 짜이는 게 확인되면 M1 진행.
|
||||
안 되면 여기서 관련 `base/` 문서부터 고치고 재시도.
|
||||
|
||||
## M1 — 실제 스캐폴딩
|
||||
|
||||
- [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/`, `quad-roblox/` 폴더 + 각 `wally.toml`
|
||||
- [ ] 루트 `default.project.json`, `.luaurc`(`architecture.md` "구현 착수:
|
||||
소스 트리 구조 확정" 절 그대로)
|
||||
- [x] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
|
||||
`luau` CLI, `architecture.md` "테스트 전략" 절 참고) —
|
||||
`quad-base/test/mock.luau` + `smoke.*.luau`, 전부 PASS
|
||||
- [x] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
|
||||
- [ ] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수
|
||||
`luau` CLI, `architecture.md` "테스트 전략" 절 참고)
|
||||
- [ ] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템
|
||||
Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별
|
||||
멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절
|
||||
그대로, 2026-08-19 확정) — `quad-base/src/init.luau`의 `New()`/
|
||||
`RunInit`/`AddPlugin`으로 구현·smoke 테스트 검증 완료
|
||||
- [x] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용
|
||||
시작(**[2026-08-19 확인]** 두 폴더 모두 M1 이전인 설계 단계부터
|
||||
이미 쓰이고 있었고 — QA 라운드/역전 결정 기록 — M1 착수 이후에도
|
||||
계속 같은 방식으로 쓰이는 중이라 "실사용 시작"이라는 조건은 사실상
|
||||
항상 충족돼 있었음)
|
||||
그대로, 2026-08-19 확정)
|
||||
- [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작
|
||||
|
||||
## M2 — 디스패치 엔진
|
||||
|
||||
|
|
@ -387,15 +361,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
|
||||
## M5 — quad-roblox 최소 프로바이더
|
||||
|
||||
> **⚠️ 구현 관례**: `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` 참고)
|
||||
- [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드)
|
||||
- [ ] `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 다섯 번째 세션
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"name": "quad",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"quad-base": {
|
||||
"$path": "quad-base/src"
|
||||
},
|
||||
"quad-roblox": {
|
||||
"$path": "quad-roblox/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
mise.toml
13
mise.toml
|
|
@ -1,13 +0,0 @@
|
|||
# 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
18
pesde.lock
|
|
@ -1,18 +0,0 @@
|
|||
# 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
13
pesde.toml
|
|
@ -1,13 +0,0 @@
|
|||
# 워크스페이스 루트 — 그 자체로는 게시되지 않음(`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"
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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 = "^" }
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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" }
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
--[[
|
||||
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
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
--[[
|
||||
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
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
--[[
|
||||
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()
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
--[[
|
||||
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,
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
--[[ 임시 스모크 테스트 — 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 ===")
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
--[[ 임시 스모크 테스트 — 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 ===")
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
--[[ 임시 스모크 테스트 — 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 ===")
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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 = "^" }
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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" }
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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" }
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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" }
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
--!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 {}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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" }
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
--!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