licensure 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6759a51d14e8f88b4d718fb1368cf9e2593660e43863949d73bb929062be7c12
4
+ data.tar.gz: 396ad8b96f81968fffab0dce383b368632ae6c12f71c8055589c3b361b6a0fb8
5
+ SHA512:
6
+ metadata.gz: 69a98b94fe4fe29946a48d8281ce993632db4be662e6cefb70cb5826d38807546a2dc8d4d1bf8598d63265ef4613b19b1797490b83efd84754d7a1a2a59f9a49
7
+ data.tar.gz: df348e79ddac7e9f8bb783a6743a0706b1fac62de1100268fceaa51b895c80813ebd730d0edb734be0905a95d4b88fdc0b666670baefbb6225664bccd0f831ee
@@ -0,0 +1,14 @@
1
+ # .licensure.yml
2
+ allowed_licenses:
3
+ - MIT
4
+ - Apache-2.0
5
+ - BSD-2-Clause
6
+ - BSD-3-Clause
7
+ - ISC
8
+ - Ruby
9
+
10
+ ignored_gems:
11
+ - bundler
12
+ - rake
13
+
14
+ deny_unknown: true
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # Licensure
2
+
3
+ Licensure is a RubyGem CLI tool that inspects dependency licenses from `Gemfile.lock` and checks them against a configurable allow list.
4
+
5
+ ## Installation
6
+
7
+ Install as a gem:
8
+
9
+ ```bash
10
+ gem install licensure
11
+ ```
12
+
13
+ Or add it to your `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "licensure"
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ Initialize config:
22
+
23
+ ```bash
24
+ licensure init
25
+ ```
26
+
27
+ List dependency licenses:
28
+
29
+ ```bash
30
+ licensure list
31
+ ```
32
+
33
+ Check licenses against `.licensure.yml`:
34
+
35
+ ```bash
36
+ licensure check
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ Licensure uses `.licensure.yml`:
42
+
43
+ ```yaml
44
+ allowed_licenses:
45
+ - MIT
46
+ - Apache-2.0
47
+ - BSD-2-Clause
48
+ - BSD-3-Clause
49
+ - ISC
50
+ - Ruby
51
+
52
+ ignored_gems:
53
+ - bundler
54
+ - rake
55
+
56
+ deny_unknown: true
57
+ ```
58
+
59
+ - `allowed_licenses`: Allowed license identifiers. Empty means allow all.
60
+ - `ignored_gems`: Gem names excluded from checks.
61
+ - `deny_unknown`: Treat gems without license metadata as warnings.
62
+
63
+ ## Commands
64
+
65
+ ```bash
66
+ licensure list [--format table|csv|json|markdown] [--recursive] [--output FILE] [--gemfile-lock PATH]
67
+ licensure check [--config FILE] [--recursive] [--format table|csv|json|markdown] [--gemfile-lock PATH]
68
+ licensure init
69
+ licensure version
70
+ licensure help [command]
71
+ ```
72
+
73
+ ## Output Formats
74
+
75
+ `list` and `check` support:
76
+
77
+ - `table`
78
+ - `csv`
79
+ - `json`
80
+ - `markdown`
81
+
82
+ Example:
83
+
84
+ ```bash
85
+ licensure list --format json
86
+ licensure check --format markdown
87
+ ```
88
+
89
+ ## CI Example (GitHub Actions)
90
+
91
+ ```yaml
92
+ name: License Check
93
+ on: [push, pull_request]
94
+ jobs:
95
+ check:
96
+ runs-on: ubuntu-latest
97
+ steps:
98
+ - uses: actions/checkout@v4
99
+ - uses: ruby/setup-ruby@v1
100
+ with:
101
+ ruby-version: "3.3"
102
+ bundler-cache: true
103
+ - run: gem install licensure
104
+ - run: licensure check
105
+ ```
106
+
107
+ ## Development
108
+
109
+ ```bash
110
+ bundle install
111
+ bundle exec rake spec
112
+ ```
113
+
114
+ ## License
115
+
116
+ Released under the MIT License. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/licensure ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "licensure"
7
+ require "licensure/cli"
8
+
9
+ exit(Licensure::CLI.new(ARGV).run)
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ module Licensure
6
+ # CLI entrypoint and subcommand orchestrator.
7
+ class CLI
8
+ FORMATS = %w[table csv json markdown].freeze
9
+
10
+ # @param argv [Array<String>]
11
+ # @param output [IO]
12
+ # @param error_output [IO]
13
+ # @param input [IO]
14
+ def initialize(argv = ARGV, output: $stdout, error_output: $stderr, input: $stdin)
15
+ @argv = argv.dup
16
+ @output = output
17
+ @error_output = error_output
18
+ @input = input
19
+ end
20
+
21
+ # @return [Integer]
22
+ def run
23
+ command = @argv.shift
24
+
25
+ case command
26
+ when nil, "help", "-h", "--help"
27
+ run_help(@argv.shift)
28
+ when "list"
29
+ run_list
30
+ when "check"
31
+ run_check
32
+ when "init"
33
+ run_init
34
+ when "version"
35
+ run_version
36
+ else
37
+ @error_output.puts("Unknown command: #{command}")
38
+ 2
39
+ end
40
+ rescue OptionParser::ParseError, Licensure::Error => e
41
+ @error_output.puts(e.message)
42
+ 2
43
+ rescue StandardError => e
44
+ @error_output.puts("Unexpected error: #{e.message}")
45
+ @error_output.puts(e.backtrace.join("\n"))
46
+ 2
47
+ end
48
+
49
+ private
50
+
51
+ # @return [Integer]
52
+ def run_list
53
+ options = {
54
+ format: "table",
55
+ recursive: false,
56
+ output: nil,
57
+ lockfile_path: "Gemfile.lock",
58
+ help: false
59
+ }
60
+
61
+ parser = OptionParser.new do |opts|
62
+ opts.banner = "Usage: licensure list [options]"
63
+ opts.on("-f", "--format FORMAT", FORMATS, "Output format: #{FORMATS.join(', ')}") { |value| options[:format] = value }
64
+ opts.on("-r", "--recursive", "Include transitive dependencies") { options[:recursive] = true }
65
+ opts.on("-o", "--output FILE", "Write output to file") { |value| options[:output] = value }
66
+ opts.on("--gemfile-lock PATH", "Path to Gemfile.lock") { |value| options[:lockfile_path] = value }
67
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
68
+ end
69
+
70
+ parser.parse!(@argv)
71
+ return print_help(parser) if options[:help]
72
+
73
+ assert_no_extra_arguments!
74
+
75
+ dependencies = DependencyResolver.new(
76
+ lockfile_path: options[:lockfile_path],
77
+ recursive: options[:recursive]
78
+ ).resolve
79
+
80
+ infos = LicenseFetcher.new.fetch_all(dependencies)
81
+ render_to(options[:format], infos: infos, output_path: options[:output])
82
+ 0
83
+ end
84
+
85
+ # @return [Integer]
86
+ def run_check
87
+ options = {
88
+ config_path: ".licensure.yml",
89
+ format: "table",
90
+ recursive: false,
91
+ lockfile_path: "Gemfile.lock",
92
+ help: false
93
+ }
94
+
95
+ parser = OptionParser.new do |opts|
96
+ opts.banner = "Usage: licensure check [options]"
97
+ opts.on("-c", "--config FILE", "Path to .licensure.yml") { |value| options[:config_path] = value }
98
+ opts.on("-r", "--recursive", "Include transitive dependencies") { options[:recursive] = true }
99
+ opts.on("-f", "--format FORMAT", FORMATS, "Output format: #{FORMATS.join(', ')}") { |value| options[:format] = value }
100
+ opts.on("--gemfile-lock PATH", "Path to Gemfile.lock") { |value| options[:lockfile_path] = value }
101
+ opts.on("-h", "--help", "Show help") { options[:help] = true }
102
+ end
103
+
104
+ parser.parse!(@argv)
105
+ return print_help(parser) if options[:help]
106
+
107
+ assert_no_extra_arguments!
108
+
109
+ configuration = Configuration.load(options[:config_path])
110
+ dependencies = DependencyResolver.new(
111
+ lockfile_path: options[:lockfile_path],
112
+ recursive: options[:recursive]
113
+ ).resolve
114
+ infos = LicenseFetcher.new.fetch_all(dependencies)
115
+ result = LicenseChecker.new(configuration: configuration).check(infos)
116
+
117
+ formatter_for(options[:format], output: @output).render_check_result(result)
118
+ result.violations.empty? ? 0 : 1
119
+ end
120
+
121
+ # @return [Integer]
122
+ def run_init
123
+ parser = OptionParser.new do |opts|
124
+ opts.banner = "Usage: licensure init"
125
+ opts.on("-h", "--help", "Show help") { @output.puts(opts); return 0 }
126
+ end
127
+
128
+ parser.parse!(@argv)
129
+ assert_no_extra_arguments!
130
+
131
+ config_path = ".licensure.yml"
132
+ if File.exist?(config_path)
133
+ @output.print("#{config_path} already exists. Overwrite? [y/N]: ")
134
+ answer = @input.gets&.strip&.downcase
135
+ unless %w[y yes].include?(answer)
136
+ @output.puts("Aborted")
137
+ return 0
138
+ end
139
+ end
140
+
141
+ File.write(config_path, Configuration::SAMPLE_CONFIG)
142
+ @output.puts("Created #{config_path}")
143
+ 0
144
+ end
145
+
146
+ # @return [Integer]
147
+ def run_version
148
+ @output.puts("Licensure #{Licensure::VERSION}")
149
+ 0
150
+ end
151
+
152
+ # @param subcommand [String, nil]
153
+ # @return [Integer]
154
+ def run_help(subcommand)
155
+ return print_main_help if subcommand.nil?
156
+
157
+ case subcommand
158
+ when "list"
159
+ print_help(build_list_help_parser)
160
+ when "check"
161
+ print_help(build_check_help_parser)
162
+ when "init"
163
+ @output.puts("Usage: licensure init")
164
+ 0
165
+ when "version"
166
+ @output.puts("Usage: licensure version")
167
+ 0
168
+ else
169
+ @error_output.puts("Unknown help topic: #{subcommand}")
170
+ 2
171
+ end
172
+ end
173
+
174
+ # @return [Integer]
175
+ def print_main_help
176
+ @output.puts <<~TEXT
177
+ Usage: licensure <command> [options]
178
+
179
+ Commands:
180
+ list Show dependency license information
181
+ check Validate licenses against .licensure.yml
182
+ init Create .licensure.yml
183
+ version Show current version
184
+ help Show help
185
+ TEXT
186
+ 0
187
+ end
188
+
189
+ # @param parser [OptionParser]
190
+ # @return [Integer]
191
+ def print_help(parser)
192
+ @output.puts(parser)
193
+ 0
194
+ end
195
+
196
+ # @return [OptionParser]
197
+ def build_list_help_parser
198
+ OptionParser.new do |opts|
199
+ opts.banner = "Usage: licensure list [options]"
200
+ opts.on("-f", "--format FORMAT", FORMATS)
201
+ opts.on("-r", "--recursive")
202
+ opts.on("-o", "--output FILE")
203
+ opts.on("--gemfile-lock PATH")
204
+ end
205
+ end
206
+
207
+ # @return [OptionParser]
208
+ def build_check_help_parser
209
+ OptionParser.new do |opts|
210
+ opts.banner = "Usage: licensure check [options]"
211
+ opts.on("-c", "--config FILE")
212
+ opts.on("-r", "--recursive")
213
+ opts.on("-f", "--format FORMAT", FORMATS)
214
+ opts.on("--gemfile-lock PATH")
215
+ end
216
+ end
217
+
218
+ # @param format [String]
219
+ # @param output [IO]
220
+ # @return [Licensure::Formatters::Base]
221
+ def formatter_for(format, output:)
222
+ case format
223
+ when "table"
224
+ Formatters::Table.new(output: output)
225
+ when "csv"
226
+ Formatters::Csv.new(output: output)
227
+ when "json"
228
+ Formatters::Json.new(output: output)
229
+ when "markdown"
230
+ Formatters::Markdown.new(output: output)
231
+ else
232
+ raise CLIError, "Unsupported format: #{format}"
233
+ end
234
+ end
235
+
236
+ # @param format [String]
237
+ # @param infos [Array<Licensure::GemLicenseInfo>]
238
+ # @param output_path [String, nil]
239
+ # @return [void]
240
+ def render_to(format, infos:, output_path:)
241
+ if output_path
242
+ File.open(output_path, "w") do |file|
243
+ formatter_for(format, output: file).render(infos)
244
+ end
245
+ return
246
+ end
247
+
248
+ formatter_for(format, output: @output).render(infos)
249
+ end
250
+
251
+ # @return [void]
252
+ def assert_no_extra_arguments!
253
+ return if @argv.empty?
254
+
255
+ raise CLIError, "Unknown arguments: #{@argv.join(' ')}"
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Licensure
6
+ # Loads and validates .licensure.yml configuration.
7
+ class Configuration
8
+ VALID_KEYS = %w[allowed_licenses ignored_gems deny_unknown].freeze
9
+
10
+ SAMPLE_CONFIG = <<~YAML.freeze
11
+ # .licensure.yml
12
+ allowed_licenses:
13
+ - MIT
14
+ - Apache-2.0
15
+ - BSD-2-Clause
16
+ - BSD-3-Clause
17
+ - ISC
18
+ - Ruby
19
+
20
+ ignored_gems:
21
+ - bundler
22
+ - rake
23
+
24
+ # Treat gems with unspecified licenses as warnings
25
+ deny_unknown: true
26
+ YAML
27
+
28
+ # @param path [String]
29
+ # @return [Licensure::Configuration]
30
+ def self.load(path = ".licensure.yml")
31
+ raise ConfigurationError, "Configuration file not found: #{path}" unless File.exist?(path)
32
+
33
+ payload = YAML.safe_load(File.read(path), aliases: false) || {}
34
+ unless payload.is_a?(Hash)
35
+ raise ConfigurationError, "Configuration must be a mapping"
36
+ end
37
+
38
+ new(payload.transform_keys(&:to_s))
39
+ rescue Psych::SyntaxError => e
40
+ raise ConfigurationError, "Failed to parse configuration: #{e.message}"
41
+ end
42
+
43
+ # @return [Licensure::Configuration]
44
+ def self.default
45
+ new({})
46
+ end
47
+
48
+ attr_reader :allowed_licenses, :ignored_gems
49
+
50
+ # @param data [Hash]
51
+ def initialize(data)
52
+ warn_unknown_keys(data)
53
+
54
+ @allowed_licenses = validate_array!(data.fetch("allowed_licenses", []), "allowed_licenses")
55
+ @ignored_gems = validate_array!(data.fetch("ignored_gems", []), "ignored_gems")
56
+ @deny_unknown = validate_boolean!(data.fetch("deny_unknown", true), "deny_unknown")
57
+ end
58
+
59
+ # @return [Boolean]
60
+ def deny_unknown?
61
+ @deny_unknown
62
+ end
63
+
64
+ private
65
+
66
+ # @param data [Hash]
67
+ # @return [void]
68
+ def warn_unknown_keys(data)
69
+ (data.keys - VALID_KEYS).each do |key|
70
+ $stderr.puts("Warning: Unknown configuration key '#{key}'")
71
+ end
72
+ end
73
+
74
+ # @param value [Object]
75
+ # @param key [String]
76
+ # @return [Array<String>]
77
+ def validate_array!(value, key)
78
+ unless value.is_a?(Array)
79
+ raise ConfigurationError, "#{key} must be an array"
80
+ end
81
+
82
+ value.map(&:to_s)
83
+ end
84
+
85
+ # @param value [Object]
86
+ # @param key [String]
87
+ # @return [Boolean]
88
+ def validate_boolean!(value, key)
89
+ unless value == true || value == false
90
+ raise ConfigurationError, "#{key} must be a boolean"
91
+ end
92
+
93
+ value
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module Licensure
6
+ # Parses Gemfile.lock and resolves dependency names/versions.
7
+ class DependencyResolver
8
+ # @param lockfile_path [String]
9
+ # @param recursive [Boolean]
10
+ def initialize(lockfile_path: "Gemfile.lock", recursive: false)
11
+ @lockfile_path = lockfile_path
12
+ @recursive = recursive
13
+ end
14
+
15
+ # @return [Array<Hash{Symbol => String}>]
16
+ def resolve
17
+ raise DependencyResolutionError, "Gemfile.lock not found: #{@lockfile_path}" unless File.exist?(@lockfile_path)
18
+
19
+ parser = Bundler::LockfileParser.new(File.read(@lockfile_path))
20
+ specs_by_name = parser.specs.to_h { |spec| [spec.name, spec] }
21
+
22
+ names = if @recursive
23
+ parser.specs.map(&:name)
24
+ else
25
+ parser.dependencies.keys
26
+ end
27
+
28
+ names.uniq
29
+ .reject { |name| name == "bundler" }
30
+ .filter_map do |name|
31
+ spec = specs_by_name[name]
32
+ next unless spec
33
+
34
+ { name: name, version: spec.version.to_s }
35
+ end
36
+ .sort_by { |dependency| dependency[:name] }
37
+ rescue Bundler::LockfileError => e
38
+ raise DependencyResolutionError, "Failed to parse #{@lockfile_path}: #{e.message}"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ # Base error class for Licensure.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when configuration loading or validation fails.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when Gemfile.lock parsing fails.
11
+ class DependencyResolutionError < Error; end
12
+
13
+ # Raised for CLI argument and execution failures.
14
+ class CLIError < Error; end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ module Formatters
5
+ # Base formatter abstraction.
6
+ class Base
7
+ # @param output [IO]
8
+ def initialize(output: $stdout)
9
+ @output = output
10
+ end
11
+
12
+ # @param _gem_license_infos [Array<Licensure::GemLicenseInfo>]
13
+ # @return [String]
14
+ def render(_gem_license_infos)
15
+ raise NotImplementedError, "Implement #render in formatter subclasses"
16
+ end
17
+
18
+ # @param _check_result [Licensure::CheckResult]
19
+ # @return [String]
20
+ def render_check_result(_check_result)
21
+ raise NotImplementedError, "Implement #render_check_result in formatter subclasses"
22
+ end
23
+
24
+ protected
25
+
26
+ # @param content [String]
27
+ # @return [String]
28
+ def write_output(content)
29
+ @output.write(content)
30
+ @output.write("\n") unless content.end_with?("\n")
31
+ content
32
+ end
33
+
34
+ # @param info [Licensure::GemLicenseInfo]
35
+ # @return [Array<String>]
36
+ def gem_row(info)
37
+ [
38
+ info.name.to_s,
39
+ info.version.to_s,
40
+ info.licenses.join(" | "),
41
+ info.source.to_s,
42
+ info.homepage.to_s
43
+ ]
44
+ end
45
+
46
+ # @param violation [Licensure::Violation]
47
+ # @return [Array<String>]
48
+ def violation_row(violation)
49
+ row = gem_row(violation.gem_info).take(4)
50
+ row << violation.reason.to_s
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Licensure
6
+ module Formatters
7
+ # Renders CSV output for list/check results.
8
+ class Csv < Base
9
+ # @param gem_license_infos [Array<Licensure::GemLicenseInfo>]
10
+ # @return [String]
11
+ def render(gem_license_infos)
12
+ content = ::CSV.generate do |csv|
13
+ csv << %w[Gem Version License Source Homepage]
14
+ gem_license_infos.each do |info|
15
+ csv << gem_row(info)
16
+ end
17
+ end
18
+
19
+ write_output(content)
20
+ end
21
+
22
+ # @param check_result [Licensure::CheckResult]
23
+ # @return [String]
24
+ def render_check_result(check_result)
25
+ content = ::CSV.generate do |csv|
26
+ csv << %w[Gem Version License Source Status Reason]
27
+
28
+ check_result.passed.each do |info|
29
+ csv << gem_row(info).take(4) + ["PASSED", ""]
30
+ end
31
+
32
+ check_result.violations.each do |violation|
33
+ csv << violation_row(violation).take(4) + ["VIOLATION", violation.reason]
34
+ end
35
+
36
+ check_result.warnings.each do |warning|
37
+ csv << violation_row(warning).take(4) + ["WARNING", warning.reason]
38
+ end
39
+ end
40
+
41
+ write_output(content)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Licensure
7
+ module Formatters
8
+ # Renders JSON output for list/check results.
9
+ class Json < Base
10
+ # @param gem_license_infos [Array<Licensure::GemLicenseInfo>]
11
+ # @return [String]
12
+ def render(gem_license_infos)
13
+ payload = {
14
+ generated_at: Time.now.iso8601,
15
+ gems: gem_license_infos.map { |info| gem_payload(info) }
16
+ }
17
+
18
+ write_output(JSON.pretty_generate(payload))
19
+ end
20
+
21
+ # @param check_result [Licensure::CheckResult]
22
+ # @return [String]
23
+ def render_check_result(check_result)
24
+ payload = {
25
+ generated_at: Time.now.iso8601,
26
+ summary: {
27
+ total: check_result.passed.size + check_result.violations.size + check_result.warnings.size,
28
+ passed: check_result.passed.size,
29
+ violations: check_result.violations.size,
30
+ warnings: check_result.warnings.size
31
+ },
32
+ violations: check_result.violations.map { |violation| violation_payload(violation) },
33
+ warnings: check_result.warnings.map { |warning| violation_payload(warning) },
34
+ passed: check_result.passed.map { |info| gem_payload(info) }
35
+ }
36
+
37
+ write_output(JSON.pretty_generate(payload))
38
+ end
39
+
40
+ private
41
+
42
+ # @param info [Licensure::GemLicenseInfo]
43
+ # @return [Hash]
44
+ def gem_payload(info)
45
+ {
46
+ name: info.name,
47
+ version: info.version,
48
+ licenses: info.licenses,
49
+ source: info.source.to_s,
50
+ homepage: info.homepage
51
+ }
52
+ end
53
+
54
+ # @param violation [Licensure::Violation]
55
+ # @return [Hash]
56
+ def violation_payload(violation)
57
+ gem_payload(violation.gem_info).merge(reason: violation.reason)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ module Formatters
5
+ # Renders Markdown tables for list/check results.
6
+ class Markdown < Base
7
+ LIST_HEADERS = ["Gem", "Version", "License", "Source", "Homepage"].freeze
8
+ CHECK_HEADERS = ["Gem", "Version", "License", "Source", "Reason"].freeze
9
+
10
+ # @param gem_license_infos [Array<Licensure::GemLicenseInfo>]
11
+ # @return [String]
12
+ def render(gem_license_infos)
13
+ rows = gem_license_infos.map { |info| gem_row(info) }
14
+ write_output(markdown_table(LIST_HEADERS, rows))
15
+ end
16
+
17
+ # @param check_result [Licensure::CheckResult]
18
+ # @return [String]
19
+ def render_check_result(check_result)
20
+ sections = [
21
+ markdown_section("PASSED", LIST_HEADERS, check_result.passed.map { |info| gem_row(info) }),
22
+ markdown_section("VIOLATIONS", CHECK_HEADERS, check_result.violations.map { |item| violation_row(item) }),
23
+ markdown_section("WARNINGS", CHECK_HEADERS, check_result.warnings.map { |item| violation_row(item) })
24
+ ]
25
+
26
+ write_output(sections.join("\n\n"))
27
+ end
28
+
29
+ private
30
+
31
+ # @param title [String]
32
+ # @param headers [Array<String>]
33
+ # @param rows [Array<Array<String>>]
34
+ # @return [String]
35
+ def markdown_section(title, headers, rows)
36
+ "## #{title}\n\n#{markdown_table(headers, rows)}"
37
+ end
38
+
39
+ # @param headers [Array<String>]
40
+ # @param rows [Array<Array<String>>]
41
+ # @return [String]
42
+ def markdown_table(headers, rows)
43
+ header_line = "| #{headers.join(' | ')} |"
44
+ separator = "| #{Array.new(headers.size, '---').join(' | ')} |"
45
+ row_lines = rows.map { |row| "| #{row.map { |value| escape_markdown(value.to_s) }.join(' | ')} |" }
46
+
47
+ [header_line, separator, *row_lines].join("\n")
48
+ end
49
+
50
+ # @param value [String]
51
+ # @return [String]
52
+ def escape_markdown(value)
53
+ value.gsub("|", "\\|")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ module Formatters
5
+ # Renders terminal-friendly ASCII tables.
6
+ class Table < Base
7
+ LIST_HEADERS = ["Gem", "Version", "License", "Source", "Homepage"].freeze
8
+ CHECK_HEADERS = ["Gem", "Version", "License", "Source", "Reason"].freeze
9
+
10
+ # @param gem_license_infos [Array<Licensure::GemLicenseInfo>]
11
+ # @return [String]
12
+ def render(gem_license_infos)
13
+ rows = gem_license_infos.map { |info| gem_row(info) }
14
+ write_output(build_table(LIST_HEADERS, rows))
15
+ end
16
+
17
+ # @param check_result [Licensure::CheckResult]
18
+ # @return [String]
19
+ def render_check_result(check_result)
20
+ sections = []
21
+ sections << section("PASSED", build_table(LIST_HEADERS, check_result.passed.map { |info| gem_row(info) }))
22
+ sections << section("VIOLATIONS", build_table(CHECK_HEADERS, check_result.violations.map { |item| violation_row(item) }))
23
+ sections << section("WARNINGS", build_table(CHECK_HEADERS, check_result.warnings.map { |item| violation_row(item) }))
24
+
25
+ write_output(sections.join("\n\n"))
26
+ end
27
+
28
+ private
29
+
30
+ # @param title [String]
31
+ # @param table [String]
32
+ # @return [String]
33
+ def section(title, table)
34
+ "#{title}\n#{table}"
35
+ end
36
+
37
+ # @param headers [Array<String>]
38
+ # @param rows [Array<Array<String>>]
39
+ # @return [String]
40
+ def build_table(headers, rows)
41
+ widths = headers.each_with_index.map do |header, index|
42
+ [header.length, *rows.map { |row| row[index].to_s.length }].max
43
+ end
44
+
45
+ border = "+-#{widths.map { |width| "-" * width }.join("-+-")}-+"
46
+ header_line = "| #{headers.each_with_index.map { |header, index| header.ljust(widths[index]) }.join(" | ")} |"
47
+
48
+ row_lines = rows.map do |row|
49
+ "| #{row.each_with_index.map { |value, index| value.to_s.ljust(widths[index]) }.join(" | ")} |"
50
+ end
51
+
52
+ [border, header_line, border, *row_lines, border].join("\n")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ # Checks dependency licenses against configuration rules.
5
+ class LicenseChecker
6
+ # @param configuration [Licensure::Configuration]
7
+ def initialize(configuration:)
8
+ @configuration = configuration
9
+ end
10
+
11
+ # @param gem_license_infos [Array<Licensure::GemLicenseInfo>]
12
+ # @return [Licensure::CheckResult]
13
+ def check(gem_license_infos)
14
+ filtered_infos = gem_license_infos.reject do |info|
15
+ @configuration.ignored_gems.include?(info.name)
16
+ end
17
+
18
+ violations = []
19
+ warnings = []
20
+ passed = []
21
+
22
+ filtered_infos.each do |info|
23
+ if info.licenses.empty?
24
+ if @configuration.deny_unknown?
25
+ warnings << Violation.new(gem_info: info, reason: "License not specified")
26
+ else
27
+ passed << info
28
+ end
29
+ next
30
+ end
31
+
32
+ if @configuration.allowed_licenses.empty?
33
+ passed << info
34
+ next
35
+ end
36
+
37
+ if allowed_license?(info.licenses)
38
+ passed << info
39
+ next
40
+ end
41
+
42
+ reason = "License '#{info.licenses.join(", ")}' is not in the allowed list"
43
+ violations << Violation.new(gem_info: info, reason: reason)
44
+ end
45
+
46
+ CheckResult.new(violations: violations, warnings: warnings, passed: passed)
47
+ end
48
+
49
+ private
50
+
51
+ # @param licenses [Array<String>]
52
+ # @return [Boolean]
53
+ def allowed_license?(licenses)
54
+ licenses.any? { |license| @configuration.allowed_licenses.include?(license) }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ module Licensure
7
+ # Fetches gem license data from local gemspecs and RubyGems API.
8
+ class LicenseFetcher
9
+ RUBYGEMS_ENDPOINT = "https://rubygems.org/api/v1/gems".freeze
10
+ REQUEST_TIMEOUT = 5
11
+
12
+ # @param dependencies [Array<Hash{Symbol => String}>]
13
+ # @return [Array<Licensure::GemLicenseInfo>]
14
+ def fetch_all(dependencies)
15
+ dependencies.map { |dependency| fetch(dependency[:name], dependency[:version]) }
16
+ end
17
+
18
+ # @param name [String]
19
+ # @param version [String]
20
+ # @return [Licensure::GemLicenseInfo]
21
+ def fetch(name, version)
22
+ local = fetch_from_gemspec(name)
23
+ return build_info(name, version, local[:licenses], :gemspec, local[:homepage]) if local
24
+
25
+ remote = fetch_from_api(name)
26
+ return build_info(name, version, remote[:licenses], :api, remote[:homepage]) if remote
27
+
28
+ build_info(name, version, [], :unknown, nil)
29
+ end
30
+
31
+ private
32
+
33
+ # @param name [String]
34
+ # @return [Hash, nil]
35
+ def fetch_from_gemspec(name)
36
+ spec = Gem::Specification.find_by_name(name)
37
+ licenses = normalize_licenses(spec.licenses, spec.license)
38
+ return nil if licenses.empty?
39
+
40
+ { licenses: licenses, homepage: spec.homepage }
41
+ rescue Gem::LoadError
42
+ nil
43
+ end
44
+
45
+ # @param name [String]
46
+ # @return [Hash, nil]
47
+ def fetch_from_api(name)
48
+ uri = URI("#{RUBYGEMS_ENDPOINT}/#{name}.json")
49
+ request = Net::HTTP::Get.new(uri)
50
+ request["User-Agent"] = "Licensure/#{Licensure::VERSION}"
51
+
52
+ response = http_client_for(uri).request(request)
53
+ return nil unless response.is_a?(Net::HTTPSuccess)
54
+
55
+ payload = JSON.parse(response.body)
56
+ licenses = normalize_licenses(payload["licenses"], payload["license"])
57
+ return nil if licenses.empty?
58
+
59
+ { licenses: licenses, homepage: payload["homepage_uri"] || payload["homepage"] }
60
+ rescue JSON::ParserError, StandardError
61
+ nil
62
+ end
63
+
64
+ # @param uri [URI::HTTPS]
65
+ # @return [Net::HTTP]
66
+ def http_client_for(uri)
67
+ client = Net::HTTP.new(uri.host, uri.port)
68
+ client.use_ssl = true
69
+ client.open_timeout = REQUEST_TIMEOUT
70
+ client.read_timeout = REQUEST_TIMEOUT
71
+ client
72
+ end
73
+
74
+ # @param licenses [Array<String>, nil]
75
+ # @param license [String, nil]
76
+ # @return [Array<String>]
77
+ def normalize_licenses(licenses, license)
78
+ items = []
79
+ items.concat(Array(licenses))
80
+ items << license if license
81
+
82
+ items.compact
83
+ .map { |item| item.to_s.strip }
84
+ .reject(&:empty?)
85
+ .uniq
86
+ end
87
+
88
+ # @param name [String]
89
+ # @param version [String]
90
+ # @param licenses [Array<String>]
91
+ # @param source [Symbol]
92
+ # @param homepage [String, nil]
93
+ # @return [Licensure::GemLicenseInfo]
94
+ def build_info(name, version, licenses, source, homepage)
95
+ GemLicenseInfo.new(
96
+ name: name,
97
+ version: version,
98
+ licenses: licenses,
99
+ source: source,
100
+ homepage: homepage
101
+ )
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ # License information for a single gem dependency.
5
+ GemLicenseInfo = Struct.new(
6
+ :name,
7
+ :version,
8
+ :licenses,
9
+ :source,
10
+ :homepage,
11
+ keyword_init: true
12
+ )
13
+
14
+ # License check result aggregation.
15
+ CheckResult = Struct.new(
16
+ :violations,
17
+ :warnings,
18
+ :passed,
19
+ keyword_init: true
20
+ )
21
+
22
+ # Violation or warning entry with reason.
23
+ Violation = Struct.new(
24
+ :gem_info,
25
+ :reason,
26
+ keyword_init: true
27
+ )
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Licensure
4
+ VERSION = "0.1.0"
5
+ end
data/lib/licensure.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "licensure/version"
4
+ require_relative "licensure/errors"
5
+ require_relative "licensure/types"
6
+ require_relative "licensure/configuration"
7
+ require_relative "licensure/dependency_resolver"
8
+ require_relative "licensure/license_fetcher"
9
+ require_relative "licensure/license_checker"
10
+ require_relative "licensure/formatters/base"
11
+ require_relative "licensure/formatters/table"
12
+ require_relative "licensure/formatters/csv"
13
+ require_relative "licensure/formatters/json"
14
+ require_relative "licensure/formatters/markdown"
15
+ require_relative "licensure/cli"
16
+
17
+ # Top-level namespace for Licensure.
18
+ module Licensure
19
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: licensure
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bundler
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ description: Licensure collects dependency license metadata and validates it against
41
+ a configurable allow list.
42
+ email:
43
+ - t.yudai92@gmail.com
44
+ executables:
45
+ - licensure
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".licensure.yml.example"
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - exe/licensure
54
+ - lib/licensure.rb
55
+ - lib/licensure/cli.rb
56
+ - lib/licensure/configuration.rb
57
+ - lib/licensure/dependency_resolver.rb
58
+ - lib/licensure/errors.rb
59
+ - lib/licensure/formatters/base.rb
60
+ - lib/licensure/formatters/csv.rb
61
+ - lib/licensure/formatters/json.rb
62
+ - lib/licensure/formatters/markdown.rb
63
+ - lib/licensure/formatters/table.rb
64
+ - lib/licensure/license_checker.rb
65
+ - lib/licensure/license_fetcher.rb
66
+ - lib/licensure/types.rb
67
+ - lib/licensure/version.rb
68
+ homepage: https://github.com/ydah/licensure
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/ydah/licensure
73
+ source_code_uri: https://github.com/ydah/licensure
74
+ changelog_uri: https://github.com/ydah/licensure/releases
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.1'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.6
90
+ specification_version: 4
91
+ summary: License compliance checker for Ruby dependencies
92
+ test_files: []