fuzz: 문법에서 문장을 만들어 파서에 먹인다 — 갈림 하나를 잡았다

저장소의 .cool 파일로만 대조하면 사람이 쓴 코드만 훑는다. 문법이 약속했는데
파서가 못 읽는 구석은 아무도 안 밟으면 드러나지 않는다.

lib/ebnf_gen.ml이 문법에서 문장을 만든다. 텍스트가 아니라 토큰 열을 만드는
이유는, 렉서의 줄바꿈 삽입을 거치면 문법이 허용해도 렉서가 만들 수 없는
문장이 생기는데 그건 파서의 잘못이 아니기 때문이다. 검사하려는 것은 문법과
파서 사이지 렉서가 아니다.

커버리지를 같이 잰다. 안 밟은 규칙은 시험되지 않은 규칙이므로, 통과했다는
말에 값이 없다. 현재 프로덕션 95개 전부를 밟고 거부 0건이다.

이 퍼저가 잡은 갈림 하나: 대입 왼쪽 제약. 문법은 expr_stmt = expr, ["=" expr]
로 적었는데 파서는 파싱 중에 "변수나 필드만"을 강제하고 있었다. 구문으로
가르면 ident 하나로 대입과 식이 갈리지 않아 LL(1)이 깨지므로, 제약을 이름
해소로 옮겼다. 파서는 이제 순수하게 구문만 본다.

만드는 과정에서 퍼저 자체의 함정도 하나 지났다. 처음엔 연료를 총 확장
횟수로 셌더니 선언 머리에서 다 써 버려 식과 문에 도달하지 못했고, 커버리지를
재기 전까지는 "3000개 통과"가 아무 뜻도 아니었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZVDeU6KLuUVL3gs18Hm3E
This commit is contained in:
2026-08-30 17:10:20 +09:00
co-authored by Claude Opus 5
parent 61b1920909
commit bb19a39e04
5 changed files with 274 additions and 13 deletions
+53 -2
View File
@@ -327,8 +327,9 @@ let () =
(parse_err "fn f(\n a: Int\n)" = Some "다중 줄 목록에는 후행 콤마가 필요합니다");
check "if 머리의 struct 리터럴"
(parse_err "fn f() {\n if H { a: 1 } {\n b\n }\n}" <> None);
check "대입 왼쪽 검사"
(parse_err "fn f() {\n g() = 1\n}" = Some "대입 왼쪽에는 변수나 필드만 올 수 있습니다");
(* 대입 왼쪽 제약은 파서가 아니라 이름 해소가 본다. 구문으로 가르면
ident 하나로 대입과 식이 갈리지 않아 문법이 LL(1)이 아니게 된다 *)
check "대입 왼쪽은 파서가 보지 않는다" (parse_err "fn f() {\n g() = 1\n}" = None);
check "한 줄에 문 두 개" (parse_err "fn f() {\n a b\n}" = Some "문 끝에 줄바꿈이 필요합니다")
(* --- 괄호 안에서는 struct 리터럴이 다시 허용된다 --- *)
@@ -392,6 +393,8 @@ let has_err src frag =
let () =
check "중복 정의" (has_err "fn f()\nfn f()" "두 번 정의");
(* 파서에서 옮겨온 검사. 구문으로 가르면 문법이 LL(1)이 아니게 된다 *)
check "대입 왼쪽" (has_err "fn f() {\n g() = 1\n}" "대입 왼쪽에는 변수나 필드만");
check "중복 파라미터" (has_err "fn f(a: Int, a: Int)" "파라미터 a");
check "중복 제네릭" (has_err "fn f[a, a]()" "제네릭 파라미터 a");
check "선언되지 않은 effect 변수" (has_err "fn f() effects e" "선언되지 않은 effect 변수 e");
@@ -1426,3 +1429,51 @@ let () =
(if spec then "받음" else "거부");
check (Filename.basename f ^ ": 문법과 파서의 판정이 같다") (hand = spec))
files
(* 문법이 약속한 것을 파서가 실제로 읽는가.
저장소의 .cool 파일만으로는 사람이 쓴 코드만 훑는다 — 아무도 안 밟은
구석은 드러나지 않는다. 문법에서 문장을 만들어 파서에 먹인다.
커버리지를 같이 재는 이유: 안 밟은 규칙은 시험되지 않은 규칙이다. *)
let () =
let read f =
let ic = open_in_bin f in
let n = in_channel_length ic in
let s = really_input_string ic n in
close_in ic;
s
in
match Ebnf.parse_result (read "../docs/grammar.ebnf") with
| Error _ -> ()
| Ok g ->
let tokens =
[ "ident"; "int_lit"; "string_lit"; "NEWLINE"; "char"; "digit"; "letter" ]
in
let visited = Hashtbl.create 128 in
let bad = ref 0 in
let shown = ref 0 in
for i = 1 to 2500 do
Random.init i;
let toks = Ebnf_gen.sentence ~tokens ~visited ~max_depth:18 g in
match Parser.parse_result toks with
| Ok _ -> ()
| Error e ->
incr bad;
if !shown < 3 then begin
incr shown;
Printf.printf " (씨앗 %d: 문법은 만들었는데 파서가 거부 — %s)\n" i e.msg
end
done;
check "문법이 만든 문장을 파서가 전부 받는다" (!bad = 0);
let lexical =
[ "ident"; "int_lit"; "string_lit"; "str_char"; "escape"; "bool_lit"; "literal" ]
in
let all =
Ebnf.expand g
|> List.map (fun (r : Ebnf.rule) -> r.name)
|> List.sort_uniq compare
|> List.filter (fun r -> not (List.mem r lexical))
in
let unvisited = List.filter (fun r -> not (Hashtbl.mem visited r)) all in
if unvisited <> [] then
Printf.printf " (밟지 않은 프로덕션: %s)\n" (String.concat " " unvisited);
check "생성이 모든 프로덕션을 밟는다" (unvisited = [])