diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md index 14c222e..3c8b23a 100644 --- a/.claude/base/project-setup-plan.md +++ b/.claude/base/project-setup-plan.md @@ -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 토글이 미래에 생기면 이 절 전체가 불필요해짐 — 그때 다시 볼 것. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 6642f37..ed5cb82 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -430,7 +430,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 - **`Ref`에 공개 필드 `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`는 **부기**일 뿐, diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 6d22a48..ecdbc02 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -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`/`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 남은 의심 / 못 본 것 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 1adcc52..e4687d0 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2015,3 +2015,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs 이중 claim은 셋업 유무로 error(*"claim 은 slot 이랑 무관"* — 리뷰 전제 기각) / **`PlayerGui`는 공동 소유 객체라 claim 대상 아님**, 루트는 `ScreenGui`·`SurfaceGui` / `FrameParam` 원소 타입 파라미터. +- **`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) 둘 다 ①. ②/③ 없음. diff --git a/.claude/session/2026-08-28-03-m2-unit1-common-base.md b/.claude/session/2026-08-28-03-m2-unit1-common-base.md new file mode 100644 index 0000000..dd2ecaa --- /dev/null +++ b/.claude/session/2026-08-28-03-m2-unit1-common-base.md @@ -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`·`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를 보라". diff --git a/ROADMAP.md b/ROADMAP.md index dfc1c9d..83f6dbf 100644 --- a/ROADMAP.md +++ b/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`를 요구하지만 `--!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 구현이고 미주입 슬롯은 diff --git a/quad-base/src/Brand.luau b/quad-base/src/Brand.luau new file mode 100644 index 0000000..c716d0f --- /dev/null +++ b/quad-base/src/Brand.luau @@ -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, +} diff --git a/quad-base/src/LifetimeHandle.luau b/quad-base/src/LifetimeHandle.luau new file mode 100644 index 0000000..38160a5 --- /dev/null +++ b/quad-base/src/LifetimeHandle.luau @@ -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 diff --git a/quad-base/src/Ref.luau b/quad-base/src/Ref.luau new file mode 100644 index 0000000..147253e --- /dev/null +++ b/quad-base/src/Ref.luau @@ -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 = QuadTypes.Ref +type Callback = QuadTypes.RefCallback + +local RefBrand = Brand.RefBrand +local EpochBrand = Brand.EpochBrand + +local WEAK_KEY_MT = { __mode = "k" } + +local RefImpl = {} +RefImpl.__index = RefImpl + +function RefImpl.Set(self: Ref, value: T?): Ref + 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)(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(self: Ref, fn: Callback): Ref + self.WeakCallbacks[fn] = true + fn(self.Value, self) + return self +end + +function RefImpl.Callback(self: Ref, fn: Callback): Ref + 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(self: Ref, fn: Callback): Ref + self.Callbacks[fn] = nil + self.WeakCallbacks[fn] = nil + return self +end + +local function Ref(default: T?): Ref + 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 +end + +return Ref diff --git a/quad-base/src/Relate.luau b/quad-base/src/Relate.luau index 484ca6b..e753680 100644 --- a/quad-base/src/Relate.luau +++ b/quad-base/src/Relate.luau @@ -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 }?, diff --git a/quad-base/src/Void.luau b/quad-base/src/Void.luau new file mode 100644 index 0000000..32876af --- /dev/null +++ b/quad-base/src/Void.luau @@ -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 diff --git a/quad-base/src/init.luau b/quad-base/src/init.luau index c3d60a9..ef0bbed 100644 --- a/quad-base/src/init.luau +++ b/quad-base/src/init.luau @@ -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 diff --git a/quad-base/test/mock.luau b/quad-base/test/mock.luau index a27945a..3904c80 100644 --- a/quad-base/test/mock.luau +++ b/quad-base/test/mock.luau @@ -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, } diff --git a/quad-base/test/spec.brand.luau b/quad-base/test/spec.brand.luau new file mode 100644 index 0000000..24aa890 --- /dev/null +++ b/quad-base/test/spec.brand.luau @@ -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 ===") diff --git a/quad-base/test/spec.lifetime.luau b/quad-base/test/spec.lifetime.luau new file mode 100644 index 0000000..f86e544 --- /dev/null +++ b/quad-base/test/spec.lifetime.luau @@ -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 ===") diff --git a/quad-base/test/spec.ref.luau b/quad-base/test/spec.ref.luau new file mode 100644 index 0000000..c29213f --- /dev/null +++ b/quad-base/test/spec.ref.luau @@ -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 ===") diff --git a/quad-base/test/spec.relate.luau b/quad-base/test/spec.relate.luau new file mode 100644 index 0000000..4518db3 --- /dev/null +++ b/quad-base/test/spec.relate.luau @@ -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 ===") diff --git a/quad-base/test/spec.void.luau b/quad-base/test/spec.void.luau new file mode 100644 index 0000000..6901dcc --- /dev/null +++ b/quad-base/test/spec.void.luau @@ -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 ===") diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 706424a..8608239 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -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` 최소형(`.claude/base/ref-plan.md`, M2 `H-128`). 콜백은 `fn(value, ref)` — +-- 두 번째 인자가 Ref 자신(= `Epoch`). `:Wait`는 M8. +export type RefCallback = (value: T?, ref: Ref) -> () +export type Ref = { + Value: T?, + Revision: number, + Callbacks: { [RefCallback | thread]: true }, -- 강한 해시맵 셋(+ M8 `:Wait` 대기자) + WeakCallbacks: { [RefCallback]: true }, -- weak-key 테이블 + Set: (self: Ref, value: T?) -> Ref, + Callback: (self: Ref, fn: RefCallback) -> Ref, + WeakCallback: (self: Ref, fn: RefCallback) -> Ref, + Uncallback: (self: Ref, fn: RefCallback) -> Ref, +} + export type Quad = { Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 debug: boolean, New: () -> Quad, RunInit: (self: Quad, initFn: (Quad) -> any) -> (), AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P, + + -- M2 공통 기반이 얹는 탑레벨 값(`ROADMAP.md` M2 `H-80`). `Source`/`Store`/ + -- `Effect`/`Blocker`는 각 단위에서 추가된다. + Relate: () -> Relate, + Void: (...any) -> (), + Ref: (default: T?) -> Ref, + + -- 생명주기 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, } --[[ diff --git a/scripts/test.sh b/scripts/test.sh index 4986164..053e98a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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