Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.5.0 - unreleased

- Warn on macOS when Homebrew gcc or binutils commands appear to shadow
Xcode command line tools.
- Warn on Apple Silicon macOS when the shell appears to mix native and
Rosetta Homebrew prefixes.
- Warn on macOS when Conda or cross-architecture compiler/linker
environment variables may affect opam builds.
- Warn when known legacy compatibility packages such as
`ocaml-native-compilers` or `camlp4-extra` are installed in the active
switch.
- Warn when installed packages declare opam `build-env` metadata.

## 0.4.0 - 2026-06-09

- Add an `opam-doctor` executable so opam can dispatch the tool as
Expand Down
8 changes: 8 additions & 0 deletions docs/diagnostic-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The current checks cover:
- whether the resolved `ocaml` and installed switch tools appear to come from
the active switch;
- selected opam packages used by ordinary OCaml development;
- installed packages that declare opam `build-env` metadata;
- macOS environment state that can cause opam builds to see the wrong
compiler or binutils tools;
- the VS Code OCaml Platform extension, when the `code` command is available.

The command may skip checks when a prerequisite command is missing. For
Expand Down Expand Up @@ -86,10 +89,15 @@ treated as display text; tools should prefer `name`, `status`, and
| `env.path.current-directory` | Current-directory entries in `PATH`. |
| `env.color.forced` | Forced color-output environment variables. |
| `env.grep-options` | `GREP_OPTIONS` environment variable state. |
| `env.macos.toolchain-shadow` | Homebrew gcc or binutils commands shadowing Xcode tools on macOS. |
| `env.macos.homebrew-architecture` | Mixed native/Rosetta Homebrew state on Apple Silicon macOS. |
| `env.macos.toolchain-variables` | Conda or cross-architecture toolchain environment variables on macOS. |
| `opam.package.dune` | Installed `dune` package state. |
| `opam.package.ocaml-lsp-server` | Installed `ocaml-lsp-server` package state. |
| `opam.package.ocamlformat` | Installed `ocamlformat` package state. |
| `opam.package.utop` | Installed optional `utop` package state. |
| `opam.package.legacy` | Known legacy compatibility package names installed in the active switch. |
| `opam.package.build-env` | Installed packages that declare opam `build-env` metadata. |
| `opam.packages` | Failure to read installed opam packages. |
| `editor.vscode.command` | VS Code command availability. |
| `editor.vscode.ocaml-platform` | VS Code OCaml Platform extension state. |
Expand Down
263 changes: 262 additions & 1 deletion lib/env.ml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,268 @@ let grep_options_diagnostics ?(env = Platform.env) () =
]
| _ -> []

let diagnostics ?(env = Platform.env) os =
let output_lines output =
output
|> String.split_on_char '\n'
|> List.map String.trim
|> List.filter (fun line -> line <> "")

let first_stdout_line result =
match result.Process.status with
| Process.Exited 0 -> (
match output_lines result.stdout with
| line :: _ -> Some line
| [] -> None)
| _ -> None

let locate_command ~(run : Process.runner) os command =
let locator, args_for = Platform.command_locator os in
first_stdout_line (run locator (args_for command))

let contains_substring haystack needle =
let haystack_length = String.length haystack in
let needle_length = String.length needle in
let rec loop index =
needle_length = 0
|| index + needle_length <= haystack_length
&& (String.sub haystack index needle_length = needle
|| loop (index + 1))
in
loop 0

let normalized_path_contains path needle =
contains_substring
(Platform.normalize_path path)
(Platform.normalize_path needle)

let homebrew_binutils_path path =
normalized_path_contains path "/opt/homebrew/opt/binutils/bin/"
|| normalized_path_contains path "/usr/local/opt/binutils/bin/"

let homebrew_compiler_path path =
normalized_path_contains path "/opt/homebrew/bin/"
|| normalized_path_contains path "/usr/local/bin/"

let shadowed_macos_tool command path =
match command with
| "cc" | "gcc" -> homebrew_compiler_path path
| "ar" | "ranlib" | "ld" | "strip" -> homebrew_binutils_path path
| _ -> false

let macos_toolchain_shadow_diagnostics ~(run : Process.runner) os =
match os with
| Platform.Macos -> (
let commands = [ "cc"; "gcc"; "ar"; "ranlib"; "ld"; "strip" ] in
let shadowed =
commands
|> List.filter_map (fun command ->
match locate_command ~run os command with
| Some path when shadowed_macos_tool command path ->
Some (command, path)
| _ -> None)
in
match shadowed with
| [] -> []
| _ :: _ ->
let detail =
"Commands resolving to Homebrew toolchain paths:\n"
^ (shadowed
|> List.map (fun (command, path) ->
Printf.sprintf "%s: %s" command path)
|> String.concat "\n")
in
[
Check.make ~id:"env.macos.toolchain-shadow"
~title:
"Homebrew toolchain commands may shadow Xcode tools"
~detail
~suggestion:
"Use Xcode command line tools ahead of Homebrew \
gcc/binutils for opam builds, or run the build from a \
shell with the intended PATH."
Check.Warn;
])
| _ -> []

let path_entries os env =
match env "PATH" with None -> [] | Some path -> split_path os path

let path_has_prefix os env prefix =
path_entries os env
|> List.exists (fun entry ->
Platform.is_path_under ~parent:prefix entry)

