feat(m2): 첫 단위 공통 기반 — Void/Brand/LifetimeHandle/Ref 최소형 + mock 생명주기 + spec 5개

- quad-base/src: Void.luau(단일 no-op), Brand.luau(Brand() + 브랜드 인스턴스 15 +
  M2 is* 11), LifetimeHandle.luau(InitLifetimeHandle — 모듈 인스턴스에 영어 level 2
  에러 스텁 4종), Ref.luau(.Value/.Revision/:Set/:Callback/:WeakCallback/:Uncallback,
  EpochBrand+RefBrand), init.luau 재export. Relate.luau는 타입만 quad-types에서 재export.
- quad-types: Quad에 M2 첫 단위 탑레벨 값 + Ref<T>/RefCallback<T>/Relate/Epoch 타입.
- test/mock.luau: installLifetime(quad) — lifecycle-pattern.md (0)/(1) 스케치 그대로,
  Destroy가 모든 Connection을 끊도록 보강(gcconn 판정 근거).
- scripts/test.sh: spec.* 수집 + luau-analyze(src/spec/mock). 전부 ALL PASS, analyze 0건.
- 발견 ①: H-165 pesde shim은 생성 시점 export 타입만 안다(project-setup-plan.md),
  H-166 Ref.Revision 초기값 0(ref-plan.md). ROADMAP 공통 기반 체크박스 완료 표기.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey 2026-08-28 18:10:50 +09:00
parent 92721d768f
commit d9898d6629
Signed by: qwreey
GPG key ID: D28DB79297A214BD
20 changed files with 1214 additions and 19 deletions

View file

@ -248,7 +248,14 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했
(8라운드에서 실제로 실행해 확인). 지금 규칙은 하나다 — **테스트는 (8라운드에서 실제로 실행해 확인). 지금 규칙은 하나다 — **테스트는
`./scripts/test.sh`로 돌린다**(그 스크립트가 `relink.sh`를 먼저 부른다). `./scripts/test.sh`로 돌린다**(그 스크립트가 `relink.sh`를 먼저 부른다).
그냥 `luau`로 돌리면 스모크가 죽고 `luau-analyze`는 모듈을 `any`로 떨어뜨려 그냥 `luau`로 돌리면 스모크가 죽고 `luau-analyze`는 모듈을 `any`로 떨어뜨려
**조용히 통과**한다("거짓 클린"). Luau의 **조용히 통과**한다("거짓 클린"). **[2026-08-28 M2 첫 단위, `H-165`] 둘째
함정 — `quad-types``export type`을 추가하면 `pesde install`을 다시 돌려야
한다.** pesde가 만드는 링크 파일(`quad-base/roblox_packages/quad_types.luau`)은
`return module` 위에 **그 시점에 존재하던 export 타입만** `export type X =
module.X`로 손으로 나열한 shim이라, `quad-types/src/init.luau`에 타입을 새로
export해도 shim을 재생성하기 전엔 `QuadTypes.Ref` 같은 참조가 "Unknown type"으로
죽는다(`relink.sh`는 복사만 갱신하지 shim은 못 고친다 — 실측). `test.sh`
**[2026-08-28]** `luau-analyze`도 같이 돌리므로 이 실패는 조용하지 않다. Luau의
`.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — `.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 —
그때 다시 볼 것. 그때 다시 볼 것.

View file

@ -430,7 +430,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
- **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()` - **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`
`Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)` `Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)`
(그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서 (그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서
성립한다. 성립한다. **[2026-08-28 M2 첫 단위, `H-166`] 초기값은 `0`** — 어느 문서도
적지 않았던 것을 구현이 정했다. 계약은 `==`/`~=`뿐이라 값 자체는 무관하고,
`:Set``4294967295`로 감는 것까지 테스트(`spec.ref.luau`)가 고정한다.
- **`EpochBrand:register(self)`** — `Source``SourceBrand`이면서 동시에 - **`EpochBrand:register(self)`** — `Source``SourceBrand`이면서 동시에
`EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`). `EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`).
- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐, - **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐,

View file

@ -14,7 +14,8 @@
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 | | 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| — | — | — | — | **[2026-08-28 기준] 아직 발견 없음** | — | | `H-165` | ① | 1 | 🟡 | `quad-types``export type`을 더하면 pesde shim이 그걸 모른다 — `pesde install` 재실행 없이는 `QuadTypes.Ref`가 Unknown type | ✅ 반영(`project-setup-plan.md`) |
| `H-166` | ① | 1 | 🟢 | `Ref.Revision` 초기값을 어느 문서도 안 정했다 | ✅ 반영(`ref-plan.md`: `0`) |
## 상세 ## 상세
@ -22,6 +23,26 @@
(파일:줄 / `base/` 절), (2) 무엇이, (3) 문서가 이미 답을 갖고 있는가, (4) 어떻게 (파일:줄 / `base/` 절), (2) 무엇이, (3) 문서가 이미 답을 갖고 있는가, (4) 어떻게
처리했는가(①이면 커밋 해시).) 처리했는가(①이면 커밋 해시).)
### 단위 1 — 공통 기반 (2026-08-28)
### `H-165` 🟡 — pesde shim은 생성 시점의 export 타입만 안다
- **어디서**: `quad-base/roblox_packages/quad_types.luau`(pesde 생성물, 커밋 안 됨) /
`base/project-setup-plan.md``test.sh` 절.
- **무엇이**: `quad-types/src/init.luau``Ref<T>`/`Relate`/`Epoch`를 `export type`으로
추가하고 `test.sh`를 돌리자 `luau-analyze``Unknown type 'QuadTypes.Ref'`. shim이
`export type Quad = module.Quad` / `CheckedQuad`만 손으로 나열한 파일이라
`return module`로는 타입이 안 넘어온다. `relink.sh`는 복사만 갱신한다.
- **문서가 답을 갖고 있었나**: 아니다 — 첫 함정(심볼릭 링크)만 적혀 있었다.
- **처리**: `pesde install` 재실행으로 shim 재생성(같은 세션 실측), `project-setup-plan.md`
둘째 함정으로 기록. `test.sh`가 이제 `luau-analyze`를 같이 돌려 조용히 지나가지 않는다.
### `H-166` 🟢 — `Ref.Revision` 초기값이 문서에 없다
- **어디서**: `base/ref-plan.md` "`Ref`는 `Epoch`를 만족한다" 절 / `base/state-epoch-plan.md` §2.
- **무엇이**: 갱신식(`bit32.bnot(-rev)`)과 표만 있고 시작값이 없다.
- **처리**: `0`으로 구현하고 그 절에 한 줄 추가. 계약이 `==`/`~=`뿐이라 값은 무관.
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용) ## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? | | 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
@ -35,4 +56,24 @@
(탐사자가 실제로 돌려보고 계약대로였던 자리 — 다음 탐사자가 다시 파지 않게.) (탐사자가 실제로 돌려보고 계약대로였던 자리 — 다음 탐사자가 다시 파지 않게.)
**단위 1 (메인 세션, 2026-08-28)**:
- `Relate.luau`(M1) ↔ `relate-plan.md` "API"/"실제 구조": 4 메서드, lazy 서브테이블,
공유 `{__mode="v"}` 메타테이블, `inst` weak — 전부 일치(`spec.relate.luau`가 고정).
- `lifecycle-pattern.md` (0)/(1) 스케치는 mock 시그널 위에 그대로 돌아간다 — `Destroy`
`gcconn.Connected=false``canExecute` false/`canBound` true, gchold 강참조, 조기
해제, 이중 바인드 게이트 모양. 스케치의 한국어 에러 문구는 같은 문서가 이미
*"실제 문구는 영어"*라 밝힌 자리표시자라 문서 결함 아님(코드는 영어 + `level 2`).
- `ref-plan.md` `:Set` 블록을 한 줄씩 옮겼고 계약 9개가 테스트로 고정됐다. 함수키
dedup(강+약 동시 등록 시 1회)과 순회 중 해제 skip이 실제로 성립한다.
- `brand-plan.md` 합성 술어(`isState = isSource or StateBrand`, `isRef = isPreRef or
isPostRef or RefBrand`)와 weak-key 멤버십 — 성립.
**툴링 사실 둘**(설계 아님, 다음 단위가 알아야 함):
- `require("@self/X")`**`init.luau`에서만** 통한다 — 일반 파일에서 `@self`는 그 파일
자신이라 `could not resolve child component`. 형제 모듈은 `./X`, 패키지는 `../roblox_packages/...`.
- GC 테스트 함정 둘: 같은 프레임의 죽은 레지스터가 임시값을 붙잡는다(별도 함수 안에서
만들 것) / **불변 업밸류만 잡는 클로저는 Luau가 프로토에 캐시해 영영 GC되지 않는다**
(테스트 클로저가 업밸류를 직접 변경하게 할 것 — `lifecycle-pattern.md` (0)의
`false or` 트릭이 막는 것과 같은 최적화).
## §6 남은 의심 / 못 본 것 ## §6 남은 의심 / 못 본 것

