openapi-sourcetools 0.4.3 → 0.5.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.
data/bin/openapi-merge CHANGED
@@ -1,63 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Copyright © 2021 Ismo Kärkkäinen
4
+ # Copyright © 2021-2024 Ismo Kärkkäinen
5
5
  # Licensed under Universal Permissive License. See LICENSE.txt.
6
6
 
7
- require_relative '../lib/common.rb'
7
+ require_relative '../lib/common'
8
8
  require 'optparse'
9
9
  require 'yaml'
10
- require 'json'
11
10
  require 'set'
12
11
 
13
-
14
- default_env(:out, '')
15
- default_env(:format, 'YAML')
16
- default_env(:prune, true);
17
-
18
- ENV['POSIXLY_CORRECT'] = '1'
19
- parser = OptionParser.new do |opts|
20
- opts.summary_indent = ' '
21
- opts.summary_width = 26
22
- opts.banner = 'Usage: openapi-merge [options] sources...'
23
- opts.separator ''
24
- opts.separator 'Options (equivalent environment variable and value in parentheses):'
25
- opts.on('-o', '--output FILE', 'Output result to FILE, not stdout (OUT=FILE).') do |f|
26
- env(:out, f)
27
- end
28
- opts.on('-k', '--keep', 'Keep all unreferenced objects under components.') do
29
- env(:prune, false)
30
- end
31
- opts.on('-p', '--prune', 'Prune all unreferenced objects under components (default).') do
32
- env(:prune, true)
33
- end
34
- opts.on('--yaml', 'Output format is YAML (default).') do
35
- env(:format, 'YAML')
36
- end
37
- opts.on('--json', 'Output format is JSON.') do
38
- env(:format, 'JSON')
39
- end
40
- opts.on('-h', '--help', 'Print this help and exit.') do
41
- $stdout.puts %(#{opts}
42
-
43
- Source files are combined to form one API specification document. Sources are
44
- allowed only to append to the merged document, not re-define anything in it.
45
- )
46
- exit 0
47
- end
48
- end
49
- parser.parse!
50
-
51
- aargh("Format neither JSON nor YAML: #{env(:format)}", 1) unless %w[JSON YAML].include? env(:format)
52
-
53
- def read_source(filename)
54
- YAML.safe_load(File.read(filename))
55
- rescue Errno::ENOENT => e
56
- aargh("Could not read #{filename}", 2)
57
- rescue StandardError => e
58
- aargh(e.to_s, 3)
59
- end
60
-
61
12
  def raise_se(message)
62
13
  raise StandardError, message
63
14
  end
@@ -77,12 +28,12 @@ end
77
28
 
78
29
  def add_undefined(merged, incoming, filename, path, max_depths)
79
30
  incoming.each_pair do |key, value|
80
- unless merged.has_key? key
31
+ unless merged.key? key
81
32
  merged[key] = value
82
33
  next
83
34
  end
84
35
  m = merged[key]
85
- raise_se "Path #{path_combo(path, false)} merged type #{m.class} differs from type #{value.class} in #{filename}" unless m.class == value.class
36
+ raise_se("Path #{path_combo(path, false)} merged type #{m.class} differs from type #{value.class} in #{filename}") unless m.instance_of?(value.class)
86
37
  raise_se("Re-definition of #{key} #{path_combo(path)} in #{filename}") if too_deep(path, max_depths)
87
38
  if m.is_a? Hash # paths or similar
88
39
  path.push key
@@ -97,22 +48,6 @@ def add_undefined(merged, incoming, filename, path, max_depths)
97
48
  raise_se "Re-definition of #{key} #{path_combo(path)} in #{filename}"
98
49
  end
99
50
  end
100
- rescue StandardError => e
101
- aargh(e.to_s, 3)
102
- end
103
-
104
- max_depths = Hash.new(0)
105
- max_depths['openapi'] = 1
106
- max_depths['info'] = 1
107
- max_depths['servers'] = 1
108
- max_depths['paths'] = 2 # Allows get, post, options, etc. from different files.
109
- max_depths['webhooks'] = 2
110
- max_depths['components'] = 2
111
- max_depths['security'] = 1
112
- max_depths['tags'] = 1
113
- merged = {}
114
- ARGV.each do |filename|
115
- add_undefined(merged, read_source(filename), filename, [], max_depths)
116
51
  end
117
52
 
118
53
  def gather_refs(doc, found)
