Monorepo management for opam overlays
0
fork

Configure Feed

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

feat(monopam): detect unused deps in lint command

Lint now checks both directions:
- Missing: META requires a library whose package is not in opam depends
- Unused: opam runtime dep not needed by META requires or dune files

Scoped deps (with-test, with-doc, build) and implicit deps (ocaml,
dune) are excluded from the unused check. Dune files are scanned for
executable/test library usage to avoid false positives on cmdliner,
eio_main, etc.

+226 -74
+62 -24
bin/cmd_lint.ml
··· 4 4 [ 5 5 `S Manpage.s_description; 6 6 `P 7 - "Checks that every library referenced in META $(b,requires) has its \ 8 - providing opam package declared in the subtree's opam $(b,depends)."; 7 + "Checks META $(b,requires) against opam $(b,depends) in both directions: \ 8 + reports missing deps (needed by META but not declared) and unused deps \ 9 + (declared but not needed by META)."; 9 10 `S "HOW IT WORKS"; 10 11 `P 11 12 "$(b,monopam lint) builds a library-to-package index from installed META \ ··· 16 17 `Pre "monopam lint ca-certs kdf"; 17 18 ] 18 19 19 - let pp_table issues = 20 - (* Group by subtree, preserving order *) 20 + let group_by_subtree issues = 21 21 let groups = Hashtbl.create 16 in 22 22 let order = ref [] in 23 23 List.iter ··· 26 26 let existing = try Hashtbl.find groups i.subtree with Not_found -> [] in 27 27 Hashtbl.replace groups i.subtree (i :: existing)) 28 28 issues; 29 + (groups, List.rev !order) 30 + 31 + let pp_table issues = 32 + let groups, order = group_by_subtree issues in 29 33 let columns = 30 34 [ 31 - Tty.Table.column "Package"; Tty.Table.column ~max_width:60 "Missing deps"; 35 + Tty.Table.column "Package"; 36 + Tty.Table.column ~max_width:50 "Missing"; 37 + Tty.Table.column ~max_width:50 "Unused"; 32 38 ] 33 39 in 34 40 let rows = 35 - List.rev_map 41 + List.map 36 42 (fun subtree -> 37 43 let issues = List.rev (Hashtbl.find groups subtree) in 38 - let pkgs = 39 - List.map (fun (i : Monopam.Lint.issue) -> i.needed_package) issues 44 + let missing = 45 + List.filter_map 46 + (fun (i : Monopam.Lint.issue) -> 47 + match i.kind with Missing -> Some i.package | _ -> None) 48 + issues 49 + |> List.sort_uniq String.compare 50 + in 51 + let unused = 52 + List.filter_map 53 + (fun (i : Monopam.Lint.issue) -> 54 + match i.kind with Unused -> Some i.package | _ -> None) 55 + issues 40 56 |> List.sort_uniq String.compare 41 57 in 42 58 [ 43 59 Tty.Span.text subtree; 44 60 Tty.Span.styled 45 61 Tty.Style.(fg (Tty.Color.ansi `Yellow)) 46 - (String.concat " " pkgs); 62 + (String.concat " " missing); 63 + Tty.Span.styled 64 + Tty.Style.(fg (Tty.Color.ansi `Cyan)) 65 + (String.concat " " unused); 47 66 ]) 48 - !order 67 + order 49 68 in 50 69 let table = Tty.Table.of_rows ~border:Tty.Border.rounded columns rows in 51 70 Fmt.pr "%a@." Tty.Table.pp table 52 71 53 72 let pp_plain issues = 54 - let groups = Hashtbl.create 16 in 55 - let order = ref [] in 56 - List.iter 57 - (fun (i : Monopam.Lint.issue) -> 58 - if not (Hashtbl.mem groups i.subtree) then order := i.subtree :: !order; 59 - let existing = try Hashtbl.find groups i.subtree with Not_found -> [] in 60 - Hashtbl.replace groups i.subtree (i :: existing)) 61 - issues; 73 + let groups, order = group_by_subtree issues in 62 74 List.iter 63 75 (fun subtree -> 64 76 let issues = List.rev (Hashtbl.find groups subtree) in 65 - let pkgs = 66 - List.map (fun (i : Monopam.Lint.issue) -> i.needed_package) issues 77 + let missing = 78 + List.filter_map 79 + (fun (i : Monopam.Lint.issue) -> 80 + match i.kind with Missing -> Some i.package | _ -> None) 81 + issues 67 82 |> List.sort_uniq String.compare 68 83 in 69 - Fmt.pr "%s %s@." subtree (String.concat " " pkgs)) 70 - (List.rev !order) 84 + let unused = 85 + List.filter_map 86 + (fun (i : Monopam.Lint.issue) -> 87 + match i.kind with Unused -> Some i.package | _ -> None) 88 + issues 89 + |> List.sort_uniq String.compare 90 + in 91 + if missing <> [] then 92 + Fmt.pr "%s missing: %s@." subtree (String.concat " " missing); 93 + if unused <> [] then 94 + Fmt.pr "%s unused: %s@." subtree (String.concat " " unused)) 95 + order 71 96 72 97 let run filter () = 73 98 Eio_main.run @@ fun env -> ··· 92 117 end 93 118 else begin 94 119 if Tty.is_tty () then pp_table issues else pp_plain issues; 120 + let n_missing = 121 + List.filter (fun i -> i.Monopam.Lint.kind = Missing) issues |> List.length 122 + in 123 + let n_unused = 124 + List.filter (fun i -> i.Monopam.Lint.kind = Unused) issues |> List.length 125 + in 95 126 let n_pkgs = 96 127 List.map (fun (i : Monopam.Lint.issue) -> i.subtree) issues 97 128 |> List.sort_uniq String.compare 98 129 |> List.length 99 130 in 100 - Fmt.pr "%a %d missing deps in %d packages (%s)@." 131 + let parts = 132 + List.filter_map Fun.id 133 + [ 134 + (if n_missing > 0 then Some (Fmt.str "%d missing" n_missing) else None); 135 + (if n_unused > 0 then Some (Fmt.str "%d unused" n_unused) else None); 136 + ] 137 + in 138 + Fmt.pr "%a %s in %d packages (%s)@." 101 139 Fmt.(styled (`Fg `Red) string) 102 - "✗" (List.length issues) n_pkgs scanned_label; 140 + "✗" (String.concat ", " parts) n_pkgs scanned_label; 103 141 `Ok () 104 142 end 105 143
+153 -42
lib/lint.ml
··· 1 1 (** Dependency linting for monorepo packages. 2 2 3 - Compares META [requires] against opam file [depends] to find missing opam 4 - dependencies. *) 3 + Compares META [requires] against opam [depends] in both directions. *) 5 4 6 5 module String_set = Set.Make (String) 7 6 ··· 25 24 "findlib"; 26 25 "bytes"; 27 26 ] 27 + 28 + (** Packages that are always implicit — not in META but always needed. *) 29 + let implicit_deps = String_set.of_list [ "ocaml"; "dune" ] 28 30 29 31 let is_builtin lib = 30 32 let top = ··· 123 125 | OP.Option (inner, _) -> extract_dep_name inner 124 126 | _ -> None 125 127 126 - let rec extract_dep_names (v : OP.value) = 128 + (** Check if a dep entry has [with-test], [with-doc], or [build] scope. *) 129 + let rec is_scoped (v : OP.value) = 127 130 match v.pelem with 128 - | OP.List { pelem = items; _ } -> List.filter_map extract_dep_name items 129 - | OP.Group { pelem = items; _ } -> List.concat_map extract_dep_names items 130 - | OP.Logop (_, l, r) -> extract_dep_names l @ extract_dep_names r 131 - | _ -> Option.to_list (extract_dep_name v) 131 + | OP.Option (_, constraints) -> has_scope constraints.pelem 132 + | _ -> false 132 133 133 - (** Parse an opam file and return the set of dependency package names. *) 134 + and has_scope items = 135 + List.exists 136 + (fun (item : OP.value) -> 137 + match item.pelem with 138 + | OP.Ident s -> s = "with-test" || s = "with-doc" || s = "build" 139 + | OP.Logop (_, l, r) -> has_scope [ l ] || has_scope [ r ] 140 + | OP.Pfxop (_, inner) -> has_scope [ inner ] 141 + | _ -> false) 142 + items 143 + 144 + (** Parse an opam file and return [(runtime_deps, all_deps)]. 145 + 146 + Runtime deps exclude those annotated with [:with-test], [:with-doc], or 147 + [build]. *) 134 148 let opam_depends content = 135 149 try 136 150 let opam = OpamParser.FullPos.string content "opam" in 137 151 List.fold_left 138 - (fun acc (item : OP.opamfile_item) -> 152 + (fun (runtime, all) (item : OP.opamfile_item) -> 139 153 match item.pelem with 140 154 | OP.Variable (name, value) when name.pelem = "depends" -> 155 + let deps = 156 + match value.pelem with 157 + | OP.List { pelem = items; _ } -> items 158 + | _ -> [] 159 + in 141 160 List.fold_left 142 - (fun s n -> String_set.add n s) 143 - acc (extract_dep_names value) 144 - | _ -> acc) 145 - String_set.empty opam.file_contents 161 + (fun (rt, al) dep -> 162 + match extract_dep_name dep with 163 + | None -> (rt, al) 164 + | Some n -> 165 + let al = String_set.add n al in 166 + if is_scoped dep then (rt, al) else (String_set.add n rt, al)) 167 + (runtime, all) deps 168 + | _ -> (runtime, all)) 169 + (String_set.empty, String_set.empty) 170 + opam.file_contents 146 171 with exn -> 147 172 Log.debug (fun m -> m "opam parse failed: %s" (Printexc.to_string exn)); 148 - String_set.empty 173 + (String_set.empty, String_set.empty) 174 + 175 + (** Scan a subtree directory for [*.opam] files. 149 176 150 - (** Scan a subtree directory for [*.opam] files, return [(pkg_name, deps)]. *) 177 + Returns [(pkg_name, runtime_deps, all_deps)] for each opam file. *) 151 178 let scan_opam_files ~fs subtree_path = 152 179 let eio_path = Eio.Path.(fs / Fpath.to_string subtree_path) in 153 180 let entries = try Eio.Path.read_dir eio_path with Eio.Io _ -> [] in ··· 156 183 if Filename.check_suffix name ".opam" then 157 184 let pkg_name = Filename.chop_suffix name ".opam" in 158 185 match load_file fs Fpath.(subtree_path / name) with 159 - | Some content -> Some (pkg_name, opam_depends content) 186 + | Some content -> 187 + let runtime, all = opam_depends content in 188 + Some (pkg_name, runtime, all) 160 189 | None -> None 161 190 else None) 162 191 entries 163 192 193 + (* ---- Dune file scanning (for executable deps not in META) ---- *) 194 + 195 + module Sexp = Sexpt.Sexp 196 + 197 + let parse_sexps content = 198 + match Sexp.parse_string_many content with Ok sexps -> sexps | Error _ -> [] 199 + 200 + let find_field name sexps = 201 + List.find_map 202 + (function 203 + | Sexp.List (Sexp.Atom n :: rest) when n = name -> Some rest | _ -> None) 204 + sexps 205 + 206 + (** Extract all libraries from [(libraries ...)] in any build stanza. *) 207 + let extract_used_libs sexps = 208 + let from_fields fields = 209 + match find_field "libraries" fields with 210 + | None -> [] 211 + | Some libs -> 212 + List.filter_map 213 + (function 214 + | Sexp.Atom s when not (String.starts_with ~prefix:"%" s) -> Some s 215 + | Sexp.List [ Sexp.Atom "re_export"; Sexp.Atom s ] -> Some s 216 + | _ -> None) 217 + libs 218 + in 219 + List.concat_map 220 + (function 221 + | Sexp.List (Sexp.Atom kind :: fields) 222 + when List.mem kind 223 + [ "library"; "executable"; "executables"; "test"; "tests" ] -> 224 + from_fields fields 225 + | _ -> []) 226 + sexps 227 + 228 + let rec find_dune_files ~fs dir = 229 + let eio_path = Eio.Path.(fs / Fpath.to_string dir) in 230 + let entries = try Eio.Path.read_dir eio_path with Eio.Io _ -> [] in 231 + List.concat_map 232 + (fun entry -> 233 + if String.starts_with ~prefix:"." entry then [] 234 + else if entry = "_build" || entry = "_opam" then [] 235 + else 236 + let child = Fpath.(dir / entry) in 237 + let eio_child = Eio.Path.(fs / Fpath.to_string child) in 238 + match Eio.Path.kind ~follow:false eio_child with 239 + | `Directory -> find_dune_files ~fs child 240 + | `Regular_file when entry = "dune" -> [ child ] 241 + | _ -> [] 242 + | exception Eio.Io _ -> []) 243 + entries 244 + 245 + (** Collect all packages referenced via [(libraries ...)] in any dune file in a 246 + subtree. This covers executable and test deps not captured by META. *) 247 + let dune_needed_packages ~fs ~index subtree_path = 248 + let dune_files = find_dune_files ~fs subtree_path in 249 + List.concat_map 250 + (fun df -> 251 + match load_file fs df with 252 + | None -> [] 253 + | Some content -> extract_used_libs (parse_sexps content)) 254 + dune_files 255 + |> List.filter_map (fun lib -> 256 + if is_builtin lib then None else Some (lib_to_package index lib)) 257 + |> String_set.of_list 258 + 164 259 (* ---- Types ---- *) 165 260 166 - type issue = { subtree : string; library : string; needed_package : string } 261 + type kind = Missing | Unused 262 + type issue = { subtree : string; kind : kind; package : string } 167 263 type result = { issues : issue list; packages_scanned : int } 168 264 169 265 (* ---- Core algorithm ---- *) ··· 196 292 if pkgs = [] then Log.debug (fun m -> m "%s: no opam files" subtree) 197 293 else begin 198 294 incr scanned; 199 - let own_set = String_set.of_list (List.map fst pkgs) in 295 + let own_set = String_set.of_list (List.map (fun (n, _, _) -> n) pkgs) in 200 296 let all_deps = 201 297 List.fold_left 202 - (fun acc (_, deps) -> String_set.union acc deps) 298 + (fun acc (_, _, all) -> String_set.union acc all) 203 299 String_set.empty pkgs 204 300 in 205 - Log.debug (fun m -> 206 - m "%s: %d packages, %d declared deps" subtree (List.length pkgs) 207 - (String_set.cardinal all_deps)); 208 - (* For each package, load its META and check requires *) 301 + (* Packages referenced in any dune file (covers executables/tests) *) 302 + let dune_pkgs = dune_needed_packages ~fs ~index subtree_path in 303 + (* For each package, load its META and check both directions *) 209 304 List.iter 210 - (fun (pkg_name, _declared_deps) -> 305 + (fun (pkg_name, runtime_deps, _all_deps) -> 211 306 let meta_path = Fpath.(build_lib / pkg_name / "META") in 212 307 match load_file fs meta_path with 213 308 | None -> ··· 222 317 let required_libs = 223 318 collect_requires expr |> List.sort_uniq String.compare 224 319 in 225 - List.iter 226 - (fun lib -> 227 - if is_builtin lib then () 228 - else 229 - let needed = lib_to_package index lib in 230 - if 231 - String_set.mem needed own_set 232 - || String_set.mem needed all_deps 233 - then () 320 + let meta_pkgs = 321 + List.fold_left 322 + (fun acc lib -> 323 + if is_builtin lib then acc 234 324 else 235 - issues := 236 - { 237 - subtree; 238 - library = lib; 239 - needed_package = needed; 240 - } 241 - :: !issues) 242 - required_libs)) 325 + let pkg = lib_to_package index lib in 326 + if String_set.mem pkg own_set then acc 327 + else String_set.add pkg acc) 328 + String_set.empty required_libs 329 + in 330 + (* Missing: needed by META but not declared *) 331 + String_set.iter 332 + (fun pkg -> 333 + if not (String_set.mem pkg all_deps) then 334 + issues := 335 + { subtree; kind = Missing; package = pkg } 336 + :: !issues) 337 + meta_pkgs; 338 + (* Unused: runtime dep not needed by META or dune files *) 339 + let needed = 340 + String_set.union meta_pkgs 341 + (String_set.union dune_pkgs 342 + (String_set.union own_set implicit_deps)) 343 + in 344 + let unused = String_set.diff runtime_deps needed in 345 + String_set.iter 346 + (fun pkg -> 347 + issues := 348 + { subtree; kind = Unused; package = pkg } :: !issues) 349 + unused)) 243 350 pkgs 244 351 end) 245 352 subdirs; ··· 249 356 List.sort 250 357 (fun a b -> 251 358 match String.compare a.subtree b.subtree with 252 - | 0 -> String.compare a.needed_package b.needed_package 359 + | 0 -> 360 + let ka = match a.kind with Missing -> 0 | Unused -> 1 in 361 + let kb = match b.kind with Missing -> 0 | Unused -> 1 in 362 + if ka <> kb then compare ka kb 363 + else String.compare a.package b.package 253 364 | n -> n) 254 365 !issues; 255 366 packages_scanned = !scanned;
+11 -8
lib/lint.mli
··· 1 1 (** Dependency linting for monorepo packages. 2 2 3 - Scans dune files to build a library-to-package map, then checks that every 4 - library referenced in [(libraries ...)] stanzas has its providing package 5 - declared in the subtree's [(depends ...)] stanza. *) 3 + Compares META [requires] against opam [depends] in both directions: 4 + - Missing: libraries needed by META but not declared in opam depends 5 + - Unused: runtime deps declared in opam but not needed by META requires *) 6 + 7 + type kind = Missing | Unused (** The kind of dependency issue. *) 6 8 7 9 type issue = { 8 10 subtree : string; (** Monorepo subdirectory *) 9 - library : string; (** Library name used in a dune file *) 10 - needed_package : string; (** Opam package that provides [library] *) 11 + kind : kind; (** Whether the dep is missing or unused *) 12 + package : string; (** The opam package involved *) 11 13 } 12 - (** A single missing-dependency issue. *) 14 + (** A single dependency issue. *) 13 15 14 16 type result = { 15 - issues : issue list; (** Missing dependencies found *) 17 + issues : issue list; (** Dependency issues found *) 16 18 packages_scanned : int; (** Number of subtrees checked *) 17 19 } 18 20 (** Result of a lint run. *) 19 21 20 22 val run : fs:Eio.Fs.dir_ty Eio.Path.t -> monorepo:Fpath.t -> unit -> result 21 23 (** [run ~fs ~monorepo ()] scans all subtrees under [monorepo], builds a 22 - library-to-package index, and reports missing dependencies. *) 24 + library-to-package index from META files, and reports both missing and 25 + unused dependencies. *)