feat: 단위 2 /code-review high 반영 — ① 여섯(H-199/H-201/H-202/H-204/H-206/H-207) + ② 넷(H-198/H-200/H-203/H-205) §4 합류
체크포인트 재개 첫 항목. 8각도 후보 22 → 검증 생존 10(정확성 8건 실측 재현). - H-199: With/Compute nil dep 조용한 탈락 → collectDeps + "dep #N is nil"(level 3) - H-201: store:Of 이름 문자열 검증(할당 전, level 2) - H-202: Compute fn 함수 검증(형제 표면과 같은 급) - H-204: Store defaults 평범한 테이블 검증(clone 전) - H-206: implsOf 세 벌 → ImplRegistry.luau 신설(잎, 내부 전용) - H-207: Source.Set이 Impl.Emit 직접 호출로 위임(꼬리 한 벌) - ② 넷은 코드에 TODO 마커만, 문항은 round11.md §4(마커 10 = 문항 10, 1:1) - H-198(🔴 닫힌 게이트 너머 fn 도중 Set → 영구 stale)은 state-epoch-plan §4 확정 의사코드 자체의 구멍 — 그 절에 ⚠️ 결정 대기 배너, README 색인 갱신 - architecture.md: ImplRegistry 소스 트리 등재, error 계약 "도착지가 계약" 명료화 - spec.state 13절·spec.store 2·3절 신설/확장, 감사 2라운드(6건 → 니트 2) 반영 Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
This commit is contained in:
parent
3cb45646d4
commit
dc81dd26db
16 changed files with 363 additions and 61 deletions
|
|
@ -73,7 +73,7 @@
|
||||||
| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. |
|
| `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 **⚠️ [2026-08-26, 8라운드 `H-120`] 실제로는 `Callback(guard(fn))`이다** — `Ref` 콜백의 *"등록 즉시 1회, 값이 nil이어도"* 계약 때문에 맨 `fn`을 걸면 **생성 시점에 `fn(nil)`이 먼저 불려** `inst`를 바로 쓰는 콜백이 pre-pass에 닿기도 전에 죽는다. `guard(fn) = function(v) if v ~= nil then fn(v) end end`. `Ref` 계약은 안 건드리고 슈가 쪽에서 막는다. |
|
||||||
| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 — **[2026-08-29 완료]** `State.luau`의 `:Gate`/`GateNode` + `Blocker.luau`(단위 4) |
|
| `gate-plan.md` | **[2026-08-21 신설, 같은 날 표면 확정]** `state:Gate(setup)` — 상류 emit을 가로채 내려보낼지 정책이 정하는 **`GateNode`**(`ComputeNode`와 같은 층위)를 만드는 State 메소드. 탑레벨 `Gate(...)` 프리미티브는 **안 만든다**(처음 방향에서 뒤집힘) — `Blocker`가 `state:Apply(blocker)` 안에서 이 배선을 쓰고, `Debounce`/`Throttle`은 `state:Apply(...)` 팩토리가 내부에서 `:Gate`를 부른다. `Get()`엔 영향 없음(통지만 막음)까지 확정. **[2026-08-24 6라운드 `H-33`/`H-49`] 열린 항목이 전부 닫혔다** — 재진입은 2026-08-21에 이미 닫혀 있었고, 마지막 남은 생명주기(=`Gate`에 `Flush`/`Cancel` 표면을 둘지)는 **안 두는 것**으로 확정: `blocker:Policy(emit)`을 노출하고 `Debounce`/`Throttle`이 자기 `Blocker`를 조종하는 정책이 된다(정책 합성은 손으로 중첩). (**[2026-08-22]** 미결이던 "마일스톤 범위 — `Gate`만 vs `Blocker`까지"는 **둘 다 같은 마일스톤**으로 해소.) 구현은 M2 — **[2026-08-29 완료]** `State.luau`의 `:Gate`/`GateNode` + `Blocker.luau`(단위 4) |
|
||||||
| `claim-plan.md` | **[2026-08-28 신설·같은 날 확정, `research/`에서 승격]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper.<Class>(key) {…}) -> inst` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 `drive` 위의 한 겹(DFS 이름 해석 → bottom-up `drive`)으로 닫는다. 계약은 claim-once·own-all(부기 대상 자식 전부 매핑, 디스크립터 순서가 정본, 이름 중복·부재 UB + debug 검사, 같은 `inst` 이중 claim error, 다중 quad UB). 루트 키는 센티널 `D.Mapper.Root`, props 타입은 `D.<Class>`와 `type <Class>Param` 공유, `Claim`은 타입 인자 없음(추론). **루트의 `.Parent`는 부기 밖이라 밖에서 대입 허용**(`H-146` 예외를 좁혀 복원 — 여러 스크립트가 한 `PlayerGui`를 쓰는 흔한 경우의 답, PlayerGui 직하 Slot 공유는 중간 모듈). `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님). 프로바이더 op `nativeFindChild` + **`nativeClaim`**(gcconn/gchold 셋업의 유일한 자리 — `New`도 호출; 이중 claim은 그 셋업 유무로 error). **`PlayerGui`류 공동 소유 컨테이너는 claim 대상 아님**(루트는 `ScreenGui`·`SurfaceGui`). `type <Class>Param<E>` 원소 타입 파라미터. **M5 스코프**(`H-161`) |
|
| `claim-plan.md` | **[2026-08-28 신설·같은 날 확정, `research/`에서 승격]** 이미 있는 트리(PlayerGui, `Clone()` 사본, Studio에서 만든 GUI)를 quad가 **소유**하는 `Claim(inst, D.Mapper.<Class>(key) {…}) -> inst` — 루트가 Slot일 수 없던 공백(`H-146`/`H-148`)을 `drive` 위의 한 겹(DFS 이름 해석 → bottom-up `drive`)으로 닫는다. 계약은 claim-once·own-all(부기 대상 자식 전부 매핑, 디스크립터 순서가 정본, 이름 중복·부재 UB + debug 검사, 같은 `inst` 이중 claim error, 다중 quad UB). 루트 키는 센티널 `D.Mapper.Root`, props 타입은 `D.<Class>`와 `type <Class>Param` 공유, `Claim`은 타입 인자 없음(추론). **루트의 `.Parent`는 부기 밖이라 밖에서 대입 허용**(`H-146` 예외를 좁혀 복원 — 여러 스크립트가 한 `PlayerGui`를 쓰는 흔한 경우의 답, PlayerGui 직하 Slot 공유는 중간 모듈). `archive/existing-instance-bind-rejected.md`와 다름(재바인드 아님). 프로바이더 op `nativeFindChild` + **`nativeClaim`**(gcconn/gchold 셋업의 유일한 자리 — `New`도 호출; 이중 claim은 그 셋업 유무로 error). **`PlayerGui`류 공동 소유 컨테이너는 claim 대상 아님**(루트는 `ScreenGui`·`SurfaceGui`). `type <Class>Param<E>` 원소 타입 파라미터. **M5 스코프**(`H-161`) |
|
||||||
| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **열린 설계 항목 없음.** **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) |
|
| `state-epoch-plan.md` | **[2026-08-21 신설, 같은 날 채택 확정·`Epoch` 일반화까지 반영]** State의 재계산/전파 판정을 `invalid` 플래그가 아니라 **`Epoch` 리비전 비교**로 한다 — DFS 전파 도중 `Get()`이 섞인 값을 캐시하던 glitch(실재)를 없애는 **정확성** 결정. `type Epoch = { Revision: number }`(그 자체로 키가 되는 unique 테이블, `Source`가 구조적으로 만족), 부기는 재사용 가능한 **`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet = {[Epoch]: true}`로 **배열이 아니라 집합** — 게이트 배치가 그 모양이다)으로 떼어냈고, State는 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값 유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고 **출처(`Epoch`나 그 집합)만** 싣고, 순회는 **캐시 카운터가 같을 때만** 돌며 값만 앞당기고(**[2026-08-25 `H-85`]** 옛 `rawInvalid` 불린은 `cacheTargetCount`/`cacheCurrCount` 쌍으로 교체 — 재계산 *도중* 도착한 무효화를 꼬리가 지우던 것과, `fn`이 던졌을 때 계산된 적 없는 캐시를 유효하다고 확신하던 것 둘을 같이 닫음) 통지는 상류 emit을 기다린다. 중복 *통지*도 같이 접히므로 `source-state-plan.md`의 옛 "항상 전파 / 중복 통지는 안 접음" 서술이 역전됨(`archive/always-propagate-no-dedup-superseded.md`). ⚠️ 2026-08-14에 폐기된 `invalid` 기반 dedup과는 다른 장치 — 그 금지는 유효. 리비전 갱신은 **`bit32.bnot(-rev)`** 한 번(사용자 확정 — 랩어라운드 **감소**를 단일 FASTCALL로, hot path라 값을 uint32에 가두고 `2^53` 포화 자체를 없앰. **[2026-08-22 정정]** 한때 `band(rev + 1, mask)`로 잘못 옮겨져 있었음). **[2026-08-31 `H-198`]** §4 "재계산이 끝나면"의 라이브 리비전 "전부 갱신"에 실측 구멍 발견(닫힌 게이트 너머 `fn` 도중 `Set` → 영구 stale — `qa-request/pre-implementation-handtrace-round11.md` §4 회신 대기, 그 절에 ⚠️ 배너). 그 외 열린 설계 항목 없음. **[2026-08-24 재확정]** 구현 마일스톤은 전부 **M2**다 — 2026-08-22엔 `GateNode`가 디스패치 쪽에 있어 `EpochMap.luau`/`Epoch` 인터페이스만 갈려 있었으나, 마일스톤 순서 교체로 그 분리가 없어졌다 — **[2026-08-28 완료]** `EpochMap.luau`/`State.luau`(단위 2) |
|
||||||
|
|
||||||
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)
|
## `reference/` — 온디맨드 참고 자료 (2026-08-07 신설)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ quad/
|
||||||
│ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require
|
│ ├── Void.luau # **[2026-08-28 `H-162`]** `return function() end` 한 줄 — 단일 no-op. 의존 없는 잎(`None`/`Brand`/`Relate`와 같은 급), `Dispatch/*`·핸들러·최상위 `init.luau`가 require
|
||||||
│ ├── Brand.luau # **[2026-08-28 M2 첫 단위]** `Brand()` 생성자 + **브랜드 인스턴스 전부**(`EpochBrand`를 `Source`/`Ref`/`GateNode`가 공유하므로 타입 모듈마다 두면 순환 require) + `is*` 술어(타입이 생길 때 그 술어를 여기 추가, 최상위 `init.luau`가 재export). 의존 없는 잎(`base/brand-plan.md`)
|
│ ├── Brand.luau # **[2026-08-28 M2 첫 단위]** `Brand()` 생성자 + **브랜드 인스턴스 전부**(`EpochBrand`를 `Source`/`Ref`/`GateNode`가 공유하므로 타입 모듈마다 두면 순환 require) + `is*` 술어(타입이 생길 때 그 술어를 여기 추가, 최상위 `init.luau`가 재export). 의존 없는 잎(`base/brand-plan.md`)
|
||||||
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
|
│ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체
|
||||||
|
│ ├── ImplRegistry.luau # **[2026-08-31 `H-206`]** 인스턴스별 임플 저장 접근 `implsOf(module)`(`module._impl`, `H-181`) 한 벌 — State/Observer/Effect에 verbatim 세 벌이던 것을 잎으로 추출(순수 데이터 접근, 내부 전용 — `init.luau` 재export 없음)
|
||||||
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만) — **[2026-08-28 M2 첫 단위]** `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종을 설치하고 백엔드가 덮어쓴다, `Relate`는 안 쓴다(그건 아래 quad-roblox 실 구현 몫 — `base/lifecycle-pattern.md`)
|
│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만) — **[2026-08-28 M2 첫 단위]** `InitLifetimeHandle(module)`이 모듈 인스턴스에 영어 `level 2` 에러 스텁 4종을 설치하고 백엔드가 덮어쓴다, `Relate`는 안 쓴다(그건 아래 quad-roblox 실 구현 몫 — `base/lifecycle-pattern.md`)
|
||||||
│ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
|
│ ├── Ref.luau # 범용 값 박스(.Value/.Revision 읽기 + :Set()/:WeakCallback()/:Callback()/:Uncallback()/:Wait(); `Epoch`를 만족 — `base/ref-plan.md`. **[2026-08-27 `H-128`]** `:Wait`·핸들러 뺀 최소형은 M2 공통 기반), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음
|
||||||
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
|
│ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리)
|
||||||
|
|
@ -339,7 +340,10 @@ quad가 던지는 error 자리는 약 29곳이고(`base/` 전수), **쓰기 전
|
||||||
```
|
```
|
||||||
|
|
||||||
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
|
내부 불변식 위반은 곧 **quad 자신의 버그**라, 호출부가 아니라 터진 자리를
|
||||||
가리켜야 리포트가 쓸모 있다.
|
가리켜야 리포트가 쓸모 있다. **[2026-08-31 명료화]** 표의 `2`는 리터럴이
|
||||||
|
아니라 **"사용자 호출부를 가리킨다"의 기본형**이다 — 검증이 내부 헬퍼
|
||||||
|
프레임을 하나 거치면(`newNode`의 dep 검증, `collectDeps`의 nil 검증) 같은
|
||||||
|
뜻을 지키기 위해 `3`을 쓴다. 프레임 수가 아니라 도착지가 계약이다.
|
||||||
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
|
- **⭐ 메시지는 영어로 통일한다**(**사용자 확정**, 2026-08-25). 지금
|
||||||
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
|
코퍼스는 영어 6 / 한국어 약 23으로 이미 갈려 있고, 공개 표면인데
|
||||||
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은
|
정해진 적이 없었다. `.claude/conventions.md`의 *"사용자가 보게 될 것은
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,14 @@ if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
|
||||||
|
|
||||||
### 재계산이 끝나면
|
### 재계산이 끝나면
|
||||||
|
|
||||||
|
> **⚠️ [2026-08-31, `H-198` 결정 대기]** 이 절의 "전부 갱신"(라이브 리비전)에
|
||||||
|
> 실측 구멍이 발견됐다 — `fn` **도중** 닫힌 게이트 상류에서 난 `Set`은 emit이
|
||||||
|
> 유보돼 카운터 쌍(`H-85`)이 안 움직이는데, 꼬리의 `_track`이 라이브 리비전을
|
||||||
|
> 찍어 "본 것"으로 기록하므로 나중 flush가 `_invalidate` 없이 통지만 하고
|
||||||
|
> 캐시가 영구 stale이 된다(재현 실측). 문항과 선택지는
|
||||||
|
> `qa-request/pre-implementation-handtrace-round11.md` §4의 `H-198` — 회신
|
||||||
|
> 전까지 이 절을 무결한 확정으로 읽지 말 것(코드는 이 절 그대로 + `TODO(H-198)`).
|
||||||
|
|
||||||
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
|
- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`valueEpochMap`은 자기가 읽은 상류
|
||||||
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
|
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
|
||||||
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
|
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,16 @@
|
||||||
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)|
|
| `H-195` | ① | 3 | 🟢 | `effect-plan.md` "의사코드 — 생성자" 절의 `_consumeCleanup` 머리 주석 *"`_cleanup`의 유무가 곧 '설치돼 있는가'가 된다"*가 바로 아래 ⚠️ 문단(*"`_cleanup`의 유무로 판정하면 안 된다"*)·코드 주석과 정면 충돌 | ✅ 반영(2026-08-29, 메인 세션)|
|
||||||
| `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)|
|
| `H-196` | ① | 3 | 🟢 | `source-state-plan.md` "`state:Observer(fn)`" 절(*"owning leaf가 이미 죽었으면 no-op"*)·"Slot 생존 확인" 절(*"거짓이면 그냥 no-op"*)이 `H-159` 이전 서술 — 지금은 홀드 뒤 재바인드 시 1회 | ✅ 반영(2026-08-29, 메인 세션)|
|
||||||
| `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)|
|
| `H-197` | ① | 3·4 | 🟢 | `spec.init.luau` 1이 `Effect`/`Blocker`/`onDestroying`의 존재를 안 본다 — 그 파일 헤더가 *"이 런타임 확인이 유일한 가드"*라 하는 `H-80` 드리프트 가드에 단위 3·4 값이 빠짐 | ✅ 반영(2026-08-29, 메인 세션)|
|
||||||
|
| `H-198` | **②** | 2 | 🔴 | `_recompute` 꼬리의 `dep:_track`이 **라이브** 리비전을 찍어서, `fn` 도중 닫힌 게이트 상류에서 난 `Set`을 "본 것"으로 오인 — 카운터 쌍은 게이트가 `_receive`를 삼켜 안 움직이고 `Refresh`도 눈멀어, 나중 flush가 `_invalidate` 없이 하류로 전달(영구 stale 캐시, 실측 재현). §4 확정 의사코드 자체의 구멍 | §4 대기 (`-- TODO(H-198)`) |
|
||||||
|
| `H-199` | ① | 2 | 🟡 | `With`/`Compute`의 vararg 수집(`deps[#deps + 1]`)이 nil dep을 조용히 버림 — 중간 nil은 뒤 dep들을 왼쪽으로 밀어 `fn`의 positional lazy 인자가 엉뚱한 자리에 감(실측) | ✅ `collectDeps` 공유 + `dep #N is nil` error(level 3), `spec.state` 13 |
|
||||||
|
| `H-200` | **②** | 4 | 🟡 | `Impl.Gate`의 detach(`H-188`)는 "비함수 반환" 경로만 — `setup`이 **던지면** 반쯤 만든 노드가 상류 `_subs`에 좀비로 남음(`_onUpstreamEmit = Void`라 크래시는 없지만 유보 집합을 영영 못 비움). `spec.gate` 1의 "아무것도 안 남긴다" 단언은 반환 경로만 검사 | §4 대기 (`-- TODO(H-200)`) |
|
||||||
|
| `H-201` | ① | 2 | 🟡 | `store:Of(nil)`이 RESERVED·rawget을 통과해 Source를 할당한 **뒤** `self[name] =`에서 Luau 내부 에러로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`이 숫자를 반환 | ✅ `type(name) ~= "string"` → error level 2, `spec.store` 3 |
|
||||||
|
| `H-202` | ① | 2 | 🟢 | `Compute`가 `fn`이 함수인지 안 봄(형제 표면 Gate/Observer/Apply는 전부 검증) — 첫 `Get`의 `_recompute` 안 내부 프레임에서 죽음 | ✅ level 2 검증(형제와 같은 급), `spec.state` 13 |
|
||||||
|
| `H-203` | **②** | 4 | 🟡 | `Off()`의 스냅샷 순회가 `IsBlocked`를 다시 안 읽음 — 첫 게이트 flush의 하류가 `b:On()`을 다시 켜도 남은 핸들이 전부 flush돼 `IsOn() == true`인데 통지가 새어나감(실측). 문서의 미지원 선언은 네스팅(`On`/`On`/`Off` 카운팅)만 다룸 | §4 대기 (`-- TODO(H-203)`) |
|
||||||
|
| `H-204` | ① | 2 | 🟢 | `Store(defaults)`가 `defaults`가 평범한 테이블인지 안 봄 — 비테이블은 `table.clone` 내부 프레임에서, 맨 Source는 내부 필드(`Revision`)를 지목하는 에러로 죽음 | ✅ 테이블·메타테이블 검증(level 2), `spec.store` 2 |
|
||||||
|
| `H-205` | **②** | 2 | 🟢 | `_recompute`의 Modifier 가드 `error(…, 2)`가 항상 `Impl.Get`의 자기 줄을 지목(유일한 호출자가 내부 두 줄) — `architecture.md` error 계약이 없애려던 바로 그 결과. lazy 체인이라 어떤 고정 level도 유저 코드에 못 닿음 | §4 대기 (`-- TODO(H-205)`) |
|
||||||
|
| `H-206` | ① | 2·3 | 🟢 | `implsOf` 헬퍼 + 4줄 ephemeron 근거 주석이 세 파일(`State`/`Observer`/`Effect`)에 verbatim 중복 — 저장 방식이 바뀌면 세 곳을 다 고쳐야 하고, 미묘하게 다른 네 번째 사본이 레지스트리를 조용히 가름 | ✅ `ImplRegistry.luau` 신설(순수 데이터 접근 — 공유 허용 범위) |
|
||||||
|
| `H-207` | ① | 2 | 🟢 | `Source.Set`이 `Emit`의 꼬리(리비전 범프 + `emitDown` + self 반환) 전체를 복제 — 한 파일 안에 같아야 하는 계약 두 벌 | ✅ `Set`이 `Impl.Emit(self)` 직접 호출로 위임(같은 타입·같은 임플 — 공유 허용 범위) |
|
||||||
|
|
||||||
## 상세
|
## 상세
|
||||||
|
|
||||||
|
|
@ -364,6 +374,114 @@
|
||||||
(`spec.init` 2가 `q.Effect`를 부르긴 하지만 그건 `H-181` GC 검사다).
|
(`spec.init` 2가 `q.Effect`를 부르긴 하지만 그건 `H-181` GC 검사다).
|
||||||
- **처리**: ✅ 반영. 1절에 세 줄.
|
- **처리**: ✅ 반영. 1절에 세 줄.
|
||||||
|
|
||||||
|
### 단위 2 — `/code-review high` (2026-08-31, 체크포인트 재개 첫 항목) — 10건 중 ① 여섯(`H-199`/`H-201`/`H-202`/`H-204`/`H-206`/`H-207`) 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`) §4
|
||||||
|
|
||||||
|
체크포인트의 "못 한 것 하나"였던 단위 2 파일 리뷰. 8개 각도 파인더가 후보 22건 →
|
||||||
|
dedup 10건, 정확성 8건 전부 실제 코드에 재현 스크립트로 확인(효율 배치는 "관측된
|
||||||
|
병목에만 구조" 원칙 위반으로, 약한 정리 항목들은 심각도 미달로 기각). 대상은 단위 2
|
||||||
|
파일 넷이었지만 교차 추적이 단위 4 표면(`Gate`/`Blocker`) 둘을 같이 잡았다.
|
||||||
|
|
||||||
|
### `H-198` 🔴 — 닫힌 게이트 너머의 `fn` 도중 `Set`이 영구 stale 캐시가 된다 (②)
|
||||||
|
|
||||||
|
- **어디서**: `State.luau` `_recompute`의 `dep:_track(self._valueEpochMap)` 꼬리 —
|
||||||
|
`state-epoch-plan.md` "재계산이 끝나면"(*"`valueEpochMap`은 자기가 읽은 상류 전부에
|
||||||
|
대해 갱신한다"*, 사용자 확정 *"단순히 전부 업데이트 하는건 맞아보입니다"*)을 그대로
|
||||||
|
옮긴 것.
|
||||||
|
- **무엇이**: `S = Source(1); g = S:Apply(blocker); blocker:On(); N = g:Compute(fn)`에서
|
||||||
|
`fn` 도중 `S:Set(99)`가 나면 — 게이트가 emit을 유보하므로 N의 `_receive`가 안 불려
|
||||||
|
카운터 쌍(`H-85`)이 안 움직이고, `_track`은 g의 **라이브** 맵(S@새 리비전)을 그대로
|
||||||
|
받아 "이미 본 것"으로 기록한다. `Get`의 `Refresh` 안전망도 눈멀고, 나중에
|
||||||
|
`blocker:Off()`가 flush하면 `Update`가 false(이미 최신)라 **`_invalidate` 없이 하류로
|
||||||
|
전달만** 한다 — `N:Get()`은 S가 다시 움직일 때까지 영원히 1(재현: *"N:Get() #2 after
|
||||||
|
flush = 1 (should be 99)"*), 그런데 하류 구독자들은 통지를 **받았다**. `H-85`가 닫은
|
||||||
|
것과 같은 실패 계열(재계산 도중 상류 쓰기 — 그 절이 "흔하다"고 명시)의 게이트 경유
|
||||||
|
변형이고, **확정 의사코드 자체의 구멍**이라 옮기기 실수가 아니다.
|
||||||
|
- **갈래**: §4 — 처방이 §4 확정 의사코드 수정이라 어느 모양이든 사용자 결정.
|
||||||
|
|
||||||
|
### `H-199` 🟡 — nil dep이 조용히 사라지고 뒤 dep들이 밀린다 (①)
|
||||||
|
|
||||||
|
- **어디서**: `State.luau` `With`/`Compute`의 수집 루프(`deps[#deps + 1] = (select(i, ...))`).
|
||||||
|
- **무엇이**: 실측 — `s:Compute(fn, typoNilVar)`가 에러 없이 dep 1개짜리 노드가 되어
|
||||||
|
의도한 dep 변경에 영영 반응 안 함; `s:Compute(fn, a, nil, b)`는 b가 nil 자리로 밀려
|
||||||
|
`fn`의 positional 인자가 어긋남. 진단 0으로 조용히 틀린 반응성.
|
||||||
|
- **문서가 답을 갖고 있나**: 있다 — `architecture.md` error 계약의 예시(*"dep #3 is not
|
||||||
|
a State/Source/Ref"*)와 `newNode`의 기존 dep 검증이 이 자리의 계약. nil만 수집 단계에서
|
||||||
|
빠져나가고 있었다.
|
||||||
|
- **처리**: 수집을 `collectDeps` 하나로(두 메소드 공유), nil이면 `dep #N is nil`
|
||||||
|
error(level 3 — `newNode`와 같은 번호 체계, self가 #1). `spec.state` 13.
|
||||||
|
|
||||||
|
### `H-200` 🟡 — `setup` throw 경로의 좀비 GateNode (②)
|
||||||
|
|
||||||
|
- **어디서**: `State.luau` `Impl.Gate` — `H-188`의 detach는 `setup` **반환값이 비함수**인
|
||||||
|
경로에만 있다.
|
||||||
|
- **무엇이**: `pcall(function() s:Gate(function() error("boom") end) end)` 뒤 `s._subs`에
|
||||||
|
노드가 남는다(재현). `_onUpstreamEmit = Void`(H-188)라 이후 `Set`이 크래시하진 않지만,
|
||||||
|
노드는 `_hold`로 s를 강참조한 채 매 emit을 받아 유보 집합을 쌓고 영영 flush 못 한다
|
||||||
|
(weak `_subs` 키라 GC가 언젠가 걷어갈 때까지). `spec.gate` 1의 *"실패한 Gate는 구독자
|
||||||
|
집합에 아무것도 안 남긴다"* 단언은 반환 경로만 돌려서 이 경로를 가려왔다.
|
||||||
|
- **문서 긴장**: `architecture.md` "예외 안전성 계약 — 감싸지 않는다"(예외 후 부기는
|
||||||
|
복구 안 됨)를 따르면 UB가 맞고 spec 문구만 좁히면 되지만, `H-188` 자신이 "우리가
|
||||||
|
통제하는 검증 경로"를 닫은 전례라 어느 쪽인지 사용자 결정 자리.
|
||||||
|
- **갈래**: §4.
|
||||||
|
|
||||||
|
### `H-201` 🟡 / `H-202` 🟢 / `H-204` 🟢 — 입력 검증 셋 (①)
|
||||||
|
|
||||||
|
- **`H-201`**: `store:Of(nil)`이 Source를 **할당한 뒤** `self[name] = src`에서 *"table
|
||||||
|
index is nil"*(내부 프레임)로 죽고, `Of(123)`은 조용히 성공해 `Names(): { string }`에
|
||||||
|
숫자가 섞임(재현). `Of`는 정확히 계산된 이름을 위한 문이라 이게 주 오용 경로.
|
||||||
|
`type(name) ~= "string"` → error level 2, 할당 전에. `spec.store` 3.
|
||||||
|
- **`H-202`**: `s:Compute(42)`가 노드를 만들고 첫 `Get`이 `State.luau` 내부 프레임
|
||||||
|
(*"attempt to call a number value"*)에서 죽음 — 형제 표면(Gate 249/Observer 269/
|
||||||
|
Apply 279)은 전부 자기 자리에서 검증. 같은 급으로 추가. `spec.state` 13.
|
||||||
|
- **`H-204`**: `Store(5)`는 `table.clone` 내부 프레임에서, `Store(Source(100))`(감싸는
|
||||||
|
테이블을 잊음)은 Source 내부 필드를 지목하는 에러(*"default for "Revision" is not a
|
||||||
|
Source"*)로, 보호 메타테이블은 clone에서 죽음(전부 재현). clone 전에 "테이블 + 메타테이블
|
||||||
|
없음" 검증(level 2). `spec.store` 2.
|
||||||
|
- 셋 다 `H-190`·단위 3 `fn` 검사와 같은 급(형제 검증과 동급의 입력 검증, 문서 무변경)이라 ①.
|
||||||
|
|
||||||
|
### `H-203` 🟡 — `Off()` 순회 중 재차단이 안 먹힌다 (②)
|
||||||
|
|
||||||
|
- **어디서**: `Blocker.luau` `runHandles`의 스냅샷 순회.
|
||||||
|
- **무엇이**: 한 Blocker에 게이트 둘 — 첫 게이트 flush의 하류 `_receive`가 `b:On()`을
|
||||||
|
다시 켜도, 스냅샷의 남은 핸들은 무조건 `handle(true)`로 flush된다(재현, 순서 무관) —
|
||||||
|
`b:IsOn() == true`인데 통지가 나간다. `Off` 63행 주석(*"IsBlocked = false first, so a
|
||||||
|
flush that re-enters sees the blocker open"*)은 여는 방향 재진입만 다룬다.
|
||||||
|
- **문서 긴장**: 단위 3·4 탐사자는 같은 관측을 `blocker-plan.md` "재진입(네스팅)" 미지원의
|
||||||
|
연장으로 보고 발견으로 안 세었다(§5 끝 "관측만"). 리뷰는 `IsOn` 불변식 위반으로 봤다 —
|
||||||
|
그 절의 미지원 선언은 카운팅 네스팅만 명시하므로 어느 쪽인지는 계약 결정.
|
||||||
|
- **갈래**: §4.
|
||||||
|
|
||||||
|
### `H-205` 🟢 — Modifier 가드의 level 2가 항상 quad 내부를 지목 (②)
|
||||||
|
|
||||||
|
- **어디서**: `State.luau` `_recompute`의 `isModifier(result)` 가드(`modifier-plan.md` 7번).
|
||||||
|
- **무엇이**: `_recompute`의 유일한 호출자가 `Impl.Get`의 내부 두 줄이라 level 2는 항상
|
||||||
|
`State.luau`의 `Get` 본문을 지목한다 — `architecture.md` error 계약이 사용자 입력을
|
||||||
|
level 2로 정한 근거(*옛 코드가 quad 내부만 지목*)를 정확히 재현하는 결과. lazy
|
||||||
|
체인(passThrough → 상류 `Get`)이라 **어떤 고정 level도 유저 코드에 못 닿는다** —
|
||||||
|
직접 `Get`일 때만 level 3이 유저 호출부를 가리킴.
|
||||||
|
- **갈래**: §4 — level 선택이 계약 결정.
|
||||||
|
|
||||||
|
### `H-206` 🟢 / `H-207` 🟢 — 중복 제거 둘 (①)
|
||||||
|
|
||||||
|
- **`H-206`**: `implsOf` + ephemeron 근거 주석 4줄이 `State`/`Observer`/`Effect`에
|
||||||
|
verbatim 세 벌 — 저장 방식 변경이 세 파일 수정이 되고, 미묘하게 다른 사본이 인스턴스별
|
||||||
|
임플 레지스트리를 조용히 가른다. **`ImplRegistry.luau` 신설**(잎, 의존 없음)로 한 벌 —
|
||||||
|
순수 데이터 접근이라 `conventions.md` 설계 원칙의 공유 허용 범위("공유해도 되는 건
|
||||||
|
데이터와 순수 술어")이고, 새 표면이 아니라 코드 배치(스텁 파일 배치 전례와 같은 급).
|
||||||
|
- **`H-207`**: `Source.Set`이 `Emit`의 꼬리(리비전 범프 + `emitDown` + `return self`)를
|
||||||
|
복제 — `Set`이 `return Impl.Emit(self)`로 위임(직접 호출 — 같은 타입·같은 임플 테이블,
|
||||||
|
서브타입 오버라이드가 없어 콜론 위임 함정(`H-144` 계열) 해당 없음). 기존 spec이 동작
|
||||||
|
고정(`spec.source` 2·3).
|
||||||
|
|
||||||
|
### 단위 2 리뷰가 이상 없다고 확인한 것 (§5 성격, 여기 둠)
|
||||||
|
|
||||||
|
`EpochMap` 5 연산 ↔ §3, 다이아몬드 dedup 규칙 1~3, `GateNode` swap/`Sync`/discard,
|
||||||
|
`bit32` 리비전 카운터, `RESERVED` ↔ `CheckReservedKeys` 동기, quad-types 표면 이름·인자
|
||||||
|
수 일치, `init.luau` `RunInit` 멱등 배선(마킹-후-실행, 순환 없음, 키 충돌 없음). 리뷰가
|
||||||
|
파일했다 기각한 것 중 기록할 것 하나 — Effect의 `Ref` dep 등록이 생성 중 `onRefFire`를
|
||||||
|
`from = ref`로 즉시 발화해 `_epochs:Update` + 홀드 `Rerun`을 태우는 모양은 "신선한
|
||||||
|
Effect는 `canExecute`가 항상 거짓"이라 실패를 못 만들어 발견으로 안 올림.
|
||||||
|
|
||||||
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
|
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
|
||||||
|
|
||||||
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|
| 문항 | 무엇 | 선택지 | 권고 | 권고 근거 | 옛 메커니즘 복원? |
|
||||||
|
|
@ -377,6 +495,10 @@
|
||||||
| **`H-185`** (`/code-review`, 단위 3) | cleanup 팩 vs 첫 반환만 | (a) 런타임이 반환 전부를 cleanup 목록으로 소진(역순) / (b) 타입을 `-> (() -> ())?`류로 좁힘(`H-95`가 기각한 모양 — 무반환 `fn`이 막힘) / (c) 문서·타입 주석에 "첫 반환만" 명시 | **(a)** | 타입이 이미 팩을 광고하고 다중 자원 정리는 자연스러운 용례 — 소진 순서만 정하면 됨(`_consumeCleanup` 한 곳) | 아니오 |
|
| **`H-185`** (`/code-review`, 단위 3) | cleanup 팩 vs 첫 반환만 | (a) 런타임이 반환 전부를 cleanup 목록으로 소진(역순) / (b) 타입을 `-> (() -> ())?`류로 좁힘(`H-95`가 기각한 모양 — 무반환 `fn`이 막힘) / (c) 문서·타입 주석에 "첫 반환만" 명시 | **(a)** | 타입이 이미 팩을 광고하고 다중 자원 정리는 자연스러운 용례 — 소진 순서만 정하면 됨(`_consumeCleanup` 한 곳) | 아니오 |
|
||||||
| **`H-186`** (`/code-review`, 단위 3) | 교차 인스턴스 dep | (a) `Effect`/`Observer`/`Compute` 생성 시 dep의 인스턴스가 다르면 `error(…, 2)`(임플이 `module`을 아니 판정 가능) / (b) UB 문서화 / (c) 지원(dep의 백엔드가 게이팅 — 지금 동작) | **(a)** | 지금 동작은 두 백엔드가 한 핸들을 나눠 판정해 에러 귀속이 틀린다; `architecture.md` 13번의 다중 `New()`는 "같은 인스턴스 안"이 전제 | 아니오 |
|
| **`H-186`** (`/code-review`, 단위 3) | 교차 인스턴스 dep | (a) `Effect`/`Observer`/`Compute` 생성 시 dep의 인스턴스가 다르면 `error(…, 2)`(임플이 `module`을 아니 판정 가능) / (b) UB 문서화 / (c) 지원(dep의 백엔드가 게이팅 — 지금 동작) | **(a)** | 지금 동작은 두 백엔드가 한 핸들을 나눠 판정해 에러 귀속이 틀린다; `architecture.md` 13번의 다중 `New()`는 "같은 인스턴스 안"이 전제 | 아니오 |
|
||||||
| **`H-187`** (`/code-review`, 단위 3·4) | 타입 별칭 이름 넷 | (a) 그대로 승인(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`) / (b) 인라인으로 풀어 이름을 없앰 / (c) 다른 이름 | **(a)** | 시그니처는 문서 그대로이고 이름이 없으면 `quad-types` 선언이 세 배로 길어진다 | 아니오 |
|
| **`H-187`** (`/code-review`, 단위 3·4) | 타입 별칭 이름 넷 | (a) 그대로 승인(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`) / (b) 인라인으로 풀어 이름을 없앰 / (c) 다른 이름 | **(a)** | 시그니처는 문서 그대로이고 이름이 없으면 `quad-types` 선언이 세 배로 길어진다 | 아니오 |
|
||||||
|
| **`H-198`** (`/code-review`, 단위 2) 🔴 | `fn` 도중 닫힌 게이트 상류의 `Set`이 영구 stale 캐시(§4 확정 의사코드의 구멍 — `_track`이 라이브 리비전을 찍어 안 본 변경을 "본 것"으로 기록) | (a) `_recompute`가 **`fn` 직전에 dep 리비전을 스냅샷**하고 성공 시 그 스냅샷을 찍음 — `fn` 도중 변경은 "안 본 것"으로 남아 flush가 규칙 1(재계산)로 떨어짐 / (b) UB 문서화(게이트 너머 재계산 중 `Set`) / (c) `Get` 시점마다 읽은 리비전을 그때그때 기록 | **(a)** | `H-85`의 해법(*"fn 직전 스냅샷"* — `gen` 카운터)과 정확히 같은 결을 에포크 맵에 적용하는 것. 문서의 "전부 갱신"이 값을 하는 자리(게이트 유보 중 앞당겨 읽기 → 통지만)는 스냅샷도 재계산 **시작 시점** 리비전이라 그대로 성립. (b)는 `H-85` 근거(*"재계산 중 상류 쓰기는 흔하다"*)와 정면 충돌, (c)는 읽기 훅이 필요한 새 구조 | 아니오 |
|
||||||
|
| **`H-200`** (`/code-review`, 단위 4) | `Gate` `setup`이 **던질 때** 반쯤 만든 노드가 `_subs`에 좀비로 남음 | (a) 예외 계약대로 UB 유지 + `spec.gate` 1 단언 문구를 반환 경로로 좁힘(코드 무변경) / (b) `newNode` 직후 `self._subs[node] = nil`로 떼고 `setup` 성공 후 재등록 — throw면 자연히 미등록(pcall 없음, 두 줄) / (c) `pcall`로 감싸 되감기(예외 계약 위반) | **(b)** | `H-188`이 이미 "우리가 통제하는 검증 경로는 닫는다"를 택한 전례고, (b)는 pcall 없이 spec의 단언(*"아무것도 안 남긴다"*)을 두 실패 경로 모두에서 참으로 만듦. 부수 효과는 setup 실행 중 emit을 게이트가 못 받는 창 하나인데 그 구간은 유보 집합이 항상 비어 있어 관측 불가 | 아니오 |
|
||||||
|
| **`H-203`** (`/code-review`, 단위 4) | `Off()` 순회 중 하류의 `b:On()` 재차단이 남은 핸들 flush를 못 막음(`IsOn() == true`인데 통지가 샘) | (a) 순회가 핸들마다 `IsBlocked`를 다시 읽고 참이면 중단 — 남은 배치는 유보 유지, 다음 `Off`가 마저 flush / (b) 스냅샷 의미론을 계약으로 문서화("한 번 시작된 `Off`는 끝까지 flush") — 탐사자가 §5에서 미지원 연장으로 본 그 판정 / (c) 순회 중 `On()`을 error로 | **(a)** | `IsOn()`이 참인데 통지가 나가는 창은 하류가 합리적으로 기대할 불변식 위반이고, 비용이 핸들당 불린 비교 하나. `blocker-plan.md` 미지원 선언의 근거는 카운팅 네스팅("`On()` 여러 번")이라 이 경우를 안 다룸. 다만 (b)도 탐사자 판정과 일치하는 방어 가능한 자리라 사용자 결정 | 아니오 |
|
||||||
|
| **`H-205`** (`/code-review`, 단위 2) | `_recompute`의 Modifier 가드 level — 지금 level 2는 항상 `Impl.Get` 내부 줄을 지목 | (a) level 3으로 — 직접 `Get`이면 유저 호출부, 체인 경유면 지금과 같은 내부 프레임(엄격히 우세, 더 나빠지는 경우 없음) / (b) 정직한 level 1 + 메시지에 맥락 의존(사용자 입력인데 내부 표기라 계약과 어긋남) / (c) 현행 유지 + 한계를 문서화 | **(a)** | lazy 체인이라 어떤 고정 level도 항상 유저 코드에 닿진 못하지만, (a)는 닿을 수 있는 유일한 경우(직접 `Get`)를 살리고 나머지에서 현행과 동일 — 유일하게 손해 없는 개선 | 아니오 |
|
||||||
| **`H-174`** (탐사자, **단위 2 착수 전 필요**) | 반응형 모듈이 자기 `quad` 인스턴스의 `canExecute`/`canBound`에 닿는 법 | (a) `Source.luau`/`State.luau`/`Observer.luau`/`Effect.luau`를 `InitSource(module)`류 **팩토리**로 — 클래스와 `Subscribed`/`WeakSubscribed` 레지스트리를 `module`을 닫은 클로저 안에서 만들고 `module.Source = …`로 심음(`InitDispatch`와 같은 모양, `EpochMap`/`Brand`/`Ref`는 그대로 잎). 공개 필드가 없는 `State`/`Observer` 클래스는 `Init`의 반환값이나 `module.RunInit` 뒤의 비공개 필드로 형제 `Init`에 넘김 / (b) 잎 모듈 유지 + 값마다 역참조 필드(`observer._quad`)를 두고 `self._quad.canExecute(self)` / (c) 잎 모듈 유지 + 모듈 로컬 슬롯 하나를 백엔드가 채움(인스턴스 격리 포기) | **(a)** | `architecture.md` 13번(*"모듈 인스턴스를 인자로 받도록"*)과 `module-lifecycle-plan.md` "New()의 내부 구성"이 이미 이 모양이고, `Observer`의 두 레지스트리가 인스턴스별이 되어 *"완전히 별도의 새 Quad 네임스페이스"*와 맞음. (b)는 새 필드 + 값이 자기 모듈을 강참조(`Relate` 되참조 계열 위험), (c)는 `spec.lifetime` 8·`New()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 |
|
| **`H-174`** (탐사자, **단위 2 착수 전 필요**) | 반응형 모듈이 자기 `quad` 인스턴스의 `canExecute`/`canBound`에 닿는 법 | (a) `Source.luau`/`State.luau`/`Observer.luau`/`Effect.luau`를 `InitSource(module)`류 **팩토리**로 — 클래스와 `Subscribed`/`WeakSubscribed` 레지스트리를 `module`을 닫은 클로저 안에서 만들고 `module.Source = …`로 심음(`InitDispatch`와 같은 모양, `EpochMap`/`Brand`/`Ref`는 그대로 잎). 공개 필드가 없는 `State`/`Observer` 클래스는 `Init`의 반환값이나 `module.RunInit` 뒤의 비공개 필드로 형제 `Init`에 넘김 / (b) 잎 모듈 유지 + 값마다 역참조 필드(`observer._quad`)를 두고 `self._quad.canExecute(self)` / (c) 잎 모듈 유지 + 모듈 로컬 슬롯 하나를 백엔드가 채움(인스턴스 격리 포기) | **(a)** | `architecture.md` 13번(*"모듈 인스턴스를 인자로 받도록"*)과 `module-lifecycle-plan.md` "New()의 내부 구성"이 이미 이 모양이고, `Observer`의 두 레지스트리가 인스턴스별이 되어 *"완전히 별도의 새 Quad 네임스페이스"*와 맞음. (b)는 새 필드 + 값이 자기 모듈을 강참조(`Relate` 되참조 계열 위험), (c)는 `spec.lifetime` 8·`New()`의 존재 이유와 충돌. 어느 쪽이든 **필드는 발화 시점에 늦게 읽는다**(상세 절의 하위 함정) | 아니오 |
|
||||||
|
|
||||||
**[2026-08-28 회신 — 단위 1 배치 전량 확정]** 사용자 원문: *"174 는 module.canExecute 로
|
**[2026-08-28 회신 — 단위 1 배치 전량 확정]** 사용자 원문: *"174 는 module.canExecute 로
|
||||||
|
|
@ -390,7 +512,7 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복
|
||||||
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
|
번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열.
|
||||||
|
|
||||||
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
|
코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야
|
||||||
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개 마커**가 코드에 있다(`grep -rn "TODO(H-" quad-base quad-types`) — 위 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
|
한다. **[2026-08-28 기준] 마커 0개**였고 **[2026-08-29 단위 3·4 리뷰 뒤] `H-182`~`H-187` 여섯 개**, **[2026-08-31 단위 2 리뷰 뒤] `H-198`/`H-200`/`H-203`/`H-205` 넷이 더해져 열 개 마커**가 코드에 있다(`grep -rn "TODO(H-" quad-base quad-types`) — 마커 없이 답변된 §4 행은 넷(`H-168`~`H-170`, `H-174`)이고, 그중 셋(`H-168`~`H-170`)은 단위 1 모듈을 막지 않아 코드는 문서
|
||||||
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
|
블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제).
|
||||||
|
|
||||||
## §5 이상 없다고 확인한 것
|
## §5 이상 없다고 확인한 것
|
||||||
|
|
|
||||||
|
|
@ -2041,3 +2041,12 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs
|
||||||
`module._impl`)·`H-188`~`H-190` 반영, ② `H-182`~`H-187` §4. 탐사자 ① 7건(`H-191`~`H-197`,
|
`module._impl`)·`H-188`~`H-190` 반영, ② `H-182`~`H-187` §4. 탐사자 ① 7건(`H-191`~`H-197`,
|
||||||
전부 옛 문장) 반영, 전 코퍼스 스윕으로 `HasBlockedEmit`·`LifetimeHandle` 표기 정리.
|
전부 옛 문장) 반영, 전 코퍼스 스윕으로 `HasBlockedEmit`·`LifetimeHandle` 표기 정리.
|
||||||
**M2 단위 넷 구현·감사·리뷰·탐사 완료** — 남은 건 §4 회신.
|
**M2 단위 넷 구현·감사·리뷰·탐사 완료** — 남은 건 §4 회신.
|
||||||
|
|
||||||
|
- **`session/2026-08-31-01-unit2-code-review.md`** — 체크포인트 재개 첫 항목: 단위 2 파일
|
||||||
|
`/code-review high` 완주(포크 조기 반환을 `SendMessage` 재개로 해결 — 재실행보다 이
|
||||||
|
방법 먼저). 8각도 22후보 → 검증 생존 10: ① 여섯 반영(`H-199` nil dep `collectDeps` /
|
||||||
|
`H-201`·`H-202`·`H-204` 입력 검증 셋 / `H-206` `ImplRegistry.luau` 신설 / `H-207`
|
||||||
|
`Set`→`Emit` 위임), ② 넷 §4 합류(`H-198` 🔴 닫힌 게이트 너머 `fn` 도중 `Set` 영구
|
||||||
|
stale — §4 확정 의사코드 자체의 구멍 / `H-200` setup throw 좀비 / `H-203` `Off` 중
|
||||||
|
재차단 / `H-205` Modifier 가드 level). 문서 이름 변경(pre-implementation 아님)은
|
||||||
|
사용자 지시로 §4 회신 뒤로 미룸. 남은 재개 지점은 §4 문항 열 개 회신뿐.
|
||||||
|
|
|
||||||
50
.claude/session/2026-08-31-01-unit2-code-review.md
Normal file
50
.claude/session/2026-08-31-01-unit2-code-review.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 2026-08-31-01 — 체크포인트 재개: 단위 2 `/code-review high` 완주와 반영
|
||||||
|
|
||||||
|
## 맥락
|
||||||
|
|
||||||
|
2026-08-29 아침 체크포인트(`session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절)의
|
||||||
|
재개 지점 둘 중, 사용자가 *"미완인 것 먼저 처리해줘"*로 (2) 단위 2 파일 리뷰를 먼저
|
||||||
|
지시했다. §4 문항 여섯(`H-182`~`H-187`)의 회신은 이 세션 시작 시점에 아직 없음.
|
||||||
|
|
||||||
|
부수 결정 하나 — 사용자: *"이건 pre-implementation 은 아닌듯 하네, 이름은 나중에
|
||||||
|
바꾸자"*. `pre-implementation-handtrace-round11*.md`는 구현 **중** 발견 문서라 이름이
|
||||||
|
안 맞는다는 것. **이름 변경은 §4 회신 처리 뒤로 미룸**(문항지·brief와 인용처를 한꺼번에
|
||||||
|
옮겨야 해서) — 아직 안 했고, 잊지 말 것.
|
||||||
|
|
||||||
|
## 포크 조기 반환과 재개
|
||||||
|
|
||||||
|
`/code-review high` + 경로 넷(`EpochMap`/`Source`/`State`/`Store`)으로 실행. 체크포인트가
|
||||||
|
기록한 실패 모드(하위 탐색자 완료 전 포크 조기 반환)가 **이번에도 재현**됐다 — 첫
|
||||||
|
notification 시점에 파인더 4개가 미완인 채 멈춤. 이번엔 opus 대체가 아니라
|
||||||
|
**`SendMessage`로 포크를 재개**(나머지 파인더 수거 → dedup → 검증 → `ReportFindings`까지
|
||||||
|
끝내고 턴을 닫으라고 명시)했고, 그걸로 완주했다. 다음에 같은 패턴이 나오면 이 방법을
|
||||||
|
먼저 쓸 것 — 재실행보다 싸고(이미 돈 파인더 결과를 버리지 않음) 잘 듣는다.
|
||||||
|
|
||||||
|
## 결과 — 8각도, 후보 22 → 검증 생존 10
|
||||||
|
|
||||||
|
정확성 8건 전부 실제 코드에 재현 스크립트로 확인. 효율 배치는 리뷰 스스로 "관측된
|
||||||
|
병목에만 구조" 원칙 위반으로 기각, 약한 정리 항목들은 심각도 미달 기각. 발견 상세와
|
||||||
|
처리의 소스는 `qa-request/pre-implementation-handtrace-round11.md`(요약 표
|
||||||
|
`H-198`~`H-207`, 상세 절 "단위 2 — `/code-review high`") — 여기선 갈래만:
|
||||||
|
|
||||||
|
- **① 여섯**(같은 커밋에서 반영): `H-199`(nil dep 조용한 탈락 — `collectDeps` + error),
|
||||||
|
`H-201`(`Of` 이름 문자열 검증), `H-202`(`Compute` fn 검증), `H-204`(`defaults` 평범한
|
||||||
|
테이블 검증), `H-206`(`implsOf` 세 벌 → `ImplRegistry.luau` 신설), `H-207`(`Source.Set`이
|
||||||
|
`Emit` 꼬리 위임). 검증 셋은 `H-190`·단위 3 `fn` 검사와 같은 급(형제 검증과 동급),
|
||||||
|
중복 제거 둘은 conventions 설계 원칙의 공유 허용 범위(데이터/순수 접근, 같은 타입).
|
||||||
|
- **② 넷**(§4 문항 + `TODO` 마커): `H-198` 🔴(닫힌 게이트 너머 `fn` 도중 `Set` → 영구
|
||||||
|
stale 캐시 — **`state-epoch-plan.md` §4 확정 의사코드 자체의 구멍**, `H-85`와 같은 실패
|
||||||
|
계열의 게이트 경유 변형), `H-200`(`Gate` setup throw 좀비 노드 — 예외 계약 UB냐
|
||||||
|
`H-188` 연장이냐), `H-203`(`Blocker:Off` 순회 중 재차단 무시 — 탐사자는 §5에서 미지원
|
||||||
|
연장으로 봤고 리뷰는 `IsOn` 불변식 위반으로 봄), `H-205`(Modifier 가드 level — lazy
|
||||||
|
체인이라 어떤 고정 level도 항상은 유저 코드에 못 닿음).
|
||||||
|
|
||||||
|
리뷰가 확인한 "이상 없음" 목록과 기각 중 기록할 것 하나(Effect `Ref` dep의 생성 중
|
||||||
|
`onRefFire` 즉시 발화 — 신선한 Effect는 `canExecute` 거짓이라 실패 불가)는 round11.md의
|
||||||
|
"단위 2 리뷰가 이상 없다고 확인한 것" 절로.
|
||||||
|
|
||||||
|
## 이 시점 상태
|
||||||
|
|
||||||
|
`./scripts/test.sh` 전부 통과(`spec.state` 13절·`spec.store` 2·3절에 새 가드 테스트 추가),
|
||||||
|
`luau-analyze` 진단 0, 코드 마커 `TODO(H-)` 열 개 = §4 문항 열 개와 1:1. **재개 지점은
|
||||||
|
§4 배치 회신 하나로 줄었다**(여섯 + 이번 넷). M2 종료 보고는 그 회신 처리 뒤.
|
||||||
|
|
@ -10,9 +10,14 @@
|
||||||
단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가
|
단위 넷 / 관여 시점), 발견과 배치 문항은 `-round11.md`(§4 표가 사용자가
|
||||||
읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서
|
읽을 유일한 자리). **진행 상태의 소스는 `ROADMAP.md` M2 체크박스**, 여기서
|
||||||
세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴),
|
세지 않는다. **[2026-08-29 새벽 기준]** 단위 1·2 완료(단위 2 감사 6라운드 수렴),
|
||||||
단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료 — **[2026-08-29 아침 체크포인트]** 재개 지점은 (1) `round11.md` §4 문항 여섯(`H-182`~`H-187`) 사용자 회신, (2) 단위 2 파일 `/code-review`(미완, 포크 조기 반환) —
|
단위 3(`Observer`/`Effect`)·단위 4(`GateNode`/`Blocker`) 구현 완료(M2 체크박스 전부 `[x]`), 단위 3·4 끝 절차(감사·리뷰·탐사)도 완료. **[2026-08-31]** 체크포인트에
|
||||||
진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절. 밤샘 자율
|
미완으로 남았던 단위 2 파일 `/code-review high`도 완료(포크 재개 지시로 완주) —
|
||||||
구간(사용자 허용, 병렬 2)이라 이 줄은 자주 낡는다 — `ROADMAP.md` 체크박스가 소스.
|
발견 10건 중 ① 여섯 반영, ② 넷(`H-198`/`H-200`/`H-203`/`H-205`)이 §4 합류.
|
||||||
|
**남은 재개 지점은 `round11.md` §4 문항 열 개(`H-182`~`H-187` + 위 넷) 사용자
|
||||||
|
회신뿐** — 진행 원문은 `session/2026-08-28-03-m2-unit1-common-base.md` 마지막 절과
|
||||||
|
`session/2026-08-31-01-unit2-code-review.md`. §4 회신 처리 뒤 할 일 하나:
|
||||||
|
`pre-implementation-handtrace-round11*` 파일명 변경(사용자 2026-08-31 — 구현 중
|
||||||
|
문서라 pre-implementation이 안 맞음; 새 이름은 그때 사용자와 정할 것).
|
||||||
아래는 착수 전(2026-08-26) 서술:
|
아래는 착수 전(2026-08-26) 서술:
|
||||||
|
|
||||||
**[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.**
|
**[2026-08-26] 8라운드까지 전부 처리 완료 — M2 착수 게이트가 0이다.**
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ local function runHandles(self: any, doEmit: boolean)
|
||||||
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
|
for handle in pairs(self._handles) do -- `H-63` (3): snapshot, then walk
|
||||||
snapshot[#snapshot + 1] = handle
|
snapshot[#snapshot + 1] = handle
|
||||||
end
|
end
|
||||||
|
-- TODO(H-203): the walk never re-reads `IsBlocked` — a subscriber fired by an
|
||||||
|
-- earlier handle's flush that calls `blocker:On()` cannot stop the remaining
|
||||||
|
-- handles: their batches propagate while `IsOn() == true`. (The line-comment in
|
||||||
|
-- `Off` covers only the open-direction re-entry; the doc disclaims nesting only.)
|
||||||
for _, handle in ipairs(snapshot) do
|
for _, handle in ipairs(snapshot) do
|
||||||
handle(doEmit)
|
handle(doEmit)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
|
|
||||||
local Brand = require("./Brand")
|
local Brand = require("./Brand")
|
||||||
local EpochMap = require("./EpochMap")
|
local EpochMap = require("./EpochMap")
|
||||||
|
local ImplRegistry = require("./ImplRegistry")
|
||||||
local Observer = require("./Observer")
|
local Observer = require("./Observer")
|
||||||
|
|
||||||
local EffectBrand = Brand.EffectBrand
|
local EffectBrand = Brand.EffectBrand
|
||||||
|
|
@ -42,18 +43,8 @@ local isSource = Brand.isSource
|
||||||
local isRef = Brand.isRef
|
local isRef = Brand.isRef
|
||||||
local isEpoch = Brand.isEpoch
|
local isEpoch = Brand.isEpoch
|
||||||
|
|
||||||
-- Per-instance impls live ON the module instance (`module._impl`, private — the
|
-- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
|
||||||
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
|
local implsOf = ImplRegistry.implsOf
|
||||||
-- keyed by `module` would never collect: the impl's closures capture `module`, and
|
|
||||||
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
|
|
||||||
local function implsOf(module: any): { [string]: any }
|
|
||||||
local impls = rawget(module, "_impl")
|
|
||||||
if impls == nil then
|
|
||||||
impls = {}
|
|
||||||
rawset(module, "_impl", impls)
|
|
||||||
end
|
|
||||||
return impls
|
|
||||||
end
|
|
||||||
|
|
||||||
local function createImpl(module: any)
|
local function createImpl(module: any)
|
||||||
module:RunInit(Observer.Init) -- registries live there (`H-99`)
|
module:RunInit(Observer.Init) -- registries live there (`H-99`)
|
||||||
|
|
|
||||||
28
quad-base/src/ImplRegistry.luau
Normal file
28
quad-base/src/ImplRegistry.luau
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
--[[
|
||||||
|
Per-instance implementation registry access (`H-174`/`H-181`) — the one
|
||||||
|
copy of `implsOf`, shared by `State.luau`/`Observer.luau`/`Effect.luau`
|
||||||
|
(`H-206`: three verbatim copies risked forking the registry scheme).
|
||||||
|
Dependency-free LEAF module, shared across quad instances like `Ref`: it
|
||||||
|
holds no state of its own — pure access to the `module` it is handed (the
|
||||||
|
sharing `conventions.md`'s heterogeneous-bodies principle explicitly
|
||||||
|
permits; bodies with per-type tails stay separate).
|
||||||
|
|
||||||
|
Impls live ON the module instance (`module._impl`, private — the `H-174`
|
||||||
|
(a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
|
||||||
|
keyed by `module` would never collect: the impl's closures capture
|
||||||
|
`module`, and Luau has no ephemerons (`relate-plan.md`), so every
|
||||||
|
`Quad.New()` would be pinned (`H-181`).
|
||||||
|
]]
|
||||||
|
|
||||||
|
local function implsOf(module: any): { [string]: any }
|
||||||
|
local impls = rawget(module, "_impl")
|
||||||
|
if impls == nil then
|
||||||
|
impls = {}
|
||||||
|
rawset(module, "_impl", impls)
|
||||||
|
end
|
||||||
|
return impls
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
implsOf = implsOf,
|
||||||
|
}
|
||||||
|
|
@ -34,23 +34,15 @@
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local Brand = require("./Brand")
|
local Brand = require("./Brand")
|
||||||
|
local ImplRegistry = require("./ImplRegistry")
|
||||||
|
|
||||||
local ObserverBrand = Brand.ObserverBrand
|
local ObserverBrand = Brand.ObserverBrand
|
||||||
|
|
||||||
local WEAK_KEY_MT = { __mode = "k" }
|
local WEAK_KEY_MT = { __mode = "k" }
|
||||||
|
|
||||||
-- Per-instance impls live ON the module instance (`module._impl`, private — the
|
-- One implementation (and registry pair) per quad instance (`H-174`);
|
||||||
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
|
-- storage scheme in `ImplRegistry.luau`.
|
||||||
-- keyed by `module` would never collect: the impl's closures capture `module`, and
|
local implsOf = ImplRegistry.implsOf
|
||||||
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
|
|
||||||
local function implsOf(module: any): { [string]: any }
|
|
||||||
local impls = rawget(module, "_impl")
|
|
||||||
if impls == nil then
|
|
||||||
impls = {}
|
|
||||||
rawset(module, "_impl", impls)
|
|
||||||
end
|
|
||||||
return impls
|
|
||||||
end
|
|
||||||
|
|
||||||
local function createImpl(module: any)
|
local function createImpl(module: any)
|
||||||
local Impl = {}
|
local Impl = {}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,14 @@ local function Init(module: any)
|
||||||
return self._value
|
return self._value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- In-place mutation was done by the caller; only the signal is sent.
|
||||||
|
-- Root Sources only — derived States have no such concept ("하드 경계").
|
||||||
|
function Impl.Emit(self: any): any
|
||||||
|
self.Revision = bit32.bnot(-self.Revision)
|
||||||
|
emitDown(self, self) -- payload = the source epoch itself
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
-- `H-68`: same value still bumps the revision and emits — dedup is the
|
-- `H-68`: same value still bumps the revision and emits — dedup is the
|
||||||
-- downstream's job (EpochMap judgement, gates), and `==` would silently
|
-- downstream's job (EpochMap judgement, gates), and `==` would silently
|
||||||
-- drop in-place table mutations.
|
-- drop in-place table mutations.
|
||||||
|
|
@ -58,17 +66,9 @@ local function Init(module: any)
|
||||||
error("Source: cannot Set a Modifier as a Source value", 2)
|
error("Source: cannot Set a Modifier as a Source value", 2)
|
||||||
end
|
end
|
||||||
self._value = value
|
self._value = value
|
||||||
self.Revision = bit32.bnot(-self.Revision)
|
-- `H-207`: one copy of the bump-and-emit tail. Direct call, not colon
|
||||||
emitDown(self, self) -- payload = the source epoch itself
|
-- delegation — same type, same impl table, no subtype override to hit.
|
||||||
return self
|
return Impl.Emit(self)
|
||||||
end
|
|
||||||
|
|
||||||
-- In-place mutation was done by the caller; only the signal is sent.
|
|
||||||
-- Root Sources only — derived States have no such concept ("하드 경계").
|
|
||||||
function Impl.Emit(self: any): any
|
|
||||||
self.Revision = bit32.bnot(-self.Revision)
|
|
||||||
emitDown(self, self)
|
|
||||||
return self
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Impl._track(self: any, map: any)
|
function Impl._track(self: any, map: any)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
|
|
||||||
local Brand = require("./Brand")
|
local Brand = require("./Brand")
|
||||||
local EpochMap = require("./EpochMap")
|
local EpochMap = require("./EpochMap")
|
||||||
|
local ImplRegistry = require("./ImplRegistry")
|
||||||
local Observer = require("./Observer")
|
local Observer = require("./Observer")
|
||||||
local Void = require("./Void")
|
local Void = require("./Void")
|
||||||
local QuadTypes = require("../roblox_packages/quad_types")
|
local QuadTypes = require("../roblox_packages/quad_types")
|
||||||
|
|
@ -60,19 +61,8 @@ local isModifier = Brand.isModifier
|
||||||
|
|
||||||
local WEAK_KEY_MT = { __mode = "k" }
|
local WEAK_KEY_MT = { __mode = "k" }
|
||||||
|
|
||||||
-- One implementation per quad instance (`H-174`).
|
-- One implementation per quad instance (`H-174`); storage scheme in `ImplRegistry.luau`.
|
||||||
-- Per-instance impls live ON the module instance (`module._impl`, private — the
|
local implsOf = ImplRegistry.implsOf
|
||||||
-- `H-174` (a) form: "RunInit 뒤의 비공개 필드로 형제 Init에 넘김"). A weak-key map
|
|
||||||
-- keyed by `module` would never collect: the impl's closures capture `module`, and
|
|
||||||
-- Luau has no ephemerons (`relate-plan.md`), so every `Quad.New()` would be pinned.
|
|
||||||
local function implsOf(module: any): { [string]: any }
|
|
||||||
local impls = rawget(module, "_impl")
|
|
||||||
if impls == nil then
|
|
||||||
impls = {}
|
|
||||||
rawset(module, "_impl", impls)
|
|
||||||
end
|
|
||||||
return impls
|
|
||||||
end
|
|
||||||
|
|
||||||
local function createImpl(module: any)
|
local function createImpl(module: any)
|
||||||
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
|
module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`)
|
||||||
|
|
@ -155,10 +145,15 @@ local function createImpl(module: any)
|
||||||
-- (`hold[1]`), never this result node.
|
-- (`hold[1]`), never this result node.
|
||||||
local result = self._fn(hold[1], self._cache, table.unpack(hold, 2))
|
local result = self._fn(hold[1], self._cache, table.unpack(hold, 2))
|
||||||
if isModifier(result) then
|
if isModifier(result) then
|
||||||
|
-- TODO(H-205): level 2 here always blames Impl.Get's own line (its only
|
||||||
|
-- callers are internal) — the contract wants user-code blame; level TBD.
|
||||||
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2)
|
error("State: a Compute function returned a Modifier — State/Source cannot hold Modifiers", 2)
|
||||||
end
|
end
|
||||||
self._cache = result
|
self._cache = result
|
||||||
for _, dep in ipairs(hold) do
|
for _, dep in ipairs(hold) do
|
||||||
|
-- TODO(H-198): tracking LIVE revisions here marks as seen an upstream Set
|
||||||
|
-- that landed DURING fn but whose emit a closed gate withheld — the later
|
||||||
|
-- flush then forwards downstream without `_invalidate` (permanent stale cache).
|
||||||
dep:_track(self._valueEpochMap) -- fresh value is current w.r.t. EVERY upstream
|
dep:_track(self._valueEpochMap) -- fresh value is current w.r.t. EVERY upstream
|
||||||
end
|
end
|
||||||
self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid
|
self._cacheCurrCount = gen -- only on success: a thrown fn never marks the cache valid
|
||||||
|
|
@ -181,20 +176,32 @@ local function createImpl(module: any)
|
||||||
return s:Get()
|
return s:Get()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Impl.With(self: any, ...: any): any
|
-- `H-199`: a nil dep must error, not vanish — `deps[#deps + 1]` would drop it
|
||||||
|
-- silently AND shift later deps left, so `fn`'s positional lazy-dep args land
|
||||||
|
-- in the wrong slots. Index numbering matches `newNode`'s (self is dep #1).
|
||||||
|
local function collectDeps(self: any, ...: any): { any }
|
||||||
local deps = { self }
|
local deps = { self }
|
||||||
for i = 1, select("#", ...) do
|
for i = 1, select("#", ...) do
|
||||||
deps[#deps + 1] = (select(i, ...))
|
local dep = (select(i, ...))
|
||||||
|
if dep == nil then
|
||||||
|
error(`State: dep #{i + 1} is nil`, 3) -- 3: past With/Compute to the user's call
|
||||||
|
end
|
||||||
|
deps[i + 1] = dep
|
||||||
end
|
end
|
||||||
return newNode(deps, passThrough) -- value is `self`'s, subscriptions are wider
|
return deps
|
||||||
|
end
|
||||||
|
|
||||||
|
function Impl.With(self: any, ...: any): any
|
||||||
|
return newNode(collectDeps(self, ...), passThrough) -- value is `self`'s, subscriptions are wider
|
||||||
end
|
end
|
||||||
|
|
||||||
function Impl.Compute(self: any, fn: any, ...: any): any
|
function Impl.Compute(self: any, fn: any, ...: any): any
|
||||||
local deps = { self }
|
if type(fn) ~= "function" then
|
||||||
for i = 1, select("#", ...) do
|
-- `H-202`: without this the crash lands at the first `Get` deep inside
|
||||||
deps[#deps + 1] = (select(i, ...))
|
-- `_recompute` — sibling surfaces (Gate/Observer/Apply) all validate here.
|
||||||
|
error("State: Compute fn must be a function", 2)
|
||||||
end
|
end
|
||||||
return newNode(deps, fn) -- one node: edges only, no combining node
|
return newNode(collectDeps(self, ...), fn) -- one node: edges only, no combining node
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ──
|
-- ── GateNode (`gate-plan.md` "`GateNode` 조립") — same layer as a ComputeNode ──
|
||||||
|
|
@ -252,6 +259,9 @@ local function createImpl(module: any)
|
||||||
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
|
local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node
|
||||||
node._withheld = newWithheld()
|
node._withheld = newWithheld()
|
||||||
node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee
|
node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee
|
||||||
|
-- TODO(H-200): if `setup` THROWS here, the node stays in `self._subs` as a
|
||||||
|
-- zombie subscriber (the detach below only covers the non-function return) —
|
||||||
|
-- spec.gate §1's "nothing left in the subscriber set" only holds for that path.
|
||||||
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
|
local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean
|
||||||
return node:_flush(commit)
|
return node:_flush(commit)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ local function Init(module: any)
|
||||||
|
|
||||||
-- Dynamic keys only: the type cannot stop a computed name, so the runtime does.
|
-- Dynamic keys only: the type cannot stop a computed name, so the runtime does.
|
||||||
function Impl.Of(self: any, name: string): any
|
function Impl.Of(self: any, name: string): any
|
||||||
|
if type(name) ~= "string" then
|
||||||
|
-- `H-201`: `Of` exists precisely for computed names — a nil/number name
|
||||||
|
-- must not pass (nil died at `self[name] =` with an internal frame after
|
||||||
|
-- already allocating a Source; a number silently broke `Names(): { string }`).
|
||||||
|
error(`Store: Of name must be a string (got {typeof(name)})`, 2)
|
||||||
|
end
|
||||||
if RESERVED[name] then
|
if RESERVED[name] then
|
||||||
error(`Store: "{name}" is a reserved store key`, 2)
|
error(`Store: "{name}" is a reserved store key`, 2)
|
||||||
end
|
end
|
||||||
|
|
@ -70,6 +76,17 @@ local function Init(module: any)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function Store(defaults: any?): any
|
local function Store(defaults: any?): any
|
||||||
|
-- `H-204`: validate the bag itself before cloning it — a non-table died
|
||||||
|
-- inside `table.clone`, and a bare Source (forgot the wrapping table) got
|
||||||
|
-- pairs-walked into an error naming a Source-internal field.
|
||||||
|
if defaults ~= nil then
|
||||||
|
if type(defaults) ~= "table" then
|
||||||
|
error(`Store: defaults must be a table of Sources (got {typeof(defaults)})`, 2)
|
||||||
|
end
|
||||||
|
if getmetatable(defaults) ~= nil then
|
||||||
|
error("Store: defaults must be a plain table without a metatable (a bare Source instead of { name = Source }?)", 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`)
|
local self = table.clone(defaults or {}) -- `or {}`: `Store<<{}>>()` is valid (`H-83`)
|
||||||
for name, value in pairs(self) do
|
for name, value in pairs(self) do
|
||||||
if RESERVED[name :: any] then
|
if RESERVED[name :: any] then
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,38 @@ do
|
||||||
print("PASS")
|
print("PASS")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 13. 가드 (H-199/H-202) — nil dep은 조용히 사라지지 않고 error, Compute fn은 함수여야 ===")
|
||||||
|
do
|
||||||
|
local s = Source(1)
|
||||||
|
-- H-199: a nil mid-list used to shift later deps left (fn's positional args
|
||||||
|
-- landed in the wrong slots); a single trailing nil used to vanish entirely.
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
s:Compute(function(x)
|
||||||
|
return x:Get()
|
||||||
|
end, nil :: any)
|
||||||
|
end)
|
||||||
|
assert(not ok and string.find(tostring(err), "dep #2 is nil", 1, true) ~= nil, "trailing nil dep: " .. tostring(err))
|
||||||
|
assert(string.find(tostring(err), "spec.state.luau", 1, true) ~= nil, "points at the caller")
|
||||||
|
local other = Source(2)
|
||||||
|
local ok2, err2 = pcall(function()
|
||||||
|
s:Compute(function(x)
|
||||||
|
return x:Get()
|
||||||
|
end, other, nil :: any, other)
|
||||||
|
end)
|
||||||
|
assert(not ok2 and string.find(tostring(err2), "dep #3 is nil", 1, true) ~= nil, "mid-list nil dep: " .. tostring(err2))
|
||||||
|
local ok3 = pcall(function()
|
||||||
|
s:With(nil :: any)
|
||||||
|
end)
|
||||||
|
assert(not ok3, "With rejects nil deps through the same collector")
|
||||||
|
-- H-202: non-function fn errors at Compute, not at the first Get deep in _recompute.
|
||||||
|
local ok4, err4 = pcall(function()
|
||||||
|
(s :: any):Compute(42)
|
||||||
|
end)
|
||||||
|
assert(not ok4 and string.find(tostring(err4), "Compute fn must be a function", 1, true) ~= nil, "non-function fn: " .. tostring(err4))
|
||||||
|
assert(string.find(tostring(err4), "spec.state.luau", 1, true) ~= nil, "points at the caller")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== ALL PASS ===")
|
print("=== ALL PASS ===")
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,21 @@ do
|
||||||
Store({ ok = Source(1), bad = Quad.Ref(1) } :: any)
|
Store({ ok = Source(1), bad = Quad.Ref(1) } :: any)
|
||||||
end)
|
end)
|
||||||
assert(not ok2, "a Ref is not a Source")
|
assert(not ok2, "a Ref is not a Source")
|
||||||
|
-- H-204: the bag itself is validated before clone — a non-table used to die
|
||||||
|
-- inside table.clone, a bare Source got an error naming a Source-internal field.
|
||||||
|
local ok3, err3 = pcall(function()
|
||||||
|
Store(5 :: any)
|
||||||
|
end)
|
||||||
|
assert(not ok3 and string.find(tostring(err3), "must be a table", 1, true) ~= nil, "non-table defaults: " .. tostring(err3))
|
||||||
|
assert(string.find(tostring(err3), "spec.store.luau", 1, true) ~= nil, "level 2 points at the caller")
|
||||||
|
local ok4, err4 = pcall(function()
|
||||||
|
Store(Source(100) :: any)
|
||||||
|
end)
|
||||||
|
assert(not ok4 and string.find(tostring(err4), "plain table", 1, true) ~= nil, "bare Source as defaults: " .. tostring(err4))
|
||||||
|
local ok5 = pcall(function()
|
||||||
|
Store(setmetatable({}, { __metatable = "locked" }) :: any)
|
||||||
|
end)
|
||||||
|
assert(not ok5, "protected metatable rejected before clone")
|
||||||
print("PASS")
|
print("PASS")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -61,6 +76,20 @@ do
|
||||||
assert(not ok2 and string.find(tostring(err2), "reserved", 1, true) ~= nil, name .. " via Of: " .. tostring(err2))
|
assert(not ok2 and string.find(tostring(err2), "reserved", 1, true) ~= nil, name .. " via Of: " .. tostring(err2))
|
||||||
assert(string.find(tostring(err2), "spec.store.luau", 1, true) ~= nil, "level 2")
|
assert(string.find(tostring(err2), "spec.store.luau", 1, true) ~= nil, "level 2")
|
||||||
end
|
end
|
||||||
|
-- H-201: Of exists for computed names, so a computed nil/number must error
|
||||||
|
-- here (nil used to allocate a Source then die at an internal frame; a number
|
||||||
|
-- silently broke `Names(): { string }`).
|
||||||
|
local s = Store({} :: {})
|
||||||
|
local okNil, errNil = pcall(function()
|
||||||
|
s:Of(nil :: any)
|
||||||
|
end)
|
||||||
|
assert(not okNil and string.find(tostring(errNil), "Of name must be a string", 1, true) ~= nil, "nil Of name: " .. tostring(errNil))
|
||||||
|
assert(string.find(tostring(errNil), "spec.store.luau", 1, true) ~= nil, "level 2 points at the caller")
|
||||||
|
local okNum, errNum = pcall(function()
|
||||||
|
s:Of(123 :: any)
|
||||||
|
end)
|
||||||
|
assert(not okNum and string.find(tostring(errNum), "Of name must be a string", 1, true) ~= nil, "number Of name: " .. tostring(errNum))
|
||||||
|
assert(#s:Names() == 0, "nothing was allocated by the rejected names")
|
||||||
print("PASS")
|
print("PASS")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue