testprune 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.
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'footprint'
5
+ require_relative 'test_body'
6
+ require_relative 'safety_check'
7
+ require_relative 'baseline'
8
+
9
+ module Testprune
10
+ # One test proposed for removal, with why and (after the safety pass) whether
11
+ # it's safe. `review_only` candidates (structural/overlap) are reported but
12
+ # never auto-patched.
13
+ Candidate = Struct.new(
14
+ :footprint, :confidence, :group, :reason, :kept_by,
15
+ :review_only, :safe, :safety_note,
16
+ keyword_init: true
17
+ )
18
+
19
+ # Holds the detection outcome and exposes the set that `apply` may patch.
20
+ # `ambient_units`/`setup_only` record what baseline subtraction removed, so the
21
+ # report can disclose it rather than silently dropping tests.
22
+ class DetectorResult
23
+ attr_reader :footprints, :candidates, :ambient_units, :setup_only
24
+
25
+ def initialize(footprints, candidates, ambient_units: 0, setup_only: 0)
26
+ @footprints = footprints
27
+ @candidates = candidates
28
+ @ambient_units = ambient_units
29
+ @setup_only = setup_only
30
+ end
31
+
32
+ # Only HIGH-confidence, non-review candidates that passed the safety check are
33
+ # auto-applicable. Structural (MEDIUM) and overlap (LOW) are review-only.
34
+ def approved_removals
35
+ @candidates.select { |c| c.confidence == :high && !c.review_only && c.safe }
36
+ end
37
+ end
38
+
39
+ # Detects duplicate/redundant tests from semantic footprints. Each removable
40
+ # candidate is justified by a *retained* keeper whose coverage is a superset
41
+ # (or equal), which the SafetyCheck then re-verifies against cascading gaps.
42
+ # Above this many post-identical/subset footprints, skip O(n²) overlap detection
43
+ # and log a warning. ~500 yields ~125k pairs which is acceptable; 2000+ becomes
44
+ # unusably slow on large suites.
45
+ OVERLAP_SIZE_LIMIT = 500
46
+
47
+ class DuplicationDetector
48
+ def initialize(footprints, overlap_threshold: 0.9, baseline_fraction: nil)
49
+ @original_footprints = footprints # preserved for SafetyCheck ambient-unit guarantee
50
+ ambient = Baseline.ambient_units(footprints, baseline_fraction)
51
+ had_coverage = footprints.reject(&:empty?).size
52
+ @footprints = strip_ambient(footprints, ambient).reject(&:empty?)
53
+ @ambient = ambient.size
54
+ @setup_only = had_coverage - @footprints.size # lost all signal to baseline
55
+ @threshold = overlap_threshold
56
+ @candidates = []
57
+ @seen = Set.new # ids already proposed for removal
58
+ @protected = Set.new # ids chosen as keepers — never propose these
59
+ end
60
+
61
+ def call
62
+ detect_identical
63
+ detect_subset
64
+ detect_structural
65
+ detect_overlap
66
+ SafetyCheck.new(@footprints, original_footprints: @original_footprints).apply(@candidates)
67
+ DetectorResult.new(@footprints, @candidates,
68
+ ambient_units: @ambient, setup_only: @setup_only)
69
+ end
70
+
71
+ private
72
+
73
+ # Returns copies of the footprints with ambient (shared-setup) units removed,
74
+ # so detection compares only each test's *distinctive* coverage. A test whose
75
+ # footprint was entirely ambient becomes empty and is dropped — we can't tell
76
+ # what it uniquely exercises, so it must never be proposed for removal.
77
+ def strip_ambient(footprints, ambient)
78
+ return footprints if ambient.empty?
79
+
80
+ footprints.map do |fp|
81
+ fp.dup.tap { |copy| copy.units = fp.units - ambient }
82
+ end
83
+ end
84
+
85
+ def available
86
+ @footprints.reject { |fp| @seen.include?(fp.id) || @protected.include?(fp.id) }
87
+ end
88
+
89
+ def detect_identical
90
+ @footprints.group_by(&:units).each_value do |members|
91
+ next if members.size < 2
92
+
93
+ members = members.sort_by(&:id)
94
+ keeper = members.first
95
+ @protected << keeper.id
96
+ members.drop(1).each do |fp|
97
+ propose_local(fp, keeper, group: :identical,
98
+ high_reason: "identical coverage to #{keeper.id}",
99
+ low_reason: "identical coverage to #{keeper.id}, but in a different " \
100
+ 'test file (likely a shared code path, not a redundant test)')
101
+ end
102
+ end
103
+ end
104
+
105
+ def detect_subset
106
+ available.sort_by(&:id).each do |candidate|
107
+ # Search for a keeper among all footprints not yet proposed for removal.
108
+ # Crucially, we include @protected footprints (already-designated keepers):
109
+ # excluding them caused false negatives in coverage chains where A ⊊ B ⊊ C —
110
+ # after B is protected as A's keeper, C must still be findable as B's keeper.
111
+ keeper = @footprints
112
+ .reject { |fp| @seen.include?(fp.id) || fp.id == candidate.id }
113
+ .find { |other| candidate.units.proper_subset?(other.units) }
114
+ next unless keeper
115
+
116
+ @protected << keeper.id
117
+ propose_local(candidate, keeper, group: :subset,
118
+ high_reason: "coverage is a strict subset of #{keeper.id}",
119
+ low_reason: "coverage is a strict subset of #{keeper.id}, but in a different " \
120
+ 'test file (likely a shared code path, not a redundant test)')
121
+ end
122
+ end
123
+
124
+ # Coverage-equivalence only earns HIGH (auto-removable) confidence when both
125
+ # tests live in the same file — i.e. variations of one scenario. Equivalence
126
+ # across files usually means the tests merely share a guard/middleware path
127
+ # while asserting different things, so it's demoted to LOW review-only.
128
+ def propose_local(footprint, keeper, group:, high_reason:, low_reason:)
129
+ if same_file?(footprint, keeper)
130
+ propose(footprint, confidence: :high, group: group, reason: high_reason, keeper: keeper)
131
+ else
132
+ propose(footprint, confidence: :low, group: group, review_only: true,
133
+ reason: low_reason, keeper: keeper)
134
+ end
135
+ end
136
+
137
+ def same_file?(a, b)
138
+ !a.file.nil? && a.file == b.file
139
+ end
140
+
141
+ def detect_structural
142
+ by_signature = {}
143
+ available.each do |fp|
144
+ sig = TestBody.signature(fp.file, fp.line)
145
+ (by_signature[sig] ||= []) << fp if sig
146
+ end
147
+
148
+ by_signature.each_value do |members|
149
+ next if members.size < 2
150
+
151
+ members = members.sort_by(&:id)
152
+ keeper = members.first
153
+ @protected << keeper.id
154
+ members.drop(1).each do |fp|
155
+ next if (fp.units & keeper.units).empty? # require real coverage overlap
156
+
157
+ propose(fp, confidence: :medium, group: :structural, review_only: true,
158
+ reason: "test body structurally identical to #{keeper.id}", keeper: keeper)
159
+ end
160
+ end
161
+ end
162
+
163
+ def detect_overlap
164
+ pool = available
165
+ if pool.size > OVERLAP_SIZE_LIMIT
166
+ warn "testprune: #{pool.size} candidates after identical/subset detection; " \
167
+ "overlap (LOW-confidence) detection skipped to avoid O(n²) cost at this " \
168
+ "suite size. HIGH/MEDIUM results are unaffected."
169
+ return
170
+ end
171
+
172
+ pool.combination(2).each do |a, b|
173
+ next if @seen.include?(a.id) || @seen.include?(b.id)
174
+
175
+ score = jaccard(a.units, b.units)
176
+ next if score < @threshold
177
+
178
+ smaller, larger = [a, b].sort_by { |fp| [fp.units.size, fp.id] }
179
+ next if @protected.include?(smaller.id)
180
+
181
+ @protected << larger.id
182
+ propose(smaller, confidence: :low, group: :overlap, review_only: true,
183
+ reason: "#{(score * 100).round}% coverage overlap with #{larger.id}",
184
+ keeper: larger)
185
+ end
186
+ end
187
+
188
+ def propose(footprint, confidence:, group:, reason:, keeper:, review_only: false)
189
+ @candidates << Candidate.new(
190
+ footprint: footprint, confidence: confidence, group: group, reason: reason,
191
+ kept_by: [keeper.id], review_only: review_only
192
+ )
193
+ @seen << footprint.id
194
+ end
195
+
196
+ # Avoids materializing the union Set: uses inclusion-exclusion arithmetic instead.
197
+ def jaccard(a, b)
198
+ inter = (a & b).size
199
+ union = a.size + b.size - inter
200
+ union.zero? ? 0.0 : inter.to_f / union
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require_relative 'semantic_map'
5
+
6
+ module Testprune
7
+ # A single test's semantic coverage: the set of AST-aware unit IDs it executed,
8
+ # plus the metadata needed to report and patch it.
9
+ Footprint = Struct.new(:id, :description, :file, :line, :wall_time, :units, keyword_init: true) do
10
+ def empty?
11
+ units.empty?
12
+ end
13
+ end
14
+
15
+ # Turns recorded per-test coverage (run.json) into Footprints by mapping
16
+ # Coverage locations onto Prism semantic units. Caches one SemanticMap per file.
17
+ class SemanticIndex
18
+ def initialize(root)
19
+ @root = root
20
+ @maps = {}
21
+ end
22
+
23
+ def build_footprints(tests)
24
+ tests.map { |test| footprint(test) }
25
+ end
26
+
27
+ def footprint(test)
28
+ units = Set.new
29
+ (test['coverage'] || {}).each do |file, data|
30
+ next unless File.exist?(file)
31
+
32
+ map = map_for(file)
33
+ collect_methods(units, map, data['methods'])
34
+ collect_branches(units, map, data['branches'])
35
+ collect_lines(units, map, data['lines'])
36
+ end
37
+
38
+ Footprint.new(
39
+ id: test['id'], description: test['description'], file: test['file'],
40
+ line: test['line'], wall_time: test['wall_time'] || 0.0, units: units
41
+ )
42
+ end
43
+
44
+ def label_for(id)
45
+ @maps.each_value do |map|
46
+ unit = map.units[id]
47
+ return unit.label if unit
48
+ end
49
+ id
50
+ end
51
+
52
+ private
53
+
54
+ def collect_methods(units, map, entries)
55
+ (entries || []).each do |entry|
56
+ _name, sl, sc, = entry
57
+ unit = map.method_unit(sl, sc)
58
+ units << unit.id if unit
59
+ end
60
+ end
61
+
62
+ def collect_branches(units, map, entries)
63
+ (entries || []).each do |entry|
64
+ type, sl, sc, el, ec = entry
65
+ units << map.branch_unit(type, sl, sc, el, ec).id
66
+ end
67
+ end
68
+
69
+ def collect_lines(units, map, lines)
70
+ (lines || []).each do |lineno|
71
+ unit = map.line_unit(lineno)
72
+ units << unit.id if unit
73
+ end
74
+ end
75
+
76
+ def map_for(file)
77
+ @maps[file] ||= SemanticMap.for_file(file, relpath: relativize(file))
78
+ rescue StandardError => e
79
+ warn "testprune: could not parse #{file} (#{e.class}: #{e.message}) — skipping"
80
+ @maps[file] = SemanticMap.new(file, '', relativize(file))
81
+ end
82
+
83
+ def relativize(file)
84
+ file.start_with?("#{@root}/") ? file[(@root.length + 1)..] : file
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'tempfile'
5
+ require 'fileutils'
6
+ require_relative '../testprune'
7
+ require_relative 'test_body'
8
+
9
+ module Testprune
10
+ # Emits a git-applyable patch that comments out the approved tests. Locates each
11
+ # test's exact AST block (def or `it`/`describe` block) with Prism, comments
12
+ # those lines, and diffs against the original via `git diff --no-index`. Writes
13
+ # only the .patch file — never mutates the target source.
14
+ class PatchWriter
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def write(candidates)
20
+ FileUtils.mkdir_p(@config.output_dir)
21
+ patch = candidates.group_by { |c| c.footprint.file }
22
+ .map { |file, group| file_patch(file, group) }
23
+ .compact.join
24
+ File.write(@config.patch_file, patch)
25
+ @config.patch_file
26
+ end
27
+
28
+ private
29
+
30
+ def file_patch(file, candidates)
31
+ unless file && File.exist?(file)
32
+ ids = candidates.map { |c| c.footprint.id }
33
+ warn "testprune: #{ids.size} candidate(s) skipped — #{file || '(no file)'} not found:\n" \
34
+ " #{ids.join(', ')}"
35
+ return nil
36
+ end
37
+
38
+ original = File.read(file, encoding: 'UTF-8:UTF-8', invalid: :replace, undef: :replace)
39
+ tree = begin
40
+ Prism.parse(original).value
41
+ rescue StandardError => e
42
+ warn "testprune: Prism parse failed for #{file} (#{e.message}) — skipping"
43
+ return nil
44
+ end
45
+ ranges = candidates.filter_map { |c| block_range(tree, c) }
46
+ return nil if ranges.empty?
47
+
48
+ modified = comment_out(original, ranges)
49
+ diff(file, modified)
50
+ end
51
+
52
+ # [start_line, end_line, reason] for the test block at the candidate's line.
53
+ def block_range(tree, candidate)
54
+ node = TestBody.locate(tree, candidate.footprint.line)
55
+ return nil unless node
56
+
57
+ loc = node.location
58
+ [loc.start_line, loc.end_line, candidate.reason]
59
+ end
60
+
61
+ def comment_out(original, ranges)
62
+ lines = original.lines
63
+ commented = lines.dup
64
+ annotations = {} # 0-based insert index => annotation text
65
+
66
+ ranges.each do |start_line, end_line, reason|
67
+ annotations[start_line - 1] = "# testprune: removed redundant test — #{reason}\n"
68
+ (start_line..end_line).each do |lineno|
69
+ idx = lineno - 1
70
+ commented[idx] = "# #{commented[idx]}"
71
+ end
72
+ end
73
+
74
+ # Insert annotations from the bottom up so earlier indexes stay valid.
75
+ annotations.sort_by { |idx, _| -idx }.each do |idx, text|
76
+ indent = lines[idx][/\A\s*/]
77
+ commented.insert(idx, "#{indent}#{text}")
78
+ end
79
+ commented.join
80
+ end
81
+
82
+ # Unified diff via git, with headers rewritten to the repo-relative path so
83
+ # the patch applies cleanly from the project root.
84
+ def diff(file, modified)
85
+ relpath = relativize(file)
86
+ Tempfile.create(['testprune', '.rb']) do |tmp|
87
+ tmp.write(modified)
88
+ tmp.flush
89
+ out, _err, status = Open3.capture3(
90
+ 'git', 'diff', '--no-index', '--no-color', file, tmp.path
91
+ )
92
+ raise Error, 'git diff failed while building patch' if status.exitstatus > 1
93
+
94
+ rewrite_headers(out, relpath)
95
+ end
96
+ end
97
+
98
+ def rewrite_headers(diff, relpath)
99
+ diff.lines.map do |line|
100
+ if line.start_with?('diff --git ')
101
+ "diff --git a/#{relpath} b/#{relpath}\n"
102
+ elsif line.start_with?('--- ')
103
+ "--- a/#{relpath}\n"
104
+ elsif line.start_with?('+++ ')
105
+ "+++ b/#{relpath}\n"
106
+ else
107
+ line
108
+ end
109
+ end.join
110
+ end
111
+
112
+ def relativize(file)
113
+ root = @config.root
114
+ file.start_with?("#{root}/") ? file[(root.length + 1)..] : file
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'coverage'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require_relative '../testprune'
7
+ require_relative 'coverage_delta'
8
+
9
+ module Testprune
10
+ # Process-wide singleton that the framework adapters drive. It starts Coverage,
11
+ # snapshots peek_result around each test, and writes run.json when the suite ends.
12
+ class Recorder
13
+ def self.instance
14
+ @instance ||= new(Configuration.from_env)
15
+ end
16
+
17
+ # Used in testprune's own test suite to prevent Recorder state from leaking
18
+ # between test runs (the singleton is normally process-scoped by design).
19
+ def self.reset!
20
+ @instance = nil
21
+ end
22
+
23
+ def initialize(config)
24
+ @config = config
25
+ @tests = []
26
+ @framework = nil
27
+ end
28
+
29
+ attr_accessor :framework
30
+
31
+ # Start Coverage with all required options and keep it running for the
32
+ # entire suite. Staying always-on means `Coverage.running?` is true before
33
+ # SimpleCov (or any other coverage tool) loads, so they see it already
34
+ # running and skip their own `Coverage.start` call — no guard needed in
35
+ # test_helper.rb. Per-test footprints are captured by diffing `peek_result`
36
+ # snapshots before and after each test; `peek_result` is non-destructive so
37
+ # SimpleCov's final `Coverage.result` call at suite end still gets the full
38
+ # aggregate unaffected.
39
+ def start_coverage
40
+ return if @started
41
+
42
+ Coverage.setup(lines: true, branches: true, methods: true)
43
+ Coverage.resume
44
+ @started = true
45
+ end
46
+
47
+ # Bracket a single test (RSpec around(:each)). Minitest uses start_test/
48
+ # finish_test directly from lifecycle hooks instead, to avoid wrapping #run.
49
+ def around(id:, file:, line:, description: nil)
50
+ start_test
51
+ begin
52
+ yield
53
+ ensure
54
+ finish_test(id: id, file: file, line: line, description: description)
55
+ end
56
+ end
57
+
58
+ # Snapshot coverage counts before the test begins.
59
+ def start_test
60
+ @before = Coverage.peek_result
61
+ @test_started = monotonic
62
+ end
63
+
64
+ # Record this test's coverage delta + wall time. Coverage keeps running
65
+ # between tests — the peek_result diff is accurate for serial suites because
66
+ # nothing else executes between start_test and finish_test.
67
+ def finish_test(id:, file:, line:, description: nil)
68
+ wall = monotonic - @test_started
69
+ delta = CoverageDelta.compute(@before, Coverage.peek_result, @config)
70
+ @tests << {
71
+ 'id' => id,
72
+ 'description' => description || id,
73
+ 'file' => file && File.expand_path(file),
74
+ 'line' => line,
75
+ 'wall_time' => wall,
76
+ 'coverage' => delta
77
+ }
78
+ end
79
+
80
+ def dump
81
+ return if @dumped
82
+
83
+ @dumped = true
84
+ FileUtils.mkdir_p(@config.output_dir)
85
+ File.write(@config.run_file, JSON.generate(payload))
86
+ end
87
+
88
+ private
89
+
90
+ def payload
91
+ {
92
+ 'root' => @config.root,
93
+ 'framework' => @framework,
94
+ 'tests' => @tests
95
+ }
96
+ end
97
+
98
+ def monotonic
99
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Testprune
6
+ # Renders the analysis as a grouped, human-readable report (or JSON). Candidates
7
+ # are grouped by confidence; HIGH shows safety status, MEDIUM/LOW are marked
8
+ # review-only.
9
+ class Report
10
+ GROUP_TITLES = {
11
+ identical: 'Identical coverage',
12
+ subset: 'Subset / subsumed coverage',
13
+ structural: 'Structurally duplicated test body',
14
+ overlap: 'High coverage overlap'
15
+ }.freeze
16
+
17
+ def initialize(result, json: false)
18
+ @result = result
19
+ @json = json
20
+ end
21
+
22
+ def render
23
+ @json ? render_json : render_text
24
+ end
25
+
26
+ private
27
+
28
+ def render_text
29
+ lines = []
30
+ lines << 'testprune — test coverage redundancy report'
31
+ lines << "Suite: #{test_count} test(s), framework=#{@result.run['framework']}"
32
+ if @result.ambient_units.positive?
33
+ lines << "Baseline: subtracted #{@result.ambient_units} shared-setup unit(s); " \
34
+ "#{@result.setup_only} test(s) had no distinctive coverage and were set aside."
35
+ end
36
+ lines << ''
37
+
38
+ lines.concat(section('HIGH confidence — safe to remove', high_candidates))
39
+ lines.concat(section('MEDIUM confidence — review (structural duplicates)', medium_candidates))
40
+ lines.concat(section('LOW confidence — review (overlapping coverage)', low_candidates))
41
+
42
+ lines.concat(savings_section)
43
+ lines << ''
44
+ lines << if @result.approved_removals.empty?
45
+ 'No auto-removable candidates. Nothing to apply.'
46
+ else
47
+ 'Run `testprune apply` to review and emit a removal patch.'
48
+ end
49
+ lines.join("\n")
50
+ end
51
+
52
+ def section(title, candidates)
53
+ return [] if candidates.empty?
54
+
55
+ out = ["#{title}: #{candidates.size}"]
56
+ candidates.each { |c| out.concat(candidate_lines(c)) }
57
+ out << ''
58
+ out
59
+ end
60
+
61
+ def candidate_lines(candidate)
62
+ fp = candidate.footprint
63
+ out = []
64
+ out << " [#{candidate.group}] #{fp.id}"
65
+ out << " at: #{fp.file}:#{fp.line}" if fp.file
66
+ out << " reason: #{candidate.reason}"
67
+ out << " kept by: #{candidate.kept_by.join(', ')}" unless candidate.kept_by.empty?
68
+ out << " covers: #{covered_labels(fp)}"
69
+ out << " #{safety_line(candidate)}"
70
+ out
71
+ end
72
+
73
+ def covered_labels(footprint)
74
+ labels = footprint.units.map { |id| @result.label_for(id) }.sort
75
+ labels.size <= 4 ? labels.join('; ') : "#{labels.first(4).join('; ')} (+#{labels.size - 4} more)"
76
+ end
77
+
78
+ def safety_line(candidate)
79
+ case candidate.safe
80
+ when true then '✓ safe — every covered unit remains covered by a retained test'
81
+ when false then "✗ NOT safe — #{candidate.safety_note} (kept)"
82
+ else '· review-only — not auto-applied'
83
+ end
84
+ end
85
+
86
+ def savings_section
87
+ s = @result.savings
88
+ [
89
+ 'Estimated CI savings:',
90
+ " #{s.approved_count} test(s), #{format('%.4f', s.approved_time)}s " \
91
+ "(~#{format('%.1f', s.percent_of_test_time)}% of #{format('%.4f', s.total_test_time)}s test time)",
92
+ ' Note: under parallel CI runners, wall-clock savings will be lower.'
93
+ ]
94
+ end
95
+
96
+ def high_candidates = @result.candidates.select { |c| c.confidence == :high }
97
+ def medium_candidates = @result.candidates.select { |c| c.confidence == :medium }
98
+ def low_candidates = @result.candidates.select { |c| c.confidence == :low }
99
+ def test_count = (@result.run['tests'] || []).size
100
+
101
+ def render_json
102
+ JSON.pretty_generate(
103
+ framework: @result.run['framework'],
104
+ test_count: test_count,
105
+ savings: {
106
+ approved_count: @result.savings.approved_count,
107
+ approved_time: @result.savings.approved_time,
108
+ total_test_time: @result.savings.total_test_time,
109
+ percent_of_test_time: @result.savings.percent_of_test_time
110
+ },
111
+ candidates: @result.candidates.map { |c| candidate_json(c) }
112
+ )
113
+ end
114
+
115
+ def candidate_json(candidate)
116
+ fp = candidate.footprint
117
+ {
118
+ id: fp.id, file: fp.file, line: fp.line,
119
+ confidence: candidate.confidence, group: candidate.group,
120
+ reason: candidate.reason, kept_by: candidate.kept_by,
121
+ review_only: candidate.review_only, safe: candidate.safe,
122
+ safety_note: candidate.safety_note,
123
+ covers: fp.units.map { |id| @result.label_for(id) }.sort
124
+ }
125
+ end
126
+ end
127
+ end