active_mutator 0.3.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: '08222e2342f45fd4c9150cd9201383231f1add08c5c5582bccf74f4350c87181'
4
- data.tar.gz: ae88865c407500e7b7fcc0cb9a0ab72fe90aa2860136f9bd523203a1baae9c35
3
+ metadata.gz: 0c97d4e4b28fb22f07d54f258e65162b4a4e9c731ab89caa851610e1ff3d1a83
4
+ data.tar.gz: dfd2fdddc29f209281e45001ae7a21bd6d75fce6833a5f4086f3592d36378843
5
5
  SHA512:
6
- metadata.gz: c0ed008b82b47bf0e70e74bd51330eafaeb02f6c7241e5a19e2a75395293db1ccf02e3f13f3810abf2da1972f5b50b678e35bae4efb946b139aac22e0faccfd0
7
- data.tar.gz: dd02830ae6b56cdc687cf90dc5131d1409298ac8e5d03217a57f92a044aca4dee6731043498e1f021d9eee4ef064af8cab80fb048263046dfe28fe719bef0077
6
+ metadata.gz: 983e510da62ef0a31abefd1342485e17a365013ce13eb16b9ad3a1b929ca5ff13c643c15ae9529c6b0def30215268b1a2c424d102489f4351d27b2c9cddd2ca1
7
+ data.tar.gz: 272643ac14e46cfba0e0ea95d4b9fe4cedc7ace3b7359eaabdf7bed60a8caf2451f84ba3986828f9528dfe402e9fe1fe4d9b20d7daa63f842ec728ebfb4ef2af
data/README.md CHANGED
@@ -258,6 +258,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
258
258
  | `--force-baseline` | off | ignore cached coverage map |
259
259
  | `--preload-helper FILE` / `--no-preload-helper` | auto-detect | parent spec-helper preload |
260
260
  | `--serial-pattern PAT` | `spec/system/`, `spec/features/` | covering-path prefixes forced serial |
261
+ | `--spec-path DIR` | `spec/` | where spec files live, relative to the project root (repeatable; the first use replaces the default `spec/`), e.g. `--spec-path engines/billing/spec --spec-path spec` |
261
262
  | `--browser-boot-seconds S` | 15 | serial-lane timeout bump |
262
263
  | `--timeout-factor F` / `--timeout-floor S` | 8 / 10 | mutation timeout budget |
263
264
  | `--[no-]adaptive-timeout` | on | scale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; `--timeout-factor`/`--timeout-floor` set the starting budget) |
@@ -266,6 +267,14 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
266
267
  | `--[no-]class-level` | on | mutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects |
267
268
  | `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
268
269
 
270
+ `--spec-path` tells active_mutator where spec files live (coverage
271
+ classification, digests, escalation); RSpec's own discovery is still the
272
+ project's job — a project with specs under `test/` also needs
273
+ `--default-path test` in its `.rspec`. The serial-lane defaults stay
274
+ `spec/system/` and `spec/features/` regardless of `--spec-path`; a
275
+ custom spec root with browser specs should set `--serial-pattern`
276
+ (e.g. `--serial-pattern test/system/`) itself.
277
+
269
278
  `--debug-plan` prints the planned mutant list as one JSON document
270
279
  (`{"planned": [...], "pre_resolved": {...}}`) and exits without running
271
280
  anything. A coverage baseline is still built or loaded, since timeouts
@@ -285,6 +294,8 @@ flags override file values (`--require` and `--exclude` add to the file's
285
294
  lists; the first `--serial-pattern` replaces them). Recognized keys:
286
295
  `jobs`, `format`, `timeout_factor`, `timeout_floor`,
287
296
  `browser_boot_seconds`, `fail_at`, `exclude`, `serial_patterns`,
297
+ `spec_paths` (where spec files live, relative to the project root;
298
+ replaces the default `spec`),
288
299
  `requires`, `operators` (custom operator files, loaded before analysis; see
289
300
  [Custom operators](docs/guides/custom-operators.md)),
290
301
  `preload_helper` (a path, or `false` to skip preload),
@@ -301,6 +312,9 @@ exclude:
301
312
  - lib/generated
302
313
  serial_patterns:
303
314
  - spec/system/
315
+ spec_paths:
316
+ - engines/billing/spec
317
+ - spec
304
318
  fail_at: 90 # legacy suite: gate on score instead of zero-survivors
305
319
  ```
306
320
 
@@ -5,10 +5,11 @@ require "json"
5
5
  module ActiveMutator
6
6
  # Runs the host suite once, instrumented, in a subprocess. Produces and
7
7
  # caches the CoverageMap. Invalidation is coarse: any digest change in
8
- # {app,lib,spec}/**/*.rb triggers a full re-run.
8
+ # {app,lib}/**/*.rb or the configured spec paths triggers a full re-run.
9
9
  class Baseline
10
- def initialize(root:, cache_dir: File.join(root, ".active_mutator"))
10
+ def initialize(root:, spec_paths: ["spec"], cache_dir: File.join(root, ".active_mutator"))
11
11
  @root = root
12
+ @spec_paths = spec_paths
12
13
  @cache_dir = cache_dir
13
14
  @out_path = File.join(cache_dir, "coverage.json")
14
15
  end
@@ -19,13 +20,19 @@ module ActiveMutator
19
20
  digests = current_digests
20
21
  if !force && File.exist?(@out_path)
21
22
  map = CoverageMap.load(@out_path)
22
- if map.fresh?(digests)
23
+ # A spec_paths change silently degrades the delta classifier: files
24
+ # under a removed spec path just vanish from the digest scan, so
25
+ # BaselineDelta treats their stale example records as untouched
26
+ # source coverage instead of dropping them. Force a full rebuild
27
+ # whenever the configured spec_paths differ from what the cache was
28
+ # stamped with.
29
+ if stored_spec_paths(map) == @spec_paths && map.fresh?(digests)
23
30
  @last_refresh = :cached
24
31
  return map
25
32
  end
26
- if map.version == 2
33
+ if stored_spec_paths(map) == @spec_paths && map.version == 2
27
34
  delta = BaselineDelta.compute(old_digests: stored_digests(map), new_digests: digests,
28
- coverage_map: map, root: @root)
35
+ coverage_map: map, root: @root, spec_paths: @spec_paths)
29
36
  unless delta.full?
30
37
  run_partial!(delta)
31
38
  stamp_digests(digests)
@@ -59,6 +66,25 @@ module ActiveMutator
59
66
  ok = system(env, "bundle", "exec", "rspec", chdir: @root, out: :err)
60
67
  raise BaselineFailed, "baseline suite failed, fix the suite before mutating" unless ok
61
68
  raise BaselineFailed, "baseline produced no coverage output" unless File.exist?(@out_path)
69
+
70
+ verify_complete!(@out_path)
71
+ end
72
+
73
+ # An aborted subprocess can still exit 0 with a partial map (RSpec
74
+ # rescues Errno::EPIPE and runs after(:suite)); stamping that as fresh
75
+ # silently reports every mutant uncovered. Payloads without the count
76
+ # predate this check and are accepted as-is.
77
+ def verify_complete!(out_path)
78
+ payload = JSON.parse(File.read(out_path))
79
+ expected = payload["expected_examples"]
80
+ return unless expected
81
+
82
+ recorded = payload.fetch("records", {}).size
83
+ return if recorded >= expected
84
+
85
+ raise BaselineFailed,
86
+ "baseline aborted early: #{recorded} of #{expected} examples recorded — " \
87
+ "re-run without interrupting the suite"
62
88
  end
63
89
 
64
90
  def baseline_env(out_path)
@@ -66,6 +92,7 @@ module ActiveMutator
66
92
  "ACTIVE_MUTATOR" => "1",
67
93
  "ACTIVE_MUTATOR_ROOT" => @root,
68
94
  "ACTIVE_MUTATOR_BASELINE_OUT" => out_path,
