diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index a682d70..93cd863 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -58,18 +58,19 @@ State를 만족함" 절). 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. -- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를 - 무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라 - 타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는 - 타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 - 설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는 - 이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입 - 에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로 - 확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인 - 상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지. - `luau-test/done/16-*`(type function으로 `Store` 레코드 필드 합성)에 - 이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는 - 경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. +- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이 + 오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고, + 방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field: + type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입 + 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉 + `Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이 + 합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다. + **[2026-08-19 실측 완료]** 맞았음 — + `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 + `ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로 + 접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키 + 3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는 + `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. - **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index d625c57..9822769 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -71,7 +71,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | -| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-14] 전제가 뒤집혀 재작성 대기** — 옛 모델("이미 dirty면 전파 중단")을 검증 중이었으나 그게 폐기됨, 이제 확인할 건 "emit은 항상 전파되고 중복 재계산은 캐시로만 막힌다" | ROADMAP M0-1 | +| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | ROADMAP M0-1 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | @@ -87,6 +87,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | +| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 22ca682..2d1c922 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,10 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트 +> 마지막 갱신: 2026-08-19 — M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 +> `05`를 현행 모델("emit은 항상 전파, 재계산만 캐시로 dedup")로 재작성해 +> 통과 → `rewrite-required/` → `done/` 이동, `todos.md` 00번이 요구하던 +> "Store 미선언 키 타입 에러" 확인도 신규 스파이크 `21`로 완료 → `done/` +> 직행. 직전 갱신은 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트 > (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과, > `rewrite-required/` → `done/` 이동. 근거: > `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. @@ -23,9 +27,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 5 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -50,7 +54,7 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 (5건) **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 @@ -69,7 +73,6 @@ |---|---|---| | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | -| `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | @@ -83,18 +86,18 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (14건) +## ✅ `done/` — 통과 or 판정 끝 (16건) -**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 -`04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위 -`rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시 -통과였다는 사실 자체는 유효): +**런타임 13개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는 +검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19] +`05`는 현행 모델로 재작성해 다시 여기로 돌아옴**: | 파일 | 확인된 것 | |---|---| | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | +| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | @@ -111,6 +114,7 @@ | `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) | | `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 | | `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType`이 정확히 `{ty: Source, count: Source}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | +| `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16`의 `ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 | ### 특별히 중요한 통과 3건 diff --git a/.claude/luau-test/done/05-store-state-diamond-propagation.luau b/.claude/luau-test/done/05-store-state-diamond-propagation.luau new file mode 100644 index 0000000..6f65677 --- /dev/null +++ b/.claude/luau-test/done/05-store-state-diamond-propagation.luau @@ -0,0 +1,175 @@ +--[[ + 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() + 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. + + 배경: ROADMAP.md M0 1번째 항목. **[재작성, 2026-08-19]** 옛 버전은 + "이미 dirty면 더 아래로 전파하지 않는다"를 검증했는데, 그 규칙은 + 확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 모순돼 역전됨 + (`archive/invalidate-dedup-propagation-reversed.md`). 이 버전은 + 현행 모델을 검증: + 1. emit(invalidate 신호)은 자기 invalid 상태와 무관하게 *항상* + 전파된다 — 이미 dirty인 노드도 다시 전파를 계속함(early-stop 없음) + 2. 중복 **재계산**은 오직 :Get() 시점 캐시로만 막힌다(dirty 플래그가 + "내 캐시가 낡았다" 표시일 뿐, 전파 게이트가 아님) + 3. :Get()을 안 부르는 Observer는 다이아몬드에서 한 번의 source 변경마다 + 경로 수만큼(여기선 2번) 계속 운다 — 옛 모델처럼 두 번째부터 + 침묵하면 안 됨(핵심 음성 대조군) + + 다이아몬드 구조: + source + / \ + stateA stateB + \ / + stateC (:With(stateA, stateB):Compute(...)) + + 실행: `luau 05-store-state-diamond-propagation.luau` +]] + +local function makeSource(initial) + local self = { value = initial, listeners = {} } + function self:Get() + return self.value + end + function self:Set(v) + self.value = v + self:Invalidate() + end + function self:Invalidate() + -- source 자신은 dirty 개념이 없음(항상 최신) — 리스너에게 신호만 쏨 + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + return self +end + +local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 } +local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 } + +local function makeState(name, deps, computeFn) + local self = { + name = name, + dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty + cached = nil, + listeners = {}, + } + function self:Invalidate() + invalidateCallCount[name] += 1 + -- 핵심: invalid는 "내 캐시가 낡았다"는 표시일 뿐 전파 게이트가 아님 + -- — 이미 dirty였어도 무조건 다시 세팅하고 무조건 아래로 전파한다. + self.dirty = true + print(string.format(" [%s] invalidate #%d — 항상 전파", name, invalidateCallCount[name])) + for _, fn in self.listeners do + fn() + end + end + function self:OnInvalidate(fn) + table.insert(self.listeners, fn) + end + function self:Get() + if self.dirty then + computeCallCount[name] += 1 + print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name])) + local args = {} + for i, d in deps do + args[i] = d:Get() + end + self.cached = computeFn(table.unpack(args)) + self.dirty = false + else + print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name)) + end + return self.cached + end + for _, d in deps do + d:OnInvalidate(function() + self:Invalidate() + end) + end + return self +end + +local source = makeSource(1) +local stateA = makeState("stateA", { source }, function(v) + return v + 10 +end) +local stateB = makeState("stateB", { source }, function(v) + return v + 100 +end) +local stateC = makeState("stateC", { stateA, stateB }, function(a, b) + return a + b +end) + +print("=== 1. 최초 Get() — 전부 계산돼야 함 ===") +print("stateC:Get() =", stateC:Get()) +print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC) +assert( + computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1, + "최초 계산 횟수가 예상과 다름" +) + +print() +print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===") +print("stateC:Get() =", stateC:Get()) +assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)") + +print() +print("=== 3. source:Set() -> 다이아몬드 invalidate 전파(항상 전파, early-stop 없음) ===") +source:Set(2) +print( + "invalidate 호출 횟수(stateC):", + invalidateCallCount.stateC, + "(stateA 경로 1번 + stateB 경로 1번 = 2번이어야 함 — 두 번 다 끝까지 실행돼야 함, 조기 종료 없음)" +) +assert(invalidateCallCount.stateC == 2, "다이아몬드 두 경로 모두 stateC까지 끝까지 전파돼야 함(early-stop 없음)") + +print() +print("=== 4. invalidate 이후 Get() — 재계산은 딱 1번(중복 재계산 방지는 캐시 몫) ===") +print("stateC:Get() =", stateC:Get()) +print( + "compute 호출 횟수(stateC):", + computeCallCount.stateC, + "(2여야 함 — 1차 계산 + 이번 재계산 1번. invalidate가 2번 왔다고 재계산도 2번이면 버그)" +) +assert(computeCallCount.stateC == 2, "invalidate가 2번 와도 재계산은 캐시로 1번만 막혀야 함") + +print() +print("=== 5. :Get()을 안 부르는 Observer — 매 source 변경마다 계속 울어야 함(옛 모델 음성 대조군) ===") +do + local observerFireCount = 0 + -- state:Observer(fn)을 흉내: :Get()을 절대 호출하지 않는 순수 리스너 + stateC:OnInvalidate(function() + observerFireCount += 1 + end) + + for i = 1, 3 do + source:Set(2 + i) + end + + print("3번의 source:Set() 이후 Observer 발화 횟수:", observerFireCount, "(다이아몬드라 회당 2번씩, 6이어야 함)") + assert( + observerFireCount == 6, + "Get()을 안 부르는 Observer가 계속 안 울면(예: 2 근처에서 멈추면) 옛 '이미 dirty면 전파 중단' 모델로 회귀한 것" + ) +end + +print() +print("모든 assert 통과 — '항상 전파 + Get() 시점 캐시 dedup' 모델이 예상대로 동작함") + +--[[ + 확인 포인트: + 1. 위 assert들이 전부 통과하는가. + 2. 3번 절에서 stateC의 invalidate 로그가 "이미 dirty" 같은 조기 종료 + 문구 없이 매번 "항상 전파"로 끝까지 도는지 눈으로 확인. + 3. 5번 절이 이 파일에서 가장 중요한 회귀 방지 장치 — 옛(역전된) 모델을 + 그대로 되돌려 넣으면(Invalidate 안에 `if self.dirty then return end`를 + 부활시키면) observerFireCount가 6이 아니라 2에서 멈추고 이 assert가 + 실패해야 정상. + 4. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 + 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 + 부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 + 정확성"만 검증 대상. +]] diff --git a/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau b/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau new file mode 100644 index 0000000..2081222 --- /dev/null +++ b/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau @@ -0,0 +1,58 @@ +--!strict +--[[ + 검증 대상: `Store<{field: T}>`로 선언되지 않은 이름에 dot-access하면 + 타입 시간에 실제로 거부되는지 — `.claude/base/store-plan.md` "Store = + Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" + 항목, 사용자 원문: *"Store<{ field: type }> 상 없는 네임에는 타입 + 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 + 설계가 그럴것이예요"* — "아마"라 M0에서 실측 확인하라고 명시됨. + `todos.md` 00번 항목 / `ROADMAP.md` M0 목록. + + `16-type-store-key-typefunction.luau`가 이미 검증한 `ProcessStoreType` + (type function 기반 `Store` 합성)을 그대로 재사용 — 새로 검증하는 + 건 그 결과 타입이 **인덱서 없는 순수 레코드**라 미선언 프로퍼티 + 접근을 실제로 거부하는지 하나뿐. + + 실행: `luau-analyze 21-type-store-undeclared-key-rejected.luau` — 양성 + 경로(선언된 키 3개)는 클린, 미선언 키 접근 2건(읽기/메소드 호출)만 + TypeError로 걸려야 함. +]] + +export type Source = { + Get: (self: Source) -> T, + Set: (self: Source, value: T) -> (), +} + +type function WrapStore(ty: type): type + local result = types.newtable() + result:setproperty(types.singleton("Get"), types.newfunction({ head = { result } }, { head = { ty } })) + result:setproperty(types.singleton("Set"), types.newfunction({ head = { result, ty } }, { head = {} })) + return result +end + +type function ProcessStoreType(ty: type): type + local props = ty:properties() :: { [type]: { read: type?, write: type? } } + local result = types.newtable() + for i, v in props do + local fieldType = v.read or v.write + if fieldType then + result:setproperty(i, WrapStore(fieldType)) + end + end + return result +end + +type Input = { ty: string, count: number } +type Processed = ProcessStoreType + +local processed: Processed = nil :: any + +-- 양성 경로 — 선언된 키는 정상 접근 +local okTy: string = processed.ty:Get() +local okCount: number = processed.count:Get() +processed.ty:Set("hi") +print(okTy, okCount) + +-- 검증 포인트 — 선언 안 된 키에 접근하면 타입 에러가 나야 함 +print(processed.doesNotExist) -- 반드시 에러: 'doesNotExist'는 Processed에 없는 프로퍼티 +processed.alsoMissing:Get() -- 반드시 에러: 'alsoMissing'도 없는 프로퍼티(체이닝된 :Get() 호출까지 안 감) diff --git a/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau b/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau deleted file mode 100644 index 7e587b0..0000000 --- a/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau +++ /dev/null @@ -1,174 +0,0 @@ ---[[ - !!! [2026-08-14] 재작성 대기 — 아래 코드는 폐기된 모델을 검증 중 !!! - - 이 파일은 "이미 dirty로 표시된 노드는 더 아래로 전파하지 않는다"를 - assert하는데, 그 규칙이 확정된 Observer 계약(fn이 :Get()을 안 불러도 - 됨)과 모순돼 역전됐음. 통과했다고 해서 현행 설계를 검증한 게 아님. - - 역전 근거/영향 범위: archive/invalidate-dedup-propagation-reversed.md - - 현행 모델: base/bind-system-plan.md "전파 모델 확정" / - "다이아몬드 의존성은 무엇이 푸는가" - - 재작성 방향(STATUS.md와 동일): - 1. emit은 자기 invalid 상태와 무관하게 *항상* 전파되는가 - 2. 중복 재계산은 :Get() 시점 캐시로만 막히는가 (재계산 1회는 그대로 유효) - 3. :Get()을 안 부르는 Observer가 매 변경마다 계속 울리는가 - — 옛 모델에선 두 번째부터 침묵했으므로 이게 딱 맞는 음성 대조군 - - --- 이하 원문(옛 모델) --- - - 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() - 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. - - 배경: ROADMAP.md M0 1번째 항목 "Store/State push-invalidate -> - pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 - 포함 — 이미 invalid면 전파 중단되는지)". - - 다이아몬드 구조: - source - / \ - stateA stateB - \ / - stateC (:With(stateA, stateB):Compute(...)) - - 검증할 것 두 가지: - 1. source가 바뀌면 invalidate 신호가 stateA/stateB를 거쳐 stateC까지 - 전파되는데, "이미 dirty로 표시된 노드는 더 이상 아래로 전파하지 - 않는다"는 방어가 있어야 다이아몬드에서 stateC가 두 경로로 두 번 - invalidate 신호를 받아도 문제없이 처리됨(도달 자체는 두 번 일어나되, - 두 번째는 즉시 조기 종료돼야 함). - 2. stateC:Get()을 실제로 호출했을 때, compute 함수가 정확히 1번만 - 실행되는가(다이아몬드 때문에 stateA 경로/stateB 경로 각각 한 번씩 - 총 2번 이상 실행되면 버그). - - 실행: `luau 05-store-state-diamond-propagation.luau` -]] - -local function makeSource(initial) - local self = { value = initial, listeners = {} } - function self:Get() - return self.value - end - function self:Set(v) - self.value = v - self:Invalidate() - end - function self:Invalidate() - -- source 자신은 dirty 개념이 없음(항상 최신) — 그냥 리스너에게 신호만 쏨 - for _, fn in self.listeners do - fn() - end - end - function self:OnInvalidate(fn) - table.insert(self.listeners, fn) - end - return self -end - -local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 } -local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 } - -local function makeState(name, deps, computeFn) - local self = { - name = name, - dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty - cached = nil, - listeners = {}, - } - function self:Invalidate() - invalidateCallCount[name] += 1 - if self.dirty then - -- 핵심: 이미 dirty면 더 아래로 전파하지 않음(다이아몬드 방어) - print(string.format(" [%s] 이미 dirty -> 전파 중단", name)) - return - end - print(string.format(" [%s] dirty로 표시, 아래로 전파", name)) - self.dirty = true - for _, fn in self.listeners do - fn() - end - end - function self:OnInvalidate(fn) - table.insert(self.listeners, fn) - end - function self:Get() - if self.dirty then - computeCallCount[name] += 1 - print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name])) - local args = {} - for i, d in deps do - args[i] = d:Get() - end - self.cached = computeFn(table.unpack(args)) - self.dirty = false - else - print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name)) - end - return self.cached - end - for _, d in deps do - d:OnInvalidate(function() - self:Invalidate() - end) - end - return self -end - -local source = makeSource(1) -local stateA = makeState("stateA", { source }, function(v) - return v + 10 -end) -local stateB = makeState("stateB", { source }, function(v) - return v + 100 -end) -local stateC = makeState("stateC", { stateA, stateB }, function(a, b) - return a + b -end) - -print("=== 1. 최초 Get() — 전부 계산돼야 함 ===") -print("stateC:Get() =", stateC:Get()) -print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC) -assert( - computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1, - "최초 계산 횟수가 예상과 다름" -) - -print() -print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===") -print("stateC:Get() =", stateC:Get()) -assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)") - -print() -print("=== 3. source:Set() -> 다이아몬드 invalidate 전파 ===") -source:Set(2) -print( - "invalidate 호출 횟수(stateC):", - invalidateCallCount.stateC, - "(stateA 경로 1번 + stateB 경로 1번 = 2번 호출은 정상, 단 2번째는 즉시 'already dirty'로 중단돼야 함)" -) - -print() -print("=== 4. invalidate 이후 Get() — 정확히 1번만 재계산되는가 ===") -print("stateC:Get() =", stateC:Get()) -print( - "compute 호출 횟수(stateC):", - computeCallCount.stateC, - "(2여야 함 — 1차 계산 + 이번 재계산, 3 이상이면 다이아몬드 중복 재계산 버그)" -) -assert(computeCallCount.stateC == 2, "다이아몬드 의존성 때문에 stateC가 여러 번 재계산됨 (버그)") - -print() -print("모든 assert 통과 — 다이아몬드 전파/재계산 모델이 예상대로 동작함") - ---[[ - 확인 포인트: - 1. 위 assert들이 전부 통과하는가(하나라도 실패하면 error로 죽고 스택 - 트레이스가 찍힘 — 그대로 알려줄 것). - 2. invalidateCallCount.stateC가 정확히 2(stateA 경로, stateB 경로 각각 - 1번씩 도달)이지만, 그 중 두 번째 호출은 "이미 dirty" 로그로 조기 - 종료되는지 눈으로 확인. - 3. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 - 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 - 부분(.claude/base/bind-system-plan.md "Store/State/Source 온톨로지" - 절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 - 정확성"만 검증 대상. -]] diff --git a/.claude/todos.md b/.claude/todos.md index 7cd86ff..cdaf1a9 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -43,9 +43,9 @@ 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` 3번에도 올라가 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 - 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 - 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, - 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: + 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시 + 검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/` + 문서에도 ⚠️로 표시돼 있다: - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 @@ -71,8 +71,11 @@ - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. - - **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`) - — M0에서 실측 확인. + - **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가 + 나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`, + `ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키 + 접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store = + Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요. 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy