monkey_lens 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1f86a074dcc3f3a8e7294549267b5a45db15a4e421a155adaf2b4ac6c562853
4
- data.tar.gz: 8260182a2b09f07a983d6a5ba481dc1849a1f1ade79afd94710df7d85a76fced
3
+ metadata.gz: 731b73d48ea7d97b0d3ce1f66a5f246fee22298053e4011c2f1e3a8fd8060f57
4
+ data.tar.gz: a3ddb66de6351e173ee7db2b785949e1ea602487192c0a899396ef345efe745f
5
5
  SHA512:
6
- metadata.gz: 526967dd0425e75440c63878be6e34a28deab3d777d022337a340b527855defb439438c1bf5a47057f888734ba515b10fc4ed0f66fd63d281cb09c17bea5a98d
7
- data.tar.gz: a9dacd19531ff3934fe76b8a96192e3b2e5a38f911531ea8d535acd25d68a4d1d53231b5fdea1e5913439e64eb1c740304ececf2f26609f95e7b87c888120713
6
+ metadata.gz: 5939da5fffbbb4c203da4f5d2168a3a04e03b578c67e323ef80b54bacbf7265ceec4916bea7cf87502b8977e5d60907e52c1306eb3ec35373f8dae701efb5ab9
7
+ data.tar.gz: 22c9b607eb22bf61e6455346fc10b07eae60d3ef3d8186fcb8e7b238c3996a689381c3fc0b503b1df890db617b48e74a4109f5e31d557af26d0923f488b37d80
data/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to MonkeyLens are documented here.
4
4
 
5
+ ## 0.4.0 — 2026-08-14
6
+
7
+ - Record `original_name`, `aliases`, and `super_owner` on every captured method.
8
+ - Diff `alias_changed` and `super_owner_changed` so alias_method and prepend-layer shifts are visible.
9
+ - Inspect output includes aliases and the super-method owner.
10
+
11
+ ## 0.3.0 — 2026-08-11
12
+
13
+ - Added `MonkeyLens::Provenance` to attribute method `source_location` to gem, app, stdlib, or eval origins.
14
+ - Opt-in provenance capture via `provenance: true` on `MonkeyLens.capture` and `--provenance` on CLI capture/inspect.
15
+ - Included provenance in inspect output and snapshot method records when enabled.
16
+
17
+ ## 0.2.0 — 2026-08-04
18
+
19
+ - Added `MonkeyLens::Policy` for approving known runtime drift without deleting evidence from the baseline.
20
+ - Added wildcard waivers for change type, target, and method identifiers.
21
+ - Required a written reason for every waiver.
22
+ - Added optional ISO-date expiration for temporary approvals.
23
+ - Added YAML policy loading through `MonkeyLens::Policy.load`.
24
+ - Added `Policy::Decision` with effective and waived changes, threshold evaluation, and JSON-ready output.
25
+ - Added the `MonkeyLens.evaluate` convenience API.
26
+
5
27
  ## 0.1.0 — 2026-08-04
6
28
 
7
29
  - Initial runtime snapshot and drift engine.
data/README.md CHANGED
@@ -13,6 +13,14 @@
13
13
  <a href="LICENSE.txt"><img alt="MIT License" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
14
14
  </p>
15
15
 
16
+ <p align="center">
17
+ <a href="https://rubygems.org/gems/monkey_lens"><strong>Official RubyGems gem</strong></a>
18
+ ·
19
+ <a href="https://theworker02.github.io/monkeylens/"><strong>Documentation website</strong></a>
20
+ ·
21
+ <a href="https://github.com/theworker02/monkeylens/releases/latest"><strong>Latest GitHub release</strong></a>
22
+ </p>
23
+
16
24
  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
25
 
18
26
  It turns invisible runtime mutation into reviewable evidence.
@@ -43,7 +51,7 @@ Then run:
43
51
  bundle install
44
52
  ```
45
53
 
46
- After release, direct installation will be:
54
+ Install directly from the official RubyGems release:
47
55
 
48
56
  ```bash
49
57
  gem install monkey_lens
@@ -144,11 +152,13 @@ end
144
152
  | Change | Default severity |
