audit(lifecycle): partial gcconn trick verification, close Relate cycle spike gap

User confirmed the two Roblox-engine-dependent assumptions behind the
gcconn trick (ClassName signal never fires, Connection.Connected flips
synchronously on Destroy) via a Studio script; record what's verified vs
still open in a new audit/ folder and document the GC-trigger technique
used. While re-auditing the corpus, found relate-plan.md's ephemeron/
mutual-cycle claim was never actually run in Luau, and CLAUDE.md's
open-questions list still listed State as unresolved after it was
finalized in session 20 — add spike 18 and fix the stale entry. A
background sweep synced .claude/README.md's summary rows against
sessions 8-21 and added spikes 19/20 for newer ownership/refcount
mechanisms lacking coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGWwZ8khc3Zq7DAnd4Uw6a
This commit is contained in:
qwreey 2026-08-13 10:09:47 +09:00
parent 2688409ebd
commit 5e6498a3e1
Signed by: qwreey
GPG key ID: D28DB79297A214BD
11 changed files with 876 additions and 27 deletions

View file

@ -15,7 +15,8 @@
| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 |
| `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]``[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]``[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) |
| `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) |
| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. 아직 결과 미확인 — `luau-test/README.md`가 색인 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13]** `10`의 일부가 처음으로 실측 확인됨(부분) — 확인된 결과 자체는 `audit/`에 기록, 이 폴더는 스크립트/색인만 유지. 나머지 16개+`10`의 남은 부분은 여전히 결과 미확인 — `luau-test/README.md`가 색인 |
| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김 |
| `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 | | `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 |
| `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | | `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 |
@ -27,20 +28,20 @@
| 문서 | 내용 | | 문서 | 내용 |
|---|---| |---|---|
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) | | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`) | | `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder``A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With``Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute``-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌) |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)``updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List``reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)``updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List``reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added``string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존) |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) |
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 |
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |
| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | | `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 |
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)``state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)``state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 |
| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) |
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md` | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지 |
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract``v==nil` 가드 추가(더 이상 "retract 불필요" 아님, no-op일 뿐), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지) | | `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기 |
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)``GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 |
| `relate-plan.md` | **[2026-08-08 신설]** `Relate``inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md``bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot``kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate``inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md``bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot``kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 |
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)``Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
@ -61,10 +62,10 @@
| `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 | | `debug-tooling-plan.md` | 실물 Instance→코드 위치 역추적 Studio 플러그인(`quad-debug`) — 채널 실현 가능성(BindableEvent/Function 크로스 컨텍스트)까지 실측 검증 완료, 세부 API 이름·구현만 남음 | 하 — 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정, base 설계 시 훅 확장 지점만 고려 |
| `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 | | `documentation-plan.md` | 문서 사이트 구조(초심자/api/심화/`quadnomicon` 4축, 백엔드별 트랙 분리) + UI 네이밍 컨벤션·Store 부작용 패턴·권장 이벤트 핸들링 3개 세부 문서 뼈대 | 하 — 착수 시점 미정, 구조/스코프만 합의된 상태 |
| `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 | | `documentation-content-map.md` | 위 4축에 실제로 뭘 채울지 `base/` 전체를 초심자/api/심화/skip으로 서베이한 콘텐츠 맵 — 초심자 core loop 목차 초안 포함 | 하 — 문서화 착수 시점의 목차/우선순위표로 쓸 것 |
| `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 진짜 불리한 점 중 고칠 만한 것 3개, 못 고치는 트레이드오프 정리 | 하 — 사용자 검토 후 반영 여부 결정 대기 | | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 |
| `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 |
| `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1(M0~M4 착수 전 확인 권장) + 11개 우선순위2 + 2개 단순화후보 | 상 — M0 착수 전 최소 우선순위1 항목 확인 권장 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 |
| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 | | `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 |
| `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 |
## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요

View file

@ -0,0 +1,92 @@
# gcconn 트릭 — 부분 실측 검증 결과
**상태**: 부분 확인(2026-08-13). `luau-test/10-roblox-studio-checks.server.luau`
공식 스크립트가 아니라 사용자가 별도로 작성한 저수준 검증 스크립트로
확인됨 — `bindLifetime`/`canBound`/`unbindLifetime` 자체의 이중 바인딩
로직(A-1/A-2)과 Part B/C는 여전히 미검증. **아래 "아직 확인 안 된 것"이
전부 해소되기 전까진 공식 `10` 파일을 완주한 것으로 치지 말 것.**
## 배경
`base/lifecycle-pattern.md``bindLifetime`/`canExecute` 구현은 두 가지
Roblox 엔진 의존 가정에 기대고 있고, 둘 다 문서만으로는 확정할 수 없어
Studio 실측이 필요했음:
1. `GetPropertyChangedSignal("ClassName")`은 절대 발화하지 않는다(엔진이
`ClassName`을 변경 불가능한 프로퍼티로 취급하므로 — 콜백 클로저가
`gchold`를 업밸류로 캡처해 살려두는 용도로만 씀).
2. `RBXScriptConnection.Connected``inst:Destroy()` 시점에 GC를 기다릴
필요 없이 동기적으로 즉시 `false`로 바뀐다 — `canExecute`가 이 값
하나로 생존을 판단하므로, 이게 틀리면 설계 전체가 무너짐.
`luau-test/README.md`는 이 스파이크(`10`의 A 섹션)가 실패하면(신호 발화
또는 A-2 실패) "gcconn 트릭 전체를 재검토해야 하는 심각한 발견"이라고
못 박아둔 상태였음.
## 실측 방법
Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아님) — 요지:
- `weak = setmetatable({}, {__mode="v"})`에 target을 담아 GC 생존 여부 관찰.
- `inst:GetPropertyChangedSignal("ClassName"):Connect(...)`로 gcconn
트릭과 동일한 신호를 구독, 콜백 클로저가 `target`을 업밸류로 캡처.
- Connection을 변수로 안 잡는 경우(Test 1)와 잡는 경우(Test 2) 둘 다 확인.
- `triggerGC()`로 GC 완료를 간접 관찰(기법 상세는
`luau-test/gc-trigger-helper.server.luau`).
## 확인된 것
1. **`ClassName` PropertyChangedSignal 미발화** — 6 epoch(각 GC 사이클)
동안, 그리고 `Destroy()` 전후 모두 `warn`이 한 번도 안 뜸. 트리거
조건 1(신호 발화)은 회피 확인.
2. **연결이 살아있는 동안 콜백 클로저가 캡처한 값이 GC 안 됨** — Test 1/2
둘 다 `weak[1]`이 6 epoch 내내 살아있음. `lifecycle-pattern.md`
"클로저 생존이 곧 gchold 생존" 주장과 일치.
3. **`Connection.Connected``Destroy()` 직후 동기적으로 `false`로 전환**
— GC를 기다리지 않고 즉시 확인됨(Test 2). `canExecute(inst, value)`
유일한 하드 의존성이 실측으로 확인됨.
4. **Destroy 이후 GC를 한 번 더 돌리면 클로저가 캡처했던 값이 실제로
수거됨** — `conn` 변수 자체는 스크립트 스코프에 여전히 남아있어도
(Connection 객체 자체는 안 죽음), disconnect되면 콜백의 upvalue 참조는
놓아준다는 것도 확인 — 메모리 누수로 안 남는다는 근거.
## 아직 확인 안 된 것
- **A-1/A-2 (`canBound` 이중 바인딩 게이트, unbind 후 재바인딩 허용)**
`bindLifetime`/`canBound`/`unbindLifetime`/`Subscribed` 로직 자체는
이 스크립트에 없음(순수 GC/Connection 메커니즘만 테스트함). `luau-test/
README.md`가 명시한 두 번째 "심각한 발견" 트리거 조건(A-2 실패 시
`canBound`/`unbindLifetime` 설계 재검토)은 미해소 — 공식 `10` 파일을
그대로 실행해서 확인해야 함.
- **Part B (Attribute의 Instance 참조 타입 지원)**, **Part C
(CollectionService 태그/`GetTagged` 왕복)** — 미실행.
- **`inst` 자체를 `__mode="k"` weak key로 쓰는 경로** — 실제 `Relate`
구조(`base/relate-plan.md`)는 바깥 테이블이 `inst`를 weak key로 잡는데,
이 스크립트는 `__mode="v"` + 정수 리터럴 키만 썼음. `inst`가 다른 곳에서
안 참조되면 relate 엔트리(gcconn/gchold/value 전체)가 실제로 죽는
경로는 미검증 — `canExecute``.Connected`만으로 정확히 동작하는 한
정합성보다는 메모리 누수 방지 쪽 문제라 상대적으로 덜 치명적이지만,
완전히 별개 항목이니 열어둘 것. weak-key-on-table 자체는
`07-relate-weak-table-gc.luau`가 순수 luau CLI에서 이미 확인했음 —
Instance가 plain table과 동일하게 weak key로 동작하는지는 아직 별개로
미확인.
## 부수 발견 — Roblox Studio에서도 GC 완료를 간접 관찰 가능
`07-relate-weak-table-gc.luau`의 기존 서술("Roblox 실제 게임 스크립트
환경에는 `collectgarbage()`가 노출되지 않아 GC 타이밍 검증이 불가능,
그래서 순수 luau CLI에서만 해야 함")은 **"명시적 `collectgarbage()` API가
없다"는 부분은 여전히 맞지만, "그래서 Studio에서 GC 검증 자체가 불가능하다"는
결론은 이번에 반증됨** — weak-value 테이블에 canary를 넣어두고
할당 압력(`table.create` 반복)을 걸며 `task.wait`로 기다리면, incremental
GC가 실제로 완료되는 시점을 간접 관찰 가능함(사용자가 이번 검증에서
확인). `07`의 docstring과 이 기법 자체는 `luau-test/gc-trigger-helper.server.luau`
분리해뒀음 — Studio 기반 스파이크(`10` 등)가 GC 완료를 기다려야 할 때
그 파일의 `waitForGC`를 그대로 복붙하면 됨.
## 다음 확인 시 참고
공식 `10-roblox-studio-checks.server.luau`를 그대로 돌려서 A-1/A-2/B/C를
마저 확인할 것 — 이 문서의 "확인된 것"과 겹치는 A 섹션 앞부분(신호 미발화,
Destroy 시 Connected 전환)은 다시 안 봐도 되지만, `canBound`/`unbindLifetime`
로직은 반드시 실제로 돌려봐야 함.

View file

@ -187,6 +187,9 @@ function bindLifetime(inst, value)
relate:SetStrong(inst, GCHOLD, gchold) relate:SetStrong(inst, GCHOLD, gchold)
gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function()
local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존
-- 2026-08-13 부분 실측 확인(미발화 + Destroy 시 Connected 즉시
-- 전환) — audit/gcconn-trick-verification.md. canBound 이중
-- 바인딩 게이트(A-1/A-2)는 아직 공식 luau-test/10으로 미확인.
end) end)
relate:SetStrong(inst, GCCONN, gcconn) relate:SetStrong(inst, GCCONN, gcconn)
end end

View file

@ -9,12 +9,22 @@
배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇. 배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇.
중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가 중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가
노출되지 않음(강제 GC 트리거 불가) — 그래서 이 GC 타이밍 검증은 명시적으로 노출되지 않음 — 그래서 이 스크립트 자체(collectgarbage()를
Roblox Studio가 아니라 반드시 순수 luau CLI에서 해야 함(standalone 직접 호출)는 Roblox Studio가 아니라 순수 luau CLI에서 돌려야 함
Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은 VM/GC 구현 (standalone Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은
자체가 같은 Luau이므로 여기서 확인된 동작이 그대로 적용된다고 가정할 VM/GC 구현 자체가 같은 Luau이므로 여기서 확인된 동작이 그대로
수 있지만, "그대로 적용된다"는 가정 자체는 이 스크립트로 검증 불가능한 적용된다고 가정할 수 있지만, "그대로 적용된다"는 가정 자체는 이
항목으로 남음(참고만 할 것). 스크립트로 검증 불가능한 항목으로 남음(참고만 할 것).
**[2026-08-13 갱신]** "그래서 Studio에서는 GC 타이밍 검증 자체가
불가능하다"는 예전 결론은 정정됨 — `collectgarbage()` API가 없을
뿐, weak-value 테이블에 canary를 넣고 할당 압력을 걸며 기다리는
간접 기법으로 Studio에서도 GC 완료를 관찰 가능함이 실측 확인됨
(`gc-trigger-helper.server.luau`, `audit/gcconn-trick-verification.md`
참고). `10`처럼 Studio 스파이크에서 GC 완료를 기다려야 하면 그
헬퍼를 쓸 것 — 이 파일(`07`)은 여전히 순수 luau CLI 전용으로 남김
(더 정확한 `collectgarbage()` 직접 호출을 쓸 수 있어 굳이 간접
기법으로 바꿀 이유 없음).
실행: `luau 07-relate-weak-table-gc.luau` 실행: `luau 07-relate-weak-table-gc.luau`
]] ]]

View file

@ -0,0 +1,91 @@
--[[
검증 대상: `base/relate-plan.md` "위험한 패턴 — 서로 다른 두 `Relate`의
상호 강참조 순환" 절의 핵심 주장 — Luau에 ephemeron 테이블이 없어서
(https://luau.org/compatibility/ "Lua 5.2" 섹션), 서로 다른 두 weak-key
테이블이 서로의 키를 상대방의 강한 값으로 제공하는 순환을 만들면 GC가
절대 못 푼다는 것. 지금까지 이 주장은 **공식 문서 인용으로만** 뒷받침돼
있었고, 실제 Luau로 재현해본 적은 없었음(2026-08-12 열세/열네 번째
세션, `Slot`의 `kSlotMap`/`slotOwner`가 이 패턴에 걸려있었다가 한쪽을
`SetWeak`로 낮춰 수정된 실제 사례).
이 스파이크는 두 부분:
1. **위험한 패턴 재현(음성 대조군)** — 두 테이블 다 상대방 키를 강하게
들면 실제로 안 죽는지.
2. **수정된 패턴 확인(양성 대조군)** — `relate-plan.md`가 처방한 대로
한쪽을 weak-value로 낮추면 실제로 죽는지.
배경: `base/relate-plan.md` "위험한 패턴" 절, `base/slot-plan.md` "Slot과
Store 바인드의 관계" 절(`kSlotMap`/`slotOwner` 실사례).
실행: `luau 18-relate-mutual-cycle-gc.luau` (순수 luau CLI, Roblox 불필요
— `collectgarbage()`를 직접 씀, `07`과 같은 이유).
]]
local function newStrongKeyWeakTable()
return setmetatable({}, { __mode = "k" }) -- 키만 weak, 값은 기본(strong)
end
print("=== 1. 위험한 패턴 재현 — 상호 강참조 순환은 GC가 안 풀어야 함 ===")
do
local relateA = newStrongKeyWeakTable() -- inst(weak key) -> value(strong 값)
local relateB = newStrongKeyWeakTable() -- value(weak key) -> inst(strong 값)
local canary
do
local inst = {}
local value = {}
relateA[inst] = value
relateB[value] = inst
-- canary 자신은 inst/value를 약하게만 참조 — 관찰 전용, 생존에 관여 안 함
canary = setmetatable({ inst, value }, { __mode = "v" })
-- 이 do-블록을 벗어나면 inst/value에 대한 강한 로컬 참조는 사라짐 —
-- 남은 건 relateA/relateB 안의 상호 참조뿐
end
collectgarbage()
collectgarbage()
local instAlive = canary[1] ~= nil
local valueAlive = canary[2] ~= nil
print("inst 살아있음?", instAlive, "(기대: true — 상호 순환 때문에 안 풀림, ephemeron 없음)")
print("value 살아있음?", valueAlive, "(기대: true — 같은 이유)")
if not instAlive and not valueAlive then
warn("[예상 밖] 상호 순환인데도 GC됨 — Luau가 이 케이스를 실제로 처리한다는 뜻, relate-plan.md의 '위험한 패턴' 경고 자체를 재검토해야 함")
end
end
print()
print("=== 2. 수정된 패턴 — relate-plan.md 처방대로 한쪽을 weak-value로 낮추면 풀려야 함 ===")
do
local relateA = newStrongKeyWeakTable() -- inst(weak key) -> value(strong 값), 그대로 둠
local relateBWeak = setmetatable({}, { __mode = "kv" }) -- value(weak key) -> inst(이번엔 값도 weak)
local canary
do
local inst = {}
local value = {}
relateA[inst] = value
relateBWeak[value] = inst
canary = setmetatable({ inst, value }, { __mode = "v" })
end
collectgarbage()
collectgarbage()
local instAlive = canary[1] ~= nil
local valueAlive = canary[2] ~= nil
print("inst 살아있음?", instAlive, "(기대: false — 이번엔 GC됨)")
print("value 살아있음?", valueAlive, "(기대: false — 같은 이유)")
if instAlive or valueAlive then
warn("[예상 밖] weak-value로 낮췄는데도 안 죽음 — relate-plan.md의 처방(SetWeak로 낮추기)이 실제로는 안 통한다는 뜻, 최우선으로 재검토")
end
end
--[[
확인 포인트:
1번 섹션은 true/true, 2번 섹션은 false/false가 나와야 relate-plan.md의
주장(위험한 패턴 존재 + 처방 효과 있음) 둘 다 실측 확인된 것. 어느 한쪽이
라도 기대와 다르면(특히 warn이 뜨면) `base/relate-plan.md` "위험한 패턴"
절과 `base/slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 설계
전체를 재검토해야 함 — 이 사례 하나가 Slot GC 설계의 유일한 근거였음.
]]

