monkey_lens 0.1.0 → 0.3.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: 9c3c706d59eb29d1ad4de38b99d3e5539891aa630fac314d39dc16fa3b3fbdbd
4
+ data.tar.gz: 60e610c3c01434eadb5edc1e4dc2dfe699f48d914c685bd0b1659cff1649f0f5
5
5
  SHA512:
6
- metadata.gz: 526967dd0425e75440c63878be6e34a28deab3d777d022337a340b527855defb439438c1bf5a47057f888734ba515b10fc4ed0f66fd63d281cb09c17bea5a98d
7
- data.tar.gz: a9dacd19531ff3934fe76b8a96192e3b2e5a38f911531ea8d535acd25d68a4d1d53231b5fdea1e5913439e64eb1c740304ececf2f26609f95e7b87c888120713
6
+ metadata.gz: 5908c484375db223128fadb7cf724de9f79d0a14c8e713a886962a965ae00882b73762d05a4f360fd3761464811f749fcbadcb6dff2644ca1f9c6a17f17d080e
7
+ data.tar.gz: 7f64d16a2bc0035ed66caca551786d53495b1bed098cbfd6cad47f7532cb177ac73feec73e83e3c4108ec24c3545c14588fd1822c10d85fc88af5693a05f7d6e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to MonkeyLens are documented here.
4
4
 
5
+ ## 0.3.0 — 2026-08-11
6
+
7
+ - Added `MonkeyLens::Provenance` to attribute method `source_location` to gem, app, stdlib, or eval origins.
8
+ - Opt-in provenance capture via `provenance: true` on `MonkeyLens.capture` and `--provenance` on CLI capture/inspect.
9
+ - Included provenance in inspect output and snapshot method records when enabled.
10
+
11
+ ## 0.2.0 — 2026-08-04
12
+
13
+ - Added `MonkeyLens::Policy` for approving known runtime drift without deleting evidence from the baseline.
14
+ - Added wildcard waivers for change type, target, and method identifiers.
15
+ - Required a written reason for every waiver.
16
+ - Added optional ISO-date expiration for temporary approvals.
17
+ - Added YAML policy loading through `MonkeyLens::Policy.load`.
18
+ - Added `Policy::Decision` with effective and waived changes, threshold evaluation, and JSON-ready output.
19
+ - Added the `MonkeyLens.evaluate` convenience API.
20
+
5
21
  ## 0.1.0 — 2026-08-04
6
22
 
7
23
  - 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
@@ -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
@@ -60,8 +61,9 @@ module MonkeyLens
60
61
  "visibility" => visibility.to_s,
61
62
  "parameters" => method.parameters.map { |kind, parameter| [kind.to_s, parameter&.to_s] },
62
63
  "arity" => method.arity,
63
- "source_location" => normalize_source(method.source_location)
64
- }]
64
+ "source_location" => normalize_source(method.source_location),
65
+ "provenance" => provenance_for(method.source_location)
66
+ }.compact]
65
67
  rescue NameError
66
68
  nil
67
69
  end.to_h
@@ -83,5 +85,11 @@ module MonkeyLens
83
85
  cwd = File.expand_path(Dir.pwd)
84
86
  absolute.start_with?("#{cwd}/") ? absolute.delete_prefix("#{cwd}/") : absolute
85
87
  end
88
+
89
+ def provenance_for(location)
90
+ return nil unless @provenance
91
+
92
+ Provenance.for_source_location(location)&.to_h
93
+ end
86
94
  end
87
95
  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,23 @@ 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
+ @out.puts " ##{name} [#{method.fetch("visibility")}] owner=#{method.fetch("owner")} source=#{source}#{provenance_text}"
102
108
  end
103
109
  0
104
110
  end
@@ -137,7 +143,7 @@ module MonkeyLens
137
143
  end
138
144
 
139
145
  def common_options
140
- {config: ".monkeylens.yml", requires: []}
146
+ {config: ".monkeylens.yml", requires: [], provenance: false}
141
147
  end
142
148
 
143
149
  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")
@@ -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.3.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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthew Looney
@@ -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