95
+ "ACTIVE_MUTATOR_SPEC_PATHS" => @spec_paths.join(":"),
69
96
  # RUBYOPT, not `rspec --require`: project .rspec requires (spec_helper
70
97
  # → app code) run before command-line requires, and Coverage misses
71
98
  # everything loaded before Coverage.start. -r fires before rspec boots.
@@ -83,6 +110,12 @@ module ActiveMutator
83
110
  JSON.parse(File.read(@out_path)).fetch("digests", {})
84
111
  end
85
112
 
113
+ # A pre-0.4.0 cache predates spec_paths and has no key; treat that as the
114
+ # old implicit default so existing default-config caches stay valid.
115
+ def stored_spec_paths(map)
116
+ JSON.parse(File.read(@out_path)).fetch("spec_paths", ["spec"])
117
+ end
118
+
86
119
  def run_partial!(delta)
87
120
  targets = delta.rerun_spec_files + delta.rerun_example_ids
88
121
  partial_out = File.join(@cache_dir, "partial.json")
@@ -91,6 +124,8 @@ module ActiveMutator
91
124
  ok = system(env, "bundle", "exec", "rspec", *targets, chdir: @root, out: :err)
92
125
  raise BaselineFailed, "partial baseline run failed, fix the suite before mutating" unless ok
93
126
  raise BaselineFailed, "partial baseline produced no output" unless File.exist?(partial_out)
127
+
128
+ verify_complete!(partial_out)
94
129
  end
95
130
  merge_partial!(partial_out, delta)
96
131
  ensure
@@ -122,11 +157,14 @@ module ActiveMutator
122
157
  def stamp_digests(digests)
123
158
  data = JSON.parse(File.read(@out_path))
124
159
  data["digests"] = digests
160
+ data["spec_paths"] = @spec_paths
125
161
  AtomicFile.write(@out_path, JSON.generate(data))
126
162
  end
127
163
 
128
164
  def current_digests
129
- files = Dir[File.join(@root, "{app,lib,spec}/**/*.rb")].sort
165
+ files = Dir[File.join(@root, "{app,lib}/**/*.rb")]
166
+ files += @spec_paths.flat_map { |sp| Dir[File.join(@root, sp, "**", "*.rb")] }
167
+ files = files.uniq.sort
130
168
  files += [File.join(@root, "Gemfile.lock"), File.join(@root, ".rspec")].select { |f| File.exist?(f) }
131
169
  files.to_h { |f| [f.delete_prefix("#{@root}/"), Digest::SHA256.file(f).hexdigest] }
132
170
  end
@@ -15,10 +15,10 @@ module ActiveMutator
15
15
  # files, a full re-run is cheaper and simpler than a giant partial one.
16
16
  REFERENCE_FULL_RATIO = 0.5
17
17
 
18
- def self.compute(old_digests:, new_digests:, coverage_map:, root:)
18
+ def self.compute(old_digests:, new_digests:, coverage_map:, root:, spec_paths: ["spec"])
19
19
  changed = (old_digests.keys | new_digests.keys)
20
20
  .reject { |k| old_digests[k] == new_digests[k] }
21
- return FULL if changed.any? { |k| full_trigger?(k) }
21
+ return FULL if changed.any? { |k| full_trigger?(k, spec_paths: spec_paths) }
22
22
 
23
23
  rerun_spec_files = []
24
24
  rerun_example_ids = []
@@ -35,7 +35,7 @@ module ActiveMutator
35
35
  changed.each do |rel|
36
36
  added = !old_digests.key?(rel)
37
37
  deleted = !new_digests.key?(rel)
38
- if rel.start_with?("spec/")
38
+ if spec_paths.any? { |sp| rel.start_with?("#{sp}/") }
39
39
  owned = coverage_map.examples_for_spec_file(rel)
40
40
  if deleted
41
41
  # A deleted spec file with no records is support-like: other spec
