Lowent 매뉴얼←↑→

31 짓고 시험하기 — 패키지, 구성, 시험, 대조

먼저 알아야 할 것

2장 첫 프로그램 · lowentc 의 모드와 두 백엔드
21장 모듈 · 검색 경로가 없다
26장 태스크와 채널 · schedule explore_interleavings 시험

돌아보기

2장에서 VM 과 네이티브가 다른 출력을 내면 누구의 결함이라고 했는가? 그리고 --check 가 조용하다는 것은 무엇을 보증하지 않는가?

답. 컴파일러의 결함이라고 했다. 두 백엔드는 같은 중간 표현에서 출발하기 때문이다. --check 가 조용하다는 것은 머리의 약속과 본문이 어긋나지 않는다는 데까지이고, 시험이 통과했다거나 프로그램이 옳다는 것을 보증하지 않는다. 이 장은 그 둘 사이를 채우는 도구들 — 시험, 구성, 대조 — 과 프로젝트를 짓는 법을 다룬다.

이 장의 필요성과 맥락

제8부의 마지막 장이다. 앞의 장들은 파일 하나를 --check 하고 --run 했다. 실제 프로젝트는 매니페스트가 있고, 빌드마다 다른 구성이 있고, 시험이 있고, 의존이 있다. Lowent 의 도구는 이 모두에 같은 원칙을 적용한다 — 적은 것은 검사하고, 기본값은 소스에 보이며, 조용히 달라지는 것은 없다. 그리고 컴파일러 자신도 같은 원칙으로 검증된다.

이 장이 끝나면

pkg.low 매니페스트와 lowentc run·build 로 프로젝트를 짓는 법을 익힌다. build option 과 config 로 빌드마다 다른 프로그램을 짓되 꺼진 가지도 검사받는다는 것을 알게 된다. test 블록이 통과할 때와 실패할 때의 모습, 두 백엔드 대조와 계약 대조가 컴파일러를 어떻게 검증하는지, 의존을 내용 해시로 고정하는 방법도 보게 된다.

이 장에서 답할 질문

  1. 시험이 있는데 계약까지 적어야 하는가?

31.1 매니페스트와 프로젝트#

프로젝트의 뿌리에 pkg.low 를 두고, 진입점은 src/main.low 에 둔다.

package name "greeter" .
package version "0.1.0" .
package license "MIT" .

매니페스트는 평평한 선언 몇 줄이다. 열쇠말은 닫힌 집합이고 version 은 판 번호 규칙(semver)으로 검사된다. 도구와 사람이 소스 전체를 읽지 않고 이것 하나로 프로젝트의 정체를 알게 하려는 파일이라서, 여기만은 적은 것을 검사한다. 읽어서 믿을 수 없다면 없는 것과 같다.

$ lowentc run
hello from a package
main() = 0
$ lowentc build
built …/out/greeter
$ ./out/greeter main
hello from a package

lowentc run 은 매니페스트를 찾아 진입점을 VM 으로 돌리고, lowentc build 는 C 로 내보내 out/ 에 실행 파일을 짓는다. 빌드는 방출된 C 의 내용 해시로 캐시되어, 바뀌지 않은 코드는 다시 컴파일하지 않는다. 매니페스트는 첫 파일의 폴더에서 위로 걸어 올라가며 찾는다.

 pkg.low ──▶ 진입점 src/main.low
                  │
                  ▼
             --check 와 같은 검사 ──▶ 거절이면 여기서 멈춘다 (돌지도 짓지도 않는다)
                  │
        ┌─────────┴──────────┐
        ▼                    ▼
   lowentc run          lowentc build
   VM 으로 돌린다       C 로 내보낸다 ──▶ 해시가 같으면 캐시 ──▶ out/greeter
                                      └─▶ 처음이면 cc ─────────▶ out/greeter

두 명령 모두 짓거나 돌리기 전에 --check 와 같은 검사를 거친다. 언어가 거절한 프로그램은 돌지도 않고 실행 파일이 되지도 않는다. run 에는 2026-09-16 부터, build 에는 2026-09-26 부터 그렇다 — 그 전의 build 는 검사를 건너뛰고 return true . 같은 잘못이 든 프로그램도 지었다(이 장을 다시 쓰며 실측해 찾았다).

