defmodule NPM.Diagnostics.EngineCheck do @moduledoc """ Checks engine compatibility across all installed packages. Validates that the current Node.js version (if installed) satisfies engine requirements declared by packages in node_modules. """ @type engine_issue :: %{ package: String.t(), engine: String.t(), required: String.t(), actual: String.t() | nil, satisfied: boolean() } @doc """ Scans node_modules for engine requirements and checks compatibility. """ @spec check_all(String.t()) :: [engine_issue()] def check_all(node_modules_dir) do node_version = current_node_version() case File.ls(node_modules_dir) do {:ok, entries} -> entries |> Enum.flat_map(&read_engines(node_modules_dir, &1)) |> Enum.flat_map(&check_engine(&1, node_version)) |> Enum.sort_by(& &1.package) _ -> [] end end @doc """ Checks a single package's engines against current versions. """ @spec check_package(map(), String.t() | nil) :: [engine_issue()] def check_package(%{"engines" => engines} = data, node_version) when is_map(engines) do name = data["name"] || "unknown" check_engine({name, engines}, node_version) end def check_package(_, _), do: [] @doc """ Returns only unsatisfied engine requirements. """ @spec unsatisfied([engine_issue()]) :: [engine_issue()] def unsatisfied(issues), do: Enum.reject(issues, & &1.satisfied) @doc """ Formats engine issues for display. """ @spec format_issues([engine_issue()]) :: String.t() def format_issues([]), do: "All engine requirements satisfied." def format_issues(issues) do Enum.map_join(issues, "\n", fn issue -> status = if issue.satisfied, do: "✓", else: "✗" actual = issue.actual || "not installed" "#{status} #{issue.package}: #{issue.engine} #{issue.required} (current: #{actual})" end) end defp read_engines(nm_dir, entry) do if String.starts_with?(entry, "@") do read_scoped_engines(nm_dir, entry) else read_single_engines(nm_dir, entry, entry) end end defp read_scoped_engines(nm_dir, scope) do scope_dir = Path.join(nm_dir, scope) case File.ls(scope_dir) do {:ok, subs} -> Enum.flat_map(subs, &read_single_engines(scope_dir, &1, "#{scope}/#{&1}")) _ -> [] end end defp read_single_engines(parent, name, full_name) do pkg_json = Path.join([parent, name, "package.json"]) case File.read(pkg_json) do {:ok, content} -> data = NPM.JSON.decode!(content) case data["engines"] do engines when is_map(engines) and map_size(engines) > 0 -> [{full_name, engines}] _ -> [] end _ -> [] end rescue _ -> [] end defp check_engine({name, engines}, node_version) do Enum.flat_map(engines, fn {engine, range} -> {actual, satisfied} = evaluate_engine(engine, range, node_version) [ %{ package: name, engine: engine, required: range, actual: actual, satisfied: satisfied } ] end) end defp evaluate_engine("node", range, node_version) do case node_version do nil -> {nil, true} version -> {version, NPMSemver.matches?(version, range)} end rescue _ -> {node_version, true} end defp evaluate_engine(_engine, _range, _node_version), do: {nil, true} defp current_node_version do case System.cmd("node", ["--version"], stderr_to_stdout: true) do {"v" <> version, 0} -> String.trim(version) _ -> nil end rescue _ -> nil end end