tooling: 프로젝트 셋업 문서화 + wally→pesde 전환, M1 스캐폴딩 골격

pesde 워크스페이스 실제 설치·검증(패키지명 하이픈 금지, workspace 의존성
문법, per-package pesde.lock), init.luau의 @self require 규칙(Luau RFC
확인), 워크스페이스 의존성이 심볼릭 링크라 luau CLI의 require-by-string과
충돌하는 함정을 base/project-setup-plan.md로 정리. architecture.md
패키징 방식 절도 같이 정정. Relate.luau/New()-InitXxx 골격/mock 하네스는
이 구조를 실제로 검증하는 과정에서 나온 최소 스캐폴딩.

Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
qwreey-agent-selene 2026-08-19 14:01:07 +09:00
parent 14f733dde1
commit 205af32da4
No known key found for this signature in database
19 changed files with 890 additions and 12 deletions

View file

@ -43,7 +43,8 @@
| 문서 | 내용 |
|---|---|
| `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` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 |
| `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` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류. **[2026-08-19 정정]** 패키징 매니저를 wally에서 pesde로 전환 — 세부는 `project-setup-plan.md` |
| `project-setup-plan.md` | **[2026-08-19 신설]** M0/M1 스캐폴딩을 실제로 pesde/`luau` CLI로 굴려보고 검증한 결과 — pesde 워크스페이스 구조(`workspace_members`, 패키지 이름은 하이픈 금지), `rokit.toml` 툴체인 핀(실제 설치·버전 일치 확인), `init.luau`에서 `@self`가 필수인 이유(Luau RFC `abstract-module-paths-and-init-dot-luau`), 워크스페이스 의존성이 심볼릭 링크로 연결되고 `luau` CLI의 require-by-string이 심볼릭 링크를 안 따라가는 실측 함정과 우회, `.luaurc` alias 런타임 미지원 재확인, `pesde.lock` 커밋 권고(잠정). "확인 완료/아직 확인 안 된 것" 절이 다음에 뭘 검증해야 하는지의 소스 |
| `typing-limits.md` | **[2026-08-13 열세 번째 세션 신설]** Luau 타입 시스템이 quad 설계에 대해 **못 해주는 것**을 한 군데 모은 확정 문서 — 여러 `base/` 문서에 캐비엇으로 흩어져 있던 걸 통합. 대전제는 "**Luau의 한계를 우회하려고 타입/API를 비틀지 않는다**"(비틀면 나중에 Luau가 고쳐줘도 자동 수혜를 못 받고 되돌리는 마이그레이션이 생김). 1번 항목이 가장 큼 — **재귀 제네릭이 다른 타입 인자로 자기를 반환하면(`Compute<U>(self: State<T>,...) -> State<U>`) 타입 안전성이 에러 없이 조용히 사라짐**(구 `question.md` 0-Y, 스파이크 다수로 확정 — 근거·개수는 `audit/type-recursion-issue/`). 대응은 두 개: (a) 타입 선언을 "데이터부/메소드부"로 쪼개 콜백 파라미터 추론을 살리고, (b) **파생 State를 만드는 자리마다 결과 타입을 명시 주석으로 바인딩**(그 한 줄만 검증 안 되고 다운스트림 전체는 정상 체크됨). Luau RFC `relax-recursive-type-restriction``Promise<T>.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` 하나를 공유). **[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<<T>>(name)`), 레코드 필드 타이핑은 Luau `type function`으로 해결 확인, `store.key = value` 폐기 → `store.key:Set(value)`(타입 대칭성+lazy 정직성), "Store가 Store를 저장 가능한가"는 **그런 경우를 안 만듦**으로 확정(`State<State<T>>`와는 다른 축) |

View file

@ -177,17 +177,39 @@ quad는 이제 "스크립트"가 아니라 **라이브러리**다. DOMless Roblo
아래가 다음 세션에서 실제로 만들 구조. 지금은 문서 확정까지만, 실제
폴더/`wally.toml`/`project.json` 스캐폴딩은 다음 세션.
**패키징 방식(모노레포, RbxUtil 선례 채택)**: 최종적으로는 여러 개의 독립
wally 패키지로 나누고 싶지만, 지금 Luau 툴링(특히 wally로 설치된 패키지의
타입 정보 단절·`luau-lsp`의 심볼릭 링크 해석 문제 — 최근 `luau-lsp 1.63.0`
에서야 수정됨)이 아직 불안정해서 **당장은 모놀리식**으로 감. `Sleitnick/
RbxUtil`이 정확히 이 패턴(루트 하나로 통합 개발/테스트, 서브폴더마다 자체
`wally.toml`로 독립 퍼블리시)을 쓰는 선례라 그대로 채택. `.luaurc`
`aliases`**런타임 require에서 아직 엔진이 지원 안 함**(Roblox 스태프가
지원 예정이라고만 밝힌 상태, 2026-01 기준) — 그래서 alias는 편집기
자동완성/타입체크용으로만 곁들이고, 실제 크로스패키지 require는 상대경로로
쓴다. 나중에 실제로 레포를 쪼갤 때는 Rojo `project.json`의 트리 매핑 규칙만
유지하면 되고, require는 그 시점에 한 번 기계적으로 바꾸는 정도로 감수.
**패키징 방식(모노레포, RbxUtil 선례 채택) — [2026-08-19 정정] 패키지
매니저를 wally에서 pesde로 전환.** 원래는 wally 툴링 불안정(설치된 패키지의
타입 정보 단절·`luau-lsp` 심볼릭 링크 해석 문제)을 이유로 "최종적으론 독립
패키지로 쪼개고 싶지만 당장은 모놀리식"으로 타협했었는데, **사용자 결정
(2026-08-19): pesde로 간다** — dev-dependency를 1급으로 지원하는 등 wally보다
툴링이 낫다는 판단. **모노레포 자체의 모양(루트 통합 개발, 서브패키지마다
독립 게시)은 안 바뀜** — `Sleitnick/RbxUtil`이 wally로 하던 바로 그 패턴을
pesde는 **네이티브 workspace**(Cargo 워크스페이스와 동형: 루트
`workspace_members` + 멤버 간 `{ workspace = "scope/name", version = "^" }`
의존)로 처음부터 1급 지원하므로, wally가 안고 있던 타입 정보 단절 문제
자체도 이 전환으로 같이 해소될 것으로 기대(단, 이 세션은 pesde 바이너리가
없는 샌드박스라 `pesde install`로 실제 링크 결과까지는 검증 못함 — 실제
설치 검증은 사람이 로컬에서 `rokit install` 이후 확인할 것).
실제 구현: 루트 `pesde.toml`(`private = true`,
`workspace_members = ["quad-base", "quad-roblox"]`) + `quad-base/pesde.toml`/
`quad-roblox/pesde.toml`(각각 `[target] environment = "roblox"`,
`quad-roblox``quad-base`에 workspace 의존), 툴체인은 `rokit.toml`로 핀.
pesde는 "패키지 안에 `default.project.json`을 두지 말 것"이 컨벤션(그
파일은 소비자가 직접 만드는 sync 설정 몫) — 루트의 `default.project.json`
이 규칙의 예외가 아니라 애초에 그 규칙이 가리키는 대상이 아님(워크스페이스
루트 자신의 통합 개발/테스트용, 게시되는 패키지 안이 아니므로).
`.luaurc``aliases`는 여전히 **런타임 require에서 엔진이 지원 안 함**
(2026-08-19 이 세션에 커스텀 alias로 직접 재확인 — `require("@alias/x")`
"could not jump to alias"로 실패) — 그래서 alias는 편집기 자동완성/타입체크용으로만
곁들이고, 실제 크로스패키지 require는 상대경로로 쓴다(단, `init.luau`
안에서는 평범한 상대경로가 아니라 **예약 alias `@self`**를 써야 함 —
`init.luau`는 require-by-string 상 "자기 폴더 자체"를 가리키므로 `./`
아니라 `@self/`로 그 폴더 안의 형제 파일에 접근한다, 2026-08-19 사용자 지적
+ Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인 — 실제로 이
세션의 `quad-base/src/init.luau`가 이 착오로 크로스파일 require가 전부
깨졌다가 `@self`로 고치고 나서야 정상화됨). 나중에 실제로 레포를 쪼갤 때는
Rojo `project.json`의 트리 매핑 규칙만 유지하면 되고, require는 그 시점에
한 번 기계적으로 바꾸는 정도로 감수.
**패키지 경계**: `quad-base`는 다른 렌더 백엔드(GTK 등, 항목 12 참고)에서도
재사용 가능해야 한다는 전제 — Store/State/Source 온톨로지+전파뿐 아니라

View file

@ -0,0 +1,212 @@
# 프로젝트 셋업 — pesde 워크스페이스, `.luaurc`, require 구조
**상태**: base — `architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절이
정한 소스 트리를 **실제로 pesde/luau CLI로 셋업해보고 검증한 결과**. 그
절은 "무엇을 어디에 두는가"까지만 다루고 "그걸 실제로 어떻게 굴리는가"는
비워뒀는데, 이 문서가 그 나머지 — 패키지 매니저 조작, require 문법,
현재 환경에서 확인된 한계까지. **[2026-08-19 신설, 같은 날 pesde 실제
설치·`pesde install` 실행으로 검증]**
**전제**: 이 문서가 서술하는 건 **M0/M1 스캐폴딩 단계에서 확인된 사실**이지
M3 이후 실제 구현이 아님 — `quad-base/src`는 아직 `Relate.luau`/골격
`New()`/`Debug` 서브시스템뿐이고 `quad-roblox/src`는 비어 있음
(`.claude/todos.md`가 여전히 진행 상황의 소스). 여기 적힌 require/pesde
규칙은 실제 소스가 늘어나도 안 바뀔 구조적 사실이라 base로 승격했지만,
"무엇이 구현됐는가"는 이 문서가 아니라 `todos.md`/`ROADMAP.md`를 볼 것.
## 왜 wally가 아니라 pesde인가
**사용자 결정(2026-08-19)**: dev-dependency를 1급으로 지원하는 등 wally보다
툴링이 낫다는 판단. 상세 배경/재검토는 `architecture.md`의 "구현 착수:
소스 트리 구조 확정" 절 "패키징 방식" 문단이 소스 — 여기서 반복하지
않음. 요지만: 모노레포 모양(루트 통합 개발, 서브패키지마다 독립 게시)
자체는 안 바뀌고, pesde의 네이티브 workspace 기능이 그 모양에 그대로
들어맞는다.
## pesde 워크스페이스 구조
```
quad/
├── pesde.toml # 워크스페이스 루트, private = true, workspace_members
├── rokit.toml # pesde/rojo 버전 핀
├── quad-base/
│ └── pesde.toml # name = "qwreey/quad_base"
└── quad-roblox/
└── pesde.toml # name = "qwreey/quad_roblox", quad_base에 workspace 의존
```
- **루트 `pesde.toml`**: `private = true`(게시 안 됨) + `workspace_members =
["quad-base", "quad-roblox"]`. `[target] environment = "roblox"`
필요(공식 workspace 가이드 예제가 루트에도 `[target]`을 요구함 — 이
세션엔 `roblox` 하나만 있어 실제로 검증 안 됨, 필요 여부/의미는 M5 이후
재확인 후보).
- **서브패키지 `pesde.toml`**: `[target] environment = "roblox"` +
`build_files = ["src"]` + `lib = "src/init.luau"`. `quad-roblox`
`[dependencies] quad_base = { workspace = "qwreey/quad_base", version =
"^" }`.
- **⚠️ 패키지 이름은 `a-z`/`0-9`/`_`만 허용 — 하이픈 금지**(`pesde
install` 실측: `qwreey/quad-base`는 파싱 단계에서 바로 거부됨, 에러
메시지가 "did not match any variant of untagged enum
DependencySpecifiers"로 나와서 원인 파악에 혼동을 줌 — 실제 원인은 이름
문자 제약이지 의존성 선언 문법이 아니었음). **`quad_base`/`quad_roblox`로
확정** — 폴더 이름(`quad-base`/`quad-roblox`)은 `architecture.md`
이미 확정해둔 것이라 그대로 두고, `pesde.toml``name` 필드만 언더스코어로
다르게 쓴다. 둘이 다르다는 걸 헷갈리지 말 것.
- **`pesde add`는 워크스페이스 멤버를 자동으로 못 찾는다** — 레지스트리
검색 전용 커맨드라 로컬 워크스페이스 형제를 이름으로 넘기면
"package not found"로 실패한다. `[dependencies]``workspace = "scope/name"`
줄은 **직접 손으로 쓸 것**(위 표기 그대로 — 실제로 `pesde install`
받아들이는 걸 확인함).
- **`pesde install`은 워크스페이스 루트에서 한 번**만 돌리면 전체가
갱신됨(`qwreey/quad`/`qwreey/quad_base`/`qwreey/quad_roblox` 셋 다
스캔·링크).
## 툴체인 — `rokit.toml`
`initreq/vide`(참고 레포)의 `rokit.toml` 선례를 따르되 wally 항목은
뺐다. **[2026-08-19] 이 세션이 `/code/.local/bin`에 pesde 바이너리를
직접 다운로드해 설치·검증 완료** — `pesde 0.7.3`, `rokit.toml`의 핀
(`pesde-pkg/pesde@0.7.3+registry.0.2.3`)과 정확히 일치. `rojo` 핀은
GitHub 릴리스 페이지 조회로만 확인했고 실제 설치·실행 검증은 안 함(Rojo는
Studio 연동이 필요해 `HUMAN_TODO.md` 1번과 같은 처지) — 버전이 오래되면
`rokit add rojo-rbx/rojo`로 다시 확인할 것.
## require 구조 — `@self`가 필수인 이유
**[2026-08-19, 사용자 지적 + Luau RFC `abstract-module-paths-and-init-dot-luau`로 확인]**
`init.luau`라는 파일은 require-by-string 상 **자기가 든 폴더 자체**를
가리키는 특수 취급을 받는다 — 그래서 그 파일 **안에서** 쓰는 상대
경로는 일반 파일과 기준점이 다르다:
- **`init.luau` 안에서 `require("./X")`/`require("../X")`** — 이 폴더
자체가 "자기"이므로, 상대 경로는 **이 폴더의 형제/조상**을 가리킨다.
`quad-base/src/init.luau` 안의 `./Debug``quad-base/src/Debug`
아니라 `quad-base/Debug`를 가리킨다(존재하지 않으면 즉시 에러).
- **`init.luau` 안에서 그 폴더 *안의* 형제 파일에 접근하려면
`@self/X`를 써야 한다** — `@self`는 예약 alias로, 이 모듈(=이 폴더)
자신의 경로로 치환된다. `quad-base/src/init.luau`
`quad-base/src/Debug`에 접근하려면 `require("@self/Debug")`.
- **일반 파일(`init.luau`가 아닌 `*.luau`)에서는 평범한 파일-상대
경로**(`./`/`../`)면 충분 — `@self`가 전혀 필요 없다. 예:
`quad-base/src/Debug/init.luau`(이것도 init.luau라 위 규칙이 적용됨)
안에서 형제 `Relate.luau`(`quad-base/src/Relate.luau`, `Debug`
폴더의 부모에 있음)를 가져오려면 — `Debug/init.luau`의 "자기"는
`Debug` 폴더이므로 그 부모(=`quad-base/src`)에 있는 `Relate.luau`
형제 폴더 취급 → `require("./Relate")`(◯), `require("../Relate")`(✕,
한 단계 더 올라가 `quad-base/Relate`를 찾으려다 실패).
**실측 근거**: `tbox`(`initreq/tbox`, 다른 참고 레포)의 `src/init.luau`
`require("@self/types")`/`require("@self/schema/string")` 패턴을
실제로 쓰고 있어 교차 확인됨. 이 세션에서 `quad-base/src/init.luau`
처음에 `require("./Debug")`로 잘못 짜여 크로스파일 require가 전부
깨졌었고(런타임은 크래시, `luau-analyze`**조용히** `Unifiable<Error>`
새며 0 진단으로 통과 — 이것도 `typing-limits.md`의 "실측 방법 주의"
경고("`luau-analyze`가 진단 0건이어도 타입이 제대로 해소됐다는 뜻이
아닙니다")가 가리키는 것과 같은 종류의 함정, 다만 원인은 재귀 제네릭이
아니라 require 경로 오류라 그 문서 1번 항목과는 별개 사례), `@self`
고치자 즉시 정상화됨(둘 다 clean).
**체크리스트**: 새 `init.luau`를 짤 때마다 "이 파일 안의 `require`
같은 폴더 안의 형제를 가리키는가, 아니면 이 폴더의 형제/조상을
가리키는가"를 먼저 물을 것 — 전자면 `@self/`, 후자면 `./``../`.
## 워크스페이스 의존성은 심볼릭 링크로 연결된다 — CLI 테스트의 함정
**[2026-08-19 실측]** `pesde install`이 워크스페이스 멤버 간 의존성을
해소하는 방식은 **심볼릭 링크**다 — `quad-roblox/roblox_packages/`
안에 실제로 이렇게 생긴다:
```
quad-roblox/roblox_packages/
├── quad_base.luau # 얇은 링커: return require("./.pesde/qwreey+quad_base/0.0.0/quad_base/src")
└── .pesde/qwreey+quad_base/0.0.0/quad_base/
├── src -> ../../../../../quad-base/src (symlink)
├── test -> ../../../../../quad-base/test (symlink)
├── pesde.toml -> ... (symlink)
└── pesde.lock -> ... (symlink)
```
**⚠️ 문제**: Luau의 standalone require-by-string 구현은 **심볼릭 링크를
안 따라간다** — 의도된 설계다(Luau RFC 검색 결과: "보안 상의 이유로
symlink는 일반 파일처럼 취급되고 따라가지 않는다", 추후 `.luaurc`
opt-in 토글이 추가될 수 있다고만 언급됨, 아직 없음). 직접 재현:
```lua
-- entry.luau, ./linked가 실제 폴더로의 symlink일 때
local v = require("./linked")
-- error requiring module "./linked": could not resolve child component "linked"
```
**실무 영향**: `quad-roblox`가 실제로 `quad_base`를 쓰게 되면(M5+),
표준 경로(`require(".../roblox_packages/quad_base")`)는 **`luau` CLI로
직접 못 돌린다** — `could not resolve child component`로 즉시 깨짐.
Roblox Studio/Rojo sync는 이 문제와 무관할 가능성이 높음(Rojo는 Luau의
require 리졸버가 아니라 평범한 파일시스템 워크로 트리를 만듦 — **단,
이 세션은 Rojo가 설치돼 있지 않아 실측은 못 했음, `HUMAN_TODO.md`
Studio 연동이 되면 같이 확인할 것**).
**임시 우회(M0/M1식 CLI 스파이크/mock 테스트 한정)**: `roblox_packages/`
거치지 말고 실제 형제 패키지 경로를 직접 가리킬 것 — 예:
`quad-roblox/src`에서 검증용 스크립트를 짤 때
`require("../../quad-base/src")`처럼. **프로덕션 `quad-roblox` 소스
자체는 그대로 표준 pesde 경로(`roblox_packages/quad_base`)를 쓸 것** —
Rojo/Studio가 실제로 소비하는 건 그 경로고, 위 우회는 어디까지나 이
환경의 CLI 스파이크 테스트용 임시 조치. Rojo의 symlink 처리가 실측
확인되면 이 절 자체가 불필요해질 수 있음(그때 다시 볼 것).
## `.luaurc` — alias는 여전히 편집기 전용
`.luaurc``aliases`(`@quad-base`/`@quad-roblox`)는 **런타임
require에서 여전히 안 먹는다** — `architecture.md`가 이미 이렇게
서술해뒀던 걸 이 세션에 직접 재확인: `require("@quad-base/Debug")`
실행하면 `could not jump to alias "quad-base/src"`로 실패(별도 에러
메시지라 위 심볼릭 링크 문제와는 다른 원인 — alias 자체가 런타임
미지원이라는 뜻, `@self`는 **예약 alias**라 이 제약과 무관하게 항상
동작하는 것과 구분할 것). 그래서 alias는 편집기 자동완성/타입체크
용도로만 남기고, 실제 require는 위 규칙대로 상대경로 + `@self`.
## `pesde.lock` — 커밋 권고 (미확정, 사용자 판단 필요)
**[2026-08-19 실측, 최초 서술 정정]** 처음엔 "워크스페이스 루트에 딱
하나만 생긴다"고 적었으나 **틀렸음** — 실제로는 `pesde install`
**워크스페이스 멤버마다 각자의 `pesde.lock`도 같이 만든다**(루트
`pesde.lock` 1개 + `quad-base/pesde.lock` + `quad-roblox/pesde.lock`,
총 3개). 루트 것은 `[workspace."qwreey/quad_base"]`류 멤버 매핑만
담고, 멤버 것들은 각자의 실제 의존성 그래프를 담는다(`quad_roblox`의
lock엔 `[graph."qwreey/quad_base@0.0.0 roblox"]` + `pkg_ref.ref_ty =
"workspace"`가 있음, `quad_base`는 의존성이 없어 메타데이터만).
**이 세션의 잠정 권고는 셋 다 커밋**: Cargo 생태계의 "라이브러리는
lockfile을 커밋하지 않는다" 관행이 여기 그대로 적용 안 되는 이유는, 이
lockfile들이 **게시되는 대상이 아니기** 때문 — 루트는 `private = true`,
`quad-base`/`quad-roblox`의 `pesde.toml``includes = ["src/*"]`뿐이라
`pesde.lock`은 애초에 게시물에 안 들어감(외부 소비자는 이 파일들을 절대
못 봄 — 자기 프로젝트에서 새로 resolve함). 그래서 "라이브러리 lockfile
딜레마" 자체가 성립하지 않고, 그냥 "이 모노레포를 체크아웃한 개발자/CI가
재현 가능한 빌드를 얻는가" 문제로 좁혀지는데 그건 커밋하는 쪽이 유리 —
**다만 이건 이 세션의 판단이고 최종 확정 아님**, `todos.md`에 확인 필요
항목으로 반영.
## 확인 완료 / 아직 확인 안 된 것
**확인 완료(이 세션, 실제 pesde/luau 실행 근거)**:
- pesde 워크스페이스 설치가 3개 패키지(루트+2서브) 전부에 대해 성공
- 패키지 이름 문자 제약(하이픈 금지)
- `workspace = "scope/name"` 의존성 선언 문법
- `@self``init.luau`의 형제 파일 접근에 필수라는 것(런타임+
`luau-analyze` 양쪽)
- `.luaurc` alias가 런타임에서 여전히 안 먹는다는 것(재확인)
- 워크스페이스 의존성이 symlink로 연결되고, 그게 `luau` CLI의
require-by-string과 충돌한다는 것(직접 재현 + Luau RFC로 원인 확인)
**아직 확인 안 됨(다음에 도구/환경이 갖춰지면)**:
- 루트 `pesde.toml``[target]` 섹션이 실제로 의미가 있는지(지금은
workspace 가이드 예제를 그대로 따라 둔 것, 검증 안 됨)
- Rojo가 이 symlink들을 실제로 따라가서 Studio에 정상 동기화하는지 —
`HUMAN_TODO.md` 1번(Studio 연동)이 되면 확인
- `quad_base` 설치 시 나온 "`roblox_sync_config_generator` 스크립트가
없으면 linking에 문제가 생길 수 있다"는 WARN의 실제 영향 범위 — 지금은
install 자체를 막지 않아서 방치, 실제 Rojo 동기화 단계에서 문제가
드러나면 그때 pesde 문서의 `[target.scripts]` 절을 찾아볼 것
- `pesde.lock` 커밋 여부 최종 확정(위 절 권고는 잠정)

View file

@ -0,0 +1,107 @@
# 2026-08-19, 네 번째 세션 — M0/M1 스캐폴딩 첫 시도, wally→pesde 전환, `@self` require 함정
**요약**: 사용자 요청으로 M0(스파이크)/M1(스캐폴딩)을 revert 가능한
상태로 실제로 짜보며 문제를 찾는 시도. 그 과정에서 진짜 문제(require
경로 버그)를 하나 찾았는데 원인 진단이 틀렸었고, 사용자가 직접 정답
(`@self`)을 지목해 정정. 이어서 사용자가 패키지 매니저를 wally에서
pesde로 바꾸자고 결정, tbox 참고 후 실제 pesde를 설치해 워크스페이스
전체를 검증. 산출물은 `.claude/base/project-setup-plan.md`(신설)와
`architecture.md`의 "패키징 방식" 절 정정.
## 1. M0/M1 첫 실제 시도
`ROADMAP.md` M0(스파이크 3종)/M1(스캐폴딩)을 실제로 Luau로 짜봄:
- M0 항목 3(재귀 재-dispatch)은 기존 스파이크 `03`을 재실행해 여전히
유효함만 확인(재작성 불필요).
- M0 항목 1(다이아몬드 전파)은 `05-store-state-diamond-propagation.luau`
"emit은 항상 전파 + `:Get()` 시점 캐시로만 dedup" 현행 모델로 재작성,
통과 후 `done/`으로 이동.
- `todos.md`가 M0 항목으로 요구하던 "Store 미선언 키가 타입 에러 나는가"도
새 스파이크 `21`로 확인 — `luau-analyze`가 정확히 2건의 `TypeError`
거부함을 확인(사용자의 "아마 그럴 것" 추측이 맞았음).
- M1: `quad-base/`, `quad-roblox/` 폴더, `Relate.luau`(전량 구현),
`Debug/init.luau` + 최상위 `init.luau`(`New()`/`InitXxx` 팩토리
체이닝 + `Relate` 기반 멱등 가드), `quad-base/test/mock.luau`(최소
mock) + 스모크 테스트까지 작성.
## 2. require 버그 — 원인을 잘못 짚었다가 사용자가 정정
`quad-base/src/init.luau`(`require("./Debug")`)가 크로스파일 require
전부 실패. 여러 각도로 재현하다 "standalone `luau` CLI가 relative
require를 **process CWD** 기준으로 푼다"는 결론을 냈고, 이 결론으로
첫 보고를 마쳤음(**틀린 진단**).
사용자가 바로잡음: *"init.luau 는 상위 폴더를 자신으로 만들어낸다는
의미라, @self 로 주변 요소를 접근해야해"* + Luau 공식 문서 링크 제공.
`rfcs.luau.org/abstract-module-paths-and-init-dot-luau`를 확인한 결과:
`init.luau`는 require-by-string 상 **자기가 든 폴더 자체**를 가리키는
특수 케이스라, 그 안의 `./X`는 그 폴더의 형제를 가리키고, 폴더
**안의** 형제 파일을 가리키려면 예약 alias `@self/X`가 필요함 — CWD와는
무관한 문제였음. `quad-base/src/init.luau``require("./Debug")`
`require("@self/Debug")`, `Debug/init.luau``require("../Relate")`
`require("./Relate")`로 고치자 즉시 정상화(런타임 clean, `luau-analyze`
0 진단). 부수로 `Relate.luau`의 진짜 타입 내로잉 버그 2건도 이때 처음
드러나 같이 고침(전에는 require가 안 뚫려 그 부분이 타입체크 자체를 안
받고 있었음).
**교훈**: CWD 기반이라는 첫 결론은 "여러 재현 케이스가 다 맞아떨어졌다"는
확신 때문에 유지했는데, 실제로는 초기 가설(구조적 require 특수 케이스)을
검증 안 하고 다른 잘못된 가설로 건너뛴 것 — 사용자가 정확한 1차 소스
(공식 문서)를 제시해줘서 빠르게 정정됨.
## 3. tbox 확인 → pesde 결정 → 실제 설치·검증
사용자 요청: `tbox`(`initreq/tbox`) 확인 후 "pesde로 가야 할 것 같다"
(dev-dependency 등 더 나은 툴링). `tbox`엔 pesde/wally 설정 자체가 없었지만
(독립 스키마 라이브러리, 패키지 매니저 미사용), `src/init.luau`
`require("@self/...")` 패턴을 실제로 쓰고 있어 위 2번 정정을 교차
확인해줬고, `.vscode/settings.json``enableNewSolver: true`를 이미
켜둔 것도 `HUMAN_TODO.md` 6번(에디터 솔버 확인)에 참고 근거로 남음.
pesde 실물 설정은 `initreq/vide`(`pesde.toml`+`rokit.toml` 보유)를
템플릿으로 씀. 이어서 사용자가 직접 pesde 공식 설치 문서 링크를 주고
`/code/.local/bin`(이미 PATH)에 설치해보라고 요청 — GitHub 릴리스에서
`pesde-0.7.3-linux-x86_64.zip`을 받아 압축 해제 후 그 경로에 배치,
`pesde 0.7.3` 확인(`rokit.toml`의 핀과 정확히 일치).
**실제 `pesde install`을 워크스페이스 루트에서 돌려서 나온 것들**(전부
`.claude/base/project-setup-plan.md`에 정리):
1. 패키지 이름에 하이픈 불가(`a-z`/`0-9`/`_`만) — `qwreey/quad-base`
파싱 단계에서 거부됨(에러 메시지가 원인을 안 알려줘서 처음엔 의존성
선언 문법이 잘못된 줄 알았음). `quad_base`/`quad_roblox`로 고침.
2. `workspace = "scope/name"` 의존성 문법은 원래 손으로 쓴 그대로
맞았음(이름만 고치니 바로 통과) — `pesde add`는 워크스페이스 멤버를
못 찾는다는 것도 같이 확인(레지스트리 전용 커맨드).
3. `pesde.lock`은 워크스페이스 루트에 딱 하나만 생김.
4. **가장 중요한 발견** — 워크스페이스 의존성은 **심볼릭 링크**로
연결됨(`roblox_packages/.pesde/scope+pkg/version/pkg/src` →
실제 형제 패키지 경로). 실제로 `quad_base``quad-roblox`에서
`require`하는 스모크 테스트를 짜보니 "could not resolve child
component 'src'"로 깨짐 — 직접 격리 재현(`/tmp`에 symlink 하나만
만들어 `require`) 후 원인이 **Luau의 require-by-string이 symlink를
의도적으로 안 따라간다**(보안상의 이유, RFC 검색으로 확인, 향후
`.luaurc` opt-in 토글 가능성만 언급되고 아직 없음)로 확정.
`quad-roblox`가 실제로 `quad_base`를 쓰게 될 M5부터 이 문제가
현실화됨 — Rojo/Studio는 아마 무관(파일시스템 워크라 symlink를 그냥
따라갈 가능성이 높음)이지만 이 세션엔 Rojo가 없어 미검증.
## 4. 산출물
- `.claude/base/project-setup-plan.md` 신설 — 위 내용 전부 정리,
"확인 완료/아직 확인 안 된 것" 절로 후속 검증 항목 명시.
- `.claude/base/architecture.md` "패키징 방식" 절 — wally→pesde 전환 반영.
- `.gitignore``roblox_packages/`/`luau_packages/`/`.pesde/` 추가.
- `pesde.toml`(루트+quad-base+quad-roblox), `rokit.toml`, `.luaurc`,
`default.project.json` 신설.
- `quad-base/src/{Relate.luau,init.luau,Debug/init.luau}`,
`quad-base/test/{mock.luau,smoke.mock.luau}` 신설.
- `luau-test/05`(다이아몬드 전파, 재작성 후 `done/`), `luau-test/21`(Store
미선언 키, 신규) — 둘 다 `STATUS.md` 표 텍스트는 아직 안 고침(스스로
발견한 것 — 다음에 손댈 것).
## 5. 다음
사용자가 이 세션 산출물(문서화 + 셋업 파일)만 먼저 커밋하길 원함 —
`luau-test` 스파이크 변경은 별도로 취급. 커밋 후 "지금 상황에서 검증
가능한 것들"을 계속 검증하는 방향으로 이어감(구체 목록은
`project-setup-plan.md`의 "아직 확인 안 됨" 절 + `todos.md`).

