licensed 1.5.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +22 -1
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +17 -24
  6. data/Rakefile +2 -2
  7. data/docs/adding_a_new_source.md +93 -0
  8. data/docs/commands.md +81 -0
  9. data/docs/configuration.md +8 -8
  10. data/docs/migrating_to_newer_versions.md +3 -0
  11. data/docs/reporters.md +174 -0
  12. data/docs/sources/bundler.md +5 -5
  13. data/lib/licensed.rb +5 -14
  14. data/lib/licensed/cli.rb +23 -9
  15. data/lib/licensed/commands.rb +9 -0
  16. data/lib/licensed/commands/cache.rb +82 -0
  17. data/lib/licensed/commands/command.rb +112 -0
  18. data/lib/licensed/commands/list.rb +24 -0
  19. data/lib/licensed/commands/status.rb +49 -0
  20. data/lib/licensed/configuration.rb +3 -8
  21. data/lib/licensed/dependency.rb +116 -58
  22. data/lib/licensed/dependency_record.rb +76 -0
  23. data/lib/licensed/migrations.rb +7 -0
  24. data/lib/licensed/migrations/v2.rb +65 -0
  25. data/lib/licensed/reporters.rb +9 -0
  26. data/lib/licensed/reporters/cache_reporter.rb +76 -0
  27. data/lib/licensed/reporters/list_reporter.rb +69 -0
  28. data/lib/licensed/reporters/reporter.rb +119 -0
  29. data/lib/licensed/reporters/status_reporter.rb +67 -0
  30. data/lib/licensed/shell.rb +8 -10
  31. data/lib/licensed/sources.rb +15 -0
  32. data/lib/licensed/{source → sources}/bower.rb +14 -19
  33. data/lib/licensed/{source → sources}/bundler.rb +73 -48
  34. data/lib/licensed/{source → sources}/cabal.rb +40 -46
  35. data/lib/licensed/{source → sources}/dep.rb +15 -27
  36. data/lib/licensed/{source → sources}/git_submodule.rb +14 -19
  37. data/lib/licensed/{source → sources}/go.rb +28 -35
  38. data/lib/licensed/{source → sources}/manifest.rb +68 -90
  39. data/lib/licensed/{source → sources}/npm.rb +16 -25
  40. data/lib/licensed/{source → sources}/pip.rb +23 -25
  41. data/lib/licensed/sources/source.rb +69 -0
  42. data/lib/licensed/ui/shell.rb +4 -0
  43. data/lib/licensed/version.rb +6 -1
  44. data/licensed.gemspec +4 -4
  45. data/script/source-setup/bundler +1 -1
  46. metadata +32 -18
  47. data/lib/licensed/command/cache.rb +0 -82
  48. data/lib/licensed/command/list.rb +0 -43
  49. data/lib/licensed/command/status.rb +0 -79
  50. data/lib/licensed/command/version.rb +0 -18
  51. data/lib/licensed/license.rb +0 -68
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Licensed
4
- module Source
5
- class GitSubmodule
4
+ module Sources
5
+ class GitSubmodule < Source
6
6
  REMOTE_URL_ARGUMENT = "$(git remote get-url origin)".freeze
7
7
  GIT_SUBMODULES_ARGUMENTS = [
8
8
  "$displaypath", # path from repo root to submodule folder to find the name and submodule content
@@ -11,21 +11,13 @@ module Licensed
11
11
  "$(git config --get remote.origin.url)", # use the configured remote origin url as the homepage
12
12
  ].freeze
13
13
 
14
- def self.type
15
- "git_submodule"
16
- end
17
-
18
- def initialize(config)
19
- @config = config
20
- end
21
-
22
14
  def enabled?
23
15
  return false unless Licensed::Shell.tool_available?("git") && Licensed::Git.git_repo?
24
16
  gitmodules_path.exist?
25
17
  end
26
18
 
27
- def dependencies
28
- @dependencies ||= git_submodules_command.lines.map do |line|
19
+ def enumerate_dependencies
20
+ git_submodules_command.lines.map do |line|
29
21
  displaypath, toplevel, version, homepage = line.strip.split
30
22
  name = File.basename(displaypath)
31
23
  submodule_path = if toplevel == @config.pwd.to_s
@@ -36,13 +28,16 @@ module Licensed
36
28
  end
37
29
  submodule_paths[name] = submodule_path
38
30
 
