docs(luau-test): M0 사전검증 스파이크 신설, luau-ignoreme→.claude/luau-test 이동

base/ 확정 사항 중 아직 실제 Luau로 부딪혀본 적 없는 것(M0 스파이크
대상)을 사용자가 luau/luau-analyze/luau-lsp/Roblox Studio로 직접
돌려볼 수 있는 독립 실행 스크립트 14개 + README 색인으로 정리. 커밋
f198fd9의 정정사항(Ref 콜백/대기자 배열 소진을 None에서 nil로 되돌린
것 등)을 반영해 02번을 재작성했고, 타입 관련 실측이 필요한 항목
(Attribute 제네릭 DI 키 narrowing, Ref/PreRef 구조적 서브타입, Source/
Ref nilable-default 오버로드)을 새로 찾아 12~14번으로 추가함. 처음엔
git 자동 제외 폴더(luau-ignoreme/)에 만들었으나 커밋해서 레포에
남기기로 해 .claude/luau-test/로 이동, .claude/README.md에 색인 추가.
CLAUDE.md에 세션 요약 반영 — 아직 실행 결과는 미확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-09 23:55:22 +09:00
parent f198fd9c6b
commit df4a77b02d
Signed by: qwreey
GPG key ID: D28DB79297A214BD
17 changed files with 2137 additions and 1 deletions

View file

@ -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/`로 승격(또는 구현 착수 시

View file

@ -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 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가
아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적.
]]

View file

@ -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 "<None>"
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 대신 선형 탐색 재사용 등록 함수"가 실제로
의도대로 동작하는지의 핵심 확인.
]]

View file

@ -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 "<None>"
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에
추가할 것).
]]

View file

@ -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. 스택 오버플로 없이 전부 정상 종료되는가.
]]

View file

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

View file

@ -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 "<None>"
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> = T | typeof(None)`류) 실 Luau
에러 메시지를 보고 판단해볼 것 — 지금 파일은 `any` 캐스팅으로
일단 회피해뒀음, 이 부분은 M7/M8 실제 구현 시 정확한 타입을 찾아야 함.
]]

View file

@ -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는 정확한 타이밍을 보장 안 하므로 완벽한 증거는
아님, 참고 신호 정도로만 볼 것).
]]

View file

@ -0,0 +1,78 @@
--!strict
--[[
검증 대상: Source<T>가 구조적으로 State<T>를 만족하는(self 타이핑 +
State 참조가 섞인 제네릭 :Compute) 설계가 Luau 타입 솔버에서 안전하게
추론되는지 — 실제 실행이 아니라 타입 체크(luau-analyze) 대상.
배경: .claude/base/store-semantics.md "검증 필요(확정 아님, M0 스파이크
대상)" 절, ROADMAP.md M0 2번째 항목.
핵심 우려: State<T>가 거꾸로 Source를 참조하는 "상호 재귀"는 Luau
솔버가 취약한 패턴 — 그래서 아래 State<T>는 Source를 전혀 참조하지
않도록 독립적으로 먼저 정의하고, Source<T>만 State<T>를 단방향으로
참조하게 구성함. 타입은 사용자 선호대로 &(교차)가 아니라 손으로
펼쳐 씀(런타임 구현 델리게이션과는 별개 축이라 상관없음).
실행: `luau-analyze 08-type-source-satisfies-state.luau`
(로컬에 luau-analyze가 없으면 Luau 공식 릴리즈의 CLI 툴체인 설치 필요 —
https://github.com/luau-lang/luau/releases, 또는 lune 배포판에 포함된 것)
기대 결과: 에러 없이 통과하거나, 통과 안 하면 정확히 *어느 줄에서*
*무슨 에러*가 나는지가 다음 결정에 중요한 정보임 — 에러가 나면 그
메시지를 그대로 가져와서 알려줄 것.
]]
-- State<T>는 Source를 절대 참조하지 않음(단방향 의존을 위한 핵심 제약)
export type State<T> = {
Get: (self: State<T>) -> T,
With: (self: State<T>, ...State<any>) -> State<any>,
Compute: <U>(self: State<T>, fn: (T) -> U) -> State<U>,
}
-- Source<T>만 State<T>를 참조(단방향) — self 타이핑(Source<T> 자신을 가리킴)과
-- 바깥 타입 참조(State<U>)가 섞인 제네릭 메소드가 바로 검증 대상
export type Source<T> = {
Get: (self: Source<T>) -> T,
With: (self: Source<T>, ...State<any>) -> State<any>,
Compute: <U>(self: Source<T>, fn: (T) -> U) -> State<U>,
Set: (self: Source<T>, value: T) -> (),
Emit: (self: Source<T>) -> (),
}
-- 1. Source 값을 만드는 흉내 생성자(런타임 구현은 아직 없으므로 타입만 맞추는 더미)
local function fakeSource<T>(default: T): Source<T>
return (nil :: any) :: Source<T>
end
-- 2. State<T>를 요구하는 함수에 Source<T>를 그대로 넘길 수 있는가
-- (구조적 서브타이핑 — "Source가 State를 만족함" 절의 핵심 주장)
local function useAsState<T>(s: State<T>): T
return s:Get()
end
local mySource: Source<number> = fakeSource(0)
local viaSubtype: number = useAsState(mySource) -- 여기가 타입체크 되는지가 핵심
-- 3. Compute 체이닝이 제네릭을 타고 잘 흐르는가(Source -> State<string> -> State<boolean>)
local derived1: State<string> = mySource:Compute(function(n: number): string
return tostring(n)
end)
local derived2: State<boolean> = derived1:Compute(function(s: string): boolean
return #s > 0
end)
-- 4. store.key가 Source<T>를 직접 반환한다는 모델(레코드 필드 읽기/쓰기 대칭)
export type Store = {
-- 실제로는 defaults의 각 키를 Source<...>로 매핑하는 mapped type이 이상적이지만
-- Luau에 mapped type이 없으므로(2026-08 시점) 구체 예시 하나로만 검증
Health: Source<number>,
}
local function useStore(store: Store)
store.Health:Set(100) -- 쓰기
local hp: number = store.Health:Get() -- 읽기 — 같은 필드 타입(Source<number>)으로 대칭
return hp
end
print("이 파일은 luau-analyze로만 의미가 있음 (런타임 실행은 그냥 통과함)")
print(viaSubtype, derived2)

View file

@ -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에 반영할 것.
]]

View file

@ -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 로그를 그대로 복사해서 공유해주면 됨")

View file

@ -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를 씀,
여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만
검증 대상.
]]

View file

@ -0,0 +1,93 @@
--!strict
--[[
검증 대상: `[Attribute<<boolean>> "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<T> — Attribute<<T>>(name)이 반환하는 "타입이 실린 키" 흉내
type SpecialKey<T> = { __attributeKeyBrand: T }
local function Attribute<T>(name: string): SpecialKey<T>
return (nil :: any) :: SpecialKey<T>
end
-- ===== 시도 1: 동질적(homogeneous) 인덱스 시그니처 — 항상 통과해야 함(비교군) =====
-- 이 방식은 "이 테이블의 모든 특수 키가 전부 boolean 값이어야 한다"는
-- 고정된 단일 인스턴스라, 애초에 여러 타입을 섞을 수 없음 — 진짜 검증
-- 대상이 아니라 대조군.
type HomogeneousParams = {
[SpecialKey<boolean>]: boolean,
}
local homo: HomogeneousParams = {
[Attribute("Enabled")] = true, -- 이건 당연히 통과해야 함
}
-- ===== 시도 2: 이질적(heterogeneous) — 한 테이블에 boolean/number Attribute를 섞음 =====
-- 이게 진짜 검증 대상: SpecialKey<T>의 T가 키마다 달라도 값이 그 T로
-- 각각 좁혀지는가? (TypeScript의 mapped/conditional type이 있어야 되는
-- 문제 — Luau에 해당 기능이 없으면 아래 셋 중 하나가 일어날 것으로 예상:
-- (a) 두 번째 대입에서 타입 에러, (b) 값 타입이 조용히 any/union으로
-- 뭉개짐, (c) 테이블 타입 자체를 선언하는 시점에 에러)
local mixedProps: { [SpecialKey<any>]: any } = {} -- 일단 any로 도피한 버전(항상 통과할 것)
mixedProps[Attribute("Enabled")] = true
mixedProps[Attribute("Count")] = 5
-- 진짜 물어볼 질문: 개별 대입 표현식 하나만 놓고 봤을 때, Luau가
-- `Attribute<T>(name)`의 제네릭 인스턴스화 결과로 옆의 값 리터럴 타입을
-- 체크/추론해주는지 — 함수 호출 결과 타입과 그 옆 대입값 사이의 관계는
-- "인덱스 시그니처"가 아니라 그냥 "함수 반환 타입에 맞는 변수 대입"
-- 문제로 좁혀서 아래처럼 직접 테스트:
local function setAttributeTyped<T>(key: SpecialKey<T>, 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<<T>>(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>]: T}`류
제네릭 인덱스 시그니처를 실제로 선언할 수 있는지부터 luau-lsp가
에러를 내는지 먼저 볼 것).
3. 최종적으로 "제네릭 DI 키를 테이블 리터럴 안에서 쓸 때 값 타입이
실제로 좁혀지는지"에 대한 결론이 나오면 attribute-plan.md의
"[실측 필요, M0/M10]" 캐비엇을 그 결과로 갱신할 것 — 안 되는 걸로
확인되면 "정적 체크는 `BooleanAttribute`류 정적 타입 패밀리 쪽만
신뢰 가능"이라는 문서의 fallback 결론이 확정됨.
]]

