licensee 10.0.0 → 10.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbe5733519ac70cb6abdf04a94a91e0561b4733ecfaf1be5a2f3358b7178f146
4
- data.tar.gz: 2dcdc288201a43e20bada85d4019937002c97325c28d3620876a0e4eebefbe92
3
+ metadata.gz: 7cb258a437a638b51ce88253da3fdc5e9024bbcc4ca39ad85a7d9082e5affc9c
4
+ data.tar.gz: b565687716e0006143184d8b05aef9f01af3c6a69fe76314d98cb83814350274
5
5
  SHA512:
6
- metadata.gz: e836c91d216d21da1428e063b6eee658b18ed05c4722c54b4cd427eafed96acd0af0f3096f503e344b63bb08ff73a7a8fe6ee879aec6be0b231905554531c31e
7
- data.tar.gz: '08feed6876192a2088d7e2dba10b6176834f6d6cfb930afdb394c15ea51517c75637db1f5ee1f3dda6bb020288a9e69ecf7767d51267f3c13d8b1fcb46128eb4'
6
+ metadata.gz: 7c91eb05607ecee1a7d87b968c78078443ba1a961e59d5b9811aab1cfb311671becf52223a82a8f63e7b17874f1f0152e0978c63e0af13b8bdf8bd80a2eb3d85
7
+ data.tar.gz: 06b6501dbd438cb2a218cec4d879c2f8d838642c7fff10753df4f3ac908c84062a1ae47df76750868aade6634e3deb8a1fca50f8027772b11bc33bcd2d64fa93
@@ -15,13 +15,80 @@ class LicenseeCLI < Thor
15
15
  option :diff, type: :boolean, desc: 'Compare the license to the closest match'
16
16
  option :ref, type: :string, desc: 'The name of the commit/branch/tag to search (github.com only)'
17
17
  option :filesystem, type: :boolean, desc: 'Force looking at the filesystem (ignore git data)'
18
+ option :recursive, type: :boolean, desc: 'Recursively detect licenses in subdirectories'
19
+ option :depth, type: :numeric, default: 3, desc: 'Maximum directory depth for --recursive'
18
20
  def detect(_path = nil)
19
21
  Licensee.confidence_threshold = options[:confidence]
20
22
 
21
- handle_json_output if options[:json]
23
+ if options[:recursive]
24
+ handle_recursive_detect
25
+ else
26
+ handle_json_output if options[:json]
27
+ print_project_summary
28
+ print_matched_files
29
+ maybe_diff_license_file
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle_recursive_detect
36
+ root = File.expand_path(path || Dir.pwd)
37
+ results = collect_recursive_results(root, root, options[:depth])
38
+ output_recursive_results(results)
39
+ exit(results.any? { |r| !r[:project].licenses.empty? })
40
+ end
41
+
42
+ def output_recursive_results(results)
43
+ if options[:json]
44
+ say results.map { |r| r[:project].to_h.merge(path: r[:path]) }.to_json
45
+ else
46
+ print_recursive_results(results)
47
+ end
48
+ end
49
+
50
+ def collect_recursive_results(root, current, remaining_depth)
51
+ results = []
52
+ add_recursive_project(root, current, results)
53
+ return results if remaining_depth <= 0
54
+
55
+ subdirectories(current).each do |full_path|
56
+ results.concat(collect_recursive_results(root, full_path, remaining_depth - 1))
57
+ end
58
+ results
59
+ end
60
+
61
+ def subdirectories(path)
62
+ Dir.children(path).sort.filter_map do |entry|
63
+ full_path = File.join(path, entry)
64
+ full_path if File.directory?(full_path)
65
+ end
66
+ rescue StandardError
67
+ []
68
+ end
69
+
70
+ def add_recursive_project(root, full_path, results)
71
+ proj = Licensee.project(full_path,
72
+ detect_packages: options[:packages],
73
+ detect_readme: options[:readme])
74
+ return if proj.licenses.empty?
75
+
76
+ rel = Pathname.new(full_path).relative_path_from(Pathname.new(root)).to_s
77
+ rel = '.' if rel == ''
78
+ results << { path: rel, project: proj }
79
+ end
80
+
81
+ def print_recursive_results(results)
82
+ if results.empty?
83
+ say 'No licenses detected.'
84
+ return
85
+ end
22
86
 