View file

@ -2015,3 +2015,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
이중 claim은 셋업 유무로 error(*"claim 은 slot 이랑 무관"* — 리뷰 전제 기각) / 이중 claim은 셋업 유무로 error(*"claim 은 slot 이랑 무관"* — 리뷰 전제 기각) /
**`PlayerGui`는 공동 소유 객체라 claim 대상 아님**, 루트는 `ScreenGui`·`SurfaceGui` / **`PlayerGui`는 공동 소유 객체라 claim 대상 아님**, 루트는 `ScreenGui`·`SurfaceGui` /
`FrameParam<E>` 원소 타입 파라미터. `FrameParam<E>` 원소 타입 파라미터.
- **`session/2026-08-28-03-m2-unit1-common-base.md`** — **M2 착수.** 다른 에이전트가
초안한 자율 구현 규약을 검토해 순서 오류 하나(`EpochMap`이 `Effect` 뒤 → State 본체
앞)와 소스 단일화를 고쳐 채택(`qa-request/pre-implementation-handtrace-round11-brief.md`,
세 갈래 분류 / 단위 넷 / 두 층 커밋 게이트, `HUMAN_TODO.md` 2번 닫힘). 첫 단위(공통
기반)를 사용자 확인(*"진행하면 될것 같아"*) 뒤 구현 — `Void`/`Brand`(인스턴스 15 +
`is*` 11 한 잎 파일)/`LifetimeHandle`(`InitLifetimeHandle` 에러 스텁)/`Ref` 최소형/
`quad-types` 타입/mock `installLifetime`(+`Destroy`가 Connection 전부 끊음)/spec 5개,
`test.sh``spec.*`+`luau-analyze`. 발견 `H-165`(pesde shim은 생성 시점 export만 —
`pesde install` 재실행)·`H-166`(`Revision` 초기값 0) 둘 다 ①. ②/③ 없음.

View file

@ -0,0 +1,39 @@
# 2026-08-28 (03) — M2 착수: 자율 구현 규약 채택 + 첫 단위(공통 기반) 구현
## 경위
- 다른 에이전트가 초안한 "M2 자율 구현 규약" 프롬프트를 사용자가 가져와 *"어떻게
봐? 진행 하면 될것같아?"* — 대조 결과 **순서 오류 하나**(`EpochMap`이 `Effect` 뒤;
`ROADMAP.md` "반응형 본체"는 `EpochMap`이 State 본체보다 먼저라 못 박음)와 소스
단일화 몇 건(규약 위치는 `base/`가 아니라 `conventions.md` 한 줄 + brief 파일 /
`Void` 체크박스 부재 / `HUMAN_TODO.md` 2번 / 커밋 게이트 두 층 / `TODO(H-nnn)`
마커 형식 / 단위를 넷으로)을 지적. 사용자: *"수정하고 너가 진행하자. epochmap
순서 하나 고치고 진행할 수 있겠니?"* → 규약 커밋 `f94234a`.
- 첫 단위 계획(§6)의 배치 결정 셋(브랜드 인스턴스를 `Brand.luau` 한 파일에 / mock
생명주기를 `mock.luau` 안에 / 테스트 `spec.*` + analyze)을 보여주고 사용자
*"진행하면 될것 같아"*`92721d7`.
## 구현 (전부 `./scripts/test.sh` ALL PASS, `luau-analyze` 0건)
- `Void.luau` / `Brand.luau`(생성자 + 인스턴스 15 + `is*` 11) / `LifetimeHandle.luau`
(`InitLifetimeHandle` — 모듈 인스턴스에 에러 스텁 4종) / `Ref.luau` 최소형 /
`init.luau` 재export / `quad-types``Quad`·`Ref<T>`·`Relate`·`Epoch` 타입 /
`Relate.luau`는 타입만 `quad-types`에서 재export(구현 무변경, 대조 일치).
- `test/mock.luau`: `installLifetime(quad)`(`lifecycle-pattern.md` (0)/(1) 스케치
그대로) + `Destroy`가 모든 Connection을 끊도록 보강(gcconn 판정의 근거).
- `scripts/test.sh`: `spec.*` 수집 + `luau-analyze quad-base/src + spec + mock`.
- spec 5개(brand/relate/lifetime/ref/void).
## 발견 (`qa-request/pre-implementation-handtrace-round11.md`)
- `H-165` ① pesde shim은 생성 시점 export 타입만 안다 → `pesde install` 재실행,
`project-setup-plan.md`에 둘째 함정으로 기록.
- `H-166``Ref.Revision` 초기값 미정 → `0`, `ref-plan.md`.
- 툴링 사실: `@self``init.luau` 전용 / GC 테스트 함정 둘(죽은 레지스터, 불변
업밸류 클로저 캐시) — §5.
- ②/③ 갈래 발견 **없음** — §4 표는 비어 있다.
## 다음
단위 끝 절차(규약 §4): 감사 루프 → `/code-review high` → 커밋 → fable 탐사자 →
사용자에게 "§4를 보라".

View file