31.2 의존은 해시로 고정한다#

lowentc add <이름> <자리> 는 의존을 매니페스트에 해시 핀과 함께 적는다. --lock-write 는 지금 무엇을 상대로 빌드하는지를 락 파일로 찍고, --lock <파일> 은 그 해시와 바이트가 다르면 빌드를 거절한다. 판 번호가 같아도 바이트가 바뀌었으면 다른 의존이다.

진본인지를 확인하는 층은 따로 있다. lowentc key new·sign·verify 가 ed25519 분리 서명을 만들고 확인한다. verify 는 답을 네 층으로 나누어 말한다 — 무결(해시와 핀이 맞는가), 진본(신뢰하는 키로 서명되었는가), 전송, 접근. 한 층의 초록이 다른 층의 초록을 뜻하지 않기 때문이다. 설정을 찾으려고 읽는 환경 변수는 HOME 하나이고, 설정의 우선순위(명령 줄 > 프로젝트 > 사용자 > 전역)는 도움말에 적혀 있다. 소스 밖에서 동작을 몰래 바꾸는 자리를 줄이려는 것이다. 그 밖에 이름이 LOW 로 시작하는 스위치가 몇 있으나 모두 기본은 꺼져 있고, 시험과 옮겨 가기를 위한 것이다 — 결함 주입기 LOW_HOST_FAULT(28장), 없앤 붙임점 필드 접근을 잠시 되받는 되돌림 문 LOWENT_ALLOW_GLUED_FIELD, 대조 도구의 걸음 상한 LOWENT_ORACLE_BUDGET 같은 것이다. 평소에 켤 일이 없고, 켜면 그 사실이 명령 줄에 보인다.

verify 가 네 층을 따로 말하는 모습은 이렇다(서명하지 않은 파일에서 실측).

 [3 integrity]     BLAKE3 a19e7c93…      이 바이트가 핀과 같은가
 [2 authenticity]  unsigned — LOCAL       누가 냈는가 --- 믿는 키의 서명
 [1 transport]     TLS (curl 의 몫)       서버가 누구인가 --- 바이트가 아니라
 [4 access]        도구 설정의 토큰       들어갈 수 있는가 --- 무결과 별개

한 층이 초록이어도 다른 층은 따로 본다. TLS 가 성공했다는 것은 서버가 맞다는 뜻이지 받은 바이트가 맞다는 뜻이 아니다.

31.3 빌드마다 다른 프로그램 — build option 과 config#

examples/ch31/knobs.low

module knobs .
rem run: tick_rate
rem run: cpus

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

실행 결과

$ lowentc --run tick_rate knobs.low
tick_rate() = 250
$ lowentc --run cpus knobs.low
cpus() = 64

build option <이름> <갈래> … 이 손잡이를 선언한다. 갈래는 bool·int·choice 셋이다. config <이름> 은 그 값을 번역 시점 상수로 읽는다. 구성 파일을 주지 않으면 선언의 기본값이 쓰인다.

같은 소스를 다른 구성으로 짓는다. 구성 파일은 이름 값 줄의 모음이다.

smp false
maxcpu 8

examples/ch31/knobs_small.low

module knobs_small .
rem flags: --config small.config
rem run: tick_rate
rem run: cpus

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

실행 결과

$ lowentc --config small.config --run tick_rate knobs_small.low
tick_rate() = 100
$ lowentc --config small.config --run cpus knobs_small.low
cpus() = 8

smp 를 끄자 tick_rate 는 100 을, cpus 는 8 을 낸다. 값이 정해지면 꺼진 가지는 생성물에 남지 않는다. 비용이 0 이다.

 build option smp bool default true .       ← 소스가 손잡이를 선언한다
 small.config:  smp false                   ← 구성 파일이 값을 준다 (없으면 기본값)

 if config smp . do return config hz . end  ← smp 가 켜졌을 때의 가지
 return 100 .                               ← 꺼졌을 때의 가지

 번역   두 가지 모두 파싱하고 타입 검사한다
 생성물 고른 가지만 남는다 (꺼진 가지의 비용 0)

