Skip to content
This repository has been archived by the owner on Aug 1, 2021. It is now read-only.

Add support for .licensir.exs file #18

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,4 @@ This project contains 3rd party work as follow:

- ASCII table rendering: a [partial copy](./lib/table_rex) of [djm/table_rex](https://github.com/djm/table_rex).
- CSV rendering: a [partial copy](./lib/csv) of [beatrichartz/csv](https://github.com/beatrichartz/csv).
- Config parsing: a [partial copy](./lib/credo) of [rrrene/credo](https://github.com/rrrene/credo)
20 changes: 20 additions & 0 deletions lib/credo/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Copyright (c) 2015-2020 René Föhring

Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:

The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
4 changes: 4 additions & 0 deletions lib/credo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
This directory contains a copy of [rrrene/credo](https://github.com/rrrene/credo).

A hard copy is used instead of a dependency so that `mix archive.install ...`,
which does not recognize the archive's defined dependencies, is supported.
76 changes: 76 additions & 0 deletions lib/credo/exs_loader.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
defmodule Credo.ExsLoader do
@moduledoc false

def parse(exs_string) do
case Code.string_to_quoted(exs_string) do
{:ok, ast} ->
{:ok, process_exs(ast)}

{:error, {line_meta, message, trigger}} when is_list(line_meta) ->
{:error, {line_meta[:line], message, trigger}}

{:error, value} ->
{:error, value}
end
end

defp process_exs(v)
when is_atom(v) or is_binary(v) or is_float(v) or is_integer(v),
do: v

defp process_exs(list) when is_list(list) do
Enum.map(list, &process_exs/1)
end

defp process_exs({:sigil_w, _, [{:<<>>, _, [list_as_string]}, []]}) do
String.split(list_as_string, ~r/\s+/)
end

# TODO: support regex modifiers
defp process_exs({:sigil_r, _, [{:<<>>, _, [regex_as_string]}, []]}) do
Regex.compile!(regex_as_string)
end

defp process_exs({:%{}, _meta, body}) do
process_map(body, %{})
end

defp process_exs({:{}, _meta, body}) do
process_tuple(body, {})
end

defp process_exs({:__aliases__, _meta, name_list}) do
Module.safe_concat(name_list)
end

defp process_exs({{:__aliases__, _meta, name_list}, options}) do
{Module.safe_concat(name_list), process_exs(options)}
end

defp process_exs({key, value}) when is_atom(key) or is_binary(key) do
{process_exs(key), process_exs(value)}
end

defp process_tuple([], acc), do: acc

defp process_tuple([head | tail], acc) do
acc = process_tuple_item(head, acc)
process_tuple(tail, acc)
end

defp process_tuple_item(value, acc) do
Tuple.append(acc, process_exs(value))
end

defp process_map([], acc), do: acc

defp process_map([head | tail], acc) do
acc = process_map_item(head, acc)
process_map(tail, acc)
end

defp process_map_item({key, value}, acc)
when is_atom(key) or is_binary(key) do
Map.put(acc, key, process_exs(value))
end
end
28 changes: 28 additions & 0 deletions lib/licensir/config_file.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
defmodule Licensir.ConfigFile do
@moduledoc """
Parse a project's .licensir.exs file to determine what licenses are acceptable to the user, not acceptable, and projects that are allowed
"""

@config_filename ".licensir.exs"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

original issue mentioned .licenses.exs but I used .licensir.exs instead. LMK which you prefer


defstruct allowlist: [], denylist: [], allow_deps: []
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change to :allow, :deny?


def parse(nil), do: parse(@config_filename)
def parse(file) do
if File.exists?(file) do
{:ok, raw} =
file
|> File.read!()
|> Credo.ExsLoader.parse()

{:ok,
%__MODULE__{
allowlist: raw[:allowlist] || [],
denylist: raw[:denylist] || [],
allow_deps: Enum.map(raw[:allow_deps] || [], &Atom.to_string/1)
}}
else
{:ok, %__MODULE__{}}
end
end
end
4 changes: 4 additions & 0 deletions lib/licensir/license.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule Licensir.License do
license: nil,
certainty: 0.0,
mix: nil,
status: :unknown,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to a better name here. This is also what would be in the csv and stdout output.

hex_metadata: nil,
file: nil

Expand All @@ -33,6 +34,9 @@ defmodule Licensir.License do
certainty: float(),
mix: list(String.t()) | nil,
hex_metadata: list(String.t()) | nil,
status: status(),
file: String.t() | nil
}

@type status :: :allowed | :not_allowed | :unknown
end
16 changes: 15 additions & 1 deletion lib/licensir/scanner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Licensir.Scanner do
@moduledoc """
Scans the project's dependencies for their license information.
"""
alias Licensir.{License, FileAnalyzer, Guesser}
alias Licensir.{License, FileAnalyzer, Guesser, ConfigFile}

@human_names %{
apache2: "Apache 2",
Expand All @@ -25,13 +25,15 @@ defmodule Licensir.Scanner do
def scan(opts) do
# Make sure the dependencies are loaded
Mix.Project.get!()
{:ok, config} = Licensir.ConfigFile.parse(opts[:config_file])

deps()
|> to_struct()
|> filter_top_level(opts)
|> search_hex_metadata()
|> search_file()
|> Guesser.guess()
|> put_status(config)
end

@spec deps() :: list(Mix.Dep.t())
Expand Down Expand Up @@ -59,6 +61,18 @@ defmodule Licensir.Scanner do
}
end

defp put_status(licenses, config) when is_list(licenses),
do: Enum.map(licenses, &put_status(&1, config))

defp put_status(%License{name: name, license: license_name} = license, %ConfigFile{} = config) do
cond do
name in config.allow_deps -> %{license | status: :allowed}
license_name in config.allowlist -> %{license | status: :allowed}
license_name in config.denylist -> %{license | status: :not_allowed}
true -> license
end
end

defp filter_top_level(deps, opts) do
if Keyword.get(opts, :top_level_only) do
Enum.filter(deps, &(&1.dep.top_level))
Expand Down
26 changes: 21 additions & 5 deletions lib/mix/tasks/licenses.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,32 @@ defmodule Mix.Tasks.Licenses do
def run(argv) do
{opts, _argv} = OptionParser.parse!(argv, switches: @switches)

Licensir.Scanner.scan(opts)
|> Enum.sort_by(fn license -> license.name end)
licenses =
opts
|> Licensir.Scanner.scan()
|> Enum.sort_by(fn license -> license.name end)

licenses
|> Enum.map(&to_row/1)
|> render(opts)

exit_status(licenses)
end

defp exit_status(licenses) do
if Enum.any?(licenses, &(&1.status == :not_allowed)) do
exit({:shutdown, 1})
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see a good way to test this since it would also shutdown the test, but running it manually proved it worked.

end

defp to_row(map) do
[map.name, map.version, map.license]
[map.name, map.version, map.license, license_status(map.status)]
end

def license_status(:allowed), do: "Allowed"
def license_status(:not_allowed), do: "Not allowed"
def license_status(_), do: "Unknown"

defp render(rows, opts) do
cond do
Keyword.get(opts, :csv) -> render_csv(rows)
Expand All @@ -40,13 +56,13 @@ defmodule Mix.Tasks.Licenses do
_ = Mix.Shell.IO.info([:yellow, "Notice: This is not a legal advice. Use the information below at your own risk."])

rows
|> TableRex.quick_render!(["Package", "Version", "License"])
|> TableRex.quick_render!(["Package", "Version", "License", "Status"])
|> IO.puts()
end

defp render_csv(rows) do
rows
|> List.insert_at(0, ["Package", "Version", "License"])
|> List.insert_at(0, ["Package", "Version", "License", "Status"])
|> CSV.encode()
|> Enum.each(&IO.write/1)
end
Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/licensir-config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
%{
allowlist: ["MIT", "Apache 2.0"],
denylist: ["GPLv2", "Licensir Mock License"],
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted for the human name since that's the output folks would see in their stdout/csv.

allow_deps: [:dep_mock_license]
}
13 changes: 13 additions & 0 deletions test/licensir/config_file_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Licensir.ConfigFileTest do
use Licensir.Case
alias Licensir.{ConfigFile}

describe "parse" do
test "returns options" do
assert {:ok, config} = ConfigFile.parse("test/fixtures/licensir-config.exs")
assert config.allowlist == ["MIT", "Apache 2.0"]
assert config.denylist == ["GPLv2", "Licensir Mock License"]
assert config.allow_deps == ["dep_mock_license"]
end
end
end
16 changes: 16 additions & 0 deletions test/licensir/scanner_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ defmodule Licensir.ScannerTest do
refute has_license?(licenses, %{app: :dep_of_dep})
end

test "returns the acceptability of the license" do
licenses = Licensir.Scanner.scan([config_file: "test/fixtures/licensir-config.exs"])

not_allowed_license = Enum.find(licenses, & &1.name == "dep_one_license")
assert not_allowed_license.status == :not_allowed

unknown_license = Enum.find(licenses, & &1.name == "dep_license_undefined")
assert unknown_license.status == :unknown

allowed_license = Enum.find(licenses, & &1.name == "dep_two_variants_same_license")
assert allowed_license.status == :allowed

allowed_app = Enum.find(licenses, & &1.name == "dep_mock_license")
assert allowed_app.status == :allowed
end

defp has_license?(licenses, search_map) do
Enum.any?(licenses, fn license ->
Map.merge(license, search_map) == license
Expand Down
44 changes: 23 additions & 21 deletions test/mix/tasks/licenses_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,19 @@ defmodule Licensir.Mix.Tasks.LicensesTest do
expected =
IO.ANSI.format_fragment([
[:yellow, "Notice: This is not a legal advice. Use the information below at your own risk."], :reset, "\n",
"+-----------------------------------+---------+----------------------------------------------------+", "\n",
"| Package | Version | License |", "\n",
"+-----------------------------------+---------+----------------------------------------------------+", "\n",
"| dep_license_undefined | | Undefined |", "\n",
"| dep_of_dep | | Undefined |", "\n",
"| dep_one_license | | Licensir Mock License |", "\n",
"| dep_one_unrecognized_license_file | | Unrecognized license file content |", "\n",
"| dep_two_conflicting_licenses | | Unsure (found: License One, Licensir Mock License) |", "\n",
"| dep_two_licenses | | License Two, License Three |", "\n",
"| dep_two_variants_same_license | | Apache 2.0 |", "\n",
"| dep_with_dep | | Undefined |", "\n",
"+-----------------------------------+---------+----------------------------------------------------+", "\n", "\n"
"+-----------------------------------+---------+----------------------------------------------------+---------+", "\n",
"| Package | Version | License | Status |", "\n",
"+-----------------------------------+---------+----------------------------------------------------+---------+", "\n",
"| dep_license_undefined | | Undefined | Unknown |", "\n",
"| dep_mock_license | | Licensir Mock License | Unknown |", "\n",
"| dep_of_dep | | Undefined | Unknown |", "\n",
"| dep_one_license | | Licensir Mock License | Unknown |", "\n",
"| dep_one_unrecognized_license_file | | Unrecognized license file content | Unknown |", "\n",
"| dep_two_conflicting_licenses | | Unsure (found: License One, Licensir Mock License) | Unknown |", "\n",
"| dep_two_licenses | | License Two, License Three | Unknown |", "\n",
"| dep_two_variants_same_license | | Apache 2.0 | Unknown |", "\n",
"| dep_with_dep | | Undefined | Unknown |", "\n",
"+-----------------------------------+---------+----------------------------------------------------+---------+", "\n", "\n"
])
|> to_string()

Expand All @@ -37,15 +38,16 @@ defmodule Licensir.Mix.Tasks.LicensesTest do

expected =
"""
Package,Version,License\r
dep_license_undefined,,Undefined\r
dep_of_dep,,Undefined\r
dep_one_license,,Licensir Mock License\r
dep_one_unrecognized_license_file,,Unrecognized license file content\r
dep_two_conflicting_licenses,,"Unsure (found: License One, Licensir Mock License)"\r
dep_two_licenses,,"License Two, License Three"\r
dep_two_variants_same_license,,Apache 2.0\r
dep_with_dep,,Undefined\r
Package,Version,License,Status\r
dep_license_undefined,,Undefined,Unknown\r
dep_mock_license,,Licensir Mock License,Unknown\r
dep_of_dep,,Undefined,Unknown\r
dep_one_license,,Licensir Mock License,Unknown\r
dep_one_unrecognized_license_file,,Unrecognized license file content,Unknown\r
dep_two_conflicting_licenses,,"Unsure (found: License One, Licensir Mock License)",Unknown\r
dep_two_licenses,,"License Two, License Three",Unknown\r
dep_two_variants_same_license,,Apache 2.0,Unknown\r
dep_with_dep,,Undefined,Unknown\r
"""

assert output == expected
Expand Down
3 changes: 2 additions & 1 deletion test/support/test_app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule Licensir.TestApp do
{:dep_license_undefined, path: "test/fixtures/deps/dep_license_undefined"},
{:dep_two_variants_same_license, path: "test/fixtures/deps/dep_two_variants_same_license"},
{:dep_two_conflicting_licenses, path: "test/fixtures/deps/dep_two_conflicting_licenses"},
{:dep_one_unrecognized_license_file, path: "test/fixtures/deps/dep_one_unrecognized_license_file"}
{:dep_one_unrecognized_license_file, path: "test/fixtures/deps/dep_one_unrecognized_license_file"},
{:dep_mock_license, path: "test/fixtures/deps/dep_mock_license"}
]
]
end
Expand Down