@@ -129,7 +64,7 @@ def gather_refs(doc, found)
129
64
  end
130
65
  end
131
66
 
132
- if env(:prune)
67
+ def prune(merged)
133
68
  prev_refs = Set.new
134
69
  loop do # May have references from deleted so repeat until nothing deleted.
135
70
  refs = Set.new
@@ -153,7 +88,7 @@ if env(:prune)
153
88
  sub = used
154
89
  p.each_index do |k|
155
90
  if k + 1 < p.size
156
- sub[p[k]] = {} unless sub.has_key? p[k]
91
+ sub[p[k]] = {} unless sub.key? p[k]
157
92
  sub = sub[p[k]]
158
93
  else
159
94
  sub[p[k]] = item
@@ -166,19 +101,58 @@ if env(:prune)
166
101
  end
167
102
  end
168
103
 
169
- output = env(:out)
170
- if output.empty?
171
- output = $stdout
172
- else
173
- begin
174
- output = File.open(output, 'w')
175
- rescue StandardError
176
- aargh("Failed to open for writing: #{output}", 1)
104
+ def main
105
+ output_file = nil
106
+ keep = false
107
+
108
+ ENV['POSIXLY_CORRECT'] = '1'
109
+ parser = OptionParser.new do |opts|
110
+ opts.summary_indent = ' '
111
+ opts.summary_width = 26
112
+ opts.banner = 'Usage: openapi-merge [options] sources...'
113
+ opts.separator ''
114
+ opts.separator 'Options:'
115
+ opts.on('-o', '--output FILE', 'Output result to FILE, not stdout.') do |f|
116
+ output_file = f
117
+ end
118
+ opts.on('-k', '--keep', 'Keep all unreferenced objects under components.') do
119
+ keep = true
120
+ end
121
+ opts.on('-p', '--prune', 'Prune all unreferenced objects under components (default).') do
122
+ keep = false
123
+ end
124
+ opts.on('-h', '--help', 'Print this help and exit.') do
125
+ $stdout.puts %(#{opts}
126
+
127
+ Source files are combined to form one API specification document. Sources are
128
+ allowed only to append to the merged document, not re-define anything in it.
129
+ )
130
+ exit 0
131
+ end
177
132
  end
178
- end
133
+ parser.parse!
134
+
135
+ max_depths = Hash.new(0)
136
+ max_depths['openapi'] = 1
137
+ max_depths['info'] = 1
138
+ max_depths['servers'] = 1
139
+ max_depths['paths'] = 2 # Allows get, post, etc. from different files.
140
+ max_depths['webhooks'] = 2
141
+ max_depths['components'] = 2
142
+ max_depths['security'] = 1
143
+ max_depths['tags'] = 1
144
+ merged = {}
145
+ ARGV.each do |filename|
146
+ s = load_source(filename)
147
+ return 2 if s.nil?
148
+ add_undefined(merged, s, filename, [], max_depths)
149
+ rescue StandardError => e
150
+ return aargh(e.to_s, 4)
151
+ end
152
+
153
+ prune(merged) unless keep
179
154
 
180
- case env(:format)
181
- when 'JSON' then output.puts JSON.generate(merged)
182
- when 'YAML' then output.puts YAML.dump(merged)
155
+ dump_result(output_file, YAML.dump(merged), 3)
183
156
  end
184
- output.close
157
+
158
+ exit(main) if (defined? $unit_test).nil?
@@ -1,111 +1,84 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Copyright © 2021 Ismo Kärkkäinen
4
+ # Copyright © 2021-2024 Ismo Kärkkäinen
5
5
  # Licensed under Universal Permissive License. See LICENSE.txt.
6
6
 
7
- require_relative '../lib/common.rb'
7
+ require_relative '../lib/common'
8
8
  require 'optparse'
9
9
  require 'yaml'
10
- require 'json'
11
10
 
12
11
 
13
- default_env(:in, '')
14
- default_env(:out, '')
15
- default_env(:format, 'YAML')
16
- default_env(:error, true)
12
+ def main
13
+ input_name = nil
14
+ output_name = nil
15
+ error = true
17
16
 
