diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index fdfbad8..a59f63c 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -135,7 +135,7 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋)와 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 +│ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `bind-system-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ └── init.luau └── quad-roblox/ diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 5808152..1282285 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -5,7 +5,7 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 `quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 남은 건 세부 시그니처(dependency -array API, `CreatedRef` 모양) 뿐 — 구현 단계에서 자연히 정리됨. 원본: +array API) 뿐 — 구현 단계에서 자연히 정리됨. 원본: `.claude/initreq/raw-userinput.md` "key와 value에 대한 바인드 연산은 pluggable 하도록 구성하기" / "스토어는 스토어를 저장 가능한가" / "Ref는 고민중" 절. v1의 문제점은 `reference/quad-v1-architecture.md` @@ -177,6 +177,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — KV 매치와 무관. + **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 + `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" + 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`은 "빈 슬롯" + 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 + 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 + 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 + 경로가 다르다는 점에 유의. `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. @@ -291,15 +298,21 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 - Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). -- **바인드 방법**: children을 배열 아이템으로 넣듯 `CreatedRef` 같은 - 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: - `[1] = CreatedRef(function(inst) ... end)`) — `(v=Ref)` 매치 핸들러가 - 이걸 처리함. **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 - 마운트 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — - 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, - 원래 있던 "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 - 없는 평범한 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 - 옮김. +- **바인드 방법**: children을 배열 아이템으로 넣듯 `Ref(default)`(또는 + `:Callback(fn)`을 미리 걸어둔 `Ref(default):Callback(fn)`) 인스턴스 + 자체를 숫자 키 슬롯에 그대로 넣는 방식 — `(v=Ref)` 매치 핸들러가 이걸 + 처리함. **별도 `CreatedRef` 래퍼 함수는 없음(2026-08-07 아홉 번째 + 세션, 사용자 확인) — `Ref`/`PreRef`가 이미 Compose식 `Type(default)` + 팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로 + children 배열에 놓는 것 자체가 바인드 관용구.** 원래 "`CreatedRef` 같은 + 이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로 + 좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후 + 래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함. + **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 마운트 + 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — 아래 + "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, 원래 있던 + "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 없는 평범한 + 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 옮김. - **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` @@ -348,8 +361,8 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 - 잘 순회함). + 가능(발화 후 해당 인덱스만 **`None`으로 소진** — 아래 구현 디테일의 + 2026-08-07 열 번째 세션 정정 참고, 단순 `nil` 처리는 아님). - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -363,20 +376,38 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`가 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume - payload 정정)**: 값이 새로 `:Set()`될 때, 같은 배열 하나를 - `for i, v in <배열> do ... end`로 한 번만 순회하면서 `type(v) == - "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** - (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 - 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, - `coroutine.yield()`의 리턴값이 곧 `self`가 되도록 정정. 세 번째 - 세션 원안은 `value`를 넘기는 것으로 적혀 있었으나 이러면 - `ref:Wait().Value`가 안 풀려서 이번 세션에 정정) 후 `[i] = nil` - (1회성 소진), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 - 여전히 원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — - 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 - 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 인덱스가 있어도 - 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 일반화 `for`가 - 계속 잘 순회하므로 압축(compact)할 필요도 없음). + payload 정정, 열 번째 세션에서 소진 방식 정정)**: 값이 새로 `:Set()`될 + 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 + 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 + **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 + resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield + 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 + 리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`를 + 넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서 + 이번 세션에 정정) 후 **`[i] = None`**(**`nil`이 아님** — 아래 + "왜 `nil`이 아니라 `None`인가" 참고), 아니면 일반 콜백 함수로 보고 + 그냥 `v(value)`(콜백은 여전히 원래 값을 직접 받음, 소진 안 함, 계속 + 유지)로 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 + 값 타입 하나로 분기 가능(`type(v) == "thread"` → 대기자, + `type(v) == "function"` → 콜백, 그 외/`None` → 빈 슬롯이라 스킵). + 새 콜백/대기자 등록은 `table.insert`로 끝. + - **왜 `nil`이 아니라 `None`인가(2026-08-07 열 번째 세션, 사용자가 실제 + Luau REPL로 반례 제시 후 정정) — 이전 서술("성긴 배열이어도 일반화 + `for`가 계속 잘 순회하므로 압축 불필요")은 절반만 맞았음.** 대기자/콜백 + 자체는 순서가 안 중요해서(어느 게 먼저 fire되든 상관없이 전부 fire되기만 + 하면 됨) "잘 순회함"까지는 맞았지만, 두 가지를 놓쳤음: (1) 키가 촘촘한 + 저범위 정수(1,2,3,...)에서 벗어나 듬성듬성해지면(`nil`로 지운 슬롯도 + 포함) Luau/Lua 테이블이 그 키들을 해시 파트로 취급해 순회 순서가 해시 + 버킷 순서가 되어버림(사용자가 `{[1]=1,[2222]=2222,[211]=211,...}`류 + REPL 실측으로 확인 — 대기자/콜백 리스트 자체는 이 순서 소실이 문제 + 안 되지만, 순서가 실제로 중요한 다른 배열(`PreRef` pre-pass 등)엔 + 치명적). (2) `table.insert`가 내부적으로 쓰는 `#t`(length 연산자)는 + Lua 명세상 구멍이 있는 테이블에서 **정의되지 않은 동작**이라, 다음 + 콜백/대기자 등록이 엉뚱한 인덱스에 들어가 기존 항목을 덮어쓸 위험이 + 있음 — 이건 대기자/콜백 리스트에도 실제로 해당하는 진짜 버그. + `None`은 `nil`이 아닌 **실재하는 값**이라 그 슬롯이 "차 있다"는 사실 + 자체는 안 바뀌므로 두 문제 다 피함 — 소진된 슬롯도 여전히 non-nil + 값을 갖고 있어 테이블이 "구멍 없는 시퀀스"라는 불변식이 깨지지 않음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -397,12 +428,12 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 풀면 됨 — React `useRef(null)`도 명시적 타입 인자 없이는 같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는 트레이드오프 아님. -- **`CreatedRef`와의 관계**: 둘은 상충하지 않음 — 이 절의 Ref가 범용 - 프리미티브, `CreatedRef(fn)`는 그 위에 얹힌 "children 배열에 넣으면 - dispatch가 자동으로 채워주는" 특수 편의 패턴(quad가 만든 instance에 - 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 기반 + `PreRef` - 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" - 절이 최신. +- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**: + 이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref` + 인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든 + instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치 + 기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로 + 표현, `PreRef` 신설" 절이 최신. - **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도 자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스 호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너 @@ -422,12 +453,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) 때 이름이 여전히 맞는지 같이 재검토할 것. -### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션) +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 +세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 +아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) -**`CreatedRef(fn, {phase="created"|"mounted"})`의 `phase` 옵션 자체를 -없앤다.** 위 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 -파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 -**일반 `Ref`/`CreatedRef`를** 다른 children보다 앞/뒤 어디에 놓느냐가 +**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두 +타이밍을 고르게 하던 것 자체를 없앤다.** 위 "확정된 디스패치 모델" 절에 +새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트) +덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤 +어디에 놓느냐가 이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기 서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로, "마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도 @@ -472,7 +506,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 같은 원칙의 재적용. - **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 - `Ref`/`CreatedRef`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + `Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, @@ -485,7 +519,96 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 앞에 얹히는 것. -- **일반 `Ref`/`CreatedRef`는 계속 Modifier/Store 어디든 자유롭게 + - **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션, 사용자 확인) — + 새 규칙 불필요, 배열 index 순서 그대로.** 같은 인스턴스에 `PreRef`가 + 여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔 + "배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로 + fire하면 됨 — 서로 다른 우선순위/순서 개념을 별도로 만들 필요 없음 + (호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리" + 에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회). + - **호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의 + 선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변).** + `Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번 + 순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며 + `isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i] + = None`**으로 소진(`nil`이 아님 — 위 "왜 `nil`이 아니라 `None`인가" + 절과 같은 이유, 2026-08-07 열 번째 세션 정정: `nil`로 지우면 그 + 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회 순서 + 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는 바로 그 + 보장이라 치명적). (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가 + **같은 테이블**을 다시 순회 — 이때 `None`으로 소진된 슬롯은 **정상 + `Dispatch.process`/`NoneHandler` 경로를 안 타고 두 패스 루프 자신이 + 직접 건너뜀**(`if v == None then continue end`, 배열 파트 전용 + 특수 취급). **해시 파트의 `None`(Modifier 필드 명시적 지우기)과는 + 의미가 다름** — 해시 파트 `None`은 "이 프로퍼티 핸들러야, 방금 + 지워진 걸 알고 알아서 처리해"라는 의미 있는 재디스패치(`NoneHandler` + 경유)지만, 배열 파트 `None`은 그냥 "여기 원래 아무것도 없었던 것과 + 같다"는 순수 빈 슬롯 표시라 처리할 핸들러 자체가 없음 — 재디스패치 + 없이 루프가 직접 스킵하는 게 맞음. "호이스팅"은 PreRef를 배열의 맨 + 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 + 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 + 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 + fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성 + 요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`를 + 매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면 + 두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를 + `Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가 + 엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은 + 이 오탐을 막기 위해 반드시 필요. + - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props + 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 + 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 + 번째 세션). 같은 세션에서 사용자가 직접 `{[1]=1,[2222]=2222, + [211]=211,...}`류 **키가 듬성듬성한(sparse)** 테이블을 REPL로 + 실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시 + 버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) `None`으로 + 소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로 + 이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로). + **여전히 M0에서 검증해야 하는 건 다른 케이스**: `props.Modifier`/ + `props.Ref`를 caller가 안 넘겨 생기는 리터럴 `nil`-hole(`{nil, ref, + child}`, 위 "지금 할 일" 우선순위1 항목)은 caller가 직접 쓰는 raw + Lua 리터럴이라 프레임워크가 `None`으로 대신 못 채워줌 — 이번 REPL + 실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수 + 있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만 + 생겨도 **테이블 전체**가 해시 파트 취급으로 넘어가 그 인스턴스의 + 배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시 + 실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상 + `props.Modifier or Modifier()`처럼 non-nil을 보장하라"는 컨벤션 + 문서화까지 검토할 것. + - **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신, + 새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).** + `Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를 + 도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로 + 충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst, + flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미 + 다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서 + 안 좋음). **`flatten(nonFlatten) -> flatten` 함수 자체에 얹는 방안은 + 검토 후 기각** — flatten은 Modifier 값을 합치는 순수 변환(현재 `inst`를 + 안 받음, `research/existing-instance-bind-plan.md`가 다루는 "이미 + 마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가" + 질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 **여러 번 + 재호출될 가능성이 있음** — 여기에 PreRef fire를 얹으면 재바인드마다 + PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의 + 정의 자체가 깨짐. `Dispatch.drive`는 최초 마운트 시 정확히 한 번만 + 불리는 게 이미 전제라 이 위험이 없음. + - **동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러 + (2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼 + 있었음, 지금 확정.** 위 "Modifier 필드로 막는 이유"/"Source/Store + 값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에 + 지워지므로(`:Peek`/`Override`/버그로 타입을 우회해 PreRef가 Modifier나 + Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. + 전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return + isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children + 배열 리터럴에만 놓을 수 있음") end }` — `NoneHandler`와 같은 결의 + "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 + Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에 + 등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 + `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(`None` 처리, + `nil` 아님)해 정상 두 패스 스캔에 다시 노출되지 않게 하므로, 이 + Handler가 실제로 + 매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로 + 새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 `error`. +- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). - **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** @@ -677,7 +800,7 @@ stale하다" 수준이 아니라 **영영 갱신이 안 일어날 수 있음**. **결정(2026-08-06 후속 세션, 사용자 확정)**: 별도 `ObserverHolder` 래퍼 타입은 안 만듦 — `state:Observer(fn)`가 반환하는 값 자체가 이미 "children 배열에 바로 놓을 수 있는 leaf 값"이라 감쌀 필요가 없음. -`CreatedRef`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 +`Ref`와 완전히 같은 층위. **자유 함수 `Observer(state, fn)`가 아니라 메소드 `state:Observer(fn)`로 확정** — `state`가 항상 필요한 필수 인자라 `:` 리시버 자리에 자연스럽게 들어가고(다른 형태면 인자 두 개짜리 자유 함수가 되어 읽는 순서가 어색해짐), `architecture.md`의 @@ -728,7 +851,7 @@ retract/Destroy되면 자동으로 정리됨. 8번)과 같은 결. `base/effect-plan.md`의 Effect도 동일하게 권장. - **base가 제공하는 것은 `isObserver`류 타입 판별자 하나** — children 배열 dispatch가 숫자 슬롯 값을 훑을 때 "이게 Observer인가"를 판별해 - `CreatedRef`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 + `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array 참가자 패턴의 반복. - **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 @@ -1388,7 +1511,8 @@ function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값 -- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님 local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag, - StateTag, SourceTag, StoreTag, SlotTag = {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, ModifierTag = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} -- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서: Brand.set(newHandle, ObserverTag) @@ -1426,6 +1550,22 @@ end 문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "별도 `isSource` 불필요" 서술도 같이 정정 대상. +**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것 +추가(2026-08-07 열 번째 세션).** 위 코드 예시가 원래 `RefTag`/ +`PreRefTag`/`ModifierTag`를 안 만들어뒀는데, 이 문서 곳곳(PreRef 절의 +`isPreRef(v)`, `component-composition-plan.md`의 `isModifier(v)` 등)이 +이미 이 predicate들이 존재한다고 전제하고 써왔음 — 실제로 만들어야 하는 +게 맞아서 태그 목록에 추가. **`isRef`/`isPreRef`는 `isObserver`와 같은 +단순 항등**(`isRef(x) = Brand.get(x) == RefTag`, `isPreRef(x) = +Brand.get(x) == PreRefTag`) — `isState`처럼 집합 멤버십이 아님, 즉 +**`isRef(preRefInstance)`는 `false`.** 이게 중요한 이유: `PreRef`가 +"Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 해서 `isRef`가 +`PreRef`도 통과시키면, 일반 `(v=Ref)` 매치 핸들러가 `PreRef` 인스턴스도 +집어삼켜버려 위 "PreRef" 절이 요구하는 "일반 Ref 경로를 절대 타면 안 +됨"이 깨짐 — `Ref`/`PreRef`는 State/Source 같은 상하위 관계가 아니라 +서로 배타적인 형제 브랜드. `isModifier`도 같은 단순 항등 +(`Brand.get(x) == ModifierTag`). + **`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** `Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` @@ -1469,10 +1609,6 @@ State ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는 모듈 이름** — 방향은 전부 확정, 이름만 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). -- **`CreatedRef`(가칭)의 정확한 함수 이름** — children 배열에 아이템으로 - 넣는다는 방향, 그리고 타이밍은 옵션 값이 아니라 위치 + `PreRef` 타입으로 - 표현한다는 것까지 확정(위 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` - 신설" 절), 정확한 API 이름만 남음. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 198fdff..c504439 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -198,7 +198,32 @@ Modifier/Ref/자식을 구분"이 이미 v1의 유일한 해법이었던 패턴 명시적으로 원하는 내부 `Frame{...}` 호출의 배열 자리에 다시 꽂아넣음 (`return Frame { props.Modifier, props.Ref, ... }`) — **사용자 확정** ("결과적으로 함수 구현에선 타입을 멀쩡히 지정하는게 더 중요하니 네임드가 -맞는듯"). Compose(named `modifier` 파라미터 강제, 린트로 감시)와 +맞는듯"). + +**⚠️ 필수 관용구 — `props.Modifier or None`/`props.Ref or None`으로 +써야 함, 맨 리터럴로 꽂으면 안 됨(2026-08-07 열 번째 세션, `nil`-hole +버그 실측 후 확정).** caller가 `props.Modifier`/`props.Ref`를 안 넘기면 +`nil`인데, `{nil, props.Ref, child}`처럼 Lua 배열 리터럴에 `nil`이 그대로 +들어가면 그 순간 테이블의 배열 파트 전체가 순회 순서 보장을 잃을 위험이 +있음(`base/bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절 — Luau +REPL 실측으로 확인된 실제 버그, 국소적 피해가 아니라 테이블 전체에 영향). +그래서 **컴포넌트 저작자는 항상 `or None`으로 감싸서 넘겨야 함**: +```luau +return Frame { props.Modifier or None, props.Ref or None, child } +``` +- **왜 `Modifier()`(빈 modifier 생성)가 아니라 `None`인가**: 별도 할당이 + 필요 없고, 기존 메커니즘을 그대로 재사용함 — `flatten` 단계는 애초에 + `isModifier(v)`가 거짓인 값은 그냥 건드리지 않고 통과시키므로 + (`None`은 Modifier가 아니라서 자동으로 이 경로), `props.Modifier or + None`이 최종적으로 배열 파트에 `None`인 채로 남으면 두 패스 루프 + 자신의 array-part `None`-스킵 규칙(위 "PreRef" 절)이 그대로 적용돼 + 아무 일도 안 일어남 — 새 특수 케이스 코드가 하나도 안 늘어남. +- 이 관용구는 컴포넌트 저작자가 **직접 챙겨야 하는 규율**(base가 강제로 + 검증해줄 방법은 없음, Lua는 이런 걸 린트로만 잡을 수 있음) — quad + 문서화(초심자 가이드/`props.Modifier`/`props.Ref` 절)에 필수 패턴으로 + 명시할 것, `research/documentation-content-map.md`에 반영 필요. + +Compose(named `modifier` 파라미터 강제, 린트로 감시)와 Fusion/Vide(named prop 전달, `[Children]`류 예약 키)가 서로 다른 이유로 전부 같은 결론에 도달한 유일한 실용적 패턴 — quad가 발명한 게 아니라 선례가 수렴하는 지점(위 "프레임워크 사례 조사" 절 참고). diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 1a2a7df..8bc8ed8 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -31,6 +31,14 @@ pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 변경은 전체 교체, 부분 오버레이 없음"(`base/architecture.md` 3번) 원칙과 충돌함. +**flatten이 배열 항목 중 뭐가 Modifier인지 판별하는 수단 — `isModifier` +(`Brand` 기반, 2026-08-07 열 번째 세션 명시).** 다른 모든 nominal 타입 +판별과 같은 메커니즘(`bind-system-plan.md`의 `Brand` 절) 재사용 — flatten은 +배열을 훑으며 `isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고, 나머지는 +전혀 안 건드리고 그대로 배열 파트에 남겨둠(그래서 `None`처럼 Modifier가 +아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`의 +"필수 관용구" 절 참고). + 관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 해야 하는지/clone이 필요한지는 별개 미정 문제로 `research/existing-instance-bind-plan.md`의 "Default 값과 얽히는 문제" 절 @@ -112,6 +120,16 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 낫다고 판단 — **immutable 기본으로 확정**. +**바닥 생성자 — `Modifier()`(필드 없는 빈 인스턴스, 2026-08-07 열 번째 +세션 명시).** 지금까지 문서 어디에도 modifier 체이닝이 시작되는 첫 +호출(`props.Modifier`처럼 이미 존재하는 modifier를 이어받지 않고 처음부터 +만드는 경우)이 명시된 적이 없었던 갭 — `Source(default)`/`Ref(default)`/ +`Store({defaults})`와 같은 "`Type(args)` 팩토리" 관습을 그대로 적용하면 +됨, Modifier는 초기 필드가 필수가 아니므로 `args`가 비어도 되는 +`Modifier()`. `mod:FontSize(20)`처럼 체이닝하는 모든 예시가 실은 이 +`Modifier()`가 만든 빈 인스턴스 위에서 시작함. `base/store-semantics.md` +"독립 존재 가능한 프리미티브" 절의 예시 목록도 이걸로 갱신. + ### 4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음 `:FontSize(value)`(리터럴) / `:FontSize(function(current) return diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index 9d41fa0..58ff539 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -73,8 +73,9 @@ pull-recompute)·`:Compute` 인자 규칙·State 쓰기 금지·`Source` 독립 - **독립 존재 가능한 프리미티브** — Source, Ref, Store, Modifier. 다른 무언가 없이 그 자체로 `Type(args)` 팩토리 함수로 만들어짐(`Source(default)`/ - `Ref(default)`/`Store({defaults})`/`mod:UICorner(8)`, 위 "생성자 - 스타일 확정" 참고). + `Ref(default)`/`Store({defaults})`/`Modifier()`, 위 "생성자 + 스타일 확정" 참고 — `Modifier()`는 빈 인스턴스, 실제 필드는 + `mod:UICorner(8)`류 체이닝으로 그 위에 얹음). - **원천에 종속된 파생 데이터** — State, Observer. 자기 혼자 존재할 수 없고 항상 특정 원천(Source/다른 State)에 의존 — 그래서 이 둘은 자유 함수 생성자가 없고, 항상 원천에 대한 메소드 호출로만 얻어진다 diff --git a/.claude/question.md b/.claude/question.md index 2b9d6f1..251e44f 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -64,13 +64,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. -- **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 - 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" - 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. +- **`canExecute`(3순위, 사소함)**: 실제로 "이 핸들이 아직 살아있나" 확인인데 + 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. **(2026-08-07 추가)** `PreRef`(children 배열 전용, Modifier/Store에 못 들어가는 Ref 특수화 — `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절)도 신규 이름이라 이 라운드에 같이 재검토 - 대상. + 대상. (`CreatedRef`는 더 이상 이 목록에 없음 — 별도 이름 자체가 폐기됨, + `Ref(default)`/`PreRef(default)`를 children 배열에 직접 놓는 것으로 + 확정, 아래 "지금까지 확정된 것" 참고.) - **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 @@ -132,10 +133,11 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** 없이 나란히 적용됨.** 왜 이 경우만 예외로 방어하는지 명문화 필요, 또는 Luau에서 실제 타입 차단이 가능한지부터 확인(안 되면 그냥 UB로 격하) — 문서모순 절 + 우선순위2-2. -- **`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 - nil-hole 함정에 그대로 노출됨** — caller가 Modifier/Ref를 안 넘기면 - `{nil, ref, child}`에서 뒤 항목까지 통째로 무시될 수 있는 버그 클래스. - M0 스파이크에 이 케이스(안 넘기는 경우)를 반드시 포함시킬 것 — 우선순위1-5. +- ~~`props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴 + nil-hole 함정에 그대로 노출됨~~ — **반영 완료(2026-08-07 열 번째 + 세션)**. `props.Modifier or None`/`props.Ref or None` 관용구를 필수로 + 확정(`base/component-composition-plan.md` "필수 관용구" 절) — M0에선 + 이 관용구 자체가 타입/런타임 양쪽에서 문제없이 동작하는지만 검증. - **`canExecute`/`Connected`의 실제 구현 방식(Parent==nil vs Connection. Connected vs Destroying 플래그)이 미확정인데 이미 Slot/Observer/store-bind retract 전역에 재사용 확정됨** — M2/M3 착수 전 실측 필요 — 우선순위1-6. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 44b6109..55e5639 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -34,7 +34,7 @@ 8. **컴포넌트 경계 넘기기** — `props.Modifier`/`props.Ref` named parameter 패턴 (`component-composition-plan.md`) 9. **이벤트** — self(Instance) 안 받음, 문자열 키(`Frame { MouseButton1Click = fn }`) (`bind-system-plan.md`) 10. **생명주기** — GC 위임(수동 정리 불필요), Destroy 이후 대상 재사용 금지 (`lifecycle-pattern.md`) -11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn)` + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.md`) +11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `Ref(default):Callback(fn)`을 children 배열 숫자 슬롯에 직접 놓기 + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.md`) 12. **파생값 최소 예시** — `:With(...)` + `:Compute(fn)` 기본형 (`bind-system-plan.md`, `store-semantics.md`) 13. **Tween 기초** — `[Tween(key, ...)] = storeValue`, 취소 시 현재 보간값에서 자연스럽게 이어짐 (`research/tween-plan.md`) 14. **UI 숏핸드(quad-roblox 한정)** — `UICorner`/`UIPadding`/`UIPaddingOffset`/`UIScale` 인라인 키 (`base/ui-shorthand-plan.md`) @@ -67,13 +67,13 @@ v1 폐기 API/버그/구조 결함 전부 v2 설계를 정당화하는 내부 그때만 재사용 가치 있음 — 지금 3축 어디에도 해당 없음. ### bind-system-plan.md (943줄, 최대 문서) -- 초심자: Source/Store/State 기본 정의+생성자, State 읽기 전용 규칙 / dot-access가 값 읽기 1급 경로 / `:With`+`:Compute` 최소 사용법 / Ref 기본 개념+`CreatedRef` / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) +- 초심자: Source/Store/State 기본 정의+생성자, State 읽기 전용 규칙 / dot-access가 값 읽기 1급 경로 / `:With`+`:Compute` 최소 사용법 / Ref 기본 개념(children 배열에 직접 놓기, 별도 `CreatedRef` 없음) / 이벤트 self 미채택 기본 규칙+문자열 키 / 인스턴스 생성(제네릭+정적 필드) / 라이브러리 초기화 3줄(`RobloxFactory(QuadBase)`) - api: `state:Observer(fn)` 사용법(→심화: weak-table 내부 인덱싱) / `:Subscribe()`/`:Unsubscribe()` 시그니처(→심화: 강참조 레지스트리 구조) / Ref 일반화 표면 API(→심화: "왜 값이 아니라 콜백인가") / 이벤트 store-bind 존재+권장 안 함 가이드(→심화: 엔지니어링 비용 근거) / 핸들러 4종 계약(`isHandlable`/`priority`/`process`/`retract`) / `Attribute` 특수 키 후보(미확정 명시 필요) - 심화: push-invalidate/pull-recompute 전파 모델+"관측해야 실체화된다" 원칙+`previous` 캐비엇 / **왜 State를 Modifier처럼 플래튼하지 않는가**(이미 문서화 완료, 아래 3번 참고) / Store가 Store를 못 담는 이유 / 이벤트 self 미채택 4가지 근거 / store-bind 재귀 래핑 내부 메커니즘, retract가 Destroy 시 호출 안 되는 이유 / 같은 팩토리 재호출 no-op·다른 팩토리 충돌 에러 내부 안전장치 - skip: quad2-try 리서치 결과 섹션 전체(OOP 상속/커스텀 파서/Slot 스텁/`Pipe` 폐기 이력) / PA님 코드 교차검증 절(역사적 검증 기록) / "남은 열린 질문"/"확정된 것" 메타 요약 ### component-composition-plan.md / module-lifecycle-plan.md -- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달 / `InitRoblox(Module)` 팩토리 초기화 +- 초심자: 컴포넌트=순수 함수 / 리프 프로퍼티엔 State만 바인딩 / `props.Modifier`/`props.Ref` named parameter 경계 전달(**`props.Modifier or None`/`props.Ref or None` 필수 관용구 — 안 쓰면 nil-hole 버그, 2026-08-07 열 번째 세션 확정**) / `InitRoblox(Module)` 팩토리 초기화 - api: State(파생, 읽기전용) vs Source(원본, 쓰기가능) 경계 요약(→심화) / Slot 반환 컴포넌트는 Modifier/Ref 파라미터 미선언 / `Modifier.Override(mod1, mod2, ...)` 유틸(구 `Merge`, `props.Modifier` 단일 슬롯용 특수 상황으로 한정 소개 — 아래 modifier-plan.md 절 참고) / Bind는 유일 슬롯(재호출 no-op, 충돌 에러, →심화) / `:With`/`:Compute`로 파생 State 생성 시그니처 / 모듈 싱글톤 스코프 - 심화: v1 `Extend` 자동 store 소유 폐지 이유(React 벤치마킹) / Source가 State를 구조적으로 만족하는 서브타입 설계(2026-08-06 후속 세션 — `StoreSource` 프록시 중간안은 폐기되고 이걸로 대체됨, `store-semantics.md` 참고) / named-parameter 경계 방식 채택 이유(Compose/Fusion/Vide/v1 선례 수렴) / 다중 루트 반환 개념 제거 근거 / 팩토리 초기화 패턴 채택 이유(RBVM `InitNamespace` 반례) / Store 책임 분리(base가 `LifetimeHandle` 소유) / v1 named 체이닝 연산 폐기 - skip: Compose/Fusion/Vide/v1 프레임워크 비교 원자료 / provider/processor 네이밍 미정 등 열린 질문 메모 @@ -180,6 +180,23 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: 최우선 목표로 뒀는가 — 위 `심화` 3번(`왜 push-invalidate/pull-recompute 인가`)을 더 깊게 확장, `Blocker` 같은 파생 프리미티브가 이 목표 위에서 왜 자연스럽게 나왔는지까지 포함하는 설계 철학 에세이 +6. **왜 배열/해시 두 패스 순서를 안 뒤집는가, `PreRef`는 왜 그 예외로 + 따로 필요한가** (2026-08-07 세 번째 세션 원자료, `bind-system-plan.md` + "`phase` 옵션 폐기" 절 마지막 항목이 이 자리를 지목해뒀던 것 — 지금까지 + 여기 안 옮겨져 있었음) — "프로퍼티/이벤트가 항상 children/Ref보다 + 나중"이라는 순서를 고치는 대신 `PreRef`라는 별도 타입으로 예외를 + 빼낸 선택 자체가 에세이 소재. **여기 곁들일 후보 프레이밍(사용자 제시, + 2026-08-07, 정확한 정의는 미확정 — 아래 5번 목록 참고)**: `Ref`는 + `(v=Ref)` 매치 핸들러로 처리돼 다른 핸들러들과 + 같은 우선순위 스캔에 참여한다는 의미에서 "hook"(순서 등록 가능, 다른 + 값으로 교체되면 `retract`로 취소됨)에 가깝고, `PreRef`는 그 스캔 밖의 + 고정 pre-pass라는 의미에서 "pre-hook"(항상 최우선 고정, 순서/취소 + 개념 자체가 다름)에 가깝다는 구분 — quadnomicon 에세이로 쓸 때 이 + "hook"/"pre-hook" 용어 자체를 채택할지부터 먼저 확인 필요(복수 `PreRef` + 간 순서는 2026-08-07 아홉 번째 세션에서 해소됨 — 배열 index 순서 + 그대로, 별도 규칙 없음, `bind-system-plan.md` "PreRef" 절 참고. 취소 + 가능 여부는 여전히 미정 — PreRef는 fire와 동시에 소진되는 1회성 + pre-pass 참가자라 "취소"라는 개념 자체가 성립하는지부터 다시 볼 것). **publish 안 하는 것과의 경계**: 세션별 정정 이력, 조사 원자료(Fusion 반응 그래프 BFS 분석, quad2-try 죽은 코드 조사 등)는 quadnomicon에도 @@ -204,6 +221,14 @@ additional-primitives-plan.md`의 "문서화 백로그" 절이 원자료)**: - 키 기반 동적 컬렉션 재조정 최종 이름/시그니처(`Render`/`Draw`/`List` 등 후보만 있음, `Slot:Extract` 세부 시맨틱도 미정) — `research/ additional-primitives-plan.md`(2026-08-06 신설, 설계 진행 중) +- **"hook"/"pre-hook" 용어 채택 여부 + `PreRef`의 취소 가능성** (2026-08-07, + 위 심화 후보 6번 참고) — `bind-system-plan.md`는 `PreRef`가 위치 무관 + 호이스팅이라는 것과 일반 `Ref`가 우선순위 스캔에 참여한다는 것까지는 + 확정해뒀고(복수 `PreRef` 간 순서=배열 index 순서, 동적 경로로 도착한 + PreRef는 전용 Handler가 즉시 error — 둘 다 아홉 번째 세션에서 추가 + 확정), "hook 대 pre-hook"이라는 용어 자체를 문서화 시 채택할지와 + `PreRef`의 취소 가능성(애초에 fire와 동시에 소진되는 1회성이라 + "취소"가 의미 있는 개념인지부터)만 아직 미정. 이 항목들은 `.claude/question.md`에도 이미 열린 질문으로 잡혀있음 — 여기선 "확정 전엔 문서화 대상 아님"이라는 표시만 겸함. diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index fd7dcc8..f8a82d4 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -119,7 +119,15 @@ nil-index 크래시 vs 조용한 no-op)가 안 정해져 있음. ### 1-5. `props.Modifier`/`props.Ref` forwarding 관례가 Lua 배열 리터럴의 nil-hole 함정에 그대로 노출됨 -**위치**: `base/component-composition-plan.md` "최종 결론" 1번 — +**[2026-08-07 열 번째 세션 갱신 — 반영 완료.]** 아래 제안 (a)/(b) 대신 +더 단순한 (c)류 해법으로 확정: `props.Modifier or None`/`props.Ref or +None` 관용구를 필수로 강제 — `None`이 항상 non-nil이라 리터럴 구멍 자체가 +안 생기고, 이미 있는 array-part `None`-스킵 메커니즘(PreRef 논의 중 +같은 세션에서 확정)을 그대로 재사용해 새 코드가 안 늘어남. +`base/component-composition-plan.md` "필수 관용구" 절, `ROADMAP.md` M0에 +반영 완료 — 더 이상 열린 항목 아님, 아래는 원래 발견 당시 기록. + +**위치(당시)**: `base/component-composition-plan.md` "최종 결론" 1번 — `return Frame { props.Modifier, props.Ref, ... }` 패턴. **문제**: caller가 `props.Modifier`나 `props.Ref`를 안 넘기면 그 값은 diff --git a/CLAUDE.md b/CLAUDE.md index 78f41d7..462cb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -663,7 +663,10 @@ M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. - **콜백/대기자 실행 구현 디테일 추가**: 같은 배열 하나를 한 번의 일반화 `for`로 순회하며 `type(v)=="thread"`면 `coroutine.resume`+ 슬롯 nil 처리(1회성), 함수면 그냥 호출(유지) — 새 등록은 `table.insert` - 로 끝, 성긴 배열이어도 압축 불필요. + 로 끝, 성긴 배열이어도 압축 불필요. **[정정, 2026-08-07 열 번째 세션]** + "슬롯 nil 처리"는 틀림 — 사용자가 Luau REPL로 반례 제시, 실제로는 + `None`으로 소진해야 함(`#t`/`table.insert` 안전성 문제). `base/ + bind-system-plan.md` "왜 `nil`이 아니라 `None`인가" 절이 최신. - v1의 `OnCreated` 특수 DI 키는 이식 안 함 — `Ref():Callback(fn)`으로 완전 대체. @@ -1156,3 +1159,206 @@ Tag/Attribute 전용 문서 신설.** None 논의를 파고들다 디스패치 **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-07 아홉 번째 세션 — 코퍼스 전체 정합성 감사·수정, `agent-mistake.md` +신설, `CreatedRef` 이름 완전 폐기 + +여러 세션에 걸쳐 쌓인 stale 참조/자기모순을 서브에이전트 5개 병렬 감사로 +찾아내 전부 수정하고(커밋 `98bd46a`), 이어서 사용자가 직접 발견한 남은 +문제(`CreatedRef` 이름 자체가 더 이상 존재할 이유가 없다는 지적)까지 처리한 +세션. 세 부분으로 나눠 요약: + +**1. 코퍼스 정합성 감사·수정 (커밋 `98bd46a`)**: `CreatedRef`의 `phase` +옵션 폐기가 `bind-system-plan.md` 안에서 세 곳 중 두 곳에 방치돼 있던 것, +`question.md`의 `Ref` 재검토 대상 여부 자기모순, UICorner 숏핸드 개명이 +5개 문서에 전파 안 된 것, `canExecute(handle)` 시그니처 정정이 막 확정된 +직후라 두 곳에 전파 안 된 것, `architecture.md`/`ROADMAP.md`/`CLAUDE.md`의 +stale 문구·누락 참조 등 12개 항목을 수정. `store-semantics.md` 제목도 +"State는 **Store** 위의 캐시 레이어"에서 "State는 **Source** 위의 캐시 +레이어"로 정정(사용자 확인: Source 단독 존재 가능 + Store는 Source들의 +집합이라는 온톨로지가 맞음). `slot-plan.md`의 CRUD 의미론 갭 하나만 +사용자가 다음 세션에서 직접 다루기로 보류. + +**2. `archive/agent-mistake.md` 신설** — 설계 반전(`*-reversed.md`)/기각 +후보(`*-rejected.md`)와 구분되는 세 번째 archive 카테고리: 에이전트가 +문서 작성 중 스스로 낸 개념 혼동을 같은 세션 안에서 정정한 사례 전용 +(`canExecute`/`isHandlable` 혼동, `isSource` 불필요 오판 2건). CLAUDE.md +세션 로그에 전체 경위가 장황하게 남아있던 것 중 최종 결론이 이미 `base/` +문서에 반영돼 중복이던 걸 옮기고 포인터만 남김 — 앞으로도 비슷한 사례가 +생기면 여기로 옮길 것(사용자 확인). + +**3. `CreatedRef` 이름 완전 폐기 — 사용자가 직접 발견.** "Ref가 이미 다 +정해진 것 같은데 `CreatedRef`는 이제 없는 말 아니냐"는 지적: `Source(default)`/ +`Ref(default)`/`Store({defaults})`가 이미 Kotlin Compose식 "타입 이름 +자체가 팩토리 함수" 생성자 스타일로 확정돼 있었는데(2026-08-06 네 번째 +세션), `CreatedRef(fn)`라는 별도 래퍼 이름만 그 확정 이전(2026-08-04, +Ref가 아직 "instance 얻는 통로"로 좁게 정의됐던 시절)의 잔재로 계속 +남아있었던 것 — 실제로는 `Ref(default)`(또는 `PreRef(default)`) +인스턴스 자체를 children 배열 숫자 슬롯에 그대로 놓으면 `(v=Ref)` 매치 +핸들러가 처리하므로, 별도 래퍼 함수가 있을 이유 자체가 없었음. `base/ +bind-system-plan.md`(바인드 방법 절 재작성, "CreatedRef와의 관계" 절 +삭제, "phase 옵션 폐기" 절/열린질문 절 정리) · `ROADMAP.md`(M0/M8 체크 +박스) · `question.md`(용어 재검토 목록에서 제거, 해소로 표시) · +`architecture.md`(소스트리 주석) · `research/documentation-content-map.md` +전부 동기화 완료. `archive/ref-phase-option-reversed.md`(phase 옵션 +자체의 역전 이력)와 CLAUDE.md 이전 세션 로그의 `CreatedRef` 언급은 +당시 기록으로서 정확하므로 그대로 둠 — 역사적 서술과 현재 유효한 설계를 +헷갈리지 않도록 "phase 옵션 폐기" 절 제목에 "이 절이 당시 쓰던 이름 +자체도 이후 폐기됨" 포인터만 추가. + +**부수 작업 — `PreRef`/`Modifier`의 "pre-hook" 태깅 요청 처리.** 같은 +세션 앞부분에서 사용자가 "PreRef와 Modifier는 문서화 시 pre-hook 태그가 +필요해 보인다, hook과 pre-hook의 차이(취소 가능/순서 등록 가능)도 적어 +두면 좋겠다"고 제안 — 이건 런타임 설계가 아니라 문서 사이트 콘텐츠 +분류 아이디어라 base/에 "확정"으로 못박지 않고 `research/ +documentation-content-map.md`(심화 콘텐츠 후보 6번 + "문서화 아직 보류" +목록)에 사용자 원문 프레이밍 그대로 미확정 표시로 남겨둠 — `PreRef`가 +"인스턴스에 뭐가 일어나기 전에 채워진다"는 사실 자체는 이미 확정 +서술돼 있었지만(재확인 후 "메모에서 지워도 됨"으로 답변), "hook"/ +"pre-hook" 용어 채택 여부·`PreRef`의 취소 가능성·복수 `PreRef` 간 순서는 +다음에 사용자가 직접 정해야 base/로 승격 가능. **[정정, 2026-08-07 열 +번째 세션]** 같은 대화에서 "Ref 콜백/대기자 배열은 압축 없이 `self[i] += nil`로만 지워도 된다"는 설계가 이미 정확히 반영돼 있다고 여기 적었던 +건 틀림 — 실제로는 `nil`이 아니라 `None`으로 지워야 함(아래 열 번째 +세션 절 참고), 이때는 아직 발견 전이었음. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). `slot-plan.md` CRUD +의미론과 "hook/pre-hook" 프레이밍 확정만 사용자가 직접 다룰 후보로 남음. + +**같은 세션 후속 — `PreRef` pre-pass 구현 위치·복수 `PreRef` 순서·동적 +경로 가드 확정.** 사용자가 구체적인 구현 방안 세 개를 직접 제시: + +1. **복수 `PreRef` 간 순서는 배열 index 순서 그대로** — 별도 규칙 발명 + 불필요, 위 "props 순회 순서" 절이 이미 확정한 "배열 파트는 index + 순서대로" 계약을 그냥 재사용하면 됨. 호이스팅은 "PreRef 대 나머지" + 에만 적용되는 규칙이지 "PreRef끼리"엔 적용될 게 없음. +2. **pre-pass가 사는 곳 — 새 `Dispatch.*` 함수 대신 이미 확정된 + `Dispatch.drive(inst, flattened)` 자신.** 사용자가 두 대안을 직접 + 제시(`Dispatch.process(inst, flatten, prerefs)`류 신설 함수 vs + `flatten(inst, nonFlatten)` 함수 자체에 얹기) — 검토 결과 둘 다 + 불필요/위험함이 드러남. 전자는 이미 `Handler.process`/`Dispatch.process` + 이름이 다른 뜻으로 확정돼 있어 겹침. 후자(flatten에 얹기)는 사용자가 + "가장 간단해 보인다"고 제안했지만, `research/existing-instance-bind-plan.md`가 + 다루는 "이미 마운트된 Instance 재바인드 시 flatten을 다시 해야 + 하는가"라는 열린 질문이 실제로 flatten이 한 인스턴스 생애주기 동안 + 여러 번 재호출될 가능성을 열어두고 있어서, 거기 PreRef fire를 얹으면 + 재바인드마다 PreRef가 또 fire되어 "이 인스턴스 하나의 construction + 훅"이라는 정의 자체가 깨짐 — 기각. `Dispatch.drive`는 최초 마운트 + 시 한 번만 불리는 게 이미 전제라 이 위험이 없어서 그대로 거기 좁은 + pre-pass 한 줄만 얹으면 충분. +3. **동적 경로로 도착한 `PreRef`는 런타임에도 명시적으로 error — + 지금까지 타입 차단만 문서화돼 있던 빈틈을 채움.** 사용자 제안 + 그대로 채택: `{isHandlable = v is PreRef, process = error(...)}` + 전용 Handler를 정상 우선순위 레지스트리에 등록(`NoneHandler`와 같은 + "한 값 종류 전담" 패턴, 새 메커니즘 아님). 리터럴 배열의 `PreRef`는 + pre-pass가 fire와 동시에 슬롯을 소진시켜 정상 두 패스에 다시 + 노출되지 않으므로, 이 Handler가 실제로 매치되는 경우는 타입 차단을 + 어떻게든 우회한 버그 케이스뿐 — no-op이 아니라 즉시 `error`가 맞음. + +전부 `base/bind-system-plan.md` "PreRef" 절에 반영, `ROADMAP.md` M8 +체크박스 갱신, `research/documentation-content-map.md`의 "복수 PreRef +순서" 미정 표시 제거(해소됨, "취소 가능성"만 계속 미정으로 남김). + +**같은 세션 두 번째 후속 — "호이스팅이 물리적 재배치가 아니라 별도 +선행 스캔"이라는 것과 소진 방식을 명시화(뒤이은 세 번째 후속에서 +`nil`→`None`으로 다시 정정됨, 아래 참고).** 사용자가 "drive에서도 +결국 PreRef를 목록에서 뽑아내야 하는데, 호이스팅 안 되면 PreRef +의미가 사라지는 거 아니냐"고 재질문 — 이전 답변이 `Dispatch.drive`가 +pre-pass를 갖는다고만 하고 정확한 알고리즘을 안 써서 나온 질문. +`Dispatch.drive`가 같은 `flattened` 테이블을 **두 번** 순회한다는 +것으로 답변: (1) pre-pass가 배열 전체를 index 순으로 훑어 `PreRef`를 +fire하며 그 자리에서 슬롯을 소진, (2) 그 다음 평소 두 패스가 같은 +테이블을 다시 순회하되 소진된 슬롯은 자연히 건너뜀. "호이스팅"은 +PreRef를 배열 앞으로 물리적으로 옮기는 게 아니라 "PreRef 전용 선행 +루프가 통째로 먼저 끝난 뒤에야 나머지가 시작된다"는 뜻이라 소스 위치와 +무관하게 항상 먼저 fire됨. **소진이 최적화가 아니라 정확성 요건인 이유도 +명시**: 안 지우면 두 번째 패스가 이미 처리된 PreRef를 `Dispatch.process`로 +다시 넘겨서, 바로 위에서 신설한 "동적 경로 가드" Handler(`(v=PreRef)`→ +`error`)가 정상 사용에도 오탐 에러를 던지게 됨. + +## 2026-08-07 열 번째 세션 — 소진 슬롯을 `nil`이 아니라 `None`으로, +사용자가 Luau REPL 반례로 직접 발견 + +같은 날 이어진 세션. 사용자가 Luau REPL에서 직접 +`for i,v in {[1]=1,[2222]=2222,[211]=211,[131]=131,[3]=3,[6]=6, +[122]=122,[11]=11,[312]=312,[821]=821,[991]=991} do print(i,v) end`을 +돌려 순회 순서가 `1, 6, 122, 11, 991, 2222, 131, 312, 3, 821, 211`로 +나온다는 걸 보여줌 — index 오름차순이 전혀 아님. 이건 위 아홉 번째 +세션에서 "PreRef pre-pass가 fire된 슬롯을 `nil`로 지우면 된다"고 적은 +것과 여섯 번째 세션에서 "Ref 콜백/대기자 배열도 `[i]=nil`로 소진하면 +된다"고 적었던 것 둘 다를 뒤집는 반례 — 키가 촘촘한 저범위 정수에서 +벗어나면(구멍이든 원래 듬성듬성이든) Luau/Lua 테이블이 해시 파트 +취급으로 넘어가 순회가 해시 버킷 순서가 됨. + +**해결 — 소진에 `nil` 대신 `None` 센티널 사용, 전 코퍼스에 전파.** +`None`은 `nil`이 아닌 실재하는 값이라 그 슬롯을 "차 있다"로 유지시켜서 +테이블이 "구멍 없는 시퀀스"라는 불변식이 안 깨짐 — 두 가지를 동시에 +해결: (1) 순서가 실제로 중요한 배열(PreRef pre-pass)의 순서 보장 유지, +(2) `table.insert`가 내부적으로 쓰는 `#t`가 Lua 명세상 구멍 있는 +테이블에서 정의되지 않은 동작이라는 문제(Ref 콜백/대기자 배열이 새 +등록 때 `table.insert`를 씀 — 순서 자체는 원래도 안 중요했지만 이 +`#t` 안전성 문제는 진짜 버그였음). **배열 파트의 `None`은 해시 파트의 +`None`(Modifier 필드 명시적 지우기, `NoneHandler` 경유)과 의미가 +다름** — 배열 파트 `None`은 처리할 핸들러가 없는 순수 빈 슬롯 표시라 +`Dispatch.process`/`NoneHandler`를 안 거치고 두 패스 루프 자신이 직접 +`if v == None then continue end`로 스킵. + +`base/bind-system-plan.md`의 "왜 `nil`이 아니라 `None`인가"(Ref +콜백/대기자 절)와 PreRef pre-pass 절에 반영, `ROADMAP.md` M0/M8 +체크박스 갱신, 위 아홉/여섯 번째 세션 문단에 정정 표시 추가(원문은 +유지, 틀렸던 부분만 짧게 정정 포인터). + +**부수 발견 — `props.Modifier`/`props.Ref` nil-hole 위험도가 이전 +서술보다 큼.** `pre-implementation-audit.md` 1-5가 이미 이 위험을 +"뒤 항목까지 무시될 수 있음"으로 국소적 피해처럼 서술해뒀는데, 이번 +REPL 실측으로 실제로는 구멍이 하나만 생겨도 **그 테이블 전체**가 순서 +보장을 잃을 수 있다는 게 드러남 — M0 스파이크에서 반드시 실측하고, +심각하면 "raw 리터럴 대신 `props.Modifier or Modifier()`로 non-nil +보장" 컨벤션 문서화까지 검토하기로 `ROADMAP.md` M0에 메모 추가. 이 +케이스는 caller가 직접 쓰는 raw Lua 리터럴이라 `None`으로 프레임워크가 +대신 채워줄 수 없어서 별도 해법이 필요함 — `None` 소진 전략과 혼동하지 +말 것. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터). M0 착수 시 위 +nil-hole 위험도 실측이 우선순위 높아짐. + +**같은 세션 세 번째 후속 — `props.Modifier`/`props.Ref` nil-hole +해법을 실제로 확정, 세션 clear 전 문서 완결성 점검하며 발견한 갭 +3개도 같이 보강.** 사용자가 "컴포넌트에서는 꼭 `or None`이나 +`Modifier()` 같은 걸로 nil 못하게 강제하는 걸 문서화하자"고 요청, 그 +자리에서 결정하고 clear 전 세션 전체를 다시 훑어 새로 알게 됐지만 +아직 문서에 없던 것들을 마저 채움: + +1. **`props.Modifier or None`/`props.Ref or None`을 필수 관용구로 + 확정** — `Modifier()`(빈 modifier 새로 생성)가 아니라 `None`을 쓰는 + 이유는 이미 있는 array-part `None`-스킵 메커니즘(PreRef 논의에서 + 확정)을 그대로 재사용해 새 코드/할당이 하나도 안 늘어나기 때문 — + `flatten`이 `isModifier(None) == false`라 그냥 통과시키고, 이어지는 + 두 패스 루프가 `None`을 만나면 스킵. `base/component-composition-plan.md` + "필수 관용구" 절 신설, `ROADMAP.md` M0/`pre-implementation-audit.md` + 1-5/`question.md`에 반영(1-5는 해소로 표시). +2. **`Modifier()` 바닥 생성자가 문서 어디에도 없었던 갭 발견·보강** — + `Source(default)`/`Ref(default)`/`Store({defaults})`와 나란히 있어야 + 할 "`Type(args)` 팩토리" 4번째 예시가 원래 없었음(이전 아홉 번째 + 세션에 `Modifier.Rounded(8)` stale 참조를 고치면서 실수로 체이닝 + 예시인 `mod:UICorner(8)`로 잘못 채워 넣었던 것도 같이 바로잡음). + `modifier-plan.md` 3번 절에 명시, `store-semantics.md` 예시 목록 + 정정, `ROADMAP.md` M7 체크박스 추가. +3. **`Brand` 태그 목록에 `RefTag`/`PreRefTag`/`ModifierTag`가 빠져있던 + 갭 발견·보강** — 이번 세션 내내 `isPreRef(v)`/`isModifier(v)`를 + 이미 존재하는 predicate처럼 써왔는데 정작 여덟 번째 세션의 `Brand` + 태그 목록엔 없었음. 추가하면서 **`isRef`/`isPreRef`가 `isState`와 + 달리 집합 멤버십이 아니라 단순 항등이라는 것도 명시** — + `isRef(preRefInstance)`가 참이면 일반 `(v=Ref)` 핸들러가 `PreRef`도 + 집어삼켜 PreRef 전용 pre-pass/가드 Handler 설계 전체가 무너지므로 + 반드시 배타적이어야 함. `bind-system-plan.md`의 `Brand` 절, + `ROADMAP.md` M2 체크박스에 반영. +4. **배열 파트 `None`과 해시 파트 `None`(`NoneHandler`)이 같은 센티널인데 + 처리 경로가 다르다는 걸 `None` 센티널 절 자체에 명시적으로 + 교차 참조 추가** — 이전엔 PreRef 절에만 있고 `None` 센티널 원래 + 정의 절엔 이 예외가 안 적혀 있어서, 그 절만 읽으면 모든 `None`이 + `NoneHandler`를 탄다고 오해할 수 있었음. + +전부 커밋 `98bd46a` 이후 아직 커밋 안 된 이번 대화 전체 변경사항에 +포함 — 다음 세션이 새로 알아야 할 건 없음, `ROADMAP.md` M0부터 그대로 +시작. diff --git a/ROADMAP.md b/ROADMAP.md index 7ad5505..5339a96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,15 +28,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) - [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + - 일반 `Ref`/`CreatedRef`의 위치 기반 순서까지 최소 스파이크로 검증 + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 - 폐기 → 위치로 표현, `PreRef` 신설" 절) + 폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은 + `nil`이 아니라 `None`으로(2026-08-07 열 번째 세션 정정, 사용자가 + Luau REPL로 반례 제시 — 키가 듬성듬성해지면 순회가 index 순서를 + 전혀 안 지킴), 이 경로는 nil-hole 위험이 아예 없도록 설계됐으므로 + "구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜 + `nil`이 아니라 `None`인가" 참고)** - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, `export type Params = {...}`로 타입 체크되는지 확인 - (`component-composition-plan.md` 최종 결론 1번) — **caller가 Modifier/Ref를 - 안 넘기는 케이스(Lua 배열 리터럴의 nil-hole 함정, `{nil, ref, child}`처럼 - 뒤 항목이 무시될 수 있는 경우)를 반드시 케이스에 포함** - (`research/pre-implementation-audit.md` 1-5) + (`component-composition-plan.md` 최종 결론 1번) — **`props.Modifier or + None`/`props.Ref or None` 관용구(2026-08-07 열 번째 세션 확정, + `component-composition-plan.md` "필수 관용구" 절)로 nil-hole을 막는 + 케이스를 반드시 포함할 것 — caller가 Modifier/Ref를 안 넘겨도 + `or None`이 항상 non-nil을 보장하므로 `{nil, ref, child}`류 리터럴 + 구멍 자체가 안 생김(`research/pre-implementation-audit.md` 1-5). + M0에서 검증할 것은 "어떻게 막을지"가 아니라 이 관용구가 실제로 + 타입 체크/런타임 양쪽에서 문제없이 동작하는지** - [ ] 위 과정에서 소스 트리/메커니즘 문서에 고칠 부분이 생기면 그 자리에서 `.claude/base/` 갱신 @@ -71,10 +80,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 여덟 번째 세션 정정) - [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ - `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot` - 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None` 항등 - 비교 — `bind-system-plan.md`의 `Brand` 절, 2026-08-07 여덟 번째 - 세션 신설) + `isAttribute`/`isTween`/`isBlocker`/`isSource`/`isStore`/`isSlot`/ + `isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째 세션 추가 — 원래 + 태그 목록에서 빠져있었음, `isRef`/`isPreRef`는 단순 항등이지 + `isState`처럼 집합 멤버십 아님) 전부의 기반. `isNone`만 예외로 + 레지스트리 없이 `x == None` 항등 비교 — `bind-system-plan.md`의 + `Brand` 절, 2026-08-07 여덟 번째 세션 신설) - [ ] `LifetimeHandle.luau`/`PerInstanceState.luau` **인터페이스만**(타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 @@ -136,7 +147,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M7 — Modifier -- [ ] flatten-before-dispatch, immutable `table.clone` 체이닝 +- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션 + 명시 — `Source(default)`/`Ref(default)`/`Store({defaults})`와 같은 + `Type(args)` 팩토리 관습, `modifier-plan.md` 3번) +- [ ] flatten-before-dispatch(`isModifier(v)`로 배열 항목 중 Modifier만 + 판별해 필드 merge, 나머지는 안 건드리고 통과 — 2026-08-07 열 번째 + 세션 명시, `modifier-plan.md` 1번), immutable `table.clone` 체이닝 - [ ] `Modifier.Override(mod1, mod2, ...)`(가칭, 구 `Merge`) — 필드별 raw 덮어쓰기, 특별한 State/함수 분기 불필요(`modifier-plan.md` 9번) - [ ] `Override`가 서브타입 관계인 서로 다른 Modifier 타입(예: `FrameModifier`/ @@ -164,10 +180,27 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 위치 무관 호이스팅 pre-pass — `base/bind-system-plan.md` "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절 + "API 모양" 절) -- [ ] `CreatedRef` 메커니즘(숫자 슬롯 참가자) +- [ ] `(v=Ref)` 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 + `Ref(default)` 인스턴스를 인식해 바인드(별도 `CreatedRef` 래퍼 + 없음 — 이름 자체가 폐기됨, 아래 참고) +- [ ] `PreRef` pre-pass — 새 `Dispatch.*` 함수 없이 `Dispatch.drive(inst, + flattened)` 자신이 두 패스(배열→해시) 루프 전에 배열 파트를 훑어 + `PreRef` 항목만 fire(Dispatch.process/getHandler 우회하는 raw 루프, + `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten 재호출 가능성과 + 충돌하므로 기각). 복수 `PreRef`는 배열 index 순서 그대로(별도 규칙 + 없음). fire된 슬롯은 그 자리에서 소진(`None` 처리, `nil` 아님 — + 2026-08-07 열 번째 세션 정정)해 이어지는 정상 두 패스에 다시 노출 + 안 되게 함 — `base/bind-system-plan.md` "PreRef" 절 +- [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef, + process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록, + `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 + pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 + 우회한 버그라는 뜻 — 같은 절 참고 - [ ] Ref 콜백/대기자 실행 루프(`type(v)=="thread"`면 - `coroutine.resume(v, self)`+소진, 함수면 `v(value)` 호출+유지 — - 같은 배열 하나로 통합). `:Wait(thread?)`는 `thread`가 `nil`이면 + `coroutine.resume(v, self)`+`None`으로 소진(`nil` 아님 — + 2026-08-07 열 번째 세션 정정, `#t`/`table.insert` 안전성), 함수면 + `v(value)` 호출+유지 — 같은 배열 하나로 통합). `:Wait(thread?)`는 + `thread`가 `nil`이면 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) - [ ] `LifetimeHandle` quad-roblox 실제 구현(Instance 생존 확인, 인터페이스