diff --git a/.claude/README.md b/.claude/README.md index 4903ac3..b2eec46 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -26,7 +26,7 @@ | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | | `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | -| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | +| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-19 기준] 폴더 자체가 아직 없음**(M0/M1 스캐폴딩만으론 안 생기고 실제로 렌더링해보고 쓰는 단계부터, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | | `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함) | | `tools/` | **[2026-08-13 신설, `session/2026-08-13-09-structure-and-guardrails.md`]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **[2026-08-16]** 절 참조는 WARN이 아니라 **ERROR** — 판정 규칙은 `conventions.md`의 "절 인용 규약"이 소스. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | @@ -43,14 +43,16 @@ | 문서 | 내용 | |---|---| -| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | -| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` | +| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` | +| `project-setup-plan.md` | **[2026-08-19 신설, 같은 날 두 차례 후속 갱신]** M0/M1 스캐폴딩을 실제로 pesde/`luau`/Rojo/`selene` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `mise.toml` 툴체인 핀(`rokit.toml`에서 전환, 실제 설치·attestation 검증까지 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string은 이를 못 따라가지만 Rojo/Studio 배포 경로는 무관함을 실측 확인, `selene`의 CWD 상대 config 탐색 함정, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 | +| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), §8에 **새 타입·API 설계 시 체크리스트**. **[2026-08-19 신설]** §6 — `type function`을 거친 값(패스스루라도)은 이후 제네릭 self 메소드 체이닝(`AddPlugin`류)이 조용히 깨짐, `quad-types-plan.md`의 `CheckedQuad` 배선 중 실측 발견·회피(원본은 type function을 절대 안 거치게 하고 검사 결과는 별도 필드로 격리). 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` + `luau-test/23` | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | | `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | -| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 Init 가드(순서 의존성 해소) | +| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설, 같은 날 후속 정정]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `module:RunInit(initFn)`(함수 자체를 릴레이션 키로 쓰는 공유 멱등 가드, 파일마다 따로 두던 센티널 폐기). 실제로 `quad-base/src/init.luau`에 구현·검증됨. `RunInit`은 backend 설치엔 재사용 안 함 — `_initializedBy` 문자열 마커(같은 팩토리=no-op, 다른 팩토리=에러)를 별도로 유지하는 걸로 확정(2026-08-19 해소) | +| `quad-types-plan.md` | **[2026-08-19 신설, 같은 날 후속으로 확장]** 워크스페이스 세 번째 멤버 `quad-types` — 구현 없는 `Quad` 타입 계약 + `AddPlugin`(실측 검증된 제네릭 self 플러그인 체이닝) + `CheckedQuad`(런타임 주입 때문에 pesde semver 보호가 안 걸리는 자리를 메꾸는 컴파일 타임 버전 체크, 글롭/캐럿 패턴 지원). 버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 `type-version-check`(워크스페이스 네 번째 멤버, 사용자가 나중에 독립 저장소로 분리 예정 — `HUMAN_TODO.md` 9번)로 분리됐고 `CheckedQuad`가 그 위에 얹힘. `type function`이 `T`를 패스스루만 해도 이후 제네릭 self 메소드 체이닝이 조용히 깨진다는 새 Luau 함정을 발견·회피(별도 가상 필드로 격리), `export type function`/이중 꺾쇠 제네릭 인스턴스화 등 cross-package 사용 함정도 정리. quad-roblox가 quad-base 대신 이 가벼운 패키지만 의존 — dev-dependency로 두면 게시 후 소비자 환경에서 타입-전용 require가 런타임 크래시하는 문제를 원천 회피 | | `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화. **[2026-08-19]** 가칭 `PopOnly`를 `Detach`로 리네임 확정(`Extract`의 명령형 추출과 동사가 겹치지 않으면서 "관리 주체는 reconcile"이라는 뜻을 살림) — 공개 표면 위치도 `None` sentinel 선례를 따라 패키지 최상위 export로 같이 확정(`Slot`이 함수라 `Slot.Detach` 형태로 못 붙임), 정의 파일 배치는 M6 구현 시점 확정 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index ce29eeb..ae48a33 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -177,17 +177,58 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제 폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션. -**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립 -wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의 -타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0` -에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/ -RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체 -`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc`의 -`aliases`는 **런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가 -지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기 -자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로 -쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만 -유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수. +**패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지 +매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의 +타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립 +패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정 +(2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다 +툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다 +독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을 +pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트 +`workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }` +의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제 +자체도 이 전환으로 같이 해소됨 — **[2026-08-19 같은 날 후속 세션]** +pesde/rojo 바이너리를 이 샌드박스에 직접 설치해 `pesde install`/`rojo +build`까지 실제로 돌려 링크 결과를 확인 완료(`base/project-setup-plan.md`가 +소스, 워크스페이스 의존성이 symlink로 연결되고 Rojo는 이를 투명하게 +따라감). +실제 구현: 루트 `pesde.toml`(`private = true`, +`workspace_members = ["quad-base", "quad-roblox", "quad-types", +"type-version-check"]`) + +`quad-base/pesde.toml`/`quad-roblox/pesde.toml`/`quad-types/pesde.toml` +(각각 `[target] environment = "roblox"`) + `type-version-check/pesde.toml` +(`[target] environment = "luau"`, 아래 참고), 툴체인은 `mise.toml`로 핀 +(`rokit.toml`에서 전환, 2026-08-19 사용자 결정 — 더 범용적인 도구라는 +판단, `base/project-setup-plan.md`의 "툴체인" 절 참고). **[2026-08-19 같은 +날 후속]** `type-version-check`(`[target] environment = "luau"` — quad에 +종속되지 않은 범용 패키지라 다른 멤버와 달리 roblox가 아님)는 워크스페이스 +네 번째 멤버로 추가됐고, `quad-types`가 이것에 workspace 의존(자기 target이 +roblox라 명시적으로 `target = "luau"` 지정 필요) — `base/quad-types-plan.md`의 +"`type-version-check`" 절이 소스. 사용자가 나중에 독립 저장소로 분리할 +예정(`HUMAN_TODO.md` 9번). +**[2026-08-19 같은 날 셋째 후속 세션]** `quad-roblox`는 `quad-base`가 +아니라 **`quad-types`(구현 없는 타입 계약 전용 패키지)에만 workspace +의존** — `quad-base`는 `QuadRoblox(Quad): QuadRoblox` 패턴으로 **런타임 +주입**받으므로 pesde 의존 선언이 필요 없고, 오히려 무거운 quad-base +전체를 dev-dependency로 두면 게시 후 소비자 환경에서 그 타입 전용 +require가 크래시하는 문제가 있어 별도 패키지로 뽑음 — +`base/quad-types-plan.md`가 소스. +pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그 +파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`은 +이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스 +루트 자신의 통합 개발/테스트용, 게시되는 패키지 안이 아니므로). +`.luaurc`의 `aliases`는 여전히 **런타임 require에서 엔진이 지원 안 함** +(2026-08-19 이 세션에 커스텀 alias로 직접 재확인 — `require("@alias/x")`가 +"could not jump to alias"로 실패) — 그래서 alias는 편집기 자동완성/타입체크용으로만 +곁들이고, 실제 크로스패키지 require는 상대경로로 쓴다(단, `init.luau` +안에서는 평범한 상대경로가 아니라 **예약 alias `@self`**를 써야 함 — +`init.luau`는 require-by-string 상 "자기 폴더 자체"를 가리키므로 `./`가 +아니라 `@self/`로 그 폴더 안의 형제 파일에 접근한다, 2026-08-19 사용자 지적 ++ Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인 — 실제로 이 +세션의 `quad-base/src/init.luau`가 이 착오로 크로스파일 require가 전부 +깨졌다가 `@self`로 고치고 나서야 정상화됨). 나중에 실제로 레포를 쪼갤 때는 +Rojo `project.json`의 트리 매핑 규칙만 유지하면 되고, require는 그 시점에 +한 번 기계적으로 바꾸는 정도로 감수. **패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도 재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라 @@ -206,10 +247,18 @@ op" 절. ``` quad/ -├── .luaurc # @quad-base, @quad-roblox alias (편집기 경험용, 런타임 비의존) +├── .luaurc # 편집기 경험용 alias(런타임 비의존, `base/project-setup-plan.md` 참고) +├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀 +├── pesde.toml # 워크스페이스 루트(private, workspace_members) ├── default.project.json # 루트 통합 개발/테스트용 Rojo 프로젝트 +├── quad-types/ # 구현 없는 Quad 타입 계약 + CheckedQuad 버전체크(`base/quad-types-plan.md`) +│ ├── pesde.toml # type_version_check workspace 의존(target="luau") +│ └── src/init.luau +├── type-version-check/ # quad에 종속되지 않은 범용 버전 패턴 매칭(`base/quad-types-plan.md` "`type-version-check`" 절) — 사용자가 나중에 독립 저장소로 분리 예정(HUMAN_TODO 9번) +│ ├── pesde.toml # [target] environment = "luau" +│ └── src/init.luau # matchesPattern(런타임) + export type function CheckVersion ├── quad-base/ -│ ├── wally.toml +│ ├── pesde.toml │ └── src/ │ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션) │ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속 @@ -239,7 +288,7 @@ quad/ │ ├── LifecycleHooks.luau # OnCreated/OnRendered/OnDestroyed — PreRef/PostRef/Effect를 반환하는 순수 팩토리 슈가(`base/lifecycle-hooks-plan.md`), 새 타입/Dispatch 개념 없음 │ └── init.luau └── quad-roblox/ - ├── wally.toml + ├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존 └── src/ ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 4244f8d..dea2ac6 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -85,67 +85,105 @@ export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제 **순서 의존성은 각 `InitXxx`를 `require`처럼 멱등하게 만들어서 해소한다** (2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`가 -직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저 -**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`을 -weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미 -됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require` -캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는 -한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을 -여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프 -캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다. +직접 관리할 필요가 없다. + +**[2026-08-19 같은 날 후속 정정] 멱등 가드는 `RunInit` 하나로 통합됨 — +파일마다 `Relate()`/`INITED` 센티널을 따로 두지 않는다.** 원래는 각 +`InitXxx` 파일이 자기 톱레벨에 `local relate = Relate()`를 두고 파일 +전용 센티널 키(`INITED`)로 "이 `module`에 이미 Init됐는가"를 기록하는 +보일러플레이트를 파일마다 반복했는데, **사용자 지적**: 그 반복 자체가 +불필요하다 — **함수 자기 자신을 릴레이션 키로 쓰면** 센티널이 따로 +필요 없다(`Relateany, boolean>`이 곧 "이 함수, 이 모듈에 +실행했는가" 표가 됨). `module` 인스턴스마다 공유하는 `RunInit` 메서드 +하나가 이 판단을 전담하고, 개별 `InitXxx` 파일은 **가드 없이 그냥 +뮤테이션만** 하면 된다: ```lua --- Dispatch/init.luau +-- 최상위 init.luau local Relate = require(...) -local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스 +local runInitRelate = Relate() -- (module, initFn) -> 이미 실행됐는가, 파일 스코프 공유 -local function Init(module) - if relate:GetStrong(module, INITED) then - return -- 이미 이 module 인스턴스엔 Init됨, no-op +local function New(): Quad + local module = { New = New } :: Quad + + function module.RunInit(self, initFn) + if runInitRelate:GetStrong(self, initFn) then + return -- 이 module 인스턴스에 이 initFn은 이미 실행됨, no-op + end + runInitRelate:SetStrong(self, initFn, true) -- 실행 전에 먼저 표시(순환 의존 대비) + initFn(self) end - relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고) - -- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음 - -- 예: InitLifetime(module) - local ... - module.Dispatch = ... + + module:RunInit(InitDebug) + -- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 추가 + return module +end +``` + +```lua +-- Debug/init.luau — 가드 없이 그냥 뮤테이션만(RunInit이 이미 1회만 보장) +local function Init(module) + module.debug = false end return Init ``` -이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`require`가 -의존 그래프를 알아서 풀어주는 것과 같은 감각), 최상위 `New()`는 순서를 -신경 쓰지 않고 아는 `InitXxx(module)`을 전부 호출해도 된다 — 이미 누가 -먼저 채웠으면 알아서 스킵된다. - -- **GC와도 자연히 맞물림**: `relate-plan.md`의 "API" 절에 따르면 `Relate`의 - **첫 인자(`inst`, 여기선 `module`)는 항상 weak**다(선택의 여지가 없는 - 고정 동작 — `Weak`/`Strong` 구분은 오직 `value` 쪽 보관 방식만 가리킴). - 그래서 `value` 쪽을 `SetWeak`으로 두든 `SetStrong`으로 두든 상관없이, - 어떤 `Quad` 인스턴스(전체 `module` 테이블)가 더 이상 참조되지 않아 - 수거되면 이 Init-완료 기록도 같이 사라진다 — 별도 정리 로직 불필요. - `relate-plan.md`가 이미 확정해둔 "각 모듈이 자기 톱레벨에 `Relate()` - 하나를 두고 재사용" 관례를 그대로 쓰는 것이라 새 메커니즘 아님. - (`relate-plan.md`가 별도로 명시한 "명시적으로 만든 기록은 명시적으로 - 지울 것" 원칙과 충돌하는 게 아니라 — 이 경우는 기록의 키 자체가 죽으면 - 그 기록을 다시 조회할 주체 자체가 사라지므로 지울 대상이 없어지는, - 원칙이 애초에 상정하지 않은 자리다.) -- **`value`는 `SetStrong`으로 통일**: 위 문단대로 GC 결과엔 차이가 없지만 - (boolean 리터럴은 애초에 GC 대상이 아님), `relate-plan.md`의 일반 규칙 - "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 기준으로는 이 `true` - 플래그를 다른 어디도 붙잡고 있지 않으므로 `SetStrong`이 그 규칙에 맞는 - 선택이다. +- **의존성을 갖는 `InitXxx`도 패턴이 그대로 유지된다** — 자기 의존성을 + `module:RunInit(InitLifetime)`처럼 부르기만 하면 되고, `RunInit` 자체가 + 멱등하므로 여러 `InitXxx`가 같은 의존성을 부르는 순서/중복은 걱정할 + 필요 없다(옛 설계와 결론은 같음, 가드 소유 위치만 파일별→공유로 이동). +- **GC와도 자연히 맞물림** — `relate-plan.md`의 "API" 절에 따르면 + `Relate`의 **첫 인자(`inst`, 여기선 `module`)는 항상 weak**이므로, + `Quad` 인스턴스가 더 이상 참조되지 않아 수거되면 이 Init-완료 기록도 + 같이 사라진다(별도 정리 로직 불필요, `value`는 `SetStrong` — boolean + 리터럴이라 GC 결과엔 무관하지만 "다른 곳에서 안전하게 유지되는 것만 + `SetWeak`" 일반 규칙에 맞음). - **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제 - 정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend - 팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개 - 계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base - 내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현 - 디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지 - 않게 구분해서 쓸 것. + 정의는 `base/bind-system-plan.md`)와는 여전히 다른 층위** — 그건 + backend 팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 + 하는 공개 계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, `RunInit`은 + quad-base 내부 서브시스템이 **한 번만** 도는지만 보면 되는 사적 구현 + 디테일이라 "다른 호출자면 에러" 분기 자체가 없다. + **[2026-08-19 해소, 사용자 결정] `RunInit`은 backend 설치에 재사용 + 안 함 — `_initializedBy` 마커를 그대로 별도로 둔다.** 근거는 위에서 + 이미 짚은 그대로: `RunInit`은 "이 함수가 이미 돌았는가"만 답하는 + 함수-identity 추적이라 "이 *슬롯*을 다른 함수가 이미 채웠는가"(다른 + 팩토리 재호출 = 에러)를 표현 못 함 — 억지로 슬롯 키를 얹어 확장하면 + "멱등 실행"과 "유일 슬롯 점유"라는 서로 다른 두 의미가 API 하나에 + 섞여 `RunInit`의 단순함이 깨짐. `_initializedBy`는 `bind-system-plan.md` + 3차 라운드가 이미 확정해둔 그대로 문자열 마커 하나로 남김: + + ```lua + -- 예시(quad-roblox, M5 실제 구현 시) + local function InitRoblox(module) + if module._initializedBy == "roblox" then + return module -- 같은 팩토리 재호출 = no-op + end + if module._initializedBy ~= nil then + error(`Quad module already initialized by '{module._initializedBy}'`) + end + module._initializedBy = "roblox" + -- ... 실제 백엔드 설치(bindLifetime/canBound/addTag/removeTag/setAttribute 등 주입) + return module + end + ``` + + `RunInit`(quad-base 내부 서브시스템, 함수 identity 추적)과 + `_initializedBy`(backend 유일 슬롯, 문자열 마커 + 다른 값이면 에러)는 + 계속 **서로 다른 메커니즘**으로 남는다 — 이름이 겹치지 않게 쓸 것. + 실제 `RobloxFactory`/`InitRoblox` 구현은 M5(`architecture.md` 소스 + 트리의 `quad-roblox/src/RobloxFactory.luau`)에서. - **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `InitA`↔`InitB`처럼 상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저 표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시 미완성 exports를 돌려주는 것과 같은 이유로, 실제 작업 시작 전에 먼저 "완료"로 표시해둔다. +- **실측**: `quad-base/src/init.luau`+`Debug/init.luau`가 위 코드 + 그대로 구현돼 있고, `quad-base/test/smoke.init.luau`가 (a) 같은 + `initFn`을 여러 번 `RunInit`해도 1회만 실행, (b) `New()`로 만든 + 서로 다른 인스턴스는 기록을 공유하지 않음(각자 독립 1회), (c) 서로 + 다른 `initFn`은 서로의 실행 여부에 영향 안 줌 — 셋 다 `luau`/ + `luau-analyze`/`selene` 클린으로 확인. ## Bind는 누가, 어떻게 구현하는가 diff --git a/.claude/base/project-setup-plan.md b/.claude/base/project-setup-plan.md new file mode 100644 index 0000000..5c733ec --- /dev/null +++ b/.claude/base/project-setup-plan.md @@ -0,0 +1,373 @@ +# 프로젝트 셋업 — pesde 워크스페이스, `.luaurc`, require 구조 + +**상태**: base — `architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절이 +정한 소스 트리를 **실제로 pesde/luau CLI로 셋업해보고 검증한 결과**. 그 +절은 "무엇을 어디에 두는가"까지만 다루고 "그걸 실제로 어떻게 굴리는가"는 +비워뒀는데, 이 문서가 그 나머지 — 패키지 매니저 조작, require 문법, +현재 환경에서 확인된 한계까지. **[2026-08-19 신설, 같은 날 pesde 실제 +설치·`pesde install` 실행으로 검증]** + +**전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지 +M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격 +`New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음 +(`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde +규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만, +"무엇이 구현됐는가"는 이 문서가 아니라 `todos.md`/`ROADMAP.md`를 볼 것. + +## 왜 wally가 아니라 pesde인가 + +**사용자 결정(2026-08-19)**: dev-dependency를 1급으로 지원하는 등 wally보다 +툴링이 낫다는 판단. 상세 배경/재검토는 `architecture.md`의 "구현 착수: +소스 트리 구조 확정" 절 "패키징 방식" 문단이 소스 — 여기서 반복하지 +않음. 요지만: 모노레포 모양(루트 통합 개발, 서브패키지마다 독립 게시) +자체는 안 바뀌고, pesde의 네이티브 workspace 기능이 그 모양에 그대로 +들어맞는다. + +## pesde 워크스페이스 구조 + +**전체 멤버 목록·최신 트리는 `architecture.md`의 "구현 착수: 소스 트리 +구조 확정" 절이 소스** — 새 워크스페이스 멤버가 늘 때마다 그쪽만 갱신하면 +되게 하기 위해 여기서 다시 나열/개수 세지 않는다(멤버 수가 실제로 2→3→4로 +늘어난 이력에서 이 문서의 구식 트리가 갱신을 놓쳤던 게 계기). 아래는 그 +구조가 실제로 동작하는 **pesde 메커니즘 자체**(문법/함정)만 다룬다 — +예시엔 최소 2-멤버 형태만 남겨두고 서술 부담을 줄임: + +``` +quad/ +├── pesde.toml # 워크스페이스 루트, private = true, workspace_members +├── mise.toml # pesde/rojo/luau-lsp/selene 버전 핀 +├── quad-base/ +│ ├── pesde.toml # name = "qwreey/quad_base" +│ └── selene.toml +└── quad-roblox/ + ├── pesde.toml # name = "qwreey/quad_roblox", quad_base에 workspace 의존 + └── selene.toml +``` + +- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members` + (멤버 목록은 `architecture.md`가 소스). `[target] environment = "roblox"`도 + 필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이 + 세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후 + 재확인 후보). +- **서브패키지 `pesde.toml`**: `[target] environment = "roblox"` + + `build_files = ["src"]` + `lib = "src/init.luau"`. `quad-roblox`는 + `[dependencies] quad_base = { workspace = "qwreey/quad_base", version = + "^" }`. +- **⚠️ 패키지 이름은 `a-z`/`0-9`/`_`만 허용 — 하이픈 금지**(`pesde + install` 실측: `qwreey/quad-base`는 파싱 단계에서 바로 거부됨, 에러 + 메시지가 "did not match any variant of untagged enum + DependencySpecifiers"로 나와서 원인 파악에 혼동을 줌 — 실제 원인은 이름 + 문자 제약이지 의존성 선언 문법이 아니었음). **`quad_base`/`quad_roblox`로 + 확정** — 폴더 이름(`quad-base`/`quad-roblox`)은 `architecture.md`가 + 이미 확정해둔 것이라 그대로 두고, `pesde.toml`의 `name` 필드만 언더스코어로 + 다르게 쓴다. 둘이 다르다는 걸 헷갈리지 말 것. +- **`pesde add`는 워크스페이스 멤버를 자동으로 못 찾는다** — 레지스트리 + 검색 전용 커맨드라 로컬 워크스페이스 형제를 이름으로 넘기면 + "package not found"로 실패한다. `[dependencies]`의 `workspace = "scope/name"` + 줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`이 + 받아들이는 걸 확인함). +- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 **모든 + 워크스페이스 멤버**가 스캔·링크됨(개수는 `architecture.md`의 + `workspace_members`가 소스 — 새 멤버가 늘어도 이 동작은 안 바뀜). +- **[2026-08-19 후속, `type-version-check` 신설 때 실측] 의존하는 + 워크스페이스 멤버의 `target`이 자기 자신의 기본 target과 다르면 + `workspace = "..."` 의존 선언에 `target = "..."`를 명시해야 한다.** + `quad-types`(기본 target `roblox`)가 `type-version-check`(자기 + `[target] environment = "luau"`)에 의존할 때, `target` 없이 `{ + workspace = "qwreey/type_version_check", version = "^" }`만 쓰면 + `pesde install`이 `no workspace member found with name + qwreey/type_version_check and target roblox`로 실패한다 — `target = + "luau"`를 추가해야 해소됨. + +## 툴체인 — `rokit.toml`에서 `mise.toml`로 전환 (2026-08-19) + +원래는 `initreq/vide`(참고 레포)의 `rokit.toml` 선례를 따랐음(같은 날 +`pesde`/`rojo`/`luau-lsp` 셋 다 `/code/.local/bin`에 직접 다운로드해 +설치·핀과 버전 일치까지 검증). **같은 날 후속 세션에 `mise.toml`로 +재전환** — 사용자 결정("요즘은 rokit 보단 mise로 까는듯 하네. 더 +범용적이라 이걸 택하는듯"), 근거는 +`Word30210/roblox-project-example`(`initreq/roblox-project-example`로 +클론해 확인)의 `mise.toml`. `rokit`은 이 샌드박스에 아예 없어 한 번도 +직접 검증 못 했던 반면, **`mise`는 이 샌드박스 자체가 이미 `luau` 설치에 +쓰고 있어서 `mise install`을 그 자리에서 실행해 진짜로 검증**함 — +`pesde`/`rojo`/`luau-lsp`/`selene` 넷 다 GitHub artifact attestation + +SLSA provenance 검증까지 거쳐 설치됨(이전 세션이 `curl`로 직접 받던 +방식보다 공급망 신뢰도가 높음), `mise exec -- --version`으로 +버전 일치까지 확인. `mise.toml`의 tool 키는 백엔드 접두사가 붙는다 +(`github:owner/repo` / `aqua:owner/repo`) — `rokit.toml`의 `owner/repo@ver` +평문 표기와 형태가 다르니 그대로 베끼지 말 것. + +**darklua는 검토 후 채택 안 함** — 참고 레포는 `.darklua.json`의 +`convert_require` 룰로 경로 기반 require를 빌드 시점에 Rojo sourcemap +기준 `script.Parent`류로 변환하는데, **사용자 판단(2026-08-19)**: "Roblox +안에서도 이미 string require가 적용되긴 하고, `@self`와 `@game`이 +먹는다. 같은 동작을 하지만 `./` 등으로 위치를 어떻게 두냐에 유의가 +필요할 뿐" — 즉 실제 Roblox 엔진도 이 세션이 확인한 것과 같은 +require-by-string 의미론(`@self` 등)을 그대로 지원하므로, darklua로 다른 +형태로 변환할 필요성 자체가 낮다는 판단. quad-base는 엔진 무관이어야 +하므로 애초에 Roblox 전용 변환의 적용 대상도 아님. + +**[2026-08-19 후속 확인 — 정확한 경계]** `darklua` 바이너리(0.19.0)를 +직접 설치해 참고 레포에서 실제로 `darklua process`를 돌려 정확히 어디까지 +관여하는지 확인: +- **`require("@self/X")`/`require("@game/...")`는 `convert_require`가 + 아예 손을 안 댐** — `unable to require resource: unknown source name + '@self'`로 경고만 찍고 원문 그대로 통과시킴. 즉 예약 alias는 런타임이 + 직접 처리한다고 전제하고 있고, 이 세션의 결론과 정합. +- **커스텀 `.luaurc` alias(`@pkg` 등)는 다르다 — `convert_require`가 + 실제로 `@pkg/assets`를 + `require(game:GetService('ReplicatedStorage'):WaitForChild('roblox_packages'):WaitForChild('assets'))` + 로 치환함**(`.luaurc`의 alias 매핑 + Rojo sourcemap을 같이 읽어서 + 해석 — 대상 파일이 실제로 존재해야 성공, `pesde install` 전엔 실패). + 즉 **커스텀 alias 기반 require를 실제로 쓰려면 darklua(또는 동급 + 빌드 스텝) 없이는 배포 시점에 그 문자열이 그대로 남아 동작을 보장할 + 근거가 없다** — standalone `luau` CLI가 커스텀 alias를 거부하는 것과 + 같은 결(`could not jump to alias`), Roblox 엔진이 예약 alias 밖의 + 커스텀 alias까지 자체적으로 푼다는 근거는 없음. +- **결론**: quad는 커스텀 alias를 전혀 안 쓰고 상대경로+`@self`만 + 쓰므로 지금은 darklua가 불필요. **나중에 `@pkg/quad_base`류 축약 + alias를 도입하고 싶어지면 그때는 darklua(혹은 동급 변환)가 실질적으로 + 필요해진다** — 그 시점에 이 절을 다시 열 것. + +## require 구조 — `@self`가 필수인 이유 + +**[2026-08-19, 사용자 지적 + Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인]** + +`init.luau`라는 파일은 require-by-string 상 **자기가 든 폴더 자체**를 +가리키는 특수 취급을 받는다 — 그래서 그 파일 **안에서** 쓰는 상대 +경로는 일반 파일과 기준점이 다르다: + +- **`init.luau` 안에서 `require("./X")`/`require("../X")`** — 이 폴더 + 자체가 "자기"이므로, 상대 경로는 **이 폴더의 형제/조상**을 가리킨다. + 즉 `quad-base/src/init.luau` 안의 `./Debug`는 `quad-base/src/Debug`가 + 아니라 `quad-base/Debug`를 가리킨다(존재하지 않으면 즉시 에러). +- **`init.luau` 안에서 그 폴더 *안의* 형제 파일에 접근하려면 + `@self/X`를 써야 한다** — `@self`는 예약 alias로, 이 모듈(=이 폴더) + 자신의 경로로 치환된다. `quad-base/src/init.luau`가 + `quad-base/src/Debug`에 접근하려면 `require("@self/Debug")`. +- **일반 파일(`init.luau`가 아닌 `*.luau`)에서는 평범한 파일-상대 + 경로**(`./`/`../`)면 충분 — `@self`가 전혀 필요 없다. 예: + `quad-base/src/Debug/init.luau`(이것도 init.luau라 위 규칙이 적용됨) + 안에서 형제 `Relate.luau`(`quad-base/src/Relate.luau`, `Debug` + 폴더의 부모에 있음)를 가져오려면 — `Debug/init.luau`의 "자기"는 + `Debug` 폴더이므로 그 부모(=`quad-base/src`)에 있는 `Relate.luau`는 + 형제 폴더 취급 → `require("./Relate")`(◯), `require("../Relate")`(✕, + 한 단계 더 올라가 `quad-base/Relate`를 찾으려다 실패). + +**실측 근거**: `tbox`(`initreq/tbox`, 다른 참고 레포)의 `src/init.luau`가 +`require("@self/types")`/`require("@self/schema/string")` 패턴을 +실제로 쓰고 있어 교차 확인됨. 이 세션에서 `quad-base/src/init.luau`가 +처음에 `require("./Debug")`로 잘못 짜여 크로스파일 require가 전부 +깨졌었고(런타임은 크래시, `luau-analyze`는 **조용히** `Unifiable`로 +새며 0 진단으로 통과 — 이것도 `typing-limits.md`의 "실측 방법 주의" +경고("`luau-analyze`가 진단 0건이어도 타입이 제대로 해소됐다는 뜻이 +아닙니다")가 가리키는 것과 같은 종류의 함정, 다만 원인은 재귀 제네릭이 +아니라 require 경로 오류라 그 문서 1번 항목과는 별개 사례), `@self`로 +고치자 즉시 정상화됨(둘 다 clean). + +**체크리스트**: 새 `init.luau`를 짤 때마다 "이 파일 안의 `require`가 +같은 폴더 안의 형제를 가리키는가, 아니면 이 폴더의 형제/조상을 +가리키는가"를 먼저 물을 것 — 전자면 `@self/`, 후자면 `./`나 `../`. + +## 워크스페이스 의존성은 심볼릭 링크로 연결된다 — CLI 테스트의 함정 + +**[2026-08-19 실측]** `pesde install`이 워크스페이스 멤버 간 의존성을 +해소하는 방식은 **심볼릭 링크**다 — `quad-roblox/roblox_packages/` +안에 실제로 이렇게 생긴다: + +``` +quad-roblox/roblox_packages/ +├── quad_base.luau # 얇은 링커: return require("./.pesde/qwreey+quad_base/0.0.0/quad_base/src") +└── .pesde/qwreey+quad_base/0.0.0/quad_base/ + ├── src -> ../../../../../quad-base/src (symlink) + ├── test -> ../../../../../quad-base/test (symlink) + ├── pesde.toml -> ... (symlink) + └── pesde.lock -> ... (symlink) +``` + +**⚠️ 문제**: Luau의 standalone require-by-string 구현은 **심볼릭 링크를 +안 따라간다** — 의도된 설계다(Luau RFC 검색 결과: "보안 상의 이유로 +symlink는 일반 파일처럼 취급되고 따라가지 않는다", 추후 `.luaurc`에 +opt-in 토글이 추가될 수 있다고만 언급됨, 아직 없음). 직접 재현: + +```lua +-- entry.luau, ./linked가 실제 폴더로의 symlink일 때 +local v = require("./linked") +-- error requiring module "./linked": could not resolve child component "linked" +``` + +**실무 영향**: `quad-roblox`가 실제로 `quad_base`를 쓰게 되면(M5+), +표준 경로(`require(".../roblox_packages/quad_base")`)는 **`luau` CLI로 +직접 못 돌린다** — `could not resolve child component`로 즉시 깨짐. + +**[2026-08-19 후속 세션, 확인 완료] Rojo/Studio는 이 문제와 무관함 — +`rojo`를 같은 방식으로 `/code/.local/bin`에 설치해 직접 검증.** `quad-roblox/` +아래 `src`+`roblox_packages`를 매핑하는 임시 project.json으로 +`rojo sourcemap`을 돌려보니, symlink를 정확히 따라가 실제 파일까지 +해소함을 확인: + +```json +{"name":"src","filePaths":["../quad-base/src/init.luau"], + "children":[ + {"name":"Debug","filePaths":["../quad-base/src/Debug/init.luau"]}, + {"name":"Relate","filePaths":["../quad-base/src/Relate.luau"]} + ]} +``` + +`rojo build`(실제 `.rbxm` 생성)도 같은 트리로 에러 없이 성공. 즉 Rojo는 +`fs::canonicalize`류 평범한 파일시스템 API로 트리를 만들어서 symlink를 +투명하게 통과하고, 위 함정은 **Luau standalone CLI의 require-by-string +전용 문제**로 확정 — Studio 배포 경로엔 영향 없음. Studio 자체(플러그인 +연동)까지는 아직 미검증(`HUMAN_TODO.md` 1번, 계정 분리 대기)이지만, +`rojo build`/`sourcemap` 레벨에서 이미 심볼릭 링크 순회가 확인됐으므로 +Studio도 같은 파일시스템 계층을 쓰는 이상 다르게 동작할 이유가 없다. + +**우회가 필요한 범위는 M0/M1식 CLI 스파이크/mock 테스트로 좁혀짐**: +`roblox_packages/`를 거치지 말고 실제 형제 패키지 경로를 직접 가리킬 +것 — 예: `quad-roblox/src`에서 검증용 스크립트를 짤 때 +`require("../../quad-base/src")`처럼. **프로덕션 `quad-roblox` 소스 +자체는 그대로 표준 pesde 경로(`roblox_packages/quad_base`)를 쓸 것** — +Rojo/Studio가 실제로 소비하는 게 그 경로이고 위에서 확인했듯 문제없이 +동작한다. + +**[2026-08-19 셋째 후속 세션] 이 함정이 이제 `quad-base` 자기 자신의 +프로덕션 진입점에도 실제로 닥침** — `quad-types` 워크스페이스 패키지가 +신설되며 `quad-base/src/init.luau`가 `require("./roblox_packages/quad_types")`를 +쓰게 됐는데(`quad-types-plan.md` 참고), 이건 M0/M1 스파이크가 아니라 +**실제 출하 소스**라 위 "우회는 스파이크에만"이라는 구분이 더 이상 +깔끔하게 안 맞음. 이 세션이 실제로 쓴 해법: `pesde install`이 만든 +심볼릭 링크를 **로컬 CLI 테스트용으로만** 실제 디렉토리 복사본으로 +치환(`find . -type l ... cp -r`류, 저장소 파일은 안 건드리고 +`roblox_packages/.pesde/`의 링크만 대상) — Rojo/Studio는 이미 symlink를 +투명하게 처리하므로 배포 경로엔 아무 영향 없고, 순수 이 샌드박스의 +`luau`/`luau-analyze` 실행을 가능하게 하는 로컬 조치다. **아직 반복 +가능한 스크립트/mise task로 정식화하진 않음** — 매번 `pesde install` +후 수동으로 치환했음. 다음 세션이 CLI 테스트를 또 돌리려면 같은 수동 +치환이 필요하거나, 이 시점에 정식 스크립트화를 고려할 것(Luau의 +`.luaurc` symlink opt-in 토글이 미래에 생기면 이 절 전체가 불필요해짐 — +그때 다시 볼 것). + +**[2026-08-19 같은 날 넷째 후속 세션] 의존 대상의 `target`에 따라 링크 +디렉토리 이름이 달라진다** — `quad-types`(target `roblox`)가 +`type-version-check`(target `luau`)에 의존하면, `quad-base`/`quad-roblox`가 +쓰는 `roblox_packages/`와 달리 `quad-types/src/init.luau`는 +`require("./luau_packages/type_version_check")`로 **`luau_packages/`** +아래에서 링크를 찾는다 — pesde가 의존 대상 패키지 자신의 target 이름으로 +디렉토리를 분리하기 때문(`.gitignore`에 `luau_packages/`도 이미 +포함돼 있어 별도 조치 불필요). + +같은 워크어라운드가 2단 의존 체인에도 그대로 재적용됨 — `type-version-check` 신설로 +`quad-types`→`type-version-check`가 추가되면서 `quad-base`/`quad-roblox`→ +`quad-types`→`type-version-check`처럼 깊이 2인 워크스페이스 의존 그래프가 +생겼는데, `pesde install` 후 같은 심볼릭 링크 치환을 반복 적용하는 것만으로 +`luau-analyze`/`luau` 양쪽 다 문제없이 동작 확인됨 — 이 우회가 단일 +깊이에 국한되지 않고 일반화됨이 실측으로 재확인됨. + +## `.luaurc` — alias는 여전히 편집기 전용 + +`.luaurc`의 `aliases`(`@quad-base`/`@quad-roblox`)는 **런타임 +require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇게 +서술해뒀던 걸 이 세션에 직접 재확인: `require("@quad-base/Debug")`를 +실행하면 `could not jump to alias "quad-base/src"`로 실패(별도 에러 +메시지라 위 심볼릭 링크 문제와는 다른 원인 — alias 자체가 런타임 +미지원이라는 뜻, `@self`는 **예약 alias**라 이 제약과 무관하게 항상 +동작하는 것과 구분할 것). 그래서 alias는 편집기 자동완성/타입체크 +용도로만 남기고, 실제 require는 위 규칙대로 상대경로 + `@self`. + +## `selene` 린터 — 패키지별 설정, CWD 상대 config 탐색 함정 + +**[2026-08-19 신설]** 사용자 결정으로 `selene`(참고 레포 +`initreq/roblox-project-example`의 `scripts/selene.toml` 그대로 채택)을 +도입 — `luau-analyze`(타입체크)와 겹치지 않는 별도 축(정의되지 않은 +변수, 사용 안 하는 변수, `assert` 메시지 누락 등 스타일/버그 패턴 +린트)이라 같이 씀. `quad-base/selene.toml`/`quad-roblox/selene.toml` +각각 독립 배치(참고 레포도 패키지마다 독립 설정) — 아래 실측 이유 때문에 +루트 단일 설정은 안 씀. + +**⚠️ `selene`의 `--config` 탐색은 파일 트리를 거슬러 올라가며 찾는 게 +아니라 CWD 기준 고정 경로(`./selene.toml`)다.** 루트에 `selene.toml` +하나만 두고 `selene quad-base/`를 저장소 루트에서 실행하면, 그 자리엔 +`selene.toml`이 없어(패키지 폴더 안에 있으므로) **Luau 문법 자체를 못 +읽는 기본(Lua 5.1) std로 조용히 폴백** — `type Quad = {...}` 같은 평범한 +타입 선언까지 전부 "unexpected token" 파싱 에러로 쏟아짐(30여 건, 전부 +가짜). 처음엔 이게 "이 selene 빌드가 Luau 타입 문법을 아예 지원 못 +한다"는 뜻인 줄 알았으나, `std = "luau"`가 든 `selene.toml`을 CWD에 +두면 정확히 같은 파일이 0 에러로 통과함 — 즉 **문제는 selene의 Luau +지원이 아니라 config 탐색 방식**이었다. + +**규칙**: 항상 **패키지 디렉토리 안에서** 실행할 것 — +`cd quad-base && selene .`(참고 레포의 Justfile도 정확히 이 패턴으로 +각 패키지를 순회함, `refresh`/`clean` 레시피). 저장소 루트에서 +`--config quad-base/selene.toml quad-base/`처럼 명시적으로 경로를 +지정해도 되지만, 그럴 거면 그냥 `cd`가 더 안전(설정 파일 지정을 +빠뜨리기 쉬움). + +## `pesde.lock` — 커밋 권고 (미확정, 사용자 판단 필요) + +**[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱 +하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`이 +**워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트 +`pesde.lock` 1개 + 멤버마다 1개씩, 개수는 `architecture.md`의 +`workspace_members`를 따라간다 — 2026-08-19 이 세션 안에서만 2개 +멤버(2+1개 lock)에서 4개 멤버(4+1개 lock)로 늘어난 전례가 있어 여기 숫자를 +고정하지 않는다). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만 +담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의 +lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty = +"workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만). + +**이 세션의 잠정 권고는 셋 다 커밋**: Cargo 생태계의 "라이브러리는 +lockfile을 커밋하지 않는다" 관행이 여기 그대로 적용 안 되는 이유는, 이 +lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `private = true`, +`quad-base`/`quad-roblox`의 `pesde.toml`도 `includes = ["src/*"]`뿐이라 +`pesde.lock`은 애초에 게시물에 안 들어감(외부 소비자는 이 파일들을 절대 +못 봄 — 자기 프로젝트에서 새로 resolve함). 그래서 "라이브러리 lockfile +딜레마" 자체가 성립하지 않고, 그냥 "이 모노레포를 체크아웃한 개발자/CI가 +재현 가능한 빌드를 얻는가" 문제로 좁혀지는데 그건 커밋하는 쪽이 유리 — +**다만 이건 이 세션의 판단이고 최종 확정 아님**, `todos.md`에 확인 필요 +항목으로 반영. + +## 확인 완료 / 아직 확인 안 된 것 + +**확인 완료(이 세션, 실제 pesde/luau 실행 근거)**: +- pesde 워크스페이스 설치가 당시 워크스페이스 멤버 전부(그때는 + 루트+2서브)에 대해 성공(**[2026-08-19 후속]** 이후 `quad-types`/ + `type-version-check` 추가로 멤버가 늘어난 뒤에도 같은 절차로 계속 성공 — + 아래 후속 문단들 참고) +- 패키지 이름 문자 제약(하이픈 금지) +- `workspace = "scope/name"` 의존성 선언 문법 +- `@self`가 `init.luau`의 형제 파일 접근에 필수라는 것(런타임+ + `luau-analyze` 양쪽) +- `.luaurc` alias가 런타임에서 여전히 안 먹는다는 것(재확인) +- 워크스페이스 의존성이 symlink로 연결되고, 그게 `luau` CLI의 + require-by-string과 충돌한다는 것(직접 재현 + Luau RFC로 원인 확인) +- **[2026-08-19 후속 세션]** Rojo(`/code/.local/bin`에 직접 설치, + `7.7.0`, 툴체인 핀과 일치)는 위 symlink 문제와 무관 — `rojo + sourcemap`/`rojo build` 둘 다 `roblox_packages`의 symlink를 실제 + 파일까지 투명하게 따라감을 확인. 위 심볼릭 링크 함정은 Luau standalone + CLI의 require-by-string 전용 문제로 범위가 좁혀짐 +- **덤 확인** — `rojo`가 PATH에 잡히자 `luau-lsp`(에디터)가 자동으로 + `rojo sourcemap default.project.json --output sourcemap.json --watch`를 + 백그라운드로 띄움(루트 `default.project.json` 기준). 즉 지금 이 + 워크스페이스에서 에디터 타입 링킹이 실제로 살아있다는 뜻 — wally가 + 안고 있던 "설치된 패키지의 타입 정보 단절" 문제가 이 구성에선 재현 + 안 됨(`architecture.md`가 pesde 전환의 배경으로 들었던 문제 자체가 + 실제로 해소됐다는 간접 증거). 산출물 `sourcemap.json`은 재생성되는 + 빌드 산물이라 `.gitignore`에 추가 +- **[2026-08-19 셋째 후속 세션]** `mise.toml`이 `pesde`/`rojo`/`luau-lsp`/ + `selene` 넷 다 정확히 설치·버전 일치함을 `mise install` + `mise exec`로 + 직접 확인(attestation/provenance 검증까지 포함) — `rokit`은 끝내 + 한 번도 직접 검증 못 했던 것과 대비됨 +- `selene`의 `--config` 탐색이 파일 트리를 안 거슬러 올라가고 CWD + 기준 고정 경로라는 것(위 "`selene` 린터" 절) + +**아직 확인 안 됨(다음에 도구/환경이 갖춰지면)**: +- 루트 `pesde.toml`의 `[target]` 섹션이 실제로 의미가 있는지(지금은 + workspace 가이드 예제를 그대로 따라 둔 것, 검증 안 됨) +- Roblox Studio 자체(플러그인 연동)까지의 실제 동기화 — `rojo + build`/`sourcemap` 레벨은 확인됐지만 `HUMAN_TODO.md` 1번(계정 분리)이 + 되기 전까진 Studio 실물로는 미확인 +- `quad_base` 설치 시 나온 "`roblox_sync_config_generator` 스크립트가 + 없으면 linking에 문제가 생길 수 있다"는 WARN의 실제 영향 범위 — 지금은 + install 자체를 막지 않아서 방치, 실제 Rojo 동기화 단계에서 문제가 + 드러나면 그때 pesde 문서의 `[target.scripts]` 절을 찾아볼 것 +- `pesde.lock` 커밋 여부 최종 확정(위 절 권고는 잠정) diff --git a/.claude/base/quad-types-plan.md b/.claude/base/quad-types-plan.md new file mode 100644 index 0000000..12c2bc1 --- /dev/null +++ b/.claude/base/quad-types-plan.md @@ -0,0 +1,261 @@ +# `quad-types` — 구현 없는 `Quad` 타입 계약 + 컴파일 타임 버전 체크 + +**상태**: base — 2026-08-19 세션에 신설·구현·검증까지 완료. 워크스페이스 +세 번째 멤버 `quad-types`의 존재 이유, `AddPlugin`/`CheckedQuad`의 정확한 +사용법, 그 배선에서 실제로 깨졌던 Luau 함정들을 정리. **[같은 날 후속]** +버전 패턴 매칭 자체는 quad에 종속되지 않은 범용 패키지 +`type-version-check`(워크스페이스 네 번째 멤버)로 분리됐고, `CheckedQuad`는 +`CheckedQuad`으로 확장돼 그 위에 얹힌다 — 아래 "`type-version-check`" +절. + +## 왜 필요한가 — dev-dependency로는 못 푸는 문제 + +`quad-roblox`는 `QuadRoblox(Quad): QuadRoblox`처럼 quad-base 인스턴스를 +**런타임에 함수 인자로 주입**받는다(`base/module-lifecycle-plan.md` +"Bind는 누가, 어떻게 구현하는가" 절이 확정해둔 팩토리 패턴 — quad-roblox +자신은 quad-base를 `require`할 필요가 없어 보인다). + +**그런데 타입 주석 하나 때문에 얘기가 달라진다.** `QuadRoblox`의 시그니처가 +`Quad` 타입을 참조하려면 그 타입이 정의된 모듈을 `require`해야 하고, +**이 require는 "타입만 쓰려는 목적이어도 런타임에 실제로 실행된다"** +(실측 확인, 2026-08-19 — `require`가 반환하는 모듈에서 export 타입만 +꺼내 써도 그 `require` 문 자체는 평범한 런타임 호출이라, 대상이 없으면 +그 자리에서 크래시함). 그래서: + +- `quad_base`를 **일반 의존성**으로 두면: 소비자가 `quad-roblox`를 설치할 + 때마다 무거운 quad-base 전체가 통째로 딸려온다(quad-base를 이미 따로 + 설치해서 `QuadRoblox(Quad)`에 넘기는 상황이면 완전히 중복). +- `quad_base`를 **dev-dependency**로 두면: 로컬 개발 중엔 문제없지만, + `quad-roblox`가 게시된 뒤 **소비자 환경엔 dev-dependency가 전파되지 + 않아** 그 타입-전용 require가 못 찾고 그 자리에서 런타임 크래시난다. + +**해법**: `Quad`의 타입 계약만 담은, 런타임 구현이 사실상 없는 세 번째 +워크스페이스 패키지 `quad-types`를 두고, `quad-roblox`는 이것만 **일반 +의존성**으로 둔다 — 항상 안전하게 실 의존성으로 넣을 수 있을 만큼 +작고, quad-base 전체를 안 끌고 온다. + +## `quad-base` 안에 폴더로 두면 안 되는가 — 안 됨 + +pesde의 워크스페이스 의존성은 **패키지 단위**로만 걸린다 +(`{ workspace = "scope/name" }`) — 서브폴더 단위 의존 문법이 없다. +`quad-base/types/`처럼 폴더로 만들어도 `quad-roblox`가 그걸 가져오려면 +결국 `quad_base` 패키지 전체를 의존성으로 선언해야 하고, 실제로 링크되는 +것도 quad-base 전체 소스 트리다(어느 파일을 실제로 require하는지와 +무관). 그래서 **반드시 별도 pesde 패키지**(`workspace_members`의 새 +멤버)여야 "가벼운 타입만" 효과가 실제로 생긴다. + +## 구조 + +``` +quad-types/ +├── pesde.toml # name = "qwreey/quad_types", type_version_check workspace 의존 +└── src/init.luau # export type Quad, export type CheckedQuad + +type-version-check/ # 워크스페이스 네 번째 멤버, quad에 종속되지 않음 +├── pesde.toml # name = "qwreey/type_version_check", environment = "luau" +└── src/init.luau # matchesPattern(런타임), export type function CheckVersion +``` + +- `quad-base`는 `quad_types`에 workspace 의존 — **자기 `Quad` 타입을 + 따로 선언하지 않고 `type Quad = QuadTypes.Quad`로 그대로 가져다 씀** + (한 곳에만 진실이 있게, 구현이 계약과 어긋나면 구조적 타입에러로 + 자연히 드러남). 실제로 `quad-base/src/init.luau`에 반영됨. +- `quad-roblox`도 `quad_types`에 workspace 의존(quad-base 아님). +- **[참고, 2026-08-19 사용자 판단] 모든 백엔드/플러그인 패키지가 이 + 패턴을 따를 필요는 없다** — 예: 가상의 `quad-spring`/`quad-spring-roblox` + 쌍은 타입 분리 없이 `quad-spring-roblox`가 `quad-spring`을 평범하게 + 일반 의존성으로 둬도 된다("주입만 하면 Spring이 같이 따라오도록"). + `quad-types` 분리는 **quad-base처럼 사실상 모든 패키지가 의존하는 + 핵심 계약**일 때만 값어치가 있다. + +## `Quad` 타입 — 확정된 표면 + +```lua +export type Quad = { + Version: "0.0.0", -- quad-base/pesde.toml의 version과 항상 맞출 것 + debug: boolean, + New: () -> Quad, + RunInit: (self: Quad, initFn: (Quad) -> any) -> (), + AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P, +} +``` + +`Version`은 리터럴(singleton) 타입 — `string`이 아니라 정확히 `"0.0.0"`. +이 리터럴이 아래 `CheckVersion`의 판정 근거이자, 그 자체로도 평범한 +구조적 타이핑만으로 이미 어느 정도 버전 불일치를 잡아준다(다른 리터럴 +`"0.1.0"`은 `"0.0.0"`과 구조적으로 호환 안 됨) — `CheckVersion`이 주는 +추가 가치는 **감지 자체**가 아니라 사람이 읽을 수 있는 진단 메시지다 +(아래 절). + +## `AddPlugin` — 실측 검증된 플러그인 체이닝 + +```lua +AddPlugin: (self: Self, pluginFn: (Self) -> P) -> Self & P +``` + +`Self`를 고정된 `Quad`가 아니라 **제네릭**으로 둬야 체이닝이 누적된다 +(고정하면 두 번째 `AddPlugin` 호출이 첫 번째 확장을 잃어버림). 실측 +확인(2026-08-19): +- `quad:AddPlugin(springFn)`(→ `Quad & SpringPlugin`)`:AddPlugin(otherFn)` + 체이닝 결과가 정확히 `Quad & SpringPlugin & OtherPlugin`로 누적됨. +- 플러그인 추가 전 그 메소드에 접근하면 정확히 `Key 'X' not found`로 + 거부됨(음성 대조군). + +**런타임 구현**(quad-base, 실제 반영됨): `pluginFn(self)`를 호출해 얻은 +확장 테이블의 필드를 `self`에 **직접 mutate**하고 `self` 그대로 반환 — +새 테이블을 만들지 않는다. 이유: `RunInit`의 멱등 추적이 `module` +identity에 의존하므로(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절), +`AddPlugin`이 새 테이블을 반환하면 그 추적이 끊긴다. + +## `type-version-check` — 범용 버전 패턴 매칭 패키지 + +**[2026-08-19 신설]** 처음엔 `CheckVersion`가 정확 일치(`"0.0.0"`)만 +보는 quad-types 내부 함수였다. 그런데 정확 일치는 `quad-spring`/ +`quad-spring-roblox`처럼 **독립적으로 게시되는 백엔드 플러그인** 쌍엔 너무 +빡빡하다 — 최신 `quad-spring-roblox`가 예전 `quad-spring`도 잘 다루는 +경우가 흔할 텐데, 정확 일치를 강제하면 그때마다 재게시가 필요해진다 +(**사용자 판단**: "구현해주는것 정말 쉽고... 있으면 좋다고 생각함"). +그래서 글롭/캐럿 패턴을 지원하는 별도 패키지로 뺐다 — quad 전용 이름을 +안 섞어서 quad-spring류가 quad-base 전체를 끌고 올 필요 없이 이것만 +가볍게 의존하게 하기 위함이기도 하다. + +**[2026-08-19] 지금은 quad 모노레포 워크스페이스의 네 번째 멤버로 두지만, +사용자가 나중에 독립 저장소로 직접 분리할 예정** — `HUMAN_TODO.md` 참고. + +**패턴 문법**(`.`로 나뉜 각 자리): `"*"` = 와일드카드, `"N^"` = 그 자리 +숫자값이 N **이상**이면 통과(caret), 그 외 = 정확히 같은 문자열이어야 +통과. 예: `"3.*.*"`(메이저만 고정), `"3.3^.4^"`(마이너 3 이상 + 패치 +4 이상), `"0.0.0"`(정확 일치 — quad-types가 지금 쓰는 패턴). + +```lua +export type function CheckVersion(actual: type, pattern: type): type +``` + +`actual`/`pattern` 둘 다 문자열 리터럴(singleton) 타입이어야 하고, 일치하면 +트리비얼한 `true`(`types.singleton(true)`) 하나만 반환 — `quad-types` +"함정 3"과 같은 이유로 원본 타입을 절대 반환하지 않는다. + +**Luau 신규 실측 함정 2건**(이 세션에 처음 발견, `typing-limits.md`가 +다루는 "타입 시스템 해석 한계"와는 결이 달라 여기 기록): +- **`type function`은 같은 파일의 바깥 스코프 로컬 함수를 아예 참조 못 + 한다** — `Type function cannot reference outer local 'X'`로 컴파일 + 자체가 실패. 그래서 런타임용 `matchesPattern`과 `CheckVersion` 내부의 + 매칭 로직은 **물리적으로 별개 함수로 중복**돼 있다 + (`type-version-check/src/init.luau`) — 하나를 고치면 반드시 다른 + 하나도 같이 고칠 것. +- **cross-package 사용엔 `export type function`이 필요**하다(`type + function`만으론 안 됨) — 안 그러면 다른 파일에서 `Unknown type + 'Module.CheckVersion'`으로 막힌다. 그리고 명시적 제네릭 인스턴스화가 + **2개 이상**이면 단일 꺾쇠(`Foo`)가 비교 연산자로 오파싱되니 + 반드시 이중 꺾쇠(`Foo<>`)를 써야 한다(코퍼스에 이미 있던 + `AttributeKey<>` 관례와 같은 이유). + +`Version` 필드는 Luau 내장 `index` type function으로 뽑는다 +(수동 `t:readproperty(...)`보다 간결 — **사용자 제안**으로 채택, 실측 확인 +완료). + +## `CheckedQuad` — 버전 불일치를 컴파일 타임에 사람이 읽을 메시지로 + +**왜 필요한가**: `quad-roblox`가 `quad_base`를 pesde `[dependencies]`로 +선언하지 않고 런타임 주입으로만 받게 되면서, **pesde 자신의 semver 충돌 +방지 장치가 이 관계엔 전혀 안 걸린다** — 선언된 의존성이 아니라 그냥 +함수 인자라서. `CheckedQuad`이 그 빈자리를 메꾸는 컴파일 타임 +대체 안전장치다(사용자 판단: "런타임 에러까지 내려면 quad-roblox +소스에 버전을 하드코딩해야 하는데 그건 과함 — 타입 에러만 내는 걸로 +충분"). `Pattern`은 위 `type-version-check`의 글롭/캐럿 패턴 문자열 — +quad-base/quad-roblox처럼 같은 모노레포에서 항상 같이 개발되는 관계는 +정확 일치(`"0.0.0"`)를, quad-spring-roblox류 독립 게시 플러그인은 +`"0.*.*"` 같은 느슨한 패턴을 직접 골라 쓴다. + +```lua +export type CheckedQuad = T & { __versionCheck: TypeVersionCheck.CheckVersion, Pattern> } +``` + +**사용법**: +```lua +local function CheckQuad(quad: T): QuadTypes.CheckedQuad + return quad :: any +end + +local checked = CheckQuad(injectedQuad) +local _ = checked.__versionCheck -- ⚠️ 필수 — 아래 "함정 2" 참고 +local quad = checked:AddPlugin(installRobloxBackend) -- 이후 정상적으로 체이닝 +``` + +### 실측으로 깨진 시도들 (전부 순서대로 실제로 시도하고 버림) + +**함정 1 — `error()`로 에러 내면 안 됨.** `type function` 안에서 +`error()`를 부르면 "이 type function 자체가 실행에 실패함"으로 판정돼 +버려져서 원하는 메시지가 안 뜬다. **`print("메시지")` + `return +types.never` 조합만 호출부에 정확히 `TypeError: <메시지>`로 뜬다** +(실측 확인 — 3줄짜리 최소 재현으로 검증). + +**함정 2 — 함수 본문 안의 로컬 타입 별칭으론 절대 평가 안 됨.** +```lua +-- ❌ 이렇게 하면 아무 진단도 안 뜬다(제네릭 인스턴스화 시 재평가 안 됨) +local function QuadRoblox(quad: T): T + type _Check = CheckVersion + return quad +end +``` +체크는 **리턴 타입/필드 타입처럼 호출부마다 실제로 해석되는 자리**에 +박아 넣어야 한다. 이게 `CheckedQuad`이 함수 파라미터/반환 +타입 표현식 안에 직접 나타나야 하는 이유고, `__versionCheck` 필드도 **실제로 +참조해야만** 평가된다(lazy) — 위 사용법 예제의 `local _ = +checked.__versionCheck` 줄이 빠지면 검사가 조용히 스킵된다. + +**함정 3 — [가장 중요, 가장 늦게 발견] 값이 한 번이라도 `type +function`을 거치면 이후 제네릭 self 메소드 체이닝이 조용히 깨진다.** +처음엔 `CheckVersion`가 성공 시 `T`를 그대로 패스스루(`return t`)하는 +버전으로 짰다 — 단독으로는 완벽히 통과했다(리턴 타입 표현식에 직접 +써서 즉시 평가되고, `AddPlugin`도 안 뭉개지는 것처럼 보였다). 그런데 +**`CheckVersion`의 결과를 다른 타입과 `&`로 합친 뒤 `AddPlugin`을 +호출하는 조합**에서 `Expected this to be exactly 'P & Self', but got +'P & Self'`처럼 **앞뒤가 똑같은, 의미 없는 진단**이 뜨며 깨졌다. 더 +좁혀보니, 심지어 **`&`로 안 합쳐도, `CheckVersion`를 거친 값에 +`AddPlugin`을 부르기만 해도 똑같이 깨졌다** — 재구성 비용(값을 +`types.newtable()`로 다시 조립하는 것) 문제가 아니라, **"이 타입이 +`type function`을 거쳤다는 이력 자체"**가 이후 제네릭 self 추론을 +방해하는 것으로 보인다. 그래서 최종 설계는 **`CheckVersion`이 `T`를 +전혀 참조/반환하지 않고**(성공 시 트리비얼한 `types.singleton(true)` +하나만), 검증 결과를 원본과 절대 안 섞이는 **별도 필드** +(`__versionCheck`)로 완전히 격리한다 — `T` 자신은 `type function`을 +한 번도 거치지 않은 "순수한" 타입으로 계속 흘러가므로, 그 뒤 +`AddPlugin` 체이닝이 몇 번이 되든 전혀 안 깨진다(실측 확인). + +**교훈**: `type function`의 부작용은 "재구성이 원본을 뭉갠다"는 상상보다 +넓다 — **패스스루도 이력만으로 오염된다.** 검증/변형용 `type function`을 +설계할 때는 원본 타입을 **절대 반환하지 말고**, 트리비얼한 마커만 +반환해서 완전히 별도 필드로 격리할 것 — `typing-limits.md`의 "새 +타입/API를 설계할 때 체크리스트"에 추가할 후보. + +## 실측 근거 + +`.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` — +실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 위 +사용법 그대로 재현: 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝 + 이전 +확장 필드 유지) 전부 클린, 음성 경로(버전 불일치)는 정확히 그 줄에서 +`TypeError: type-version-check: version "9.9.9" does not match pattern +"0.0.0"` 하나만. + +`quad-base/test/smoke.plugin.luau` — 실제 런타임 `AddPlugin` 구현(mutate ++ identity 보존 + 체이닝)을 실행 레벨로 검증. + +## 남은 것 + +- `quad-roblox`가 실제로 `CheckedQuad`을 쓰는 진입점 + (`QuadRoblox` 등) 구현은 M5 — 지금은 quad-roblox/src가 비어 있어 이 + 문서의 사용법 예제가 실제 위치는 아직 없음. +- `_initializedBy`(별도 문자열 마커, backend 유일 슬롯 가드)와의 관계는 + `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고 — `CheckedQuad`는 + **버전** 호환성만 보고, **누가 이미 backend를 설치했는지**는 별개 + 문제로 계속 `_initializedBy`가 담당한다. +- **[백로그, 2026-08-19 신설]** `quad-roblox-types`(가칭) — `quad-types`와 + 같은 패턴으로, `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한 + 패키지. **사용자가 지금 만들 필요는 없다고 명시적으로 후순위 지정** — + 다만 이후 쉽게 뽑을 수 있게 `quad-roblox`의 공개 타입은 지금부터 단일 + `src/init.luau`(또는 `types.luau`) 형태로 몰아두는 걸 관례로 유지할 것 + (quad-types 자신이 이미 이 형태 — `Quad`/`CheckedQuad` 둘 다 + `src/init.luau` 하나에 있음). +- **[HUMAN_TODO]** `type-version-check`는 사용자가 나중에 독립 저장소로 + 직접 분리할 예정 — 루트 `HUMAN_TODO.md` 참고. diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index a682d70..93cd863 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -58,18 +58,19 @@ State를 만족함" 절). 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. -- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를 - 무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라 - 타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는 - 타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 - 설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는 - 이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입 - 에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로 - 확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인 - 상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지. - `luau-test/done/16-*`(type function으로 `Store` 레코드 필드 합성)에 - 이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는 - 경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. +- **[2026-08-18 구현 전 QA, 2026-08-19 M0 실측으로 해소] lazy 생성이 + 오타/동적 키로 Source를 무한정 누적하는 트레이드오프는 그대로 수용하고, + 방어선은 런타임이 아니라 타입에 둔다.** 사용자 판정: *"Store<{ field: + type }> 상 없는 네임에는 타입 시간에 Source 가 없는것으로 나와 타입 + 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요"* — 즉 + `Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이 + 합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다. + **[2026-08-19 실측 완료]** 맞았음 — + `luau-test/done/21-type-store-undeclared-key-rejected.luau`가 + `ProcessStoreType`(`16`과 동일 type function)의 결과 타입에 미선언 키로 + 접근하면 정확히 `TypeError` 2건(읽기·메소드 체이닝)이 나고, 선언된 키 + 3개는 클린임을 확인. 런타임에 굳이 이름을 받아야 하는 경우는 + `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. - **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 6c2e937..f087cfd 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -220,12 +220,12 @@ type State = { with different parameters"로 선언 시점에 막힘, 실측: `type-recursive-issue-with-typeof/spikes/ 19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도 - §8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정" + §9의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정" 전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다. **시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**: 콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone` -체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 +체이닝(§7)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면 (= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단 두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable` @@ -361,7 +361,71 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 6. 성립이 확인된 것 (안심해도 되는 것) +## 6. `type function`을 거친 값은 이후 제네릭 self 메소드 체이닝이 조용히 깨짐 + +**[2026-08-19 신설, 근거: `quad-types-plan.md`, 실측 `luau-test/23`]** + +### 무엇이 안 되는가 + +값의 **정적 타입**이 한 번이라도 `type function`을 거치면(설령 그 +`type function`이 입력을 그대로 반환하는 순수 패스스루라도), 그 뒤 +그 값에 **제네릭 self 파라미터를 쓰는 메소드**(`(self: Self, +...) -> Self & P`류, `AddPlugin`이 실제 사례)를 부르면 진단이 조용히 +깨집니다: + +```lua +type function CheckVersion(t: type): type + return t -- 순수 패스스루 — 재구성 없음 +end + +local checked: CheckVersion = ... -- 여기까진 정상 +local extended = checked:AddPlugin(somePlugin) -- 여기서 깨짐: +-- TypeError: Expected this to be exactly 'P & Self', but got 'P & Self' +-- (양쪽이 글자 그대로 같은, 의미 없는 진단) +``` + +**핵심은 "재구성"이 아니라 "이력"입니다** — `types.newtable()`로 새로 +조립한 타입만 문제인 게 아니라, `return t`로 원본을 그대로 돌려줘도 +똑같이 깨집니다. 값이 `type function` 호출을 거쳤다는 사실 자체가 +이후 제네릭 self 추론을 방해하는 것으로 보입니다(정확한 내부 메커니즘은 +미상 — 솔버가 type function 출력을 "불투명한" 타입으로 취급해 self +단일화에 필요한 정보를 잃는 것으로 추정됨). + +### 그래서 우리가 하는 것 + +검증/변형용 `type function`은 **원본 타입을 절대 반환하지 않는다** — +성공 시에도 트리비얼한 마커(`types.singleton(true)` 등)만 반환하고, +그 결과를 원본과 **완전히 격리된 별도 필드**로만 노출합니다: + +```lua +-- ✅ 원본 T는 type function을 한 번도 안 거침 +type CheckedQuad = T & { __versionCheck: CheckVersion } + +local checked: CheckedQuad = ... +local _ = checked.__versionCheck -- 강제 평가(아래 캐비엇 참고) +local extended = checked:AddPlugin(somePlugin) -- 안 깨짐 — checked의 T 부분은 순수함 +``` + +`quad-types-plan.md`의 "`CheckedQuad`" 절에 전체 배선과 실측 +과정이 있습니다 — ①`error()` 대신 `print`+`types.never`, ②검증은 함수 +본문 로컬 타입 별칭이 아니라 리턴/필드 타입 표현식 자체에 박아 넣어야 +호출부마다 재평가됨, ③(이 항목) 원본을 절대 반환하지 않고 별도 필드로 +격리, 세 가지가 함께 필요합니다. **[2026-08-19 후속]** 실제 버전 매칭 +로직(`CheckVersion`)은 quad에 종속되지 않은 별도 패키지 +`type-version-check`로 분리됐지만, 이 항목이 다루는 "패스스루도 이력만으로 +오염된다" 함정과 그 회피(별도 가상 필드 격리)는 그대로 유효합니다. + +### 언제 마주치는가 + +`AddPlugin`처럼 **제네릭 self 파라미터**를 쓰는 메소드가 있는 타입에, +`type function` 기반 검사/변형을 적용하려는 모든 자리 — quad에서는 +지금 `quad-types`의 `CheckedQuad`이 유일한 실사례지만, 앞으로 +비슷한 "타입 레벨 게이트 + 체이닝 가능한 API" 조합을 설계할 때마다 재발할 +수 있는 일반 패턴입니다. 아래 §8 체크리스트에 항목 추가. + +--- + +## 7. 성립이 확인된 것 (안심해도 되는 것) 한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다. 아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것: @@ -381,7 +445,7 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 7. 새 타입/API를 설계할 때 체크리스트 +## 8. 새 타입/API를 설계할 때 체크리스트 1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo` 안에서 `-> Foo`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), @@ -412,6 +476,11 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC 스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를 확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가 실측에서 뒤집힌" 것들입니다. +7. **`type function`으로 검사/변형한 값에 제네릭 self 메소드 + (`(self:Self,...)`류)를 나중에 부를 계획인가?** → 6번 한계에 + 걸림. 그 `type function`이 원본 타입을 조금이라도 반환하면(패스스루 + 포함) 안 됨 — 검사 결과는 원본과 절대 안 섞이는 별도 필드로 격리하고, + 원본 타입 자체는 `type function`을 아예 거치지 않게 할 것. > **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로 > 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze @@ -421,14 +490,16 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC --- -## 8. 미해결 / 추적 중 +## 9. 미해결 / 추적 중 -- **에디터(`luau-lsp`)의 솔버 설정** — `luau-analyze` CLI는 새 솔버가 - 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값**(`LuauSolverV2=false`)이라 - 같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면 - `"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터 - 환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상 - 새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요. +- **[2026-08-19 설정 완료]** 에디터(`luau-lsp`)의 솔버 설정 — `luau-analyze` + CLI는 새 솔버가 기본값이지만 `luau-lsp`는 **옛 솔버가 기본값** + (`LuauSolverV2=false`)이라 같은 코드에 다른 진단이 남. `luau-lsp` + 바이너리(1.69.0)를 직접 설치해 `luau-lsp analyze + --flag:LuauSolverV2=true/false`로 실측 — 새 솔버가 필요하다는 결론 + 재확인, `quad/.vscode/settings.json`에 `"luau-lsp.fflags.enableNewSolver": + true`를 반영·커밋 완료(`tbox`도 같은 설정 확인). 실제 VSCode 세션에서 + 이 설정이 반영되는지 육안 확인만 사람 몫으로 남음(`HUMAN_TODO.md` 6번). - **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증(③ 포함). - **`state:With(...)`/`state:Apply(factory)`에 1번 ③(`typeof`) 개별 실측** — `Compute`에서만 확인됐고, base pseudocode에 실제로 반영할 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index d625c57..66b6141 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -71,7 +71,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | | `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | -| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-14] 전제가 뒤집혀 재작성 대기** — 옛 모델("이미 dirty면 전파 중단")을 검증 중이었으나 그게 폐기됨, 이제 확인할 건 "emit은 항상 전파되고 중복 재계산은 캐시로만 막힌다" | ROADMAP M0-1 | +| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | 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 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | @@ -79,7 +79,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", `.claude/session-summary.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` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 error" 절 + 7번 절 | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 특수 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | -| `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | +| `13-type-ref-preref-subtype.luau` (타입체크 전용) | **[2026-08-19 재작성]** `PreRef`/`PostRef`가 `Ref`를 구조적으로 만족하는지 — 원래 이 파일에 있던 런타임(B) 부분은 A의 더미 스텁이 실행을 막아 도달 불가였던 문제라 `22`로 분리, PostRef까지 확장 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정), `ref-plan.md`의 "`PostRef`" 절 | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지. **[2026-08-15] 통과 → `done/`** — 원인은 설계가 아니라 `types.newfunction` API 버전 드리프트였음, `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | @@ -87,6 +87,9 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | | `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | +| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 | +| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지 | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 `Brand` 절 | +| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 | ## 공통 유틸리티 diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 22ca682..36502cc 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,18 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트 +> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad` +> 버전 패턴 체크 + `AddPlugin` 체이닝) 검증용 `23` 신규 추가 → +> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성. +> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후 +> 제네릭 self 체이닝이 깨진다는 새 Luau 함정을 발견(`typing-limits.md` +> §6로 승격). 직전 갱신은 같은 날 — `13`을 타입 전용/런타임 두 파일로 분리 +> (A의 더미 스텁이 B 실행을 막던 문제 해결) + PostRef까지 확장, 런타임 +> 절반은 신규 `22`로 분가 → 둘 다 `done/`. 직전 갱신은 같은 날 — +> M0/M1 스캐폴딩을 처음 실제로 짜보는 과정에서 `05`를 현행 모델("emit은 +> 항상 전파, 재계산만 캐시로 dedup")로 재작성해 통과 → `rewrite-required/` +> → `done/` 이동, `todos.md` 00번이 요구하던 "Store 미선언 키 타입 에러" +> 확인도 신규 스파이크 `21`로 완료 → `done/` 직행. 직전 갱신은 +> 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트 > (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과, > `rewrite-required/` → `done/` 이동. 근거: > `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. @@ -23,9 +35,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -50,7 +62,7 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건) **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 @@ -69,8 +81,6 @@ |---|---|---| | `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) | | `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 | -| `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) | -| `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | @@ -83,24 +93,26 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (14건) +## ✅ `done/` — 통과 or 판정 끝 (19건) -**런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 -`04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위 -`rewrite-required/`로 이동했고, 아래 표엔 남은 9개만 있음**(실측 당시 -통과였다는 사실 자체는 유효): +**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는 +검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19] +`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13` +런타임 절반, PostRef까지 확장)가 합류**: | 파일 | 확인된 것 | |---|---| | `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 | | `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 | | `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 | +| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 | | `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 | | `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 | | `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 | | `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 | | `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) | | `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 | +| `22-runtime-ref-preref-postref-brand` | **[2026-08-19 신규]** `isPreRef`/`isPostRef`가 서로 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)임을 확인, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라냄 | **타입 스파이크 중 판정이 끝나 더 할 일 없는 것**: @@ -109,8 +121,11 @@ | `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 | | `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 | | `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) | +| `13-type-ref-preref-subtype` | **[2026-08-19 재작성]** ✅ 통과 — `PreRef`/`PostRef` 둘 다 `Ref`를 구조적으로 만족(음성 대조군도 정확히 에러). 런타임 B섹션은 `22`로 분리(A의 더미 스텁이 B 실행을 막던 문제 해결) | | `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 | | `16-type-store-key-typefunction` | **[2026-08-15]** ✅ 통과 — 원인은 설계 문제가 아니라 `types.newfunction` API 버전 드리프트(배열이 아니라 `{head=...}` 레코드). `ProcessStoreType`이 정확히 `{ty: Source, count: Source}` 구조를 만족, 음성 대조군 4건(틀린 Get/Set 타입 2건, 존재하지 않는 메소드) 전부 정확히 에러. 근거: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절 | +| `21-type-store-undeclared-key-rejected` | **[2026-08-19 신규]** ✅ 통과 — `16`의 `ProcessStoreType`을 재사용해 미선언 키 접근 2건(읽기, 메소드 체이닝)이 정확히 `TypeError`로 거부됨을 확인, 양성 경로(선언된 키 3개) 클린. `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측 확정 | +| `23-type-quadtypes-checkversion-addplugin` | **[2026-08-19 신규, 같은 날 후속으로 재작성]** ✅ 통과 — 실제 `quad-types`/`quad-base`/`type-version-check`로 `CheckedQuad`+`AddPlugin` 통합 검증. 재작성 과정에서 `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 조용히 깨진다는 새 Luau 함정 발견(`typing-limits.md` §6으로 승격), `export type function`/이중 꺾쇠 제네릭 인스턴스화 요구도 같이 실측 — 최종 설계(별도 가상 필드로 격리)는 양성/음성 경로 모두 클린 | ### 특별히 중요한 통과 3건 diff --git a/.claude/luau-test/done/05-store-state-diamond-propagation.luau b/.claude/luau-test/done/05-store-state-diamond-propagation.luau new file mode 100644 index 0000000..6f65677 --- /dev/null +++ b/.claude/luau-test/done/05-store-state-diamond-propagation.luau @@ -0,0 +1,175 @@ +--[[ + 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() + 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. + + 배경: ROADMAP.md M0 1번째 항목. **[재작성, 2026-08-19]** 옛 버전은 + "이미 dirty면 더 아래로 전파하지 않는다"를 검증했는데, 그 규칙은 + 확정된 Observer 계약(fn이 :Get()을 안 불러도 됨)과 모순돼 역전됨 + (`archive/invalidate-dedup-propagation-reversed.md`). 이 버전은 + 현행 모델을 검증: + 1. emit(invalidate 신호)은 자기 invalid 상태와 무관하게 *항상* + 전파된다 — 이미 dirty인 노드도 다시 전파를 계속함(early-stop 없음) + 2. 중복 **재계산**은 오직 :Get() 시점 캐시로만 막힌다(dirty 플래그가 + "내 캐시가 낡았다" 표시일 뿐, 전파 게이트가 아님) + 3. :Get()을 안 부르는 Observer는 다이아몬드에서 한 번의 source 변경마다 + 경로 수만큼(여기선 2번) 계속 운다 — 옛 모델처럼 두 번째부터 + 침묵하면 안 됨(핵심 음성 대조군) + + 다이아몬드 구조: + source + / \ + stateA stateB + \ / + stateC (:With(stateA, stateB):Compute(...)) + + 실행: `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 + -- 핵심: invalid는 "내 캐시가 낡았다"는 표시일 뿐 전파 게이트가 아님 + -- — 이미 dirty였어도 무조건 다시 세팅하고 무조건 아래로 전파한다. + self.dirty = true + print(string.format(" [%s] invalidate #%d — 항상 전파", name, invalidateCallCount[name])) + 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 전파(항상 전파, early-stop 없음) ===") +source:Set(2) +print( + "invalidate 호출 횟수(stateC):", + invalidateCallCount.stateC, + "(stateA 경로 1번 + stateB 경로 1번 = 2번이어야 함 — 두 번 다 끝까지 실행돼야 함, 조기 종료 없음)" +) +assert(invalidateCallCount.stateC == 2, "다이아몬드 두 경로 모두 stateC까지 끝까지 전파돼야 함(early-stop 없음)") + +print() +print("=== 4. invalidate 이후 Get() — 재계산은 딱 1번(중복 재계산 방지는 캐시 몫) ===") +print("stateC:Get() =", stateC:Get()) +print( + "compute 호출 횟수(stateC):", + computeCallCount.stateC, + "(2여야 함 — 1차 계산 + 이번 재계산 1번. invalidate가 2번 왔다고 재계산도 2번이면 버그)" +) +assert(computeCallCount.stateC == 2, "invalidate가 2번 와도 재계산은 캐시로 1번만 막혀야 함") + +print() +print("=== 5. :Get()을 안 부르는 Observer — 매 source 변경마다 계속 울어야 함(옛 모델 음성 대조군) ===") +do + local observerFireCount = 0 + -- state:Observer(fn)을 흉내: :Get()을 절대 호출하지 않는 순수 리스너 + stateC:OnInvalidate(function() + observerFireCount += 1 + end) + + for i = 1, 3 do + source:Set(2 + i) + end + + print("3번의 source:Set() 이후 Observer 발화 횟수:", observerFireCount, "(다이아몬드라 회당 2번씩, 6이어야 함)") + assert( + observerFireCount == 6, + "Get()을 안 부르는 Observer가 계속 안 울면(예: 2 근처에서 멈추면) 옛 '이미 dirty면 전파 중단' 모델로 회귀한 것" + ) +end + +print() +print("모든 assert 통과 — '항상 전파 + Get() 시점 캐시 dedup' 모델이 예상대로 동작함") + +--[[ + 확인 포인트: + 1. 위 assert들이 전부 통과하는가. + 2. 3번 절에서 stateC의 invalidate 로그가 "이미 dirty" 같은 조기 종료 + 문구 없이 매번 "항상 전파"로 끝까지 도는지 눈으로 확인. + 3. 5번 절이 이 파일에서 가장 중요한 회귀 방지 장치 — 옛(역전된) 모델을 + 그대로 되돌려 넣으면(Invalidate 안에 `if self.dirty then return end`를 + 부활시키면) observerFireCount가 6이 아니라 2에서 멈추고 이 assert가 + 실패해야 정상. + 4. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 + 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 + 부분은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 + 정확성"만 검증 대상. +]] diff --git a/.claude/luau-test/done/13-type-ref-preref-subtype.luau b/.claude/luau-test/done/13-type-ref-preref-subtype.luau new file mode 100644 index 0000000..7d8d0c4 --- /dev/null +++ b/.claude/luau-test/done/13-type-ref-preref-subtype.luau @@ -0,0 +1,79 @@ +--!strict +--[[ + 검증 대상(타입 전용, 런타임 대상 아님 — 절대 `luau`로 실행하지 말 것, + `luau-analyze`/`luau-lsp`로만 확인): `PreRef`/`PostRef`가 + 구조적으로 `Ref`를 만족하는지(08번 파일이 Source/State에 대해 + 검증한 것과 정확히 같은 질문). + + **[재작성, 2026-08-19]** 원래 이 파일은 타입(A)/런타임(B) 두 섹션이 + 한 파일에 있었는데, A의 더미 스텁(`fakePreRef(0)`이 `nil`을 반환)이 + 런타임에선 "attempt to index nil"로 죽어 B 섹션이 전혀 실행되지 + 못했음(`luau-test/STATUS.md` 🟠 항목). 지금은 순수 타입 전용 파일로 + 분리 — 런타임 검증은 `22-runtime-ref-preref-postref-brand.luau`가 + 담당(PostRef까지 같이 커버, ROADMAP `luau-test/13` 재작성 지침). + + 배경: `.claude/base/ref-plan.md`의 "`PostRef`" 절 — + "`isPostRef`는 `isPreRef`와 같은 층위의 가장 구체적인 항등 체크이고, + `isRef`가 그 위에 얹히는 상위 개념". + + 실행: `luau-analyze 13-type-ref-preref-subtype.luau` +]] + +export type Ref = { + Value: T, + Set: (self: Ref, value: T) -> Ref, + Callback: (self: Ref, fn: (T) -> ()) -> Ref, + Wait: (self: Ref, thread: thread?) -> Ref, +} + +-- PreRef/PostRef는 둘 다 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 +-- 문서가 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 +-- 차이는 런타임 전용이라 정적 타입엔 안 드러남) +export type PreRef = { + Value: T, + Set: (self: PreRef, value: T) -> PreRef, + Callback: (self: PreRef, fn: (T) -> ()) -> PreRef, + Wait: (self: PreRef, thread: thread?) -> PreRef, +} + +export type PostRef = { + Value: T, + Set: (self: PostRef, value: T) -> PostRef, + Callback: (self: PostRef, fn: (T) -> ()) -> PostRef, + Wait: (self: PostRef, thread: thread?) -> PostRef, +} + +local function fakePreRef(default: T): PreRef + return (nil :: any) :: PreRef +end + +local function fakePostRef(default: T): PostRef + return (nil :: any) :: PostRef +end + +-- 시도: PreRef/PostRef 값을 Ref가 필요한 자리에 그대로 넘길 수 있는가 +local function useAsRef(r: Ref): T + return r.Value +end + +local myPreRef: PreRef = fakePreRef(0) +local viaPreRefSubtype: number = useAsRef(myPreRef) -- <- luau-analyze 확인 포인트 1 +print(viaPreRefSubtype) + +local myPostRef: PostRef = fakePostRef(0) +local viaPostRefSubtype: number = useAsRef(myPostRef) -- <- luau-analyze 확인 포인트 2 +print(viaPostRefSubtype) + +-- 음성 대조군 — 타입이 실제로 강제되는지(0 진단이 곧 안전은 아니므로) +local _wrongPreRef: string = useAsRef(myPreRef) -- 반드시 에러: number를 string으로 +local _wrongPostRef: string = useAsRef(myPostRef) -- 반드시 에러: number를 string으로 + +--[[ + 확인 포인트: + 1. `viaPreRefSubtype`/`viaPostRefSubtype` 줄이 에러 없이 통과하는가 — + 구조적 서브타이핑이 PreRef/PostRef 둘 다에 성립하는지. + 2. 음성 대조군 두 줄이 정확히 TypeError로 걸리는가(안 걸리면 이 + 스파이크 자체가 아무것도 검증 못 하고 있다는 뜻). + 3. 이 파일은 절대 `luau`로 실행하지 말 것 — `fakePreRef`/`fakePostRef`가 + 런타임엔 `nil`이라 즉시 크래시함(의도된 것, 타입 전용). +]] diff --git a/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau b/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau new file mode 100644 index 0000000..2081222 --- /dev/null +++ b/.claude/luau-test/done/21-type-store-undeclared-key-rejected.luau @@ -0,0 +1,58 @@ +--!strict +--[[ + 검증 대상: `Store<{field: T}>`로 선언되지 않은 이름에 dot-access하면 + 타입 시간에 실제로 거부되는지 — `.claude/base/store-plan.md` "Store = + Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" + 항목, 사용자 원문: *"Store<{ field: type }> 상 없는 네임에는 타입 + 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 + 설계가 그럴것이예요"* — "아마"라 M0에서 실측 확인하라고 명시됨. + `todos.md` 00번 항목 / `ROADMAP.md` M0 목록. + + `16-type-store-key-typefunction.luau`가 이미 검증한 `ProcessStoreType` + (type function 기반 `Store` 합성)을 그대로 재사용 — 새로 검증하는 + 건 그 결과 타입이 **인덱서 없는 순수 레코드**라 미선언 프로퍼티 + 접근을 실제로 거부하는지 하나뿐. + + 실행: `luau-analyze 21-type-store-undeclared-key-rejected.luau` — 양성 + 경로(선언된 키 3개)는 클린, 미선언 키 접근 2건(읽기/메소드 호출)만 + TypeError로 걸려야 함. +]] + +export type Source = { + Get: (self: Source) -> T, + Set: (self: Source, value: T) -> (), +} + +type function WrapStore(ty: type): type + local result = types.newtable() + result:setproperty(types.singleton("Get"), types.newfunction({ head = { result } }, { head = { ty } })) + result:setproperty(types.singleton("Set"), types.newfunction({ head = { result, ty } }, { head = {} })) + return result +end + +type function ProcessStoreType(ty: type): type + local props = ty:properties() :: { [type]: { read: type?, write: type? } } + local result = types.newtable() + for i, v in props do + local fieldType = v.read or v.write + if fieldType then + result:setproperty(i, WrapStore(fieldType)) + end + end + return result +end + +type Input = { ty: string, count: number } +type Processed = ProcessStoreType + +local processed: Processed = nil :: any + +-- 양성 경로 — 선언된 키는 정상 접근 +local okTy: string = processed.ty:Get() +local okCount: number = processed.count:Get() +processed.ty:Set("hi") +print(okTy, okCount) + +-- 검증 포인트 — 선언 안 된 키에 접근하면 타입 에러가 나야 함 +print(processed.doesNotExist) -- 반드시 에러: 'doesNotExist'는 Processed에 없는 프로퍼티 +processed.alsoMissing:Get() -- 반드시 에러: 'alsoMissing'도 없는 프로퍼티(체이닝된 :Get() 호출까지 안 감) diff --git a/.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau b/.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau new file mode 100644 index 0000000..a4dfbad --- /dev/null +++ b/.claude/luau-test/done/22-runtime-ref-preref-postref-brand.luau @@ -0,0 +1,113 @@ +--[[ + 검증 대상(런타임): `isRef`/`isPreRef`/`isPostRef` predicate 합성이 + `base/ref-plan.md`/`base/brand-plan.md`에 적힌 대로 동작하는지 — + `isPreRef`/`isPostRef`는 같은 층위의 가장 구체적인 항등(서로 + 배타적 형제)이고 `isRef`가 그 위에 얹히는 상위 개념(OR로 합성). + 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가 + `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 **명시적으로 + 좁혀야만** PreRef/PostRef를 잘못 삼키지 않는다는 것. + + **[2026-08-19 신설]** `13-type-ref-preref-subtype.luau`(타입 전용)의 + 런타임 절반을 분리 + PostRef까지 확장한 것 — 원본은 PreRef만 다뤘고 + 타입/런타임이 한 파일에 있어 A의 더미 스텁이 B의 실행을 막았음 + (`luau-test/STATUS.md` 🟠 항목). PostRef 확장은 ROADMAP의 재작성 + 지침("같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 + 그대로 확장"). + + 배경: `.claude/base/ref-plan.md`의 "`PostRef`" 절 + ("`isPostRef`는 `isPreRef`와 같은 층위의 가장 구체적인 항등 체크이고, + `isRef`가 그 위에 얹히는 상위 개념 — `Dispatch/Leaf.luau`의 일반 Ref + 매치는 이제 `isRef(v) and not isPreRef(v) and not isPostRef(v)`"). + + 실행: `luau 22-runtime-ref-preref-postref-brand.luau` +]] + +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, PostRefTag = {}, {}, {} + +local function isPreRef(x) + return Brand.get(x) == PreRefTag +end +local function isPostRef(x) + return Brand.get(x) == PostRefTag +end +local function isRef(x) + -- PreRef/PostRef는 Ref의 하위 개념(둘 다 OR로 얹음), 서로는 배타적 형제 + return isPreRef(x) or isPostRef(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 function makePostRef() + local self = {} + Brand.set(self, PostRefTag) + return self +end + +local ref1 = makeRef() +local preref1 = makePreRef() +local postref1 = makePostRef() + +print("=== 1. isRef/isPreRef/isPostRef 기본 동작 ===") +print("isRef(ref1) =", isRef(ref1), "(true여야 함)") +print("isPreRef(ref1) =", isPreRef(ref1), "(false — Ref는 PreRef가 아님)") +print("isPostRef(ref1) =", isPostRef(ref1), "(false — Ref는 PostRef가 아님)") +print("isRef(preref1) =", isRef(preref1), "(true — PreRef는 Ref의 하위 개념)") +print("isPreRef(preref1) =", isPreRef(preref1), "(true)") +print("isPostRef(preref1) =", isPostRef(preref1), "(false — PreRef/PostRef는 서로 배타적 형제)") +print("isRef(postref1) =", isRef(postref1), "(true — PostRef도 Ref의 하위 개념)") +print("isPreRef(postref1) =", isPreRef(postref1), "(false — 서로 배타적 형제)") +print("isPostRef(postref1) =", isPostRef(postref1), "(true)") + +assert(isRef(ref1) == true) +assert(isPreRef(ref1) == false) +assert(isPostRef(ref1) == false) +assert(isRef(preref1) == true, "PreRef는 isRef를 통과해야 함 (2026-08-09 재정정의 핵심)") +assert(isPreRef(preref1) == true) +assert(isPostRef(preref1) == false, "PreRef/PostRef는 서로 배타적이어야 함") +assert(isRef(postref1) == true, "PostRef도 isRef를 통과해야 함") +assert(isPreRef(postref1) == false, "PreRef/PostRef는 서로 배타적이어야 함") +assert(isPostRef(postref1) == true) + +-- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef/PostRef를 +-- 잘못 삼키면 안 됨(둘 다 자기 전담 핸들러가 따로 처리) +local function leafRefHandlerIsHandlable(v) + return isRef(v) and not isPreRef(v) and not isPostRef(v) +end + +print() +print("=== 2. Leaf의 (v=Ref) 핸들러가 PreRef/PostRef를 잘못 삼키지 않는가 ===") +print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)") +print("leafRefHandlerIsHandlable(preref1) =", leafRefHandlerIsHandlable(preref1), "(false — PreRef 전담 핸들러 몫)") +print("leafRefHandlerIsHandlable(postref1) =", leafRefHandlerIsHandlable(postref1), "(false — PostRef 전담 핸들러 몫)") + +assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)") +assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그)") +assert(leafRefHandlerIsHandlable(postref1) == false, "PostRef가 Leaf 핸들러에 잘못 잡힘 (버그)") + +print() +print("모든 assert 통과 — isRef(v) and not isPreRef(v) and not isPostRef(v) 조합이 기대대로 동작함") + +--[[ + 확인 포인트: + 1. 위 assert가 전부 통과하는가. + 2. `isPreRef(postref1)`/`isPostRef(preref1)`이 둘 다 `false` — + PreRef/PostRef가 서로를 오인하지 않는 진짜 배타적 형제인지가 이 + 파일이 13번(PreRef만 다뤘던 원본)보다 새로 넓힌 부분. +]] diff --git a/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau b/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau new file mode 100644 index 0000000..9677551 --- /dev/null +++ b/.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau @@ -0,0 +1,89 @@ +--!strict +--[[ + 검증 대상(타입 전용): `quad-types`의 `CheckedQuad`(버전 + 패턴 체크, `type-version-check` 패키지 위에 얹힘)가 실제 `quad-base`가 + 구현하는 `Quad` 타입과 맞물려 동작하는지 — 그리고 검증 후에도 + `AddPlugin`의 제네릭 체이닝이 안 깨지는지. + + 배경: 2026-08-19 세션 대화 — quad-roblox가 `QuadRoblox(Quad): + QuadRoblox`처럼 quad-base 인스턴스를 **런타임 주입**으로 받게 되면서, + pesde의 semver 충돌 방지가 이 관계엔 안 걸리게 됨(선언된 의존성이 + 아니라 그냥 함수 인자라서) — 그 빈 자리를 메꾸는 컴파일 타임 버전 + 체크. `quad-types/src/init.luau`의 `CheckedQuad` 절, + `type-version-check/src/init.luau`의 `CheckVersion` 절 참고. + + **이 파일이 재현하는 핵심 함정(처음 시도했다가 깨진 것들)**: + 1. `error()`는 못 씀 — type function 자체가 실패한 걸로 판정돼서 + 버려짐. `print(...)` + `return types.never` 조합만 + "TypeError: <메시지>"로 정확히 뜬다. + 2. 체크를 함수 본문 안의 로컬 타입 별칭으로 두면(`type _Check = + CheckVersion`) 제네릭이 인스턴스화될 때마다 재평가되지 않아 + 진단이 아예 안 뜬다 — 리턴 타입/필드 타입처럼 **호출부마다 실제로 + 해석돼야 하는 자리**에 박아 넣어야 함. + 3. **[가장 중요]** `CheckVersion`가 `T`를 단순 패스스루(`return + t`)해도, 그 결과 타입이 **한 번이라도 type function을 거쳤다는 + 이력만으로** 이후 `AddPlugin` 같은 제네릭 self 메소드 + 체이닝이 조용히 깨짐. 검증 결과는 **원본 타입과 절대 안 섞이는 + 별도 가상 필드**(`__versionCheck`)로 격리해야 하고, 그 필드를 + 실제로 참조해야만 평가가 일어난다(lazy) — 이 파일의 `_forceCheck` + 줄이 그 필수 스텝을 보여줌. + 4. **[신규]** cross-package 사용에는 `type function` 선언에 `export`가 + 필요하다(`type function`이 아니라 `export type function`) — 안 + 그러면 다른 파일에서 `Unknown type 'Module.CheckVersion'`으로 + 막힘. 그리고 명시적 제네릭 인스턴스화가 **2개 이상**이면 단일 + 꺾쇠(`Foo`)가 비교 연산자로 오파싱돼 반드시 이중 꺾쇠 + (`Foo<>`)를 써야 한다(코퍼스에 이미 있던 `AttributeKey<>` + 관례와 같은 이유). + + 실행: `luau-analyze 23-type-quadtypes-checkversion-addplugin.luau` +]] + +local QuadTypes = require("../../../quad-types/src") + +type RobloxExt = { Frame: (self: any) -> string } + +-- quad-roblox가 실제로 쓰게 될 패턴 — 검증과 확장은 별도 스텝(합치면 3번 +-- 함정에 걸림). 여기선 quad-base와 정확히 같은 버전만 허용("0.0.0" 그대로) — +-- quad-spring-roblox류 느슨한 소비자는 "0.*.*" 같은 패턴을 대신 씀. +local function CheckQuad(quad: T): QuadTypes.CheckedQuad + return quad :: any +end + +local function installRobloxBackend(_self: QuadTypes.Quad): RobloxExt + return nil :: any +end + +-- 실제 quad-base가 구현하는 Quad 타입 그대로(버전 일치) +local goodQuad: QuadTypes.Quad = nil :: any + +-- 다른 버전을 흉내 — 구조는 같지만 Version 리터럴만 다름 +local badQuad = { + Version = "9.9.9" :: "9.9.9", + debug = false, + New = (nil :: any) :: () -> any, + RunInit = (nil :: any) :: (self: any, initFn: (any) -> any) -> (), + AddPlugin = (nil :: any) :: (self: Self, pluginFn: (Self) -> P) -> Self & P, +} + +-- 1) 버전이 맞으면: 강제 참조 후 통과 + AddPlugin 체이닝까지 그대로 살아있어야 함 +local checked = CheckQuad(goodQuad) +local _forceCheck = checked.__versionCheck -- ⚠️ 필수 — 없으면 검증이 조용히 스킵됨 + +local withRoblox = checked:AddPlugin(installRobloxBackend) +local okFrame: string = withRoblox:Frame() +local okDebug: boolean = withRoblox.debug +print(okFrame) + +type SpringPlugin = { Spring: (self: any, v: number) -> number } +local function springFn(_self: QuadTypes.Quad): SpringPlugin + return nil :: any +end +local withSpring = withRoblox:AddPlugin(springFn) +local spring: number = withSpring:Spring(1) +local stillFrame: string = withSpring:Frame() -- 먼저 추가한 RobloxExt도 안 사라짐 + +-- 2) 버전이 다르면 강제 참조 시점에 TypeError가 나야 함(음성 대조군) +local checkedBad = CheckQuad(badQuad) +local _forceCheckBad = checkedBad.__versionCheck + +print(okDebug, spring, stillFrame) diff --git a/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau b/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau deleted file mode 100644 index 7e587b0..0000000 --- a/.claude/luau-test/rewrite-required/05-store-state-diamond-propagation.luau +++ /dev/null @@ -1,174 +0,0 @@ ---[[ - !!! [2026-08-14] 재작성 대기 — 아래 코드는 폐기된 모델을 검증 중 !!! - - 이 파일은 "이미 dirty로 표시된 노드는 더 아래로 전파하지 않는다"를 - assert하는데, 그 규칙이 확정된 Observer 계약(fn이 :Get()을 안 불러도 - 됨)과 모순돼 역전됐음. 통과했다고 해서 현행 설계를 검증한 게 아님. - - 역전 근거/영향 범위: archive/invalidate-dedup-propagation-reversed.md - - 현행 모델: base/bind-system-plan.md "전파 모델 확정" / - "다이아몬드 의존성은 무엇이 푸는가" - - 재작성 방향(STATUS.md와 동일): - 1. emit은 자기 invalid 상태와 무관하게 *항상* 전파되는가 - 2. 중복 재계산은 :Get() 시점 캐시로만 막히는가 (재계산 1회는 그대로 유효) - 3. :Get()을 안 부르는 Observer가 매 변경마다 계속 울리는가 - — 옛 모델에선 두 번째부터 침묵했으므로 이게 딱 맞는 음성 대조군 - - --- 이하 원문(옛 모델) --- - - 검증 대상: Store/State의 push-invalidate(신호만) / pull-recompute(Get() - 시점 재계산) 전파 모델이 다이아몬드 의존성에서 정확히 동작하는지. - - 배경: ROADMAP.md M0 1번째 항목 "Store/State push-invalidate -> - pull-recompute propagation을 실제로 짜보기(다이아몬드 의존성 케이스 - 포함 — 이미 invalid면 전파 중단되는지)". - - 다이아몬드 구조: - source - / \ - stateA stateB - \ / - stateC (:With(stateA, stateB):Compute(...)) - - 검증할 것 두 가지: - 1. source가 바뀌면 invalidate 신호가 stateA/stateB를 거쳐 stateC까지 - 전파되는데, "이미 dirty로 표시된 노드는 더 이상 아래로 전파하지 - 않는다"는 방어가 있어야 다이아몬드에서 stateC가 두 경로로 두 번 - invalidate 신호를 받아도 문제없이 처리됨(도달 자체는 두 번 일어나되, - 두 번째는 즉시 조기 종료돼야 함). - 2. stateC:Get()을 실제로 호출했을 때, compute 함수가 정확히 1번만 - 실행되는가(다이아몬드 때문에 stateA 경로/stateB 경로 각각 한 번씩 - 총 2번 이상 실행되면 버그). - - 실행: `luau 05-store-state-diamond-propagation.luau` -]] - -local function makeSource(initial) - local self = { value = initial, listeners = {} } - function self:Get() - return self.value - end - function self:Set(v) - self.value = v - self:Invalidate() - end - function self:Invalidate() - -- source 자신은 dirty 개념이 없음(항상 최신) — 그냥 리스너에게 신호만 쏨 - for _, fn in self.listeners do - fn() - end - end - function self:OnInvalidate(fn) - table.insert(self.listeners, fn) - end - return self -end - -local invalidateCallCount = { stateA = 0, stateB = 0, stateC = 0 } -local computeCallCount = { stateA = 0, stateB = 0, stateC = 0 } - -local function makeState(name, deps, computeFn) - local self = { - name = name, - dirty = true, -- 처음엔 아직 계산 안 됐으니 dirty - cached = nil, - listeners = {}, - } - function self:Invalidate() - invalidateCallCount[name] += 1 - if self.dirty then - -- 핵심: 이미 dirty면 더 아래로 전파하지 않음(다이아몬드 방어) - print(string.format(" [%s] 이미 dirty -> 전파 중단", name)) - return - end - print(string.format(" [%s] dirty로 표시, 아래로 전파", name)) - self.dirty = true - for _, fn in self.listeners do - fn() - end - end - function self:OnInvalidate(fn) - table.insert(self.listeners, fn) - end - function self:Get() - if self.dirty then - computeCallCount[name] += 1 - print(string.format(" [%s] pull-recompute 실행 (총 %d번째)", name, computeCallCount[name])) - local args = {} - for i, d in deps do - args[i] = d:Get() - end - self.cached = computeFn(table.unpack(args)) - self.dirty = false - else - print(string.format(" [%s] 캐시된 값 그대로 반환(재계산 없음)", name)) - end - return self.cached - end - for _, d in deps do - d:OnInvalidate(function() - self:Invalidate() - end) - end - return self -end - -local source = makeSource(1) -local stateA = makeState("stateA", { source }, function(v) - return v + 10 -end) -local stateB = makeState("stateB", { source }, function(v) - return v + 100 -end) -local stateC = makeState("stateC", { stateA, stateB }, function(a, b) - return a + b -end) - -print("=== 1. 최초 Get() — 전부 계산돼야 함 ===") -print("stateC:Get() =", stateC:Get()) -print("compute 호출 횟수:", computeCallCount.stateA, computeCallCount.stateB, computeCallCount.stateC) -assert( - computeCallCount.stateA == 1 and computeCallCount.stateB == 1 and computeCallCount.stateC == 1, - "최초 계산 횟수가 예상과 다름" -) - -print() -print("=== 2. 재차 Get() — 캐시만 반환, 재계산 없어야 함 ===") -print("stateC:Get() =", stateC:Get()) -assert(computeCallCount.stateC == 1, "invalidate 안 했는데 재계산이 일어남 (버그)") - -print() -print("=== 3. source:Set() -> 다이아몬드 invalidate 전파 ===") -source:Set(2) -print( - "invalidate 호출 횟수(stateC):", - invalidateCallCount.stateC, - "(stateA 경로 1번 + stateB 경로 1번 = 2번 호출은 정상, 단 2번째는 즉시 'already dirty'로 중단돼야 함)" -) - -print() -print("=== 4. invalidate 이후 Get() — 정확히 1번만 재계산되는가 ===") -print("stateC:Get() =", stateC:Get()) -print( - "compute 호출 횟수(stateC):", - computeCallCount.stateC, - "(2여야 함 — 1차 계산 + 이번 재계산, 3 이상이면 다이아몬드 중복 재계산 버그)" -) -assert(computeCallCount.stateC == 2, "다이아몬드 의존성 때문에 stateC가 여러 번 재계산됨 (버그)") - -print() -print("모든 assert 통과 — 다이아몬드 전파/재계산 모델이 예상대로 동작함") - ---[[ - 확인 포인트: - 1. 위 assert들이 전부 통과하는가(하나라도 실패하면 error로 죽고 스택 - 트레이스가 찍힘 — 그대로 알려줄 것). - 2. invalidateCallCount.stateC가 정확히 2(stateA 경로, stateB 경로 각각 - 1번씩 도달)이지만, 그 중 두 번째 호출은 "이미 dirty" 로그로 조기 - 종료되는지 눈으로 확인. - 3. 이 스파이크는 실제 :With/:Compute API 모양이 아니라 최소 골격만 - 흉내낸 것 — 실제 구현 시 self/deps를 State 핸들로 lazy하게 넘기는 - 부분(.claude/base/bind-system-plan.md "Store/State/Source 온톨로지" - 절)은 여기 반영 안 돼 있음, 이 파일은 오직 "전파 알고리즘 자체의 - 정확성"만 검증 대상. -]] diff --git a/.claude/luau-test/rewrite-required/13-type-ref-preref-subtype.luau b/.claude/luau-test/rewrite-required/13-type-ref-preref-subtype.luau deleted file mode 100644 index 8244063..0000000 --- a/.claude/luau-test/rewrite-required/13-type-ref-preref-subtype.luau +++ /dev/null @@ -1,140 +0,0 @@ ---!strict ---[[ - 검증 대상: 2026-08-09 열한 번째 세션(커밋 f198fd9)에서 뒤집힌 결정 — - `isRef`/`isPreRef`가 "서로 배타적인 형제 브랜드"에서 "Source가 State를 - 만족하는 것과 같은 포함 관계(PreRef가 Ref의 하위 개념)"로 재정정됨. - 이전엔 `isRef(preRefInstance) == false`였는데, 지금은 - `isRef(preRefInstance) == true`로 바뀜. - - 이 파일은 두 부분으로 나뉨: - A) 타입 체크 대상 — `PreRef`가 구조적으로 `Ref`를 만족하는지 - (08번 파일이 Source/State에 대해 검증한 것과 정확히 같은 질문을 - Ref/PreRef에 대해 재검증). - B) 런타임 대상 — `isRef`/`isPreRef` predicate 합성이 문서에 적힌 대로 - 동작하는지, 그리고 `Dispatch/Leaf.luau`의 `(v=Ref)` 매치 핸들러가 - 이제 `isHandlable = isRef(v) and not isPreRef(v)`로 **명시적으로 - 좁혀야만** PreRef를 잘못 삼키지 않는다는 것. - - 배경: .claude/base/brand-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` - - [정정, 2026-08-13 첫 실측 라운드] 위 "런타임 부분은 그냥 통과함" 예상은 - 틀렸음 — 실제로 돌려보니 A섹션의 `fakePreRef(0)`가 런타임에 `nil`을 - 반환하는 더미 스텁이라, B섹션이 시작하기 전에 `useAsRef(myPreRef)`의 - `r.Value` 접근에서 "attempt to index nil"로 죽어 B섹션 런타임 검증까지 - 도달하지 못함. 타입 A섹션(luau-analyze)은 별개로 통과. 상세는 - `luau-test/STATUS.md` 🟠 항목 — A/B를 별도 파일로 분리해야 함(재작성 - 대기). -]] - --- ===== A) 타입 체크 대상 ===== - -export type Ref = { - Value: T, - Set: (self: Ref, value: T) -> Ref, - Callback: (self: Ref, fn: (T) -> ()) -> Ref, - Wait: (self: Ref, thread: thread?) -> Ref, -} - --- PreRef는 "Ref 런타임을 재사용하되 브랜드 태그만 다름"이라고 문서가 --- 명시함 — 타입도 필드 구성이 완전히 동일해야 자연스러움(브랜드 차이는 --- 런타임 전용이라 정적 타입엔 안 드러남, 아래서 별도 nominal 표시로만 구분) -export type PreRef = { - Value: T, - Set: (self: PreRef, value: T) -> PreRef, - Callback: (self: PreRef, fn: (T) -> ()) -> PreRef, - Wait: (self: PreRef, thread: thread?) -> PreRef, -} - -local function fakePreRef(default: T): PreRef - return (nil :: any) :: PreRef -end - --- 시도: PreRef 값을 Ref가 필요한 자리에 그대로 넘길 수 있는가 -local function useAsRef(r: Ref): T - return r.Value -end - -local myPreRef: PreRef = fakePreRef(0) -local viaSubtype: number = useAsRef(myPreRef) -- <- 여기가 luau-analyze 확인 포인트 - -print("A) 타입 체크는 luau-analyze/luau-lsp로 확인 — 런타임은 그냥 통과") -print(viaSubtype) - --- ===== B) 런타임 대상 — Brand/isRef/isPreRef predicate 합성 ===== - -local Brand = {} -local registry = setmetatable({}, { __mode = "k" }) -function Brand.set(x, tag) - registry[x] = tag -end -function Brand.get(x) - return registry[x] -end - -local RefTag, PreRefTag = {}, {} - -local function isPreRef(x) - return Brand.get(x) == PreRefTag -end -local function isRef(x) - -- 재정정된 합성 — PreRef가 Ref의 하위 개념(OR로 얹음) - return isPreRef(x) or Brand.get(x) == RefTag -end - -local function makeRef() - local self = {} - Brand.set(self, RefTag) - return self -end -local function makePreRef() - local self = {} - Brand.set(self, PreRefTag) - return self -end - -local ref1 = makeRef() -local preref1 = makePreRef() - -print() -print("=== B-1. isRef/isPreRef 기본 동작 ===") -print("isRef(ref1) =", isRef(ref1), "(true여야 함)") -print("isPreRef(ref1) =", isPreRef(ref1), "(false여야 함 — Ref는 PreRef가 아님)") -print("isRef(preref1) =", isRef(preref1), "(true여야 함 — 2026-08-09 재정정의 핵심)") -print("isPreRef(preref1) =", isPreRef(preref1), "(true여야 함)") - --- Dispatch/Leaf.luau의 (v=Ref) 매치 핸들러 흉내 — PreRef를 잘못 삼키면 안 됨 -local function leafRefHandlerIsHandlable(v) - return isRef(v) and not isPreRef(v) -end - -print() -print("=== B-2. Leaf의 (v=Ref) 핸들러가 PreRef를 잘못 삼키지 않는가 ===") -print("leafRefHandlerIsHandlable(ref1) =", leafRefHandlerIsHandlable(ref1), "(true — 일반 Ref는 처리해야 함)") -print( - "leafRefHandlerIsHandlable(preref1) =", - leafRefHandlerIsHandlable(preref1), - "(false여야 함 — PreRef는 pre-pass가 이미 처리했어야 하고, 이 핸들러가 또 삼키면 안 됨)" -) - -assert(leafRefHandlerIsHandlable(ref1) == true, "일반 Ref가 Leaf 핸들러에서 거부됨 (버그)") -assert(leafRefHandlerIsHandlable(preref1) == false, "PreRef가 Leaf 핸들러에 잘못 잡힘 (버그 — 2026-08-09 재정정이 요구하는 명시적 좁히기 실패)") -print() -print("assert 전부 통과 — isRef(v) and not isPreRef(v) 조합이 기대대로 동작함") - ---[[ - 확인 포인트: - A) luau-analyze/luau-lsp에서 `viaSubtype` 줄이 에러 없이 통과하는가 — - 08번 파일이 Source/State에 대해 확인했던 것과 같은 결론(구조적 - 서브타이핑 성립)이 Ref/PreRef에도 그대로 적용되는지. - B) 런타임 assert가 전부 통과하는가 — 특히 `isRef(preref1) == true` - (뒤집힌 결정 자체)와 `leafRefHandlerIsHandlable(preref1) == false` - (그 뒤집힘 때문에 Leaf 핸들러가 이제 반드시 `not isPreRef(v)`를 - 같이 확인해야 한다는 요구사항)가 실제로 필요한 조합인지. -]] diff --git a/.claude/project-context.md b/.claude/project-context.md index 43fe517..7bb08e8 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -10,10 +10,13 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터 지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은 길게 잡음. -**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전**(M0에 -착수하면 루트 `CLAUDE.md` 머리말도 같이 고칠 것 — 같은 상태를 두 곳이 -서술하고 있음) — 저장소 루트에 실제 소스 -코드(`src/` 등)가 없음. 핵심 아키텍처(Store 책임 분리, `process`/`retract` +**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치 +엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도 +같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에 +`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/ +`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는 +아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가 +소스. 핵심 아키텍처(Store 책임 분리, `process`/`retract` 디스패치 모델, Store/State/Source 온톨로지, 소스 트리 구조, Modifier 메커니즘, 컴포넌트=플레인 함수, 컴포넌트 경계 modifier/Ref 전달)는 전부 `.claude/base/`에 문서로 확정돼 있음 — 먼저 `.claude/base/architecture.md`를 읽을 것. 사용자가 @@ -73,8 +76,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/ `pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새 - 파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, - **[2026-08-18 기준] 폴더 자체가 아직 없음**. + 파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — **실사용** + 피드백용(M0/M1 스캐폴딩이 아니라 실제로 렌더링해보고 쓰는 단계부터), + **[2026-08-19 기준] 폴더 자체가 아직 없음** — 첫 피드백이 생길 때 만들면 됨. `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index a7db24e..19b7b05 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1535,3 +1535,77 @@ ERROR 0. 드러나 **순수 슈가로 재평가**(옛 "실제 기능 갭이라 우선순위 위" 서술 철회). `ROADMAP.md`/`question.md`/`todos.md`/`README.md`/ `source-state-plan.md`/`blocker-plan.md` 전량 반영. + +## 2026-08-19 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정 + +원문: `session/2026-08-19-04-pesde-migration-and-project-setup.md` + +`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 revert 가능한 상태로 실제로 +짜보는 시도 — `quad-base/`/`quad-roblox/` 폴더, `Relate.luau` 전량 구현, +`Debug/init.luau` + 최상위 `init.luau`, mock+스모크 테스트까지 작성. 그 +과정에서 크로스파일 require가 전부 깨지는 진짜 버그를 찾았으나 원인 +진단은 처음에 틀렸음(CWD 기준설로 오판) — 사용자가 Luau RFC를 근거로 +`init.luau`는 `@self/X`가 필요한 특수 케이스라고 직접 정정. 이어서 사용자 +결정으로 패키지 매니저를 wally에서 pesde로 전환, tbox 참고 후 실제 설치해 +워크스페이스 전체를 검증. 산출물은 `base/project-setup-plan.md`(신설)와 +`architecture.md`의 "패키징 방식" 절 정정. + +**커밋 후 후속(같은 세션, §5)**: 사용자가 "전부 차근차근"이라고 답해 이어서 +4가지 진행 — Rojo를 직접 설치해 `rojo sourcemap`/`rojo build`가 워크스페이스 +symlink를 투명하게 따라감을 확인(Luau standalone CLI 전용 문제였음을 +재확인, Studio 실물 검증만 계정 분리 대기로 남음), 스파이크 `13`을 +타입 전용/런타임(`22` 신규) 두 파일로 분리해 `PreRef`/`PostRef` 배타성까지 +검증, `luau-lsp`를 직접 설치해 새 Luau 솔버 필요성을 재귀 제네릭 스파이크로 +재확인하고 `.vscode/settings.json`에 반영, 스파이크 `05`/`21` STATUS.md +텍스트 갱신. + +## 2026-08-19 — rokit→mise 전환, selene 린터 도입, darklua 검토 후 기각 + +원문: `session/2026-08-19-05-mise-migration-and-selene-linter.md` + +사용자가 참고 GitHub 레포 `Word30210/roblox-project-example`의 +`mise.toml`을 제시("요즘은 rokit보단 mise로 까는듯") → 클론해 구조 전체를 +훑고 mise 전환/selene 린터/darklua/Justfile 네 후보를 멀티셀렉트로 제시, +사용자는 mise 전환과 selene만 채택. darklua는 사용자가 직접 반박해 보류 +— "Roblox 엔진 자체가 이미 `@self`/`@game` string require를 지원하므로 +변환 계층이 불필요"라는 논거. mise 전환은 GitHub artifact attestation + +SLSA provenance 검증까지 거쳐 실제 설치·검증 완료. + +## 2026-08-19 — `RunInit` 재설계, darklua 경계 정밀화, 한국어 진행 합의 + +원문: `session/2026-08-19-06-runinit-redesign-and-darklua-precision.md` + +사용자 요청 두 가지: (1) darklua 기각 근거를 실측으로 정밀화 — 직접 +설치해 돌려보니 `@self`/`@game`은 안 건드리고 커스텀 `.luaurc` alias만 +변환한다는 정확한 경계 확인(지금은 불필요하지만 나중에 축약 alias를 쓰면 +필요해질 수 있음을 `project-setup-plan.md`에 반영). (2) `New()`의 멱등 +Init 가드를 파일마다 `Relate`+센티널을 두는 대신 **함수 자체를 릴레이션 +키로 쓰는 공유 `module:RunInit(initFn)`**로 재설계 — 실제 구현+3개 +시나리오 스모크 테스트까지 완료. 이후 대화를 한국어로 진행하기로 합의. + +## 2026-08-19 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계 + +원문: `session/2026-08-19-07-quad-types-package-addplugin-checkversion.md` + +`RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리 확정한 뒤, +사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면 +dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고 +`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설, +`AddPlugin` 플러그인 체이닝과 `CheckedQuad` 컴파일 타임 +버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 "값이 한 번이라도 +`type function`을 거치면 이후 제네릭 self 체이닝이 조용히 깨진다"는 새 +Luau 함정을 발견해 `typing-limits.md` §6으로 승격. + +## 2026-08-19 — `type-version-check` 패키지 추출, `CheckedQuad` 확장 + +원문: `session/2026-08-19-08-type-version-check-package-extraction.md` + +직전 세션의 `CheckedQuad`(정확 버전 일치만)가 `quad-spring`/ +`quad-spring-roblox`류 독립 게시 플러그인엔 너무 빡빡하다는 사용자 지적 +→ 글롭(`"*"`)/캐럿(`"N^"`) 패턴을 지원하는 `CheckedQuad`으로 +확장. 버전 매칭 로직 자체는 quad에 종속되지 않은 범용 워크스페이스 멤버 +`type-version-check`로 분리(사용자 지시: 지금은 모노레포 안에 두고 독립 +저장소 분리는 나중에 직접 — `HUMAN_TODO.md` 9번). 새 Luau 함정 2건 발견 +(`type function`은 outer local 참조 불가, cross-package엔 `export type +function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로 +구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋. diff --git a/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md b/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md new file mode 100644 index 0000000..cdf78aa --- /dev/null +++ b/.claude/session/2026-08-19-04-pesde-migration-and-project-setup.md @@ -0,0 +1,146 @@ +# 2026-08-19, 네 번째 세션 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정 + +**요약**: 사용자 요청으로 M0(스파이크)/M1(스캐폴딩)을 revert 가능한 +상태로 실제로 짜보며 문제를 찾는 시도. 그 과정에서 진짜 문제(require +경로 버그)를 하나 찾았는데 원인 진단이 틀렸었고, 사용자가 직접 정답 +(`@self`)을 지목해 정정. 이어서 사용자가 패키지 매니저를 wally에서 +pesde로 바꾸자고 결정, tbox 참고 후 실제 pesde를 설치해 워크스페이스 +전체를 검증. 산출물은 `.claude/base/project-setup-plan.md`(신설)와 +`architecture.md`의 "패키징 방식" 절 정정. + +## 1. M0/M1 첫 실제 시도 + +`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 실제로 Luau로 짜봄: +- M0 항목 3(재귀 재-dispatch)은 기존 스파이크 `03`을 재실행해 여전히 + 유효함만 확인(재작성 불필요). +- M0 항목 1(다이아몬드 전파)은 `05-store-state-diamond-propagation.luau`를 + "emit은 항상 전파 + `:Get()` 시점 캐시로만 dedup" 현행 모델로 재작성, + 통과 후 `done/`으로 이동. +- `todos.md`가 M0 항목으로 요구하던 "Store 미선언 키가 타입 에러 나는가"도 + 새 스파이크 `21`로 확인 — `luau-analyze`가 정확히 2건의 `TypeError`로 + 거부함을 확인(사용자의 "아마 그럴 것" 추측이 맞았음). +- M1: `quad-base/`, `quad-roblox/` 폴더, `Relate.luau`(전량 구현), + `Debug/init.luau` + 최상위 `init.luau`(`New()`/`InitXxx` 팩토리 + 체이닝 + `Relate` 기반 멱등 가드), `quad-base/test/mock.luau`(최소 + mock) + 스모크 테스트까지 작성. + +## 2. require 버그 — 원인을 잘못 짚었다가 사용자가 정정 + +`quad-base/src/init.luau`(`require("./Debug")`)가 크로스파일 require +전부 실패. 여러 각도로 재현하다 "standalone `luau` CLI가 relative +require를 **process CWD** 기준으로 푼다"는 결론을 냈고, 이 결론으로 +첫 보고를 마쳤음(**틀린 진단**). + +사용자가 바로잡음: *"init.luau 는 상위 폴더를 자신으로 만들어낸다는 +의미라, @self 로 주변 요소를 접근해야해"* + Luau 공식 문서 링크 제공. +`rfcs.luau.org/abstract-module-paths-and-init-dot-luau`를 확인한 결과: +`init.luau`는 require-by-string 상 **자기가 든 폴더 자체**를 가리키는 +특수 케이스라, 그 안의 `./X`는 그 폴더의 형제를 가리키고, 폴더 +**안의** 형제 파일을 가리키려면 예약 alias `@self/X`가 필요함 — CWD와는 +무관한 문제였음. `quad-base/src/init.luau`의 `require("./Debug")`→ +`require("@self/Debug")`, `Debug/init.luau`의 `require("../Relate")`→ +`require("./Relate")`로 고치자 즉시 정상화(런타임 clean, `luau-analyze` +0 진단). 부수로 `Relate.luau`의 진짜 타입 내로잉 버그 2건도 이때 처음 +드러나 같이 고침(전에는 require가 안 뚫려 그 부분이 타입체크 자체를 안 +받고 있었음). + +**교훈**: CWD 기반이라는 첫 결론은 "여러 재현 케이스가 다 맞아떨어졌다"는 +확신 때문에 유지했는데, 실제로는 초기 가설(구조적 require 특수 케이스)을 +검증 안 하고 다른 잘못된 가설로 건너뛴 것 — 사용자가 정확한 1차 소스 +(공식 문서)를 제시해줘서 빠르게 정정됨. + +## 3. tbox 확인 → pesde 결정 → 실제 설치·검증 + +사용자 요청: `tbox`(`initreq/tbox`) 확인 후 "pesde로 가야 할 것 같다" +(dev-dependency 등 더 나은 툴링). `tbox`엔 pesde/wally 설정 자체가 없었지만 +(독립 스키마 라이브러리, 패키지 매니저 미사용), `src/init.luau`가 +`require("@self/...")` 패턴을 실제로 쓰고 있어 위 2번 정정을 교차 +확인해줬고, `.vscode/settings.json`이 `enableNewSolver: true`를 이미 +켜둔 것도 `HUMAN_TODO.md` 6번(에디터 솔버 확인)에 참고 근거로 남음. + +pesde 실물 설정은 `initreq/vide`(`pesde.toml`+`rokit.toml` 보유)를 +템플릿으로 씀. 이어서 사용자가 직접 pesde 공식 설치 문서 링크를 주고 +`/code/.local/bin`(이미 PATH)에 설치해보라고 요청 — GitHub 릴리스에서 +`pesde-0.7.3-linux-x86_64.zip`을 받아 압축 해제 후 그 경로에 배치, +`pesde 0.7.3` 확인(`rokit.toml`의 핀과 정확히 일치). + +**실제 `pesde install`을 워크스페이스 루트에서 돌려서 나온 것들**(전부 +`.claude/base/project-setup-plan.md`에 정리): +1. 패키지 이름에 하이픈 불가(`a-z`/`0-9`/`_`만) — `qwreey/quad-base`가 + 파싱 단계에서 거부됨(에러 메시지가 원인을 안 알려줘서 처음엔 의존성 + 선언 문법이 잘못된 줄 알았음). `quad_base`/`quad_roblox`로 고침. +2. `workspace = "scope/name"` 의존성 문법은 원래 손으로 쓴 그대로 + 맞았음(이름만 고치니 바로 통과) — `pesde add`는 워크스페이스 멤버를 + 못 찾는다는 것도 같이 확인(레지스트리 전용 커맨드). +3. `pesde.lock`은 워크스페이스 루트에 딱 하나만 생김. +4. **가장 중요한 발견** — 워크스페이스 의존성은 **심볼릭 링크**로 + 연결됨(`roblox_packages/.pesde/scope+pkg/version/pkg/src` → + 실제 형제 패키지 경로). 실제로 `quad_base`를 `quad-roblox`에서 + `require`하는 스모크 테스트를 짜보니 "could not resolve child + component 'src'"로 깨짐 — 직접 격리 재현(`/tmp`에 symlink 하나만 + 만들어 `require`) 후 원인이 **Luau의 require-by-string이 symlink를 + 의도적으로 안 따라간다**(보안상의 이유, RFC 검색으로 확인, 향후 + `.luaurc` opt-in 토글 가능성만 언급되고 아직 없음)로 확정. + `quad-roblox`가 실제로 `quad_base`를 쓰게 될 M5부터 이 문제가 + 현실화됨 — Rojo/Studio는 아마 무관(파일시스템 워크라 symlink를 그냥 + 따라갈 가능성이 높음)이지만 이 세션엔 Rojo가 없어 미검증. + +## 4. 산출물 + +- `.claude/base/project-setup-plan.md` 신설 — 위 내용 전부 정리, + "확인 완료/아직 확인 안 된 것" 절로 후속 검증 항목 명시. +- `.claude/base/architecture.md` "패키징 방식" 절 — wally→pesde 전환 반영. +- `.gitignore` — `roblox_packages/`/`luau_packages/`/`.pesde/` 추가. +- `pesde.toml`(루트+quad-base+quad-roblox), `rokit.toml`, `.luaurc`, + `default.project.json` 신설. +- `quad-base/src/{Relate.luau,init.luau,Debug/init.luau}`, + `quad-base/test/{mock.luau,smoke.mock.luau}` 신설. +- `luau-test/05`(다이아몬드 전파, 재작성 후 `done/`), `luau-test/21`(Store + 미선언 키, 신규) — 둘 다 `STATUS.md` 표 텍스트는 아직 안 고침(스스로 + 발견한 것 — 다음에 손댈 것). + +## 5. 커밋 후 후속 — 05/21 STATUS.md 반영, Rojo 설치·symlink 검증 + +사용자가 산출물(문서화+셋업 파일)만 먼저 커밋하길 원해 그렇게 진행(`tooling:` +커밋). 이어서 4가지를 전부 순서대로 진행하기로 함(사용자: "전부 차근차근 +진행해보면 될듯 함") — (1) `05`/`21` STATUS.md 텍스트 반영 후 별도 커밋 +(`qa:`), (2) Rojo 설치 후 symlink 처리 검증, (3) 스파이크 `13` 재작성, +(4) `HUMAN_TODO` 6번 에디터 솔버 설정. + +**(2) 완료** — pesde와 같은 방식으로 `rojo`도 `/code/.local/bin`에 직접 +설치(`7.7.0`, `rokit.toml` 핀과 일치). `quad-roblox/`에 `src`+ +`roblox_packages`를 매핑하는 임시 project.json으로 `rojo +sourcemap`/`rojo build`를 돌려본 결과, **symlink를 투명하게 따라가 +실제 `quad-base/src/init.luau` 등까지 정확히 해소함을 확인** — +`project-setup-plan.md`가 가장 크게 남겨뒀던 미해결 항목이 이걸로 +닫힘. 결론: 이전 세션이 발견한 "workspace 의존성 symlink가 require를 +깨뜨린다"는 문제는 **Luau standalone CLI 전용**이고 Rojo/Studio 배포 +경로엔 영향 없음(Studio 실물 확인은 여전히 계정 분리 대기, +`HUMAN_TODO.md` 1번). 임시 검증 파일(`test-symlink-check.project.json`)은 +확인 후 삭제, 결과만 `project-setup-plan.md`에 반영. + +**(3) 완료** — `13-type-ref-preref-subtype.luau`를 타입 전용으로 남기고 +(`PostRef`도 `Ref`를 만족하는지 추가), 런타임 절반은 신규 +`22-runtime-ref-preref-postref-brand.luau`로 분리(A의 더미 스텁이 B +실행을 막던 문제 해결). `isPreRef`/`isPostRef`가 서로 배타적 형제이고 +Leaf 핸들러 흉내가 셋을 정확히 갈라냄을 확인. 둘 다 `done/`. + +**(4) 완료** — `luau-lsp` 바이너리(1.69.0)도 같은 방식으로 +`/code/.local/bin`에 직접 설치. `luau-lsp analyze +--flag:LuauSolverV2=true/false`로 spike `08`(재귀 제네릭 패턴)을 +비교한 결과 새 솔버 필요성 재확인(옛 솔버는 같은 패턴에 에러 3건, +새 솔버는 1건). `quad/.vscode/settings.json`에 +`enableNewSolver: true` 반영·커밋. 부수로 typing-limits.md §1의 핵심 +주장("`local s = n:Compute(fn); local wrong: number = s:Get()`가 0 +진단으로 통과") 자체도 Luau 0.734에서 여전히 재현됨을 별도 최소 +repro로 재확인(`s`가 `Unifiable`로 새는 것, `wrong` 줄은 진단 +0건 — base 문서 정정 불필요, 그대로 유효함만 재확인). tbox가 쓰던 +`LuauDoNotExportBrokenTypeFunction` override는 quad의 현재 type +function 스파이크(`16`/`21`)에서 유무 차이가 없어 채택 안 함. +`HUMAN_TODO.md` 6번/`typing-limits.md` §8 갱신, `rokit.toml`에 +`luau-lsp` 핀 추가. + +**남은 사람 몫**: VSCode를 실제로 열어 `.vscode/settings.json` 설정이 +반영됐는지 육안 확인(HUMAN_TODO 6번), Studio 실물 동기화(HUMAN_TODO +1번, 계정 분리 대기). 이번 라운드로 이번 대화의 4개 후속 검증 항목은 +전부 닫힘. diff --git a/.claude/session/2026-08-19-05-mise-migration-and-selene-linter.md b/.claude/session/2026-08-19-05-mise-migration-and-selene-linter.md new file mode 100644 index 0000000..28f4bee --- /dev/null +++ b/.claude/session/2026-08-19-05-mise-migration-and-selene-linter.md @@ -0,0 +1,73 @@ +# 2026-08-19, 다섯 번째 세션 — rokit→mise 전환, selene 린터 도입, darklua 검토 후 기각 + +**요약**: 사용자가 `Word30210/roblox-project-example`(참고용 GitHub 레포)의 +`mise.toml`을 보여주며 "요즘은 rokit보단 mise로 까는듯 하네" 언급. +`initreq/roblox-project-example`로 클론해 구조 전체를 훑고 흡수할 요소를 +찾음 — mise 전환, selene 린터, darklua 세 후보 중 사용자가 앞의 둘만 +채택. + +## 1. 참고 레포 훑기 + +`packages/`(독립 게시 패키지)+`places/`(멀티 플레이스 게임 프로젝트)+ +`scripts/`(빌드 도구) 구조, 각 서브패키지가 독립 `pesde.toml`/ +`selene.toml`/`stylua.toml`/`.luaurc`/`.vscode`를 가짐. `Justfile`로 +`refresh`/`clean`/`dev` 태스크 러너(각 패키지를 순회하며 `pesde +install`+`darklua process` 반복). `.darklua.json`의 `convert_require` +룰이 경로 기반 require를 Rojo sourcemap 기준 `script.Parent`류로 빌드 +시점에 변환. `places/main`의 `default.project.json`(로컬 dev, `src` +직결)과 `build.project.json`(`dist`, darklua 처리 결과) 분리. `optional` +path 문법(`{"$path": {"optional": "roblox_packages"}}`)으로 설치 전에도 +Rojo 에러 안 나게 함. + +`packages/assets/src/init.luau`가 `require("@self/assets")`를 실제로 +씀 — 지난 세션에 발견한 `@self` 규칙의 세 번째 교차 확인(tbox, 이번 +세션 자체 실측에 이어). + +## 2. 사용자 선택 — mise 전환 + selene 채택, darklua는 보류 + +멀티셀렉트로 네 후보(mise 전환/selene/darklua/Justfile) 제시, +사용자 답: mise 전환(추천)과 selene은 채택. darklua에는 직접 반박 — +*"roblox 안에서도 이미 string require가 적용되긴 하고, @self와 @game이 +먹는다. 같은 동작을 하지만, ./ 등으로 위치를 어떻게 두냐에 유의가 +필요할 뿐임. 따라서 darklua의 필요성은 잘 모르겠다"* — 즉 실제 Roblox +엔진도 이 세션들이 확인해온 require-by-string 의미론을 그대로 지원하므로 +변환 계층이 불필요하다는 판단(Justfile은 언급 안 돼 도입 안 함). + +## 3. mise 전환 — 실제 검증까지 완료 + +`/tmp`에서 먼저 `mise install`로 pesde/rojo를 테스트 — **GitHub artifact +attestation + SLSA provenance 검증까지 거쳐 설치됨**(이전 세션이 `curl`로 +직접 받던 것보다 공급망 신뢰도가 높음). `rokit`은 이 샌드박스에 없어 +한 번도 못 써봤던 것과 대비되게, `mise`는 이 샌드박스 자체가 이미 +`luau` 설치에 쓰고 있어 실제 검증이 가능했음. `rokit.toml` 삭제, +`mise.toml` 신설(`github:`/`aqua:` 백엔드 접두사 문법 — `rokit.toml`의 +평문 `owner/repo@ver`와 형태가 다름) — `pesde`/`rojo`/`luau-lsp`/ +`selene` 넷 다 `mise install`+`mise exec`로 버전 일치까지 재확인. + +## 4. selene 도입 — CWD 상대 config 탐색 함정 발견 + +참고 레포의 `scripts/selene.toml`을 그대로 채택. 처음 루트에 단일 +`selene.toml`을 두고 `selene quad-base/`를 저장소 루트에서 돌렸더니 +`type Quad = {...}` 같은 평범한 타입 선언까지 전부 파싱 에러(30여 건) — +"이 selene 빌드가 Luau 타입 문법 자체를 지원 안 하나?"로 오인했다가, +최소 재현으로 `std = "luau"`가 CWD에 없으면 조용히 Lua 5.1 std로 +폴백한다는 걸 확인(`--config`가 파일 트리를 안 거슬러 올라감, CWD 기준 +고정 경로). 참고 레포처럼 **패키지별 독립 `selene.toml`**로 전환하고 +`cd quad-base && selene .`처럼 패키지 안에서 실행하는 걸로 확정. + +부수로 `quad-base/test/smoke.mock.luau`의 `assert(cond)` 3건(메시지 +없음)이 selene의 `incorrect_standard_library_use`(deny)에 걸려 실제 +수정(메시지 추가) — 도입하자마자 실제 코드 품질 개선. + +## 5. 산출물 + +- `mise.toml`(루트, `rokit.toml` 대체), `quad-base/selene.toml`, + `quad-roblox/selene.toml` 신설. +- `.claude/base/project-setup-plan.md` — "툴체인" 절 전면 갱신(mise 전환 + 경위, darklua 기각 근거), "`selene` 린터" 절 신설. +- `.claude/base/architecture.md`/`.claude/README.md`의 `rokit.toml` + 잔여 참조 정정. +- `quad-base/test/smoke.mock.luau` — selene이 잡은 `assert` 메시지 + 누락 3건 수정. +- `initreq/roblox-project-example` 클론 보존(읽기 전용 참고 레포, + `.gitignore`로 이미 제외되는 `initreq/` 하위라 커밋 대상 아님). diff --git a/.claude/session/2026-08-19-06-runinit-redesign-and-darklua-precision.md b/.claude/session/2026-08-19-06-runinit-redesign-and-darklua-precision.md new file mode 100644 index 0000000..4aa3a59 --- /dev/null +++ b/.claude/session/2026-08-19-06-runinit-redesign-and-darklua-precision.md @@ -0,0 +1,69 @@ +# 2026-08-19, 여섯 번째 세션 — `RunInit` 재설계, darklua 경계 정밀화 + +**요약**: 사용자가 두 가지를 요청. (1) 지난 darklua 기각 근거를 실측으로 +정밀화, (2) `New()`의 멱등 Init 가드를 파일마다 `Relate`+센티널을 두는 +대신 **함수 자체를 릴레이션 키로 쓰는 공유 `module:RunInit(initFn)`**로 +재설계. 둘 다 실제로 구현·검증까지 완료. 이후 대화는 한국어로 진행하기로 +합의. + +## 1. darklua 경계 실측 — `@self`/`@game`은 안 건드리고, 커스텀 alias만 변환 + +`darklua` 0.19.0을 직접 설치해 참고 레포(`initreq/roblox-project-example`)에서 +`pesde install`+`rojo sourcemap`+`darklua process`를 실제로 돌려봄: +- `require("@self/X")`/`require("@game/...")`는 `convert_require`가 + 손을 안 대고 경고만 찍은 채 원문 그대로 통과(`unknown source name`). +- 커스텀 `.luaurc` alias(`@pkg`)는 실제로 + `require(game:GetService('ReplicatedStorage'):WaitForChild('roblox_packages'):WaitForChild('assets'))`로 + 치환됨 — `.luaurc` 매핑 + Rojo sourcemap을 같이 읽어 해석. + +결론: quad는 상대경로+`@self`만 쓰므로 지금 darklua는 정말 불필요하지만, +**나중에 `@pkg/quad_base`류 축약 alias를 쓰고 싶어지면 그때는 darklua가 +실질적으로 필요해진다** — `project-setup-plan.md`에 이 경계를 정확히 반영. + +## 2. `RunInit` 재설계 + +사용자 제안: 모듈 설정 완료 여부 릴레이션을 파일마다(`INITED` 센티널 + +`Relate()`) 따로 두지 말고, **함수 자체를 릴레이션 키로** 쓰고 +`module.RunInit(initfun: (module)->any)`를 구현해 "실행한 적 없으면 +실행"을 전담시키자는 것. 근거: `(any)->any : boolean?` 형태의 릴레이션이 +간단하고, 파일마다 보일러플레이트를 반복할 이유가 없음. + +실제로 반영: +- `quad-base/src/init.luau` — 최상위에 `runInitRelate = Relate()` 하나만 + 두고, `module.RunInit(self, initFn)`이 `(module, initFn) -> boolean?` + 릴레이션으로 실행 여부를 판정. `Quad` 타입에 `RunInit` 필드 추가. +- `quad-base/src/Debug/init.luau` — 가드 보일러플레이트 전부 삭제, 그냥 + 무조건 `module.debug = false`만(멱등은 호출부 `RunInit`이 보장). +- `quad-base/test/smoke.init.luau` 신설 — 같은 `initFn` 재호출 시 + 1회만 실행, 서로 다른 `New()` 인스턴스는 기록 비공유, 서로 다른 + `initFn`은 서로 무간섭 — 3개 시나리오 전부 검증. `selene`이 `assert` + 메시지 누락/미사용 매개변수 몇 건을 잡아 같이 수정. +- `luau`/`luau-analyze`/`selene` 셋 다 클린. +- `module-lifecycle-plan.md` "New()의 내부 구성" 절 — 옛 파일별 + 센티널 의사코드를 새 `RunInit` 의사코드로 교체, 근거·GC 특성· + `_initializedBy`와의 층위 차이 재서술. + +## 3. 미결 — `RunInit`을 backend 설치 진입점에도 재사용할지 + +사용자 질문: `RunInit`을 `QuadRoblox(Quad) -> QuadRoblox`(backend 주입 +진입점) 내부에서도 그대로 써도 되는지. **답은 아직 안 냄** — `RunInit`은 +함수 identity로만 추적하는데, backend 가드(`base/bind-system-plan.md`의 +"Bind는 누가, 어떻게 구현하는가" 절)는 "같은 팩토리 재호출=no-op, **다른 +팩토리=에러**"라는 계약이 있어서, `InitRoblox`/`InitGtk`처럼 서로 다른 +함수가 같은 "백엔드 슬롯"을 다투는 상황을 `RunInit`은 구분 못함(둘 다 +"아직 안 돈 함수"라 각자 조용히 실행되어 버림 — 에러가 나야 하는데 안 +남). 슬롯 키를 별도로 감싸는 방안을 떠올렸으나 결론은 안 냄 — +`module-lifecycle-plan.md`에 ⚠️ 미결로 반영, M2/M5 착수 전 확인 필요. + +## 4. 타입 관련 질문 — 답변 보류 + +사용자가 "pesde가 타입만 뽑아주는 게 있다고 들었다"며 quad-roblox가 +quad-base 타입을 그걸로 갖게 되는지 물음. 워크스페이스 링크(심볼릭 +링크로 연결된 실제 소스)를 통한 타입 추론은 이미 지난 세션에 실측 +확인됐고 이 메커니즘과는 무관해 보이지만, `HUMAN_TODO.md` 8번이 말하는 +"pesde의 타입 추출"(d.ts류, const 지원과 엮인) 기능 자체를 이 세션이 +따로 조사하지 않아 정확한 답은 다음 턴에서 이어감. + +## 5. 이후 진행 + +사용자 요청으로 이제부터 대화를 한국어로 진행. diff --git a/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md b/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md new file mode 100644 index 0000000..e49e496 --- /dev/null +++ b/.claude/session/2026-08-19-07-quad-types-package-addplugin-checkversion.md @@ -0,0 +1,125 @@ +# 2026-08-19, 일곱 번째 세션 — `quad-types` 패키지 신설, `AddPlugin`/`CheckedQuad` 실측 설계 + +**요약**: `RunInit` vs backend 유일 슬롯 가드를 `_initializedBy`로 분리 +확정한 뒤, 사용자가 "quad-roblox가 quad-base를 런타임 주입으로만 받으면 +dev-dependency로도 타입이 못 산다"는 문제를 제기 — 실측으로 확인하고 +`quad-types`(구현 없는 타입 계약 전용 워크스페이스 패키지)를 신설, +`AddPlugin` 플러그인 체이닝과 `CheckedQuad` 컴파일 타임 +버전 체크를 설계·구현·검증까지 전부 마침. 과정에서 새 Luau 함정을 +하나 발견해 `typing-limits.md`에 승격. + +## 1. `_initializedBy` 결정 반영 + +지난 턴에서 제기된 "`RunInit`을 backend 설치에도 재사용해도 되는가" +질문에 사용자가 짧게 답함: `_initializedBy`를 그대로 쓰자. `RunInit`은 +함수 identity 추적이라 "다른 팩토리 재호출=에러"라는 backend 계약을 +못 만족한다는 진단을 그대로 확정, `module-lifecycle-plan.md`에 반영 +(예시 `InitRoblox` 의사코드 포함, 실제 구현은 M5). + +## 2. dev-dependency 문제 제기 → `quad-types` 신설 + +사용자 문제 제기 요지: `QuadRoblox(Quad): QuadRoblox`처럼 quad-base를 +런타임 주입으로 받으면 quad-roblox가 quad-base를 pesde 의존성으로 +선언할 필요가 없어 보이는데, 실제로는 타입 참조 때문에 `require`가 +필요하다. 이걸 dev-dependency로 두면 게시 후 소비자 환경에서 깨질 것 +같은데, 정확히 왜/어떻게 깨지는지 실측해달라는 요청 + "quad-types +폴더를 quad-base 안에 넣고 그것만 링킹하는 게 되는지"/"버전 필드로 +타입함수 검증하는 게 되는지" 두 구체적 대안 질문. + +**실측 확인**: +- `require(...)`로 타입만 뽑아 쓰는 것도 **런타임에 실제로 실행됨** — + 대상 모듈을 지우고 돌려보니 진짜 크래시(`could not resolve child + component`). dev-dependency 우려가 정확했음. +- pesde 워크스페이스 의존성은 **패키지 단위**만 가능 — `quad-base/types/` + 폴더로는 "가벼운 타입만" 효과를 못 얻음(전체 패키지가 통째로 + 링크됨). **별도 워크스페이스 멤버로 뽑아야만** 실제로 가벼워짐. +- 버전 체크 타입함수 — 최소 재현으로 즉시 성공(`type function +CheckVersion` + `readproperty`/`value()`로 리터럴 비교). + +**결론**: `quad-types` 3번째 워크스페이스 멤버 신설, `pesde.toml` ++ `quad-base`/`quad-roblox` 의존성 전환(quad-roblox는 quad-base 대신 +quad-types만 의존)까지 실제로 실행 — `pesde install`로 링크 확인. +`quad-spring`/`quad-spring-roblox` 같은 가상의 다른 플러그인 쌍은 이 +분리가 필요 없다고 사용자가 별도로 짚음(quad-base처럼 "거의 모든 +패키지가 의존하는 핵심 계약"일 때만 값어치가 있음). + +## 3. `AddPlugin` — 제네릭 self 체이닝 실측 + +`Quad:AddPlugin(pluginFn): T`에서 `T`가 정확히 "플러그인이 누적된 +Quad"가 되는지 확인해달라는 요청("타입의 근간인 부분"). 여러 스파이크로 +검증: +- `(self: Self, pluginFn: (Self) -> P) -> Self & P` — `Self`를 + 제네릭으로 둬야 체이닝이 누적됨(고정하면 두 번째 호출이 첫 확장을 + 잃음). 실제로 `Quad & SpringPlugin & OtherPlugin`까지 정확히 누적 + 확인, 음성 대조군(플러그인 추가 전 접근)도 정확히 거부됨. +- 사용자도 독립적으로 같은 패턴을 직접 테스트해 성공 확인(대화 중 + "성공했어" 보고). +- quad-base에 실제 구현 — `pluginFn(self)`의 결과를 `self`에 mutate + (새 테이블 안 만듦, `RunInit`의 identity 추적을 안 끊기 위해). + `smoke.plugin.luau`로 mutate/identity/체이닝 전부 실행 레벨 검증. + +## 4. `CheckedQuad` — 배선하며 세 번 깨짐, 세 번째가 핵심 발견 + +사용자가 구체적 구현 지침을 줌: `error()` 말고 `print()`+`types.never`, +검증 결과는 `__versionCheck` 같은 가상 필드에, 성공 값은 트리비얼하게. +실제로 배선하며 순서대로: + +1. **`error()` 시도 → 실패**: type function 자체가 실패로 판정됨. + `print`+`types.never`로 교체 → 즉시 성공(호출부에 정확히 + "TypeError: <메시지>"). +2. **함수 본문 로컬 타입 별칭 시도 → 무반응**: `type _Check = + CheckVersion`를 본문에 두면 제네릭 인스턴스화마다 재평가 안 됨. + 리턴 타입 표현식 자체로 옮기니 즉시 해결. +3. **[가장 중요] 패스스루(`return t`) 버전 → 단독으론 통과, `AddPlugin` + 체이닝과 조합하면 조용히 깨짐**: `CheckVersion & RobloxExt` 뒤에 + `:AddPlugin(...)`을 부르면 "Expected this to be exactly 'P & Self', + but got 'P & Self'"처럼 앞뒤가 같은 의미 없는 진단이 남. `&`로 안 + 합쳐도, 패스스루만 거쳐도 동일하게 깨짐 — **재구성이 아니라 "type + function을 거쳤다는 이력 자체"가 문제**. 최종 설계: `CheckVersion`이 + `T`를 절대 반환하지 않고(성공 시 `types.singleton(true)`만), 결과를 + `T & { __versionCheck: CheckVersion }`처럼 원본과 완전히 격리된 + 필드로만 노출 — 이 형태만 `AddPlugin` 체이닝과 완전히 호환됨(실측). + `__versionCheck`는 실제로 참조해야 평가되는 lazy 필드라는 점도 + 다시 확인(함정 2와 같은 결). + +이 세 번째 발견은 quad 코퍼스에 없던 새 Luau 한계라 `typing-limits.md` +§6으로 승격(6번을 신설하며 기존 6/7/8을 7/8/9로 재번호, 내부 상호 +참조 §6/§8도 같이 고침), §8 체크리스트에 항목 7 추가. + +## 5. 부수 발견 — quad-base 자기 자신도 CLI symlink 함정에 걸림 + +`quad-base/src/init.luau`가 `quad-types`를 workspace 의존으로 받게 +되며 `require("./roblox_packages/quad_types")`를 쓰게 됐는데, 이건 +지난 세션에 발견한 심볼릭 링크 문제(Rojo는 괜찮고 standalone `luau` +CLI만 못 따라감)가 **이제 quad-base 자신의 프로덕션 진입점에도** 닥침 — +지난 세션엔 quad-roblox→quad-base(아직 코드 없음)만 영향권이라 여유가 +있었는데, 이번엔 실제로 존재하는 quad-base의 `require`가 막힘. 이 +세션은 `pesde install`이 만든 심볼릭 링크를 로컬 CLI 테스트용으로만 +실제 디렉토리 복사본으로 치환하는 즉석 조치로 우회(`find ... -type l +... cp -r`) — 정식 스크립트화는 아직 안 함, `project-setup-plan.md`에 +다음 세션이 알아야 할 것으로 남김. + +## 6. 산출물 + +- `quad-types/`(신규 패키지: `pesde.toml`, `src/init.luau` — + `Quad`/`CheckVersion`/`CheckedQuad`), `quad-types/selene.toml`. +- `quad-base/pesde.toml` — `quad_types` 의존성 추가. +- `quad-roblox/pesde.toml` — 의존성을 `quad_base`→`quad_types`로 전환. +- `pesde.toml`(루트) — `workspace_members`에 `quad-types` 추가. +- `quad-base/src/init.luau` — `Quad` 타입을 `quad-types`에서 가져오도록 + 전환, `Version`/`AddPlugin` 실제 구현 추가. +- `quad-base/test/smoke.plugin.luau` 신규. +- `.claude/luau-test/done/23-type-quadtypes-checkversion-addplugin.luau` + 신규 — 실제 quad-types/quad-base 통합 검증. +- `.claude/base/quad-types-plan.md` 신규 — 전체 설계/함정/실측 근거. +- `.claude/base/typing-limits.md` — §6(신규, type function 이력 오염) + 추가 + 6/7/8 재번호(→7/8/9) + 체크리스트 항목 추가. +- `.claude/base/module-lifecycle-plan.md` — `_initializedBy` 결정 반영. +- `.claude/base/architecture.md`/`.claude/base/project-setup-plan.md`/ + `.claude/README.md`/`luau-test/README.md`/`STATUS.md` — 관련 갱신. + +## 7. 다음 + +`quad-roblox`의 `QuadRoblox`/`CheckedQuad` 실사용은 M5. 심볼릭 링크 +로컬 우회의 정식 스크립트화는 다음에 필요해지면. 사용자 요청으로 +대화는 계속 한국어로 진행 중. diff --git a/.claude/session/2026-08-19-08-type-version-check-package-extraction.md b/.claude/session/2026-08-19-08-type-version-check-package-extraction.md new file mode 100644 index 0000000..88838ef --- /dev/null +++ b/.claude/session/2026-08-19-08-type-version-check-package-extraction.md @@ -0,0 +1,116 @@ +# 2026-08-19, 여덟 번째 세션 — `type-version-check` 패키지 추출, `CheckedQuad` 확장 + +**요약**: 직전(일곱 번째) 세션이 만든 `CheckedQuad`(정확 버전 일치만 +지원)가 `quad-spring`/`quad-spring-roblox`류 독립 게시 플러그인엔 너무 +빡빡하다는 사용자 지적으로 시작. 글롭(`"*"`)/캐럿(`"N^"`) 패턴 매칭을 +지원하도록 `CheckedQuad`으로 확장하고, 그 매칭 로직 자체는 +quad에 종속되지 않은 범용 워크스페이스 패키지 `type-version-check`로 +분리·구현·검증까지 완료. 사용자 지시대로 지금은 quad 모노레포 안에 +두고, 독립 저장소 분리는 `HUMAN_TODO.md`에 남김. + +## 1. 문제 제기 — 정확 일치는 독립 게시 플러그인엔 과함 + +사용자 발언 요지: "quad-spring/quad-spring-roblox 같은건 버전 확인이 +exactly 할 필요는 없다고 봄" — 최신 `quad-spring-roblox`가 과거 +`quad-spring` 버전도 잘 다룰 가능성이 높으므로, 글롭(`"3.*.*"`)이나 +캐럿(`"3.3^.4^"`, 마이너 3 이상 + 패치 4 이상) 패턴을 지원하는 게 +"구현하기 정말 쉽고... 있으면 좋다"는 판단. 같이 제안된 것: (1) 미래 +`quad-roblox-types` 패키지(지금 만들 필요는 없음, 다만 `quad-roblox`의 +공개 타입을 지금부터 단일 파일에 몰아둬서 나중에 쉽게 분리되게만 +준비), (2) 버전 체크 패턴 자체를 `qwreey/type-version-check`로 독립 +추출(다른 프로젝트에도 쓸 수 있고, quad-spring-roblox류가 이것 때문에 +quad-base 전체를 끌고 올 필요가 없어짐), (3) Luau 내장 `index<>` type +function으로 `Version` 필드를 뽑는 게 수동 `readproperty`보다 나아 +보인다는 제안. + +**사용자 명시적 지시**: "우선 이 프로젝트 안에 넣어둬줘. 나중에 내가 +다른 프로젝트로 분리해줄게. Human todo 로 남기면 될듯 함." + +## 2. `type-version-check` 패키지 구현 + +새 워크스페이스 멤버(`[target] environment = "luau"` — quad에 종속되지 +않아 다른 멤버와 달리 roblox가 아님). 핵심: + +- 런타임 유틸 `matchesPattern(actual, pattern)` — `.`로 나눈 각 자리를 + `"*"`(와일드카드) / `"N^"`(그 자리 숫자값이 N 이상이면 통과) / 그 외 + 정확 일치로 비교. +- `export type function CheckVersion(actual: type, pattern: type): type` — + `actual`/`pattern`을 문자열 리터럴로 검증 후 같은 매칭 로직을 타입 + 레벨에서 재현, 성공 시 `types.singleton(true)`만 반환(원본 타입 + 패스스루 금지 — 직전 세션이 확정한 함정 회피 원칙 그대로 유지). + +**새로 발견한 Luau 함정 2건** (이전 세션들의 `typing-limits.md` §6과는 +다른 결의 순수 문법 제약): +1. `type function`은 같은 파일의 바깥 스코프 로컬 함수를 못 + 참조한다(`Type function cannot reference outer local 'X'`) — + `matchesPattern`과 `CheckVersion` 내부의 매칭 로직은 물리적으로 + 중복된 별개 함수로 유지해야 함. +2. cross-package 사용엔 `type function`이 아니라 `export type + function`이 필요(안 그러면 `Unknown type 'Module.X'`), 그리고 명시적 + 제네릭 인스턴스화가 2개 이상이면 단일 꺾쇠가 비교 연산자로 + 오파싱되므로 이중 꺾쇠(`Foo<>`)가 필요(코퍼스의 기존 + `AttributeKey<>` 관례와 동일 이유). + +`index` 내장 type function으로 `Version` 필드 추출 — +사용자 제안대로 채택, 단독 스파이크와 `CheckVersion`에 실제로 물려서 +둘 다 실측 확인. + +selene `shadowing` 경고(중첩 스코프의 `actualValue` 재선언, 두 곳)를 +안쪽 변수를 `actualNumber`로 리네임해 해소. + +## 3. `quad-types` 쪽 배선 + +`quad-types/pesde.toml`에 `type_version_check = { workspace = +"qwreey/type_version_check", version = "^", target = "luau" }` 추가 — +`target` 없이는 `pesde install`이 "no workspace member found with name +qwreey/type_version_check and target roblox"로 실패(quad-types 자신의 +기본 target이 roblox라 명시적 target 지정이 필요, `quad-types`↔`type- +version-check`가 서로 다른 target을 가진 첫 워크스페이스 의존 관계라서 +이번에 처음 실측됨). + +`CheckedQuad = T & { __versionCheck: CheckVersion }` → +`CheckedQuad = T & { __versionCheck: +TypeVersionCheck.CheckVersion, Pattern> }`로 확장. + +심볼릭 링크 로컬 CLI 우회(`project-setup-plan.md`가 이미 문서화한 +워크어라운드)를 2단 깊이 의존 그래프(`quad-base`/`quad-roblox` → +`quad-types` → `type-version-check`)에 재적용 — 문제없이 일반화됨을 +확인. + +## 4. 검증 + +전 패키지(`quad-base`/`quad-roblox`/`quad-types`/`type-version-check`) +`luau-analyze`/`luau`/`selene` 전부 클린. 스파이크 +`23-type-quadtypes-checkversion-addplugin.luau`를 새 시그니처로 +재작성해 재검증 — 양성 경로(버전 일치 + `AddPlugin` 2회 체이닝) 클린, +음성 경로(버전 불일치)는 정확히 `TypeError: type-version-check: version +"9.9.9" does not match pattern "0.0.0"` 하나만 발생. + +## 5. 문서 반영 + +`base/quad-types-plan.md`(`type-version-check` 절 신설 + `CheckedQuad` 섹션 갱신 + 남은 것에 `quad-roblox-types` 백로그/HUMAN_TODO +포인터 추가), `base/architecture.md`(소스 트리에 `type-version-check/` +추가, 패키징 방식 문단 갱신), `base/project-setup-plan.md`(cross-target +워크스페이스 의존 함정 + 2단 심볼릭 링크 우회 재확인), `base/typing- +limits.md`(§6 예시 코드를 `CheckedQuad`으로 갱신 + 절 인용 +수정), `.claude/README.md`/`luau-test/README.md`/`luau-test/STATUS.md` +(스파이크 23 설명 갱신), 루트 `HUMAN_TODO.md`(9번 — `type-version-check` +독립 저장소 분리는 사용자 몫). + +## 감사 루프 (2라운드, 핸드오버 체크리스트대로) + +1라운드는 `quad-types-plan.md`/`type-version-check` 자신의 파일/주석에 +남아있던 구 `CheckedQuad`(콤마 없는 단일 파라미터) 잔존 4건과 +`architecture.md`의 `pesde.toml` 나열 누락, `luau-test/STATUS.md` 배너 +stale 1건을 찾아 전부 수정. 2라운드(각도를 인덱스 레이어/교차 참조로 +전환)는 `project-setup-plan.md`의 옛 2-멤버 트리 다이어그램과 "3개 +패키지"/"총 3개" 개수 하드코딩(멤버가 2→4로 늘어난 걸 못 따라간 자리 +2곳)을 찾아 `architecture.md`를 소스로 가리키게 일반화. 이후 3라운드는 +새 발견 0건 — 여기서 감사 루프 종료. + +## 다음에 확인할 것 + +없음 — 이 턴의 설계/구현/검증/문서화/2라운드 감사까지 전부 마무리. +`quad-roblox-types`는 사용자가 명시적으로 후순위 지정(지금 안 만듦), +`type-version-check` 독립 분리는 사용자 본인이 나중에 직접 진행. diff --git a/.claude/session/2026-08-19-09-handover-prep-roadmap-status-sync.md b/.claude/session/2026-08-19-09-handover-prep-roadmap-status-sync.md new file mode 100644 index 0000000..ab9b2b1 --- /dev/null +++ b/.claude/session/2026-08-19-09-handover-prep-roadmap-status-sync.md @@ -0,0 +1,78 @@ +# 2026-08-19, 아홉 번째 세션 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소 + +**요약**: 사용자가 "quad-roblox-types도 base 문서에 짧게 언급해둘까, +핸드오버 준비해줘, 빠진 게 있으면 적절히 적어달라"고 요청. 확인해보니 +`quad-roblox-types`는 직전 세션에 이미 `quad-types-plan.md`에 반영돼 +있었으나, 감사 과정에서 훨씬 큰 두 가지 실제 공백을 발견 — (1) +`session-summary.md`에 오늘 세션 5개(04~08)의 색인 항목이 통째로 빠져 +있었고, (2) `CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 +시작 전"이라는 낡은 전제를 그대로 깔고 있었는데 실제로는 오늘 M0(스파이크 +4개)/M1(스캐폴딩) 전부가 이미 완료·커밋된 상태였음. 둘 다 발견 즉시 +반영, 감사 라운드로 재검증까지 마침. + +## 1. `quad-roblox-types` 확인 + +`base/quad-types-plan.md`의 "남은 것" 절에 이미 백로그로 적혀 있음을 +확인(직전 세션 산출물). 추가로 두 곳에 짧은 포인터만 보강 — +`todos.md` 4번 백로그(다른 미래 패키지 아이디어들과 나란히), `ROADMAP.md` +M5 섹션 상단(구현 관례 각주: quad-roblox 공개 타입은 지금부터 단일 파일에 +몰아둘 것). 개수/설명 자체는 `quad-types-plan.md`가 계속 유일한 소스. + +## 2. `session-summary.md` 색인 공백 발견·수정 + +`.claude/session/` 폴더엔 오늘 파일이 01~08까지 있는데 +`session-summary.md`엔 01~03만 있었음(04~08 다섯 개 누락). 각 세션 파일 +상단 "요약" 단락을 압축해 5개 항목 신설. 그 과정에서 session-04 항목이 +그 세션 §5(커밋 후 후속 — Rojo/symlink 검증, 스파이크 13→13/22 분리, +에디터 새 솔버 설정 확정)를 놓치고 있는 것도 감사가 잡아내 보강. + +## 3. `ROADMAP.md`/`CLAUDE.md`/`project-context.md` 대규모 stale 발견 + +감사 라운드가 지적: `CLAUDE.md`/`project-context.md`가 "[2026-08-16 +기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전"이라고 서술 중인데, +오늘 커밋 로그(`205af32`~`5dfc9b9`, 총 10개)를 보면 M0 스파이크 4개 +전부와 M1 스캐폴딩 대부분이 이미 끝나 있었음 — `quad-base/src/init.luau`에 +동작하는 `New()`/`RunInit`/`AddPlugin`이 실존, smoke 테스트도 전부 PASS. + +`Explore` 서브에이전트로 각 M0/M1 체크박스를 하나하나 실측 대조(과대평가 +방지): +- **M0 4개 전부 통과** — `luau-test/done/05`(다이아몬드 전파, 현행 + 모델로 재작성됨)/`08`(Source⊇State 제네릭)/`03`(재귀 재-process + 디스패치)/`01`+`02`+`13`+`22`(props 두 패스+PreRef/PostRef)/`06` + (컴포넌트 경계 `or None` 관용구). +- **M1 5개 중 4개 확실히 완료, 1개는 항상 충족돼 있던 조건으로 판명** — + 폴더+`pesde.toml`(단 체크박스 텍스트가 `wally.toml`로 stale — pesde + 전환 반영해 정정), `default.project.json`/`.luaurc`, mock 테스트 + 하네스, `New()`/`RunInit`. `qa-request/`/`archive/` "실사용 시작" + 항목은 실제로 M1 이전부터 이미 계속 쓰이고 있어 조건이 항상 참이었음 + (모호했던 문구를 명시적으로 정리). + +**반영**: `ROADMAP.md` 상단 배너 갱신(M0/M1 완료, M2 착수 예정), M0/M1 +체크박스 전부 `[x]` + 근거 스파이크 파일명 추가, "통과 기준"의 하드코딩된 +개수("세 개 다") 제거(개수는 `luau-test/STATUS.md`가 소스), M5 섹션에 +`quad-roblox-types` 관례 각주. `CLAUDE.md`/`project-context.md` 머리말을 +"M0/M1 완료, M2 착수 예정"으로 갱신. `README.md`/`project-context.md`의 +`feedback/` 폴더 부재 설명도 "구현 시작 전이라서"에서 "M0/M1 스캐폴딩만 +으론 안 생기고 실사용 단계부터"로 정정(폴더가 없다는 결론 자체는 그대로, +근거만 정확하게). + +## 4. 감사 루프 + +핸드오버 체크리스트대로 라운드를 나눠 진행(전부 `quad-doc-auditor` 단독 +호출, 병렬 없음): +1. `type-version-check`/`CheckedQuad` 관련 변경 감사 3라운드 + (직전 턴에서 이미 완료 — 1라운드 5건, 2라운드 3건 발견·수정, 3라운드 + 무발견으로 수렴). +2. `session-summary.md`/`todos.md` 신규 변경 감사 1라운드 — 위 3번 항목의 + 대규모 stale(CLAUDE.md/project-context.md/ROADMAP.md)을 여기서 발견. +3. ROADMAP/CLAUDE.md/project-context.md 수정분 재감사 1라운드 — 무발견, + 여기서 종료. + +`python3 .claude/tools/doc-check.py`는 전 과정에서 ERROR 0 유지(기존 +WARN 8건은 이 세션과 무관한 사전 부채). + +## 다음에 확인할 것 + +없음 — M2(디스패치 엔진) 착수가 다음 마일스톤. M2 진입 전 필독 문서는 +`ROADMAP.md` 상단 배너와 `todos.md` 0번이 이미 안내하고 있음(`typing-limits.md`/ +`dispatch-core-plan.md`). diff --git a/.claude/todos.md b/.claude/todos.md index 7cd86ff..0b5084b 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -43,9 +43,9 @@ 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` 3번에도 올라가 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 - 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 - 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, - 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: + 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시 + 검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/` + 문서에도 ⚠️로 표시돼 있다: - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 @@ -71,8 +71,11 @@ - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. - - **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`) - — M0에서 실측 확인. + - **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가 + 나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`, + `ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키 + 접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store = + Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요. 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy @@ -197,6 +200,10 @@ 참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의 전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계 게이트와 무관. + **[2026-08-19 추가]** `quad-roblox-types`(가칭, `quad-types`와 같은 + 패턴으로 `quad-roblox` 전체 대신 그 타입만 필요한 모듈을 위한 패키지)도 + 같은 성격의 백로그로 신설 — 사용자가 지금 만들 필요는 없다고 명시적으로 + 후순위 지정, 상세는 `base/quad-types-plan.md`의 "남은 것" 절. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). 6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include diff --git a/.gitignore b/.gitignore index 726540f..990d99b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,14 @@ # Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음) __pycache__/ *.pyc + +# pesde 설치 산출물(각 패키지 로컬에 생김) — pesde.lock 커밋 여부는 아직 +# 미정(라이브러리 컨벤션 확인 필요, HUMAN_TODO 참고)이라 여기 안 넣음 +roblox_packages/ +luau_packages/ +lune_packages/ +.pesde/ + +# luau-lsp가 rojo 설치를 감지하면 백그라운드에서 자동 생성/watch함 +# (에디터 타입 링킹용 산출물, 커밋 대상 아님) +sourcemap.json diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..e53b762 --- /dev/null +++ b/.luaurc @@ -0,0 +1,10 @@ +{ + "languageMode": "strict", + "lint": { + "*": true + }, + "aliases": { + "quad-base": "quad-base/src", + "quad-roblox": "quad-roblox/src" + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e7ad09c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "luau-lsp.fflags.enableNewSolver": true +} diff --git a/CLAUDE.md b/CLAUDE.md index 4778dbe..09dde0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,10 @@ # CLAUDE.md Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트. -**[2026-08-16 기준] 지금은 설계/계획 단계이고 구현은 아직 시작 전** — 같은 -상태를 `.claude/project-context.md`도 서술하니 M0에 착수하면 두 곳을 같이 -고칠 것. +**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치 +엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`도 +서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는 +항상 루트 `ROADMAP.md`.