C 의 #ifdef 와 결정적으로 다른 점이 있다. 꺼진 가지도 파싱되고 타입 검사를 받는다. C 에서는 켜지지 않은 조합의 코드가 아무에게도 읽히지 않은 채 썩어서, 커널 규모의 프로젝트에서 “그 옵션 조합은 빌드조차 안 된다” 는 일이 생긴다. 여기서는 접히는 것이 코드 내기이지 검사가 아니다.

선언해 놓고 아무 코드도 읽지 않는 손잡이는 거절된다.

examples/ch31/unused.low

module unused_opt .
rem expect: E-OPT-UNUSED

build option trace bool default false .

fn answer output u64 .
do
  return 42 .
end

실행 결과

$ lowentc --check unused.low
unused.low:4:0 E-OPT-UNUSED: this build option is declared and NO code reads it (`config <name>`). It shows up in the configuration, the user turns it off — and nothing happens. A knob that does nothing is worse than no knob: it is a decision that LOOKS like it was made

그런 손잡이는 구성에 나타나고, 쓰는 사람이 그것을 끄는데, 아무 일도 일어나지 않는다. 결정처럼 보이지만 결정이 아니다. 구성 파일이 선택지에 없는 값을 주거나(E-CONFIG-TYPE), 없는 손잡이를 적어도(E-CONFIG-UNDEF) 거절된다.

실제 사례. 끌 수 없는 손잡이

명세의 예는 hz 손잡이가 smp 에 기대게(depends smp) 적는다. 기대는 쪽이 켜져 있는데 기댐을 받는 쪽이 꺼져 있으면 그런 빌드는 없으므로 거절한다는 규칙이다. 이 책을 쓰며 그 예를 돌려 보니, choice 손잡이는 늘 값이 있어 “켜짐” 으로 읽혀서 smp 를 끄는 구성이 모두 E-CONFIG-DEPENDS 로 거절되었다. 개발 저장소의 시험은 그 구성으로 실행만 보고 검사는 보지 않아 이 어긋남을 놓쳤다. 이 장의 예제가 depends 를 뺀 이유다. 시험이 보는 것과 사용자가 하는 것이 다르면 초록불이 아무것도 말하지 않는다는 교훈이 여기에도 있다.

31.4 시험#

test <이름> do … end 블록 안의 expect 가 단언이다. --test 가 모든 시험을 돌린다.

examples/ch31/tests_clause.low

module tests_clause .
rem test

fn clamp8 input v u64 . output u8 .
do
  return narrow_sat u8 v .
end

test clamp_keeps_small
do
  expect eq (clamp8 7) 7 .
end

test clamp_saturates
do
  expect eq (clamp8 1000) 255 .
end

실행 결과

$ lowentc --test tests_clause.low
  [PASS] clamp_keeps_small
  [PASS] clamp_saturates
== tests: 2 run, 2 passed, 0 FAILED ==

시험이 실패하면 이렇게 나온다.

examples/ch31/failing.low

module failing .
rem test-fail

fn clamp8 input v u64 . output u8 .
do
  return narrow_wrap u8 v .
end

test clamp_saturates
do
  expect eq (clamp8 1000) 255 .
end

실행 결과

$ lowentc --test failing.low
  [FAIL] clamp_saturates
         E-TEST-FAIL: an `expect` in this test is FALSE — the test failed (this is not a contract violation: it is the test telling you the code is wrong)
== tests: 1 run, 0 passed, 1 FAILED ==

E-TEST-FAIL 은 계약 위반과 다른 진단이다. 계약 위반은 코드가 자기 약속을 어긴 것이고, 시험 실패는 시험이 코드가 틀렸다고 말하는 것이다. 고칠 것이 다르다. 여기서는 narrow_wrap 을 narrow_sat 로 바꿔야 한다. 그리고 처리기는 expect 를 최적화로 없애지 않는다. 사라진 시험은 돌지 않은 시험이다.

