ruby-lean 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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.md +21 -0
- data/README.md +199 -0
- data/exe/ruby-lean +6 -0
- data/lib/ruby_lean/archive_inspector.rb +76 -0
- data/lib/ruby_lean/cli.rb +93 -0
- data/lib/ruby_lean/command/lint.rb +120 -0
- data/lib/ruby_lean/gemspec_loader.rb +95 -0
- data/lib/ruby_lean/model/finding.rb +51 -0
- data/lib/ruby_lean/model/package.rb +25 -0
- data/lib/ruby_lean/model/result.rb +99 -0
- data/lib/ruby_lean/reporters/json_reporter.rb +21 -0
- data/lib/ruby_lean/reporters/text_reporter.rb +97 -0
- data/lib/ruby_lean/rules/engine.rb +301 -0
- data/lib/ruby_lean/version.rb +5 -0
- data/lib/ruby_lean.rb +20 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4553b3d3a834471cf5bfbda717aa0931d752c5d9d198b002c435c7078341d644
|
|
4
|
+
data.tar.gz: bcd4971c8b74cdfb009f36c373813bfeb4b2a0a1658cb3f681ef713005b882b3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: eca97ad8086fe7fb52844200a0f32a44e0d5fd9629d8722c5a58df8edf43a10ca2b8febca0b00c58f9ce931fb084f746e9ebb5d9655d9fca91250cb8e01feaf1
|
|
7
|
+
data.tar.gz: 25331d162f42aa60e36dd068fea8a4c70af392bfd19f041d82173af1e14a2f62d0f2079e14d1fbfa5b2bdd9d13e100ce9923ee101e10e023ab99c84b51a29781
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-11-13
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- __`lint` command__: Checks whether all files declared in `.gemspec` and included in `.gem` packages comply with leanness and packaging rules.
|
|
15
|
+
- __Dual-layer inspection__: Analyzes effective gemspec declarations (`Gem::Specification#files`) and verifies built `.gem` archive contents in isolated temporary storage.
|
|
16
|
+
- __Lint rules__: Comprehensive detection of missing files, invalid paths, test suites, CI metadata, coverage reports, editor settings, development build tools, VCS files, and dynamic `git ls-files` discovery.
|
|
17
|
+
- __`--strict` flag__: Enables failing exit code on advisory warnings for CI pipelines.
|
|
18
|
+
- __`--no-archive` flag__: Allows specification-only inspection without building `.gem` archives.
|
|
19
|
+
- __`--format json` option__: Structured, deterministic JSON output for scripting and CI integration.
|
|
20
|
+
- __`--gemspec` option__: Explicitly specify `.gemspec` file path.
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 - ∞ Raphael Stolt
|
|
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,199 @@
|
|
|
1
|
+
# ruby-lean
|
|
2
|
+
|
|
3
|
+
Validate and inspect RubyGem package contents to keep your published gems lean.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
When you publish a Ruby gem to RubyGems.org, RubyGems packages all files resolved by `Gem::Specification#files` in your `.gemspec`. In many gems, files are selected using loose directory globs (`Dir["**/*"]`) or broad `git ls-files` commands. That means your published gem can unintentionally include development artefacts like IDE settings, test suites, build output, documentation drafts, and CI configuration. These files bloat the gem download size, slow down `gem install` and `bundle install`, and expose internal project structure to downstream users.
|
|
8
|
+
|
|
9
|
+
`ruby-lean` audits your `.gemspec` file list and verifies built `.gem` archives so only the source code and assets your users need end up in the published gem.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
### From RubyGems
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install ruby-lean
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### From source
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone git@github.com:lean-packages/ruby-lean.git && cd ruby-lean
|
|
23
|
+
rake build
|
|
24
|
+
gem install ./ruby-lean-*.gem
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This places the `ruby-lean` binary in your Ruby environment's executable directory, which should be on your PATH.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
ruby-lean <COMMAND>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Commands
|
|
36
|
+
|
|
37
|
+
| Command | Description |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `lint` | Check if any unnecessary or invalid files are included in the gem. |
|
|
40
|
+
|
|
41
|
+
### `lint` — Check package contents
|
|
42
|
+
|
|
43
|
+
Verifies that all files declared in `.gemspec` and packaged in `.gem` meet leanness and validity policies.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Check the current directory
|
|
47
|
+
ruby-lean lint
|
|
48
|
+
|
|
49
|
+
# Check a specific project directory
|
|
50
|
+
ruby-lean lint /path/to/my-gem
|
|
51
|
+
|
|
52
|
+
# Check a specific gemspec file
|
|
53
|
+
ruby-lean lint --gemspec /path/to/my-gem.gemspec
|
|
54
|
+
|
|
55
|
+
# Fail on advisory warnings (recommended for CI)
|
|
56
|
+
ruby-lean lint --strict
|
|
57
|
+
|
|
58
|
+
# Inspect gemspec file list only without building an archive
|
|
59
|
+
ruby-lean lint --no-archive
|
|
60
|
+
|
|
61
|
+
# JSON output for scripting
|
|
62
|
+
ruby-lean lint --format json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
__`--strict` flag:__ Treats advisory warnings (such as development test files, CI metadata, and build tools) as failures, exiting with code `1`.
|
|
66
|
+
|
|
67
|
+
__`--no-archive` flag:__ Skips building a temporary `.gem` archive and inspects only the effective gemspec file list on disk.
|
|
68
|
+
|
|
69
|
+
## Options and flags
|
|
70
|
+
|
|
71
|
+
All commands support these options and flags:
|
|
72
|
+
|
|
73
|
+
| Option / Flag | Description |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `--gemspec <PATH>` | Path to explicit `.gemspec` file |
|
|
76
|
+
| `-f, --format <FORMAT>` | Output format: `text` or `json` (default: `text`) |
|
|
77
|
+
| `-s, --strict` | Treat advisory warnings as errors |
|
|
78
|
+
| `--no-archive` | Skip building and verifying temporary `.gem` archive |
|
|
79
|
+
| `-q, --quiet` | Suppress non-essential informational output |
|
|
80
|
+
| `-v, --version` | Print version information |
|
|
81
|
+
| `-h, --help` | Show help message |
|
|
82
|
+
|
|
83
|
+
## Lint rules
|
|
84
|
+
|
|
85
|
+
`ruby-lean` evaluates package contents against high-signal rules:
|
|
86
|
+
|
|
87
|
+
### Errors (Always fail policy)
|
|
88
|
+
|
|
89
|
+
| Rule ID | Description |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `RUBYLEAN_INVALID_SPEC` | Gemspec syntax error, unloadable specification, or validation failure. |
|
|
92
|
+
| `RUBYLEAN_MISSING_FILE` | Declared file does not exist on disk. |
|
|
93
|
+
| `RUBYLEAN_INVALID_PATH` | Absolute path, directory entry, or path escaping the gem root (`../`). |
|
|
94
|
+
| `RUBYLEAN_BUILD_ERROR` | RubyGems archive packaging failure. |
|
|
95
|
+
| `RUBYLEAN_ARCHIVE_MISMATCH` | Discrepancy between gemspec declared files and actual `.gem` archive contents. |
|
|
96
|
+
|
|
97
|
+
### Advisory warnings (Fail with `--strict`)
|
|
98
|
+
|
|
99
|
+
| Rule ID | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `RUBYLEAN_DEV_FILE` | Test or specification files (`spec/`, `test/`, `features/`) included in gem. |
|
|
102
|
+
| `RUBYLEAN_CI_METADATA` | CI configuration files (`.github/`, `.gitlab/`, `.circleci/`, etc.) included. |
|
|
103
|
+
| `RUBYLEAN_COVERAGE` | Test coverage artifacts or configs (`coverage/`, `.simplecov`) included. |
|
|
104
|
+
| `RUBYLEAN_EDITOR_CONFIG` | Editor or IDE configuration files (`.vscode/`, `.idea/`, etc.) included. |
|
|
105
|
+
| `RUBYLEAN_BUILD_TOOL` | Development build tools (`Gemfile`, `Gemfile.lock`, `Rakefile`, `.rubocop.yml`) included. |
|
|
106
|
+
| `RUBYLEAN_VCS_METADATA` | VCS metadata (`.gitignore`, `.gitattributes`) included. |
|
|
107
|
+
| `RUBYLEAN_GIT_DEPENDENT` | Gemspec source relies on `git ls-files` for dynamic file discovery (advisory). |
|
|
108
|
+
| `RUBYLEAN_SYMLINK` | Packaged file is a symbolic link. |
|
|
109
|
+
|
|
110
|
+
*Note:* Standard documentation and runtime assets (`README*`, `LICENSE*`, `CHANGELOG*`, `CONTRIBUTING*`, `docs/`, `lib/`, `exe/`) are explicitly preserved and never flagged.
|
|
111
|
+
|
|
112
|
+
## Exit codes
|
|
113
|
+
|
|
114
|
+
| Exit Code | Status | Description |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `0` | `SUCCESS` | Gem passed policy (0 errors; warnings allowed unless `--strict`). |
|
|
117
|
+
| `1` | `LINT_FAILED` | Lint policy failed (errors found, or warnings found with `--strict`). |
|
|
118
|
+
| `2` | `EXECUTION_ERROR` | Usage error, invalid options, missing gemspec, or gemspec evaluation failure. |
|
|
119
|
+
|
|
120
|
+
## CI integration
|
|
121
|
+
|
|
122
|
+
Add `ruby-lean lint` to your CI pipeline to enforce that gem packages stay clean:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
# Example GitHub Actions step
|
|
126
|
+
- name: Check RubyGem package contents
|
|
127
|
+
run: ruby-lean lint --strict
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## JSON output
|
|
131
|
+
|
|
132
|
+
All commands support `--format json` for scripting and automation. Example `lint` output:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"schemaVersion": "1",
|
|
137
|
+
"tool": {
|
|
138
|
+
"name": "ruby-lean",
|
|
139
|
+
"version": "0.1.0"
|
|
140
|
+
},
|
|
141
|
+
"package": {
|
|
142
|
+
"name": "example-gem",
|
|
143
|
+
"version": "1.0.0",
|
|
144
|
+
"gemspec": "example-gem.gemspec"
|
|
145
|
+
},
|
|
146
|
+
"inspection": {
|
|
147
|
+
"mode": "archive",
|
|
148
|
+
"archiveVerified": true,
|
|
149
|
+
"fileCount": 4,
|
|
150
|
+
"totalBytes": 1420
|
|
151
|
+
},
|
|
152
|
+
"summary": {
|
|
153
|
+
"errors": 0,
|
|
154
|
+
"warnings": 1,
|
|
155
|
+
"info": 0
|
|
156
|
+
},
|
|
157
|
+
"findings": [
|
|
158
|
+
{
|
|
159
|
+
"rule": "RUBYLEAN_DEV_FILE",
|
|
160
|
+
"severity": "warning",
|
|
161
|
+
"message": "Development-oriented test or specification files are included in the package.",
|
|
162
|
+
"paths": [
|
|
163
|
+
"spec/example_spec.rb"
|
|
164
|
+
],
|
|
165
|
+
"suggestedAction": "Filter out test directories (spec/, test/, features/) from spec.files in your gemspec."
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Security considerations
|
|
172
|
+
|
|
173
|
+
A `.gemspec` file contains executable Ruby code. Evaluating a gemspec via RubyGems executes Ruby instructions. `ruby-lean` is intended for **trusted local gem repositories** and maintainers inspecting their own code. Evaluating untrusted gemspecs is not sandboxed.
|
|
174
|
+
|
|
175
|
+
## AI disclaimer
|
|
176
|
+
|
|
177
|
+
This package is developed with the assistance of Artificial Intelligence (AI). AI tools are used throughout the development
|
|
178
|
+
process, including code generation, documentation, testing, and maintenance.
|
|
179
|
+
|
|
180
|
+
While reasonable care is taken to review and validate AI-generated contributions, AI-generated code is not inherently correct,
|
|
181
|
+
secure, or free of defects. All contributions remain subject to human review and project quality standards.
|
|
182
|
+
|
|
183
|
+
As with any software, use this package at your own risk. No guarantee is made regarding its fitness for a particular purpose,
|
|
184
|
+
and the maintainers assume no liability for damages resulting from its use.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development instructions.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
This project is licensed under the MIT license. Please see [LICENSE.md](LICENSE.md) for more details.
|
|
193
|
+
|
|
194
|
+
## Related projects
|
|
195
|
+
|
|
196
|
+
- [cargo-lean](https://github.com/lean-packages/cargo-lean) — The `Rust` counterpart.
|
|
197
|
+
- [php-lean](https://github.com/lean-packages/php-lean) — The `PHP` counterpart.
|
|
198
|
+
- [py-lean](https://github.com/lean-packages/py-lean) — The `Python` equivalent.
|
|
199
|
+
- [js-lean](https://github.com/lean-packages/js-lean) — The `JavaScript` equivalent.
|
data/exe/ruby-lean
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
require "rubygems/package"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module RubyLean
|
|
10
|
+
class ArchiveInspector
|
|
11
|
+
ArchiveData = Struct.new(:archive_files, :file_count, :total_bytes, :largest_files, :build_warnings, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
def self.inspect_package(package)
|
|
14
|
+
new.inspect_package(package)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def inspect_package(package)
|
|
18
|
+
Dir.mktmpdir("ruby-lean-") do |tmpdir|
|
|
19
|
+
output_gem = File.join(tmpdir, "#{package.name}-#{package.version}.gem")
|
|
20
|
+
gemspec_filename = File.basename(package.gemspec_path)
|
|
21
|
+
|
|
22
|
+
cmd = ["gem", "build", gemspec_filename, "--output", output_gem]
|
|
23
|
+
stdout, stderr, status = Open3.capture3(*cmd, chdir: package.gem_root)
|
|
24
|
+
|
|
25
|
+
unless status.success? && File.exist?(output_gem)
|
|
26
|
+
error_msg = stderr.to_s.strip
|
|
27
|
+
error_msg = stdout.to_s.strip if error_msg.empty?
|
|
28
|
+
error_msg = "Unknown gem build error" if error_msg.empty?
|
|
29
|
+
raise BuildError, error_msg
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
parse_archive(output_gem, stderr)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def parse_archive(gem_path, stderr)
|
|
39
|
+
archive_files = []
|
|
40
|
+
file_entries = []
|
|
41
|
+
total_bytes = 0
|
|
42
|
+
|
|
43
|
+
File.open(gem_path, "rb") do |io|
|
|
44
|
+
Gem::Package::TarReader.new(io) do |reader|
|
|
45
|
+
reader.each do |entry|
|
|
46
|
+
next unless entry.full_name == "data.tar.gz"
|
|
47
|
+
|
|
48
|
+
gz = Zlib::GzipReader.new(entry)
|
|
49
|
+
Gem::Package::TarReader.new(gz) do |data_reader|
|
|
50
|
+
data_reader.each do |data_entry|
|
|
51
|
+
next unless data_entry.file?
|
|
52
|
+
|
|
53
|
+
fname = data_entry.full_name
|
|
54
|
+
fsize = data_entry.header.size
|
|
55
|
+
|
|
56
|
+
archive_files << fname
|
|
57
|
+
file_entries << { "path" => fname, "bytes" => fsize }
|
|
58
|
+
total_bytes += fsize
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
largest_files = file_entries.sort_by { |e| -e["bytes"] }.first(5)
|
|
66
|
+
|
|
67
|
+
ArchiveData.new(
|
|
68
|
+
archive_files: archive_files.sort,
|
|
69
|
+
file_count: archive_files.size,
|
|
70
|
+
total_bytes: total_bytes,
|
|
71
|
+
largest_files: largest_files,
|
|
72
|
+
build_warnings: stderr.to_s.strip
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module RubyLean
|
|
6
|
+
class CLI
|
|
7
|
+
def self.start(args = ARGV)
|
|
8
|
+
new.start(args)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def start(args = ARGV)
|
|
12
|
+
options = {
|
|
13
|
+
format: "text",
|
|
14
|
+
strict: false,
|
|
15
|
+
archive: true,
|
|
16
|
+
quiet: false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
args = args.dup
|
|
20
|
+
|
|
21
|
+
# Check top-level flags first
|
|
22
|
+
if args.include?("-v") || args.include?("--version")
|
|
23
|
+
puts "ruby-lean #{RubyLean::VERSION}"
|
|
24
|
+
return 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Extract command if provided
|
|
28
|
+
command = "lint"
|
|
29
|
+
if args.first == "lint"
|
|
30
|
+
args.shift
|
|
31
|
+
elsif args.first && !args.first.start_with?("-") && File.exist?(args.first)
|
|
32
|
+
# First arg is a path
|
|
33
|
+
options[:path] = args.shift
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
parser = OptionParser.new do |opts|
|
|
37
|
+
opts.banner = "Usage: ruby-lean lint [PATH] [options]"
|
|
38
|
+
opts.separator ""
|
|
39
|
+
opts.separator "Options:"
|
|
40
|
+
|
|
41
|
+
opts.on("--gemspec PATH", "Path to explicit .gemspec file") do |path|
|
|
42
|
+
options[:gemspec] = path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
opts.on("-f", "--format FORMAT", %w[text json], "Output format (text, json; default: text)") do |fmt|
|
|
46
|
+
options[:format] = fmt
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
opts.on("-s", "--strict", "Fail on warnings as well as errors") do
|
|
50
|
+
options[:strict] = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
opts.on("--no-archive", "Inspect gemspec file list only without building archive") do
|
|
54
|
+
options[:archive] = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
opts.on("-q", "--quiet", "Suppress non-essential informational output") do
|
|
58
|
+
options[:quiet] = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
opts.on_tail("-v", "--version", "Print version and exit") do
|
|
62
|
+
puts "ruby-lean #{RubyLean::VERSION}"
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on_tail("-h", "--help", "Show this help message and exit") do
|
|
67
|
+
puts opts
|
|
68
|
+
return 0
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
remaining = parser.parse!(args)
|
|
74
|
+
options[:path] ||= remaining.first || "."
|
|
75
|
+
rescue OptionParser::InvalidOption, OptionParser::InvalidArgument => e
|
|
76
|
+
warn "Error: #{e.message}"
|
|
77
|
+
warn "Run 'ruby-lean --help' for usage instructions."
|
|
78
|
+
return 2
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
Command::Lint.run(options)
|
|
83
|
+
rescue ExecutionError => e
|
|
84
|
+
warn "Error: #{e.message}"
|
|
85
|
+
2
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
warn "Unexpected error: #{e.message}"
|
|
88
|
+
warn e.backtrace.join("\n") if $DEBUG
|
|
89
|
+
2
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLean
|
|
4
|
+
module Command
|
|
5
|
+
class Lint
|
|
6
|
+
def self.run(options = {})
|
|
7
|
+
new(options).run
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(options = {})
|
|
11
|
+
@options = options
|
|
12
|
+
@target_path = options[:path] || "."
|
|
13
|
+
@explicit_gemspec = options[:gemspec]
|
|
14
|
+
@format = (options[:format] || "text").to_s.downcase
|
|
15
|
+
@strict = !!options[:strict]
|
|
16
|
+
@archive = options.fetch(:archive, true)
|
|
17
|
+
@quiet = !!options[:quiet]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
package = GemspecLoader.discover_and_load(@target_path, explicit_gemspec: @explicit_gemspec)
|
|
22
|
+
|
|
23
|
+
result = if @archive
|
|
24
|
+
inspect_with_archive(package)
|
|
25
|
+
else
|
|
26
|
+
inspect_specification_only(package)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
output = render_result(result)
|
|
30
|
+
|
|
31
|
+
if @format == "json"
|
|
32
|
+
puts output
|
|
33
|
+
else
|
|
34
|
+
puts output unless output.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result.exit_code(strict: @strict)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def inspect_with_archive(package)
|
|
43
|
+
archive_data = nil
|
|
44
|
+
build_error_finding = nil
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
archive_data = ArchiveInspector.inspect_package(package)
|
|
48
|
+
rescue BuildError => e
|
|
49
|
+
build_error_finding = Model::Finding.new(
|
|
50
|
+
rule: "RUBYLEAN_BUILD_ERROR",
|
|
51
|
+
severity: "error",
|
|
52
|
+
message: "Failed to build .gem archive: #{e.message}",
|
|
53
|
+
paths: [package.relative_gemspec_path],
|
|
54
|
+
suggested_action: "Fix gemspec errors or build issues reported by RubyGems."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if archive_data
|
|
59
|
+
findings = Rules::Engine.audit(package, archive_files: archive_data.archive_files)
|
|
60
|
+
Model::Result.new(
|
|
61
|
+
package: package,
|
|
62
|
+
inspection_mode: "archive",
|
|
63
|
+
archive_verified: true,
|
|
64
|
+
file_count: archive_data.file_count,
|
|
65
|
+
total_bytes: archive_data.total_bytes,
|
|
66
|
+
largest_files: archive_data.largest_files,
|
|
67
|
+
archive_files: archive_data.archive_files,
|
|
68
|
+
findings: findings
|
|
69
|
+
)
|
|
70
|
+
else
|
|
71
|
+
# Fallback to spec rules plus build error finding
|
|
72
|
+
findings = Rules::Engine.audit(package, archive_files: nil)
|
|
73
|
+
findings << build_error_finding if build_error_finding
|
|
74
|
+
|
|
75
|
+
Model::Result.new(
|
|
76
|
+
package: package,
|
|
77
|
+
inspection_mode: "archive",
|
|
78
|
+
archive_verified: false,
|
|
79
|
+
file_count: package.effective_files.size,
|
|
80
|
+
total_bytes: calculate_disk_bytes(package),
|
|
81
|
+
findings: findings
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect_specification_only(package)
|
|
87
|
+
findings = Rules::Engine.audit(package, archive_files: nil)
|
|
88
|
+
|
|
89
|
+
Model::Result.new(
|
|
90
|
+
package: package,
|
|
91
|
+
inspection_mode: "specification",
|
|
92
|
+
archive_verified: false,
|
|
93
|
+
file_count: package.effective_files.size,
|
|
94
|
+
total_bytes: calculate_disk_bytes(package),
|
|
95
|
+
findings: findings
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def calculate_disk_bytes(package)
|
|
100
|
+
total = 0
|
|
101
|
+
package.effective_files.each do |rel_path|
|
|
102
|
+
full_path = File.join(package.gem_root, rel_path)
|
|
103
|
+
total += File.size(full_path) if File.file?(full_path)
|
|
104
|
+
end
|
|
105
|
+
total
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def render_result(result)
|
|
111
|
+
case @format
|
|
112
|
+
when "json"
|
|
113
|
+
Reporters::JsonReporter.render(result)
|
|
114
|
+
else
|
|
115
|
+
Reporters::TextReporter.render(result, strict: @strict, quiet: @quiet)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module RubyLean
|
|
7
|
+
class GemspecLoader
|
|
8
|
+
def self.discover_and_load(target_path = ".", explicit_gemspec: nil)
|
|
9
|
+
new.discover_and_load(target_path, explicit_gemspec: explicit_gemspec)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def discover_and_load(target_path = ".", explicit_gemspec: nil)
|
|
13
|
+
gemspec_path = resolve_gemspec_path(target_path, explicit_gemspec)
|
|
14
|
+
load_gemspec(gemspec_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolve_gemspec_path(target_path = ".", explicit_gemspec = nil)
|
|
18
|
+
if explicit_gemspec
|
|
19
|
+
path = File.expand_path(explicit_gemspec)
|
|
20
|
+
unless File.exist?(path) && File.file?(path)
|
|
21
|
+
raise ExecutionError, "Specified gemspec does not exist: #{explicit_gemspec}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
return path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
target = File.expand_path(target_path || ".")
|
|
28
|
+
|
|
29
|
+
if File.file?(target) && target.end_with?(".gemspec")
|
|
30
|
+
return target
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unless File.directory?(target)
|
|
34
|
+
raise ExecutionError, "Target path is not a directory or gemspec: #{target_path}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
matches = Dir.glob(File.join(target, "*.gemspec"))
|
|
38
|
+
if matches.empty?
|
|
39
|
+
raise ExecutionError, "No .gemspec file found in #{target_path}. Specify one with --gemspec PATH"
|
|
40
|
+
elsif matches.size > 1
|
|
41
|
+
names = matches.map { |m| File.basename(m) }.join(", ")
|
|
42
|
+
raise ExecutionError, "Multiple .gemspec files found (#{names}). Specify one with --gemspec PATH"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
matches.first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_gemspec(gemspec_path)
|
|
49
|
+
gemspec_path = File.expand_path(gemspec_path)
|
|
50
|
+
gem_root = File.dirname(gemspec_path)
|
|
51
|
+
|
|
52
|
+
source_text = File.read(gemspec_path) rescue ""
|
|
53
|
+
git_dependent = detect_git_dependency(source_text)
|
|
54
|
+
|
|
55
|
+
spec = nil
|
|
56
|
+
# Evaluate gemspec in its directory context
|
|
57
|
+
Dir.chdir(gem_root) do
|
|
58
|
+
begin
|
|
59
|
+
code = File.read(gemspec_path, mode: "r:UTF-8:-")
|
|
60
|
+
spec = eval(code, binding, gemspec_path)
|
|
61
|
+
rescue StandardError, ScriptError => e
|
|
62
|
+
raise GemspecLoadError, "Failed to evaluate gemspec #{File.basename(gemspec_path)}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if spec.nil? || !spec.is_a?(Gem::Specification)
|
|
67
|
+
raise GemspecLoadError, "Failed to evaluate gemspec: #{File.basename(gemspec_path)} is invalid or empty"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
spec.loaded_from = gemspec_path
|
|
71
|
+
|
|
72
|
+
effective_files = Array(spec.files).map(&:to_s).uniq.sort
|
|
73
|
+
|
|
74
|
+
Model::Package.new(
|
|
75
|
+
name: spec.name || File.basename(gemspec_path, ".gemspec"),
|
|
76
|
+
version: spec.version ? spec.version.to_s : "0.0.0",
|
|
77
|
+
gemspec_path: gemspec_path,
|
|
78
|
+
gem_root: gem_root,
|
|
79
|
+
effective_files: effective_files,
|
|
80
|
+
spec: spec,
|
|
81
|
+
git_dependent: git_dependent
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def detect_git_dependency(source)
|
|
88
|
+
# Check if gemspec uses `git ls-files` or similar git command
|
|
89
|
+
source.match?(/`\s*git\s+ls-files/i) ||
|
|
90
|
+
source.match?(/%x\[\s*git\s+ls-files/i) ||
|
|
91
|
+
source.match?(/IO\.popen\(\s*\[?['"]git['"],\s*['"]ls-files['"]/i) ||
|
|
92
|
+
source.match?(/Open3\.(?:capture[23]|popen3)\(\s*['"]git\s+ls-files/i)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLean
|
|
4
|
+
module Model
|
|
5
|
+
class Finding
|
|
6
|
+
SEVERITIES = %w[error warning info].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :rule, :severity, :message, :paths, :suggested_action
|
|
9
|
+
|
|
10
|
+
def initialize(rule:, severity:, message:, paths: [], suggested_action: nil)
|
|
11
|
+
@rule = rule.to_s
|
|
12
|
+
@severity = severity.to_s.downcase
|
|
13
|
+
@message = message.to_s
|
|
14
|
+
@paths = Array(paths).map(&:to_s).sort
|
|
15
|
+
@suggested_action = suggested_action&.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error?
|
|
19
|
+
@severity == "error"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def warning?
|
|
23
|
+
@severity == "warning"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def info?
|
|
27
|
+
@severity == "info"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
data = {
|
|
32
|
+
"rule" => @rule,
|
|
33
|
+
"severity" => @severity,
|
|
34
|
+
"message" => @message,
|
|
35
|
+
"paths" => @paths
|
|
36
|
+
}
|
|
37
|
+
data["suggestedAction"] = @suggested_action if @suggested_action
|
|
38
|
+
data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ==(other)
|
|
42
|
+
other.is_a?(Finding) &&
|
|
43
|
+
rule == other.rule &&
|
|
44
|
+
severity == other.severity &&
|
|
45
|
+
message == other.message &&
|
|
46
|
+
paths == other.paths &&
|
|
47
|
+
suggested_action == other.suggested_action
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLean
|
|
4
|
+
module Model
|
|
5
|
+
class Package
|
|
6
|
+
attr_reader :name, :version, :gemspec_path, :gem_root, :effective_files, :spec, :git_dependent
|
|
7
|
+
|
|
8
|
+
def initialize(name:, version:, gemspec_path:, gem_root:, effective_files: [], spec: nil, git_dependent: false)
|
|
9
|
+
@name = name.to_s
|
|
10
|
+
@version = version.to_s
|
|
11
|
+
@gemspec_path = gemspec_path.to_s
|
|
12
|
+
@gem_root = File.expand_path(gem_root.to_s)
|
|
13
|
+
@effective_files = Array(effective_files).map(&:to_s).uniq.sort
|
|
14
|
+
@spec = spec
|
|
15
|
+
@git_dependent = !!git_dependent
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def relative_gemspec_path
|
|
19
|
+
Pathname.new(@gemspec_path).relative_path_from(Pathname.new(@gem_root)).to_s
|
|
20
|
+
rescue ArgumentError
|
|
21
|
+
@gemspec_path
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RubyLean
|
|
6
|
+
module Model
|
|
7
|
+
class Result
|
|
8
|
+
attr_reader :package, :inspection_mode, :archive_verified, :file_count, :total_bytes, :largest_files, :archive_files, :findings
|
|
9
|
+
|
|
10
|
+
def initialize(package:, inspection_mode: "archive", archive_verified: false, file_count: 0, total_bytes: nil, largest_files: [], archive_files: nil, findings: [])
|
|
11
|
+
@package = package
|
|
12
|
+
@inspection_mode = inspection_mode.to_s
|
|
13
|
+
@archive_verified = !!archive_verified
|
|
14
|
+
@file_count = Integer(file_count)
|
|
15
|
+
@total_bytes = total_bytes ? Integer(total_bytes) : nil
|
|
16
|
+
@largest_files = Array(largest_files)
|
|
17
|
+
@archive_files = archive_files ? Array(archive_files).map(&:to_s).sort : nil
|
|
18
|
+
@findings = Array(findings).sort_by { |f| [severity_rank(f.severity), f.rule, f.paths.first || ""] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def errors
|
|
22
|
+
@findings.select(&:error?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warnings
|
|
26
|
+
@findings.select(&:warning?)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def info_items
|
|
30
|
+
@findings.select(&:info?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def errors_count
|
|
34
|
+
errors.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def warnings_count
|
|
38
|
+
warnings.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def info_count
|
|
42
|
+
info_items.size
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def passed?(strict: false)
|
|
46
|
+
return false if errors_count > 0
|
|
47
|
+
return false if strict && warnings_count > 0
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def exit_code(strict: false)
|
|
53
|
+
return 1 unless passed?(strict: strict)
|
|
54
|
+
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
data = {
|
|
60
|
+
"schemaVersion" => "1",
|
|
61
|
+
"tool" => {
|
|
62
|
+
"name" => "ruby-lean",
|
|
63
|
+
"version" => RubyLean::VERSION
|
|
64
|
+
},
|
|
65
|
+
"package" => {
|
|
66
|
+
"name" => @package.name,
|
|
67
|
+
"version" => @package.version,
|
|
68
|
+
"gemspec" => @package.relative_gemspec_path
|
|
69
|
+
},
|
|
70
|
+
"inspection" => {
|
|
71
|
+
"mode" => @inspection_mode,
|
|
72
|
+
"archiveVerified" => @archive_verified,
|
|
73
|
+
"fileCount" => @file_count
|
|
74
|
+
},
|
|
75
|
+
"summary" => {
|
|
76
|
+
"errors" => errors_count,
|
|
77
|
+
"warnings" => warnings_count,
|
|
78
|
+
"info" => info_count
|
|
79
|
+
},
|
|
80
|
+
"findings" => @findings.map(&:to_h)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
data["inspection"]["totalBytes"] = @total_bytes if @total_bytes
|
|
84
|
+
data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def severity_rank(sev)
|
|
90
|
+
case sev.to_s.downcase
|
|
91
|
+
when "error" then 0
|
|
92
|
+
when "warning" then 1
|
|
93
|
+
when "info" then 2
|
|
94
|
+
else 3
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubyLean
|
|
6
|
+
module Reporters
|
|
7
|
+
class JsonReporter
|
|
8
|
+
def self.render(result)
|
|
9
|
+
new(result).render
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(result)
|
|
13
|
+
@result = result
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
JSON.pretty_generate(@result.to_h)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLean
|
|
4
|
+
module Reporters
|
|
5
|
+
class TextReporter
|
|
6
|
+
def self.render(result, strict: false, quiet: false)
|
|
7
|
+
new(result, strict: strict, quiet: quiet).render
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(result, strict: false, quiet: false)
|
|
11
|
+
@result = result
|
|
12
|
+
@strict = strict
|
|
13
|
+
@quiet = quiet
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
lines = []
|
|
18
|
+
|
|
19
|
+
unless @quiet
|
|
20
|
+
lines << "ruby-lean lint: #{@result.package.name} #{@result.package.version}"
|
|
21
|
+
lines << " Gemspec: #{@result.package.relative_gemspec_path}"
|
|
22
|
+
mode_label = @result.archive_verified ? "Archive verified (.gem)" : "Specification-only (--no-archive)"
|
|
23
|
+
lines << " Mode: #{mode_label}"
|
|
24
|
+
lines << " Files: #{@result.file_count}"
|
|
25
|
+
if @result.total_bytes
|
|
26
|
+
lines << " Total size: #{format_bytes(@result.total_bytes)}"
|
|
27
|
+
end
|
|
28
|
+
lines << ""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if @result.findings.empty?
|
|
32
|
+
lines << "No issues found. Gem packaging passes policy."
|
|
33
|
+
lines << "Policy passed."
|
|
34
|
+
else
|
|
35
|
+
# Group findings by severity
|
|
36
|
+
errors = @result.errors
|
|
37
|
+
warnings = @result.warnings
|
|
38
|
+
info = @result.info_items
|
|
39
|
+
|
|
40
|
+
if errors.any?
|
|
41
|
+
lines << "ERRORS (#{errors.size}):"
|
|
42
|
+
errors.each { |f| lines.concat(format_finding(f)) }
|
|
43
|
+
lines << ""
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if warnings.any?
|
|
47
|
+
lines << "WARNINGS (#{warnings.size}):"
|
|
48
|
+
warnings.each { |f| lines.concat(format_finding(f)) }
|
|
49
|
+
lines << ""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if info.any?
|
|
53
|
+
lines << "INFO (#{info.size}):"
|
|
54
|
+
info.each { |f| lines.concat(format_finding(f)) }
|
|
55
|
+
lines << ""
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
summary_text = "Summary: #{@result.errors_count} error(s), #{@result.warnings_count} warning(s)"
|
|
59
|
+
summary_text += " (strict mode enabled)" if @strict
|
|
60
|
+
lines << summary_text
|
|
61
|
+
|
|
62
|
+
if @result.passed?(strict: @strict)
|
|
63
|
+
lines << "Policy passed."
|
|
64
|
+
else
|
|
65
|
+
lines << "Policy failed."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def format_finding(finding)
|
|
75
|
+
out = []
|
|
76
|
+
out << " [#{finding.rule}] #{finding.message}"
|
|
77
|
+
finding.paths.each do |p|
|
|
78
|
+
out << " - #{p}"
|
|
79
|
+
end
|
|
80
|
+
if finding.suggested_action
|
|
81
|
+
out << " Suggested action: #{finding.suggested_action}"
|
|
82
|
+
end
|
|
83
|
+
out
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def format_bytes(bytes)
|
|
87
|
+
if bytes < 1024
|
|
88
|
+
"#{bytes} B"
|
|
89
|
+
elsif bytes < 1024 * 1024
|
|
90
|
+
"#{(bytes / 1024.0).round(1)} KB (#{bytes} bytes)"
|
|
91
|
+
else
|
|
92
|
+
"#{(bytes / (1024.0 * 1024.0)).round(2)} MB (#{bytes} bytes)"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module RubyLean
|
|
7
|
+
module Rules
|
|
8
|
+
class Engine
|
|
9
|
+
def self.audit(package, archive_files: nil)
|
|
10
|
+
new(package, archive_files: archive_files).audit
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(package, archive_files: nil)
|
|
14
|
+
@package = package
|
|
15
|
+
@archive_files = archive_files
|
|
16
|
+
@gem_root = package.gem_root
|
|
17
|
+
@effective_files = package.effective_files
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def audit
|
|
21
|
+
findings = []
|
|
22
|
+
|
|
23
|
+
# 1. Path validity and existence checks (Errors)
|
|
24
|
+
findings.concat(check_paths_and_missing_files)
|
|
25
|
+
|
|
26
|
+
# 2. Advisory warning checks
|
|
27
|
+
findings.concat(check_dev_files)
|
|
28
|
+
findings.concat(check_ci_metadata)
|
|
29
|
+
findings.concat(check_coverage_artifacts)
|
|
30
|
+
findings.concat(check_editor_configs)
|
|
31
|
+
findings.concat(check_build_tools)
|
|
32
|
+
findings.concat(check_vcs_metadata)
|
|
33
|
+
findings.concat(check_symlinks)
|
|
34
|
+
findings.concat(check_git_dependency)
|
|
35
|
+
|
|
36
|
+
# 3. Archive mismatch checks (if archive was inspected)
|
|
37
|
+
findings.concat(check_archive_mismatches) if @archive_files
|
|
38
|
+
|
|
39
|
+
findings
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def check_paths_and_missing_files
|
|
45
|
+
findings = []
|
|
46
|
+
invalid_paths = []
|
|
47
|
+
missing_files = []
|
|
48
|
+
|
|
49
|
+
gem_root_pathname = Pathname.new(@gem_root).cleanpath
|
|
50
|
+
|
|
51
|
+
@effective_files.each do |rel_path|
|
|
52
|
+
# Check for absolute path or root escaping traversal
|
|
53
|
+
if rel_path.start_with?("/") || rel_path.start_with?("\\")
|
|
54
|
+
invalid_paths << rel_path
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
expanded_path = File.expand_path(rel_path, @gem_root)
|
|
59
|
+
expanded_pathname = Pathname.new(expanded_path).cleanpath
|
|
60
|
+
|
|
61
|
+
# Check if path escapes gem root
|
|
62
|
+
begin
|
|
63
|
+
relative_to_root = expanded_pathname.relative_path_from(gem_root_pathname).to_s
|
|
64
|
+
if relative_to_root.start_with?("..")
|
|
65
|
+
invalid_paths << rel_path
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
rescue ArgumentError
|
|
69
|
+
invalid_paths << rel_path
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check existence
|
|
74
|
+
if !File.exist?(expanded_path)
|
|
75
|
+
missing_files << rel_path
|
|
76
|
+
elsif File.directory?(expanded_path)
|
|
77
|
+
# Declaring a directory as a file entry
|
|
78
|
+
invalid_paths << rel_path
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if invalid_paths.any?
|
|
83
|
+
findings << Model::Finding.new(
|
|
84
|
+
rule: "RUBYLEAN_INVALID_PATH",
|
|
85
|
+
severity: "error",
|
|
86
|
+
message: "Gemspec contains absolute paths, directory entries, or paths traversing outside the gem root.",
|
|
87
|
+
paths: invalid_paths,
|
|
88
|
+
suggested_action: "Remove absolute paths and directory entries; ensure all paths are relative to the gem root."
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if missing_files.any?
|
|
93
|
+
findings << Model::Finding.new(
|
|
94
|
+
rule: "RUBYLEAN_MISSING_FILE",
|
|
95
|
+
severity: "error",
|
|
96
|
+
message: "Files referenced in gemspec effective file list do not exist on disk.",
|
|
97
|
+
paths: missing_files,
|
|
98
|
+
suggested_action: "Ensure declared files exist or remove non-existent references from spec.files."
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
findings
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_dev_files
|
|
106
|
+
matches = @effective_files.select do |path|
|
|
107
|
+
dev_file?(path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return [] if matches.empty?
|
|
111
|
+
|
|
112
|
+
[
|
|
113
|
+
Model::Finding.new(
|
|
114
|
+
rule: "RUBYLEAN_DEV_FILE",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
message: "Development-oriented test or specification files are included in the package.",
|
|
117
|
+
paths: matches,
|
|
118
|
+
suggested_action: "Filter out test directories (spec/, test/, features/) from spec.files in your gemspec."
|
|
119
|
+
)
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def dev_file?(path)
|
|
124
|
+
p = path.downcase
|
|
125
|
+
# Test directories
|
|
126
|
+
return true if p.start_with?("spec/", "test/", "tests/", "features/", "benchmarks/")
|
|
127
|
+
return true if p.match?(%r{^[^/]+/(?:spec|test|tests|features)/})
|
|
128
|
+
|
|
129
|
+
# Standalone test files outside lib/
|
|
130
|
+
return true if !p.start_with?("lib/", "exe/", "bin/", "ext/") && (
|
|
131
|
+
p.end_with?("_test.rb", "_spec.rb") ||
|
|
132
|
+
File.basename(p).start_with?("test_")
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def check_ci_metadata
|
|
139
|
+
matches = @effective_files.select do |path|
|
|
140
|
+
p = path.downcase
|
|
141
|
+
p.start_with?(".github/", ".gitlab/", ".circleci/", ".buildkite/") ||
|
|
142
|
+
%w[.travis.yml azure-pipelines.yml appveyor.yml .appveyor.yml .codecov.yml jenkinsfile].include?(File.basename(p))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return [] if matches.empty?
|
|
146
|
+
|
|
147
|
+
[
|
|
148
|
+
Model::Finding.new(
|
|
149
|
+
rule: "RUBYLEAN_CI_METADATA",
|
|
150
|
+
severity: "warning",
|
|
151
|
+
message: "Continuous Integration (CI) configuration or workflow files are included in the package.",
|
|
152
|
+
paths: matches,
|
|
153
|
+
suggested_action: "Exclude .github/, .gitlab/, and other CI configurations from spec.files."
|
|
154
|
+
)
|
|
155
|
+
]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def check_coverage_artifacts
|
|
159
|
+
matches = @effective_files.select do |path|
|
|
160
|
+
p = path.downcase
|
|
161
|
+
p.start_with?("coverage/") || %w[.simplecov .simplecov_spawn].include?(File.basename(p))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
return [] if matches.empty?
|
|
165
|
+
|
|
166
|
+
[
|
|
167
|
+
Model::Finding.new(
|
|
168
|
+
rule: "RUBYLEAN_COVERAGE",
|
|
169
|
+
severity: "warning",
|
|
170
|
+
message: "Test coverage configuration or generated report artifacts are included in the package.",
|
|
171
|
+
paths: matches,
|
|
172
|
+
suggested_action: "Remove coverage reports and .simplecov from spec.files."
|
|
173
|
+
)
|
|
174
|
+
]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def check_editor_configs
|
|
178
|
+
matches = @effective_files.select do |path|
|
|
179
|
+
p = path.downcase
|
|
180
|
+
p.start_with?(".idea/", ".vscode/") ||
|
|
181
|
+
%w[.editorconfig .project .classpath .settings].include?(File.basename(p)) ||
|
|
182
|
+
p.end_with?(".swp", ".swo", "~")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
return [] if matches.empty?
|
|
186
|
+
|
|
187
|
+
[
|
|
188
|
+
Model::Finding.new(
|
|
189
|
+
rule: "RUBYLEAN_EDITOR_CONFIG",
|
|
190
|
+
severity: "warning",
|
|
191
|
+
message: "Editor or IDE configuration files are included in the package.",
|
|
192
|
+
paths: matches,
|
|
193
|
+
suggested_action: "Exclude editor and IDE configuration directories and files from spec.files."
|
|
194
|
+
)
|
|
195
|
+
]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def check_build_tools
|
|
199
|
+
build_tools_filenames = %w[
|
|
200
|
+
gemfile gemfile.lock rakefile .rubocop.yml .rubocop_todo.yml
|
|
201
|
+
.standard.yml .yardopts brewfile procfile .tool-versions .ruby-version
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
matches = @effective_files.select do |path|
|
|
205
|
+
base = File.basename(path).downcase
|
|
206
|
+
build_tools_filenames.include?(base) || path.start_with?("bin/setup", "bin/console")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return [] if matches.empty?
|
|
210
|
+
|
|
211
|
+
[
|
|
212
|
+
Model::Finding.new(
|
|
213
|
+
rule: "RUBYLEAN_BUILD_TOOL",
|
|
214
|
+
severity: "warning",
|
|
215
|
+
message: "Development build tools, linters, or lockfiles are included in the package.",
|
|
216
|
+
paths: matches,
|
|
217
|
+
suggested_action: "Exclude Gemfile, Rakefile, linters, and development scripts from spec.files."
|
|
218
|
+
)
|
|
219
|
+
]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def check_vcs_metadata
|
|
223
|
+
matches = @effective_files.select do |path|
|
|
224
|
+
p = path.downcase
|
|
225
|
+
p.start_with?(".git/", ".hg/", ".svn/") ||
|
|
226
|
+
%w[.gitignore .gitattributes .gitmodules].include?(File.basename(p))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return [] if matches.empty?
|
|
230
|
+
|
|
231
|
+
[
|
|
232
|
+
Model::Finding.new(
|
|
233
|
+
rule: "RUBYLEAN_VCS_METADATA",
|
|
234
|
+
severity: "warning",
|
|
235
|
+
message: "Version control system (VCS) metadata or configuration files are included in the package.",
|
|
236
|
+
paths: matches,
|
|
237
|
+
suggested_action: "Exclude .gitignore, .gitattributes, and other repository metadata from spec.files."
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def check_symlinks
|
|
243
|
+
matches = @effective_files.select do |path|
|
|
244
|
+
full_path = File.join(@gem_root, path)
|
|
245
|
+
File.symlink?(full_path)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
return [] if matches.empty?
|
|
249
|
+
|
|
250
|
+
[
|
|
251
|
+
Model::Finding.new(
|
|
252
|
+
rule: "RUBYLEAN_SYMLINK",
|
|
253
|
+
severity: "warning",
|
|
254
|
+
message: "Symbolic link included in package files.",
|
|
255
|
+
paths: matches,
|
|
256
|
+
suggested_action: "Replace symlinks with regular files or ensure target files are valid package contents."
|
|
257
|
+
)
|
|
258
|
+
]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_git_dependency
|
|
262
|
+
return [] unless @package.git_dependent
|
|
263
|
+
|
|
264
|
+
[
|
|
265
|
+
Model::Finding.new(
|
|
266
|
+
rule: "RUBYLEAN_GIT_DEPENDENT",
|
|
267
|
+
severity: "warning",
|
|
268
|
+
message: "Gemspec uses dynamic git ls-files discovery, which may include unneeded files or fail in non-git environments.",
|
|
269
|
+
paths: [@package.relative_gemspec_path],
|
|
270
|
+
suggested_action: "Use explicit file lists or filtered Dir.glob expressions instead of invoking git ls-files."
|
|
271
|
+
)
|
|
272
|
+
]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def check_archive_mismatches
|
|
276
|
+
gemspec_set = @effective_files.to_set
|
|
277
|
+
archive_set = @archive_files.to_set
|
|
278
|
+
|
|
279
|
+
missing_in_archive = (gemspec_set - archive_set).to_a.sort
|
|
280
|
+
extra_in_archive = (archive_set - gemspec_set).to_a.sort
|
|
281
|
+
|
|
282
|
+
findings = []
|
|
283
|
+
if missing_in_archive.any? || extra_in_archive.any?
|
|
284
|
+
discrepancy_details = []
|
|
285
|
+
discrepancy_details.concat(missing_in_archive.map { |f| "#{f} (missing in .gem archive)" })
|
|
286
|
+
discrepancy_details.concat(extra_in_archive.map { |f| "#{f} (present in archive but not in gemspec)" })
|
|
287
|
+
|
|
288
|
+
findings << Model::Finding.new(
|
|
289
|
+
rule: "RUBYLEAN_ARCHIVE_MISMATCH",
|
|
290
|
+
severity: "error",
|
|
291
|
+
message: "Discrepancy detected between gemspec declared files and actual built .gem archive contents.",
|
|
292
|
+
paths: missing_in_archive + extra_in_archive,
|
|
293
|
+
suggested_action: "Review gemspec file patterns and build process to ensure archive contents match expectations."
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
findings
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
data/lib/ruby_lean.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_lean/version"
|
|
4
|
+
require_relative "ruby_lean/model/finding"
|
|
5
|
+
require_relative "ruby_lean/model/package"
|
|
6
|
+
require_relative "ruby_lean/model/result"
|
|
7
|
+
require_relative "ruby_lean/gemspec_loader"
|
|
8
|
+
require_relative "ruby_lean/archive_inspector"
|
|
9
|
+
require_relative "ruby_lean/rules/engine"
|
|
10
|
+
require_relative "ruby_lean/reporters/text_reporter"
|
|
11
|
+
require_relative "ruby_lean/reporters/json_reporter"
|
|
12
|
+
require_relative "ruby_lean/command/lint"
|
|
13
|
+
require_relative "ruby_lean/cli"
|
|
14
|
+
|
|
15
|
+
module RubyLean
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class ExecutionError < Error; end
|
|
18
|
+
class GemspecLoadError < ExecutionError; end
|
|
19
|
+
class BuildError < ExecutionError; end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby-lean
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- lean-packages contributors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: A Ruby-native CLI to audit RubyGem package distributions, detect unnecessary
|
|
14
|
+
files, and inspect archive contents.
|
|
15
|
+
email:
|
|
16
|
+
- maintainers@lean-packages.org
|
|
17
|
+
executables:
|
|
18
|
+
- ruby-lean
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.md
|
|
24
|
+
- README.md
|
|
25
|
+
- exe/ruby-lean
|
|
26
|
+
- lib/ruby_lean.rb
|
|
27
|
+
- lib/ruby_lean/archive_inspector.rb
|
|
28
|
+
- lib/ruby_lean/cli.rb
|
|
29
|
+
- lib/ruby_lean/command/lint.rb
|
|
30
|
+
- lib/ruby_lean/gemspec_loader.rb
|
|
31
|
+
- lib/ruby_lean/model/finding.rb
|
|
32
|
+
- lib/ruby_lean/model/package.rb
|
|
33
|
+
- lib/ruby_lean/model/result.rb
|
|
34
|
+
- lib/ruby_lean/reporters/json_reporter.rb
|
|
35
|
+
- lib/ruby_lean/reporters/text_reporter.rb
|
|
36
|
+
- lib/ruby_lean/rules/engine.rb
|
|
37
|
+
- lib/ruby_lean/version.rb
|
|
38
|
+
homepage: https://github.com/lean-packages/ruby-lean
|
|
39
|
+
licenses:
|
|
40
|
+
- MIT
|
|
41
|
+
metadata:
|
|
42
|
+
homepage_uri: https://github.com/lean-packages/ruby-lean
|
|
43
|
+
source_code_uri: https://github.com/lean-packages/ruby-lean
|
|
44
|
+
changelog_uri: https://github.com/lean-packages/ruby-lean/blob/main/CHANGELOG.md
|
|
45
|
+
post_install_message:
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 2.6.0
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.5.22
|
|
61
|
+
signing_key:
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Audits RubyGem distribution contents using native RubyGems packaging rules
|
|
64
|
+
test_files: []
|