7
.gitignore vendored
View file

@ -3,3 +3,10 @@
# Python 바이트코드 — doc-check.py 실행 시 생김(32e9db0에 실수로 딸려 들어갔었음)
__pycache__/
*.pyc
# pesde 설치 산출물(각 패키지 로컬에 생김) — pesde.lock 커밋 여부는 아직
# 미정(라이브러리 컨벤션 확인 필요, HUMAN_TODO 참고)이라 여기 안 넣음
roblox_packages/
luau_packages/
lune_packages/
.pesde/

10
.luaurc Normal file
View file

@ -0,0 +1,10 @@
{
"languageMode": "strict",
"lint": {
"*": true
},
"aliases": {
"quad-base": "quad-base/src",
"quad-roblox": "quad-roblox/src"
}
}

15
default.project.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "quad",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"quad-base": {
"$path": "quad-base/src"
},
"quad-roblox": {
"$path": "quad-roblox/src"
}
}
}
}

12
pesde.lock Normal file
View file

@ -0,0 +1,12 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad"
version = "0.0.0"
target = "roblox"
[workspace."qwreey/quad_base"]
roblox = "quad-base"
[workspace."qwreey/quad_roblox"]
roblox = "quad-roblox"

13
pesde.toml Normal file
View file

@ -0,0 +1,13 @@
# 워크스페이스 루트 — 그 자체로는 게시되지 않음(`private = true`).
# `quad-base`/`quad-roblox`가 실제 게시 대상 워크스페이스 멤버.
# 구조는 `.claude/base/architecture.md` "구현 착수: 소스 트리 구조 확정" 절의
# RbxUtil식 모노레포(루트 통합 개발, 서브패키지마다 독립 게시)를 그대로 따르되
# 패키지 매니저만 wally -> pesde로 전환(사용자 결정, 2026-08-19 — dev-dependency
# 지원이 더 낫다는 판단).
name = "qwreey/quad"
version = "0.0.0"
private = true
workspace_members = ["quad-base", "quad-roblox"]
[target]
environment = "roblox"

