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: show suppressed issue stats when exclusions are active

Engine.run now returns { issues; excluded } instead of a plain issue
list. Each suppressed issue (per rule + file) is recorded, so the
caller can display exactly how many warnings each exclusion hides.

The display shows:
⚠ N issues suppressed by .merlintrc exclusions:
[E330] 12 suppressed
[E410] 3 suppressed

File-scoped rules now run on ALL files and filter afterward (instead
of skipping excluded files entirely), so the suppression count is
accurate — it reflects the real number of warnings that would appear
if the exclusion were removed.

+79 -123
+19 -3
bin/main.ml
··· 212 212 Log.info (fun m -> m "Starting visual analysis on %d files" files_count); 213 213 214 214 (* Run the engine to get all issues *) 215 - let all_issues = 215 + let { Merlint.Engine.issues = all_issues; excluded = all_excluded } = 216 216 match rule_filter with 217 217 | Some filter -> 218 218 Merlint.Engine.run ~filter ~dune_describe ?profiling:profiling_state 219 219 project_root 220 220 | None -> ( 221 - (* Create a default filter that enables all rules *) 222 221 match Merlint.Filter.parse "all" with 223 222 | Ok filter -> 224 223 Merlint.Engine.run ~filter ~dune_describe ?profiling:profiling_state 225 224 project_root 226 - | Error _ -> [] (* Should not happen *)) 225 + | Error _ -> { Merlint.Engine.issues = []; excluded = [] }) 227 226 in 228 227 229 228 Fmt.pr "Running merlint analysis...@.@.Analyzing %d files@.@." files_count; 229 + 230 + (* Show exclusion stats so suppressed issues are visible *) 231 + if all_excluded <> [] then begin 232 + let n = List.length all_excluded in 233 + let by_rule = Hashtbl.create 16 in 234 + List.iter 235 + (fun (e : Merlint.Engine.exclusion_stats) -> 236 + let prev = try Hashtbl.find by_rule e.rule with Not_found -> 0 in 237 + Hashtbl.replace by_rule e.rule (prev + 1)) 238 + all_excluded; 239 + Fmt.pr "@[<v>%a %d issues suppressed by .merlintrc exclusions:@," 240 + Fmt.(styled `Yellow string) "⚠" n; 241 + Hashtbl.iter (fun rule count -> 242 + Fmt.pr " [%s] %d suppressed@," rule count) 243 + by_rule; 244 + Fmt.pr "@]@." 245 + end; 230 246 231 247 (* Group issues by category for reporting *) 232 248 let issues_by_category = group_issues_by_category all_issues in
+42 -113
lib/engine.ml
··· 1 1 (** Linting engine *) 2 2 3 3 let src = Logs.Src.create "merlint.engine" ~doc:"Linting engine" 4 + module Log = (val Logs.src_log src : Logs.LOG) 4 5 5 - module Log = (val Logs.src_log src : Logs.LOG) 6 + type exclusion_stats = { rule : string; file : string } 7 + type result = { issues : Rule.Run.result list; excluded : exclusion_stats list } 6 8 7 - (** Run a single rule on a file *) 8 9 let run_file_rule ?profiling ctx rule = 9 10 let code = Rule.code rule in 10 11 Log.debug (fun m -> m "Running rule %s on %s" code ctx.Context.filename); 11 12 let start_time = Unix.gettimeofday () in 12 - let result = 13 - try Rule.Run.file rule ctx 14 - with exn -> 15 - Log.err (fun m -> 16 - m "Rule %s failed on %s: %s" code ctx.Context.filename 17 - (Printexc.to_string exn)); 18 - [] 19 - in 13 + let res = try Rule.Run.file rule ctx with exn -> 14 + Log.err (fun m -> m "Rule %s failed on %s: %s" code ctx.Context.filename (Printexc.to_string exn)); [] in 20 15 let duration = Unix.gettimeofday () -. start_time in 21 - (match profiling with 22 - | Some prof -> 23 - Profiling.add_timing prof 24 - { 25 - operation = 26 - Profiling.File_rule 27 - { rule_code = code; filename = ctx.Context.filename }; 28 - duration; 29 - } 30 - | None -> ()); 31 - result 16 + (match profiling with Some prof -> Profiling.add_timing prof { operation = Profiling.File_rule { rule_code = code; filename = ctx.Context.filename }; duration } | None -> ()); res 32 17 33 - (** Run a single rule on a project *) 34 18 let run_project_rule ?profiling ctx rule = 35 19 let code = Rule.code rule in 36 20 Log.debug (fun m -> m "Running project rule %s" code); 37 21 let start_time = Unix.gettimeofday () in 38 - let result = 39 - try Rule.Run.project rule ctx 40 - with exn -> 41 - Log.err (fun m -> 42 - m "Project rule %s failed: %s" code (Printexc.to_string exn)); 43 - [] 44 - in 22 + let res = try Rule.Run.project rule ctx with exn -> 23 + Log.err (fun m -> m "Project rule %s failed: %s" code (Printexc.to_string exn)); [] in 45 24 let duration = Unix.gettimeofday () -. start_time in 46 - (match profiling with 47 - | Some prof -> 48 - Profiling.add_timing prof 49 - { operation = Profiling.Project_rule code; duration } 50 - | None -> ()); 51 - result 25 + (match profiling with Some prof -> Profiling.add_timing prof { operation = Profiling.Project_rule code; duration } | None -> ()); res 52 26 53 - (** Setup analysis context and enabled rules *) 54 27 let setup_analysis ~filter ~dune_describe project_root = 55 28 let config = Config.load_from_path project_root in 56 29 let files_to_analyze = Dune.project_files dune_describe in 57 30 let files_to_analyze_str = List.map Fpath.to_string files_to_analyze in 58 - let project_ctx = 59 - Context.project ~config ~project_root ~all_files:files_to_analyze_str 60 - ~dune_describe 61 - in 62 - let enabled_rules = 63 - Data.all_rules 64 - |> List.filter (fun rule -> 65 - Filter.is_enabled_by_code filter (Rule.code rule)) 66 - in 31 + let project_ctx = Context.project ~config ~project_root ~all_files:files_to_analyze_str ~dune_describe in 32 + let enabled_rules = Data.all_rules |> List.filter (fun rule -> Filter.is_enabled_by_code filter (Rule.code rule)) in 67 33 (config, files_to_analyze, project_ctx, enabled_rules) 68 34 69 - (** Run project-scoped rules and filter issues based on exclusions *) 70 35 let run_project_rules ?profiling enabled_rules project_ctx = 71 36 let config = project_ctx.Context.config in 72 - enabled_rules 73 - |> List.filter Rule.is_project_scoped 74 - |> List.concat_map (fun rule -> 75 - let code = Rule.code rule in 76 - let issues = run_project_rule ?profiling project_ctx rule in 77 - (* Filter out issues for files that are excluded from this rule *) 78 - List.filter 79 - (fun result -> 80 - match Rule.Run.location result with 81 - | Some loc -> 82 - let file = loc.Location.file in 83 - let excluded = 84 - Rule_config.should_exclude config.exclusions ~rule:code ~file 85 - in 86 - if excluded then 87 - Log.debug (fun m -> 88 - m "Excluding %s issue for file %s" code file); 89 - not excluded 90 - | None -> 91 - (* Issues without locations can't be excluded by file *) 92 - true) 93 - issues) 37 + let excluded_acc = ref [] in 38 + let issues = enabled_rules |> List.filter Rule.is_project_scoped |> List.concat_map (fun rule -> 39 + let code = Rule.code rule in 40 + let issues = run_project_rule ?profiling project_ctx rule in 41 + List.filter (fun r -> match Rule.Run.location r with 42 + | Some loc -> let file = loc.Location.file in 43 + let skip = Rule_config.should_exclude config.exclusions ~rule:code ~file in 44 + if skip then excluded_acc := { rule = code; file } :: !excluded_acc; 45 + not skip 46 + | None -> true) issues) in 47 + (issues, List.rev !excluded_acc) 94 48 95 - (** Analyze a single file with applicable rules *) 96 - let analyze_single_file ?profiling ~backend ~config ~project_root ~file_rules 97 - filepath = 49 + let analyze_single_file ?profiling ~backend ~config ~project_root ~file_rules filepath = 98 50 let filename = Fpath.to_string filepath in 99 - try 51 + let excluded_acc = ref [] in 52 + let issues = try 100 53 let merlin_start = Unix.gettimeofday () in 101 54 let outline = Merlin.outline backend ~file:filename in 102 55 let dump = Merlin.dump_ast backend ~file:filename in 103 56 let merlin_duration = Unix.gettimeofday () -. merlin_start in 104 - (match profiling with 105 - | Some prof -> 106 - Profiling.add_timing prof 107 - { operation = Profiling.Merlin filename; duration = merlin_duration } 108 - | None -> ()); 109 - let file_ctx = 110 - Context.file ~filename ~config ~project_root ~outline ~dump 111 - in 112 - let applicable_rules = 113 - List.filter 114 - (fun rule -> 115 - let code = Rule.code rule in 116 - let excluded = 117 - Rule_config.should_exclude config.exclusions ~rule:code 118 - ~file:filename 119 - in 120 - if excluded then 121 - Log.debug (fun m -> m "Excluding rule %s for file %s" code filename); 122 - not excluded) 123 - file_rules 124 - in 125 - List.concat_map (run_file_rule ?profiling file_ctx) applicable_rules 126 - with exn -> 127 - Log.err (fun m -> 128 - m "Failed to analyze %s: %s" filename (Printexc.to_string exn)); 129 - [] 57 + (match profiling with Some prof -> Profiling.add_timing prof { operation = Profiling.Merlin filename; duration = merlin_duration } | None -> ()); 58 + let file_ctx = Context.file ~filename ~config ~project_root ~outline ~dump in 59 + let all_results = List.concat_map (run_file_rule ?profiling file_ctx) file_rules in 60 + List.filter (fun r -> 61 + let code = Rule.Run.code r in 62 + let skip = Rule_config.should_exclude config.exclusions ~rule:code ~file:filename in 63 + if skip then excluded_acc := { rule = code; file = filename } :: !excluded_acc; 64 + not skip) all_results 65 + with exn -> Log.err (fun m -> m "Failed to analyze %s: %s" filename (Printexc.to_string exn)); [] in 66 + (issues, List.rev !excluded_acc) 130 67 131 - (** Run all checks on a project *) 132 68 let run ~filter ~dune_describe ?profiling project_root = 133 69 Log.info (fun m -> m "Starting analysis of %s" project_root); 134 - 135 - let config, files_to_analyze, project_ctx, enabled_rules = 136 - setup_analysis ~filter ~dune_describe project_root 137 - in 138 - 139 - let project_issues = run_project_rules ?profiling enabled_rules project_ctx in 140 - 70 + let config, files_to_analyze, project_ctx, enabled_rules = setup_analysis ~filter ~dune_describe project_root in 71 + let project_issues, project_excluded = run_project_rules ?profiling enabled_rules project_ctx in 141 72 let file_rules = List.filter Rule.is_file_scoped enabled_rules in 142 73 let backend = Merlin.v () in 143 - let analyze_file = 144 - analyze_single_file ?profiling ~backend ~config ~project_root ~file_rules 145 - in 146 - let file_issues = List.concat_map analyze_file files_to_analyze in 74 + let analyze_file = analyze_single_file ?profiling ~backend ~config ~project_root ~file_rules in 75 + let file_results = List.map analyze_file files_to_analyze in 147 76 Merlin.close backend; 148 - 149 - let all_issues = project_issues @ file_issues in 150 - List.sort Rule.Run.compare all_issues 77 + let file_issues = List.concat_map fst file_results in 78 + let file_excluded = List.concat_map snd file_results in 79 + { issues = List.sort Rule.Run.compare (project_issues @ file_issues); excluded = project_excluded @ file_excluded }
+15 -5
lib/engine.mli
··· 1 1 (** Linting engine. *) 2 2 3 + type exclusion_stats = { 4 + rule : string; 5 + file : string; 6 + } 7 + (** A single suppressed issue. *) 8 + 9 + type result = { 10 + issues : Rule.Run.result list; 11 + excluded : exclusion_stats list; 12 + } 13 + (** Analysis result. *) 14 + 3 15 val run : 4 16 filter:Filter.t -> 5 17 dune_describe:Dune.describe -> 6 18 ?profiling:Profiling.t -> 7 19 string -> 8 - Rule.Run.result list 9 - (** [run ~filter ~dune_describe ?profiling project_root] runs all checks on a 10 - project. Runs all enabled rules using the given dune describe for project 11 - structure. If [profiling] is provided, timing data will be collected. 12 - Returns a sorted list of issues found. *) 20 + result 21 + (** [run ~filter ~dune_describe ?profiling project_root] runs all checks. 22 + Returns detected issues and a record of every suppressed issue. *)
+3 -2
test/test_engine.ml
··· 6 6 | Error msg -> Alcotest.failf "Failed to create filter: %s" msg 7 7 | Ok filter -> 8 8 let dune_describe = Dune.describe (Fpath.v ".") in 9 - let results = Engine.run ~filter ~dune_describe "." in 9 + let result = Engine.run ~filter ~dune_describe "." in 10 10 Alcotest.(check int) 11 - "no results with all rules disabled" 0 (List.length results) 11 + "no results with all rules disabled" 0 12 + (List.length result.Engine.issues) 12 13 13 14 let suite = 14 15 ( "engine",