18
- parser = OptionParser.new do |opts|
19
- opts.summary_indent = ' '
20
- opts.summary_width = 24
21
- opts.banner = 'Usage: openapi-processpaths [options]'
22
- opts.separator ''
23
- opts.separator 'Options (equivalent environment variable and value in parentheses):'
24
- opts.on('-i', '--input FILE', 'Read source from FILE, not stdin (IN=FILE).') do |f|
25
- env(:in, f)
26
- end
27
- opts.on('-o', '--output FILE', 'Output result to FILE, not stdout (OUT=FILE).') do |f|
28
- env(:out, f)
29
- end
30
- opts.on('--warn', 'Only warn of paths with same fixed parts (ERROR=0).') do
31
- env(:error, false)
32
- end
33
- opts.on('--yaml', 'Output format is YAML (default) (FORMAT=YAML).') do
34
- env(:format, 'YAML')
35
- end
36
- opts.on('--json', 'Output format is JSON. (FORMAT=JSON)') do
37
- env(:format, 'JSON')
38
- end
39
- opts.on('-h', '--help', 'Print this help and exit.') do
40
- $stdout.puts %(#{opts}
17
+ parser = OptionParser.new do |opts|
18
+ opts.summary_indent = ' '
19
+ opts.summary_width = 24
20
+ opts.banner = 'Usage: openapi-processpaths [options]'
21
+ opts.separator ''
22
+ opts.separator 'Options:'
23
+ opts.on('-i', '--input FILE', 'Read source from FILE, not stdin.') do |f|
24
+ input_name = f
25
+ end
26
+ opts.on('-o', '--output FILE', 'Output result to FILE, not stdout.') do |f|
27
+ output_name = f
28
+ end
29
+ opts.on('--warn', 'Only warn of paths with same fixed parts.') do
30
+ error = false
31
+ end
32
+ opts.on('-h', '--help', 'Print this help and exit.') do
33
+ $stdout.puts %(#{opts}
41
34
 
42
35
  Processes API specification document path objects into form that is expected by
43
36
  later stage tools. Checks for paths that may be ambiguous.
44
37
  )
45
- exit 0
38
+ exit 0
39
+ end
46
40
  end
47
- end
48
- parser.parse!
41
+ parser.parse!
49
42
 
50
- aargh("Format neither JSON nor YAML: #{env(:format)}", 1) unless %w[JSON YAML].include? env(:format)
51
-
52
- def read_source(filename)
53
- YAML.safe_load(filename.empty? ? $stdin : File.read(filename))
54
- rescue Errno::ENOENT => e
55
- aargh("Could not read #{filename.empty? ? 'stdin' : filename}", 2)
56
- rescue StandardError => e
57
- aargh(e.to_s, 3)
58
- end
59
- doc = read_source(env(:in))
43
+ doc = load_source(input_name)
44
+ return 2 if doc.nil?
60
45
 
61
- processed = {}
62
- doc.fetch('paths', {}).each_pair do |path, value|
63
- parts = split_path(path, true)
64
- processed[path] = {
65
- 'parts' => parts,
66
- 'orig' => value,
67
- 'lookalike' => [],
68
- path: ServerPath.new(parts)
69
- }
70
- end
71
-
72
- # Find lookalike sets.
73
- lookalikes = false
74
- paths = processed.keys.sort
75
- paths.each_index do |k|
76
- pk = paths[k]
77
- a = processed[pk]
78
- (0...k).each do |n|
79
- pn = paths[n]
80
- b = processed[pn]
81
- next unless (a[:path].compare(b[:path])) == 0
82
- a['lookalike'].push pn
83
- b['lookalike'].push pk
84
- $stderr.puts("Similar: #{pn} #{pk}")
85
- lookalikes = true
46
+ processed = {}
47
+ doc.fetch('paths', {}).each_pair do |path, value|
48
+ parts = split_path(path, true)
49
+ processed[path] = {
50
+ 'parts' => parts,
51
+ 'orig' => value,
52
+ 'lookalike' => [],
53
+ path: ServerPath.new(parts)
54
+ }
86
55
  end
87
- end
88
- aargh('Similar paths found.', 7) if lookalikes && env(:error)
89
56
 
90
- # Clean-up temporary fields.
91
- processed.each_value do |v|
92
- v.keys.each { |k| v.delete(k) if k.is_a? Symbol }
93
- end
94
- doc['paths'] = processed
57
+ # Find lookalike sets.
58
+ lookalikes = false
59
+ paths = processed.keys.sort!
60
+ paths.size.times do |k|
61
+ pk = paths[k]
62
+ a = processed[pk]
63
+ k.times do |n|
64
+ pn = paths[n]
65
+ b = processed[pn]
66
+ next unless a[:path].compare(b[:path]).zero?
67
+ a['lookalike'].push pn
68
+ b['lookalike'].push pk
69
+ $stderr.puts("Similar: #{pn} #{pk}")
70
+ lookalikes = true
71
+ end
72
+ end
73
+ return aargh('Similar paths found.', 4) if lookalikes && error
95
74
 
96
- output = env(:out)
97
- if output.empty?
98
- output = $stdout
99
- else
100
- begin
101
- output = File.open(output, 'w')
102
- rescue StandardError
103
- aargh("Failed to open for writing: #{output}", 6)
75
+ # Remove temporary fields.
76
+ processed.each_value do |v|
77
+ v.keys.each { |k| v.delete(k) if k.is_a? Symbol }
104
78
  end
105
- end
79
+ doc['paths'] = processed
106
80
 
107
- case env(:format)
108
- when 'JSON' then output.puts JSON.generate(doc)
109
- when 'YAML' then output.puts YAML.dump(doc)
81
+ dump_result(output_name, YAML.dump(doc), 3)
110
82
  end
111
- output.close
83
+
84
+ exit(main) if (defined? $unit_test).nil?
data/lib/apiobjects.rb ADDED
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './common'
4
+ require 'set'
5
+
6
+ def same(a, b, ignored_keys = Set.new(%w[summary description]))
7
+ return a == b unless a.is_a?(Hash) && b.is_a?(Hash)
8
+ keys = Set.new(a.keys + b.keys) - ignored_keys
9
+ keys.to_a.each do |k|
10
+ return false unless a.key?(k) && b.key?(k)
11
+ return false unless same(a[k], b[k], ignored_keys)
12
+ end
13
+ true
14
+ end
15
+
16
+ def ref_string(name, schema_path)
17
+ "#{schema_path}/#{name}"
18
+ end
19
+
20
+ def reference(obj, schemas, schema_path, ignored_keys = Set.new(%w[summary description]), prefix = 'Schema')
21
+ # Check if identical schema has been added and if so, return the $ref string.
22
+ schemas.keys.sort.each do |k|
23
+ return ref_string(k, schema_path) if same(obj, schemas[k], ignored_keys)
24
+ end
25
+ # One of the numbers will not match existing keys. More number than keys.
26
+ (schemas.size + 1).times do |n|
27
+ # 'x' is to simplify find and replace (Schema1x vs Schema1 and Schema10)
28
+ k = "#{prefix}#{n}x"
29
+ next if schemas.key?(k)
30
+ schemas[k] = obj.merge
31
+ return ref_string(k, schema_path)
32
+ end
33
+ end
34
+
35
+ class Components
36
+ attr_reader :path, :prefix
37
+ attr_accessor :items, :ignored_keys
38
+
39
+ def initialize(path, prefix, ignored_keys = %w[summary description examples example])
40
+ @items = {}
41
+ path = "#/#{path.join('/')}/" if path.is_a?(Array)
42
+ path = "#{path}/" unless path.end_with?('/')
43
+ @path = path
44
+ @prefix = prefix
45
+ @ignored_keys = Set.new(ignored_keys)
46
+ end
47
+
48
+ def add_options(opts)
49
+ opts.on('--use FIELD', 'Use FIELD in comparisons.') do |f|
50
+ @ignored_keys.delete(f)
51
+ end
52
+ opts.on('--ignore FIELD', 'Ignore FIELD in comparisons.') do |f|
53
+ @ignored_keys.add(f)
54
+ end
55
+ end
56
+
57
+ def help
58
+ %(All fields are used in object equality comparisons except:\n#{@ignored_keys.to_a.sort!.join("\n")})
59
+ end
60
+
61
+ def ref_string(name)
62
+ "#{@path}#{name}"
63
+ end
64
+
65
+ def reference(obj)
66
+ # Check if identical schema has been added. If so, return the $ref string.
67
+ @items.each do |k, v|
68
+ return ref_string(k) if same(obj, v, @ignored_keys)
69
+ end
70
+ # One of the numbers will not match existing keys. More number than keys.
71
+ (@items.size + 1).times do |n|
72
+ # 'x' is to simplify find and replace (Schema1x vs Schema1 and Schema10)
73
+ cand = "#{@prefix}#{n}x"
74
+ next if @items.key?(cand)
75
+ @items[cand] = obj.merge
76
+ return ref_string(cand)
77
+ end
78
+ end
79
+ end
80
+
81
+
82
+ class PathOperation
83
+ attr_accessor :path, :operation, :info, :parameters
84
+ attr_accessor :servers, :security, :tags
85
+ attr_accessor :summary, :description
86
+ end
87
+
88
+ # Should mapping JSON schema types to native types be a separate step?
89
+ # Just add x-openapi-sourcetools-native: nativetype
90
+ # Separate program that can pipe modified spec out to code generation?
91
+
92
+ # JSON schema is way too complex to simplify here. One could have a convenience
93
+ # method that determines how many bytes the value needs, and if it needs to be
94
+ # signed.
95
+
96
+ # When creating types for schemas or otherwise, the type name can be added
97
+ # into the item and that way be used as an indicator that the type has been
98
+ # declared or needs a declaration.
99
+
100
+ def make_path_operations(apidoc)
101
+ # Check openapi
102
+ # Store info as is for reference
103
+ # Store servers as is for default value for PathOperation
104
+ # Process components. Lazy manner, only when referenced.
105
+ # Store security as is for default value for PathOperation.
106
+ # Store tags as mapping from name to object for use with PathOperation.
107
+ # Process paths:
108
+ # Store parameters as is for default value for PathOperation.
109
+ # All other fields, check if it looks like OperationObject and create a
110
+ # PathOperation using it. For others, store as is for default value.
111
+
112
+ end
113
+
114
+
115
+ class ServerPath
116
+ include Comparable
117
+
118
+ attr_accessor :parts
119
+
120
+ def initialize(parts)
121
+ @parts = parts
122
+ end
123
+
124
+ def <=>(other) # Variables are after fixed strings.
125
+ pp = other.is_a?(Array) ? other : p.parts
126
+ parts.each_index do |k|
127
+ return 1 if pp.size <= k # Longer comes after shorter.
128
+ pk = parts[k]
129
+ ppk = pp[k]
130
+ if pk.is_a? String
131
+ if ppk.is_a? String
132
+ c = pk <=> ppk
133
+ else
134
+ return -1
135
+ end
136
+ else
137
+ if ppk.is_a? String
138
+ return 1
139
+ else
140
+ c = pk.fetch('var', '') <=> ppk.fetch('var', '')
141
+ end
142
+ end
143
+ return c unless c.zero?
144
+ end
145
+ (parts.size < pp.size) ? -1 : 0
146
+ end
147
+
148
+ def compare(p, range = nil) # Not fit for sorting. Variable equals anything.
149
+ pp = p.is_a?(Array) ? p : p.parts
150
+ if range.nil?
151
+ range = 0...parts.size
152
+ elsif range.is_a? Number
153
+ range = range...(range + 1)
154
+ end
155
+ range.each do |k|
156
+ return 1 if pp.size <= k # Longer comes after shorter.
157
+ ppk = pp[k]
158
+ next unless ppk.is_a? String
159
+ pk = parts[k]
160
+ next unless pk.is_a? String
161
+ c = pk <=> ppk
162
+ return c unless c.zero?
163
+ end
164
+ (parts.size < pp.size) ? -1 : 0
165
+ end
166
+ end
167
+
168
+ # Adds all refs found in the array to refs with given required state.
169
+ def gather_array_refs(refs, items, required)
170
+ items.each do |s|
171
+ r = s['$ref']
172
+ next if r.nil?
173
+ refs[r] = required || refs.fetch(r, false)
174
+ end
175
+ end
176
+
177
+ # For any key '$ref' adds to refs whether referred type is required.
178
+ # Requires that there are no in-lined schemas, openapi-addschemas has been run.
179
+ def gather_refs(refs, schema)
180
+ # This implies types mixed together according to examples. Needs mixed type.
181
+ # AND. Also, mixing may fail. Adds a new schema, do here.
182
+ items = schema['allOf']
183
+ return gather_array_refs(refs, items, true) unless items.nil?
184
+ # As long as one schema is fulfilled, it is ok. OR, first that fits.
185
+ items = schema['anyOf'] if items.nil?
186
+ # oneOf implies selection between different types. No multiple matches. XOR.
187
+ # Needs to ensure that later types do not match.
188
+ # Should check if there is enough difference to ensure single match.
189
+ # Use separate program run after addschemas to create allOf mixed schema
190
+ # and verify the others can be dealt with.
191
+ items = schema['oneOf'] if items.nil?
192
+ return gather_array_refs(refs, items, false) unless items.nil?
193
+ # Defaults below handle it if "type" is not "object".
194
+ reqs = schema.fetch('required', [])
195
+ schema.fetch('properties', {}).each do |name, spec|
196
+ r = spec['$ref']
197
+ next if r.nil?
198
+ refs[r] = reqs.include?(name) || refs.fetch(r, false)
199
+ end
200
+ end
201
+
202
+ class SchemaInfo
203
+ attr_accessor :ref, :schema, :direct_refs, :name, :post_refs
204
+
205
+ def initialize(ref, name, schema)
206
+ @ref = ref
207
+ @name = name
208
+ @schema = schema
209
+ @direct_refs = {}
210
+ gather_refs(@direct_refs, schema)
211
+ end
212
+
213
+ def set_post_refs(seen)
214
+ @post_refs = Set.new(@direct_refs.keys) - seen
215
+ end
216
+
217
+ def to_s
218
+ v = @direct_refs.keys.sort.map { |k| "#{k}:#{@direct_refs[k] ? 'req' : 'opt'}" }
219
+ "#{@ref}: #{v.join(' ')}"
220
+ end
221
+ end
222
+
223
+ def var_or_method_value(x, name)
224
+ if name.start_with?('@')
225
+ n = name
226
+ else
227
+ n = "@#{name}"
228
+ end
229
+ return x.instance_variable_get(n) if x.instance_variable_defined?(n)
230
+ return x.public_send(name) if x.respond_to?(name)
231
+ raise ArgumentError, "#{name} is not #{x.class} instance variable nor public method"
232
+ end
233
+
234
+ class SchemaOrderer
235
+ attr_accessor :schemas, :order, :orderer
236
+
237
+ def initialize(path, schema_specs)
238
+ @schemas = {}
239
+ schema_specs.each do |name, schema|
240
+ r = "#{path}#{name}"
241
+ @schemas[r] = SchemaInfo.new(r, name, schema)
242
+ end
243
+ end
244
+
245
+ def sort!(orderer = 'required_first')
246
+ case orderer
247
+ when 'required_first' then @order = required_first
248
+ when '<=>' then @order = @schemas.values.sort { |a, b| a <=> b }
249
+ else
250
+ @order = @schemas.values.sort do |a, b|
251
+ va = var_or_method_value(a, orderer)
252
+ vb = var_or_method_value(b, orderer)
253
+ va <=> vb
254
+ end
255
+ end
256
+ @orderer = orderer
257
+ seen = Set.new
258
+ @order.each do |si|
259
+ si.set_post_refs(seen)
260
+ seen.add(si.ref)
261
+ end
262
+ @order
263
+ end
264
+
265
+ def required_first
266
+ chosen = []
267
+ until chosen.size == @schemas.size
268
+ used = Set.new(chosen.map { |si| si.ref })
269
+ avail = @schemas.values.select { |si| !used.member?(si.ref) }
270
+ best = nil
271
+ avail.each do |si|
272
+ prereq = chosen.count { |x| x.direct_refs.fetch(si.ref, false) }
273
+ fulfilled = chosen.count { |x| si.direct_refs.fetch(x.ref, false) }
274
+ postreq = si.direct_refs.size - (prereq + fulfilled)
275
+ better = false
276
+ if best.nil?
277
+ better = true
278
+ else
279
+ # Minimize preceding types requiring this.
280
+ if prereq < best.first
281
+ better = true
282
+ elsif prereq == best.first
283
+ # Minimize remaining unfulfilled requires.
284
+ if postreq < best[1]
285
+ better = true
286
+ elsif postreq == best[1]
287
+ # Check mutual direct requirements.
288
+ best_req_si = best.last.direct_refs.fetch(si.ref, false)
289
+ si_req_best = si.direct_refs.fetch(best.last.ref, false)
290
+ if best_req_si
291
+ better = true unless si_req_best
292
+ end
293
+ # Order by name if no other difference.
294
+ better = si.ref < best.last.ref unless better
295
+ end
296
+ end
297
+ end
298
+ best = [ prereq, postreq, si ] if better
299
+ end
300
+ chosen.push(best.last)
301
+ end
302
+ chosen
303
+ end
304
+ end