6
quad-base/pesde.lock Normal file
View file

@ -0,0 +1,6 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad_base"
version = "0.0.0"
target = "roblox"

9
quad-base/pesde.toml Normal file
View file

@ -0,0 +1,9 @@
name = "qwreey/quad_base"
version = "0.0.0"
description = "quad — 엔진 무관 코어: Store/State/Source 온톨로지, pluggable 디스패치 엔진"
includes = ["src/*"]
[target]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"

View file

@ -0,0 +1,25 @@
--[[
InitDebug(module) — `module.debug` 표면 설치.
`.claude/base/module-lifecycle-plan.md` "모듈 표면의 디버그 플래그" /
"New()의 내부 구성 — InitXxx 팩토리 체이닝" 절 그대로.
각 InitXxx가 따르는 멱등 가드 모양의 첫 실제 사례 — 파일 스코프
`relate`(클로저 밖)로 "이 particular module 인스턴스에 이미 Init됐는가"를
기록한다.
]]
local Relate = require("./Relate")
local relate = Relate() -- 파일 스코프, 이 Init 전체가 공유하는 단 하나의 인스턴스
local INITED = {} -- 이 파일 전용 유일 키(다른 InitXxx의 relate와 네임스페이스가 안 겹침)
local function Init(module: any)
if relate:GetStrong(module, INITED) then
return -- 이미 이 module 인스턴스엔 Init됨, no-op
end
relate:SetStrong(module, INITED, true) -- 실제 작업 전에 먼저 표시(순환 의존 대비)
module.debug = false
end
return Init

