qa: M0 luau-test 스파이크 05 재작성 통과 + 21 신규(Store 미선언 키) 반영

05는 "emit은 항상 전파, 재계산만 :Get() 캐시로 dedup" 현행 모델로
재작성해 rewrite-required/ -> done/ 이동. 21은 todos.md 00번이 요구하던
"Store 미선언 키 타입 에러" 확인 신규 스파이크 — ProcessStoreType 결과
타입이 미선언 키 접근을 정확히 TypeError로 거부함을 확인, store-plan.md의
"아마" 표시를 해소. STATUS.md/README.md/todos.md 텍스트도 같이 갱신.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 14:24:19 +09:00
parent 205af32da4
commit 0c4c4a0537
No known key found for this signature in database
7 changed files with 270 additions and 202 deletions

View file

@ -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<T>` 레코드 필드 합성)에
이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는
경우는 `: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`에 남아있던

View file

@ -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<State<T>>` **정상 동작**, 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<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `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번 |
## 공통 유틸리티

View file

@ -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<Input>`이 정확히 `{ty: Source<string>, count: Source<number>}` 구조를 만족, 음성 대조군 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건

View file

@ -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하게 넘기는
부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]

View file

@ -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<T>` 합성)을 그대로 재사용 — 새로 검증하는
건 그 결과 타입이 **인덱서 없는 순수 레코드**라 미선언 프로퍼티
접근을 실제로 거부하는지 하나뿐.
실행: `luau-analyze 21-type-store-undeclared-key-rejected.luau` — 양성
경로(선언된 키 3개)는 클린, 미선언 키 접근 2건(읽기/메소드 호출)만
TypeError로 걸려야 함.
]]
export type Source<T> = {
Get: (self: Source<T>) -> T,
Set: (self: Source<T>, 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<Input>
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() 호출까지 안 감)

View file

@ -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 온톨로지"
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
정확성"만 검증 대상.
]]

View file

@ -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