@ -272,7 +272,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
> 여기 있는 것 전부 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다. > 여기 있는 것 전부 State-free이자 dispatch-free라 어느 쪽에도 안 걸립니다.
> 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다. > 이 절이 끝나야 아래 반응형 본체를 짤 수 있습니다.
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()` - [x] **[2026-08-28 완료 — `quad-base/src/Brand.luau` + `test/spec.brand.luau`, 브랜드 인스턴스 15개와 M2 `is*` 11개가 이 잎 파일에]** `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`, 브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`). **다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
옛 공유 레지스트리 + `Brand.get(x) -> tag` 옛 공유 레지스트리 + `Brand.get(x) -> tag`
@ -302,7 +302,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
않는다"*로 확정 — `isNone``None.luau`(M3) 쪽에 산다. 이 마일스톤 않는다"*로 확정 — `isNone``None.luau`(M3) 쪽에 산다. 이 마일스톤
분리로 처음 눈에 띈 잔재를 **[2026-08-24]** 정정한 것) — 분리로 처음 눈에 띈 잔재를 **[2026-08-24]** 정정한 것) —
`brand-plan.md``Brand` 절, 2026-08-07 여덟 번째 세션 신설) `brand-plan.md``Brand` 절, 2026-08-07 여덟 번째 세션 신설)
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) — - [x] **[2026-08-28 완료 — `relate-plan.md` 대조 일치, `test/spec.relate.luau`, 타입은 `quad-types`로 옮겨 재export]** `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
**[2026-08-28 확인] 파일은 M1 커밋 `205af32`에 이미 있다**(`RunInit`이 **[2026-08-28 확인] 파일은 M1 커밋 `205af32`에 이미 있다**(`RunInit`이
쓴다) — 이 체크박스의 남은 일은 `base/relate-plan.md` 대조와 테스트뿐. 쓴다) — 이 체크박스의 남은 일은 `base/relate-plan.md` 대조와 테스트뿐.
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`. `Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
@ -310,7 +310,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`) 생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`)
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
대체(2026-08-08 세션 신설). 대체(2026-08-08 세션 신설).
- [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ - [x] **[2026-08-28 완료 — `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종 설치, `test/spec.lifetime.luau`]** `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/
`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 `unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
@ -347,7 +347,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
이 게이트를 그대로 탐 이 게이트를 그대로 탐
- [ ] **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래 - [x] **[2026-08-28 완료 — `quad-base/src/Ref.luau` + `test/spec.ref.luau`(`:Set` 순서·`bit32` 랩·dedup·weak GC·스냅샷 순회·thread 소진)]** **⭐ [2026-08-27 9라운드 `H-128` 신설] `Ref.luau` 최소형** — 아래
`Effect(fn, ...deps)``Ref` dep 분기(`isRef(d)` → `Effect(fn, ...deps)``Ref` dep 분기(`isRef(d)` →
`d:WeakCallback(onRefFire)``self._epochs:Sync(d)`)가 **M2 안에서 `d:WeakCallback(onRefFire)``self._epochs:Sync(d)`)가 **M2 안에서
실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/ 실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/
@ -363,7 +363,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
**[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이 **[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이
마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types``Quad``Ref` 마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types``Quad``Ref`
생성자 필드도 여기서** 추가한다 — M8의 `H-25` 체크박스는 이걸로 흡수. 생성자 필드도 여기서** 추가한다 — M8의 `H-25` 체크박스는 이걸로 흡수.
- [ ] **[2026-08-28 `H-162`] `Void`** — 단일 no-op 함수 export. no-op 클로저를 - [x] **[2026-08-28 완료 — `quad-base/src/Void.luau` + `test/spec.void.luau`]** **[2026-08-28 `H-162`] `Void`** — 단일 no-op 함수 export. no-op 클로저를
돌려주는 자리는 새 클로저 대신 이것. 아래 `H-80` 탑레벨 목록에만 있고 돌려주는 자리는 새 클로저 대신 이것. 아래 `H-80` 탑레벨 목록에만 있고
여기 체크박스가 없어 "개수·목록은 소스 하나" 규약에 어긋나던 것을 M2 여기 체크박스가 없어 "개수·목록은 소스 하나" 규약에 어긋나던 것을 M2
착수 규약 커밋에서 신설. 착수 규약 커밋에서 신설.
@ -629,7 +629,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy 호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch 조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구** 쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M3(디스패치)가 요구**
- [ ] **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]** - [ ] **[2026-08-28 부분 — 첫 단위분(`Relate`/`Void`/`Ref`/`is*` 11개/생명주기 4종)은 `quad-types` `Quad`에 추가됨, `Source`/`Store`/`Effect`/`Blocker`는 각 단위에서]** **[2026-08-24 `H-25` 파생, 2026-08-25 `H-80`으로 목록 확장]**
`quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 — `quad-types``Quad`**이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형, `Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
2026-08-27 `H-128`) / 2026-08-27 `H-128`) /
@ -675,7 +675,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
(영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적 (영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적
코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫 코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫
`:Get()`에서 엉뚱한 에러로 죽는다. 생성 시 1회라 hot path 아님 `:Get()`에서 엉뚱한 에러로 죽는다. 생성 시 1회라 hot path 아님
- [ ] **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 — - [x] **[2026-08-28 완료 — 첫 단위로 당겨서 `test/mock.luau``installLifetime(quad)`, `lifecycle-pattern.md` (0)/(1) 스케치 그대로; mock `Destroy`가 모든 Connection을 끊도록 보강]** **[2026-08-25 신설, `H-97`]** mock 백엔드용 생명주기 4종 최소 구현 —
`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면 `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`. 안 하면
**아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매 **아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은 발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은

136
quad-base/src/Brand.luau Normal file
View file

@ -0,0 +1,136 @@
--[[
Brand — instance brands, one weak-key set per brand.
`.claude/base/brand-plan.md` "구현 — 인스턴스 브랜드" / "`isX` wrapper" sections,
as-is.
`Brand()` makes one brand object holding a weak-key member set; a value
registers itself into the brand(s) it belongs to (multi-tagging is the
point — `Source` is both `SourceBrand` and `EpochBrand`). There is no
reverse lookup (`Brand.get(x) -> tag` was reversed, see
`archive/brand-shared-registry-reversed.md`).
Every brand instance lives in this leaf file (M2 first-unit plan,
`qa-request/pre-implementation-handtrace-round11-brief.md` §6): `EpochBrand`
is shared by `Source`/`Ref`/`GateNode`, so keeping the instances per type
module would force a circular require. Each *type* still registers only
into its own brand at its own construction sites; subtype relations are
expressed once, in the predicate (`isState`/`isRef` below), never by
double registration.
Method names are lowercase (`:register`/`:is`) on purpose — this is a
base-internal utility, not a public quad surface (`brand-plan.md`).
`Brand` depends on nothing. In particular there is no `None` special
case here — `isNone` is `v == None` and lives with `None.luau` (M3).
]]
export type Brand = {
register: (self: Brand, x: any) -> (),
is: (self: Brand, x: any) -> boolean,
}
local function Brand(): Brand
local members = setmetatable({}, { __mode = "k" }) :: { [any]: true }
return {
register = function(_self: Brand, x: any)
members[x] = true
end,
is = function(_self: Brand, x: any): boolean
return members[x] == true
end,
}
end
-- Each type owns exactly one brand. The full list is declared here even for
-- types that arrive in later milestones (`Tag`/`Attribute`/`Tween`/`Slot`)
-- so that the set of brands has one source; their `is*` predicates are added
-- with the type.
local ObserverBrand = Brand()
local EffectBrand = Brand()
local TagBrand = Brand()
local AttributeBrand = Brand()
local TweenBrand = Brand()
local BlockerBrand = Brand()
local StateBrand = Brand()
local SourceBrand = Brand()
local StoreBrand = Brand()
local SlotBrand = Brand()
local RefBrand = Brand()
local PreRefBrand = Brand()
local PostRefBrand = Brand()
local ModifierBrand = Brand()
local EpochBrand = Brand()
-- Plain identity predicates.
local function isEpoch(x: any): boolean
return EpochBrand:is(x)
end
local function isSource(x: any): boolean
return SourceBrand:is(x)
end
local function isStore(x: any): boolean
return StoreBrand:is(x)
end
local function isObserver(x: any): boolean
return ObserverBrand:is(x)
end
local function isEffect(x: any): boolean
return EffectBrand:is(x)
end
local function isBlocker(x: any): boolean
return BlockerBrand:is(x)
end
local function isModifier(x: any): boolean
return ModifierBrand:is(x)
end
-- Subtype relations: the more specific predicate is defined first and the
-- wider one is composed on top of it, so the direction of inclusion is
-- visible in the code (`brand-plan.md`).
local function isState(x: any): boolean
return isSource(x) or StateBrand:is(x) -- Source structurally satisfies State
end
local function isPreRef(x: any): boolean
return PreRefBrand:is(x)
end
local function isPostRef(x: any): boolean
return PostRefBrand:is(x)
end
local function isRef(x: any): boolean
-- PreRef/PostRef reuse the Ref runtime = both are a kind of Ref.
-- They are exclusive siblings of each other.
return isPreRef(x) or isPostRef(x) or RefBrand:is(x)
end
return {
Brand = Brand,
ObserverBrand = ObserverBrand,
EffectBrand = EffectBrand,
TagBrand = TagBrand,
AttributeBrand = AttributeBrand,
TweenBrand = TweenBrand,
BlockerBrand = BlockerBrand,
StateBrand = StateBrand,
SourceBrand = SourceBrand,
StoreBrand = StoreBrand,
SlotBrand = SlotBrand,
RefBrand = RefBrand,
PreRefBrand = PreRefBrand,
PostRefBrand = PostRefBrand,
ModifierBrand = ModifierBrand,
EpochBrand = EpochBrand,
isEpoch = isEpoch,
isSource = isSource,
isState = isState,
isStore = isStore,
isObserver = isObserver,
isEffect = isEffect,
isBlocker = isBlocker,
isModifier = isModifier,
isRef = isRef,
isPreRef = isPreRef,
isPostRef = isPostRef,
}

View file