145
153
  | --- | --- |
146
154
  | Method owner changed | Critical |
155
+ | Super-method owner changed | High |
147
156
  | Method source changed | High |
148
157
  | Method removed | High |
149
158
  | Prepend/ancestor order changed | High |
150
159
  | Method signature changed | Medium |
151
160
  | Visibility changed | Medium |
161
+ | Method aliases changed | Medium |
152
162
  | Method added | Low |
153
163
 
154
164
  The threshold is configured with `fail_on: low|medium|high|critical`.
@@ -6,9 +6,10 @@ module MonkeyLens
6
6
  class Capture
7
7
  VISIBILITIES = %i[public protected private].freeze
8
8
 
9
- def initialize(targets:, ignore_methods: [])
9
+ def initialize(targets:, ignore_methods: [], provenance: false)
10
10
  @targets = targets
11
11
  @ignore_methods = ignore_methods.to_set
12
+ @provenance = provenance
12
13
  end
13
14
 
14
15
  def call
@@ -54,14 +55,21 @@ module MonkeyLens
54
55
 
55
56
  method = receiver.instance_method(method_name)
56
57
  visibility = VISIBILITIES.find { |candidate| receiver.public_send("#{candidate}_method_defined?", method_name) }
58
+ aliases = aliases_for(receiver, method_name, method)
59
+ super_method = method.super_method
60
+ original = method.respond_to?(:original_name) ? method.original_name.to_s : method_name.to_s
57
61
 
58
62
  [method_name.to_s, {
59
63
  "owner" => constant_name(method.owner),
60
64
  "visibility" => visibility.to_s,
61
65
  "parameters" => method.parameters.map { |kind, parameter| [kind.to_s, parameter&.to_s] },
62
66
  "arity" => method.arity,
63
- "source_location" => normalize_source(method.source_location)
64
- }]
67
+ "source_location" => normalize_source(method.source_location),
68
+ "provenance" => provenance_for(method.source_location),
69
+ "original_name" => original,
70
+ "aliases" => aliases,
71
+ "super_owner" => super_method && constant_name(super_method.owner)
72
+ }.compact]
65
73
  rescue NameError
66
74
  nil
67
75
  end.to_h
@@ -83,5 +91,21 @@ module MonkeyLens
83
91
  cwd = File.expand_path(Dir.pwd)
84
92
  absolute.start_with?("#{cwd}/") ? absolute.delete_prefix("#{cwd}/") : absolute
85
93
  end
94
+
95
+ def provenance_for(location)
96
+ return nil unless @provenance
97
+
98
+ Provenance.for_source_location(location)&.to_h
99
+ end
100
+
101
+ def aliases_for(receiver, method_name, method)
102
+ receiver.instance_methods(true).filter_map do |other|
103
+ next if other == method_name
104
+
105
+ other.to_s if receiver.instance_method(other) == method
106
+ rescue NameError
107
+ nil
108
+ end.sort
109
+ end
86
110
  end
87
111
  end
@@ -40,13 +40,14 @@ module MonkeyLens
40
40
  private
41
41
 
42
42
  def capture
43
- options = common_options.merge(output: ".monkeylens.json")
43
+ options = common_options.merge(output: ".monkeylens.json", provenance: false)
44
44
  parser = parser_for("capture", options) do |opts|
45
45
  opts.on("-o", "--output PATH", "Snapshot output path") { |value| options[:output] = value }
46
+ opts.on("--provenance", "Attribute method source to gem/app/stdlib/eval") { options[:provenance] = true }
46
47
  end
47
48
  parser.parse!(@argv)
48
49
  config = load_runtime(options)
49
- snapshot = MonkeyLens.capture(targets: config.targets, ignore_methods: config.ignore_methods)
50
+ snapshot = MonkeyLens.capture(targets: config.targets, ignore_methods: config.ignore_methods, provenance: options[:provenance])
50
51
  snapshot.write(options[:output])
51
52
  @out.puts "Captured #{snapshot.targets.length} target(s) to #{options[:output]}"
52
53
  0
@@ -87,18 +88,26 @@ module MonkeyLens
87
88
 
88
89
  def inspect_target
89
90
  options = common_options