View file

@ -0,0 +1,278 @@
--[[
검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후
새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 —
전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라
손으로 추론만으로는 놓치기 쉬운 클래스(이 프로젝트가 이미 같은 클래스에서
실제 버그를 두 번 냄: `retractUnder`의 and/or 삼항 falsy 버그, Slot
`recompute`의 off-by-one). 셋 다 Roblox 엔진/GC 타이밍과 무관한 순수 Luau
테이블 로직이지만, 03/04/11번 스파이크가 커버하는 것과는 다른 새 알고리즘
모양(여러 위치가 하나의 이름/자리를 공유하는 참조 카운트, 캐싱된 키 객체의
사이클 간 재사용, "같은 owner면 무시/다른 owner면 error" 3분기)이라 별도로
실측이 필요하다고 판단해 신규 작성함.
A) Tag 참조 카운트 — `base/tag-plan.md` "메커니즘 — TagHandler" 절의
`kTagMap`/`tagNameMap`. 서로 다른 두 위치가 같은 이름을 겹쳐 가질 때
(`Frame { Tag("a"), Tag("a","b") }`류) 한쪽이 이름을 잃어도 다른 쪽이
아직 쥐고 있으면 실제 `RemoveTag`가 안 불려야 함(웹 className 합집합
시맨틱).
B) Attribute 소유권 — `base/attribute-plan.md` "이름 소유권" 절의 `rawNew`+
`owners` 레지스트리. 직접 리터럴 쓰기와 그룹 위임이 같은 이름을 동시에
관리하려 하면 즉시 error. 그룹의 "남아있는 이름"은 캐싱된 같은 키 객체를
여러 사이클에 걸쳐 재사용해야 함(매번 새 키를 만들면 owners 레지스트리가
자기 자신과 충돌하는 오탐이 남).
C) Slot 요소 소유권 — `base/slot-plan.md` "요소 소유권 — `elementOwner`" 절의
`claimOwner`/`releaseOwner`. 같은 element가 이미 다른 곳에 마운트돼
있으면 error, 같은 owner가 다시 클레임하면 조용한 no-op(재귀 재emit
대응), 해제 후 다른 곳에 새로 붙는 건 정상 허용. top-level(Dispatch)과
nested(`Add`) 경로가 **같은** 레지스트리를 공유해야 서로의 마운트를 잡음.
실행: `luau 19-ownership-refcount-relate-patterns.luau`
]]
-- ===== 공용: Relate 흉내(이 스파이크는 순수 로직 검증이 목적이라 weak-key는
-- 07/18번이 이미 따로 다룸 — 여기선 plain 테이블로 단순화) =====
local function makeRegistry()
local t = {}
return {
get = function(_, a, b)
local sub = t[a]
return sub and sub[b]
end,
set = function(_, a, b, v)
t[a] = t[a] or {}
t[a][b] = v
end,
}
end
local function report(name, ok, expected, actual)
local pass = ok
print(string.format(" [%s] %s%s", pass and "PASS" or "FAIL", name, pass and "" or string.format(" (expected=%s actual=%s)", tostring(expected), tostring(actual))))
return pass
end
local allPass = true
-- ===== A. Tag 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가짐 =====
print("=== A. Tag kTagMap/tagNameMap — 참조 카운트 ===")
do
local kTagMap = makeRegistry() -- (inst,k) -> Tag(흉내: {names={...}})
local tagNameMap = makeRegistry() -- (inst,name) -> {[Tag]=true}
local removeLog = {}
local addLog = {}
local function names(tagList)
local out = {}
for _, n in tagList do
out[n] = true
end
return out
end
local function makeTag(...)
return { __names = names({ ... }) }
end
local function tagNames(tag)
local out = {}
for n in tag.__names do
table.insert(out, n)
end
return out
end
local function tagContains(tag, name)
return tag.__names[name] == true
end
local function TagRetract(inst, k, newv)
local oldv = kTagMap:get(inst, k)
if not oldv then
return
end
local newvIsTag = newv ~= nil and newv.__names ~= nil
for _, name in tagNames(oldv) do
local holders = tagNameMap:get(inst, name) or {}
holders[oldv] = nil
tagNameMap:set(inst, name, holders)
if next(holders) == nil and not (newvIsTag and tagContains(newv, name)) then
table.insert(removeLog, name)
end
end
end
local function TagProcess(inst, k, v)
for _, name in tagNames(v) do
local holders = tagNameMap:get(inst, name) or {}
if next(holders) == nil then
table.insert(addLog, name)
end
holders[v] = true
tagNameMap:set(inst, name, holders)
end
kTagMap:set(inst, k, v)
end
-- Frame { Tag("a"), Tag("a", "b") } — 두 위치(k=1, k=2)가 "a"를 겹쳐 가짐
local tagPos1 = makeTag("a")
local tagPos2 = makeTag("a", "b")
TagRetract("Frame1", 1, tagPos1)
TagProcess("Frame1", 1, tagPos1)
TagRetract("Frame1", 2, tagPos2)
TagProcess("Frame1", 2, tagPos2)
-- Frame{Tag("a"), Tag("a","b")} 마운트 직후: "a"는 두 위치가 겹쳐 가지므로
-- AddTag("a")는 첫 홀더가 생길 때 한 번만 실제로 불려야 함(로그 = {a, b})
allPass = report("초기 마운트: AddTag 로그", #addLog == 2 and addLog[1] == "a" and addLog[2] == "b", "{a,b}", table.concat(addLog, ",")) and allPass
-- 위치1이 "a"를 잃음(Tag()로 교체, 빈 값) — 위치2가 아직 "a"를 쥐고 있으므로
-- 실제 RemoveTag("a")는 안 불려야 함
local emptyTag = makeTag()
TagRetract("Frame1", 1, emptyTag)
TagProcess("Frame1", 1, emptyTag)
allPass = report("위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림", #removeLog == 0, "0", #removeLog) and allPass
-- 위치2도 "a"를 잃음 — 이제 진짜로 RemoveTag("a")가 불려야 함(b는 그대로 유지)
local tagPos2b = makeTag("b")
TagRetract("Frame1", 2, tagPos2b)
TagProcess("Frame1", 2, tagPos2b)
allPass = report("마지막 홀더도 a를 잃으면 RemoveTag(a) 실제로 불림", #removeLog == 1 and removeLog[1] == "a", "{a}", table.concat(removeLog, ",")) and allPass
end
-- ===== B. Attribute 소유권 — rawNew 전용 키 + owners 레지스트리 =====
print()
print("=== B. Attribute owners 레지스트리 — 소유권 충돌 + 캐시 재사용 ===")
do
local owners = makeRegistry() -- (inst,name) -> keyObject
local function rawNew(name)
return { __name = name } -- identity가 의미있는 새 키 객체
end
local function AttributeKeyProcess(inst, k, v)
local name = k.__name
local map = owners:get(inst, "map") or {}
local current = map[name]
if current ~= nil and current ~= k then
error(string.format('attribute "%s"는 이미 다른 AttributeKey가 관리 중', name))
end
if v == nil then
map[name] = nil
else
map[name] = k
end
owners:set(inst, "map", map)
end
local directKey = rawNew("Enabled") -- 직접 리터럴 쓰기 흉내(공개 캐시 대신 여기선 그냥 키 하나)
AttributeKeyProcess("Frame1", directKey, true)
local ok1 = pcall(function()
local groupKey = rawNew("Enabled") -- 그룹이 rawNew로 만든 별개 키 객체(이름은 같음)
AttributeKeyProcess("Frame1", groupKey, false)
end)
allPass = report("직접 쓰기 + 그룹이 같은 이름을 동시에 관리 -> error", ok1 == false, false, ok1) and allPass
-- 그룹 자신의 diff 사이클 — 같은 이름이 여러 사이클에 걸쳐 살아남으면
-- 캐싱된 같은 키 객체를 재사용해야 함(안 그러면 owners가 자기 자신과 충돌)
local groupCache = {} -- {[name] = keyObject} — attribute-plan.md의 "이름 -> 그 이름 전용 키" 맵
local function groupCycle(names)
for _, name in names do
local key = groupCache[name] or rawNew(name)
groupCache[name] = key
AttributeKeyProcess("Frame2", key, true) -- 실제로는 Dispatch.retractUnder+process 페어, 여기선 process만 흉내
end
end
groupCycle({ "Health", "Mana" })
local firstHealthKey = groupCache["Health"]
groupCycle({ "Health", "Mana" }) -- 2번째 사이클, 같은 이름들 살아남음
local ok2 = groupCache["Health"] == firstHealthKey
allPass = report("남아있는 이름은 사이클 간 같은 키 객체 재사용(재생성 아님)", ok2, true, ok2) and allPass
local ok3, err3 = pcall(function()
-- 캐시를 무시하고 매번 새 키를 만들면(버그 시뮬레이션) 자기 자신과도 충돌해야 정상
local staleKey = rawNew("Health")
AttributeKeyProcess("Frame2", staleKey, true)
end)
allPass = report("캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌(캐시 재사용이 왜 필수인지 반증)", ok3 == false, false, ok3) and allPass
end
-- ===== C. Slot elementOwner — claimOwner/releaseOwner =====
print()
print("=== C. Slot elementOwner — 다중 마운트 금지 + 같은 owner는 no-op ===")
do
local elementOwner = makeRegistry()
local OWNER = "__owner"
local function claimOwner(element, ownerKey)
local current = elementOwner:get(element, OWNER)
if current == ownerKey then
return false -- 이미 같은 owner — no-op
end
if current ~= nil then
error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
end
elementOwner:set(element, OWNER, ownerKey)
return true
end
local function releaseOwner(element, ownerKey)
if elementOwner:get(element, OWNER) == ownerKey then
elementOwner:set(element, OWNER, nil)
end
end
local slotA = { __name = "slotA" }
local frame1 = { __name = "Frame1" }
local frame2 = { __name = "Frame2" }
local outerSlot = { __name = "outerSlot" }
local claimed1 = claimOwner(slotA, frame1) -- top-level Dispatch 마운트
allPass = report("최초 클레임은 true(실제로 붙음)", claimed1 == true, true, claimed1) and allPass
local claimed2 = claimOwner(slotA, frame1) -- 같은 owner가 재emit(재귀 재계산 등)
allPass = report("같은 owner 재클레임은 false(no-op, 재파괴/재생성 없음)", claimed2 == false, false, claimed2) and allPass
local ok4 = pcall(function()
claimOwner(slotA, outerSlot) -- nested Add가 같은 slotA를 다른 곳에 붙이려 함
end)
allPass = report("top-level이 이미 소유 중인데 nested Add가 같은 element를 또 클레임 -> error", ok4 == false, false, ok4) and allPass
releaseOwner(slotA, frame1) -- top-level에서 정상 해제(retract)
local claimed3 = claimOwner(slotA, outerSlot) -- 해제 후엔 다른 곳에 정상적으로 다시 붙을 수 있음
allPass = report("release 이후엔 다른 owner가 정상 클레임 가능", claimed3 == true, true, claimed3) and allPass
local ok5 = pcall(function()
claimOwner(slotA, frame2) -- outerSlot이 아직 쥐고 있는데 frame2가 가로채려 함
end)
allPass = report("release 안 된 상태에서 제3자가 가로채려 하면 -> error", ok5 == false, false, ok5) and allPass
end
print()
print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — 위 로그에서 어느 케이스인지 확인할 것 ===")
--[[
확인 포인트:
1. A: "위치1이 a를 잃어도 위치2가 쥐고 있어 RemoveTag 안 불림" — 이게 FAIL이면
웹 className류 합집합 시맨틱이 실제로 안 되는 것이므로 tag-plan.md의
`kTagMap`/`tagNameMap` 알고리즘 자체를 재검토해야 함(최우선 보고).
2. B: "캐시 우회하고 새 키로 같은 이름 쓰면 자기 자신과도 충돌" 케이스가
FAIL(즉 에러가 안 남)이면 오히려 좋은 신호가 아니라, attribute-plan.md가
"캐시가 그룹 값 교체를 넘어 영속돼야 한다"고 요구하는 이유 자체가
이 스파이크에서 재현이 안 된 것 — 이 경우 이 검증 케이스 설계를
재검토할 것(알고리즘이 아니라 테스트 설계 문제일 수 있음).
3. C: 세 가지 분기(같은 owner=no-op, 다른 owner=error, release 후 재클레임=
정상)가 전부 정확히 갈리는가 — 이 셋 중 하나라도 안 갈리면 Slot의
"재귀 재emit마다 서브트리가 파괴됐다 재생성되는" 파괴적 버그
(slot-plan.md 참고)로 직결되므로 FAIL이면 최우선 보고.
]]

View file

@ -0,0 +1,143 @@
--[[
검증 대상: `Slot:Splice(index, removeCount, ...newElements)`
(`base/slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절, 2026-08-12
열다섯 번째 세션 신규) — 한 구간을 제거하고 동시에 새 요소를 그 자리에
삽입하는 shift+recompute 1회짜리 연산이, 문서 자신이 명시한 불변식
("`Splice`의 결과는 항상 `Extract` 반복 + `Add` 반복으로도 재현 가능해야
함")을 실제로 만족하는지.
왜 이게 필요한가: 이 프로젝트는 인덱스 시프트 계산에서 이미 한 번 실제
버그를 냈음(`Dispatch.recompute`의 offset이 자기 자신을 포함해 누적되던
off-by-one, 2026-08-11 여섯 번째 세션, `base/bind-system-plan.md` 참고) —
Splice는 제거 구간과 삽입 구간이 서로 다른 길이일 때 뒤 요소들이 얼마나
밀리는지를 한 번에 계산해야 해서 같은 클래스의 off-by-one 위험이 있음.
Roblox 엔진/GC 타이밍과 무관한 순수 인덱스 산술이라 `luau` CLI로 바로
검증 가능.
방법: `rawSplice`를 흉내낸 구현과, 그와 독립적으로 "제거 구간을 하나씩
`Extract`, 그 자리에 하나씩 `Add`"를 반복하는 훨씬 단순하고 명백히 옳은
참조 구현(reference impl) 둘을 각 케이스에 나란히 돌려 최종 배열과 반환된
제거분이 완전히 일치하는지 비교 — 손으로 기대값을 미리 계산해두지 않아도
두 구현이 divergence하면 그 자체가 버그 신호.
실행: `luau 20-slot-splice-index-arithmetic.luau`
]]
-- ===== 참조 구현: Extract 반복 + Add 반복(단순하고 명백히 옳음) =====
local function referenceSplice(arr, index, removeCount, newElements)
local out = table.clone(arr)
local removed = {}
-- removeCount개를 index 위치에서 하나씩 Extract(뒤 요소가 당겨짐)
for _ = 1, removeCount do
table.insert(removed, table.remove(out, index))
end
-- newElements를 index 위치부터 하나씩 Add(뒤 요소가 밀림)
for i, el in newElements do
table.insert(out, index + i - 1, el)
end
return out, removed
end
-- ===== 실측 대상: shift 1회 + recompute 1회로 묶은 구현 =====
-- base/slot-plan.md가 "새 능력 추가 아님, 순수 최적화"라고 명시한 대로,
-- table.move 기반 단일 시프트로 같은 결과를 내야 함.
local function rawSplice(arr, index, removeCount, newElements)
local n = #arr
local removed = {}
for i = 0, removeCount - 1 do
removed[i + 1] = arr[index + i]
end
local newCount = #newElements
local delta = newCount - removeCount -- 양수면 뒤가 더 밀림, 음수면 당겨짐
if delta > 0 then
-- 뒤 요소들을 delta칸 뒤로 미리 밀어 공간 확보(뒤에서부터 복사해야 겹침 안 깨짐)
for i = n, index + removeCount, -1 do
arr[i + delta] = arr[i]
end
elseif delta < 0 then
-- 뒤 요소들을 -delta칸 앞으로 당김(앞에서부터 복사)
for i = index + removeCount, n do
arr[i + delta] = arr[i]
end
for i = n + delta + 1, n do
arr[i] = nil
end
end
for i, el in newElements do
arr[index + i - 1] = el
end
return arr, removed
end
-- ===== 케이스들 — 경계값 위주(off-by-one이 실제로 숨을 만한 자리) =====
local cases = {
{ name = "제거=삽입 길이 같음(제자리 교체)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 2, newElements = { "X", "Y" } },
{ name = "삽입 > 제거(뒤가 밀림)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 1, newElements = { "X", "Y", "Z" } },
{ name = "제거 > 삽입(뒤가 당겨짐)", arr = { "a", "b", "c", "d", "e" }, index = 2, removeCount = 3, newElements = { "X" } },
{ name = "removeCount=0(순수 삽입, Add와 동치여야 함)", arr = { "a", "b", "c" }, index = 2, removeCount = 0, newElements = { "X", "Y" } },
{ name = "newElements 없음(순수 제거, Extract 반복과 동치여야 함)", arr = { "a", "b", "c", "d" }, index = 2, removeCount = 2, newElements = {} },
{ name = "맨 앞(index=1)", arr = { "a", "b", "c" }, index = 1, removeCount = 1, newElements = { "X", "Y" } },
{ name = "맨 끝까지 제거(index+removeCount-1 == #arr)", arr = { "a", "b", "c", "d" }, index = 3, removeCount = 2, newElements = { "X" } },
{ name = "끝에 순수 추가(index = #arr+1, removeCount=0)", arr = { "a", "b", "c" }, index = 4, removeCount = 0, newElements = { "X", "Y" } },
{ name = "전체 교체(index=1, removeCount=#arr)", arr = { "a", "b", "c" }, index = 1, removeCount = 3, newElements = { "X", "Y", "Z", "W" } },
{ name = "단일 원소 배열에서 단일 교체", arr = { "a" }, index = 1, removeCount = 1, newElements = { "X" } },
{ name = "삽입 개수가 제거보다 훨씬 큼(delta 큰 양수)", arr = { "a", "b", "c" }, index = 2, removeCount = 1, newElements = { "X", "Y", "Z", "W", "V" } },
}
local function arraysEqual(a, b)
if #a ~= #b then
return false
end
for i = 1, #a do
if a[i] ~= b[i] then
return false
end
end
return true
end
local allPass = true
for _, case in cases do
local refArr, refRemoved = referenceSplice(case.arr, case.index, case.removeCount, case.newElements)
local rawArr = table.clone(case.arr)
local _, rawRemoved = rawSplice(rawArr, case.index, case.removeCount, case.newElements)
local finalOk = arraysEqual(refArr, rawArr)
local removedOk = arraysEqual(refRemoved, rawRemoved)
local pass = finalOk and removedOk
allPass = allPass and pass
print(string.format(" [%s] %s", pass and "PASS" or "FAIL", case.name))
if not pass then
print(" reference 최종:", table.concat(refArr, ","), " / raw 최종:", table.concat(rawArr, ","))
print(" reference 제거분:", table.concat(refRemoved, ","), " / raw 제거분:", table.concat(rawRemoved, ","))
end
end
print()
print(allPass and "=== 전체 PASS ===" or "=== 하나 이상 FAIL — off-by-one 등 shift 계산 버그 가능성, 최우선 보고 ===")
--[[
확인 포인트:
1. 모든 케이스가 PASS인가 — 특히 "delta 큰 양수"/"전체 교체"/"맨 끝까지
제거" 세 케이스는 시프트 방향(뒤에서부터 vs 앞에서부터 복사)을 잘못
고르면 데이터가 겹쳐써지는 클래식 버그가 나는 자리라 우선 확인할 것.
2. 이 파일의 `rawSplice`는 문서(`slot-plan.md`)가 서술한 알고리즘을
이 스파이크 작성자가 재구현한 것 — 실제 M6 구현이 이거랑 정확히
같은 모양일 필요는 없지만, "제거+삽입을 시프트 1회로 묶어도
Extract반복+Add반복과 결과가 항상 같다"는 불변식 자체가 성립하는지
확인하는 게 이 스파이크의 목적. FAIL이 나면 `rawSplice`가 아니라
불변식 자체(또는 이 스파이크의 케이스 설계)를 의심해볼 것.
3. 이 스파이크는 논리적 정합성만 다룸 — 실제 Slot 구현이 여기에 더해
다뤄야 하는 것(요소 타입 검증, 이미 마운트된 element 거부, 물리
detach/reattach 타이밍)은 범위 밖, `slot-plan.md` 본문 참고.
]]

View file

@ -23,7 +23,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| 환경 | 필요한 것 | 해당 파일 | | 환경 | 필요한 것 | 해당 파일 |
|---|---|---| |---|---|---|
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17 | | **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17, 18, 19, 20 |
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 | | **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 |
| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 | | **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 |
@ -55,7 +55,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" |
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>``State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 |
| `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 |
| `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | | `10-roblox-studio-checks.server.luau` (Studio 전용) | (A) `bindLifetime`/`unbindLifetime`/`canExecute`/`canBound`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복**[2026-08-13]** A 섹션 앞부분(신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됨, `audit/gcconn-trick-verification.md` 참고. A-1/A-2(`canBound` 게이트)/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md`, `bind-system-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` |
| `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 |
| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<<T>> "name"] = value`(구 `Attribute<<T>>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) |
| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef<T>``Ref<T>`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md``Brand` 절(2026-08-09 열한 번째 세션 재정정) |
@ -63,6 +63,18 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
| `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `bind-system-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) |
| `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store<T>``T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source<T>` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 |
| `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 |
| `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 신규]** "retract는 항상 불림" 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 소유권/참조카운트 알고리즘(A: Tag `kTagMap`/`tagNameMap` 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`, B: Attribute `rawNew`+`owners` 소유권 — 충돌 시 error + 사이클 간 캐시 키 재사용, C: Slot `elementOwner``claimOwner`/`releaseOwner` — 같은 owner 재클레임은 no-op, 다른 owner는 error)가 실제로 정확히 갈리는지 | `tag-plan.md` "메커니즘" 절, `attribute-plan.md` "이름 소유권" 절, `slot-plan.md` "요소 소유권 — `elementOwner`" 절(전부 2026-08-12 세션들, 03/04/11번과는 다른 새 알고리즘 모양이라 별도 실측 필요하다고 판단) |
| `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 열다섯 번째 세션), `bind-system-plan.md``recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
## 공통 유틸리티
- `gc-trigger-helper.server.luau` — Roblox Studio(collectgarbage() 미노출
환경)에서 GC 완료를 간접 관찰하는 `waitForGC()` 스니펫. Studio 기반
스파이크(`10` 등)가 "GC가 끝날 때까지 기다렸다가 weak 참조가 사라졌는지
확인"해야 할 때 그대로 복붙해서 쓸 것. 순수 luau CLI 스크립트(`07` 등)는
`collectgarbage()`를 직접 부르면 되므로 이 헬퍼가 필요 없음 — 발견 경위는
`audit/gcconn-trick-verification.md` 참고.
## 갱신 이력 ## 갱신 이력
@ -115,6 +127,33 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
스케치, 타입체크 전용)/`17`(제네릭 __index 체이닝, 런타임)로 신규 추가. 스케치, 타입체크 전용)/`17`(제네릭 __index 체이닝, 런타임)로 신규 추가.
둘 다 이전까지 이 폴더 어디에도 커버 대상이 없던 완전히 새 항목. 둘 다 이전까지 이 폴더 어디에도 커버 대상이 없던 완전히 새 항목.
**7차 (2026-08-13)**: `10`의 A 섹션 앞부분(ClassName 신호 미발화,
`Destroy()``Connection.Connected` 즉시 전환)을 사용자가 공식 파일이
아닌 자작 스크립트로 먼저 실측 — 결과는 `.claude/audit/
gcconn-trick-verification.md`에 정리(부분 확인, A-1/A-2/B/C는 아직 official
`10`으로 재확인 필요). 부수적으로 Studio에서도 GC 완료를 간접 관찰하는
기법을 발견해 `gc-trigger-helper.server.luau`로 분리, `07`의 "Studio에서
GC 검증 불가" 서술도 이에 맞춰 정정.
**8차 (2026-08-13)**: `18` 신규 추가 — 2026-08-12 열세/열네 번째 세션에서
`relate-plan.md`에 확정된 "두 `Relate` 상호 강참조 순환은 ephemeron 없이는
GC가 안 풂" 주장이 지금까지 공식 문서 인용으로만 뒷받침돼 있었고 실제
Luau로 재현해본 적이 없었던 갭 — `Slot``kSlotMap`/`slotOwner` GC 수정
사례(session 13/14) 전체가 이 사례 하나에 기대고 있어 우선순위 있게 추가.
**9차 (2026-08-13)**: CLAUDE.md 세션 8~21(대부분 2026-08-12) 전체를 대상으로
"새 메커니즘 중 실 Luau로 안 부딪혀본 게 더 있는가"를 재점검 — Ref/Slot의
`Relate` diff 기반 retract(세션 8/9)는 03/04번이 이미 검증한 것과 같은
클래스의 재귀 dispatch/체인 로직이라 스킵. 반면 Tag 참조 카운트(세션 11/16)
+ Attribute `rawNew` 소유권(세션 10) + Slot `elementOwner` 소유권 판정
(세션 12, GC 쪽은 이미 18번이 커버)은 "여러 위치가 하나의 이름/자리를
공유"하는 셋 다 새로운 알고리즘 모양이라 `19` 하나로 묶어 신규 추가.
`Slot:Splice`(세션 15)는 순수 index 산술이지만 이 프로젝트가 같은 클래스
(`recompute` off-by-one)에서 실제 버그를 낸 전례가 있어 `20`으로 별도
신규 추가. 그 외(`Tag:Added`의 vararg→`string|{string}` 전환, Attribute의
"retract 완전 no-op" 재정정 등)는 단순 분기/타입 정리라 스파이크 불필요로
판단, 추가 안 함.
## 결과 확인 후 할 일 ## 결과 확인 후 할 일
각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면
@ -127,9 +166,11 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
`collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이 `collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이
어려운 항목이라 참고 신호로만 쓸 것. 어려운 항목이라 참고 신호로만 쓸 것.
- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가 - `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가
발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것. 발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것
A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime` **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/
설계 자체를 재검토해야 함. gcconn-trick-verification.md`), 재확인 불필요. A-2(재-bindLifetime 허용
여부)가 실패하면 `canBound`/`unbindLifetime` 설계 자체를 재검토해야
함 — **이건 아직 미확인, 공식 `10` 파일로 꼭 돌려볼 것.**
- `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지 - `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지
그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운 그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운
경로라 실제 구현에서도 잘 짜였는지 중요한 신호). 경로라 실제 구현에서도 잘 짜였는지 중요한 신호).
@ -153,3 +194,16 @@ Luau로 부딪혀본 적은 없다는 걸 핸드오버 점검 중 발견 — `16
- `17`은 전부 PASS가 기대값 — 특히 (B) 메타테이블 참조 동일성 assert가 - `17`은 전부 PASS가 기대값 — 특히 (B) 메타테이블 참조 동일성 assert가
실패하면 M7 전체 설계("클래스별 런타임 코드 불필요")의 핵심 전제가 실패하면 M7 전체 설계("클래스별 런타임 코드 불필요")의 핵심 전제가
무너지는 것이니 최우선으로 알려줄 것. 무너지는 것이니 최우선으로 알려줄 것.
- `18`은 1번 섹션이 true/true, 2번 섹션이 false/false로 나와야 기대값 —
둘 중 하나라도 다르면(특히 `warn`이 뜨면) `relate-plan.md` "위험한 패턴"
절과 `slot-plan.md``kSlotMap`/`slotOwner`/`elementOwner` GC 설계
전체를 최우선으로 재검토해야 함(Slot GC 안전성의 유일한 근거였음).
- `19`는 전부 PASS가 기대값 — A 섹션이 FAIL이면 `tag-plan.md`의 참조
카운트 알고리즘 자체를, C 섹션이 FAIL이면(특히 "같은 owner 재클레임은
no-op"이 안 되면) `slot-plan.md``elementOwner` 설계를 최우선으로
재검토할 것 — C가 깨지면 재귀 재emit마다 마운트된 서브트리 전체가
파괴됐다 재생성되는 파괴적 버그로 직결됨.
- `20`도 전부 PASS가 기대값 — FAIL이 있으면 어느 케이스(특히 delta 부호가
바뀌는 경계값)인지와 최종 배열/제거분이 어떻게 달랐는지 그대로 알려줄
것, `Slot:Splice`의 shift 방향/양 계산에 off-by-one이 있다는 뜻이라
`slot-plan.md` "확정" CRUD 표를 바로 고쳐야 함.

View file

@ -0,0 +1,45 @@
--[[
GC 완료를 간접 관찰하는 헬퍼 — Roblox Studio(실제 게임 스크립트) 환경 전용.
배경: Roblox는 `collectgarbage()`를 스크립트에 노출하지 않음(순수 `luau`
CLI/`lune`과 다른 점 — `07-relate-weak-table-gc.luau`는 그쪽 환경에서
`collectgarbage()`를 직접 부름). 그래서 예전엔 "Studio에서는 GC 타이밍
검증 자체가 불가능하다"고 봤는데, incremental GC가 "충분한 할당 압력 +
충분한 시간"이 주어지면 결국 한 사이클을 완료한다는 성질을 이용하면
간접적으로 관찰 가능함 — 죽었어야 할 객체를 weak-value 테이블(canary)에
넣어두고 그게 사라지는 시점을 기다리면 됨. 2026-08-13 사용자가 gcconn
트릭 검증 중 직접 확인(`audit/gcconn-trick-verification.md` 참고).
주의:
- 정확한 트리거가 아니라 **간접 관찰**임 — "이 시점에 정확히 GC가
끝났다"를 보장하지 않음, "이 루프가 끝났다면 적어도 GC 사이클 한 번은
지나갔다" 정도의 신호로만 쓸 것.
- `task.wait`가 필요해서 Roblox 런타임(Studio/게임) 전용 — 순수 `luau`
CLI엔 `task` 라이브러리가 없음(그쪽은 그냥 `collectgarbage()`를 직접
부르면 됨, `07` 참고).
- 실행 시간이 김(관찰 대상 크기/환경에 따라 GC 사이클 하나당 수 초~
수십 초) — 다른 Studio 스파이크(`10` 등)에서 GC 완료를 기다려야 할
때 아래 `waitForGC`를 그대로 복붙해서 쓰면 됨.
사용 예:
local weak = setmetatable({}, {__mode = "v"})
weak[1] = someValueExpectedToDie
-- ... someValueExpectedToDie에 대한 다른 참조를 전부 없앤 뒤 ...
waitForGC("someValue 해제 대기")
assert(weak[1] == nil, "여전히 살아있음 — GC 안 됐거나 다른 곳에서 참조 중")
]]
local function waitForGC(label: string?)
print(`[GC] {label or "GC"} 대기 시작`)
local canary = setmetatable({ {} }, { __mode = "v" }) -- 이 canary 하나만 참조하는 빈 테이블
local epoch = 0
while canary[1] do
task.wait(0.1)
table.create(5000, true) -- 할당 압력 생성 — incremental GC 진행을 강제
epoch += 1
if epoch % 100 == 0 then
print(`[GC] {label or "GC"} 대기 중: {epoch}`)
end
end
print(`[GC] {label or "GC"} 완료로 추정(epoch ~{epoch})`)
end

View file

@ -0,0 +1,104 @@
# 2026-08-13 첫 번째 세션 — gcconn 트릭 부분 실측, `Relate` 상호 순환 스파이크 신규, README 동기화
## 배경
사용자가 `base/lifecycle-pattern.md``bindLifetime`/`canExecute`가 기대는
"gcconn 트릭"의 핵심 가정 둘(ClassName PropertyChangedSignal 미발화,
`Connection.Connected``Destroy()` 시 GC 없이 동기적으로 `false`가 됨)을
Roblox Studio에서 직접 실측하는 저수준 스크립트를 짜서 돌려봄 —
`luau-test/10-roblox-studio-checks.server.luau`의 공식 스크립트는 아니고,
같은 두 가정만 따로 떼어 검증한 자작 스크립트. 출력 로그를 보여주며 "이걸로
`luau-test/README.md`가 '심각한 발견'이라고 못 박아둔 위험이 해소됐다고 볼
수 있는지" 확인 요청.
## 1단계 — 읽기 전용 검토(쓰기 금지 지시)
당시 다른 에이전트가 작업 중이라는 안내가 있어 쓰기 없이 결론만 냄:
스크립트/로직 자체는 정확했고, 두 핵심 가정(신호 미발화, Connected 즉시
전환) 모두 확인됨 — 하지만 공식 `10` 파일이 커버하는 나머지(A-1/A-2
`canBound`/`unbindLifetime` 이중 바인딩 게이트, Part B Attribute Instance
참조, Part C CollectionService 왕복)는 이 자작 스크립트가 아예 안 건드려서
"완전 해소"로 볼 수 없다는 게 결론 — 부분 확인.
## 2단계 — 문서화 요청
사용자가 이어서: (1) 해소된 부분만 정리해 `.claude/audit/` 같은 폴더에
남기고, (2) 실측에 쓴 "GC 강제 트리거" 기법(canary weak table + 할당 압력
+ `task.wait` 폴링)도 재사용 가능하게 문서화하고, (3) 그 김에 코퍼스 전체를
훑어 luau-test에 더 필요한 게 있는지/stale한 게 있는지 보고 적절히
갱신해달라고 요청.
## 한 일
**`.claude/audit/` 신설** — `gcconn-trick-verification.md` 작성: 확인된
것 4개(신호 미발화, 연결 살아있는 동안 클로저 캡처값 GC 안 됨, Destroy 직후
`Connected` 동기적 전환, Destroy+GC 후 클로저 캡처값 실제 수거), 아직 확인
안 된 것(A-1/A-2, Part B/C, `inst` 자체를 `__mode="k"` weak key로 쓰는
경로) 명확히 구분.
**GC 트리거 기법 문서화** — `luau-test/gc-trigger-helper.server.luau`
신설(`waitForGC()` 복붙용 스니펫). 부수적으로 `07-relate-weak-table-gc.luau`
"Studio에서는 GC 타이밍 검증 자체가 불가능"이라던 서술이 이번 발견으로
틀렸음을 확인 — `collectgarbage()` API가 없다는 것만 맞고, 간접 관찰은
가능하다고 정정.
**`18-relate-mutual-cycle-gc.luau` 신규** — 코퍼스를 다시 훑던 중,
`relate-plan.md`의 "두 `Relate` 상호 강참조 순환은 ephemeron 없이는 GC가
안 풂"이라는 주장(Slot의 `kSlotMap`/`slotOwner` GC 수정 사례 전체의 유일한
근거)이 지금까지 **공식 문서 인용으로만** 뒷받침돼 있었고 실제 Luau로
재현해본 적이 없었던 갭을 발견 — 음성 대조군(순환 재현)/양성 대조군(한쪽을
weak-value로 낮추면 풀리는지) 둘 다 넣은 순수 luau CLI 스파이크로 작성.
**CLAUDE.md stale 항목 발견·수정** — "지금 할 일" 2번(용어 정리)이 여전히
`State`를 "1순위, 위험도 높음, open"으로 나열하고 있었는데, `question.md`
확인 결과 `State`는 이미 2026-08-12 스무 번째 세션에 확정(현재 이름 유지)돼
있었음 — `question.md`는 정확했고 CLAUDE.md의 압축 요약만 안 따라감. 실제
열려있는 항목 목록(`DI`→`D`, `Slot`, `canExecute`, `Brand`, `Tag`류,
`Attribute`/`AttributeKey`)으로 정정.
**서브에이전트에 위임한 코퍼스 전체 스윕** — 세션 8~21(전부 2026-08-12,
`retract는 항상 불림` 정정부터 `Compute` 네이밍 근거까지)이 지난 마지막
전체 감사(세션 16) 이후 `.claude/README.md`의 요약 테이블에 제대로
반영됐는지, 그리고 이 기간에 생긴 새 메커니즘 중 luau-test 커버리지가
빠진 게 있는지 조사 위임. 결과:
- `base/`/`research/` 문서 **본문 자체는 이미 전부 최신**이었음 — 갭은
전적으로 `.claude/README.md`의 요약 테이블(색인 레이어)에만 있었음.
`bind-system-plan.md`/`slot-plan.md`/`tag-plan.md`/`modifier-plan.md`/
`architecture.md`/`framework-comparison-findings.md`/
`operator-sugar-plan.md`/`pre-implementation-audit.md` 8개 행에
세션 12~21 변경사항을 인용 마커로 보강.
- `attribute-plan.md` 행은 단순 append가 아니라 실제 오류 수정 — 세션 11의
중간 단계(`v==nil` 가드)를 "현재 상태"인 것처럼 적어뒀는데, 세션 16이
이미 이걸 완전히 뒤집어(retract 완전 no-op) 낡은 서술이 됐던 것.
- **`19-ownership-refcount-relate-patterns.luau`**,
**`20-slot-splice-index-arithmetic.luau`** 신규 추가 — 세션 8~21에서
생긴 세 소유권/참조카운트 알고리즘(Tag `kTagMap`/`tagNameMap`, Attribute
`rawNew`+`owners`, Slot `elementOwner`)과 `Slot:Splice`의 시프트 산술이
03/04/11/18과는 다른 새 알고리즘 모양이라 실측 가치가 있다고 판단해
작성. Ref/Slot의 retract-via-`Relate`-diff(세션 8/9)는 기존 03/04와 같은
메커니즘 급이라 스파이크 불필요로 판단, 추가 안 함.
- Part C(세션 17~21의 `pre-implementation-audit.md`/
`framework-comparison-findings.md`/`operator-sugar-plan.md`/
`question.md`/`ROADMAP.md` 개별 파일 스팟체크)는 전부 이미 정확해서
추가 수정 없음.
## 결과물
- 신규: `.claude/audit/gcconn-trick-verification.md`,
`luau-test/gc-trigger-helper.server.luau`,
`luau-test/18-relate-mutual-cycle-gc.luau`,
`luau-test/19-ownership-refcount-relate-patterns.luau`,
`luau-test/20-slot-splice-index-arithmetic.luau`
- 수정: `luau-test/07-relate-weak-table-gc.luau`(docstring 정정),
`luau-test/README.md`(테이블/공통 유틸리티 절/갱신이력 8~9차/결과 확인
체크리스트), `base/lifecycle-pattern.md`(gcconn 구현 스케치에 실측 각주),
`.claude/README.md`(`audit/` 행 신설, `base`/`research` 테이블 9개 행
동기화), `CLAUDE.md`("지금 할 일" 1/2번)
## 남은 것
- 공식 `luau-test/10` 파일로 A-1/A-2/Part B/Part C 마저 확인 필요(`10`의
A 섹션 앞부분만 부분 확인된 상태).
- `01`~`06`, `08`, `09`, `11`~`20`(총 18개 중 `10` 제외 전부) — 여전히
사용자가 실제로 안 돌려봄, M0 착수 전 최우선 게이트.

View file

@ -114,18 +114,27 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은 인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은
2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부 2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부
해소되어 **11개 전원 완료** — M0 착수 전 남은 유일한 게이트는: 해소되어 **11개 전원 완료** — M0 착수 전 남은 유일한 게이트는:
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-12 열일곱 번째 세션에 - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개 —
16/17 추가로 총 17개) 스파이크 결과** — 아직 사용자가 2026-08-12 열일곱 번째 세션에 16/17 추가, 2026-08-13에 세션 8~21이
`luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 안 돌려봄, 결과가 만든 커버리지 갭을 메우는 18/19/20 추가) 스파이크 결과** —
**[2026-08-13] `10`의 A 섹션 일부(ClassName 신호 미발화, Destroy 시
Connection.Connected 즉시 전환)가 처음으로 부분 확인됨**, 결과는
`.claude/audit/gcconn-trick-verification.md`. 나머지 19개 전체(신규
`18`/`19`/`20` 포함) + `10`의 남은 부분(A-1/A-2/B/C)은 여전히 사용자가
`luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 안 돌려봄 — 결과가
나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선 나오면 그것부터 반영할 것(`luau-test/README.md`가 파일별로 뭘 우선
확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고, 확인해야 하는지 이미 적어둠). 걸리는 게 있으면 `base/` 문서부터 고치고,
없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌 없으면 그대로 M0 실제 코드 작성에 재사용. 설계 자체는 더 이상 막힌
게 없음 — `.claude/question.md` 2번이 최신 상태. 게 없음 — `.claude/question.md` 2번이 최신 상태.
2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는
`.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가
stale해지는 패턴이 반복됐어서). 아직 열려있는 것만 짚으면: `State`(1순위, stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`
위험도 높음 — 업계 통념("쓸 수 있는 로컬 상태")과 반대 의미라 오해 위험), 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
`DI`→`D`, `Slot`, `canExecute`→`isAlive`, `Brand`. 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
— 아직 진짜로 열려있는 것만 짚으면: `DI`→`D`(1순위), `Slot`(2순위),
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만 3. `research/existing-instance-bind-plan.md`는 급하지 않음 — 스코프 논의만
필요, 구현 착수를 막지 않음. 필요, 구현 착수를 막지 않음.
4. **[백로그]** 범용 렌더 디버깅 도구 `quad-mock`(Tween mock 등 동적 동작 4. **[백로그]** 범용 렌더 디버깅 도구 `quad-mock`(Tween mock 등 동적 동작
@ -733,3 +742,22 @@ Vue/Svelte는 lazy인데도 `computed`를 쓰지만, quad 자기 코퍼스 안
`Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값" `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"
관례를 선점해뒀어서 lazy한 State에 재사용하면 자기 관례와 충돌 — `Compute` 관례를 선점해뒀어서 lazy한 State에 재사용하면 자기 관례와 충돌 — `Compute`
더 정확하다는 데 동의, `bind-system-plan.md`에 근거 추가. 더 정확하다는 데 동의, `bind-system-plan.md`에 근거 추가.
**2026-08-13 첫 번째 세션 — gcconn 트릭 부분 실측, `Relate` 상호 순환
스파이크 신규, README 동기화**
(`session/2026-08-13-01-gcconn-audit-relate-cycle-spike-readme-sync.md`)
사용자가 Studio에서 gcconn 트릭의 핵심 가정(ClassName 신호 미발화, Destroy
`Connected` 즉시 전환) 둘을 자작 스크립트로 실측 — 결과를
`.claude/audit/gcconn-trick-verification.md`에 정리(부분 확인, `luau-test/10`
A-1/A-2/B/C는 미해소로 명확히 구분). GC 강제 트리거 기법을
`gc-trigger-helper.server.luau`로 문서화하면서 `07`의 "Studio에서 GC 검증
불가" stale 서술도 정정. 코퍼스를 다시 훑다가 `relate-plan.md`의 "두
`Relate` 상호 순환은 ephemeron 없이 GC 안 됨" 주장이 공식 문서 인용으로만
뒷받침돼 실측된 적 없었던 갭을 발견해 `18` 신규 작성, CLAUDE.md 자신의
"지금 할 일"이 이미 해소된 `State` 용어 논쟁을 stale하게 open으로 남겨뒀던
것도 발견·수정. 서브에이전트에 위임한 코퍼스 스윕으로 세션 8~21(마지막
전체 감사인 세션 16 이후) 변경사항이 `.claude/README.md` 요약 테이블에
안 반영돼 있던 8개 행 동기화(base/research 문서 본문 자체는 이미 최신,
색인 레이어만 밀렸던 것) + `attribute-plan.md` 행의 실제 오류(폐기된
중간 단계 서술) 수정 + 새 소유권/참조카운트 알고리즘(Tag/Attribute/Slot)과
`Slot:Splice` 산술을 커버하는 `19`/`20` 스파이크 추가(총 20개).