23
- print_project_summary
24
- print_matched_files
25
- maybe_diff_license_file
87
+ results.each do |result|
88
+ proj = result[:project]
89
+ license_ids = proj.licenses.map(&:spdx_id).join(', ')
90
+ matched_files = proj.matched_files.map(&:path).join(', ')
91
+ say "#{result[:path]} (#{matched_files}): #{license_ids}"
92
+ end
26
93
  end
27
94
  end
@@ -6,11 +6,12 @@ require 'tmpdir'
6
6
  class LicenseeCLI < Thor
7
7
  desc 'diff [PATH]', 'Compare the given license text to a known license'
8
8
  option :license, type: :string, desc: 'The SPDX ID or key of the license to compare'
9
+ option :line_diff, type: :boolean, desc: 'Use line-based diff instead of word-based diff'
9
10
  def diff(_path = nil)
10
11
  say "Comparing to #{expected_license.name}:"
11
12
  print_table diff_summary_rows
12
13
  exit_on_exact_match
13
- say word_diff
14
+ say diff_output
14
15
  end
15
16
 
16
17
  private
@@ -43,13 +44,15 @@ class LicenseeCLI < Thor
43
44
  @input_text ||= license_to_diff.content_normalized(wrap: 80)
44
45
  end
45
46
 
46
- def word_diff
47
- Dir.mktmpdir { |dir| word_diff_in_dir(dir) }
47
+ def diff_output
48
+ Dir.mktmpdir { |dir| diff_output_in_dir(dir) }
48
49
  end
49
50
 
50
- def word_diff_in_dir(dir)
51
+ def diff_output_in_dir(dir)
51
52
  path = File.expand_path 'LICENSE', dir
52
- Dir.chdir(dir) { git_word_diff(path) }
53
+ Dir.chdir(dir) do
54
+ options[:line_diff] ? git_line_diff(path) : git_word_diff(path)
55
+ end
53
56
  end
54
57
 
55
58
  def git_word_diff(path)
@@ -61,6 +64,15 @@ class LicenseeCLI < Thor
61
64
  `git diff --word-diff`
62
65
  end
63
66
 
67
+ def git_line_diff(path)
68
+ `git init`
69
+ File.write(path, expected_text)
70
+ `git add LICENSE`
71
+ `git commit -m 'left'`
72
+ File.write(path, input_text)
73
+ `git diff`
74
+ end
75
+
64
76
  def license_to_diff
65
77
  return options[:license_to_diff] if options[:license_to_diff]
66
78
  return project.license_file if remote? || ($stdin.tty? && project.license_file)
@@ -54,9 +54,14 @@ module Licensee
54
54
  alias_method :"#{field}?", field
55
55
  end
56
56
 
57
- # Backward compatibalize `#["spdx-id"]` calls to avoid a breaking change
57
+ # Backward compatibility: `#["spdx-id"]` calls to avoid a breaking change.
58
+ # Also ensure computed fields (e.g. "source") route through the instance
59
+ # method rather than reading the raw nil struct field, keeping hash-style
60
+ # and method-style access consistent.
58
61
  def [](key)
59
62
  key = 'spdx_id' if key == 'spdx-id'
63
+ return source if key.to_s == 'source'
64
+
60
65
  super
61
66
  end
62
67
 
@@ -8,6 +8,12 @@ module Licensee
8
8
  # license entry in the database (e.g. LGPL-3.0-or-later → lgpl-3.0).
9
9
  SPDX_SUFFIX_REGEX = /-or-later\z|-only\z/i
10
10
 
11
+ # Regex matching individual SPDX expression tokens (license IDs or operators).
12
+ SPDX_TOKEN_REGEX = /[A-Za-z0-9.\-+]+/
13
+
14
+ # SPDX boolean operators to skip when parsing compound expressions.
15
+ SPDX_OPERATORS = %w[OR AND WITH].freeze
16
+
11
17
  def match
12
18
  return @match if defined? @match
13
19
 
@@ -24,12 +30,44 @@ module Licensee
24
30
  90
25
31
  end
26
32
 
33
+ # Returns all licenses found in a compound SPDX expression such as
34
+ # "MIT OR Apache-2.0". Unknown identifiers resolve to the 'other'
35
+ # (NOASSERTION) license. Returns nil when the property is absent or
36
+ # contains only a single token (plain license key).
37
+ def spdx_expression_licenses
38
+ return @spdx_expression_licenses if defined? @spdx_expression_licenses
39
+
40
+ @spdx_expression_licenses = resolve_spdx_expression_licenses
41
+ end
42
+
27
43
  def license_property
28
44
  raise NotImplementedError, "#{self.class}#license_property is not implemented"