92
quad-base/src/Relate.luau Normal file
View file

@ -0,0 +1,92 @@
--[[
Relate — inst를 weak 키로 하는 범용 릴레이션 프리미티브.
`.claude/base/relate-plan.md` "API"/"실제 구조" 절 그대로 구현.
]]
export type Relate = {
SetStrong: (self: Relate, inst: any, key: any, value: any) -> (),
GetStrong: (self: Relate, inst: any, key: any) -> any?,
SetWeak: (self: Relate, inst: any, key: any, value: any) -> (),
GetWeak: (self: Relate, inst: any, key: any) -> any?,
}
type Bucket = {
StrongMap: { [any]: any }?,
WeakMap: { [any]: any }?,
}
-- 모든 WeakMap이 공유하는 단일 메타테이블 — 매번 새로 만들 이유가 없음
local WEAK_VALUE_MT = { __mode = "v" }
local RelateImpl = {}
RelateImpl.__index = RelateImpl
local function getBucket(self, inst: any): Bucket?
return self.buckets[inst]
end
local function getOrCreateBucket(self, inst: any): Bucket
local bucket = self.buckets[inst]
if bucket == nil then
bucket = {} :: Bucket
self.buckets[inst] = bucket
end
return bucket
end
function RelateImpl:SetStrong(inst: any, key: any, value: any)
local bucket = getOrCreateBucket(self, inst)
local strongMap = bucket.StrongMap
if strongMap == nil then
local newMap = {}
bucket.StrongMap = newMap
newMap[key] = value
return
end
strongMap[key] = value
end
function RelateImpl:GetStrong(inst: any, key: any): any?
local bucket = getBucket(self, inst)
if bucket == nil then
return nil
end
local strongMap = bucket.StrongMap
if strongMap == nil then
return nil
end
return strongMap[key]
end
function RelateImpl:SetWeak(inst: any, key: any, value: any)
local bucket = getOrCreateBucket(self, inst)
local weakMap = bucket.WeakMap
if weakMap == nil then
local newMap = setmetatable({}, WEAK_VALUE_MT)
bucket.WeakMap = newMap
newMap[key] = value
return
end
weakMap[key] = value
end
function RelateImpl:GetWeak(inst: any, key: any): any?
local bucket = getBucket(self, inst)
if bucket == nil then
return nil
end
local weakMap = bucket.WeakMap
if weakMap == nil then
return nil
end
return weakMap[key]
end
local function Relate(): Relate
local self = setmetatable({
buckets = setmetatable({}, { __mode = "k" }) :: { [any]: Bucket },
}, RelateImpl)
return (self :: any) :: Relate
end
return Relate

