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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "monkey_lens/cli"
5
+
6
+ exit MonkeyLens::CLI.run(ARGV)
@@ -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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MonkeyLens
6
+ module Formatter
7
+ class JSON
8
+ def initialize(result)
9
+ @result = result
10
+ end
11
+
12
+ def render
13
+ ::JSON.pretty_generate(@result.to_h)
14
+ end
15
+ end
16
+ end
17
+ 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonkeyLens
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ require "monkey_lens/rake_task"
7
+ MonkeyLens::RakeTask.new
8
+ end
9
+ end
10
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonkeyLens
4
+ VERSION = "0.1.0"
5
+ end
@@ -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)
@@ -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: []