동시성 코드는 test <이름> schedule explore_interleavings . do … end 로 가능한 모든 흐름의 차례를 돌려 답이 같은지 본다 (26장). 경우가 많으면 limit <수> 로 상한을 둔다.

op 머리에도 문서와 시험을 적는 절이 있다.

examples/ch31/clauses.low

module clauses .
rem run: first_two [65,66]
rem test

enum parse_error do
  too_short .
end

fn first_two
  rem lowdoc — 사람이 읽는 설명. 도구가 문서로 뽑는다
  lowdoc "Add the first two bytes; refuses input shorter than two." .
  input data slice u8 .
  output result u64 parse_error .
  errors too_short lt (len data) 2 .
  rem tests — 이 op 을 시험하는 op 의 이름. 도구는 그 이름이 실제로 있는지 검사한다
  tests first_two_ok first_two_short .
do
  guard ge (len data) 2 . else return error too_short .
  return ok (add (widen u64 (index data 0)) (widen u64 (index data 1))) .
end

fn first_two_ok output bool . do
  let r result u64 parse_error be first_two "AB" .
  return is_ok r .
end

fn first_two_short output bool . do
  let r result u64 parse_error be first_two "A" .
  return is_error r .
end

rem test 블록이 시험 op 들을 부른다 — lowentc --test 가 돌린다
test first_two_cases do
  expect first_two_ok .
  expect first_two_short .
end

실행 결과

$ lowentc --run first_two clauses.low [65,66]
first_two([65,66]) = ok 131
  arg0 (written) = [65,66]
$ lowentc --test clauses.low
  [PASS] first_two_cases
== tests: 1 run, 1 passed, 0 FAILED ==

문. 시험이 있는데 계약까지 적어야 하는가?

답. 둘은 다른 것을 잡는다. 시험은 저자가 고른 입력에서 답이 맞는지 보고, 계약은 모든 호출에서 약속이 지켜지는지 본다. 그리고 계약은 시험을 만드는 재료가 된다. 개발 저장소의 계약 대조 도구는 기대 출력을 따로 적지 않고 requires·ensures·errors 를 판정 기준으로 삼아 경계값 입력을 만든다. 계약을 정직하게 적으면 시험이 공짜로 늘어난다.

31.5 컴파일러를 검증하는 법#

증명(제10부)은 모델에 대한 것이다. 실제 컴파일러가 그 모델을 따라가는지는 따로 확인해야 한다. 원리는 하나다 — 같은 것을 두 가지 방법으로 만들고, 답이 다르면 결함으로 본다.

방법무엇과 무엇을 맞대나무엇을 잡나
두 백엔드 대조VM 실행 ↔ 네이티브 실행두 백엔드가 다른 답을 내는 곳
계약 대조적어 둔 계약 ↔ 실제 실행 결과계약이 사실과 다른 곳
분석 자기 점검“안전하다” 고 믿은 색인 ↔ 실제 색인분석이 틀린 곳(E-VM-ANALYSIS)
증명서 검산검사를 지운 근거 ↔ 독립 검산기의 산술규칙을 잘못 적용한 곳
한 줄 바꾸기프로그램 ↔ 사실 하나를 지운 프로그램정답을 몰라도 관계가 깨진 곳

표 31.1 — 컴파일러를 맞대는 방법

이런 검사는 결함이 있음을 보이지만 없음을 보이지는 못한다. 두 구현이 같은 답을 낸다고 둘 다 맞는 것은 아니다. 없음은 증명의 몫이다. 공개 저장소에서 직접 돌려 볼 수 있는 것은 impl/ 의 make check 다. 단위 시험, 표준 라이브러리 전체 검사, 여러 op 의 VM·네이티브 대조, 거절되어야 하는 프로그램의 진단 코드를 한 번에 본다. 이 책의 검증 스크립트도 같은 대조를 모든 예제에 적용한다 — 그리고 그 대조가 이 책을 쓰는 동안 컴파일러의 어긋남 여럿을 드러냈다.