26
quad-base/src/init.luau Normal file
View file

@ -0,0 +1,26 @@
--[[
quad-base 최상위 진입점.
`.claude/base/architecture.md` 확정 결정 13 / `.claude/base/module-lifecycle-plan.md`
"New()의 내부 구성 — InitXxx 팩토리 체이닝" 절 그대로.
`require(quad-base)`는 이미 만들어진 기본 인스턴스 자체다 — 다중
인스턴스화가 필요한 드문 경우에만 반환값의 `.New()`를 명시적으로 호출.
]]
local InitDebug = require("@self/Debug")
type Quad = {
New: () -> Quad,
debug: boolean,
}
local function New(): Quad
local module = { New = New } :: Quad
InitDebug(module)
-- 서브시스템이 늘어날 때마다 이 자리에 InitXxx(module)를 순서 무관하게 추가
return module
end
return New()

227
quad-base/test/mock.luau Normal file
View file

@ -0,0 +1,227 @@
--[[
quad-base용 최소 mock — Vide `test/mock.luau` 선례를 따르되 범위를 더
좁힘(`.claude/base/architecture.md` "테스트 전략" 절): parent/children
트리 + 타입 검증 없는 property bag + property별 변경 시그널만. `IsA()`/
클래스별 프로퍼티 스키마/`WaitForChild`/`DataModel`은 만들지 않는다 —
quad-base 코어는 `inst`를 `any`로 취급하고 Instance 특정 동작을 참조하지
않으므로 mock이 실제 Roblox 충실도를 가질 이유가 없다(같은 절).
Vide의 mock과 달리 GC-독립 userdata 프록시 트릭을 안 씀 — 그건 Roblox
엔진 userdata의 GC 동일성을 흉내내려는 것이고, 그 동일성 문제는
quad-roblox의 LifetimeHandle(gcconn 트릭)이 다루는 자리라 quad-base
정적 스냅샷 테스트 범위 밖.
]]
export type Signal = {
Connect: (self: Signal, fn: (...any) -> ()) -> Connection,
Fire: (self: Signal, ...any) -> (),
}
export type Connection = {
Connected: boolean,
Disconnect: (self: Connection) -> (),
}
local Signal = {}
Signal.__index = Signal
local function newSignal(): Signal
return (setmetatable({ connections = {} }, Signal) :: any) :: Signal
end
function Signal:Connect(fn: (...any) -> ()): Connection
local connections = (self :: any).connections
local conn = { Connected = true, fn = fn }
function conn.Disconnect(self2)
if not self2.Connected then
return
end
self2.Connected = false
local i = table.find(connections, conn)
if i then
table.remove(connections, i)
end
end
table.insert(connections, conn)
return (conn :: any) :: Connection
end
function Signal:Fire(...: any)
-- 발화 도중 연결이 끊기는 경우를 대비해 스냅샷을 뜬 뒤 순회(Vide 선례)
local snapshot = table.clone((self :: any).connections)
for i = #snapshot, 1, -1 do
local conn = snapshot[i]
if conn.Connected then
conn.fn(...)
end
end
end
export type MockInstance = {
ClassName: string,
Name: string,
Parent: MockInstance?,
Destroying: Signal,
GetPropertyChangedSignal: (self: MockInstance, property: string) -> Signal,
GetChildren: (self: MockInstance) -> { MockInstance },
FindFirstChild: (self: MockInstance, name: string) -> MockInstance?,
Destroy: (self: MockInstance) -> (),
[string]: any, -- 타입 검증 없는 property bag
}
type Data = {
className: string,
name: string,
parent: Data?,
children: { Data },
changed: { [any]: Signal },
properties: { [any]: any },
destroying: Signal,
}
local dataOf = setmetatable({}, { __mode = "k" }) :: { [any]: Data }
local proxyOf = setmetatable({}, { __mode = "v" }) :: { [Data]: any }
local methods = {}
local function fireChanged(data: Data, key: any)
local sig = data.changed[key]
if sig then
sig:Fire()
end
end
local function getData(inst: any): Data
local data = dataOf[inst]
if data == nil then
error("not a mock instance", 2)
end
return data
end
local function getProxy(data: Data): any
local existing = proxyOf[data]
if existing then
return existing
end
local proxy = setmetatable({}, {
__index = function(_, key)
if methods[key] then
return methods[key]
elseif key == "Name" then
return data.name
elseif key == "ClassName" then
return data.className
elseif key == "Parent" then
if data.parent == nil then
return nil
end
return getProxy(data.parent)
elseif key == "Destroying" then
return data.destroying
else
return data.properties[key]
end
end,
__newindex = function(_, key, value)
if key == "Name" then
if type(value) ~= "string" then
error("Name must be a string", 2)
end
data.name = value
elseif key == "Parent" then
assert(value == nil or dataOf[value] ~= nil, "attempt to set non-instance as Parent")
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
end
if value == nil then
data.parent = nil
else
local newParentData = getData(value)
data.parent = newParentData
table.insert(newParentData.children, data)
end
else
data.properties[key] = value
end
fireChanged(data, key)
end,
})
dataOf[proxy] = data
proxyOf[data] = proxy
return proxy
end
local Instance = {}
function Instance.new(className: string): MockInstance
local data: Data = {
className = className,
name = className,
parent = nil,
children = {},
changed = {},
properties = {},
destroying = newSignal(),
}
return (getProxy(data) :: any) :: MockInstance
end
function methods.GetPropertyChangedSignal(inst: any, property: string): Signal
local data = getData(inst)
local sig = data.changed[property]
if sig == nil then
sig = newSignal()
data.changed[property] = sig
end
return sig
end
function methods.GetChildren(inst: any): { any }
local data = getData(inst)
local out = table.create(#data.children)
for i, childData in data.children do
out[i] = getProxy(childData)
end
return out
end
function methods.FindFirstChild(inst: any, name: string): any?
local data = getData(inst)
for _, childData in data.children do
if childData.name == name then
return getProxy(childData)
end
end
return nil
end
function methods.Destroy(inst: any)
local data = getData(inst)
data.destroying:Fire()
if data.parent then
local siblings = data.parent.children
local i = table.find(siblings, data)
if i then
table.remove(siblings, i)
end
data.parent = nil
fireChanged(data, "Parent")
end
end
local function isMockInstance(value: any): boolean
return dataOf[value] ~= nil
end
return {
Instance = Instance,
newSignal = newSignal,
isMockInstance = isMockInstance,
}

View file

@ -0,0 +1,59 @@
--[[ 임시 스모크 테스트 — mock.luau 자체가 기대대로 동작하는지 확인 ]]
local mock = require("./mock")
local Instance = mock.Instance
print("=== 1. property bag + changed signal ===")
do
local frame = Instance.new("Frame")
local fired = {}
frame:GetPropertyChangedSignal("Visible"):Connect(function()
table.insert(fired, frame.Visible)
end)
frame.Visible = false
frame.Visible = true
assert(#fired == 2 and fired[1] == false and fired[2] == true, "changed signal must fire per write with the new value visible")
print("PASS")
end
print()
print("=== 2. parent/children tree ===")
do
local root = Instance.new("Frame")
local child1 = Instance.new("Frame")
local child2 = Instance.new("Frame")
child1.Name = "A"
child2.Name = "B"
child1.Parent = root
child2.Parent = root
local children = root:GetChildren()
assert(#children == 2, "expected 2 children")
assert(root:FindFirstChild("A") == child1)
assert(root:FindFirstChild("B") == child2)
assert(child1.Parent == root)
child1.Parent = nil
assert(#root:GetChildren() == 1, "removing Parent must detach from old parent's children")
print("PASS")
end
print()
print("=== 3. Destroy fires Destroying + detaches ===")
do
local root = Instance.new("Frame")
local child = Instance.new("Frame")
child.Parent = root
local destroyed = false
child.Destroying:Connect(function()
destroyed = true
end)
child:Destroy()
assert(destroyed, "Destroying must fire on Destroy")
assert(#root:GetChildren() == 0, "Destroy must detach from parent")
print("PASS")
end
print()
print("=== ALL PASS ===")

13
quad-roblox/pesde.lock Normal file
View file

@ -0,0 +1,13 @@
# This file is automatically @generated by pesde.
# It is not intended for manual editing.
format = 2
name = "qwreey/quad_roblox"
version = "0.0.0"
target = "roblox"
[graph."qwreey/quad_base@0.0.0 roblox"]
direct = ["quad_base", { workspace = "qwreey/quad_base", version = "^" }, "standard"]
[graph."qwreey/quad_base@0.0.0 roblox".pkg_ref]
ref_ty = "workspace"
path = "quad-base"

12
quad-roblox/pesde.toml Normal file
View file

@ -0,0 +1,12 @@
name = "qwreey/quad_roblox"
version = "0.0.0"
description = "quad — Roblox 백엔드: quad-base의 pluggable 인터페이스를 실제로 구현"
includes = ["src/*"]
[target]
environment = "roblox"
build_files = ["src"]
lib = "src/init.luau"
[dependencies]
quad_base = { workspace = "qwreey/quad_base", version = "^" }

10
rokit.toml Normal file
View file

@ -0,0 +1,10 @@
# Rokit-관리 툴체인. `vide`(`.claude/initreq/vide/rokit.toml`) 선례를 따르되
# wally 항목은 뺌(pesde로 전환, 2026-08-19 사용자 결정).
#
# ⚠️ 버전 핀은 이 세션이 웹에서 확인한 시점 기준 — 이 샌드박스엔 rokit/pesde
# 바이너리가 없어 실제로 `rokit install`을 돌려 검증하지 못했다. 실제
# 설치 전에 `rokit add pesde-pkg/pesde` / `rokit add rojo-rbx/rojo`로
# 최신 태그를 직접 재확인할 것.
[tools]
pesde = "pesde-pkg/pesde@0.7.3+registry.0.2.3"
rojo = "rojo-rbx/rojo@7.7.0"