diff --git a/.claude/README.md b/.claude/README.md index d2e26be..748ba9b 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -15,6 +15,7 @@ | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | +| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. 아직 결과 미확인 — `luau-test/README.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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | `research/`의 문서가 설계 확정되면 `base/`로 승격(또는 구현 착수 시 diff --git a/.claude/luau-test/01-two-pass-array-hash-order.luau b/.claude/luau-test/01-two-pass-array-hash-order.luau new file mode 100644 index 0000000..961c42e --- /dev/null +++ b/.claude/luau-test/01-two-pass-array-hash-order.luau @@ -0,0 +1,60 @@ +--[[ + 검증 대상: base 디스패치 드라이버가 명시적으로 강제하는 + "배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중" 두 패스 순회 계약. + + 배경: .claude/base/bind-system-plan.md "props 순회 순서" 절, ROADMAP.md M0 4번째 항목. + 사용자가 이미 Luau REPL로 `for i,v in {a=1, 2, b=3} do ... end`가 + `1, 2` 다음 `a, 1` `b, 3` 순서로 나오는 걸 확인했었지만(우연한 관찰), + base는 이 우연한 동작에 기대지 않고 배열 파트(1..#t)를 먼저, 그 다음 + 별도로 해시 파트만 골라내는 두 패스를 "명시적으로" 강제하기로 확정함 + — 이 스크립트는 그 강제 버전이 실제로 계약대로 동작하는지 확인. + + 실행: `luau 01-two-pass-array-hash-order.luau` (Roblox 필요 없음, 순수 CLI) + 기대 결과: "array pass"가 항상 "hash pass"보다 먼저 전부 출력되고, + array pass 안에서는 index 순서(1,2,3...)가 정확히 지켜져야 함. +]] + +local function isArrayKey(k) + return type(k) == "number" and k == math.floor(k) and k >= 1 +end + +-- Dispatch.drive(inst, flattened)의 최소 스파이크 버전 +local function drive(inst, flattened) + -- pass 1: 배열 파트, index 순서 보장 + local n = #flattened + for i = 1, n do + local v = flattened[i] + print(string.format("[array pass] inst=%s i=%d v=%s", tostring(inst), i, tostring(v))) + end + + -- pass 2: 해시 파트, 배열 인덱스(1..#t)는 건너뜀 + -- 주의: pairs()/제네릭 for는 배열 파트도 다시 순회하므로 반드시 걸러내야 함 + for k, v in flattened do + if not (isArrayKey(k) and k <= n) then + print(string.format("[hash pass] inst=%s k=%s v=%s", tostring(inst), tostring(k), tostring(v))) + end + end +end + +local children = { "Ref1", "Child2", "Child3" } +local props = { + children[1], + children[2], + children[3], + Name = "TestFrame", + BackgroundTransparency = 0, + Event_Activated = "handler", +} + +print("=== two-pass order 검증 ===") +drive("FakeInstance", props) + +--[[ + 추가로 확인할 것 (실행 후 눈으로 확인): + 1. array pass 3개가 hash pass보다 먼저, 그리고 i=1,2,3 순서로 나오는가? + 2. hash pass에 array 항목(children)이 중복으로 안 섞여 나오는가? + 3. 테이블 리터럴에서 해시 키를 적는 소스 텍스트 순서를 바꿔도(Name/ + BackgroundTransparency/Event_Activated 순서를 바꿔서 재실행) + array pass 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가 + 아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적. +]] diff --git a/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau new file mode 100644 index 0000000..a341f98 --- /dev/null +++ b/.claude/luau-test/02-none-sentinel-vs-nil-holes.luau @@ -0,0 +1,172 @@ +--[[ + 검증 대상: 배열 슬롯을 "소진"시킬 때 nil로 지울지 None 센티널로 지울지는 + 그 배열의 성격(순서가 중요한가, 슬롯 재사용이 필요한가)에 따라 갈린다는, + 2026-08-09 열한 번째 세션에 재정정된 최종 결론. + + **[중요, 2026-08-09 커밋 f198fd9 반영]** 이 파일의 이전 버전은 + "Ref 콜백/대기자 배열도 None으로 소진해야 한다"고 잘못 적어뒀었음 — + 사용자가 직접 찾아낸 버그: None으로 소진하면 그 슬롯이 영원히 + non-nil로 남아있어서, `:Wait()`/`:Callback()`가 반복 호출될 때마다 + 배열이 끝없이 길어지는(예전 소진 슬롯을 재사용 못 하는) 진짜 버그였음. + .claude/base/bind-system-plan.md "왜 None이 아니라 nil인가" 절(2026-08-09 + 열한 번째 세션, 최종 정정)이 최신 소스 — 결론은 두 패턴이 서로 다른 + 문제를 풀고 있었다는 것: + + - **순서가 중요한 배열(PreRef pre-pass 소진 슬롯, Length/Offset의 + sourceList)**: 계속 `None` — 구멍이 생기면 해시 파트로 밀려 + index 순회 순서가 깨지므로, "채워짐"을 유지해야 함. + - **순서가 안 중요하고 슬롯 재사용이 필요한 배열(Ref 콜백/대기자 + 리스트)**: `nil` + "빈 슬롯을 선형 탐색해 재사용"하는 등록 함수 + (`table.insert`는 안 씀 — 구멍 있는 테이블에서 `#t`가 미정의 + 동작이라서). 순서 자체는 문제 안 됨 — 일반화 `for`는 구멍이 있어도 + 모든 non-nil 엔트리를 빠짐없이 방문하기 때문. + + 이 스크립트는 두 패턴을 나란히 재현해서 각각이 실제로 옳은 선택인지, + 그리고 "None을 잘못 썼을 때 실제로 배열이 끝없이 자라는" 버그 자체도 + 수치로 보여줌. + + 실행: `luau 02-none-sentinel-vs-nil-holes.luau` +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- =============================================================== +-- Part A. 순서가 중요한 배열 — None이 맞는 경우 (PreRef pre-pass, sourceList) +-- =============================================================== + +print("=== A. 순서가 중요한 배열: None으로 소진해야 순서/#t가 안 깨짐 ===") + +local N = 50 + +local function buildList(n) + local t = {} + for i = 1, n do + t[i] = "item" .. i + end + return t +end + +print("-- A-1) BAD: nil로 지우면 순서/#t가 불안정해짐 --") +local bad = buildList(N) +for i = 2, N, 2 do + bad[i] = nil +end +print("bad #t =", #bad, "(Lua 명세상 정의되지 않은 동작 — 실제 값 확인용)") +local badOrder = {} +for i, v in bad do + table.insert(badOrder, tostring(i) .. "=" .. tostring(v)) +end +print("bad 순회 순서(구멍이 생겨 흐트러질 수 있음):", table.concat(badOrder, ", ")) + +print() +print("-- A-2) GOOD: None으로 지우면 #t/순서가 항상 보존됨(PreRef pre-pass에 필요한 성질) --") +local good = buildList(N) +for i = 2, N, 2 do + good[i] = None +end +print("good #t =", #good, "(항상 N — 구멍이 없으니까)") +local goodOrder = {} +for i = 1, #good do + local v = good[i] + goodOrder[#goodOrder + 1] = tostring(i) .. "=" .. (v == None and "None" or tostring(v)) +end +print("good 순회 순서(1..#t로 직접, 항상 안정적):", table.concat(goodOrder, ", ")) + +-- =============================================================== +-- Part B. 순서가 안 중요하고 슬롯 재사용이 필요한 배열 — nil이 맞는 경우 +-- (Ref 콜백/대기자 리스트가 실제로 이 카테고리, 2026-08-09 최종 정정) +-- =============================================================== + +print() +print("=== B. Ref 콜백/대기자 리스트: None을 쓰면 무한 성장 버그, nil+재사용이 맞음 ===") + +-- 등록: table.insert 대신 "빈(nil) 슬롯을 선형 탐색해 재사용" +local function registerNil(list, value) + for i = 1, #list + 1 do + if list[i] == nil then + list[i] = value + return i + end + end +end + +-- 소진: 그 인덱스를 nil로 되돌림(재사용 가능하게) +local function consumeNil(list, i) + list[i] = nil +end + +-- 대조군: 예전에 잘못 썼던 None 기반 버전(table.insert로만 추가, 소진은 None) +local function registerNoneBad(list, value) + table.insert(list, value) + return #list +end +local function consumeNoneBad(list, i) + list[i] = None +end + +print("-- B-1) nil + 슬롯 재사용: 동시 대기자 수만큼만 배열 크기가 유지되는가 --") +do + local waiters = {} + local maxSizeSeen = 0 + -- "등록 -> 곧바로 소진"을 여러 번 반복(:Wait() 호출 후 fire되는 흔한 패턴 흉내) + for cycle = 1, 1000 do + local idx = registerNil(waiters, "waiter" .. cycle) + maxSizeSeen = math.max(maxSizeSeen, #waiters) + consumeNil(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(0이어야 함 — 전부 소진됨)") + print("과정 중 관측된 최대 배열 크기 =", maxSizeSeen, "(작게 유지돼야 함, 이상적으론 1)") +end + +print() +print("-- B-2) None + table.insert(예전 버그): 같은 패턴을 반복하면 배열이 끝없이 자람 --") +do + local waiters = {} + for cycle = 1, 1000 do + local idx = registerNoneBad(waiters, "waiter" .. cycle) + consumeNoneBad(waiters, idx) + end + print("1000번 등록/소진 반복 후 배열 길이 =", #waiters, "(1000이어야 함 — 이게 바로 그 버그)") + local noneCount = 0 + for _, v in waiters do + if v == None then + noneCount += 1 + end + end + print("그 중 None으로 채워진(죽은) 슬롯 개수 =", noneCount, "(전부 죽은 슬롯인데 자리만 차지)") +end + +print() +print("-- B-3) nil 소진이 순서를 안 깨는가(대기자는 순서 안 중요하지만, 그래도 확인) --") +do + local waiters = {} + registerNil(waiters, "keep-me-1") + local idx2 = registerNil(waiters, "temp-2") + registerNil(waiters, "keep-me-3") + consumeNil(waiters, idx2) -- 중간 슬롯 소진 -> 구멍 생김 + local visited = {} + for i, v in waiters do + table.insert(visited, tostring(i) .. "=" .. tostring(v)) + end + print("구멍 있는 상태에서 순회(전부 방문되기만 하면 충분, 순서 무관):", table.concat(visited, ", ")) + -- 이제 새 등록이 빈 슬롯(구멍)을 재사용하는지 확인 + local reusedIdx = registerNil(waiters, "reused") + print("새 등록이 빈 슬롯(index=" .. idx2 .. ")을 재사용했는가?", reusedIdx == idx2) +end + +--[[ + 확인 포인트: + 1. Part A — good(None) 쪽은 #t/순서가 항상 N으로 안정적인가(PreRef + pre-pass가 요구하는 성질 재확인). + 2. Part B-1 — nil+재사용 방식은 반복해도 배열이 안 커지는가(0 또는 + 작은 값 유지)? + 3. Part B-2 — None+table.insert 방식은 실제로 1000까지 자라는가 — + 이게 바로 사용자가 찾아낸 "무한 성장" 버그의 정량적 재현. 이 결과가 + 기대와 다르면(예: 실제로는 안 자란다면) bind-system-plan.md의 정정 + 근거 자체를 재검토해야 하니 반드시 알려줄 것. + 4. Part B-3 — 새 등록이 소진된 빈 슬롯(index=idx2)을 실제로 재사용하는가 + — 이게 "table.insert 대신 선형 탐색 재사용 등록 함수"가 실제로 + 의도대로 동작하는지의 핵심 확인. +]] diff --git a/.claude/luau-test/03-recursive-store-bind-dispatch.luau b/.claude/luau-test/03-recursive-store-bind-dispatch.luau new file mode 100644 index 0000000..62b1115 --- /dev/null +++ b/.claude/luau-test/03-recursive-store-bind-dispatch.luau @@ -0,0 +1,168 @@ +--[[ + 검증 대상: process(inst,k,v)/retract(inst,k,v) 기반 재귀 재-dispatch + 모델(.claude/base/bind-system-plan.md "확정된 디스패치 모델" 절)이 실제 + Luau 함수 재귀로 자연스럽게 짜이는지, 우선순위 스캔(isHandlable)이 + 기대대로 동작하는지에 대한 최소 스파이크. + + 배경: ROADMAP.md M0 3번째 항목 "process/retract 재귀 재-process + 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable + 우선순위 스캔 포함)". + + 여기서는 다단 체인(retractUnder)까지는 다루지 않음 — 그건 + 04-dispatch-chain-retractUnder.luau가 별도로 다룸(단일 owner 슬롯 + 추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위 + 스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음 + 핸들러로 자연히 좁혀지는가"까지만 검증. + + 실행: `luau 03-recursive-store-bind-dispatch.luau` + + 참고(2026-08-09 세션 갱신 반영): 아래 makeStore의 `subscribe(fn)`은 + 이 스파이크 전용으로 단순화한 것 — 실제 base 설계는 StoreBind가 + `state:Observer(fn)` + `bindLifetime(inst, observer)`/ + `unbindLifetime(inst, observer)`(`.claude/base/lifecycle-pattern.md`) + 조합으로 구독/해제한다. 여기서 검증하려는 건 그 구독 배관이 아니라 + "우선순위 스캔+재귀 process/retract 자체가 Luau에서 잘 도는가"라서 + 영향 없음 — 실제 Handler 구현 짤 때는 subscribe 대신 저 조합을 쓸 것. +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) + +-- 아주 단순화된 "Store" 시늉 — 실제로는 Source/State가 되겠지만 여기선 +-- 그냥 값+구독자 리스트를 가진 테이블 +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Dispatch 최소 스파이크 +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(handler) + table.insert(handlers, handler) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if h then + print(string.format(" [Dispatch.process] inst=%s k=%s -> handler=%s", tostring(inst), tostring(k), h.name)) + h.process(inst, k, v) + else + print(string.format(" [Dispatch.process] inst=%s k=%s -> 매치되는 핸들러 없음!", tostring(inst), tostring(k))) + end +end + +-- 핸들러 1: NoneHandler (해시 파트 전용, 매우 높은 우선순위) +Dispatch.addHandler({ + name = "NoneHandler", + priority = 1000, + isHandlable = function(inst, k, v) + return v == None + end, + process = function(inst, k, v) + Dispatch.process(inst, k, nil) -- 재귀 재호출 + end, + retract = function() end, +}) + +-- 핸들러 2: StoreBind (store-like 값을 잡아 재귀 재-dispatch) +Dispatch.addHandler({ + name = "StoreBind", + priority = 900, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [StoreBind] %s.%s 값 변경 감지 -> 재귀 process(realv=%s)", + tostring(inst), + tostring(k), + tostring(realv) + ) + ) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) -- 최초 1회 적용 (state:Observer의 "등록 즉시 1회 실행"을 흉내) + end, + retract = function(inst, k, v) + print(string.format(" [StoreBind.retract] %s.%s 구독 해제(흉내)", tostring(inst), tostring(k))) + end, +}) + +-- 핸들러 3: PropertyHandler (catch-all, 가장 낮은 우선순위) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function() end, +}) + +print("=== 1. Store 값을 프로퍼티에 바인드 ===") +local colorStore = makeStore("red") +Dispatch.process("Frame1", "BackgroundColor", colorStore) + +print() +print("=== 2. Store 값 변경 -> 재귀 재-dispatch로 실제 값이 다시 세팅되는가 ===") +colorStore:set("blue") + +print() +print("=== 3. None 센티널 -> nil로 재귀 재-dispatch되어 PropertyHandler로 흘러가는가 ===") +Dispatch.process("Frame1", "Rotation", None) + +print() +print("=== 4. 무한 재귀 없이 종료되는가 ===") +print("위 1~3에서 스택 오버플로/무한 루프 없이 정상 종료됐다면 통과") + +--[[ + 확인 포인트: + 1. 콘솔에 handler=StoreBind가 먼저 찍히고, 그 다음 재귀로 + handler=PropertyHandler가 찍히는가? + 2. colorStore:set("blue") 이후 PropertyHandler가 다시(blue로) 불리는가? + 3. None 케이스가 PropertyHandler까지 자연스럽게 흘러가는가(중간에 + NoneHandler가 한 번만 관여하고 끝나는가)? + 4. table.sort 기반 우선순위 스캔이 매번 안정적으로 같은 순서를 내는가 + (Luau table.sort는 unstable sort일 수 있음 — 동일 priority 핸들러가 + 여러 개면 순서가 실행마다 바뀔 수 있다는 점 주의. 실제 구현에서는 + priority를 세밀하게 나누거나 등록 순서를 tie-breaker로 쓰는 걸 + 검토할 가치가 있어 보임 — 지금 base 문서엔 이 tie-break 규칙이 + 명시돼 있지 않음, 실제로 문제가 되면 base/bind-system-plan.md에 + 추가할 것). +]] diff --git a/.claude/luau-test/04-dispatch-chain-retractUnder.luau b/.claude/luau-test/04-dispatch-chain-retractUnder.luau new file mode 100644 index 0000000..9bf5128 --- /dev/null +++ b/.claude/luau-test/04-dispatch-chain-retractUnder.luau @@ -0,0 +1,201 @@ +--[[ + 검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고, + retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계 + (.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인 + (A->B->C)에서 실제로 정확한지 검증. + + 배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는 + 1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에 + 재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함. + CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단 + 체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)" + 이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증. + + 시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시 + Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store" + 케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로 + PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property) + 3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는 + UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는 + 그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.) + + 실행: `luau 04-dispatch-chain-retractUnder.luau` + + 참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은 + 이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` + + `bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을 + 쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/ + retractUnder 로직 검증엔 영향 없음. +]] + +local StoreTag = {} +local function isStoreLike(v) + return type(v) == "table" and v[StoreTag] == true +end +local function makeStore(initial) + local self = { [StoreTag] = true, value = initial, subscribers = {} } + function self.get(_self) + return self.value + end + function self.set(_self, v) + self.value = v + for _, fn in self.subscribers do + fn(v) + end + end + function self.subscribe(_self, fn) + table.insert(self.subscribers, fn) + end + return self +end + +-- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 — +-- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸) +local chains = {} -- [inst] = { [k] = { handler, handler, ... } } +local function chainFor(inst, k) + chains[inst] = chains[inst] or {} + chains[inst][k] = chains[inst][k] or {} + return chains[inst][k] +end + +local Dispatch = {} +local handlers = {} + +function Dispatch.addHandler(h) + table.insert(handlers, h) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) +end + +function Dispatch.getHandler(inst, k, v) + for _, h in handlers do + if h.isHandlable(inst, k, v) then + return h + end + end + return nil +end + +function Dispatch.process(inst, k, v) + local h = Dispatch.getHandler(inst, k, v) + if not h then + print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v))) + return + end + local list = chainFor(inst, k) + table.insert(list, h) + print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list)) + h.process(inst, k, v) +end + +-- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김 +function Dispatch.retractUnder(inst, k, keep, v) + local list = chainFor(inst, k) + local cutoff = 0 + if keep then + for i, h in list do + if h == keep then + cutoff = i + break + end + end + end + for i = #list, cutoff + 1, -1 do + local retractedHandler = list[i] + local passedValue = (i == cutoff + 1) and v or nil + print( + string.format( + " [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거", + tostring(inst), + tostring(k), + retractedHandler.name, + tostring(passedValue) + ) + ) + retractedHandler.retract(inst, k, passedValue) + list[i] = nil + end +end + +-- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처) +local function makeStoreBindHandler(name, priority) + local self + self = { + name = name, + priority = priority, + isHandlable = function(inst, k, v) + return isStoreLike(v) + end, + process = function(inst, k, store) + local function reprocess(realv) + print( + string.format( + " [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process", + name, + tostring(inst), + tostring(k) + ) + ) + Dispatch.retractUnder(inst, k, self, realv) + Dispatch.process(inst, k, realv) + end + store:subscribe(reprocess) + reprocess(store:get()) + end, + retract = function(inst, k, v) + print(string.format(" [%s.retract] 나 자신(구독) 정리", name)) + end, + } + return self +end + +Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900)) +Dispatch.addHandler({ + name = "PropertyHandler", + priority = 0, + isHandlable = function() + return true + end, + process = function(inst, k, v) + print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) + end, + retract = function(inst, k, v) + print(string.format(" [PropertyHandler.retract] 이전 값 무름")) + end, +}) + +print('=== 1단계: StoreA(값="hello") 바인드 ===') +local storeA = makeStore("hello") +Dispatch.process("Frame1", "Text", storeA) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===") +storeA:set("world") +print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)") + +print() +print("=== 3단계: StoreA 값을 store로 다시 바꿔서(다단 체인 유도) 스트레스 테스트 ===") +local storeB = makeStore("nested") +storeA:set(storeB) +print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) + +print() +print("=== 4단계: 안쪽 StoreB 값을 바꿔서, retractUnder(keep=StoreBindA 자신)가") +print(" 바깥 A는 안 건드리고 그 밑(B 이후)만 정리하는지 확인 ===") +storeB:set("nested-changed") + +--[[ + 확인 포인트 (이게 이 파일의 핵심 목적): + 1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로 + 돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로 + push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가? + (누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류) + 2. 3단계~4단계에서 A(StoreBindA) 자신은 살아남고, 그 밑(구 PropertyHandler + 또는 구 중첩 핸들러)만 정리되는가 — "A가 자길 엉뚱하게 retract하는" + 버그(CLAUDE.md가 기각한 1차 설계의 실패 모드)가 재현되지 않는가? + 3. 체인 길이가 각 단계마다 예상한 값과 정확히 일치하는가(주석에 적어둔 + 기대값과 실제 print 결과를 비교). + 4. 스택 오버플로 없이 전부 정상 종료되는가. +]] diff --git a/.claude/luau-test/05-store-state-diamond-propagation.luau b/.claude/luau-test/05-store-state-diamond-propagation.luau new file mode 100644 index 0000000..b0e0870 --- /dev/null +++ b/.claude/luau-test/05-store-state-diamond-propagation.luau @@ -0,0 +1,157 @@ +--[[ + 검증 대상: 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/luau-test/06-component-boundary-nil-hole-props.luau b/.claude/luau-test/06-component-boundary-nil-hole-props.luau new file mode 100644 index 0000000..fc6b2f5 --- /dev/null +++ b/.claude/luau-test/06-component-boundary-nil-hole-props.luau @@ -0,0 +1,87 @@ +--!strict +--[[ + 검증 대상: 컴포넌트 경계에서 props.Modifier/props.Ref를 "or None"으로 + 감싸 넘기는 필수 관용구가 실제로 nil-hole 문제를 막아주는지, 그리고 + `export type Params = {...}`로 타입 체크되는 컴포넌트 하나가 실제 + Luau에서 문제없이 짜이는지. + + 배경: ROADMAP.md M0 5번째 항목, .claude/base/component-composition-plan.md + "필수 관용구" 절, .claude/research/pre-implementation-audit.md 1-5. + + 두 가지 방법으로 확인 필요함: + 1. 런타임 동작(nil-hole 재현) 확인: `luau 06-component-boundary-nil-hole-props.luau` + 2. 타입 체크(Params 타입, Modifier/Ref 타입 흉내) 확인: + `luau-analyze 06-component-boundary-nil-hole-props.luau` + (luau-analyze가 로컬에 없으면 Luau 공식 릴리즈 CLI 툴체인 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune/rojo 배포판) +]] + +local None = setmetatable({}, { __tostring = function() + return "" +end }) :: any + +-- Modifier/Ref를 아주 얇게 흉내낸 타입(실제 구현 API 모양과 다를 수 있음, +-- 여기선 오직 "props.Modifier or None" 패턴의 타입/런타임 동작만 검증) +type FakeModifier = { isModifier: true } +type FakeRef = { isRef: true } + +export type Params = { + Modifier: FakeModifier?, + Ref: FakeRef?, + Text: string, +} + +local function MyComponent(props: Params) + -- 핵심 관용구 — 이게 없으면 아래 "BAD" 케이스처럼 nil-hole이 생김 + local children = { + props.Modifier or None, + props.Ref or None, + props.Text, + "fixed-child-1", + "fixed-child-2", + } + return children +end + +print("=== BAD: or None 없이 raw로 꽂았을 때 ===") +local badProps: Params = { Text = "hello" } -- Modifier/Ref 둘 다 안 넘김 +local badChildren = { badProps.Modifier, badProps.Ref, badProps.Text, "fixed-child-1", "fixed-child-2" } +print("bad #t =", #badChildren, "(정의된 대로면 5, 하지만 앞쪽 nil-hole 때문에 불안정할 수 있음)") +for i, v in badChildren do + print(" bad[" .. tostring(i) .. "] =", tostring(v)) +end + +print() +print("=== GOOD: or None 관용구 사용 ===") +local goodChildren = MyComponent(badProps) +print("good #t =", #goodChildren, "(항상 5여야 함)") +for i = 1, #goodChildren do + print(" good[" .. tostring(i) .. "] =", tostring(goodChildren[i])) +end + +print() +print("=== 대조군: Modifier/Ref 둘 다 넘겼을 때도 동일하게 동작하는가 ===") +local fullProps: Params = { + Modifier = { isModifier = true }, + Ref = { isRef = true }, + Text = "hello", +} +local fullChildren = MyComponent(fullProps) +print("full #t =", #fullChildren, "(항상 5)") + +--[[ + 확인 포인트 (런타임 실행): + 1. bad #t가 5가 아니거나(예: 3), 순회 시 앞쪽 두 슬롯이 이상하게 뒤로 + 밀리거나 사라지는가 — 이게 실제 nil-hole 버그의 재현. + 2. good/full 양쪽 모두 #t가 정확히 5이고, 순서(Modifier자리, Ref자리, + Text, fixed-child-1, fixed-child-2)가 항상 지켜지는가. + + 확인 포인트 (luau-analyze 타입 체크): + 1. `export type Params`가 옵셔널 Modifier?/Ref? 필드로 문제없이 + 타입체크되는가. + 2. `props.Modifier or None`에서 None을 `any`로 캐스팅해뒀는데, 이걸 + 실제 Modifier/None 유니온 타입으로 더 정확히 표현하려면 어떤 타입 + 선언이 필요한지(예: `type Slot = T | typeof(None)`류) 실 Luau + 에러 메시지를 보고 판단해볼 것 — 지금 파일은 `any` 캐스팅으로 + 일단 회피해뒀음, 이 부분은 M7/M8 실제 구현 시 정확한 타입을 찾아야 함. +]] diff --git a/.claude/luau-test/07-relate-weak-table-gc.luau b/.claude/luau-test/07-relate-weak-table-gc.luau new file mode 100644 index 0000000..3e7e39e --- /dev/null +++ b/.claude/luau-test/07-relate-weak-table-gc.luau @@ -0,0 +1,135 @@ +--[[ + 검증 대상: .claude/base/relate-plan.md가 확정한 Relate의 실제 구조 + ({ [inst(weak)]: { StrongMap?, WeakMap? } })가 Luau의 진짜 weak-table + GC 동작과 맞아떨어지는지 — lazy 서브테이블 생성, WeakMap 공유 + 메타테이블, 그리고 무엇보다 "inst가 죽으면 중첩된 것까지 전부 + 같이 GC되는가"라는 .claude/base/bind-system-plan.md "왜 GC-안전한가" + 절의 핵심 주장 자체. + + 배경: .claude/base/relate-plan.md "M2 착수 시 실측 확인" 캐비엇. + + 중요한 제약: Roblox의 실제 게임 스크립트 환경에는 collectgarbage()가 + 노출되지 않음(강제 GC 트리거 불가) — 그래서 이 GC 타이밍 검증은 + Roblox Studio가 아니라 반드시 순수 luau CLI에서 해야 함(standalone + Luau 인터프리터는 collectgarbage를 허용). Roblox 쪽은 VM/GC 구현 + 자체가 같은 Luau이므로 여기서 확인된 동작이 그대로 적용된다고 가정할 + 수 있지만, "그대로 적용된다"는 가정 자체는 이 스크립트로 검증 불가능한 + 항목으로 남음(참고만 할 것). + + 실행: `luau 07-relate-weak-table-gc.luau` +]] + +local sharedWeakValueMeta = { __mode = "v" } + +local function Relate() + local outer = setmetatable({}, { __mode = "k" }) -- inst는 항상 weak + local relate = {} + + local function subtable(inst) + local t = outer[inst] + if not t then + t = {} + outer[inst] = t + end + return t + end + + function relate.SetStrong(_, inst, key, value) + local t = subtable(inst) + t.StrongMap = t.StrongMap or {} + t.StrongMap[key] = value + end + function relate.GetStrong(_, inst, key) + local t = outer[inst] + if not t or not t.StrongMap then + return nil + end + return t.StrongMap[key] + end + function relate.SetWeak(_, inst, key, value) + local t = subtable(inst) + if not t.WeakMap then + t.WeakMap = setmetatable({}, sharedWeakValueMeta) + end + t.WeakMap[key] = value + end + function relate.GetWeak(_, inst, key) + local t = outer[inst] + if not t or not t.WeakMap then + return nil + end + return t.WeakMap[key] + end + + -- 디버깅 전용 — 실제 Relate API엔 없음, 이 스파이크에서 관찰용으로만 + function relate._debugHasSubtable(_, inst) + return outer[inst] ~= nil + end + + return relate +end + +print("=== 1. lazy 생성 확인 ===") +local relate1 = Relate() +local instA = {} -- 실제로는 Roblox Instance지만, 순수 luau CLI엔 없으므로 plain table로 대체 +print("Set 호출 전 subtable 존재?", relate1:_debugHasSubtable(instA), "(false여야 함)") +relate1:SetStrong(instA, "k1", "v1") +print("SetStrong 호출 후 subtable 존재?", relate1:_debugHasSubtable(instA), "(true여야 함)") +print("GetStrong(instA, k1) =", relate1:GetStrong(instA, "k1")) +print("GetWeak(instA, 아무거나) — WeakMap 아직 안 만들어졌어도 nil로 안전하게 반환?", relate1:GetWeak(instA, "nope")) + +print() +print("=== 2. inst가 스코프를 벗어나면 그 안의 StrongMap도 같이 사라지는가(간접 확인) ===") +local relate2 = Relate() +do + local instB = {} + relate2:SetStrong(instB, "tween", "FAKE_TWEEN_INSTANCE") + print("instB 살아있을 때 GetStrong =", relate2:GetStrong(instB, "tween")) + -- instB에 대한 유일한 강참조는 이 do-블록의 로컬 변수뿐 — 블록을 벗어나면 사라짐 +end +collectgarbage() -- 표준 luau CLI에서 지원(Roblox에선 사용 불가 — 위 주석 참고) +collectgarbage() +print("(instB 참조를 잃었으므로 같은 값으로 재조회는 애초에 불가능 — 아래 3번이 실질 확인)") + +print() +print("=== 3. weak key가 실제로 GC되는지 카운팅으로 확인 ===") +local relate3 = Relate() +local keepAlive = {} -- 이 배열에 담긴 것만 살아남음 +for i = 1, 100 do + local inst = {} + relate3:SetStrong(inst, "data", "payload" .. i) + if i <= 10 then + keepAlive[i] = inst -- 앞 10개만 강하게 붙잡아둠 + end + -- 나머지 90개는 루프 변수 스코프를 벗어나는 즉시 참조를 잃음 +end +collectgarbage() +collectgarbage() + +local aliveCount = 0 +for i = 1, 10 do + if relate3:GetStrong(keepAlive[i], "data") ~= nil then + aliveCount += 1 + end +end +print("강하게 붙잡아둔 10개 중 살아있는 것:", aliveCount, "(10이어야 함)") + +print() +print('=== 참고: collectgarbage("count") 메모리 변화(대략적 신호일 뿐) ===') +print(collectgarbage("count"), "KB") + +--[[ + 확인 포인트: + 1. 1번 섹션 — SetStrong 호출 전엔 subtable이 안 만들어져 있다가, 호출 + 순간에만 생기는가(lazy 생성 실측). + 2. 3번 섹션 — collectgarbage()가 실제로 동작하고(에러 안 나고), + 강하게 붙잡아둔 10개는 살아있는가(당연히 그래야 함 — sanity check). + 3. **가장 중요한 미해결 관찰**: 이 스크립트는 "죽은 90개가 실제로 + GC됐는지"를 직접 카운트하지 못함(Luau가 weak table 내부 엔트리 + 개수를 세는 표준 API를 안 줌) — `collectgarbage("count")`로 전체 + 메모리 사용량 변화를 보는 정도가 간접 확인의 최선. 필요하면 위 + 3번 섹션의 루프를 더 크게(예: 100 -> 1,000,000) 돌리면서 루프 + 전후 collectgarbage("count") 차이를 비교해보면 신호가 더 뚜렷해질 + 수 있음(주의: GC는 정확한 타이밍을 보장 안 하므로 완벽한 증거는 + 아님, 참고 신호 정도로만 볼 것). +]] diff --git a/.claude/luau-test/08-type-source-satisfies-state.luau b/.claude/luau-test/08-type-source-satisfies-state.luau new file mode 100644 index 0000000..290e8fc --- /dev/null +++ b/.claude/luau-test/08-type-source-satisfies-state.luau @@ -0,0 +1,78 @@ +--!strict +--[[ + 검증 대상: Source가 구조적으로 State를 만족하는(self 타이핑 + + State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게 + 추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상. + + 배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크 + 대상)" 절, ROADMAP.md M0 2번째 항목. + + 핵심 우려: State가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau + 솔버가 취약한 패턴 — 그래서 아래 State는 Source를 전혀 참조하지 + 않도록 독립적으로 먼저 정의하고, Source만 State를 단방향으로 + 참조하게 구성함. 타입은 사용자 선호대로 &(교차)가 아니라 손으로 + 펼쳐 씀(런타임 구현 델리게이션과는 별개 축이라 상관없음). + + 실행: `luau-analyze 08-type-source-satisfies-state.luau` + (로컬에 luau-analyze가 없으면 Luau 공식 릴리즈의 CLI 툴체인 설치 필요 — + https://github.com/luau-lang/luau/releases, 또는 lune 배포판에 포함된 것) + + 기대 결과: 에러 없이 통과하거나, 통과 안 하면 정확히 *어느 줄에서* + *무슨 에러*가 나는지가 다음 결정에 중요한 정보임 — 에러가 나면 그 + 메시지를 그대로 가져와서 알려줄 것. +]] + +-- State는 Source를 절대 참조하지 않음(단방향 의존을 위한 핵심 제약) +export type State = { + Get: (self: State) -> T, + With: (self: State, ...State) -> State, + Compute: (self: State, fn: (T) -> U) -> State, +} + +-- Source만 State를 참조(단방향) — self 타이핑(Source 자신을 가리킴)과 +-- 바깥 타입 참조(State)가 섞인 제네릭 메소드가 바로 검증 대상 +export type Source = { + Get: (self: Source) -> T, + With: (self: Source, ...State) -> State, + Compute: (self: Source, fn: (T) -> U) -> State, + Set: (self: Source, value: T) -> (), + Emit: (self: Source) -> (), +} + +-- 1. Source 값을 만드는 흉내 생성자(런타임 구현은 아직 없으므로 타입만 맞추는 더미) +local function fakeSource(default: T): Source + return (nil :: any) :: Source +end + +-- 2. State를 요구하는 함수에 Source를 그대로 넘길 수 있는가 +-- (구조적 서브타이핑 — "Source가 State를 만족함" 절의 핵심 주장) +local function useAsState(s: State): T + return s:Get() +end + +local mySource: Source = fakeSource(0) +local viaSubtype: number = useAsState(mySource) -- 여기가 타입체크 되는지가 핵심 + +-- 3. Compute 체이닝이 제네릭을 타고 잘 흐르는가(Source -> State -> State) +local derived1: State = mySource:Compute(function(n: number): string + return tostring(n) +end) +local derived2: State = derived1:Compute(function(s: string): boolean + return #s > 0 +end) + +-- 4. store.key가 Source를 직접 반환한다는 모델(레코드 필드 읽기/쓰기 대칭) +export type Store = { + -- 실제로는 defaults의 각 키를 Source<...>로 매핑하는 mapped type이 이상적이지만 + -- Luau에 mapped type이 없으므로(2026-08 시점) 구체 예시 하나로만 검증 + Health: Source, +} + +local function useStore(store: Store) + store.Health:Set(100) -- 쓰기 + local hp: number = store.Health:Get() -- 읽기 — 같은 필드 타입(Source)으로 대칭 + return hp +end + +print("이 파일은 luau-analyze로만 의미가 있음 (런타임 실행은 그냥 통과함)") +print(viaSubtype, derived2) diff --git a/.claude/luau-test/09-type-modifier-overridden-subtype.luau b/.claude/luau-test/09-type-modifier-overridden-subtype.luau new file mode 100644 index 0000000..69be83c --- /dev/null +++ b/.claude/luau-test/09-type-modifier-overridden-subtype.luau @@ -0,0 +1,73 @@ +--!strict +--[[ + 검증 대상: Modifier.Overridden(mod1, mod2, ...)가 서브타입 관계인 + 서로 다른 Modifier 타입(FrameModifier <: GuiObjectModifier)을 섞을 때 + 타입이 통과하는지 — 필드 setter가 전부 self를 반환하는 fluent 타입이라 + 구조적 서브타이핑이 실제로 성립하는지가 관건. + + 배경: .claude/base/modifier-plan.md 9-2번 절, ROADMAP.md M7. + "막히는 지점"으로 문서가 지목한 것: `:BackgroundColor3` 같은 메소드가 + FrameModifier에서는 FrameModifier를, GuiObjectModifier에서는 + GuiObjectModifier를 리턴하므로 같은 이름 필드의 리턴 타입이 갈려서 + 단순 구조적 서브타이핑이 깨질 수 있음. + + 이 파일은 두 버전을 나란히 둠: + A) "정직한" 버전 — 메소드 리턴 타입이 각자 자기 자신 + B) fallback 버전 — 문제가 생기면 쓸 `Overridden(...: any): any` 완화형 + luau-analyze를 돌려서 A가 실제로 어디서 막히는지(또는 안 막히는지) + 확인하는 게 목적. + + 실행: `luau-analyze 09-type-modifier-overridden-subtype.luau` +]] + +-- ===== A) 정직한 버전 ===== + +export type GuiObjectModifier = { + -- Color3 대신 number로 단순화(luau-analyze 단독 실행 환경엔 Roblox 타입이 없을 수 있어서) + BackgroundColor3: (self: GuiObjectModifier, v: number) -> GuiObjectModifier, + Apply: (self: GuiObjectModifier, f: (GuiObjectModifier) -> GuiObjectModifier) -> GuiObjectModifier, +} + +export type FrameModifier = { + BackgroundColor3: (self: FrameModifier, v: number) -> FrameModifier, + Apply: (self: FrameModifier, f: (FrameModifier) -> FrameModifier) -> FrameModifier, + ClipsDescendants: (self: FrameModifier, v: boolean) -> FrameModifier, -- Frame 전용 필드 +} + +local function fakeFrameModifier(): FrameModifier + return (nil :: any) :: FrameModifier +end + +-- 시도 1: FrameModifier 값을 GuiObjectModifier 변수에 그대로 대입 — 통과하는가? +local frameMod: FrameModifier = fakeFrameModifier() +local asGuiObjectMod: GuiObjectModifier = frameMod -- <- 여기가 luau-analyze 에러 나는지 확인 포인트 1 + +-- 시도 2: Overridden을 GuiObjectModifier 시그니처로 선언하고 FrameModifier를 인자로 넘김 +local function OverriddenHonest(...: GuiObjectModifier): GuiObjectModifier + return (nil :: any) :: GuiObjectModifier +end +local result1 = OverriddenHonest(frameMod) -- <- 확인 포인트 2 + +-- ===== B) fallback(any) 버전 ===== + +local function OverriddenLoose(...: any): any + return (nil :: any) +end +local result2 = OverriddenLoose(frameMod, asGuiObjectMod) -- 이건 항상 통과해야 함(any이므로) + +print("런타임 실행 자체는 의미 없음 — luau-analyze 출력을 확인할 것") +print(result1, result2) + +--[[ + 확인 포인트: + 1. "시도 1"(asGuiObjectMod 대입)에서 luau-analyze가 에러를 내는가? + 낸다면 정확한 에러 메시지(타입 불일치 상세)를 기록해둘 것 — + BackgroundColor3 필드의 리턴 타입 불일치 때문인지, 아니면 다른 + 이유인지가 다음 설계 결정에 중요함. + 2. "시도 2"(함수 인자로 넘기기)도 같은 결과가 나오는가, 아니면 대입과 + 함수 인자 전달이 Luau에서 다르게 취급되는가(공변성 처리 차이 가능성). + 3. A가 전부 막히면 -> .claude/base/modifier-plan.md 9-2번의 fallback대로 + `Overridden(...: any): any`로 확정하고 이 항목을 M7에서 다시 열 것. + A가 통과하면 -> 서브타입 체이닝을 정식으로 타입에 반영할 수 있다는 + 뜻이니 그 결과를 modifier-plan.md에 반영할 것. +]] diff --git a/.claude/luau-test/10-roblox-studio-checks.server.luau b/.claude/luau-test/10-roblox-studio-checks.server.luau new file mode 100644 index 0000000..93a5d5c --- /dev/null +++ b/.claude/luau-test/10-roblox-studio-checks.server.luau @@ -0,0 +1,206 @@ +--[[ + 검증 대상 (Roblox Studio 전용 — 순수 luau CLI로는 안 됨, 실제 Instance/ + Connection/CollectionService/Attribute가 필요함): + + A) bindLifetime/unbindLifetime/canExecute의 gcconn 트릭 — Observer/ + Effect 값의 이중 바인딩을 canBound로 막는지까지 포함해서 검증 + (2026-08-09 세션에 unbindLifetime 추가 + canBound 이름 확정 + + gchold를 배열이 아니라 value를 키로 쓰는 테이블로 바꾼 것까지 반영 + — 이전 버전의 이 스크립트는 array 기반 gchold였음, 이번에 정정). + B) Attribute가 Instance 참조 타입을 실제로 지원하는가(ObjectValue + 없이 Ref 용도로 쓸 수 있다는 CLAUDE.md 서술의 실측). + C) CollectionService 태그 + GetTagged 왕복이 quad-debug가 기대하는 + 대로 동작하는가(태그 추가/제거, GetTagged로 조회). + + 배경: .claude/base/lifecycle-pattern.md "bindLifetime/canExecute/ + unbindLifetime — 확정" 절 + "실측 필요(M0/M2)" 캐비엇, + .claude/base/bind-system-plan.md "이중 바인딩 금지" 절(canBound), + CLAUDE.md 2026-08-06 세션의 Attribute Instance 참조 지원 언급, + .claude/research/debug-tooling-plan.md의 CollectionService 노출 방식. + + 실행 방법: + 1. Roblox Studio에서 아무 place나 열고(빈 baseplate로 충분), + ServerScriptService에 이 파일 내용을 그대로 붙여넣은 Script를 + 하나 만든다. + 2. Play(F5) 또는 Run(F8) — Output 창에서 결과를 확인. + 3. 확인 끝나면 이 Script는 지워도 됨(Studio 안에 실제로 만든 Script + 얘기 — 이 원본 파일 자체는 `.claude/luau-test/`에 참고용으로 + 남겨둠). + + 주의: HUMAN_TODO.md 1번(Studio 별도 계정) 확인 후 실행할 것 — + SAFETY.md 준수. +]] + +print("========================================") +print("A) bindLifetime/unbindLifetime/canExecute/canBound gcconn 트릭") +print("========================================") + +do + local relate = {} -- 이 스파이크 전용 아주 단순한 strong map (inst -> {gcconn, gchold}) + + -- Observer/Effect를 흉내낸 최소 값 — .Subscribed 필드가 canExecute/canBound가 + -- 공유하는 그 필드(base/bind-system-plan.md "이중 바인딩 금지" 절 참고) + local function fakeObserver() + return { isObserverSpike = true, Subscribed = false } + end + local function isObserverLike(v) + return type(v) == "table" and v.isObserverSpike == true + end + + -- canBound(handle) — "아직 어느 경로로도 안 묶였으면 true" + local function canBound(value) + return not (isObserverLike(value) and value.Subscribed) + end + + local function bindLifetime(inst: Instance, value: any) + local isOE = isObserverLike(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + + local entry = relate[inst] + if not entry then + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 + local gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() + -- 이 콜백은 정상적으로는 절대 발화하면 안 됨 — 발화하면 그 자체가 + -- "ClassName이 신호를 절대 안 쏜다"는 가정이 틀렸다는 증거이므로 경고. + warn("[예상 밖] ClassName Changed가 실제로 발화함! gcconn 트릭의 전제가 깨짐:", inst:GetFullName()) + local _ = gchold + end) + entry = { gcconn = gcconn, gchold = gchold } + relate[inst] = entry + end + entry.gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + if isOE then + value.Subscribed = true -- canExecute/canBound가 보는 필드 그대로 재사용 + end + end + + local function unbindLifetime(inst: Instance, value: any) + local entry = relate[inst] + if entry then + entry.gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제(O(1)) + end + if isObserverLike(value) then + value.Subscribed = false + end + end + + local function canExecute(inst: Instance, value: any): boolean + if isObserverLike(value) and not value.Subscribed then + return false + end + local entry = relate[inst] + return entry ~= nil and entry.gcconn.Connected + end + + local target = Instance.new("Folder") + target.Name = "QuadLifetimeSpikeTarget" + target.Parent = workspace + + local obs1 = fakeObserver() + bindLifetime(target, obs1) + print("bindLifetime 직후 canExecute(target, obs1) =", canExecute(target, obs1), "(true여야 함)") + + print() + print("-- A-1) canBound 이중 바인딩 게이트: 같은 obs1을 또 bindLifetime하면 error가 나야 함 --") + local ok, err = pcall(function() + bindLifetime(target, obs1) + end) + print("두 번째 bindLifetime(obs1) 성공?", ok, "(false여야 함)", not ok and tostring(err) or "") + + print() + print("-- A-2) unbindLifetime: 특정 값 하나만 조기 해제, inst 전체엔 영향 없어야 함 --") + local obs2 = fakeObserver() + bindLifetime(target, obs2) + print("obs2 bindLifetime 직후 canExecute =", canExecute(target, obs2), "(true)") + unbindLifetime(target, obs2) + print("obs2 unbindLifetime 이후 canExecute =", canExecute(target, obs2), "(false여야 함, .Subscribed가 다시 false)") + print("obs1(같은 inst, 안 건드림)은 여전히 canExecute =", canExecute(target, obs1), "(true여야 함 — obs2 해제가 obs1에 영향 없어야 함)") + print("unbindLifetime 이후 같은 obs2를 다시 bindLifetime 가능한가(canBound가 재바인딩 허용하는지)?") + local ok2 = pcall(function() + bindLifetime(target, obs2) + end) + print("재-bindLifetime(obs2) 성공?", ok2, "(true여야 함 — unbindLifetime이 canBound를 다시 통과시켜야 함)") + + print() + print("-- A-3) Destroy 시 canExecute가 false로 바뀌는가(gcconn.Connected 확인) --") + target:Destroy() + print( + "Destroy 후 canExecute(target, obs1) =", + canExecute(target, obs1), + "(false여야 함 — Connection.Connected가 Destroy로 즉시 끊기는지 확인)" + ) + + -- 5초 정도 대기하며 위 warn이 늦게라도 튀어나오는지 관찰(비동기 우려 대비) + task.delay(5, function() + print("[A] 5초 대기 종료 — 그 사이 warn이 안 떴다면 gcconn 트릭 전제가 안전함") + end) +end + +print() +print("========================================") +print("B) Attribute의 Instance 참조 타입 지원 여부") +print("========================================") + +do + local target = Instance.new("Folder") + target.Name = "QuadAttributeRefSpikeTarget" + target.Parent = workspace + + local holder = Instance.new("Folder") + holder.Name = "QuadAttributeRefSpikeHolder" + holder.Parent = workspace + + local ok, err = pcall(function() + holder:SetAttribute("RefToTarget", target) + end) + print("SetAttribute(Instance) 성공?", ok, err and tostring(err) or "") + + if ok then + local readBack = holder:GetAttribute("RefToTarget") + print("GetAttribute 결과가 원본과 같은 Instance인가?", readBack == target) + end + + -- 대상이 Destroy되면 Attribute는 어떻게 되는가(참고 확인 — nil로 풀리는지, + -- 아니면 죽은 참조를 계속 들고 있는지는 Ref 설계에 영향을 줄 수 있음) + target:Destroy() + task.wait() + local afterDestroy = holder:GetAttribute("RefToTarget") + print("target Destroy 후 GetAttribute =", afterDestroy, "(nil로 풀리는지, 죽은 참조 그대로인지 확인)") + + holder:Destroy() +end + +print() +print("========================================") +print("C) CollectionService 태그 + GetTagged 왕복") +print("========================================") + +do + local CollectionService = game:GetService("CollectionService") + local TAG = "QuadDebugSpikeTag" + + local a = Instance.new("Folder") + a.Name = "TaggedA" + a.Parent = workspace + local b = Instance.new("Folder") + b.Name = "TaggedB" + b.Parent = workspace + + CollectionService:AddTag(a, TAG) + CollectionService:AddTag(b, TAG) + + local tagged = CollectionService:GetTagged(TAG) + print("GetTagged 결과 개수 =", #tagged, "(2여야 함)") + + CollectionService:RemoveTag(a, TAG) + local taggedAfterRemove = CollectionService:GetTagged(TAG) + print("RemoveTag 이후 GetTagged 개수 =", #taggedAfterRemove, "(1이어야 함)") + + a:Destroy() + b:Destroy() +end + +print() +print("모든 섹션 실행 완료 — Output 로그를 그대로 복사해서 공유해주면 됨") diff --git a/.claude/luau-test/11-modifier-illegal-value-error.luau b/.claude/luau-test/11-modifier-illegal-value-error.luau new file mode 100644 index 0000000..29a8d31 --- /dev/null +++ b/.claude/luau-test/11-modifier-illegal-value-error.luau @@ -0,0 +1,302 @@ +--[[ + 검증 대상: 2026-08-09 세션에 "UB, 방어 없음"에서 "즉시 error"로 전환된 + 두 규칙이 실제 Luau에서 자연스럽게 짜이는지 (신규 파일 — 이 폴더의 + 1차 작성 이후 새로 확정된 내용이라 이걸 검증하는 스크립트가 + 없었음): + + A) Modifier 필드에 핸들러 계층 값(Ref/PreRef/Observer/Effect/Slot/ + Modifier)이 들어오면 제네릭 __index 셋터가 최종 저장 직전에 즉시 + error. State/Source 값은 여전히 허용. + B) State/Source 자체의 "확정되는 값"(Source:Set, Store({defaults}) + 생성 시 각 default, State:Compute(fn)의 캐싱 직전)이 Modifier이면 + 즉시 error. Slot/Tag/Attribute/Tween 같은 다른 핸들러 계층 값은 + 여전히 허용(Modifier만의 예외). + + 배경: .claude/base/modifier-plan.md "Modifier 필드에 핸들러 계층 값이 + 들어오면 즉시 error" 절 + "7. State/Source가 Modifier를 값으로 담는 + 것 — 명시적 error로 확정" 절(둘 다 2026-08-09 세션 정정, 이전엔 + "UB, 가능하면 타입으로 막을 것"이었음). + + 실행: `luau 11-modifier-illegal-value-error.luau` +]] + +-- ===== Brand 흉내 — 실제로는 base/bind-system-plan.md의 Brand 절이 다루는 +-- weak-key 레지스트리 기반이지만, 이 스파이크에선 태그 필드로 단순화 ===== + +local function tag(name) + return function(t) + return setmetatable(t or {}, { __index = { __brand = name } }) + end +end + +local function brandOf(v) + if type(v) ~= "table" then + return nil + end + local mt = getmetatable(v) + return mt and mt.__index and mt.__index.__brand +end + +local makeRef = tag("Ref") +local makePreRef = tag("PreRef") +local makeObserver = tag("Observer") +local makeEffect = tag("Effect") +local makeSlot = tag("Slot") + +local function isRef(v) + return brandOf(v) == "Ref" +end +local function isPreRef(v) + return brandOf(v) == "PreRef" +end +local function isObserver(v) + return brandOf(v) == "Observer" +end +local function isEffect(v) + return brandOf(v) == "Effect" +end +local function isSlot(v) + return brandOf(v) == "Slot" +end + +-- ===== Modifier — 제네릭 __index 셋터 + "핸들러 계층 값 즉시 error" 체크 ===== + +local ModifierBrand = {} +local function isModifier(v) + return type(v) == "table" and v[ModifierBrand] == true +end +local function isState(v) + -- Source가 State를 구조적으로 만족(store-semantics.md) — 여기선 둘 다 + -- ".__isStateLike" 태그로 단순화해서 흉내 + return type(v) == "table" and v.__isStateLike == true +end + +local function illegalModifierFieldValue(v) + return isRef(v) or isPreRef(v) or isObserver(v) or isEffect(v) or isSlot(v) or isModifier(v) +end + +local function Modifier(initial) + local self = initial and table.clone(initial) or {} + self[ModifierBrand] = true + return setmetatable(self, { + __index = function(t, key) + -- 제네릭 setter 합성(modifier-plan.md 4번 절의 __index 트릭) + return function(selfArg, arg) + local clone = table.clone(selfArg) + local value + if type(arg) == "function" and not isState(selfArg[key]) then + -- plain 필드 + 함수 인자: 즉시 호출해 값 확정 (State 분기는 이 스파이크에서 생략) + value = arg(selfArg[key]) + else + value = arg + end + + -- 핵심 체크 지점: 최종 저장 직전 + if illegalModifierFieldValue(value) then + error( + string.format( + "Modifier 필드 '%s'에 핸들러 계층 값(%s)을 저장할 수 없음", + tostring(key), + tostring(brandOf(value) or (isModifier(value) and "Modifier") or "?") + ) + ) + end + + clone[key] = value + return clone + end + end, + }) +end + +print("=== A. Modifier 필드에 핸들러 계층 값 -> 즉시 error ===") + +local mod = Modifier() + +local casesA = { + { name = "plain 리터럴(허용)", fn = function() + return mod:FontSize(20) + end, expectError = false }, + { name = "State 유사 값(허용)", fn = function() + return mod:TextColor(setmetatable({ __isStateLike = true }, {})) + end, expectError = false }, + { name = "Ref(금지)", fn = function() + return mod:SomeField(makeRef()) + end, expectError = true }, + { name = "PreRef(금지)", fn = function() + return mod:SomeField(makePreRef()) + end, expectError = true }, + { name = "Observer(금지)", fn = function() + return mod:SomeField(makeObserver()) + end, expectError = true }, + { name = "Effect(금지)", fn = function() + return mod:SomeField(makeEffect()) + end, expectError = true }, + { name = "Slot(금지)", fn = function() + return mod:SomeField(makeSlot()) + end, expectError = true }, + { name = "다른 Modifier(금지)", fn = function() + return mod:SomeField(Modifier()) + end, expectError = true }, + { + name = "변환 함수가 Ref를 반환(금지 — 콜백이어도 최종값만 봄)", + fn = function() + return mod:SomeField(function(old) + return makeRef() + end) + end, + expectError = true, + }, +} + +for _, case in casesA do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +-- ===== B. State/Source가 확정하는 값이 Modifier면 즉시 error ===== + +print() +print("=== B. Source:Set / Store 생성 / State:Compute 캐싱 -> Modifier면 즉시 error ===") + +local function checkNotModifier(value, where) + if isModifier(value) then + error(where .. ": Modifier를 State/Source 값으로 저장할 수 없음") + end +end + +local function Source(default) + checkNotModifier(default, "Source(default)") + local self = { __isStateLike = true, value = default } + function self:Get() + return self.value + end + function self:Set(v) + checkNotModifier(v, "Source:Set") + self.value = v + end + function self:Compute(fn) + local derived = { __isStateLike = true, dirty = true } + function derived:Get() + if self.dirty then + local result = fn(self.value) + checkNotModifier(result, "State:Compute 캐싱") + derived.cached = result + derived.dirty = false + end + return derived.cached + end + return derived + end + return self +end + +local function Store(defaults) + local sources = {} + for k, v in defaults or {} do + sources[k] = Source(v) -- 여기서도 checkNotModifier가 자연히 걸림 + end + return sources +end + +local casesB = { + { + name = "Source(plain 초기값) — 허용", + fn = function() + return Source(1) + end, + expectError = false, + }, + { + name = "Source(Modifier 초기값) — 금지", + fn = function() + return Source(Modifier()) + end, + expectError = true, + }, + { + name = "source:Set(plain) — 허용", + fn = function() + local s = Source(1) + s:Set(2) + end, + expectError = false, + }, + { + name = "source:Set(Modifier) — 금지", + fn = function() + local s = Source(1) + s:Set(Modifier()) + end, + expectError = true, + }, + { + name = "Store({defaults}) 중 하나가 Modifier — 금지", + fn = function() + return Store({ Health = 100, Style = Modifier() }) + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Modifier를 반환 — Get() 호출 시점에 금지", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return Modifier() + end) + derived:Get() -- 캐싱 시점에 걸려야 함 + end, + expectError = true, + }, + { + name = "state:Compute(fn)이 Slot을 반환 — 허용(Modifier만의 예외)", + fn = function() + local s = Source(1) + local derived = s:Compute(function(v) + return makeSlot() + end) + derived:Get() + end, + expectError = false, + }, +} + +for _, case in casesB do + local ok, err = pcall(case.fn) + local pass = (ok == not case.expectError) + print( + string.format( + " [%s] %s: ok=%s expectError=%s %s", + pass and "PASS" or "FAIL", + case.name, + tostring(ok), + tostring(case.expectError), + (not ok) and ("(error: " .. tostring(err) .. ")") or "" + ) + ) +end + +--[[ + 확인 포인트: + 1. 모든 케이스가 "PASS"로 찍히는가 — FAIL이 있으면 어느 케이스인지, + 기대와 실제가 어떻게 달랐는지 알려줄 것. + 2. A의 마지막 케이스("변환 함수가 Ref를 반환")처럼 "콜백이 반환한 값"도 + 리터럴과 동일하게 잡히는지 — modifier-plan.md가 명시한 "콜백이냐 + 직접 실행이냐를 구분하지 않고 최종 저장값 하나만 본다"는 원칙의 핵심. + 3. B에서 Slot 같은 "Modifier가 아닌 다른 핸들러 계층 값"은 State/Source에 + 여전히 자유롭게 들어갈 수 있는가(Modifier만의 예외라는 걸 재확인). + 4. 이 스파이크는 Brand/isState를 태그 필드로 단순화한 것 — 실제 구현은 + base/bind-system-plan.md의 weak-key 레지스트리 기반 Brand를 씀, + 여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만 + 검증 대상. +]] diff --git a/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau new file mode 100644 index 0000000..75a7074 --- /dev/null +++ b/.claude/luau-test/12-type-attribute-generic-key-narrowing.luau @@ -0,0 +1,93 @@ +--!strict +--[[ + 검증 대상: `[Attribute<> "name"] = value`처럼 제네릭 파라미터로 + 타입을 명시하는 특수 DI 키를 테이블 리터럴에 쓸 때, `=` 뒤 `value`의 + 타입이 실제로 그 제네릭 파라미터로 좁혀지는지 — Luau 타입 솔버가 + "이 계산된 키의 제네릭 인스턴스에 따라 옆 값의 타입이 달라진다"는 + 이질적인(heterogeneous) 매핑을 실제로 풀 수 있는지가 핵심. + + 배경: .claude/base/attribute-plan.md "[실측 필요, M0/M10]" 절 + (2026-08-09 열한 번째 세션에 새로 명시된 항목 — base 문서 자신이 + "미검증"이라고 못박아둔 몇 안 되는 곳). 문서 원문: "Luau 솔버가 이 + 조합을 못 풀면 value가 any로 남을 수 있음 — 단, 타입 추론이 안 + 되더라도 런타임 동작에는 영향 없음". 이 스크립트는 그 예상을 실제 + Luau로 확인하는 것. + + 실행: `luau-analyze 12-type-attribute-generic-key-narrowing.luau` + (또는 luau-lsp로 이 파일을 열어 인라인 진단을 확인 — 사용자가 직접 + luau-lsp로 확인할 예정) + + 참고: 이건 Roblox 실제 SetAttribute API 타입이 아니라, "제네릭 DI 키 + + 테이블 리터럴 값 타입 연동"이라는 메커니즘 자체만 최소로 흉내낸 + 것 — Roblox 전역 타입이 필요 없어서 luau-lsp의 sourcemap 없이도 + 그대로 확인 가능함. +]] + +-- SpecialKey — Attribute<>(name)이 반환하는 "타입이 실린 키" 흉내 +type SpecialKey = { __attributeKeyBrand: T } + +local function Attribute(name: string): SpecialKey + return (nil :: any) :: SpecialKey +end + +-- ===== 시도 1: 동질적(homogeneous) 인덱스 시그니처 — 항상 통과해야 함(비교군) ===== +-- 이 방식은 "이 테이블의 모든 특수 키가 전부 boolean 값이어야 한다"는 +-- 고정된 단일 인스턴스라, 애초에 여러 타입을 섞을 수 없음 — 진짜 검증 +-- 대상이 아니라 대조군. +type HomogeneousParams = { + [SpecialKey]: boolean, +} + +local homo: HomogeneousParams = { + [Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함 +} + +-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 ===== +-- 이게 진짜 검증 대상: SpecialKey의 T가 키마다 달라도 값이 그 T로 +-- 각각 좁혀지는가? (TypeScript의 mapped/conditional type이 있어야 되는 +-- 문제 — Luau에 해당 기능이 없으면 아래 셋 중 하나가 일어날 것으로 예상: +-- (a) 두 번째 대입에서 타입 에러, (b) 값 타입이 조용히 any/union으로 +-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러) + +local mixedProps: { [SpecialKey]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것) +mixedProps[Attribute("Enabled")] = true +mixedProps[Attribute("Count")] = 5 + +-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가 +-- `Attribute(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을 +-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는 +-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입" +-- 문제로 좁혀서 아래처럼 직접 테스트: + +local function setAttributeTyped(key: SpecialKey, value: T) + -- 실제로는 여기서 SetAttribute(inst, name, value)를 호출하겠지만, + -- 이 스파이크는 타입 추론 자체만 봄 +end + +setAttributeTyped(Attribute("Enabled"), true) -- T=boolean으로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Count"), 5) -- T=number로 추론돼 통과해야 함 +setAttributeTyped(Attribute("Enabled"), 5) -- <- 여기가 핵심: T=boolean인데 5(number)를 넘김. +-- 이게 타입 에러로 잡히면(기대하는 결과) "제네릭 키 함수 호출 패턴"은 +-- 최소한 함수 인자 형태로는 잘 작동한다는 뜻 — 그럼 테이블 리터럴 +-- `{[Attribute<>(name)] = value}` 안에서도 Luau가 "이건 사실 +-- 위 setAttributeTyped 호출과 같은 형태"로 취급해주는지가 다음 질문. + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(homo, mixedProps) + +--[[ + 확인 포인트 (luau-analyze / luau-lsp): + 1. `setAttributeTyped(Attribute("Enabled"), 5)` 줄에서 실제로 타입 + 에러가 나는가? — 나면 "함수 인자 형태의 제네릭 키+값 연동"은 + Luau가 지원한다는 뜻. + 2. 위가 통과한다면, 그 다음으로 `mixedProps[Attribute("Enabled")] = + 5`처럼 **인덱스 대입 문법**으로도 같은 체크가 되는지 직접 추가해 + 실험해볼 것(이 파일엔 일부러 안 넣어둠 — `{[SpecialKey]: T}`류 + 제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가 + 에러를 내는지 먼저 볼 것). + 3. 최종적으로 "제네릭 DI 키를 테이블 리터럴 안에서 쓸 때 값 타입이 + 실제로 좁혀지는지"에 대한 결론이 나오면 attribute-plan.md의 + "[실측 필요, M0/M10]" 캐비엇을 그 결과로 갱신할 것 — 안 되는 걸로 + 확인되면 "정적 체크는 `BooleanAttribute`류 정적 타입 패밀리 쪽만 + 신뢰 가능"이라는 문서의 fallback 결론이 확정됨. +]] diff --git a/.claude/luau-test/13-type-ref-preref-subtype.luau b/.claude/luau-test/13-type-ref-preref-subtype.luau new file mode 100644 index 0000000..495aa4b --- /dev/null +++ b/.claude/luau-test/13-type-ref-preref-subtype.luau @@ -0,0 +1,135 @@ +--!strict +--[[ + 검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 — + `isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를 + 만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨. + 이전엔 `isRef(preRefInstance) == false`였는데, 지금은 + `isRef(preRefInstance) == true`로 바뀜. + + 이 파일은 두 부분으로 나뉨: + A) 타입 체크 대상 — `PreRef`가 구조적으로 `Ref`를 만족하는지 + (08번 파일이 Source/State에 대해 검증한 것과 정확히 같은 질문을 + Ref/PreRef에 대해 재검증). + B) 런타임 대상 — `isRef`/`isPreRef` predicate 합성이 문서에 적힌 대로 + 동작하는지, 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가 + 이제 `isHandlable = isRef(v) and not isPreRef(v)`로 **명시적으로 + 좁혀야만** PreRef를 잘못 삼키지 않는다는 것. + + 배경: .claude/base/bind-system-plan.md의 `Brand` 절 + ("isRef(x)는 그 위에 Brand.get(x)==RefTag를 OR로 얹은 상위 개념")와 + "`(v=Ref)` children 배열 leaf 매치 핸들러... isRef(v) and not + isPreRef(v)로 명시적으로 좁혀야 함" 부분. + + 실행: + A) `luau-analyze 13-type-ref-preref-subtype.luau` (또는 luau-lsp) + B) `luau 13-type-ref-preref-subtype.luau` (런타임 부분은 그냥 통과함, + 타입 에러가 있어도 런타임 실행 자체는 대부분 luau CLI가 그냥 + 진행시켜줌 — 확실히 하려면 A/B를 따로 luau-analyze/luau로 각각 + 돌려볼 것) +]] + +-- ===== A) 타입 체크 대상 ===== + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, + Callback: (self: Ref, fn: (T) -> ()) -> Ref, + Wait: (self: Ref, thread: thread?) -> Ref, +} + +-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가 +-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는 +-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분) +export type PreRef = { + Value: T, + Set: (self: PreRef, value: T) -> PreRef, + Callback: (self: PreRef, fn: (T) -> ()) -> PreRef, + Wait: (self: PreRef, thread: thread?) -> PreRef, +} + +local function fakePreRef(default: T): PreRef + return (nil :: any) :: PreRef +end + +-- 시도: PreRef 값을 Ref가 필요한 자리에 그대로 넘길 수 있는가 +local function useAsRef(r: Ref): T + return r.Value +end + +local myPreRef: PreRef = fakePreRef(0) +local viaSubtype: number = useAsRef(myPreRef) -- <- 여기가 luau-analyze 확인 포인트 + +print("A) 타입 체크는 luau-analyze/luau-lsp로 확인 — 런타임은 그냥 통과") +print(viaSubtype) + +-- ===== B) 런타임 대상 — Brand/isRef/isPreRef predicate 합성 ===== + +local Brand = {} +local registry = setmetatable({}, { __mode = "k" }) +function Brand.set(x, tag) + registry[x] = tag +end +function Brand.get(x) + return registry[x] +end + +local RefTag, PreRefTag = {}, {} + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isRef(x) + -- 재정정된 합성 — PreRef가 Ref의 하위 개념(OR로 얹음) + return isPreRef(x) or Brand.get(x) == RefTag +end + +local function makeRef() + local self = {} + Brand.set(self, RefTag) + return self +end +local function makePreRef() + local self = {} + Brand.set(self, PreRefTag) + return self +end + +local ref1 = makeRef() +local preref1 = makePreRef() + +print() +print("=== B-1. isRef/isPreRef 기본 동작 ===") +print("isRef(ref1) =", isRef(ref1), "(true여야 함)") +print("isPreRef(ref1) =", isPreRef(ref1), "(false여야 함 — Ref는 PreRef가 아님)") +print("isRef(preref1) =", isRef(preref1), "(true여야 함 — 2026-08-09 재정정의 핵심)") +print("isPreRef(preref1) =", isPreRef(preref1), "(true여야 함)") + +-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef를 잘못 삼키면 안 됨 +local function leafRefHandlerIsHandlable(v) + return isRef(v) and not isPreRef(v) +end + +print() +print("=== B-2. Leaf의 (v=Ref) 핸들러가 PreRef를 잘못 삼키지 않는가 ===") +print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)") +print( + "leafRefHandlerIsHandlable(preref1) =", + leafRefHandlerIsHandlable(preref1), + "(false여야 함 — PreRef는 pre-pass가 이미 처리했어야 하고, 이 핸들러가 또 삼키면 안 됨)" +) + +assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)") +assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그 — 2026-08-09 재정정이 요구하는 명시적 좁히기 실패)") +print() +print("assert 전부 통과 — isRef(v) and not isPreRef(v) 조합이 기대대로 동작함") + +--[[ + 확인 포인트: + A) luau-analyze/luau-lsp에서 `viaSubtype` 줄이 에러 없이 통과하는가 — + 08번 파일이 Source/State에 대해 확인했던 것과 같은 결론(구조적 + 서브타이핑 성립)이 Ref/PreRef에도 그대로 적용되는지. + B) 런타임 assert가 전부 통과하는가 — 특히 `isRef(preref1) == true` + (뒤집힌 결정 자체)와 `leafRefHandlerIsHandlable(preref1) == false` + (그 뒤집힘 때문에 Leaf 핸들러가 이제 반드시 `not isPreRef(v)`를 + 같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지. +]] diff --git a/.claude/luau-test/14-type-nilable-default-overload.luau b/.claude/luau-test/14-type-nilable-default-overload.luau new file mode 100644 index 0000000..3d365a9 --- /dev/null +++ b/.claude/luau-test/14-type-nilable-default-overload.luau @@ -0,0 +1,84 @@ +--!strict +--[[ + 검증 대상: `Source(default)`/`Ref(default)`의 `default` 인자를 생략할 수 + 있는 건 오직 `T`가 nilable(`T?`)일 때뿐이라는 캐비엇(2026-08-09 + 열한 번째 세션, 커밋 f198fd9 신규) — "타입으로 막을 수 있으면 막고 + 안 되면 UB로 문서 경고"라고 base 문서가 적어둔 부분을 실제로 타입 + 오버로드로 막을 수 있는지 검증. + + 배경: .claude/base/bind-system-plan.md "[보강, 2026-08-09 열한 번째 + 세션] Source(default)/Ref(default)의 default 인자가 '선택'이라는 + 서술은 정확히는 T가 nil을 포함할 때만 성립함" 절. 문제 상황: + `Ref()`(default 생략)를 만들면 실제 런타임 값은 `nil`인데 + `T=number`(non-nilable)라고 선언하면 타입과 실제 값이 어긋남 — + 특히 `:Callback(fn)`이 등록 즉시 그 시점 값(nil)으로 1회 호출되므로 + 이 어긋남이 바로 드러남. + + 시도할 두 가지 설계: + A) 단일 시그니처 `Ref(default: T?): Ref` — default를 항상 + optional로 열어둠. 이러면 `Ref()`가 타입 에러 없이 + 통과해버려서(캐비엇을 막지 못함) 이게 바로 지금 실제로 벌어지고 + 있는 문제 상황. + B) 오버로드 흉내 — `default: T` 필수 시그니처와 `(): Ref` + 무인자 시그니처 두 개를 함수 타입 교차(`&`)로 합쳐, "생략하면 + 자동으로 반환 타입이 T?로 바뀐다"를 강제할 수 있는지. + + 실행: `luau-analyze 14-type-nilable-default-overload.luau` (또는 + luau-lsp) +]] + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, +} + +-- ===== A) 단일 시그니처 — default가 항상 optional(현재 캐비엇이 실제로 벌어지는 형태) ===== + +local function RefA(default: T?): Ref + return (nil :: any) :: Ref +end + +local refA1: Ref = RefA(5) -- 정상 — 통과해야 함 +local refA2: Ref = RefA() -- <- 문제의 그 케이스: default 생략, T=number(non-nilable)인데 +-- 통과해버리면(기대되는 나쁜 결과) 이게 바로 캐비엇이 막고 싶어하는 구멍 — +-- 런타임엔 .Value가 nil인데 타입은 number라고 거짓말하는 상태가 됨. + +-- ===== B) 오버로드 흉내 — 함수 타입 교차로 "생략 시 T?" 강제 시도 ===== + +type RefCtorOverload = ((default: T) -> Ref) & (() -> Ref) + +local RefB: RefCtorOverload = (nil :: any) :: RefCtorOverload + +local refB1: Ref = RefB(5) -- 정상 — 첫 번째 오버로드(T=number)로 통과해야 함 +local refB2 = RefB() -- 두 번째 오버로드로 잡혀야 함 — 추론된 타입이 Ref 류가 될 것으로 예상 +-- 아래가 진짜 확인 대상: refB2를 non-nilable Ref에 대입하면 막히는가? +local refB2_annotated: Ref = RefB() -- <- 이것도 에러가 나야 "막혔다"고 할 수 있음 +-- (T가 추론 컨텍스트에서 number로 잡히면서 동시에 "무인자 오버로드라 T? +-- 여야 한다"는 두 요구가 충돌하는지가 관건 — 충돌해서 에러가 나면 성공, +-- 조용히 number로 통과해버리면 오버로드로도 못 막는다는 뜻) + +-- 대조군 — nilable로 명시하면 항상 통과해야 함(오버로드가 정상 케이스는 안 막는지 확인) +local refB3: Ref = RefB() + +print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것") +print(refA1, refA2, refB1, refB2, refB2_annotated, refB3) + +--[[ + 확인 포인트: + 1. A) `refA2 = RefA()` 줄이 에러 없이 통과하는가? (예상: 통과함 — + 이게 바로 "타입으로 못 막는" 현재 상태를 보여주는 대조군) + 2. B) `refB2_annotated: Ref = RefB()` 줄이 에러가 나는가? + - 에러가 나면: 오버로드 방식으로 실제로 이 캐비엇을 타입 레벨에서 + 막을 수 있다는 뜻 — base 문서의 "타입으로 막을 수 있으면 막을 것" + 을 실제 설계로 채택할 근거가 생김, `Source`/`Ref` 생성자를 + 이 오버로드 모양으로 다시 쓸 것. + - 에러가 안 나면(조용히 통과): Luau의 제네릭 함수 교차 타입 + 오버로드가 이 정도로 정교한 추론을 못 한다는 뜻 — 문서의 + "안 되면 UB로 경고"가 fallback이 아니라 사실상 유일한 선택지로 + 확정됨. + 3. `refB3`(nilable로 명시한 정상 케이스)는 항상 통과하는가 — 오버로드 + 자체가 정상 사용까지 막아버리는 부작용은 없는지 확인. + 4. 이 결과가 나오면 `bind-system-plan.md`의 해당 캐비엇 절에 "실측 + 결과"로 반영할 것 — 지금은 "타입으로 막을 수 있으면 막고"라는 + 조건문으로만 적혀 있어서 결론이 필요함. +]] diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md new file mode 100644 index 0000000..8826776 --- /dev/null +++ b/.claude/luau-test/README.md @@ -0,0 +1,118 @@ +# .claude/luau-test — M0 착수 전 실 Luau 기술검증 스파이크 모음 + +**[2026-08-09 이동]** 처음엔 레포 루트 `luau-ignoreme/`(git 자동 제외 +폴더)에 만들었으나, 사용자가 직접 확인해볼 만한 검증 코드라 커밋해서 +레포에 남기기로 함 — `.claude/luau-test/`로 옮기고 일반 추적 대상으로 +전환(더 이상 `*-ignoreme*` gitignore 패턴에 안 걸림). 위치만 바뀌었을 뿐 +내용/역할은 그대로 — 아직 M0가 공식 시작 전인 상태에서 미리 돌려보는 +사전 검증 스파이크 모음. + +## 왜 이게 필요한가 + +`.claude/base/`와 `ROADMAP.md` M0가 "추론만으로 확정하고 실제 Luau 코드로 +부딪혀본 적 없는 것"으로 명시적으로 지목한 항목들, 그리고 이후 세션들에서 +"M0/M2 스파이크 검증 목록에 추가됨"으로 흩어져 있던 항목들을 모아 각각 +독립 실행 가능한 스크립트로 만들었음. **내가(에이전트) 직접 실행은 못 +했음** — 이 환경엔 `luau`/`luau-analyze` 바이너리가 없어서, 전부 사용자가 +직접 돌려보고 결과를 알려줘야 함. + +각 파일 맨 위 주석에 다음이 전부 적혀있음: 뭘 검증하는지, 어느 base 문서/ +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(런타임 부분) | +| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14 | +| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 | + +**12/13/14는 특히 `luau-lsp`로 확인해달라고 요청받은 것들** — `luau-analyze`도 +같은 타입 솔버를 쓰므로 원리적으로는 같은 결과가 나와야 하지만, `luau-lsp`가 +에디터에서 인라인으로 에러 위치/메시지를 보여줘서 "정확히 어느 표현식이 +막히는지"를 확인하기 더 편함. sourcemap/Roblox 전역 타입 없이도 그대로 +확인 가능하게 만들어뒀음(전부 순수 Luau 타입 문법만 씀). + +로컬에 `luau`/`luau-analyze`가 없으면 위 GitHub 릴리즈에서 플랫폼에 맞는 +바이너리를 받으면 됨. Roblox Studio 파일은 스크립트 내용을 그대로 +`ServerScriptService`에 붙여넣은 `Script`로 만들어 Play(F5)하면 됨. + +## 파일 목록 — 뭘 검증하는지 요약 + +| 파일 | 검증 대상 | 근거 문서 | +|---|---|---| +| `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | +| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | +| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | +| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지 | `bind-system-plan.md` "Dispatch 체인", 2026-08-08 세 번째 세션 | +| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | 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 착수 시 실측 확인" | +| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `store-semantics.md` "검증 필요", ROADMAP M0-2 | +| `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` | +| `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` (타입체크 전용) | `[Attribute<> "name"] = value`처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | +| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유 | `bind-system-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | +| `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `bind-system-plan.md` "[보강, 2026-08-09 열한 번째 세션]" 절 | + +## 갱신 이력 + +**1차 (2026-08-09 저녁, `8169b90`~`5836c2d` 반영)**: 01/02/05/06/07/08/09는 +검증 대상 API가 그대로였고, `03`/`04`에 참고 노트 추가, `10` Part A 갱신 +(canBound/unbindLifetime 반영), `11` 신규 추가. + +**2차 (2026-08-09 커밋 `f198fd9`, "중간검토(질문 모드)에서 발견된 설계 +결함 다수 수정" 반영)** — 사용자가 직접 `.claude/base/` 전체를 훑으며 +찾은 정정들 중 이 폴더(당시 `luau-ignoreme/`)에 영향 있는 것만: + +- `02`: **전면 재작성.** 이전 버전은 "Ref 콜백/대기자 배열도 None으로 + 소진해야 한다"고 잘못 적어뒀는데, 이게 실제로는 무한 성장 버그였음이 + 드러나 `nil`로 되돌아감(순서가 안 중요하고 슬롯 재사용이 필요한 + 배열은 `nil`, 순서가 중요한 배열(PreRef pre-pass/sourceList)은 + 계속 `None` — 두 카테고리로 나눠 각각 재현). +- `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요 + 항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute + 제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + + `isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을 + 오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요" + 로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정 + (13)이라 기존 파일 중 커버하는 게 없었음. +- `01`/`03`~`11`(위 02 제외)은 f198fd9의 다른 변경(Slot CRUD 인덱스 + 기준 전환, Source 리프 직접 바인딩 정상 경로 재확인, Dispatch 직접 + 호출 UB 명시, Tag retract 전제 명시, Attribute 타입 파라미터화 확정 + 등)과 대조해본 결과 검증 대상 API에 영향 없어 안 건드림. + +**3차 (2026-08-09, 폴더 이동)**: `luau-ignoreme/` → `.claude/luau-test/`로 +이동, git 추적 대상으로 전환. 내용 변경 없음 — 경로 참조하는 문구만 +동기화. + +## 결과 확인 후 할 일 + +각 파일 결과를 알려주면, 실제로 걸리는 부분이 있는지 보고 필요하면 +`.claude/base/` 문서를 그 자리에서 고침(ROADMAP.md M0 통과 기준 그대로: +"안 되면 여기서 관련 base/ 문서부터 고치고 재시도"). 특히: + +- `08`/`09`가 luau-analyze에서 에러를 내면 어떤 정확한 에러 메시지인지가 + 다음 타입 설계 방향(펼쳐 쓰기 vs `any` fallback)을 결정하는 데 중요함. +- `07`이 예상대로 GC가 안 되는 것처럼 보이면(90개 안 죽는 것 같으면), + `collectgarbage("count")` 수치 변화를 같이 알려줄 것 — 정확한 판정이 + 어려운 항목이라 참고 신호로만 쓸 것. +- `10`의 A 섹션에서 만약 `warn`이 실제로 뜨면(ClassName Changed가 + 발화함), gcconn 트릭 전체를 재검토해야 하는 심각한 발견이니 바로 알려줄 것. + A-2(재-bindLifetime 허용 여부)가 실패하면 `canBound`/`unbindLifetime` + 설계 자체를 재검토해야 함. +- `11`은 전부 PASS가 기대값 — FAIL이 하나라도 있으면 어느 케이스인지 + 그대로 알려줄 것(특히 "변환 함수가 반환한 값" 케이스는 놓치기 쉬운 + 경로라 실제 구현에서도 잘 짜였는지 중요한 신호). +- `02`의 Part B-2("None + table.insert" 대조군)가 실제로 배열 길이 1000까지 + 자라는 게 확인되면 사용자가 찾은 버그가 정량적으로 재현된 것 — 반대로 + 안 자란다면 정정 근거 자체를 재검토해야 하니 꼭 알려줄 것. +- `12`/`14`는 **어느 쪽으로 나와도 유용한 정보** — 통과하면 그 타입 + 패턴을 실제 설계로 채택, 실패하면 `any`/정적 타입 패밀리로 fallback한다는 + 각 파일의 결론 그대로 base 문서에 반영하면 됨. 정확한 luau-lsp 에러 + 메시지(어느 줄, 어떤 문구)를 그대로 붙여서 알려주면 다음 문서 갱신이 + 빠름. +- `13`은 A(타입)/B(런타임) 둘 다 확인해줄 것 — B의 assert가 실패하면 + `Dispatch/Leaf.luau` 설계(`isRef(v) and not isPreRef(v)`) 자체가 + 잘못 짜인 것이니 우선순위 높게 알려줄 것. diff --git a/CLAUDE.md b/CLAUDE.md index e6b6184..f8a641d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 pre-implementation-audit.md`(2026-08-06 신설)의 우선순위1 항목부터 먼저 확인할 것** — 특히 M0 스파이크 코드 자체에 반영해야 할 항목(props.Modifier/ Ref를 안 넘기는 케이스 포함, `store.key` 레코드 필드 타이핑도 M0로 - 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. + 앞당기기 검토)이 있음, 아래 최신 세션 요약 참고. **M0 실제 착수 전, + `.claude/luau-test/`(2026-08-09 신설)의 사전 검증 스파이크 결과부터 + 확인할 것** — M0가 공식 짜야 할 스파이크와 겹치는 항목들을 미리 + 독립 스크립트로 만들어 사용자가 `luau`/`luau-analyze`/`luau-lsp`/ + Roblox Studio로 직접 돌려보기로 한 상태, 아직 결과 미확인. 걸리는 + 게 있으면 `base/` 문서부터 고치고, 없으면 그대로 M0 실제 코드 작성에 + 재사용하면 됨(README 참고). 2. **용어 정리 — 사용자가 별도로 요청, 진행 중.** "register"(v1) 같이 부정확한 이름들을 전체적으로 재검토하자는 요청 — 1차 제안 완료(우선순위 순: `State`가 React/Vue식 "쓸 수 있는 로컬 상태"라는 통상 의미와 반대라 @@ -2426,3 +2432,63 @@ Blocker 전체, 소스트리/네이밍 컨벤션/Handler 3분류/테스트 전 전 상태가 더 탄탄해졌을 뿐 우선순위 자체는 그대로. 이 중간검토가 마지막 배치(6단계)까지 끝났는지, 사용자가 이어서 더 볼 부분이 있는지는 다음 세션 시작 시 확인. + +## 2026-08-09 열두 번째 세션 — `.claude/luau-test/` 신설: M0 사전 검증 +스파이크 작성, 결과는 아직 미확인 + +M0가 공식적으로 짜야 할 스파이크(위 "지금 할 일" 1번, `ROADMAP.md` M0 +체크박스)와 지금까지 세션 로그 곳곳에 흩어져 있던 "실제 Luau로 부딪혀본 +적 없는 것"/"M0/M2 스파이크 검증 목록에 추가됨" 표시들을 한 곳에 모아, +사용자가 직접 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 돌려볼 +수 있는 독립 실행 스크립트 14개 + `README.md` 색인으로 만듦. 세 라운드에 +걸쳐 진행됨: + +1. **1차 작성** — 레포 루트 `luau-ignoreme/`(당시엔 git 자동 제외 폴더로 + 시작)에 M0 체크리스트 5개 항목(Store/State 다이아몬드 전파, Source가 + State를 구조적으로 만족하는 제네릭 타입, process/retract 재귀 디스패치, + 배열/해시 두 패스 순회, `props.Modifier or None` nil-hole 관용구) + + `Dispatch` 체인/`retractUnder` 다단 검증, `Relate`의 weak-table GC + 실측, `Modifier.Overridden` 서브타입 타입체크, Roblox 전용 + `bindLifetime`/`canExecute`/Attribute Instance 참조/`CollectionService` + 태그 확인까지 10개 파일 작성(01~10). +2. **2차 — 커밋 `f198fd9`("중간검토에서 발견된 설계 결함 다수 수정") 반영.** + 그 사이 사용자가 직접 `.claude/base/` 전체를 훑으며 여러 결함을 + 정정(위 절 참고) — 그 중 `02`(Ref 콜백/대기자 배열의 소진 센티널이 + `None`→`nil`로 되돌아간 것, 실제로 `None`을 쓰면 배열이 무한정 + 자라는 버그였음이 드러남)이 luau-test 내용과 정면으로 어긋나 전면 + 재작성(순서가 중요한 배열은 계속 `None`, 순서 무관+슬롯 재사용 + 필요한 배열은 `nil`이라는 최종 구분 + 무한 성장 버그의 정량적 + 재현까지 포함). `Modifier` UB→error 전환(11 신규)도 이 라운드에 + 같이 반영. 나머지 파일은 대조 결과 영향 없음을 서브에이전트+직접 + 문서 대조로 확인. +3. **3차 — 사용자 요청으로 "타입 관련 실측 필요, 특히 `luau-lsp`로 + 확인해야 할 것" 3개 추가(12~14).** base 문서 자신이 "실측 필요"라고 + 명시적으로 못박아둔 지점(`attribute-plan.md`의 `[Attribute<> + "name"] = value` 제네릭 DI 키가 실제로 값 타입을 좁혀주는지, 12번)과 + f198fd9에서 뒤집힌 결정(`isRef`/`isPreRef`가 이제 `Source`/`State`와 + 같은 포함 관계 — `PreRef`가 `Ref`의 하위 개념이 됨, `PreRef`가 + `Ref`를 구조적으로 만족하는지 타입체크까지 포함, 13번), 그리고 + 같은 세션에 새로 명시된 캐비엇(`Source(default)`/`Ref(default)`의 + `default` 생략은 `T`가 nilable일 때만 안전하다는 것을 함수 오버로드로 + 타입 레벨에서 실제로 막을 수 있는지, 14번)을 찾아 작성. +4. **폴더 이동 — `luau-ignoreme/` → `.claude/luau-test/`.** 사용자가 + "커밋해서 레포에 남기자"고 판단 — `*-ignoreme*` gitignore 패턴을 + 벗어나 일반 추적 대상으로 전환, `.claude/README.md`에 새 폴더 행 + 추가. 내용/역할은 안 바뀜, 경로 참조 문구만 동기화. + +**아직 아무것도 실행 안 됨 — 에이전트도 로컬에 `luau`/`luau-analyze`가 +없어서 직접 못 돌려봤고, 사용자가 다음에 `luau`/`luau-analyze`/ +`luau-lsp`/Roblox Studio로 직접 돌려보고 결과를 알려주기로 함.** 결과에 +따라 할 일: +- 전부 통과 → M0 실제 착수 시 이 스크립트들의 로직을 그대로 재사용하며 + 진행. +- 하나라도 걸림(특히 12/14의 타입 narrowing 실패, 07의 GC 신호 이상, + 10의 `warn` 발생, 13의 런타임 assert 실패) → 해당 `base/` 문서를 + 그 자리에서 정정. +- `.claude/luau-test/README.md`의 "결과 확인 후 할 일" 절에 파일별로 + 뭘 우선 확인해야 하는지 이미 적어둠 — 다음 세션은 그 응답을 + 대조하는 것부터 시작하면 됨. + +**다음 세션이 할 일**: 사용자가 luau-test 실행 결과를 갖고 오면 그것부터 +반영. 아직 없으면 `ROADMAP.md` M0 착수 우선순위는 그대로(위 "지금 할 일" +1번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서.