let first_command_output ~(run : Process.runner) command args =
first_stdout_line (run command args)

let apple_silicon_capable ~run =
match
first_command_output ~run "sysctl" [ "-n"; "hw.optional.arm64" ]
with
| Some value -> String.trim value = "1"
| None -> false

let macos_architecture ~run =
first_command_output ~run "uname" [ "-m" ] |> Option.map String.trim

let homebrew_prefix ~run =
first_command_output ~run "brew" [ "--prefix" ]
|> Option.map String.trim

let macos_homebrew_arch_diagnostics ?(env = Platform.env)
~(run : Process.runner) os =
match os with
| Platform.Macos ->
let arch = macos_architecture ~run in
let apple_silicon = apple_silicon_capable ~run in
let prefix = homebrew_prefix ~run in
let has_arm_brew = path_has_prefix os env "/opt/homebrew" in
let has_intel_brew = path_has_prefix os env "/usr/local" in
let mixed_path =
apple_silicon && has_arm_brew && has_intel_brew
in
let prefix_mismatch =
match (arch, prefix) with
| Some "arm64", Some prefix ->
Platform.is_path_under ~parent:"/usr/local" prefix
| Some "x86_64", Some prefix when apple_silicon ->
Platform.is_path_under ~parent:"/opt/homebrew" prefix
| _ -> false
in
if not (mixed_path || prefix_mismatch) then []
else
let values =
[
Option.map (Printf.sprintf "Process architecture: %s") arch;
Option.map (Printf.sprintf "Homebrew prefix: %s") prefix;
Some
(Printf.sprintf "PATH has /opt/homebrew: %s"
(string_of_bool has_arm_brew));
Some
(Printf.sprintf "PATH has /usr/local: %s"
(string_of_bool has_intel_brew));
]
|> List.filter_map (fun value -> value)
in
[
Check.make ~id:"env.macos.homebrew-architecture"
~title:"Homebrew architecture may not match this shell"
~detail:(String.concat "\n" values)
~suggestion:
"Use one Homebrew architecture in this shell before \
running opam builds: /opt/homebrew for native arm64, \
/usr/local for Rosetta x86_64."
Check.Warn;
]
| _ -> []

let toolchain_environment_variables =
[
"CC";
"CXX";
"CPP";
"LD";
"AR";
"AS";
"RANLIB";
"NM";
"STRIP";
"CFLAGS";
"CPPFLAGS";
"CXXFLAGS";
"LDFLAGS";
"HOST";
"BUILD";
"host_alias";
"build_alias";
]

let non_empty_env env name =
match env name with
| Some value when String.trim value <> "" -> Some value
| _ -> None

let value_mentions needle value =
contains_substring
(String.lowercase_ascii value)
(String.lowercase_ascii needle)

let conda_toolchain_value ~conda_prefix value =
value_mentions "conda" value
||
match conda_prefix with
| Some prefix -> value_mentions prefix value
| None -> false

let architecture_mismatch ~arch ~apple_silicon value =
let value = String.lowercase_ascii value in
match arch with
| Some "arm64" ->
contains_substring value "x86_64"
|| contains_substring value "-arch x86_64"
| Some "x86_64" when apple_silicon ->
contains_substring value "arm64"
|| contains_substring value "-arch arm64"
| _ -> false

let suspicious_toolchain_variable ~arch ~apple_silicon ~conda_prefix
name value =
let reasons =
[
(conda_toolchain_value ~conda_prefix value, "Conda toolchain");
( architecture_mismatch ~arch ~apple_silicon value,
"architecture mismatch" );
]
|> List.filter_map (function
| true, reason -> Some reason
| _ -> None)
in
match reasons with
| [] -> None
| _ :: _ ->
Some
(Printf.sprintf "%s=%s (%s)" name value
(String.concat ", " reasons))

let macos_toolchain_environment_diagnostics ?(env = Platform.env)
~(run : Process.runner) os =
match os with
| Platform.Macos -> (
let arch = macos_architecture ~run in
let apple_silicon = apple_silicon_capable ~run in
let conda_prefix = non_empty_env env "CONDA_PREFIX" in
let suspicious =
toolchain_environment_variables
|> List.filter_map (fun name ->
match non_empty_env env name with
| Some value ->
suspicious_toolchain_variable ~arch ~apple_silicon
~conda_prefix name value
| None -> None)
in
match suspicious with
| [] -> []
| _ :: _ ->
[
Check.make ~id:"env.macos.toolchain-variables"
~title:
"compiler or linker environment variables may affect \
macOS builds"
~detail:
("Suspicious toolchain environment variables:\n"
^ String.concat "\n" suspicious)
~suggestion:
"Unset Conda or cross-toolchain variables before \
running opam builds, unless you are intentionally \
cross-compiling."
Check.Warn;
])
| _ -> []

let diagnostics ?(env = Platform.env) ?(run = Process.run) os =
path_current_directory_diagnostics ~env os
@ forced_color_diagnostics ~env ()
@ grep_options_diagnostics ~env ()
@ macos_toolchain_shadow_diagnostics ~run os
@ macos_homebrew_arch_diagnostics ~env ~run os
@ macos_toolchain_environment_diagnostics ~env ~run os
Loading
Loading