inquirex 0.7.0 → 0.9.4

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,559 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module SafeSource
5
+ # Default-deny AST allowlist for the Inquirex flow DSL.
6
+ #
7
+ # The validator parses the source with Prism and walks it by *recursive
8
+ # descent against an expected shape* rather than by visiting every node and
9
+ # asking "is this one forbidden?". At each position — top-level statement,
10
+ # flow-block statement, step-block statement, argument, keyword value, Hash
11
+ # element — only the handful of node types the real DSL produces there are
12
+ # accepted, and anything else is a violation. A node type nobody thought
13
+ # about is therefore rejected by construction, which is the whole point: a
14
+ # blocklist of forbidden methods would be defeated by the first construct
15
+ # that was overlooked.
16
+ #
17
+ # What that rules out, without needing to name any of it: `system`,
18
+ # backticks and `%x`, `exec`/`spawn`/`fork`, `require`/`load`, `eval` and
19
+ # the `*_eval` family, `send`/`__send__`/`public_send`/`method`, every
20
+ # constant reference other than the `Inquirex` entry point (so no `File`,
21
+ # `IO`, `Dir`, `Kernel`, `ENV`, `ObjectSpace`, `Process`,
22
+ # `Object.const_get`), instance, class and global variables,
23
+ # `def`/`class`/`module`, `begin`/`rescue`, `at_exit`, `BEGIN`/`END`,
24
+ # singleton definitions, `__FILE__`-style magic, assignments, conditionals,
25
+ # loops, string/symbol/regexp interpolation (including inside heredocs),
26
+ # splats and block-pass arguments.
27
+ #
28
+ # Ruby blocks are accepted only where the DSL itself opens a nested scope
29
+ # ({Vocabulary} `block:`), and those blocks are validated statement by
30
+ # statement in turn. Every other block is rejected, which is why `compute`,
31
+ # a block-form `default`, `fallback` and an action's `run` cannot be used in
32
+ # safe mode: a `compute` block is indistinguishable from a payload.
33
+ #
34
+ # @example
35
+ # Validator.new("Inquirex.define { start :a }").violations # => []
36
+ # Validator.new("system('id')").violations
37
+ # # => ["line 1: the DSL must be a single `Inquirex.define` block"]
38
+ class Validator
39
+ # Cap on reported violations, so a hostile 64 KiB payload cannot turn a
40
+ # validation error into a megabyte of flash message.
41
+ MAX_REPORTED = 10
42
+
43
+ # Appended to violations an author could legitimately have meant.
44
+ UNSAFE_HINT = " — evaluate source you wrote yourself with `Inquirex.load_dsl(text, unsafe: true)`"
45
+
46
+ # Node types worth naming explicitly in a violation message, because the
47
+ # humanized class name would not tell the author what they actually wrote.
48
+ NODE_LABELS = {
49
+ Prism::XStringNode => "a backtick or %x command",
50
+ Prism::InterpolatedXStringNode => "a backtick or %x command",
51
+ Prism::InterpolatedStringNode => "an interpolated string (interpolation is never allowed, not even in a heredoc)",
52
+ Prism::InterpolatedSymbolNode => "an interpolated symbol",
53
+ Prism::InterpolatedRegularExpressionNode => "an interpolated regular expression",
54
+ Prism::RegularExpressionNode => "a regular expression",
55
+ Prism::ConstantReadNode => "a constant reference",
56
+ Prism::ConstantPathNode => "a constant reference",
57
+ Prism::InstanceVariableReadNode => "an instance variable",
58
+ Prism::ClassVariableReadNode => "a class variable",
59
+ Prism::GlobalVariableReadNode => "a global variable",
60
+ Prism::DefNode => "a method definition",
61
+ Prism::ClassNode => "a class definition",
62
+ Prism::ModuleNode => "a module definition",
63
+ Prism::SingletonClassNode => "a singleton class definition",
64
+ Prism::BeginNode => "a begin/rescue block",
65
+ Prism::LambdaNode => "a lambda",
66
+ Prism::BlockNode => "a block",
67
+ Prism::BlockArgumentNode => "a block-pass (&) argument",
68
+ Prism::SplatNode => "a splat (*) argument",
69
+ Prism::AssocSplatNode => "a double-splat (**) argument",
70
+ Prism::SourceFileNode => "__FILE__",
71
+ Prism::SourceLineNode => "__LINE__",
72
+ Prism::SourceEncodingNode => "__ENCODING__",
73
+ Prism::SelfNode => "self",
74
+ Prism::PreExecutionNode => "a BEGIN block",
75
+ Prism::PostExecutionNode => "an END block"
76
+ }.freeze
77
+
78
+ # @param source [String, nil] Inquirex DSL source to validate
79
+ # @param max_bytes [Integer] ceiling on source size
80
+ # @param max_depth [Integer] ceiling on AST nesting depth
81
+ def initialize(source,
82
+ max_bytes: SafeSource::DEFAULT_MAX_SOURCE_BYTES,
83
+ max_depth: SafeSource::DEFAULT_MAX_DEPTH)
84
+ @source = source
85
+ @max_bytes = max_bytes
86
+ @max_depth = max_depth
87
+ end
88
+
89
+ # Every reason this source falls outside the allowlist.
90
+ #
91
+ # @return [Array<String>] `"line N: reason"` messages, empty when safe
92
+ def violations
93
+ @violations ||= analyze.uniq.first(MAX_REPORTED)
94
+ end
95
+
96
+ private
97
+
98
+ # @return [Array<String>]
99
+ def analyze
100
+ @found = []
101
+ return ["the DSL is blank"] if @source.nil? || @source.to_s.strip.empty?
102
+ return ["the DSL must be a String, got #{@source.class}"] unless @source.is_a?(String)
103
+
104
+ size = @source.bytesize
105
+ return ["the DSL is #{size} bytes, over the #{@max_bytes}-byte limit"] if size > @max_bytes
106
+
107
+ result = Prism.parse(@source)
108
+ return syntax_violations(result) if result.failure?
109
+ # A __END__ data section is inert under eval, but nothing that generates
110
+ # flow DSL emits one, and a payload parked below the marker is exactly
111
+ # the sort of thing a future refactor could start feeding somewhere else.
112
+ return ["line #{result.data_loc.start_line}: the DSL must not have a __END__ data section"] if result.data_loc
113
+ if (comment = foreign_encoding(result))
114
+ return ["the DSL must not declare a source encoding (found #{comment.value.inspect}); write UTF-8"]
115
+ end
116
+
117
+ check_program(result.value)
118
+ @found
119
+ end
120
+
121
+ # An `# encoding:` magic comment naming anything other than UTF-8 or
122
+ # US-ASCII. Validation and evaluation must agree on where one token ends
123
+ # and the next begins; in encodings whose multi-byte sequences may contain
124
+ # ASCII bytes (Shift_JIS and friends) that agreement depends on both
125
+ # readers applying the same encoding, and the comment is the only lever a
126
+ # payload has over it. Nothing that generates flow DSL emits one.
127
+ #
128
+ # @param result [Prism::ParseResult]
129
+ # @return [Prism::MagicComment, nil]
130
+ def foreign_encoding(result)
131
+ result.magic_comments.find do |comment|
132
+ %w[encoding coding].include?(comment.key.downcase) &&
133
+ !%w[utf-8 utf8 us-ascii ascii binary ascii-8bit].include?(comment.value.downcase)
134
+ end
135
+ end
136
+
137
+ # @param result [Prism::ParseResult]
138
+ # @return [Array<String>]
139
+ def syntax_violations(result)
140
+ result.errors.first(MAX_REPORTED).map do |error|
141
+ "line #{error.location.start_line}: syntax error — #{error.message}"
142
+ end
143
+ end
144
+
145
+ # Records a violation against a node's line.
146
+ #
147
+ # @param node [Prism::Node, nil]
148
+ # @param message [String]
149
+ # @return [nil]
150
+ def reject(node, message)
151
+ line = node&.location&.start_line || 1
152
+ @found << "line #{line}: #{message}"
153
+ nil
154
+ end
155
+
156
+ # The whole program must be exactly one `Inquirex.define` block.
157
+ #
158
+ # @param program [Prism::ProgramNode]
159
+ # @return [void]
160
+ def check_program(program)
161
+ body = program.statements.body
162
+ if body.length != 1
163
+ return reject(program,
164
+ "the DSL must be a single `#{Vocabulary::ENTRY_CONSTANT}.#{Vocabulary::ENTRY_METHOD}` block " \
165
+ "(found #{body.length} top-level statements)")
166
+ end
167
+
168
+ check_entry(body.first)
169
+ end
170
+
171
+ # @param node [Prism::Node] the single top-level statement
172
+ # @return [void]
173
+ def check_entry(node)
174
+ unless entry_call?(node)
175
+ return reject(node,
176
+ "the DSL must be a single `#{Vocabulary::ENTRY_CONSTANT}.#{Vocabulary::ENTRY_METHOD}` block")
177
+ end
178
+
179
+ spec = Vocabulary.entry_spec
180
+ check_block_for(node, spec, depth: 1)
181
+ check_arguments(node, spec, depth: 1)
182
+ end
183
+
184
+ # @param node [Prism::Node]
185
+ # @return [Boolean] whether this is literally `Inquirex.define`
186
+ def entry_call?(node)
187
+ node.is_a?(Prism::CallNode) &&
188
+ node.name == Vocabulary::ENTRY_METHOD &&
189
+ !node.safe_navigation? &&
190
+ node.receiver.is_a?(Prism::ConstantReadNode) &&
191
+ node.receiver.name == Vocabulary::ENTRY_CONSTANT
192
+ end
193
+
194
+ # Validates every statement in a block body against a scope's table.
195
+ #
196
+ # @param block [Prism::BlockNode]
197
+ # @param scope [Symbol] a scope registered with {Vocabulary.register_scope}
198
+ # @param depth [Integer]
199
+ # @return [void]
200
+ def check_block(block, scope, depth:)
201
+ return reject(block, too_deep_message) if too_deep?(depth)
202
+
203
+ reject(block.parameters, "#{scope_label(scope)} block takes no parameters") if block.parameters
204
+
205
+ body = block.body
206
+ return if body.nil?
207
+ unless body.is_a?(Prism::StatementsNode)
208
+ return reject(body, "#{describe(body)} is not allowed inside #{scope_label(scope)} block")
209
+ end
210
+
211
+ body.body.each { |statement| check_call(statement, scope, depth: depth + 1) }
212
+ end
213
+
214
+ # @param node [Prism::Node] one statement from a block body
215
+ # @param scope [Symbol]
216
+ # @param depth [Integer]
217
+ # @return [void]
218
+ def check_call(node, scope, depth:)
219
+ return reject(node, too_deep_message) if too_deep?(depth)
220
+
221
+ unless plain_self_call?(node)
222
+ return reject(node, "#{describe(node)} is not allowed inside #{scope_label(scope)} block")
223
+ end
224
+
225
+ spec = Vocabulary.spec_for(scope, node.name)
226
+ return reject_unknown_call(node, scope) if spec.nil?
227
+
228
+ # Blocks are checked first so that `default { ... }` reports the Ruby
229
+ # block, not the argument count it happens to also get wrong.
230
+ check_block_for(node, spec, depth: depth)
231
+ check_arguments(node, spec, depth: depth)
232
+ end
233
+
234
+ # Explains a call the scope's table does not contain — quoting the
235
+ # recorded reason when the word exists but was deliberately excluded.
236
+ #
237
+ # @param node [Prism::CallNode]
238
+ # @param scope [Symbol]
239
+ # @return [nil]
240
+ def reject_unknown_call(node, scope)
241
+ reason = Vocabulary.exclusion_for(scope, node.name)
242
+ return reject(node, "`#{node.name}` is not available in safe mode: #{reason}#{UNSAFE_HINT}") if reason
243
+
244
+ reject(node, "`#{node.name}` is not allowed inside #{scope_label(scope)} block")
245
+ end
246
+
247
+ # A bare `keyword ...` call on implicit self — no receiver, no `&.`.
248
+ #
249
+ # @param node [Prism::Node]
250
+ # @return [Boolean]
251
+ def plain_self_call?(node)
252
+ node.is_a?(Prism::CallNode) &&
253
+ node.receiver.nil? &&
254
+ !node.safe_navigation? &&
255
+ node.call_operator_loc.nil?
256
+ end
257
+
258
+ # @param node [Prism::CallNode]
259
+ # @param spec [CallSpec]
260
+ # @param depth [Integer]
261
+ # @return [void]
262
+ def check_arguments(node, spec, depth:)
263
+ args = node.arguments&.arguments&.dup || []
264
+
265
+ # A trailing keyword hash is only peeled off when the call actually
266
+ # takes keywords. For `options single: "Single"` the hash IS the one
267
+ # positional argument, which is exactly how Ruby passes it.
268
+ keywords = args.pop if spec.keywords && args.last.is_a?(Prism::KeywordHashNode)
269
+
270
+ check_positional(node, args, spec.positional, depth: depth)
271
+ check_keywords(node, keywords, spec.keywords, depth: depth)
272
+ end
273
+
274
+ # @param node [Prism::CallNode] for the error message and line number
275
+ # @param args [Array<Prism::Node>] positional arguments
276
+ # @param descriptor [Array<Symbol>, Hash] see {CallSpec#positional}
277
+ # @param depth [Integer]
278
+ # @return [void]
279
+ def check_positional(node, args, descriptor, depth:)
280
+ case descriptor
281
+ when Array
282
+ if args.length != descriptor.length
283
+ return reject(node, "`#{node.name}` takes #{descriptor.length} argument(s), got #{args.length}")
284
+ end
285
+
286
+ descriptor.each_with_index do |kind, index|
287
+ check_value(args[index], kind, depth: depth + 1, context: "`#{node.name}` argument #{index + 1}")
288
+ end
289
+ when Hash
290
+ check_variadic(node, args, descriptor, depth: depth)
291
+ end
292
+ end
293
+
294
+ # @param node [Prism::CallNode]
295
+ # @param args [Array<Prism::Node>]
296
+ # @param descriptor [Hash] `{ repeat:, min: }` or `{ optional: }`
297
+ # @param depth [Integer]
298
+ # @return [void]
299
+ def check_variadic(node, args, descriptor, depth:)
300
+ if (kind = descriptor[:repeat])
301
+ minimum = descriptor.fetch(:min, 0)
302
+ return reject(node, "`#{node.name}` needs at least #{minimum} argument(s)") if args.length < minimum
303
+ else
304
+ kind = descriptor.fetch(:optional)
305
+ return reject(node, "`#{node.name}` takes at most 1 argument") if args.length > 1
306
+ end
307
+
308
+ args.each_with_index do |arg, index|
309
+ check_value(arg, kind, depth: depth + 1, context: "`#{node.name}` argument #{index + 1}")
310
+ end
311
+ end
312
+
313
+ # @param node [Prism::CallNode]
314
+ # @param keywords [Prism::KeywordHashNode, nil]
315
+ # @param descriptor [nil, Hash] see {CallSpec#keywords}
316
+ # @param depth [Integer]
317
+ # @return [void]
318
+ def check_keywords(node, keywords, descriptor, depth:)
319
+ return if keywords.nil?
320
+ return reject(keywords, "`#{node.name}` takes no keyword arguments") if descriptor.nil?
321
+
322
+ keywords.elements.each do |element|
323
+ unless element.is_a?(Prism::AssocNode)
324
+ next reject(element, "`#{node.name}`: #{describe(element)} is not allowed in a keyword list")
325
+ end
326
+
327
+ name = literal_key(element.key)
328
+ next reject(element.key, "`#{node.name}`: keyword names must be plain symbols") if name.nil?
329
+
330
+ kind = descriptor[name] || descriptor[Vocabulary::ANY_OTHER]
331
+ next reject(element, "`#{node.name}` does not accept `#{name}:`") if kind.nil?
332
+
333
+ check_value(element.value, kind, depth: depth + 1, context: "`#{node.name}` #{name}:")
334
+ end
335
+ end
336
+
337
+ # @param node [Prism::Node, nil]
338
+ # @param kind [Symbol] :literal, :string, :symbol, :type_name or :rule
339
+ # @param depth [Integer]
340
+ # @param context [String] what this value belongs to, for the message
341
+ # @return [void]
342
+ def check_value(node, kind, depth:, context:)
343
+ return reject(node, too_deep_message) if too_deep?(depth)
344
+
345
+ case kind
346
+ when :literal then check_literal(node, depth: depth, context: context)
347
+ when :string then check_static(node, :static_string?, "a plain string", context: context)
348
+ when :symbol then check_static(node, :static_symbol?, "a plain symbol", context: context)
349
+ when :type_name then check_type_name(node, context: context)
350
+ when :rule then check_rule(node, depth: depth, context: context)
351
+ end
352
+ end
353
+
354
+ # @param node [Prism::Node, nil]
355
+ # @param predicate [Symbol] {#static_string?} or {#static_symbol?}
356
+ # @param label [String] how to describe the accepted shape to the author
357
+ # @param context [String]
358
+ # @return [void]
359
+ def check_static(node, predicate, label, context:)
360
+ return if send(predicate, node)
361
+
362
+ reject(node, "#{context} must be #{label}, found #{describe(node)}")
363
+ end
364
+
365
+ # One of the gem's own data types. Bound to {Node::TYPES} rather than
366
+ # accepting any symbol, so a typo is a validation error instead of a step
367
+ # that silently renders as free text.
368
+ #
369
+ # @param node [Prism::Node, nil]
370
+ # @param context [String]
371
+ # @return [void]
372
+ def check_type_name(node, context:)
373
+ return reject(node, "#{context} must be a plain symbol, found #{describe(node)}") unless static_name?(node)
374
+ return if Node::TYPES.include?(node.unescaped.to_sym)
375
+
376
+ reject(node, "#{context} must be one of #{Node::TYPES.join(", ")}, found #{node.unescaped}")
377
+ end
378
+
379
+ # A string with no embedded expressions.
380
+ #
381
+ # Prism represents a dedented heredoc — and adjacent literal
382
+ # concatenation — as an InterpolatedStringNode whose parts happen to be
383
+ # plain StringNodes. Those are static text, and multi-line `send_email`
384
+ # bodies rely on them, so they are accepted; the moment any part is an
385
+ # embedded expression (or a nested interpolated string) the node is
386
+ # rejected.
387
+ #
388
+ # @param node [Prism::Node, nil]
389
+ # @return [Boolean]
390
+ def static_string?(node)
391
+ case node
392
+ when Prism::StringNode then true
393
+ when Prism::InterpolatedStringNode then node.parts.all?(Prism::StringNode)
394
+ else false
395
+ end
396
+ end
397
+
398
+ # A symbol with no embedded expressions.
399
+ #
400
+ # @param node [Prism::Node, nil]
401
+ # @return [Boolean]
402
+ def static_symbol?(node)
403
+ case node
404
+ when Prism::SymbolNode then true
405
+ when Prism::InterpolatedSymbolNode then node.parts.all?(Prism::StringNode)
406
+ else false
407
+ end
408
+ end
409
+
410
+ # A single-part symbol or string, i.e. a node whose text is knowable
411
+ # without evaluating anything.
412
+ #
413
+ # @param node [Prism::Node, nil]
414
+ # @return [Boolean]
415
+ def static_name?(node)
416
+ node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
417
+ end
418
+
419
+ # Literals, and Arrays/Hashes built only out of literals.
420
+ #
421
+ # @param node [Prism::Node, nil]
422
+ # @param depth [Integer]
423
+ # @param context [String]
424
+ # @return [void]
425
+ def check_literal(node, depth:, context:)
426
+ return reject(node, too_deep_message) if too_deep?(depth)
427
+
428
+ case node
429
+ when Prism::IntegerNode, Prism::FloatNode, Prism::TrueNode, Prism::FalseNode, Prism::NilNode
430
+ nil
431
+ when Prism::ArrayNode
432
+ node.elements.each { |element| check_literal(element, depth: depth + 1, context: context) }
433
+ when Prism::HashNode, Prism::KeywordHashNode
434
+ check_hash_literal(node, depth: depth, context: context)
435
+ else
436
+ return if static_string?(node) || static_symbol?(node)
437
+
438
+ reject(node, "#{context} must be a literal value, found #{describe(node)}")
439
+ end
440
+ end
441
+
442
+ # @param node [Prism::HashNode, Prism::KeywordHashNode]
443
+ # @param depth [Integer]
444
+ # @param context [String]
445
+ # @return [void]
446
+ def check_hash_literal(node, depth:, context:)
447
+ node.elements.each do |element|
448
+ unless element.is_a?(Prism::AssocNode)
449
+ next reject(element, "#{context}: #{describe(element)} is not allowed in a Hash literal")
450
+ end
451
+
452
+ if literal_key(element.key).nil?
453
+ next reject(element.key, "#{context}: Hash keys must be plain symbols or strings")
454
+ end
455
+
456
+ check_literal(element.value, depth: depth + 1, context: context)
457
+ end
458
+ end
459
+
460
+ # @param node [Prism::Node, nil]
461
+ # @param depth [Integer]
462
+ # @param context [String]
463
+ # @return [void]
464
+ def check_rule(node, depth:, context:)
465
+ return reject(node, too_deep_message) if too_deep?(depth)
466
+
467
+ spec = plain_self_call?(node) ? Vocabulary.spec_for(:rule, node.name) : nil
468
+ if spec.nil?
469
+ return reject(node,
470
+ "#{context} must be one of #{Vocabulary.allowed_names(:rule).join(", ")}, found #{describe(node)}")
471
+ end
472
+ return reject(node.block, "rule `#{node.name}` takes no block") if node.block
473
+
474
+ check_arguments(node, spec, depth: depth)
475
+ end
476
+
477
+ # @param node [Prism::CallNode]
478
+ # @param spec [CallSpec]
479
+ # @param depth [Integer]
480
+ # @return [void]
481
+ def check_block_for(node, spec, depth:)
482
+ block = node.block
483
+
484
+ if spec.block == :forbidden
485
+ return if block.nil?
486
+
487
+ return reject(block,
488
+ "`#{node.name}` takes no block in safe mode — a Ruby block is arbitrary server-side " \
489
+ "code that cannot be validated#{UNSAFE_HINT}")
490
+ end
491
+
492
+ # `{ optional: scope }` — the call accepts the block but does not need
493
+ # it, because the same fields can be given as keywords.
494
+ scope = spec.block_scope
495
+
496
+ return if block.nil? && spec.block.is_a?(Hash)
497
+ return reject(node, "`#{node.name}` requires a block") if block.nil?
498
+ unless block.is_a?(Prism::BlockNode)
499
+ return reject(block, "`#{node.name}` requires a literal block, found #{describe(block)}")
500
+ end
501
+
502
+ check_block(block, scope, depth: depth + 1)
503
+ end
504
+
505
+ # @param depth [Integer]
506
+ # @return [Boolean]
507
+ def too_deep?(depth)
508
+ depth > @max_depth
509
+ end
510
+
511
+ # @return [String]
512
+ def too_deep_message
513
+ "the DSL nests deeper than #{@max_depth} levels"
514
+ end
515
+
516
+ # The Symbol a Hash/keyword key denotes, or nil when the key is not a
517
+ # plain (uninterpolated) symbol or string.
518
+ #
519
+ # @param node [Prism::Node]
520
+ # @return [Symbol, nil]
521
+ def literal_key(node)
522
+ node.unescaped.to_sym if static_name?(node)
523
+ end
524
+
525
+ # @param scope [Symbol]
526
+ # @return [String] human-readable scope name, article included
527
+ def scope_label(scope)
528
+ Vocabulary.label_for(scope)
529
+ end
530
+
531
+ # A short, author-facing description of a node the allowlist rejected.
532
+ #
533
+ # @param node [Prism::Node, nil]
534
+ # @return [String]
535
+ def describe(node)
536
+ return "nothing" if node.nil?
537
+ return describe_call(node) if node.is_a?(Prism::CallNode)
538
+
539
+ NODE_LABELS.fetch(node.class) { humanize(node.class) }
540
+ end
541
+
542
+ # @param node [Prism::CallNode]
543
+ # @return [String]
544
+ def describe_call(node)
545
+ return "`#{node.name}`" if node.receiver.nil?
546
+
547
+ "a method call `#{node.name}` on #{describe(node.receiver)}"
548
+ end
549
+
550
+ # @param klass [Class] a Prism node class
551
+ # @return [String] e.g. "a local variable write"
552
+ def humanize(klass)
553
+ words = klass.name.delete_prefix("Prism::").delete_suffix("Node")
554
+ .gsub(/([a-z\d])([A-Z])/, '\1 \2').downcase
555
+ "#{words.start_with?(/[aeiou]/) ? "an" : "a"} #{words}"
556
+ end
557
+ end
558
+ end
559
+ end