diff --git a/.claude/README.md b/.claude/README.md index e8a29b9..b53ed88 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -24,13 +24,13 @@ | `base/` | 결정 완료 + 프로젝트 전체에 걸치는 컨텍스트 — plan/done 개념 없음, 계속 참조되는 배경지식. **항상 읽어야 하는** 배경지식만 여기 둠(다른 문서를 이해하는 데 전제되는 것) | | `reference/` | **[2026-08-07 신설]** 결정 자체가 아니라 다른 문서가 근거로 인용하는 온디맨드 참고 자료(v1 스냅샷, 프레임워크 비교 리서치) — "완료" 개념 없는 건 `base/`와 같지만, 항상 읽을 필요는 없고 해당 문서가 인용될 때만 열어보면 됨. `quadnomicon` 소재 후보가 많음 | | `research/` | 아직 착수 전, 사용자와 스코프/설계를 더 상의해야 함 | -| `qa-request/` | 구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음 — 지금은 구현 자체가 시작 전이라 비어있음 | +| `qa-request/` | 원래 용도는 "구현 완료(코드/에이전트 검증까지 끝남) + 사용자 본인의 실기기(Roblox Studio) QA만 남음". **[2026-08-18 확장]** 구현 전에도 **사용자 심사 라운드의 산출물**을 여기 둠 — `pre-implementation-qa-round1.md`(1라운드: `base/` 확정 문서 전체를 문항으로 재확인받아 **"아니오"가 나온 항목만** 모은 결함 목록 + 신규 요구사항(`N-n`) + 부수 오탈자. **같은 날 전부 `base/`에 반영 완료**라 지금은 "무엇이 왜 틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스. 아직 안 닫힌 것은 `question.md` 3번과 `.claude/todos.md` 00번이 소스), `pre-implementation-qa-round2.md`(2라운드: 확정 의사코드를 실제로 손으로 실행해보는 트레이싱 — **완료**, 발견된 크래시 `RC-1`(`recompute` 트리거 모델)도 같은 날 후속 세션에서 Blocker 게이팅 설계로 해결·반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리), `pre-implementation-qa-round3.md`(3라운드: `RC-1` 해법(Blocker 게이팅)이 실제로 `attachSlot`/`recompute`에 반영된 걸 손으로 트레이싱 — **완료**, `RC-3`/`RC-4`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행되는 순서 문제)와 `bk.N` 수명주기 미정을 발견했다가 같은 세션에 사용자가 최초 분석 오류를 직접 정정하며 전부 해결·`base/` 반영까지 끝남, `archive/question-resolved.md`에 논의 요지 정리. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 의존하게 된 마일스톤 순서 불일치는 각주로 반영, 마일스톤 재편 여부는 열려 있음). 다음 라운드가 필요해지면 라운드마다 파일을 새로 만들고 이름에 라운드 번호를 넣을 것 | | `archive/` | 완료 + 사용자가 실사용/실기기로 직접 검증까지 마침 (구현 대상). **[2026-08-06 확장]** 완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용(제목 `[역전됨]` — 한 번 확정했다가 뒤집힌 것) — 더 이상 능동적으로 참고 안 해도 되지만(토큰 낭비 방지 위해 `base/`/`research/`에서 뺌) `quadnomicon` 소재로는 나중에 쓸 수 있음. **[2026-08-07 확장]** 후보였다가 채택 안 된 것(확정한 적 없이 검토 후 기각)도 같은 방식으로 보존, 제목은 구분을 위해 `[기각됨]` — `[역전됨]`과 의미가 다르므로 혼동하지 말 것. **[2026-08-07 세 번째 확장]** 설계 반전/기각과 별개로, 에이전트가 문서 작성 중 스스로 낸 개념 혼동을 정정한 이력은 `[에이전트 실수]` 태그로 `agent-mistake.md` 하나에 모음(`.claude/session-summary.md`/`session/` 로그와의 중복 방지) | -| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 빈 폴더로 존재하지만 여긴 그것도 아직 아님 | +| `feedback/` | 실사용 피드백을 정리한 긴 로그 — **[2026-08-16 기준] 폴더 자체가 아직 없음**(구현 시작 전, 첫 피드백이 생길 때 만들면 됨). `qa-request/`는 **[2026-08-18] 더 이상 비어 있지 않음**(구현 전 QA 1라운드 산출물이 들어감) — 여긴 아직 폴더도 없음 | | `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` 캐비엇을 지우고 이 문서는 근거로 남김. **개수는 폴더가 소스**(여기서 세지 않음): `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/`(개수는 폴더가 소스). 다른 audit 기록과 달리 **스크립트를 같이 둠**: 이 건의 근거가 "여러 formulation을 서로 대조한 것"이라 개별 파일을 직접 돌려야 판정이 재현되기 때문. 결론은 `base/typing-limits.md`로 승격됨), `fallback-xpcall-verification.md`(**[2026-08-14 신설]** `base/fallback-plan.md`의 `Traceback` 메커니즘 전부 확인 — 클로저 업밸류 배선/중첩 스택 캡처/`err: any`/`error(msg)` 위치 접두 10개 검증 전부 통과. 스크립트 1개뿐이라 재현용으로 같이 둠: `fallback-xpcall-spike.luau`), **`type-recursive-issue-with-typeof/`**(**[2026-08-15 신설]** 사용자가 발견한 `typeof(named fn)` 간접참조가 0-Y(재귀 제네릭 반환 leak)를 실제로 우회하는지 실측 — `REPORT.md` + `spikes/`. 결론: 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 LHS 명시 없이도 다운스트림이 안전해짐(체이닝 50단·타입 변경·중첩 self 호출까지 확인), `typing-limits.md` §1 ③으로 승격. 부수적으로 `setmetatable` 확장 시도에서 quad와 무관한 Luau 0.733 솔버 버그(모순 진단 두 개 동시 발생) 발견, 채택 안 함. `luau-test/16`(type function으로 `Store` 레코드 필드 합성) 복구도 이 조사 중 완료 — API 버전 드리프트였을 뿐 설계 문제 아니었음, `typing-limits.md` §5 승격), **`type-recursive-issue-try-callback/`**(**[2026-08-15 신설]** 콜백 파라미터 무주석 추론을 뚫을 방법이 정말 없는지 type function/메타테이블/오버로드/제네릭 디폴트 등 전방위로 재시도 — `REPORT.md` + `spikes/`(개수는 폴더가 소스 — 최초 라운드 + `/code-review high`가 이중 꺾쇠 명시적 제네릭 인스턴스화를 안 시도했음을 지적해 추가된 후속 조사 라운드로 구성). 결론: quad의 `state:Compute(fn)` 단일 호출 모양을 유지한 채로는 여전히 안 됨. 발견 셋 — (1) 근본 원인이 재귀 자기참조가 아니라 "제네릭 콜백 인자 전반에 컨텍스트 타입 전파가 안 됨"이라는 게 더 정확함(재귀 없는 최소 사례로도 재현), (2) `T`를 명시 중간 변수로 먼저 고정하거나 재사용 가능한 monomorphize 헬퍼를 거치면 실제로 추론이 살아나지만 둘 다 단일 콜론 호출을 2단계 체인으로 바꿔야만 해서 §0 대전제로 채택 안 함, (3) 이중 꺾쇠 명시 인스턴스화(`Compute<>(fn)`)는 leaf 호출에선 sound하게 성립하지만(spurious 진단 원인도 규명 — read-only/read-write 가변성 불일치) 매 호출 T/U 전부 명시 필요 + 중첩 self 호출 여전히 실패라 순손해로 채택 안 함) | | `tools/` | **[2026-08-13 신설, `session/2026-08-13-09-structure-and-guardrails.md`]** 코퍼스 기계 점검 — `doc-check.py`가 깨진 파일/절 참조, README 색인 누락, 날짜 없는 시한부 주장("아직 안 돌려봄" 등), 미반영 ⚠️ 배너를 한 번에 훑음. **[2026-08-16]** 절 참조는 WARN이 아니라 **ERROR** — 판정 규칙은 `conventions.md`의 "절 인용 규약"이 소스. **중대 변경 후 커밋 전에 돌릴 것**(`python3 .claude/tools/doc-check.py`) — 수동 감사에서 나온 발견의 대부분이 이 종류였고, 실제로 문서를 쪼개다 잘못 옮긴 참조를 이게 잡아냄. ERROR는 고치고 WARN은 판단 대상 | -| `agents/` | **[2026-08-16 신설]** 프로젝트 서브에이전트 정의(`.claude/agents/*.md`, Claude Code 표준 위치). 현재 `quad-doc-auditor.md` 하나 — `doc-check.py`가 못 잡는 의미론적 stale/모순(본문 문장이 뒤집힌 결정을 여전히 서술, 개수/목록 이중 소스 드리프트 등)을 신선한 맥락에서 찾는 읽기 전용 감사자. 중대 변경 커밋 전에 위임하는 게 기본 — **여러 개를 병렬로 돌리고 수정은 메인 세션이 일괄로 하는 루프**(패스 수는 여기 안 적음 — 소스는 conventions.md)이며 절차는 `.claude/conventions.md` "작업 방식" 절이 소스(**[2026-08-16]** 이 루프를 담던 `workflows/quad-handover-audit.js`는 토큰 과다·픽스 에이전트발 부정확 서술·사용자 질의 불가 때문에 폐기됨). `tools/`의 기계 점검과 짝을 이루는 의미론적 점검 계층 | +| `agents/` | **[2026-08-16 신설]** 프로젝트 서브에이전트 정의(`.claude/agents/*.md`, Claude Code 표준 위치). 현재 `quad-doc-auditor.md` 하나 — `doc-check.py`가 못 잡는 의미론적 stale/모순(본문 문장이 뒤집힌 결정을 여전히 서술, 개수/목록 이중 소스 드리프트 등)을 신선한 맥락에서 찾는 읽기 전용 감사자. 중대 변경 커밋 전에 위임하는 게 기본 — **[2026-08-18 재설계] 한 턴에 하나씩만 돌리고(병렬 금지) 발견이 0건인 라운드가 나올 때까지 턴을 늘리는 루프**, 수정은 메인 세션이 일괄로 함(라운드 수·범위 좁히기 규칙은 여기 안 적음 — 소스는 conventions.md)이며 절차는 `.claude/conventions.md` "작업 방식" 절이 소스(**[2026-08-16]** 이 루프를 담던 `workflows/quad-handover-audit.js`는 토큰 과다·픽스 에이전트발 부정확 서술·사용자 질의 불가 때문에 폐기됨). `tools/`의 기계 점검과 짝을 이루는 의미론적 점검 계층 | | `agent-memory/` | **[2026-08-16 신설]** 서브에이전트가 라운드를 넘겨 유지하는 영속 메모리(`agent-memory/<에이전트 이름>/MEMORY.md`가 색인). 지금은 `quad-doc-auditor/` 하나 — 코퍼스 구조, 반복되는 실패 패턴 등을 기억해 감사 라운드마다 처음부터 파악하지 않게 함. **사람이 손으로 채우는 문서가 아니라 에이전트가 스스로 쓰는 것**이지만, `.gitignore` 대상이 아니라 커밋하면 코퍼스 일부가 되고 `doc-check.py` 검사 대상에도 들어감(감사 대상이기도 하다는 뜻 — 여기 적힌 주장도 stale해질 수 있음). **[2026-08-16 확정] 커밋해서 추적함**(사용자 결정). 사용자 논거: "실 기록이고 디펜던시도 아니고, 어차피 `SAFETY.md`에 따라 구현 시점에는 컨테이너에서 개발되며 다른 프라이빗 git에 올라가고 검토 후 머징되는거라, 문제되는 메모리 있으면(환경 노출 등) 사람이 감사처리 마지막으로 함. 결국 프로젝트 사이드 기록이고 같이 올려지는게 맞는게, 개발 환경이 다수라서 필요해보임" — 즉 **개발 환경이 여러 개라 메모리가 따라다녀야 하고**, 노출 위험은 머지 전 사람 검토가 최종 방어선. 커밋하는 쪽이 정해졌으니 여기 내용도 감사 대상이다(에이전트가 자기 메모리에 stale한 결론을 남기는 일이 실제로 있었음 — 2026-08-16에 폐기된 워크플로를 살아있는 것처럼 서술한 2건이 감사로 잡힘) | | `session/` | **[2026-08-11 신설]** 세션별 상세 로그 원문(시행착오·정정 전 서술 포함, `quadnomicon` 개발로그 소재용) — 루트 `CLAUDE.md`가 3196줄까지 불어나 성능 저하를 유발해서 분리함. 파일명 `YYYY-MM-DD-NN-slug.md`, `.claude/session-summary.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`) — 읽기 전용 리서치 소스, 여기 내용을 옮기지 말고 항상 원본 그대로 유지 | @@ -45,26 +45,26 @@ |---|---| | `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, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction`이 `Promise.andThen`으로 예시 든 바로 그 패턴이라 **지금 선언 그대로 두면 Luau 쪽 수정만으로 코드 변경 없이 풀림**(추적: `luau-lang/luau#2380`). **[2026-08-15 추가]** ③ 인라인 대신 이름 붙은 함수 + `typeof`로 선언하면 콜백 파라미터 주석은 여전히 필요하지만 LHS 명시 없이도 다운스트림이 안전해짐(①을 대체하지 않음, 보강). 그 외 Modifier `Overridden` 서브타입/Attribute 제네릭 키 narrowing/nilable default 오버로드도 여기 통합, `store.key` type function 한계는 **검증 완료로 승격**(§5), 7번에 **새 타입·API 설계 시 체크리스트**. 실측 근거는 `audit/type-recursion-issue/` + `audit/type-recursive-issue-with-typeof/` | -| `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()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요) | -| `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`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화) | -| `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md` | -| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외 | -| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) | +| `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` 하나를 공유). **[2026-08-18 구현 전 QA 반영]** **두 predicate는 값이 같은 게 아니라 서로의 부정**(`canBound` 참 = "지금 묶어도 됨")이라 게이트가 전부 `if not canBound(v) then error(...)`로 정정됨 — 옛 서술대로 짰으면 정상 첫 바인드가 전부 에러났음. gcconn/gchold 저장도 `SetStrong`→**`SetWeak`** 정정 | +| `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급 경로(**[2026-08-18] `store "key"` 문자열 커링은 기각** — 동적 키는 `store:GetDynamic<>(name)`), 레코드 필드 타이핑은 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()`, **이중 바인딩 금지 게이트**(`canBound`, State emit 전파 게이팅은 `canExecute` — `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절이 소스), PA님 코드 교차검증. **[2026-08-14 열두 번째 세션]** 새 절 "Observer/Effect Leaf dedup" — `RefLeafHandler`와 같은 `old ~= v` dedup(성능 최적화, correctness엔 불필요). **[2026-08-18 구현 전 QA 반영]** `canBound` 방향 정정, `:Compute` 콜백 표기 정정(`fn(self, previous?, ...deps)`), FALLBACK 가드 에러에 `k` 타입 싣기, 그리고 **⚠️ 미해결로 신설된 "중간 State가 살아남는가"**(상류 strong/하류 weak 불변식 — M3 착수 전 결론 필요) | +| `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`. **[2026-08-14 열두 번째 세션]** Observer/Effect Leaf도 `Ref`와 같은 identical-value dedup 채택(성능 최적화). **[2026-08-18 구현 전 QA 반영]** **`Dispatch.drive`의 `None` 스킵 분기 폐기**(반응형 값이 내놓는 `None`은 어차피 `process`에 도착) → `NoneHandler`는 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, `setLength(0)`/`setOffsetSource(None)` 등록 담당). Length/Offset 등록 책임도 "처음 매치한 Handler"→**말단 Handler**로 정정. base 소유 Fallback Handler **등록 주체는 백엔드 팩토리→quad-base 자신으로 재역전**. "방어 가드는 죽은 코드" 서술에 한정 추가(한 핸들러가 여러 값 모양을 받으면 판별은 그 핸들러 몫), `PreRef`가 "배열 먼저" 보장 위에 성립한다는 근거 정정(별도 pre-pass라 독립), `Quad.debug` 게이팅. **[2026-08-18 구현 전 QA 2라운드 후속]** "Length/Offset" 절에 크래시하던 `recompute` 트리거 모델(`RC-1`)을 owner별 `Blocker` 게이팅으로 고친 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절 신설 — `setLength`/`setOffsetSource` 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈. **[2026-08-18 구현 전 QA 3라운드]** "저장 위치" 절에 `bk.N`(recompute 순회 상한) 수명주기 신설(그때그때 실제 개수, `inst`/Slot 두 owner 타입 동일 규칙 — `setLength`가 갱신, `setOffsetSource`는 안 건드림) — 부수로 `RC-1`의 원래 크래시 서술도 정정("N이 배치 전에 고정"이라는 옛 전제의 부산물이었을 뿐, 지금 Blocker 게이팅이 필요한 이유는 크래시 방지가 아니라 비용) | +| `bind-system-plan.md` | **[2026-08-14, 3단계 분할로 203줄까지 축소 — 지금은 "인스턴스 생성/이벤트 네이밍 인체공학 + 분할 색인" 문서]** 반응형 코어는 `source-state-plan.md`, Store는 `store-plan.md`, 디스패치 코어는 `dispatch-core-plan.md`로 나갔음. 아래 이력은 분할 전 이 파일이 담고 있던 결정들의 기록(현행 소스는 각 분할 문서). pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 **[2026-08-13 열네 번째 세션] 2단계 분할 + 모델 교체 — 디스패치 코어 전체가 `dispatch-core-plan.md`로 나갔고(이 문서엔 반응형 코어와 인체공학만 남음), 나가면서 **하강 diff**로 재작성됨. 따라서 위 5차 세션 서술 중 "`Dispatch.process`가 인덱스 **점유 여부**를 먼저 체크"와 "`retractFrom(inst,k,index,v)` **4-인자**"는 **더 이상 현행이 아님**(점유 체크 폐지 → 핸들러 비교, 힌트 인자 소멸 → 3-인자) — 현행은 `dispatch-core-plan.md`. **[2026-08-18 구현 전 QA 반영]** 남아 있던 인체공학 절이 크게 갱신됨 — 네임스페이스 **`DI`→`D`(Declarative) 확정**(코퍼스 전수 반영, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화), **`New`는 커링**(`New "Frame" {...}`)이고 **`D`는 전량 코드 생성된 순수 별칭 테이블**(생성 범위는 "GUI에 쓰이는 모든 인스턴스", 밖은 `any`), 그리고 **"이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 옛 전제가 거짓**임이 사용자 반례로 확인돼 "생성기가 이벤트 필드의 콜백 타입까지 만든다"로 바뀜 | +| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정. **[2026-08-18 구현 전 QA 반영]** 모듈 표면에 **`Quad.debug`(기본 `false`)** 신설(지금은 핸들러 우선순위 동률 경고를 게이팅), base 소유 Fallback Handler 등록 주체가 quad-base 자신이라는 **명시적 예외** 반영. **[2026-08-19 신설]** "New()의 내부 구성" 절 — `InitXxx(module)` 팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 Init 가드(순서 의존성 해소) | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/dispatch-core-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 제거→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록. **[2026-08-13 세션, 여섯 번째 — 전면 역전]** `State` 교체가 **파괴에서 언마운트로 뒤집힘**(`state`와 동일 — "이전 값을 지울지는 그 값을 만든 쪽이 정한다"는 `Ref`/`Attribute`와 같은 철학) — 이에 따라 (a) 비파괴 짝 `rawUnmount`/`unmountSlotTree` 신설(`rawRemove`/`destroySlotTree`와 딱 하나만 다름: 안 죽임)되고 `reconcile`이 직접 부르는 게 `rawAdd`/`rawUnmount`/`rawMove`로 바뀜, (b) **오래 "오버엔지니어링"으로 기각돼 있던 포탈이 별도 기능이 아니라 이 결정의 자연스러운 귀결이 됨**(옛 "폐기, 옮기지 않음" 결정은 역전, `archive/slot-discard-no-portal-reversed.md`), (c) 명시적 파괴 수단으로 base 탑레벨 `dispose(value)` 신설 — 아직 트리가 살아있길 요구하는 값이면 파괴를 **거부하고 error** **[2026-08-13 열네 번째 세션]** 하강 diff 반영 — `SlotHandler`의 클로저가 받는 값이 항상 `Slot`이거나 `nil`임이 계약으로 보장되고, 언마운트 경로의 `setOffsetSource(None)`/`setLength(0)` 순서는 그대로. **[2026-08-14 열 번째 세션]** `dispose`의 시그니처/범위(`question.md` 0-B) 확정 — `dispose(value: Slot | Instance)`, `isSlot`이 아니면 백엔드 주입 op `disposeInst(inst)`로 위임(`addTag`/`removeTag`/`setAttribute`와 같은 패턴), `Observer`/`Effect`는 GC-native lifecycle만으로 충분해 범위에서 명시적으로 제외. **[2026-08-18 구현 전 QA 반영]** **`:List` reconcile의 `nil` 리턴은 다시 파괴가 기본**(값 교체와 새 `PopOnly`(가칭)만 비파괴 — 2026-08-13의 "전부 비파괴" 일반화가 `:List`엔 안 맞았음), `dispose` 절에 `SetAndDispose` 백로그 후보 추가. **[2026-08-18 구현 전 QA 2라운드 후속]** "재귀 메커니즘" 절의 `attachSlot`이 자기 flush 루프를 자기 자신의 `Blocker`로 감싸도록 재작성돼 `RC-1` 해결(부모와 별도 Blocker, 런타임 단건 `Add`는 게이팅 불필요). **[2026-08-18 구현 전 QA 3라운드]** `attachSlot`이 `slot._mounted = true`를 `activateList` 호출 뒤로 미루도록 재정렬 — `:List` 최초 population이 무게이팅 recompute를 태우던 것(`RC-3`)과 nested Slot이 이중 `attachSlot`되던 것(`RC-4`) 둘 다 해결. `spliceArraysDown`이 밀어야 할 배열에 `bk.observers`/`bk.N` 갱신도 명문화 | +| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유). **[2026-08-18 구현 전 QA 반영]** 고정 메소드(=Modifier 필드 이름 예약)는 `Apply` 하나가 아니라 **`Apply`/`Peek`/`Overridden` 셋**(M7 타입 생성 스크립트 제외 목록에 반영 필요), `Overridden`은 닷/콜론 둘 다 가능 | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | -| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 | -| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | -| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강 | +| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정. **[2026-08-18 구현 전 QA 2라운드 후속]** `IsOn()`/`OffWithoutEmit()` 신설(`RC-1` 해결 과정에서 나옴) — `state:Block()` 없이 Blocker를 직접 쓰는 두 번째 용례(base 내부 Length/Offset 배치 게이팅)도 추가. **[2026-08-18 구현 전 QA 3라운드]** 이 용례의 존재 이유 정정 — `RC-1`의 원래 크래시는 사라졌고(`bk.N` 수명주기 재정의로), 지금 필요한 이유는 배치 등록 비용(O(N²)→O(N)) | +| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료. **[2026-08-18 구현 전 QA 반영]** **`:Unsubscribe()`는 `:Subscribe()`의 짝으로 축소** — leaf 바인딩 경로에서 cleanup을 앞당기면 dedup 때문에 재바인딩이 안 일어나 Effect가 조용히 죽음(그 dedup 경로의 process/retract 대칭은 **미확인**, M3 착수 전 확인) | +| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님). **[2026-08-14 세션] Tween 지원 추가** — 자식 프로퍼티를 직접 대입하지 않고 `Dispatch.process(child, prop, ..., 1)`로 위임하는 것으로 확정(프로세스 중 `inst`를 바꾸는 건 키를 바꾸는 것과 같은 층위라 UB 아님, `dispatch-core-plan.md`에 일반 규칙으로 명문화) — Tween 해석 코드가 `PropertyHandler` 하나에만 남는다는 불변식이 유지되고, 이 문서가 새로 정할 건 스칼라→프로퍼티 `wrap`을 `Tween.Value`에만 적용되도록 들어올리는 헬퍼 하나뿐. 옛 "트윈까지 지원할 필요 없음" 서술은 역전됨(그때는 Tween이 독립 Dispatch 핸들러였음). ROADMAP M10에 빠져 있던 체크리스트 항목도 이 세션에 보강. **[2026-08-18 구현 전 QA 반영]** 만든 자식을 다시 찾을 때 **`FindFirstChild` 대신 `Relate` 저장**(이름은 표시·판정용, 릴레이션은 조회용), 자식 프로퍼티 세팅도 `Dispatch.process`로 위임해 Tween이 공짜로 따라오게 | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/dispatch-core-plan.md` "Dispatch 체인" 절 참고 **[2026-08-13 열네 번째 세션]** 하강 diff 반영(`isTag(hintValue)` 방어 가드 폐지 — 클로저 인자의 타입이 계약으로 보장됨, 깜빡임 방지가 깊은 체인에서도 유지) + **패키지 재배치**(참조 카운트 Handler까지 quad-base, 백엔드는 `addTag`/`removeTag(inst, {string})`만 주입 — 웹 `className` 대응 때문에, vararg 아닌 테이블인 이유는 `Tag:Added`와 동일) | -| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드) | -| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 **[2026-08-13 열네 번째 세션, 0-Z 확정]** 그룹이 **자기 전용 키**(비공개 `GetKey`)로 위임하고 이름 소유권은 `AttributeKeyHandler`의 **이름 claim**(`nameClaims` Relate, 충돌 시 즉시 error)이 판정 — 하강 diff에선 두 그룹이 똑같이 `StoreBind`로 보여 점유 체크가 성립하지 않기 때문. 후보 (a)(그룹 안 claimant Relate)는 **그룹↔직접 쓰기를 못 잡아** 기각. 같은 세션에 **패키지 재배치**(값·알고리즘·단일 키 전부 quad-base, 백엔드는 `setAttribute(inst,name,v)`만 주입, 엔진 고유 타입 패밀리만 백엔드). **[2026-08-18 구현 전 QA 반영]** **`Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 이김)을 둘 다 제공**으로 열린 항목 해소, 그리고 **⚠️ 같은 그룹 객체를 두 위치에 놓는 경우를 잡을 위치별 claim이 필요**하다는 미해결 항목 신설(`Ref`처럼 `bindLifetime` 재사용은 불가) | +| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 특수 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | -| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`) | -| `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 `false`를 넣으면 disconnect. 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | -| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **분리는 순수 이동** | +| `ref-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Ref`/`PreRef`/`PostRef` — 지연 없는 확정 값 박스. 용도 재정의(leaf 노드를 담는 용도로도, leaf 노드에 바인딩하는 용도로도), `.Value`+`:Set`/`:Callback`/`:Wait`(전부 self 반환), `Ref`의 retract가 `TagHandler`와 같은 `Relate` diff 패턴이라는 것, 이중 바인딩 금지(`canBound`), `PreRef` 호이스팅 pre-pass와 1회용 `_fired` 가드. **분리는 순수 이동 — 결정은 하나도 안 바뀜** **[2026-08-13 열네 번째 세션]** 하강 diff 반영(0-Z 배너 해소) — "이전 클로저가 언바인딩, 다음 `process`가 바인딩" 두 단계는 그대로이고 그걸 일으키는 주체만 `Dispatch.process`의 핸들러 선비교로 바뀜 **[2026-08-14 아홉 번째 세션]** `PostRef` 확정·편입 — `PreRef`의 거울상(같은 pre-pass가 수집만 하고 두 패스가 **전부 끝난 뒤** fire, `ProcessedPostRef` 센티널+전담 Handler까지 완전 대칭). 보장 범위는 "자기 서브트리 완성"이고 **자기가 부모에 붙는 것보다는 여전히 먼저**임에 주의. 계열 안 fire 순서는 **배열 index 순서 보장 유지**(같은 세션에 미보장으로 뒤집었다 철회 — `archive/preref-order-unguaranteed-withdrawn.md`). **[2026-08-18 구현 전 QA 반영]** 내부 구조가 **별도 `.Callbacks` 테이블 + 평범한 `.Value` 필드**로 단순화(`__index` 우회 폐기), `RefLeafHandler.isHandlable`에 빠져 있던 `type(k)=="number"` 추가(leaf 바인딩은 **배열 전용**), "배열 파트의 `None`은 process를 안 탄다"는 옛 명확화 전면 정정 | +| `event-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리 — 사용자가 직접 지목]** 이벤트 바인딩 — 핸들러가 self(Instance)를 **안** 받는다는 확정(Ref가 이미 커버, 이중 쓰기 경로 방지), 이벤트도 store-bind 가능하며 **[2026-08-18 정정] `None`/`nil`을 넣으면 disconnect**(옛 `false` 센티널은 `None` 도입 전의 선택이라 폐기 — `EventHandler.isHandlable`이 `v == nil`에도 매치돼야 함). 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 `bind-system-plan.md`에 남음, `GetPropertyChangedSignal`은 `onchange-plan.md`. **분리는 순수 이동** | +| `brand-plan.md` | **[2026-08-13 아홉 번째 세션, `bind-system-plan.md`에서 분리]** `Brand` — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`), `isState`를 branded 타입 전부로 일반화(`isPostRef` 포함, 2026-08-14 아홉 번째 세션). 동작/구현은 확정, **이름 `Brand` 자체만 용어 정리 대기**(`question.md` 1번). **[2026-08-18 구현 전 QA 반영]** **`Brand`는 아무 의존성도 갖지 않는다** — `Brand.get`이 `x == None`을 먼저 보는 특수 분기 안은 기각(`isNone`은 그냥 `v == None`, `None`을 평범하게 태깅하는 건 무방). **분리는 순수 이동** | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | | `fallback-plan.md` | **[2026-08-14 세션, `research/`에서 승격]** `Fallback`/`Traceback` — 컴포넌트 함수를 감싸 에러 시 플레이스홀더를 그려주는 순수 슈가(`additional-primitives-plan.md`의 "Error Boundary" 절이 내린 "빈 자리 아님" 결론 위에 얹힘). `Fallback`은 `pcall` 기반(trace 없음), `Traceback`은 `xpcall`+`debug.traceback` 기반(trace 항상 있음) — 플래그 대신 별도 함수로 분리(`Ref`/`PreRef`와 같은 패턴). `err: any`(Lua `error()`가 임의 값을 던질 수 있음, `error(msg)` 기본 호출의 위치 접두 캐비엇 포함) 확정. 패키지는 `quad-base`, 이름 확정. 메커니즘 실측은 `audit/fallback-xpcall-verification.md`. 구현 우선순위는 형제 백로그(`quad-mock`/`quad-debug`/`Operator`)와 동급, 맨 뒤 | | `lifecycle-hooks-plan.md` | **[2026-08-14 아홉 번째 세션, `research/`에서 승격]** 생명주기 훅 슈가 `OnCreated`/`OnRendered`/`OnDestroyed` — 각각 `PreRef():Callback(fn)`/`PostRef():Callback(fn)`/`Effect(function() return fn end)`를 반환하는 **순수 팩토리 함수**라 새 타입/Dispatch 개념이 전혀 안 생김(호출 즉시 평가돼 기존 인스턴스로 사라짐), 여러 개 나란히 등록도 자연 지원(단 **같은 계열끼리의 순서는 미보장**). 마지막 열린 항목이던 `OnRendered`는 사용자가 **채택 확정** — 메커니즘은 `PostRef`(`base/ref-plan.md`), 원래 열어뒀던 (a)/(b)/(c) 중 **(a)**. 캐비엇: `OnRendered`는 서브트리 완성은 보장하지만 **이 인스턴스가 부모에 붙기 전**에 불림(React `componentDidMount`와 다름) — 문서화 필수. 패키지 `quad-base` 확정. **[2026-08-14 열 번째 세션]** `dispose()` 범위(0-B)가 `Slot`+`Instance`로 좁혀지고 `Observer`/`Effect`는 제외되는 쪽으로 확정되며 `OnDestroyed` 이름 재검토 조건이 발동 없이 종결 — `OnDestroyed`가 최종 이름, 용어 대기열에서도 제외 | @@ -92,6 +92,8 @@ | `quad-recursive-acronym.md` | **[2026-08-14 신설]** GNU/WINE류로 `Quad`를 재귀 약어화하는 카피 브레인스토밍 — 설계 결정도 착수 게이팅도 아니고 나중에 README.md 헤딩 등에 쓸 캐치프레이즈 후보 모음. 자학 개그 방향(기각)과 지연평가/재귀·커링/펑터/클로저를 자랑하는 방향(채택 후보, 미확정) 정리 | 하 — 카피 소재, 설계 상의 필요 없음. 사용자가 최종 문구 고르면 반영 | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | | `doc-include-plan.md` | **[2026-08-14 신설]** 문서 stale 감소용 include 도구 `doc-include.py`(가칭) — 원본 파일에 `` 류 마커로 요약 구간을 표시해두면 인용하는 문서가 그 구간을 기계적으로 추출해 붙여넣게 하는 도구. `doc-check.py`(사후 탐지)와 짝을 이루는 사전 차단 장치. AsciiDoc tagged include/markdown-magic이 선례, build vs buy 검토 후 Python 표준 라이브러리로 직접 제작(~100줄) 채택. 파일럿은 `.claude/session-summary.md` ← `.claude/session/*.md` 요약 마커부터(CLAUDE.md 분할로 목적지가 "통째로 생성되는 파일"이 돼 단방향 생성으로 단순화됨) | 하 — M0/설계 게이트와 무관한 메타 도구. **[2026-08-16 기준]** 플랜 초안 단계, 열린 질문 미해소(소스: 이 문서의 "열린 질문" 절) | +| `fastscroll-plan.md` | **[2026-08-18 신설]** 사용자 아이디어 메모 — 완전 외부 패키지 `quad-roblox-fastscroll`(리스트/그리드 내 상대 위치 계산으로 움직일 요소만 갱신, 배경의 빈 공간만 스크롤). 가상 레이아웃 유틸이 선행 요구사항으로 보임. 설계 논의 전, 아직 아이디어 단계 | 최하 — 사용자가 "quad가 잘 작동하게 될 때" 직접 검토하겠다고 후순위 지정. 선행 확인 필요 사항(`Visible=false`일 때 `AbsoluteSize`/`AbsolutePosition` 갱신 여부)은 Roblox Studio 실측 필요 | +| `spring-plan.md` | **[2026-08-18 신설]** 사용자 아이디어 메모 — 스프링 물리 기반 지속 업데이트 프리미티브(`quad-spring`), 이전 상태와 비교해 스프링 연산을 수행하는 중간 핸들러. 참고 구현 [qwreey/spring.lua](https://github.com/qwreey/spring.lua) 사용 가능 여부 확인 필요. 확정 `Tween` 모델과는 별개 트랙 — `quad-base`의 `onStep`류 후킹 인터페이스로 얹을지, 엔진별 `quad-roblox-spring`으로 각자 구현할지, `Source` 확장 primitive로 둘지 미정 | 최하 — "모든게 완성된 후, 별도 모듈로 분화"라고 사용자가 직접 명시 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 @@ -120,7 +122,7 @@ | `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)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` | -| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — `base/lifecycle-pattern.md`가 이미 거부해둔 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라 틀렸음. 정정: 저 이름들은 참조 카운트/이름 claim **알고리즘 구현**일 뿐이고, `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 별도 이름의 `...FallbackHandler`이며, 등록 주체는 quad-base 모듈이 아니라 백엔드 팩토리(`BaseModule` 뮤테이션 시점, 자기 전용 Handler들과 같이). 현행은 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절 | +| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 | ## 참고 diff --git a/.claude/archive/question-resolved.md b/.claude/archive/question-resolved.md index 4bcb23b..38281ae 100644 --- a/.claude/archive/question-resolved.md +++ b/.claude/archive/question-resolved.md @@ -20,6 +20,114 @@ > 유효한 규약은 `base/typing-limits.md`. 나머지(0-Z/0-A/0-B 등)는 > 여전히 `question.md`가 소스. +## [해소됨, 2026-08-18 구현 전 QA] `DI` → `D`(Declarative) 리네임 확정 + +2026-08-08 용어 정리 라운드부터 `question.md` **1순위**로 열려 있던 항목. +사용자가 구현 전 QA 라운드 중 직접 확정("이거 하면서 DI => D 확정하자"). + +- **확정 내용 두 갈래**: (1) 네임스페이스/모듈 자체는 `DI` → **`D`** + (`D.Frame` / `D/init.luau` / `D.InstSlot` / `D.FrameModifier`), + (2) "특수 DI 키"라는 **설명용 표현**은 `D`로 바꾸지 않고 **"특수 키"로 + 단순화**(수식어를 빼도 문맥상 통한다는 판단). +- **`D`로 가는 근거**: (1) "Instance" 전용 개념이 아니라 quad-* 전반의 + declare 요소로 확장 가능한 이름, (2) 엔진 종속 없이 다른 백엔드에서도 + 재사용 가능, (3) `D.FrameModifier`류 타입 프리픽스가 짧아야 한다는 실용적 + 제약. 원래 이름 `DI`의 문제는 **"Dependency Injection"과 완전히 겹쳐 실제로 + 오해가 있었던 전례**. +- **2026-08-08에 확정을 미룬 유일한 사유였던 "한 글자 식별자의 검색성/ + 자기설명력"은 표기 규약으로 보완** — 문서에서 `D`가 처음 나오는 자리에서는 + 항상 `D`(Declarative)로 풀어쓴다(`base/architecture.md`의 "코드 스타일 — + 네이밍 케이싱" 절). +- 지금 유효한 설계는 `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 + 네이밍 인체공학" 절이 소스. + +## [해소됨, 2026-08-18 구현 전 QA 2라운드 후속] `RC-1` — `recompute` 트리거 모델 재설계 + +`base/dispatch-core-plan.md`의 `recompute`가 배열 위치를 순차 등록하는 +동안(`Frame{A,B}`처럼 정적 자식 2개짜리도) 아직 등록 안 된 자리를 `nil`로 +읽어 산술 에러를 내던 크래시(`qa-request/pre-implementation-qa-round2.md`의 +"RC-1" 절에서 손 트레이싱으로 발견) — 같은 날 후속 세션에서 사용자가 +Blocker 재사용 설계를 직접 제시해 해결됨. + +- **핵심**: owner(물리 `inst` 또는 Slot)마다 `Relate`로 들고 있는 전용 + `Blocker`를 배치(`Dispatch.drive`의 배열 파트 순회, `attachSlot`의 자기 + `_elements` flush) 시작 시 `On`, `setLength`의 Observer 콜백(등록 즉시 + 1회 실행 포함)이 `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 + 끝나면 `blocker:OffWithoutEmit()` + 명시적 `recompute` 딱 1회. + `setOffsetSource`는 등록되는 그 자리에서 앞선 형제들의 길이 합을 직접 + 계산해 즉시 `:Set`해서(recompute를 안 기다림) 배치 도중 `:List`가 + 실체화되며 옛 offset을 읽는 문제도 같이 없앰. +- **Blocker에 신설된 API**: `IsOn()`(`IsBlocked` 조회 얇은 래퍼), + `OffWithoutEmit()`(끄되 gated state의 대기 emit은 흘려보내지 않음, + `HasBlockedEmit`은 그대로 리셋) — `state:Block()`을 거치지 않고 직접 + 쓰는 첫 사례. 처음 요청됐던 `HasBlocked`(Blocker 자신의 새 최상위 + 플래그)는 논의 중 불필요함이 확인돼 신설 안 함. +- **중첩 Slot마다 별도 Blocker**(부모와 공유 금지, `blocker-plan.md`의 + 재진입 미지원 규칙 그대로), **런타임 단건 `slot:Add()`는 게이팅 불필요** + (이미 안정된 앞선 position만 참조하므로 무관) — 둘 다 사용자가 직접 + 확인. +- **후속 정정 — `attachSlot` 호출 순서**: 기존 의사코드는 `setLength`를 + 먼저 불렀는데, "호출 순서는 `setOffsetSource` → `setLength`" 일반 + 규칙과 어긋나 있었음이 드러남(RC-1로 `setOffsetSource`가 즉시 계산을 + 하게 되며 순서가 겉으로 드러났기 때문) — Slot의 진짜 `.Length`는 + `activateList` 실체화 뒤에야 확정되므로 `setOffsetSource` → 실체화 → + `setLength` → 물리 마운트 순으로 바로잡음. 같은 논의에서 **코루틴 yield + 금지 불변식**도 확정(`Dispatch.process`/`attachSlot` 호출 체인 도중 + yield는 UB). +- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 + 만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, + `base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" + 절이 소스. 논의 원문(설계 제안 전문, 확인 질문 3개와 답변)은 + `qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절. + +## [해소됨, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 + `RC-3`/`RC-4`(`attachSlot`의 `:List` 초기 population 중복 처리) + +`recompute`가 순회 상한으로 쓰는 `bk.N`이 Slot을 ownerKey로 재사용할 때 +무엇이고 언제 갱신되는지 문서에 없던 갭(`qa-request/ +pre-implementation-qa-round3.md`가 원본) — 트레이싱 중 `attachSlot`이 +`:List`의 최초 population을 이중 처리하는 결함(`RC-3`/`RC-4`)도 같이 +발견됐고, 셋 다 같은 세션에 사용자가 직접 해법을 제시해 해결됨. + +- **`bk.N` = 그때그때 실제 개수**, `inst`/Slot 두 owner 타입 동일 규칙 — + `Dispatch.setLength`(항상 뒤에 불림, `setOffsetSource`는 안 건드림)가 + 이전에 없던 더 큰 position을 + 등록할 때마다 늘고, `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`)이 + 위치를 구조적으로 지울 때 줄어듦. **최초 분석 오류를 사용자가 직접 + 정정**: 필자는 "그때그때 실제 개수"면 배치 등록 중 `RC-1`과 같은 + 크래시가 되돌아온다고 판단했으나, Blocker 게이팅은 `bk.N`이 아니라 + `blocker:IsOn()`만 확인하므로 배치 중엔 `recompute` 자체가 안 돌아 + `bk.N`이 무엇이든 무관함 — `RC-1`의 원래 크래시는 "`bk.N`이 배치 전에 + 이미 최종 크기로 고정"이라는, 이제는 사라진 전제의 부산물이었을 뿐. + Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 아니라 배치 비용 + (O(N²)→O(N)). +- **`RC-3`/`RC-4`**: `attachSlot`이 `slot._mounted = true`를 맨 위에서 + 세팅해뒀던 탓에, `:List`의 최초 reconcile(`activateList`, 아직 + flush 루프의 Blocker가 켜지기 전)이 부르는 `rawAdd`가 "이미 마운트됨" + 경로를 타 항목마다 무게이팅 `recompute`가 돌고(`RC-3`), nested Slot + 요소는 그 자리에서 이미 `attachSlot`된 뒤 뒤이은 flush 루프가 같은 + 요소를 또 `attachSlot`해 이중 실행됐다(`RC-4`). **해법(사용자 제시)**: + `slot._mounted = true`를 `activateList` 호출 **뒤**로 옮기면 끝 — + `activateList` 도중엔 `rawAdd`가 "아직 마운트 전" 경로(그냥 + `_elements`에만 push)를 타고, flush 루프가 `:List`/수동 CRUD 구분 없이 + 모든 요소를 처음이자 한 번만 물리 마운트한다. `rawAdd`의 "이미 + 마운트됨" 즉시-`attachSlot` 분기 자체는 그대로 남음 — 최초 flush + 이후의 런타임 갱신(예: `:List`의 `data`가 나중에 바뀌어 nested Slot이 + 새로 추가되는 경우)엔 여전히 필요한 경로이기 때문. +- **부수 발견**: `spliceArraysDown`이 밀어야 할 배열 목록에 + `bk.observers`가 빠져 있었음(`_elements`/`lengthList`/`sourceList` + 셋만 서술돼 있었음) — 같이 반영. +- **`ROADMAP.md` 마일스톤 정합성**: `RC-1`의 Blocker 게이팅으로 M2 + (`Dispatch.setLength`/`setOffsetSource`)가 M3 체크박스에 있는 + `Blocker.luau`에 구조적으로 의존하게 됐는데 로드맵 어디에도 이 순서 + 의존이 명시가 안 돼 있던 것도 발견 — 가장 보수적인 조치(마일스톤 + 재편 없이 M2 체크박스에 각주만 추가)로 우선 반영, 재편 여부는 열려 + 있음. +- 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "저장 위치"/"배치 + 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/slot-plan.md`의 + "재귀 메커니즘"/"파괴" 절, `base/blocker-plan.md`의 "두 번째 용례" + 절이 소스. 논의 원문(최초 분석·사용자 정정·확인 질문과 답변 전문)은 + `qa-request/pre-implementation-qa-round3.md`. + --- # 확인/결정 필요 목록 diff --git a/.claude/archive/tag-attribute-load-time-registration-reversed.md b/.claude/archive/tag-attribute-load-time-registration-reversed.md index 2106257..a1941aa 100644 --- a/.claude/archive/tag-attribute-load-time-registration-reversed.md +++ b/.claude/archive/tag-attribute-load-time-registration-reversed.md @@ -1,8 +1,23 @@ -# [역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음 +# [역전됨 → 절반 재역전됨] Tag/Attribute Handler는 "quad-base 모듈 로드 시점에 스스로 등록" — 등록 주체와 이름이 둘 다 틀렸음 **상태**: archive — 2026-08-14 열두 번째 세션(이 대화)에서 역전. 정본은 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절. +> **⚠️ [재역전, 2026-08-18 구현 전 QA] 이 문서가 폐기했던 두 결론 중 +> "등록 주체" 쪽은 다시 뒤집혀 현행이 됐다.** 사용자 확정 — +> Fallback Handler는 **quad-base 자신이 등록**한다. 이유는 백엔드를 +> 로드하지 않았을 때 "provider가 초기화됐는지 확인하라"는 안내 경로가 +> 아예 안 도는 것("quad-roblox 를 로드하지 않았을 때 로드했는지 물어보는 +> 요소가 처리가 안 된다"), 그리고 `InitNamespace` 거부 원칙은 *남의 상태를 +> 건드리는 top-level 부작용*과 *사용자 수동 init*을 금지한 것이지 모듈이 +> 자기 레지스트리를 채우는 걸 금지한 게 아니라는 것. 근거 전문은 +> `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" +> 절이 소스. +> **이 문서의 "이름" 쪽 결론(등록되는 건 `TagHandler`가 아니라 +> `TagFallbackHandler`)은 그대로 유효**하다 — 아래 2번 항목. +> 즉 이 문서는 이제 "폐기된 설계의 보존"이 아니라 **한 번 뒤집혔다가 +> 절반이 되돌아온 경위 기록**이다. + ## 뒤집힌 주장 (2026-08-14 열한 번째 세션, "네 번째, 최종 정정") `TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 **quad-base diff --git a/.claude/audit/gcconn-trick-verification.md b/.claude/audit/gcconn-trick-verification.md index 1ea97b2..ed7c99c 100644 --- a/.claude/audit/gcconn-trick-verification.md +++ b/.claude/audit/gcconn-trick-verification.md @@ -77,10 +77,12 @@ Studio에서 실행된 사용자 자작 스크립트(공식 `10` 파일이 아 - **이중 바인딩 게이트 + unbind/Destroy 후 재바인딩 허용** — `bindLifetime`/ `unbindLifetime` 로직 자체는 이 스크립트에 없음(순수 GC/Connection - 메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신]** 게이트는 - `canBound(value)`이고(`if canBound(v) then error(...) end`, `canExecute`는 - emit 게이팅 전용으로 분리 — `lifecycle-pattern.md` "`canBound` vs - `canExecute`" 절), 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 + 메커니즘만 테스트함). **[2026-08-14 열한 번째 세션 재갱신, 2026-08-18 + 방향 정정]** 게이트는 `canBound(value)`이고(`if not canBound(v) then + error(...) end` — `canBound` 참 = "지금 묶어도 됨", `canExecute`는 + emit 게이팅 전용으로 분리되며 둘은 서로의 부정 — + `lifecycle-pattern.md` "`canBound` vs `canExecute`" 절), + 검증해야 할 명제는 안 바뀜: (a) 살아있는 바인딩을 가진 값을 다시 `bindLifetime`하면 error, (b) `unbindLifetime(value)` 후에는 통과, (c) **`inst`가 Destroy된 뒤에도 통과**(모델이 명시적으로 허용). 전부 미해소이고, 공식 `10` 파일은 **재작성 후에야** 이걸 확인할 수 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 78306aa..ce29eeb 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -34,12 +34,12 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 테이블을 계속 쌓는 방식, `reference/quad-v1-architecture.md` 참고)은 폐기. store 바인드에 대한 변경은 "전체 변경"으로 간주(UB 아님, 문서화된 의미론) — 부분 복사/오버레이가 필요하면 팩토리 함수로 필요한 곳만 명시적으로 복사. -4. **PA님 스타일 DI 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`, +4. **PA님 스타일 특수 키 계속 지원**: `[AttributeKey "Name"]`(구 `Attribute`, 2026-08-11 아홉 번째 세션에 그룹 `Attribute(...)`와 이름 충돌 방지로 리네임) 같은 특수 바인드 키, store 컴퓨티드 바인드도 가능해야 함 (`retract`, 구 cleanup, `base/lifecycle-pattern.md` 참고). **[정정, 2026-08-08 세 번째 세션]** `Tag`는 더 이상 `[Tag ""] = true` 해시 파트 - DI 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨, + 특수 키가 아님 — array-part 값 객체(`Tag(...)`)로 재설계됨, `base/tag-plan.md` 참고(`archive/tag-hash-key-model-reversed.md`에 구 모델 보존). **[2026-08-11 아홉 번째 세션]** `Attribute(...)`도 여러 Store를 한 번에 attribute로 묶는 array-part 값 객체로 신설(`Tag`와 @@ -98,16 +98,74 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo 정확한 패키지 이름, 아래 "구현 착수" 절 참고) — base가 가상돔 없이도 프로바이더 패턴으로 백엔드를 받는 인터페이스만 정의하고, 실제 Roblox 구현은 `quad-roblox`가 담당. -13. **모듈은 기본 싱글톤, `New()`는 나중에.** 한 Lua 스레드에서 Roblox/비-Roblox - 프로바이더를 동시에 쓸 일이 거의 없을 거라 판단 — 필요해지면 그때 `New()` - 추가. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, 새 설계 아니라 - 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 `Init(QuadId?)`로 - 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 "팩토리가 - `BaseModule`을 뮤테이션" 패턴(14번) 그대로, `New()`가 생기면 매번 새 - `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. Dispatch의 handler - 레지스트리를 포함해 지금 module-level state로 사는 모든 것(`_initializedBy` - 마커, Dispatch 레지스트리 등)이 자동으로 테이블별 스코핑됨 — 상세 근거는 +13. **모듈은 기본 싱글톤, `New()`는 추가 인스턴스가 필요할 때만.** + **[재정정, 2026-08-19 — 이전 정정(`New()`→`Quad()` 전면 치환)이 + 부정확했음, `/code-review high` 이후 사용자가 직접 바로잡음]** `New`와 + `Quad`가 이름이 다투는 게 아니다 — 이전 정정이 "미래 API 이름은 + `Quad()`"라고 단정하며 `New()`를 전부 `Quad()`로 바꿨던 게 틀렸다. + 사용자 원문: *"내가 말한건 Quad() 만 제공하면 항상 모든 Quad 요구처에서 + 각각의 Quad() 를 수행해서 새로운 모듈 스코프가 나온다는게 문제였어. + 그래서 Quad 는 기본적으로 생성된걸 리턴하긴 하는데, Quad.New() 도 + 제공하는거 어떻냐는거였음, 즉 New() 는 존재하고 기본 리턴은 New() 해서 + 주는건데, 리턴 안에 New 필드가 있고 그 함수를 쓰면 하나의 새로운 Quad + 네임스페이스가 만들어지는식. 안 그러면 모든 컴포넌트를 나눈 모델 + 등에서 Quad() 를 해서 새로운 모듈 스코프가 나와서, 래핑된 Quad() 를 + 수행하는 부분을 따로 작성해두어야한다던가 해질꺼임."* + - **`Quad`(`require`의 반환값, 호출 아님)는 이미 만들어진 기본 + 인스턴스 자체다** — 모듈 로드 시점에 그 모듈 자신이 내부적으로 + `New()`를 한 번 불러 만든 결과를 그대로 top-level 반환값으로 내보냄. + 그래서 평범한 소비자는 아무것도 호출할 필요 없이 `require(quad)`가 + 돌려준 걸 바로 `Quad.Dispatch`처럼 씀(지금 싱글톤 단계와 동일한 + 접근 방식 — 달라지는 게 없음). + - **`Quad.New()`가 명시적 opt-in으로 설계됨**(**구현은 아직 미착수** — + 지금은 싱글톤 단계라 `New` 필드 자체가 아직 안 노출됨, 바로 아래 + "M0 스캐폴딩에 주는 함의" 참고) — 다중 인스턴스화가 실제로 붙는 + 시점엔 반환된 기본 인스턴스 **안에** `New` 필드(함수)가 생기고, + 이걸 실제로 호출하면 완전히 **별도의 새 Quad 네임스페이스** + (자기만의 Dispatch 레지스트리 등)가 나오는 모양으로 짠다. Roblox + + 비-Roblox 프로바이더를 진짜로 동시에 써야 하는 드문 경우에만 이걸 + 쓰게 될 것. + - **왜 "그냥 `Quad()`를 부르면 새 인스턴스"로 안 하는가** — 컴포넌트를 + 여러 파일/모듈로 쪼갠 실제 앱에서는 각 파일이 독립적으로 "Quad + 인스턴스 하나 줘"를 요청하게 되는데, 그 요청 방법 자체가 "새로 + 만들기"라면 **파일마다 서로 다른, 서로 공유 안 되는 인스턴스**를 + 만들게 되는 사고가 난다(Dispatch 레지스트리가 안 공유되는 등) — + 그러면 앱 전체가 하나의 공유 부트스트랩을 따로 작성해서 그걸 통해서만 + Quad를 얻도록 강제해야 함. `require`가 이미 인스턴스화된 기본값을 + 주면 이 위험 자체가 원천적으로 없음 — 새 인스턴스가 필요한 그 드문 + 경우만 명시적으로 `.New()`를 부르면 되므로 실수로 스코프가 갈라질 + 일이 없다. + 한 Lua 스레드에서 Roblox/비-Roblox 프로바이더를 동시에 쓸 일이 거의 + 없을 거라 판단해 지금은 `New()` 없이 싱글톤만 두고, 필요해지면 그때 + `New()`를 노출한다. **메커니즘도 이미 정해짐(2026-08-08 두 번째 세션, + 새 설계 아니라 기존 패턴의 자연스러운 연장)**: v1처럼 `require`를 감싸 + `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀 — 대신 지금 있는 + "팩토리가 `BaseModule`을 뮤테이션" 패턴(14번) 그대로, 매번 새 + `BaseModule` 테이블을 만들어 팩토리로 채우는 것뿐. 상세 근거는 `base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 그렇게만 하면 지금 + module-level state로 사는 모든 것(`_initializedBy` 마커, Dispatch + 레지스트리 등)이 **"자동으로" 테이블별 스코핑된다**고 했는데, 사용자 + 판정은 다르다 — *"모듈이 하나의 인스턴스(dispatch 레지스트리 하나, + canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 … + require 를 감싸지는 않고 단순히 InitModule(module) 등을 받도록 각 + 코드들을 약간 고쳐서 이것을 해결함."* 즉 **코드 변경 없이 자동으로** + 되는 게 아니라, module-level state를 참조하는 코드들이 모듈 인스턴스를 + 인자로 받도록 **손을 대야** 한다 — `New()`가 실제로 호출되면(위 opt-in + 경로) 그 순간 만들어지는 새 `BaseModule` 테이블에 대해 이 손질이 필요. + **지금은 `New()` 자체가 노출 안 된 싱글톤 단계라 `Quad.Dispatch`로 + 바로 접근**한다. `New()` 자신이 내부적으로 어떤 형태로 조립되는지(v1 + 스타일 `InitXxx(module)` 팩토리 체이닝, 타입 재익스포트)는 + `module-lifecycle-plan.md`의 "New()의 내부 구성" 절 참고. + - **M0 스캐폴딩에 주는 함의 — [2026-08-19] 정해짐, 실제로는 M0가 아니라 + `ROADMAP.md` M1(실제 스캐폴딩)에 적용됨.** 레지스트리를 module-level + upvalue로 직접 잡아두면 나중에 다중 인스턴스화할 때 전면 수정이 + 된다는 우려가 있었는데, 바로 위에서 가리키는 InitXxx 패턴(각 + `InitXxx(module)`가 `module`을 upvalue가 아니라 **파라미터로 받아** + 뮤테이션)이 처음부터 그 형태다 — 나중에 바꿀 일 자체가 없게 M1 + 스캐폴딩부터 이 모양으로 짠다. M0는 독립 스파이크 파일로 개별 + 가설만 검증하는 단계라 이 구조 자체를 아직 안 씀(`ROADMAP.md`의 + "M0 — 스켈레톤 + 기술검증" 절 참고). 14. **pluggable 초기화는 팩토리 함수로.** rbvm처럼 네임스페이스 하나하나 수동 init 하는 방식(`base/lifecycle-pattern.md` 5번 항목 참고)은 피하고, `InitRoblox(Module)` 같은 팩토리 함수가 생성된 모듈을 뮤테이션하는 도구를 @@ -167,9 +225,10 @@ quad/ │ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff) │ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환) │ │ ├── StoreBind.luau # store 값 재귀 재실행 로직(범용, 엔진 무관) +│ │ ├── None.luau # NoneHandler(`v==None`을 `nil`로 바꿔 재귀만 — 배열/해시 구분 없음) + NilHandler(`k=number and v==nil` 전용 말단, `setLength(0)`/`setOffsetSource(None)` 등록) (`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절, 2026-08-18 재설계 — `drive`의 `None` 스킵 분기 폐기) │ │ ├── Leaf.luau # (i:number, v=Ref/Observer/Effect/PreRef/PostRef) children-array leaf 매칭 Handler(일반 Ref 매치는 `isRef(v) and not isPreRef(v) and not isPostRef(v)`, Observer/Effect는 `ObserverEffectLeafHandler` 하나가 `type(k)=="number" and (isObserver(v) or isEffect(v))`로 같이 매치 — `base/source-state-plan.md` "Observer/Effect Leaf dedup" 절, 2026-08-14 열두 번째 세션), StoreBind와 같은 층위(범용/엔진무관, 2026-08-08 두 번째 세션 확정) -│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) -│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨 +│ │ ├── Tag.luau # TagHandler — 이름별 참조 카운트(`tagNameMap`), 실제 호출은 주입된 addTag/removeTag(inst, {string}). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `TagFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]** 백엔드 팩토리가 아님)(`base/tag-plan.md`, 2026-08-13 열네 번째 세션 base로 이동) +│ │ ├── AttributeKey.luau # AttributeKeyHandler — 이름 claim(`nameClaims`, 소유권 충돌 즉시 error) + 주입된 setAttribute(inst,name,v) 호출, `None`→nil은 재디스패치로 자동(`base/attribute-plan.md` "이름 소유권" 절). `HANDLER_PRIORITY_FALLBACK`에는 이걸 감싸는 `AttributeKeyFallbackHandler`가 quad-base 자신에 의해 등록됨(**[재역전, 2026-08-18]**) │ │ ├── Attribute.luau # AttributeGroupHandler — 그룹 전용 키(비공개 GetKey)로 이름마다 AttributeKey 경로에 인덱스 1 위임, 클로저가 자기 키 전부 retractFrom(`base/attribute-plan.md` "메커니즘" 절). `AttributeGroupFallbackHandler`가 같은 방식으로 감쌈 │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── Relate.luau # inst를 weak 키로 하는 범용 릴레이션(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`), 비싱글톤 생성자(`base/relate-plan.md`) — 구 PerInstanceState/perInstanceState 대체 @@ -184,16 +243,16 @@ quad/ └── src/ ├── 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/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetStrong으로 gcconn/gchold 저장(`base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용) + ├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `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 기반 자동 판별 - │ ├── OnChange.luau # `OnChange(name)` DI 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) + │ ├── OnChange.luau # `OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 + 이름별 weak 캐시(`AttributeKey`와 동일 기법, `base/onchange-plan.md`, 2026-08-10 세션) │ ├── Slot.luau # base Slot 재조정 로직의 실제 적용/해제(Instance Parent 조작) │ └── InstanceChild.luau # k:number, v:Instance — 중첩 인스턴스 자식(예: Frame { Frame {} }) ├── Animate.luau # `Animate(info)` 편의 콤비네이터 — `factory(self)->State`, `:Apply`로 붙임(내부는 `:Compute`/`Tween{...}` 조합), base 프리미티브 아님(`base/tween-plan.md`) - ├── DI/ - │ └── init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances) + ├── D/ + │ └── init.luau # **전량 코드 생성 산출물** — 제네릭 생성자 `New`(커링: `New "Frame" {...}`) + 클래스별 정적 별칭 필드(`D.Frame = New<> "Frame" :: (({...}) -> Frame)`). 생성 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입/`State`/`None`까지 타입으로 찍음(**[2026-08-18 확정]** `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) └── init.luau ``` @@ -221,10 +280,26 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로 `:Unsubscribe()`, `relate:SetWeak(...)`/`:GetWeak(...)`/`:SetStrong(...)`/ `:GetStrong(...)`, `mod:FontSize(...)`(필드 setter 체이닝). 3. 프리미티브 타입 자신의 네임스페이스에 달린 정적 결합 함수 — - `Modifier.Overridden(mod1, mod2, ...)`가 유일한 현재 사례. 콜론 메서드는 - 아니지만(여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 - 됨) `Modifier` 타입 고유의 공개 연산이라는 점에서 1/2과 같은 부류 — - `Modifier()` 생성자와 같은 이유로 대문자. + `Modifier.Overridden(mod1, mod2, ...)`, `Attribute.Merged(...)`/ + `Attribute.Overridden(...)`. **그 프리미티브 타입 고유의 공개 연산** + 이라는 점에서 1/2과 같은 부류 — `Modifier()`/`Attribute()` 생성자와 + 같은 이유로 대문자. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 이 분류를 만든 근거로 + *"콜론 메서드는 아니지만 (여러 Modifier를 동등한 인자로 받아야 해서 + self 하나로 안 됨)"* 을 들었는데 **그 근거는 성립하지 않는다** — + `Overridden`은 **콜론 체이닝(`a:Overridden(b)`)으로도 제공**된다 + (사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서 + 직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함"*). 분류 자체는 + "닷 접근으로도 부를 수 있는 정적 결합 함수"라는 표면 차이로 유지하되, + 2번(콜론 메서드)과 배타적이지 않다는 점에 유의. + 4. **`D`(Declarative) 네임스페이스와 그 필드**(`D.Frame`/`D.InstSlot`/ + `D.FrameModifier`) 및 생성자 `New` — **[2026-08-18 신설]** 프리미티브 + 타입은 아니지만 사용자가 직접 쓰는 선언형 표면이라 대문자. + **표기 규약: 문서에서 `D`가 처음 나오는 자리에서는 항상 + `D`(Declarative)로 풀어쓸 것** — 한 글자 식별자라 grep이 어렵고 + 이름만으로 뜻이 안 드러난다는 게 2026-08-08부터 개명을 미뤄온 유일한 + 사유였고, 이 표기 규약이 그 보완책으로 같이 확정됐다 + (`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). - **소문자 시작(camelCase)** — 특정 프리미티브 타입 하나에 안 묶이고 여러 타입을 넘나드는 범용 유틸(`isState`/`isSource`/`isRef`/`isPreRef`/ `isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/ @@ -331,7 +406,7 @@ pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬 `archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는 경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 — `:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07] -읽기는 `:Get()` 하나로 통일 — `.value` 표기는 Ref 전용으로 좁혀짐). 값 하나만 +읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만 다룰 땐 Store와 별개인 가벼운 `Source` 프리미티브를 독립적으로도 씀. `store.key` dot-access를 타입 추론 1급 경로로 삼는 것도 3차 라운드에서 정식 확정됨 — **더 이상 열린 질문 아님**, [2026-08-04 기준] 남은 건 정확한 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 28945ec..64c891a 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -30,7 +30,7 @@ Store 여러 개를 한 번에 attribute로 묶어 바인드하는 그룹 `Attri 프리미티브 신설, 이름 충돌 방지를 위해 기존 단일 키 생성자를 `Attribute<>` → `AttributeKey<>`로 리네임(잠정 확정 — 최종 이름은 여전히 `.claude/question.md` 용어정리 대기열). `[AttributeKey "Name"]`(구 -`[Attribute "Name"]`) DI 키의 존재 자체는 `architecture.md` 4번 항목에서 +`[Attribute "Name"]`) 특수 키의 존재 자체는 `architecture.md` 4번 항목에서 이미 확정. UICorner 숏핸드/Tween처럼 별도 전용 문서가 없던 걸 2026-08-07 여덟 번째 세션에 메꿈("1 프리미티브 1 파일" 관례를 Tag/Attribute에도 적용해야 한다는 사용자 지적) — @@ -63,15 +63,19 @@ Instance 참조 타입도 지원해서 `ObjectValue` 없이도 Ref 용도로 Att `Attribute`가 아니라 이미 타입별로 갈라져 있어 아래 그룹 `Attribute(...)`와 겹치지 않음 — 리네임 대상 아님.** -**근거**: 이미 확정된 DI 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 +**근거**: 이미 확정된 `D`(Declarative) 인스턴스 생성 패턴(`bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절)과 구조적으로 똑같은 문제라 같은 결론 -재사용 — `new(className)` 제네릭 생성자 + 자주 쓰는 ~25개는 -정적 필드로 미리 바인딩했던 것과 동일한 절충. **내부 구현은 완전히 +재사용 — 제네릭 생성자 하나 + 클래스별 정적 필드를 미리 바인딩하는 +것과 동일한 절충(**[정정, 2026-08-18 구현 전 QA]** 그 생성자의 이름은 +`New`이고 타입은 `new(className): from>` 같은 타입 +레벨 인덱싱이 **아니라 생성기가 찍어내며**, 범위도 "자주 쓰는 ~25개"가 +아니라 "GUI에 쓰이는 모든 인스턴스"다 — 재사용하는 건 절충의 **모양**이지 +그 수치가 아님). **내부 구현은 완전히 동일**(같은 Handler를 타고, 같은 프리미티브) — 둘 사이 차이는 순전히 호출부가 타입을 어떻게 명시하느냐(제네릭 파라미터 vs 이름)뿐이라 어느 쪽을 쓰든 런타임 동작에 차이 없음. -**[실측 필요, M0/M10]** `[AttributeKey<> "name"] = value`처럼 DI +**[실측 필요, M0/M10]** `[AttributeKey<> "name"] = value`처럼 특수 키 제네릭 파라미터로 `=` 뒤 `value`의 타입까지 실제로 좁혀지는지는 미검증 — Luau 솔버가 이 조합을 못 풀면 `value`가 `any`로 남을 수 있음. 단, **타입 추론이 안 되더라도 런타임 동작에는 영향 없음**(순수 정적 @@ -218,6 +222,30 @@ function AttributeKeyHandler.process(inst, k, v, index) end ``` +- **⚠️ [열린 항목, 2026-08-18 구현 전 QA] 같은 그룹 객체를 두 위치에 놓는 + 경우(`Frame { a, a }`)는 이 claim으로 안 잡힌다 — 위치별 claim이 따로 + 필요하다.** `groupKey(v, name)`이 **그룹 값 객체별·이름별 메모이즈**라, + 같은 객체 `a`가 `k=1`과 `k=2`에 놓이면 양쪽이 **완전히 같은 키 객체**로 + 위임한다. 그러면 위 `nameClaims` 체크는 `cur == k`라 **통과**하고, 두 + 위치가 `(inst, 같은 key)`라는 **하나의 체인을 공유**하게 된다 — 더 + 치명적인 건 철거로, `k=1`이 retract되면 그 클로저가 + `Dispatch.retractFrom(inst, key, 1)`을 불러 **`k=2`가 아직 쓰고 있는 + 바인딩까지 통째로 철거**한다. + - **`Ref`의 "이중 배치 방지"(`base/ref-plan.md`)와 같은 클래스의 + 문제지만 같은 해법을 쓸 수 없다** — 사용자 판정: *"Attribute 는 + bindLifetime 를 못함. Ref 와 다르게 여기저기서 사용 가능하기 때문. 한 + 곳에서 바운딩 했다고 다시 바운딩 못할 순 없음. 따라서 위치별 claim 을 + 하나 두어야한다고 생각함."* `Ref`는 "한 곳에만 배치"가 규칙이지만 + 그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다. + - **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹 + 객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다. + - **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지 + (`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존 + `nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠. + - **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 + **정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`) + — 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹 + `Attribute`는 자원이 **값 하나**라 겹침이 곧 충돌이다. - **해제 → 재클레임 순서는 `Dispatch`가 보장함.** 같은 핸들러 재프로세스는 `slot.retractor(v)` → `h.process(...)` 순서이고, 핸들러가 바뀌는 경우는 `retractFrom` → `process` 순서(`base/dispatch-core-plan.md` "Dispatch @@ -270,7 +298,8 @@ Store 필드 여러 개를 각각 `[AttributeKey<> "name"] = store.name`으 ``` Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음 -Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성) +Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error +Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김 attr:NameMap(): {[string]: Source} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것) Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일) ``` @@ -284,6 +313,26 @@ Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 Store 기각과 안 부딪히나" 참고. +**[해소, 2026-08-18 구현 전 QA] 이름 겹침 정책은 `Merged`/`Overridden` 둘 +다 제공하는 것으로 확정** — 열려 있던 "겹치면 error냐 뒤가 이기냐"는 +**둘 중 하나를 고르는 문제가 아니었다**. 사용자 판정: *"차라리 Merged, +Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자는 그냥 조용히 +덮어써주는것. 사용자 의도에 따라 달라질 부분이라 분리해주는것이 +이로워보임."* + +- `Attribute.Merged(a, b, ...)` — 같은 이름이 겹치면 **즉시 error**(합성 + 시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시 + error)과 결이 같음. +- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**. +- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지 + `Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기, + `Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선 + **충돌 시 정책**(error냐 덮어쓰기냐)을 가른다. `base/tag-plan.md`가 + `Tag.Merged` 코드 주석에서 두 이름을 "집합 합치기 vs 이미 계산된 것 + 합치기"로 대조해둔 서술과 같이 읽을 것. +- **`Tag`에는 `Overridden`이 필요 없다** — `Tag`는 이름 집합의 합집합이라 + 애초에 "충돌"이라는 개념이 없다(겹치면 그냥 하나로 합쳐지는 게 정답). + ### 메커니즘 — 그룹 전용 키로 단일 키 경로에 위임 (2026-08-13 열네 번째 세션 확정) **[2026-08-11 아홉 번째 세션 후속, 개정]** 최초안은 "자기 완결형 Handler, @@ -445,8 +494,8 @@ 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`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 백엔드 팩토리 뮤테이션 시점에 등록됨(아래 참고) | -| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D`/`DI` 층) | +| `AttributeKeyHandler`(이름 claim 포함) / `AttributeGroupHandler`(전용 키 위임) | quad-base, `HANDLER_PRIORITY_FALLBACK`으로는 이걸 감싸는 `AttributeKeyFallbackHandler`/`AttributeGroupFallbackHandler`가 등록됨 — **[재역전, 2026-08-18] 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절) | +| 엔진 고유 타입 패밀리(`Color3Attribute`/`UDim2Attribute`/`InstanceAttribute`류) | 백엔드(quad-roblox의 `D` 층) | | **`setAttribute(inst, name, v)`** — `v == nil`이면 그 이름을 지움 | 백엔드가 주입 | - **왜 타입 패밀리만 갈리는가**: Roblox attribute가 받는 타입 집합 @@ -458,9 +507,10 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 claim 알고리즘 구현일 뿐, 스스로 등록되는 주체가 아님(2026-08-14 열두 번째 세션 정정).** `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 `AttributeKeyFallbackHandler`/ - `AttributeGroupFallbackHandler` — 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리(`BaseModule` 뮤테이션 시점, 자기 전용 Handler들과 - 같이 등록). 옛 "quad-base 모듈 로드 시점에 스스로 등록" 모델은 + `AttributeGroupFallbackHandler` — **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 + 상태에서도 안내 에러 경로가 돌아야 하기 때문, `base/dispatch-core-plan.md`의 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스). 경위는 `archive/tag-attribute-load-time-registration-reversed.md`. `setAttribute`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 진짜 @@ -481,16 +531,13 @@ quad-roblox** 소속이었음 — 그런데 실제로 엔진에 종속된 건 하강 diff 재디스패치(0-A)는 확정·반영 완료** — 위 "이름 소유권"/ "메커니즘" 절이 정본, 뒤집힌 옛 모델은 `archive/dispatch-hintvalue-model-reversed.md`. -- **[열림, 사소함, 2026-08-13 열네 번째 세션 신설] `Attribute.Merged`에서 - 두 Store가 같은 이름을 가지면 지금은 조용히 하나가 이김** — - `:NameMap()` 평탄화가 dispatch 이전 단계라 위 이름 claim이 못 잡는 - 자리. 이름 겹침을 error로 잡는 게 이 문서의 다른 결정들과 결이 같고 - 구현도 싸지만(합성 시점 1회 체크), "Merged는 뒤가 이긴다"를 의도된 - override로 볼 여지도 있어서 사용자 확인 대기 — `question.md` 3번. +- **[해소, 2026-08-18 구현 전 QA] `Attribute.Merged`의 이름 겹침 정책** — + `Merged`(error)와 `Overridden`(뒤가 이김)을 **둘 다 제공**하는 것으로 + 확정. 상세는 위 "채택안 — `Tag`와 동형인 array-part 값 객체" 절. - **이름은 잠정 확정, 최종 확정은 대기열**: 겹침 방지를 위해 그룹 값은 `Attribute`, 단일 키는 `AttributeKey<>`로 코드/문서 전체 통일해서 - 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`DI`→`D`/ - `Slot`/`canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 + 당장의 해석 모호성은 없앴음 — 그래도 최종 이름은 다른 가칭들(`Slot`/ + `canExecute`/`Brand`)과 함께 `.claude/question.md` 용어정리 대기열에 있음, 나중에 한꺼번에 재검토. - **[백로그, 2026-08-12 세션 후속]** 그룹이 이름을 조용히 놓아도 `setAttribute(inst,name,nil)`을 자동으로 안 해준다는 위 "그룹 `Attribute(...)`" diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index a15bd73..51d3b2c 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -8,7 +8,7 @@ > | 나간 것 | 어디로 | 단계 | > |---|---|---| > | `Ref`/`PreRef` 전체 | `base/ref-plan.md` | 1단계(9차 세션) | -> | 이벤트 바인딩(self 미전달, `false`로 disconnect) | `base/event-plan.md` | 1단계 | +> | 이벤트 바인딩(self 미전달, `None`/`nil`로 disconnect) | `base/event-plan.md` | 1단계 | > | `Brand`(런타임 nominal 판별) | `base/brand-plan.md` | 1단계 | > | **디스패치 코어**(핸들러 계약 / 디스패치 모델 / `chains`·`retractFrom` / 체크리스트 / Length·Offset) | **`base/dispatch-core-plan.md`** | **2단계(14차 세션)** | > | **반응형 코어**(Source/State 온톨로지·서브타입, 전파 모델, `:With`/`:Compute`/`:Apply`/`previous`, `Observer`, 구독·생명주기 게이트) | **`base/source-state-plan.md`** | **3단계(2026-08-14)** | @@ -53,7 +53,7 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 - **`Ref` / `PreRef`** — 용도 재정의, `.Value`/`:Set`/`:Callback`/`:Wait` API, `Ref`의 retract, PreRef 호이스팅/1회용 가드 → **`base/ref-plan.md`**. - **이벤트 바인딩** — 핸들러가 self(Instance)를 안 받는다는 확정, 이벤트도 - store-bind 가능(`false`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 + store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트 *네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절에 그대로 있음. - **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, @@ -112,9 +112,15 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 아니라 **같은 팩토리 재호출(무시) vs 다른 팩토리로 유일 슬롯 충돌(에러)이라는 서로 다른 케이스를 각각 가리키고 있었음**. 구현은 모듈 테이블에 "누가 초기화했는지" 마커(`_initializedBy = "roblox"`류, 정확한 이름은 구현 단계)만 -두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 실은 -열려있던 게 아니라 자연히 풀림 — `New()`가 생기면 각 인스턴스가 별도 -테이블이 되므로 이 마커도 테이블별로 독립적으로 스코핑됨, 재설계 불필요. +두면 됨. 모듈 스코핑(`New()`, `base/architecture.md` 13번)과의 관계도 +실은 열려있던 게 아니라 자연히 풀림 — `New()`가 실제로 호출되면 그 +호출이 만드는 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 +독립적으로 스코핑됨(**[재정정, 2026-08-19 — `architecture.md` 13번의 +재정정과 맞춤]** `Quad`는 이미 만들어진 기본 인스턴스이고 `New()`는 그 +안의 opt-in 필드다, "`Quad()`를 부르면 매번 새 인스턴스"가 아님. 단 +"자동으로"는 아님 — module-level state를 참조하는 코드들이 모듈 +인스턴스를 인자로 받도록 손을 봐야 하는 건 architecture.md 13번의 정정 +그대로, 여기서 반복 안 함). ## 인스턴스 생성 / 이벤트 네이밍 인체공학 — 확정(2026-08-04 3~4차 라운드, PA님 실 코드로 검증됨) @@ -123,19 +129,73 @@ RobloxFactory(QuadBase)` 세 줄 정도로 직접 조립하면 됨(별도 번들 문제와 같은 원인). 사용자가 실제 참고 코드를 `.claude/initreq/artworks/DeclarativeProgramming/ DeclarativeInstance.luau`(PA님 작성, UI 포함 전반적 설계 패턴을 시범 적용한 -데모 모듈)에 공유해줘서 직접 확인 — **"DI"는 Dependency Injection이 아니라 -"Declarative Instance"(선언형 인스턴스 생성)**. +데모 모듈)에 공유해줘서 직접 확인 — 원래 가칭 `DI`는 Dependency Injection이 +아니라 "Declarative Instance"(선언형 인스턴스 생성)의 약자였음. +**[2026-08-18 확정] 네임스페이스 이름은 `D`(Declarative)** — `DI`는 Dependency +Injection과 완전히 겹쳐 실제로 오해가 있었던 전례가 있고, `D`는 (1) +"Instance" 전용 개념이 아니라 quad-* 전반의 declare 요소로 확장 가능하며, +(2) 엔진 종속 없이 다른 백엔드에서도 재사용 가능하고, (3) `D.FrameModifier`류 +타입 프리픽스가 짧아야 한다는 실용적 제약을 만족한다. 한 글자 식별자라 +grep이 어렵고 이름만으로 뜻이 안 드러나는 게 유일한 단점이었으므로, +**문서에서 `D`가 처음 나오는 자리에서는 항상 `D`(Declarative)로 풀어쓴다** +(표기 규약은 `base/architecture.md`의 "코드 스타일 — 네이밍 케이싱" 절). -**인스턴스 생성 — PA님 코드 그대로 채택**: 처음 제안했던 "필드=1급 타입 -경로, 문자열=폴백"이라는 2트랙(`DI.Frame` vs `DI.New<> "Frame"`) 구상 -보다 실제로는 더 단순했음(`DeclarativeInstance.luau:104-160`) — -**제네릭 생성자 함수 하나(`new(className): from>`)가 알려진 타입과 모르는 타입을 전부 커버**하고, 그중 UI에서 자주 -쓰는 클래스 ~25개(`Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입 -테이블에 등록된 것들)만 모듈 로드 시점에 **즉시(eager)** `constructor.Frame = -new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지연 생성이 -아니라 그냥 정적 테이블. quad-v2도 이 모양 그대로 채택: 하나의 제네릭 -생성자 + 자주 쓰는 것만 정적으로 미리 바인딩. +**인스턴스 생성 — 호출 모양은 PA님 코드 그대로, 타입은 생성기가 만든다 +([2026-08-18 구현 전 QA에서 후자를 정정])**: 처음 제안했던 "필드=1급 타입 +경로, 문자열=폴백"이라는 2트랙 구상보다 실제 호출 모양은 더 단순했음 +(`DeclarativeInstance.luau:104-160`) — **제네릭 생성자 함수 하나 + +자주 쓰는 클래스를 필드로 미리 채운 정적 테이블**(`constructor.Frame = +new("Frame")`, `__index` 지연 생성이 아니라 eager). quad-v2도 이 모양을 +그대로 채택한다. **다만 PA님 코드가 그 필드 타입을 뽑는 방식** +(`new(className): from>` — 타입 +레벨 인덱싱)**은 채택하지 않는다**: + +1. **이벤트 필드가 콜백 타입이 안 나온다.** Roblox 타입 정의에서 + `MouseButton1Click`은 시그널 계열 타입이라, 인덱싱으로 뽑으면 + `RBXScriptSignal`이 그대로 나오고 quad가 원하는 `((...) -> ())?` 콜백 + 시그니처가 안 나옴. +2. **LSP마다 `Frame` 타입을 다루는 방식이 다를 수 있어** 타입 함수/인덱싱에 + 의존하는 게 위험하다. +3. **`T | State`(그리고 `T | Tween`, `None`/`nil` 등)까지 타입 함수로 + 조립해야 하는데**, 그럴 바엔 `D` 파일을 통째로 생성하는 쪽이 단순하다. + +**따라서 `D`는 전량 코드 생성 산출물이다** — 타입뿐 아니라 `New` 호출문까지 +생성기가 찍어낸다(사용자: *"전부 코드 생성이나, New 같은것도 생성기에서 +같이 적어주어야할 부분"*). 손으로 쓰지 않는다. + +**`New`는 커링, `D`는 처리 없는 별칭 테이블 (2026-08-18 확정)**: + +```luau +-- New(name)이 생성자 함수를 반환하고, 그걸 props 테이블로 다시 호출 +New "Frame" { ... } -- == New("Frame")({ ... }) +New<> "Frame" { ... } -- 직접 사용도 같은 모양 + +-- D는 그 결과에 캐스트만 얹은 순수 별칭 테이블 (생성기 산출물) +D.Frame = New<> "Frame" :: (({ ...타입명시 }) -> Frame) +``` + +- **이름은 대문자 `New`로 통일**(사용자 확정: *"2. New입니다."*) — PA님 코드 + 인용의 소문자 `new`와 섞여 있던 것을 정리. +- **뒤집는 게 아니라 명시화**다 — PA님 패턴의 `constructor.Frame = + new("Frame")`이 이미 사실상 커링이었고, 다만 (a) "커링이다", (b) 2단계 호출 + 계약, (c) `New "Name" {...}`라는 직접 호출 형태가 문서에 적힌 적이 없었다. +- **기각된 "2트랙"과 혼동하지 말 것** — 기각된 건 *"필드=1급 타입 경로, + 문자열=폴백"* 이라는 **능력 차이**였지 `New`라는 이름이나 문자열 호출 + 자체가 아니다. 이 확정은 오히려 두 형태가 **완전히 같은 것**(하나가 다른 + 하나의 미리 적용된 결과)임을 못박는다. +- **생성 범위는 "GUI에 쓰이는 모든 인스턴스"**(사용자 확정) — 예전 서술의 + "자주 쓰는 ~25개"도 아니고 Roblox 전체 클래스도 아님. 전량 생성하면 `D` + 파일이 너무 커진다는 게 이유. "GUI에 쓰이는"의 정확한 판정 기준(API + 덤프에서 `GuiObject` 하위 + `UIComponent` 하위 + `LayerCollector`류 등)은 + 생성기 구현 시점에 정한다. +- **범위 밖 클래스는 느슨하게 `any`**(사용자 확정: *"느슨하게 any 로 하고, + 필요하면 이를 직접 구현 가능하게 둡니다. cast 를 하든, 유저의 자유"*) — + `New<> "X" {...}`를 직접 쓰면 props 타입은 `any`이고, 필요하면 사용자가 + `::` 캐스트로 좁힌다. 새 확장 지점을 만드는 게 아니라 `D.Frame` 자신이 + 이미 캐스트 한 줄이므로 **같은 한 줄을 사용자가 직접 쓰면 되는 것**. + 따라서 **"제네릭 생성자 함수 하나가 알려진 타입과 모르는 타입을 전부 + 커버"라는 옛 서술은 런타임에 대해서만 맞다** — 타입은 `D` 범위 안만 + 정확하고 밖은 `any`다. **이벤트 바인딩 — `On.EventName` 도트액세스 안 씀, PA님 방식(평범한 문자열 키 + 런타임 리플렉션)으로 전환**: `DeclarativeInstance.luau:13-91`의 @@ -143,25 +203,51 @@ new("Frame")`처럼 필드로 미리 채워둠 — `__index` 메타메소드 지 `GetEventsOfClass`로 클래스별 프로퍼티/이벤트 타입을 캐싱해두고, 키가 `RBXScriptSignal` 타입이면 자동으로 `instance[key]:Connect(value)`로 처리함 — `Frame { MouseButton1Click = fn }`처럼 별도 네임스페이스 없이 그냥 문자열 -키로 씀. 이건 타입 안전성을 어느 정도 포기하는 대가지만(콜백 시그니처까지 -Luau가 검증 못 함 — `apply(instance: T, properties: U): T & U`가 스키마 -검증 없이 구조적으로만 merge), 이미 UB로 남긴 "테이블 리터럴 안 키별 값 -타입 자동 검증 불가"와 같은 급의 한계라 손해가 크지 않고, `On.` 접두어 없이 -문법이 더 간결해짐 — **사용자 확정**("PA 님 방식 괜찮은듯. 타이핑은 인라인이 -되긴 하겠지 정도면 괜찮다"). quad-v2 구현에서는 이 "키가 이벤트인가" +키로 씀. `On.` 접두어 없이 문법이 더 간결해짐 — **사용자 확정**("PA 님 +방식 괜찮은듯. 타이핑은 인라인이 되긴 하겠지 정도면 괜찮다"). + +**[정정, 2026-08-18 구현 전 QA] "콜백 시그니처까지 Luau가 검증 못 한다"는 +서술은 거짓이었음.** 옛 문장은 이 방식이 *"타입 안전성을 어느 정도 포기하는 +대가"* 이고 *"콜백 시그니처까지 Luau가 검증 못 함"* 이라고 적었는데, 사용자가 +직접 반례를 작성해 보여줬다: + +```luau +function Frame (prop: {MouseButton1Click: ((a: number)->())?}) +end + +Frame{ + MouseButton1Click = function(a) -- a: number 로 추론됨 + end +} +``` + +props 테이블 **타입에 필드로 선언돼 있으면 콜백 파라미터가 그대로 +추론된다.** 런타임 판별을 `ReflectionService`로 하는 것과 **타입을 생성기가 +제공하는 것은 완전히 별개 축**인데 옛 서술이 둘을 묶어버린 것. +따라서 이건 "감수하는 대가"가 아니라 **`D` 생성기가 챙겨야 하는 구현 +체크리스트 항목**이다 — 생성기는 클래스별 props 타입에 **이벤트 필드까지 +정확한 콜백 타입으로** 포함시켜야 하고, 값 타입은 콜백뿐 아니라 +`State<...>`와 disconnect 센티널(`None`/`nil`, `base/event-plan.md`)까지 +포함하는 유니온이어야 한다. 이건 위 "타입은 생성기가 만든다"의 직접적 +근거이기도 하다(인덱싱으로는 시그널 타입이 그대로 나와서 안 됨) — 두 항목은 +같은 문제의 양면이므로 같이 볼 것. quad-v2 구현에서는 이 "키가 이벤트인가" 판별을 `isHandlable`로 감싼 pluggable 핸들러(`quad-roblox`가 `Reflection Service` 기반으로 구현)로 두면 됨 — 별도 `On` 모듈/필드 접근 구조 자체가 불필요해짐. -**Store 쪽 dot-access는 그대로 유지**: `store.key`(1급 타입 경로)/ -`store "key"`(문자열 커링, 동적 키 폴백)는 이벤트와 달리 실질적으로 Luau가 +**Store 쪽 dot-access는 그대로 유지**: `store.key`는 실질적으로 Luau가 타입을 좁혀주는 이득이 있어서(Store 자체가 `{key: Source, ...}`류 -평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 -유지 — 이벤트만 예외였을 뿐, "정적으로 알려진 것=필드 접근" 원칙 자체가 -깨진 건 아님. +평범한 레코드 타입으로 지어짐, `base/store-plan.md`) 그대로 유지. +**[정정, 2026-08-18] `store "key"` 문자열 커링은 기각됐다** — 여기 폴백으로 +같이 적혀 있었으나 폐기됨(`"a"`가 그냥 `string`으로 들어가 `Source`의 +`T`를 알 수 없고, dot-access + `type function` 타이핑이 자리잡아 더 이상 +필요 없어짐). 동적 키는 명시적 `store:GetDynamic<>(name)`으로 간다 — +`base/store-plan.md`가 소스. +**이벤트가 이 관습의 예외인 성격도 바뀜** — "타입을 포기하는 예외"가 아니라 +**이름 지정 방식만 문자열 키인 예외**다(타입은 위 정정대로 생성기가 준다). **`GetPropertyChangedSignal`은 이 문자열 키 패턴이 안 통함 — 별도 `OnChange` -DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 +특수 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal이라 그대로 `Connect`하면 되지만, `GetPropertyChangedSignal(name)`은 프로퍼티 이름을 인자로 받아야 하고 그 이름이 "값 세팅" 키 네임스페이스와 겹쳐서 평범한 문자열 키로는 세팅과 리스닝을 구분할 수 없음 — 상세는 @@ -196,10 +282,10 @@ DI 키로 확정(2026-08-10 세션).** 이벤트는 `inst[key]`가 이미 Signal `RobloxFactory` 재호출 가드)를 거치며 전부 확정됨. 그 라운드들 기준으로 남았던 건 순수 API 표면 이름뿐이었음: -- **`DI`(또는 다른 이름) 등 정확한 모듈 이름** — 방향은 전부 확정, 이름만 - 구현 단계에서 남음(`On` 모듈은 이벤트 바인딩이 PA님 방식으로 바뀌며 아예 - 불필요해짐 — 위 "인스턴스 생성 / 이벤트 네이밍" 절 참고). Source/State - 쪽 이름 문제는 `base/source-state-plan.md`가 소스. +- **[해소, 2026-08-18] 모듈 이름은 `D`로 확정** — 옛 항목("`DI`(또는 다른 + 이름) 등 정확한 모듈 이름")은 닫혔다. 근거는 위 "인스턴스 생성 / 이벤트 + 네이밍 인체공학" 절. Source/State 쪽 이름 문제는 + `base/source-state-plan.md`가 소스. - **매 `process()` 호출마다 우선순위 스캔 비용** — 실제 구현/벤치마크 단계에서 확인 필요(디자인 자체는 확정됐으므로 더 이상 사용자 확인 대상 아님, 구현 검증 대상). diff --git a/.claude/base/blocker-plan.md b/.claude/base/blocker-plan.md index 184841f..e0f186c 100644 --- a/.claude/base/blocker-plan.md +++ b/.claude/base/blocker-plan.md @@ -38,7 +38,15 @@ debounce-throttle-plan.md`)가 추가되면 같은 자리에 들어옴. 이걸 Blocker() -> blocker -- 생성자 blocker:On() -> self -- IsBlocked = true로만 설정, 그 외 아무것도 안 함 blocker:Off() -> self -- IsBlocked = false로 먼저 설정, 그 다음 등록된 - -- onunblock 핸들 전부 실행(순서 무관, idempotent) + -- onunblock 핸들 전부 실행(emit=true로, 순서 무관, idempotent) +blocker:OffWithoutEmit() -> self -- [2026-08-18 신설] IsBlocked = false로 먼저 설정, 그 다음 + -- 등록된 onunblock 핸들 전부 실행(emit=false로) — 각 핸들이 + -- 자기 HasBlockedEmit은 그대로 리셋하되 실제 emit은 건너뜀. + -- `Off()`와 내부 로직을 공유(아래 "onunblock 핸들" 참고), + -- 차이는 넘기는 emit 플래그 하나뿐. +blocker:IsOn() -> boolean -- [2026-08-18 신설] `self.IsBlocked`를 그대로 반환하는 + -- 얇은 조회 메소드 — 필드 `IsBlocked`는 그대로 유지(아래 + -- "이름 확정" 참고), 호출부 가독성만을 위한 추가. state:Block(blocker) -> state -- 새 gated state 반환. **호출되는 즉시**(나중에 -- 처음 블록될 때가 아니라) onunblock 핸들을 @@ -49,9 +57,14 @@ gated state의 동작: - 원본 state가 emit(무효화)될 때, 이 gated state로 전파를 시도. - `blocker.IsBlocked`이면: 전파 안 하고 `HasBlockedEmit = true`만 세팅. - `blocker.IsBlocked`가 아니면: 평소처럼 그냥 전파(투명하게 통과). -- `blocker:Off()`가 실행하는 onunblock 핸들은: `HasBlockedEmit`을 확인해 - true면 그제서야 정확히 1회 전파(emit)하고 플래그를 리셋. 이미 false면 - 아무 것도 안 함(idempotent). +- **onunblock 핸들은 이제 `emit: boolean` 인자를 받는다**(`blocker:Off()`/ + `:OffWithoutEmit()`가 공유하는 내부 실행 경로, 2026-08-18 신설) — + `HasBlockedEmit`을 확인해 true면 `emit`이 참일 때만 그제서야 정확히 + 1회 전파(emit)하고, `emit`이 거짓이면 전파 없이 플래그만 리셋. 이미 + `HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함 + (idempotent). 즉 `Off()`는 "밀린 전파를 흘려보내며 끈다", + `OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기 + 상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨. **`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도 누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서 @@ -77,6 +90,32 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 가까운 지점)에 거는 게 원칙 — 소스가 여러 개든, 하나가 한 주기에 여러 번 바뀌든 상관없이 이 지점 하나만 지키면 됨. 소스 쪽에 각각 거는 게 아니다. +## `state:Block()` 없이 직접 쓰는 두 번째 용례 — base 내부 부기 게이팅 (2026-08-18 신설) + +지금까지 위 예시는 전부 `state:Block(blocker)`로 만든 **gated state**를 +경유하는 사용자 대상 패턴이었다. `base/dispatch-core-plan.md`의 +"Length/Offset" 절이 `recompute`의 크래시(`RC-1`, 배열 위치가 하나씩 +순차 등록되는 동안 아직 등록 안 된 자리를 읽어 산술 에러가 나는 경로)를 +고치며 **Blocker를 gated state 없이 직접 쓰는 두 번째 용례**를 만들었다 +— **[정정, 2026-08-18 구현 전 QA 3라운드]** 그 크래시 자체는 이후 +`bk.N`(순회 상한)의 정의를 고치며 사라졌지만(`base/dispatch-core-plan.md` +"저장 위치" 절), 이 용례는 그대로 유효하다 — 이유가 크래시 방지에서 +배치 등록 비용(O(N²)→O(N)) 절감으로 바뀌었을 뿐. 콜백 안에서 +`blocker:IsOn()`을 직접 확인하고 스스로 전파를 건너뛰는 +방식(`Length` State의 Observer가 `if not blocker:IsOn() then recompute(...) end` +형태로 자기 자신을 게이팅). 이 용례는 `state:Block()`을 전혀 호출하지 +않으므로 gated state도, 그 위에 걸리는 onunblock 핸들도 생기지 않는다 — +`blocker:Off()`/`:OffWithoutEmit()`을 불러도 실행할 핸들이 없어 두 +메소드가 이 용례에서는 사실상 동일하게 동작하지만, **의도를 코드에 남기기 +위해 `OffWithoutEmit()`을 쓴다**("이 배치가 끝나면 무엇이든 자동으로 +흘려보내지 말고, 호출자가 직접 정확히 한 번 후속 작업을 한다"는 의도 +표현). 상세 메커니즘·`Dispatch.setLength`/`setOffsetSource`가 이 Blocker를 +어떻게 만들고 어디에 저장하는지는 `base/dispatch-core-plan.md`의 "배치 +등록을 안전하게 만드는 Blocker 게이팅" 절이 소스 — 여기서 반복하지 않음. +**재진입(네스팅) 미지원 규칙은 이 용례에도 그대로 적용** — 중첩된 owner +(예: 부모 Slot 안의 자식 Slot)마다 각자 자기 owner 키로 별도 Blocker를 +새로 만들어야 하고, 부모 Blocker를 재사용/전달하면 안 됨(아래 "재진입" 절). + ## 이름 확정 - 클래스: `Blocker` — `Observer`/`Modifier`/`Ref`와 같은 명사-행위자 @@ -89,6 +128,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 - 필드: **`IsBlocked`**(Blocker 자신의 On/Off 상태), **`HasBlockedEmit`** (gated state의 대기 플래그, `Is`/`Has` 접두어로 불리언임을 바로 알려줌). - 메소드: `state:Block(blocker) -> state`. +- **[2026-08-18 신설] `IsOn() -> boolean`**(`IsBlocked` 필드를 그대로 읽는 + 얇은 조회 메소드), **`OffWithoutEmit() -> self`**(위 "onunblock 핸들" + 참고) — 사용자 확정: *"IsBlocked가 있다면 그냥 두어도 될듯 함. + HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 즉 `IsBlocked`/ + `HasBlockedEmit` 필드는 그대로 유지하고, 별도 `HasBlocked`(Blocker + 자신의 새 최상위 플래그)는 **신설하지 않는다** — `OffWithoutEmit()`이 + 각 gated state의 기존 `HasBlockedEmit`을 그대로 리셋해주는 것으로 + 충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의 + 과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md` + "RC-1" 절에 논의 경위 기록). ## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수 @@ -104,7 +153,22 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한 문서(API 레퍼런스 수준)에 명시적으로 강조할 것** — 네스팅을 시도하면 조용히 잘못된 시점에 조기 해제되는, 원인 추적이 어려운 버그로 이어짐. -## 상태: 핵심 메커니즘+이름 확정. [2026-08-07 기준] 남은 건 문서화뿐 +**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** — +위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset +배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을 +재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** — +부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도 +Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의 +`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려 +부모의 나머지 등록이 게이팅을 잃는 사고가 났을 것 — 바로 위 문단이 +경고하는 실패 모드의 구체 사례. + +## 상태: 핵심 메커니즘+이름 확정. [2026-08-18 기준] 남은 건 문서화뿐 + +**[2026-08-18 갱신]** `IsOn()`/`OffWithoutEmit()`(위 "메커니즘" 절)과 +`state:Block()` 없이 직접 쓰는 두 번째 용례는 이 날짜에 추가된 실제 API +확장 — "남은 건 문서화뿐"이라는 결론 자체는 안 바뀌었지만(API 표면과 +메커니즘은 이 확장을 포함해 다시 확정 완료), 기준 날짜만 갱신. `quadnomicon`에서 "Batch를 기각하고 왜 Blocker로 갔는가"를 비교 설명하는 게 좋은 소재(`archive/batch-rejected.md`와 나란히 인용). diff --git a/.claude/base/brand-plan.md b/.claude/base/brand-plan.md index ca517b7..5cf50d7 100644 --- a/.claude/base/brand-plan.md +++ b/.claude/base/brand-plan.md @@ -125,15 +125,25 @@ wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의 들어오면 즉시 error" 절이 필요로 해서 이번에 같이 적음. -**`None`은 이 레지스트리에 안 들어감 — 싱글턴이라 항등 비교로 충분.** -`Observer`/`Store`처럼 인스턴스가 여러 개 생기는 타입과 달리 `None`은 -quad 전체에서 딱 하나만 존재하므로 weak table 조회보다 `x == None` -레퍼런스 비교가 더 싸고 정확함. 다만 `Brand.get(x)`가 "quad가 아는 모든 -값의 태그를 답해주는 범용 introspection 창구"(quad-debug 같은 도구가 -"이 값이 뭐냐"를 물어볼 단일 창구) 역할까지 겸하게 하려면 `None`도 -빠지면 안 되므로, `Brand.get`이 내부적으로 `x == None`을 먼저 확인하는 -특수 분기를 하나 두고 그 뒤에 일반 레지스트리 조회로 폴백 — `isNone`은 -바로 이 특수 분기의 실제 구현체가 됨(별도로 새로 만들 것 없음). +**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 — +`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용 +introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이 +내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 +레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다. +사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 +의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == +None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."* + +- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장 + 밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다. +- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이 + 레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단 + 자체는 그대로 유효. +- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가 + 허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉 + "범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한 + 등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는 + 것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다. **duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는 이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index b67a9ff..d114b27 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -238,9 +238,16 @@ return Frame { props.Modifier or None, props.Ref or None, child } 필요 없고, 기존 메커니즘을 그대로 재사용함 — `flatten` 단계는 애초에 `isModifier(v)`가 거짓인 값은 그냥 건드리지 않고 통과시키므로 (`None`은 Modifier가 아니라서 자동으로 이 경로), `props.Modifier or - None`이 최종적으로 배열 파트에 `None`인 채로 남으면 두 패스 루프 - 자신의 array-part `None`-스킵 규칙(위 "PreRef" 절)이 그대로 적용돼 - 아무 일도 안 일어남 — 새 특수 케이스 코드가 하나도 안 늘어남. + None`이 최종적으로 배열 파트에 `None`인 채로 남으면 **그 자리는 기여 + 0으로 정상 처리된다** — 새 특수 케이스 코드가 하나도 안 늘어남. + **[근거 정정, 2026-08-18 구현 전 QA]** 예전엔 근거를 "두 패스 루프 + 자신의 array-part `None`-스킵 규칙"으로 적었는데, 그 스킵 규칙 자체가 + 폐기됐다(반응형 값이 내놓는 `None`은 어차피 `Dispatch.process`에 + 도착하므로 — `base/dispatch-core-plan.md`의 "`None` 센티널" 절). 지금은 + `NoneHandler`가 매치돼 `nil`로 재귀하고 `NilHandler`가 `setLength(0)`/ + `setOffsetSource(None)`을 등록한다. **결론(`or None`을 쓰는 것)은 안 + 바뀜** — 여전히 "아무것도 안 놓은 것과 같은 효과"이고, 오히려 리터럴 + 경로와 반응형 경로가 같은 핸들러로 수렴해 더 단순해졌다. - 이 관용구는 컴포넌트 저작자가 **직접 챙겨야 하는 규율**(base가 강제로 검증해줄 방법은 없음, Lua는 이런 걸 린트로만 잡을 수 있음) — quad 문서화(초심자 가이드/`props.Modifier`/`props.Ref` 절)에 필수 패턴으로 diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index e4a971a..fb1959a 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -127,14 +127,34 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 **이걸로 `module-lifecycle-plan.md`의 "열린 질문이었던 것 — 전부 해소됨" 절에 있는 "provider가 아직 주입 안 된 상태에서 dispatch가 호출되면?" 케이스(`pre-implementation-audit.md` - 1-4)도 별도 분기 없이 자동으로 해소됨** — provider 미주입 상태는 + 1-4)도 별도 분기 없이 자동으로 해소됨** — **backend가 직접 소유하는 + 핸들러(`Property`/`Event`/`Slot`류)에 한해** provider 미주입 상태는 결국 그 클래스를 다루는 핸들러가 레지스트리에 하나도 없는 상태이므로 - "매치 실패"와 정확히 같은 경로로 수렴함. 오타 키/미지원 조합/provider - 미주입을 서로 다른 에러 종류로 구분할 필요가 없음. + "매치 실패"와 정확히 같은 경로로 수렴함. **[한정, 2026-08-18 `/code-review + high` — `D-7` 재역전과의 정합성]** `Tag`/`Attribute`처럼 **base가 + Fallback Handler를 자기 로드 시점에 스스로 등록하는 것**(위 문단, + "base가 소유하는 핸들러와 주입되는 엔진 op" 절)은 이 일반화의 예외다 — + 백엔드가 하나도 없어도 그 Fallback Handler는 이미 레지스트리에 있으므로 + **매치는 되고**, 실패는 "매치 실패" 에러가 아니라 그 자리에서 실행되는 + 주입 op 스텁의 명시적 에러(`addTag가 구현되지 않음...` 류)로 남 + — "provider 미주입"과 "매치 실패"가 **에러 경로 자체는 다르지만 둘 + 다 명확한 에러로 수렴한다"**는 결론은 안 바뀜, 다만 오타 키/미지원 + 조합과 provider 미주입을 구분할 필요가 없다는 문장은 backend 소유 + 핸들러에만 해당한다. - **디버그 모드 — 핸들러 등록/정렬 시점에 동률 감지 시 print 경고 + 전체 핸들러 목록 조회 함수.** 우선순위는 핸들러 등록 시점에 정적으로 sort되므로 동률 감지 자체는 그 시점에 공짜로 가능 — `priority`가 같은 - 두 핸들러가 등록되면 콘솔에 경고를 찍고, `Dispatch.listHandlers()`류 + 두 핸들러가 등록되면 콘솔에 경고를 찍되, **[요구 추가, 2026-08-18 구현 전 + QA] 무조건 찍는 게 아니라 모듈 표면의 불리언 플래그 `Quad.debug`(기본 + `false`)가 `true`일 때만 찍는다**(사용자: *"동률 print 는 라이브러리가 + debug 모드일 때만. (Quad.debug: boolean = default false) 식이고, true 로 + 하면 디버깅 가능"*). `Quad.debug`는 **새 공개 API 표면**이라 + `base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고, + 다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시 + 이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다. + `Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게 + 항상 호출 가능한지) 구현 시 정할 것. + 그리고 `Dispatch.listHandlers()`류 함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함. 구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라 M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인 @@ -312,7 +332,7 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 - **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, - 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 프로퍼티/이벤트/특수 키) 나중(2026-08-07 세 번째 세션).** Luau 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, @@ -323,9 +343,17 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 - 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 - 먼저 처리된다는 게 base 자체의 보장**이 됨 — `ref-plan.md`의 "Ref 일반화" 절 - 뒤에 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + 슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨. + **[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서 + 성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장 + 위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도 + pre-pass for 문**에서 먼저 처리되고 `flattened`에는 소진 + 마커(`ProcessedPreRef`/`ProcessedPostRef`)만 남는다(사용자: *"preref 랑 + postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은 + **서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이 + 아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가 + 프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제 Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). @@ -355,34 +383,49 @@ end 바인딩/등록 하나가 "지금 살아있어서 실행돼도 되는가"만 보는 별개의 라이프타임 게이트(`base/lifecycle-pattern.md` "생명 바인드 유틸" 절) — KV 매치와 무관. - **이 `NoneHandler`는 해시 파트(프로퍼티/이벤트) 전용 — 배열 파트에서 - `None`을 만나는 건 완전히 다른 규칙(2026-08-07 열 번째 세션, "PreRef" - 절 "호이스팅의 실제 구현" 참고).** 배열 파트의 `None`(`props.Ref or - None`처럼 애초에 아무것도 놓인 적 없는 자리)은 "빈 슬롯" - 표시일 뿐 처리할 핸들러 자체가 없으므로, `Dispatch.drive`의 두 패스 - 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 - 건너뜀 — 같은 센티널 값이지만 배열 파트냐 해시 파트냐에 따라 처리 - 경로가 다르다는 점에 유의. **[정정, 2026-08-14 두 번째 세션] "PreRef - pre-pass가 소진시킨 자리"는 이 규칙의 예가 아님** — 그 자리는 `None`이 - 아니라 별도 센티널 `ProcessedPreRef`로 소진되고, `ProcessedPreRefHandler` - (`base/ref-plan.md`의 "PreRef" 절)를 통해 정상 `Dispatch.process` - 경로를 그대로 탐(아래 "Length/Offset" 절 참고). **[2026-08-14 아홉 번째 - 세션] `PostRef`가 소진시킨 자리(`ProcessedPostRef`)도 완전히 같은 취급** - — 전용 센티널 + 전용 `ProcessedPostRefHandler`(`base/ref-plan.md`의 - "`PostRef`" 절), 즉 "정말 빈 자리인 `None`"만 두 패스 루프가 직접 - 건너뜀 — 예전엔 이 둘(원래부터 - 빈 자리 vs 한때 PreRef였다가 소진된 자리)이 똑같이 `None`으로 뭉뚱그려져 - `setLength`/`setOffsetSource` 등록 책임 소재가 불분명한 갭이 있었음 - (2026-08-14 첫 번째 세션 조사에서 발견), 지금은 서로 다른 센티널로 - 명확히 분리됨. + **[재설계, 2026-08-18 구현 전 QA] `NoneHandler`는 해시 파트 전용이 + 아니고, `Dispatch.drive`는 `None`을 건너뛰지 않는다.** 옛 서술은 + "배열 파트의 `None`은 두 패스 루프가 `Dispatch.process`를 거치지 않고 + 바로 건너뛴다"였는데, 그 전제 자체가 거짓이었음 — 리터럴 + `Frame{None}`만 생각하면 루프가 걸러내면 그만이지만 + **`Frame{ State }`처럼 반응형 값이 `None`을 내놓으면 그 + `None`은 `StoreBind`의 재귀를 타고 `Dispatch.process`에 그대로 + 도착**하기 때문. 사용자 판정: *"drive 는 v == None 인지 확인 안하고 + 그냥 프로세스 태우는게 가장 적절한 처리로 보임"*. 따라서: + - **`Dispatch.drive`에 `None` 특수 분기는 없다** — 배열이든 해시든 + 모든 `(k,v)`가 `Dispatch.process(inst,k,v,1)`을 탄다. + - **`NoneHandler`가 하는 일은 재귀 하나뿐** — `v == None`을 매치해 + `Dispatch.process(inst, k, nil, index+1)`로 내려보내는 것. 배열/해시 + 구분도 하지 않는다. + - **실질 정리(그리고 `setLength(0)`/`setOffsetSource(None)` 등록)는 + 아래 `NilHandler`가 맡는다** — 사용자 선택(2026-08-18): *"NoneHandler는 + 재귀만, NilHandler가 실질 담당"*. 즉 배열 자리가 비는 처리 로직은 + `None` 경로든 진짜 `nil` 경로든 **한 곳에만** 있다. + - **`process` 자체가 이전 것을 걷어낸다** — `Tag` → `None` 전환에서 + 이전 `Tag` 기여가 실제로 사라져야 하는데, 이건 하강 diff가 자동으로 + 해준다(핸들러가 `TagHandler`에서 `NoneHandler`로 바뀌므로 아래 + "Dispatch 체인" 절 (B) 분기가 `retractFrom`을 부름). `NoneHandler`가 + 반환하는 retractor 자체는 no-op이어도 된다. + + **`ProcessedPreRef`/`ProcessedPostRef`는 그대로 별개다** — pre-pass가 + 소진시킨 자리는 `None`이 아니라 전용 센티널로 채워지고 전용 nop + 핸들러(`ProcessedPreRefHandler`/`ProcessedPostRefHandler`, + `base/ref-plan.md`의 "PreRef"/"`PostRef`" 절)가 정상 `Dispatch.process` + 경로에서 캐치한다. 예전엔 "원래부터 빈 자리"와 "한때 PreRef였다가 소진된 + 자리"가 똑같이 `None`으로 뭉뚱그려져 등록 책임 소재가 불분명한 갭이 + 있었고(2026-08-14 첫 번째 세션 조사), 지금은 서로 다른 센티널로 명확히 + 분리돼 있음. + `NoneHandler.isHandlable`은 `v == None`(센티널 자체)을 잡는 것이지 - `v == nil`이 아님 — 진짜 `nil`은 애초에 테이블 순회로 나올 수 없다는 게 - 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커. + `v == nil`이 아님 — 진짜 `nil`은 테이블 순회로 나올 수 없다는 게 + 이 문제의 출발점이었으므로, 매치 대상은 항상 `None` 마커(반응형 값이 + 내놓는 진짜 `nil`은 아래 `NilHandler`가 받는다). `Dispatch.process(inst, k, nil)`로 재귀 호출하는 순간 `None`은 더 이상 - 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 키 `k`를 - 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand 등)로 흘러감 — - `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 좁혀지는 - 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. + 존재하지 않고 진짜 `nil`이 되므로, 다음 우선순위 스캔은 자연히 그 + `nil`을 담당하는 핸들러로 흘러감 — 배열 자리(`k`가 숫자)면 `NilHandler`, + 해시 자리면 키 `k`를 원래 담당하던 핸들러(프로퍼티/이벤트/UI shorthand + 등)로. `StoreBind` 핸들러가 `realv`를 들고 재귀하면 자연히 다음 핸들러로 + 좁혀지는 것과 정확히 같은 원리, 무한루프 걱정도 동일하게 없음. - **`Dispatch.process`/`Handler.process` 이름 겹침 — 소유자 네임스페이싱으로 해소, 새 이름 발명 안 함 (2026-08-07 여덟 번째 세션 후속).** 원래 "확정된 디스패치 모델" 절은 "스캔+실행"과 "매치된 핸들러 자신의 처리 @@ -404,9 +447,10 @@ end OnChangeHandler/UICornerHandler 등)은 팩토리가 `BaseModule`을 뮤테이션하는 시점에 이걸로 등록됨(아래 "base 유틸은 인터페이스" 절과 같은 패턴, 새 메커니즘 아님). **`Tag`/`Attribute`의 base 소유 - Fallback Handler들(`TagFallbackHandler` 등)도 같은 팩토리 뮤테이션 - 시점에 같이 등록됨** — quad-base 모듈 로드 자체의 부작용이 아님, - 상세는 아래 "base가 소유하는 핸들러와 주입되는 엔진 op" 절. + Fallback Handler들(`TagFallbackHandler` 등)은 이와 달리 quad-base + 자신이 등록함**(**[재역전, 2026-08-18 구현 전 QA]** — 백엔드가 하나도 + 안 붙은 상태에서도 안내 에러 경로가 돌아야 하기 때문), 상세는 아래 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절. - Handler 자신의 필드는 계속 `process`/`retract`(이미 확정된 이름, `question.md`에 "특별한 문제 없음"으로 못박혀 있어 재검토 대상 아님) — 겹침은 실제 런타임 충돌이 아니라 프로즈 표기 문제였을 뿐이라, 항상 @@ -425,6 +469,13 @@ end `PostRef`를 fire하는 짧은 루프. 둘 다 배열 재순회가 아니라 pre-pass 하나 + 실제 `PostRef` 개수만큼의 목록 순회라 비용이 작음 — 상세는 `base/ref-plan.md`의 "`PostRef`" 절. + **[2026-08-18 구현 전 QA 2라운드 후속, `RC-1` 해결] 배열 파트 순회 + 전체를 `inst` 전용 `Blocker`로 감싼다** — 순회 시작 전에 + `Relate(inst)`에 lazy 생성한 Blocker를 `:On()`하고, 배열 파트 순회가 + (pre-pass/post-pass 포함) 전부 끝나면 `:OffWithoutEmit()` 한 뒤 + `recompute(inst, bk)`를 명시적으로 1회 호출 — 상세 근거·`setLength`/ + `setOffsetSource`가 이 Blocker를 어떻게 쓰는지는 아래 "배치 등록을 + 안전하게 만드는 Blocker 게이팅" 절이 소스. **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 @@ -463,6 +514,52 @@ end 재호출도 이 메커니즘 위에서 동일하게 동작(`None`으로 유지되는 매 사이클마다 담당자가 자연히 정확하게 갱신됨, 별도 특수 처리 불필요). +### `NilHandler` — 배열 자리의 진짜 `nil`을 받는 짝 핸들러 (2026-08-18 신설, 사용자 요구) + +**왜 필요한가**: 반응형 값이 `None`이 아니라 **진짜 `nil`** 을 내놓는 +경우(`State`)도 정상 동작해야 한다는 사용자 요구. `None`을 +쓰라고 강제하지 않는다 — *"State 일 수도 있지만, +State 이여도 작동은 함"*. + +```lua +NilHandler.priority = <매우 높음> +NilHandler.isHandlable(inst, k, v) = (type(k) == "number" and v == nil) +function NilHandler.process(inst, k, v, index) + -- 이 자리는 아무것도 마운트하지 않는다 — 순서 계산에서 빠지도록 등록만 한다. + -- 순서 주의: setOffsetSource가 먼저, setLength가 나중(아래 "해제(그 자리가 + -- 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절의 계약 — + -- setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- 반대로 하면 죽는 중인 서브트리의 Source에 :Set()이 날아간다). + -- [2026-08-18 감사에서 순서 정정] + Dispatch.setOffsetSource(inst, k, None) + Dispatch.setLength(inst, k, 0) + return function() end +end +``` + +- **매치 범위는 `k`가 숫자인 자리로 한정** — 해시 자리의 `nil`은 그 키를 + 원래 담당하던 핸들러(프로퍼티/이벤트)의 몫이다(`None` 재귀가 도착하는 + 기존 경로 그대로, 위 절). 이벤트 키에서 `nil`이 disconnect를 뜻한다는 + 규정은 `base/event-plan.md`가 소스. +- **재귀는 하지 않는다** — 이미 `nil`이라 더 내려보낼 곳이 없다. + `NoneHandler`가 재귀만 담당하고 여기로 흘려보내므로, 배열 자리가 비는 + 처리 로직은 **이 한 곳에만** 있다(사용자 선택, 2026-08-18). +- **호출 순서는 `setOffsetSource` → `setLength`** — 아래 "해제(그 자리가 더 + 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)`" 절이 계약으로 + 고정해둔 순서를 그대로 따른다. (`base/ref-plan.md`의 + `ProcessedPreRefHandler`/`ProcessedPostRefHandler` 의사코드는 아직 반대 + 순서로 적혀 있음 — 이 세션 이전부터 있던 것이라 같이 고쳤다.) +- **`setLength(0)` / `setOffsetSource(None)`의 비대칭은 의도된 것** — + 타입이 각각 `number | State`와 `Source | None`이라서 + (`base/ref-plan.md`의 "왜 `None`이 아니라 `nil`인가" 절, 아래 + "Length/Offset" 절). +- **retractor는 no-op이어도 된다** — 이전 것의 철거는 하강 diff가 + `retractFrom`으로 해준다(위 `NoneHandler` 항목과 같은 이유). +- **"중간 노드는 `inst`에 부작용을 가하지 않는다"(아래 "Dispatch 체인" 절)와 + 충돌하지 않는다** — `setLength`/`setOffsetSource`는 `inst`의 프로퍼티를 + 건드리는 게 아니라 Dispatch 자신의 순서 부기이고, 애초에 `NilHandler`는 + 재위임을 하지 않는 **말단** 핸들러다. + ### Dispatch는 프리미티브가 아니다 — 탑레벨 싱글톤 확정 (2026-08-08 두 번째 세션) `Dispatch.process`/`getHandler`/`addHandler`/`drive`를 `Source`/`Ref`/`Store`/ @@ -503,18 +600,35 @@ end 더 이상 별도로 등록되는 핸들러가 아님 — Property 핸들러 내부에서 소비되는 값-레벨 래퍼로 재설계됨(`base/tween-plan.md`). - **모듈 재생성(`New()`)과의 관계 — 새 설계 불필요, 이미 있는 선례로 자연히 - 풀림.** v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 + 풀림.** (**[재정정, 2026-08-19]** 이 헤딩을 한때 `Quad()`로 바꿨던 게 + 틀렸음 — `New()`가 맞는 이름, `architecture.md` "확정된 결정" 13번의 + 재정정이 소스. 요지: `Quad`(`require`의 반환값)는 이미 만들어진 기본 + 인스턴스이고, 그 안의 `New` 필드를 명시적으로 호출해야만 별도의 새 + Quad 네임스페이스가 생긴다 — "그냥 `Quad()`를 부르면 매번 새 인스턴스"가 + 아니다.) v1처럼 `require`를 감싸 `Init(QuadId?)`로 격리 인스턴스를 만드는 방식은 안 씀(위 "확정된 것" 절 — id 기반 조회 자체가 Ref로 대체되며 기각됨). 대신 이미 확정된 "base 유틸은 인터페이스, 실제 구현은 팩토리가 `BaseModule`을 뮤테이션해서 주입"(`RobloxFactory(BaseModule)`) 패턴을 그대로 따름 — Dispatch의 handler 레지스트리도 `BaseModule` 테이블에 딸린 state 중 하나일 뿐이라, `_initializedBy` 마커에 대해 이미 확정된 것과 완전히 같은 논리가 적용됨(위 "base 유틸은 인터페이스" 절, "`New()`가 - 생기면 각 인스턴스가 별도 테이블이 되므로 이 마커도 테이블별로 독립적으로 - 스코핑됨, 재설계 불필요"). `New()`가 실제로 생기면 그 시점에 BaseModule - 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 자연히 같이 - 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 인스턴스 - 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 없음. + 실제로 호출되면 그 호출이 만드는 인스턴스가 별도 테이블이 되므로 이 + 마커도 테이블별로 독립적으로 스코핑됨" — 단 아래 "[한정]" 문단대로 코드 + 손질은 필요, 재설계까지는 불필요). 다중 인스턴스화가 실제로 생기면 그 + 시점에 BaseModule 전체를 인스턴스별 테이블로 만드는 메커니즘에 Dispatch도 + 자연히 같이 딸려가고, 호출부는 `module.Dispatch.process(...)`처럼 그 + 인스턴스 테이블을 통해 접근하게 됨 — 지금 미리 프리미티브화해둘 이유가 + 없음. **[한정, 2026-08-18 구현 전 QA]** 다만 "재설계 불필요"가 **"코드 + 변경 불필요"는 아니다** — 사용자 판정에 따르면 그때는 module-level + state를 참조하는 코드들이 모듈 인스턴스를 인자로 받도록 + (`InitModule(module)` 류) 손을 봐야 한다(`base/architecture.md` "확정된 + 결정" 13번). 지금은 `New()` 자체가 노출 안 된 싱글톤 단계라 + `Quad.Dispatch`로 바로 접근한다. **[2026-08-19 추가]** 이 문단이 말하는 + "`InitModule(module)` 류"의 정확한 형태(각 서브시스템별 `InitXxx(module)` + 팩토리 체이닝 + `Relate` 기반 인스턴스별 멱등 가드)가 + `module-lifecycle-plan.md`의 "New()의 내부 구성" 절에 구체화됨 — + `Dispatch/init.luau`도 그 패턴을 따르는 `InitDispatch(module)` 하나로 + 구현된다. ### base가 소유하는 핸들러와 주입되는 엔진 op (2026-08-13 열네 번째 세션 신설) @@ -565,24 +679,42 @@ setAttribute(inst: any, name: string, v: any?): () -- v == nil이면 그 이름 실제로 꽂히는 건 그 알고리즘을 그대로 감싸는 **별도 이름의 엔티티** (`TagFallbackHandler`/`AttributeKeyFallbackHandler`/ `AttributeGroupFallbackHandler`) — "이게 기본 안전망으로 자동 설치되는 -대상"임을 이름 자체로 구분한다. **등록 주체는 quad-base 모듈 자체가 -아니라 필요한 엔진(백엔드 팩토리)** — quad-roblox 같은 백엔드가 -`BaseModule`을 구성할 때 자기 전용 Handler들(Property/Event/OnChange/ -UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다(위 -`Dispatch.addHandler` 절과 같은 경로, `base/module-lifecycle-plan.md`가 -이미 확정해둔 "base는 인터페이스만, 등록/구현은 백엔드 팩토리가 -`BaseModule`을 뮤테이션하는 시점에" 원칙을 그대로 따르는 것뿐 — 새 -예외가 아님). "quad-base가 자기 모듈 로드 시점에 스스로 등록"이라던 -옛 모델은 정확히 `base/lifecycle-pattern.md`가 이미 거부해둔 -`InitNamespace`류 top-level 부작용 패턴과 같은 클래스라 틀렸음 — 원문· -근거는 `archive/tag-attribute-load-time-registration-reversed.md`. +대상"임을 이름 자체로 구분한다. + +**[재역전, 2026-08-18 구현 전 QA — 사용자 확정] 등록 주체는 다시 +quad-base 자신이다(모듈이 자기 레지스트리를 구성하는 시점).** 2026-08-14 +열두 번째 세션은 이걸 "백엔드 팩토리가 자기 Handler들과 같이 등록한다"로 +뒤집었었는데, 그러면 **quad-roblox를 아예 로드하지 않은 상태에서는 이 +Fallback Handler들도 존재하지 않아**, 위 "매치 실패는 즉시 `error`" 절이 +약속한 *"provider가 초기화됐는지 확인하라"* 안내 경로 자체가 동작하지 +않는다(사용자: *"안 그러면 quad-roblox 를 로드하지 않았을 때 로드했는지 +물어보는 요소가 처리가 안 된다"*). Fallback 밴드의 존재 이유가 "아무도 이 +자리를 안 가져갔을 때"인데, 그 등록을 "누군가 자리를 가져가는 시점"에 +의존시키면 밴드가 가장 필요한 상황에서 비어 있게 된다. + +**`InitNamespace` 거부 원칙과 충돌하지 않는 이유**: 그 원칙이 금지한 건 +**라이브러리마다 사용자가 수동으로 init을 호출하게 만드는 것**과 **모듈이 +로드되면서 *남의* 상태를 건드리는 것**이다(`base/lifecycle-pattern.md`의 +"rbvm에서 그대로 가져오면 안 되는 것" 절). base가 **자기 모듈 안의 자기 +레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니다 — 외부에 노출되는 init +표면이 늘지 않고, 순서 의존도 없고(레지스트리와 등록 코드가 같은 모듈), +사용자가 할 일도 없다. 백엔드가 나중에 자기 Handler를 등록해 이기는 구조도 +그대로다(Fallback 밴드는 항상 최하위). A-3의 다중 인스턴스화(`New()`)로 +가더라도 자리는 그대로 — 그때는 "모듈 로드 시"가 "인스턴스 생성 시"가 될 +뿐이다. + +옛 역전 원문은 `archive/tag-attribute-load-time-registration-reversed.md` +(그 문서 자체가 이번에 재역전됐다는 배너를 달아뒀음). **그 역전이 같이 +고쳤던 "이름" 쪽 결론은 그대로 유효** — 등록되는 엔티티는 알고리즘 구현체 +(`TagHandler` 등)가 아니라 그걸 감싼 `*FallbackHandler`다. + `HANDLER_PRIORITY_FALLBACK`이라는 밴드 자체가 정확히 이런 용도 — "아무도 이 자리를 안 가져갔을 때의 안전한 기본 동작"을 base가 값싸게 제공하는 것. 엔진 저자 입장에서 "자동/공짜"인 이유는 직접 알고리즘을 -안 짜도 되기 때문이지 quad-base 모듈 자체가 부작용을 내서가 아님 — -모든 백엔드가 (자기 팩토리 뮤테이션 한 번으로) `Tag`/`Attribute` 부기를 -얻고, 특별히 뭔가를 더 하지 않아도 이 값들이 어떤 자리에 놓이든 최소한 -매치는 됨. +안 짜도 되기 때문이고, **백엔드를 아직 안 붙였어도 이 밴드는 이미 채워져 +있다**(위 재역전) — 그래서 모든 백엔드가 `Tag`/`Attribute` 부기를 공짜로 +얻고, 백엔드가 하나도 없을 때조차 "이 값이 어떤 자리에 놓이든 최소한 +매치는 되고, 엔진 op이 없으면 그 자리에서 명확한 에러가 난다"가 성립한다. `addTag`/`removeTag`/`setAttribute`는 base가 시그니처만 소유하고 실제 구현은 팩토리가 뮤테이션으로 주입하는 **타입 계약**(`bindLifetime`/ @@ -625,7 +757,7 @@ UICorner)과 **같이** 이 base 소유 Fallback Handler들도 등록해준다( - **타입 패밀리는 백엔드 몫**: `AttributeKey<>` 제네릭 생성자와 스칼라 편의 패밀리(`StringAttribute`/`NumberAttribute`/`BooleanAttribute`) 까지가 base이고, `Color3Attribute`류처럼 **엔진 고유 타입**에 묶인 - 패밀리는 그 백엔드(quad-roblox의 `D`/`DI` 층)가 자기 것으로 추가함 — + 패밀리는 그 백엔드(quad-roblox의 `D` 층)가 자기 것으로 추가함 — "이 값이 이 백엔드에서 표현 가능한가"라는 검증도 base가 아니라 주입된 `setAttribute`의 몫(`base/attribute-plan.md` "패키지 배치" 절). @@ -730,6 +862,14 @@ end 정의상 그 핸들러의 `isHandlable`을 만족함. 즉 말단 핸들러는 **`nil` 여부만 구분하면 되고**, 옛 모델이 요구하던 `isX(hintValue)` 방어 가드는 필요 없어짐(옛 규칙은 힌트의 타입 미보장을 메우던 임시방편이었음). + **[한정, 2026-08-18 구현 전 QA] 보장 범위는 "같은 핸들러"까지지 "같은 값 + 모양"까지가 아니다** — `isHandlable`이 **여러 모양의 값**을 받아들이는 + 핸들러라면 그 안에서 어느 모양인지 가르는 `is` 판별은 **여전히 필수**이고, + 그건 그 핸들러 자신의 몫이다(사용자: *"처음부터 한 핸들러가 여러 값을 + 가질 수 있어 is 처리가 필요한건, 그 핸들러의 몫입니다"*). 실제 사례가 + 이미 있음 — `PropertyHandler`는 평범한 값과 `Tween` 래퍼를 **둘 다** + 받아 `isTween(realv)`로 분기한다(`base/tween-plan.md`). 없어진 건 + **타입 미보장을 메우려던 방어 가드**뿐이다. - **깊은 체인에서도 힌트가 안 사라짐** — 힌트를 위에서 아래로 실어 보내는 게 아니라 **각 레벨이 자기 재프로세스에서 자기 힌트를 받기** 때문. `State>`에서 바깥이 새 inner State를 내놓아도 인덱스 2는 @@ -758,6 +898,7 @@ end |---|---|---| | `StoreBind` | 중간 | 없음(구독 + 재위임만) | | `NoneHandler` | 중간 | 없음(재위임만) | + | `NilHandler` | 말단 | 없음(`setLength`/`setOffsetSource` 부기만 — 2026-08-18 신설) | | `PropertyHandler` | 말단 | 프로퍼티 세팅 | | `TagHandler` | 말단 | `addTag`/`removeTag` | | `AttributeKeyHandler` | 말단 | `setAttribute` | @@ -908,8 +1049,13 @@ end 깊은 인덱스엔 안 옴 / `nil`이라 가정 금지") 중 앞의 둘은 하강 diff로 구조적으로 사라졌음: - 값이 넘어오는 건 **오직 같은 핸들러로 재프로세스될 때**이므로 그 값은 - 정의상 `isHandlable`을 만족함 → `isTag(...)` 같은 **방어 가드는 이제 - 불필요**(넣어도 무해하지만 죽은 코드). + 정의상 `isHandlable`을 만족함 → **타입 미보장을 메우려던 방어 가드** + (`isTag(...)`를 "혹시 래퍼가 새어 들어왔을까 봐" 부르는 것)는 이제 + 불필요. **[한정, 2026-08-18 구현 전 QA] 다만 한 핸들러가 여러 값 모양을 + 받는다면 그 판별은 여전히 필수이고, 그건 그 핸들러 자신의 책임** + (`PropertyHandler`의 `isTween(realv)` 분기가 실제 사례 — 위 "Dispatch + 체인" 절의 같은 한정 참고). 보장 범위는 "같은 핸들러"까지지 "같은 값 + 모양"까지가 아니다. - 깊이와 무관하게 **각 레벨이 자기 인자를 받음** → 깜빡임 방지 최적화가 깊은 체인에서도 유효. - 다만 **`nil`이라고 가정하는 것은 여전히 금지**(단순 철거일 때만 `nil`). @@ -967,6 +1113,16 @@ end 중간 노드가 `inst`에 직접 부작용을 냈다면 그 흔적을 지울 주체가 없어짐. 조건부로만 재위임하는 핸들러를 만들면 재위임을 건너뛰는 자리에서 `Dispatch.retractFrom(inst, k, index + 1)`로 아래를 직접 정리할 것. + +**9. `process` 안에서(또는 `process`가 부르는 컴포넌트 함수/`updateFn` +안에서) 코루틴 yield 금지(2026-08-18 신설, `/code-review high`로 이 +불변식이 "Length/Offset" 절에만 묻혀 있던 걸 발견해 여기로도 끌어올림).** +아래 "Length/Offset" 절의 배치 게이팅(`Blocker`)이 "position이 항상 +순서대로, 다른 코드가 끼어들 틈 없이 동기로 처리된다"는 전제 위에 +서 있음 — 이 체인 도중 yield가 끼면 같은 owner의 `Blocker`를 다른 +코드가 그 사이에 건드릴 수 있어 게이팅 순서 보장이 깨짐. 상세 근거는 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절. + ### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) **문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, @@ -1005,16 +1161,33 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 store-bind 핸들러가 값이 바뀔 때마다 다시 호출. **호출 책임은 `Slot` - 자신의 `:List`/CRUD가 아니라 그 위치를 처음 매치한 Handler(`Dispatch/ - Slot.luau`)** — `Slot`은 `inst`/`i`를 모르는 독립 값(어디 마운트될지 + 자신의 `:List`/CRUD가 아니라 그 위치의 체인을 실제로 끝내는 말단 + Handler(`Dispatch/Slot.luau`)** — **[정정, 2026-08-18 구현 전 QA]** + 옛 서술은 "그 위치를 **처음** 매치한 Handler"였는데 부정확했다: 배열 + 위치에 `State`이 오면 처음 매치하는 건 `StoreBind`(중간 노드)이고, + 중간 노드는 `inst`에 부작용을 가하지 않는다는 계약(아래 "Dispatch 체인" + 절)과 정면으로 어긋난다. 사용자 판정은 *"최종 말단 요소가 이를 + 처리하는게 더 올바른것으로 보이는데"* — 재귀가 끝나 실제 값을 받은 + 말단 Handler가 등록한다(`State`이면 재귀 끝의 `Dispatch/Slot.luau`, + 빈 자리면 `NilHandler`, `PreRef`/`PostRef` 소진 자리면 각 nop Handler). + 같이 검토 대상이던 *"단순히 모든 핸들러가 `k=number`일 때 처리하도록 + 두는"* 안은 채택 안 함 — 그 안이 메우려던 갭(`State`에서 + `None`이 올 때 아무도 `0`을 안 채우는 것)이 위 `NilHandler` 신설로 이미 + 닫혔고, 말단 규칙 하나로 전부 커버되기 때문. `Slot`은 `inst`/`i`를 + 모르는 독립 값(어디 마운트될지 자기가 결정 안 함)이라, `process(inst, i, slotValue)`가 매치되는 시점에 그 Handler가 `Dispatch.setLength(inst, i, slotValue.Length)`를 1회 호출(길이 자체가 바뀌는 매 순간은 이미 `slotValue.Length`가 `State`라 알아서 전파됨, Handler가 매번 다시 부를 필요 없음). `state` 교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출. - **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 - **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 - 하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우 + **스스로 만들어서** 등록. **[2026-08-18 구현 전 QA 2라운드 후속 — + `RC-1` 해결]** 예전엔 "Dispatch는 그냥 레지스트리에 넣어두기만 하고 + `recompute`가 그 자리에 값을 `:Set()`한다"였는데, 이제 **등록되는 그 + 자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 즉시 + `:Set()`한다**(아래 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절의 + "`setOffsetSource`의 즉시 계산" 참고) — `recompute`는 이후 값이 바뀔 때 + 전체를 다시 계산하는 역할로 남는다. Slot이 매치되는 경우 이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래 참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이 등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정, @@ -1045,17 +1218,24 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 첫 번째 세션 조사에서 발견). 지금은 그 슬롯이 전용 센티널 `ProcessedPreRef`로 소진되고, **`ProcessedPreRefHandler`(`base/ ref-plan.md`의 "PreRef" 절)가 정상 매치 과정에서 직접 `setLength(0)`/ - `setOffsetSource(None)`을 등록** — "이 위치를 처음 매치한 Handler가 - 등록 책임을 진다"는 위 원칙을 특수 취급 없이 그대로 만족. + `setOffsetSource(None)`을 등록** — "그 위치의 말단 Handler가 등록 책임을 + 진다"는 위 원칙을 특수 취급 없이 그대로 만족. **[2026-08-14 아홉 번째 세션] `PostRef` 소진 자리도 동일** — `ProcessedPostRefHandler`(`base/ref-plan.md`의 "`PostRef`" 절)가 같은 두 등록을 하는 거울상 Handler라, 새 규칙 없이 그대로 맞물림. + **[정정, 2026-08-18 구현 전 QA] 값 자체가 `None`/`nil`인 자리도 이제 + 같은 원칙으로 덮인다** — `Dispatch.drive`가 `None`을 건너뛰지 않으므로 + 그 자리는 `NoneHandler`(재귀만) → `NilHandler`(말단)를 거치고, + **등록을 실제로 하는 건 `NilHandler`**(위 "`NilHandler`" 절). + `State`처럼 반응형 값이 뒤늦게 `None`을 내놓는 경로도 + 같은 자리로 수렴한다. **해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` → `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** 별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 -반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 -부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 +반대면 위험함**: `setLength`가 끝에서 `gatedRecompute`를 경유해(배치 +게이팅 중이 아니면) `recompute`를 돌리므로, 먼저 부르면 그 `recompute`가 +아직 남아있는 옛 `Source`(지금 막 떼어내는 서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의 `offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 @@ -1069,10 +1249,49 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) 이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. -**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 -`inst`의 array part 크기 `N` — `bk.N`으로 같이 저장, `Dispatch.drive`가 -최초 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 -lazy 생성. +**저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에 +귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장) +— `Relate(parentInst)`에 lazy 생성. + +**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner +타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔 +`bk.N`을 "`Dispatch.drive`가 최초 배열 파트 순회 시점에 이미 아는, 저작 +시점에 고정된 값"으로만 서술했는데, `base/slot-plan.md`의 "재귀 +메커니즘" 절이 같은 `recompute`/`getBookkeeping`을 **Slot 자신**을 +ownerKey로 재사용하면서 이 전제(N이 고정)가 안 맞는 케이스가 생겼다 — +Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이유). **사용자 +확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될 +때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에 +동일하게 적용"* — 즉: +- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`를 + 등록할 때마다 `bk.N`이 `i`로 늘어난다(`Dispatch.drive`의 배열 파트 + 순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이 + 하나의 규칙) — **`Dispatch.setOffsetSource`는 `bk.N`을 건드리지 + 않는다**, 호출 순서가 항상 `setOffsetSource(i)` → `setLength(i)`라서 + (아래 "`setLength` 구현" 절) `bk.N`을 `setLength`에서만 올려야 + `lengthList[i]`가 아직 안 채워진 채로 `bk.N`만 먼저 커지는 창이 안 + 생긴다. +- `spliceArraysDown`(Slot의 `rawRemove`/`rawUnmount`가 부름, `base/ + slot-plan.md` "파괴" 절)이 position 하나를 구조적으로 제거할 때마다 + `bk.N`이 그만큼 줄어든다. +- `Dispatch.drive`의 `inst`에서는 이 규칙이 사실상 안 보인다 — 최상위 + 배열 리터럴은 구조적으로 늘거나 줄지 않으므로(재-dispatch는 전체 + 교체) `bk.N`이 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 + 케이스가 아니라 같은 규칙의 특수한 안정 상태다. + +**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치 +등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을 +안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가 +`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로, +배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도 +안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 이미 +최종 크기로 고정돼 있었던 것**의 부산물이었을 뿐 — 지금은 그 전제 자체가 +없다. 그런데도 Blocker 게이팅이 여전히 필요한 이유는 크래시 방지가 +아니라 **비용**이다(등록마다 `recompute`가 한 번씩 도는 O(N²) 대신 +배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 사용자가 직접 지적한 "이러면 +첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. 상세 트레이싱은 +`qa-request/pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기가 +명세에 없음" 절. **`sourceList`에도 `nil`이 아니라 `None`을 쓰는 이유는 기존 배열 파트 원칙 재사용** — 모든 number 인덱스를 반드시 채워야 하는데(위 UB 규칙) @@ -1094,6 +1313,14 @@ lazy 생성. **recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: +**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래 의사코드를 배치 +등록 중 안전하지 않게 만들던 크래시(`RC-1`)는 해결됨 — 해법은 "배치 +등록을 안전하게 만드는 Blocker 게이팅" 절(바로 아래)이 소스, 여기 +`recompute` 자체의 코드는 안 바뀜(off-by-one 수정 버전 그대로). 바뀐 +건 **언제 호출되는가**뿐 — `setLength`/`setOffsetSource`가 새로 개입한다. +트레이싱 경위·논의 원문은 `qa-request/pre-implementation-qa-round2.md`의 +"RC-1" 절. + **[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어 있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한 뒤 `offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 @@ -1166,18 +1393,32 @@ end 문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치 vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것. -전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 -길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +전체 순회의 O(N) 비용은 무시 가능(`Dispatch.drive`의 최상위 `inst` +기준으로는 `N`이 저작 시점에 고정된 배열 리터럴 길이, 보통 작음 — +Slot 자신이 `ownerKey`인 재귀 케이스는 `N`이 생애주기 내내 바뀌지만 +그 실제 개수 자체도 보통 작아서 결론은 같음, `N`의 정확한 수명주기는 +위 "저장 위치" 절 참고) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, `Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 일어나게 막음. **`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), -`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션).** **[재작성, 2026-08-18 +구현 전 QA 2라운드 후속 — `RC-1` 해결]** `setLength`는 더 이상 `recompute`를 +직접 부르지 않는다 — State든 상수든 항상 아래 `gatedRecompute` 하나를 +경유하고, 그 함수가 `blocker:IsOn()`을 확인해 배치 등록 중이면 건너뛴다 +(Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이 +게이트를 통과한다 — 사용자: *"setLength 는 recompute 를 직접 수행하진 +않고, Observer 에서 recompute 를 수행해. 맨 처음 emit 에서도 blocker 가 +on 이면 무시하는식"*). `blocker`가 무엇이고 어디서 오는지는 바로 아래 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절 참고 — 이 함수는 그 +Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지 않음, +그건 호출하는 배치 쪽 책임): ```lua -function Dispatch.setLength(inst, i, len) - local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 +function Dispatch.setLength(ownerKey, i, len) + local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성 + local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고) local oldObserver = bk.observers[i] if oldObserver then @@ -1186,24 +1427,132 @@ function Dispatch.setLength(inst, i, len) end bk.lengthList[i] = len + bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고 - if isState(len) then - local observer = len:Observer(function() - recompute(inst, bk) - end) - bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 - bk.observers[i] = observer + local function gatedRecompute() + if not blocker:IsOn() then + recompute(ownerKey, bk) + end end - recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) + if isState(len) then + local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨 + bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + else + gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름 + end end ``` `:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 -본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 -같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 -끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, -`inst` 생명주기에 자동 귀속)를 충족. +본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst +또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적 +`:Unsubscribe()`가 없으면 안 끊기므로 안 맞음. `bindLifetime`/ +`unbindLifetime`이 이미 이 요구(GC-native, `ownerKey` 생명주기에 자동 +귀속)를 충족. + +### 배치 등록을 안전하게 만드는 Blocker 게이팅 (2026-08-18, `RC-1` 해결) + +**문제 재확인**: `bk.N`(그 owner의 array part 크기)은 배치가 시작되는 +시점에 이미 정해져 있는데, `bk.lengthList[1..N]`은 각 position이 처리될 +때마다 하나씩 채워진다 — 순차 처리 도중에 `recompute`가 돌면 아직 안 +채워진 뒤쪽 position을 `nil`로 읽어 산술 에러가 난다(`Frame{A,B}`처럼 +정적 자식 2개짜리도 재현됨, 트레이싱 상세는 +`qa-request/pre-implementation-qa-round2.md`의 "RC-1" 절). + +**[정정, 2026-08-18 구현 전 QA 3라운드] 위 크래시는 `bk.N`이 "배치 시작 +전에 이미 최종 크기로 고정"이라는, 그때 당시의 전제 위에서만 성립한다 — +그 전제 자체가 위 "저장 위치" 절에서 뒤집혔다(`bk.N`은 이제 그때그때 +실제 개수). 아래 게이팅은 여전히 필요하지만, 지금은 **크래시 방지가 +아니라 비용** 때문이다 — 게이팅 없이 등록마다 `recompute`가 한 번씩 +돌면 O(N²), 게이팅으로 배치 끝에 한 번만 돌면 O(N). 상세는 "저장 위치" +절 참고. + +**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그 +자리에서 직접 계산한다(사용자 설계, 2026-08-18)**: + +1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기 + 자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그 + owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작 + 전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고 + **직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이 + 직접 쓰는 두 번째 용례" 절 참고. +2. 배치가 도는 동안, 각 position의 `setLength`가 트리거하는 + `gatedRecompute`(위)는 `blocker:IsOn()`이 참이라 전부 스킵된다 — 즉 + **배치 도중엔 `recompute`가 단 한 번도 안 돈다**, 그래서 + `bk.lengthList`의 빈 자리를 읽을 일 자체가 없다. +3. **`setOffsetSource`는 그동안 손 놓고 있지 않는다 — 등록되는 그 + 자리에서 자기보다 앞선 position들의 길이 합을 직접 계산해 `:Set`한다** + (아래 "`setOffsetSource`의 즉시 계산" 참고). 배치가 항상 position을 + 순서대로(1,2,...,N) 처리하므로, position `i`를 등록하는 시점엔 `1..i-1`이 + 이미 전부 끝나 있어 이 합산이 항상 정확하다. **이게 "recompute를 + 미루면 초기 레이아웃이 이상해진다"는 우려를 없앤다** — `:List`가 + 실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가 + 배치 중이라도 항상 최신값을 보게 됨. +4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는 + `attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을 + 부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로 + 호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고, + `ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위 + 재귀 케이스)도 같이 확정시킨다. + +**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서 +`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면 +스킵 — 참여 안 하는 자리는 계산할 게 없음): + +```lua +function Dispatch.setOffsetSource(ownerKey, i, source) + local bk = getBookkeeping(ownerKey) + bk.sourceList[i] = source + if source ~= None then + local sum = 0 + for j = 1, i - 1 do + local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음 + sum += (if isState(v) then v:Get() else v) + end + if source:Get() ~= sum then + source:Set(sum) + end + end +end +``` + +이건 `recompute`의 로직을 대체하는 게 아니라 **보완**한다 — 이 즉시 +계산은 "지금 막 등록되는 이 position의 초기값"만 맞춰줄 뿐이고, 이후 어느 +position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보다 뒤에 +있는 모든 position의 offset을 다시 계산해야 하므로 여전히 `recompute`의 +전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로). + +**적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로 +별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히 +둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위 +`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의** +`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 — +중첩된 Slot마다 그 Slot 자신의 owner 키로 **별도** Blocker를 새로 만듦, +부모 Blocker 재사용 금지는 `base/blocker-plan.md`의 "재진입" 절 그대로). +**런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 흔한 패턴은 +이 게이팅이 필요 없다** — 사용자 확인: *"그건 이미 마운트가 된 +이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위 +요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서, +setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함"* — 이미 +마운트된 배치 밖에서 하나씩 추가되는 position은 그 앞의 모든 position이 +이미 안정적으로 등록돼 있어 `nil` 자리를 만들 여지가 없고, 그 owner의 +Blocker는 이미 `OffWithoutEmit()`으로 꺼진 채라 `gatedRecompute`가 +평소처럼 즉시 돈다. + +**⚠️ 불변식 — `Dispatch.process`/`attachSlot` 호출 체인 도중에는 코루틴 +yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체가 +"position이 항상 1,2,...,N 순서대로, 다른 코드가 끼어들 틈 없이 동기로 +처리된다"는 전제 위에 서 있다 — 이 체인 도중 어딘가(컴포넌트 함수, +`updateFn`, Handler 등) yield가 끼면, 아직 배치가 안 끝난 owner의 같은 +`Blocker`를 다른 코드가 그 사이에 건드릴 수 있어(예: 다른 이벤트 콜백이 +같은 owner에 `setLength`를 부르는 것) 배치 도중/직후의 게이팅 순서 보장이 +깨진다. 사용자: *"모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 +할듯. 안 그럼 꼬이는 문제가 발생하지 않나 생각함"* — 웹 백엔드처럼 +`setLength`가 뒤섞이면 특히 골치 아파짐. 새 방어 로직을 넣는다는 뜻이 +아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은 +톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것. **동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index ef68701..02c2945 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -77,8 +77,10 @@ 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`인 이유도 동일 — 하드 블록이 아니라 나중에 +function(inst,k,v) error(`Effect binding should be array index item, but +got {typeof(k)}`) end }`(**[2026-08-18]** 에러 메시지에 실제 `k` 타입을 +실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절). +`FALLBACK`인 이유도 동일 — 하드 블록이 아니라 나중에 named 자리 바인드 같은 실제 기능이 확정되면 평범한 우선순위의 Handler로 값싸게 override 가능한 자리로 열어둠. @@ -149,8 +151,31 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 대부분이 GC-native인 것과 정반대라 혼동하기 쉬운 지점 — 사용자 문서에 명시적으로 경고할 것(`:Subscribe()`를 부르는 순간부터 그 핸들의 생애주기는 전적으로 수동 관리 대상이 됨). -- **`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 않는다 — Effect - 계층에서 의미가 확장됨.** Observer의 `:Unsubscribe()`는 "미래 재실행만 +- **⚠️ [축소, 2026-08-18 구현 전 QA] `:Unsubscribe()`는 `:Subscribe()`의 + 짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는 + **`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를 + 부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나, + 최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe + 한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선 + 그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."* + - **왜 위험한가**: leaf 바인딩 + `State`/`State` + 조합에서, 값이 실제로 안 바뀌면 **dedup 최적화 때문에 retract가 아무 + 일도 안 한다**(`base/source-state-plan.md`의 "Observer/Effect Leaf + dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리 + 실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안 + 일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님. + - **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가 + 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이 + 실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것). + `ObserverEffectLeafHandler` 의사코드 기준으론 `process`의 + `if old ~= v then bindLifetime(...) end`와 클로저의 + `if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만, + **`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그 + cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다. + M3 착수 전 확인할 것. +- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥 + 위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의 + `:Unsubscribe()`는 "미래 재실행만 끊는다"(Observer 자체엔 정리할 상태가 없음)로 충분하지만, Effect의 계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회 호출된다" 이고 leaf 사망은 그 "끝"의 신호 중 하나일 뿐이라, `:Unsubscribe()`도 diff --git a/.claude/base/event-plan.md b/.claude/base/event-plan.md index f2807e2..49776db 100644 --- a/.claude/base/event-plan.md +++ b/.claude/base/event-plan.md @@ -1,4 +1,4 @@ -# 이벤트 바인딩 — self 미전달, `false`로 disconnect +# 이벤트 바인딩 — self 미전달, `None`/`nil`로 disconnect > **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.** > 사용자가 "이벤트 연결은 다른 base 문서가 되어야 할 듯"이라고 직접 @@ -52,8 +52,7 @@ SyntheticEvent만 주는 것과 같은 모양). 해당 Connection도 자연히 같이 정리됨 — 별도 Disconnect 관리가 애초에 불필요. **[정정, 2026-08-06 후속 세션] 동적으로 Connect/Disconnect를 반복하고 싶은 케이스는 Ref로 수동 처리하는 대신 store-bind로 네이티브 - 지원하기로 확정** — 아래 "이벤트도 store-bind 가능 — `false`로 - disconnect" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 + 지원하기로 확정** — 아래 "이벤트도 store-bind 가능" 절 참고. 엔지니어링 비용이 예상보다 훨씬 낮다는 게 나중에 확인됨(기존 store-bind 재실행 래핑을 그대로 재사용, 새 디스패치 메커니즘 불필요). @@ -64,7 +63,7 @@ SyntheticEvent만 주는 것과 같은 모양). 문서가 아니라 quad-roblox 로컬 결정 — 다른 백엔드 구현체를 만들 때 참고할 만한 템플릿 정도로만 취급. -## 이벤트도 store-bind 가능 — `false`로 disconnect (2026-08-06 후속 세션) +## 이벤트도 store-bind 가능 — `None`/`nil`로 disconnect (2026-08-06 후속 세션, **센티널은 2026-08-18에 `false`→`None`/`nil`로 정정**) **결정**: 이벤트 핸들러 값으로 State를 넘기는 것(reactive하게 콜백을 바꿔치기/해제하는 것)을 지원한다. quad-roblox 로컬 결정, base 변경 없음. @@ -80,13 +79,39 @@ SyntheticEvent만 주는 것과 같은 모양). 다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate` 저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐). -**`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 -없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 -신호를 값으로 전달하기엔 부적합. 대신 `false`(Luau에서 실재하는 싱글톤 -타입)를 "연결 없음" 센티널로 씀: `process(inst,k,false)`가 들어오면 -`retract`가 하던 일(기존 Connection 해제)만 하고 새로 Connect 안 함. -이벤트인지 여부는 값이 아니라 키(리플렉션으로 판별)로 결정되므로, 다른 -boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 없음. +**[재확정, 2026-08-18 구현 전 QA] 센티널은 `None`(그리고 그 재귀가 만드는 +`nil`)이다 — `false` 아님.** 원래 결정(2026-08-06)은 *"`nil`은 Lua +테이블에서 '키가 아예 없음'과 구별이 안 되니 실재하는 싱글톤 `false`를 +센티널로 쓴다"*였는데, **그건 `None` 센티널이 확정되기 전의 선택**이다. +지금은 `None`이 정확히 그 역할("테이블에 실재하면서 '없음'을 뜻하는 값")로 +도입돼 있으므로(`base/modifier-plan.md` 2-1, `base/dispatch-core-plan.md`의 +"`None` 센티널" 절), 같은 문제를 푸는 센티널이 두 개가 되는 셈이라 이벤트만 +다른 걸 쓸 이유가 없음. 사용자 판정: *"이젠 None 이 있어서 false 을 +사용해야할 이유가 없어졌다고 봄 … 일관적이게 None/nil 을 주는게 맞다는 +생각"*. + +동작: + +- `MouseButton1Click = None`이면 `NoneHandler`가 매우 높은 우선순위로 먼저 + 매치해 `Dispatch.process(inst, k, nil, index+1)`로 재귀한다 — 즉 + **`EventHandler`가 실제로 받는 값은 `nil`**이다. +- 따라서 **`EventHandler.isHandlable`은 `v == nil`인 경우에도 매치돼야 + 한다** — 매치 판정은 값이 아니라 키(리플렉션으로 이벤트 이름인지 판별)로 + 하므로 원래도 값 모양에 의존하지 않았지만, "`nil`이면 매치 안 함" 같은 + 가드를 넣으면 안 된다는 게 이제 명시적 계약이다. +- `(k=이벤트키, v=nil)`을 받으면 `retract`가 하던 일(기존 Connection 해제)만 + 하고 새로 Connect 하지 않는다. +- **배열 자리의 `nil`을 잡는 `NilHandler`와 겹치지 않는다** — + `NilHandler`는 `type(k) == "number"` 전용이고 이벤트 키는 문자열이다 + (`base/dispatch-core-plan.md`의 "`NilHandler`" 절). +- 옛 근거였던 *"이벤트인지 여부는 키로 결정되므로 다른 boolean 프로퍼티 + 핸들러와 `(k, false)` 매칭이 겹칠 위험이 없다"* 는 `false`를 안 쓰는 + 이상 필요 없어져 삭제됨. + +**store-bind될 때의 타입도 같이 바뀐다** — 값 타입이 `((...) -> ())? | +false`가 아니라 `((...) -> ()) | None | nil`(그리고 `State<...>`)이다. +`D` 생성기가 이벤트 필드 타입을 찍을 때 이 유니온을 포함해야 함 +(`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). **quad가 미는 기본 패턴은 아님 — 부차적 옵션.** 저빈도 UI 이벤트(클릭류)를 조건부로 켜고 끄고 싶은 흔한 케이스는 사실 이 메커니즘 없이도 됨 — 핸들러 diff --git a/.claude/base/lifecycle-hooks-plan.md b/.claude/base/lifecycle-hooks-plan.md index 0298d86..d8d5e53 100644 --- a/.claude/base/lifecycle-hooks-plan.md +++ b/.claude/base/lifecycle-hooks-plan.md @@ -25,7 +25,7 @@ React/Vue류 프레임워크의 `OnCreated`/`OnRendered`/`OnDisposed` 생명주기 훅을 quad에도 두면 좋겠다는 제안. 처음엔 `Frame{[OnCreated] = fn}`처럼 -싱글톤 프리미티브를 해시 파트 DI 키로 쓰는 안을 검토했으나, `:Compute` +싱글톤 프리미티브를 해시 파트 특수 키로 쓰는 안을 검토했으나, `:Compute` 콜백에 `State`이 들어올 때의 처리가 까다로워질 것 같다는 우려로 스스로 기각 — 대신 `OnCreated(fn)`이 이미 있는 `PreRef` 인스턴스를 반환하는 **순수 팩토리 함수**(children 배열에 놓는 슈가)라면 그 우려 자체가 안 @@ -79,8 +79,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 이게 바로 사용자가 처음에 걱정했던 **"`:Compute` 콜백에 `State`이 들어오면 처리가 까다로워지지 않을까"** 문제가 애초에 안 생기는 이유와 -정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 DI 키**(예: -`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — DI 키는 Store/Dispatch +정확히 같은 뿌리: 그 우려는 `OnCreated`가 **해시 파트 특수 키**(예: +`[OnCreated] = fn`)였다면 실제로 발생했을 문제임 — 특수 키는 Store/Dispatch 디스패치 경로를 거쳐야 하고, 그 값이 `State`으로 감싸이는 경우까지 핸들러가 다뤄야 함. 반면 팩토리 함수 호출은 **Store/Dispatch 경로를 아예 안 탐** — 순수 Lua 함수 호출이 즉시 평가되어 끝나고, 그 @@ -103,12 +103,12 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절에 이미 이렇게 확정돼 있음: -> quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다. +> quad v1의 `OnCreated` 특수 키는 이식하지 않는다. > `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 > 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 > 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. -이 문장이 거부한 건 v1식 **"특수 DI 키"** 메커니즘(해시 파트에 매직 +이 문장이 거부한 건 v1식 **"특수 키"** 메커니즘(해시 파트에 매직 키를 두고 Dispatch가 그 키를 특별 취급하는 것)이지, **"팩토리 함수가 기존 `Ref`/`PreRef`를 반환해서 children 배열에 놓는 것"**과는 층위가 다름 — 이 문서의 `OnCreated(fn)`은 정확히 저 문단이 이미 권장한 @@ -183,7 +183,7 @@ Frame { **생성자가 매번 새로 불려 독립된 인스턴스**를 만들어냄 — children 배열의 서로 다른 숫자 슬롯에 놓이므로, 같은 인스턴스에 여러 개를 나란히 등록하는 게 자연히 지원됨. 이건 `Ref():Callback(fn)` 단일 -슈가 관용구나 v1의 단일 DI 키 관례와 달리, **팩토리-함수 접근이 주는 +슈가 관용구나 v1의 단일 특수 키 관례와 달리, **팩토리-함수 접근이 주는 공짜 이점**임(v1처럼 "이 키엔 콜백 하나만" 같은 제약이 아예 성립할 자리가 없음 — 애초에 키가 아니라 매번 새로 만들어지는 값이므로). @@ -291,16 +291,16 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를 ## 이름 컨벤션 - **`On` 접두 자체는 이미 선례가 있음** — `base/onchange-plan.md`의 - `OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 DI 키). 단 + `OnChange(name)`(`GetPropertyChangedSignal` 바인딩용 특수 키). 단 **메커니즘은 다름**: `OnChange`는 이름을 인자로 받아 캐시된 키 객체를 - 반환하는 **해시 파트 DI 키 팩토리**(`base/onchange-plan.md` "확정" + 반환하는 **해시 파트 특수 키 팩토리**(`base/onchange-plan.md` "확정" 절)인 반면, 이 문서의 `OnCreated`/`OnRendered`/`OnDestroyed`는 **배열 파트에 놓이는 값(`PreRef`/`PostRef`/`EffectHandle`)을 만드는 팩토리**라 이름 패턴만 - 같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 DI 키와의 대조" + 같고 소속 카테고리가 다름 — `OnChange` 쪽 "다른 특수 키와의 대조" 표에 이 둘을 끼워 넣을 필요는 없어 보임(별도 표로 다루는 게 맞음). - `OnCreated`/`OnDestroyed` **이름 확정** — 다만 v1이 이미 - `OnCreated`라는 이름을 다른 메커니즘(특수 DI 키)으로 썼던 전례가 + `OnCreated`라는 이름을 다른 메커니즘(특수 키)으로 썼던 전례가 있어 위 "①" 절의 대조 설명 없이 이름만 보면 헷갈릴 수 있음, 문서화 시 명시할 것. - `OnDestroyed`는 최초 가칭이던 `OnDisposed`보다 사용자가 선호 — diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index a9bfdb5..cd6d1f9 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -135,6 +135,16 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 번째 세션에 `value` 단독으로 최종 정정**, **`canBound`는 2026-08-14 열한 번째 세션에 별도 진입점으로 재도입** — 아래 "(3)" 절) +> **[정정, 2026-08-18 구현 전 QA]** `canBound`의 **판정 방향이 뒤집혀 +> 있었다** — 이름 그대로 "지금 묶을 수 있는가"(참 = 아직 안 묶여 있어서 +> 묶어도 됨)여야 하는데, 문서 전체가 참 = "이미 묶여 있음"으로 쓰고 +> 게이트를 `if canBound(v) then error(...)`로 적어뒀었다. 그대로 구현하면 +> **정상적인 첫 바인드가 전부 에러나고 이중 바인드는 무사통과**한다. +> 아래 (1)~(3) 절은 전부 정정된 방향(`canBound(v) == not isBoundAlive(v)`, +> 게이트는 `if not canBound(v) then error(...)`)으로 다시 쓰여 있다. +> 사용자 판정 원문과 파급 목록은 +> `.claude/qa-request/pre-implementation-qa-round1.md`의 `S-1`. + **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ `canBound`/`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 @@ -145,7 +155,7 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 ```lua bindLifetime(inst: any, value: any): () -- inst가 필요한 건 이것 하나뿐 unbindLifetime(value: any): () -canBound(value: any): boolean -- "이미 유효하게 묶여 있는가" — 구조적 점유 확인 +canBound(value: any): boolean -- "지금 묶어도 되는가" — 참이면 아직 안 묶여 있음(구조적 점유 없음) canExecute(value: any): boolean -- "지금 발화해도 되는가" — emit 전파 게이팅 ``` @@ -247,8 +257,8 @@ local InstData = Relate() -- inst -> gchold/gcconn (위 (0)에서 채워짐) local BindData = Relate() -- value -> gchold/gcconn (bindLifetime이 채움) -- 비공개(export 안 함) — canBound/canExecute가 공유하는 실제 판정. --- 이 값이 "구조적으로 이미 살아있는 바인딩을 갖고 있는가"는 어느 쪽 --- 진입점에서 물어도 항상 같은 값이라, 판정 로직은 여기 하나만 있음. +-- "이 값이 구조적으로 이미 살아있는 바인딩을 갖고 있는가" 하나만 답한다. +-- 두 공개 진입점은 이걸 서로 반대 방향으로 감싼다(아래 "(3)" 절). local function isBoundAlive(value) -- (a) inst-scoped 경로: bindLifetime이 복사해둔 gcconn을 value 자신에게서 찾음. -- inst가 Destroy되면 Connected가 즉시 false, 이후 GC가 항목까지 치움 @@ -266,9 +276,9 @@ end function bindLifetime(inst, value) -- 이중 바인딩 금지(base/source-state-plan.md) — 게이트는 canBound. - -- "이미 유효한 바인딩을 갖고 있다"를 묻는 자리이지 "지금 발화해도 - -- 되는가"를 묻는 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). - if canBound(value) then + -- "지금 묶어도 되는가"를 묻는 자리이지 "지금 발화해도 되는가"를 묻는 + -- 자리가 아님(둘의 구분은 아래 "(3)" 절 참고). 못 묶는 경우만 에러. + if not canBound(value) then -- 어느 경로로 묶여있는지만 메시지에 실어줌. `.Subscribed`를 무조건 -- 인덱싱하면 안 됨 — 게이트는 값 타입을 안 가려서 value가 평범한 -- 클로저일 수도 있음(그 경우 필드 접근 자체가 에러). @@ -296,18 +306,19 @@ function unbindLifetime(value) BindData:SetWeak(value, "gcconn", nil) end --- "이미 유효하게 묶여 있는가" — 구조적 점유 확인용. bindLifetime의 이중 --- 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, Ref가 두 자리에 --- 동시에 놓이는 걸 막는 가드(`question.md` 0-W, `base/ref-plan.md`)처럼 --- "이 값이 이미 다른 어딘가에 물려 있는가"를 묻는 자리는 전부 이걸 씀. +-- "지금 묶어도 되는가" — 참이면 아직 아무 데도 안 묶여 있다는 뜻. +-- bindLifetime의 이중 바인딩 가드, Observer:Subscribe()의 이중 등록 가드, +-- Ref가 두 자리에 동시에 놓이는 걸 막는 가드(`base/ref-plan.md`)처럼 +-- "이 값을 지금 묶어도 되는가"를 묻는 자리는 전부 이걸 씀. 호출부는 +-- 항상 `if not canBound(v) then error(...) end` 모양이 된다. function canBound(value) - return isBoundAlive(value) + return not isBoundAlive(value) end -- "지금 발화해도 되는가" — State emit 전파 루프가 구독자를 게이팅할 --- 때만 씀(아래 "(4) 실제 호출부" 절). 오늘은 canBound와 판정값이 항상 --- 같지만(같은 isBoundAlive를 공유), 호출부의 질문 자체가 다르므로 --- 이름을 분리해둔다. +-- 때만 씀(아래 "(4) 실제 호출부" 절). 같은 isBoundAlive를 공유하지만 +-- canBound와는 **반대 방향**(canBound(v) == not canExecute(v))이고, +-- 호출부의 질문 자체도 다르므로 이름을 분리해둔다. function canExecute(value) return isBoundAlive(value) end @@ -318,7 +329,8 @@ end 1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]` 강참조가 그것. 2. **`value`는 `inst`가 살아있는지 스스로 확인할 방법을 갖는다** — `BindData`에 - 복사된 gcconn 참조가 그것. `canBound`/`canExecute`가 `inst` 없이 성립하는 이유. + 복사된 gcconn 참조가 그것. `isBoundAlive`(따라서 `canBound`/`canExecute`)가 + `inst` 없이 성립하는 이유. **`Subscribed`는 이 계약과 일절 무관하다 — 오직 전역 `:Subscribe()` 경로 전용 필드.** `bindLifetime`/`unbindLifetime`은 이 필드를 **읽지도 쓰지도 @@ -337,7 +349,7 @@ end local Subscribed = {} -- 전역 강참조 레지스트리(weak 아님 — 살려두는 게 목적) function Observer:Subscribe() - if canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) + if not canBound(self) then -- bindLifetime과 정확히 같은 게이트(같은 isBoundAlive 공유) error(if self.Subscribed then "이미 :Subscribe()된 값" else "이미 Instance에 바인딩된 값") @@ -384,28 +396,36 @@ end 전파 루프가 매 발화마다 각 구독자에게만 묻는 질문(아래 "(4)" 절) — `Effect`/`Observer`처럼 실제로 콜백을 실행하는 값에만 의미가 있음. -**오늘 두 문맥의 판정값은 우연히 같다**(둘 다 `isBoundAlive` 하나로 -귀결 — gcconn이 살아있는가 OR `.Subscribed`인가). 다섯 번째 세션은 이 -우연한 일치를 "애초에 같은 질문"으로 결론지어 하나로 합쳤지만, 호출부가 -왜 그 질문을 묻는지는 서로 다름 — `Ref`처럼 발화라는 개념 자체가 없는 -값에게 "발화해도 되는가"(`canExecute`)를 묻는 건 개념이 안 맞고, 나중에 -"구조적으로는 묶여 있지만 일시적으로 발화만 멈춘" 상태가 생기면(지금은 -없음) `canBound`는 참인데 `canExecute`는 거짓이어야 하는 경우도 생길 수 -있음 — 판정값이 갈라질 여지 자체가 원래 있었다는 뜻. +**[정정, 2026-08-18 구현 전 QA] 두 판정값은 같은 게 아니라 서로의 +부정이다** — `canBound(v) == not isBoundAlive(v)`, `canExecute(v) == +isBoundAlive(v)`. 열한 번째 세션은 "판정 로직도 같고 값도 항상 같은데 +호출부의 질문만 다르다"를 이름 분리의 근거로 적었는데, 그건 `canBound`를 +"이미 묶여 있는가"로 잘못 읽은 결과였다. 이름 그대로 읽으면 두 질문은 +**반대 방향**이고, 공유하는 건 판정 **로직**(`isBoundAlive`) 하나뿐이다. +**부정 관계라는 사실은 이름 분리의 명분을 오히려 강화한다** — 같은 값을 +두 이름으로 부르는 게 아니라, 서로 다른 방향을 묻는 두 predicate이기 +때문에 호출부가 `not`을 붙이는지 여부로 의도가 드러난다. + +여전히 유효한 것 — **호출부가 왜 묻는지가 서로 다르다**: `Ref`처럼 +발화라는 개념 자체가 없는 값에게 "발화해도 되는가"(`canExecute`)를 묻는 +건 개념이 안 맞고, 나중에 "구조적으로는 묶여 있지만 일시적으로 발화만 +멈춘" 상태가 생기면(지금은 없음) 둘의 관계가 단순 부정에서 더 벌어질 +여지도 있다. **해법 — 이름은 둘, 판정 로직은 하나(사용자 제안).** 실제 gcconn/ `.Subscribed` 체크는 비공개 헬퍼 `isBoundAlive(value)`(위 (1) 코드 -블록) 하나에만 있고, `canBound`/`canExecute`는 둘 다 그 헬퍼를 그대로 -호출하는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. **바뀐 -호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의 -가드(위 (2))는 이제 `canBound`를 씀 — `canExecute`를 쓰던 옛 코드에서 -이름만 바뀜, 동작은 동일. **안 바뀐 호출부**: State 전파 루프(아래 -"(4)")만 여전히 `canExecute`를 씀. +블록) 하나에만 있고, `canBound`/`canExecute`는 그 헬퍼를 각각 부정해서/ +그대로 감싸는 얇은 진입점 — 코드 중복 없이 호출부의 의미만 분리됨. +**바뀐 호출부**: `bindLifetime`의 가드(위 (1))와 `Observer:Subscribe()`의 +가드(위 (2))는 이제 `canBound`를 씀 — 형태는 항상 **`if not canBound(v) +then error(...) end`**(못 묶는 경우에만 에러). **안 바뀐 호출부**: State +전파 루프(아래 "(4)")만 여전히 `canExecute`를 쓰고, 거기선 부정 없이 +그대로 씀. -부수 효과(다섯 번째 세션 결론과 값은 동일, 이름만 갈라짐): **"바인딩이 -죽은 뒤의 재사용은 허용"** — `inst`가 Destroy됐거나 `unbindLifetime`된 -`value`는 `canBound`가 거짓이라 게이트를 통과함(다시 다른 `inst`에 걸 -수 있음). 살아있는 바인딩만 막는 게 이 게이트의 의도. +부수 효과: **"바인딩이 죽은 뒤의 재사용은 허용"** — `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 9e2e6d7..6214449 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -80,7 +80,7 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 **결론 — `None`은 raw 저장 계층에만 존재하는 실재값 센티널, merge/setter는 전혀 안 바뀜.** 이벤트 store-bind의 "`nil` 대신 실재하는 센티널" -(`false`로 disconnect, `base/event-plan.md` "이벤트도 store-bind +(`None`/`nil`로 disconnect, `base/event-plan.md` "이벤트도 store-bind 가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라 **디스패치 단계**에서 풀린다: @@ -308,13 +308,20 @@ Modifier에는 없음). ### 5. 타입 출처는 이미 확정된 dot-access 관습 재사용 "누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 -이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 -전역 관습(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 -됨 — `mod:UICorner(8)`/`mod:FontSize(...)`처럼 DI 쪽 "제네릭 생성자 함수 -하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. +이미 적용한 **"정적으로 알려진 건 dot-access"** 프로젝트 전역 관습 +(`base/store-plan.md` "타입 추론 문제" 절)을 그대로 적용하면 됨 — +`mod:UICorner(8)`/`mod:FontSize(...)`처럼 `D`(Declarative) 쪽 "제네릭 생성자 +함수 하나 + 클래스별 정적 필드" 패턴 재사용. +**[정정, 2026-08-18 구현 전 QA]** 여기 짝으로 적혀 있던 두 서술이 이번 +라운드에 바뀌었다 — (a) "동적인 건 **문자열 폴백**"의 그 폴백 +(`store "key"` 문자열 커링)은 **기각**됐고 동적 키는 +`store:GetDynamic<>(name)`으로 감, (b) 정적 필드가 "**자주 쓰는 것만**"이 +아니라 **생성기가 "GUI에 쓰이는 모든 인스턴스"를 전량 찍어냄** +(`base/bind-system-plan.md`). Modifier 타입 생성(M7)이 재사용하는 건 그 +**패턴**(제네릭 + 생성된 정적 필드)이지 옛 범위 서술이 아님. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, `base/event-plan.md` -"이벤트 바인딩 — self 미전달, false로 disconnect" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 +"이벤트 바인딩 — self 미전달" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.) `mod:UICorner(8)`가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 @@ -410,11 +417,17 @@ immutable clone 체이닝(3번)에 얹는 얇은 sugar라 구현/개념 비용 호출해 clone된 새 Modifier를 반환하므로, `Apply` 자체는 clone할 필요조차 없음(`factory(self)`가 이미 새 값을 만들어 줌). -**구현 시 주의**: `Apply`는 제네릭 `__index`가 즉석에서 만들어주는 필드 +**구현 시 주의**: 고정 메소드는 제네릭 `__index`가 즉석에서 만들어주는 필드 setter 클로저(4번)와 이름이 겹치면 안 됨 — `__index`가 고정 메소드 -테이블(현재는 `Apply` 하나)을 먼저 확인하고, 없을 때만 필드 setter를 -합성하도록 구현. 따라서 **`Apply`는 Modifier 필드 이름으로 예약됨**(실제 -스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요). +테이블을 먼저 확인하고, 없을 때만 필드 setter를 합성하도록 구현. +**[정정, 2026-08-18 구현 전 QA] 고정 메소드는 `Apply` 하나가 아니라 +`Apply` / `Peek` / `Overridden` 셋이고, 셋 다 Modifier 필드 이름으로 +예약된다.** 옛 서술("현재는 `Apply` 하나")은 9번 절이 `:Peek(key)`를 +추가하던 시점에 갱신되지 않은 stale이고, `Overridden`도 콜론 호출을 +지원한다(사용자 확정: *"Overridden 도 편의 상 A: 체인으로 제공 가능함 … +콜론과 닷 둘다 가능함"*). 실제 스타일 프로퍼티 이름과 겹칠 일은 거의 +없어 보이지만 문서화 필요 — 특히 **`FrameModifier`류 타입 생성 스크립트의 +제외 목록에 셋 다 들어가야 함**(M7). **권장 관용구, 문서화 필요(2026-08-07 다섯 번째 세션)**: 특정 modifier를 계속 변형/보정하고 싶은 경우(스타일 프리셋, 커링된 팩토리 등)엔 항상 diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 2abbc9c..4244f8d 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -30,6 +30,123 @@ RBVM처럼 `init namespace` 하나하나 부르는 방식은 별로(`base/lifecy 주고 사용자가 호출하도록. `base/architecture.md` 14번 항목과 동일한 결정 — 여기서는 "왜"만 보강. +## New()의 내부 구성 — InitXxx 팩토리 체이닝 (2026-08-19 신설) + +바로 위 절이 확정한 `InitRoblox(Module)` 패턴(팩토리가 모듈 테이블을 +뮤테이션)은 지금까지 서술상 **backend 주입**에만 적용되는 것처럼 보였는데, +`New()` **자신**이 quad-base 내부 서브시스템(Dispatch 등)을 구성하는 +방식도 대칭적으로 같은 패턴을 쓴다 — 새 설계가 아니라 이미 확정된 원칙을 +quad-base 자기 자신에도 적용한 구체화. 논의 원문은 +`session/2026-08-19-01-new-initxxx-composition-relate-guard.md`. + +**사용자 제안 원문 요지**(2026-08-19): *"생성형식 자체는 비싱글톤이고, +Dispatch 같은것도 `Init(module)` 을 받는 함수로써 ... `module.Dispatch = ...` +형식들로 구현되고 ... 재익스포트식으로 구현하겠다는 이야기였음. 처음부터 +`InitModuleName` 식으로 구현하여 팩토리를 쌓아 모듈을 리턴하는 방식으로, +quad v1 의 방식을 가져와봄직 하다."* + +**형태**(각 서브시스템 파일이 `Init` 함수를 export하고, 타입도 재익스포트): + +```lua +-- Dispatch/init.luau +local function Init(module) + local ... -- 여기서 레지스트리/릴레이션 생성 + module.Dispatch = ... +end +export type Dispatch = ... +return Init +``` + +```lua +-- 최상위 init.luau +local InitDispatch = require(...) +type Dispatch = InitDispatch.Dispatch -- 재익스포트 + +local function New(): { Dispatch: Dispatch, New: typeof(New), ... } + local module = { New = New } + InitDispatch(module) + -- 서브시스템 개수만큼 InitXxx(module) 을 순서대로 쌓음 + return module +end + +return New() +``` + +`module = {New = New}` 자기참조는 `base/architecture.md` "확정된 결정" +13번이 이미 확정해둔 것과 정확히 같은 형태 — 별도 결정이 아니라 그 결정이 +실제로 어떻게 코드로 나오는지를 구체화한 것뿐. + +**타입 재익스포트는 실측 확인됨**(2026-08-19, 사용자: "그거 타입 익스포트 +잘 됨") — `type Dispatch = InitDispatch.Dispatch` 형태로 서브모듈의 +export 타입을 최상위에서 그대로 재노출하는 게 Luau에서 문제없이 동작한다. +`base/typing-limits.md`가 우려하는 "명시 바인딩 필요" 케이스와는 다른 +자리라는 뜻 — 거긴 재귀 제네릭이 자기를 다른 타입 인자로 반환하는 게 +문제였고, 여긴 단순 alias라 그 한계에 안 걸린다. + +**순서 의존성은 각 `InitXxx`를 `require`처럼 멱등하게 만들어서 해소한다** +(2026-08-19, 사용자 제안) — 서브시스템 간 호출 순서를 최상위 `New()`가 +직접 관리할 필요가 없다: 각 `InitXxx` 파일이 자기 톱레벨(함수 클로저 +**밖**, 파일 스코프)에 `local relate = Relate()`를 하나 두고, `module`을 +weak key 삼아 "이 `module` 인스턴스에 이미 Init됐는지"를 기록한다. 이미 +됐으면 그대로 스킵, 아니면 실제 작업을 한 번만 수행 — Lua의 `require` +캐시와 같은 발상이지만, `require` 캐시는 **파일** 단위(Init 함수 자체는 +한 번만 로드)인 반면 `New()`는 여러 번 호출돼 서로 다른 `module` 테이블을 +여러 개 만들 수 있어서, "이 particular 인스턴스에" 멱등하려면 파일 스코프 +캐시로는 부족하고 `module`을 키로 하는 이 `Relate`가 따로 필요하다. + +```lua +-- Dispatch/init.luau +local Relate = require(...) +local relate = Relate() -- 파일 스코프, 클로저 밖 — 이 Init 전체가 공유하는 단 하나의 인스턴스 + +local function Init(module) + if relate:GetStrong(module, INITED) then + return -- 이미 이 module 인스턴스엔 Init됨, no-op + end + relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비, 아래 참고) + -- 자신의 의존성도 그냥 require+호출 — 상대도 멱등하므로 중복/순서 걱정 없음 + -- 예: InitLifetime(module) + local ... + module.Dispatch = ... +end +return Init +``` + +이렇게 하면 **의존하는 쪽이 자기 의존성을 직접 호출**하면 되고(`require`가 +의존 그래프를 알아서 풀어주는 것과 같은 감각), 최상위 `New()`는 순서를 +신경 쓰지 않고 아는 `InitXxx(module)`을 전부 호출해도 된다 — 이미 누가 +먼저 채웠으면 알아서 스킵된다. + +- **GC와도 자연히 맞물림**: `relate-plan.md`의 "API" 절에 따르면 `Relate`의 + **첫 인자(`inst`, 여기선 `module`)는 항상 weak**다(선택의 여지가 없는 + 고정 동작 — `Weak`/`Strong` 구분은 오직 `value` 쪽 보관 방식만 가리킴). + 그래서 `value` 쪽을 `SetWeak`으로 두든 `SetStrong`으로 두든 상관없이, + 어떤 `Quad` 인스턴스(전체 `module` 테이블)가 더 이상 참조되지 않아 + 수거되면 이 Init-완료 기록도 같이 사라진다 — 별도 정리 로직 불필요. + `relate-plan.md`가 이미 확정해둔 "각 모듈이 자기 톱레벨에 `Relate()` + 하나를 두고 재사용" 관례를 그대로 쓰는 것이라 새 메커니즘 아님. + (`relate-plan.md`가 별도로 명시한 "명시적으로 만든 기록은 명시적으로 + 지울 것" 원칙과 충돌하는 게 아니라 — 이 경우는 기록의 키 자체가 죽으면 + 그 기록을 다시 조회할 주체 자체가 사라지므로 지울 대상이 없어지는, + 원칙이 애초에 상정하지 않은 자리다.) +- **`value`는 `SetStrong`으로 통일**: 위 문단대로 GC 결과엔 차이가 없지만 + (boolean 리터럴은 애초에 GC 대상이 아님), `relate-plan.md`의 일반 규칙 + "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 기준으로는 이 `true` + 플래그를 다른 어디도 붙잡고 있지 않으므로 `SetStrong`이 그 규칙에 맞는 + 선택이다. +- **`_initializedBy` 가드(아래 "Bind는 누가, 어떻게 구현하는가" 절, 실제 + 정의는 `base/bind-system-plan.md`)와는 다른 층위** — 그건 backend + 팩토리가 유일 슬롯을 채웠는지 **누가** 채웠는지까지 구분해야 하는 공개 + 계약(같은 팩토리 재호출=no-op, 다른 팩토리=에러)이고, 이건 quad-base + 내부 서브시스템 각각이 **한 번만** 도는지만 보면 되는 사적 구현 + 디테일이라 "다른 호출자면 에러" 같은 분기 자체가 없다. 이름이 겹치지 + 않게 구분해서 쓸 것. +- **플래그를 실제 작업 전에 먼저 세우는 이유**: 나중에 `InitA`↔`InitB`처럼 + 상호 의존이 생기면([2026-08-19 기준] 지금은 없음, 대비만), 먼저 + 표시해두지 않으면 무한 재귀에 빠진다 — `require`가 순환 참조 시 + 미완성 exports를 돌려주는 것과 같은 이유로, 실제 작업 시작 전에 먼저 + "완료"로 표시해둔다. + ## Bind는 누가, 어떻게 구현하는가 인터페이스 상 `bind`를 두고 이것도 pluggable하게 할지 고민 — 단 **1개만 존재할 @@ -77,7 +194,29 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 ## 모듈 스코핑 (참고, 확정은 `base/architecture.md` 13번) 한 Lua 스레드에서 둘 이상의 모듈 분화체(Roblox+비Roblox 동시)를 쓸 일이 -거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 `New()` 추가. +거의 없을 거라 판단, 지금은 싱글톤으로 두고 필요해지면 다중 인스턴스화를 +추가. **[정정, 2026-08-18, 재정정 2026-08-19]** "코드 변경 없이 자동으로 +스코핑"되는 게 아니라 module-level state를 참조하는 코드들이 모듈 +인스턴스를 인자로 받도록 손봐야 한다. 이름은 `Quad()`가 아니라 +**`New()`가 맞음**(2026-08-18에 한 차례 `Quad()`로 잘못 정정됐다가 +바로잡힘) — `Quad`(`require`의 반환값)는 이미 만들어진 기본 인스턴스고, +`New()`는 그 안에서 명시적으로만 부르는 opt-in 필드다. 상세는 +`base/architecture.md` "확정된 결정" 13번이 소스. + +## 모듈 표면의 디버그 플래그 — `Quad.debug` (2026-08-18 신설, 사용자 요구) + +**`Quad.debug: boolean`(기본 `false`)** — 라이브러리 자체의 디버그 모드 +스위치. 지금 이 플래그가 게이팅하는 것은 **핸들러 우선순위 동률 경고 +print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로 +"개발 중에만 켜고 싶은" 진단 출력은 전부 여기 얹는다. 사용자 요구 +원문과 배경은 그 문서에 있음. + +- **기본이 `false`인 이유**: 라이브러리가 사용자 콘솔에 아무것도 안 찍는 + 게 기본이어야 함. 켜는 건 명시적 opt-in. +- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑" + 절과 같이 정할 것. +- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도 + 같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임). ## Quad는 스크립트인가 라이브러리인가 (확정, 참고용) @@ -141,9 +280,13 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 `archive/tag-attribute-load-time-registration-reversed.md`) — `HANDLER_PRIORITY_FALLBACK`에 실제로 꽂히는 건 이걸 감싸는 `TagFallbackHandler`/`AttributeKeyFallbackHandler`/ - `AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리(바로 위 문단과 같은 `BaseModule` 뮤테이션 경로 — - 새 예외 아님).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 + `AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 + 상태에서도 안내 에러 경로가 돌아야 하기 때문 — `base/dispatch-core-plan.md`의 + "base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반 + 원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인 + 이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라 + 누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가 뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의 기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한 no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서). @@ -154,7 +297,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 가드/`New()`와의 관계는 2026-08-04 3차 라운드에서 확정**: 같은 팩토리로 재호출하면 무시(no-op), 다른 팩토리로 재호출하면 에러(유일 슬롯 충돌 — 바로 위 "Bind는 누가, 어떻게 구현하는가" 절의 원칙과 일치) — `New()`가 - 생기면 인스턴스별 테이블이 분리되므로 이 가드도 자연히 인스턴스별로 - 스코핑됨, 별도 재설계 불필요. **이 결론이 Dispatch의 handler 레지스트리에도 + 실제로 호출되면 그 인스턴스별 테이블이 분리되므로 이 가드도 자연히 + 인스턴스별로 스코핑됨(**[한정, 2026-08-18 `/code-review high`, 이름 + 재정정 2026-08-19]** 위 "모듈 스코핑" 절의 정정과 맞춰 — "자동으로"는 + 아니고 module-level state를 참조하는 코드는 손을 봐야 함, 그 손질까지 + 하고 나면 이 가드 자체는 재설계 불필요라는 뜻). **이 결론이 Dispatch의 + handler 레지스트리에도 그대로 적용된다는 게 2026-08-08 두 번째 세션에서 재확인/일반화됨** — `base/dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절. diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index 6139cac..7cb3ce5 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -17,7 +17,7 @@ ## 확정 -- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 DI 키 +- **`OnChange(propertyName): OnChangeKey`** — 프로퍼티 이름을 감싸는 특수 키 팩토리, `AttributeKey(name)`/`Tag(...)`와 같은 패턴(`AttributeKey`는 구 `Attribute` — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹 `Attribute(...)` 프리미티브가 신설되며 이름 충돌 방지로 리네임됨, @@ -26,12 +26,17 @@ - **제네릭 타입 파라미터 없음 — `OnChange<>` 같은 타입 파라미터화는 안 함.** 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 (`function(v: UDim2) ... end`) — Luau가 그 타입이 실제 프로퍼티 타입과 - 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 - Luau가 검증 못 하는 대가를 받아들인다"는 결정(`base/bind-system-plan.md` "이벤트 - 바인딩 — `On.EventName` 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 - 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 - `AttributeKey<>`처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 - 엄격한 걸 요구하는 셈이라 일관성이 깨짐. + 일치하는지 검증해주지 않음. + **[근거 교체, 2026-08-18 구현 전 QA — 결론은 그대로]** 옛 근거는 *"이벤트 + 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다는 결정과 + 같은 급"* 이었는데, **그 전제가 거짓**이다(이벤트는 props 타입의 **필드**라 + `D` 생성기가 콜백 타입을 정확히 줄 수 있음 — + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절). + 진짜 이유는 **`OnChange(name)`이 이름을 인자로 받는 팩토리라 그 경로가 + 없다는 것** — 필드가 아니므로 생성기가 미리 타입을 찍어둘 자리가 없고, + 프로퍼티별로 전량 생성하는 안은 이미 기각돼 있다(아래 항목). 그래서 + `AttributeKey<>`처럼 제네릭으로 맞추려는 시도만 남는데, 그건 호출부가 + 매번 타입을 두 번 적게 만들 뿐이라 채택 안 함. - **기각안 — 프로퍼티별 정적 `OnChange.PropertyName` 전량 코드 생성**: `archive/onchange-per-property-codegen-rejected.md` 참고. Attribute의 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 @@ -58,7 +63,7 @@ 캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`dispatch-core-plan.md` "핸들러 내부 상태 저장" 절). - **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 - store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 + store-bind 가능" 메커니즘(`base/event-plan.md`)이 `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`(와 그 반환 클로저)만 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 @@ -73,14 +78,16 @@ 의도적으로 허용해도 되는 동작 — 문제 없음(사용자 확인). `Handlers/OnChange.luau` 안 `OnChange(name)` 팩토리에 `AttributeKey`와 동일한 캐시 구현. -## 다른 특수 DI 키와의 대조 +## 다른 특수 키와의 대조 | | 소스 | 값 타입 | 패키지 경계 | |---|---|---|---| -| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백, 타입 미검증 | quad-roblox(`Handlers/Event.luau`) | +| 이벤트(`MouseButton1Click = fn`) | `inst[key]`가 이미 Signal | 콜백 — **[2026-08-18 정정] 타입 검증됨**(props 타입의 필드라 `D` 생성기가 콜백 시그니처를 찍어줌) | 판별은 quad-roblox(`Handlers/Event.luau`), 타입은 `D` 생성기 | | `AttributeKey(name)` | 주입된 `setAttribute` op | 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) | **quad-base**(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드 | | `OnChange(name)` | `GetPropertyChangedSignal(name)` | 콜백, 타입 미검증(제네릭 없음) | quad-roblox(`Handlers/OnChange.luau`) | -`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 -성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가 -헷갈리지 않도록 표로 명확히 구분해둠. +`OnChange`가 Attribute처럼 제네릭화되지 않은 이유는 위 "확정" 절의 정정된 +근거대로 **이름을 인자로 받는 팩토리라 타입을 미리 찍어둘 필드가 없기** +때문 — "콜백을 받는다"는 성질이 이벤트에 가깝다는 분류 자체는 그대로지만, +이벤트 쪽은 필드라서 타입이 나온다는 게 2026-08-18에 확인됐으므로 그 +유사성이 근거가 되지는 못한다. diff --git a/.claude/base/ref-plan.md b/.claude/base/ref-plan.md index 09168a6..f974a1e 100644 --- a/.claude/base/ref-plan.md +++ b/.claude/base/ref-plan.md @@ -105,20 +105,24 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current` 존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우 채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상 - 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 - 가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고, + 필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 배열 + (`self.Callbacks`) 하나를 재사용(발화 후 해당 인덱스만 **`nil`로 소진** + — 아래 구현 디테일 참고, **[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**, 바로 아래 캐비엇 참고). - - **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고 - `__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** — - Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`를 - `self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에 - 같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스, - 위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do` - 같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이 - 경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`로 - 오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면 - 이 충돌 자체가 안 생김. + - **[재설계, 2026-08-18 구현 전 QA] 콜백/대기자는 별도 필드 + `.Callbacks` 테이블에 담고, `.Value`는 그냥 평범한 hash 필드로 둔다.** + 옛 설계(2026-08-09 열한 번째 세션 보강)는 **Ref 객체 자신이 곧 + 콜백/대기자 배열**(숫자 키 색인)이라, `T`가 함수/스레드일 때 + `for i, v in self do` 순회가 hash 파트의 `.Value`까지 훑어 오분류되는 + 걸 막으려고 **`.Value`를 `__index` 메타메소드로** 구현해야 했다. + 사용자 판정: *"단순히 .Callbacks: {fun, thread} 등이 있는게 맞지 + 않나라는 생각임. .Value 는 단순 해시필드로 주는게 더 간단해보임. + 엔지니어링 난이도구 단순 테이블 하나 더 만드는게 쉽고, 크게 비싸지도 + 않다고 생각됨."* — 순회 대상이 `self`가 아니라 `self.Callbacks`가 + 되므로 **hash 파트 충돌 자체가 안 생기고, `__index` 우회 기법을 쓸 + 이유가 사라진다.** 대가는 Ref 하나당 테이블 하나가 더 만들어지는 것뿐 + (`.Callbacks`를 첫 등록 시점에 lazy로 만들지는 구현 재량). - **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자 제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인 코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`로 @@ -133,8 +137,9 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `nil`이면 yield, 있으면 yield 안 함. - **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로 - `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만 - 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 + `:Set()`될 때, 같은 배열 하나를 `for i, v in self.Callbacks do ... end`로 + 한 번만 순회하면서(**[2026-08-18]** 순회 대상은 Ref 객체 자신이 아니라 + `.Callbacks` 테이블 — 위 재설계) `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 보고 **`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을 resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield 경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`의 @@ -167,10 +172,18 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디 `table.insert`의 `#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈 슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던 이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이 - 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열 - (PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속 - `None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할 - 이유가 없었음. + 필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열은 + 실재하는 센티널로 소진** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, + 하나로 통일할 이유가 없었음. + **[정정, 2026-08-18 구현 전 QA] 후자의 예시가 부정확했음** — 옛 + 문장은 순서가 중요한 배열의 예로 "`PreRef` pre-pass 소진 슬롯, + Length/Offset `sourceList`"를 들며 둘 다 `None`으로 채운다고 적었는데, + **pre-pass가 소진시킨 자리는 `None`이 아니라 전용 센티널 + `ProcessedPreRef`/`ProcessedPostRef`** 로 채워지고 전용 nop + 핸들러가 정상 `Dispatch.process` 경로에서 그걸 캐치한다(아래 "PreRef"/ + "`PostRef`" 절, `base/dispatch-core-plan.md`는 2026-08-14에 이미 이렇게 + 정정돼 있었고 이 문장만 갱신에서 빠졌음). `sourceList`가 `None`인 것은 + 맞음. - **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를 `:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건 다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로 @@ -254,9 +267,14 @@ skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아 local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 — -- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가) -RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) and not isPostRef(v) +RefLeafHandler.isHandlable(inst, k, v) = + type(k) == "number" and isRef(v) and not isPreRef(v) and not isPostRef(v) -- [2026-08-14 열두 번째 세션 정정] PostRef 도입(아홉 번째 세션) 당시 이 자리가 -- 안 갱신돼 있었음 — 아래 "타입/판별" 절의 최종 공식과 일치시킴 + -- [2026-08-18 구현 전 QA] type(k) == "number" 체크가 빠져 있었음 — leaf 바인딩은 + -- 배열 전용이고(사용자 확정: "배열 전용이 맞음"), 짝인 ObserverEffectLeafHandler엔 + -- 이 체크가 필수라고 이미 명시돼 있었음. 빠지면 named 자리로 흘러온 Ref를 잡으려는 + -- HANDLER_PRIORITY_FALLBACK 가드(아래 "동적 경로 가드")가 죽은 코드가 된다. function RefLeafHandler.process(inst, k, v, index) local old = relate:GetStrong(inst, k) @@ -477,10 +495,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store `ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스 루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤 Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset - 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). 진짜로 - 원래부터 빈 자리인 `None`(`props.Ref or None` 등)은 여전히 두 패스 - 루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로 - 혼동 금지. "호이스팅"은 PreRef를 배열의 맨 + 등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정, + 2026-08-18 구현 전 QA] 원래부터 빈 자리인 `None`도 이제 정상 경로를 + 탄다** — 여기 "여전히 두 패스 루프가 직접 건너뜀"이라고 적혀 있었으나 + 그 스킵 분기 자체가 폐기됐다(아래 "[전면 정정, 2026-08-18 …]" 항목). + 지금은 `NoneHandler`(재귀만) → `NilHandler`(등록 담당)를 거친다. + 두 센티널은 **매치되는 Handler가 다를 뿐** 둘 다 정상 + `Dispatch.process` 경로다. "호이스팅"은 PreRef를 배열의 맨 앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가 통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서 소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저 @@ -499,33 +520,45 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급> ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef) function ProcessedPreRefHandler.process(inst, i, v) - Dispatch.setLength(inst, i, 0) + -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저 — setLength가 + -- 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- (`base/dispatch-core-plan.md`의 해제 순서 계약) Dispatch.setOffsetSource(inst, i, None) + Dispatch.setLength(inst, i, 0) return function() end -- no-op retract, 이 자리는 fire가 끝나 -- 되돌릴 상태 자체가 없음 end ``` `isHandlable`이 `v == ProcessedPreRef`만 잡으므로 배열 파트 전용(해시 파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 `base/ - dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 "이 - 위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급 - 없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가 + dispatch-core-plan.md`의 "Length/Offset" 절이 확정해둔 "그 위치의 + 말단 Handler가 등록 책임을 진다"는 계약을 특수 취급 + 없이 그대로 만족시킴(**[정정, 2026-08-18]** 그 계약은 예전엔 "처음 + 매치한 Handler"라고 적혀 있었으나 중간 노드가 매치되는 경우가 있어 + 말단 기준으로 정정됨) — 매치되는 Handler 자신이 곧 등록자라 "누가 등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된 no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과 같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가 dispatch 체인에 실제로 올라가 있어도(**[정정] 예전 서술과 달리 이제는 올라가 있음** — 아래 참고) retract가 할 일이 없는 것뿐. - - **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) — - `NoneHandler.isHandlable(inst,k,v) = (v == None)`은 `k` 타입을 전혀 - 안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만, - 실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의 - `None`은 **애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스 - 루프가 `Dispatch.process` 호출 전에 자기 스스로 - `if v == None then continue end`로 걸러냄). `NoneHandler`는 - `Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의 - `None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로 - `NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가 - 정확한 설명. + - **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`도 + `Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는 + 전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에 + `Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가 + `if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number` + 조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데, + 리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State }` + 처럼 반응형 값이 `None`을 내놓으면 그 `None`은 `StoreBind`의 재귀를 + 타고 `Dispatch.process`에 그대로 도착**한다(사용자 지적). + 지금 확정된 모델은: + - `Dispatch.drive`에 `None` 스킵 분기가 **없다** — 전부 `process`를 탄다. + - `NoneHandler`는 `k` 타입과 무관하게 매치되고, 하는 일은 + **`nil`로 바꿔 재귀하는 것 하나뿐**. + - `k`가 숫자면 그 재귀가 **`NilHandler`**(`k=number and v==nil` 전용, + `base/dispatch-core-plan.md`의 "`NilHandler`" 절)에 도착해 거기서 + `setLength(0)`/`setOffsetSource(None)`을 등록한다. + 즉 이 자리에서 "매치되는 경우가 없다"가 아니라 **매치되고, 정상 + 경로로 0 기여가 등록된다**가 정확한 설명이다. - **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props 순회 순서" 절은 `{a=1, 2, b=3}`류 **구멍 없는** 테이블에서 배열 파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세 @@ -574,9 +607,13 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함. 전용 `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" + function(inst,k,v) error(`PreRef binding should be array index item, + but got {typeof(k)}`) end }`(**[2026-08-18, `/code-review high`로 + 누락 발견 — `PostRef`의 "동적 경로 가드 Handler도 거울상으로 하나 더" 절/ + `effect-plan.md`의 "동적 경로 가드" 절과 짝을 맞춤]** 에러 메시지에 + 실제 `k` 타입을 실을 것) — `k` 타입은 안 가림(숫자든 문자열이든 + `isPreRef(v)`만 보고 매치). `NoneHandler`와 같은 결의 "한 값 종류만 + 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. **[2026-08-14 열한 번째 세션] 우선순위는 `HANDLER_PRIORITY_FALLBACK`**(무조건 매치하는 하드 블록이 아니라 `Tag`/`Attribute`와 같은 "base가 소유하지만 백엔드/특정 자리에서 @@ -631,10 +668,21 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store 자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위 "요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는 자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.) -- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게 - 들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 +- **일반 `Ref`도 값으로서는 Modifier 필드/Store 값 어디로든 전달될 수 + 있음** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간 처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분). -- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.** + **[한정, 2026-08-18 구현 전 QA] 다만 leaf 바인딩이 일어나는 자리는 + 배열(숫자 키) 전용이다** — 옛 문장("Modifier/Store 어디든 자유롭게 + 들어감")은 "named 해시 키에 놓아도 leaf 바인딩이 된다"로 읽혔는데 그건 + 틀리다. 어디로든 **흘러갈** 수 있다는 뜻이고, 실제로 `Ref:Set(inst)`가 + 일어나는 건 배열 자리뿐(아래 `RefLeafHandler.isHandlable`의 `k` 체크). + **왜 배열 전용인가(사용자 논거, 그동안 어디에도 안 적혀 있던 것)**: + `Ref`끼리는 **배열 index 순서가 통하므로**, "다른 `Ref` 처리를 먼저 해야 + 하는 순서 의존"이 있을 때도 그걸 표현할 수 있게 하려는 것 — + `PreRef`/`PostRef`의 "계열 안 fire 순서는 배열 index 순서" 보장과 같은 + 결의 근거다. 컴포넌트 함수에 `Ref`를 named 파라미터로 넘기는 건 이와 + 무관하게 얼마든지 가능(그건 그 함수의 인자일 뿐 leaf 바인딩이 아님). +- **quad v1의 `OnCreated` 특수 키는 이식하지 않는다.** `Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로 완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) — v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠. @@ -747,8 +795,9 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store ProcessedPostRefHandler.priority = <매우 높음, ProcessedPreRefHandler와 동급> ProcessedPostRefHandler.isHandlable(inst, k, v) = (v == ProcessedPostRef) function ProcessedPostRefHandler.process(inst, i, v) - Dispatch.setLength(inst, i, 0) + -- [순서 정정, 2026-08-18 감사] setOffsetSource가 먼저(위 ProcessedPreRefHandler와 동일 이유) Dispatch.setOffsetSource(inst, i, None) + Dispatch.setLength(inst, i, 0) return function() end -- no-op retract, PreRef와 같은 이유(되돌릴 상태가 없음) end ``` @@ -768,8 +817,9 @@ dispatch-core-plan.md` "Length/Offset" 절의 계약을 특수 취급 없이 그 Store 경로로 뒤늦게 도착한 값은 "이 인스턴스의 construction 훅"이라는 정의 자체를 만족시킬 수 없음). 타입은 런타임에 지워지므로 정상 우선순위 레지스트리에 `{ priority = HANDLER_PRIORITY_FALLBACK, isHandlable = -isPostRef(v), process = error("PostRef는 children 배열 리터럴에만 놓을 -수 있음") }` Handler를 등록(`k` 타입 안 가림 — `PreRef`의 "동적 경로 +isPostRef(v), process = error(`PostRef binding should be array index item, +but got {typeof(k)}`) }` Handler를 등록(**[2026-08-18]** 에러 메시지에 +실제 `k` 타입을 실을 것 — `base/source-state-plan.md`의 "동적 경로 가드" 절)(`k` 타입 안 가림 — `PreRef`의 "동적 경로 가드" 절과 완전히 같은 이유로 `HANDLER_PRIORITY_FALLBACK`, 2026-08-14 열한 번째 세션) — pre-pass가 이미 소진시키므로 이게 매치되면 곧 타입 차단을 우회한 버그라는 뜻. diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 502aaa3..62e5725 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -166,6 +166,11 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌 - **소유권/멤버십 전역 판정** — `Slot`의 `elementOwner`. - **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute` (`base/lifecycle-pattern.md`). 애초에 클로저 수명과 무관한 질문. +- **"이 인스턴스에 이미 했는가"류 인스턴스별 멱등 가드**(2026-08-19 + 신설) — `New()`가 만드는 각 `module` 인스턴스별로 `InitXxx(module)`가 + 이미 실행됐는지 기록하는 것도 여러 호출 지점(다른 `InitXxx`가 자기 + 의존성으로 호출하는 경우 포함)을 가로질러야 해서 클로저 캡처로 대체 + 불가 — `base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절. **쓸 때 같이 지킬 것**: - **정리 조건을 실제 정리와 묶을 것.** `Relate` 엔트리를 지우는 코드가 @@ -214,9 +219,17 @@ Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌 - `base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절의 `base.perInstanceState(inst)` placeholder — Tween 등 핸들러가 `retract` 대상을 저장하는 용도, `SetStrong`으로. -- `base/lifecycle-pattern.md`의 `bindLifetime`/`canExecute` — gcconn/gchold를 - `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 "안 죽는 것"이므로 - strong). +- `base/lifecycle-pattern.md`의 `bindLifetime`/`canBound`/`canExecute` — + gcconn/gchold를 `Relate`의 **`SetWeak`**으로 저장. + **[정정, 2026-08-18 구현 전 QA]** 옛 서술은 `SetStrong`("둘 다 존재 + 이유가 '안 죽는 것'이므로 strong")이었는데 **근거까지 통째로 틀렸다** — + 둘의 생존은 gcconn 클로저의 upvalue와 `gchold[1]`이 이미 보장하므로, + 위 "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 절의 규칙이 + 그대로 적용된다. strong으로 구현하면 `gchold`가 `value`를 강하게 잡고 + `BindData`가 `value`를 키로 `gchold`를 강하게 잡는 모양이 되어, 이 + 문서가 경고하는 **두-`Relate` 상호 강참조 순환**(Luau에 ephemeron이 + 없어 실제 누수)에 정확히 걸린다. 구현 스케치는 이미 전부 `SetWeak`으로 + 적혀 있었고(`base/lifecycle-pattern.md`) 이 요약 줄만 어긋나 있었음. ## 이름 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 99ff3a6..f90037e 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -91,12 +91,16 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " 붙는 동적 리스트라 이 전제 자체가 없음** — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 — 특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에 - Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })`). + Ref를 넘기면 됨(`slot:Add(Frame { Ref = myRef })` — **여기서 `Frame`은 + `Ref`라는 named 파라미터를 받는 컴포넌트 함수다.** Instance 리터럴에 + `Ref = ...`를 named 키로 놓는 건 leaf 바인딩이 아니고, 실제로는 + `HANDLER_PRIORITY_FALLBACK` 가드가 잡아 에러를 낸다 — leaf 바인딩은 + 배열(숫자 키) 전용, `base/ref-plan.md`. **[명시 추가, 2026-08-18 구현 전 + QA]**). - **`T`의 실제 의미**: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종 마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상 `T = Instance` 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라 - `DI.InstSlot = Slot<>`(`DI` 네임스페이스 이름 자체는 - `question.md` 1번 용어정리 대기 중, 여기선 잠정 표기)가 사실상 "그" + `D.InstSlot = Slot<>`가 사실상 "그" Slot 타입. `Slot()`가 기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선 `any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것 @@ -355,8 +359,9 @@ function SlotHandler.process(inst, k, slotValue, index) -- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음. unmountSlotTree(slotValue) -- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정** - -- (setLength가 끝에서 recompute를 돌리므로 offsetSource를 먼저 비워야 - -- 죽는 중인 Source에 헛된 :Set()이 안 감, 아래 ⚠️ 절 참고) + -- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로 + -- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감, + -- 아래 ⚠️ 절 참고) Dispatch.setOffsetSource(inst, k, None) Dispatch.setLength(inst, k, 0) unbindLifetime(slotValue) -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝 @@ -843,9 +848,10 @@ fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `it 되는 식): - **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환. "지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면 - **[정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트됨**(단순 - `Visible = false`도 아님 — 위 "구현" 절 `rawUnmount` 참고, 이 - 문단 작성 당시엔 아직 destroy 모델이었음). 편의상 + **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 + — "`nil` 리턴은 파괴가 기본" 절 참고] 파괴됨**(단순 `Visible = + false`도 아니고, 언마운트도 아님 — `rawRemove`. `PopOnly`를 명시 + 반환해야 대신 언마운트+재사용됨). 편의상 `nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 `:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw `nil`/`None` 금지와 안 부딪힘). @@ -970,12 +976,26 @@ end **해법**: `updateFn`을 매 사이클 호출하되, `prev`를 줘서 "바꿀 게 없으면 그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 `nil` -반환으로 **[정정, 2026-08-13 3차 감사] 언마운트**되게 함(위 캐비엇대로 -파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 -통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 -존재하지 않음(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 -GC되기 전까지 `nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 -있음 — 위 캐비엇 참고). +반환으로 **[정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 +재역전 — 아래 참고] 언마운트**되게 함(위 캐비엇대로 파괴 아님) — Visible +토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면 +20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음 +(애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지 +`nil` 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇 +참고). + +**⚠️ [재정정, 2026-08-18 구현 전 QA, `/code-review high`로 이 절의 stale +서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로 +쓰고 있는데, 그 결론은 이후 재역전됐다.** 지금 유효한 규칙은 "`nil` +리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴" 절(SL-3 해소, +`question.md`/`archive/question-resolved.md` 참고) — filter 탈락으로 +`updateFn`이 그냥 `nil`을 반환하면 이제 **파괴**(`rawRemove`)가 기본이고, +"Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면 +`nil` 대신 명시적으로 `PopOnly`를 반환해야 언마운트+재사용이 된다. 이 +절의 **동기**(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제 +자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은 +`PopOnly` 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위 +"`nil` 리턴은 파괴가 기본" 절도 같이 볼 것. **"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 `userdata`가 그 채널** — item이 plain table이라 매번 `Source`를 새로 안 만들고 재사용하려면 그 @@ -1077,6 +1097,10 @@ function activateList(self, inst) local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능) local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key]) if result == None then result = nil end -- 편의: None도 nil과 동일 취급 + -- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시. + -- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다. + local popOnly = (result == PopOnly) + if popOnly then result = nil end if result ~= nil then -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그 @@ -1087,11 +1111,16 @@ function activateList(self, inst) end if result ~= prev then - -- [정정, 2026-08-13 여섯 번째 세션] 파괴가 아니라 **언마운트** — - -- rawUnmount는 rawRemove와 같되 element를 Destroy하지 않고 - -- 소유권만 반납(unmountSlotTree 사용, 아래 "파괴" 절). - -- 명시적 CRUD Remove/Clear/dispose만 여전히 파괴. - if prev ~= nil then rawUnmount(self, prev) end + -- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래 + -- "`nil` 리턴은 파괴가 기본" 절이 소스: + -- (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만** + -- — state 교체와 동형, 지우라고 한 적이 없음. + -- (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드. + -- (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시. + if prev ~= nil then + if result ~= nil or popOnly then rawUnmount(self, prev) + else rawRemove(self, prev) end + end if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준 mounted[key] = result elseif prev ~= nil and keyIndex[key] ~= pos then @@ -1099,12 +1128,13 @@ function activateList(self, inst) end userdata[key] = ud -- result와 무관, 그대로 기록 + -- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당) newKeyIndex[key] = pos end for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key if not seen[key] then local prev = mounted[key] - if prev ~= nil then rawUnmount(self, prev) end -- 위와 같은 이유로 비파괴 + if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴 mounted[key], userdata[key] = nil, nil end end @@ -1177,19 +1207,17 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 같이 GC됨(아래 "구독 시점" 절). -- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawMove`뿐** - (**[정정, 2026-08-13 여섯 번째 세션]** 예전엔 `rawRemove`(파괴)였으나 - 언마운트 전환으로 바뀜 — 데이터에서 빠진 아이템도 파괴되지 않고 - 언마운트만 되며, 아무도 안 들고 있으면 GC) — - `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" - 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 - 셋을 직접 호출할 일이 없을 뿐 — **[정정, 2026-08-13 감사] "제거는 - 항상 파괴 확정이라 Extract 아닌 Remove 경로"라는 예전 근거는 언마운트 - 전환으로 이제 틀림**(바로 위에서 정정했듯 reconcile의 제거는 이제 - 비파괴 `rawUnmount`이고, 이 함수 자체가 `rawRemove`의 비파괴 - 짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드 +- **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawUnmount`/`rawRemove`/ + `rawMove`** (**[재정정, 2026-08-18 구현 전 QA]** 2026-08-13 여섯 번째 + 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 + **부분적으로 되돌림** — `nil` 리턴/키 소멸은 다시 **파괴**가 기본이고, + 값 교체와 `PopOnly`만 비파괴. 아래 "`nil` 리턴은 파괴가 기본" 절이 + 소스) — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 + 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 + 자체가 그 셋을 직접 호출할 일이 없을 뿐. `rawUnmount`는 `rawRemove`의 + 비파괴 짝으로서 `Extract` 계열과 공유하는 저수준 프리미티브 — 위 코드 블록의 "rawRemove의 비파괴 짝 — `:List`의 reconcile과 `Extract` - 계열이 씀" 주석 참고). reconcile이 공개 `Slot:Extract` 대신 + 계열이 씀" 주석 참고. reconcile이 공개 `Slot:Extract` 대신 `rawUnmount`를 직접 부르는 진짜 이유는 파괴 여부가 아니라, reconcile이 이미 자기 `mounted` 맵으로 element를 추적 중이라 `Extract`의 "제거한 element를 호출자에게 반환" 계약이 불필요하고, 공개 CRUD의 가드/에러 @@ -1200,6 +1228,60 @@ raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. +### `nil` 리턴은 파괴가 기본 — `PopOnly`(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기) + +**[재정정]** 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로 +지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 `:List`의 reconcile까지 +전부 비파괴로 바꿨는데, **`:List`에는 그 일반화가 안 맞는다**는 게 사용자 +판정: *"List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지, +파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데…"* + +**세 경로로 갈린다**(위 `reconcile` 의사코드): + +| updateFn의 반환 | 이전 요소(`prev`) 처리 | 왜 | +|---|---|---| +| 새 값(`result ~= nil`) | **언마운트만** | 밀려난 것뿐이지 "지워라"가 아님. `state` 교체와 동형이고, `Slot { State }` sugar(`:Single`)가 이 경로를 타므로 아래 "`State` 교체" 절의 확정도 그대로 유지됨 | +| `nil` / `None` | **파괴**(`rawRemove`) | `updateFn`이 명시적으로 "이 자리를 지워라"라고 말한 것 | +| `PopOnly`(가칭) | **언마운트만** + 재사용 대기 | 아래 | +| 키가 데이터에서 사라짐 | **파괴**(`rawRemove`) | `nil` 리턴과 같은 의미(그 아이템은 이제 없음) | + +**`PopOnly`(가칭) — `Instance.new`/`Destroy` 비용을 아끼는 재사용 경로.** +`filter` 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 +파괴/재생성하는 건 비싸다. 그래서 `updateFn`이 **`PopOnly`와 함께 userdata를 +반환**하면 그 자리는 파괴 없이 `Parent = nil`로만 내려오고 Slot에서 빠진다: + +```lua +-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다 +return PopOnly, { old = prev, source = ... } +``` + +- **보존 주체는 `userdata`** — reconcile은 `mounted[key]`에서만 뺄 뿐 + `userdata[key]`는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의 + `old`가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 `updateFn`이 + 같은 `userdata`를 다섯 번째 인자로 다시 받으므로, 거기서 `old`를 꺼내 + 그대로 반환하면 **재마운트**된다(`rawAdd` 경로). +- **userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 `nil`을 반환하기 + 전까지 안 지워진다** — 즉 "언제 진짜로 버릴지"를 `updateFn`이 결정한다. +- **⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 + 발견한 갭).** 그 경우 reconcile의 소멸 루프가 `mounted[key]`/`userdata[key]`를 + **둘 다** 지우는데, `PopOnly`로 홀드 중이던 요소는 `mounted[key]`가 이미 + `nil`이라 `rawRemove`(파괴) 대상이 아니다 — 결과적으로 그 요소는 + **파괴되지도, `updateFn`에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 + 된다**(Parent는 이미 `nil`). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 + 못박은 것과도, 위 "버릴 시점은 `updateFn`이 정한다"와도 어긋난다. + **세 선택지 중 하나를 M8 착수 전에 정할 것**(`question.md` 3번): + (a) 소멸 루프가 `userdata[key].old`도 확인해 `rawRemove`로 파괴, + (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), + (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 묻는다. + **지금 문서는 (a)를 기본으로 가정하지 않는다** — 결정 전이므로 구현 금지. +- **이름은 가칭** — 사용자 확정: *"PopOnly 확정. 다만 이름은 변경될 수 + 있음. 이름에 대해서는 더 생각해보아야함"*. `question.md` 용어 정리 항목에 + 올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정. +- **`Slot`의 다른 비파괴 API와의 관계**: `Extract`/`ExtractAll`/`Splice`가 + 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 **호출자가 + 직접 부르는 명령형 경로**다. `PopOnly`는 같은 일을 **reconcile 안에서 + 선언적으로** 하기 위한 것이라 서로 대체 관계가 아니다. + ### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime` (2026-08-09 일곱 번째 세션) @@ -1391,11 +1473,48 @@ nil/None 금지)는 그대로. ### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용 +**✅ [해결, 2026-08-18 구현 전 QA 2라운드 후속]** 아래가 재사용하는 +`Dispatch.setLength`/`setOffsetSource`/`recompute`가 배치 등록 중 크래시할 +수 있던 문제(`RC-1`)는 해결됨 — `base/dispatch-core-plan.md`의 +"배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그 +해법이 `attachSlot`의 flush 루프에 어떻게 적용되는지만 다룬다(아래 +코드의 `blocker` 관련 줄). + `base/dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는 owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나 weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출**이 됩니다. +**[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음 +— `setLength`가 먼저, `setOffsetSource`가 나중이던 것을 바로잡음.** +`base/dispatch-core-plan.md`의 "`NilHandler`" 절이 이미 확정해둔 **"호출 +순서는 `setOffsetSource` → `setLength`"** 일반 규칙(해제 시점 계약에서 +나왔지만 등록 시점에도 그대로 적용)과 이 `attachSlot` 의사코드가 계속 +어긋나 있었던 것 — RC-1을 고치며 `setOffsetSource`가 즉시 계산을 하게 +되면서 이 불일치가 드러남. 사용자 확정: *"length 를 알게되는 시점은 각 +요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset +전파가 한번 더 일어나게됨"* — Slot의 진짜 `.Length`는 `activateList`가 +자기 `:List`를 최초 reconcile한 **뒤에야** 확정되므로, `setLength`를 그 +전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는 +**`setOffsetSource`(즉시 계산) → (Slot이면) 실체화 → `setLength`(그제서야 +확정된 값으로 등록) → 물리 마운트**. + +**[재정정, 2026-08-18 구현 전 QA 3라운드] "확정된 값으로 등록"은 값 +자체가 아니라 이 순서를 지키는 한 자연히 따라오는 결과를 가리킨 표현이지, +`setLength` 호출 시점에 `slot.Length`의 **값**이 반드시 최종값이어야 +한다는 뜻은 아니다.** 실체화(`activateList`)와 물리 마운트(flush 루프)의 +순서를 더 트레이싱하며 `RC-3`/`RC-4`(둘 다 `activateList` 도중 아직 +`self._mounted`가 안 세팅된 상태를 요구한다는 게 드러남 — 아래 코드의 +"`_mounted`는 여기서 아직 세팅하지 않는다" 주석 참고)를 고치는 과정에서, +`slot.Length`가 실제로 최종값으로 안정되는 시점은 `activateList` 직후가 +아니라 **flush 루프가 끝난 뒤의 마지막 `recompute`**로 한 단계 더 +밀렸다 — `Dispatch.setLength(ownerKey, position, slot.Length)`가 넘기는 +건 **State 객체 자신**이라, 등록 시점에 값이 아직 안 굳어 있어도 +무해하다(부모는 객체를 구독해뒀다가 나중에 값이 바뀌면 정상 반응). +`setOffsetSource → setLength` 순서 자체(왜 `setOffsetSource`가 먼저여야 +하는지)는 안 바뀜 — 상세 트레이싱은 +`qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. + ```lua -- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합 local function attachSlot(slot, physicalTarget, ownerKey, position) @@ -1406,30 +1525,89 @@ local function attachSlot(slot, physicalTarget, ownerKey, position) -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은 -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에 -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음. + + local offsetSource = Source(0) + Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- 먼저 — 앞선 형제 합으로 즉시 계산 + slot.Offset = offsetSource + + -- [재정정, 2026-08-18 구현 전 QA 3라운드, `RC-3`/`RC-4` 해결 — + -- 사용자 설계] `_mounted`는 여기서 아직 세팅하지 않는다 — 그래야 + -- 아래 `activateList`가 실행되는 동안 `self._mounted`가 계속 + -- `false`라, `:List`의 reconcile이 부르는 `rawAdd`가 "아직 마운트 + -- 전"(= `_elements`에만 넣고 끝) 경로를 타서 이 시점엔 물리 + -- 마운트도 Dispatch 등록도 전혀 안 일어난다. 옛 코드는 `_mounted`를 + -- 맨 위에서 세팅해뒀었는데, 그러면 reconcile의 `rawAdd`가 매 항목마다 + -- 즉시 물리 마운트 + `Dispatch.setLength`를 태워(아래 flush 루프가 + -- 곧 다시 처리할 바로 그 자리를) 두 가지 문제를 냈다 — (a) 아직 + -- Blocker가 없어(그건 flush 루프 직전에야 생김) 매 항목마다 게이팅 + -- 없이 `recompute`가 돎(`RC-3`), (b) nested Slot 항목은 이 시점에 + -- 이미 `attachSlot`이 한 번 불렸는데, 아래 flush 루프가 같은 요소를 + -- 다시 순회하며 `attachSlot`을 **또** 불러 이중 실행됨(`RC-4`). + -- `_mounted`를 `activateList` 뒤로 미루면 이 함수 안에서 실제 + -- 마운트가 일어나는 자리는 아래 flush 루프 단 하나로 통일된다 — + -- `:List`든 수동 CRUD든 구분할 필요가 없어짐. 상세 트레이싱은 + -- `qa-request/pre-implementation-qa-round3.md`의 `RC-3`/`RC-4` 절. + if slot._listed then + activateList(slot, physicalTarget) -- reconcile이 채우는 건 `_elements`뿐 — 물리 마운트는 안 함(위 참고) + end + slot._mounted = true slot._mountedInst = physicalTarget - Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State, 기존 로직 그대로 - local offsetSource = Source(0) - Dispatch.setOffsetSource(ownerKey, position, offsetSource) - slot.Offset = offsetSource + -- **[정정, 2026-08-18 3라운드]** `slot.Length`는 이 시점에 아직 + -- "확정된 값"이 아니다 — 최종 값은 아래 flush 루프 끝의 `recompute`가 + -- 매긴다. 여기서 넘기는 건 값이 아니라 **State 객체 자신**이라 무해함: + -- 부모는 이 객체를 구독해뒀다가, 그 값이 나중에(flush 끝나고) 바뀌면 + -- 정상적으로 다시 반응한다(부모 배치가 아직 안 끝났으면 부모 자신의 + -- Blocker가 그 반응을 알아서 미룸 — 아래 "확인만 하고 새 결함 없음" + -- 절 참고). 옛 주석("확정된 값으로 등록")은 옛 순서(`_mounted`가 + -- `activateList`보다 먼저라 그 안에서 이미 최종화되던 것) 기준이었고 + -- 이제는 안 맞아 정정. + Dispatch.setLength(ownerKey, position, slot.Length) - if slot._listed then - activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜 - end - - -- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에 - -- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님) + -- attach 전에 이미 들어와있던 요소들(수동 CRUD로 마운트 전 `:Add()`된 + -- 것) **및** 방금 `activateList`가 `_elements`에만 채워둔 `:List` + -- 결과물 — 이제 이 flush 루프가 어느 경로로 왔든 상관없이 유일한 + -- 물리 마운트 지점이다. `slot._elements`의 개수(N)가 이미 정해진 채 + -- position을 하나씩 등록하는 배치라 `Dispatch.drive`와 같은 크래시 + -- 위험이 있음(`RC-1`) — 이 Slot 자신의 owner 키로 별도 Blocker를 새로 + -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의 + -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용. + local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용 + blocker:On() for i, element in ipairs(slot._elements) do if isSlot(element) then attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신 else + -- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도 + -- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1. + Dispatch.setOffsetSource(slot, i, None) + Dispatch.setLength(slot, i, 1) element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행 end end + blocker:OffWithoutEmit() + local bk = getBookkeeping(slot) + if bk then recompute(slot, bk) end -- 여기서 slot.Length가 비로소 진짜 값으로 확정됨 end ``` +**⚠️ [신설, 2026-08-18 3라운드 감사 후속] 좁은 엣지 케이스 — 배치 밖에서 +이 Slot이 단독으로 (재)마운트되면, 부모의 `recompute`가 아직 안 굳은 +`slot.Length`로 한 번 헛돌 수 있다.** `Dispatch.setLength(ownerKey, +position, slot.Length)`(위 코드)는 `slot.Length`가 `State`라 등록 즉시 +1회 실행을 동기로 태우는데, 이 `attachSlot` 호출이 `Dispatch.drive`의 +배치나 부모 Slot의 flush 루프 **안**이면 부모 Blocker가 아직 켜져 있어 +안전하게 스킵되지만, **배치 밖**(예: `state` 값이 steady state에서 +반응형으로 교체될 때, 부모 owner의 Blocker는 이미 꺼진 채)이면 부모의 +`gatedRecompute`가 즉시 실행돼 아직 flush가 안 끝난 `slot.Length`로 한 +번 계산한다 — flush가 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 +정확한 값으로 자기 교정된다. 크래시도 영구적으로 틀린 값도 아니고 +최악의 경우 한 프레임짜리 낭비 재계산 — 손대지 않기로 함, 다만 이 +자리를 다시 만질 때 놓치지 않도록 기록. 트레이싱 원문은 +`qa-request/pre-implementation-qa-round3.md`의 "확인만 하고 새 결함 +없음" 절. + **최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:** ```lua -- process(inst, k, slotValue, index) @@ -1447,6 +1625,15 @@ end -- attachSlot될 때 위 flush 루프가 처리 ``` +**이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인, +2026-08-18)** — *"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운 +개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 +요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 +받고 수행 가능함"* — 이 시점엔 `self`의 Blocker가 이미 flush 배치를 +끝내고 `OffWithoutEmit()`으로 꺼져 있고, 새로 등록되는 position보다 +앞선 모든 position은 이미 안정적으로 채워져 있어 `nil` 자리가 생길 +여지 자체가 없다. + `recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록 확장됐으므로(`base/dispatch-core-plan.md` 참고) — `Slot.Length`는 더 이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested @@ -1533,7 +1720,7 @@ function rawUnmount(self, index) releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음) if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end - spliceArraysDown(self, index) + spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) end @@ -1550,11 +1737,52 @@ function rawRemove(self, index) -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦 if isSlot(element) then destroySlotTree(element) else element:Destroy() end - spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김 + spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고 recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만 end ``` +**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는 +배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가 +이 코퍼스 어디에도 명시된 적이 없었음.** + +- **`bk.observers`도 같이 당겨야 함** — 위 코드가 이미 `bk.observers[index]`를 + 읽어 `unbindLifetime`하지만(제거되는 그 위치의 것), `spliceArraysDown` + 자신이 이동시켜야 하는 배열 목록에 지금까지 `observers`가 빠져 있었다 + (`_elements`/`lengthList`/`sourceList` 셋만 언급됨). `bk.observers[i]`는 + `Dispatch.setLength`가 그 자리 length가 `State`일 때만 채우는(위 + "`setLength` 구현" 절) position-indexed 배열이라, 나머지 셋과 똑같이 + 뒤 position들이 한 칸씩 당겨질 때 같이 안 당기면 이후 그 position의 + observer가 엉뚱한 것(옛 이웃의 observer)을 가리키게 된다. +- **`bk.N`도 여기서 하나 줄여야 함** — `recompute`(`base/dispatch-core-plan.md` + "Length/Offset" 절)가 `for i = 1, bk.N do`로 순회하는 그 상한. **`bk.N`의 + 정의 자체가 이 코퍼스 어디에도 없던 갭**이었다(`qa-request/ + pre-implementation-qa-round3.md`의 "`bk.N`의 수명주기" 절 — **사용자 + 확정(2026-08-18)**: *"bk.N = 그때그때 실제 개수(새 최대 위치가 등록될 + 때마다 증가, spliceArraysDown이 압축할 때 감소)로 두 owner 타입에 + 동일하게 적용"*). 즉 `bk.N`은 `Dispatch.setLength`가 이전에 본 적 + 없는 더 큰 position을 등록할 때마다 그 값으로 늘어나고(`setOffsetSource`는 + 건드리지 않음 — 항상 `setLength`보다 먼저 불려서 그 시점엔 + `lengthList[i]`가 아직 없으므로, `Dispatch.drive`/`attachSlot`의 flush + 배치도, Slot의 런타임 단건 + `rawAdd`도 이 하나의 규칙으로 통일), `spliceArraysDown`이 위치 하나를 + 물리적으로 지울 때(`rawRemove`/`rawUnmount`) 그만큼 줄어든다. **`Dispatch.drive`의 + `inst`에서는 이 규칙이 사실상 눈에 안 띈다** — 최상위 배열 리터럴은 + 구조적으로 늘거나 줄지 않으므로(전체 재-dispatch만 있음) `bk.N`이 + 등록이 끝난 뒤로는 그냥 고정값처럼 보일 뿐, 별도 케이스가 아니라 같은 + 규칙의 특수한 안정 상태다. +- **왜 이게 `RC-1`의 크래시를 다시 불러오지 않는가**: `Dispatch.drive`/ + `attachSlot`의 배치 등록 중엔 `recompute`가 각 owner의 Blocker + 게이팅으로 아예 안 도는데(`blocker:IsOn()`만 확인, `bk.N`은 안 봄) — + 그래서 배치 도중 `bk.N`이 최종값보다 작은 채로 계속 늘어나는 중이어도 + 안전하다. `RC-1`의 원래 크래시는 **`bk.N`이 배치가 시작되기도 전에 + 이미 최종 크기로 고정돼 있었던 것**의 부산물이었다는 게 이번에 다시 + 확인됨 — 지금은 그 전제 자체가 없다. 그 대신 Blocker 게이팅이 여전히 + 필요한 이유는 크래시 방지가 아니라 **비용**(등록마다 `recompute`가 + 한 번씩 도는 O(N²) 대신 배치 끝에 O(1)번만) — `RC-1` 해결 논의에서 + 사용자가 직접 지적한 "이러면 첫 실행에서 계속 recompute 비용이 쌓임" + 문제 그대로. + **왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target 인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리 target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로 @@ -1855,6 +2083,25 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 엔진 자체 `:Destroy()` 메소드와 동명이라 사용자가 "그냥 `:Destroy()` 부르는 거 아님?"으로 착각할 위험이 있어 기각 — `dispose` 유지. +**[백로그 후보, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터.** +위 "`Set`(언마운트) → 그 다음 정리" 순서 요구 때문에 호출부가 매번 +**`Get()`으로 이전 값을 미리 잡아두고 → `Set(new)` → 잡아둔 옛 값을 +`dispose`** 하는 3단계를 손으로 써야 해서 편의성이 떨어진다는 사용자 지적: +*"source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 생각해보았음(단 +여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). Get해놓고 Set 이후 +나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 source 자체에 :콜론 +메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음."* 후보 둘: + +1. `source:Apply(SetAndDispose(new))` — 콤비네이터. **단 여기서의 `Apply`는 + `State`가 아니라 `Source`를 넘겨주는 함수여야 함**(사용자 명시) — 지금 + 확정된 `state:Apply(factory)`는 `factory(self)`에 `State`를 넘기므로, + `Source` 전용 변형이 필요한지 같이 정해야 한다. +2. `Source`에 콜론 메서드로 직접 얹기(`source:SetAndDispose(new)`). + +**미결**: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지. +`state:Apply`의 시그니처(`(State) -> U`)에 영향이 갈 수 있으므로 **M3 +착수 전에 방향만이라도 정해둘 것**. `question.md`에 올려둠. + #### 구현상 바뀌어야 하는 것 **[반영 완료, 2026-08-13 감사 후속]** 비파괴 경로를 `unmountSlotTree`로 @@ -1866,14 +2113,20 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의 코드가 `unmountSlotTree` + `setOffsetSource(None)`/`setLength(0)` + `unbindLifetime` + `releaseOwner`를 부름. -2. **`:List`의 `reconcile`** — 교체/소멸 시 `rawRemove`(파괴) 대신 같은 - 비파괴 경로. 데이터에서 빠진 아이템도 파괴되지 않고 언마운트만 되며, - 아무도 안 들고 있으면 GC(quad 전역의 GC-native 원칙 그대로). +2. **`:List`의 `reconcile`** — **[재정정, 2026-08-18 구현 전 QA]** 여기서 + 비파괴가 되는 건 **값 교체와 `PopOnly`뿐**이다. `updateFn`이 `nil`/`None`을 + 반환하거나 키가 데이터에서 사라진 경우는 **다시 파괴가 기본**(사용자 + 판정) — 상세와 이유는 위 "`nil` 리턴은 파괴가 기본" 절이 소스. + 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 + `:List`에는 안 맞는 일반화였음. **여전히 파괴인 것**: 명시적 CRUD `Slot:Remove(index)`/`Slot:Clear()` -(CRUD 표가 "제거 **+ 파괴**"로 이미 정의)와 `dispose`. 즉 **"자동 경로는 -언마운트, 명시적으로 지우라고 한 것만 파괴"**로 갈림 — `Ref`/`Attribute`의 -"지울 거면 명시적으로" 철학과 정확히 같은 결. +(CRUD 표가 "제거 **+ 파괴**"로 이미 정의), `dispose`, 그리고 위 2번의 +`:List` 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 +지우라고 한 것만 파괴"**이되, **`:List`에서 `nil`을 반환하는 것 자체가 +"지우라고 한 것"으로 센다** — `Ref`/`Attribute`의 "지울 거면 명시적으로" +철학과 같은 결이고, `updateFn`이 지우지 않길 원하면 `PopOnly`로 그 의도를 +명시한다. `unmountSlotTree`는 `destroySlotTree`가 하는 일 중 **실제 파괴와 자식 소유권 반납만 빼고 나머지는 그대로 함**(자식 observer `unbindLifetime`, @@ -1892,9 +2145,12 @@ quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 (2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할 때는 이 순서를 반드시 지켜야 함**: -- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는 - `sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 - (`base/dispatch-core-plan.md` "Length/Offset" 절). +- `Dispatch.setLength`는 끝에서 `gatedRecompute`를 경유해(배치 게이팅 + 중이 아니면) `recompute`를 돌리고, `recompute`는 `sourceList`를 + 순회하며 각 자리의 `offset:Set(sum)`을 호출함(`base/dispatch-core-plan.md` + "Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히 + 일어나므로 이 경로에서는 `gatedRecompute`가 거의 항상 즉시 `recompute`로 + 이어짐). - 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 diff --git a/.claude/base/source-state-plan.md b/.claude/base/source-state-plan.md index cf01ca1..bf6f60d 100644 --- a/.claude/base/source-state-plan.md +++ b/.claude/base/source-state-plan.md @@ -78,8 +78,8 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 자리엔 Source를 넣을 수 있지만 역은 안 됨(Svelte의 `Writable extends Readable`와 같은 모양). Source는 State가 주는 모든 것(`:Get()`, `:With(...)`, `:Compute(fn)`) 위에 `:Set(value)`/`:Emit()`을 - 추가로 가짐([정정, 2026-08-07] `.value`는 State/Source에서 제외되고 - `Get()`으로 통일됨, `.value` 표기는 Ref 전용으로 좁혀짐 — 아래 + 추가로 가짐([정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외되고 + `Get()`으로 통일됨, 그 표기는 Ref의 `.Value` 전용으로 좁혀짐 — 아래 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절 참고). - **`:With`/`:Compute`는 Source에서도 항상 `State`를 반환** — Source 자신을 변형하는 게 아니라, "Source의 State 뷰를 뽑아 그 위에 파이핑"하는 @@ -98,9 +98,10 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴. 다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류 매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임 구현 델리게이션 포함)이라 그 금지와 충돌하지 않음. -- **동적 키 폴백(`store "key"`)은 이제 `State`가 아니라 `Source`를 - 반환**하는 것으로 자연히 갱신됨(`base/store-plan.md`의 "타입 추론 - 문제" 절과 연동). +- **동적 키 경로도 `State`가 아니라 `Source`를 반환**하는 것으로 자연히 + 갱신됨 — **[정정, 2026-08-18]** 그 경로는 `store "key"` 문자열 커링이 + 아니라 `store:GetDynamic<>(name): Source`다(문자열 커링은 기각, + `base/store-plan.md`의 "타입 추론 문제" 절). **[해소됨, 2026-08-13 첫 실측 라운드]** 핵심 질문(Source가 State를 구조적으로 만족하는 제네릭 메소드 체이닝)은 `08-type-source-satisfies-state.luau`로 @@ -292,6 +293,38 @@ Observer와 동일한 패턴(외부 weak table, `{[child] = true}` 류)으로 유일한 중복 방지 수단이 되면서 "State는 캐싱하는 존재"라는 근거가 더 강해짐. +### ⚠️ 미해결 — 중간 State가 살아남는가(구독 엣지의 방향성) (2026-08-18 구현 전 QA에서 제기, **M3 착수 전 결론 필요**) + +**사용자가 지목한 미검증 항목**: *"확인해봐야 하는게 State -> State -> +State -> Observer Leaf Bind 에서 중간 State 는 참조되지 않아도 사라지지 +않음이 명확해야함. 물론 compute 등의 callback 상 가져서 안전할 수 있지만, +With 등이 있는 경우 parent 와 연결된 상대를 자기 자신에 가지고 있어야 +할것임. 이게 되는지는 더 알아봐야할 필요가 있음."* + +확정된 두 서술을 겹치면 체인이 끊길 수 있다: + +- State는 자기 **구독자(하류)를 weak로** 담는다(위 문단, 그리고 + `base/lifecycle-pattern.md`의 "(4) 실제 호출부" 절). +- Observer는 `gchold`(leaf) 또는 전역 레지스트리가 살려준다. +- 그러면 `A → B → C → Observer` 체인에서 **중간 노드 `B`/`C`를 강하게 + 붙잡는 주체가 지금 문서 어디에도 명시돼 있지 않다.** 체이닝을 한 줄로 + 쓰는 흔한 형태에선 아무도 `B`/`C`를 로컬 변수로 안 들고 있고, 하류 weak + 링크만으로는 생존이 보장되지 않아 **중간 State가 수거되고 전파가 조용히 + 끊길** 수 있다. + +**사용자가 지목한 해법 방향 — 구독 엣지는 하류로 weak, 상류로 strong.** +각 노드가 자기 parent(상류)를 강참조로 들고 있으면 leaf에서 root까지가 +한 줄로 살아있게 된다. `:Compute`는 콜백 클로저가 상류를 캡처해 **우연히** +안전할 수 있지만, **`:With`가 만드는 pass-through 노드는 계산 함수가 +없어서** 그런 우연한 캡처가 없다 — 그래서 "우연"에 기대면 안 되고 방향성을 +불변식으로 못박아야 한다. + +**해야 할 일**: (a) 이 방향성(상류 strong / 하류 weak)을 이 문서의 +불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 추가 +(`07-relate-weak-table-gc.luau`가 연쇄 GC를 이미 다루므로 그 옆에). +**미검증 상태로 M3에 착수하면 안 되는 항목** — 아래 "결론"의 "관리 부담은 +작음"은 이 항목이 닫히기 전까지는 잠정이다. + **결론**: 노드별 캐시 유지(현재 모델) 유지, 플래튼 기각. Modifier가 플래튼+클론을 쓰는 건 애초에 캐싱이 필요 없는 정적 데이터라 성립하는 것이고, State는 존재 이유 자체(캐싱)가 달라 같은 패턴을 적용할 수 없음. @@ -451,20 +484,23 @@ Tag/Modifier의 클론은 호출 즉시 결과가 확정되는 값이라 "-ed"( `self:Get()`을 실제로 읽을 때만 계산이 트리거됨. with한 값과 동일한 lazy 원칙을 self에도 그대로 적용 — 별도 `ComputeWithout` 변형은 불필요, `Compute` 하나로 일관. -- **[정정, 2026-08-07] `.value`는 State/Source에서 제외, `:Get()`만 지원.** +- **[정정, 2026-08-07] 프로퍼티 읽기 표기는 State/Source에서 제외, `:Get()`만 지원.** 이전엔 `Get()`을 감싼 읽기 전용 계산 속성(`base/lifecycle-pattern.md`의 `Connected`와 동일한 "저장되는 필드가 아니라 계산된 속성" 패턴)으로 - `.value`/`:Get()` 둘 다 지원하고 `.value`를 관용적 표기로 앞세웠으나, + `.value`/`:Get()` 둘 다 지원하고 프로퍼티 읽기를 관용적 표기로 앞세웠으나, "관측해야 실체화된다"는 원칙이 가장 날카롭게 느껴져야 할 지점에서 프로퍼티 문법이 그 느낌을 무디게 한다는 재검토 끝에 함수 호출 - `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. `.value` - 표기 자체는 폐기하지 않고 **Ref 전용으로 좁힘**(Ref는 lazy가 아니라 + `:Get()` 하나로 좁힘 — `:Set()`과의 동사 짝도 자연스러움. 프로퍼티 + 표기 자체는 폐기하지 않고 **Ref의 `.Value` 전용으로 좁힘**(**[표기 정정, + 2026-08-18]** 이 문단이 소문자 `.value`로 쓰고 있었음 — 실제 필드는 + 대문자 `.Value`. Ref는 lazy가 아니라 값을 읽어도 계산이 트리거되지 않으므로 프로퍼티 문법이 정직함 — `base/ref-plan.md`의 `.Value`가 그대로 유일한 존재가 됨, 이름 충돌 자체가 사라져 별도 표기 정리 불필요). -- 예시 갱신: `store "key1":With(store "key2"):Compute(function(key1) return +- 예시 갱신: `store.key1:With(store.key2):Compute(function(key1) return key1:Get() + store.key2:Get() end)` — `key1`은 이제 raw 숫자가 아니라 - State. + State. (**[2026-08-18]** 예시가 기각된 `store "key1"` 문자열 커링 문법으로 + 쓰여 있던 걸 dot-access로 고침.) **[2026-08-12 세션 감사에서 확인] `:Compute` 콜백 인자에 `:Get()`을 빠뜨리는 실수가 반복되기 쉬움 — 실제로 `.claude/` 문서 예시 코드 4곳(`tag-plan.md`, @@ -597,9 +633,13 @@ deps만 받고 싶어도 `previous`가 2번째 자리를 차지하므로, 그 객체일 수 있음(예: 큰 로케일 테이블을 Roblox `LocalizationTable` Instance로 변환하는 경우 — `LocalizationTable`은 `Set`/`Get`/`List`로 부분 갱신 가능한 userdata). 매번 새로 만들지 않고 이전 결과를 그대로 -재사용해 필드만 patch하고 싶을 때를 위해, `fn(value, previous)` 형태로 -**직전에 이 Compute 함수가 반환했던 값**을 두 번째 인자로 받을 수 있게 -한다. +재사용해 필드만 patch하고 싶을 때를 위해, **직전에 이 Compute 함수가 +반환했던 값**을 두 번째 인자로 받을 수 있게 한다. +**[표기 정정, 2026-08-18 구현 전 QA]** 이 절만 옛 표기 `fn(value, +previous)`로 남아 있었는데, 최종 시그니처는 **`fn(self, previous?, +...deps)`** 다 — 첫 인자는 raw 값이 아니라 **lazy 핸들**이라 안에서 +`self:Get()`을 불러야 한다(위 "`:With`/`:Compute` — self 인자도 lazy +핸들로 통일" 절이 소스, `:Get()` 누락이 반복되는 실수라 별도 절까지 있음). - **opt-in**: 안 쓰는 Compute 함수는 두 번째 인자를 그냥 무시하면 됨 — 비용 0. 대부분의 Compute는 이걸 쓸 필요 없음. @@ -906,8 +946,17 @@ retract/Destroy되면 자동으로 정리됨. 등으로 동적으로 흘러들어오면(타입 우회 버그) 명확히 에러내야 함 — 전용 `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`인 이유는 이게 무조건 막는 +function(inst,k,v) error(`Ref/Observer binding should be array index item, +but got {typeof(k)}`) end }`. +**[요구 추가, 2026-08-18 구현 전 QA] 에러 메시지에 실제 `k`의 타입을 +실을 것.** 사용자 요구: *"Priority Fallback 이 type(k) == "string" 인 +상황에서는 가장 위에 Ref/Observer binding should be array index item, but +got typeof k 처럼 알려줄 필요는 있는듯"*. 근거는 **메시지에 `k` 타입이 +없으면 최종 사용자가 두 원인을 구분할 수 없다는 것** — (a) 핸들러가 +등록이 안 된 것인지, (b) `MyRef = Ref(...)`처럼 named 자리에 잘못 쓴 +것인지. 같은 규칙이 `base/ref-plan.md`의 `PreRef`/`PostRef` 동적 경로 +가드와 `base/effect-plan.md`의 `Effect` 가드에도 그대로 적용된다. +`HANDLER_PRIORITY_FALLBACK`인 이유는 이게 무조건 막는 하드 블록이 아니라 `Tag`/`Attribute`/`PreRef`와 같은 "base가 소유하되 평범한 우선순위로 등록된 다른 Handler가 있으면 그쪽이 이기는" 자리이기 때문(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 @@ -1087,28 +1136,30 @@ leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifeti ```lua -- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) --- — 둘 다 진입 전 동일하게 확인 -if canBound(self) then +-- — 둘 다 진입 전 동일하게 확인. [정정, 2026-08-18 구현 전 QA] canBound는 +-- "지금 묶어도 되는가"(참 = 아직 안 묶임)라 게이트는 `not`이 붙는다. +if not canBound(self) then error(if self.Subscribed then "이미 :Subscribe()로 전역 바인딩된 값" else "이미 다른 Instance에 바인딩된 값") end ``` -- **"이미 유효하게 묶여 있다"(`canBound`)와 "지금 실행 가능하다" - (`canExecute`)는 판정 로직이 같아서**(둘 다 비공개 헬퍼 - `isBoundAlive`를 그대로 부름, `base/lifecycle-pattern.md`) 값은 항상 - 같지만, 호출부의 질문이 서로 달라 이름은 분리돼 있음 — 이 절(이중 - 바인딩 금지)은 `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 - 씀. +- **[정정, 2026-08-18 구현 전 QA] `canBound`와 `canExecute`는 값이 같은 게 + 아니라 서로의 부정이다** — `canBound(v) == not canExecute(v)`. 둘이 + 공유하는 건 판정 **로직**(비공개 헬퍼 `isBoundAlive`, + `base/lifecycle-pattern.md`)이지 판정 **값**이 아니다. 옛 서술("판정 + 로직이 같아서 값도 항상 같지만 호출부의 질문만 다르다")은 `canBound`를 + "이미 묶여 있는가"로 잘못 읽은 것이었음. 이 절(이중 바인딩 금지)은 + `canBound`를 쓰고, State emit 전파 루프만 `canExecute`를 씀. - **에러 메시지에서 어느 경로인지는 `.Subscribed`로 가름** — 이 필드는 **전역 `:Subscribe()` 경로에서만 세팅되므로**(아래 정정) 참이면 전역, - 거짓인데 `canBound`가 참이면 leaf 경로. -- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 유효한 - 바인딩이 있음"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 - 순서와 무관하게 대칭적으로 막힘. + 거짓인데 `canBound`가 **거짓**이면 leaf 경로. +- 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "지금 묶어도 되는가"만 + 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 + 대칭적으로 막힘. - **죽은 바인딩의 재사용은 허용** — `inst`가 Destroy됐거나 - `unbindLifetime`된 값은 `canBound`가 거짓이라 게이트를 통과함(다른 + `unbindLifetime`된 값은 `canBound`가 **참**이라 게이트를 통과함(다른 `inst`에 다시 걸 수 있음). 게이트가 막는 건 **살아있는** 이중 바인딩뿐. **[정정, 2026-08-14 다섯 번째 세션] 옛 서술 — "`canBound`의 내부 플래그는 @@ -1119,9 +1170,9 @@ end 그 필드를 읽지도 쓰지도 않음. leaf 경로의 생존은 `bindLifetime`이 `value` 쪽 릴레이션에 복사해둔 gcconn 참조로 판정됨(`base/lifecycle-pattern.md`). 옛 서술이 걱정했던 "필드를 둘로 나누면 `bindLifetime`으로만 등록된 -Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 일어남 -— `canBound`(와 `canExecute`가 공유하는 `isBoundAlive`)가 gcconn 경로를 -**먼저** 보기 때문. 역전 원문·오염 경로·교훈은 +Observer가 **안 묶인 것으로 오판**됨"은 실제로는 안 일어남 — +`canBound`/`canExecute`가 공유하는 `isBoundAlive`가 gcconn 경로를 +**먼저** 보므로, 그런 Observer는 `canBound`가 제대로 거짓이 된다. 역전 원문·오염 경로·교훈은 `archive/canexecute-inst-arg-reversed.md`(그 문서 하단에 이 재분리 경위도 추가돼 있음). - **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` @@ -1153,8 +1204,8 @@ Observer가 `canBound`에서 항상 `false`로 오판됨"은 실제로는 안 `Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 -`canExecute` 게이트를 확인** — 진입 전 `canExecute(value)`를 확인하고, -통과하면 gchold 등록 + gcconn 참조 복사를 수행. +`canBound` 게이트를 확인** — 진입 전 `canBound(value)`를 확인하고, +참이면(=아직 안 묶여 있으면) gchold 등록 + gcconn 참조 복사를 수행. **children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — `Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" @@ -1166,8 +1217,10 @@ leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만 ```lua function bindLifetime(inst, value) - if canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로 + if not canBound(value) then -- [정정, 2026-08-14 열두 번째 세션] 이 절이 확정한 대로 -- bindLifetime의 게이트는 canBound, canExecute 아님 + -- [정정, 2026-08-18 구현 전 QA] 방향이 뒤집혀 있었음 — + -- canBound 참 = 묶어도 됨이라 에러는 not 쪽 error("이미 바인딩된 값") -- 메시지 분기는 위 게이트 스케치 참고 end ... -- gchold 등록 + gcconn 참조 복사(base/lifecycle-pattern.md) @@ -1181,11 +1234,11 @@ end - **[정정, 2026-08-14 다섯 번째 세션] 게이트는 값 타입을 안 가린다** — 옛 서술은 "`canBound`는 `.Subscribed` 필드가 있는 Observer/Effect 전용 predicate라 그 외 값(예: Tween 내부 클로저, Slot)은 그냥 통과"였는데, - `canExecute`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 살아있는 - 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 마운트하는 - 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 실수를 - `bindLifetime` 층위에서도 공짜로 잡아줌. -- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canExecute`가 참이 되므로, 그 + 공유 헬퍼 `isBoundAlive`는 gcconn 경로를 먼저 보므로 **어떤 값이든** 이미 + 살아있는 바인딩이 있으면 걸러짐. 이게 더 맞음 — Slot을 두 `inst`에 이중 + 마운트하는 것도 원래 금지(`base/slot-plan.md`의 `elementOwner`)라, 같은 + 실수를 `bindLifetime` 층위에서도 공짜로 잡아줌. +- 값이 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 **거짓**이 되므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 없이 이미 성립. diff --git a/.claude/base/store-plan.md b/.claude/base/store-plan.md index 48e9d21..a682d70 100644 --- a/.claude/base/store-plan.md +++ b/.claude/base/store-plan.md @@ -58,6 +58,18 @@ State를 만족함" 절). 생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과 **`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어 저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함. +- **[확인 요구, 2026-08-18 구현 전 QA] lazy 생성이 오타/동적 키로 Source를 + 무한정 누적하는 트레이드오프는 그대로 수용하고, 방어선은 런타임이 아니라 + 타입에 둔다.** 사용자 판정: *"Store<{ field: type }> 상 없는 네임에는 + 타입 시간에 Source 가 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 + 설계가 그럴것이예요"* — 즉 `Store<{field: T}>`로 선언된 Store에 없는 + 이름을 쓰면 `type function`이 합성한 결과 타입에 그 프로퍼티가 없어 **타입 + 에러**가 나야 한다. **다만 사용자도 "아마"라고 했으므로 M0에서 실제로 + 확인할 것** — `type function`으로 합성한 테이블 타입이 (인덱서를 안 붙인 + 상태에서) 미선언 프로퍼티 접근을 실제로 거부하는지. + `luau-test/done/16-*`(type function으로 `Store` 레코드 필드 합성)에 + 이 음성 대조군이 있는지도 같이 볼 것. 런타임에 굳이 이름을 받아야 하는 + 경우는 `:GetDynamic`(아래 "타입 추론 문제" 절)이 정식 창구. - **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹 스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값 템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던 @@ -105,9 +117,15 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게 아니라 기존 원칙의 정상적인 적용. -**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키 -전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지. -`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지. +**남는 것**: `:` 체이닝 원칙은 `:Set()` 자체가 그 사례라 유지. +**[정정, 2026-08-18 구현 전 QA] `myStore "key"`(문자열 커링)는 기각됐다** — +여기엔 "동적 키 전용 미타입 폴백으로 격하돼 그대로 유지"로 적혀 있었으나, +사용자 판정은 폐기다: *"store "a" 식으로 문자열 호출하는것 또한 기각된 +바임. 저러면 "a" 가 string 으로 들어가서, Source 의 타입을 모르기도 +하고, 우린 더이상 필요하지 않게 된 요소임."* 근거는 (a) `"a"`가 그냥 +`string`으로 들어가 `Source`의 `T`를 알 수 없고, (b) dot-access + +`type function` 타이핑이 자리잡아 더 이상 필요 없어졌다는 것. 동적 키는 +아래 `:GetDynamic` 항목으로 간다. `base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께 읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며 @@ -117,7 +135,8 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 ## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드) - `store "key"`(문자열 커링)로 `state`를 오버로드 함수 타입으로 정확히 - 추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정** + 추론하려는 시도는 포기하고(그 문자열 커링 자체도 **[2026-08-18] 기각**, + 위 절), **`store.key`(dot-access)를 1급 경로로 확정** — Store 타입을 `{key: Source, other: Source}`류 평범한 레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열 리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State` @@ -125,8 +144,37 @@ Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐 갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가 더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이 해소됨, 위 "Store 값 설정 문법" 절 참고). - `store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source`) - 폴백으로 격하. + **[정정, 2026-08-18] 동적 키 경로는 문자열 커링이 아니라 명시적 메소드다** — + `store:GetDynamic<>(name): Source`. 런타임 동작 자체는 원래도 + dot-access와 같았고(lazy `__index`가 없는 이름을 만들면 그 자리에서 + Source를 만들어줌 — 아래 "없는 키" 항목) 문제는 **타입**뿐이었다: + 선언되지 않은 이름은 `type function`이 합성한 레코드 타입에 없어서 타입 + 에러가 난다(그게 방어선이라는 게 사용자 확정). 그래서 "런타임에 이름이 + 정해지는" 정당한 용도를 위해 **타입을 호출자가 직접 주는 명시적 창구**를 + 둔다 — 사용자 판정: *"동적히는 여전히 그냥 Store.Name 하면 얻어는 짐. + 타입 애러가 난다는 점인데, 이는 GetDynamic(name): Source 로 + 제공하는게 최선으로 보임."* 이름이 명시적이라 "여기서 타입 보장을 + 포기했다"가 호출부에 드러나는 것도 문자열 커링보다 나은 점. + - **⚠️ [구현 주의, 2026-08-18 감사에서 발견] 콜론 메소드는 Store의 + lazy `__index`와 정면으로 부딪힌다.** 위 "Store = Source들의 이름 붙은 + 모음" 절이 확정한 대로 **없는 키를 인덱싱하면 그 자리에서 `Source`를 + 만들어 저장**하므로, 아무 장치 없이 `store:GetDynamic("x")`를 부르면 + `store.GetDynamic`이 **`"GetDynamic"`이라는 이름의 새 `Source`를 + 만들어 반환**하고 그걸 함수로 호출해 런타임 에러가 난다. 따라서 + **`__index`가 고정 메소드 테이블을 먼저 확인하고, 없을 때만 lazy + `Source` 생성으로 폴백**해야 하며, 그 결과 **`GetDynamic`은 Store의 + 예약 키 이름이 된다**(그 이름의 Source는 dot-access로 못 만듦). + `Modifier`가 `Apply`/`Peek`/`Overridden`을 같은 이유로 예약하는 것과 + 정확히 같은 구조(`base/modifier-plan.md`의 "구현 시 주의") — 다만 + Store의 키 이름은 **사용자 도메인 데이터 이름**이라 Modifier(스타일 + 프로퍼티 이름)보다 충돌 확률이 높다는 게 차이. + - **대안(미결, 사용자 판단 필요)**: 예약 키를 하나도 만들고 싶지 않으면 + **탑레벨 함수**(`getDynamic(store, name)`)로 두면 된다 — `isState`/ + `bindLifetime`처럼 "특정 프리미티브에 안 묶인 범용 유틸은 소문자 + 탑레벨"이라는 기존 네이밍 규칙(`base/architecture.md`의 "코드 스타일 — + 네이밍 케이싱")에도 오히려 더 맞는다. 사용자가 지정한 표기는 + `GetDynamic(name)`이므로 **일단 콜론 메소드 + 예약 키로 적어두되, + M3/M4 구현 전에 어느 쪽인지 확인할 것**(`question.md` 3번). - 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트 전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의 **유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환). diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index 5989587..f1aa310 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -269,12 +269,13 @@ removeTag(inst: any, names: {string}): () - **등록 우선순위는 `HANDLER_PRIORITY_FALLBACK` — 단 여기 꽂히는 건 `TagHandler` 자신이 아니라 그걸 감싸는 `TagFallbackHandler`(2026-08-14 열두 번째 세션 정정 — `TagHandler`는 참조 카운트 알고리즘 구현일 - 뿐, 스스로 등록되는 주체가 아님).** 등록 주체는 quad-base 모듈 자체가 - 아니라 백엔드 팩토리 — quad-roblox 같은 백엔드가 `BaseModule`을 - 구성할 때 자기 전용 Handler들과 같이 이 `TagFallbackHandler`도 등록해줌 - (`base/module-lifecycle-plan.md`가 이미 확정해둔 "base는 인터페이스만, - 등록은 팩토리 뮤테이션 시점" 원칙 그대로). 옛 "quad-base 모듈 로드 - 시점에 스스로 등록" 모델은 + 뿐, 스스로 등록되는 주체가 아님).** **[재역전, 2026-08-18 구현 전 QA] + 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(모듈이 자기 + 레지스트리를 구성하는 시점) — 백엔드를 아직 로드하지 않은 상태에서도 + "provider가 초기화됐는지 확인하라"는 안내 에러 경로가 돌아야 하기 + 때문이고, 이건 `InitNamespace` 거부 원칙과 충돌하지 않는다(근거는 + `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 + 엔진 op" 절이 소스). 경위는 `archive/tag-attribute-load-time-registration-reversed.md`. `addTag`/`removeTag`만 백엔드 팩토리가 채우는 타입 계약, 안 채운 슬롯의 base 기본값은 명시적으로 에러내는 스텁. 더 명확한 메시지나 diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 062d0cb..0396a73 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -71,7 +71,7 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 타입체크되려면, 생성되는 `FrameModifier`류 정적 타입의 메소드 목록에 `UICorner`/`UIPadding`/`UIScale`이 (진짜 프로퍼티들과 나란히) 포함돼 있어야 함 — 순수 런타임 관점(제네릭 `__index`가 처리)에선 문제없지만, -타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(DI +타입 레벨에선 별도로 챙겨야 하는 항목.** quad-roblox의 각종 타입(`D` 인스턴스 타입, Modifier 타입 등)이 Roblox API 덤프를 읽어 Luau 타입 파일을 구워내는 스크립트로 생성될 예정이라(구현 단계 결정 사항) — 이 스크립트가 실제 Roblox 프로퍼티뿐 아니라 이 3개 숏핸드 키도 각 @@ -84,6 +84,30 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 피하기 위함. +**[요구 추가, 2026-08-18 구현 전 QA] 다시 찾을 때 `FindFirstChild` 대신 +`Relate`에 저장해둔 참조를 쓸 것.** 사용자 요구: *"FindFirstChild 는 비용이 +ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면 릴레이션으로 +저장하는것도 좋은 생각."* + +- **조회 경로**: `(inst, 숏핸드키) → child`를 `Relate`에 저장해두고 그걸로 + 되찾는다. `Relate`는 `inst`를 weak 키로 쓰므로 부모가 죽으면 항목도 + 자연히 빠진다(`base/relate-plan.md`). +- **고정 이름 규약을 없애자는 뜻은 아님** — 이름(`_quad_corner`류)은 + 디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든 + `UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은 + 표시·판정용, `Relate`는 조회용**으로 역할이 갈린다. +- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는 + `Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata + 동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`). + 숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지 + 구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다. +- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**: + *"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운 + dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."* + 즉 숏핸드 Handler가 자식 프로퍼티를 직접 쓰지 말고 + `Dispatch.process(child, k, v, 1)`로 넘기면 `State`/`Tween` 래핑이 + 공짜로 따라온다. + ### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, 반환 클로저는 관여 안 함 (2026-08-07 여덟 번째 세션) `modifier-plan.md`의 `None` 센티널(`base/dispatch-core-plan.md`의 diff --git a/.claude/conventions.md b/.claude/conventions.md index 3af73ac..eda635e 100644 --- a/.claude/conventions.md +++ b/.claude/conventions.md @@ -134,6 +134,19 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 조사하게 되는 재작업을 막기 위함. `.claude/base/`로 승격, `.claude/qa-request/`로 이동, 또는 문서 자체를 갱신. code-docker/webmanager의 `.claude/` 관리 방식이 좋은 예시(`.claude/initreq/code-docker/webmanager/.claude/README.md` 참고). +- **⭐ [2026-08-19 신설] 설계 결정이 오간 세션은 끝나기 전에 + `session/YYYY-MM-DD-NN-slug.md` 원문을 반드시 남길 것.** 2026-08-19 + 세션이 점검해보니 2026-08-18(커밋 10개, QA 1~2라운드 포함)엔 `session/` + 파일이 1개뿐이었고 2026-08-19는 이 점검 전까지 0개였다 — 실제로 여러 + 세션이 이 규율을 빠뜨렸다는 뜻(경위는 + `session/2026-08-19-01-new-initxxx-composition-relate-guard.md`). **사용자 + 판단(2026-08-19): 이미 지나간 공백은 재구성하지 말고 인정하고 넘어간다** + (그 시점 대화 원문에 접근할 수 없는 채로 "원문"을 지어내면 그 자체가 + 허위 기록이 됨) — 대신 **앞으로 같은 공백이 반복되지 않도록** 이 항목을 + 명문화한다. `base/`/`research/`에 실제로 반영되는 설계 결정이 있었던 + 세션은, 커밋 직전이 아니라 **작업이 일단락되는 시점마다** session/ 파일을 + 쓸 것 — 핸드오버 체크리스트(바로 아래)가 요구하는 감사·커밋 절차와 + 별개로, 이건 그 이전에 지켜야 할 절차다. - **⭐ 중대 변경 핸드오버 체크리스트 — 확정된 결정을 뒤집거나 문서를 쪼갤 때 반드시 이 순서를 밟을 것.** 2026-08-13 일곱/여덟 번째 세션에 6+6라운드 수동 감사로 55건을 찾았는데, **거의 전부가 "변경한 세션이 @@ -189,9 +202,10 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 짜는 대신 고정 정의로 옮긴 것. **아주 큰 변경**(설계 반전 규모)엔 이걸로 대체하지 말고 `/code-review`(diff 기반)와 사용자의 직접 diff 검토를 병행할 것 — 이 서브에이전트는 diff가 아니라 코퍼스 전체의 정합성만 봄. -- **⭐ [2026-08-16 재설계] "핸드오버 준비하고 커밋해" 류 요청엔 되묻지 말고 - 아래 감사 루프를 돌릴 것.** 단일 `quad-doc-auditor` 패스는 비결정적이라 - 매번 다 잡는다는 보장이 없다는 게 사용자 지적 — 그래서 병렬 다회로 돌린다. +- **⭐ [2026-08-16 신설, 2026-08-18 재설계] "핸드오버 준비하고 커밋해" 류 + 요청엔 되묻지 말고 아래 감사 루프를 돌릴 것.** 단일 `quad-doc-auditor` + 패스는 비결정적이라 매번 다 잡는다는 보장이 없다 — 그래서 **여러 번** + 돌린다. 다만 **여러 개를 동시에 띄우지는 않는다**(아래 1번). **처음엔 이걸 Workflow(`quad-handover-audit.js`)로 만들어 픽스까지 서브에이전트에 맡겼으나, 첫 실동에서 에이전트 67개·4.6M 토큰을 쓰고도 수렴하지 못해 2026-08-16에 폐기**(원문은 `archive/`가 아니라 git 히스토리와 @@ -199,34 +213,61 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 갈림, (2) 파일별로 픽스 에이전트를 쪼개니 **그 에이전트들이 또 부정확한 서술을 새로 만들어냄**, (3) 서브에이전트는 사용자에게 물을 수가 없어서 판단이 필요한 항목이 그냥 임의 처리됨. 지금 절차: - 1. **`quad-doc-auditor`를 한 메시지에서 병렬 호출**(Agent 도구 직접 호출 - — Workflow 안 씀). **최소 2개, 변경 사항이 많으면 그만큼 늘릴 것** — - 사용자 지침(2026-08-16): "최소 2에, 변경 사항이 많아진다면 batch 수가 - 늘어나는게 맞다고 봄". 감사자는 sonnet이라 패스당 비용이 낮고 역할이 - "빠르게 전체를 훑는 것"이라, 커버리지를 넓히는 쪽이 이득인 구조다 - (사실 검증은 상위 모델인 메인이 한다). 기준은 라이브 문서 몇 개가 - 바뀌었나 — 두어 개 고친 정도면 2개, 설계 반전이나 문서 분할처럼 - 코퍼스 전반을 건드린 변경이면 3~4개. + 1. **⭐ `quad-doc-auditor`는 한 턴에 하나만 호출한다 — 병렬 금지** + (Agent 도구 직접 호출, Workflow 안 씀). **사용자 지침(2026-08-18): + "시간이 걸려도 상관 없으니, 차라리 병렬 에이전트를 덜 써줘. 그냥 한 + 턴에 하나씩만 사용하고, 0 이 나올때 까지 턴 수를 늘리는게 나아보임. + 토큰을 너무 많이 소비해서 다른 작업을 못 하고, 세션 한도에 닿더라고."** + 이건 2026-08-16의 "최소 2개 병렬, 변경이 많으면 3~4개로 늘림" 지침을 + **대체한다** — 커버리지를 병렬 폭이 아니라 **턴 수**로 얻는다. + 근거가 된 실측(2026-08-18): 전 코퍼스 감사 **한 패스가 서브에이전트 + 토큰 21만 / 툴 호출 82회**였고, 계획대로 4개를 병렬로 돌렸으면 한 + 번에 80만 토큰대였다 — 세션 한도를 그 자리에서 태워 정작 본 작업을 + 못 하게 된다. **감사자 모델은 `sonnet` 유지 — haiku로 더 낮추지 말 것**(사용자 판단, 2026-08-16). 실제로 이 감사가 잡아온 것들(재역전된 배너, 자기 메모리의 stale 서술)은 문맥 추론이 꽤 들어간 발견이었다. - 2. **두 리포트를 메인 세션이 합쳐서 검토하고, 수정도 메인이 일괄로 한다.** + 2. **⭐ [2026-08-18 신설] 프롬프트로 감사 범위를 좁힐 것 — 기본은 diff + 범위다.** 감사자 정의 자체는 "코퍼스 전체를 신선한 맥락에서 다시 + 읽는다"인데(라이브 문서 90개 이상, `base/`만 1만 줄 이상), 대부분의 + 핸드오버에서 실제로 위험한 건 **이번에 바뀐 파일과 그걸 인용하는 + 곳**이다. 그래서 프롬프트에 (a) 바뀐 파일 목록, (b) 이번에 뒤집힌 + 결정 목록, (c) "그 파일들을 인용하는 곳까지만 확장해서 보라"를 명시할 + 것. 전 코퍼스 스윕은 **오래 안 돌렸을 때 한 번씩**만. + 라운드마다 각도를 바꾸면(예: 1라운드는 `base/` 정합성, 2라운드는 + 인덱스 레이어(README/ROADMAP/question/todos), 3라운드는 + archive/luau-test/audit) 병렬로 폭을 넓히던 효과를 턴으로 얻을 수 있다. + 3. **리포트는 메인 세션이 검토하고, 수정도 메인이 한다.** 픽스를 서브에이전트에 위임하지 말 것(위 (2)번 이유). 감사자는 읽기 전용이고 발견만 리포트한다. - 3. **애매하면 임의로 정하지 말고 그 자리에서 사용자에게 보고할 것.** + 4. **애매하면 임의로 정하지 말고 그 자리에서 사용자에게 보고할 것.** 메인 세션은 사용자에게 물을 수 있다는 게 서브에이전트 대비 유일한 강점이니 그걸 쓸 것(어떤 발견이 여기 해당하는지 판정 기준은 `.claude/agents/quad-doc-auditor.md`의 "출력 형식" 절이 소스 — 여기서 다시 나열하지 않음). - 4. **새 발견이 없는 라운드가 연속 2번 나올 때까지 1~2를 반복**(보통 - 2~3라운드). **수렴 안 하면 조용히 멈추지 말고 사용자에게 보고할 것** — + 5. **새 발견이 0건인 라운드가 나올 때까지 1~3을 반복**(사용자 지침 + 2026-08-18의 "0 이 나올때 까지 턴 수를 늘리는게"). 옛 규칙이던 "무발견 + **2연속**"은 병렬 다패스를 전제한 것이라 같이 완화됐다 — 대신 라운드마다 + 각도를 바꾸는 것(위 2번)이 그 역할을 대신한다. + **수렴 안 하면 조용히 멈추지 말고 사용자에게 보고할 것** — 첫 실동이 실제로 6라운드를 소진하고도 수렴 못 했고(새 발견 28→15→16→7→11→6, 단조 감소가 아니었음), 그때 조용히 끊으면 - "감사 통과"로 오독된다. - 5. `python3 .claude/tools/doc-check.py`로 ERROR 0을 최종 확인한 뒤 평소 + "감사 통과"로 오독된다. 비용 때문에 중간에 멈출 때도 마찬가지로 + "몇 라운드에서 왜 멈췄는지"를 반드시 보고할 것. + 6. `python3 .claude/tools/doc-check.py`로 ERROR 0을 최종 확인한 뒤 평소 커밋 절차(git status/diff 검토, 메시지 작성)로 넘어갈 것. **`git commit`은 항상 메인 세션이 직접 함** — 커밋 전 diff 재검토는 대화형 맥락이 필요해서 위임하지 않는다. +- **⭐ [2026-08-18 실측] `/code-review`는 감사자를 대체하지 않는다 — 큰 + 변경에는 둘 다 필요하다.** 2026-08-18 핸드오버에서 감사자 1패스가 1건을 + 잡은 뒤 사용자가 `/code-review high`를 직접 돌리자 **10건이 더 나왔고 + 전부 유효**했다. 둘이 보는 축이 다르기 때문 — 감사자는 **코퍼스 전체의 + 의미론적 정합성**(A 문서의 결정과 B 문서의 서술이 어긋나는가), code-review는 + **diff 자체의 결함**(이번에 새로 쓴 서술 안의 모순, 새 API가 기존 계약과 + 충돌하는가). 실제로 그 10건엔 "새로 확정한 `store:GetDynamic`이 Store의 + lazy `__index`와 충돌해 그대로 구현하면 런타임 에러"처럼 감사자 각도에선 + 안 보이는 것이 있었다. **`/code-review`는 사용자만 호출할 수 있으므로**, + 큰 변경을 커밋하기 전엔 그걸 돌릴지 사용자에게 물어볼 것. - **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할 것.** 다만 위 체크리스트+`doc-check.py`+`quad-doc-auditor`가 자리잡으면 이 감사는 "기계도 서브에이전트도 못 보는 것"(설계 자체의 자기모순, 의사코드 @@ -259,4 +300,22 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는 **로컬 git 커밋까지만** 하고 원격 추가/푸시는 하지 말 것. (2) Roblox Studio는 메인 계정이 아닌 별도 계정으로만 사용 — 로그인 계정 전환을 사용자가 안 해줬다면 Studio 관련 작업(MCP 연결 등)을 진행하지 말고 대기. +- **⭐ [2026-08-18 신설, 같은 날 정정] 이 레포의 모든 커밋에 사용자 GitHub + 계정(`github.com/qwreey`)을 co-author로 추가할 것 — 커밋 메시지 끝에 + `Co-authored-by: qwreey `만 넣는다. 하네스 기본 + `Co-Authored-By: Claude Sonnet 5 ` 트레일러는 이 + 레포에 한해 넣지 말 것.** **사용자 확정**(2026-08-18) — 커밋 계정 자체가 + `qwreey-bot`이라 이미 에이전트 커밋임이 드러나므로 Claude 트레일러까지 + 이중으로 밝힐 필요는 없고, 대신 해당 GitHub 계정 프로필에 Claude를 + 사용한다고 명시해둘 예정("어차피 qwreey-bot 자체가 에이전트가 커밋했다고 + 알려주는지라 큰 필요는 없어"). `.claude/settings.json`에 + `"includeCoAuthoredBy": false`도 선언해뒀지만 **[2026-08-18 확인] 하네스 + 버그로 무시됨**(`includeCoAuthoredBy`/`attribution.commit` 둘 다 알려진 + 미해결 버그 — GitHub Issue #7543/#65657) — 그래서 실제 억제는 이 문장을 + 세션이 읽고 지키는 방식으로만 보장된다. **qwreey 트레일러는 계속 + 추가한다** — 위 문단이 이미 그 근거(GitHub 기여 그래프 반영)를 서술함, + 여기서 반복 안 함. `github` 리모트로 싱크되어 + `upstream`(`github.com/qwreey/quad`, 사용자 본인 소유)에 PR이 merge될 때 + GitHub 기여 그래프에 반영되게 하기 위함 — 관련 리모트 정책은 메모리 + `git-remote-push-policy` 및 위 `SAFETY.md` 항목 참고. diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 297d432..d625c57 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -78,7 +78,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `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/`, 열한 번째 세션에 `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/session-summary.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` "Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이 들어오면 즉시 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 열한 번째 세션 신설) | +| `12-type-attribute-generic-key-narrowing.luau` (타입체크 전용) | `[AttributeKey<> "name"] = value`(구 `Attribute<>`)처럼 제네릭 특수 키를 쓸 때 `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 열한 번째 세션 재정정) | | `14-type-nilable-default-overload.luau` (타입체크 전용) | `Source(default)`/`Ref(default)`의 `default` 생략이 `T`가 nilable일 때만 안전하다는 캐비엇을, 함수 오버로드(교차 타입)로 실제로 타입 레벨에서 막을 수 있는지 | `source-state-plan.md` "State는 쓰기 대상이 아님" 절의 `default` 생략 캐비엇 | | `15-type-compute-trailing-deps-typepack.luau` (타입체크 전용) | `:Compute(fn, ...)`의 trailing deps를 `fn`에 위치 인자(lazy State 핸들)로도 노출하는 확장, 최종 시그니처 `fn(self, previous?, ...deps)` — 이형(heterogeneous) 다중 deps를 제네릭 타입 팩(`U...`)으로 표현 가능한지, `previous?`가 팩 앞(정정된 순서)에서만 통과하고 팩 뒤(옛 순서)에서는 막히는지 | `source-state-plan.md` "trailing deps를 fn에 lazy positional 인자로도 노출" 절(2026-08-11 후속 세션, 순서는 같은 날 세 번째 세션에 정정) | @@ -114,7 +114,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 계속 `None` — 두 카테고리로 나눠 각각 재현). - `12`/`13`/`14`: **신규 추가.** 사용자 요청으로 "타입 관련 실측 필요 항목, 특히 luau-lsp로 확인해야 하는 것"을 새로 찾아 만듦 — Attribute - 제네릭 DI 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + + 제네릭 특수 키의 값 타입 narrowing(12), Ref/PreRef 구조적 서브타입 + `isRef`/`isPreRef` 재정정(13), Source/Ref의 nilable-default 캐비엇을 오버로드로 막을 수 있는지(14). 셋 다 base 문서가 "미검증"/"실측 필요" 로 스스로 표시해둔 지점이거나(12, 14) 이번 f198fd9에서 뒤집힌 결정 @@ -239,13 +239,14 @@ Connected 즉시 전환"은 새 모델에서도 유효**(재작성 시 살릴 재검토해야 하는 심각한 발견이니 바로 알려줄 것 — **[2026-08-13] 이 조건은 이미 회피 확인됨**(`audit/gcconn-trick-verification.md`), 재확인 불필요. - - **[2026-08-14 열한 번째 세션 재정정]** 이중 바인딩 게이트는 - **`canBound(value)`**다(`if canBound(v) then error(...) end`) — + - **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 + 바인딩 게이트는 **`canBound(value)`**다(`if not canBound(v) then + error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 남고, `canBound`가 - 별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유, - `lifecycle-pattern.md` "`canBound` vs `canExecute`" 절). 판정 - 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 - `bindLifetime`할 수 있어야 하고(게이트가 `canBound` 거짓이라 + 별도 진입점으로 재도입됨(판정 로직은 비공개 헬퍼 하나를 공유하되 + **서로의 부정**, `lifecycle-pattern.md` "`canBound` vs `canExecute`" + 절). 판정 기준은 안 바뀜: `unbindLifetime(value)` 이후 같은 값을 다시 + `bindLifetime`할 수 있어야 하고(게이트가 `canBound` **참**이라 통과), **`inst`가 Destroy된 뒤의 재바인딩도 명시적으로 허용**임 (살아있는 바인딩만 막는 게 게이트의 의도). 이게 실패하면 이 재분리 설계 자체를 재검토해야 함 — **아직 미확인.** diff --git a/.claude/luau-test/STATUS.md b/.claude/luau-test/STATUS.md index af7dbbd..22ca682 100644 --- a/.claude/luau-test/STATUS.md +++ b/.claude/luau-test/STATUS.md @@ -72,7 +72,7 @@ | `05-store-state-diamond-propagation.luau` | 옛 모델 기준으로는 ✅ 통과였음 | **검증하던 모델이 뒤집힘**(2026-08-14) — 이 스파이크는 "이미 dirty면 더 아래로 전파하지 않음"을 assert하는데, 그게 `Observer` 계약과 모순돼 폐기됨(`archive/invalidate-dedup-propagation-reversed.md`). 재작성 방향: **emit은 자기 invalid 상태와 무관하게 항상 전파**되는지, 중복 재계산은 `:Get()` 시점 캐시로만 막히는지(재계산 1회 검증은 그대로 유효), 그리고 **`:Get()`을 안 부르는 `Observer`가 매 변경마다 계속 울리는지**(옛 모델에선 두 번째부터 침묵 — 이게 음성 대조군으로 딱 맞음) | | `13-type-ref-preref-subtype.luau` | 타입 A섹션 ✅ 통과 / **런타임 B섹션 실행 불가** | B가 A의 더미 스텁(`fakePreRef = nil`)에 막혀 도달 못 함 — 두 섹션을 파일로 분리 | | `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 | -| `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 섹션은 손댈 것 없음 | +| `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 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `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/project-context.md b/.claude/project-context.md index 0b5bdce..43fe517 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -69,9 +69,13 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 결론은 `base/typing-limits.md`로 승격 — 이후 신설된 폴더들도 같은 구성 관례를 따름(`type-recursive-issue-with-typeof/`, `type-recursive-issue-try-callback/` 등). -- `.claude/qa-request/`, `.claude/feedback/` — 구현 시작되면 쓰기 시작함, - **[2026-08-16 기준] 아직 비어 있음**(`feedback/`은 폴더 자체가 아직 - 없음). `.claude/archive/`는 원래 같은 취급이었으나 +- `.claude/qa-request/` — 원래는 "구현이 끝나고 사용자 실기기 QA만 남은 것"을 + 담는 폴더였으나, **[2026-08-18]** 구현 전 사용자 심사 라운드의 산출물도 + 여기 둠(`pre-implementation-qa-round1.md`/`pre-implementation-qa-round2.md`/ + `pre-implementation-qa-round3.md` 전부 **완료** — 라운드마다 새 + 파일, 상태의 소스는 각 파일 자신). `.claude/feedback/` — 구현 시작되면 쓰기 시작함, + **[2026-08-18 기준] 폴더 자체가 아직 없음**. + `.claude/archive/`는 원래 같은 취급이었으나 2026-08-06 세 번째 세션부터 **완전히 뒤집힌 설계 결정을 원문+역전 이유+diff와 함께 보존하는 용도로도 사용 시작**(구현 완료 대상만이 아님) — `archive/store-source-proxy-reversed.md`가 첫 사례, 나중 diff --git a/.claude/qa-request/pre-implementation-qa-round1.md b/.claude/qa-request/pre-implementation-qa-round1.md new file mode 100644 index 0000000..d996b40 --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round1.md @@ -0,0 +1,1245 @@ +# 구현 전 QA **1라운드** — 사용자 심사에서 "아니오"가 나온 항목 + +**상태**: **1라운드 완료 + `base/` 반영 완료(2026-08-18)**. 이 파일은 +`.claude/qa-request/`에 라운드별로 쌓인다 — **2라운드는 새 파일** +(`pre-implementation-qa-round2.md`)로 만들 것이고, 이 문서를 이어 쓰지 않는다. +2라운드 대상은 맨 아래 "진행 로그" 절의 "아직 안 본 것" 목록. + +**1라운드 범위**(2026-08-18 세션에 시작). `.claude/base/` 확정 문서를 의존성 +순서로 훑으며 **표면 타입계약 / 내부 구현 메커니즘 / 동작 원리·불변식** 세 +층위로 "예가 나와야 정상인 주장"을 사용자에게 확인받는 작업의 산출물. + +**이 문서의 용도**: 사용자가 "아니오" 또는 "부분적으로 틀림"으로 판정한 +항목만 모은다. **[2026-08-18 갱신] 반영 완료 — 이 문서는 이제 "무엇이 왜 +틀렸었나"의 근거 기록이고, 지금 유효한 설계는 항상 `base/`가 소스다.** +같은 날 세션에서 아래 항목 전부를 `base/`(+`ROADMAP.md`/`question.md`/ +`archive/`)에 반영했고, 반영 과정에서 판단이 갈리던 네 건(SL-3의 `PopOnly`, +D-7의 재역전 여부, N-4의 `NoneHandler`/`NilHandler` 역할 분담, ST-2의 동적 +키 경로)은 그 자리에서 사용자에게 물어 확정했다 — 그 답변도 각 항목에 +반영돼 있다. + +**아직 안 닫힌 것**(결론 전에 해당 마일스톤 착수 금지)은 `question.md` 3번과 +`.claude/todos.md` 00번이 소스 — 여기서 다시 나열하지 않는다. + +**표기**: `X-N`의 `X`는 문서 코드(A=architecture, S=source-state, …), +`N`은 그 문서 안 질문 순번. 사용자 답변 원문은 그대로 인용한다(`conventions.md`의 +"사용자 발언을 근거로 인용할 때" 관례). + +**진행 현황의 소스는 이 문서 맨 아래 "진행 로그" 절** — 어느 문서까지 +심사했는지는 거기가 소스이고, 다른 곳에 개수를 적지 않는다. + +## 먼저 볼 것 — 파급이 큰 순서 + +목록 전체를 순서대로 읽기 전에, **다른 항목의 전제가 되거나 코드가 반대로 +도는** 것부터 보는 게 효율적이다. 아래 분류는 심사한 에이전트의 판단이고 +확정이 아니다 — 회신 시 우선순위가 다르면 그대로 알려주면 된다. + +**1. 구현하면 반대로 도는 것** +- `S-1` — `canBound` 게이트 호출부 반전. 지금 문서대로 짜면 **정상 첫 + 바인드가 전부 에러나고 이중 바인드는 통과**한다. `lifecycle-pattern.md`가 + 진원지이고 `source-state-plan.md`/`ref-plan.md`가 전부 이걸 인용한다. +- `RE-1` — gcconn/gchold를 `SetStrong`으로 적은 두 곳. 그대로 구현하면 같은 + 문서가 경고하는 **두-`Relate` 상호 강참조 누수**에 정확히 걸린다. + +**2. 설계 자체가 바뀌는 것** +- `RF-4` + `N-4` — `drive`의 `None` 스킵 제거, `NoneHandler`가 `k=number`를 + 직접 처리, `NilHandler` 신설. `D-6`의 열린 갭이 여기서 닫힌다. +- `EV-1` — 이벤트 disconnect 센티널 `false` → `None`/`nil`. +- `BS-2` — "이벤트 콜백은 타입 검증 못 한다"가 거짓. `onchange-plan.md`의 + 근거까지 같이 무너진다. +- `R-1` — `Ref`의 내부 구조(`.Callbacks` 분리 + `.Value`를 평범한 필드로). +- `SL-3` — `:List` reconcile의 `nil` 리턴을 파괴로 되돌릴지 + `PopOnly`. + +**3. 아직 답이 없는 것 (착수 전 결론 필요)** +- `S-12` — 중간 State가 GC되는지 **미검증**. M3 착수 전 실측 필요. +- `AT-1` — `Frame{a, a}`(같은 그룹 객체 이중 배치)를 UB로 둘지 error로 잡을지. +- `D-7` — base Fallback Handler 등록 주체가 다시 뒤집힐 가능성. + +**4. 이름·표면 확정 (같이 처리하면 효율적)** +- `N-8` — **`DI` → `D` 리네임 확정**(2026-08-18, `question.md` 1순위였던 + 항목). 라이브 문서 19개 파일에 걸쳐 있고, **헤딩 1개와 그걸 절 인용하는 + 곳 1개가 짝으로 묶여** 있어 한쪽만 고치면 `doc-check.py` ERROR가 난다. + 반영 대상 전수 목록이 그 항목에 표로 들어있다. +- `N-9` — **`New` 커링 + `D`는 전량 코드 생성** (2026-08-18에 열린 항목 전부 확정됨). + N-8과 **같은 줄들을 건드리므로 한 번에 처리할 것**(`architecture.md` + 소스 트리 주석, `ROADMAP.md` 체크박스). **`BS-2`와는 같은 문제의 양면**이라 + (인덱싱으로는 이벤트 콜백 타입이 안 나온다는 것) 반드시 묶어서 볼 것. + 부수적으로 `bind-system-plan.md`의 **PA님 시그니처 인용**과 **"알려진/모르는 + 타입을 전부 커버"** 서술 두 곳이 더 이상 정확하지 않게 됨. + +**5. 나머지** — `A-3`, `ST-2`, `D-1`, `D-5`, `M-3`, `R-3`, `B-1`, `SL-1`, +`E-2`, 그리고 `N-1`~`N-7`(신규 요구사항). + +--- + +## A. `base/architecture.md` + +### A-3 — `New()` 도입 시 "자동으로 테이블별 스코핑"된다는 서술 + +- **판정**: 부분적으로 틀림 (사용자 답변: "이해가 잘 안가나, …") +- **문서 위치**: `base/architecture.md`의 "확정된 결정" 13번 항목 + (`base/dispatch-core-plan.md`의 "Dispatch는 프리미티브가 아니다" 절도 + 같은 주장을 근거로 인용함 — 같이 확인 필요). +- **문서가 주장하는 것**: `New()`가 생기면 매번 새 `BaseModule` 테이블을 + 만들어 팩토리로 채우는 것뿐이고, 그러면 Dispatch 핸들러 레지스트리와 + `_initializedBy` 마커 등 지금 module-level state로 사는 모든 것이 + **자동으로** 테이블별 스코핑된다. +- **사용자 답변 원문**: + > 이해가 잘 안가나, 모듈이 하나의 인스턴스(dispatch 레지스트리 하나, + > canExecute 등 계약 필드 하나) 만 가지고 있다면 예. 단, 나중에 모듈이 + > 여러 인스턴스를 지원해, quad-roblox, quad-mock 등을 같이 굴리고 싶어지는 + > 시점이 올 때는 v1 처럼 Init() 수행 가능하도록 두는데, require 를 감싸지는 + > 않고 단순히 InitModule(module) 등을 받도록 각 코드들을 약간 고쳐서 이것을 + > 해결함. Quad() 하면 새로운 quad 가 나오는 식. 단 지금은 단순 싱글톤으로 + > 구현해 Quad() 안하고 Quad.Dispatch 접근 가능. +- **어긋나는 지점**: 문서는 "코드 변경 없이 자동으로 스코핑됨"을 주장하지만, + 사용자는 **"각 코드들을 약간 고쳐서"** 해결한다고 봄 — 즉 module-level + state를 참조하는 코드들이 `InitModule(module)` 같은 걸 받도록 손을 대야 + 한다는 것. 또 미래 API 이름도 문서의 `New()`가 아니라 **`Quad()`** 로 + 언급됨(현재는 싱글톤이라 `Quad()` 없이 `Quad.Dispatch` 직접 접근). +- **파급**: 실제 코드 배치에 영향 — module-level upvalue로 레지스트리를 잡아두면 + 나중 다중 인스턴스화 때 전면 수정이 되므로, 지금부터 그 참조 형태를 + 정해둘지 여부가 M0 스캐폴딩 결정이 됨. +- **⚠️ [후속 정정, 2026-08-19] 위 "어긋나는 지점"의 이름 해석이 부정확했음 + — `New()`를 통째로 `Quad()`로 바꾸는 건 사용자 의도가 아니었다.** 이 + 판정을 그대로 `base/architecture.md`에 반영했다가(2026-08-18) 여러 파일에 + `New()`→`Quad()` 전면 치환이 퍼졌었는데, 사용자가 직접 바로잡음 — + *"New 와 Quad 가 이름 모순은 아닐꺼야... Quad 는 기본적으로 생성된걸 + 리턴하긴 하는데, Quad.New() 도 제공하는거 어떻냐는거였음, 즉 New() 는 + 존재하고 기본 리턴은 New() 해서 주는건데, 리턴 안에 New 필드가 있고 + 그 함수를 쓰면 하나의 새로운 Quad 네임스페이스가 만들어지는식."* + 실제 의미: `Quad`(`require`의 반환값)는 이미 만들어진 기본 인스턴스이고, + `New()`는 그 안에 있는 **명시적 opt-in 필드**(호출하면 별도의 새 + 네임스페이스 생성) — "그냥 `Quad()`를 부르면 매번 새 인스턴스"가 아님. + 컴포넌트를 여러 모듈로 쪼갠 앱에서 각자 `Quad()`를 불러 인스턴스를 + "얻어야" 하는 모델이면 실수로 서로 다른 인스턴스가 생겨버리는 게 + 진짜 문제였음. 지금 유효한 서술은 `base/architecture.md` "확정된 결정" + 13번의 재정정이 소스. + +--- + +## S. `base/source-state-plan.md` + +### S-1 — `canBound` 게이트 호출부가 뒤집혀 있음 ⚠️ 파급 큼 + +- **판정**: 아니오 — **호출부가 잘못됨**(이름 `canBound`는 맞음). +- **문서 위치**: `base/source-state-plan.md`의 "이중 바인딩 금지" 절의 게이트 + 스케치와, 같은 문서 "`bindLifetime`이 이 게이트의 두 번째" 절의 + `bindLifetime` 의사코드. **`base/lifecycle-pattern.md`의 "`canBound` vs + `canExecute`" 절이 이 판정의 소스라고 선언돼 있으므로 거기가 진짜 + 진원지** — 정정 시 그 문서부터 볼 것. +- **문서가 주장하는 것**: + ```lua + if canBound(self) then + error("이미 :Subscribe()로 전역 바인딩된 값" / "이미 다른 Instance에 바인딩된 값") + end + ``` + 즉 `canBound == true`가 "이미 살아있게 묶여 있다 = 더 못 묶는다"는 뜻으로 + 쓰이고 있음. +- **사용자 판정**: 이름이 아니라 **호출부가 잘못됐다** — `canBound`는 이름 + 그대로 "지금 묶을 수 있는가"(true = 묶어도 됨)여야 하고, 게이트는 + `if not canBound(value) then error(...) end` 형태가 돼야 한다. +- **연쇄로 같이 무너지는 서술 (정정 시 반드시 함께 볼 것)**: + 1. **"`canBound`와 `canExecute`는 판정 로직이 같아서 값이 항상 같다"** — + `base/source-state-plan.md` "이중 바인딩 금지" 절과 + `base/lifecycle-pattern.md`가 둘 다 "비공개 헬퍼 `isBoundAlive`를 그대로 + 부른다"고 서술함. 그런데 `canExecute`는 true=살아있음이고 `canBound`는 + true=아직 안 묶임이므로, 올바른 관계는 **동치가 아니라 부정** + (`canBound(v) == not isBoundAlive(v)`)이다. "값은 항상 같지만 호출부의 + 질문이 달라 이름만 분리했다"는 근거 서술 자체가 성립하지 않게 됨 — + 실제로는 **반대 방향을 묻는 두 predicate**임. + 2. **`bindLifetime` 의사코드의 `if canBound(value) then error("이미 + 바인딩된 값") end`** — 같은 이유로 뒤집혀 있음. 이 줄엔 + `[정정, 2026-08-14 열두 번째 세션]` 배너까지 붙어 "게이트는 canBound가 + 맞다"고 못박아 뒀는데, 정작 방향이 틀린 채로 확정된 것. + 3. **"죽은 바인딩의 재사용은 허용 — `canBound`가 거짓이라 게이트를 + 통과함"** — 방향이 뒤집히면 이 문장도 뒤집혀야 함(죽은 바인딩은 + `canBound`가 **참**이라 통과). +- **파급**: M8(`Ref`/`PreRef`/`PostRef`)과 M3(Observer/Effect) 양쪽의 진입 + 게이트가 전부 이 predicate를 쓴다. 지금 문서대로 구현하면 **정상적인 첫 + 바인드가 전부 에러나고 이중 바인드는 무사통과**하는, 정확히 반대로 도는 + 게이트가 된다. +- **정정해야 할 정확한 위치 (`base/lifecycle-pattern.md` 확인 후 확정)**: + 1. **`canBound`의 구현 자체** — `(1)` 코드 블록: + ```lua + function canBound(value) + return isBoundAlive(value) -- ← 이름대로면 not isBoundAlive(value) 여야 함 + end + ``` + `canExecute`는 `return isBoundAlive(value)`가 맞으므로, **둘은 서로의 + 부정**이 된다. 같은 블록의 주석("어느 쪽 진입점에서 물어도 항상 같은 + 값이라")과 `(3)` 절의 "오늘 두 문맥의 판정값은 우연히 같다"도 함께 + 틀리게 됨. + 2. **`bindLifetime`의 가드** — `if canBound(value) then error(...)` + (`(1)` 코드 블록). + 3. **`Observer:Subscribe()`의 가드** — `if canBound(self) then error(...)` + (`(2)` 코드 블록). + 4. **`(3)` 절 끝의 "죽은 바인딩 재사용" 서술** — "`canBound`가 **거짓**이라 + 게이트를 통과함" → 방향이 뒤집히면 "**참**이라 통과함"이 됨. + 5. `base/source-state-plan.md`의 "이중 바인딩 금지" 절 게이트 스케치와 + `bindLifetime` 의사코드(앞서 적은 것). + 6. `base/ref-plan.md`의 "이중 배치 방지" 절 — 같은 게이트를 쓴다고 + 선언돼 있으므로 같은 방향 오류가 있는지 확인 필요. +- **⚠️ 함께 재검토할 것**: 이 정정이 들어가면 `(3)` 절이 두 이름을 나눈 + 근거였던 *"판정 로직은 하나(`isBoundAlive`)를 공유하고 값도 항상 같다"*가 + 깨진다. **부정 관계라면 오히려 이름 분리의 명분은 더 강해지지만**, 그 + 절의 서술은 통째로 다시 써야 함. + +### S-12 — 중간 State가 GC되지 않고 살아남는지 미검증 ⚠️ 미해결 + +- **판정**: 결론 보류 — 사용자가 **검증 필요**로 지목(틀렸다고 확정한 게 + 아니라, 문서가 이 케이스를 다룬 적이 없음). +- **문서 위치**: `base/source-state-plan.md`의 "`state:Observer(fn)`" 절 + (구독자를 weak로 담는 근거 서술), 같은 문서 "왜 State 체인을 Modifier처럼 + 플래튼하지 않는가" 절("각 노드가 자기 구독자 목록 + 자기 캐시만 가지면 + 된다"), `base/lifecycle-pattern.md`(gchold/gcconn). +- **사용자 답변 원문**: + > 확인해봐야 하는게 State -> State -> State -> Observer Leaf Bind 에서 중간 + > State 는 참조되지 않아도 사라지지 않음이 명확해야함. 물론 compute 등의 + > callback 상 가져서 안전할 수 있지만, With 등이 있는 경우 parent 와 연결된 + > 상대를 자기 자신에 가지고 있어야 할것임. 이게 되는지는 더 알아봐야할 + > 필요가 있음. +- **문제의 구조**: 확정된 두 서술을 겹치면 체인이 끊길 수 있음 — + - State는 자기 **구독자(하류)를 weak로** 담는다(S-12에서 확인된 서술). + - Observer는 `gchold`(leaf) 또는 전역 레지스트리가 살려준다. + - 그러면 `A → B → C → Observer` 체인에서 **중간 노드 `B`/`C`를 강하게 + 붙잡는 주체가 문서 어디에도 명시돼 있지 않다.** 아무도 `B`/`C`를 로컬 + 변수로 안 들고 있으면(체이닝 한 줄로 쓰는 흔한 형태), 하류 weak 링크만 + 으로는 생존이 보장되지 않아 중간 State가 수거되고 전파가 조용히 끊길 수 + 있음. +- **사용자가 지목한 해법 방향**: 각 노드가 **자기 parent(상류)를 강참조로 + 들고 있어야** 한다 — `:Compute`는 콜백 클로저가 상류를 캡처해 우연히 + 안전할 수 있지만, **`:With`가 만드는 pass-through 노드는 계산 함수가 + 없어서** 그런 우연한 캡처가 없다. 즉 "구독 엣지는 하류로 weak, 상류로 + strong"이라는 방향성이 명시돼야 함. +- **해야 할 일**: (a) `base/source-state-plan.md`에 이 방향성(상류 strong / + 하류 weak)을 불변식으로 명문화할지 결정, (b) `luau-test`에 실측 스파이크 + 추가(`07`이 연쇄 GC를 이미 다루므로 그 옆에). **미검증 상태로 M3에 착수하면 + 안 되는 항목.** + +--- + +## ST. `base/store-plan.md` + +### ST-2 — `store "key"` 문자열 커링은 **폐기됐는데** 문서엔 "동적 키 폴백으로 유지"로 남아 있음 + +- **판정**: 아니오 — 틀림. (사용자 재확인 완료) +- **사용자 답변 원문**: + > store "a" 식으로 문자열 호출하는것 또한 기각된 바임. 저러면 "a" 가 string + > 으로 들어가서, Source 의 타입을 모르기도 하고, 우린 더이상 필요하지 + > 않게 된 요소임. +- **문서가 주장하는 것**: dot-access를 1급으로 확정하면서 `store "key"`는 + 폐기가 아니라 **동적 키용 미타입 폴백(`Source`)으로 격하해 유지**한다. +- **실제로 맞는 것**: 문자열 커링 호출 자체가 **기각**. 근거는 (a) `"a"`가 + 그냥 `string`으로 들어가 `Source`의 `T`를 알 수 없고, (b) dot-access + + `type function` 타이핑(ST-4)이 자리잡아 **더 이상 필요 없는 요소**가 됨. +- **정정해야 할 위치(grep 결과 — 전수)**: + - `base/store-plan.md:119` — "…`store "key"`(문자열 커링)로 `state`를 + 오버로드 함수 타입으로 정확히 추론하려는 시도는 포기하고…" + - `base/store-plan.md:128` — "`store "key"` 문자열 커링은 동적 키가 필요할 + 때 쓰는 미타입(`Source`) 폴백으로 격하." + - `base/store-plan.md` "남는 것" 문단 — "`myStore "key"`(문자열 커링)는 … + 그대로 유지" + - `base/bind-system-plan.md:157` — "`store "key"`(문자열 커링, 동적 키 + 폴백)는 이벤트와 달리 …" + - `base/source-state-plan.md:101` — "동적 키 폴백(`store "key"`)은 이제 + `State`가 아니라 `Source`를 반환" + - `base/source-state-plan.md:465` — **예시 코드 자체가 폐기된 문법으로 + 쓰여 있음**: `store "key1":With(store "key2"):Compute(...)`. 이건 서술이 + 아니라 예제라 그대로 베껴 쓰일 위험이 큼 — `store.key1:With(store.key2)` + 로 고쳐야 함. +- **파급**: 폐기 사실이 반영되면 "동적 키를 어떻게 다루는가"라는 질문이 + **다시 열린다** — dot-access만 남으면 런타임에 키 이름이 정해지는 경우의 + 정식 경로가 없음. ST-1 답변("타입 시간에 Source가 없는 것으로 나와 타입 + 에러만 나면 됨")과 합치면 "동적 키는 아예 지원 안 함"이 의도로 보이나, + **명시적으로 확인받지는 않았음**. + +--- + +## D. `base/dispatch-core-plan.md` + +### D-1 — "`isX(hint)` 방어 가드는 죽은 코드"는 **과장**이다 (한 핸들러가 여러 값 모양을 받는 경우 여전히 필요) + +- **판정**: 큰 틀은 맞으나 **일반화가 과함** — 사용자가 단서를 붙임. +- **사용자 답변 원문**: + > 맞습니다. 더 정확히 표현하자면, State 에 대해 process 하면 후행 처리가 + > 생길텐데, state 의 새로운 값이 나오면 get 을 통해 얻어진 것으로 다시 + > process 가 수행됩니다. 여기서 이전의 프로세스 슬롯/인덱스와 비교해서 + > 정확히 자신이 만들어낸 것이라면, 단순 retract 처리가 발생하고, 아니라면 + > retractUnder 로 전부 제거되어 nil 이 됩니다. 즉 process 가 retract 를 + > 담당한다가 성립하므로 위는 옳은 표현입니다. **다만, 처음부터 한 핸들러가 + > 여러 값을 가질 수 있어 is 처리가 필요한건, 그 핸들러의 몫입니다.** +- **문서가 (너무 강하게) 주장하는 것** — 두 군데: + 1. "핸들러 계약" 절: *"방어 가드를 남겨둬도 무해하지만 **죽은 코드**이고, + 반대로 **그 가드가 있어야만 정확한 코드는 이제 없음**."* + 2. "Handler 작성 체크리스트" 3번: *"`isTag(...)` 같은 **방어 가드는 이제 + 불필요**(넣어도 무해하지만 죽은 코드)."* +- **왜 과한가**: 하강 diff가 보장하는 것은 **"넘어온 값이 그 핸들러의 + `isHandlable`을 만족한다"**까지다. `isHandlable`이 **여러 모양의 값**을 + 받아들이는 핸들러에서는, 그 안에서 어느 모양인지 가르는 `is` 판별이 + **여전히 필수**다 — 보장은 "같은 핸들러"까지지 "같은 값 모양"까지가 + 아니기 때문. 실제 사례가 이미 코퍼스에 있음: `PropertyHandler`는 평범한 + 값과 `Tween` 래퍼를 **둘 다** 받아 `isTween(realv)`로 분기함 + (`base/tween-plan.md`의 3-상태 릴레이션 슬롯). 즉 "그 가드가 있어야만 + 정확한 코드"가 지금도 존재한다. +- **정정 방향**: "옛 모델이 요구하던 *타입 미보장을 메우는* 방어 가드는 + 불필요"와 "한 핸들러가 여러 값 모양을 다루면 그 판별은 **핸들러 자신의 + 책임**"을 나눠 쓸 것. 지금 문장은 앞의 것만 말하면서 뒤의 것까지 부정한다. +- **파급**: 체크리스트 3번은 "새 Handler 짜기 전에 훑을 목록"으로 지정된 + 곳이라, 이 상태로 두면 다중 모양 핸들러 작성자가 필요한 판별을 **빼도록 + 유도**한다. + +### D-5 — `PreRef`/`PostRef`는 "배열 먼저" 보장 *위에* 성립하는 게 아니라 **더 위 루프**에서 처리된다 + +- **판정**: 구조는 맞으나 **문서가 근거로 든 관계가 부정확** — 그 가정 위에 + 쓰인 서술은 재검토 필요. +- **사용자 답변 원문**: + > preref 랑 postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고 + > flattened 에는 처리됨을 나타내는 값만 놔두는 구현. 이를 위해 + > Processed*Ref 와 ProcessedRefHandler(nop) 가 존재한다. PreRef 는 해당 + > 보장 아래 성립하는게 아니라, 먼저 처리되는 것. 구조 자체는 틀린것이 + > 없으나 가정을 그렇게 하였다면 재검토가 필요할 수 있음 +- **문서가 주장하는 것**: `dispatch-core-plan.md`의 "props 순회 순서는 base + 디스패치 드라이버가 명시적으로 두 단계로 고정한다" 절 끝 — + *"결과적으로 배열 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 + 세팅보다 항상 먼저 처리된다는 게 base 자체의 보장이 됨 — `ref-plan.md`의 + … "PreRef" 절이 **이 보장 위에서 성립**."* +- **실제로 맞는 것**: `PreRef`/`PostRef`는 두 패스 순회의 **배열 파트 패스에 + 얹혀 있는 게 아니라, 그보다 위의 별도 pre-pass for 문**에서 처리되고, + `flattened`에는 소진 마커(`ProcessedPreRef`/`ProcessedPostRef`)만 남는다. + 즉 "배열 먼저"라는 보장에 **의존하지 않는다** — 독립적으로 더 먼저 돈다. +- **정정 방향**: `PreRef` 성립 근거를 "배열 파트 우선 보장"에서 떼어낼 것. + 두 보장이 서로 독립임을 명시하고, `ref-plan.md`가 이 문장을 인용하고 + 있다면 거기도 같이 볼 것. +- **부수**: 사용자는 nop 핸들러를 `ProcessedRefHandler`로 통칭했는데 문서는 + `ProcessedPreRefHandler`/`ProcessedPostRefHandler` 둘로 나눠 부름 — 하나로 + 합칠지 여부는 확인 안 됨. + +### D-6 — `setLength`/`setOffsetSource` 호출 책임자가 **미결정**이다 ⚠️ 열린 설계 질문 + +- **판정**: 아니오 — "그 위치를 처음 매치한 Handler"라는 문서의 규정이 + **부정확**하고, 아직 **정해지지 않은 케이스**가 있음. +- **사용자 답변 원문**: + > 그 위치를 처음 매치한 핸들러는 약간 부정확함. State 등 일 수 있음. + > 최종 말단 요소가 이를 처리하는게 더 올바른것으로 보이는데, Slot 의 + > retract 처리가 비록 setOffset/Length 를 잘 stale 되지 않도록 처리는 + > 해주지만, 처음부터 State 에서 None 이 오는 경우는 생각이 + > 필요한듯 보임. 0 으로 채워지는걸 누가 하냐를 지금 정해져있지 않을텐데. + > 어디 쪽에서 하는게 맞는지 확인해야함. None 처리자는 그 자체로 리프연산이라 + > 볼 수 있느냐 하면 아니란것도 문제. 단순히 모든 핸들러가 k=number 일 때 + > 처리하도록 두는게 맞는지 검토해보고 싶음 +- **문서가 주장하는 것**: `dispatch-core-plan.md`의 "Length/Offset" 절 — + *"호출 책임은 `Slot` 자신의 `:List`/CRUD가 아니라 **그 위치를 처음 매치한 + Handler**(`Dispatch/Slot.luau`)"*, 그리고 "둘 다 array part의 모든 number + 인덱스에 대해 반드시 호출 — 생략은 UB". +- **문제 1 — "처음 매치한 Handler"는 중간 노드일 수 있다**: 배열 위치에 + `State`이 오면 그 위치를 **처음** 매치하는 건 `StoreBind`(중간 + 노드)다. 그런데 D-3에서 확정된 계약은 **중간 노드가 `inst`에 부작용을 + 가하지 않는다**는 것 — `setLength`/`setOffsetSource` 등록이 그 계약과 + 어떻게 양립하는지가 서술돼 있지 않다. 사용자 판단은 **"최종 말단 요소가 + 처리하는 게 더 올바르다"** 쪽. +- **문제 2 — `State`에서 `None`이 올 때 누가 `0`을 채우는가가 + 미정**: `Slot`의 retract는 stale을 막아주지만, **처음부터** `None`이 + 흘러오는 경로가 열려 있다. 그리고 `None` 처리자(`NoneHandler`)는 + **중간 노드라 말단(leaf) 연산으로 볼 수도 없어서** "말단이 등록한다"는 + 규칙으로도 안 덮인다. +- **사용자가 검토하고 싶어하는 대안**: *"단순히 모든 핸들러가 `k=number`일 + 때 처리하도록 두는 게 맞는지"* — 즉 "위치를 매치한 특정 한 핸들러의 + 책임"이 아니라 "숫자 키를 다루는 모든 핸들러의 공통 의무"로 재규정하는 안. +- **파급**: `Length`/`Offset`은 형제 순서 보장의 유일한 메커니즘이라, 이 + 갭이 남으면 `State`이 섞인 배열에서 **형제 순서가 조용히 + 어긋난다**(문서 스스로 이 상태를 UB로 규정). M3(Slot) 착수 전에 결론이 + 필요한 항목. + +### D-7 — base 소유 Fallback Handler의 등록 주체가 다시 뒤집힐 수 있음 ✅ 해소(같은 세션 내 재역전) + +- **판정**: 아니오(잠정) — 사용자는 **quad-base 로드 시 등록이 맞다고 했었다**고 + 기억하며, 문서의 현재 확정과 반대. 다만 사용자도 "더 확인이 필요"라고 함. +- **사용자 답변 원문**: + > fallback 들은 quad-base 로드 시가 맞다고 했었음. - 안 그러면 quad-roblox + > 를 로드하지 않았을 때 로드했는지 물어보는 요소가 처리가 안 된다고 했는데, + > 이것도 더 확인이 필요한 부분으로 보임. +- **문서의 현재 확정**: `dispatch-core-plan.md`의 "base가 소유하는 핸들러와 + 주입되는 엔진 op" 절 — **[재정정, 2026-08-14 열두 번째 세션]** 으로 + *"등록 주체는 quad-base 모듈 자체가 아니라 필요한 엔진(백엔드 팩토리)"* 로 + 확정했고, 반대 모델("quad-base가 자기 모듈 로드 시점에 스스로 등록")을 + `archive/tag-attribute-load-time-registration-reversed.md`로 **역전 처리까지 + 마쳤음**. 근거는 "`lifecycle-pattern.md`가 이미 거부한 `InitNamespace`류 + top-level 부작용 패턴과 같은 클래스". +- **사용자가 든 반대 근거**: 백엔드 팩토리가 등록 주체라면, **quad-roblox를 + 아예 로드하지 않은 상태**에서는 그 Fallback Handler들도 없으므로 + "provider가 초기화됐는지 물어보는" 안내 경로 자체가 동작하지 않는다. +- **⚠️ 이건 "역전을 다시 역전"하는 판단이라 특히 신중해야 함** — 정정 + 회신에 (a) 어느 쪽이 최종인지, (b) 최종이 "로드 시 등록"이면 + `archive/tag-attribute-load-time-registration-reversed.md`를 되살릴지 + 아니면 새로 쓸지, (c) `InitNamespace` 거부 원칙과 어떻게 양립시킬지를 + 같이 적어주시면 좋겠음. +- **해소(같은 세션 내, 2026-08-18) — (a)/(b)/(c) 전부 답변됨**: + (a) **최종은 로드 시 등록** — "등록 주체는 다시 quad-base 자신이다 + (모듈이 자기 레지스트리를 구성하는 시점)"으로 재역전 확정. (b) 새로 + 안 쓰고 **기존 `archive/tag-attribute-load-time-registration-reversed.md`에 + 재역전 배너만 추가**(그 문서 자체가 "이번에 재역전됐다"는 걸 스스로 + 알림 — 새 archive 문서 생성 없음). (c) `InitNamespace` 거부 원칙과의 + 양립: 그 원칙이 금지한 건 "사용자가 수동으로 init을 호출하게 만드는 + 것"과 "모듈 로드 시 *남의* 상태를 건드리는 것" 둘인데, base가 **자기 + 모듈 안의 자기 레지스트리**를 자기가 채우는 건 그 어느 쪽도 아니므로 + 충돌 없음. 지금 유효한 설계는 `base/dispatch-core-plan.md`의 "base가 + 소유하는 핸들러와 주입되는 엔진 op" 절의 "[재역전, 2026-08-18 구현 전 + QA — 사용자 확정]" 배너가 소스. + +--- + +## RE. `base/relate-plan.md` + +### RE-1 — gcconn/gchold 보관이 `SetStrong`이라고 적힌 두 곳이 틀림 (정답은 `SetWeak`) + +- **판정**: 아니오 — **`SetWeak`이 맞음**(사용자 확정). +- **틀린 위치 2곳**: + 1. `base/relate-plan.md`의 "대체하는 것" 절 — *"`bindLifetime`/`canExecute` + — gcconn/gchold를 `Relate`의 `SetStrong`으로 저장(둘 다 존재 이유가 + '안 죽는 것'이므로 strong)"*. **괄호 안 근거까지 통째로 틀림** — 둘의 + 생존은 gcconn 클로저 upvalue와 `gchold[1]`이 이미 보장하므로, 같은 + 문서의 "다른 곳에서 안전하게 유지되는 것은 항상 `SetWeak`" 절 규칙에 + 따라 weak가 맞다. + 2. `base/architecture.md` 소스 트리의 `quad-roblox/src/LifetimeHandle.luau` + 행 주석 — *"`Relate:SetStrong`으로 gcconn/gchold 저장"*. +- **맞는 서술(그대로 두면 됨)**: `relate-plan.md`의 "다른 곳에서 안전하게 + 유지되는 것은 항상 `SetWeak`" 절, 그리고 `base/lifecycle-pattern.md`의 + 구현 스케치(`InstData:SetWeak`/`BindData:SetWeak` 전부 weak). +- **왜 그냥 오탈자가 아닌가**: 1번은 **근거 문장까지 딸려 있어** 읽는 쪽이 + "strong이어야 하는 이유가 있구나"로 납득하게 만든다. 실제로 이 서술을 + 따라 `SetStrong`으로 구현하면 `relate-plan.md`가 경고하는 **두-`Relate` + 상호 강참조 순환**(RE-2, Luau에 ephemeron이 없어 실제 누수)에 정확히 + 걸린다 — `gchold`가 `value`를 강하게 잡고 `BindData`가 `value`를 키로 + `gchold`를 강하게 잡는 모양이 되기 때문. 같은 문서가 "이 규칙을 지키면 그 + 위험이 구조적으로 안 생긴다"고 자랑하는 바로 그 사례를 반대로 적어둔 셈. + +--- + +## M. `base/modifier-plan.md` + +### M-3 — 예약 필드 이름이 `Apply` 하나가 아니라 **셋**이고, `Overridden`은 콜론 메소드로도 쓸 수 있다 + +- **판정**: 아니오 — 문서 두 곳이 틀림. +- **사용자 답변 원문**: + > 전부 가능한게 맞음. Overridden 도 편의 상 A: 체인으로 제공 가능함. 밖에서 + > 직접 (A, B) 해주어도 좋고. 콜론과 닷 둘다 가능함 +- **틀린 곳 1 — `modifier-plan.md` 8번 절 "구현 시 주의"**: *"`__index`가 + 고정 메소드 테이블(**현재는 `Apply` 하나**)을 먼저 확인하고 … 따라서 + **`Apply`는 Modifier 필드 이름으로 예약됨**"*. + → 9번 절이 `:Peek(key)`를 추가했고 `Overridden`도 콜론 호출을 지원하므로, + 고정 메소드는 **`Apply` / `Peek` / `Overridden` 셋**이고 **셋 다 필드 + 이름으로 예약**된다. "현재는 `Apply` 하나"는 `Peek`이 생기던 시점에 + 갱신되지 않은 stale. +- **틀린 곳 2 — `base/architecture.md` "코드 스타일 — 네이밍 케이싱" 절**: + 대문자 3번 항목이 `Modifier.Overridden`을 *"**콜론 메서드는 아니지만** + (여러 Modifier를 동등한 인자로 받아야 해서 self 하나로 안 됨)"* 이라고 + 단정하며, 이를 "정적 결합 함수"라는 **세 번째 하위 분류를 새로 만든 + 유일한 근거**로 든다. + → 실제로는 콜론 호출도 지원하므로 그 괄호 안 근거("self 하나로 안 됨")가 + 성립하지 않는다. 정정 시 이 세 번째 분류를 유지할지(다른 근거로) 아니면 + 2번 항목(콜론 메서드)에 흡수할지 판단 필요. +- **파급**: 예약 필드 이름은 **Roblox 프로퍼티 이름과 충돌하면 안 되는** + 목록이라, 셋으로 늘어난 사실이 `FrameModifier`류 타입 생성 스크립트의 + 제외 목록에 반영돼야 함(M7). + +--- + +## R. `base/ref-plan.md` + +### R-1 — "Ref 객체 자신이 곧 콜백/대기자 배열" 구조를 재고할 것 (사용자: 별도 `.Callbacks` 테이블 + `.Value`는 평범한 해시 필드) + +- **판정**: 아니오 — 사용자가 **더 단순한 대안**을 제시. +- **사용자 답변 원문**: + > 단순히 .Callbacks: {fun, thread} 등이 있는게 맞지 않나라는 생각임. .Value + > 는 단순 해시필드로 주는게 더 간단해보임. 엔지니어링 난이도구 단순 테이블 + > 하나 더 만드는게 쉽고, 크게 비싸지도 않다고 생각됨. +- **문서가 주장하는 것**(2026-08-09 열한 번째 세션 보강): Ref 객체 **자신이 + 곧 콜백/대기자 배열**(숫자 키 색인)이고, 그래서 `.Value`를 `self.Value = v` + 로 얹으면 `T`가 함수/스레드일 때 `for i,v in self do` 순회가 hash 파트까지 + 훑어 오분류되므로 **`.Value`를 `__index` 메타메소드로** 구현해 저장 위치를 + 배열과 분리해야 한다. +- **사용자 대안**: 콜백/대기자를 **별도 필드 `.Callbacks`** 에 담고, `.Value`는 + 그냥 **평범한 해시 필드**로 둔다. 근거는 "테이블 하나 더 만드는 게 쉽고 + 크게 비싸지도 않다" — 즉 `__index` 우회 기법을 쓸 이유 자체를 없앤다. +- **이 정정이 무효화하는 서술들 (같이 볼 것)**: + 1. `.Value`의 `__index` 구현 근거 문단 전체(위 인용) — 대안에서는 + hash 파트 충돌 자체가 안 생기므로 불필요해짐. + 2. "구현 디테일" 문단의 **`for i, v in <배열> do` 단일 순회로 `type(v)` + 분기**(thread=대기자 / function=콜백) — 별도 `.Callbacks` 테이블이 + 생기면 순회 대상이 `self`가 아니라 그 테이블이 된다. `type(v)` 분기 + 자체는 유지 가능하나 서술 위치가 바뀜. + 3. 아래 R-3의 `nil` 소진/빈 슬롯 재사용 결정 — 배열이 어디 있든 논리는 + 그대로 유효하지만, 서술이 "Ref 객체 자신"을 전제로 쓰여 있어 같이 + 고쳐야 함. +- **파급**: `base/architecture.md` 소스 트리의 `Ref.luau` 주석 + (`범용 값 박스(.Value 읽기 + :Set()/:Callback()/:Wait() 셋)`)은 그대로 둬도 + 되지만, 구현 구조를 서술한 곳은 전부 갱신 필요. + +### R-3 — `PreRef` pre-pass 소진 슬롯을 `None`으로 채운다는 서술이 stale + +- **판정**: 아니오 — 틀림(이미 2026-08-14에 정정된 내용이 이 문서에 반영 안 됨). +- **사용자 답변 원문**: + > PreRef, PostRef 는 ProcessedPreRef 등의 nop 핸들러가 캐치하는걸로 자리를 + > 채우지 None 으로 채우진 않았던것 같음 sourceList 는 정확. 단, length 는 + > 0 을 넣는게 옳다. length: number|state 와 offset: + > state|None 차이라고 생각함 +- **틀린 위치**: `base/ref-plan.md`의 "왜 `None`이 아니라 `nil`인가" 절 결론 + 문장 — *"순서가 중요한 배열(**`PreRef` pre-pass 소진 슬롯**, Length/Offset + `sourceList`)은 계속 `None`"*. +- **실제로 맞는 것**: pre-pass가 소진시킨 자리는 `None`이 아니라 **전용 센티널 + `ProcessedPreRef`/`ProcessedPostRef`** 로 채워지고, 전용 nop 핸들러 + (`ProcessedPreRefHandler`/`ProcessedPostRefHandler`)가 정상 `Dispatch.process` + 경로에서 그걸 캐치한다. `dispatch-core-plan.md`는 이미 이렇게 정정돼 + 있음(**[정정, 2026-08-14 두 번째 세션]**) — `ref-plan.md`만 갱신에서 빠짐. + `sourceList`가 `None`인 것은 맞음. +- **부수 확인(정정 아님)**: 해제 시 `setLength`엔 **숫자 `0`**, `setOffsetSource`엔 + **`None`** 이 들어가는 비대칭이 의도된 것 — 타입이 각각 + `number | State` 와 `Source | None` 이라서. 두 문서 모두 + 이미 이렇게 적혀 있어 고칠 것 없음. + +### RF-4 — `drive`가 `None`을 건너뛰는 특수 분기를 **없애야** 함 ⚠️ 설계 변경, D-6과 연결 + +- **판정**: 아니오 — 전제("배열 파트의 `None`은 `Dispatch.process`를 절대 안 + 탄다")가 **거짓**이고, 그 위에 세운 설계도 같이 바뀜. +- **사용자 답변 원문**: + > 직접적으로 Frame{None} 이면 맞긴한데, Frame{Store} 이면 탈 수 + > 있음. 무엇이냐 상관 없이 탈 수 있는게 맞긴 하고, 그 경우도 k=number 이면 + > 단순 넘어가기를 해야함. 또한, 이 경우 setLength/OffsetSource 를 여기서 + > 처리하는게 맞다고 보임. 즉, drive 는 v == None 인지 확인 안하고 그냥 + > 프로세스 태우는게 가장 적절한 처리로 보임. NoneHandler 의 retract 는 Nop + > 일 수 있어보이나, process 자체가 이전걸 retract 하는건 필요(Tag -> None). +- **깨진 전제**: 리터럴 `Frame{None}`만 생각하면 두 패스 루프가 걸러내면 + 그만이지만, **`Frame{ State }`** 처럼 반응형 값이 `None`을 + 내놓으면 그 `None`은 `StoreBind`의 재귀를 타고 **`Dispatch.process`에 그대로 + 도착**한다. 즉 "배열 파트의 `None`은 `process`를 안 탄다"는 보장이 애초에 + 성립하지 않는다. +- **틀린 위치**: + - `base/ref-plan.md`의 옛 `명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변)` 항목 전체(2026-08-18에 전면 정정됨) — + *"배열 파트의 `None`은 **애초에 `Dispatch.process` 자체를 절대 안 + 탄다**"*, *"`k=number` 조합으로 `NoneHandler`가 실제로 매치되는 경우는 + 없음"*. + - `base/dispatch-core-plan.md`의 "`None` 센티널" 절 — *"`Dispatch.drive`의 + 두 패스 루프 자신이 `NoneHandler`/`Dispatch.process`를 거치지 않고 바로 + 건너뜀"*, 그리고 "정말 빈 자리인 `None`만 두 패스 루프가 직접 건너뜀"이라는 + 후속 서술. + - 같은 문서 "Length/Offset" 절이 `None` 슬롯의 등록 책임을 서술한 부분. + - `base/component-composition-plan.md`의 "필수 관용구" 문단 — *"`props.Modifier + or None`이 최종적으로 배열 파트에 `None`인 채로 남으면 **두 패스 루프 + 자신의 array-part `None`-스킵 규칙**이 그대로 적용돼 아무 일도 안 + 일어남"*. 스킵 규칙이 사라지면 이 근거가 바뀐다(`NoneHandler`가 매치돼 + `setLength(0)`/`setOffsetSource(None)`을 등록하는 경로가 됨). + **결론(`or None`을 쓰는 것)은 안 바뀜** — CC-3에서 관용구 자체는 + "맞음"으로 확인됐으므로 근거 문장만 갱신. +- **사용자가 지시한 새 설계**: + 1. **`Dispatch.drive`는 `v == None`을 확인하지 않고 전부 `Dispatch.process`에 + 태운다** — 특수 분기 제거. (이게 "가장 적절한 처리"라는 판단.) + 2. **`NoneHandler`가 `k == number`인 경우를 스스로 처리**한다 — 실제 값 + 세팅은 하지 않고 넘어가되, 그 자리의 **`setLength(0)` / + `setOffsetSource(None)` 등록을 여기서** 한다. + 3. `NoneHandler`가 **반환하는 retractor**는 no-op이어도 되지만, + **`process` 자체는 이전 것을 retract시키는 역할을 해야 한다** — + `Tag` → `None` 전환에서 이전 Tag 기여가 실제로 걷혀야 하기 때문. +- **⚠️ D-6과의 관계**: D-6이 *"`State`에서 `None`이 올 때 누가 + `0`을 채우는가가 미정, 그리고 `None` 처리자는 말단이 아니라 중간 노드라 + '말단이 등록한다' 규칙으로도 안 덮인다"* 고 남겨둔 갭의 **답이 여기 나옴** — + `NoneHandler`가 `k=number`일 때 직접 등록한다. 다만 이러면 `NoneHandler`가 + "중간(래핑) 노드"이면서도 등록 책임을 지게 되므로, **D-3의 "중간 노드는 + `inst`에 부작용을 가하지 않는다" 계약과의 관계를 정리해야 함** + (`setLength`/`setOffsetSource`는 `inst`의 프로퍼티를 건드리는 게 아니라 + Dispatch 부기라 계약 위반이 아니라고 볼 여지가 크지만, 문서가 그렇게 + 명시하고 있지는 않음). +- **정정 시 같이 확인할 것**: 3번의 "`process`가 이전 것을 retract"가 + 하강 diff 모델에서 **자동으로 성립하는지**(핸들러가 `TagHandler` → + `NoneHandler`로 바뀌므로 (B) 분기가 `retractFrom`을 부름) 아니면 **별도 + 코드가 필요한지**. 자동이면 문서에 그 경로를 명시만 하면 되고, 아니라면 + `NoneHandler.process`에 명시적 정리가 들어가야 함. + +--- + +## B. `base/brand-plan.md` + +### B-1 — `Brand.get`이 `None`을 특수 분기하는 설계는 **의존성을 만들므로 기각** + +- **판정**: 부분적으로 틀림 — 포함 관계(`isRef(preRef)==true`)와 테이블 + 아이덴티티 태그는 맞으나, **`None` 처리 방식이 틀림**. +- **사용자 답변 원문**: + > 맞음. 그런데 Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에 + > 의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v == + > None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일. +- **문서가 주장하는 것**: `brand-plan.md`의 옛 `None은 이 레지스트리에 안 들어감` 문단(2026-08-18에 정정됨) — *"`Brand.get(x)`가 … 범용 introspection 창구 역할까지 + 겸하게 하려면 `None`도 빠지면 안 되므로, **`Brand.get`이 내부적으로 + `x == None`을 먼저 확인하는 특수 분기를 하나 두고** 그 뒤에 일반 레지스트리 + 조회로 폴백 — `isNone`은 바로 이 특수 분기의 실제 구현체가 됨"*. +- **실제로 맞는 것**: **`Brand` 모듈은 아무 의존성도 갖지 않아야 한다** — + `None`을 참조하는 특수 분기를 넣으면 `Brand → None` 의존이 생긴다. + `isNone`은 **필요한 곳에서 `v == None`** 으로 하면 되고, `isNone`이라는 + 이름의 함수를 두더라도 그 구현이 그냥 `v == None`이면 된다. `None` 자체를 + 레지스트리에 태깅하는 것 자체는 무방. +- **파급**: "`Brand.get`이 quad가 아는 모든 값을 답해주는 단일 introspection + 창구"라는 서술도 같이 재검토 필요 — `None`이 그 창구에서 빠지는 걸 + 받아들일지, 아니면 `None`도 평범하게 레지스트리에 등록해 특수 분기 없이 + 답이 나오게 할지(사용자 답변은 후자를 허용함). + +--- + +## AT. `base/attribute-plan.md` + +### AT-1 — 같은 그룹 객체를 두 위치에 놓는 경우(`Frame{a, a}`)가 미검토 ⚠️ 열린 항목 + +- **판정**: 확정 내용 자체는 맞음. 다만 사용자가 **새 검토 항목**을 제기. +- **사용자 답변 원문**: + > 맞음. 이러면 a=Attribute() Frame{a,a} 가 되어도 이미 문제가 안 나는것으로 + > 보이긴 하는데, UB로 둘지 에러를 쉽게 낼 수 있는지는 확인이 필요해보임. +- **왜 검토가 필요한가 (문서 의사코드 기준 손 트레이싱)**: `groupKey(v, name)`이 + **그룹 값 객체별·이름별 메모이즈**이므로, 같은 객체 `a`가 `k=1`과 `k=2`에 + 놓이면 **양쪽이 완전히 같은 키 객체**로 위임한다. 그러면: + - `nameClaims` 체크는 `cur == k`라 **통과**한다(에러가 안 남). + - 그런데 두 위치가 `(inst, 같은 key)`라는 **하나의 체인을 공유**하게 된다 — + `k=1`의 `process`가 `Dispatch.process(inst, key, source, 1)`을 부르고, + `k=2`도 **같은 인자로 같은 체인**을 다시 부른다. + - 더 중요한 건 철거: `k=1`이 retract되면 그 클로저가 + `Dispatch.retractFrom(inst, key, 1)`을 불러 **`k=2`가 아직 쓰고 있는 + 바인딩까지 통째로 철거**한다. + - 이건 `Ref`의 "이중 배치 방지"(`base/ref-plan.md`)가 막은 것과 **정확히 + 같은 클래스의 문제**로 보인다 — 다만 `Ref`는 `bindLifetime` 게이트로 + 잡히는데 그룹 `Attribute`엔 대응 게이트가 없다. +- **✅ 후속 답변으로 결론 나옴 (SL-1 답변에 덧붙여짐)** — **위치별 claim을 + 하나 두는 쪽**으로: + > 이전 답변에 대해 diff 를 보다 더 생각이 나서 말하자면, Attribute 는 + > bindLifetime 를 못함. Ref 와 다르게 여기저기서 사용 가능하기 때문. 한 + > 곳에서 바운딩 했다고 다시 바운딩 못할 순 없음. 따라서 위치별 claim 을 + > 하나 두어야한다고 생각함. + + 즉 `Ref`처럼 `bindLifetime`을 재사용하는 방식은 **쓸 수 없다** — `Ref`는 + "한 곳에만 배치"가 규칙이지만 `Attribute` 그룹 값은 **여러 곳에서 쓸 수 + 있어야** 하므로, 한 번 바인딩했다고 다시 못 하게 만들면 안 된다. 대신 + **위치별 claim 레지스트리를 하나 추가**해서 같은 그룹 객체가 같은 + 위치 집합을 이중 점유하는 것만 잡는다. +- **정정 시 설계할 것**: 그 위치별 claim이 무엇을 키로 하는지 + (`(inst, groupValue) → k` 인지, `groupKey` 단위인지), 그리고 기존 + `nameClaims`와 어떻게 공존하는지. +- **같이 볼 것**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게 **정상 + 관례**로 확정돼 있고(T-1) 위치(`k`) 기준 참조 카운트로 안전하다 — 그룹 + `Attribute`만 왜 다른지(자원이 "값 하나"라 겹침=충돌)가 정정 시 같이 + 서술되면 좋겠음. + +--- + +## SL. `base/slot-plan.md` + +### SL-1 — `Ref`의 leaf 바인딩은 **배열(숫자 키) 전용**이고, `RefLeafHandler`에 `k` 체크가 빠져 있음 + +- **판정**: 아니오 — "배열 전용"이 맞고, 이를 부정하는 서술과 구현이 틀림. +- **사용자 답변 원문**: + > 배열 전용이 맞음. 컴포넌트 일 때는 함수의 인자에 맞게 위처럼 보낼 수 + > 있겠으나, 기본 의도는 리프에선 숫자 바인딩임. 이유는 Ref끼리는 순서가 + > 통하므로, 다른 Ref처리를 먼저 해야하는 순서 의존이 있을 때에도 가능하게 + > 하고자였음. +- **고쳐야 할 곳**: + 1. **`base/ref-plan.md`의 `RefLeafHandler.isHandlable`** — + `isRef(v) and not isPreRef(v) and not isPostRef(v)` 로만 적혀 있어 + **`type(k) == "number"` 체크가 빠졌다.** 짝인 + `ObserverEffectLeafHandler`엔 그 체크가 **필수**라고 명시돼 있고 + (S-8에서 확인), 빠지면 named 자리로 흘러온 값을 잡으려는 FALLBACK + 가드가 죽은 코드가 된다 — 같은 이유가 `Ref`에도 그대로 적용된다. + 2. **`base/ref-plan.md`의 옛 `일반 Ref는 계속 Modifier/Store 어디든 자유롭게 들어감` 항목**(2026-08-18에 한정 서술 추가) — 이 문장이 "named 해시 키에 놓아도 leaf 바인딩이 + 된다"로 읽힌다. 실제 의미는 "Modifier 필드나 Store 값으로 **전달**될 + 수 있다"(=값으로서 어디든 흘러갈 수 있다)이지 "leaf 바인딩 자리가 + 아무 데나 된다"가 아니므로, 오해가 없게 다시 써야 한다. + 3. **`base/slot-plan.md`의 예시 `slot:Add(Frame { Ref = myRef })`** — + 여기 `Frame`이 **컴포넌트 함수**라서 `Ref`가 그 함수의 named + 파라미터인 경우에만 성립한다. 실제 Instance 리터럴로 읽히면 위 1번 + 규칙과 정면으로 어긋나므로, 예시에 그 전제를 명시해야 한다. +- **배열 전용인 이유(사용자 논거, 문서에 없던 것)**: `Ref`끼리는 **배열 + index 순서가 통하므로**, "다른 Ref 처리를 먼저 해야 하는 순서 의존"이 + 있을 때도 표현이 가능하게 하려는 것. (`PreRef`/`PostRef`의 "계열 안 + 순서 보장"과 같은 결의 근거인데, 일반 `Ref`에 대해서는 어디에도 안 + 적혀 있었음.) + +### SL-3 — `:List` reconcile의 `nil` 리턴을 비파괴로 확정한 것을 **재검토해야 함** ⚠️ 확정 뒤집기 후보 + +- **판정**: 부분적으로 틀림 — 사용자는 **파괴가 기본이 맞다**고 보며, + 현재 문서 확정(비파괴)과 어긋남. +- **사용자 답변 원문**: + > List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지, + > 파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데, + > Instance.new Destroy 비용을 아끼고 싶은, filter 부분에 있어서는 단순 + > Parent = nil 로 두고 싶을수도 있음. 이를 위해 특정 리턴 등은 Parent = nil + > 로 만들고 홀드해둔 다음, 나중에 prev 상 이를 사용할 수 있게 두는 방법이 + > 가능한가 모색해보고 싶음. 아마 ud 는 명시적으로 nil 안 하면 안 지워지니, + > PopOnly 등을 만들어 PopOnly, { old = ..., source... } 등을 하면 Parent 를 + > 빼고 slot 에서 적절히 빼주는게 방법으로 보임. +- **문서의 현재 확정**: `slot-plan.md`의 "구현상 바뀌어야 하는 것" 절 2번 — + *"**`:List`의 `reconcile`** — 교체/소멸 시 `rawRemove`(파괴) 대신 같은 + 비파괴 경로. 데이터에서 빠진 아이템도 파괴되지 않고 언마운트만 되며, + 아무도 안 들고 있으면 GC"*. 그리고 "자동 경로는 언마운트, 명시적으로 + 지우라고 한 것만 파괴"라는 일반 규칙. +- **어긋나는 지점**: 사용자는 `:List` reconcile의 `nil` 리턴에 한해 + **파괴가 기본**이라고 봄. 즉 "자동 경로 = 전부 언마운트"라는 일반화가 + `:List`에는 안 맞을 수 있다. (`State` 교체가 언마운트인 것은 + SL-3 질문에서 별도로 부정되지 않았으므로 그대로 유효해 보임 — 두 경로를 + 분리해서 정할 필요.) +- **사용자가 원하는 추가 기능 — 재사용을 위한 `PopOnly`(가칭)**: + `filter` 용도에서 `Instance.new`/`Destroy` 비용을 아끼려면, 파괴하지 않고 + **`Parent = nil`로만 두고 홀드**해뒀다가 나중에 `prev`(`:Compute`의 + `previous`와 같은 자리)로 **재사용**할 수 있어야 함. 구체안: + `updateFn`이 `PopOnly, { old = ..., source = ... }` 같은 걸 반환하면 + reconcile이 `Parent`만 빼고 Slot에서 적절히 제거하는 방식. + - 참고로 사용자가 짚은 전제: userdata는 **명시적으로 `nil`을 안 하면 + 안 지워지므로** 홀드만 해두면 살아있다. +- **결정해야 할 것**: (a) `:List` reconcile `nil` 리턴의 기본을 파괴로 + 되돌릴지, (b) `PopOnly`를 이번 설계에 넣을지 백로그로 뺄지, (c) 넣는다면 + `updateFn` 반환 규약(현재는 "반환값을 해석하는 `:List` 내부 로직")이 + 어떻게 확장되는지. + +--- + +## E. `base/effect-plan.md` + +### E-2 — `:Unsubscribe()`가 leaf 바인딩에도 cleanup을 부르게 한 건 틀림 (`Subscribe`의 짝으로 좁혀야 함) + +- **판정**: 아니오 — 계약을 **축소**해야 함. +- **사용자 답변 원문**: + > 표면적으로 맞아보이긴 하나, subscribe 한게 아니면 unsubscribe 는 지원하면 + > 안 되거나, 적어도 리프 바운딩에선 그래선 안 됨. leaf 에 바운딩 된 경우, + > 특히 state 또는 observer 가 들어갈 때, 단순 emit 에서 최적화로 + > 인해 이전과 동등이라 retract 가 아무 일을 하지 않음. (그런데, 별개로, + > retract 가 아무것도 안 하고 나서, process 쪽에서도 아무것도 안 하는지는 + > 확인이 필요해보임) 그래서 다시 바운딩 안 먹는것이라 의도한 바가 아님. + > subscribe 는 unsubscribe 의 짝이라고 생각함. +- **문서가 주장하는 것**: `effect-plan.md`의 "`EffectHandle:Subscribe()`/ + `:Unsubscribe()`" 절 — *"`:Unsubscribe()`는 Observer의 것을 그냥 위임하지 + 않는다 — Effect 계층에서 의미가 확장됨 … **직전(또는 유일한) cleanup을 + 정확히 1회 호출** — leaf가 죽을 때 하던 것과 정확히 같은 이벤트를 수동으로 + 앞당기는 것"*. +- **실제로 맞는 것**: **`:Unsubscribe()`는 `:Subscribe()`의 짝**이다 — + `:Subscribe()`로 등록하지 않은 핸들(=leaf 바인딩된 핸들)에는 지원하지 + 않거나, 최소한 leaf 바인딩 경로에서는 cleanup을 앞당기면 안 된다. +- **왜 위험한가 (사용자 논거)**: leaf 바인딩 + `State`/`State` + 조합에서, **값이 실제로 안 바뀌면 dedup 최적화 때문에 retract가 아무 일도 + 안 한다**(S-8의 `old ~= v` dedup). 그런데 `:Unsubscribe()`가 cleanup을 + 미리 실행해버리면, 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안 + 일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님. +- **⚠️ 함께 확인해야 할 별건(사용자가 괄호로 남긴 것)**: dedup 경로에서 + **retract가 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** + 대칭이 실제로 성립하는지 확인 필요. `ObserverEffectLeafHandler` 의사코드 + 기준으론 `process`의 `if old ~= v then bindLifetime(...) end`와 클로저의 + `if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만, + **`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로**(E-2의 (2)번, + 그 자체는 "맞음"으로 확인됨) 그 cascade가 dedup 분기 안에 제대로 들어가 + 있는지가 별도 확인 대상. +- **정정 범위**: `base/effect-plan.md`의 해당 절, 그리고 같은 논리가 적용되는 + `base/source-state-plan.md`의 Observer `:Unsubscribe()` 서술(거기는 이미 + "전역 경로 전용"으로 좁혀져 있어 괜찮아 보이나 같이 확인). + +--- + +## EV. `base/event-plan.md` + +### EV-1 — 이벤트 disconnect 센티널을 `false`에서 `None`/`nil`로 바꿀 것 + +- **판정**: 아니오 — 센티널 선택이 이제 낡음. +- **사용자 답변 원문**: + > 다만, 이젠 None 이 있어서 false 을 사용해야할 이유가 없어졌다고 봄. false + > 대신 None/nil 을 사용하지 말아야할 이유가 없다면 일관적이게 None/nil 을 + > 주는게 맞다는 생각 +- **문서가 주장하는 것**: `event-plan.md`의 "이벤트도 store-bind 가능" 절(제목의 센티널 표기는 2026-08-18에 `None`/`nil`로 갱신됨) — *"**`false`로 disconnect, `nil` 아님.** `nil`은 + Lua 테이블에서 '키가 아예 없음'과 구별이 안 됨 … 대신 `false`(Luau에서 + 실재하는 싱글톤 타입)를 '연결 없음' 센티널로 씀"*. +- **왜 낡았나**: 그 결정(2026-08-06)은 **`None` 센티널이 확정되기 전**에 + "테이블에 실재하는 값으로 표현 가능한 것"이 필요해서 `false`를 고른 것인데, + 이후 `None`이 정확히 그 역할로 도입됐다(`base/modifier-plan.md` 2-1, + `base/dispatch-core-plan.md` "`None` 센티널"). 지금은 **같은 문제를 푸는 + 센티널이 두 개** 있는 셈이고, 이벤트만 다른 걸 쓸 이유가 없음. +- **정정 시 같이 설계해야 할 것**: + - `None`으로 바꾸면 **`NoneHandler`가 매우 높은 우선순위로 먼저 매치**해서 + `nil`로 재귀시킨다 — 그러면 `EventHandler`가 **`(k=이벤트키, v=nil)`을 + 받아 disconnect로 처리**할 수 있어야 한다. `isHandlable`이 `nil` 값에도 + 매치되도록 규정이 필요. + - 문서의 기존 근거였던 *"이벤트인지 여부는 값이 아니라 키(리플렉션)로 + 결정되므로 다른 boolean 프로퍼티 핸들러와 `(k, false)` 매칭이 겹칠 위험 + 없음"* 은 `false`를 안 쓰면 아예 불필요해지므로 삭제 대상. + - N-4(`NilHandler` 신설)와 상호작용 — `k`가 숫자가 아닌 이벤트 키에서 + `nil`이 어느 핸들러에 가야 하는지 같이 정할 것. + +--- + +## BS. `base/bind-system-plan.md` + +### BS-2 — "이벤트 콜백 시그니처는 Luau가 검증 못 한다"는 전제가 **거짓** ⚠️ 두 문서의 근거가 무너짐 + +- **판정**: 아니오 — 사용자가 **반례 코드를 직접 작성해 제시**. +- **사용자 답변 원문**: + > ReflectionService 사용은 맞음. 단 새로 프로젝트 루트에 생성된 test.luau 를 + > 보면 알 수 있지만, 이벤트의 콜백 또한 타입을 지정해주는게 가능함. D가 + > 만들어지는 제네레이터 상에서 타입을 적절히 제공하면 콜백 시그니처는 충분히 + > 처리 가능한것으로 보임. 이는 OnChange 와 다르게, 필드이기 때문에 타이핑이 + > 가능함. +- **반례 (2026-08-18 사용자가 직접 작성해 보여준 코드 — 당시 프로젝트 루트에 + `test.luau`로 뒀다가 이후 삭제, 아래가 그 전문)**: + ```luau + function Frame (prop: {MouseButton1Click: ((a: number)->())?}) + end + + Frame{ + MouseButton1Click = function(a) -- a: number + end + } + ``` + props 테이블 **타입에 필드로 선언돼 있으면 콜백 파라미터가 그대로 + 추론된다** — 런타임 판별을 `ReflectionService`로 하는 것과 **타입을 생성기가 + 제공하는 것은 완전히 별개 축**인데, 문서가 둘을 묶어버렸다. +- **틀린 위치 1 — `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 + 인체공학" 절**: *"이건 타입 안전성을 어느 정도 포기하는 대가지만(**콜백 + 시그니처까지 Luau가 검증 못 함** — `apply(instance: T, properties: U): + T & U`가 스키마 검증 없이 구조적으로만 merge), 이미 UB로 남긴 … 와 같은 + 급의 한계라 손해가 크지 않고"*. + → `D` 생성기가 클래스별 props 타입에 **이벤트 필드까지 정확한 콜백 타입으로 + 포함**시키면 검증된다. "감수하는 대가"가 아니라 **생성기가 챙겨야 하는 + 구현 체크리스트 항목**이다(`ui-shorthand-plan.md`가 `UICorner`/`UIPadding`/ + `UIScale`을 Modifier 타입 메소드 목록에 끼워 넣으라고 한 것과 같은 성격). +- **틀린 위치 2 — `base/onchange-plan.md`의 "확정" 절**: `OnChange<>` + 제네릭을 안 만드는 근거로 *"이미 확정된 '이벤트 바인딩은 콜백 시그니처를 + Luau가 검증 못 하는 대가를 받아들인다'는 결정 … 과 같은 급의 트레이드오프라 + 새로 정당화할 것 없음"* 을 듦. + → **그 전제가 거짓이므로 이 근거는 통째로 무효.** 다만 사용자 답변에 따르면 + **결론(제네릭 없음)은 유지되고 근거만 바뀐다** — 이벤트는 **필드**라 + 타이핑이 되지만 `OnChange(name)`은 **이름을 인자로 받는 팩토리**라 그 + 경로가 없다는 것이 진짜 이유. +- **파급**: + - `base/architecture.md`의 소스 트리 `DI/init.luau` 주석("제네릭 생성자 + + ~25개 정적 필드")과 `D`/`DI` 생성기 설계에 **"이벤트 필드의 콜백 타입도 + 생성한다"**가 추가돼야 함. + - `base/store-plan.md`의 "타입 추론 문제" 절이 *"이벤트는 이 관습의 + **유일한 예외**"* 라고 서술하는데, 예외의 성격이 바뀐다 — "타입을 + 포기하는 예외"가 아니라 "**이름 지정 방식만** 문자열 키인 예외"다. + - **이벤트가 store-bind될 때**(`State`/`false`→EV-1에 따라 + `None`/`nil`)의 타입은 어떻게 되는지도 같이 정해야 함 — 필드 타입이 + `((a: number)->())?` 뿐이면 `State<...>`나 센티널을 못 받는다. + +--- + +## 추가 요구사항 — 심사 중 사용자가 새로 지시한 것 + +기존 서술이 틀려서가 아니라, 확인 과정에서 사용자가 **새로 요구한 사항**. +정정이 아니라 설계 추가라 위 결함 목록과 분리해 둔다. + +### N-1 — FALLBACK 가드의 에러 메시지에 실제 `k` 타입을 실어줄 것 + +- **출처**: S-8 답변("정확함. 다만 …"). +- **사용자 답변 원문**: + > 정확함. 다만 Priority Fallback 이 type(k) == "string" 인 상황에서는 가장 + > 위에 Ref/Observer binding should be array index item, but got typeof k 처럼 + > 알려줄 필요는 있는듯 - 안 그러면 핸들러 미등록 이슈인지, MyRef = Ref 같이 + > 아에 잘못 쓴 이슈인지 분간이 안가서 최종 유저에게 혼선을 줄 여지가 + > 존재하는듯. +- **요구 내용**: `HANDLER_PRIORITY_FALLBACK` 가드(`Observer`/`Ref`/`PreRef`/ + `PostRef`의 동적 경로 가드)가 에러를 낼 때, 단순히 "배열 리터럴에만 놓을 수 + 있음"이 아니라 **실제로 들어온 `k`의 타입을 메시지에 포함**할 것 — + `Ref/Observer binding should be array index item, but got ` 형태. +- **근거(사용자 논거)**: 메시지에 `k` 타입이 없으면 사용자가 두 원인을 구분할 + 수 없다 — (a) 핸들러가 등록이 안 된 것인지, (b) `MyRef = Ref(...)`처럼 + named 자리에 잘못 쓴 것인지. 최종 사용자에게 혼선을 줌. +- **반영할 곳**: `base/source-state-plan.md` "동적 경로 가드" 절의 가드 + 스케치, `base/ref-plan.md`의 `PreRef`/`PostRef` 동적 경로 가드, 그리고 + `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" + 절(FALLBACK 자리 서술). + +### N-2 — 없는 Store 키는 "타입 에러"로 잡히면 충분 (구현 시 확인) + +- **출처**: ST-1 답변. +- **사용자 답변 원문**: + > 네, 맞고, Store<{ field: type }> 상 없는 네임에는 타입 시간에 Source 가 + > 없는것으로 나와 타입 에러만 나면 됩니다. 아마 지금 설계가 그럴것이예요 +- **내용**: lazy `__index` 생성이 오타/동적 키로 Source를 무한정 누적하는 + 트레이드오프는 **그대로 수용**. 방어선은 런타임이 아니라 타입 — + `Store<{field: T}>`로 선언된 Store에 없는 이름을 쓰면 `type function`이 + 합성한 결과 타입에 그 프로퍼티가 없어 **타입 에러**가 나야 한다. +- **확인 필요**: 사용자도 "아마 지금 설계가 그럴 것"이라며 단정하지 않았음 — + `type function`으로 합성한 테이블 타입이 **미선언 프로퍼티 접근을 실제로 + 거부하는지**(인덱서를 안 붙였을 때 Luau가 에러를 내는지) M0에서 확인할 것. + ST-4의 스파이크(`luau-test/done/16-...`)에 이 음성 대조군이 있는지도 같이 + 볼 것. + +### N-3 — 동률 경고 print는 전역 디버그 플래그 `Quad.debug`로 게이팅 + +- **출처**: D-8 답변. +- **사용자 답변 원문**: + > 동률 print 는 라이브러리가 debug 모드일 때만. (Quad.debug: boolean = + > default false) 식이고, true 로 하면 디버깅 가능. 다른건 OK +- **내용**: `dispatch-core-plan.md`의 "디버그 모드 — 핸들러 등록/정렬 시점에 + 동률 감지 시 print 경고" 항목은 무조건 찍는 게 아니라 **모듈 표면의 + 불리언 플래그 `Quad.debug`(기본 `false`)가 `true`일 때만** 찍는다. +- **파급**: `Quad.debug`는 지금 어느 문서에도 없는 **새 공개 API 표면**이다 — + `base/module-lifecycle-plan.md`(모듈 표면)와 `base/architecture.md`의 소스 + 트리에 반영이 필요하고, `New()`/`Quad()` 다중 인스턴스화(A-3) 시 이 플래그가 + 인스턴스별인지 전역인지도 같이 정해야 함. `Dispatch.listHandlers()`도 같은 + 디버그 표면에 속하는지 확인 필요. + +### N-4 — `NilHandler` 신설 요구 (`State`도 동작해야 함) + +- **출처**: B-1 답변에 덧붙인 별건("이 답과는 연관 없는 말을 하자면"). +- **사용자 답변 원문**: + > State 일 수도 있지만, State 이여도 작동은 함. 이것도 + > NoneHandler 유사하게 NilHandler 가 필요함. 오직 k=number v=nil 일 때만 + > 받고, NoneHandler 와 유사하게 retract 처리를 담당. 단, 재귀만 안 할 뿐임. + > 혹은, NoneHandler 가 다시 NilHandler 가 불리도록 하는게 더 나을수도 + > 있겠다는 생각(단순 재귀로 NoneHandler 는 재귀처리만 담당함). +- **요구 내용**: 반응형 값이 `None`이 아니라 **진짜 `nil`** 을 내놓는 경우 + (`State`)도 정상 동작해야 하므로, **`NilHandler`를 신설**한다. + - `isHandlable`: **`k == number` 이고 `v == nil` 일 때만** 매치. + - 역할: `NoneHandler`와 마찬가지로 **retract 처리 담당**. 단 **재귀는 하지 + 않음**(`NoneHandler`는 `nil`로 바꿔 재귀하는 게 일이지만, `NilHandler`는 + 이미 `nil`이라 더 내려보낼 곳이 없음). + - **사용자가 선호한 대안 구조**: `NoneHandler`는 **재귀 처리만** 담당하게 + 두고, 그 재귀가 결국 `NilHandler`를 부르게 만드는 쪽이 더 깔끔할 수 + 있음 — 즉 실질 정리 로직을 `NilHandler` 한 곳에 모으는 구성. +- **RF-4와 함께 봐야 함**: RF-4가 "`drive`는 `None` 스킵을 없애고 전부 + `process`에 태운다 + `NoneHandler`가 `k=number`에서 `setLength(0)`/ + `setOffsetSource(None)`을 등록한다"였는데, 여기에 `NilHandler`가 들어오면 + **그 등록 책임이 둘 중 어디에 있는지**를 같이 정해야 함(재귀 구조를 + 택하면 자연히 `NilHandler` 쪽으로 모임). +- **반영할 곳**: `base/dispatch-core-plan.md`의 "`None` 센티널" 절(핸들러 + 목록과 재귀 구조), `base/architecture.md` 소스 트리(`Dispatch/` 아래 파일 + 목록), `base/dispatch-core-plan.md` "Length/Offset" 절의 등록 책임 규정. + +### N-5 — `Attribute.Merged`와 `Attribute.Overridden`을 **둘 다** 제공 (열려 있던 결정 해소) + +- **출처**: AT-4. `question.md` 3번에 "사용자 확인 대기"로 열려 있던 항목의 + 답이며, **선택지 둘 중 하나가 아니라 제3안**이 채택됨. +- **사용자 답변 원문**: + > 차라리 Merged, Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, + > 후자는 그냥 조용히 덮어써주는것. 사용자 의도에 따라 달라질 부분이라 + > 분리해주는것이 이로워보임. +- **결정**: 이름 겹침의 처리 방식을 **API로 분리**한다. + - `Attribute.Merged(a, b, ...)` — 같은 이름이 겹치면 **error**. + - `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**(덮어쓰기). + - 근거: 어느 쪽이 맞는지는 **사용자 의도에 달린 문제**라 프레임워크가 + 하나로 정하지 말고 골라 쓰게 한다. +- **파급 / 정리할 것**: + - `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인 array-part 값 객체" + API 목록에 `Attribute.Overridden` 추가, "열린 질문" 절의 해당 항목 해소 + 처리, `.claude/question.md` 3번에서 제거. + - **`Merged`/`Overridden`이라는 이름 쌍의 의미가 코퍼스 전체에서 + 재정렬됨** — 지금까지는 `Merged`=무손실 합집합(`Tag`), + `Overridden`=필드 단위 덮어쓰기(`Modifier`)로 **연산의 종류**를 + 가르는 이름이었는데, `Attribute`에선 **충돌 시 정책**(error냐 + 덮어쓰기냐)을 가르는 이름이 된다. `base/tag-plan.md`가 `Tag.Merged` 코드 주석에서 `Merged`를 + 집합 합치기, `Overridden`을 이미 계산된 것 합치기로 대조해둔 서술과 + 같이 볼 것. + - **`Tag`에도 `Overridden`이 필요한가**는 자동으로 따라오지 않음 — + `Tag`는 합집합이라 애초에 충돌 개념이 없음. 확인 불필요해 보이나 + 정정 시 한 줄 명시해두면 좋겠음. + +### N-6 — `SetAndDispose` 류 편의 콤비네이터 검토 (백로그 후보) + +- **출처**: SL-2 답변. +- **사용자 답변 원문**: + > 정확하나, source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 + > 생각해보았음(단 여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). + > Get해놓고 Set 이후 나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 + > source 자체에 :콜론 메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음. +- **문제**: `dispose`는 "`Set`(언마운트) → 그 다음 `dispose`" 순서를 + 요구하는데(SL-2), 그러려면 호출부가 **`Get()`으로 이전 값을 미리 + 잡아두고 → `Set(new)` → 잡아둔 옛 값을 `dispose`** 하는 3단계를 매번 + 손으로 써야 해서 편의성이 떨어진다. +- **후보 두 가지**: + 1. `source:Apply(SetAndDispose(new))` — 콤비네이터. **단 여기서의 + `Apply`는 `State`가 아니라 `Source`를 넘겨주는 함수여야 함**(사용자 + 명시) — 지금 확정된 `state:Apply(factory)`는 `factory(self)`에 + `State`를 넘기므로, `Source` 전용 변형이 필요한지 같이 정해야 함. + 2. `Source`에 **콜론 메서드**로 직접 얹기(예: `source:SetAndDispose(new)`). +- **미결**: 어느 쪽을 택할지, 그리고 애초에 이번 범위에 넣을지 백로그로 + 뺄지. `state:Apply`의 시그니처(`(State) -> U`)에 영향이 갈 수 있으므로 + M3 착수 전에 방향만이라도 정해두는 게 좋음. + +### N-7 — UI 숏핸드가 만든 자식을 `FindFirstChild` 대신 `Relate`로 기억할 것 + +- **출처**: UI-1 답변. +- **사용자 답변 원문**: + > 다만, FindFirstChild 는 비용이 ref 저장보단 비쌈. spring 등으로 움직일 + > 수도 있다 생각하면 릴레이션으로 저장하는것도 좋은 생각. 각 숏핸드가 + > 만들어낸 요소의 프로퍼티 세팅은 새로운 dispatch.process(target,k,v) 로 + > 위임해 tween 등이 자연스럽게 가능. +- **문서가 주장하는 것**: `base/ui-shorthand-plan.md`는 재사용 대상을 + **"quad가 이전에 만든 고정 이름(`_quad_corner`류) 자식"**으로 한정하는데, + 그 "찾기"를 어떻게 하는지는 v1처럼 **이름으로 조회**(`FindFirstChild`)하는 + 것으로 읽힌다. +- **요구 내용**: 이름 조회 대신 **`Relate`에 `(inst, 숏핸드키) → child`로 + 저장**해서 다시 찾을 것. 근거는 (a) `FindFirstChild`가 참조 저장보다 + 비싸고, (b) **spring 등으로 자식이 계속 움직이는** 상황이면 그 조회가 + 반복 비용이 됨. +- **주의 — 고정 이름 규약을 없애자는 뜻은 아님**: 이름(`_quad_corner`류)은 + 디버깅 가시성(`research/debug-tooling-plan.md` 9번)과 "사용자가 만든 + `UICorner`를 건드리지 않는다"는 판정에 여전히 필요해 보임. `Relate`는 + **조회 경로**를 대체하는 것이고, 두 가지가 어떤 관계인지(이름은 표시용, + 릴레이션은 조회용) 정정 시 명시할 것. +- **주의 — `Relate` 키 전제**: `inst`-키 `Relate`는 gcconn 셋업 위에서만 + 성립하는데(RE-1/L-1), 숏핸드가 만드는 **자식**도 quad가 만든 Instance라 + 그 셋업을 거치는지 확인 필요. + +### N-8 — `DI` → `D` 리네임 **확정** (2026-08-18) + 전수 반영 목록 + +- **출처**: 사용자가 이 QA 라운드 중 직접 확정("이거 하면서 DI => D 확정하자"). + 2026-08-08 용어 정리 라운드부터 `question.md` **1순위**로 열려 있던 항목. +- **확정 내용 두 갈래** (사용자 판정): + 1. **네임스페이스/모듈 자체는 `DI` → `D`.** `D.Frame` / `D/init.luau` / + `D.InstSlot` / `D.FrameModifier`. + 2. **"특수 DI 키"라는 설명용 표현은 `D`로 바꾸지 않고 "특수 키"로 + 단순화.** (사용자 선택: `"특수 키"로 단순화`) — "특수 D 키"라고 쓰지 + 않는다. 수식어 자체를 빼도 문맥상 통한다는 판단. +- **`D`로 가는 근거(이미 기록돼 있던 것)**: (1) "Instance" 전용 개념이 아니라 + quad-* 전반의 declare 요소로 확장 가능한 이름, (2) 엔진 종속 없이 다른 + 백엔드에서도 재사용 가능, (3) `D.FrameModifier`류 타입 프리픽스가 짧아야 + 한다는 실용적 제약. 원래 이름 `DI`의 문제는 **"Dependency Injection"과 + 완전히 겹쳐 실제로 오해가 있었던 전례**가 있다는 것. +- **⚠️ 같이 정해야 할 것 — 한 글자 식별자의 검색성/자기설명력 보완책** + (사용자 선택: `예, 같이 넣어둘 것`). 2026-08-08에 `D`를 확정 못 하고 미룬 + **유일한 사유**가 이거였음 — `D` 한 글자는 grep도 어렵고 이름만으로 + 뜻이 안 드러난다. 정정 시 아래를 같이 정할 것: + - 문서에서 `D`가 처음 나올 때 **항상 "Declarative"로 풀어쓰는** 규약을 + 둘지(예: "`D`(Declarative) 네임스페이스"). + - `conventions.md`의 "문서 표기 규약"에 넣을지, 아니면 `base/architecture.md` + "코드 스타일 — 네이밍 케이싱" 절에 넣을지. + +#### 반영 대상 — 전수 (2026-08-18 기준 grep, `session/`·`session-summary.md`·`initreq/` 제외) + +**갈래 ① `DI` → `D` (네임스페이스/모듈)** + +| 파일 | 줄 | 무엇 | +|---|---|---| +| `base/architecture.md` | 195 | 소스 트리의 `DI/` 디렉토리 + 그 아래 `init.luau` 주석 | +| `base/slot-plan.md` | 98 | `DI.InstSlot = Slot<>` — **"(`DI` 네임스페이스 이름 자체는 `question.md` 1번 용어정리 대기 중…)" 괄호도 삭제** | +| `base/bind-system-plan.md` | 126 | *"**"DI"는 Dependency Injection이 아니라 "Declarative Instance"**"* — 이 문단이 개명의 근거 자체이므로 **재작성**(왜 `D`가 됐는지로) | +| `base/bind-system-plan.md` | 130 | `DI.Frame` vs `DI.New<> "Frame"` | +| `base/bind-system-plan.md` | 199 | 열린 질문 *"**`DI`(또는 다른 이름) 등 정확한 모듈 이름**"* — **항목째 삭제**(해소됨) | +| `base/modifier-plan.md` | 313 | "DI 쪽 '제네릭 생성자 함수 하나 + …' 패턴 재사용" | +| `base/ui-shorthand-plan.md` | 74 | "quad-roblox의 각종 타입(DI 인스턴스 타입, Modifier 타입 등)" | +| `base/attribute-plan.md` | 66 | "이미 확정된 DI 인스턴스 생성 패턴" | +| `base/attribute-plan.md` | 449 | 표의 `백엔드(quad-roblox의 `D`/`DI` 층)` — **이미 병기 중**, `D`로 단일화 | +| `base/attribute-plan.md` | 492 | "최종 이름은 다른 가칭들(`DI`→`D`/…)과 함께 대기열" — 목록에서 제거 | +| `base/dispatch-core-plan.md` | 628 | `그 백엔드(quad-roblox의 `D`/`DI` 층)` — **이미 병기 중**, `D`로 단일화 | +| `ROADMAP.md` | 333 | 체크박스 `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드) | +| `ROADMAP.md` | 429–430 | `DI.InstSlot` + "`DI` 네임스페이스 이름 자체는 `question.md` 1번" 대기 문구 | +| `ROADMAP.md` | 726 | `quad-roblox의 `D`/`DI` 층` — `D`로 단일화 | +| `ROADMAP.md` | 825 | 용어 정리 스윕 체크박스의 `State`/`DI`/`Slot` 목록 — `DI` 제거 | +| `question.md` | 43, 46, 47 | **`DI` 항목 자체** — 해소 처리해 `archive/question-resolved.md`로 이전(`Merge`→`Overridden` 등 기존 해소 항목과 같은 방식). 46–47행의 파급(`DI.FrameModifier`류 타입 프리픽스)은 **이번 리네임에 실제로 포함**되므로 반영 목록에 흡수 | +| `question.md` | 183 | "M3 Source/M5 DI 생성자" | +| `todos.md` | 95 | 용어 정리 목록의 "`DI`→`D`(1순위)" — 해소로 제거 | +| `research/additional-primitives-plan.md` | 21 | 프리미티브 나열 `.../`Slot`/`DI`)` | +| `research/debug-tooling-plan.md` | 5, 126, 379, 460, 479 | "Source/DI 생성자", `DI/init.luau`, "DI 제네릭 생성자", "Dispatch/DI", "M5(quad-roblox DI 제네릭 생성자)" | +| `research/pre-implementation-audit.md` | 537, 538, 541 | "DI 쪽 패턴 재사용", "DI 타입 생성 계층(M5)", "M5 DI 체크리스트" | +| 이 문서 자신 | BS-2의 파급 문단 | `DI/init.luau` 주석과 `D`/`DI` 생성기 언급 | + +**갈래 ② "DI 키" → "특수 키" (설명용 표현)** + +| 파일 | 줄 | +|---|---| +| `base/architecture.md` | 37, 42, 191 | +| `base/onchange-plan.md` | 20, **76(헤딩 — 아래 ⚠️)** | +| `base/attribute-plan.md` | 33, 74 | +| `base/lifecycle-hooks-plan.md` | 28, 82, 83, 106, 111, 186, 294, 296, 300, 303 | +| `base/dispatch-core-plan.md` | 315 | +| `base/bind-system-plan.md` | 164 | +| `base/ref-plan.md` | 637 | +| `ROADMAP.md` | 135, 711 | +| `.claude/README.md` | 64 | +| `luau-test/README.md` | 81, 117 | + +- **⚠️ 헤딩 1개 + 그 헤딩을 절 인용하는 곳 1개가 짝으로 묶여 있음 — 반드시 + 같은 커밋에서 함께 고칠 것.** + - `base/onchange-plan.md:76` 의 `## 다른 특수 DI 키와의 대조` (헤딩) + - `base/lifecycle-hooks-plan.md:300` 이 그 절을 `"다른 특수 DI 키와의 대조"` + 로 **절 인용**하고 있음. + - 한쪽만 고치면 `doc-check.py`가 **절 참조 불일치 ERROR**로 잡아 커밋이 + 막힌다(`conventions.md`의 "절 인용 규약"이 ERROR로 승격돼 있음). +- **`base/ref-plan.md:637` / `base/lifecycle-hooks-plan.md:106` 은 같은 문장의 + 원문·인용 관계** — `lifecycle-hooks-plan.md`가 `ref-plan.md`의 문장을 + blockquote로 그대로 옮겨 적었으므로 **문구가 계속 일치해야** 함. +- **archive/·session/·session-summary.md 는 건드리지 말 것** — 히스토리 + 문서라 당시 표기 그대로 두는 게 `conventions.md` 관례 + (`archive/tag-hash-key-model-reversed.md` 제목의 "DI 키" 등). + +#### 반영 후 확인 + +- `python3 .claude/tools/doc-check.py` → **ERROR 0**(특히 위 절 인용 짝). +- `grep -rn '\bDI\b' --include='*.md' . | grep -v '/initreq/\|/session/\|session-summary.md\|/archive/'` → **0건**이어야 함. +- 인덱스 3층 갱신: `.claude/README.md`, `question.md`(항목 이전), + 루트 `ROADMAP.md` — `conventions.md`의 중대 변경 핸드오버 체크리스트 6번. + +### N-9 — `New`를 커링으로 명시하고 `D`를 "처리 없는 별칭 테이블"로 규정 (사용자 제안) + +- **출처**: 사용자 제안(2026-08-18, N-8 확정 직후). +- **사용자 발언 원문**: + > D.Frame 같은건 New 에서 커링되어, New 함수는 New(name)({}) 되는게 이롭다 + > 생각하는데 어떰? 실제 사용 상 New<> "Frame" {...} 로도 쓸 수 있고, + > D 에선 별다른 처리 없이 D.Frame = New<> "Frame" :: (({...타입명시}) + > -> Frame) 으로 쉽게 만들 수 있다는게 내 생각임. +- **제안 내용**: + 1. **`New`는 커링** — `New(name)`이 생성자 함수를 반환하고, 그걸 다시 + props 테이블로 호출: `New(name)({...})`. + 2. **직접 사용도 같은 모양** — `New<> "Frame" {...}` + (Lua 문법상 `New("Frame")({...})`). + 3. **`D`는 별다른 처리가 없다** — 필드마다 + `D.Frame = New<> "Frame" :: (({...타입명시}) -> Frame)` 로 + 캐스팅만 얹은 **순수 별칭 테이블**. +- **지금 문서와의 관계 — 뒤집는 게 아니라 명시화**: + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절이 + 인용한 PA님 패턴이 이미 `constructor.Frame = new("Frame")` 이라 **사실상 + 커링이었음** — 다만 (a) "커링이다"라는 것과 (b) 2단계 호출 계약과 + (c) `New(name)({...})` / `New "Name" {...}` 라는 **직접 호출 형태**가 + 문서에 명시된 적이 없다. BS-1에서 "eager 정적 테이블"은 이미 확인됐으므로 + 이 제안은 그 위에 "그 정적 필드가 정확히 무엇인가"를 채우는 것. +- **BS-2와 정합적** — 필드별 `::` 캐스트로 타입을 주는 방식은, `D` 생성기가 + 어차피 클래스별 props 타입(이벤트 콜백 시그니처 포함)을 뽑아야 한다는 + BS-2의 결론과 맞물린다. 기존에 인용돼 있던 + `new(className): from>` + (타입 레벨 인덱싱)보다 생성기 입장에서 단순함. +- **기각된 "2트랙"과 혼동하지 말 것** — 같은 절이 기각한 건 + *"필드=1급 타입 경로, 문자열=폴백"* 이라는 **능력 차이**였지 `New`라는 + 이름이나 문자열 호출 자체가 아니었다. 이 제안은 오히려 **두 형태가 완전히 + 같은 것**(하나가 다른 하나의 미리 적용된 결과)임을 못박는다. + +#### 후속 확정 (2026-08-18, 같은 대화에서 1~4번 전부 확정) + +**2. 이름은 대문자 `New`로 확정.** 사용자 답변: *"2. New입니다."* +지금 코퍼스는 둘 다 씀 — `bind-system-plan.md`가 PA님 코드를 인용할 땐 +소문자 `new(className)`, 같은 절의 (기각된) 2트랙 구상엔 대문자 +`DI.New<> "Frame"`. **대문자로 통일**하고, `base/architecture.md`의 +"코드 스타일 — 네이밍 케이싱" 절에 `D.New`가 어느 부류로 들어가는지도 +같이 적을 것. + +**3. `D`는 전부 코드 자동 생성이 맞음 — `New` 호출문까지 생성기가 찍는다.** +- **사용자 답변 원문**: + > 3. 코드로 자동 생성되는것이 맞는게, index 가지고만 하면 + > MouseButton1Click 이 RBXScriptConnection 이 되어버림. 처리하기 힘들다 + > 생각하는게, lsp 마다 Frame 타입을 어떻게 다루냐 다를 수 있음. 게다가 + > T|State 같은것 또한 처리해야하는데, 이걸 타입 함수로 다 처리하게 + > 만드는것 보다 단순히 D 파일이 자동 생성되는게 좋다고 생각함. 그리고 + > 코드 생성 산출물인가는 맞음, 전부 코드 생성이나, New 같은것도 생성기에서 + > 같이 적어주어야할 부분. +- **⚠️ 이건 단순 확인이 아니라 `bind-system-plan.md`가 인용한 시그니처를 + 부정한다.** 그 문서는 PA님 코드를 그대로 인용해 + `new(className): from>` — + 즉 **타입 레벨 인덱싱**으로 클래스 타입을 뽑는 모양인데, 사용자가 든 + 세 가지 이유로 그 방식만으로는 부족하다: + 1. **이벤트 필드가 콜백 타입이 안 된다.** Roblox 타입 정의에서 + `MouseButton1Click`은 시그널 계열 타입이라, 인덱싱으로 뽑으면 그 + 타입이 그대로 나와버리고 quad가 원하는 + `((...) -> ())?` 콜백 시그니처가 안 나옴. **이게 곧 `BS-2`가 요구한 + "생성기가 이벤트 필드의 콜백 타입까지 만들어야 한다"의 직접적 근거** — + 두 항목은 같은 문제의 양면이므로 반드시 같이 처리할 것. + 2. **LSP마다 `Frame` 타입을 다루는 방식이 다를 수 있어** 타입 함수/인덱싱에 + 의존하는 게 위험하다. + 3. **`T | State`(그리고 `T | Tween`, `None` 등)까지 타입 함수로 + 조립해야 하는데**, 그럴 바엔 `D` 파일을 통째로 생성하는 쪽이 단순하다. +- **따라서**: `D`는 타입뿐 아니라 **`D.Frame = New<> "Frame" :: (...)` + 라는 값 선언까지 생성기가 찍어내는 파일**이다. 손으로 쓰지 않는다. +- **반영**: `base/bind-system-plan.md`의 PA님 시그니처 인용을 이 결론에 맞게 + 다시 쓸 것 — "PA님 코드 그대로 채택"이라는 프레이밍 자체가 + **타입 조립 방식에 한해서는 더 이상 정확하지 않음**(호출 모양은 그대로 + 채택, 타입은 생성으로 감). + +**4. 생성 범위는 "GUI에 쓰이는 모든 인스턴스" — 전량은 부적합.** +- **사용자 답변 원문**: + > 4. 는 모든 인스턴스를 넣기는 부적합함. GUI에 쓰이는 모든 인스턴스를 자동 + > 생성한다로 잡아줘도 좋을것으로 보임. 안 그럼 D 파일이 너무 커짐. +- 즉 기존 **"자주 쓰는 ~25개"** 도 아니고 **Roblox 전체 클래스**도 아닌, + **"GUI에 쓰이는 것 전부"** 가 기준. 근거는 파일 크기 — 전량 생성하면 + `D` 파일이 너무 커진다. +- **반영**: `base/bind-system-plan.md`가 생성 대상을 `~25개`(자주 쓰는 + `Frame`/`TextButton`/`UICorner` 등, `UIInstances` 타입 테이블 등록분)로 + 적어둔 서술과, `base/architecture.md` 소스 트리의 + `# 제네릭 생성자 + ~25개 정적 필드(UIInstances)` 주석을 이 기준으로 교체. **"GUI에 쓰이는"의 정확한 판정 + 기준**(Roblox API 덤프에서 무엇을 GUI로 볼 것인가 — `GuiObject` 하위 + + `UIComponent` 하위 + `LayerCollector`류 등)은 생성기 구현 시점에 정할 + 것으로 남김. + +**1. `D`에 없는 클래스는 느슨하게 `any` — 필요하면 사용자가 직접 채운다.** +- **사용자 답변 원문**: + > a. 느슨하게 any 로 하고, 필요하면 이를 직접 구현 가능하게 둡니다. cast 를 + > 하든, 유저의 자유 +- **내용**: `D`가 커버하는 범위(위 4번의 "GUI에 쓰이는 모든 인스턴스") **밖**의 + 클래스를 `New<> "X" {...}` 로 직접 쓰면 props 타입은 **느슨하게 `any`**. + quad는 그 자리에서 타입 안전성을 보장하지 않고, 필요하면 **사용자가 직접 + 좁힌다** — `::` 캐스트를 쓰든 자기 래퍼를 만들든 자유. +- **왜 이게 자연스러운가**: `D.Frame` 자체가 애초에 + `New<> "Frame" :: (({...}) -> Frame)` — **캐스트 한 줄**이다(위 N-9 + 본문). 즉 생성기 산출물이 특권적인 게 아니라, **사용자가 임의 클래스에 + 대해 똑같은 한 줄을 직접 쓸 수 있다.** "직접 구현 가능하게 둔다"가 새 확장 + 지점을 만든다는 뜻이 아니라, 이미 있는 패턴을 그대로 쓰면 된다는 뜻. +- **파급 — BS-1의 서술 수정 필요**: `base/bind-system-plan.md`가 확정해둔 + *"제네릭 생성자 함수 하나가 알려진 타입과 모르는 타입을 **전부 커버**"* 는 + 이제 정확하지 않다. **런타임은 여전히 전부 커버하지만 타입은 아니다** — + `D` 범위 안은 생성된 정확한 타입, 밖은 `any`. 그 문장을 이 구분이 드러나게 + 다시 쓸 것. +- **이걸로 N-9의 열린 항목은 전부 닫힘** — [2026-08-18 기준] 반영도 같은 날 완료. + +#### 반영할 곳 + +- `base/bind-system-plan.md` "인스턴스 생성 / 이벤트 네이밍 인체공학" 절 — + 커링 계약과 두 호출 형태를 본문에 추가, PA님 시그니처 인용을 이 결론에 + 맞춰 갱신. +- `base/architecture.md` 소스 트리의 `D/init.luau` 주석(현 + `DI/init.luau # 제네릭 생성자 + ~25개 정적 필드(UIInstances)`) — N-8과 + 같은 줄이라 **한 번에 같이 고칠 것**. +- `ROADMAP.md`의 `D/init.luau` 체크박스(현 333행) 및 M5 관련 항목. +- `base/slot-plan.md`의 `D.InstSlot = Slot<>` — 이것도 같은 + "별칭 테이블" 패턴인지(즉 `D`가 인스턴스 생성자 말고 타입 별칭도 담는지) + 확인 필요. + +--- + +## 부수 발견 — 오탈자/표기 불일치 (사용자 판정 불필요, 확인만 됨) + +여기 있는 것은 설계 오류가 아니라 **문서 표기 실수**다. 위 절들과 달리 +사용자 회신을 기다릴 필요 없이 정정 라운드 때 같이 고치면 된다. + +- **`architecture.md` "Store/State/Source 온톨로지 — 확정됨" 절의 `.value` + (소문자)** — A-6에서 **`ref.Value`(대문자)가 맞음**으로 확인됨. 같은 문서 + 소스 트리 주석(`Ref.luau` 행)은 `.Value`로 맞게 적혀 있어 한 문서 안에서 + 두 표기가 섞여 있음. 다른 문서에도 `.value`가 퍼져 있는지 정정 시 grep + 필요. +- **`source-state-plan.md` "`:Compute(fn)`의 선택적 두 번째 인자" 절의 + `fn(value, previous)`** — S-2에서 최종 시그니처가 + **`fn(self, previous?, ...deps)`** 로 확인됨. 그 절만 `self`가 lazy 핸들로 + 통일되기 전의 구 표기(`value`)로 남아 있어, 그 절만 읽으면 첫 인자가 raw + 값인 줄 오해하게 됨. + +--- + +## 진행 로그 + +**1라운드(2026-08-18) 완료.** `base/` 25개 문서를 의존성 순서로 훑으며 +`AskUserQuestion`으로 확인. 문서당 3~4개 주장씩, 총 22배치. + +| 문서 | 결함/열린 항목 | 신규 요구 | +|---|---|---| +| `architecture.md` | A-3 | — | +| `source-state-plan.md` | S-1, S-12 | N-1 | +| `store-plan.md` | ST-2 | N-2 | +| `lifecycle-pattern.md` | (S-1의 진원지) | — | +| `dispatch-core-plan.md` | D-1, D-5, D-6, D-7 | N-3 | +| `relate-plan.md` | RE-1 | — | +| `modifier-plan.md` | M-3 | — | +| `ref-plan.md` | R-1, R-3, RF-4 | N-4 | +| `brand-plan.md` | B-1 | — | +| `attribute-plan.md` | AT-1 | N-5 | +| `slot-plan.md` | SL-1, SL-3 | N-6 | +| `effect-plan.md` | E-2 | — | +| `event-plan.md` | EV-1 | — | +| `bind-system-plan.md` | BS-2 | — | +| `ui-shorthand-plan.md` | — | N-7 | +| (전역 이름·표면) | — | **N-8 `DI`→`D`**, **N-9 `New` 커링** | +| `blocker-plan.md` / `tag-plan.md` / `tween-plan.md` / `typing-limits.md` / `component-composition-plan.md` / `module-lifecycle-plan.md` / `onchange-plan.md` / `purity-and-effects-plan.md` / `fallback-plan.md` / `lifecycle-hooks-plan.md` | **전부 통과** | — | + +**아직 안 본 것 (2라운드 대상 — 새 파일 `pre-implementation-qa-round2.md`에 쓸 것)**: +- `slot-plan.md`의 `:List` 내부(`reconcile` 구현, `keyFn`, `userdata` + 생명주기, 구독 시점, Slot-in-Slot 재귀)와 `dispatch-core-plan.md`의 + `recompute`를 **손으로 트레이싱**하는 검증 — 이번 라운드는 "확정된 주장이 + 맞는가"를 물었지 의사코드를 실행해보진 않았다. +- `reference/` (v1 스냅샷, 프레임워크 비교) — 확정 문서가 아니라 제외했으나, + `base/`가 근거로 인용하는 사실들이라 인용이 정확한지는 미검증. +- `research/` 11개 — 확정 전이라 제외. +- 루트 `ROADMAP.md`의 마일스톤 분할이 이번 발견들과 맞는지. diff --git a/.claude/qa-request/pre-implementation-qa-round2.md b/.claude/qa-request/pre-implementation-qa-round2.md new file mode 100644 index 0000000..d81499e --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round2.md @@ -0,0 +1,340 @@ +# 구현 전 QA **2라운드** — 의사코드 손 트레이싱 + +**상태**: **완료 — 핵심 발견(`RC-1`)까지 같은 날 후속 세션에서 해결· +`base/` 반영 완료.** 1라운드는 "확정된 주장이 맞는가"를 물었을 뿐 의사코드를 +실제로 실행해보진 않았음(`pre-implementation-qa-round1.md` 맨 아래 "진행 +로그" 절). 2라운드는 그 갭을 메우는 작업 — `base/slot-plan.md`의 `:List` +`reconcile`과 `base/dispatch-core-plan.md`의 `recompute`를 구체 시나리오로 +직접 손으로 실행해보고, 부수로 `reference/` 인용 정확성과 `ROADMAP.md` +마일스톤 정합성도 훑었다. + +**이 문서의 용도**: 1라운드와 달리 "예/아니오 판정" 문항이 아니라 트레이싱 +결과 자체가 산출물 — 버그를 찾으면 그 자리에서 기록하고, 방향이 갈리는 +것만 사용자에게 물었다. 새로 발견된 결함은 `RC-1` 하나뿐이고, 나머지는 +"트레이싱했지만 문제 없음 확인"으로 아래 각 절에 남긴다(재작업 방지용 +기록 — `conventions.md`의 "작업이 끝나면(또는 방향이 바뀌면) 항상 자기 +문서화" 원칙). + +--- + +## RC-1 — `recompute`의 트리거 모델 자체가 재검토 대상 ✅ 해결(2026-08-18 후속 세션) + +**판정**: 트레이싱으로 실제 크래시 경로를 확인 — 사용자도 진단은 맞다고 +동의했고, 같은 날 후속 대화에서 **Blocker를 재사용하는 배치 게이팅 +설계로 확정**됐다(아래 "해결 — Blocker 게이팅" 절). 해법이 실제 +반영된 곳은 `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 +Blocker 게이팅" 절, `base/slot-plan.md`의 "재귀 메커니즘" 절, +`base/blocker-plan.md`의 "`state:Block()` 없이 직접 쓰는 두 번째 용례" +절 — 이 문서는 그 결론에 이르는 논의 원문만 보존한다. + +### 발견한 크래시 경로 + +`base/dispatch-core-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 +순서 보장" 절의 `recompute` 의사코드: + +```lua +local function recompute(ownerKey, bk) + local sum = 0 + for i = 1, bk.N do + local offset = bk.sourceList[i] + if offset ~= nil and offset ~= None and offset:Get() ~= sum then + offset:Set(sum) + end + local v = bk.lengthList[i] + sum += (if isState(v) then v:Get() else v) -- ← v가 nil이면 여기서 산술 에러 + end + ... +end +``` + +**구체 시나리오 — `Frame{A, B}`(정적 자식 2개, 반응형 없음, `N=2`).** + +1. `Dispatch.drive(inst, flattened)`가 배열 파트를 순서대로 순회 — + `bk.N`은 "그 `inst`의 array part 크기" 이므로 순회를 **시작하는 시점에 + 이미 `2`로 정해져 있음**(같은 절의 "저장 위치" 문단). +2. position 1(`A`)을 담당하는 말단 Handler가 `Dispatch.setLength(inst,1,1)`을 + 부름. `setLength`의 의사코드는 **끝에서 무조건 `recompute(inst,bk)`를 + 호출**(같은 절 "`setLength` 구현" 문단 — "등록 즉시 1회(Observer 자체의 + '등록 즉시 1회 실행'과 겹쳐도 무해)"). +3. 이 시점의 `recompute` 루프가 `i=1..bk.N(=2)`을 돎 — `i=1`은 + `bk.lengthList[1]=1`이라 정상. `i=2`는 **position 2(`B`)가 아직 처리되기 + 전이라 `bk.lengthList[2]`가 `nil`** — `sum += (if isState(v) then + v:Get() else v)`에서 `sum += nil`이 되어 산술 에러로 크래시. + +`sourceList`(offset) 쪽은 같은 절에 이미 nil 가드가 있음(*"[방어, +2026-08-13 여섯 번째 세션] `nil`도 같이 배제"*) — `lengthList` 쪽엔 그 +대칭 가드가 없다. 정적 자식 2개짜리 `Frame`처럼 **가장 흔한 경우**에서 +바로 재현되는 경로라, 별도 `Slot`/반응형 값 없이도 걸림. + +### 왜 지금까지 안 잡혔나 + +기존 `luau-test/` 스파이크 중 이 함수를 다루는 건 +`done/20-slot-splice-index-arithmetic.luau` 하나뿐인데, 이건 `Splice`의 +순수 배열 인덱스 산술(제거/삽입 시 뒤 요소가 몇 칸 밀리는지)만 검증하고 +**`Dispatch.drive`가 여러 position을 순차 처리하며 `bk.lengthList`를 점진적으로 +채우는 과정 자체는 다루지 않음** — 이 경로를 실행해본 스파이크가 없었다. +같은 함수의 다른 버그(offset이 자기 자신을 포함해 누적되던 off-by-one)는 +2026-08-11 세션에 이미 한 번 발견·수정됐지만(같은 문서 "recompute — 매번 +전체 순회" 문단), 이번 것은 그와 별개의 문제. + +### 사용자 답변 원문 — 진단은 맞다고 확인, 그러나 더 큰 재설계 필요 + +> setLength/setOffset 자체는 리레이아웃을 트리거하진 않아야한다고 생각함. +> 명시 리트리거를 해야하는게, 이러면 첫 실행에서 계속 recompute 비용이 +> 쌓임. 옵져버 생성 시 클로저 위쪽에 init = true 두고 처리할 필요가 +> 있는듯 하고, 각각의 process 가 setLength 를 나중에 수행했을 때는 +> recompute 를 어떻게 할지 생각해봐야할듯. 등록을 배치로 미룸은 맞는데, +> State 이 오는건 어쩌냐를 잘 모르겠음. 각각 처음에 바운딩 +> 할 땐 그럼 offset/length 어떻게 계산할지는 이것도 아직 모르겠음.. +> 생각을 더 해 + +**애초에 열려 있던 세 갈래**(사용자가 처음엔 하나로 안 좁힘) — (1) +`setLength`/`setOffsetSource` 자체는 `recompute`를 트리거하지 않아야 +한다(명시적 리트리거 필요), (2) 등록을 배치로 미루는 방향은 맞지만 +`State`처럼 값이 나중에 도착하는 경우를 어떻게 커버할지 불명, +(3) 최초 바인딩 시점의 offset/length 계산 방식 자체가 안 잡힘 — 아래가 +같은 날 후속 대화에서 이 세 갈래를 하나로 합친 결과다. + +### 해결 — Blocker 게이팅 (2026-08-18, 같은 날 후속 세션) + +**사용자가 직접 제시한 설계 원문**: + +> 각각의 length 들을 그냥 받아서 바인딩 하는게 아니야. 각 Frame 에 대한 +> Blocker 를 릴레이션으로 가지고 있고 이건 드라이빙 함수가 실행될 때 +> 생성돼. Offset/Length 소스가 설정 될 때, 특히 Length 에 있어서는 이 +> Frame->Blocker 로 있는걸 얻어와서 한번 적용하고 넣어둬. 그리고 Observer +> callback 에서도 Blocker 가 IsOn 상태면 무시해줘. 드라이브 함수가 +> 실행되어 Blocker 가 생성될 때 기본으로 On 을 해줘. 이러면 length 가 +> 변경되며 계속 recompute 되지 않아. 그런 다음 OffWithoutEmit 을 해. +> 맨 마지막으로 recompute 를 한번 하면 되는식이야. +> +> 다만, 이러면 초기에 레이아웃이 이상해져. 주의할 점은 setOffsetSource +> 를 하게 되면 이건 자기 자신 위쪽으로 있는 요소들의 length 를 합해서 +> 설정해줘야할거야. 이건 첫 for 루프에서도 작동한다고 봄. 즉, 사실 첫 +> 루프 상 recompute 자체는 필요 없어. 그리고 setOffsetSource 는 여전히 +> slot 의 실체화로 List 가 수행되기 전에 설정되어야하고, length 가 확정 +> 된 다음에 다음 요소로 넘어가야해. + +**요지 두 갈래로 나뉜다**: +1. **`recompute`를 배치가 끝날 때까지 아예 안 돈다** — owner(inst 또는 + Slot)마다 `Relate`로 들고 있는 전용 `Blocker`를 배치 시작 시 `On`, + `setLength`의 Observer 콜백(등록 즉시 1회 실행 포함)이 + `blocker:IsOn()`이면 `recompute`를 건너뜀. 배치가 끝나면 + `OffWithoutEmit()` + 명시적 `recompute` 딱 1회. +2. **`setOffsetSource`는 그 즉시 앞선 형제들의 길이 합을 직접 계산해 + `:Set`한다** — recompute를 미루는 것만으로는 "초기 레이아웃이 + 이상해지는" 문제(배치 도중 `:List`가 실체화되며 옛/기본값 offset을 + 읽어버림)가 남기 때문에, offset 자체는 즉시 계산으로 옮겨 recompute를 + 기다리지 않게 함. + +**뒤이은 확인 질문 3개와 답변**(`AskUserQuestion`, 같은 세션): + +- **중첩된 Slot이 `attachSlot`을 재귀할 때도 각자 별도 Blocker가 + 필요한가?** → *"예 — 중첩마다 별도 Blocker (권장)"* — + `base/blocker-plan.md`의 재진입(네스팅) 미지원 규칙 그대로 적용. +- **런타임에 이미 마운트된 Slot에 한 번에 하나씩 `:Add()`하는 경우도 + 같은 위험이 있는가?** → *"그건 이미 마운트가 된 이후라서 별 상관 + 없음. 가장 큰 문제는 마운트 중간에 후행 nil 이 있는데 recompute 가 + 난다는게 문제... 정확한 내 의견은 이래: setLength 는 recompute 를 + 직접 수행하진 않고, Observer 에서 recompute 를 수행해. 맨 처음 emit + 에서도 blocker 가 on 이면 무시하는식. 그럼에도 새로운 개체가 뒤에 + 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를 + 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고 + 수행 가능함"* — 크래시는 오직 "N이 미리 정해진 채 배치로 등록"되는 + 두 자리(`Dispatch.drive`, `attachSlot`의 flush)에서만 나고, 런타임 + 단건 append는 이미 안정된 앞선 position만 참조하므로 무관함이 확정. + `setLength`가 `recompute`를 직접 안 부르고 Observer 콜백(첫 실행 + 포함)만을 경유한다는 것도 이 답변에서 확정됨. +- **`IsOn`/`HasBlocked`를 기존 `IsBlocked`/`HasBlockedEmit`과 어떻게 + 관계지을까?** → *"IsBlocked가 있다면 그냥 두어도 될듯 함. + HasBlockedEmit 만 처리된다면 괜찮다 생각"* — 기존 필드는 그대로 두고, + `IsOn()`은 `IsBlocked`를 읽는 얇은 조회 메소드로만 추가. 처음 요청했던 + `HasBlocked`(Blocker 자신의 새 최상위 플래그)는 **신설하지 않음** — + `OffWithoutEmit()`이 각 gated state의 기존 `HasBlockedEmit`을 그대로 + 리셋해주는 것으로 충분하다고 판단. + +### 후속 정정 — `attachSlot`의 호출 순서가 뒤집혀 있었음 (같은 세션, RC-1 반영 직후) + +위 설계를 `attachSlot`에 실제로 반영하는 과정에서 사용자가 직접 짚은 +추가 결함: `attachSlot`의 기존 의사코드는 `setLength`를 먼저, `setOffsetSource`를 +나중에 불렀는데, 이건 `base/dispatch-core-plan.md`의 "`NilHandler`" 절이 +이미 확정해둔 **"호출 순서는 `setOffsetSource` → `setLength`"** 일반 +규칙과 어긋나 있었다(RC-1로 `setOffsetSource`가 즉시 계산을 하게 되면서 +이 불일치가 드러남 — 그 전엔 둘 다 `recompute`에 얹혀 있어서 순서가 +겉으로 안 드러났었다). 사용자 확정 원문: + +> length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset +> 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨. 따라서 위가 +> 맞음 + +즉 Slot의 진짜 `.Length`는 `activateList`가 자기 `:List`를 최초 +reconcile한 **뒤에야** 확정되므로, 순서는 **`setOffsetSource`(즉시 계산) +→ (Slot이면) `activateList` 실체화 → `setLength`(그제서야 확정된 값으로 +등록) → 물리 마운트**여야 한다 — `setLength`를 실체화 전에 부르면 등록 +직후 값이 또 바뀌어 전파가 한 번 낭비된다. 평범한 Instance 요소도 같은 +순서(`setOffsetSource(None)` → `setLength(1)` → `Parent` 대입)를 따르며, +이 경로는 기존 의사코드에 아예 안 보이던 것도 이번에 같이 채워짐. + +**부수 확정 — 코루틴 yield 금지 불변식.** 사용자가 이 논의 말미에 지적: + +> 모든 컴포넌트든 뭐든 yield 되면 안되는 sync 함수이여야 할듯. 안 그럼 +> 꼬이는 문제가 발생하지 않나 생각함 + +이 배치 게이팅 전체가 "position이 항상 순서대로, 끼어드는 코드 없이 +동기로 처리된다"는 전제 위에 있어서, `Dispatch.process`/`attachSlot` +호출 체인 도중 코루틴 yield가 끼면 같은 owner의 Blocker를 다른 코드가 +그 사이에 건드릴 수 있다 — 명시적 불변식(UB 선언)으로 문서화하기로 확정. + +**반영된 곳**: +- `base/blocker-plan.md` — `IsOn()`/`OffWithoutEmit()` 신설, onunblock + 핸들이 `emit: boolean`을 받도록 변경, `state:Block()` 없이 직접 쓰는 + 용례 신설, 재진입 규칙에 이 용례의 실제 사례 추가. +- `base/dispatch-core-plan.md` — "Length/Offset" 절에 "배치 등록을 + 안전하게 만드는 Blocker 게이팅" 절 신설(`setLength`/`setOffsetSource` + 재작성, `Dispatch.drive`도 자기 Blocker로 배열 파트 순회를 감쌈), 코루틴 + yield 금지 불변식 추가. +- `base/slot-plan.md` — "재귀 메커니즘" 절의 `attachSlot`이 자기 flush + 루프를 자기 자신의 Blocker로 감싸도록, 그리고 `setOffsetSource`→`setLength` + 순서로 재작성(평범한 Instance 요소의 등록도 명시적으로 채움), 런타임 + 단건 `Add` 경로는 게이팅 불필요함을 명시. + +--- + +## SL — `base/slot-plan.md`의 `:List` reconcile 트레이싱 — 새 결함 없음, 기존 미해결 갭만 재확인 + +`base/slot-plan.md`의 "구현" 절(`activateList`/`reconcile` 의사코드)을 아래 +시나리오로 손으로 실행: + +- **재정렬**(`[A,B,C]` → `[C,A,B]`, 값 동일) — `pos`/`keyIndex` 비교가 + `rawMove`를 정확한 절대 위치로 호출, 정상. +- **키 제거**(`[A,B,C]` → `[A,C]`) — 소멸 루프가 `keyIndex`(직전 사이클 전체 + key 집합)를 순회해 `B`를 `rawRemove`, 나머지 `pos` 압축도 정상. +- **필터 토글**(`updateFn`이 특정 key에 `nil` 반환) — `rawRemove`(파괴) + 경로를 타고, `pos`가 그 키만큼 증가 안 해 뒤 요소가 정상 압축됨. +- **`PopOnly` 반환 후 재등장** — `mounted[key]=nil`이지만 `userdata[key]`가 + `{old=...}`를 강하게 붙잡아 GC를 막고, 다음 사이클에 `prev=nil`로 + 받은 `updateFn`이 `ud.old`를 그대로 반환하면 `rawAdd`로 재마운트됨 — 문서 + 서술대로 정상 동작. +- **nested Slot 반환**(`isSlot(result)`) — `pos = candidateIndex - 1 + + result.Length:Get()`으로 다음 형제의 위치가 정확히 밀림, "`:List`의 + `index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함" 절의 결론과 + 일치. +- **중복 key** — `seen[key]` 체크가 `updateFn` 호출 *전에* 있어 즉시 + `error`, 상태 오염 없음. + +**PopOnly 홀드 중 키가 데이터에서 완전히 사라지는 경우**만 트레이싱으로도 +재현됨 — 소멸 루프가 `mounted[key]`(이미 `nil`)만 보고 `rawRemove`를 +건너뛰어, 그 요소가 파괴도 반환도 안 되고 참조만 끊겨 GC된다. 이건 **이미 +`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절과 `question.md` +3번(`PopOnly`로 홀드 중이던 요소의 키가 사라지면 어떻게 처분하는가)이 +알고 있는 미해결 갭** — 이번 트레이싱은 그 갭이 실제로 재현됨을 손으로 +다시 확인했을 뿐, 새 발견이 아니다. 결론은 그대로 `question.md` 3번의 +(a)/(b)/(c) 선택지에 맡긴다. + +--- + +## D — `base/dispatch-core-plan.md`의 하강 diff 재디스패치 트레이싱 — 문제 없음 + +"Dispatch 체인" 절의 `Dispatch.process`/`Dispatch.retractFrom`을 아래 +시나리오로 트레이싱: + +- **최초 마운트**(`store.key = a`, `a: State`) — `StoreBind`가 `index=1`을 + 잡고 `a:Get()`을 들고 `Dispatch.process(inst,k,realv,2)`를 재귀, `chains`에 + `[1]=StoreBind`, `[2]=TagHandler`가 순서대로 쌓임. 정상. +- **같은 핸들러로 값 교체**(`a`가 새 `Tag`를 내놓음) — `Dispatch.process`의 + (A) 분기가 인덱스 2 슬롯의 기존 `retractor`에 새 값을 넘기고 클로저를 + 교체, 인덱스 1은 안 건드림. `chains` 구조·재귀 깊이 전부 문서 서술과 + 일치. +- **`State>`** — 안쪽 재귀가 `index+1`이라는 별개 슬롯을 쓰므로 + StoreBind 싱글톤이 같은 `(inst,k)`에 두 번 매치돼도 슬롯이 안 겹침 — + "`State>`는 정상 지원 대상" 절의 결론과 일치. + +새로 발견된 문제 없음. + +--- + +## `reference/` 인용 정확성 — 표본 점검, 불일치 없음 + +`base/` 문서가 `reference/quad-v1-architecture.md`/`reference/ +comparison-fusion-vide.md`를 인용하는 자리 중 표본 3곳을 원문과 대조: + +- `base/lifecycle-pattern.md`가 v1의 GC 방지 핫팩을 인용한 자리 — 원문 + (`PropertyChangedSignal("ClassName")`에 연결해 참조를 붙잡아두는 관용구)과 + 일치. +- `base/slot-plan.md`가 v1 `mount.lua`를 분석한 자리("부모/자식 부기까지 + 했지만 다중 마운트 방지는 없었음") — 원문(`mount.lua`는 실제로 부모/자식 + 부기(bookkeeping) + 라이프사이클 파괴까지 담당하는 무거운 모듈)과 + 정합(다중 마운트 방지 부재는 문서 전체 맥락상 타당한 요약). +- `base/tween-plan.md`가 Fusion의 Tween/Spring을 반면교사로 인용한 자리 — + `reference/comparison-fusion-vide.md`의 해당 문구와 일치. + +전수 대조는 아니고(`reference/`를 인용하는 자리 전체는 `base/*.md`에서 +`reference/`로 grep한 결과 10여 곳 — 정확한 개수는 grep 결과 자체가 +소스), 표본에서 불일치가 안 나와 전수 대조로 확장하지 않음. + +## `ROADMAP.md` 마일스톤 정합성 — 1라운드 반영분 확인, 불일치 없음 + +1라운드에서 뒤집힌 것 중 `ROADMAP.md`가 언급하는 것들이 갱신됐는지 확인: +`NilHandler`/`NoneHandler` 분리(**[정정, 2026-08-18 `/code-review high`] +M2가 아니라 M7 — Modifier 마일스톤에 체크박스가 있음, M2엔 서술 문단 +안에서만 이름이 언급될 뿐 별도 체크리스트 항목이 없음**), `PopOnly` 반환 +경로(M6), `store:GetDynamic` 탑레벨/콜론 미정 표시(M3), `DI`→`D` +리네임(전역) — 전부 반영 확인됨. +`D-6`(`setLength`/`setOffsetSource` 호출 책임자를 "처음 매치한 Handler"로 +서술하던 옛 오류)의 흔적도 `ROADMAP.md`엔 없음(애초에 그 정도 구현 +디테일은 `base/`만 갖고 있고 `ROADMAP.md`는 체크박스 수준이라 옮겨붙을 +자리가 없었음). + +--- + +## [중간 상태 기록, Blocker 해법 확정 이전] 반영 + +**⚠️ 이 절은 낡았다 — RC-1이 아직 "미해결"이던 시점(사용자가 "생각을 더 +해"라며 방향을 안 정했을 때)에 세워둔 임시 조치 목록이다.** 그 뒤 같은 +세션 후속 대화에서 Blocker 게이팅 설계가 확정되며 아래 내용 대부분이 +다시 뒤집히거나 대체됐다 — **지금 유효한 반영 목록은 위 "해결 — Blocker +게이팅" 절의 "반영된 곳" 문단이 소스**, 여기서 다시 정리하지 않는다. +`question.md`는 이 중간 단계에서 `RC-1`을 추가했다가 해소 단계에서 +다시 뺀 것이라 최종 diff엔 흔적이 안 남는다(2026-08-18 감사에서 확인) — +다음 세션이 "question.md가 바뀌었어야 하는데 안 바뀌었다"고 오해하지 +않도록 남겨둠. 아래는 그 시점의 원문 그대로 보존(역사 기록): + +- `question.md` 3번에 `RC-1`을 M2 착수 전 결론 필요 항목으로 추가(→ 이후 + 해소되며 제거, `archive/question-resolved.md`로). +- `.claude/todos.md` 00번(구현 전 QA 결과 요약)의 미해결 목록에 `RC-1` + 추가, 머리말을 "M2/M3 착수 전 필요"로 갱신(→ 이후 해소 반영으로 다시 + 갱신). +- `base/dispatch-core-plan.md`의 "Length/Offset" 절 `recompute`/`setLength` + **의사코드 자체엔 손대지 않음**(nil 가드 같은 국소 수선도 넣지 않기로 + 함) — 대신 그 절 바로 위에 미해결 배너를 추가(→ 이후 의사코드 자체가 + Blocker 게이팅으로 재작성됨). +- `base/slot-plan.md`의 "재귀 메커니즘" 절 서두에 `RC-1` 포인터 추가(→ + 이후 `attachSlot` 의사코드 자체가 재작성됨). +- `ROADMAP.md` M2/M6 체크박스 3곳에 `RC-1` 경고/각주 추가(→ 이후 해결 + 표시로 갱신). +- `.claude/README.md`/`project-context.md`의 `qa-request/` 서술을 round2 + 진행 중 상태로 갱신(→ 이후 완료 상태로 갱신). + +--- + +## 진행 로그 + +**2라운드(2026-08-18) — `:List` reconcile + `recompute` 손 트레이싱, `reference/` +표본 대조, `ROADMAP.md` 정합성 확인, 그리고 같은 날 후속 세션에서 `RC-1` +해결까지 완료.** 1라운드가 "아직 안 본 것"으로 남겨둔 항목 중: + +- `slot-plan.md`의 `:List` 내부 — **트레이싱 완료**(위 "SL" 절). +- `dispatch-core-plan.md`의 `recompute` — **트레이싱 완료, `RC-1` 발견 → + 같은 날 Blocker 게이팅 설계로 해결**(위 "해결 — Blocker 게이팅" 절). +- `reference/` — **표본 점검 완료**(전수는 아님, 위 참고). +- `research/` 13개(1라운드 기록 당시 11개였으나 같은 날 세션 중 + `fastscroll-plan.md`/`spring-plan.md`가 추가돼 지금은 13개 — + 정확한 개수는 `research/` 폴더 자체가 소스) — **이번 라운드에서도 제외**, + 확정 전 문서라 우선순위 낮음(`.claude/README.md`의 `research/` 표 참고). +- 루트 `ROADMAP.md` 마일스톤 분할 — **확인 완료**(위 "ROADMAP.md 마일스톤 + 정합성" 절). + +**남은 일**: 없음 — `RC-1`까지 닫혀 2라운드 자체가 완료됐다. 3라운드가 +필요해지면(예: 이번에 새로 들어간 `attachSlot`/`recompute`/`Blocker` +의사코드를 다시 손으로 트레이싱하는 검증) 새 파일 +`pre-implementation-qa-round3.md`를 만들 것. diff --git a/.claude/qa-request/pre-implementation-qa-round3.md b/.claude/qa-request/pre-implementation-qa-round3.md new file mode 100644 index 0000000..e224cfb --- /dev/null +++ b/.claude/qa-request/pre-implementation-qa-round3.md @@ -0,0 +1,422 @@ +# 구현 전 QA **3라운드** — Blocker/`attachSlot`/`recompute` 손 트레이싱 + +**상태**: **완료 — `RC-3`/`RC-4`/`bk.N` 전부 사용자 확정을 거쳐 `base/` +반영 완료.** 2라운드가 발견·해결한 `RC-1`(Blocker 게이팅) 반영분을 +대상으로, 이번엔 `attachSlot`이 그 게이팅을 실제로 어떻게 쓰는지와 +`recompute`가 의존하는 `bk.N`의 수명주기를 손으로 실행해봤다. 부수로 +`ROADMAP.md` 마일스톤 분할이 이번 라운드 발견과 맞는지도 검토했다 +(1라운드가 미룬 항목). + +**⚠️ 이 문서를 읽을 때 주의 — 아래 문제 서술 중 일부는 최초 작성 당시의 +분석 오류를 포함한 채 그대로 남아 있다(의도적으로 안 고침, 논의 과정 +보존).** 특히 `bk.N` 절의 "(a)/(b) 두 갈래 다 깨진다"는 최초 분석은 +**틀렸다** — 사용자가 직접 지적해 정정됐다("해결 — `bk.N`" 절 참고). +지금 유효한 결론은 각 절의 "해결" 소제목 아래만 — 그 위 문제 서술은 +"당시엔 이렇게 봤다"는 트레이싱 원문으로만 읽을 것. + +**이 문서의 용도**: 2라운드와 같은 톤 — 트레이싱 결과 자체가 산출물이고, +버그는 그 자리에서 기록, 방향이 갈리는 것만 사용자에게 물었다. + +--- + +## RC-3 — `activateList`가 자기 Slot의 Blocker보다 먼저 실행됨 + +**대상**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` 의사코드. + +**순서를 그대로 읽으면**: +```lua +slot._mounted = true -- (1) 이 시점부터 이미 "마운트됨" +... +if slot._listed then + activateList(slot, physicalTarget) -- (2) reconcile 실행 — 아직 Blocker 없음 +end +Dispatch.setLength(ownerKey, position, slot.Length) -- (3) +local blocker = getBlocker(slot) -- (4) Blocker가 여기서야 생성됨 +blocker:On() +for i, element in ipairs(slot._elements) do ... end -- (5) +blocker:OffWithoutEmit() +``` + +**문제**: (1)에서 `slot._mounted = true`가 이미 세팅된 채로 (2)의 +`activateList`가 실행된다. `activateList`의 `reconcile`은 새 항목마다 +`rawAdd(self, result, pos)`를 부르는데(`slot-plan.md`의 `:List` "구현" +절), "이미 마운트된 outer에 나중에 `Add`" 절이 명시하듯 `self._mounted`가 +참이면 `rawAdd`는 그 자리에서 즉시 물리 마운트 경로를 탄다 — +`isSlot(element)`면 `attachSlot(element, ...)` 재귀, 아니면(대칭적으로) +`Dispatch.setOffsetSource(self,index,None)` + `Dispatch.setLength(self,index,1)` ++ `element.Parent = physicalTarget`. `Dispatch.setLength`는 끝에서 +`gatedRecompute`를 부르고, `gatedRecompute`는 `getBlocker(ownerKey=self)`의 +`IsOn()`을 확인하는데 — **(4)의 `getBlocker(slot):On()`이 아직 실행되기 +전이므로, 새로 생성된 Blocker는 기본 off 상태고 게이트가 그냥 통과된다.** + +즉 `:List`의 초기 reconcile이 채우는 **모든 항목마다** `recompute`가 +그 자리에서 즉시(게이팅 없이) 돈다 — 이건 정확히 `RC-1`이 막으려던 +"배치 등록 중 매 position마다 recompute가 도는" 모양이다. 사용자가 +`RC-1` 논의에서 직접 지적한 문제("이러면 첫 실행에서 계속 recompute +비용이 쌓임")가 `:List`의 초기 population 경로에서는 그 처방이 적용되기 +**전** 자리에서 그대로 재현된다. + +**크래시로 이어지는지는 `bk.N`에 달려 있다** — 아래 "`bk.N` 수명주기" +절 참고. `bk.N`이 이 시점에 아직 `0`(또는 `nil`)이면 크래시는 안 나고 +그냥 매 항목마다 무의미한 `recompute(self,bk)` 호출만 쌓인다(루프가 +`for i=1,0`이라 즉시 반환). `bk.N`이 최종 개수로 미리 정해져 있는 +모델이면 `RC-1`과 완전히 같은 모양(뒤쪽 `lengthList[i]`가 아직 `nil`)의 +크래시가 난다. + +--- + +## RC-4 — flush 루프가 `:List`로 이미 마운트된 요소를 다시 처리함 + +**같은 `attachSlot`에서 이어지는 문제**: (5)의 flush 루프 +(`for i, element in ipairs(slot._elements) do ... end`)는 `slot._listed` +여부를 확인하지 않고 **항상** 돈다. 그런데 `_listed`/`_crudUsed`는 상호 +배타(`slot-plan.md`의 "CRUD API 확정" 절 — `_crudUsed`/`_listed` 역방향 +가드)이므로, `:List`가 설치된 Slot의 +`_elements`는 수동 `:Add()`가 아니라 **오직 (2)의 `activateList`가 +채운 것뿐**이다 — 그리고 위 `RC-3`에서 확인했듯 그 채움 과정 자체가 +이미 각 항목을 물리적으로 마운트(`element.Parent = physicalTarget`)하고 +`Dispatch.setOffsetSource`/`setLength`를 등록까지 마친 상태다. + +flush 루프는 이 사실을 모르고 **같은 요소들을 처음 보는 것처럼** 다시 +처리한다 — `Dispatch.setOffsetSource(slot, i, None)`/ +`Dispatch.setLength(slot, i, 1)`을 중복 호출하고, 이미 부모에 붙어있는 +`element`에 `element.Parent = physicalTarget`를 다시 대입한다(Roblox라면 +`AncestryChanged`가 불필요하게 한 번 더 발화). 값 자체는(멱등하게) 결국 +맞게 수렴하겠지만, `:List`가 nested Slot을 요소로 반환한 경우 +(`isSlot(element)`)는 **`attachSlot(element, physicalTarget, slot, i)`가 +통째로 두 번 실행**된다 — 이건 멱등하지 않다: `slot._mounted = true`를 +다시 세팅하는 정도는 무해해 보여도, "마운트된 Slot의 재마운트는 즉시 +throw" 규칙(`slot-plan.md` "마운트된 Slot의 재마운트는 즉시 throw" 절)에 +비춰보면 **nested Slot이 자기 자신을 향해 재귀적으로 이미 마운트된 +채로 다시 `attachSlot`되는 것 자체가 그 규칙이 막으려는 상황과 같은 +모양**이라, 최소한 이 규칙과의 정합성을 다시 검토해야 한다. + +**추정 원인**: flush 루프의 주석("attach 전에 이미 들어와있던 요소들 +flush")이 밝히듯, 이 루프는 **수동 CRUD로 마운트 전에 `:Add()`된 +요소**만 염두에 두고 `RC-1` 해결 과정에서 추가된 것 — `:List` 케이스가 +같은 함수를 통과한다는 걸 놓친 것으로 보인다(`RC-1` 자체는 +`Dispatch.drive`/`attachSlot`의 flush 두 자리만 위험하다고 확인했고, +`activateList`는 그 확인 대상에 없었다). + +**참고**: 이 두 결함(`RC-3`/`RC-4`)은 정확한 크래시/오작동 심각도가 +`bk.N`의 수명주기에 좌우되므로, 아래 질문의 답이 나온 뒤 같은 자리에서 +같이 고치는 게 맞아 보인다(둘 다 "`_listed`면 flush 루프를 건너뛰고, +`activateList` 자체를 `blocker:On()`/`OffWithoutEmit()`으로 감싼다"는 +같은 방향의 수정으로 닫힐 가능성이 높음 — 다만 이건 제안이지 확정 +아님, 사용자 확인 필요). + +### 해결 — `_mounted`를 `activateList` 뒤로 미룸 (2026-08-18, 같은 세션 후속, 사용자 설계) + +위에서 제안했던 "`_listed`면 flush 루프를 건너뛴다"는 **채택 안 됨** — +사용자가 더 단순한 대안을 직접 제시했다: + +> if not slot._listed then ... end 로 감싸면 안 되는거 아닌가요? 그냥 +> _mounted 를 activateList 아래 두는게 안되는 이유가 있어요? 만일, +> 그렇게 감싼다면 그건 blocker 를 안 타니까요. 그리고 또, attachSlot 은 +> 런타임 상 발생할 수 있는게 맞긴 하죠? 왜냐면, 안 그러면 List 에서 +> Frame 만 던질 수 있어요. nested slot 을 던지는 컴포넌트는 사용 +> 못하게 될텐데요. + +**`slot._mounted = true`/`slot._mountedInst = physicalTarget`를 +`attachSlot` 맨 위에서 `activateList` 호출 **뒤**(flush 루프 바로 전)로 +옮기면 `RC-3`/`RC-4`가 한 번에 닫힌다**: + +- `activateList`가 실행되는 동안 `self._mounted`가 계속 `false`이므로, + `:List`의 reconcile이 부르는 `rawAdd`는 "아직 마운트 전" 경로 + (`_elements`에만 넣고 물리 마운트/Dispatch 등록은 안 함, + `slot-plan.md`가 이미 "self가 아직 마운트 전이면 _elements에만 + 들어가고, self가 나중에 attachSlot될 때 위 flush 루프가 처리"로 + 명시해둔 바로 그 경로)를 탄다. → `RC-3`(항목마다 무게이팅 + `recompute`) 자체가 안 생김 — flush 루프 전엔 어떤 Dispatch 등록도 + 없으므로. +- flush 루프가 `slot._elements`(이제 `:List`든 수동 CRUD든 항상 여기에만 + 쌓여 있음)를 순회하며 **처음이자 유일하게** 각 요소를 물리 + 마운트한다 — nested Slot이면 `attachSlot`도 여기서 **딱 한 번만** + 불린다. → `RC-4`(이중 실행) 자체가 안 생김. `_listed` 분기가 필요 + 없어짐 — flush 루프가 두 경로(`:List`/수동 CRUD) 모두에 대해 이미 + 동일하게 옳은 유일한 마운트 지점이 됨. +- **`rawAdd`의 `self._mounted` 즉시-마운트 분기 자체는 그대로 남는다** — + 사용자가 확인한 대로 이건 삭제 대상이 아니라 **런타임에 실제로 필요한 + 경로**다: `attachSlot`으로 최초 마운트가 끝난 **뒤**(예: `data`가 + 나중에 바뀌어 `:List`의 reconcile이 다시 실행될 때) `self._mounted`는 + 이미 `true`이므로, 그 시점에 새로 추가되는 nested Slot 항목은 이 + 분기를 통해 정상적으로 즉시 `attachSlot`된다 — 그래서 `:List`가 + nested Slot을 반환하는 컴포넌트를 계속 지원한다. 이번에 바뀐 건 오직 + "`attachSlot` 자기 자신의 **최초** flush 이전엔 이 분기가 안 타야 + 한다"는 타이밍 하나뿐. + +**반영**: `base/slot-plan.md` "재귀 메커니즘" 절의 `attachSlot` +의사코드(`_mounted` 위치 이동 + 주석), `base/dispatch-core-plan.md`의 +"저장 위치"/"배치 등록을 안전하게 만드는 Blocker 게이팅" 절, `base/ +blocker-plan.md`의 "두 번째 용례" 절(아래 `bk.N` 해결과 같이 반영). + +--- + +## `bk.N`의 수명주기가 명세에 없음 — 판단 필요 + +`recompute`(`base/dispatch-core-plan.md` "Length/Offset" 절)는 +`for i = 1, bk.N do`로 순회한다. `bk.N`의 정의는 문서에 **딱 한 곳**뿐: + +> **저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +> `inst`의 array part 크기 `N`으로 같이 저장, `Dispatch.drive`가 최초 +> 배열 파트 순회 시점에 이미 알고 있는 값) — `Relate(parentInst)`에 +> lazy 생성. + +이건 **`Dispatch.drive`가 순회하는 최상위 `inst`** 전용 서술이다 — +그 경우 `N`은 저작 시점에 고정된 배열 리터럴 길이라 정말로 "한 번 알면 +끝"이다. 그런데 `base/slot-plan.md`의 "재귀 메커니즘" 절이 **같은 +`recompute`/`getBookkeeping`을 Slot 자신을 ownerKey로 재사용**하면서 +(`Dispatch.setLength`/`setOffsetSource`가 "owner 키(`inst`)가 물리 +Instance일 필요가 없음"을 근거로), Slot의 경우 `bk.N`이 무엇이고 언제 +갱신되는지는 **어디에도 안 적혀 있다**. `getBookkeeping`/ +`spliceArraysDown` 자체도 이 코퍼스 전체에서 정의된 적이 없는(호출만 +되는) 헬퍼다(grep 확인, `bk.N =` 대입 자체가 코퍼스에 0건). + +**왜 이게 그냥 구현 디테일이 아니라 지금 결정이 필요한가**: `Dispatch.drive`의 +`inst`와 달리, **Slot의 자식 개수는 Slot 전체 생애주기 동안 계속 +바뀐다**(그게 Slot의 존재 이유) — "한 번 알면 끝"이라는 `inst` 쪽 전제가 +Slot에는 애초에 성립하지 않는다. 두 갈래 다 손으로 트레이싱해보면 각각 +다른 방식으로 깨진다: + +**(a) `bk.N`이 "고정값"이라면(배치 시작 시 저장, 이후 안 바뀜)**: +`rawRemove`(`slot-plan.md` "파괴" 절)를 트레이싱하면 — +```lua +function rawRemove(self, index) + ... + spliceArraysDown(self, index) -- _elements/lengthList/sourceList 한 칸씩 당김 + recompute(self, bk) -- bk.N은 그대로(감소 안 함) +end +``` +`spliceArraysDown`이 배열을 한 칸씩 당기고(마지막 자리는 비거나 stale +복제값으로 남음, 정의가 없어 어느 쪽인지도 불명) `bk.N` 자체를 줄이지 +않으므로, **2개짜리 Slot에서 요소 하나를 `Remove`하기만 해도** 다음 +`recompute`의 `for i=1,bk.N(=2)`가 이제 존재하지 않는 위치 2를 읽는다 +— `spliceArraysDown`이 그 자리를 `nil`로 비운다면 `RC-1`과 정확히 같은 +`sum += nil` 산술 에러, 옛 값을 그대로 둔 복제라면 그 값을 이중으로 +합산하는 조용한 오계산이다. 어느 쪽이든 **가장 흔한 조작(2개 이상인 +Slot에서 하나 제거)에서 매번 재현**된다 — `RC-1`이 "정적 자식 2개짜리 +`Frame`에서도 재현"이라고 짚었던 것과 같은 급의 흔함. + +**(b) `bk.N`이 "그때그때 실제 개수"라면(예: `#ownerKey._elements`로 매번 +파생, 또는 매 `setLength`/`spliceArraysDown` 호출마다 갱신)**: 위 +`rawRemove` 크래시는 없어진다. 대신 마운트 시점 배치(`Dispatch.drive` +최상위, `attachSlot`의 flush)에서 `RC-1`이 막으려던 **바로 그 크래시가 +되돌아온다** — 배치 도중 `bk.N`이 이미 등록된 position 개수만큼만 +증가한 상태라면 recompute 자체는 안전해지지만(순회 범위가 실제 채워진 +자리까지만), 반대로 **Blocker 게이팅이 애초에 막으려던 "배치 끝나기 +전엔 recompute 안 돈다"는 전제가 필요 없어진다는 뜻**이라 — `RC-1`의 +해법 전체가 어떤 `bk.N` 모델을 전제하는지부터 다시 맞춰야 한다. +(자세히 보면 게이팅으로 `recompute` 호출 자체를 스킵하므로 (b) 모델이어도 +크래시는 안 나지만, **배치 종료 후 딱 1회 도는 마지막 `recompute`가 이번엔 +반대로 부족한 `N`을 볼 수 있다** — 예: `attachSlot` flush 루프 중간에 +어떤 position의 `setLength`가 **State**를 받아 `Observer`의 "등록 즉시 +1회 실행"이 배치 밖 시점까지 늦게 도착하는 경합이 있다면.) + +**분기점 — 사용자 판단 필요(아래 질문 참고)**: `bk.N`을 그때그때 실제 +개수로 둘지, 배치 시작 시 저장해두는 값으로 둘지에 따라 고칠 자리가 +갈린다 — 전자면 `rawRemove`/`rawUnmount`/런타임 `rawAdd`가 문제, +후자면 `RC-3`/`RC-4`가 이미 지적한 자리가 문제. 어느 쪽이든 `RC-3`/ +`RC-4`는 별도로 고쳐야 하지만, `bk.N` 자체의 수명주기 규칙은 이 +문서가 결정하지 않는다 — 아래에서 직접 여쭤본다. + +### 해결 — `bk.N` = 그때그때 실제 개수, 위 (b) 분석은 틀렸음 (2026-08-18, 같은 세션 후속, 사용자 지적) + +위 (b) 갈래("`bk.N`이 그때그때 실제 개수면 마운트 배치에서 `RC-1`이 +막으려던 크래시가 되돌아온다")는 **분석 오류였다.** 사용자가 직접 +잡아냄: + +> 그때그때 실제 개수를 전부 적용하는건 안 돼? 사실 전부 똑같은 +> 방법으로 구현해도 상관 없지 않아? 그리고 drive 중에는 recompute +> 안나지 않아? 계속 후행 붙이기라서 약간 다를텐 + +**틀렸던 지점**: `Dispatch.drive`/`attachSlot`의 배치 등록 중 +`recompute`가 안 도는 이유는 **`bk.N`이 아니라 Blocker 게이팅** +(`blocker:IsOn()`만 확인하는 `gatedRecompute`)이다 — 이 게이트는 +`bk.N`이 무엇이든 **전혀 상관하지 않는다**. 그러므로 `bk.N`이 배치 +도중 계속 늘어나는 중이어도(아직 최종 크기가 아니어도) 배치 안에서 +`recompute` 자체가 안 도니 크래시도, 부정확한 계산도 안 생긴다 — +필자가 (b)를 쓰며 "게이팅이 배치 끝나기 전엔 recompute 안 돈다는 +전제가 필요 없어진다"고 적었던 건 스스로 반대 결론(게이팅이 여전히 +작동 중이라는 사실)을 옆에 적어두고도 놓친 것. + +**결론**: `bk.N` = **그때그때 실제 개수**로 두 owner 타입(`inst`, +Slot 자신) 모두에 동일한 규칙 적용 — `Dispatch.setLength`/ +`setOffsetSource`가 이전에 없던 더 큰 position을 등록할 때마다 +`bk.N`이 그 값으로 늘어나고, `spliceArraysDown`(Slot의 `rawRemove`/ +`rawUnmount`)이 위치를 구조적으로 지울 때 그만큼 줄어든다. `Dispatch.drive`의 +`inst`에서는 최상위 배열이 구조적으로 안 바뀌므로 이 규칙이 그냥 +"등록 끝나면 고정값처럼 보이는" 특수한 안정 상태가 될 뿐, 별도 모델이 +필요 없다 — **두 owner 타입에 정말로 똑같은 구현**(사용자가 지적한 +그대로). + +**`RC-1`의 원래 크래시가 실제로 뭐였는지 다시 정리하면**: `bk.N`이 +"배치가 시작되기도 전에 이미 최종 크기로 고정"돼 있었던 것의 부산물 +— 그 전제 자체가 이번에 사라졌다. Blocker 게이팅이 지금도 필요한 +이유는 크래시 방지가 아니라 **비용**이다: 게이팅 없이 매 position +등록마다 `recompute`가 한 번씩 돌면 배치당 O(N²), 게이팅으로 배치 끝에 +한 번만 돌면 O(N) — `RC-1` 최초 논의에서 사용자가 직접 지적한 "이러면 +첫 실행에서 계속 recompute 비용이 쌓임" 문제 그대로. + +**추가로 확인된 갭 — `spliceArraysDown`이 미는 배열 목록에 `bk.observers`가 +빠져 있었음.** `rawRemove`/`rawUnmount`가 제거되는 위치의 +`bk.observers[index]`를 `unbindLifetime`하긴 하지만, `spliceArraysDown` +자신이 밀어야 할 배열로 지금까지 `_elements`/`lengthList`/`sourceList` +셋만 서술돼 있었다 — `observers`도 같이 밀지 않으면 이후 그 위치의 +observer가 옛 이웃 것을 계속 가리키게 된다. `base/slot-plan.md`에 +반영. + +**반영**: `base/dispatch-core-plan.md`의 "저장 위치" 절(`bk.N` +수명주기 정의 신설), "배치 등록을 안전하게 만드는 Blocker 게이팅" +절의 "문제 재확인" 문단(크래시 전제가 바뀌었다는 정정 추가), +`base/slot-plan.md`의 `spliceArraysDown`/`rawRemove`/`rawUnmount` +근처(`bk.observers`/`bk.N` 갱신 명문화), `base/blocker-plan.md`의 +"두 번째 용례" 절(같은 정정), `ROADMAP.md` M2 체크박스(같은 정정 + +M2/M3 교차 의존 각주). + +--- + +## 확인만 하고 새 결함 없음 — 재검증 + +- **중첩 Slot의 `Length:Set`이 부모 Blocker가 켜져 있는 동안 나가는 + 경우** — `attachSlot`이 재귀로 `attachSlot(element, physicalTarget, + slot, i)`를 부를 때, 안쪽 재귀도 자기 자신의 `getBlocker(element)`로 + 별도 Blocker를 새로 만들어(부모 Blocker와 무관) 자기 flush를 감싼다. + 안쪽 재귀 끝의 `recompute(element, bk)`가 `element.Length:Set(sum)`을 + 호출하면, 이건 **부모 쪽 관점에서 보면 `Dispatch.setLength(parentSlot, + i, element.Length)`가 이미 등록해둔 그 State 객체의 값이 바뀌는 것** — + 부모의 `gatedRecompute`가 이 변화를 받지만, 부모의 Blocker가 아직 + 켜져 있으면(외곽 배치가 안 끝났으면) 정상적으로 스킵되고, 부모 배치가 + 끝난 뒤 마지막 `recompute(parentSlot, parentBk)` 한 번에 자연스럽게 + 반영된다 — 설계 의도대로 동작, 새 문제 없음. +- **`getBlocker`의 lazy 생성 기본값이 off라는 전제가 런타임 단건 경로를 + 성립시킴** — "이미 마운트가 된 이후"의 단건 `:Add()`가 게이팅 없이 + 바로 `gatedRecompute`를 태우는 게 안전한 이유는, 그 시점 Blocker가 + (flush 때 만들어져 `OffWithoutEmit()`으로 꺼진 채 남아있으므로) 항상 + off 상태이기 때문 — 트레이싱으로 재확인, 새 발견 아님. +- **⚠️ [신설, 반영 후 자체 재검토] 재정렬로 새로 생긴 좁은 엣지 케이스 — + 배치 밖(steady state)에서 Slot이 단독으로 (재)마운트될 때, 부모의 + `recompute`가 아직 안 굳은 `slot.Length` 값으로 한 번 먼저 돌 수 + 있음.** `Dispatch.setLength(ownerKey, position, slot.Length)`(위 해결 + 절의 재정렬 뒤 코드)은 `slot.Length`가 `State`라 `Observer` "등록 즉시 + 1회 실행"을 그 자리에서 동기로 태운다 — 이게 부모의 `gatedRecompute`를 + 부르는데, **이 Slot 마운트가 `Dispatch.drive`의 배치나 부모 Slot의 + flush 루프 **안**이면** 부모의 Blocker가 아직 켜져 있어 안전하게 + 스킵되지만(트레이싱 확인, 새 결함 아님), **배치 밖에서 이 + `attachSlot`이 단독으로 불리는 경우**(예: `state` 값이 steady + state에서 반응형으로 교체돼 재-dispatch되는 경우, 부모 owner의 + Blocker는 이미 예전에 `OffWithoutEmit()`으로 꺼진 채)엔 부모의 + `gatedRecompute`가 즉시 실행돼, 아직 flush가 안 끝나 최종값이 아닌 + `slot.Length`로 부모가 한 번 (헛되이) 재계산한다 — 뒤이어 flush가 + 끝나고 `slot.Length:Set(최종값)`이 다시 발화하면 부모가 다시 정확하게 + 재계산해 값 자체는 스스로 바로잡힌다. **크래시도 영구적으로 틀린 + 값도 아니고**, `Get()~=sum` 가드 때문에 실제로 `:Set`이 두 번 나가는 + 것도 조건부(첫 번째 계산이 우연히 맞을 수도 있음)라 — Roblox 기준 + 최악의 경우 한 프레임짜리 낭비 재계산 정도. 재정렬 이전 코드(`_mounted`가 + `activateList`보다 먼저)에는 이 경로 자체가 없었음(`slot.Length`가 + 이미 등록 시점에 확정돼 있었으므로) — 그래서 완전히 새로 생긴 특성. + **크래시급이 아니라 이 라운드를 다시 열진 않지만, 다음에 이 자리를 + 만지는 세션이 알아야 할 사실로 기록.** +- **`Dispatch.drive` 자신은 코드 블록이 없다** — 이 문서 전체에서 + `Dispatch.drive`는 항상 산문으로만 서술되고(`Dispatch.drive(inst, + flattened)`가 배열→해시 두 패스로 `Dispatch.process`를 부른다는 것), + Blocker 게이팅을 그 함수 **자신**이 어떻게 여닫는지 보여주는 의사코드는 + 없다(`attachSlot`만 실제 코드로 있음). 버그는 아님 — `Dispatch.drive` + 자체가 이 코퍼스 어디에도 전체 코드로 나온 적이 없어서(항상 서술뿐), + 이번에 새로 생긴 갭이 아니라 원래부터 그랬던 문서화 수준의 차이일 + 뿐이다. 실제 구현 시(M2) `attachSlot`과 같은 패턴(자기 owner=inst의 + Blocker를 `:On()` → 배열 파트 순회 → `:OffWithoutEmit()` → + `recompute` 1회)으로 쓰면 될 걸로 보이나, 코드로 명문화돼 있지 않다는 + 점만 기록. + +--- + +## `ROADMAP.md` 마일스톤 정합성 — 새 불일치 발견 + +1라운드가 "다음에 검토"로 미뤄뒀던 항목. 이번 라운드는 `RC-1`의 Blocker +게이팅 해법이 실제로 마일스톤 순서와 맞물리는지를 봤다. + +**문제 — M2가 M3의 산출물(`Blocker`)에 구조적으로 의존하게 됐다.** +`ROADMAP.md` M2(디스패치 엔진)의 `Dispatch.setLength`/`setOffsetSource` +체크박스(90번대 줄)는 이렇게 적혀 있다: + +> **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음 +> 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로 +> 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 `recompute`를 +> 미루고 배치가 끝나면 명시적으로 한 번만 돎 + +즉 M2 체크박스 자체가 "`setLength`/`setOffsetSource`를 구현하려면 +`Blocker`가 있어야 한다"고 명시한다. 그런데 `Blocker.luau`는 M3 +체크박스(`## M3 — Store/State/Source` 절)에 있고, 그 근거는: + +> `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를 한꺼번에 +> 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive, State와 +> 밀접히 연관돼 있어 같은 마일스톤에서 개발) + +이 근거("State와 밀접히 연관돼 있어서")는 `RC-1` 이전의 오래된 이유 +그대로다(`base/blocker-plan.md` 자신도 "store 개발(M3)과 밀접하게 +연관됨... 별도 파일로 두되 State와 같은 마일스톤에서 함께 구현할 것"이라고 +써 있음, `.claude/todos.md` 4번의 "M3에서 `Blocker`를 구현할 때"도 동일). +`RC-1`로 생긴 **M2 → Blocker** 의존은 그 뒤에 어디에도 반영이 안 됐다 — +M2가 M3보다 먼저 오는 로드맵 순서상, **M2를 그대로 순서대로 구현하면 +아직 존재하지 않는 `Blocker.luau`를 참조하게 된다.** + +이건 2라운드가 확인한 "`RC-1` 언급이 텍스트로는 반영됐는가"(반영됨, +확인 완료)와는 다른 질문 — **텍스트는 맞는데 그 텍스트가 만드는 +마일스톤 간 순서 요구가 로드맵 구조와 어긋난다.** + +**참고로 M6(Slot)의 두 자리(368번대 줄 근처)는 이미 "위 M2 항목 참고"로 +정확히 교차 참조돼 있어 문제 없음** — M6는 M3보다 뒤라 Blocker가 이미 +존재한다는 전제가 깨지지 않는다. 문제는 오직 M2 하나. + +**선택지는 여기서 결정하지 않는다** — 가능한 방향만 짚어둔다(사용자 +판단 필요): +1. `Blocker.luau`(또는 그 최소 부분집합 — `On`/`Off`/`IsOn`/ + `OffWithoutEmit`만)를 M2로 옮기거나 M2 시작 부분에 선행 항목으로 추가. +2. M2 체크박스에 "M3의 `Blocker.luau`를 먼저(또는 병행) 구현해야 함"이라는 + 명시적 순서 각주를 달아, 로드맵 순서 자체는 유지하되 M2 착수 시 + 이 사실을 놓치지 않게 한다. +3. M2/M3 마일스톤 경계를 재검토(예: Blocker를 M2로 통째로 승격) — + 가장 큰 변경이라 신중히. + +**임시로 2번(각주) 채택** — 마일스톤 경계 자체를 바꾸는 1/3번은 +설계·일정에 영향이 가는 결정이라 사용자 확인 없이 고르지 않았다. 2번은 +로드맵 구조를 안 바꾸면서 "M2가 M3의 산출물에 기대고 있다"는 사실만 +빠짐없이 남기는 가장 보수적인 조치라 우선 적용해뒀음(`ROADMAP.md` M2 +체크박스) — 1/3번을 원하면 언제든 다시 정리 가능, 아직 최종 확정 +아님. + +--- + +## 진행 로그 + +**3라운드(2026-08-18) — `attachSlot`/`recompute`/Blocker 게이팅 손 +트레이싱, `ROADMAP.md` 마일스톤 정합성 재검토, 같은 세션에 전부 해결· +`base/` 반영까지 완료.** 발견 순서대로: + +1. `RC-3`(`activateList`가 자기 Slot의 Blocker보다 먼저 실행돼 항목마다 + 무게이팅 `recompute`가 도는 것으로 보였음)와 `RC-4`(flush 루프가 + `:List`로 이미 마운트된 요소를 중복 처리 — nested Slot이면 이중 + `attachSlot`)를 발견. +2. `bk.N`(순회 상한)의 수명주기가 문서 어디에도 없다는 것도 발견, 최초 + 분석은 "고정값/그때그때 실제 개수 두 갈래 다 각기 다른 방식으로 + 깨진다"고 판단해 사용자에게 물음. +3. **사용자가 그 분석 자체를 정정** — Blocker 게이팅은 `bk.N`이 아니라 + `blocker:IsOn()`만 보므로, "그때그때 실제 개수" 모델이 배치 중 + 크래시를 되돌린다는 결론은 틀렸음을 지적("그때그때 실제 개수를 전부 + 적용하는건 안 돼? ... 그리고 drive 중에는 recompute 안나지 않아?"). + `bk.N` = 그때그때 실제 개수로 두 owner 타입에 동일 적용 확정. +4. `RC-3`/`RC-4`도 사용자가 더 단순한 해법을 직접 제시 — flush 루프를 + `_listed`로 분기하는 대신, `attachSlot`의 `slot._mounted = true`를 + `activateList` 호출 **뒤**로 옮기는 것 하나로 둘 다 닫힘("_mounted + 를 activateList 아래 두는게 안되는 이유가 있어요?"). +5. 부수로 `spliceArraysDown`이 밀어야 할 배열 목록에 `bk.observers`가 + 빠져 있던 것도 같이 발견·반영. +6. `ROADMAP.md` M2가 M3의 `Blocker.luau`에 구조적으로 의존하게 된 + 불일치는 각주로 반영(가장 보수적인 조치, 마일스톤 재편 여부는 열림). + +**반영 완료**: `base/slot-plan.md`(`attachSlot` 의사코드 재작성, +`spliceArraysDown`/`bk.N`/`bk.observers` 명문화), `base/ +dispatch-core-plan.md`(`bk.N` 수명주기 신설, 크래시 전제 정정), +`base/blocker-plan.md`(게이팅 존재 이유 정정), `ROADMAP.md`(M2 체크박스 +정정 + M2/M3 교차 의존 각주). `python3 .claude/tools/doc-check.py`로 +ERROR 0 확인 완료. diff --git a/.claude/question.md b/.claude/question.md index 84255cc..5450334 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -40,18 +40,19 @@ `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절, `archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.) -- **`DI`(Declarative Instance, 1순위)**: "Dependency Injection"의 업계 - 표준 축약어와 완전히 겹침 — 4차 라운드에서 이미 한 번 실제로 오해가 - 있었던 전례(`base/bind-system-plan.md`의 "인스턴스 생성" 절 참고). - **파급 효과(2026-08-06 추가)**: `DI`가 리네임되면 `DI.FrameModifier`류 - Modifier 클래스별 타입 프리픽스도 같이 바뀌어야 함 — `DI` 리네임 논의 - 때 이 연쇄까지 같이 고려할 것. **(2026-08-08 추가)** 사용자가 `D`(Declarative - 만 남김)로 축약하는 안을 제안 — 근거: (1) "Instance" 전용 개념이 아니라 - quad-* 전반의 declare 요소로 확장해도 되는 이름, (2) 엔진 종속 없이 다른 - 백엔드에서도 재사용 가능, (3) 어차피 `D.FrameModifier`류 타입 프리픽스가 - 길면 못 쓰므로 짧아야 한다는 실용적 제약. 아직 최종 확정 아님 — 다음 - 세션에서 마저 논의(한 글자 식별자의 검색성/자기설명력 트레이드오프를 - 문서에서 어떻게 보완할지도 같이). +- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는 + `archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과 + 완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare + 요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게 + 유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는 + "문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로 + 보완하기로 같이 확정. 코퍼스 반영 완료 — + `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절. +- **`PopOnly`(가칭, 2026-08-18 신설)**: `:List` reconcile에서 "파괴하지 말고 + 자리만 비우라"를 지시하는 반환 센티널(`base/slot-plan.md`의 "`nil` 리턴은 + 파괴가 기본" 절). 메커니즘은 확정됐고 **이름만 열려 있음** — 사용자: + *"PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 + 생각해보아야함"*. - **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가 다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이 헷갈릴 수 있음. @@ -113,6 +114,17 @@ ## 3. 낮은 우선순위 — 열려 있지만 급하지 않음 +- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`에 + 구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지, + `Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로 + 앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법 + 때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가 + `getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작 + `Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직 + 없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔 + 임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전 + 필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의 + "ROADMAP.md 마일스톤 정합성" 절. - **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설, 같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level @@ -159,6 +171,46 @@ 남은 근거는 편의성과 Slot offset이 밀리고 당겨지는 케이스뿐이라 우선순위가 더 내려감 — `state:Flatten()`류 콤비네이터 아이디어는 그대로 백로그. 상세는 `research/operator-sugar-plan.md` 마지막 절. +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `store:GetDynamic`을 + 콜론 메소드로 둘지, 탑레벨 함수로 둘지** — 콜론 메소드로 두면 Store의 + lazy `__index`(없는 키를 인덱싱하면 그 자리에서 `Source`를 만들어 저장)와 + 부딪혀서, `__index`가 고정 메소드 테이블을 먼저 확인해야 하고 그 결과 + **`GetDynamic`이 모든 Store의 예약 키 이름**이 된다(그 이름의 Source는 + dot-access로 못 만듦). Store 키는 사용자 도메인 데이터 이름이라 충돌 + 확률이 `Modifier`의 예약 이름들보다 높다. 대안은 탑레벨 + `getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자 + 탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전 + 필요**, `base/store-plan.md`의 "타입 추론 문제" 절. +- **[신설, 2026-08-18 커밋 전 `/code-review high`] `PopOnly`로 홀드 중이던 + 요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면 + `mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가 + `userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고 + 참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은 + `updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의 + `old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를 + 고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정, + 2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전 + 필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가 + 기본" 절. +- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** — + 같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별 + claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼 + `bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야 + 하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey` + 단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 — + `base/attribute-plan.md`의 "이름 소유권" 절. +- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** — + `Get()` → `Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게 + 불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))` + (단 이때 `Apply`는 `State`가 아니라 `Source`를 넘겨야 함)와 + `source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번 + 범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것 + (`state:Apply` 시그니처에 영향), `base/slot-plan.md`의 `dispose` 절. +- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State → + State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 + 없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가 + 지목했고, **명문화 여부 결정 + `luau-test` 실측이 M3 착수 전에 필요** — + `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절. - **[신설, 2026-08-14 리뷰] `AttributeGroupHandler.process`의 부분 실패 롤백** — 이름 순회 도중 소유권 충돌 error가 나면 그 전에 등록된 이름들이 이 사이클엔 회수되지 않음(클로저가 안 만들어짐). 피해는 그 인스턴스 @@ -166,12 +218,10 @@ 문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적 롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정 불필요 — M10 구현 시점에 판단. -- **[신설, 2026-08-13 열네 번째 세션] `Attribute.Merged`의 이름 중복** — - 두 Store가 같은 이름을 가지면 지금은 `:NameMap()` 평탄화 단계에서 - 조용히 하나가 이김(dispatch 이전이라 이름 claim이 못 잡는 자리). - 합성 시점 1회 체크로 error를 내는 게 이 문서 다른 결정들과 결이 - 같지만, "Merged는 뒤가 이긴다"를 의도된 override로 볼 여지도 있어 - 사용자 확인 필요 — `base/attribute-plan.md` "열린 질문" 절. +- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면 + error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정 + (제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인 + array-part 값 객체" 절. - **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고. 채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를 넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은 @@ -180,7 +230,7 @@ 채택 안 함으로 확정, `base/event-plan.md` "이벤트 핸들러는 self(Instance)를 받지 않는다" 절 참고). 사용자가 "quad 개발 완료 전엔 착수 못 함"으로 직접 후순위 지정한 건 여전함 — base 설계(M2 Dispatch/ - M3 Source/M5 DI 생성자) 시점에 훅 확장 지점만 고려해두면 됨. + M3 Source/M5 `D` 생성자) 시점에 훅 확장 지점만 고려해두면 됨. - **문서화 전략(UI 네이밍 컨벤션, Store 부작용을 게임 시스템에서 쓰는 패턴)** — `research/documentation-plan.md`(뼈대만). 정식 백로그 항목으로 올릴지, 착수 시점을 언제로 볼지 사용자 판단 필요. diff --git a/.claude/research/additional-primitives-plan.md b/.claude/research/additional-primitives-plan.md index 3a92eaa..5efb586 100644 --- a/.claude/research/additional-primitives-plan.md +++ b/.claude/research/additional-primitives-plan.md @@ -18,7 +18,7 @@ context-rejected.md`. **[2026-08-09 세 번째 세션]** 마지막으로 남아 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 같나요. 이것만으로 이 프로젝트는 충분하다 생각해요?" — 지금까지 확정된 -독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`DI`)+파생 데이터 +독립 프리미티브(`Source`/`Store`/`Ref`/`Modifier`/`Slot`/`D`)+파생 데이터 (`State`/`Observer`)만으로 충분한지, 웹 프레임워크나 실제 Roblox 개발 관점에서 솔직하게 재검토해달라는 요청. diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index 3e05cd0..24f07da 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -2,7 +2,7 @@ **상태**: research — 착수 전, 사용자와 계속 논의 필요. 사용자가 "quad 개발이 어느 정도 끝날 때까지는 실제 구현에 못 들어갈 것"이라고 스스로 판단한 후순위 -항목이지만, **base 설계(디스패치 엔진/Source/DI 생성자) 시점에 훅 확장 +항목이지만, **base 설계(디스패치 엔진/Source/`D` 생성자) 시점에 훅 확장 지점만 미리 고려해두면 나중에 훨씬 싸게 먹힌다**는 문제의식으로 지금 미리 정리해둠. `ROADMAP.md` 백로그 항목("범용 렌더 디버깅 도구로서의 quad-mock")과 목적이 다름 — 아래 "quad-mock 백로그와의 관계" 절 참고. @@ -123,7 +123,7 @@ Compute 함수가 어디서 생성됐는지"를 보여주는 **연결 그래프* 등록하는 훅(기본 no-op). 켜졌을 때만 이 레지스트리가 존재하므로 GC-native 원칙(`lifecycle-pattern.md`)과 충돌 없음 — 꺼져 있으면 레지스트리 자체가 안 만들어짐. -- **quad-roblox `DI/init.luau`의 제네릭 생성자(`new(className)`)** — 인스턴스 +- **quad-roblox `D/init.luau`의 제네릭 생성자(`New(className)`)** — 인스턴스 생성 순간 `debug.info(2, "sl")`로 caller의 script+line을 얻어 기록하는 훅(기본 no-op). Instance 생성은 프로퍼티 변경보다 훨씬 드물게 일어나므로 (렌더 타임 1회), 여기서만 비교적 비싼 `debug.info` 호출을 해도 부담 적음. @@ -376,7 +376,7 @@ columnNumber}`를 리터럴로 박아 넣는 컴파일타임 트랜스폼. 런 값으로 존재. **quad-debug 적용 후보**: 위 "계측 지점 3곳"에서 제안한 -`debug.info(2, "sl")` 런타임 캡처(DI 제네릭 생성자, 호출 시점 caller +`debug.info(2, "sl")` 런타임 캡처(`D` 제네릭 생성자, 호출 시점 caller 위치)의 대안/보완으로, **darklua** 같은 빌드타임 Luau 변환기로 quad 생성자 호출부를 순회하며 파일/라인 리터럴을 인자로 미리 주입하는 방식을 검토할 만함. `debug.info`가 "호출자(caller)의 정확한 라인"을 항상 @@ -457,7 +457,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: 나중에 완전 제거하고 싶을 때도 별도 패키지면 그냥 require 자체를 안 하면 끝). - **`quad-debug-roblox`** — 게임(클라이언트) 쪽에서 require하는 provider. - quad-roblox의 Dispatch/DI에 실제 훅을 꽂고, BindableEvent/Function을 + quad-roblox의 Dispatch/`D`에 실제 훅을 꽂고, BindableEvent/Function을 **quad 모듈 자신의 Instance 트리 안에** 만들어 CollectionService 태그로 노출(위 "데이터 채널" 절 — `ReplicatedStorage` 등 게임 트리에 별도 주입 안 함), `IsStudio` 가드 포함. @@ -476,7 +476,7 @@ Tween mock 등 동적 동작 포함")와 목적이 다름: 만들 필요는 없음). - M3(Source) 구현 시 마찬가지로 나중에 weak-registry 등록 훅을 끼우기 쉬운 생성자 모양인지만 유의. -- M5(quad-roblox DI 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 +- M5(quad-roblox `D` 제네릭 생성자) 구현 시 caller 정보를 나중에 끼워넣기 쉬운 단일 진입점(생성자 함수 하나)인지만 유의 — 이건 이미 `bind-system-plan.md`가 "제네릭 생성자 함수 하나로 통일"이라 확정해둔 것과 자연히 맞아떨어짐, 별도 조치 불필요할 가능성이 큼. diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 7368915..92c27db 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -36,7 +36,7 @@ 목차를 잡으면 좋아 보임(그대로 확정은 아니고 초안): 1. **초기화** — `RobloxFactory(QuadBase)`로 base+backend 조립 (`module-lifecycle-plan.md`, `bind-system-plan.md`) -2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 `new` + 자주 쓰는 ~25개 클래스 정적 필드(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) +2. **Instance 만들기** — DOMless 즉시 생성 모델, 제네릭 생성자 `New` + 클래스별 정적 필드(**[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 전량 코드 생성)(`Frame`, `TextButton` 등) (`architecture.md`, `bind-system-plan.md`) 3. **속성 채우기** — `[Attribute "Name"]`, ~~`[Tag ""] = true`~~ **[2026-08-13 정정] 구모델(폐기, `archive/tag-hash-key-model-reversed.md`) — 실제로는 `Tag(...)` array-part 값 객체** 특수 바인드 키 (`architecture.md`) 4. **반응형 기초** — `Source`/`Store` 생성, `store.key`(dot-access)로 Source 읽기(Source는 State를 만족), `store.key:Set(value)`로 쓰기, State는 항상 읽기 전용 (`base/source-state-plan.md`, `base/store-plan.md`; 2026-08-06 후속 세션에서 dot-access가 Source를 직접 반환하고 쓰기가 `:Set()`으로 바뀜) 5. **스타일링** — Modifier 기본 체이닝(`:FontSize(14)`), 배열/인라인 merge 우선순위 규칙 (`modifier-plan.md`) diff --git a/.claude/research/fastscroll-plan.md b/.claude/research/fastscroll-plan.md new file mode 100644 index 0000000..e861a25 --- /dev/null +++ b/.claude/research/fastscroll-plan.md @@ -0,0 +1,37 @@ +# `quad-roblox-fastscroll` — 스크롤 최적화 유틸 (아이디어 메모) + +**상태**: research 착수 전 — 사용자 노트를 그대로 옮겨 적은 **아이디어 +메모**. 설계 논의는 아직 없었고, 사용자가 "quad가 잘 작동하게 될 때" +직접 검토하겠다고 못박은 **아주 나중** 항목. 지금은 잊지 않게 적어두는 +것 자체가 목적. + +## 아이디어 + +리스트/그리드 컨테이너 안에서 각 항목의 상대 위치를 구해, **실제로 +움직여야 하는 것만** 같이 움직이게 하는 유틸. 스크롤 자체는 배경 위의 +**빈 공간만** 스크롤시키고, 그 결과 absolute position이 필요한 요소만 +갱신되게 한다 — 이 방향이 성립하려면 항목들의 원래 레이아웃을 계산해줄 +**가상 레이아웃(virtual layout) 유틸**이 선행 요구사항으로 보인다(원문: +"이걸 위하여 가상 레이아웃 유틸이 필요하긴 함"). + +## 패키지 경계 + +**완전 외부 요소**로 `quad-roblox-fastscroll`(quad 코어/quad-roblox 코어와 +분리된 별도 패키지) — 원문 그대로. quad 코어 설계에 영향을 주는 항목이 +아니라, quad 위에 얹는 최적화 유틸리티로 스코프됨. + +## 선행 확인 필요 — 실측 전제 + +사용자가 명시적으로 지목한 첫 확인 사항: **`Visible = false`일 때 +`AbsoluteSize`/`AbsolutePosition`이 계속 업데이트되는지 여부**부터 +확인해야 한다. 이 최적화 전략 전체가 "안 보이는 요소는 레이아웃 계산에서 +빼도 좌표가 정확한가"에 의존하므로, Roblox UI 레이아웃 엔진이 +`Visible = false`인 인스턴스도 계속 계산하는지 아니면 멈추는지가 접근 +방식을 가른다. 이건 `.claude/luau-test/`류 추론이 아니라 **Roblox Studio +실측**이 필요한 항목 — quad-doc-auditor/luau-test 스파이크로 대신할 수 +없고, MCP 연결 이후 사용자가 직접(또는 요청 시) 확인해야 함. + +## 우선순위 + +최하 — 사용자가 "아주 나중에 quad가 잘 작동하게 될 때 내가 검토해볼 +예정"으로 직접 후순위 지정. 지금 단계에서 추가 설계 논의 불필요. diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index 8cb8ae6..04fa967 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -534,11 +534,11 @@ Modifier를 합친다"는 시나리오가 `Overridden`의 가장 그럴듯한 **문제**: Modifier의 런타임 체이닝 엔진은 quad-base 소유가 맞지만, 클래스별 정적 타입 안전성(`mod:UICorner(8)`가 `FrameModifier` 타입으로 추론되는 -것)은 "DI 쪽 '제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드' 패턴 -재사용"이라 문서 스스로 밝히듯 quad-roblox의 DI 타입 생성 계층(M5)에 강하게 +것)은 "`D` 쪽 '제네릭 생성자 함수 하나 + 정적 별칭 필드' 패턴 +재사용"이라 문서 스스로 밝히듯 quad-roblox의 `D` 타입 생성 계층(M5)에 강하게 결합돼 있다. 그런데 `ROADMAP.md` M7 체크리스트(flatten-before-dispatch, `Modifier.Overridden`, `State` 차단)엔 이 클래스별 타입 생성 작업이 -전혀 없고, M5 DI 체크리스트에도 Modifier 언급이 없음. +전혀 없고, M5 `D` 체크리스트에도 Modifier 언급이 없음. **제안**: M5 또는 M7 체크리스트에 "quad-roblox 클래스별 typed Modifier 생성자(FrameModifier 등)" 항목을 명시적으로 추가해 누락을 막을 것. diff --git a/.claude/research/spring-plan.md b/.claude/research/spring-plan.md new file mode 100644 index 0000000..1cf1c25 --- /dev/null +++ b/.claude/research/spring-plan.md @@ -0,0 +1,87 @@ +# `quad-spring` — 스프링 물리 기반 지속 업데이트 프리미티브 (아이디어 메모) + +**상태**: research 착수 전 — 사용자 노트를 그대로 옮겨 적은 **아이디어 +메모**. 설계 논의는 아직 없었고, 사용자가 "모든게 완성된 후, 별도 +모듈로 분화"라고 직접 못박은 **아주 나중** 항목. + +## 아이디어 + +특정 Spring 설정을 받아 이전 상태와 비교해 스프링 물리 연산을 수행해주는 +**중간 핸들러**(intermediate handler). StoreBind와 비슷한 구조로 별도 +핸들러 하나를 만들 수 있을 것으로 보인다는 원문 메모(원문: "스토어바인드도 +비슷하게 핸들러 하나 생성 가능할 수 있음"). + +기각된 직접 `Tween` 접근(과거 특수 bind key 모델, `archive/tween-special-bind-key-reversed.md`)과는 +성격이 다르다 — 이건 `Tween`만으로 간결히 해결되지 않는 **인터랙티브 +디자인** 상황(사용자 입력에 실시간으로 반응하는 감쇠/타겟 추적)을 위한 +**지속 업데이트되는** primitive를 염두에 둔 것. 확정된 `Tween` 모델 +(`base/tween-plan.md`)과는 별도 트랙으로 취급. + +## 참고 구현 + +[qwreey/spring.lua](https://github.com/qwreey/spring.lua/blob/master/main.lua) — +사용자 본인의 기존 구현. 사용 가능 여부(라이선스/의존성/quad 아키텍처와의 +정합성) 확인이 필요하다고 원문에 명시. **[2026-08-18 추가]** 산술 연산 +자체는 100줄 내외로 짧고 바뀌지 않는 코드라, 재구현하지 않고 이 파일을 +그대로 **임베딩**하고 싶다는 게 사용자 의도. 다만 **그 코드가 정확한지는 +아직 검증되지 않았고**, quad에 들여오기 전에 에이전트가 확인하는 절차를 +거쳐야 한다(사용자가 직접 "해당 코드가 정확한지는 나중에 에이전트가 +확인하도록 둬야해"라고 명시) — 지금 이 메모 단계에서 검증까지 마친 것은 +아니다. + +## 구현 형태 아이디어 (2026-08-18 추가, 사용자 메모) + +실제 구현 시 순서와 계층 구조에 대한 사용자의 구체적인 구상. 여전히 +**아이디어 메모** 단계이고(원문 표현: "실제 구현할 때에는 ... 것 같아", +"이건 더 검토해 보아야해") 확정된 설계가 아니지만, 아래 "구현 방향 후보"의 +세 번째 갈래(`Source` 기반 자기-emit 프리미티브)를 훨씬 +구체화한 내용이라 이 갈래가 지금은 유력한 후보로 보인다 — 그래도 결정 +시점이 오기 전까지는 다른 갈래를 공식적으로 배제하지 않는다. + +1. **코어 프리미티브**: `Spring impl Source` 형태 — 스프링 하나가 + 그 자체로 `Source`를 구현하며, `:SetTarget()` / `:GetTarget()` + 같은 연산을 제공한다. `quad-spring`에 위치. +2. **`:Apply()` 체인 슈가**: `state:Apply(Springify { 상수... })` 형태로 + 기존 `:Apply()` 체인에 스프링을 끼워 넣는 편의 도구. `Springify`는 + 가칭이고 이름은 나중에 확정. 이것도 `quad-spring`에 위치. +3. **다필드 타입은 별도 바인딩이 필요하다.** `UDim2`처럼 필드가 여럿인 + 값은 전체를 하나로 lerp할 수 없고 **필드마다 독립적으로 스프링 + 타겟이 되어야** 한다 — 그래서 엔진(또는 타입)별로 실 구현체가 따로 + 있어야 한다는 게 핵심 근거. `Springify`를 통해 필요한 만큼 내부 + 스프링을 여러 개 생성하고, 그 스프링들의 목표값을 갱신하고, 매 프레임 + dt에 맞춰 각 스프링의 실값을 얻은 뒤 하나의 합성 객체(예: `UDim2`)로 + 묶어 던지는 `UDimSpringify` 같은 타입별 헬퍼가 필요하다. +4. **패키지 분리 방향 (미정, 더 검토 필요)**: 위 이유로 `quad-spring` + (엔진 중립 코어: `Spring` 프리미티브 + `Springify` 슈가)과 + `quad-spring-roblox`(Roblox 타입별 바인딩, 예: `UDimSpringify`)로 + 나누는 방식이 사용자가 지금 기울어 있는 안이다. 근거는 패키지 의존 + 방향 — 만약 `quad-spring`에 대한 바인딩 구현을 `quad-roblox` 안에서 + 직접 한다면, `quad-roblox`가 (타입만이라도) `quad-spring`에 의존하게 + 되어버린다는 게 사용자 우려(원문: "quad-roblox 는 quad-spring 을 + 타입이라도 의존하게 되니까"). **다만 사용자 본인이 "이건 더 검토해 + 보아야해"라고 명시 — 확정 아님.** + +## 구현 방향 후보 (미정) + +두 갈래가 거론됐고 아직 어느 쪽도 확정되지 않았다: + +1. **엔진 중립 후킹**: `quad-base`가 `onStep`류 프레임 단위 처리 후킹 + 인터페이스를 제공하고, 그 위에 스프링 primitive를 얹는 방식. 원문에서 + 사용자가 "기본 생각으론 후킹이 맞다 보긴 하는데"로 약하게 기운 + 방향이지만 확정은 아님. +2. **엔진별 개별 구현**: `quad-roblox-spring`처럼 엔진마다 각자 맞게 + 개발하는 방식. + +또는 별도 방향으로 — `Source`를 확장해 damping/target 등 스프링 +파라미터를 가진, **자기 자신에 계속 emit하는** 프리미티브를 primitive +레벨에 두는 안도 언급됨. 이 형태면 다른 `:Compute` 체인과 자연스럽게 +엮일 수 있다는 게 사용자 관찰 — 위 "구현 형태 아이디어" 절이 이 갈래를 +구체화한 내용이다. + +**결정 시점**: quad 코어가 충분히 개발되어 이런 pluggable 요소가 더 +필요해지는 시점에 판단 — 지금은 방향을 좁히지 않고 후보만 기록. + +## 우선순위 + +최하 — "모든게 완성된 후, 별도 모듈로 분화"라고 사용자가 직접 명시. M0 +설계 게이트와 무관. diff --git a/.claude/session-summary.md b/.claude/session-summary.md index 525fac1..d53e8db 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -1436,3 +1436,71 @@ blob과 바이트 단위로 동일, 메인이 `git rev-parse`로 독립 확인). 도움이 되는 정보에 가깝지 이게 warn을 만들지는 못할듯" — 기계 검사 대상이 아니라 읽는 쪽 판단 재료), (3) (C) 추적은 컨텍스트 보호를 위해 서브에이전트 위임. `conventions.md`에 "문서 표기 규약" 절 신설. + +## 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영 + +원문: `session/2026-08-18-01-pre-implementation-qa-applied.md` + +`.claude/qa-request/pre-implementation-qa-round1.md`(사용자가 `base/` 확정 문서를 문항으로 +재심사해 "아니오"가 나온 것만 모아둔 문서)를 실제 문서에 반영. **그대로 +구현하면 반대로 돌던 두 건**이 닫혔다 — `canBound`가 이름과 반대 방향으로 +쓰이고 있어 정상 첫 바인드가 전부 에러날 뻔한 것(정정 결과 `canBound`와 +`canExecute`는 값이 같은 게 아니라 **서로의 부정**이고, 그게 오히려 이름 +분리의 명분이 됨), 그리고 gcconn/gchold를 `SetStrong`으로 적어 같은 문서가 +경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸리던 것. + +설계가 바뀐 것: `Dispatch.drive`의 `None` 스킵 폐기 → `NoneHandler`는 재귀 +전담 + **`NilHandler` 신설**(깨진 전제는 "배열 파트의 `None`은 `process`를 +안 탄다" — `Frame{State}`이면 탄다), 이벤트 disconnect 센티널 +`false`→`None`/`nil`, `Ref` 내부 구조를 `.Callbacks` 분리로 단순화, +`:List` reconcile의 `nil` 리턴을 **다시 파괴**로 되돌리고 `PopOnly`(가칭) +신설, base Fallback Handler 등록 주체를 **quad-base 로드 시로 재역전** +(백엔드 미로드 상태에서 안내 에러 경로가 안 도는 게 이유 — `InitNamespace` +거부 원칙과의 양립 근거를 새로 씀). **"이벤트 콜백 시그니처는 Luau가 검증 +못 한다"가 거짓**임이 사용자 반례로 확인돼 `onchange-plan.md`가 그걸 근거로 +쓰던 자리까지 같이 무너졌고(결론은 유지, 근거만 교체), 겸해서 `New` 커링과 +"`D`는 전량 코드 생성된 순수 별칭 테이블"이 명문화됨. + +이름 쪽: **`DI` → `D`(Declarative) 확정**(2026-08-08부터 1순위로 열려 있던 +항목) — 코퍼스 전수 반영, 미뤄온 유일한 사유였던 한 글자 식별자의 검색성은 +"처음 나올 때 항상 `D`(Declarative)로 풀어쓴다" 표기 규약으로 보완. +`Attribute.Merged`/`Overridden`을 **둘 다 제공**하는 제3안으로 이름 겹침 +정책도 해소. + +판단이 갈리던 네 건(`PopOnly` 채택, D-7 재역전, `NoneHandler`/`NilHandler` +역할 분담, 동적 키 `GetDynamic`)은 그 자리에서 사용자에게 물어 확정. +남은 착수 금지 게이트(중간 State GC 미검증 등)는 `question.md` 3번과 +`todos.md` 00번이 소스. `doc-check.py` ERROR 0. + +**커밋 전 검증에서 배운 것**: `quad-doc-auditor` 1패스가 "배너는 고쳤는데 +그 배너가 부정하는 본문 bullet은 안 고친" 건을 하나 잡았고, 이어서 사용자가 +직접 돌린 `/code-review high`가 **10건을 더** 잡았다(전부 유효, 전부 반영) — +감사자가 못 본 것들이라 **두 도구가 서로를 대체하지 않는다는 게 실측으로 +드러났다**(감사자는 코퍼스 전체 정합성, code-review는 diff 자체의 결함). +그중엔 ROADMAP이 SL-3 역전을 안 따라와 M8 체크리스트대로 짜면 방금 되돌린 +결함을 다시 만드는 건, 그리고 **설계 갭 2건**(`GetDynamic` 콜론 메소드가 +Store의 lazy `__index`와 충돌 / `PopOnly` 홀드 중 키가 사라지면 파괴도 반환도 +안 됨)이 있어 새 열린 질문으로 올렸다. **감사 비용 메모**: 감사자 한 패스가 +서브에이전트 토큰 21만이라(코퍼스 전체를 다시 읽는 정의라서) 계획했던 4패스를 +중단했음. **그래서 사용자 지침으로 감사 절차 자체를 바꿨다** — 병렬 금지(한 +턴에 하나), 범위는 diff로 좁히고 라운드마다 각도를 바꿈, 종료 조건은 무발견 +1회(옛 "2연속"은 병렬 전제라 완화). `conventions.md`의 감사 루프 절이 소스. + +## 2026-08-19 — `New()` 내부 구성(InitXxx + Relate 멱등 가드) 확정, 세션 기록 공백 발견 + +원문: `session/2026-08-19-01-new-initxxx-composition-relate-guard.md` + +사용자가 `New()`를 v1 스타일 `InitXxx(module)` 팩토리 체이닝으로 짜자고 +제안(이미 확정된 `InitRoblox(Module)` backend 주입 패턴을 quad-base 자기 +내부에도 대칭 적용) → 채택, `module-lifecycle-plan.md`에 "New()의 내부 +구성" 절 신설. 이어서 서브시스템 간 호출 순서 문제를 "Init을 `require`처럼 +멱등하게"(각 `InitXxx` 파일 톱레벨에 `Relate()` 하나 두고 `module`을 weak +key로 완료 여부 기록) 방식으로 직접 해소하는 아이디어도 제안·반영 — +`relate-plan.md`의 기존 확정 API/관례와 정확히 부합함을 확인. 핸드오버 +감사 2라운드를 거치며 라운드 1의 수정 자체가 절 인용 사각지대를 새로 +만든 걸 라운드 2가 잡는 등 실제로 반복 라운드가 필요함을 다시 확인. + +**부수 발견 — session/ 기록 공백**: 2026-08-18에 커밋 10개(QA 1~2라운드 +포함)가 있었는데 그날 session/ 파일은 1개뿐, 2026-08-19는 이 세션 전까지 +(QA 3라운드 커밋 1개가 있었음에도) 0개였음. 과거 대화 트랜스크립트에 접근 불가라 그 공백을 사후 재구성하는 건 +허위 기록 위험이 있어 보류 — 처리 방침은 사용자 확인 대기. diff --git a/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md b/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md new file mode 100644 index 0000000..ed60eb0 --- /dev/null +++ b/.claude/session/2026-08-18-01-pre-implementation-qa-applied.md @@ -0,0 +1,179 @@ +# 2026-08-18 — 구현 전 QA 결과를 `base/`에 일괄 반영 + +**요청**: "pre-implementation-qa 의 적용을 수행하자." + +`.claude/qa-request/pre-implementation-qa-round1.md`(같은 날 앞선 세션이 +만든, `base/` 확정 +문서를 사용자에게 문항으로 재심사한 결과)의 항목을 실제 문서에 반영한 세션. +그 문서는 "여기서 정정하지 않는다, 사용자 정정 회신이 오면 반영한다"고 +적혀 있었지만 **각 항목에 이미 사용자 답변 원문과 논거가 붙어 있었고**, +사용자가 적용을 지시했으므로 그 답변을 정정 근거로 삼아 반영했다. + +## 먼저 사용자에게 물은 네 가지 + +QA 문서가 "결론 없음"으로 남겨둔 항목 중, 임의로 정하면 안 되는 것만 +`AskUserQuestion`으로 물었다(나머지는 답변 원문이 이미 방향을 확정함): + +1. **SL-3 — `:List` reconcile의 `nil` 리턴** → *"PopOnly 확정. 다만 이름은 + 변경될 수 있음. 이름에 대해서는 더 생각해보아야함"* → 파괴가 기본으로 + 되돌리고 `PopOnly`(가칭)를 이번 설계에 넣음. +2. **D-7 — base Fallback Handler 등록 주체** → **quad-base 로드 시 등록으로 + 재역전**(2026-08-14의 역전을 다시 뒤집음). +3. **N-4/RF-4 — `None`/`nil` 배열 슬롯 처리 책임** → *"NoneHandler는 + 재귀만, NilHandler가 실질 담당"*. +4. **ST-2 파급 — 동적 키 경로** → *"동적히는 여전히 그냥 Store.Name 하면 + 얻어는 짐. 타입 애러가 난다는 점인데, 이는 GetDynamic(name): + Source 로 제공하는게 최선으로 보임."* + +## 반영한 것 — 성격별 + +**(1) 그대로 구현하면 반대로 도는 것** + +- **S-1 `canBound` 방향 반전**: `canBound(v) == not isBoundAlive(v)`, + 게이트는 전부 `if not canBound(v) then error(...)`. 진원지 + `lifecycle-pattern.md`의 (1)(2)(3) 절을 다시 쓰고, + `source-state-plan.md`/`ROADMAP.md`/`luau-test`(README·STATUS)/ + `audit/gcconn-trick-verification.md`까지 같은 방향으로 정정. + **부수 발견**: 열한 번째 세션이 이름 분리의 근거로 적은 "판정 로직도 + 같고 값도 항상 같다"가 무너짐 — 실제로는 **서로의 부정**이고, 그게 + 오히려 이름 분리의 명분을 강화한다는 쪽으로 절을 다시 씀. +- **RE-1 `SetStrong` → `SetWeak`**: `relate-plan.md`의 "대체하는 것" 절과 + `architecture.md` 소스 트리 주석. 근거 문장("둘 다 존재 이유가 '안 죽는 + 것'이므로 strong")까지 통째로 틀렸던 것이라 근거도 교체 — 그대로 짰으면 + 같은 문서가 경고하는 두-`Relate` 상호 강참조 누수에 정확히 걸렸다. + +**(2) 설계가 바뀐 것** + +- **RF-4+N-4**: `Dispatch.drive`의 `None` 스킵 분기 폐기 → `NoneHandler`는 + 재귀 전담, **`NilHandler` 신설**(`k=number and v==nil` 말단, + `setLength(0)`/`setOffsetSource(None)` 등록). 깨진 전제는 "배열 파트의 + `None`은 `process`를 절대 안 탄다"였는데 `Frame{ State }`이면 + 탄다는 것. +- **D-6 파생**: Length/Offset 등록 책임이 "그 위치를 **처음** 매치한 + Handler"에서 **말단 Handler**로 정정(중간 노드는 `inst`에 부작용을 안 + 가한다는 D-3 계약과 충돌했음). 같이 검토 대상이던 "모든 핸들러가 + `k=number`일 때 처리" 안은 `NilHandler`가 갭을 닫아 채택 안 함 — + **이건 사용자 답변에서 바로 나온 결론이 아니라 두 답변을 합친 추론이라 + 세션 보고에서 따로 짚었다.** +- **EV-1**: 이벤트 disconnect 센티널 `false` → `None`/`nil`. `EventHandler`가 + `v == nil`에도 매치돼야 한다는 계약이 새로 생김. +- **D-7**: Fallback Handler 등록 주체 재역전. `InitNamespace` 거부 원칙과의 + 양립 근거를 새로 씀 — 그 원칙이 금지한 건 *사용자 수동 init*과 *남의 + 상태를 건드리는 top-level 부작용*이지, 모듈이 자기 레지스트리를 채우는 + 게 아니다. `archive/tag-attribute-load-time-registration-reversed.md`엔 + "절반 재역전" 배너를 달았다(이름 쪽 결론은 그대로 유효). +- **R-1**: `Ref` 내부 구조가 `.Callbacks` 별도 테이블 + 평범한 `.Value` + 필드로 단순화 → `__index` 우회 기법의 존재 이유 자체가 사라짐. +- **SL-3**: `:List` reconcile의 `nil`/키 소멸은 다시 파괴, 값 교체와 + `PopOnly`만 비파괴. `State` 교체가 언마운트인 것은 그대로 유지되게 + 세 경로를 표로 갈랐다(`:Single` sugar가 교체 경로를 타므로 자동으로 안전). +- **BS-2+N-9**: "이벤트 콜백 시그니처는 Luau가 검증 못 한다"가 거짓 — + 사용자가 반례 코드를 직접 작성해 보여줌. `onchange-plan.md`가 이 전제를 + 근거로 쓰던 자리도 근거만 교체(결론은 유지: `OnChange`는 필드가 아니라 + 팩토리라 타입을 미리 찍어둘 자리가 없다). 겸해서 `New` 커링 계약과 + "`D`는 전량 코드 생성된 순수 별칭 테이블"을 명문화. + +**(3) 이름/표면** + +- **N-8 `DI` → `D`(Declarative)** 확정 — 코퍼스 전수 반영(네임스페이스는 + `D`, "특수 DI 키"라는 설명 표현은 "특수 키"로 단순화). 2026-08-08부터 + 개명을 미뤄온 유일한 사유(한 글자 식별자의 검색성)는 **"문서에서 처음 + 나올 때 항상 `D`(Declarative)로 풀어쓴다"** 표기 규약으로 보완하고 + `architecture.md`의 네이밍 케이싱 절에 4번 항목으로 넣었다. + `question.md`의 1순위 항목은 `archive/question-resolved.md`로 이전. +- **N-5** `Attribute.Merged`(겹치면 error) / `Attribute.Overridden`(뒤가 + 이김) **둘 다 제공** — 열려 있던 "error냐 override냐"가 제3안으로 해소. + 덤으로 `Merged`/`Overridden`이라는 이름 쌍의 의미가 코퍼스에서 재정렬됨 + (연산의 종류 → 충돌 시 정책). + +**(4) 나머지** — A-3(`New()` 자동 스코핑이 아니라 `Quad()` + 코드 수정 +필요), D-1(방어 가드 "죽은 코드"에 한정 추가), D-5(`PreRef`는 배열 우선 +보장 위가 아니라 별도 pre-pass), M-3(예약 필드가 `Apply` 하나가 아니라 +셋 + `Overridden`은 콜론도 가능), B-1(`Brand`는 무의존 — `None` 특수 분기 +기각), R-3(`ProcessedPreRef` 센티널), SL-1(`RefLeafHandler`에 `k` 체크 +추가 + 배열 전용 근거 명문화), E-2(`:Unsubscribe()`는 `:Subscribe()`의 +짝으로 축소), N-1(FALLBACK 에러에 `k` 타입), N-2(타입이 방어선), N-3 +(`Quad.debug`), N-6(`SetAndDispose` 후보), N-7(UI 숏핸드는 `Relate`로 +조회), 부수 오탈자 2건(`.value` 케이싱, `fn(value, previous)` 표기). + +## 열어둔 것 (착수 금지 게이트) + +`question.md` 3번과 `todos.md` 00번이 소스 — 중간 State GC 미검증(M3), +그룹 `Attribute` 위치별 claim 키 설계(M10), `SetAndDispose` 방향(M3 전), +dedup 경로의 process/retract 대칭 확인(M3 전), `PopOnly` 이름, +`Store` 미선언 키의 타입 에러 실측(M0). + +## 커밋 전 검증 — 감사자 1패스 + `/code-review high` + +**`quad-doc-auditor` 1패스(base 코퍼스 각도)**: 확실 발견 1건 — +`ref-plan.md`가 "원래부터 빈 자리인 `None`은 **여전히** 두 패스 루프가 직접 +건너뜀"이라고 남겨둔 문장이 같은 파일의 2026-08-18 배너와 정면 모순 +(정확히 "배너는 고쳤는데 그 배너가 부정하는 본문 bullet은 안 고친" 실패 +패턴). 수정 완료. + +**감사 비용 이슈로 나머지 각도는 중단** — 한 패스가 서브에이전트 토큰 +21만/툴 호출 82회였다. 감사자 정의가 "코퍼스 **전체**를 신선한 맥락에서 +다시 읽는다"인 데다(라이브 문서 91개, `base/`만 ~12,000줄) 이번 프롬프트가 +바뀐 결정 16개를 교차 검증하라고 시켜서, 계획대로 4개를 돌렸으면 80만 +토큰대였을 것. **다음에 큰 변경을 감사할 때는 전 코퍼스가 아니라 diff가 +건드린 파일 + 그걸 인용하는 곳으로 범위를 좁혀 프롬프트할 것.** +(부수: `/model`이 opus로 보여 감사자가 opus로 도는지 의심됐는데, 정의 +frontmatter는 `model: sonnet`이고 오버라이드도 안 넘겼다. 다만 **이번 +실행이 실제 sonnet이었는지는 확인 못 함** — 이 세션 트랜스크립트에 +sidechain 레코드가 안 남았다. `todos.md` 7번의 "정의가 언제/얼마나 +반영되는지 모른다"가 여전히 유효.) + +**사용자가 `/code-review high`를 직접 돌림 — 10건 전부 유효**했고 전부 +반영했다. 감사자가 못 잡은 것들이라 **두 도구가 서로를 대체하지 않는다는 +게 실측으로 드러난 라운드**(감사자는 코퍼스 전체 정합성, code-review는 +diff 자체의 결함): + +- **[high] `ROADMAP.md`가 SL-3 역전을 안 따라옴** — `unmountSlotTree`를 + "`:List`의 reconcile"이 쓴다고 그대로 적혀 있었음. M8 체크리스트를 보고 + 구현하면 정확히 이번에 되돌린 결함을 다시 만든다. +- **[medium] `modifier-plan.md` §5 / `attribute-plan.md` 근거 문단**이 + 기각된 "문자열 폴백"과 "자주 쓰는 ~25개"를 근거로 계속 인용. +- **[medium] `GetDynamic` 콜론 메소드가 Store의 lazy `__index`와 충돌** — + 아무 장치 없이 부르면 `"GetDynamic"`이라는 이름의 Source를 만들어 함수로 + 호출하게 됨. 예약 키가 되거나 탑레벨 함수여야 함 → **새 열린 질문**. +- **[medium] ROADMAP에 이번 라운드의 새 표면이 통째로 누락** + (`Attribute.Overridden`/`Quad.debug`/`GetDynamic`/`PopOnly`) → 전부 추가. +- **[medium] `PopOnly` 계약과 의사코드 불일치** — 키가 사라지면 홀드 중이던 + 요소가 파괴도 반환도 안 되고 참조만 끊김 → **새 열린 질문**. +- **[low] `NilHandler`의 `setLength`/`setOffsetSource` 호출 순서가 같은 + 문서의 해제 순서 계약과 반대** → 뒤집음. `ProcessedPreRef`/`PostRef` + 핸들러도 같은 순서 오류가 **이번 세션 이전부터** 있어서 같이 고침. +- **[low]** `architecture.md` 정정 배너가 원문을 "콜론"이 아니라 "콜백" + 메서드로 오인용(정정하려는 문장의 뜻이 뒤집힘), ROADMAP 433행에 리네임 + 전 "대기 중/잠정 표기" 잔여, `documentation-content-map.md`가 `D` 스윕에서 + 누락. + +## 절차 변경 — 감사 루프를 병렬에서 순차로 (사용자 지침) + +이번 라운드의 토큰 소비를 보고 사용자가 감사 절차 자체를 바꿨다: + +> "시간이 걸려도 상관 없으니, 차라리 병렬 에이전트를 덜 써줘. 그냥 한 턴에 +> 하나씩만 사용하고, 0 이 나올때 까지 턴 수를 늘리는게 나아보임. 토큰을 +> 너무 많이 소비해서 다른 작업을 못 하고, 세션 한도에 닿더라고." + +`conventions.md`의 감사 루프 절을 그에 맞게 재작성했다 — (1) **병렬 금지, +한 턴에 하나**(2026-08-16의 "최소 2개 병렬" 지침을 대체), (2) **프롬프트로 +범위를 diff로 좁힐 것**(전 코퍼스 스윕은 오래 안 돌렸을 때만), 라운드마다 +각도를 바꿔 병렬로 얻던 폭을 턴으로 얻는다, (3) 종료 조건은 **무발견 1회** +(옛 "무발견 2연속"은 병렬 다패스 전제라 같이 완화), (4) 비용 때문에 중간에 +멈출 때도 "몇 라운드에서 왜 멈췄는지" 반드시 보고. + +같이 명문화한 것: **`/code-review`는 감사자를 대체하지 않는다** — 이번에 +감사자 1패스(1건) 뒤 `/code-review high`가 10건을 더 잡았고 전부 유효했다. +보는 축이 달라서다(감사자=코퍼스 전체 정합성, code-review=diff 자체의 결함). +`/code-review`는 사용자만 호출할 수 있으므로, 큰 변경 커밋 전엔 돌릴지 +물어보는 것을 관례로 넣었다. + +## 도구/절차 메모 + +- `doc-check.py`: 처음 돌렸을 때 ERROR 6건 — 전부 **내가 절 제목을 바꾸는 + 바람에 다른 문서의 인용이 깨진 것**과, ROADMAP blockquote 안에서 인용을 + 줄바꿈에 걸친 것(`conventions.md`가 이미 경고한 실패 모드를 그대로 밟음). + 고쳐서 **ERROR 0**, WARN 8은 전부 이 세션 이전부터 있던 것. +- 그 QA 문서는 지우지 않고 **근거 기록으로 격하**(상단 + 배너 교체) — 사용자 답변 원문이 그대로 남아 있어야 나중에 되짚을 수 있음. diff --git a/.claude/session/2026-08-19-01-new-initxxx-composition-relate-guard.md b/.claude/session/2026-08-19-01-new-initxxx-composition-relate-guard.md new file mode 100644 index 0000000..61db7ac --- /dev/null +++ b/.claude/session/2026-08-19-01-new-initxxx-composition-relate-guard.md @@ -0,0 +1,115 @@ +# 2026-08-19 — `New()`의 내부 구성: InitXxx 팩토리 체이닝 + `Relate` 기반 멱등 Init 가드 + +**요청**: `Quad.New()`가 실제로 어떻게 구현돼야 하는지에 대한 사용자 +아이디어 검토 요청으로 시작 — 결론까지 나서 `base/`에 반영, 이어서 +핸드오버 감사 루프와 세션 기록 공백 점검까지 같은 세션에서 처리. + +## 1. 제안 — `New()`를 InitXxx 팩토리 체이닝으로 + +사용자 원문: *"New() 가 실행되면 Quad 를 만드는 함수가 있는것으로 처음부터 +구현하는게 맞음. ... New 결과 안에 .New 함수를 넣어줌. 즉, 생성형식 자체는 +비싱글톤이고, Dispatch 같은것도 Init(module) 을 받는 함수로써 ... +module.Dispatch = ... 형식들로 구현되고 ... 재익스포트식으로 구현하겠다는 +이야기였음. 처음부터 InitModuleName 식으로 구현하여 팩토리를 쌓아 모듈을 +리턴하는 방식으로, quad v1 의 방식을 가져와봄직 하다는것."* + +**조사**: 기존 `base/architecture.md` 13번("모듈은 기본 싱글톤, `New()`는 +추가 인스턴스가 필요할 때만")과 14번("pluggable 초기화는 팩토리 함수로"), +`module-lifecycle-plan.md`가 이미 `InitRoblox(Module)` 형태의 backend 주입 +패턴을 확정해뒀다는 걸 확인 — 이번 제안은 그 패턴을 quad-base **자기 +자신의 내부 구성**(Dispatch 등)에도 대칭 적용하자는 것이라 새 설계가 +아니라 기존 원칙의 자연스러운 확장으로 판단. `lifecycle-pattern.md`가 +거부한 rbvm `InitNamespace` 패턴(소비자가 라이브러리마다 수동으로 init을 +부르는 것)과도 안 겹침 — 여기선 `New()` 하나만 외부에 노출되고 내부에서만 +`InitXxx(module)`를 부름. + +**결론**: 채택 추천 — `module = {New = New}` 자기참조도 이미 확정된 결정과 +정확히 일치, `type Dispatch = InitDispatch.Dispatch` 재익스포트만 실제 +Luau 동작 확인 필요하다고 남겨둠. + +**사용자 확인**: "그거 타입 익스포트 잘 됨. 구체화 반영해줘." → 실측 +확인됐다는 뜻으로 받아 `module-lifecycle-plan.md`에 "New()의 내부 구성" +절 신설, `architecture.md` 13번에서 포인터 연결. + +## 2. 정제 — Init을 `require`처럼 멱등하게 + +사용자 원문: *"Init 은 require 처럼 생각 가능한듯. Init 여러번은 한번만 +작동하게 자신 모듈 최상단에 Relate 를 (함수 안 아님. 클로저 바깥) 놓고, +자신 모듈의 init 여부를 저장해. 그리고 한번만 작동하도록 두고, 자신 init +에선 필요한것들을 init 해줘. 디펜던시 느낌인거지. ... 맨 바깥 quad-base +진입점의 New 에선 모든 Init 을 그냥 실행해도 돼."* + +이게 1절 문서화 때 "구현 단계에서 정할 것"으로 남겨뒀던 "서브시스템 간 +`InitXxx` 호출 순서 의존성" 문제를 실제로 푼다 — 각 `InitXxx` 파일이 자기 +톱레벨(클로저 밖)에 `local relate = Relate()`를 두고 `module`을 weak key로 +"이미 이 인스턴스에 Init됐는지"를 기록하면, 의존하는 쪽이 자기 의존성을 +직접 호출해도 중복/순서 걱정이 없어짐(멱등) — `require`가 파일 단위로 하는 +캐싱을, `New()`가 여러 `module` 인스턴스를 만들 수 있다는 차이 때문에 +인스턴스 단위로 다시 구현하는 것. + +`relate-plan.md`의 확정 API(`Relate()`/`SetWeak`/`GetWeak`/`SetStrong`/ +`GetStrong`, "각 모듈이 자기 톱레벨에 `Relate()` 하나 재사용" 관례)와 +정확히 부합함을 확인 — 새 메커니즘이 아니라 기존 프리미티브의 정확한 +용례. `module-lifecycle-plan.md`에 반영, 순환 의존 대비를 위해 플래그를 +실제 작업 전에 먼저 세우는 규칙도 같이 명문화. + +## 3. 핸드오버 감사 루프 (2라운드, `conventions.md`의 "핸드오버 준비하고 +커밋해" 절차) + +바뀐 파일: `base/architecture.md`, `base/module-lifecycle-plan.md`, +`base/dispatch-core-plan.md`, `README.md`, `ROADMAP.md`. + +**라운드 1**(`quad-doc-auditor`, agentId `ad95c24230306ab0f`) — 확실 3건 + +의심 3건 + 사용자판단 1건: +- 확실: `architecture.md`의 "M0 스캐폴딩에 주는 함의" 불릿이 이미 InitXxx + 절이 답한 질문을 여전히 미결정으로 서술 / `README.md` 색인에 새 절 요약 + 누락 / `module-lifecycle-plan.md`의 "지금은 없음"이 날짜 없는 시한부 + 주장. +- 의심: `_initializedBy` 상호 참조가 정의를 못 찾게 함(`bind-system-plan.md` + 누락) / GC 인과 서술이 `relate-plan.md`의 "`inst`는 항상 weak" 규칙과 + 어긋나게 읽힘 / `dispatch-core-plan.md`가 새 절을 안 가리켜 상호참조 누락. +- 사용자판단(문서만으론 못 정함, 이번엔 직접 판단해 처리): InitXxx 구조가 + M0/M1 중 어느 마일스톤부터인지 → `ROADMAP.md`의 "M0 — 스켈레톤 + + 기술검증" 절 실제 내용(스파이크 전용, "진짜 마일스톤 아님")을 근거로 + **M1**로 확정, `ROADMAP.md` M1 체크리스트에 항목 신설. + +전부 반영(위 6곳 수정 + M1 체크박스 추가). + +**라운드 2**(agentId `a1d60d10fc36621ed`) — 라운드 1 수정 자체가 새 stale +2건을 만든 걸 발견: +- `architecture.md`가 인용하던 "M0 스캐폴딩에 주는 함의"라는 절 제목 + 문구를 라운드 1 수정이 지워버려 절 인용 규약 사각지대(파일명 없는 같은 + 문서 내 인용이라 `doc-check.py`가 안 잡음) 발생 → 원래 제목 문구를 불릿 + 맨 앞에 복원하면서 내용만 정정. +- "위 'M0 — 스켈레톤 + 기술검증' 절 참고"가 실제로는 `ROADMAP.md` 안의 + 절인데 파일명이 빠져 같은 문서 안인 것처럼 읽힘 → `ROADMAP.md`의 명시. +- 추가로 "확실": 이 새 절 자체가 사용자 발언 3건을 인용하면서 + `session/2026-08-19-*.md` 포인터가 없음(이 문서가 그 포인터). +- 의심: `relate-plan.md`의 "언제 Relate를 쓰는가" 체크리스트에 새 용례가 + 안 실림 → 다섯 번째 불릿 추가. +- 사용자판단: Init-완료 플래그 값(`true`)을 `SetWeak`/`SetStrong` 중 뭘로 + 적을지가 문서 간 안 맞음 → boolean은 GC 대상이 아니라 실질 차이는 없지만 + `relate-plan.md`의 일반 규칙("다른 곳에서 안 붙잡는 값은 Strong") 기준 + **`SetStrong`으로 통일**. + +전부 반영. 라운드 3은 이 문서 작성 이후 진행 예정(아래 미해결 참고). + +## 4. 세션 기록 공백 발견 (사용자가 감사 진행 중 별도로 제기) + +사용자 질문: *"세션 기록들 요즘 왜 안 적어? ... 18일 자가 하나 뿐이네."* + +확인 결과 — `git log`엔 2026-08-18에 커밋 10개(QA 1~2라운드, 감사 루프 +재설계, GitHub co-author 정책, git 원격 정책 등), 2026-08-19에 커밋 1개 +(QA 3라운드)가 있는데, `session/`엔 2026-08-18 파일이 `pre-implementation-qa-applied.md` +**하나뿐**이고 2026-08-19 파일은 이 문서 이전엔 **0개**였다. 즉 QA +2라운드/3라운드(`todos.md` 00번이 상세히 서술하는, RC-1/RC-3/RC-4를 실제로 +찾아 해결한 세션들)와 tooling/research 커밋 다수가 session/ 원문 없이 +커밋됨 — 실제 공백. + +**한계**: 이 세션은 그 과거 대화의 실제 트랜스크립트에 접근할 수 없다(커밋 +메시지와 현재 파일 상태만 볼 수 있음) — `session/`의 정의 자체가 "시행착오 +포함 원문"이라, 원문을 못 본 채로 "raw log"를 지어내면 오히려 그 자체가 +허위 기록이 된다. 그래서 이 문서는 **이번 세션분만** 원문으로 채웠고, +과거 공백(08-18 QA 2/3라운드 등)을 어떻게 처리할지는 사용자에게 별도로 +물어야 함(요약만 `session-summary.md`에 사후 추가할지, 아예 공백으로 +인정하고 넘어갈지 등 — 이 문서 자체가 그 판단의 근거 자료). diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bcf43f6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "includeCoAuthoredBy": false +} diff --git a/.claude/todos.md b/.claude/todos.md index 7938f4e..2ca18f1 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -5,6 +5,72 @@ (`.claude/question.md`, `luau-test/STATUS.md` 등). +00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드 + 전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를 + 문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은 + `.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로 + 적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정 + 반영됐다**(개수는 그 문서가 소스, 여기서 세지 않음). 그대로 구현하면 + 반대로 돌던 두 건(`canBound` 게이트 방향, gcconn/gchold 강/약)도 닫혔다. + 2라운드는 `:List`의 `reconcile`/`recompute` 같은 확정 의사코드를 실제로 + 손으로 실행해보는 작업(원본과 진행 로그는 + `.claude/qa-request/pre-implementation-qa-round2.md`가 소스) — `recompute` + 트레이싱에서 `Frame{A,B}`처럼 정적 자식 2개짜리도 첫 마운트에 크래시하는 + 경로(`RC-1`)를 찾았고, 같은 날 후속 대화에서 사용자가 직접 제시한 + Blocker 재사용 게이팅 설계로 해결·반영까지 완료됐다 + (`archive/question-resolved.md`의 `RC-1` 절). + + **3라운드(완료, `.claude/qa-request/pre-implementation-qa-round3.md`가 + 소스) — `RC-1` 해법이 실제로 `attachSlot`에 반영된 걸 트레이싱하다 + 새 문제 발견, 같은 세션에 전부 해결·반영까지 완료.** 처음엔 `activateList`가 + 자기 Slot의 Blocker가 켜지기 **전에** 실행돼 `:List` 초기 population이 + 문제(`RC-3`/`RC-4`)를 낸다고 봤고, `recompute`가 의존하는 `bk.N`(순회 + 상한)의 수명주기도 문서에 없어 "고정값/그때그때 실제 개수 둘 다 각기 + 다른 방식으로 깨진다"고 판단했으나 — **사용자가 이 분석 자체를 + 정정**했다: Blocker 게이팅은 `bk.N`이 아니라 `blocker:IsOn()`만 보므로 + "그때그때 실제 개수" 모델이 배치 크래시를 되돌린다는 결론은 틀렸었다 + (`bk.N` = 그때그때 실제 개수로 확정). `RC-3`/`RC-4`도 사용자가 더 + 단순한 해법을 직접 제시 — flush 루프를 분기하는 대신 `attachSlot`의 + `slot._mounted = true`를 `activateList` 호출 뒤로 옮기는 것 하나로 + 둘 다 닫힘. 부수로 `spliceArraysDown`이 밀어야 할 배열에 + `bk.observers`가 빠져 있던 것도 발견·반영, `ROADMAP.md` M2가 M3의 + `Blocker.luau`에 구조적으로 의존하게 된 것도 각주로 반영(마일스톤 + 재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md + 마일스톤 정합성" 절 참고). + + **아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀 + 있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야 + 하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md` + 3번에도 올라가 + 있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한 + 항목만 그렇다 — 아래 "dedup 경로" 대칭 확인, "Store 미선언 키" 실측 + 확인 둘은 판단이 아니라 구현 시 검증 작업이라 `question.md`엔 없음, + 여기 목록이 소스**), 각 `base/` 문서에도 ⚠️로 표시돼 있다: + - **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md` + M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau` + (또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수 + 전 필요**. `qa-request/pre-implementation-qa-round3.md`의 + "ROADMAP.md 마일스톤 정합성" 절. + - **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong / + 하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.** + - **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) — + 방향은 확정, 키 설계가 미정. M10 착수 전 필요. + - **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply` + 시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도. + - **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md` + `:Unsubscribe()` 절) — M3 착수 전 확인. + - **`PopOnly` 이름**(`base/slot-plan.md`) — 메커니즘은 확정, 이름만 열림. + - **`PopOnly` 홀드 중 키가 사라졌을 때의 처분**(`base/slot-plan.md`) — + 지금 의사코드대로면 파괴도 반환도 안 되고 참조만 끊김. **[정정, + 2026-08-18 `/code-review high` — `ROADMAP.md`의 M6 PopOnly 체크박스와 + 대조해 발견] M6(`:List`가 있는 마일스톤) 착수 전 필요** — M8(`Ref`) + 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥 지나칠 위험이 있었음. + - **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지** + (`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가 + 됨(lazy `__index`와 충돌). M3/M4 착수 전 필요. + - **`Store` 미선언 키가 실제로 타입 에러가 나는지**(`base/store-plan.md`) + — M0에서 실측 확인. + 0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).** `question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy 핸들 계약)는 13차 세션에, `0-Z`(Attribute 이름 소유권)와 `0-A`(재디스패치 @@ -17,7 +83,10 @@ 재도입**됨(2026-08-14 다섯 번째 세션에 하나로 합쳤던 걸 부분적으로 되짚음 — "이미 묶여 있는가"(bound 문맥)와 "지금 발화해도 되는가" (execute 문맥)는 판정 로직은 공유해도 호출부의 질문이 다르다는 사용자 - 지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). + 지적, `base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절. + **[정정, 2026-08-18] 두 predicate는 값이 같은 게 아니라 서로의 부정**이고 + 게이트는 항상 `if not canBound(v) then error(...)` 모양이다 — 그 문서의 + 같은 절이 소스). `question.md`엔 이제 "결정 대기" 절 자체가 없음(비어서 헤딩째로 삭제). **M0 착수 전 반드시 읽을 것 — 이 두 개는 "결정"이 아니라 "구현 규약"이라 @@ -72,7 +141,8 @@ stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는 2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이 목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정) - — 아직 진짜로 열려있는 것만 짚으면: `DI`→`D`(1순위), `Slot`(2순위), + — 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영 + 완료로 목록에서 빠짐**, 대신 `PopOnly`(가칭)가 새로 들어옴): `Slot`(2순위), `canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지 방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/ `Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위). @@ -113,6 +183,14 @@ 백엔드 팩토리 표면에 추가될 예정이라는 것도 M1 설계 시 인지. 설계는 네 라운드로 대부분 확정됐고 남은 열린 질문은 `question.md` 3번(개수는 거기도 반복 안 함 — 소스는 `research/debounce-throttle-plan.md` 12절). + **[2026-08-18 추가]** 사용자 아이디어 메모 두 건도 같은 성격의 백로그로 + 신설 — 스크롤 최적화 외부 유틸 `quad-roblox-fastscroll` + (`research/fastscroll-plan.md`, 선행으로 `Visible=false`일 때 + `AbsoluteSize`/`AbsolutePosition` 갱신 여부 실측 필요)과 스프링 물리 + 기반 지속 업데이트 프리미티브 `quad-spring`(`research/spring-plan.md`, + 참고 구현 `qwreey/spring.lua` 사용 가능성 확인 필요) — 둘 다 설계 논의 + 전 아이디어 단계이고 사용자가 직접 "아주 나중"으로 후순위 지정, M0/설계 + 게이트와 무관. 5. 자율 작업 루프/스케줄 설정 여부는 사용자 결정 대기 중 (`HUMAN_TODO.md` 2번 항목). 6. **[신규 백로그, 2026-08-14 열네 번째 세션]** 문서 stale 감소용 include diff --git a/ROADMAP.md b/ROADMAP.md index 2b4c15f..822eb25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -85,6 +85,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 소스 트리 구조 확정" 절 그대로) - [ ] quad-base용 최소 mock 테스트 하네스(Vide `test/mock.luau` 선례, 순수 `luau` CLI, `architecture.md` "테스트 전략" 절 참고) +- [ ] 최상위 `New()`/`InitXxx(module)` 팩토리 체이닝 골격 — 각 서브시스템 + Init이 `module`을 파라미터로 받아 뮤테이션, `Relate` 기반 인스턴스별 + 멱등 가드(`base/module-lifecycle-plan.md`의 "New()의 내부 구성" 절 + 그대로, 2026-08-19 확정) - [ ] 이 시점부터 `.claude/qa-request/`/`.claude/archive/` 폴더 실사용 시작 ## M2 — 디스패치 엔진 @@ -132,7 +136,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 좁혀야 함(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로 제외 항 하나 추가, `isPostRef`도 `isRef` 아래 형제로 신설). `isModifier`는 여전히 단순 항등, 상위 개념 없음. **[정정, 2026-08-11 아홉 번째 - 세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 DI 키 + 세션]** `isAttribute` 하나였던 게 `isAttributeKey`(단일 키 특수 키 predicate, 해시파트 `k`를 판별)와 `isAttribute`(그룹 값 predicate, array-part `v`를 판별, `isTag`와 같은 결)로 분리됨 — 그룹 `Attribute(...)` 프리미티브 신설로 같은 이름이 서로 다른 두 @@ -184,7 +188,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 (2026-08-09 여섯 번째 세션, `base/dispatch-core-plan.md` "Length/Offset" - 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) + 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소). + **[2026-08-18 구현 전 QA 2라운드 후속] `bk.N≥2`인 자리가 처음 + 채워지는 동안 크래시하던 경로(`RC-1`)는 owner별 `Blocker` 게이팅으로 + 해결됨** — `setLength`/`setOffsetSource`가 배치 등록 중엔 + `recompute`를 미루고 배치가 끝나면 명시적으로 한 번만 돎, 상세는 + `base/dispatch-core-plan.md`의 "배치 등록을 안전하게 만드는 Blocker + 게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는 + `bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가 + 바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker` + (`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는 + 크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만 + 호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에 + 있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는 + `Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면 + (`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행) + 만들 필요가 있는지 사용자 판단 필요 — + `qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤 + 정합성" 절. - [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 @@ -200,7 +221,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 출력 후 즉시 error(provider 초기화 확인 안내 포함 — provider 미주입 상태도 이 경로로 자동 커버, `pre-implementation-audit.md` 1-3/1-4), 핸들러 등록/정렬 시점 동률 감지 print 경고 + - `Dispatch.listHandlers()` 디버그 유틸 + `Dispatch.listHandlers()` 디버그 유틸. **[2026-08-18]** 동률 경고는 + 무조건 찍지 않고 **모듈 표면의 `Quad.debug`(boolean, 기본 `false`)가 + 참일 때만** — `Quad.debug` 자체가 이번에 신설된 새 공개 표면이다 + (`base/module-lifecycle-plan.md`의 "모듈 표면의 디버그 플래그" 절) - [ ] `Dispatch/Leaf.luau` — `(i:number, v=Ref/Observer/PreRef/PostRef)` children-array leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ @@ -234,6 +258,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M3 — Store/State/Source - [ ] `Source.luau`/`State.luau`/`Store.luau` +- [ ] **[2026-08-18 신설]** `store:GetDynamic<>(name): Source` — 런타임에 + 이름이 정해지는 동적 키의 정식 창구(옛 `store "key"` 문자열 커링은 + 기각). **⚠️ 콜론 메소드로 두면 `__index`가 고정 메소드 테이블을 먼저 + 확인해야 하고 `GetDynamic`이 예약 키가 됨** — 탑레벨 함수로 둘지 + 아직 미결(`base/store-plan.md`의 "타입 추론 문제" 절, `question.md` 3번) - [ ] **State 전파 루프 — 구독자는 weak, 발화마다 `canExecute` 게이팅** (2026-08-14 다섯 번째 세션 확정, `base/lifecycle-pattern.md`의 "실제 호출부 — State 전파(`emit`)가 `canExecute`로 게이팅한다" 절) — @@ -295,9 +324,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 호출"로 정정 — 진짜 독립 경로는 둘뿐). **[2026-08-14 다섯 번째 세션에 별도 predicate `canBound(handle)`을 폐기하고 `canExecute` 하나로 합쳤다가, 같은 날 열한 번째 세션에 - 다시 갈라짐]** — "이미 유효하게 묶여 있다"(bound 문맥)와 "지금 - 발화해도 되는가"(execute 문맥)는 판정값은 같아도 호출부의 질문이 - 달라, `Ref` 이중 배치 방지(`question.md` 0-W)를 계기로 `canBound`가 + 다시 갈라짐]** — "지금 묶어도 되는가"(bound 문맥)와 "지금 + 발화해도 되는가"(execute 문맥)는 호출부의 질문이 다르고 + **[2026-08-18 구현 전 QA 정정] 판정값도 같은 게 아니라 서로의 + 부정**이라(`canBound(v) == not canExecute(v)`, 게이트는 항상 + `if not canBound(v) then error(...)`), `Ref` 이중 배치 + 방지(`question.md` 0-W)를 계기로 `canBound`가 별도 진입점으로 재도입됨 — 판정 로직(비공개 `isBoundAlive` 헬퍼)은 공유해 코드 중복은 없음. **이 절이 쓰는 게이트는 이제 `canBound`** (emit 전파 게이팅 전용 `canExecute`가 아님). `.Subscribed` 필드가 @@ -330,7 +362,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M5 — quad-roblox 최소 프로바이더 - [ ] `RobloxFactory.luau`(BaseModule 뮤테이션, 재호출 가드) -- [ ] `DI/init.luau`(제네릭 생성자 + ~25개 정적 필드) +- [ ] `D/init.luau`(제네릭 생성자 `New` + 생성기가 찍는 정적 별칭 필드 — **[2026-08-18]** 범위는 "GUI에 쓰이는 모든 인스턴스", 이벤트 필드의 콜백 타입까지 생성, `base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) - [ ] `Handlers/Property.luau`, `Handlers/InstanceChild.luau` - [ ] **Instance 생성 시점의 gcconn/gchold 셋업**(2026-08-14 다섯 번째 세션 확정, 옛 "`bindLifetime` 첫 호출에서 lazy 생성"에서 전환 — `base/ @@ -362,8 +394,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 비파괴 경로 `unmountSlotTree`를 `destroySlotTree`와 별도로 구현 — 차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함 (자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈). - **쓰는 자리 둘**: `SlotHandler.process`가 반환하는 클로저, `:List`의 - `reconcile`. **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`. + **쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고 + `:List`의 `reconcile` 중 **값 교체와 `PopOnly`(가칭) 경로만**. + **여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고 + **[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이 + `nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의 + "reconcile은 전부 비파괴" 일반화가 `:List`엔 안 맞았음 — + `base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절이 소스). - **해제 시 owner 등록 되돌리는 순서 고정** — `setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**. 반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset @@ -400,6 +437,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State`도 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. + **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 + 해결됨**, 위 M2 항목 참고. - [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/ `Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션, 2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12 @@ -426,9 +465,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔 실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/ Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시 - error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `DI.InstSlot = - Slot<>`(`DI` 네임스페이스 이름 자체는 `question.md` 1번 - 용어정리 대기 중, 여기선 잠정 표기)가 quad-roblox의 사실상 유일한 + error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot = + Slot<>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 — + 옛 `question.md` 1번 용어정리 항목은 해소되어 + `archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한 Slot 타입. - [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정, `keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를 @@ -438,7 +478,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 userdata: UD?): (T|nil, UD?)`가 **매 reconcile 사이클마다 호출** (filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면 - 저비용 재사용 경로. 파라미터 순서는 반환값 순서(`prev`류 먼저, + 저비용 재사용 경로. + **[2026-08-18 신설] `PopOnly`(가칭) 반환 경로** — `updateFn`이 + `PopOnly, { old = ..., source = ... }`를 반환하면 그 자리는 **파괴하지 + 않고 `Parent = nil`로만 내려와** Slot에서 빠지고, 보존은 반환한 + userdata가 담당(다음 사이클에 거기서 `old`를 꺼내 반환하면 재마운트). + `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. + **⚠️ 이름은 가칭이고, "키가 데이터에서 사라졌을 때 PopOnly로 홀드 + 중이던 요소를 어떻게 처분하는가"는 미결** — 착수 전 결론 필요 + (`base/slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저, `userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가 `prev`보다 앞이었음). **`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열 @@ -503,6 +551,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/ `removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은 불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절). + **`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로 + 해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기 + Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).** - [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로 확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음). `initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상 @@ -584,7 +635,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`가 쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** — 그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄 - (`base/dispatch-core-plan.md`) + (`base/dispatch-core-plan.md`). + **[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive`의 `None` 스킵 + 분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`에 + 도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고, + 실제 정리는 아래 `NilHandler`가 맡는다 +- [ ] **[2026-08-18 신설]** `NilHandler` — `isHandlable`이 + `type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러. + `Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)` + 등록이 이 핸들러의 일이고 재귀는 안 함(`State`도 정상 + 동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`의 + "`NilHandler`" 절) - [ ] 프로퍼티류 필드 타입에 `T' = T | Tween` 치환 반영(타입 생성 스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween`로 만들면 끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md` @@ -699,16 +760,17 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 > `AttributeGroupHandler`는 참조 카운트/이름 claim **알고리즘 구현**일 > 뿐 — `HANDLER_PRIORITY_FALLBACK`에 실제로 등록되는 건 이를 감싸는 > 별도 파일 `TagFallbackHandler`/`AttributeKeyFallbackHandler`/ -> `AttributeGroupFallbackHandler`이고, 등록 주체는 quad-base 모듈 -> 자체가 아니라 **백엔드 팩토리**(`RobloxFactory`가 `BaseModule` -> 뮤테이션 시점에 자기 전용 Handler들과 같이 등록). 아래 체크리스트의 +> `AttributeGroupFallbackHandler`이고, **[재역전, 2026-08-18 구현 전 QA] +> 등록 주체는 백엔드 팩토리가 아니라 quad-base 자신**(백엔드 미로드 +> 상태에서도 안내 에러 경로가 돌아야 하기 때문 — +> `base/dispatch-core-plan.md`의 해당 절). 아래 체크리스트의 > `Handler` 파일 항목은 전부 이 구분을 반영하도록 갱신됨 — 뒤집힌 > 옛 모델은 > `archive/tag-attribute-load-time-registration-reversed.md`. - [ ] `Handlers/Event.luau`(`ReflectionService` 기반 자동 판별) -- [ ] `Handlers/OnChange.luau`(`OnChange(name)` DI 키 팩토리+Handler, +- [ ] `Handlers/OnChange.luau`(`OnChange(name)` 특수 키 팩토리+Handler, `GetPropertyChangedSignal` 바인딩 — 제네릭 없이 콜백 타입은 인라인 명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 (`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10 @@ -723,7 +785,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<>(name)` + 이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리 `String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리 - (`Color3Attribute`류)만 quad-roblox의 `D`/`DI` 층에서 각자 추가. + (`Color3Attribute`류)만 quad-roblox의 `D`(Declarative) 층에서 각자 추가. 타입 파라미터화 이름만 착수 전 확인, `base/attribute-plan.md`) - [ ] `quad-base/Dispatch/AttributeKey.luau`(`AttributeKeyHandler` — `setAttribute(inst,name,v)`를 `v`가 뭐든 무조건 호출 + **이름 @@ -735,13 +797,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/ AttributeKeyFallback.luau`(`AttributeKeyFallbackHandler` — 위 `AttributeKeyHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 - 등록되는 별도 이름의 엔티티. 등록 주체는 `RobloxFactory`가 - `BaseModule` 뮤테이션 시점에 자기 전용 Handler들과 같이 — + 등록되는 별도 이름의 엔티티. **[재역전, 2026-08-18] 등록 주체는 + `RobloxFactory`가 아니라 quad-base 자신** — `base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절) - [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1, - store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형 array-part 값 객체, - `base/attribute-plan.md`) + store2, ...)`/`Merged`/**`Overridden`**/`:NameMap`, `Tag`와 동형 + array-part 값 객체, `base/attribute-plan.md`. **[2026-08-18]** + `Merged`는 이름이 겹치면 error, `Overridden`은 뒤가 이김 — 둘 다 제공) - [ ] `quad-base/Dispatch/Attribute.luau`(`AttributeGroupHandler` — 이름마다 **그룹 전용 키**(비공개 `GetKey`, 그룹 값 객체별·이름별 메모이즈)로 `Dispatch.process(inst,key,source,1)`만 부르고, 반환 클로저가 자기가 @@ -755,7 +818,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 AttributeGroupFallback.luau`(`AttributeGroupFallbackHandler` — 위 `AttributeGroupHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 등록되는 별도 이름의 엔티티, 등록 주체는 `AttributeKeyFallbackHandler`와 - 동일하게 `RobloxFactory`) + 동일하게 **quad-base 자신** — [재역전, 2026-08-18]) - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`, `base/tag-plan.md` — 2026-08-08 세 번째 세션 array-part 값 객체로 @@ -772,7 +835,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] **[2026-08-14 열두 번째 세션 신설]** `quad-base/Dispatch/ TagFallback.luau`(`TagFallbackHandler` — 위 `TagHandler`를 그대로 감싸 `HANDLER_PRIORITY_FALLBACK`으로 등록되는 별도 이름의 엔티티, - 등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 `RobloxFactory`) + 등록 주체는 `AttributeKeyFallbackHandler`와 동일하게 **quad-base + 자신** — [재역전, 2026-08-18]) - [ ] **[2026-08-14 세션에 누락 발견, 신규]** `quad-roblox/Handlers/ InstanceShorthand.luau` — UI 편의 숏핸드 `UICorner`/`UIPadding` (+`UIPaddingOffset`)/`UIScale`(`base/ui-shorthand-plan.md`). 이 @@ -822,8 +886,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## 특정 마일스톤에 안 묶이고 병행 가능 -- [ ] 용어 정리 스윕 — `State`/`DI`/`Slot` 등(`PerInstanceState`는 `Relate`로 - 대체·해소됨) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 +- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`로 + 대체·해소됨, `DI`→`D`는 2026-08-18 확정·반영 완료) — `.claude/question.md` 1번, 최종 이름 확정되는 대로 아무 시점에나 - [ ] 각 마일스톤 완료 시 `.claude/qa-request/`/`.claude/archive/`에 기록, 필요하면 `.claude/session-summary.md` "세션 히스토리"도 갱신(전체 원문은 diff --git a/SAFETY.md b/SAFETY.md index cb2a7a5..1a9a354 100644 --- a/SAFETY.md +++ b/SAFETY.md @@ -1,2 +1,37 @@ -- Git 레포지토에리 대해 접근을 주지 마세요: 모델이 어디로 튈 지 모르기에, github 에는 작업하지 않음. 그래서 모델의 작업 공간은 오직 git.qwreey.moe(예를 들어) 에 제한적 계정으로 계정을 생성해 주는것으로 사용. +- Git 레포지토리 접근은 원격별로 용도가 분리되어 있음(2026-08-18 기준, + `qwreey-bot` 제한 계정 도입 후 갱신): + - `origin` (`https://git.qwreey.moe/qwreey-bot/quad.git`) — **메인 + 작업 공간.** 모델이 어디로 튈지 모르니 기본 작업은 GitHub이 아닌 + 여기(비-GitHub 제한 계정)에서만 함. 자유롭게 push 가능. + - `github` (`https://github.com/qwreey-bot/quad`) — `upstream`을 포크한, + 같은 `qwreey-bot` 계정 소유 레포. **사용자가 명시적으로 "싱크"를 + 요청할 때만** push — 그 후 사용자가 GitHub 웹 GUI에서 이 포크 기준 + PR을 만들어 변경사항을 검토함. 기본 push 대상 아님, 사용자 요청 없이 + 먼저 올리지 말 것. + - `upstream` (`https://github.com/qwreey/quad`) — 원본 레포(모델 + 계정 소유 아님). **pull/fetch 전용, 절대 push 금지** — 다른 경로로 + 생긴 변경사항을 당겨오기 위해서만 존재. - Code-docker 활용: 컨테이너 환경으로 한번 격리하여 사용할 수 있도록 유도. studio 환경은 메인 계정이 아닌 다른 계정을 사용하여야함 +- **[2026-08-18 직접 점검 확인]** 모델이 안전하게 쓸 수 있는 유틸 — 아래 + 둘 다 실제로 위험 옵션을 하나씩 시도해 authz/격리가 거부하는지 확인한 + 결과(세션 로그에 전체 시도 목록 있음, 여기는 결론만). + - **code-docker 컨테이너 자체**: 일반 로컬 작업(빌드/테스트/파일 조작)에 + 자유롭게 사용 가능. non-privileged, `SYS_ADMIN`/`NET_ADMIN`/ + `SYS_MODULE`/`SYS_RAWIO` 등 위험 capability 없음, seccomp 필터 + 활성화, PID 네임스페이스 격리(호스트/다른 컨테이너 프로세스 안 보임), + `docker.sock` 등 호스트 제어 소켓 미장착, 루트 파일시스템은 overlay + (호스트 바인드 아님). 호스트 자체도 Proxmox VM이라 컨테이너 탈출 + 성공해도 물리 하이퍼바이저 직행은 아님. 남는 약점은 uid 0로 실행 + + AppArmor `unconfined`뿐 — 알려진 흔한 탈출 경로는 다 막혀 있음. + - **dind**(`DOCKER_HOST=tcp://dind:2375`, code-docker와 같은 호스트 + 네임스페이스 공유): 컨테이너 빌드/실행 용도로 사용 가능, `dind-authz` + 플러그인이 위험 옵션을 화이트리스트 방식으로 차단함 — 직접 확인된 + 거부 목록: `--privileged`, `--cap-add=SYS_ADMIN`/`ALL`, `--pid=host`/ + `--net=host`/`--ipc=host`, 호스트 임의 경로 마운트(`/`, `/etc`, + `docker.sock`, `-v`/`--mount` 문법 둘 다 — **경로 순회(`../..`)로 + 허용 prefix를 흉내내는 우회도 막힘**, 즉 문자열 prefix 매칭이 아니라 + 정규화된 경로로 검사함), `--device` passthrough, + `--security-opt seccomp=unconfined`/`apparmor=unconfined`. 허용되는 + 건 현재 프로젝트 디렉토리(`./`) 바인드 마운트뿐(편의 기능으로 의도된 + 것) — 이 범위를 벗어나는 새 시도를 할 땐 이 목록이 최신인지 다시 + 확인할 것(authz 정책이 바뀌면 이 서술도 갱신 필요).