39
- Licensed::Dependency.new(@config.pwd.join(displaypath), {
40
- "type" => self.class.type,
41
- "name" => name,
42
- "version" => version,
43
- "homepage" => homepage,
44
- "path" => submodule_path
45
- })
31
+ Licensed::Dependency.new(
32
+ name: submodule_path,
33
+ version: version,
34
+ path: @config.pwd.join(displaypath),
35
+ metadata: {
36
+ "type" => self.class.type,
37
+ "name" => name,
38
+ "homepage" => homepage
39
+ }
40
+ )
46
41
  end
47
42
  end
48
43
 
@@ -3,33 +3,31 @@ require "json"
3
3
  require "pathname"
4
4
 
5
5
  module Licensed
6
- module Source
7
- class Go
8
- def self.type
9
- "go"
10
- end
11
-
12
- def initialize(config)
13
- @config = config
14
- end
15
-
6
+ module Sources
7
+ class Go < Source
16
8
  def enabled?
17
9
  Licensed::Shell.tool_available?("go") && go_source?
18
10
  end
19
11
 
20
- def dependencies
21
- @dependencies ||= with_configured_gopath do
12
+ def enumerate_dependencies
13
+ with_configured_gopath do
22
14
  packages.map do |package|
23
15
  import_path = non_vendored_import_path(package["ImportPath"])
16
+ error = package.dig("Error", "Err") if package["Error"]
24
17
  package_dir = package["Dir"]
25
- Dependency.new(package_dir, {
26
- "type" => Go.type,
27
- "name" => import_path,
28
- "summary" => package["Doc"],
29
- "homepage" => homepage(import_path),
30
- "search_root" => search_root(package_dir),
31
- "version" => package_version(package)
32
- })
18
+
19
+ Dependency.new(
20
+ name: import_path,
21
+ version: package_version(package),
22
+ path: package_dir,
23
+ search_root: search_root(package_dir),
24
+ errors: Array(error),
25
+ metadata: {
26
+ "type" => Go.type,
27
+ "summary" => package["Doc"],
28
+ "homepage" => homepage(import_path)
29
+ }
30
+ )
33
31
  end
34
32
  end
35
33
  end
@@ -53,25 +51,18 @@ module Licensed
53
51
  def root_package_deps
54
52
  # check for ignored packages to avoid raising errors calling `go list`
55
53
  # when ignored package is not found
56
- Array(root_package["Deps"])
57
- .reject { |name| @config.ignored?("type" => Go.type, "name" => name) }
58
- .map { |name| package_info(name) }
54
+ Array(root_package["Deps"]).map { |name| package_info(name) }
59
55
  end
60
56
 
61
57
  # Returns the list of dependencies as returned by "go list -json -deps"
62
58
  # available in go 1.11
63
59
  def go_list_deps
64
- @go_list_deps ||= begin
65
- deps = package_info_command("-deps")
66
- # the CLI command returns packages in a pretty-printed JSON format but
67
- # not separated by commas. this gsub adds commas after all non-indented
68
- # "}" that close root level objects.
69
- # (?!\z) uses negative lookahead to not match the final "}"
70
- deps.gsub!(/^}(?!\z)$/m, "},")
71
- JSON.parse("[#{deps}]")
72
- .reject { |pkg| @config.ignored?("type" => Go.type, "name" => pkg["ImportPath"]) }
73
- .each { |pkg| raise pkg.dig("Error", "Err") if pkg["Error"] }
74
- end
60
+ # the CLI command returns packages in a pretty-printed JSON format but
61
+ # not separated by commas. this gsub adds commas after all non-indented
62
+ # "}" that close root level objects.
63
+ # (?!\z) uses negative lookahead to not match the final "}"
64
+ deps = package_info_command("-deps").gsub(/^}(?!\z)$/m, "},")
65
+ JSON.parse("[#{deps}]")
75
66
  end
76
67
 
77
68
  # Returns whether the given package import path belongs to the
@@ -129,6 +120,8 @@ module Licensed
129
120
  #
130
121
  # package - package object obtained from package_info
131
122
  def search_root(package_dir)
123
+ return nil if package_dir.nil? || package_dir.empty?
124
+
132
125
  # search root choices:
133
126
  # 1. vendor folder if package is vendored
134
127
  # 2. GOPATH
@@ -165,7 +158,7 @@ module Licensed
165
158
  #
166
159
  # args - additional arguments to `go list`, e.g. Go package import path
