diff --git a/.claude/README.md b/.claude/README.md index 83e54a6..78f5bba 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` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 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 승격) | +| `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 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/` 35개(00~19는 최초 라운드, `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 20~33(+21b) 후속 조사 추가). 결론: 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 아홉 번째 세션 신설]** 코퍼스 기계 점검 — `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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | diff --git a/.claude/audit/luau-test-first-run-2026-08-13.md b/.claude/audit/luau-test-first-run-2026-08-13.md index c391440..6064406 100644 --- a/.claude/audit/luau-test-first-run-2026-08-13.md +++ b/.claude/audit/luau-test-first-run-2026-08-13.md @@ -124,7 +124,7 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0) | `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef`가 `Ref` 자리에 대입 가능 — 진단 0건 | | `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 | | `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 | -| `16-type-store-key-typefunction` | ❌ 실패 | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음. 문서가 스스로 "API 이름이 다를 수 있다"고 예비해둔 케이스 | +| `16-type-store-key-typefunction` | ❌ 실패(당시) → **[2026-08-15] ✅ 통과로 복구** | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음(레코드 대신 배열을 넘기고 있었음) — 설계 문제 아니라 API 버전 드리프트였음이 나중에 확인됨. 상세: `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절, `luau-test/done/16-type-store-key-typefunction.luau` | ## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌 @@ -206,7 +206,8 @@ crash 0, FAIL 0. - `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요. - `15`: 음성 대조군을 별도 파일/블록으로 격리해 `SyntaxError`가 A/B/D 판정을 막지 않도록. -- `16`: 설치된 버전의 `types.*` 실제 API 재확인 후 재시도. +- `16`: 설치된 버전의 `types.*` 실제 API 재확인 후 재시도. **[2026-08-15 + 완료]** — `done/`으로 이동, 위 표에 반영. ## 결론 diff --git a/.claude/audit/type-recursion-issue/REPORT.md b/.claude/audit/type-recursion-issue/REPORT.md index 89f2e98..463ecf0 100644 --- a/.claude/audit/type-recursion-issue/REPORT.md +++ b/.claude/audit/type-recursion-issue/REPORT.md @@ -89,9 +89,21 @@ local doubled = state:Compute(function(s) return s:Get() * 2 end) -- ❌ 2×2 매트릭스(`02`~`04`)로 좁혀보면, **T가 제네릭인지는 무관하고, `Compute`가 자기 로컬 제네릭 `U`를 가진 채로 재귀 타입의 필드로 선언돼 있다는 것 자체**가 원인입니다. `Compute`를 필드가 아니라 자유 함수로 -빼면(`06`/`07`/`10`) 재귀가 있어도 없어도 무주석 람다가 바로 통과하고, -`:` 메소드 문법 자체는 무관(`05`)하며, 리턴 타입 주석만으로는 안 -풀리고(`09`/`12`), self를 완전 자유 제네릭으로 열어도 안 풀립니다(`13`). +빼면 재귀가 있어도 없어도 무주석 람다가 바로 통과합니다 — **단, 이건 +self 자신이 모노몰픽(비제네릭)일 때만입니다**(`07`: `self: Box`, +`10`: `self: Box3`, 둘 다 통과). **[2026-08-15 정정, +`/code-review` 지적]** `06`은 원래 이 목록에 같이 묶여 "통과"로 +서술됐으나, `06`은 `self: Box`처럼 **self 자신도 제네릭**이라 +사실은 실패합니다(`--annotate`로 보면 `s`가 `{value:c}`류 duck 타입, +곱셈에 `Consider annotating the return` `TypeError`까지 남) — 즉 +`06`은 07/10이 아니라 아래 `13`(self가 완전 자유 제네릭이면 여전히 +안 풀림)과 같은 편입니다. 자유 함수로 빼는 것 자체가 아니라 **"self가 +비제네릭인가"가 진짜 분기점**입니다. `:` 메소드 문법 자체는 +무관(`05`)하며, 리턴 타입 주석만으로는 안 풀리고(`09`/`12`), self를 +완전 자유 제네릭으로 열어도 안 풀립니다(`13`, `06`도 같은 클래스). +후속 조사(`audit/type-recursive-issue-try-callback/REPORT.md` 1번 절)가 +이 결론을 "제네릭이 관여하는 함수 호출 인자로 넘긴 함수 리터럴엔 +컨텍스트 타입이 전파 안 됨"으로 더 일반화해 재확인했습니다. ### 회피책 — "데이터부/메소드부" 쪼개기 (`14`) diff --git a/.claude/audit/type-recursive-issue-try-callback/REPORT.md b/.claude/audit/type-recursive-issue-try-callback/REPORT.md new file mode 100644 index 0000000..6751e5b --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/REPORT.md @@ -0,0 +1,474 @@ +# 콜백 파라미터 무주석 추론 — 되는 방법이 정말 없는지 전방위 실험 + +**출발점**: 사용자가 `typing-limits.md` §1의 남은 캐비엇("콜백 파라미터는 +여전히 명시 주석 필요")을 직접 손으로 여러 방식을 시도해봤지만 전부 +막혔고, "안 된다"는 결론이 나오긴 했지만 확실하다고 보기 어렵다며 — +type function/메타테이블/제네릭/그 외 뭐든 다 엮어서 한 번 더 파보라고 +요청. **결론이 다시 "안 된다"로 나와도 그 자체가 원하는 결과물**(여러 +각도로 확실히 막혔다는 실측 근거)이라는 전제로 진행. + +**이 폴더의 구성**: `REPORT.md`(이 문서) + `spikes/`(재현 스크립트, +`00`~`33`, `20`부터는 후속 조사분). `type-recursion-issue/`/ +`type-recursive-issue-with-typeof/`와 같은 이유로 스크립트를 같이 둡니다. + +**[2026-08-15 후속 조사 추가]** `/code-review high`가 이 문서(당시 +`00`~`19`)를 리뷰하며, 코퍼스에 이미 실사용 중인 **이중 꺾쇠 명시적 +제네릭 인스턴스화 문법**(`Foo<>(...)`, `base/attribute-plan.md`의 +`AttributeKey<>`, `type-recursion-issue/spikes/38/39/41`의 +`t:Compute<>>(...)`에서 이미 쓰임)을 단 하나도 시도하지 +않았다는 결정적 미검증 각도를 지적함 — 20번부터 이어서 조사, 결과는 +아래 11번 절. + +**실측 환경**: Luau `0.733`(`mise ls luau`), `luau-analyze`(새 솔버 +기본값)와 `luau-analyze --solver=old` 교차검증. + +## TL;DR + +**"콜백 파라미터를 완전히 무주석으로 자동 추론시키는 방법"은 여전히 +없습니다.** 다만 그 과정에서 원래 문제의 성격 자체가 처음 생각했던 +것과 다르다는 게 드러났고, **작동은 하지만 quad의 API 모양을 바꿔야만 +하는 메커니즘 하나**를 새로 찾았습니다(0번 대전제로 기각). + +| 질문 | 답 | +|---|---| +| 콜백 파라미터 무주석 추론이 되는 **새로운** 방법을 찾았는가? | **아니오(암묵 호출 기준) — 단, [2026-08-15 후속] 명시 이중 꺾쇠 인스턴스화(`state:Compute<>(fn)`)는 예외.** 여기 정리된 최초 20개 formulation(type function, 메타테이블, 오버로드, 제네릭 디폴트, 테이블 리터럴, 캐스팅)은 전부 암묵 호출만 시도했고 전부 실패. `<>`는 후속 조사(11번 절)에서 leaf 호출에 한해 성립·sound함이 확인됐지만 채택은 안 함 — 아래 후속 행 참고. | +| 이 문제가 quad의 **자기 참조(재귀) 타입 특유의** 문제인가? | **아니오 — 근본 원인 재정정.** 재귀 없는 가장 평범한 제네릭 함수(`Map(arr: {T}, fn: (T)->U): {U}`)조차 콜백 파라미터가 무주석이면 `unknown`으로 새어나감(00번). **Luau는 제네릭 함수 호출 인자로 넘긴 함수 리터럴에 컨텍스트 타입을 전파하지 않는다**는 게 진짜 원인 — 자기 참조는 이 위에 얹힌 부가 문제일 뿐. | +| 그럼 왜 지금까지 "재귀가 문제"라고 생각했는가? | 비-재귀 제네릭도 원래 안 되지만(00번), quad가 실제로 부딪힌 건 항상 `Compute`(재귀형)였고, 재귀형에서는 실패 양상이 "완전 무주석"이 아니라 "구조적 duck-typing으로 뭉개짐"(06/07번, `{value:unknown}`류)이라 재귀가 원인처럼 보였을 뿐. 근본은 같은 "제네릭 콜백 파라미터는 컨텍스트 전파 안 됨" 문제. | +| **작동하는** 메커니즘을 찾긴 했는가? | **찾았습니다 — 두 개, 둘 다 API 모양을 바꿔야만 씀.** (a) `T`를 별도의 명시 타입 주석이 달린 중간 변수로 먼저 못박으면(`U`만 제네릭으로 남으면) 콜백 파라미터가 정확히 추론됨 — **재귀형에서도** 됨(09번), quad의 실제 계약(self 핸들, 중첩 self 호출)까지 검증(17번). (b) 재사용 가능한 "monomorphize 헬퍼" 함수 하나(`MonomorphicCompute(self: State): ((...)->...)`)를 거치면 **T별로 손으로 다시 쓸 필요 없이** 자동으로 그 특수화가 일어남(18번) — 이게 이번 실험의 가장 흥미로운 발견. | +| 왜 채택 안 하는가? | 둘 다 `state:Compute(fn)` **단일 콜론 호출**을 `무언가(state)를 먼저 호출해 특수화된 함수를 얻고, 그걸 다시 호출하는 2단계 체인`으로 바꿔야만 작동함 — quad의 자연스러운 API 모양(단일 메소드 호출)을 콜백 추론을 위해 비트는 것이라 `typing-limits.md` §0 대전제 위반. | +| `type function`으로 뚫는 방법은? | **막다른 길, 기존 리서치와 같은 결론으로 재확인.** `Compute` 필드 하나만 type function으로 조립해도(12번), type function은 콘크리트 인스턴스화 시점에만 lazy 평가되고(확인됨) 그 안에서 "아직 안 정해진 `Box`"를 참조할 API가 `types` 라이브러리에 없음 — `type-recursive-issue-with-typeof/REPORT.md` 6-2절이 이미 확정한 것과 정확히 같은 벽. | +| 메타테이블 변형은? | 새로 시도 안 함(직전 실험이 이미 솔버 버그로 막다른 길임을 확정해뒀고, 이번 실험이 밝힌 "근본 원인은 재귀 무관"이라는 사실 자체가 메타테이블 경로를 더 팔 이유를 없앰). | +| **[후속, 2026-08-15]** 이중 꺾쇠 명시적 인스턴스화(`Compute<>(fn)`)로 콜백 파라미터 무주석 추론이 되는가? | **됩니다 — leaf 호출에서는, 게다가 소음(spurious diagnostic) 없이 완전히 sound하게.** 단 (a) T/U 둘 다 항상 명시해야 함(부분 인스턴스화는 이 재귀형에서 아예 깨짐, 21/21b), (b) 콜백 안에서 self를 또 호출하는 중첩(`:Apply` 패턴)은 여전히 실패(32) — 기존 캐비엇 그대로. **채택 안 함** — 11번 절 참고, §0 대전제 관점에서 순손해로 판단. | +| `base/typing-limits.md`를 고쳐야 하는가? | **원칙 자체는 안 바뀜.** §1의 "콜백 파라미터는 명시 주석 필요"는 여전히 유효 — 이번 실험은 그 옆에 "여러 각도로 재확인, 전부 막힘" 각주만 추가할 근거. 후속 조사(11번)도 같은 결론 — 각주 포인터만 추가, 가이드는 안 바뀜. | + +## 검증 방법 + +`type-recursive-issue-with-typeof/`와 동일하게 매 formulation마다: +1. `luau-analyze --annotate`로 실제 추론된 타입을 눈으로 확인(진단 0건은 + 안전의 증거가 아님). +2. **양성 대조군**: 정상 사용이 진짜로 통과하는지. +3. **음성 대조군**: 명백히 틀린 대입/없는 메소드 호출이 진짜로 에러 + 나는지. +4. quad의 실제 계약(콜백이 self 핸들을 받고, 콜백 안에서 그 self를 또 + `:Compute`하는 중첩까지)으로 최종 확인해야 "성립"으로 인정 — 장난감 + 사례(콜백이 raw 값을 받는 경우)에서만 되는 건 낮은 가치로 별도 표기. +5. 유의미할 때 `--solver=old` 교차검증. + +## 1. 근본 원인 재정정 — 재귀와 무관하게, Luau는 제네릭 콜백 인자에 컨텍스트 타입을 안 준다 + +`spikes/00-sanity-nonrecursive-generic-fails.luau` — **자기 참조가 전혀 +없는**, 교과서적인 `Array.map` 모양의 제네릭 함수: + +```lua +local function Map(arr: {T}, fn: (T) -> U): {U} ... end +local arr: {number} = {1,2,3} +local result = Map(arr, function(a) return tostring(a) end) +``` + +`--annotate`로 보면 `a`는 `unknown`으로 샙니다(`result`의 타입 자체는 +`{string}`으로 정확히 잡히는데도). 대조로 `spikes/01-sanity-concrete- +nongeneric-works.luau`는 **제네릭이 아예 없는** 함수(`(fn: (number) -> +string) -> string`)에 똑같이 무주석 콜백을 넘기면 `a: number`로 정확히 +추론됩니다. + +**결론**: quad가 지금까지 "재귀 자기 참조 때문에 콜백 파라미터가 안 +된다"고 이해하고 있던 것은 절반만 맞았습니다. 진짜 경계선은 +**"제네릭이 관여하는 함수 호출 인자로 넘긴 함수 리터럴은 컨텍스트 +타입 전파를 못 받는다"**입니다 — `T`가 이미 다른 인자(`arr`)로부터 +호출 시점에 완전히 확정된 뒤에도 마찬가지입니다. 자기 참조(`State`가 +자기 자신을 언급)는 이 위에 **추가로** 겹쳐 문제를 더 나쁘게 만드는 +요인(구조적 duck-typing, 아래 6/7번)일 뿐, 근본 원인이 아닙니다. + +## 2. 닫힌 문(구문 자체가 없거나, 전파 경로 자체가 없음) + +- **`spikes/02-no-explicit-generic-call-syntax.luau`** — TS의 + `f(x)`류로 호출부에서 제네릭 인자를 못박는 문법이 Luau에 + 있는지 확인 — **없음**. `Map::(...)`도 + `Map(...)`도 전부 `SyntaxError`. 애초에 "T를 호출부에서 + 미리 고정"하는 표준 경로 자체가 언어에 없습니다. +- **`spikes/03-cast-whole-function-no-help.luau`** — 함수 리터럴 전체를 + `(function(a) ... end) :: (number) -> string`처럼 캐스팅하면 컨텍스트가 + 전파될까 시도 — **안 됨**, `a`는 여전히 `unknown`. 캐스트는 함수 리터럴 + 본문이 이미 체크된 뒤에 씌워지는 사후 연산이라 본문 체크 시점의 + 파라미터 타입엔 영향을 못 줌. +- **`spikes/13-generic-default-syntax-unsupported.luau`** — TS의 + ``류 제네릭 기본값 문법 존재 여부 확인 — **`SyntaxError`, + Luau 0.733엔 이 문법 자체가 없음.** +- **`spikes/14-read-write-modifiers-exist-irrelevant.luau`** — `read`/ + `write` 프로퍼티 modifier는 **문법으로 존재**(파싱됨)하지만, 이건 + 프로퍼티의 가변성(mutability) 표시일 뿐 제네릭 콜백 추론과는 무관 — + 적용할 지점이 없음(참고용으로만 기록). + +## 3. 테이블 리터럴 컨텍스트 — 콘크리트 타입에서만 효과, 제네릭 호출 인자로는 안 통함 + +`spikes/04-table-literal-concrete-context-works.luau`가 처음엔 유망해 +보였습니다 — `local t: Handlers = { fn = function(a) ... end }`(`Handlers`가 +**이미 콘크리트로 확정된 non-generic 타입**)에서는 `a`가 정확히 +추론됩니다. 이게 "테이블 리터럴은 함수 호출 인자와 다른 특별 경로를 +타는 게 아닌가"라는 가설을 세우게 했는데, `spikes/05-table-literal- +generic-callarg-fails.luau`로 **같은 콜백을 제네릭 함수의 호출 인자 +안에** 테이블로 감싸 넣어보면(`Foo(arr, { fn = function(a) ... end })`, +`Foo`가 제네릭) — 다시 `a: unknown`으로 샙니다. **결론: 테이블 +리터럴 자체가 특별한 게 아니라, 04번이 통과한 건 그 테이블의 타입이 +호출 이전에 이미 명시 주석으로 콘크리트화돼 있었기 때문**입니다(2번 +결론과 정확히 같은 근본 원인의 재확인). + +## 4. 자기 참조가 얹히면 실패 양상이 "무주석 unknown"에서 "구조적 duck-typing 오염"으로 바뀜 + +- **`spikes/06-selfref-minimal-duckshape-fails.luau`** — 자기 참조하는 + 최소형(`Box = {value:T} & {Compute: typeof(Compute)}` — `Compute` + 선언 자체는 `typeof` 간접참조를 쓰지만, 그 간접참조가 **콜백 + 파라미터 추론까지 살려주진 않는다는 걸 보여주는 사례**입니다, 2번 + §5의 `type-recursive-issue-with-typeof/`가 이미 확인한 것과 같은 + 한계의 최소 재현)에 무주석 콜백을 넘기면, `a`가 완전한 `unknown`이 + 아니라 **`{value:unknown}`**처럼 부분적으로 구조를 추측한 duck + 타입이 됩니다. +- **`spikes/07-inline-recursive-param-breaks-all-calls.luau`** — quad의 + 실제 `State` 모양(인라인 재귀 선언, `typeof` 트릭 없이)으로 같은 + 실험을 하면 한 단계 더 나쁩니다 — `a`가 `{Get:()->(unknown)}`류로 + 추측되는데, 이 추측된 타입이 원래 `State`와 구조적으로 안 맞아서 + (`Get`이 read-only 프로퍼티로 잡히는 등) **완전히 정상적인 콜백 본문 + (`a:Get()`)에서조차 실제 `TypeError`가 납니다.** 이건 + `type-recursive-issue-with-typeof/REPORT.md` 5-2절이 `setmetatable` + 변형에서 발견했던 "정상/오용 안 가리고 전부 깨짐" 현상과 **완전히 + 같은 클래스** — 메타테이블이 원인이 아니라 "무주석 self-참조 파라미터" + 자체가 원인이었다는 뜻입니다. + +## 5. ⭐ `T`를 명시 중간 변수로 먼저 못박으면 재귀형에서도 콜백 파라미터가 정확히 추론됨 + +이번 실험에서 가장 중요한 양성 발견입니다. + +`spikes/08-WINNER-partial-fixT-viaannotation.luau`(비재귀 대조군)로 +먼저 확인: 제네릭 함수 `Compute`를, **T만 명시 타입 주석으로 +콘크리트화하고(U는 여전히 제네릭인) 중간 변수**에 담으면: + +```lua +local computeForNumber: (self: {value: number}, fn: ({value: number}) -> U) -> {value: U} + = Compute +local r = computeForNumber(self1, function(a) return tostring(a.value) end) +``` + +`a`가 정확히 `{value: number}`로 추론됩니다. 그리고 +`spikes/09-WINNER-recursive-fixT-viaannotation.luau`로 **자기 참조하는 +`Box`**(Compute 필드가 `Box`/`Box`를 직접 언급하는 진짜 재귀형)에 +똑같이 적용해도 **여전히 정확합니다** — `a: Box`로 잡히고, 음성 +대조군(`wrong: number = r.value`, 실제론 `string`)도 정확히 에러납니다. + +**왜 되는가**: 2절/3절의 결론과 일관됩니다. 문제는 "제네릭이 함수 호출 +시점에 함께 풀려야 하는 경우" 컨텍스트 전파가 끊기는 것 — `T`를 +**호출과 별개의 이전 문장에서 명시 주석으로 이미 확정**해버리면, 그 +다음 호출에서는 풀어야 할 제네릭이 `U`(콜백의 반환 타입으로부터 +추론되는, 파라미터 타입에 영향 안 주는 방향의 제네릭) 하나뿐이라 +컨텍스트 전파가 정상 작동합니다. + +## 6. 하지만 자동화가 안 됨 — 필드 추출만으론 같은 이득을 못 받음 + +`spikes/10-field-extract-noannot-fails.luau` — 5절의 성공이 "그냥 필드를 +미리 꺼내놓으면 되는구나"로 오해되지 않도록: `self1.Compute`를 **재주석 +없이** 그대로 변수에 담아보면, 그 변수 **자기 자신의 추론된 타입부터** +`Unifiable`로 새어 있습니다(기존 0-Y와 동일 증상) — 5절의 성공은 +`typeof`가 그런 것처럼 "자동으로 얻어지는" 게 아니라, **사용자가 T가 +확정된 전체 함수 시그니처를 손으로 다시 써야만** 일어납니다. 즉 이 +기법은 콜백 파라미터의 명시 주석 부담을 없애는 대신 **더 큰 시그니처 +전체를 다른 곳에서 명시하는 부담으로 옮길 뿐**입니다 — 원래 +`function(a: State)` 한 조각 쓰던 걸, 훨씬 긴 함수 타입 전체 +(`(self: State, func: (State) -> U) -> State`)로 +바꿔 쓰는 셈이라 순수하게 손해입니다. + +## 7. `typeof` 대신 평범한 타입 별칭으로 같은 간접참조를 하면 — 아예 거부됨 + +`spikes/11-alias-indirection-fails.luau` — 5절의 "중간 변수"를 필드 +자체에 박아 넣어 자동화해보려고, `Compute` 필드 타입을 별도의 제네릭 +별칭(`type FixedComputeOf = (self: Box, ...) -> Box`)으로 +간접 참조하게 하면 **`TypeError: Recursive type being used with different +parameters`**로 선언 자체가 거부됩니다. `typeof(namedFn)` 간접참조 +(`type-recursive-issue-with-typeof/`가 확정한 그 기법)가 왜 특별한지를 +반증하는 대조군 — **일반 타입 별칭 사이의 상호 재귀는 이 우회를 못 +받고, 오직 "이름 붙은 *함수*를 `typeof`로 참조"하는 형태만 통합니다.** + +## 8. `type function`으로 필드만 조립 — 기존 결론과 같은 벽에서 재확인 종료 + +`spikes/12-typefunction-field-lazy-deadend.luau` — `Compute` 필드 +하나만 `type function`으로 T에 따라 조립하는 안(9번 성공을 자동화하는 +또 다른 시도)을 검토. 먼저 type function이 **콘크리트 인스턴스화 +시점에만 lazy 평가**된다는 것부터 직접 확인(`error()`를 넣고 `Box`를 +쓰지 않으면 안 불림, `Box`를 실제로 쓰면 그 순간 불림 — 확인됨). +하지만 그 안에서 "아직 확정 안 된 `U`에 대한 `Box`"(콜백의 반환 +타입으로 만들 리턴 타입)를 표현하려면 `types` 라이브러리에 "이름 붙은 +별칭을 지연 적용하는" API가 있어야 하는데 **없습니다** — +`type-recursive-issue-with-typeof/REPORT.md` 6-2절이 재귀 `Compute` 자체를 +type function으로 지연시키려다 `stack overflow`로 막혔던 것과 정확히 +같은 벽입니다. 필드 하나로 범위를 좁혀도 이 벽 자체는 안 없어지므로, +더 이상 이 방향을 파는 건 낭비로 판단하고 종료. + +## 9. 오버로드(교차 타입)로 콘크리트 분기를 얹어보기 — 둘 다 실패 + +- **`spikes/15-intersection-overload-ambiguous.luau`**(재귀 없는 통제 + 버전) — 제네릭 오버로드 하나 + 콘크리트(T=number 고정) 오버로드 + 하나를 교차 타입으로 합쳐두면, Luau가 **어느 쪽으로 콜백을 체크해야 + 할지 모호하다며 `TypeError: ... is ambiguous`**를 냄 — 오버로드 + 분기가 컨텍스트 타입을 좁혀주기는커녕 아예 선택 자체가 막힘. +- **`spikes/16-intersection-overload-recursive-breaks.luau`**(재귀형에 + 적용) — 여긴 오버로드 모호성 문제까지 가기도 전에 `Box`와 + `Box`를 같은 재귀 별칭 안에서 같이 쓴 것 자체가 `Recursive + type being used with different parameters`로 거부됨(7절과 같은 벽). + +## 10. ⭐⭐ NEAR-MISS — 재사용 가능한 "monomorphize 헬퍼"는 실제로 작동함(단, API 모양을 바꿔야 함) + +`spikes/18-NEARMISS-monomorphize-helper-two-call.luau` — 6절의 "자동화 +안 됨" 문제를 다른 각도로 풀어봄: 필드를 그냥 꺼내는 대신, **T에 대해 +제네릭인 재사용 가능한 헬퍼 함수를 하나 만들어서** 그 헬퍼의 반환 타입 +주석에 5절의 "T 고정, U만 제네릭" 시그니처를 (T가 아니라 그 함수 자신의 +제네릭 파라미터로) 써두면: + +```lua +local function MonomorphicCompute(self: State) + : ((self: State, func: (State) -> U) -> State) + return (self :: any).Compute +end + +local computeFor = MonomorphicCompute(test) -- T=number가 *이 호출에서* 자동 추론됨 +local d1 = computeFor(test, function(a) return a:Get() > 0 end) -- a 무주석, 정확히 추론! +``` + +`computeFor`의 타입이 `MonomorphicCompute(test)` 호출 한 번만으로 +자동으로 `(self:State, func:(State)->U)->State`로 +잡히고 — **사용자가 T별로 손으로 시그니처를 다시 쓸 필요가 없습니다.** +이어지는 `computeFor(test, function(a) ...)` 호출에서 `a`는 정확히 +`State`로 추론되고, 음성 대조군도 정확히 에러납니다. +`spikes/17-WINNER-realistic-quad-contract-nested.luau`로 quad의 진짜 +계약(콜백이 self 핸들을 받고, 콜백 안에서 그 self를 또 `:Compute`하는 +중첩)까지 이 "T 고정" 기법 자체를 확인 — 중첩 내부의 `a1`까지 정확히 +`State`로 추론되고, 없는 메소드 호출(`a:NoSuchMethod()`)은 +"Key 'NoSuchMethod' not found in table 'State'"로 **진짜 타입 +기반** 에러가 남(duck-typing 오염이 아님). + +**왜 작동하는가**: `MonomorphicCompute(test)` 호출 자체는 1절의 "이미 +확정된 T" 상황과 같습니다(`test`의 타입에서 T=number가 바로 추론되고, +이 호출엔 무주석 함수 리터럴 인자가 없으므로 컨텍스트 전파 문제 자체가 +안 생김) — 그 결과로 나온 `computeFor`는 **T가 이미 콘크리트로 박힌** +함수 값이라, 5절과 똑같은 이유로 다음 호출에서 컨텍스트가 정상 +전파됩니다. + +**왜 채택하지 않는가 (0번 대전제)**: 이건 `state:Compute(fn)`이라는 +**단일 콜론 호출**을 `MonomorphicCompute(state)(state, fn)`류의 +**2단계 체인**으로 바꿔야만 얻어지는 이득입니다. 콜론 호출 +`state:Compute(fn)`은 문법적으로 `state.Compute(state, fn)`(필드 +접근 + 단일 호출)과 완전히 동일하고, 이건 1절의 실패 케이스와 정확히 +같은 모양입니다 — **T와 콜백을 같은 호출 안에서 동시에 풀어야 하는 한** +이 문제를 벗어날 방법이 없다는 뜻입니다. `MonomorphicCompute`를 quad의 +공개 API로 노출해 `state:Compute(fn)`을 `Compute(state)(fn)`류로 +바꾸는 건, 콜백 추론 하나를 위해 quad 전역의 호출 관례(`:Compute`/ +`:With`/`:Apply`/`:Observer` 전부)를 바꾸는 것이라 — 이건 "타입 사정에 +맞춰 API를 비트는 것"의 교과서적 사례로 `typing-limits.md` §0을 정면 +위반합니다. **기록해두는 가치는 있지만(다음에 누가 또 시도하지 않도록, +그리고 정말 절박해지면 재검토할 수 있도록) 채택은 안 함.** + +부수적으로, `spikes/19-oldsolver-crosscheck-rejects-typeof.luau`로 +`--solver=old` 교차검증 — `typeof(Get)`/`typeof(Compute)` 선언 자체가 +옛 솔버에서 `Recursive type being used with different parameters`로 +거부됩니다(`typing-limits.md` §8이 이미 예견한 것과 일치) — 이번 +실험에서 찾은 모든 새 기법(08/09/17/18)은 이 `typeof` 패턴 위에 얹혀 +있으므로 **전부 새 솔버 전제**라는 기존 제약을 그대로 물려받습니다. +새 결론 아님, 재확인일 뿐. + +## 11. `<>` 명시적 이중 꺾쇠 인스턴스화 — 후속 조사(2026-08-15) + +**출발점**: `/code-review high`가 이 문서(당시 `00`~`19`)를 리뷰하며, +20개 formulation이 전부 **암묵적 추론 호출**(`Map(arr, fn)`)만 +시도했고, 코퍼스에 이미 쓰이는 **이중 꺾쇠 명시적 인스턴스화** +(`Foo<>(...)`, `base/attribute-plan.md`의 `AttributeKey<>`, +`type-recursion-issue/spikes/38/39/41`의 `t:Compute<>>(...)`)를 +단 하나도 안 해봤다고 지적. 스파이크 `20`~`33`으로 이 각도를 전방위로 +검증. + +### 11-1. 비-재귀 제네릭에서는 완전히 깨끗함 (양성 대조군) + +`spikes/20` — 재귀와 무관한 평범한 `Map(arr, fn)`에 +`Map<>(arr, function(a) ... end)`처럼 두 타입 인자를 +전부 명시하면, `a`가 무주석인데도 정확히 `number`로 추론되고 음성 +대조군(`wrong: number = result[1]`, 실제론 `{string}`)도 정확히 +에러납니다. §1의 근본 원인(1절, "제네릭 함수 호출 인자로 넘긴 함수 +리터럴엔 컨텍스트 타입이 전파 안 됨")이 정확히 **명시 인스턴스화로 +우회 가능한 문제**임을 확인 — 타입 인자를 전부 못박아버리면 그 "제네릭 +호출"이 사실상의 콘크리트 호출이 되어 정상적으로 컨텍스트가 +전파됩니다. + +### 11-2. 부분 인스턴스화 — 이름이 아니라 선언 순서로 바인딩되고, 재귀형에서는 아예 깨짐 + +`spikes/21`(quad의 실제 `Box` 자기참조형에 `<>` 하나만 줘서 +"U=string, T는 self에서 추론"을 의도) — **`<>`는 이름이 아니라 +선언 순서의 첫 번째 제네릭(`T`)에 바인딩됩니다.** 즉 `<>`은 +"U=string"이 아니라 "T=string"이 되어 실제 `self`(`Box`)와 +충돌하고, 그 충돌이 `"Internal error: outstanding free or blocked +type in function call"`이라는 이해하기 힘든 내부 에러로 새어나옵니다. + +이게 단순히 "잘못된 슬롯을 줘서" 그런 건지 확인하려고 `spikes/21b`로 +**올바른 T만** 주고(`<>`, self와 일치) U를 추론에 맡겨봤지만 +**여전히 같은 Internal error**가 나고, 심지어 **양성 대조군까지 +깨집니다**(`ok: string = r1:Get()`가 `U`가 `string`으로 완전히 +해소되지 않고 `(string <: 'a <: never)`라는 미해소 bounded 타입으로 +남아 실패). **결론: 이 재귀형에서 부분 인스턴스화는 어느 방향으로 +줘도 깨집니다** — 전부 주거나(11-1/11-3처럼) 전부 안 주거나 +(기존 §1①/③) 둘 중 하나만 유효한 선택지이고, "T는 생략하고 U만 +준다"는 경로 자체가 없습니다. 별도 파일로 남기진 않았지만 조사 중 +비-재귀 `Map`으로도 같은 두 변형을 즉석에서 확인했는데, `<>`이 +재귀형과 마찬가지로 항상 첫 번째 선언 순서(`T`)에 바인딩되는 것, +반대로 올바른 `T`만 주고 `U`를 비워두면 `U`가 완전히 해소되지 않는 +동일한 미해소 bounded 타입 증상을 보이는 것 둘 다 재현됐습니다 +(비-재귀에선 self 충돌이 없어 Internal error까지는 안 가지만, +다운스트림 타입이 진짜 콘크리트가 아니라는 결함은 재귀형과 같습니다). +재귀 유무와 무관하게 Luau의 명시 인스턴스화는 "선언 순서대로, 전부 +아니면 없음"에 가깝습니다. + +### 11-3. 스퓨리어스(spurious) 진단의 정체 — `read`/`write` 프로퍼티 modifier 불일치, `08-metatable-BUG`와는 다른 문제 + +`spikes/22` — T, U를 **둘 다** 명시(`n:Compute<>(fn)`)하면 +`a0`가 무주석인데도 `--annotate`로 정확히 `{Get:()->(number)}`로 +잡히고, 콜백 본문(`check: number = a0:Get()`)도 정상 통과, 다운스트림 +음성 대조군(`wrong2: number = r2:Get()`)도 정확히 에러납니다. **그런데 +호출 라인 자체에 스퓨리어스 진단이 하나 더 남습니다**: + +``` +`Get` is a read-only property in the latter type, but the former type +requires a read-write property +``` + +**원인 규명**: `a0`의 타입은 여전히 usage로부터 duck-typed 추론되고, +이 추론된 shape는 "읽기만 관측됨"이라 Luau가 `Get`을 **read-only**로 +표시합니다. 반면 명시 인스턴스화로 확정된 기대 타입(`BoxData`) +쪽의 `Get`은 (기본값이라) **read-write**로 선언돼 있어 둘이 구조적으로 +안 맞습니다. `spikes/23`(필드 선언 순서 스왑)은 효과 없음, `spikes/24` +(②쪼개기 패턴만 적용, `read` 없이)도 스퓨리어스 진단을 못 없앱니다 +(단 ``이 사라져 메시지가 조금 더 읽기 쉬워질 뿐 — 원인은 +그대로). + +`spikes/25`(**WINNER**) — ②쪼개기 + `Get` 필드에 명시적 `read` +modifier + 전체 명시 인스턴스화, 셋을 **모두** 적용하면 스퓨리어스 +진단이 완전히 사라집니다. `spikes/26`으로 확인한 바 `read` modifier +**단독**(쪼개기 없이)으로는 안 됨 — 쪼개기 없이는 재귀형 `Box` +전체(`Get`+`Compute` 둘 다)를 구조적으로 맞춰야 하는데 usage로부터 +추론된 duck shape엔 `Compute` 필드 자체가 없어 다른(더 지저분한) +스퓨리어스 진단으로 바뀔 뿐입니다 — 즉 ②(쪼개기)가 이 수정의 필수 +전제입니다. + +**`08-metatable-BUG`와의 관계**: 증상은 비슷(정상 사용에도 스퓨리어스/모순 +진단)하지만 **원인이 다릅니다**. `08-metatable-BUG`(`type-recursive-issue-with-typeof/`)는 +`setmetatable`을 쓸 때만 나타나는, 올바른 대입에도 모순되는 진단 +두 개가 동시에 남는 진짜 솔버 버그(재현 불가한 근본 원인)였습니다. +여기서 발견한 건 `setmetatable`을 전혀 안 쓴 순수 `typeof` 선언에서, +**단순한 read/write 프로퍼티 가변성(mutability) 표시 불일치**라는 +훨씬 평범하고 고칠 수 있는 원인이었고, 실제로 `read` modifier +하나로 완전히 해소됩니다. **같은 클래스의 버그가 아닙니다** — 이쪽은 +"버그"라기보다 Luau의 정상적인 (다만 직관적이지 않은) 가변성 추론 +규칙이었습니다. + +### 11-4. ⚠️ 함정 — "깨끗해 보이는" 암묵 호출은 sound하지 않음 + +②쪼개기+`read`를 적용한 채로 **명시 인스턴스화 없이**(암묵 호출) 같은 +콜백을 넣어보면(`spikes/27`) — 이것도 스퓨리어스 진단 없이 깨끗해 +**보입니다**. 하지만 `spikes/28`로 콜백 본문에 진짜 오용(`local +badType: string = a0:Get()`, 실제론 number / `a0:NoSuchMethod()`, +존재 안 하는 메소드)을 추가해보면 **둘 다 안 잡힙니다** — Luau가 +`a0`의 타입을 콜백 **본문의 사용 방식 그 자체로부터** duck-typing해 +버려서, 뭘 하든 "일관성"만 있으면 통과합니다(`NoSuchMethod`는 존재하는 +필드로 duck-type되고, `Get`의 반환 타입은 `badType`에 맞춰 `string`으로 +재추론된 뒤 그 여파로 진짜 실수 위치가 아닌 엉뚱한 곳에서 혼란스러운 +에러가 남). 이건 §1 4절(기존 06/07번)이 이미 문서화한 것과 정확히 같은 +함정의 재확인입니다 — **명시 인스턴스화 없이는 ②+`read`도 안전판이 +못 됩니다.** + +`spikes/29`(같은 두 오용을 **명시 인스턴스화와 함께** 넣음)와 +`spikes/30`(`NoSuchMethod` 하나만 단독으로)은 둘 다 정확히 잡힙니다 — +명시 인스턴스화가 기대 타입을 콘크리트로 고정해버리므로, `a0`의 +duck-typed shape가 그 콘크리트 타입과 구조적으로 안 맞으면 호출 라인 +전체가 에러납니다(개별 필드별로 정확히 짚어주진 않고 하나의 뭉뚱그린 +shape mismatch로 나오지만, **빠짐없이 잡힙니다**). **결론: 이 기법이 +sound하려면 명시 인스턴스화가 필수**이고, 11-3의 "쪼개기+`read`"만으론 +부족합니다. + +### 11-5. quad 실제 계약 검증 — 체이닝은 완전히 성립, 중첩 self-호출은 여전히 실패 + +`spikes/31` — WINNER 포뮬레이션으로 깊이 3, 타입이 매번 바뀌는 체인 +(`number→boolean→string→number`)을 구성 — **전부 깨끗합니다**: 세 +콜백 파라미터 전부 무주석으로 정확히 추론, 스퓨리어스 진단 0건, 양성 +대조군 3개 전부 통과, 음성 대조군 3개 전부 정확히 에러. 이번 조사에서 +가장 강한 양성 결과입니다. + +`spikes/32` — 콜백 안에서 `a0`를 또 `:Compute`하는 중첩(quad의 +`:Apply` factory 패턴에 대응, §1②가 이미 "이 방식으론 못 풀림, 파라미터 +주석 필요"로 캐비엇 걸어둔 자리) — **여전히 실패**합니다: +`"Too many type parameters passed to function typed as ... Expected at +most 0 type parameters, but 2 provided."` `a0`는 여전히 usage로부터 +duck-typing된 shape라, 이 한 번의 호출 지점에서 `a0.Compute`가 이미 +비-제네릭(구체) 함수 타입으로 굳어버려 그 위에 `<<...>>`를 얹을 대상 +자체가 없어집니다. **바깥 호출의 명시 인스턴스화는 파라미터 자신의 +중첩 메소드 호출로 전파되지 않습니다** — §1②의 기존 캐비엇 그대로 +유효, 이 조사로 안 풀림. + +`spikes/33` — WINNER 포뮬레이션을 `--solver=old`로 교차검증 — 이 +조사와 무관한 **기존에 이미 알려진** 이유로 거부됩니다(`read` 키워드 +자체가 옛 솔버엔 없음, `typeof(namedFn)` 재귀 선언도 옛 솔버가 거부 — +`typing-limits.md` §8/§1③이 이미 명시한 그대로). 새로운 제약이 +추가되는 게 아니라 기존 "새 솔버 전제"를 그대로 물려받을 뿐입니다. + +### 11-6. 실용성 판단 — 기술적으로 성립하지만 채택 안 함 + +**"성립 여부"와 "채택 여부"를 명확히 분리합니다.** leaf(비-중첩) +`:Compute` 호출에 한해 11-1~11-5는 이 기법이 **진짜로 작동하고 +sound함**을 보여줍니다 — 이건 "실패"가 아닙니다. 그럼에도 채택하지 +않는 이유: + +1. **매 호출마다 타입 인자 두 개를 전부 써야 합니다.** `T`는 이미 + `self`(`n`)의 타입에서 100% 결정되는 정보라 **순수 중복**이고, + `U`는 사용자가 콜백을 **작성하기도 전에** 그 반환 타입을 먼저 + 선언해야 한다는 뜻이라, 지금의 "콜백 파라미터 하나에 + `a0: State`처럼 타입 하나만 적어주면 되는" 부담보다 + 객관적으로 더 큽니다(적어야 하는 타입 개수도 늘고, 적어야 하는 + 시점도 더 이릅니다). +2. **더 어려운 자리(`:Apply` 중첩)는 여전히 못 풉니다**(11-5, + `spikes/32`). 이 기법을 채택해도 quad는 "leaf 호출은 `<>` 필수 + + 파라미터 무주석, 중첩 호출은 파라미터 주석 필수"라는 **두 개의 + 서로 다른 규칙**을 사용자에게 동시에 요구하게 되어, 지금의 "콜백 + 파라미터엔 항상 타입 주석을 단다"는 단일하고 일관된 규칙보다 + 오히려 사용자 부담과 실수 표면이 늘어납니다. +3. **`read` modifier 소급 적용 비용.** 11-3의 수정이 성립하려면 quad + 전역의 자기참조 타입(`State`/`Store`/`Modifier`/`Tag`/`Ref`/`Slot`/ + `Attribute` 등)에서 self를 받아 **호출만 되고 대입되지 않는** 모든 + 접근자 필드를 `read`로 명시해야 합니다 — 새 API를 설계할 때마다 + 챙겨야 하는 새 관례가 하나 늘어나는 것이고, 지금까지 base + pseudocode 어디에도 `read`/`write` modifier를 쓴 전례가 없습니다. +4. **`Foo<>(...)` 호출부 문법 자체가 지금 quad API 표면 어디에도 + 없는 새로운 사용자 대면 관용구입니다.** `AttributeKey<>`처럼 + **선언**에서 제네릭을 명시하는 건 이미 있지만, **매 메소드 호출부**에서 + 명시 인스턴스화를 강제하는 패턴은 전례가 없어 `typing-limits.md` + §0("API의 자연스러운 모양을 타입 사정으로 바꾸지 않는다")이 경계하는 + 바로 그 종류의 변화입니다 — 10번 절의 `MonomorphicCompute`처럼 + 호출 자체를 2단계로 쪼개는 건 아니지만("`state:Compute(fn)`이라는 + 단일 콜론 호출" 모양 자체는 유지됨), 그 대가로 매 호출부에 원래 + 없던 타입 인자 보일러플레이트를 강제한다는 점에서 같은 원칙 위반 + 스펙트럼 위에 있다고 판단합니다. + +**결론: 기술적으로 작동하고 sound한 새 메커니즘을 찾았지만, §0 +대전제 관점에서 순손해(더 많이 써야 하고, 더 이른 시점에 써야 하고, +더 넓은 범위의 타입 선언 변경이 필요하고, 어려운 자리는 여전히 못 +풀면서 규칙만 두 개로 늘림)로 판단해 **채택하지 않습니다**.** 기록해두는 +가치는 있음(다음에 누가 또 시도하지 않도록) — 10번 절의 +`MonomorphicCompute`와 같은 취급. + +## 무엇이 남는가 — `base/typing-limits.md` 반영 + +- **§1의 원칙은 바뀌지 않습니다.** "콜백 파라미터는 명시 주석 필요"는 + 여전히 유효 — 이 실험은 그 옆에 "여러 각도로 재확인했지만 전부 + 막혔다"는 각주를 추가할 근거만 줍니다. +- **작지만 유용한 재정정 하나는 반영할 가치가 있습니다**: 이 문제가 + "재귀 자기 참조 특유의 문제"가 아니라 "제네릭 콜백 인자 전반의 문제 + (재귀가 있으면 실패 양상이 duck-typing 오염으로 악화될 뿐)"라는 게 + 이번 실험으로 처음 명확해졌습니다 — §1의 서술을 문자 그대로 반박하는 + 건 아니지만("재귀 제네릭이 자기를 다른 타입 인자로 감싸 반환하면"은 + quad가 실제로 겪는 형태에 대한 정확한 설명 그대로 유지), 원인 이해를 + 한 단계 더 정확하게 해주는 각주로 추가. +- **`MonomorphicCompute` 2단계 체인 기법은 base에 반영하지 않습니다** — + §0 대전제상 "찾았지만 채택 안 함"으로 이 REPORT에만 기록. +- 새로 열린 설계 질문 없음. M0/설계 게이트에 영향 없음(사용자가 요청 + 시점에 이미 명시). diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/00-sanity-nonrecursive-generic-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/00-sanity-nonrecursive-generic-fails.luau new file mode 100644 index 0000000..eeb88cd --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/00-sanity-nonrecursive-generic-fails.luau @@ -0,0 +1,11 @@ +--!strict +-- 순수 sanity check: 재귀 없는 평범한 제네릭 함수도 콜백 파라미터 무주석 추론이 되는가? +local function Map(arr: {T}, fn: (T) -> U): {U} + return {} :: any +end + +local arr: {number} = {1,2,3} +local result = Map(arr, function(a) return tostring(a) end) -- a 무주석 + +local ok: string = result[1] +local wrong: number = result[1] diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/01-sanity-concrete-nongeneric-works.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/01-sanity-concrete-nongeneric-works.luau new file mode 100644 index 0000000..ee2d309 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/01-sanity-concrete-nongeneric-works.luau @@ -0,0 +1,8 @@ +--!strict +-- 제네릭이 아예 없는 구체 함수 타입에서도 콜백 파라미터 추론이 되는가? +local function Apply(fn: (number) -> string): string + return fn(1) +end + +local r = Apply(function(a) return tostring(a) end) -- a 무주석, 기대: number +local ok: string = r diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/02-no-explicit-generic-call-syntax.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/02-no-explicit-generic-call-syntax.luau new file mode 100644 index 0000000..6455b42 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/02-no-explicit-generic-call-syntax.luau @@ -0,0 +1,17 @@ +--!strict +-- Luau에 함수 호출부에서 제네릭 타입 인자를 명시하는 문법이 있는지 확인 +-- (TS의 `f(x)`류 -- 있다면 T를 호출부에서 못박아 콜백 파라미터를 +-- 추론시킬 실마리가 될 수 있었음). +-- +-- 결론: 둘 다 SyntaxError. Luau는 함수 호출에 대한 명시적 제네릭 인자 +-- 문법 자체가 없음(타입 별칭 인스턴스화 `Array`와는 다른 문법). +local function Map(arr: {T}, fn: (T) -> U): {U} + return {} :: any +end +local arr: {number} = {1, 2, 3} + +-- 시도 1: `::<...>` 형태 -- SyntaxError +local result1 = Map::(arr, function(a) return tostring(a) end) + +-- 시도 2: `<...>` 바로 붙이는 형태(제네릭 함수 호출 관용구가 있다면 이 모양일 것) -- SyntaxError +local result2 = Map(arr, function(a) return tostring(a) end) diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/03-cast-whole-function-no-help.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/03-cast-whole-function-no-help.luau new file mode 100644 index 0000000..a410fbd --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/03-cast-whole-function-no-help.luau @@ -0,0 +1,7 @@ +--!strict +local function Map(arr: {T}, fn: (T) -> U): {U} + return {} :: any +end +local arr: {number} = {1,2,3} +local result = Map(arr, (function(a) return tostring(a) end) :: (number) -> string) +local ok: string = result[1] diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/04-table-literal-concrete-context-works.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/04-table-literal-concrete-context-works.luau new file mode 100644 index 0000000..7b28970 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/04-table-literal-concrete-context-works.luau @@ -0,0 +1,4 @@ +--!strict +-- 테이블 리터럴 컨텍스트에서는 무주석 함수 파라미터가 추론되는가? (call-arg와 다른 경로인지 확인) +type Handlers = { fn: (number) -> string } +local t: Handlers = { fn = function(a) return tostring(a) end } diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/05-table-literal-generic-callarg-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/05-table-literal-generic-callarg-fails.luau new file mode 100644 index 0000000..fd7b573 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/05-table-literal-generic-callarg-fails.luau @@ -0,0 +1,8 @@ +--!strict +-- 제네릭 함수 호출의 "인자"로 테이블 리터럴을 넘기면(그 안에 콜백을 담아서) 컨텍스트 추론이 살아나는가? +local function Foo(arr: {T}, handlers: { fn: (T) -> U }): {U} + return {} :: any +end +local arr: {number} = {1,2,3} +local result = Foo(arr, { fn = function(a) return tostring(a) end }) +local ok: {string} = result diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/06-selfref-minimal-duckshape-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/06-selfref-minimal-duckshape-fails.luau new file mode 100644 index 0000000..4ad91fb --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/06-selfref-minimal-duckshape-fails.luau @@ -0,0 +1,9 @@ +--!strict +type Box = { value: T } +local function Compute(self: Box, fn: (Box) -> U): Box + return { value = fn(self) } :: any +end +type BoxM = Box & { Compute: typeof(Compute) } +local b: BoxM = (nil :: any) :: BoxM +local r = b:Compute(function(a) return tostring(a.value) end) +local ok: string = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/07-inline-recursive-param-breaks-all-calls.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/07-inline-recursive-param-breaks-all-calls.luau new file mode 100644 index 0000000..2723e3d --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/07-inline-recursive-param-breaks-all-calls.luau @@ -0,0 +1,10 @@ +--!strict +-- 인라인 선언(구 leaky 방식)에서는 콜백 파라미터가 무주석으로도 추론되는가? (반환타입 leak과 별개 질문) +type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (State) -> U) -> State, +} +local test: State = (nil :: any) :: State +local r = test:Compute(function(a) return tostring(a:Get()) end) +local ok: string = r:Get() +local wrong: number = r:Get() diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/08-WINNER-partial-fixT-viaannotation.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/08-WINNER-partial-fixT-viaannotation.luau new file mode 100644 index 0000000..2f8b738 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/08-WINNER-partial-fixT-viaannotation.luau @@ -0,0 +1,12 @@ +--!strict +-- T만 먼저 명시 주석으로 고정한 중간 변수를 거치면, U는 여전히 제네릭이어도 콜백 파라미터가 추론되는가? +local function Compute(self: {value: T}, fn: ({value: T}) -> U): {value: U} + return { value = fn(self) } :: any +end + +local self1: {value: number} = { value = 1 } + +-- T=number로 고정된 중간 함수 변수(여전히 U는 제네릭) +local computeForNumber: (self: {value: number}, fn: ({value: number}) -> U) -> {value: U} = Compute +local r = computeForNumber(self1, function(a) return tostring(a.value) end) +local ok: string = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/09-WINNER-recursive-fixT-viaannotation.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/09-WINNER-recursive-fixT-viaannotation.luau new file mode 100644 index 0000000..7f8186b --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/09-WINNER-recursive-fixT-viaannotation.luau @@ -0,0 +1,17 @@ +--!strict +-- 2x2 매트릭스의 D칸: 재귀형 + T를 중간변수로 고정 + U만 제네릭 +type Box = { + value: T, + Compute: (self: Box, fn: (Box) -> U) -> Box, +} +local function ComputeImpl(self: Box, fn: (Box) -> U): Box + return { value = fn(self) } :: any +end + +local self1: Box = (nil :: any) :: Box + +-- T=number로 고정한 중간 변수 (U만 여전히 제네릭), self1.Compute가 아니라 자유함수를 통해 +local computeForNumber: (self: Box, fn: (Box) -> U) -> Box = ComputeImpl +local r = computeForNumber(self1, function(a) return tostring(a.value) end) +local ok: string = r.value +local wrong: number = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/10-field-extract-noannot-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/10-field-extract-noannot-fails.luau new file mode 100644 index 0000000..2ca3a4a --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/10-field-extract-noannot-fails.luau @@ -0,0 +1,12 @@ +--!strict +-- 필드 추출을 손으로 재주석하지 않고 그냥 뽑아서 두 단계로 호출하면? +type Box = { + value: T, + Compute: (self: Box, fn: (Box) -> U) -> Box, +} +local self1: Box = (nil :: any) :: Box + +local extractedCompute = self1.Compute -- 재주석 없이 그대로 추출 +local r = extractedCompute(self1, function(a) return tostring(a.value) end) +local ok: string = r.value +local wrong: number = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/11-alias-indirection-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/11-alias-indirection-fails.luau new file mode 100644 index 0000000..51b2d34 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/11-alias-indirection-fails.luau @@ -0,0 +1,11 @@ +--!strict +-- FixedCompute 별칭으로 필드를 감싸면 콜론 호출에서 파라미터가 자동으로 풀리는가? +type FixedComputeOf = (self: Box, fn: (Box) -> U) -> Box +type Box = { + value: T, + Compute: FixedComputeOf, +} +local self1: Box = (nil :: any) :: Box +local r = self1:Compute(function(a) return tostring(a.value) end) +local ok: string = r.value +local wrong: number = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/12-typefunction-field-lazy-deadend.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/12-typefunction-field-lazy-deadend.luau new file mode 100644 index 0000000..4cb42cb --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/12-typefunction-field-lazy-deadend.luau @@ -0,0 +1,9 @@ +--!strict +type function MakeComputeField(T) + error("marker: reached field builder") +end +type Box = { + value: T, + Compute: MakeComputeField, +} +local b: Box = (nil :: any) :: Box diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/13-generic-default-syntax-unsupported.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/13-generic-default-syntax-unsupported.luau new file mode 100644 index 0000000..c7aba1f --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/13-generic-default-syntax-unsupported.luau @@ -0,0 +1,6 @@ +--!strict +local function Foo(x: T): T + return x +end +local a = Foo(1) +local b: string = Foo("hi") diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/14-read-write-modifiers-exist-irrelevant.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/14-read-write-modifiers-exist-irrelevant.luau new file mode 100644 index 0000000..0500620 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/14-read-write-modifiers-exist-irrelevant.luau @@ -0,0 +1,4 @@ +--!strict +type T = { + read Get: (T) -> number, +} diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/15-intersection-overload-ambiguous.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/15-intersection-overload-ambiguous.luau new file mode 100644 index 0000000..acabd13 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/15-intersection-overload-ambiguous.luau @@ -0,0 +1,8 @@ +--!strict +-- 재귀 아닌 타입에서 오버로드(교차타입)로 콘크리트 분기를 하나 추가하면 그 분기 매칭 시 추론이 되는가? +local ComputeGeneric: ((self: {value: number}, fn: ({value: number}) -> U) -> {value: U}) + & ((self: {value: number}, fn: ({value: number}) -> string) -> {value: string}) + = (nil :: any) + +local self1: {value: number} = { value = 1 } +local r = ComputeGeneric(self1, function(a) return tostring(a.value) end) diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/16-intersection-overload-recursive-breaks.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/16-intersection-overload-recursive-breaks.luau new file mode 100644 index 0000000..2a6292c --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/16-intersection-overload-recursive-breaks.luau @@ -0,0 +1,9 @@ +--!strict +type Box = { + value: T, + Compute: ((self: Box, fn: (Box) -> U) -> Box) + & ((self: Box, fn: (Box) -> string) -> Box), +} +local self1: Box = (nil :: any) :: Box +local r = self1:Compute(function(a) return tostring(a.value) end) +local ok: string = r.value diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/17-WINNER-realistic-quad-contract-nested.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/17-WINNER-realistic-quad-contract-nested.luau new file mode 100644 index 0000000..92c4b43 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/17-WINNER-realistic-quad-contract-nested.luau @@ -0,0 +1,35 @@ +--!strict +-- quad 실제 State/Compute 모양(typeof 패턴) + T 고정 중간변수 트릭을 실제 계약(중첩 self 호출 포함)으로 검증 +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: State = (nil :: any) :: State + +-- T=number로 고정한 중간 변수 (콜백 파라미터 무주석 시도) +local computeForNumber: (self: State, func: (State) -> U) -> State = Compute +local d1 = computeForNumber(test, function(a) return a:Get() > 0 end) -- a 무주석, boolean 반환 기대 +local ok1: boolean = d1:Get() +local wrong1: number = d1:Get() + +-- 중첩: 콜백 안에서 a를 또 Compute(quad :Apply factory 패턴과 동형) -- 안쪽도 T=number로 고정한 중간변수를 통해서만 무주석 가능한지 확인 +local nested = computeForNumber(test, function(a) + local innerComputeForNumber: (self: State, func: (State) -> U2) -> State = Compute + local inner = innerComputeForNumber(a, function(a1) return a1:Get() > 0 end) -- a1도 무주석 시도 + local innerOk: boolean = inner:Get() + return "y" +end) +local ok2: string = nested:Get() + +-- 존재하지 않는 메소드 호출 -- 반드시 에러 +local sound = computeForNumber(test, function(a) + a:NoSuchMethod() + return 1 +end) diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/18-NEARMISS-monomorphize-helper-two-call.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/18-NEARMISS-monomorphize-helper-two-call.luau new file mode 100644 index 0000000..54a79b0 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/18-NEARMISS-monomorphize-helper-two-call.luau @@ -0,0 +1,25 @@ +--!strict +-- 재사용 가능한 "monomorphize 헬퍼" 함수 하나로 T별 수동 어노테이션을 없앨 수 있는가? +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), +} + +-- 한 번만 작성되는 재사용 가능한 헬퍼 -- T는 이 함수 자신의 제네릭(호출 시점에 자동 추론) +local function MonomorphicCompute(self: State): ((self: State, func: (State) -> U) -> State) + return (self :: any).Compute +end + +local test: State = (nil :: any) :: State + +-- 사용자 쪽 코드 -- T별 수동 주석 없이 헬퍼만 거쳐서 부르면? +local computeFor = MonomorphicCompute(test) +local d1 = computeFor(test, function(a) return a:Get() > 0 end) -- a 무주석 +local ok1: boolean = d1:Get() +local wrong1: number = d1:Get() diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/19-oldsolver-crosscheck-rejects-typeof.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/19-oldsolver-crosscheck-rejects-typeof.luau new file mode 100644 index 0000000..96fedc0 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/19-oldsolver-crosscheck-rejects-typeof.luau @@ -0,0 +1,28 @@ +--!strict +-- `--solver=old` 교차검증. 17번(quad 실계약 실측 성공 사례)을 그대로 +-- `--solver=old`로 돌리면 어떻게 되는지 확인 -- typeof 간접참조 자체가 +-- 옛 솔버에서 선언 시점에 거부되는지(`typing-limits.md` §8이 이미 예견한 +-- 것)를 이 실험 세트에서도 재확인하기 위함. +-- +-- 실행: luau-analyze --solver=old --annotate 19-oldsolver-crosscheck-rejects-typeof.luau +-- +-- 결과: `typeof(Get)`/`typeof(Compute)` 선언 자체가 +-- "Recursive type being used with different parameters"로 거부됨(새 솔버는 +-- 정상 통과). 이 실험 세트의 새 발견(08/09/17/18)은 전부 이 typeof 패턴 +-- 위에 얹혀 있으므로, 옛 솔버에서는 전부 마찬가지로 무효 -- quad가 이미 +-- 새 솔버를 전제로 하고 있다는 기존 결론(typing-limits.md §8)과 정합적. +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: State = (nil :: any) :: State +local computeForNumber: (self: State, func: (State) -> U) -> State = Compute +local d1 = computeForNumber(test, function(a) return a:Get() > 0 end) +local ok1: boolean = d1:Get() diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/20-explicit-instantiation-nonrecursive-works.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/20-explicit-instantiation-nonrecursive-works.luau new file mode 100644 index 0000000..a2e9089 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/20-explicit-instantiation-nonrecursive-works.luau @@ -0,0 +1,24 @@ +--!strict +-- Baseline / positive control: explicit double-angle instantiation +-- (Foo<>(...)) on a NON-recursive generic function. This is the +-- formulation `/code-review` flagged as never having been tried in the +-- prior `type-recursive-issue-try-callback/` batch (which only tried +-- implicit-inference calls like `Map(arr, fn)`). +local function Map(arr: {T}, fn: (T) -> U): {U} + local out = {} + for i, v in arr do + out[i] = fn(v) + end + return out +end + +local arr: {number} = {1, 2, 3} + +-- explicit instantiation, callback param UNANNOTATED +local result = Map<>(arr, function(a) + local check: number = a -- a should be inferred as `number` + return tostring(a) +end) + +local ok: string = result[1] -- should be fine +local wrong: number = result[1] -- should ERROR (result is {string}) diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/21-partial-instantiation-binds-positionally-not-by-name.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/21-partial-instantiation-binds-positionally-not-by-name.luau new file mode 100644 index 0000000..5dddcd8 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/21-partial-instantiation-binds-positionally-not-by-name.luau @@ -0,0 +1,29 @@ +--!strict +-- Does `Foo<>(...)` with only ONE type arg let you target U while T is +-- inferred from self? Tested on quad's actual recursive self-handle shape. +-- Hypothesis under test: "you can skip T and give only U". +local function Compute(self: Box, fn: (Box) -> U): Box + return nil :: any +end +export type Box = { + Get: (self: Box) -> T, + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +-- intent: "U=string, T inferred as number from self" +local r1 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong1: number = r1:Get() +-- RESULT: `<>` binds POSITIONALLY to the FIRST declared generic (T), +-- not to U. So this actually means "T=string" (conflicting with self's real +-- T=number), not "U=string, T inferred". The conflict manifests as an +-- "Internal error: outstanding free or blocked type in function call" plus +-- a confusing follow-on TypeError — NOT a clean rejection, NOT a working +-- partial-inference. See 21b for confirmation that even giving the CORRECT +-- leftmost T alone (not conflicting) still breaks with the same internal +-- error on this recursive type — so this isn't "wrong argument order", it's +-- "partial instantiation is broken here regardless of which arg you give". diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/21b-partial-instantiation-correct-T-still-broken.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/21b-partial-instantiation-correct-T-still-broken.luau new file mode 100644 index 0000000..a609f8e --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/21b-partial-instantiation-correct-T-still-broken.luau @@ -0,0 +1,28 @@ +--!strict +-- Follow-up to 21: give T CORRECTLY (matches self's actual T=number) and +-- leave U to be inferred from the callback's return. Confirms partial +-- instantiation is unconditionally broken on quad's recursive self type, +-- not just "wrong slot picked". +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r1 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local ok: string = r1:Get() -- positive control — should be fine... +local wrong1: number = r1:Get() -- ...and this should error +-- RESULT: still "Internal error: outstanding free or blocked type in +-- function call" AND the POSITIVE control (`ok`) itself fails — U stays an +-- unresolved bounded type `(string <: 'a <: never)` instead of resolving to +-- `string`. Partial instantiation on this recursive shape isn't just +-- unhelpful, it's actively worse than not instantiating at all. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/22-full-instantiation-callback-param-inferred-but-spurious-diagnostic.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/22-full-instantiation-callback-param-inferred-but-spurious-diagnostic.luau new file mode 100644 index 0000000..cd83952 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/22-full-instantiation-callback-param-inferred-but-spurious-diagnostic.luau @@ -0,0 +1,27 @@ +--!strict +-- FULL explicit instantiation (both T and U given, T matches self) on +-- quad's actual recursive self-handle shape. +local function Compute(self: Box, fn: (Box) -> U): Box + return nil :: any +end +export type Box = { + Get: (self: Box) -> T, + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() -- body typechecks fine, no error here + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() -- correctly errors (downstream sound) +-- RESULT: `a0` IS correctly inferred (unannotated!) as a structural shape +-- matching `Box` well enough that the callback body typechecks and +-- downstream typing is sound (wrong2 correctly errors). BUT the call line +-- itself gets ONE extra spurious diagnostic: "`Get` is a read-only property +-- in the latter type, but the former type requires a read-write property." +-- This is Luau inferring a0's type FROM USAGE (duck-typed, "only ever +-- read") rather than binding it to the nominal Box, and that +-- inferred read-only shape doesn't structurally unify with Box's +-- (implicitly read-write) `Get` field. See 23-26 for the fix. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/23-field-order-swap-no-effect.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/23-field-order-swap-no-effect.luau new file mode 100644 index 0000000..94fc481 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/23-field-order-swap-no-effect.luau @@ -0,0 +1,19 @@ +--!strict +-- Does declaration order of Get/Compute fields affect the spurious +-- diagnostic from 22? Control variant requested by the task. +local function Compute(self: Box, fn: (Box) -> U): Box + return nil :: any +end +export type Box = { + Compute: typeof(Compute), -- swapped: Compute before Get + Get: (self: Box) -> T, +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: no effect. Same spurious read-only/read-write diagnostic as 22. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/24-split-pattern-alone-does-not-fix-spurious-diagnostic.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/24-split-pattern-alone-does-not-fix-spurious-diagnostic.luau new file mode 100644 index 0000000..42771fa --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/24-split-pattern-alone-does-not-fix-spurious-diagnostic.luau @@ -0,0 +1,25 @@ +--!strict +-- Does typing-limits.md §1② ("split" BoxData/Box pattern) alone remove the +-- spurious diagnostic from 22, when combined with full explicit +-- instantiation? +export type BoxData = { + Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: no, the split pattern alone does NOT remove the spurious +-- diagnostic — it just makes the "Expected" side of the diagnostic prettier +-- (`(BoxData) -> string` instead of a ``-laden mess). The +-- underlying read-only vs read-write mismatch on `Get` is unchanged. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/25-WINNER-split-plus-read-modifier-plus-explicit-instantiation.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/25-WINNER-split-plus-read-modifier-plus-explicit-instantiation.luau new file mode 100644 index 0000000..0135ec6 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/25-WINNER-split-plus-read-modifier-plus-explicit-instantiation.luau @@ -0,0 +1,26 @@ +--!strict +-- WINNER: split (②) + explicit `read` property modifier on Get + full +-- explicit <> instantiation. All three together remove the spurious +-- diagnostic from 22/23/24 entirely. +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: CLEAN. Only the genuine `wrong2` error remains. `a0` is +-- unannotated and correctly, soundly inferred as `BoxData`-shaped. +-- The `read` marker matters because the inferred usage-derived shape for +-- `a0` is inherently read-only (it's only ever called, never assigned into) +-- — marking the real field `read` too makes both sides agree. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/26-read-modifier-alone-without-split-does-not-fix.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/26-read-modifier-alone-without-split-does-not-fix.luau new file mode 100644 index 0000000..f739d58 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/26-read-modifier-alone-without-split-does-not-fix.luau @@ -0,0 +1,24 @@ +--!strict +-- Does `read Get` alone (WITHOUT the split pattern) already fix 22? I.e. is +-- the split actually load-bearing, or would `read` alone have been enough? +local function Compute(self: Box, fn: (Box) -> U): Box + return nil :: any +end +export type Box = { + read Get: (self: Box) -> T, + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: no, `read` alone (no split) does NOT fix it — it just changes the +-- spurious diagnostic into a different, even more confusing recursive +-- -laden mismatch, because now the comparison is between two full +-- recursive Box shapes (which structurally require BOTH Get and +-- Compute) instead of the split BoxData (which only requires Get). The +-- split (②) is load-bearing for the 25 fix, not optional. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/27-CAUTION-implicit-call-looks-clean-but-is-a-trap.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/27-CAUTION-implicit-call-looks-clean-but-is-a-trap.luau new file mode 100644 index 0000000..a62b9f4 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/27-CAUTION-implicit-call-looks-clean-but-is-a-trap.luau @@ -0,0 +1,27 @@ +--!strict +-- CAUTION / FALSE POSITIVE: same split+read fix as 25, but WITHOUT explicit +-- <> instantiation (plain implicit call). Does the `read` fix +-- accidentally also rescue the implicit-call case that typing-limits.md §1③ +-- already established doesn't work? +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT ON ITS OWN LOOKS CLEAN: no spurious diagnostic, `check` typechecks, +-- `wrong2` correctly errors. BUT this is misleading — see 28, which adds +-- negative controls INSIDE the callback body and reveals this "success" is +-- pure duck-typing-from-usage (unsound), not real inference. Do not read +-- this file's clean result in isolation as "implicit calls now work". diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/28-CAUTION-negative-controls-expose-duck-typing-unsoundness.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/28-CAUTION-negative-controls-expose-duck-typing-unsoundness.luau new file mode 100644 index 0000000..90321ac --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/28-CAUTION-negative-controls-expose-duck-typing-unsoundness.luau @@ -0,0 +1,38 @@ +--!strict +-- Negative controls for 27: is a0's inferred type ACTUALLY the nominal +-- BoxData, or is Luau just duck-typing a shape FROM the callback +-- body's own usage (i.e. tautologically "consistent" with whatever the body +-- happens to do, catching nothing)? +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute(function(a0) + local badType: string = a0:Get() -- should ERROR: Get() returns number + a0:NoSuchMethod() -- should ERROR: no such method + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: confirms unsoundness. +-- * `a0:NoSuchMethod()` produces ZERO error -- Luau just duck-types a +-- `NoSuchMethod` field into existence on a0's inferred shape, because +-- nothing constrains a0 to the real BoxData. +-- * `local badType: string = a0:Get()` also produces no error AT THAT +-- LINE -- instead the solver infers Get's return type as `string` to +-- satisfy it, which then conflicts with the later `tostring(a0:Get())` +-- usage that also needs Get, producing a confusing downstream error +-- ("No valid instantiation could be inferred for generic type parameter +-- T... expected number|string, at most number&string") instead of a +-- clear "badType is wrong" message at the actual mistake's location. +-- This is the SAME class of danger typing-limits.md already documents for +-- unannotated self-ref params (old REPORT section 4, spikes 06/07): the +-- callback body is checked against a shape inferred FROM the body itself, +-- not against the real type, so genuine misuse can go silently uncaught. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/29-explicit-instantiation-negative-controls-are-sound.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/29-explicit-instantiation-negative-controls-are-sound.luau new file mode 100644 index 0000000..b53a8f8 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/29-explicit-instantiation-negative-controls-are-sound.luau @@ -0,0 +1,30 @@ +--!strict +-- Same negative controls as 28, but WITH explicit <> instantiation +-- (the 25 formulation). Does explicit instantiation make the check sound +-- (vs 28's duck-typing trap)? +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local badType: string = a0:Get() -- should ERROR: Get() returns number + a0:NoSuchMethod() -- should ERROR: no such method + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: sound. The call line now errors because the callback's inferred +-- shape (`Get` returning `string`, plus an extra `NoSuchMethod` field) +-- doesn't match the now-CONCRETE expected `(BoxData) -> string` +-- (T=number is pinned by explicit instantiation, not left free to be +-- shaped by usage). One combined diagnostic covers both mistakes rather +-- than two separate ones, but it DOES fail analysis -- nothing slips +-- through silently. See 30 for confirmation that NoSuchMethod alone +-- (isolated from the badType mistake) is caught too. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/30-explicit-instantiation-nosuchmethod-alone-is-caught.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/30-explicit-instantiation-nosuchmethod-alone-is-caught.luau new file mode 100644 index 0000000..6671bb6 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/30-explicit-instantiation-nosuchmethod-alone-is-caught.luau @@ -0,0 +1,23 @@ +--!strict +-- Isolate: does explicit instantiation catch a bogus method call even when +-- nothing else in the callback body is wrong? +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + a0:NoSuchMethod() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT: caught. Call-site TypeError (shape mismatch: real BoxData +-- has no NoSuchMethod field). Confirms 29 wasn't a fluke of having two +-- simultaneous mistakes. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/31-chain-depth-3-type-changing-explicit-instantiation.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/31-chain-depth-3-type-changing-explicit-instantiation.luau new file mode 100644 index 0000000..215247d --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/31-chain-depth-3-type-changing-explicit-instantiation.luau @@ -0,0 +1,42 @@ +--!strict +-- Chained calls with explicit <> instantiation, type changing at each +-- step: number -> boolean -> string -> number. Uses the 25 (WINNER) +-- formulation throughout. +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r1 = n:Compute<>(function(a0) + local x: number = a0:Get() + return x > 0 +end) +local r2 = r1:Compute<>(function(a1) + local y: boolean = a1:Get() + return tostring(y) +end) +local r3 = r2:Compute<>(function(a2) + local z: string = a2:Get() + return #z +end) + +-- positive controls +local ok1: boolean = r1:Get() +local ok2: string = r2:Get() +local ok3: number = r3:Get() + +-- negative controls +local wrong1: number = r1:Get() -- should error (r1 is Box) +local wrong2: number = r2:Get() -- should error (r2 is Box) +local wrong3: string = r3:Get() -- should error (r3 is Box) +-- RESULT: fully clean chaining. All three callback params inferred +-- unannotated and correctly, no spurious diagnostics at any step, all +-- three positive controls pass, all three negative controls correctly +-- error. This is the strongest positive result in this investigation. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/32-nested-self-call-inside-callback-still-fails.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/32-nested-self-call-inside-callback-still-fails.luau new file mode 100644 index 0000000..a0831e7 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/32-nested-self-call-inside-callback-still-fails.luau @@ -0,0 +1,40 @@ +--!strict +-- Nested self-call: callback body calls a0:Compute(...) again (quad's +-- :Apply factory / nested-Compute pattern, typing-limits.md §1② already +-- flags this as needing manual annotation under the split approach). Does +-- explicit instantiation rescue this case too? +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local outer = n:Compute<>(function(a0) + -- nested call, ALSO explicitly instantiated + local inner = a0:Compute<>(function(a1) + local v: number = a1:Get() + return v > 0 + end) + local b: boolean = inner:Get() + return tostring(b) +end) + +local ok: string = outer:Get() +local wrong: number = outer:Get() -- should error +-- RESULT: FAILS. "Too many type parameters passed to function typed as +-- ... Expected at most 0 type parameters, but 2 provided." Because `a0` +-- itself is a duck-typed (usage-derived) shape, not the nominal +-- BoxData, its `Compute` field gets concretized to a NON-generic +-- function type from this one call site -- so `a0:Compute<<...>>` has no +-- generics left to instantiate. Explicit instantiation on the OUTER call +-- does not propagate generics into the unannotated parameter's OWN nested +-- method calls. Consistent with typing-limits.md §1②'s existing caveat: +-- nested self-calls inside a callback (the :Apply factory pattern) still +-- need `function(a0: Box)` parameter annotation regardless of this +-- investigation's findings. diff --git a/.claude/audit/type-recursive-issue-try-callback/spikes/33-oldsolver-crosscheck-rejects-everything.luau b/.claude/audit/type-recursive-issue-try-callback/spikes/33-oldsolver-crosscheck-rejects-everything.luau new file mode 100644 index 0000000..06cd794 --- /dev/null +++ b/.claude/audit/type-recursive-issue-try-callback/spikes/33-oldsolver-crosscheck-rejects-everything.luau @@ -0,0 +1,29 @@ +--!strict +-- Cross-check with --solver=old on the WINNER formulation (25) and the +-- chain (31). Expectation per typing-limits.md §8: old solver rejects +-- `typeof(namedFn)` recursive declarations outright, so this should be +-- rejected for reasons unrelated to explicit instantiation. +export type BoxData = { + read Get: (self: BoxData) -> T, +} +local function Compute(self: BoxData, fn: (BoxData) -> U): Box + return nil :: any +end +export type Box = BoxData & { + Compute: typeof(Compute), +} + +local n: Box = nil :: any + +local r2 = n:Compute<>(function(a0) + local check: number = a0:Get() + return tostring(a0:Get()) +end) +local wrong2: number = r2:Get() +-- RESULT (run with `luau-analyze --solver=old`): rejected, but for +-- pre-existing reasons unrelated to this investigation: "read keyword is +-- illegal here" (old solver doesn't have property read/write modifiers at +-- all) and "Recursive type being used with different parameters" (the +-- typeof(namedFn) trick itself, per typing-limits.md §1③/§8, is +-- new-solver-only). No new information -- confirms this whole investigation +-- inherits the existing new-solver-only constraint, doesn't add a new one. diff --git a/.claude/audit/type-recursive-issue-with-typeof/REPORT.md b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md index 151aed7..0f3080b 100644 --- a/.claude/audit/type-recursive-issue-with-typeof/REPORT.md +++ b/.claude/audit/type-recursive-issue-with-typeof/REPORT.md @@ -15,7 +15,14 @@ 재현됨. **실측 환경**: Luau `0.733`(`mise ls luau`), `luau-analyze`(새 솔버 -기본값)와 `luau-analyze --solver=old` 둘 다로 교차검증. +기본값) 기준. **[2026-08-15 정정, `/code-review high` 지적]** `--solver=old` +교차검증은 이 폴더 자체의 `spikes/`엔 없음 — 후속 실험 +(`audit/type-recursive-issue-try-callback/spikes/ +19-oldsolver-crosscheck-rejects-typeof.luau`)에서 이 폴더의 승자 +formulation(`typeof(Get)`/`typeof(Compute)`)을 그대로 돌려 확인됨: +옛 솔버는 그 선언 자체를 "Recursive type being used with different +parameters"로 **선언 시점에 거부**(새 솔버만 통과) — `typing-limits.md` +§8이 이미 예견한 "새 솔버 전제"와 정합적. ## TL;DR @@ -43,8 +50,9 @@ 3. **음성 대조군**: 명백히 틀린 사용(`wrong: U = ...`, 없는 메소드 호출)이 진짜로 에러가 나는지 — 둘 다 확인해야 "타입이 살아있다"고 말할 수 있음 (에러가 하나도 안 나는 건 안전이 아니라 `any`로 샌 것일 수도 있음). -4. 체이닝 깊이 1/3/5/8/50, 콜백 안에서 self를 다시 호출하는 중첩 케이스, - 구솔버(`--solver=old`) 대조까지 포함. +4. 체이닝 깊이 1/3/5/8/50, 콜백 안에서 self를 다시 호출하는 중첩 케이스 + 포함(구솔버 `--solver=old` 대조는 이 폴더가 아니라 후속 실험에서 + 수행 — 위 "실측 환경" 참고). ## 1. 대조군 — 인라인 선언은 여전히 leak (기존 0-Y 그대로 재현) @@ -121,16 +129,25 @@ end)`처럼 **그 대입 줄 자체**에 틀린 타입 주석을 달면 그 줄 `Modifier`의 `__index` + `table.clone` 체이닝, `typing-limits.md` §6과 같은 계열)을 재귀 `Compute`에 확장해봤습니다. -### 5-1. raw 값을 콜백 파라미터로 받으면(quad 계약 아님) 정말 좋음 +### 5-1. `spikes/07` — U==T(타입 안 바뀜) 케이스, self-핸들 파라미터는 명시 주석 -`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` 참고). +**[2026-08-15 정정, `/code-review high` 지적]** 원래 서술이 이 파일을 +"콜백이 raw 값을 받으면 무주석으로도 정말 좋음, 체이닝 50단까지 깔끔"으로 +소개했으나, **`spikes/07-metatable-clean-when-U-equals-T.luau`를 직접 +열어보면 그 서술과 안 맞습니다** — 이 파일은 콜백이 raw 값이 아니라 +`(a0: Box): number`처럼 **self 핸들을 명시 주석으로** 받고, +체이닝도 50단이 아니라 **1단**(`d1 = test:Compute(...)` 한 번)뿐입니다. +raw 값 콜백 + 50단 체이닝을 다뤘다는 실험은 이 폴더의 `spikes/`에 +저장된 파일 중 어디에도 없어 지금은 재현할 수 없습니다 — 그 서술은 +빼고, 이 파일이 실제로 보여주는 것만 남깁니다: **자동 추론 +자체는 됩니다**(파라미터에 명시 주석이 있으므로 무주석 추론 얘기가 +아니라 타입 안전성 얘기 — `wrong: string = d1:Get()`이 정확히 에러남). +이 파일의 진짜 역할은 **5-3의 대조군**입니다 — U가 T와 같을 때는 +아래 솔버 버그가 안 남을 보여주는 baseline. 어느 쪽이든 raw 값이든 +self 핸들이든 **quad의 실제 계약은 self 핸들**이라 이 절의 결론에는 +영향 없습니다 — quad `Compute`의 콜백은 lazy self 핸들 자체를 받아야 +함(여러 세션에 걸쳐 확정된 계약, `base/typing-limits.md`/ +`bind-system-plan.md` 참고). ### 5-2. 콜백이 self 핸들을 무주석으로 받으면 — 정상/오용 안 가리고 모든 호출이 깨짐 diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index 8304f2d..8d9c2fe 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -180,11 +180,47 @@ type State = { - **콜백 파라미터는 여전히 명시 주석 필요**(`function(a0: State)`) — ②의 "쪼개기"가 다루던 문제는 별개이고, `typeof`가 대신 풀어주지 않습니다. 무주석이면 자유 타입 변수(`a0: unknown`에 가까움). + **[2026-08-15 추가 확인, `audit/type-recursive-issue-try-callback/`]** + 사용자 요청으로 이 캐비엇을 뚫을 방법이 있는지 type function/메타테이블/ + 오버로드/제네릭 디폴트 등 20개 formulation으로 재시도했으나 quad의 + `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 못 뚫음(REPORT + TL;DR 참고). 부수적으로 원인 이해가 한 단계 더 정확해짐 — 이 실패는 + "재귀 자기참조 특유의 문제"가 아니라 **"제네릭이 관여하는 함수 호출의 + 인자로 넘긴 함수 리터럴엔 Luau가 컨텍스트 타입을 전파하지 않는다"**는 + 더 일반적인 한계임(재귀가 전혀 없는 `Map(arr:{T}, fn:(T)->U)`류 + 콜백도 무주석이면 동일하게 샘). 재귀는 이 위에 얹혀 실패 양상을 + "완전 무주석"에서 "구조적 duck-typing 오염"(정상 호출조차 타입에러)으로 + 악화시키는 부가 요인. `T`를 명시 중간 변수로 먼저 고정하거나 재사용 + 가능한 monomorphize 헬퍼 함수를 거치면 실제로 추론이 살아나는 것도 + 확인했지만(재귀형·quad 실제 계약·중첩 self 호출까지 검증), 둘 다 + `state:Compute(fn)`(콜론 호출 = `state.Compute(state, fn)` 단일 호출)을 + "먼저 특수화된 함수를 얻고 그걸 다시 호출하는" 2단계 체인으로 바꿔야만 + 작동해 0번 대전제로 채택 안 함. + **[2026-08-15 후속 조사, 같은 폴더 REPORT 11번 절]** `/code-review` + 지적으로 이중 꺾쇠 명시 인스턴스화(`state:Compute<>(fn)`, 코퍼스에 + 이미 `AttributeKey<>`류로 존재하는 문법)도 재검토 — **leaf(비-중첩) + 호출에 한해 콜백 파라미터 무주석 추론이 실제로 성립하고 sound함을 + 확인**(②쪼개기 + `Get` 필드의 명시 `read` modifier + 매 호출 T·U 전부 + 명시가 함께 필요, 셋 중 하나라도 빠지면 스퓨리어스 진단이나 duck-typing + 함정으로 되돌아감). 이 역시 **채택 안 함** — 매 호출마다 이미 self로부터 + 결정되는 `T`까지 중복 명시해야 하고 `U`(콜백을 쓰기도 전에 그 반환 + 타입)를 미리 선언해야 하는 부담이 지금의 "파라미터에 타입 하나만 + 주석" 관례보다 크고, `:Apply` 중첩 자기호출(§1②가 이미 캐비엇 건 자리)엔 + 여전히 안 통해 규칙이 하나로 안 줄고 오히려 둘로 늘어나며, 부분 + 인스턴스화(`<>`)는 이름이 아니라 선언 순서로 바인딩돼 이 재귀형에서 + 아예 내부 에러로 깨짐. 상세 근거·모든 formulation은 REPORT 11번 절. - **명시 LHS 오타입 그 줄 자체는 여전히 못 잡습니다**(`wrong: State = test:Compute(...)`처럼 대입 줄 자체에 틀린 타입을 달면 그 줄은 통과 — 다운스트림에서만 잡힘). ①이 이미 명시한 구멍과 정확히 같은 종류로, **이 규약이 그 구멍을 메워주지 않습니다** — "명시 바인딩 관례를 완화할 수 있다"는 뜻이 절대 아닙니다. +- **옛 솔버는 ③의 `typeof(Compute)` 선언 자체를 거부합니다**(①이 이미 + 겪는 것과 같은 문제 — `--solver=old`에서 "Recursive type being used + with different parameters"로 선언 시점에 막힘, 실측: + `type-recursive-issue-with-typeof/spikes/ + 19-oldsolver-crosscheck-rejects-typeof.luau`). ③을 관례로 채택해도 + §8의 "M0 실착수 때 실제 에디터 환경(`luau-lsp`)에서 새 솔버 확정" + 전제는 그대로 유효 — ③이 이 요구사항을 없애주지 않습니다. **시도했지만 채택 안 함 — `setmetatable<{...}, {__index: typeof(...)}>`**: 콜백 파라미터 자동 추론까지 노리고 `Modifier`의 `__index`+`table.clone` @@ -350,7 +386,9 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC `-> Foo`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), 메소드를 인라인 대신 이름 붙은 함수 + `typeof`로 선언(1번 ③)하고, 그래도 명시 바인딩 관례는 그대로 문서에 같이 적을 것 — ③은 ①을 - 대체하지 않음. + 대체하지 않음. **⚠️ ③은 반환 타입 안전성(LHS 무주석 다운스트림)만 + 고쳐줍니다 — 콜백 파라미터 자동 추론이 같이 필요하면 이것만으론 + 부족하니 바로 아래 2번도 같이 볼 것.** 2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 콜백 파라미터 자동 추론이 필요하면 1번의 "쪼개기"(`XxxData` / `Xxx` 분리)를, 반환 타입 안전성이 우선이면 1번 ③(`typeof`)을 @@ -358,7 +396,11 @@ function은 구체 타입에 대해서만 동작하는 실행 모델이라, RFC 범위" 표 참고). **`setmetatable<{...}, {__index: typeof(...)}>`로 확장해 두 이득을 한 번에 얻으려 하지 말 것** — quad의 self-핸들 콜백 계약에서 솔버 버그를 만남(1번 ③의 "시도했지만 채택 안 함" - 참고). + 참고). **콜백 파라미터 무주석 자동 추론 자체가 필요하면**(쪼개기로 + 해결 안 되는 자리, 예: `:Apply`의 factory) `audit/ + type-recursive-issue-try-callback/`이 전방위로 재시도했지만 + quad가 채택할 만한 방법은 못 찾음(REPORT 결론) — 지금은 명시 주석 + 관례를 유지할 것. 3. **제네릭 키로 값 타입을 좁히려 하는가?** → 3번, 안 됨. 타입 패밀리를 쓸 것. 4. **서브타입 관계인 두 타입을 합성하려 하는가?** → 2번, 메소드 반환 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 3ff8174..a08850c 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -11,7 +11,7 @@ | 폴더 | 뜻 | 누가 처리 | |---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) | 에이전트 | +| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) — **`16`은 [2026-08-15] 통과로 `done/`에 있음**(아래 참고) | 에이전트 | | `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 5차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | — | diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 74fce61..ac696da 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -49,7 +49,7 @@ `rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이 아님(계약 자체는 위에서 이미 확정됨). -## 🟠 `rewrite-required/` — 스파이크가 낡음 (7건) +## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건) **[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가 바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가 diff --git a/.claude/luau-test/done/16-type-store-key-typefunction.luau b/.claude/luau-test/done/16-type-store-key-typefunction.luau index 3de580b..777af72 100644 --- a/.claude/luau-test/done/16-type-store-key-typefunction.luau +++ b/.claude/luau-test/done/16-type-store-key-typefunction.luau @@ -18,7 +18,9 @@ `types.newtable()`이 돌려주는 **뮤터블 핸들 자기 자신**을 그대로 참조하면 됨(나중에 `setproperty`로 채워도 핸들이라 소급 반영됨) — 별도 self 타입을 새로 만들 필요 없음. 이 수정만으로 통과. - 재현: `luau-analyze 16-type-store-key-typefunction.luau` — 진단 0건. + 재현: `luau-analyze 16-type-store-key-typefunction.luau` — 양성 경로는 + 클린, 이 파일 하단의 의도된 음성 대조군 4개(TypeError)만 정확히 + 에러남(진단 0건이 아님 — 그게 정상). 실측 환경: Luau 0.733(`mise ls luau`). ]] 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 index bfbf8c4..1c3aa0d 100644 --- a/.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md +++ b/.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md @@ -46,7 +46,11 @@ typing-limits를 풀어보자, 오래 걸려도 되니까 정확하게 처리해 파라미터까지 무주석 자동 추론**되는 걸 발견(raw 값 파라미터 한정). 이걸 진짜 재귀 `Compute: Box`로 확장해보니 처음엔 완벽해 보였음(체이닝 50단, 무주석 파라미터, 즉시 LHS 오타입 검출까지 전부 - 통과) — 그러나 quad의 **실제 계약**(콜백이 raw 값이 아니라 self + 통과 — **[2026-08-15 정정, `/code-review` 지적] 이 raw-값·50단 + 버전은 파일로 저장되지 않아 지금은 재현 불가**, 사용자와 직접 + 대화하며 확인한 중간 관찰일 뿐 — 최종 결론은 이 서술이 아니라 + `REPORT.md` §5-1/`typing-limits.md`가 소스) — 그러나 quad의 + **실제 계약**(콜백이 raw 값이 아니라 self 핸들을 받음)으로 정정하자 두 가지 문제가 연쇄로 드러남: (a) 파라미터 무주석 시 duck-typing으로 새서 존재하지 않는 메소드도 안 잡히는 불건전, (b) 파라미터를 명시 주석해도 **콜백 반환 타입이 self의 @@ -81,7 +85,7 @@ type function으로 0-Y 자체(재귀 `Compute`)를 우회하는 시도는 (복구 완료), `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/` +- 전체 실측 원문+스파이크 15개: `audit/type-recursive-issue-with-typeof/` (`REPORT.md` + `spikes/`, 사용자가 만든 `test-ignoreme`/`test2`/`test3`도 `00`대 파일로 보존). diff --git a/.claude/session/2026-08-15-02-try-callback-investigation-and-review-fixes.md b/.claude/session/2026-08-15-02-try-callback-investigation-and-review-fixes.md new file mode 100644 index 0000000..308b731 --- /dev/null +++ b/.claude/session/2026-08-15-02-try-callback-investigation-and-review-fixes.md @@ -0,0 +1,117 @@ +# 2026-08-15 두 번째 세션 — 콜백 파라미터 무주석 추론 전방위 재시도, `/code-review` 2회전 정합성 수정 + +## 배경 + +직전 세션(`2026-08-15-01`)이 `typeof(named fn)` 간접참조로 0-Y(반환 +타입 leak)를 우회했지만, "콜백 파라미터는 여전히 명시 주석 필요"라는 +캐비엇은 그대로 남겨뒀음. 사용자가 이 캐비엇에 대해 "제 손으로도 +안 된다는 결론이 나오긴 했지만, 확실하다고 보기는 어려워 보인다"며 +type function/메타테이블/제네릭 등 전방위 재시도를 요청 — +"확장 시도가 지금 설계에 영향 안 미치더라도 좋다"는 전제로 순수 +탐색적 리서치 성격임을 명시. + +## 1차 조사 — `type-recursive-issue-try-callback/` 신설 (에이전트 위임, spikes 00~19) + +general-purpose 에이전트에 위임(luau 스파이크 다수 작성/실행이라 +메인 컨텍스트 보호 목적). 결과: + +- **근본 원인 재정정**: 재귀 자기참조 특유의 문제가 아니라 "제네릭이 + 관여하는 함수 호출 인자로 넘긴 함수 리터럴엔 Luau가 컨텍스트 타입을 + 전파하지 않는다"는 더 일반적 한계(재귀 없는 `Map(arr,fn)`도 + 똑같이 샘, 00번). +- **near-miss 두 개 발견, 둘 다 채택 안 함**: (a) `T`를 명시 중간 + 변수로 먼저 고정하면 재귀형에서도 콜백 파라미터가 정확히 추론됨 + (09번), (b) 재사용 가능한 monomorphize 헬퍼 함수를 거치면 T별로 + 손으로 다시 쓸 필요 없이 같은 효과(18번, 이번 조사의 가장 흥미로운 + 발견). 둘 다 `state:Compute(fn)` 단일 호출을 2단계 체인으로 바꿔야만 + 작동해 `typing-limits.md` §0 대전제(API를 타입 사정으로 비틀지 + 않음) 위반으로 기각. +- `type function`으로 재귀 반환 자체를 지연 평가하는 시도는 여전히 + `stack overflow` 막다른 길(직전 리서치와 같은 결론 재확인). + +`base/typing-limits.md` §1에 각주로 반영, `.claude/README.md` audit +색인에 신설 행 추가. + +## `/code-review high` 1회전 — 문서 정합성 9건 + 미검증 각도 1건 + +메인 세션이 직접 검증 후 수정. 대부분 카운트/인용 stale(README +audit 개수 7→6, `luau-test/STATUS.md` 헤딩 7→6, `ROADMAP.md` M0 +체크리스트 미갱신, `luau-test-first-run` 표의 스파이크 16 결과가 +"❌ 실패"로 안 고쳐진 것 등 — 전부 프로젝트 자체 규율인 "배너뿐 아니라 +본문도 고칠 것"이 재발한 사례). + +**핵심 발견 1건**: 1차 조사 20개 formulation이 전부 **암묵 호출**만 +시도했고, 코퍼스에 이미 실사용 중인 **이중 꺾쇠 명시적 제네릭 +인스턴스화**(`Foo<>(...)`, `base/attribute-plan.md`의 +`AttributeKey<>`, `type-recursion-issue/spikes/38/39/41`에 이미 +있음)를 단 하나도 시도하지 않았음. 메인 세션이 직접 빠르게 재현해보니 +quad 실제 self-핸들 계약에서 **부분 성공**(다운스트림 타입은 정확) + +이전과 다른 spurious 진단(read-only/read-write 불일치)이 섞여 나옴 — +새 각도라 판단해 후속 조사로 위임. + +## 후속 조사 — `<>` 명시적 인스턴스화 (에이전트 위임, spikes 20~33+21b) + +- **spurious 진단 원인 규명**: `08-metatable-BUG`(직전 세션의 solver + 버그)와 다른 문제 — duck-typed 콜백 파라미터가 "읽기만 관측돼 + read-only로 잡히는데 기대 타입은 기본 read-write"인 단순 가변성 + 불일치. 쪼개기(②)+`Get` 필드에 명시 `read` modifier로 완전 해소됨. +- **leaf 호출에선 실제로 sound하게 성립**(체이닝 depth 3까지 완전 + 클린, 음성 대조군도 정확히 잡힘). +- **partial instantiation은 이름이 아니라 선언 순서로 바인딩** — `U`만 + 주려 해도 `T`부터 채워짐, 재귀형에서 Internal error로 깨짐(21/21b). +- **최종 채택 안 함**: 매 호출마다 이미 self로부터 결정되는 `T`까지 + 중복 명시 + 콜백을 쓰기도 전에 반환 타입 `U`를 미리 선언해야 하는 + 부담이 지금 관례("파라미터에 타입 하나만 주석")보다 크고, `:Apply` + 중첩 자기호출은 여전히 안 풀림 — `typing-limits.md` §0 대전제상 + 순손해. +- **함정 하나 추가 발견**: 쪼개기+`read`만 하고 명시 인스턴스화를 뺀 + 암묵 호출은 겉보기엔 깨끗해 보이지만 음성 대조군을 전혀 못 잡는 + 순수 duck-typing이었음(27 vs 28 대조). + +`REPORT.md` §11 신설(TL;DR 갱신 포함), `typing-limits.md` §1엔 각주 +포인터만(원칙 자체는 안 바뀜). + +## `/code-review`(재사용 high) 2회전 — 정합성 9건 + +첫 회전이 새로 만든 문서/수정 자체에서 또 발견된 stale — 대부분 +"수정하며 새 stale을 만드는" 패턴의 재발: + +- `typing-limits.md` §1③의 "바뀌지 않는 것" 목록에 옛 솔버 거부 + 캐비엇이 빠져있던 것(①만 그 캐비엇이 있고 ③엔 없었음) — 추가. +- §7 체크리스트 항목 1(무조건 ③ 쓰라는 투)과 항목 2(파라미터 추론엔 + ②도 같이 필요하다는 헤지)가 서로 부딪히던 것 — 항목 1에 "③은 + 반환 타입 안전성만 고쳐줌, 파라미터 추론엔 부족" 경고 추가. +- **이 세션 자체의 작업이 세션 히스토리에 아직 안 남아있던 것** — 이 + 파일이 그 반영. +- `CLAUDE.md`의 `.claude/audit/` 소개 bullet이 3개 폴더만 나열한 채 + 최신 3개(fallback-xpcall/typeof/try-callback)가 안 반영돼 있던 것 — + luau-test bullet과 같은 패턴으로 "나열 안 하고 README.md로 미룸"으로 + 전환. +- `type-recursion-issue/REPORT.md`(0-Y 원래 리서치, 13차 세션 산출물)의 + "06/07/10 자유 함수로 빼면 재귀 유무 무관하게 통과" 서술이 실제로는 + 틀렸음 — `06`은 `self: Box`처럼 self 자신이 제네릭이라 실제로는 + duck-typing 오염 + TypeError가 남(직접 `luau-analyze --annotate`로 + 재현 확인). `07`/`10`은 self가 모노몰픽이라 통과 — 진짜 분기점은 + "자유 함수인가"가 아니라 "self가 비제네릭인가"였음, 06을 13(self + 완전 자유 제네릭도 안 풀림)과 같은 편으로 재분류. +- 스파이크 개수 off-by-one 2건(README의 try-callback 34→35, 직전 + 세션 로그의 typeof 폴더 14→15 — 둘 다 서브레터 파일(`21b`, + `00b`/`00c`)을 안 세서 생긴 것). +- 직전 세션 로그가 "raw 값 콜백 + 50단 체이닝이 깨끗이 통과"를 + 마치 확정 사실처럼 서술한 것 — 그 formulation은 파일로 저장 안 돼 + 지금 재현 불가라는 정정 각주 추가(REPORT.md §5-1이 이미 밟은 + 것과 같은 조치). + +## 남은 것 + +`.claude/README.md`(audit `현재 6개` 카운트가 커밋 시점 기준으로는 +아직 5개였던 것 — 커밋 안 한 현재 작업 트리 기준으로는 이미 6개로 +정확해서 별도 조치 없음, 커밋되면 자동 해소), 3건은 code-review가 +검증 단계에서 이미 refuted 처리(허위 양성). + +## 관련 문서 + +`.claude/audit/type-recursive-issue-try-callback/`(REPORT.md + spikes +35개), `base/typing-limits.md` §1/§7, `.claude/README.md`, +`CLAUDE.md`, `.claude/audit/type-recursion-issue/REPORT.md`, +`.claude/session/2026-08-15-01-typeof-recursive-generic-workaround.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 392a836..0e7a39a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,14 +63,17 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 나열 안 하고 `STATUS.md`로 미룸**(나열하다 stale해지는 패턴이 실제로 반복됐음, 아래 "지금 할 일" 0번 참고). - `.claude/audit/` — **[2026-08-13 신설]** 스파이크를 실제로 돌린 **실측 - 결과** 기록(계획 아님). 부분 확인도 있는 그대로 남김 — - `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체, 구 0-Y의 1차 - 근거 — 단 그 문서의 "raw 값이면 완전 클린" 판정은 아래 문서가 뒤집었음), - `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 - 부분 확인), **`type-recursion-issue/`**(**[13차 세션]** 0-Y 재실측 전체 — - `REPORT.md` + `spikes/` 44개. **이 폴더만 예외적으로 스크립트를 같이 - 둠** — 판정이 "여러 formulation 대조"라 개별 파일을 직접 돌려야 재현됨. - 결론은 `base/typing-limits.md`로 승격). + 결과** 기록(계획 아님). 부분 확인도 있는 그대로 남김 — **지금 몇 개가 + 있는지·각각 뭘 확인했는지는 여기서 나열 안 하고 `.claude/README.md`의 + `audit/` 행으로 미룸**(luau-test와 같은 이유 — 나열하다 새 폴더가 + 추가될 때마다 stale해지는 패턴이 실제로 반복됐음, 가장 최근엔 + 2026-08-15에 이 목록이 3개에서 멈춰 있는 걸 `/code-review`가 발견). + `type-recursion-issue/`만 참고로 짚으면: **[13차 세션]** 0-Y 재실측 + 전체 — `REPORT.md` + `spikes/` 44개, **스크립트를 같이 두는** 예외적 + 구성(판정이 "여러 formulation 대조"라 개별 파일을 직접 돌려야 재현됨), + 결론은 `base/typing-limits.md`로 승격 — 이후 신설된 폴더들도 같은 + 구성 관례를 따름(`type-recursive-issue-with-typeof/`, + `type-recursive-issue-try-callback/` 등). - `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, 지금은 비어있음. `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 @@ -1487,3 +1490,22 @@ include/markdown-magic이 선례임을 확인 후 build-vs-buy 논의, 문제가 `done/`으로 이동. type function으로 0-Y 자체를 우회하는 시도는 `stack overflow`로 막다른 길 확인. 전체 실측: `audit/type-recursive-issue-with-typeof/`. + +**2026-08-15 두 번째 세션 — 콜백 파라미터 무주석 추론 전방위 재시도, +`/code-review` 2회전 정합성 수정** +(`session/2026-08-15-02-try-callback-investigation-and-review-fixes.md`) +사용자 요청으로 "콜백 파라미터는 명시 주석 필요" 캐비엇을 type +function/메타테이블/제네릭 등으로 전방위 재시도(`audit/ +type-recursive-issue-try-callback/`, spikes 35개) — **결론은 그대로 +"안 됨"**이지만 근본 원인이 재귀 특유가 아니라 "제네릭 콜백 인자엔 +컨텍스트 타입 전파 자체가 안 됨"이라는 더 일반적 한계임이 드러났고, +`/code-review high`가 지적한 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`) +후속 조사까지 포함해 near-miss 세 개(중간 변수 고정/monomorphize +헬퍼/명시 인스턴스화) 전부 §0 대전제(API를 타입 사정으로 비틀지 않음) +위반으로 기각. `typing-limits.md` §1에 각주로만 반영, 원칙 자체는 +안 바뀜. 이어진 `/code-review` 2회전이 이 작업과 직전 세션 산출물 +전반에서 정합성 문제(옛 솔버 캐비엇 누락, 체크리스트 항목 간 모순, +`type-recursion-issue/REPORT.md`의 실제 틀린 서술(`06`이 통과한다는 +잘못된 주장, 재귀가 아니라 "self가 제네릭인가"가 진짜 분기점이었음), +스파이크 개수 off-by-one 2건, 이 세션 자체의 히스토리 누락 등) 다수 +발견·수정. diff --git a/ROADMAP.md b/ROADMAP.md index 7c539bc..ec3947e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -245,12 +245,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에 걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은 `bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관 -- [ ] `store.key` dot-access 타입 추론 확인 — Luau `type function` +- [x] `store.key` dot-access 타입 추론 확인 — Luau `type function` (`WrapStore`/`ProcessStoreType`)으로 `Store`가 `T`의 각 필드를 `Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱 - 번째 세션, `base/typing-limits.md` "`store.key` 레코드 필드 - 타이핑" 절) — 실제 문법이 통과하는지는 - `luau-test`의 `16-type-store-key-typefunction.luau`로 실측 필요 + 번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]** + `luau-test/done/16-type-store-key-typefunction.luau` 통과(원인은 + 설계 문제가 아니라 `types.newfunction` API 버전 드리프트였음) - [ ] `:Compute(fn, ...)` — trailing args로 추가 의존성 직접 받는 sugar (2026-08-11 세션, `base/source-state-plan.md` "`:Compute(fn, ...)`" 절) — `:With(...):Compute(fn)` 체인과 달리 노드 1개(Compute 노드