greenroom 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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "optparse"
6
+
7
+ module Greenroom
8
+ module CLI
9
+ module Check
10
+ Result = Data.define(:accepted, :message, :experiment, :candidate)
11
+
12
+ class << self
13
+ def run(arguments, stdout: $stdout, stderr: $stderr)
14
+ json = false
15
+ parser = OptionParser.new do |options|
16
+ options.banner = "Usage: greenroom check [--json] [commit]"
17
+ options.on("--json") { json = true }
18
+ end
19
+ parser.parse!(arguments)
20
+ return usage(stderr, parser) if arguments.length > 1
21
+
22
+ require "rubocop-ast"
23
+ result = Repository.new(Dir.pwd).check(arguments.first || "HEAD")
24
+ print_result(stdout, result, json)
25
+ result.accepted ? 0 : 1
26
+ rescue OptionParser::ParseError => error
27
+ stderr.puts(error.message)
28
+ stderr.puts(parser)
29
+ 2
30
+ end
31
+
32
+ private
33
+
34
+ def usage(stderr, parser)
35
+ stderr.puts("One commit is allowed.")
36
+ stderr.puts(parser)
37
+ 2
38
+ end
39
+
40
+ def print_result(stdout, result, json)
41
+ if json
42
+ stdout.puts(JSON.generate({
43
+ accepted: result.accepted,
44
+ message: result.message,
45
+ experiment: result.experiment,
46
+ candidate: result.candidate
47
+ }))
48
+ else
49
+ stdout.puts(result.message)
50
+ end
51
+ end
52
+ end
53
+
54
+ # This check gates an automatic merge. The RuboCop check sees one file
55
+ # version, so it cannot compare the new method body with the old body.
56
+ class Repository
57
+ def initialize(directory)
58
+ @directory = directory
59
+ end
60
+
61
+ def check(commit)
62
+ resolved = git("rev-parse", "--verify", "#{commit}^{commit}").strip
63
+ parents = git("rev-list", "--parents", "-n", "1", resolved).split.drop(1)
64
+ # A merge has more than one source state. A comparison with its first
65
+ # parent would give a different meaning to the acceptance result.
66
+ return reject("The commit is a merge commit.") if parents.length > 1
67
+ return reject("The commit has no parent.") if parents.empty?
68
+
69
+ changes = git("diff-tree", "--no-commit-id", "--name-status", "-r", "-M20%", parents.first, resolved).lines
70
+ # A rejected shape goes to human review. Thus, an uncertain result
71
+ # must reject instead of accepting a change for an automatic merge.
72
+ return reject("The commit must modify one Ruby file.") unless changes.one?
73
+
74
+ fields = changes.first.chomp.split("\t")
75
+ # Git can report a rename as R or as an addition and a deletion.
76
+ # Requiring one M entry rejects both forms and their identity change.
77
+ return reject("The changed file must have status M.") unless fields.length == 2 && fields.first == "M"
78
+
79
+ path = fields.last
80
+ return reject("The changed file must have the .rb extension.") unless path.end_with?(".rb")
81
+
82
+ # Complete blobs support a syntax tree comparison across both file
83
+ # versions. Parsing unified diff lines would mix syntax with context.
84
+ before = git("show", "#{parents.first}:#{path}")
85
+ after = git("show", "#{resolved}:#{path}")
86
+ Analyzer.new(before, after).check
87
+ rescue GitError, Analyzer::SourceError => error
88
+ reject(error.message)
89
+ end
90
+
91
+ private
92
+
93
+ GitError = Class.new(StandardError)
94
+
95
+ def git(*arguments)
96
+ stdout, _stderr, status = Open3.capture3("git", *arguments, chdir: @directory)
97
+ raise GitError, "Git could not read the commit." unless status.success?
98
+
99
+ stdout
100
+ end
101
+
102
+ def reject(message)
103
+ Result.new(false, message, nil, nil)
104
+ end
105
+ end
106
+
107
+ class Analyzer
108
+ SourceError = Class.new(StandardError)
109
+ Scope = Data.define(:name, :node, :methods, :statements)
110
+ Experiment = Data.define(:name, :method, :use_block, :candidate)
111
+
112
+ def initialize(before, after)
113
+ # Syntax trees omit source locations. Therefore, comment and white
114
+ # space positions do not affect the acceptance result.
115
+ @before = parse(before)
116
+ @after = parse(after)
117
+ end
118
+
119
+ def check
120
+ return reject("Unsupported Ruby method or class syntax is present.") if unsupported?(@before) || unsupported?(@after)
121
+
122
+ before_scopes = scopes(@before)
123
+ after_scopes = scopes(@after)
124
+ return reject("The class and module structure changed.") unless before_scopes.keys == after_scopes.keys
125
+ return reject("The class or module header changed.") unless before_scopes.all? { |name, scope| same_header?(scope, after_scopes.fetch(name)) }
126
+ return reject("A top-level statement changed.") unless top_level(@before) == top_level(@after)
127
+
128
+ changed = []
129
+ added = []
130
+ before_scopes.each do |name, before_scope|
131
+ after_scope = after_scopes.fetch(name)
132
+ removed_names = before_scope.methods.keys - after_scope.methods.keys
133
+ return reject("An existing method was removed.") unless removed_names.empty?
134
+
135
+ common_names = before_scope.methods.keys & after_scope.methods.keys
136
+ common_names.each do |method_name|
137
+ changed << [name, before_scope.methods.fetch(method_name), after_scope.methods.fetch(method_name)] unless same_ast?(before_scope.methods.fetch(method_name), after_scope.methods.fetch(method_name))
138
+ end
139
+ (after_scope.methods.keys - before_scope.methods.keys).each do |method_name|
140
+ added << [name, after_scope.methods.fetch(method_name)]
141
+ end
142
+ end
143
+
144
+ return reject("Exactly one existing method must change.") unless changed.one?
145
+ return reject("Exactly one candidate method must be added.") unless added.one?
146
+
147
+ scope_name, before_method, after_method = changed.first
148
+ added_scope_name, candidate = added.first
149
+ return reject("The candidate method must be in the changed class.") unless scope_name == added_scope_name
150
+ return reject("The candidate method must be private.") unless private_method?(after_scopes.fetch(scope_name), candidate)
151
+ return reject("An existing method visibility changed.") unless same_visibilities?(before_scopes.fetch(scope_name), after_scopes.fetch(scope_name))
152
+ return reject("Another class body statement changed.") unless unchanged_statements?(before_scopes.fetch(scope_name), after_scopes.fetch(scope_name), candidate)
153
+ return reject("The changed method signature must stay the same.") unless same_ast?(before_method.children[1], after_method.children[1])
154
+ other_scope_changed = before_scopes.any? do |name, before_scope|
155
+ next false if name == scope_name
156
+
157
+ after_scope = after_scopes.fetch(name)
158
+ !unchanged_statements?(before_scope, after_scope)
159
+ end
160
+ return reject("Another class or module changed.") if other_scope_changed
161
+
162
+ science = science_blocks(after_method)
163
+ unless science
164
+ shape_error = science_shape_error(after_method)
165
+ return reject(shape_error || "The changed method body must contain one Scientist.run call.")
166
+ end
167
+
168
+ use_block, try_block, _compare_block, experiment, science_statement = science
169
+ return reject("The use block must match the old method body.") unless matching_span?(before_method, after_method, science_statement, use_block)
170
+ # Tests found behavior changes when statements move into a block.
171
+ # A return in the moved part bypasses compare and publish.
172
+ # A binding sees the block's local variables. Tests found the same
173
+ # behavior for yield, rescue, ensure, __method__, local variables,
174
+ # and constant references, so those forms remain valid.
175
+ return reject("A return in a use or try block is unsafe.") if unsafe_return?(use_block) || unsafe_return?(try_block)
176
+ return reject("A binding in a use or try block is unsafe.") if binding_call?(use_block) || binding_call?(try_block)
177
+
178
+ candidate_name = candidate.children[0]
179
+ Result.new(true, "Accepted experiment #{experiment} with candidate #{candidate_name}.", experiment, candidate_name.to_s)
180
+ end
181
+
182
+ # The judge uses the same structural relation that the merge gate
183
+ # verifies. A second parser could accept a shape that the gate rejects.
184
+ def experiments
185
+ scopes(@after).values.flat_map do |scope|
186
+ scope.methods.values.filter_map do |method|
187
+ science = science_blocks(method)
188
+ next unless science
189
+
190
+ use_block, try_block, _compare_block, name = science
191
+ try_body = block_body(try_block)
192
+ next unless try_body&.send_type? && try_body.receiver.nil?
193
+
194
+ candidate = scope.methods[try_body.method_name]
195
+ Experiment.new(name, method, use_block, candidate) if candidate
196
+ end
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ def parse(source)
203
+ processed = RuboCop::AST::ProcessedSource.new(source, 3.2)
204
+ raise SourceError, "The Ruby source has a syntax error." unless processed.valid_syntax?
205
+
206
+ processed.ast
207
+ end
208
+
209
+ def scopes(root)
210
+ found = {}
211
+ walk_scopes(root, [], found)
212
+ found
213
+ end
214
+
215
+ def walk_scopes(node, namespace, found)
216
+ return unless node.is_a?(RuboCop::AST::Node)
217
+
218
+ if node.class_type? || node.module_type?
219
+ name_node = node.children[0]
220
+ raise SourceError, "Anonymous classes are not supported." unless name_node&.const_type?
221
+
222
+ name = constant_name(name_node, namespace)
223
+ raise SourceError, "A class or module is reopened in this file." if found.key?(name)
224
+
225
+ body = node.class_type? ? node.children[2] : node.children[1]
226
+ statements = body&.begin_type? ? body.children : [body].compact
227
+ definitions = statements.select(&:def_type?)
228
+ methods = definitions.to_h { |method| [method.children[0], method] }
229
+ raise SourceError, "Duplicate method definitions are not supported." unless definitions.length == methods.length
230
+
231
+ found[name] = Scope.new(name, node, methods, statements)
232
+ statements.each { |statement| walk_scopes(statement, name.split("::"), found) unless statement.def_type? }
233
+ else
234
+ node.children.each { |child| walk_scopes(child, namespace, found) }
235
+ end
236
+ end
237
+
238
+ def constant_name(node, namespace)
239
+ parent, name = node.children
240
+ return (namespace + [name.to_s]).join("::") unless parent
241
+
242
+ [constant_name(parent, []), name].join("::")
243
+ end
244
+
245
+ def same_ast?(left, right)
246
+ ast_value(left) == ast_value(right)
247
+ end
248
+
249
+ def ast_value(value)
250
+ return value unless value.is_a?(RuboCop::AST::Node)
251
+
252
+ [value.type, *value.children.map { |child| ast_value(child) }]
253
+ end
254
+
255
+ def method_body(method)
256
+ method.children[2]
257
+ end
258
+
259
+ def block_body(block)
260
+ block.children[2]
261
+ end
262
+
263
+ def science_blocks(method)
264
+ science_statements = method_statements(method).filter_map do |statement|
265
+ science_block = if statement.block_type?
266
+ statement
267
+ elsif statement.lvasgn_type? && statement.children[1]&.block_type?
268
+ statement.children[1]
269
+ end
270
+ [statement, science_block] if scientist_run_block?(science_block)
271
+ end
272
+ return unless science_statements.one?
273
+
274
+ science_statement, science_block = science_statements.first
275
+
276
+ send_node, arguments, science_body = science_block.children
277
+ return unless send_node.send_type? && send_node.method?(:run)
278
+ return unless send_node.receiver&.const_type? && send_node.receiver.const_name == "Scientist"
279
+ return unless send_node.arguments.one? && send_node.arguments.first.str_type?
280
+ return unless arguments.one?
281
+
282
+ experiment_variable = arguments.children.first.children.first
283
+ expressions = science_body&.begin_type? ? science_body.children : [science_body].compact
284
+ return unless expressions.length == 3
285
+
286
+ methods = %i[use try compare]
287
+ return unless expressions.each_with_index.all? do |expression, index|
288
+ expression.block_type? && expression.send_node.method?(methods[index]) &&
289
+ expression.send_node.receiver&.lvar_type? && expression.send_node.receiver.children.first == experiment_variable
290
+ end
291
+
292
+ [*expressions, send_node.arguments.first.value, science_statement]
293
+ end
294
+
295
+ def science_shape_error(method)
296
+ statement = method_statements(method).find do |item|
297
+ each_node(item).any? { |node| scientist_run_block?(node) }
298
+ end
299
+ return unless statement
300
+
301
+ direct_block = statement.lvasgn_type? ? statement.children[1] : statement
302
+ return if scientist_run_block?(direct_block)
303
+
304
+ if statement.masgn_type?
305
+ "The Scientist.run statement must be a bare call or a local variable assignment."
306
+ else
307
+ "The Scientist.run call must be a direct method body statement."
308
+ end
309
+ end
310
+
311
+ def scientist_run_block?(node)
312
+ return false unless node&.block_type?
313
+
314
+ send_node = node.send_node
315
+ send_node.method?(:run) && send_node.receiver&.const_type? && send_node.receiver.const_name == "Scientist"
316
+ end
317
+
318
+ def matching_span?(before_method, after_method, science_statement, use_block)
319
+ before = method_statements(before_method)
320
+ after = method_statements(after_method)
321
+ science_index = after.index(science_statement)
322
+ suffix_length = after.length - science_index - 1
323
+ middle_end = before.length - suffix_length
324
+ return false unless middle_end > science_index
325
+
326
+ # A statement outside the block keeps its position and behavior.
327
+ # Thus, the science block can wrap one body span.
328
+ prefix_matches = statement_values(before.take(science_index)) == statement_values(after.take(science_index))
329
+ suffix_matches = statement_values(before.drop(middle_end)) == statement_values(after.drop(science_index + 1))
330
+ middle = before[science_index...middle_end]
331
+ use = node_statements(block_body(use_block))
332
+ use_matches = if science_statement.lvasgn_type?
333
+ assignment = middle.last
334
+ assignment&.lvasgn_type? && assignment.children.first == science_statement.children.first &&
335
+ statement_values([*middle[0...-1], assignment.children[1]]) == statement_values(use)
336
+ else
337
+ statement_values(middle) == statement_values(use)
338
+ end
339
+ prefix_matches && suffix_matches && use_matches
340
+ end
341
+
342
+ def method_statements(method)
343
+ node_statements(method_body(method))
344
+ end
345
+
346
+ def node_statements(node)
347
+ node&.begin_type? ? node.children : [node].compact
348
+ end
349
+
350
+ def private_method?(scope, method)
351
+ visibility_at(scope, method) == :private
352
+ end
353
+
354
+ def visibility_at(scope, method)
355
+ visibility = :public
356
+ scope.statements.each do |statement|
357
+ visibility = statement.method_name if visibility_call?(statement)
358
+ return visibility if statement.equal?(method)
359
+ end
360
+ nil
361
+ end
362
+
363
+ def visibility_call?(node)
364
+ node.send_type? && node.receiver.nil? && %i[public protected private].include?(node.method_name) && node.arguments.empty?
365
+ end
366
+
367
+ def unchanged_statements?(before_scope, after_scope, candidate = nil)
368
+ before = before_scope.statements.reject(&:def_type?)
369
+ after = after_scope.statements.reject(&:def_type?).dup
370
+ if candidate
371
+ candidate_index = after_scope.statements.index(candidate)
372
+ marker = after_scope.statements[0...candidate_index].reverse.find { |statement| visibility_call?(statement) }
373
+ before_private_count = before.count { |statement| statement.send_type? && statement.method?(:private) }
374
+ after_private_count = after.count { |statement| statement.send_type? && statement.method?(:private) }
375
+ after.delete_at(after.index(marker)) if marker&.method?(:private) && after_private_count == before_private_count + 1
376
+ end
377
+ statement_values(before) == statement_values(after)
378
+ end
379
+
380
+ def same_visibilities?(before_scope, after_scope)
381
+ before_scope.methods.all? do |name, method|
382
+ visibility_at(before_scope, method) == visibility_at(after_scope, after_scope.methods.fetch(name))
383
+ end
384
+ end
385
+
386
+ def same_header?(before_scope, after_scope)
387
+ before_node = before_scope.node
388
+ after_node = after_scope.node
389
+ count = before_node.class_type? ? 2 : 1
390
+ same_ast?(sequence(before_node.children.take(count)), sequence(after_node.children.take(count)))
391
+ end
392
+
393
+ def top_level(root)
394
+ statements = root&.begin_type? ? root.children : [root].compact
395
+ statement_values(statements)
396
+ end
397
+
398
+ def statement_values(statements)
399
+ statements.map do |statement|
400
+ if statement.class_type? || statement.module_type?
401
+ count = statement.class_type? ? 2 : 1
402
+ [:scope, statement.type, *statement.children.take(count).map { |child| ast_value(child) }]
403
+ else
404
+ ast_value(statement)
405
+ end
406
+ end
407
+ end
408
+
409
+ def unsupported?(root)
410
+ each_node(root).any? do |node|
411
+ node.defs_type? || node.sclass_type? ||
412
+ (node.send_type? && node.method?(:define_method)) ||
413
+ (node.send_type? && node.method?(:new) && node.receiver&.const_type? && node.receiver.const_name == "Class")
414
+ end
415
+ end
416
+
417
+ def sequence(nodes)
418
+ RuboCop::AST::Node.new(:begin, nodes)
419
+ end
420
+
421
+ def unsafe_return?(block)
422
+ unsafe_return_in?(block_body(block), [])
423
+ end
424
+
425
+ def unsafe_return_in?(node, scopes)
426
+ return false unless node.is_a?(RuboCop::AST::Node)
427
+ return true if node.return_type? && scopes.none? { |scope| scope == :safe }
428
+
429
+ # A nested method or lambda owns its return. A proc or a plain block
430
+ # keeps the science block as the nearest return scope.
431
+ child_scopes = scopes
432
+ child_scopes = scopes + [:safe] if node.def_type? || node.defs_type? || (node.block_type? && node.lambda?)
433
+ node.children.any? { |child| unsafe_return_in?(child, child_scopes) }
434
+ end
435
+
436
+ def binding_call?(block)
437
+ each_node(block_body(block)).any? { |node| node.send_type? && node.receiver.nil? && node.method?(:binding) }
438
+ end
439
+
440
+ def each_node(node, values = [])
441
+ return values unless node.is_a?(RuboCop::AST::Node)
442
+
443
+ values << node
444
+ node.children.each { |child| each_node(child, values) }
445
+ values
446
+ end
447
+
448
+ def reject(message)
449
+ Result.new(false, message, nil, nil)
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end