Opinionated OCaml linter with Merlin integration for code quality, naming conventions, and style checks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

merlint: add E920 — flag README/.mli/.mld with untested OCaml code

A doc file with a code block (```ocaml fenced or {[ ... ]} odoc) is a
spec for the API. If no (mdx ...) stanza in the same dune file
references it, the snippet is never type-checked or run, so it drifts
silently as the API evolves.

E920 walks every dune file, scans sibling README.md/*.mli/*.mld for
OCaml code, and flags any whose covering mdx stanza is missing. A
(mdx ...) stanza without (files ...) defaults to README.md, matching
dune's default. Documentation category, with bad/good cram fixtures.

+237
+7
docs/index.html
··· 1749 1749 </div> 1750 1750 <div class="error-hint"><p>Every *.opam file must declare tags: ["org:blacksun" "&lt;topic&gt;" ...] where each topic is a slug declared in categories.toml at the project root (or listed in the topics: field of .merlint). Edit the package's dune-project so dune regenerates the opam file.</p></div> 1751 1751 </div> 1752 + <div class="error-card" id="E920"> 1753 + <div> 1754 + <span class="error-code">E920</span> 1755 + <span class="error-title">Untested OCaml code in documentation</span> 1756 + </div> 1757 + <div class="error-hint"><p>When a README.md, .mli or .mld contains OCaml code blocks (```ocaml fenced or {[ ... ]} odoc), add an (mdx (files &lt;file&gt;)) stanza to the same directory's dune file so the snippets are type-checked and run during dune test.</p></div> 1758 + </div> 1752 1759 1753 1760 <a href="#top" class="back-to-top">↑ Top</a> 1754 1761 </body>
+1
lib/data.ml
··· 80 80 E900.rule; 81 81 E905.rule; 82 82 E915.rule; 83 + E920.rule; 83 84 ]
+114
lib/rules/e920.ml
··· 1 + (** E920: documentation files with OCaml code blocks must be MDX-tested. *) 2 + 3 + type payload = { dune_file : string; doc_file : string } 4 + 5 + (* Markdown fenced ``` ocaml block, allowing language tags like ocaml, 6 + ocaml env=foo, ocaml skip, etc. *) 7 + let md_ocaml_re = 8 + Re.compile 9 + Re.( 10 + seq 11 + [ 12 + bol; 13 + str "```"; 14 + rep (alt [ char ' '; char '\t' ]); 15 + str "ocaml"; 16 + alt [ char '\n'; char ' '; char '\t' ]; 17 + ]) 18 + 19 + (* Odoc verbatim/code block: {[ ... ]}. Language defaults to OCaml. *) 20 + let odoc_block_re = Re.compile (Re.str "{[") 21 + 22 + let has_ocaml_code path = 23 + try 24 + let ic = open_in path in 25 + Fun.protect 26 + ~finally:(fun () -> close_in ic) 27 + (fun () -> 28 + let content = really_input_string ic (in_channel_length ic) in 29 + if Filename.check_suffix path ".md" then Re.execp md_ocaml_re content 30 + else 31 + (* .mli / .mld — odoc-style {[ ... ]} blocks *) 32 + Re.execp odoc_block_re content) 33 + with Sys_error _ -> false 34 + 35 + (* The set of files an (mdx ...) stanza references. We support: 36 + (mdx (files a.mli b.md)) 37 + (mdx (files a.mli) (files b.md)) 38 + (mdx) -> defaults to README.md 39 + Treat a bare (mdx ...) without (files ...) as covering README.md, which 40 + matches dune's default. *) 41 + let mdx_covered_files stanzas = 42 + let from_files_field = function 43 + | Sexp.List (Sexp.Atom "files" :: rest) -> 44 + List.filter_map (function Sexp.Atom s -> Some s | _ -> None) rest 45 + | _ -> [] 46 + in 47 + List.concat_map 48 + (function 49 + | Sexp.List (Sexp.Atom "mdx" :: fields) -> 50 + let listed = List.concat_map from_files_field fields in 51 + if listed = [] then [ "README.md" ] else listed 52 + | _ -> []) 53 + stanzas 54 + 55 + let parse_dune_file path = 56 + try 57 + let ic = open_in path in 58 + Fun.protect 59 + ~finally:(fun () -> close_in ic) 60 + (fun () -> 61 + let content = really_input_string ic (in_channel_length ic) in 62 + match Sexp.Value.parse_string_many content with 63 + | Ok stanzas -> stanzas 64 + | Error _ -> []) 65 + with Sys_error _ -> [] 66 + 67 + let scan_dir dune_path = 68 + let dir = Filename.dirname dune_path in 69 + let stanzas = parse_dune_file dune_path in 70 + let covered = mdx_covered_files stanzas in 71 + let entries = try Sys.readdir dir |> Array.to_list with Sys_error _ -> [] in 72 + List.filter_map 73 + (fun name -> 74 + let path = Filename.concat dir name in 75 + let is_doc = 76 + name = "README.md" 77 + || Filename.check_suffix name ".mli" 78 + || Filename.check_suffix name ".mld" 79 + in 80 + if (not is_doc) || List.mem name covered then None 81 + else if has_ocaml_code path then 82 + Some (Issue.v { dune_file = dune_path; doc_file = name }) 83 + else None) 84 + entries 85 + 86 + let rec dune_files dir = 87 + let entries = try Sys.readdir dir |> Array.to_list with Sys_error _ -> [] in 88 + List.concat_map 89 + (fun entry -> 90 + if String.length entry > 0 && (entry.[0] = '.' || entry.[0] = '_') then [] 91 + else 92 + let path = Filename.concat dir entry in 93 + if entry = "dune" && try not (Sys.is_directory path) with _ -> false 94 + then [ path ] 95 + else if try Sys.is_directory path with _ -> false then dune_files path 96 + else []) 97 + entries 98 + 99 + let check (ctx : Context.project) = 100 + List.concat_map scan_dir (dune_files ctx.project_root) 101 + 102 + let pp ppf { dune_file; doc_file } = 103 + Fmt.pf ppf "%s/%s: contains OCaml code blocks but %s has no (mdx ...) stanza" 104 + (Filename.dirname dune_file) 105 + doc_file dune_file 106 + 107 + let rule = 108 + Rule.v ~code:"E920" ~title:"Untested OCaml code in documentation" 109 + ~hint: 110 + "When a README.md, .mli or .mld contains OCaml code blocks (```ocaml \ 111 + fenced or {[ ... ]} odoc), add an (mdx (files <file>)) stanza to the \ 112 + same directory's dune file so the snippets are type-checked and run \ 113 + during dune test." 114 + ~category:Rule.Documentation ~examples:[] ~pp (Project check)
+10
lib/rules/e920.mli
··· 1 + (** E920: documentation files with OCaml code blocks must be MDX-tested. 2 + 3 + A README.md, .mli or .mld file that contains an OCaml code block 4 + ([```ocaml ...```] in markdown, [{[ ... ]}] in odoc) should be listed in an 5 + [(mdx (files ...))] stanza in the same directory's [dune] file so the code 6 + is type-checked and run during [dune test]. Untested examples drift silently 7 + as the API evolves. *) 8 + 9 + val rule : Rule.t 10 + (** The E920 rule definition. *)
+7
test/cram/e920.t/bad/README.md
··· 1 + # bad 2 + 3 + Untested example: 4 + 5 + ```ocaml 6 + let _ = print_endline "hi" 7 + ```
+3
test/cram/e920.t/bad/dune
··· 1 + (env 2 + (dev 3 + (flags :standard %{dune-warnings})))
+1
test/cram/e920.t/bad/dune-project
··· 1 + (lang dune 3.21)
+2
test/cram/e920.t/bad/lib/dune
··· 1 + (library 2 + (name foo))
+7
test/cram/e920.t/bad/lib/foo.mli
··· 1 + (** A library with an untested example. 2 + 3 + {[ 4 + let answer = Foo.compute () 5 + ]} *) 6 + 7 + val compute : unit -> int
+7
test/cram/e920.t/good/README.md
··· 1 + # good 2 + 3 + This example is MDX-tested: 4 + 5 + ```ocaml 6 + let _ = print_endline "hi" 7 + ```
+6
test/cram/e920.t/good/dune
··· 1 + (env 2 + (dev 3 + (flags :standard %{dune-warnings}))) 4 + 5 + (mdx 6 + (files README.md))
+1
test/cram/e920.t/good/dune-project
··· 1 + (lang dune 3.21)
+5
test/cram/e920.t/good/lib/dune
··· 1 + (library 2 + (name foo)) 3 + 4 + (mdx 5 + (files foo.mli))
+7
test/cram/e920.t/good/lib/foo.mli
··· 1 + (** A library with an MDX-tested example. 2 + 3 + {[ 4 + let answer = Foo.compute () 5 + ]} *) 6 + 7 + val compute : unit -> int
+59
test/cram/e920.t/run.t
··· 1 + Bad: README.md and lib/foo.mli have OCaml code blocks but no mdx stanza references them. 2 + 3 + $ merlint -B -r E920 bad/ 4 + Running merlint analysis... 5 + 6 + Analyzing 1 files 7 + 8 + ✓ Code Quality (0 total issues) 9 + ✓ Code Style (0 total issues) 10 + ✓ Naming Conventions (0 total issues) 11 + ✗ Documentation (2 total issues) 12 + [E920] Untested OCaml code in documentation (2 issues) 13 + When a README.md, .mli or .mld contains OCaml code blocks (```ocaml fenced or 14 + {[ ... ]} odoc), add an (mdx (files <file>)) stanza to the same directory's 15 + dune file so the snippets are type-checked and run during dune test. 16 + - (global) bad/lib/foo.mli: contains OCaml code blocks but bad/lib/dune has no (mdx ...) stanza 17 + - (global) bad/README.md: contains OCaml code blocks but bad/dune has no (mdx ...) stanza 18 + ✓ Project Structure (0 total issues) 19 + ✓ Test Quality (0 total issues) 20 + ✓ Interop Testing (0 total issues) 21 + ✓ Code Generation (0 total issues) 22 + 23 + ╭───────────────┬────────────────────────────────────────────╮ 24 + │ Category │ Issues │ 25 + ├───────────────┼────────────────────────────────────────────┤ 26 + │ Documentation │ 2 (2 untested ocaml code in documentation) │ 27 + ╰───────────────┴────────────────────────────────────────────╯ 28 + 29 + 30 + Summary: ✗ 2 total issues (applied 1 rule) 31 + ✗ Some checks failed. See details above. 32 + [1] 33 + 34 + 35 + 36 + 37 + 38 + 39 + Good: every doc file with OCaml code is referenced by an mdx stanza. 40 + 41 + $ merlint -B -r E920 good/ 42 + Running merlint analysis... 43 + 44 + Analyzing 1 files 45 + 46 + ✓ Code Quality (0 total issues) 47 + ✓ Code Style (0 total issues) 48 + ✓ Naming Conventions (0 total issues) 49 + ✓ Documentation (0 total issues) 50 + ✓ Project Structure (0 total issues) 51 + ✓ Test Quality (0 total issues) 52 + ✓ Interop Testing (0 total issues) 53 + ✓ Code Generation (0 total issues) 54 + 55 + Summary: ✓ 0 total issues (applied 1 rule) 56 + ✓ All checks passed! 57 + 58 + 59 +