# SPDX-FileCopyrightText: 2021 Rosa Richter
#
# SPDX-License-Identifier: MIT
defmodule Mix.Tasks.Licenses.Lint do
@moduledoc """
Check the current project's licenses.
The Hex administrators recommend setting a package's `:licenses` value to SPDX license identifiers.
However, this is only a recommendation, and is not enforced in any way.
This task will enforce the use of SPDX identifiers in your package,
and will return an error code if the current project is using any unrecognized or non-OSI-approved licenses.
## Configuration
* `:package` - contain a `:licenses` list, which must be a list containing SPDX license identifiers, for example `["MIT"]`
## Command line options
* `--reuse` - additionally check if the licenses declared in `mix.exs` match those in the `LICENSES` directory
according to the [REUSE specification](https://reuse.software).
* `--osi` - additionally check if all licenses are approved by the [Open Source Initiative](https://opensource.org/licenses)
* `--update` - pull down a fresh copy of the SPDX license list instead of using the version checked in with this tool.
"""
use Mix.Task
@shortdoc "Check the current project's licenses."
def run(args) do
package = Mix.Project.get!().project()[:package]
validate_package!(package)
license_list =
if "--update" in args do
HexLicenses.SPDX.fetch_licenses()
|> HexLicenses.SPDX.parse_licenses()
else
HexLicenses.SPDX.licenses()
end
{:ok, result} = HexLicenses.lint(package, license_list)
error? = false
error? =
if "--reuse" in args do
check_reuse_spec() || error?
else
error?
end
check_osi_approved = "--osi" in args
allowed_statuses =
if check_osi_approved do
[:osi_approved]
else
[:osi_approved, :not_approved]
end
unsafe_licenses =
Enum.filter(result, fn {_license, status} -> status not in allowed_statuses end)
error? =
if Enum.empty?(unsafe_licenses) do
if check_osi_approved do
Mix.shell().info("This project's licenses are all recognized and OSI-approved.")
else
Mix.shell().info("This project's licenses are all valid SPDX identifiers.")
end
error?
else
Mix.shell().info("This project has #{Enum.count(unsafe_licenses)} unsafe licenses:")
Enum.each(unsafe_licenses, &print_status/1)
true
end
if error? do
exit({:shutdown, 1})
end
end
defp validate_package!(package) do
if is_nil(package) do
Mix.shell().error("This project does not have :package key defined in mix.exs.")
exit({:shutdown, 1})
end
if Enum.empty?(Keyword.get(package, :licenses, [])) do
Mix.shell().error("This project's :package config has a nil or empty :licenses list.")
exit({:shutdown, 1})
end
end
defp print_status({license, :not_approved}) do
Mix.shell().info(" - \"#{license}\" is not OSI-approved.")
end
defp print_status({license, :not_recognized}) do
Mix.shell().info(" - \"#{license}\" is not an SPDX ID")
end
defp check_reuse_spec do
mix_licenses =
Mix.Project.config()
|> Access.fetch!(:package)
|> Access.fetch!(:licenses)
|> MapSet.new()
file_licenses =
Mix.Project.config_files()
|> Enum.find(fn config_file -> Path.basename(config_file) == "mix.exs" end)
|> Path.dirname()
|> Path.join("LICENSES")
|> File.ls!()
|> Enum.map(fn license_file -> Path.basename(license_file, ".txt") end)
|> MapSet.new()
missing_from_mix = MapSet.difference(file_licenses, mix_licenses)
missing_from_dir = MapSet.difference(mix_licenses, file_licenses)
if Enum.any?(missing_from_mix) do
Mix.shell().info("This project has licenses in LICENSES/ that are not declared in mix.exs:")
Enum.each(missing_from_mix, fn license ->
Mix.shell().info(" - #{license}")
end)
end
if Enum.any?(missing_from_dir) do
Mix.shell().info(
"This project has licenses declared in mix.exs that are not present in LICENSES/"
)
Enum.each(missing_from_dir, fn license ->
Mix.shell().info(" - #{license}")
end)
end
if Enum.empty?(missing_from_mix) and Enum.empty?(missing_from_dir) do
Mix.shell().info(
"This project's declared licenses match the files in the LICENSES/ directory"
)
false
else
true
end
end
end