29
45
  end
30
46
 
31
47
  private
32
48
 
49
+ def find_license_for_token(token, licenses)
50
+ key = token.downcase
51
+ licenses.find { |l| l.key == key } ||
52
+ match_by_spdx_base_key(key, licenses)
53
+ end
54
+
55
+ def spdx_expression_tokens(prop)
56
+ prop.scan(SPDX_TOKEN_REGEX).reject { |t| SPDX_OPERATORS.include?(t.upcase) }
57
+ end
58
+
59
+ def resolve_spdx_expression_licenses
60
+ prop = license_property
61
+ return nil if prop.nil? || prop.to_s.empty?
62
+
63
+ tokens = spdx_expression_tokens(prop)
64
+ return nil if tokens.size <= 1
65
+
66
+ licenses = Licensee.licenses(hidden: true)
67
+ other = License.find('other')
68
+ tokens.map { |t| find_license_for_token(t, licenses) || other }
69
+ end
70
+
33
71
  def match_by_spdx_base_key(prop, licenses)
34
72
  base = prop.sub(SPDX_SUFFIX_REGEX, '')
35
73
  return if base == prop
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensee
4
+ module Matchers
5
+ # Matches license identifiers in pyproject.toml.
6
+ # Based on Matchers::Cargo
7
+ class PyProject < Licensee::Matchers::Package
8
+ LICENSE_REGEX = /^\s*['"]?license['"]?\s*=\s*['"]([^'"]+)['"]\s*/ix
9
+
10
+ private
11
+
12
+ def license_property
13
+ match = @file.content.match LICENSE_REGEX
14
+ match[1].downcase if match && match[1]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -15,6 +15,7 @@ module Licensee
15
15
  autoload :NpmBower, 'licensee/matchers/npm_bower'
16
16
  autoload :NuGet, 'licensee/matchers/nuget'
17
17
  autoload :Package, 'licensee/matchers/package'
18
+ autoload :PyProject, 'licensee/matchers/pyproject'
18
19
  autoload :Reference, 'licensee/matchers/reference'
19
20
  autoload :Spdx, 'licensee/matchers/spdx'
20
21
  end
@@ -14,16 +14,18 @@ module Licensee
14
14
 
15
15
  # Hash of Filename => [possible matchers]
16
16
  FILENAMES_EXTENSIONS = {
17
- 'DESCRIPTION' => [Matchers::Cran],
18
- 'dist.ini' => [Matchers::DistZilla],
19
- 'LICENSE.spdx' => [Matchers::Spdx],
20
- 'Cargo.toml' => [Matchers::Cargo]
17
+ 'DESCRIPTION' => [Matchers::Cran],
18
+ 'dist.ini' => [Matchers::DistZilla],
19
+ 'LICENSE.spdx' => [Matchers::Spdx],
20
+ 'Cargo.toml' => [Matchers::Cargo],
21
+ 'pyproject.toml' => [Matchers::PyProject]
21
22
  }.freeze
22
23
 
23
24
  FILENAMES_SCORES = {
24
25
  'package.json' => 1.0,
25
26
  'LICENSE.spdx' => 1.0,
26
27
  'Cargo.toml' => 1.0,
28
+ 'pyproject.toml' => 1.0,
27
29
  'DESCRIPTION' => 0.9,
28
30
  'dist.ini' => 0.8,
29
31
  'bower.json' => 0.75,
@@ -8,12 +8,6 @@
8
8
  # :name - the file's path relative to the repo root
9
9
  # :oid - the file's OID
10
10
 
11
- begin
12
- require 'rugged'
13
- rescue LoadError
14
- # rugged is optional; GitProject will raise RuggedNotAvailable when not installed
15
- end
16
-
17
11
  module Licensee
18
12
  module Projects
19
13
  # Scans a Git repository (via Rugged) for license-related files.
@@ -24,7 +18,12 @@ module Licensee
24
18
  class RuggedNotAvailable < InvalidRepository; end
25
19
 
26
20
  def self.available?
27
- !!defined?(Rugged)
21
+ return true if defined?(Rugged)
22
+
23
+ require 'rugged'
24
+ true
25
+ rescue LoadError
26
+ false
28
27
  end
29
28
 
30
29
  def initialize(repo, revision: nil, **args)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Licensee
4
- VERSION = '10.0.0'
4
+ VERSION = '10.1.0'
5
5
  end
@@ -93,7 +93,7 @@
93
93
  },
94
94
  {
95
95
  "filename": "licensee.gemspec",
96
- "content": "# frozen_string_literal: true\n\nrequire File.expand_path('lib/licensee/version', __dir__)\n\nGem::Specification.new do |gem|\n gem.name = 'licensee'\n gem.version = Licensee::VERSION\n\n gem.summary = 'A Ruby Gem to detect open source project licenses'\n gem.description = <<-DESC\n Licensee automates the process of reading LICENSE files and\n compares their contents to known licenses using a fancy maths.\n DESC\n\n gem.authors = ['Ben Balter']\n gem.email = 'ben.balter@github.com'\n gem.homepage = 'https://github.com/licensee/licensee'\n gem.license = 'MIT'\n gem.metadata['rubygems_mfa_required'] = 'true'\n\n gem.bindir = 'bin'\n gem.executables << 'licensee'\n\n gem.add_dependency('dotenv', '>= 2', '< 4')\n gem.add_dependency('octokit', '>= 4.20', '< 11.0')\n gem.add_dependency('reverse_markdown', '>= 1', '< 4')\n gem.add_dependency('rugged', '>= 0.24', '<2.0')\n gem.add_dependency('thor', '>= 0.19', '< 2.0')\n\n gem.add_development_dependency('gem-release', '~> 2.0')\n gem.add_development_dependency('mustache', '>= 0.9', '< 2.0')\n gem.add_development_dependency('pry', '~> 0.9')\n gem.add_development_dependency('rspec', '~> 3.5')\n gem.add_development_dependency('rubocop', '~> 1.0')\n gem.add_development_dependency('rubocop-performance', '~> 1.5')\n gem.add_development_dependency('rubocop-rspec', '~> 3.0')\n gem.add_development_dependency('simplecov', '~> 0.16')\n gem.add_development_dependency('webmock', '~> 3.1')\n\n gem.required_ruby_version = '>= 3.2'\n\n # ensure the gem is built out of versioned files\n gem.files = Dir[\n '{bin,lib,man,test,vendor,spec}/**/*',\n 'README*', 'LICENSE*'\n ] & `git ls-files -z`.split(\"\\0\")\nend\n",
96
+ "content": "# frozen_string_literal: true\n\nrequire File.expand_path('lib/licensee/version', __dir__)\n\nGem::Specification.new do |gem|\n gem.name = 'licensee'\n gem.version = Licensee::VERSION\n\n gem.summary = 'A Ruby Gem to detect open source project licenses'\n gem.description = <<-DESC\n Licensee automates the process of reading LICENSE files and\n compares their contents to known licenses using a fancy maths.\n DESC\n\n gem.authors = ['Ben Balter']\n gem.email = 'ben.balter@github.com'\n gem.homepage = 'https://github.com/licensee/licensee'\n gem.license = 'MIT'\n gem.metadata['rubygems_mfa_required'] = 'true'\n gem.post_install_message = <<~MSG\n NOTE: The rugged gem is no longer a required dependency of licensee.\n If you scan bare Git repositories or Git repos without a working tree,\n add rugged to your Gemfile or run: gem install rugged\n MSG\n\n gem.bindir = 'bin'\n gem.executables << 'licensee'\n\n gem.add_dependency('dotenv', '>= 2', '< 4')\n gem.add_dependency('octokit', '>= 4.20', '< 11.0')\n gem.add_dependency('reverse_markdown', '>= 1', '< 4')\n gem.add_dependency('thor', '>= 0.19', '< 2.0')\n\n gem.add_development_dependency('gem-release', '~> 2.0')\n gem.add_development_dependency('mustache', '>= 0.9', '< 2.0')\n gem.add_development_dependency('pry', '~> 0.9')\n gem.add_development_dependency('rspec', '~> 3.5')\n gem.add_development_dependency('rubocop', '~> 1.0')\n gem.add_development_dependency('rubocop-performance', '~> 1.5')\n gem.add_development_dependency('rubocop-rspec', '~> 3.0')\n gem.add_development_dependency('rugged', '>= 0.24', '<2.0')\n gem.add_development_dependency('simplecov', '~> 1.0')\n gem.add_development_dependency('webmock', '~> 3.1')\n\n gem.required_ruby_version = '>= 3.2'\n\n # ensure the gem is built out of versioned files\n gem.files = Dir[\n '{bin,lib,man,test,vendor,spec}/**/*',\n 'README*', 'LICENSE*'\n ] & `git ls-files -z`.split(\"\\0\")\nend\n",
97
97
  "content_hash": null,
98
98
  "content_normalized": null,
99
99
  "matcher": {
@@ -368,6 +368,14 @@ RSpec.describe Integration do
368
368
  expect(project).to have_attributes(license: license, package_file: have_attributes(path: 'package.json'))
369
369
  end
370
370
 
371
+ it 'matches a pyproject.toml file' do
372
+ write_file.call('pyproject.toml', "[project]\nlicense = \"MIT\"")
373
+ project = project_type.new(project_path, detect_packages: true)
374
+
375
+ license = Licensee::License.find('mit')
376
+ expect(project).to have_attributes(license: license, package_file: have_attributes(path: 'pyproject.toml'))
377
+ end
378
+
371
379
  it 'matches a README file' do
372
380
  write_file.call('README', "## License\n#{Licensee::License.find('mit').content}")
373
381
  project = project_type.new(project_path, detect_readme: true)
@@ -176,4 +176,94 @@ RSpec.describe Licensee::Commands::Detect do
176
176
  expect(output[1]).to include('Unsupported remote URL')
177
177
  end
178
178
  end
179
+
180
+ context 'when using --recursive' do
181
+ let(:fixtures_path) { File.expand_path('../../fixtures', __dir__) }
182
+
183
+ context 'when scanning a directory with licensed subdirectories' do
184
+ let(:arguments) { ['--recursive', fixtures_path] }
185
+
186
+ it 'returns a zero exit code' do
187
+ expect(status.exitstatus).to be(0)
188
+ end
189
+
190
+ it 'outputs at least one result' do
191
+ expect(stdout).not_to be_empty
192
+ end
193
+
194
+ it 'outputs lines in path (file): LICENSE format' do
195
+ expect(stdout.lines).to all(match(/\S+ \(.+\): \S+/))
196
+ end
197
+ end
198
+
199
+ context 'when scanning an empty directory' do
200
+ let(:arguments) { ['--recursive', tmpdir] }
201
+
202
+ after { FileUtils.rm_rf(tmpdir) }
203
+
204
+ def tmpdir
205
+ @tmpdir ||= Dir.mktmpdir
206
+ end
207
+
208
+ it 'returns a non-zero exit code' do
209
+ expect(status.exitstatus).to be(1)
210
+ end
211
+
212
+ it 'says no licenses detected' do
213
+ expect(stdout).to include('No licenses detected')
214
+ end
215
+ end
216
+
217
+ context 'when PATH itself has a license' do
218
+ let(:arguments) { ['--recursive', File.join(fixtures_path, 'mit')] }
219
+
220
+ it 'includes the root directory in output' do
221
+ expect(stdout).to include('.')
222
+ end
223
+
224
+ it 'returns zero exit code' do
225
+ expect(status.exitstatus).to be(0)
226
+ end
227
+ end
228
+
229
+ context 'when using --json' do
230
+ let(:arguments) { ['--recursive', '--json', fixtures_path] }
231
+
232
+ it 'returns valid JSON array' do
233
+ parsed = JSON.parse(stdout)
234
+ expect(parsed).to be_an(Array)
235
+ end
236
+
237
+ it 'includes path keys in each result' do
238
+ parsed = JSON.parse(stdout)
239
+ expect(parsed.all? { |r| r.key?('path') }).to be true
240
+ end
241
+ end
242
+
243
+ context 'when using --depth' do
244
+ after { FileUtils.rm_rf(shallow_path) }
245
+
246
+ def shallow_path
247
+ @shallow_path ||= begin
248
+ dir = Dir.mktmpdir
249
+ subdir = File.join(dir, 'deep', 'nested', 'project')
250
+ FileUtils.mkdir_p(subdir)
251
+ FileUtils.cp(File.join(fixtures_path, 'mit', 'LICENSE.txt'), subdir)
252
+ dir
253
+ end
254
+ end
255
+
256
+ it 'with depth 1 does not find deeply nested licenses' do
257
+ _, _, s = Open3.capture3(*[command, '--recursive', '--depth', '1', shallow_path].flatten,
258
+ chdir: project_root)
259
+ expect(s.exitstatus).to be(1)
260
+ end
261
+
262
+ it 'with depth 3 finds the nested license' do
263
+ out, = Open3.capture3(*[command, '--recursive', '--depth', '3', shallow_path].flatten,
264
+ chdir: project_root)
265
+ expect(out).to include('MIT')
266
+ end
267
+ end
268
+ end
179
269
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensee
4
+ module Commands
5
+ module Diff
6
+ end
7
+ end
8
+ end
9
+
10
+ RSpec.describe Licensee::Commands::Diff do
11
+ let(:mit_fixture_path) { fixture_path('mit_markdown') }
12
+ let(:license_file) { File.join(mit_fixture_path, 'LICENSE.md') }
13
+
14
+ def run_diff(*extra_args)
15
+ Dir.chdir project_root do
16
+ Open3.capture3(
17
+ 'bundle', 'exec', 'bin/licensee', 'diff', license_file,
18
+ '--license=mit', *extra_args
19
+ )
20
+ end
21
+ end
22
+
23
+ def stdout(args = []) = run_diff(*args)[0]
24
+ def status(args = []) = run_diff(*args)[2]
25
+
26
+ context 'with default (word diff)' do
27
+ it 'returns a zero exit code for an exact match' do
28
+ _out, _err, s = run_diff
29
+ expect(s.exitstatus).to be(0)
30
+ end
31
+
32
+ it 'outputs similarity information' do
33
+ expect(stdout).to match(/Similarity/i)
34
+ end
35
+ end
36
+
37
+ context 'with --line-diff' do
38
+ it 'returns a zero exit code for an exact match' do
39
+ _out, _err, s = run_diff('--line-diff')
40
+ expect(s.exitstatus).to be(0)
41
+ end
42
+
43
+ it 'outputs similarity information' do
44
+ expect(stdout(['--line-diff'])).to match(/Similarity/i)
45
+ end
46
+ end
47
+ end
@@ -15,6 +15,7 @@ RSpec.describe Licensee::Commands::Version do
15
15
  end
16
16
 
17
17
  def stdout = output[0]
18
+ def stderr = output[1]
18
19
  def status = output[2]
19
20
 
20
21
  it 'returns the version' do
@@ -24,4 +25,8 @@ RSpec.describe Licensee::Commands::Version do
24
25
  it 'Returns a zero exit code' do
25
26
  expect(status.exitstatus).to be(0)
26
27
  end
28
+
29
+ it 'does not output warnings to stderr' do
30
+ expect(stderr).to eq('')
31
+ end
27
32
  end
@@ -68,6 +68,20 @@ RSpec.describe Licensee::LicenseMeta do
68
68
  end
69
69
  end
70
70
 
71
+ context 'when accessing the computed "source" field via #[]' do
72
+ it 'returns the SPDX URL when spdx_id is set' do
73
+ expect(meta['source']).to eql('https://spdx.org/licenses/MIT.html')
74
+ end
75
+
76
+ it 'is consistent with the #source method' do
77
+ expect(meta['source']).to eql(meta.source)
78
+ end
79
+
80
+ it 'is consistent with to_h' do
81
+ expect(meta['source']).to eql(meta.to_h[:source])
82
+ end
83
+ end
84
+
71
85
  describe '#from_yaml' do
72
86
  subject(:license_meta) { described_class.from_yaml(yaml) }
73
87
 
@@ -73,4 +73,83 @@ RSpec.describe Licensee::Matchers::Package do
73
73
  expect { base_matcher.send(:license_property) }.to raise_error(NotImplementedError, /Package#license_property/)
74
74
  end
75
75
  end
76
+
77
+ describe '#spdx_expression_licenses' do
78
+ context 'with a plain single license key' do
79
+ let(:license_property) { 'mit' }
80
+
81
+ it 'returns nil (not a compound expression)' do
82
+ expect(matcher.spdx_expression_licenses).to be_nil
83
+ end
84
+ end
85
+
86
+ context 'with a nil license property' do
87
+ let(:license_property) { nil }
88
+
89
+ it 'returns nil' do
90
+ expect(matcher.spdx_expression_licenses).to be_nil
91
+ end
92
+ end
93
+
94
+ context 'with an "MIT OR Apache-2.0" expression' do
95
+ let(:license_property) { 'MIT OR Apache-2.0' }
96
+
97
+ it 'does not include the OR operator as a license' do
98
+ expect(matcher.spdx_expression_licenses.size).to eq(2)
99
+ end
100
+
101
+ it 'includes MIT' do
102
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('mit'))
103
+ end
104
+
105
+ it 'includes Apache-2.0' do
106
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('apache-2.0'))
107
+ end
108
+ end
109
+
110
+ context 'with an "MIT AND Apache-2.0" expression' do
111
+ let(:license_property) { 'MIT AND Apache-2.0' }
112
+
113
+ it 'includes MIT' do
114
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('mit'))
115
+ end
116
+
117
+ it 'includes Apache-2.0' do
118
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('apache-2.0'))
119
+ end
120
+ end
121
+
122
+ context 'with a compound expression containing an unknown identifier' do
123
+ let(:license_property) { 'UnknownFoo OR MIT' }
124
+
125
+ it 'returns other for the unknown identifier' do
126
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('other'))
127
+ end
128
+
129
+ it 'returns MIT for the known identifier' do
130
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('mit'))
131
+ end
132
+ end
133
+
134
+ context 'with a compound expression where all identifiers are unknown' do
135
+ let(:license_property) { 'UnknownFoo OR AnotherUnknown' }
136
+
137
+ it 'returns other for all tokens' do
138
+ result = matcher.spdx_expression_licenses
139
+ expect(result).to all(eq(Licensee::License.find('other')))
140
+ end
141
+ end
142
+
143
+ context 'with -or-later suffix tokens in compound expression' do
144
+ let(:license_property) { 'LGPL-2.1-or-later OR GPL-3.0-or-later' }
145
+
146
+ it 'strips -or-later and returns LGPL-2.1' do
147
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('lgpl-2.1'))
148
+ end
149
+
150
+ it 'strips -or-later and returns GPL-3.0' do
151
+ expect(matcher.spdx_expression_licenses).to include(Licensee::License.find('gpl-3.0'))
152
+ end
153
+ end
154
+ end
76
155
  end
