docs: 콜백 파라미터 무주석 추론 전방위 재시도, code-review 2회전 정합성 수정
type-recursive-issue-try-callback/ 신설(spikes 35개) — type function/ 메타테이블/제네릭/이중 꺾쇠 명시 인스턴스화까지 전방위로 재시도했으나 quad의 state:Compute(fn) 단일 호출 모양을 유지한 채로는 여전히 안 됨, 근본 원인은 재귀 특유가 아니라 "제네릭 콜백 인자엔 컨텍스트 타입 전파가 안 됨"이라는 더 일반적 Luau 한계임을 확인. typing-limits.md §1/§7에 반영, 원칙 자체는 안 바뀜. 이어진 /code-review 2회전이 이 작업과 luau-test/16 복구 세션 전반에서 정합성 문제 다수 발견 — 스파이크 결과 표 stale(16번 실패로 잔존), 폴더 개수 off-by-one 여러 건, type-recursion-issue/REPORT.md의 실제 오류(self가 제네릭이면 자유 함수로 빼도 여전히 실패한다는 사실이 "통과"로 잘못 서술됨), 옛 솔버 캐비엇 누락, 체크리스트 항목 간 모순, 이 세션 자체의 히스토리 누락 등 — 전부 수정. doc-check.py ERROR 0 유지. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019HvEKn9f67kkx2nGLG8PsP
This commit is contained in:
parent
eae94d0574
commit
6bfd93fd62
48 changed files with 1409 additions and 37 deletions
|
|
@ -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<T>` 레코드 필드 합성) 복구도 이 조사 중 완료 — 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<T>` 레코드 필드 합성) 복구도 이 조사 중 완료 — 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<<T,U>>(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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 |
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0)
|
|||
| `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef<T>`가 `Ref<T>` 자리에 대입 가능 — 진단 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/`으로 이동, 위 표에 반영.
|
||||
|
||||
## 결론
|
||||
|
||||
|
|
|
|||
|
|
@ -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<A>`처럼 **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`)
|
||||
|
||||
|
|
|
|||
474
.claude/audit/type-recursive-issue-try-callback/REPORT.md
Normal file
474
.claude/audit/type-recursive-issue-try-callback/REPORT.md
Normal file
|
|
@ -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<<T,U>>(...)`, `base/attribute-plan.md`의
|
||||
`AttributeKey<<T>>`, `type-recursion-issue/spikes/38/39/41`의
|
||||
`t:Compute<<StateB<boolean>>>(...)`에서 이미 쓰임)을 단 하나도 시도하지
|
||||
않았다는 결정적 미검증 각도를 지적함 — 20번부터 이어서 조사, 결과는
|
||||
아래 11번 절.
|
||||
|
||||
**실측 환경**: Luau `0.733`(`mise ls luau`), `luau-analyze`(새 솔버
|
||||
기본값)와 `luau-analyze --solver=old` 교차검증.
|
||||
|
||||
## TL;DR
|
||||
|
||||
**"콜백 파라미터를 완전히 무주석으로 자동 추론시키는 방법"은 여전히
|
||||
없습니다.** 다만 그 과정에서 원래 문제의 성격 자체가 처음 생각했던
|
||||
것과 다르다는 게 드러났고, **작동은 하지만 quad의 API 모양을 바꿔야만
|
||||
하는 메커니즘 하나**를 새로 찾았습니다(0번 대전제로 기각).
|
||||
|
||||
| 질문 | 답 |
|
||||
|---|---|
|
||||
| 콜백 파라미터 무주석 추론이 되는 **새로운** 방법을 찾았는가? | **아니오(암묵 호출 기준) — 단, [2026-08-15 후속] 명시 이중 꺾쇠 인스턴스화(`state:Compute<<T,U>>(fn)`)는 예외.** 여기 정리된 최초 20개 formulation(type function, 메타테이블, 오버로드, 제네릭 디폴트, 테이블 리터럴, 캐스팅)은 전부 암묵 호출만 시도했고 전부 실패. `<<T,U>>`는 후속 조사(11번 절)에서 leaf 호출에 한해 성립·sound함이 확인됐지만 채택은 안 함 — 아래 후속 행 참고. |
|
||||
| 이 문제가 quad의 **자기 참조(재귀) 타입 특유의** 문제인가? | **아니오 — 근본 원인 재정정.** 재귀 없는 가장 평범한 제네릭 함수(`Map<T,U>(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<T>(self: State<T>): (<U>(...)->...)`)를 거치면 **T별로 손으로 다시 쓸 필요 없이** 자동으로 그 특수화가 일어남(18번) — 이게 이번 실험의 가장 흥미로운 발견. |
|
||||
| 왜 채택 안 하는가? | 둘 다 `state:Compute(fn)` **단일 콜론 호출**을 `무언가(state)를 먼저 호출해 특수화된 함수를 얻고, 그걸 다시 호출하는 2단계 체인`으로 바꿔야만 작동함 — quad의 자연스러운 API 모양(단일 메소드 호출)을 콜백 추론을 위해 비트는 것이라 `typing-limits.md` §0 대전제 위반. |
|
||||
| `type function`으로 뚫는 방법은? | **막다른 길, 기존 리서치와 같은 결론으로 재확인.** `Compute` 필드 하나만 type function으로 조립해도(12번), type function은 콘크리트 인스턴스화 시점에만 lazy 평가되고(확인됨) 그 안에서 "아직 안 정해진 `Box<U>`"를 참조할 API가 `types` 라이브러리에 없음 — `type-recursive-issue-with-typeof/REPORT.md` 6-2절이 이미 확정한 것과 정확히 같은 벽. |
|
||||
| 메타테이블 변형은? | 새로 시도 안 함(직전 실험이 이미 솔버 버그로 막다른 길임을 확정해뒀고, 이번 실험이 밝힌 "근본 원인은 재귀 무관"이라는 사실 자체가 메타테이블 경로를 더 팔 이유를 없앰). |
|
||||
| **[후속, 2026-08-15]** 이중 꺾쇠 명시적 인스턴스화(`Compute<<T,U>>(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<T, U>(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<T>`가
|
||||
자기 자신을 언급)는 이 위에 **추가로** 겹쳐 문제를 더 나쁘게 만드는
|
||||
요인(구조적 duck-typing, 아래 6/7번)일 뿐, 근본 원인이 아닙니다.
|
||||
|
||||
## 2. 닫힌 문(구문 자체가 없거나, 전파 경로 자체가 없음)
|
||||
|
||||
- **`spikes/02-no-explicit-generic-call-syntax.luau`** — TS의
|
||||
`f<number>(x)`류로 호출부에서 제네릭 인자를 못박는 문법이 Luau에
|
||||
있는지 확인 — **없음**. `Map::<number,string>(...)`도
|
||||
`Map<number,string>(...)`도 전부 `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의
|
||||
`<T = number>`류 제네릭 기본값 문법 존재 여부 확인 — **`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<T,U>`가 제네릭) — 다시 `a: unknown`으로 샙니다. **결론: 테이블
|
||||
리터럴 자체가 특별한 게 아니라, 04번이 통과한 건 그 테이블의 타입이
|
||||
호출 이전에 이미 명시 주석으로 콘크리트화돼 있었기 때문**입니다(2번
|
||||
결론과 정확히 같은 근본 원인의 재확인).
|
||||
|
||||
## 4. 자기 참조가 얹히면 실패 양상이 "무주석 unknown"에서 "구조적 duck-typing 오염"으로 바뀜
|
||||
|
||||
- **`spikes/06-selfref-minimal-duckshape-fails.luau`** — 자기 참조하는
|
||||
최소형(`Box<T> = {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<T>` 모양(인라인 재귀 선언, `typeof` 트릭 없이)으로 같은
|
||||
실험을 하면 한 단계 더 나쁩니다 — `a`가 `{Get:(<Cycle>)->(unknown)}`류로
|
||||
추측되는데, 이 추측된 타입이 원래 `State<T>`와 구조적으로 안 맞아서
|
||||
(`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>`를, **T만 명시 타입 주석으로
|
||||
콘크리트화하고(U는 여전히 제네릭인) 중간 변수**에 담으면:
|
||||
|
||||
```lua
|
||||
local computeForNumber: <U>(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<T>`**(Compute 필드가 `Box<T>`/`Box<U>`를 직접 언급하는 진짜 재귀형)에
|
||||
똑같이 적용해도 **여전히 정확합니다** — `a: Box<number>`로 잡히고, 음성
|
||||
대조군(`wrong: number = r.value`, 실제론 `string`)도 정확히 에러납니다.
|
||||
|
||||
**왜 되는가**: 2절/3절의 결론과 일관됩니다. 문제는 "제네릭이 함수 호출
|
||||
시점에 함께 풀려야 하는 경우" 컨텍스트 전파가 끊기는 것 — `T`를
|
||||
**호출과 별개의 이전 문장에서 명시 주석으로 이미 확정**해버리면, 그
|
||||
다음 호출에서는 풀어야 할 제네릭이 `U`(콜백의 반환 타입으로부터
|
||||
추론되는, 파라미터 타입에 영향 안 주는 방향의 제네릭) 하나뿐이라
|
||||
컨텍스트 전파가 정상 작동합니다.
|
||||
|
||||
## 6. 하지만 자동화가 안 됨 — 필드 추출만으론 같은 이득을 못 받음
|
||||
|
||||
`spikes/10-field-extract-noannot-fails.luau` — 5절의 성공이 "그냥 필드를
|
||||
미리 꺼내놓으면 되는구나"로 오해되지 않도록: `self1.Compute`를 **재주석
|
||||
없이** 그대로 변수에 담아보면, 그 변수 **자기 자신의 추론된 타입부터**
|
||||
`Unifiable<Error>`로 새어 있습니다(기존 0-Y와 동일 증상) — 5절의 성공은
|
||||
`typeof`가 그런 것처럼 "자동으로 얻어지는" 게 아니라, **사용자가 T가
|
||||
확정된 전체 함수 시그니처를 손으로 다시 써야만** 일어납니다. 즉 이
|
||||
기법은 콜백 파라미터의 명시 주석 부담을 없애는 대신 **더 큰 시그니처
|
||||
전체를 다른 곳에서 명시하는 부담으로 옮길 뿐**입니다 — 원래
|
||||
`function(a: State<number>)` 한 조각 쓰던 걸, 훨씬 긴 함수 타입 전체
|
||||
(`<U>(self: State<number>, func: (State<number>) -> U) -> State<U>`)로
|
||||
바꿔 쓰는 셈이라 순수하게 손해입니다.
|
||||
|
||||
## 7. `typeof` 대신 평범한 타입 별칭으로 같은 간접참조를 하면 — 아예 거부됨
|
||||
|
||||
`spikes/11-alias-indirection-fails.luau` — 5절의 "중간 변수"를 필드
|
||||
자체에 박아 넣어 자동화해보려고, `Compute` 필드 타입을 별도의 제네릭
|
||||
별칭(`type FixedComputeOf<T> = <U>(self: Box<T>, ...) -> Box<U>`)으로
|
||||
간접 참조하게 하면 **`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<T>`를
|
||||
쓰지 않으면 안 불림, `Box<number>`를 실제로 쓰면 그 순간 불림 — 확인됨).
|
||||
하지만 그 안에서 "아직 확정 안 된 `U`에 대한 `Box<U>`"(콜백의 반환
|
||||
타입으로 만들 리턴 타입)를 표현하려면 `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<T>`와
|
||||
`Box<number>`를 같은 재귀 별칭 안에서 같이 쓴 것 자체가 `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<T>(self: State<T>)
|
||||
: (<U>(self: State<T>, func: (State<T>) -> U) -> State<U>)
|
||||
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)` 호출 한 번만으로
|
||||
자동으로 `<U>(self:State<number>, func:(State<number>)->U)->State<U>`로
|
||||
잡히고 — **사용자가 T별로 손으로 시그니처를 다시 쓸 필요가 없습니다.**
|
||||
이어지는 `computeFor(test, function(a) ...)` 호출에서 `a`는 정확히
|
||||
`State<number>`로 추론되고, 음성 대조군도 정확히 에러납니다.
|
||||
`spikes/17-WINNER-realistic-quad-contract-nested.luau`로 quad의 진짜
|
||||
계약(콜백이 self 핸들을 받고, 콜백 안에서 그 self를 또 `:Compute`하는
|
||||
중첩)까지 이 "T 고정" 기법 자체를 확인 — 중첩 내부의 `a1`까지 정확히
|
||||
`State<number>`로 추론되고, 없는 메소드 호출(`a:NoSuchMethod()`)은
|
||||
"Key 'NoSuchMethod' not found in table 'State<number>'"로 **진짜 타입
|
||||
기반** 에러가 남(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. `<<T,U>>` 명시적 이중 꺾쇠 인스턴스화 — 후속 조사(2026-08-15)
|
||||
|
||||
**출발점**: `/code-review high`가 이 문서(당시 `00`~`19`)를 리뷰하며,
|
||||
20개 formulation이 전부 **암묵적 추론 호출**(`Map(arr, fn)`)만
|
||||
시도했고, 코퍼스에 이미 쓰이는 **이중 꺾쇠 명시적 인스턴스화**
|
||||
(`Foo<<T,U>>(...)`, `base/attribute-plan.md`의 `AttributeKey<<T>>`,
|
||||
`type-recursion-issue/spikes/38/39/41`의 `t:Compute<<StateB<boolean>>>(...)`)를
|
||||
단 하나도 안 해봤다고 지적. 스파이크 `20`~`33`으로 이 각도를 전방위로
|
||||
검증.
|
||||
|
||||
### 11-1. 비-재귀 제네릭에서는 완전히 깨끗함 (양성 대조군)
|
||||
|
||||
`spikes/20` — 재귀와 무관한 평범한 `Map<T,U>(arr, fn)`에
|
||||
`Map<<number, string>>(arr, function(a) ... end)`처럼 두 타입 인자를
|
||||
전부 명시하면, `a`가 무주석인데도 정확히 `number`로 추론되고 음성
|
||||
대조군(`wrong: number = result[1]`, 실제론 `{string}`)도 정확히
|
||||
에러납니다. §1의 근본 원인(1절, "제네릭 함수 호출 인자로 넘긴 함수
|
||||
리터럴엔 컨텍스트 타입이 전파 안 됨")이 정확히 **명시 인스턴스화로
|
||||
우회 가능한 문제**임을 확인 — 타입 인자를 전부 못박아버리면 그 "제네릭
|
||||
호출"이 사실상의 콘크리트 호출이 되어 정상적으로 컨텍스트가
|
||||
전파됩니다.
|
||||
|
||||
### 11-2. 부분 인스턴스화 — 이름이 아니라 선언 순서로 바인딩되고, 재귀형에서는 아예 깨짐
|
||||
|
||||
`spikes/21`(quad의 실제 `Box<T>` 자기참조형에 `<<string>>` 하나만 줘서
|
||||
"U=string, T는 self에서 추론"을 의도) — **`<<X>>`는 이름이 아니라
|
||||
선언 순서의 첫 번째 제네릭(`T`)에 바인딩됩니다.** 즉 `<<string>>`은
|
||||
"U=string"이 아니라 "T=string"이 되어 실제 `self`(`Box<number>`)와
|
||||
충돌하고, 그 충돌이 `"Internal error: outstanding free or blocked
|
||||
type in function call"`이라는 이해하기 힘든 내부 에러로 새어나옵니다.
|
||||
|
||||
이게 단순히 "잘못된 슬롯을 줘서" 그런 건지 확인하려고 `spikes/21b`로
|
||||
**올바른 T만** 주고(`<<number>>`, self와 일치) U를 추론에 맡겨봤지만
|
||||
**여전히 같은 Internal error**가 나고, 심지어 **양성 대조군까지
|
||||
깨집니다**(`ok: string = r1:Get()`가 `U`가 `string`으로 완전히
|
||||
해소되지 않고 `(string <: 'a <: never)`라는 미해소 bounded 타입으로
|
||||
남아 실패). **결론: 이 재귀형에서 부분 인스턴스화는 어느 방향으로
|
||||
줘도 깨집니다** — 전부 주거나(11-1/11-3처럼) 전부 안 주거나
|
||||
(기존 §1①/③) 둘 중 하나만 유효한 선택지이고, "T는 생략하고 U만
|
||||
준다"는 경로 자체가 없습니다. 별도 파일로 남기진 않았지만 조사 중
|
||||
비-재귀 `Map`으로도 같은 두 변형을 즉석에서 확인했는데, `<<string>>`이
|
||||
재귀형과 마찬가지로 항상 첫 번째 선언 순서(`T`)에 바인딩되는 것,
|
||||
반대로 올바른 `T`만 주고 `U`를 비워두면 `U`가 완전히 해소되지 않는
|
||||
동일한 미해소 bounded 타입 증상을 보이는 것 둘 다 재현됐습니다
|
||||
(비-재귀에선 self 충돌이 없어 Internal error까지는 안 가지만,
|
||||
다운스트림 타입이 진짜 콘크리트가 아니라는 결함은 재귀형과 같습니다).
|
||||
재귀 유무와 무관하게 Luau의 명시 인스턴스화는 "선언 순서대로, 전부
|
||||
아니면 없음"에 가깝습니다.
|
||||
|
||||
### 11-3. 스퓨리어스(spurious) 진단의 정체 — `read`/`write` 프로퍼티 modifier 불일치, `08-metatable-BUG`와는 다른 문제
|
||||
|
||||
`spikes/22` — T, U를 **둘 다** 명시(`n:Compute<<number, string>>(fn)`)하면
|
||||
`a0`가 무주석인데도 `--annotate`로 정확히 `{Get:(<Cycle>)->(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<number>`)
|
||||
쪽의 `Get`은 (기본값이라) **read-write**로 선언돼 있어 둘이 구조적으로
|
||||
안 맞습니다. `spikes/23`(필드 선언 순서 스왑)은 효과 없음, `spikes/24`
|
||||
(②쪼개기 패턴만 적용, `read` 없이)도 스퓨리어스 진단을 못 없앱니다
|
||||
(단 `<Cycle>`이 사라져 메시지가 조금 더 읽기 쉬워질 뿐 — 원인은
|
||||
그대로).
|
||||
|
||||
`spikes/25`(**WINNER**) — ②쪼개기 + `Get` 필드에 명시적 `read`
|
||||
modifier + 전체 명시 인스턴스화, 셋을 **모두** 적용하면 스퓨리어스
|
||||
진단이 완전히 사라집니다. `spikes/26`으로 확인한 바 `read` modifier
|
||||
**단독**(쪼개기 없이)으로는 안 됨 — 쪼개기 없이는 재귀형 `Box<T>`
|
||||
전체(`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<number>`처럼 타입 하나만 적어주면 되는" 부담보다
|
||||
객관적으로 더 큽니다(적어야 하는 타입 개수도 늘고, 적어야 하는
|
||||
시점도 더 이릅니다).
|
||||
2. **더 어려운 자리(`:Apply` 중첩)는 여전히 못 풉니다**(11-5,
|
||||
`spikes/32`). 이 기법을 채택해도 quad는 "leaf 호출은 `<<T,U>>` 필수
|
||||
+ 파라미터 무주석, 중첩 호출은 파라미터 주석 필수"라는 **두 개의
|
||||
서로 다른 규칙**을 사용자에게 동시에 요구하게 되어, 지금의 "콜백
|
||||
파라미터엔 항상 타입 주석을 단다"는 단일하고 일관된 규칙보다
|
||||
오히려 사용자 부담과 실수 표면이 늘어납니다.
|
||||
3. **`read` modifier 소급 적용 비용.** 11-3의 수정이 성립하려면 quad
|
||||
전역의 자기참조 타입(`State`/`Store`/`Modifier`/`Tag`/`Ref`/`Slot`/
|
||||
`Attribute` 등)에서 self를 받아 **호출만 되고 대입되지 않는** 모든
|
||||
접근자 필드를 `read`로 명시해야 합니다 — 새 API를 설계할 때마다
|
||||
챙겨야 하는 새 관례가 하나 늘어나는 것이고, 지금까지 base
|
||||
pseudocode 어디에도 `read`/`write` modifier를 쓴 전례가 없습니다.
|
||||
4. **`Foo<<T,U>>(...)` 호출부 문법 자체가 지금 quad API 표면 어디에도
|
||||
없는 새로운 사용자 대면 관용구입니다.** `AttributeKey<<T>>`처럼
|
||||
**선언**에서 제네릭을 명시하는 건 이미 있지만, **매 메소드 호출부**에서
|
||||
명시 인스턴스화를 강제하는 패턴은 전례가 없어 `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/설계 게이트에 영향 없음(사용자가 요청
|
||||
시점에 이미 명시).
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
--!strict
|
||||
-- 순수 sanity check: 재귀 없는 평범한 제네릭 함수도 콜백 파라미터 무주석 추론이 되는가?
|
||||
local function Map<T, U>(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]
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
--!strict
|
||||
-- Luau에 함수 호출부에서 제네릭 타입 인자를 명시하는 문법이 있는지 확인
|
||||
-- (TS의 `f<number>(x)`류 -- 있다면 T를 호출부에서 못박아 콜백 파라미터를
|
||||
-- 추론시킬 실마리가 될 수 있었음).
|
||||
--
|
||||
-- 결론: 둘 다 SyntaxError. Luau는 함수 호출에 대한 명시적 제네릭 인자
|
||||
-- 문법 자체가 없음(타입 별칭 인스턴스화 `Array<number>`와는 다른 문법).
|
||||
local function Map<T, U>(arr: {T}, fn: (T) -> U): {U}
|
||||
return {} :: any
|
||||
end
|
||||
local arr: {number} = {1, 2, 3}
|
||||
|
||||
-- 시도 1: `::<...>` 형태 -- SyntaxError
|
||||
local result1 = Map::<number, string>(arr, function(a) return tostring(a) end)
|
||||
|
||||
-- 시도 2: `<...>` 바로 붙이는 형태(제네릭 함수 호출 관용구가 있다면 이 모양일 것) -- SyntaxError
|
||||
local result2 = Map<number, string>(arr, function(a) return tostring(a) end)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
--!strict
|
||||
local function Map<T, U>(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]
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
--!strict
|
||||
-- 테이블 리터럴 컨텍스트에서는 무주석 함수 파라미터가 추론되는가? (call-arg와 다른 경로인지 확인)
|
||||
type Handlers = { fn: (number) -> string }
|
||||
local t: Handlers = { fn = function(a) return tostring(a) end }
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
--!strict
|
||||
-- 제네릭 함수 호출의 "인자"로 테이블 리터럴을 넘기면(그 안에 콜백을 담아서) 컨텍스트 추론이 살아나는가?
|
||||
local function Foo<T, U>(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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
--!strict
|
||||
type Box<T> = { value: T }
|
||||
local function Compute<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return { value = fn(self) } :: any
|
||||
end
|
||||
type BoxM<T> = Box<T> & { Compute: typeof(Compute) }
|
||||
local b: BoxM<number> = (nil :: any) :: BoxM<number>
|
||||
local r = b:Compute(function(a) return tostring(a.value) end)
|
||||
local ok: string = r.value
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
--!strict
|
||||
-- 인라인 선언(구 leaky 방식)에서는 콜백 파라미터가 무주석으로도 추론되는가? (반환타입 leak과 별개 질문)
|
||||
type State<T> = {
|
||||
Get: (self: State<T>) -> T,
|
||||
Compute: <U>(self: State<T>, fn: (State<T>) -> U) -> State<U>,
|
||||
}
|
||||
local test: State<number> = (nil :: any) :: State<number>
|
||||
local r = test:Compute(function(a) return tostring(a:Get()) end)
|
||||
local ok: string = r:Get()
|
||||
local wrong: number = r:Get()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
--!strict
|
||||
-- T만 먼저 명시 주석으로 고정한 중간 변수를 거치면, U는 여전히 제네릭이어도 콜백 파라미터가 추론되는가?
|
||||
local function Compute<T, U>(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: <U>(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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
--!strict
|
||||
-- 2x2 매트릭스의 D칸: 재귀형 + T를 중간변수로 고정 + U만 제네릭
|
||||
type Box<T> = {
|
||||
value: T,
|
||||
Compute: <U>(self: Box<T>, fn: (Box<T>) -> U) -> Box<U>,
|
||||
}
|
||||
local function ComputeImpl<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return { value = fn(self) } :: any
|
||||
end
|
||||
|
||||
local self1: Box<number> = (nil :: any) :: Box<number>
|
||||
|
||||
-- T=number로 고정한 중간 변수 (U만 여전히 제네릭), self1.Compute가 아니라 자유함수를 통해
|
||||
local computeForNumber: <U>(self: Box<number>, fn: (Box<number>) -> U) -> Box<U> = ComputeImpl
|
||||
local r = computeForNumber(self1, function(a) return tostring(a.value) end)
|
||||
local ok: string = r.value
|
||||
local wrong: number = r.value
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
--!strict
|
||||
-- 필드 추출을 손으로 재주석하지 않고 그냥 뽑아서 두 단계로 호출하면?
|
||||
type Box<T> = {
|
||||
value: T,
|
||||
Compute: <U>(self: Box<T>, fn: (Box<T>) -> U) -> Box<U>,
|
||||
}
|
||||
local self1: Box<number> = (nil :: any) :: Box<number>
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
--!strict
|
||||
-- FixedCompute<T> 별칭으로 필드를 감싸면 콜론 호출에서 파라미터가 자동으로 풀리는가?
|
||||
type FixedComputeOf<T> = <U>(self: Box<T>, fn: (Box<T>) -> U) -> Box<U>
|
||||
type Box<T> = {
|
||||
value: T,
|
||||
Compute: FixedComputeOf<T>,
|
||||
}
|
||||
local self1: Box<number> = (nil :: any) :: Box<number>
|
||||
local r = self1:Compute(function(a) return tostring(a.value) end)
|
||||
local ok: string = r.value
|
||||
local wrong: number = r.value
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
--!strict
|
||||
type function MakeComputeField(T)
|
||||
error("marker: reached field builder")
|
||||
end
|
||||
type Box<T> = {
|
||||
value: T,
|
||||
Compute: MakeComputeField<T>,
|
||||
}
|
||||
local b: Box<number> = (nil :: any) :: Box<number>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
--!strict
|
||||
local function Foo<T = number>(x: T): T
|
||||
return x
|
||||
end
|
||||
local a = Foo(1)
|
||||
local b: string = Foo("hi")
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
--!strict
|
||||
type T = {
|
||||
read Get: (T) -> number,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
--!strict
|
||||
-- 재귀 아닌 타입에서 오버로드(교차타입)로 콘크리트 분기를 하나 추가하면 그 분기 매칭 시 추론이 되는가?
|
||||
local ComputeGeneric: (<U>(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)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
--!strict
|
||||
type Box<T> = {
|
||||
value: T,
|
||||
Compute: (<U>(self: Box<T>, fn: (Box<T>) -> U) -> Box<U>)
|
||||
& ((self: Box<number>, fn: (Box<number>) -> string) -> Box<string>),
|
||||
}
|
||||
local self1: Box<number> = (nil :: any) :: Box<number>
|
||||
local r = self1:Compute(function(a) return tostring(a.value) end)
|
||||
local ok: string = r.value
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
--!strict
|
||||
-- quad 실제 State/Compute 모양(typeof 패턴) + T 고정 중간변수 트릭을 실제 계약(중첩 self 호출 포함)으로 검증
|
||||
local function Get<T>(self: State<T>): T
|
||||
return nil :: any
|
||||
end
|
||||
local function Compute<T, U>(self: State<T>, func: (State<T>) -> U): State<U>
|
||||
return nil :: any
|
||||
end
|
||||
type State<T> = {
|
||||
Get: typeof(Get),
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local test: State<number> = (nil :: any) :: State<number>
|
||||
|
||||
-- T=number로 고정한 중간 변수 (콜백 파라미터 무주석 시도)
|
||||
local computeForNumber: <U>(self: State<number>, func: (State<number>) -> U) -> State<U> = 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: <U2>(self: State<number>, func: (State<number>) -> U2) -> State<U2> = 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)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
--!strict
|
||||
-- 재사용 가능한 "monomorphize 헬퍼" 함수 하나로 T별 수동 어노테이션을 없앨 수 있는가?
|
||||
local function Get<T>(self: State<T>): T
|
||||
return nil :: any
|
||||
end
|
||||
local function Compute<T, U>(self: State<T>, func: (State<T>) -> U): State<U>
|
||||
return nil :: any
|
||||
end
|
||||
type State<T> = {
|
||||
Get: typeof(Get),
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
-- 한 번만 작성되는 재사용 가능한 헬퍼 -- T는 이 함수 자신의 제네릭(호출 시점에 자동 추론)
|
||||
local function MonomorphicCompute<T>(self: State<T>): (<U>(self: State<T>, func: (State<T>) -> U) -> State<U>)
|
||||
return (self :: any).Compute
|
||||
end
|
||||
|
||||
local test: State<number> = (nil :: any) :: State<number>
|
||||
|
||||
-- 사용자 쪽 코드 -- 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()
|
||||
|
|
@ -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<T>(self: State<T>): T
|
||||
return nil :: any
|
||||
end
|
||||
local function Compute<T, U>(self: State<T>, func: (State<T>) -> U): State<U>
|
||||
return nil :: any
|
||||
end
|
||||
type State<T> = {
|
||||
Get: typeof(Get),
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local test: State<number> = (nil :: any) :: State<number>
|
||||
local computeForNumber: <U>(self: State<number>, func: (State<number>) -> U) -> State<U> = Compute
|
||||
local d1 = computeForNumber(test, function(a) return a:Get() > 0 end)
|
||||
local ok1: boolean = d1:Get()
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
--!strict
|
||||
-- Baseline / positive control: explicit double-angle instantiation
|
||||
-- (Foo<<T,U>>(...)) 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<T, U>(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<<number, string>>(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})
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
--!strict
|
||||
-- Does `Foo<<X>>(...)` 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<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = {
|
||||
Get: (self: Box<T>) -> T,
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
-- intent: "U=string, T inferred as number from self"
|
||||
local r1 = n:Compute<<string>>(function(a0)
|
||||
local check: number = a0:Get()
|
||||
return tostring(a0:Get())
|
||||
end)
|
||||
local wrong1: number = r1:Get()
|
||||
-- RESULT: `<<string>>` 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".
|
||||
|
|
@ -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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r1 = n:Compute<<number>>(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.
|
||||
|
|
@ -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<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = {
|
||||
Get: (self: Box<T>) -> T,
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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<number>` 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<number>, and that
|
||||
-- inferred read-only shape doesn't structurally unify with Box<T>'s
|
||||
-- (implicitly read-write) `Get` field. See 23-26 for the fix.
|
||||
|
|
@ -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<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = {
|
||||
Compute: typeof(Compute), -- swapped: Compute before Get
|
||||
Get: (self: Box<T>) -> T,
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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.
|
||||
|
|
@ -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<T> = {
|
||||
Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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<number>) -> string` instead of a `<Cycle>`-laden mess). The
|
||||
-- underlying read-only vs read-write mismatch on `Get` is unchanged.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
--!strict
|
||||
-- WINNER: split (②) + explicit `read` property modifier on Get + full
|
||||
-- explicit <<T,U>> instantiation. All three together remove the spurious
|
||||
-- diagnostic from 22/23/24 entirely.
|
||||
export type BoxData<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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<number>`-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.
|
||||
|
|
@ -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<T, U>(self: Box<T>, fn: (Box<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = {
|
||||
read Get: (self: Box<T>) -> T,
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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
|
||||
-- <Cycle>-laden mismatch, because now the comparison is between two full
|
||||
-- recursive Box<T> shapes (which structurally require BOTH Get and
|
||||
-- Compute) instead of the split BoxData<T> (which only requires Get). The
|
||||
-- split (②) is load-bearing for the 25 fix, not optional.
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
--!strict
|
||||
-- CAUTION / FALSE POSITIVE: same split+read fix as 25, but WITHOUT explicit
|
||||
-- <<T,U>> 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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = 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".
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
--!strict
|
||||
-- Negative controls for 27: is a0's inferred type ACTUALLY the nominal
|
||||
-- BoxData<number>, 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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = 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<number>.
|
||||
-- * `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.
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
--!strict
|
||||
-- Same negative controls as 28, but WITH explicit <<T,U>> instantiation
|
||||
-- (the 25 formulation). Does explicit instantiation make the check sound
|
||||
-- (vs 28's duck-typing trap)?
|
||||
export type BoxData<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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<number>) -> 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.
|
||||
|
|
@ -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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(function(a0)
|
||||
a0:NoSuchMethod()
|
||||
return tostring(a0:Get())
|
||||
end)
|
||||
local wrong2: number = r2:Get()
|
||||
-- RESULT: caught. Call-site TypeError (shape mismatch: real BoxData<number>
|
||||
-- has no NoSuchMethod field). Confirms 29 wasn't a fluke of having two
|
||||
-- simultaneous mistakes.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
--!strict
|
||||
-- Chained calls with explicit <<T,U>> instantiation, type changing at each
|
||||
-- step: number -> boolean -> string -> number. Uses the 25 (WINNER)
|
||||
-- formulation throughout.
|
||||
export type BoxData<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r1 = n:Compute<<number, boolean>>(function(a0)
|
||||
local x: number = a0:Get()
|
||||
return x > 0
|
||||
end)
|
||||
local r2 = r1:Compute<<boolean, string>>(function(a1)
|
||||
local y: boolean = a1:Get()
|
||||
return tostring(y)
|
||||
end)
|
||||
local r3 = r2:Compute<<string, number>>(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<boolean>)
|
||||
local wrong2: number = r2:Get() -- should error (r2 is Box<string>)
|
||||
local wrong3: string = r3:Get() -- should error (r3 is Box<number>)
|
||||
-- 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.
|
||||
|
|
@ -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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local outer = n:Compute<<number, string>>(function(a0)
|
||||
-- nested call, ALSO explicitly instantiated
|
||||
local inner = a0:Compute<<number, boolean>>(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<number>, 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<T>)` parameter annotation regardless of this
|
||||
-- investigation's findings.
|
||||
|
|
@ -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<T> = {
|
||||
read Get: (self: BoxData<T>) -> T,
|
||||
}
|
||||
local function Compute<T, U>(self: BoxData<T>, fn: (BoxData<T>) -> U): Box<U>
|
||||
return nil :: any
|
||||
end
|
||||
export type Box<T> = BoxData<T> & {
|
||||
Compute: typeof(Compute),
|
||||
}
|
||||
|
||||
local n: Box<number> = nil :: any
|
||||
|
||||
local r2 = n:Compute<<number, string>>(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.
|
||||
|
|
@ -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>): 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 핸들을 무주석으로 받으면 — 정상/오용 안 가리고 모든 호출이 깨짐
|
||||
|
||||
|
|
|
|||
|
|
@ -180,11 +180,47 @@ type State<T> = {
|
|||
- **콜백 파라미터는 여전히 명시 주석 필요**(`function(a0: State<T>)`) —
|
||||
②의 "쪼개기"가 다루던 문제는 별개이고, `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<T,U>(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<<T,U>>(fn)`, 코퍼스에
|
||||
이미 `AttributeKey<<T>>`류로 존재하는 문법)도 재검토 — **leaf(비-중첩)
|
||||
호출에 한해 콜백 파라미터 무주석 추론이 실제로 성립하고 sound함을
|
||||
확인**(②쪼개기 + `Get` 필드의 명시 `read` modifier + 매 호출 T·U 전부
|
||||
명시가 함께 필요, 셋 중 하나라도 빠지면 스퓨리어스 진단이나 duck-typing
|
||||
함정으로 되돌아감). 이 역시 **채택 안 함** — 매 호출마다 이미 self로부터
|
||||
결정되는 `T`까지 중복 명시해야 하고 `U`(콜백을 쓰기도 전에 그 반환
|
||||
타입)를 미리 선언해야 하는 부담이 지금의 "파라미터에 타입 하나만
|
||||
주석" 관례보다 크고, `:Apply` 중첩 자기호출(§1②가 이미 캐비엇 건 자리)엔
|
||||
여전히 안 통해 규칙이 하나로 안 줄고 오히려 둘로 늘어나며, 부분
|
||||
인스턴스화(`<<U만>>`)는 이름이 아니라 선언 순서로 바인딩돼 이 재귀형에서
|
||||
아예 내부 에러로 깨짐. 상세 근거·모든 formulation은 REPORT 11번 절.
|
||||
- **명시 LHS 오타입 그 줄 자체는 여전히 못 잡습니다**(`wrong: State<boolean>
|
||||
= 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<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
|
||||
메소드를 인라인 대신 이름 붙은 함수 + `typeof`로 선언(1번 ③)하고,
|
||||
그래도 명시 바인딩 관례는 그대로 문서에 같이 적을 것 — ③은 ①을
|
||||
대체하지 않음.
|
||||
대체하지 않음. **⚠️ ③은 반환 타입 안전성(LHS 무주석 다운스트림)만
|
||||
고쳐줍니다 — 콜백 파라미터 자동 추론이 같이 필요하면 이것만으론
|
||||
부족하니 바로 아래 2번도 같이 볼 것.**
|
||||
2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 콜백
|
||||
파라미터 자동 추론이 필요하면 1번의 "쪼개기"(`XxxData<T>` /
|
||||
`Xxx<T>` 분리)를, 반환 타입 안전성이 우선이면 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번, 메소드 반환
|
||||
|
|
|
|||
|
|
@ -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 판정 끝, 더 할 일 없음 | — |
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (7건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (6건)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
]]
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ typing-limits를 풀어보자, 오래 걸려도 되니까 정확하게 처리해
|
|||
파라미터까지 무주석 자동 추론**되는 걸 발견(raw 값 파라미터 한정).
|
||||
이걸 진짜 재귀 `Compute<U>: Box<U>`로 확장해보니 처음엔 완벽해
|
||||
보였음(체이닝 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`대 파일로 보존).
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T,U>(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<<T,U>>(...)`, `base/attribute-plan.md`의
|
||||
`AttributeKey<<T>>`, `type-recursion-issue/spikes/38/39/41`에 이미
|
||||
있음)를 단 하나도 시도하지 않았음. 메인 세션이 직접 빠르게 재현해보니
|
||||
quad 실제 self-핸들 계약에서 **부분 성공**(다운스트림 타입은 정확) +
|
||||
이전과 다른 spurious 진단(read-only/read-write 불일치)이 섞여 나옴 —
|
||||
새 각도라 판단해 후속 조사로 위임.
|
||||
|
||||
## 후속 조사 — `<<T,U>>` 명시적 인스턴스화 (에이전트 위임, 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<A>`처럼 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`.
|
||||
38
CLAUDE.md
38
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<<T,U>>(fn)`)
|
||||
후속 조사까지 포함해 near-miss 세 개(중간 변수 고정/monomorphize
|
||||
헬퍼/명시 인스턴스화) 전부 §0 대전제(API를 타입 사정으로 비틀지 않음)
|
||||
위반으로 기각. `typing-limits.md` §1에 각주로만 반영, 원칙 자체는
|
||||
안 바뀜. 이어진 `/code-review` 2회전이 이 작업과 직전 세션 산출물
|
||||
전반에서 정합성 문제(옛 솔버 캐비엇 누락, 체크리스트 항목 간 모순,
|
||||
`type-recursion-issue/REPORT.md`의 실제 틀린 서술(`06`이 통과한다는
|
||||
잘못된 주장, 재귀가 아니라 "self가 제네릭인가"가 진짜 분기점이었음),
|
||||
스파이크 개수 off-by-one 2건, 이 세션 자체의 히스토리 누락 등) 다수
|
||||
발견·수정.
|
||||
|
|
|
|||
|
|
@ -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>`가 `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 노드
|
||||
|
|
|
|||
Loading…
Reference in a new issue