haveapi 0.30.0 → 0.30.2

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
@@ -0,0 +1,279 @@
1
+ require 'json'
2
+ require 'open3'
3
+ require 'tmpdir'
4
+ require 'yaml'
5
+ require_relative '../../../../utils/performance_ci'
6
+
7
+ RSpec.describe HaveAPI::PerformanceCI do
8
+ let(:candidate) { sha('c') }
9
+ let(:default) { sha('d') }
10
+ let(:base) { sha('b') }
11
+ let(:event) { { 'repository' => { 'default_branch' => 'master' } } }
12
+ let(:repository) { instance_double(described_class::Repository) }
13
+
14
+ def sha(character)
15
+ character * 40
16
+ end
17
+
18
+ def resolver
19
+ described_class::Resolver.new(repository)
20
+ end
21
+
22
+ before do
23
+ allow(repository).to receive(:default_sha).with('master').and_return(default)
24
+ allow(repository).to receive(:commit!) do |value|
25
+ unless value.is_a?(String) && described_class::SHA.match?(value) && value != '0' * 40
26
+ raise described_class::Error, 'invalid commit'
27
+ end
28
+
29
+ value
30
+ end
31
+ allow(repository).to receive_messages(ancestor?: true, paths: ['servers/ruby/lib/haveapi/server.rb'], release_lineage: nil)
32
+ end
33
+
34
+ def resolve(name, data, **options)
35
+ resolver.resolve(event_name: name, event: data, event_sha: candidate, **options)
36
+ end
37
+
38
+ it 'selects the PR event head instead of its synthetic merge commit' do
39
+ event['pull_request'] = { 'head' => { 'sha' => candidate }, 'base' => { 'sha' => default } }
40
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
41
+
42
+ result = resolver.resolve(event_name: 'pull_request', event:, event_sha: sha('e'))
43
+ expect(result).to include(candidate_sha: candidate, baseline_sha: base, profile: 'quick', relevant: true)
44
+ end
45
+
46
+ it 'compares an entire default-branch push batch against event before' do
47
+ event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
48
+ allow(repository).to receive(:merge_base)
49
+
50
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate)
51
+ expect(repository).not_to have_received(:merge_base)
52
+ end
53
+
54
+ [false, true].each do |new_branch|
55
+ it "uses the whole feature merge-base for a #{new_branch ? 'new' : 'subsequent'} push" do
56
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate,
57
+ 'before' => new_branch ? '0' * 40 : sha('a'), 'created' => new_branch)
58
+ allow(repository).to receive(:merge_base).with(candidate, default).and_return(base)
59
+
60
+ expect(resolve('push', event, default_sha: default)).to include(baseline_sha: base, captured_default_sha: default)
61
+ end
62
+ end
63
+
64
+ it 'rejects initial default-branch pushes without an available predecessor' do
65
+ event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => '0' * 40)
66
+ expect { resolve('push', event) }.to raise_error(described_class::Error)
67
+ end
68
+
69
+ context 'with the verified maintenance lineage' do
70
+ def release
71
+ sha('e')
72
+ end
73
+
74
+ def lineage
75
+ { 'candidate_sha' => candidate, 'mapped_sha' => HaveAPI::Performance::Release::MAPPED }
76
+ end
77
+
78
+ before do
79
+ allow(repository).to receive(:release_lineage).with(candidate).and_return(lineage)
80
+ allow(repository).to receive(:default_sha).with('haveapi-0.30').and_return(release)
81
+ end
82
+
83
+ it 'compares the complete maintenance push batch using event before' do
84
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
85
+ expect(resolve('push', event)).to include(baseline_sha: base, candidate_sha: candidate,
86
+ release_lineage: lineage, reason: 'maintenance event before/after batch')
87
+ end
88
+
89
+ [false, true].each do |created|
90
+ it "captures the release head for a #{created ? 'new' : 'subsequent'} release feature" do
91
+ event.merge!('ref' => 'refs/heads/feature', 'before' => created ? '0' * 40 : sha('a'), 'after' => candidate)
92
+ allow(repository).to receive(:merge_base).with(candidate, release).and_return(base)
93
+ expect(resolve('push', event)).to include(baseline_sha: base, captured_release_sha: release,
94
+ captured_default_sha: default, release_lineage: lineage)
95
+ end
96
+ end
97
+
98
+ it 'fails if the captured release reference is unavailable' do
99
+ event.merge!('ref' => 'refs/heads/feature', 'after' => candidate)
100
+ allow(repository).to receive(:default_sha).with('haveapi-0.30').and_raise(described_class::Error, 'missing release ref')
101
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /missing release ref/)
102
+ end
103
+
104
+ it 'accepts only the original weekly reference through the proven mapping' do
105
+ event['inputs'] = { 'profile' => 'weekly', 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE }
106
+ allow(repository).to receive(:ancestor?).with(described_class::REFERENCE, candidate).and_return(false)
107
+ expect(resolve('workflow_dispatch', event)).to include(baseline_sha: described_class::REFERENCE, release_lineage: lineage)
108
+ event['inputs']['profile'] = 'quick'
109
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
110
+ event['inputs']['profile'] = 'weekly'
111
+ allow(repository).to receive(:release_lineage).with(candidate).and_return(nil)
112
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
113
+ end
114
+
115
+ it 'does not accept an unrelated explicit quick baseline' do
116
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
117
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
118
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
119
+ end
120
+ end
121
+
122
+ it 'rejects unproven maintenance branch history' do
123
+ event.merge!('ref' => 'refs/heads/haveapi-0.30', 'before' => base, 'after' => candidate)
124
+ expect { resolve('push', event) }.to raise_error(described_class::Error, /Unproven/)
125
+ end
126
+
127
+ it 'rejects deleted pushes and tag events' do
128
+ expect { resolve('push', event.merge('deleted' => true)) }.to raise_error(described_class::Error, /Deleted/)
129
+ expect { resolve('push', event.merge('ref' => 'refs/tags/v1')) }.to raise_error(described_class::Error, /branch/)
130
+ end
131
+
132
+ it 'requires a real ancestor and never substitutes a same-head baseline' do
133
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
134
+ allow(repository).to receive(:ancestor?).with(base, candidate).and_return(false)
135
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /ancestor/)
136
+ event['inputs']['baseline_sha'] = candidate
137
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /differ/)
138
+ end
139
+
140
+ it 'uses the exact scheduled event SHA and preserves fixed reference ancestry' do
141
+ expect(resolve('schedule', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
142
+ expect(repository).to have_received(:ancestor?).with(described_class::REFERENCE, candidate)
143
+ end
144
+
145
+ it 'defaults manual runs to captured default and fixed reference' do
146
+ expect(resolve('workflow_dispatch', event)).to include(candidate_sha: default, baseline_sha: described_class::REFERENCE)
147
+ end
148
+
149
+ it 'accepts complete explicit manual SHA pairs and weekly profile' do
150
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => described_class::REFERENCE, 'profile' => 'weekly' }
151
+ expect(resolve('workflow_dispatch', event)).to include(candidate_sha: candidate, baseline_sha: described_class::REFERENCE, profile: 'weekly')
152
+ event['inputs']['baseline_sha'] = base
153
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /fixed/)
154
+ end
155
+
156
+ it 'rejects partial manual pairs, invalid profiles and missing event data' do
157
+ event['inputs'] = { 'candidate_sha' => candidate }
158
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error)
159
+ event['inputs'] = { 'profile' => 'unbounded' }
160
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, /profile/)
161
+ expect { resolve('pull_request', {}) }.to raise_error(described_class::Error, /Incomplete/)
162
+ expect { resolve('pull_request_target', event.merge('inputs' => {})) }.to raise_error(described_class::Error, /Unsupported/)
163
+ end
164
+
165
+ it 'does not hide unavailable commits behind a no-path success' do
166
+ event['inputs'] = { 'candidate_sha' => candidate, 'baseline_sha' => base }
167
+ allow(repository).to receive(:commit!).with(base).and_raise(described_class::Error, 'unavailable')
168
+ expect { resolve('workflow_dispatch', event) }.to raise_error(described_class::Error, 'unavailable')
169
+ end
170
+
171
+ it 'skips only irrelevant valid push/PR changes and still forces scheduled/manual work' do
172
+ allow(repository).to receive(:paths).and_return(['clients/php/README.md'])
173
+ event.merge!('ref' => 'refs/heads/master', 'after' => candidate, 'before' => base)
174
+ expect(resolve('push', event)).to include(relevant: false)
175
+ expect(resolve('schedule', event)).to include(relevant: true)
176
+ expect(resolve('workflow_dispatch', event)).to include(relevant: true)
177
+ end
178
+
179
+ it 'detects configuration, dependencies, harness and workflow changes' do
180
+ %w[flake.lock Gemfile Makefile .ruby-version servers/ruby/benchmarks/endpoints.rb
181
+ servers/ruby/test_support/performance_sqlite_fixture.rb clients/ruby/Gemfile clients/rust/Gemfile
182
+ doc/performance.md utils/performance_ci.rb .github/workflows/servers-ruby-performance.yml].each do |path|
183
+ expect(described_class::RELEVANT).to match(path)
184
+ end
185
+ end
186
+
187
+ it 'only accepts successful job completion in the aggregate gate' do
188
+ expect(described_class.aggregate!('success', 'compared')).to be(true)
189
+ expect(described_class.aggregate!('success', 'no-work')).to be(true)
190
+ %w[failure cancelled skipped unknown].each do |result|
191
+ expect { described_class.aggregate!(result, 'compared') }.to raise_error(described_class::Error)
192
+ end
193
+ [nil, '', 'unknown'].each do |outcome|
194
+ expect { described_class.aggregate!('success', outcome) }.to raise_error(described_class::Error)
195
+ end
196
+ end
197
+
198
+ it 'fails aggregate shell execution for missing outcomes and cancelled or skipped work' do
199
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
200
+ script = YAML.load_file(path).fetch('jobs').fetch('gate').fetch('steps').first.fetch('run')
201
+ [['success', 'compared', true], ['success', 'no-work', true], ['success', '', false],
202
+ ['failure', 'compared', false], ['cancelled', 'compared', false], ['skipped', 'no-work', false]].each do |result, outcome, accepted|
203
+ _output, _error, status = Open3.capture3({ 'PERFORMANCE_RESULT' => result, 'PERFORMANCE_OUTCOME' => outcome },
204
+ 'bash', '-e', '-c', script)
205
+ expect(status.success?).to eq(accepted), "#{result}/#{outcome}"
206
+ end
207
+ end
208
+
209
+ [0, 1, 3].each do |code|
210
+ it "preserves actual runner exit #{code} with a matching report" do
211
+ Dir.mktmpdir('haveapi-runner-wrapper-') do |directory|
212
+ checkout = File.join(directory, 'checkout')
213
+ FileUtils.mkdir_p(File.join(checkout, 'servers/ruby/benchmarks'))
214
+ script = <<~RUBY
215
+ require 'json'
216
+ require 'fileutils'
217
+ output = ARGV.fetch(ARGV.index('--output') + 1)
218
+ FileUtils.mkdir_p(output)
219
+ File.write(File.join(output, 'report.json'), JSON.generate(
220
+ schema_version: #{HaveAPI::Performance::SAMPLE_SCHEMA}, status: '#{{ 0 => 'pass', 1 => 'regression', 2 => 'inconclusive', 3 => 'error' }.fetch(code)}',
221
+ baseline_sha: '#{base}', candidate_sha: '#{candidate}'
222
+ ))
223
+ exit #{code}
224
+ RUBY
225
+ File.write(File.join(checkout, 'servers/ruby/benchmarks/endpoints.rb'), script)
226
+ allow(repository).to receive(:root).and_return(checkout)
227
+ allow(repository).to receive(:git).with('rev-parse', 'HEAD').and_return(candidate)
228
+ allow(repository).to receive(:git).with('diff', 'HEAD', '--').and_return('')
229
+ selection = { 'schema_version' => 1, 'relevant' => true, 'profile' => 'quick',
230
+ 'baseline_sha' => base, 'candidate_sha' => candidate }
231
+ campaign = described_class::Campaign.new(repository:, selection:,
232
+ evidence: File.join(directory, 'evidence'), sources: File.join(directory, 'sources'))
233
+ allow(campaign).to receive(:export).with(:baseline, base).and_return(directory)
234
+ allow(campaign).to receive(:export).with(:candidate, candidate).and_return(directory)
235
+ allow(campaign).to receive_messages(verify_dependencies: nil, retain_inputs: nil, command!: nil)
236
+ expect(campaign.run).to eq(code)
237
+ expect(File.read(File.join(directory, 'evidence/runner.exit')).strip).to eq(code.to_s)
238
+ end
239
+ end
240
+ end
241
+
242
+ it 'returns error exit 3 from the actual resolver CLI on incomplete input' do
243
+ Dir.mktmpdir('haveapi-event-spec-') do |directory|
244
+ file = File.join(directory, 'event.json')
245
+ File.write(file, '{}')
246
+ script = File.expand_path('../../../../utils/performance_ci.rb', __dir__)
247
+ _output, error, status = Open3.capture3(RbConfig.ruby, script, 'resolve', '--event', file,
248
+ '--event-name', 'pull_request', '--event-sha', candidate,
249
+ '--repository', File.expand_path('../../../..', __dir__),
250
+ '--output', File.join(directory, 'selection.json'))
251
+ expect(status.exitstatus).to eq(3)
252
+ expect(error).to include('Incomplete event')
253
+ expect(File).not_to exist(File.join(directory, 'selection.json'))
254
+ end
255
+ end
256
+
257
+ it 'keeps the hosted workflow always-created, read-only and bounded' do
258
+ path = File.expand_path('../../../../.github/workflows/servers-ruby-performance.yml', __dir__)
259
+ workflow = YAML.load_file(path)
260
+ triggers = workflow.fetch('on') { workflow.fetch(true) }
261
+ expect(triggers.keys).to contain_exactly('push', 'pull_request', 'schedule', 'workflow_dispatch')
262
+ expect(triggers.fetch('push')).not_to have_key('paths')
263
+ expect(triggers.fetch('pull_request')).to be_nil
264
+ expect(triggers.fetch('schedule')).to eq([{ 'cron' => '0 3 * * 0' }])
265
+ expect(workflow.fetch('permissions')).to eq('contents' => 'read')
266
+ job = workflow.fetch('jobs').fetch('performance')
267
+ expect(job.fetch('runs-on')).to eq('ubuntu-24.04')
268
+ expect(job.fetch('timeout-minutes')).to include('45', '15')
269
+ gate = workflow.fetch('jobs').fetch('gate')
270
+ expect(gate).to include('name' => 'Performance gate', 'needs' => 'performance', 'if' => 'always()')
271
+ expect(gate.fetch('steps').first.fetch('run')).to include('test "$PERFORMANCE_RESULT" = success')
272
+ upload = job.fetch('steps').find { |step| step.fetch('uses', '').start_with?('actions/upload-artifact@') }
273
+ expect(upload).to include('if' => 'always()')
274
+ expect(upload.fetch('with')).to include('retention-days' => 30, 'if-no-files-found' => 'error')
275
+ job.fetch('steps').select { |step| step.fetch('uses', '').start_with?('actions/checkout@') }.each do |step|
276
+ expect(step.fetch('with')).to include('persist-credentials' => false, 'fetch-depth' => 0)
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,151 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require_relative '../../benchmarks/performance_compare'
6
+ require_relative 'support/samples'
7
+
8
+ describe HaveAPI::Performance do
9
+ let(:root) { File.expand_path('../../../..', __dir__) }
10
+ let(:comparison) { File.join(root, 'servers/ruby/benchmarks/performance_compare.rb') }
11
+
12
+ def with_newer_json
13
+ Dir.mktmpdir('haveapi-extra-json-') do |directory|
14
+ FileUtils.mkdir_p(File.join(directory, 'specifications'))
15
+ library = File.join(directory, 'gems/json-3.1.0/lib')
16
+ FileUtils.mkdir_p(library)
17
+ File.write(File.join(library, 'json.rb'), "module JSON; VERSION = '3.1.0'; end\n")
18
+ spec = Gem::Specification.new do |gem|
19
+ gem.name = 'json'
20
+ gem.version = '3.1.0'
21
+ gem.summary = 'Owned activation control'
22
+ gem.authors = ['Fixture']
23
+ end
24
+ File.write(File.join(directory, 'specifications/json-3.1.0.gemspec'), spec.to_ruby)
25
+ environment = ENV.keys.grep(/\ABUNDLE|\ABUNDLER|\ARUBY(?:OPT|LIB)\z/).to_h { |key| [key, nil] }
26
+ environment.merge!('GEM_HOME' => directory,
27
+ 'GEM_PATH' => [directory, Gem.loaded_specs.fetch('json').base_dir, Gem.default_dir].uniq.join(File::PATH_SEPARATOR))
28
+ yield directory, environment
29
+ end
30
+ end
31
+
32
+ it 'loads the native calibrated version in each public entry despite a newer visible gem' do
33
+ with_newer_json do |_directory, environment|
34
+ [comparison, File.join(root, 'utils/performance_ci.rb'), File.join(root, 'servers/ruby/benchmarks/endpoints.rb')].each do |entry|
35
+ program = <<~RUBY
36
+ visible = Gem::Specification.find_all_by_name('json').map { |spec| spec.version.to_s }
37
+ require #{entry.inspect}
38
+ puts JSON.generate(visible: visible, loaded: Gem.loaded_specs.fetch('json').version.to_s,
39
+ parser: JSON::Ext::Parser.name, parsed: JSON.parse('{"value":42}'))
40
+ RUBY
41
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
42
+ expect(status).to be_success, errors
43
+ result = JSON.parse(output)
44
+ expect(result.fetch('visible')).to include('3.1.0')
45
+ expect(result).to include('loaded' => '2.21.2', 'parser' => 'JSON::Ext::Parser', 'parsed' => { 'value' => 42 })
46
+ end
47
+ end
48
+ end
49
+
50
+ it 'uses the same activation in the neutral worker serialization path' do
51
+ with_newer_json do |_directory, environment|
52
+ program = <<~RUBY
53
+ require #{File.join(root, 'servers/ruby/benchmarks/endpoints.rb').inspect}
54
+ HaveAPI::Performance::Worker.define_method(:run) { { json_version: Gem.loaded_specs.fetch('json').version.to_s } }
55
+ exit HaveAPI::Performance::CLI.new(['worker']).execute
56
+ RUBY
57
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program, stdin_data: '{}')
58
+ expect(status).to be_success, errors
59
+ expect(JSON.parse(output)).to include('json_version' => '2.21.2')
60
+ end
61
+ end
62
+
63
+ it 'refuses an already activated incompatible version instead of weakening the environment' do
64
+ with_newer_json do |_directory, environment|
65
+ program = "gem 'json', '= 3.1.0'; require 'json'; require #{comparison.inspect}"
66
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
67
+ expect(status).not_to be_success
68
+ expect(errors).to include('json', '2.21.2', 'already activated json-3.1.0')
69
+ end
70
+ end
71
+
72
+ it 'reports the required version when the calibrated gem is missing' do
73
+ with_newer_json do |directory, environment|
74
+ environment['GEM_PATH'] = directory
75
+ program = "Gem.use_paths(#{directory.inspect}, [#{directory.inspect}]); require #{comparison.inspect}"
76
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', program)
77
+ expect(status).not_to be_success
78
+ expect(errors).to include('json', '2.21.2', '3.1.0')
79
+ expect(errors).to match(/Could not find/)
80
+ end
81
+ end
82
+
83
+ it 'declares the calibrated JSON constraint in all three fresh development bundles' do
84
+ expected = JSON.parse(File.read(File.join(root, 'servers/ruby/benchmarks/performance_budgets.json')))
85
+ .fetch('environment').fetch('weekly').fetch('gems').fetch('json').fetch('version')
86
+ expect(HaveAPI::Performance::JSON_VERSION).to eq(expected)
87
+ with_newer_json do |directory, environment|
88
+ HaveAPI::Performance::Release::JSON_GEMFILES.each do |path|
89
+ definition = Bundler::Dsl.evaluate(File.join(root, path), nil, {})
90
+ expect(definition.dependencies.select { |dependency| dependency.name == 'json' }.map { |dependency| dependency.requirement.to_s }).to eq(["= #{expected}"])
91
+ end
92
+ # Resolve only this installed constraint in an owned lockless fixture.
93
+ # No repository lock or external dependency is resolved or installed.
94
+ file = File.join(directory, 'Gemfile')
95
+ File.write(file, "source 'https://rubygems.org'\ngem 'json', '= #{expected}'\n")
96
+ _output, errors, status = Open3.capture3(environment, RbConfig.ruby, Gem.bin_path('bundler', 'bundle'), 'lock', '--local', '--gemfile', file)
97
+ expect(status).to be_success, errors
98
+ expect(File.read("#{file}.lock")).to include("json (#{expected})", "json (= #{expected})")
99
+ expect(File.read("#{file}.lock")).not_to include('json (3.1.0)')
100
+ end
101
+ end
102
+
103
+ it 'canonicalizes only the exact known JSON preparation declaration and binds raw replay evidence' do
104
+ release = HaveAPI::Performance::Release
105
+ old = release::JSON_GEMFILES.to_h { |path| [path, "source 'https://rubygems.org'\n"] }
106
+ pinned = old.transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
107
+ receipt = release.json_preparation(pinned)
108
+ expect(receipt.fetch('normalized_contents')).to eq(old)
109
+ expect(receipt.fetch('raw_sha256')).not_to eq(receipt.fetch('normalized_sha256'))
110
+ expect(receipt.fetch('declarations').values).to all(eq('exact-development-pin'))
111
+ unversioned = old.transform_values { |text| "#{text} gem 'json'\n" }
112
+ expect(release.json_preparation(unversioned).fetch('normalized_contents')).to eq(old)
113
+ ['3.1.0', '>= 2.21.2'].each do |requirement|
114
+ changed = pinned.transform_values { |text| text.sub('= 2.21.2', requirement) }
115
+ expect { release.json_preparation(changed) }.to raise_error(HaveAPI::Performance::ContractError)
116
+ end
117
+ changed = pinned.merge('Gemfile' => "#{pinned.fetch('Gemfile')}# unrelated comment\ngem 'other'\n")
118
+ expect(release.json_preparation(changed).fetch('normalized_contents')).not_to eq(old)
119
+ inputs = { 'json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256') }
120
+ source = { 'files_sha256' => receipt.fetch('raw_sha256') }
121
+ expect { release.validate_json_preparation(inputs, source) }.not_to raise_error
122
+ %w[raw_sha256 normalized_sha256 declarations].each do |field|
123
+ forged = Marshal.load(Marshal.dump(inputs))
124
+ forged['json_preparation'][field]['Gemfile'] = 'forged'
125
+ expect { release.validate_json_preparation(forged, source) }.to raise_error(HaveAPI::Performance::ContractError)
126
+ end
127
+ end
128
+
129
+ it 'replays the exact preparation delta while rejecting other content and raw source changes' do
130
+ release = HaveAPI::Performance::Release
131
+ %w[exact other-content raw-source].each do |control|
132
+ samples, budgets = PerformanceSamples.fixture
133
+ samples.fetch('observations').each_value do |observation|
134
+ next unless observation.fetch('source').fetch('sha') == samples.fetch('candidate_sha')
135
+
136
+ contents = observation.fetch('inputs').fetch('json_preparation').fetch('contents')
137
+ .transform_values { |text| "#{text} gem 'json', '= 2.21.2'\n" }
138
+ contents['Gemfile'] << "# other content\n" if control == 'other-content'
139
+ receipt = release.json_preparation(contents)
140
+ observation['inputs'].merge!('json_preparation' => receipt, 'manifests' => receipt.fetch('normalized_sha256'))
141
+ samples['sources']['candidate']['files_sha256'].merge!(receipt.fetch('raw_sha256')) unless control == 'raw-source'
142
+ end
143
+ comparator = HaveAPI::Performance::Comparator.new(samples, JSON.parse(budgets), budget_sha256: samples.fetch('budget_sha256'))
144
+ if control == 'exact'
145
+ expect(comparator.compare.fetch('status')).to eq('pass')
146
+ else
147
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
148
+ end
149
+ end
150
+ end
151
+ end