View file

@ -0,0 +1,135 @@
--!strict
--[[
검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 —
`isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를
만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨.
이전엔 `isRef(preRefInstance) == false`였는데, 지금은
`isRef(preRefInstance) == true`로 바뀜.
이 파일은 두 부분으로 나뉨:
A) 타입 체크 대상 — `PreRef<T>`가 구조적으로 `Ref<T>`를 만족하는지
(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<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
Callback: (self: Ref<T>, fn: (T) -> ()) -> Ref<T>,
Wait: (self: Ref<T>, thread: thread?) -> Ref<T>,
}
-- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가
-- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는
-- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분)
export type PreRef<T> = {
Value: T,
Set: (self: PreRef<T>, value: T) -> PreRef<T>,
Callback: (self: PreRef<T>, fn: (T) -> ()) -> PreRef<T>,
Wait: (self: PreRef<T>, thread: thread?) -> PreRef<T>,
}
local function fakePreRef<T>(default: T): PreRef<T>
return (nil :: any) :: PreRef<T>
end
-- 시도: PreRef<T> 값을 Ref<T>가 필요한 자리에 그대로 넘길 수 있는가
local function useAsRef<T>(r: Ref<T>): T
return r.Value
end
local myPreRef: PreRef<number> = 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)`를
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
]]

View file

