From 3670e88d78c3abaa13af8c8ef9f36389a6a86133 Mon Sep 17 00:00:00 2001 From: qwreey-agent-selene Date: Sat, 29 Aug 2026 02:58:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(m2):=20/code-review=20high(=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=203=C2=B74)=20=EB=B0=98=EC=98=81=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=84=ED=94=8C=EC=9D=84=20module.=5Fimpl=EB=A1=9C(H-181,=20?= =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20=EC=98=81=EA=B5=AC=20?= =?UTF-8?q?=ED=95=80=20=ED=95=B4=EC=86=8C),=20Gate=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20detach(H-188),=20Observer.Subscribed=3Dfalse(H-189)?= =?UTF-8?q?,=20Apply=20=EA=B2=80=EC=A6=9D(H-190);=20=E2=91=A1=20H-182~H-18?= =?UTF-8?q?7=EC=9D=80=20=C2=A74=20+=20TODO=20=EB=A7=88=EC=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - State/Observer/Effect: implByModule(weak-key, 값이 키 캡처 → ephemeron 없어 불멸) 제거, 임플은 rawset(module, "_impl") 비공개 필드(H-174 (a) 원문 모양). spec.init 2가 인스턴스 GC 고정. - State.Gate: 검증 실패 시 상류 _subs에서 detach, setup 전 _onUpstreamEmit = Void. Apply 객체 분기 검증. - Effect 시딩은 dep:_track(map), EpochSet 타입은 quad-types 하나로, State.Impl._module 제거. - 코드 마커 TODO(H-182)(H-183)(H-184)(H-185)(H-186)(H-187); round11 §4 표 여섯 행(권고 전부 (a)). - WARN 둘(round11.md 축약 참조) 전체 이름으로. 세션 원문·요약. Co-authored-by: qwreey --- .claude/base/typing-limits.md | 2 +- .claude/luau-test/STATUS.md | 4 +- .../pre-implementation-handtrace-round11.md | 57 ++++++++++++++++++- .claude/session-summary.md | 4 +- .../2026-08-28-03-m2-unit1-common-base.md | 12 +++- quad-base/src/Effect.luau | 31 +++++++--- quad-base/src/EpochMap.luau | 2 +- quad-base/src/Observer.luau | 20 ++++++- quad-base/src/State.luau | 24 ++++++-- quad-base/test/mock.luau | 3 + quad-base/test/spec.gate.luau | 8 +++ quad-base/test/spec.init.luau | 20 +++++++ quad-base/test/spec.observer.luau | 1 + quad-base/test/spec.state.luau | 7 +++ quad-types/src/init.luau | 2 + 15 files changed, 175 insertions(+), 22 deletions(-) diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 61eb13c..9fbaa43 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -173,7 +173,7 @@ export type State = StateData & { 유니온 하나(`((State) -> U) | { __apply: … -> U }`)로 두면 `Blocker`처럼 필드가 더 있는 객체가 제네릭 `U` 자리에서 너비 서브타이핑을 못 받아 `state:Apply(blocker)`가 strict에서 막힌다(인덱서 `[string]: any`로 열어도 같다 — `luau-test/done/26-*`). - 객체 쪽 반환이 `any`라 결과는 명시 주석(①과 같은 관례). `round11.md` `H-179`. + 객체 쪽 반환이 `any`라 결과는 명시 주석(①과 같은 관례). `qa-request/pre-implementation-handtrace-round11.md` `H-179`. - **캐비엇**: 콜백이 받는 `s`는 `StateData`라 `Compute`/`With`가 없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리 (`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index f82d737..c13ad92 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -181,9 +181,9 @@ | 파일 | 확인된 것 | |---|---| -| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | ✅ **[2026-08-29 M2 단위 4]** `state:Apply(factory)`의 파라미터 타입은 **교집합 오버로드**(함수 팩토리 제네릭 `U` / `__apply` 객체 `any` 반환) — 유니온 하나는 필드가 더 있는 객체(`Blocker`)를 못 받는다. 기대 진단 2건(음성 대조군)만 — 근거: `round11.md` `H-179`, `quad-types/src/init.luau` `State.Apply` | +| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | ✅ **[2026-08-29 M2 단위 4]** `state:Apply(factory)`의 파라미터 타입은 **교집합 오버로드**(함수 팩토리 제네릭 `U` / `__apply` 객체 `any` 반환) — 유니온 하나는 필드가 더 있는 객체(`Blocker`)를 못 받는다. 기대 진단 2건(음성 대조군)만 — 근거: `qa-request/pre-implementation-handtrace-round11.md` `H-179`, `quad-types/src/init.luau` `State.Apply` | | `05-store-state-diamond-propagation.luau` | **[2026-08-29 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2·3의 `quad-base/test/spec.state.luau` 3번(다이아몬드에서 두 번째 도착이 규칙 3으로 접힘, 조인 1회 계산)·`spec.effect.luau` 3번(Effect도 1회)이 실제 구현에서 같은 것을 고정한다 — 이 스파이크가 물으려던 "변경당 1회"의 답. 아래는 폐기 전 상태: 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 — 근거: **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) | -| `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) — 근거: 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | +| `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`qa-request/pre-implementation-handtrace-round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) — 근거: 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`로 **해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | diff --git a/.claude/qa-request/pre-implementation-handtrace-round11.md b/.claude/qa-request/pre-implementation-handtrace-round11.md index 9fb4262..8a45886 100644 --- a/.claude/qa-request/pre-implementation-handtrace-round11.md +++ b/.claude/qa-request/pre-implementation-handtrace-round11.md @@ -26,6 +26,16 @@ | `H-174` | **②** | 1→2 | 🔴 | 생명주기 4종은 **`New()` 인스턴스마다 다른 필드**(이 단위가 그렇게 만들었고 `spec.lifetime` 8이 고정)인데, 단위 2·3의 `base/` 의사코드(`Observer:_receive`의 `canExecute(self)`, `Subscribe` 넷의 `canBound(self)`, `EffectHandle.rawRerun`)는 **자유 함수**로 부른다 — `Observer.luau`/`Source.luau`가 자기 인스턴스의 필드에 어떻게 닿는지 어느 문서도 안 정했다. 결정 없이는 단위 2의 `_receive`를 쓸 수 없다 | ✅ (a) 사용자 확정 — 팩토리형, `module.canExecute(self)`를 발화 시점에 늦게 읽음(`lifecycle-pattern.md`·`module-lifecycle-plan.md`·`ROADMAP` 반응형 본체) | | `H-176` | ① | 2 | 🟡 | `:Compute`의 trailing deps를 타입팩 `D...`로 좁히는 선언은 strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힌다(스파이크 15가 "미검증"으로 남긴 자리) | ✅ `...any` + 콜백 주석으로 확정, `source-state-plan.md` 실측 기록 | | `H-177` | ① | 2 | 🟢 | `InitSource`/`InitStore`가 `State.implFor`만 불러 `New()`의 `RunInit` 순서에 의존했다 — `module-lifecycle-plan.md`는 "각 `InitXxx`가 `require`처럼 멱등하게 자기 의존성을 당겨온다"로 확정 | ✅ 각 Init이 `module:RunInit(dep)`를 직접 호출(감사 3라운드) | +| `H-181` | ① | 2~4 | 🔴 | 인스턴스별 임플을 `module` 키의 weak-key 맵에 뒀는데 값(임플 클로저)이 키(`module`)를 캡처 — Luau엔 ephemeron이 없어 `Quad.New()`마다 영영 안 죽고 그 강한 레지스트리의 Observer/Effect 그래프까지 핀됨 | ✅ 임플을 `module._impl`(비공개 필드 — `H-174` (a)안 원문 모양)에, `spec.init` 2 | +| `H-182` | **②** | 3 | 🟡 | leaf `Destroying` 콜백이 cleanup을 소진한 뒤에도 같은 파동 안에서는 `canExecute`가 참(gcconn은 마지막에 끊김) → 파동 후반의 dep 변경이 죽는 leaf 위에서 `fn`을 다시 돌리고 아무도 소진 안 할 cleanup을 저장 | §4 대기 (`-- TODO(H-182)`) | +| `H-183` | **②** | 3 | 🟡 | Observer 설치 발화 안에서 `self:Subscribe()`/`bindLifetime(inst, self)`를 부르면 `_catchUp`이 `fn`을 중첩 재생하고 생성자가 "already subscribed"로 죽음 — Effect의 `isRunning` 가드(`H-147`)에 해당하는 것이 Observer엔 없음 | §4 대기 (`-- TODO(H-183)`) | +| `H-184` | **②** | 1·3 | 🟡 | `bindLifetime`이 부기를 커밋한 **뒤** `_bindDestroying`의 `isRunning` 가드가 던지면 Effect가 묶인 채(`canExecute` 참) `Destroying` 연결 없이 남음 — 순서는 `lifecycle-pattern.md` (1) 그대로라 quad-roblox도 상속 | §4 대기 (`-- TODO(H-184)`) | +| `H-185` | **②** | 3 | 🟢 | `EffectFn`은 `-> ...(() -> ())` 팩(H-95)인데 런타임은 첫 반환만 cleanup으로 저장 — `return stopA, stopB`가 타입은 통과하고 `stopB`는 조용히 버려짐 | §4 대기 (`-- TODO(H-185)`) | +| `H-186` | **②** | 3 | 🟡 | 교차 인스턴스 dep(`A.Effect(fn, B.Source(0))`)을 막지도 정의하지도 않음 — dep의 백엔드가 게이팅하고 에러가 엉뚱한 인스턴스를 가리킴; `architecture.md` 13번은 다중 `New()`를 지원으로 서술 | §4 대기 (`-- TODO(H-186)`) | +| `H-187` | **②** | 3·4 | 🟢 | `quad-types`의 새 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)이 `base/`에 없는 이름 — 시그니처는 문서 그대로, 이름은 구현이 붙임 | §4 대기 (`-- TODO(H-187)`) | +| `H-188` | ① | 4 | 🟡 | `state:Gate(setup)`가 검증 실패로 error할 때 반쯤 만든 노드가 상류 `_subs`에 남아 다음 `Set`이 `nil` 호출로 죽음(GC 타이밍 의존) | ✅ 실패 시 detach + setup 전 `_onUpstreamEmit = Void`, `spec.gate` 1 | +| `H-189` | ① | 3 | 🟢 | `Observer.Subscribed`가 초기화되지 않아 `nil`(타입은 `boolean`, Effect는 `false`) | ✅ `false`로, `spec.observer` 1 | +| `H-190` | ① | 2 | 🟢 | `Apply`의 비함수 분기가 검증 없이 `factory:__apply`를 불러 quad 내부 줄을 가리키는 raw 에러 | ✅ `level 2` 검증(형제 생성자들과 같은 급), `spec.state` 10 | | `H-179` | ① | 4 | 🟡 | `state:Apply(factory)`의 파라미터를 유니온 하나로 선언하면 `state:Apply(blocker)`가 strict에서 막힌다(필드가 더 있는 객체는 제네릭 `U` 자리에서 너비 서브타이핑 실패) | ✅ 교집합 오버로드로 확정(`luau-test/done/26-*`, `typing-limits.md` §1②) | | `H-180` | ① | 4 | 🟢 | 폐기된 `state:Block` 표기가 라이브 문서 둘에 남아 있었다(`source-state-plan.md` `_hold` 파생 노드 목록, `blocker-plan.md` 사용 예시) | ✅ 정정 | | `H-178` | ① | 3 | 🟢 | 코드의 사적 필드는 `_` 접두(`_valueEpochMap`·`_emitEpochMap`·`_subs`·`_hold`…)인데 `base/` 의사코드는 `valueEpochMap`처럼 접두 없이 쓴다 — 이름은 1:1이고 밑줄만 다르다 | ✅ 기록만(문서 무변경 — 코드 관례, `H-174` 조립 세부와 같은 급) | @@ -188,6 +198,45 @@ §1②에 기록. `H-94`의 뜻(둘 다 받고 반환은 열어둔다)은 그대로 — 객체 쪽 반환이 `any`라 호출부가 결과 타입을 명시한다(§1① 관례). +### 단위 3·4 `/code-review high` (2026-08-29) — 10건 중 ① 넷(`H-181`/`H-188`/`H-189`/`H-190`) 반영, ② 여섯(`H-182`~`H-187`) §4 + +### `H-181` 🔴 — 인스턴스별 임플의 저장 자리가 인스턴스를 영영 살렸다 (①) + +- **어디서**: `State.luau`/`Observer.luau`/`Effect.luau`의 `implByModule`(weak-key, `module` 키). +- **무엇이**: 값인 임플의 메소드 클로저가 `module`을 캡처 — weak-key 테이블의 값이 키를 되참조하면 + ephemeron이 없는 Luau에선 절대 수거되지 않는다(`relate-plan.md`가 `H-71`로 경고한 바로 그 + 모양). `init.luau`가 `New()`마다 세 Init을 무조건 돌리므로 **아무것도 안 써도** 모든 인스턴스가 + 핀되고, 강한 레지스트리(`Subscribed`)에 든 Observer/Effect 그래프까지 같이 산다. 리뷰 실측. +- **처리**: 임플을 `module._impl`(비공개 필드)에 둔다 — `H-174` (a)안 원문이 *"RunInit 뒤의 + 비공개 필드로 형제 Init에 넘김"*이라 새 모양이 아니다. `module ↔ impl` 순환은 자기완결이라 + 인스턴스를 놓으면 통째로 수거된다(`spec.init.luau` 2). `State.Impl._module` 미사용 필드도 제거. + +### `H-188` 🟡 — 반쯤 만든 `GateNode` (①) + +- **처리**: 검증 실패 시 `self._subs[node] = nil`로 떼고 error; setup이 돌기 전 `_onUpstreamEmit`을 + `Void`로 두어 setup이 던져도 나중 `Set`이 `nil`을 부르지 않게(예외 후 부기는 UB라는 계약은 + 그대로 — 이건 우리가 통제하는 검증 경로만 닫은 것). + +### `H-189` 🟢 / `H-190` 🟢 (①) + +- `Observer` 생성자에 `Subscribed = false`; `Apply`의 객체 분기에 `type(factory.__apply) == + "function"` 검증(`level 2`). 둘 다 형제 코드와 같은 급. + +### `H-182` ~ `H-187` (②) — 코드엔 `-- TODO(H-nnn)` 마커만, 결정은 §4 + +- **`H-182`**: `Destroying` 콜백(cleanup 소진·연결 해제) 뒤 같은 파동에서 `canExecute`가 아직 + 참. 리뷰 실측: 부모 Effect A(dep `count`) / 자식 Effect B의 cleanup이 `count:Set(...)` → + `parent:Destroy()`에 `parent-run:0, child-run, parent-clean, parent-run:-1, child-clean`, + 끝에 `A._cleanup ~= nil`, `A._destroyConn == nil`. `H-160`은 cleanup **실행 중**만 막았다. +- **`H-183`**: 설치 발화(`o.fn(state, o, nil)`) 중 `_rerunRequired`가 아직 참이라 그 안의 + `Subscribe`가 `_catchUp`으로 `fn`을 중첩 재생. `H-147`의 원칙(*"`fn`은 자기 생명주기를 못 + 바꾼다"*)을 Observer에도 적용할지, UB로 문서화할지. +- **`H-184`**: `bindLifetime`의 순서(부기 커밋 → 훅). 훅의 `isRunning` 가드가 던지면 반쯤 묶임. +- **`H-185`**: cleanup 팩. 런타임이 첫 반환만 쓴다. +- **`H-186`**: 교차 인스턴스 dep. 리뷰 실측: `A.Effect(fn, B.Source(0))`가 B의 미주입 스텁 + 에러로 죽는다(A는 다 갖췄는데). +- **`H-187`**: 타입 별칭 이름 넷. + ### `H-180` 🟢 — `state:Block` 잔재 (①) - **처리**: `source-state-plan.md`의 `_hold` 파생 노드 목록과 `blocker-plan.md` 사용 예시에서 @@ -223,6 +272,12 @@ | **`H-168`** | `Ref(T)` vs 무인자 `Ref()` 관용구 | (a) 시그니처 유지, 문서의 빈 호출 관용구를 전부 `Ref<>()`(nil-able 파라미터는 생략 가능)로 고쳐 씀 / (b) `default: T?`, `.Value: T?`(H-167 이전 모양 — `Ref(5).Value`까지 nil 검사 강요) / (c) 두 오버로드 `Ref(T)` ∪ `Ref() -> Ref` | **(a)** | "제네릭 시그니처" 확정(단일 파라미터, 명시 확장으로 넓힘)을 그대로 두고 관용구만 그 규칙에 맞추는 것 — (b)는 문서가 기각한 모양, (c)는 그 절이 기각한 2-파라미터 솔버 문제의 재개방 | (b)가 그렇다 | | **`H-169`** | 재진입 `:Set` 뒤 남은 콜백의 인자 | (a) 블록을 `k(self.Value, self)`로(항상 최신 값, 문서 불변식 그대로) / (b) 문서에 "재진입 시 바깥 파동의 남은 콜백은 자기 파동의 값을 받는다"로 계약화(코드 유지) / (c) 재진입 자체를 금지(error) | **(a)** | 문서 불변식(*"옛 값이 보이는 창이 없다"*)이 이미 (a)를 말하고 있고 한 토큰 차이 — (b)는 인자와 `.Value`가 다른 창을 계약으로 열고, (c)는 새 가드 | 아니오 | | **`H-170`** | resume이 삼키는 에러 | (a) `local ok, err = coroutine.resume(k, self); if not ok then error(err, 0) end` — 대기자 에러를 `:Set` 호출부로 다시 올림 / (b) UB로 문서화(대기자 에러는 사라진다) / (c) 대기자 소진을 `task.spawn`류 주입 op로(Roblox `task.spawn`은 에러를 콘솔로 보냄) | **(a)** | `architecture.md` "예외 안전성 계약"(감싸지 않는다 = 에러는 전파)과 같은 결. 다만 **새 코드 두 줄(re-raise)** 이라 사용자 결정 자리. (c)는 새 주입 op | 아니오 | +| **`H-182`** (`/code-review`, 단위 3) | Destroy 파동 안 cleanup 뒤의 재실행 창 | (a) `Destroying` 콜백이 `_dying = true`를 세우고 `rawRerun`이 `canExecute`와 함께 본다(재바인드 `_bindDestroying`이 내림) / (b) `_destroyConn == nil`이면서 gcconn이 살아 있는 상태를 "죽는 중"으로 판정(새 필드 없음, 단 `Subscribe`로 묶인 핸들과 구분 못 함) / (c) UB 문서화(`fn` 안 `inst:Destroy()` UB의 이웃) | **(a)** | `H-160`이 "cleanup 중"을 홀드로 닫은 것과 같은 결 — 파동 후반은 같은 상태의 연장. 새 플래그 하나 | 아니오 | +| **`H-183`** (`/code-review`, 단위 3) | Observer 설치 발화 재진입 | (a) Observer에도 `_running` 가드 — 네 진입점·`bindLifetime` 훅 첫 줄 `error(…, 2)`(`H-147` 대칭) / (b) 생성자 순서만 바꿔(플래그 내림 → fn) 중첩 재생을 없애고 나머지는 UB / (c) UB 문서화 | **(a)** | `H-147`의 근거("유저 함수가 본인을 죽이고 살린다는 점 자체가 모순")가 Observer에도 그대로 — Effect만 막을 이유가 없다 | 아니오 | +| **`H-184`** (`/code-review`, 단위 1·3) | `bindLifetime` 부기 커밋 vs 훅 가드 순서 | (a) `bindLifetime`이 `isEffect(value)`면 훅 가드를 **먼저** 묻고(예: `value:_assertBindable()` 훅 하나) 통과 후 커밋 / (b) 훅이 던지면 부기를 되감음 / (c) UB(`fn` 안 bind는 `H-147`로 이미 금지) | **(a)** | (b)는 pcall 없이는 못 하고 예외 안전성 계약과 충돌; (c)는 반쯤 묶인 핸들이 남아 이후 bind가 전부 "already bound"로 막히는 실측이라 UB로 두기엔 무겁다 | 아니오 | +| **`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-187`** (`/code-review`, 단위 3·4) | 타입 별칭 이름 넷 | (a) 그대로 승인(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`) / (b) 인라인으로 풀어 이름을 없앰 / (c) 다른 이름 | **(a)** | 시그니처는 문서 그대로이고 이름이 없으면 `quad-types` 선언이 세 배로 길어진다 | 아니오 | | **`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 로 @@ -236,7 +291,7 @@ lazy 하게 읽으면 되는거 아냐? Set 재진입 같은 경우는, 반복 번 불리는 문제를 남겼다. 반영 위치는 위 표의 상태 열. 코드 쪽 잔여 마커: `grep -rn "TODO(H-" quad-base/src` — 이 표의 문항과 1:1이어야 -한다. **[2026-08-28 기준] 마커 0개** — 위 셋은 단위 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 모듈을 막지 않아 코드는 문서 블록 그대로 두고 문항만 올렸다(`H-168`은 코드가 아니라 M8 문서의 관용구 문제). ## §5 이상 없다고 확인한 것 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 2b89b99..13e5969 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -2036,4 +2036,6 @@ Q4(`EffectHandle` 네 진입점 의사코드 — Observer 것 재사용, `Unsubs 구현** — `Observer`(레지스트리 소유, 네 진입점)/`Effect`(`rawRerun`·홀드·cleanup 세 자리· 네 진입점 자기 본문)/`onDestroying` 스텁·mock, spec 17절 ALL PASS. `H-178`(`_` 접두) 기록. **단위 4 구현** — `GateNode`(`State.luau` `:Gate`)/`Blocker.luau`, spec 16절, `H-179`(`Apply` - 타입은 교집합 오버로드)·`H-180`(`:Block` 잔재). M2 체크박스 전부 `[x]`. + 타입은 교집합 오버로드)·`H-180`(`:Block` 잔재). M2 체크박스 전부 `[x]`. 단위 3·4 감사 + 수렴 뒤 `/code-review high` 10건: ① `H-181` 🔴(weak-key 임플 맵이 인스턴스를 영구 핀 → + `module._impl`)·`H-188`~`H-190` 반영, ② `H-182`~`H-187` §4. 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 index eb674ef..29dc0ac 100644 --- a/.claude/session/2026-08-28-03-m2-unit1-common-base.md +++ b/.claude/session/2026-08-28-03-m2-unit1-common-base.md @@ -105,7 +105,15 @@ 발견 `H-179`(`Apply` 파라미터는 교집합 오버로드 — 스파이크 4개, `luau-test/done/26-*`), `H-180`(`:Block` 잔재). ROADMAP M2 체크박스 전부 `[x]`(`H-80` 포함). +- 단위 3·4 감사 루프 수렴(단위 3: 3→4→4→3→0, 단위 4: 4→3→1→0 — 전부 문서 stale·표기· + 마크업 파손, 그중 셋은 내 수정이 만든 것). +- `/code-review high`(단위 3·4) 10건: **① 넷** — `H-181` 🔴 `implByModule`(weak-key, 값이 키 + 캡처 → ephemeron 없어 인스턴스 영구 핀) → `module._impl` 비공개 필드(`H-174` (a) 원문) / + `H-188` 반쯤 만든 `GateNode` / `H-189` `Observer.Subscribed` 초기값 / `H-190` `Apply` 검증. + **② 여섯** `H-182`~`H-187`(Destroy 파동 재실행 창 / Observer 설치 발화 재진입 / `bindLifetime` + 커밋-훅 순서 / cleanup 팩 / 교차 인스턴스 dep / 타입 별칭 이름 넷) → §4 표 + 코드 마커. + ## 다음 -단위 3·4 끝 절차(감사 루프 병렬 2 → `/code-review high` → 탐사자 → §4), 그 뒤 M2 전체 -마무리 보고. M3은 이 자율 구간의 범위 밖(규약 §1은 M2 단위 넷까지). +fable 탐사자(단위 3·4) → 사용자에게 "`round11.md` §4를 보라". §4엔 문항 여섯(권고 전부 (a)). +M3은 이 자율 구간의 범위 밖(규약 §1은 M2 단위 넷까지). diff --git a/quad-base/src/Effect.luau b/quad-base/src/Effect.luau index e4ab321..c924a97 100644 --- a/quad-base/src/Effect.luau +++ b/quad-base/src/Effect.luau @@ -42,9 +42,18 @@ local isSource = Brand.isSource local isRef = Brand.isRef local isEpoch = Brand.isEpoch -local WEAK_KEY_MT = { __mode = "k" } - -local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any } +-- Per-instance 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. +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) module:RunInit(Observer.Init) -- registries live there (`H-99`) @@ -76,6 +85,8 @@ local function createImpl(module: any) self._pending = false self:_consumeCleanup() -- sets `_rerunRequired = true` self._rerunRequired = false -- the ONLY place the flag goes down: fn really runs + -- TODO(H-185): `EffectFn` is typed `-> ...(() -> ())` (H-95 pack) but only the first + -- return is kept; extra cleanups are silently dropped. self._cleanup = self.fn(self) until not self._pending self._running = false @@ -95,6 +106,9 @@ local function createImpl(module: any) end self:_unbindDestroying() -- rebind (portal remount): drop the old connection first — idempotent -- (1) leaf dies → cleanup exactly once. The one hook point (`LP-2`). + -- TODO(H-182): after this callback, `canExecute` stays true for the rest of the Destroy + -- wave (gcconn is cut last), so a dep change later in the wave re-runs fn on the + -- dying leaf and stores a cleanup nothing consumes. self._destroyConn = module.onDestroying(inst, function() -- injected op, read at call time self:_unbindDestroying() self:_consumeCleanup() @@ -207,6 +221,8 @@ local function createImpl(module: any) -- (0) deps validation — once, in the constructor. `select("#")` so a nil hole -- does not silently truncate the list. + -- TODO(H-186): no check that a dep belongs to THIS quad instance — a cross-instance dep + -- is gated by the dep's backend (`d:Observer` resolves through its own impl). local seen = {} for i = 1, select("#", ...) do local d = (select(i, ...)) @@ -243,11 +259,12 @@ local function createImpl(module: any) self._deps[d] = o -- strong owner = Effect o:WeakSubscribe() -- the global registry side is weak end - -- seeding by "is it an Epoch" — Source/Ref are, State is not (§4 rule) + -- seeding by "is it an Epoch" — Source/Ref are (`Sync`), a State hands over + -- what it tracks (`_track` → `TrackFrom`, same polymorphic hook `_recompute` uses) if isEpoch(d) then self._epochs:Sync(d) else - self._epochs:TrackFrom(d._valueEpochMap) + d:_track(self._epochs) end end @@ -262,11 +279,11 @@ local function createImpl(module: any) end local function Init(module: any) - implByModule[module] = createImpl(module) + implsOf(module).Effect = createImpl(module) end local function implFor(module: any) - local impl = implByModule[module] + local impl = implsOf(module).Effect if impl == nil then error("Effect: Effect.Init(module) has not run for this quad instance", 1) end diff --git a/quad-base/src/EpochMap.luau b/quad-base/src/EpochMap.luau index 1fb8814..b786db6 100644 --- a/quad-base/src/EpochMap.luau +++ b/quad-base/src/EpochMap.luau @@ -27,7 +27,7 @@ local Brand = require("./Brand") local QuadTypes = require("../roblox_packages/quad_types") type Epoch = QuadTypes.Epoch -export type EpochSet = { [Epoch]: true } +export type EpochSet = QuadTypes.EpochSet export type EpochMap = { Update: (self: EpochMap, from: Epoch | EpochSet) -> boolean, Peek: (self: EpochMap, from: Epoch | EpochSet) -> boolean, diff --git a/quad-base/src/Observer.luau b/quad-base/src/Observer.luau index 286ca91..fd864b5 100644 --- a/quad-base/src/Observer.luau +++ b/quad-base/src/Observer.luau @@ -39,7 +39,18 @@ local ObserverBrand = Brand.ObserverBrand local WEAK_KEY_MT = { __mode = "k" } -local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any } +-- Per-instance 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. +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 Impl = {} @@ -132,8 +143,11 @@ local function createImpl(module: any) fn = fn or alwaysObserve, _state = state, -- strong: the handle holds its upstream (`_hold` equivalent, `H-110`) _rerunRequired = true, + Subscribed = false, -- the shared flag both handle types carry (`H-111`) }, Impl) ObserverBrand:register(o) + -- TODO(H-183): no re-entry guard — `fn` calling Subscribe/bindLifetime on `self` during + -- this install fire replays through `_catchUp` (Effect has `isRunning`, H-147). o.fn(state, o, nil) -- (1) fire once at registration — no source (`nil`) o._rerunRequired = false -- (2) the install fire lowers the flag state._subs[o] = true -- (3) only THEN join the subscriber set — reversed, (1) Setting its own @@ -144,11 +158,11 @@ local function createImpl(module: any) end local function Init(module: any) - implByModule[module] = createImpl(module) + implsOf(module).Observer = createImpl(module) end local function implFor(module: any) - local impl = implByModule[module] + local impl = implsOf(module).Observer if impl == nil then error("Observer: Observer.Init(module) has not run for this quad instance", 1) -- invariant; callers pull it in first end diff --git a/quad-base/src/State.luau b/quad-base/src/State.luau index c772721..e8f94f7 100644 --- a/quad-base/src/State.luau +++ b/quad-base/src/State.luau @@ -46,6 +46,7 @@ local Brand = require("./Brand") local EpochMap = require("./EpochMap") local Observer = require("./Observer") +local Void = require("./Void") local QuadTypes = require("../roblox_packages/quad_types") export type StateData = QuadTypes.StateData @@ -60,7 +61,18 @@ local isModifier = Brand.isModifier local WEAK_KEY_MT = { __mode = "k" } -- One implementation per quad instance (`H-174`). -local implByModule = setmetatable({}, WEAK_KEY_MT) :: { [any]: any } +-- Per-instance 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. +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) module:RunInit(Observer.Init) -- `:Observer` needs the per-instance Observer impl (idempotent pull, `H-177`) @@ -68,7 +80,6 @@ local function createImpl(module: any) local Impl = {} Impl.__index = Impl - Impl._module = module -- read lazily by later units, never captured -- ── propagation loop ────────────────────────────────────────────── -- Shared by every node type that has `_subs` (Source nodes too). @@ -240,10 +251,12 @@ local function createImpl(module: any) end local node = newNode({ self }, passThrough, GateImpl) -- StateBrand + seeding + `_hold` like any node node._withheld = newWithheld() + node._onUpstreamEmit = Void -- until setup returns: a throwing setup must not leave a nil callee local onUpstreamEmit = (setup :: any)(function(commit: boolean?): boolean return node:_flush(commit) end) if type(onUpstreamEmit) ~= "function" then + self._subs[node] = nil -- detach the half-built node before reporting (no orphan in `_subs`) error("State: Gate setup must return the onUpstreamEmit function", 2) end node._onUpstreamEmit = onUpstreamEmit -- strong: the policy closure (and, through it, the Blocker handle) @@ -263,6 +276,9 @@ local function createImpl(module: any) if type(factory) == "function" then return (factory :: any)(self) end + if type(factory) ~= "table" or type(factory.__apply) ~= "function" then + error("State: Apply factory must be a function or an object with an __apply method", 2) + end return (factory :: any):__apply(self) -- method form: `self` there is the factory object (`H-158`) end @@ -270,11 +286,11 @@ local function createImpl(module: any) end local function Init(module: any) - implByModule[module] = createImpl(module) + implsOf(module).State = createImpl(module) end local function implFor(module: any) - local impl = implByModule[module] + local impl = implsOf(module).State if impl == nil then error("State: State.Init(module) has not run for this quad instance", 1) -- invariant check; every caller pulls it in first (`H-177`) end diff --git a/quad-base/test/mock.luau b/quad-base/test/mock.luau index feeeee5..560a883 100644 --- a/quad-base/test/mock.luau +++ b/quad-base/test/mock.luau @@ -348,6 +348,9 @@ local function installLifetime(quad: any) end claim(inst) + -- TODO(H-184): the binding is committed below BEFORE `_bindDestroying` runs its `isRunning` + -- guard; if that guard throws, the Effect stays bound with no Destroying connection + -- (order transcribed from lifecycle-pattern.md (1) — quad-roblox inherits it). local gchold = InstData:GetWeak(inst, "gchold") gchold[value] = true -- 강참조: inst가 사는 동안 value 생존 보장(계약 1) -- value가 자기 홀더/생존 판정 근거를 직접 들고 있게 함(계약 2). 둘 다 weak. diff --git a/quad-base/test/spec.gate.luau b/quad-base/test/spec.gate.luau index 1330bf4..5e481de 100644 --- a/quad-base/test/spec.gate.luau +++ b/quad-base/test/spec.gate.luau @@ -76,6 +76,14 @@ do end) end) assert(not bad2, "setup must return the onUpstreamEmit function") + s:Set(3) -- [H-188] the half-built node was detached — this must not crash on a nil callee + local left = 0 + for sub in pairs((s :: any)._subs) do + if sub ~= g then + left += 1 + end + end + assert(left == 0, "a failed Gate left nothing in the upstream's subscriber set") print("PASS") end diff --git a/quad-base/test/spec.init.luau b/quad-base/test/spec.init.luau index 500718f..3313b73 100644 --- a/quad-base/test/spec.init.luau +++ b/quad-base/test/spec.init.luau @@ -6,6 +6,7 @@ ]] local Quad = require("../src") +local mock = require("./mock") local QuadTypes = require("../roblox_packages/quad_types") type Quad = QuadTypes.Quad @@ -30,5 +31,24 @@ do print("PASS") end +print() +print("=== 2. [H-181] New() 인스턴스는 참조를 놓으면 수거된다 — 임플을 모듈 비공개 필드에 두므로 순환이 자기완결 ===") +do + local collectgarbage = (_G :: any).collectgarbage :: () -> () + local weak = setmetatable({}, { __mode = "v" }) :: { any } + local function make() + local q = Quad.New() + mock.installLifetime(q) + local s = q.Source(1) + q.Effect(function() end, s):Subscribe() -- 강한 레지스트리까지 채워도 인스턴스째 죽어야 한다 + weak[1] = q + end + make() + collectgarbage() + collectgarbage() + assert(weak[1] == nil, "a dropped quad instance is collected (no module-keyed weak map pinning it)") + print("PASS") +end + print() print("=== ALL PASS ===") diff --git a/quad-base/test/spec.observer.luau b/quad-base/test/spec.observer.luau index 13529ca..87eea08 100644 --- a/quad-base/test/spec.observer.luau +++ b/quad-base/test/spec.observer.luau @@ -30,6 +30,7 @@ do assert((o :: any)._state == s, "observer holds its State strongly") assert((s :: any)._subs[o] == true, "joined the subscriber set after the install fire") assert((o :: any)._rerunRequired == false, "install fire lowered the hold flag") + assert(o.Subscribed == false, "Subscribed starts false (shared flag, same as Effect — H-189)") -- 설치 발화가 자기 State를 Set해도 플래그가 서지 않는다(순서 계약) local s2 = Source(0) local o2 = s2:Observer(function(target, _, from) diff --git a/quad-base/test/spec.state.luau b/quad-base/test/spec.state.luau index b8788ef..c4737dd 100644 --- a/quad-base/test/spec.state.luau +++ b/quad-base/test/spec.state.luau @@ -260,6 +260,13 @@ do end local viaObj = s:Apply(factory) -- 객체 오버로드: 추가 필드(`calls`)가 있어도 통과해야 한다 assert(viaObj == s and factory.calls == 1, "__apply is called as a METHOD on the factory object (H-158)") + for _, bad in { {} :: any, 5 :: any, nil :: any } do + local ok, err = pcall(function() + (s :: any):Apply(bad) -- `any` 인자는 오버로드를 못 고르므로 리시버 쪽을 캐스트 + end) + assert(not ok and string.find(tostring(err), "__apply", 1, true) ~= nil, "non-factory rejected with level 2: " .. tostring(err)) + assert(string.find(tostring(err), "spec.state.luau", 1, true) ~= nil, "points at the caller") + end print("PASS") end diff --git a/quad-types/src/init.luau b/quad-types/src/init.luau index 2d382c2..cd1df92 100644 --- a/quad-types/src/init.luau +++ b/quad-types/src/init.luau @@ -56,6 +56,8 @@ export type Ref = { -- (`.claude/base/source-state-plan.md` "self 인자도 lazy 핸들로 통일"). export type StateData = { Get: (self: StateData) -> T } +-- TODO(H-187): 아래 타입 별칭 이름 넷(`ObserverFn`/`EffectFn`/`GateEmit`/`GateSetup`)은 `base/`에 없는 +-- 이름이다 — 시그니처는 문서 그대로이나 이름은 구현이 붙였다(§4 문항). -- `Observer` — 값을 안 실어주는 leaf 구독(`source-state-plan.md` "`state:Observer(fn)`"). -- `fn(targetState, self, emitFrom?)`: 3번째가 출처(`Epoch` | 집합), 설치·캐치업 발화는 `nil`. -- 네 진입점은 `lifecycle-pattern.md` (2) — Weak가 프리미티브, `.Subscribed`는 강·약 공용.