diff --git a/CHANGES.md b/CHANGES.md index 47b38b1..bd48548 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/docs/diagnostic-contract.md b/docs/diagnostic-contract.md index 680cbd9..751d93b 100644 --- a/docs/diagnostic-contract.md +++ b/docs/diagnostic-contract.md @@ -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 @@ -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. | diff --git a/lib/env.ml b/lib/env.ml index 8198bd4..a056427 100644 --- a/lib/env.ml +++ b/lib/env.ml @@ -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 diff --git a/lib/opam.ml b/lib/opam.ml index 42c948a..5f369ef 100644 --- a/lib/opam.ml +++ b/lib/opam.ml @@ -277,6 +277,29 @@ let package_diagnostic packages package ~optional = ~suggestion:(Printf.sprintf "opam install %s" package) Check.Warn +let legacy_packages = [ "ocaml-native-compilers"; "camlp4-extra" ] + +let legacy_package_diagnostics packages = + let installed = + legacy_packages + |> List.filter (fun package -> has_package packages package) + in + match installed with + | [] -> [] + | _ :: _ -> + [ + Check.make ~id:"opam.package.legacy" + ~title:"legacy opam compatibility packages installed" + ~detail: + (Printf.sprintf "Legacy packages: %s" + (String.concat ", " installed)) + ~suggestion: + "Keep these packages only for projects that still require \ + them; prefer packages compatible with current OCaml \ + switches when possible." + Check.Warn; + ] + let read_package_state ~(run : Process.runner) = let result = run "opam" [ "list"; "--installed"; "--short" ] in match result.status with @@ -292,6 +315,7 @@ let package_diagnostics = function package_diagnostic packages "ocamlformat" ~optional:false; package_diagnostic packages "utop" ~optional:true; ] + @ legacy_package_diagnostics packages | Package_query_failed result -> [ Check.make ~id:"opam.packages" @@ -302,6 +326,41 @@ let package_diagnostics = function Check.Warn; ] +let build_env_packages ~(run : Process.runner) = + run "opam" + [ "list"; "--installed"; "--field-match=build-env:."; "--short" ] + +let build_env_diagnostics ~(run : Process.runner) = + let result = build_env_packages ~run in + match result.status with + | Process.Exited 0 -> ( + match parse_installed_packages result.stdout with + | [] -> [] + | packages -> + [ + Check.make ~id:"opam.package.build-env" + ~title:"installed packages declare build-env metadata" + ~detail: + (Printf.sprintf "Packages: %s" + (String.concat ", " packages)) + ~suggestion: + "Inspect those package files with `opam show \ + --field=build-env --raw` when debugging build \ + environment differences." + Check.Warn; + ]) + | Process.Spawn_error _ -> [] + | Exited _ | Signaled _ | Stopped _ -> + [ + Check.make ~id:"opam.package.build-env" + ~title:"could not inspect package build-env metadata" + ~detail:(Process.summary result) + ~suggestion: + "Run `opam list --installed --field-match=build-env:. \ + --short` to inspect package build-env metadata." + Check.Warn; + ] + let windows_path parent child = let separator = if String.ends_with ~suffix:"\\" parent then "" else "\\" @@ -626,6 +685,7 @@ let diagnostics ~(run : Process.runner) os = @ env_sync_diagnostic ~run os package_state @ doctor_plugin_diagnostics ~run os @ package_diagnostics package_state + @ build_env_diagnostics ~run else [ Check.make ~id:"opam.initialized" diff --git a/test/test_contract.ml b/test/test_contract.ml index c359fdc..989296f 100644 --- a/test/test_contract.ml +++ b/test/test_contract.ml @@ -196,8 +196,17 @@ let complete_opam_responses = (Process.Exited 0, "/tmp/opam/default/bin/ocamlformat\n", "") ); ( ("opam", [ "list"; "--installed"; "--short" ]), ( Process.Exited 0, - "dune\nocaml-lsp-server\nocamlformat\nutop\n", + "dune\n\ + ocaml-lsp-server\n\ + ocamlformat\n\ + utop\n\ + ocaml-native-compilers\n", "" ) ); + ( ( "opam", + [ + "list"; "--installed"; "--field-match=build-env:."; "--short"; + ] ), + (Process.Exited 0, "pkg-with-build-env\n", "") ); ] let package_query_failure_responses = @@ -249,6 +258,41 @@ let env_with_current_directory name = | "GREP_OPTIONS" -> Some "--color=always" | _ -> None +let macos_toolchain_shadow_responses = + [ + ( ("sh", [ "-c"; "command -v cc" ]), + (Process.Exited 0, "/usr/bin/cc\n", "") ); + ( ("sh", [ "-c"; "command -v gcc" ]), + (Process.Exited 0, "/usr/bin/gcc\n", "") ); + ( ("sh", [ "-c"; "command -v ar" ]), + (Process.Exited 0, "/opt/homebrew/opt/binutils/bin/ar\n", "") ); + ( ("sh", [ "-c"; "command -v ranlib" ]), + (Process.Exited 0, "/opt/homebrew/opt/binutils/bin/ranlib\n", "") + ); + ( ("sh", [ "-c"; "command -v ld" ]), + (Process.Exited 0, "/usr/bin/ld\n", "") ); + ( ("sh", [ "-c"; "command -v strip" ]), + (Process.Exited 0, "/usr/bin/strip\n", "") ); + (("uname", [ "-m" ]), (Process.Exited 0, "arm64\n", "")); + ( ("sysctl", [ "-n"; "hw.optional.arm64" ]), + (Process.Exited 0, "1\n", "") ); + (("brew", [ "--prefix" ]), (Process.Exited 0, "/usr/local\n", "")); + ] + +let mixed_homebrew_env name = + match name with + | "PATH" -> Some "/usr/local/bin:/opt/homebrew/bin:/usr/bin" + | _ -> None + +let macos_cross_toolchain_env name = + match name with + | "PATH" -> Some "/usr/bin:/bin" + | "CONDA_PREFIX" -> Some "/Users/me/miniconda3/envs/build" + | "CC" -> + Some + "/Users/me/miniconda3/envs/build/bin/x86_64-apple-darwin-clang" + | _ -> None + let emitted_diagnostic_names () = [ [ Platform.diagnostic Platform.Linux ]; @@ -262,6 +306,10 @@ let emitted_diagnostic_names () = Opam.doctor_plugin_diagnostics ~run:doctor_plugin_contract_runner Platform.Windows; Env.diagnostics Platform.Linux ~env:env_with_current_directory; + Env.diagnostics Platform.Macos ~env:mixed_homebrew_env + ~run:(fake_runner macos_toolchain_shadow_responses); + Env.diagnostics Platform.Macos ~env:macos_cross_toolchain_env + ~run:(fake_runner macos_toolchain_shadow_responses); Editor.diagnostics ~run:(fake_runner []); Editor.diagnostics ~run:(fake_runner vscode_extension_failure_responses); diff --git a/test/test_diagnostics.ml b/test/test_diagnostics.ml index 390e7c9..8ee84a4 100644 --- a/test/test_diagnostics.ml +++ b/test/test_diagnostics.ml @@ -789,6 +789,37 @@ let test_similar_package_name_does_not_count_as_installed () = expect_severity "similar package name" Check.Warn dune.severity; expect_string "similar package title" "dune not installed" dune.title +let test_legacy_packages_are_reported_when_installed () = + let diagnostics = + Opam.package_diagnostics + (Opam.Installed_packages + [ + "dune"; + "ocaml-lsp-server"; + "ocamlformat"; + "ocaml-native-compilers"; + "camlp4-extra"; + ]) + in + let legacy = find_diagnostic "opam.package.legacy" diagnostics in + expect_severity "legacy package severity" Check.Warn legacy.severity; + expect_string "legacy package title" + "legacy opam compatibility packages installed" legacy.title; + let detail = expect_some "legacy package detail" legacy.detail in + expect_contains "legacy native compilers" "ocaml-native-compilers" + detail; + expect_contains "legacy camlp4" "camlp4-extra" detail + +let test_similar_legacy_package_name_does_not_warn () = + let diagnostics = + Opam.package_diagnostics + (Opam.Installed_packages + [ + "dune"; "ocaml-lsp-server"; "ocamlformat"; "camlp4-extra-doc"; + ]) + in + expect_no_diagnostic "opam.package.legacy" diagnostics + let test_package_query_failure_is_reported () = let result = result ~stderr:"opam failed\n" (Process.Exited 31) "opam" @@ -803,6 +834,77 @@ let test_package_query_failure_is_reported () = "opam list --installed --short returned exit 31: opam failed" diagnostic +let test_build_env_packages_are_reported () = + let responses = + [ + ( ( "opam", + [ + "list"; + "--installed"; + "--field-match=build-env:."; + "--short"; + ] ), + (Process.Exited 0, "pkg-with-env\nanother-pkg\n", "") ); + ] + in + let diagnostics = + Opam.build_env_diagnostics ~run:(fake_runner responses) + in + let diagnostic = + find_diagnostic "opam.package.build-env" diagnostics + in + expect_severity "build-env package severity" Check.Warn + diagnostic.severity; + expect_string "build-env package title" + "installed packages declare build-env metadata" diagnostic.title; + let detail = + expect_some "build-env package detail" diagnostic.detail + in + expect_contains "build-env first package" "pkg-with-env" detail; + expect_contains "build-env second package" "another-pkg" detail + +let test_empty_build_env_package_query_is_quiet () = + let responses = + [ + ( ( "opam", + [ + "list"; + "--installed"; + "--field-match=build-env:."; + "--short"; + ] ), + (Process.Exited 0, "\n", "") ); + ] + in + Opam.build_env_diagnostics ~run:(fake_runner responses) + |> expect_no_diagnostic "opam.package.build-env" + +let test_build_env_package_query_failure_is_reported () = + let responses = + [ + ( ( "opam", + [ + "list"; + "--installed"; + "--field-match=build-env:."; + "--short"; + ] ), + (Process.Exited 2, "", "bad field\n") ); + ] + in + let diagnostics = + Opam.build_env_diagnostics ~run:(fake_runner responses) + in + let diagnostic = + find_diagnostic "opam.package.build-env" diagnostics + in + expect_severity "build-env query failure severity" Check.Warn + diagnostic.severity; + expect_string "build-env query failure title" + "could not inspect package build-env metadata" diagnostic.title; + expect_contains "build-env query failure detail" "bad field" + (expect_some "build-env query failure detail" diagnostic.detail) + let test_windows_opam_env_suggestion_matches_shell_wording () = let responses = [ @@ -1123,7 +1225,12 @@ let () = test_empty_opam_bin_output_is_reported; test_package_diagnostics_report_installed_and_missing_packages; test_similar_package_name_does_not_count_as_installed; + test_legacy_packages_are_reported_when_installed; + test_similar_legacy_package_name_does_not_warn; test_package_query_failure_is_reported; + test_build_env_packages_are_reported; + test_empty_build_env_package_query_is_quiet; + test_build_env_package_query_failure_is_reported; test_windows_opam_env_suggestion_matches_shell_wording; test_doctor_plugin_probe_script_preserves_powershell_if_chain; test_windows_runtime_path_probe_script_preserves_powershell_if_chain; diff --git a/test/test_env.ml b/test/test_env.ml index 02e78fa..036cf48 100644 --- a/test/test_env.ml +++ b/test/test_env.ml @@ -1,6 +1,16 @@ module Check = Doctor.Check module Env = Doctor.Env module Platform = Doctor.Platform +module Process = Doctor.Process + +let result ?(stdout = "") ?(stderr = "") status command args = + { Process.command; args; status; stdout; stderr } + +let fake_runner responses command args = + match List.assoc_opt (command, args) responses with + | Some (status, stdout, stderr) -> + result ~stdout ~stderr status command args + | None -> result (Process.Spawn_error "not found") command args let expect_string label expected actual = if not (String.equal expected actual) then @@ -179,6 +189,222 @@ let test_multiple_environment_hygiene_warnings_can_be_reported () = ignore (find_diagnostic "env.color.forced" diagnostics); ignore (find_diagnostic "env.grep-options" diagnostics) +let test_macos_homebrew_binutils_shadow_xcode_tools_warns () = + let responses = + [ + ( ("sh", [ "-c"; "command -v cc" ]), + (Process.Exited 0, "/usr/bin/cc\n", "") ); + ( ("sh", [ "-c"; "command -v gcc" ]), + (Process.Exited 0, "/usr/bin/gcc\n", "") ); + ( ("sh", [ "-c"; "command -v ar" ]), + (Process.Exited 0, "/opt/homebrew/opt/binutils/bin/ar\n", "") ); + ( ("sh", [ "-c"; "command -v ranlib" ]), + (Process.Exited 0, "/opt/homebrew/opt/binutils/bin/ranlib\n", "") + ); + ( ("sh", [ "-c"; "command -v ld" ]), + (Process.Exited 0, "/usr/bin/ld\n", "") ); + ( ("sh", [ "-c"; "command -v strip" ]), + (Process.Exited 0, "/usr/bin/strip\n", "") ); + ] + in + let diagnostics = + Env.diagnostics Platform.Macos + ~env: + (fake_env + [ ("PATH", "/opt/homebrew/opt/binutils/bin:/usr/bin") ]) + ~run:(fake_runner responses) + in + let diagnostic = + find_diagnostic "env.macos.toolchain-shadow" diagnostics + in + expect_severity "homebrew binutils severity" Check.Warn + diagnostic.Check.severity; + expect_string "homebrew binutils title" + "Homebrew toolchain commands may shadow Xcode tools" + diagnostic.Check.title; + let detail = + expect_some "homebrew binutils detail" diagnostic.Check.detail + in + expect_contains "homebrew binutils ar" "ar: /opt/homebrew" detail; + expect_contains "homebrew binutils ranlib" "ranlib: /opt/homebrew" + detail + +let test_macos_xcode_tools_do_not_warn () = + let responses = + [ + ( ("sh", [ "-c"; "command -v cc" ]), + (Process.Exited 0, "/usr/bin/cc\n", "") ); + ( ("sh", [ "-c"; "command -v gcc" ]), + (Process.Exited 0, "/usr/bin/gcc\n", "") ); + ( ("sh", [ "-c"; "command -v ar" ]), + (Process.Exited 0, "/usr/bin/ar\n", "") ); + ( ("sh", [ "-c"; "command -v ranlib" ]), + (Process.Exited 0, "/usr/bin/ranlib\n", "") ); + ( ("sh", [ "-c"; "command -v ld" ]), + (Process.Exited 0, "/usr/bin/ld\n", "") ); + ( ("sh", [ "-c"; "command -v strip" ]), + (Process.Exited 0, "/usr/bin/strip\n", "") ); + ] + in + let diagnostics = + Env.diagnostics Platform.Macos + ~env:(fake_env [ ("PATH", "/usr/bin:/bin") ]) + ~run:(fake_runner responses) + in + if + List.exists + (fun diagnostic -> + String.equal diagnostic.Check.id "env.macos.toolchain-shadow") + diagnostics + then failwith "xcode toolchain should not warn" + +let test_macos_mixed_homebrew_architecture_warns () = + let responses = + [ + (("uname", [ "-m" ]), (Process.Exited 0, "arm64\n", "")); + ( ("sysctl", [ "-n"; "hw.optional.arm64" ]), + (Process.Exited 0, "1\n", "") ); + (("brew", [ "--prefix" ]), (Process.Exited 0, "/usr/local\n", "")); + ] + in + let diagnostics = + Env.diagnostics Platform.Macos + ~env: + (fake_env + [ ("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin") ]) + ~run:(fake_runner responses) + in + let diagnostic = + find_diagnostic "env.macos.homebrew-architecture" diagnostics + in + expect_severity "homebrew arch severity" Check.Warn + diagnostic.Check.severity; + expect_string "homebrew arch title" + "Homebrew architecture may not match this shell" + diagnostic.Check.title; + let detail = + expect_some "homebrew arch detail" diagnostic.Check.detail + in + expect_contains "homebrew arch process" "Process architecture: arm64" + detail; + expect_contains "homebrew arch prefix" "Homebrew prefix: /usr/local" + detail + +let test_intel_macos_homebrew_prefix_is_quiet () = + let responses = + [ + (("uname", [ "-m" ]), (Process.Exited 0, "x86_64\n", "")); + ( ("sysctl", [ "-n"; "hw.optional.arm64" ]), + (Process.Exited 0, "0\n", "") ); + (("brew", [ "--prefix" ]), (Process.Exited 0, "/usr/local\n", "")); + ] + in + let diagnostics = + Env.diagnostics Platform.Macos + ~env:(fake_env [ ("PATH", "/usr/local/bin:/usr/bin") ]) + ~run:(fake_runner responses) + in + if + List.exists + (fun diagnostic -> + String.equal diagnostic.Check.id + "env.macos.homebrew-architecture") + diagnostics + then failwith "intel homebrew prefix should not warn" + +let macos_arm64_responses = + [ + (("uname", [ "-m" ]), (Process.Exited 0, "arm64\n", "")); + ( ("sysctl", [ "-n"; "hw.optional.arm64" ]), + (Process.Exited 0, "1\n", "") ); + ] + +let test_macos_conda_cross_toolchain_variables_warn () = + let diagnostics = + Env.diagnostics Platform.Macos + ~env: + (fake_env + [ + ("PATH", "/usr/bin:/bin"); + ("CONDA_PREFIX", "/Users/me/miniconda3/envs/build"); + ( "CC", + "/Users/me/miniconda3/envs/build/bin/x86_64-apple-darwin-clang" + ); + ("LDFLAGS", "-L/Users/me/miniconda3/envs/build/lib"); + ]) + ~run:(fake_runner macos_arm64_responses) + in + let diagnostic = + find_diagnostic "env.macos.toolchain-variables" diagnostics + in + expect_severity "toolchain variables severity" Check.Warn + diagnostic.Check.severity; + expect_string "toolchain variables title" + "compiler or linker environment variables may affect macOS builds" + diagnostic.Check.title; + let detail = + expect_some "toolchain variables detail" diagnostic.Check.detail + in + expect_contains "toolchain variables cc" "CC=/Users/me/miniconda3" + detail; + expect_contains "toolchain variables conda reason" "Conda toolchain" + detail; + expect_contains "toolchain variables arch reason" + "architecture mismatch" detail; + expect_contains "toolchain variables ldflags" + "LDFLAGS=-L/Users/me/miniconda3" detail + +let test_macos_plain_toolchain_variables_are_quiet () = + Env.diagnostics Platform.Macos + ~env: + (fake_env + [ + ("PATH", "/usr/bin:/bin"); + ("CC", "clang"); + ("CFLAGS", "-O2 -g"); + ]) + ~run:(fake_runner macos_arm64_responses) + |> expect_no_diagnostics "plain toolchain variables" + +let test_linux_conda_toolchain_variables_are_quiet () = + Env.diagnostics Platform.Linux + ~env: + (fake_env + [ + ("PATH", "/usr/bin:/bin"); + ("CONDA_PREFIX", "/home/me/miniconda3/envs/build"); + ("CC", "/home/me/miniconda3/envs/build/bin/clang"); + ]) + |> expect_no_diagnostics "linux conda toolchain variables" + +let test_rosetta_shell_with_arm64_toolchain_variable_warns () = + let responses = + [ + (("uname", [ "-m" ]), (Process.Exited 0, "x86_64\n", "")); + ( ("sysctl", [ "-n"; "hw.optional.arm64" ]), + (Process.Exited 0, "1\n", "") ); + ] + in + let diagnostics = + Env.diagnostics Platform.Macos + ~env: + (fake_env + [ + ("PATH", "/usr/local/bin:/usr/bin"); + ("CC", "/opt/homebrew/bin/arm64-apple-darwin-clang"); + ]) + ~run:(fake_runner responses) + in + let diagnostic = + find_diagnostic "env.macos.toolchain-variables" diagnostics + in + expect_severity "rosetta toolchain variable severity" Check.Warn + diagnostic.Check.severity; + expect_contains "rosetta toolchain variable detail" + "architecture mismatch" + (expect_some "rosetta toolchain variable detail" + diagnostic.Check.detail) + let () = List.iter (fun test -> test ()) @@ -192,4 +418,12 @@ let () = test_forced_clicolor_value_warns; test_grep_options_warns; test_multiple_environment_hygiene_warnings_can_be_reported; + test_macos_homebrew_binutils_shadow_xcode_tools_warns; + test_macos_xcode_tools_do_not_warn; + test_macos_mixed_homebrew_architecture_warns; + test_intel_macos_homebrew_prefix_is_quiet; + test_macos_conda_cross_toolchain_variables_warn; + test_macos_plain_toolchain_variables_are_quiet; + test_linux_conda_toolchain_variables_are_quiet; + test_rosetta_shell_with_arm64_toolchain_variable_warns; ]