@@ -54,7 +54,7 @@ module ActiveMutator
54
54
  rerun_example_ids.concat(coverage_map.examples_covering_file(abs))
55
55
  end
56
56
  unless deleted
57
- spec_contents ||= Dir[File.join(root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
57
+ spec_contents ||= spec_file_contents(root: root, spec_paths: spec_paths)
58
58
  candidates = newly_covering_candidates(root: root, rel: rel, coverage_map: coverage_map,
59
59
  spec_contents: spec_contents)
60
60
  return FULL if candidates == :full
@@ -71,8 +71,17 @@ module ActiveMutator
71
71
  drop_source_files: drop_source_files.uniq.sort)
72
72
  end
73
73
 
74
- def self.full_trigger?(rel)
75
- rel.start_with?("spec/support/") || !rel.end_with?(".rb")
74
+ def self.full_trigger?(rel, spec_paths: ["spec"])
75
+ spec_paths.any? { |sp| rel.start_with?("#{sp}/support/") } || !rel.end_with?(".rb")
76
+ end
77
+
78
+ # All *_spec.rb files under the configured spec paths, absolute path =>
79
+ # file content. Shared by the delta scan and Runner's phase-2 escalation
80
+ # so "where do specs live" is answered in exactly one place (#35).
81
+ def self.spec_file_contents(root:, spec_paths:)
82
+ spec_paths
83
+ .flat_map { |sp| Dir[File.join(root, sp, "**", "*_spec.rb")] }
84
+ .uniq.to_h { |f| [f, File.read(f)] }
76
85
  end
77
86
 
78
87
  # #11: an unchanged spec file can START covering a changed source file
@@ -9,7 +9,8 @@ module ActiveMutator
9
9
  RECORDS = {}
10
10
  TIMES = {}
11
11
 
12
- def self.diff_coverage(before, after, root)
12
+ def self.diff_coverage(before, after, root, spec_paths: ["spec"])
13
+ prefixes = spec_paths.map { |sp| "/#{sp}/" }
13
14
  hits = []
14
15
  after.each do |path, data|
15
16
  next unless path.start_with?(root)
@@ -19,7 +20,7 @@ module ActiveMutator
19
20
  # spec/fixtures/ tree), which would otherwise falsely exclude every
20
21
  # file under it.
21
22
  relative = path.delete_prefix(root)
22
- next if relative.start_with?("/spec/")
23
+ next if prefixes.any? { |p| relative.start_with?(p) }
23
24
 
24
25
  before_lines = before.dig(path, :lines)
25
26
  data[:lines].each_with_index do |count, idx|
@@ -32,8 +33,10 @@ module ActiveMutator
32
33
  hits
33
34
  end
34
35
 
35
- def self.build_payload(records, times)
36
- { "version" => 2, "records" => records, "times" => times }
36
+ def self.build_payload(records, times, expected_examples: nil)
37
+ payload = { "version" => 2, "records" => records, "times" => times }
38
+ payload["expected_examples"] = expected_examples if expected_examples
39
+ payload
37
40
  end
38
41
  end
39
42
  end
@@ -51,16 +54,22 @@ if ENV["ACTIVE_MUTATOR_BASELINE_OUT"]
51
54
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
52
55
  after = Coverage.peek_result
53
56
  root = ENV.fetch("ACTIVE_MUTATOR_ROOT")
57
+ spec_paths = ENV.fetch("ACTIVE_MUTATOR_SPEC_PATHS", "spec").split(":")
54
58
  ActiveMutator::BaselineHooks::RECORDS[example.id] =
55
- ActiveMutator::BaselineHooks.diff_coverage(before, after, root)
59
+ ActiveMutator::BaselineHooks.diff_coverage(before, after, root, spec_paths: spec_paths)
56
60
  # NOT example.execution_result.run_time: that is nil until after
57
61
  # around hooks complete.
58
62
  ActiveMutator::BaselineHooks::TIMES[example.id] = elapsed
59
63
  end
60
64
 
61
65
  config.after(:suite) do
66
+ # The expected count lets the parent detect an aborted run: RSpec
67
+ # swallows some mid-suite failures (e.g. Errno::EPIPE on a closed
68
+ # stdout) yet still runs after(:suite) and exits 0, which would stamp
69
+ # a partial coverage map as a fresh, trusted baseline.
62
70
  payload = ActiveMutator::BaselineHooks.build_payload(
63
- ActiveMutator::BaselineHooks::RECORDS, ActiveMutator::BaselineHooks::TIMES
71
+ ActiveMutator::BaselineHooks::RECORDS, ActiveMutator::BaselineHooks::TIMES,
72
+ expected_examples: RSpec.world.example_count
64
73
  )
65
74
  File.write(ENV.fetch("ACTIVE_MUTATOR_BASELINE_OUT"), JSON.generate(payload))
66
75
  end
@@ -3,6 +3,13 @@ require "optparse"
3
3
  module ActiveMutator
4
4
  module CLI
5
5
  def self.run(argv)
6
+ # Handled before OptionParser: its built-in --version handler prints
7
+ # "version unknown" and exits the process.
8
+ if argv.include?("--version")
9
+ puts "active_mutator #{VERSION}"
10
+ return 0
11
+ end
12
+
6
13
  Runner.new(parse(argv)).call
7
14
  rescue OptionParser::ParseError, Error => e
8
15
  warn "active_mutator: #{e.message}"
@@ -22,6 +29,7 @@ module ActiveMutator
22
29
  # boot cost (RSpec setup + spec file loading).
23
30
  requires: [], timeout_factor: 8.0, timeout_floor: 10.0, force_baseline: false,
24
31
  preload_helper: nil, serial_patterns: ["spec/system/", "spec/features/"],
32
+ spec_paths: ["spec"],
25
33
  browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
26
34
  max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
27
35
  operators: [], class_level: true, class_level_closure_cap: 10
@@ -47,6 +55,11 @@ module ActiveMutator
47
55
  options[:serial_patterns_replaced] = true
48
56
  options[:serial_patterns] << v
49
57
  end
58
+ o.on("--spec-path DIR", "Directory holding spec files, relative to root (repeatable; replaces the default spec/ on first use)") do |v|
59
+ options[:spec_paths] = [] unless options[:spec_paths_replaced]
60
+ options[:spec_paths_replaced] = true
61
+ options[:spec_paths] << v
62
+ end
50
63
  o.on("--browser-boot-seconds S", Float, "Extra timeout budget for serial-lane mutants") { |v| options[:browser_boot_seconds] = v }
51
64
  o.on("--[no-]adaptive-timeout", "Scale timeout budgets from observed worker wall times (default: on)") { |v| options[:adaptive_timeout] = v }
52
65
  o.on("--accept-survivors", "Record surviving mutants into the acceptance ledger") { options[:accept_survivors] = true }
@@ -59,6 +72,12 @@ module ActiveMutator
59
72
  end
60
73
  end.parse(argv)
61
74
  options.delete(:serial_patterns_replaced)
75
+ options.delete(:spec_paths_replaced)
76
+ options[:spec_paths] = options[:spec_paths].map { |sp| sp.sub(%r{/+\z}, "") }
77
+ raise OptionParser::InvalidArgument, "--spec-path list must not be empty" if options[:spec_paths].empty?
78
+ if options[:spec_paths].any? { |sp| sp.strip.empty? }
79
+ raise OptionParser::InvalidArgument, "--spec-path entries must not be blank"
80
+ end
62
81
 
63
82
  Config.new(paths: paths, root: Dir.pwd, **options)
64
83
  end
@@ -3,7 +3,8 @@ require "etc"
3
3
  module ActiveMutator
4
4
  Config = Data.define(:paths, :since, :subject_filter, :jobs, :format, :requires,
5
5
  :timeout_factor, :timeout_floor, :force_baseline, :root,
6
- :preload_helper, :serial_patterns, :browser_boot_seconds,
6
+ :preload_helper, :serial_patterns, :spec_paths,
7
+ :browser_boot_seconds,
7
8
  :accept_survivors, :exclude, :max_mutants, :debug_plan,
8
9
  :fail_at, :adaptive_timeout, :operators,
9
10
  :class_level, :class_level_closure_cap)
@@ -19,6 +19,7 @@ module ActiveMutator
19
19
  "fail_at" => :score,
20
20
  "exclude" => :string_list,
21
21
  "serial_patterns" => :string_list,
22
+ "spec_paths" => :nonempty_string_list,
22
23
  "requires" => :string_list,
23
24
  "operators" => :string_list,
24
25
  "preload_helper" => :preload_helper,
@@ -75,6 +76,12 @@ module ActiveMutator
75
76
  raise Error, "#{FILENAME}: #{key} must be a list of strings"
76
77
  end
77
78
  value
79
+ when :nonempty_string_list
80
+ unless value.is_a?(Array) && value.all?(String)
81
+ raise Error, "#{FILENAME}: #{key} must be a list of strings"
82
+ end
83
+ raise Error, "#{FILENAME}: #{key} must not be empty" if value.empty?
84
+ value
78
85
  when :boolean
79
86
  unless [true, false].include?(value)
80
87
  raise Error, "#{FILENAME}: #{key} must be true or false"
@@ -13,7 +13,8 @@ module ActiveMutator
13
13
  ClosureReload.cap = @config.class_level_closure_cap
14
14
  preload!
15
15
  preload_spec_helper!
16
- map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
16
+ map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
17
+ .coverage_map(force: @config.force_baseline)
17
18
  @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
18
19
  subjects = discover_subjects
19
20
  analyses = subjects.map { |s| Engine.new.analyze(s) }
@@ -86,7 +87,7 @@ module ActiveMutator
86
87
  # its mutant is a known equivalent.)
87
88
  return results if candidates.empty?
88
89
 
89
- spec_contents = Dir[File.join(@config.root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
90
+ spec_contents = BaselineDelta.spec_file_contents(root: @config.root, spec_paths: @config.spec_paths)
90
91
  patterns = {} # subject file => constant-reference pattern (parsed once per file)
91
92
  items = {}
92
93
  candidates.each do |r|
@@ -200,14 +201,15 @@ module ActiveMutator
200
201
  def examples_for_mutation(mutation, map)
201
202
  return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
202
203
 
203
- (map.examples_covering_file(mutation.subject.file) |
204
- map.examples_for_spec_file(convention_spec_rel(mutation.subject.file))).sort
204
+ convention_examples = convention_spec_rels(mutation.subject.file)
205
+ .flat_map { |rel| map.examples_for_spec_file(rel) }
206
+ (map.examples_covering_file(mutation.subject.file) | convention_examples).sort
205
207
  end
206
208
 
207
- def convention_spec_rel(file)
209
+ def convention_spec_rels(file)
208
210
  rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
209
211
  rest = rel.sub(%r{\A[^/]+/}, "")
210
- "spec/#{rest}_spec.rb"
212
+ @config.spec_paths.map { |sp| "#{sp}/#{rest}_spec.rb" }
211
213
  end
212
214
 
213
215
  def build_reporter
@@ -293,7 +295,11 @@ module ActiveMutator
293
295
  helper = if @config.preload_helper
294
296
  File.expand_path(@config.preload_helper, @config.root)
295
297
  else
296
- %w[spec/rails_helper.rb spec/spec_helper.rb]
298
+ # Precedence: within each spec path rails_helper wins over
299
+ # spec_helper; earlier spec paths win over later ones — same
300
+ # as today for the default ["spec"].
301
+ @config.spec_paths
302
+ .flat_map { |sp| ["#{sp}/rails_helper.rb", "#{sp}/spec_helper.rb"] }
297
303
  .map { |p| File.join(@config.root, p) }
298
304
  .find { |p| File.exist?(p) }
299
305
  end
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-30 00:00:00.000000000 Z
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism