openapi-sourcetools 0.4.2 → 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,60 +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, false);
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('-p', '--prune', 'Remove all unreferenced objects under components.') do
29
- env(:prune, true)
30
- end
31
- opts.on('--yaml', 'Output format is YAML (default).') do
32
- env(:format, 'YAML')
33
- end
34
- opts.on('--json', 'Output format is JSON.') do
35
- env(:format, 'JSON')
36
- end
37
- opts.on('-h', '--help', 'Print this help and exit.') do
38
- $stdout.puts %(#{opts}
39
-
40
- Source files are combined to form one API specification document. Sources are
41
- allowed only to append to the merged document, not re-define anything in it.
42
- )
43
- exit 0
44
- end
45
- end
46
- parser.parse!
47
-
48
- aargh("Format neither JSON nor YAML: #{env(:format)}", 1) unless %w[JSON YAML].include? env(:format)
49
-
50
- def read_source(filename)
51
- YAML.safe_load(File.read(filename))
52
- rescue Errno::ENOENT => e
53
- aargh("Could not read #{filename}", 2)
54
- rescue StandardError => e
55
- aargh(e.to_s, 3)
56
- end
57
-
58
12
  def raise_se(message)
59
13
  raise StandardError, message
60
14
  end
@@ -74,12 +28,12 @@ end
74
28
 
75
29
  def add_undefined(merged, incoming, filename, path, max_depths)
76
30
  incoming.each_pair do |key, value|
77
- unless merged.has_key? key
31
+ unless merged.key? key
78
32
  merged[key] = value
79
33
  next
80
34
  end
81
35
  m = merged[key]
82
- 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)
83
37
  raise_se("Re-definition of #{key} #{path_combo(path)} in #{filename}") if too_deep(path, max_depths)
84
38
  if m.is_a? Hash # paths or similar
85
39
  path.push key
@@ -94,22 +48,6 @@ def add_undefined(merged, incoming, filename, path, max_depths)
94
48
  raise_se "Re-definition of #{key} #{path_combo(path)} in #{filename}"
95
49
  end
96
50
  end
97
- rescue StandardError => e
98
- aargh(e.to_s, 3)
99
- end
100
-
101
- max_depths = Hash.new(0)
102
- max_depths['openapi'] = 1
103
- max_depths['info'] = 1
104
- max_depths['servers'] = 1
105
- max_depths['paths'] = 2 # Allows get, post, options, etc. from different files.
106
- max_depths['webhooks'] = 2
107
- max_depths['components'] = 2
108
- max_depths['security'] = 1
109
- max_depths['tags'] = 1
110
- merged = {}
111
- ARGV.each do |filename|
112
- add_undefined(merged, read_source(filename), filename, [], max_depths)
113
51
  end
114
52
 
115
53
  def gather_refs(doc, found)
@@ -126,11 +64,16 @@ def gather_refs(doc, found)
126
64
  end
127
65
  end
128
66
 
129
- if env(:prune)
67
+ def prune(merged)
130
68
  prev_refs = Set.new
131
69
  loop do # May have references from deleted so repeat until nothing deleted.
132
70
  refs = Set.new
133
71
  gather_refs(merged, refs)
72
+ merged.fetch('security', []).each do |sec|
73
+ sec.each_key do |key|
74
+ refs.add("#/components/securitySchemes/#{key}")
75
+ end
76
+ end
134
77
  used = {}
135
78
  all = merged.fetch('components', {})
136
79
  refs.each do |ref|
@@ -145,7 +88,7 @@ if env(:prune)
145
88
  sub = used
146
89
  p.each_index do |k|
147
90
  if k + 1 < p.size
148
- sub[p[k]] = {} unless sub.has_key? p[k]
91
+ sub[p[k]] = {} unless sub.key? p[k]
149
92
  sub = sub[p[k]]
150
93
  else
151
94
  sub[p[k]] = item
@@ -158,19 +101,58 @@ if env(:prune)
158
101
  end
159
102
  end
160
103
 
161
- output = env(:out)
162
- if output.empty?
163
- output = $stdout
164
- else
165
- begin
166
- output = File.open(output, 'w')
167
- rescue StandardError
168
- 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
132
+ 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)
169
151
  end
170
- end
171
152
 
172
- case env(:format)
173
- when 'JSON' then output.puts JSON.generate(merged)
174
- when 'YAML' then output.puts YAML.dump(merged)
153
+ prune(merged) unless keep
154
+
155
+ dump_result(output_file, YAML.dump(merged), 3)
175
156
  end
176
- 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