monkey_lens 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 +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/assets/brand/monkeylens-lockup.svg +18 -0
- data/assets/brand/monkeylens-mark.svg +15 -0
- data/exe/monkeylens +6 -0
- data/lib/monkey_lens/capture.rb +87 -0
- data/lib/monkey_lens/change.rb +35 -0
- data/lib/monkey_lens/cli.rb +165 -0
- data/lib/monkey_lens/config.rb +45 -0
- data/lib/monkey_lens/diff.rb +123 -0
- data/lib/monkey_lens/formatter/json.rb +17 -0
- data/lib/monkey_lens/formatter/sarif.rb +47 -0
- data/lib/monkey_lens/formatter/text.rb +30 -0
- data/lib/monkey_lens/formatter.rb +21 -0
- data/lib/monkey_lens/railtie.rb +10 -0
- data/lib/monkey_lens/rake_task.rb +36 -0
- data/lib/monkey_lens/snapshot.rb +52 -0
- data/lib/monkey_lens/version.rb +5 -0
- data/lib/monkey_lens.rb +27 -0
- data/sig/monkey_lens.rbs +51 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d1f86a074dcc3f3a8e7294549267b5a45db15a4e421a155adaf2b4ac6c562853
|
|
4
|
+
data.tar.gz: 8260182a2b09f07a983d6a5ba481dc1849a1f1ade79afd94710df7d85a76fced
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 526967dd0425e75440c63878be6e34a28deab3d777d022337a340b527855defb439438c1bf5a47057f888734ba515b10fc4ed0f66fd63d281cb09c17bea5a98d
|
|
7
|
+
data.tar.gz: a9dacd19531ff3934fe76b8a96192e3b2e5a38f911531ea8d535acd25d68a4d1d53231b5fdea1e5913439e64eb1c740304ececf2f26609f95e7b87c888120713
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to MonkeyLens are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0 — 2026-08-04
|
|
6
|
+
|
|
7
|
+
- Initial runtime snapshot and drift engine.
|
|
8
|
+
- CLI commands for capture, check, diff, inspect, and doctor.
|
|
9
|
+
- Text, JSON, and SARIF formatters.
|
|
10
|
+
- Rake and Rails integrations.
|
|
11
|
+
- RBS signatures, CI, Pages, release, and RubyGems publishing workflows.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Looney
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/brand/monkeylens-lockup.svg" alt="MonkeyLens — make runtime patches visible" width="760">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Audit monkey patches, method ownership, prepend layers, and Ruby runtime drift.</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/theworker02/monkeylens/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/theworker02/monkeylens/actions/workflows/ci.yml/badge.svg"></a>
|
|
11
|
+
<a href="https://rubygems.org/gems/monkey_lens"><img alt="RubyGems" src="https://img.shields.io/gem/v/monkey_lens.svg"></a>
|
|
12
|
+
<a href="https://theworker02.github.io/monkeylens/"><img alt="Website" src="https://img.shields.io/badge/website-GitHub%20Pages-f97316.svg"></a>
|
|
13
|
+
<a href="LICENSE.txt"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
MonkeyLens captures the effective Ruby method table for selected classes and modules, records who owns every method, tracks source locations, visibility, signatures, and ancestor order, then compares that runtime against a committed baseline.
|
|
17
|
+
|
|
18
|
+
It turns invisible runtime mutation into reviewable evidence.
|
|
19
|
+
|
|
20
|
+
## Why MonkeyLens?
|
|
21
|
+
|
|
22
|
+
Ruby deliberately makes classes open. That flexibility is powerful, but a production process can behave differently because a gem reopened a core class, a concern used `prepend`, a test helper redefined a method, or load order changed which implementation won.
|
|
23
|
+
|
|
24
|
+
MonkeyLens answers:
|
|
25
|
+
|
|
26
|
+
- Which methods were added, removed, or redefined?
|
|
27
|
+
- Which module currently owns the method Ruby will dispatch?
|
|
28
|
+
- Did a `prepend` layer enter or leave the ancestor chain?
|
|
29
|
+
- Did arity, parameters, visibility, or source location change?
|
|
30
|
+
- Does CI boot with the same runtime shape as the approved baseline?
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Add the gem to your bundle:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "monkey_lens"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then run:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
After release, direct installation will be:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
gem install monkey_lens
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Create `.monkeylens.yml`:
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
targets:
|
|
58
|
+
- String
|
|
59
|
+
- Array
|
|
60
|
+
- MyApp::User
|
|
61
|
+
fail_on: high
|
|
62
|
+
format: text
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Capture the approved runtime:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bundle exec monkeylens capture --output .monkeylens.json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Check for drift:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bundle exec monkeylens check
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## CLI
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
monkeylens capture Capture a runtime baseline
|
|
81
|
+
monkeylens check Compare the current runtime with a baseline
|
|
82
|
+
monkeylens diff Compare two saved snapshots
|
|
83
|
+
monkeylens inspect Explain one target's effective method table
|
|
84
|
+
monkeylens doctor Validate configuration and runtime support
|
|
85
|
+
monkeylens version Print the installed version
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Load an application before capture
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
bundle exec monkeylens capture \
|
|
92
|
+
--require ./config/environment \
|
|
93
|
+
--output .monkeylens.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### JSON or SARIF output
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
bundle exec monkeylens check --format json
|
|
100
|
+
bundle exec monkeylens check --format sarif > monkeylens.sarif
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
SARIF output can be uploaded to GitHub code scanning or processed by other security tooling.
|
|
104
|
+
|
|
105
|
+
## Library API
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
require "monkey_lens"
|
|
109
|
+
|
|
110
|
+
baseline = MonkeyLens.capture(targets: ["String", "MyApp::User"])
|
|
111
|
+
baseline.write(".monkeylens.json")
|
|
112
|
+
|
|
113
|
+
current = MonkeyLens.capture(targets: ["String", "MyApp::User"])
|
|
114
|
+
result = MonkeyLens.diff(baseline, current)
|
|
115
|
+
|
|
116
|
+
abort result.to_text unless result.clean?
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Rake integration
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
require "monkey_lens/rake_task"
|
|
123
|
+
|
|
124
|
+
MonkeyLens::RakeTask.new do |task|
|
|
125
|
+
task.config = ".monkeylens.yml"
|
|
126
|
+
task.baseline = ".monkeylens.json"
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This creates `monkey_lens:capture` and `monkey_lens:check` tasks.
|
|
131
|
+
|
|
132
|
+
## Rails integration
|
|
133
|
+
|
|
134
|
+
Add MonkeyLens to your development and test groups. Its Railtie adds the same Rake tasks after the application environment loads.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
group :development, :test do
|
|
138
|
+
gem "monkey_lens"
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Change severities
|
|
143
|
+
|
|
144
|
+
| Change | Default severity |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| Method owner changed | Critical |
|
|
147
|
+
| Method source changed | High |
|
|
148
|
+
| Method removed | High |
|
|
149
|
+
| Prepend/ancestor order changed | High |
|
|
150
|
+
| Method signature changed | Medium |
|
|
151
|
+
| Visibility changed | Medium |
|
|
152
|
+
| Method added | Low |
|
|
153
|
+
|
|
154
|
+
The threshold is configured with `fail_on: low|medium|high|critical`.
|
|
155
|
+
|
|
156
|
+
## Design guarantees
|
|
157
|
+
|
|
158
|
+
- Does not execute arbitrary project files unless explicitly passed with `--require`.
|
|
159
|
+
- Does not upload runtime information.
|
|
160
|
+
- Produces deterministic JSON for the same runtime state.
|
|
161
|
+
- Uses Ruby's public reflection APIs.
|
|
162
|
+
- Supports Ruby 3.2 and newer.
|
|
163
|
+
|
|
164
|
+
## Documentation
|
|
165
|
+
|
|
166
|
+
- [Configuration](docs/configuration.md)
|
|
167
|
+
- [Architecture](docs/architecture.md)
|
|
168
|
+
- [Release process](docs/releasing.md)
|
|
169
|
+
- [Brand guidelines](docs/branding.md)
|
|
170
|
+
- [Security policy](SECURITY.md)
|
|
171
|
+
- [Contributing](CONTRIBUTING.md)
|
|
172
|
+
|
|
173
|
+
## Funding
|
|
174
|
+
|
|
175
|
+
MonkeyLens supports:
|
|
176
|
+
|
|
177
|
+
- [GitHub Sponsors](https://github.com/sponsors/theworker02)
|
|
178
|
+
- [thanks.dev](https://thanks.dev/u/gh/theworker02)
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT © 2026 Matthew Looney
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 360" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">MonkeyLens logo</title>
|
|
3
|
+
<desc id="desc">A lens inspecting a Ruby method chain beside the MonkeyLens wordmark.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="lens" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fb923c"/><stop offset="1" stop-color="#dc2626"/></linearGradient>
|
|
6
|
+
<linearGradient id="ruby" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ef4444"/><stop offset="1" stop-color="#991b1b"/></linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<g transform="translate(34 18) scale(.63)">
|
|
9
|
+
<circle cx="228" cy="218" r="146" fill="#111827" stroke="url(#lens)" stroke-width="25"/>
|
|
10
|
+
<path d="M331 321 456 446" stroke="#f97316" stroke-width="42" stroke-linecap="round"/>
|
|
11
|
+
<path d="M142 208c35-70 137-81 185-18-44 69-139 78-185 18Z" fill="#0f172a" stroke="#fdba74" stroke-width="13"/>
|
|
12
|
+
<circle cx="234" cy="207" r="39" fill="url(#ruby)" stroke="#fecaca" stroke-width="9"/>
|
|
13
|
+
<path d="m206 207 22-25 30 13-5 34-34 5-13-27Z" fill="#fff1f2" opacity=".9"/>
|
|
14
|
+
<path d="M96 113h92M105 151h61M286 111h76M308 151h65M91 292h83M292 287h77" stroke="#fb923c" stroke-width="12" stroke-linecap="round" opacity=".9"/>
|
|
15
|
+
</g>
|
|
16
|
+
<text x="390" y="168" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="112" font-weight="800" letter-spacing="-5" fill="#111827">MonkeyLens</text>
|
|
17
|
+
<text x="397" y="235" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="32" font-weight="600" letter-spacing="2.6" fill="#64748b">MAKE RUNTIME PATCHES VISIBLE</text>
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">MonkeyLens mark</title>
|
|
3
|
+
<desc id="desc">A magnifying lens inspecting a Ruby method chain.</desc>
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="lens" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fb923c"/><stop offset="1" stop-color="#dc2626"/></linearGradient>
|
|
6
|
+
<linearGradient id="ruby" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ef4444"/><stop offset="1" stop-color="#991b1b"/></linearGradient>
|
|
7
|
+
<filter id="shadow" x="-20%" y="-20%" width="150%" height="150%"><feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#020617" flood-opacity=".35"/></filter>
|
|
8
|
+
</defs>
|
|
9
|
+
<circle cx="220" cy="218" r="148" fill="#111827" stroke="url(#lens)" stroke-width="26" filter="url(#shadow)"/>
|
|
10
|
+
<path d="M327 325 459 457" stroke="#f97316" stroke-width="44" stroke-linecap="round"/>
|
|
11
|
+
<path d="M132 208c35-70 137-81 185-18-44 69-139 78-185 18Z" fill="#0f172a" stroke="#fdba74" stroke-width="13"/>
|
|
12
|
+
<circle cx="224" cy="207" r="40" fill="url(#ruby)" stroke="#fecaca" stroke-width="9"/>
|
|
13
|
+
<path d="m196 207 22-26 31 14-5 34-35 5-13-27Z" fill="#fff1f2" opacity=".9"/>
|
|
14
|
+
<path d="M84 111h94M94 151h62M278 108h78M301 149h65M80 294h83M283 290h78" stroke="#fb923c" stroke-width="12" stroke-linecap="round" opacity=".9"/>
|
|
15
|
+
</svg>
|
data/exe/monkeylens
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module MonkeyLens
|
|
6
|
+
class Capture
|
|
7
|
+
VISIBILITIES = %i[public protected private].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(targets:, ignore_methods: [])
|
|
10
|
+
@targets = targets
|
|
11
|
+
@ignore_methods = ignore_methods.to_set
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
records = @targets.sort.to_h do |target_name|
|
|
16
|
+
target = constantize(target_name)
|
|
17
|
+
[target_name, capture_target(target_name, target)]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Snapshot.new(
|
|
21
|
+
schema: Snapshot::SCHEMA,
|
|
22
|
+
ruby: RUBY_DESCRIPTION,
|
|
23
|
+
engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby",
|
|
24
|
+
targets: records
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def constantize(name)
|
|
31
|
+
constant = name.split("::").reject(&:empty?).inject(Object) { |scope, part| scope.const_get(part, false) }
|
|
32
|
+
return constant if constant.is_a?(Module)
|
|
33
|
+
|
|
34
|
+
raise ConfigurationError, "#{name} is not a class or module"
|
|
35
|
+
rescue NameError => error
|
|
36
|
+
raise ConfigurationError, "cannot resolve target #{name}: #{error.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def capture_target(name, target)
|
|
40
|
+
{
|
|
41
|
+
"kind" => target.is_a?(Class) ? "class" : "module",
|
|
42
|
+
"ancestors" => target.ancestors.map { |ancestor| constant_name(ancestor) },
|
|
43
|
+
"instance_methods" => capture_methods(name, target, singleton: false),
|
|
44
|
+
"singleton_methods" => capture_methods(name, target.singleton_class, singleton: true)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def capture_methods(target_name, receiver, singleton:)
|
|
49
|
+
names = VISIBILITIES.flat_map { |visibility| receiver.public_send("#{visibility}_instance_methods", true) }.uniq
|
|
50
|
+
|
|
51
|
+
names.sort_by(&:to_s).filter_map do |method_name|
|
|
52
|
+
identifier = singleton ? "#{target_name}.#{method_name}" : "#{target_name}##{method_name}"
|
|
53
|
+
next if @ignore_methods.include?(identifier)
|
|
54
|
+
|
|
55
|
+
method = receiver.instance_method(method_name)
|
|
56
|
+
visibility = VISIBILITIES.find { |candidate| receiver.public_send("#{candidate}_method_defined?", method_name) }
|
|
57
|
+
|
|
58
|
+
[method_name.to_s, {
|
|
59
|
+
"owner" => constant_name(method.owner),
|
|
60
|
+
"visibility" => visibility.to_s,
|
|
61
|
+
"parameters" => method.parameters.map { |kind, parameter| [kind.to_s, parameter&.to_s] },
|
|
62
|
+
"arity" => method.arity,
|
|
63
|
+
"source_location" => normalize_source(method.source_location)
|
|
64
|
+
}]
|
|
65
|
+
rescue NameError
|
|
66
|
+
nil
|
|
67
|
+
end.to_h
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def constant_name(value)
|
|
71
|
+
value.name || value.inspect
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def normalize_source(location)
|
|
75
|
+
return nil unless location
|
|
76
|
+
|
|
77
|
+
path, line = location
|
|
78
|
+
[normalize_path(path), line]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_path(path)
|
|
82
|
+
absolute = File.expand_path(path)
|
|
83
|
+
cwd = File.expand_path(Dir.pwd)
|
|
84
|
+
absolute.start_with?("#{cwd}/") ? absolute.delete_prefix("#{cwd}/") : absolute
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MonkeyLens
|
|
4
|
+
class Change
|
|
5
|
+
LEVELS = {"low" => 1, "medium" => 2, "high" => 3, "critical" => 4}.freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :type, :severity, :target, :method_id, :before, :after, :message
|
|
8
|
+
|
|
9
|
+
def initialize(type:, severity:, target:, method_id: nil, before: nil, after: nil, message:)
|
|
10
|
+
@type = type
|
|
11
|
+
@severity = severity
|
|
12
|
+
@target = target
|
|
13
|
+
@method_id = method_id
|
|
14
|
+
@before = before
|
|
15
|
+
@after = after
|
|
16
|
+
@message = message
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def level
|
|
20
|
+
LEVELS.fetch(severity)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
"type" => type,
|
|
26
|
+
"severity" => severity,
|
|
27
|
+
"target" => target,
|
|
28
|
+
"method" => method_id,
|
|
29
|
+
"message" => message,
|
|
30
|
+
"before" => before,
|
|
31
|
+
"after" => after
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../monkey_lens"
|
|
5
|
+
|
|
6
|
+
module MonkeyLens
|
|
7
|
+
class CLI
|
|
8
|
+
class << self
|
|
9
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
10
|
+
new(argv, out:, err:).run
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(argv, out:, err:)
|
|
15
|
+
@argv = argv.dup
|
|
16
|
+
@out = out
|
|
17
|
+
@err = err
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
command = @argv.shift
|
|
22
|
+
case command
|
|
23
|
+
when "capture" then capture
|
|
24
|
+
when "check" then check
|
|
25
|
+
when "diff" then diff
|
|
26
|
+
when "inspect" then inspect_target
|
|
27
|
+
when "doctor" then doctor
|
|
28
|
+
when "version", "--version", "-v" then version
|
|
29
|
+
when nil, "help", "--help", "-h" then help
|
|
30
|
+
else
|
|
31
|
+
@err.puts "Unknown command: #{command}"
|
|
32
|
+
help(@err)
|
|
33
|
+
2
|
|
34
|
+
end
|
|
35
|
+
rescue OptionParser::ParseError, MonkeyLens::Error, Errno::ENOENT => error
|
|
36
|
+
@err.puts "MonkeyLens error: #{error.message}"
|
|
37
|
+
2
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def capture
|
|
43
|
+
options = common_options.merge(output: ".monkeylens.json")
|
|
44
|
+
parser = parser_for("capture", options) do |opts|
|
|
45
|
+
opts.on("-o", "--output PATH", "Snapshot output path") { |value| options[:output] = value }
|
|
46
|
+
end
|
|
47
|
+
parser.parse!(@argv)
|
|
48
|
+
config = load_runtime(options)
|
|
49
|
+
snapshot = MonkeyLens.capture(targets: config.targets, ignore_methods: config.ignore_methods)
|
|
50
|
+
snapshot.write(options[:output])
|
|
51
|
+
@out.puts "Captured #{snapshot.targets.length} target(s) to #{options[:output]}"
|
|
52
|
+
0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check
|
|
56
|
+
options = common_options.merge(baseline: nil, format: nil, fail_on: nil)
|
|
57
|
+
parser = parser_for("check", options) do |opts|
|
|
58
|
+
opts.on("-b", "--baseline PATH", "Baseline path") { |value| options[:baseline] = value }
|
|
59
|
+
opts.on("-f", "--format FORMAT", %w[text json sarif], "Report format") { |value| options[:format] = value }
|
|
60
|
+
opts.on("--fail-on LEVEL", Config::LEVELS, "Failure threshold") { |value| options[:fail_on] = value }
|
|
61
|
+
end
|
|
62
|
+
parser.parse!(@argv)
|
|
63
|
+
config = load_runtime(options)
|
|
64
|
+
baseline = Snapshot.read(options[:baseline] || config.baseline)
|
|
65
|
+
current = MonkeyLens.capture(targets: config.targets, ignore_methods: config.ignore_methods)
|
|
66
|
+
result = MonkeyLens.diff(baseline, current)
|
|
67
|
+
format = options[:format] || config.format
|
|
68
|
+
@out.puts Formatter.render(result, format:)
|
|
69
|
+
result.failure?(options[:fail_on] || config.fail_on) ? 1 : 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def diff
|
|
73
|
+
options = {format: "text", fail_on: nil}
|
|
74
|
+
parser = OptionParser.new do |opts|
|
|
75
|
+
opts.banner = "Usage: monkeylens diff BASELINE CURRENT [options]"
|
|
76
|
+
opts.on("-f", "--format FORMAT", %w[text json sarif]) { |value| options[:format] = value }
|
|
77
|
+
opts.on("--fail-on LEVEL", Config::LEVELS) { |value| options[:fail_on] = value }
|
|
78
|
+
end
|
|
79
|
+
parser.parse!(@argv)
|
|
80
|
+
baseline_path, current_path = @argv
|
|
81
|
+
raise OptionParser::MissingArgument, "BASELINE and CURRENT are required" unless baseline_path && current_path
|
|
82
|
+
|
|
83
|
+
result = MonkeyLens.diff(Snapshot.read(baseline_path), Snapshot.read(current_path))
|
|
84
|
+
@out.puts Formatter.render(result, format: options[:format])
|
|
85
|
+
options[:fail_on] && result.failure?(options[:fail_on]) ? 1 : 0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inspect_target
|
|
89
|
+
options = common_options
|
|
90
|
+
parser = parser_for("inspect", options)
|
|
91
|
+
parser.parse!(@argv)
|
|
92
|
+
target = @argv.shift
|
|
93
|
+
raise OptionParser::MissingArgument, "TARGET is required" unless target
|
|
94
|
+
|
|
95
|
+
load_requires(options[:requires])
|
|
96
|
+
snapshot = MonkeyLens.capture(targets: [target])
|
|
97
|
+
record = snapshot.targets.fetch(target)
|
|
98
|
+
@out.puts "#{target} (#{record.fetch("kind")})"
|
|
99
|
+
@out.puts "Ancestors: #{record.fetch("ancestors").join(" -> ")}"
|
|
100
|
+
record.fetch("instance_methods").each do |name, method|
|
|
101
|
+
@out.puts " ##{name} [#{method.fetch("visibility")}] owner=#{method.fetch("owner")} source=#{method.fetch("source_location")&.join(":") || "native"}"
|
|
102
|
+
end
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def doctor
|
|
107
|
+
options = common_options
|
|
108
|
+
parser_for("doctor", options).parse!(@argv)
|
|
109
|
+
config = Config.load(options[:config])
|
|
110
|
+
@out.puts "MonkeyLens #{VERSION}"
|
|
111
|
+
@out.puts RUBY_DESCRIPTION
|
|
112
|
+
@out.puts "Config: #{options[:config]}"
|
|
113
|
+
@out.puts "Baseline: #{File.exist?(config.baseline) ? config.baseline : "not found"}"
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def version
|
|
118
|
+
@out.puts VERSION
|
|
119
|
+
0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def help(io = @out)
|
|
123
|
+
io.puts <<~HELP
|
|
124
|
+
MonkeyLens #{VERSION} — make Ruby runtime patches visible
|
|
125
|
+
|
|
126
|
+
Usage: monkeylens COMMAND [options]
|
|
127
|
+
|
|
128
|
+
Commands:
|
|
129
|
+
capture Capture a runtime baseline
|
|
130
|
+
check Compare the current runtime against a baseline
|
|
131
|
+
diff Compare two saved snapshots
|
|
132
|
+
inspect Explain a target's effective method table
|
|
133
|
+
doctor Validate configuration and runtime support
|
|
134
|
+
version Print the installed version
|
|
135
|
+
HELP
|
|
136
|
+
0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def common_options
|
|
140
|
+
{config: ".monkeylens.yml", requires: []}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parser_for(command, options)
|
|
144
|
+
OptionParser.new do |opts|
|
|
145
|
+
opts.banner = "Usage: monkeylens #{command} [options]"
|
|
146
|
+
opts.on("-c", "--config PATH", "Configuration path") { |value| options[:config] = value }
|
|
147
|
+
opts.on("-r", "--require PATH", "Require an application file before capture") { |value| options[:requires] << value }
|
|
148
|
+
yield opts if block_given?
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def load_runtime(options)
|
|
153
|
+
config = Config.load(options[:config])
|
|
154
|
+
load_requires(config.requires + options[:requires])
|
|
155
|
+
config
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def load_requires(paths)
|
|
159
|
+
paths.each do |path|
|
|
160
|
+
expanded = File.expand_path(path)
|
|
161
|
+
require expanded
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module MonkeyLens
|
|
6
|
+
class Config
|
|
7
|
+
LEVELS = %w[low medium high critical].freeze
|
|
8
|
+
FORMATS = %w[text json sarif].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :targets, :ignore_methods, :fail_on, :format, :baseline, :requires
|
|
11
|
+
|
|
12
|
+
def initialize(targets:, ignore_methods: [], fail_on: "high", format: "text", baseline: ".monkeylens.json", requires: [])
|
|
13
|
+
@targets = Array(targets).map(&:to_s).uniq
|
|
14
|
+
@ignore_methods = Array(ignore_methods).map(&:to_s).uniq
|
|
15
|
+
@fail_on = fail_on.to_s
|
|
16
|
+
@format = format.to_s
|
|
17
|
+
@baseline = baseline.to_s
|
|
18
|
+
@requires = Array(requires).map(&:to_s)
|
|
19
|
+
validate!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.load(path = ".monkeylens.yml")
|
|
23
|
+
data = File.exist?(path) ? YAML.safe_load_file(path, permitted_classes: [], aliases: false) : {}
|
|
24
|
+
data ||= {}
|
|
25
|
+
new(
|
|
26
|
+
targets: data.fetch("targets", []),
|
|
27
|
+
ignore_methods: data.fetch("ignore_methods", []),
|
|
28
|
+
fail_on: data.fetch("fail_on", "high"),
|
|
29
|
+
format: data.fetch("format", "text"),
|
|
30
|
+
baseline: data.fetch("baseline", ".monkeylens.json"),
|
|
31
|
+
requires: data.fetch("requires", [])
|
|
32
|
+
)
|
|
33
|
+
rescue Psych::Exception => error
|
|
34
|
+
raise ConfigurationError, "invalid YAML in #{path}: #{error.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate!
|
|
40
|
+
raise ConfigurationError, "targets must contain at least one class or module name" if targets.empty?
|
|
41
|
+
raise ConfigurationError, "fail_on must be one of #{LEVELS.join(", ")}" unless LEVELS.include?(fail_on)
|
|
42
|
+
raise ConfigurationError, "format must be one of #{FORMATS.join(", ")}" unless FORMATS.include?(format)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MonkeyLens
|
|
4
|
+
class Diff
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :baseline, :current, :changes
|
|
7
|
+
|
|
8
|
+
def initialize(baseline:, current:, changes:)
|
|
9
|
+
@baseline = baseline
|
|
10
|
+
@current = current
|
|
11
|
+
@changes = changes.sort_by { |change| [-change.level, change.target, change.method_id.to_s, change.type] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def clean?
|
|
15
|
+
changes.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def failure?(threshold)
|
|
19
|
+
minimum = Change::LEVELS.fetch(threshold.to_s)
|
|
20
|
+
changes.any? { |change| change.level >= minimum }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
"schema" => 1,
|
|
26
|
+
"clean" => clean?,
|
|
27
|
+
"summary" => Change::LEVELS.keys.to_h { |level| [level, changes.count { |change| change.severity == level }] },
|
|
28
|
+
"changes" => changes.map(&:to_h)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_text
|
|
33
|
+
Formatter::Text.new(self).render
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(baseline:, current:)
|
|
38
|
+
@baseline = baseline
|
|
39
|
+
@current = current
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call
|
|
43
|
+
changes = []
|
|
44
|
+
target_names = (@baseline.targets.keys | @current.targets.keys).sort
|
|
45
|
+
target_names.each { |target| compare_target(target, changes) }
|
|
46
|
+
Result.new(baseline: @baseline, current: @current, changes:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def compare_target(target, changes)
|
|
52
|
+
before = @baseline.targets[target]
|
|
53
|
+
after = @current.targets[target]
|
|
54
|
+
|
|
55
|
+
unless before
|
|
56
|
+
changes << change("target_added", "low", target, message: "target entered the runtime snapshot", after:)
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
unless after
|
|
60
|
+
changes << change("target_removed", "critical", target, message: "target disappeared from the runtime snapshot", before:)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if before.fetch("ancestors") != after.fetch("ancestors")
|
|
65
|
+
changes << change(
|
|
66
|
+
"ancestor_chain_changed",
|
|
67
|
+
"high",
|
|
68
|
+
target,
|
|
69
|
+
before: before.fetch("ancestors"),
|
|
70
|
+
after: after.fetch("ancestors"),
|
|
71
|
+
message: "ancestor/prepend order changed"
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
compare_methods(target, "instance_methods", "#", before, after, changes)
|
|
76
|
+
compare_methods(target, "singleton_methods", ".", before, after, changes)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compare_methods(target, key, separator, before_target, after_target, changes)
|
|
80
|
+
before_methods = before_target.fetch(key)
|
|
81
|
+
after_methods = after_target.fetch(key)
|
|
82
|
+
(before_methods.keys | after_methods.keys).sort.each do |name|
|
|
83
|
+
method_id = "#{target}#{separator}#{name}"
|
|
84
|
+
before = before_methods[name]
|
|
85
|
+
after = after_methods[name]
|
|
86
|
+
|
|
87
|
+
unless before
|
|
88
|
+
changes << change("method_added", "low", target, method_id:, after:, message: "method was added")
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
unless after
|
|
92
|
+
changes << change("method_removed", "high", target, method_id:, before:, message: "method was removed")
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
compare_method_fields(target, method_id, before, after, changes)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def compare_method_fields(target, method_id, before, after, changes)
|
|
101
|
+
if before.fetch("owner") != after.fetch("owner")
|
|
102
|
+
changes << change("owner_changed", "critical", target, method_id:, before: before.fetch("owner"), after: after.fetch("owner"), message: "effective method owner changed")
|
|
103
|
+
end
|
|
104
|
+
if before.fetch("source_location") != after.fetch("source_location")
|
|
105
|
+
changes << change("source_changed", "high", target, method_id:, before: before.fetch("source_location"), after: after.fetch("source_location"), message: "method implementation source changed")
|
|
106
|
+
end
|
|
107
|
+
if before.fetch("parameters") != after.fetch("parameters") || before.fetch("arity") != after.fetch("arity")
|
|
108
|
+
changes << change("signature_changed", "medium", target, method_id:, before: signature(before), after: signature(after), message: "method signature changed")
|
|
109
|
+
end
|
|
110
|
+
if before.fetch("visibility") != after.fetch("visibility")
|
|
111
|
+
changes << change("visibility_changed", "medium", target, method_id:, before: before.fetch("visibility"), after: after.fetch("visibility"), message: "method visibility changed")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def signature(method)
|
|
116
|
+
{"parameters" => method.fetch("parameters"), "arity" => method.fetch("arity")}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def change(type, severity, target, method_id: nil, before: nil, after: nil, message:)
|
|
120
|
+
Change.new(type:, severity:, target:, method_id:, before:, after:, message:)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module MonkeyLens
|
|
6
|
+
module Formatter
|
|
7
|
+
class SARIF
|
|
8
|
+
LEVELS = {"low" => "note", "medium" => "warning", "high" => "error", "critical" => "error"}.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(result)
|
|
11
|
+
@result = result
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
::JSON.pretty_generate(
|
|
16
|
+
{
|
|
17
|
+
"$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
|
|
18
|
+
"version" => "2.1.0",
|
|
19
|
+
"runs" => [{
|
|
20
|
+
"tool" => {"driver" => {"name" => "MonkeyLens", "version" => MonkeyLens::VERSION, "rules" => rules}},
|
|
21
|
+
"results" => results
|
|
22
|
+
}]
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def rules
|
|
30
|
+
@result.changes.map(&:type).uniq.sort.map do |type|
|
|
31
|
+
{"id" => type, "shortDescription" => {"text" => type.tr("_", " ")}}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def results
|
|
36
|
+
@result.changes.map do |change|
|
|
37
|
+
{
|
|
38
|
+
"ruleId" => change.type,
|
|
39
|
+
"level" => LEVELS.fetch(change.severity),
|
|
40
|
+
"message" => {"text" => "#{change.target}#{change.method_id ? " #{change.method_id}" : ""}: #{change.message}"},
|
|
41
|
+
"properties" => change.to_h
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MonkeyLens
|
|
4
|
+
module Formatter
|
|
5
|
+
class Text
|
|
6
|
+
def initialize(result)
|
|
7
|
+
@result = result
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def render
|
|
11
|
+
return "MonkeyLens PASS — no runtime drift detected" if @result.clean?
|
|
12
|
+
|
|
13
|
+
counts = Change::LEVELS.keys.map do |level|
|
|
14
|
+
"#{@result.changes.count { |change| change.severity == level }} #{level}"
|
|
15
|
+
end.join(", ")
|
|
16
|
+
|
|
17
|
+
lines = ["MonkeyLens DRIFT — #{counts}", ""]
|
|
18
|
+
@result.changes.each do |change|
|
|
19
|
+
subject = change.method_id || change.target
|
|
20
|
+
lines << "[#{change.severity.upcase}] #{change.type} #{subject}"
|
|
21
|
+
lines << " #{change.message}"
|
|
22
|
+
lines << " before: #{change.before.inspect}" unless change.before.nil?
|
|
23
|
+
lines << " after: #{change.after.inspect}" unless change.after.nil?
|
|
24
|
+
lines << ""
|
|
25
|
+
end
|
|
26
|
+
lines.join("\n").rstrip
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formatter/text"
|
|
4
|
+
require_relative "formatter/json"
|
|
5
|
+
require_relative "formatter/sarif"
|
|
6
|
+
|
|
7
|
+
module MonkeyLens
|
|
8
|
+
module Formatter
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def render(result, format: "text")
|
|
12
|
+
formatter = case format.to_s
|
|
13
|
+
when "text" then Text
|
|
14
|
+
when "json" then JSON
|
|
15
|
+
when "sarif" then SARIF
|
|
16
|
+
else raise ConfigurationError, "unknown format #{format}"
|
|
17
|
+
end
|
|
18
|
+
formatter.new(result).render
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rake"
|
|
4
|
+
require "monkey_lens/cli"
|
|
5
|
+
|
|
6
|
+
module MonkeyLens
|
|
7
|
+
class RakeTask
|
|
8
|
+
attr_accessor :name, :config, :baseline
|
|
9
|
+
|
|
10
|
+
def initialize(name = :monkey_lens)
|
|
11
|
+
@name = name
|
|
12
|
+
@config = ".monkeylens.yml"
|
|
13
|
+
@baseline = ".monkeylens.json"
|
|
14
|
+
yield self if block_given?
|
|
15
|
+
define_tasks
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def define_tasks
|
|
21
|
+
namespace name do
|
|
22
|
+
desc "Capture a MonkeyLens runtime baseline"
|
|
23
|
+
task :capture do
|
|
24
|
+
status = CLI.run(["capture", "--config", config, "--output", baseline])
|
|
25
|
+
abort "MonkeyLens capture failed" unless status.zero?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Check the current Ruby runtime against the MonkeyLens baseline"
|
|
29
|
+
task :check do
|
|
30
|
+
status = CLI.run(["check", "--config", config, "--baseline", baseline])
|
|
31
|
+
abort "MonkeyLens detected runtime drift" unless status.zero?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module MonkeyLens
|
|
6
|
+
class Snapshot
|
|
7
|
+
SCHEMA = 1
|
|
8
|
+
|
|
9
|
+
attr_reader :schema, :ruby, :engine, :targets
|
|
10
|
+
|
|
11
|
+
def initialize(schema: SCHEMA, ruby:, engine:, targets:)
|
|
12
|
+
@schema = Integer(schema)
|
|
13
|
+
@ruby = ruby.to_s
|
|
14
|
+
@engine = engine.to_s
|
|
15
|
+
@targets = targets.transform_keys(&:to_s)
|
|
16
|
+
validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.read(path)
|
|
20
|
+
data = JSON.parse(File.read(path))
|
|
21
|
+
new(
|
|
22
|
+
schema: data.fetch("schema"),
|
|
23
|
+
ruby: data.fetch("ruby"),
|
|
24
|
+
engine: data.fetch("engine"),
|
|
25
|
+
targets: data.fetch("targets")
|
|
26
|
+
)
|
|
27
|
+
rescue JSON::ParserError, KeyError => error
|
|
28
|
+
raise SnapshotError, "invalid snapshot #{path}: #{error.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write(path)
|
|
32
|
+
File.write(path, "#{JSON.pretty_generate(to_h)}\n")
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_h
|
|
37
|
+
{
|
|
38
|
+
"schema" => schema,
|
|
39
|
+
"ruby" => ruby,
|
|
40
|
+
"engine" => engine,
|
|
41
|
+
"targets" => targets.sort.to_h
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def validate!
|
|
48
|
+
raise SnapshotError, "unsupported snapshot schema #{schema}" unless schema == SCHEMA
|
|
49
|
+
raise SnapshotError, "snapshot targets must be an object" unless targets.is_a?(Hash)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/monkey_lens.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "monkey_lens/version"
|
|
4
|
+
require_relative "monkey_lens/config"
|
|
5
|
+
require_relative "monkey_lens/snapshot"
|
|
6
|
+
require_relative "monkey_lens/change"
|
|
7
|
+
require_relative "monkey_lens/capture"
|
|
8
|
+
require_relative "monkey_lens/diff"
|
|
9
|
+
require_relative "monkey_lens/formatter"
|
|
10
|
+
|
|
11
|
+
module MonkeyLens
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class ConfigurationError < Error; end
|
|
14
|
+
class SnapshotError < Error; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def capture(targets:, ignore_methods: [])
|
|
19
|
+
Capture.new(targets:, ignore_methods:).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diff(baseline, current)
|
|
23
|
+
Diff.new(baseline:, current:).call
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "monkey_lens/railtie" if defined?(Rails::Railtie)
|
data/sig/monkey_lens.rbs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module MonkeyLens
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class ConfigurationError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class SnapshotError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Snapshot
|
|
14
|
+
SCHEMA: Integer
|
|
15
|
+
attr_reader schema: Integer
|
|
16
|
+
attr_reader ruby: String
|
|
17
|
+
attr_reader engine: String
|
|
18
|
+
attr_reader targets: Hash[String, untyped]
|
|
19
|
+
|
|
20
|
+
def self.read: (String path) -> Snapshot
|
|
21
|
+
def write: (String path) -> Snapshot
|
|
22
|
+
def to_h: () -> Hash[String, untyped]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Change
|
|
26
|
+
attr_reader type: String
|
|
27
|
+
attr_reader severity: String
|
|
28
|
+
attr_reader target: String
|
|
29
|
+
attr_reader method_id: String?
|
|
30
|
+
attr_reader before: untyped
|
|
31
|
+
attr_reader after: untyped
|
|
32
|
+
attr_reader message: String
|
|
33
|
+
|
|
34
|
+
def level: () -> Integer
|
|
35
|
+
def to_h: () -> Hash[String, untyped]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Config
|
|
39
|
+
attr_reader targets: Array[String]
|
|
40
|
+
attr_reader ignore_methods: Array[String]
|
|
41
|
+
attr_reader fail_on: String
|
|
42
|
+
attr_reader format: String
|
|
43
|
+
attr_reader baseline: String
|
|
44
|
+
attr_reader requires: Array[String]
|
|
45
|
+
|
|
46
|
+
def self.load: (?String path) -> Config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.capture: (targets: Array[String], ?ignore_methods: Array[String]) -> Snapshot
|
|
50
|
+
def self.diff: (Snapshot baseline, Snapshot current) -> Diff::Result
|
|
51
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: monkey_lens
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Matthew Looney
|
|
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: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: MonkeyLens captures method ownership, source locations, signatures, visibility,
|
|
41
|
+
and ancestor order for selected Ruby classes and modules, then detects runtime drift
|
|
42
|
+
against a committed baseline.
|
|
43
|
+
email:
|
|
44
|
+
- theworker02@users.noreply.github.com
|
|
45
|
+
executables:
|
|
46
|
+
- monkeylens
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- assets/brand/monkeylens-lockup.svg
|
|
54
|
+
- assets/brand/monkeylens-mark.svg
|
|
55
|
+
- exe/monkeylens
|
|
56
|
+
- lib/monkey_lens.rb
|
|
57
|
+
- lib/monkey_lens/capture.rb
|
|
58
|
+
- lib/monkey_lens/change.rb
|
|
59
|
+
- lib/monkey_lens/cli.rb
|
|
60
|
+
- lib/monkey_lens/config.rb
|
|
61
|
+
- lib/monkey_lens/diff.rb
|
|
62
|
+
- lib/monkey_lens/formatter.rb
|
|
63
|
+
- lib/monkey_lens/formatter/json.rb
|
|
64
|
+
- lib/monkey_lens/formatter/sarif.rb
|
|
65
|
+
- lib/monkey_lens/formatter/text.rb
|
|
66
|
+
- lib/monkey_lens/railtie.rb
|
|
67
|
+
- lib/monkey_lens/rake_task.rb
|
|
68
|
+
- lib/monkey_lens/snapshot.rb
|
|
69
|
+
- lib/monkey_lens/version.rb
|
|
70
|
+
- sig/monkey_lens.rbs
|
|
71
|
+
homepage: https://github.com/theworker02/monkeylens
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
bug_tracker_uri: https://github.com/theworker02/monkeylens/issues
|
|
76
|
+
changelog_uri: https://github.com/theworker02/monkeylens/blob/main/CHANGELOG.md
|
|
77
|
+
documentation_uri: https://theworker02.github.io/monkeylens/
|
|
78
|
+
funding_uri: https://github.com/sponsors/theworker02
|
|
79
|
+
homepage_uri: https://github.com/theworker02/monkeylens
|
|
80
|
+
source_code_uri: https://github.com/theworker02/monkeylens
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: Audit monkey patches and Ruby runtime method drift
|
|
99
|
+
test_files: []
|