diff --git a/.claude/README.md b/.claude/README.md index 18bd00b..e44309b 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -16,7 +16,7 @@ | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(CLAUDE.md 세션 로그 중복 방지) | | `feedback/` | 실사용 피드백을 정리한 긴 로그 — 지금은 비어있음(구현 시작 전) | | `luau-test/` | **[2026-08-09 신설]** `base/` 확정 사항 중 "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"(M0 스파이크 대상)을 `luau`/`luau-analyze`/`luau-lsp`/Roblox Studio로 사용자가 직접 돌려볼 독립 실행 스크립트 모음. **[2026-08-13 여섯 번째 세션, 첫 실측]** `luau`/`luau-analyze` 바이너리가 생겨 처음으로 실제 실행 — **런타임 12개 전원 통과**, 타입 쪽에서 `:Compute(fn)` lazy 핸들 계약이 Luau 추론과 충돌하는 게 드러남(당시 `question.md` 0-Y). **[2026-08-13 열세 번째 세션]** 그 0-Y가 해소되며 `review-required/`가 **비었음** — 계약은 유지 확정, 남은 건 Luau 자체 한계라 `base/typing-limits.md`가 담당. **`STATUS.md`가 상태의 소스**(pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행 분류 — 사람이 먼저 볼 것만 위에), `luau-test/README.md`는 각 파일의 검증 의도·배경, 실행 결과 상세는 `audit/luau-test-first-run-2026-08-13.md` | -| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 4개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션]** 실측된 사실 자체는 그대로 유효하고 `canExecute(value)` 1-인자 재정정으로 오히려 더 중요해졌으나, 인용하던 `canBound`가 폐기돼 미확인 항목 목록을 새 모델 기준으로 갱신함 — 이중 바인딩 게이트/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`) | +| `audit/` | **[2026-08-13 신설]** `luau-test/` 등 스파이크를 실제로 돌려본 뒤 "무엇이 확인됐고 무엇이 아직 안 됐는지"를 기록하는 곳 — 스크립트/계획 자체가 아니라 **실측 결과**만 다룸. base/luau-test와 달리 부분 확인(일부만 통과)도 있는 그대로 기록, 완전히 해소되면 관련 `base/`/`luau-test/README.md` 캐비엇을 지우고 이 문서는 근거로 남김. **현재 4개**: `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체 — 런타임 12개 통과, 구 `question.md` 0-Y의 1차 근거. **단 이 문서의 "콜백이 raw 값을 받으면 완전 클린" 판정은 아래 `type-recursion-issue/`가 뒤집었음**), `gcconn-trick-verification.md`(사용자가 Studio에서 직접 돌린 gcconn 트릭 부분 확인 — `10`의 A 섹션 앞부분만. **[2026-08-14 다섯 번째 세션, 열한 번째 세션에 `canBound` 재도입 반영해 재갱신]** 실측된 사실 자체는 그대로 유효하고 `value` 단독 1-인자 재정정으로 오히려 더 중요해졌음 — 이중 바인딩 게이트(`canBound`)/emit 게이팅(`canExecute`)/재바인딩 허용/`value` 쪽 복사 gcconn 판정/Instance userdata 동일성/B/C가 미확인), **`type-recursion-issue/`**(**[2026-08-13 열세 번째 세션 신설]** 0-Y 재실측 전체 — `REPORT.md` + `spikes/` 44개. 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`) | | `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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -31,7 +31,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가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), 별도 `canBound`는 폐기되어 `canExecute` 하나로 통합, gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md` | +| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식. **[2026-08-14 다섯 번째 세션, 시그니처 정정]** `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` — 뒤의 둘은 `inst`를 안 받음(`bindLifetime`이 바인딩 시점에 gcconn 참조를 `value` 쪽 `Relate`로 복사해두므로 `value` 하나로 생존을 물을 수 있고, 실제 호출부인 State 전파 루프엔 애초에 `inst`가 없음). `.Subscribed`는 전역 `:Subscribe()` 전용 필드로 분리(`bindLifetime`은 읽지도 쓰지도 않음), gcconn/gchold는 lazy가 아니라 **Instance 생성 시점**에 만들고 클로저가 `gchold`와 `inst`를 둘 다 캡처(userdata 포인터 동일성 = `inst`-키 `Relate` 전체의 전제). 옛 2-인자 모델은 `archive/canexecute-inst-arg-reversed.md`. **[2026-08-14 열한 번째 세션]** 별도 `canBound`가 다시 도입됨 — `bindLifetime`/`Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit 전파 게이팅만 `canExecute`(판정 로직은 비공개 헬퍼 `isBoundAlive` 하나를 공유) | | `store-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** Store = **이름 붙은 Source 모음, 그 이상 아님** — Store 부작용 허용이 기본 디자인(국소적 vs 경계를 넘는 부작용), `defaults`는 선택적 초기값 템플릿(원본을 나중에 mutate해도 UB 아님)이고 **eager 생성과 lazy 생성이 둘 다 필요**(Luau 타입은 런타임에 강제 안 되므로), `table.clone` 기반 eager 생성 스케치, `store.key`(dot-access)가 1급 경로이고 `store "key"` 문자열 커링은 동적 키용 미타입 폴백, 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State>`와는 다른 축) | | `source-state-plan.md` | **[2026-08-14 신설 — `bind-system-plan.md` 3단계 분할 + 구 store-semantics.md 흡수]** 반응형 코어: `Source`⊇`State` 구조적 서브타입(`RefSource` 폐기, 단방향 의존으로 Luau 솔버 회피 — 스파이크 `08` 통과), **push-invalidate/pull-recompute** 전파 모델과 "관측해야 실체화된다" 전역 원칙, State 체인 플래튼 기각(캐싱이 State의 존재 이유), `:With`도 매번 새 노드(clone 계열인 `Tag`/`Modifier`와 혼동 주의), `:Compute`의 lazy 핸들 계약(`:Get()` 누락이 반복되는 실수)·trailing args sugar·`fn(self, previous?, ...deps)` 순서·`previous`, `:Apply`, `:Emit()`(Source 원천 전용 하드 경계)과 `Store`/`Source`의 `T`가 Modifier일 수 없는 따름정리, `state:Observer(fn)`, `:Subscribe()`/`:Unsubscribe()`, **이중 바인딩 금지 게이트**(`canExecute` 하나로 통합), PA님 코드 교차검증 | | `dispatch-core-plan.md` | **[2026-08-13 열네 번째 세션 신설 — `bind-system-plan.md` 2단계 분할 + 0-A/0-Z 반영]** 디스패치 코어: 핸들러 계약(`isHandlable`/`priority`/`process`가 retract 클로저를 반환) / **하강 diff 재디스패치**(래핑 핸들러의 `retractFrom` 선행 호출 폐기, `Dispatch.process`가 슬롯의 `handler`를 먼저 비교해 — 같으면 그 자리 클로저에 새 값을 넘기고 재`process`, 다르면 그 자리부터 전량 철거) / `chains` 인덱스 체인과 **3-인자** `Dispatch.retractFrom(inst,k,index)`(힌트 인자 소멸 — 값 전달 경로가 (A) 분기 하나로 통일) / `None` 센티널 / Handler 작성 체크리스트 8개 / Length·Offset 형제 순서 보장 / "store 바인드는 래핑" 결론. **새 결정 둘**: `HANDLER_PRIORITY_FALLBACK`(base 제공 핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 덮어쓰면 언제나 이김), **"base가 소유하는 핸들러와 주입되는 엔진 op"**(부기가 엔진 지식을 요구하지 않으면 알고리즘은 base, 마지막 한 줄만 주입 — `addTag`/`removeTag`/`setAttribute`, **[2026-08-14 열 번째 세션]** 같은 패턴을 Dispatch 밖의 `dispose(value)`/`disposeInst`에도 재사용). 옛 힌트 모델은 `archive/dispatch-hintvalue-model-reversed.md` | @@ -61,7 +61,7 @@ |---|---| | `quad-v1-architecture.md` | v1(`initreq/quad`) 내부 동작 스냅샷 — "이 문제를 안 반복하려면"의 기준선. **[2026-08-07 `base/`→`reference/` 이동]** v2의 결정 자체가 아니라 다른 문서가 인용하는 온디맨드 자료라 항상 읽을 필요는 없음 | | `comparison-fusion-vide.md` | Fusion/Vide 아키텍처 비교 리서치 — 설계 결정 근거 자료(전파 모델 등 일부 서술은 이후 라운드에서 뒤집혔으니 `bind-system-plan.md` 쪽을 최신으로 볼 것). **[2026-08-07 `base/`→`reference/` 이동]**, `quadnomicon` 소재 후보 | -| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, Blocker의 "previous 값 비교" 미결 문제엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 | +| `comparison-charm.md` | **[2026-08-09 신설]** littensy/charm(Roblox Zustand류) 비교 — `batch()`/`atom()`/수동 dispose Effect 3가지는 quad가 이미 기각한 패턴이라 반면교사, `None` 센티널은 독립 재확인, charm-sync의 diff/patch는 quad 미착수 네트워크 복제 영역의 첫 참고자료, 이미 확정된 `:Compute`의 `previous` 인자(`base/source-state-plan.md`)엔 정황 증거(생성 시 필수 `equals`, computed의 previous-in-getter) 제공 | ## `research/` — 아직 착수 전, 상의 필요 @@ -100,10 +100,10 @@ | `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 | | `slot-discard-no-portal-reversed.md` | **[역전됨, 2026-08-13 일곱 번째 세션 신설]** Slot의 **"retract = 폐기, 옮기지 않음"(2026-08-04 확정) + "portal은 오버엔지니어링이라 안 함"** — 여섯 번째 세션에 `State` 교체가 파괴에서 **언마운트**로 뒤집히며 portal이 별도 기능이 아니라 그 귀결이 됨(`state`와 동일한 시맨틱). `base/slot-plan.md`에 히스토리로 남아 있던 세 덩어리(확정 문단 + `State` 왕복 분석 + 포탈 검토와 숙제 셋)를 원문 그대로 이전, 숙제 셋이 각각 어떻게 결말났는지도 정리 | | `existing-instance-bind-rejected.md` | **[기각됨, 2026-08-14 세션 — `research/`에서 이전]** 이미 생성된 Instance에 나중에 `{k=v}` 프롭 테이블을 바인드하는 기능 — 오래 "열린 가능성"으로 남겨뒀으나 사용자 확정으로 기각. 사유: 허용하면 `Dispatch.setOffsetSource`/`setLength` 같은 "quad가 만든 트리" 전제의 부기를 바깥에서 밀고 당기는 부가 작용이 전부 가능해져 **버그 표면이 치명적으로 넓어짐**. `pre-implementation-audit.md` 2-4(Slot 단일 마운트 소유권과의 충돌)도 이걸로 해소 | -| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(→2026-08-14 다섯 번째 세션에 폐기, `canExecute`로 통합)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | +| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 | | `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` | | `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 | -| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | +| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | ## 참고 diff --git a/.claude/archive/canexecute-inst-arg-reversed.md b/.claude/archive/canexecute-inst-arg-reversed.md index 5d99532..5f8f209 100644 --- a/.claude/archive/canexecute-inst-arg-reversed.md +++ b/.claude/archive/canexecute-inst-arg-reversed.md @@ -94,8 +94,16 @@ if self.Connection then return self.Connection.Connected end ## 같이 폐기된 것 - **`canBound(handle)`** — 이 오염된 `.Subscribed` 재사용 위에 세워진 - predicate라 정의 자체가 성립 안 함. `canExecute(value)` 하나로 통합 - (`base/lifecycle-pattern.md`의 "`canBound` 폐기" 절). + predicate라 정의 자체가 성립 안 함. `canExecute(value)` 하나로 통합. + **[부분 되짚음, 2026-08-14 열한 번째 세션]** 이 "하나로 합친다"는 + 판단만 나중에 다시 갈라짐(이 문서가 고친 시그니처/오염 원인 정정은 + 안 바뀜) — `canBound`가 별도 진입점으로 재도입되어 `bindLifetime`/ + `Observer:Subscribe()`의 "이미 묶여 있는가"(bound 문맥) 가드를 맡고, + `canExecute`는 State emit 전파 루프의 "지금 발화해도 되는가"(execute + 문맥) 게이팅에만 씀 — 판정 로직(`isBoundAlive`)은 여전히 공유, + 이름만 문맥별로 분리. 상세는 `base/lifecycle-pattern.md`의 "`canBound` + vs `canExecute`" 절, 계기는 `Ref` 이중 배치 방지(`question.md` 0-W, + `base/ref-plan.md` "이중 배치 방지" 절). - **gcconn/gchold의 lazy 생성** — `bindLifetime` 첫 호출에서 만들던 것을 **Instance 생성 시점**으로 올림. 이유는 이 역전과 별개(Instance userdata 포인터 동일성 — `inst`-키 `Relate` 전체의 전제), 같은 세션에 확정돼 같은 diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 1cb81a1..9c586eb 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -324,6 +324,57 @@ Slot이 됨. 막는 게 소유권 규칙이 아니라 "제거 = 파괴"라는 re `frame:Destroy()`하고 `Set`하는 것과 같은 문제) — 순서는 항상 `Set`(언마운트) → 그 다음 정리. +### 0-W. ~~같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가~~ **[해소됨, 2026-08-14 열한 번째 세션]** (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견) + +**결정: 선택지 (a) — `Slot`/`PreRef`와 같이 즉시 error.** 메커니즘은 +사용자 제안대로 새 전용 `Relate`를 만들지 않고 `bindLifetime`/ +`unbindLifetime`을 재사용 — `bindLifetime`이 이미 내부에 "이 value가 +다른 곳에 이미 살아있는 바인딩을 갖고 있으면 즉시 error"라는 가드를 +갖고 있어서(`base/lifecycle-pattern.md`의 `canBound` 게이트), +`RefLeafHandler.process`가 실제 바인딩 분기에서 `bindLifetime(inst, v)`를, +실제 언바인딩 분기에서 `unbindLifetime(v)`를 부르기만 하면 이중 배치가 +저절로 막힘. 상세 메커니즘/코드는 `base/ref-plan.md`의 "이중 배치 방지" +절. + +**부수 결정 — `canBound`가 `canExecute`와 별도 진입점으로 재도입됨** +(2026-08-14 다섯 번째 세션에 "canBound 폐기, canExecute로 통합"됐던 걸 +부분적으로 되짚음, 시그니처 정정 자체는 안 바뀜). 사용자 지적: "이중 +바인딩 여부"(bound 문맥)와 "지금 발화해도 되는가"(execute 문맥)는 오늘 +판정값이 같아도 서로 다른 질문 — `Ref`처럼 emit 전파에 참여하지 않는 +값에 `canExecute`를 묻는 건 개념이 안 맞음. 판정 로직(`isBoundAlive`)은 +공유하는 비공개 헬퍼 하나로 유지하고, `canBound`/`canExecute`는 그 +헬퍼를 부르는 얇은 진입점으로 분리 — 중복 구현 없이 호출부 의미만 +나뉨. `bindLifetime`/`Observer:Subscribe()`의 가드는 `canBound`로, +State emit 전파 루프만 `canExecute`로. 상세는 +`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전 +경위는 `archive/canexecute-inst-arg-reversed.md`의 추가된 절 참고. + +원 손 트레이싱/형제 프리미티브 대조표는 아래 보존(**[표기 정정, 2026-08-14 +열한 번째 세션 후속]** 아래 `Frame1 { Ref = r }`/`process(inst1,"Ref",r)`의 +`"Ref"`는 설명 편의상 쓴 표기일 뿐, 실제로는 `Ref`가 항상 children +배열 리터럴 아이템으로 놓여 `k`가 문자열 `"Ref"`가 아니라 그 자리의 배열 +인덱스(숫자)임 — 정확한 표기는 `base/ref-plan.md` "이중 배치 방지" 절 +참고, 트레이싱의 논리 자체는 `k`가 뭐든 안 바뀜): + +**손 트레이싱** (`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입, +`Frame1 { Ref = r }` / `Frame2 { Ref = r }`): + +1. `process(inst1,"Ref",r)` → `relate[inst1]["Ref"]`가 nil → `r:Set(inst1)` +2. `process(inst2,"Ref",r)` → `relate[inst2]["Ref"]`도 nil(**다른 키**) → + `r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음** +3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)` → **`r:Set(nil)`** — + inst2가 정당하게 들고 있던 값을 지움(교차 오염) + +**형제 프리미티브 대조 — 해소 전 `Ref`만 비어 있었음**: + +| | 공유 자원 | 방어 | 상태 | +|---|---|---|---| +| `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 | +| `PreRef`/`PostRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 | +| `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 | +| `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘 | +| `Ref` | 자기 자신 | `bindLifetime`/`canBound` 재사용 → 즉시 error | **막힘(해소)** | + ### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07) 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 diff --git a/.claude/audit/gcconn-trick-verification.md b/.claude/audit/gcconn-trick-verification.md index 97659c8..f9075ab 100644 --- a/.claude/audit/gcconn-trick-verification.md +++ b/.claude/audit/gcconn-trick-verification.md @@ -11,11 +11,13 @@ 재정정으로 `canExecute(value)`가 **`value` 쪽 릴레이션에 복사된 gcconn의 `.Connected`를 직접 읽는 것**이 leaf 경로 생존 판정의 전부가 됐기 때문 (`base/lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime` -— 확정" 절). 반면 이 문서가 인용하던 **`canBound`는 폐기**됐고(게이트는 -`canExecute` 하나로 통합), 공식 `10` 파일은 옛 모델을 검증 중이라 -`rewrite-required/`로 옮겨졌음 — 아래 시그니처 표기와 "아직 확인 안 된 -것"/"다음 확인 시 참고"를 그에 맞춰 갱신함. 역전 경위는 -`archive/canexecute-inst-arg-reversed.md`. +— 확정" 절). **[재정정, 2026-08-14 열한 번째 세션] `canBound`는 폐기되지 +않고 별도 진입점으로 재도입됨** — 이중 바인딩 게이트(`bindLifetime`/ +`Observer:Subscribe()`)는 `canBound`, State emit 전파 게이팅만 +`canExecute`(판정 로직은 비공개 헬퍼 하나를 공유, `base/lifecycle-pattern.md`의 +"`canBound` vs `canExecute`" 절) — 공식 `10` 파일은 이 재분리도 반영해 +재작성해야 함, 계속 `rewrite-required/`에 있음. 역전 경위는 +`archive/canexecute-inst-arg-reversed.md`(추가된 절 포함). ## 배경 @@ -75,18 +77,19 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아 - **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용** — `bindLifetime`/ `unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection - 메커니즘만 테스트함). **[2026-08-14 다섯 번째 세션 갱신]** 게이트는 이제 - `canBound`가 아니라 `canExecute(value)` 하나이고(`if canExecute(v) then - error(...) end`), 검증해야 할 명제도 바뀜: (a) 살아있는 바인딩을 가진 + 메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신]** 게이트는 + `canBound(value)`이고(`if canBound(v) then error(...) end`, `canExecute`는 + emit 게이팅 전용으로 분리 — `lifecycle-pattern.md` "`canBound` vs + `canExecute`" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는 - 통과, (c) **`inst`가 Destroy된 뒤에도 통과**(새 모델이 명시적으로 - 허용 — `lifecycle-pattern.md` "`canBound` 폐기" 절). 전부 미해소이고, - 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 있음(현재 - `luau-test/rewrite-required/`). + 통과, (c) **`inst`가 Destroy된 뒤에도 통과**(모델이 명시적으로 허용). + 전부 미해소이고, 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 + 있음(현재 `luau-test/rewrite-required/`). - **`bindLifetime`이 복사해둔 gcconn만으로 판정이 성립하는가** — - **[2026-08-14 다섯 번째 세션 신규]** `canExecute`가 `inst`를 안 받고 - `BindData:GetWeak(value, "gcconn")` 하나로 생존을 판정하는 경로 자체는 - 아직 실측된 적 없음(위 3번은 gcconn을 `inst` 쪽에서 직접 들고 있는 + **[2026-08-14 다섯 번째 세션 신규]** `canBound`/`canExecute`가 `inst`를 + 안 받고 `BindData:GetWeak(value, "gcconn")` 하나로 생존을 판정하는 + 경로 자체는 아직 실측된 적 없음(위 3번은 gcconn을 `inst` 쪽에서 + 직접 들고 있는 형태로 확인한 것). weak 릴레이션에 복사해둔 참조가 gchold 사망 후 기대대로 비워지는지도 같은 항목. - **Instance userdata 포인터 동일성** — **[2026-08-14 다섯 번째 세션 신규]** diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index f4cf506..99e3b65 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -168,12 +168,12 @@ quad/ │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) -│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). HANDLER_PRIORITY_FALLBACK으로 등록(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) +│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). quad-base가 모듈 로드 시점에 priority=HANDLER_PRIORITY_FALLBACK로 스스로 등록(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) │ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절) │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절) │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 -│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) +│ ├── LifetimeHandle.luau # `bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 함수 "인터페이스"(타입/계약만), 내부는 Relate 사용(`base/lifecycle-pattern.md`) │ ├── Ref.luau # 범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋), `Ref(default)`를 children 배열 숫자 슬롯에 직접 놓으면 (v=Ref) 매치 핸들러가 바인드 — 별도 CreatedRef 래퍼 없음 │ ├── PreRef.luau # Ref 런타임 재사용 + children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화(별도 파일, `ref-plan.md` "PreRef 신설" 절, 2026-08-07 여섯 번째 세션에서 분리) │ ├── PostRef.luau # PreRef의 거울상 — 같은 Ref 런타임/제약, 같은 pre-pass가 수집만 하고 두 패스가 전부 끝난 뒤 fire(`ref-plan.md` "`PostRef`" 절, 2026-08-14 아홉 번째 세션 확정) @@ -182,9 +182,9 @@ quad/ └── quad-roblox/ ├── wally.toml └── src/ - ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) + ├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션) ├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) - ├── LifetimeHandle.luau # bindLifetime/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) + ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) ├── Handlers/ │ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`) │ ├── Event.luau # ReflectionService 기반 자동 판별 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 6ab230c..14c6dc9 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -446,7 +446,7 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 | 그룹 값 타입+API(`Attribute(...)`/`Merged`/`:NameMap`) | quad-base | | 단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시 | quad-base | | 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) | quad-base | -| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로 등록 | +| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로 quad-base가 스스로 등록 | | 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) | | **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 | @@ -455,10 +455,18 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 수 없음. 반대로 string/number/boolean은 어느 백엔드에나 있으므로 base에 둔다. "이 값이 이 백엔드에서 표현 가능한가"라는 **검증도 base가 아니라 주입된 `setAttribute`의 몫** — base는 값을 그대로 흘려보냄. -- **백엔드가 통째로 다르게 하고 싶으면** 평범한 우선순위로 자기 핸들러를 - 등록하면 됨(base 것은 최하위 밴드라 자동으로 짐) — 상세는 - `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 - op" 절. `Tag`도 정확히 같은 구조(`base/tag-plan.md`). +- **`AttributeKeyHandler`/`AttributeGroupHandler` 자신은 quad-base가 + 모듈 로드 시점에 스스로 등록** — `setAttribute`만 백엔드 팩토리가 + 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 + 스텁(2026-08-14 열한 번째 세션 — 한때 "등록 자체가 백엔드 선택"으로 + 잘못 정정됐다가 철회됨). 더 명확한 메시지나 진짜 원자적 실패를 원하는 + 백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 + Handler를 추가로 등록 가능. 속성 처리 자체를 통째로 다른 알고리즘으로 + 바꾸고 싶은 백엔드는 `HANDLER_PRIORITY_FALLBACK`보다 확실히 높은 + 우선순위로 자기 Handler를 등록하면 base 것을 완전히 대체함(같은 + override 원리) — 상세는 `base/dispatch-core-plan.md`의 "base가 + 소유하는 핸들러와 주입되는 엔진 op" 절. `Tag`도 정확히 같은 + 구조(`base/tag-plan.md`). - 단일 키를 별도 opt-out 패키지로 쪼개지 않는다는 기존 판단은 그대로 (UICorner 숏핸드/Tween/Tag와 같은 결) — 다만 "어느 패키지의 코어인가"가 quad-roblox에서 quad-base로 바뀐 것. diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 966b96e..0bd5984 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -537,9 +537,7 @@ end (재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) — 이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend. -**주입되는 엔진 op(base는 시그니처만 소유, 실제 구현은 -`RobloxFactory(BaseModule)`류가 뮤테이션으로 주입 — `bindLifetime`/ -`canExecute`와 완전히 같은 패턴, 새 메커니즘 아님)**: +**주입되는 엔진 op**: ```lua addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신 @@ -558,13 +556,52 @@ setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름 `SetAttribute`의 네이티브 동작과 일치하고, 다른 백엔드는 자기 방식으로 매핑하면 됨(웹이면 `removeAttribute`). base 쪽 규칙 — "Attribute는 오직 명시적 `None`/`nil`로만 지워진다"(`base/attribute-plan.md`) — 은 그대로. -- **미주입 백엔드의 실패 모드**: base가 이 핸들러들을 - `HANDLER_PRIORITY_FALLBACK`(위 "핸들러 계약" 절)으로 등록하므로, - 백엔드가 자기 핸들러를 따로 등록했다면 그쪽이 항상 이기고 base 것은 - 아예 안 불림. 아무도 안 가져갔는데 op도 주입 안 된 백엔드라면 그때 - **"이 백엔드는 `addTag`를 구현하지 않음"** 같은 명확한 에러를 내는 게 - base 기본 스텁의 역할 — "매치 실패=error"(위 절)와 층위만 다른, 같은 - 성격의 즉시 실패. + +**[재정정, 2026-08-14 열한 번째 세션 — 앞선 "등록 자체도 백엔드의 +선택" 안은 틀렸음, 철회] `TagHandler`/`AttributeKeyHandler`/ +`AttributeGroupHandler`는 quad-base가 자기 모듈 로드 시점에 +`HANDLER_PRIORITY_FALLBACK`으로 스스로 `Dispatch.addHandler` 등록한다 +— 이게 기본이고 필요함.** `HANDLER_PRIORITY_FALLBACK`이라는 밴드 +자체가 정확히 이런 용도 — "아무도 이 자리를 안 가져갔을 때의 안전한 +기본 동작"을 base가 공짜로 제공하는 것. 모든 백엔드가 자동으로 +`Tag`/`Attribute` 부기(참조 카운트/이름 claim)를 얻고, 특별히 뭔가를 +하지 않아도 이 값들이 어떤 자리에 놓이든 최소한 매치는 됨. + +`addTag`/`removeTag`/`setAttribute`는 base가 시그니처만 소유하고 +실제 구현은 팩토리가 뮤테이션으로 주입하는 **타입 계약**(`bindLifetime`/ +`canExecute`와 같은 패턴, 엔진이 실제로 손대는 부분은 백엔드가 채우기로 +"계약"한 것) — 이건 그대로 유지: + +- **아직 아무 팩토리도 채우지 않은 슬롯의 기본값은 quad-base가 준다 — + 단 "동작하는 구현을 추측"하지 않고 명시적으로 에러내는 스텁으로.** + `BaseModule.addTag = function() error("addTag가 구현되지 않음 — + provider가 초기화됐는지, 이 백엔드가 Tag를 지원하는지 확인하라") end` + 류. base가 "그럴듯한 기본 동작"(예: 조용한 no-op)을 대신 만들어주는 + 건 기각 — 임의의 엔진에 뭐가 맞는 기본값인지 base는 알 수 없고, + 조용한 no-op은 실수(provider 초기화를 잊음)를 가려버림. 명시적 에러가 + 유일하게 안전한 기본값. + - **"provider 미주입"과 "이 백엔드가 애초에 Tag를 지원 안 함"은 이 + 기본 스텁 수준에서 여전히 구분 안 됨** — 둘 다 그 슬롯이 안 + 채워진 같은 상태라 원천적으로 구별 불가(`pre-implementation-audit.md` + 1-4, 2026-08-12 열일곱 번째 세션 확정 원칙 그대로). +- **[관례, opt-in] 더 명확한 메시지나 진짜 원자적 실패(부기 mutation + 0회)를 원하는 백엔드는, 그거대로 `HANDLER_PRIORITY_FALLBACK + 1` + 우선순위의 얇은 가로채기 Handler를 추가로 등록할 수 있음**: + ```lua + { priority = HANDLER_PRIORITY_FALLBACK + 1, + isHandlable = function(inst,k,v) return isTag(v) end, + process = function(inst,k,v) error("이 백엔드는 Tag를 지원하지 않음") end } + ``` + `TagHandler` 자신(`FALLBACK`)보다 한 단계 높아 스캔에서 먼저 매치되고, + "매치된 Handler 하나만 실행"이라는 기존 규칙 덕분에 `TagHandler.process` + (와 그 안의 `tagNameMap` mutation)는 아예 안 불림 — op 에러보다 + 이르고 정확한, 진짜 원자적 실패. 단 이건 **선택적 업그레이드**일 뿐 + 기본 요구사항은 아님 — base 기본 스텁 하나로도 이미 충분히 안전하게 + 실패함(`AttributeGroupHandler`의 "부분 실패 경로" 절이 이미 정리한 + "에러=패닉 상태, 그 이후 정합성은 관리 대상 아님" 원칙 + `nameClaims`/ + `tagNameMap`이 `inst`에 대해 weak라 그 인스턴스가 GC되면 잔여 부기도 + 같이 사라지는 것으로 충분히 커버됨), 더 깔끔한 실패를 원하는 백엔드만 + 추가로 얹으면 됨. - **타입 패밀리는 백엔드 몫**: `AttributeKey<>` 제네릭 생성자와 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) 까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인 @@ -1263,7 +1300,7 @@ end 전파 루프가 발화 때마다 `canExecute(observer)`로 각 구독자를 게이팅하고, 그 판정 근거(`inst` 생존)는 `bindLifetime`이 `observer` 쪽에 복사해둔 gcconn 참조가 제공함(`base/lifecycle-pattern.md`의 - "`bindLifetime`/`canExecute`/`unbindLifetime`" 절). + "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime`" 절). **[정정, 2026-08-14 다섯 번째 세션]** 이 항목의 옛 근거(*"Observer가 이미 자기 `Subscribed` 상태로 게이팅됨, `bindLifetime`도 그 필드를 세팅/해제"*)는 틀렸음 — `.Subscribed`는 전역 `:Subscribe()` 전용 필드이고 `bindLifetime`은 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index 3d9ea89..c2be858 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -70,6 +70,17 @@ leaf가 살아있는 동안만 유효, leaf가 죽으면 최종 정리 콜백 leaf당 실제 Destroying 바인딩 하나(공유 weak table로 되는 Observer보다 비쌈) — 필요할 때만 쓰는 걸로 충분. +**동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK` +(2026-08-14 열한 번째 세션, `PreRef`/`Observer`와 같은 패턴, `base/ +source-state-plan.md`의 "동적 경로 가드" 절 참고).** `EffectHandle`도 +children 배열 리터럴 전용이라, 해시 파트 named 자리 등으로 동적으로 +흘러들어오면 명확히 에러내야 함 — `{ priority = HANDLER_PRIORITY_FALLBACK, +isHandlable = function(inst,k,v) return isEffect(v) end, process = +function(inst,k,v) error("EffectHandle은 children 배열 리터럴에만 놓을 +수 있음") end }`. `FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에 +named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로 +값싸게 override 가능한 자리로 열어둠. + **보강 — `EffectHandle`의 내부 Observer 바인딩 세부(2026-08-09 열한 번째 세션, 재확인 후 명시화)**: @@ -159,10 +170,12 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 일곱 번째 세션 후속)**: 처음엔 "같은 liveness 게이트를 공유하니 동시에 써도 안전"으로 적었으나, 애초에 한 핸들은 라이프사이클 바인딩 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 - 규칙과 `canExecute(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` - 플래그 → 2026-08-09 세션에 `canBound`로 명명 → **2026-08-14 세 번째 - 세션에 `canBound` 폐기, `canExecute`로 통합**)은 - `base/source-state-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, + 규칙과 `canBound(value)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` + 플래그 → 2026-08-09 세션에 `canBound`로 명명 → 2026-08-14 다섯 번째 + 세션에 `canBound` 폐기, `canExecute`로 통합 → **같은 날 열한 번째 + 세션에 `canBound`가 별도 진입점으로 재도입**, 판정 로직은 + `canExecute`와 공유)은 `base/source-state-plan.md`의 "이중 바인딩 + 금지" 절 참고. **[정정, 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가 아니라 `unbindLifetime(value)`** — leaf 부착 자체가 내부적으로 `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime` diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 4981d34..e838296 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -130,21 +130,23 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). -### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션, -`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 세 번째 -세션에 `value` 단독으로 최종 정정**) +### `bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션, +`unbindLifetime`은 2026-08-09 세션 추가, **시그니처는 2026-08-14 다섯 +번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한 +번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절) **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ -`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가 -직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 -감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 -평평한 함수: +`canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 +핸들러 작성자가 직접 호출하는 **1급 프리미티브 연산**이라 +`LifetimeHandle.bind(...)`류로 감싸면 안 됨 — `LifetimeHandle.luau` 파일 +안에 있어도 되지만 export는 평평한 함수: ```lua bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐 unbindLifetime(value: any): () -canExecute(value: any): boolean +canBound(value: any): boolean -- "이미 유효하게 묶여 있는가" — 구조적 점유 확인 +canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅 ``` **[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`를 @@ -237,17 +239,36 @@ InstData:SetWeak(inst, "gcconn", gcconn) 생기므로(예: `dispatch-core-plan.md`의 `StoreBind.process`), 이번 변경은 "아무것도 안 걸린 Instance"까지 같은 규칙으로 통일한 것뿐. -#### (1) `bindLifetime` / `unbindLifetime` / `canExecute` +#### (1) `bindLifetime` / `unbindLifetime` / `canBound` / `canExecute` ```lua -- quad-roblox 실 구현 스케치 local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐) local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움) +-- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정. +-- 이 값이 "구조적으로 이미 살아있는 바인딩을 갖고 있는가"는 어느 쪽 +-- 진입점에서 물어도 항상 같은 값이라, 판정 로직은 여기 하나만 있음. +local function isBoundAlive(value) + -- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음. + -- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움 + -- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐). + local gcconn = BindData:GetWeak(value, "gcconn") + if gcconn ~= nil and gcconn.Connected then + return true + end + -- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드. + if isObserver(value) or isEffect(value) then + return value.Subscribed == true + end + return false +end + function bindLifetime(inst, value) - -- 이중 바인딩 금지(base/source-state-plan.md) — 게이트가 곧 canExecute. - -- "지금 실행 가능하다"는 곧 "이미 유효한 바인딩을 갖고 있다"는 뜻. - if canExecute(value) then + -- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound. + -- "이미 유효한 바인딩을 갖고 있다"를 묻는 자리이지 "지금 발화해도 + -- 되는가"를 묻는 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). + if canBound(value) then -- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건 -- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한 -- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러). @@ -275,19 +296,20 @@ function unbindLifetime(value) BindData:SetWeak(value, "gcconn", nil) end +-- "이미 유효하게 묶여 있는가" — 구조적 점유 확인용. bindLifetime의 이중 +-- 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, Ref가 두 자리에 +-- 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`)처럼 +-- "이 값이 이미 다른 어딘가에 물려 있는가"를 묻는 자리는 전부 이걸 씀. +function canBound(value) + return isBoundAlive(value) +end + +-- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할 +-- 때만 씀(아래 "(4) 실제 호출부" 절). 오늘은 canBound와 판정값이 항상 +-- 같지만(같은 isBoundAlive를 공유), 호출부의 질문 자체가 다르므로 +-- 이름을 분리해둔다. function canExecute(value) - -- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음. - -- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움 - -- (gchold가 죽으면 gcconn을 강참조하는 게 없어지므로 weak 항목이 스스로 비워짐). - local gcconn = BindData:GetWeak(value, "gcconn") - if gcconn ~= nil and gcconn.Connected then - return true - end - -- (b) 전역 경로: :Subscribe()가 세운 것. Observer/Effect에만 있는 필드. - if isObserver(value) or isEffect(value) then - return value.Subscribed == true - end - return false + return isBoundAlive(value) end ``` @@ -296,7 +318,7 @@ end 1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]` 강참조가 그것. 2. **`value`는 `inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`에 - 복사된 gcconn 참조가 그것. `canExecute`가 `inst` 없이 성립하는 이유. + 복사된 gcconn 참조가 그것. `canBound`/`canExecute`가 `inst` 없이 성립하는 이유. **`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로 전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도 @@ -309,13 +331,13 @@ end `inst`에 안 묶이는(모듈 최상위 디버그 print류) Observer/Effect 전용. 상세 규칙과 경고는 `base/source-state-plan.md`의 "`:Subscribe()`/`:Unsubscribe()`" -절이 소스이고, 여기선 `canExecute`가 보는 상태만 못박음: +절이 소스이고, 여기선 `canBound`가 보는 상태만 못박음: ```lua local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적) function Observer:Subscribe() - if canExecute(self) then -- bindLifetime과 정확히 같은 게이트 + if canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) error(if self.Subscribed then "이미 :Subscribe()된 값" else "이미 Instance에 바인딩된 값") @@ -333,26 +355,57 @@ end ``` `.Subscribed` 필드와 `Subscribed` 테이블이 **둘 다** 있는 이유: 테이블은 -강참조 루트(생존 보장), 필드는 `canExecute`가 매 발화마다 읽는 O(1) 경로 + -에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이 +강참조 루트(생존 보장), 필드는 `canBound`/`canExecute`가 매번 읽는 O(1) +경로 + 에러 메시지에서 "전역이냐 leaf냐"를 가르는 판별자. 둘은 항상 같이 쓰고 같이 지우는 한 세트(`:Unsubscribe()`가 필드만 내리고 테이블을 안 비우면 반쪽짜리 해제가 됨 — `base/source-state-plan.md`에 이미 확정된 규칙 그대로). -#### (3) `canBound` 폐기 — 게이트는 `canExecute` 하나 +#### (3) `canBound` vs `canExecute` — 문맥이 달라 다시 갈라짐 -**[역전, 2026-08-14 다섯 번째 세션]** 2026-08-09 세션에 이름 확정됐던 -별도 predicate `canBound(handle)`("아직 어느 경로로도 안 묶였는가")은 -**폐기하고 `canExecute(value)`로 통합**. 두 질문이 사실 같은 질문이기 -때문 — "이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은 -조건(위 구현의 (a) OR (b))이고, `canBound`의 내부 근거로 지목돼 있던 -`.Subscribed` 필드는 애초에 leaf 경로와 무관했으므로 그 정의 자체가 -성립하지 않았음. +**[2026-08-14 열한 번째 세션, 다섯 번째 세션의 "canBound 폐기" 결정을 +부분적으로 되짚음]** 원래 폐기 서사·오염 경로 추적은 +`archive/canexecute-inst-arg-reversed.md`에 그대로 있음(그 문서가 고친 +버그 — 2-인자 `canExecute(inst,value)`가 오염이었다는 것, `unbindLifetime`/ +`canExecute`가 `inst`를 안 받아야 한다는 것 — 은 전부 그대로 유효, 이번에 +되짚는 건 "판정을 하나의 이름으로 합칠지 두 이름으로 나눌지"뿐). -부수 효과로 **"바인딩이 죽은 뒤의 재사용은 허용"**이 명시적 의미를 얻음 — -`inst`가 Destroy됐거나 `unbindLifetime`된 `value`는 `canExecute`가 거짓이라 -게이트를 통과함(다시 다른 `inst`에 걸 수 있음). 살아있는 바인딩만 막는 게 -이 게이트의 의도. +**왜 다시 나눴나(사용자 판단, `question.md` 0-W 논의 중 제기)**: +`canExecute`라는 이름 하나가 실제로는 서로 다른 두 호출 맥락을 겸하고 +있었음: + +1. **bound 문맥 — "이 값이 이미 어딘가에 유효하게 묶여 있는가"**(구조적 + 점유 여부를 묻는 질문). `bindLifetime`의 이중 바인딩 가드, + `Observer:Subscribe()`의 이중 등록 가드, 그리고 `Ref`가 두 자리에 + 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md` + "이중 배치 방지" 절)가 전부 이 질문만 물음 — 이 값들은 emit 전파에 + 참여조차 안 하는 경우도 있음(`Ref`가 그 예). +2. **execute 문맥 — "지금 이 구독자가 발화해도 되는가"**. State emit + 전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) — + `Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음. + +**오늘 두 문맥의 판정값은 우연히 같다**(둘 다 `isBoundAlive` 하나로 +귀결 — gcconn이 살아있는가 OR `.Subscribed`인가). 다섯 번째 세션은 이 +우연한 일치를 "애초에 같은 질문"으로 결론지어 하나로 합쳤지만, 호출부가 +왜 그 질문을 묻는지는 서로 다름 — `Ref`처럼 발화라는 개념 자체가 없는 +값에게 "발화해도 되는가"(`canExecute`)를 묻는 건 개념이 안 맞고, 나중에 +"구조적으로는 묶여 있지만 일시적으로 발화만 멈춘" 상태가 생기면(지금은 +없음) `canBound`는 참인데 `canExecute`는 거짓이어야 하는 경우도 생길 수 +있음 — 판정값이 갈라질 여지 자체가 원래 있었다는 뜻. + +**해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/ +`.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드 +블록) 하나에만 있고, `canBound`/`canExecute`는 둘 다 그 헬퍼를 그대로 +호출하는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. **바뀐 +호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의 +가드(위 (2))는 이제 `canBound`를 씀 — `canExecute`를 쓰던 옛 코드에서 +이름만 바뀜, 동작은 동일. **안 바뀐 호출부**: State 전파 루프(아래 +"(4)")만 여전히 `canExecute`를 씀. + +부수 효과(다섯 번째 세션 결론과 값은 동일, 이름만 갈라짐): **"바인딩이 +죽은 뒤의 재사용은 허용"** — `inst`가 Destroy됐거나 `unbindLifetime`된 +`value`는 `canBound`가 거짓이라 게이트를 통과함(다시 다른 `inst`에 걸 +수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도. #### (4) 실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index dc6af7c..1ee73e1 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -244,7 +244,7 @@ UB로 남겨둠")은 폐기. 재검토 근거(사용자): Modifier는 애초에 같은 걸 다루는 목적이 아니고, 이런 값이 실제로 쓸모 있는 use case가 없다고 확인된 이상 조용한 UB보다 그 자리에서 막는 쪽이 낫다 — 판별 비용도 이미 있는 `Brand` 기반 predicate(`isRef`/`isPreRef`/`isPostRef`/ -`isObserver`/`isEffect`/`isSlot`/`isModifier`, `bind-system-plan.md`의 +`isObserver`/`isEffect`/`isSlot`/`isModifier`, `brand-plan.md`의 `Brand` 절)를 그대로 재사용하면 되므로 거의 공짜. - **체크 지점 — 제네릭 `__index` setter가 최종 저장 직전에 검사.** 위 @@ -596,7 +596,7 @@ setter 표면과 read 표면이 헷갈리고, 타이핑 이득도 메소드 방 안 되는 캐비엇이 있지만, 이건 quad가 대신 풀어줄 문제가 아니라 문서화 (경고)로 충분(이미 있는 "`Get()` 결과 캐싱 금지" 캐비엇과 같은 클래스). -**`isState(x): boolean` 필요 — `base/bind-system-plan.md`에 정의**. +**`isState(x): boolean` 필요 — `base/brand-plan.md`에 정의**. `Peek`가 raw union을 돌려주므로 사용자 코드가 State/plain을 분기하려면 판별 수단이 필요함(Source가 State를 구조적으로 만족하므로 `isState`가 Source도 같이 잡아줌 — **[2026-08-07 여덟 번째 세션 정정] `isSource`도 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index c4c12fd..8129ce2 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -134,10 +134,17 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 `setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기 알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막 한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가 - 소유하는 핸들러와 주입되는 엔진 op" 절). 미주입 백엔드에서는 base - 스텁이 명확한 에러를 내고, 백엔드가 통째로 다르게 처리하고 싶으면 - `HANDLER_PRIORITY_FALLBACK`보다 높은 우선순위로 자기 핸들러를 등록하면 됨 — 상세는 `base/bind-system-plan.md`의 "base - 유틸은 인터페이스, 실제 구현은 백엔드 팩토리가 주입" 절 참고. **중복 호출 + 소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/ + `AttributeKeyHandler`/`AttributeGroupHandler` 자신은 quad-base가 + 모듈 로드 시점에 `HANDLER_PRIORITY_FALLBACK`으로 스스로 등록** — + `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 뮤테이션으로 + 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 기본값은 + quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 no-op + 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서). + 더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는 + 백엔드는 opt-in으로 `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 + Handler를 추가로 등록할 수 있음 — 상세는 + `base/dispatch-core-plan.md`의 같은 절. **중복 호출 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 9f77366..343da9f 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -259,6 +259,8 @@ RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) function RefLeafHandler.process(inst, k, v, index) local old = relate:GetStrong(inst, k) if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip + bindLifetime(inst, v) -- v가 이미 다른 자리에 살아있으면 여기서 즉시 error — + -- 이중 배치 방지("이중 배치 방지" 절 참고), 별도 Relate 불필요 v:Set(inst) end relate:SetStrong(inst, k, v) @@ -266,6 +268,7 @@ function RefLeafHandler.process(inst, k, v, index) -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는 -- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요) if nextValue ~= v then + unbindLifetime(v) -- 점유 해제 — 이후 v는 다른 자리에 다시 bindLifetime 가능 v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 @@ -311,6 +314,40 @@ end 이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref 자신에 Destroy-awareness를 얹는 건 오버엔지니어링. +### 이중 배치 방지 — `question.md` 0-W 해소, (a) 선택 (2026-08-14 열한 번째 세션) + +**같은 `Ref` 객체를 두 자리에 동시에 놓으면 뒤에 놓은 자리가 앞 자리의 +바인딩을 조용히 지우는 문제**(`Frame1{r}`/`Frame2{r}`처럼 같은 `r`을 두 +Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 children +배열 아이템으로 놓이므로 `k`는 문자열 `"Ref"`가 아니라 그 자리의 배열 +인덱스(숫자) — `r:Set(inst1)` 다음 `r:Set(inst2)`가 에러 없이 덮어씀, +`inst1` 자리가 나중에 retract되면 `r:Set(nil)`이 `inst2`의 정당한 값까지 지움)를 +**즉시 error로 막기로 확정** — `Slot`의 `claimOwner`, `PreRef`/`PostRef`의 +`_fired`, `Attribute`의 이름 claim과 같은 급의 방어를 `Ref`에도 채택. + +**메커니즘 — 새 `Relate`를 안 만들고 `bindLifetime`/`unbindLifetime`을 +그대로 재사용.** `bindLifetime(inst, value)`은 이미 자기 내부에 "이 value가 +이미 다른 곳에 살아있는 바인딩을 갖고 있으면 즉시 error"라는 가드를 갖고 +있음(`base/lifecycle-pattern.md`의 `canBound` 게이트) — `Ref`가 바인딩될 +때마다 이 가드를 그대로 통과시키면 이중 배치가 저절로 막힘. 위 +`RefLeafHandler.process`의 `bindLifetime(inst, v)`/`unbindLifetime(v)` 호출이 +그것 — 실제 바인딩이 일어나는 분기(`old ~= v`)에서만 걸어서 spurious +재발행(같은 `v`가 다시 오는 경우)엔 안 걸림, 실제 언바인딩이 일어나는 +분기(`nextValue ~= v`)에서만 풀어서 그 뒤 다른 자리에 재바인딩 가능. + +**기존 dedup용 `relate`와는 별개 관심사** — `relate`는 "이 슬롯에 마지막으로 +뭐가 있었는지"(spurious 재발행 dedup)를 기억하고, `bindLifetime`은 "이 `Ref` +객체가 지금 어딘가에 살아있게 물려 있는지"(이중 배치 방지)를 판정함. 서로 +다른 축이라 하나가 다른 하나를 대체 못 함 — 계속 둘 다 필요. + +**children 배열 리터럴 경로도 같은 코드를 그대로 타므로 자동으로 커버됨** +(`Frame1{r}`/`Frame2{r}`가 원래 문제였던 그 케이스) — 리터럴 구성은 +`old`가 항상 `nil`이라 매번 `bindLifetime`이 불리고, 두 번째 자리에서 +`r`이 이미 살아있는 바인딩을 갖고 있으니 그 즉시 에러. + +`question.md`의 원 형제 프리미티브 대조 표(`Ref` 행 "없음")는 해소로 갱신, +상세는 `archive/question-resolved.md`. + ### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) @@ -533,10 +570,20 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에 지워지므로(`:Peek`/`Overridden`/버그로 타입을 우회해 PreRef가 Modifier나 Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. - 전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return - isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children - 배열 리터럴에만 놓을 수 있음") end }` — `NoneHandler`와 같은 결의 - "한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이 + 전용 `Handler`를 하나 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, + isHandlable = function(inst,k,v) return isPreRef(v) end, process = + function(inst,k,v) error("PreRef는 children 배열 리터럴에만 놓을 수 + 있음") end }` — `k` 타입은 안 가림(숫자든 문자열이든 `isPreRef(v)`만 + 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 전담하는 Handler" + 패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는 + `HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라 + `Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서 + 평범한 우선순위로 자기 핸들러를 등록하면 덮어쓸 수 있는" 자리 — + 지금은 그 자리를 아무도 안 가져가서 항상 이 가드가 매치돼 에러가 + 나지만, 나중에 named 자리 바인드 같은 실제 기능이 확정되면 base + 가드를 건드리지 않고 그 기능의 Handler를 평범한 우선순위로 하나 + 등록하는 것만으로 자연히 우선함, `base/dispatch-core-plan.md`의 + "`HANDLER_PRIORITY_FALLBACK`" 절). 이 Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에 등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의 `PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정, @@ -718,9 +765,12 @@ dispatch-core-plan.md` "Length/Offset" 절의 계약을 특수 취급 없이 그 동일: flatten되면 해시 파트로 존재하게 돼 "배열 파트" 전제를 벗어나고, Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는 정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위 -레지스트리에 `{ isHandlable = isPostRef(v), process = error("PostRef는 -children 배열 리터럴에만 놓을 수 있음") }` Handler를 등록 — pre-pass가 -이미 소진시키므로 이게 매치되면 곧 타입 차단을 우회한 버그라는 뜻. +레지스트리에 `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = +isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을 +수 있음") }` Handler를 등록(`k` 타입 안 가림 — `PreRef`의 "동적 경로 +가드" 절과 완전히 같은 이유로 `HANDLER_PRIORITY_FALLBACK`, 2026-08-14 +열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입 +차단을 우회한 버그라는 뜻. **1회용, 재사용은 즉시 error** — `PreRef`와 같은 `_fired` 플래그를 그대로 재사용(위 "PreRef는 '취소'라는 개념이 없다" 절과 같은 근거: 이미 fire된 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index 7c0f481..c195fcd 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -897,6 +897,23 @@ retract/Destroy되면 자동으로 정리됨. `Ref`와 같은 방식으로 라이프사이클에 묶어주는 것 말고는 base가 더 해줄 일이 없음. 새 dispatch 메커니즘이 아니라 기존 children-array 참가자 패턴의 반복. +- **동적 경로 가드 — `k` 무관 매치, `HANDLER_PRIORITY_FALLBACK` + (2026-08-14 열한 번째 세션, `PreRef`의 동적 경로 가드와 같은 패턴).** + `Observer`도 children 배열 리터럴 전용이라, 해시 파트 named 자리 + 등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 — + 전용 `Handler` 등록: `{ priority = HANDLER_PRIORITY_FALLBACK, + isHandlable = function(inst,k,v) return isObserver(v) end, process = + function(inst,k,v) error("Observer는 children 배열 리터럴에만 놓을 수 + 있음") end }`. `HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는 + 하드 블록이 아니라 `Tag`/`Attribute`/`PreRef`와 같은 "base가 소유하되 + 평범한 우선순위로 등록된 다른 Handler가 있으면 그쪽이 이기는" 자리이기 + 때문(`base/dispatch-core-plan.md`의 "`HANDLER_PRIORITY_FALLBACK`" 절) — + 지금은 아무도 그 자리를 안 가져가서 항상 이 가드가 에러를 내지만, 이 + Handler를 만드는 게 목적이 아니라 "지금은 확정된 기능이 없다"는 default를 + base가 값싸게 제공하는 것뿐. (**이 가드가 없던 이전엔** 확정된 "매치 + 실패는 즉시 error" 규칙에 의해 결과적으로 똑같이 에러가 났었음 — 이 + 가드는 동작을 바꾸는 게 아니라 에러 메시지를 명확하게 하고, 미래에 + override할 자리를 구조적으로 열어두는 것.) - **콜백 실행은 기존 `canExecute` predicate로 게이팅**(Slot 생존 확인과 동일한 재사용 — "canExecute 하나로 통일" 원칙, 새 메커니즘 발명 아님) — 발화 시점과 처리 시점 사이에 owning leaf가 이미 죽었으면 no-op. @@ -1023,7 +1040,7 @@ no-op. 한때 검토했던 "`isInit=false`면 허용, `isInit=true`+생존확인 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. -## 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canExecute(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, **2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합**) +## 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(value)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 `canBound`로 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정, 2026-08-14 다섯 번째 세션에 `canBound` 폐기·`canExecute`로 통합됐다가 **같은 날 열한 번째 세션에 `canBound`가 별도 진입점으로 재도입되어 다시 갈라짐** — 판정 로직은 공유, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스) **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 @@ -1055,31 +1072,36 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 바로 에러를 던져 버그를 그 자리에서 잡는 게 엔지니어링상 훨씬 쌈. -**[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)`은 -폐기하고 `canExecute(value)` 하나로 통합.** 게이트는 이 모양: +**[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`을 폐기하고 +`canExecute(value)` 하나로 통합했다가, 같은 날 열한 번째 세션에 +`canBound`가 다시 별도 진입점으로 도입됨]** — `Ref`가 emit 전파에 참여도 +안 하면서 "발화해도 되는가"(`canExecute`)를 묻는 게 개념적으로 안 맞다는 +지적(`question.md` 0-W)에서 나온 재분리. 게이트는 이 모양: ```lua -- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) -- — 둘 다 진입 전 동일하게 확인 -if canExecute(self) then +if canBound(self) then error(if self.Subscribed then "이미 :Subscribe()로 전역 바인딩된 값" else "이미 다른 Instance에 바인딩된 값") end ``` -- **"이미 유효하게 묶여 있다"와 "지금 실행 가능하다"가 정확히 같은 - 조건**이라 predicate를 둘로 나눌 이유가 없었음 — `canExecute`가 - 참이면 그 값은 어딘가에 살아있는 바인딩을 갖고 있다는 뜻이고, 그게 - 곧 "새로 묶으면 안 된다"임. +- **"이미 유효하게 묶여 있다"(`canBound`)와 "지금 실행 가능하다" + (`canExecute`)는 판정 로직이 같아서**(둘 다 비공개 헬퍼 + `isBoundAlive`를 그대로 부름, `base/lifecycle-pattern.md`) 값은 항상 + 같지만, 호출부의 질문이 서로 달라 이름은 분리돼 있음 — 이 절(이중 + 바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 + 씀. - **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, - 거짓인데 `canExecute`가 참이면 leaf 경로. + 거짓인데 `canBound`가 참이면 leaf 경로. - 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한 - 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canExecute`를 확인하므로 + 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 대칭적으로 막힘. - **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나 - `unbindLifetime`된 값은 `canExecute`가 거짓이라 게이트를 통과함(다른 + `unbindLifetime`된 값은 `canBound`가 거짓이라 게이트를 통과함(다른 `inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐. **[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 @@ -1090,9 +1112,11 @@ end 그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). 옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 -Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 -— `canExecute`가 gcconn 경로를 **먼저** 보기 때문. 역전 원문·오염 경로· -교훈은 `archive/canexecute-inst-arg-reversed.md`. +Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 +— `canBound`(와 `canExecute`가 공유하는 `isBoundAlive`)가 gcconn 경로를 +**먼저** 보기 때문. 역전 원문·오염 경로·교훈은 +`archive/canexecute-inst-arg-reversed.md`(그 문서 하단에 이 재분리 +경위도 추가돼 있음). - **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` (leaf 부착 포함) 경로는 `unbindLifetime(value)`로 해제** — 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 @@ -1106,7 +1130,7 @@ Observer가 `canExecute`에서 항상 `false`로 오판됨"은 실제로는 안 `unbindLifetime`이 leaf 해제의 실제 통로). - **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 - `canExecute` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect + `canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 8d2baa8..14288f8 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -168,9 +168,12 @@ end 일치를 검사**하므로 문제없이 `Source` 자리에 대입 가능 — 오히려 이 방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이 확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 — -`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 검증 난이도 -문제였던 것만 해소. (이 방식이 못 해주는 것은 `base/typing-limits.md`가 -따로 정리.) +`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증 +난이도 문제였던 것만 해소. **단 이 `type function` 접근 자체의 실측은 +아직 미완료** — 스파이크(`luau-test/rewrite-required/16-type-store-key- +typefunction.luau`)는 `types.newfunction` 시그니처 불일치로 깨져 재작성 +대기 중(`luau-test/STATUS.md` 소스). 실측 전까지는 설계 확정이지 검증 +완료가 아님(`base/typing-limits.md`가 이 구분을 명확히 다룸). ## Store가 Store를 저장 가능한가 diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 68e5e4c..30626f5 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -152,7 +152,7 @@ identity로 홀더를 추적하면 같은 객체를 두 위치(`k1`, `k2`)에 ```lua local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[k]: true}}} — 이름별 현재 걸고 있는 위치들 -TagHandler.priority = <일반> +TagHandler.priority = HANDLER_PRIORITY_FALLBACK TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용 function TagHandler.process(inst, k, v, index) @@ -264,12 +264,19 @@ removeTag(inst: any, names: {string}): () vararg → `string | {string}`으로 되돌아갔던 것과 **같은 이유**(위 "값 모양" 절). 배치 호출 자체는 테이블로도 되므로 웹 `className` 일괄 갱신 요구도 그대로 충족됨. -- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK`** — 특정 백엔드가 태그 - 처리를 통째로 다르게 하고 싶으면 평범한 우선순위로 자기 핸들러를 - 등록하면 자동으로 이김. 주입 op이 없는 백엔드에서는 base 스텁이 - 명확한 에러를 냄. 일반 원칙과 근거는 `base/dispatch-core-plan.md`의 - "base가 소유하는 핸들러와 주입되는 엔진 op" 절 — `Attribute`도 정확히 - 같은 구조(`base/attribute-plan.md`). +- **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK` — `TagHandler` 자신은 + quad-base가 모듈 로드 시점에 스스로 등록**(2026-08-14 열한 번째 세션 + 재확인 — 한때 "등록 자체가 백엔드 선택"으로 잘못 정정됐다가 철회됨). + `addTag`/`removeTag`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 + 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 + 진짜 원자적 실패를 원하는 백엔드는 opt-in으로 + `HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록 + 가능. 태그 처리 자체를 통째로 다른 알고리즘으로 바꾸고 싶은 백엔드는 + `HANDLER_PRIORITY_FALLBACK`보다 확실히 높은 우선순위로 자기 Handler를 + 등록하면 base `TagHandler`를 완전히 대체함(같은 override 원리). + 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 + 주입되는 엔진 op" 절 — `Attribute`도 정확히 같은 구조 + (`base/attribute-plan.md`). - 이건 새 아키텍처 개념이 아니라 이미 확정된 "base는 인터페이스/값, backend는 구현"(`LifetimeHandle`의 `bindLifetime`/`canExecute`, `Dispatch.addHandler` 자체가 그 패턴)을 핸들러 층까지 밀어붙인 것. diff --git a/.claude/base/typing-limits.md b/.claude/base/typing-limits.md index d35d77e..9f629cf 100644 --- a/.claude/base/typing-limits.md +++ b/.claude/base/typing-limits.md @@ -250,7 +250,7 @@ RFC가 순수 내부 변경이고 우리 선언이 이미 그 대상 모양이 아래는 **실측으로 통과 확인**된 것들이라 다시 의심하지 말 것: - **`Source`가 `State`를 구조적으로 만족**(서브타입으로 그대로 - 넘길 수 있음) — `luau-test/review-required/08`. 단 **단방향 의존을 + 넘길 수 있음) — `luau-test/done/08`. 단 **단방향 의존을 유지해야 함**(`State`가 `Source`를 참조하면 안 됨 — 두 제네릭 별칭의 상호 재귀는 솔버가 취약한 패턴). - **`PreRef`가 `Ref` 자리에 대입 가능** — `luau-test/13` A섹션. diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index f2769da..9c70549 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -11,8 +11,8 @@ | 폴더 | 뜻 | 누가 처리 | |---|---|---| | `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 | -| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 3차 세션]** `canExecute` 1-인자 재정정) | 에이전트 | -| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 3차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 | +| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`/`16`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) | 에이전트 | +| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 5차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 | | `done/` | 통과 or 판정 끝, 더 할 일 없음 | — | **스파이크를 고치거나 돌렸으면 파일을 해당 폴더로 `git mv`하고 STATUS.md의 @@ -76,7 +76,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 | | `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source`가 `State`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 | | `09-type-modifier-overridden-subtype.luau` (타입체크 전용) | `FrameModifier <: GuiObjectModifier`처럼 서브타입 관계인 Modifier를 `Overridden`으로 섞을 때 타입이 통과하는지 | `modifier-plan.md` 9-2번, ROADMAP M7 | -| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`]** (A) `bindLifetime`/`unbindLifetime`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 `canBound`, `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 폐기됨(게이트는 `if canExecute(v) then error(...) end` 하나, `canExecute`는 `value` 단독 1-인자, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | **[⚠️ 2026-08-14 다섯 번째 세션: A 섹션이 폐기된 모델을 검증 중 → `rewrite-required/`, 열한 번째 세션에 `canBound` 재도입으로 재작성 사유 하나 더 추가]** (A) `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute`의 gcconn 트릭 + 이중 바인딩 게이트(Destroy 시 Connected 전환 포함), (B) Attribute의 Instance 참조 타입 지원, (C) CollectionService 태그/GetTagged 왕복. **A는 재작성 대상** — 파일 속 옛 `canBound`(9차 세션 정의)와 `bindLifetime`의 `value.Subscribed = true` 세팅, 2-인자 `canExecute(inst, value)`는 전부 낡음(현재 게이트는 이중 바인딩 확인은 `canBound(v)`, emit 게이팅은 `canExecute(v)` — 둘 다 `value` 단독 1-인자로 비공개 헬퍼를 공유, gcconn/gchold는 **Instance 생성 시점**에 생성). **[2026-08-13]** A 섹션 앞부분(ClassName 신호 미발화, Destroy 시 Connected 즉시 전환)은 사용자 자작 스크립트로 부분 확인됐고 **새 모델에서도 그대로 유효**(오히려 더 중요 — `canBound`/`canExecute`가 `.Connected`를 직접 읽는 게 leaf 경로 판정의 전부), `audit/gcconn-trick-verification.md` 참고. 이중 바인딩 게이트/재바인딩 허용/B/C는 이 공식 파일로 아직 확인 안 됨 | `lifecycle-pattern.md` "`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정", `archive/canexecute-inst-arg-reversed.md`, `source-state-plan.md` "이중 바인딩 금지", CLAUDE.md 2026-08-06 세션, `debug-tooling-plan.md` | | `11-modifier-illegal-value-error.luau` | Modifier 필드에 Ref/PreRef/Observer/Effect/Slot/Modifier가 들어오면 즉시 error, State/Source가 확정하는 값이 Modifier면 즉시 error(2026-08-09 세션에 "UB"에서 전환된 규칙) | `modifier-plan.md` "핸들러 계층 값 즉시 error" 절 + 7번 절 | | `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 DI 키를 쓸 때 `value`의 타입이 실제로 `T`로 좁혀지는지 — base 문서 자신이 "미검증"이라 명시한 항목 | `attribute-plan.md` "[실측 필요, M0/M10]" (2026-08-09 열한 번째 세션 신설) | | `13-type-ref-preref-subtype.luau` | (A, 타입) `PreRef`가 `Ref`를 구조적으로 만족하는지, (B, 런타임) `isRef`/`isPreRef` 합성이 재정정대로 동작하는지(`isRef(preRefInstance)`가 이제 `true`) + Leaf 핸들러가 `isRef(v) and not isPreRef(v)`로 명시적으로 좁혀야 하는 이유. **[2026-08-14 아홉 번째 세션] 재작성 시 `PostRef`도 같이 커버할 것** — 같은 `Ref` 런타임 재사용 + 브랜드 태그만 다른 형제라 A/B 둘 다 그대로 확장되고, Leaf predicate도 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로 늘어남 | `brand-plan.md`의 `Brand` 절(2026-08-09 열한 번째 세션 재정정) | @@ -211,7 +211,7 @@ error)를 추가하고, `04`의 3~4단계를 "가드가 실제로 걸리는지 + 대조해야 판정 가능 — 별도 진행. `15`는 `SyntaxError`로 파싱 자체가 안 되는 상태(= 아무것도 검증 못 함)인 것만 먼저 확정. -**12차 (2026-08-14, 세 번째 세션, `10` → `rewrite-required/`)**: +**12차 (2026-08-14, 다섯 번째 세션, `10` → `rewrite-required/`)**: `bindLifetime`/`canExecute`/`unbindLifetime` 시그니처가 재정정되면서 (`canExecute`/`unbindLifetime`이 `inst`를 안 받는 1-인자, `.Subscribed`는 전역 `:Subscribe()` 전용이라 `bindLifetime`과 무관, 별도 predicate @@ -239,16 +239,18 @@ Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴 재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`), 재확인 불필요. - - 이중 바인딩 게이트는 이제 `canBound`가 아니라 **`canExecute(value)` - 하나**다(`if canExecute(v) then error(...) end`). 판정 기준도 이에 - 맞춰 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 - `bindLifetime`할 수 있어야 하고(게이트가 `canExecute` 거짓이라 - 통과), **`inst`가 Destroy된 뒤의 재바인딩도 이제 명시적으로 - 허용**임(살아있는 바인딩만 막는 게 게이트의 의도 — - `lifecycle-pattern.md` "`canBound` 폐기" 절). 이게 실패하면 - `canExecute` 통합 설계 자체를 재검토해야 함 — **아직 미확인.** + - **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 + **`canBound(value)`**다(`if canBound(v) then error(...) end`) — + `canExecute`는 State emit 전파 게이팅 전용으로 남고, `canBound`가 + 별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유, + `lifecycle-pattern.md` "`canBound` vs `canExecute`" 절). 판정 + 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 + `bindLifetime`할 수 있어야 하고(게이트가 `canBound` 거짓이라 + 통과), **`inst`가 Destroy된 뒤의 재바인딩도 명시적으로 허용**임 + (살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이 + 재분리 설계 자체를 재검토해야 함 — **아직 미확인.** - 새로 검증할 항목: `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔 - gcconn만으로 `inst` 생존을 판정할 수 있는가(=`canExecute`가 `inst` + gcconn만으로 `inst` 생존을 판정할 수 있는가(=`canBound`/`canExecute`가 `inst` 없이 성립하는가), gcconn/gchold를 **Instance 생성 시점**에 만들 때 클로저가 `inst`까지 캡처해 userdata 동일성이 유지되는가. - `04`는 **[2026-08-13 여섯 번째 세션에 전면 재작성 — 옛 판정 기준 폐기]** diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index 745b309..e5b660b 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -2,7 +2,7 @@ > 마지막 갱신: 2026-08-14 (**"emit은 항상 전파"** 정정으로 `05`가 옛 모델을 > 검증 중이라 `rewrite-required/`로 이동 — -> `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 **세 번째 세션**(`bindLifetime`/`canExecute`/ +> `archive/invalidate-dedup-propagation-reversed.md`). 직전 갱신은 같은 날 **다섯 번째 세션**(`bindLifetime`/`canExecute`/ > `unbindLifetime` 재정정으로 `10`이 옛 모델을 검증하고 있어 > `rewrite-required/`로 이동 — 이제 `not-run/`에는 스파이크가 없고 헬퍼만 > 남음). 직전 갱신은 2026-08-13 열네 번째 세션(하강 diff 재디스패치 확정으로 @@ -68,7 +68,7 @@ | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | | `16-type-store-key-typefunction.luau` | ❌ 실패 | `types.newfunction` 시그니처가 설치된 버전의 실제 API와 안 맞음 — 실제 API 재확인 후 재시도 | -| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 폐기된 `canBound`/`bindLifetime`의 `.Subscribed` 세팅/2-인자 `canExecute`를 검증 중 — **`canExecute(value)` 1-인자 + `bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). 이중 바인딩 게이트도 `canBound`가 아니라 `if canExecute(v) then error(...) end`로. **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | +| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 `canBound(value)`(`if canBound(v) then error(...) end`) — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 | ## ⚪ `not-run/` — 이 환경에서 못 돌림 diff --git a/.claude/question.md b/.claude/question.md index 5266a8d..4f53455 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -12,70 +12,20 @@ --- -## ⭐ 최우선 — **없음** (2026-08-13 열네 번째 세션 기준) +## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준) > **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의 > lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와 -> `0-A`(재디스패치 하강 diff)는 **열네 번째 세션에 확정·`base/` 반영 -> 완료**. 해소 전 원문과 결론은 `archive/question-resolved.md`, -> 뒤집힌 옛 재디스패치 모델은 `archive/dispatch-hintvalue-model-reversed.md`. +> `0-A`(재디스패치 하강 diff)는 열네 번째 세션에, **`0-W`(`Ref` 이중 +> 배치 방지)는 2026-08-14 열한 번째 세션에 확정·`base/` 반영 완료** — +> "결정 대기" 절 자체가 지금은 비어 있음. 해소 전 원문과 결론은 +> `archive/question-resolved.md`, 뒤집힌 옛 재디스패치 모델은 +> `archive/dispatch-hintvalue-model-reversed.md`. > > **M0 착수 전 읽을 것**: `base/typing-limits.md`(0-Y가 남긴 구현 규약), > `base/dispatch-core-plan.md`(0-A/0-Z가 반영된 디스패치 코어 — 열네 번째 > 세션에 `bind-system-plan.md`에서 분리 신설). -## 결정 대기 — M0는 안 막음 - -### 0-W. 같은 `Ref` 객체가 두 자리에 놓이는 걸 막을 것인가 (2026-08-13 열세 번째 세션 신설, 0-Z 확인 중 발견, 2026-08-14 열 번째 세션 기준 M4 구현 세부만 막는 유일한 잔여 항목 — 0-B는 해소돼 `archive/question-resolved.md`로 이전) - -**0-Z(Attribute, 열네 번째 세션에 해소됨)를 보다가 "Ref에도 같은 문제가 -있냐"는 사용자 질문에서 나온 것 — 있고, 막는 장치가 전혀 없음.** 단 -메커니즘은 0-Z와 **반대 방향**이라 별개 항목으로 분리: Attribute는 *두 소유자 → 한 자리*(이름별 -메모이즈된 키라 수렴), Ref는 *한 객체 → 두 자리*(발산). - -**손 트레이싱**(`base/ref-plan.md`의 `RefLeafHandler` 의사코드에 대입, -`Frame1 { Ref = r }` / `Frame2 { Ref = r }`): - -1. `process(inst1,"Ref",r)` → `relate[inst1]["Ref"]`가 nil → `r:Set(inst1)` -2. `process(inst2,"Ref",r)` → `relate[inst2]["Ref"]`도 nil(**다른 키**) → - `r:Set(inst2)` — inst1 바인딩이 **조용히 유실, 에러 없음** -3. inst1 자리가 retract → 클로저 인자 `nil ~= v(r)` → **`r:Set(nil)`** — - inst2가 정당하게 들고 있던 값을 지움(교차 오염) - -`relate`가 `(inst,k)`별로만 있어 "이 Ref가 이미 다른 자리에 있다"를 -원천적으로 못 봄. - -**형제 프리미티브 대조 — Ref만 비어 있음**: - -| | 공유 자원 | 방어 | 상태 | -|---|---|---|---| -| `Slot` | element | `claimOwner`/`claimOwnerAt` → 즉시 error(`Slot{a,a}`/`Frame{slot,slot}`) | 막힘 | -| `PreRef`/`PostRef` | 자기 자신 | `_fired` → 재사용 시 error | 막힘 (`PostRef`는 2026-08-14 아홉 번째 세션 신설, 같은 가드 그대로) | -| `Tag` | 태그 이름 | 위치별 참조 카운트 — 겹침이 **의도된 동작**(합집합) | 설계상 정상 | -| `Attribute` | 이름 | 그룹 전용 키 + 이름 claim → 즉시 error | 막힘(0-Z 해소, 14차 세션) | -| **`Ref`** | 자기 자신 | **없음** | **이 항목** | - -특히 걸리는 두 가지: -- **`PreRef`는 정확히 이 재사용을 error로 막고**, 문서가 "`Slot:List`의 - `updateFn`처럼 반복 호출되는 자리에선 매번 새 `PreRef()`를 만들라"는 - 관용구까지 명시해뒀음 — **일반 `Ref`는 같은 자리에서 같은 실수를 해도 - 아무도 안 막음**(비대칭). -- 스파이크 `19`가 Tag/Attribute/Slot 소유권은 음성 대조군까지 넣어 - 검증하는데 **`Ref`만 커버가 없음**. - -**0-Z와의 관계 — 독립**: 이건 하강 diff 모델이 만든 회귀가 **아니라 -원래부터 있던 갭**(옛 점유 체크도 같은 `(inst,k,index)`만 봤지, 서로 다른 -자리를 가로지르는 건 원래 안 봤음). **[2026-08-13 열네 번째 세션] 0-Z가 -해소되면서 이 표에서 `Ref`만 유일하게 비어 있게 됐음** — Attribute는 -"이름 claim"이라는 국소 레지스트리로 갔으니, `Ref`도 같은 모양(`Ref → -현재 자리` 단방향 `Relate` + 즉시 error)이 자연스러운 선택지. 여전히 -**M0를 막지는 않음**. - -**선택지**: (a) `Slot`/`PreRef`와 같이 즉시 error(일관성 높음, `Relate` -하나로 Ref→현재 자리 추적), (b) UB로 두고 문서화만(현상 유지 — -단 증상이 "조용한 값 소실"이라 다른 UB보다 나쁨), (c) 마지막 쓰기 승리를 -정식 동작으로 인정(비권장, `Ref`의 "확정된 값 박스" 의미와 충돌). - ## 1. 용어 정리 — 아직 안 정해진 것만 (사용자 요청, 진행 중) 사용자 원 메모: "quad는 register라던가 좀 부정확하거나 느낌이 바로 와닿지 @@ -109,11 +59,15 @@ 점이 지적됨. `canExecute`는 타입이 아니라 liveness(생존 여부)를 묻는 질문이라 `is`보다 `can` 계열 접두를 유지하는 쪽이 낫다는 방향으로 사용자가 기욺 — 여전히 미확정, 다음에 `can`으로 시작하는 구체 대안(예: `canRun`)을 - 같이 검토할 것. **[2026-08-14 다섯 번째 세션] 열려 있는 건 이름뿐** - — 시그니처는 `canExecute(value): boolean` 1-인자로 확정됐고(옛 - `(inst, value)` 2-인자는 폐기), 폐기된 `canBound`의 몫까지 이 하나가 - 겸함(`base/lifecycle-pattern.md`, `archive/canexecute-inst-arg-reversed.md`) - — 이름을 바꾸면 그 두 역할을 다 담아야 함에 유의. + 같이 검토할 것. **[2026-08-14 다섯 번째 세션] 시그니처는 + `canExecute(value): boolean` 1-인자로 확정**(옛 `(inst, value)` 2-인자는 + 폐기, `base/lifecycle-pattern.md`, `archive/canexecute-inst-arg-reversed.md`). + **[2026-08-14 열한 번째 세션 정정]** 당시엔 `canBound`가 폐기돼 + `canExecute` 하나가 두 역할을 겸했으나, 이후 `canBound`가 별도 + 진입점으로 재도입되며 다시 갈라짐(같은 문서의 "`canBound` vs + `canExecute`" 절) — 이 이름 정리 항목은 이제 `canExecute`(emit 게이팅 + 전용)에만 적용되고, `canBound`(이중 바인딩 가드 전용)는 별개 이름으로 + 유지됨. 둘 다 판정 로직은 비공개 헬퍼 하나를 공유. - **클로저 인자 이름 `hintValue`(3순위, 사소함, 2026-08-13 열네 번째 세션 신설)**: 하강 diff 재디스패치에서 이 인자는 더 이상 "힌트"가 아니라 **`nil`이거나 같은 핸들러가 곧 처리할 새 값**임이 계약으로 @@ -170,11 +124,12 @@ 엘비스 연산자류) 후보 추가 — Haskell 비교 리서치 중 나옴, 카탈로그 확정 규칙에 그대로 맞아 포함 근거는 있음. 상세는 `research/operator-sugar-plan.md`. 구현 자체는 맨 마지막 우선순위(순수 슈가, 없어도 무방) — 여전함. -- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문 4개** — +- **[신설, 2026-08-14] Debounce/Throttle — 남은 열린 질문(개수는 + `research/debounce-throttle-plan.md` 12절이 소스, 여기서 반복 안 함)** — 사용자 요청("`Blocker`와 유사하게 만들어야 한다")으로 `research/debounce-throttle-plan.md` 신설. 네 번의 리뷰 라운드로 **패키지 경계·주입 op 시그니처·`Timeout` 타입·동작 정식화는 전부 - 확정**됐고, 사용자 취향/방향이 갈리는 것만 남음: + 확정**됐고, 사용자 취향/방향이 갈리는 것만 남음 — 주요 4개: - **의미론** — 창이 열려 있는 동안 `:Get()`이 최신값을 주는가 (`Blocker`와 동일) vs 지연된 값을 주는가(권장, VueUse/RxJS와 일치). - **제어 핸들** — `state:Apply(Debounce{...})`로 끝낼지, `Blocker`와 @@ -185,7 +140,11 @@ 업계 표준 이름을 유지하고 문서로 경고할지, 다른 이름을 쓸지. - **`Time = 0` 허용 여부** — "이번 스텝 합치기"로 유용하지만 스케줄러 타이밍에 의존하는 준-`Blocker`가 됨. - 우선순위 낮음 — M0를 막지 않고 코어 계약도 안 건드림. 다만 **M3에서 + 이 외에 사소한 것 둘도 12절에 열려있음: 공개 생성자 2개 개정안이 + 사용자 확인만 남은 상태, `base/blocker-plan.md`가 인용하는 "`Get()`은 + 라이브 레퍼런스" 문구에 한 줄 명확화가 필요(확정 문서 수정이라 승인 + 대기, 아직 안 건드림). 우선순위 낮음 — M0를 막지 않고 코어 계약도 안 + 건드림. 다만 **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용으로 빼두는 것**만은 그 시점에 해야 함(따로 하면 같은 설계를 두 번 함). - **중첩 State 평탄화 `State>` → `State`(2026-08-13 여섯 번째 diff --git a/.claude/reference/comparison-charm.md b/.claude/reference/comparison-charm.md index bb26949..ae90abd 100644 --- a/.claude/reference/comparison-charm.md +++ b/.claude/reference/comparison-charm.md @@ -67,19 +67,18 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) 둔 이유 — quad의 배열/해시 파트 `None` 센티널 정당화(`base/ bind-system-plan.md:180-266`)와 동기 없이 같은 결론에 수렴한 사례. 새 아이디어는 아니고 인용 근거로만 가치 있음. -- **quad가 미결로 남긴 "previous 값 비교" 문제에 대한 두 가지 답.** - (1) `signal(initialValue, equals?)`(`init.luau:432`, `Equals` 타입은 +- **charm의 `previous` 유사 메커니즘 두 가지 — quad가 이미 확정한 + `:Compute(fn)`의 `previous` 인자(`base/source-state-plan.md` "previous" + 절, `fn(self, previous?, ...deps)`)에 대한 독립 실동작 증거.** (1) + `signal(initialValue, equals?)`(`init.luau:432`, `Equals` 타입은 23행)는 생성 시점에 `initialValue`를 항상 요구해서 "비교할 이전 값이 - 아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰 — - `research/additional-primitives-plan.md`가 남겨둔 "비교할 이전 값이 - 확정 안 된 문제"에 대한 한 가지 해법 형태. (2) `computed(getter)`가 + 아직 없다"는 애매한 첫 상태 자체를 구조적으로 없앰. (2) `computed(getter)`가 getter에 **이전 계산 결과**를 인자로 넘겨줌(`init.luau:538`, `(previousValue: T?) -> T`, README 276-287행, `computed.test. - luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad의 - `base/source-state-plan.md`가 이미 띄워둔 "`:Compute(fn)`에 선택적 - 두 번째 `previous` 인자" 안과 거의 동일한 모양. 새로 수입할 아이디어가 - 아니라 **이미 검토 중인 안이 실제로 동작한다는 정황 증거**로 인용 - 가치 있음. + luau:84-104`가 홀수 업데이트를 스킵하는 걸로 실제 검증) — quad가 + `base/source-state-plan.md`에서 이미 확정한 `previous` 안과 거의 동일한 + 모양. 새로 수입할 아이디어가 아니라 **이미 확정된 안이 실제로 동작한다는 + 정황 증거**로 인용 가치 있음(더 이상 열린 질문 아님). - **charm-sync의 diff/patch 메커니즘 — quad가 아직 전혀 안 다뤄본 영역이라 가장 새로운 참고자료.** `patch.luau:59-89`(`diff`)가 재귀적 구조적 diff로 중첩 patch 테이블을 만들고, `apply`/`applyMutable` @@ -116,8 +115,8 @@ init.luau`, ~1000줄) + `charm-sync`(클라/서버 상태 복제 diff 레이어) 기각한 패턴을 그대로 구현하고 있음 — 사용자가 애초에 예상한 "짧은 라이브러리라 새로운 게 없을 것"이 이 레이어에는 대체로 맞음. 진짜 참고 가치는 코어 밖에 있음: charm-sync의 diff/patch(현재 quad 스코프 밖이지만 -새 영역), 그리고 quad가 미결로 열어둔 Blocker의 "previous 값 비교" 문제에 -대한 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`의 +새 영역), 그리고 quad가 이미 확정한 `:Compute`의 `previous` 인자가 실제로 +동작한다는 두 가지 실동작 사례(`signal`의 필수 initialValue, `computed`의 previous-in-getter). 지금 당장 base 문서를 고칠 만한 발견은 없음 — 순수 참고자료로 등록. diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 3001947..c02beb3 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -7,9 +7,12 @@ batch-rejected.md`, Context(+레이어드 Store 대안) → `archive/ context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)` 메소드로 완전히 확정되어 `base/slot-plan.md`로 승격됨(아래 절은 요약+포인터만 남기고 상세는 그쪽 -참고) — **이 문서에 새로 열려있는 설계 질문은 더 이상 없음**, 아래 표/ -"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" 절은 배경 리서치 기록으로만 -유지. +참고) — **[2026-08-09 세 번째 세션 기준] 이 문서에 새로 열려있는 설계 +질문은 없었음**, 아래 표/"빈 자리 아닌 것"/"문서화 백로그"/"참고 소스" +절은 배경 리서치 기록으로만 유지. **단 이후 세션에 열린 질문 2개가 +새로 추가됨** — "Attribute 그룹 명시적 unset 유틸"(2026-08-12), +"중첩 State 평탄화 `State>`"(2026-08-13 여섯 번째 세션, 이후 +`question.md` 백로그로 근거 축소) — 아래 해당 절 참고. ## 배경 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index ef9b54f..64b52bd 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -188,11 +188,12 @@ Modifier/Ref를 아예 안 넘기는 케이스를 반드시 포함시킬 것.** ### 1-6. `canExecute`/`Connected`의 실제 구현 방식이 미확정인 채로 코어 전역에 이미 재사용 확정됨 -**[해소됨, 2026-08-08 세션. 시그니처는 2026-08-14 다섯 번째 세션에 정정]** -`bindLifetime(inst,value)`/`unbindLifetime(value)`/`canExecute(value)` -탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/ +**[해소됨, 2026-08-08 세션. 시그니처는 2026-08-14 다섯 번째 세션에 정정, +`canBound`는 열한 번째 세션에 별도 진입점으로 재도입]** +`bindLifetime(inst,value)`/`unbindLifetime(value)`/`canBound(value)`/ +`canExecute(value)` 탑레벨 함수로 확정, `Relate` 프리미티브(`base/relate-plan.md`) 위에 gcconn/ gchold를 얹는 구체 구현까지 나옴 — `base/lifecycle-pattern.md`의 -"`bindLifetime`/`canExecute`/`unbindLifetime` — 확정" 절이 최신 +"`bindLifetime`/`canBound`/`canExecute`/`unbindLifetime` — 확정" 절이 최신 (뒤의 둘이 `inst`를 안 받게 된 경위는 `archive/canexecute-inst-arg-reversed.md`). 아래는 이 결정이 나오기 전까지의 문제 서술(정확했던 문제 인식이라 그대로 둠, 남은 실측 항목은 diff --git a/.claude/session/2026-08-14-11-corpus-audit-canbound-resplit.md b/.claude/session/2026-08-14-11-corpus-audit-canbound-resplit.md new file mode 100644 index 0000000..d1d76ba --- /dev/null +++ b/.claude/session/2026-08-14-11-corpus-audit-canbound-resplit.md @@ -0,0 +1,262 @@ +# 2026-08-14 열한 번째 세션 — 코퍼스 전체 감사(서브에이전트 6개 병렬) + `canBound`/`canExecute` 재분리, `question.md` 0-W 해소 + +## 코퍼스 감사 + +사용자 요청으로 `.claude/` 전체를 감사 — 먼저 `python3 .claude/tools/doc-check.py`로 +기계 점검(ERROR 0 확인)한 뒤, `base/`(3분할)·`research/`+`reference/`· +`luau-test/`+`audit/`+`archive/`·루트 인덱스 파일(CLAUDE.md/ROADMAP.md/ +HUMAN_TODO.md/question.md/`.claude/README.md`) 6개 영역을 서브에이전트로 +병렬 감사, 보고된 항목을 직접 재확인 후 실제 사실 오류만 반영. 총 15개 +파일 수정: + +- `modifier-plan.md` 2곳 — `Brand`/`isState` 정의 인용이 9차 세션 문서 + 분할 후 옛 경로(`bind-system-plan.md`)를 계속 가리킴 → `brand-plan.md`로 + 정정 +- `tag-plan.md` — `TagHandler.priority` 의사코드가 `HANDLER_PRIORITY_FALLBACK` + 이어야 하는데 `<일반>`으로 남아 같은 문서의 "패키지 배치" 절과 자기모순 +- `typing-limits.md` — 스파이크 경로가 `review-required/08`로 stale(13차 + 세션에 `done/08`로 이동) +- `store-plan.md` — `store.key` 타입함수 접근을 "실측 확인"처럼 과장 + 서술해 `typing-limits.md`(스파이크 `16`은 여전히 `rewrite-required/`, + 미검증)와 모순 → caveat 추가 +- `additional-primitives-plan.md` — "새로 열린 질문 없음" 배너가 이후 + 세션에 추가된 열린 질문 2개(Attribute unset 유틸, `State>` + 평탄화)와 자기모순 +- `comparison-charm.md`+`.claude/README.md` — "quad가 미결로 남긴 previous + 값 비교 문제"라며 존재하지 않는 문서(`additional-primitives-plan.md`)를 + 잘못 인용 — 실제로는 `source-state-plan.md`에 이미 확정된 `:Compute`의 + `previous` 인자 +- `luau-test/STATUS.md`/`README.md` — `canExecute` 재정정 세션 번호가 + "세 번째"(STATUS.md 헤더 1곳, README.md 2곳)와 "다섯 번째"(같은 파일 + 본문, archive, CLAUDE.md)로 갈라짐 — archive 3곳 교차확인 결과 + "다섯 번째"가 맞음, `05`(8차 세션 이동)도 README 테이블에 누락돼 있어 + 보강 +- `question.md`/`HUMAN_TODO.md` — 0-W가 "M4 구현 세부만 막음"이라 + 서술했는데 실제 `Ref` 구현은 M8 — 정정, `ROADMAP.md` M8에 포인터 추가 +- `question.md` — Debounce/Throttle "남은 열린 질문 4개"가 실제 + `debounce-throttle-plan.md` 12절엔 6개(항목 3/7 누락) → 개수 서술 + 제거하고 소스 문서로 위임 +- `CLAUDE.md` 자기 자신 2곳 — `luau-test/rewrite-required` 나열이 + `05`(8차 세션 합류)/`04`/`19`(14차 세션 합류)를 빠뜨려 stale, "지금 + 할 일" 절엔 "더 이상 대기 항목 아님"이 바로 위 문단과 자기모순 → + 나열 자체를 걷어내고 `STATUS.md`로 위임 + +`doc-check.py` ERROR 0 유지 확인. 발견 없음으로 보고된 영역: +`architecture`/`attribute`/`bind-system`/`blocker`/`brand`/ +`component-composition`/`dispatch-core`/`effect`/`event`/`fallback`/ +`lifecycle-hooks`/`lifecycle-pattern`(*아래 별도 발견 있었음, 감사 라운드 +자체는 통과 보고했으나 이후 논의 중 직접 발견*)/`module-lifecycle`/ +`onchange`/`purity-and-effects`/`ref`/`relate`/`source-state`/`tween`/ +`ui-shorthand-plan`, `debounce-throttle`/`debug-tooling`/ +`documentation-*`/`framework-comparison`/`operator-sugar`/ +`pre-implementation-audit`/`v1-compat-plan`, `comparison-fusion-vide`, +`quad-v1-architecture`, `archive/` 23개 전체, `audit/` 나머지. + +## `canBound`/`canExecute` 재분리, `question.md` 0-W 해소 + +감사 완료 후 사용자가 `question.md` 0-W(같은 `Ref`가 두 자리에 동시에 +놓이는 걸 막을지)에 대해 이미 마음속으로 결정해뒀던 것을 풀어놓음 — 선택지 +(a)(즉시 error) 확정, 메커니즘은 `RefLeafHandler`가 `bindLifetime`/ +`unbindLifetime`을 재사용(새 전용 `Relate` 불필요 — `bindLifetime`이 이미 +내장한 이중 바인딩 가드를 그대로 타면 됨). 그런데 이 가드가 지금 +`canExecute`라는 이름으로 되어 있는 게 이상하다는 지적 — `canExecute`는 +"emit 처리로 State 전파 루프가 Effect/Observer를 발화시켜도 되는가"를 +묻는 함수인데, `Ref`는 emit 전파에 참여조차 안 하니 그 이름을 쓰는 게 +개념적으로 안 맞음. "bound 문맥"(이미 묶여 있는가 — `bindLifetime`/ +`Observer:Subscribe()`의 이중 등록 가드가 묻는 것)과 "execute 문맥"(지금 +발화해도 되는가 — State 전파 루프만 묻는 것)은 오늘 판정값이 같아도 +서로 다른 질문이라는 게 사용자 논거. + +**해법(사용자 제안) — 이름은 둘, 판정 로직은 하나.** 2026-08-14 다섯 +번째 세션이 "canBound 폐기, canExecute로 통합"했던 걸 부분적으로 되짚어 +`canBound`를 별도 진입점으로 재도입하되, 실제 gcconn/`.Subscribed` 체크는 +비공개 헬퍼 `isBoundAlive(value)` 하나에만 두고 `canBound`/`canExecute` +둘 다 그 헬퍼를 부르는 얇은 래퍼로 — 코드 중복 없이 호출부 의미만 분리. +**다섯 번째 세션이 고친 것(2-인자 `canExecute(inst,value)`가 오염이었다는 +것, `unbindLifetime`/`canExecute`가 `inst`를 안 받아야 한다는 것)은 전부 +그대로 유효** — 되짚은 건 "합칠지 나눌지"라는 좁은 하위 결정뿐. + +반영: `base/lifecycle-pattern.md`(정본, `isBoundAlive`/`canBound`/ +`canExecute` 코드 전면 재작성 + "canBound vs canExecute" 절 신설), +`base/ref-plan.md`("이중 배치 방지" 절 신설, `RefLeafHandler` 의사코드에 +`bindLifetime`/`unbindLifetime` 호출 추가), `archive/ +canexecute-inst-arg-reversed.md`(재분리 경위 addendum), `question.md` +0-W 항목을 `archive/question-resolved.md`로 이전, `ROADMAP.md` +M2/M3/M8 체크리스트, `base/source-state-plan.md`의 "이중 바인딩 금지" +절(게이트를 `canBound`로 교체), `base/effect-plan.md`(UB 절 갱신 + +"세 번째 세션" 오표기를 "다섯 번째"로 같이 정정 — lifecycle-pattern.md에서 +도 같은 오표기 발견해 정정), `base/architecture.md`(소스 트리 주석), +`base/dispatch-core-plan.md`/`research/pre-implementation-audit.md` +(절 제목 인용 갱신), `luau-test/README.md`/`audit/ +gcconn-trick-verification.md`(스파이크 `10` 재작성 시 반영할 새 게이트 +이름 갱신), CLAUDE.md 자신("지금 할 일" 0번 — `question.md`의 "결정 +대기" 절이 이제 완전히 빔). + +`doc-check.py` ERROR 0 유지. 새로 연 설계 질문 없음 — `Slot`의 +`claimOwner`/`Attribute`의 이름 claim처럼 `Ref`도 기존 프리미티브 +(`bindLifetime`)를 재사용하는 것으로 끝남. + +## 후속 — `Frame1{Ref=r}` 표기 정정, `PreRef`/`PostRef`/`Observer`/`Effect` +동적 경로 가드를 `HANDLER_PRIORITY_FALLBACK`으로 통일 + +사용자가 0-W 손 트레이싱의 `Frame1 { Ref = r }` 표기를 질문 — `Ref`는 +항상 children **배열** 리터럴로만 놓이므로 `k`가 문자열 `"Ref"`일 리 +없고(실제로는 배열 인덱스, 숫자), 순수 설명 편의 표기였음을 확인. +`base/ref-plan.md`의 새 절과 `archive/question-resolved.md`에 보존된 +원본 트레이싱에 정정 각주 추가(원본 자체는 역사 보존 목적으로 안 지움). + +이어서 사용자가 `PreRef`/`PostRef`/`Observer`/`Effect`가 non-number +키(해시 파트 등 동적 경로)로 들어오면 어떻게 할지 질문 — 처음엔 "`k` +무관하게 매치해서 `process`에서 에러"(Attribute처럼 process 도중 +에러내는 기존 패턴)를 제안했다가, 곧바로 스스로 "가장 아래(FALLBACK)에 +까는 게 맞다"고 정정. 결론: `PreRef`의 기존 "동적 경로 가드" Handler가 +이미 `k` 무관 매치+에러였지만 우선순위가 명시돼 있지 않았던 걸 +`HANDLER_PRIORITY_FALLBACK`으로 확정 — 하드 블록이 아니라 `Tag`/ +`Attribute`와 같은 "base가 소유하되 평범한 우선순위의 다른 Handler가 +있으면 그쪽이 이기는" 자리로 만들어, 지금은 항상 에러가 나지만 나중에 +named 자리 바인드 같은 실제 기능이 확정되면 base 가드를 안 건드리고 +그 기능의 Handler만 등록하면 자동으로 우선하게 됨. 같은 패턴을 `PostRef` +(기존 거울상 가드에 우선순위 추가)와 `Observer`/`Effect`(기존엔 전용 +가드가 없어 범용 "매치 실패=에러" 경로로 우연히 같은 결과를 내고 +있었음 — 이번에 전용 `HANDLER_PRIORITY_FALLBACK` 가드를 신설해 넷 다 +명시적으로 통일, 동작 자체는 안 바뀌고 에러 메시지만 명확해짐)에도 +적용. `base/ref-plan.md`/`base/source-state-plan.md`/`base/effect-plan.md`/ +`ROADMAP.md` M8/M3 반영, `doc-check.py` ERROR 0 유지. + +이어서 사용자가 Tag/Attribute의 미주입 백엔드 실패 모드(base 기본 +스텁이 명확한 에러를 낸다는 것)를 확인한 뒤, "미지원"과 "미등록"을 +구분 안 하는 게 맞는지 질문 — 확정 원칙(`pre-implementation-audit.md` +1-4, 스텁 입장에선 원천적으로 구별 불가) 그대로 재확인. 사용자가 +"정말로 미지원인 백엔드는 FALLBACK보다 높은 우선순위로 자기 Handler를 +등록해 명시적으로 에러내면 된다"는 관례를 문서에 말로만 적어두자고 +제안(강제 메커니즘 아님, 대부분 백엔드가 결국 지원할 거라 실익은 +크지 않지만 opt-in 옵션으로) — `base/dispatch-core-plan.md`의 "base가 +소유하는 핸들러와 주입되는 엔진 op" 절에 관례 bullet 추가. + +**곧바로 더 단순한 안으로 정정**: 사용자가 "그런 환경은 addTag든 뭐든 +nop일 수도 있으니, [모든 백엔드가] 이 op들을 다 등록하도록 해야 하고, +대신 [미지원이면] 에러로 '구현 안 되어있다'를 내도록 [팩토리가 직접 +작성]하면 된다"고 제시 — 방금 추가한 "Handler 우선순위로 덮어씌우기" +관례를 철회하고, 대신 **op 등록 자체를 필수로** 바꿈: 백엔드 팩토리는 +`addTag`/`removeTag`/`setAttribute`를 항상 명시적으로 채워야 하고(빈 +자리로 두고 base의 미주입 스텁에 기대지 않음), 지원 안 하는 op은 +`nop`(정당한 구현 선택)이든 `function() error("구현 안 됨") end`(명시적 +에러)든 팩토리가 직접 등록. Dispatch Handler나 우선순위 조정이 전혀 +불필요해서 이전 안보다 훨씬 가벼움 — 부수 효과로 "provider 미주입"과 +"미지원"도 자연히 갈라짐: 제대로 된 팩토리는 항상 세 op를 다 채우므로, +base 스텁이 실제로 매치되는 건 "어떤 팩토리도 안 돌았다"는 훨씬 좁은 +경우로 좁혀짐. `base/dispatch-core-plan.md`/`base/module-lifecycle-plan.md` +반영, `doc-check.py` ERROR 0 유지. + +**다시 한 번 정정 — 사용자가 이번엔 op 필수화만으론 부족하다고 지적.** +`addTag` 에러는 `TagHandler.process` 본문 안, 즉 `tagNameMap` 같은 부기가 +이미 일부 mutate된 *뒤*에야 남 — `Tag()`가 "바인드는 됐는데 엔진 반영만 +실패"인 반쪽짜리 상태로 남을 수 있어 원자적 실패가 아님. 사용자 결론: +"그래도 여전히 Tag()는 바운드 가능함... 핸들러도 1만큼 높은 우선순위 +채워주는 게 가장 안전하고 사실상 무료" — op 필수화(위)는 유지하되, +**추가로** 미지원 백엔드는 `HANDLER_PRIORITY_FALLBACK + 1` 우선순위의 +순수 가로채기 Handler(`isHandlable = isTag(v), process = error(...)`)도 +등록해 `TagHandler.process` 자체가 아예 안 불리게(부기 mutation 0회) 하는 +걸 권장으로 추가 — 매치된 Handler 하나만 실행되는 기존 Dispatch 규칙을 +그대로 이용하는 것이라 새 메커니즘 없음. `base/dispatch-core-plan.md`/ +`base/module-lifecycle-plan.md` 재반영, `doc-check.py` ERROR 0 유지. + +**세 번째 정정 — 사용자가 "철회 아님, 전제 자체가 틀렸다"고 바로잡음.** +어시스턴트가 "`TagHandler`/`AttributeKeyHandler`는 quad-base가 자기 +모듈 로드 시점에 스스로 등록한다"고 잘못 전제하고 "+1 Handler" 안을 +철회했었는데, 사용자가 정정: **Tag/Attribute는 모든 백엔드의 필수 +구현이 아니고, quad-base는 이 Handler들을 완성된 값으로 export만 할 +뿐 스스로 Dispatch에 등록하지 않는다.** 백엔드 팩토리가 둘 중 하나를 +선택: **(A) 지원함** — quad-base Handler를 그대로 등록 + `addTag`/ +`removeTag`/`setAttribute` 실제 구현 주입, **(B) 지원 안 함** — 그 +Handler는 등록하지 않고 `HANDLER_PRIORITY_FALLBACK + 1` 우선순위의 +얇은 "미지원" 가로채기 Handler만 등록(op 주입 자체가 불필요 — 호출할 +주체가 그 백엔드의 레지스트리에 없음). 이 모델이면 "op 에러가 부기 +mutation 뒤에 지연 발생"하는 문제 자체가 안 생김 — (B)를 고른 백엔드에서 +부기를 mutate하는 `TagHandler.process`가 아예 안 불리기 때문. 앞서 +검토했던 "op 필수화"와 "process 재정렬" 두 안은 전부 이 등록-선택 +모델로 대체되어 불필요해짐. `base/dispatch-core-plan.md`(정본, 전면 +재작성)/`base/module-lifecycle-plan.md`/`base/tag-plan.md`/ +`base/attribute-plan.md`/`base/architecture.md` 반영, `doc-check.py` +ERROR 0 유지. + +**네 번째, 최종 정정 — "등록-선택 모델" 자체가 틀렸음, 사용자가 바로잡음.** +사용자: "HANDLER_PRIORITY_FALLBACK까지 등록 안한다 했는데, 개소리 ㄴㄴ +HANDLER_PRIORITY_FALLBACK는 아무 등록 없는데 처리하려 할 때 방어까지 +포함하는 애임. 기본 등록은 필요함." **최종 확정 모델**: `TagHandler`/ +`AttributeKeyHandler`/`AttributeGroupHandler`는 quad-base가 모듈 로드 +시점에 `HANDLER_PRIORITY_FALLBACK`으로 **스스로 등록**(모든 백엔드가 +자동으로 부기를 얻음, FALLBACK 밴드의 존재 이유 자체가 이 "기본 +안전 동작 제공"임). `addTag`/`removeTag`/`setAttribute`만 백엔드 +팩토리가 채우는 타입 계약이고, 안 채운 슬롯의 base 기본값은 "그럴듯한 +기본 동작을 추측"하지 않고 **명시적으로 에러내는 스텁**(조용한 no-op은 +provider 초기화를 잊은 실수를 가려버려서 기각). 더 명확한 메시지나 +진짜 원자적 실패(부기 mutation 0회)를 원하는 백엔드만 **opt-in으로** +`HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록— +이게 필수 요구사항이 아니라 선택적 업그레이드라는 게 핵심(base 기본 +스텁 하나로도 `AttributeGroupHandler`의 "부분 실패 경로" 원칙만으로 +충분히 안전). `base/dispatch-core-plan.md`(정본, 다시 전면 재작성)/ +`base/module-lifecycle-plan.md`/`base/tag-plan.md`/ +`base/attribute-plan.md`/`base/architecture.md` 재반영, `doc-check.py` +ERROR 0 유지. + +**교훈**: 이번 라운드는 네 번 연속 정정이 있었음(op 위치 우선순위 → +op 필수화 → 등록-선택 모델(틀림) → base가 스스로 등록(최종)) — 매번 +"왜 이게 맞는지"를 서둘러 확정하기 전에 실제 아키텍처 전제(누가 +Dispatch.addHandler를 부르는가)부터 정확히 확인했어야 했음. 이 전제 +하나가 중간 두 라운드의 결론을 전부 무효화시켰음 — 확신 없는 아키텍처 +주장은 재확인 없이 단정하지 말 것. + +## 후속 — 세션 전체 자기 감사(사용자 요청 "다른곳에도 실수한거 있는지 찾아봐") + +`git diff`로 이 세션에서 건드린 25개 파일 전체를 처음부터 다시 정독 — +`canBound`/`canExecute` 재분리, `Ref` 0-W 해소, PreRef/PostRef/Observer/ +Effect 동적 경로 가드, Tag/Attribute 등록 모델, 그리고 최초 코퍼스 +감사 15건 전부 논리적 일관성 재확인. 실제 문제 3건 발견·수정: + +1. **`HUMAN_TODO.md`가 0-W 해소 이후에도 "남은 건 0-W" 그대로 방치돼 + 있었음** — 세션 도중 `question.md`/CLAUDE.md는 0-W 해소를 반영했는데 + `HUMAN_TODO.md`만 그 전 라운드(M4→M8 정정)에서 멈춰 있던 stale 갱신 + 누락. "결정 대기 절 자체가 비어있음"으로 정정. +2. `base/ref-plan.md`의 "이중 배치 방지" 절에 편집 중 생긴 어색한 줄바꿈 + (문장 중간에 orphan line) — 순수 포맷 정리. +3. **Tag/Attribute 등록 모델을 재정리하며 `tag-plan.md`/`attribute-plan.md`의 + "백엔드가 통째로 다르게 처리하고 싶으면 override 가능" 문장을 실수로 + 빠뜨림** — "미지원 신호" 용도(opt-in `FALLBACK+1` 가로채기)만 남고 + "알고리즘 전체 교체" 용도(그냥 더 높은 우선순위로 자기 Handler 등록)가 + 누락돼 있었음. 둘 다 유효한 별개 용도라 양쪽 파일에 복원. + +나머지(canBound/canExecute 코드 블록의 순환·호출부 일관성, Ref +bindLifetime/unbindLifetime 배치, FALLBACK 우선순위 값들, 세션 로그· +CLAUDE.md 서술과 실제 base/ 반영 내용의 일치)는 전부 문제 없음 확인. +`doc-check.py` ERROR 0 유지. + +## 후속 — `/code-review high`가 잡은 3건 추가 발견·수정 + +사용자가 백그라운드로 `/code-review high`를 돌림 — 위 자기 감사에서 +놓친 진짜 문제 3건을 잡아냄(전부 "canBound 폐기"라는 5차 세션 당시의 +서술이 11차 세션 재도입 이후에도 안 고쳐진 곳들, 자기 감사가 +`git diff`로 이 세션이 **건드린** 파일만 훑어서 **안 건드린** 파일 속 +5차 세션 원본 서술은 놓쳤던 사각지대): + +1. `audit/gcconn-trick-verification.md`의 상단 배너(9-14행, 이 세션이 + 그 아래 섹션만 고치고 이 배너는 안 건드렸음)가 "canBound는 폐기됐고 + 게이트는 canExecute 하나"라고 여전히 서술 — 바로 몇 줄 아래(이번에 + 고친 섹션)와 정면 모순. 재정정. +2. `.claude/README.md`의 `lifecycle-pattern.md`/`question-resolved.md`/ + `canexecute-inst-arg-reversed.md`/`audit/` 인덱스 행 4곳 — 전부 이 + 세션 동안 한 번도 안 열어봤던 파일이라 5차 세션 서술("canBound + 폐기")이 그대로 남아있었음. 전부 정정. +3. `luau-test/STATUS.md`의 스파이크 `10` 재작성 가이드 — "이중 바인딩 + 게이트는 canBound가 아니라 canExecute로 쓸 것"이라고 옛 모델을 + **재작성 지침으로 명시**하고 있었음(가장 심각 — 이대로 따라 재작성하면 + 또 틀린 스파이크가 나옴). 재정정. +4. `question.md`의 "용어 정리" 절(canExecute 이름 논의)도 "canBound의 + 몫까지 canExecute가 겸함"이라는 5차 세션 전제 위에 서 있어서 같이 정정. + +**교훈**: `git diff`만으로 하는 자기 감사는 "이 세션이 건드린 파일"의 +내부 일관성만 보고, "이 세션이 안 건드렸지만 이 세션의 결정 때문에 +stale해진 파일"은 놓침 — 이번처럼 이름 하나(`canBound`)가 재도입되면 +그 이름을 인용하는 **모든** 파일을 grep으로 전수 스캔해야 하는데, 처음 +자기 감사 땐 "내가 고친 파일들"만 재확인하고 "canBound"라는 문자열 +자체로 전체 코퍼스를 훑지 않았음. `doc-check.py` ERROR 0 유지. diff --git a/CLAUDE.md b/CLAUDE.md index b96ba21..c0094f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,11 +55,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 세기로 함). - `.claude/luau-test/` — **[2026-08-09 신설]** "추론만으로 확정하고 실제 Luau로 부딪혀본 적 없는 것"을 미리 검증하는 독립 실행 스파이크 20개 - (`luau <파일>` / `luau-analyze <파일>`). **상태의 소스는 `STATUS.md`** - (pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행), 각 파일이 뭘 왜 - 검증하는지는 `README.md`. 2026-08-13에 첫 실측 완료 — 런타임 12개 전원 - 통과, 남은 건 Studio 전용 `10`과 코드가 깨진 `13`/`15`/`16` - (`review-required/`는 13차 세션에 비워짐). + (`luau <파일>` / `luau-analyze <파일>`). **상태의 소스는 항상 `STATUS.md`** + (pass / 사람 결정 필요 / 스파이크 깨짐 / 미실행, 폴더 구조 자체가 상태), + 각 파일이 뭘 왜 검증하는지는 `README.md`. 2026-08-13에 첫 실측 완료(당시 + 런타임 12개 전원 통과) — 이후 여러 세션에 걸쳐 재설계로 몇 건이 추가로 + `rewrite-required/`에 합류했으니 **지금 몇 개가 어디 있는지는 여기서 + 나열 안 하고 `STATUS.md`로 미룸**(나열하다 stale해지는 패턴이 실제로 + 반복됐음, 아래 "지금 할 일" 0번 참고). - `.claude/audit/` — **[2026-08-13 신설]** 스파이크를 실제로 돌린 **실측 결과** 기록(계획 아님). 부분 확인도 있는 그대로 남김 — `luau-test-first-run-2026-08-13.md`(첫 실측 라운드 전체, 구 0-Y의 1차 @@ -159,13 +161,20 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 ## 지금 할 일 (우선순위순) -0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-13 열네 번째 세션 기준).** +0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy - 핸들 계약)는 13차 세션에, **`0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치 - 하강 diff)는 14차 세션에 확정·`base/` 반영 완료**. 남은 `0-W`(같은 `Ref` - 이중 배치)는 M0가 아니라 M4 구현 세부만 막음 — **[2026-08-14 열 번째 - 세션] `0-B`(`dispose` 시그니처/범위)도 해소**되어 `question.md`의 - "결정 대기"엔 이제 `0-W` 하나만 남음. + 핸들 계약)는 13차 세션에, `0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치 + 하강 diff)는 14차 세션에, `0-B`(`dispose` 시그니처/범위)는 2026-08-14 + 열 번째 세션에 확정·`base/` 반영 완료. **`0-W`(같은 `Ref` 이중 배치, + M8 구현 세부만 막던 항목)도 2026-08-14 열한 번째 세션에 해소** — + 선택지 (a) 채택(즉시 error), 메커니즘은 새 `Relate` 없이 + `bindLifetime`/`unbindLifetime` 재사용(`base/ref-plan.md` "이중 배치 + 방지" 절). 부수 결정으로 **`canBound`가 `canExecute`와 별도 진입점으로 + 재도입**됨(2026-08-14 다섯 번째 세션에 하나로 합쳤던 걸 부분적으로 + 되짚음 — "이미 묶여 있는가"(bound 문맥)와 "지금 발화해도 되는가" + (execute 문맥)는 판정 로직은 공유해도 호출부의 질문이 다르다는 사용자 + 지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). + `question.md`의 "결정 대기" 절 자체가 지금은 비어 있음. **M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라 여전히 유효**: @@ -195,9 +204,14 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 아래 그대로: - **`.claude/luau-test/`(2026-08-09 신설, 2026-08-13 기준 20개) 스파이크 결과 — [2026-08-13 여섯 번째 세션에 첫 실측 완료, 대부분 닫힘].** - **상태의 소스는 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 필요 / - 스파이크 깨짐 / 미실행 4분류), 실행 결과 상세는 - `.claude/audit/luau-test-first-run-2026-08-13.md`. 요지만: + **상태의 소스는 항상 `.claude/luau-test/STATUS.md`**(pass / 사람 결정 + 필요 / 스파이크 깨짐 / 미실행, 폴더 구조 자체가 상태) — 몇 개가 지금 + 어느 폴더에 있는지는 여기서 나열 안 함(04/05/10/13/15/16/19가 여러 + 세션에 걸쳐 재설계로 `rewrite-required/`에 들고나며 이 문단의 나열이 + 매번 stale해지는 패턴이 반복됐음, 최근엔 8차 세션의 "emit은 항상 + 전파" 정정으로 `05`도 합류). 실행 결과 상세는 + `.claude/audit/luau-test-first-run-2026-08-13.md`. 첫 실측 요지만 + (역사적 사실 — 이후 변동은 위처럼 `STATUS.md`가 소스): - **런타임 12개 전원 통과**(01~07/11/17/18/19/20, crash 0 / FAIL 0) — 특히 `07`이 연쇄 GC를, `18`이 두-`Relate` 상호 순환 미해제를 실측 확정해 GC-native 아키텍처의 핵심 전제가 검증됨. `04`는 같은 세션 @@ -206,23 +220,8 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 해소**(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 미확인. - (2) `13`/`15`/`16`은 **스파이크 코드 자체가 깨져 재작성 필요** - (설계 문제 아님 — 각각 더미 스텁 도달불가 / 음성 대조군이 - `SyntaxError` / `types.*` 실제 API 불일치). (3) 그 외엔 그대로 M0 - 실제 코드 작성에 재사용. - - **[주의] 위 "남은 것은 셋뿐"은 여섯 번째 세션 기준** — 13차 세션에 - `08`이 `done/`으로 가며 `review-required/`가 비었고, **14차 세션에 - `04`와 `19`가 하강 diff 재설계로 전제가 바뀌어 `rewrite-required/`로 - 갔음**(`19`는 B 섹션만 낡음, A/C는 유효). - **개수의 소스는 항상 `luau-test/STATUS.md`.** 지금 M0 착수를 막는 - 설계 결정은 없고, 0-Y/0-A가 남긴 규약(`base/typing-limits.md`/ - `base/dispatch-core-plan.md`)은 착수 전 필독. - - 참고로 `04`(인덱스 기반 재설계 반영)와 `19`의 B/C 섹션(폐기된 - `rawNew`+`owners`/3분기 `claimOwner` 검증하던 것)은 **둘 다 여섯 - 번째 세션에 재작성 완료**되어 통과 상태 — 더 이상 대기 항목 아님. + 지금 M0 착수를 막는 설계 결정은 없고, 0-Y/0-A가 남긴 규약 + (`base/typing-limits.md`/`base/dispatch-core-plan.md`)은 착수 전 필독. 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는 @@ -1368,3 +1367,106 @@ Modifier 필드 금지 규칙과 혼동해 잘못 답했다가 사용자 지적 `base/dispatch-core-plan.md`/`base/architecture.md`/ `base/lifecycle-hooks-plan.md`/`ROADMAP.md`/`HUMAN_TODO.md`/`README.md` 전부 반영, `doc-check.py` ERROR 0 유지. + +**2026-08-14 열한 번째 세션 — 코퍼스 전체 감사(서브에이전트 6개 병렬), +`canBound`/`canExecute` 재분리로 `question.md` 0-W 해소** +(`session/2026-08-14-11-corpus-audit-canbound-resplit.md`) +사용자 요청으로 `.claude/` 전체를 `doc-check.py` + 6개 병렬 서브에이전트로 +감사 — stale 세션 번호(luau-test 파일들의 "canExecute 재정정"이 "세 번째" +vs "다섯 번째"로 갈라짐, archive 교차확인 결과 다섯 번째가 맞음), 잘못된 +인용(`comparison-charm.md`가 이미 확정된 `:Compute`의 `previous` 인자를 +존재하지 않는 문서 인용으로 "미결"이라 잘못 서술), 자기모순 배너(`tag-plan.md` +의사코드와 "패키지 배치" 절 우선순위 불일치, `additional-primitives-plan.md`의 +"새 질문 없음" 배너가 이후 추가된 질문 2개와 모순) 등 15개 파일의 실제 +사실 오류를 발견·수정. 감사 후 사용자가 `question.md` 0-W(`Ref` 이중 +배치 방지)를 선택지 (a)로 확정 — 메커니즘은 `RefLeafHandler`가 새 +`Relate` 없이 `bindLifetime`/`unbindLifetime`을 재사용(이미 내장된 +이중 바인딩 가드를 그대로 탐). 이 과정에서 그 가드 이름이 `canExecute`인 +게 개념적으로 안 맞다는 지적 — "이미 묶여 있는가"(bound 문맥, `bindLifetime`/ +`Observer:Subscribe()`가 묻는 것)와 "지금 발화해도 되는가"(execute 문맥, +State emit 전파 루프만 묻는 것)는 판정값은 같아도 다른 질문이라, 2026-08-14 +다섯 번째 세션에 "canBound 폐기, canExecute로 통합"했던 걸 부분적으로 +되짚어 `canBound`를 별도 진입점으로 재도입(판정 로직은 비공개 헬퍼 +`isBoundAlive` 하나로 계속 공유 — 코드 중복 없이 호출부 의미만 분리, +다섯 번째 세션이 고친 시그니처/오염 정정 자체는 안 바뀜). `base/ +lifecycle-pattern.md`(정본)/`base/ref-plan.md`/`archive/ +canexecute-inst-arg-reversed.md`(addendum)/`question.md`→`archive/ +question-resolved.md`/`ROADMAP.md`/`base/source-state-plan.md`/ +`base/effect-plan.md`/`base/architecture.md`/`base/dispatch-core-plan.md`/ +`research/pre-implementation-audit.md`/`luau-test/README.md`/`audit/ +gcconn-trick-verification.md`/CLAUDE.md 자신까지 전부 반영, +`doc-check.py` ERROR 0 유지. 새로 연 설계 질문 없음. + +**같은 세션 후속**: 손 트레이싱의 `Frame1 { Ref = r }` 표기가 실제 +API와 다르다는 사용자 지적(`Ref`는 항상 배열 리터럴이라 `k`는 숫자, +`"Ref"`라는 문자열 키가 아님) — `ref-plan.md`/`archive/question-resolved.md` +정정. 이어서 `PreRef`/`PostRef`/`Observer`/`Effect`가 non-number 키로 +들어오면 어떻게 할지 논의 — 사용자가 처음엔 "process에서 에러"를 +제안했다가 스스로 "가장 아래(FALLBACK)에 까는 게 맞다"고 정정, 넷 다 +`HANDLER_PRIORITY_FALLBACK`으로 등록하는 동적 경로 가드로 통일(하드 +블록이 아니라 `Tag`/`Attribute`처럼 나중에 평범한 우선순위 Handler로 +override 가능한 자리) — `ref-plan.md`/`source-state-plan.md`/ +`effect-plan.md`/`ROADMAP.md` 반영. 마지막으로 Tag/Attribute의 미주입 +백엔드 스텁 에러가 "미지원"과 "미등록"을 구분 안 하는 게 맞는지 확인 +질문 — 확정 원칙(스텁 입장에선 원천적으로 구별 불가) 재확인. 처음엔 +"FALLBACK보다 높은 우선순위 Handler로 덮어씌우기" 관례를 문서화했으나, +사용자가 곧바로 더 단순한 안으로 정정 — **op 등록 자체를 필수로 +바꿈**: 백엔드 팩토리는 `addTag`/`removeTag`/`setAttribute`를 항상 +명시적으로 채워야 하고(`nop`도 정당한 구현, 미지원이면 +`function() error(...) end`), base의 미주입 스텁에 기대는 정상 경로는 +없앰 — Dispatch Handler/우선순위 조정 불필요해서 훨씬 가볍고, 부수 +효과로 "provider 미주입"(어떤 팩토리도 안 돌았다는 좁은 경우) vs +"미지원"(팩토리가 명시적으로 에러 등록)이 자연히 갈라짐. +`dispatch-core-plan.md`/`module-lifecycle-plan.md` 반영. **바로 다시 +정정** — 사용자가 op 필수화만으론 부족함을 지적: `addTag` 에러는 +`TagHandler.process` 본문 안, 부기(`tagNameMap` 등)가 이미 일부 +mutate된 뒤에야 나서 원자적 실패가 아님(`Tag()`가 반쪽짜리 상태로 +남을 수 있음). 결론: op 필수화는 유지하되, 미지원 백엔드는 **추가로** +`HANDLER_PRIORITY_FALLBACK + 1` 우선순위의 순수 가로채기 Handler도 +등록해 `TagHandler.process`가 아예 안 불리게(부기 mutation 0회) 하는 +걸 권장으로 추가 — "매치된 Handler 하나만 실행"이라는 기존 Dispatch +규칙을 그대로 이용, 새 메커니즘 없음. 같은 두 문서 재반영. + +**세 번째 정정(틀림, 곧바로 재정정됨)** — 어시스턴트가 "TagHandler는 +quad-base가 자기 모듈 로드 시점에 스스로 등록 안 하고, 등록 여부는 +백엔드 팩토리의 (A)지원/(B)미지원 선택"이라는 모델을 제시했으나, 이는 +어시스턴트 자신의 오독이었음(사용자가 실제로 한 말이 아님). **네 번째, +최종 정정** — 사용자가 명확히 바로잡음: "HANDLER_PRIORITY_FALLBACK까지 +등록 안한다 했는데, 개소리 — FALLBACK은 아무 등록 없을 때 처리하려는 +걸 방어하는 것까지 포함하는 애임, 기본 등록은 필요함." **최종 모델**: +`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`는 quad-base가 +모듈 로드 시점에 `HANDLER_PRIORITY_FALLBACK`으로 **스스로 등록**(모든 +백엔드가 자동으로 부기를 얻음 — FALLBACK 밴드의 존재 이유 자체가 이 +"기본 안전 동작"을 공짜로 제공하는 것). `addTag`/`removeTag`/ +`setAttribute`만 백엔드 팩토리가 채우는 타입 계약이고, 안 채운 슬롯의 +base 기본값은 "그럴듯한 기본 동작을 추측"하지 않고 **명시적으로 +에러내는 스텁**(조용한 no-op은 provider 초기화를 잊은 실수를 가려버려 +기각). 더 명확한 메시지·진짜 원자적 실패를 원하는 백엔드만 **opt-in으로** +`HANDLER_PRIORITY_FALLBACK + 1`짜리 가로채기 Handler를 추가로 등록 — +필수가 아니라 선택적 업그레이드. `dispatch-core-plan.md`(정본, 다시 +전면 재작성)/`module-lifecycle-plan.md`/`tag-plan.md`/`attribute-plan.md`/ +`architecture.md` 재반영. **교훈**: 네 라운드 연속 정정이 있었던 이유는 +"누가 실제로 Dispatch.addHandler를 부르는가"라는 아키텍처 전제를 +확신 없이 매번 새로 단정하고 그 위에 patch를 쌓았기 때문 — 불확실한 +전제는 재확인 없이 단정하지 말 것. + +**같은 세션, 사용자 요청으로 전체 자기 감사**: 이 세션이 건드린 25개 +파일을 `git diff`로 처음부터 재정독 — 실제 문제 3건 발견·수정 +(`HUMAN_TODO.md`가 0-W 해소 이후에도 "남은 건 0-W"로 방치돼 있던 것, +`ref-plan.md`의 편집 중 생긴 어색한 줄바꿈, Tag/Attribute 등록 모델 +재정리 중 `tag-plan.md`/`attribute-plan.md`에서 실수로 빠뜨린 "백엔드가 +알고리즘 전체를 교체하고 싶으면 그냥 더 높은 우선순위로 등록" 문장 +복원). 나머지는 문제 없음 확인, `doc-check.py` ERROR 0 유지. + +**같은 세션, `/code-review high` 백그라운드 실행이 추가로 3건 발견**: +`git diff` 기반 자기 감사가 "이 세션이 건드린 파일"만 훑어서, "이 +세션이 안 건드렸지만 `canBound` 재도입 때문에 stale해진 파일"을 +놓쳤음 — `audit/gcconn-trick-verification.md` 상단 배너(고친 섹션 +바로 위인데 안 건드림, "canBound 폐기" 서술이 몇 줄 아래 새 서술과 +모순), `.claude/README.md` 인덱스 행 4곳(이 세션 동안 한 번도 안 열어본 +파일이라 5차 세션 서술 그대로 잔존), `luau-test/STATUS.md`의 스파이크 +`10` 재작성 가이드(가장 심각 — "이중 바인딩 게이트는 canExecute로 쓸 +것"이라고 옛 모델을 재작성 지침으로 명시 중이었음), `question.md` +"용어 정리" 절. 전부 정정. **교훈**: 이름 하나가 재도입되면 그 이름을 +문자열로 전체 코퍼스에 grep해야지, "내가 고친 파일들"만 재확인하는 +건 안 충분함. diff --git a/HUMAN_TODO.md b/HUMAN_TODO.md index 2f536b0..664ec96 100644 --- a/HUMAN_TODO.md +++ b/HUMAN_TODO.md @@ -148,10 +148,10 @@ git branch -D worktree-debounce-throttle-plan 디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로 진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이 -마음에 안 드는 것만 답해주면 됨 — **[2026-08-13 열네 번째 세션 기준] -M0 착수를 막는 항목은 하나도 없음**(남은 건 0-W `Ref` 이중 배치, M4 구현 -세부만 막음 — 0-B `dispose` 시그니처/범위는 2026-08-14 열 번째 세션에 -해소돼 `archive/question-resolved.md`로 이전됨). +마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준] +`question.md`의 "결정 대기" 절 자체가 비어 있음**(마지막 남았던 0-W +`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지" +절, `archive/question-resolved.md`로 이전됨). --- Sources (MCP 리서치): [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server), [How to Connect Claude Code to Roblox Studio — Clauder Navi](https://www.clauder-navi.com/en/claude-roblox-studio) diff --git a/ROADMAP.md b/ROADMAP.md index 1bf2b03..347953e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,12 +147,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 대체(2026-08-08 세션 신설). - [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ - `unbindLifetime(value)`/`canExecute(value)` 탑레벨 함수 - 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 - 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 - 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 - 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` - 2번 — 2026-08-07 네 번째 세션에 반영). + `unbindLifetime(value)`/`canBound(value)`/`canExecute(value)` 탑레벨 + 함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 + M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 + `canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 + 순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9, + `question.md` 2번 — 2026-08-07 네 번째 세션에 반영). **[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는 `inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음. `bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽 @@ -167,13 +167,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `inst` 전체 죽기 전에 특정 값 하나만 조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를 정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화. - `bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스 - 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, - `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ - lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime` - — 확정" 절 참고. **이중 바인딩 금지 게이트도 `canExecute` 하나로 - 통합**(별도 `canBound`는 폐기 — M3 체크박스 참고), children 배열 leaf - 부착이 실제로는 `bindLifetime` 호출이라 이 게이트를 그대로 탐 + `bindLifetime`/`unbindLifetime`/`canBound`/`canExecute` 넷 다 + 네임스페이스 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 + 네임싱과 구분, `isState`/`isObserver`와 같은 1급 프리미티브 취급) — + `base/lifecycle-pattern.md`의 "`bindLifetime`/`canBound`/ + `canExecute`/`unbindLifetime` — 확정" 절 참고. **이중 바인딩 금지 + 게이트는 `canBound`**(`canExecute`는 emit 전파 게이팅 전용 — + **[2026-08-14 열한 번째 세션] `canBound`가 별도 진입점으로 재도입되어 + 다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스 + 참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라 + 이 게이트를 그대로 탐 - [ ] `Dispatch.setLength(inst,i,len:number|State)`/ `Dispatch.setOffsetSource(inst,i,offset:Source|None)` — array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브 @@ -271,30 +274,39 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 부르는 순수 설탕, `factory: (State) -> U): U`로 열린 타입. Source도 기존 `:With`/`:Compute` 델리게이션에 얹혀 자동 포함 - [ ] `state:Observer(fn)` — children 배열 leaf 참가자, **등록 즉시 1회 - 실행 확정**(`base/bind-system-plan.md`의 Observer 절), `isObserver` - 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()` + 실행 확정**(`base/source-state-plan.md`의 Observer 절), `isObserver` + 판별자, canExecute 게이팅, `:Subscribe()`/`:Unsubscribe()`. **동적 + 경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v + is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14 + 열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록 - [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치 1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer 구현 이후에 착수(의존 관계). `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 - 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) -- [ ] Observer/Effect 이중 바인딩 금지 — `canExecute(value)` 게이트로 + 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션). + **동적 경로 가드**도 Observer와 같은 패턴으로 등록(`base/effect-plan.md` + "동적 경로 가드" 절, 2026-08-14 열한 번째 세션) +- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(value)` 게이트로 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/source-state-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 2026-08-09 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 독립 경로는 둘뿐). - **[역전, 2026-08-14 다섯 번째 세션] 별도 predicate `canBound(handle)` - (2026-08-09 세션에 이름 확정됐던 것)은 폐기** — "이미 유효하게 묶여 - 있다"와 "지금 실행 가능하다"가 정확히 같은 조건이라 `canExecute` - 하나로 통합됨. `canBound`의 내부 근거로 지목돼 있던 `.Subscribed` - 필드는 애초에 leaf 경로와 무관했고(전역 `:Subscribe()` 전용), - leaf 생존 판정은 `bindLifetime`이 `value` 쪽 `Relate`에 복사해둔 - gcconn으로 함 — `base/lifecycle-pattern.md`의 "`canBound` 폐기" 절, - 역전 경위는 `archive/canexecute-inst-arg-reversed.md`. 부수 효과로 - **바인딩이 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 - 통과**(살아있는 바인딩만 막는 게 의도) + **[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`을 + 폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에 + 다시 갈라짐]** — "이미 유효하게 묶여 있다"(bound 문맥)와 "지금 + 발화해도 되는가"(execute 문맥)는 판정값은 같아도 호출부의 질문이 + 달라, `Ref` 이중 배치 방지(`question.md` 0-W)를 계기로 `canBound`가 + 별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은 + 공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`** + (emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가 + leaf 경로와 무관하다는 것, leaf 생존 판정을 `bindLifetime`이 `value` + 쪽 `Relate`에 복사해둔 gcconn으로 하는 것은 안 바뀜 — `base/ + lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, 역전 경위는 + `archive/canexecute-inst-arg-reversed.md`. 부수 효과로 **바인딩이 + 죽은 뒤(`Destroy`/`unbindLifetime`)의 재사용은 게이트를 통과** + (살아있는 바인딩만 막는 게 의도, 안 바뀜) - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 @@ -588,6 +600,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `(v=Ref)` 매치 핸들러 — children 배열의 숫자 슬롯에 놓인 `Ref(default)` 인스턴스를 인식해 바인드(별도 `CreatedRef` 래퍼 없음 — 이름 자체가 폐기됨, 아래 참고) +- [ ] **이중 배치 방지**(`question.md` 0-W, 2026-08-14 열한 번째 세션 + 해소) — `RefLeafHandler.process`가 실제 바인딩 분기에서 + `bindLifetime(inst, v)`를, 실제 언바인딩 분기에서 `unbindLifetime(v)`를 + 호출. 새 `Relate` 불필요 — `bindLifetime`이 이미 내장한 `canBound` + 이중 바인딩 가드를 재사용하는 것뿐(같은 `Ref`가 이미 다른 자리에 + 살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치 + 방지" 절 - [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이 `Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프 전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고 @@ -612,12 +631,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 완성은 보장하되 **이 인스턴스가 부모에 붙는 것보다는 먼저**임 — `base/ref-plan.md` "`PostRef`" 절 - [ ] `PostRef` 동적 경로 가드 Handler — `PreRef`의 것과 완전한 거울상 - (`{isHandlable = v is PostRef, process = error(...)}`), 같은 절 참고 -- [ ] `PreRef` 동적 경로 가드 Handler — `{isHandlable = v is PreRef, - process = error(...)}` 형태로 정상 우선순위 레지스트리에 등록, - `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 경로는 - pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 차단을 - 우회한 버그라는 뜻 — 같은 절 참고 + (`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v is PostRef, + process = error(...)}`), 같은 절 참고 +- [ ] `PreRef` 동적 경로 가드 Handler — `{priority = + HANDLER_PRIORITY_FALLBACK, isHandlable = v is PreRef, process = + error(...)}` 형태로 정상 우선순위 레지스트리에 등록(`k` 타입 안 + 가림), `NoneHandler`와 같은 "한 값 종류 전담" 패턴. 리터럴 배열 + 경로는 pre-pass가 이미 소진시키므로 이 Handler가 매치되면 곧 타입 + 차단을 우회한 버그라는 뜻 — 같은 절 참고. **[2026-08-14 열한 번째 + 세션]** 우선순위가 하드 블록이 아니라 `FALLBACK`인 이유(나중에 named + 자리 바인드가 확정되면 평범한 우선순위 Handler로 덮어쓸 수 있게 — + `Tag`/`Attribute`와 같은 이유)와 `Observer`/`EffectHandle`에도 같은 + 패턴의 가드가 추가됨은 `base/source-state-plan.md`/`base/effect-plan.md`의 + "동적 경로 가드" 절 참고 - [ ] **[2026-08-14 두 번째 세션 신설]** `ProcessedPreRefHandler` + **[아홉 번째 세션] `ProcessedPostRefHandler`**(완전한 거울상, 코드 한 글자 차이) — `{isHandlable = v == Processed*Ref, process = @@ -635,20 +661,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `coroutine.running()` 캡처+yield, 있으면 등록만 하고 즉시 `self` 반환(남의 thread를 여기서 대신 정지시킬 수 없어서) - [ ] `LifetimeHandle` quad-roblox 실제 구현 — `bindLifetime`/ - `unbindLifetime`/`canExecute` 본체(인터페이스 자체는 M2로 이동됨, - `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 없음). + `unbindLifetime`/`canBound`/`canExecute` 본체(인터페이스 자체는 + M2로 이동됨, `Relate` 자체는 quad-base라 quad-roblox 쪽 재구현 + 없음). **[2026-08-14 다섯 번째 세션 정정] gcconn/gchold를 여기서 lazy 생성하지 않는다** — 생성은 M5의 Instance 생성 경로가 이미 끝내둔 것이고, 이 함수들은 `InstData`에서 찾아 쓰기만 함. `bindLifetime`은 `gchold[value]=true`(강참조로 생존 보장)와 `BindData:SetWeak(value, "gchold"/"gcconn", ...)`(값이 자기 생존 판정 근거를 직접 들고 있게) - 둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림, `canExecute(value)`은 - 복사된 gcconn의 `.Connected` 또는 `.Subscribed`를 봄. + 둘만 하고, `unbindLifetime(value)`은 그 셋을 되돌림. **[2026-08-14 + 열한 번째 세션] `canBound(value)`/`canExecute(value)`는 비공개 + 헬퍼 `isBoundAlive(value)` 하나(복사된 gcconn의 `.Connected` 또는 + `.Subscribed`를 봄)를 공유하는 얇은 진입점 둘로 분리** — `bindLifetime`/ + `Observer:Subscribe()`의 이중 바인딩 가드는 `canBound`, State emit + 전파 루프만 `canExecute`. **저장은 전부 `SetWeak`**(`SetStrong` 아님 — gchold/gcconn은 아래 M5 클로저↔`gchold[1]` 상호 참조로 이미 안전하게 살아있고, "다른 곳에서 안전하게 유지되는 것은 항상 weak로 잡는다"가 일반 규칙). - `base/lifecycle-pattern.md`의 "`bindLifetime` / `unbindLifetime` / - `canExecute`" 절 + `base/lifecycle-pattern.md`의 "`bindLifetime` / `canBound` / + `canExecute` / `unbindLifetime`" 절 ## M9 — 컴포넌트 합성 레이어