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.

project rules: attach Location so per-file exclusions work

Project-scoped rules whose issues describe a specific file (E400, E520,
E521, E522, E523, E524, E525, E526, E800, E803, E805, E806, E807, E810,
E815, E820, E825, E835, E900, E905, E910, E915, E920) now pass the
offending file path through Issue.v ~loc:(Location.in_file ...). The
engine's per-file exclusion check in engine.ml only fires on issues that
carry a Location, so without this the (global) bucket bypassed the
.merlint exclusion mechanism entirely.

Also:

- E524 switched from a Project rule that walked the filesystem and
regex-matched [Cmd.v] over raw text to a File rule that counts
[Cmd.v] identifier references in the typed dump. The previous regex
produced false positives on files that mentioned [Cmd.v] in
docstrings or string literals (e.g. e524.ml itself).

- New Location.in_file helper replaces the verbose
Location.v ~file:_ ~start_line:1 ~start_col:0 ~end_line:1 ~end_col:0
boilerplate at every call site.

- Cram tests promoted to reflect the new "<file>:1:0:" prefix in lieu
of the old "(global)" prefix.

- New test/test_categories.ml covers Categories.load to silence E605
on lib/categories.ml.

- merlint's own .merlint excludes the cram fixtures under
test/cram/**, which exist intentionally as good/bad inputs to the
cram tests and should never be analysed as merlint source.

+238 -114
+25 -1
.merlint
··· 6 6 rules: 7 7 # Test example for demonstration 8 8 - files: lib/rules/test*.ml 9 - exclude: [E100] 9 + exclude: [E100] 10 + # Cram fixtures under test/cram/*.t/ are intentional good/bad samples 11 + # that cram tests run merlint against; they are not merlint source. 12 + # File paths come through workspace-relative when run from outside merlint/, 13 + # hence the merlint/ prefix. 14 + - files: merlint/test/cram/**/*.ml 15 + exclude: [*] 16 + - files: merlint/test/cram/**/*.mli 17 + exclude: [*] 18 + - files: merlint/test/cram/**/dune 19 + exclude: [*] 20 + - files: merlint/test/cram/**/*.md 21 + exclude: [*] 22 + - files: merlint/test/cram/**/*.mld 23 + exclude: [*] 24 + - files: test/cram/**/*.ml 25 + exclude: [*] 26 + - files: test/cram/**/*.mli 27 + exclude: [*] 28 + - files: test/cram/**/dune 29 + exclude: [*] 30 + - files: test/cram/**/*.md 31 + exclude: [*] 32 + - files: test/cram/**/*.mld 33 + exclude: [*]
+3
lib/location.ml
··· 1 1 include Merlin.Location 2 2 3 3 let pp ppf loc = Fmt.pf ppf "%s:%d:%d" loc.file loc.start.line loc.start.col 4 + 5 + let in_file file = 6 + Merlin.Location.v ~file ~start_line:1 ~start_col:0 ~end_line:1 ~end_col:0
+6
lib/location.mli
··· 1 1 (** Re-export of Merlin's location types for source positions. *) 2 2 3 3 include module type of Merlin.Location 4 + 5 + val in_file : string -> t 6 + (** [in_file file] is a location pointing at the very start of [file] (line 1, 7 + column 0). Use it when an issue is about a whole file rather than a specific 8 + line — it lets the engine's per-file exclusion logic treat the issue as 9 + belonging to that file. *)
+9 -2
lib/rules/e400.ml
··· 26 26 let rec check_first_non_empty = function 27 27 | [] -> 28 28 (* Empty file - missing documentation *) 29 - Some (Issue.v { module_name; file = filename }) 29 + Some 30 + (Issue.v 31 + ~loc:(Location.in_file filename) 32 + { module_name; file = filename }) 30 33 | line :: rest -> 31 34 let trimmed = String.trim line in 32 35 if trimmed = "" then check_first_non_empty rest ··· 35 38 (* Regular comment - skip it and continue looking *) 36 39 if ends_with "*)" trimmed then check_first_non_empty rest 37 40 else check_first_non_empty (skip_comment rest) 38 - else Some (Issue.v { module_name; file = filename }) 41 + else 42 + Some 43 + (Issue.v 44 + ~loc:(Location.in_file filename) 45 + { module_name; file = filename }) 39 46 in 40 47 check_first_non_empty lines 41 48
+3 -1
lib/rules/e520.ml
··· 23 23 let has_ml f = Filename.check_suffix f ".ml" in 24 24 if Sys.file_exists src_dir && Sys.is_directory src_dir then 25 25 let src_has_ml = List.exists has_ml (try_readdir src_dir) in 26 - if src_has_ml then issues := Issue.v { package = pkg } :: !issues) 26 + if src_has_ml then 27 + let loc = Location.in_file (Filename.concat pkg "dune-project") in 28 + issues := Issue.v ~loc { package = pkg } :: !issues) 27 29 packages; 28 30 !issues 29 31
+3 -3
lib/rules/e521.ml
··· 41 41 let full = Filename.concat test_dir name in 42 42 let ok = try is_cram name full with Sys_error _ -> false in 43 43 if ok then 44 - issues := 45 - Issue.v { package = pkg; path = Filename.concat "test" name } 46 - :: !issues) 44 + let path = Filename.concat "test" name in 45 + let loc = Location.in_file (Filename.concat pkg path) in 46 + issues := Issue.v ~loc { package = pkg; path } :: !issues) 47 47 entries) 48 48 packages; 49 49 !issues
+2 -1
lib/rules/e522.ml
··· 99 99 in 100 100 if not (List.mem mod_name claimed) then 101 101 let path = Filename.concat (Filename.concat pkg "lib") name in 102 - issues := Issue.v { package = pkg; file = path } :: !issues) 102 + let loc = Location.in_file path in 103 + issues := Issue.v ~loc { package = pkg; file = path } :: !issues) 103 104 (try_readdir lib_dir)) 104 105 packages; 105 106 !issues
+7 -2
lib/rules/e523.ml
··· 219 219 | [ specs ] -> 220 220 (* Single module-accepting stanza. An explicit list is redundant. *) 221 221 if List.exists (function Explicit _ -> true | _ -> false) specs then 222 - Some (Issue.v { dune = path; kind = Redundant }) 222 + Some 223 + (Issue.v ~loc:(Location.in_file path) 224 + { dune = path; kind = Redundant }) 223 225 else None 224 226 | _ :: _ :: _ -> 225 227 (* Multiple module-accepting stanzas share a directory. If any ··· 255 257 files 256 258 in 257 259 if missing = [] then None 258 - else Some (Issue.v { dune = path; kind = Uncovered missing })) 260 + else 261 + Some 262 + (Issue.v ~loc:(Location.in_file path) 263 + { dune = path; kind = Uncovered missing })) 259 264 260 265 let check (ctx : Context.project) = 261 266 let dunes = find_dune_files ctx.project_root in
+20 -50
lib/rules/e524.ml
··· 16 16 [cmd_verse/] with one file per leaf, or use [cmd_verse_<name>.ml] siblings). 17 17 *) 18 18 19 - type payload = { file : string; count : int } 20 - 21 - let cmd_v_re = 22 - Re.compile (Re.seq [ Re.bow; Re.str "Cmd"; Re.char '.'; Re.str "v"; Re.eow ]) 23 - 24 - let count_cmd_v contents = Re.all cmd_v_re contents |> List.length 25 - 26 - let read_file path = 27 - try 28 - let ic = open_in path in 29 - let n = in_channel_length ic in 30 - let s = really_input_string ic n in 31 - close_in ic; 32 - Some s 33 - with Sys_error _ -> None 19 + type payload = { count : int } 34 20 35 - let find_ml_files root = 36 - let try_readdir d = 37 - try Sys.readdir d |> Array.to_list with Sys_error _ -> [] 38 - in 39 - let is_dir p = try Sys.is_directory p with Sys_error _ -> false in 40 - let rec walk dir acc = 41 - List.fold_left 42 - (fun acc name -> 43 - if 44 - name = "_build" || name = "_opam" || name = ".git" 45 - || String.starts_with ~prefix:"." name 46 - then acc 47 - else 48 - let p = Filename.concat dir name in 49 - if is_dir p then walk p acc 50 - else if Filename.check_suffix name ".ml" then p :: acc 51 - else acc) 52 - acc (try_readdir dir) 53 - in 54 - walk root [] 21 + let count_cmd_v_in_dump (dump : Merlin.Dump.t) = 22 + List.fold_left 23 + (fun acc (elt : Merlin.Dump.elt) -> 24 + match (elt.name.prefix, elt.name.base) with 25 + | [ "Cmd" ], "v" -> acc + 1 26 + | _ -> acc) 27 + 0 dump.identifiers 55 28 56 - let check (ctx : Context.project) = 57 - let files = find_ml_files ctx.project_root in 58 - List.filter_map 59 - (fun path -> 60 - match read_file path with 61 - | None -> None 62 - | Some contents -> 63 - let count = count_cmd_v contents in 64 - if count >= 2 then Some (Issue.v { file = path; count }) else None) 65 - files 29 + let check (ctx : Context.file) = 30 + if not (Filename.check_suffix ctx.filename ".ml") then [] 31 + else 32 + let count = count_cmd_v_in_dump (Context.dump ctx) in 33 + if count >= 2 then 34 + [ Issue.v ~loc:(Location.in_file ctx.filename) { count } ] 35 + else [] 66 36 67 - let pp ppf { file; count } = 37 + let pp ppf { count } = 68 38 Fmt.pf ppf 69 - "%s defines %d Cmdliner subcommands in one file; split them into one file \ 70 - per subcommand" 71 - file count 39 + "defines %d Cmdliner subcommands in one file; split them into one file per \ 40 + subcommand" 41 + count 72 42 73 43 let rule = 74 44 Rule.v ~code:"E524" ~title:"Multiple Cmdliner subcommands in one file" ··· 79 49 reference it from main.ml's Cmd.group. Sub-subcommands of a grouped \ 80 50 subcommand follow the same rule — use cmd_<parent>/<leaf>.ml or \ 81 51 cmd_<parent>_<leaf>.ml siblings." 82 - ~examples:[] ~pp (Project check) 52 + ~examples:[] ~pp (File check)
+11 -5
lib/rules/e525.ml
··· 91 91 let pkg_dir = Filename.concat root name in 92 92 if (not (is_dir pkg_dir)) || not (has_opam_file pkg_dir) then [] 93 93 else 94 + let dune_path = Filename.concat pkg_dir "dune" in 95 + let dp_path = Filename.concat pkg_dir "dune-project" in 96 + let dune_loc = Location.in_file dune_path in 97 + let dp_loc = Location.in_file dp_path in 94 98 let dune_issue = 95 - let dune_path = Filename.concat pkg_dir "dune" in 96 99 if not (Sys.file_exists dune_path) then 97 - [ Issue.v { package = name; kind = Missing_dune } ] 100 + [ Issue.v ~loc:dune_loc { package = name; kind = Missing_dune } ] 98 101 else 99 102 match read_file dune_path with 100 103 | Some c when contains_warnings c -> [] 101 - | _ -> [ Issue.v { package = name; kind = Missing_warnings } ] 104 + | _ -> 105 + [ 106 + Issue.v ~loc:dune_loc 107 + { package = name; kind = Missing_warnings }; 108 + ] 102 109 in 103 110 let lang_issue = 104 - let dp_path = Filename.concat pkg_dir "dune-project" in 105 111 match read_file dp_path with 106 112 | None -> [] 107 113 | Some c -> ( 108 114 match parse_lang_version c with 109 115 | Some v when version_too_old v -> 110 116 [ 111 - Issue.v 117 + Issue.v ~loc:dp_loc 112 118 { package = name; kind = Lang_too_old { version = v } }; 113 119 ] 114 120 | _ -> [])
+4 -2
lib/rules/e526.ml
··· 79 79 if (not (is_dir pkg_dir)) || not (has_opam_file pkg_dir) then [] 80 80 else 81 81 let dp_path = Filename.concat pkg_dir "dune-project" in 82 + let loc = Location.in_file dp_path in 82 83 match read_file dp_path with 83 84 | None -> [] 84 85 | Some c -> ( 85 86 match find_setting c with 86 87 | Some "false" | Some "false-if-hidden-includes-supported" -> [] 87 88 | Some "true" -> 88 - [ Issue.v { package = name; kind = Set_to_true } ] 89 - | Some _ | None -> [ Issue.v { package = name; kind = Missing } ])) 89 + [ Issue.v ~loc { package = name; kind = Set_to_true } ] 90 + | Some _ | None -> 91 + [ Issue.v ~loc { package = name; kind = Missing } ])) 90 92 (try_readdir root) 91 93 92 94 let pp ppf { package; kind } =
+1 -1
lib/rules/e800.ml
··· 9 9 if d.has_scripts then 10 10 let generate_sh = Filename.concat d.path "scripts/generate.sh" in 11 11 if not (Sys.file_exists generate_sh) then 12 - Some (Issue.v { dir = d.path }) 12 + Some (Issue.v ~loc:(Location.in_file generate_sh) { dir = d.path }) 13 13 else None 14 14 else None) 15 15 dirs
+3 -1
lib/rules/e803.ml
··· 24 24 shell_patterns 25 25 in 26 26 match found with 27 - | Some pattern -> Some (Issue.v { dir = d.path; pattern }) 27 + | Some pattern -> 28 + let loc = Location.in_file (Filename.concat d.path "test.ml") in 29 + Some (Issue.v ~loc { dir = d.path; pattern }) 28 30 | None -> None 29 31 else None) 30 32 dirs
+5 -1
lib/rules/e805.ml
··· 16 16 let has_requirements = 17 17 Sys.file_exists (Filename.concat scripts "requirements.txt") 18 18 in 19 - if has_python && not has_requirements then Some (Issue.v { dir = d.path }) 19 + if has_python && not has_requirements then 20 + let loc = 21 + Location.in_file (Filename.concat scripts "requirements.txt") 22 + in 23 + Some (Issue.v ~loc { dir = d.path }) 20 24 else None) 21 25 dirs 22 26
+5 -2
lib/rules/e806.ml
··· 13 13 |> List.exists (fun f -> Filename.check_suffix f ".go") 14 14 with Sys_error _ -> false 15 15 in 16 - let has_go_mod = Sys.file_exists (Filename.concat scripts "go.mod") in 17 - if has_go && not has_go_mod then Some (Issue.v { dir = d.path }) else None) 16 + let go_mod = Filename.concat scripts "go.mod" in 17 + let has_go_mod = Sys.file_exists go_mod in 18 + if has_go && not has_go_mod then 19 + Some (Issue.v ~loc:(Location.in_file go_mod) { dir = d.path }) 20 + else None) 18 21 dirs 19 22 20 23 let pp ppf { dir } = Fmt.pf ppf "Go oracle %s/scripts/ missing go.mod" dir
+4 -2
lib/rules/e807.ml
··· 21 21 with Sys_error _ -> false 22 22 with Sys_error _ -> false 23 23 in 24 - let has_cargo = Sys.file_exists (Filename.concat scripts "Cargo.toml") in 25 - if has_rust && not has_cargo then Some (Issue.v { dir = d.path }) 24 + let cargo = Filename.concat scripts "Cargo.toml" in 25 + let has_cargo = Sys.file_exists cargo in 26 + if has_rust && not has_cargo then 27 + Some (Issue.v ~loc:(Location.in_file cargo) { dir = d.path }) 26 28 else None) 27 29 dirs 28 30
+3 -1
lib/rules/e810.ml
··· 12 12 not 13 13 (Astring.String.is_infix ~affix:"regen-traces" content 14 14 || Astring.String.is_infix ~affix:"regen_traces" content) 15 - then Some (Issue.v { dir = d.path }) 15 + then 16 + let loc = Location.in_file (Filename.concat d.path "dune") in 17 + Some (Issue.v ~loc { dir = d.path }) 16 18 else None 17 19 else None) 18 20 dirs
+2 -1
lib/rules/e815.ml
··· 9 9 if d.has_dune then 10 10 let content = Interop.dune_content d.path in 11 11 if Astring.String.is_infix ~affix:"REGEN_TRACES" content then 12 - Some (Issue.v { dir = d.path }) 12 + let loc = Location.in_file (Filename.concat d.path "dune") in 13 + Some (Issue.v ~loc { dir = d.path }) 13 14 else None 14 15 else None) 15 16 dirs
+2 -1
lib/rules/e820.ml
··· 19 19 && Astring.String.is_infix ~affix:"','" content 20 20 in 21 21 if has_open_in && has_input_line && has_split_comma then 22 - Some (Issue.v { dir = d.path }) 22 + let loc = Location.in_file (Filename.concat d.path "test.ml") in 23 + Some (Issue.v ~loc { dir = d.path }) 23 24 else None 24 25 else None) 25 26 dirs
+2 -1
lib/rules/e825.ml
··· 17 17 if has_csv then 18 18 let dune = Interop.dune_content d.path in 19 19 if not (Astring.String.is_infix ~affix:"csv" dune) then 20 - Some (Issue.v { dir = d.path }) 20 + let loc = Location.in_file (Filename.concat d.path "dune") in 21 + Some (Issue.v ~loc { dir = d.path }) 21 22 else None 22 23 else None 23 24 else None)
+3 -1
lib/rules/e835.ml
··· 28 28 in 29 29 match bad with 30 30 | [] -> None 31 - | file :: _ -> Some (Issue.v { dir = d.path; file })) 31 + | file :: _ -> 32 + let loc = Location.in_file (Filename.concat scripts file) in 33 + Some (Issue.v ~loc { dir = d.path; file })) 32 34 dirs 33 35 34 36 let pp ppf { dir; file } =
+2 -1
lib/rules/e900.ml
··· 41 41 if has_wire then 42 42 let c_dir = Filename.concat pkg_dir "c" in 43 43 if not (Sys.file_exists c_dir && Sys.is_directory c_dir) then 44 - issues := Issue.v { package = pkg } :: !issues) 44 + let loc = Location.in_file (Filename.concat pkg "dune-project") in 45 + issues := Issue.v ~loc { package = pkg } :: !issues) 45 46 packages; 46 47 !issues 47 48
+3 -3
lib/rules/e905.ml
··· 55 55 (fun sym -> 56 56 let pattern = "val " ^ sym in 57 57 if Astring.String.is_infix ~affix:pattern content then 58 - issues := 59 - Issue.v { file = Filename.concat pkg f; symbol = sym } 60 - :: !issues) 58 + let file = Filename.concat pkg f in 59 + let loc = Location.in_file file in 60 + issues := Issue.v ~loc { file; symbol = sym } :: !issues) 61 61 wire_symbols 62 62 with _ -> ()) 63 63 mli_files)
+3 -1
lib/rules/e910.ml
··· 108 108 findings := Undeclared feature :: !findings) 109 109 detected; 110 110 if !findings <> [] then 111 - issues := Issue.v { package = pkg; findings = !findings } :: !issues)) 111 + let loc = Location.in_file (Filename.concat pkg "dune-project") in 112 + issues := 113 + Issue.v ~loc { package = pkg; findings = !findings } :: !issues)) 112 114 packages; 113 115 !issues 114 116
+3 -1
lib/rules/e915.ml
··· 84 84 (fun opam -> 85 85 let findings = check_opam_file ~topics pkg_dir opam in 86 86 if findings <> [] then 87 - issues := Issue.v { package = pkg; opam; findings } :: !issues) 87 + let loc = Location.in_file (Filename.concat pkg opam) in 88 + issues := 89 + Issue.v ~loc { package = pkg; opam; findings } :: !issues) 88 90 (list_opam_files pkg_dir)) 89 91 packages; 90 92 !issues
+3 -1
lib/rules/e920.ml
··· 79 79 in 80 80 if (not is_doc) || List.mem name covered then None 81 81 else if has_ocaml_code path then 82 - Some (Issue.v { dune_file = dune_path; doc_file = name }) 82 + Some 83 + (Issue.v ~loc:(Location.in_file path) 84 + { dune_file = dune_path; doc_file = name }) 83 85 else None) 84 86 entries 85 87
+1 -1
test/cram/e400.t/run.t
··· 12 12 MLI files should start with a documentation comment (** ... *) that describes 13 13 the module's purpose and API. This helps users understand how to use the 14 14 module. Test modules (test_*) are excluded from this check. 15 - - (global) Module bad (bad.mli) is missing documentation comment 15 + - bad.mli:1:0: Module bad (bad.mli) is missing documentation comment 16 16 ✓ Project Structure (0 total issues) 17 17 ✓ Test Quality (0 total issues) 18 18 ✓ Interop Testing (0 total issues)
+1 -1
test/cram/e520.t/run.t
··· 13 13 The monorepo convention is lib/ for library code. Rename src/ to lib/ with 14 14 `git mv`; no dune changes are needed because dune auto-discovers modules in 15 15 either directory. 16 - - (global) pkg uses src/ for its library; rename to lib/ to match the monorepo convention 16 + - pkg/dune-project:1:0: pkg uses src/ for its library; rename to lib/ to match the monorepo convention 17 17 ✓ Test Quality (0 total issues) 18 18 ✓ Interop Testing (0 total issues) 19 19 ✓ Code Generation (0 total issues)
+1 -1
test/cram/e521.t/run.t
··· 13 13 Move cram tests (.t files or .t/ directories) under the package's test/cram/ 14 14 umbrella. Shared driver exes go in test/cram/helpers/; shell setup goes in 15 15 test/cram/helpers.sh (sourced via (setup_scripts helpers.sh)). 16 - - (global) pkg/test/foo.t should live under pkg/test/cram/ 16 + - pkg/test/foo.t:1:0: pkg/test/foo.t should live under pkg/test/cram/ 17 17 ✓ Test Quality (0 total issues) 18 18 ✓ Interop Testing (0 total issues) 19 19 ✓ Code Generation (0 total issues)
+1 -1
test/cram/e522.t/run.t
··· 14 14 similarly). Dune's default wrapped mode will expose it as <Pkg>.Foo. For 15 15 something that really needs its own public name, create a sublib directory 16 16 (<pkg>/lib_foo/ with its own dune) instead. 17 - - (global) foo/lib/foo_bar.ml uses package-prefixed module name; drop the prefix and let dune's wrapping expose it as a submodule, or move it into a sublib directory 17 + - foo/lib/foo_bar.ml:1:0: foo/lib/foo_bar.ml uses package-prefixed module name; drop the prefix and let dune's wrapping expose it as a submodule, or move it into a sublib directory 18 18 ✓ Test Quality (0 total issues) 19 19 ✓ Interop Testing (0 total issues) 20 20 ✓ Code Generation (0 total issues)
+2 -2
test/cram/e523.t/run.t
··· 19 19 otherwise some module is silently dropped. Prefer splitting into sibling 20 20 directories when the stanza split is a design choice rather than a build 21 21 requirement. 22 - - (global) bad/uncovered/dune has multiple stanzas but the (modules ...) fields do not cover c; those .ml files are silently excluded from the build 23 - - (global) bad/redundant/dune has a single stanza with a redundant (modules ...) field; drop it and let dune auto-discover the .ml files 22 + - bad/redundant/dune:1:0: bad/redundant/dune has a single stanza with a redundant (modules ...) field; drop it and let dune auto-discover the .ml files 23 + - bad/uncovered/dune:1:0: bad/uncovered/dune has multiple stanzas but the (modules ...) fields do not cover c; those .ml files are silently excluded from the build 24 24 ✓ Test Quality (0 total issues) 25 25 ✓ Interop Testing (0 total issues) 26 26 ✓ Code Generation (0 total issues)
+3
test/cram/e524.t/bad/dune
··· 1 + (executable 2 + (name main) 3 + (libraries cmdliner))
+3
test/cram/e524.t/good/dune
··· 1 + (executable 2 + (name main) 3 + (libraries cmdliner))
+3 -3
test/cram/e524.t/run.t
··· 2 2 $ merlint -B -r E524 bad/ 3 3 Running merlint analysis... 4 4 5 - Analyzing 0 files 5 + Analyzing 1 files 6 6 7 7 ✓ Code Quality (0 total issues) 8 8 ✓ Code Style (0 total issues) ··· 14 14 sibling file (e.g. cmd_<name>.ml exposing a single val cmd) and reference it 15 15 from main.ml's Cmd.group. Sub-subcommands of a grouped subcommand follow the 16 16 same rule — use cmd_<parent>/<leaf>.ml or cmd_<parent>_<leaf>.ml siblings. 17 - - (global) bad/main.ml defines 2 Cmdliner subcommands in one file; split them into one file per subcommand 17 + - bad/main.ml:1:0: defines 2 Cmdliner subcommands in one file; split them into one file per subcommand 18 18 ✓ Test Quality (0 total issues) 19 19 ✓ Interop Testing (0 total issues) 20 20 ✓ Code Generation (0 total issues) ··· 34 34 $ merlint -B -r E524 good/ 35 35 Running merlint analysis... 36 36 37 - Analyzing 0 files 37 + Analyzing 1 files 38 38 39 39 ✓ Code Quality (0 total issues) 40 40 ✓ Code Style (0 total issues)
+1 -1
test/cram/e525.t/run.t
··· 15 15 newer. This mirrors the workspace-root dune so that a standalone opam build of 16 16 the package still enforces strict warnings under the dev profile. Reference: 17 17 alcobar/dune. 18 - - (global) foo has no root dune file; add foo/dune with (env (dev (flags :standard %{dune-warnings}))) so standalone opam builds fail on warnings 18 + - bad/foo/dune:1:0: foo has no root dune file; add foo/dune with (env (dev (flags :standard %{dune-warnings}))) so standalone opam builds fail on warnings 19 19 ✓ Test Quality (0 total issues) 20 20 ✓ Interop Testing (0 total issues) 21 21 ✓ Code Generation (0 total issues)
+1 -1
test/cram/e526.t/run.t
··· 16 16 to list any transitive deps the package actually uses directly. This makes 17 17 (re_export ...) meaningful again and prevents deps from leaking into 18 18 downstream opam depends via META requires. 19 - - (global) foo/dune-project is missing (implicit_transitive_deps false); transitive deps leak into downstream META requires and pollute consumers' opam depends 19 + - bad/foo/dune-project:1:0: foo/dune-project is missing (implicit_transitive_deps false); transitive deps leak into downstream META requires and pollute consumers' opam depends 20 20 ✓ Test Quality (0 total issues) 21 21 ✓ Interop Testing (0 total issues) 22 22 ✓ Code Generation (0 total issues)
+1 -1
test/cram/e800.t/run.t
··· 14 14 [E800] Missing generate.sh (1 issue) 15 15 Every interop test must have scripts/generate.sh as the single entry point for 16 16 trace regeneration via `dune build @regen-traces`. 17 - - (global) Interop test bad/foo/test/interop/oracle/scripts/ is missing generate.sh 17 + - bad/foo/test/interop/oracle/scripts/generate.sh:1:0: Interop test bad/foo/test/interop/oracle/scripts/ is missing generate.sh 18 18 ✓ Code Generation (0 total issues) 19 19 20 20 ╭─────────────────┬───────────────────────────╮
+1 -1
test/cram/e803.t/run.t
··· 15 15 Interop tests must run from committed traces without needing the external tool 16 16 at test time. The test.ml should only read trace files, never shell out to run 17 17 the oracle. If you need the oracle, put it in the generator script. 18 - - (global) Interop test bad/foo/test/interop/oracle/test.ml calls Sys.command — test must run from traces alone 18 + - bad/foo/test/interop/oracle/test.ml:1:0: Interop test bad/foo/test/interop/oracle/test.ml calls Sys.command — test must run from traces alone 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬───────────────────────────────────────────╮
+1 -1
test/cram/e805.t/run.t
··· 15 15 Python oracles must pin dependencies in requirements.txt with exact versions 16 16 (e.g. crcmod==1.7). This ensures reproducible trace generation without 17 17 depending on local installs. 18 - - (global) Python oracle bad/foo/test/interop/oracle/scripts/ missing requirements.txt 18 + - bad/foo/test/interop/oracle/scripts/requirements.txt:1:0: Python oracle bad/foo/test/interop/oracle/scripts/ missing requirements.txt 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬────────────────────────────────╮
+1 -1
test/cram/e806.t/run.t
··· 15 15 Go oracles must pin the upstream module in go.mod with a tagged version or 16 16 pseudo-version. This ensures reproducible trace generation without depending 17 17 on $GOPATH or local clones. 18 - - (global) Go oracle bad/foo/test/interop/oracle/scripts/ missing go.mod 18 + - bad/foo/test/interop/oracle/scripts/go.mod:1:0: Go oracle bad/foo/test/interop/oracle/scripts/ missing go.mod 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬──────────────────────╮
+1 -1
test/cram/e807.t/run.t
··· 15 15 Rust oracles must pin the upstream crate in Cargo.toml with a tagged version 16 16 or git rev. This ensures reproducible trace generation without depending on 17 17 local checkouts. 18 - - (global) Rust oracle bad/foo/test/interop/oracle/scripts/ missing Cargo.toml 18 + - bad/foo/test/interop/oracle/scripts/Cargo.toml:1:0: Rust oracle bad/foo/test/interop/oracle/scripts/ missing Cargo.toml 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬──────────────────────────╮
+1 -1
test/cram/e810.t/run.t
··· 14 14 [E810] Missing regen-traces alias (1 issue) 15 15 Every interop test dune file must define a regen-traces alias as the single 16 16 trigger for refreshing traces: `(rule (alias regen-traces) ...)`. 17 - - (global) Interop test bad/foo/test/interop/oracle/dune missing regen-traces alias 17 + - bad/foo/test/interop/oracle/dune:1:0: Interop test bad/foo/test/interop/oracle/dune missing regen-traces alias 18 18 ✓ Code Generation (0 total issues) 19 19 20 20 ╭─────────────────┬──────────────────────────────────╮
+1 -1
test/cram/e815.t/run.t
··· 15 15 The regen-traces alias should be the single entry point — no REGEN_TRACES=1 16 16 env var sentinel. Remove the (enabled_if ...) guard so `dune build 17 17 @regen-traces` works directly. 18 - - (global) Interop test bad/foo/test/interop/oracle/dune uses REGEN_TRACES sentinel 18 + - bad/foo/test/interop/oracle/dune:1:0: Interop test bad/foo/test/interop/oracle/dune uses REGEN_TRACES sentinel 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬─────────────────────────────────────╮
+1 -1
test/cram/e820.t/run.t
··· 14 14 [E820] Hand-rolled CSV parsing (1 issue) 15 15 Use csv (Csv.decode_file with a Csv.Row codec) for CSV trace parsing. Never 16 16 hand-roll CSV readers with open_in/input_line/split_on_char. 17 - - (global) Interop test bad/foo/test/interop/oracle/test.ml hand-rolls CSV parsing instead of csv 17 + - bad/foo/test/interop/oracle/test.ml:1:0: Interop test bad/foo/test/interop/oracle/test.ml hand-rolls CSV parsing instead of csv 18 18 ✓ Code Generation (0 total issues) 19 19 20 20 ╭─────────────────┬───────────────────────────────╮
+1 -1
test/cram/e825.t/run.t
··· 14 14 [E825] Missing csv dependency (1 issue) 15 15 Interop tests with CSV traces should use csv for parsing. Add csv to the 16 16 (libraries ...) in the dune file and use Csv.decode_file with a Row codec. 17 - - (global) Interop test bad/foo/test/interop/oracle has CSV traces but dune lacks csv dependency 17 + - bad/foo/test/interop/oracle/dune:1:0: Interop test bad/foo/test/interop/oracle has CSV traces but dune lacks csv dependency 18 18 ✓ Code Generation (0 total issues) 19 19 20 20 ╭─────────────────┬──────────────────────────────╮
+1 -1
test/cram/e835.t/run.t
··· 15 15 Python deps must live in a venv. Never use pip install 16 16 --break-system-packages. The generate.sh wrapper should create/reuse a venv 17 17 automatically. 18 - - (global) bad/foo/test/interop/oracle/scripts/generate.sh uses --break-system-packages 18 + - bad/foo/test/interop/oracle/scripts/generate.sh:1:0: bad/foo/test/interop/oracle/scripts/generate.sh uses --break-system-packages 19 19 ✓ Code Generation (0 total issues) 20 20 21 21 ╭─────────────────┬───────────────────────────────────────────╮
+1 -1
test/cram/e900.t/run.t
··· 16 16 Add a c/ directory with gen.ml that calls Wire_3d.main to generate .3d files 17 17 and C validators from the Wire codec definitions. See ocaml-clcw/c/ for the 18 18 pattern. 19 - - (global) foo uses Wire.Codec but has no c/ directory for EverParse 3D generation 19 + - foo/dune-project:1:0: foo uses Wire.Codec but has no c/ directory for EverParse 3D generation 20 20 21 21 ╭─────────────────┬───────────────────────────────────────╮ 22 22 │ Category │ Issues │
+1 -1
test/cram/e905.t/run.t
··· 16 16 Move struct_, module_, c_stubs, ml_stubs out of the .mli. These belong in 17 17 c/gen.ml where they are used to generate EverParse 3D files and C stubs. The 18 18 codec is the public API; the 3D projection is a build artifact. 19 - - (global) foo/foo.mli exposes Wire EverParse symbol `struct_` in public API 19 + - foo/foo.mli:1:0: foo/foo.mli exposes Wire EverParse symbol `struct_` in public API 20 20 21 21 ╭─────────────────┬──────────────────────────────────────────╮ 22 22 │ Category │ Issues │
+3 -3
test/cram/e915.t/run.t
··· 14 14 topic is a slug declared in categories.toml at the project root (or listed in 15 15 the topics: field of .merlint). Edit the package's dune-project so dune 16 16 regenerates the opam file. 17 - - (global) pkg3/pkg3.opam: unknown topic "weird-new-topic" 18 - - (global) pkg2/pkg2.opam: tags: missing org:blacksun marker 19 - - (global) pkg1/pkg1.opam: missing tags: field 17 + - pkg1/pkg1.opam:1:0: pkg1/pkg1.opam: missing tags: field 18 + - pkg2/pkg2.opam:1:0: pkg2/pkg2.opam: tags: missing org:blacksun marker 19 + - pkg3/pkg3.opam:1:0: pkg3/pkg3.opam: unknown topic "weird-new-topic" 20 20 ✓ Test Quality (0 total issues) 21 21 ✓ Interop Testing (0 total issues) 22 22 ✓ Code Generation (0 total issues)
+2 -2
test/cram/e920.t/run.t
··· 13 13 When a README.md, .mli or .mld contains OCaml code blocks (```ocaml fenced or 14 14 {[ ... ]} odoc), add an (mdx (files <file>)) stanza to the same directory's 15 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 16 + - bad/README.md:1:0: bad/README.md: contains OCaml code blocks but bad/dune has no (mdx ...) stanza 17 + - bad/lib/foo.mli:1:0: bad/lib/foo.mli: contains OCaml code blocks but bad/lib/dune has no (mdx ...) stanza 18 18 ✓ Project Structure (0 total issues) 19 19 ✓ Test Quality (0 total issues) 20 20 ✓ Interop Testing (0 total issues)
+1
test/test.ml
··· 8 8 let () = 9 9 let suites = 10 10 [ 11 + Test_categories.suite; 11 12 Test_config.suite; 12 13 Test_config_parser.suite; 13 14 Test_rule_config.suite;
+62
test/test_categories.ml
··· 1 + open Merlint 2 + 3 + let load_in_tmp ~contents = 4 + let dir = Filename.temp_file "categories_dir" "" in 5 + Sys.remove dir; 6 + Unix.mkdir dir 0o755; 7 + let path = Filename.concat dir "categories.toml" in 8 + let oc = open_out path in 9 + output_string oc contents; 10 + close_out oc; 11 + Fun.protect 12 + ~finally:(fun () -> 13 + Sys.remove path; 14 + Unix.rmdir dir) 15 + (fun () -> Categories.load dir) 16 + 17 + let test_missing_file () = 18 + let dir = Filename.temp_file "categories_missing" "" in 19 + Sys.remove dir; 20 + Alcotest.(check (list string)) "absent file is empty" [] (Categories.load dir) 21 + 22 + let test_top_level_headers () = 23 + let slugs = load_in_tmp ~contents:"[codec]\n[crypto]\n[network]\n" in 24 + Alcotest.(check (list string)) 25 + "top-level headers extracted" 26 + [ "codec"; "crypto"; "network" ] 27 + slugs 28 + 29 + let test_dotted_headers () = 30 + let slugs = load_in_tmp ~contents:"[codec]\n[codec.text]\n[codec.binary]\n" in 31 + Alcotest.(check (list string)) 32 + "dotted headers preserved as-is" 33 + [ "codec"; "codec.text"; "codec.binary" ] 34 + slugs 35 + 36 + let test_skips_keys () = 37 + let slugs = load_in_tmp ~contents:"[codec]\nname = \"x\"\n[crypto]\n" in 38 + Alcotest.(check (list string)) 39 + "key/value lines are skipped" [ "codec"; "crypto" ] slugs 40 + 41 + let test_skips_array_tables () = 42 + let slugs = load_in_tmp ~contents:"[codec]\n[[items]]\n[crypto]\n" in 43 + Alcotest.(check (list string)) 44 + "array-table headers ([[ ... ]]) are skipped" [ "codec"; "crypto" ] slugs 45 + 46 + let test_trims_whitespace () = 47 + let slugs = load_in_tmp ~contents:" [codec] \n[crypto]\n" in 48 + Alcotest.(check (list string)) 49 + "leading/trailing whitespace around header is trimmed" [ "codec"; "crypto" ] 50 + slugs 51 + 52 + let suite = 53 + ( "categories", 54 + [ 55 + Alcotest.test_case "missing file -> empty" `Quick test_missing_file; 56 + Alcotest.test_case "top-level headers" `Quick test_top_level_headers; 57 + Alcotest.test_case "dotted headers" `Quick test_dotted_headers; 58 + Alcotest.test_case "skips key/value lines" `Quick test_skips_keys; 59 + Alcotest.test_case "skips array-table headers" `Quick 60 + test_skips_array_tables; 61 + Alcotest.test_case "trims whitespace" `Quick test_trims_whitespace; 62 + ] )
+4
test/test_categories.mli
··· 1 + (** Categories module tests. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the test suite for the {!Merlint.Categories} module. *)