흔한 오해. 초록불이 켜지면 검사한 것이다

초록불은 본 것만 말한다. 대조 목록에 없는 연산은 초록으로도 빨간불로도 보이지 않는다. 그래서 개발 저장소는 대조가 한 번도 보지 못한 옵코드를 세어서 말하게 한다. 위의 “끌 수 없는 손잡이” 도 같다. 시험이 실행만 보고 검사를 보지 않았으므로 초록이었다. 세어지지 않는 것은 관리되지 않는다.

31.6 느린 자리를 묻는다#

네이티브 방출은 op 을 두 길로 내린다. 타입이 확정된 op 은 C 의 정수와 배열로 자연스럽게 내려가고, 그렇지 못한 op 은 태그를 단 값으로 도는 느린 길에 남는다. --why-slow 는 느린 길에 남은 op 과 그 이유를 말한다.

$ lowentc --why-slow bounds.low
why-slow: 0 / 1 op(s) still on the tagged path

성능이 서명에 보이지 않는 모델은 그 자체로 소스 밖의 지식이 된다. 그래서 도구가 말한다. --no-fast 는 모든 op 을 느린 길로 내리는 대조 스위치다. 두 길이 같은 답을 내는지 볼 때 쓴다.

31.7 흔한 실수#

반례. 구성 파일에 손잡이 이름을 잘못 적는다

examples/ch31/mistake_configtypo.low

module mistake_configtypo .
rem flags: --config typo.config
rem expect: E-CONFIG-UNDEF

rem ✘ 구성 파일에 `maxcpu` 를 `maxcpus` 로 적었다

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

실행 결과

$ lowentc --check --config typo.config mistake_configtypo.low
0:0 E-CONFIG-UNDEF: the config selects an option that is not declared. A setting nobody reads changes nothing — and it LOOKS like it does

maxcpus 8 은 아무도 읽지 않는 설정이다. 조용히 넘기면 maxcpu 는 기본값 64 로 남고, 쓰는 사람은 8 로 지었다고 믿는다. 그래서 E-CONFIG-UNDEF 로 멈춘다. 진단의 줄 번호가 0:0 인 것은 잘못이 소스가 아니라 구성 파일에 있기 때문이다.

반례. choice 손잡이에 선택지에 없는 값을 준다

examples/ch31/mistake_configvalue.low

module mistake_configvalue .
rem flags: --config badvalue.config
rem expect: E-CONFIG-TYPE

rem ✘ 구성 파일이 `hz` 에 선택지에 없는 300 을 준다

build option smp bool default true .
build option hz choice 100 250 1000 default 250 .
build option maxcpu int default 64 .

fn tick_rate output u64 .
do
  if config smp . do
    return config hz .
  end
  return 100 .
end

fn cpus output u64 .
do
  return config maxcpu .
end

실행 결과

$ lowentc --check --config badvalue.config mistake_configvalue.low
8:0 E-CONFIG-TYPE: the config picks a value this `choice` option does not offer

hz 는 100 · 250 · 1000 가운데 하나다. 300 을 주면 가장 가까운 값으로 맞추거나 기본값으로 돌아가지 않고 E-CONFIG-TYPE 으로 멈춘다. 선택지는 그 값들로 시험되었다는 뜻이기도 하다. 새 값이 필요하면 소스의 선언에 선택지를 더한다.

흔한 오해. 권한을 받는 op 은 --run 으로 못 부른다

examples/ch31/runcap.low

module runcap .
rem run: say 5
rem run: say 0 5

rem `--run` 은 권한 자리를 **비워 두면 채워 준다**(2026-09-20). 자리표 `0` 을 넣던 옛 모양도 그대로 돈다
proc say input out cap io . input n u64 . output u64 . effects io .
  requires le n 100 .
do
  return write_out out 1 "hi\n" .
end

실행 결과

$ lowentc --run say runcap.low 5
hi
say(5) = 3
$ lowentc --run say runcap.low 0 5
hi
say(0, 5) = 3