@@ -45,6 +45,14 @@ RSpec.describe Licensee do
45
45
  expect { project }.to raise_error(ArgumentError, /Unsupported remote URL/)
46
46
  end
47
47
  end
48
+
49
+ context 'when rugged is not available' do
50
+ before { allow(Licensee::Projects::GitProject).to receive(:available?).and_return(false) }
51
+
52
+ it 'falls back to FSProject' do
53
+ expect(project).to be_a(Licensee::Projects::FSProject)
54
+ end
55
+ end
48
56
  end
49
57
 
50
58
  context 'when using the confidence threshold' do
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable RSpec/DescribeClass
4
+ RSpec.describe 'script/vendor-licenses' do
5
+ let(:script_path) { File.expand_path('script/vendor-licenses', project_root) }
6
+ let(:vendor_data_dir) { File.expand_path('vendor/choosealicense.com/_data', project_root) }
7
+ let(:vendor_licenses_dir) { File.expand_path('vendor/choosealicense.com/_licenses', project_root) }
8
+
9
+ it 'passes a POSIX sh syntax check' do
10
+ _, stderr, status = Open3.capture3('sh', '-n', script_path)
11
+ expect(status.exitstatus).to be(0), "sh -n failed: #{stderr}"
12
+ end
13
+
14
+ it 'does not contain Bash-specific syntax' do
15
+ content = File.read(script_path)
16
+ expect(content).not_to include('[[')
17
+ end
18
+
19
+ it 'uses --no-wildcards-match-slash for GNU tar' do
20
+ content = File.read(script_path)
21
+ expect(content).to include('--no-wildcards-match-slash')
22
+ end
23
+
24
+ it 'does not vendor nested _data/i18n files' do
25
+ nested = Dir["#{vendor_data_dir}/i18n/**/*.yml"]
26
+ expect(nested).to be_empty, "Unexpected nested i18n files found: #{nested.join(', ')}"
27
+ end
28
+
29
+ it 'vendors top-level _data YAML files' do
30
+ top_level = Dir["#{vendor_data_dir}/*.yml"]
31
+ expect(top_level).not_to be_empty
32
+ end
33
+
34
+ it 'vendors _licenses files' do
35
+ expect(Dir["#{vendor_licenses_dir}/*"]).not_to be_empty
36
+ end
37
+ end
38
+ # rubocop:enable RSpec/DescribeClass
@@ -12,7 +12,7 @@ note: Creative Commons recommends taking the additional step of adding a boilerp
12
12
 