167
160
  def package_info_command(*args)
168
- Licensed::Shell.execute("go", "list", "-json", *Array(args)).strip
161
+ Licensed::Shell.execute("go", "list", "-e", "-json", *Array(args)).strip
169
162
  end
170
163
 
171
164
  # Returns the info for the package under test
@@ -2,29 +2,22 @@
2
2
  require "pathname/common_prefix"
3
3
 
4
4
  module Licensed
5
- module Source
6
- class Manifest
7
- def self.type
8
- "manifest"
9
- end
10
-
11
- def initialize(config)
12
- @config = config
13
- end
14
-
5
+ module Sources
6
+ class Manifest < Source
15
7
  def enabled?
16
8
  File.exist?(manifest_path) || generate_manifest?
17
9
  end
18
10
 
19
- def dependencies
20
- @dependencies ||= packages.map do |package_name, sources|
21
- Licensed::Source::Manifest::Dependency.new(sources,
22
- @config.root,
23
- package_license(package_name),
24
- {
11
+ def enumerate_dependencies
12
+ packages.map do |package_name, sources|
13
+ Licensed::Sources::Manifest::Dependency.new(
14
+ name: package_name,
15
+ version: package_version(sources),
16
+ path: configured_license_path(package_name) || sources_license_path(sources),
17
+ sources: sources,
18
+ metadata: {
25
19
  "type" => Manifest.type,
26
- "name" => package_name,
27
- "version" => package_version(sources)
20
+ "name" => package_name
28
21
  }
29
22
  )
30
23
  end
@@ -40,7 +33,7 @@ module Licensed
40
33
  end
41
34
 
42
35
  # Returns the license path for a package specified in the configuration.
43
- def package_license(package_name)
36
+ def configured_license_path(package_name)
44
37
  license_path = @config.dig("manifest", "licenses", package_name)
45
38
  return unless license_path
46
39
 
@@ -49,6 +42,25 @@ module Licensed
49
42
  license_path
50
43
  end
51
44
 
45
+ # Returns the top-most directory that is common to all paths in `sources`
46
+ def sources_license_path(sources)
47
+ # if there is more than one source, try to find a directory common to
48
+ # all sources
49
+ if sources.size > 1
50
+ common_prefix = Pathname.common_prefix(*sources).to_path
51
+
52
+ # don't allow the workspace root to be used as common prefix
53
+ # the project this is run for should be excluded from the manifest,
54
+ # or ignored in the config. any license in the root should be ignored.
55
+ return common_prefix if common_prefix != @config.root
56
+ end
57
+
58
+ # use the first (or only) sources directory to find license information
59
+ source = sources.first
60
+ return File.dirname(source) if File.file?(source)
61
+ source
62
+ end
63
+
52
64
  # Returns a map of package names -> array of full source paths found
53
65
  # in the app manifest
54
66
  def packages
@@ -98,7 +110,7 @@ module Licensed
98
110
  def verify_configured_dependencies!
99
111
  # verify that dependencies are configured
100
112
  if configured_dependencies.empty?
101
- raise "The manifest \"dependencies\" cannot be empty!"
113
+ raise Source::Error.new("The manifest \"dependencies\" cannot be empty!")
102
114
  end
103
115
 
104
116
  # verify all included files match a single configured dependency
@@ -116,7 +128,7 @@ module Licensed
116
128
  end
117
129
 
118
130
  errors.compact!
119
- raise errors.join($/) unless errors.empty?
131
+ raise Source::Error.new(errors.join($/)) if errors.any?
120
132
  end
121
133
 
122
134
  # Returns the project dependencies specified from the licensed configuration
@@ -177,87 +189,53 @@ module Licensed
177
189
  )
178
190
  /imx.freeze
179
191
 
180
- def initialize(sources, root, license_path, metadata = {})
192
+ def initialize(name:, version:, path:, sources:, metadata: {})
181
193
  @sources = sources
182
- license_path ||= sources_license_path(sources, root)
183
- super license_path, metadata
184
- end
185
-
186
- # Detects license information and sets it on this dependency object.
187
- # After calling `detect_license!``, the license is set at
188
- # `dependency["license"]` and legal text is set to `dependency.text`
189
- def detect_license!
190
- # if no license key is found for the project, try to create a
191
- # temporary LICENSE file from unique source file license headers
192
- if license_key == "none"
193
- tmp_license_file = write_license_from_source_licenses(self.path, @sources)
194
- reset_license!
195
- end
196
-
197
- super
198
- ensure
199
- File.delete(tmp_license_file) if tmp_license_file && File.exist?(tmp_license_file)
194
+ super(name: name, version: version, path: path, metadata: metadata)
200
195
  end
201
196
 
202
- private
203
-
204
- # Returns the top-most directory that is common to all paths in `sources`
205
- def sources_license_path(sources, root)
206
- # return the source directory if there is only one source given
207
- return source_directory(sources[0]) if sources.size == 1
208
-
209
- common_prefix = Pathname.common_prefix(*sources).to_path
210
-
211
- # don't allow the workspace root to be used as common prefix
212
- # the project this is run for should be excluded from the manifest,
213
- # or ignored in the config. any license in the root should be ignored.
214
- return common_prefix if common_prefix != root
215
-
216
- # use the first source directory as the license path.
217
- source_directory(sources.first)
197
+ def project_files
198
+ files = super
199
+ files.concat(source_files) if files.empty?
200
+ files
218
201
  end
219
202
 
220
- # Returns the directory for the source. Checks whether the source
221
- # is a file or a directory
222
- def source_directory(source)
223
- return File.dirname(source) if File.file?(source)
224
- source
203
+ # Returns an enumeration of Licensee::ProjectFiles::LicenseFile
204
+ # representing licenses found in source header comments
205
+ def source_files
206
+ @source_files ||= begin
207
+ @sources
208
+ .select { |file| File.file?(file) }
209
+ .flat_map { |file| source_file_comments(file) }
210
+ .map do |comment, file|
211
+ Licensee::ProjectFiles::LicenseFile.new(comment, file)
212
+ end
213
+ end
225
214
  end
226
215
 
227
- # Writes any licenses found in source file comments to a LICENSE
228
- # file at `dir`
229
- # Returns the path to the license file
230
- def write_license_from_source_licenses(dir, sources)
231
- license_path = File.join(dir, "LICENSE")
232
- File.open(license_path, "w") do |f|
233
- licenses = source_comment_licenses(sources).uniq
234
- f.puts(licenses.join("\n#{LICENSE_SEPARATOR}\n"))
235
- end
216
+ private
236
217
 
237
- license_path
218
+ # Returns all source header comments for a file
219
+ def source_file_comments(file)
220
+ file_parts = { dir: File.dirname(file), name: File.basename(file) }
221
+ content = File.read(file)
222
+ matches = content.scan(HEADER_LICENSE_REGEX)
223
+ matches.map { |match| [source_comment_text(match[0]), file_parts] }
238
224
  end
239
225
 
240
- # Returns a list of unique licenses parsed from source comments
241
- def source_comment_licenses(sources)
242
- comments = sources.select { |s| File.file?(s) }.flat_map do |source|
243
- content = File.read(source)
244
- content.scan(HEADER_LICENSE_REGEX).map { |match| match[0] }
245
- end
246
-
247
- comments.map do |comment|
248
- # strip leading "*" and whitespace
249
- indent = nil
250
- comment.lines.map do |line|
251
- # find the length of the indent as the number of characters
252
- # until the first word character
253
- indent ||= line[/\A([^\w]*)\w/, 1]&.size
226
+ # Returns the comment text with leading * and whitespace stripped
227
+ def source_comment_text(comment)
228
+ indent = nil
229
+ comment.lines.map do |line|
230
+ # find the length of the indent as the number of characters
231
+ # until the first word character
232
+ indent ||= line[/\A([^\w]*)\w/, 1]&.size
254
233
 
255
- # insert newline for each line until a word character is found
256
- next "\n" unless indent
234
+ # insert newline for each line until a word character is found
235
+ next "\n" unless indent
257
236
 
258
- line[/([^\w\r\n]{0,#{indent}})(.*)/m, 2]
259
- end.join
260
- end
237
+ line[/([^\w\r\n]{0,#{indent}})(.*)/m, 2]
238
+ end.join
261
239
  end
262
240
  end
263
241
  end
@@ -2,39 +2,36 @@
2
2
  require "json"
3
3
 
4
4
  module Licensed
5
- module Source
6
- class NPM
5
+ module Sources
6
+ class NPM < Source
7
7
  def self.type
8
8
  "npm"
9
9
  end
10
10
 
11
- def initialize(config)
12
- @config = config
13
- end
14
-
15
11
  def enabled?
16
12
  Licensed::Shell.tool_available?("npm") && File.exist?(@config.pwd.join("package.json"))
17
13
  end
18
14
 
19
- def dependencies
20
- @dependencies ||= packages.map do |name, package|
15
+ def enumerate_dependencies
16
+ packages.map do |name, package|
21
17
  path = package["path"]
22
- fail "couldn't locate #{name} under node_modules/" unless path
23
- Dependency.new(path, {
24
- "type" => NPM.type,
25
- "name" => package["name"],
26
- "version" => package["version"],
27
- "summary" => package["description"],
28
- "homepage" => package["homepage"],
29
- "path" => name
30
- })
18
+ Dependency.new(
19
+ name: name,
20
+ version: package["version"],
21
+ path: path,
22
+ metadata: {
23
+ "type" => NPM.type,
24
+ "name" => package["name"],
25
+ "summary" => package["description"],
26
+ "homepage" => package["homepage"]
27
+ }
28
+ )
31
29
  end
32
30
  end
33
31
 
34
32
  def packages
35
33
  root_dependencies = JSON.parse(package_metadata_command)["dependencies"]
36
34
  recursive_dependencies(root_dependencies).each_with_object({}) do |(name, results), hsh|
37
- next if @config.ignored?("type" => NPM.type, "name" => name)
38
35
  results.uniq! { |package| package["version"] }
39
36
  if results.size == 1
40
37
  hsh[name] = results[0]
@@ -59,13 +56,7 @@ module Licensed
59
56
 
60
57
  # Returns the output from running `npm list` to get package metadata
61
58
  def package_metadata_command
62
- npm_list_command("--json", "--production", "--long")
63
- end
64
-
65
- # Executes an `npm list` command with the provided args and returns the
66
- # output from stdout
67
- def npm_list_command(*args)
68
- Licensed::Shell.execute("npm", "list", *args)
59
+ Licensed::Shell.execute("npm", "list", "--json", "--production", "--long", allow_failure: true)
69
60
  end
70
61
  end
71
62
  end
@@ -3,42 +3,40 @@ require "json"
3
3
  require "English"
4
4
 
5
5
  module Licensed
6
- module Source
7
- class Pip
8
- def self.type
9
- "pip"
10
- end
11
-
12
- def initialize(config)
13
- @config = config
14
- end
6
+ module Sources
7
+ class Pip < Source
8
+ VERSION_OPERATORS = %w(< > <= >= == !=).freeze
9
+ PACKAGE_REGEX = /^(\w+)(#{VERSION_OPERATORS.join("|")})?/
15
10
 
16
11
  def enabled?
17
12
  return unless virtual_env_pip && Licensed::Shell.tool_available?(virtual_env_pip)
18
13
  File.exist?(@config.pwd.join("requirements.txt"))
19
14
  end
20
15
 
21
- def dependencies
22
- @dependencies ||= parse_requirements_txt.map do |package_name|
16
+ def enumerate_dependencies
17
+ packages_from_requirements_txt.map do |package_name|
23
18
  package = package_info(package_name)
24
19
  location = File.join(package["Location"], package["Name"] + "-" + package["Version"] + ".dist-info")
25
- Dependency.new(location, {
26
- "type" => Pip.type,
27
- "name" => package["Name"],
28
- "summary" => package["Summary"],
29
- "homepage" => package["Home-page"],
30
- "version" => package["Version"]
31
- })
20
+ Dependency.new(
21
+ name: package["Name"],
22
+ version: package["Version"],
23
+ path: location,
24
+ metadata: {
25
+ "type" => Pip.type,
26
+ "summary" => package["Summary"],
27
+ "homepage" => package["Home-page"]
28
+ }
29
+ )
32
30
  end
33
31
  end
34
32
 
35
- # Build the list of packages from a 'requirements.txt'
36
- # Assumes that the requirements.txt follow the format pkg=1.0.0 or pkg==1.0.0
37
- def parse_requirements_txt
38
- File.open(@config.pwd.join("requirements.txt")).map do |line|
39
- p_split = line.split("=")
40
- p_split[0]
41
- end
33
+ private
34
+
35
+ def packages_from_requirements_txt
36
+ File.read(@config.pwd.join("requirements.txt"))
37
+ .lines
38
+ .map { |line| line.strip.match(PACKAGE_REGEX) { |match| match.captures.first } }
39
+ .compact
42
40
  end
43
41
 
44
42
  def package_info(package_name)