My own corner of monopam
2
fork

Configure Feed

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

monopam: detect Dead_lib — libraries declared in dune but unused in source

When a stanza's '(libraries L)' clause includes a library whose modules
never appear in any '.ml' / '.mli' in the same directory, that library
is dead weight: it bloats compile time and pollutes downstream META
'requires'. The signal is most useful for catching transitive
dependencies that survived a refactor, and for sublibs whose modules
were folded into the parent.

Resolution rule: read modules from '_opam/lib/<lib>/dune-package' when
present (parsing for '(library (name X) (main_module_name Y) (modules
M ...))'), otherwise fall back to listing '.cmi' basenames in the
install dir (zarith, asn1-combinators, ounit2 and similar pre-date
dune-package metadata). Modules with '__' in the name are wrapped
private modules and are filtered out of the fallback set.

The Lint.kind variant gains a third constructor 'Dead_lib'; the CLI
groups its issues alongside Missing and Unused. mdx-files-only stanzas
are skipped — they are intentional README wiring with no source modules
of their own.

+458 -55
+22 -29
monopam/bin/cmd_lint.ml
··· 28 28 issues; 29 29 (groups, List.rev !order) 30 30 31 + let pkgs_of_kind kind issues = 32 + List.filter_map 33 + (fun (i : Monopam.Lint.issue) -> 34 + if i.kind = kind then Some i.package else None) 35 + issues 36 + |> List.sort_uniq String.compare 37 + 31 38 let pp_table issues = 32 39 let groups, order = group_by_subtree issues in 33 40 let columns = ··· 35 42 Tty.Table.column "Package"; 36 43 Tty.Table.column ~max_width:50 "Missing"; 37 44 Tty.Table.column ~max_width:50 "Unused"; 45 + Tty.Table.column ~max_width:50 "Dead lib"; 38 46 ] 39 47 in 40 48 let rows = 41 49 List.map 42 50 (fun subtree -> 43 51 let issues = List.rev (Hashtbl.find groups subtree) in 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 56 - |> List.sort_uniq String.compare 57 - in 52 + let missing = pkgs_of_kind Monopam.Lint.Missing issues in 53 + let unused = pkgs_of_kind Monopam.Lint.Unused issues in 54 + let dead = pkgs_of_kind Monopam.Lint.Dead_lib issues in 58 55 [ 59 56 Tty.Span.text subtree; 60 57 Tty.Span.styled ··· 63 60 Tty.Span.styled 64 61 Tty.Style.(fg (Tty.Color.ansi `Cyan)) 65 62 (String.concat " " unused); 63 + Tty.Span.styled 64 + Tty.Style.(fg (Tty.Color.ansi `Magenta)) 65 + (String.concat " " dead); 66 66 ]) 67 67 order 68 68 in ··· 74 74 List.iter 75 75 (fun subtree -> 76 76 let issues = List.rev (Hashtbl.find groups subtree) in 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 82 - |> List.sort_uniq String.compare 83 - in 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 77 + let missing = pkgs_of_kind Monopam.Lint.Missing issues in 78 + let unused = pkgs_of_kind Monopam.Lint.Unused issues in 79 + let dead = pkgs_of_kind Monopam.Lint.Dead_lib issues in 91 80 if missing <> [] then 92 81 Fmt.pr "%s missing: %s@." subtree (String.concat " " missing); 93 82 if unused <> [] then 94 - Fmt.pr "%s unused: %s@." subtree (String.concat " " unused)) 83 + Fmt.pr "%s unused: %s@." subtree (String.concat " " unused); 84 + if dead <> [] then 85 + Fmt.pr "%s dead lib: %s@." subtree (String.concat " " dead)) 95 86 order 96 87 97 88 let pp_source_issues source_issues = ··· 181 172 let summary_parts ~issues ~source_issues ~dune_warning_issues = 182 173 let n_missing = count_kind Monopam.Lint.Missing issues in 183 174 let n_unused = count_kind Monopam.Lint.Unused issues in 175 + let n_dead = count_kind Monopam.Lint.Dead_lib issues in 184 176 let n_source = List.length source_issues in 185 177 let n_warn = List.length dune_warning_issues in 186 178 List.filter_map Fun.id 187 179 [ 188 180 (if n_missing > 0 then Some (Fmt.str "%d missing" n_missing) else None); 189 181 (if n_unused > 0 then Some (Fmt.str "%d unused" n_unused) else None); 182 + (if n_dead > 0 then Some (Fmt.str "%d dead-lib" n_dead) else None); 190 183 (if n_source > 0 then Some (Fmt.str "%d source" n_source) else None); 191 184 (if n_warn > 0 then Some (Fmt.str "%d dune-warnings" n_warn) else None); 192 185 ]
+230 -7
monopam/lib/lint.ml
··· 305 305 (** Owning opam package for a single dune stanza. Prefers an explicit 306 306 [(package foo)] field, otherwise infers it from [(public_name n)] or — for 307 307 [(executables ...)] stanzas — from the first installable name in 308 - [(public_names ...)] (the [\\] placeholder marks an exec as not public). 309 - Uses the part before the first dot when the name is dotted ([foo.bar] -> 310 - [foo]). Returns [None] for private stanzas. *) 308 + [(public_names ...)] (a [\\] placeholder marks an exec as not public). Uses 309 + the part before the first dot when the name is dotted ([foo.bar] -> [foo]). 310 + Returns [None] for private stanzas. *) 311 311 let stanza_owner fields = 312 312 let prefix pn = 313 313 match String.index_opt pn '.' with ··· 420 420 421 421 (* ---- Types ---- *) 422 422 423 - type kind = Missing | Unused 423 + type kind = Missing | Unused | Dead_lib 424 424 type issue = { subtree : string; kind : kind; package : string } 425 425 426 + (* ---- Dead-lib detection ---- 427 + 428 + Per stanza: a library in [(libraries L)] is dead if none of the 429 + modules it exposes appear in any [.ml] / [.mli] in the same directory. 430 + Module names come from [_opam/lib/<lib>/dune-package] when present 431 + (parsed for [(library (name X) (main_module_name Y) (modules ...))]), 432 + otherwise from a fallback that lists [*.cmi] basenames in the install 433 + directory. *) 434 + 435 + let capitalize s = 436 + if s = "" then s 437 + else 438 + String.uppercase_ascii (String.sub s 0 1) 439 + ^ String.sub s 1 (String.length s - 1) 440 + 441 + (** Walk every (library ...) sub-stanza of a parsed dune-package file and return 442 + a list of (lib_name, module_set) pairs. *) 443 + let libraries_in_dune_package sexps = 444 + let module_set_of_main fields = 445 + match field "main_module_name" fields with 446 + | Some (Sexp.Atom n :: _) -> String_set.singleton n 447 + | _ -> String_set.empty 448 + in 449 + let rec module_set_of_modules acc s = 450 + match s with 451 + | Sexp.List (Sexp.Atom "module" :: fs) -> ( 452 + match field "obj_name" fs with 453 + | Some (Sexp.Atom n :: _) -> String_set.add (capitalize n) acc 454 + | _ -> acc) 455 + | Sexp.List xs -> List.fold_left module_set_of_modules acc xs 456 + | _ -> acc 457 + in 458 + List.filter_map 459 + (function 460 + | Sexp.List (Sexp.Atom "library" :: fields) -> ( 461 + match field "name" fields with 462 + | Some (Sexp.Atom name :: _) -> 463 + let mods = module_set_of_main fields in 464 + let mods = 465 + match field "modules" fields with 466 + | Some xs -> List.fold_left module_set_of_modules mods xs 467 + | None -> mods 468 + in 469 + Some (name, mods) 470 + | _ -> None) 471 + | _ -> None) 472 + sexps 473 + 474 + let contains_double_underscore s = 475 + let n = String.length s in 476 + let rec loop i = 477 + if i + 1 >= n then false 478 + else if s.[i] = '_' && s.[i + 1] = '_' then true 479 + else loop (i + 1) 480 + in 481 + loop 0 482 + 483 + let load_eio_path eio_path = 484 + try Some (Eio.Path.load eio_path) with Eio.Io _ -> None 485 + 486 + (** Capitalised basenames of [*.cmi] files directly in [pkg_dir], excluding 487 + wrapped-private modules (those with [__] in the name). Fallback for 488 + libraries that pre-date dune-package metadata (zarith, asn1-combinators, 489 + ounit2 and similar). *) 490 + let cmi_modules_in_dir pkg_dir = 491 + let entries = try Eio.Path.read_dir pkg_dir with Eio.Io _ -> [] in 492 + List.fold_left 493 + (fun acc entry -> 494 + if Filename.check_suffix entry ".cmi" then 495 + let base = Filename.chop_suffix entry ".cmi" in 496 + if contains_double_underscore base then acc 497 + else String_set.add (capitalize base) acc 498 + else acc) 499 + String_set.empty entries 500 + 501 + (** Scan [_opam/lib/] and [_build/install/.../lib/] for libraries. For each 502 + package directory, prefer [dune-package]'s precise metadata; fall back to 503 + listing [*.cmi]. *) 504 + let build_lib_modules ~fs ~monorepo = 505 + let tbl = Hashtbl.create 256 in 506 + let scan root = 507 + let entries = try Eio.Path.read_dir root with Eio.Io _ -> [] in 508 + List.iter 509 + (fun pkg -> 510 + let pkg_dir = Eio.Path.(root / pkg) in 511 + let dune_pkg = Eio.Path.(pkg_dir / "dune-package") in 512 + let covered = ref false in 513 + (match load_eio_path dune_pkg with 514 + | None -> () 515 + | Some content -> 516 + let libs = libraries_in_dune_package (parse_sexps content) in 517 + List.iter 518 + (fun (name, mods) -> 519 + if not (String_set.is_empty mods) then begin 520 + covered := true; 521 + let existing = 522 + try Hashtbl.find tbl name 523 + with Not_found -> String_set.empty 524 + in 525 + Hashtbl.replace tbl name (String_set.union existing mods) 526 + end) 527 + libs); 528 + if not !covered then 529 + let mods = cmi_modules_in_dir pkg_dir in 530 + if not (String_set.is_empty mods) then 531 + let existing = 532 + try Hashtbl.find tbl pkg with Not_found -> String_set.empty 533 + in 534 + Hashtbl.replace tbl pkg (String_set.union existing mods)) 535 + entries 536 + in 537 + let opam_lib = 538 + Eio.Path.(fs / Fpath.to_string Fpath.(monorepo / "_opam" / "lib")) 539 + in 540 + let build_lib = 541 + Eio.Path.( 542 + fs 543 + / Fpath.to_string 544 + Fpath.(monorepo / "_build" / "install" / "default" / "lib")) 545 + in 546 + scan opam_lib; 547 + scan build_lib; 548 + tbl 549 + 550 + let module_ref_re = 551 + Re.compile 552 + (Re.seq 553 + [ 554 + Re.bow; 555 + Re.set "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 556 + Re.rep 557 + (Re.set 558 + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789'"); 559 + ]) 560 + 561 + let module_refs_in_text text = 562 + Re.all module_ref_re text 563 + |> List.map (fun g -> Re.Group.get g 0) 564 + |> String_set.of_list 565 + 566 + (** Concatenated text of every [.ml] / [.mli] in [dir]. *) 567 + let source_files_text ~fs dir = 568 + let eio_dir = Eio.Path.(fs / Fpath.to_string dir) in 569 + let entries = try Eio.Path.read_dir eio_dir with Eio.Io _ -> [] in 570 + List.fold_left 571 + (fun acc entry -> 572 + if Filename.check_suffix entry ".mli" || Filename.check_suffix entry ".ml" 573 + then 574 + match load_file fs Fpath.(dir / entry) with 575 + | Some s -> acc ^ "\n" ^ s 576 + | None -> acc 577 + else acc) 578 + "" entries 579 + 580 + (** Walk the subtree, for each [library] / [executable] / [executables] / [test] 581 + / [tests] stanza, check each [(libraries L)] entry against the stanza's 582 + directory's source. *) 583 + let dead_libs_in_subtree ~fs ~lib_modules ~subtree subtree_path = 584 + let dune_files = dune_files_in ~fs subtree_path in 585 + List.concat_map 586 + (fun df -> 587 + let dir = Fpath.parent df in 588 + match load_file fs df with 589 + | None -> [] 590 + | Some content -> 591 + let stanzas = parse_sexps content in 592 + let refs = module_refs_in_text (source_files_text ~fs dir) in 593 + List.concat_map 594 + (function 595 + | Sexp.List (Sexp.Atom kind :: fields) 596 + when List.mem kind 597 + [ 598 + "library"; "executable"; "executables"; "test"; "tests"; 599 + ] -> 600 + let libs = 601 + match field "libraries" fields with 602 + | None -> [] 603 + | Some xs -> 604 + List.filter_map 605 + (function 606 + | Sexp.Atom s 607 + when not (String.starts_with ~prefix:"%" s) -> 608 + Some s 609 + | _ -> None) 610 + xs 611 + in 612 + List.filter_map 613 + (fun lib -> 614 + if is_builtin lib then None 615 + else 616 + let entry = Hashtbl.find_opt lib_modules lib in 617 + Fmt.epr "dead_libs %s/%s lib=%s entry=%s@." subtree 618 + (Fpath.to_string dir) lib 619 + (match entry with 620 + | None -> "<unknown>" 621 + | Some s -> String_set.elements s |> String.concat ","); 622 + match entry with 623 + | None -> None 624 + (* Unknown lib: don't flag — could be a sibling 625 + library with a private name, or a build 626 + system not reflected in install dirs. *) 627 + | Some mods when String_set.is_empty mods -> None 628 + | Some mods -> 629 + if 630 + String_set.exists 631 + (fun m -> String_set.mem m refs) 632 + mods 633 + then None 634 + else 635 + Some { subtree; kind = Dead_lib; package = lib }) 636 + libs 637 + | _ -> []) 638 + stanzas) 639 + dune_files 640 + 426 641 type source_issue = { 427 642 subtree : string; 428 643 dune_project : string option; ··· 646 861 (fun (a : issue) (b : issue) -> 647 862 match String.compare a.subtree b.subtree with 648 863 | 0 -> 649 - let ka = match a.kind with Missing -> 0 | Unused -> 1 in 650 - let kb = match b.kind with Missing -> 0 | Unused -> 1 in 864 + let rank = function Missing -> 0 | Unused -> 1 | Dead_lib -> 2 in 865 + let ka = rank a.kind in 866 + let kb = rank b.kind in 651 867 if ka <> kb then compare ka kb else String.compare a.package b.package 652 868 | n -> n) 653 869 issues ··· 685 901 let build_lib = Fpath.(monorepo / "_build" / "install" / "default" / "lib") in 686 902 let subdirs = list_subdirs ~fs ~monorepo in 687 903 let sources = load_sources ~fs ~monorepo in 904 + let lib_modules = build_lib_modules ~fs ~monorepo in 688 905 let source_issues = 689 906 compute_source_issues ~fs ~monorepo ~sources subdirs |> sort_source_issues 690 907 in ··· 726 943 ~own_set ~all_deps ~subtree pkg ~fs 727 944 in 728 945 issues := new_issues @ !issues) 729 - pkgs 946 + pkgs; 947 + Printf.eprintf "DEBUG calling dead_libs_in_subtree for %s\n%!" subtree; 948 + let dead = 949 + dead_libs_in_subtree ~fs ~lib_modules ~subtree subtree_path 950 + in 951 + Printf.eprintf "DEBUG got %d dead for %s\n%!" (List.length dead) subtree; 952 + issues := dead @ !issues 730 953 end) 731 954 subdirs; 732 955 let root_diffs = Root.check ~fs ~monorepo () in
+8 -1
monopam/lib/lint.mli
··· 13 13 [(env (dev (flags :standard %{dune-warnings})))], so the warning set is 14 14 uniform across the monorepo. *) 15 15 16 - type kind = Missing | Unused (** The kind of dependency issue. *) 16 + (** The kind of dependency issue. *) 17 + type kind = 18 + | Missing (** Library is referenced from a dune stanza but not in opam. *) 19 + | Unused (** Package is in opam runtime depends but no dune file uses it. *) 20 + | Dead_lib 21 + (** Library is in a stanza's [(libraries ...)] but none of the modules its 22 + [_opam/lib/<lib>/dune-package] (or [.cmi] files) expose appear in any 23 + [.ml] / [.mli] in the same directory. *) 17 24 18 25 type issue = { 19 26 subtree : string; (** Monorepo subdirectory *)
+198 -18
monopam/test/test_lint.ml
··· 78 78 in 79 79 build_subtrees ~mkdir ~write; 80 80 let result = 81 - Monopam.Lint.run 82 - ~fs:(Eio.Stdenv.fs env) 83 - ~monorepo:(Fpath.v root) () 81 + Monopam.Lint.run ~fs:(Eio.Stdenv.fs env) ~monorepo:(Fpath.v root) () 84 82 in 85 83 f result) 86 84 ··· 88 86 i.subtree = subtree && i.kind = Monopam.Lint.Missing && i.package = package 89 87 90 88 (** A subtree whose only opam package is [test-pkg]. The library proper has no 91 - extra deps, but a sibling private [(executable)] in [gen/] uses [re]. 92 - Since [re] is not in [test-pkg.opam], the lint should flag it. *) 89 + extra deps, but a sibling private [(executable)] in [gen/] uses [re]. Since 90 + [re] is not in [test-pkg.opam], the lint should flag it. *) 93 91 let test_missing_dep_via_private_executable () = 94 92 with_temp_monorepo 95 93 (fun ~mkdir ~write -> ··· 100 98 "opam-version: \"2.0\"\ndepends: [ \"dune\" {>= \"3.21\"} ]\n"; 101 99 write "test-pkg/lib/dune" 102 100 "(library\n (name test_pkg)\n (public_name test-pkg))\n"; 103 - write "test-pkg/gen/dune" 104 - "(executable\n (name gen)\n (libraries re))\n") 101 + write "test-pkg/gen/dune" "(executable\n (name gen)\n (libraries re))\n") 105 102 (fun (result : Monopam.Lint.result) -> 106 103 Alcotest.(check bool) 107 104 "private executable's library deps are attributed to the subtree's \ 108 105 single opam package" 109 106 true 110 - (List.exists (issue_for ~subtree:"test-pkg" ~package:"re") 107 + (List.exists 108 + (issue_for ~subtree:"test-pkg" ~package:"re") 111 109 result.issues)) 112 110 113 - (** A private executable in [fuzz/] still references a library, and that 114 - library still needs to be declared somewhere in opam (with-test, build, 115 - or runtime). The lint must flag it like any other missing dep. *) 111 + (** A private executable in [fuzz/] still references a library, and that library 112 + still needs to be declared somewhere in opam (with-test, build, or runtime). 113 + The lint must flag it like any other missing dep. *) 116 114 let test_missing_dep_via_fuzz_executable () = 117 115 with_temp_monorepo 118 116 (fun ~mkdir ~write -> ··· 127 125 "(executable\n (name fuzz)\n (libraries alcobar))\n") 128 126 (fun (result : Monopam.Lint.result) -> 129 127 Alcotest.(check bool) 130 - "fuzz exec's libs are flagged when missing from opam" 131 - true 132 - (List.exists (issue_for ~subtree:"test-pkg" ~package:"alcobar") 128 + "fuzz exec's libs are flagged when missing from opam" true 129 + (List.exists 130 + (issue_for ~subtree:"test-pkg" ~package:"alcobar") 133 131 result.issues)) 134 132 135 - (** When a library referenced from any private stanza IS declared in opam 136 - (even as [{with-test}]), the lint must NOT flag it. *) 133 + (** When a library referenced from any private stanza IS declared in opam (even 134 + as [{with-test}]), the lint must NOT flag it. *) 137 135 let test_with_test_dep_not_flagged () = 138 136 with_temp_monorepo 139 137 (fun ~mkdir ~write -> ··· 152 150 "(executable\n (name fuzz)\n (libraries alcobar))\n") 153 151 (fun (result : Monopam.Lint.result) -> 154 152 Alcotest.(check bool) 155 - "with-test dep declared in opam is not flagged as missing" 153 + "with-test dep declared in opam is not flagged as missing" false 154 + (List.exists 155 + (issue_for ~subtree:"test-pkg" ~package:"alcobar") 156 + result.issues)) 157 + 158 + (** A library declared in [(libraries L)] whose [main_module_name] from 159 + [_opam/lib/<L>/dune-package] is absent from any [.ml] / [.mli] in the same 160 + directory is flagged as [Dead_lib]. The dune-package metadata is 161 + authoritative for the module names a library exposes — no guessing based on 162 + the library name. *) 163 + let test_dead_lib_via_dune_package () = 164 + with_temp_monorepo 165 + (fun ~mkdir ~write -> 166 + mkdir "test-pkg"; 167 + mkdir "test-pkg/lib"; 168 + mkdir "_opam"; 169 + mkdir "_opam/lib"; 170 + mkdir "_opam/lib/foo"; 171 + write "_opam/lib/foo/dune-package" 172 + "(lang dune 3.0)\n\ 173 + \ (library\n\ 174 + \ (name foo)\n\ 175 + \ (kind normal)\n\ 176 + \ (main_module_name Foo)\n\ 177 + \ (modes byte native))\n"; 178 + write "test-pkg/test-pkg.opam" 179 + "opam-version: \"2.0\"\n\ 180 + depends: [\n\ 181 + \ \"dune\" {>= \"3.21\"}\n\ 182 + \ \"foo\"\n\ 183 + ]\n"; 184 + write "test-pkg/lib/dune" 185 + "(library\n\ 186 + \ (name test_pkg)\n\ 187 + \ (public_name test-pkg)\n\ 188 + \ (libraries foo))\n"; 189 + write "test-pkg/lib/test_pkg.ml" "let v = 1\n") 190 + (fun (result : Monopam.Lint.result) -> 191 + Alcotest.(check bool) 192 + "library declared but its dune-package main_module_name absent in \ 193 + source is flagged Dead_lib" 194 + true 195 + (List.exists 196 + (fun (i : Monopam.Lint.issue) -> 197 + i.subtree = "test-pkg" 198 + && i.kind = Monopam.Lint.Dead_lib 199 + && i.package = "foo") 200 + result.issues)) 201 + 202 + (** When the source references the library's [main_module_name], the lint must 203 + NOT flag it. *) 204 + let test_live_lib_via_dune_package () = 205 + with_temp_monorepo 206 + (fun ~mkdir ~write -> 207 + mkdir "test-pkg"; 208 + mkdir "test-pkg/lib"; 209 + mkdir "_opam"; 210 + mkdir "_opam/lib"; 211 + mkdir "_opam/lib/foo"; 212 + write "_opam/lib/foo/dune-package" 213 + "(lang dune 3.0)\n (library\n (name foo)\n (main_module_name Foo))\n"; 214 + write "test-pkg/test-pkg.opam" 215 + "opam-version: \"2.0\"\n\ 216 + depends: [\n\ 217 + \ \"dune\" {>= \"3.21\"}\n\ 218 + \ \"foo\"\n\ 219 + ]\n"; 220 + write "test-pkg/lib/dune" 221 + "(library\n\ 222 + \ (name test_pkg)\n\ 223 + \ (public_name test-pkg)\n\ 224 + \ (libraries foo))\n"; 225 + write "test-pkg/lib/test_pkg.ml" "let v = Foo.bar ()\n") 226 + (fun (result : Monopam.Lint.result) -> 227 + Alcotest.(check bool) 228 + "library whose main_module_name appears in source is not flagged" false 229 + (List.exists 230 + (fun (i : Monopam.Lint.issue) -> 231 + i.kind = Monopam.Lint.Dead_lib && i.package = "foo") 232 + result.issues)) 233 + 234 + (** Library names like [lambdasoup] expose a module called [Soup] — the 235 + dune-package's [main_module_name] is the source of truth. The lint must not 236 + flag based on the library name alone. *) 237 + let test_dead_lib_lambdasoup_style () = 238 + with_temp_monorepo 239 + (fun ~mkdir ~write -> 240 + mkdir "test-pkg"; 241 + mkdir "test-pkg/lib"; 242 + mkdir "_opam"; 243 + mkdir "_opam/lib"; 244 + mkdir "_opam/lib/lambdasoup"; 245 + write "_opam/lib/lambdasoup/dune-package" 246 + "(lang dune 3.0)\n\ 247 + \ (library\n\ 248 + \ (name lambdasoup)\n\ 249 + \ (main_module_name Soup))\n"; 250 + write "test-pkg/test-pkg.opam" 251 + "opam-version: \"2.0\"\n\ 252 + depends: [\n\ 253 + \ \"dune\" {>= \"3.21\"}\n\ 254 + \ \"lambdasoup\"\n\ 255 + ]\n"; 256 + write "test-pkg/lib/dune" 257 + "(library\n\ 258 + \ (name test_pkg)\n\ 259 + \ (public_name test-pkg)\n\ 260 + \ (libraries lambdasoup))\n"; 261 + write "test-pkg/lib/test_pkg.ml" "let v = Soup.parse \"<p>x</p>\"\n") 262 + (fun (result : Monopam.Lint.result) -> 263 + Alcotest.(check bool) 264 + "library renaming module via main_module_name is matched correctly" 156 265 false 157 - (List.exists (issue_for ~subtree:"test-pkg" ~package:"alcobar") 266 + (List.exists 267 + (fun (i : Monopam.Lint.issue) -> 268 + i.kind = Monopam.Lint.Dead_lib && i.package = "lambdasoup") 269 + result.issues)) 270 + 271 + (** For libraries without a dune-package (older opam packages like zarith), fall 272 + back to listing [*.cmi] files in the install dir. *) 273 + let test_dead_lib_cmi_fallback () = 274 + with_temp_monorepo 275 + (fun ~mkdir ~write -> 276 + mkdir "test-pkg"; 277 + mkdir "test-pkg/lib"; 278 + mkdir "_opam"; 279 + mkdir "_opam/lib"; 280 + mkdir "_opam/lib/zarith"; 281 + write "_opam/lib/zarith/META" "description = \"\"\n"; 282 + write "_opam/lib/zarith/z.cmi" ""; 283 + write "_opam/lib/zarith/q.cmi" ""; 284 + write "test-pkg/test-pkg.opam" 285 + "opam-version: \"2.0\"\n\ 286 + depends: [\n\ 287 + \ \"dune\" {>= \"3.21\"}\n\ 288 + \ \"zarith\"\n\ 289 + ]\n"; 290 + write "test-pkg/lib/dune" 291 + "(library\n\ 292 + \ (name test_pkg)\n\ 293 + \ (public_name test-pkg)\n\ 294 + \ (libraries zarith))\n"; 295 + write "test-pkg/lib/test_pkg.ml" "let v = Z.of_int 42\n") 296 + (fun (result : Monopam.Lint.result) -> 297 + Alcotest.(check bool) 298 + "cmi-fallback finds Z module from zarith and does not flag it" false 299 + (List.exists 300 + (fun (i : Monopam.Lint.issue) -> 301 + i.kind = Monopam.Lint.Dead_lib && i.package = "zarith") 302 + result.issues)) 303 + 304 + (** [(mdx ...)] stanzas reference libraries used in README code blocks, not in 305 + [.ml] files in the same directory — Dead_lib must skip them. *) 306 + let test_dead_lib_skips_mdx () = 307 + with_temp_monorepo 308 + (fun ~mkdir ~write -> 309 + mkdir "test-pkg"; 310 + mkdir "_opam"; 311 + mkdir "_opam/lib"; 312 + mkdir "_opam/lib/foo"; 313 + write "_opam/lib/foo/dune-package" 314 + "(lang dune 3.0)\n (library (name foo) (main_module_name Foo))\n"; 315 + write "test-pkg/test-pkg.opam" 316 + "opam-version: \"2.0\"\n\ 317 + depends: [\n\ 318 + \ \"dune\" {>= \"3.21\"}\n\ 319 + \ \"foo\"\n\ 320 + ]\n"; 321 + write "test-pkg/dune" "(mdx\n (files README.md)\n (libraries foo))\n"; 322 + write "test-pkg/README.md" "") 323 + (fun (result : Monopam.Lint.result) -> 324 + Alcotest.(check bool) 325 + "mdx stanzas are skipped, no Dead_lib for foo" false 326 + (List.exists 327 + (fun (i : Monopam.Lint.issue) -> 328 + i.kind = Monopam.Lint.Dead_lib && i.package = "foo") 158 329 result.issues)) 159 330 160 331 let suite = ··· 171 342 test_missing_dep_via_fuzz_executable; 172 343 Alcotest.test_case "with-test dep not flagged" `Quick 173 344 test_with_test_dep_not_flagged; 345 + Alcotest.test_case "dead lib via dune-package" `Quick 346 + test_dead_lib_via_dune_package; 347 + Alcotest.test_case "live lib via dune-package" `Quick 348 + test_live_lib_via_dune_package; 349 + Alcotest.test_case "dead lib lambdasoup style" `Quick 350 + test_dead_lib_lambdasoup_style; 351 + Alcotest.test_case "dead lib cmi fallback" `Quick 352 + test_dead_lib_cmi_fallback; 353 + Alcotest.test_case "dead lib skips mdx" `Quick test_dead_lib_skips_mdx; 174 354 ] )