quad/.claude/audit/luau-test-first-run-2026-08-13.md
qwreey 93f548a2af
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
2026-08-13 22:08:51 +09:00

210 lines
15 KiB
Markdown

# `.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 착수 전 남은 유일한 게이트"로 꼽아온 항목.
사용자 요청: "지금 상황에서 문제가 생겨 프로젝트의 구조 변경이 생기면 큰
작업인데, 이것이 더 큰 스파이크로 번지기 전에 미리 확인하고싶습니다."
## 런타임 스파이크 (`luau`)
| 파일 | 판정 | 요지 |
|---|---|---|
| `01-two-pass-array-hash-order` | ✅ 통과 | 배열 파트 전체 → 해시 파트 순으로 처리됨. `Dispatch.drive`의 두 패스 계약과 `PreRef` 호이스팅이 기대는 바로 그 순서 |
| `02-none-sentinel-vs-nil-holes` | ✅ 통과 | `nil` 소진 시 `#t`가 50→**49**로 무너지고 순회가 흐트러짐 / `None` 소진은 `#t`가 항상 50. 반대로 Ref 콜백 배열은 `None`을 쓰면 1000회 반복 후 **죽은 슬롯 1000개**가 그대로 남음 — 두 배열의 규칙이 서로 반대여야 한다는 2026-08-09 열한 번째 세션 정정이 정량적으로 확인됨 |
| `03-recursive-store-bind-dispatch` | ✅ 통과 | StoreBind 재귀 재-dispatch, `None`→`nil` 흘러가기, 무한재귀 없이 종료 |
| `04-dispatch-chain-retractFrom` | ✅ 통과 + **버그 재현** | 아래 별도 절 |
| `05-store-state-diamond-propagation` | ✅ 통과 | 다이아몬드 의존성에서 `stateC` 재계산이 정확히 1회(중복 재계산 없음), invalidate는 2번 도달하지만 2번째가 즉시 중단 |
| `06-component-boundary-nil-hole-props` | ✅ 통과 | `or None` 없으면 앞쪽 nil-hole로 `bad[1]`/`bad[2]`가 사라짐, 관용구 쓰면 항상 5칸 유지 |
| `07-relate-weak-table-gc` | ✅ 통과(**이번에 보강 후**) | 아래 별도 절 |
| `11-modifier-illegal-value-error` | ⚠️ 부분 | 대부분 의도된 가드 에러로 통과하나 "다른 Modifier" 케이스만 브랜드 판별이 크래시해 **엉뚱한 이유로 통과** — 수정 진행 |
| `17-modifier-index-tableclone-chaining` | ❌ 크래시 | `attempt to call a number value`(44행)로 죽어 **아무것도 검증 못 함** — 수정 진행 |
| `18-relate-mutual-cycle-gc` | ✅ 통과 | 아래 별도 절 |
| `20-slot-splice-index-arithmetic` | ✅ 통과 | 11개 경계 케이스 전부 참조 구현과 일치(delta 양/음, 맨앞/맨끝, 전체 교체 등) |
`10-roblox-studio-checks.server.luau`는 Studio 전용이라 이 라운드 범위 밖
(부분 결과는 `audit/gcconn-trick-verification.md`).
## `04` — 이번 세션 감사가 찾은 버그가 실측으로 재현됨 (가장 중요)
같은 스크립트가 두 시나리오를 돌림: `chains:SetStrong``handler.process`
**앞**에 두는 정상 설계와, **뒤**에 두는 음성 대조군.
| 관측 지점 | 정상(수정본) | 음성 대조군(버그) |
|---|---|---|
| [1] 최초 마운트 후 체인 깊이 | **3** (기대) | **1** ← 인덱스 2/3 retractor 유실 |
| [3] 바깥 store 재발행 후 옛 inner 구독 | **0** (끊김) | **1** ← 안 끊김 |
| [4] 죽은 store를 건드렸을 때 | `w1` 유지 | **`STALE`로 덮어써짐** |
즉 이 버그는 "리소스가 좀 샌다" 수준이 아니라 **이미 버려진 store가
나중에 UI를 덮어쓰는** 증상까지 간다는 게 실측으로 확인됨. 수정
(`SetStrong` hoist + no-op 점유 마커)이 옳았다는 결정적 근거.
## `07` — 스파이크를 보강해야 실제 검증이 됐음
원래 3번 섹션이 "강하게 붙잡아둔 10개가 살아있는가"라는 **sanity check만**
하고 있었고, 헤더가 내세운 핵심 주장("inst가 죽으면 중첩된 것까지 전부
같이 GC")은 검증되지 않은 채였음. 파일 자신도 "Luau가 weak table 내부
엔트리 개수를 세는 표준 API를 안 줘서 직접 카운트 불가"라고 적어뒀는데
**그 전제가 틀렸음** — outer가 `__mode="k"`이므로 GC 후 죽은 엔트리는
`pairs` 순회에서 그냥 사라져 직접 셀 수 있음.
그래서 이번에 `relate._countEntries()`(테스트 전용)와 **weak-value canary
레지스트리**를 추가해 4번 섹션을 신설, 연쇄 GC를 직접 검증:
```
inst 5개만 살린 상태에서 살아남은 payload 수: 5 (기대 5 — 45개는 연쇄 GC)
relate4의 살아있는 엔트리 총 개수: 5 (기대 5)
모든 inst 참조를 놓은 뒤 살아남은 payload 수: 0 (기대 0)
relate4의 살아있는 엔트리 총 개수: 0 (기대 0)
```
**`base/bind-system-plan.md` "왜 GC-안전한가"와 `base/relate-plan.md`
전체가 기대고 있는 전제가 실측 확인됨** — quad의 GC-native 아키텍처
(명시적 Destroy 강제 없음, `bindLifetime`으로 매달아둔 자원이 inst와 함께
자동 소멸)가 실제로 성립함.
## `18` — `Relate` 상호 순환 경고가 실측 확인됨
`base/relate-plan.md` "위험한 패턴" 절이 공식 문서 인용(Luau에 ephemeron
없음)으로만 뒷받침되던 주장:
```
1. 상호 강참조 순환: inst 살아있음 = true, value 살아있음 = true (GC 못 풂)
2. 한쪽을 weak-value로 낮춤: inst 살아있음 = false, value 살아있음 = false (풀림)
```
**추측이 아니라 실제로 GC가 안 되는 게 확인됨**`Slot`
`kSlotMap`/`slotOwner`를 둘 다 `SetWeak`로 낮춘 2026-08-12 열세 번째
세션 결정이 필수 조치였음이 입증됨.
## 타입 스파이크 (`luau-analyze`) — 판정 완료
| 파일 | 판정 | 요지 |
|---|---|---|
| `08-type-source-satisfies-state` | ✅ 부분통과 | 핵심 질문(`Source<T>`를 `State<T>` 자리에 그대로 넘기기)은 클린 통과 — 구조적 서브타이핑 성립. 별개로 `State<T>`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면 `Recursive type being used with different parameters` — M0에서 타입 선언 작성 시 유의할 좁은 제약 |
| `09-type-modifier-overridden-subtype` | ✅ 통과 | 문서가 우려한 지점(`FrameModifier`↔`GuiObjectModifier`)이 그대로 재현 — `Apply`의 리턴 타입 불일치로 서브타입이 깨짐. fallback(`any`)은 정상. 확인하려던 걸 정확히 확인 |
| `12-type-attribute-generic-key-narrowing` | ❌ 실패(**설계 영향 없음**) | 제네릭 키의 `T`가 이름별로 고정 안 되고 호출마다 독립 추론돼 narrowing이 전혀 강제 안 됨. **`attribute-plan.md`가 이미 이 결과를 fallback으로 예비**("안 되면 `BooleanAttribute` 같은 타입 패밀리가 유일하게 믿을 수 있는 정적 체크 경로") — 문서의 "[실측 필요]" 마커만 "확인됨: 안 됨"으로 갱신하면 됨 |
| `13-type-ref-preref-subtype` | ✅ 통과 | `PreRef<T>``Ref<T>` 자리에 대입 가능 — 진단 0건 |
| `14-type-nilable-default-overload` | ⚠️ 부분통과 | 의도한 오용은 정확히 막지만 **정상 nilable 사용례까지 같이 막아** 현 스케치로는 채택 불가 |
| `15-type-compute-trailing-deps-typepack` | ❌ 검증불가 | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 파일 전체가 파싱 실패. 다만 파서가 복구 후 낸 진단에서 **아래 1번 이슈**가 드러남 |
| `16-type-store-key-typefunction` | ❌ 실패 | `type function` 스케치의 `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음. 문서가 스스로 "API 이름이 다를 수 있다"고 예비해둔 케이스 |
## ⚠️ 실측으로 드러난 진짜 설계 이슈 — `:Compute(fn)`의 lazy 핸들 계약이 Luau 추론과 충돌
**이번 실측 라운드에서 나온 가장 중요한 발견.** 최소 재현으로 원인을
정확히 좁혔음(에이전트 보고를 액면 그대로 받지 않고 직접 검증):
| 형태 | 진단 |
|---|---|
| 콜백이 `State<T>`(lazy 핸들)를 받음, **무주석 인라인 람다** | ❌ 2건 |
| 위 + `Get``read`로 선언 | ❌ 여전 |
| 위 + `Get: (self: State<T>) -> T` 형태로 변경 | ❌ 여전 |
| 콜백 **파라미터에 타입 주석**을 달면 | ✅ 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)
-- 우회책 1: 파라미터 주석(가장 흔한 자리에 매번 타입을 써야 함)
state:Compute(function(s: State<number>) return s:Get() * 2 end)
```
**이건 `:Compute`만의 문제가 아님**`Effect`/`Observer`/`Animate`/
`Operator` 카탈로그 등 "콜백이 lazy 핸들을 받는다"는 계약을 공유하는
API 전부에 걸림. 2026-08-07 일곱 번째 세션의 커링 스타일 확정,
2026-08-12 네 번째 세션의 "`:Get()` 누락 버그 전역 감사"가 전부 이 계약
위에 서 있음.
**결정은 사용자 몫**`.claude/question.md`에 올림. 선택지:
1. 계약 유지 + 파라미터 주석 필수(인체공학 손해, 가장 흔한 자리에 매번).
2. 콜백이 raw 값을 받도록 전환(추론은 완벽해지나 lazy/trailing-deps/
`previous` 설계 전반과 충돌 — **구조 변경 규모가 큼**).
3. 혼합(무주석은 raw, 명시적으로 lazy가 필요할 때만 별도 API).
> **[13차 세션 정정 — 위 선택지 셋 다 폐기됨]** 재실측 결과 이 셋은
> 전부 잘못된 프레이밍이었음(2번은 전제 자체가 틀렸고, 1/3번도 반환
> 타입 문제를 못 고침). **최종 결론: 계약은 그대로 유지, 남은 건 Luau의
> 현 한계라 quad가 할 수 있는 게 없음.** 대응은 "파생 State를 만드는
> 자리마다 결과 타입을 명시 주석으로 바인딩"하는 관례 하나 —
> `base/typing-limits.md`.
## 그 외 — 스파이크 코드 결함이었던 것들 (전부 수정 완료)
- **`17` 크래시의 원인이 실은 문서 결함이었음** — `modifier-plan.md`
"데이터를 테이블에 직접 두고"가 "self 최상위 리터럴 키"로 읽힐 여지가
있었는데, 그렇게 하면 `__index``rawget` 성공 시 안 불리므로 **같은
필드를 두 번째로 변환 함수와 함께 호출하는 순간 죽음**(`attempt to call
a number value`). 그 재호출 패턴이 바로 문서 3·4번 절의 대표 용례라
실사용에서 즉시 터지는 경로였음 — `modifier-plan.md`에 경고 문단
추가하고, 필드를 내부 저장소에 두는 구조로 `17`을 재작성해 통과 확인.
- `11`의 "다른 Modifier" 케이스가 브랜드 판별 크래시로 **엉뚱하게 통과**하던
것 수정 — 이제 의도된 가드 에러로 검증됨(16개 케이스 전원 통과).
- `19`의 B/C 섹션을 현행 설계로 재작성(옛 `rawNew`+`owners`, 3분기
`claimOwner` 폐기 반영). **음성 대조군을 넣어** 옛 로직이 `Slot{a,a}`
`Frame{slot,slot}`을 조용히 통과시키는 것까지 재현 확인.
- `07`을 보강(위 절 참고).
**최종 런타임 상태: 12개 전원 통과**(01/02/03/04/05/06/07/11/17/18/19/20),
crash 0, FAIL 0.
## 스파이크 자체 수정이 더 필요한 것 (설계 문제 아님)
- `13`: B 런타임 섹션이 A의 더미 스텁에 막혀 단독 실행 불가 — 분리 필요.
- `15`: 음성 대조군을 별도 파일/블록으로 격리해 `SyntaxError`가 A/B/D
판정을 막지 않도록.
- `16`: 설치된 버전의 `types.*` 실제 API 재확인 후 재시도.
## 결론
**런타임 설계는 전부 성립** — 검증된 주장마다 예외 없이 통과했고, 특히
GC-native 아키텍처의 핵심 전제(연쇄 GC)와 `Relate` 상호 순환 경고가
실측으로 확정됨. 이번 세션 감사가 찾은 버그도 음성 대조군으로 재현되어
**감사→수정 사이클이 실측으로 닫힘**.
**타입 쪽에서 하나가 걸림**`:Compute(fn)`의 lazy 핸들 계약이 Luau
양방향 추론과 충돌(위 절). 사용자가 우려한 "구조 변경이 생기면 큰 작업"에
해당할 수 있는 유일한 항목이고, **선택지 2를 고르면 실제로 큰 작업**이므로
M0 착수 전에 결정해두는 게 맞음.
> **[13차 세션 정정]** 위 문단의 걱정은 **결과적으로 기우였음** — 재실측
> 결과 선택지 2(raw 값 전환)는 애초에 문제를 안 고치므로 "큰 작업"을
> 할 이유 자체가 없어졌음. 계약을 그대로 두는 게 정답이고, **구조
> 변경은 전혀 필요 없음**. 다만 진짜 원인(반환 타입)은 Luau가 고쳐줄
> 때까지 남으므로 명시 주석 바인딩 관례로 대응 —
> `base/typing-limits.md`.
`modifier-plan.md``__index` 저장 위치 모호성은 문서 결함이었고 이번에
수정 — 스파이크가 없었으면 M2/M6 구현 중에 터졌을 건이라, 실측 라운드
자체의 값어치를 보여준 사례.