13
13
  using:
14
14
  Awesome: https://github.com/sindresorhus/awesome/blob/main/license
15
- Shields.io: https://github.com/badges/shields/blob/master/LICENSE
15
+ just: https://github.com/casey/just/blob/master/LICENSE
16
16
  psdash: https://github.com/Jahaja/psdash/blob/master/LICENSE
17
17
 
18
18
  permissions:
@@ -10,8 +10,8 @@ how: Create a text file (typically named COPYING, as per GNU conventions) in the
10
10
  note: The Free Software Foundation recommends taking the additional step of adding a boilerplate notice after the title of a licensed document. The boilerplate can be found at the "addendum" part of the license.
11
11
 
12
12
  using:
13
- GNU LilyPond (Documentation): https://git.savannah.gnu.org/cgit/lilypond.git/tree/COPYING.FDL
14
- GNU Scientific Library (Documentation): https://git.savannah.gnu.org/cgit/gsl.git/tree/doc/_static/fdl.txt
13
+ Mastodon (Documentation): https://github.com/mastodon/documentation/blob/main/LICENSE
14
+ Regular Expression Museum: https://github.com/thaliaarchi/regexp-museum/blob/main/LICENSE
15
15
  Qt (Documentation): https://code.qt.io/cgit/qt/qt.git/tree/LICENSE.FDL