90
- parser = parser_for("inspect", options)
91
+ parser = parser_for("inspect", options) do |opts|
92
+ opts.on("--provenance", "Attribute method source to gem/app/stdlib/eval") { |value| options[:provenance] = true }
93
+ end
91
94
  parser.parse!(@argv)
92
95
  target = @argv.shift
93
96
  raise OptionParser::MissingArgument, "TARGET is required" unless target
94
97
 
95
98
  load_requires(options[:requires])
96
- snapshot = MonkeyLens.capture(targets: [target])
99
+ snapshot = MonkeyLens.capture(targets: [target], provenance: options[:provenance])
97
100
  record = snapshot.targets.fetch(target)
98
101
  @out.puts "#{target} (#{record.fetch("kind")})"
99
102
  @out.puts "Ancestors: #{record.fetch("ancestors").join(" -> ")}"
100
103
  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"}"
104
+ source = method.fetch("source_location")&.join(":") || "native"
105
+ provenance = method["provenance"]
106
+ provenance_text = provenance ? " provenance=#{provenance.fetch("kind")}" : ""
107
+ aliases = Array(method["aliases"])
108
+ alias_text = aliases.empty? ? "" : " aliases=#{aliases.join(",")}"
109
+ super_text = method["super_owner"] ? " super=#{method["super_owner"]}" : ""
110
+ @out.puts " ##{name} [#{method.fetch("visibility")}] owner=#{method.fetch("owner")} source=#{source}#{provenance_text}#{alias_text}#{super_text}"
102
111
  end
103
112
  0
104
113
  end
@@ -137,7 +146,7 @@ module MonkeyLens
137
146
  end
138
147
 
139
148
  def common_options
140
- {config: ".monkeylens.yml", requires: []}
149
+ {config: ".monkeylens.yml", requires: [], provenance: false}
141
150
  end
142
151
 
143
152
  def parser_for(command, options)
@@ -101,8 +101,8 @@ module MonkeyLens
101
101
  if before.fetch("owner") != after.fetch("owner")
102
102
  changes << change("owner_changed", "critical", target, method_id:, before: before.fetch("owner"), after: after.fetch("owner"), message: "effective method owner changed")
103
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")
104
+ if before.fetch("source_location", nil) != after.fetch("source_location", nil)
105
+ changes << change("source_changed", "high", target, method_id:, before: before.fetch("source_location", nil), after: after.fetch("source_location", nil), message: "method implementation source changed")
106
106
  end
107
107
  if before.fetch("parameters") != after.fetch("parameters") || before.fetch("arity") != after.fetch("arity")
108
108
  changes << change("signature_changed", "medium", target, method_id:, before: signature(before), after: signature(after), message: "method signature changed")
@@ -110,6 +110,12 @@ module MonkeyLens
110
110
  if before.fetch("visibility") != after.fetch("visibility")
111
111
  changes << change("visibility_changed", "medium", target, method_id:, before: before.fetch("visibility"), after: after.fetch("visibility"), message: "method visibility changed")
112
112
  end
113
+ if before.fetch("aliases", []) != after.fetch("aliases", [])
114
+ changes << change("alias_changed", "medium", target, method_id:, before: before.fetch("aliases", []), after: after.fetch("aliases", []), message: "method aliases changed")
115
+ end
116
+ if before.fetch("super_owner", nil) != after.fetch("super_owner", nil)
117
+ changes << change("super_owner_changed", "high", target, method_id:, before: before.fetch("super_owner", nil), after: after.fetch("super_owner", nil), message: "super-method owner changed")
118
+ end
113
119
  end
114
120
 
115
121
  def signature(method)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ module MonkeyLens
