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:
parent
92721d768f
commit
d9898d6629
20 changed files with 1214 additions and 19 deletions
|
|
@ -248,7 +248,14 @@ Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했
|
|||
(8라운드에서 실제로 실행해 확인). 지금 규칙은 하나다 — **테스트는
|
||||
`./scripts/test.sh`로 돌린다**(그 스크립트가 `relink.sh`를 먼저 부른다).
|
||||
그냥 `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 토글이 미래에 생기면 이 절 전체가 불필요해짐 —
|
||||
그때 다시 볼 것.
|
||||
|
||||
|
|
|
|||
|
|
@ -430,7 +430,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
|||
- **`Ref<T>`에 공개 필드 `Revision: number`가 생긴다.** `:Set()`이
|
||||
`Source`와 **같은 한 줄**로 갱신한다 — `self.Revision = bit32.bnot(-self.Revision)`
|
||||
(그 문서 §2의 랩어라운드 감소). 공개여야 구조적 만족이 타입 레벨에서
|
||||
성립한다.
|
||||
성립한다. **[2026-08-28 M2 첫 단위, `H-166`] 초기값은 `0`** — 어느 문서도
|
||||
적지 않았던 것을 구현이 정했다. 계약은 `==`/`~=`뿐이라 값 자체는 무관하고,
|
||||
첫 `:Set`이 `4294967295`로 감는 것까지 테스트(`spec.ref.luau`)가 고정한다.
|
||||
- **`EpochBrand:register(self)`** — `Source`가 `SourceBrand`이면서 동시에
|
||||
`EpochBrand`인 것과 같은 다중 태깅(`base/brand-plan.md`).
|
||||
- **`.Callbacks`(푸시 경로)는 그대로다** — `Epoch`는 **부기**일 뿐,
|
||||
|
|
|
|||
|
|
@ -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) 어떻게
|
||||
처리했는가(①이면 커밋 해시).)
|
||||
|
||||
### 단위 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 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
|
||||
|
||||
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|
||||
|
|
@ -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 남은 의심 / 못 본 것
|
||||
|
|
|
|||
|
|
@ -2015,3 +2015,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
|
|||
이중 claim은 셋업 유무로 error(*"claim 은 slot 이랑 무관"* — 리뷰 전제 기각) /
|
||||
**`PlayerGui`는 공동 소유 객체라 claim 대상 아님**, 루트는 `ScreenGui`·`SurfaceGui` /
|
||||
`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) 둘 다 ①. ②/③ 없음.
|
||||
|
|
|
|||
39
.claude/session/2026-08-28-03-m2-unit1-common-base.md
Normal file
39
.claude/session/2026-08-28-03-m2-unit1-common-base.md
Normal 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를 보라".
|
||||
14
ROADMAP.md
14
ROADMAP.md
|
|
@ -272,7 +272,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
> 여기 있는 것 전부 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)`,
|
||||
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
|
||||
옛 공유 레지스트리 + `Brand.get(x) -> tag`는
|
||||
|
|
@ -302,7 +302,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
않는다"*로 확정 — `isNone`은 `None.luau`(M3) 쪽에 산다. 이 마일스톤
|
||||
분리로 처음 눈에 띈 잔재를 **[2026-08-24]** 정정한 것) —
|
||||
`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`이
|
||||
쓴다) — 이 체크박스의 남은 일은 `base/relate-plan.md` 대조와 테스트뿐.
|
||||
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
|
||||
|
|
@ -310,7 +310,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
생성(첫 `Set` 호출 시에만), `WeakMap`은 공유 메타테이블(`{__mode="v"}`)
|
||||
재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를
|
||||
대체(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)` 탑레벨
|
||||
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
|
||||
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
|
||||
|
|
@ -347,7 +347,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M2 체크박스
|
||||
참고**), 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)` →
|
||||
`d:WeakCallback(onRefFire)` → `self._epochs:Sync(d)`)가 **M2 안에서
|
||||
실제로 돌려면** 필요한 표면만: `.Value`/`.Revision`/`:Set(value)`/
|
||||
|
|
@ -363,7 +363,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
**[2026-08-27 `/code-review`]** 아래 `H-80` 탑레벨 목록의 규칙(*"이
|
||||
마일스톤이 얹는 탑레벨 값 전부"*)대로 **`quad-types`의 `Quad`에 `Ref`
|
||||
생성자 필드도 여기서** 추가한다 — 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` 탑레벨 목록에만 있고
|
||||
여기 체크박스가 없어 "개수·목록은 소스 하나" 규약에 어긋나던 것을 M2
|
||||
착수 규약 커밋에서 신설.
|
||||
|
|
@ -629,7 +629,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
|
||||
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
|
||||
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 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`에 **이 마일스톤이 얹는 탑레벨 값 전부** 추가 —
|
||||
`Source` / `Store` / `Effect` / `Blocker` / `Relate` / **`Void`**(단일 no-op 함수 export — no-op 클로저를 돌려주는 자리는 새 클로저 대신 이것, **[2026-08-28 `H-162`]**) / **`Ref`**(최소형,
|
||||
2026-08-27 `H-128`) /
|
||||
|
|
@ -675,7 +675,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
(영어 메시지). 타입은 `Source<T>`를 요구하지만 `--!nocheck`/동적
|
||||
코드가 raw 값을 넘기면 지금 스케치(`table.clone`)는 조용히 받고 첫
|
||||
`: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`. 안 하면
|
||||
**아래 "mock 대상 테스트"가 전파 루프를 한 번도 못 돈다**(루프가 매
|
||||
발화마다 `canExecute`를 부르는데 그건 M8 구현이고 미주입 슬롯은
|
||||
|
|
|
|||
136
quad-base/src/Brand.luau
Normal file
136
quad-base/src/Brand.luau
Normal 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,
|
||||
}
|
||||
44
quad-base/src/LifetimeHandle.luau
Normal file
44
quad-base/src/LifetimeHandle.luau
Normal 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
113
quad-base/src/Ref.luau
Normal 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
|
||||
|
|
@ -3,12 +3,10 @@
|
|||
`.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?,
|
||||
}
|
||||
local QuadTypes = require("../roblox_packages/quad_types")
|
||||
|
||||
-- 타입의 소스는 `quad-types`(`Quad.Relate` 필드가 같은 타입을 써야 함) — 여기선 재export만.
|
||||
export type Relate = QuadTypes.Relate
|
||||
|
||||
type Bucket = {
|
||||
StrongMap: { [any]: any }?,
|
||||
|
|
|
|||
14
quad-base/src/Void.luau
Normal file
14
quad-base/src/Void.luau
Normal 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
|
||||
|
|
@ -14,8 +14,12 @@
|
|||
]]
|
||||
|
||||
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 InitDebug = require("@self/Debug")
|
||||
local InitLifetimeHandle = require("@self/LifetimeHandle")
|
||||
|
||||
type Quad = QuadTypes.Quad
|
||||
|
||||
|
|
@ -27,6 +31,22 @@ local function New(): Quad
|
|||
local module = {
|
||||
New = New,
|
||||
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
|
||||
|
||||
function module.RunInit(self, initFn)
|
||||
|
|
@ -46,6 +66,7 @@ local function New(): Quad
|
|||
end
|
||||
|
||||
module:RunInit(InitDebug)
|
||||
module:RunInit(InitLifetimeHandle) -- 생명주기 4종 에러 스텁 — 백엔드가 덮어씀
|
||||
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
||||
|
||||
return module
|
||||
|
|
|
|||
|
|
@ -10,11 +10,25 @@
|
|||
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
|
||||
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 = {
|
||||
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
|
||||
Fire: (self: Signal, ...any) -> (),
|
||||
DisconnectAll: (self: Signal) -> (),
|
||||
}
|
||||
|
||||
export type Connection = {
|
||||
|
|
@ -46,6 +60,15 @@ function Signal:Connect(fn: (...any) -> ()): Connection
|
|||
return (conn :: any) :: Connection
|
||||
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)
|
||||
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
|
||||
local snapshot = table.clone((self :: any).connections)
|
||||
|
|
@ -205,6 +228,12 @@ end
|
|||
function methods.Destroy(inst: any)
|
||||
local data = getData(inst)
|
||||
data.destroying:Fire()
|
||||
-- Roblox처럼 이 인스턴스의 모든 연결을 끊는다(Destroying 포함) —
|
||||
-- gcconn(`ClassName` 변경 시그널)의 `.Connected`가 생존 판정의 근거.
|
||||
data.destroying:DisconnectAll()
|
||||
for _, sig in data.changed do
|
||||
sig:DisconnectAll()
|
||||
end
|
||||
if data.parent then
|
||||
local siblings = data.parent.children
|
||||
local i = table.find(siblings, data)
|
||||
|
|
@ -220,8 +249,121 @@ local function isMockInstance(value: any): boolean
|
|||
return dataOf[value] ~= nil
|
||||
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 {
|
||||
Instance = Instance,
|
||||
newSignal = newSignal,
|
||||
isMockInstance = isMockInstance,
|
||||
installLifetime = installLifetime,
|
||||
}
|
||||
|
|
|
|||
109
quad-base/test/spec.brand.luau
Normal file
109
quad-base/test/spec.brand.luau
Normal 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 ===")
|
||||
139
quad-base/test/spec.lifetime.luau
Normal file
139
quad-base/test/spec.lifetime.luau
Normal 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 ===")
|
||||
206
quad-base/test/spec.ref.luau
Normal file
206
quad-base/test/spec.ref.luau
Normal 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 ===")
|
||||
95
quad-base/test/spec.relate.luau
Normal file
95
quad-base/test/spec.relate.luau
Normal 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 ===")
|
||||
22
quad-base/test/spec.void.luau
Normal file
22
quad-base/test/spec.void.luau
Normal 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 ===")
|
||||
|
|
@ -17,12 +17,66 @@
|
|||
|
||||
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 = {
|
||||
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,
|
||||
|
||||
-- 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,
|
||||
}
|
||||
|
||||
--[[
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
# 스모크 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고).
|
||||
# 테스트 — 리링크를 먼저 돌린다(scripts/relink.sh 주석 참고). smoke.* = M1 스모크, spec.* = 모듈 계약 테스트.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
cd "$(dirname "$0")/.."
|
||||
./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
|
||||
echo "no smoke tests found (quad-base/test/smoke.*.luau)" >&2
|
||||
echo "no tests found (quad-base/test/{smoke,spec}.*.luau)" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
echo "=== $f"
|
||||
luau "$f" || fail=1
|
||||
|
|
|
|||
Loading…
Reference in a new issue