16
16
 
17
17
  permissions:
@@ -14,7 +14,7 @@ note: The Free Software Foundation recommends taking the additional step of addi
14
14
 
15
15
  using:
16
16
  Ansible: https://github.com/ansible/ansible/blob/devel/COPYING
17
- Bash: https://git.savannah.gnu.org/cgit/bash.git/tree/COPYING
17
+ Niri: https://github.com/niri-wm/niri/blob/main/LICENSE
18
18
  uBlock Origin: https://github.com/gorhill/uBlock/blob/master/LICENSE.txt
19
19
 
20
20
  permissions:
@@ -9,6 +9,7 @@
9
9
  <crossRef>https://joinup.ec.europa.eu/sites/default/files/inline-files/EUPL%20v1_2%20EN(1).txt</crossRef>
10
10
  <crossRef>http://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32017D0863</crossRef>
11
11
  <crossRef>https://opensource.org/license/EUPL-1.2</crossRef>
12
+ <crossRef>https://interoperable-europe.ec.europa.eu/sites/default/files/inline-files/EUPL%20v1_2%20EN(1).txt</crossRef>
12
13
  </crossRefs>
13
14
  <notes>
14
15
  This license was released: 19 May 2016. This license is available in the
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: licensee
3
3
  version: !ruby/object:Gem::Version