@ -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<number>()`(default 생략)를 만들면 실제 런타임 값은 `nil`인데
`T=number`(non-nilable)라고 선언하면 타입과 실제 값이 어긋남 —
특히 `:Callback(fn)`이 등록 즉시 그 시점 값(nil)으로 1회 호출되므로
이 어긋남이 바로 드러남.
시도할 두 가지 설계:
A) 단일 시그니처 `Ref<T>(default: T?): Ref<T>` — default를 항상
optional로 열어둠. 이러면 `Ref<number>()`가 타입 에러 없이
통과해버려서(캐비엇을 막지 못함) 이게 바로 지금 실제로 벌어지고
있는 문제 상황.
B) 오버로드 흉내 — `default: T` 필수 시그니처와 `(): Ref<T?>`
무인자 시그니처 두 개를 함수 타입 교차(`&`)로 합쳐, "생략하면
자동으로 반환 타입이 T?로 바뀐다"를 강제할 수 있는지.
실행: `luau-analyze 14-type-nilable-default-overload.luau` (또는
luau-lsp)
]]
export type Ref<T> = {
Value: T,
Set: (self: Ref<T>, value: T) -> Ref<T>,
}
-- ===== A) 단일 시그니처 — default가 항상 optional(현재 캐비엇이 실제로 벌어지는 형태) =====
local function RefA<T>(default: T?): Ref<T>
return (nil :: any) :: Ref<T>
end
local refA1: Ref<number> = RefA(5) -- 정상 — 통과해야 함
local refA2: Ref<number> = RefA() -- <- 문제의 그 케이스: default 생략, T=number(non-nilable)인데
-- 통과해버리면(기대되는 나쁜 결과) 이게 바로 캐비엇이 막고 싶어하는 구멍 —
-- 런타임엔 .Value가 nil인데 타입은 number라고 거짓말하는 상태가 됨.
-- ===== B) 오버로드 흉내 — 함수 타입 교차로 "생략 시 T?" 강제 시도 =====
type RefCtorOverload = (<T>(default: T) -> Ref<T>) & (<T>() -> Ref<T?>)
local RefB: RefCtorOverload = (nil :: any) :: RefCtorOverload
local refB1: Ref<number> = RefB(5) -- 정상 — 첫 번째 오버로드(T=number)로 통과해야 함
local refB2 = RefB() -- 두 번째 오버로드로 잡혀야 함 — 추론된 타입이 Ref<unknown?> 류가 될 것으로 예상
-- 아래가 진짜 확인 대상: refB2를 non-nilable Ref<number>에 대입하면 막히는가?
local refB2_annotated: Ref<number> = RefB() -- <- 이것도 에러가 나야 "막혔다"고 할 수 있음
-- (T가 추론 컨텍스트에서 number로 잡히면서 동시에 "무인자 오버로드라 T?
-- 여야 한다"는 두 요구가 충돌하는지가 관건 — 충돌해서 에러가 나면 성공,
-- 조용히 number로 통과해버리면 오버로드로도 못 막는다는 뜻)
-- 대조군 — nilable로 명시하면 항상 통과해야 함(오버로드가 정상 케이스는 안 막는지 확인)
local refB3: Ref<number?> = RefB()
print("런타임 실행은 의미 없음 — luau-analyze/luau-lsp 진단만 확인할 것")
print(refA1, refA2, refB1, refB2, refB2_annotated, refB3)
--[[
확인 포인트:
1. A) `refA2 = RefA()` 줄이 에러 없이 통과하는가? (예상: 통과함 —
이게 바로 "타입으로 못 막는" 현재 상태를 보여주는 대조군)
2. B) `refB2_annotated: Ref<number> = RefB()` 줄이 에러가 나는가?
- 에러가 나면: 오버로드 방식으로 실제로 이 캐비엇을 타입 레벨에서
막을 수 있다는 뜻 — base 문서의 "타입으로 막을 수 있으면 막을 것"
을 실제 설계로 채택할 근거가 생김, `Source`/`Ref` 생성자를
이 오버로드 모양으로 다시 쓸 것.
- 에러가 안 나면(조용히 통과): Luau의 제네릭 함수 교차 타입
오버로드가 이 정도로 정교한 추론을 못 한다는 뜻 — 문서의
"안 되면 UB로 경고"가 fallback이 아니라 사실상 유일한 선택지로
확정됨.
3. `refB3`(nilable로 명시한 정상 케이스)는 항상 통과하는가 — 오버로드
자체가 정상 사용까지 막아버리는 부작용은 없는지 확인.
4. 이 결과가 나오면 `bind-system-plan.md`의 해당 캐비엇 절에 "실측
결과"로 반영할 것 — 지금은 "타입으로 막을 수 있으면 막고"라는
조건문으로만 적혀 있어서 결론이 필요함.
]]

118
.claude/luau-test/README.md Normal file
View file

@ -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<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 |
| `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<<T>> "name"] = value`처럼 제네릭 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 열한 번째 세션 재정정) |
| `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)`) 자체가
잘못 짜인 것이니 우선순위 높게 알려줄 것.

View file

@ -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<<T>>
"name"] = value` 제네릭 DI 키가 실제로 값 타입을 좁혀주는지, 12번)과
f198fd9에서 뒤집힌 결정(`isRef`/`isPreRef`가 이제 `Source`/`State`와
같은 포함 관계 — `PreRef``Ref`의 하위 개념이 됨, `PreRef<T>`
`Ref<T>`를 구조적으로 만족하는지 타입체크까지 포함, 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번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서.