feat: M3 단위 1 — 디스패치 코어(InitDispatch) + spec 둘, H-212~H-214
- Dispatch/init.luau: InitDispatch(module) 팩토리(§6) — 인스턴스별 레지스트리 +chains(Relate), getHandler(순수 스캔)/process(하강 diff (A)/(B), 점유 마커, SetStrong 선행, retractor 생략 즉시 error)/3-인자 retractFrom(꼬리 역순, 구멍 error level 1)/addHandler(등록 시 정렬, 동률 경고는 module.debug)/ listHandlers(순수 조회)/drive((b) 본체 루프만 — ⓪⓪' 단위 2, (a)(c) M8). 매치 실패는 typeof+브랜드(is* 프로브)+provider 안내, 지연 생성 - spec.dispatch.luau(12절: (A)/(B)·깊은 체인 힌트·같은 핸들러 두 슬롯·조건부 재위임·다른 키 위임·동률·격리) / spec.drive.luau(F-4-1 언어 동작 실측) — 전부 PASS, luau-analyze·selene 클린 - H-212(①): base 의사코드 error 셋이 error 계약(영어+level, 08-25) 이전 표기 — dispatch-core-plan.md와 코드 같은 커밋 정정 - H-213(①): HANDLER_PRIORITY_* 리터럴 값은 문서 미정 — 1000/0/-1000/-1000000 - H-214(②): listHandlers·동률 경고가 원하는 핸들러 "이름"이 계약 3종에 없음 — round12 §4 문항 등재, 코드는 TODO(H-214) 마커 1곳 - 스파이크 01 폐기(직전 커밋에서 done/ 이동): spec.drive가 같은 질문을 상시 회귀로 대체(§6 승인) — STATUS.md·ROADMAP 재검증 대기 절 [x] - ROADMAP M3 체크박스: 단위 1 몫 여섯 [x](drive 범위 절단 주석 포함) Co-authored-by: qwreey <me@qwreey.moe> Claude-Session: https://claude.ai/code/session_01LF78pXeFGD1ZSVD3ifteYG
This commit is contained in:
parent
590b0fe6a1
commit
a6d0c7f9a7
8 changed files with 710 additions and 21 deletions
|
|
@ -979,7 +979,7 @@ function Dispatch.process(inst, k, v, index)
|
||||||
-- (h.process가 재귀하는 동안 잠깐 열려 있는 구간)
|
-- (h.process가 재귀하는 동안 잠깐 열려 있는 구간)
|
||||||
local retractor = h.process(inst, k, v, index)
|
local retractor = h.process(inst, k, v, index)
|
||||||
if retractor == nil then
|
if retractor == nil then
|
||||||
error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가")
|
error("quad.Dispatch: handler returned no retractor — return Void when there is nothing to undo", 2)
|
||||||
end
|
end
|
||||||
slot.retractor = retractor
|
slot.retractor = retractor
|
||||||
else
|
else
|
||||||
|
|
@ -990,7 +990,7 @@ function Dispatch.process(inst, k, v, index)
|
||||||
list[index] = { handler = h, retractor = NOOP }
|
list[index] = { handler = h, retractor = NOOP }
|
||||||
local retractor = h.process(inst, k, v, index)
|
local retractor = h.process(inst, k, v, index)
|
||||||
if retractor == nil then
|
if retractor == nil then
|
||||||
error("Dispatch: 핸들러가 retractor 반환을 생략했음 — 생략 불가")
|
error("quad.Dispatch: handler returned no retractor — return Void when there is nothing to undo", 2)
|
||||||
end
|
end
|
||||||
list[index] = { handler = h, retractor = retractor }
|
list[index] = { handler = h, retractor = retractor }
|
||||||
end
|
end
|
||||||
|
|
@ -1016,7 +1016,7 @@ function Dispatch.retractFrom(inst, k, index)
|
||||||
for i = #list, index, -1 do
|
for i = #list, index, -1 do
|
||||||
local slot = list[i]
|
local slot = list[i]
|
||||||
if slot == nil then
|
if slot == nil then
|
||||||
error("Dispatch: 인덱스 " .. i .. "에 슬롯이 없음 — 배열에 구멍이 뚫렸음")
|
error(`quad.Dispatch.retractFrom: no slot at index {i} — the chain array has a hole, bookkeeping is broken`, 1)
|
||||||
end
|
end
|
||||||
slot.retractor(nil)
|
slot.retractor(nil)
|
||||||
list[i] = nil
|
list[i] = nil
|
||||||
|
|
@ -1024,6 +1024,13 @@ function Dispatch.retractFrom(inst, k, index)
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**[2026-08-31 `H-212`]** 위 의사코드의 error 세 자리가 한국어·`level` 없음으로
|
||||||
|
남아 있었다 — 이 절이 쓰인 뒤(2026-08-13) 확정된 `base/architecture.md`의
|
||||||
|
"error 계약 — `level` 이분과 메시지 언어" 절(*"`base/`의 예시 메시지도 영어로
|
||||||
|
쓴다"*)이 반영 안 된 것. M3 단위 1 구현과 같은 커밋에서 영어 + `level`로
|
||||||
|
정정했다 — retractor 생략은 핸들러(제공자) 쪽 계약 위반이라 호출부를 가리키는
|
||||||
|
`2`, 배열 구멍은 내부 부기 파손이라 그 자리를 가리키는 `1`.
|
||||||
|
|
||||||
- **래핑 핸들러는 재-dispatch 전에 아무것도 철거하지 않는다 — 그냥 아래로
|
- **래핑 핸들러는 재-dispatch 전에 아무것도 철거하지 않는다 — 그냥 아래로
|
||||||
내려보낸다.** `StoreBind`/`NoneHandler`가 하는 일은 이제 한 줄:
|
내려보낸다.** `StoreBind`/`NoneHandler`가 하는 일은 이제 한 줄:
|
||||||
```lua
|
```lua
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# 스파이크 상태판 — **폴더가 곧 상태**
|
# 스파이크 상태판 — **폴더가 곧 상태**
|
||||||
|
|
||||||
> 마지막 갱신: **2026-08-29** — M2 구현이 스파이크 셋을 닫음: `05`(다이아몬드,
|
> 마지막 갱신: **2026-08-31** — M3 단위 1이 `01`을 닫음: 재작성이 물어야 했던
|
||||||
|
> 언어 동작(일반화 `for`의 배열→해시 순서)을 `quad-base/test/spec.drive.luau`
|
||||||
|
> 1번이 상시 회귀로 실측하므로 **폐기 → `done/`**, 재작성 안 함(round12 brief
|
||||||
|
> §6 사용자 승인). 직전 갱신 **2026-08-29** — M2 구현이 스파이크 셋을 닫음: `05`(다이아몬드,
|
||||||
> `spec.state`/`spec.effect` 3번이 대체)·`15`(타입팩, `H-176` 기각 실측) **폐기 →
|
> `spec.state`/`spec.effect` 3번이 대체)·`15`(타입팩, `H-176` 기각 실측) **폐기 →
|
||||||
> `done/`**, 신규 `26`(`:Apply` 교집합 오버로드, `H-179`) `done/` 직행, "만들어야 할
|
> `done/`**, 신규 `26`(`:Apply` 교집합 오버로드, `H-179`) `done/` 직행, "만들어야 할
|
||||||
> 스파이크"의 중간 State GC(`spec.state` 11번)·`CheckedQuad` 재실행(`23`) 닫힘 —
|
> 스파이크"의 중간 State GC(`spec.state` 11번)·`CheckedQuad` 재실행(`23`) 닫힘 —
|
||||||
|
|
@ -149,7 +152,6 @@
|
||||||
|
|
||||||
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
|
|
||||||
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
||||||
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
|
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
|
||||||
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
|
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
|
||||||
|
|
@ -187,6 +189,7 @@
|
||||||
| 파일 | 확인된 것 |
|
| 파일 | 확인된 것 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | ✅ **[2026-08-29 M2 단위 4]** `state:Apply(factory)`의 파라미터 타입은 **교집합 오버로드**(함수 팩토리 제네릭 `U` / `__apply` 객체 `any` 반환) — 유니온 하나는 필드가 더 있는 객체(`Blocker`)를 못 받는다. 기대 진단 2건(음성 대조군)만 — 근거: `qa-request/m2-implementation-round11.md` `H-179`, `quad-types/src/init.luau` `State<T>.Apply` |
|
| `26-type-apply-object-factory-overload.luau` (타입체크 전용) | ✅ **[2026-08-29 M2 단위 4]** `state:Apply(factory)`의 파라미터 타입은 **교집합 오버로드**(함수 팩토리 제네릭 `U` / `__apply` 객체 `any` 반환) — 유니온 하나는 필드가 더 있는 객체(`Blocker`)를 못 받는다. 기대 진단 2건(음성 대조군)만 — 근거: `qa-request/m2-implementation-round11.md` `H-179`, `quad-types/src/init.luau` `State<T>.Apply` |
|
||||||
|
| `01-two-pass-array-hash-order.luau` | **[2026-08-31 폐기 → `done/`로 이동, 재작성 안 함]** M3 단위 1의 `quad-base/test/spec.drive.luau` 1번이 재작성이 물어야 했던 그 질문 — **일반화 `for` 한 번**이 배열 파트 전체를 해시보다 먼저, 배열 안은 index 순서로 주는가(`F-4-1`) — 를 실제 `Dispatch.drive`에 대고 상시 회귀로 실측한다(round12 brief §6, 사용자 승인). 파일은 역사로만 남긴다. 아래는 폐기 전 상태: 숫자 `for` + 일반화 `for` **두 루프** 버전이라 옛 형태 기준으로는 ✅ 통과였으나, 구현이 단일 일반화 `for`로 정정되며(`base/dispatch-core-plan.md` "props 순회 순서" 절) 언어 동작 자체를 묻도록 재작성 대기였음 |
|
||||||
| `05-store-state-diamond-propagation.luau` | **[2026-08-29 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2·3의 `quad-base/test/spec.state.luau` 3번(다이아몬드에서 두 번째 도착이 규칙 3으로 접힘, 조인 1회 계산)·`spec.effect.luau` 3번(Effect도 1회)이 실제 구현에서 같은 것을 고정한다 — 이 스파이크가 물으려던 "변경당 1회"의 답. 아래는 폐기 전 상태: 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 — 근거: **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) |
|
| `05-store-state-diamond-propagation.luau` | **[2026-08-29 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2·3의 `quad-base/test/spec.state.luau` 3번(다이아몬드에서 두 번째 도착이 규칙 3으로 접힘, 조인 1회 계산)·`spec.effect.luau` 3번(Effect도 1회)이 실제 구현에서 같은 것을 고정한다 — 이 스파이크가 물으려던 "변경당 1회"의 답. 아래는 폐기 전 상태: 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 — 근거: **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) |
|
||||||
| `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`qa-request/m2-implementation-round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) — 근거: 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
| `15-type-compute-trailing-deps-typepack.luau` | **[2026-08-28 폐기 → `done/`로 이동, 재작성 안 함]** M2 단위 2가 실제 `quad-types` 선언에서 타입팩 형태를 실측해 기각했다(`qa-request/m2-implementation-round11.md` `H-176`: strict에서 콜백 dep 추론이 깨져 정상 호출까지 막힘 → deps 자리 `...any`). 이 스파이크가 물으려던 (B)의 답이 나왔으므로 파일은 역사로만 `done/`에 남긴다. 아래는 폐기 전 상태: **파싱 실패**(SyntaxError) — 근거: 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||||
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`로 **해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 |
|
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 **당시의** Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인. **[2026-08-24]** 그 대비의 한쪽(Ref 콜백)은 6라운드 `H-7`로 **해시맵 셋**이 되어 사라졌지만, 이 스파이크가 실제로 확인한 것(**일반 Lua 테이블에서 `nil` 구멍과 `None` 채움의 거동 차이**)은 그대로 유효하다 — `sourceList`/`flattened`처럼 순서가 중요한 배열이 여전히 그 결론 위에 선다 |
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,70 @@
|
||||||
|
|
||||||
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|
| 번호 | 갈래 | 단위 | 심각도 | 한 줄 | 상태 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| (아직 없음) | | | | | |
|
| `H-212` | ① | 1 | 🟢 | `dispatch-core-plan.md` "Dispatch 체인" 의사코드의 error 세 자리가 한국어·`level` 없음 — 그 절(2026-08-13)보다 늦게 확정된 `architecture.md` error 계약(영어, level 이분, 2026-08-25)이 미반영 | ✅ 반영(`dispatch-core-plan.md` 의사코드 영어+level, `H-212` 문단) |
|
||||||
|
| `H-213` | ① | 1 | 🟢 | `HANDLER_PRIORITY_*` 상수의 실제 숫자값을 어느 문서도 안 정했다 — 문서가 정한 건 이름·순서(HIGH > NORMAL > LOW > FALLBACK)·열린 공간(± 오프셋)뿐 | ✅ 구현이 채움: 1000 / 0 / -1000 / -1000000 (밴드 간 ± 오프셋 여유, `Dispatch/init.luau` 주석) |
|
||||||
|
| `H-214` | **②** | 1 | 🟡 | `listHandlers`가 "이름/priority를 반환"이고 동률 경고·`quad-debug` 체인 덤프도 핸들러 이름을 원하는데, Handler 계약(3종)엔 `name` 필드가 없다 — 새 필드라 자율 반영 불가 | ⏳ §4 대기 — 코드는 핸들러 객체 배열 반환 + `TODO(H-214)` 마커 |
|
||||||
|
|
||||||
|
### `H-212` — base 의사코드 error가 error 계약 이전 표기로 남아 있었다 (①)
|
||||||
|
|
||||||
|
`process`의 retractor 생략 error 두 자리와 `retractFrom`의 배열 구멍 error가
|
||||||
|
한국어 메시지에 `level` 인자 없음 — `base/architecture.md`의 "error 계약 —
|
||||||
|
`level` 이분과 메시지 언어" 절(*"`base/`의 예시 메시지도 영어로 쓴다"*)이 그
|
||||||
|
의사코드보다 늦게 확정되며 반영이 안 된 자리다. 문서가 이미 답(영어 + level
|
||||||
|
이분)을 갖고 있어 ①: retractor 생략은 핸들러(제공자) 계약 위반이라 호출부를
|
||||||
|
가리키는 `2`, 배열 구멍은 내부 부기 파손이라 그 자리를 가리키는 `1`.
|
||||||
|
`base/`와 코드를 같은 커밋에서 맞췄다.
|
||||||
|
|
||||||
|
### `H-213` — 우선순위 밴드 상수의 리터럴 값 (①)
|
||||||
|
|
||||||
|
"우선순위 동률/매치 실패 처리" 절은 상수 **이름 넷**과 순서, "열린 숫자 공간
|
||||||
|
위의 편의 상수"(`HANDLER_PRIORITY_HIGH + 1`식 미세 조정)만 정하고 값은 안
|
||||||
|
정했다. 구현 선택: `HIGH = 1000` / `NORMAL = 0` / `LOW = -1000` /
|
||||||
|
`FALLBACK = -1000000`. 근거 — 밴드 사이 간격이 커서 ± 오프셋이 이웃 밴드를
|
||||||
|
침범하기 어렵고, `FALLBACK + 1`(base Fallback을 가로채는 관례 자리)이 `LOW`
|
||||||
|
보다 한참 아래에 남는다. 계약 의미(순서·밴드)는 값과 무관해 사용자 결정
|
||||||
|
대상이 아니라고 판단 — 다른 값을 원하면 §4 회신에 얹으면 된다.
|
||||||
|
|
||||||
|
### `H-214` — `listHandlers`/동률 경고가 원하는 핸들러 "이름"이 계약에 없다 (②)
|
||||||
|
|
||||||
|
`dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절: *"`Dispatch.listHandlers()`는
|
||||||
|
현재 등록된 전체 핸들러(이름/priority)를 **반환**"*. `research/debug-tooling-plan.md`
|
||||||
|
쪽 서술(체인 슬롯을 "이름으로 바로 덤프")도 같은 걸 전제한다. 그런데 핸들러
|
||||||
|
계약은 `isHandlable`/`priority`/`process` **3종으로 못 박혀** 있고(같은 문서
|
||||||
|
"핸들러 계약" 절: *"다음 3개를 제공하는"*), `name`을 붙이는 건 **새 필드**라
|
||||||
|
규약 §2의 ② 갈래다. 기각 이력 grep: Handler에 이름 필드를 검토·기각한 기록
|
||||||
|
없음(등록 엔티티 이름 논의(`TagFallbackHandler` 등)는 **변수명** 이야기지
|
||||||
|
계약 필드가 아님). 임시 구현: 등록된 핸들러 객체 배열(우선순위순 사본)을
|
||||||
|
그대로 반환 — 정의된 정보(priority, 함수들)는 다 담기고 새 개념이 없다.
|
||||||
|
동률 경고 print는 priority 값만 찍는다. 코드 마커 `TODO(H-214)` 1곳
|
||||||
|
(`Dispatch/init.luau`의 `listHandlers`).
|
||||||
|
|
||||||
## §4 배치 문항지 (사용자가 읽을 유일한 자리)
|
## §4 배치 문항지 (사용자가 읽을 유일한 자리)
|
||||||
|
|
||||||
**[2026-08-31 기준] 열린 문항 없음.**
|
|
||||||
|
|
||||||
| 번호 | 무엇 | 선택지 | 권고 | 권고 근거 |
|
| 번호 | 무엇 | 선택지 | 권고 | 권고 근거 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| (아직 없음) | | | | |
|
| `H-214` | `listHandlers`·동률 경고·(나중의) `quad-debug` 덤프가 쓸 핸들러 **이름** — Handler 계약(3종)엔 `name`이 없다 | (a) 계약에 **선택 필드 `name: string?`** 추가 — 있으면 경고·덤프·`listHandlers`가 쓰고 없으면 priority만 / (b) 이름 없이 감 — `listHandlers`는 핸들러 객체 배열만 반환(지금 임시 구현), "이름/priority" 서술을 문서에서 걷어냄 / (c) 다른 방식(별도 등록 인자 `addHandler(h, name)` 등) | **(a)** | 문서 두 곳(`dispatch-core-plan.md` "우선순위 동률/매치 실패 처리", `research/debug-tooling-plan.md`)이 이미 "이름"을 전제하고, 선택 필드면 기존 3종 계약을 안 깬다. (c)는 이름이 핸들러 자신이 아니라 레지스트리에 살게 돼 체인 슬롯 덤프(슬롯엔 handler 객체만 저장)가 역조회를 또 요구함 |
|
||||||
|
|
||||||
## §5 이상 없음 확인 (탐사자·구현이 확인만 하고 문제 없었던 자리)
|
## §5 이상 없음 확인 (탐사자·구현이 확인만 하고 문제 없었던 자리)
|
||||||
|
|
||||||
(아직 없음)
|
- **[2026-08-31, 단위 1 구현]** "Dispatch 체인" 절 의사코드를 한 줄씩 옮기며
|
||||||
|
대조 — (A)/(B) 분기, (A)의 소비 직후 `NOOP` 교체, (B)의 점유 마커 선행,
|
||||||
|
`chains:SetStrong`이 `h.process` 앞, retractor 생략 즉시 error 양쪽,
|
||||||
|
`retractFrom` 꼬리 역순·항상 소비·구멍 error, `H-103` 주석(pcall 안 감쌈)
|
||||||
|
전부 그대로 — 전사 차이는 `H-212`(error 표기)뿐. `spec.dispatch.luau`
|
||||||
|
1~12가 각 계약을 실측(같은 핸들러 두 슬롯 = `State<State<T>>` 유사 구조,
|
||||||
|
깊은 체인 (A) 연쇄에서 각 레벨이 자기 힌트를 받는 것 포함).
|
||||||
|
- **[2026-08-31]** `F-4-1`의 언어 동작(일반화 `for`가 배열 파트 전체를
|
||||||
|
해시보다 먼저, 배열 안은 index 순서) — `spec.drive.luau` 1번이 실측 통과.
|
||||||
|
스파이크 `01` 재작성은 이 spec이 상시 회귀로 대체(§6 계획, `STATUS.md`에
|
||||||
|
기록).
|
||||||
|
- **[2026-08-31]** 매치 실패 메시지의 "브랜드 출력"은 기각된 Brand 역조회
|
||||||
|
(`archive/brand-shared-registry-reversed.md`)를 재도입하지 않고 모듈 공개
|
||||||
|
술어(`is*`) 프로브로 구현 — 실패 경로에서만 돌고(지연 생성 규칙), 새 표면
|
||||||
|
없음.
|
||||||
|
- **[2026-08-31]** `H-165`(quad-types에 `export type` 추가 시 pesde shim
|
||||||
|
재생성 필요)를 예고대로 밟았고 `pesde install` 재실행으로 해소 —
|
||||||
|
`Handler`/`Dispatch`가 shim에 올라옴. 새 발견 아님(문서 그대로).
|
||||||
|
|
||||||
## §6 남은 의심 (발견은 아니지만 다음 라운드가 파볼 자리)
|
## §6 남은 의심 (발견은 아니지만 다음 라운드가 파볼 자리)
|
||||||
|
|
||||||
|
|
|
||||||
31
ROADMAP.md
31
ROADMAP.md
|
|
@ -166,9 +166,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
없어서 그냥 잊히기 쉬운 자리**라 여기 모은다. **무엇이 지금 어느 폴더에
|
없어서 그냥 잊히기 쉬운 자리**라 여기 모은다. **무엇이 지금 어느 폴더에
|
||||||
있는지의 소스는 항상 `.claude/luau-test/STATUS.md`** — 여기서 세지 않는다.
|
있는지의 소스는 항상 `.claude/luau-test/STATUS.md`** — 여기서 세지 않는다.
|
||||||
|
|
||||||
- [ ] **`01`(props 순회 순서)** — 두 루프로 짜여 있어 지금 계약의 구현
|
- [x] **`01`(props 순회 순서)** — **[2026-08-31 닫힘, 재작성 안 함]** M3
|
||||||
(단일 일반화 `for`, `F-4-1`)과 안 맞음. 재작성하면서 "배열 파트 전체가
|
단위 1의 `spec.drive.luau` 1번이 재작성이 물어야 했던 언어 동작
|
||||||
해시보다 먼저 + 배열 안에서는 index 순서"를 그대로 확인할 것
|
(일반화 `for` 한 번이 배열 파트 전체를 해시보다 먼저, 배열 안은 index
|
||||||
|
순서)을 실제 `Dispatch.drive`에 대고 상시 회귀로 실측 — 스파이크는
|
||||||
|
폐기, `done/`으로 이동(round12 brief §6 사용자 승인,
|
||||||
|
`luau-test/STATUS.md`의 그 행이 소스)
|
||||||
- [x] **`05`(다이아몬드 전파)** — **[2026-08-29 닫힘, 재작성 안 함]** M2
|
- [x] **`05`(다이아몬드 전파)** — **[2026-08-29 닫힘, 재작성 안 함]** M2
|
||||||
구현의 `spec.state.luau` 3번(다이아몬드 규칙 3, 조인 1회)·
|
구현의 `spec.state.luau` 3번(다이아몬드 규칙 3, 조인 1회)·
|
||||||
`spec.effect.luau` 3번이 실제 구현에서 같은 것을 고정해 스파이크는
|
`spec.effect.luau` 3번이 실제 구현에서 같은 것을 고정해 스파이크는
|
||||||
|
|
@ -728,7 +731,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
> **빌드 순서상 역방향 간선은 없습니다.**
|
> **빌드 순서상 역방향 간선은 없습니다.**
|
||||||
|
|
||||||
|
|
||||||
- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수
|
- [x] **[2026-08-31 M3 단위 1]** `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수
|
||||||
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
|
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
|
||||||
(오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교** →
|
(오케스트레이터: getHandler → **그 인덱스의 기존 핸들러와 비교** →
|
||||||
같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`로
|
같으면 그 자리 클로저에 새 값을 넘기고 같은 핸들러의 `.process`로
|
||||||
|
|
@ -749,8 +752,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
|
테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
|
||||||
`type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
|
`type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
|
||||||
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
|
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
|
||||||
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
|
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기였다가
|
||||||
(`luau-test/STATUS.md`)
|
**[2026-08-31]** `spec.drive.luau`가 대체하며 폐기(`luau-test/STATUS.md`).
|
||||||
|
**[2026-08-31 단위 1 범위 절단(round12 brief §6)]** 지금 커밋된 `drive`는
|
||||||
|
파이프라인 (b) 본체 루프만이다 — ⓪/⓪' 배치 Blocker 게이팅은 M3 단위
|
||||||
|
2(`getBlocker`/부기가 생기는 자리)에서, (a) pre-pass와 (c) `postRefList`는
|
||||||
|
M8(`PreRef`/`PostRef` 본체)에서 배선된다
|
||||||
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외
|
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-39`] 말단 핸들러는 예외
|
||||||
없이 자기 배열 자리의 `setOffsetSource(inst,k,None)` → `setLength(inst,k,0)`을
|
없이 자기 배열 자리의 `setOffsetSource(inst,k,None)` → `setLength(inst,k,0)`을
|
||||||
등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이
|
등록한다** — 이 계약을 핸들러 작성 체크리스트에 넣고, 실제로 넷이
|
||||||
|
|
@ -761,7 +768,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
(`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치).
|
(`Frame { Tag("card"), TextLabel{} }`처럼 말단이 앞에 오는 흔한 배치).
|
||||||
**배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.**
|
**배열 맨 끝이면 안 터지므로 "가끔 되고 가끔 터지는" 형태로 드러난다.**
|
||||||
소스는 `base/dispatch-core-plan.md`의 등록 책임 절
|
소스는 `base/dispatch-core-plan.md`의 등록 책임 절
|
||||||
- [ ] **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types`의 `Quad`
|
- [x] **[2026-08-31 M3 단위 1 완료]** **⭐ [2026-08-24 신설, 6라운드 손 트레이싱 `H-25`] `quad-types`의 `Quad`
|
||||||
타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌
|
타입에 `Dispatch` 필드와 그 타입 재수출을 추가** — `Quad`가 5필드 닫힌
|
||||||
레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면
|
레코드이고 `RunInit`은 반환값이 없어 타입을 못 넓히므로, 이걸 안 하면
|
||||||
`module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔
|
`module:RunInit(InitDispatch)` 뒤의 `quad.Dispatch` 접근이 **런타임엔
|
||||||
|
|
@ -779,7 +786,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
`H-25` 파생 항목에도 적어뒀다.) 빠뜨리면 그 마일스톤 완료 후
|
`H-25` 파생 항목에도 적어뒀다.) 빠뜨리면 그 마일스톤 완료 후
|
||||||
`quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선
|
`quad.Store`/`quad.Slot` 접근이 런타임엔 되는데 `luau-analyze`에선
|
||||||
타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.**
|
타입에러인, `H-25`가 실측한 그 문제가 **마일스톤마다 반복된다.**
|
||||||
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
|
- [x] **[2026-08-31 M3 단위 1]** `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
|
||||||
`process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도
|
`process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도
|
||||||
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
|
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
|
||||||
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
|
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
|
||||||
|
|
@ -879,13 +886,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
|
대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
|
||||||
`qa-request/pre-implementation-qa-round5-followup.md`와
|
`qa-request/pre-implementation-qa-round5-followup.md`와
|
||||||
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
|
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
|
||||||
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
- [x] **[2026-08-31 M3 단위 1]** 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
||||||
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
||||||
`Void`(**[2026-08-28 `H-162`]** 단일 no-op)를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
`Void`(**[2026-08-28 `H-162`]** 단일 no-op)를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
||||||
호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션
|
호출, `base/dispatch-core-plan.md` "핸들러 계약" 절, 2026-08-08 세션
|
||||||
/ **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process`
|
/ **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process`
|
||||||
반환값으로 합쳐지며 대상만 바뀜**)
|
반환값으로 합쳐지며 대상만 바뀜**)
|
||||||
- [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정,
|
- [x] **[2026-08-31 M3 단위 1 — 단, 목록의 핸들러 "이름"은 계약에 `name`
|
||||||
|
필드가 없어 `H-214`로 §4 대기, `listHandlers`는 임시로 핸들러 객체
|
||||||
|
배열 반환]** 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정,
|
||||||
`base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절) —
|
`base/dispatch-core-plan.md` "우선순위 동률/매치 실패 처리" 절) —
|
||||||
`HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW`/**`_FALLBACK`**(base 제공
|
`HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW`/**`_FALLBACK`**(base 제공
|
||||||
핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 자기 핸들러를
|
핸들러의 기본 밴드 — 백엔드가 평범한 우선순위로 자기 핸들러를
|
||||||
|
|
@ -902,7 +911,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) —
|
leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) —
|
||||||
quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/
|
quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/
|
||||||
dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절)
|
dispatch-core-plan.md` "Dispatch는 프리미티브가 아니다" 절)
|
||||||
- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]={handler, retractor}}}}`
|
- [x] **[2026-08-31 M3 단위 1]** `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]={handler, retractor}}}}`
|
||||||
— **재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)**) +
|
— **재귀 깊이 인덱스 → (담당 핸들러, 그가 반환한 retractor 클로저)**) +
|
||||||
**3-인자** `Dispatch.retractFrom(inst,k,index)` — 재귀 재-dispatch
|
**3-인자** `Dispatch.retractFrom(inst,k,index)` — 재귀 재-dispatch
|
||||||
(StoreBind/NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08
|
(StoreBind/NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08
|
||||||
|
|
|
||||||
220
quad-base/src/Dispatch/init.luau
Normal file
220
quad-base/src/Dispatch/init.luau
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
--[[
|
||||||
|
InitDispatch(module) — the dispatch engine, installed on the module
|
||||||
|
instance as `module.Dispatch` (factory shape: `.claude/base/
|
||||||
|
module-lifecycle-plan.md` "New()의 내부 구성 — InitXxx 팩토리 체이닝";
|
||||||
|
Dispatch is not a primitive: `.claude/base/dispatch-core-plan.md`
|
||||||
|
"Dispatch는 프리미티브가 아니다"). The handler registry and `chains` are
|
||||||
|
per-instance state — two `New()` modules never share them.
|
||||||
|
|
||||||
|
Sources, as-is:
|
||||||
|
- `dispatch-core-plan.md` "Dispatch 체인" — `chains` (per-(inst,k) index
|
||||||
|
array, each slot {handler, retractor}), descending-diff `process`
|
||||||
|
(A)/(B) branches, 3-arg `retractFrom` (tail-first), the `H-103` caveat
|
||||||
|
(a throwing h.process leaves the NOOP marker permanently — no pcall,
|
||||||
|
`architecture.md` "예외 안전성 계약").
|
||||||
|
- same doc "핸들러 계약" / "우선순위 동률/매치 실패 처리" — priority scan,
|
||||||
|
first match wins; match failure errors immediately (message built only
|
||||||
|
on failure, with brand if known + typeof + provider guidance); named
|
||||||
|
priority bands; tie warning printed only when `module.debug`;
|
||||||
|
`listHandlers` is a pure query (returns, never prints — `D-8`).
|
||||||
|
- `bind-system-plan.md` "`New(name)(props)` 파이프라인 의사코드" — `drive`.
|
||||||
|
M3 unit-1 scope (`qa-request/m3-implementation-round12-brief.md` §6):
|
||||||
|
only stage (b), the single generalized `for`, lives here now. The ⓪/⓪'
|
||||||
|
batch Blocker gating is wired in unit 2 (where `getBlocker`/bookkeeping
|
||||||
|
are born); the (a) PreRef/PostRef pre-pass and (c) postRefList come with
|
||||||
|
M8 (where those values are born).
|
||||||
|
|
||||||
|
Error levels follow `architecture.md` "error 계약": provider/user-side
|
||||||
|
contract violations point at the caller (level 2), broken internal
|
||||||
|
bookkeeping points at itself (level 1). Messages in English.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Relate = require("./Relate")
|
||||||
|
local Void = require("./Void")
|
||||||
|
local QuadTypes = require("../roblox_packages/quad_types")
|
||||||
|
local HandlerModule = require("@self/Handler")
|
||||||
|
|
||||||
|
export type Handler = HandlerModule.Handler
|
||||||
|
export type Dispatch = QuadTypes.Dispatch
|
||||||
|
|
||||||
|
type Slot = { handler: Handler, retractor: (nextValue: any?) -> () }
|
||||||
|
|
||||||
|
-- Band constants. The docs fix only the names, the ordering (HIGH > NORMAL >
|
||||||
|
-- LOW > FALLBACK) and that the space is open for ± offsets — the literals are
|
||||||
|
-- implementation-chosen with room between bands (`H-213`).
|
||||||
|
local HANDLER_PRIORITY_HIGH = 1000
|
||||||
|
local HANDLER_PRIORITY_NORMAL = 0
|
||||||
|
local HANDLER_PRIORITY_LOW = -1000
|
||||||
|
local HANDLER_PRIORITY_FALLBACK = -1000000
|
||||||
|
|
||||||
|
-- Probed (cold path only) to name a value's brand in the match-failure
|
||||||
|
-- message — there is deliberately no reverse lookup on Brand
|
||||||
|
-- (`archive/brand-shared-registry-reversed.md`), so we ask the module's own
|
||||||
|
-- public predicates. Base predicates only; backend-added brands just fall
|
||||||
|
-- back to typeof. `isEpoch` last (most generic).
|
||||||
|
local BRAND_PROBES = {
|
||||||
|
"isSource",
|
||||||
|
"isState",
|
||||||
|
"isStore",
|
||||||
|
"isObserver",
|
||||||
|
"isEffect",
|
||||||
|
"isBlocker",
|
||||||
|
"isRef",
|
||||||
|
"isPreRef",
|
||||||
|
"isPostRef",
|
||||||
|
"isModifier",
|
||||||
|
"isEpoch",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function Init(module: any)
|
||||||
|
-- {[inst(weak)] = {[k] = {[index] = {handler, retractor}}(strong)}}
|
||||||
|
local chains = Relate()
|
||||||
|
local handlers: { Handler } = {} -- kept sorted, higher priority first
|
||||||
|
local NOOP = Void -- `H-162`: the single exported no-op, never a fresh closure
|
||||||
|
|
||||||
|
local Dispatch = {} :: any
|
||||||
|
Dispatch.HANDLER_PRIORITY_HIGH = HANDLER_PRIORITY_HIGH
|
||||||
|
Dispatch.HANDLER_PRIORITY_NORMAL = HANDLER_PRIORITY_NORMAL
|
||||||
|
Dispatch.HANDLER_PRIORITY_LOW = HANDLER_PRIORITY_LOW
|
||||||
|
Dispatch.HANDLER_PRIORITY_FALLBACK = HANDLER_PRIORITY_FALLBACK
|
||||||
|
|
||||||
|
-- Pure scan: first handler (in priority order) whose isHandlable accepts.
|
||||||
|
-- Returns nil on no match — the immediate error is `process`'s job, so the
|
||||||
|
-- failure message is only ever built there ("에러 메시지는 매치 실패
|
||||||
|
-- 시에만" — 핸들러 계약 절의 지연 생성 규칙).
|
||||||
|
function Dispatch.getHandler(inst: any, k: any, v: any): Handler?
|
||||||
|
for _, h in handlers do
|
||||||
|
if h.isHandlable(inst, k, v) then
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noMatchMessage(k: any, v: any): string
|
||||||
|
local brand: string? = nil
|
||||||
|
for _, probeName in BRAND_PROBES do
|
||||||
|
local predicate = module[probeName]
|
||||||
|
if type(predicate) == "function" and (predicate :: (any) -> boolean)(v) then
|
||||||
|
brand = string.sub(probeName, 3) -- "isSource" -> "Source"
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return `quad.Dispatch: no handler matched key {tostring(k)} (value: {typeof(v)}{if brand then `, brand: {brand}` else ""})`
|
||||||
|
.. " — check that the provider for this value (e.g. quad-roblox) is initialized"
|
||||||
|
end
|
||||||
|
|
||||||
|
function Dispatch.process(inst: any, k: any, v: any, index: number)
|
||||||
|
-- [순서 주의] list 확보 + chains 등록은 반드시 h.process 호출 *전에* —
|
||||||
|
-- h.process가 재귀 Dispatch.process(inst,k,…,index+1)를 부르는 게 정상
|
||||||
|
-- 경로이고(StoreBind/NoneHandler), 그때 chains에 이 list가 아직 없으면
|
||||||
|
-- 재귀 호출이 자기만의 새 테이블을 만들어 저장한 뒤 바깥이 덮어써서
|
||||||
|
-- 하위 위임 retractor가 통째로 유실됨(최초 마운트에서 항상 발생).
|
||||||
|
local list: { Slot } = chains:GetStrong(inst, k) :: any
|
||||||
|
if list == nil then
|
||||||
|
list = {}
|
||||||
|
chains:SetStrong(inst, k, list)
|
||||||
|
end
|
||||||
|
|
||||||
|
local slot = list[index]
|
||||||
|
local h = Dispatch.getHandler(inst, k, v)
|
||||||
|
if h == nil then
|
||||||
|
error(noMatchMessage(k, v), 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
if slot ~= nil and slot.handler == h then
|
||||||
|
-- (A) same handler — leave everything below untouched; hand the new
|
||||||
|
-- value to the sitting retractor so it does its own transition, then
|
||||||
|
-- replace this one slot with the fresh retractor.
|
||||||
|
-- v is guaranteed to satisfy h.isHandlable — getHandler picked h on v.
|
||||||
|
slot.retractor(v)
|
||||||
|
slot.retractor = NOOP -- consumed; no double call while h.process recurses
|
||||||
|
local retractor = h.process(inst, k, v, index)
|
||||||
|
if retractor == nil then
|
||||||
|
error("quad.Dispatch: handler returned no retractor — return Void when there is nothing to undo", 2)
|
||||||
|
end
|
||||||
|
slot.retractor = retractor
|
||||||
|
else
|
||||||
|
-- (B) different handler (or empty slot) — retract this slot and below,
|
||||||
|
-- then install fresh.
|
||||||
|
Dispatch.retractFrom(inst, k, index)
|
||||||
|
-- Occupancy marker first: while h.process recurses, `list` must stay a
|
||||||
|
-- hole-free sequence for `#list` to be defined (`#` on holey tables is
|
||||||
|
-- unspecified).
|
||||||
|
list[index] = { handler = h, retractor = NOOP }
|
||||||
|
local retractor = h.process(inst, k, v, index)
|
||||||
|
if retractor == nil then
|
||||||
|
error("quad.Dispatch: handler returned no retractor — return Void when there is nothing to undo", 2)
|
||||||
|
end
|
||||||
|
list[index] = { handler = h, retractor = retractor }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- ⚠️ `H-103`: if h.process throws, the NOOP marker above stays permanently —
|
||||||
|
-- that slot's cleanup is gone and even an explicit retract recovers nothing.
|
||||||
|
-- Deliberately NOT wrapped in pcall (`architecture.md` "예외 안전성 계약":
|
||||||
|
-- hot path, and quad does not guarantee bookkeeping integrity after a throw).
|
||||||
|
|
||||||
|
function Dispatch.retractFrom(inst: any, k: any, index: number)
|
||||||
|
-- From index (inclusive) to the tail, retracted tail-first (deepest
|
||||||
|
-- index first — LIFO: the shallower slot created the deeper one).
|
||||||
|
-- The argument is always nil here: plain retract with no follow-up.
|
||||||
|
local list: { Slot }? = chains:GetStrong(inst, k) :: any
|
||||||
|
if list == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
for i = #list, index, -1 do
|
||||||
|
local slot = list[i]
|
||||||
|
if slot == nil then
|
||||||
|
error(`quad.Dispatch.retractFrom: no slot at index {i} — the chain array has a hole, bookkeeping is broken`, 1)
|
||||||
|
end
|
||||||
|
slot.retractor(nil)
|
||||||
|
list[i] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Dispatch.addHandler(handler: Handler)
|
||||||
|
-- Tie detection is free at registration time (the registry is sorted
|
||||||
|
-- here, statically). No tiebreak rule is enforced — ties are usually a
|
||||||
|
-- handler-design mistake, surfaced via debug visibility only.
|
||||||
|
if module.debug then
|
||||||
|
for _, existing in handlers do
|
||||||
|
if existing.priority == handler.priority then
|
||||||
|
print(
|
||||||
|
`quad.Dispatch: handler priority tie at {handler.priority}`
|
||||||
|
.. " — ties have no defined order; offset from a HANDLER_PRIORITY_* band"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(handlers, handler)
|
||||||
|
table.sort(handlers, function(a: Handler, b: Handler)
|
||||||
|
return a.priority > b.priority
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Dispatch.listHandlers(): { Handler }
|
||||||
|
-- Pure query — returns the registered handlers in scan order, prints
|
||||||
|
-- nothing (`D-8`); whether to display them is the caller's business.
|
||||||
|
-- A copy, so callers can't disturb the scan order.
|
||||||
|
-- TODO(H-214): the Handler contract has no `name` field, but this doc'd
|
||||||
|
-- surface ("이름/priority") and the tie warning/`quad-debug` chain dump
|
||||||
|
-- want one — pending §4 batch decision.
|
||||||
|
return table.clone(handlers)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Dispatch.drive(inst: any, flattened: { [any]: any })
|
||||||
|
-- Pipeline stage (b) only in M3 unit 1 (see header). Single generalized
|
||||||
|
-- `for`: Luau's traversal gives the whole array part before the hash
|
||||||
|
-- part (`F-4-1`) — that ordering stays a *contract* base promises, and
|
||||||
|
-- quad-base keeps it by leaning on exactly this language behavior
|
||||||
|
-- (spec.drive.luau measures it). Chain entry is always index 1.
|
||||||
|
for k, v in flattened do
|
||||||
|
Dispatch.process(inst, k, v, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module.Dispatch = Dispatch
|
||||||
|
end
|
||||||
|
|
||||||
|
return Init
|
||||||
|
|
@ -26,6 +26,7 @@ local InitStore = require("@self/Store")
|
||||||
local Observer = require("@self/Observer")
|
local Observer = require("@self/Observer")
|
||||||
local Effect = require("@self/Effect")
|
local Effect = require("@self/Effect")
|
||||||
local Blocker = require("@self/Blocker")
|
local Blocker = require("@self/Blocker")
|
||||||
|
local InitDispatch = require("@self/Dispatch")
|
||||||
|
|
||||||
type Quad = QuadTypes.Quad
|
type Quad = QuadTypes.Quad
|
||||||
|
|
||||||
|
|
@ -81,6 +82,7 @@ local function New(): Quad
|
||||||
module:RunInit(InitStore)
|
module:RunInit(InitStore)
|
||||||
module:RunInit(Observer.Init) -- 단위 3 — 레지스트리 둘의 소유 모듈(`H-99`)
|
module:RunInit(Observer.Init) -- 단위 3 — 레지스트리 둘의 소유 모듈(`H-99`)
|
||||||
module:RunInit(Effect.Init)
|
module:RunInit(Effect.Init)
|
||||||
|
module:RunInit(InitDispatch) -- M3 단위 1 — 인스턴스별 레지스트리 + chains
|
||||||
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
-- 서브시스템이 늘어날 때마다 이 자리에 module:RunInit(InitXxx)를 순서 무관하게 추가
|
||||||
|
|
||||||
return module
|
return module
|
||||||
|
|
|
||||||
331
quad-base/test/spec.dispatch.luau
Normal file
331
quad-base/test/spec.dispatch.luau
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
--[[
|
||||||
|
Dispatch core contract — `.claude/base/dispatch-core-plan.md` "Dispatch 체인" /
|
||||||
|
"핸들러 계약" / "우선순위 동률/매치 실패 처리", scoped by
|
||||||
|
`.claude/qa-request/m3-implementation-round12-brief.md` §6 (unit 1).
|
||||||
|
Test handlers are spec-local (no real handlers exist yet); `inst` is a plain
|
||||||
|
table (base treats inst as backend-opaque).
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Quad = require("../src")
|
||||||
|
local QuadTypes = require("../roblox_packages/quad_types")
|
||||||
|
|
||||||
|
type Handler = QuadTypes.Handler
|
||||||
|
|
||||||
|
local function makeLeaf(log: { string }, tag: string, match: (any) -> boolean, priority: number): Handler
|
||||||
|
return {
|
||||||
|
isHandlable = function(_inst: any, _k: any, v: any): boolean
|
||||||
|
return match(v)
|
||||||
|
end,
|
||||||
|
priority = priority,
|
||||||
|
process = function(_inst: any, k: any, v: any, index: number): (any?) -> ()
|
||||||
|
table.insert(log, `{tag}:process:{tostring(k)}:{tostring(v)}:{index}`)
|
||||||
|
return function(hint: any?)
|
||||||
|
table.insert(log, `{tag}:retract:{tostring(k)}:{tostring(hint)}`)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wrapping handler — matches `{ inner = ... }`, unwraps one layer and
|
||||||
|
-- redelegates with `index + 1` (the StoreBind/NoneHandler shape).
|
||||||
|
local function makeWrap(dispatch: QuadTypes.Dispatch, log: { string }, tag: string, priority: number): Handler
|
||||||
|
return {
|
||||||
|
isHandlable = function(_inst: any, _k: any, v: any): boolean
|
||||||
|
return type(v) == "table" and (v :: any).inner ~= nil
|
||||||
|
end,
|
||||||
|
priority = priority,
|
||||||
|
process = function(inst: any, k: any, v: any, index: number): (any?) -> ()
|
||||||
|
table.insert(log, `{tag}:process:{index}`)
|
||||||
|
dispatch.process(inst, k, (v :: any).inner, index + 1)
|
||||||
|
return function(hint: any?)
|
||||||
|
table.insert(log, `{tag}:retract:{index}:{if hint == nil then "nil" else "value"}`)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isString(v: any): boolean
|
||||||
|
return type(v) == "string"
|
||||||
|
end
|
||||||
|
local function isNumber(v: any): boolean
|
||||||
|
return type(v) == "number"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function expectLog(log: { string }, expected: { string })
|
||||||
|
assert(#log == #expected, `log length {#log} ~= expected {#expected} — got [{table.concat(log, " | ")}]`)
|
||||||
|
for i, want in expected do
|
||||||
|
assert(log[i] == want, `log[{i}] = "{log[i]}", want "{want}"`)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
print("=== 1. getHandler — 우선순위 스캔·첫 매치, 매치 없으면 nil (순수 조회) ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
local low = makeLeaf(log, "low", isString, q.Dispatch.HANDLER_PRIORITY_LOW)
|
||||||
|
local high = makeLeaf(log, "high", isString, q.Dispatch.HANDLER_PRIORITY_HIGH)
|
||||||
|
q.Dispatch.addHandler(low)
|
||||||
|
q.Dispatch.addHandler(high)
|
||||||
|
local inst = {}
|
||||||
|
assert(q.Dispatch.getHandler(inst, "k", "s") == high, "higher priority wins the scan")
|
||||||
|
assert(q.Dispatch.getHandler(inst, "k", 42) == nil, "no match -> nil (the error is process's job)")
|
||||||
|
assert(#log == 0, "getHandler is a pure scan — no process/retract side effects")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 2. process 매치 실패 — 즉시 error, typeof + (있으면) 브랜드 + provider 안내 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local inst = {}
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
q.Dispatch.process(inst, "Size", 5, 1)
|
||||||
|
end)
|
||||||
|
assert(not ok, "no registered handler -> error")
|
||||||
|
local msg = tostring(err)
|
||||||
|
assert(string.find(msg, "no handler matched", 1, true) ~= nil, `message says no handler matched: {msg}`)
|
||||||
|
assert(string.find(msg, "number", 1, true) ~= nil, `message carries typeof(v): {msg}`)
|
||||||
|
assert(string.find(msg, "provider", 1, true) ~= nil, `message points at provider init: {msg}`)
|
||||||
|
|
||||||
|
local src = q.Source(1)
|
||||||
|
local ok2, err2 = pcall(function()
|
||||||
|
q.Dispatch.process(inst, "Size", src, 1)
|
||||||
|
end)
|
||||||
|
assert(not ok2, "branded value with no handler -> error")
|
||||||
|
assert(string.find(tostring(err2), "brand: Source", 1, true) ~= nil, `message names the brand: {tostring(err2)}`)
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 3. (B) 설치·교체 — 다른 핸들러가 오면 그 자리 retract(nil) 후 새로 설치 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "num", isNumber, q.Dispatch.HANDLER_PRIORITY_NORMAL + 1))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", "hello", 1)
|
||||||
|
expectLog(log, { "str:process:k:hello:1" })
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.process(inst, "k", 42, 1)
|
||||||
|
expectLog(log, { "str:retract:k:nil", "num:process:k:42:1" })
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 4. (A) 같은 핸들러 재프로세스 — retractor가 **새 값**을 받고, 그 다음 process ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", "a", 1)
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.process(inst, "k", "b", 1)
|
||||||
|
-- 인자는 nil이 아니라 대체하는 새 값 그 자체(하강 diff의 타입 보장)
|
||||||
|
expectLog(log, { "str:retract:k:b", "str:process:k:b:1" })
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 5. retractor 반환 생략 — (B) 신규 설치·(A) 재프로세스 양쪽에서 즉시 error ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local calls = 0
|
||||||
|
local bad: Handler = {
|
||||||
|
isHandlable = function(_inst: any, _k: any, v: any): boolean
|
||||||
|
return type(v) == "string"
|
||||||
|
end,
|
||||||
|
priority = q.Dispatch.HANDLER_PRIORITY_NORMAL,
|
||||||
|
process = function(_inst: any, _k: any, _v: any, _index: number): (any?) -> ()
|
||||||
|
calls += 1
|
||||||
|
if calls == 1 then
|
||||||
|
return Quad.Void
|
||||||
|
end
|
||||||
|
return nil :: any -- contract violation on the 2nd call
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
q.Dispatch.addHandler(bad)
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", "first", 1) -- fine: returns Void
|
||||||
|
|
||||||
|
local okA, errA = pcall(function()
|
||||||
|
q.Dispatch.process(inst, "k", "second", 1) -- (A) branch, returns nil
|
||||||
|
end)
|
||||||
|
assert(not okA, "(A) branch: nil retractor -> error")
|
||||||
|
assert(string.find(tostring(errA), "returned no retractor", 1, true) ~= nil, tostring(errA))
|
||||||
|
|
||||||
|
local okB, errB = pcall(function()
|
||||||
|
q.Dispatch.process(inst, "other", "fresh", 1) -- (B) branch, calls == 3 -> nil
|
||||||
|
end)
|
||||||
|
assert(not okB, "(B) branch: nil retractor -> error")
|
||||||
|
assert(string.find(tostring(errB), "returned no retractor", 1, true) ~= nil, tostring(errB))
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 6. retractFrom — 꼬리(깊은 인덱스)부터 역순, 항상 소비, 재설치 가능 ===")
|
||||||
|
do
|
||||||
|
-- 같은 wrap 핸들러가 인덱스 1·2를 차지(State<State<T>> 유사 구조) + leaf가 3.
|
||||||
|
-- 최초 마운트에서 재귀 위임이 살아남는 것 자체가 "SetStrong이 h.process보다
|
||||||
|
-- 먼저"의 음성 대조다(뒤에 두면 하위 retractor가 유실돼 여기서 안 잡힌다).
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler(makeWrap(q.Dispatch, log, "wrap", q.Dispatch.HANDLER_PRIORITY_HIGH))
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", { inner = { inner = "s" } }, 1)
|
||||||
|
expectLog(log, { "wrap:process:1", "wrap:process:2", "str:process:k:s:3" })
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.retractFrom(inst, "k", 1)
|
||||||
|
expectLog(log, { "str:retract:k:nil", "wrap:retract:2:nil", "wrap:retract:1:nil" })
|
||||||
|
table.clear(log)
|
||||||
|
-- 자리가 전부 소비됐으니(list[i] = nil) 새 값이 1번부터 새로 선다
|
||||||
|
q.Dispatch.process(inst, "k", "fresh", 1)
|
||||||
|
expectLog(log, { "str:process:k:fresh:1" })
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 7. 깊은 체인의 (A) 연쇄 — 아무것도 철거되지 않고 각 레벨이 자기 힌트를 받음 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler(makeWrap(q.Dispatch, log, "wrap", q.Dispatch.HANDLER_PRIORITY_HIGH))
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", { inner = { inner = "old" } }, 1)
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.process(inst, "k", { inner = { inner = "new" } }, 1)
|
||||||
|
expectLog(log, {
|
||||||
|
"wrap:retract:1:value", -- (A): 새 값을 받음, 아래는 안 건드림
|
||||||
|
"wrap:process:1",
|
||||||
|
"wrap:retract:2:value",
|
||||||
|
"wrap:process:2",
|
||||||
|
"str:retract:k:new", -- leaf도 진짜 값("new")을 힌트로 받음 — nil 아님
|
||||||
|
"str:process:k:new:3",
|
||||||
|
})
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 8. 조건부 재위임 핸들러 — 재위임을 건너뛰는 자리에서 retractFrom(index+1) 직접 호출 ===")
|
||||||
|
do
|
||||||
|
-- Handler 작성 체크리스트 8번의 가상 위반 사례를 "올바른 쪽"으로 구현.
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
local cond: Handler = {
|
||||||
|
isHandlable = function(_inst: any, _k: any, v: any): boolean
|
||||||
|
return type(v) == "table" and (v :: any).enabled ~= nil
|
||||||
|
end,
|
||||||
|
priority = q.Dispatch.HANDLER_PRIORITY_HIGH,
|
||||||
|
process = function(inst: any, k: any, v: any, index: number): (any?) -> ()
|
||||||
|
if (v :: any).enabled then
|
||||||
|
q.Dispatch.process(inst, k, (v :: any).inner, index + 1)
|
||||||
|
else
|
||||||
|
q.Dispatch.retractFrom(inst, k, index + 1)
|
||||||
|
end
|
||||||
|
return Quad.Void
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
q.Dispatch.addHandler(cond)
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", { enabled = true, inner = "s" }, 1)
|
||||||
|
expectLog(log, { "str:process:k:s:2" })
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.process(inst, "k", { enabled = false }, 1)
|
||||||
|
expectLog(log, { "str:retract:k:nil" }) -- 아래가 고아로 남지 않는다
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 9. 다른 키로 위임 — 그 키에선 항상 인덱스 1, 정리는 위임한 클로저 몫 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
local delegator: Handler = {
|
||||||
|
isHandlable = function(_inst: any, _k: any, v: any): boolean
|
||||||
|
return type(v) == "table" and (v :: any).other ~= nil
|
||||||
|
end,
|
||||||
|
priority = q.Dispatch.HANDLER_PRIORITY_HIGH,
|
||||||
|
process = function(inst: any, _k: any, v: any, _index: number): (any?) -> ()
|
||||||
|
q.Dispatch.process(inst, "otherKey", (v :: any).other, 1)
|
||||||
|
return function(hint: any?)
|
||||||
|
if hint == nil then
|
||||||
|
-- 다른 키의 정리는 그 키를 등록했던 클로저가 자기 철거 시점에
|
||||||
|
-- 한다("`retractFrom`은 다른 키에 대해서만 허용")
|
||||||
|
q.Dispatch.retractFrom(inst, "otherKey", 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
q.Dispatch.addHandler(delegator)
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "str", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "orig", { other = "s" }, 1)
|
||||||
|
expectLog(log, { "str:process:otherKey:s:1" }) -- 다른 키는 재귀 깊이와 무관하게 1부터
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.retractFrom(inst, "orig", 1)
|
||||||
|
expectLog(log, { "str:retract:otherKey:nil" })
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 10. addHandler 동률 — 규칙 없음(에러 아님), 경고 print는 debug일 때만 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "a", isString, q.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "b", isNumber, q.Dispatch.HANDLER_PRIORITY_NORMAL)) -- tie, debug=false: 침묵
|
||||||
|
assert(q.debug == false, "debug defaults to false")
|
||||||
|
q.debug = true
|
||||||
|
print("(아래 quad.Dispatch 동률 경고 한 줄은 의도된 출력이다 — print 캡처가 안 돼 육안 확인)")
|
||||||
|
q.Dispatch.addHandler(makeLeaf(log, "c", isNumber, q.Dispatch.HANDLER_PRIORITY_NORMAL)) -- tie, debug=true: 경고 print
|
||||||
|
q.debug = false
|
||||||
|
-- 동률이어도 등록 자체는 정상 — 매치가 안 겹치는 핸들러는 그대로 동작
|
||||||
|
local inst = {}
|
||||||
|
q.Dispatch.process(inst, "k", "s", 1)
|
||||||
|
assert(log[#log] == "a:process:k:s:1", "tie handlers still dispatch fine when matches don't overlap")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 11. listHandlers — 항상 호출 가능, 우선순위순 반환만(출력 없음), 사본 ===")
|
||||||
|
do
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
local low = makeLeaf(log, "low", isString, q.Dispatch.HANDLER_PRIORITY_LOW)
|
||||||
|
local high = makeLeaf(log, "high", isNumber, q.Dispatch.HANDLER_PRIORITY_HIGH)
|
||||||
|
local fallback = makeLeaf(log, "fb", isNumber, q.Dispatch.HANDLER_PRIORITY_FALLBACK)
|
||||||
|
q.Dispatch.addHandler(low)
|
||||||
|
q.Dispatch.addHandler(fallback)
|
||||||
|
q.Dispatch.addHandler(high)
|
||||||
|
local listed = q.Dispatch.listHandlers()
|
||||||
|
assert(#listed == 3, "returns every registered handler")
|
||||||
|
assert(listed[1] == high and listed[2] == low and listed[3] == fallback, "scan (priority) order")
|
||||||
|
table.clear(listed) -- 사본이라 레지스트리에 영향 없음
|
||||||
|
assert(q.Dispatch.getHandler({}, "k", 5) == high, "mutating the returned list does not disturb the registry")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 12. 인스턴스 격리 — New() 둘은 레지스트리·chains를 공유하지 않음 ===")
|
||||||
|
do
|
||||||
|
local q1 = Quad.New()
|
||||||
|
local q2 = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q1.Dispatch.addHandler(makeLeaf(log, "str", isString, q1.Dispatch.HANDLER_PRIORITY_NORMAL))
|
||||||
|
local inst = {}
|
||||||
|
assert(q1.Dispatch.getHandler(inst, "k", "s") ~= nil, "registered in q1")
|
||||||
|
assert(q2.Dispatch.getHandler(inst, "k", "s") == nil, "invisible in q2")
|
||||||
|
q1.Dispatch.process(inst, "k", "s", 1)
|
||||||
|
q2.Dispatch.retractFrom(inst, "k", 1) -- q2의 chains엔 이 (inst,k)가 없음 — no-op
|
||||||
|
table.clear(log)
|
||||||
|
q1.Dispatch.retractFrom(inst, "k", 1) -- q1의 체인은 그대로 살아 있다
|
||||||
|
expectLog(log, { "str:retract:k:nil" })
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== ALL PASS ===")
|
||||||
66
quad-base/test/spec.drive.luau
Normal file
66
quad-base/test/spec.drive.luau
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
--[[
|
||||||
|
Dispatch.drive — M3 unit-1 scope: pipeline stage (b) only, the single
|
||||||
|
generalized `for` (`.claude/base/bind-system-plan.md` "`New(name)(props)`
|
||||||
|
파이프라인 의사코드"; scope cut: round12 brief §6).
|
||||||
|
|
||||||
|
This spec measures the *language behavior* the contract leans on (`F-4-1`):
|
||||||
|
a generalized `for` over a plain Luau table gives the whole array part
|
||||||
|
before the hash part, and the array part in index order. That is the exact
|
||||||
|
question the retired spike `01` (luau-test/rewrite-required) was due to
|
||||||
|
ask — this spec replaces it as a standing regression (round12 brief §6).
|
||||||
|
]]
|
||||||
|
|
||||||
|
local Quad = require("../src")
|
||||||
|
local QuadTypes = require("../roblox_packages/quad_types")
|
||||||
|
|
||||||
|
local q = Quad.New()
|
||||||
|
local log: { string } = {}
|
||||||
|
q.Dispatch.addHandler({
|
||||||
|
isHandlable = function(_inst: any, _k: any, _v: any): boolean
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
priority = q.Dispatch.HANDLER_PRIORITY_NORMAL,
|
||||||
|
process = function(_inst: any, k: any, v: any, index: number): (any?) -> ()
|
||||||
|
table.insert(log, `{tostring(k)}={tostring(v)}@{index}`)
|
||||||
|
return Quad.Void
|
||||||
|
end,
|
||||||
|
} :: QuadTypes.Handler)
|
||||||
|
|
||||||
|
print("=== 1. 배열 파트 전체가 해시 파트보다 먼저 + 배열 안에서는 index 순서 ===")
|
||||||
|
do
|
||||||
|
table.clear(log)
|
||||||
|
local flattened: { [any]: any } =
|
||||||
|
{ "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "a10", Size = "s", Text = "t", Name = "n" }
|
||||||
|
q.Dispatch.drive({}, flattened)
|
||||||
|
assert(#log == 13, `13 entries processed, got {#log}`)
|
||||||
|
for i = 1, 10 do
|
||||||
|
assert(log[i] == `{i}=a{i}@1`, `array slot {i} in index order before any hash key, got "{log[i]}"`)
|
||||||
|
end
|
||||||
|
local hashSeen: { [string]: boolean } = {}
|
||||||
|
for i = 11, 13 do
|
||||||
|
hashSeen[log[i]] = true
|
||||||
|
end
|
||||||
|
assert(hashSeen["Size=s@1"] and hashSeen["Text=t@1"] and hashSeen["Name=n@1"], "all hash keys after the array part")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 2. 모든 진입은 체인 인덱스 1 ===")
|
||||||
|
do
|
||||||
|
for _, entry in log do
|
||||||
|
assert(string.sub(entry, -2) == "@1", `drive enters every chain at index 1, got "{entry}"`)
|
||||||
|
end
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 3. 빈 props — no-op (아무 핸들러도 안 불림) ===")
|
||||||
|
do
|
||||||
|
table.clear(log)
|
||||||
|
q.Dispatch.drive({}, {})
|
||||||
|
assert(#log == 0, "empty flattened processes nothing")
|
||||||
|
print("PASS")
|
||||||
|
end
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== ALL PASS ===")
|
||||||
Loading…
Reference in a new issue