4
- version: 10.0.0
4
+ version: 10.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Balter
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-05 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dotenv
@@ -219,14 +219,14 @@ dependencies:
219
219
  requirements:
220
220
  - - "~>"
221
221
  - !ruby/object:Gem::Version
222
- version: '0.16'
222
+ version: '1.0'
223
223
  type: :development
224
224
  prerelease: false
225
225
  version_requirements: !ruby/object:Gem::Requirement
226
226
  requirements:
227
227
  - - "~>"
228
228
  - !ruby/object:Gem::Version
229
- version: '0.16'
229
+ version: '1.0'
230
230
  - !ruby/object:Gem::Dependency
231
231
  name: webmock
232
232
  requirement: !ruby/object:Gem::Requirement
@@ -283,6 +283,7 @@ files:
283
283
  - lib/licensee/matchers/npm_bower.rb
284
284
  - lib/licensee/matchers/nuget.rb
285
285
  - lib/licensee/matchers/package.rb
286
+ - lib/licensee/matchers/pyproject.rb
286
287
  - lib/licensee/matchers/reference.rb
287
288
  - lib/licensee/matchers/spdx.rb
288
289
  - lib/licensee/project_files.rb
@@ -383,6 +384,7 @@ files:
383
384
  - spec/fixtures/wrk-modified-apache/LICENSE
384
385
  - spec/integration_spec.rb
385
386
  - spec/licensee/commands/detect_spec.rb
387
+ - spec/licensee/commands/diff_spec.rb
386
388
  - spec/licensee/commands/license_path_spec.rb
387
389
  - spec/licensee/commands/version_spec.rb
388
390
  - spec/licensee/content_helper_spec.rb
@@ -416,6 +418,7 @@ files:
416
418
  - spec/licensee/projects/project_spec.rb
417
419
  - spec/licensee/rule_spec.rb
418
420
  - spec/licensee_spec.rb
421
+ - spec/script_vendor_licenses_spec.rb
419
422
  - spec/spec_helper.rb
420
423
  - spec/vendored_license_spec.rb
421
424
  - vendor/choosealicense.com/_data/fields.yml
@@ -538,7 +541,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
538
541
  - !ruby/object:Gem::Version
539
542
  version: '0'
540
543
  requirements: []
541
- rubygems_version: 3.6.2
544
+ rubygems_version: 3.6.9
542
545
  specification_version: 4
543
546
  summary: A Ruby Gem to detect open source project licenses
544
547
  test_files: []