7
+ class Policy
8
+ class Waiver
9
+ attr_reader :type, :target, :method_pattern, :reason, :expires_on
10
+
11
+ def initialize(type: "*", target: "*", method: "*", reason:, expires_on: nil)
12
+ raise ConfigurationError, "waiver reason is required" if reason.to_s.strip.empty?
13
+
14
+ @type = type.to_s
15
+ @target = target.to_s
16
+ @method_pattern = method.to_s
17
+ @reason = reason.to_s
18
+ @expires_on = expires_on && Date.iso8601(expires_on.to_s)
19
+ rescue Date::Error
20
+ raise ConfigurationError, "invalid waiver expiration #{expires_on.inspect}"
21
+ end
22
+
23
+ def match?(change, today: Date.today)
24
+ return false if expired?(today:)
25
+
26
+ matches?(type, change.type) &&
27
+ matches?(target, change.target) &&
28
+ matches?(method_pattern, change.method_id.to_s)
29
+ end
30
+
31
+ def expired?(today: Date.today)
32
+ expires_on && expires_on < today
33
+ end
34
+
35
+ def to_h
36
+ {
37
+ "type" => type,
38
+ "target" => target,
39
+ "method" => method_pattern,
40
+ "reason" => reason,
41
+ "expires_on" => expires_on&.iso8601
42
+ }.compact
43
+ end
44
+
45
+ private
46
+
47
+ def matches?(pattern, value)
48
+ File.fnmatch?(pattern, value.to_s, File::FNM_EXTGLOB)
49
+ end
50
+ end
51
+
52
+ class Decision
53
+ attr_reader :result, :effective_changes, :waived_changes, :threshold
54
+
55
+ def initialize(result:, effective_changes:, waived_changes:, threshold:)
56
+ @result = result
57
+ @effective_changes = effective_changes.freeze
58
+ @waived_changes = waived_changes.freeze
59
+ @threshold = threshold.to_s
60
+ end
61
+
62
+ def clean?
63
+ effective_changes.empty?
64
+ end
65
+
66
+ def failure?
67
+ minimum = Change::LEVELS.fetch(threshold)
68
+ effective_changes.any? { |change| change.level >= minimum }
69
+ end
70
+
71
+ def to_h
72
+ {
73
+ "schema" => 1,
74
+ "clean" => clean?,
75
+ "failure" => failure?,
76
+ "threshold" => threshold,
77
+ "summary" => Change::LEVELS.keys.to_h do |level|
78
+ [level, effective_changes.count { |change| change.severity == level }]
79
+ end,
80
+ "effective_changes" => effective_changes.map(&:to_h),
81
+ "waived_changes" => waived_changes.map do |item|
82
+ {"change" => item.fetch(:change).to_h, "waiver" => item.fetch(:waiver).to_h}
83
+ end
84
+ }
85
+ end
86
+ end
87
+
88
+ attr_reader :waivers
89
+
90
+ def self.load(path)
91
+ data = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false) || {}
92
+ from_hash(data)
93
+ rescue Psych::Exception => error
94
+ raise ConfigurationError, "invalid policy file #{path}: #{error.message}"
95
+ end
96
+
97
+ def self.from_hash(data)
98
+ values = data["waivers"] || data[:waivers] || []
99
+ new(waivers: values)
100
+ end
101
+
102
+ def initialize(waivers: [])
103
+ @waivers = waivers.map do |waiver|
104
+ waiver.is_a?(Waiver) ? waiver : Waiver.new(**symbolize(waiver))
105
+ end.freeze
106
+ end
107
+
108
+ def evaluate(result, threshold: "high", today: Date.today)
109
+ Change::LEVELS.fetch(threshold.to_s)
110
+ effective = []
111
+ waived = []
112
+ result.changes.each do |change|
113
+ waiver = waivers.find { |candidate| candidate.match?(change, today:) }
114
+ waiver ? waived << {change:, waiver:} : effective << change
115
+ end
116
+ Decision.new(result:, effective_changes: effective, waived_changes: waived, threshold:)
117
+ end
118
+
119
+ private
120
+
121
+ def symbolize(value)
122
+ value.to_h.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MonkeyLens
4
+ class Provenance
5
+ KINDS = %w[gem app stdlib eval unknown].freeze
6
+
7
+ attr_reader :kind, :gem, :path, :line
8
+
9
+ def initialize(kind:, gem: nil, path: nil, line: nil)
10
+ @kind = kind.to_s
11
+ @gem = gem
12
+ @path = path
13
+ @line = line
14
+ end
15
+
16
+ def self.for_source_location(location)
17
+ return nil unless location
18
+
19
+ path, line = location
20
+ path = path.to_s
21
+ line = Integer(line)
22
+ kind, gem_name = classify(path)
23
+ new(kind:, gem: gem_name, path: normalize_path(path), line:)
24
+ end
25
+
26
+ def self.classify(path)
27
+ return ["eval", nil] if eval_path?(path)
28
+
29
+ expanded = File.expand_path(path)
30
+ gem_info = gem_for_path(expanded)
31
+ return ["gem", gem_info] if gem_info
32
+
33
+ if stdlib_path?(expanded)
34
+ ["stdlib", nil]
35
+ elsif app_path?(expanded)
36
+ ["app", nil]
37
+ else
38
+ ["unknown", nil]
39
+ end
40
+ end
41
+
42
+ def self.eval_path?(path)
43
+ path.start_with?("(eval") || path == "eval"
44
+ end
45
+
46
+ def self.gem_for_path(path)
47
+ Gem.loaded_specs.each_value do |spec|
48
+ spec.full_require_paths.each do |require_path|
49
+ expanded = File.expand_path(require_path)
50
+ next unless path.start_with?("#{expanded}/") || path == expanded
51
+
52
+ return spec.name
53
+ end
54
+ end
55
+ nil
56
+ end
57
+
58
+ def self.stdlib_path?(path)
59
+ ruby_root = RbConfig::CONFIG["rubylibdir"]
60
+ return path.start_with?(ruby_root) if ruby_root
61
+
62
+ path.include?("/ruby/") && path.include?("/lib/")
63
+ end
64
+
65
+ def self.app_path?(path)
66
+ cwd = File.expand_path(Dir.pwd)
67
+ path.start_with?("#{cwd}/") || path == cwd
68
+ end
69
+
70
+ def self.normalize_path(path)
71
+ absolute = File.expand_path(path)
72
+ cwd = File.expand_path(Dir.pwd)
73
+ absolute.start_with?("#{cwd}/") ? absolute.delete_prefix("#{cwd}/") : absolute
74
+ end
75
+
76
+ def to_h
77
+ {
78
+ "kind" => kind,
79
+ "gem" => gem,
80
+ "path" => path,
81
+ "line" => line
82
+ }.compact
83
+ end
84
+ end
85
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MonkeyLens
4
- VERSION = "0.1.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/monkey_lens.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "monkey_lens/config"
5
5
  require_relative "monkey_lens/snapshot"
