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
+66
View File
@@ -48,7 +48,73 @@ let compare_files files =
Printf.printf "\n파일 %d개 중 판정이 갈린 것 %d개\n" (List.length files) !bad;
if !bad > 0 then exit 1
(* 문법에서 문장을 만들어 손 파서가 받는지 본다 *)
let fuzz n =
let g =
match Coollang.Ebnf.parse_result (read "docs/grammar.ebnf") with
| Ok g -> g
| Error e -> failwith (Printf.sprintf "문법 %d행: %s" e.line e.msg)
in
let tokens =
[ "ident"; "int_lit"; "string_lit"; "NEWLINE"; "char"; "digit"; "letter" ]
in
let bad = ref 0 in
let visited = Hashtbl.create 128 in
for i = 1 to n do
Random.init i;
let toks = Coollang.Ebnf_gen.sentence ~tokens ~visited ~max_depth:18 g in
match Coollang.Parser.parse_result toks with
| Ok _ -> ()
| Error e ->
incr bad;
if !bad <= 5 then begin
Printf.printf "\n[%d] 문법은 만들었는데 파서가 거부: %s\n " i e.msg;
List.iter
(fun (t : Coollang.Token.t) ->
match t.kind with
| Coollang.Token.Eof -> ()
| Coollang.Token.Newline -> print_string "\\n "
| k -> Printf.printf "%s " (Coollang.Token.show_kind k))
toks;
print_newline ()
end
done;
Printf.printf "\n문장 %d개 중 파서가 거부한 것 %d개\n" n !bad;
(* 어휘 층은 파서 문법에서 도달할 수 없다 — 분모에서 뺀다 *)
let lexical = [ "ident"; "int_lit"; "string_lit"; "str_char"; "escape"; "bool_lit"; "literal" ] in
let all =
Coollang.Ebnf.expand g
|> List.map (fun (r : Coollang.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
Printf.printf "프로덕션 %d개 중 %d개를 밟았다\n" (List.length all)
(List.length all - List.length unvisited);
if unvisited <> [] then
Printf.printf "밟지 않은 것: %s\n" (String.concat " " unvisited);
if !bad > 0 then exit 1
let () =
if Array.length Sys.argv > 1 && Sys.argv.(1) = "--dump" then (
let g = match Coollang.Ebnf.parse_result (read "docs/grammar.ebnf") with
| Ok g -> g | Error e -> failwith (string_of_int e.line) in
let tokens = [ "ident"; "int_lit"; "string_lit"; "NEWLINE"; "char"; "digit"; "letter" ] in
for i = 1 to 8 do
Random.init i;
let toks = Coollang.Ebnf_gen.sentence ~tokens g in
Printf.printf "[%d] (%d토큰) " i (List.length toks);
List.iter (fun (t : Coollang.Token.t) ->
match t.kind with
| Coollang.Token.Eof -> ()
| Coollang.Token.Newline -> print_string "\\n "
| k -> Printf.printf "%s " (Coollang.Token.show_kind k)) toks;
print_newline ()
done;
exit 0);
if Array.length Sys.argv > 1 && Sys.argv.(1) = "--fuzz" then (
fuzz (if Array.length Sys.argv > 2 then int_of_string Sys.argv.(2) else 200);
exit 0);
if Array.length Sys.argv > 2 && Sys.argv.(1) = "--compare" then (
compare_files (Array.to_list (Array.sub Sys.argv 2 (Array.length Sys.argv - 2)));
exit 0);