p.a 와 p.b 는 서로 다른 자리인데 한쪽을 대여하면 다른 쪽까지 잠겼다. 메서드가
하는 일의 대부분이 한 필드에 쓰면서 다른 필드를 읽는 것이라, std.map 의 keep 은
그것 때문에 함수 둘로 갈라져 있었고 파서도 같은 자리에서 걸렸다.
FeOwnState 가 필드별 칸을 넷 갖는다. 값으로 복사되는 구조체라 흐름 병합과
스냅샷은 손댈 것이 없었다. 전체를 대여하면 모든 필드와 충돌하고, 필드를
대여하면 전체 및 같은 필드와 충돌한다. 칸이 모자라면 전체 대여로 되돌아가
더 많이 보고할 뿐 놓치지 않는다.
읽기는 식별자에서 일어나는데 그 자리에서는 자기가 무엇의 밑동인지 알 수 없다.
그래서 투영이 내려가는 길에 어느 필드인지 적어두고 식별자가 그것을 집는다.
인덱스는 갈라지지 않는다 -- xs[i] 의 i 는 상수가 아닐 수 있고, 필드 이름은
상수다.
길에서 나온 것: mem.replace 가 목적지 대여를 가져가고 돌려주지 않았다. 일반
호출 인자는 문장 끝에 돌려주는데 intrinsic 경로에만 그것이 없었다. 전에는
그 자리가 어차피 거부돼서 드러나지 않았다.
var p = Pair{ a: 1, b: 2 };
let r = &mut p.a;
p.b = 3; // ok -- 전에는 에러
p.a = 3; // 에러
take(p); // 에러
SPEC §5 R6 을 고쳤고, 옛 규칙을 그대로 적어둔 문단과 예제를 갈아치웠다.
own/badrfld 는 이제 허용되는 코드였으므로 같은 필드를 건드리도록 다시 겨눴고
okrfld·badrall·badrsame·exec/fieldbrw 를 더했다.
228/228, 32/32.
렉서 다음은 파서다. 노드는 한 배열에 살고 자식은 그 안의 인덱스다 -- 노드는
^Node 를 들 수 없고(여럿이며 한 번씩 소유하지 않는다) &Node 도 들 수 없다(R4).
인덱스는 둘 다 아니다. 소스도 필드가 아니라 매 단계에 같이 다닌다.
unit demo / fn answer @2 / let n = (+ 1 (* 2 3)) / return n / balanced
전위 표기로 다시 찍는 것이 시험의 요점이다. 1 + 2 * 3 이 어떻게 묶였는지는
그렇게만 보인다.
쓰면서 나온 컴파일러 버그 넷:
1. 다른 유닛의 타입을 필드로 쓰면 그 필드 타입이 영영 UNKNOWN 이었다. 필드
해석이 유닛마다 선언 직후에 돌아서, 아직 선언되지 않은 유닛의 타입을 찾다
실패하고 그 답을 굳혔다. 이제 모든 유닛이 선언을 마친 뒤에 한 번 푼다.
2. 그리고 그 해석은 타입을 선언한 유닛에서 해야 한다. 필드 타입은 그 유닛의
import 로 쓰였는데 아무 유닛에서나 풀고 있었다. 타입 계층에 enter/leave
콜백을 두고 체커가 그 자리로 데려간다.
3. cycle_state 를 재귀 검사와 크기 계산이 같이 썼다. 첫 번째가 보는 중인 구조체
가 두 번째에게는 다 끝난 것으로 보여서, 필드가 하나뿐인 것처럼 1 바이트로
자리를 잡았다 -- Parser 가 그래서 자기 토큰을 밟았다. layout_state 로 나눴다.
4. 다른 유닛의 상수(ast.NONE)를 lowering 이 필드 접근으로 봤다. 체커가 이미
링크 이름을 붙여두었으니 그것이 있으면 전역이다.
그리고 R1 을 실제로 지키게 했다: 소유자를 놓으면 그것이 가진 것도 놓는다.
전에는 자기 drop 이 있거나 자기가 owned 일 때만이어서, drop 을 가진 타입을
필드로 담은 구조체는 그것을 놓을 방법이 없었다(drop 은 손으로 못 부른다).
이제 release_at 이 drop 을 부르고 필드로 내려간다. 그 덕에 List/Arena/Map 의
drop 이 전부 필요 없어져서 지웠다 -- 버퍼가 owned 이니 R1 이 알아서 한다.
221/221, 29/29.
check.c 3,937 줄, lower.c 1,913 줄이었다. 가장 큰 파일이 978 줄이 됐다.
check.c 679 스코프·심볼·흐름·소유권 접착
checkexp.c 739 포매팅 검사와 표현식
checkstm.c 635 문장, 함수, 메서드
checkgen.c 618 제네릭 실체화
checkcal.c 978 유닛 경계 호출과 옵셔널/에러 유니온
checkpro.c 184 선언 패스와 프로그램
lower.c 567 타입·슬롯·지역·블록·mem.*
lowerprn.c 238 포매팅 빌트인 전개
lowerexp.c 468 표현식
lowerstm.c 543 문장·함수·프로그램
줄 범위로 잘랐다. 주제별로 묶는 것보다 정확한데, 한 줄도 잃거나 겹치지
않기 때문이다. 파일 순서가 이미 단계를 따라가서 경계가 실제 이음매에 떨어진다.
모든 정의가 static 을 잃고 비공개 헤더에 프로토타입을 갖는다. 대안 --
static 을 유지하고 #include 로 텍스트만 나누는 것 -- 은 결합을 보여주는 대신
숨긴다.
두 스위트 그대로: 209/209, 21/21.
&mut T 를 &mut T 파라미터에 넘기면 호출이 끝날 때 돌려받는다. 호출이 도는
동안 호출자는 그 값에 손댈 수 없으므로 별칭이 생기지 않는다. 이것이 없으면
배타 파라미터를 다시 넘기는 일이 함수당 한 번만 가능해서 &mut 가 사실상 쓸 수
없었다 -- 재귀 하강 파서를 쓰다가 걸렸다.
페이로드 없는 enum 은 이름 붙은 수라서 수로 읽을 수 있다. 반대 방향은 안 된다:
임의의 수는 변이가 아니다.
calc 프로그램: 재귀 하강 수식 계산기. 우선순위, 괄호, 오류 전파.
1+2*3 = 7 (1+2)*3 = 9 2*(3+4)-5 = 9 10/3 = 3
1+ = error (1+2 = error
std.str 은 eq/starts_with/find/trim/parse_int 을 바이트 슬라이스 위에서 한다.
std.list 는 자라는 제네릭 시퀀스다 -- 버퍼를 소유하므로 리스트를 놓으면
원소도 놓인다. 성장은 두 배씩이라 push 당 복사량이 상수로 눌린다.
찾은 버그 넷:
- 메서드가 자기 타입의 유닛이 아니라 호출한 유닛에 속한 것으로 계산됐다.
다른 유닛의 제네릭 타입을 쓰면 필드가 전부 private 으로 보였다.
- 참조로 도달한 메서드를 찾지 못했다. self.grow() 가 안 됐다.
- 이미 참조인 수신자의 주소를 한 번 더 떠서 넘겼다. 포인터의 포인터를 받은
메서드가 그것을 구조체로 읽었다.
- 유닛으로 한정된 제네릭 타입(list.List(i32))이 타입 자리에서도 식 자리에서도
해석되지 않았다.
drop 을 가진 타입은 인스턴스마다 그 메서드가 존재해야 한다 -- 이름으로 부르는
사람이 없어도 스코프 정리가 부른다. 그리고 자기 drop 안에서는 필드를 꺼낼 수
있다. 객체가 사라지는 중이라 뒤에 남아 읽힐 것이 없다.
run.py 207/207, exec.py 19/19.
defer 목록을 '스코프가 아직 갚아야 할 것' 목록으로 일반화했다. defer 블록과
소유 값 해제가 같은 목록에 쓰인 순서대로 들어가고, 모든 이탈 경로가 역순으로
갚는다.
해제에는 값 옆에 플래그를 둔다. 값이 저장될 때 세우고 넘겨줄 때 지운다. 값이
아직 여기 있는 경로에서만 해제되는데, 그건 코드의 모양만 봐서는 알 수 없는
것이다. 검사기가 소유권을 넘기는 사용을 이미 표시해두므로 그것을 읽는다.
!void 함수의 빈 return 은 성공이다. 줄 값도 없고 오류도 없다는 뜻인데
프론트엔드가 타입 불일치로 거부하고 있었다.
런타임이 할당/해제 횟수를 센다. owndrop 프로그램이 그 둘이 일치함을
실행으로 증명한다 -- 이른 반환, 이미 넘긴 값, 스코프 끝 전부.
할당하는 내장 함수들이다. 평범한 호출이 아니라서 여기서 편다. create 는 값을
받아 그 복사본을 가리키는 소유 포인터를 주고, 할당이 실패할 수 있으므로 결과가
에러 유니온이다. 실패 코드는 OutOfMemory 이고, 소스 어디에도 그 이름이 적혀
있지 않지만 다른 이름과 같은 표에 들어간다.
갓 할당한 저장소는 통째로 소유하므로 쓸 수 있다 -- 방해할 사람이 없다. 그래서
alloc_slice 는 ^[]mut T 를 준다. 소유 슬라이스는 포인터와 길이가 값 자체라서
.^ 로 통과할 것이 없고, destroy 는 그 안의 포인터를 푼다.
heap 프로그램이 할당·try·defer 해제·for 순회를 한꺼번에 돈다: sum 4950
모노모피제이션이 실제로 코드를 만드는 자리가 여기다. 프론트엔드는 어떤
인스턴스가 존재하는지만 정했다. 검사기가 인스턴스마다 선언·바인딩·유닛·링크
이름을 기록하고, lowering 이 그 바인딩을 다시 걸고 같은 본문을 자기 이름으로
내린다. 제네릭 선언 자체는 코드가 없다.
comptime 인자는 값이 아니므로 호출에서 넘기지 않고 파라미터 자리도 잡지
않는다. 메서드 호출은 도달한 대상을 첫 인자로 넘긴다 -- self: Self 든
self: &Self 든 수신자의 주소로 같다. 구조체 메서드가 아예 lowering 되지
않고 있었다.
exec.py 13/13.
io.Writer 는 핸들 하나짜리 enum 이다. 참조도 컨텍스트 포인터도 담지 않으므로
Copy 이고 자유롭게 오간다 (SPEC 5 R8). fmt 는 sink 를 소유하지 않는다 --
호출자가 버퍼를 주고 앞에서 몇 바이트가 쓰였는지 돌려받는다.
lowering 에 추가: enum 변이 상수, match, 정수 캐스트, 문자열 이스케이프.
프론트엔드 정밀도 하나: 항상 빠져나가는 분기의 상태를 병합하지 않는다. 그
분기가 소비한 값이 그 분기를 지나지 않은 경로에서도 소비된 것처럼 보였다.
fmt_i32 가 이것 때문에 못 쓰였다.
extern "c" 이름은 유닛 접두사를 붙이지 않는다. 링커가 이미 아는 이름이라는
것이 그 선언의 요점이다.
run.py 199/199, exec.py 11/11.
std 는 예약된 이름이고 프로그램이 아니라 컴파일러와 함께 있으므로 자기 루트를
갖는다 (--std=). 본문 없는 선언은 링커가 찾을 것 -- 런타임이나 C 라이브러리 --
이므로 IR 에 extern 으로 나간다.
런타임에 write/alloc/free/exit 를 넣었다. 이것이 표준 라이브러리가 스스로
말할 수 없는 전부이고 나머지는 Ferro 로 쓴다.
@trap @unreachable @size_of @align_of @line 을 내린다.
링크 이름에서 점과 괄호를 걸렀다. 유닛 경로에는 점이 있고 제네릭 인스턴스에는
괄호가 있는데 어셈블러가 받지 않는다.
타입 인자를 바인딩한 상태로 선언을 인스턴스마다 한 번씩 검사한다. 바인딩된
이름은 그냥 그 인자 타입이므로 본문, 필드 타입, 시그니처가 모두 같은 규칙으로
풀린다. 인스턴스 정체성은 선언 유닛 + 선언 + 인자 철자다.
찾은 버그 셋:
- 파서가 comptime 파라미터의 이름을 'comptime' 이라는 키워드에서 가져갔다.
타입 파라미터 이름이 전부 comptime 이 되어 아무것도 바인딩되지 않았다.
- 인스턴스 이름이 중첩마다 길어져서, 깊은 사슬에서 잘린 이름끼리 충돌해
재귀가 깊이 제한에 닿기 전에 조용히 멈췄다. 길어지면 인자를 일련번호로
적어 정체성을 유지한다.
- 순서 비교 연산자가 피연산자 타입을 보지 않아 구조체끼리 비교해도 통과했다.
제네릭과 무관한 기존 구멍이다.
fixture 셋이 명세와 어긋나 있어 명세를 따랐다. badbody 와 badop 은 호출 지점을
primary error 로 기대했지만 SPEC 9 는 본문의 연산이 primary 이고 호출에는
'instantiated here' note 를 붙이라고 한다. okscope 는 제네릭 본문이 호출자의
이름을 본다고 기대했지만 SPEC 9 는 정의 유닛에서 해석한다 -- badscope 로 옮기고
이유를 적었다.
188/188.
import 가 만든 binding 으로 다른 유닛의 선언에 닿는다. 호출, 구조체 리터럴,
값 참조 세 자리다. 시그니처의 타입은 그 시그니처가 쓰인 유닛에서 해석한다 --
호출한 쪽에서 해석하면 같은 이름이 다른 타입을 가리킨다.
pub 없는 선언과 필드는 자기 유닛 밖에서 보이지 않는다.
error.Name 은 구현된 적이 없었다. 기본 에러 집합 core.Error 의 멤버이고, 그
집합은 선언이 아니라 수집으로 채워지므로 변이 목록 없이 정체성만 갖는다.
units 34/34. dotpriv/main 은 위반이 있는 유닛을 import 하므로 받아들여질 수
없다 -- 마커를 붙이고 이유를 적었다.
유닛마다 FeCheck 를 새로 만들면 타입 문맥도 유닛마다 따로 생겨서 유닛 경계를
넘는 이름을 볼 수가 없었다. 검사기가 빌드 전체를 맡고, 모든 유닛의 선언을
등록한 뒤에 어느 본문이든 보기 시작한다.
스코프와 심볼과 타입은 현재 유닛보다 오래 살아야 하므로 AST 아레나가 아니라
검사기 자신의 아레나에서 잡는다. 이름이 같아도 유닛이 다르면 다른 타입이므로
nominal 타입은 선언한 유닛으로도 구분한다.
pub 은 파서가 버리고 있었다. 이제 FE_NODE_PUB 으로 남긴다.
format 검사는 인자가 떨어진 자리에서 개수 불일치를 말하고 문자열을 다 훑은
뒤 같은 말을 또 했다. aggregate storage 검사는 M7 쪽이 optional 뒤의 참조를
보려고 도는 김에 평범한 &T 필드까지 잡아서, 뒤이어 도는 M6 검사와 겹쳤다.
fixture 전수 검사 결과 --check 경로에 중복 진단이 남아 있지 않다.
이동한 값을 쓰면 진단이 두 번 나왔다. 원인이 둘이다. 식별자를 읽으면
FE_OWN_READ 가 이미 보고하는데 mark_moved 가 FE_OWN_MOVE 로 같은 자리를 다시
보고했고, member lvalue 는 check_lvalue 가 base 를 검사한 뒤 check_lvalue_core
가 또 검사했다. M6/M7 두 검사기를 합칠 때 남은 자국이다.
러너는 진단의 첫 줄만 마커와 대조하므로 fixture 188개가 이것을 잡지 못했다.
Checking the markers for the first time found five disagreements in areas that
are implemented. Four were the marker's fault:
- own/badarg pinned "self", but the rule being broken is that a returned
reference must derive from a parameter -- `self` has nothing to do with it.
- own/badbrmov pinned line 9, which is the closing brace; the second destroy is
on line 8.
- own/badloop pinned line 8, the destroy after the loop. The diagnostic is on
line 6, inside it, and line 6 is right: the second iteration moves the same
value again, so the loop body is where it is caught. Whoever wrote the marker
expected the error after the loop.
- optional/badcatch pinned line 14, the body of the catch block. The catch
expression on line 13 is what cannot fall through.
The fifth was the compiler's. own/badweak assigns a `&mut i32` to a `&i32` and
got "initializer type mismatch", which says nothing about why. Weakening an
exclusive borrow to a shared one is a specific rule and now says so, for
references and slices alike.
own/ is fully green: 50/50. Overall 133 -> 138 of 188. The six remaining marker
disagreements are all under units/ and generic/, where nothing is implemented
yet, so there is no diagnostic to compare against and no way to tell whether
the marker is right.
Unifying the two engines exposed what the split had been hiding: every rule
that lived only in the M6 body was silently dropped for units the M7 half
claimed, and since M1-M6 sources never reached that half, nothing failed until
they all did. Eleven cases across m3, m5 and m6 caught it.
Checker, all from the M6 statement and lvalue cases:
- writing a struct field needs a writable place, so `p.x = 3` on a `let` is an
error again (m3-badfield)
- `let` cannot bind a mutable slice, a var with no initializer needs a type,
and a void expression cannot initialize (m3-bad-mlet)
- a returned reference must derive from a parameter or a static, and a void
expression cannot be returned from a value function (m6-badarg, badret,
badself, badtwo, badlocsl)
- rebinding a reference must not outlive its source scope, and must release the
previous borrow (m6-badscop)
- the loop case delegates to the core, which carries the flow capture and merge
that detects a value moved on every iteration; the M7 version had none of it
(m5-bad-loop)
Emitter:
- builtins other than the print family (@size_of, @align_of) and the str alias
methods are lowered by the core, which the M7 call path never reached, so
they were emitted verbatim into the C (m3-struct)
- the trim helper is emitted from the M7 type-helper pass as well, not only the
core one, or the call has no definition to link (m6-oktrim)
M1-M7 all green: 16, 19, 50, 20, 16, 57, 42.
Compiling the merged sources with the Open Watcom install on this host
(C:\WATCOM19\binnt) found four things the merge got wrong, none of which any
amount of reading would have caught reliably:
- FE_M7_FLOW_CAP and <stdlib.h> lived in check_m7.c's preamble, above the
textual include, and were dropped with the wrapper.
- emit_error_return takes a const char *, not a FeNode *; the hand-written
forward declaration disagreed with the definition.
- emit_match was never defined by the M7 half, only called, so renaming it
alongside the other delegating pairs left a declared-but-undefined static.
- type_needs_drop and emit_lvalue are used a few hundred lines before the
declaration block, so their declarations had to be hoisted.
Also drop two locals that existed only to be cast to void.
All twelve compiler sources now compile with -za -wx -wcd=202 and produce no
warnings. That is a syntax and type check, not verification -- the DOS build
and the milestone suite remain the gate.
check_m7.c textually included check.c, renamed three entry points aside, and
selected between two whole checkers by scanning each unit for `?`, `!`, `try`
and friends. A unit that mentioned any of them was checked by a second
implementation, so an M1-M6 rule fixed in check.c never reached it -- and the
split hid real defects, since the M7 half was reached by no existing fixture
until M7 cases were registered.
The split was cheaper to undo than it looked: every M7 dispatcher already
delegated to its M6 counterpart for nodes it did not handle. So the M7 entry
points become the single check_expr/check_stmt/check_lvalue/check_call, and
the former M6 bodies become check_expr_core/check_stmt_core/check_lvalue_core,
reached as the fallback. Recursion runs through the unified entry, which is
what makes an optional nested inside otherwise-M6 code get checked at all.
m7_check_fn and m7_check_method were identical to the M6 versions apart from
which check_stmt they called, so they are dropped. The feature scanner
(m7_type_ast, m7_node_feature, m7_program_feature) is gone with the dispatch
it fed, including the loop case that still consulted it. fe_check_program and
fe_check_expr_type keep the M7 bodies, which are supersets.
The build compiles check.c directly again.
No behaviour intended to change: the unified checker applies the union of the
rules to every unit, which for M1-M6 sources is what the M6 half already did.
SPEC.md allows `try` only inside a function returning an error union, but the
check sat in the FE_N_EXPR_STMT case, so it only ever saw a bare `try e;` and
walked past `var x = try e;` and `x = try e;`. Move it onto the try expression
in check_expr and drop the statement-level copy.
This could not land before M7: closing the hole forces m5/runtime.fe's `run` to
return an error union, and value returns from `-> !T` need contextual success
construction. That arrives with M7, and `run` is now `-> !i32`, so the rule can
be enforced. Supersedes the SPEC.AUDIT.md entry that recorded the blockage.
M1-M7: 183 passed.
조건부 이동, 이중 destroy, 직접 drop 호출에 대한 실패 fixture를 추가하고
runtime harness와 test-dos.bat를 그에 맞춰 갱신한다.
M5는 아직 완료가 아니다. 모든 경로에서 정확히 1회 cleanup, defer/drop의 선언
역순 병합, try 전파 경로 cleanup, MaybeMoved 런타임 live flag, struct drop과
필드 역순 drop, 분기/루프 상태 합류, 누수/이중해제 카운터 harness가 남아 있다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PQm6oAvWX4Lp3iSN5AHGT