From 93f548a2af9730f7a22665a1c2a633d83fc2c65e Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 13 Aug 2026 22:08:51 +0900 Subject: [PATCH] =?UTF-8?q?docs(type):=200-Y=20=ED=95=B4=EC=86=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=AC=EA=B7=80=20=EC=A0=9C=EB=84=A4=EB=A6=AD=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EC=9D=80=20Luau=20=EC=83=81=EC=9C=84=20?= =?UTF-8?q?=ED=95=9C=EA=B3=84=EB=A1=9C=20=ED=99=95=EC=A0=95,=20typing-limi?= =?UTF-8?q?ts.md=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 44개 스파이크로 question.md 0-Y를 재실측한 결과, 여섯 번째 세션의 "콜백이 raw 값을 받으면 완전 클린" 판정이 틀렸음이 드러남 — 그건 진단 0건만 확인한 것이었고, luau-analyze --annotate로 열어보니 반환 타입이 Unifiable로 조용히 새고 있었음(틀린 대입도 안 잡힘). 진짜 원인은 콜백 계약이 아니라 Compute가 State(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 자체 — RFC relax-recursive-type-restriction이 Promise.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 Claude-Session: https://claude.ai/code/session_01KckSawrsJSJmDBcSojJPxZ --- .claude/README.md | 5 +- .claude/archive/question-resolved.md | 32 +- .../audit/luau-test-first-run-2026-08-13.md | 42 +- .claude/audit/type-recursion-issue/REPORT.md | 465 ++++++++++++++++++ .../spikes/00-baseline-repro.luau | 20 + .../spikes/01-readonly-get.luau | 19 + .../spikes/02-matrix-a-mono-mono.luau | 19 + .../spikes/03-matrix-b-genericT-monoU.luau | 18 + .../spikes/04-matrix-c-monoT-genericU.luau | 18 + .../spikes/05-genericU-call-syntax.luau | 20 + .../spikes/06-genericU-no-recursion.luau | 18 + .../07-genericU-no-recursion-monoA.luau | 15 + .../08-genericU-return-annotation-hint.luau | 16 + ...tate-recursive-return-annotation-hint.luau | 18 + .../spikes/10-selfref-field-free-compute.luau | 20 + .../11-compute-as-field-plain-body.luau | 21 + ...-as-field-plain-body-annotated-return.luau | 18 + .../spikes/13-double-generic-self-and-U.luau | 21 + ...14-split-intersection-compute-outside.luau | 25 + ...15-isolate-recursive-different-params.luau | 12 + .../16-isolate-recursive-same-params.luau | 10 + ...isolate-compute-only-different-params.luau | 13 + ...isolate-compute-lazy-different-params.luau | 12 + .../spikes/19-split-full-chain-stress.luau | 51 ++ .../20-caveat-lambda-param-lacks-compute.luau | 28 ++ .../21-apply-needs-full-self-in-callback.luau | 34 ++ .../spikes/21b-apply-minimal-isolated.luau | 25 + .../21c-apply-two-branches-same-body.luau | 28 ++ ...two-branches-different-expr-same-type.luau | 28 ++ .../21e-apply-branch-trivial-passthrough.luau | 27 + .../22-apply-with-annotation-works.luau | 27 + .../23-with-plus-compute-real-example.luau | 42 ++ .../spikes/24-with-heterogeneous-types.luau | 30 ++ .../spikes/25-observer-no-local-generic.luau | 25 + .../spikes/26-deep-chain-many-computes.luau | 35 ++ ...oes-error-type-still-catch-mismatches.luau | 26 + ...28-explicit-lhs-annotation-still-safe.luau | 25 + .../29-is-callback-param-itself-safe.luau | 27 + .../30-fix-U-with-return-annotation.luau | 26 + ...31-fully-annotated-nonsplit-soundness.luau | 24 + .../spikes/32-direct-U-return-soundness.luau | 9 + .../33-same-param-recursion-soundness.luau | 20 + .../34-rawvalue-contract-soundness.luau | 23 + ...pped-nonrecursive-container-soundness.luau | 20 + .../spikes/36-explicit-generic-try1.luau | 9 + .../spikes/37-explicit-generic-single.luau | 15 + .../spikes/38-double-angle-bracket.luau | 9 + .../39-double-angle-bracket-garbage.luau | 9 + ...-double-angle-bracket-on-real-problem.luau | 24 + ...1-double-angle-bracket-fixes-problemA.luau | 20 + ...plicit-generic-on-nonrecursive-sanity.luau | 13 + .../43-explicit-lhs-binds-downstream.luau | 26 + .claude/base/bind-system-plan.md | 47 +- .claude/base/effect-plan.md | 8 +- .claude/base/modifier-plan.md | 11 +- .claude/base/store-semantics.md | 20 +- .claude/base/tween-plan.md | 21 +- .claude/base/typing-limits.md | 298 +++++++++++ .claude/luau-test/README.md | 2 +- .claude/luau-test/STATUS.md | 37 +- .../08-type-source-satisfies-state.luau | 0 .claude/question.md | 58 +-- .claude/research/operator-sugar-plan.md | 11 +- .claude/research/pre-implementation-audit.md | 4 +- ...-08-13-13-type-recursion-limit-resolved.md | 236 +++++++++ CLAUDE.md | 86 +++- HUMAN_TODO.md | 51 +- ROADMAP.md | 18 +- 68 files changed, 2320 insertions(+), 170 deletions(-) create mode 100644 .claude/audit/type-recursion-issue/REPORT.md create mode 100644 .claude/audit/type-recursion-issue/spikes/00-baseline-repro.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/01-readonly-get.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/02-matrix-a-mono-mono.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/03-matrix-b-genericT-monoU.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/04-matrix-c-monoT-genericU.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/05-genericU-call-syntax.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/06-genericU-no-recursion.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/07-genericU-no-recursion-monoA.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/08-genericU-return-annotation-hint.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/09-state-recursive-return-annotation-hint.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/10-selfref-field-free-compute.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/11-compute-as-field-plain-body.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/12-compute-as-field-plain-body-annotated-return.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/13-double-generic-self-and-U.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/14-split-intersection-compute-outside.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/15-isolate-recursive-different-params.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/16-isolate-recursive-same-params.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/17-isolate-compute-only-different-params.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/18-isolate-compute-lazy-different-params.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/19-split-full-chain-stress.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/20-caveat-lambda-param-lacks-compute.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/21-apply-needs-full-self-in-callback.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/21b-apply-minimal-isolated.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/21c-apply-two-branches-same-body.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/21d-apply-two-branches-different-expr-same-type.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/21e-apply-branch-trivial-passthrough.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/22-apply-with-annotation-works.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/25-observer-no-local-generic.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/26-deep-chain-many-computes.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/27-does-error-type-still-catch-mismatches.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/28-explicit-lhs-annotation-still-safe.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/29-is-callback-param-itself-safe.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/30-fix-U-with-return-annotation.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/31-fully-annotated-nonsplit-soundness.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/32-direct-U-return-soundness.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/33-same-param-recursion-soundness.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/34-rawvalue-contract-soundness.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/35-wrapped-nonrecursive-container-soundness.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/36-explicit-generic-try1.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/37-explicit-generic-single.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/38-double-angle-bracket.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/39-double-angle-bracket-garbage.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/40-double-angle-bracket-on-real-problem.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/41-double-angle-bracket-fixes-problemA.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/42-explicit-generic-on-nonrecursive-sanity.luau create mode 100644 .claude/audit/type-recursion-issue/spikes/43-explicit-lhs-binds-downstream.luau create mode 100644 .claude/base/typing-limits.md rename .claude/luau-test/{review-required => done}/08-type-source-satisfies-state.luau (100%) create mode 100644 .claude/session/2026-08-13-13-type-recursion-limit-resolved.md diff --git a/.claude/README.md b/.claude/README.md index e22fcb5..34cb0e8 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -15,8 +15,8 @@ | `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | -| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(`question.md` 0-Y). **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | -| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 2개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, `question.md` 0-Y의 1차 근거), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만, A-1/A-2/B/C는 미확인) | +| `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 3개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만, A-1/A-2/B/C는 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨) | | `tools/` | **[2026-08-13 아홉 번째 세션 신설]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | | `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, CLAUDE.md의 "세션 히스토리" 절에서 각 항목이 여기로 링크. 항상 읽을 필요 없음 — 결정의 논의 과정이 궁금할 때만 | | `initreq/` | 프로젝트 착수 시 클론해둔 참고 레포(quad v1, fusion, vide, rbvm, tbox, code-docker) + PA님 실 코드(`artworks/`, 4차 라운드 교차검증 근거) + 원본 요청(`req.md`, `raw-userinput.md`) + `quad2-try`(이전에 시도했다 폐기한 v2 재작성 시도 — 리서치 완료, 결론은 `base/bind-system-plan.md`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -30,6 +30,7 @@ | 문서 | 내용 | |---|---| | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | +| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute(self: State,...) -> State`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 44개로 확정). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드/`store.key` type function 한계도 여기 통합, 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | | `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 | diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 45427d7..4b01f9f 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -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`를 반환하는 것 자체는 **어떤 방법으로도 +> 안 풀림**(아래 선택지 1/2/3 전부 무효 — 특히 "raw 값이면 완전 클린"이라던 +> 아래 표의 판정이 실측으로 **뒤집힘**, raw 값 계약도 똑같이 불안전함). +> (B)는 Luau RFC `relax-recursive-type-restriction`이 `Promise.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` 핸들을 받는 quad의 커링 계약이 **Luau 양방향 타입 추론과 충돌**함. 가장 흔한 관용구가 그대로 실패: diff --git a/.claude/audit/luau-test-first-run-2026-08-13.md b/.claude/audit/luau-test-first-run-2026-08-13.md index 381a7a4..5c1a244 100644 --- a/.claude/audit/luau-test-first-run-2026-08-13.md +++ b/.claude/audit/luau-test-first-run-2026-08-13.md @@ -1,5 +1,26 @@ # `.claude/luau-test/` 첫 실측 결과 (2026-08-13 여섯 번째 세션) +> **⚠️ [2026-08-13 열세 번째 세션 정정] 이 문서의 타입 관련 결론 하나가 +> 뒤집혔습니다 — 아래 "⚠️ 실측으로 드러난 진짜 설계 이슈" 절의 +> "콜백이 raw 값을 받으면 ✅ 완전 클린(0건)" 판정.** +> +> 그 판정은 **"진단이 0건이다"만 확인**한 것이었고, 반환 타입이 실제로 +> 해소됐는지는 확인하지 않았습니다. 열세 번째 세션에 `luau-analyze +> --annotate`로 추론된 실제 타입을 열어보니 **raw 값 계약도 똑같이 +> `Unifiable`로 새고 있었고**(틀린 타입에 대입해도 안 잡힘), +> 따라서 아래 표의 "raw 값 = 완전 클린"과 그에 근거한 "선택지 2로 가면 +> 추론이 완벽해진다"는 서술은 **틀렸습니다.** +> +> 진짜 원인은 콜백 계약이 아니라 **`Compute`가 `State`(자기 이름을 +> 다른 타입 인자로 감싼 타입)를 반환한다는 것 자체**(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` 형태로 변경 | ❌ 여전 | | 콜백 **파라미터에 타입 주석**을 달면 | ✅ 0건 | -| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ✅ **0건** | +| 콜백이 **raw 값 `T`** 를 받으면(`fn(v)`) | ~~✅ **0건**~~ **[13차 세션 정정] 진단만 0건이고 반환 타입은 똑같이 `Unifiable` — 안전하지 않음** | 즉 **원인은 "콜백이 lazy `State` 핸들을 받는다"는 quad의 커링 계약 그 자체**임. `read`/`self` 표기 조정으로는 안 풀리고, raw 값을 넘기면 완벽히 추론됨. +> **[13차 세션 정정] 위 문단이 틀렸음.** 원인은 커링 계약이 아니라 +> `Compute`의 **반환 타입**(`State`)이고, 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 구현 중에 터졌을 건이라, 실측 라운드 자체의 값어치를 보여준 사례. diff --git a/.claude/audit/type-recursion-issue/REPORT.md b/.claude/audit/type-recursion-issue/REPORT.md new file mode 100644 index 0000000..89f2e98 --- /dev/null +++ b/.claude/audit/type-recursion-issue/REPORT.md @@ -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`인 경우를 잡아낸 게 이것뿐이었음 +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`를 "Get만 있는 자기참조 데이터부"와 "Compute + 등 로컬 제네릭 메소드부"로 쪼개면 됩니다. 파라미터 쪽은 실제로 + 존재하지 않는 필드에 접근하면 정상적으로 에러가 나는 등, **진짜 + 타입체크가 살아있는 채로** 무주석 람다가 통과합니다. +- **문제 B — `Compute`의 반환값 자체(`State`).** `Compute`가 원래 + 타입 T와 **다른** 타입 U로 새 State를 만들어 반환하는 것 — 즉 + `:Compute`의 존재 이유 그 자체 — 는 **어떤 방법을 써도(쪼개기든, + 파라미터/리턴 둘 다 명시 주석이든, `question.md`가 원래 "완전 + 클린"이라 적어뒀던 raw 값 계약이든) 실제로는 타입이 안전하게 추론되지 + 않습니다.** 에러를 내면서 막히는 게 아니라 **`Unifiable`라는 + 내부 에러 타입으로 조용히 샙니다** — 겉보기엔 통과한 것처럼 보이고, + 그 결과를 엉뚱한 타입에 대입해도 아무 에러가 안 뜹니다. 이건 + `luau-analyze`/`--annotate`/`luau-lsp`(새 솔버 강제) 세 경로 전부에서 + 동일하게 재현됩니다 — 도구별 차이가 아니라 Luau 자체의 한계입니다. + +**즉 사용자가 RFC를 읽고 낸 결론("Luau가 타입으로써 이걸 제공하기 +전까지는 이 제약을 그냥 감수해야 한다")이 맞았고, 오히려 이 리포트의 +초안이 낙관적으로 틀렸습니다.** 다만 실측해보니 그 제약의 실제 모양은 +RFC가 설명하는 것("컴파일 에러로 거부됨")보다 더 나쁩니다 — **거부되는 +게 아니라 조용히 타입 안전성을 잃습니다.** + +--- + +## 1. 문제 A — 콜백 파라미터 추론은 실제로 풀림 + +### 최소 재현과 원인 + +```lua +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} +local state: State = ... +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 = { + Get: (self: StateData) -> T, -- 자기 자신만 재참조(Compute 모름) +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + -- self/콜백 파라미터 둘 다 StateData 사용 +} +``` + +**이 쪼개기의 파라미터 쪽 효과는 진짜입니다** — `29`에서 확인: 콜백 +파라미터로 존재하지 않는 메소드/필드를 부르면 정상적으로 +`TypeError: Key 'NoSuchMethod' not found in table 'StateData'`가 +납니다. 코드 생성(사용자가 제안한 "T별로 구워서 인라이닝") 없이, +`State`가 여전히 진짜 제네릭인 채로 타입 선언 두 개만 손으로 쓰면 +됩니다. + +**캐비엇**: 콜백이 받는 `s`는 `StateData`라 `Compute`/`With`가 없어서, +콜백 "안에서" 또 `s:Compute(...)`를 부르는 중첩은 안 됩니다(`20`). +`:Apply`의 factory처럼 콜백이 원본의 전체 능력이 필요한 자리는 이 +쪼개기로 못 풀고 `function(self: State)`처럼 파라미터 타입 주석이 +계속 필요합니다(`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=state:Compute(function(s:StateData): number return s:Get()*2 end) +``` + +**에러가 하나도 안 떴던 그 "통과"가 사실 `Unifiable`(Luau +내부의 미해결/에러 타입 placeholder)였습니다.** 겉보기엔 성공한 것처럼 +보였을 뿐, `U`(=`Compute`가 반환하는 `State`의 U)가 실제로는 전혀 +해소되지 않은 채였습니다. + +### 진짜 위험한 부분 — 타입 안전성이 조용히 사라짐 + +`27`/`28`로 직접 확인: `s`가 `State`이어야 하는데 **명백히 틀린 +타입에 대입해도 에러가 안 납니다**: + +```lua +local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State이어야 함 +local wrong: number = s:Get() -- ❌이어야 하는데 통과함(exit=0, 에러 없음) +``` + +LHS에 일부러 틀린 타입을 명시해도(`28`, `local wrongLHS: State = +n:Compute(function(x) return tostring(x:Get()) end)`) 마찬가지로 통과. +**`luau-lsp analyze --flag:LuauSolverV2=true`로도 동일하게 재현**돼서 +`luau-analyze` CLI만의 문제가 아님을 확인했습니다. + +### 이게 "쪼개기"만의 문제가 아님 — 모든 formulation에서 동일 + +가장 중요한 재확인: 이 불안전성은 **문제 A와 무관하게, `Compute`가 +`State`(다른 파라미터로 재귀하는 반환)를 만드는 한 어떤 방식을 써도 +똑같이 재현**됩니다. + +| 실험 | 형태 | 결과 | +|---|---|---| +| `28` | 쪼개기 + 무주석 | Unifiable, 틀린 대입도 통과 | +| `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`이고 틀린 대입이 잡히지 않습니다 — +**즉 원래 0-Y 표에 있던 "raw 값 = 완전 클린"이라는 판정 자체가 이번 +실측으로 뒤집힙니다.** + +### 정확한 경계 — "재귀"가 아니라 "다른 파라미터로 재귀"가 원인 + +세 가지 대조군으로 정확히 좁혔습니다: + +- `33`: `Compute`가 **같은** T로 재귀하는 `State`를 반환(로컬 제네릭 + `U` 없음, matrix B와 동일 모양) → **진짜 sound**. `doubled:State`로 + 정확히 추론되고, 틀린 대입은 실제로 에러가 남. +- `35`: `Compute`가 로컬 제네릭 `U`를 **재귀하지 않는 평범한 컨테이너** + `Box`에 담아 반환 → **진짜 sound**. `s:Box`로 정확히 + 추론됨. +- `07`/`32`: `Compute`가 `U`를 **감싸지 않고 그대로** 반환(래핑 자체가 + 없음) → **진짜 sound**. + +→ **"제네릭으로 감싸서 반환하는 것" 자체는 문제가 아닙니다. 문제는 +정확히 "자기 자신과 같은 이름의 재귀 타입을, 자기 타입 파라미터와 +다른 인자로 다시 감싸서 반환하는 것"** — 즉 `Compute(self: State, +...) -> State`에서 `State`가 `State`와 다른 인자로 자기 +자신을 재참조하는 바로 그 자리입니다. 이건 사용자가 처음 찾아준 RFC +(`relax-recursive-type-restriction.html`)가 `Promise.andThen`으로 +예시 들었던 바로 그 패턴이고, `Promise.andThen`도 이 패턴과 100% +동일한 모양입니다. + +### RFC와의 관계 — "완화"가 아니라 "거부만 안 할 뿐, 안 풀림" + +초안에서는 "새 솔버가 이 제약을 이미 완화했다"고 썼는데, 그건 **딱 +선언(타입 alias 자체를 적는 것)까지만** 맞는 얘기였습니다(`17`/`18`이 +확인한 게 이것 — 선언은 통과함). 그런데 이번에 **실제 호출부에서 U가 +진짜로 풀리는지**까지 확인해보니 안 풀립니다 — 옛 솔버는 이 패턴을 +선언 시점에 아예 거부해서 "이 기능을 못 쓴다"는 게 최소한 명확한 반면, +**새 솔버는 선언은 받아주고 사용도 허용하지만, 실제로는 조용히 타입 +안전성만 잃습니다.** RFC가 제안한 완화 메커니즘("타입 별칭을 진짜 +type function처럼 취급해 lazy expansion")이 선언 검사 단계까지만 +반영되고, 실제 인스턴스화/단일화 단계는 아직 안 풀린 것으로 보입니다 +— 웹서치로 찾은 관련 이슈(`luau-lang/luau#2380` "Allow recursive +generic types to differ", 아직 열려 있고 메인테이너 답변 없음)도 이 +영역이 계속 진행 중인 작업임을 뒷받침하지만, **"조용히 Unifiable로 +샌다"는 이번에 직접 관측한 증상 자체를 정확히 다루는 이슈는 못 +찾았습니다** — GitHub 검색을 더 깊게 하진 않았습니다. + +### 딱 하나 더 본 것 — 깊은 체인에서의 거동 (`26`) + +10단 이상 `Compute`를 체이닝(number→string→number→boolean→...)해보면, +한 번 `Unifiable`로 오염된 뒤에는 **항상 조용히 통과하는 것도 +아닙니다** — 몇 단계 뒤에서 `#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 = 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`를 반환하는 것은 리액티브 파생값 API의 본질 +그 자체이고(RFC 자신이 `Promise.andThen`을 같은 모양의 대표 +사례로 듦), 이걸 피하려고 타입을 비트는 것(모노모픽 코드 생성, 반환 +타입을 `any`로 열기, API 모양 자체를 바꾸기 등)은 **RFC가 고쳐줄 +대상에서 스스로 이탈하는 것**이라 지금 이득보다 나중 부채가 큽니다. + +- **지금**: 명시적 타입 바인딩(`:`)이 강제됨 — 위 3-1의 규약. +- **RFC/이슈가 해결되면**: 지금 그대로의 선언이 **코드 변경 없이** + 자연히 풀림(아래 6-2절 근거). +- **당장 우리가 할 수 있는 바 없음** — 문서화(`base/typing-limits.md`)와 + 추적 링크(`luau-lang/luau#2380`)가 전부. + +## 4. 아직 안 본 것 + +- `Unifiable`가 뜨는 정확한 내부 매커니즘(어느 제약 해소 단계에서 + 포기하는지)은 Luau 소스코드 레벨까지는 안 팠습니다 — grounding은 + RFC 2건 + 대조 실험 + 관련 이슈 1건(`#2380`, 못 닫힘)까지만. +- 이 불안전성을 근본적으로 피하는 **다른 API 설계**(예: `:Compute`가 + `State`를 반환하는 대신 다른 무언가를 반환하는 방식)는 탐색하지 + 않았습니다 — **[2026-08-13 사용자 확정] 앞으로도 탐색 안 함**: + "타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고, 이것은 + 상위의 Luau의 현 한계다". 이 항목은 열린 숙제가 아니라 **명시적으로 + 범위 밖**입니다(3-2절, `base/typing-limits.md`). +- `previous?`/trailing deps 제네릭 팩(`rewrite-required/15`)도 같은 + 이유(Compute가 결국 `State`를 반환)로 똑같이 영향받을 걸로 + 보이지만 별도 검증은 안 했습니다. +- `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()`처럼 명시적으로 타입 인자를 넣어주는 방법이 되는지, (2) 지금 +당장은 명시 타이핑으로 버티더라도, RFC가 낙관하는 미래 방향이 실제로 +들어왔을 때 우리 코드가 **자연히** 그 위에 올라탈 수 있는 형태로 +지금부터 짜둘 수 있는지. + +### 6-1. `<<...>>` — 진짜 문법이었지만 우리 케이스엔 안 먹힘 + +**결론부터: 문법은 실재하고 일반적인 제네릭엔 진짜로 작동하지만, +`State`(재귀 self-참조 타입을 다른 파라미터로 반환) 케이스는 여전히 +못 고칩니다.** + +Luau 소스(`Ast/src/Parser.cpp` `parseMethodCall`)를 직접 열어 확인 — +메소드 호출 뒤에 `<`가 연달아 두 번(`<<`) 오면 `parseTypeInstantiationExpr`로 +분기해 명시 타입 인자 리스트를 파싱합니다(단일 `<`는 비교 연산자와 +문법이 겹쳐서 안 됨 — 그래서 이중 괄호). 이 정보(`AstExprCall:: +typeArguments`)는 새 솔버의 `ConstraintGenerator.cpp`(2818행)에서 +실제로 읽혀 `FunctionCallConstraint`까지 전달됩니다 — 죽은 코드가 +아니라 진짜 배선돼 있습니다. + +**일반 제네릭에선 실제로 작동합니다(`42`)**: +```lua +local function identity(v: T): T return v end +local x = identity<>(5 :: any) +local wrong: number = x -- 진짜 에러남(Expected 'number', got 'string') +``` +`--annotate`로도 `x:string`으로 정확히 찍힘 — explicit 제네릭 인자가 +진짜로 타입을 확정시킵니다. + +**하지만 우리의 `Compute(...) -> State`에 똑같이 적용해도 +안 풀립니다(`40`)**: +```lua +local s = n:Compute<>(function(x) return tostring(x:Get()) end) +-- --annotate: local s:Unifiable=... (여전히!) +local wrong: number = s:Get() -- 여전히 안 잡힘 +``` +`U`를 추론이 아니라 **명시로 못박아도** `State`라는 반환 타입 자체가 +`Unifiable`로 새는 건 그대로입니다. 문제 A(파라미터 self 추론) +쪽도 명시 제네릭으로 우회되는지 따로 확인했는데(`41`, 쪼개기 없이 원본 +재귀 타입 그대로 + `<>`만 추가) **완전히 무효과** — `00`과 +정확히 같은 에러가 그대로 남. `<<...>>`를 아무리 붙여도 파서가 받아만 +줄 뿐 타입 체크 결과에 어떤 영향도 못 줍니다(같은 이유로 `39`에서 +존재하지 않는 타입 이름을 넣어도 에러가 안 났던 것 — 애초에 우리 +케이스에선 이 정보가 실질적으로 안 쓰이고 있다는 뜻). + +**해석**: 문제 B는 "U가 무엇인지 모른다"가 아니라 **"U를 알아도 +`State`(자기 자신을 다른 파라미터로 재귀 참조하는 타입)를 실제 +콘크리트 타입으로 확장(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 = { + andThen: (self: Promise, callback: (T) -> Promise) -> Promise +} +``` +— **지금 이 순간에도 우리가 이미 쓰고 있는, 아무 변형도 안 가한 +자연스러운 재귀 제네릭 선언 그 자체**입니다. 즉: + +- **문법을 바꿔서 미리 대비할 필요가 없습니다** — RFC의 완화가 + 들어오면 지금 그대로의 `Compute(self: State, fn) -> State` + 선언이 (또는 우리가 채택한 쪼개기 버전도, `State` 자체는 여전히 + 진짜 제네릭 재귀 별칭이므로 마찬가지로) **아무 코드 변경 없이** 자동으로 + 올바르게 풀릴 걸로 보입니다. +- **오히려 하지 말아야 할 것**: 사용자가 처음 제안했던 "T별로 코드 + 생성해서 미리 구워두기"(모노모picization) 방향으로 갔다면, 그건 + **제네릭 자체를 없애버리는 것**이라 RFC가 고치는 대상(제네릭 재귀 + 별칭의 인스턴스화)과 무관해집니다 — 그 경로로 가면 나중에 RFC가 + 들어와도 자동으로 득 볼 게 없고, 오히려 코드 생성 인프라를 걷어내는 + 별도 마이그레이션이 필요해집니다. **이번 세션에서 코드 생성 없이 + 쪼개기만으로 문제 A를 푼 게(1절) 결과적으로 이 방향과도 맞아떨어짐** + — `State`가 여전히 손으로 쓴 두 개의 제네릭 타입 별칭일 뿐이라, + 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였음이 드러남(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` | 사용자 원래 `` 단일 괄호 시도 재현 | ❌ SyntaxError(`<`는 비교 연산자와 충돌) | +| `37` | 쪼개기 Compute에 `` 단일 괄호 | ❌ 동일 SyntaxError | +| `38`,`39` | `<>` 이중 괄호 최초 확인 | 파싱은 됨(에러 없음) — 근데 존재하지 않는 타입명도 안 걸림(수상함) | +| `40` | `<<...>>`로 문제 B(반환 타입) 우회 시도 | ❌ 여전히 Unifiable | +| `41` | `<<...>>`로 문제 A(파라미터) 우회 시도 | ❌ 완전 무효과, `00`과 동일 에러 | +| `42` | 대조군: 일반(비재귀) 제네릭에 `<<...>>` | ✅ 진짜 작동함(문법/배선 자체는 확인됨) | +| `43` | **명시 LHS 주석이 다운스트림에 타입을 바인딩하는가** | ✅ **먹힘** — 3-1절, 실효 대응책의 근거 | + +전부 `spikes/` 안에 있습니다. 실행 환경/재현 방법은 이 문서 맨 위 +"재현 방법 세 갈래" 참고. diff --git a/.claude/audit/type-recursion-issue/spikes/00-baseline-repro.luau b/.claude/audit/type-recursion-issue/spikes/00-baseline-repro.luau new file mode 100644 index 0000000..7535565 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/00-baseline-repro.luau @@ -0,0 +1,20 @@ +--!strict +-- 0-Y 베이스라인 재현: audit/luau-test-first-run-2026-08-13.md에 정리된 +-- "지금 확정된 관용구" 최소 재현. 무주석 인라인 람다가 lazy State 핸들을 +-- 받는 계약에서 정말 타입에러가 나는지 확인. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +-- 기대: 실패 (audit 문서 기준) +local doubled = state:Compute(function(s) return s:Get() * 2 end) + +print("00 done") diff --git a/.claude/audit/type-recursion-issue/spikes/01-readonly-get.luau b/.claude/audit/type-recursion-issue/spikes/01-readonly-get.luau new file mode 100644 index 0000000..2f349f4 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/01-readonly-get.luau @@ -0,0 +1,19 @@ +--!strict +-- 00의 에러 메시지가 "Get이 latter(추론된) 타입에서 read-only인데 +-- former(선언된) 타입은 read-write를 요구한다"고 명시함. +-- State의 Get을 read-only로 선언하면 방향이 맞아떨어져 풀리는지 확인. + +export type State = { + read Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +local doubled = state:Compute(function(s) return s:Get() * 2 end) + +print("01 done") diff --git a/.claude/audit/type-recursion-issue/spikes/02-matrix-a-mono-mono.luau b/.claude/audit/type-recursion-issue/spikes/02-matrix-a-mono-mono.luau new file mode 100644 index 0000000..338d70f --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/02-matrix-a-mono-mono.luau @@ -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") diff --git a/.claude/audit/type-recursion-issue/spikes/03-matrix-b-genericT-monoU.luau b/.claude/audit/type-recursion-issue/spikes/03-matrix-b-genericT-monoU.luau new file mode 100644 index 0000000..4fdcb8a --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/03-matrix-b-genericT-monoU.luau @@ -0,0 +1,18 @@ +--!strict +-- 매트릭스 B: T는 제네릭(State), U는 고정(Compute가 항상 State 자기 +-- 자신 타입을 반환 -- 즉 self map, U 제네릭 없음). + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> T) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +local doubled = state:Compute(function(s) return s:Get() * 2 end) + +print("03 genericT-monoU done") diff --git a/.claude/audit/type-recursion-issue/spikes/04-matrix-c-monoT-genericU.luau b/.claude/audit/type-recursion-issue/spikes/04-matrix-c-monoT-genericU.luau new file mode 100644 index 0000000..7aacdf4 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/04-matrix-c-monoT-genericU.luau @@ -0,0 +1,18 @@ +--!strict +-- 매트릭스 C: T는 고정(State, number만), U만 제네릭. +-- 00과 대조해 "T의 제네릭성"이 원인인지, "U의 제네릭성"이 원인인지 분리. + +export type State = { + Get: (self: State) -> number, + Compute: (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") diff --git a/.claude/audit/type-recursion-issue/spikes/05-genericU-call-syntax.luau b/.claude/audit/type-recursion-issue/spikes/05-genericU-call-syntax.luau new file mode 100644 index 0000000..b9d201e --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/05-genericU-call-syntax.luau @@ -0,0 +1,20 @@ +--!strict +-- 04(monoT, genericU)이 실패했음. 원인이 ":" 메소드 호출(암묵적 self +-- 바인딩) 때문인지, 아니면 U가 로컬 제네릭이라는 사실 자체 때문인지 +-- 분리 -- 똑같은 타입을 일반 함수 호출 문법으로 불러봄(self를 명시 인자로). + +export type State = { + Get: (self: State) -> number, + Compute: (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") diff --git a/.claude/audit/type-recursion-issue/spikes/06-genericU-no-recursion.luau b/.claude/audit/type-recursion-issue/spikes/06-genericU-no-recursion.luau new file mode 100644 index 0000000..26fb068 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/06-genericU-no-recursion.luau @@ -0,0 +1,18 @@ +--!strict +-- State 재귀 자체를 완전히 없애고, self가 그냥 평범한 제네릭 A인 +-- 순수 HOF에서도 "로컬 제네릭 U + 콜백 인자 무주석"이 깨지는지 확인. +-- State/Get 개념을 전혀 안 쓰고 가장 순수한 형태로 축소. + +type Box = { value: A } + +local function Compute(self: Box, fn: (self: Box) -> U): U + return fn(self) +end + +local box: Box = { value = 10 } + +-- s의 타입이 Box로 바로 주석 없이 추론되어야 하는데, +-- Compute 자체가 로컬 제네릭 U를 갖고 있어서 안 되는지 확인 +local doubled = Compute(box, function(s) return s.value * 2 end) + +print("06 done") diff --git a/.claude/audit/type-recursion-issue/spikes/07-genericU-no-recursion-monoA.luau b/.claude/audit/type-recursion-issue/spikes/07-genericU-no-recursion-monoA.luau new file mode 100644 index 0000000..0219633 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/07-genericU-no-recursion-monoA.luau @@ -0,0 +1,15 @@ +--!strict +-- 06에서 Box까지 제네릭이었던 것을 Box(비제네릭, number 고정)로 낮춰서 +-- "재귀적 self 타입이 없는 상태에서 U만 제네릭"인 경우를 04와 대조. + +type Box = { value: number } + +local function Compute(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") diff --git a/.claude/audit/type-recursion-issue/spikes/08-genericU-return-annotation-hint.luau b/.claude/audit/type-recursion-issue/spikes/08-genericU-return-annotation-hint.luau new file mode 100644 index 0000000..0939a82 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/08-genericU-return-annotation-hint.luau @@ -0,0 +1,16 @@ +--!strict +-- luau-analyze가 06/07에서 "Consider annotating the return with number"라고 +-- 직접 제안함. 그 제안대로 콜백의 리턴 타입만(파라미터는 여전히 무주석) +-- 명시하면 self 파라미터까지 같이 풀리는지 확인. + +type Box = { value: number } + +local function Compute(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") diff --git a/.claude/audit/type-recursion-issue/spikes/09-state-recursive-return-annotation-hint.luau b/.claude/audit/type-recursion-issue/spikes/09-state-recursive-return-annotation-hint.luau new file mode 100644 index 0000000..c1f6dc0 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/09-state-recursive-return-annotation-hint.luau @@ -0,0 +1,18 @@ +--!strict +-- 08과 같은 아이디어를 원래 State(재귀 self) 형태(00)에 적용 -- +-- 파라미터 s는 무주석으로 두고 콜백의 "리턴 타입만" 명시하면 통과하는지. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +local doubled = state:Compute(function(s): number return s:Get() * 2 end) + +print("09 done") diff --git a/.claude/audit/type-recursion-issue/spikes/10-selfref-field-free-compute.luau b/.claude/audit/type-recursion-issue/spikes/10-selfref-field-free-compute.luau new file mode 100644 index 0000000..3e5aee0 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/10-selfref-field-free-compute.luau @@ -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(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") diff --git a/.claude/audit/type-recursion-issue/spikes/11-compute-as-field-plain-body.luau b/.claude/audit/type-recursion-issue/spikes/11-compute-as-field-plain-body.luau new file mode 100644 index 0000000..de96e1a --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/11-compute-as-field-plain-body.luau @@ -0,0 +1,21 @@ +--!strict +-- 10에서 "Compute가 필드가 아니라 자유 함수"라 통과했다는 가설을 더 좁힘. +-- 이번엔 Compute를 다시 State의 필드(자기참조)로 되돌리되, 콜백 본문은 +-- s:Get() 대신 순수 데이터 필드 s.value로 단순화 -- 문제가 "Compute가 +-- 자기참조 타입의 필드라는 것" 자체인지, "콜백 안에서 또 self참조 메소드 +-- (Get)를 호출하는 중첩"인지 분리. + +export type State = { + value: number, + Compute: (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") diff --git a/.claude/audit/type-recursion-issue/spikes/12-compute-as-field-plain-body-annotated-return.luau b/.claude/audit/type-recursion-issue/spikes/12-compute-as-field-plain-body-annotated-return.luau new file mode 100644 index 0000000..e8d6bd3 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/12-compute-as-field-plain-body-annotated-return.luau @@ -0,0 +1,18 @@ +--!strict +-- 11 + 콜백 리턴 타입만 명시. "Compute가 자기참조 타입의 필드"라는 문제가 +-- 리턴 타입 annotation 하나로 우회되는지(파라미터 s는 여전히 무주석). + +export type State = { + value: number, + Compute: (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") diff --git a/.claude/audit/type-recursion-issue/spikes/13-double-generic-self-and-U.luau b/.claude/audit/type-recursion-issue/spikes/13-double-generic-self-and-U.luau new file mode 100644 index 0000000..33621ee --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/13-double-generic-self-and-U.luau @@ -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: 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") diff --git a/.claude/audit/type-recursion-issue/spikes/14-split-intersection-compute-outside.luau b/.claude/audit/type-recursion-issue/spikes/14-split-intersection-compute-outside.luau new file mode 100644 index 0000000..bde46c2 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/14-split-intersection-compute-outside.luau @@ -0,0 +1,25 @@ +--!strict +-- 10/07이 통과한 이유는 "Compute가 자기참조 타입 자신의 필드가 아니라"는 +-- 점이었음. 그런데 실사용에선 `state:Compute(fn)` 메소드 문법이 꼭 +-- 필요함. 절충안: State를 둘로 쪼개서 -- StateData는 Get만 담아 +-- 자기참조하고(Compute를 언급 안 함), Compute는 별도로 선언한 뒤 +-- 교차 타입(&)으로 합쳐 state 값 자체엔 여전히 Compute가 필드로 붙어있게 +-- 함. Compute의 self 파라미터 타입은 State(전체, Compute 포함) 대신 +-- StateData(Compute 미포함)를 가리키게 해서 재귀 사슬을 끊음. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +local doubled = state:Compute(function(s) return s:Get() * 2 end) + +print("14 done") diff --git a/.claude/audit/type-recursion-issue/spikes/15-isolate-recursive-different-params.luau b/.claude/audit/type-recursion-issue/spikes/15-isolate-recursive-different-params.luau new file mode 100644 index 0000000..5571028 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/15-isolate-recursive-different-params.luau @@ -0,0 +1,12 @@ +--!strict +-- 08 스파이크가 낸 "Recursive type being used with different parameters" 에러를 +-- Compute/self-application 문제와 분리해서 최소 재현. State가 자기 자신을 +-- *다른* 타입 인자(State)로 자기 선언 안에서 참조하는 것 자체가 +-- 문제인지 확인 (Compute 없이, With 하나만으로). + +export type State = { + Get: (self: State) -> T, + With: (self: State, ...State) -> State, +} + +print("15 done") diff --git a/.claude/audit/type-recursion-issue/spikes/16-isolate-recursive-same-params.luau b/.claude/audit/type-recursion-issue/spikes/16-isolate-recursive-same-params.luau new file mode 100644 index 0000000..3623805 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/16-isolate-recursive-same-params.luau @@ -0,0 +1,10 @@ +--!strict +-- 대조군: With의 가변인자를 State가 아니라 State(같은 파라미터)로 +-- 맞추면 통과하는지 -- "다른 파라미터로 재귀 참조"가 진짜 원인인지 확정. + +export type State = { + Get: (self: State) -> T, + With: (self: State, ...State) -> State, +} + +print("16 done") diff --git a/.claude/audit/type-recursion-issue/spikes/17-isolate-compute-only-different-params.luau b/.claude/audit/type-recursion-issue/spikes/17-isolate-compute-only-different-params.luau new file mode 100644 index 0000000..2ed09da --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/17-isolate-compute-only-different-params.luau @@ -0,0 +1,13 @@ +--!strict +-- RFC(relax-recursive-type-restriction.html)가 예시로 든 Promise.andThen과 +-- 정확히 같은 모양: Compute: (self: State, fn: (T) -> U) -> State +-- 만 단독으로 선언했을 때 그 자체로 "Recursive type being used with different +-- parameters"가 나는지 확인 (08 스파이크는 With 줄과 섞여 있어 Compute 단독 +-- 원인인지 불명확했음). + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (T) -> U) -> State, +} + +print("17 done") diff --git a/.claude/audit/type-recursion-issue/spikes/18-isolate-compute-lazy-different-params.luau b/.claude/audit/type-recursion-issue/spikes/18-isolate-compute-lazy-different-params.luau new file mode 100644 index 0000000..dbd273f --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/18-isolate-compute-lazy-different-params.luau @@ -0,0 +1,12 @@ +--!strict +-- 17과 같은데 fn이 raw T가 아니라 lazy self 핸들을 받는 버전(0-Y 원래 +-- 계약)도 "다른 파라미터로 재귀 참조" 자체로는 독립적으로 통과/실패하는지. +-- (bidirectional inference 문제와 별개로, 타입 "선언" 자체가 성립하는지만 봄 +-- -- 호출부 없음) + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +print("18 done") diff --git a/.claude/audit/type-recursion-issue/spikes/19-split-full-chain-stress.luau b/.claude/audit/type-recursion-issue/spikes/19-split-full-chain-stress.luau new file mode 100644 index 0000000..42a7709 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/19-split-full-chain-stress.luau @@ -0,0 +1,51 @@ +--!strict +-- 14의 "쪼개기" 트릭을 실전 규모로 스트레스 테스트: +-- - State:Compute 체이닝 (number -> string -> boolean, 서로 다른 T) +-- - State> 중첩 +-- - Source가 State를 구조적으로 만족 + Source도 같은 쪼개기 적용 +-- - 전부 무주석 인라인 람다로 + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +export type SourceData = StateData & { + Set: (self: SourceData, value: T) -> (), + Emit: (self: SourceData) -> (), +} +export type Source = SourceData & { + Compute: (self: SourceData, fn: (self: SourceData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end +local function fakeSource(v: T): Source + return (nil :: any) :: Source +end + +-- 1) 기본: 무주석 람다로 체이닝, T가 매 단계 바뀜(number -> string -> boolean) +local n: State = fakeState(5) +local s: State = n:Compute(function(x) return tostring(x:Get()) end) +local b: State = s:Compute(function(x) return #x:Get() > 0 end) + +-- 2) State> 중첩 -- 바깥 Compute의 U 자체가 State +local nested: State> = n:Compute(function(x) return fakeState(x:Get() + 1) end) +local unwrapped: State = nested:Compute(function(x) return x:Get():Get() end) + +-- 3) Source도 같은 패턴으로 Compute 체이닝 + Set/Emit +local src: Source = fakeSource(0) +src:Set(10) +src:Emit() +local derived: State = src:Compute(function(x) return tostring(x:Get()) end) + +-- 4) Source가 State 자리에 구조적으로 들어가는지(서브타입) +local function useAsState(st: State): T + return st:Get() +end +local viaSubtype: number = useAsState(src) + +print("19 done", b, derived, unwrapped, viaSubtype) diff --git a/.claude/audit/type-recursion-issue/spikes/20-caveat-lambda-param-lacks-compute.luau b/.claude/audit/type-recursion-issue/spikes/20-caveat-lambda-param-lacks-compute.luau new file mode 100644 index 0000000..6487c96 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/20-caveat-lambda-param-lacks-compute.luau @@ -0,0 +1,28 @@ +--!strict +-- 중요한 캐비엇 확인: 쪼개기 트릭에서 콜백이 받는 s는 StateData +-- (Get만 있음)로 타이핑됨 -- State(Compute 포함)가 아님. 그러면 +-- 콜백 "안에서" s:Compute(...)를 또 호출하는 중첩 패턴은 타입 에러가 +-- 나야 정상. 실제로 그런지, 그리고 이게 실사용에서 문제가 되는 패턴인지 +-- 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 콜백 안에서 받은 s로 또 Compute를 거는 중첩 -- 예상: 타입 에러 +-- (s: StateData엔 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) diff --git a/.claude/audit/type-recursion-issue/spikes/21-apply-needs-full-self-in-callback.luau b/.claude/audit/type-recursion-issue/spikes/21-apply-needs-full-self-in-callback.luau new file mode 100644 index 0000000..91a7f02 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/21-apply-needs-full-self-in-callback.luau @@ -0,0 +1,34 @@ +--!strict +-- 실사용 확인: tween-plan.md 341행 예시처럼 `:Apply`의 factory 콜백 +-- "내부에서" `self:Compute(...)`를 또 호출하는 패턴이 실제로 존재함. +-- 즉 Apply의 콜백 파라미터는 Compute 없는 StateData가 아니라 +-- 전체 State(=Compute 포함)여야 함 -- Compute와 요구사항이 다름. +-- +-- 실험: receiver(self, 메소드 디스패치용)는 StateData로 쪼개서 유지하되 +-- factory 콜백의 파라미터 타입만 전체 State로 명시하면 +-- (선언 자체가 아니라 "필드 시그니처 내부의 콜백 파라미터 위치"라 +-- 재귀 문제를 안 일으키는지) 무주석으로 통과하는지 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/21b-apply-minimal-isolated.luau b/.claude/audit/type-recursion-issue/spikes/21b-apply-minimal-isolated.luau new file mode 100644 index 0000000..6b23308 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/21b-apply-minimal-isolated.luau @@ -0,0 +1,25 @@ +--!strict +-- 21의 진단이 *error-type*/*CYCLE*로 뒤덮여 있다는 지적 — 캐스케이드 +-- 노이즈인지, 아니면 진짜 다른 문제인지 최소 형태로 재확인. +-- 분기(if)와 이중 Compute 호출을 다 걷어내고 "Apply 콜백 안에서 +-- Compute를 한 번만 호출"하는 가장 단순한 형태로 축소. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +local result = n:Apply(function(self) + return self:Compute(function(h) return h:Get() * 2 end) +end) + +print("21b done", result) diff --git a/.claude/audit/type-recursion-issue/spikes/21c-apply-two-branches-same-body.luau b/.claude/audit/type-recursion-issue/spikes/21c-apply-two-branches-same-body.luau new file mode 100644 index 0000000..37cfa8f --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/21c-apply-two-branches-same-body.luau @@ -0,0 +1,28 @@ +--!strict +-- 21b는 통과, 21은 실패. 유일한 차이는 21이 if-분기 두 개에서 각각 +-- self:Compute(...)를 부른다는 것. 분기 자체가 문제인지, 아니면 21 특유의 +-- "두 분기가 서로 다른 본문"이 문제인지 분리 -- 여기선 두 분기가 완전히 +-- 같은 본문. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/21d-apply-two-branches-different-expr-same-type.luau b/.claude/audit/type-recursion-issue/spikes/21d-apply-two-branches-different-expr-same-type.luau new file mode 100644 index 0000000..2f9265b --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/21d-apply-two-branches-different-expr-same-type.luau @@ -0,0 +1,28 @@ +--!strict +-- 21c는 완전히 같은 본문이라 통과했음. 이번엔 두 분기의 "표현식은 +-- 다르지만 최종 타입은 둘 다 number로 같음"인 경우도 통과하는지 확인 -- +-- 21(실패)의 진짜 원인이 "표현식이 다르다"인지 "결과 타입이 갈린다"인지 +-- 분리. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/21e-apply-branch-trivial-passthrough.luau b/.claude/audit/type-recursion-issue/spikes/21e-apply-branch-trivial-passthrough.luau new file mode 100644 index 0000000..cc99bbe --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/21e-apply-branch-trivial-passthrough.luau @@ -0,0 +1,27 @@ +--!strict +-- 21과 정확히 같은 두 분기(h:Get()*2 vs h:Get())로 재현 -- 21d와 다른 점은 +-- 두 번째 분기가 "그냥 h:Get()" 트리비얼 패스스루라는 것. 이게 원인인지 +-- 최종 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/22-apply-with-annotation-works.luau b/.claude/audit/type-recursion-issue/spikes/22-apply-with-annotation-works.luau new file mode 100644 index 0000000..6e9bc16 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/22-apply-with-annotation-works.luau @@ -0,0 +1,27 @@ +--!strict +-- 21의 실패를 확인했으니, "Apply의 factory만 명시 주석을 요구"하는 +-- 절충안이 실제로 동작하는지 확인 (self 파라미터에 타입 주석을 달면). + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + Apply: (self: StateData, factory: (self: State) -> U) -> U, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- Compute는 여전히 무주석 +local doubled = n:Compute(function(s) return s:Get() * 2 end) + +-- Apply만 self에 타입 주석 +local result = n:Apply(function(self: State) + return self:Compute(function(h) return h:Get() * 2 end) +end) + +print("22 done", doubled, result) diff --git a/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau b/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau new file mode 100644 index 0000000..5867363 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/23-with-plus-compute-real-example.luau @@ -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 = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +-- With(dep) -- 단일 추가 의존성 버전(2개 합치기), 콜백이 (self, dep) 두 +-- lazy 핸들을 받음. 실제로는 With가 별도 노드를 만들지만 여기선 타입 +-- 추론 자체만 검증. +export type Withable = StateData & { + With: (self: StateData, dep: StateData) -> WithResult, +} +export type WithResult = StateData & { + Compute: (self: WithResult, fn: (self: StateData, dep: StateData) -> U) -> State, +} + +local function fakeState(v: T): State & Withable + return (nil :: any) :: State & Withable +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) + return true +end) +print("t check", t) diff --git a/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau b/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau new file mode 100644 index 0000000..75a5709 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/24-with-heterogeneous-types.luau @@ -0,0 +1,30 @@ +--!strict +-- 23과 동일하지만 진짜 이형 타입(T=number, D=string)으로 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +export type Withable = StateData & { + With: (self: StateData, dep: StateData) -> WithResult, +} +export type WithResult = StateData & { + Compute: (self: WithResult, fn: (self: StateData, dep: StateData) -> U) -> State, +} + +local function fakeState(v: T): State & Withable + return (nil :: any) :: State & Withable +end + +local n: State & Withable = fakeState(0) +local s: State & Withable = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/25-observer-no-local-generic.luau b/.claude/audit/type-recursion-issue/spikes/25-observer-no-local-generic.luau new file mode 100644 index 0000000..ccb7a4a --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/25-observer-no-local-generic.luau @@ -0,0 +1,25 @@ +--!strict +-- Observer는 state:Observer(fn) 메소드지만 U 같은 로컬 제네릭 반환이 없음 +-- (EffectHandle을 반환할 뿐, State를 만들지 않음) -- 그래서 굳이 쪼개기 +-- 트릭 없이도 self:State 그대로 필드로 둬도 무주석 람다가 통과하는지 확인 +-- (matrix B 패턴과 동일한 결일 것으로 예상). + +export type EffectHandle = { + Unsubscribe: (self: EffectHandle) -> (), +} +export type State = { + Get: (self: State) -> T, + Observer: (self: State, fn: (self: State) -> ()) -> EffectHandle, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +local handle = n:Observer(function(s) + print(s:Get() * 2) +end) + +print("25 done", handle) diff --git a/.claude/audit/type-recursion-issue/spikes/26-deep-chain-many-computes.luau b/.claude/audit/type-recursion-issue/spikes/26-deep-chain-many-computes.luau new file mode 100644 index 0000000..9da0ac0 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/26-deep-chain-many-computes.luau @@ -0,0 +1,35 @@ +--!strict +-- 깊은 체이닝 스트레스 테스트: Compute를 10번 이상 연쇄, 타입도 계속 +-- 바꿔가며(number -> string -> number -> boolean -> ... ) 무주석 람다로 +-- 전부 연결. 얕은 체인(19)에서만 통과하고 깊어지면 솔버가 포기하는지 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local s0: State = 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) diff --git a/.claude/audit/type-recursion-issue/spikes/27-does-error-type-still-catch-mismatches.luau b/.claude/audit/type-recursion-issue/spikes/27-does-error-type-still-catch-mismatches.luau new file mode 100644 index 0000000..741d5e5 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/27-does-error-type-still-catch-mismatches.luau @@ -0,0 +1,26 @@ +--!strict +-- 결정적 질문: --annotate가 보여준 Unifiable가 진짜 "타입 체크가 +-- 무력화됐다"는 뜻인지 확인. Compute 리턴값을 明시 주석 없이 받은 뒤, +-- 일부러 틀린 타입에 대입해서 정말 안 잡히는지 테스트. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 명시 주석 없이 받음(19의 s/b처럼 LHS에 State 안 붙임) +local s = n:Compute(function(x) return tostring(x:Get()) end) + +-- 일부러 틀린 타입 대입 -- 진짜 타입체크가 되고 있다면 여기서 에러가 나야 함 +local wrong: number = s:Get() -- s는 State이어야 하므로 Get()은 string. number에 대입하면 에러여야 정상 +local right: string = s:Get() + +print("27 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/28-explicit-lhs-annotation-still-safe.luau b/.claude/audit/type-recursion-issue/spikes/28-explicit-lhs-annotation-still-safe.luau new file mode 100644 index 0000000..16c795d --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/28-explicit-lhs-annotation-still-safe.luau @@ -0,0 +1,25 @@ +--!strict +-- 27이 "무주석 LHS"에서 타입 안전성이 완전히 무력화됨을 보여줬음. +-- 그럼 19처럼 LHS에 명시 타입(local s: State = ...)을 붙이면 +-- 최소한 그 자리에서는 안전한지 확인 -- 만약 명시 LHS도 "틀린 타입"을 +-- 넣었을 때 안 잡히면, Unifiable가 정말 any처럼 뭐든 다 받아 +-- 삼킨다는 뜻이라 이 트릭 전체가 무의미해짐. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 콜백은 string을 반환하는데 LHS는 일부러 State이라고 우김 +-- -- 진짜 타입체크가 되고 있다면 여기서 에러가 나야 함 +local wrongLHS: State = n:Compute(function(x) return tostring(x:Get()) end) + +print("28 done", wrongLHS) diff --git a/.claude/audit/type-recursion-issue/spikes/29-is-callback-param-itself-safe.luau b/.claude/audit/type-recursion-issue/spikes/29-is-callback-param-itself-safe.luau new file mode 100644 index 0000000..a8b47f8 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/29-is-callback-param-itself-safe.luau @@ -0,0 +1,27 @@ +--!strict +-- 27/28에서 Compute의 "리턴 타입"(U/State)이 Unifiable로 새는 +-- 걸 확인했음. 그런데 콜백의 "파라미터"(s: StateData) 자체는 여전히 +-- 제대로 추론되고 있는지는 별개로 확인 필요 -- 파라미터에 대해 존재하지 +-- 않는 필드/메소드를 부르면 정상적으로 잡히는지 테스트. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 존재하지 않는 메소드 호출 -- 파라미터 타입이 진짜 StateData로 +-- 추론되고 있다면 여기서 "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) diff --git a/.claude/audit/type-recursion-issue/spikes/30-fix-U-with-return-annotation.luau b/.claude/audit/type-recursion-issue/spikes/30-fix-U-with-return-annotation.luau new file mode 100644 index 0000000..efcdf66 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/30-fix-U-with-return-annotation.luau @@ -0,0 +1,26 @@ +--!strict +-- 27/28이 U(=State의 U)가 Unifiable로 새는 걸 확인했음. +-- 콜백의 "리턴 타입"만 명시(파라미터는 여전히 무주석)하면 U가 제대로 +-- 잡히는지 확인 -- 이게 되면 "파라미터는 무주석, 리턴만 명시"라는 +-- 절충 관용구가 살아남음. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 파라미터는 무주석, 리턴 타입만 명시 +local s = n:Compute(function(x): string return tostring(x:Get()) end) + +local wrong: number = s:Get() -- s가 진짜 State이면 여기서 에러여야 함 +local right: string = s:Get() + +print("30 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/31-fully-annotated-nonsplit-soundness.luau b/.claude/audit/type-recursion-issue/spikes/31-fully-annotated-nonsplit-soundness.luau new file mode 100644 index 0000000..9d2cab5 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/31-fully-annotated-nonsplit-soundness.luau @@ -0,0 +1,24 @@ +--!strict +-- 대조군: "쪼개기 없이, 파라미터/리턴 둘 다 명시 주석"(=question.md +-- 선택지 1, 이미 known-working으로 알려진 경우)도 U가 진짜 정상 해소되는지 +-- --annotate로 확인. 여기서도 Unifiable가 뜨면 이건 쪼개기와 +-- 무관한 Luau 자체의 한계라는 뜻. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 파라미터/리턴 둘 다 명시 +local s = n:Compute(function(x: State): string return tostring(x:Get()) end) + +local wrong: number = s:Get() +local right: string = s:Get() + +print("31 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/32-direct-U-return-soundness.luau b/.claude/audit/type-recursion-issue/spikes/32-direct-U-return-soundness.luau new file mode 100644 index 0000000..be59ee8 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/32-direct-U-return-soundness.luau @@ -0,0 +1,9 @@ +--!strict +type Box = { value: number } +local function Compute(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) diff --git a/.claude/audit/type-recursion-issue/spikes/33-same-param-recursion-soundness.luau b/.claude/audit/type-recursion-issue/spikes/33-same-param-recursion-soundness.luau new file mode 100644 index 0000000..1df66f7 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/33-same-param-recursion-soundness.luau @@ -0,0 +1,20 @@ +--!strict +-- 결정적 대조군: Compute가 State(같은 T)를 반환하면(=matrix B, 03과 +-- 동일 패턴) 진짜 sound한지, 아니면 이것도 Unifiable인지 확인. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> T) -> State, -- U 없음, 항상 같은 T +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) +local doubled = n:Compute(function(x) return x:Get() * 2 end) + +local wrong: string = doubled:Get() -- doubled가 State면 여기서 에러여야 함 +local right: number = doubled:Get() + +print("33 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/34-rawvalue-contract-soundness.luau b/.claude/audit/type-recursion-issue/spikes/34-rawvalue-contract-soundness.luau new file mode 100644 index 0000000..ed568ea --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/34-rawvalue-contract-soundness.luau @@ -0,0 +1,23 @@ +--!strict +-- 08 스파이크(raw value fn, option 2)도 Compute가 State를 반환하는 한 +-- 같은 문제(differing recursion)를 겪는지 확인 -- LHS 명시 주석을 빼고 +-- 진짜 추론 결과를 봄. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (T) -> U) -> State, -- raw value 계약(옵션 2) +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = 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이어야 하므로 에러여야 함 +local right: string = s:Get() + +print("34 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/35-wrapped-nonrecursive-container-soundness.luau b/.claude/audit/type-recursion-issue/spikes/35-wrapped-nonrecursive-container-soundness.luau new file mode 100644 index 0000000..9992f35 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/35-wrapped-nonrecursive-container-soundness.luau @@ -0,0 +1,20 @@ +--!strict +-- 마지막 분리: "재귀"가 핵심 원인인지, 아니면 "로컬 제네릭 U를 아무 +-- 컨테이너로든 감싸서 반환하면" 다 이 모양인지 확인. Box는 자기 자신을 +-- 재참조하지 않는 평범한 제네릭 컨테이너(Compute 필드 없음) -- 여기서도 +-- Unifiable가 뜨면 "재귀"는 무관하고 "제네릭 U를 감싸서 반환" +-- 자체가 원인. + +type Box = { value: A } + +local function Compute(self: Box, fn: (self: Box) -> U): Box + return { value = fn(self) } +end + +local box: Box = { value = 5 } +local s = Compute(box, function(x) return tostring(x.value) end) + +local wrong: number = s.value -- s가 Box이어야 하므로 에러여야 함 +local right: string = s.value + +print("35 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/36-explicit-generic-try1.luau b/.claude/audit/type-recursion-issue/spikes/36-explicit-generic-try1.luau new file mode 100644 index 0000000..4f27df2 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/36-explicit-generic-try1.luau @@ -0,0 +1,9 @@ +--!strict +type StateA = { + __inner: T, + Compute: ( self: Self, func: (self: Self)->any ) -> Target, +} +local t = (nil :: any) :: StateA +local a = t:Compute>(function(self) + return nil +end) diff --git a/.claude/audit/type-recursion-issue/spikes/37-explicit-generic-single.luau b/.claude/audit/type-recursion-issue/spikes/37-explicit-generic-single.luau new file mode 100644 index 0000000..ae21d09 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/37-explicit-generic-single.luau @@ -0,0 +1,15 @@ +--!strict +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +local function fakeState(v: T): State + return (nil :: any) :: State +end +local n: State = fakeState(5) +local s = n:Compute(function(x) return tostring(x:Get()) end) +local wrong: number = s:Get() +local right: string = s:Get() +print("37 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/38-double-angle-bracket.luau b/.claude/audit/type-recursion-issue/spikes/38-double-angle-bracket.luau new file mode 100644 index 0000000..daee29c --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/38-double-angle-bracket.luau @@ -0,0 +1,9 @@ +--!strict +export type StateB = { + __inner: T, + Compute: ( self: StateB, func: (self: StateB)->any ) -> U, +} +local t = (nil :: any) :: StateB +local mapped = t:Compute<>>(function(self) + return nil +end) diff --git a/.claude/audit/type-recursion-issue/spikes/39-double-angle-bracket-garbage.luau b/.claude/audit/type-recursion-issue/spikes/39-double-angle-bracket-garbage.luau new file mode 100644 index 0000000..ea33d65 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/39-double-angle-bracket-garbage.luau @@ -0,0 +1,9 @@ +--!strict +export type StateB = { + __inner: T, + Compute: ( self: StateB, func: (self: StateB)->any ) -> U, +} +local t = (nil :: any) :: StateB +local mapped = t:Compute<>(function(self) + return nil +end) diff --git a/.claude/audit/type-recursion-issue/spikes/40-double-angle-bracket-on-real-problem.luau b/.claude/audit/type-recursion-issue/spikes/40-double-angle-bracket-on-real-problem.luau new file mode 100644 index 0000000..d94468d --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/40-double-angle-bracket-on-real-problem.luau @@ -0,0 +1,24 @@ +--!strict +-- <<...>>가 진짜 명시적 제네릭 인자 문법인지, 그리고 그걸로 문제 B +-- (Compute의 State 반환 불안전성)를 우회할 수 있는지 결정적으로 확인. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- 명시적으로 U=string이라고 못박음 +local s = n:Compute<>(function(x) return tostring(x:Get()) end) + +local wrong: number = s:Get() -- 진짜 sound하면 여기서 에러여야 함 +local right: string = s:Get() + +print("40 done", wrong, right) diff --git a/.claude/audit/type-recursion-issue/spikes/41-double-angle-bracket-fixes-problemA.luau b/.claude/audit/type-recursion-issue/spikes/41-double-angle-bracket-fixes-problemA.luau new file mode 100644 index 0000000..cb09ba1 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/41-double-angle-bracket-fixes-problemA.luau @@ -0,0 +1,20 @@ +--!strict +-- <<...>>가 문제 B는 못 고쳤음. 그럼 문제 A(파라미터 self 추론)는 +-- 고쳐주는지 확인 -- 쪼개기 없이 원본 재귀 타입 그대로, 명시 제네릭 +-- 인자만 주는 방식으로 무주석 self 파라미터가 통과하는지. + +export type State = { + Get: (self: State) -> T, + Compute: (self: State, fn: (self: State) -> U) -> State, +} + +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local state: State = fakeState(0) + +-- 쪼개기 없이, self는 여전히 State(재귀), 대신 명시 제네릭 인자 시도 +local doubled = state:Compute<>(function(s) return s:Get() * 2 end) + +print("41 done", doubled) diff --git a/.claude/audit/type-recursion-issue/spikes/42-explicit-generic-on-nonrecursive-sanity.luau b/.claude/audit/type-recursion-issue/spikes/42-explicit-generic-on-nonrecursive-sanity.luau new file mode 100644 index 0000000..45f472b --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/42-explicit-generic-on-nonrecursive-sanity.luau @@ -0,0 +1,13 @@ +--!strict +-- <<...>>가 일반적인(재귀 아닌) 제네릭 함수에는 진짜 작동하는지 sanity +-- check -- 이게 되면 문법/배선 자체는 살아있고, 문제 B는 정말 재귀 +-- 케이스에만 있는 별개의 버그라는 뜻. + +local function identity(v: T): T + return v +end + +local x = identity<>(5 :: any) -- 명시적으로 string이라고 우김(5는 any로 캐스트해서 통과시킴) +local wrong: number = x -- 진짜 explicit 타입이 먹혔으면(x: string) 여기서 에러여야 함 + +print("42 done", wrong) diff --git a/.claude/audit/type-recursion-issue/spikes/43-explicit-lhs-binds-downstream.luau b/.claude/audit/type-recursion-issue/spikes/43-explicit-lhs-binds-downstream.luau new file mode 100644 index 0000000..aa63a12 --- /dev/null +++ b/.claude/audit/type-recursion-issue/spikes/43-explicit-lhs-binds-downstream.luau @@ -0,0 +1,26 @@ +--!strict +-- 결정적 확인: LHS 명시 주석이 "RHS를 검증"해주진 않지만(28에서 확인), +-- 최소한 "다운스트림에 올바른 타입을 바인딩"은 해주는가? +-- 이게 되면 "명시적 타입 바인딩 강제"가 실효성 있는 대응책이 됨. + +export type StateData = { + Get: (self: StateData) -> T, +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, +} +local function fakeState(v: T): State + return (nil :: any) :: State +end + +local n: State = fakeState(5) + +-- LHS에 명시 주석 +local s: State = n:Compute(function(x) return tostring(x:Get()) end) + +-- 다운스트림에서 s가 진짜 State으로 취급되는가? +local ok: string = s:Get() -- 맞는 사용 -- 통과해야 함 +local bad: number = s:Get() -- 틀린 사용 -- 에러나야 함(다운스트림 바인딩이 살아있다면) +local bad2 = s:NoSuchMethod() -- 없는 메소드 -- 에러나야 함 + +print("43", ok, bad, bad2) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index d59ddc7..570398b 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -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`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 +> 자체**였음(Luau의 현 한계, RFC가 `Promise.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 타입 추론/인스턴스·이벤트 네이밍/ diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index dbc8964..951633d 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -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가 바인드된 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 55e21c2..5005fe8 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -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) 여러 diff --git a/.claude/base/store-semantics.md b/.claude/base/store-semantics.md index ad8fbe4..daa32a3 100644 --- a/.claude/base/store-semantics.md +++ b/.claude/base/store-semantics.md @@ -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`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면(`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`의 `:Compute` 시그니처가 자기 자신(`Source`)과 `State`를 동시에 참조하는 제네릭 메소드라, Luau 솔버가 재귀 타입 조합에서 막히지 않는지가 원래 질문이었음. 구분해서 볼 것: diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 95b4f4f..c658d5a 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -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 북키핑 — 정리 안 해도 됨 (확정) diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md new file mode 100644 index 0000000..1cba009 --- /dev/null +++ b/.claude/base/typing-limits.md @@ -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(...) -> State` 모양인 건 본질이고, + Luau가 지금 그걸 못 다룬다고 해서 반환 타입을 `any`로 열거나, + API를 다른 모양으로 재설계하거나, 제네릭을 포기하고 타입별 코드를 + 생성하는 건 **전부 하지 않습니다.** +- **이유는 "게을러서"가 아니라 "그게 더 비싸서"입니다.** 지금 비틀어 + 놓으면 (a) 비튼 만큼 복잡도와 사용자 인체공학 손해가 영구히 + 남고, (b) Luau가 고쳐졌을 때 자동으로 수혜를 받는 게 아니라 비튼 + 걸 되돌리는 별도 마이그레이션이 필요해집니다. 아래 1번의 실측이 + 이걸 구체적으로 보여줍니다 — **지금 그대로 두면 Luau 쪽 수정만으로 + 코드 변경 없이 풀립니다.** +- **대신 반드시 하는 것**: (1) 한계를 이 문서에 명시, (2) 사용자 + 코드가 취해야 하는 관례를 명시, (3) 추적 링크(RFC/이슈)를 남겨 + 나중에 되돌릴 지점을 알 수 있게 함. + +**예외**: 그 한계를 우회하는 방법이 *비트는 게 아니라 그냥 더 나은 +설계*인 경우엔 당연히 채택합니다(아래 1번의 "쪼개기"가 실제로 그런 +사례 — 코드 생성 없이 타입 선언 두 개로 끝나고, 나중에 Luau가 +고쳐져도 손해가 없음). + +--- + +## 1. ⭐ 재귀 제네릭이 다른 타입 인자로 자기를 반환하면 타입 안전성이 조용히 사라짐 + +**실측 근거: `audit/type-recursion-issue/`**(REPORT.md + 재현 스파이크 +44개). 이게 이 문서에서 가장 크고, 가장 넓게 영향을 주는 항목입니다. + +### 무엇이 안 되는가 + +`Compute(self: State, fn) -> State`처럼 **자기 자신과 같은 +이름의 재귀 타입을, 자기 타입 파라미터(`T`)와 다른 인자(`U`)로 다시 +감싸서 반환**하면, 반환 타입이 실제로는 해소되지 않고 Luau 내부의 +`Unifiable`로 **조용히** 샙니다. + +**"조용히"가 핵심입니다** — 컴파일 에러가 나서 막히는 게 아니라, +진단 0건으로 통과한 뒤 그 결과에 대한 타입 체크만 사라집니다: + +```lua +local s = n:Compute(function(x) return tostring(x:Get()) end) -- s는 State이어야 함 +local wrong: number = s:Get() -- ❌이어야 하는데 에러 안 남 +``` + +`luau-analyze` / `luau-analyze --annotate` / `luau-lsp`(새 솔버) +세 경로 전부 동일 — 도구 문제가 아니라 Luau 자체의 현 한계입니다. + +### 정확한 경계 (오해 방지 — 이것들은 멀쩡함) + +실측으로 좁힌 결과 **"제네릭을 감싸서 반환하는 것" 자체는 문제가 +아닙니다.** 아래는 전부 정상 작동합니다: + +| 패턴 | 상태 | +|---|---| +| 같은 인자로만 재귀(`-> State`, `U` 없음) | ✅ 정상 | +| 재귀 아닌 컨테이너에 담아 반환(`-> Box`) | ✅ 정상 | +| 감싸지 않고 그대로 반환(`-> U`) | ✅ 정상 | +| 콜백 **파라미터**의 타입 추론 | ✅ 정상(아래 "쪼개기" 적용 시) | +| 콜백 **안**의 로직(원본 값을 어떻게 다루는지) | ✅ 정상 | +| 명시 주석 **이후** 다운스트림 전체 | ✅ 정상 | +| **자기 이름을 다른 인자로 감싸 반환**(`State` → `State`) | ❌ **이것만** | + +즉 구멍은 정확히 **"그 한 줄이 진짜 그 타입을 만드는가"** 하나로 +좁혀집니다. + +### 그래서 우리가 하는 것 — ① 명시적 타입 바인딩 강제 + +**파생 State를 만드는 자리마다 결과 타입을 `:` 주석으로 명시합니다.** + +```lua +-- ✅ 이렇게 +local label: State = 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 = { + Get: (self: StateData) -> T, -- 자기 자신만 재참조(Compute를 모름) +} +export type State = StateData & { + Compute: (self: StateData, fn: (self: StateData) -> U) -> State, + -- self / 콜백 파라미터 둘 다 StateData를 가리킴 +} +``` + +- 이러면 `state:Compute(function(s) return s:Get() * 2 end)`가 + **무주석으로 통과**하고, `s`에 대한 타입 체크도 진짜로 살아있습니다 + (없는 필드 접근하면 정상적으로 에러남). +- **코드 생성이 필요 없습니다.** `State`는 여전히 진짜 제네릭이고, + 손으로 쓰는 타입 선언이 하나 늘 뿐입니다. (한때 검토했던 "T별로 + 구워서 인라이닝"은 채택 안 함 — 0번 대전제 위반이고, 제네릭을 + 없애버려서 나중에 Luau가 고쳐져도 수혜를 못 받음.) +- **캐비엇**: 콜백이 받는 `s`는 `StateData`라 `Compute`/`With`가 + 없습니다. 콜백 안에서 다시 `s:Compute(...)`를 부르는 자리 + (`:Apply`의 factory가 대표적)는 이 방식으로 못 풀고 + `function(self: State)`처럼 파라미터 주석이 필요합니다 — + 다만 `: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.andThen: + (self: Promise, callback: (T) -> Promise) -> Promise`로, + **우리 `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<>` 제네릭 키의 값 타입 narrowing은 안 됨 + +**근거: `luau-test/done/12-type-attribute-generic-key-narrowing.luau`** + +`[AttributeKey<> "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` → `{[K]: Source}` 합성을 Luau `type function`으로 하는 +설계는 **설계 레벨로는 확정**(`pre-implementation-audit.md` 1-10)이지만, +스파이크가 `types.newfunction` 시그니처 불일치로 깨져 **실측 확인이 안 +된 상태**입니다. + +**우리가 하는 것**: 스파이크 재작성 후 재시도(에이전트 몫, +`luau-test/STATUS.md` 🟠). `type function`은 비교적 최근/진화 중인 +기능이라 버전에 따라 API가 다를 수 있음. + +--- + +## 6. 성립이 확인된 것 (안심해도 되는 것) + +한계만 모아두면 "타입이 다 안 되는구나"로 오독되기 쉬워서 같이 적습니다. +아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것: + +- **`Source`가 `State`를 구조적으로 만족**(서브타입으로 그대로 + 넘길 수 있음) — `luau-test/review-required/08`. 단 **단방향 의존을 + 유지해야 함**(`State`가 `Source`를 참조하면 안 됨 — 두 제네릭 + 별칭의 상호 재귀는 솔버가 취약한 패턴). +- **`PreRef`가 `Ref` 자리에 대입 가능** — `luau-test/13` A섹션. +- **Modifier의 제네릭 `__index` + `table.clone` 체이닝** — `luau-test/done/17`. +- **콜백 파라미터/본문의 타입 체크**(1번의 쪼개기 적용 시) — 진짜 + 살아있음. + +--- + +## 7. 새 타입/API를 설계할 때 체크리스트 + +1. **자기 이름을 다른 타입 인자로 감싸 반환하는가?**(`Foo` 안에서 + `-> Foo`) → 1번 한계에 걸림. 설계를 바꾸지 말고(0번 대전제), + 명시 바인딩 관례를 문서에 같이 적을 것. +2. **로컬 제네릭을 가진 메소드가 재귀 타입의 필드인가?** → 1번의 + "쪼개기"를 적용할 것(`XxxData` / `Xxx` 분리). +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번 관례 재검증. diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index b7499ad..531713d 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -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개) | — | diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index bbc8735..4a41979 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -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`가 **자기 자신**을 다른 타입 인자로 재귀 참조하면 `Recursive type being used with different parameters` — 사용자 방향은 "구울 때 인라이닝" | `question.md` **0-Y** 하단 | +**[2026-08-13 열세 번째 세션] 마지막 한 건이 해소됐습니다.** +`08-type-source-satisfies-state.luau`가 남겨뒀던 잔여 케이스(`State`가 +자기 자신을 다른 타입 인자로 재귀 참조하면 막힘)는 **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`가 아님 | diff --git a/.claude/luau-test/review-required/08-type-source-satisfies-state.luau b/.claude/luau-test/done/08-type-source-satisfies-state.luau similarity index 100% rename from .claude/luau-test/review-required/08-type-source-satisfies-state.luau rename to .claude/luau-test/done/08-type-source-satisfies-state.luau diff --git a/.claude/question.md b/.claude/question.md index 2399564..2fad4a7 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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` 핸들을 받는 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` | ❌ | -| 콜백 파라미터에 타입 주석 | ✅ | -| **콜백이 raw 값 `T`를 받으면** | ✅ **완전 클린** | - -즉 표기 조정으로는 안 풀리고, **계약 자체가 원인**. `:Compute`만이 -아니라 `Effect`/`Observer`/`Animate`/`Operator` 등 같은 계약을 공유하는 -API 전부에 걸리며, 2026-08-07 일곱 번째 세션(커링 스타일 확정)과 -2026-08-12 네 번째 세션(`:Get()` 누락 버그 전역 감사)이 전부 이 계약 -위에 서 있음. - -**선택지**: -1. **계약 유지 + 파라미터 주석 필수** — 설계 변경 없음, 대신 가장 흔한 - 자리에 매번 `function(s: State)`를 써야 하는 인체공학 손해. -2. **콜백이 raw 값을 받도록 전환** — 타입 추론은 완벽해지지만 lazy - 평가/trailing deps/`previous` 설계 전반과 충돌. **구조 변경 규모가 큼** - (사용자가 이번 라운드에서 미리 잡고 싶다고 한 바로 그 종류). -3. **혼합** — 기본은 raw, lazy가 실제로 필요한 자리만 별도 API. - -**M0 착수 전에 정하는 게 맞음** — 2번을 고르면 base 문서 다수가 영향받음. - -**[사용자 방향, 2026-08-13]** 순환 타입을 만드는 것보다 **`State` 타입 -자체를 구울 때(코드 생성 시점) 인라이닝**해주는 쪽이 맞아 보인다는 의견 — -즉 `State`가 자기 자신을 재귀 참조하는 선언을 피하고, 타입 생성기가 -각 `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의 diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index fd1bcfe..c4a8796 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -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도 사용처에서 명시 +> 주석 바인딩이 필요한 대상임. ## 동기 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 8e48863..77299ee 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -362,7 +362,9 @@ State를 구조적으로 만족)이 통과했음 — 아래 "검증이 실패했 전제 자체가 (핵심 케이스에 한해) 더 이상 미래형이 아님. 다만 통과와 별개로 좁은 잔여 케이스(`State`가 자기 자신을 다른 타입 인자로 재귀 참조하는 경우, `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 스파이크에 폴백 한 줄 박아두기)은 더 이상 적용 대상 없음. 원래 서술은 배경 기록으로 남김: diff --git a/.claude/session/2026-08-13-13-type-recursion-limit-resolved.md b/.claude/session/2026-08-13-13-type-recursion-limit-resolved.md new file mode 100644 index 0000000..a83ed6b --- /dev/null +++ b/.claude/session/2026-08-13-13-type-recursion-limit-resolved.md @@ -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` 지연 평가 등). 요청: + +> "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`를 "Get만 있는 `StateData`"와 + "`Compute` 등을 얹은 `State`"로 쪼개고 메소드의 self/콜백 파라미터를 + `StateData`로 가리키게 하면 무주석 람다가 통과. +- 스트레스 테스트(`19`/`23`/`24`)도 통과 — 체이닝, `State>`, + `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) + return true +end) +``` + +> "t는 error type 나오더라" + +CLI로는 클린 통과였는데 에디터 진단이 달랐음. 원인을 쫓다가 +**`luau-analyze --annotate`(추론된 실제 타입을 소스에 찍어주는 옵션)**를 +써봤고, 여기서 진짜가 드러남: + +```lua +local doubled:Unifiable=state:Compute(function(s:StateData): number ...) +``` + +**"진단 0건으로 통과"했던 것들이 전부 `Unifiable`(Luau 내부 +미해결/에러 타입)였음.** 즉 타입 체커가 조용히 포기한 상태였고, 겉으로만 +성공처럼 보였던 것. + +### 얼마나 나쁜지 확인 (`27`/`28`) + +```lua +local s = n:Compute(function(x) return tostring(x:Get()) end) -- State이어야 함 +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`. **즉 0-Y의 선택지 +1/2/3 프레이밍 자체가 잘못된 전제 위에 서 있었음.** + +### 정확한 경계 (`32`/`33`/`35`) + +대조군으로 좁힌 결과 **"제네릭을 감싸 반환하는 것" 자체는 문제가 아님**: + +- `33`: 같은 T로만 재귀(`-> State`) → ✅ 진짜 sound +- `35`: 재귀 아닌 컨테이너(`-> Box`) → ✅ 진짜 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에 대한 andThen과 같은 흔한 유형을 위해 다른 유형 리컬션을 +> 미래에 지원할 계획은 있어보임. 따라서 우리는 당장 luau가 타입으로써 +> 그것을 제공하기 전까지는 제공 못 한다는 제약사항이 생겨도 될 것 +> 같다는 결론이 나왔음." + +fetch해보니 정확했음 — RFC가 **거부되는 예시로 직접 드는 게** +`Promise.andThen: (self: Promise, callback: (T) -> Promise) +-> Promise`로, **우리 `Compute`와 글자 그대로 같은 모양**. 완화 근거는 +"This pays for itself in the considerable gain in expressivity gained +for users of the type system"이고, 메커니즘은 "타입 별칭을 진짜 type +function처럼 취급해 lazy expansion"이라는 **순수 내부 변경(사용자 문법 +변경 없음)**. + +옛/새 솔버 대조로 상태도 정확히 파악: 옛 솔버는 이 패턴을 **선언 +시점에 거부**, 새 솔버는 **선언은 받아주지만 인스턴스화는 아직 안 됨** +→ 그래서 "조용히 새는" 중간 상태. + +## 5. 후속 질문 두 개 + +사용자 추가 요청: + +> "State:Compute() 로써 직접 넣어주는 것. +> 혹은 RFC가 낙관적으로 보는 방향성이 가능하도록 플레이싱 홀드 해놓고 +> 나중에 변경될 경우 자연히 등록될 수 있는 방식을 찾고 싶습니다." + +### 5-1. 명시 제네릭 인자 — 문법은 실재하나 이 케이스엔 무효 + +Luau 소스(`Ast/src/Parser.cpp`의 `parseMethodCall`)를 직접 열어 확인: +메소드 호출 뒤 `<`가 **연달아 둘**(`<<`) 오면 `parseTypeInstantiationExpr`로 +분기(단일 `<`는 비교 연산자와 충돌해서 안 됨). 이 정보는 새 솔버의 +`ConstraintGenerator.cpp`(2818행)에서 실제로 읽혀 `FunctionCallConstraint`까지 +전달됨 — 죽은 코드 아님. + +- `42`(일반 제네릭 `identity<>(...)`): ✅ 진짜 작동, 틀린 대입도 잡힘 +- `40`(우리 `Compute<>`): ❌ 여전히 `Unifiable` +- `41`(문제 A에 적용): ❌ 완전 무효과 + +→ **U를 명시로 못박아도 `State` 확장 단계가 안 되므로 무용지물.** + +### 5-2. 플레이스홀더 — 필요 없음(좋은 소식) + +RFC 완화가 순수 내부 변경이고 우리 선언이 **이미 그 대상 모양 그대로**라, +지금 뭘 심어둘 필요가 없음. 오히려 **하지 말아야 할 것**이 분명해짐 — +한때 검토됐던 "T별 코드 생성(구울 때 인라이닝)"으로 갔다면 제네릭 +자체를 없애버려 RFC 수혜 대상에서 스스로 이탈했을 것. + +## 6. 사용자 최종 정리 → 확정 + +> "해당 제한이 풀리기 전까지는 명시적 타입바인딩으로 `:` 사용이 +> 강제되나, 그것이 들어오고 난 뒤에는 풀리게 된다. 지금으로써 quad +> 프로젝트가 타입을 비틀어 해당 시도를 하는 건 전혀 적합하지 않고, +> 이것은 상위의 Luau의 현 한계다. RFC와 해당 이슈 해결의 수혜를 받게 +> 될 때 해결될 이슈로써, 당장 우리가 할 수 있는 바 없다." + +이 "명시적 타입 바인딩" 대응이 실효가 있는지는 문서화 전에 별도 +실측(`43`)으로 확인했고, **먹힘**: + +```lua +local s: State = 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` 동작 때문에 중간에 잘못된 결론을 낼 뻔했음. diff --git a/CLAUDE.md b/CLAUDE.md index a845b79..eb349d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 핸들을 받는 커링 계약이 - **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`로 조용히 새고 +있었음 — 틀린 대입도 안 잡힘). 진짜 원인은 콜백 계약이 아니라 **`Compute`가 +`State`(자기 이름을 다른 타입 인자로 감싼 타입)를 반환한다는 것 자체**로, +사용자가 찾아온 RFC(`relax-recursive-type-restriction`)가 `Promise.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` + 음성 대조군 필수. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index a57b2a5..0d67619 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -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 착수는 안 막음). --- diff --git a/ROADMAP.md b/ROADMAP.md index ae241c6..b9affb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 — 스켈레톤 + 기술검증 (스파이크, "진짜" 마일스톤 아님)