@ -0,0 +1,44 @@
--[[
InitLifetimeHandle(module) — the *interface* of the four top-level
lifetime primitives, installed on the module instance as error stubs.
`.claude/base/lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/
`unbindLifetime` — 확정" section; injection rule from
`.claude/base/module-lifecycle-plan.md` ("base 유틸(... 생명 바인드 유틸)이
인터페이스만 두고 실제 구현은 백엔드 팩토리가 뮤테이션으로 주입").
Contract (types in `quad-types`):
bindLifetime(inst, value) -- inst is needed here and only here
unbindLifetime(value) -- early release of one value; no-op if unbound
canBound(value) -> boolean -- "may I bind this now?" true = not bound anywhere
canExecute(value)-> boolean -- "may this fire now?" emit-propagation gate
`canBound(v) == not canExecute(v)` — both wrap one private predicate
(`isBoundAlive`) that belongs to the backend, not here.
quad-base knows nothing about Instances, so these slots default to stubs
that error loudly (never a silent no-op — base cannot guess an arbitrary
engine's "right" default). quad-roblox overwrites them on the module
instance (M8); tests overwrite them with the mock backend
(`quad-base/test/mock.luau` `installLifetime`, ROADMAP `H-97`).
They are flat top-level functions, not `LifetimeHandle.bind(...)` —
first-class primitives handler authors call directly, like `isState`.
]]
local function notInstalled(name: string): (...any) -> ...any
return function(...: any)
error(
`quad: {name} is not available — no backend has installed the lifetime primitives `
.. "(quad-roblox does this when it wraps the module; tests use mock.installLifetime)",
2
)
end
end
local function Init(module: any)
module.bindLifetime = notInstalled("bindLifetime")
module.unbindLifetime = notInstalled("unbindLifetime")
module.canBound = notInstalled("canBound")
module.canExecute = notInstalled("canExecute")
end
return Init

113
quad-base/src/Ref.luau Normal file
View file

@ -0,0 +1,113 @@
--[[
Ref — general value box. Minimal M2 form (`ROADMAP.md` M2 "공통 기반",
9라운드 `H-128`): `.Value` / `.Revision` / `:Set` / `:Callback` /
`:WeakCallback` / `:Uncallback` + `isRef`, and `EpochBrand` membership.
`:Wait`, `PreRef`/`PostRef` and the dispatch handlers stay in M8.
Sources, as-is:
- `.claude/base/ref-plan.md` "API 모양" (surface, self-returning
mutations, "already filled → call once on registration"),
"`.Callbacks`는 … 해시맵 셋" (dedup contract, snapshot before
iterating), "`:WeakCallback(fn)`" (weak-key table `.WeakCallbacks`,
Weak is the primitive and `:Callback` only adds the GC keep),
"`:Set(value)`의 순서" (value → revision → callbacks; the code below
is that block), "`Ref`는 `Epoch`를 만족한다" (`.Revision`, brand).
- `.claude/base/state-epoch-plan.md` §2: revision bump is the uint32
wrap-around decrement `bit32.bnot(-rev)`; only `==`/`~=` are contract.
Callback signature is `fn(value, ref)` — the second argument is the Ref
itself, i.e. the `Epoch` a consumer (`Effect`) feeds to `EpochMap:Update`.
Plain user callbacks just ignore it.
`Ref` is NOT a State: no emit propagation, no `Get`/`Compute`. Being an
`Epoch` only means "carries a mark distinguishable from the previous one".
]]
local Brand = require("./Brand")
local QuadTypes = require("../roblox_packages/quad_types")
export type Ref<T> = QuadTypes.Ref<T>
type Callback<T> = QuadTypes.RefCallback<T>
local RefBrand = Brand.RefBrand
local EpochBrand = Brand.EpochBrand
local WEAK_KEY_MT = { __mode = "k" }
local RefImpl = {}
RefImpl.__index = RefImpl
function RefImpl.Set<T>(self: Ref<T>, value: T?): Ref<T>
self.Value = value -- (1) settle the value first
self.Revision = bit32.bnot(-self.Revision) -- (2) before callbacks (`H-108`)
-- (3) snapshot before iterating (`H-23`): registering/unregistering
-- during the walk must not disturb `pairs`.
local snapshot: { any } = {}
local callbacks = self.Callbacks
local weakCallbacks = self.WeakCallbacks
for k in pairs(callbacks) do
table.insert(snapshot, k)
end
for k in pairs(weakCallbacks) do
-- A key present in both tables is carried once — otherwise that
-- callback fires twice. Function keys only: `:Wait()` (M8) puts its
-- thread waiters in `.Callbacks` only, so draining below is right.
if callbacks[k] == nil then
table.insert(snapshot, k)
end
end
for _, k in ipairs(snapshot) do
if callbacks[k] == nil and weakCallbacks[k] == nil then
continue -- released while we were walking
end
if type(k) == "thread" then
callbacks[k] = nil -- waiters are consumed
coroutine.resume(k, self) -- the Ref itself, not the value
else
-- value + the Ref itself (= `Epoch`). The cast only quiets the checker:
-- `k` comes from a `{ any }` snapshot and the `thread` branch is above.
(k :: Callback<T>)(value, self)
end
end
return self
end
-- "Weak" is the primitive: same behaviour, same guards, only the GC keep
-- removed. Registration calls the callback once right away with whatever
-- is there — nil/unset included (`H-120`; the nil guard is the caller's,
-- `base/lifecycle-hooks-plan.md`).
function RefImpl.WeakCallback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.WeakCallbacks[fn] = true
fn(self.Value, self)
return self
end
function RefImpl.Callback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.Callbacks[fn] = true -- the strong keep, on top of the weak primitive
fn(self.Value, self)
return self
end
-- Detaches a user-registered callback. Both tables — a weakly registered
-- callback is otherwise impossible to remove and keeps firing.
-- Dedup on registration means there is never a "how many times" question.
function RefImpl.Uncallback<T>(self: Ref<T>, fn: Callback<T>): Ref<T>
self.Callbacks[fn] = nil
self.WeakCallbacks[fn] = nil
return self
end
local function Ref<T>(default: T?): Ref<T>
local self = setmetatable({
Value = default,
Revision = 0,
Callbacks = {},
WeakCallbacks = setmetatable({}, WEAK_KEY_MT),
}, RefImpl)
RefBrand:register(self)
EpochBrand:register(self) -- multi-tagging: a Ref is also an Epoch
return (self :: any) :: Ref<T>
end
return Ref

View file

@ -3,12 +3,10 @@
`.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현. `.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현.
]] ]]
export type Relate = { local QuadTypes = require("../roblox_packages/quad_types")
SetStrong: (self: Relate, inst: any, key: any, value: any) -> (),
GetStrong: (self: Relate, inst: any, key: any) -> any?, -- 타입의 소스는 `quad-types`(`Quad.Relate` 필드가 같은 타입을 써야 함) — 여기선 재export만.
SetWeak: (self: Relate, inst: any, key: any, value: any) -> (), export type Relate = QuadTypes.Relate
GetWeak: (self: Relate, inst: any, key: any) -> any?,
}
type Bucket = { type Bucket = {
StrongMap: { [any]: any }?, StrongMap: { [any]: any }?,

14
quad-base/src/Void.luau Normal file
View file

@ -0,0 +1,14 @@
--[[
Void — the single no-op function quad exports.
`.claude/base/architecture.md` source tree `Void.luau` line / `H-162`.
Every place that would otherwise hand out a fresh `function() end`
(handler retractors, cleanup slots, ...) returns this one value instead.
Dependency-free leaf on purpose: `Dispatch/init.luau` (M3) binds it at
file scope, so defining it on the top-level `init.luau` would be a
circular require.
]]
local function Void(...: any) end
return Void

View file

@ -14,8 +14,12 @@
]] ]]
local Relate = require("@self/Relate") local Relate = require("@self/Relate")
local Brand = require("@self/Brand")
local Ref = require("@self/Ref")
local Void = require("@self/Void")
local QuadTypes = require("./roblox_packages/quad_types") local QuadTypes = require("./roblox_packages/quad_types")
local InitDebug = require("@self/Debug") local InitDebug = require("@self/Debug")
local InitLifetimeHandle = require("@self/LifetimeHandle")
type Quad = QuadTypes.Quad type Quad = QuadTypes.Quad
@ -27,6 +31,22 @@ local function New(): Quad
local module = { local module = {
New = New, New = New,
Version = "0.0.0", Version = "0.0.0",
-- M2 공통 기반 — 의존 없는 잎 모듈 재export(`ROADMAP.md` M2 `H-80`)
Relate = Relate,
Void = Void,
Ref = Ref,
isEpoch = Brand.isEpoch,
isSource = Brand.isSource,
isState = Brand.isState,
isStore = Brand.isStore,
isObserver = Brand.isObserver,
isEffect = Brand.isEffect,
isBlocker = Brand.isBlocker,
isModifier = Brand.isModifier,
isRef = Brand.isRef,
isPreRef = Brand.isPreRef,
isPostRef = Brand.isPostRef,
} :: Quad } :: Quad
function module.RunInit(self, initFn) function module.RunInit(self, initFn)
@ -46,6 +66,7 @@ local function New(): Quad
end end
module:RunInit(InitDebug) module:RunInit(InitDebug)
module:RunInit(InitLifetimeHandle) -- 생명주기 4종 에러 스텁 — 백엔드가 덮어씀
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가 -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
return module return module

View file

@ -10,11 +10,25 @@
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는 엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base
정적 스냅샷 테스트 범위 밖. 정적 스냅샷 테스트 범위 밖.
**[2026-08-28 M2, ROADMAP `H-97`] `installLifetime(quad)`** — 생명주기 4종
(`bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`)의 mock 백엔드.
quad-roblox가 할 모듈 뮤테이션을 그대로 흉내내며, 본문은
`.claude/base/lifecycle-pattern.md` (0)/(1)의 실 구현 스케치를 그대로 옮긴
것(gcconn = 절대 안 발화하는 `ClassName` 변경 시그널의 Connection,
gchold = inst에 매달린 값들의 강참조 홀더, `InstData`/`BindData` 두 Relate).
이걸 위해 `Destroy`가 Roblox처럼 **그 인스턴스의 모든 Connection을 끊는다**
(`Connected = false`) — gcconn의 `.Connected`가 곧 생존 판정이라서.
Roblox와 다른 점 하나: gcconn/gchold 셋업(`nativeClaim`)은 quad가 Instance를
만들 때 하는 일인데 mock Instance는 quad 밖에서 만들어지므로 여기선
`bindLifetime` 첫 호출에 lazy로 한다 — quad-roblox는 그러면 안 된다
(userdata 동일성 구멍, 같은 문서 (0) 절).
]] ]]
export type Signal = { export type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection, Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (), Fire: (self: Signal, ...any) -> (),
DisconnectAll: (self: Signal) -> (),
} }
export type Connection = { export type Connection = {
@ -46,6 +60,15 @@ function Signal:Connect(fn: (...any) -> ()): Connection
return (conn :: any) :: Connection return (conn :: any) :: Connection
end end
-- Destroy가 부른다 — Roblox는 Destroy 시 그 인스턴스의 모든 연결을 끊는다.
function Signal:DisconnectAll()
local connections = (self :: any).connections
for i = #connections, 1, -1 do
connections[i].Connected = false
connections[i] = nil
end
end
function Signal:Fire(...: any) function Signal:Fire(...: any)
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례) -- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
local snapshot = table.clone((self :: any).connections) local snapshot = table.clone((self :: any).connections)
@ -205,6 +228,12 @@ end
function methods.Destroy(inst: any) function methods.Destroy(inst: any)
local data = getData(inst) local data = getData(inst)
data.destroying:Fire() data.destroying:Fire()
-- Roblox처럼 이 인스턴스의 모든 연결을 끊는다(Destroying 포함) —
-- gcconn(`ClassName` 변경 시그널)의 `.Connected`가 생존 판정의 근거.
data.destroying:DisconnectAll()
for _, sig in data.changed do
sig:DisconnectAll()
end
if data.parent then if data.parent then
local siblings = data.parent.children local siblings = data.parent.children
local i = table.find(siblings, data) local i = table.find(siblings, data)
@ -220,8 +249,121 @@ local function isMockInstance(value: any): boolean
return dataOf[value] ~= nil return dataOf[value] ~= nil
end end
--[[
installLifetime(quad) — `.claude/base/lifecycle-pattern.md` (0)/(1) 스케치를
mock 시그널 위에 그대로 옮김. quad 모듈 인스턴스를 뮤테이션한다(quad-roblox와
같은 경로). 반환값은 없고, 같은 quad에 두 번 부르면 두 번째는 무시된다.
]]
local Relate = require("../src/Relate")
local installed = setmetatable({}, { __mode = "k" }) :: { [any]: true }
local function installLifetime(quad: any)
if installed[quad] then
return
end
installed[quad] = true
local isObserver, isEffect = quad.isObserver, quad.isEffect
local InstData = Relate() -- inst -> gchold/gcconn ((0)에서 채움)
local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움)
-- `false or`: local 함수가 상수 접힘/인라인되면 아래 클로저의 업밸류 캡처가 사라진다
local nop = (false or function(...: any) end) :: (...any) -> ()
-- (0) nativeClaim 상당 — mock에선 lazy(헤더 주석 참고)
local function claim(inst: any)
if InstData:GetWeak(inst, "gchold") ~= nil then
return
end
local gchold = {}
local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
nop(gchold, inst) -- 절대 발화 안 함. 클로저가 gchold와 inst를 업밸류로 붙잡는 게 전부
end)
gchold[1] = gcconn -- 배열 자리 1번은 gcconn 전용(값들은 해시 자리에)
InstData:SetWeak(inst, "gchold", gchold)
InstData:SetWeak(inst, "gcconn", gcconn)
end
-- 비공개 — canBound/canExecute가 공유하는 실제 판정.
local function isBoundAlive(value: any): boolean
-- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음
local gcconn = BindData:GetWeak(value, "gcconn")
if gcconn ~= nil and gcconn.Connected then
return true
end
-- (b) 전역 경로: 구독 경로(강/약)가 세운 것. Observer/Effect에만 있는 필드.
if isObserver(value) or isEffect(value) then
return value.Subscribed == true
end
return false
end
local function canBound(value: any): boolean
return not isBoundAlive(value)
end
local function canExecute(value: any): boolean
return isBoundAlive(value)
end
local function bindLifetime(inst: any, value: any)
if not isMockInstance(inst) then
error("bindLifetime: inst is not a mock instance", 2)
end
if not canBound(value) then
-- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건
-- 인덱싱하면 안 됨 — value가 평범한 클로저일 수도 있음.
local isGlobal = isObserver(value) or isEffect(value)
if isGlobal then
isGlobal = value.Subscribed == true
end
error(
if isGlobal
then "bindLifetime: value is already subscribed"
else "bindLifetime: value is already bound to another Instance",
2
)
end
claim(inst)
local gchold = InstData:GetWeak(inst, "gchold")
gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1)
-- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak.
BindData:SetWeak(value, "gchold", gchold)
BindData:SetWeak(value, "gcconn", InstData:GetWeak(inst, "gcconn"))
if isObserver(value) then
value:_catchUp() -- [H-159] 묶이기 전 홀드된 emit 1회 (단위 3에서 합류)
end
if isEffect(value) then
value:_bindDestroying(inst) -- Destroying 연결 + 홀드 캐치업 1회 (단위 3)
end
end
local function unbindLifetime(value: any)
-- bind의 대칭 — cleanup은 부르지 않고, Ref 콜백/내부 Observer도 안 뗀다.
if isEffect(value) then
value:_unbindDestroying() -- Destroying 연결만 끊는다 (단위 3)
end
local gchold = BindData:GetWeak(value, "gchold")
if gchold then
gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제
end
BindData:SetWeak(value, "gchold", nil)
BindData:SetWeak(value, "gcconn", nil)
end
quad.bindLifetime = bindLifetime
quad.unbindLifetime = unbindLifetime
quad.canBound = canBound
quad.canExecute = canExecute
end
return { return {
Instance = Instance, Instance = Instance,
newSignal = newSignal, newSignal = newSignal,
isMockInstance = isMockInstance, isMockInstance = isMockInstance,
installLifetime = installLifetime,
} }

View file

@ -0,0 +1,109 @@
--[[ Brand 계약 — `.claude/base/brand-plan.md` "구현 — 인스턴스 브랜드" / "`isX` wrapper" ]]
local Brand = require("../src/Brand")
local Quad = require("../src")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. register/is — 등록한 값만 통과 ===")
do
local b = Brand.Brand()
local x, y = {}, {}
b:register(x)
assert(b:is(x) == true, "registered value must pass")
assert(b:is(y) == false, "unregistered value must not pass")
assert(b:is(nil) == false and b:is(1) == false and b:is("s") == false, "non-table values are never members")
print("PASS")
end
print()
print("=== 2. 다중 태깅 — 한 값이 여러 브랜드에 동시에 ===")
do
local a, b = Brand.Brand(), Brand.Brand()
local x = {}
a:register(x)
b:register(x)
assert(a:is(x) and b:is(x), "a value may belong to several brands at once")
print("PASS")
end
print()
print("=== 3. 브랜드 간 독립 — 한 브랜드 등록이 다른 브랜드에 안 샘 ===")
do
local a, b = Brand.Brand(), Brand.Brand()
local x = {}
a:register(x)
assert(b:is(x) == false, "registration must not leak across brands")
print("PASS")
end
print()
print("=== 4. weak-key — 등록만으로는 값이 살아남지 않음 ===")
do
local b = Brand.Brand()
local weak = setmetatable({}, { __mode = "v" })
do
local x = {}
b:register(x)
weak[1] = x
end
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "brand membership must not keep the value alive")
print("PASS")
end
print()
print("=== 5. isRef 계층 — PreRef/PostRef는 Ref의 한 종류, 서로는 배타 ===")
do
local pre, post, plain = {}, {}, {}
Brand.PreRefBrand:register(pre)
Brand.PostRefBrand:register(post)
Brand.RefBrand:register(plain)
assert(Brand.isRef(pre) and Brand.isRef(post) and Brand.isRef(plain), "isRef passes {Ref, PreRef, PostRef}")
assert(Brand.isPreRef(pre) and not Brand.isPreRef(post) and not Brand.isPreRef(plain), "isPreRef is the most specific identity")
assert(Brand.isPostRef(post) and not Brand.isPostRef(pre) and not Brand.isPostRef(plain), "isPostRef is the most specific identity")
-- Leaf 매치 핸들러가 쓰는 좁힘(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)
local function isPlainRef(v)
return Brand.isRef(v) and not Brand.isPreRef(v) and not Brand.isPostRef(v)
end
assert(isPlainRef(plain) and not isPlainRef(pre) and not isPlainRef(post), "plain-Ref narrowing")
print("PASS")
end
print()
print("=== 6. isState 계층 — Source는 State를 구조적으로 만족 ===")
do
local src, st = {}, {}
Brand.SourceBrand:register(src)
Brand.StateBrand:register(st)
assert(Brand.isState(src) and Brand.isState(st), "isState passes both")
assert(Brand.isSource(src) and not Brand.isSource(st), "isSource is the narrower one")
print("PASS")
end
print()
print("=== 7. 단순 항등 술어 + Quad 탑레벨 재export ===")
do
local pairsToCheck: { { brand: Brand.Brand, name: string } } = {
{ brand = Brand.EpochBrand, name = "isEpoch" },
{ brand = Brand.StoreBrand, name = "isStore" },
{ brand = Brand.ObserverBrand, name = "isObserver" },
{ brand = Brand.EffectBrand, name = "isEffect" },
{ brand = Brand.BlockerBrand, name = "isBlocker" },
{ brand = Brand.ModifierBrand, name = "isModifier" },
}
for _, pair in pairsToCheck do
local brand, name = pair.brand, pair.name
local x = {}
assert((Quad :: any)[name] == (Brand :: any)[name], name .. " must be re-exported on Quad as the same function")
assert((Brand :: any)[name](x) == false, name .. " false before registration")
brand:register(x)
assert((Brand :: any)[name](x) == true, name .. " true after registration")
end
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,139 @@
--[[
LifetimeHandle 계약 — `.claude/base/lifecycle-pattern.md` "`bindLifetime`/`canBound`/
`canExecute`/`unbindLifetime` — 확정" 절 + mock 백엔드(`mock.luau` installLifetime, ROADMAP `H-97`).
Observer/Effect의 `.Subscribed` 경로는 단위 3(Observer/Effect)에서 합류.
]]
local Quad = require("../src")
local mock = require("./mock")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 미주입 스텁 — 네 슬롯 전부 영어 메시지로 error, 호출부(level 2)를 가리킴 ===")
do
local fresh = Quad.New()
for _, name in { "bindLifetime", "unbindLifetime", "canBound", "canExecute" } do
local ok, err = pcall(function()
return (fresh :: any)[name]({}, {})
end)
assert(not ok, name .. " must error before a backend is installed")
assert(string.find(tostring(err), name, 1, true) ~= nil, name .. ": message names the slot: " .. tostring(err))
assert(string.find(tostring(err), "not available", 1, true) ~= nil, name .. ": message is English: " .. tostring(err))
-- level 2 = 이 파일(호출부)을 가리킨다, LifetimeHandle.luau가 아니라
assert(string.find(tostring(err), "spec.lifetime.luau", 1, true) ~= nil, name .. ": error level must point at the caller: " .. tostring(err))
end
print("PASS")
end
local quad = Quad.New()
mock.installLifetime(quad)
local Instance = mock.Instance
print()
print("=== 2. 주입 후 — bind 전엔 canBound/‖canExecute, bind 후엔 반대 ===")
do
local inst = Instance.new("Frame")
local value = {}
assert(quad.canBound(value) == true and quad.canExecute(value) == false, "unbound value: may bind, may not execute")
quad.bindLifetime(inst, value)
assert(quad.canBound(value) == false and quad.canExecute(value) == true, "bound value: may not bind again, may execute")
print("PASS")
end
print()
print("=== 3. Destroy → canExecute false / canBound true (gcconn.Connected 전환) ===")
do
local inst = Instance.new("Frame")
local value = {}
quad.bindLifetime(inst, value)
inst:Destroy()
assert(quad.canExecute(value) == false, "after Destroy the value may not execute")
assert(quad.canBound(value) == true, "after Destroy the value may be bound again")
print("PASS")
end
print()
print("=== 4. 이중 바인딩 게이트 — `if not canBound(v) then error(...)` 모양, level 2 ===")
do
local a, b = Instance.new("Frame"), Instance.new("Frame")
local value = {}
quad.bindLifetime(a, value)
local ok, err = pcall(function()
quad.bindLifetime(b, value)
end)
assert(not ok, "binding an already-bound value must error")
assert(string.find(tostring(err), "already bound", 1, true) ~= nil, "message: " .. tostring(err))
assert(string.find(tostring(err), "spec.lifetime.luau", 1, true) ~= nil, "level 2 points at the caller: " .. tostring(err))
-- 같은 inst에 다시 묶는 것도 이중 바인딩
local ok2 = pcall(function()
quad.bindLifetime(a, value)
end)
assert(not ok2, "rebinding to the same inst is also a double bind")
print("PASS")
end
print()
print("=== 5. unbindLifetime — 특정 값 하나만 조기 해제, inst는 그대로 ===")
do
local inst = Instance.new("Frame")
local x, y = {}, {}
quad.bindLifetime(inst, x)
quad.bindLifetime(inst, y)
quad.unbindLifetime(x)
assert(quad.canExecute(x) == false and quad.canBound(x) == true, "x is released")
assert(quad.canExecute(y) == true, "y is untouched")
quad.unbindLifetime(x) -- 안 걸려있던 값에 불러도 안전한 no-op
quad.unbindLifetime({})
quad.bindLifetime(inst, x) -- 해제됐으니 다시 묶을 수 있음
assert(quad.canExecute(x) == true, "x can be rebound after release")
print("PASS")
end
print()
print("=== 6. gchold — inst가 사는 동안 value 생존 보장(계약 1), inst가 죽고 놓이면 같이 회수 ===")
do
local inst = Instance.new("Frame")
local weak = setmetatable({}, { __mode = "v" })
do
local value = {}
quad.bindLifetime(inst, value)
weak[1] = value
end
collectgarbage()
collectgarbage()
assert(weak[1] ~= nil, "bound value must survive while inst lives (gchold strong ref)")
inst:Destroy()
inst = nil :: any
collectgarbage()
collectgarbage()
assert(weak[1] == nil, "after Destroy + dropping inst, the value is collectable")
print("PASS")
end
print()
print("=== 7. 첫 인자는 mock Instance여야 함 ===")
do
local ok, err = pcall(function()
quad.bindLifetime({}, {})
end)
assert(not ok and string.find(tostring(err), "not a mock instance", 1, true) ~= nil, "non-instance first arg: " .. tostring(err))
print("PASS")
end
print()
print("=== 8. installLifetime은 quad 인스턴스별 — 다른 New()엔 안 퍼짐, 두 번 불러도 무시 ===")
do
local other = Quad.New()
local ok = pcall(function()
other.canBound({})
end)
assert(not ok, "another Quad instance still has the stubs")
local before = quad.bindLifetime
mock.installLifetime(quad)
assert(quad.bindLifetime == before, "second install is ignored")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,206 @@
--[[
Ref 최소형 계약 — `.claude/base/ref-plan.md` "API 모양" / "`.Callbacks`는 … 해시맵 셋" /
"`:WeakCallback(fn)`" / "`:Set(value)`의 순서" / "`Ref`는 `Epoch`를 만족한다",
`.claude/base/state-epoch-plan.md` §2(리비전 랩).
]]
local Quad = require("../src")
local Ref = Quad.Ref
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 생성 — Value/Revision 초기값, 브랜드(isRef + isEpoch), Callback 테이블 둘 ===")
do
local r = Ref(5)
assert(r.Value == 5, "default lands in .Value")
assert(r.Revision == 0, "initial revision is 0")
assert(Quad.isRef(r) and Quad.isEpoch(r), "a Ref is both Ref and Epoch (multi-tagging)")
assert(not Quad.isPreRef(r) and not Quad.isPostRef(r) and not Quad.isState(r), "plain Ref only")
assert((getmetatable(r.WeakCallbacks :: any) :: any).__mode == "k", "WeakCallbacks is weak-keyed")
assert(getmetatable(r.Callbacks) == nil, "Callbacks is a plain strong set")
local empty = Ref()
assert(empty.Value == nil, "Ref() has no value")
print("PASS")
end
print()
print("=== 2. :Set 순서 — 콜백이 볼 때 .Value와 .Revision이 이미 새 것, fn(value, ref) ===")
do
local r = Ref(1)
local seen: { any } = {}
r:Callback(function(value, ref)
table.insert(seen, { value = value, refValue = ref.Value, rev = ref.Revision, ref = ref })
end)
assert(#seen == 1 and seen[1].value == 1 and seen[1].ref == r, "registration calls once with the current value + the Ref itself")
local rev0 = r.Revision
local ret = r:Set(2)
assert(ret == r, ":Set returns self")
assert(#seen == 2, "one fire per Set")
assert(seen[2].value == 2 and seen[2].refValue == 2, ".Value is settled before callbacks")
assert(seen[2].rev ~= rev0 and seen[2].rev == r.Revision, ".Revision is bumped before callbacks")
print("PASS")
end
print()
print("=== 3. 리비전 — bit32.bnot(-rev) 랩어라운드 감소: 0 → 4294967295 → 4294967294, 매번 다름 ===")
do
local r = Ref()
r:Set(1)
assert(r.Revision == 4294967295, "0 wraps to 4294967295, got " .. r.Revision)
r:Set(2)
assert(r.Revision == 4294967294, "then decrements, got " .. r.Revision)
local last = r.Revision
for _ = 1, 100 do
r:Set(0)
assert(r.Revision ~= last, "every Set changes the revision")
last = r.Revision
end
print("PASS")
end
print()
print("=== 4. 즉시 1회 호출 — nil/미설정이어도 그 상태 그대로 (H-120), Weak도 동일 ===")
do
local r = Ref()
local calls = 0
local weakCalls = 0
r:Callback(function(value)
calls += 1
assert(value == nil, "nil is passed as-is")
end)
r:WeakCallback(function(value)
weakCalls += 1
assert(value == nil, "nil is passed as-is")
end)
assert(calls == 1 and weakCalls == 1, "both registrations fire once immediately")
print("PASS")
end
print()
print("=== 5. 중복 등록은 dedup — 같은 fn을 여러 번/양쪽에 걸어도 Set당 1회 ===")
do
local r = Ref(0)
local calls = 0
local function fn()
calls += 1
end
r:Callback(fn):Callback(fn):WeakCallback(fn)
calls = 0
r:Set(1)
assert(calls == 1, "same fn registered strongly + weakly fires once per Set, got " .. calls)
print("PASS")
end
print()
print("=== 6. :Uncallback — 양쪽 테이블에서 뗌, self 반환, 안 걸린 fn은 no-op ===")
do
local r = Ref(0)
local strong, weak = 0, 0
local function s()
strong += 1
end
local function w()
weak += 1
end
r:Callback(s):WeakCallback(w)
strong, weak = 0, 0
assert(r:Uncallback(s) == r, "Uncallback returns self")
r:Uncallback(w)
r:Uncallback(function() end)
r:Set(1)
assert(strong == 0 and weak == 0, "detached callbacks do not fire")
assert(r.Callbacks[s] == nil and r.WeakCallbacks[w] == nil, "entries are removed from both tables")
print("PASS")
end
print()
print("=== 7. WeakCallback — 다른 곳에서 안 잡으면 GC 뒤 침묵, Callback은 살아남음 ===")
do
local r = Ref(0)
local strongCalls, weakCalls = 0, 0
-- ⚠️ 두 가지 GC 함정을 피한 모양: (1) 별도 함수 안에서 등록 — 같은 프레임의
-- 죽은 레지스터가 클로저를 붙잡는 스택 잔재를 피한다. (2) 클로저가 업밸류를
-- **직접 변경**한다 — 불변 업밸류만 잡는 클로저는 Luau가 프로토에 캐시해
-- 강참조로 붙들어 영영 GC되지 않는다(`lifecycle-pattern.md`의 `false or`
-- 트릭이 막는 것과 같은 최적화).
local function register()
r:Callback(function()
strongCalls += 1
end)
r:WeakCallback(function()
weakCalls += 1
end)
end
register()
collectgarbage()
collectgarbage()
strongCalls, weakCalls = 0, 0
r:Set(1)
assert(strongCalls == 1, "strongly registered callback survives GC")
assert(weakCalls == 0, "weakly registered callback is collected and silent")
assert(next(r.WeakCallbacks) == nil, "WeakCallbacks entry is gone")
print("PASS")
end
print()
print("=== 8. 발화 중 Uncallback/Callback 안전 — 스냅샷 순회, 순회 중 해제된 건 skip ===")
do
local r = Ref(0)
local order: { string } = {}
local walking = false -- 등록 즉시 1회 호출과 Set 순회를 구분
local a: (number?, any) -> ()
local b: (number?, any) -> ()
-- a와 b가 서로를 떼므로 pairs 순서와 무관하게 정확히 하나만 돈다
a = function()
if not walking then
return
end
table.insert(order, "a")
r:Uncallback(b)
r:Callback(function()
if walking then
table.insert(order, "late") -- 등록 즉시 1회만, 이번 파동의 순회엔 안 낌
end
end)
end
b = function()
if not walking then
return
end
table.insert(order, "b")
r:Uncallback(a)
end
r:Callback(a):Callback(b)
walking = true
r:Set(1)
walking = false
local counts: { [string]: number } = { a = 0, b = 0, late = 0 }
for _, v in order do
counts[v] = (counts[v] or 0) + 1
end
assert(counts.a + counts.b == 1, "exactly one of a/b runs — the other was released mid-walk and skipped, got " .. table.concat(order, ","))
assert(counts.late == counts.a, "a callback registered mid-walk fires its registration call only, got " .. table.concat(order, ","))
print("PASS")
end
print()
print("=== 9. thread 키 — 대기자는 1회 소진, resume 인자는 Ref 자신 (M8 :Wait의 기반) ===")
do
local r = Ref(0)
local got: any = nil
local co = coroutine.create(function()
got = coroutine.yield()
end)
coroutine.resume(co)
r.Callbacks[co] = true -- :Wait()가 M8에서 할 등록을 직접 흉내
r:Set(1)
assert(got == r, "waiter is resumed with the Ref itself")
assert(r.Callbacks[co] == nil, "waiter is consumed")
assert(coroutine.status(co) == "dead", "waiter ran to completion")
r:Set(2) -- 소진됐으니 다시 resume 안 함(죽은 코루틴 resume이면 여기서 티가 남)
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,95 @@
--[[ Relate 계약 — `.claude/base/relate-plan.md` "API (확정)" / "실제 구조 (확정, 2026-08-08 세션)" ]]
local Relate = require("../src/Relate")
local Quad = require("../src")
-- strict 모드에서 `collectgarbage`는 선언 안 된 전역 — 표준 luau CLI엔 있다(Roblox엔 없음)
local collectgarbage = (_G :: any).collectgarbage :: () -> ()
print("=== 1. 네 메서드 — Strong/Weak는 서로 다른 슬롯, 같은 키라도 안 섞임 ===")
do
local r = Relate()
local inst = {}
r:SetStrong(inst, "k", 1)
r:SetWeak(inst, "k", {})
assert(r:GetStrong(inst, "k") == 1, "GetStrong reads what SetStrong wrote")
assert(type(r:GetWeak(inst, "k")) == "table", "GetWeak reads what SetWeak wrote")
assert(r:GetStrong(inst, "missing") == nil and r:GetWeak(inst, "missing") == nil, "unknown key is nil")
assert(r:GetStrong({}, "k") == nil and r:GetWeak({}, "k") == nil, "unknown inst is nil")
assert(Quad.Relate == Relate, "Relate must be re-exported on Quad")
print("PASS")
end
print()
print("=== 2. lazy 서브테이블 — 읽기는 쓰기를 유발하지 않음, Set이 처음 불릴 때만 생성 ===")
do
local r = Relate()
local inst = {}
local buckets = (r :: any).buckets
assert(r:GetStrong(inst, "k") == nil, "read on fresh relate")
assert(buckets[inst] == nil, "a read must not create the bucket")
r:SetStrong(inst, "k", true)
assert(buckets[inst] ~= nil and buckets[inst].StrongMap ~= nil, "SetStrong creates bucket + StrongMap")
assert(buckets[inst].WeakMap == nil, "WeakMap stays unmade until SetWeak")
r:SetWeak(inst, "w", {})
assert(buckets[inst].WeakMap ~= nil, "SetWeak creates WeakMap")
print("PASS")
end
print()
print("=== 3. 공유 메타테이블 — 모든 WeakMap이 같은 {__mode='v'} 객체 ===")
do
local r = Relate()
local a, b = {}, {}
r:SetWeak(a, "k", {})
r:SetWeak(b, "k", {})
local buckets = (r :: any).buckets
local mtA, mtB = getmetatable(buckets[a].WeakMap), getmetatable(buckets[b].WeakMap)
assert(mtA ~= nil and mtA == mtB, "WeakMap metatable is shared")
assert(mtA.__mode == "v", "WeakMap is weak-valued")
assert(getmetatable(buckets[a].StrongMap) == nil, "StrongMap has no metatable")
print("PASS")
end
print()
print("=== 4. Weak 값은 다른 곳에서 안 잡으면 GC됨, Strong 값은 살아남음 ===")
do
local r = Relate()
local inst = {}
do
r:SetWeak(inst, "w", {})
r:SetStrong(inst, "s", {})
end
collectgarbage()
collectgarbage()
assert(r:GetWeak(inst, "w") == nil, "weakly held value is collected")
assert(r:GetStrong(inst, "s") ~= nil, "strongly held value survives")
print("PASS")
end
print()
print("=== 5. inst는 항상 weak — inst를 놓으면 버킷째 사라짐 ===")
do
local r = Relate()
local buckets = (r :: any).buckets
do
local inst = {}
r:SetStrong(inst, "s", {})
end
collectgarbage()
collectgarbage()
assert(next(buckets) == nil, "bucket keyed by a dead inst must be collected")
print("PASS")
end
print()
print("=== 6. 비싱글톤 — 인스턴스마다 독립 ===")
do
local a, b = Relate(), Relate()
local inst = {}
a:SetStrong(inst, "k", 1)
assert(b:GetStrong(inst, "k") == nil, "separate Relate instances do not share buckets")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -0,0 +1,22 @@
--[[ Void 계약 — `H-162`: 단일 no-op 함수, 반환값 없음, 항상 같은 함수 ]]
local Quad = require("../src")
local Void = require("../src/Void")
print("=== 1. 항등 — Quad.Void와 Void.luau가 같은 함수, 매 require도 같음 ===")
do
assert(Quad.Void == Void, "Quad.Void is the very same function as Void.luau")
assert(require("../src/Void") == Void, "module caching keeps one identity")
print("PASS")
end
print()
print("=== 2. no-op — 어떤 인자로 불러도 아무것도 반환하지 않음 ===")
do
assert(select("#", Void()) == 0, "Void() returns nothing")
assert(select("#", Void(1, "a", {}, nil)) == 0, "Void(...) returns nothing regardless of args")
print("PASS")
end
print()
print("=== ALL PASS ===")

View file

@ -17,12 +17,66 @@
local TypeVersionCheck = require("./luau_packages/type_version_check") local TypeVersionCheck = require("./luau_packages/type_version_check")
-- `Epoch` — 최소 인터페이스(`.claude/base/state-epoch-plan.md` §2). 판정은
-- identity + "직전과 다른 Revision"뿐, 순서 비교 없음. 런타임 판별은 `isEpoch`.
export type Epoch = { Revision: number }
-- `Relate` — inst를 weak 키로 하는 릴레이션(`.claude/base/relate-plan.md` "API").
-- `inst`는 항상 weak, `Weak`/`Strong`은 value의 보관 방식만 가리킨다.
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?,
}
-- `Ref<T>` 최소형(`.claude/base/ref-plan.md`, M2 `H-128`). 콜백은 `fn(value, ref)` —
-- 두 번째 인자가 Ref 자신(= `Epoch`). `:Wait`는 M8.
export type RefCallback<T> = (value: T?, ref: Ref<T>) -> ()
export type Ref<T> = {
Value: T?,
Revision: number,
Callbacks: { [RefCallback<T> | thread]: true }, -- 강한 해시맵 셋(+ M8 `:Wait` 대기자)
WeakCallbacks: { [RefCallback<T>]: true }, -- weak-key 테이블
Set: (self: Ref<T>, value: T?) -> Ref<T>,
Callback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
WeakCallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
Uncallback: (self: Ref<T>, fn: RefCallback<T>) -> Ref<T>,
}
export type Quad = { export type Quad = {
Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것
debug: boolean, debug: boolean,
New: () -> Quad, New: () -> Quad,
RunInit: (self: Quad, initFn: (Quad) -> any) -> (), RunInit: (self: Quad, initFn: (Quad) -> any) -> (),
AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P, AddPlugin: <Self, P>(self: Self, pluginFn: (Self) -> P) -> Self & P,
-- M2 공통 기반이 얹는 탑레벨 값(`ROADMAP.md` M2 `H-80`). `Source`/`Store`/
-- `Effect`/`Blocker`는 각 단위에서 추가된다.
Relate: () -> Relate,
Void: (...any) -> (),
Ref: <T>(default: T?) -> Ref<T>,
-- 생명주기 4종 — quad-base는 인터페이스(에러 스텁)만, 백엔드가 주입
-- (`.claude/base/lifecycle-pattern.md`). `canBound(v) == not canExecute(v)`.
bindLifetime: (inst: any, value: any) -> (),
unbindLifetime: (value: any) -> (),
canBound: (value: any) -> boolean,
canExecute: (value: any) -> boolean,
-- 브랜드 술어(`.claude/base/brand-plan.md`). 나머지(`isTag`/`isAttribute*`/
-- `isTween`/`isSlot`)는 그 타입의 마일스톤에서.
isEpoch: (x: any) -> boolean,
isSource: (x: any) -> boolean,
isState: (x: any) -> boolean,
isStore: (x: any) -> boolean,
isObserver: (x: any) -> boolean,
isEffect: (x: any) -> boolean,
isBlocker: (x: any) -> boolean,
isModifier: (x: any) -> boolean,
isRef: (x: any) -> boolean,
isPreRef: (x: any) -> boolean,
isPostRef: (x: any) -> boolean,
} }
--[[ --[[

View file

@ -1,15 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고). # 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고). smoke.* = M1 스모크, spec.* = 모듈 계약 테스트.
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
./scripts/relink.sh ./scripts/relink.sh
files=(quad-base/test/smoke.*.luau) files=(quad-base/test/smoke.*.luau quad-base/test/spec.*.luau)
if [ "${#files[@]}" -eq 0 ]; then if [ "${#files[@]}" -eq 0 ]; then
echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2 echo "no tests found (quad-base/test/{smoke,spec}.*.luau)" >&2
exit 1 exit 1
fi fi
fail=0 fail=0
# 타입 검사 — relink 뒤라 심볼릭 링크 때문에 조용히 통과하는 "거짓 클린"이 없다.
# smoke.*는 M1 임시 스모크라 제외(느슨하게 쓰였음) — src와 spec/mock만 strict로 본다.
echo "=== luau-analyze quad-base/src quad-base/test/spec.*.luau quad-base/test/mock.luau"
luau-analyze quad-base/src quad-base/test/spec.*.luau quad-base/test/mock.luau || fail=1
for f in "${files[@]}"; do for f in "${files[@]}"; do
echo "=== $f" echo "=== $f"
luau "$f" || fail=1 luau "$f" || fail=1