licensed 1.5.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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)