From eae94d0574e6efd43c5917f143e7c0e1c0a76789 Mon Sep 17 00:00:00 2001 From: qwreey Date: Sat, 15 Aug 2026 15:06:39 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20typeof(named=20fn)=20=EA=B0=84=EC=A0=91?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EB=A1=9C=200-Y=20=EC=9A=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1,=20luau-test/16=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing-limits.md §1에 검증된 선언 스타일 규약(③) 추가 — 재귀 메소드를 인라인 대신 이름 붙은 함수 + typeof로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출 확인). setmetatable 확장은 quad 계약에서 솔버 버그를 만나 채택 안 함. luau-test/16(type function으로 Store 필드 합성)을 복구해 done/으로 이동 — 원인은 설계 문제가 아니라 API 버전 드리프트, §5를 검증 완료로 승격. 전체 실측: audit/type-recursive-issue-with-typeof/(REPORT.md + spikes 15개). code-review 지적 4건(불건전 판정 오류, 음성 대조군 개수, 체이닝 깊이 주장의 재현 스파이크 누락, store-plan.md stale 참조) 전부 반영. Co-Authored-By: Claude Sonnet 5 --- .claude/README.md | 4 +- .../REPORT.md | 248 ++++++++++++++++++ .../00-user-original-typeof-discovery.luau | 66 +++++ .../00b-user-metatable-nonrecursive.luau | 20 ++ ...c-user-metatable-typefunction-attempt.luau | 27 ++ .../spikes/01-baseline-inline-leak.luau | 25 ++ .../spikes/02-typeof-no-split.luau | 32 +++ .../spikes/03-split-inline-no-typeof.luau | 27 ++ .../spikes/04-split-typeof.luau | 32 +++ .../05-WINNER-typeof-selfhandle-chain.luau | 39 +++ .../06-known-gap-wrong-lhs-not-caught.luau | 11 + .../07-metatable-clean-when-U-equals-T.luau | 19 ++ ...tatable-BUG-contradictory-diagnostics.luau | 9 + .../09-typefunction-store-key-FIXED.luau | 39 +++ ...-typefunction-recursive-state-deadend.luau | 18 ++ ...unannotated-selfparam-all-calls-break.luau | 50 ++++ ...2-chain-depth-1-3-5-8-50-typechanging.luau | 74 ++++++ .claude/base/store-plan.md | 11 +- .claude/base/typing-limits.md | 136 ++++++++-- .claude/luau-test/README.md | 2 +- .claude/luau-test/STATUS.md | 14 +- .../done/16-type-store-key-typefunction.luau | 69 +++++ .../16-type-store-key-typefunction.luau | 67 ----- .claude/research/pre-implementation-audit.md | 7 +- ...-01-typeof-recursive-generic-workaround.md | 104 ++++++++ CLAUDE.md | 17 ++ 26 files changed, 1064 insertions(+), 103 deletions(-) create mode 100644 .claude/audit/type-recursive-issue-with-typeof/REPORT.md create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/00-user-original-typeof-discovery.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/00b-user-metatable-nonrecursive.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/00c-user-metatable-typefunction-attempt.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/01-baseline-inline-leak.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/02-typeof-no-split.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/03-split-inline-no-typeof.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/04-split-typeof.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/05-WINNER-typeof-selfhandle-chain.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/06-known-gap-wrong-lhs-not-caught.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/07-metatable-clean-when-U-equals-T.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/08-metatable-BUG-contradictory-diagnostics.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/09-typefunction-store-key-FIXED.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/10-typefunction-recursive-state-deadend.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/11-metatable-unannotated-selfparam-all-calls-break.luau create mode 100644 .claude/audit/type-recursive-issue-with-typeof/spikes/12-chain-depth-1-3-5-8-50-typechanging.luau create mode 100644 .claude/luau-test/done/16-type-store-key-typefunction.luau delete mode 100644 .claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau create mode 100644 .claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md diff --git a/.claude/README.md b/.claude/README.md index 34c376b..83e54a6 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -16,7 +16,7 @@ | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[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` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 4개**: `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/` 44개. 다른 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`) | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 6개**: `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/` 44개. 다른 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 승격) | | `tools/` | **[2026-08-13 아홉 번째 세션 신설]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | | `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 | | `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -30,7 +30,7 @@ | 문서 | 내용 | |---|---| | `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, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` | +| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (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/` | | `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` 하나를 공유) | | `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급 경로이고 `store "key"` 문자열 커링은 동적 키용 미타입 폴백, 레코드 필드 타이핑은 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엔 불필요) | diff --git a/.claude/audit/type-recursive-issue-with-typeof/REPORT.md b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md new file mode 100644 index 0000000..151aed7 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md @@ -0,0 +1,248 @@ +# `typeof(namedFn)` 간접참조가 0-Y를 실제로 우회하는지 — 실측 + +**출발점**: 사용자가 `luau` 설계자와 가까운 사람과의 대화에서 힌트를 얻어 +`test-ignoreme.luau`를 직접 작성 — `Compute`류 메소드를 타입 안에 +**인라인 제네릭 시그니처로 직접 쓰지 않고**, 이름 붙은 top-level 함수로 +선언한 뒤 `typeof(그함수)`로 필드 타입을 참조하면 `base/typing-limits.md` +1번(0-Y, 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 조용히 +`Unifiable`로 새는 문제)이 안 생기는 것 같다는 관찰. 이 문서는 +그 관찰을 `--annotate` + 부정 대조군으로 검증한 기록입니다. + +**이 폴더의 구성**: `REPORT.md`(이 문서) + `spikes/`(재현 스크립트, +`00`~`12`, 사용자가 만든 `test-ignoreme.luau`/`test2`/`test3`도 `00`대에 +그대로 보존). `type-recursion-issue/`와 같은 이유로 스크립트를 같이 +둡니다 — 여러 formulation을 대조한 판정이라 개별 파일을 직접 돌려야 +재현됨. + +**실측 환경**: Luau `0.733`(`mise ls luau`), `luau-analyze`(새 솔버 +기본값)와 `luau-analyze --solver=old` 둘 다로 교차검증. + +## TL;DR + +**결론이 두 번 뒤집혔습니다.** 처음엔 "고침" → 실제 quad 계약으로 +재확인하니 "일부만 고침, 콜백 파라미터가 self 핸들이면 여전히 안 됨" → +`setmetatable` 없이 순수 `typeof`만 쓰면 "완전히 고침"으로 최종 수렴. +그 과정에서 **quad와 무관한 Luau 솔버 버그**(모순되는 진단 두 개가 동시에 +남)도 하나 발견했습니다. + +| 질문 | 답 | +|---|---| +| `typeof(namedFn)` 간접참조가 0-Y(반환 타입 leak)를 없애는가? | **✅ 없앱니다** — `setmetatable` 없이 쓸 때. LHS 명시 주석 없이도 다운스트림이 정확히 타이핑됨(체이닝 깊이 50, 타입이 바뀌는 체이닝, 콜백 안 재귀 self 호출까지 전부 확인). | +| 콜백 파라미터(self) 자동 추론도 같이 풀리는가? | **❌ 아니오.** 파라미터는 여전히 명시 주석 필요(`function(a0: State)`) — 무주석이면 자유 타입 변수 `unknown`. 이건 0-Y와 별개 문제(기존 "쪼개기"가 다루던 문제)이고, `typeof`는 이걸 안 풀어줍니다. | +| `setmetatable<{...}, {__index: typeof(...)}>`로 파라미터 자동 추론까지 얻을 수 있는가? | **부분적으로 — 그리고 위험합니다.** raw 값을 파라미터로 받으면(quad 계약 아님) 자동 추론되지만, **quad의 실제 계약(콜백이 self 핸들을 받음)에서 콜백 반환 타입이 self의 T와 다르면(quad `Compute`의 존재 이유 그 자체) 올바른 대입에도 모순되는 진단 두 개가 동시에 남는 솔버 버그**를 발견. quad에 채택 불가. | +| `type function`으로 `Store`→`{[K]: Source}` 합성(0-A 관련 백로그, `typing-limits.md` §5)은? | **✅ 이제 완전히 통과.** 깨져 있던 이유는 API 버전 드리프트(`types.newfunction`의 두 번째 인자가 배열이 아니라 `{head=..., tail=...}` 레코드로 바뀜) — 실제 설계 문제가 전혀 아니었음. | +| `type function`으로 `Compute: State` 자체(재귀 반환)를 지연 평가할 수 있는가? | **❌ 막다른 길.** 제네릭 U가 아직 구체화 안 된 채로 자기 자신을 재귀 호출하면 `stack overflow`로 즉시 죽음 — type function 실행 모델 자체가 구체 타입만 다루므로 구조적으로 안 됨. | +| `base/typing-limits.md` §1(0-Y 원칙)을 뒤집어야 하는가? | **아니오.** §1의 "명시 바인딩 강제" 원칙은 지금도 유효합니다(아래 "무엇이 남는가" 참고) — 이 실측은 그 원칙에 **선언 스타일 규약**을 하나 추가할 근거를 줄 뿐, 원칙 자체를 반박하지 않습니다. | + +## 검증 방법 + +`type-recursion-issue/`와 동일하게 매 formulation마다: +1. `luau-analyze --annotate`로 **실제 추론된 타입**을 눈으로 확인(진단 + 0건 ≠ 안전, `typing-limits.md`가 이미 경고한 함정). +2. **양성 대조군**: 정상 사용(`ok: T = ...`)이 진짜로 에러 없이 통과하는지. +3. **음성 대조군**: 명백히 틀린 사용(`wrong: U = ...`, 없는 메소드 호출)이 + 진짜로 에러가 나는지 — 둘 다 확인해야 "타입이 살아있다"고 말할 수 있음 + (에러가 하나도 안 나는 건 안전이 아니라 `any`로 샌 것일 수도 있음). +4. 체이닝 깊이 1/3/5/8/50, 콜백 안에서 self를 다시 호출하는 중첩 케이스, + 구솔버(`--solver=old`) 대조까지 포함. + +## 1. 대조군 — 인라인 선언은 여전히 leak (기존 0-Y 그대로 재현) + +`spikes/01-baseline-inline-leak.luau` — `type State = { Compute: +(self: State, ...) -> State }`처럼 타입 안에 직접 재귀 제네릭 +메소드를 쓰면, **명시 LHS 주석이 틀려도(`wrong3: State = ...`) +그 줄 자체가 에러 없이 통과**하고 무주석 변수는 다운스트림 오용까지 +전부 새어나감(`--annotate`로 보면 `Unifiable`). `typing-limits.md` +§1의 서술과 정확히 일치 — 이게 이 실측 전체의 기준선입니다. + +## 2. `typeof` 간접참조만으로 반환 타입 leak이 사라짐 + +`spikes/02-typeof-no-split.luau` — `Compute`를 top-level `local function +Compute(self: State, func): State`로 선언하고 `type State = +{ Compute: typeof(Compute) }`처럼 참조만 바꿨더니: +- 무주석 `r2 = test:Compute(...)`도 `r2:Get()`에 틀린 타입을 대입하면 + **정확히 에러남**(§1이 요구하던 "명시 바인딩 없이도 안전"이 여기선 + 실제로 됨). +- `--annotate`로 보면 `r2`의 실제 타입이 `Unifiable`가 아니라 + 진짜 구조적 타입(`{Get:..., Compute:..., value: number}`, 재귀는 + ``로 정상 표기)으로 잡힘. +- **`const`는 무관** — `local function`으로도 동일(사용자의 처음 + 가설이었던 `const`가 원인이 아니라, "재귀가 타입 별칭 자기 확장이 + 아니라 이름 붙은 함수의 `typeof` 안에 있다"는 것 자체가 원인). + +**대가**: 콜백 파라미터(`a0`)는 여전히 무주석이면 자유 타입 변수(`a0: +a`, 실질적으로 `unknown`) — 이건 0-Y와 별개인 "파라미터 추론" 문제라 +`typeof`가 안 풀어줌(4번 참고). + +## 3. 체이닝 깊이는 문제가 안 됨 — 진짜 지연/공유형이지 재재료화가 아님 + +`spikes/05-WINNER-typeof-selfhandle-chain.luau`가 최종 승자 formulation을 +담고 있지만 **깊이 3까지만** 체이닝합니다 — 깊이 5/8/50 검증은 별도 +`spikes/12-chain-depth-1-3-5-8-50-typechanging.luau`(코드리뷰 지적으로 +뒤늦게 분리·추가, 원래 이 REPORT는 이 파일 없이 아래 주장을 하고 +있었음)가 근거입니다. 아래는 `05`+`12` 둘을 합쳐 실제로 확인하는 것: + +- 깊이 1/3/5/8/**50**까지 체이닝(`d1:Compute(...):Compute(...)...`), + 각 단계마다 양성(`ok: T = dN:Get()`)/음성(`wrong: U = dN:Get()`) + 대조군 쌍 — **음성은 전부 정확히 에러나고 양성은 전부 통과**, + 놓치는 단계 없음. +- 각 단계에서 **타입이 실제로 바뀌는** 체이닝(number→boolean→string→ + number 3-순환) — 여전히 정확. +- `--annotate`로 본 `dN`의 추론 타입 크기가 깊이와 무관하게 **일정** + (depth 1과 depth 50에서 거의 같은 글자 수) — 이건 "매 링크마다 전체 + 구조를 재료화하는 eager 확장"이 아니라 진짜 지연/공유 평가라는 + 신호(사용자가 원래 가설로 세웠던 "지연 확장"과 부합). +- 타이밍: 50단 체이닝 전체 `luau-analyze` **0.02초 안팎** — 컴파일 + 타임 절벽 없음. +- **콜백 안에서 self를 다시 호출하는 중첩**(`a0:Compute(...)`, 실제 + quad에서 Effect/파생 콜백이 받은 state를 또 파생시키는 패턴과 동형)도 + 정확 — 이건 4번의 `setmetatable` 버전에서 터진 버그가 여기선 안 + 일어남을 보여주는 핵심 대조. +- **건전성**: 존재하지 않는 메소드(`a0:NoSuchMethod()`)를 호출하면 + 정확히 에러남 — self 타입이 `any`로 새서 아무 호출이나 받아주는 + 게 아님을 확인. + +## 4. 알려진(그리고 인정 가능한) 남은 구멍 — 명시 LHS 오타입 그 줄 자체 + +`spikes/06-known-gap-wrong-lhs-not-caught.luau` — `local wrong: +State = test:Compute(function(a0: State) return 1 +end)`처럼 **그 대입 줄 자체**에 틀린 타입 주석을 달면 그 줄은 에러 +없이 통과합니다(다운스트림은 여전히 안전 — `wrong` 변수를 실제로 쓰면 +그때 잡힘, 3번의 다운스트림 검증과 동일 메커니즘). 이건 **새 구멍이 +아니라 `typing-limits.md` §1이 이미 명시한 그 구멍**("주석이 그 한 +줄의 RHS를 검증해주진 않지만... 다운스트림에는 정확히 바인딩된다")과 +정확히 같은 종류 — `typeof`를 써도 이 특정 지점은 그대로 남습니다. +회귀가 아니라 원래 알려진 한계가 형태를 유지한 것. + +## 5. `setmetatable` + 명시 제네릭 인스턴스화(`<>()`) — 별도 실험, 결론은 부정적 + +사용자가 `test2`/`test3-ignoreme.luau`에서 시도한 `setmetatable<{inner:T}, +{__index: typeof(genericFn<>())}>` 패턴(quad가 이미 확정해 쓰는 +`Modifier`의 `__index` + `table.clone` 체이닝, `typing-limits.md` §6과 +같은 계열)을 재귀 `Compute`에 확장해봤습니다. + +### 5-1. raw 값을 콜백 파라미터로 받으면(quad 계약 아님) 정말 좋음 + +`spikes/07-metatable-clean-when-U-equals-T.luau`(정확히는 이 파일은 +U==T 케이스; 원래 raw-param 버전은 아래 5-3 이전 실험) — 콜백이 +`(rawValue) -> U`를 받는 형태면 **파라미터가 무주석이어도 자동으로 +`number`로 추론**되고, 체이닝 50단에서도 hover 타입이 +`{inner:number}`로 고정 크기 유지(0-Y-free보다도 더 깔끔). 하지만 +**이건 quad의 실제 계약이 아닙니다** — quad `Compute`의 콜백은 lazy +self 핸들 자체를 받아야 함(여러 세션에 걸쳐 확정된 계약, +`base/typing-limits.md`/`bind-system-plan.md` 참고). + +### 5-2. 콜백이 self 핸들을 무주석으로 받으면 — 정상/오용 안 가리고 모든 호출이 깨짐 + +**[2026-08-15 정정, `/code-review high` 지적 — 아래는 원래 서술이 +틀렸던 부분을 고친 것]** 콜백 파라미터 타입을 `Box`(self 자기 +자신)로 하고 무주석으로 두면, Luau가 **콜백 안에서 뭘 호출했는가만 +보고 그때그때 파라미터 타입을 재정의하는 duck-typing**으로 샙니다. +처음엔 이게 "존재하지 않는 메소드도 조용히 통과시키는 불건전"이라고 +서술했으나, `spikes/11-metatable-unannotated-selfparam-all-calls-break.luau`를 +다시 돌려보면 **`a0:NoSuchMethod()`를 호출하는 줄도 실제로는 +에러납니다** — 다만 "그런 메소드가 없다"는 정상적인 에러가 아니라, +duck-typing으로 재정의된 콜백의 함수 타입 자체가 `Compute`가 기대하는 +함수 타입과 구조적으로 안 맞아서 나는 에러입니다. 그리고 이 에러는 +`NoSuchMethod` 호출뿐 아니라 **`a0:Get()`처럼 완전히 정상적인 호출을 +쓰는 줄에도 똑같이 남** — 이 파일 안 5개 `test:Compute(...)` 호출 +전부가 예외 없이 에러납니다. 즉 실제 문제는 "나쁜 코드를 몰래 +통과시킴"(불건전)이 아니라 **"콜백 파라미터가 self를 재귀 참조하고 +무주석이면, 맞는 코드든 틀린 코드든 가리지 않고 전부 타입체크에 +실패해 이 formulation 자체를 못 씀"**입니다. 결론(채택 안 함)은 +바뀌지 않지만 이유는 다릅니다. + +### 5-3. 콜백이 self 핸들을 명시 주석으로 받고, 반환 타입이 원래 T와 다르면 — 솔버 버그 + +`spikes/08-metatable-BUG-contradictory-diagnostics.luau`(9줄짜리 최소 +재현) — 5-2처럼 콜백 전체가 깨지는 걸 막으려 파라미터에 명시 주석 +(`Box`)을 달면, 정상 사용은 정상적으로 통과하고 존재하지 않는 +메소드도 진짜로 잡힙니다. 그런데 **콜백의 반환 +타입이 self의 원래 T와 다르면**(예: `Box` 위에서 콜백이 +`boolean`을 반환) — 이건 quad `Compute`가 존재하는 이유 그 자체인 +가장 흔한 케이스 — **완전히 올바른 대입(`ok: boolean = +d1:Get()`)에도 모순되는 진단 두 개가 동시에 남습니다**: + +``` +Expected this to be exactly 'number', but got 'boolean' +Expected this to be 'boolean', but got 'number' +``` + +`spikes/07`(U==T, 타입 안 바뀜)은 이 버그가 안 남 — **트리거는 정확히 +"self-참조 파라미터 + 콜백이 다른 타입을 반환"** 조합입니다. 이건 +quad 설계 문제가 아니라 **`setmetatable`+`typeof(genericFn<>())` +조합에 특정된 Luau 0.733 솔버 버그**로 보임 — `setmetatable` 없이 +순수 `typeof`만 쓰면(3번) 정확히 같은 시나리오가 깨끗하게 통과하는 걸 +확인했으므로, `typeof` 자체의 문제가 아니라 metatable 경로 특정 문제로 +좁혀짐. **quad에 이 formulation은 채택하지 않음.** (업스트림 제보 +여부는 사용자 판단 — 최소 재현은 `08` 파일 하나로 충분히 작음, 이미 +알려진 이슈인지는 미확인.) + +## 6. `type function` — `Store` 필드 합성은 고쳐짐, 재귀 `Compute`는 막다른 길 + +### 6-1. `luau-test/done/16-type-store-key-typefunction.luau` 복구 성공(원래 `rewrite-required/`) + +`spikes/09-typefunction-store-key-FIXED.luau` — 원래 스파이크가 깨진 +이유는 설계 문제가 아니라 **API 버전 드리프트**: `types.newfunction`의 +시그니처가 `(parameters: {head, tail}, returns: {head, tail}, generics?)`로 +**레코드**를 받는데, 원본 스파이크는 `types.newfunction({}, { ty })`처럼 +**배열**을 넘기고 있었음(`{head = {ty}}`가 맞음). 이것만 고치고, self +파라미터도 `types.newtable()`이 반환하는 **뮤터블 핸들 자기 자신**을 +그대로 self 타입으로 넘기면(나중에 `setproperty`로 채워도 핸들이라 +소급 반영됨) 됩니다. 수정 후: +- `ProcessStoreType<{ty:string, count:number}>`가 정확히 + `{ty: Source, count: Source}` 구조를 만족. +- 4개 음성 대조군(틀린 타입 Get, 틀린 타입 Set 2건, 존재하지 않는 메소드) + **전부 정확히 에러남**. +- 에러 메시지도 `t1 where t1 = { Get: (t1) -> string, ... }`처럼 + **`typeof` 간접참조보다 훨씬 읽기 쉬움**(재귀가 `` 없이 한 + 단계 `t1`로 깔끔하게 표기됨) — hover/에러 가독성 면에서 오히려 + `typeof`보다 나음. + +**→ `pre-implementation-audit.md` 1-10과 `typing-limits.md` §5의 +근거가 됨. 이건 "설계가 막혔었다"가 아니라 "실측 스크립트가 낡은 +API를 썼었다"였을 뿐이라, 원 설계(`bind-system-plan.md`의 `store.key` +레코드 필드 타이핑)는 그대로 유효 — 승격만 하면 됨.** + +### 6-2. 재귀 `Compute: State` 자체를 type function으로 지연시키는 건 막다른 길 + +`spikes/10-typefunction-recursive-state-deadend.luau` — `MakeState(ty)` +안에서 `Compute` 메소드의 반환 타입을 만들려고 `MakeState(U)`를(U는 +아직 구체화 안 된 `types.generic("U")`) 호출하면 **`stack overflow`로 +type function 자체가 런타임 크래시**합니다. type function은 실행 시점에 +구체 타입에 대해서만 동작하는 모델이라(RFC가 별칭에 주려는 "진짜 +lazy expansion"과 다름), 아직 안 정해진 제네릭을 인자로 자기 자신을 +재호출하는 걸 구조적으로 지원하지 않음 — `types` 라이브러리에 "지금 +당장 안 풀고 나중에 적용할 타입 함수 호출"을 표현하는 API(예: +`types.apply(fn, args)`류)가 없음(`types.generic`/`types.newfunction` +등 목록에 없음, `luau.org/types-library` 확인). +**결론: type function으로 0-Y 자체를 우회하는 시도는 여기서 막힘 — +더 이상 이 방향으로 시간 쓸 필요 없음.** + +## 무엇이 남는가 (base/ 반영 방향, 다음 세션에서 처리) + +- **`typing-limits.md` §1은 뒤집지 않습니다.** "재귀 제네릭 반환이 + 조용히 샌다"는 여전히 참이고 명시 바인딩 원칙도 여전히 유효합니다 + (4번 참고 — `typeof`를 써도 "그 줄 자체"의 구멍은 남음). 이 실측이 + 주는 건 **원칙에 대한 반박이 아니라 선언 스타일 하나를 추가로 검증한 + 것** — "메소드를 재귀 타입 안에 인라인으로 쓰지 않고, 이름 붙은 + top-level 함수 + `typeof`로 참조하면, LHS 명시 없이도 *다운스트림*이 + 안전해진다"는 관례를 **추가**로 문서화할 근거. **콜백 파라미터 + 명시 주석은 여전히 필수**(§1의 "쪼개기"가 다루던 문제는 그대로 남음, + `typeof`가 대신 풀어주지 않음). +- **`setmetatable` 계열은 채택하지 않음** — quad의 실제 self-핸들 계약 + 아래서 (a) 무주석이면 정상/오용 가리지 않고 콜백 전체가 타입체크 + 실패, (b) 명시 주석이어도 타입이 바뀌는 가장 흔한 케이스에서 솔버 + 버그. 이 결과 자체는 "시도했고 왜 안 되는지 안다"로 기록해두는 + 가치가 있음(다음에 누가 또 같은 길을 시도하지 않도록). +- **`typing-limits.md` §5는 "미검증" → "검증 완료"로 승격.** 스파이크 + `16`을 `spikes/09`의 수정 내용으로 고쳐 `luau-test/done/`으로 옮기고 + `pre-implementation-audit.md` 1-10 관련 서술도 "설계 확정, 실측 + 미완"에서 "설계+실측 둘 다 완료"로 갱신. +- **선언 스타일 규약이 base 코드 전체(특히 `bind-system-plan.md`/ + `source-state-plan.md`의 `State`/`Source`/`Modifier` 등 재귀 제네릭을 + 쓰는 모든 타입)에 실제로 반영되려면**, 그 문서들의 pseudocode를 + 인라인 메소드 선언에서 `local function` + `typeof` 참조 스타일로 + 바꿔야 함 — 이건 **중대 변경 핸드오버 체크리스트 대상**(여러 문서에 + 흩어진 pseudocode를 한 번에 고쳐야 함)이라 이 세션에서 바로 하지 + 않고 사용자 확인 후 별도로 처리 권장. diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/00-user-original-typeof-discovery.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/00-user-original-typeof-discovery.luau new file mode 100644 index 0000000..d6bc744 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/00-user-original-typeof-discovery.luau @@ -0,0 +1,66 @@ +--!strict + +const function Apply(self: State, func: (State) -> U): U + return nil :: any +end +const function Compute(self: State, func: (State) -> U): State + return nil :: any +end +const function Get(self: State): T + return nil :: any +end + +type State = { + read Apply: typeof (Apply), + read Get: typeof (Get), + read Compute: typeof (Compute), + read value: T, +} + +const test = (nil :: any)::State + +-- TypeError: Expected this to be 'string', but got 'number' +const testres_1: string = test:Apply(function(a0) + return 0 +end) + +-- TypeError: Expected this to be +-- 'State' +-- but got ... +const testres_2: State = test:Compute(function(a0) + return 0 +end) + +-- OK. no type error +const testres_2: State = test:Compute(function(a0) + return true +end) +-- But every a0 is unknown. so we must bind it. i will find out more + +function Mapper(a0: State): number + return 1 +end +-- TypeError: Expected this to be 'boolean', but got 'number' +-- error in upstream test, not Compute(Mapper) +-- red line under 'test' +-- i think this is might be ok +test:Compute(Mapper) +-- Same error +test:Compute(function(a0: State) + return 1 +end) + +-- OK +const testres_3 = test:Compute(function(a0: State) + return 1 +end) +-- TypeError: Expected this to be 'string', but got 'number' +const testres_4: string = testres_3:Get() + +-- Error +-- TypeError: Expected this to be +-- 'State' +-- but got ... +const testres_5: State = test:Compute(function(a0: State) + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/00b-user-metatable-nonrecursive.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/00b-user-metatable-nonrecursive.luau new file mode 100644 index 0000000..67fbd05 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/00b-user-metatable-nonrecursive.luau @@ -0,0 +1,20 @@ +--!strict + +-- ApplyT 말고 f 쪽에 Box 를 하려는 순간 문제 발생함 +-- 언노운 되어버림. +const applyImpl = (nil :: any) :: ()->( + (self: Box, f: (ApplyT) -> U) -> U +) +const boxImpl = (nil :: any) :: () -> { + Apply: typeof(applyImpl<>()) +} + +type Box = setmetatable<{ + inner: T, +}, { __index: typeof(boxImpl<>()) }> + +const t = (nil::any)::Box +local o = t:Apply(function(a0) + return 1 +end) + diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/00c-user-metatable-typefunction-attempt.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/00c-user-metatable-typefunction-attempt.luau new file mode 100644 index 0000000..f768d2a --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/00c-user-metatable-typefunction-attempt.luau @@ -0,0 +1,27 @@ +local box = {} + +const ApplyImpl = (nil :: any) :: ()->( + (self: Box, mapper: (Box)->U)->U +) + +box.BoxMethod = (nil :: any) :: ()->( + { + Apply: typeof(ApplyImpl<>()) + } +) + +type function BoxFactory(t: type): type + return types.newtable({ + [types.singleton "Value"] = t, + }) +end + +type Box = setmetatable, { + __index: typeof(box.BoxMethod<>()) +}> + +const t = (nil::any)::Box +t:Apply(function(a0) -- 언노운 + +end) + diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/01-baseline-inline-leak.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/01-baseline-inline-leak.luau new file mode 100644 index 0000000..aa1a014 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/01-baseline-inline-leak.luau @@ -0,0 +1,25 @@ +--!strict +-- STYLE A: inline recursive generic method inside a single self-referencing type, WITH `read` + +type State = { + read Get: (self: State) -> T, + read Compute: (self: State, func: (State) -> U) -> State, +} + +local test = (nil :: any) :: State + +-- CHECK1: unannotated callback param -- is a0 inferred or unknown? +local r1 = test:Compute(function(a0) + return a0 +end) + +-- CHECK2: annotated param, unannotated LHS, negative control on downstream :Get() +local r2 = test:Compute(function(a0: State): number + return 1 +end) +local wrong2: string = r2:Get() + +-- CHECK3: annotated param, LHS annotated WRONG type (negative control at assignment site) +local wrong3: State = test:Compute(function(a0: State): number + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/02-typeof-no-split.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/02-typeof-no-split.luau new file mode 100644 index 0000000..ac73ee7 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/02-typeof-no-split.luau @@ -0,0 +1,32 @@ +--!strict +-- STYLE B: named functions + typeof, single self-referencing type, WITH `read` + +local function Get(self: State): T + return nil :: any +end +local function Compute(self: State, func: (State) -> U): State + return nil :: any +end + +type State = { + read Get: typeof(Get), + read Compute: typeof(Compute), +} + +local test = (nil :: any) :: State + +-- CHECK1 +local r1 = test:Compute(function(a0) + return a0 +end) + +-- CHECK2 +local r2 = test:Compute(function(a0: State): number + return 1 +end) +local wrong2: string = r2:Get() + +-- CHECK3 +local wrong3: State = test:Compute(function(a0: State): number + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/03-split-inline-no-typeof.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/03-split-inline-no-typeof.luau new file mode 100644 index 0000000..5a59f3a --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/03-split-inline-no-typeof.luau @@ -0,0 +1,27 @@ +--!strict +-- STYLE C: split StateData/State, inline method type in State, WITH `read` + +type StateData = { + read Get: (self: StateData) -> T, +} +type State = StateData & { + read Compute: (self: StateData, func: (StateData) -> U) -> State, +} + +local test = (nil :: any) :: State + +-- CHECK1: unannotated callback param -- does splitting auto-infer it as StateData? +local r1 = test:Compute(function(a0) + return a0 +end) + +-- CHECK2: annotated param, unannotated LHS, negative control on downstream :Get() +local r2 = test:Compute(function(a0: StateData): number + return 1 +end) +local wrong2: string = r2:Get() + +-- CHECK3: LHS annotated WRONG type +local wrong3: State = test:Compute(function(a0: StateData): number + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/04-split-typeof.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/04-split-typeof.luau new file mode 100644 index 0000000..8b56e36 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/04-split-typeof.luau @@ -0,0 +1,32 @@ +--!strict +-- STYLE D: split StateData/State, typeof-derived method type in State, WITH `read` + +type StateData = { + read Get: (self: StateData) -> T, +} + +local function Compute(self: StateData, func: (StateData) -> U): State + return nil :: any +end + +type State = StateData & { + read Compute: typeof(Compute), +} + +local test = (nil :: any) :: State + +-- CHECK1: unannotated callback param +local r1 = test:Compute(function(a0) + return a0 +end) + +-- CHECK2: annotated param, unannotated LHS, negative control on downstream :Get() +local r2 = test:Compute(function(a0: StateData): number + return 1 +end) +local wrong2: string = r2:Get() + +-- CHECK3: LHS annotated WRONG type +local wrong3: State = test:Compute(function(a0: StateData): number + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/05-WINNER-typeof-selfhandle-chain.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/05-WINNER-typeof-selfhandle-chain.luau new file mode 100644 index 0000000..f8d58a1 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/05-WINNER-typeof-selfhandle-chain.luau @@ -0,0 +1,39 @@ +--!strict +-- 순수 typeof(no setmetatable) + self-handle 콜백 파라미터(quad 실제 계약) + 타입 변경 체이닝 +local function Get(self: State): T + return nil :: any +end +local function Compute(self: State, func: (State) -> U): State + return nil :: any +end +type State = { + Get: typeof(Get), + Compute: typeof(Compute), +} + +local test = (nil :: any) :: State + +-- 타입이 바뀌는 체이닝: number -> boolean -> string -> number +local d1 = test:Compute(function(a0: State): boolean return true end) +local d2 = d1:Compute(function(a0: State): string return "x" end) +local d3 = d2:Compute(function(a0: State): number return 1 end) + +local ok1: boolean = d1:Get() +local ok2: string = d2:Get() +local ok3: number = d3:Get() +local wrong1: string = d1:Get() +local wrong2: number = d2:Get() +local wrong3: boolean = d3:Get() + +-- 콜백 안에서 재귀 self 호출(중첩) -- 진짜 quad 패턴(예: Effect가 받은 state에 대해 또 Compute) +local nested = test:Compute(function(a0: State): string + local innerState = a0:Compute(function(a1: State): boolean return a1:Get() > 0 end) + local innerVal: boolean = innerState:Get() + return "y" +end) + +-- 존재하지 않는 메소드 -- 반드시 에러 (건전성) +local sound = test:Compute(function(a0: State): number + a0:NoSuchMethod() + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/06-known-gap-wrong-lhs-not-caught.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/06-known-gap-wrong-lhs-not-caught.luau new file mode 100644 index 0000000..6e10130 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/06-known-gap-wrong-lhs-not-caught.luau @@ -0,0 +1,11 @@ +--!strict +local function Get(self: State): T return nil :: any end +local function Compute(self: State, func: (State) -> U): State return nil :: any end +type State = { Get: typeof(Get), Compute: typeof(Compute) } + +local test = (nil :: any) :: State + +-- 명시 LHS 오타입 (self-handle 파라미터로, 실제 계약대로) +local wrong: State = test:Compute(function(a0: State): number + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/07-metatable-clean-when-U-equals-T.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/07-metatable-clean-when-U-equals-T.luau new file mode 100644 index 0000000..7bab352 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/07-metatable-clean-when-U-equals-T.luau @@ -0,0 +1,19 @@ +--!strict +local computeImpl = (nil :: any) :: () -> ( + (self: Box, f: (Box) -> U) -> Box +) +local getImpl = (nil :: any) :: () -> ( + (self: Box) -> ImplT +) +local boxImpl = (nil :: any) :: () -> { + Compute: typeof(computeImpl<>()), + Get: typeof(getImpl<>()), +} +type Box = setmetatable<{ inner: T }, { __index: typeof(boxImpl<>()) }> + +local test = (nil :: any) :: Box + +-- U == T this time (number -> number, no type change) +local d1 = test:Compute(function(a0: Box): number return a0:Get() + 1 end) +local ok: number = d1:Get() +local wrong: string = d1:Get() diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/08-metatable-BUG-contradictory-diagnostics.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/08-metatable-BUG-contradictory-diagnostics.luau new file mode 100644 index 0000000..376ec79 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/08-metatable-BUG-contradictory-diagnostics.luau @@ -0,0 +1,9 @@ +--!strict +local computeImpl = (nil :: any) :: () -> ((self: Box, f: (Box) -> U) -> Box) +local getImpl = (nil :: any) :: () -> ((self: Box) -> ImplT) +local boxImpl = (nil :: any) :: () -> { Compute: typeof(computeImpl<>()), Get: typeof(getImpl<>()) } +type Box = setmetatable<{ inner: T }, { __index: typeof(boxImpl<>()) }> + +local test = (nil :: any) :: Box +local d1 = test:Compute(function(a0: Box): boolean return true end) +local ok: boolean = d1:Get() diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/09-typefunction-store-key-FIXED.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/09-typefunction-store-key-FIXED.luau new file mode 100644 index 0000000..0319c7e --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/09-typefunction-store-key-FIXED.luau @@ -0,0 +1,39 @@ +--!strict + +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 checkTy: string = processed.ty:Get() +local checkCount: number = processed.count:Get() + +-- negative controls +local wrongTy: number = processed.ty:Get() +processed.ty:Set(123) -- should error, ty is Source, expects string +processed.count:Set("nope") -- should error, count is Source +processed.ty:NoSuchMethod() -- should error diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/10-typefunction-recursive-state-deadend.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/10-typefunction-recursive-state-deadend.luau new file mode 100644 index 0000000..a7e5847 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/10-typefunction-recursive-state-deadend.luau @@ -0,0 +1,18 @@ +--!strict + +type function MakeState(ty: type): type + local result = types.newtable() + local U = types.generic("U") + result:setproperty(types.singleton("Get"), types.newfunction({ head = { result } }, { head = { ty } })) + result:setproperty(types.singleton("Compute"), types.newfunction( + { head = { result, types.newfunction({ head = { result } }, { head = { U } }) } }, + { head = { MakeState(U) } }, + { U } + )) + return result +end + +type S = MakeState +local s: S = nil :: any +local r = s:Compute(function(a0) return "hi" end) +local checkGet: string = r:Get() diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/11-metatable-unannotated-selfparam-all-calls-break.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/11-metatable-unannotated-selfparam-all-calls-break.luau new file mode 100644 index 0000000..577f219 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/11-metatable-unannotated-selfparam-all-calls-break.luau @@ -0,0 +1,50 @@ +--!strict +-- test4/5는 콜백이 raw T를 받는 잘못된 계약이었음(quad 실제 계약은 fn(self)가 +-- lazy State 핸들 자체를 받음, base/typing-limits.md 참고). +-- self: Box를 그대로 콜백에 넘기도록 정정 + 콜백 안에서 self 메소드를 +-- 다시 호출할 수 있는지(=self가 진짜 Box로 타이핑되는지)까지 검증. + +local computeImpl = (nil :: any) :: () -> ( + (self: Box, f: (Box) -> U) -> Box +) +local getImpl = (nil :: any) :: () -> ( + (self: Box) -> ImplT +) +local boxImpl = (nil :: any) :: () -> { + Compute: typeof(computeImpl<>()), + Get: typeof(getImpl<>()), +} + +type Box = setmetatable<{ + inner: T, +}, { __index: typeof(boxImpl<>()) }> + +local test = (nil :: any) :: Box + +-- CHECK1: 무주석 콜백 파라미터 -- self가 진짜 Box로 추론되는가? +local r1 = test:Compute(function(a0) + return a0:Get() + 1 -- a0가 Box가 아니면 :Get()에서 에러남 +end) + +-- CHECK2: 콜백 안에서 a0:Compute(...) 재귀 호출(진짜 self 메소드 접근 가능?) +local r1b = test:Compute(function(a0) + local inner = a0:Compute(function(a1) return true end) + return 1 +end) + +-- CHECK3: 무주석 LHS, 다운스트림 오용 -- 반드시 에러 +local r2 = test:Compute(function(a0): number + return a0:Get() + 1 +end) +local wrong2: string = r2:Get() + +-- CHECK4: 명시 LHS 오타입 -- 반드시 에러 +local wrong3: Box = test:Compute(function(a0): number + return a0:Get() + 1 +end) + +-- CHECK5: 존재하지 않는 메소드 -- 반드시 에러 (self 타입이 any로 새지 않았는지 확인) +local r3 = test:Compute(function(a0) + a0:NoSuchMethod() + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-with-typeof/spikes/12-chain-depth-1-3-5-8-50-typechanging.luau b/.claude/audit/type-recursive-issue-with-typeof/spikes/12-chain-depth-1-3-5-8-50-typechanging.luau new file mode 100644 index 0000000..3364f36 --- /dev/null +++ b/.claude/audit/type-recursive-issue-with-typeof/spikes/12-chain-depth-1-3-5-8-50-typechanging.luau @@ -0,0 +1,74 @@ +--!strict +-- 05-WINNER의 승자 formulation(순수 typeof, self-핸들 콜백 파라미터)을 +-- 깊이 1/3/5/8/50까지 실제로 확장 검증. 05는 depth 3까지만 있어서 +-- REPORT.md 3절이 주장하는 "50단 체이닝" 자체는 이 파일이 근거임 +-- (code-review 지적으로 뒤늦게 분리 — 원래 REPORT는 이 스파이크 없이 +-- 그 주장을 함, 05와 혼동하지 말 것). +-- 매 체크포인트(d1/d3/d5/d8/d50)마다 타입이 실제로 바뀌는 3-순환 +-- (number→boolean→string→number→...) 체이닝 + 양성/음성 대조군 쌍. +local function Get(self: State): T return nil :: any end +local function Compute(self: State, func: (State) -> U): State return nil :: any end +type State = { Get: typeof(Get), Compute: typeof(Compute) } + +local test = (nil :: any) :: State + +local d1 = test:Compute(function(a0: State): boolean return true end) +local ok_d1: boolean = d1:Get() +local wrong_d1: number = d1:Get() +local d2 = d1:Compute(function(a0: State): string return "x" end) +local d3 = d2:Compute(function(a0: State): number return 1 end) +local ok_d3: number = d3:Get() +local wrong_d3: boolean = d3:Get() +local d4 = d3:Compute(function(a0: State): boolean return true end) +local d5 = d4:Compute(function(a0: State): string return "x" end) +local ok_d5: string = d5:Get() +local wrong_d5: boolean = d5:Get() +local d6 = d5:Compute(function(a0: State): number return 1 end) +local d7 = d6:Compute(function(a0: State): boolean return true end) +local d8 = d7:Compute(function(a0: State): string return "x" end) +local ok_d8: string = d8:Get() +local wrong_d8: boolean = d8:Get() +local d9 = d8:Compute(function(a0: State): number return 1 end) +local d10 = d9:Compute(function(a0: State): boolean return true end) +local d11 = d10:Compute(function(a0: State): string return "x" end) +local d12 = d11:Compute(function(a0: State): number return 1 end) +local d13 = d12:Compute(function(a0: State): boolean return true end) +local d14 = d13:Compute(function(a0: State): string return "x" end) +local d15 = d14:Compute(function(a0: State): number return 1 end) +local d16 = d15:Compute(function(a0: State): boolean return true end) +local d17 = d16:Compute(function(a0: State): string return "x" end) +local d18 = d17:Compute(function(a0: State): number return 1 end) +local d19 = d18:Compute(function(a0: State): boolean return true end) +local d20 = d19:Compute(function(a0: State): string return "x" end) +local d21 = d20:Compute(function(a0: State): number return 1 end) +local d22 = d21:Compute(function(a0: State): boolean return true end) +local d23 = d22:Compute(function(a0: State): string return "x" end) +local d24 = d23:Compute(function(a0: State): number return 1 end) +local d25 = d24:Compute(function(a0: State): boolean return true end) +local d26 = d25:Compute(function(a0: State): string return "x" end) +local d27 = d26:Compute(function(a0: State): number return 1 end) +local d28 = d27:Compute(function(a0: State): boolean return true end) +local d29 = d28:Compute(function(a0: State): string return "x" end) +local d30 = d29:Compute(function(a0: State): number return 1 end) +local d31 = d30:Compute(function(a0: State): boolean return true end) +local d32 = d31:Compute(function(a0: State): string return "x" end) +local d33 = d32:Compute(function(a0: State): number return 1 end) +local d34 = d33:Compute(function(a0: State): boolean return true end) +local d35 = d34:Compute(function(a0: State): string return "x" end) +local d36 = d35:Compute(function(a0: State): number return 1 end) +local d37 = d36:Compute(function(a0: State): boolean return true end) +local d38 = d37:Compute(function(a0: State): string return "x" end) +local d39 = d38:Compute(function(a0: State): number return 1 end) +local d40 = d39:Compute(function(a0: State): boolean return true end) +local d41 = d40:Compute(function(a0: State): string return "x" end) +local d42 = d41:Compute(function(a0: State): number return 1 end) +local d43 = d42:Compute(function(a0: State): boolean return true end) +local d44 = d43:Compute(function(a0: State): string return "x" end) +local d45 = d44:Compute(function(a0: State): number return 1 end) +local d46 = d45:Compute(function(a0: State): boolean return true end) +local d47 = d46:Compute(function(a0: State): string return "x" end) +local d48 = d47:Compute(function(a0: State): number return 1 end) +local d49 = d48:Compute(function(a0: State): boolean return true end) +local d50 = d49:Compute(function(a0: State): string return "x" end) +local ok_d50: string = d50:Get() +local wrong_d50: boolean = d50:Get() \ No newline at end of file diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 14288f8..d3b4d1e 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -169,11 +169,12 @@ end 방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — `ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증 -난이도 문제였던 것만 해소. **단 이 `type function` 접근 자체의 실측은 -아직 미완료** — 스파이크(`luau-test/rewrite-required/16-type-store-key- -typefunction.luau`)는 `types.newfunction` 시그니처 불일치로 깨져 재작성 -대기 중(`luau-test/STATUS.md` 소스). 실측 전까지는 설계 확정이지 검증 -완료가 아님(`base/typing-limits.md`가 이 구분을 명확히 다룸). +난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의 +실측도 완료** — 스파이크(`luau-test/done/16-type-store-key- +typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져 +있었으나 원인이 설계 문제가 아니라 API 버전 드리프트였음이 드러나 수정 +후 통과(음성 대조군 4건 포함), `base/typing-limits.md` §5로 승격. +상세는 `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. ## Store가 Store를 저장 가능한가 diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 9f629cf..8304f2d 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -143,15 +143,74 @@ export type State = StateData & { (`tween-plan.md`의 `Animate`, `research/operator-sugar-plan.md`의 `Sum`류) 그런 팩토리는 최상위 함수 선언이라 자연히 주석을 답니다. +### 그래서 우리가 하는 것 — ③ 선언 스타일: 인라인 대신 `typeof(named function)` + +**[2026-08-15 추가, 근거: `audit/type-recursive-issue-with-typeof/REPORT.md`]** +①②와 별개로 추가 검증된 **선언 스타일 규약**입니다 — ①의 "명시 바인딩 +강제" 원칙을 대체하지 않고, 그 위에 얹히는 보강입니다. + +재귀 메소드(`Compute` 등)를 타입 안에 **인라인 제네릭 시그니처로 직접 +쓰지 않고**, 이름 붙은 top-level 함수로 선언한 뒤 `typeof(그함수)`로 +필드 타입만 참조하면: + +```lua +-- ✅ 이렇게 (인라인 대신 이름 붙은 함수 + typeof) +local function Compute(self: State, func: (State) -> U): State + return nil :: any +end +type State = { + Get: typeof(Get), + Compute: typeof(Compute), +} + +-- ❌ 이렇게 두면 반환 타입이 `Unifiable`로 샘(①이 다루는 원래 문제) +type State = { + Get: (self: State) -> T, + Compute: (self: State, func: (State) -> U) -> State, +} +``` + +**LHS 명시 주석 없이도 다운스트림이 정확히 타이핑됩니다** — 체이닝 +깊이 50, 타입이 바뀌는 체이닝(number→boolean→string→number), 콜백 +안에서 self를 다시 호출하는 중첩까지 전부 실측 확인(위 REPORT `2`~`3`절). +비용 없음(50단 체이닝 `luau-analyze` 0.02초 안팎, hover 타입 크기도 +깊이와 무관하게 일정). + +**바뀌지 않는 것**: +- **콜백 파라미터는 여전히 명시 주석 필요**(`function(a0: State)`) — + ②의 "쪼개기"가 다루던 문제는 별개이고, `typeof`가 대신 풀어주지 + 않습니다. 무주석이면 자유 타입 변수(`a0: unknown`에 가까움). +- **명시 LHS 오타입 그 줄 자체는 여전히 못 잡습니다**(`wrong: State + = test:Compute(...)`처럼 대입 줄 자체에 틀린 타입을 달면 그 줄은 + 통과 — 다운스트림에서만 잡힘). ①이 이미 명시한 구멍과 정확히 같은 + 종류로, **이 규약이 그 구멍을 메워주지 않습니다** — "명시 바인딩 + 관례를 완화할 수 있다"는 뜻이 절대 아닙니다. + +**시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**: +콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone` +체이닝(§6)과 같은 계열로 확장을 시도했으나, quad의 실제 계약(콜백이 +self 핸들 자체를 받음)에서 **콜백 반환 타입이 self의 원래 T와 다르면 +(= `Compute`가 존재하는 이유 그 자체) 올바른 대입에도 모순되는 진단 +두 개가 동시에 남는 Luau 0.733 솔버 버그**를 만남 — `setmetatable` +없이 순수 `typeof`만 쓰면 같은 시나리오가 깨끗이 통과하므로, `typeof` +자체가 아니라 `setmetatable` 경로 특정 문제로 좁혀짐. 최소 재현 +9줄: REPORT 5-3절. + ### 영향 범위 -| API | 파라미터 추론 | 반환 타입 안전성 | -|---|---|---| -| `state:Compute(fn)` | 쪼개기로 해결 | ❌ 명시 바인딩 필요 | -| `state:With(...)` | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 | -| `state:Apply(factory)` | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 | -| `Effect(fn, state)` | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) | -| `state:Observer(fn)` | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) | +| API | 선언 스타일 | 파라미터 추론 | 반환 타입 안전성(LHS 무주석 시) | +|---|---|---|---| +| `state:Compute(fn)` | 인라인 + 쪼개기 | 쪼개기로 자동 해결 | ❌ 명시 바인딩 필요 | +| `state:Compute(fn)` | `typeof(named fn)`(③) | 콜백 파라미터 명시 주석 필요 | ✅ 무주석이어도 안전(다운스트림) | +| `state:With(...)` | 인라인 + 쪼개기 | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 | +| `state:Apply(factory)` | 인라인 | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 | +| `Effect(fn, state)` | — | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) | +| `state:Observer(fn)` | — | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) | + +`state:With(...)`/`state:Apply(factory)`도 원리상 ③으로 같은 이득을 +받을 것으로 예상되나(둘 다 `Compute`와 같은 "재귀 자기 반환" 모양), +**아직 개별 실측은 안 함** — 실제로 base pseudocode에 ③을 반영할 때 +같이 확인할 것. **`Effect`/`Observer`는 이 문제와 무관합니다** — 한때 0-Y가 "같은 lazy 핸들 계약을 공유하니 같이 걸린다"고 서술했으나 실측 결과 아니었음 @@ -229,18 +288,39 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이 --- -## 5. `store.key` 레코드 필드 타이핑(`type function`)은 미검증 +## 5. `store.key` 레코드 필드 타이핑(`type function`) — ✅ 검증 완료 -**근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`** +**[2026-08-15 확정, 근거: `luau-test/done/16-type-store-key-typefunction.luau`, +`audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절]** `Store` → `{[K]: Source}` 합성을 Luau `type function`으로 하는 -설계는 **설계 레벨로는 확정**(`pre-implementation-audit.md` 1-10)이지만, -스파이크가 `types.newfunction` 시그니처 불일치로 깨져 **실측 확인이 안 -된 상태**입니다. +설계(`pre-implementation-audit.md` 1-10)는 **설계와 실측 둘 다 +확정**입니다. 원래 스파이크가 깨졌던 이유는 설계 문제가 아니라 +**`types.newfunction`의 API 버전 드리프트**였습니다 — 시그니처가 +`(parameters: {head: {type}?, tail: type?}, returns: {head: {type}?, +tail: type?}?, generics: {type}?): type`로 parameters/returns 둘 다 +**레코드**를 받는데, 원래 스파이크는 배열(`{ ty }`)을 그대로 넘기고 +있었습니다. self 파라미터는 `types.newtable()`이 돌려주는 **뮤터블 +핸들 자기 자신**을 그대로 참조하면 됩니다(나중에 `setproperty`로 +채워도 핸들이라 소급 반영됨). -**우리가 하는 것**: 스파이크 재작성 후 재시도(에이전트 몫, -`luau-test/STATUS.md` 🟠). `type function`은 비교적 최근/진화 중인 -기능이라 버전에 따라 API가 다를 수 있음. +수정 후 `ProcessStoreType<{ty:string, count:number}>`가 정확히 +`{ty: Source, count: Source}` 구조를 만족하고, 음성 +대조군(틀린 타입 `Get`/`Set`, 존재하지 않는 메소드) 전부 정확히 +에러납니다. 에러 메시지도 `t1 where t1 = { Get: (t1) -> string, ... }`처럼 +`` 없이 한 단계로 표기돼 아래 1번의 `typeof` 간접참조보다 +오히려 읽기 쉽습니다. + +**참고 — 1번(재귀 제네릭 반환 leak)과의 관계**: `type function`으로 +`Compute: State`처럼 **자기 자신을 재귀 호출**하는 것(1번 문제 +자체를 이 메커니즘으로 우회하는 것)은 별도로 시도해봤으나 막다른 +길이었습니다 — 제네릭 인자가 아직 구체화되지 않은 채로 type function이 +자기 자신을 호출하면 `stack overflow`로 즉시 크래시합니다(type +function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC가 별칭에 +주려는 "진짜 lazy expansion"과 다름 — `types` 라이브러리에 지연 적용을 +표현하는 API 자체가 없음). 이 항목(레코드 필드 합성)과 1번은 **서로 +다른 문제**이고, `type function`이 도와주는 건 이쪽뿐입니다. 상세는 +`audit/type-recursive-issue-with-typeof/REPORT.md` 6-2절. --- @@ -268,9 +348,17 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이 1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo` 안에서 `-> Foo`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), - 명시 바인딩 관례를 문서에 같이 적을 것. -2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 1번의 - "쪼개기"를 적용할 것(`XxxData` / `Xxx` 분리). + 메소드를 인라인 대신 이름 붙은 함수 + `typeof`로 선언(1번 ③)하고, + 그래도 명시 바인딩 관례는 그대로 문서에 같이 적을 것 — ③은 ①을 + 대체하지 않음. +2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 콜백 + 파라미터 자동 추론이 필요하면 1번의 "쪼개기"(`XxxData` / + `Xxx` 분리)를, 반환 타입 안전성이 우선이면 1번 ③(`typeof`)을 + 적용할 것 — 필요하면 병행 가능(개별 검증은 아직 안 됨, 위 "영향 + 범위" 표 참고). **`setmetatable<{...}, {__index: typeof(...)}>`로 + 확장해 두 이득을 한 번에 얻으려 하지 말 것** — quad의 self-핸들 + 콜백 계약에서 솔버 버그를 만남(1번 ③의 "시도했지만 채택 안 함" + 참고). 3. **제네릭 키로 값 타입을 좁히려 하는가?** → 3번, 안 됨. 타입 패밀리를 쓸 것. 4. **서브타입 관계인 두 타입을 합성하려 하는가?** → 2번, 메소드 반환 @@ -298,5 +386,13 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이 `"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터 환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상 새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요. -- **5번(`store.key` type function)** — 스파이크 재작성 후 실측. -- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증. +- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증(③ 포함). +- **`state:With(...)`/`state:Apply(factory)`에 1번 ③(`typeof`) 개별 + 실측** — `Compute`에서만 확인됐고, base pseudocode에 실제로 반영할 + 때 같이 확인할 것. +- **`setmetatable`+`typeof(genericFn<>())` 조합의 모순 진단 + 버그**(1번 ③ "시도했지만 채택 안 함") — quad는 이 formulation을 + 안 쓰기로 해서 quad 쪽에서 더 팔 필요는 없지만, Luau 0.733의 실제 + 솔버 버그로 보이므로 이미 알려진 이슈인지 확인 후 업스트림 제보 + 검토(최소 재현 9줄, `audit/type-recursive-issue-with-typeof/spikes/ + 08-metatable-BUG-contradictory-diagnostics.luau`). diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 9c70549..3ff8174 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -82,7 +82,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `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 열한 번째 세션 재정정) | | `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` 필드를 만족하는지 | `typing-limits.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | +| `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 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `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` "요소 소유권" | diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index e5b660b..74fce61 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -1,6 +1,10 @@ # 스파이크 상태판 — **폴더가 곧 상태** -> 마지막 갱신: 2026-08-14 (**"emit은 항상 전파"** 정정으로 `05`가 옛 모델을 +> 마지막 갱신: 2026-08-15 — `16`이 `types.newfunction` API 버전 드리프트 +> (배열이 아니라 `{head=..., tail=...}` 레코드를 받음) 수정으로 통과, +> `rewrite-required/` → `done/` 이동. 근거: +> `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절. +> 직전 갱신은 2026-08-14(**"emit은 항상 전파"** 정정으로 `05`가 옛 모델을 > 검증 중이라 `rewrite-required/`로 이동 — > `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 **다섯 번째 세션**(`bindLifetime`/`canExecute`/ > `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어 @@ -19,9 +23,9 @@ | 폴더 | 뜻 | 개수 | 누가 처리 | |---|---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 6 | 에이전트 | | `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 | -| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 13 | — | +| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 14 | — | **폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜 @@ -67,7 +71,6 @@ | `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`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | -| `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 | | `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 열한 번째 세션 재정정]** 이중 바인딩 게이트는 `canBound(value)`(`if canBound(v) then error(...) end`) — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | ## ⚪ `not-run/` — 이 환경에서 못 돌림 @@ -79,7 +82,7 @@ |---|---| | `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 | -## ✅ `done/` — 통과 or 판정 끝 (13건) +## ✅ `done/` — 통과 or 판정 끝 (14건) **런타임 12개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] 그중 `04`/`19`, [2026-08-14] 추가로 `05`는 검증 대상 설계가 바뀌어 위 @@ -106,6 +109,7 @@ | `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 | | `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) | | `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절 | ### 특별히 중요한 통과 3건 diff --git a/.claude/luau-test/done/16-type-store-key-typefunction.luau b/.claude/luau-test/done/16-type-store-key-typefunction.luau new file mode 100644 index 0000000..3de580b --- /dev/null +++ b/.claude/luau-test/done/16-type-store-key-typefunction.luau @@ -0,0 +1,69 @@ +--!strict +--[[ + 검증 대상: `Store`가 레코드 타입 T의 각 필드를 Source로 감싼 타입을 + Luau `type function`으로 실제로 합성할 수 있는지 — 실제 실행이 아니라 + 타입 체크(luau-analyze/luau-lsp) 대상. + + 배경: .claude/base/bind-system-plan.md "`store.key` 레코드 필드 타이핑" + 절(2026-08-12 열일곱 번째 세션) — 사용자가 제시한 WrapStore/ + ProcessStoreType 스케치를 그대로 옮김. `research/pre-implementation-audit.md` + 1-10 해소 근거. + + [2026-08-15 audit/type-recursive-issue-with-typeof/REPORT.md 6-1절에서 복구] + 원래 실패 원인은 설계 문제가 아니라 **API 버전 드리프트**였음 — + `types.newfunction`의 시그니처가 `(parameters: {head: {type}?, tail: + type?}, returns: {head: {type}?, tail: type?}?, generics: {type}?): type` + 로, parameters/returns 둘 다 **레코드**(`{head=..., tail=...}`)를 + 받는데 원본은 배열(`{ ty }`)을 그대로 넘기고 있었음. self 파라미터는 + `types.newtable()`이 돌려주는 **뮤터블 핸들 자기 자신**을 그대로 + 참조하면 됨(나중에 `setproperty`로 채워도 핸들이라 소급 반영됨) — + 별도 self 타입을 새로 만들 필요 없음. 이 수정만으로 통과. + 재현: `luau-analyze 16-type-store-key-typefunction.luau` — 진단 0건. + + 실측 환경: Luau 0.733(`mise ls luau`). +]] + +export type Source = { + Get: (self: Source) -> T, + Set: (self: Source, value: T) -> (), +} + +type function WrapStore(ty: type): type + -- Source 형태를 그대로 조립. self는 이 함수가 만드는 result + -- 테이블 핸들 자신 — 아직 setproperty로 다 채우기 전이어도 핸들 + -- 참조라 나중 변경이 소급 반영됨(types.newfunction이 캡처하는 건 + -- 핸들이지 스냅샷이 아님) + 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 + -- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열 + 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 + +-- 검증 포인트: Processed의 각 필드가 실제로 Source 구조를 만족하는지 +local checkTy: string = processed.ty:Get() +local checkCount: number = processed.count:Get() + +-- 음성 대조군 — 타입이 실제로 강제되는지(진단 0건이 곧 안전은 아니므로, +-- typing-limits.md §1의 교훈 그대로 이 스파이크에도 적용) +local _wrongTy: number = processed.ty:Get() -- 반드시 에러: string을 number로 +processed.ty:Set(123) -- 반드시 에러: Source에 number를 Set +processed.count:Set("nope") -- 반드시 에러: Source에 string을 Set +processed.ty:NoSuchMethod() -- 반드시 에러: 존재하지 않는 메소드 diff --git a/.claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau b/.claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau deleted file mode 100644 index bbd019f..0000000 --- a/.claude/luau-test/rewrite-required/16-type-store-key-typefunction.luau +++ /dev/null @@ -1,67 +0,0 @@ ---!strict ---[[ - 검증 대상: `Store`가 레코드 타입 T의 각 필드를 Source로 감싼 타입을 - Luau `type function`으로 실제로 합성할 수 있는지 — 실제 실행이 아니라 - 타입 체크(luau-analyze/luau-lsp) 대상. - - 배경: .claude/base/bind-system-plan.md "`store.key` 레코드 필드 타이핑" - 절(2026-08-12 열일곱 번째 세션) — 사용자가 제시한 WrapStore/ - ProcessStoreType 스케치를 그대로 옮김. `research/pre-implementation-audit.md` - 1-10 해소 근거. - - 핵심 우려: (a) `type function` 문법 자체(`types.newtable`/`:setproperty`/ - `types.singleton`/`ty:properties()`/`types.newfunction`)가 실제 설치된 - Luau 버전에서 정확히 이 API 모양인지 — type function은 비교적 최근/ - 계속 진화 중인 기능이라 버전에 따라 세부 이름이 다를 수 있음 - (https://luau.org/types/type-functions/ , https://luau.org/types-library/ - 최신 문서 기준으로 맞춰볼 것 — 아래는 사용자가 손으로 쓴 스케치를 최대한 - 그대로 옮긴 것이라 API 이름이 실제와 다를 수 있음, 그 자체가 이 - 스파이크의 목적). (b) 결과 타입 `ProcessStoreType<{ty: string}>`이 - 실제로 `{ty: Source}`을 "만족"해서(이름이 아니라 구조로) - `Source` 필드가 필요한 자리에 그대로 대입 가능한지. - - 실행: `luau-analyze 16-type-store-key-typefunction.luau` 또는 - `luau-lsp analyze` — 문법/API 에러가 나면 정확한 에러 메시지(어느 줄, - 어떤 API 호출)를 그대로 알려줄 것 — 그게 다음 시도에서 API 이름을 - 고치는 데 바로 쓰임. 이 파일 자체가 한 번에 통과할 걸 기대하기보다, - "type function으로 이 방향 자체가 막히는지 아닌지"를 확인하는 게 목적. - - 기대 결과: 맨 아래 `checkTy`/`checkCount` 대입이 타입 에러 없이 통과 - (= `Processed.ty`가 구조적으로 `Source`을 만족). -]] - -export type Source = { - Get: (self: Source) -> T, - Set: (self: Source, value: T) -> (), -} - -type function WrapStore(ty: type): type - -- Source 형태를 그대로 조립 — 실사용에선 :Set/:Compute/:With 등도 - -- 같은 방식으로 추가해야 하지만, 이 스파이크는 "필드가 Source로 감싸 - -- 지는가"라는 구조적 만족 여부만 보면 되므로 :Get만 붙임 - local result = types.newtable() - result:setproperty(types.singleton("Get"), types.newfunction({}, { ty })) - 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 - -- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열 - 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 - --- 검증 포인트: Processed의 각 필드가 실제로 Source 구조를 만족하는지 -local checkTy: string = processed.ty:Get() -local checkCount: number = processed.count:Get() diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 8d30b30..66c57db 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -305,9 +305,10 @@ base 인터페이스가 그보다 늦은 M8에서 만들어지는 순서 역전. 이름이 아니라 구조로 일치를 검사하므로 문제없음. 기술적으로 막힐 위험이 없어졌으니 M0/M3 어느 시점에 검증해도 무방 — `ROADMAP.md` 배치를 억지로 안 옮겨도 됨. 상세는 `base/typing-limits.md` "`store.key` 레코드 필드 -타이핑" 절, 실제 문법 실측은 -`luau-test`의 `16-type-store-key-typefunction.luau`(신규). 아래는 -원래 발견 당시 기록. +타이핑" 절, 실제 문법 실측은 `luau-test/done/16-type-store-key- +typefunction.luau`(**[2026-08-15] 통과** — 원래 스파이크가 API 버전 +드리프트로 깨져있던 걸 고침, `audit/type-recursive-issue-with-typeof/ +REPORT.md` 6-1절). 아래는 원래 발견 당시 기록. **위치**: `ROADMAP.md` M0 vs M3 `"store.key dot-access 타입 추론 확인"`. diff --git a/.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md b/.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md new file mode 100644 index 0000000..bfbf8c4 --- /dev/null +++ b/.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md @@ -0,0 +1,104 @@ +# 2026-08-15 — `typeof(named fn)` 간접참조로 0-Y 우회 실측, `luau-test/16` 복구 + +## 배경 + +사용자가 Luau 설계자와 가까운 사람과의 대화에서 힌트를 얻어 +`test-ignoreme.luau`를 직접 작성 — `Compute`류 재귀 제네릭 메소드를 +타입 안에 인라인으로 쓰지 않고 이름 붙은 top-level 함수로 선언한 뒤 +`typeof(그함수)`로 필드 타입만 참조하면 `base/typing-limits.md` 1번 +(0-Y, 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 조용히 +`Unifiable`로 새는 문제)이 안 생기는 것 같다는 관찰을 가져옴. +"모든 typeof를 flatten하려면 비용이 많이 들어 사용 시점에야 계산하는 +것 같다 — 이걸로 재귀를 분리하고 지연 확장할 수 있지 않을까"라는 +가설. 사용자가 이후 `test2`/`test3-ignoreme.luau`로 `Modifier`의 +`__index`+`table.clone` 체이닝(이미 확정된 패턴, `typing-limits.md` +§6)과 같은 계열인 `setmetatable<{...}, {__index: typeof(fn<>())}>` +확장도 직접 시도해봄("내 실험으론 여기까지"). + +같은 세션에 사용자가 advisor(Opus)를 조언자로 추가 — "차근차근 +typing-limits를 풀어보자, 오래 걸려도 되니까 정확하게 처리해줘"라는 +요청으로 본격적인 다단계 실측을 진행. + +## 방법론 + +`audit/type-recursion-issue/`(0-Y 원래 실측)와 동일한 3단 교차검증을 +매 formulation에 적용: (1) `luau-analyze --annotate`로 실제 추론된 +타입을 눈으로 확인(진단 0건 ≠ 안전), (2) 양성 대조군(정상 사용이 +진짜 통과하는지), (3) 음성 대조군(명백히 틀린 사용이 진짜 에러 +나는지). 여기에 이번 세션에서 추가한 것: 체이닝 깊이 1/3/5/8/50 스윕 ++ 각 단계 negative control, `--solver=old` 대조, 실제 quad `Compute` +계약(콜백이 lazy self 핸들을 받음 — raw 값이 아님)으로 정합성 재확인. + +## 진행 과정 (시행착오 포함) + +1. **1차 확인**: `test-ignoreme.luau`를 `--annotate`로 돌려보니 사용자 + 가설이 맞았음 — `Compute` 결과가 `Unifiable`가 아니라 진짜 + 구조 타입으로 잡힘, `const`는 무관(`local function`도 동일). +2. advisor 호출 — "체이닝 깊이가 관건, 매트릭스로 통제 변수 실험하라"는 + 조언. 4-스타일 매트릭스(인라인/typeof/분리+인라인/분리+typeof) 작성, + `read` 일관성 통제. +3. **체이닝 깊이 결정적 테스트**: depth 1~50, 각 체크포인트 negative + control — **전부 통과, hover 타입 크기도 깊이와 무관하게 일정**(진짜 + 지연/공유 평가, eager 재료화 아님). 이 시점에 advisor의 "체이닝이 + 전부를 결정한다"는 우려는 해소. +4. 인터넷 재연결로 세션 재개, 사용자가 만든 `test2`/`test3` 확인 — + `setmetatable` + 명시 제네릭 인스턴스화(`<>()`) 패턴이 **콜백 + 파라미터까지 무주석 자동 추론**되는 걸 발견(raw 값 파라미터 한정). + 이걸 진짜 재귀 `Compute: Box`로 확장해보니 처음엔 완벽해 + 보였음(체이닝 50단, 무주석 파라미터, 즉시 LHS 오타입 검출까지 전부 + 통과) — 그러나 quad의 **실제 계약**(콜백이 raw 값이 아니라 self + 핸들을 받음)으로 정정하자 두 가지 문제가 연쇄로 드러남: (a) 파라미터 + 무주석 시 duck-typing으로 새서 존재하지 않는 메소드도 안 잡히는 + 불건전, (b) 파라미터를 명시 주석해도 **콜백 반환 타입이 self의 + 원래 T와 다르면(=Compute가 존재하는 이유 그 자체) 올바른 대입에도 + 모순되는 진단 두 개가 동시에 남는 솔버 버그**. +5. advisor 재호출 — "chaining depth가 아니라 self-handle 파라미터 여부 + × U≠T 여부가 진짜 축이었다. quad 계약은 정확히 깨진 사분면에 있다. + `setmetatable` 없이도 재현되는지부터 싸게 확인하라"는 조언. +6. 확인 결과 **`setmetatable`을 빼면 모순 버그가 사라짐** — 순수 + `typeof`(분리 없음) + self-핸들 파라미터 명시 주석만으로 타입이 + 바뀌는 3단 체이닝, 콜백 안 재귀 self 호출, 존재하지 않는 메소드 + 거부까지 전부 완전히 깨끗하게 통과. **이게 최종 승자 formulation.** +7. 최소 재현(9줄)으로 `setmetatable` 버그를 격리해서 남김(quad와 + 무관한 Luau 0.733 솔버 이슈로 판단, 업스트림 제보는 사용자 판단). + +같은 세션에 병행: `luau-test/rewrite-required/16-type-store-key- +typefunction.luau`(0-A/1-10 관련, `types.newfunction` 시그니처 +불일치로 깨져있던 스파이크)를 `luau.org/types-library` 실제 문서 +대조로 복구 — 원인은 설계 문제가 아니라 API 버전 드리프트(두 번째 +인자가 배열이 아니라 `{head=..., tail=...}` 레코드)였음. 별도로 +type function으로 0-Y 자체(재귀 `Compute`)를 우회하는 시도는 +`stack overflow`로 막다른 길임을 확인. + +## 결과 — base 반영 + +- `base/typing-limits.md` §1에 **③ 선언 스타일 규약**(인라인 대신 + 이름 붙은 함수 + `typeof`) 추가 — ①(명시 바인딩 강제)을 대체하지 + 않는 보강. `setmetatable` 확장은 "시도했지만 채택 안 함"으로 명시, + 이유와 최소 재현 경로 남김. +- `base/typing-limits.md` §5를 "미검증" → **"검증 완료"**로 승격. +- `luau-test/rewrite-required/16-...` → `luau-test/done/16-...` + (복구 완료), `STATUS.md`/`README.md` 동기화(rewrite-required 7→6, + done 13→14). +- `research/pre-implementation-audit.md` 1-10에 실측 완료 포인터 추가. +- 전체 실측 원문+스파이크 14개: `audit/type-recursive-issue-with-typeof/` + (`REPORT.md` + `spikes/`, 사용자가 만든 `test-ignoreme`/`test2`/`test3`도 + `00`대 파일로 보존). + +## 교훈 + +- **"체이닝 깊이가 관건"이라는 최초 가설은 틀렸음** — 실제 결정 축은 + "콜백 파라미터가 self 핸들인가 raw 값인가" × "콜백이 T와 다른 타입을 + 반환하는가"였음. 겉보기에 그럴듯한 축(깊이)을 먼저 실측으로 배제한 + 뒤에야 진짜 축이 드러남 — 매 formulation을 실제 API 계약(quad의 + lazy self 핸들 계약)으로 재확인하지 않았다면 "완벽한 해법을 찾았다"고 + 잘못 결론 내릴 뻔했음. +- **진단 0건이 안전을 뜻하지 않는다는 원칙(1번의 원래 교훈)이 이번엔 + 반대 방향으로도 함정이었음** — `setmetatable` 무주석 파라미터 + 케이스는 진단 0건이었지만 duck-typing으로 새서 존재하지 않는 + 메소드도 안 잡는 **불건전**이었음. "에러가 안 남 = 좋음"이 아니라 + 매번 "존재하지 않는 메소드 호출" 같은 건전성 음성 대조군을 같이 + 둬야 함. +- advisor를 두 번 불러 각각 "다음에 뭘 확인해야 최소 비용으로 결론이 + 뒤집힐 수 있는지"를 물은 게 결정적 — 혼자였다면 `setmetatable` + formulation을 "완전한 해법"으로 그대로 base에 반영했을 가능성이 높음. diff --git a/CLAUDE.md b/CLAUDE.md index 1b324fe..392a836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1470,3 +1470,20 @@ include/markdown-magic이 선례임을 확인 후 build-vs-buy 논의, 문제가 작성 — 파일럿은 부작용이 가장 작은 CLAUDE.md 세션 히스토리부터, 마커 문법·소급 적용 범위 등 열린 질문은 **사용자가 내일 직접 다듬기로 함**, 구현 착수 안 함. + +**2026-08-15 세션 — `typeof(named fn)` 간접참조로 0-Y 우회 실측, +`luau-test/16` 복구** (`session/2026-08-15-01-typeof-recursive-generic-workaround.md`) +사용자가 발견한 "재귀 메소드를 인라인 대신 이름 붙은 함수 + `typeof`로 +선언하면 0-Y(재귀 제네릭 반환 leak)가 안 생기는 것 같다"는 관찰을 +`--annotate`+양성/음성 대조군+체이닝 깊이 1~50 스윕으로 검증 — +**확인됨**(LHS 명시 없이도 다운스트림 안전, 콜백 파라미터 명시 주석은 +여전히 필요). `typing-limits.md` §1에 ③으로 추가(①을 대체하지 않는 +보강). 도중 시도한 `setmetatable<{...}, {__index: typeof(fn<>())}>` +확장은 quad의 실제 self-핸들 콜백 계약에서 **모순되는 진단 두 개가 +동시에 남는 Luau 0.733 솔버 버그**를 만나 채택 안 함(quad와 무관한 +버그로 판단, 최소 재현 9줄 남김). 병행: `luau-test/16(type function으로 +`Store` 필드 합성)`이 API 버전 드리프트로 깨져있던 걸 복구 — +`typing-limits.md` §5를 "미검증"→"검증 완료"로 승격, +`done/`으로 이동. type function으로 0-Y 자체를 우회하는 시도는 +`stack overflow`로 막다른 길 확인. 전체 실측: +`audit/type-recursive-issue-with-typeof/`.