6
6
  require_relative "monkey_lens/change"
7
7
  require_relative "monkey_lens/capture"
8
+ require_relative "monkey_lens/provenance"
8
9
  require_relative "monkey_lens/diff"
9
10
  require_relative "monkey_lens/formatter"
10
11
 
@@ -15,13 +16,18 @@ module MonkeyLens
15
16
 
16
17
  module_function
17
18
 
18
- def capture(targets:, ignore_methods: [])
19
- Capture.new(targets:, ignore_methods:).call
19
+ def capture(targets:, ignore_methods: [], provenance: false)
20
+ Capture.new(targets:, ignore_methods:, provenance:).call
20
21
  end
21
22
 
22
23
  def diff(baseline, current)
23
24
  Diff.new(baseline:, current:).call
24
25
  end
26
+
27
+ def evaluate(result, policy:, threshold: "high", today: Date.today)
28
+ policy.evaluate(result, threshold:, today:)
29
+ end
25
30
  end
26
31
 
32
+ require_relative "monkey_lens/policy"
27
33
  require_relative "monkey_lens/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkey_lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Looney
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '5.0'
18
+ version: '6.0'
19
19
  type: :development
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '5.0'
25
+ version: '6.0'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -63,6 +63,8 @@ files:
63
63
  - lib/monkey_lens/formatter/json.rb
64
64
  - lib/monkey_lens/formatter/sarif.rb
65
65
  - lib/monkey_lens/formatter/text.rb
66
+ - lib/monkey_lens/policy.rb
67
+ - lib/monkey_lens/provenance.rb
66
68
  - lib/monkey_lens/railtie.rb
67
69
  - lib/monkey_lens/rake_task.rb
68
70
  - lib/monkey_lens/snapshot.rb