docs(type): 0-Y 해소 — 재귀 제네릭 반환은 Luau 상위 한계로 확정, typing-limits.md 신설

44개 스파이크로 question.md 0-Y를 재실측한 결과, 여섯 번째 세션의
"콜백이 raw 값을 받으면 완전 클린" 판정이 틀렸음이 드러남 — 그건 진단
0건만 확인한 것이었고, luau-analyze --annotate로 열어보니 반환 타입이
Unifiable<Error>로 조용히 새고 있었음(틀린 대입도 안 잡힘).

진짜 원인은 콜백 계약이 아니라 Compute가 State<U>(자기 이름을 다른 타입
인자로 감싼 타입)를 반환한다는 것 자체 — RFC relax-recursive-type-restriction이
Promise<T>.andThen으로 예시 든 바로 그 패턴. 사용자 확정: quad가 타입을
비틀 일이 아니라 상위 Luau의 현 한계이고, RFC/이슈 수혜를 받을 때 해결될
일이라 당장 할 수 있는 바 없음.

- base/typing-limits.md 신설 — 흩어져 있던 타입 한계 5건 통합, 대전제
  "Luau 한계를 우회하려 타입/API를 비틀지 않는다", 새 API 설계 체크리스트
- audit/type-recursion-issue/ 신설 — REPORT.md + spikes 44개(audit 폴더에
  스크립트를 같이 둔 첫 예외, 판정 재현에 개별 실행이 필요해서)
- 0-Y 해소 전파: question.md(최우선 2건→1건) / archive / base 5개 /
  research 2개 / 인덱스 4개 / luau-test(08을 done/으로, review-required 비움)
- audit/luau-test-first-run-2026-08-13.md: 판정이 뒤집힌 당사자라 배너뿐
  아니라 본문 표·문단·결론까지 전수 수정
- HUMAN_TODO 6번 신설: luau-lsp 기본이 옛 솔버라 CLI와 진단이 다름

교훈: luau-analyze 진단 0건은 타입 해소를 뜻하지 않음 — 타입 스파이크는
--annotate로 실제 추론 타입을 확인하고 음성 대조군을 같이 둘 것.

doc-check.py ERROR 0 유지(WARN 59건, 변경 전과 동일).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ
This commit is contained in:
qwreey 2026-08-13 22:08:51 +09:00
parent ed99a16aaf
commit 93f548a2af
Signed by: qwreey
GPG key ID: D28DB79297A214BD
68 changed files with 2320 additions and 170 deletions

File diff suppressed because one or more lines are too long

View file