2026-09-20 까지는 그랬다: 권한 자리도 인자 수에 들어서 say 5 는 E-VM-ARITY 였고, 자리표 0 을 채운 say 0 5 만 돌았다. 그래서 cap io 나 cap allocator 를 받는 증인 프로그램 들은 네이티브로만 돌았고, 두 뒤끝을 맞대는 이 책의 규율이 그 자리에서 끊겨 있었다. 이제 도구가 권한 자리를 채운다 — 인자를 덜 준 경우에만. 그래서 say 5 도 say 0 5 도 돈다. 자리표는 여전히 도구의 편의일 뿐이다: 프로그램 안에서 권한 자리에 수를 넘기면 E-CAP-FORGE 로 거절된다(16장).

흔한 오해. 구성에서 꺼진 가지는 검사하지 않는다

examples/ch31/dead_branch.low

module dead_branch .
rem flags: --config smp_off.config
rem expect: E-TYPE-RETURN

build option smp bool default true .

fn rate output u64 .
do
  if config smp . do
    rem 이 구성에서는 꺼진 가지다 --- 그래도 검사받는다
    return true .
  end
  return 0 .
end

실행 결과

$ lowentc --check --config smp_off.config dead_branch.low
dead_branch.low:11:0 E-TYPE-RETURN: the returned value does not match the op's `output` — expected `u64`, found `bool`

smp 를 끈 구성에서는 return true . 가 결코 돌지 않는다. 그래도 u64 자리에 bool 을 돌려주는 이 줄은 E-TYPE-RETURN 으로 거절된다. C 의 #ifdef 안 코드는 켜지지 않은 조합에서 아무에게도 읽히지 않지만, config 의 꺼진 가지는 파싱되고 타입 검사를 받는다. 값이 정해진 뒤 생성물에서 빠질 뿐이다. 그래서 어떤 구성으로 지어도 나머지 조합이 썩지 않는다.

31.8 이 장의 문법 한눈에#

모양뜻왜 이렇게
package name "greeter" . · package version "0.1.0" .pkg.low 매니페스트여기만은 적은 것을 검사한다 — 열쇠말은 닫힌 집합
lowentc run · lowentc build프로젝트를 VM 으로 돌린다 · out/ 에 짓는다매니페스트를 걸어 올라가며 찾는다
lowentc add <이름> <자리> · --lock-write · --lock의존을 해시와 함께 고정한다판 번호가 같아도 바이트가 다르면 다른 의존
build option smp bool default true .빌드 손잡이 — bool·int·choice읽히지 않는 손잡이는 E-OPT-UNUSED
config smp · --config small.config손잡이 값을 번역 시점 상수로 읽는다 · 구성 파일꺼진 가지도 검사받는다
test <이름> do expect <조건> . end · --test시험 블록과 단언실패는 E-TEST-FAIL — 계약 위반과 다른 진단
test … schedule explore_interleavings limit <수> . do … end모든 차례를 돌리는 시험드문 차례의 결함
lowentc --run <op> <파일> <인자…>op 하나를 VM 으로 돌린다권한 자리는 도구가 채운다 — 거절된 단위는 돌지 않는다
--why-slow · --no-fast느린 길에 남은 op 과 이유 · 모두 느린 길로성능을 도구가 말한다
lowdoc "…" . · tests op1 op2 .op 에 딸린 문서 · 이 op 을 시험하는 op 이름문서는 op 과 함께 움직이고, 없어진 시험은 머리가 알린다

표 31.2 — 빌드와 시험의 문법 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

pkg.low 는 검사되는 매니페스트이고 lowentc run·build 가 프로젝트를 돌리고 짓는다. 의존은 내용 해시로 고정하고 진본은 서명으로 따로 확인한다. build option 과 config 는 빌드마다 다른 프로그램을 짓되 꺼진 가지도 검사받으며, 읽히지 않는 손잡이는 거절된다. test 블록의 실패는 계약 위반과 다른 진단이다. 컴파일러는 두 백엔드·계약·분석·증명서·한 줄 바꾸기로 맞대어 검증되고, --why-slow 는 느린 자리를 말한다.