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:
parent
f198fd9c6b
commit
df4a77b02d
17 changed files with 2137 additions and 1 deletions
|
|
@ -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/`로 승격(또는 구현 착수 시
|
||||
|
|
|
|||
60
.claude/luau-test/01-two-pass-array-hash-order.luau
Normal file
60
.claude/luau-test/01-two-pass-array-hash-order.luau
Normal 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 결과가 그대로인지 확인해볼 것 — 순서가 소스 텍스트가
|
||||
아니라 오직 "배열/해시 파트 분리"에만 의존한다는 걸 재확인하는 목적.
|
||||
]]
|
||||
172
.claude/luau-test/02-none-sentinel-vs-nil-holes.luau
Normal file
172
.claude/luau-test/02-none-sentinel-vs-nil-holes.luau
Normal 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 대신 선형 탐색 재사용 등록 함수"가 실제로
|
||||
의도대로 동작하는지의 핵심 확인.
|
||||
]]
|
||||
168
.claude/luau-test/03-recursive-store-bind-dispatch.luau
Normal file
168
.claude/luau-test/03-recursive-store-bind-dispatch.luau
Normal 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에
|
||||
추가할 것).
|
||||
]]
|
||||
201
.claude/luau-test/04-dispatch-chain-retractUnder.luau
Normal file
201
.claude/luau-test/04-dispatch-chain-retractUnder.luau
Normal 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. 스택 오버플로 없이 전부 정상 종료되는가.
|
||||
]]
|
||||
157
.claude/luau-test/05-store-state-diamond-propagation.luau
Normal file
157
.claude/luau-test/05-store-state-diamond-propagation.luau
Normal 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 온톨로지"
|
||||
절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의
|
||||
정확성"만 검증 대상.
|
||||
]]
|
||||
87
.claude/luau-test/06-component-boundary-nil-hole-props.luau
Normal file
87
.claude/luau-test/06-component-boundary-nil-hole-props.luau
Normal 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 실제 구현 시 정확한 타입을 찾아야 함.
|
||||
]]
|
||||
135
.claude/luau-test/07-relate-weak-table-gc.luau
Normal file
135
.claude/luau-test/07-relate-weak-table-gc.luau
Normal 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는 정확한 타이밍을 보장 안 하므로 완벽한 증거는
|
||||
아님, 참고 신호 정도로만 볼 것).
|
||||
]]
|
||||
78
.claude/luau-test/08-type-source-satisfies-state.luau
Normal file
78
.claude/luau-test/08-type-source-satisfies-state.luau
Normal 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)
|
||||
73
.claude/luau-test/09-type-modifier-overridden-subtype.luau
Normal file
73
.claude/luau-test/09-type-modifier-overridden-subtype.luau
Normal 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에 반영할 것.
|
||||
]]
|
||||
206
.claude/luau-test/10-roblox-studio-checks.server.luau
Normal file
206
.claude/luau-test/10-roblox-studio-checks.server.luau
Normal 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 로그를 그대로 복사해서 공유해주면 됨")
|
||||
302
.claude/luau-test/11-modifier-illegal-value-error.luau
Normal file
302
.claude/luau-test/11-modifier-illegal-value-error.luau
Normal 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를 씀,
|
||||
여기선 그 판별 로직 자체가 아니라 "체크 지점 배치가 실제로 동작하는가"만
|
||||
검증 대상.
|
||||
]]
|
||||
|
|
@ -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 결론이 확정됨.
|
||||
]]
|
||||
135
.claude/luau-test/13-type-ref-preref-subtype.luau
Normal file
135
.claude/luau-test/13-type-ref-preref-subtype.luau
Normal 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)`를
|
||||
같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지.
|
||||
]]
|
||||
84
.claude/luau-test/14-type-nilable-default-overload.luau
Normal file
84
.claude/luau-test/14-type-nilable-default-overload.luau
Normal 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
118
.claude/luau-test/README.md
Normal 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)`) 자체가
|
||||
잘못 짜인 것이니 우선순위 높게 알려줄 것.
|
||||
68
CLAUDE.md
68
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<<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번 참고) — 단, 이 폴더 결과를 먼저 확인하고 진행하는 게 순서.
|
||||
|
|
|
|||
Loading…
Reference in a new issue