@ -12,8 +12,13 @@
찾는 법: 지금 유효한 설계는 항상 `base/`가 소스이고, 이 문서는 "그 결정이
왜/언제 그렇게 났는가"를 되짚을 때만 볼 것. 아래는 분리 직전의 전문
그대로이며, 열린 항목(0-Y/0-Z/0-A/0-B와 용어·낮은 우선순위의 미확정
건)은 `question.md`에 그대로 남아 있으므로 여기와 중복됨.
그대로이며, 당시 열려 있던 항목(0-Y/0-Z/0-A/0-B와 용어·낮은 우선순위의
미확정 건)은 `question.md`에도 있어 중복됨.
> **[2026-08-13 열세 번째 세션 갱신] `0-Y`는 그 뒤 해소됐음** — 해당
> 절 머리에 해소 배너를 달아뒀고, `question.md`에서는 제거됨. 지금
> 유효한 규약은 `base/typing-limits.md`. 나머지(0-Z/0-A/0-B 등)는
> 여전히 `question.md`가 소스.
---
@ -29,7 +34,28 @@
## 지금 열려있는 것 (우선순위순)
### 0-Y. ⭐ **최우선(신설) — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가** (2026-08-13 여섯 번째 세션, 첫 실측에서 발견)
### 0-Y. ~~⭐ 최우선~~ **[해소됨, 2026-08-13 열세 번째 세션]** — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가 (2026-08-13 여섯 번째 세션, 첫 실측에서 발견)
> **[해소됨] 결론: 계약은 그대로 유지, 이건 quad가 풀 문제가 아니라
> Luau의 현 한계.** 44개 스파이크로 재실측한 결과 0-Y가 사실 **독립된
> 두 문제**였음이 드러남 — (A) 콜백 파라미터 추론은 타입 선언을
> "데이터부/메소드부"로 쪼개면 **진짜로 풀림**(코드 생성 불필요),
> (B) `Compute``State<U>`를 반환하는 것 자체는 **어떤 방법으로도
> 안 풀림**(아래 선택지 1/2/3 전부 무효 — 특히 "raw 값이면 완전 클린"이라던
> 아래 표의 판정이 실측으로 **뒤집힘**, raw 값 계약도 똑같이 불안전함).
> (B)는 Luau RFC `relax-recursive-type-restriction``Promise<T>.andThen`으로
> 예시 든 바로 그 패턴이라, 지금 선언을 그대로 두면 Luau 쪽 수정만으로
> 코드 변경 없이 풀림. **당장의 대응은 "파생 State를 만드는 자리마다
> 결과 타입을 명시 주석으로 바인딩"** — 그 한 줄만 검증이 안 되고
> 다운스트림 전체는 정상 체크됨(실측 확인).
>
> - 지금 유효한 규약: **`base/typing-limits.md`**(신설)
> - 실측 근거 전문: `audit/type-recursion-issue/`(REPORT.md + spikes 44개)
> - 추적: [`luau-lang/luau#2380`](https://github.com/luau-lang/luau/issues/2380)
>
> 아래는 해소 전 원문(선택지 1/2/3 프레이밍과 "구울 때 인라이닝" 방향
> 포함) — **그 프레이밍 자체가 실측으로 범위를 벗어난 것으로 판명**됐으니
> 히스토리로만 볼 것.
**실측 결과**: 콜백이 lazy `State<T>` 핸들을 받는 quad의 커링 계약이
**Luau 양방향 타입 추론과 충돌**함. 가장 흔한 관용구가 그대로 실패:

View file

@ -1,5 +1,26 @@
# `.claude/luau-test/` 첫 실측 결과 (2026-08-13 여섯 번째 세션)
> **⚠️ [2026-08-13 열세 번째 세션 정정] 이 문서의 타입 관련 결론 하나가
> 뒤집혔습니다 — 아래 "⚠️ 실측으로 드러난 진짜 설계 이슈" 절의
> "콜백이 raw 값을 받으면 ✅ 완전 클린(0건)" 판정.**
>
> 그 판정은 **"진단이 0건이다"만 확인**한 것이었고, 반환 타입이 실제로
> 해소됐는지는 확인하지 않았습니다. 열세 번째 세션에 `luau-analyze
> --annotate`로 추론된 실제 타입을 열어보니 **raw 값 계약도 똑같이
> `Unifiable<Error>`로 새고 있었고**(틀린 타입에 대입해도 안 잡힘),
> 따라서 아래 표의 "raw 값 = 완전 클린"과 그에 근거한 "선택지 2로 가면
> 추론이 완벽해진다"는 서술은 **틀렸습니다.**
>
> 진짜 원인은 콜백 계약이 아니라 **`Compute``State<U>`(자기 이름을
> 다른 타입 인자로 감싼 타입)를 반환한다는 것 자체**(Luau의 현 한계)로
> 확정됐고, 콜백의 lazy 핸들 계약은 **그대로 유지**됩니다.
>
> - 지금 유효한 규약: **`base/typing-limits.md`**
> - 재실측 전문(스파이크 44개): **`audit/type-recursion-issue/`**
>
> 아래 런타임 스파이크 결과(12개 통과, `04`/`07`/`18` 절)는 **그대로
> 유효**합니다 — 정정 대상은 타입 절뿐입니다.
**배경**: 2026-08-09 열두 번째 세션에 스파이크를 만들기 시작한 이래
**처음으로 `luau`/`luau-analyze` 바이너리가 사용 가능해져 실제로 돌려본
결과**. 그동안 CLAUDE.md가 "M0 착수 전 남은 유일한 게이트"로 꼽아온 항목.
@ -102,12 +123,17 @@ relate4의 살아있는 엔트리 총 개수: 0 (기대 0)
| 위 + `Get``read`로 선언 | ❌ 여전 |
| 위 + `Get: (self: State<T>) -> T` 형태로 변경 | ❌ 여전 |
| 콜백 **파라미터에 타입 주석**을 달면 | ✅ 0건 |
| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | **0건** |
| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ~~✅ **0건**~~ **[13차 세션 정정] 진단만 0건이고 반환 타입은 똑같이 `Unifiable<Error>` — 안전하지 않음** |
즉 **원인은 "콜백이 lazy `State<T>` 핸들을 받는다"는 quad의 커링 계약
그 자체**임. `read`/`self` 표기 조정으로는 안 풀리고, raw 값을 넘기면
완벽히 추론됨.
> **[13차 세션 정정] 위 문단이 틀렸음.** 원인은 커링 계약이 아니라
> `Compute`**반환 타입**(`State<U>`)이고, raw 값을 넘겨도 "완벽히
> 추론"되지 않음(진단만 0건). 정확한 원인 분리와 근거는
> `audit/type-recursion-issue/`, 규약은 `base/typing-limits.md`.
```lua
-- 지금 확정된 관용구 — 타입 추론 실패
state:Compute(function(s) return s:Get() * 2 end)
@ -127,6 +153,13 @@ API 전부에 걸림. 2026-08-07 일곱 번째 세션의 커링 스타일 확정
`previous` 설계 전반과 충돌 — **구조 변경 규모가 큼**).
3. 혼합(무주석은 raw, 명시적으로 lazy가 필요할 때만 별도 API).
> **[13차 세션 정정 — 위 선택지 셋 다 폐기됨]** 재실측 결과 이 셋은
> 전부 잘못된 프레이밍이었음(2번은 전제 자체가 틀렸고, 1/3번도 반환
> 타입 문제를 못 고침). **최종 결론: 계약은 그대로 유지, 남은 건 Luau의
> 현 한계라 quad가 할 수 있는 게 없음.** 대응은 "파생 State를 만드는
> 자리마다 결과 타입을 명시 주석으로 바인딩"하는 관례 하나 —
> `base/typing-limits.md`.
## 그 외 — 스파이크 코드 결함이었던 것들 (전부 수정 완료)
- **`17` 크래시의 원인이 실은 문서 결함이었음** — `modifier-plan.md`
@ -165,6 +198,13 @@ GC-native 아키텍처의 핵심 전제(연쇄 GC)와 `Relate` 상호 순환 경
해당할 수 있는 유일한 항목이고, **선택지 2를 고르면 실제로 큰 작업**이므로
M0 착수 전에 결정해두는 게 맞음.
> **[13차 세션 정정]** 위 문단의 걱정은 **결과적으로 기우였음** — 재실측
> 결과 선택지 2(raw 값 전환)는 애초에 문제를 안 고치므로 "큰 작업"을
> 할 이유 자체가 없어졌음. 계약을 그대로 두는 게 정답이고, **구조
> 변경은 전혀 필요 없음**. 다만 진짜 원인(반환 타입)은 Luau가 고쳐줄
> 때까지 남으므로 명시 주석 바인딩 관례로 대응 —
> `base/typing-limits.md`.
`modifier-plan.md``__index` 저장 위치 모호성은 문서 결함이었고 이번에
수정 — 스파이크가 없었으면 M2/M6 구현 중에 터졌을 건이라, 실측 라운드
자체의 값어치를 보여준 사례.

View file

@ -0,0 +1,465 @@
# 0-Y 실측 — 재귀 제네릭 반환 타입은 Luau 상위 한계, quad가 지금 할 수 있는 것 없음
> **[2026-08-13 열세 번째 세션 확정] 이 실측으로 `question.md` 0-Y는
> 해소됐습니다.** 사용자 최종 정리:
>
> > "해당 제한이 풀리기 전까지는 명시적 타입바인딩으로 `:` 사용이
> > 강제되나, 그것이 들어오고 난 뒤에는 풀리게 된다. 지금으로써 quad
> > 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고,
> > 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈 해결의 수혜를 받게
> > 될 때 해결될 이슈로써, 당장 우리가 할 수 있는 바 없다."
>
> **지금 유효한 설계 규약은 `base/typing-limits.md`가 소스** — 이
> 문서는 그 결론에 이른 실측 근거(실험 44개 + RFC/소스 대조)를 원문
> 그대로 보존하는 audit 기록입니다. 규약만 필요하면 `typing-limits.md`
> 보고, "왜 그렇게 정해졌나"가 궁금할 때만 이 문서를 여세요.
**이 폴더의 구성**: 이 `REPORT.md`(실측 서술) + `spikes/`(재현 스크립트
44개). `.claude/audit/`의 다른 기록과 달리 스크립트를 같이 두는 이유는
이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을
직접 돌려봐야 판정이 재현되기 때문입니다(`luau-analyze spikes/<파일>`,
추론된 실제 타입까지 보려면 `luau-analyze --annotate spikes/<파일>`).
**⚠️ 이 리포트는 한 번 크게 정정됐습니다.** 초안은 "쪼개기(split-type)
트릭으로 문제가 완전히 풀렸다"고 결론 내렸는데, 사용자가 "`23` 아래에
체이닝을 더 넣어봤더니 `error type`이 나온다"고 지적한 걸 계기로
`--annotate`(실제 추론된 타입을 눈으로 확인하는 옵션)와 `luau-lsp`
교차검증했더니 **그 "성공"이 진짜 성공이 아니라 타입 체커가 조용히
포기한 것**이었음이 드러났습니다. 아래 내용은 그 재검증을 반영한
최종본입니다. 실험 파일 `00`~`25`는 초안 때 만든 것, `26`~`35`는 이
정정 과정에서, `36`~`43`은 사용자의 후속 질문(명시 제네릭 인자/미래
대비)에 답하며 추가한 것입니다.
**재현 방법 세 갈래** — 이 문서의 모든 판정은 셋 다로 교차확인했습니다:
1. `luau-analyze spikes/<파일>` — 진단만 봄
2. `luau-analyze --annotate spikes/<파일>` — **추론된 실제 타입을 소스에
찍어줌. 이 건에서 결정적이었던 도구** — 진단이 0건이어도 타입이
`Unifiable<Error>`인 경우를 잡아낸 게 이것뿐이었음
3. `luau-lsp analyze --flag:LuauSolverV2=true spikes/<파일>` — 에디터가
실제로 쓰는 언어서버로 교차검증(1.69.0으로 확인)
**실측 환경(2026-08-13)**: `luau`/`luau-analyze`는 mise 설치본,
grounding용으로 `luau-lang/luau` HEAD(`c73bb37`, 2026-08-12)를 clone해
파서/솔버 소스를 직접 확인, 교차검증용으로 `luau-lsp` 1.69.0 바이너리
사용. **clone/바이너리는 세션 종료 시 삭제했습니다**(24M+16M, 레포에
남길 이유 없음) — 재현이 필요하면 같은 출처에서 다시 받으면 됩니다.
## TL;DR
0-Y는 사실 **완전히 독립된 두 문제**였습니다:
- **문제 A — 콜백 파라미터(self) 추론.** `state:Compute(fn)`에서 `fn`
파라미터 `s`가 무주석일 때 타입이 안 잡히는 문제. **이건 진짜
풀립니다** — `State<T>`를 "Get만 있는 자기참조 데이터부"와 "Compute
등 로컬 제네릭 메소드부"로 쪼개면 됩니다. 파라미터 쪽은 실제로
존재하지 않는 필드에 접근하면 정상적으로 에러가 나는 등, **진짜
타입체크가 살아있는 채로** 무주석 람다가 통과합니다.
- **문제 B — `Compute`의 반환값 자체(`State<U>`).** `Compute`가 원래
타입 T와 **다른** 타입 U로 새 State를 만들어 반환하는 것 — 즉
`:Compute`의 존재 이유 그 자체 — 는 **어떤 방법을 써도(쪼개기든,
파라미터/리턴 둘 다 명시 주석이든, `question.md`가 원래 "완전
클린"이라 적어뒀던 raw 값 계약이든) 실제로는 타입이 안전하게 추론되지
않습니다.** 에러를 내면서 막히는 게 아니라 **`Unifiable<Error>`라는
내부 에러 타입으로 조용히 샙니다** — 겉보기엔 통과한 것처럼 보이고,
그 결과를 엉뚱한 타입에 대입해도 아무 에러가 안 뜹니다. 이건
`luau-analyze`/`--annotate`/`luau-lsp`(새 솔버 강제) 세 경로 전부에서
동일하게 재현됩니다 — 도구별 차이가 아니라 Luau 자체의 한계입니다.
**즉 사용자가 RFC를 읽고 낸 결론("Luau가 타입으로써 이걸 제공하기
전까지는 이 제약을 그냥 감수해야 한다")이 맞았고, 오히려 이 리포트의
초안이 낙관적으로 틀렸습니다.** 다만 실측해보니 그 제약의 실제 모양은
RFC가 설명하는 것("컴파일 에러로 거부됨")보다 더 나쁩니다 — **거부되는
게 아니라 조용히 타입 안전성을 잃습니다.**
---
## 1. 문제 A — 콜백 파라미터 추론은 실제로 풀림
### 최소 재현과 원인
```lua
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local state: State<number> = ...
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`).
### 회피책 — "데이터부/메소드부" 쪼개기 (`14`)
```lua
export type StateData<T> = {
Get: (self: StateData<T>) -> T, -- 자기 자신만 재참조(Compute 모름)
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
-- self/콜백 파라미터 둘 다 StateData<T> 사용
}
```
**이 쪼개기의 파라미터 쪽 효과는 진짜입니다** — `29`에서 확인: 콜백
파라미터로 존재하지 않는 메소드/필드를 부르면 정상적으로
`TypeError: Key 'NoSuchMethod' not found in table 'StateData<number>'`
납니다. 코드 생성(사용자가 제안한 "T별로 구워서 인라이닝") 없이,
`State<T>`가 여전히 진짜 제네릭인 채로 타입 선언 두 개만 손으로 쓰면
됩니다.
**캐비엇**: 콜백이 받는 `s``StateData<T>``Compute`/`With`가 없어서,
콜백 "안에서" 또 `s:Compute(...)`를 부르는 중첩은 안 됩니다(`20`).
`:Apply`의 factory처럼 콜백이 원본의 전체 능력이 필요한 자리는 이
쪼개기로 못 풀고 `function(self: State<T>)`처럼 파라미터 타입 주석이
계속 필요합니다(`21`, `21b`~`21e`는 전부 실패, `22`는 주석 추가로 해결
— 다만 base 문서가 이미 `:Apply`엔 이름 붙인 재사용 팩토리를 권장해서
실질 손해는 작아 보임). `Effect(fn, state)`(자유 함수)와
`state:Observer(fn)`(로컬 제네릭 반환 없음)는 애초에 이 문제와 무관—
지금 그대로도 무주석으로 잘 됩니다(`25`).
## 2. 문제 B — `Compute`의 반환 타입은 조용히 안전성을 잃음 (`26`~`35`, 정정의 핵심)
### 어떻게 발견했나
`23`(쪼개기 적용한 `With`+`Compute` 실사용 예시)에 사용자가 체이닝을 더
붙여보고 에디터에서 `error type`을 봤다고 알려준 게 계기였습니다.
`luau-analyze`로는 클린 통과였는데 왜 에디터는 다르게 보였는지
확인하려고 `--annotate`(실제 추론된 타입을 소스에 그대로 찍어주는 옵션)를
써봤더니:
```lua
-- luau-analyze --annotate 14-split-intersection-compute-outside.luau
local doubled:Unifiable<Error>=state:Compute(function(s:StateData<number>): number return s:Get()*2 end)
```
**에러가 하나도 안 떴던 그 "통과"가 사실 `Unifiable<Error>`(Luau
내부의 미해결/에러 타입 placeholder)였습니다.** 겉보기엔 성공한 것처럼
보였을 뿐, `U`(=`Compute`가 반환하는 `State<U>`의 U)가 실제로는 전혀
해소되지 않은 채였습니다.
### 진짜 위험한 부분 — 타입 안전성이 조용히 사라짐
`27`/`28`로 직접 확인: `s``State<string>`이어야 하는데 **명백히 틀린
타입에 대입해도 에러가 안 납니다**:
```lua
local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State<string>이어야 함
local wrong: number = s:Get() -- ❌이어야 하는데 통과함(exit=0, 에러 없음)
```
LHS에 일부러 틀린 타입을 명시해도(`28`, `local wrongLHS: State<boolean> =
n:Compute(function(x) return tostring(x:Get()) end)`) 마찬가지로 통과.
**`luau-lsp analyze --flag:LuauSolverV2=true`로도 동일하게 재현**돼서
`luau-analyze` CLI만의 문제가 아님을 확인했습니다.
### 이게 "쪼개기"만의 문제가 아님 — 모든 formulation에서 동일
가장 중요한 재확인: 이 불안전성은 **문제 A와 무관하게, `Compute`
`State<U>`(다른 파라미터로 재귀하는 반환)를 만드는 한 어떤 방식을 써도
똑같이 재현**됩니다.
| 실험 | 형태 | 결과 |
|---|---|---|
| `28` | 쪼개기 + 무주석 | Unifiable<Error>, 틀린 대입도 통과 |
| `30` | 쪼개기 + 콜백 **리턴 타입만** 명시 | 동일하게 안전 안 됨 |
| `31` | **쪼개기 없이** 파라미터/리턴 **둘 다 명시**(=question.md 선택지 1) | 동일 |
| `34` | **raw 값 계약**(=question.md 선택지 2, 원래 "완전 클린"으로 알려져 있던 것) | 동일 |
`34`가 특히 중요합니다 — 원래 audit 문서(`luau-test-first-run-2026-08-13.md`)가
"콜백이 raw 값 T를 받으면 완전 클린(0건)"이라고 적어둔 바로 그
케이스인데, 그건 **"에러가 안 뜬다"만 확인했을 뿐 반환 타입이 진짜
해소됐는지는 확인한 적이 없었습니다.** 이번에 `--annotate`로 열어보니
`34`도 똑같이 `Unifiable<Error>`이고 틀린 대입이 잡히지 않습니다 —
**즉 원래 0-Y 표에 있던 "raw 값 = 완전 클린"이라는 판정 자체가 이번
실측으로 뒤집힙니다.**
### 정확한 경계 — "재귀"가 아니라 "다른 파라미터로 재귀"가 원인
세 가지 대조군으로 정확히 좁혔습니다:
- `33`: `Compute`**같은** T로 재귀하는 `State<T>`를 반환(로컬 제네릭
`U` 없음, matrix B와 동일 모양) → **진짜 sound**. `doubled:State<number>`
정확히 추론되고, 틀린 대입은 실제로 에러가 남.
- `35`: `Compute`가 로컬 제네릭 `U`를 **재귀하지 않는 평범한 컨테이너**
`Box<U>`에 담아 반환 → **진짜 sound**. `s:Box<string>`로 정확히
추론됨.
- `07`/`32`: `Compute``U`**감싸지 않고 그대로** 반환(래핑 자체가
없음) → **진짜 sound**.
**"제네릭으로 감싸서 반환하는 것" 자체는 문제가 아닙니다. 문제는
정확히 "자기 자신과 같은 이름의 재귀 타입을, 자기 타입 파라미터와
다른 인자로 다시 감싸서 반환하는 것"** — 즉 `Compute<U>(self: State<T>,
...) -> State<U>`에서 `State<U>``State<T>`와 다른 인자로 자기
자신을 재참조하는 바로 그 자리입니다. 이건 사용자가 처음 찾아준 RFC
(`relax-recursive-type-restriction.html`)가 `Promise<T>.andThen`으로
예시 들었던 바로 그 패턴이고, `Promise<T>.andThen`도 이 패턴과 100%
동일한 모양입니다.
### RFC와의 관계 — "완화"가 아니라 "거부만 안 할 뿐, 안 풀림"
초안에서는 "새 솔버가 이 제약을 이미 완화했다"고 썼는데, 그건 **딱
선언(타입 alias 자체를 적는 것)까지만** 맞는 얘기였습니다(`17`/`18`이
확인한 게 이것 — 선언은 통과함). 그런데 이번에 **실제 호출부에서 U가
진짜로 풀리는지**까지 확인해보니 안 풀립니다 — 옛 솔버는 이 패턴을
선언 시점에 아예 거부해서 "이 기능을 못 쓴다"는 게 최소한 명확한 반면,
**새 솔버는 선언은 받아주고 사용도 허용하지만, 실제로는 조용히 타입
안전성만 잃습니다.** RFC가 제안한 완화 메커니즘("타입 별칭을 진짜
type function처럼 취급해 lazy expansion")이 선언 검사 단계까지만
반영되고, 실제 인스턴스화/단일화 단계는 아직 안 풀린 것으로 보입니다
— 웹서치로 찾은 관련 이슈(`luau-lang/luau#2380` "Allow recursive
generic types to differ", 아직 열려 있고 메인테이너 답변 없음)도 이
영역이 계속 진행 중인 작업임을 뒷받침하지만, **"조용히 Unifiable<Error>
샌다"는 이번에 직접 관측한 증상 자체를 정확히 다루는 이슈는 못
찾았습니다** — GitHub 검색을 더 깊게 하진 않았습니다.
### 딱 하나 더 본 것 — 깊은 체인에서의 거동 (`26`)
10단 이상 `Compute`를 체이닝(number→string→number→boolean→...)해보면,
한 번 `Unifiable<Error>`로 오염된 뒤에는 **항상 조용히 통과하는 것도
아닙니다** — 몇 단계 뒤에서 `#x:Get()`(문자열 길이 연산)처럼 실제
연산자 제약과 부딪히면 `TypeError: Expected type table, got 'a'
instead`처럼 **뜬금없는 자리에서 에러가 남**(정작 문제의 근원인 앞
단계 Compute 호출 줄이 아니라 훨씬 뒤에서). 즉 이 불안전성은 "항상
조용히 무시됨"도 "항상 에러남"도 아니고, **예측하기 어려운 지점에서
간헐적으로만 진짜 에러가 튀어나오는** 가장 다루기 힘든 형태입니다.
## 3. 종합
| API | 문제 A(파라미터 추론) | 문제 B(반환 타입 안전성) |
|---|---|---|
| `state:Compute(fn)` | ✅ 쪼개기로 해결 | ❌ **어떤 방법으로도 해결 못 함** |
| `state:With(...)` | ✅ 쪼개기로 해결(이형 dep 포함) | 반환이 결국 `Compute`로 이어지면 동일하게 영향 |
| `Effect(fn, state)` | 원래도 무관 | 반환이 없으므로 무관 |
| `state:Observer(fn)` | 원래도 무관(로컬 제네릭 없음) | 반환이 `EffectHandle`(재귀 아님)이라 무관 |
| `state:Apply(factory)` | 파라미터 주석 필요(2절 캐비엇) | factory가 결국 `:Compute`를 부르면 동일하게 영향 |
**핵심 결론**: `question.md` 0-Y가 "콜백 파라미터가 lazy 핸들을 받는
계약" 문제로 프레이밍했던 것 중, **콜백 파라미터 쪽(문제 A)은 진짜
풀 수 있는 문제였고 쪼개기로 실제로 풀립니다.** 하지만 **`:Compute`
새 타입을 반환한다는 것 자체(문제 B)는 이 세션에서 시도한 그 무엇으로도
못 풀었고, 이건 애초에 0-Y가 "선택지 1/2/3" 프레임으로 물었던 질문의
범위 밖에 있는 더 근본적인 제약**입니다 — 콜백이 lazy 핸들을 받든 raw
값을 받든 무관하게 똑같이 안 풀립니다.
### 3-1. 실효 대응책 — 명시적 타입 바인딩은 다운스트림에 진짜로 먹힘 (`43`)
**추론이 못 주는 타입을 사람이 `:`로 직접 박아주면, 그 자리부터
아래로는 타입 체크가 정상 작동합니다.** `28`이 "LHS 주석은 RHS를
검증해주지 않는다"를 보여줬기 때문에 처음엔 주석이 무의미해 보였는데,
그 둘은 별개 질문이라 `43`으로 따로 확인했습니다:
```lua
local s: State<string> = n:Compute(function(x) return tostring(x:Get()) end)
local ok: string = s:Get() -- ✅ 통과
local bad: number = s:Get() -- ✅ 에러남! Expected 'number', got 'string'
local bad2 = s:NoSuchMethod() -- ✅ 에러남! does not have key 'NoSuchMethod'
```
즉 정확히 이렇게 갈립니다:
| 구간 | 체크되는가 |
|---|---|
| 콜백 **안**(원본 값을 어떻게 다루는지) | ✅ 진짜 체크됨(`29`) |
| `Compute` 호출문 자체(RHS가 LHS 주석과 맞는지) | ❌ **안 됨** — 여기가 유일한 구멍 |
| 명시 주석 **이후** 다운스트림 전체 | ✅ 진짜 체크됨(`43`) |
**그래서 실무 규약은 "파생 State를 만드는 자리마다 결과 타입을 명시
주석으로 박는다"** 가 됩니다 — 구멍이 "그 한 줄이 실제로 그 타입을
만드는지"로 좁혀지고, 나머지 코드베이스 전체는 정상적으로 타입
안전해집니다. 잃는 것은 그 한 줄의 자동 검증뿐입니다.
### 3-2. 최종 결론
**이건 quad가 설계로 풀 문제가 아니라 상위 Luau의 현 한계입니다.**
`:Compute``State<U>`를 반환하는 것은 리액티브 파생값 API의 본질
그 자체이고(RFC 자신이 `Promise<T>.andThen`을 같은 모양의 대표
사례로 듦), 이걸 피하려고 타입을 비트는 것(모노모픽 코드 생성, 반환
타입을 `any`로 열기, API 모양 자체를 바꾸기 등)은 **RFC가 고쳐줄
대상에서 스스로 이탈하는 것**이라 지금 이득보다 나중 부채가 큽니다.
- **지금**: 명시적 타입 바인딩(`:`)이 강제됨 — 위 3-1의 규약.
- **RFC/이슈가 해결되면**: 지금 그대로의 선언이 **코드 변경 없이**
자연히 풀림(아래 6-2절 근거).
- **당장 우리가 할 수 있는 바 없음** — 문서화(`base/typing-limits.md`)와
추적 링크(`luau-lang/luau#2380`)가 전부.
## 4. 아직 안 본 것
- `Unifiable<Error>`가 뜨는 정확한 내부 매커니즘(어느 제약 해소 단계에서
포기하는지)은 Luau 소스코드 레벨까지는 안 팠습니다 — grounding은
RFC 2건 + 대조 실험 + 관련 이슈 1건(`#2380`, 못 닫힘)까지만.
- 이 불안전성을 근본적으로 피하는 **다른 API 설계**(예: `:Compute`
`State<U>`를 반환하는 대신 다른 무언가를 반환하는 방식)는 탐색하지
않았습니다 — **[2026-08-13 사용자 확정] 앞으로도 탐색 안 함**:
"타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고, 이것은
상위의 Luau의 현 한계다". 이 항목은 열린 숙제가 아니라 **명시적으로
범위 밖**입니다(3-2절, `base/typing-limits.md`).
- `previous?`/trailing deps 제네릭 팩(`rewrite-required/15`)도 같은
이유(Compute가 결국 `State<U>`를 반환)로 똑같이 영향받을 걸로
보이지만 별도 검증은 안 했습니다.
- `luau-lsp``enableNewSolver` 설정과 Roblox 전역 타입 정의
(`--definitions`)의 상호작용은 `--platform=standard`(정의 파일 없이)
환경에서만 확인했습니다.
## 5. 실무 캐비엇 — 에디터(`luau-lsp`)는 기본이 옛 솔버
`luau-analyze` CLI는 새 솔버가 기본값이지만, 실제 에디터가 물고 있는
`luau-lsp`(최신 릴리즈 1.69.0으로 직접 대조)는 **기본값이 옛
솔버**(`LuauSolverV2=false`)입니다. 옛 솔버로는 문제 B의 패턴
자체가 선언 시점에 거부되므로(오히려 안전한 실패), **이 문서가 다루는
쪼개기 등을 실제로 눈으로 확인하려면 에디터 쪽 설정도 새 솔버로
맞춰야** 합니다:
```json
{ "luau-lsp.fflags.enableNewSolver": true }
```
VSCode라면 워크스페이스 `.vscode/settings.json`에 넣으면 팀 전체가
공유됩니다. 다만 위에서 정리했듯 **새 솔버로 맞춰도 문제 B는 여전히
안 풀리고 조용히 위험해질 뿐**이라, 이 설정을 켜는 것 자체가 안전을
보장해주진 않습니다.
**이 선택은 아직 안 정해졌습니다(열린 항목)** — 두 방향의 트레이드오프가
실측으로는 이렇게 갈립니다:
- **새 솔버(V2)로 맞춤**: 문제 A의 쪼개기가 작동해 무주석 콜백을 쓸 수
있음. 대신 문제 B가 조용한 구멍으로 남음.
- **옛 솔버 유지(현재 luau-lsp 기본값)**: 문제 B 패턴을 선언 시점에
아예 거부 — "조용한 구멍" 대신 "명확한 실패". 대신 재귀 제네릭
선언 자체를 못 쓰므로 `Compute`의 지금 시그니처가 성립 안 함.
즉 옛 솔버는 "안전하지만 이 API를 아예 못 만듦"이고, 새 솔버는 "만들 수
있지만 한 줄이 검증 안 됨"이라 **실질적으로는 새 솔버 외에 선택지가
없어 보입니다** — M0 실착수 때 실제 에디터 환경에서 확정할 것
(`base/typing-limits.md`의 "미해결" 절에 추적).
## 6. 후속 리서치 — 명시 제네릭 인자, 그리고 미래 대비 설계 (`36`~`42`)
사용자가 리포트 확인 후 두 가지를 추가로 요청: (1) `state:Compute<FromState,
ToState>()`처럼 명시적으로 타입 인자를 넣어주는 방법이 되는지, (2) 지금
당장은 명시 타이핑으로 버티더라도, RFC가 낙관하는 미래 방향이 실제로
들어왔을 때 우리 코드가 **자연히** 그 위에 올라탈 수 있는 형태로
지금부터 짜둘 수 있는지.
### 6-1. `<<...>>` — 진짜 문법이었지만 우리 케이스엔 안 먹힘
**결론부터: 문법은 실재하고 일반적인 제네릭엔 진짜로 작동하지만,
`State<U>`(재귀 self-참조 타입을 다른 파라미터로 반환) 케이스는 여전히
못 고칩니다.**
Luau 소스(`Ast/src/Parser.cpp` `parseMethodCall`)를 직접 열어 확인 —
메소드 호출 뒤에 `<`가 연달아 두 번(`<<`) 오면 `parseTypeInstantiationExpr`
분기해 명시 타입 인자 리스트를 파싱합니다(단일 `<`는 비교 연산자와
문법이 겹쳐서 안 됨 — 그래서 이중 괄호). 이 정보(`AstExprCall::
typeArguments`)는 새 솔버의 `ConstraintGenerator.cpp`(2818행)에서
실제로 읽혀 `FunctionCallConstraint`까지 전달됩니다 — 죽은 코드가
아니라 진짜 배선돼 있습니다.
**일반 제네릭에선 실제로 작동합니다(`42`)**:
```lua
local function identity<T>(v: T): T return v end
local x = identity<<string>>(5 :: any)
local wrong: number = x -- 진짜 에러남(Expected 'number', got 'string')
```
`--annotate`로도 `x:string`으로 정확히 찍힘 — explicit 제네릭 인자가
진짜로 타입을 확정시킵니다.
**하지만 우리의 `Compute<U>(...) -> State<U>`에 똑같이 적용해도
안 풀립니다(`40`)**:
```lua
local s = n:Compute<<string>>(function(x) return tostring(x:Get()) end)
-- --annotate: local s:Unifiable<Error>=... (여전히!)
local wrong: number = s:Get() -- 여전히 안 잡힘
```
`U`를 추론이 아니라 **명시로 못박아도** `State<U>`라는 반환 타입 자체가
`Unifiable<Error>`로 새는 건 그대로입니다. 문제 A(파라미터 self 추론)
쪽도 명시 제네릭으로 우회되는지 따로 확인했는데(`41`, 쪼개기 없이 원본
재귀 타입 그대로 + `<<number>>`만 추가) **완전히 무효과**`00`
정확히 같은 에러가 그대로 남. `<<...>>`를 아무리 붙여도 파서가 받아만
줄 뿐 타입 체크 결과에 어떤 영향도 못 줍니다(같은 이유로 `39`에서
존재하지 않는 타입 이름을 넣어도 에러가 안 났던 것 — 애초에 우리
케이스에선 이 정보가 실질적으로 안 쓰이고 있다는 뜻).
**해석**: 문제 B는 "U가 무엇인지 모른다"가 아니라 **"U를 알아도
`State<U>`(자기 자신을 다른 파라미터로 재귀 참조하는 타입)를 실제
콘크리트 타입으로 확장(expand)하는 단계 자체가 아직 안 됨"** —
RFC가 말하는 "lazy expansion of type alias contents"가 이 확장
단계를 가리키는 걸로 보이고, 그 부분이 아직 안 들어와서 명시 인자를
줘도 무용지물입니다.
### 6-2. 미래 RFC를 위한 "플레이스홀더" — 필요 없어 보임(좋은 소식)
RFC 원문을 다시 정독: 완화 근거로 "This pays for itself in the
considerable gain in expressivity gained for users of the type
system"(비용을 치를 가치가 있다)는 문장이 있고, **완화 메커니즘은
"타입 별칭을 진짜 type function처럼 취급해 lazy expansion"이라는
순수 내부 솔버 아키텍처 변경**이라고 명시돼 있습니다. RFC가 직접
"이 정의는 지금 거부되지만"이라며 드는 예시가 바로:
```lua
type Promise<T> = {
andThen: <U>(self: Promise<T>, callback: (T) -> Promise<U>) -> Promise<U>
}
```
— **지금 이 순간에도 우리가 이미 쓰고 있는, 아무 변형도 안 가한
자연스러운 재귀 제네릭 선언 그 자체**입니다. 즉:
- **문법을 바꿔서 미리 대비할 필요가 없습니다** — RFC의 완화가
들어오면 지금 그대로의 `Compute<U>(self: State<T>, fn) -> State<U>`
선언이 (또는 우리가 채택한 쪼개기 버전도, `State<T>` 자체는 여전히
진짜 제네릭 재귀 별칭이므로 마찬가지로) **아무 코드 변경 없이** 자동으로
올바르게 풀릴 걸로 보입니다.
- **오히려 하지 말아야 할 것**: 사용자가 처음 제안했던 "T별로 코드
생성해서 미리 구워두기"(모노모picization) 방향으로 갔다면, 그건
**제네릭 자체를 없애버리는 것**이라 RFC가 고치는 대상(제네릭 재귀
별칭의 인스턴스화)과 무관해집니다 — 그 경로로 가면 나중에 RFC가
들어와도 자동으로 득 볼 게 없고, 오히려 코드 생성 인프라를 걷어내는
별도 마이그레이션이 필요해집니다. **이번 세션에서 코드 생성 없이
쪼개기만으로 문제 A를 푼 게(1절) 결과적으로 이 방향과도 맞아떨어짐**
`State<T>`가 여전히 손으로 쓴 두 개의 제네릭 타입 별칭일 뿐이라,
RFC가 들어오면 이 쪼개기 자체도 필요 없어질 가능성이 있어 보입니다
(제거해도 그만, 남겨둬도 그만 — 검증은 필요).
- **지금 할 수 있는 유일한 "대비"는 문서화뿐입니다**: `Compute`(및 같은
모양을 공유하는 `With`/`Apply`)의 반환 타입이 현재 Luau에서 정적으로
검증되지 않는다는 걸 base 문서에 캐비엇으로 남겨두고, 이 RFC
(`luau-lang/luau` 관련 이슈 `#2380`)를 추적 링크로 걸어두는 정도가
합리적으로 보입니다 — 코드/타입 구조 자체를 미래를 위해 미리
바꿔둘 이유는 실측상 없어 보입니다.
## 7. 실험 파일 인덱스
`00`~`25`(초안, 문제 A 중심)와 `26`~`35`(정정, 문제 B 발견)로 나뉩니다.
자세한 개별 실험 설명은 각 파일 상단 주석 참고. 핵심만:
| 파일 | 확인 내용 | 결과 |
|---|---|---|
| `00`~`13` | 문제 A 원인 좁히기(매트릭스, 자유함수 대조군 등) | 자기참조 필드+로컬 제네릭이 원인으로 확정 |
| `14` | 쪼개기 최소 재현 | ✅(파라미터 쪽만, 이후 재검증 필요했음이 드러남) |
| `15`~`18` | 문제 B 선언 단계, RFC 대조 | 로컬 제네릭 차이는 선언은 통과(새 솔버) |
| `19`,`23`,`24` | 쪼개기 스트레스(체이닝/중첩/이형 With) | 이후 전부 Unifiable<Error>였음이 드러남(26~ 참고) |
| `20` | 쪼개기 캐비엇(콜백 안 재중첩) | ❌ 예상대로 |
| `21`,`21b`~`21e`,`22` | `:Apply` 파라미터 주석 필요성 | 주석 없이는 불가, 있으면 가능 |
| `25` | `Observer`는 원래 무관 | ✅ |
| `26` | 깊은 체인(10단+) | 간헐적/예측불가 에러 패턴 확인 |
| `27`,`28` | **쪼개기의 반환 타입 불안전성** | ❌ 틀린 대입도 통과 — 정정의 시작점 |
| `29` | 쪼개기의 파라미터 쪽은 진짜 안전한지 재확인 | ✅ 진짜 sound(문제 A는 유효) |
| `30` | 리턴 타입 주석으로 문제 B 우회 시도 | ❌ 안 됨 |
| `31` | 쪼개기 없이 완전 명시(선택지 1) | ❌ 역시 불안전 |
| `32` | 대조군(U를 안 감싸고 직접 반환) | ✅ sound |
| `33` | 대조군(같은 T로만 재귀) | ✅ sound — 정확한 경계 확정 |
| `34` | raw 값 계약(선택지 2, "완전 클린"으로 알려졌던 것) | ❌ 역시 불안전 — 판정 뒤집힘 |
| `35` | 대조군(재귀 아닌 컨테이너) | ✅ sound |
| `36` | 사용자 원래 `<T>` 단일 괄호 시도 재현 | ❌ SyntaxError(`<`는 비교 연산자와 충돌) |
| `37` | 쪼개기 Compute에 `<T>` 단일 괄호 | ❌ 동일 SyntaxError |
| `38`,`39` | `<<T>>` 이중 괄호 최초 확인 | 파싱은 됨(에러 없음) — 근데 존재하지 않는 타입명도 안 걸림(수상함) |
| `40` | `<<...>>`로 문제 B(반환 타입) 우회 시도 | ❌ 여전히 Unifiable<Error> |
| `41` | `<<...>>`로 문제 A(파라미터) 우회 시도 | ❌ 완전 무효과, `00`과 동일 에러 |
| `42` | 대조군: 일반(비재귀) 제네릭에 `<<...>>` | ✅ 진짜 작동함(문법/배선 자체는 확인됨) |
| `43` | **명시 LHS 주석이 다운스트림에 타입을 바인딩하는가** | ✅ **먹힘** — 3-1절, 실효 대응책의 근거 |
전부 `spikes/` 안에 있습니다. 실행 환경/재현 방법은 이 문서 맨 위
"재현 방법 세 갈래" 참고.

View file

@ -0,0 +1,20 @@
--!strict
-- 0-Y 베이스라인 재현: audit/luau-test-first-run-2026-08-13.md에 정리된
-- "지금 확정된 관용구" 최소 재현. 무주석 인라인 람다가 lazy State<T> 핸들을
-- 받는 계약에서 정말 타입에러가 나는지 확인.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
-- 기대: 실패 (audit 문서 기준)
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("00 done")

View file

@ -0,0 +1,19 @@
--!strict
-- 00의 에러 메시지가 "Get이 latter(추론된) 타입에서 read-only인데
-- former(선언된) 타입은 read-write를 요구한다"고 명시함.
-- State<T>의 Get을 read-only로 선언하면 방향이 맞아떨어져 풀리는지 확인.
export type State<T> = {
read Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("01 done")

View file

@ -0,0 +1,19 @@
--!strict
-- 매트릭스 A: T도 U도 전부 제네릭 없이 완전 monomorphic.
-- 재귀적 self 타입(State가 자기 자신을 파라미터로 참조)만 남기고
-- 제네릭을 전부 제거하면 무주석 람다가 통과하는지 확인.
export type State = {
Get: (self: State) -> number,
Compute: (self: State, fn: (self: State) -> number) -> State,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("02 mono-mono done")

View file

@ -0,0 +1,18 @@
--!strict
-- 매트릭스 B: T는 제네릭(State<T>), U는 고정(Compute가 항상 State<T> 자기
-- 자신 타입을 반환 -- 즉 self map, U 제네릭 없음).
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: (self: State<T>, fn: (self: State<T>) -> T) -> State<T>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("03 genericT-monoU done")

View file

@ -0,0 +1,18 @@
--!strict
-- 매트릭스 C: T는 고정(State, number만), U만 제네릭.
-- 00과 대조해 "T의 제네릭성"이 원인인지, "U의 제네릭성"이 원인인지 분리.
export type State = {
Get: (self: State) -> number,
Compute: <U>(self: State, fn: (self: State) -> U) -> U,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("04 monoT-genericU done")

View file

@ -0,0 +1,20 @@
--!strict
-- 04(monoT, genericU)이 실패했음. 원인이 ":" 메소드 호출(암묵적 self
-- 바인딩) 때문인지, 아니면 U가 로컬 제네릭이라는 사실 자체 때문인지
-- 분리 -- 똑같은 타입을 일반 함수 호출 문법으로 불러봄(self를 명시 인자로).
export type State = {
Get: (self: State) -> number,
Compute: <U>(self: State, fn: (self: State) -> U) -> U,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
-- 메소드 호출(:) 대신 함수 호출(.)로 self를 명시 인자로 넘김
local doubled = state.Compute(state, function(s) return s:Get() * 2 end)
print("05 done")

View file

@ -0,0 +1,18 @@
--!strict
-- State 재귀 자체를 완전히 없애고, self가 그냥 평범한 제네릭 A인
-- 순수 HOF에서도 "로컬 제네릭 U + 콜백 인자 무주석"이 깨지는지 확인.
-- State/Get 개념을 전혀 안 쓰고 가장 순수한 형태로 축소.
type Box<A> = { value: A }
local function Compute<A, U>(self: Box<A>, fn: (self: Box<A>) -> U): U
return fn(self)
end
local box: Box<number> = { value = 10 }
-- s의 타입이 Box<number>로 바로 주석 없이 추론되어야 하는데,
-- Compute 자체가 로컬 제네릭 U를 갖고 있어서 안 되는지 확인
local doubled = Compute(box, function(s) return s.value * 2 end)
print("06 done")

View file

@ -0,0 +1,15 @@
--!strict
-- 06에서 Box<A>까지 제네릭이었던 것을 Box(비제네릭, number 고정)로 낮춰서
-- "재귀적 self 타입이 없는 상태에서 U만 제네릭"인 경우를 04와 대조.
type Box = { value: number }
local function Compute<U>(self: Box, fn: (self: Box) -> U): U
return fn(self)
end
local box: Box = { value = 10 }
local doubled = Compute(box, function(s) return s.value * 2 end)
print("07 done")

View file

@ -0,0 +1,16 @@
--!strict
-- luau-analyze가 06/07에서 "Consider annotating the return with number"라고
-- 직접 제안함. 그 제안대로 콜백의 리턴 타입만(파라미터는 여전히 무주석)
-- 명시하면 self 파라미터까지 같이 풀리는지 확인.
type Box = { value: number }
local function Compute<U>(self: Box, fn: (self: Box) -> U): U
return fn(self)
end
local box: Box = { value = 10 }
local doubled = Compute(box, function(s): number return s.value * 2 end)
print("08 done")

View file

@ -0,0 +1,18 @@
--!strict
-- 08과 같은 아이디어를 원래 State(재귀 self) 형태(00)에 적용 --
-- 파라미터 s는 무주석으로 두고 콜백의 "리턴 타입만" 명시하면 통과하는지.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
local doubled = state:Compute(function(s): number return s:Get() * 2 end)
print("09 done")

View file

@ -0,0 +1,20 @@
--!strict
-- 07(통과)과 04(실패)의 유일한 구조적 차이는: State는 자기 자신을 참조하는
-- 메소드 필드(Get: (self:State)->T)를 갖고 있고, Box는 순수 데이터 필드뿐.
-- 그 가설을 검증: Box에 자기참조 메소드 필드(Get)를 추가하되, Compute는
-- 여전히 필드가 아니라 자유 함수로 둔 채로도 깨지는지 확인.
type Box3 = {
value: number,
Get: (self: Box3) -> number, -- 자기참조 메소드 필드 추가
}
local function Compute<U>(self: Box3, fn: (self: Box3) -> U): U
return fn(self)
end
local box: Box3 = (nil :: any) :: Box3
local doubled = Compute(box, function(s) return s:Get() * 2 end)
print("10 done")

View file

@ -0,0 +1,21 @@
--!strict
-- 10에서 "Compute가 필드가 아니라 자유 함수"라 통과했다는 가설을 더 좁힘.
-- 이번엔 Compute를 다시 State의 필드(자기참조)로 되돌리되, 콜백 본문은
-- s:Get() 대신 순수 데이터 필드 s.value로 단순화 -- 문제가 "Compute가
-- 자기참조 타입의 필드라는 것" 자체인지, "콜백 안에서 또 self참조 메소드
-- (Get)를 호출하는 중첩"인지 분리.
export type State = {
value: number,
Compute: <U>(self: State, fn: (self: State) -> U) -> U,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
local doubled = state:Compute(function(s) return s.value * 2 end)
print("11 done")

View file

@ -0,0 +1,18 @@
--!strict
-- 11 + 콜백 리턴 타입만 명시. "Compute가 자기참조 타입의 필드"라는 문제가
-- 리턴 타입 annotation 하나로 우회되는지(파라미터 s는 여전히 무주석).
export type State = {
value: number,
Compute: <U>(self: State, fn: (self: State) -> U) -> U,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
local doubled = state:Compute(function(s): number return s.value * 2 end)
print("12 done")

View file

@ -0,0 +1,21 @@
--!strict
-- 04/11이 실패한 이유가 "Compute 필드 타입 선언 안에 State라는 이름이
-- 리터럴로 재귀 등장"하는 것이라는 가설. Compute의 self 파라미터를
-- 리터럴 State가 아니라 완전히 자유로운 제네릭 Self로 바꾸면(sub-try5
-- 패턴처럼) 필드 선언 자체에서 재귀 언급이 사라짐 -- 이게 U와 같이 있어도
-- 통과하는지 확인.
export type State = {
Get: (self: State) -> number,
Compute: <Self, U>(self: Self, fn: (self: Self) -> U) -> U,
}
local function fakeState(): State
return (nil :: any) :: State
end
local state: State = fakeState()
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("13 done")

View file

@ -0,0 +1,25 @@
--!strict
-- 10/07이 통과한 이유는 "Compute가 자기참조 타입 자신의 필드가 아니라"는
-- 점이었음. 그런데 실사용에선 `state:Compute(fn)` 메소드 문법이 꼭
-- 필요함. 절충안: State를 둘로 쪼개서 -- StateData<T>는 Get만 담아
-- 자기참조하고(Compute를 언급 안 함), Compute는 별도로 선언한 뒤
-- 교차 타입(&)으로 합쳐 state 값 자체엔 여전히 Compute가 필드로 붙어있게
-- 함. Compute의 self 파라미터 타입은 State<T>(전체, Compute 포함) 대신
-- StateData<T>(Compute 미포함)를 가리키게 해서 재귀 사슬을 끊음.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
local doubled = state:Compute(function(s) return s:Get() * 2 end)
print("14 done")

View file

@ -0,0 +1,12 @@
--!strict
-- 08 스파이크가 낸 "Recursive type being used with different parameters" 에러를
-- Compute/self-application 문제와 분리해서 최소 재현. State<T>가 자기 자신을
-- *다른* 타입 인자(State<any>)로 자기 선언 안에서 참조하는 것 자체가
-- 문제인지 확인 (Compute 없이, With 하나만으로).
export type State<T> = {
Get: (self: State<T>) -> T,
With: (self: State<T>, ...State<any>) -> State<any>,
}
print("15 done")

View file

@ -0,0 +1,10 @@
--!strict
-- 대조군: With의 가변인자를 State<any>가 아니라 State<T>(같은 파라미터)로
-- 맞추면 통과하는지 -- "다른 파라미터로 재귀 참조"가 진짜 원인인지 확정.
export type State<T> = {
Get: (self: State<T>) -> T,
With: (self: State<T>, ...State<T>) -> State<T>,
}
print("16 done")

View file

@ -0,0 +1,13 @@
--!strict
-- RFC(relax-recursive-type-restriction.html)가 예시로 든 Promise<T>.andThen과
-- 정확히 같은 모양: Compute: <U>(self: State<T>, fn: (T) -> U) -> State<U>
-- 만 단독으로 선언했을 때 그 자체로 "Recursive type being used with different
-- parameters"가 나는지 확인 (08 스파이크는 With 줄과 섞여 있어 Compute 단독
-- 원인인지 불명확했음).
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (T) -> U) -> State<U>,
}
print("17 done")

View file

@ -0,0 +1,12 @@
--!strict
-- 17과 같은데 fn이 raw T가 아니라 lazy self 핸들을 받는 버전(0-Y 원래
-- 계약)도 "다른 파라미터로 재귀 참조" 자체로는 독립적으로 통과/실패하는지.
-- (bidirectional inference 문제와 별개로, 타입 "선언" 자체가 성립하는지만 봄
-- -- 호출부 없음)
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
print("18 done")

View file

@ -0,0 +1,51 @@
--!strict
-- 14의 "쪼개기" 트릭을 실전 규모로 스트레스 테스트:
-- - State<T>:Compute 체이닝 (number -> string -> boolean, 서로 다른 T)
-- - State<State<T>> 중첩
-- - Source<T>가 State<T>를 구조적으로 만족 + Source도 같은 쪼개기 적용
-- - 전부 무주석 인라인 람다로
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
export type SourceData<T> = StateData<T> & {
Set: (self: SourceData<T>, value: T) -> (),
Emit: (self: SourceData<T>) -> (),
}
export type Source<T> = SourceData<T> & {
Compute: <U>(self: SourceData<T>, fn: (self: SourceData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local function fakeSource<T>(v: T): Source<T>
return (nil :: any) :: Source<T>
end
-- 1) 기본: 무주석 람다로 체이닝, T가 매 단계 바뀜(number -> string -> boolean)
local n: State<number> = fakeState(5)
local s: State<string> = n:Compute(function(x) return tostring(x:Get()) end)
local b: State<boolean> = s:Compute(function(x) return #x:Get() > 0 end)
-- 2) State<State<T>> 중첩 -- 바깥 Compute의 U 자체가 State<number>
local nested: State<State<number>> = n:Compute(function(x) return fakeState(x:Get() + 1) end)
local unwrapped: State<number> = nested:Compute(function(x) return x:Get():Get() end)
-- 3) Source도 같은 패턴으로 Compute 체이닝 + Set/Emit
local src: Source<number> = fakeSource(0)
src:Set(10)
src:Emit()
local derived: State<string> = src:Compute(function(x) return tostring(x:Get()) end)
-- 4) Source가 State 자리에 구조적으로 들어가는지(서브타입)
local function useAsState<T>(st: State<T>): T
return st:Get()
end
local viaSubtype: number = useAsState(src)
print("19 done", b, derived, unwrapped, viaSubtype)

View file

@ -0,0 +1,28 @@
--!strict
-- 중요한 캐비엇 확인: 쪼개기 트릭에서 콜백이 받는 s는 StateData<T>
-- (Get만 있음)로 타이핑됨 -- State<T>(Compute 포함)가 아님. 그러면
-- 콜백 "안에서" s:Compute(...)를 또 호출하는 중첩 패턴은 타입 에러가
-- 나야 정상. 실제로 그런지, 그리고 이게 실사용에서 문제가 되는 패턴인지
-- 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 콜백 안에서 받은 s로 또 Compute를 거는 중첩 -- 예상: 타입 에러
-- (s: StateData<number>엔 Compute 필드가 없음)
local nested = n:Compute(function(s)
local inner = s:Compute(function(s2) return s2:Get() * 2 end)
return inner:Get()
end)
print("20 done", nested)

View file

@ -0,0 +1,34 @@
--!strict
-- 실사용 확인: tween-plan.md 341행 예시처럼 `:Apply`의 factory 콜백
-- "내부에서" `self:Compute(...)`를 또 호출하는 패턴이 실제로 존재함.
-- 즉 Apply의 콜백 파라미터는 Compute 없는 StateData가 아니라
-- 전체 State(=Compute 포함)여야 함 -- Compute와 요구사항이 다름.
--
-- 실험: receiver(self, 메소드 디스패치용)는 StateData로 쪼개서 유지하되
-- factory 콜백의 파라미터 타입만 전체 State<T>로 명시하면
-- (선언 자체가 아니라 "필드 시그니처 내부의 콜백 파라미터 위치"라
-- 재귀 문제를 안 일으키는지) 무주석으로 통과하는지 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- Apply 콜백 파라미터(self)가 무주석인데 그 안에서 :Compute를 또 호출
local result = n:Apply(function(self)
if true then
return self:Compute(function(h) return h:Get() * 2 end)
end
return self:Compute(function(h) return h:Get() end)
end)
print("21 done", result)

View file

@ -0,0 +1,25 @@
--!strict
-- 21의 진단이 *error-type*/*CYCLE*로 뒤덮여 있다는 지적 — 캐스케이드
-- 노이즈인지, 아니면 진짜 다른 문제인지 최소 형태로 재확인.
-- 분기(if)와 이중 Compute 호출을 다 걷어내고 "Apply 콜백 안에서
-- Compute를 한 번만 호출"하는 가장 단순한 형태로 축소.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local result = n:Apply(function(self)
return self:Compute(function(h) return h:Get() * 2 end)
end)
print("21b done", result)

View file

@ -0,0 +1,28 @@
--!strict
-- 21b는 통과, 21은 실패. 유일한 차이는 21이 if-분기 두 개에서 각각
-- self:Compute(...)를 부른다는 것. 분기 자체가 문제인지, 아니면 21 특유의
-- "두 분기가 서로 다른 본문"이 문제인지 분리 -- 여기선 두 분기가 완전히
-- 같은 본문.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local result = n:Apply(function(self)
if true then
return self:Compute(function(h) return h:Get() * 2 end)
end
return self:Compute(function(h) return h:Get() * 2 end)
end)
print("21c done", result)

View file

@ -0,0 +1,28 @@
--!strict
-- 21c는 완전히 같은 본문이라 통과했음. 이번엔 두 분기의 "표현식은
-- 다르지만 최종 타입은 둘 다 number로 같음"인 경우도 통과하는지 확인 --
-- 21(실패)의 진짜 원인이 "표현식이 다르다"인지 "결과 타입이 갈린다"인지
-- 분리.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local result = n:Apply(function(self)
if true then
return self:Compute(function(h) return h:Get() * 2 end)
end
return self:Compute(function(h) return h:Get() + 1 end)
end)
print("21d done", result)

View file

@ -0,0 +1,27 @@
--!strict
-- 21과 정확히 같은 두 분기(h:Get()*2 vs h:Get())로 재현 -- 21d와 다른 점은
-- 두 번째 분기가 "그냥 h:Get()" 트리비얼 패스스루라는 것. 이게 원인인지
-- 최종 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local result = n:Apply(function(self)
if true then
return self:Compute(function(h) return h:Get() * 2 end)
end
return self:Compute(function(h) return h:Get() end) -- 트리비얼 패스스루
end)
print("21e done", result)

View file

@ -0,0 +1,27 @@
--!strict
-- 21의 실패를 확인했으니, "Apply의 factory만 명시 주석을 요구"하는
-- 절충안이 실제로 동작하는지 확인 (self 파라미터에 타입 주석을 달면).
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
Apply: <U>(self: StateData<T>, factory: (self: State<T>) -> U) -> U,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- Compute는 여전히 무주석
local doubled = n:Compute(function(s) return s:Get() * 2 end)
-- Apply만 self에 타입 주석
local result = n:Apply(function(self: State<number>)
return self:Compute(function(h) return h:Get() * 2 end)
end)
print("22 done", doubled, result)

View file

@ -0,0 +1,42 @@
--!strict
-- slot-plan.md 914행 실제 코드: layoutOrder:With(offset):Compute(function(i, o)
-- return i:Get() + o:Get() end) -- With로 모은 뒤 Compute 콜백이 여러 개의
-- lazy 핸들을 무주석으로 받는 실사용 패턴을 split 트릭으로 재현.
-- With(...)는 이형 dep들을 모아 "self 자신 + deps"를 콜백에 그대로 넘기는
-- pass-through 노드로 모델링(bind-system-plan.md "trailing deps" 절 참고).
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
-- With(dep) -- 단일 추가 의존성 버전(2개 합치기), 콜백이 (self, dep) 두
-- lazy 핸들을 받음. 실제로는 With가 별도 노드를 만들지만 여기선 타입
-- 추론 자체만 검증.
export type Withable<T> = StateData<T> & {
With: <D>(self: StateData<T>, dep: StateData<D>) -> WithResult<T, D>,
}
export type WithResult<T, D> = StateData<T> & {
Compute: <U>(self: WithResult<T, D>, fn: (self: StateData<T>, dep: StateData<D>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T> & Withable<T>
return (nil :: any) :: State<T> & Withable<T>
end
local layoutOrder = fakeState(0)
local offset = fakeState(0)
local combined = layoutOrder:With(offset):Compute(function(i, o)
return i:Get() + o:Get()
end)
print("23 done", combined)
-- 유저가 발견한 후속 케이스: combined에 체이닝 + 명시 타입 주석
local t = combined:Compute(function(self: StateData<number>)
return true
end)
print("t check", t)

View file

@ -0,0 +1,30 @@
--!strict
-- 23과 동일하지만 진짜 이형 타입(T=number, D=string)으로 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
export type Withable<T> = StateData<T> & {
With: <D>(self: StateData<T>, dep: StateData<D>) -> WithResult<T, D>,
}
export type WithResult<T, D> = StateData<T> & {
Compute: <U>(self: WithResult<T, D>, fn: (self: StateData<T>, dep: StateData<D>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T> & Withable<T>
return (nil :: any) :: State<T> & Withable<T>
end
local n: State<number> & Withable<number> = fakeState(0)
local s: State<string> & Withable<string> = fakeState("x")
local combined = n:With(s):Compute(function(i, str)
local numPart: number = i:Get()
local strPart: string = str:Get()
return numPart .. strPart
end)
print("24 done", combined)

View file

@ -0,0 +1,25 @@
--!strict
-- Observer는 state:Observer(fn) 메소드지만 U 같은 로컬 제네릭 반환이 없음
-- (EffectHandle을 반환할 뿐, State<U>를 만들지 않음) -- 그래서 굳이 쪼개기
-- 트릭 없이도 self:State<T> 그대로 필드로 둬도 무주석 람다가 통과하는지 확인
-- (matrix B 패턴과 동일한 결일 것으로 예상).
export type EffectHandle = {
Unsubscribe: (self: EffectHandle) -> (),
}
export type State<T> = {
Get: (self: State<T>) -> T,
Observer: (self: State<T>, fn: (self: State<T>) -> ()) -> EffectHandle,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local handle = n:Observer(function(s)
print(s:Get() * 2)
end)
print("25 done", handle)

View file

@ -0,0 +1,35 @@
--!strict
-- 깊은 체이닝 스트레스 테스트: Compute를 10번 이상 연쇄, 타입도 계속
-- 바꿔가며(number -> string -> number -> boolean -> ... ) 무주석 람다로
-- 전부 연결. 얕은 체인(19)에서만 통과하고 깊어지면 솔버가 포기하는지 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local s0: State<number> = fakeState(0)
local s1 = s0:Compute(function(x) return tostring(x:Get()) end) -- number -> string
local s2 = s1:Compute(function(x) return #x:Get() end) -- string -> number
local s3 = s2:Compute(function(x) return x:Get() > 0 end) -- number -> boolean
local s4 = s3:Compute(function(x) return if x:Get() then 1 else 0 end) -- boolean -> number
local s5 = s4:Compute(function(x) return x:Get() * 2.5 end) -- number -> number
local s6 = s5:Compute(function(x) return tostring(x:Get()) end) -- number -> string
local s7 = s6:Compute(function(x) return x:Get() .. "!" end) -- string -> string
local s8 = s7:Compute(function(x) return #x:Get() end) -- string -> number
local s9 = s8:Compute(function(x) return x:Get() % 3 end) -- number -> number
local s10 = s9:Compute(function(x) return x:Get() == 0 end) -- number -> boolean
local s11 = s10:Compute(function(x) return not x:Get() end) -- boolean -> boolean
local s12 = s11:Compute(function(x) return x:Get() end) -- boolean -> boolean (passthrough)
-- 최종 타입이 정확히 boolean으로 좁혀졌는지 실사용 검증(에러 없이 대입되면 통과)
local finalCheck: boolean = s12:Get()
print("26 done", finalCheck)

View file

@ -0,0 +1,26 @@
--!strict
-- 결정적 질문: --annotate가 보여준 Unifiable<Error>가 진짜 "타입 체크가
-- 무력화됐다"는 뜻인지 확인. Compute 리턴값을 明시 주석 없이 받은 뒤,
-- 일부러 틀린 타입에 대입해서 정말 안 잡히는지 테스트.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 명시 주석 없이 받음(19의 s/b처럼 LHS에 State<string> 안 붙임)
local s = n:Compute(function(x) return tostring(x:Get()) end)
-- 일부러 틀린 타입 대입 -- 진짜 타입체크가 되고 있다면 여기서 에러가 나야 함
local wrong: number = s:Get() -- s는 State<string>이어야 하므로 Get()은 string. number에 대입하면 에러여야 정상
local right: string = s:Get()
print("27 done", wrong, right)

View file

@ -0,0 +1,25 @@
--!strict
-- 27이 "무주석 LHS"에서 타입 안전성이 완전히 무력화됨을 보여줬음.
-- 그럼 19처럼 LHS에 명시 타입(local s: State<string> = ...)을 붙이면
-- 최소한 그 자리에서는 안전한지 확인 -- 만약 명시 LHS도 "틀린 타입"을
-- 넣었을 때 안 잡히면, Unifiable<Error>가 정말 any처럼 뭐든 다 받아
-- 삼킨다는 뜻이라 이 트릭 전체가 무의미해짐.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 콜백은 string을 반환하는데 LHS는 일부러 State<boolean>이라고 우김
-- -- 진짜 타입체크가 되고 있다면 여기서 에러가 나야 함
local wrongLHS: State<boolean> = n:Compute(function(x) return tostring(x:Get()) end)
print("28 done", wrongLHS)

View file

@ -0,0 +1,27 @@
--!strict
-- 27/28에서 Compute의 "리턴 타입"(U/State<U>)이 Unifiable<Error>로 새는
-- 걸 확인했음. 그런데 콜백의 "파라미터"(s: StateData<T>) 자체는 여전히
-- 제대로 추론되고 있는지는 별개로 확인 필요 -- 파라미터에 대해 존재하지
-- 않는 필드/메소드를 부르면 정상적으로 잡히는지 테스트.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 존재하지 않는 메소드 호출 -- 파라미터 타입이 진짜 StateData<number>로
-- 추론되고 있다면 여기서 "Key 'NoSuchMethod' not found" 에러가 나야 함
local bad = n:Compute(function(x) return x:NoSuchMethod() end)
-- 존재하지 않는 필드 산술도 확인
local bad2 = n:Compute(function(x) return x.value + 1 end) -- StateData엔 value 필드 없음
print("29 done", bad, bad2)

View file

@ -0,0 +1,26 @@
--!strict
-- 27/28이 U(=State<U>의 U)가 Unifiable<Error>로 새는 걸 확인했음.
-- 콜백의 "리턴 타입"만 명시(파라미터는 여전히 무주석)하면 U가 제대로
-- 잡히는지 확인 -- 이게 되면 "파라미터는 무주석, 리턴만 명시"라는
-- 절충 관용구가 살아남음.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 파라미터는 무주석, 리턴 타입만 명시
local s = n:Compute(function(x): string return tostring(x:Get()) end)
local wrong: number = s:Get() -- s가 진짜 State<string>이면 여기서 에러여야 함
local right: string = s:Get()
print("30 done", wrong, right)

View file

@ -0,0 +1,24 @@
--!strict
-- 대조군: "쪼개기 없이, 파라미터/리턴 둘 다 명시 주석"(=question.md
-- 선택지 1, 이미 known-working으로 알려진 경우)도 U가 진짜 정상 해소되는지
-- --annotate로 확인. 여기서도 Unifiable<Error>가 뜨면 이건 쪼개기와
-- 무관한 Luau 자체의 한계라는 뜻.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 파라미터/리턴 둘 다 명시
local s = n:Compute(function(x: State<number>): string return tostring(x:Get()) end)
local wrong: number = s:Get()
local right: string = s:Get()
print("31 done", wrong, right)

View file

@ -0,0 +1,9 @@
--!strict
type Box = { value: number }
local function Compute<U>(self: Box, fn: (self: Box) -> U): U
return fn(self)
end
local box: Box = { value = 10 }
local doubled = Compute(box, function(s) return s.value * 2 end)
local wrong: string = doubled -- doubled는 number여야 함, 진짜 타입체크되면 여기서 에러
print("32 done", wrong)

View file

@ -0,0 +1,20 @@
--!strict
-- 결정적 대조군: Compute가 State<T>(같은 T)를 반환하면(=matrix B, 03과
-- 동일 패턴) 진짜 sound한지, 아니면 이것도 Unifiable<Error>인지 확인.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: (self: State<T>, fn: (self: State<T>) -> T) -> State<T>, -- U 없음, 항상 같은 T
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local doubled = n:Compute(function(x) return x:Get() * 2 end)
local wrong: string = doubled:Get() -- doubled가 State<number>면 여기서 에러여야 함
local right: number = doubled:Get()
print("33 done", wrong, right)

View file

@ -0,0 +1,23 @@
--!strict
-- 08 스파이크(raw value fn, option 2)도 Compute가 State<U>를 반환하는 한
-- 같은 문제(differing recursion)를 겪는지 확인 -- LHS 명시 주석을 빼고
-- 진짜 추론 결과를 봄.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (T) -> U) -> State<U>, -- raw value 계약(옵션 2)
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- LHS 주석 없이 raw value 콜백으로 Compute
local s = n:Compute(function(v: number): string return tostring(v) end)
local wrong: number = s:Get() -- s가 State<string>이어야 하므로 에러여야 함
local right: string = s:Get()
print("34 done", wrong, right)

View file

@ -0,0 +1,20 @@
--!strict
-- 마지막 분리: "재귀"가 핵심 원인인지, 아니면 "로컬 제네릭 U를 아무
-- 컨테이너로든 감싸서 반환하면" 다 이 모양인지 확인. Box<U>는 자기 자신을
-- 재참조하지 않는 평범한 제네릭 컨테이너(Compute 필드 없음) -- 여기서도
-- Unifiable<Error>가 뜨면 "재귀"는 무관하고 "제네릭 U를 감싸서 반환"
-- 자체가 원인.
type Box<A> = { value: A }
local function Compute<T, U>(self: Box<T>, fn: (self: Box<T>) -> U): Box<U>
return { value = fn(self) }
end
local box: Box<number> = { value = 5 }
local s = Compute(box, function(x) return tostring(x.value) end)
local wrong: number = s.value -- s가 Box<string>이어야 하므로 에러여야 함
local right: string = s.value
print("35 done", wrong, right)

View file

@ -0,0 +1,9 @@
--!strict
type StateA<T> = {
__inner: T,
Compute: <Target,Self>( self: Self, func: (self: Self)->any ) -> Target,
}
local t = (nil :: any) :: StateA<number>
local a = t:Compute<StateA<boolean>>(function(self)
return nil
end)

View file

@ -0,0 +1,15 @@
--!strict
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
local s = n:Compute<string>(function(x) return tostring(x:Get()) end)
local wrong: number = s:Get()
local right: string = s:Get()
print("37 done", wrong, right)

View file

@ -0,0 +1,9 @@
--!strict
export type StateB<T> = {
__inner: T,
Compute: <U>( self: StateB<T>, func: (self: StateB<T>)->any ) -> U,
}
local t = (nil :: any) :: StateB<number>
local mapped = t:Compute<<StateB<boolean>>>(function(self)
return nil
end)

View file

@ -0,0 +1,9 @@
--!strict
export type StateB<T> = {
__inner: T,
Compute: <U>( self: StateB<T>, func: (self: StateB<T>)->any ) -> U,
}
local t = (nil :: any) :: StateB<number>
local mapped = t:Compute<<ThisTypeDoesNotExist>>(function(self)
return nil
end)

View file

@ -0,0 +1,24 @@
--!strict
-- <<...>>가 진짜 명시적 제네릭 인자 문법인지, 그리고 그걸로 문제 B
-- (Compute의 State<U> 반환 불안전성)를 우회할 수 있는지 결정적으로 확인.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- 명시적으로 U=string이라고 못박음
local s = n:Compute<<string>>(function(x) return tostring(x:Get()) end)
local wrong: number = s:Get() -- 진짜 sound하면 여기서 에러여야 함
local right: string = s:Get()
print("40 done", wrong, right)

View file

@ -0,0 +1,20 @@
--!strict
-- <<...>>가 문제 B는 못 고쳤음. 그럼 문제 A(파라미터 self 추론)는
-- 고쳐주는지 확인 -- 쪼개기 없이 원본 재귀 타입 그대로, 명시 제네릭
-- 인자만 주는 방식으로 무주석 self 파라미터가 통과하는지.
export type State<T> = {
Get: (self: State<T>) -> T,
Compute: <U>(self: State<T>, fn: (self: State<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local state: State<number> = fakeState(0)
-- 쪼개기 없이, self는 여전히 State<T>(재귀), 대신 명시 제네릭 인자 시도
local doubled = state:Compute<<number>>(function(s) return s:Get() * 2 end)
print("41 done", doubled)

View file

@ -0,0 +1,13 @@
--!strict
-- <<...>>가 일반적인(재귀 아닌) 제네릭 함수에는 진짜 작동하는지 sanity
-- check -- 이게 되면 문법/배선 자체는 살아있고, 문제 B는 정말 재귀
-- 케이스에만 있는 별개의 버그라는 뜻.
local function identity<T>(v: T): T
return v
end
local x = identity<<string>>(5 :: any) -- 명시적으로 string이라고 우김(5는 any로 캐스트해서 통과시킴)
local wrong: number = x -- 진짜 explicit 타입이 먹혔으면(x: string) 여기서 에러여야 함
print("42 done", wrong)

View file

@ -0,0 +1,26 @@
--!strict
-- 결정적 확인: LHS 명시 주석이 "RHS를 검증"해주진 않지만(28에서 확인),
-- 최소한 "다운스트림에 올바른 타입을 바인딩"은 해주는가?
-- 이게 되면 "명시적 타입 바인딩 강제"가 실효성 있는 대응책이 됨.
export type StateData<T> = {
Get: (self: StateData<T>) -> T,
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
}
local function fakeState<T>(v: T): State<T>
return (nil :: any) :: State<T>
end
local n: State<number> = fakeState(5)
-- LHS에 명시 주석
local s: State<string> = n:Compute(function(x) return tostring(x:Get()) end)
-- 다운스트림에서 s가 진짜 State<string>으로 취급되는가?
local ok: string = s:Get() -- 맞는 사용 -- 통과해야 함
local bad: number = s:Get() -- 틀린 사용 -- 에러나야 함(다운스트림 바인딩이 살아있다면)
local bad2 = s:NoSuchMethod() -- 없는 메소드 -- 에러나야 함
print("43", ok, bad, bad2)

View file

@ -1250,12 +1250,13 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"(
### trailing deps를 `fn`에 lazy positional 인자로도 노출 — 방향+순서(`fn(self, previous?, ...deps)`) 확정, 이형 다중 deps 표현 가능 여부만 실측 필요 (2026-08-11 후속)
> **⚠️ [2026-08-13 3차 감사에서 발견] 이 절도 아래 "self도 lazy 핸들로
> 통일"과 같은 계약(`question.md` **0-Y**) 위에 직접 얹혀 있음** — self가
> lazy `State` 핸들이라는 전제가 흔들리면 trailing deps를 같은 방식으로
> 노출한다는 이 절의 결론도 같이 흔들림. 0-Y 배너는 이 문서 아래쪽(이
> 절보다 한참 뒤)에 있어 위에서부터 읽으면 이 절을 확정으로 오인하기
> 쉬움 — 실제 배너는 "self 인자도 lazy 핸들로 통일" 절 참고.
> **[2026-08-13 열세 번째 세션, 해소]** 이 절이 얹혀 있던 "self도 lazy
> 핸들로 통일" 계약(구 `question.md` 0-Y)이 **그대로 유지로 확정**됨 —
> 전제가 안 흔들리므로 이 절의 결론도 유효. 다만 이 절이 남겨둔 실측
> 항목(이형 다중 deps를 제네릭 팩으로 표현 가능한지)은 **여전히
> 미검증**임: 그 스파이크(`15`)가 파싱 실패 상태라 재작성이 필요하고,
> 재작성해도 반환 타입 쪽은 `base/typing-limits.md` 1번 한계에 똑같이
> 걸림(명시 주석 바인딩으로 대응).
**문제 제기(사용자)**: `:Compute(fn, a, b, c)`가 이미 `a,b,c`를 trailing
args로 받아 구독을 건다면, 그 값을 `fn(self, a, b, c)`처럼 위치 인자로도
@ -1907,14 +1908,18 @@ Modifier처럼 플래튼하지 않는가"는 설계 근거를 알고 싶은 사
**`:With`/`:Compute` — self 인자도 lazy 핸들로 통일**
> **⚠️ [2026-08-13 첫 실측에서 발견, `question.md` 0-Y] 아래 lazy 핸들
> 계약이 Luau 양방향 추론과 충돌함이 확인됨.** 가장 흔한 관용구
> (`state:Compute(function(s) return s:Get() * 2 end)`)가 타입 에러를
> 낸다 — 콜백이 raw 값을 받는 형태면 완전히 클린하다는 것까지 최소
> 재현으로 확인됨(`luau-test`의 `15-type-compute-trailing-deps-typepack.luau`,
> `audit/luau-test-first-run-2026-08-13.md`). `Effect`/`Observer`/`Animate`/
> `Operator` 등 같은 계약을 공유하는 API 전부에 걸림 — 아래 서술은 M0
> 착수 전 사용자가 확정해야 할 미해결 사안이지 확정된 계약이 아님.
> **[2026-08-13 열세 번째 세션, 해소 — 아래 계약은 그대로 확정]**
> 한때 이 계약이 Luau 추론과 충돌한다며 `question.md` 0-Y로 열려 있었고,
> "콜백이 raw 값을 받으면 완전히 클린"이라는 1차 판정까지 붙어 있었음.
> **44개 스파이크 재실측 결과 그 1차 판정이 뒤집혔음** — raw 값 계약도
> 똑같이 불안전했고, 진짜 문제는 콜백 계약이 아니라 **`Compute`
> `State<U>`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것
> 자체**였음(Luau의 현 한계, RFC가 `Promise<T>.andThen`으로 예시 든 바로
> 그 패턴). **따라서 아래 lazy 핸들 계약은 바꿀 이유가 없고 그대로
> 확정**이며, 콜백 파라미터 추론은 타입 선언을 "데이터부/메소드부"로
> 쪼개면 해결됨. 반환 타입만 사용처에서 명시 주석으로 바인딩하면 됨 —
> 규약 전문은 **`base/typing-limits.md`**, 실측 근거는
> `audit/type-recursion-issue/`.
- 최초안(self 값은 포지셔널 raw 값, with한 값만 클로저로 읽음)에는 실제
단점이 있었음 — self가 raw 값이면 `fn` 호출 전에 항상 self를 먼저
@ -2251,13 +2256,15 @@ vs `[BooleanAttribute "name"]`)뿐 아니라 `None`/`process`/`retract` 동작
## 남은 열린 질문 (`.claude/question.md`에도 취합)
> **⚠️ [2026-08-13 7차 감사 캐비엇] 아래 "전부 확정됨"은 2026-08-13
> **이전** 기준.** 지금 이 문서의 계약 중 **둘이 실제로 열려 있음**
> (1) `question.md` **0-Y**: `:Compute`/`Effect`/`Observer`의 콜백이
> lazy `State` 핸들을 받는다는 커링 계약이 Luau 추론과 충돌(첫 실측에서
> 발견), (2) `question.md` **0-Z**: 이 문서 최상단 ⚠️ 배너가 예고하는
> `hintValue`/`retractFrom` 재-dispatch 모델 교체. 둘 다 "API 표면 이름"이
> **⚠️ [2026-08-13 7차 감사 캐비엇, 13차 세션 갱신] 아래 "전부 확정됨"은
> 2026-08-13 **이전** 기준.** 지금 이 문서의 계약 중 **하나가 실제로
> 열려 있음** — `question.md` **0-Z**: 이 문서 최상단 ⚠️ 배너가 예고하는
> `hintValue`/`retractFrom` 재-dispatch 모델 교체. "API 표면 이름"이
> 아니라 **핵심 계약**이므로, 아래 목록만 보고 "이름만 남았다"고 읽지 말 것.
>
> (여기 같이 적혀 있던 **0-Y**(콜백의 lazy 핸들 계약)는 **해소됨**
> 계약은 그대로 유지로 확정, 남은 건 Luau 자체의 한계라 우리가 할 게
> 없음. `base/typing-limits.md` 참고.)
이 문서의 핵심 설계 질문은 2026-08-04 세 라운드(전파 모델/`:Compute`/State
쓰기 금지/Slot 생존 확인 → dot-access 타입 추론/인스턴스·이벤트 네이밍/

View file

@ -33,9 +33,11 @@ Effect(fn, state?) -> EffectHandle
`state:Observer(...)`를 감싸는 걸로 구현 — `fn`은 포지셔널 인자로 `state`
받고(`fn(state)`, `:Compute``fn(self)` 포지셔널-self 패턴 재사용,
모듈화 목적 — 클로저 캡처 없이 `fn`을 독립적으로 정의/재사용 가능
**[2026-08-13 4차 감사] 이 `fn(state)`가 lazy `State` 핸들을 받는다는
전제 자체가 `question.md` 0-Y로 열려 있음, `Effect`는 0-Y가 명시적으로
지목하는 영향 대상 중 하나**),
**[2026-08-13 13차 세션 해소] 이 `fn(state)`가 lazy `State` 핸들을
받는다는 전제는 확정 유지.** 한때 `question.md` 0-Y가 `Effect`를 영향
대상으로 지목했으나, 실측 결과 **`Effect`는 애초에 무관**했음(자유
함수라 문제의 조건인 "재귀 타입의 필드 + 로컬 제네릭"에 안 걸림) —
`base/typing-limits.md` 1번 "영향 범위" 표 참고),
Observer가 이제 등록 즉시 1회 실행되므로(아래 Observer 절 참고) 그 첫
실행이 "설치"를 겸함. 이후 `state`가 무효화될 때마다 **직전 `fn` 호출이
리턴한 cleanup을 먼저 호출한 뒤 `fn`을 재호출**, 그리고 Effect가 바인드된

View file

@ -170,11 +170,12 @@ raw 값, State면 State 핸들 그 자체(아래 4-1 표의 "State + 함수" 행
없이 그냥 지금 들고 있는 걸 그대로 준다는 원칙 하나로 이 절과 4-1절 표가
전부 설명됨.
> **⚠️ [2026-08-13 3차 감사에서 발견]** 위 "State 핸들 그 자체"/`field:Compute(fn)`
> 위임은 `:Compute`/`:With`의 self-lazy-핸들 계약을 그대로 물려받음 —
> 그 계약 자체가 Luau 추론과 충돌한다는 게 `question.md` **0-Y**로 열려
> 있음(`base/bind-system-plan.md`의 동일 배너). 0-Y 결론에 따라 이 절의
> "State + 함수" 위임 방식도 같이 바뀔 수 있음.
> **[2026-08-13 열세 번째 세션, 해소]** 위 "State 핸들 그 자체"/`field:Compute(fn)`
> 위임은 `:Compute`/`:With`의 self-lazy-핸들 계약을 그대로 물려받는데,
> 그 계약이 **그대로 유지로 확정**됐음(구 `question.md` 0-Y) — 이 절의
> "State + 함수" 위임 방식도 안 바뀜. 단 `field:Compute(fn)`이 반환하는
> 파생 State의 타입은 사용처에서 명시 주석으로 바인딩해야 함
> (`base/typing-limits.md` 1번).
**별도 `func(state) -> state` 인자 모양은 불필요(검토 후 기각).** "여러
Compute를 합치고 싶다"는 동기였는데, 이미 두 가지로 다 커버됨: (1) 여러

View file

@ -6,11 +6,12 @@
참고). 원본: `.claude/initreq/raw-userinput.md` "store는 부작용을 허용함" /
"state는 어떻게 구현하는가" 절.
> **⚠️ [2026-08-13 첫 실측에서 발견, `question.md` 0-Y] 단, 온톨로지 중
> 하나 — self/deps를 lazy `State` 핸들로 넘기는 `:Compute`/`:With` 콜백
> 계약 — 는 "확정"이 아니라 미해결.** Luau 양방향 추론과 충돌함이
> 실측으로 확인됨 — 상세는 `base/bind-system-plan.md`의 동일 배너,
> M0 착수 전 사용자가 확정해야 할 사안.
> **[2026-08-13 열세 번째 세션, 해소]** self/deps를 lazy `State` 핸들로
> 넘기는 `:Compute`/`:With` 콜백 계약은 한때 미해결(구 `question.md`
> 0-Y)이었으나 **그대로 유지로 확정**됨. 남은 것은 quad 설계 문제가
> 아니라 Luau의 현 한계(파생 State의 반환 타입이 정적으로 검증되지
> 않아 사용처에서 명시 주석 바인딩이 필요) — 전역 규약은
> **`base/typing-limits.md`**, 실측 근거는 `audit/type-recursion-issue/`.
## Store는 부작용을 허용하는 게 기본 디자인
@ -194,9 +195,12 @@ State를 만족하도록 만들고, RefSource라는 별도 타입은 폐기**하
`Source`를 참조 안 함) 회피책이 그대로 맞아떨어짐. **다만 좁은 잔여
케이스 하나는 남음**: `State<T>`가 **자기 자신**을 다른 타입 인자로
재귀 참조하면(`Recursive type being used with different parameters`)
막힘 — 이건 아래 논의 대상이던 "두 타입 간 상호 재귀"와는 다른 문제라
별도로 `question.md` **0-Y** 하단에 추적 중(사용자 방향: 구울 때
인라이닝). 아래는 그 판단에 이른 원래 추론 과정(구분 기준 등)이라 계속
막힘 — 이건 아래 논의 대상이던 "두 타입 간 상호 재귀"와는 다른 문제로,
**[2026-08-13 열세 번째 세션 결론] Luau의 현 한계로 확정**되어
`base/typing-limits.md` 1번이 담당함(구 `question.md` 0-Y는 해소).
당시 검토됐던 "구울 때 인라이닝"(T별 코드 생성) 방향은 **채택 안 함**
제네릭 자체를 없애버려 나중에 Luau가 고쳐져도 수혜를 못 받기 때문.
아래는 그 판단에 이른 원래 추론 과정(구분 기준 등)이라 계속
유효한 배경 — `Source<T>``:Compute` 시그니처가 자기 자신(`Source<T>`)과
`State<U>`를 동시에 참조하는 제네릭 메소드라, Luau 솔버가 재귀 타입
조합에서 막히지 않는지가 원래 질문이었음. 구분해서 볼 것:

View file

@ -219,9 +219,9 @@ end
-- Animate(info)는 factory(self) -> State를 반환 — :Apply 전용
-- (2026-08-12 세션 후속 논의로 :Compute 직결에서 정정됨, 아래
-- "왜 `:Apply`로 정정됐는가" 절 참고)
-- [2026-08-13 4차 감사] 아래 selfH:Get()은 :Compute의 self-lazy-핸들
-- 계약에 의존 — 그 계약 자체가 question.md 0-Y로 열려 있음. 0-Y가
-- raw 값 쪽으로 결론나면 이 구현도 selfH:Get() -> selfH로 바뀜.
-- [2026-08-13 13차 세션] 아래 selfH:Get()이 의존하는 :Compute의
-- self-lazy-핸들 계약은 그대로 유지로 확정됨(구 question.md 0-Y 해소)
-- — 이 구현 그대로 유효. base/typing-limits.md 참고.
local function Animate(info)
return function(self)
return self:Compute(function(selfH)
@ -456,19 +456,18 @@ Tween(opts: {
받으므로(다른 모든 핸들러와 동일) 이 문제 자체가 성립하지 않음. 절 자체는
과거 기록으로만 남김, 실행할 내용 없음.
## 열린 질문 — Tween 자체 설계는 전부 해소됨 (단 0-Y는 예외)
## 열린 질문 — 전부 해소됨
**2026-08-12 세션에서 옵션 값 모양/override 정책 이름/릴레이션 슬롯 저장
모양/`Animate` 콤비네이터 시그니처까지 전부 확정됨**, 마지막 남았던 아래
질문도 같은 날 다섯 번째 후속 논의로 확정됨.
> **⚠️ [2026-08-13 7차 감사 캐비엇] "열린 질문 없음"은 *Tween 고유의*
> 설계에 한정.** `Animate``:Apply(factory)`로 꽂히는 콤비네이터이고
> 그 `factory(self)`가 **lazy `State` 핸들을 받는다**는 계약 위에 서
> 있는데, 그 계약 자체가 `question.md` **0-Y**로 열려 있음(위 `Animate`
> 절 코드 주석 참고). 0-Y가 "raw 값 전환"으로 결론나면 `Animate`
> 시그니처와 옵션 resolve 방식이 같이 바뀜 — Tween 값 모양/override
> 정책/북키핑은 그와 무관하게 그대로 유효.
> **[2026-08-13 열세 번째 세션, 해소]** 한때 여기 "0-Y는 예외"라는
> 캐비엇이 있었음 — `Animate`가 얹혀 있는 `factory(self)`의 lazy 핸들
> 계약이 열려 있다는 것이었는데, **그 계약이 그대로 유지로 확정**되어
> `Animate`의 시그니처/옵션 resolve 방식 모두 안 바뀜. 남은 Luau 쪽
> 한계(파생 State의 반환 타입 명시 바인딩 필요)는 `Animate`만의 문제가
> 아니라 전역 규약이므로 `base/typing-limits.md`가 담당.
### 자연 완료(Completed) 시 per-instance 북키핑 — 정리 안 해도 됨 (확정)

View file

@ -0,0 +1,298 @@
# 타입 시스템의 한계와 그 대응 — quad 전역 규약
**이 문서는 "Luau 타입 시스템이 quad 설계에 대해 못 해주는 것"을 한
군데 모은 확정 문서입니다.** 각 한계마다 (a) 정확히 무엇이 안 되는가,
(b) 그래서 우리가 코드/문서에서 뭘 해야 하는가, (c) 언제/어떻게 풀릴
전망인가를 적습니다.
**왜 따로 모으는가**: 이 한계들은 여러 `base/` 문서에 흩어져 각자
캐비엇으로 붙어 있었고, 그러다 보니 (1) 새 API를 설계할 때 같은 벽에
매번 새로 부딪히고, (2) "이건 우리 설계 문제인가 Luau 문제인가"를
매번 다시 판정하고, (3) 나중에 Luau가 고쳐줬을 때 **어디를 되돌려야
하는지** 알 수 없었습니다. 2026-08-13 열세 번째 세션에 가장 큰 한 건
(아래 1번)이 확정되면서 같이 정리했습니다.
---
## 0. 대전제 — Luau의 한계를 우회하려고 타입/API를 비틀지 않는다
**[2026-08-13 확정, 사용자]**
> "지금으로써 quad 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀
> 적합하지 않고, 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈
> 해결의 수혜를 받게 될 때 해결될 이슈로써, 당장 우리가 할 수 있는
> 바 없다."
이 원칙이 아래 모든 항목에 우선합니다. 구체적으로:
- **API의 자연스러운 모양을 타입 사정으로 바꾸지 않는다.** 리액티브
파생값 API가 `Compute<U>(...) -> State<U>` 모양인 건 본질이고,
Luau가 지금 그걸 못 다룬다고 해서 반환 타입을 `any`로 열거나,
API를 다른 모양으로 재설계하거나, 제네릭을 포기하고 타입별 코드를
생성하는 건 **전부 하지 않습니다.**
- **이유는 "게을러서"가 아니라 "그게 더 비싸서"입니다.** 지금 비틀어
놓으면 (a) 비튼 만큼 복잡도와 사용자 인체공학 손해가 영구히
남고, (b) Luau가 고쳐졌을 때 자동으로 수혜를 받는 게 아니라 비튼
걸 되돌리는 별도 마이그레이션이 필요해집니다. 아래 1번의 실측이
이걸 구체적으로 보여줍니다 — **지금 그대로 두면 Luau 쪽 수정만으로
코드 변경 없이 풀립니다.**
- **대신 반드시 하는 것**: (1) 한계를 이 문서에 명시, (2) 사용자
코드가 취해야 하는 관례를 명시, (3) 추적 링크(RFC/이슈)를 남겨
나중에 되돌릴 지점을 알 수 있게 함.
**예외**: 그 한계를 우회하는 방법이 *비트는 게 아니라 그냥 더 나은
설계*인 경우엔 당연히 채택합니다(아래 1번의 "쪼개기"가 실제로 그런
사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가
고쳐져도 손해가 없음).
---
## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐
**실측 근거: `audit/type-recursion-issue/`**(REPORT.md + 재현 스파이크
44개). 이게 이 문서에서 가장 크고, 가장 넓게 영향을 주는 항목입니다.
### 무엇이 안 되는가
`Compute<U>(self: State<T>, fn) -> State<U>`처럼 **자기 자신과 같은
이름의 재귀 타입을, 자기 타입 파라미터(`T`)와 다른 인자(`U`)로 다시
감싸서 반환**하면, 반환 타입이 실제로는 해소되지 않고 Luau 내부의
`Unifiable<Error>`**조용히** 샙니다.
**"조용히"가 핵심입니다** — 컴파일 에러가 나서 막히는 게 아니라,
진단 0건으로 통과한 뒤 그 결과에 대한 타입 체크만 사라집니다:
```lua
local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State<string>이어야 함
local wrong: number = s:Get() -- ❌이어야 하는데 에러 안 남
```
`luau-analyze` / `luau-analyze --annotate` / `luau-lsp`(새 솔버)
세 경로 전부 동일 — 도구 문제가 아니라 Luau 자체의 현 한계입니다.
### 정확한 경계 (오해 방지 — 이것들은 멀쩡함)
실측으로 좁힌 결과 **"제네릭을 감싸서 반환하는 것" 자체는 문제가
아닙니다.** 아래는 전부 정상 작동합니다:
| 패턴 | 상태 |
|---|---|
| 같은 인자로만 재귀(`-> State<T>`, `U` 없음) | ✅ 정상 |
| 재귀 아닌 컨테이너에 담아 반환(`-> Box<U>`) | ✅ 정상 |
| 감싸지 않고 그대로 반환(`-> U`) | ✅ 정상 |
| 콜백 **파라미터**의 타입 추론 | ✅ 정상(아래 "쪼개기" 적용 시) |
| 콜백 **안**의 로직(원본 값을 어떻게 다루는지) | ✅ 정상 |
| 명시 주석 **이후** 다운스트림 전체 | ✅ 정상 |
| **자기 이름을 다른 인자로 감싸 반환**(`State<T>` → `State<U>`) | ❌ **이것만** |
즉 구멍은 정확히 **"그 한 줄이 진짜 그 타입을 만드는가"** 하나로
좁혀집니다.
### 그래서 우리가 하는 것 — ① 명시적 타입 바인딩 강제
**파생 State를 만드는 자리마다 결과 타입을 `:` 주석으로 명시합니다.**
```lua
-- ✅ 이렇게
local label: State<string> = count:Compute(function(c) return tostring(c:Get()) end)
-- ❌ 이렇게 두면 이후 코드 전체의 타입 체크가 사라짐
local label = count:Compute(function(c) return tostring(c:Get()) end)
```
주석이 그 한 줄의 RHS를 검증해주진 않지만(그게 위의 구멍),
**다운스트림에는 정확히 바인딩됩니다** — 실측으로 확인:
잘못된 사용(`local bad: number = label:Get()`)도, 없는 메소드
호출(`label:NoSuchMethod()`)도 정상적으로 에러가 납니다. 그래서
"한 줄만 못 믿고 나머지 코드베이스 전체는 안전"한 상태가 됩니다.
이건 **API 문서/예제/튜토리얼에도 그대로 반영해야 하는 관례**입니다
(사용자가 무주석으로 쓰면 조용히 타입 안전성을 잃으므로) —
`research/documentation-plan.md`/`documentation-content-map.md`가
문서 작성에 들어갈 때 이 관례를 초심자 트랙에 넣을 것.
### 그래서 우리가 하는 것 — ② 타입 선언은 "데이터부/메소드부" 쪼개기
위 1번과 별개로, **콜백 파라미터가 무주석일 때 추론이 안 되는 문제**는
진짜로 풀립니다. 원인은 "로컬 제네릭 `U`를 가진 메소드가 자기를
재귀 참조하는 타입의 필드로 선언돼 있다"는 것이고, 그 필드가 참조하는
self 타입에서 자기 자신(`Compute`)을 빼면 됩니다:
```lua
export type StateData<T> = {
Get: (self: StateData<T>) -> T, -- 자기 자신만 재참조(Compute를 모름)
}
export type State<T> = StateData<T> & {
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>) -> U) -> State<U>,
-- self / 콜백 파라미터 둘 다 StateData<T>를 가리킴
}
```
- 이러면 `state:Compute(function(s) return s:Get() * 2 end)`
**무주석으로 통과**하고, `s`에 대한 타입 체크도 진짜로 살아있습니다
(없는 필드 접근하면 정상적으로 에러남).
- **코드 생성이 필요 없습니다.** `State<T>`는 여전히 진짜 제네릭이고,
손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로
구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을
없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.)
- **캐비엇**: 콜백이 받는 `s``StateData<T>``Compute`/`With`가
없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리
(`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고
`function(self: State<T>)`처럼 파라미터 주석이 필요합니다 —
다만 `:Apply`는 이미 "이름 붙인 재사용 팩토리"를 권장하는 자리라
(`tween-plan.md`의 `Animate`, `research/operator-sugar-plan.md`
`Sum`류) 그런 팩토리는 최상위 함수 선언이라 자연히 주석을 답니다.
### 영향 범위
| API | 파라미터 추론 | 반환 타입 안전성 |
|---|---|---|
| `state:Compute(fn)` | 쪼개기로 해결 | ❌ 명시 바인딩 필요 |
| `state:With(...)` | 쪼개기로 해결(이형 dep 포함) | ❌ 명시 바인딩 필요 |
| `state:Apply(factory)` | factory 파라미터 주석 필요 | ❌ 명시 바인딩 필요 |
| `Effect(fn, state)` | 해당 없음(자유 함수) | 해당 없음(반환이 재귀 타입 아님) |
| `state:Observer(fn)` | 해당 없음(로컬 제네릭 없음) | 해당 없음(`EffectHandle` 반환) |
**`Effect`/`Observer`는 이 문제와 무관합니다** — 한때 0-Y가 "같은
lazy 핸들 계약을 공유하니 같이 걸린다"고 서술했으나 실측 결과 아니었음
(각각 자유 함수라서, 그리고 로컬 제네릭 반환이 없어서).
### 언제 풀리는가 — 지금 그대로 두면 자동으로 풀림
- **Luau RFC**: [`relax-recursive-type-restriction`](https://rfcs.luau.org/relax-recursive-type-restriction.html)
— 완화 근거로 "This pays for itself in the considerable gain in
expressivity gained for users of the type system"을 명시. RFC가
직접 드는 "지금은 거부되는" 예시가 `Promise<T>.andThen:
<U>(self: Promise<T>, callback: (T) -> Promise<U>) -> Promise<U>`로,
**우리 `Compute`와 글자 그대로 같은 모양**입니다.
- **완화 메커니즘은 순수 내부 변경**("타입 별칭을 진짜 type function처럼
취급해 lazy expansion") — **사용자 문법 변경 없음.** 즉 지금 우리가
쓰는 선언 그대로 두면, Luau 쪽이 고쳐지는 순간 **코드 변경 없이**
올바르게 풀립니다.
- **추적**: [`luau-lang/luau#2380`](https://github.com/luau-lang/luau/issues/2380)
("Allow recursive generic types to differ", 2026-08-13 기준 열려
있음). 이게 닫히면 이 절의 ①(명시 바인딩 강제)을 재검증하고,
불필요해지면 관례를 풀 것.
- **참고 — 옛 솔버는 이 패턴을 선언 시점에 거부**했습니다(`--solver=old`).
새 솔버는 선언을 받아주지만 위처럼 조용히 새는 중간 상태입니다.
즉 RFC의 완화가 **선언 검사까지는 들어왔고 인스턴스화까지는 아직**인
것으로 보입니다.
### 미리 대비해 둘 것 — 없음
RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이므로,
"미래에 자연히 등록되도록 플레이스홀더를 심어두는" 종류의 작업은
**필요 없다는 게 실측 결론**입니다. 오히려 지금 뭔가 심어두는 게
0번 대전제 위반입니다.
---
## 2. `Modifier.Overridden`의 서브타입 합성은 정적 체크 포기
**근거: `luau-test/done/09-type-modifier-overridden-subtype.luau`**
`FrameModifier``GuiObjectModifier`의 서브타입이어야 자연스러운데,
필드 setter 메소드의 반환 타입이 각각 자기 자신이라 같은 이름 필드끼리
반환 타입이 갈려 구조적 서브타이핑이 깨집니다 — 실측으로 재현 확인.
**우리가 하는 것**: `Modifier.Overridden`의 시그니처를 `(...: any): any`류로
느슨하게 열어 정적 체크를 포기(fallback이 정상 작동함도 같이 확인됨).
상세는 `base/modifier-plan.md` 9-2번 절.
---
## 3. `AttributeKey<<T>>` 제네릭 키의 값 타입 narrowing은 안 됨
**근거: `luau-test/done/12-type-attribute-generic-key-narrowing.luau`**
`[AttributeKey<<T>> "name"] = value`에서 `T`가 이름별로 고정되지 않고
호출마다 독립 추론돼 narrowing이 전혀 강제되지 않음 — 실측으로 "안 됨"
확정.
**우리가 하는 것**: `base/attribute-plan.md`가 이미 예비해둔 fallback을
채택 — 정적 체크가 필요하면 `BooleanAttribute` 같은 **타입 패밀리**가
유일하게 믿을 수 있는 경로.
---
## 4. `Source(default)`/`Ref(default)`의 nilable 캐비엇은 타입으로 못 막음
**근거: `luau-test/done/14-type-nilable-default-overload.luau`**
`default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을 함수
오버로드(교차 타입)로 막으려던 스케치는, 의도한 오용은 정확히 막지만
**정상 nilable 사용례까지 같이 막아** 채택 불가.
**우리가 하는 것**: 타입으로 강제하지 않고 문서 경고(UB)로 유지 —
`base/bind-system-plan.md`의 해당 절. 새 설계 결정이 필요한 항목은
아님(대안이 이미 존재).
---
## 5. `store.key` 레코드 필드 타이핑(`type function`)은 미검증
**근거: `luau-test/rewrite-required/16-type-store-key-typefunction.luau`**
`Store<T>``{[K]: Source<V>}` 합성을 Luau `type function`으로 하는
설계는 **설계 레벨로는 확정**(`pre-implementation-audit.md` 1-10)이지만,
스파이크가 `types.newfunction` 시그니처 불일치로 깨져 **실측 확인이 안
된 상태**입니다.
**우리가 하는 것**: 스파이크 재작성 후 재시도(에이전트 몫,
`luau-test/STATUS.md` 🟠). `type function`은 비교적 최근/진화 중인
기능이라 버전에 따라 API가 다를 수 있음.
---
## 6. 성립이 확인된 것 (안심해도 되는 것)
한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다.
아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것:
- **`Source<T>``State<T>`를 구조적으로 만족**(서브타입으로 그대로
넘길 수 있음) — `luau-test/review-required/08`. 단 **단방향 의존을
유지해야 함**(`State<T>`가 `Source`를 참조하면 안 됨 — 두 제네릭
별칭의 상호 재귀는 솔버가 취약한 패턴).
- **`PreRef<T>``Ref<T>` 자리에 대입 가능** — `luau-test/13` A섹션.
- **Modifier의 제네릭 `__index` + `table.clone` 체이닝**`luau-test/done/17`.
- **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜
살아있음.
---
## 7. 새 타입/API를 설계할 때 체크리스트
1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo<T>` 안에서
`-> Foo<U>`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제),
명시 바인딩 관례를 문서에 같이 적을 것.
2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 1번의
"쪼개기"를 적용할 것(`XxxData<T>` / `Xxx<T>` 분리).
3. **제네릭 키로 값 타입을 좁히려 하는가?** → 3번, 안 됨. 타입
패밀리를 쓸 것.
4. **서브타입 관계인 두 타입을 합성하려 하는가?** → 2번, 메소드 반환
타입이 갈리면 깨짐.
5. **타입으로 오용을 막으려 하는가?** → 4번 사례처럼 정상 사용례까지
막는 경우가 흔함. 막기 전에 정상 사용례를 반드시 같이 테스트할 것.
6. **위 어디에도 안 걸리는데 안 되는 것 같다**`luau-test/`
스파이크를 추가하고 실측할 것. **추론만으로 "된다/안 된다"를
확정하지 말 것** — 이 문서의 항목 중 여러 개가 "된다고 믿었다가
실측에서 뒤집힌" 것들입니다.
> **실측 방법 주의**: `luau-analyze`가 진단 0건이어도 타입이 제대로
> 해소됐다는 뜻이 아닙니다(1번이 정확히 그 사례). **`luau-analyze
> --annotate`로 추론된 실제 타입을 눈으로 확인**하고, 가능하면
> "일부러 틀린 타입에 대입해서 진짜 에러가 나는지" 음성 대조군을
> 같이 둘 것.
---
## 8. 미해결 / 추적 중
- **에디터(`luau-lsp`)의 솔버 설정**`luau-analyze` CLI는 새 솔버가
기본값이지만 `luau-lsp`**옛 솔버가 기본값**(`LuauSolverV2=false`)이라
같은 코드에 다른 진단이 나옵니다. 새 솔버로 맞추려면
`"luau-lsp.fflags.enableNewSolver": true`. **M0 실착수 때 실제 에디터
환경에서 확정할 것** — 옛 솔버는 1번 패턴을 아예 거부하므로 사실상
새 솔버 외에 선택지가 없어 보이지만, 실환경에서 확인 필요.
- **5번(`store.key` type function)** — 스파이크 재작성 후 실측.
- **`luau-lang/luau#2380`** — 닫히면 1번 관례 재검증.

View file

@ -10,7 +10,7 @@
| 폴더 | 뜻 | 누가 처리 |
|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(현재 `08` 하나) | ⭐ 사용자 |
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 |
| `rewrite-required/` | 스파이크 코드가 깨짐(설계 문제 아님, `13`/`15`/`16`) | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용 `10` + GC 헬퍼) | 사용자 or MCP 연결 후 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음(15개) | — |

View file

@ -1,19 +1,19 @@
# 스파이크 상태판 — **폴더가 곧 상태**
> 마지막 갱신: 2026-08-13 아홉 번째 세션(폴더 재편).
> 마지막 갱신: 2026-08-13 열세 번째 세션(`08` 해소 → `done/`, `review-required/` 비워짐).
> 첫 실측은 여섯 번째 세션 — 상세 결과는 `.claude/audit/luau-test-first-run-2026-08-13.md`.
> 실행법: `luau <파일>` (런타임) / `luau-analyze <파일>` (타입 전용).
**사람이 볼 게 있는 건 `review-required/` 하나뿐입니다.** 나머지는
에이전트가 처리할 일(`rewrite-required/`)이거나, Studio가 필요한 일
(`not-run/`)이거나, 끝난 일(`done/`)입니다.
**[2026-08-13 열세 번째 세션] `review-required/`가 비었습니다** — 마지막
한 건이던 `08`이 해소돼 `done/`으로 갔습니다. 지금 남은 건 에이전트가
처리할 일(`rewrite-required/`)과 Studio가 필요한 일(`not-run/`)뿐입니다.
| 폴더 | 뜻 | 개수 | 누가 처리 |
|---|---|---|---|
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | 1 | ⭐ 사용자 |
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
| `rewrite-required/` | 스파이크 코드가 깨짐(설계 문제 **아님**) | 3 | 에이전트 |
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 1(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 15 | — |
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
@ -21,17 +21,21 @@
---
## ⭐ `review-required/` — 사람 결정 필요 (1건)
## ⭐ `review-required/` — 사람 결정 필요 (0건, 비어 있음)
| 파일 | 무엇이 걸렸나 | 어디로 |
|---|---|---|
| `08-type-source-satisfies-state.luau` | 핵심 질문(Source⊇State)은 **통과**. 다만 `State<T>`**자기 자신**을 다른 타입 인자로 재귀 참조하면 `Recursive type being used with different parameters` — 사용자 방향은 "구울 때 인라이닝" | `question.md` **0-Y** 하단 |
**[2026-08-13 열세 번째 세션] 마지막 한 건이 해소됐습니다.**
`08-type-source-satisfies-state.luau`가 남겨뒀던 잔여 케이스(`State<T>`가
자기 자신을 다른 타입 인자로 재귀 참조하면 막힘)는 **Luau의 현 한계로
확정**되어 quad가 설계로 풀 대상이 아님이 정해졌고(구 `question.md`
0-Y 해소), 스파이크는 `done/`으로 이동했습니다. 당시 검토됐던 "구울 때
인라이닝" 방향은 **채택 안 함**.
`15``:Compute(fn)` lazy 핸들 계약 충돌(**`question.md` 0-Y** 본 항목)도
같은 종류의 사람 결정 사안이지만, **스파이크 자체가 파싱 실패라 그 결과를
신뢰할 수 없어** `rewrite-required/`에 둠. 재작성해서 돌아가면 이 폴더로
승격할 것. 단 **0-Y 판단 자체는 그걸 기다릴 필요 없음** — 계약 충돌은 이미
별도 최소 재현으로 확인됨(`audit/luau-test-first-run-2026-08-13.md`).
- 지금 유효한 규약: **`base/typing-limits.md`**
- 실측 근거 전문(스파이크 44개 포함): `audit/type-recursion-issue/`
`15`도 같은 계약을 다루지만 **스파이크 자체가 파싱 실패**라
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
아님(계약 자체는 위에서 이미 확정됨).
## 🟠 `rewrite-required/` — 스파이크가 깨짐, 설계 문제 아님 (3건)
@ -48,7 +52,7 @@
| `10-roblox-studio-checks.server.luau` | **Studio 전용**(`luau` CLI로 못 돌림). A 섹션 앞부분만 사용자 자작 스크립트로 실측 — `audit/gcconn-trick-verification.md`. **A-1/A-2(`canBound` 게이트)/B/C는 여전히 미확인** |
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
## ✅ `done/` — 통과 or 판정 끝 (15건)
## ✅ `done/` — 통과 or 판정 끝 (16건)
**런타임 12개 전원 통과**(crash 0 / FAIL 0):
@ -71,6 +75,7 @@
| 파일 | 판정 |
|---|---|
| `08-type-source-satisfies-state` | ✅ 핵심 질문(Source⊇State 구조적 서브타이핑) 통과. 잔여 케이스(자기 이름을 다른 인자로 재귀 참조)는 **[2026-08-13 13차 세션] Luau 현 한계로 확정** — quad가 풀 대상 아님, `base/typing-limits.md` 1번 |
| `09-type-modifier-overridden-subtype` | ✅ 통과 — 문서가 우려한 `FrameModifier`↔`GuiObjectModifier` 서브타입 깨짐이 그대로 재현, fallback(`any`)은 정상 |
| `12-type-attribute-generic-key-narrowing` | ❌지만 **설계 영향 없음** — 제네릭 키 narrowing이 안 되는 건 `attribute-plan.md`가 이미 fallback으로 예비해둔 결과(타입 패밀리가 유일하게 믿을 경로) |
| `14-type-nilable-default-overload` | ⚠️ 부분 — 의도한 오용은 막지만 정상 nilable 사용례까지 막아 현 스케치로는 채택 불가. **설계 결정은 아직 필요 없음**(대안이 이미 UB 경고로 존재)이라 `review-required`가 아님 |

View file

@ -12,59 +12,19 @@
---
## ⭐ 최우선 — M0 착수를 막고 있음 (2건)
## ⭐ 최우선 — M0 착수를 막고 있음 (1건)
둘은 사용자가 **직접 스케치하며 판단하겠다고 명시 이관**한 항목이라
항목은 사용자가 **직접 스케치하며 판단하겠다고 명시 이관**한 것이라
에이전트가 기본값으로 밀고 갈 수 없음. 루트 `HUMAN_TODO.md` 4번에도 있음.
### 0-Y. ⭐ **최우선(신설) — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가** (2026-08-13 여섯 번째 세션, 첫 실측에서 발견)
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`(`:Compute(fn)`의
> lazy 핸들 계약)는 해소됨** — 실측 결론은 "계약은 그대로 두고, 파생
> State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩한다. 그 외는
> Luau의 현 한계라 지금 우리가 할 수 있는 바 없다". 지금 유효한 규약은
> **`base/typing-limits.md`**, 실측 근거 전문은
> `audit/type-recursion-issue/`, 해소 전 원문은
> `archive/question-resolved.md`의 0-Y 절.
**실측 결과**: 콜백이 lazy `State<T>` 핸들을 받는 quad의 커링 계약이
**Luau 양방향 타입 추론과 충돌**함. 가장 흔한 관용구가 그대로 실패:
```lua
state:Compute(function(s) return s:Get() * 2 end) -- ❌ TypeError 2건
```
최소 재현으로 원인을 좁힘(`audit/luau-test-first-run-2026-08-13.md`):
| 형태 | 결과 |
|---|---|
| lazy 핸들 + 무주석 인라인 람다 | ❌ |
| 위 + `read Get` 선언 | ❌ |
| 위 + `Get: (self: State<T>) -> T` | ❌ |
| 콜백 파라미터에 타입 주석 | ✅ |
| **콜백이 raw 값 `T`를 받으면** | ✅ **완전 클린** |
즉 표기 조정으로는 안 풀리고, **계약 자체가 원인**. `:Compute`만이
아니라 `Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는
API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정)과
2026-08-12 네 번째 세션(`:Get()` 누락 버그 전역 감사)이 전부 이 계약
위에 서 있음.
**선택지**:
1. **계약 유지 + 파라미터 주석 필수** — 설계 변경 없음, 대신 가장 흔한
자리에 매번 `function(s: State<number>)`를 써야 하는 인체공학 손해.
2. **콜백이 raw 값을 받도록 전환** — 타입 추론은 완벽해지지만 lazy
평가/trailing deps/`previous` 설계 전반과 충돌. **구조 변경 규모가 큼**
(사용자가 이번 라운드에서 미리 잡고 싶다고 한 바로 그 종류).
3. **혼합** — 기본은 raw, lazy가 실제로 필요한 자리만 별도 API.
**M0 착수 전에 정하는 게 맞음** — 2번을 고르면 base 문서 다수가 영향받음.
**[사용자 방향, 2026-08-13]** 순환 타입을 만드는 것보다 **`State` 타입
자체를 구울 때(코드 생성 시점) 인라이닝**해주는 쪽이 맞아 보인다는 의견 —
`State<T>`가 자기 자신을 재귀 참조하는 선언을 피하고, 타입 생성기가
`T`에 대해 평탄한 타입을 뽑아주는 방향(`Modifier`의 클래스별 flat 타입을
생성기로 뽑는 이미 확정된 패턴과 같은 결). 이러면 `08`에서 걸린
`Recursive type being used with different parameters` 제약도 같이 비켜감.
**다만 이건 사람이 직접 확인해야 할 부분으로 사용자가 보류** — 0-Z(Attribute)와
함께 사용자가 직접 스케치하며 판단할 목록.
**관련 실측 근거**: `08`(재귀 타입 제약), `15`(read-only/read-write `Get`
불일치 — 파싱 실패로 검증불가 상태라 재작성 필요), `14`(Ref 생성자 오버로드가
정상 nilable 사용례까지 막음), `16`(`type function` API 불일치). 전부
`audit/luau-test-first-run-2026-08-13.md`.
### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관)
**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의

View file

@ -7,11 +7,12 @@
함수를 지우고 고쳐도 안전 — 우선순위가 낮은 이유. **지금 이 문서를 쓰는
목적은 구현 착수가 아니라 설계/네이밍 논의를 미리 남겨두는 것뿐.**
> **⚠️ [2026-08-13 4차 감사에서 발견]** 아래 모든 `Operator.*` 예시가
> `h:Get()`(self-lazy-핸들 계약)에 의존 — 그 계약 자체가 `question.md`
> **0-Y**로 열려 있음(`Effect`/`Observer`/`Animate`/`Operator`가 전부
> 같은 계약 공유 대상으로 명시됨). 우선순위가 어차피 최하위라 지금
> 당장 막는 건 없지만, 나중에 착수 시점엔 0-Y 결론부터 확인할 것.
> **[2026-08-13 열세 번째 세션, 해소]** 아래 모든 `Operator.*` 예시가
> 의존하는 `h:Get()`(self-lazy-핸들 계약)은 **그대로 유지로 확정**됨
> (구 `question.md` 0-Y) — 예시를 고칠 필요 없음. 나중에 착수할 때는
> `base/typing-limits.md`(특히 7번 "새 타입/API를 설계할 때 체크리스트")를
> 먼저 볼 것 — `Operator.*`가 반환하는 파생 State도 사용처에서 명시
> 주석 바인딩이 필요한 대상임.
## 동기

View file

@ -362,7 +362,9 @@ State를 구조적으로 만족)이 통과했음 — 아래 "검증이 실패했
전제 자체가 (핵심 케이스에 한해) 더 이상 미래형이 아님. 다만 통과와
별개로 좁은 잔여 케이스(`State<T>`가 자기 자신을 다른 타입 인자로
재귀 참조하는 경우, `Recursive type being used with different
parameters`)가 하나 발견돼 `question.md` **0-Y** 하단에서 추적 중 —
parameters`)가 하나 발견됐고, **[2026-08-13 열세 번째 세션] 그건
Luau의 현 한계로 확정되어 `base/typing-limits.md` 1번이 담당**함
(구 `question.md` 0-Y는 해소 — quad가 설계로 풀 대상이 아님).
이건 "검증 실패 시 Plan B 없음"과는 다른 종류의 문제(전면 실패가
아니라 narrow edge case)라 아래 원래 제안(M0 스파이크에 폴백 한 줄
박아두기)은 더 이상 적용 대상 없음. 원래 서술은 배경 기록으로 남김:

View file

@ -0,0 +1,236 @@
# 2026-08-13 열세 번째 세션 — 0-Y 재실측, 재귀 제네릭 반환 타입은 Luau 상위 한계로 확정
**한 줄 요약**: `question.md` 0-Y(`:Compute(fn)`의 lazy 핸들 계약)를
44개 스파이크로 재실측했더니 **여섯 번째 세션의 1차 판정("콜백이 raw
값을 받으면 완전 클린")이 틀렸음**이 드러났고, 진짜 원인은 콜백 계약이
아니라 **Luau가 재귀 제네릭의 다른 인자 반환을 못 다루는 것**으로
확정 — 계약은 그대로 유지, `base/typing-limits.md` 신설로 전역 규약화.
---
## 1. 발단 — 사용자가 직접 부딪혀본 흔적에서 시작
사용자가 레포 루트에 `test-ignoreme.luau`를 만들어 0-Y를 직접 파보고
있었음(try1~sub-try6: 명시 제네릭 인자, `Self` 자유 제네릭, `type
function`으로 State 자체를 만들기, `Echo<Self>` 지연 평가 등). 요청:
> "0-Y에 대해 가능한 모든 걸 시도해봐. try-ignoreme 폴더 만들고 거기
> 안에서 해. luau의 진짜 작동 방식을 봐야 하면 클론해도 좋아."
`luau`/`luau-analyze`가 처음엔 PATH에 없었는데 사용자가 env를 갱신해줘
사용 가능해짐. grounding용으로 `luau-lang/luau` HEAD(`c73bb37`)를 clone,
나중에 교차검증용으로 `luau-lsp` 1.69.0 바이너리도 받음.
## 2. 1차 결론 — 쪼개기로 풀었다고 판단 (나중에 절반만 맞았음이 드러남)
실험 `00`~`25`로 원인을 좁힘:
- **2×2 매트릭스**(`02`~`04`): T가 제네릭인지는 무관, **`Compute`가 자기
로컬 제네릭 `U`를 가진 채 재귀 타입의 필드로 선언돼 있다는 것**이 원인.
- `:` 메소드 문법은 무관(`05`), 자유 함수로 빼면 재귀가 있어도 통과
(`06`/`07`/`10`), 리턴 주석만으론 안 풀림(`09`/`12`), self를 자유
제네릭으로 열어도 안 됨(`13`).
- **회피책 발견**(`14`): `State<T>`를 "Get만 있는 `StateData<T>`"와
"`Compute` 등을 얹은 `State<T>`"로 쪼개고 메소드의 self/콜백 파라미터를
`StateData<T>`로 가리키게 하면 무주석 람다가 통과.
- 스트레스 테스트(`19`/`23`/`24`)도 통과 — 체이닝, `State<State<T>>`,
`Source` 서브타이핑, 이형 `With`까지.
이 시점에 리포트 초안을 썼고, **"쪼개기로 문제가 완전히 풀렸다,
코드 생성도 불필요하다"**고 결론냈음.
### 이 단계에서 낸 방법론 실수 (사용자가 잡아줌)
사용자가 "저거 error-type 가득하더라"고 지적해 `21`을 축소 재현하려
했는데, 그 직전 `cd`로 셸이 clone한 `luau/` 서브레포 안에 들어가 있는
걸 놓쳐서 **`21b`~`21e`가 존재하지 않는 파일을 대상으로 실행**됐음.
`luau-analyze`는 **파일이 없어도 exit 0 + 출력 없음**이라 이걸 "통과"로
오독 — 한동안 "분기가 두 개면 통과, 하나면 실패"라는 엉뚱한 결론을 낼
뻔했음. 사용자가 "23은 멀쩡하네"라고 짚어준 것도 재확인 계기가 됨
(23은 `cd` 전 결과라 실제로 맞았음). 올바른 디렉터리에서 전체 재실행해
바로잡음.
**교훈**: 도구가 "조용히 성공"하는 경로(없는 파일 = exit 0)를 항상
의심할 것. 이게 바로 아래 3절에서 훨씬 큰 스케일로 반복됨.
## 3. 결정적 반전 — 사용자가 체이닝을 더 넣어보고 발견
사용자가 `23` 아래에 직접 한 줄을 더 붙여봄:
```lua
local t = combined:Compute(function(self: StateData<number>)
return true
end)
```
> "t는 error type 나오더라"
CLI로는 클린 통과였는데 에디터 진단이 달랐음. 원인을 쫓다가
**`luau-analyze --annotate`(추론된 실제 타입을 소스에 찍어주는 옵션)**를
써봤고, 여기서 진짜가 드러남:
```lua
local doubled:Unifiable<Error>=state:Compute(function(s:StateData<number>): number ...)
```
**"진단 0건으로 통과"했던 것들이 전부 `Unifiable<Error>`(Luau 내부
미해결/에러 타입)였음.** 즉 타입 체커가 조용히 포기한 상태였고, 겉으로만
성공처럼 보였던 것.
### 얼마나 나쁜지 확인 (`27`/`28`)
```lua
local s = n:Compute(function(x) return tostring(x:Get()) end) -- State<string>이어야 함
local wrong: number = s:Get() -- ❌이어야 하는데 에러 안 남
```
LHS에 일부러 틀린 타입을 명시해도(`28`) 통과. `luau-lsp
--flag:LuauSolverV2=true`로도 동일 재현 — 도구 문제가 아님.
### 어떤 formulation으로도 안 풀림 (`30`/`31`/`34`)
| 실험 | 형태 | 결과 |
|---|---|---|
| `28` | 쪼개기 + 무주석 | 불안전 |
| `30` | 쪼개기 + 콜백 리턴 타입만 명시 | 불안전 |
| `31` | 쪼개기 없이 파라미터/리턴 둘 다 명시(선택지 1) | 불안전 |
| `34` | **raw 값 계약**(선택지 2) | 불안전 |
**`34`가 이 세션에서 가장 중요한 발견** — 여섯 번째 세션 audit이 "콜백이
raw 값을 받으면 완전 클린(0건)"이라고 적어둔 바로 그 케이스인데, 그건
**"에러가 안 뜬다"만 확인한 것**이었고 반환 타입은 확인한 적이 없었음.
`--annotate`로 열어보니 똑같이 `Unifiable<Error>`. **즉 0-Y의 선택지
1/2/3 프레이밍 자체가 잘못된 전제 위에 서 있었음.**
### 정확한 경계 (`32`/`33`/`35`)
대조군으로 좁힌 결과 **"제네릭을 감싸 반환하는 것" 자체는 문제가 아님**:
- `33`: 같은 T로만 재귀(`-> State<T>`) → ✅ 진짜 sound
- `35`: 재귀 아닌 컨테이너(`-> Box<U>`) → ✅ 진짜 sound
- `07`/`32`: 안 감싸고 그대로(`-> U`) → ✅ 진짜 sound
문제는 정확히 **"자기 이름을 자기 타입 파라미터와 다른 인자로 다시
감싸 반환"** 하나뿐.
## 4. 사용자가 RFC를 찾아옴 — 세션 방향이 여기서 정해짐
세션 중간에 사용자가 직접 리서치해 두 RFC를 보내옴:
> "https://rfcs.luau.org/relax-recursive-type-restriction.html,
> https://rfcs.luau.org/recursive-type-restriction.html 를 보면 미래에
> Promise<T>에 대한 andThen과 같은 흔한 유형을 위해 다른 유형 리컬션을
> 미래에 지원할 계획은 있어보임. 따라서 우리는 당장 luau가 타입으로써
> 그것을 제공하기 전까지는 제공 못 한다는 제약사항이 생겨도 될 것
> 같다는 결론이 나왔음."
fetch해보니 정확했음 — RFC가 **거부되는 예시로 직접 드는 게**
`Promise<T>.andThen: <U>(self: Promise<T>, callback: (T) -> Promise<U>)
-> Promise<U>`로, **우리 `Compute`와 글자 그대로 같은 모양**. 완화 근거는
"This pays for itself in the considerable gain in expressivity gained
for users of the type system"이고, 메커니즘은 "타입 별칭을 진짜 type
function처럼 취급해 lazy expansion"이라는 **순수 내부 변경(사용자 문법
변경 없음)**.
옛/새 솔버 대조로 상태도 정확히 파악: 옛 솔버는 이 패턴을 **선언
시점에 거부**, 새 솔버는 **선언은 받아주지만 인스턴스화는 아직 안 됨**
→ 그래서 "조용히 새는" 중간 상태.
## 5. 후속 질문 두 개
사용자 추가 요청:
> "State<T>:Compute<FromState, ToState>() 로써 직접 넣어주는 것.
> 혹은 RFC가 낙관적으로 보는 방향성이 가능하도록 플레이싱 홀드 해놓고
> 나중에 변경될 경우 자연히 등록될 수 있는 방식을 찾고 싶습니다."
### 5-1. 명시 제네릭 인자 — 문법은 실재하나 이 케이스엔 무효
Luau 소스(`Ast/src/Parser.cpp`의 `parseMethodCall`)를 직접 열어 확인:
메소드 호출 뒤 `<`**연달아 둘**(`<<`) 오면 `parseTypeInstantiationExpr`
분기(단일 `<`는 비교 연산자와 충돌해서 안 됨). 이 정보는 새 솔버의
`ConstraintGenerator.cpp`(2818행)에서 실제로 읽혀 `FunctionCallConstraint`까지
전달됨 — 죽은 코드 아님.
- `42`(일반 제네릭 `identity<<string>>(...)`): ✅ 진짜 작동, 틀린 대입도 잡힘
- `40`(우리 `Compute<<string>>`): ❌ 여전히 `Unifiable<Error>`
- `41`(문제 A에 적용): ❌ 완전 무효과
→ **U를 명시로 못박아도 `State<U>` 확장 단계가 안 되므로 무용지물.**
### 5-2. 플레이스홀더 — 필요 없음(좋은 소식)
RFC 완화가 순수 내부 변경이고 우리 선언이 **이미 그 대상 모양 그대로**라,
지금 뭘 심어둘 필요가 없음. 오히려 **하지 말아야 할 것**이 분명해짐 —
한때 검토됐던 "T별 코드 생성(구울 때 인라이닝)"으로 갔다면 제네릭
자체를 없애버려 RFC 수혜 대상에서 스스로 이탈했을 것.
## 6. 사용자 최종 정리 → 확정
> "해당 제한이 풀리기 전까지는 명시적 타입바인딩으로 `:` 사용이
> 강제되나, 그것이 들어오고 난 뒤에는 풀리게 된다. 지금으로써 quad
> 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고,
> 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈 해결의 수혜를 받게
> 될 때 해결될 이슈로써, 당장 우리가 할 수 있는 바 없다."
이 "명시적 타입 바인딩" 대응이 실효가 있는지는 문서화 전에 별도
실측(`43`)으로 확인했고, **먹힘**:
```lua
local s: State<string> = n:Compute(...)
local ok: string = s:Get() -- ✅ 통과
local bad: number = s:Get() -- ✅ 에러남
local bad2 = s:NoSuchMethod() -- ✅ 에러남
```
즉 구멍은 **"그 한 줄이 진짜 그 타입을 만드는가"** 하나로 좁혀지고,
나머지 코드베이스 전체는 정상적으로 타입 안전. 콜백 **안**의 로직도
진짜 체크됨(`29`).
## 7. 이번 세션의 문서 작업
사용자 지시: "try-ignoreme에서 luau 폴더와 luau-lsp-bin, lsp-settings.json,
v.luau를 지우고 `.claude/audit/type-recursion-issue` 등 적절한 폴더 안으로
이동. 또 이 리서치를 기반으로 타이핑 전역에 있어 우리의 한계, 우리가
해두어야 하는 지점을 정리. 전체 문서에 대해 이 사항을 적용."
1. **정리**: clone(24M)/lsp 바이너리(16M)/설정/스크래치 삭제,
`REPORT.md` + `spikes/` 44개를 `.claude/audit/type-recursion-issue/`
이동(`try-ignoreme/`는 제거). audit 폴더에 스크립트를 같이 두는 건
기존 관례의 예외라 그 이유를 문서에 명시.
2. **`base/typing-limits.md` 신설** — 흩어져 있던 타입 한계를 통합
(재귀 제네릭 반환 / Modifier `Overridden` 서브타입 / Attribute 제네릭
키 narrowing / nilable default 오버로드 / `store.key` type function),
0번에 대전제("Luau 한계를 우회하려 타입/API를 비틀지 않는다"), 6번에
"성립이 확인된 것"(오해 방지), **7번에 새 타입/API 설계 시 체크리스트**,
8번에 미해결 추적.
3. **0-Y 해소 전파**: `question.md`에서 제거(최우선 2건→1건),
`archive/question-resolved.md`에 해소 배너, `base/`
(bind-system/modifier/tween/effect/store-semantics) 배너 5곳을 해소
결론으로 교체, `research/`(operator-sugar/pre-implementation-audit) 2곳,
인덱스 레이어(`CLAUDE.md`/`.claude/README.md`/`ROADMAP.md`/`HUMAN_TODO.md`),
`luau-test/STATUS.md`+`README.md`(스파이크 `08``done/`으로 `git mv`,
`review-required/`**비었음**).
4. **가장 중요한 정정**: `audit/luau-test-first-run-2026-08-13.md`
정정 배너 + **본문 표/문단/결론까지** 수정 — 이 문서의 "raw 값 =
완전 클린" 판정이 이번에 뒤집힌 당사자인데, 배너만 달고 본문을 안
고치는 게 CLAUDE.md 체크리스트 2번이 경고하는 바로 그 실패 패턴이라
전수 수정.
5. **HUMAN_TODO 6번 신설**: 에디터(`luau-lsp`)의 기본 솔버가 옛 솔버라
CLI와 진단이 다른 문제 — M0 착수 때 확인(지금 막진 않음).
`doc-check.py` **ERROR 0** 유지 확인.
## 8. 교훈으로 남길 것
- **`luau-analyze`가 진단 0건이어도 타입이 해소됐다는 뜻이 아니다.**
이번 건 전체가 이 함정 하나에서 비롯됨(여섯 번째 세션도, 이번 세션
초안도 같은 함정에 빠졌음). 앞으로 타입 스파이크는 **`--annotate`
추론된 실제 타입을 눈으로 확인하고, "일부러 틀린 타입에 대입해서
진짜 에러가 나는지" 음성 대조군을 같이 둘 것** —
`base/typing-limits.md` 7번에 규칙으로 명문화했음.
- **사용자가 직접 한 줄 붙여본 게 두 번 다 결정적이었음**(체이닝 추가로
`error type` 발견, RFC 검색). 에이전트가 "통과했다"고 보고한 걸 그대로
믿지 않고 만져본 것이 초안의 잘못된 결론을 막았음.
- **도구의 조용한 성공 경로를 의심할 것** — 없는 파일에 exit 0을 주는
`luau-analyze` 동작 때문에 중간에 잘못된 결론을 낼 뻔했음.

View file

@ -57,12 +57,17 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
(`luau <파일>` / `luau-analyze <파일>`). **상태의 소스는 `STATUS.md`**
(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행), 각 파일이 뭘 왜
검증하는지는 `README.md`. 2026-08-13에 첫 실측 완료 — 런타임 12개 전원
통과, 남은 건 Studio 전용 `10`과 코드가 깨진 `13`/`15`/`16`.
통과, 남은 건 Studio 전용 `10`과 코드가 깨진 `13`/`15`/`16`
(`review-required/`는 13차 세션에 비워짐).
- `.claude/audit/`**[2026-08-13 신설]** 스파이크를 실제로 돌린 **실측
결과**만 기록(계획/스크립트 아님). 부분 확인도 있는 그대로 남김 —
`luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체, 0-Y의 근거),
결과** 기록(계획 아님). 부분 확인도 있는 그대로 남김 —
`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/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함,
지금은 비어있음. `.claude/archive/`는 원래 같은 취급이었으나
2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전
@ -153,23 +158,24 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
## 지금 할 일 (우선순위순)
0. **⭐ 최우선 — `.claude/question.md` 0-Y / 0-Z 두 결정.** (`question.md`엔
0. **⭐ 최우선 — `.claude/question.md` 0-Z 결정.** (`question.md`엔
0-B `dispose(any)` 시그니처/범위도 열려 있지만, M6 구현 세부만 막을 뿐
M0 착수 자체는 안 막아서 이 최우선 두 개와 급 다르게 취급 — 별도 항목
M0 착수 자체는 안 막아서 이 최우선 항목과 급 다르게 취급 — 별도 항목
아님.)
**0-Y(신설, 2026-08-13 첫 실측에서 발견): `:Compute(fn)`의 lazy 핸들
계약을 유지할 것인가.** 콜백이 lazy `State<T>` 핸들을 받는 커링 계약이
**Luau 양방향 추론과 충돌**해 가장 흔한 관용구
(`state:Compute(function(s) return s:Get() * 2 end)`)가 타입 에러를 냄.
`read`/`self` 표기 조정으로는 안 풀리고, 콜백이 **raw 값을 받으면 완전
클린**(최소 재현으로 확인, `audit/luau-test-first-run-2026-08-13.md`).
`Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는 API
전부에 걸림 — 선택지는 (1) 계약 유지 + 파라미터 주석 필수, (2) raw 값
전환(**구조 변경 규모 큼**), (3) 혼합. **M0 착수 전에 정할 것.**
사용자 방향: 순환 타입을 만들기보다 **`State` 타입을 구울 때 인라이닝**
하는 쪽(생성기가 `T`별 평탄 타입을 뽑는, `Modifier` flat 타입과 같은 결)
— 다만 사람이 직접 확인할 부분이라 0-Z와 함께 사용자 판단 대기.
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨.**
> `:Compute(fn)`의 lazy 핸들 계약은 **그대로 유지**로 확정. 44개
> 스파이크 재실측 결과 진짜 원인이 콜백 계약이 아니라 **Luau 자체의
> 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이
> 에러 없이 조용히 사라짐)였고, 당시 "raw 값이면 완전 클린"이라던
> 1차 판정도 **뒤집혔음**(raw 값 계약도 똑같이 불안전). 사용자 정리:
> "quad가 타입을 비틀 일이 아니라 상위 Luau의 현 한계이고, RFC/이슈
> 수혜를 받을 때 해결될 일이라 당장 우리가 할 수 있는 바 없다."
> **구현 시 지켜야 할 규약이 생겼으니 M0 착수 전 반드시 읽을 것:
> `base/typing-limits.md`**(핵심은 "파생 State를 만드는 자리마다
> 결과 타입을 명시 주석으로 바인딩" + 7번 설계 체크리스트). 실측
> 근거는 `audit/type-recursion-issue/`, 해소 전 원문은
> `archive/question-resolved.md`의 0-Y 절.
**0-Z: Attribute 이름 소유권 결정.**
2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로
@ -200,9 +206,10 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부
해소되어 **11개 전원 완료** — 이 항목(우선순위1) 기준 남은 유일한
게이트는 아래였음(**[2026-08-13 4차 감사 정정] 위 0번 항목의 0-Y/0-Z가
이후 같은 날 발견돼 실제로는 게이트가 하나 더 있음 — "유일한"은 그
발견 전 서술이 안 갱신된 stale, 0-Y/0-Z가 최우선으로 먼저 해소돼야
함**):
이후 같은 날 발견돼 실제로는 게이트가 하나 더 있었음 — "유일한"은 그
발견 전 서술. **[13차 세션 재정정] 그중 0-Y는 해소됐고 지금 남은
게이트는 0-Z 하나**, 다만 0-Y가 남긴 구현 규약
`base/typing-limits.md`는 M0 착수 전에 읽어야 함**):
- **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크
결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].**
**상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 /
@ -212,9 +219,10 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
특히 `07`이 연쇄 GC를, `18`이 두-`Relate` 상호 순환 미해제를 실측
확정해 GC-native 아키텍처의 핵심 전제가 검증됨. `04`는 같은 세션
감사가 찾은 `chains:SetStrong` 순서 버그를 음성 대조군으로 재현.
- **타입 쪽에서 하나가 걸림** → 그게 위 **0-Y**. 나머지 타입 스파이크는
판정 완료(`09` 통과, `12`는 실패지만 문서가 이미 fallback으로
예비해둔 결과라 설계 영향 없음, `14`는 부분).
- **타입 쪽에서 하나가 걸렸었음** → 그게 구 **0-Y**, **[13차 세션]
해소**(Luau 현 한계로 확정, `base/typing-limits.md`). 나머지 타입
스파이크는 판정 완료(`08`/`09` 통과, `12`는 실패지만 문서가 이미
fallback으로 예비해둔 결과라 설계 영향 없음, `14`는 부분).
- **남은 것은 셋뿐**: (1) `10`은 **Studio 전용이라 `luau` CLI로 못
돌림** — A 섹션 앞부분만 사용자 자작 스크립트로 부분 확인
(`audit/gcconn-trick-verification.md`), A-1/A-2/B/C 미확인.
@ -222,8 +230,10 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
(설계 문제 아님 — 각각 더미 스텁 도달불가 / 음성 대조군이
`SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0
실제 코드 작성에 재사용.
- **[주의] "설계가 더 이상 안 막혔다"는 이 실측 *이전* 서술이었음** —
실측이 0-Y를 새로 열었으므로 지금은 위 0번 항목이 먼저다.
- **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에
`08``done/`으로 가며 `review-required/`가 비었음. **개수의 소스는
항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 건 0-Z 하나이고,
0-Y가 남긴 규약(`base/typing-limits.md`)은 착수 전 필독.
- 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된
`rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯
번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님.
@ -1059,3 +1069,27 @@ research/reference/luau-test/archive 전체는 정합성 문제 없음 확인
`archive/`(README.md 색인 18개와 실제 디렉토리 대조) 전체를 순서대로
직접 정독, 알려진 재발 패턴("6개 문서" stale, "8차 세션" 오표기) grep
재확인. **새로 발견된 부정확성 0건** — 순수 검증 라운드로 종료.
**2026-08-13 열세 번째 세션 — 0-Y 해소: 재귀 제네릭 반환은 Luau 상위 한계로 확정, `base/typing-limits.md` 신설**
(`session/2026-08-13-13-type-recursion-limit-resolved.md`)
사용자가 직접 파보던 흔적(`test-ignoreme.luau`)에서 출발해 44개 스파이크로
0-Y를 재실측 — **여섯 번째 세션의 "콜백이 raw 값을 받으면 완전 클린"
판정이 틀렸음이 드러남**(그건 "진단 0건"만 확인한 것이었고, `luau-analyze
--annotate`로 열어보니 반환 타입이 `Unifiable<Error>`로 조용히 새고
있었음 — 틀린 대입도 안 잡힘). 진짜 원인은 콜백 계약이 아니라 **`Compute`
`State<U>`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 자체**로,
사용자가 찾아온 RFC(`relax-recursive-type-restriction`)가 `Promise<T>.andThen`으로
예시 든 바로 그 패턴. **결론: 계약은 그대로 유지, quad가 타입을 비틀 일이
아니라 Luau의 현 한계 — 당장 할 수 있는 바 없음**(RFC는 순수 내부 변경이라
지금 선언 그대로 두면 자동 수혜, 추적 `luau-lang/luau#2380`). 대응은
**"파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"** 관례
하나(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨을 실측 확인).
흩어져 있던 타입 한계 5건을 **`base/typing-limits.md`로 통합 신설**(대전제
"Luau 한계를 우회하려 타입/API를 비틀지 않는다" + 새 API 설계 체크리스트),
실측 근거는 `audit/type-recursion-issue/`(REPORT + spikes 44개, audit
폴더에 스크립트를 같이 둔 첫 예외). `question.md` 최우선이 2건→1건(0-Z만),
스파이크 `08``done/`으로 가며 `review-required/`가 비었음. **가장
중요한 정정**: 판정이 뒤집힌 당사자인 `audit/luau-test-first-run-2026-08-13.md`
배너뿐 아니라 본문 표·문단·결론까지 전수 수정(체크리스트 2번 준수).
교훈 — **`luau-analyze` 진단 0건은 타입 해소를 뜻하지 않음**, 타입
스파이크는 `--annotate` + 음성 대조군 필수.

View file

@ -2,8 +2,9 @@
에이전트가 못 하거나(로컬 GUI 조작, 외부 계정/기기 필요) 사용자의 결정이 필요해서
멈춰둔 것만 여기 모음. 설계 질문은 대체로 `.claude/question.md`에 따로 있고 디폴트를
잡아둔 채 진행 중이라 급하지 않음 — **단 2026-08-13부터는 예외 둘이 생겨 아래 4번에
올렸음**(0-Y/0-Z, M0 착수를 실제로 막고 있고 사용자가 직접 판단하겠다고 한 항목).
잡아둔 채 진행 중이라 급하지 않음 — **단 2026-08-13부터는 예외가 생겨 아래 4번에
올렸음**(0-Z, M0 착수를 실제로 막고 있고 사용자가 직접 판단하겠다고 한 항목.
같이 올렸던 0-Y는 같은 날 열세 번째 세션에 해소됨).
## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용)
@ -55,25 +56,29 @@ git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초
내용은 항상 `.claude/`에 자기 문서화(완료 표시, 다음 TODO 갱신)해서 다음 세션이나
사람이 바로 이어받을 수 있게 할 것.
## 4. ⭐ **[2026-08-13 신설, 막고 있음] `question.md` 0-Y / 0-Z 두 결정**
## 4. ⭐ **[2026-08-13 신설, 막고 있음] `question.md` 0-Z 결정**
**이 둘은 위 3번과 성격이 다름 — 실제로 M0 구현 착수를 막고 있고, 사용자가
**이 위 3번과 성격이 다름 — 실제로 M0 구현 착수를 막고 있고, 사용자가
"직접 스케치하며 판단하겠다"고 명시 이관한 항목**이라 에이전트가 기본값으로
밀고 갈 수 없음.
- **0-Y — `:Compute(fn)`의 lazy 핸들 계약을 유지할 것인가.** 첫 실측
(`luau-analyze`)에서 가장 흔한 관용구
`state:Compute(function(s) return s:Get() * 2 end)`가 **Luau 양방향 추론과
충돌해 타입 에러**를 내는 게 확인됨. 표기 조정으론 안 풀리고, 콜백이 raw
값을 받으면 완전히 클린 — 즉 **계약 자체가 원인**. `Effect`/`Observer`/
`Animate`/`Operator`가 전부 같은 계약 위에 있어서 파급이 큼. 사용자가 낸
방향은 "`State` 타입을 구울 때 인라이닝"(생성기가 `T`별 평탄 타입을 뽑는,
`Modifier` flat 타입과 같은 결) — 사람이 직접 확인할 부분이라 보류 중.
- **0-Z — Attribute 이름 소유권을 무엇으로 판정할 것인가.** 재디스패치
모델을 "하강 diff"로 재설계하면서 유일하게 안 풀린 항목. 사용자 코멘트:
"이전 결정(이름별 claimant `Relate`)을 다시 가져오는 게 맞아 보이나, 나중에
제가 물리적으로 스케치해보며 심층 분석해보겠습니다."
> **[2026-08-13 열세 번째 세션] 여기 같이 있던 `0-Y`는 해소됨 — 사람이
> 결정할 게 더 없음.** 44개 스파이크 재실측으로 원인이 콜백 계약이 아니라
> **Luau 자체의 한계**(재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입
> 안전성이 조용히 사라짐)임이 확정됐고, 사용자가 "quad가 타입을 비틀 일이
> 아니라 상위 Luau 한계이니 당장 할 수 있는 바 없다"로 정리. 계약은 그대로
> 유지, 대응은 "파생 State를 만드는 자리마다 결과 타입 명시 주석 바인딩"
> 관례 하나. 규약은 `.claude/base/typing-limits.md`, 근거는
> `.claude/audit/type-recursion-issue/`.
>
> 다만 **거기서 파생된 작은 확인거리 하나가 아래 6번으로 넘어감**(에디터의
> Luau 솔버 설정) — M0 착수 때 확인하면 되고 지금 막고 있진 않음.
**막고 있는 범위**: 0-Z가 정해져야 `bind-system-plan.md`/`tag-plan.md`/
`slot-plan.md`/`attribute-plan.md`/`ref-plan.md`/`architecture.md`/
`ROADMAP.md` 7개를 새 모델로 한 번에 옮길 수 있음 — 그 전에 M2/M4/M6/M10을
@ -91,11 +96,31 @@ git.qwreey.moe에 제한된 계정 생성). 로컬 git 저장소는 이미 초
GC 강제 트리거가 필요하면 같은 폴더의 `gc-trigger-helper.server.luau` 참고.
위 1번(MCP 연결)이 되면 에이전트가 대신 돌릴 수도 있음.
## 6. **[2026-08-13 신설, 안 막음]** 에디터의 Luau 솔버 설정 확인
`luau-analyze` CLI는 **새 솔버가 기본값**이지만 에디터가 쓰는
`luau-lsp`**옛 솔버가 기본값**(`LuauSolverV2=false`)이라 **같은 코드에
다른 진단이 나옵니다** — 이번 세션에 실제로 겪은 혼선의 원인이었음
(CLI는 클린인데 에디터엔 빨간 줄).
옛 솔버는 quad의 `Compute` 시그니처 패턴 자체를 선언 시점에 거부하므로
사실상 새 솔버 외에 선택지가 없어 보이지만, **실제 사용하시는 에디터
환경에서 확인이 필요**합니다. VSCode의 "Luau Language Server" 확장이라면
워크스페이스 `.vscode/settings.json`에:
```json
{ "luau-lsp.fflags.enableNewSolver": true }
```
M0 착수 시점에 확인하면 되고 지금 막고 있진 않음. 배경은
`.claude/base/typing-limits.md` 8번, 실측은
`.claude/audit/type-recursion-issue/REPORT.md` 5절.
## 3. `.claude/question.md`**나머지** 항목 검토 (급하지 않음)
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
마음에 안 드는 것만 답해주면 됨 — **위 4번(0-Y/0-Z)을 제외하면** 막고 있는
마음에 안 드는 것만 답해주면 됨 — **위 4번(0-Z)을 제외하면** 막고 있는
항목은 없음(0-B `dispose` 시그니처는 M6 구현 세부만 막고 M0 착수는 안 막음).
---

View file

@ -8,12 +8,18 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음 — 그래도 아직 M0
자체는 시작 안 함.** 다음 세션은 바로 M0부터.
> **⚠️ M0 착수 자체가 두 결정으로 막혀 있음 — `.claude/question.md` 0-Y
> (`:Compute(fn)` lazy 핸들 계약과 Luau 타입 추론 충돌)/0-Z(Attribute
> 이름 소유권). 둘 다 "M0 착수 전에 정할 것"으로 명시돼 있음 —
> `HUMAN_TODO.md` 4번, `CLAUDE.md` "지금 할 일" 0번 항목 참고. 아래
> M0 체크리스트 자체는 이 두 결정과 무관하게 유효하지만, 순서상 이
> 둘이 먼저.**
> **⚠️ M0 착수 자체가 한 결정으로 막혀 있음 — `.claude/question.md`
> **0-Z**(Attribute 이름 소유권). "M0 착수 전에 정할 것"으로 명시돼
> 있음 — `HUMAN_TODO.md` 4번, `CLAUDE.md` "지금 할 일" 0번 항목 참고.
> 아래 M0 체크리스트 자체는 이 결정과 무관하게 유효하지만, 순서상 이게
> 먼저.**
>
> **[2026-08-13 열세 번째 세션] 같이 막고 있던 `0-Y`(`:Compute(fn)` lazy
> 핸들 계약)는 해소됨** — 계약은 그대로 유지로 확정, 남은 건 Luau의 현
> 한계라 quad가 지금 할 수 있는 게 없음. **다만 구현 시 지켜야 할 규약이
> 하나 생겼으니 M0 착수 전에 반드시 읽을 것: `.claude/base/typing-limits.md`**
> (특히 "파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩"과
> 7번 체크리스트).
## M0 — 스켈레톤 + 기술검증 (스파이크, "진짜" 마일스톤 아님)